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 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
- ## Known limitations
398
+ ## Copilot CLI integration (experimental)
397
399
 
398
- - **`recruit` requires manual acknowledgment**: Recruited 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.
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
 
@@ -4,6 +4,7 @@ interface StartOpts {
4
4
  temporalAddress: string;
5
5
  name?: string;
6
6
  skipPreflight?: boolean;
7
+ agent: 'claude' | 'copilot';
7
8
  }
8
9
  export declare function start(opts: StartOpts): Promise<void>;
9
10
  interface StatusOpts {
@@ -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
- const claudeArgs = [
90
- '--dangerously-skip-permissions',
91
- '--dangerously-load-development-channels', 'server:claude-tempo',
92
- ];
93
- if (opts.name) {
94
- claudeArgs.push('-n', opts.name);
95
- }
96
- const envVars = {
97
- CLAUDE_TEMPO_ENSEMBLE: opts.ensemble,
98
- };
99
- if (opts.conductor) {
100
- envVars.CLAUDE_TEMPO_CONDUCTOR = 'true';
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
- if (opts.name) {
103
- envVars.CLAUDE_TEMPO_PLAYER_NAME = opts.name;
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
- const stopPoller = (0, channel_1.startMessagePoller)(handle, async (messages) => {
186
- for (const msg of messages) {
187
- log(`Message from ${msg.from}: ${msg.text}`);
188
- try {
189
- await mcpServer.server.notification({
190
- method: 'notifications/claude/channel',
191
- params: {
192
- content: msg.text,
193
- meta: {
194
- from_player: msg.from,
195
- sent_at: msg.timestamp,
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
- catch (err) {
201
- log('Channel notification error:', err);
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);
@@ -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 Claude Code session in a directory. Rejects if the name is already active.', {
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 a new Claude Code session in a visible terminal
49
- const spawnArgs = [
50
- '--dangerously-skip-permissions',
51
- '--dangerously-load-development-channels', 'server:claude-tempo',
52
- '-n', name,
53
- ];
54
- const { pid } = (0, spawn_1.spawnInTerminal)(spawnArgs, workDir, {
55
- CLAUDE_TEMPO_ENSEMBLE: config.ensemble,
56
- CLAUDE_TEMPO_CONDUCTOR: '',
57
- });
58
- log(`Spawned claude process (pid ${pid}) in ${workDir} as "${name}"`);
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
- const nameInstruction = `You have been recruited as "${name}". Call set_name("${name}") immediately.`;
83
- const fullMessage = initialMessage
84
- ? `${nameInstruction}\n\nThen: ${initialMessage}`
85
- : nameInstruction;
86
- await newHandle.signal('receiveMessage', {
87
- from: getPlayerId(),
88
- text: fullMessage,
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.1.3",
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",