claude-tempo 0.4.0 → 0.5.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.
package/README.md CHANGED
@@ -32,7 +32,7 @@ This will:
32
32
  1. Check that Temporal CLI is installed
33
33
  2. Start the Temporal dev server (data persists in `~/.claude-tempo/`)
34
34
  3. Register required search attributes
35
- 4. Create `.mcp.json` in your project
35
+ 4. Register the claude-tempo MCP server (globally by default)
36
36
  5. Launch a conductor session in a new terminal window
37
37
 
38
38
  Then add players:
@@ -52,7 +52,7 @@ For more control, run each step individually:
52
52
  # Start Temporal dev server (keep running)
53
53
  claude-tempo server
54
54
 
55
- # In your project directory, create .mcp.json
55
+ # Register claude-tempo MCP server (globally by default)
56
56
  cd your-project
57
57
  claude-tempo init
58
58
 
@@ -352,6 +352,7 @@ export TEMPORAL_API_KEY=tcl_...
352
352
  | `CLAUDE_TEMPO_ENSEMBLE` | `default` | Ensemble name |
353
353
  | `CLAUDE_TEMPO_CONDUCTOR` | `false` | Enable conductor mode |
354
354
  | `CLAUDE_TEMPO_PLAYER_NAME` | *(random hex)* | Player name on startup |
355
+ | `CLAUDE_TEMPO_DEFAULT_AGENT` | `claude` | Default agent type (`claude` or `copilot`) |
355
356
 
356
357
  ## Stale session cleanup
357
358
 
@@ -359,7 +360,7 @@ When a session crashes or closes without graceful shutdown, Temporal detects it
359
360
 
360
361
  - If a message to a dead session remains undelivered for **3 minutes**, the workflow self-completes
361
362
  - Before exiting, it notifies the conductor with the undelivered message so work can be reassigned
362
- - Idle sessions with no pending messages remain running until the 24-hour timeout
363
+ - Idle sessions with no pending messages are probed after 1 hour of inactivity via a heartbeat ping; if the ping goes undelivered, the session self-completes
363
364
 
364
365
  No manual cleanup needed — `cue` a dead player and the system handles the rest.
365
366
 
@@ -367,52 +368,60 @@ No manual cleanup needed — `cue` a dead player and the system handles the rest
367
368
 
368
369
  > **Warning:** Copilot bridge support is experimental and subject to breaking changes.
369
370
 
370
- GitHub Copilot CLI sessions can join an ensemble via the Copilot bridge. Bridge sessions are headless — they require a Claude conductor or custom Temporal client to receive work via `cue`.
371
+ GitHub Copilot CLI sessions can join an ensemble via the Copilot bridge. Bridge sessions are headless — they require a conductor or another player to receive work via `cue`.
371
372
 
372
373
  <details>
373
374
  <summary>Setup and usage</summary>
374
375
 
375
376
  ### Prerequisites
376
377
 
378
+ - [GitHub Copilot CLI](https://docs.github.com/en/copilot/github-copilot-in-the-cli) installed and authenticated
379
+ - An active GitHub Copilot subscription
380
+ - Node.js 20+
381
+ - Install the Copilot SDK: `npm install @github/copilot-sdk`
382
+
383
+ ### Starting Copilot sessions
384
+
385
+ Use `--agent copilot` with any session-launching command:
386
+
377
387
  ```bash
378
- npm install @github/copilot-sdk # optional dependency (~243MB)
388
+ claude-tempo start myband --agent copilot -n copilot-1 # start a player
389
+ claude-tempo conduct myband --agent copilot # start a conductor
390
+ claude-tempo up myband --agent copilot # full setup
379
391
  ```
380
392
 
381
- Also requires [GitHub Copilot CLI](https://docs.github.com/en/copilot/github-copilot-in-the-cli) installed, authenticated, with an active subscription. Node 20+ required for Copilot features.
393
+ Or recruit from within any active session:
382
394
 
383
- ### Starting a Copilot player
395
+ > "Recruit a copilot session named 'copilot-dev' in /repos/my-project with agent copilot"
384
396
 
385
- The easiest way is via `recruit` from any active session:
397
+ ### Setting a default agent
386
398
 
387
- > "Recruit a copilot session named 'copilot-dev' in /repos/my-project with agent copilot"
399
+ To avoid passing `--agent copilot` every time:
400
+
401
+ ```bash
402
+ claude-tempo config set default-agent copilot
403
+ ```
388
404
 
389
- Or start the bridge directly:
405
+ Or via environment variable:
390
406
 
391
407
  ```bash
392
- CLAUDE_TEMPO_ENSEMBLE=default COPILOT_BRIDGE_NAME=copilot-dev npx ts-node src/copilot-bridge.ts
408
+ export CLAUDE_TEMPO_DEFAULT_AGENT=copilot
393
409
  ```
394
410
 
395
- ### How it works
411
+ Resolution order: `--agent` flag → `CLAUDE_TEMPO_DEFAULT_AGENT` env → config file → `claude`.
396
412
 
397
- 1. Bridge spawns a Copilot CLI session via the SDK with claude-tempo as MCP server
398
- 2. MCP server registers the session as a Temporal workflow
399
- 3. Bridge polls for pending messages every 2 seconds
400
- 4. Messages are injected as prompts via `session.sendAndWait()`
401
- 5. The Copilot session can use all claude-tempo tools
413
+ ### Model override
402
414
 
403
- ### Copilot environment variables
415
+ Set `COPILOT_BRIDGE_MODEL` to use a specific model for Copilot sessions:
404
416
 
405
- | Variable | Default | Description |
406
- |----------|---------|-------------|
407
- | `COPILOT_BRIDGE_NAME` | *(none)* | Player name |
408
- | `COPILOT_BRIDGE_MODEL` | *(Copilot default)* | Model override |
409
- | `GITHUB_TOKEN` | *(logged-in user)* | GitHub auth token |
417
+ ```bash
418
+ COPILOT_BRIDGE_MODEL=gpt-4o claude-tempo start myband --agent copilot
419
+ ```
410
420
 
411
421
  ### Limitations
412
422
 
413
- - No interactive access — bridge sessions only respond to cues
414
- - 2-second polling latency (vs instant for Claude Code sessions)
415
- - Must be spawned via the bridge to participate
423
+ - Headless only — bridge sessions respond to cues, no interactive terminal
424
+ - ~2-second polling latency (vs instant for Claude Code sessions)
416
425
  - `@github/copilot-sdk` adds ~243MB to node_modules
417
426
  - Node 20+ required (rest of claude-tempo works on Node 18+)
418
427
 
@@ -3,6 +3,8 @@ import { AgentType } from '../types';
3
3
  interface StartOpts extends CliOverrides {
4
4
  ensemble: string;
5
5
  conductor: boolean;
6
+ replace?: boolean;
7
+ resume?: boolean;
6
8
  name?: string;
7
9
  skipPreflight?: boolean;
8
10
  agent: AgentType;
@@ -76,12 +76,42 @@ async function start(opts) {
76
76
  if (opts.conductor) {
77
77
  try {
78
78
  const connection = await (0, connection_1.createTemporalConnection)(config);
79
- const client = new client_1.Client({ connection });
79
+ const client = new client_1.Client({ connection, namespace: config.temporalNamespace });
80
80
  const conductorWfId = (0, config_1.conductorWorkflowId)(opts.ensemble);
81
81
  const handle = client.workflow.getHandle(conductorWfId);
82
82
  const desc = await handle.describe();
83
83
  if (desc.status.name === 'RUNNING') {
84
- out.warn(`A conductor workflow already exists for ensemble "${opts.ensemble}". Reconnecting...`);
84
+ if (opts.replace) {
85
+ out.log(`Stopping existing conductor for ensemble "${opts.ensemble}"...`);
86
+ try {
87
+ await handle.signal(signals_1.shutdownSignal);
88
+ // Wait briefly for graceful shutdown
89
+ for (let i = 0; i < 10; i++) {
90
+ await new Promise(r => setTimeout(r, 500));
91
+ const check = await handle.describe();
92
+ if (check.status.name !== 'RUNNING')
93
+ break;
94
+ }
95
+ }
96
+ catch {
97
+ // Force cancel if signal fails
98
+ try {
99
+ await handle.cancel();
100
+ }
101
+ catch { /* already gone */ }
102
+ }
103
+ out.success('Existing conductor stopped');
104
+ }
105
+ else if (opts.resume) {
106
+ out.log(`Resuming conductor for ensemble "${opts.ensemble}" — reconnecting to existing workflow state.\n`);
107
+ }
108
+ else {
109
+ out.error(`A conductor is already running for ensemble "${opts.ensemble}".`);
110
+ out.log(` ${out.dim('claude-tempo conduct --resume')} Reconnect a new session to the existing workflow`);
111
+ out.log(` ${out.dim('claude-tempo conduct --replace')} Stop the existing conductor and start fresh`);
112
+ await connection.close();
113
+ process.exit(1);
114
+ }
85
115
  }
86
116
  await connection.close();
87
117
  }
@@ -117,25 +147,27 @@ async function start(opts) {
117
147
  out.success(`Launched copilot bridge${opts.name ? ` "${opts.name}"` : ''} (pid ${pid ?? 'unknown'})`);
118
148
  }
119
149
  else {
150
+ // Default conductor name to "conductor" so the Claude Code session name matches
151
+ const sessionName = opts.name || (opts.conductor ? 'conductor' : undefined);
120
152
  const claudeArgs = [
121
153
  '--dangerously-skip-permissions',
122
154
  '--dangerously-load-development-channels', 'server:claude-tempo',
123
155
  ];
124
- if (opts.name) {
125
- claudeArgs.push('-n', opts.name);
156
+ if (opts.resume && sessionName) {
157
+ // Resume the previous Claude Code conversation by name
158
+ claudeArgs.push('--resume', sessionName);
159
+ }
160
+ else if (sessionName) {
161
+ claudeArgs.push('-n', sessionName);
126
162
  }
127
163
  const envVars = {
128
164
  ...temporalEnvVars,
129
165
  [config_1.ENV.ENSEMBLE]: opts.ensemble,
166
+ [config_1.ENV.CONDUCTOR]: opts.conductor ? 'true' : '',
167
+ [config_1.ENV.PLAYER_NAME]: sessionName || '',
130
168
  };
131
- if (opts.conductor) {
132
- envVars[config_1.ENV.CONDUCTOR] = 'true';
133
- }
134
- if (opts.name) {
135
- envVars[config_1.ENV.PLAYER_NAME] = opts.name;
136
- }
137
169
  const { pid } = (0, spawn_1.spawnInTerminal)(claudeArgs, workDir, envVars);
138
- out.success(`Launched ${role} session${opts.name ? ` "${opts.name}"` : ''} (pid ${pid ?? 'unknown'})`);
170
+ out.success(`Launched ${role} session${sessionName ? ` "${sessionName}"` : ''} (pid ${pid ?? 'unknown'})`);
139
171
  }
140
172
  out.log(` Ensemble: ${opts.ensemble}`);
141
173
  out.log(` Directory: ${workDir}`);
@@ -157,11 +189,9 @@ async function status(opts) {
157
189
  return; // unreachable, helps TS
158
190
  }
159
191
  const client = new client_1.Client({ connection, namespace: config.temporalNamespace });
160
- // Build query
161
- let query = 'WorkflowType = "claudeSessionWorkflow" AND ExecutionStatus = "Running"';
162
- if (opts.ensemble) {
163
- query += ` AND ClaudeTempoEnsemble = "${opts.ensemble}"`;
164
- }
192
+ // List all running session workflows, filter by ensemble using metadata queries.
193
+ // This avoids depending on custom search attributes which are eventually consistent.
194
+ const query = 'WorkflowType = "claudeSessionWorkflow" AND ExecutionStatus = "Running"';
165
195
  const sessions = [];
166
196
  for await (const wf of client.workflow.list({ query })) {
167
197
  try {
@@ -171,11 +201,15 @@ async function status(opts) {
171
201
  handle.query('getPart').catch(() => ''),
172
202
  ]);
173
203
  const meta = metadata;
204
+ const ensemble = meta.ensemble || '?';
205
+ // Filter by ensemble if specified
206
+ if (opts.ensemble && ensemble !== opts.ensemble)
207
+ continue;
174
208
  sessions.push({
175
209
  id: wf.workflowId,
176
210
  name: meta.playerId || wf.workflowId.split('-').pop() || '?',
177
211
  part: part || '',
178
- ensemble: meta.ensemble || '?',
212
+ ensemble,
179
213
  workDir: meta.workDir || '?',
180
214
  branch: meta.gitBranch || '',
181
215
  host: meta.hostname || '',
@@ -494,16 +528,18 @@ async function up(opts) {
494
528
  }));
495
529
  }
496
530
  else {
531
+ // Default conductor name so the Claude Code session name matches the ensemble role
532
+ const sessionName = opts.name || 'conductor';
497
533
  const claudeArgs = [
498
534
  '--dangerously-skip-permissions',
499
535
  '--dangerously-load-development-channels', 'server:claude-tempo',
536
+ '-n', sessionName,
500
537
  ];
501
- if (opts.name)
502
- claudeArgs.push('-n', opts.name);
503
538
  ({ pid } = (0, spawn_1.spawnInTerminal)(claudeArgs, process.cwd(), {
504
539
  ...temporalEnvVars,
505
540
  [config_1.ENV.ENSEMBLE]: opts.ensemble,
506
541
  [config_1.ENV.CONDUCTOR]: 'true',
542
+ [config_1.ENV.PLAYER_NAME]: sessionName,
507
543
  }));
508
544
  }
509
545
  console.log();
@@ -635,14 +671,22 @@ async function stop(opts) {
635
671
  }
636
672
  else {
637
673
  // Stop multiple sessions (--ensemble or --all)
638
- let query = 'WorkflowType = "claudeSessionWorkflow" AND ExecutionStatus = "Running"';
639
- if (opts.ensemble) {
640
- query += ` AND ClaudeTempoEnsemble = "${opts.ensemble}"`;
641
- }
674
+ const query = 'WorkflowType = "claudeSessionWorkflow" AND ExecutionStatus = "Running"';
642
675
  let stopped = 0;
643
676
  for await (const wf of client.workflow.list({ query })) {
644
677
  try {
645
678
  const handle = client.workflow.getHandle(wf.workflowId);
679
+ // Filter by ensemble using metadata if specified
680
+ if (opts.ensemble) {
681
+ try {
682
+ const meta = (await handle.query('getMetadata'));
683
+ if (meta.ensemble !== opts.ensemble)
684
+ continue;
685
+ }
686
+ catch {
687
+ continue;
688
+ }
689
+ }
646
690
  await handle.signal(signals_1.shutdownSignal);
647
691
  stopped++;
648
692
  out.log(` ${out.dim('stopped')} ${wf.workflowId}`);
@@ -667,39 +711,41 @@ async function stop(opts) {
667
711
  await connection.close();
668
712
  }
669
713
  async function stopByName(client, name, config, ensemble) {
670
- // Find the workflow by player name via search attribute
671
- let query = `WorkflowType = "claudeSessionWorkflow" AND ExecutionStatus = "Running" AND ClaudeTempoPlayerId = "${name}"`;
672
- if (ensemble) {
673
- query += ` AND ClaudeTempoEnsemble = "${ensemble}"`;
674
- }
714
+ // Find the workflow by player name using metadata queries (not search attributes).
715
+ const query = `WorkflowType = "claudeSessionWorkflow" AND ExecutionStatus = "Running"`;
675
716
  let found = false;
676
717
  for await (const wf of client.workflow.list({ query })) {
677
- found = true;
678
718
  const handle = client.workflow.getHandle(wf.workflowId);
679
- // Check if this is a conductor warn about it
719
+ // Check metadata to match by name and ensemble
720
+ let metadata;
680
721
  try {
681
- const metadata = (await handle.query('getMetadata'));
682
- if (metadata.isConductor) {
683
- out.warn(`"${name}" is a conductor session`);
684
- }
685
- // Notify the conductor that this session was stopped (if it's not the conductor itself)
686
- if (!metadata.isConductor && metadata.ensemble) {
687
- try {
688
- const conductorWfId = (0, config_1.conductorWorkflowId)(metadata.ensemble);
689
- const conductorHandle = client.workflow.getHandle(conductorWfId);
690
- await conductorHandle.signal(signals_1.playerReportSignal, {
691
- playerId: name,
692
- text: 'Session stopped by CLI',
693
- type: 'result',
694
- });
695
- }
696
- catch {
697
- // No conductor or conductor not running — fine
698
- }
699
- }
722
+ metadata = (await handle.query('getMetadata'));
723
+ if (metadata.playerId !== name)
724
+ continue;
725
+ if (ensemble && metadata.ensemble !== ensemble)
726
+ continue;
700
727
  }
701
728
  catch {
702
- // Query failed — proceed with shutdown anyway
729
+ continue;
730
+ }
731
+ found = true;
732
+ if (metadata.isConductor) {
733
+ out.warn(`"${name}" is a conductor session`);
734
+ }
735
+ // Notify the conductor that this session was stopped (if it's not the conductor itself)
736
+ if (!metadata.isConductor && metadata.ensemble) {
737
+ try {
738
+ const conductorWfId = (0, config_1.conductorWorkflowId)(metadata.ensemble);
739
+ const conductorHandle = client.workflow.getHandle(conductorWfId);
740
+ await conductorHandle.signal(signals_1.playerReportSignal, {
741
+ playerId: name,
742
+ text: 'Session stopped by CLI',
743
+ type: 'result',
744
+ });
745
+ }
746
+ catch {
747
+ // No conductor or conductor not running — fine
748
+ }
703
749
  }
704
750
  // Send shutdown signal (graceful)
705
751
  try {
@@ -790,7 +836,7 @@ ${out.bold('Commands:')}
790
836
  ${out.cyan('up')} [ensemble] First-time setup: start Temporal, configure MCP, launch conductor
791
837
  ${out.cyan('down')} Stop Temporal, terminate sessions, remove MCP config
792
838
  ${out.cyan('server')} Start the Temporal dev server and register search attributes
793
- ${out.cyan('conduct')} [ensemble] Start a conductor session (one per ensemble)
839
+ ${out.cyan('conduct')} [ensemble] Start a conductor session (resumes existing, --replace to restart)
794
840
  ${out.cyan('start')} [ensemble] Start a player session
795
841
  ${out.cyan('stop')} [ensemble] Stop sessions (-n <name> for one, or --all)
796
842
  ${out.cyan('status')} [ensemble] Show active sessions and Temporal health
package/dist/cli/mcp.js CHANGED
@@ -14,7 +14,7 @@ function isGlobalMcpRegistered() {
14
14
  encoding: 'utf8',
15
15
  stdio: ['ignore', 'pipe', 'ignore'],
16
16
  });
17
- return output.includes('claude-tempo');
17
+ return /\bclaude-tempo\b/.test(output);
18
18
  }
19
19
  catch {
20
20
  return false;
package/dist/cli.js CHANGED
@@ -49,6 +49,8 @@ function parseArgs(argv) {
49
49
  keepMcp: false,
50
50
  all: false,
51
51
  project: false,
52
+ replace: false,
53
+ resume: false,
52
54
  };
53
55
  let i = 0;
54
56
  while (i < argv.length) {
@@ -89,6 +91,12 @@ function parseArgs(argv) {
89
91
  else if (arg === '--project') {
90
92
  result.project = true;
91
93
  }
94
+ else if (arg === '--replace') {
95
+ result.replace = true;
96
+ }
97
+ else if (arg === '--resume') {
98
+ result.resume = true;
99
+ }
92
100
  else if (arg === '--ensemble' && i + 1 < argv.length) {
93
101
  result.ensemble = argv[++i];
94
102
  }
@@ -142,6 +150,8 @@ async function main() {
142
150
  await (0, commands_1.start)({
143
151
  ensemble,
144
152
  conductor: true,
153
+ replace: args.replace,
154
+ resume: args.resume,
145
155
  name: args.name,
146
156
  skipPreflight: args.skipPreflight,
147
157
  agent: resolvedAgent(),
package/dist/config.js CHANGED
@@ -12,6 +12,10 @@ exports.conductorWorkflowId = conductorWorkflowId;
12
12
  const fs_1 = require("fs");
13
13
  const path_1 = require("path");
14
14
  const os_1 = require("os");
15
+ const VALID_AGENTS = ['claude', 'copilot'];
16
+ function validAgent(value) {
17
+ return VALID_AGENTS.includes(value) ? value : 'claude';
18
+ }
15
19
  /** Environment variable name constants — use these instead of string literals. */
16
20
  exports.ENV = {
17
21
  ENSEMBLE: 'CLAUDE_TEMPO_ENSEMBLE',
@@ -180,10 +184,9 @@ function getConfig(overrides = {}) {
180
184
  temporalApiKey: resolveOpt(overrides.temporalApiKey, exports.ENV.TEMPORAL_API_KEY, configFile.temporalApiKey, temporalCli.temporalApiKey),
181
185
  temporalTlsCertPath: resolveOpt(overrides.temporalTlsCertPath, exports.ENV.TEMPORAL_TLS_CERT_PATH, configFile.temporalTlsCertPath, temporalCli.temporalTlsCertPath),
182
186
  temporalTlsKeyPath: resolveOpt(overrides.temporalTlsKeyPath, exports.ENV.TEMPORAL_TLS_KEY_PATH, configFile.temporalTlsKeyPath, temporalCli.temporalTlsKeyPath),
183
- defaultAgent: (overrides.defaultAgent
187
+ defaultAgent: validAgent(overrides.defaultAgent
184
188
  || process.env[exports.ENV.DEFAULT_AGENT]
185
- || configFile.defaultAgent
186
- || 'claude'),
189
+ || configFile.defaultAgent),
187
190
  taskQueue: process.env[exports.ENV.TASK_QUEUE] ?? 'claude-tempo',
188
191
  ensemble: process.env[exports.ENV.ENSEMBLE] ?? 'default',
189
192
  };
@@ -221,7 +224,7 @@ function getConfigWithSources(overrides = {}) {
221
224
  temporalApiKey: apiKey.value,
222
225
  temporalTlsCertPath: tlsCert.value,
223
226
  temporalTlsKeyPath: tlsKey.value,
224
- defaultAgent: (defaultAgent.value || 'claude'),
227
+ defaultAgent: validAgent(defaultAgent.value),
225
228
  taskQueue: process.env[exports.ENV.TASK_QUEUE] ?? 'claude-tempo',
226
229
  ensemble: process.env[exports.ENV.ENSEMBLE] ?? 'default',
227
230
  },
@@ -116,10 +116,11 @@ async function main() {
116
116
  // `claude-session-{ensemble}-{playerId}`, where playerId comes from
117
117
  // CLAUDE_TEMPO_PLAYER_NAME or a random hex. We pass CLAUDE_TEMPO_PLAYER_NAME
118
118
  // to the MCP server env so both sides agree on the ID.
119
- const isConductor = !!process.env[config_1.ENV.CONDUCTOR];
119
+ const isConductor = process.env[config_1.ENV.CONDUCTOR] === 'true';
120
+ const requestedName = process.env[config_1.ENV.PLAYER_NAME] || playerName || '';
120
121
  const playerIdForWorkflow = isConductor
121
122
  ? 'conductor'
122
- : (process.env[config_1.ENV.PLAYER_NAME] || playerName || `copilot-${Date.now()}`);
123
+ : (requestedName && requestedName !== 'conductor' ? requestedName : '') || `copilot-${Date.now()}`;
123
124
  const expectedWorkflowId = `claude-session-${config.ensemble}-${playerIdForWorkflow}`;
124
125
  // Build the MCP server command — always use the compiled dist/server.js
125
126
  // Run `npm run build` (or `pnpm build`) before using the bridge.
@@ -137,7 +138,7 @@ async function main() {
137
138
  [config_1.ENV.TEMPORAL_ADDRESS]: config.temporalAddress,
138
139
  [config_1.ENV.TEMPORAL_NAMESPACE]: config.temporalNamespace,
139
140
  [config_1.ENV.TASK_QUEUE]: config.taskQueue,
140
- [config_1.ENV.CONDUCTOR]: process.env[config_1.ENV.CONDUCTOR] || '',
141
+ [config_1.ENV.CONDUCTOR]: isConductor ? 'true' : '',
141
142
  [config_1.ENV.BRIDGE_MODE]: '1', // disable MCP server's message poller — bridge handles delivery
142
143
  [config_1.ENV.PLAYER_NAME]: playerIdForWorkflow, // ensures MCP server uses same workflow ID
143
144
  ...(config.temporalApiKey ? { [config_1.ENV.TEMPORAL_API_KEY]: config.temporalApiKey } : {}),
@@ -286,6 +287,7 @@ async function main() {
286
287
  await session.sendAndWait({ prompt: `Call set_name("${playerName}") immediately. Respond in one short sentence.` }, 120_000);
287
288
  log(`set_name completed in ${Date.now() - t0}ms`);
288
289
  }
290
+ const MAESTRO_ACK = '\n\n[IMPORTANT: This message is from a human (Maestro). Immediately cue the sender back with a brief acknowledgment and your planned next step before doing the work.]';
289
291
  // Start message poller — inject messages into the Copilot session.
290
292
  // Tracks consecutive failures and attempts session recreation before giving up.
291
293
  let polling = true;
@@ -331,9 +333,12 @@ async function main() {
331
333
  processing = true;
332
334
  const ids = messages.map((m) => m.id);
333
335
  await handle.signal('markDelivered', ids);
334
- // Format messages into a single prompt
336
+ // Format messages into a single prompt, appending ack instruction for Maestro messages
335
337
  const prompt = messages
336
- .map((m) => `[Message from ${m.from}]: ${m.text}`)
338
+ .map((m) => {
339
+ const line = `[Message from ${m.from}]: ${m.text}`;
340
+ return m.isMaestro ? line + MAESTRO_ACK : line;
341
+ })
337
342
  .join('\n\n');
338
343
  log(`Injecting ${messages.length} message(s) into Copilot session`);
339
344
  log(`Prompt: ${prompt.substring(0, 300)}`);
package/dist/server.js CHANGED
@@ -90,7 +90,12 @@ async function main() {
90
90
  }
91
91
  const config = (0, config_1.getConfig)();
92
92
  const isConductor = process.env[config_1.ENV.CONDUCTOR] === 'true';
93
- let playerId = isConductor ? 'conductor' : (process.env[config_1.ENV.PLAYER_NAME] || crypto.randomBytes(4).toString('hex'));
93
+ const requestedName = process.env[config_1.ENV.PLAYER_NAME] || '';
94
+ // Prevent non-conductor sessions from using "conductor" as a name,
95
+ // which would collide with the conductor's deterministic workflow ID.
96
+ let playerId = isConductor
97
+ ? 'conductor'
98
+ : (requestedName && requestedName !== 'conductor' ? requestedName : '') || crypto.randomBytes(4).toString('hex');
94
99
  const getPlayerId = () => playerId;
95
100
  const setPlayerId = (id) => { playerId = id; };
96
101
  const workDir = process.cwd();
@@ -182,6 +187,7 @@ async function main() {
182
187
  (0, recruit_1.registerRecruitTool)(mcpServer, client, config, getPlayerId, isBridgeMode ? 'copilot' : 'claude');
183
188
  (0, report_1.registerReportTool)(mcpServer, client, config, getPlayerId);
184
189
  (0, terminate_1.registerTerminateTool)(mcpServer, client, config, getPlayerId);
190
+ const MAESTRO_ACK = '\n\n[IMPORTANT: This message is from a human (Maestro). Immediately cue the sender back with a brief acknowledgment and your planned next step before doing the work.]';
185
191
  // Start message poller — push messages into Claude Code via channel notifications.
186
192
  // Skip when running under the Copilot bridge: the bridge has its own poller that
187
193
  // injects messages via sendAndWait. If both pollers run, this one wins the race and
@@ -191,11 +197,12 @@ async function main() {
191
197
  : (0, channel_1.startMessagePoller)(handle, async (messages) => {
192
198
  for (const msg of messages) {
193
199
  log(`Message from ${msg.from}: ${msg.text}`);
200
+ const content = msg.isMaestro ? msg.text + MAESTRO_ACK : msg.text;
194
201
  try {
195
202
  await mcpServer.server.notification({
196
203
  method: 'notifications/claude/channel',
197
204
  params: {
198
- content: msg.text,
205
+ content,
199
206
  meta: {
200
207
  from_player: msg.from,
201
208
  sent_at: msg.timestamp,
@@ -42,23 +42,29 @@ function registerEnsembleTool(server, client, config, getPlayerId, ownWorkflowId
42
42
  scope: zod_1.z.string().optional().describe('Filter scope: "machine" (same hostname), "repo" (same git root), "all" (default). All scopes are within the current ensemble.'),
43
43
  }, async (args) => {
44
44
  const scope = (args.scope ?? 'all');
45
- let query = `WorkflowType = "claudeSessionWorkflow" AND ExecutionStatus = "Running" AND ClaudeTempoEnsemble = "${config.ensemble}"`;
46
- if (scope === 'machine') {
47
- query += ` AND ClaudeTempoHostname = "${os.hostname()}"`;
48
- }
45
+ // List all running session workflows, then filter by ensemble using
46
+ // in-memory metadata queries. This avoids depending on custom search
47
+ // attributes which are eventually consistent and may be missing/stale.
48
+ const query = `WorkflowType = "claudeSessionWorkflow" AND ExecutionStatus = "Running"`;
49
49
  const players = [];
50
50
  try {
51
51
  for await (const workflow of client.workflow.list({ query })) {
52
52
  try {
53
53
  const handle = client.workflow.getHandle(workflow.workflowId);
54
54
  const metadata = await handle.query('getMetadata');
55
- const part = await handle.query('getPart');
55
+ // Filter by ensemble
56
+ if (metadata.ensemble !== config.ensemble)
57
+ continue;
58
+ // Filter by scope
59
+ if (scope === 'machine' && metadata.hostname !== os.hostname())
60
+ continue;
56
61
  if (scope === 'repo') {
57
62
  const ownHandle = client.workflow.getHandle(ownWorkflowId);
58
63
  const ownMeta = await ownHandle.query('getMetadata');
59
64
  if (metadata.gitRoot !== ownMeta.gitRoot)
60
65
  continue;
61
66
  }
67
+ const part = await handle.query('getPart');
62
68
  players.push({
63
69
  playerId: metadata.playerId,
64
70
  part,
@@ -14,14 +14,17 @@ function registerRecruitTool(server, client, config, getPlayerId, ownAgentType =
14
14
  (0, helpers_1.defineTool)(server, 'recruit', `Start a new named session in a directory. Rejects if the name is already active. Supports Claude Code or Copilot CLI agents. Defaults to "${ownAgentType}" (same as this session).`, {
15
15
  workDir: zod_1.z.string().describe('The working directory for the new session'),
16
16
  name: zod_1.z.string().describe('Name for the new session'),
17
+ conductor: zod_1.z.boolean().optional()
18
+ .describe('Whether this session is a conductor (default: false)'),
17
19
  initialMessage: zod_1.z.string().optional()
18
20
  .describe('Optional task or message for the new session (sent after it sets its name)'),
19
21
  agent: zod_1.z.enum(['claude', 'copilot']).optional()
20
22
  .describe(`Which agent to use (default: "${ownAgentType}", same as this session)`),
21
23
  }, async (args) => {
22
24
  const { workDir, name, initialMessage } = args;
25
+ const isConductor = args.conductor === true;
23
26
  const agent = args.agent || ownAgentType;
24
- // Validate name to prevent search attribute query injection
27
+ // Validate name
25
28
  if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
26
29
  return {
27
30
  content: [{
@@ -31,7 +34,36 @@ function registerRecruitTool(server, client, config, getPlayerId, ownAgentType =
31
34
  isError: true,
32
35
  };
33
36
  }
37
+ if (name === 'conductor' && !isConductor) {
38
+ return {
39
+ content: [{
40
+ type: 'text',
41
+ text: `The name "conductor" is reserved for conductor sessions. Use a different name, or set conductor: true.`,
42
+ }],
43
+ isError: true,
44
+ };
45
+ }
34
46
  try {
47
+ // Check if a conductor already exists when recruiting a conductor
48
+ if (isConductor) {
49
+ try {
50
+ const conductorWfId = (0, config_1.conductorWorkflowId)(config.ensemble);
51
+ const conductorHandle = client.workflow.getHandle(conductorWfId);
52
+ const desc = await conductorHandle.describe();
53
+ if (desc.status.name === 'RUNNING') {
54
+ return {
55
+ content: [{
56
+ type: 'text',
57
+ text: `A conductor is already running in ensemble "${config.ensemble}". Use \`claude-tempo conduct --replace\` from the CLI to replace it, or \`terminate\` it first.`,
58
+ }],
59
+ isError: true,
60
+ };
61
+ }
62
+ }
63
+ catch {
64
+ // No existing conductor — proceed
65
+ }
66
+ }
35
67
  // Check if a session with this name is already active
36
68
  const existing = await (0, resolve_1.resolveSession)(client, config.ensemble, name);
37
69
  if (existing) {
@@ -45,7 +77,7 @@ function registerRecruitTool(server, client, config, getPlayerId, ownAgentType =
45
77
  }
46
78
  // Record existing workflows so we can find the new one
47
79
  const existingIds = new Set();
48
- const listQuery = `WorkflowType = "claudeSessionWorkflow" AND ExecutionStatus = "Running" AND ClaudeTempoEnsemble = "${config.ensemble}"`;
80
+ const listQuery = `WorkflowType = "claudeSessionWorkflow" AND ExecutionStatus = "Running"`;
49
81
  for await (const wf of client.workflow.list({ query: listQuery })) {
50
82
  existingIds.add(wf.workflowId);
51
83
  }
@@ -59,6 +91,7 @@ function registerRecruitTool(server, client, config, getPlayerId, ownAgentType =
59
91
  temporalApiKey: config.temporalApiKey,
60
92
  temporalTlsCertPath: config.temporalTlsCertPath,
61
93
  temporalTlsKeyPath: config.temporalTlsKeyPath,
94
+ isConductor,
62
95
  workDir,
63
96
  });
64
97
  log(`Spawned copilot-bridge (pid ${pid}) in ${workDir} as "${name}"`);
@@ -71,7 +104,8 @@ function registerRecruitTool(server, client, config, getPlayerId, ownAgentType =
71
104
  ];
72
105
  const envVars = {
73
106
  [config_1.ENV.ENSEMBLE]: config.ensemble,
74
- [config_1.ENV.CONDUCTOR]: '',
107
+ [config_1.ENV.CONDUCTOR]: isConductor ? 'true' : '',
108
+ [config_1.ENV.PLAYER_NAME]: name,
75
109
  [config_1.ENV.TEMPORAL_ADDRESS]: config.temporalAddress,
76
110
  [config_1.ENV.TEMPORAL_NAMESPACE]: config.temporalNamespace,
77
111
  };
@@ -1,2 +1,8 @@
1
1
  import { Client, WorkflowHandle } from '@temporalio/client';
2
+ /**
3
+ * Resolve a session by player name.
4
+ * Lists all running session workflows and queries each for metadata.
5
+ * This avoids depending on custom search attributes which are eventually
6
+ * consistent and may be missing or stale.
7
+ */
2
8
  export declare function resolveSession(client: Client, ensemble: string, playerName: string): Promise<WorkflowHandle | null>;