@orchestrator-claude/cli 3.25.2 → 3.26.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.
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/templates/base/claude/agents/debug-sidecar.md +133 -0
- package/dist/templates/base/claude/agents/doc-sidecar.md +60 -0
- package/dist/templates/base/claude/agents/implementer.md +162 -31
- package/dist/templates/base/claude/agents/test-sidecar.md +106 -0
- package/dist/templates/base/claude/hooks/mailbox-listener.ts +246 -0
- package/dist/templates/base/claude/hooks/sprint-registry.ts +85 -0
- package/dist/templates/base/claude/settings.json +19 -0
- package/dist/templates/base/claude/skills/sprint-launch/SKILL.md +176 -0
- package/dist/templates/base/claude/skills/sprint-teammate/sprint-teammate.md +79 -0
- package/dist/templates/base/scripts/lib/SprintLauncher.ts +325 -0
- package/dist/templates/base/scripts/lib/TmuxManager.ts +296 -0
- package/dist/templates/base/scripts/lib/WorktreeIsolator.ts +165 -0
- package/dist/templates/base/scripts/lib/WorktreeManager.ts +106 -0
- package/dist/templates/base/scripts/lib/mailbox/types.ts +175 -0
- package/dist/templates/base/scripts/lib/sidecar/SidecarWatcher.ts +249 -0
- package/dist/templates/base/scripts/lib/sidecar/run.ts +90 -0
- package/dist/templates/base/scripts/sprint-launch.ts +285 -0
- package/package.json +1 -1
- package/templates/base/claude/agents/debug-sidecar.md +133 -0
- package/templates/base/claude/agents/doc-sidecar.md +60 -0
- package/templates/base/claude/agents/implementer.md +162 -31
- package/templates/base/claude/agents/test-sidecar.md +106 -0
- package/templates/base/claude/hooks/mailbox-listener.ts +246 -0
- package/templates/base/claude/hooks/sprint-registry.ts +85 -0
- package/templates/base/claude/settings.json +19 -0
- package/templates/base/claude/skills/sprint-launch/SKILL.md +176 -0
- package/templates/base/claude/skills/sprint-teammate/sprint-teammate.md +79 -0
- package/templates/base/scripts/lib/SprintLauncher.ts +325 -0
- package/templates/base/scripts/lib/TmuxManager.ts +296 -0
- package/templates/base/scripts/lib/WorktreeIsolator.ts +165 -0
- package/templates/base/scripts/lib/WorktreeManager.ts +106 -0
- package/templates/base/scripts/lib/mailbox/types.ts +175 -0
- package/templates/base/scripts/lib/sidecar/SidecarWatcher.ts +249 -0
- package/templates/base/scripts/lib/sidecar/run.ts +90 -0
- package/templates/base/scripts/sprint-launch.ts +285 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
#!/usr/bin/env npx tsx
|
|
2
|
+
/**
|
|
3
|
+
* run.ts — CLI entry point for the reactive sidecar watcher (RFC-025 v3.3)
|
|
4
|
+
*
|
|
5
|
+
* Launched by TmuxManager in sidecar panes. Parses CLI args, creates a
|
|
6
|
+
* SidecarWatcher, and runs until SIGTERM/SIGINT.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* npx tsx scripts/lib/sidecar/run.ts --inbox /path/to/inbox --worktree /path/to/wt --agent doc-sidecar
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { createSidecarWatcher } from './SidecarWatcher.js';
|
|
13
|
+
import { parseArgs } from 'node:util';
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// CLI argument parsing
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
const { values } = parseArgs({
|
|
20
|
+
options: {
|
|
21
|
+
inbox: { type: 'string' },
|
|
22
|
+
worktree: { type: 'string' },
|
|
23
|
+
agent: { type: 'string' },
|
|
24
|
+
},
|
|
25
|
+
strict: true,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
if (!values.inbox || !values.worktree || !values.agent) {
|
|
29
|
+
// eslint-disable-next-line no-console
|
|
30
|
+
console.error('Usage: npx tsx run.ts --inbox <path> --worktree <path> --agent <slug>');
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// Force OAuth/Claude Max — strip API keys from process.env BEFORE any SDK use.
|
|
36
|
+
// The SDK reads process.env internally; passing `env` in query() options alone
|
|
37
|
+
// is not sufficient. This mirrors `env -u ANTHROPIC_API_KEY` from TmuxManager.
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
const API_KEY_VARS = ['ANTHROPIC_API_KEY', 'CLAUDE_API_KEY'] as const;
|
|
41
|
+
for (const key of API_KEY_VARS) {
|
|
42
|
+
if (process.env[key]) {
|
|
43
|
+
// eslint-disable-next-line no-console
|
|
44
|
+
console.log(`[sidecar:${values.agent}] Stripping ${key} from env to force OAuth`);
|
|
45
|
+
delete process.env[key];
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
// Resilience: prevent unhandled rejections from killing the watcher.
|
|
51
|
+
// The SDK's internal chokidar/inotify errors (ENOSPC) are non-fatal —
|
|
52
|
+
// they surface as unhandled rejections and must not crash the process.
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
process.on('unhandledRejection', (reason) => {
|
|
56
|
+
// eslint-disable-next-line no-console
|
|
57
|
+
console.error(
|
|
58
|
+
`[sidecar:${values.agent}] Unhandled rejection (non-fatal):`,
|
|
59
|
+
reason instanceof Error ? reason.message : String(reason),
|
|
60
|
+
);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// Start watcher
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
// eslint-disable-next-line no-console
|
|
68
|
+
console.log(`[sidecar:${values.agent}] Starting reactive watcher on ${values.inbox}`);
|
|
69
|
+
|
|
70
|
+
const watcher = createSidecarWatcher({
|
|
71
|
+
inboxPath: values.inbox,
|
|
72
|
+
worktreePath: values.worktree,
|
|
73
|
+
agentSlug: values.agent,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
watcher.start();
|
|
77
|
+
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
// Graceful shutdown
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
async function shutdown(signal: string): Promise<void> {
|
|
83
|
+
// eslint-disable-next-line no-console
|
|
84
|
+
console.log(`[sidecar:${values.agent}] Received ${signal}, shutting down...`);
|
|
85
|
+
await watcher.stop();
|
|
86
|
+
process.exit(0);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
process.on('SIGTERM', () => void shutdown('SIGTERM'));
|
|
90
|
+
process.on('SIGINT', () => void shutdown('SIGINT'));
|