@orchestrator-claude/cli 3.25.2 → 3.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/dist/index.d.ts +1 -1
  2. package/dist/index.js +1 -1
  3. package/dist/templates/base/claude/agents/debug-sidecar.md +133 -0
  4. package/dist/templates/base/claude/agents/doc-sidecar.md +60 -0
  5. package/dist/templates/base/claude/agents/implementer.md +162 -31
  6. package/dist/templates/base/claude/agents/test-sidecar.md +106 -0
  7. package/dist/templates/base/claude/hooks/mailbox-listener.ts +246 -0
  8. package/dist/templates/base/claude/hooks/sprint-registry.ts +85 -0
  9. package/dist/templates/base/claude/settings.json +19 -0
  10. package/dist/templates/base/claude/skills/sprint-launch/SKILL.md +176 -0
  11. package/dist/templates/base/claude/skills/sprint-teammate/sprint-teammate.md +79 -0
  12. package/dist/templates/base/scripts/lib/SprintLauncher.ts +325 -0
  13. package/dist/templates/base/scripts/lib/TmuxManager.ts +296 -0
  14. package/dist/templates/base/scripts/lib/WorktreeIsolator.ts +165 -0
  15. package/dist/templates/base/scripts/lib/WorktreeManager.ts +106 -0
  16. package/dist/templates/base/scripts/lib/mailbox/types.ts +175 -0
  17. package/dist/templates/base/scripts/lib/sidecar/SidecarWatcher.ts +249 -0
  18. package/dist/templates/base/scripts/lib/sidecar/run.ts +90 -0
  19. package/dist/templates/base/scripts/sprint-launch.ts +285 -0
  20. package/package.json +1 -1
  21. package/templates/base/claude/agents/debug-sidecar.md +133 -0
  22. package/templates/base/claude/agents/doc-sidecar.md +60 -0
  23. package/templates/base/claude/agents/implementer.md +162 -31
  24. package/templates/base/claude/agents/test-sidecar.md +106 -0
  25. package/templates/base/claude/hooks/mailbox-listener.ts +246 -0
  26. package/templates/base/claude/hooks/sprint-registry.ts +85 -0
  27. package/templates/base/claude/settings.json +19 -0
  28. package/templates/base/claude/skills/sprint-launch/SKILL.md +176 -0
  29. package/templates/base/claude/skills/sprint-teammate/sprint-teammate.md +79 -0
  30. package/templates/base/scripts/lib/SprintLauncher.ts +325 -0
  31. package/templates/base/scripts/lib/TmuxManager.ts +296 -0
  32. package/templates/base/scripts/lib/WorktreeIsolator.ts +165 -0
  33. package/templates/base/scripts/lib/WorktreeManager.ts +106 -0
  34. package/templates/base/scripts/lib/mailbox/types.ts +175 -0
  35. package/templates/base/scripts/lib/sidecar/SidecarWatcher.ts +249 -0
  36. package/templates/base/scripts/lib/sidecar/run.ts +90 -0
  37. package/templates/base/scripts/sprint-launch.ts +285 -0
@@ -0,0 +1,165 @@
1
+ /**
2
+ * WorktreeIsolator.ts — Isolates worktree Claude environments (RFC-025)
3
+ *
4
+ * Responsibilities:
5
+ * 1. generateClaudeMd(role, worktreePath): Reads .claude/agents/<role>.md and
6
+ * writes a role-specific CLAUDE.md to the worktree with a header wrapper.
7
+ * 2. generateSettingsJson(role, worktreePath): Strips orchestrator hooks, overrides
8
+ * agent field, preserves all other settings, writes to worktree .claude/settings.json.
9
+ * 3. isolateWorktree(role, worktreePath): Convenience method calling both above.
10
+ *
11
+ * DI via factory function pattern (same pattern as gate-guardian.ts in ADR-017 Phase 4).
12
+ */
13
+
14
+ import * as fs from 'node:fs';
15
+ import * as path from 'node:path';
16
+
17
+ // ─────────────────────────────────────────────────────────────
18
+ // Types
19
+ // ─────────────────────────────────────────────────────────────
20
+
21
+ export interface WorktreeIsolatorDeps {
22
+ readFileSync: typeof fs.readFileSync;
23
+ writeFileSync: typeof fs.writeFileSync;
24
+ mkdirSync: typeof fs.mkdirSync;
25
+ existsSync: typeof fs.existsSync;
26
+ }
27
+
28
+ export interface SprintContext {
29
+ sessionName: string;
30
+ workflowId: string;
31
+ mailboxRole: string;
32
+ task?: string;
33
+ }
34
+
35
+ export interface WorktreeIsolator {
36
+ generateClaudeMd(role: string, worktreePath: string, sprintContext?: SprintContext): void;
37
+ generateSettingsJson(role: string, worktreePath: string, sprintContext?: SprintContext): void;
38
+ isolateWorktree(role: string, worktreePath: string, sprintContext?: SprintContext): void;
39
+ }
40
+
41
+ // Shape of .claude/settings.json (only fields we care about)
42
+ interface ClaudeSettings {
43
+ env?: Record<string, string>;
44
+ permissions?: unknown;
45
+ hooks?: unknown;
46
+ enableAllProjectMcpServers?: boolean;
47
+ enabledPlugins?: unknown;
48
+ alwaysThinkingEnabled?: boolean;
49
+ agent?: string;
50
+ [key: string]: unknown;
51
+ }
52
+
53
+ // ─────────────────────────────────────────────────────────────
54
+ // Factory
55
+ // ─────────────────────────────────────────────────────────────
56
+
57
+ const defaultDeps: WorktreeIsolatorDeps = {
58
+ readFileSync: fs.readFileSync,
59
+ writeFileSync: fs.writeFileSync,
60
+ mkdirSync: fs.mkdirSync,
61
+ existsSync: fs.existsSync,
62
+ };
63
+
64
+ export function createWorktreeIsolator(
65
+ projectRoot: string,
66
+ deps: Partial<WorktreeIsolatorDeps> = {},
67
+ ): WorktreeIsolator {
68
+ const { readFileSync, writeFileSync, mkdirSync, existsSync } = {
69
+ ...defaultDeps,
70
+ ...deps,
71
+ };
72
+
73
+ function generateClaudeMd(role: string, worktreePath: string, sprintContext?: SprintContext): void {
74
+ const capitalised = role.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join('-');
75
+ const lines: string[] = [`# ${capitalised} Agent`];
76
+
77
+ if (sprintContext) {
78
+ const taskHint = sprintContext.task
79
+ ? `Task hint: ${sprintContext.task} (full task via mailbox)`
80
+ : 'Check mailbox for task assignments';
81
+ lines.push(
82
+ `Sprint: ${sprintContext.sessionName} | Mailbox: ${sprintContext.mailboxRole}`,
83
+ taskHint,
84
+ );
85
+ }
86
+
87
+ const output = lines.join('\n') + '\n';
88
+ writeFileSync(path.join(worktreePath, 'CLAUDE.md'), output, 'utf-8');
89
+ }
90
+
91
+ function generateSettingsJson(
92
+ role: string,
93
+ worktreePath: string,
94
+ sprintContext?: SprintContext,
95
+ ): void {
96
+ const settingsPath = path.join(projectRoot, '.claude', 'settings.json');
97
+ const raw = readFileSync(settingsPath, 'utf-8') as string;
98
+ const original: ClaudeSettings = JSON.parse(raw);
99
+
100
+ // Build hooks: inject sprint-specific hooks when sprint context is available
101
+ const hooks: Record<string, unknown> = sprintContext
102
+ ? {
103
+ SessionStart: [
104
+ {
105
+ matcher: '',
106
+ hooks: [
107
+ { type: 'command', command: 'npx tsx .claude/hooks/sprint-registry.ts' },
108
+ {
109
+ type: 'command',
110
+ command: 'npx tsx .claude/hooks/mailbox-listener.ts',
111
+ timeout: 3000,
112
+ },
113
+ ],
114
+ },
115
+ ],
116
+ PostToolUse: [
117
+ {
118
+ matcher: '',
119
+ hooks: [
120
+ {
121
+ type: 'command',
122
+ command: 'npx tsx .claude/hooks/mailbox-listener.ts',
123
+ timeout: 3000,
124
+ },
125
+ ],
126
+ },
127
+ ],
128
+ }
129
+ : {};
130
+
131
+ // Merge sprint env vars into existing env when sprint context is present
132
+ const env = sprintContext
133
+ ? {
134
+ ...(original.env ?? {}),
135
+ SPRINT_ID: sprintContext.sessionName,
136
+ PANE_ROLE: sprintContext.mailboxRole,
137
+ }
138
+ : original.env;
139
+
140
+ const isolated: ClaudeSettings = {
141
+ ...original,
142
+ env,
143
+ hooks,
144
+ agent: role,
145
+ };
146
+
147
+ const claudeDir = path.join(worktreePath, '.claude');
148
+ if (!existsSync(claudeDir)) {
149
+ mkdirSync(claudeDir, { recursive: true });
150
+ }
151
+
152
+ writeFileSync(
153
+ path.join(claudeDir, 'settings.json'),
154
+ JSON.stringify(isolated, null, 2),
155
+ 'utf-8',
156
+ );
157
+ }
158
+
159
+ function isolateWorktree(role: string, worktreePath: string, sprintContext?: SprintContext): void {
160
+ generateClaudeMd(role, worktreePath, sprintContext);
161
+ generateSettingsJson(role, worktreePath, sprintContext);
162
+ }
163
+
164
+ return { generateClaudeMd, generateSettingsJson, isolateWorktree };
165
+ }
@@ -0,0 +1,106 @@
1
+ import * as childProcess from 'child_process';
2
+ import path from 'path';
3
+
4
+ export interface WorktreeManagerDeps {
5
+ execSync: typeof childProcess.execSync;
6
+ }
7
+
8
+ interface WorktreeEntry {
9
+ worktreePath: string;
10
+ branchName: string;
11
+ }
12
+
13
+ export interface WorktreeManager {
14
+ prune(): void;
15
+ getCurrentBranch(): string;
16
+ addWorktree(index: number, sessionName: string): string;
17
+ removeWorktree(worktreePath: string): void;
18
+ removeAll(sessionName: string): void;
19
+ getCreatedWorktrees(): string[];
20
+ }
21
+
22
+ export function createWorktreeManager(
23
+ projectRoot: string,
24
+ deps?: Partial<WorktreeManagerDeps>,
25
+ ): WorktreeManager {
26
+ const exec = deps?.execSync ?? childProcess.execSync;
27
+ const opts = { encoding: 'utf-8' as const };
28
+ const tracked: WorktreeEntry[] = [];
29
+
30
+ function prune(): void {
31
+ exec(`git -C "${projectRoot}" worktree prune`, opts);
32
+ }
33
+
34
+ function getCurrentBranch(): string {
35
+ const result = exec(`git -C "${projectRoot}" rev-parse --abbrev-ref HEAD`, opts);
36
+ return (result as string).trim();
37
+ }
38
+
39
+ function addWorktree(index: number, sessionName: string): string {
40
+ const worktreePath = path.resolve(projectRoot, '..', `wt-${sessionName}-${index}`);
41
+ const branchName = `${sessionName}-impl-${index}`;
42
+
43
+ exec(`git -C "${projectRoot}" worktree add -b "${branchName}" "${worktreePath}" HEAD`, opts);
44
+ tracked.push({ worktreePath, branchName });
45
+
46
+ return worktreePath;
47
+ }
48
+
49
+ function removeWorktree(worktreePath: string): void {
50
+ try {
51
+ exec(`git -C "${projectRoot}" worktree remove --force "${worktreePath}"`, opts);
52
+ } catch {
53
+ // Worktree may not exist — not an error
54
+ }
55
+
56
+ const entryIndex = tracked.findIndex((e) => e.worktreePath === worktreePath);
57
+ if (entryIndex !== -1) {
58
+ const { branchName } = tracked[entryIndex];
59
+ tracked.splice(entryIndex, 1);
60
+ try {
61
+ exec(`git -C "${projectRoot}" branch -d "${branchName}"`, opts);
62
+ } catch {
63
+ // Branch delete failure is non-fatal (e.g. unmerged changes) — warn but do not crash
64
+ console.warn(
65
+ `[WorktreeManager] Could not delete branch "${branchName}" — it may not have been fully merged.`,
66
+ );
67
+ }
68
+ }
69
+ }
70
+
71
+ function removeAll(_sessionName: string): void {
72
+ // Snapshot so iteration is stable even if tracked mutates
73
+ const entries = [...tracked];
74
+
75
+ for (const { worktreePath } of entries) {
76
+ try {
77
+ exec(`git -C "${projectRoot}" worktree remove --force "${worktreePath}"`, opts);
78
+ } catch {
79
+ // Continue removing remaining worktrees on partial failure
80
+ }
81
+ }
82
+
83
+ for (const { branchName } of entries) {
84
+ try {
85
+ exec(`git -C "${projectRoot}" branch -D "${branchName}"`, opts);
86
+ } catch {
87
+ // Branch may already be deleted — not an error
88
+ }
89
+ }
90
+
91
+ tracked.splice(0);
92
+ }
93
+
94
+ function getCreatedWorktrees(): string[] {
95
+ return tracked.map((e) => e.worktreePath);
96
+ }
97
+
98
+ return {
99
+ prune,
100
+ getCurrentBranch,
101
+ addWorktree,
102
+ removeWorktree,
103
+ removeAll,
104
+ getCreatedWorktrees,
105
+ };
106
+ }
@@ -0,0 +1,175 @@
1
+ /**
2
+ * types.ts — Mailbox protocol types and Zod schemas (RFC-025 M1)
3
+ *
4
+ * Tasks:
5
+ * 1.1 — MessageEnvelope + Zod schema
6
+ * 1.2 — MailboxAddress / Role value types
7
+ * 1.3 — Body variant types + Zod sub-schemas
8
+ */
9
+
10
+ import { z } from 'zod';
11
+
12
+ // ─────────────────────────────────────────────────────────────
13
+ // Task 1.2 — Role value type
14
+ // ─────────────────────────────────────────────────────────────
15
+
16
+ /**
17
+ * Role identifies the agent type in the mailbox system.
18
+ * Each role may have multiple instances (differentiated by index in MailboxAddress).
19
+ */
20
+ export const RoleSchema = z.enum([
21
+ 'orchestrator',
22
+ 'implementer',
23
+ 'planner',
24
+ 'reviewer',
25
+ 'debug-sidecar',
26
+ 'test-sidecar',
27
+ 'doc-sidecar',
28
+ ]);
29
+ export type Role = z.infer<typeof RoleSchema>;
30
+
31
+ // ─────────────────────────────────────────────────────────────
32
+ // Task 1.2 — MailboxAddress value type
33
+ // ─────────────────────────────────────────────────────────────
34
+
35
+ /**
36
+ * MailboxAddress uniquely identifies an agent mailbox.
37
+ * Multiple instances of the same role are distinguished by index.
38
+ *
39
+ * @example { role: 'implementer', index: 0 } → "implementer-0"
40
+ */
41
+ export const MailboxAddressSchema = z.object({
42
+ role: RoleSchema,
43
+ index: z.number().int().nonnegative(),
44
+ });
45
+ export type MailboxAddress = z.infer<typeof MailboxAddressSchema>;
46
+
47
+ /**
48
+ * Converts a MailboxAddress to its canonical directory-safe string form.
49
+ * Example: { role: 'implementer', index: 0 } → "implementer-0"
50
+ */
51
+ export function addressToString(address: MailboxAddress): string {
52
+ return `${address.role}-${address.index}`;
53
+ }
54
+
55
+ // ─────────────────────────────────────────────────────────────
56
+ // Task 1.3 — Body variant schemas
57
+ // ─────────────────────────────────────────────────────────────
58
+
59
+ /**
60
+ * TaskAssignmentBody — orchestrator → implementer/planner
61
+ * Assigns a specific task with acceptance criteria.
62
+ */
63
+ export const TaskAssignmentBodySchema = z.object({
64
+ type: z.literal('task_assignment'),
65
+ taskId: z.string().min(1),
66
+ title: z.string().min(1),
67
+ description: z.string(),
68
+ acceptanceCriteria: z.array(z.string()),
69
+ });
70
+ export type TaskAssignmentBody = z.infer<typeof TaskAssignmentBodySchema>;
71
+
72
+ /**
73
+ * TaskCompleteBody — implementer/planner → orchestrator
74
+ * Signals that a task has been completed successfully.
75
+ */
76
+ export const TaskCompleteBodySchema = z.object({
77
+ type: z.literal('task_complete'),
78
+ taskId: z.string().min(1),
79
+ summary: z.string(),
80
+ });
81
+ export type TaskCompleteBody = z.infer<typeof TaskCompleteBodySchema>;
82
+
83
+ /**
84
+ * StatusUpdateBody — implementer/planner → orchestrator
85
+ * Reports incremental progress on a task.
86
+ */
87
+ export const StatusUpdateBodySchema = z.object({
88
+ type: z.literal('status_update'),
89
+ taskId: z.string().min(1),
90
+ progress: z.number().int().min(0).max(100),
91
+ message: z.string(),
92
+ });
93
+ export type StatusUpdateBody = z.infer<typeof StatusUpdateBodySchema>;
94
+
95
+ /**
96
+ * ErrorReportBody — any → any
97
+ * Reports an error. taskId is optional (error may not be task-scoped).
98
+ */
99
+ export const ErrorReportBodySchema = z.object({
100
+ type: z.literal('error_report'),
101
+ errorCode: z.string().min(1),
102
+ message: z.string(),
103
+ taskId: z.string().optional(),
104
+ });
105
+ export type ErrorReportBody = z.infer<typeof ErrorReportBodySchema>;
106
+
107
+ /**
108
+ * PingBody — any → any
109
+ * Heartbeat request.
110
+ */
111
+ export const PingBodySchema = z.object({
112
+ type: z.literal('ping'),
113
+ });
114
+ export type PingBody = z.infer<typeof PingBodySchema>;
115
+
116
+ /**
117
+ * PongBody — any → any
118
+ * Heartbeat response.
119
+ */
120
+ export const PongBodySchema = z.object({
121
+ type: z.literal('pong'),
122
+ });
123
+ export type PongBody = z.infer<typeof PongBodySchema>;
124
+
125
+ /**
126
+ * MessageBodySchema — discriminated union over all body variants.
127
+ * Discriminant key: `type`.
128
+ */
129
+ export const MessageBodySchema = z.discriminatedUnion('type', [
130
+ TaskAssignmentBodySchema,
131
+ TaskCompleteBodySchema,
132
+ StatusUpdateBodySchema,
133
+ ErrorReportBodySchema,
134
+ PingBodySchema,
135
+ PongBodySchema,
136
+ ]);
137
+ export type MessageBody = z.infer<typeof MessageBodySchema>;
138
+
139
+ // ─────────────────────────────────────────────────────────────
140
+ // Task 1.1 — MessageEnvelope + Zod schema
141
+ // ─────────────────────────────────────────────────────────────
142
+
143
+ /**
144
+ * MessageEnvelope is the top-level protocol unit exchanged between agents.
145
+ * All messages on the filesystem are JSON-serialized MessageEnvelope objects.
146
+ */
147
+ export const MessageEnvelopeSchema = z.object({
148
+ /** UUID v4 — unique message identifier */
149
+ id: z.string().uuid(),
150
+ /** Sender address */
151
+ from: MailboxAddressSchema,
152
+ /** Recipient address */
153
+ to: MailboxAddressSchema,
154
+ /** ISO 8601 UTC timestamp of creation */
155
+ timestamp: z.string().datetime(),
156
+ /** Typed message payload */
157
+ body: MessageBodySchema,
158
+ });
159
+ export type MessageEnvelope = z.infer<typeof MessageEnvelopeSchema>;
160
+
161
+ // ─────────────────────────────────────────────────────────────
162
+ // RegistryEntry — sprint agent registration record (RFC-025 M4)
163
+ // ─────────────────────────────────────────────────────────────
164
+
165
+ export const RegistryEntrySchema = z.object({
166
+ role: RoleSchema,
167
+ sprintId: z.string(),
168
+ sessionId: z.string().optional(),
169
+ pid: z.number(),
170
+ registeredAt: z.string().datetime(),
171
+ worktreePath: z.string().optional(),
172
+ workflowId: z.string().optional(),
173
+ resumedAt: z.string().datetime().optional(),
174
+ });
175
+ export type RegistryEntry = z.infer<typeof RegistryEntrySchema>;
@@ -0,0 +1,249 @@
1
+ /**
2
+ * SidecarWatcher.ts — Reactive sidecar process for sprint mailbox events (RFC-025 v3.3)
3
+ *
4
+ * A Node.js watcher that listens for incoming mailbox messages and spawns
5
+ * Agent SDK `query()` calls on demand — zero idle resource usage.
6
+ *
7
+ * Architecture:
8
+ * 1. chokidar watches the inbox directory for new .json files
9
+ * 2. On "add" event: read + validate envelope via Zod, then unlink (consume)
10
+ * 3. Build a prompt from the envelope body and call SDK query()
11
+ * 4. Stream query output to stdout (visible in the tmux pane)
12
+ *
13
+ * DI via factory function — same pattern as MailboxWatcher.ts and gate-guardian.ts.
14
+ * All external dependencies (chokidar, SDK, fs) are injected for testability.
15
+ */
16
+
17
+ import * as fs from 'node:fs';
18
+ import * as path from 'node:path';
19
+ import { watch as chokidarWatchFn } from 'chokidar';
20
+ import type { FSWatcher, WatchOptions } from 'chokidar';
21
+ import { query as sdkQuery } from '@anthropic-ai/claude-agent-sdk';
22
+ import type { Options } from '@anthropic-ai/claude-agent-sdk';
23
+ import { MessageEnvelopeSchema } from '../mailbox/types.js';
24
+ import type { MessageEnvelope } from '../mailbox/types.js';
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // Types
28
+ // ---------------------------------------------------------------------------
29
+
30
+ /**
31
+ * Minimal query function signature — accepts prompt + options, returns async iterable.
32
+ * Matches @anthropic-ai/claude-agent-sdk's query() signature.
33
+ */
34
+ export type QueryFn = (params: {
35
+ prompt: string;
36
+ options?: Options;
37
+ }) => AsyncIterable<unknown>;
38
+
39
+ export interface SidecarWatcherDeps {
40
+ /** Absolute path to the inbox directory to watch. */
41
+ inboxPath: string;
42
+ /** Absolute path to the worktree — passed as cwd to SDK query(). */
43
+ worktreePath: string;
44
+ /** Agent slug passed to SDK query() options.agent. */
45
+ agentSlug: string;
46
+ /** Chokidar watch function (injected for testability). */
47
+ watch: (inboxPath: string, options?: WatchOptions) => FSWatcher;
48
+ /** Agent SDK query function (injected for testability). */
49
+ query: QueryFn;
50
+ /** fs.readFileSync (injected for testability). */
51
+ readFileSync: (filePath: string, encoding: BufferEncoding) => string;
52
+ /** fs.unlinkSync (injected for testability). */
53
+ unlinkSync: (filePath: string) => void;
54
+ }
55
+
56
+ export interface SidecarWatcher {
57
+ /** Start watching the inbox directory. Idempotent — calling twice is safe. */
58
+ start(): void;
59
+ /** Stop watching and release resources. Resolves when the watcher is closed. */
60
+ stop(): Promise<void>;
61
+ }
62
+
63
+ // ---------------------------------------------------------------------------
64
+ // Prompt builder
65
+ // ---------------------------------------------------------------------------
66
+
67
+ /**
68
+ * Builds the prompt string sent to the Agent SDK from a validated envelope.
69
+ * For task_assignment: includes taskId, title, description, and acceptance criteria.
70
+ * For other body types: includes the serialised body as JSON context.
71
+ */
72
+ function buildPrompt(envelope: MessageEnvelope): string {
73
+ const { body } = envelope;
74
+
75
+ if (body.type === 'task_assignment') {
76
+ const criteria = body.acceptanceCriteria.length > 0
77
+ ? `\n\nAcceptance criteria:\n${body.acceptanceCriteria.map(c => `- ${c}`).join('\n')}`
78
+ : '';
79
+ return [
80
+ `Task: ${body.taskId} — ${body.title}`,
81
+ body.description ? `\n${body.description}` : '',
82
+ criteria,
83
+ ].join('');
84
+ }
85
+
86
+ if (body.type === 'status_update') {
87
+ return `Status update for ${body.taskId}: [${body.progress}%] ${body.message}`;
88
+ }
89
+
90
+ if (body.type === 'task_complete') {
91
+ return `Task ${body.taskId} is complete. Summary: ${body.summary}`;
92
+ }
93
+
94
+ if (body.type === 'error_report') {
95
+ const taskSuffix = body.taskId ? ` (task: ${body.taskId})` : '';
96
+ return `Error [${body.errorCode}]${taskSuffix}: ${body.message}`;
97
+ }
98
+
99
+ // ping / pong / unknown — send minimal context
100
+ return `Received message of type "${body.type}" from ${envelope.from.role}-${envelope.from.index}. Acknowledge and stand by.`;
101
+ }
102
+
103
+ // ---------------------------------------------------------------------------
104
+ // File guard
105
+ // ---------------------------------------------------------------------------
106
+
107
+ function isMessageFile(filePath: string): boolean {
108
+ const basename = path.basename(filePath);
109
+ return basename.endsWith('.json') && !basename.startsWith('.tmp-');
110
+ }
111
+
112
+ // ---------------------------------------------------------------------------
113
+ // Factory
114
+ // ---------------------------------------------------------------------------
115
+
116
+ const defaultDeps: Pick<SidecarWatcherDeps, 'watch' | 'query' | 'readFileSync' | 'unlinkSync'> = {
117
+ watch: chokidarWatchFn,
118
+ query: sdkQuery,
119
+ readFileSync: (p, enc) => fs.readFileSync(p, enc),
120
+ unlinkSync: fs.unlinkSync,
121
+ };
122
+
123
+ export function createSidecarWatcher(deps: SidecarWatcherDeps): SidecarWatcher {
124
+ const {
125
+ inboxPath,
126
+ worktreePath,
127
+ agentSlug,
128
+ watch,
129
+ query,
130
+ readFileSync,
131
+ unlinkSync,
132
+ } = { ...defaultDeps, ...deps };
133
+
134
+ let fsWatcher: FSWatcher | null = null;
135
+
136
+ /**
137
+ * Handles a newly-added file in the inbox.
138
+ * Steps:
139
+ * 1. Guard: skip non-message files
140
+ * 2. Read + parse the envelope JSON
141
+ * 3. Validate against Zod schema
142
+ * 4. Unlink (consume) the file
143
+ * 5. Call SDK query() and drain the stream to stdout
144
+ */
145
+ async function handleAddedFile(filePath: string): Promise<void> {
146
+ if (!isMessageFile(filePath)) {
147
+ return;
148
+ }
149
+
150
+ let envelope: MessageEnvelope;
151
+
152
+ try {
153
+ const raw = readFileSync(filePath, 'utf-8');
154
+ const parsed: unknown = JSON.parse(raw);
155
+ envelope = MessageEnvelopeSchema.parse(parsed);
156
+ } catch (err) {
157
+ // eslint-disable-next-line no-console
158
+ console.error(
159
+ `[sidecar:${agentSlug}] Failed to parse envelope at ${filePath}:`,
160
+ err instanceof Error ? err.message : String(err),
161
+ );
162
+ return;
163
+ }
164
+
165
+ // Consume the file — done before query() so re-entry is safe
166
+ try {
167
+ unlinkSync(filePath);
168
+ } catch {
169
+ // Best-effort — continue even if unlink fails (e.g. concurrent consumer)
170
+ }
171
+
172
+ const prompt = buildPrompt(envelope);
173
+
174
+ // eslint-disable-next-line no-console
175
+ console.log(`[sidecar:${agentSlug}] Processing message ${envelope.id} — calling query()...`);
176
+
177
+ try {
178
+ // Defense-in-depth: strip API keys from env passed to query().
179
+ // Primary stripping happens in run.ts (process.env level), but this
180
+ // ensures keys are never passed even if run.ts is bypassed.
181
+ const sidecarEnv: Record<string, string | undefined> = { ...process.env };
182
+ delete sidecarEnv['ANTHROPIC_API_KEY'];
183
+ delete sidecarEnv['CLAUDE_API_KEY'];
184
+
185
+ const stream = query({
186
+ prompt,
187
+ options: {
188
+ agent: agentSlug,
189
+ cwd: worktreePath,
190
+ // Sidecar runs non-interactively — must bypass all permission prompts.
191
+ permissionMode: 'bypassPermissions',
192
+ allowDangerouslySkipPermissions: true,
193
+ // Ephemeral — no need to persist session history for reactive sidecars.
194
+ persistSession: false,
195
+ // Force OAuth — same as `env -u ANTHROPIC_API_KEY` in TmuxManager.
196
+ env: sidecarEnv,
197
+ },
198
+ });
199
+
200
+ // Drain the stream — log result messages for observability
201
+ for await (const msg of stream) {
202
+ const m = msg as Record<string, unknown>;
203
+ if (m.type === 'result') {
204
+ // eslint-disable-next-line no-console
205
+ console.log(
206
+ `[sidecar:${agentSlug}] query() completed — result: ${m.subtype}`,
207
+ typeof m.result === 'string' ? m.result.slice(0, 200) : '',
208
+ );
209
+ }
210
+ }
211
+ } catch (err) {
212
+ // eslint-disable-next-line no-console
213
+ console.error(
214
+ `[sidecar:${agentSlug}] query() error for message ${envelope.id}:`,
215
+ err instanceof Error ? err.message : String(err),
216
+ );
217
+ }
218
+ }
219
+
220
+ function start(): void {
221
+ if (fsWatcher !== null) {
222
+ // Already started — idempotent
223
+ return;
224
+ }
225
+
226
+ fsWatcher = watch(inboxPath, {
227
+ ignoreInitial: true,
228
+ persistent: true,
229
+ // usePolling fallback: when inotify watchers are exhausted (ENOSPC),
230
+ // chokidar falls back to polling. 1s interval is fine for mailbox events.
231
+ usePolling: true,
232
+ interval: 1000,
233
+ });
234
+
235
+ fsWatcher.on('add', (filePath: string) => {
236
+ // Fire-and-forget — errors are caught inside handleAddedFile
237
+ void handleAddedFile(filePath);
238
+ });
239
+ }
240
+
241
+ async function stop(): Promise<void> {
242
+ if (fsWatcher !== null) {
243
+ await fsWatcher.close();
244
+ fsWatcher = null;
245
+ }
246
+ }
247
+
248
+ return { start, stop };
249
+ }