claude-tempo 0.1.3 → 0.2.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 +1 -0
- package/README.md +94 -2
- package/dist/cli/commands.d.ts +1 -0
- package/dist/cli/commands.js +55 -17
- package/dist/cli.js +10 -0
- package/dist/copilot-bridge.d.ts +21 -0
- package/dist/copilot-bridge.js +388 -0
- package/dist/server.js +25 -19
- package/dist/tools/recruit.js +92 -22
- package/package.json +12 -1
package/CLAUDE.md
CHANGED
|
@@ -16,6 +16,7 @@ claude-tempo is an MCP server that enables multiple Claude Code sessions to coor
|
|
|
16
16
|
```
|
|
17
17
|
src/
|
|
18
18
|
├── server.ts # MCP server entry point
|
|
19
|
+
├── copilot-bridge.ts # Copilot SDK bridge for Copilot CLI players
|
|
19
20
|
├── worker.ts # Temporal worker setup
|
|
20
21
|
├── workflows/
|
|
21
22
|
│ ├── session.ts # claude-session workflow
|
package/README.md
CHANGED
|
@@ -156,6 +156,7 @@ The `claude-tempo` CLI handles setup, session management, and diagnostics.
|
|
|
156
156
|
```
|
|
157
157
|
--temporal-address <addr> Temporal server address (default: localhost:7233)
|
|
158
158
|
-n, --name <name> Set the player name for the session (start/conduct/up)
|
|
159
|
+
--agent <claude|copilot> Agent type to spawn (default: claude; start/conduct)
|
|
159
160
|
--skip-preflight Skip preflight checks (start/conduct)
|
|
160
161
|
--background, -d Run Temporal in background (server only)
|
|
161
162
|
--dir <path> Target directory for init (default: cwd)
|
|
@@ -186,6 +187,7 @@ ok You're all set!
|
|
|
186
187
|
|
|
187
188
|
What next?
|
|
188
189
|
claude-tempo start myband Add a player session
|
|
190
|
+
claude-tempo start myband --agent copilot -n copilot-1 Add a Copilot player
|
|
189
191
|
claude-tempo status myband See who's active
|
|
190
192
|
Or ask the conductor to recruit players for you
|
|
191
193
|
```
|
|
@@ -393,9 +395,99 @@ When a Claude Code session crashes or is closed without graceful shutdown, its T
|
|
|
393
395
|
|
|
394
396
|
This means you don't need to manually clean up crashed sessions — just `cue` the dead player and the system handles the rest.
|
|
395
397
|
|
|
396
|
-
##
|
|
398
|
+
## Copilot CLI integration (experimental)
|
|
397
399
|
|
|
398
|
-
|
|
400
|
+
GitHub Copilot CLI sessions can join an ensemble via the **Copilot bridge**. The bridge uses the [Copilot SDK](https://github.com/github/copilot-sdk) to spawn a Copilot session with claude-tempo as an MCP server, and injects incoming messages as prompts.
|
|
401
|
+
|
|
402
|
+
### Setup
|
|
403
|
+
|
|
404
|
+
The Copilot SDK is an optional dependency — install it only if you want Copilot support:
|
|
405
|
+
|
|
406
|
+
```bash
|
|
407
|
+
npm install @github/copilot-sdk
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
You also need:
|
|
411
|
+
- [GitHub Copilot CLI](https://docs.github.com/en/copilot/github-copilot-in-the-cli) installed and authenticated
|
|
412
|
+
- An active GitHub Copilot subscription
|
|
413
|
+
|
|
414
|
+
### Starting a Copilot player
|
|
415
|
+
|
|
416
|
+
```bash
|
|
417
|
+
# Via CLI (recommended):
|
|
418
|
+
claude-tempo start --agent copilot -n copilot-dev
|
|
419
|
+
|
|
420
|
+
# Or directly with env vars (Linux/macOS):
|
|
421
|
+
CLAUDE_TEMPO_ENSEMBLE=default COPILOT_BRIDGE_NAME=copilot-dev npx ts-node src/copilot-bridge.ts
|
|
422
|
+
|
|
423
|
+
# Or directly with env vars (Windows PowerShell):
|
|
424
|
+
$env:TEMPORAL_ADDRESS="localhost:7233"; $env:CLAUDE_TEMPO_ENSEMBLE="default"; $env:COPILOT_BRIDGE_NAME="copilot-dev"; npx ts-node src/copilot-bridge.ts
|
|
425
|
+
|
|
426
|
+
# Or from any session in the ensemble, recruit one:
|
|
427
|
+
# "Recruit a copilot session named 'copilot-dev' with agent copilot"
|
|
428
|
+
```
|
|
429
|
+
|
|
430
|
+
The CLI `--agent` flag and the `recruit` tool's `agent` parameter both accept `"claude"` (default) or `"copilot"`.
|
|
431
|
+
|
|
432
|
+
### Shell shortcuts
|
|
433
|
+
|
|
434
|
+
Add these functions to your shell profile to simplify launching Copilot bridge sessions:
|
|
435
|
+
|
|
436
|
+
**Linux/macOS** — add to `~/.bashrc` or `~/.zshrc`:
|
|
437
|
+
|
|
438
|
+
```bash
|
|
439
|
+
copilot-tempo() {
|
|
440
|
+
CLAUDE_TEMPO_ENSEMBLE="${1:-default}" COPILOT_BRIDGE_NAME="${2}" \
|
|
441
|
+
npx ts-node /path/to/claude-tempo/src/copilot-bridge.ts
|
|
442
|
+
}
|
|
443
|
+
```
|
|
444
|
+
|
|
445
|
+
**Windows** — add to your PowerShell `$PROFILE`:
|
|
446
|
+
|
|
447
|
+
```powershell
|
|
448
|
+
function copilot-tempo($ensemble = "default", $name = "") {
|
|
449
|
+
$env:TEMPORAL_ADDRESS = "localhost:7233"
|
|
450
|
+
$env:CLAUDE_TEMPO_ENSEMBLE = $ensemble
|
|
451
|
+
$env:COPILOT_BRIDGE_NAME = $name
|
|
452
|
+
npx ts-node C:\path\to\claude-tempo\src\copilot-bridge.ts
|
|
453
|
+
$env:CLAUDE_TEMPO_ENSEMBLE = ""
|
|
454
|
+
$env:COPILOT_BRIDGE_NAME = ""
|
|
455
|
+
}
|
|
456
|
+
```
|
|
457
|
+
|
|
458
|
+
Usage:
|
|
459
|
+
|
|
460
|
+
```bash
|
|
461
|
+
copilot-tempo # join "default" ensemble, auto-generated name
|
|
462
|
+
copilot-tempo my-project copilot-1 # join "my-project" ensemble as "copilot-1"
|
|
463
|
+
```
|
|
464
|
+
|
|
465
|
+
### How it works
|
|
466
|
+
|
|
467
|
+
1. The bridge spawns a Copilot CLI session via the SDK with claude-tempo configured as an MCP server
|
|
468
|
+
2. The MCP server registers the session as a Temporal workflow (same as Claude Code players)
|
|
469
|
+
3. An initial prompt is sent to trigger MCP server initialization (the SDK lazily starts MCP servers)
|
|
470
|
+
4. The bridge polls the workflow for pending messages every 2 seconds
|
|
471
|
+
5. When messages arrive, they're injected as prompts via `session.sendAndWait()`
|
|
472
|
+
6. The Copilot session can use all claude-tempo tools (`ensemble`, `cue`, `report`, etc.)
|
|
473
|
+
|
|
474
|
+
### Environment variables
|
|
475
|
+
|
|
476
|
+
| Variable | Default | Description |
|
|
477
|
+
|----------|---------|-------------|
|
|
478
|
+
| `COPILOT_BRIDGE_NAME` | *(none)* | Player name (calls `set_name` automatically) |
|
|
479
|
+
| `COPILOT_BRIDGE_MODEL` | *(Copilot default)* | Model override for the Copilot session |
|
|
480
|
+
| `GITHUB_TOKEN` | *(logged-in user)* | GitHub auth token |
|
|
481
|
+
|
|
482
|
+
### Limitations
|
|
483
|
+
|
|
484
|
+
- **`recruit` requires manual acknowledgment (Claude backend)**: Recruited Claude Code sessions use `--dangerously-load-development-channels` to enable channel-based message delivery. Claude Code shows an interactive confirmation prompt that must be manually acknowledged (press Enter) in the spawned terminal window. This will be resolved once claude-tempo is published as an approved channel plugin. The Copilot backend does not have this limitation.
|
|
485
|
+
- **No interactive access** — Copilot bridge sessions run in the background. Unlike Claude Code sessions where you can chat directly, bridge sessions only respond to cues from other players. To send messages to a bridge session, use `cue` from another player or signal the workflow directly via the Temporal CLI.
|
|
486
|
+
- **Conductor polling latency** — Copilot conductors poll for messages every 2 seconds, unlike Claude Code conductors which receive instant channel notifications. This adds slight latency to orchestration.
|
|
487
|
+
- **No push-based message delivery** — the bridge polls for messages (2s interval), unlike Claude Code sessions which receive instant channel notifications.
|
|
488
|
+
- **Copilot sessions must be spawned via the bridge** to participate (not standalone Copilot CLI).
|
|
489
|
+
- **The `@github/copilot-sdk` adds ~243MB** to node_modules when installed.
|
|
490
|
+
- **Node 20+ required for Copilot features** — the `@github/copilot-sdk` requires Node.js 20 or later. The rest of claude-tempo works on Node 18+.
|
|
399
491
|
|
|
400
492
|
## License
|
|
401
493
|
|
package/dist/cli/commands.d.ts
CHANGED
package/dist/cli/commands.js
CHANGED
|
@@ -85,25 +85,61 @@ async function start(opts) {
|
|
|
85
85
|
// No existing conductor — proceed normally
|
|
86
86
|
}
|
|
87
87
|
}
|
|
88
|
-
out.log(`Starting ${out.bold(role)} in ensemble ${out.cyan(opts.ensemble)}`);
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
88
|
+
out.log(`Starting ${out.bold(role)} in ensemble ${out.cyan(opts.ensemble)}${opts.agent === 'copilot' ? out.dim(' (copilot)') : ''}`);
|
|
89
|
+
if (opts.agent === 'copilot') {
|
|
90
|
+
// Spawn copilot-bridge as a detached headless subprocess
|
|
91
|
+
const isDev = __filename.endsWith('.ts');
|
|
92
|
+
const cmd = isDev ? 'npx' : 'node';
|
|
93
|
+
const cmdArgs = isDev
|
|
94
|
+
? ['ts-node', (0, path_1.resolve)(__dirname, '..', '..', 'src', 'copilot-bridge.ts')]
|
|
95
|
+
: [(0, path_1.resolve)(__dirname, '..', 'copilot-bridge.js')];
|
|
96
|
+
// Log bridge output for debugging
|
|
97
|
+
const fs = require('fs');
|
|
98
|
+
const logName = opts.name || `copilot-${Date.now()}`;
|
|
99
|
+
const logPath = (0, path_1.join)(workDir, 'logs', `${logName}.log`);
|
|
100
|
+
fs.mkdirSync((0, path_1.join)(workDir, 'logs'), { recursive: true });
|
|
101
|
+
const logFd = fs.openSync(logPath, 'a');
|
|
102
|
+
let child;
|
|
103
|
+
try {
|
|
104
|
+
child = (0, child_process_1.spawn)(cmd, cmdArgs, {
|
|
105
|
+
cwd: workDir,
|
|
106
|
+
detached: true,
|
|
107
|
+
stdio: ['ignore', logFd, logFd],
|
|
108
|
+
env: {
|
|
109
|
+
...process.env,
|
|
110
|
+
CLAUDE_TEMPO_ENSEMBLE: opts.ensemble,
|
|
111
|
+
COPILOT_BRIDGE_NAME: opts.name || '',
|
|
112
|
+
TEMPORAL_ADDRESS: opts.temporalAddress,
|
|
113
|
+
...(opts.conductor ? { CLAUDE_TEMPO_CONDUCTOR: 'true' } : {}),
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
child.unref();
|
|
117
|
+
}
|
|
118
|
+
finally {
|
|
119
|
+
fs.closeSync(logFd);
|
|
120
|
+
}
|
|
121
|
+
out.success(`Launched copilot bridge${opts.name ? ` "${opts.name}"` : ''} (pid ${child.pid ?? 'unknown'})`);
|
|
101
122
|
}
|
|
102
|
-
|
|
103
|
-
|
|
123
|
+
else {
|
|
124
|
+
const claudeArgs = [
|
|
125
|
+
'--dangerously-skip-permissions',
|
|
126
|
+
'--dangerously-load-development-channels', 'server:claude-tempo',
|
|
127
|
+
];
|
|
128
|
+
if (opts.name) {
|
|
129
|
+
claudeArgs.push('-n', opts.name);
|
|
130
|
+
}
|
|
131
|
+
const envVars = {
|
|
132
|
+
CLAUDE_TEMPO_ENSEMBLE: opts.ensemble,
|
|
133
|
+
};
|
|
134
|
+
if (opts.conductor) {
|
|
135
|
+
envVars.CLAUDE_TEMPO_CONDUCTOR = 'true';
|
|
136
|
+
}
|
|
137
|
+
if (opts.name) {
|
|
138
|
+
envVars.CLAUDE_TEMPO_PLAYER_NAME = opts.name;
|
|
139
|
+
}
|
|
140
|
+
const { pid } = (0, spawn_1.spawnInTerminal)(claudeArgs, workDir, envVars);
|
|
141
|
+
out.success(`Launched ${role} session${opts.name ? ` "${opts.name}"` : ''} (pid ${pid ?? 'unknown'})`);
|
|
104
142
|
}
|
|
105
|
-
const { pid } = (0, spawn_1.spawnInTerminal)(claudeArgs, workDir, envVars);
|
|
106
|
-
out.success(`Launched ${role} session${opts.name ? ` "${opts.name}"` : ''} (pid ${pid ?? 'unknown'})`);
|
|
107
143
|
out.log(` Ensemble: ${opts.ensemble}`);
|
|
108
144
|
out.log(` Directory: ${workDir}`);
|
|
109
145
|
out.log(`\nCheck status: ${out.dim('claude-tempo status ' + opts.ensemble)}`);
|
|
@@ -541,6 +577,7 @@ ${out.bold('Commands:')}
|
|
|
541
577
|
${out.bold('Options:')}
|
|
542
578
|
--temporal-address <addr> Temporal server address (default: localhost:7233)
|
|
543
579
|
-n, --name <name> Set the session window name (start/conduct/up only)
|
|
580
|
+
--agent <claude|copilot> Agent type to spawn (default: claude; start/conduct)
|
|
544
581
|
--skip-preflight Skip preflight checks (start/conduct only)
|
|
545
582
|
--background Run Temporal in background (server only)
|
|
546
583
|
--keep-mcp Don't remove .mcp.json entry (down only)
|
|
@@ -554,6 +591,7 @@ ${out.bold('Typical workflow:')}
|
|
|
554
591
|
${out.dim('claude-tempo server')} Start Temporal (once, keep running)
|
|
555
592
|
${out.dim('claude-tempo conduct myband')} Start a conductor
|
|
556
593
|
${out.dim('claude-tempo start myband')} Add player sessions
|
|
594
|
+
${out.dim('claude-tempo start myband --agent copilot -n copilot-1')} Add a Copilot player
|
|
557
595
|
${out.dim('claude-tempo status myband')} Check who's active
|
|
558
596
|
|
|
559
597
|
${out.bold('Environment:')}
|
package/dist/cli.js
CHANGED
|
@@ -68,6 +68,14 @@ function parseArgs(argv) {
|
|
|
68
68
|
else if (arg === '--keep-mcp') {
|
|
69
69
|
result.keepMcp = true;
|
|
70
70
|
}
|
|
71
|
+
else if (arg === '--agent' && i + 1 < argv.length) {
|
|
72
|
+
const val = argv[++i];
|
|
73
|
+
if (val !== 'claude' && val !== 'copilot') {
|
|
74
|
+
out.error(`Invalid agent type: "${val}". Must be "claude" or "copilot".`);
|
|
75
|
+
process.exit(1);
|
|
76
|
+
}
|
|
77
|
+
result.agent = val;
|
|
78
|
+
}
|
|
71
79
|
else if (arg === '--help' || arg === '-h') {
|
|
72
80
|
result.command = 'help';
|
|
73
81
|
}
|
|
@@ -100,6 +108,7 @@ async function main() {
|
|
|
100
108
|
temporalAddress: args.temporalAddress,
|
|
101
109
|
name: args.name,
|
|
102
110
|
skipPreflight: args.skipPreflight,
|
|
111
|
+
agent: args.agent ?? 'claude',
|
|
103
112
|
});
|
|
104
113
|
break;
|
|
105
114
|
case 'start':
|
|
@@ -109,6 +118,7 @@ async function main() {
|
|
|
109
118
|
temporalAddress: args.temporalAddress,
|
|
110
119
|
name: args.name,
|
|
111
120
|
skipPreflight: args.skipPreflight,
|
|
121
|
+
agent: args.agent ?? 'claude',
|
|
112
122
|
});
|
|
113
123
|
break;
|
|
114
124
|
case 'status':
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copilot Bridge — allows GitHub Copilot CLI sessions to participate
|
|
3
|
+
* as players in a claude-tempo ensemble.
|
|
4
|
+
*
|
|
5
|
+
* The bridge:
|
|
6
|
+
* 1. Spawns a Copilot CLI session via the Copilot SDK
|
|
7
|
+
* 2. Configures it with claude-tempo as an MCP server (so it gets all tools)
|
|
8
|
+
* 3. Polls the Temporal workflow for pending messages
|
|
9
|
+
* 4. Injects messages as prompts via the SDK
|
|
10
|
+
*
|
|
11
|
+
* Usage:
|
|
12
|
+
* npx ts-node src/copilot-bridge.ts
|
|
13
|
+
*
|
|
14
|
+
* Environment variables:
|
|
15
|
+
* CLAUDE_TEMPO_ENSEMBLE — ensemble name (default: "default")
|
|
16
|
+
* CLAUDE_TEMPO_PLAYER_NAME — player ID for workflow registration (set by spawner for deterministic workflow IDs)
|
|
17
|
+
* COPILOT_BRIDGE_NAME — player name for set_name (optional)
|
|
18
|
+
* COPILOT_BRIDGE_MODEL — model to use (optional)
|
|
19
|
+
* GITHUB_TOKEN — GitHub auth token (optional, uses logged-in user by default)
|
|
20
|
+
*/
|
|
21
|
+
export {};
|
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Copilot Bridge — allows GitHub Copilot CLI sessions to participate
|
|
4
|
+
* as players in a claude-tempo ensemble.
|
|
5
|
+
*
|
|
6
|
+
* The bridge:
|
|
7
|
+
* 1. Spawns a Copilot CLI session via the Copilot SDK
|
|
8
|
+
* 2. Configures it with claude-tempo as an MCP server (so it gets all tools)
|
|
9
|
+
* 3. Polls the Temporal workflow for pending messages
|
|
10
|
+
* 4. Injects messages as prompts via the SDK
|
|
11
|
+
*
|
|
12
|
+
* Usage:
|
|
13
|
+
* npx ts-node src/copilot-bridge.ts
|
|
14
|
+
*
|
|
15
|
+
* Environment variables:
|
|
16
|
+
* CLAUDE_TEMPO_ENSEMBLE — ensemble name (default: "default")
|
|
17
|
+
* CLAUDE_TEMPO_PLAYER_NAME — player ID for workflow registration (set by spawner for deterministic workflow IDs)
|
|
18
|
+
* COPILOT_BRIDGE_NAME — player name for set_name (optional)
|
|
19
|
+
* COPILOT_BRIDGE_MODEL — model to use (optional)
|
|
20
|
+
* GITHUB_TOKEN — GitHub auth token (optional, uses logged-in user by default)
|
|
21
|
+
*/
|
|
22
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
23
|
+
if (k2 === undefined) k2 = k;
|
|
24
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
25
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
26
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
27
|
+
}
|
|
28
|
+
Object.defineProperty(o, k2, desc);
|
|
29
|
+
}) : (function(o, m, k, k2) {
|
|
30
|
+
if (k2 === undefined) k2 = k;
|
|
31
|
+
o[k2] = m[k];
|
|
32
|
+
}));
|
|
33
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
34
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
35
|
+
}) : function(o, v) {
|
|
36
|
+
o["default"] = v;
|
|
37
|
+
});
|
|
38
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
39
|
+
var ownKeys = function(o) {
|
|
40
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
41
|
+
var ar = [];
|
|
42
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
43
|
+
return ar;
|
|
44
|
+
};
|
|
45
|
+
return ownKeys(o);
|
|
46
|
+
};
|
|
47
|
+
return function (mod) {
|
|
48
|
+
if (mod && mod.__esModule) return mod;
|
|
49
|
+
var result = {};
|
|
50
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
51
|
+
__setModuleDefault(result, mod);
|
|
52
|
+
return result;
|
|
53
|
+
};
|
|
54
|
+
})();
|
|
55
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
56
|
+
const fs = __importStar(require("fs"));
|
|
57
|
+
const path = __importStar(require("path"));
|
|
58
|
+
const client_1 = require("@temporalio/client");
|
|
59
|
+
const config_1 = require("./config");
|
|
60
|
+
// Optional dependency — must be installed separately: npm install @github/copilot-sdk
|
|
61
|
+
let CopilotClient;
|
|
62
|
+
let approveAll;
|
|
63
|
+
try {
|
|
64
|
+
const sdk = require('@github/copilot-sdk');
|
|
65
|
+
CopilotClient = sdk.CopilotClient;
|
|
66
|
+
approveAll = sdk.approveAll;
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
console.error('Error: @github/copilot-sdk is not installed.\n' +
|
|
70
|
+
'Install it with: npm install @github/copilot-sdk\n' +
|
|
71
|
+
'See the Copilot CLI integration section in the README.');
|
|
72
|
+
process.exit(1);
|
|
73
|
+
}
|
|
74
|
+
// Unbuffered logging — fs.writeSync(2, ...) bypasses Node.js stream buffering,
|
|
75
|
+
// ensuring log output appears immediately even when stderr is redirected to a file.
|
|
76
|
+
const log = (...args) => {
|
|
77
|
+
const msg = `[copilot-bridge] ${args.map(a => typeof a === 'string' ? a : JSON.stringify(a)).join(' ')}\n`;
|
|
78
|
+
fs.writeSync(2, msg);
|
|
79
|
+
};
|
|
80
|
+
/** Filter process.env to exclude undefined values (safe to spread as Record<string, string>). */
|
|
81
|
+
const cleanEnv = () => Object.fromEntries(Object.entries(process.env).filter((e) => e[1] !== undefined));
|
|
82
|
+
const POLL_INTERVAL_MS = 2000;
|
|
83
|
+
const CREATE_SESSION_TIMEOUT_MS = 45_000;
|
|
84
|
+
const MAX_CONSECUTIVE_FAILURES = 3;
|
|
85
|
+
const MAX_SESSION_RECREATIONS = 2;
|
|
86
|
+
/** Wrap createSession with a timeout so auth/network hangs don't block forever. */
|
|
87
|
+
async function createSessionWithTimeout(copilotClient, sessionConfig, timeoutMs = CREATE_SESSION_TIMEOUT_MS) {
|
|
88
|
+
let timer;
|
|
89
|
+
const timeout = new Promise((_, reject) => {
|
|
90
|
+
timer = setTimeout(() => reject(new Error(`createSession timed out after ${timeoutMs / 1000}s — check Copilot auth and network connectivity`)), timeoutMs);
|
|
91
|
+
});
|
|
92
|
+
try {
|
|
93
|
+
return await Promise.race([
|
|
94
|
+
copilotClient.createSession(sessionConfig),
|
|
95
|
+
timeout,
|
|
96
|
+
]);
|
|
97
|
+
}
|
|
98
|
+
finally {
|
|
99
|
+
clearTimeout(timer);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
async function main() {
|
|
103
|
+
const config = (0, config_1.getConfig)();
|
|
104
|
+
const playerName = process.env.COPILOT_BRIDGE_NAME;
|
|
105
|
+
const model = process.env.COPILOT_BRIDGE_MODEL;
|
|
106
|
+
const workDir = process.cwd();
|
|
107
|
+
log(`Starting Copilot bridge in ${workDir} (ensemble: ${config.ensemble})`);
|
|
108
|
+
// Connect Temporal client (for polling only — the MCP server child process runs its own worker)
|
|
109
|
+
const connection = await client_1.Connection.connect({
|
|
110
|
+
address: config.temporalAddress,
|
|
111
|
+
});
|
|
112
|
+
const client = new client_1.Client({
|
|
113
|
+
connection,
|
|
114
|
+
namespace: config.temporalNamespace,
|
|
115
|
+
});
|
|
116
|
+
// Determine the expected workflow ID. The MCP server uses the pattern
|
|
117
|
+
// `claude-session-{ensemble}-{playerId}`, where playerId comes from
|
|
118
|
+
// CLAUDE_TEMPO_PLAYER_NAME or a random hex. We pass CLAUDE_TEMPO_PLAYER_NAME
|
|
119
|
+
// to the MCP server env so both sides agree on the ID.
|
|
120
|
+
const isConductor = !!process.env.CLAUDE_TEMPO_CONDUCTOR;
|
|
121
|
+
const playerIdForWorkflow = isConductor
|
|
122
|
+
? 'conductor'
|
|
123
|
+
: (process.env.CLAUDE_TEMPO_PLAYER_NAME || playerName || `copilot-${Date.now()}`);
|
|
124
|
+
const expectedWorkflowId = `claude-session-${config.ensemble}-${playerIdForWorkflow}`;
|
|
125
|
+
// Build the MCP server command — always use the compiled dist/server.js
|
|
126
|
+
// Run `npm run build` (or `pnpm build`) before using the bridge.
|
|
127
|
+
const serverJsPath = path.resolve(__dirname, '..', 'dist', 'server.js');
|
|
128
|
+
if (!fs.existsSync(serverJsPath)) {
|
|
129
|
+
log(`ERROR: ${serverJsPath} not found. Run 'pnpm build' first.`);
|
|
130
|
+
process.exit(1);
|
|
131
|
+
}
|
|
132
|
+
log(`MCP server path: ${serverJsPath}`);
|
|
133
|
+
const serverCommand = 'node';
|
|
134
|
+
const serverArgs = [serverJsPath];
|
|
135
|
+
const mcpEnv = {
|
|
136
|
+
...cleanEnv(),
|
|
137
|
+
CLAUDE_TEMPO_ENSEMBLE: config.ensemble,
|
|
138
|
+
TEMPORAL_ADDRESS: config.temporalAddress,
|
|
139
|
+
TEMPORAL_NAMESPACE: config.temporalNamespace,
|
|
140
|
+
CLAUDE_TEMPO_TASK_QUEUE: config.taskQueue,
|
|
141
|
+
CLAUDE_TEMPO_CONDUCTOR: process.env.CLAUDE_TEMPO_CONDUCTOR || '',
|
|
142
|
+
CLAUDE_TEMPO_BRIDGE_MODE: '1', // disable MCP server's message poller — bridge handles delivery
|
|
143
|
+
CLAUDE_TEMPO_PLAYER_NAME: playerIdForWorkflow, // ensures MCP server uses same workflow ID
|
|
144
|
+
};
|
|
145
|
+
// Spawn Copilot SDK client and session
|
|
146
|
+
const copilotClient = new CopilotClient({
|
|
147
|
+
logLevel: 'debug',
|
|
148
|
+
env: {
|
|
149
|
+
...cleanEnv(),
|
|
150
|
+
...(process.env.GITHUB_TOKEN ? { GITHUB_TOKEN: process.env.GITHUB_TOKEN } : {}),
|
|
151
|
+
},
|
|
152
|
+
});
|
|
153
|
+
const sessionConfig = {
|
|
154
|
+
// approveAll is intentional: Copilot bridge sessions run headless with no
|
|
155
|
+
// interactive terminal, so there is no way to prompt for permission approval.
|
|
156
|
+
// All tool calls are auto-approved by design — the bridge operator accepts
|
|
157
|
+
// this when launching the bridge process.
|
|
158
|
+
onPermissionRequest: approveAll,
|
|
159
|
+
workingDirectory: workDir,
|
|
160
|
+
mcpServers: {
|
|
161
|
+
'claude-tempo': {
|
|
162
|
+
command: serverCommand,
|
|
163
|
+
args: serverArgs,
|
|
164
|
+
env: mcpEnv,
|
|
165
|
+
tools: ['*'],
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
systemMessage: {
|
|
169
|
+
mode: 'append',
|
|
170
|
+
content: `You are part of the "${config.ensemble}" ensemble of Claude Code sessions coordinated via Temporal. ` +
|
|
171
|
+
`IMPORTANT: If you receive a message instructing you to call \`set_name\`, do so immediately before anything else. ` +
|
|
172
|
+
`When you receive a message from another session, treat it like a coworker asking for help — respond promptly, then resume your work. ` +
|
|
173
|
+
`Use \`set_name\` to give yourself a human-readable name. ` +
|
|
174
|
+
`Use \`ensemble\` to see who else is active. ` +
|
|
175
|
+
`Use \`cue\` to reply directly to the player who messaged you, or to ask others for help. ` +
|
|
176
|
+
`Use \`recruit\` if you need a session in a directory where none exists. ` +
|
|
177
|
+
`Use \`report\` to notify the conductor of task completion, blockers, or questions — always report when you finish a recruited task.`,
|
|
178
|
+
},
|
|
179
|
+
...(model ? { model } : {}),
|
|
180
|
+
};
|
|
181
|
+
log('Creating Copilot session...');
|
|
182
|
+
let session = await createSessionWithTimeout(copilotClient, sessionConfig);
|
|
183
|
+
log(`Copilot session created: ${session.sessionId}`);
|
|
184
|
+
// Track session health — resets to true on any successful interaction
|
|
185
|
+
let sessionAlive = true;
|
|
186
|
+
let lastEventTime = Date.now();
|
|
187
|
+
let lastEventType = 'session.created';
|
|
188
|
+
function attachEventLogger(s) {
|
|
189
|
+
s.on((event) => {
|
|
190
|
+
lastEventTime = Date.now();
|
|
191
|
+
lastEventType = event.type;
|
|
192
|
+
// Log tool calls and completions fully, truncate verbose events
|
|
193
|
+
if (event.type === 'tool.execution_start' || event.type === 'tool.execution_complete') {
|
|
194
|
+
log(`[event:${event.type}]`, JSON.stringify(event.data ?? event).substring(0, 800));
|
|
195
|
+
}
|
|
196
|
+
else if (event.type === 'assistant.message') {
|
|
197
|
+
const data = event.data ?? event;
|
|
198
|
+
const tools = data.toolRequests?.map((t) => t.name).join(', ') || 'none';
|
|
199
|
+
log(`[event:${event.type}] content="${(data.content || '').substring(0, 200)}" tools=[${tools}]`);
|
|
200
|
+
}
|
|
201
|
+
else if (event.type === 'session.idle') {
|
|
202
|
+
log(`[event:session.idle] Session is idle`);
|
|
203
|
+
}
|
|
204
|
+
else if (event.type?.includes('error') || event.type?.includes('disconnect')) {
|
|
205
|
+
log(`[event:${event.type}]`, JSON.stringify(event.data ?? event).substring(0, 500));
|
|
206
|
+
sessionAlive = false;
|
|
207
|
+
}
|
|
208
|
+
else {
|
|
209
|
+
log(`[event:${event.type}]`);
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
attachEventLogger(session);
|
|
214
|
+
// Send an initial prompt to trigger MCP server initialization.
|
|
215
|
+
// The Copilot SDK doesn't start MCP server subprocesses until the session
|
|
216
|
+
// processes a message that could use tools. We await this so the workflow
|
|
217
|
+
// registers before we try to find it, and so subsequent sendAndWait calls
|
|
218
|
+
// don't collide with this one.
|
|
219
|
+
log('Sending initial prompt to trigger MCP server startup...');
|
|
220
|
+
try {
|
|
221
|
+
const t0 = Date.now();
|
|
222
|
+
const initResult = await session.sendAndWait({ prompt: 'Call the ensemble tool to list active sessions. Respond in one short sentence.' }, 120_000);
|
|
223
|
+
log(`Initial prompt completed in ${Date.now() - t0}ms, result:`, JSON.stringify(initResult)?.substring(0, 300));
|
|
224
|
+
}
|
|
225
|
+
catch (err) {
|
|
226
|
+
log(`Initial prompt error after ${Date.now()}ms:`, err?.message, err?.stack?.substring(0, 300));
|
|
227
|
+
}
|
|
228
|
+
// Wait for the MCP server's workflow to register in Temporal.
|
|
229
|
+
// We know the exact workflow ID because we pass CLAUDE_TEMPO_PLAYER_NAME to the
|
|
230
|
+
// MCP server — no need for a time-window heuristic that could misidentify workflows.
|
|
231
|
+
log(`Waiting for workflow ${expectedWorkflowId} to register...`);
|
|
232
|
+
const handle = client.workflow.getHandle(expectedWorkflowId);
|
|
233
|
+
let workflowReady = false;
|
|
234
|
+
for (let attempt = 0; attempt < 30; attempt++) {
|
|
235
|
+
try {
|
|
236
|
+
const desc = await handle.describe();
|
|
237
|
+
if (desc.status.name === 'RUNNING') {
|
|
238
|
+
workflowReady = true;
|
|
239
|
+
break;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
catch {
|
|
243
|
+
// Workflow not yet started
|
|
244
|
+
}
|
|
245
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
246
|
+
if (attempt % 5 === 4)
|
|
247
|
+
log(`Still waiting... attempt ${attempt + 1}/30`);
|
|
248
|
+
}
|
|
249
|
+
if (!workflowReady) {
|
|
250
|
+
log(`ERROR: Workflow ${expectedWorkflowId} did not register within 30 seconds`);
|
|
251
|
+
await session.disconnect();
|
|
252
|
+
await copilotClient.stop();
|
|
253
|
+
process.exit(1);
|
|
254
|
+
}
|
|
255
|
+
log(`Workflow ready: ${expectedWorkflowId}`);
|
|
256
|
+
// If a name was requested, send the set_name instruction
|
|
257
|
+
if (playerName) {
|
|
258
|
+
log(`Sending set_name instruction for "${playerName}"...`);
|
|
259
|
+
const t0 = Date.now();
|
|
260
|
+
await session.sendAndWait({ prompt: `Call set_name("${playerName}") immediately. Respond in one short sentence.` }, 120_000);
|
|
261
|
+
log(`set_name completed in ${Date.now() - t0}ms`);
|
|
262
|
+
}
|
|
263
|
+
// Start message poller — inject messages into the Copilot session.
|
|
264
|
+
// Tracks consecutive failures and attempts session recreation before giving up.
|
|
265
|
+
let polling = true;
|
|
266
|
+
let processing = false;
|
|
267
|
+
let pollCount = 0;
|
|
268
|
+
let consecutiveFailures = 0;
|
|
269
|
+
let sessionRecreations = 0;
|
|
270
|
+
/** Attempt to recreate the Copilot session after repeated failures. */
|
|
271
|
+
async function recreateSession() {
|
|
272
|
+
sessionRecreations++;
|
|
273
|
+
if (sessionRecreations > MAX_SESSION_RECREATIONS) {
|
|
274
|
+
log(`ERROR: Exceeded max session recreations (${MAX_SESSION_RECREATIONS}). Giving up.`);
|
|
275
|
+
return false;
|
|
276
|
+
}
|
|
277
|
+
log(`Attempting session recreation (${sessionRecreations}/${MAX_SESSION_RECREATIONS})...`);
|
|
278
|
+
try {
|
|
279
|
+
await session.disconnect().catch(() => { });
|
|
280
|
+
session = await createSessionWithTimeout(copilotClient, sessionConfig);
|
|
281
|
+
attachEventLogger(session);
|
|
282
|
+
sessionAlive = true;
|
|
283
|
+
consecutiveFailures = 0;
|
|
284
|
+
log(`Session recreated successfully: ${session.sessionId}`);
|
|
285
|
+
return true;
|
|
286
|
+
}
|
|
287
|
+
catch (err) {
|
|
288
|
+
log(`Session recreation failed: ${err?.message}`);
|
|
289
|
+
return false;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
const poll = async () => {
|
|
293
|
+
if (!polling || processing)
|
|
294
|
+
return;
|
|
295
|
+
pollCount++;
|
|
296
|
+
// Periodic health check
|
|
297
|
+
if (pollCount % 30 === 0) { // every ~60 seconds
|
|
298
|
+
const silenceSec = ((Date.now() - lastEventTime) / 1000).toFixed(0);
|
|
299
|
+
log(`[health] poll #${pollCount}, sessionAlive=${sessionAlive}, lastEvent=${lastEventType} ${silenceSec}s ago`);
|
|
300
|
+
}
|
|
301
|
+
try {
|
|
302
|
+
const messages = await handle.query('pendingMessages');
|
|
303
|
+
if (messages.length === 0)
|
|
304
|
+
return;
|
|
305
|
+
processing = true;
|
|
306
|
+
const ids = messages.map((m) => m.id);
|
|
307
|
+
await handle.signal('markDelivered', ids);
|
|
308
|
+
// Format messages into a single prompt
|
|
309
|
+
const prompt = messages
|
|
310
|
+
.map((m) => `[Message from ${m.from}]: ${m.text}`)
|
|
311
|
+
.join('\n\n');
|
|
312
|
+
log(`Injecting ${messages.length} message(s) into Copilot session`);
|
|
313
|
+
log(`Prompt: ${prompt.substring(0, 300)}`);
|
|
314
|
+
if (!sessionAlive) {
|
|
315
|
+
log('WARNING: session appears dead, sendAndWait may hang');
|
|
316
|
+
}
|
|
317
|
+
const t0 = Date.now();
|
|
318
|
+
const result = await session.sendAndWait({ prompt }, 300_000); // 5 min timeout
|
|
319
|
+
const elapsed = Date.now() - t0;
|
|
320
|
+
log(`sendAndWait completed in ${elapsed}ms`);
|
|
321
|
+
log(`Response: ${JSON.stringify(result)?.substring(0, 500)}`);
|
|
322
|
+
// Success — reset failure tracking
|
|
323
|
+
consecutiveFailures = 0;
|
|
324
|
+
sessionAlive = true;
|
|
325
|
+
processing = false;
|
|
326
|
+
}
|
|
327
|
+
catch (err) {
|
|
328
|
+
processing = false;
|
|
329
|
+
consecutiveFailures++;
|
|
330
|
+
log(`Poll error (${consecutiveFailures}/${MAX_CONSECUTIVE_FAILURES}): ${err?.message}`);
|
|
331
|
+
log(`Error stack: ${err?.stack?.substring(0, 300)}`);
|
|
332
|
+
if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
|
|
333
|
+
log('Consecutive failure threshold reached — attempting session recovery');
|
|
334
|
+
const recovered = await recreateSession();
|
|
335
|
+
if (!recovered) {
|
|
336
|
+
log('ERROR: Session recovery failed. Shutting down bridge.');
|
|
337
|
+
polling = false;
|
|
338
|
+
clearInterval(interval);
|
|
339
|
+
process.exit(2);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
};
|
|
344
|
+
const interval = setInterval(poll, POLL_INTERVAL_MS);
|
|
345
|
+
log('Message poller started. Bridge is running.');
|
|
346
|
+
// Write PID file so callers can find/kill orphaned bridge processes
|
|
347
|
+
const pidDir = path.join(workDir, 'logs');
|
|
348
|
+
const pidFile = path.join(pidDir, `${playerName || playerIdForWorkflow}.pid`);
|
|
349
|
+
try {
|
|
350
|
+
fs.mkdirSync(pidDir, { recursive: true });
|
|
351
|
+
fs.writeFileSync(pidFile, String(process.pid));
|
|
352
|
+
log(`PID file written: ${pidFile}`);
|
|
353
|
+
}
|
|
354
|
+
catch (err) {
|
|
355
|
+
log(`Warning: could not write PID file: ${err?.message}`);
|
|
356
|
+
}
|
|
357
|
+
// Graceful shutdown
|
|
358
|
+
const shutdown = async () => {
|
|
359
|
+
log('Shutting down...');
|
|
360
|
+
polling = false;
|
|
361
|
+
clearInterval(interval);
|
|
362
|
+
try {
|
|
363
|
+
await handle.signal('shutdown');
|
|
364
|
+
}
|
|
365
|
+
catch {
|
|
366
|
+
// workflow may already be gone
|
|
367
|
+
}
|
|
368
|
+
try {
|
|
369
|
+
await session.disconnect();
|
|
370
|
+
}
|
|
371
|
+
catch {
|
|
372
|
+
// session may already be disconnected
|
|
373
|
+
}
|
|
374
|
+
// Clean up PID file
|
|
375
|
+
try {
|
|
376
|
+
fs.unlinkSync(pidFile);
|
|
377
|
+
}
|
|
378
|
+
catch { /* may already be gone */ }
|
|
379
|
+
await copilotClient.stop();
|
|
380
|
+
process.exit(0);
|
|
381
|
+
};
|
|
382
|
+
process.on('SIGINT', shutdown);
|
|
383
|
+
process.on('SIGTERM', shutdown);
|
|
384
|
+
}
|
|
385
|
+
main().catch((err) => {
|
|
386
|
+
log('Fatal error:', err);
|
|
387
|
+
process.exit(1);
|
|
388
|
+
});
|
package/dist/server.js
CHANGED
|
@@ -181,27 +181,33 @@ async function main() {
|
|
|
181
181
|
(0, recruit_1.registerRecruitTool)(mcpServer, client, config, getPlayerId);
|
|
182
182
|
(0, report_1.registerReportTool)(mcpServer, client, config, getPlayerId);
|
|
183
183
|
(0, terminate_1.registerTerminateTool)(mcpServer, client, config, getPlayerId);
|
|
184
|
-
// Start message poller — push messages into Claude Code via channel notifications
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
184
|
+
// Start message poller — push messages into Claude Code via channel notifications.
|
|
185
|
+
// Skip when running under the Copilot bridge: the bridge has its own poller that
|
|
186
|
+
// injects messages via sendAndWait. If both pollers run, this one wins the race and
|
|
187
|
+
// sends messages via notifications/claude/channel — which Copilot doesn't understand.
|
|
188
|
+
const isBridgeMode = process.env.CLAUDE_TEMPO_BRIDGE_MODE === '1';
|
|
189
|
+
const stopPoller = isBridgeMode
|
|
190
|
+
? () => { } // no-op — bridge handles message delivery
|
|
191
|
+
: (0, channel_1.startMessagePoller)(handle, async (messages) => {
|
|
192
|
+
for (const msg of messages) {
|
|
193
|
+
log(`Message from ${msg.from}: ${msg.text}`);
|
|
194
|
+
try {
|
|
195
|
+
await mcpServer.server.notification({
|
|
196
|
+
method: 'notifications/claude/channel',
|
|
197
|
+
params: {
|
|
198
|
+
content: msg.text,
|
|
199
|
+
meta: {
|
|
200
|
+
from_player: msg.from,
|
|
201
|
+
sent_at: msg.timestamp,
|
|
202
|
+
},
|
|
196
203
|
},
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
catch (err) {
|
|
207
|
+
log('Channel notification error:', err);
|
|
208
|
+
}
|
|
202
209
|
}
|
|
203
|
-
}
|
|
204
|
-
});
|
|
210
|
+
});
|
|
205
211
|
// Connect MCP transport
|
|
206
212
|
const transport = new stdio_js_1.StdioServerTransport();
|
|
207
213
|
await mcpServer.connect(transport);
|
package/dist/tools/recruit.js
CHANGED
|
@@ -1,6 +1,41 @@
|
|
|
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 path = __importStar(require("path"));
|
|
38
|
+
const child_process_1 = require("child_process");
|
|
4
39
|
const zod_1 = require("zod");
|
|
5
40
|
const spawn_1 = require("../spawn");
|
|
6
41
|
const resolve_1 = require("./resolve");
|
|
@@ -10,13 +45,15 @@ function sleep(ms) {
|
|
|
10
45
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
11
46
|
}
|
|
12
47
|
function registerRecruitTool(server, client, config, getPlayerId) {
|
|
13
|
-
(0, helpers_1.defineTool)(server, 'recruit', 'Start a new named
|
|
48
|
+
(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.', {
|
|
14
49
|
workDir: zod_1.z.string().describe('The working directory for the new session'),
|
|
15
50
|
name: zod_1.z.string().describe('Name for the new session'),
|
|
16
51
|
initialMessage: zod_1.z.string().optional()
|
|
17
52
|
.describe('Optional task or message for the new session (sent after it sets its name)'),
|
|
53
|
+
agent: zod_1.z.enum(['claude', 'copilot']).default('claude')
|
|
54
|
+
.describe('Which agent to use: "claude" (default) or "copilot" (GitHub Copilot CLI via SDK)'),
|
|
18
55
|
}, async (args) => {
|
|
19
|
-
const { workDir, name, initialMessage } = args;
|
|
56
|
+
const { workDir, name, initialMessage, agent } = args;
|
|
20
57
|
// Validate name to prevent search attribute query injection
|
|
21
58
|
if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
|
|
22
59
|
return {
|
|
@@ -45,17 +82,40 @@ function registerRecruitTool(server, client, config, getPlayerId) {
|
|
|
45
82
|
for await (const wf of client.workflow.list({ query: listQuery })) {
|
|
46
83
|
existingIds.add(wf.workflowId);
|
|
47
84
|
}
|
|
48
|
-
// Spawn
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
'
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
85
|
+
// Spawn the session using the selected backend
|
|
86
|
+
if (agent === 'copilot') {
|
|
87
|
+
// Use ts-node in dev, compiled JS in production
|
|
88
|
+
const isDev = __filename.endsWith('.ts');
|
|
89
|
+
const cmd = isDev ? 'npx' : 'node';
|
|
90
|
+
const cmdArgs = isDev
|
|
91
|
+
? ['ts-node', path.resolve(__dirname, '..', 'src', 'copilot-bridge.ts')]
|
|
92
|
+
: [path.resolve(__dirname, '..', 'copilot-bridge.js')];
|
|
93
|
+
const child = (0, child_process_1.spawn)(cmd, cmdArgs, {
|
|
94
|
+
cwd: workDir,
|
|
95
|
+
detached: true,
|
|
96
|
+
stdio: 'ignore',
|
|
97
|
+
env: {
|
|
98
|
+
...process.env,
|
|
99
|
+
CLAUDE_TEMPO_ENSEMBLE: config.ensemble,
|
|
100
|
+
COPILOT_BRIDGE_NAME: name,
|
|
101
|
+
TEMPORAL_ADDRESS: config.temporalAddress,
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
child.unref();
|
|
105
|
+
log(`Spawned copilot-bridge (pid ${child.pid}) in ${workDir} as "${name}"`);
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
const spawnArgs = [
|
|
109
|
+
'--dangerously-skip-permissions',
|
|
110
|
+
'--dangerously-load-development-channels', 'server:claude-tempo',
|
|
111
|
+
'-n', name,
|
|
112
|
+
];
|
|
113
|
+
const { pid } = (0, spawn_1.spawnInTerminal)(spawnArgs, workDir, {
|
|
114
|
+
CLAUDE_TEMPO_ENSEMBLE: config.ensemble,
|
|
115
|
+
CLAUDE_TEMPO_CONDUCTOR: '',
|
|
116
|
+
});
|
|
117
|
+
log(`Spawned claude process (pid ${pid}) in ${workDir} as "${name}"`);
|
|
118
|
+
}
|
|
59
119
|
// Poll for the new workflow to appear (up to ~15s)
|
|
60
120
|
let newWorkflowId = null;
|
|
61
121
|
for (let attempt = 0; attempt < 30; attempt++) {
|
|
@@ -77,16 +137,26 @@ function registerRecruitTool(server, client, config, getPlayerId) {
|
|
|
77
137
|
}],
|
|
78
138
|
};
|
|
79
139
|
}
|
|
80
|
-
// Send it a message instructing it to set its name
|
|
81
140
|
const newHandle = client.workflow.getHandle(newWorkflowId);
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
141
|
+
// For copilot agent, the bridge handles set_name automatically.
|
|
142
|
+
// For claude agent, send a message instructing it to set its name.
|
|
143
|
+
if (agent === 'claude') {
|
|
144
|
+
const nameInstruction = `You have been recruited as "${name}". Call set_name("${name}") immediately.`;
|
|
145
|
+
const fullMessage = initialMessage
|
|
146
|
+
? `${nameInstruction}\n\nThen: ${initialMessage}`
|
|
147
|
+
: nameInstruction;
|
|
148
|
+
await newHandle.signal('receiveMessage', {
|
|
149
|
+
from: getPlayerId(),
|
|
150
|
+
text: fullMessage,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
else if (initialMessage) {
|
|
154
|
+
// For copilot, just send the initial task (name is set by the bridge)
|
|
155
|
+
await newHandle.signal('receiveMessage', {
|
|
156
|
+
from: getPlayerId(),
|
|
157
|
+
text: initialMessage,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
90
160
|
return {
|
|
91
161
|
content: [{
|
|
92
162
|
type: 'text',
|
package/package.json
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-tempo",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "MCP server for multi-session Claude Code coordination via Temporal",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "https://github.com/vinceblank/claude-tempo.git"
|
|
8
|
+
},
|
|
5
9
|
"type": "commonjs",
|
|
6
10
|
"main": "dist/server.js",
|
|
7
11
|
"exports": {
|
|
@@ -30,8 +34,12 @@
|
|
|
30
34
|
"scripts": {
|
|
31
35
|
"build": "tsc && node -e \"const{bundleWorkflowCode}=require('@temporalio/worker');const path=require('path');const fs=require('fs');bundleWorkflowCode({workflowsPath:path.resolve('dist/workflows/session.js')}).then(b=>{fs.writeFileSync('workflow-bundle.js',b.code);console.log('Workflow bundle created')})\"",
|
|
32
36
|
"dev": "ts-node src/server.ts",
|
|
37
|
+
"copilot-bridge": "ts-node src/copilot-bridge.ts",
|
|
33
38
|
"test": "echo \"No tests yet\" && exit 0"
|
|
34
39
|
},
|
|
40
|
+
"optionalDependencies": {
|
|
41
|
+
"@github/copilot-sdk": "^0.2.0"
|
|
42
|
+
},
|
|
35
43
|
"dependencies": {
|
|
36
44
|
"@modelcontextprotocol/sdk": "^1.28.0",
|
|
37
45
|
"@temporalio/activity": "~1.11.7",
|
|
@@ -59,6 +67,9 @@
|
|
|
59
67
|
"CLAUDE.md",
|
|
60
68
|
"README.md"
|
|
61
69
|
],
|
|
70
|
+
"engines": {
|
|
71
|
+
"node": ">=18"
|
|
72
|
+
},
|
|
62
73
|
"license": "MIT",
|
|
63
74
|
"trustedDependencies": [
|
|
64
75
|
"@swc/core",
|