claude-tempo 0.7.0 → 0.8.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/CLAUDE.md CHANGED
@@ -30,10 +30,11 @@ src/
30
30
  │ ├── listen.ts # Manual message check
31
31
  │ ├── recruit.ts # Spawn new session
32
32
  │ ├── report.ts # Report to conductor
33
- │ ├── terminate.ts # Terminate a session
33
+ │ ├── stop.ts # Stop a session
34
34
  │ └── helpers.ts # Zod/MCP tool registration wrapper
35
35
  ├── types.ts # Shared type definitions
36
36
  ├── channel.ts # Claude channel notification helper
37
+ ├── git-info.ts # Git repository detection helper
37
38
  └── config.ts # Env var handling
38
39
  ```
39
40
 
@@ -66,8 +67,9 @@ npm test
66
67
  - **Ensemble**: The set of all active players, namespaced by `CLAUDE_TEMPO_ENSEMBLE`
67
68
  - **Cue**: A message sent to a player by name via Temporal signal
68
69
  - **Part**: A player's description of what it's working on
69
- - **Recruit**: Spawning a new Claude Code session as a player
70
+ - **Recruit**: Spawning a new Claude Code session as a player. The workflow is pre-created with the initial message before the process spawns, ensuring reliable delivery.
70
71
  - **set_name**: Players start with a random hex ID; `set_name` updates the `ClaudeTempoPlayerId` search attribute to a human-readable name
72
+ - **Session status**: Each session has a status (`pending` → `active` → `stale`) tracked via `ClaudeTempoStatus` search attribute. Pre-created workflows start as `pending`, transition to `active` when the process connects, and become `stale` if messages go undelivered for 3+ minutes.
71
73
 
72
74
  ## Dashboard
73
75
 
package/README.md CHANGED
@@ -1,6 +1,13 @@
1
- # claude-tempo
2
-
3
- Multi-session [Claude Code](https://claude.ai/code) coordination via [Temporal](https://temporal.io).
1
+ <p align="center">
2
+ <picture>
3
+ <source media="(prefers-color-scheme: dark)" srcset="assets/logo-dark.svg">
4
+ <source media="(prefers-color-scheme: light)" srcset="assets/logo-light.svg">
5
+ <img alt="claude-tempo" src="assets/logo-light.svg" height="140">
6
+ </picture>
7
+ </p>
8
+ <p align="center">
9
+ Multi-session <a href="https://claude.ai/code">Claude Code</a> coordination via <a href="https://temporal.io">Temporal</a>.
10
+ </p>
4
11
 
5
12
  Multiple Claude Code sessions discover each other, exchange messages in real time, and coordinate work — across machines, not just localhost.
6
13
 
@@ -174,7 +181,7 @@ Ensemble: myband
174
181
  Building the REST endpoints
175
182
  /Users/me/projects/app feat/api my-machine.local
176
183
 
177
- bob
184
+ bob (pending)
178
185
  Working on the dashboard
179
186
  /Users/me/projects/app feat/ui my-machine.local
180
187
 
@@ -210,7 +217,7 @@ These tools are available inside Claude Code sessions connected to claude-tempo:
210
217
  | `listen` | Manually check for pending messages. |
211
218
  | `recruit` | Spawn a new Claude Code session in a directory. Can recruit a conductor with `conductor: true`. |
212
219
  | `report` | Send updates to the conductor. No-op if no conductor exists. |
213
- | `terminate` | Terminate a player session by name. |
220
+ | `stop` | Stop a player session by name. |
214
221
  | `schedule` | Create a one-shot or recurring schedule to cue a player. |
215
222
  | `unschedule` | Cancel a named schedule. |
216
223
  | `schedules` | List all active schedules. |
@@ -398,6 +405,23 @@ Sessions start with a random 8-character hex ID. Set a name at launch with `-n`
398
405
  - Names must contain only letters, numbers, hyphens, and underscores
399
406
  - The name "conductor" is reserved for conductor sessions
400
407
 
408
+ ### Session status lifecycle
409
+
410
+ Each session has a status that tracks its connection state:
411
+
412
+ | Status | Meaning |
413
+ |--------|---------|
414
+ | `pending` | Workflow created by `recruit`, but the Claude Code process hasn't connected yet |
415
+ | `active` | Session is running and responsive |
416
+ | `stale` | Messages have gone undelivered for 3+ minutes — the session is likely disconnected |
417
+
418
+ Status transitions:
419
+ - **`pending` → `active`** — when the spawned session connects and sends its `updateMetadata` signal
420
+ - **`active` → `stale`** — when undelivered messages exceed the stale threshold (3 minutes)
421
+ - Any status → **terminated** — on graceful shutdown or `stop`
422
+
423
+ `claude-tempo status` shows `(pending)` and `(stale)` indicators next to player names. The `ClaudeTempoStatus` search attribute is also set, so you can filter sessions by status in the Temporal UI (e.g., `ClaudeTempoStatus = "stale"`).
424
+
401
425
  ### Terminal support
402
426
 
403
427
  `recruit` and the CLI detect your terminal automatically:
@@ -409,10 +433,13 @@ Sessions start with a random 8-character hex ID. Set a name at launch with `-n`
409
433
  | Terminal.app | ✓ | — | — |
410
434
  | gnome-terminal | — | ✓ | — |
411
435
  | konsole / xterm | — | ✓ | — |
436
+ | Windows Terminal | — | — | ✓ (tabs) |
412
437
  | cmd.exe / PowerShell | — | — | ✓ |
413
438
 
414
439
  macOS terminals preserve the full shell environment (fish, zsh, bash) including node version managers (fnm, nvm).
415
440
 
441
+ Windows Terminal is detected automatically via the `WT_SESSION` environment variable. When running inside Windows Terminal, recruited sessions open as new tabs (with the player name as the tab title) instead of separate cmd.exe windows.
442
+
416
443
  ## Configuration
417
444
 
418
445
  Run `claude-tempo config` to save Temporal connection settings so you don't need flags or env vars every time:
@@ -97,7 +97,7 @@ async function start(opts) {
97
97
  if (opts.replace) {
98
98
  out.log(`Stopping existing conductor for ensemble "${opts.ensemble}"...`);
99
99
  try {
100
- await handle.signal(signals_1.shutdownSignal);
100
+ await handle.signal(signals_1.updateMetadataSignal, { status: 'terminated' });
101
101
  // Wait briefly for graceful shutdown
102
102
  for (let i = 0; i < 10; i++) {
103
103
  await new Promise(r => setTimeout(r, 500));
@@ -228,6 +228,7 @@ async function status(opts) {
228
228
  host: meta.hostname || '',
229
229
  conductor: meta.isConductor || false,
230
230
  agentType: meta.agentType || 'claude',
231
+ status: meta.status || 'active',
231
232
  });
232
233
  }
233
234
  catch {
@@ -280,8 +281,11 @@ async function status(opts) {
280
281
  for (const s of members) {
281
282
  const role = s.conductor ? out.yellow(' (conductor)') : '';
282
283
  const agent = s.agentType === 'copilot' ? out.dim(' [copilot]') : '';
284
+ const statusLabel = s.status === 'stale' ? out.yellow(' (stale)')
285
+ : s.status === 'pending' ? out.dim(' (pending)')
286
+ : '';
283
287
  const name = out.bold(s.name);
284
- out.log(` ${name}${role}${agent}`);
288
+ out.log(` ${name}${role}${statusLabel}${agent}`);
285
289
  if (s.part)
286
290
  out.log(` ${out.dim(s.part)}`);
287
291
  const details = [s.workDir, s.branch, s.host].filter(Boolean).join(' ');
@@ -382,6 +386,7 @@ const SEARCH_ATTRIBUTES = [
382
386
  { name: 'ClaudeTempoGitRoot', type: 'Keyword' },
383
387
  { name: 'ClaudeTempoEnsemble', type: 'Keyword' },
384
388
  { name: 'ClaudeTempoPlayerId', type: 'Keyword' },
389
+ { name: 'ClaudeTempoStatus', type: 'Keyword' },
385
390
  ];
386
391
  function isTemporalReachable(config) {
387
392
  return (0, connection_1.createTemporalConnection)(config)
@@ -926,7 +931,7 @@ async function stop(opts) {
926
931
  continue;
927
932
  }
928
933
  }
929
- await handle.signal(signals_1.shutdownSignal);
934
+ await handle.signal(signals_1.updateMetadataSignal, { status: 'terminated' });
930
935
  stopped++;
931
936
  out.log(` ${out.dim('stopped')} ${wf.workflowId}`);
932
937
  }
@@ -986,9 +991,9 @@ async function stopByName(client, name, config, ensemble) {
986
991
  // No conductor or conductor not running — fine
987
992
  }
988
993
  }
989
- // Send shutdown signal (graceful)
994
+ // Send termination status update (graceful)
990
995
  try {
991
- await handle.signal(signals_1.shutdownSignal);
996
+ await handle.signal(signals_1.updateMetadataSignal, { status: 'terminated' });
992
997
  out.success(`Stopped "${name}"`);
993
998
  }
994
999
  catch {
@@ -179,7 +179,7 @@ async function main() {
179
179
  `- listen: Check for pending messages\n` +
180
180
  `- recruit: Spawn a new player session\n` +
181
181
  `- report: Report to the conductor\n` +
182
- `- terminate: Terminate a session\n\n` +
182
+ `- stop: Stop a session\n\n` +
183
183
  `When you receive a message from another session, treat it like a coworker asking for help — respond promptly using your MCP tools.`,
184
184
  },
185
185
  excludedTools: ['write_powershell', 'read_powershell', 'list_powershell'],
@@ -391,7 +391,7 @@ async function main() {
391
391
  polling = false;
392
392
  clearInterval(interval);
393
393
  try {
394
- await handle.signal('shutdown');
394
+ await handle.signal('updateMetadata', { status: 'terminated', terminatedBy: 'system' });
395
395
  }
396
396
  catch {
397
397
  // workflow may already be gone
@@ -0,0 +1,4 @@
1
+ export declare function getGitInfo(workDir: string): {
2
+ gitRoot?: string;
3
+ gitBranch?: string;
4
+ };
@@ -0,0 +1,28 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.getGitInfo = getGitInfo;
4
+ const child_process_1 = require("child_process");
5
+ function getGitInfo(workDir) {
6
+ try {
7
+ const gitRoot = (0, child_process_1.execSync)('git rev-parse --show-toplevel', {
8
+ cwd: workDir,
9
+ encoding: 'utf-8',
10
+ stdio: ['pipe', 'pipe', 'pipe'],
11
+ }).trim();
12
+ let gitBranch;
13
+ try {
14
+ gitBranch = (0, child_process_1.execSync)('git rev-parse --abbrev-ref HEAD', {
15
+ cwd: workDir,
16
+ encoding: 'utf-8',
17
+ stdio: ['pipe', 'pipe', 'pipe'],
18
+ }).trim();
19
+ }
20
+ catch {
21
+ // not on a branch
22
+ }
23
+ return { gitRoot, gitBranch };
24
+ }
25
+ catch {
26
+ return {};
27
+ }
28
+ }
package/dist/server.js CHANGED
@@ -37,20 +37,20 @@ Object.defineProperty(exports, "__esModule", { value: true });
37
37
  const crypto = __importStar(require("crypto"));
38
38
  const os = __importStar(require("os"));
39
39
  const path = __importStar(require("path"));
40
- const child_process_1 = require("child_process");
41
40
  const mcp_js_1 = require("@modelcontextprotocol/sdk/server/mcp.js");
42
41
  const stdio_js_1 = require("@modelcontextprotocol/sdk/server/stdio.js");
43
42
  const client_1 = require("@temporalio/client");
44
43
  const config_1 = require("./config");
45
44
  const connection_1 = require("./connection");
46
45
  const worker_1 = require("./worker");
46
+ const git_info_1 = require("./git-info");
47
47
  const ensemble_1 = require("./tools/ensemble");
48
48
  const cue_1 = require("./tools/cue");
49
49
  const set_part_1 = require("./tools/set-part");
50
50
  const listen_1 = require("./tools/listen");
51
51
  const recruit_1 = require("./tools/recruit");
52
52
  const report_1 = require("./tools/report");
53
- const terminate_1 = require("./tools/terminate");
53
+ const stop_1 = require("./tools/stop");
54
54
  const set_name_1 = require("./tools/set-name");
55
55
  const schedule_1 = require("./tools/schedule");
56
56
  const unschedule_1 = require("./tools/unschedule");
@@ -59,30 +59,6 @@ const save_ensemble_1 = require("./tools/save-ensemble");
59
59
  const load_ensemble_1 = require("./tools/load-ensemble");
60
60
  const channel_1 = require("./channel");
61
61
  const log = (...args) => console.error('[claude-tempo]', ...args);
62
- function getGitInfo(workDir) {
63
- try {
64
- const gitRoot = (0, child_process_1.execSync)('git rev-parse --show-toplevel', {
65
- cwd: workDir,
66
- encoding: 'utf-8',
67
- stdio: ['pipe', 'pipe', 'pipe'],
68
- }).trim();
69
- let gitBranch;
70
- try {
71
- gitBranch = (0, child_process_1.execSync)('git rev-parse --abbrev-ref HEAD', {
72
- cwd: workDir,
73
- encoding: 'utf-8',
74
- stdio: ['pipe', 'pipe', 'pipe'],
75
- }).trim();
76
- }
77
- catch {
78
- // not on a branch
79
- }
80
- return { gitRoot, gitBranch };
81
- }
82
- catch {
83
- return {};
84
- }
85
- }
86
62
  async function main() {
87
63
  // Only activate when explicitly opted in via CLAUDE_TEMPO_ENSEMBLE
88
64
  if (!process.env[config_1.ENV.ENSEMBLE]) {
@@ -104,7 +80,7 @@ async function main() {
104
80
  const getPlayerId = () => playerId;
105
81
  const setPlayerId = (id) => { playerId = id; };
106
82
  const workDir = process.cwd();
107
- const { gitRoot, gitBranch } = getGitInfo(workDir);
83
+ const { gitRoot, gitBranch } = (0, git_info_1.getGitInfo)(workDir);
108
84
  log(`Starting ${isConductor ? 'conductor' : `peer ${playerId}`} in ${workDir}`);
109
85
  // Connect Temporal client
110
86
  const connection = await (0, connection_1.createTemporalConnection)(config);
@@ -142,7 +118,7 @@ async function main() {
142
118
  taskQueue: config.taskQueue,
143
119
  args: [sessionInput],
144
120
  workflowIdConflictPolicy: client_1.WorkflowIdConflictPolicy.USE_EXISTING,
145
- // No execution timeout — workflows live until shutdown signal or stale detection.
121
+ // No execution timeout — workflows live until terminated status or stale detection.
146
122
  searchAttributes: {
147
123
  ...(gitRoot ? { ClaudeTempoGitRoot: [gitRoot] } : {}),
148
124
  ClaudeTempoHostname: [os.hostname()],
@@ -151,6 +127,35 @@ async function main() {
151
127
  },
152
128
  });
153
129
  log(`Workflow ${workflowId} started (or reconnected)`);
130
+ // Watch for workflow completion — exit the process when the workflow ends
131
+ // (e.g., via stop tool setting status to 'terminated')
132
+ handle.result().then(() => {
133
+ log('Workflow completed — shutting down');
134
+ stopPoller();
135
+ worker.shutdown();
136
+ workerRunPromise.catch(() => { }).then(() => process.exit(0));
137
+ }).catch((err) => {
138
+ // Only exit on workflow-level errors (cancelled, failed), not transient connection errors
139
+ const name = err?.name || '';
140
+ if (name.includes('WorkflowFailed') || name.includes('WorkflowCancelled') || name.includes('WorkflowNotFound')) {
141
+ log('Workflow ended unexpectedly — shutting down');
142
+ stopPoller();
143
+ worker.shutdown();
144
+ workerRunPromise.catch(() => { }).then(() => process.exit(1));
145
+ }
146
+ else {
147
+ log('Transient error watching workflow result:', err?.message || err);
148
+ }
149
+ });
150
+ // If the workflow was pre-created by a recruiter, update it with real metadata
151
+ // and mark the session as active now that it's connected.
152
+ await handle.signal('updateMetadata', {
153
+ hostname: os.hostname(),
154
+ gitRoot,
155
+ gitBranch,
156
+ status: 'active',
157
+ enableStaleDetection: true,
158
+ });
154
159
  // If there's a conductor running, announce ourselves
155
160
  if (!isConductor) {
156
161
  try {
@@ -193,7 +198,7 @@ async function main() {
193
198
  (0, listen_1.registerListenTool)(mcpServer, handle);
194
199
  (0, recruit_1.registerRecruitTool)(mcpServer, client, config, getPlayerId, isBridgeMode ? 'copilot' : 'claude');
195
200
  (0, report_1.registerReportTool)(mcpServer, client, config, getPlayerId);
196
- (0, terminate_1.registerTerminateTool)(mcpServer, client, config, getPlayerId);
201
+ (0, stop_1.registerStopTool)(mcpServer, client, config, getPlayerId);
197
202
  (0, schedule_1.registerScheduleTool)(mcpServer, client, config, getPlayerId);
198
203
  (0, unschedule_1.registerUnscheduleTool)(mcpServer, client, config);
199
204
  (0, schedules_1.registerSchedulesTool)(mcpServer, client, config);
@@ -236,15 +241,10 @@ async function main() {
236
241
  log('Shutting down...');
237
242
  stopPoller();
238
243
  try {
239
- await handle.signal('shutdown');
244
+ await handle.signal('updateMetadata', { status: 'terminated', terminatedBy: 'system' });
240
245
  }
241
246
  catch {
242
- try {
243
- await handle.cancel();
244
- }
245
- catch {
246
- // workflow may already be gone
247
- }
247
+ // workflow may already be gone
248
248
  }
249
249
  worker.shutdown();
250
250
  await workerRunPromise.catch(() => { });
package/dist/spawn.js CHANGED
@@ -176,8 +176,43 @@ function spawnInTerminal(claudeArgs, workDir, envVars) {
176
176
  return { pid: child.pid };
177
177
  }
178
178
  if (process.platform === 'win32') {
179
- // Use 'start' to open a visible terminal window, and pass the full
180
- // command as a single string to avoid DEP0190 deprecation warning
179
+ // Detect Windows Terminal: WT_SESSION env var is set when running inside it.
180
+ // wt.exe is a UWP app execution alias that Node.js can't resolve directly,
181
+ // but `cmd.exe /c start "" wt.exe ...` works through the Windows shell.
182
+ const hasWt = Boolean(process.env.WT_SESSION);
183
+ if (hasWt) {
184
+ // Extract player name from claudeArgs (-n <name>) for tab title
185
+ const nameIdx = claudeArgs.indexOf('-n');
186
+ const tabTitle = nameIdx !== -1 && nameIdx + 1 < claudeArgs.length
187
+ ? claudeArgs[nameIdx + 1]
188
+ : 'claude-tempo';
189
+ // Build inline env var assignments for cmd /c since wt.exe spawns
190
+ // a new process that won't inherit our env.
191
+ // Escape values for cmd.exe: wrap in quotes and escape inner special chars.
192
+ const cmdEscape = (s) => s.replace(/([&|<>^"%])/g, '^$1');
193
+ const setCmds = Object.entries(envVars)
194
+ .map(([k, v]) => `set "${k}=${cmdEscape(v)}"`)
195
+ .join(' && ');
196
+ const claudeCmd = `${cmdEscape(claudeBin)} ${claudeArgs.map(a => `"${cmdEscape(a)}"`).join(' ')}`;
197
+ const innerCmd = setCmds
198
+ ? `${setCmds} && ${claudeCmd}`
199
+ : claudeCmd;
200
+ // Use `cmd.exe /c start "" wt.exe ...` to resolve the UWP app alias
201
+ const child = (0, child_process_1.spawn)('cmd.exe', [
202
+ '/c', 'start', '',
203
+ 'wt.exe', '-w', '0',
204
+ 'new-tab',
205
+ '--title', tabTitle,
206
+ '-d', workDir,
207
+ 'cmd', '/k', innerCmd,
208
+ ], {
209
+ detached: true,
210
+ stdio: 'ignore',
211
+ });
212
+ child.unref();
213
+ return { pid: child.pid };
214
+ }
215
+ // Fallback: open a new cmd.exe window
181
216
  const child = (0, child_process_1.spawn)('cmd.exe', ['/c', 'start', '""', claudeBin, ...claudeArgs], {
182
217
  cwd: workDir,
183
218
  detached: true,
@@ -74,6 +74,7 @@ function registerEnsembleTool(server, client, config, getPlayerId, ownWorkflowId
74
74
  gitBranch: metadata.gitBranch,
75
75
  isConductor: metadata.isConductor,
76
76
  agentType: metadata.agentType || 'claude',
77
+ status: metadata.status,
77
78
  isYou: metadata.playerId === getPlayerId(),
78
79
  });
79
80
  }
@@ -98,6 +99,8 @@ function registerEnsembleTool(server, client, config, getPlayerId, ownWorkflowId
98
99
  p.isYou ? '(you)' : '',
99
100
  p.isConductor ? '(conductor)' : '',
100
101
  p.agentType === 'copilot' ? '[copilot]' : '',
102
+ p.status === 'stale' ? '(stale)' : '',
103
+ p.status === 'pending' ? '(pending)' : '',
101
104
  ].filter(Boolean).join(' ');
102
105
  return [
103
106
  `**${p.playerId}** ${tags}`.trim(),
@@ -1,8 +1,44 @@
1
1
  "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
2
35
  Object.defineProperty(exports, "__esModule", { value: true });
3
36
  exports.registerRecruitTool = registerRecruitTool;
37
+ const os = __importStar(require("os"));
4
38
  const zod_1 = require("zod");
39
+ const client_1 = require("@temporalio/client");
5
40
  const config_1 = require("../config");
41
+ const git_info_1 = require("../git-info");
6
42
  const spawn_1 = require("../spawn");
7
43
  const resolve_1 = require("./resolve");
8
44
  const helpers_1 = require("./helpers");
@@ -57,7 +93,7 @@ function registerRecruitTool(server, client, config, getPlayerId, ownAgentType =
57
93
  return {
58
94
  content: [{
59
95
  type: 'text',
60
- 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.`,
96
+ text: `A conductor is already running in ensemble "${config.ensemble}". Use \`claude-tempo conduct --replace\` from the CLI to replace it, or \`stop\` it first.`,
61
97
  }],
62
98
  isError: true,
63
99
  };
@@ -73,17 +109,53 @@ function registerRecruitTool(server, client, config, getPlayerId, ownAgentType =
73
109
  return {
74
110
  content: [{
75
111
  type: 'text',
76
- text: `Session **${name}** is already active. Use \`cue\` to send it a message, or \`terminate\` it first.`,
112
+ text: `Session **${name}** is already active. Use \`cue\` to send it a message, or \`stop\` it first.`,
77
113
  }],
78
114
  isError: true,
79
115
  };
80
116
  }
81
- // Record existing workflows so we can find the new one
82
- const existingIds = new Set();
83
- const listQuery = `WorkflowType = "claudeSessionWorkflow" AND ExecutionStatus = "Running"`;
84
- for await (const wf of client.workflow.list({ query: listQuery })) {
85
- existingIds.add(wf.workflowId);
86
- }
117
+ // Pre-create the Temporal workflow so the initial message is already loaded
118
+ const workflowId = isConductor
119
+ ? (0, config_1.conductorWorkflowId)(config.ensemble)
120
+ : (0, config_1.sessionWorkflowId)(config.ensemble, name);
121
+ const { gitRoot, gitBranch } = (0, git_info_1.getGitInfo)(workDir);
122
+ const sessionInput = {
123
+ metadata: {
124
+ playerId: name,
125
+ ensemble: config.ensemble,
126
+ hostname: os.hostname(),
127
+ workDir,
128
+ gitRoot,
129
+ gitBranch,
130
+ isConductor,
131
+ agentType: agent,
132
+ status: 'pending',
133
+ },
134
+ autoSummary: `Session in ${require('path').basename(workDir)}`,
135
+ disableStaleDetection: true,
136
+ ...(initialMessage ? {
137
+ messages: [{
138
+ id: require('crypto').randomUUID(),
139
+ from: getPlayerId(),
140
+ text: initialMessage,
141
+ timestamp: new Date().toISOString(),
142
+ delivered: false,
143
+ }],
144
+ } : {}),
145
+ };
146
+ await client.workflow.start('claudeSessionWorkflow', {
147
+ workflowId,
148
+ taskQueue: config.taskQueue,
149
+ args: [sessionInput],
150
+ workflowIdConflictPolicy: client_1.WorkflowIdConflictPolicy.USE_EXISTING,
151
+ searchAttributes: {
152
+ ...(gitRoot ? { ClaudeTempoGitRoot: [gitRoot] } : {}),
153
+ ClaudeTempoHostname: [os.hostname()],
154
+ ClaudeTempoEnsemble: [config.ensemble],
155
+ ClaudeTempoPlayerId: [name],
156
+ },
157
+ });
158
+ log(`Pre-created workflow ${workflowId} for recruit "${name}"`);
87
159
  // Spawn the session using the selected backend
88
160
  if (agent === 'copilot') {
89
161
  const { pid } = (0, spawn_1.spawnCopilotBridge)({
@@ -122,42 +194,26 @@ function registerRecruitTool(server, client, config, getPlayerId, ownAgentType =
122
194
  const { pid } = (0, spawn_1.spawnInTerminal)(spawnArgs, workDir, envVars);
123
195
  log(`Spawned claude process (pid ${pid}) in ${workDir} as "${name}"`);
124
196
  }
125
- // Poll for the new workflow to appear (up to ~15s)
126
- let newWorkflowId = null;
127
- for (let attempt = 0; attempt < 30; attempt++) {
197
+ // Brief poll (5s) to confirm the session connected, but don't fail if it hasn't
198
+ const handle = client.workflow.getHandle(workflowId);
199
+ let confirmed = false;
200
+ for (let attempt = 0; attempt < 10; attempt++) {
128
201
  await sleep(500);
129
- for await (const wf of client.workflow.list({ query: listQuery })) {
130
- if (!existingIds.has(wf.workflowId)) {
131
- newWorkflowId = wf.workflowId;
202
+ try {
203
+ const desc = await handle.describe();
204
+ if (desc.status.name === 'RUNNING') {
205
+ confirmed = true;
132
206
  break;
133
207
  }
134
208
  }
135
- if (newWorkflowId)
136
- break;
137
- }
138
- if (!newWorkflowId) {
139
- return {
140
- content: [{
141
- type: 'text',
142
- text: `Session "${name}" spawned but did not register within 15 seconds. It may still be starting up. Check \`ensemble\` shortly.`,
143
- }],
144
- };
145
- }
146
- const newHandle = client.workflow.getHandle(newWorkflowId);
147
- // Name is already set via CLAUDE_TEMPO_PLAYER_NAME env var at startup,
148
- // so we only need to send the initial task message if provided.
149
- // (Previously we sent a set_name instruction here, but that was redundant
150
- // and could cause confusion if the LLM renamed itself incorrectly.)
151
- if (initialMessage) {
152
- await newHandle.signal('receiveMessage', {
153
- from: getPlayerId(),
154
- text: initialMessage,
155
- });
209
+ catch { /* not ready yet */ }
156
210
  }
157
211
  return {
158
212
  content: [{
159
213
  type: 'text',
160
- text: `Recruited session **${name}** in ${workDir}. It will set its name shortly.${initialMessage ? ' Initial task sent.' : ''}`,
214
+ text: confirmed
215
+ ? `Recruited session **${name}** in ${workDir}. Workflow running.${initialMessage ? ' Initial task pre-loaded.' : ''}`
216
+ : `Recruited session **${name}** in ${workDir}. Workflow pre-created, session still starting up.${initialMessage ? ' Initial task pre-loaded.' : ''}`,
161
217
  }],
162
218
  };
163
219
  }
@@ -1,4 +1,4 @@
1
1
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
2
  import { Client } from '@temporalio/client';
3
3
  import { Config } from '../config';
4
- export declare function registerTerminateTool(server: McpServer, client: Client, config: Config, getPlayerId: () => string): void;
4
+ export declare function registerStopTool(server: McpServer, client: Client, config: Config, getPlayerId: () => string): void;
@@ -0,0 +1,50 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.registerStopTool = registerStopTool;
4
+ const zod_1 = require("zod");
5
+ const config_1 = require("../config");
6
+ const resolve_1 = require("./resolve");
7
+ const helpers_1 = require("./helpers");
8
+ function registerStopTool(server, client, config, getPlayerId) {
9
+ (0, helpers_1.defineTool)(server, 'stop', 'Stop a player session by name. Signals termination and the session exits gracefully.', {
10
+ playerId: zod_1.z.string().describe('The player name of the session to stop'),
11
+ }, async (args) => {
12
+ const { playerId } = args;
13
+ if (playerId === getPlayerId()) {
14
+ return {
15
+ content: [{ type: 'text', text: 'Cannot stop your own session.' }],
16
+ isError: true,
17
+ };
18
+ }
19
+ try {
20
+ const handle = await (0, resolve_1.resolveSession)(client, config.ensemble, playerId);
21
+ if (!handle) {
22
+ return {
23
+ content: [{ type: 'text', text: `No active session found with name "${playerId}".` }],
24
+ isError: true,
25
+ };
26
+ }
27
+ // Notify the conductor before stopping (matches CLI stop behavior)
28
+ try {
29
+ const conductorHandle = client.workflow.getHandle((0, config_1.conductorWorkflowId)(config.ensemble));
30
+ await conductorHandle.signal('receiveMessage', {
31
+ from: getPlayerId(),
32
+ text: `Stopping session **${playerId}**.`,
33
+ });
34
+ }
35
+ catch {
36
+ // No conductor running — that's fine
37
+ }
38
+ await handle.signal('updateMetadata', { status: 'terminated', terminatedBy: getPlayerId() });
39
+ return {
40
+ content: [{ type: 'text', text: `Stop signal sent to **${playerId}**. The session will exit gracefully.` }],
41
+ };
42
+ }
43
+ catch (err) {
44
+ return {
45
+ content: [{ type: 'text', text: `Failed to stop: ${err}` }],
46
+ isError: true,
47
+ };
48
+ }
49
+ });
50
+ }
package/dist/types.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export type AgentType = 'claude' | 'copilot';
2
+ export type SessionStatus = 'active' | 'stale' | 'pending' | 'terminated';
2
3
  export interface SessionMetadata {
3
4
  playerId: string;
4
5
  ensemble: string;
@@ -8,6 +9,7 @@ export interface SessionMetadata {
8
9
  gitBranch?: string;
9
10
  isConductor: boolean;
10
11
  agentType?: AgentType;
12
+ status?: SessionStatus;
11
13
  }
12
14
  export interface SessionInput {
13
15
  metadata: SessionMetadata;