@bradygaster/squad-cli 0.8.4 → 0.8.16

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 (156) hide show
  1. package/README.md +2 -2
  2. package/dist/cli/commands/aspire.d.ts.map +1 -1
  3. package/dist/cli/commands/aspire.js +7 -8
  4. package/dist/cli/commands/aspire.js.map +1 -1
  5. package/dist/cli/commands/copilot-bridge.d.ts +42 -0
  6. package/dist/cli/commands/copilot-bridge.d.ts.map +1 -0
  7. package/dist/cli/commands/copilot-bridge.js +191 -0
  8. package/dist/cli/commands/copilot-bridge.js.map +1 -0
  9. package/dist/cli/commands/import.js.map +1 -1
  10. package/dist/cli/commands/rc-tunnel.d.ts +30 -0
  11. package/dist/cli/commands/rc-tunnel.d.ts.map +1 -0
  12. package/dist/cli/commands/rc-tunnel.js +107 -0
  13. package/dist/cli/commands/rc-tunnel.js.map +1 -0
  14. package/dist/cli/commands/rc.d.ts +13 -0
  15. package/dist/cli/commands/rc.d.ts.map +1 -0
  16. package/dist/cli/commands/rc.js +270 -0
  17. package/dist/cli/commands/rc.js.map +1 -0
  18. package/dist/cli/commands/start.d.ts +18 -0
  19. package/dist/cli/commands/start.d.ts.map +1 -0
  20. package/dist/cli/commands/start.js +219 -0
  21. package/dist/cli/commands/start.js.map +1 -0
  22. package/dist/cli/commands/upstream.js.map +1 -1
  23. package/dist/cli/commands/watch.d.ts +10 -0
  24. package/dist/cli/commands/watch.d.ts.map +1 -1
  25. package/dist/cli/commands/watch.js +181 -65
  26. package/dist/cli/commands/watch.js.map +1 -1
  27. package/dist/cli/core/cast.d.ts +40 -0
  28. package/dist/cli/core/cast.d.ts.map +1 -0
  29. package/dist/cli/core/cast.js +442 -0
  30. package/dist/cli/core/cast.js.map +1 -0
  31. package/dist/cli/core/gh-cli.d.ts +25 -0
  32. package/dist/cli/core/gh-cli.d.ts.map +1 -1
  33. package/dist/cli/core/gh-cli.js +15 -1
  34. package/dist/cli/core/gh-cli.js.map +1 -1
  35. package/dist/cli/core/init.d.ts +9 -1
  36. package/dist/cli/core/init.d.ts.map +1 -1
  37. package/dist/cli/core/init.js +108 -13
  38. package/dist/cli/core/init.js.map +1 -1
  39. package/dist/cli/core/migrations.js.map +1 -1
  40. package/dist/cli/core/nap.d.ts +37 -0
  41. package/dist/cli/core/nap.d.ts.map +1 -0
  42. package/dist/cli/core/nap.js +528 -0
  43. package/dist/cli/core/nap.js.map +1 -0
  44. package/dist/cli/core/output.d.ts +5 -0
  45. package/dist/cli/core/output.d.ts.map +1 -1
  46. package/dist/cli/core/output.js +7 -0
  47. package/dist/cli/core/output.js.map +1 -1
  48. package/dist/cli/core/upgrade.d.ts +0 -1
  49. package/dist/cli/core/upgrade.d.ts.map +1 -1
  50. package/dist/cli/core/upgrade.js.map +1 -1
  51. package/dist/cli/core/version.js.map +1 -1
  52. package/dist/cli/shell/agent-status.d.ts +11 -0
  53. package/dist/cli/shell/agent-status.d.ts.map +1 -0
  54. package/dist/cli/shell/agent-status.js +26 -0
  55. package/dist/cli/shell/agent-status.js.map +1 -0
  56. package/dist/cli/shell/commands.d.ts +10 -0
  57. package/dist/cli/shell/commands.d.ts.map +1 -1
  58. package/dist/cli/shell/commands.js +143 -29
  59. package/dist/cli/shell/commands.js.map +1 -1
  60. package/dist/cli/shell/components/AgentPanel.d.ts +1 -4
  61. package/dist/cli/shell/components/AgentPanel.d.ts.map +1 -1
  62. package/dist/cli/shell/components/AgentPanel.js +88 -6
  63. package/dist/cli/shell/components/AgentPanel.js.map +1 -1
  64. package/dist/cli/shell/components/App.d.ts +11 -6
  65. package/dist/cli/shell/components/App.d.ts.map +1 -1
  66. package/dist/cli/shell/components/App.js +212 -35
  67. package/dist/cli/shell/components/App.js.map +1 -1
  68. package/dist/cli/shell/components/ErrorBoundary.d.ts +22 -0
  69. package/dist/cli/shell/components/ErrorBoundary.d.ts.map +1 -0
  70. package/dist/cli/shell/components/ErrorBoundary.js +31 -0
  71. package/dist/cli/shell/components/ErrorBoundary.js.map +1 -0
  72. package/dist/cli/shell/components/InputPrompt.d.ts +3 -0
  73. package/dist/cli/shell/components/InputPrompt.d.ts.map +1 -1
  74. package/dist/cli/shell/components/InputPrompt.js +155 -13
  75. package/dist/cli/shell/components/InputPrompt.js.map +1 -1
  76. package/dist/cli/shell/components/MessageStream.d.ts +17 -4
  77. package/dist/cli/shell/components/MessageStream.d.ts.map +1 -1
  78. package/dist/cli/shell/components/MessageStream.js +215 -23
  79. package/dist/cli/shell/components/MessageStream.js.map +1 -1
  80. package/dist/cli/shell/components/Separator.d.ts +17 -0
  81. package/dist/cli/shell/components/Separator.d.ts.map +1 -0
  82. package/dist/cli/shell/components/Separator.js +10 -0
  83. package/dist/cli/shell/components/Separator.js.map +1 -0
  84. package/dist/cli/shell/components/ThinkingIndicator.d.ts +21 -0
  85. package/dist/cli/shell/components/ThinkingIndicator.d.ts.map +1 -0
  86. package/dist/cli/shell/components/ThinkingIndicator.js +102 -0
  87. package/dist/cli/shell/components/ThinkingIndicator.js.map +1 -0
  88. package/dist/cli/shell/components/index.d.ts +3 -0
  89. package/dist/cli/shell/components/index.d.ts.map +1 -1
  90. package/dist/cli/shell/components/index.js +2 -0
  91. package/dist/cli/shell/components/index.js.map +1 -1
  92. package/dist/cli/shell/coordinator.d.ts +10 -0
  93. package/dist/cli/shell/coordinator.d.ts.map +1 -1
  94. package/dist/cli/shell/coordinator.js +99 -4
  95. package/dist/cli/shell/coordinator.js.map +1 -1
  96. package/dist/cli/shell/error-messages.d.ts +21 -0
  97. package/dist/cli/shell/error-messages.d.ts.map +1 -0
  98. package/dist/cli/shell/error-messages.js +61 -0
  99. package/dist/cli/shell/error-messages.js.map +1 -0
  100. package/dist/cli/shell/index.d.ts +24 -3
  101. package/dist/cli/shell/index.d.ts.map +1 -1
  102. package/dist/cli/shell/index.js +949 -29
  103. package/dist/cli/shell/index.js.map +1 -1
  104. package/dist/cli/shell/lifecycle.d.ts +2 -0
  105. package/dist/cli/shell/lifecycle.d.ts.map +1 -1
  106. package/dist/cli/shell/lifecycle.js +59 -6
  107. package/dist/cli/shell/lifecycle.js.map +1 -1
  108. package/dist/cli/shell/memory.d.ts +6 -1
  109. package/dist/cli/shell/memory.d.ts.map +1 -1
  110. package/dist/cli/shell/memory.js +12 -1
  111. package/dist/cli/shell/memory.js.map +1 -1
  112. package/dist/cli/shell/router.d.ts +16 -0
  113. package/dist/cli/shell/router.d.ts.map +1 -1
  114. package/dist/cli/shell/router.js +27 -0
  115. package/dist/cli/shell/router.js.map +1 -1
  116. package/dist/cli/shell/session-store.d.ts +47 -0
  117. package/dist/cli/shell/session-store.d.ts.map +1 -0
  118. package/dist/cli/shell/session-store.js +125 -0
  119. package/dist/cli/shell/session-store.js.map +1 -0
  120. package/dist/cli/shell/sessions.d.ts +2 -0
  121. package/dist/cli/shell/sessions.d.ts.map +1 -1
  122. package/dist/cli/shell/sessions.js +19 -5
  123. package/dist/cli/shell/sessions.js.map +1 -1
  124. package/dist/cli/shell/shell-metrics.d.ts +34 -0
  125. package/dist/cli/shell/shell-metrics.d.ts.map +1 -0
  126. package/dist/cli/shell/shell-metrics.js +98 -0
  127. package/dist/cli/shell/shell-metrics.js.map +1 -0
  128. package/dist/cli/shell/spawn.d.ts.map +1 -1
  129. package/dist/cli/shell/spawn.js +20 -6
  130. package/dist/cli/shell/spawn.js.map +1 -1
  131. package/dist/cli/shell/terminal.d.ts +26 -0
  132. package/dist/cli/shell/terminal.d.ts.map +1 -1
  133. package/dist/cli/shell/terminal.js +65 -2
  134. package/dist/cli/shell/terminal.js.map +1 -1
  135. package/dist/cli/shell/theme-colors.d.ts +39 -0
  136. package/dist/cli/shell/theme-colors.d.ts.map +1 -0
  137. package/dist/cli/shell/theme-colors.js +39 -0
  138. package/dist/cli/shell/theme-colors.js.map +1 -0
  139. package/dist/cli/shell/types.d.ts +2 -0
  140. package/dist/cli/shell/types.d.ts.map +1 -1
  141. package/dist/cli/shell/useAnimation.d.ts +42 -0
  142. package/dist/cli/shell/useAnimation.d.ts.map +1 -0
  143. package/dist/cli/shell/useAnimation.js +139 -0
  144. package/dist/cli/shell/useAnimation.js.map +1 -0
  145. package/dist/cli-entry.d.ts +0 -7
  146. package/dist/cli-entry.d.ts.map +1 -1
  147. package/dist/cli-entry.js +701 -96
  148. package/dist/cli-entry.js.map +1 -1
  149. package/package.json +156 -140
  150. package/templates/orchestration-log.md +1 -1
  151. package/templates/package.json +3 -0
  152. package/templates/ralph-triage.js +543 -0
  153. package/templates/scribe-charter.md +1 -1
  154. package/templates/squad.agent.md +10 -10
  155. package/templates/workflows/squad-heartbeat.yml +52 -196
  156. package/templates/workflows/squad-insider-release.yml +1 -1
@@ -5,47 +5,171 @@
5
5
  * Manages CopilotSDK sessions and routes messages to agents/coordinator.
6
6
  */
7
7
  import { createRequire } from 'node:module';
8
+ import { existsSync, readFileSync, unlinkSync } from 'node:fs';
9
+ import { join } from 'node:path';
8
10
  import React from 'react';
9
11
  import { render } from 'ink';
10
12
  import { App } from './components/App.js';
13
+ import { ErrorBoundary } from './components/ErrorBoundary.js';
11
14
  import { SessionRegistry } from './sessions.js';
12
15
  import { ShellRenderer } from './render.js';
13
16
  import { StreamBridge } from './stream-bridge.js';
14
- import { ShellLifecycle } from './lifecycle.js';
17
+ import { ShellLifecycle, loadWelcomeData } from './lifecycle.js';
15
18
  import { SquadClient } from '@bradygaster/squad-sdk/client';
16
- import { buildCoordinatorPrompt, parseCoordinatorResponse } from './coordinator.js';
19
+ import { initSquadTelemetry, TIMEOUTS, StreamingPipeline, recordAgentSpawn, recordAgentDuration, recordAgentError, recordAgentDestroy, RuntimeEventBus } from '@bradygaster/squad-sdk';
20
+ import { enableShellMetrics, recordShellSessionDuration, recordAgentResponseLatency, recordShellError } from './shell-metrics.js';
21
+ import { buildCoordinatorPrompt, buildInitModePrompt, parseCoordinatorResponse, hasRosterEntries } from './coordinator.js';
17
22
  import { loadAgentCharter, buildAgentPrompt } from './spawn.js';
23
+ import { createSession, saveSession, loadLatestSession } from './session-store.js';
24
+ import { parseDispatchTargets } from './router.js';
25
+ import { agentSessionGuidance, genericGuidance, formatGuidance } from './error-messages.js';
26
+ import { parseCastResponse, createTeam, formatCastSummary } from '../core/cast.js';
18
27
  export { SessionRegistry } from './sessions.js';
19
28
  export { StreamBridge } from './stream-bridge.js';
20
29
  export { ShellRenderer } from './render.js';
21
30
  export { ShellLifecycle } from './lifecycle.js';
22
31
  export { spawnAgent, loadAgentCharter, buildAgentPrompt } from './spawn.js';
23
- export { buildCoordinatorPrompt, parseCoordinatorResponse, formatConversationContext } from './coordinator.js';
24
- export { parseInput } from './router.js';
32
+ export { buildCoordinatorPrompt, buildInitModePrompt, parseCoordinatorResponse, formatConversationContext, hasRosterEntries } from './coordinator.js';
33
+ export { parseInput, parseDispatchTargets } from './router.js';
25
34
  export { executeCommand } from './commands.js';
26
35
  export { MemoryManager, DEFAULT_LIMITS } from './memory.js';
27
36
  export { detectTerminal, safeChar, boxChars } from './terminal.js';
28
37
  export { createCompleter } from './autocomplete.js';
38
+ export { createSession, saveSession, loadLatestSession, listSessions, loadSessionById } from './session-store.js';
29
39
  export { App } from './components/App.js';
40
+ export { ErrorBoundary } from './components/ErrorBoundary.js';
41
+ export { sdkDisconnectGuidance, teamConfigGuidance, agentSessionGuidance, genericGuidance, formatGuidance, } from './error-messages.js';
42
+ export { enableShellMetrics, recordShellSessionDuration, recordAgentResponseLatency, recordShellError, isShellTelemetryEnabled, _resetShellMetrics, } from './shell-metrics.js';
30
43
  const require = createRequire(import.meta.url);
31
44
  const pkg = require('../../../package.json');
45
+ /**
46
+ * Approve all permission requests. CLI runs locally with user trust,
47
+ * so no interactive confirmation is needed.
48
+ */
49
+ const approveAllPermissions = () => ({ kind: 'approved' });
50
+ /** Debug logger — writes to stderr only when SQUAD_DEBUG=1. */
51
+ function debugLog(...args) {
52
+ if (process.env['SQUAD_DEBUG'] === '1') {
53
+ console.error('[SQUAD_DEBUG]', ...args);
54
+ }
55
+ }
56
+ /**
57
+ * Retry a send function when the response is empty (ghost response).
58
+ * Ghost responses occur when session.idle fires before assistant.message,
59
+ * causing sendAndWait() to return undefined or empty content.
60
+ */
61
+ export async function withGhostRetry(sendFn, options = {}) {
62
+ const maxRetries = options.maxRetries ?? 3;
63
+ const backoffMs = options.backoffMs ?? [1000, 2000, 4000];
64
+ const log = options.debugLog ?? (() => { });
65
+ const preview = options.promptPreview ?? '';
66
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
67
+ if (attempt > 0) {
68
+ log('ghost response detected', {
69
+ timestamp: new Date().toISOString(),
70
+ attempt,
71
+ promptPreview: preview.slice(0, 80),
72
+ });
73
+ options.onRetry?.(attempt, maxRetries);
74
+ const delay = backoffMs[attempt - 1] ?? backoffMs[backoffMs.length - 1] ?? 4000;
75
+ await new Promise(r => setTimeout(r, delay));
76
+ }
77
+ const result = await sendFn();
78
+ if (result)
79
+ return result;
80
+ }
81
+ log('ghost response: all retries exhausted', {
82
+ timestamp: new Date().toISOString(),
83
+ promptPreview: preview.slice(0, 80),
84
+ });
85
+ options.onExhausted?.(maxRetries);
86
+ return '';
87
+ }
32
88
  export async function runShell() {
89
+ // Ink requires a TTY for raw mode input — bail out early when piped (#576)
90
+ if (!process.stdin.isTTY) {
91
+ console.error('✗ Squad shell requires an interactive terminal (TTY).');
92
+ console.error(' Piped or redirected stdin is not supported.');
93
+ console.error(" Tip: Run 'squad --preview' for non-interactive usage.");
94
+ process.exit(1);
95
+ }
96
+ // Show immediate feedback — users need to see something within 100ms
97
+ console.error('◆ Loading Squad shell...');
98
+ // Configurable REPL timeout: SQUAD_REPL_TIMEOUT (seconds) > TIMEOUTS.SESSION_RESPONSE_MS (ms)
99
+ const replTimeoutMs = (() => {
100
+ const envSeconds = process.env['SQUAD_REPL_TIMEOUT'];
101
+ if (envSeconds) {
102
+ const parsed = parseInt(envSeconds, 10);
103
+ if (!isNaN(parsed) && parsed > 0)
104
+ return parsed * 1000;
105
+ }
106
+ return TIMEOUTS.SESSION_RESPONSE_MS;
107
+ })();
108
+ debugLog('REPL timeout:', replTimeoutMs, 'ms');
109
+ const sessionStart = Date.now();
110
+ let messageCount = 0;
33
111
  const registry = new SessionRegistry();
34
112
  const renderer = new ShellRenderer();
35
113
  const teamRoot = process.cwd();
114
+ // Session persistence — create or resume a previous session
115
+ // Skip resume on first run (no team.md or .first-run marker present)
116
+ const hasTeam = existsSync(join(teamRoot, '.squad', 'team.md'));
117
+ const isFirstRun = existsSync(join(teamRoot, '.squad', '.first-run'));
118
+ let persistedSession = createSession();
119
+ const recentSession = (hasTeam && !isFirstRun) ? loadLatestSession(teamRoot) : null;
120
+ if (recentSession) {
121
+ persistedSession = recentSession;
122
+ debugLog('resuming recent session', persistedSession.id);
123
+ }
124
+ // Initialize OpenTelemetry if endpoint is configured (e.g. Aspire dashboard)
125
+ const eventBus = new RuntimeEventBus();
126
+ const telemetry = initSquadTelemetry({ serviceName: 'squad-cli', mode: 'cli', eventBus });
127
+ if (telemetry.tracing || telemetry.metrics) {
128
+ debugLog('🔭 Telemetry active — exporting to ' + process.env['OTEL_EXPORTER_OTLP_ENDPOINT']);
129
+ }
130
+ // Streaming pipeline for token usage and response latency metrics
131
+ const streamingPipeline = new StreamingPipeline();
132
+ // Shell-level observability metrics (auto-enabled when OTel is configured)
133
+ const shellMetricsActive = enableShellMetrics();
134
+ if (shellMetricsActive) {
135
+ debugLog('shell observability metrics enabled');
136
+ }
36
137
  // Initialize lifecycle — discover team agents
37
138
  const lifecycle = new ShellLifecycle({ teamRoot, renderer, registry });
38
139
  try {
39
140
  await lifecycle.initialize();
40
141
  }
41
- catch {
142
+ catch (err) {
143
+ debugLog('lifecycle.initialize() failed:', err);
42
144
  // Non-fatal: shell works without discovered agents
43
145
  }
44
146
  // Create SDK client (auto-connects on first session creation)
45
147
  const client = new SquadClient({ cwd: teamRoot });
46
148
  let shellApi;
149
+ let origAddMessage;
47
150
  const agentSessions = new Map();
48
151
  let coordinatorSession = null;
152
+ let activeInitSession = null;
153
+ let pendingCastConfirmation = null;
154
+ // Eager SDK warm-up — start coordinator session before user's first message
155
+ // This runs in background so UI renders immediately
156
+ (async () => {
157
+ try {
158
+ debugLog('eager warm-up: creating coordinator session');
159
+ const systemPrompt = buildCoordinatorPrompt({ teamRoot });
160
+ coordinatorSession = await client.createSession({
161
+ streaming: true,
162
+ systemMessage: { mode: 'append', content: systemPrompt },
163
+ workingDirectory: teamRoot,
164
+ onPermissionRequest: approveAllPermissions,
165
+ });
166
+ debugLog('eager warm-up: coordinator session ready');
167
+ }
168
+ catch (err) {
169
+ debugLog('eager warm-up failed (non-fatal, will retry on first dispatch):', err);
170
+ // Non-fatal — first dispatch will create the session as before
171
+ }
172
+ })();
49
173
  const streamBuffers = new Map();
50
174
  // StreamBridge wires streaming pipeline events into Ink component state.
51
175
  const _bridge = new StreamBridge(registry, {
@@ -63,25 +187,45 @@ export async function runShell() {
63
187
  shellApi?.refreshAgents();
64
188
  },
65
189
  onError: (agentName, error) => {
190
+ debugLog(`StreamBridge error for ${agentName}:`, error);
191
+ streamBuffers.delete(agentName);
192
+ const friendly = error.message.replace(/^Error:\s*/i, '');
193
+ const guidance = agentSessionGuidance(agentName, friendly);
66
194
  shellApi?.addMessage({
67
195
  role: 'system',
68
- content: `❌ ${agentName}: ${error.message}`,
196
+ content: formatGuidance(guidance),
69
197
  timestamp: new Date(),
70
198
  });
71
199
  },
72
200
  });
73
201
  /** Extract text delta from an SDK session event. */
74
202
  function extractDelta(event) {
75
- const val = event['delta'] ?? event['content'];
76
- return typeof val === 'string' ? val : '';
203
+ const val = event['deltaContent'] ?? event['delta'] ?? event['content'];
204
+ const result = typeof val === 'string' ? val : '';
205
+ debugLog('extractDelta', { type: event['type'], keys: Object.keys(event), hasDeltaContent: 'deltaContent' in event, result: result.slice(0, 80) });
206
+ return result;
77
207
  }
78
208
  /**
79
209
  * Send a prompt and wait for the full streamed response.
80
210
  * Prefers sendAndWait (blocks until idle); falls back to sendMessage + turn_end event.
211
+ * Returns the full response content from sendAndWait as a fallback string.
81
212
  */
82
213
  async function awaitStreamedResponse(session, prompt) {
83
214
  if (session.sendAndWait) {
84
- await session.sendAndWait({ prompt }, 120_000);
215
+ debugLog('awaitStreamedResponse: using sendAndWait');
216
+ // ThinkingIndicator already shows elapsed time via its own timer;
217
+ // no need to override the current activity hint with generic text.
218
+ const result = await session.sendAndWait({ prompt }, replTimeoutMs);
219
+ debugLog('awaitStreamedResponse: sendAndWait returned', {
220
+ type: typeof result,
221
+ keys: result ? Object.keys(result) : [],
222
+ hasData: !!result?.['data'],
223
+ });
224
+ // Return full response content as fallback for when deltas weren't captured
225
+ const data = result?.['data'];
226
+ const content = typeof data?.['content'] === 'string' ? data['content'] : '';
227
+ debugLog('awaitStreamedResponse: fallback content length', content.length);
228
+ return content;
85
229
  }
86
230
  else {
87
231
  const done = new Promise((resolve) => {
@@ -101,12 +245,56 @@ export async function runShell() {
101
245
  });
102
246
  await session.sendMessage({ prompt });
103
247
  await done;
248
+ return '';
104
249
  }
105
250
  }
106
- /** Send a message to an agent session and stream the response. */
251
+ /** Convenience wrapper for withGhostRetry with shell UI integration. */
252
+ function ghostRetry(sendFn, promptPreview) {
253
+ return withGhostRetry(sendFn, {
254
+ debugLog,
255
+ promptPreview,
256
+ onRetry: (attempt, max) => {
257
+ const totalAttempts = max + 1; // max is retry count, +1 for initial attempt
258
+ const currentAttempt = attempt + 1; // attempt is retry number, +1 for total attempt number
259
+ shellApi?.addMessage({
260
+ role: 'system',
261
+ content: `⚠ Empty response detected. Retrying... (attempt ${currentAttempt}/${totalAttempts})`,
262
+ timestamp: new Date(),
263
+ });
264
+ },
265
+ onExhausted: (max) => {
266
+ const totalAttempts = max + 1;
267
+ shellApi?.addMessage({
268
+ role: 'system',
269
+ content: `❌ Agent did not respond after ${totalAttempts} attempts. Try again or run \`squad doctor\`.`,
270
+ timestamp: new Date(),
271
+ });
272
+ },
273
+ });
274
+ }
275
+ /**
276
+ * Send a message to an agent session and stream the response.
277
+ *
278
+ * **Streaming architecture:**
279
+ * 1. Register `message_delta` listener BEFORE sending message (ensures we catch all deltas)
280
+ * 2. Call `awaitStreamedResponse` which uses `sendAndWait` (blocks until session idle)
281
+ * 3. Accumulate deltas into `accumulated` via the `onDelta` handler
282
+ * 4. Fallback to `sendAndWait` result if no deltas were captured (ghost response handling)
283
+ * 5. Remove listener in finally block to prevent memory leaks
284
+ *
285
+ * Both agent and coordinator dispatch use identical event wiring patterns.
286
+ */
107
287
  async function dispatchToAgent(agentName, message) {
288
+ debugLog('dispatchToAgent:', agentName, message.slice(0, 120));
289
+ const dispatchStartMs = Date.now();
290
+ let firstTokenRecorded = false;
291
+ let dispatchError = false;
108
292
  let session = agentSessions.get(agentName);
109
293
  if (!session) {
294
+ shellApi?.setActivityHint(`Connecting to ${agentName}...`);
295
+ shellApi?.setAgentActivity(agentName, 'connecting...');
296
+ // Give React a tick to render the connection hint before blocking on SDK
297
+ await new Promise(resolve => setImmediate(resolve));
110
298
  const charter = loadAgentCharter(agentName, teamRoot);
111
299
  const systemPrompt = buildAgentPrompt(charter);
112
300
  if (!registry.get(agentName)) {
@@ -117,29 +305,137 @@ export async function runShell() {
117
305
  streaming: true,
118
306
  systemMessage: { mode: 'append', content: systemPrompt },
119
307
  workingDirectory: teamRoot,
308
+ onPermissionRequest: approveAllPermissions,
120
309
  });
121
310
  agentSessions.set(agentName, session);
122
311
  }
312
+ // Record agent spawn metric
313
+ recordAgentSpawn(agentName, 'direct');
314
+ // Attach streaming pipeline for token/latency metrics
315
+ const sid = session.sessionId ?? `agent-${agentName}-${Date.now()}`;
316
+ if (!streamingPipeline.isAttached(sid))
317
+ streamingPipeline.attachToSession(sid);
318
+ streamingPipeline.markMessageStart(sid);
123
319
  registry.updateStatus(agentName, 'streaming');
124
320
  shellApi?.refreshAgents();
321
+ shellApi?.setActivityHint(`${agentName} is thinking...`);
322
+ shellApi?.setAgentActivity(agentName, 'thinking...');
125
323
  let accumulated = '';
324
+ let deltaIndex = 0;
126
325
  const onDelta = (event) => {
326
+ debugLog('agent onDelta fired', agentName, { eventType: event['type'] });
127
327
  const delta = extractDelta(event);
128
328
  if (!delta)
129
329
  return;
330
+ if (!firstTokenRecorded) {
331
+ firstTokenRecorded = true;
332
+ recordAgentResponseLatency(agentName, Date.now() - dispatchStartMs, 'direct');
333
+ }
334
+ // Feed delta to streaming pipeline for TTFT/latency metrics
335
+ streamingPipeline.processEvent({
336
+ type: 'message_delta',
337
+ sessionId: sid,
338
+ agentName,
339
+ content: delta,
340
+ index: deltaIndex++,
341
+ timestamp: new Date(),
342
+ });
130
343
  accumulated += delta;
131
344
  shellApi?.setStreamingContent({ agentName, content: accumulated });
345
+ shellApi?.setActivityHint(undefined); // Clear hint once content is flowing
346
+ };
347
+ // Listen for usage events to record token metrics and capture model name
348
+ const onUsage = (event) => {
349
+ const inputTokens = typeof event['inputTokens'] === 'number' ? event['inputTokens'] : 0;
350
+ const outputTokens = typeof event['outputTokens'] === 'number' ? event['outputTokens'] : 0;
351
+ const model = typeof event['model'] === 'string' ? event['model'] : 'unknown';
352
+ const estimatedCost = typeof event['estimatedCost'] === 'number' ? event['estimatedCost'] : 0;
353
+ // Update model display in agent panel
354
+ registry.updateModel(agentName, model);
355
+ shellApi?.refreshAgents();
356
+ // Feed usage to streaming pipeline for token/duration metrics
357
+ streamingPipeline.processEvent({
358
+ type: 'usage',
359
+ sessionId: sid,
360
+ agentName,
361
+ model,
362
+ inputTokens,
363
+ outputTokens,
364
+ estimatedCost,
365
+ timestamp: new Date(),
366
+ });
132
367
  };
133
368
  session.on('message_delta', onDelta);
134
369
  try {
135
- await awaitStreamedResponse(session, message);
370
+ session.on('usage', onUsage);
371
+ }
372
+ catch { /* event may not exist */ }
373
+ // Listen for tool/activity events to show Copilot-style hints
374
+ const onToolCall = (event) => {
375
+ const toolName = event['toolName'] ?? event['name'] ?? event['tool'];
376
+ if (typeof toolName === 'string') {
377
+ const hintMap = {
378
+ 'read_file': 'Reading file...',
379
+ 'write_file': 'Writing file...',
380
+ 'edit_file': 'Editing file...',
381
+ 'run_command': 'Running command...',
382
+ 'search': 'Searching codebase...',
383
+ 'spawn_agent': `Spawning specialist...`,
384
+ 'analyze': 'Analyzing dependencies...',
385
+ };
386
+ const hint = hintMap[toolName] ?? `Using ${toolName}...`;
387
+ shellApi?.setActivityHint(hint);
388
+ registry.updateActivityHint(agentName, hint.replace(/\.\.\.$/, ''));
389
+ shellApi?.setAgentActivity(agentName, hint.replace(/\.\.\.$/, '').toLowerCase());
390
+ shellApi?.refreshAgents();
391
+ }
392
+ };
393
+ try {
394
+ session.on('tool_call', onToolCall);
395
+ }
396
+ catch { /* event may not exist */ }
397
+ try {
398
+ accumulated = await ghostRetry(async () => {
399
+ accumulated = '';
400
+ deltaIndex = 0;
401
+ const fallback = await awaitStreamedResponse(session, message);
402
+ debugLog('agent dispatch:', agentName, 'accumulated length', accumulated.length, 'fallback length', fallback.length);
403
+ if (!accumulated && fallback)
404
+ accumulated = fallback;
405
+ return accumulated;
406
+ }, message);
407
+ }
408
+ catch (err) {
409
+ dispatchError = true;
410
+ // Evict dead session so next attempt creates a fresh one
411
+ debugLog('dispatchToAgent: evicting dead session for', agentName, err);
412
+ recordShellError('agent_dispatch', agentName);
413
+ recordAgentError(agentName, 'dispatch_failure');
414
+ agentSessions.delete(agentName);
415
+ streamBuffers.delete(agentName);
416
+ throw err;
136
417
  }
137
418
  finally {
138
419
  try {
139
420
  session.off('message_delta', onDelta);
140
421
  }
141
422
  catch { /* session may not support off */ }
142
- shellApi?.setStreamingContent(null);
423
+ try {
424
+ session.off('usage', onUsage);
425
+ }
426
+ catch { /* ignore */ }
427
+ try {
428
+ session.off('tool_call', onToolCall);
429
+ }
430
+ catch { /* ignore */ }
431
+ // Record agent duration and destroy metrics
432
+ const durationMs = Date.now() - dispatchStartMs;
433
+ recordAgentDuration(agentName, durationMs, dispatchError ? 'error' : 'success');
434
+ recordAgentDestroy(agentName);
435
+ streamingPipeline.detachFromSession(sid);
436
+ shellApi?.clearAgentStream(agentName);
437
+ shellApi?.setActivityHint(undefined);
438
+ shellApi?.setAgentActivity(agentName, undefined);
143
439
  if (accumulated) {
144
440
  shellApi?.addMessage({
145
441
  role: 'agent',
@@ -152,37 +448,243 @@ export async function runShell() {
152
448
  shellApi?.refreshAgents();
153
449
  }
154
450
  }
155
- /** Send a message through the coordinator and route based on response. */
451
+ /**
452
+ * Send a message through the coordinator and route based on response.
453
+ *
454
+ * **Streaming architecture:**
455
+ * 1. Create coordinator session with `streaming: true` config
456
+ * 2. Register `message_delta` listener BEFORE sending message (ensures we catch all deltas)
457
+ * 3. Call `awaitStreamedResponse` which uses `sendAndWait` (blocks until session idle)
458
+ * 4. Accumulate deltas into `accumulated` via the `onDelta` handler
459
+ * 5. Fallback to `sendAndWait` result if no deltas were captured (ghost response handling)
460
+ * 6. Remove listener in finally block to prevent memory leaks
461
+ * 7. Parse accumulated response and route to agents or show direct answer
462
+ *
463
+ * Event wiring is identical to `dispatchToAgent` — both use the same `message_delta` pattern.
464
+ */
465
+ /** Extract a meaningful activity description from coordinator text near an agent name mention. */
466
+ function extractAgentHint(text, agentName) {
467
+ const lower = text.toLowerCase();
468
+ const nameIdx = lower.lastIndexOf(agentName.toLowerCase());
469
+ if (nameIdx === -1)
470
+ return 'working...';
471
+ const afterName = text.slice(nameIdx + agentName.length, nameIdx + agentName.length + 120);
472
+ const patterns = [
473
+ /^\s*(?:is|will|should|can)\s+(\w[\w\s,'-]{3,50}?)(?:[.\n;]|$)/i,
474
+ /^\s*[:\-→—]+\s*(\w[\w\s,'-]{3,50}?)(?:[.\n;]|$)/i,
475
+ /^\s+(?:to|for)\s+(\w[\w\s,'-]{3,50}?)(?:[.\n;]|$)/i,
476
+ ];
477
+ for (const pattern of patterns) {
478
+ const match = afterName.match(pattern);
479
+ if (match?.[1]) {
480
+ let hint = match[1].trim().replace(/[.…,;:\-]+$/, '').trim();
481
+ if (hint.length > 45)
482
+ hint = hint.slice(0, 42) + '...';
483
+ return hint.charAt(0).toUpperCase() + hint.slice(1);
484
+ }
485
+ }
486
+ return 'working...';
487
+ }
156
488
  async function dispatchToCoordinator(message) {
489
+ debugLog('dispatchToCoordinator: sending message', message.slice(0, 120));
490
+ const coordStartMs = Date.now();
491
+ let coordFirstToken = false;
492
+ let coordError = false;
157
493
  if (!coordinatorSession) {
494
+ shellApi?.setActivityHint('Connecting to SDK...');
495
+ // Give React a tick to render the connection hint before blocking on SDK
496
+ await new Promise(resolve => setImmediate(resolve));
158
497
  const systemPrompt = buildCoordinatorPrompt({ teamRoot });
159
498
  coordinatorSession = await client.createSession({
160
499
  streaming: true,
161
500
  systemMessage: { mode: 'append', content: systemPrompt },
162
501
  workingDirectory: teamRoot,
502
+ onPermissionRequest: approveAllPermissions,
503
+ });
504
+ debugLog('coordinator session created:', {
505
+ sessionId: coordinatorSession.sessionId,
506
+ hasOn: typeof coordinatorSession.on === 'function',
507
+ hasSendAndWait: typeof coordinatorSession.sendAndWait === 'function',
163
508
  });
164
509
  }
510
+ shellApi?.setActivityHint('Coordinator is thinking...');
511
+ // Record coordinator spawn metric
512
+ recordAgentSpawn('coordinator', 'coordinator');
513
+ const coordSid = coordinatorSession.sessionId ?? `coordinator-${Date.now()}`;
514
+ if (!streamingPipeline.isAttached(coordSid))
515
+ streamingPipeline.attachToSession(coordSid);
516
+ streamingPipeline.markMessageStart(coordSid);
517
+ // Build a set of known agent names for detecting mentions in coordinator text
518
+ const knownAgentNames = registry.getAll().map(a => a.name.toLowerCase());
165
519
  let accumulated = '';
520
+ let coordDeltaIndex = 0;
166
521
  const onDelta = (event) => {
522
+ debugLog('coordinator onDelta fired', { eventType: event['type'] });
167
523
  const delta = extractDelta(event);
168
524
  if (!delta)
169
525
  return;
526
+ if (!coordFirstToken) {
527
+ coordFirstToken = true;
528
+ recordAgentResponseLatency('coordinator', Date.now() - coordStartMs, 'coordinator');
529
+ }
530
+ // Feed delta to streaming pipeline for TTFT/latency metrics
531
+ streamingPipeline.processEvent({
532
+ type: 'message_delta',
533
+ sessionId: coordSid,
534
+ agentName: 'coordinator',
535
+ content: delta,
536
+ index: coordDeltaIndex++,
537
+ timestamp: new Date(),
538
+ });
170
539
  accumulated += delta;
171
- shellApi?.setStreamingContent({ agentName: 'coordinator', content: accumulated });
540
+ // Don't push coordinator routing text to streamingContent — it's internal
541
+ // routing instructions, not user-facing content. Keeping streamingContent
542
+ // empty lets the ThinkingIndicator stay visible with the "Routing..." hint.
543
+ // Parse streaming text for agent name mentions → update AgentPanel
544
+ for (const name of knownAgentNames) {
545
+ if (delta.toLowerCase().includes(name)) {
546
+ const displayName = registry.get(name)?.name ?? name;
547
+ registry.updateStatus(name, 'working');
548
+ // Extract task description from accumulated coordinator text
549
+ const hint = extractAgentHint(accumulated, name);
550
+ registry.updateActivityHint(name, hint);
551
+ shellApi?.setActivityHint(`${displayName} — ${hint}`);
552
+ shellApi?.setAgentActivity(name, hint);
553
+ shellApi?.refreshAgents();
554
+ }
555
+ }
556
+ };
557
+ // Listen for usage events to record token metrics
558
+ const onCoordUsage = (event) => {
559
+ const inputTokens = typeof event['inputTokens'] === 'number' ? event['inputTokens'] : 0;
560
+ const outputTokens = typeof event['outputTokens'] === 'number' ? event['outputTokens'] : 0;
561
+ const model = typeof event['model'] === 'string' ? event['model'] : 'unknown';
562
+ const estimatedCost = typeof event['estimatedCost'] === 'number' ? event['estimatedCost'] : 0;
563
+ streamingPipeline.processEvent({
564
+ type: 'usage',
565
+ sessionId: coordSid,
566
+ agentName: 'coordinator',
567
+ model,
568
+ inputTokens,
569
+ outputTokens,
570
+ estimatedCost,
571
+ timestamp: new Date(),
572
+ });
573
+ };
574
+ // Listen for tool/activity events (same pattern as dispatchToAgent)
575
+ const onToolCall = (event) => {
576
+ const toolName = event['toolName'] ?? event['name'] ?? event['tool'];
577
+ if (typeof toolName === 'string') {
578
+ const hintMap = {
579
+ 'read_file': 'Reading file...',
580
+ 'write_file': 'Writing file...',
581
+ 'edit_file': 'Editing file...',
582
+ 'run_command': 'Running command...',
583
+ 'search': 'Searching codebase...',
584
+ 'spawn_agent': 'Spawning agent...',
585
+ 'task': 'Dispatching to agent...',
586
+ 'analyze': 'Analyzing dependencies...',
587
+ };
588
+ // Try to extract agent name from task description (e.g., "🔧 Morpheus: Building effects")
589
+ const desc = typeof event['description'] === 'string' ? event['description'] : '';
590
+ const agentMatch = desc.match(/^\S*\s*(\w+):/);
591
+ const matchedAgent = agentMatch?.[1]?.toLowerCase();
592
+ if (matchedAgent && knownAgentNames.includes(matchedAgent)) {
593
+ registry.updateStatus(matchedAgent, 'working');
594
+ const taskSummary = desc.replace(/^\S*\s*\w+:\s*/, '').slice(0, 60);
595
+ registry.updateActivityHint(matchedAgent, taskSummary || 'working...');
596
+ shellApi?.setActivityHint(`${registry.get(matchedAgent)?.name ?? matchedAgent} — ${taskSummary || 'working'}...`);
597
+ shellApi?.setAgentActivity(matchedAgent, taskSummary || 'working...');
598
+ shellApi?.refreshAgents();
599
+ }
600
+ else {
601
+ const hint = hintMap[toolName] ?? `Using ${toolName}...`;
602
+ shellApi?.setActivityHint(hint);
603
+ }
604
+ }
172
605
  };
173
- coordinatorSession.on('message_delta', onDelta);
606
+ const activeCoordSession = coordinatorSession;
607
+ // Wire event listeners BEFORE sending the message to ensure we catch all events
608
+ activeCoordSession.on('message_delta', onDelta);
609
+ try {
610
+ activeCoordSession.on('usage', onCoordUsage);
611
+ }
612
+ catch { /* event may not exist */ }
174
613
  try {
175
- await awaitStreamedResponse(coordinatorSession, message);
614
+ activeCoordSession.on('tool_call', onToolCall);
615
+ }
616
+ catch { /* event may not exist */ }
617
+ debugLog('coordinator message_delta + usage + tool_call listeners registered');
618
+ try {
619
+ accumulated = await ghostRetry(async () => {
620
+ accumulated = '';
621
+ coordDeltaIndex = 0;
622
+ debugLog('coordinator: starting awaitStreamedResponse');
623
+ const fallback = await awaitStreamedResponse(activeCoordSession, message);
624
+ debugLog('coordinator dispatch: accumulated length', accumulated.length, 'fallback length', fallback.length);
625
+ if (!accumulated && fallback) {
626
+ debugLog('coordinator: using sendAndWait fallback content');
627
+ accumulated = fallback;
628
+ }
629
+ return accumulated;
630
+ }, message);
631
+ debugLog('coordinator: final accumulated length', accumulated.length);
632
+ }
633
+ catch (err) {
634
+ coordError = true;
635
+ // Evict dead coordinator session so next attempt creates a fresh one
636
+ debugLog('dispatchToCoordinator: evicting dead coordinator session', err);
637
+ recordShellError('coordinator_dispatch');
638
+ recordAgentError('coordinator', 'dispatch_failure');
639
+ coordinatorSession = null;
640
+ streamBuffers.delete('coordinator');
641
+ throw err;
176
642
  }
177
643
  finally {
178
644
  try {
179
- coordinatorSession.off('message_delta', onDelta);
645
+ activeCoordSession.off('message_delta', onDelta);
646
+ debugLog('coordinator message_delta listener removed');
180
647
  }
181
648
  catch { /* session may not support off */ }
182
- shellApi?.setStreamingContent(null);
649
+ try {
650
+ activeCoordSession.off('usage', onCoordUsage);
651
+ }
652
+ catch { /* ignore */ }
653
+ try {
654
+ activeCoordSession.off('tool_call', onToolCall);
655
+ }
656
+ catch { /* ignore */ }
657
+ // Record coordinator duration and destroy metrics
658
+ const coordDurationMs = Date.now() - coordStartMs;
659
+ recordAgentDuration('coordinator', coordDurationMs, coordError ? 'error' : 'success');
660
+ recordAgentDestroy('coordinator');
661
+ streamingPipeline.detachFromSession(coordSid);
662
+ shellApi?.clearAgentStream('coordinator');
663
+ // Reset any agents that were marked working during coordinator dispatch
664
+ for (const name of knownAgentNames) {
665
+ const agent = registry.get(name);
666
+ if (agent && (agent.status === 'working' || agent.status === 'streaming')) {
667
+ registry.updateStatus(name, 'idle');
668
+ shellApi?.setAgentActivity(name, undefined);
669
+ }
670
+ }
671
+ // Re-sync registry from team.md for any new agents added by coordinator
672
+ const freshRoster = loadWelcomeData(teamRoot);
673
+ if (freshRoster) {
674
+ for (const agent of freshRoster.agents) {
675
+ const lname = agent.name.toLowerCase();
676
+ if (!registry.get(lname)) {
677
+ registry.register(agent.name, agent.role);
678
+ }
679
+ }
680
+ }
681
+ shellApi?.refreshWelcome();
682
+ shellApi?.refreshAgents();
183
683
  }
184
684
  // Parse routing decision from coordinator response
685
+ debugLog('coordinator accumulated (first 200 chars)', accumulated.slice(0, 200));
185
686
  const decision = parseCoordinatorResponse(accumulated);
687
+ debugLog('coordinator decision', { type: decision.type, hasRoutes: !!(decision.routes?.length), hasDirectAnswer: !!decision.directAnswer });
186
688
  if (decision.type === 'route' && decision.routes?.length) {
187
689
  for (const route of decision.routes) {
188
690
  shellApi?.addMessage({
@@ -214,42 +716,435 @@ export async function runShell() {
214
716
  });
215
717
  }
216
718
  }
719
+ /** Cancel all active operations (called on Ctrl+C during processing). */
720
+ async function handleCancel() {
721
+ debugLog('handleCancel: aborting active sessions');
722
+ // Abort init session if active
723
+ if (activeInitSession) {
724
+ try {
725
+ await activeInitSession.abort?.();
726
+ debugLog('aborted init session');
727
+ }
728
+ catch (err) {
729
+ debugLog('abort init failed:', err);
730
+ }
731
+ activeInitSession = null;
732
+ }
733
+ // Clear pending cast confirmation
734
+ pendingCastConfirmation = null;
735
+ // Abort coordinator session
736
+ if (coordinatorSession) {
737
+ try {
738
+ await coordinatorSession.abort?.();
739
+ }
740
+ catch (err) {
741
+ debugLog('abort coordinator failed:', err);
742
+ }
743
+ }
744
+ // Abort all agent sessions
745
+ for (const [name, session] of agentSessions) {
746
+ try {
747
+ await session.abort?.();
748
+ debugLog(`aborted session: ${name}`);
749
+ }
750
+ catch (err) {
751
+ debugLog(`abort ${name} failed:`, err);
752
+ }
753
+ }
754
+ // Clear streaming state
755
+ streamBuffers.clear();
756
+ shellApi?.setStreamingContent(null);
757
+ shellApi?.setActivityHint(undefined);
758
+ shellApi?.addMessage({
759
+ role: 'system',
760
+ content: 'Operation cancelled.',
761
+ timestamp: new Date(),
762
+ });
763
+ }
764
+ /**
765
+ * Init Mode — cast a team when the roster is empty.
766
+ * Creates a temporary coordinator session with Init Mode instructions,
767
+ * sends the user's message, parses the team proposal, creates files,
768
+ * and then re-dispatches the original message to the now-populated team.
769
+ */
770
+ async function handleInitCast(parsed, skipConfirmation) {
771
+ debugLog('handleInitCast: entering Init Mode');
772
+ shellApi?.setProcessing(true);
773
+ // Check for a stored init prompt (from `squad init "prompt"`)
774
+ const initPromptFile = join(teamRoot, '.squad', '.init-prompt');
775
+ let castPrompt = parsed.raw;
776
+ if (existsSync(initPromptFile)) {
777
+ const storedPrompt = readFileSync(initPromptFile, 'utf-8').trim();
778
+ if (storedPrompt) {
779
+ debugLog('handleInitCast: using stored init prompt', storedPrompt.slice(0, 100));
780
+ castPrompt = storedPrompt;
781
+ }
782
+ }
783
+ shellApi?.addMessage({
784
+ role: 'system',
785
+ content: '🏗️ No team yet — casting one based on your project...',
786
+ timestamp: new Date(),
787
+ });
788
+ shellApi?.setActivityHint('Casting your team...');
789
+ // Create a temporary Init Mode coordinator session
790
+ let initSession = null;
791
+ try {
792
+ const initSysPrompt = buildInitModePrompt({ teamRoot });
793
+ initSession = await client.createSession({
794
+ streaming: true,
795
+ systemMessage: { mode: 'append', content: initSysPrompt },
796
+ workingDirectory: teamRoot,
797
+ onPermissionRequest: approveAllPermissions,
798
+ });
799
+ activeInitSession = initSession;
800
+ debugLog('handleInitCast: init session created');
801
+ // Send the prompt and collect the response
802
+ let accumulated = '';
803
+ const onDelta = (event) => {
804
+ const delta = extractDelta(event);
805
+ if (delta)
806
+ accumulated += delta;
807
+ };
808
+ initSession.on('message_delta', onDelta);
809
+ try {
810
+ accumulated = await ghostRetry(async () => {
811
+ accumulated = '';
812
+ const fallback = await awaitStreamedResponse(initSession, castPrompt);
813
+ if (!accumulated && fallback)
814
+ accumulated = fallback;
815
+ return accumulated;
816
+ }, castPrompt);
817
+ }
818
+ finally {
819
+ try {
820
+ initSession.off('message_delta', onDelta);
821
+ }
822
+ catch { /* ignore */ }
823
+ }
824
+ debugLog('handleInitCast: response length', accumulated.length);
825
+ debugLog('handleInitCast: response preview', accumulated.slice(0, 500));
826
+ // Parse the team proposal
827
+ const proposal = parseCastResponse(accumulated);
828
+ if (!proposal) {
829
+ debugLog('handleInitCast: failed to parse team from response');
830
+ debugLog('handleInitCast: full response:', accumulated);
831
+ shellApi?.addMessage({
832
+ role: 'system',
833
+ content: [
834
+ '⚠ Could not parse a team proposal from the model response.',
835
+ '',
836
+ 'Try again, or run: squad init "describe your project"',
837
+ ].join('\n'),
838
+ timestamp: new Date(),
839
+ });
840
+ return;
841
+ }
842
+ // Show the proposed team
843
+ shellApi?.addMessage({
844
+ role: 'agent',
845
+ agentName: 'coordinator',
846
+ content: `Team proposed:\n\n${formatCastSummary(proposal)}\n\nUniverse: ${proposal.universe}`,
847
+ timestamp: new Date(),
848
+ });
849
+ // Close the init session — it's no longer needed after parsing the proposal
850
+ try {
851
+ await initSession.close?.();
852
+ }
853
+ catch { /* ignore */ }
854
+ initSession = null;
855
+ activeInitSession = null;
856
+ // P2: Cast confirmation — require user approval for freeform REPL casts
857
+ if (!skipConfirmation) {
858
+ shellApi?.addMessage({
859
+ role: 'system',
860
+ content: 'Look good? Type **y** to confirm or **n** to cancel.',
861
+ timestamp: new Date(),
862
+ });
863
+ pendingCastConfirmation = { proposal, parsed };
864
+ shellApi?.setActivityHint(undefined);
865
+ shellApi?.setProcessing(false);
866
+ return;
867
+ }
868
+ // Auto-confirmed path (auto-cast or /init command) — create team immediately
869
+ await finalizeCast(proposal, parsed);
870
+ }
871
+ catch (err) {
872
+ debugLog('handleInitCast error:', err);
873
+ recordShellError('init_cast', err instanceof Error ? err.constructor.name : 'unknown');
874
+ shellApi?.addMessage({
875
+ role: 'system',
876
+ content: `⚠ Team casting failed: ${err instanceof Error ? err.message : String(err)}\nTry again or edit .squad/team.md directly.`,
877
+ timestamp: new Date(),
878
+ });
879
+ }
880
+ finally {
881
+ if (initSession) {
882
+ try {
883
+ await initSession.close?.();
884
+ }
885
+ catch { /* ignore */ }
886
+ }
887
+ activeInitSession = null;
888
+ shellApi?.setActivityHint(undefined);
889
+ shellApi?.setProcessing(false);
890
+ }
891
+ }
892
+ /**
893
+ * Finalize a confirmed cast — create team files, register agents, re-dispatch.
894
+ * Shared by the auto-confirmed path and the pending-confirmation accept path.
895
+ */
896
+ async function finalizeCast(proposal, parsed) {
897
+ shellApi?.setActivityHint('Creating team files...');
898
+ const result = await createTeam(teamRoot, proposal);
899
+ debugLog('finalizeCast: team created', {
900
+ members: result.membersCreated.length,
901
+ files: result.filesCreated.length,
902
+ });
903
+ shellApi?.addMessage({
904
+ role: 'system',
905
+ content: `✅ Team hired! ${result.membersCreated.length} members created.`,
906
+ timestamp: new Date(),
907
+ });
908
+ // Clean up stored init prompt (it's been consumed)
909
+ const initPromptFile = join(teamRoot, '.squad', '.init-prompt');
910
+ if (existsSync(initPromptFile)) {
911
+ try {
912
+ unlinkSync(initPromptFile);
913
+ }
914
+ catch { /* ignore */ }
915
+ }
916
+ // Invalidate the old coordinator session so the next dispatch builds one
917
+ // with the real team roster
918
+ if (coordinatorSession) {
919
+ try {
920
+ await coordinatorSession.abort?.();
921
+ }
922
+ catch { /* ignore */ }
923
+ coordinatorSession = null;
924
+ streamBuffers.delete('coordinator');
925
+ }
926
+ // Register the new agents in the session registry
927
+ for (const member of proposal.members) {
928
+ const roleName = member.role || 'Agent';
929
+ registry.register(member.name, roleName);
930
+ }
931
+ // Refresh the header box to show new team roster
932
+ shellApi?.refreshWelcome();
933
+ shellApi?.setActivityHint('Routing your message to the team...');
934
+ // Re-dispatch the original message — now with a populated roster
935
+ shellApi?.addMessage({
936
+ role: 'system',
937
+ content: '📌 Routing your message to the team now...',
938
+ timestamp: new Date(),
939
+ });
940
+ await dispatchToCoordinator(parsed.content ?? parsed.raw);
941
+ shellApi?.setActivityHint(undefined);
942
+ }
217
943
  /** Handle dispatching parsed input to agents or coordinator. */
218
944
  async function handleDispatch(parsed) {
945
+ // P2: Handle pending cast confirmation before any other dispatch
946
+ if (pendingCastConfirmation) {
947
+ const input = parsed.raw.trim().toLowerCase();
948
+ const { proposal, parsed: originalParsed } = pendingCastConfirmation;
949
+ pendingCastConfirmation = null;
950
+ if (input === 'y' || input === 'yes') {
951
+ try {
952
+ await finalizeCast(proposal, originalParsed);
953
+ }
954
+ catch (err) {
955
+ debugLog('finalizeCast error:', err);
956
+ recordShellError('init_cast', err instanceof Error ? err.constructor.name : 'unknown');
957
+ shellApi?.addMessage({
958
+ role: 'system',
959
+ content: `⚠ Team casting failed: ${err instanceof Error ? err.message : String(err)}\nTry again or edit .squad/team.md directly.`,
960
+ timestamp: new Date(),
961
+ });
962
+ }
963
+ }
964
+ else {
965
+ shellApi?.addMessage({
966
+ role: 'system',
967
+ content: 'Cast cancelled. Describe what you\'re building to try again.',
968
+ timestamp: new Date(),
969
+ });
970
+ }
971
+ return;
972
+ }
973
+ // Guard: require a Squad team before processing work requests
974
+ const teamFile = join(teamRoot, '.squad', 'team.md');
975
+ if (!existsSync(teamFile)) {
976
+ shellApi?.addMessage({
977
+ role: 'system',
978
+ content: '\u26A0 No Squad team found. Run /init to create your team first.',
979
+ timestamp: new Date(),
980
+ });
981
+ return;
982
+ }
983
+ // Check if roster is actually populated — if not, enter Init Mode (cast a team)
984
+ const teamContent = readFileSync(teamFile, 'utf-8');
985
+ if (!hasRosterEntries(teamContent)) {
986
+ await handleInitCast(parsed, parsed.skipCastConfirmation);
987
+ return;
988
+ }
989
+ messageCount++;
219
990
  try {
220
- if (parsed.type === 'direct_agent' && parsed.agentName) {
991
+ // Check for multiple @agent mentions for parallel dispatch
992
+ const knownAgents = registry.getAll().map(s => s.name);
993
+ const targets = parseDispatchTargets(parsed.raw, knownAgents);
994
+ if (targets.agents.length > 1) {
995
+ debugLog('handleDispatch: multi-agent dispatch detected', {
996
+ agents: targets.agents,
997
+ contentPreview: targets.content.slice(0, 80),
998
+ });
999
+ for (const agent of targets.agents) {
1000
+ shellApi?.addMessage({
1001
+ role: 'system',
1002
+ content: `📌 Dispatching to ${agent} (parallel)`,
1003
+ timestamp: new Date(),
1004
+ });
1005
+ }
1006
+ const results = await Promise.allSettled(targets.agents.map(agent => dispatchToAgent(agent, targets.content || parsed.raw)));
1007
+ for (let i = 0; i < results.length; i++) {
1008
+ const r = results[i];
1009
+ if (r.status === 'rejected') {
1010
+ debugLog('handleDispatch: parallel agent failed', targets.agents[i], r.reason);
1011
+ shellApi?.addMessage({
1012
+ role: 'system',
1013
+ content: `⚠ ${targets.agents[i]} failed: ${r.reason instanceof Error ? r.reason.message : String(r.reason)}`,
1014
+ timestamp: new Date(),
1015
+ });
1016
+ }
1017
+ }
1018
+ }
1019
+ else if (parsed.type === 'direct_agent' && parsed.agentName) {
1020
+ debugLog('handleDispatch: single agent dispatch', { agent: parsed.agentName });
221
1021
  await dispatchToAgent(parsed.agentName, parsed.content ?? parsed.raw);
222
1022
  }
223
1023
  else if (parsed.type === 'coordinator') {
1024
+ debugLog('handleDispatch: routing through coordinator');
224
1025
  await dispatchToCoordinator(parsed.content ?? parsed.raw);
225
1026
  }
226
1027
  }
227
1028
  catch (err) {
1029
+ debugLog('handleDispatch error:', err);
1030
+ recordShellError('dispatch', err instanceof Error ? err.constructor.name : 'unknown');
228
1031
  const errorMsg = err instanceof Error ? err.message : String(err);
1032
+ const friendly = errorMsg.replace(/^Error:\s*/i, '');
1033
+ // Only show raw error detail when SQUAD_DEBUG=1; otherwise keep it generic
1034
+ const detail = process.env['SQUAD_DEBUG'] === '1' ? friendly : 'Something went wrong processing your message.';
229
1035
  if (shellApi) {
1036
+ const guidance = genericGuidance(detail);
230
1037
  shellApi.addMessage({
231
1038
  role: 'system',
232
- content: `❌ ${errorMsg}`,
1039
+ content: formatGuidance(guidance),
233
1040
  timestamp: new Date(),
234
1041
  });
235
1042
  }
236
1043
  }
237
1044
  }
238
- const { waitUntilExit } = render(React.createElement(App, {
1045
+ /** Auto-save session when messages change. */
1046
+ let shellMessages = [];
1047
+ function autoSave() {
1048
+ persistedSession.messages = shellMessages;
1049
+ try {
1050
+ saveSession(teamRoot, persistedSession);
1051
+ }
1052
+ catch (err) {
1053
+ debugLog('autoSave failed:', err);
1054
+ }
1055
+ }
1056
+ /** Callback for /resume command — replaces current messages with restored session. */
1057
+ function onRestoreSession(session) {
1058
+ persistedSession = session;
1059
+ // Clear old messages and terminal to prevent content bleed-through
1060
+ shellApi?.clearMessages();
1061
+ process.stdout.write('\x1b[2J\x1b[3J\x1b[H');
1062
+ // Use unwrapped addMessage to avoid per-message autoSave and duplicate pushes
1063
+ for (const msg of session.messages) {
1064
+ origAddMessage?.(msg);
1065
+ }
1066
+ shellMessages = [...session.messages];
1067
+ autoSave();
1068
+ }
1069
+ // Clear terminal and scrollback — prevents old scaffold output from
1070
+ // bleeding through above the header box in extended sessions.
1071
+ process.stdout.write('\x1b[2J\x1b[3J\x1b[H');
1072
+ const { waitUntilExit } = render(React.createElement(ErrorBoundary, null, React.createElement(App, {
239
1073
  registry,
240
1074
  renderer,
241
1075
  teamRoot,
242
1076
  version: pkg.version,
243
- onReady: (api) => { shellApi = api; },
1077
+ onReady: (api) => {
1078
+ // Wrap addMessage to auto-save on every message
1079
+ const origAdd = api.addMessage;
1080
+ origAddMessage = origAdd;
1081
+ api.addMessage = (msg) => {
1082
+ origAdd(msg);
1083
+ shellMessages.push(msg);
1084
+ autoSave();
1085
+ };
1086
+ shellApi = api;
1087
+ // Restore messages from resumed session
1088
+ if (recentSession && recentSession.messages.length > 0) {
1089
+ for (const msg of recentSession.messages) {
1090
+ origAdd(msg);
1091
+ }
1092
+ shellMessages = [...recentSession.messages];
1093
+ origAdd({
1094
+ role: 'system',
1095
+ content: `✓ Resumed session ${recentSession.id.slice(0, 8)} (${recentSession.messages.length} messages)`,
1096
+ timestamp: new Date(),
1097
+ });
1098
+ }
1099
+ // Bug fix #3: Clean up orphan .init-prompt if team already exists
1100
+ const initPromptPath = join(teamRoot, '.squad', '.init-prompt');
1101
+ const teamFilePath = join(teamRoot, '.squad', 'team.md');
1102
+ if (existsSync(teamFilePath)) {
1103
+ const tc = readFileSync(teamFilePath, 'utf-8');
1104
+ if (hasRosterEntries(tc) && existsSync(initPromptPath)) {
1105
+ debugLog('Cleaning up orphan .init-prompt (team already exists)');
1106
+ try {
1107
+ unlinkSync(initPromptPath);
1108
+ }
1109
+ catch { /* ignore */ }
1110
+ }
1111
+ }
1112
+ // Bug fix #1: Auto-cast after shellApi is guaranteed to be set (no race condition)
1113
+ if (existsSync(initPromptPath) && existsSync(teamFilePath)) {
1114
+ const tc = readFileSync(teamFilePath, 'utf-8');
1115
+ if (!hasRosterEntries(tc)) {
1116
+ const storedPrompt = readFileSync(initPromptPath, 'utf-8').trim();
1117
+ if (storedPrompt) {
1118
+ debugLog('Auto-cast: .init-prompt found with empty roster, triggering cast');
1119
+ // Trigger cast after Ink settles, but now shellApi is guaranteed to be set
1120
+ setTimeout(() => {
1121
+ handleInitCast({ type: 'coordinator', raw: storedPrompt, content: storedPrompt }, true).catch(err => {
1122
+ debugLog('Auto-cast error:', err);
1123
+ });
1124
+ }, 100);
1125
+ }
1126
+ }
1127
+ }
1128
+ },
244
1129
  onDispatch: handleDispatch,
245
- }), { exitOnCtrlC: false });
1130
+ onCancel: handleCancel,
1131
+ onRestoreSession,
1132
+ })), { exitOnCtrlC: false, patchConsole: false });
1133
+ // Clear the loading message now that Ink is rendering
1134
+ process.stderr.write('\r\x1b[K');
246
1135
  await waitUntilExit();
1136
+ // Record shell session duration before cleanup
1137
+ recordShellSessionDuration(Date.now() - sessionStart);
1138
+ // Final session save before cleanup
1139
+ autoSave();
247
1140
  // Cleanup: close all sessions and disconnect
248
- for (const [, session] of agentSessions) {
1141
+ for (const [name, session] of agentSessions) {
249
1142
  try {
250
1143
  await session.close();
251
1144
  }
252
- catch { /* best-effort cleanup */ }
1145
+ catch (err) {
1146
+ debugLog(`Failed to close session for ${name}:`, err);
1147
+ }
253
1148
  }
254
1149
  // coordinatorSession is assigned inside dispatchToCoordinator closure;
255
1150
  // TS control flow can't see the mutation, so assert the type.
@@ -258,16 +1153,41 @@ export async function runShell() {
258
1153
  try {
259
1154
  await coordSession.close();
260
1155
  }
261
- catch { /* best-effort cleanup */ }
1156
+ catch (err) {
1157
+ debugLog('Failed to close coordinator session:', err);
1158
+ }
262
1159
  }
263
1160
  try {
264
1161
  await client.disconnect();
265
1162
  }
266
- catch { /* best-effort cleanup */ }
1163
+ catch (err) {
1164
+ debugLog('Failed to disconnect client:', err);
1165
+ }
267
1166
  try {
268
1167
  await lifecycle.shutdown();
269
1168
  }
270
- catch { /* best-effort cleanup */ }
271
- console.log('👋 Squad out.');
1169
+ catch (err) {
1170
+ debugLog('Failed to shutdown lifecycle:', err);
1171
+ }
1172
+ try {
1173
+ await telemetry.shutdown();
1174
+ }
1175
+ catch (err) {
1176
+ debugLog('Failed to shutdown telemetry:', err);
1177
+ }
1178
+ // NO_COLOR-aware exit message with session summary
1179
+ const nc = process.env['NO_COLOR'] != null && process.env['NO_COLOR'] !== '';
1180
+ const prefix = nc ? '-- ' : '\x1b[36m--\x1b[0m ';
1181
+ if (messageCount > 0) {
1182
+ const elapsedMs = Date.now() - sessionStart;
1183
+ const mins = Math.round(elapsedMs / 60000);
1184
+ const durationStr = mins >= 1 ? `${mins} min` : '<1 min';
1185
+ const agentNames = [...agentSessions.keys()];
1186
+ const agentStr = agentNames.length > 0 ? ` with ${agentNames.join(', ')}.` : '';
1187
+ console.log(`${prefix}Squad out. ${durationStr}${agentStr} ${messageCount} message${messageCount === 1 ? '' : 's'}.`);
1188
+ }
1189
+ else {
1190
+ console.log(`${prefix}Squad out.`);
1191
+ }
272
1192
  }
273
1193
  //# sourceMappingURL=index.js.map