@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.
- package/README.md +2 -2
- package/dist/cli/commands/aspire.d.ts.map +1 -1
- package/dist/cli/commands/aspire.js +7 -8
- package/dist/cli/commands/aspire.js.map +1 -1
- package/dist/cli/commands/copilot-bridge.d.ts +42 -0
- package/dist/cli/commands/copilot-bridge.d.ts.map +1 -0
- package/dist/cli/commands/copilot-bridge.js +191 -0
- package/dist/cli/commands/copilot-bridge.js.map +1 -0
- package/dist/cli/commands/import.js.map +1 -1
- package/dist/cli/commands/rc-tunnel.d.ts +30 -0
- package/dist/cli/commands/rc-tunnel.d.ts.map +1 -0
- package/dist/cli/commands/rc-tunnel.js +107 -0
- package/dist/cli/commands/rc-tunnel.js.map +1 -0
- package/dist/cli/commands/rc.d.ts +13 -0
- package/dist/cli/commands/rc.d.ts.map +1 -0
- package/dist/cli/commands/rc.js +270 -0
- package/dist/cli/commands/rc.js.map +1 -0
- package/dist/cli/commands/start.d.ts +18 -0
- package/dist/cli/commands/start.d.ts.map +1 -0
- package/dist/cli/commands/start.js +219 -0
- package/dist/cli/commands/start.js.map +1 -0
- package/dist/cli/commands/upstream.js.map +1 -1
- package/dist/cli/commands/watch.d.ts +10 -0
- package/dist/cli/commands/watch.d.ts.map +1 -1
- package/dist/cli/commands/watch.js +181 -65
- package/dist/cli/commands/watch.js.map +1 -1
- package/dist/cli/core/cast.d.ts +40 -0
- package/dist/cli/core/cast.d.ts.map +1 -0
- package/dist/cli/core/cast.js +442 -0
- package/dist/cli/core/cast.js.map +1 -0
- package/dist/cli/core/gh-cli.d.ts +25 -0
- package/dist/cli/core/gh-cli.d.ts.map +1 -1
- package/dist/cli/core/gh-cli.js +15 -1
- package/dist/cli/core/gh-cli.js.map +1 -1
- package/dist/cli/core/init.d.ts +9 -1
- package/dist/cli/core/init.d.ts.map +1 -1
- package/dist/cli/core/init.js +108 -13
- package/dist/cli/core/init.js.map +1 -1
- package/dist/cli/core/migrations.js.map +1 -1
- package/dist/cli/core/nap.d.ts +37 -0
- package/dist/cli/core/nap.d.ts.map +1 -0
- package/dist/cli/core/nap.js +528 -0
- package/dist/cli/core/nap.js.map +1 -0
- package/dist/cli/core/output.d.ts +5 -0
- package/dist/cli/core/output.d.ts.map +1 -1
- package/dist/cli/core/output.js +7 -0
- package/dist/cli/core/output.js.map +1 -1
- package/dist/cli/core/upgrade.d.ts +0 -1
- package/dist/cli/core/upgrade.d.ts.map +1 -1
- package/dist/cli/core/upgrade.js.map +1 -1
- package/dist/cli/core/version.js.map +1 -1
- package/dist/cli/shell/agent-status.d.ts +11 -0
- package/dist/cli/shell/agent-status.d.ts.map +1 -0
- package/dist/cli/shell/agent-status.js +26 -0
- package/dist/cli/shell/agent-status.js.map +1 -0
- package/dist/cli/shell/commands.d.ts +10 -0
- package/dist/cli/shell/commands.d.ts.map +1 -1
- package/dist/cli/shell/commands.js +143 -29
- package/dist/cli/shell/commands.js.map +1 -1
- package/dist/cli/shell/components/AgentPanel.d.ts +1 -4
- package/dist/cli/shell/components/AgentPanel.d.ts.map +1 -1
- package/dist/cli/shell/components/AgentPanel.js +88 -6
- package/dist/cli/shell/components/AgentPanel.js.map +1 -1
- package/dist/cli/shell/components/App.d.ts +11 -6
- package/dist/cli/shell/components/App.d.ts.map +1 -1
- package/dist/cli/shell/components/App.js +212 -35
- package/dist/cli/shell/components/App.js.map +1 -1
- package/dist/cli/shell/components/ErrorBoundary.d.ts +22 -0
- package/dist/cli/shell/components/ErrorBoundary.d.ts.map +1 -0
- package/dist/cli/shell/components/ErrorBoundary.js +31 -0
- package/dist/cli/shell/components/ErrorBoundary.js.map +1 -0
- package/dist/cli/shell/components/InputPrompt.d.ts +3 -0
- package/dist/cli/shell/components/InputPrompt.d.ts.map +1 -1
- package/dist/cli/shell/components/InputPrompt.js +155 -13
- package/dist/cli/shell/components/InputPrompt.js.map +1 -1
- package/dist/cli/shell/components/MessageStream.d.ts +17 -4
- package/dist/cli/shell/components/MessageStream.d.ts.map +1 -1
- package/dist/cli/shell/components/MessageStream.js +215 -23
- package/dist/cli/shell/components/MessageStream.js.map +1 -1
- package/dist/cli/shell/components/Separator.d.ts +17 -0
- package/dist/cli/shell/components/Separator.d.ts.map +1 -0
- package/dist/cli/shell/components/Separator.js +10 -0
- package/dist/cli/shell/components/Separator.js.map +1 -0
- package/dist/cli/shell/components/ThinkingIndicator.d.ts +21 -0
- package/dist/cli/shell/components/ThinkingIndicator.d.ts.map +1 -0
- package/dist/cli/shell/components/ThinkingIndicator.js +102 -0
- package/dist/cli/shell/components/ThinkingIndicator.js.map +1 -0
- package/dist/cli/shell/components/index.d.ts +3 -0
- package/dist/cli/shell/components/index.d.ts.map +1 -1
- package/dist/cli/shell/components/index.js +2 -0
- package/dist/cli/shell/components/index.js.map +1 -1
- package/dist/cli/shell/coordinator.d.ts +10 -0
- package/dist/cli/shell/coordinator.d.ts.map +1 -1
- package/dist/cli/shell/coordinator.js +99 -4
- package/dist/cli/shell/coordinator.js.map +1 -1
- package/dist/cli/shell/error-messages.d.ts +21 -0
- package/dist/cli/shell/error-messages.d.ts.map +1 -0
- package/dist/cli/shell/error-messages.js +61 -0
- package/dist/cli/shell/error-messages.js.map +1 -0
- package/dist/cli/shell/index.d.ts +24 -3
- package/dist/cli/shell/index.d.ts.map +1 -1
- package/dist/cli/shell/index.js +949 -29
- package/dist/cli/shell/index.js.map +1 -1
- package/dist/cli/shell/lifecycle.d.ts +2 -0
- package/dist/cli/shell/lifecycle.d.ts.map +1 -1
- package/dist/cli/shell/lifecycle.js +59 -6
- package/dist/cli/shell/lifecycle.js.map +1 -1
- package/dist/cli/shell/memory.d.ts +6 -1
- package/dist/cli/shell/memory.d.ts.map +1 -1
- package/dist/cli/shell/memory.js +12 -1
- package/dist/cli/shell/memory.js.map +1 -1
- package/dist/cli/shell/router.d.ts +16 -0
- package/dist/cli/shell/router.d.ts.map +1 -1
- package/dist/cli/shell/router.js +27 -0
- package/dist/cli/shell/router.js.map +1 -1
- package/dist/cli/shell/session-store.d.ts +47 -0
- package/dist/cli/shell/session-store.d.ts.map +1 -0
- package/dist/cli/shell/session-store.js +125 -0
- package/dist/cli/shell/session-store.js.map +1 -0
- package/dist/cli/shell/sessions.d.ts +2 -0
- package/dist/cli/shell/sessions.d.ts.map +1 -1
- package/dist/cli/shell/sessions.js +19 -5
- package/dist/cli/shell/sessions.js.map +1 -1
- package/dist/cli/shell/shell-metrics.d.ts +34 -0
- package/dist/cli/shell/shell-metrics.d.ts.map +1 -0
- package/dist/cli/shell/shell-metrics.js +98 -0
- package/dist/cli/shell/shell-metrics.js.map +1 -0
- package/dist/cli/shell/spawn.d.ts.map +1 -1
- package/dist/cli/shell/spawn.js +20 -6
- package/dist/cli/shell/spawn.js.map +1 -1
- package/dist/cli/shell/terminal.d.ts +26 -0
- package/dist/cli/shell/terminal.d.ts.map +1 -1
- package/dist/cli/shell/terminal.js +65 -2
- package/dist/cli/shell/terminal.js.map +1 -1
- package/dist/cli/shell/theme-colors.d.ts +39 -0
- package/dist/cli/shell/theme-colors.d.ts.map +1 -0
- package/dist/cli/shell/theme-colors.js +39 -0
- package/dist/cli/shell/theme-colors.js.map +1 -0
- package/dist/cli/shell/types.d.ts +2 -0
- package/dist/cli/shell/types.d.ts.map +1 -1
- package/dist/cli/shell/useAnimation.d.ts +42 -0
- package/dist/cli/shell/useAnimation.d.ts.map +1 -0
- package/dist/cli/shell/useAnimation.js +139 -0
- package/dist/cli/shell/useAnimation.js.map +1 -0
- package/dist/cli-entry.d.ts +0 -7
- package/dist/cli-entry.d.ts.map +1 -1
- package/dist/cli-entry.js +701 -96
- package/dist/cli-entry.js.map +1 -1
- package/package.json +156 -140
- package/templates/orchestration-log.md +1 -1
- package/templates/package.json +3 -0
- package/templates/ralph-triage.js +543 -0
- package/templates/scribe-charter.md +1 -1
- package/templates/squad.agent.md +10 -10
- package/templates/workflows/squad-heartbeat.yml +52 -196
- package/templates/workflows/squad-insider-release.yml +1 -1
package/dist/cli/shell/index.js
CHANGED
|
@@ -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 {
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
645
|
+
activeCoordSession.off('message_delta', onDelta);
|
|
646
|
+
debugLog('coordinator message_delta listener removed');
|
|
180
647
|
}
|
|
181
648
|
catch { /* session may not support off */ }
|
|
182
|
-
|
|
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
|
-
|
|
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:
|
|
1039
|
+
content: formatGuidance(guidance),
|
|
233
1040
|
timestamp: new Date(),
|
|
234
1041
|
});
|
|
235
1042
|
}
|
|
236
1043
|
}
|
|
237
1044
|
}
|
|
238
|
-
|
|
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) => {
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
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 {
|
|
1163
|
+
catch (err) {
|
|
1164
|
+
debugLog('Failed to disconnect client:', err);
|
|
1165
|
+
}
|
|
267
1166
|
try {
|
|
268
1167
|
await lifecycle.shutdown();
|
|
269
1168
|
}
|
|
270
|
-
catch {
|
|
271
|
-
|
|
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
|