Merge branch 'main' into cw/agent-details-conflict-1772802863659

# Conflicts:
#	docs/server-api.md
This commit is contained in:
Lukas May
2026-03-06 14:15:30 +01:00
32 changed files with 956 additions and 273 deletions

View File

@@ -2,7 +2,7 @@
* File-Based Agent I/O Tests
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { describe, it, expect, beforeEach, afterEach, afterAll } from 'vitest';
import { mkdirSync, writeFileSync, readFileSync, rmSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
@@ -15,7 +15,9 @@ import {
readDecisionFiles,
readPageFiles,
generateId,
writeErrandManifest,
} from './file-io.js';
import { buildErrandPrompt } from './prompts/index.js';
import type { Initiative, Phase, Task } from '../db/schema.js';
let testDir: string;
@@ -367,3 +369,116 @@ New content for the page.
expect(pages).toHaveLength(1);
});
});
describe('writeErrandManifest', () => {
let errandTestDir: string;
beforeEach(() => {
errandTestDir = join(tmpdir(), `cw-errand-test-${randomUUID()}`);
mkdirSync(errandTestDir, { recursive: true });
});
afterAll(() => {
// no-op: beforeEach creates dirs, afterEach in outer scope cleans up
});
it('writes manifest.json with correct shape', async () => {
await writeErrandManifest({
agentWorkdir: errandTestDir,
errandId: 'errand-abc',
description: 'fix typo',
branch: 'cw/errand/fix-typo-errandabc',
projectName: 'my-project',
agentId: 'agent-1',
agentName: 'swift-owl',
});
const manifestPath = join(errandTestDir, '.cw', 'input', 'manifest.json');
expect(existsSync(manifestPath)).toBe(true);
const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8'));
expect(manifest).toEqual({
errandId: 'errand-abc',
agentId: 'agent-1',
agentName: 'swift-owl',
mode: 'errand',
});
expect('files' in manifest).toBe(false);
expect('contextFiles' in manifest).toBe(false);
});
it('writes errand.md with correct YAML frontmatter', async () => {
await writeErrandManifest({
agentWorkdir: errandTestDir,
errandId: 'errand-abc',
description: 'fix typo',
branch: 'cw/errand/fix-typo-errandabc',
projectName: 'my-project',
agentId: 'agent-1',
agentName: 'swift-owl',
});
const errandMdPath = join(errandTestDir, '.cw', 'input', 'errand.md');
expect(existsSync(errandMdPath)).toBe(true);
const content = readFileSync(errandMdPath, 'utf-8');
expect(content).toContain('id: errand-abc');
expect(content).toContain('description: fix typo');
expect(content).toContain('branch: cw/errand/fix-typo-errandabc');
expect(content).toContain('project: my-project');
});
it('writes expected-pwd.txt with agentWorkdir path', async () => {
await writeErrandManifest({
agentWorkdir: errandTestDir,
errandId: 'errand-abc',
description: 'fix typo',
branch: 'cw/errand/fix-typo-errandabc',
projectName: 'my-project',
agentId: 'agent-1',
agentName: 'swift-owl',
});
const pwdPath = join(errandTestDir, '.cw', 'expected-pwd.txt');
expect(existsSync(pwdPath)).toBe(true);
const content = readFileSync(pwdPath, 'utf-8').trim();
expect(content).toBe(errandTestDir);
});
it('creates input directory if it does not exist', async () => {
const freshDir = join(tmpdir(), `cw-errand-fresh-${randomUUID()}`);
mkdirSync(freshDir, { recursive: true });
await writeErrandManifest({
agentWorkdir: freshDir,
errandId: 'errand-xyz',
description: 'add feature',
branch: 'cw/errand/add-feature-errandxyz',
projectName: 'other-project',
agentId: 'agent-2',
agentName: 'brave-eagle',
});
expect(existsSync(join(freshDir, '.cw', 'input', 'manifest.json'))).toBe(true);
expect(existsSync(join(freshDir, '.cw', 'input', 'errand.md'))).toBe(true);
expect(existsSync(join(freshDir, '.cw', 'expected-pwd.txt'))).toBe(true);
rmSync(freshDir, { recursive: true, force: true });
});
});
describe('buildErrandPrompt', () => {
it('includes the description in the output', () => {
const result = buildErrandPrompt('fix typo in README');
expect(result).toContain('fix typo in README');
});
it('includes signal.json instruction', () => {
const result = buildErrandPrompt('some change');
expect(result).toContain('signal.json');
expect(result).toContain('"status": "done"');
});
it('includes error signal format', () => {
const result = buildErrandPrompt('some change');
expect(result).toContain('"status": "error"');
});
});

View File

@@ -298,6 +298,50 @@ export async function writeInputFiles(options: WriteInputFilesOptions): Promise<
);
}
// =============================================================================
// ERRAND INPUT FILE WRITING
// =============================================================================
export async function writeErrandManifest(options: {
agentWorkdir: string;
errandId: string;
description: string;
branch: string;
projectName: string;
agentId: string;
agentName: string;
}): Promise<void> {
await mkdir(join(options.agentWorkdir, '.cw', 'input'), { recursive: true });
// Write errand.md first (before manifest.json)
const errandMdContent = formatFrontmatter({
id: options.errandId,
description: options.description,
branch: options.branch,
project: options.projectName,
});
await writeFile(join(options.agentWorkdir, '.cw', 'input', 'errand.md'), errandMdContent, 'utf-8');
// Write manifest.json last (after all other files exist)
await writeFile(
join(options.agentWorkdir, '.cw', 'input', 'manifest.json'),
JSON.stringify({
errandId: options.errandId,
agentId: options.agentId,
agentName: options.agentName,
mode: 'errand',
}) + '\n',
'utf-8',
);
// Write expected-pwd.txt
await writeFile(
join(options.agentWorkdir, '.cw', 'expected-pwd.txt'),
options.agentWorkdir,
'utf-8',
);
}
// =============================================================================
// OUTPUT FILE READING
// =============================================================================

View File

@@ -0,0 +1,155 @@
/**
* AgentLifecycleController Tests — Regression coverage for event emissions.
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { AgentLifecycleController } from './controller.js';
import type { AgentRepository } from '../../db/repositories/agent-repository.js';
import type { AccountRepository } from '../../db/repositories/account-repository.js';
import type { SignalManager } from './signal-manager.js';
import type { RetryPolicy } from './retry-policy.js';
import type { AgentErrorAnalyzer } from './error-analyzer.js';
import type { ProcessManager } from '../process-manager.js';
import type { CleanupManager } from '../cleanup-manager.js';
import type { CleanupStrategy } from './cleanup-strategy.js';
import type { EventBus, AgentAccountSwitchedEvent } from '../../events/types.js';
function makeController(overrides: {
repository?: Partial<AgentRepository>;
accountRepository?: Partial<AccountRepository>;
eventBus?: EventBus;
}): AgentLifecycleController {
const signalManager: SignalManager = {
clearSignal: vi.fn(),
checkSignalExists: vi.fn(),
readSignal: vi.fn(),
waitForSignal: vi.fn(),
validateSignalFile: vi.fn(),
};
const retryPolicy: RetryPolicy = {
maxAttempts: 3,
backoffMs: [1000, 2000, 4000],
shouldRetry: vi.fn().mockReturnValue(false),
getRetryDelay: vi.fn().mockReturnValue(0),
};
const errorAnalyzer = { analyzeError: vi.fn() } as unknown as AgentErrorAnalyzer;
const processManager = { getAgentWorkdir: vi.fn() } as unknown as ProcessManager;
const cleanupManager = {} as unknown as CleanupManager;
const cleanupStrategy = {
shouldCleanup: vi.fn(),
executeCleanup: vi.fn(),
} as unknown as CleanupStrategy;
return new AgentLifecycleController(
signalManager,
retryPolicy,
errorAnalyzer,
processManager,
overrides.repository as AgentRepository,
cleanupManager,
cleanupStrategy,
overrides.accountRepository as AccountRepository | undefined,
false,
overrides.eventBus,
);
}
describe('AgentLifecycleController', () => {
describe('handleAccountExhaustion', () => {
it('emits agent:account_switched with correct payload when new account is available', async () => {
const emittedEvents: AgentAccountSwitchedEvent[] = [];
const eventBus: EventBus = {
emit: vi.fn((event) => { emittedEvents.push(event as AgentAccountSwitchedEvent); }),
on: vi.fn(),
off: vi.fn(),
once: vi.fn(),
};
const agentRecord = {
id: 'agent-1',
name: 'test-agent',
accountId: 'old-account-id',
provider: 'claude',
};
const newAccount = { id: 'new-account-id' };
const repository: Partial<AgentRepository> = {
findById: vi.fn().mockResolvedValue(agentRecord),
};
const accountRepository: Partial<AccountRepository> = {
markExhausted: vi.fn().mockResolvedValue(agentRecord),
findNextAvailable: vi.fn().mockResolvedValue(newAccount),
};
const controller = makeController({ repository, accountRepository, eventBus });
// Call private method via any-cast
await (controller as any).handleAccountExhaustion('agent-1');
const accountSwitchedEvents = emittedEvents.filter(
(e) => e.type === 'agent:account_switched'
);
expect(accountSwitchedEvents).toHaveLength(1);
const event = accountSwitchedEvents[0];
expect(event.type).toBe('agent:account_switched');
expect(event.payload.agentId).toBe('agent-1');
expect(event.payload.name).toBe('test-agent');
expect(event.payload.previousAccountId).toBe('old-account-id');
expect(event.payload.newAccountId).toBe('new-account-id');
expect(event.payload.reason).toBe('account_exhausted');
});
it('does not emit agent:account_switched when no new account is available', async () => {
const eventBus: EventBus = {
emit: vi.fn(),
on: vi.fn(),
off: vi.fn(),
once: vi.fn(),
};
const agentRecord = {
id: 'agent-2',
name: 'test-agent-2',
accountId: 'old-account-id',
provider: 'claude',
};
const repository: Partial<AgentRepository> = {
findById: vi.fn().mockResolvedValue(agentRecord),
};
const accountRepository: Partial<AccountRepository> = {
markExhausted: vi.fn().mockResolvedValue(agentRecord),
findNextAvailable: vi.fn().mockResolvedValue(null),
};
const controller = makeController({ repository, accountRepository, eventBus });
await (controller as any).handleAccountExhaustion('agent-2');
expect(eventBus.emit).not.toHaveBeenCalled();
});
it('does not emit when agent has no accountId', async () => {
const eventBus: EventBus = {
emit: vi.fn(),
on: vi.fn(),
off: vi.fn(),
once: vi.fn(),
};
const repository: Partial<AgentRepository> = {
findById: vi.fn().mockResolvedValue({ id: 'agent-3', name: 'x', accountId: null }),
};
const accountRepository: Partial<AccountRepository> = {
markExhausted: vi.fn(),
findNextAvailable: vi.fn(),
};
const controller = makeController({ repository, accountRepository, eventBus });
await (controller as any).handleAccountExhaustion('agent-3');
expect(eventBus.emit).not.toHaveBeenCalled();
});
});
});

View File

@@ -21,6 +21,7 @@ import type { RetryPolicy, AgentError } from './retry-policy.js';
import { AgentExhaustedError, AgentFailureError } from './retry-policy.js';
import type { AgentErrorAnalyzer } from './error-analyzer.js';
import type { CleanupStrategy, AgentInfo } from './cleanup-strategy.js';
import type { EventBus, AgentAccountSwitchedEvent } from '../../events/types.js';
const log = createModuleLogger('lifecycle-controller');
@@ -48,6 +49,7 @@ export class AgentLifecycleController {
private cleanupStrategy: CleanupStrategy,
private accountRepository?: AccountRepository,
private debug: boolean = false,
private eventBus?: EventBus,
) {}
/**
@@ -304,7 +306,7 @@ export class AgentLifecycleController {
}
/**
* Handle account exhaustion by marking account as exhausted.
* Handle account exhaustion by marking account as exhausted and emitting account_switched event.
*/
private async handleAccountExhaustion(agentId: string): Promise<void> {
if (!this.accountRepository) {
@@ -319,15 +321,34 @@ export class AgentLifecycleController {
return;
}
const previousAccountId = agent.accountId;
// Mark account as exhausted for 1 hour
const exhaustedUntil = new Date(Date.now() + 60 * 60 * 1000);
await this.accountRepository.markExhausted(agent.accountId, exhaustedUntil);
await this.accountRepository.markExhausted(previousAccountId, exhaustedUntil);
log.info({
agentId,
accountId: agent.accountId,
accountId: previousAccountId,
exhaustedUntil
}, 'marked account as exhausted due to usage limits');
// Find the next available account and emit account_switched event
const newAccount = await this.accountRepository.findNextAvailable(agent.provider ?? 'claude');
if (newAccount && this.eventBus) {
const event: AgentAccountSwitchedEvent = {
type: 'agent:account_switched',
timestamp: new Date(),
payload: {
agentId,
name: agent.name,
previousAccountId,
newAccountId: newAccount.id,
reason: 'account_exhausted',
},
};
this.eventBus.emit(event);
}
} catch (error) {
log.warn({
agentId,

View File

@@ -14,6 +14,7 @@ import type { AgentRepository } from '../../db/repositories/agent-repository.js'
import type { AccountRepository } from '../../db/repositories/account-repository.js';
import type { ProcessManager } from '../process-manager.js';
import type { CleanupManager } from '../cleanup-manager.js';
import type { EventBus } from '../../events/types.js';
export interface LifecycleFactoryOptions {
repository: AgentRepository;
@@ -21,6 +22,7 @@ export interface LifecycleFactoryOptions {
cleanupManager: CleanupManager;
accountRepository?: AccountRepository;
debug?: boolean;
eventBus?: EventBus;
}
/**
@@ -32,7 +34,8 @@ export function createLifecycleController(options: LifecycleFactoryOptions): Age
processManager,
cleanupManager,
accountRepository,
debug = false
debug = false,
eventBus,
} = options;
// Create core components
@@ -51,7 +54,8 @@ export function createLifecycleController(options: LifecycleFactoryOptions): Age
cleanupManager,
cleanupStrategy,
accountRepository,
debug
debug,
eventBus,
);
return lifecycleController;

View File

@@ -98,6 +98,7 @@ export class MultiProviderAgentManager implements AgentManager {
cleanupManager: this.cleanupManager,
accountRepository,
debug,
eventBus,
});
// Listen for process crashed events to handle agents specially
@@ -282,14 +283,15 @@ export class MultiProviderAgentManager implements AgentManager {
});
const agentId = agent.id;
// 3a. Append inter-agent communication instructions with actual agent ID
prompt = prompt + buildInterAgentCommunication(agentId, mode);
// 3a. Append inter-agent communication + preview instructions (skipped for focused agents)
if (!options.skipPromptExtras) {
prompt = prompt + buildInterAgentCommunication(agentId, mode);
// 3b. Append preview deployment instructions if applicable
if (['execute', 'refine', 'discuss'].includes(mode) && initiativeId) {
const shouldInject = await this.shouldInjectPreviewInstructions(initiativeId);
if (shouldInject) {
prompt = prompt + buildPreviewInstructions(agentId);
if (['execute', 'refine', 'discuss'].includes(mode) && initiativeId) {
const shouldInject = await this.shouldInjectPreviewInstructions(initiativeId);
if (shouldInject) {
prompt = prompt + buildPreviewInstructions(agentId);
}
}
}
@@ -607,6 +609,7 @@ export class MultiProviderAgentManager implements AgentManager {
this.activeAgents.set(agentId, activeEntry);
if (this.eventBus) {
// verified: payload matches AgentResumedEvent shape (agentId, name, taskId, sessionId)
const event: AgentResumedEvent = {
type: 'agent:resumed',
timestamp: new Date(),
@@ -629,6 +632,73 @@ export class MultiProviderAgentManager implements AgentManager {
}
}
/**
* Deliver a user message to a running or idle errand agent.
* Does not use the conversations table — the message is injected directly
* as the next resume prompt for the agent's Claude Code session.
*/
async sendUserMessage(agentId: string, message: string): Promise<void> {
const agent = await this.repository.findById(agentId);
if (!agent) throw new Error(`Agent not found: ${agentId}`);
if (agent.status !== 'running' && agent.status !== 'idle') {
throw new Error(`Agent is not running (status: ${agent.status})`);
}
if (!agent.sessionId) {
throw new Error('Agent has no session ID');
}
const provider = getProvider(agent.provider);
if (!provider) throw new Error(`Unknown provider: ${agent.provider}`);
const agentCwd = this.processManager.getAgentWorkdir(agent.worktreeId);
// Clear previous signal.json
const signalPath = join(agentCwd, '.cw/output/signal.json');
try {
await unlink(signalPath);
} catch {
// File might not exist
}
await this.repository.update(agentId, { status: 'running', result: null });
const { command, args, env: providerEnv } = this.processManager.buildResumeCommand(provider, agent.sessionId, message);
const { processEnv } = await this.credentialHandler.prepareProcessEnv(providerEnv, provider, agent.accountId);
// Stop previous tailer/poll
const prevActive = this.activeAgents.get(agentId);
prevActive?.cancelPoll?.();
if (prevActive?.tailer) {
await prevActive.tailer.stop();
}
let sessionNumber = 1;
if (this.logChunkRepository) {
sessionNumber = (await this.logChunkRepository.getSessionCount(agentId)) + 1;
}
const { pid, outputFilePath, tailer } = await this.processManager.spawnDetached(
agentId, agent.name, command, args, agentCwd, processEnv, provider.name, message,
(event) => this.outputHandler.handleStreamEvent(agentId, event, this.activeAgents.get(agentId)),
this.createLogChunkCallback(agentId, agent.name, sessionNumber),
);
await this.repository.update(agentId, { pid, outputFilePath });
const activeEntry: ActiveAgent = { agentId, pid, tailer, outputFilePath };
this.activeAgents.set(agentId, activeEntry);
const { cancel } = this.processManager.pollForCompletion(
agentId, pid,
() => this.handleDetachedAgentCompletion(agentId),
() => this.activeAgents.get(agentId)?.tailer,
);
activeEntry.cancelPoll = cancel;
log.info({ agentId, pid }, 'resumed errand agent for user message');
}
/**
* Sync credentials from agent's config dir back to DB after completion.
* The subprocess may have refreshed tokens mid-session; this ensures
@@ -796,6 +866,7 @@ export class MultiProviderAgentManager implements AgentManager {
log.info({ agentId, pid }, 'resume detached subprocess started');
if (this.eventBus) {
// verified: payload matches AgentResumedEvent shape (agentId, name, taskId, sessionId)
const event: AgentResumedEvent = {
type: 'agent:resumed',
timestamp: new Date(),

View File

@@ -5,9 +5,7 @@
import {
SIGNAL_FORMAT,
SESSION_STARTUP,
GIT_WORKFLOW,
CONTEXT_MANAGEMENT,
} from './shared.js';
export function buildConflictResolutionPrompt(
@@ -29,7 +27,12 @@ You are a Conflict Resolution agent. Your job is to merge \`${targetBranch}\` in
${conflictList}
</conflict_details>
${SIGNAL_FORMAT}
${SESSION_STARTUP}
<session_startup>
1. \`pwd\` — confirm working directory
2. \`git status\` — check branch state
3. Read \`CLAUDE.md\` at the repo root (if it exists) — it contains project conventions you must follow.
</session_startup>
<resolution_protocol>
Follow these steps in order:
@@ -57,7 +60,6 @@ Follow these steps in order:
8. **Signal done**: Write signal.json with status "done".
</resolution_protocol>
${GIT_WORKFLOW}
${CONTEXT_MANAGEMENT}
<important>
- You are on a temporary branch created from ${sourceBranch}. You are merging ${targetBranch} INTO this branch — bringing it up to date, NOT the other way around.

View File

@@ -0,0 +1,16 @@
export function buildErrandPrompt(description: string): string {
return `You are working on a small, focused change in an isolated worktree.
Description: ${description}
Work interactively with the user. Make only the changes needed to fulfill the description.
When you are done, write .cw/output/signal.json:
{ "status": "done", "result": { "message": "<one-sentence summary of what you changed>" } }
If you cannot complete the change:
{ "status": "error", "error": "<explanation>" }
Do not create any other output files.`;
}

View File

@@ -13,6 +13,7 @@ export { buildDetailPrompt } from './detail.js';
export { buildRefinePrompt } from './refine.js';
export { buildChatPrompt } from './chat.js';
export type { ChatHistoryEntry } from './chat.js';
export { buildErrandPrompt } from './errand.js';
export { buildWorkspaceLayout } from './workspace.js';
export { buildPreviewInstructions } from './preview.js';
export { buildConflictResolutionPrompt, buildConflictResolutionDescription } from './conflict-resolution.js';

View File

@@ -81,6 +81,15 @@ Each phase must pass: **"Could a detail agent break this into tasks without clar
</examples>
</specificity>
<subagent_usage>
Use subagents to parallelize your analysis — don't do everything sequentially:
- **Domain decomposition**: Spawn separate subagents to investigate different aspects of the initiative (e.g., one for database/schema concerns, one for API surface, one for frontend components) and synthesize their findings into your phase plan.
- **Dependency mapping**: Spawn a subagent to map existing code dependencies and file ownership while you analyze initiative requirements, so you can make informed decisions about phase boundaries and parallelism.
- **Pattern discovery**: When the initiative touches multiple subsystems, spawn subagents to search for existing patterns in each subsystem simultaneously rather than exploring them one at a time.
Don't spawn subagents for trivial initiatives with obvious structure — use judgment.
</subagent_usage>
<existing_context>
- Account for existing phases/tasks — don't plan work already covered
- Always generate new phase IDs — never reuse existing ones

View File

@@ -33,6 +33,15 @@ Ignore style, grammar, formatting unless they cause genuine ambiguity. Rough but
If all pages are already clear, signal done with no output files.
</improvement_priorities>
<subagent_usage>
Use subagents to parallelize your work:
- **Parallel page analysis**: Spawn one subagent per page (or group of related pages) to analyze clarity issues simultaneously rather than reviewing pages sequentially.
- **Codebase verification**: When checking whether a requirement is feasible or matches existing patterns, spawn a subagent to search the codebase while you continue reviewing other pages.
- **Cross-reference validation**: Spawn a subagent to verify that all [[page:$id|title]] cross-references are valid and consistent across pages.
Don't over-split — if there are only 1-2 short pages, just do the work directly.
</subagent_usage>
<rules>
- Ask 2-4 questions if you need clarification
- Preserve [[page:\$id|title]] cross-references

View File

@@ -61,6 +61,8 @@ export interface SpawnAgentOptions {
branchName?: string;
/** Context data to write as input files in agent workdir */
inputContext?: AgentInputContext;
/** Skip inter-agent communication and preview instructions (for focused agents like conflict resolution) */
skipPromptExtras?: boolean;
}
/**

View File

@@ -202,6 +202,17 @@ export function agentProcedures(publicProcedure: ProcedureBuilder) {
return candidates[0] ?? null;
}),
getTaskAgent: publicProcedure
.input(z.object({ taskId: z.string().min(1) }))
.query(async ({ ctx, input }): Promise<AgentInfo | null> => {
const agentManager = requireAgentManager(ctx);
const all = await agentManager.list();
const matches = all
.filter(a => a.taskId === input.taskId)
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
return matches[0] ?? null;
}),
getActiveConflictAgent: publicProcedure
.input(z.object({ initiativeId: z.string().min(1) }))
.query(async ({ ctx, input }): Promise<AgentInfo | null> => {
@@ -225,12 +236,15 @@ export function agentProcedures(publicProcedure: ProcedureBuilder) {
getAgentOutput: publicProcedure
.input(agentIdentifierSchema)
.query(async ({ ctx, input }): Promise<string> => {
.query(async ({ ctx, input }) => {
const agent = await resolveAgent(ctx, input);
const logChunkRepo = requireLogChunkRepository(ctx);
const chunks = await logChunkRepo.findByAgentId(agent.id);
return chunks.map(c => c.content).join('');
return chunks.map(c => ({
content: c.content,
createdAt: c.createdAt.toISOString(),
}));
}),
onAgentOutput: publicProcedure

View File

@@ -9,6 +9,7 @@ export interface ActiveArchitectAgent {
initiativeId: string;
mode: string;
status: string;
name?: string;
}
const MODE_TO_STATE: Record<string, InitiativeActivityState> = {
@@ -30,6 +31,18 @@ export function deriveInitiativeActivity(
if (initiative.status === 'archived') {
return { ...base, state: 'archived' };
}
// Check for active conflict resolution agent — takes priority over pending_review
// because the agent is actively working to resolve merge conflicts
const conflictAgent = activeArchitectAgents?.find(
a => a.initiativeId === initiative.id
&& a.name?.startsWith('conflict-')
&& (a.status === 'running' || a.status === 'waiting_for_input'),
);
if (conflictAgent) {
return { ...base, state: 'resolving_conflict' };
}
if (initiative.status === 'pending_review') {
return { ...base, state: 'pending_review' };
}
@@ -41,6 +54,7 @@ export function deriveInitiativeActivity(
// so architect agents (discuss/plan/detail/refine) surface activity
const activeAgent = activeArchitectAgents?.find(
a => a.initiativeId === initiative.id
&& !a.name?.startsWith('conflict-')
&& (a.status === 'running' || a.status === 'waiting_for_input'),
);
if (activeAgent) {

View File

@@ -129,27 +129,42 @@ export function initiativeProcedures(publicProcedure: ProcedureBuilder) {
: await repo.findAll();
}
// Fetch active architect agents once for all initiatives
// Fetch active agents once for all initiatives (architect + conflict)
const ARCHITECT_MODES = ['discuss', 'plan', 'detail', 'refine'];
const allAgents = ctx.agentManager ? await ctx.agentManager.list() : [];
const activeArchitectAgents = allAgents
.filter(a =>
ARCHITECT_MODES.includes(a.mode ?? '')
(ARCHITECT_MODES.includes(a.mode ?? '') || a.name?.startsWith('conflict-'))
&& (a.status === 'running' || a.status === 'waiting_for_input')
&& !a.userDismissedAt,
)
.map(a => ({ initiativeId: a.initiativeId ?? '', mode: a.mode ?? '', status: a.status }));
.map(a => ({ initiativeId: a.initiativeId ?? '', mode: a.mode ?? '', status: a.status, name: a.name }));
// Batch-fetch projects for all initiatives
const projectRepo = ctx.projectRepository;
const projectsByInitiativeId = new Map<string, Array<{ id: string; name: string }>>();
if (projectRepo) {
await Promise.all(initiatives.map(async (init) => {
const projects = await projectRepo.findProjectsByInitiativeId(init.id);
projectsByInitiativeId.set(init.id, projects.map(p => ({ id: p.id, name: p.name })));
}));
}
const addProjects = (init: typeof initiatives[0]) => ({
projects: projectsByInitiativeId.get(init.id) ?? [],
});
if (ctx.phaseRepository) {
const phaseRepo = ctx.phaseRepository;
return Promise.all(initiatives.map(async (init) => {
const phases = await phaseRepo.findByInitiativeId(init.id);
return { ...init, activity: deriveInitiativeActivity(init, phases, activeArchitectAgents) };
return { ...init, ...addProjects(init), activity: deriveInitiativeActivity(init, phases, activeArchitectAgents) };
}));
}
return initiatives.map(init => ({
...init,
...addProjects(init),
activity: deriveInitiativeActivity(init, [], activeArchitectAgents),
}));
}),
@@ -473,6 +488,7 @@ export function initiativeProcedures(publicProcedure: ProcedureBuilder) {
initiativeId: input.initiativeId,
baseBranch: initiative.branch,
branchName: tempBranch,
skipPromptExtras: true,
});
}),
};

View File

@@ -70,6 +70,7 @@ export const ALL_EVENT_TYPES: DomainEventType[] = [
'chat:session_closed',
'initiative:pending_review',
'initiative:review_approved',
'initiative:changes_requested',
];
/**
@@ -102,6 +103,7 @@ export const TASK_EVENT_TYPES: DomainEventType[] = [
'phase:merged',
'initiative:pending_review',
'initiative:review_approved',
'initiative:changes_requested',
];
/**

View File

@@ -6,6 +6,7 @@ import { trpc } from "@/lib/trpc";
import { useSubscriptionWithErrorHandling } from "@/hooks";
import {
type ParsedMessage,
type TimestampedChunk,
getMessageStyling,
parseAgentOutput,
} from "@/lib/parse-agent-output";
@@ -21,8 +22,8 @@ export function AgentOutputViewer({ agentId, agentName, status, onStop }: AgentO
const [messages, setMessages] = useState<ParsedMessage[]>([]);
const [follow, setFollow] = useState(true);
const containerRef = useRef<HTMLDivElement>(null);
// Accumulate raw JSONL: initial query data + live subscription chunks
const rawBufferRef = useRef<string>('');
// Accumulate timestamped chunks: initial query data + live subscription chunks
const chunksRef = useRef<TimestampedChunk[]>([]);
// Load initial/historical output
const outputQuery = trpc.getAgentOutput.useQuery(
@@ -40,8 +41,8 @@ export function AgentOutputViewer({ agentId, agentName, status, onStop }: AgentO
// TrackedEnvelope shape: { id, data: { agentId, data: string } }
const raw = event?.data?.data ?? event?.data;
const chunk = typeof raw === 'string' ? raw : JSON.stringify(raw);
rawBufferRef.current += chunk;
setMessages(parseAgentOutput(rawBufferRef.current));
chunksRef.current = [...chunksRef.current, { content: chunk, createdAt: new Date().toISOString() }];
setMessages(parseAgentOutput(chunksRef.current));
},
onError: (error) => {
console.error('Agent output subscription error:', error);
@@ -54,14 +55,14 @@ export function AgentOutputViewer({ agentId, agentName, status, onStop }: AgentO
// Set initial output when query loads
useEffect(() => {
if (outputQuery.data) {
rawBufferRef.current = outputQuery.data;
chunksRef.current = outputQuery.data;
setMessages(parseAgentOutput(outputQuery.data));
}
}, [outputQuery.data]);
// Reset output when agent changes
useEffect(() => {
rawBufferRef.current = '';
chunksRef.current = [];
setMessages([]);
setFollow(true);
}, [agentId]);
@@ -160,57 +161,64 @@ export function AgentOutputViewer({ agentId, agentName, status, onStop }: AgentO
<div
ref={containerRef}
onScroll={handleScroll}
className="flex-1 overflow-y-auto bg-terminal p-4"
className="flex-1 overflow-y-auto overflow-x-hidden bg-terminal p-4"
>
{isLoading ? (
<div className="text-terminal-muted text-sm">Loading output...</div>
) : !hasOutput ? (
<div className="text-terminal-muted text-sm">No output yet...</div>
) : (
<div className="space-y-2">
<div className="space-y-2 min-w-0">
{messages.map((message, index) => (
<div key={index} className={getMessageStyling(message.type)}>
{message.type === 'system' && (
<div className="flex items-center gap-2">
<Badge variant="secondary" className="text-xs bg-terminal-border text-terminal-system">System</Badge>
<span className="text-xs text-terminal-muted">{message.content}</span>
<Timestamp date={message.timestamp} />
</div>
)}
{message.type === 'text' && (
<div className="font-mono text-sm whitespace-pre-wrap text-terminal-fg">
{message.content}
</div>
<>
<Timestamp date={message.timestamp} />
<div className="font-mono text-sm whitespace-pre-wrap break-words text-terminal-fg">
{message.content}
</div>
</>
)}
{message.type === 'tool_call' && (
<div className="border-l-2 border-terminal-tool pl-3 py-1">
<Badge variant="default" className="mb-1 text-xs">
{message.meta?.toolName}
</Badge>
<div className="font-mono text-xs text-terminal-muted whitespace-pre-wrap">
<div className="border-l-2 border-terminal-tool pl-3 py-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<Badge variant="default" className="text-xs">
{message.meta?.toolName}
</Badge>
<Timestamp date={message.timestamp} />
</div>
<div className="font-mono text-xs text-terminal-muted whitespace-pre-wrap break-words">
{message.content}
</div>
</div>
)}
{message.type === 'tool_result' && (
<div className="border-l-2 border-terminal-result pl-3 py-1 bg-white/[0.02]">
<div className="border-l-2 border-terminal-result pl-3 py-1 bg-white/[0.02] min-w-0">
<Badge variant="outline" className="mb-1 text-xs text-terminal-result border-terminal-result">
Result
</Badge>
<div className="font-mono text-xs text-terminal-muted whitespace-pre-wrap">
<div className="font-mono text-xs text-terminal-muted whitespace-pre-wrap break-words">
{message.content}
</div>
</div>
)}
{message.type === 'error' && (
<div className="border-l-2 border-terminal-error pl-3 py-1 bg-terminal-error/10">
<div className="border-l-2 border-terminal-error pl-3 py-1 bg-terminal-error/10 min-w-0">
<Badge variant="destructive" className="mb-1 text-xs">
Error
</Badge>
<div className="font-mono text-xs text-terminal-error whitespace-pre-wrap">
<div className="font-mono text-xs text-terminal-error whitespace-pre-wrap break-words">
{message.content}
</div>
</div>
@@ -228,6 +236,7 @@ export function AgentOutputViewer({ agentId, agentName, status, onStop }: AgentO
{message.meta?.duration && (
<span className="text-xs text-terminal-muted">{(message.meta.duration / 1000).toFixed(1)}s</span>
)}
<Timestamp date={message.timestamp} />
</div>
</div>
)}
@@ -239,3 +248,16 @@ export function AgentOutputViewer({ agentId, agentName, status, onStop }: AgentO
</div>
);
}
function formatTime(date: Date): string {
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false });
}
function Timestamp({ date }: { date?: Date }) {
if (!date) return null;
return (
<span className="shrink-0 text-[10px] text-terminal-muted/60 font-mono tabular-nums">
{formatTime(date)}
</span>
);
}

View File

@@ -1,6 +1,7 @@
import { MoreHorizontal } from "lucide-react";
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
DropdownMenu,
DropdownMenuContent,
@@ -20,6 +21,7 @@ export interface SerializedInitiative {
branch: string | null;
createdAt: string;
updatedAt: string;
projects?: Array<{ id: string; name: string }>;
activity: {
state: string;
activePhase?: { id: string; name: string };
@@ -30,11 +32,12 @@ export interface SerializedInitiative {
function activityVisual(state: string): { label: string; variant: StatusVariant; pulse: boolean } {
switch (state) {
case "executing": return { label: "Executing", variant: "active", pulse: true };
case "pending_review": return { label: "Pending Review", variant: "warning", pulse: true };
case "discussing": return { label: "Discussing", variant: "active", pulse: true };
case "detailing": return { label: "Detailing", variant: "active", pulse: true };
case "refining": return { label: "Refining", variant: "active", pulse: true };
case "executing": return { label: "Executing", variant: "active", pulse: true };
case "pending_review": return { label: "Pending Review", variant: "warning", pulse: true };
case "discussing": return { label: "Discussing", variant: "active", pulse: true };
case "detailing": return { label: "Detailing", variant: "active", pulse: true };
case "refining": return { label: "Refining", variant: "active", pulse: true };
case "resolving_conflict": return { label: "Resolving Conflict", variant: "urgent", pulse: true };
case "ready": return { label: "Ready", variant: "active", pulse: false };
case "blocked": return { label: "Blocked", variant: "error", pulse: false };
case "complete": return { label: "Complete", variant: "success", pulse: false };
@@ -87,11 +90,19 @@ export function InitiativeCard({ initiative, onClick }: InitiativeCardProps) {
className="p-4"
onClick={onClick}
>
{/* Row 1: Name + overflow menu */}
<div className="flex items-center justify-between">
<span className="min-w-0 truncate text-base font-bold">
{initiative.name}
</span>
{/* Row 1: Name + project pills + overflow menu */}
<div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<span className="shrink-0 text-base font-bold">
{initiative.name}
</span>
{initiative.projects && initiative.projects.length > 0 &&
initiative.projects.map((p) => (
<Badge key={p.id} variant="outline" size="xs" className="shrink-0 font-normal">
{p.name}
</Badge>
))}
</div>
<div onClick={(e) => e.stopPropagation()}>
<DropdownMenu>
<DropdownMenuTrigger asChild>

View File

@@ -45,6 +45,10 @@ export function mapEntityStatus(rawStatus: string): StatusVariant {
case "medium":
return "warning";
// Urgent / conflict resolution
case "resolving_conflict":
return "urgent";
// Error / failed
case "crashed":
case "blocked":

View File

@@ -253,13 +253,13 @@ export function ContentTab({ initiativeId, initiativeName }: ContentTabProps) {
{resolvedActivePageId && (
<>
{(isSaving || updateInitiativeMutation.isPending) && (
<div className="flex justify-end mb-2">
<div className="flex justify-end mb-2 h-4">
{(isSaving || updateInitiativeMutation.isPending) && (
<span className="text-xs text-muted-foreground">
Saving...
</span>
</div>
)}
)}
</div>
{activePageQuery.isSuccess && (
<input
value={pageTitle}

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef, useCallback } from "react";
import { useEffect, useRef, useCallback, useMemo } from "react";
import { useEditor, EditorContent } from "@tiptap/react";
import type { Editor } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
@@ -36,33 +36,33 @@ export function TiptapEditor({
const onPageLinkDeletedRef = useRef(onPageLinkDeleted);
onPageLinkDeletedRef.current = onPageLinkDeleted;
const pageLinkDeletionDetector = createPageLinkDeletionDetector(onPageLinkDeletedRef);
const baseExtensions = [
StarterKit,
Table.configure({ resizable: true, cellMinWidth: 50 }),
TableRow,
TableCell,
TableHeader,
Placeholder.configure({
includeChildren: true,
placeholder: ({ node }) => {
if (node.type.name === 'heading') {
return `Heading ${node.attrs.level}`;
}
return "Type '/' for commands...";
},
}),
Link.configure({
openOnClick: false,
}),
SlashCommands,
BlockSelectionExtension,
];
const extensions = enablePageLinks
? [...baseExtensions, PageLinkExtension, pageLinkDeletionDetector]
: baseExtensions;
const extensions = useMemo(() => {
const detector = createPageLinkDeletionDetector(onPageLinkDeletedRef);
const base = [
StarterKit,
Table.configure({ resizable: true, cellMinWidth: 50 }),
TableRow,
TableCell,
TableHeader,
Placeholder.configure({
includeChildren: true,
placeholder: ({ node }) => {
if (node.type.name === 'heading') {
return `Heading ${node.attrs.level}`;
}
return "Type '/' for commands...";
},
}),
Link.configure({
openOnClick: false,
}),
SlashCommands,
BlockSelectionExtension,
];
return enablePageLinks
? [...base, PageLinkExtension, detector]
: base;
}, [enablePageLinks]);
const editor = useEditor(
{

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useMemo } from "react";
import { useCallback, useEffect, useRef, useMemo, useState } from "react";
import { motion, AnimatePresence } from "motion/react";
import { X, Trash2, MessageCircle, RotateCw } from "lucide-react";
import type { ChatTarget } from "@/components/chat/ChatSlideOver";
@@ -7,12 +7,15 @@ import { Button } from "@/components/ui/button";
import { StatusBadge } from "@/components/StatusBadge";
import { StatusDot } from "@/components/StatusDot";
import { TiptapEditor } from "@/components/editor/TiptapEditor";
import { AgentOutputViewer } from "@/components/AgentOutputViewer";
import { getCategoryConfig } from "@/lib/category";
import { markdownToTiptapJson } from "@/lib/markdown-to-tiptap";
import { useExecutionContext } from "./ExecutionContext";
import { trpc } from "@/lib/trpc";
import { cn } from "@/lib/utils";
type SlideOverTab = "details" | "logs";
interface TaskSlideOverProps {
onOpenChat?: (target: ChatTarget) => void;
}
@@ -24,8 +27,15 @@ export function TaskSlideOver({ onOpenChat }: TaskSlideOverProps) {
const deleteTaskMutation = trpc.deleteTask.useMutation();
const updateTaskMutation = trpc.updateTask.useMutation();
const [tab, setTab] = useState<SlideOverTab>("details");
const close = useCallback(() => setSelectedTaskId(null), [setSelectedTaskId]);
// Reset tab when task changes
useEffect(() => {
setTab("details");
}, [selectedEntry?.task?.id]);
// Escape key closes
useEffect(() => {
if (!selectedEntry) return;
@@ -152,80 +162,107 @@ export function TaskSlideOver({ onOpenChat }: TaskSlideOverProps) {
</button>
</div>
{/* Tab bar */}
<div className="flex gap-4 border-b border-border px-5">
{(["details", "logs"] as const).map((t) => (
<button
key={t}
className={cn(
"relative pb-2 pt-3 text-sm font-medium transition-colors",
tab === t
? "text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
onClick={() => setTab(t)}
>
{t === "details" ? "Details" : "Agent Logs"}
{tab === t && (
<span className="absolute inset-x-0 bottom-0 h-0.5 bg-primary" />
)}
</button>
))}
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-5">
{/* Metadata grid */}
<div className="grid grid-cols-2 gap-3 text-sm">
<MetaField label="Status">
<StatusBadge status={task.status} />
</MetaField>
<MetaField label="Category">
<CategoryBadge category={task.category} />
</MetaField>
<MetaField label="Priority">
<PriorityText priority={task.priority} />
</MetaField>
<MetaField label="Type">
<span className="font-medium">{task.type}</span>
</MetaField>
<MetaField label="Agent" span={2}>
<span className="font-medium">
{selectedEntry.agentName ?? "Unassigned"}
</span>
</MetaField>
</div>
<div className={cn("flex-1 min-h-0", tab === "details" ? "overflow-y-auto" : "flex flex-col")}>
{tab === "details" ? (
<div className="px-5 py-4 space-y-5">
{/* Metadata grid */}
<div className="grid grid-cols-2 gap-3 text-sm">
<MetaField label="Status">
<StatusBadge status={task.status} />
</MetaField>
<MetaField label="Category">
<CategoryBadge category={task.category} />
</MetaField>
<MetaField label="Priority">
<PriorityText priority={task.priority} />
</MetaField>
<MetaField label="Type">
<span className="font-medium">{task.type}</span>
</MetaField>
<MetaField label="Agent" span={2}>
<span className="font-medium">
{selectedEntry.agentName ?? "Unassigned"}
</span>
</MetaField>
</div>
{/* Description — editable tiptap */}
<Section title="Description">
<TiptapEditor
entityId={task.id}
content={editorContent}
onUpdate={handleDescriptionUpdate}
enablePageLinks={false}
/>
</Section>
{/* Description — editable tiptap */}
<Section title="Description">
<TiptapEditor
entityId={task.id}
content={editorContent}
onUpdate={handleDescriptionUpdate}
enablePageLinks={false}
/>
</Section>
{/* Dependencies */}
<Section title="Blocked By">
{dependencies.length === 0 ? (
<p className="text-sm text-muted-foreground">None</p>
) : (
<ul className="space-y-1.5">
{dependencies.map((dep) => (
<li
key={dep.name}
className="flex items-center gap-2 text-sm"
>
<StatusDot status={dep.status} size="sm" />
<span className="min-w-0 flex-1 truncate">
{dep.name}
</span>
</li>
))}
</ul>
)}
</Section>
{/* Dependencies */}
<Section title="Blocked By">
{dependencies.length === 0 ? (
<p className="text-sm text-muted-foreground">None</p>
) : (
<ul className="space-y-1.5">
{dependencies.map((dep) => (
<li
key={dep.name}
className="flex items-center gap-2 text-sm"
>
<StatusDot status={dep.status} size="sm" />
<span className="min-w-0 flex-1 truncate">
{dep.name}
</span>
</li>
))}
</ul>
)}
</Section>
{/* Blocks */}
<Section title="Blocks">
{dependents.length === 0 ? (
<p className="text-sm text-muted-foreground">None</p>
) : (
<ul className="space-y-1.5">
{dependents.map((dep) => (
<li
key={dep.name}
className="flex items-center gap-2 text-sm"
>
<StatusDot status={dep.status} size="sm" />
<span className="min-w-0 flex-1 truncate">
{dep.name}
</span>
</li>
))}
</ul>
)}
</Section>
{/* Blocks */}
<Section title="Blocks">
{dependents.length === 0 ? (
<p className="text-sm text-muted-foreground">None</p>
) : (
<ul className="space-y-1.5">
{dependents.map((dep) => (
<li
key={dep.name}
className="flex items-center gap-2 text-sm"
>
<StatusDot status={dep.status} size="sm" />
<span className="min-w-0 flex-1 truncate">
{dep.name}
</span>
</li>
))}
</ul>
)}
</Section>
</div>
) : (
<AgentLogsTab taskId={task.id} />
)}
</div>
{/* Footer */}
@@ -293,6 +330,43 @@ export function TaskSlideOver({ onOpenChat }: TaskSlideOverProps) {
);
}
// ---------------------------------------------------------------------------
// Agent Logs Tab
// ---------------------------------------------------------------------------
function AgentLogsTab({ taskId }: { taskId: string }) {
const { data: agent, isLoading } = trpc.getTaskAgent.useQuery(
{ taskId },
{ refetchOnWindowFocus: false },
);
if (isLoading) {
return (
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
Loading...
</div>
);
}
if (!agent) {
return (
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
No agent has been assigned to this task yet.
</div>
);
}
return (
<div className="flex-1 min-h-0">
<AgentOutputViewer
agentId={agent.id}
agentName={agent.name ?? undefined}
status={agent.status}
/>
</div>
);
}
// ---------------------------------------------------------------------------
// Small helpers
// ---------------------------------------------------------------------------

View File

@@ -7,7 +7,8 @@
export { useAutoSave } from './useAutoSave.js';
export { useDebounce, useDebounceWithImmediate } from './useDebounce.js';
export { useLiveUpdates } from './useLiveUpdates.js';
export { useLiveUpdates, INITIATIVE_LIST_RULES } from './useLiveUpdates.js';
export type { LiveUpdateRule } from './useLiveUpdates.js';
export { useRefineAgent } from './useRefineAgent.js';
export { useConflictAgent } from './useConflictAgent.js';
export { useSubscriptionWithErrorHandling } from './useSubscriptionWithErrorHandling.js';

View File

@@ -15,6 +15,18 @@ export interface LiveUpdateRule {
*
* Encapsulates error toast + reconnect config so pages don't duplicate boilerplate.
*/
/**
* Reusable rules for any page displaying initiative cards.
* Covers all event prefixes that can change derived initiative activity state.
*/
export const INITIATIVE_LIST_RULES: LiveUpdateRule[] = [
{ prefix: 'initiative:', invalidate: ['listInitiatives'] },
{ prefix: 'task:', invalidate: ['listInitiatives'] },
{ prefix: 'phase:', invalidate: ['listInitiatives'] },
{ prefix: 'agent:', invalidate: ['listInitiatives'] },
{ prefix: 'merge:', invalidate: ['listInitiatives'] },
];
export function useLiveUpdates(rules: LiveUpdateRule[]) {
const utils = trpc.useUtils();

View File

@@ -44,17 +44,21 @@ const INVALIDATION_MAP: Partial<Record<MutationName, QueryName[]>> = {
spawnArchitectDiscuss: ["listAgents"],
spawnArchitectPlan: ["listAgents"],
spawnArchitectDetail: ["listAgents", "listInitiativeTasks"],
spawnConflictResolutionAgent: ["listAgents", "listInitiatives", "getInitiative"],
// --- Initiatives ---
createInitiative: ["listInitiatives"],
updateInitiative: ["listInitiatives", "getInitiative"],
updateInitiativeProjects: ["getInitiative"],
approveInitiativeReview: ["listInitiatives", "getInitiative"],
requestInitiativeChanges: ["listInitiatives", "getInitiative"],
// --- Phases ---
createPhase: ["listPhases", "listInitiativePhaseDependencies"],
deletePhase: ["listPhases", "listInitiativeTasks", "listInitiativePhaseDependencies", "listChangeSets"],
updatePhase: ["listPhases", "getPhase"],
approvePhase: ["listPhases", "listInitiativeTasks"],
approvePhase: ["listPhases", "listInitiativeTasks", "listInitiatives"],
requestPhaseChanges: ["listPhases", "listInitiativeTasks", "listPhaseTasks", "getInitiative"],
queuePhase: ["listPhases"],
createPhaseDependency: ["getPhaseDependencies", "listInitiativePhaseDependencies", "listPhaseTaskDependencies"],
removePhaseDependency: ["getPhaseDependencies", "listInitiativePhaseDependencies", "listPhaseTaskDependencies"],
@@ -71,7 +75,11 @@ const INVALIDATION_MAP: Partial<Record<MutationName, QueryName[]>> = {
revertChangeSet: ["listPhases", "listPhaseTasks", "listInitiativeTasks", "listPages", "getPage", "listChangeSets", "getRootPage", "getChangeSet"],
// --- Pages ---
updatePage: ["listPages", "getPage", "getRootPage"],
// NOTE: getPage omitted — useAutoSave handles optimistic updates for the
// active page, and SSE `page:updated` events cover external changes.
// Including getPage here caused double-invalidation (mutation + SSE) and
// refetch storms that flickered the editor.
updatePage: ["listPages", "getRootPage"],
createPage: ["listPages", "getRootPage"],
deletePage: ["listPages", "getRootPage"],

View File

@@ -7,6 +7,7 @@ export interface ParsedMessage {
| "session_end"
| "error";
content: string;
timestamp?: Date;
meta?: {
toolName?: string;
isError?: boolean;
@@ -60,108 +61,135 @@ export function getMessageStyling(type: ParsedMessage["type"]): string {
}
}
export function parseAgentOutput(raw: string): ParsedMessage[] {
const lines = raw.split("\n").filter(Boolean);
/**
* A chunk of raw JSONL content with an optional timestamp from the DB.
*/
export interface TimestampedChunk {
content: string;
createdAt: string;
}
/**
* Parse agent output. Accepts either a flat string (legacy) or timestamped chunks.
* When chunks have timestamps, each parsed message inherits the chunk's timestamp.
*/
export function parseAgentOutput(raw: string | TimestampedChunk[]): ParsedMessage[] {
const chunks: { content: string; timestamp?: Date }[] =
typeof raw === "string"
? [{ content: raw }]
: raw.map((c) => ({ content: c.content, timestamp: new Date(c.createdAt) }));
const parsedMessages: ParsedMessage[] = [];
for (const line of lines) {
try {
const event = JSON.parse(line);
for (const chunk of chunks) {
const lines = chunk.content.split("\n").filter(Boolean);
for (const line of lines) {
try {
const event = JSON.parse(line);
// System initialization
if (event.type === "system" && event.session_id) {
parsedMessages.push({
type: "system",
content: `Session started: ${event.session_id}`,
});
}
// Assistant messages with text and tool calls
else if (
event.type === "assistant" &&
Array.isArray(event.message?.content)
) {
for (const block of event.message.content) {
if (block.type === "text" && block.text) {
parsedMessages.push({
type: "text",
content: block.text,
});
} else if (block.type === "tool_use") {
parsedMessages.push({
type: "tool_call",
content: formatToolCall(block),
meta: { toolName: block.name },
});
}
// System initialization
if (event.type === "system" && event.session_id) {
parsedMessages.push({
type: "system",
content: `Session started: ${event.session_id}`,
timestamp: chunk.timestamp,
});
}
}
// User messages with tool results
else if (
event.type === "user" &&
Array.isArray(event.message?.content)
) {
for (const block of event.message.content) {
if (block.type === "tool_result") {
const rawContent = block.content;
const output =
typeof rawContent === "string"
? rawContent
: Array.isArray(rawContent)
? rawContent
.map((c: any) => c.text ?? JSON.stringify(c))
.join("\n")
: (event.tool_use_result?.stdout || "");
const stderr = event.tool_use_result?.stderr;
if (stderr) {
// Assistant messages with text and tool calls
else if (
event.type === "assistant" &&
Array.isArray(event.message?.content)
) {
for (const block of event.message.content) {
if (block.type === "text" && block.text) {
parsedMessages.push({
type: "error",
content: stderr,
meta: { isError: true },
type: "text",
content: block.text,
timestamp: chunk.timestamp,
});
} else if (output) {
const displayOutput =
output.length > 1000
? output.substring(0, 1000) + "\n... (truncated)"
: output;
} else if (block.type === "tool_use") {
parsedMessages.push({
type: "tool_result",
content: displayOutput,
type: "tool_call",
content: formatToolCall(block),
timestamp: chunk.timestamp,
meta: { toolName: block.name },
});
}
}
}
}
// Legacy streaming format
else if (event.type === "stream_event" && event.event?.delta?.text) {
// User messages with tool results
else if (
event.type === "user" &&
Array.isArray(event.message?.content)
) {
for (const block of event.message.content) {
if (block.type === "tool_result") {
const rawContent = block.content;
const output =
typeof rawContent === "string"
? rawContent
: Array.isArray(rawContent)
? rawContent
.map((c: any) => c.text ?? JSON.stringify(c))
.join("\n")
: (event.tool_use_result?.stdout || "");
const stderr = event.tool_use_result?.stderr;
if (stderr) {
parsedMessages.push({
type: "error",
content: stderr,
timestamp: chunk.timestamp,
meta: { isError: true },
});
} else if (output) {
const displayOutput =
output.length > 1000
? output.substring(0, 1000) + "\n... (truncated)"
: output;
parsedMessages.push({
type: "tool_result",
content: displayOutput,
timestamp: chunk.timestamp,
});
}
}
}
}
// Legacy streaming format
else if (event.type === "stream_event" && event.event?.delta?.text) {
parsedMessages.push({
type: "text",
content: event.event.delta.text,
timestamp: chunk.timestamp,
});
}
// Session completion
else if (event.type === "result") {
parsedMessages.push({
type: "session_end",
content: event.is_error ? "Session failed" : "Session completed",
timestamp: chunk.timestamp,
meta: {
isError: event.is_error,
cost: event.total_cost_usd,
duration: event.duration_ms,
},
});
}
} catch {
// Not JSON, display as-is
parsedMessages.push({
type: "text",
content: event.event.delta.text,
type: "error",
content: line,
timestamp: chunk.timestamp,
meta: { isError: true },
});
}
// Session completion
else if (event.type === "result") {
parsedMessages.push({
type: "session_end",
content: event.is_error ? "Session failed" : "Session completed",
meta: {
isError: event.is_error,
cost: event.total_cost_usd,
duration: event.duration_ms,
},
});
}
} catch {
// Not JSON, display as-is
parsedMessages.push({
type: "error",
content: line,
meta: { isError: true },
});
}
}
return parsedMessages;

View File

@@ -1,3 +1,4 @@
import { useMemo } from "react";
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { motion } from "motion/react";
import { AlertCircle } from "lucide-react";
@@ -11,6 +12,7 @@ import { ExecutionTab } from "@/components/ExecutionTab";
import { ReviewTab } from "@/components/review";
import { PipelineTab } from "@/components/pipeline";
import { useLiveUpdates } from "@/hooks";
import type { LiveUpdateRule } from "@/hooks";
type Tab = "content" | "plan" | "execution" | "review";
const TABS: Tab[] = ["content", "plan", "execution", "review"];
@@ -27,15 +29,17 @@ function InitiativeDetailPage() {
const { tab: activeTab } = Route.useSearch();
const navigate = useNavigate();
// Single SSE stream for all live updates
useLiveUpdates([
// Single SSE stream for all live updates — memoized to avoid re-subscribe on render
const liveUpdateRules = useMemo<LiveUpdateRule[]>(() => [
{ prefix: 'initiative:', invalidate: ['getInitiative'] },
{ prefix: 'task:', invalidate: ['listPhases', 'listTasks', 'listInitiativeTasks', 'getPhaseDependencies', 'listPhaseTaskDependencies'] },
{ prefix: 'phase:', invalidate: ['listPhases', 'listTasks', 'listInitiativePhaseDependencies', 'getPhaseDependencies'] },
{ prefix: 'agent:', invalidate: ['listAgents', 'getActiveRefineAgent'] },
{ prefix: 'agent:', invalidate: ['listAgents', 'getActiveRefineAgent', 'getTaskAgent', 'getActiveConflictAgent'] },
{ prefix: 'page:', invalidate: ['listPages', 'getPage', 'getRootPage'] },
{ prefix: 'changeset:', invalidate: ['getChangeSet', 'listChangeSets'] },
{ prefix: 'preview:', invalidate: ['listPreviews', 'getPreviewStatus'] },
]);
], []);
useLiveUpdates(liveUpdateRules);
// tRPC queries
const initiativeQuery = trpc.getInitiative.useQuery({ id });

View File

@@ -5,7 +5,7 @@ import { Plus } from "lucide-react";
import { Button } from "@/components/ui/button";
import { InitiativeList } from "@/components/InitiativeList";
import { CreateInitiativeDialog } from "@/components/CreateInitiativeDialog";
import { useLiveUpdates } from "@/hooks";
import { useLiveUpdates, INITIATIVE_LIST_RULES } from "@/hooks";
import { trpc } from "@/lib/trpc";
export const Route = createFileRoute("/initiatives/")({
@@ -29,10 +29,7 @@ function DashboardPage() {
const projectsQuery = trpc.listProjects.useQuery();
// Single SSE stream for live updates
useLiveUpdates([
{ prefix: 'task:', invalidate: ['listInitiatives'] },
{ prefix: 'phase:', invalidate: ['listInitiatives'] },
]);
useLiveUpdates(INITIATIVE_LIST_RULES);
return (
<motion.div

View File

@@ -11,7 +11,7 @@
| `process-manager.ts` | `AgentProcessManager` — worktree creation, command building, detached spawn |
| `output-handler.ts` | `OutputHandler` — JSONL stream parsing, completion detection, proposal creation, task dedup, task dependency persistence |
| `file-tailer.ts` | `FileTailer` — watches output files, fires parser + raw content callbacks |
| `file-io.ts` | Input/output file I/O: frontmatter writing, signal.json reading, tiptap conversion. Output files support `action` field (create/update/delete) for chat mode CRUD. |
| `file-io.ts` | Input/output file I/O: frontmatter writing, signal.json reading, tiptap conversion. Output files support `action` field (create/update/delete) for chat mode CRUD. Includes `writeErrandManifest()` for errand agent input files. |
| `markdown-to-tiptap.ts` | Markdown to Tiptap JSON conversion using MarkdownManager |
| `index.ts` | Public exports, `ClaudeAgentManager` deprecated alias |
@@ -24,14 +24,14 @@
| `accounts/` | Account discovery, config dir setup, credential management, usage API |
| `credentials/` | `AccountCredentialManager` — credential injection per account |
| `lifecycle/` | `LifecycleController` — retry policy, signal recovery, missing signal instructions |
| `prompts/` | Mode-specific prompt builders (execute, discuss, plan, detail, refine, chat, conflict-resolution) + shared blocks (test integrity, deviation rules, git workflow, session startup, progress tracking) + inter-agent communication instructions |
| `prompts/` | Mode-specific prompt builders (execute, discuss, plan, detail, refine, chat, conflict-resolution, errand) + shared blocks (test integrity, deviation rules, git workflow, session startup, progress tracking) + inter-agent communication instructions. Conflict-resolution uses a minimal inline startup (pwd, git status, CLAUDE.md) instead of the full `SESSION_STARTUP`/`CONTEXT_MANAGEMENT` blocks. |
## Key Flows
### Spawning an Agent
1. **tRPC procedure** calls `agentManager.spawn(options)`
2. Manager generates alias (adjective-animal), creates DB record
2. Manager generates alias (adjective-animal), creates DB record. Appends inter-agent communication and preview instructions unless `skipPromptExtras: true` (used by conflict-resolution agents to keep prompts lean).
3. `AgentProcessManager.createWorktree()` — creates git worktree at `.cw-worktrees/agent/<alias>/`
4. `file-io.writeInputFiles()` — writes `.cw/input/` with assignment files (initiative, pages, phase, task) and read-only context dirs (`context/phases/`, `context/tasks/`)
5. Provider config builds spawn command via `buildSpawnCommand()`
@@ -115,6 +115,30 @@ cw account add --token <token> --email user@example.com
Stored as `credentials: {"claudeAiOauth":{"accessToken":"<token>"}}` and `configJson: {"hasCompletedOnboarding":true}`.
## Errand Agent Support
### `sendUserMessage(agentId, message)`
Delivers a user message directly to a running or idle errand agent without going through the conversations table. Used by the `errand.sendMessage` tRPC procedure.
**Steps**: look up agent → validate status (`running`|`idle`) → validate `sessionId` → clear signal.json → update status to `running` → build resume command → stop active tailer/poll → spawn detached → start polling.
**Key difference from `resumeForConversation`**: no `conversationResumeLocks`, no conversations table entry, raw message passed as resume prompt.
### `writeErrandManifest(options)`
Writes errand input files to `<agentWorkdir>/.cw/input/`:
- `errand.md` — YAML frontmatter with `id`, `description`, `branch`, `project`
- `manifest.json``{ errandId, agentId, agentName, mode: "errand" }` (no `files`/`contextFiles` arrays)
- `expected-pwd.txt` — the agent workdir path
Written in order: `errand.md` first, `manifest.json` last (same discipline as `writeInputFiles`).
### `buildErrandPrompt(description)`
Builds the initial prompt for errand agents. Exported from `prompts/errand.ts` and re-exported from `prompts/index.ts`. The prompt instructs the agent to make only the changes needed for the description and write `signal.json` when done.
## Auto-Resume for Conversations
When Agent A asks Agent B a question via `cw ask` and Agent B is idle, the conversation router automatically resumes Agent B's session. This mirrors the `resumeForCommit()` pattern.
@@ -153,9 +177,10 @@ Agent output is persisted to `agent_log_chunks` table and drives all live stream
- DB insert → `agent:output` event emission (single source of truth for UI)
- No FK to agents — survives agent deletion
- Session tracking: spawn=1, resume=previousMax+1
- Read path (`getAgentOutput` tRPC): concatenates all DB chunks (no file fallback)
- Live path (`onAgentOutput` subscription): listens for `agent:output` events
- Frontend: initial query loads from DB, subscription accumulates raw JSONL, both parsed via `parseAgentOutput()`
- Read path (`getAgentOutput` tRPC): returns timestamped chunks `{ content, createdAt }[]` from DB
- Live path (`onAgentOutput` subscription): listens for `agent:output` events (client stamps with `Date.now()`)
- Frontend: initial query loads timestamped chunks, subscription accumulates live chunks, both parsed via `parseAgentOutput()` which accepts `TimestampedChunk[]`
- Timestamps displayed inline (HH:MM:SS) on text, tool_call, system, and session_end messages
## Inter-Agent Communication

View File

@@ -200,4 +200,4 @@ Components: `ChatSlideOver`, `ChatBubble`, `ChatInput`, `ChangeSetInline` in `sr
`listInitiatives` returns an `activity` field on each initiative, computed server-side from phase statuses via `deriveInitiativeActivity()` in `apps/server/trpc/routers/initiative-activity.ts`. This eliminates per-card N+1 `listPhases` queries.
Activity states (priority order): active architect agents > `pending_review` > `executing` > `blocked` > `complete` > `ready` > `planning` > `idle` > `archived`. Each state maps to a `StatusVariant` + pulse animation in `InitiativeCard`'s `activityVisual()` function. Active architect agents (modes: discuss, plan, detail, refine) are checked first — mapping to `discussing`, `detailing`, `detailing`, `refining` states respectively — so auto-spawned agents surface activity even when no phases exist yet. `PhaseSidebarItem` also shows a spinner when a detail agent is active for its phase.
Activity states (priority order): conflict agent > `archived` > active architect agents > `pending_review` > `executing` > `blocked` > `complete` > `ready` > `planning` > `idle`. Each state maps to a `StatusVariant` + pulse animation in `InitiativeCard`'s `activityVisual()` function. Active conflict agents (name starts with `conflict-`) are checked first — returning `resolving_conflict` (urgent variant, pulsing). Active architect agents (modes: discuss, plan, detail, refine) are checked next — mapping to `discussing`, `detailing`, `detailing`, `refining` states respectively — so auto-spawned agents surface activity even when no phases exist yet. `PhaseSidebarItem` also shows a spinner when a detail agent is active for its phase.

View File

@@ -62,7 +62,8 @@ Each procedure uses `require*Repository(ctx)` helpers that throw `TRPCError(INTE
| getAgent | query | Single agent by name or ID; also returns `taskName`, `initiativeName`, `exitCode` |
| getAgentResult | query | Execution result |
| getAgentQuestions | query | Pending questions |
| getAgentOutput | query | Full output from DB log chunks |
| getAgentOutput | query | Timestamped log chunks from DB (`{ content, createdAt }[]`) |
| getTaskAgent | query | Most recent agent assigned to a task (by taskId) |
| getAgentInputFiles | query | Files written to agent's `.cw/input/` dir (text only, sorted, 500 KB cap) |
| getAgentPrompt | query | Assembled prompt — reads from DB (`agents.prompt`) first; falls back to `.cw/agent-logs/<name>/PROMPT.md` for pre-persistence agents (1 MB cap) |
| getActiveRefineAgent | query | Active refine agent for initiative |

View File

@@ -4,17 +4,18 @@ export type { PendingQuestions, QuestionItem } from '../../../apps/server/agent/
export type ExecutionMode = 'yolo' | 'review_per_phase';
export type InitiativeActivityState =
| 'idle' // Active but no phases and no agents
| 'discussing' // Discuss agent actively scoping the initiative
| 'planning' // All phases pending (no work started)
| 'detailing' // Detail/plan agent actively decomposing phases into tasks
| 'refining' // Refine agent actively working on content
| 'ready' // Phases approved, waiting to execute
| 'executing' // At least one phase in_progress
| 'pending_review' // At least one phase pending_review
| 'blocked' // At least one phase blocked (none in_progress/pending_review)
| 'complete' // All phases completed
| 'archived'; // Initiative archived
| 'idle' // Active but no phases and no agents
| 'discussing' // Discuss agent actively scoping the initiative
| 'planning' // All phases pending (no work started)
| 'detailing' // Detail/plan agent actively decomposing phases into tasks
| 'refining' // Refine agent actively working on content
| 'resolving_conflict' // Conflict resolution agent actively fixing merge conflicts
| 'ready' // Phases approved, waiting to execute
| 'executing' // At least one phase in_progress
| 'pending_review' // At least one phase pending_review
| 'blocked' // At least one phase blocked (none in_progress/pending_review)
| 'complete' // All phases completed
| 'archived'; // Initiative archived
export interface InitiativeActivity {
state: InitiativeActivityState;