claude-tempo 0.22.0-beta.1 → 0.22.0

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.
@@ -48,8 +48,8 @@ export interface SpawnProcessInput {
48
48
  nativeResolvable?: boolean;
49
49
  /** When true, use --resume instead of -n (reconnect to existing session). */
50
50
  resume?: boolean;
51
- /** Claude Code session UUID for --session-id (new sessions) or --resume (encore). */
52
- claudeSessionId?: string;
51
+ /** Session UUID used for Copilot SDK sessionId and Claude Code --resume/--session-id. */
52
+ sessionId?: string;
53
53
  /** Tool restrictions from the agent definition frontmatter. */
54
54
  allowedTools?: string[];
55
55
  /** Custom claude binary path (from config.claudeBin). */
@@ -70,8 +70,8 @@ export interface EncoreResult {
70
70
  agentDefinitionPath?: string;
71
71
  nativeResolvable?: boolean;
72
72
  allowedTools?: string[];
73
- /** Claude Code session UUID for deterministic --resume. */
74
- claudeSessionId?: string;
73
+ /** Session UUID used for Copilot SDK sessionId and Claude Code --resume/--session-id. */
74
+ sessionId?: string;
75
75
  temporalAddress: string;
76
76
  temporalNamespace: string;
77
77
  /** Custom claude binary path (from config.claudeBin). */
@@ -82,8 +82,8 @@ export interface OutboxActivityResult {
82
82
  error?: string;
83
83
  }
84
84
  export interface RecruitResult extends OutboxActivityResult {
85
- /** Claude Code session UUID assigned at recruit time. */
86
- claudeSessionId?: string;
85
+ /** Session UUID assigned at recruit time. */
86
+ sessionId?: string;
87
87
  }
88
88
  export interface OutboxActivities {
89
89
  deliverCue(input: DeliverCueInput): Promise<OutboxActivityResult>;
@@ -107,8 +107,8 @@ function createOutboxActivities(client, config) {
107
107
  ? (0, config_1.conductorWorkflowId)(ensemble)
108
108
  : (0, config_1.sessionWorkflowId)(ensemble, targetName);
109
109
  const { gitRoot, gitBranch } = (0, git_info_1.getGitInfo)(workDir);
110
- // Generate a UUID for the Claude Code session — used for deterministic --resume on encore
111
- const claudeSessionId = crypto.randomUUID();
110
+ // Generate a UUID for the session — used for deterministic --resume on encore
111
+ const sessionId = crypto.randomUUID();
112
112
  const sessionInput = {
113
113
  metadata: {
114
114
  playerId: targetName,
@@ -120,7 +120,7 @@ function createOutboxActivities(client, config) {
120
120
  isConductor,
121
121
  agentType: agent,
122
122
  status: 'pending',
123
- claudeSessionId,
123
+ sessionId,
124
124
  ...(agentDefinition ? { playerType: agentDefinition } : {}),
125
125
  ...(agentDefinitionDescription ? { playerTypeDescription: agentDefinitionDescription } : {}),
126
126
  recruitedBy: fromPlayerId,
@@ -149,15 +149,15 @@ function createOutboxActivities(client, config) {
149
149
  ClaudeTempoPlayerId: [targetName],
150
150
  },
151
151
  });
152
- log(`Pre-created workflow ${workflowId} for recruit "${targetName}" (sessionId=${claudeSessionId})`);
153
- return { success: true, claudeSessionId };
152
+ log(`Pre-created workflow ${workflowId} for recruit "${targetName}" (sessionId=${sessionId})`);
153
+ return { success: true, sessionId };
154
154
  }
155
155
  catch (err) {
156
156
  throw activity_1.ApplicationFailure.nonRetryable(`Failed to start recruited session "${targetName}": ${err instanceof Error ? err.message : String(err)}`);
157
157
  }
158
158
  },
159
159
  async spawnProcess(input) {
160
- const { targetName, workDir, isConductor, agent, systemPrompt, ensemble, temporalAddress, temporalNamespace, agentDefinition, agentDefinitionPath, nativeResolvable, resume, claudeSessionId, allowedTools, claudeBin } = input;
160
+ const { targetName, workDir, isConductor, agent, systemPrompt, ensemble, temporalAddress, temporalNamespace, agentDefinition, agentDefinitionPath, nativeResolvable, resume, sessionId, allowedTools, claudeBin } = input;
161
161
  // Read secrets from the worker's config closure — never from workflow state
162
162
  const { temporalApiKey, temporalTlsCertPath, temporalTlsKeyPath } = config;
163
163
  try {
@@ -175,6 +175,7 @@ function createOutboxActivities(client, config) {
175
175
  temporalTlsKeyPath,
176
176
  isConductor,
177
177
  workDir,
178
+ sessionId,
178
179
  });
179
180
  log(`Spawned copilot-bridge (pid ${pid}) in ${workDir} as "${targetName}"`);
180
181
  }
@@ -194,8 +195,8 @@ function createOutboxActivities(client, config) {
194
195
  // For encore: use UUID for deterministic --resume (no interactive picker).
195
196
  // For new sessions: use --session-id to track the UUID for future encores.
196
197
  const nameArgs = resume
197
- ? ['--resume', claudeSessionId || targetName]
198
- : ['-n', targetName, ...(claudeSessionId ? ['--session-id', claudeSessionId] : [])];
198
+ ? ['--resume', sessionId || targetName]
199
+ : ['-n', targetName, ...(sessionId ? ['--session-id', sessionId] : [])];
199
200
  // Build --allowedTools flag from agent definition frontmatter
200
201
  const allowedToolsFlags = allowedTools && allowedTools.length > 0
201
202
  ? ['--allowedTools', ...allowedTools]
@@ -296,7 +297,7 @@ function createOutboxActivities(client, config) {
296
297
  agentDefinitionPath,
297
298
  nativeResolvable,
298
299
  allowedTools,
299
- claudeSessionId: metadata.claudeSessionId || undefined,
300
+ sessionId: metadata.sessionId || undefined,
300
301
  temporalAddress: config.temporalAddress,
301
302
  temporalNamespace: config.temporalNamespace,
302
303
  claudeBin: config.claudeBin,
@@ -438,10 +438,25 @@ const SEARCH_ATTRIBUTES = [
438
438
  { name: 'ClaudeTempoStatus', type: 'Keyword' },
439
439
  { name: 'ClaudeTempoPlayerType', type: 'Keyword' },
440
440
  ];
441
- function isTemporalReachable(config) {
442
- return (0, connection_1.createTemporalConnection)(config)
443
- .then(conn => { conn.close(); return true; })
444
- .catch(() => false);
441
+ async function isTemporalReachable(config) {
442
+ try {
443
+ const conn = await (0, connection_1.createTemporalConnection)(config);
444
+ try {
445
+ // Verify namespace is ready — a gRPC connection alone doesn't guarantee the server can serve requests
446
+ const client = new client_1.Client({ connection: conn, namespace: config.temporalNamespace || 'default' });
447
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
448
+ for await (const _ of client.workflow.list({ query: 'WorkflowId = "__readiness_probe__"' })) {
449
+ break;
450
+ }
451
+ }
452
+ finally {
453
+ await conn.close();
454
+ }
455
+ return true;
456
+ }
457
+ catch {
458
+ return false;
459
+ }
445
460
  }
446
461
  function temporalCliExists() {
447
462
  const cmd = process.platform === 'win32' ? 'where' : 'which';
@@ -777,7 +792,7 @@ async function up(opts) {
777
792
  isConductor: true,
778
793
  agentType: conductorAgent,
779
794
  status: 'pending',
780
- claudeSessionId: conductorSessionId,
795
+ sessionId: conductorSessionId,
781
796
  ...(resolvedConductorType ? { playerType: resolvedConductorType.name, playerTypeDescription: resolvedConductorType.description || '' } : {}),
782
797
  },
783
798
  autoSummary: `Conductor session`,
@@ -858,7 +873,7 @@ async function up(opts) {
858
873
  console.log();
859
874
  out.log(`Recruiting ${lineup.players.length} player${lineup.players.length !== 1 ? 's' : ''} from lineup...`);
860
875
  for (const player of lineup.players) {
861
- const playerAgent = player.agent === 'copilot' ? 'copilot' : 'claude';
876
+ const playerAgent = player.agent === 'copilot' ? 'copilot' : (player.agent === 'claude' ? 'claude' : opts.agent);
862
877
  const playerWorkDir = player.workDir || process.cwd();
863
878
  const playerTypeName = player.type;
864
879
  const resolvedPlayerType = playerTypeName ? (0, agent_types_1.resolveAgentType)(playerTypeName) : null;
@@ -877,7 +892,7 @@ async function up(opts) {
877
892
  isConductor: false,
878
893
  agentType: playerAgent,
879
894
  status: 'pending',
880
- claudeSessionId: playerSessionId,
895
+ sessionId: playerSessionId,
881
896
  recruitedBy: sessionName,
882
897
  ...(resolvedPlayerType ? { playerType: resolvedPlayerType.name, playerTypeDescription: resolvedPlayerType.description || '' } : {}),
883
898
  },
@@ -965,8 +980,23 @@ async function up(opts) {
965
980
  try {
966
981
  const entry = lineupScheduleToEntry(sched);
967
982
  const schedulerWfId = (0, config_1.schedulerWorkflowId)(opts.ensemble);
968
- const handle = client.workflow.getHandle(schedulerWfId);
969
- await handle.signal(scheduler_signals_1.addScheduleSignal, entry);
983
+ // Try to signal existing scheduler; if not running, start it with this schedule as seed
984
+ try {
985
+ const handle = client.workflow.getHandle(schedulerWfId);
986
+ await handle.describe();
987
+ await handle.signal(scheduler_signals_1.addScheduleSignal, entry);
988
+ }
989
+ catch {
990
+ await client.workflow.start('claudeSchedulerWorkflow', {
991
+ workflowId: schedulerWfId,
992
+ taskQueue: config.taskQueue,
993
+ args: [{ ensemble: opts.ensemble, entries: [entry] }],
994
+ workflowIdConflictPolicy: client_1.WorkflowIdConflictPolicy.USE_EXISTING,
995
+ searchAttributes: {
996
+ ClaudeTempoEnsemble: [opts.ensemble],
997
+ },
998
+ });
999
+ }
970
1000
  out.check(sched.name, true, `→ ${sched.target}`);
971
1001
  }
972
1002
  catch (err) {
package/dist/config.d.ts CHANGED
@@ -8,6 +8,7 @@ export declare const ENV: {
8
8
  readonly BRIDGE_NAME: "COPILOT_BRIDGE_NAME";
9
9
  readonly BRIDGE_MODE: "CLAUDE_TEMPO_BRIDGE_MODE";
10
10
  readonly BRIDGE_MODEL: "COPILOT_BRIDGE_MODEL";
11
+ readonly BRIDGE_SESSION_ID: "COPILOT_BRIDGE_SESSION_ID";
11
12
  readonly TEMPORAL_ADDRESS: "TEMPORAL_ADDRESS";
12
13
  readonly TEMPORAL_NAMESPACE: "TEMPORAL_NAMESPACE";
13
14
  readonly TEMPORAL_API_KEY: "TEMPORAL_API_KEY";
package/dist/config.js CHANGED
@@ -29,6 +29,7 @@ exports.ENV = {
29
29
  BRIDGE_NAME: 'COPILOT_BRIDGE_NAME',
30
30
  BRIDGE_MODE: 'CLAUDE_TEMPO_BRIDGE_MODE',
31
31
  BRIDGE_MODEL: 'COPILOT_BRIDGE_MODEL',
32
+ BRIDGE_SESSION_ID: 'COPILOT_BRIDGE_SESSION_ID',
32
33
  TEMPORAL_ADDRESS: 'TEMPORAL_ADDRESS',
33
34
  TEMPORAL_NAMESPACE: 'TEMPORAL_NAMESPACE',
34
35
  TEMPORAL_API_KEY: 'TEMPORAL_API_KEY',
@@ -16,6 +16,7 @@
16
16
  * CLAUDE_TEMPO_PLAYER_NAME — player ID for workflow registration (set by spawner for deterministic workflow IDs)
17
17
  * COPILOT_BRIDGE_NAME — player name for set_name (optional)
18
18
  * COPILOT_BRIDGE_MODEL — model to use (optional)
19
+ * COPILOT_BRIDGE_SESSION_ID — deterministic session ID for resumable sessions (optional)
19
20
  * GITHUB_TOKEN — GitHub auth token (optional, uses logged-in user by default)
20
21
  */
21
22
  export {};
@@ -17,6 +17,7 @@
17
17
  * CLAUDE_TEMPO_PLAYER_NAME — player ID for workflow registration (set by spawner for deterministic workflow IDs)
18
18
  * COPILOT_BRIDGE_NAME — player name for set_name (optional)
19
19
  * COPILOT_BRIDGE_MODEL — model to use (optional)
20
+ * COPILOT_BRIDGE_SESSION_ID — deterministic session ID for resumable sessions (optional)
20
21
  * GITHUB_TOKEN — GitHub auth token (optional, uses logged-in user by default)
21
22
  */
22
23
  var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
@@ -86,6 +87,8 @@ const MAX_CONSECUTIVE_FAILURES = 3;
86
87
  const MAX_SESSION_RECREATIONS = 2;
87
88
  /** Check workflow status every N polls (~30s at 2s interval). */
88
89
  const WORKFLOW_STATUS_CHECK_INTERVAL = 15;
90
+ /** Proactively recreate the Copilot session after this idle period (ms). Default 60 min. */
91
+ const SESSION_MAX_IDLE_MS = 60 * 60 * 1000;
89
92
  /** Wrap createSession with a timeout so auth/network hangs don't block forever. */
90
93
  async function createSessionWithTimeout(copilotClient, sessionConfig, timeoutMs = CREATE_SESSION_TIMEOUT_MS) {
91
94
  let timer;
@@ -106,6 +109,7 @@ async function main() {
106
109
  const config = (0, config_1.getConfig)();
107
110
  const playerName = process.env[config_1.ENV.BRIDGE_NAME];
108
111
  const model = process.env[config_1.ENV.BRIDGE_MODEL];
112
+ const copilotSessionId = process.env[config_1.ENV.BRIDGE_SESSION_ID] || `tempo-${config.ensemble}-${playerName || 'unknown'}-${Date.now()}-${process.pid}`;
109
113
  const workDir = process.cwd();
110
114
  log(`Starting Copilot bridge in ${workDir} (ensemble: ${config.ensemble})`);
111
115
  // Connect Temporal client (for polling only — the MCP server child process runs its own worker)
@@ -156,6 +160,7 @@ async function main() {
156
160
  },
157
161
  });
158
162
  const sessionConfig = {
163
+ sessionId: copilotSessionId,
159
164
  // approveAll is intentional: Copilot bridge sessions run headless with no
160
165
  // interactive terminal, so there is no way to prompt for permission approval.
161
166
  // All tool calls are auto-approved by design — the bridge operator accepts
@@ -290,6 +295,11 @@ async function main() {
290
295
  process.exit(1);
291
296
  }
292
297
  log(`Workflow ready: ${expectedWorkflowId}`);
298
+ // Store sessionId in workflow metadata for future encore/resume
299
+ try {
300
+ await handle.signal('updateMetadata', { sessionId: copilotSessionId });
301
+ }
302
+ catch { /* workflow may not be ready yet */ }
293
303
  // If a name was requested, send the set_name instruction
294
304
  if (playerName) {
295
305
  log(`Sending set_name instruction for "${playerName}"...`);
@@ -314,6 +324,8 @@ async function main() {
314
324
  let pollCount = 0;
315
325
  let consecutiveFailures = 0;
316
326
  let sessionRecreations = 0;
327
+ let proactiveRecreations = 0;
328
+ let lastActivityTime = Date.now();
317
329
  // interval declared here, assigned after poll is defined
318
330
  let interval;
319
331
  // Shared cleanup — disconnects session, removes PID file, stops client.
@@ -351,18 +363,33 @@ async function main() {
351
363
  log(`ERROR: Exceeded max session recreations (${MAX_SESSION_RECREATIONS}). Giving up.`);
352
364
  return false;
353
365
  }
354
- log(`Attempting session recreation (${sessionRecreations}/${MAX_SESSION_RECREATIONS})...`);
366
+ log(`Attempting session recovery (${sessionRecreations}/${MAX_SESSION_RECREATIONS})...`);
355
367
  try {
356
368
  await session.disconnect().catch(() => { });
357
- session = await createSessionWithTimeout(copilotClient, sessionConfig);
358
- attachEventLogger(session);
359
- sessionAlive = true;
360
- consecutiveFailures = 0;
361
- log(`Session recreated successfully: ${session.sessionId}`);
362
- return true;
369
+ // Try resumeSession first to preserve conversation history
370
+ try {
371
+ const { sessionId: _discard, ...resumeConfig } = sessionConfig;
372
+ session = await copilotClient.resumeSession(copilotSessionId, resumeConfig);
373
+ attachEventLogger(session);
374
+ sessionAlive = true;
375
+ consecutiveFailures = 0;
376
+ lastActivityTime = Date.now();
377
+ log(`Session resumed successfully: ${session.sessionId}`);
378
+ return true;
379
+ }
380
+ catch (resumeErr) {
381
+ log(`resumeSession failed (${resumeErr?.message}), falling back to createSession`);
382
+ session = await createSessionWithTimeout(copilotClient, sessionConfig);
383
+ attachEventLogger(session);
384
+ sessionAlive = true;
385
+ consecutiveFailures = 0;
386
+ lastActivityTime = Date.now();
387
+ log(`Session recreated (fresh) successfully: ${session.sessionId}`);
388
+ return true;
389
+ }
363
390
  }
364
391
  catch (err) {
365
- log(`Session recreation failed: ${err?.message}`);
392
+ log(`Session recovery failed: ${err?.message}`);
366
393
  return false;
367
394
  }
368
395
  }
@@ -393,13 +420,37 @@ async function main() {
393
420
  process.exit(0);
394
421
  }
395
422
  }
423
+ // Proactive stale-session detection — recreate before the SDK server GCs the session
424
+ const idleMs = Date.now() - lastActivityTime;
425
+ if (idleMs > SESSION_MAX_IDLE_MS && !processing) {
426
+ try {
427
+ processing = true; // guard against overlapping polls during async recreation
428
+ log(`Session idle for ${(idleMs / 1000 / 60).toFixed(0)}min — proactively recreating`);
429
+ proactiveRecreations++;
430
+ const recovered = await recreateSession();
431
+ if (recovered) {
432
+ // Proactive recreation is lifecycle management, not failure recovery — restore failure budget
433
+ // but don't reset to 0: use proactiveRecreations to cap total lifecycle recreations
434
+ sessionRecreations = Math.max(0, sessionRecreations - 1);
435
+ }
436
+ else {
437
+ // Session is almost certainly dead server-side — force immediate recovery on next message
438
+ // Use MAX - 1 so the next poll error increments to the threshold and triggers recovery
439
+ consecutiveFailures = MAX_CONSECUTIVE_FAILURES - 1;
440
+ sessionAlive = false;
441
+ log('ERROR: Proactive session recreation failed — will force recovery on next message');
442
+ }
443
+ }
444
+ finally {
445
+ processing = false;
446
+ }
447
+ }
396
448
  try {
397
449
  const messages = await handle.query('pendingMessages');
398
450
  if (messages.length === 0)
399
451
  return;
400
452
  processing = true;
401
453
  const ids = messages.map((m) => m.id);
402
- await handle.signal('markDelivered', ids);
403
454
  // Format messages into a single prompt, appending ack instruction for Maestro messages
404
455
  const prompt = messages
405
456
  .map((m) => {
@@ -417,8 +468,11 @@ async function main() {
417
468
  const elapsed = Date.now() - t0;
418
469
  log(`sendAndWait completed in ${elapsed}ms`);
419
470
  log(`Response: ${JSON.stringify(result)?.substring(0, 500)}`);
471
+ // Mark delivered only after successful send — failed messages stay in pending queue for retry
472
+ await handle.signal('markDelivered', ids);
420
473
  // Success — reset failure tracking
421
474
  consecutiveFailures = 0;
475
+ lastActivityTime = Date.now();
422
476
  sessionAlive = true;
423
477
  processing = false;
424
478
  }
@@ -15,3 +15,10 @@ export declare function listLineups(): Array<{
15
15
  * Read a saved lineup by name from ~/.claude-tempo/ensembles/.
16
16
  */
17
17
  export declare function readSavedLineup(name: string): string | null;
18
+ /**
19
+ * List all available lineups — saved (~/.claude-tempo/ensembles/) + shipped (examples/ensembles/).
20
+ */
21
+ export declare function listAllLineups(): Array<{
22
+ name: string;
23
+ source: 'saved' | 'shipped';
24
+ }>;
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.saveLineup = saveLineup;
4
4
  exports.listLineups = listLineups;
5
5
  exports.readSavedLineup = readSavedLineup;
6
+ exports.listAllLineups = listAllLineups;
6
7
  const fs_1 = require("fs");
7
8
  const path_1 = require("path");
8
9
  const yaml_1 = require("yaml");
@@ -129,6 +130,40 @@ function readSavedLineup(name) {
129
130
  }
130
131
  return null;
131
132
  }
133
+ /**
134
+ * Find the package root by walking up from a directory.
135
+ */
136
+ function findPackageRoot(dir) {
137
+ if ((0, fs_1.existsSync)((0, path_1.join)(dir, 'package.json')))
138
+ return dir;
139
+ const parent = (0, path_1.resolve)(dir, '..');
140
+ return parent === dir ? dir : findPackageRoot(parent);
141
+ }
142
+ /**
143
+ * List all available lineups — saved (~/.claude-tempo/ensembles/) + shipped (examples/ensembles/).
144
+ */
145
+ function listAllLineups() {
146
+ const results = [];
147
+ // Saved lineups
148
+ for (const l of listLineups()) {
149
+ results.push({ name: l.name, source: 'saved' });
150
+ }
151
+ // Shipped examples
152
+ const pkgRoot = findPackageRoot((0, path_1.resolve)(__dirname));
153
+ const shippedDir = (0, path_1.join)(pkgRoot, 'examples', 'ensembles');
154
+ if ((0, fs_1.existsSync)(shippedDir)) {
155
+ for (const f of (0, fs_1.readdirSync)(shippedDir)) {
156
+ if (f.endsWith('.yaml') || f.endsWith('.yml')) {
157
+ const name = f.replace(/\.ya?ml$/, '');
158
+ // Skip if already in saved (saved takes precedence)
159
+ if (!results.some(r => r.name === name)) {
160
+ results.push({ name, source: 'shipped' });
161
+ }
162
+ }
163
+ }
164
+ }
165
+ return results;
166
+ }
132
167
  function formatDurationMs(ms) {
133
168
  if (ms >= 86_400_000 && ms % 86_400_000 === 0)
134
169
  return `${ms / 86_400_000}d`;
package/dist/spawn.d.ts CHANGED
@@ -64,6 +64,8 @@ export interface CopilotBridgeOpts {
64
64
  workDir: string;
65
65
  /** Directory for log and PID files. Defaults to `logs/` inside workDir. */
66
66
  logDir?: string;
67
+ /** Copilot SDK session ID for resumable sessions. */
68
+ sessionId?: string;
67
69
  }
68
70
  export interface CopilotBridgeResult {
69
71
  pid: number | undefined;
package/dist/spawn.js CHANGED
@@ -427,6 +427,7 @@ function spawnCopilotBridge(opts) {
427
427
  ...(opts.temporalApiKey ? { [config_1.ENV.TEMPORAL_API_KEY]: opts.temporalApiKey } : {}),
428
428
  ...(opts.temporalTlsCertPath ? { [config_1.ENV.TEMPORAL_TLS_CERT_PATH]: opts.temporalTlsCertPath } : {}),
429
429
  ...(opts.temporalTlsKeyPath ? { [config_1.ENV.TEMPORAL_TLS_KEY_PATH]: opts.temporalTlsKeyPath } : {}),
430
+ ...(opts.sessionId ? { [config_1.ENV.BRIDGE_SESSION_ID]: opts.sessionId } : {}),
430
431
  },
431
432
  });
432
433
  child.unref();
package/dist/tui/App.js CHANGED
@@ -222,6 +222,17 @@ function App({ api, ensemble, defaultAgent }) {
222
222
  return;
223
223
  }
224
224
  }
225
+ // Gates/stages — Enter shows detail for selected item
226
+ if ((s.overlay.type === 'gates' || s.overlay.type === 'stages') && key.return) {
227
+ const selected = s.overlay.items[s.overlay.selectedIndex];
228
+ if (selected) {
229
+ const detail = selected.sublabel
230
+ ? `\n ${selected.label}\n\n ${selected.sublabel.split(' ').join('\n ')}`
231
+ : `\n ${selected.label}\n\n No details available.`;
232
+ dispatch({ type: 'SHOW_COMMAND_OVERLAY', title: selected.label.slice(0, 40), content: detail });
233
+ }
234
+ return;
235
+ }
225
236
  return; // Swallow all other input while overlay is active
226
237
  }
227
238
  // Player detail view — Escape goes back, ↑↓ scrolls messages
@@ -703,7 +714,7 @@ function App({ api, ensemble, defaultAgent }) {
703
714
  if (atMatch) {
704
715
  // @player message → send directly to that player
705
716
  const [, targetPlayer, message] = atMatch;
706
- dispatch({ type: 'APPEND_SENT_MESSAGE', to: targetPlayer, text: message });
717
+ dispatch({ type: 'APPEND_SENT_MESSAGE', to: targetPlayer, text: `@${targetPlayer} ${message}` });
707
718
  api.sendAsMaestro(s.activeEnsemble, targetPlayer, message).catch(err => dispatch({
708
719
  type: 'COMMIT_STATIC',
709
720
  item: { id: nextStaticId(), type: 'error', content: `\u2717 Failed to deliver to @${targetPlayer}: ${err}`, timestamp: Date.now() },
@@ -838,9 +849,11 @@ function App({ api, ensemble, defaultAgent }) {
838
849
  }));
839
850
  dispatch({ type: 'REFRESH_ENSEMBLE_DATA', players, messages: [], history: [], schedules });
840
851
  // Skip redundant conversation dispatches when data hasn't changed
852
+ // Always dispatch on first poll (conversation === null) to exit "Loading messages..."
841
853
  const lastChatMsg = chatResult.messages[chatResult.messages.length - 1];
842
854
  const newLastTs = lastChatMsg?.timestamp ?? '';
843
- if (chatResult.total !== lastChatRef.current.total || newLastTs !== lastChatRef.current.lastTs) {
855
+ const isFirstLoad = stateRef.current.conversation === null;
856
+ if (isFirstLoad || chatResult.total !== lastChatRef.current.total || newLastTs !== lastChatRef.current.lastTs) {
844
857
  dispatch({ type: 'SET_CONVERSATION', conversation });
845
858
  dispatch({ type: 'SET_ENSEMBLE_CHAT', chat: chatResult });
846
859
  lastChatRef.current = { total: chatResult.total, lastTs: newLastTs };
@@ -1000,7 +1013,7 @@ function App({ api, ensemble, defaultAgent }) {
1000
1013
  ? ['up', name, '--lineup', lineup]
1001
1014
  : ['up', name];
1002
1015
  await new Promise((resolve, reject) => {
1003
- execFile('claude-tempo', args, { cwd: workDir, timeout: 60000 }, (err, _stdout, stderr) => {
1016
+ execFile('claude-tempo', args, { cwd: workDir, timeout: 60000, shell: true }, (err, _stdout, stderr) => {
1004
1017
  if (err)
1005
1018
  reject(new Error(stderr?.trim() || err.message || 'Unknown error'));
1006
1019
  else
@@ -1337,7 +1350,7 @@ function App({ api, ensemble, defaultAgent }) {
1337
1350
  react_1.default.createElement(PromptArea_1.PromptArea, {
1338
1351
  hints: promptHints,
1339
1352
  onSubmit: handleSubmit,
1340
- disabled: state.phase === 'error' || state.phase === 'recruit' || state.phase === 'schedule-create' || !!state.confirmingStop || !!state.confirmingDisband || !!state.confirmingLineup || state.pickerVisible || state.statusOverlay || !!state.overlay,
1353
+ disabled: (state.phase !== 'main' && state.phase !== 'chat') || !!state.confirmingStop || !!state.confirmingDisband || !!state.confirmingLineup || state.pickerVisible || state.statusOverlay || !!state.overlay,
1341
1354
  commandNames: commandNamesList,
1342
1355
  playerNames: playerNamesList,
1343
1356
  initialHistory: cmdHistory,
@@ -29,7 +29,7 @@ function createTempoClient(client) {
29
29
  try {
30
30
  const h = handle(globalMaestroId);
31
31
  const byEnsemble = await h.query('maestroPlayersByEnsemble');
32
- return Object.entries(byEnsemble).map(([name, players]) => {
32
+ const results = Object.entries(byEnsemble).map(([name, players]) => {
33
33
  const conductor = players.find(p => p.isConductor);
34
34
  return {
35
35
  name,
@@ -38,6 +38,10 @@ function createTempoClient(client) {
38
38
  conductorStatus: conductor?.status,
39
39
  };
40
40
  });
41
+ // Only trust Maestro if it has discovered ensembles; fall through to
42
+ // Strategy 2 when empty — the Maestro may not have refreshed yet.
43
+ if (results.length > 0)
44
+ return results;
41
45
  }
42
46
  catch {
43
47
  // Global Maestro not available — fall through
@@ -14,6 +14,7 @@ exports.formatHelpSummary = formatHelpSummary;
14
14
  */
15
15
  const child_process_1 = require("child_process");
16
16
  const platform_1 = require("./utils/platform");
17
+ const saver_1 = require("../ensemble/saver");
17
18
  // ── Parser ──
18
19
  /**
19
20
  * Parse raw input into a structured command.
@@ -358,7 +359,7 @@ async function handleGates(_args, dispatch, api, ctx) {
358
359
  type: 'gates',
359
360
  title: 'Quality Gates',
360
361
  items: allItems,
361
- hint: 'esc=close',
362
+ hint: '\u21B5=detail esc=close',
362
363
  },
363
364
  });
364
365
  }
@@ -414,7 +415,7 @@ async function handleStages(_args, dispatch, api, ctx) {
414
415
  type: 'stages',
415
416
  title: 'Stages',
416
417
  items: allItems,
417
- hint: 'esc=close',
418
+ hint: '\u21B5=detail esc=close',
418
419
  },
419
420
  });
420
421
  }
@@ -523,18 +524,26 @@ async function handleSearch(args, dispatch, api, ctx) {
523
524
  ? [{ name: ctx.activeEnsemble }]
524
525
  : await api.discoverEnsembles();
525
526
  const allResults = [];
527
+ const seen = new Set();
526
528
  for (const ens of ensembles) {
527
- const messages = await api.getMessages(ens.name, 100);
528
- for (const m of messages) {
529
+ // Fetch from both Maestro event log (100) and ensemble chat cache (500)
530
+ const [messages, chatResult] = await Promise.all([
531
+ api.getMessages(ens.name, 100),
532
+ api.getEnsembleChat(ens.name, 0, 500).catch(() => ({ messages: [] })),
533
+ ]);
534
+ // Merge both sources, dedup by from+to+timestamp prefix
535
+ const combined = [
536
+ ...messages.map(m => ({ from: m.from, to: m.to, text: m.text, timestamp: m.timestamp })),
537
+ ...chatResult.messages.map((m) => ({ from: m.from, to: m.to, text: m.text, timestamp: m.timestamp })),
538
+ ];
539
+ for (const m of combined) {
540
+ const dedupKey = `${m.from}:${m.to}:${m.text.slice(0, 60)}:${m.timestamp.slice(0, 19)}`;
541
+ if (seen.has(dedupKey))
542
+ continue;
543
+ seen.add(dedupKey);
529
544
  const haystack = `${m.from} ${m.to} ${m.text}`.toLowerCase();
530
545
  if (haystack.includes(termLower)) {
531
- allResults.push({
532
- ensemble: ens.name,
533
- from: m.from,
534
- to: m.to,
535
- text: m.text,
536
- timestamp: m.timestamp,
537
- });
546
+ allResults.push({ ensemble: ens.name, ...m });
538
547
  }
539
548
  }
540
549
  }
@@ -543,14 +552,14 @@ async function handleSearch(args, dispatch, api, ctx) {
543
552
  return;
544
553
  }
545
554
  const lines = [`\n ${allResults.length} result${allResults.length !== 1 ? 's' : ''} for "${term}":\n`];
546
- for (const r of allResults.slice(-20)) {
555
+ for (const r of allResults.slice(-50)) {
547
556
  const time = formatTimestamp(r.timestamp);
548
557
  const text = r.text.replace(/\n/g, ' ');
549
558
  const truncated = text.length > 70 ? text.slice(0, 67) + '...' : text;
550
559
  lines.push(` ${time} ${r.from} \u2192 ${r.to}: ${truncated}`);
551
560
  }
552
- if (allResults.length > 20) {
553
- lines.push(`\n ... and ${allResults.length - 20} more. Narrow your search for fewer results.`);
561
+ if (allResults.length > 50) {
562
+ lines.push(`\n ... and ${allResults.length - 50} more. Narrow your search for fewer results.`);
554
563
  }
555
564
  dispatch({ type: 'SHOW_COMMAND_OVERLAY', title: `Search \u00B7 "${term}"`, content: lines.join('\n') });
556
565
  }
@@ -567,7 +576,8 @@ async function handleRecruitConductor(_args, dispatch, api, ctx) {
567
576
  }
568
577
  commitStatic(dispatch, 'info', '\u2026 Recruiting conductor (tempo-conductor)...');
569
578
  // Spawn the conductor directly via CLI — no conductor exists yet to receive commands
570
- (0, child_process_1.execFile)('claude-tempo', ['conduct', ensemble], { timeout: 30000 }, (execErr) => {
579
+ // shell: true resolves .cmd wrappers on Windows
580
+ (0, child_process_1.execFile)('claude-tempo', ['conduct', ensemble], { timeout: 30000, shell: true }, (execErr) => {
571
581
  if (execErr) {
572
582
  commitStatic(dispatch, 'error', `\u2717 Failed to recruit conductor: ${execErr.message || execErr}`);
573
583
  }
@@ -585,7 +595,31 @@ async function handleLineup(args, dispatch, api, ctx) {
585
595
  const subcommand = args[0].toLowerCase();
586
596
  if (subcommand === 'load') {
587
597
  if (args.length < 2) {
588
- commitStatic(dispatch, 'error', 'Usage: /lineup load <file.yml>');
598
+ // No file arg show available lineups
599
+ try {
600
+ const lineups = (0, saver_1.listAllLineups)();
601
+ if (lineups.length === 0) {
602
+ commitStatic(dispatch, 'info', 'No lineups available. Create one with /lineup save.');
603
+ return;
604
+ }
605
+ const items = lineups.map(l => ({
606
+ id: l.name,
607
+ label: l.name,
608
+ sublabel: l.source === 'saved' ? 'saved' : 'shipped example',
609
+ }));
610
+ dispatch({
611
+ type: 'SHOW_OVERLAY',
612
+ overlay: {
613
+ type: 'lineups',
614
+ title: 'Available Lineups',
615
+ items,
616
+ hint: 'Usage: /lineup load <name> \u00B7 esc=close',
617
+ },
618
+ });
619
+ }
620
+ catch {
621
+ commitStatic(dispatch, 'error', 'Usage: /lineup load <name>');
622
+ }
589
623
  return;
590
624
  }
591
625
  const filePath = args[1];
@@ -57,9 +57,11 @@ function ConversationStream({ conversation, sentMessages, contentHeight, overflo
57
57
  const allConvoMsgs = [...conversation];
58
58
  for (const m of sentMessages) {
59
59
  const ts = new Date(m.timestamp).getTime();
60
+ // Strip @player prefix from sent text for comparison (server doesn't have it)
61
+ const sentBody = m.text.replace(/^@\S+\s+/, '');
60
62
  const alreadyOnServer = conversation.some(c => c.direction === 'out' &&
61
63
  Math.abs(new Date(c.timestamp).getTime() - ts) < 30000 &&
62
- c.text.slice(0, 60) === m.text.slice(0, 60));
64
+ c.text.slice(0, 60) === sentBody.slice(0, 60));
63
65
  if (!alreadyOnServer) {
64
66
  allConvoMsgs.push({ id: `local-${m.timestamp}`, from: 'you', to: m.to, text: m.text, timestamp: m.timestamp, direction: 'out' });
65
67
  }