chati-dev 2.0.4 → 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.
@@ -5,37 +5,57 @@
5
5
  * Validates that the model being used matches the agent's assignment.
6
6
  * Constitution Article XVI enforcement.
7
7
  *
8
- * Model assignments (from agent definitions):
9
- * - orchestrator: opus
10
- * - brief, detail, phases, tasks: sonnet
11
- * - architect, dev: sonnet | upgrade: opus if complex
12
- * - ux: sonnet
13
- * - qa-planning, qa-implementation: sonnet
14
- * - devops: sonnet
15
- * - greenfield-wu, brownfield-wu: sonnet
8
+ * Model assignments (from orchestrator spec — Model Map):
9
+ * - orchestrator: sonnet | upgrade: opus if complex routing
10
+ * - greenfield-wu: haiku | upgrade: sonnet if multi-stack
11
+ * - brownfield-wu: opus | no downgrade
12
+ * - brief: sonnet | upgrade: opus if 10+ integrations
13
+ * - detail: opus | no downgrade
14
+ * - architect: opus | no downgrade
15
+ * - ux: sonnet | upgrade: opus if design system from scratch
16
+ * - phases: sonnet | upgrade: opus if 20+ requirements
17
+ * - tasks: sonnet | upgrade: opus if 50+ tasks
18
+ * - qa-planning: opus | no downgrade
19
+ * - qa-implementation: opus | no downgrade
20
+ * - dev: opus | no downgrade
21
+ * - devops: sonnet | upgrade: opus if multi-env
16
22
  *
17
- * This hook is advisory — it warns but does not block.
23
+ * This hook is advisory in IDE mode — it warns but does not block.
18
24
  */
19
25
 
20
26
  import { existsSync, readFileSync } from 'fs';
21
27
  import { join } from 'path';
22
28
 
23
29
  const AGENT_MODELS = {
24
- orchestrator: 'opus',
25
- 'greenfield-wu': 'sonnet',
26
- 'brownfield-wu': 'sonnet',
30
+ orchestrator: 'sonnet',
31
+ 'greenfield-wu': 'haiku',
32
+ 'brownfield-wu': 'opus',
27
33
  brief: 'sonnet',
28
- detail: 'sonnet',
29
- architect: 'sonnet',
34
+ detail: 'opus',
35
+ architect: 'opus',
30
36
  ux: 'sonnet',
31
37
  phases: 'sonnet',
32
38
  tasks: 'sonnet',
33
- 'qa-planning': 'sonnet',
34
- 'qa-implementation': 'sonnet',
35
- dev: 'sonnet',
39
+ 'qa-planning': 'opus',
40
+ 'qa-implementation': 'opus',
41
+ dev: 'opus',
36
42
  devops: 'sonnet',
37
43
  };
38
44
 
45
+ /**
46
+ * Upgrade conditions per agent. When context matches, the model
47
+ * should be upgraded to the specified target.
48
+ */
49
+ const UPGRADE_CONDITIONS = {
50
+ orchestrator: { to: 'opus', condition: 'complex routing or deviation handling' },
51
+ 'greenfield-wu': { to: 'sonnet', condition: 'multi-stack or enterprise' },
52
+ brief: { to: 'opus', condition: '10+ integrations' },
53
+ ux: { to: 'opus', condition: 'design system from scratch' },
54
+ phases: { to: 'opus', condition: '20+ requirements' },
55
+ tasks: { to: 'opus', condition: '50+ tasks' },
56
+ devops: { to: 'opus', condition: 'multi-environment or IaC' },
57
+ };
58
+
39
59
  function getCurrentAgent(projectDir) {
40
60
  const sessionPath = join(projectDir, '.chati', 'session.yaml');
41
61
  if (!existsSync(sessionPath)) return null;
@@ -71,6 +91,6 @@ async function main() {
71
91
  }
72
92
  }
73
93
 
74
- export { AGENT_MODELS, getCurrentAgent };
94
+ export { AGENT_MODELS, UPGRADE_CONDITIONS, getCurrentAgent };
75
95
 
76
96
  main();
@@ -158,57 +158,137 @@ brownfield-wu -> Brief -> Architect -> Detail -> UX -> Phases -> Tasks -> QA-Pla
158
158
  | devops | chati.dev/agents/deploy/devops.md |
159
159
 
160
160
  ### Transition Logic
161
+
161
162
  ```
162
163
  When an agent completes (score >= 95%):
163
164
  1. Agent generates handoff at chati.dev/artifacts/handoffs/{agent-name}-handoff.md
164
165
  2. Agent updates session.yaml (status: completed, score, completed_at)
165
166
  3. Agent updates CLAUDE.md with current state
166
167
  4. Orchestrator identifies next agent from pipeline
168
+ 5. Update session.yaml: current_agent = next_agent
169
+ 6. Update project.state if crossing macro-phase boundary:
170
+ - WU through QA-Planning = clarity
171
+ - Dev + QA-Implementation = build
172
+ - Final validation = validate
173
+ - DevOps = deploy
174
+ 7. Activate agent using Hybrid Activation Protocol (see below)
175
+ ```
176
+
177
+ ---
178
+
179
+ ## Hybrid Activation Protocol
180
+
181
+ The orchestrator uses TWO activation modes depending on the agent type:
182
+
183
+ ### Interactive Agents (run IN-CONVERSATION)
184
+
185
+ These agents require human interaction and run in the same terminal as the orchestrator:
186
+ - **greenfield-wu** — needs user description of the project
187
+ - **brownfield-wu** — needs user guidance on codebase
188
+ - **brief** — needs iterative requirement extraction with user
189
+
190
+ For interactive agents:
191
+ ```
192
+ 1. Read the agent's .md file from the Agent Location Map
193
+ 2. Display model recommendation (see Model Map below)
194
+ 3. Load agent context into the conversation
195
+ 4. User interacts with the agent directly
196
+ 5. Agent completes, generates handoff, orchestrator continues
197
+ ```
198
+
199
+ ### Autonomous Agents (run in SEPARATE TERMINALS)
200
+
201
+ All other agents run in separate Claude Code processes with the correct model:
202
+ - **detail**, **architect**, **ux**, **phases**, **tasks**
203
+ - **qa-planning**, **dev**, **qa-implementation**, **devops**
204
+
205
+ For autonomous agents:
206
+ ```
207
+ 1. Use the Bash tool to spawn the agent in a separate terminal:
208
+
209
+ node packages/chati-dev/src/terminal/run-agent.js \
210
+ --agent {agent_name} \
211
+ --task-id {primary_task_id} \
212
+ --project-dir {absolute_project_path} \
213
+ --previous-agent {previous_agent_name} \
214
+ --timeout 600000
215
+
216
+ 2. Wait for the JSON output
217
+
218
+ 3. Parse the result:
219
+ - If "status": "complete" → Save handoff, continue to next agent
220
+ - If "status": "needs_input" → Read "needs_input_question", ask the user,
221
+ then re-run with --additional-context "{user_response}"
222
+ - If "status": "error" → Apply Recovery Protocol (retry up to 2 times)
223
+ - If retries exhausted → Fall back to in-conversation activation
224
+
225
+ 4. Update session.yaml with completion data
226
+ ```
227
+
228
+ ### Parallel Group Execution
167
229
 
168
230
  ╔══════════════════════════════════════════════════════════════╗
169
- 4.5. PARALLELIZATION CHECK (MANDATORY — DO NOT SKIP)
170
- ║ ║
171
- ║ Before activating next agent, check parallel eligibility: ║
231
+ ║ PARALLELIZATION CHECK (MANDATORY — DO NOT SKIP)
172
232
  ║ ║
173
233
  ║ GROUP 1 — Clarity Phase (after Brief completes): ║
174
234
  ║ Agents: [detail, architect, ux] ║
175
- AUTONOMOUS: Spawn all 3 simultaneously (default)
176
- ║ HUMAN-IN-THE-LOOP: Offer parallel option (recommended) ║
177
- ║ Write scopes: ║
178
- ║ detail → chati.dev/artifacts/2-PRD/ ║
179
- ║ architect → chati.dev/artifacts/3-Architecture/ ║
180
- ║ ux → chati.dev/artifacts/4-UX/ ║
181
- ║ → Merge ALL handoffs before proceeding to Phases ║
235
+ ALL run in parallel via separate terminals
182
236
  ║ ║
183
237
  ║ GROUP 2 — Build Phase (Dev agent tasks): ║
184
- ALL MODES: Independent tasks ALWAYS run in parallel
185
- Analyze task dependency graph from tasks.md
186
- ║ → Tasks with no shared file deps = parallel ║
187
- ║ → Tasks with deps = sequential within their chain ║
188
- ║ → Each terminal gets isolated write scope per task ║
189
- ║ → Merge results after each parallel batch ║
190
- ║ ║
191
- ║ If next agent is NOT in a parallel group: ║
192
- ║ → Continue with sequential activation (step 5) ║
238
+ ║ Independent tasks ALWAYS run in parallel
239
+ Tasks with dependencies = sequential within chain
193
240
  ╚══════════════════════════════════════════════════════════════╝
194
241
 
195
- 5. Update session.yaml: current_agent = next_agent
196
- 6. Update project.state if crossing macro-phase boundary:
197
- - WU through QA-Planning = clarity
198
- - Dev + QA-Implementation = build
199
- - Final validation = validate
200
- - DevOps = deploy
201
- 7. Read next agent's command file -> Extract Model field from Identity section
202
- 8. Evaluate upgrade conditions against session context
203
- 9. Display model recommendation (see Model Selection Protocol below)
204
- 10. Activate agent
242
+ When the next agent is part of a parallel group, use:
243
+
244
+ ```
245
+ node packages/chati-dev/src/terminal/run-parallel.js \
246
+ --agents detail,architect,ux \
247
+ --task-ids {task_id_detail},{task_id_architect},{task_id_ux} \
248
+ --project-dir {absolute_project_path} \
249
+ --previous-agent brief \
250
+ --timeout 900000
251
+ ```
252
+
253
+ Parse the consolidated JSON output:
254
+ - If all agents completed → merged handoff ready, continue to next sequential agent
255
+ - If partial failure → present failed agents to user with options:
256
+ 1. Retry failed agents only
257
+ 2. Continue with partial results
258
+ 3. Fall back to sequential execution for failed agents
259
+
260
+ ### Sequential Fallback
261
+
262
+ If terminal spawning fails (claude CLI not found, system error, etc.):
263
+ ```
264
+ 1. Log the failure
265
+ 2. Fall back to in-conversation activation (read agent .md file, become agent)
266
+ 3. Record in session.yaml:
267
+ terminal_fallback:
268
+ agent: {name}
269
+ reason: "{error_message}"
270
+ timestamp: "{now}"
271
+ 4. Continue pipeline normally
272
+ ```
273
+
274
+ ### Needs-Input Relay Pattern
275
+
276
+ When a spawned agent needs user input:
277
+ ```
278
+ 1. Agent returns status: "needs_input" in JSON output
279
+ 2. Agent includes question in "needs_input_question" field
280
+ 3. Orchestrator reads the question
281
+ 4. Orchestrator presents question to user (in their language)
282
+ 5. User responds
283
+ 6. Orchestrator re-spawns agent with --additional-context "{user_response}"
284
+ 7. Repeat until agent completes or max 3 relay cycles
205
285
  ```
206
286
 
207
287
  ---
208
288
 
209
289
  ## Model Selection Protocol
210
290
 
211
- Before activating any agent, the orchestrator reads the agent's `Model` field and recommends the optimal model.
291
+ Model selection is **enforced by construction**: the orchestrator passes `--model` to the spawned terminal. For interactive agents, the model is recommended to the user.
212
292
 
213
293
  ### Model Map (Quick Reference)
214
294
 
@@ -227,30 +307,19 @@ Before activating any agent, the orchestrator reads the agent's `Model` field an
227
307
  | qa-implementation | opus | no downgrade |
228
308
  | devops | sonnet | opus if multi-environment or IaC |
229
309
 
230
- ### Evaluation Logic
231
-
232
- ```
233
- 1. Read agent's Model field from its .md file
234
- 2. Extract default model and upgrade condition
235
- 3. If upgrade condition exists:
236
- a. Check session context (project.type, codebase_size, integrations_count, requirements_count)
237
- b. Check previous agent signals (handoff complexity, discovered tech debt)
238
- c. If condition matches -> recommend upgraded model
239
- d. If condition does not match -> use default model
240
- 4. If "no downgrade" -> always use the specified model
241
- ```
242
-
243
- ### Recommendation Message (IDE Mode)
244
-
245
- When the recommended model differs from the current model, display:
310
+ ### Enforcement
246
311
 
247
312
  ```
248
- 💡 Model recommendation for {agent_name}: {recommended_model}
249
- Reason: {justification}
250
- Current: {current_model}
313
+ For autonomous agents (spawned terminals):
314
+ Model is AUTOMATICALLY selected and passed via --model flag
315
+ No user intervention needed
316
+ → Correct model guaranteed by the prompt builder
251
317
 
252
- To switch: /model {recommended_model}
253
- Or continue with current model.
318
+ For interactive agents (in-conversation):
319
+ Display model recommendation to user:
320
+ 💡 Model recommendation for {agent_name}: {recommended_model}
321
+ To switch: /model {recommended_model}
322
+ → Model governance hook provides defense-in-depth validation
254
323
  ```
255
324
 
256
325
  ### Session Logging
@@ -258,10 +327,11 @@ When the recommended model differs from the current model, display:
258
327
  ```yaml
259
328
  # Appended to session.yaml on each agent activation
260
329
  model_selections:
261
- - agent: brief
262
- recommended: sonnet
263
- actual: opus # What the user actually used
264
- reason: "default"
330
+ - agent: detail
331
+ recommended: opus
332
+ actual: opus
333
+ mode: terminal # terminal | in-conversation
334
+ reason: "no downgrade"
265
335
  timestamp: "2026-..."
266
336
  ```
267
337
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chati-dev",
3
- "version": "2.0.4",
3
+ "version": "2.0.5",
4
4
  "description": "AI-Powered Multi-Agent Orchestration System — 13 agents, 6 IDEs, 4 languages",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,127 @@
1
+ /**
2
+ * @fileoverview Parse structured handoff data from spawned agent output.
3
+ *
4
+ * Agents running in separate `claude -p` terminals include a
5
+ * <chati-handoff> block in their output. This module extracts
6
+ * and parses that block so the orchestrator can read the results.
7
+ */
8
+
9
+ /**
10
+ * Parse the <chati-handoff> block from agent stdout.
11
+ *
12
+ * @param {string} output - Full stdout from the agent process
13
+ * @returns {{ found: boolean, handoff: object|null, rawOutput: string }}
14
+ */
15
+ export function parseAgentOutput(output) {
16
+ if (!output || typeof output !== 'string') {
17
+ return { found: false, handoff: null, rawOutput: '' };
18
+ }
19
+
20
+ const match = output.match(/<chati-handoff>([\s\S]*?)<\/chati-handoff>/);
21
+ if (!match) {
22
+ return { found: false, handoff: null, rawOutput: output };
23
+ }
24
+
25
+ const content = match[1].trim();
26
+ const handoff = parseHandoffFields(content);
27
+
28
+ return { found: true, handoff, rawOutput: output };
29
+ }
30
+
31
+ /**
32
+ * Parse YAML-like key-value fields from the handoff block content.
33
+ *
34
+ * Supports:
35
+ * scalar: value
36
+ * list:
37
+ * - item1
38
+ * - item2
39
+ * map:
40
+ * key1: value1
41
+ * key2: value2
42
+ *
43
+ * @param {string} content - Content inside <chati-handoff> tags
44
+ * @returns {object} Parsed handoff data
45
+ */
46
+ function parseHandoffFields(content) {
47
+ const result = {
48
+ status: 'unknown',
49
+ score: null,
50
+ summary: '',
51
+ outputs: [],
52
+ decisions: {},
53
+ blockers: [],
54
+ needs_input_question: null,
55
+ };
56
+
57
+ const lines = content.split('\n');
58
+ let currentKey = null;
59
+ let currentType = null; // 'list' | 'map'
60
+
61
+ for (const line of lines) {
62
+ const trimmed = line.trim();
63
+ if (!trimmed) continue;
64
+
65
+ // Check if this is a list item ( - value)
66
+ const listMatch = trimmed.match(/^-\s+(.+)/);
67
+ if (listMatch && currentKey && currentType === 'list') {
68
+ if (Array.isArray(result[currentKey])) {
69
+ result[currentKey].push(listMatch[1].trim());
70
+ }
71
+ continue;
72
+ }
73
+
74
+ // Check if this is a map item ( key: value) under a map key
75
+ const mapMatch = trimmed.match(/^(\w[\w_-]*):\s+(.+)/);
76
+ if (mapMatch && currentKey && currentType === 'map') {
77
+ if (typeof result[currentKey] === 'object' && !Array.isArray(result[currentKey])) {
78
+ result[currentKey][mapMatch[1]] = mapMatch[2].trim();
79
+ }
80
+ continue;
81
+ }
82
+
83
+ // Top-level key: value
84
+ const kvMatch = trimmed.match(/^(\w[\w_-]*):\s*(.*)/);
85
+ if (kvMatch) {
86
+ const key = kvMatch[1];
87
+ const value = kvMatch[2].trim();
88
+
89
+ // Known list keys
90
+ if (['outputs', 'blockers'].includes(key)) {
91
+ currentKey = key;
92
+ currentType = 'list';
93
+ // If value is inline (not empty), treat as single item
94
+ if (value && value !== '') {
95
+ result[key] = [value];
96
+ currentKey = null;
97
+ currentType = null;
98
+ }
99
+ continue;
100
+ }
101
+
102
+ // Known map keys
103
+ if (['decisions'].includes(key)) {
104
+ currentKey = key;
105
+ currentType = 'map';
106
+ continue;
107
+ }
108
+
109
+ // Scalar keys
110
+ currentKey = null;
111
+ currentType = null;
112
+
113
+ if (key === 'status') {
114
+ result.status = value || 'unknown';
115
+ } else if (key === 'score') {
116
+ const num = parseInt(value, 10);
117
+ result.score = isNaN(num) ? null : num;
118
+ } else if (key === 'summary') {
119
+ result.summary = value || '';
120
+ } else if (key === 'needs_input_question') {
121
+ result.needs_input_question = value === 'null' || value === '' ? null : value;
122
+ }
123
+ }
124
+ }
125
+
126
+ return result;
127
+ }
@@ -28,3 +28,12 @@ export {
28
28
  getReadScope,
29
29
  buildIsolationEnv,
30
30
  } from './isolation.js';
31
+
32
+ export {
33
+ buildAgentPrompt,
34
+ AGENT_FILE_MAP,
35
+ } from './prompt-builder.js';
36
+
37
+ export {
38
+ parseAgentOutput,
39
+ } from './handoff-parser.js';
@@ -0,0 +1,313 @@
1
+ /**
2
+ * @fileoverview Prompt builder for multi-terminal agent execution.
3
+ *
4
+ * Builds a complete, self-contained prompt for spawned agent terminals.
5
+ * When an agent runs in a separate `claude -p` process, it has zero
6
+ * conversation history — the prompt must contain everything: PRISM
7
+ * context, agent definition, previous handoff, write scope, and
8
+ * output format instructions.
9
+ */
10
+
11
+ import { existsSync, readFileSync } from 'fs';
12
+ import { join } from 'path';
13
+ import { runPrism } from '../context/engine.js';
14
+ import { loadHandoff, formatHandoff } from '../tasks/handoff.js';
15
+ import { getWriteScope } from './isolation.js';
16
+
17
+ // Re-read at import time so the prompt builder always uses the correct map
18
+ // without circular dependency issues (the hook file runs main() on import
19
+ // which writes to stdout — we only need the data, so we inline the map).
20
+ const AGENT_MODELS = {
21
+ orchestrator: 'sonnet',
22
+ 'greenfield-wu': 'haiku',
23
+ 'brownfield-wu': 'opus',
24
+ brief: 'sonnet',
25
+ detail: 'opus',
26
+ architect: 'opus',
27
+ ux: 'sonnet',
28
+ phases: 'sonnet',
29
+ tasks: 'sonnet',
30
+ 'qa-planning': 'opus',
31
+ 'qa-implementation': 'opus',
32
+ dev: 'opus',
33
+ devops: 'sonnet',
34
+ };
35
+
36
+ /**
37
+ * Map of agent names to their definition file paths (relative to project root).
38
+ */
39
+ export const AGENT_FILE_MAP = {
40
+ 'greenfield-wu': 'chati.dev/agents/clarity/greenfield-wu.md',
41
+ 'brownfield-wu': 'chati.dev/agents/clarity/brownfield-wu.md',
42
+ brief: 'chati.dev/agents/clarity/brief.md',
43
+ detail: 'chati.dev/agents/clarity/detail.md',
44
+ architect: 'chati.dev/agents/clarity/architect.md',
45
+ ux: 'chati.dev/agents/clarity/ux.md',
46
+ phases: 'chati.dev/agents/clarity/phases.md',
47
+ tasks: 'chati.dev/agents/clarity/tasks.md',
48
+ 'qa-planning': 'chati.dev/agents/quality/qa-planning.md',
49
+ dev: 'chati.dev/agents/build/dev.md',
50
+ 'qa-implementation': 'chati.dev/agents/quality/qa-implementation.md',
51
+ devops: 'chati.dev/agents/deploy/devops.md',
52
+ };
53
+
54
+ /**
55
+ * @typedef {object} PromptBuildConfig
56
+ * @property {string} agent - Agent name (e.g. 'detail')
57
+ * @property {string} taskId - Task identifier
58
+ * @property {string} projectDir - Project root (absolute path)
59
+ * @property {string} [previousAgent] - Agent that produced the handoff
60
+ * @property {string} [workflow] - Active workflow name
61
+ * @property {object} [sessionState] - Parsed session.yaml fields
62
+ * @property {string[]} [writeScope] - Override write scope
63
+ * @property {string} [additionalContext] - Extra context (e.g. user answer to needs_input)
64
+ */
65
+
66
+ /**
67
+ * Build a complete, self-contained prompt for a spawned agent terminal.
68
+ *
69
+ * @param {PromptBuildConfig} config
70
+ * @returns {{ prompt: string, model: string, metadata: { agent: string, layers: number, promptSize: number } }}
71
+ */
72
+ export function buildAgentPrompt(config) {
73
+ if (!config || !config.agent) {
74
+ throw new Error('buildAgentPrompt requires config.agent');
75
+ }
76
+ if (!config.projectDir) {
77
+ throw new Error('buildAgentPrompt requires config.projectDir');
78
+ }
79
+
80
+ const sections = [];
81
+
82
+ // 1. PRISM Context (L0-L4) — spawned terminals always start FRESH
83
+ const prismResult = buildPrismSection(config);
84
+ if (prismResult.xml) {
85
+ sections.push(prismResult.xml);
86
+ }
87
+
88
+ // 2. Agent definition (.md file)
89
+ const agentDef = loadAgentDefinition(config.agent, config.projectDir);
90
+ if (agentDef) {
91
+ sections.push('<!-- AGENT DEFINITION -->\n' + agentDef);
92
+ }
93
+
94
+ // 3. Previous handoff
95
+ const handoffSection = buildHandoffSection(config);
96
+ if (handoffSection) {
97
+ sections.push(handoffSection);
98
+ }
99
+
100
+ // 4. Additional context (user input relay for needs_input)
101
+ if (config.additionalContext) {
102
+ sections.push(
103
+ '<!-- ADDITIONAL CONTEXT -->\n' +
104
+ '## Additional Context from User\n\n' +
105
+ config.additionalContext
106
+ );
107
+ }
108
+
109
+ // 5. Write scope instructions
110
+ sections.push(buildWriteScopeSection(config));
111
+
112
+ // 6. Session context
113
+ sections.push(buildSessionSection(config));
114
+
115
+ // 7. Output format instructions (handoff template)
116
+ sections.push(buildOutputInstructions());
117
+
118
+ const prompt = sections.join('\n\n---\n\n');
119
+ const model = AGENT_MODELS[config.agent] || 'sonnet';
120
+
121
+ return {
122
+ prompt,
123
+ model,
124
+ metadata: {
125
+ agent: config.agent,
126
+ layers: prismResult.layerCount || 0,
127
+ promptSize: prompt.length,
128
+ },
129
+ };
130
+ }
131
+
132
+ // ---------------------------------------------------------------------------
133
+ // Internal helpers
134
+ // ---------------------------------------------------------------------------
135
+
136
+ /**
137
+ * Run the PRISM context engine for the agent.
138
+ * Spawned terminals always start FRESH (remainingPercent: 100).
139
+ */
140
+ function buildPrismSection(config) {
141
+ const domainsDir = join(config.projectDir, 'chati.dev', 'domains');
142
+
143
+ if (!existsSync(domainsDir)) {
144
+ return { xml: null, layerCount: 0 };
145
+ }
146
+
147
+ const state = config.sessionState || {};
148
+
149
+ // Load handoff data for PRISM L3
150
+ let handoff = {};
151
+ if (config.previousAgent) {
152
+ const loaded = loadHandoff(config.projectDir, config.previousAgent);
153
+ if (loaded.loaded && loaded.handoff) {
154
+ handoff = loaded.handoff;
155
+ }
156
+ }
157
+
158
+ const result = runPrism({
159
+ domainsDir,
160
+ remainingPercent: 100, // Spawned terminals are always FRESH
161
+ mode: state.project?.state || 'clarity',
162
+ agent: config.agent,
163
+ workflow: config.workflow || null,
164
+ pipelinePosition: config.agent,
165
+ taskId: config.taskId || null,
166
+ handoff,
167
+ artifacts: state.artifacts || [],
168
+ taskCriteria: [],
169
+ });
170
+
171
+ return {
172
+ xml: result.xml || null,
173
+ layerCount: result.layerCount || 0,
174
+ };
175
+ }
176
+
177
+ /**
178
+ * Load the agent's full .md definition file.
179
+ */
180
+ function loadAgentDefinition(agent, projectDir) {
181
+ const relativePath = AGENT_FILE_MAP[agent];
182
+ if (!relativePath) return null;
183
+
184
+ const fullPath = join(projectDir, relativePath);
185
+ if (!existsSync(fullPath)) return null;
186
+
187
+ return readFileSync(fullPath, 'utf-8');
188
+ }
189
+
190
+ /**
191
+ * Build the previous handoff section for the prompt.
192
+ */
193
+ function buildHandoffSection(config) {
194
+ if (!config.previousAgent) return null;
195
+
196
+ const result = loadHandoff(config.projectDir, config.previousAgent);
197
+ if (!result.loaded || !result.handoff) return null;
198
+
199
+ return (
200
+ '<!-- PREVIOUS HANDOFF -->\n' +
201
+ `## Handoff from ${config.previousAgent}\n\n` +
202
+ formatHandoff({
203
+ from: {
204
+ agent: result.handoff.from_agent || config.previousAgent,
205
+ task_id: result.handoff.from_task || 'unknown',
206
+ phase: result.handoff.from_phase || 'unknown',
207
+ },
208
+ to: config.agent,
209
+ timestamp: result.handoff.timestamp || new Date().toISOString(),
210
+ status: result.handoff.status || 'unknown',
211
+ score: result.handoff.score,
212
+ summary: result.handoff.summary || '',
213
+ outputs: result.handoff.outputs || [],
214
+ decisions: result.handoff.decisions || {},
215
+ blockers: result.handoff.blockers || [],
216
+ criteria_met: result.handoff.criteria_met || [],
217
+ criteria_unmet: result.handoff.criteria_unmet || [],
218
+ })
219
+ );
220
+ }
221
+
222
+ /**
223
+ * Build write scope instructions embedded in the prompt.
224
+ * This is the primary enforcement mechanism — env vars alone are not
225
+ * respected by the spawned Claude process.
226
+ */
227
+ function buildWriteScopeSection(config) {
228
+ const scope = config.writeScope || getWriteScope(config.agent);
229
+
230
+ if (scope.length === 0) {
231
+ return (
232
+ '<!-- WRITE SCOPE -->\n' +
233
+ '## Write Scope (MANDATORY)\n\n' +
234
+ 'You have NO write access. This is a read-only execution.\n' +
235
+ 'Do NOT use Write, Edit, or any file-modifying tool.'
236
+ );
237
+ }
238
+
239
+ const paths = scope.map(p => `- \`${p}\``).join('\n');
240
+
241
+ return (
242
+ '<!-- WRITE SCOPE -->\n' +
243
+ '## Write Scope (MANDATORY)\n\n' +
244
+ 'You may ONLY write to these paths:\n' +
245
+ paths + '\n\n' +
246
+ '**All other write operations are BLOCKED.** Do NOT attempt to write, edit, or create files outside these paths. ' +
247
+ 'Read access is unrestricted — you may read any file in the project.'
248
+ );
249
+ }
250
+
251
+ /**
252
+ * Build session context section.
253
+ */
254
+ function buildSessionSection(config) {
255
+ const state = config.sessionState || {};
256
+ const project = state.project || {};
257
+
258
+ const lines = [
259
+ '<!-- SESSION CONTEXT -->',
260
+ '## Session Context',
261
+ '',
262
+ `- **Project**: ${project.name || '(unnamed)'}`,
263
+ `- **Type**: ${project.type || 'greenfield'}`,
264
+ `- **Mode**: ${project.state || 'clarity'}`,
265
+ `- **Language**: ${state.language || 'en'} (interaction) / English (artifacts)`,
266
+ `- **User Level**: ${state.user_level || 'auto'}`,
267
+ `- **Execution Mode**: ${state.execution_mode || 'autonomous'}`,
268
+ `- **Your Agent**: ${config.agent}`,
269
+ `- **Your Model**: ${AGENT_MODELS[config.agent] || 'sonnet'}`,
270
+ ];
271
+
272
+ return lines.join('\n');
273
+ }
274
+
275
+ /**
276
+ * Build output format instructions so the agent produces a parseable handoff.
277
+ */
278
+ function buildOutputInstructions() {
279
+ return `<!-- OUTPUT INSTRUCTIONS -->
280
+ ## Output Instructions (MANDATORY)
281
+
282
+ When you complete your work, you MUST include a structured handoff block at the END of your response.
283
+ This block is how the orchestrator reads your results. Without it, your work cannot be collected.
284
+
285
+ \`\`\`
286
+ <chati-handoff>
287
+ status: complete
288
+ score: 95
289
+ summary: One to three sentence summary of what was accomplished.
290
+ outputs:
291
+ - path/to/artifact1.md
292
+ - path/to/artifact2.md
293
+ decisions:
294
+ key1: value1
295
+ key2: value2
296
+ blockers:
297
+ - Description of any unresolved blocker
298
+ needs_input_question: null
299
+ </chati-handoff>
300
+ \`\`\`
301
+
302
+ ### Status values:
303
+ - **complete**: All work finished successfully (score >= 95 required)
304
+ - **partial**: Some work done but not all criteria met
305
+ - **needs_input**: You need information from the user to continue. Set \`needs_input_question\` to your question.
306
+ - **error**: Something went wrong that you cannot recover from
307
+
308
+ ### Important:
309
+ - The \`<chati-handoff>\` block MUST appear in your response
310
+ - Score must be 0-100 (95+ to pass quality gate)
311
+ - List ALL artifacts you created/modified in outputs
312
+ - If you need user input, set status to "needs_input" and write your question in needs_input_question`;
313
+ }
@@ -0,0 +1,248 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * CLI runner for single-agent terminal execution.
4
+ *
5
+ * Called by the orchestrator via the Bash tool to spawn an agent
6
+ * in a separate Claude Code process with the correct model.
7
+ *
8
+ * Usage:
9
+ * node run-agent.js --agent detail --task-id expand-prd \
10
+ * --project-dir /path/to/project --previous-agent brief \
11
+ * --timeout 600000
12
+ *
13
+ * Outputs JSON to stdout for the orchestrator to parse.
14
+ */
15
+
16
+ import { fileURLToPath } from 'url';
17
+ import { buildAgentPrompt } from './prompt-builder.js';
18
+ import { spawnTerminal } from './spawner.js';
19
+ import { parseAgentOutput } from './handoff-parser.js';
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // CLI argument parsing (no external deps)
23
+ // ---------------------------------------------------------------------------
24
+
25
+ function parseArgs(argv) {
26
+ const args = {};
27
+ for (let i = 2; i < argv.length; i++) {
28
+ const arg = argv[i];
29
+ if (arg.startsWith('--')) {
30
+ const key = arg.slice(2);
31
+ const next = argv[i + 1];
32
+ if (next && !next.startsWith('--')) {
33
+ args[key] = next;
34
+ i++;
35
+ } else {
36
+ args[key] = 'true';
37
+ }
38
+ }
39
+ }
40
+ return args;
41
+ }
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // Main
45
+ // ---------------------------------------------------------------------------
46
+
47
+ async function main() {
48
+ const args = parseArgs(process.argv);
49
+
50
+ // Validate required args
51
+ if (!args.agent) {
52
+ outputError('Missing required argument: --agent');
53
+ process.exit(1);
54
+ }
55
+ if (!args['task-id']) {
56
+ outputError('Missing required argument: --task-id');
57
+ process.exit(1);
58
+ }
59
+
60
+ const projectDir = args['project-dir'] || process.cwd();
61
+ const timeout = parseInt(args.timeout, 10) || 600_000; // default 10 minutes
62
+
63
+ // Load session state if available
64
+ let sessionState = {};
65
+ try {
66
+ const { existsSync, readFileSync } = await import('fs');
67
+ const { join } = await import('path');
68
+ const sessionPath = join(projectDir, '.chati', 'session.yaml');
69
+ if (existsSync(sessionPath)) {
70
+ const raw = readFileSync(sessionPath, 'utf-8');
71
+ sessionState = parseSimpleYaml(raw);
72
+ }
73
+ } catch {
74
+ // Session state is optional — continue without it
75
+ }
76
+
77
+ // Build the agent prompt
78
+ let promptResult;
79
+ try {
80
+ promptResult = buildAgentPrompt({
81
+ agent: args.agent,
82
+ taskId: args['task-id'],
83
+ projectDir,
84
+ previousAgent: args['previous-agent'] || null,
85
+ workflow: args.workflow || null,
86
+ sessionState,
87
+ additionalContext: args['additional-context'] || null,
88
+ });
89
+ } catch (err) {
90
+ outputError(`Failed to build prompt: ${err.message}`);
91
+ process.exit(1);
92
+ }
93
+
94
+ // Spawn the agent terminal
95
+ const startTime = Date.now();
96
+ let handle;
97
+
98
+ try {
99
+ handle = spawnTerminal({
100
+ agent: args.agent,
101
+ taskId: args['task-id'],
102
+ model: promptResult.model,
103
+ prompt: promptResult.prompt,
104
+ workingDir: projectDir,
105
+ timeout,
106
+ });
107
+ } catch (err) {
108
+ outputError(`Failed to spawn terminal: ${err.message}`);
109
+ process.exit(1);
110
+ }
111
+
112
+ // Wait for the process to complete
113
+ try {
114
+ await waitForExit(handle, timeout);
115
+ } catch (err) {
116
+ outputError(`Terminal execution failed: ${err.message}`);
117
+ process.exit(2);
118
+ }
119
+
120
+ const elapsed = Date.now() - startTime;
121
+ const stdout = handle.stdout.join('');
122
+ const stderr = handle.stderr.join('');
123
+
124
+ // Parse the handoff from stdout
125
+ const parsed = parseAgentOutput(stdout);
126
+
127
+ if (parsed.found) {
128
+ outputResult({
129
+ status: parsed.handoff.status,
130
+ agent: args.agent,
131
+ model: promptResult.model,
132
+ exitCode: handle.exitCode,
133
+ handoff: parsed.handoff,
134
+ elapsed,
135
+ });
136
+ } else {
137
+ // No handoff block found — return raw output
138
+ outputResult({
139
+ status: handle.exitCode === 0 ? 'partial' : 'error',
140
+ agent: args.agent,
141
+ model: promptResult.model,
142
+ exitCode: handle.exitCode,
143
+ handoff: null,
144
+ rawOutput: stdout.slice(0, 5000), // Truncate to avoid huge JSON
145
+ stderr: stderr.slice(0, 2000),
146
+ elapsed,
147
+ });
148
+ }
149
+
150
+ process.exit(handle.exitCode === 0 ? 0 : 1);
151
+ }
152
+
153
+ // ---------------------------------------------------------------------------
154
+ // Helpers
155
+ // ---------------------------------------------------------------------------
156
+
157
+ /**
158
+ * Wait for a terminal handle's process to exit.
159
+ */
160
+ function waitForExit(handle, timeout) {
161
+ return new Promise((resolve, reject) => {
162
+ if (handle.status === 'exited') {
163
+ return resolve();
164
+ }
165
+
166
+ const timer = setTimeout(() => {
167
+ if (handle.process && typeof handle.process.kill === 'function') {
168
+ try { handle.process.kill('SIGKILL'); } catch { /* ignore */ }
169
+ }
170
+ handle.status = 'exited';
171
+ handle.exitCode = -2;
172
+ reject(new Error(`Terminal timed out after ${Math.round(timeout / 1000)}s`));
173
+ }, timeout);
174
+
175
+ handle.process.once('exit', () => {
176
+ clearTimeout(timer);
177
+ resolve();
178
+ });
179
+
180
+ handle.process.once('error', (err) => {
181
+ clearTimeout(timer);
182
+ reject(err);
183
+ });
184
+ });
185
+ }
186
+
187
+ /**
188
+ * Output a structured JSON result to stdout.
189
+ */
190
+ function outputResult(data) {
191
+ process.stdout.write(JSON.stringify(data, null, 2) + '\n');
192
+ }
193
+
194
+ /**
195
+ * Output an error as JSON to stdout (so orchestrator can parse it).
196
+ */
197
+ function outputError(message) {
198
+ process.stdout.write(JSON.stringify({ status: 'error', error: message }) + '\n');
199
+ }
200
+
201
+ /**
202
+ * Minimal YAML parser for session.yaml (handles flat and one-level nested keys).
203
+ */
204
+ function parseSimpleYaml(content) {
205
+ const result = {};
206
+ let currentSection = null;
207
+
208
+ for (const line of content.split('\n')) {
209
+ if (line.trim() === '' || line.trim().startsWith('#')) continue;
210
+
211
+ const indent = line.search(/\S/);
212
+ const trimmed = line.trim();
213
+ const kvMatch = trimmed.match(/^([\w_-]+):\s*(.*)/);
214
+
215
+ if (kvMatch) {
216
+ const key = kvMatch[1];
217
+ const value = kvMatch[2].trim().replace(/^["']|["']$/g, '');
218
+
219
+ if (indent === 0) {
220
+ if (value === '' || value === undefined) {
221
+ // Section header
222
+ currentSection = key;
223
+ result[key] = result[key] || {};
224
+ } else {
225
+ result[key] = value;
226
+ currentSection = null;
227
+ }
228
+ } else if (currentSection && indent > 0) {
229
+ if (typeof result[currentSection] !== 'object') {
230
+ result[currentSection] = {};
231
+ }
232
+ result[currentSection][key] = value;
233
+ }
234
+ }
235
+ }
236
+
237
+ return result;
238
+ }
239
+
240
+ // Guard pattern (per project convention)
241
+ if (process.argv[1] === fileURLToPath(import.meta.url)) {
242
+ main().catch(err => {
243
+ outputError(err.message);
244
+ process.exit(1);
245
+ });
246
+ }
247
+
248
+ export { parseArgs, waitForExit, parseSimpleYaml };
@@ -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,