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 +4 -2
- package/README.md +32 -5
- package/dist/cli/commands.js +10 -5
- package/dist/copilot-bridge.js +2 -2
- package/dist/git-info.d.ts +4 -0
- package/dist/git-info.js +28 -0
- package/dist/server.js +36 -36
- package/dist/spawn.js +37 -2
- package/dist/tools/ensemble.js +3 -0
- package/dist/tools/recruit.js +92 -36
- package/dist/tools/{terminate.d.ts → stop.d.ts} +1 -1
- package/dist/tools/stop.js +50 -0
- package/dist/types.d.ts +2 -0
- package/dist/workflows/session.js +50 -31
- package/dist/workflows/signals.d.ts +9 -2
- package/dist/workflows/signals.js +2 -2
- package/package.json +1 -1
- package/workflow-bundle.js +53 -34
- package/dist/tools/terminate.js +0 -52
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
|
-
│ ├──
|
|
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
|
-
|
|
2
|
-
|
|
3
|
-
|
|
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
|
-
| `
|
|
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:
|
package/dist/cli/commands.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
|
994
|
+
// Send termination status update (graceful)
|
|
990
995
|
try {
|
|
991
|
-
await handle.signal(signals_1.
|
|
996
|
+
await handle.signal(signals_1.updateMetadataSignal, { status: 'terminated' });
|
|
992
997
|
out.success(`Stopped "${name}"`);
|
|
993
998
|
}
|
|
994
999
|
catch {
|
package/dist/copilot-bridge.js
CHANGED
|
@@ -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
|
-
`-
|
|
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('
|
|
394
|
+
await handle.signal('updateMetadata', { status: 'terminated', terminatedBy: 'system' });
|
|
395
395
|
}
|
|
396
396
|
catch {
|
|
397
397
|
// workflow may already be gone
|
package/dist/git-info.js
ADDED
|
@@ -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
|
|
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
|
|
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,
|
|
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('
|
|
244
|
+
await handle.signal('updateMetadata', { status: 'terminated', terminatedBy: 'system' });
|
|
240
245
|
}
|
|
241
246
|
catch {
|
|
242
|
-
|
|
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
|
-
//
|
|
180
|
-
//
|
|
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,
|
package/dist/tools/ensemble.js
CHANGED
|
@@ -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(),
|
package/dist/tools/recruit.js
CHANGED
|
@@ -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 \`
|
|
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 \`
|
|
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
|
-
//
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
//
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
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;
|