chati-dev 2.0.3 → 2.0.5

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.
@@ -0,0 +1,238 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * CLI runner for parallel agent terminal execution.
4
+ *
5
+ * Called by the orchestrator via the Bash tool to spawn multiple agents
6
+ * simultaneously in separate Claude Code processes.
7
+ *
8
+ * Usage:
9
+ * node run-parallel.js --agents detail,architect,ux \
10
+ * --task-ids expand-prd,architect-design,ux-wireframe \
11
+ * --project-dir /path/to/project --previous-agent brief \
12
+ * --timeout 900000
13
+ *
14
+ * Outputs consolidated JSON to stdout for the orchestrator to parse.
15
+ */
16
+
17
+ import { fileURLToPath } from 'url';
18
+ import { buildAgentPrompt } from './prompt-builder.js';
19
+ import { spawnParallelGroup } from './spawner.js';
20
+ import { TerminalMonitor } from './monitor.js';
21
+ import { collectResults, mergeHandoffs, buildConsolidatedHandoff } from './collector.js';
22
+ import { parseAgentOutput } from './handoff-parser.js';
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // CLI argument parsing
26
+ // ---------------------------------------------------------------------------
27
+
28
+ function parseArgs(argv) {
29
+ const args = {};
30
+ for (let i = 2; i < argv.length; i++) {
31
+ const arg = argv[i];
32
+ if (arg.startsWith('--')) {
33
+ const key = arg.slice(2);
34
+ const next = argv[i + 1];
35
+ if (next && !next.startsWith('--')) {
36
+ args[key] = next;
37
+ i++;
38
+ } else {
39
+ args[key] = 'true';
40
+ }
41
+ }
42
+ }
43
+ return args;
44
+ }
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // Main
48
+ // ---------------------------------------------------------------------------
49
+
50
+ async function main() {
51
+ const args = parseArgs(process.argv);
52
+
53
+ if (!args.agents) {
54
+ outputError('Missing required argument: --agents (comma-separated)');
55
+ process.exit(1);
56
+ }
57
+ if (!args['task-ids']) {
58
+ outputError('Missing required argument: --task-ids (comma-separated)');
59
+ process.exit(1);
60
+ }
61
+
62
+ const agents = args.agents.split(',').map(s => s.trim());
63
+ const taskIds = args['task-ids'].split(',').map(s => s.trim());
64
+
65
+ if (agents.length !== taskIds.length) {
66
+ outputError(`Agent count (${agents.length}) must match task-id count (${taskIds.length})`);
67
+ process.exit(1);
68
+ }
69
+
70
+ const projectDir = args['project-dir'] || process.cwd();
71
+ const previousAgent = args['previous-agent'] || null;
72
+ const timeout = parseInt(args.timeout, 10) || 900_000; // default 15 minutes
73
+
74
+ // Load session state
75
+ let sessionState = {};
76
+ try {
77
+ const { existsSync, readFileSync } = await import('fs');
78
+ const { join } = await import('path');
79
+ const sessionPath = join(projectDir, '.chati', 'session.yaml');
80
+ if (existsSync(sessionPath)) {
81
+ const raw = readFileSync(sessionPath, 'utf-8');
82
+ // Minimal parse
83
+ sessionState = {};
84
+ for (const line of raw.split('\n')) {
85
+ const m = line.trim().match(/^([\w_-]+):\s*(.+)/);
86
+ if (m) sessionState[m[1]] = m[2].trim().replace(/^["']|["']$/g, '');
87
+ }
88
+ }
89
+ } catch { /* optional */ }
90
+
91
+ // Build prompts for all agents
92
+ const configs = [];
93
+ const startTime = Date.now();
94
+
95
+ for (let i = 0; i < agents.length; i++) {
96
+ try {
97
+ const promptResult = buildAgentPrompt({
98
+ agent: agents[i],
99
+ taskId: taskIds[i],
100
+ projectDir,
101
+ previousAgent,
102
+ sessionState,
103
+ });
104
+
105
+ configs.push({
106
+ agent: agents[i],
107
+ taskId: taskIds[i],
108
+ model: promptResult.model,
109
+ prompt: promptResult.prompt,
110
+ workingDir: projectDir,
111
+ timeout,
112
+ });
113
+ } catch (err) {
114
+ outputError(`Failed to build prompt for ${agents[i]}: ${err.message}`);
115
+ process.exit(1);
116
+ }
117
+ }
118
+
119
+ // Spawn all terminals in parallel
120
+ let group;
121
+ try {
122
+ group = spawnParallelGroup(configs);
123
+ } catch (err) {
124
+ outputError(`Failed to spawn parallel group: ${err.message}`);
125
+ process.exit(1);
126
+ }
127
+
128
+ // Monitor until completion
129
+ const monitor = new TerminalMonitor({ pollInterval: 2000, timeout });
130
+
131
+ for (const terminal of group.terminals) {
132
+ monitor.addTerminal(terminal);
133
+ }
134
+
135
+ await new Promise((resolve) => {
136
+ monitor.onComplete(() => {
137
+ monitor.stopMonitoring();
138
+ resolve();
139
+ });
140
+
141
+ // Safety timeout
142
+ const safetyTimer = setTimeout(() => {
143
+ monitor.stopMonitoring();
144
+ resolve();
145
+ }, timeout + 10_000);
146
+
147
+ monitor.onComplete(() => clearTimeout(safetyTimer));
148
+ monitor.startMonitoring();
149
+ });
150
+
151
+ const elapsed = Date.now() - startTime;
152
+
153
+ // Collect and merge results
154
+ const rawResults = collectResults(group.groupId, group.terminals);
155
+
156
+ // Parse handoffs from each terminal's stdout
157
+ const agentResults = rawResults.results.map(r => {
158
+ const parsed = parseAgentOutput(r.stdout);
159
+ return {
160
+ ...r,
161
+ handoff: parsed.found ? parsed.handoff : null,
162
+ handoffFound: parsed.found,
163
+ };
164
+ });
165
+
166
+ // Merge handoffs
167
+ const mergeInput = agentResults.map(r => ({
168
+ agent: r.agent,
169
+ status: r.handoff?.status || (r.exitCode === 0 ? 'complete' : 'failed'),
170
+ outputs: r.handoff?.outputs || [],
171
+ decisions: r.handoff?.decisions || {},
172
+ blockers: r.handoff?.blockers || [],
173
+ summary: r.handoff?.summary || '',
174
+ }));
175
+
176
+ const merged = mergeHandoffs(mergeInput);
177
+ const nextAgent = determineNextAgent(agents);
178
+ const consolidated = buildConsolidatedHandoff(merged, nextAgent);
179
+
180
+ // Output consolidated result
181
+ const output = {
182
+ status: rawResults.summary.failed === 0 ? 'complete' : 'partial',
183
+ groupId: group.groupId,
184
+ agents: agents.map((a, i) => ({
185
+ agent: a,
186
+ model: configs[i].model,
187
+ status: agentResults[i].handoff?.status || (agentResults[i].exitCode === 0 ? 'complete' : 'failed'),
188
+ score: agentResults[i].handoff?.score || null,
189
+ exitCode: agentResults[i].exitCode,
190
+ handoffFound: agentResults[i].handoffFound,
191
+ })),
192
+ mergedHandoff: consolidated,
193
+ summary: rawResults.summary,
194
+ elapsed,
195
+ performance: {
196
+ sequentialEstimate: elapsed * agents.length,
197
+ parallelActual: elapsed,
198
+ timeSaved: elapsed * (agents.length - 1),
199
+ },
200
+ };
201
+
202
+ process.stdout.write(JSON.stringify(output, null, 2) + '\n');
203
+ process.exit(rawResults.summary.failed > 0 ? 1 : 0);
204
+ }
205
+
206
+ // ---------------------------------------------------------------------------
207
+ // Helpers
208
+ // ---------------------------------------------------------------------------
209
+
210
+ /**
211
+ * Determine the next sequential agent after a parallel group.
212
+ * GROUP 1 (detail+architect+ux) → phases
213
+ * GROUP 2 (dev tasks) → qa-implementation
214
+ */
215
+ function determineNextAgent(agents) {
216
+ const groupKey = agents.sort().join(',');
217
+ if (groupKey.includes('architect') && groupKey.includes('detail') && groupKey.includes('ux')) {
218
+ return 'phases';
219
+ }
220
+ if (agents.every(a => a === 'dev')) {
221
+ return 'qa-implementation';
222
+ }
223
+ return null;
224
+ }
225
+
226
+ function outputError(message) {
227
+ process.stdout.write(JSON.stringify({ status: 'error', error: message }) + '\n');
228
+ }
229
+
230
+ // Guard pattern
231
+ if (process.argv[1] === fileURLToPath(import.meta.url)) {
232
+ main().catch(err => {
233
+ outputError(err.message);
234
+ process.exit(1);
235
+ });
236
+ }
237
+
238
+ export { parseArgs, determineNextAgent };
@@ -40,8 +40,10 @@ export function _resetCounter() {
40
40
 
41
41
  /**
42
42
  * @typedef {object} SpawnConfig
43
- * @property {string} agent - Agent name (e.g. "architect")
44
- * @property {string} taskId - Task identifier
43
+ * @property {string} agent - Agent name (e.g. "architect")
44
+ * @property {string} taskId - Task identifier
45
+ * @property {string} [model] - LLM model (haiku/sonnet/opus)
46
+ * @property {string} [prompt] - Full prompt string (from prompt-builder, piped via stdin)
45
47
  * @property {object} [contextPayload] - Context to inject via env var
46
48
  * @property {string[]} [writeScope] - Override write scope
47
49
  * @property {string} [workingDir] - Working directory for the process
@@ -54,6 +56,7 @@ export function _resetCounter() {
54
56
  * @property {object|null} process - child_process.ChildProcess (null when dry)
55
57
  * @property {string} agent - Agent name
56
58
  * @property {string} taskId - Task identifier
59
+ * @property {string} model - Model used for this terminal
57
60
  * @property {string} startedAt - ISO timestamp
58
61
  * @property {string} status - "running" | "exited" | "killed"
59
62
  * @property {number|null} exitCode - Process exit code (null while running)
@@ -68,7 +71,7 @@ export function _resetCounter() {
68
71
  * perform any I/O and is therefore fully testable in isolation.
69
72
  *
70
73
  * @param {SpawnConfig} config
71
- * @returns {{ command: string, args: string[], env: Record<string, string> }}
74
+ * @returns {{ command: string, args: string[], env: Record<string, string>, terminalId: string, prompt: string|null }}
72
75
  */
73
76
  export function buildSpawnCommand(config) {
74
77
  if (!config || typeof config !== 'object') {
@@ -89,6 +92,7 @@ export function buildSpawnCommand(config) {
89
92
  CHATI_TERMINAL_ID: terminalId,
90
93
  CHATI_AGENT: config.agent,
91
94
  CHATI_TASK_ID: config.taskId,
95
+ CHATI_SPAWNED: 'true',
92
96
  };
93
97
 
94
98
  if (config.contextPayload) {
@@ -99,19 +103,18 @@ export function buildSpawnCommand(config) {
99
103
  }
100
104
  }
101
105
 
102
- // Build the prompt that will be sent to claude CLI
103
- const prompt = `Execute task ${config.taskId} as agent ${config.agent}. ` +
104
- `Write scope: ${isolationEnv.CHATI_WRITE_SCOPE || 'none'}. ` +
105
- `Terminal ID: ${terminalId}.`;
106
-
106
+ // Build CLI args — prompt is piped via stdin, NOT as a CLI argument
107
107
  const command = 'claude';
108
- const args = [
109
- '--print',
110
- '--dangerously-skip-permissions',
111
- prompt,
112
- ];
108
+ const args = ['--print', '--dangerously-skip-permissions'];
109
+
110
+ if (config.model) {
111
+ args.push('--model', config.model);
112
+ }
113
113
 
114
- return { command, args, env, terminalId };
114
+ // Prompt is returned separately for stdin piping (avoids ARG_MAX limits)
115
+ const prompt = config.prompt || null;
116
+
117
+ return { command, args, env, terminalId, prompt };
115
118
  }
116
119
 
117
120
  /**
@@ -121,7 +124,7 @@ export function buildSpawnCommand(config) {
121
124
  * @returns {TerminalHandle}
122
125
  */
123
126
  export function spawnTerminal(config) {
124
- const { command, args, env, terminalId } = buildSpawnCommand(config);
127
+ const { command, args, env, terminalId, prompt } = buildSpawnCommand(config);
125
128
 
126
129
  const cwd = config.workingDir || process.cwd();
127
130
  const timeout = config.timeout || 300_000; // default 5 minutes
@@ -129,15 +132,22 @@ export function spawnTerminal(config) {
129
132
  const child = spawn(command, args, {
130
133
  cwd,
131
134
  env: { ...process.env, ...env },
132
- stdio: ['ignore', 'pipe', 'pipe'],
135
+ stdio: ['pipe', 'pipe', 'pipe'],
133
136
  });
134
137
 
138
+ // Pipe prompt via stdin (avoids shell argument length limits)
139
+ if (prompt) {
140
+ child.stdin.write(prompt);
141
+ }
142
+ child.stdin.end();
143
+
135
144
  /** @type {TerminalHandle} */
136
145
  const handle = {
137
146
  id: terminalId,
138
147
  process: child,
139
148
  agent: config.agent,
140
149
  taskId: config.taskId,
150
+ model: config.model || 'sonnet',
141
151
  startedAt: new Date().toISOString(),
142
152
  status: 'running',
143
153
  exitCode: null,
@@ -250,11 +260,11 @@ export function killTerminal(handle) {
250
260
  * Return the current status snapshot of a terminal.
251
261
  *
252
262
  * @param {TerminalHandle} handle
253
- * @returns {{ id: string, agent: string, status: string, elapsed: number, exitCode: number|null }}
263
+ * @returns {{ id: string, agent: string, model: string, status: string, elapsed: number, exitCode: number|null }}
254
264
  */
255
265
  export function getTerminalStatus(handle) {
256
266
  if (!handle) {
257
- return { id: 'unknown', agent: 'unknown', status: 'unknown', elapsed: 0, exitCode: null };
267
+ return { id: 'unknown', agent: 'unknown', model: 'unknown', status: 'unknown', elapsed: 0, exitCode: null };
258
268
  }
259
269
 
260
270
  const elapsed = Date.now() - new Date(handle.startedAt).getTime();
@@ -262,6 +272,7 @@ export function getTerminalStatus(handle) {
262
272
  return {
263
273
  id: handle.id,
264
274
  agent: handle.agent,
275
+ model: handle.model || 'unknown',
265
276
  status: handle.status,
266
277
  elapsed,
267
278
  exitCode: handle.exitCode,