claude-tempo 0.4.1 → 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.
@@ -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>;
@@ -3,25 +3,17 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.resolveSession = resolveSession;
4
4
  /**
5
5
  * Resolve a session by player name.
6
- * 1. Try search attribute query (fast, indexed but eventually consistent)
7
- * 2. Fall back to listing all ensemble workflows and querying metadata (always current)
6
+ * Lists all running session workflows and queries each for metadata.
7
+ * This avoids depending on custom search attributes which are eventually
8
+ * consistent and may be missing or stale.
8
9
  */
9
- function escapeQueryString(s) {
10
- return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
11
- }
12
10
  async function resolveSession(client, ensemble, playerName) {
13
- // Fast path: search attribute (may lag behind by a few seconds after rename)
14
- const saQuery = `WorkflowType = "claudeSessionWorkflow" AND ExecutionStatus = "Running" AND ClaudeTempoEnsemble = "${escapeQueryString(ensemble)}" AND ClaudeTempoPlayerId = "${escapeQueryString(playerName)}"`;
15
- for await (const wf of client.workflow.list({ query: saQuery })) {
16
- return client.workflow.getHandle(wf.workflowId);
17
- }
18
- // Fallback: list all ensemble workflows and check in-memory metadata
19
- const fallbackQuery = `WorkflowType = "claudeSessionWorkflow" AND ExecutionStatus = "Running" AND ClaudeTempoEnsemble = "${escapeQueryString(ensemble)}"`;
20
- for await (const wf of client.workflow.list({ query: fallbackQuery })) {
11
+ const query = `WorkflowType = "claudeSessionWorkflow" AND ExecutionStatus = "Running"`;
12
+ for await (const wf of client.workflow.list({ query })) {
21
13
  try {
22
14
  const handle = client.workflow.getHandle(wf.workflowId);
23
15
  const metadata = await handle.query('getMetadata');
24
- if (metadata.playerId === playerName) {
16
+ if (metadata.ensemble === ensemble && metadata.playerId === playerName) {
25
17
  return handle;
26
18
  }
27
19
  }
@@ -2,10 +2,11 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.registerTerminateTool = registerTerminateTool;
4
4
  const zod_1 = require("zod");
5
+ const signals_1 = require("../workflows/signals");
5
6
  const resolve_1 = require("./resolve");
6
7
  const helpers_1 = require("./helpers");
7
8
  function registerTerminateTool(server, client, config, getPlayerId) {
8
- (0, helpers_1.defineTool)(server, 'terminate', 'Terminate a player session by name. Use this to clean up orphaned sessions.', {
9
+ (0, helpers_1.defineTool)(server, 'terminate', 'Terminate a player session by name. Sends a graceful shutdown signal so the session can clean up.', {
9
10
  playerId: zod_1.z.string().describe('The player name of the session to terminate'),
10
11
  }, async (args) => {
11
12
  const { playerId } = args;
@@ -23,19 +24,22 @@ function registerTerminateTool(server, client, config, getPlayerId) {
23
24
  isError: true,
24
25
  };
25
26
  }
26
- // Warn the session before terminating
27
+ // Notify the session before shutting it down. The message poller
28
+ // delivers this to the Claude session so it knows why it lost its tools.
27
29
  try {
28
30
  await handle.signal('receiveMessage', {
29
31
  from: getPlayerId(),
30
- text: `Your session is being terminated by player ${getPlayerId()}. Please save your work and close this terminal.`,
32
+ text: `Your session has been terminated by ${getPlayerId()}. Your claude-tempo tools will stop working shortly.`,
31
33
  });
34
+ // Brief delay to let the poller deliver the message before shutdown
35
+ await new Promise(r => setTimeout(r, 1000));
32
36
  }
33
37
  catch {
34
- // May fail if workflow is in a bad state — proceed with termination
38
+ // May fail if workflow is in a bad state — proceed with shutdown
35
39
  }
36
- await handle.terminate(`Terminated by player ${getPlayerId()}`);
40
+ await handle.signal(signals_1.shutdownSignal);
37
41
  return {
38
- content: [{ type: 'text', text: `Session **${playerId}** terminated. If the Claude Code terminal is still open, the user will need to close it manually.` }],
42
+ content: [{ type: 'text', text: `Shutdown signal sent to **${playerId}**. The workflow and MCP server will exit. The Claude Code terminal may remain open and will need to be closed manually.` }],
39
43
  };
40
44
  }
41
45
  catch (err) {
package/dist/types.d.ts CHANGED
@@ -31,6 +31,8 @@ export interface Message {
31
31
  text: string;
32
32
  timestamp: string;
33
33
  delivered: boolean;
34
+ /** True when sent from the Maestro dashboard by a human. */
35
+ isMaestro?: boolean;
34
36
  }
35
37
  export interface SentMessage {
36
38
  id: string;
@@ -6,6 +6,15 @@ const signals_1 = require("./signals");
6
6
  async function claudeSessionWorkflow(input) {
7
7
  const STALE_MESSAGE_MS = 3 * 60 * 1000; // 3 minutes
8
8
  const HEARTBEAT_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
9
+ // Ensure search attributes are always current — critical when reconnecting
10
+ // via WorkflowIdConflictPolicy.USE_EXISTING, which skips the attributes
11
+ // passed to client.workflow.start().
12
+ (0, workflow_1.upsertSearchAttributes)({
13
+ ClaudeTempoEnsemble: [input.metadata.ensemble],
14
+ ClaudeTempoPlayerId: [input.metadata.playerId],
15
+ ClaudeTempoHostname: [input.metadata.hostname],
16
+ ...(input.metadata.gitRoot ? { ClaudeTempoGitRoot: [input.metadata.gitRoot] } : {}),
17
+ });
9
18
  // State (carried across continue-as-new)
10
19
  let part = input.part ?? input.autoSummary ?? 'No description set';
11
20
  const messages = input.messages ?? [];
@@ -20,7 +29,9 @@ async function claudeSessionWorkflow(input) {
20
29
  text: msg.text,
21
30
  timestamp: new Date().toISOString(),
22
31
  delivered: false,
32
+ isMaestro: msg.isMaestro,
23
33
  });
34
+ lastActivityTime = Date.now();
24
35
  });
25
36
  (0, workflow_1.setHandler)(signals_1.setPartSignal, (newPart) => {
26
37
  part = newPart;