claude-tempo 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +35 -26
- package/dist/cli/commands.d.ts +2 -0
- package/dist/cli/commands.js +97 -51
- package/dist/cli/mcp.js +1 -1
- package/dist/cli.js +10 -0
- package/dist/config.js +7 -4
- package/dist/copilot-bridge.js +10 -5
- package/dist/server.js +9 -2
- package/dist/tools/ensemble.js +11 -5
- package/dist/tools/recruit.js +37 -3
- package/dist/tools/resolve.d.ts +6 -0
- package/dist/tools/resolve.js +6 -14
- package/dist/tools/terminate.js +10 -6
- package/dist/types.d.ts +2 -0
- package/dist/workflows/session.js +11 -0
- package/dist/workflows/signals.d.ts +1 -0
- package/package.json +28 -11
- package/workflow-bundle.js +3937 -637
package/README.md
CHANGED
|
@@ -32,7 +32,7 @@ This will:
|
|
|
32
32
|
1. Check that Temporal CLI is installed
|
|
33
33
|
2. Start the Temporal dev server (data persists in `~/.claude-tempo/`)
|
|
34
34
|
3. Register required search attributes
|
|
35
|
-
4.
|
|
35
|
+
4. Register the claude-tempo MCP server (globally by default)
|
|
36
36
|
5. Launch a conductor session in a new terminal window
|
|
37
37
|
|
|
38
38
|
Then add players:
|
|
@@ -52,7 +52,7 @@ For more control, run each step individually:
|
|
|
52
52
|
# Start Temporal dev server (keep running)
|
|
53
53
|
claude-tempo server
|
|
54
54
|
|
|
55
|
-
#
|
|
55
|
+
# Register claude-tempo MCP server (globally by default)
|
|
56
56
|
cd your-project
|
|
57
57
|
claude-tempo init
|
|
58
58
|
|
|
@@ -352,6 +352,7 @@ export TEMPORAL_API_KEY=tcl_...
|
|
|
352
352
|
| `CLAUDE_TEMPO_ENSEMBLE` | `default` | Ensemble name |
|
|
353
353
|
| `CLAUDE_TEMPO_CONDUCTOR` | `false` | Enable conductor mode |
|
|
354
354
|
| `CLAUDE_TEMPO_PLAYER_NAME` | *(random hex)* | Player name on startup |
|
|
355
|
+
| `CLAUDE_TEMPO_DEFAULT_AGENT` | `claude` | Default agent type (`claude` or `copilot`) |
|
|
355
356
|
|
|
356
357
|
## Stale session cleanup
|
|
357
358
|
|
|
@@ -359,7 +360,7 @@ When a session crashes or closes without graceful shutdown, Temporal detects it
|
|
|
359
360
|
|
|
360
361
|
- If a message to a dead session remains undelivered for **3 minutes**, the workflow self-completes
|
|
361
362
|
- Before exiting, it notifies the conductor with the undelivered message so work can be reassigned
|
|
362
|
-
- Idle sessions with no pending messages
|
|
363
|
+
- Idle sessions with no pending messages are probed after 1 hour of inactivity via a heartbeat ping; if the ping goes undelivered, the session self-completes
|
|
363
364
|
|
|
364
365
|
No manual cleanup needed — `cue` a dead player and the system handles the rest.
|
|
365
366
|
|
|
@@ -367,52 +368,60 @@ No manual cleanup needed — `cue` a dead player and the system handles the rest
|
|
|
367
368
|
|
|
368
369
|
> **Warning:** Copilot bridge support is experimental and subject to breaking changes.
|
|
369
370
|
|
|
370
|
-
GitHub Copilot CLI sessions can join an ensemble via the Copilot bridge. Bridge sessions are headless — they require a
|
|
371
|
+
GitHub Copilot CLI sessions can join an ensemble via the Copilot bridge. Bridge sessions are headless — they require a conductor or another player to receive work via `cue`.
|
|
371
372
|
|
|
372
373
|
<details>
|
|
373
374
|
<summary>Setup and usage</summary>
|
|
374
375
|
|
|
375
376
|
### Prerequisites
|
|
376
377
|
|
|
378
|
+
- [GitHub Copilot CLI](https://docs.github.com/en/copilot/github-copilot-in-the-cli) installed and authenticated
|
|
379
|
+
- An active GitHub Copilot subscription
|
|
380
|
+
- Node.js 20+
|
|
381
|
+
- Install the Copilot SDK: `npm install @github/copilot-sdk`
|
|
382
|
+
|
|
383
|
+
### Starting Copilot sessions
|
|
384
|
+
|
|
385
|
+
Use `--agent copilot` with any session-launching command:
|
|
386
|
+
|
|
377
387
|
```bash
|
|
378
|
-
|
|
388
|
+
claude-tempo start myband --agent copilot -n copilot-1 # start a player
|
|
389
|
+
claude-tempo conduct myband --agent copilot # start a conductor
|
|
390
|
+
claude-tempo up myband --agent copilot # full setup
|
|
379
391
|
```
|
|
380
392
|
|
|
381
|
-
|
|
393
|
+
Or recruit from within any active session:
|
|
382
394
|
|
|
383
|
-
|
|
395
|
+
> "Recruit a copilot session named 'copilot-dev' in /repos/my-project with agent copilot"
|
|
384
396
|
|
|
385
|
-
|
|
397
|
+
### Setting a default agent
|
|
386
398
|
|
|
387
|
-
|
|
399
|
+
To avoid passing `--agent copilot` every time:
|
|
400
|
+
|
|
401
|
+
```bash
|
|
402
|
+
claude-tempo config set default-agent copilot
|
|
403
|
+
```
|
|
388
404
|
|
|
389
|
-
Or
|
|
405
|
+
Or via environment variable:
|
|
390
406
|
|
|
391
407
|
```bash
|
|
392
|
-
|
|
408
|
+
export CLAUDE_TEMPO_DEFAULT_AGENT=copilot
|
|
393
409
|
```
|
|
394
410
|
|
|
395
|
-
|
|
411
|
+
Resolution order: `--agent` flag → `CLAUDE_TEMPO_DEFAULT_AGENT` env → config file → `claude`.
|
|
396
412
|
|
|
397
|
-
|
|
398
|
-
2. MCP server registers the session as a Temporal workflow
|
|
399
|
-
3. Bridge polls for pending messages every 2 seconds
|
|
400
|
-
4. Messages are injected as prompts via `session.sendAndWait()`
|
|
401
|
-
5. The Copilot session can use all claude-tempo tools
|
|
413
|
+
### Model override
|
|
402
414
|
|
|
403
|
-
|
|
415
|
+
Set `COPILOT_BRIDGE_MODEL` to use a specific model for Copilot sessions:
|
|
404
416
|
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
| `COPILOT_BRIDGE_MODEL` | *(Copilot default)* | Model override |
|
|
409
|
-
| `GITHUB_TOKEN` | *(logged-in user)* | GitHub auth token |
|
|
417
|
+
```bash
|
|
418
|
+
COPILOT_BRIDGE_MODEL=gpt-4o claude-tempo start myband --agent copilot
|
|
419
|
+
```
|
|
410
420
|
|
|
411
421
|
### Limitations
|
|
412
422
|
|
|
413
|
-
-
|
|
414
|
-
- 2-second polling latency (vs instant for Claude Code sessions)
|
|
415
|
-
- Must be spawned via the bridge to participate
|
|
423
|
+
- Headless only — bridge sessions respond to cues, no interactive terminal
|
|
424
|
+
- ~2-second polling latency (vs instant for Claude Code sessions)
|
|
416
425
|
- `@github/copilot-sdk` adds ~243MB to node_modules
|
|
417
426
|
- Node 20+ required (rest of claude-tempo works on Node 18+)
|
|
418
427
|
|
package/dist/cli/commands.d.ts
CHANGED
package/dist/cli/commands.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
125
|
-
|
|
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${
|
|
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
|
-
//
|
|
161
|
-
|
|
162
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
671
|
-
|
|
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
|
|
719
|
+
// Check metadata to match by name and ensemble
|
|
720
|
+
let metadata;
|
|
680
721
|
try {
|
|
681
|
-
|
|
682
|
-
if (metadata.
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
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
|
-
|
|
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 (
|
|
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
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
|
|
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
|
},
|
package/dist/copilot-bridge.js
CHANGED
|
@@ -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 =
|
|
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
|
-
: (
|
|
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]:
|
|
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) =>
|
|
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
|
-
|
|
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
|
|
205
|
+
content,
|
|
199
206
|
meta: {
|
|
200
207
|
from_player: msg.from,
|
|
201
208
|
sent_at: msg.timestamp,
|
package/dist/tools/ensemble.js
CHANGED
|
@@ -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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
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,
|
package/dist/tools/recruit.js
CHANGED
|
@@ -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
|
|
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"
|
|
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
|
};
|
package/dist/tools/resolve.d.ts
CHANGED
|
@@ -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>;
|