@bradygaster/squad-cli 0.8.5 → 0.8.17-preview

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 (153) hide show
  1. package/README.md +2 -2
  2. package/dist/cli/commands/copilot-bridge.d.ts +42 -0
  3. package/dist/cli/commands/copilot-bridge.d.ts.map +1 -0
  4. package/dist/cli/commands/copilot-bridge.js +191 -0
  5. package/dist/cli/commands/copilot-bridge.js.map +1 -0
  6. package/dist/cli/commands/import.js.map +1 -1
  7. package/dist/cli/commands/rc-tunnel.d.ts +30 -0
  8. package/dist/cli/commands/rc-tunnel.d.ts.map +1 -0
  9. package/dist/cli/commands/rc-tunnel.js +107 -0
  10. package/dist/cli/commands/rc-tunnel.js.map +1 -0
  11. package/dist/cli/commands/rc.d.ts +13 -0
  12. package/dist/cli/commands/rc.d.ts.map +1 -0
  13. package/dist/cli/commands/rc.js +270 -0
  14. package/dist/cli/commands/rc.js.map +1 -0
  15. package/dist/cli/commands/start.d.ts +18 -0
  16. package/dist/cli/commands/start.d.ts.map +1 -0
  17. package/dist/cli/commands/start.js +219 -0
  18. package/dist/cli/commands/start.js.map +1 -0
  19. package/dist/cli/commands/upstream.js.map +1 -1
  20. package/dist/cli/commands/watch.d.ts +10 -0
  21. package/dist/cli/commands/watch.d.ts.map +1 -1
  22. package/dist/cli/commands/watch.js +181 -65
  23. package/dist/cli/commands/watch.js.map +1 -1
  24. package/dist/cli/core/cast.d.ts +40 -0
  25. package/dist/cli/core/cast.d.ts.map +1 -0
  26. package/dist/cli/core/cast.js +442 -0
  27. package/dist/cli/core/cast.js.map +1 -0
  28. package/dist/cli/core/gh-cli.d.ts +25 -0
  29. package/dist/cli/core/gh-cli.d.ts.map +1 -1
  30. package/dist/cli/core/gh-cli.js +15 -1
  31. package/dist/cli/core/gh-cli.js.map +1 -1
  32. package/dist/cli/core/init.d.ts +9 -1
  33. package/dist/cli/core/init.d.ts.map +1 -1
  34. package/dist/cli/core/init.js +108 -13
  35. package/dist/cli/core/init.js.map +1 -1
  36. package/dist/cli/core/migrations.js.map +1 -1
  37. package/dist/cli/core/nap.d.ts +37 -0
  38. package/dist/cli/core/nap.d.ts.map +1 -0
  39. package/dist/cli/core/nap.js +528 -0
  40. package/dist/cli/core/nap.js.map +1 -0
  41. package/dist/cli/core/output.d.ts +5 -0
  42. package/dist/cli/core/output.d.ts.map +1 -1
  43. package/dist/cli/core/output.js +7 -0
  44. package/dist/cli/core/output.js.map +1 -1
  45. package/dist/cli/core/upgrade.d.ts +0 -1
  46. package/dist/cli/core/upgrade.d.ts.map +1 -1
  47. package/dist/cli/core/upgrade.js.map +1 -1
  48. package/dist/cli/core/version.js.map +1 -1
  49. package/dist/cli/shell/agent-status.d.ts +11 -0
  50. package/dist/cli/shell/agent-status.d.ts.map +1 -0
  51. package/dist/cli/shell/agent-status.js +26 -0
  52. package/dist/cli/shell/agent-status.js.map +1 -0
  53. package/dist/cli/shell/commands.d.ts +10 -0
  54. package/dist/cli/shell/commands.d.ts.map +1 -1
  55. package/dist/cli/shell/commands.js +143 -29
  56. package/dist/cli/shell/commands.js.map +1 -1
  57. package/dist/cli/shell/components/AgentPanel.d.ts +1 -4
  58. package/dist/cli/shell/components/AgentPanel.d.ts.map +1 -1
  59. package/dist/cli/shell/components/AgentPanel.js +88 -6
  60. package/dist/cli/shell/components/AgentPanel.js.map +1 -1
  61. package/dist/cli/shell/components/App.d.ts +11 -6
  62. package/dist/cli/shell/components/App.d.ts.map +1 -1
  63. package/dist/cli/shell/components/App.js +212 -35
  64. package/dist/cli/shell/components/App.js.map +1 -1
  65. package/dist/cli/shell/components/ErrorBoundary.d.ts +22 -0
  66. package/dist/cli/shell/components/ErrorBoundary.d.ts.map +1 -0
  67. package/dist/cli/shell/components/ErrorBoundary.js +31 -0
  68. package/dist/cli/shell/components/ErrorBoundary.js.map +1 -0
  69. package/dist/cli/shell/components/InputPrompt.d.ts +3 -0
  70. package/dist/cli/shell/components/InputPrompt.d.ts.map +1 -1
  71. package/dist/cli/shell/components/InputPrompt.js +155 -13
  72. package/dist/cli/shell/components/InputPrompt.js.map +1 -1
  73. package/dist/cli/shell/components/MessageStream.d.ts +17 -4
  74. package/dist/cli/shell/components/MessageStream.d.ts.map +1 -1
  75. package/dist/cli/shell/components/MessageStream.js +215 -23
  76. package/dist/cli/shell/components/MessageStream.js.map +1 -1
  77. package/dist/cli/shell/components/Separator.d.ts +17 -0
  78. package/dist/cli/shell/components/Separator.d.ts.map +1 -0
  79. package/dist/cli/shell/components/Separator.js +10 -0
  80. package/dist/cli/shell/components/Separator.js.map +1 -0
  81. package/dist/cli/shell/components/ThinkingIndicator.d.ts +21 -0
  82. package/dist/cli/shell/components/ThinkingIndicator.d.ts.map +1 -0
  83. package/dist/cli/shell/components/ThinkingIndicator.js +102 -0
  84. package/dist/cli/shell/components/ThinkingIndicator.js.map +1 -0
  85. package/dist/cli/shell/components/index.d.ts +3 -0
  86. package/dist/cli/shell/components/index.d.ts.map +1 -1
  87. package/dist/cli/shell/components/index.js +2 -0
  88. package/dist/cli/shell/components/index.js.map +1 -1
  89. package/dist/cli/shell/coordinator.d.ts +10 -0
  90. package/dist/cli/shell/coordinator.d.ts.map +1 -1
  91. package/dist/cli/shell/coordinator.js +99 -4
  92. package/dist/cli/shell/coordinator.js.map +1 -1
  93. package/dist/cli/shell/error-messages.d.ts +21 -0
  94. package/dist/cli/shell/error-messages.d.ts.map +1 -0
  95. package/dist/cli/shell/error-messages.js +61 -0
  96. package/dist/cli/shell/error-messages.js.map +1 -0
  97. package/dist/cli/shell/index.d.ts +24 -3
  98. package/dist/cli/shell/index.d.ts.map +1 -1
  99. package/dist/cli/shell/index.js +943 -34
  100. package/dist/cli/shell/index.js.map +1 -1
  101. package/dist/cli/shell/lifecycle.d.ts +2 -0
  102. package/dist/cli/shell/lifecycle.d.ts.map +1 -1
  103. package/dist/cli/shell/lifecycle.js +59 -6
  104. package/dist/cli/shell/lifecycle.js.map +1 -1
  105. package/dist/cli/shell/memory.d.ts +6 -1
  106. package/dist/cli/shell/memory.d.ts.map +1 -1
  107. package/dist/cli/shell/memory.js +12 -1
  108. package/dist/cli/shell/memory.js.map +1 -1
  109. package/dist/cli/shell/router.d.ts +16 -0
  110. package/dist/cli/shell/router.d.ts.map +1 -1
  111. package/dist/cli/shell/router.js +27 -0
  112. package/dist/cli/shell/router.js.map +1 -1
  113. package/dist/cli/shell/session-store.d.ts +47 -0
  114. package/dist/cli/shell/session-store.d.ts.map +1 -0
  115. package/dist/cli/shell/session-store.js +125 -0
  116. package/dist/cli/shell/session-store.js.map +1 -0
  117. package/dist/cli/shell/sessions.d.ts +2 -0
  118. package/dist/cli/shell/sessions.d.ts.map +1 -1
  119. package/dist/cli/shell/sessions.js +19 -5
  120. package/dist/cli/shell/sessions.js.map +1 -1
  121. package/dist/cli/shell/shell-metrics.d.ts +34 -0
  122. package/dist/cli/shell/shell-metrics.d.ts.map +1 -0
  123. package/dist/cli/shell/shell-metrics.js +98 -0
  124. package/dist/cli/shell/shell-metrics.js.map +1 -0
  125. package/dist/cli/shell/spawn.d.ts.map +1 -1
  126. package/dist/cli/shell/spawn.js +20 -6
  127. package/dist/cli/shell/spawn.js.map +1 -1
  128. package/dist/cli/shell/terminal.d.ts +26 -0
  129. package/dist/cli/shell/terminal.d.ts.map +1 -1
  130. package/dist/cli/shell/terminal.js +65 -2
  131. package/dist/cli/shell/terminal.js.map +1 -1
  132. package/dist/cli/shell/theme-colors.d.ts +39 -0
  133. package/dist/cli/shell/theme-colors.d.ts.map +1 -0
  134. package/dist/cli/shell/theme-colors.js +39 -0
  135. package/dist/cli/shell/theme-colors.js.map +1 -0
  136. package/dist/cli/shell/types.d.ts +2 -0
  137. package/dist/cli/shell/types.d.ts.map +1 -1
  138. package/dist/cli/shell/useAnimation.d.ts +42 -0
  139. package/dist/cli/shell/useAnimation.d.ts.map +1 -0
  140. package/dist/cli/shell/useAnimation.js +139 -0
  141. package/dist/cli/shell/useAnimation.js.map +1 -0
  142. package/dist/cli-entry.d.ts +0 -7
  143. package/dist/cli-entry.d.ts.map +1 -1
  144. package/dist/cli-entry.js +701 -96
  145. package/dist/cli-entry.js.map +1 -1
  146. package/package.json +18 -2
  147. package/templates/orchestration-log.md +1 -1
  148. package/templates/package.json +3 -0
  149. package/templates/ralph-triage.js +543 -0
  150. package/templates/scribe-charter.md +1 -1
  151. package/templates/squad.agent.md +10 -10
  152. package/templates/workflows/squad-heartbeat.yml +52 -196
  153. 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,9 +187,13 @@ 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
  },
@@ -73,7 +201,9 @@ export async function runShell() {
73
201
  /** Extract text delta from an SDK session event. */
74
202
  function extractDelta(event) {
75
203
  const val = event['deltaContent'] ?? event['delta'] ?? event['content'];
76
- return typeof val === 'string' ? val : '';
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.
@@ -82,10 +212,20 @@ export async function runShell() {
82
212
  */
83
213
  async function awaitStreamedResponse(session, prompt) {
84
214
  if (session.sendAndWait) {
85
- const result = 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
+ });
86
224
  // Return full response content as fallback for when deltas weren't captured
87
225
  const data = result?.['data'];
88
- return typeof data?.['content'] === 'string' ? data['content'] : '';
226
+ const content = typeof data?.['content'] === 'string' ? data['content'] : '';
227
+ debugLog('awaitStreamedResponse: fallback content length', content.length);
228
+ return content;
89
229
  }
90
230
  else {
91
231
  const done = new Promise((resolve) => {
@@ -108,10 +248,53 @@ export async function runShell() {
108
248
  return '';
109
249
  }
110
250
  }
111
- /** 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
+ */
112
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;
113
292
  let session = agentSessions.get(agentName);
114
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));
115
298
  const charter = loadAgentCharter(agentName, teamRoot);
116
299
  const systemPrompt = buildAgentPrompt(charter);
117
300
  if (!registry.get(agentName)) {
@@ -122,32 +305,137 @@ export async function runShell() {
122
305
  streaming: true,
123
306
  systemMessage: { mode: 'append', content: systemPrompt },
124
307
  workingDirectory: teamRoot,
308
+ onPermissionRequest: approveAllPermissions,
125
309
  });
126
310
  agentSessions.set(agentName, session);
127
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);
128
319
  registry.updateStatus(agentName, 'streaming');
129
320
  shellApi?.refreshAgents();
321
+ shellApi?.setActivityHint(`${agentName} is thinking...`);
322
+ shellApi?.setAgentActivity(agentName, 'thinking...');
130
323
  let accumulated = '';
324
+ let deltaIndex = 0;
131
325
  const onDelta = (event) => {
326
+ debugLog('agent onDelta fired', agentName, { eventType: event['type'] });
132
327
  const delta = extractDelta(event);
133
328
  if (!delta)
134
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
+ });
135
343
  accumulated += delta;
136
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
+ });
137
367
  };
138
368
  session.on('message_delta', onDelta);
139
369
  try {
140
- const fallback = await awaitStreamedResponse(session, message);
141
- if (!accumulated && fallback) {
142
- accumulated = fallback;
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();
143
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;
144
417
  }
145
418
  finally {
146
419
  try {
147
420
  session.off('message_delta', onDelta);
148
421
  }
149
422
  catch { /* session may not support off */ }
150
- 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);
151
439
  if (accumulated) {
152
440
  shellApi?.addMessage({
153
441
  role: 'agent',
@@ -160,40 +448,243 @@ export async function runShell() {
160
448
  shellApi?.refreshAgents();
161
449
  }
162
450
  }
163
- /** 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
+ }
164
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;
165
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));
166
497
  const systemPrompt = buildCoordinatorPrompt({ teamRoot });
167
498
  coordinatorSession = await client.createSession({
168
499
  streaming: true,
169
500
  systemMessage: { mode: 'append', content: systemPrompt },
170
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',
171
508
  });
172
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());
173
519
  let accumulated = '';
520
+ let coordDeltaIndex = 0;
174
521
  const onDelta = (event) => {
522
+ debugLog('coordinator onDelta fired', { eventType: event['type'] });
175
523
  const delta = extractDelta(event);
176
524
  if (!delta)
177
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
+ });
178
539
  accumulated += delta;
179
- 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
+ }
180
556
  };
181
- coordinatorSession.on('message_delta', onDelta);
182
- try {
183
- const fallback = await awaitStreamedResponse(coordinatorSession, message);
184
- if (!accumulated && fallback) {
185
- accumulated = fallback;
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
+ }
186
604
  }
605
+ };
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 */ }
613
+ try {
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;
187
642
  }
188
643
  finally {
189
644
  try {
190
- coordinatorSession.off('message_delta', onDelta);
645
+ activeCoordSession.off('message_delta', onDelta);
646
+ debugLog('coordinator message_delta listener removed');
191
647
  }
192
648
  catch { /* session may not support off */ }
193
- 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();
194
683
  }
195
684
  // Parse routing decision from coordinator response
685
+ debugLog('coordinator accumulated (first 200 chars)', accumulated.slice(0, 200));
196
686
  const decision = parseCoordinatorResponse(accumulated);
687
+ debugLog('coordinator decision', { type: decision.type, hasRoutes: !!(decision.routes?.length), hasDirectAnswer: !!decision.directAnswer });
197
688
  if (decision.type === 'route' && decision.routes?.length) {
198
689
  for (const route of decision.routes) {
199
690
  shellApi?.addMessage({
@@ -225,42 +716,435 @@ export async function runShell() {
225
716
  });
226
717
  }
227
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
+ }
228
943
  /** Handle dispatching parsed input to agents or coordinator. */
229
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++;
230
990
  try {
231
- 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 });
232
1021
  await dispatchToAgent(parsed.agentName, parsed.content ?? parsed.raw);
233
1022
  }
234
1023
  else if (parsed.type === 'coordinator') {
1024
+ debugLog('handleDispatch: routing through coordinator');
235
1025
  await dispatchToCoordinator(parsed.content ?? parsed.raw);
236
1026
  }
237
1027
  }
238
1028
  catch (err) {
1029
+ debugLog('handleDispatch error:', err);
1030
+ recordShellError('dispatch', err instanceof Error ? err.constructor.name : 'unknown');
239
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.';
240
1035
  if (shellApi) {
1036
+ const guidance = genericGuidance(detail);
241
1037
  shellApi.addMessage({
242
1038
  role: 'system',
243
- content: `❌ ${errorMsg}`,
1039
+ content: formatGuidance(guidance),
244
1040
  timestamp: new Date(),
245
1041
  });
246
1042
  }
247
1043
  }
248
1044
  }
249
- 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, {
250
1073
  registry,
251
1074
  renderer,
252
1075
  teamRoot,
253
1076
  version: pkg.version,
254
- 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
+ },
255
1129
  onDispatch: handleDispatch,
256
- }), { 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');
257
1135
  await waitUntilExit();
1136
+ // Record shell session duration before cleanup
1137
+ recordShellSessionDuration(Date.now() - sessionStart);
1138
+ // Final session save before cleanup
1139
+ autoSave();
258
1140
  // Cleanup: close all sessions and disconnect
259
- for (const [, session] of agentSessions) {
1141
+ for (const [name, session] of agentSessions) {
260
1142
  try {
261
1143
  await session.close();
262
1144
  }
263
- catch { /* best-effort cleanup */ }
1145
+ catch (err) {
1146
+ debugLog(`Failed to close session for ${name}:`, err);
1147
+ }
264
1148
  }
265
1149
  // coordinatorSession is assigned inside dispatchToCoordinator closure;
266
1150
  // TS control flow can't see the mutation, so assert the type.
@@ -269,16 +1153,41 @@ export async function runShell() {
269
1153
  try {
270
1154
  await coordSession.close();
271
1155
  }
272
- catch { /* best-effort cleanup */ }
1156
+ catch (err) {
1157
+ debugLog('Failed to close coordinator session:', err);
1158
+ }
273
1159
  }
274
1160
  try {
275
1161
  await client.disconnect();
276
1162
  }
277
- catch { /* best-effort cleanup */ }
1163
+ catch (err) {
1164
+ debugLog('Failed to disconnect client:', err);
1165
+ }
278
1166
  try {
279
1167
  await lifecycle.shutdown();
280
1168
  }
281
- catch { /* best-effort cleanup */ }
282
- 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
+ }
283
1192
  }
284
1193
  //# sourceMappingURL=index.js.map