claude-tempo 0.6.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +4 -2
- package/README.md +111 -6
- package/dist/activities/schedule-fire.js +37 -2
- package/dist/cli/commands.d.ts +6 -0
- package/dist/cli/commands.js +264 -12
- package/dist/cli.js +11 -0
- package/dist/copilot-bridge.js +2 -2
- package/dist/ensemble/loader.d.ts +5 -0
- package/dist/ensemble/loader.js +60 -0
- package/dist/ensemble/saver.d.ts +17 -0
- package/dist/ensemble/saver.js +130 -0
- package/dist/ensemble/schema.d.ts +24 -0
- package/dist/ensemble/schema.js +3 -0
- package/dist/git-info.d.ts +4 -0
- package/dist/git-info.js +28 -0
- package/dist/server.js +40 -36
- package/dist/spawn.js +37 -2
- package/dist/tools/ensemble.js +3 -0
- package/dist/tools/load-ensemble.d.ts +5 -0
- package/dist/tools/load-ensemble.js +282 -0
- package/dist/tools/recruit.js +96 -36
- package/dist/tools/save-ensemble.d.ts +4 -0
- package/dist/tools/save-ensemble.js +44 -0
- package/dist/tools/{terminate.d.ts → stop.d.ts} +1 -1
- package/dist/tools/stop.js +50 -0
- package/dist/types.d.ts +2 -0
- package/dist/workflows/session.js +50 -31
- package/dist/workflows/signals.d.ts +9 -2
- package/dist/workflows/signals.js +2 -2
- package/package.json +2 -1
- package/workflow-bundle.js +53 -34
- package/dist/tools/terminate.js +0 -52
package/CLAUDE.md
CHANGED
|
@@ -30,10 +30,11 @@ src/
|
|
|
30
30
|
│ ├── listen.ts # Manual message check
|
|
31
31
|
│ ├── recruit.ts # Spawn new session
|
|
32
32
|
│ ├── report.ts # Report to conductor
|
|
33
|
-
│ ├──
|
|
33
|
+
│ ├── stop.ts # Stop a session
|
|
34
34
|
│ └── helpers.ts # Zod/MCP tool registration wrapper
|
|
35
35
|
├── types.ts # Shared type definitions
|
|
36
36
|
├── channel.ts # Claude channel notification helper
|
|
37
|
+
├── git-info.ts # Git repository detection helper
|
|
37
38
|
└── config.ts # Env var handling
|
|
38
39
|
```
|
|
39
40
|
|
|
@@ -66,8 +67,9 @@ npm test
|
|
|
66
67
|
- **Ensemble**: The set of all active players, namespaced by `CLAUDE_TEMPO_ENSEMBLE`
|
|
67
68
|
- **Cue**: A message sent to a player by name via Temporal signal
|
|
68
69
|
- **Part**: A player's description of what it's working on
|
|
69
|
-
- **Recruit**: Spawning a new Claude Code session as a player
|
|
70
|
+
- **Recruit**: Spawning a new Claude Code session as a player. The workflow is pre-created with the initial message before the process spawns, ensuring reliable delivery.
|
|
70
71
|
- **set_name**: Players start with a random hex ID; `set_name` updates the `ClaudeTempoPlayerId` search attribute to a human-readable name
|
|
72
|
+
- **Session status**: Each session has a status (`pending` → `active` → `stale`) tracked via `ClaudeTempoStatus` search attribute. Pre-created workflows start as `pending`, transition to `active` when the process connects, and become `stale` if messages go undelivered for 3+ minutes.
|
|
71
73
|
|
|
72
74
|
## Dashboard
|
|
73
75
|
|
package/README.md
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
<p align="center">
|
|
2
|
+
<picture>
|
|
3
|
+
<source media="(prefers-color-scheme: dark)" srcset="assets/logo-dark.svg">
|
|
4
|
+
<source media="(prefers-color-scheme: light)" srcset="assets/logo-light.svg">
|
|
5
|
+
<img alt="claude-tempo" src="assets/logo-light.svg" height="140">
|
|
6
|
+
</picture>
|
|
7
|
+
</p>
|
|
8
|
+
<p align="center">
|
|
9
|
+
Multi-session <a href="https://claude.ai/code">Claude Code</a> coordination via <a href="https://temporal.io">Temporal</a>.
|
|
10
|
+
</p>
|
|
4
11
|
|
|
5
12
|
Multiple Claude Code sessions discover each other, exchange messages in real time, and coordinate work — across machines, not just localhost.
|
|
6
13
|
|
|
@@ -91,7 +98,7 @@ claude-tempo <command> [options]
|
|
|
91
98
|
|
|
92
99
|
| Command | Description |
|
|
93
100
|
|---------|-------------|
|
|
94
|
-
| `up [ensemble]` | First-time setup: start Temporal, configure MCP, launch conductor |
|
|
101
|
+
| `up [ensemble]` | First-time setup: start Temporal, configure MCP, launch conductor. Use `--from` to load a blueprint. |
|
|
95
102
|
| `down` | Stop Temporal, terminate sessions, remove MCP config |
|
|
96
103
|
| `server` | Start the Temporal dev server and register search attributes |
|
|
97
104
|
| `conduct [ensemble]` | Start a conductor session (one per ensemble). Use `--resume` or `--replace` if one exists. |
|
|
@@ -101,6 +108,7 @@ claude-tempo <command> [options]
|
|
|
101
108
|
| `stop [ensemble]` | Stop sessions (`-n <name>` for one, `--all` for everything) |
|
|
102
109
|
| `init` | Register claude-tempo MCP server globally (`--project` for per-directory) |
|
|
103
110
|
| `preflight` | Run environment checks |
|
|
111
|
+
| `ensemble <sub>` | Manage saved blueprints (`save`, `list`, `show`) |
|
|
104
112
|
| `help` | Show usage info |
|
|
105
113
|
|
|
106
114
|
### Global options
|
|
@@ -115,6 +123,7 @@ claude-tempo <command> [options]
|
|
|
115
123
|
--skip-preflight Skip preflight checks (start/conduct)
|
|
116
124
|
-d, --dir <path> Target directory (default: cwd)
|
|
117
125
|
--background Run Temporal in background (server only)
|
|
126
|
+
--from <file> Load an ensemble blueprint on startup (up only)
|
|
118
127
|
--resume Resume an existing conductor session (conduct only)
|
|
119
128
|
--replace Stop existing conductor and start fresh (conduct only)
|
|
120
129
|
```
|
|
@@ -172,7 +181,7 @@ Ensemble: myband
|
|
|
172
181
|
Building the REST endpoints
|
|
173
182
|
/Users/me/projects/app feat/api my-machine.local
|
|
174
183
|
|
|
175
|
-
bob
|
|
184
|
+
bob (pending)
|
|
176
185
|
Working on the dashboard
|
|
177
186
|
/Users/me/projects/app feat/ui my-machine.local
|
|
178
187
|
|
|
@@ -208,10 +217,12 @@ These tools are available inside Claude Code sessions connected to claude-tempo:
|
|
|
208
217
|
| `listen` | Manually check for pending messages. |
|
|
209
218
|
| `recruit` | Spawn a new Claude Code session in a directory. Can recruit a conductor with `conductor: true`. |
|
|
210
219
|
| `report` | Send updates to the conductor. No-op if no conductor exists. |
|
|
211
|
-
| `
|
|
220
|
+
| `stop` | Stop a player session by name. |
|
|
212
221
|
| `schedule` | Create a one-shot or recurring schedule to cue a player. |
|
|
213
222
|
| `unschedule` | Cancel a named schedule. |
|
|
214
223
|
| `schedules` | List all active schedules. |
|
|
224
|
+
| `save_ensemble` | Save the current ensemble as a YAML blueprint (conductor only). |
|
|
225
|
+
| `load_ensemble` | Load a blueprint to recruit players and create schedules. |
|
|
215
226
|
|
|
216
227
|
## Scheduling
|
|
217
228
|
|
|
@@ -244,6 +255,80 @@ Schedules support one-shot delays, fixed times, and recurring intervals with opt
|
|
|
244
255
|
- `claude-tempo status` shows active schedules alongside sessions
|
|
245
256
|
- A single durable scheduler workflow per ensemble manages all schedules using Temporal timers
|
|
246
257
|
|
|
258
|
+
## Ensemble Blueprints
|
|
259
|
+
|
|
260
|
+
Define reusable ensemble configurations as YAML files. A blueprint specifies which players to recruit, what instructions to give them, what schedules to create, and optionally which custom agent files to use.
|
|
261
|
+
|
|
262
|
+
### Example blueprint
|
|
263
|
+
|
|
264
|
+
```yaml
|
|
265
|
+
name: my-project
|
|
266
|
+
conductor:
|
|
267
|
+
instructions: "Coordinate the frontend and backend teams"
|
|
268
|
+
players:
|
|
269
|
+
- name: frontend
|
|
270
|
+
workDir: /repos/my-app
|
|
271
|
+
instructions: "Build the React dashboard in src/components"
|
|
272
|
+
- name: backend
|
|
273
|
+
workDir: /repos/my-api
|
|
274
|
+
instructions: "Implement the REST endpoints in src/routes"
|
|
275
|
+
- name: ops
|
|
276
|
+
workDir: /repos/infra
|
|
277
|
+
agent: agents/ops-agent.md
|
|
278
|
+
instructions: "Monitor deployments and run health checks"
|
|
279
|
+
schedules:
|
|
280
|
+
- name: status-check
|
|
281
|
+
message: "Report your current progress and any blockers"
|
|
282
|
+
target: all
|
|
283
|
+
every: 30m
|
|
284
|
+
- name: deploy-reminder
|
|
285
|
+
message: "Check if the staging deploy succeeded"
|
|
286
|
+
target: ops
|
|
287
|
+
delay: 10m
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
### Three ways to use blueprints
|
|
291
|
+
|
|
292
|
+
1. **From the CLI** — load a blueprint when starting an ensemble:
|
|
293
|
+
|
|
294
|
+
```bash
|
|
295
|
+
claude-tempo up --from my-blueprint.yaml
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
2. **From inside a session** — use the `load_ensemble` tool:
|
|
299
|
+
|
|
300
|
+
*"Load the blueprint from ~/.claude-tempo/ensembles/my-project.yaml"*
|
|
301
|
+
|
|
302
|
+
3. **Save the current state** — snapshot a running ensemble as a blueprint (conductor only):
|
|
303
|
+
|
|
304
|
+
*"Save this ensemble as a blueprint called my-project"*
|
|
305
|
+
|
|
306
|
+
### Natural language examples
|
|
307
|
+
|
|
308
|
+
Tell your session things like:
|
|
309
|
+
|
|
310
|
+
- *"Load the my-project blueprint"*
|
|
311
|
+
- *"Save this ensemble as a blueprint"*
|
|
312
|
+
- *"Load the blueprint from /repos/configs/team.yaml"*
|
|
313
|
+
|
|
314
|
+
### Fan-out schedules
|
|
315
|
+
|
|
316
|
+
Use `target: "all"` in a schedule to deliver a message to every active player (excluding the conductor). This is useful for periodic status checks or broadcast announcements:
|
|
317
|
+
|
|
318
|
+
- *"Schedule a message every 30 minutes to all players asking for a progress update"*
|
|
319
|
+
|
|
320
|
+
### Custom agents
|
|
321
|
+
|
|
322
|
+
The `agent` field on a player can be a path to a `.md` file that will be used as the session's system prompt via `--system-prompt`. This lets you create specialized agents with domain-specific instructions:
|
|
323
|
+
|
|
324
|
+
```yaml
|
|
325
|
+
players:
|
|
326
|
+
- name: security-reviewer
|
|
327
|
+
workDir: /repos/my-app
|
|
328
|
+
agent: agents/security-review.md
|
|
329
|
+
instructions: "Review the latest PR for security issues"
|
|
330
|
+
```
|
|
331
|
+
|
|
247
332
|
## Conductors
|
|
248
333
|
|
|
249
334
|
A **conductor** is an optional special player that acts as an orchestration hub. Use one when you want:
|
|
@@ -320,6 +405,23 @@ Sessions start with a random 8-character hex ID. Set a name at launch with `-n`
|
|
|
320
405
|
- Names must contain only letters, numbers, hyphens, and underscores
|
|
321
406
|
- The name "conductor" is reserved for conductor sessions
|
|
322
407
|
|
|
408
|
+
### Session status lifecycle
|
|
409
|
+
|
|
410
|
+
Each session has a status that tracks its connection state:
|
|
411
|
+
|
|
412
|
+
| Status | Meaning |
|
|
413
|
+
|--------|---------|
|
|
414
|
+
| `pending` | Workflow created by `recruit`, but the Claude Code process hasn't connected yet |
|
|
415
|
+
| `active` | Session is running and responsive |
|
|
416
|
+
| `stale` | Messages have gone undelivered for 3+ minutes — the session is likely disconnected |
|
|
417
|
+
|
|
418
|
+
Status transitions:
|
|
419
|
+
- **`pending` → `active`** — when the spawned session connects and sends its `updateMetadata` signal
|
|
420
|
+
- **`active` → `stale`** — when undelivered messages exceed the stale threshold (3 minutes)
|
|
421
|
+
- Any status → **terminated** — on graceful shutdown or `stop`
|
|
422
|
+
|
|
423
|
+
`claude-tempo status` shows `(pending)` and `(stale)` indicators next to player names. The `ClaudeTempoStatus` search attribute is also set, so you can filter sessions by status in the Temporal UI (e.g., `ClaudeTempoStatus = "stale"`).
|
|
424
|
+
|
|
323
425
|
### Terminal support
|
|
324
426
|
|
|
325
427
|
`recruit` and the CLI detect your terminal automatically:
|
|
@@ -331,10 +433,13 @@ Sessions start with a random 8-character hex ID. Set a name at launch with `-n`
|
|
|
331
433
|
| Terminal.app | ✓ | — | — |
|
|
332
434
|
| gnome-terminal | — | ✓ | — |
|
|
333
435
|
| konsole / xterm | — | ✓ | — |
|
|
436
|
+
| Windows Terminal | — | — | ✓ (tabs) |
|
|
334
437
|
| cmd.exe / PowerShell | — | — | ✓ |
|
|
335
438
|
|
|
336
439
|
macOS terminals preserve the full shell environment (fish, zsh, bash) including node version managers (fnm, nvm).
|
|
337
440
|
|
|
441
|
+
Windows Terminal is detected automatically via the `WT_SESSION` environment variable. When running inside Windows Terminal, recruited sessions open as new tabs (with the player name as the tab title) instead of separate cmd.exe windows.
|
|
442
|
+
|
|
338
443
|
## Configuration
|
|
339
444
|
|
|
340
445
|
Run `claude-tempo config` to save Temporal connection settings so you don't need flags or env vars every time:
|
|
@@ -10,7 +10,43 @@ function createScheduleActivities(client) {
|
|
|
10
10
|
async fireSchedule(input) {
|
|
11
11
|
const { ensemble, scheduleName, message, target, createdBy } = input;
|
|
12
12
|
try {
|
|
13
|
-
|
|
13
|
+
const text = `[scheduled: ${scheduleName}] ${message}`;
|
|
14
|
+
// Handle target "all" — deliver to every active player except the conductor
|
|
15
|
+
if (target === 'all') {
|
|
16
|
+
const query = `WorkflowType = "claudeSessionWorkflow" AND ExecutionStatus = "Running"`;
|
|
17
|
+
const failures = [];
|
|
18
|
+
let delivered = 0;
|
|
19
|
+
for await (const wf of client.workflow.list({ query })) {
|
|
20
|
+
try {
|
|
21
|
+
const handle = client.workflow.getHandle(wf.workflowId);
|
|
22
|
+
const metadata = await handle.query('getMetadata');
|
|
23
|
+
if (metadata.ensemble !== ensemble)
|
|
24
|
+
continue;
|
|
25
|
+
if (metadata.isConductor)
|
|
26
|
+
continue; // skip conductor
|
|
27
|
+
await handle.signal('receiveMessage', {
|
|
28
|
+
from: createdBy,
|
|
29
|
+
text,
|
|
30
|
+
isScheduled: true,
|
|
31
|
+
scheduleName,
|
|
32
|
+
});
|
|
33
|
+
delivered++;
|
|
34
|
+
}
|
|
35
|
+
catch (err) {
|
|
36
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
37
|
+
failures.push(`${wf.workflowId}: ${msg}`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
if (delivered === 0 && failures.length === 0) {
|
|
41
|
+
await notifyFailure(client, ensemble, createdBy, scheduleName, target, 'No active players found in the ensemble.');
|
|
42
|
+
return { success: false, error: 'No active players found' };
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
success: failures.length === 0,
|
|
46
|
+
error: failures.length > 0 ? `Delivered to ${delivered} players, ${failures.length} failed: ${failures.join('; ')}` : undefined,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
// Resolve single target player by querying running session workflows
|
|
14
50
|
const handle = await resolveSession(client, ensemble, target);
|
|
15
51
|
if (!handle) {
|
|
16
52
|
// Notify the creator (or conductor as fallback) about the failure
|
|
@@ -18,7 +54,6 @@ function createScheduleActivities(client) {
|
|
|
18
54
|
return { success: false, error: `No active session found for "${target}"` };
|
|
19
55
|
}
|
|
20
56
|
// Send cue signal with from set to the original creator's name
|
|
21
|
-
const text = `[scheduled: ${scheduleName}] ${message}`;
|
|
22
57
|
await handle.signal('receiveMessage', {
|
|
23
58
|
from: createdBy,
|
|
24
59
|
text,
|
package/dist/cli/commands.d.ts
CHANGED
|
@@ -27,6 +27,7 @@ export declare function server(opts: ServerOpts): Promise<void>;
|
|
|
27
27
|
interface UpOpts extends CliOverrides {
|
|
28
28
|
ensemble: string;
|
|
29
29
|
name?: string;
|
|
30
|
+
from?: string;
|
|
30
31
|
agent: AgentType;
|
|
31
32
|
}
|
|
32
33
|
export declare function up(opts: UpOpts): Promise<void>;
|
|
@@ -44,6 +45,11 @@ interface StopOpts extends CliOverrides {
|
|
|
44
45
|
all?: boolean;
|
|
45
46
|
}
|
|
46
47
|
export declare function stop(opts: StopOpts): Promise<void>;
|
|
48
|
+
interface EnsembleCommandOpts extends CliOverrides {
|
|
49
|
+
subcommand?: string;
|
|
50
|
+
name?: string;
|
|
51
|
+
}
|
|
52
|
+
export declare function ensembleCommand(opts: EnsembleCommandOpts): Promise<void>;
|
|
47
53
|
export declare function help(): void;
|
|
48
54
|
export declare function version(): void;
|
|
49
55
|
export {};
|
package/dist/cli/commands.js
CHANGED
|
@@ -40,6 +40,7 @@ exports.server = server;
|
|
|
40
40
|
exports.up = up;
|
|
41
41
|
exports.down = down;
|
|
42
42
|
exports.stop = stop;
|
|
43
|
+
exports.ensembleCommand = ensembleCommand;
|
|
43
44
|
exports.help = help;
|
|
44
45
|
exports.version = version;
|
|
45
46
|
const fs_1 = require("fs");
|
|
@@ -50,8 +51,11 @@ const spawn_1 = require("../spawn");
|
|
|
50
51
|
const config_1 = require("../config");
|
|
51
52
|
const connection_1 = require("../connection");
|
|
52
53
|
const signals_1 = require("../workflows/signals");
|
|
54
|
+
const scheduler_signals_1 = require("../workflows/scheduler-signals");
|
|
53
55
|
const preflight_1 = require("./preflight");
|
|
54
56
|
const mcp_1 = require("./mcp");
|
|
57
|
+
const loader_1 = require("../ensemble/loader");
|
|
58
|
+
const saver_1 = require("../ensemble/saver");
|
|
55
59
|
const out = __importStar(require("./output"));
|
|
56
60
|
/** Package root is two levels up from dist/cli/ */
|
|
57
61
|
const PACKAGE_ROOT = (0, path_1.resolve)(__dirname, '..', '..');
|
|
@@ -93,7 +97,7 @@ async function start(opts) {
|
|
|
93
97
|
if (opts.replace) {
|
|
94
98
|
out.log(`Stopping existing conductor for ensemble "${opts.ensemble}"...`);
|
|
95
99
|
try {
|
|
96
|
-
await handle.signal(signals_1.
|
|
100
|
+
await handle.signal(signals_1.updateMetadataSignal, { status: 'terminated' });
|
|
97
101
|
// Wait briefly for graceful shutdown
|
|
98
102
|
for (let i = 0; i < 10; i++) {
|
|
99
103
|
await new Promise(r => setTimeout(r, 500));
|
|
@@ -224,6 +228,7 @@ async function status(opts) {
|
|
|
224
228
|
host: meta.hostname || '',
|
|
225
229
|
conductor: meta.isConductor || false,
|
|
226
230
|
agentType: meta.agentType || 'claude',
|
|
231
|
+
status: meta.status || 'active',
|
|
227
232
|
});
|
|
228
233
|
}
|
|
229
234
|
catch {
|
|
@@ -276,8 +281,11 @@ async function status(opts) {
|
|
|
276
281
|
for (const s of members) {
|
|
277
282
|
const role = s.conductor ? out.yellow(' (conductor)') : '';
|
|
278
283
|
const agent = s.agentType === 'copilot' ? out.dim(' [copilot]') : '';
|
|
284
|
+
const statusLabel = s.status === 'stale' ? out.yellow(' (stale)')
|
|
285
|
+
: s.status === 'pending' ? out.dim(' (pending)')
|
|
286
|
+
: '';
|
|
279
287
|
const name = out.bold(s.name);
|
|
280
|
-
out.log(` ${name}${role}${agent}`);
|
|
288
|
+
out.log(` ${name}${role}${statusLabel}${agent}`);
|
|
281
289
|
if (s.part)
|
|
282
290
|
out.log(` ${out.dim(s.part)}`);
|
|
283
291
|
const details = [s.workDir, s.branch, s.host].filter(Boolean).join(' ');
|
|
@@ -378,6 +386,7 @@ const SEARCH_ATTRIBUTES = [
|
|
|
378
386
|
{ name: 'ClaudeTempoGitRoot', type: 'Keyword' },
|
|
379
387
|
{ name: 'ClaudeTempoEnsemble', type: 'Keyword' },
|
|
380
388
|
{ name: 'ClaudeTempoPlayerId', type: 'Keyword' },
|
|
389
|
+
{ name: 'ClaudeTempoStatus', type: 'Keyword' },
|
|
381
390
|
];
|
|
382
391
|
function isTemporalReachable(config) {
|
|
383
392
|
return (0, connection_1.createTemporalConnection)(config)
|
|
@@ -555,11 +564,18 @@ async function up(opts) {
|
|
|
555
564
|
temporalEnvVars[config_1.ENV.TEMPORAL_TLS_CERT_PATH] = config.temporalTlsCertPath;
|
|
556
565
|
if (config.temporalTlsKeyPath)
|
|
557
566
|
temporalEnvVars[config_1.ENV.TEMPORAL_TLS_KEY_PATH] = config.temporalTlsKeyPath;
|
|
567
|
+
// Load blueprint if --from is provided
|
|
568
|
+
const blueprint = opts.from ? (0, loader_1.loadBlueprint)((0, path_1.resolve)(opts.from)) : undefined;
|
|
569
|
+
if (blueprint) {
|
|
570
|
+
out.check('Blueprint loaded', true, blueprint.name);
|
|
571
|
+
}
|
|
572
|
+
// Resolve conductor agent from blueprint or CLI flags
|
|
573
|
+
const conductorAgent = blueprint?.conductor?.agent === 'copilot' ? 'copilot' : opts.agent;
|
|
558
574
|
// Step 5: Launch conductor
|
|
559
575
|
console.log();
|
|
560
|
-
out.log(`Launching conductor in ensemble ${out.cyan(opts.ensemble)}${
|
|
576
|
+
out.log(`Launching conductor in ensemble ${out.cyan(opts.ensemble)}${conductorAgent === 'copilot' ? out.dim(' (copilot)') : ''}...`);
|
|
561
577
|
let pid;
|
|
562
|
-
if (
|
|
578
|
+
if (conductorAgent === 'copilot') {
|
|
563
579
|
({ pid } = (0, spawn_1.spawnCopilotBridge)({
|
|
564
580
|
name: opts.name || `${opts.ensemble}-conductor`,
|
|
565
581
|
ensemble: opts.ensemble,
|
|
@@ -587,16 +603,199 @@ async function up(opts) {
|
|
|
587
603
|
[config_1.ENV.PLAYER_NAME]: sessionName,
|
|
588
604
|
}));
|
|
589
605
|
}
|
|
606
|
+
out.success(`Conductor launched (pid ${pid ?? 'unknown'})`);
|
|
607
|
+
// Step 6: If blueprint provided, recruit players and create schedules
|
|
608
|
+
if (blueprint) {
|
|
609
|
+
// Connect to Temporal to send signals
|
|
610
|
+
const connection = await (0, connection_1.createTemporalConnection)(config);
|
|
611
|
+
const client = new client_1.Client({ connection, namespace: config.temporalNamespace });
|
|
612
|
+
// Wait for conductor workflow to appear
|
|
613
|
+
out.log(`\n Waiting for conductor to register...`);
|
|
614
|
+
const conductorWfId = (0, config_1.conductorWorkflowId)(opts.ensemble);
|
|
615
|
+
let conductorReady = false;
|
|
616
|
+
for (let i = 0; i < 30; i++) {
|
|
617
|
+
await new Promise(r => setTimeout(r, 500));
|
|
618
|
+
try {
|
|
619
|
+
const handle = client.workflow.getHandle(conductorWfId);
|
|
620
|
+
const desc = await handle.describe();
|
|
621
|
+
if (desc.status.name === 'RUNNING') {
|
|
622
|
+
conductorReady = true;
|
|
623
|
+
break;
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
catch { /* not yet */ }
|
|
627
|
+
}
|
|
628
|
+
if (!conductorReady) {
|
|
629
|
+
out.warn('Conductor did not register within 15s — skipping blueprint players/schedules');
|
|
630
|
+
}
|
|
631
|
+
else {
|
|
632
|
+
out.check('Conductor registered', true);
|
|
633
|
+
// Send conductor instructions if provided
|
|
634
|
+
if (blueprint.conductor?.instructions) {
|
|
635
|
+
try {
|
|
636
|
+
const handle = client.workflow.getHandle(conductorWfId);
|
|
637
|
+
await handle.signal('receiveMessage', {
|
|
638
|
+
from: 'blueprint',
|
|
639
|
+
text: blueprint.conductor.instructions,
|
|
640
|
+
});
|
|
641
|
+
out.check('Conductor instructions sent', true);
|
|
642
|
+
}
|
|
643
|
+
catch (err) {
|
|
644
|
+
out.warn(`Could not send conductor instructions: ${err}`);
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
// Recruit players sequentially (each polls for ~15s)
|
|
648
|
+
if (blueprint.players.length > 0) {
|
|
649
|
+
console.log();
|
|
650
|
+
out.log(`Recruiting ${blueprint.players.length} player${blueprint.players.length !== 1 ? 's' : ''} from blueprint...`);
|
|
651
|
+
for (const player of blueprint.players) {
|
|
652
|
+
const playerAgent = player.agent === 'copilot' ? 'copilot' : 'claude';
|
|
653
|
+
const playerWorkDir = player.workDir || process.cwd();
|
|
654
|
+
// Record existing workflows to detect the new one
|
|
655
|
+
const existingIds = new Set();
|
|
656
|
+
const listQuery = `WorkflowType = "claudeSessionWorkflow" AND ExecutionStatus = "Running"`;
|
|
657
|
+
for await (const wf of client.workflow.list({ query: listQuery })) {
|
|
658
|
+
existingIds.add(wf.workflowId);
|
|
659
|
+
}
|
|
660
|
+
// Spawn the player
|
|
661
|
+
if (playerAgent === 'copilot') {
|
|
662
|
+
(0, spawn_1.spawnCopilotBridge)({
|
|
663
|
+
name: player.name,
|
|
664
|
+
ensemble: opts.ensemble,
|
|
665
|
+
temporalAddress: config.temporalAddress,
|
|
666
|
+
temporalNamespace: config.temporalNamespace,
|
|
667
|
+
temporalApiKey: config.temporalApiKey,
|
|
668
|
+
temporalTlsCertPath: config.temporalTlsCertPath,
|
|
669
|
+
temporalTlsKeyPath: config.temporalTlsKeyPath,
|
|
670
|
+
isConductor: false,
|
|
671
|
+
workDir: playerWorkDir,
|
|
672
|
+
});
|
|
673
|
+
}
|
|
674
|
+
else {
|
|
675
|
+
const claudeArgs = [
|
|
676
|
+
'--dangerously-skip-permissions',
|
|
677
|
+
'--dangerously-load-development-channels', 'server:claude-tempo',
|
|
678
|
+
'-n', player.name,
|
|
679
|
+
];
|
|
680
|
+
(0, spawn_1.spawnInTerminal)(claudeArgs, playerWorkDir, {
|
|
681
|
+
...temporalEnvVars,
|
|
682
|
+
[config_1.ENV.ENSEMBLE]: opts.ensemble,
|
|
683
|
+
[config_1.ENV.CONDUCTOR]: '',
|
|
684
|
+
[config_1.ENV.PLAYER_NAME]: player.name,
|
|
685
|
+
});
|
|
686
|
+
}
|
|
687
|
+
// Poll for the new workflow to appear (up to ~15s)
|
|
688
|
+
let newWorkflowId = null;
|
|
689
|
+
for (let attempt = 0; attempt < 30; attempt++) {
|
|
690
|
+
await new Promise(r => setTimeout(r, 500));
|
|
691
|
+
for await (const wf of client.workflow.list({ query: listQuery })) {
|
|
692
|
+
if (!existingIds.has(wf.workflowId)) {
|
|
693
|
+
newWorkflowId = wf.workflowId;
|
|
694
|
+
break;
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
if (newWorkflowId)
|
|
698
|
+
break;
|
|
699
|
+
}
|
|
700
|
+
if (newWorkflowId && player.instructions) {
|
|
701
|
+
try {
|
|
702
|
+
const handle = client.workflow.getHandle(newWorkflowId);
|
|
703
|
+
await handle.signal('receiveMessage', {
|
|
704
|
+
from: 'blueprint',
|
|
705
|
+
text: player.instructions,
|
|
706
|
+
});
|
|
707
|
+
}
|
|
708
|
+
catch { /* best effort */ }
|
|
709
|
+
}
|
|
710
|
+
const status = newWorkflowId ? out.green('ok') : out.yellow('slow');
|
|
711
|
+
out.log(` ${status} ${out.bold(player.name)} in ${playerWorkDir}`);
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
// Create schedules
|
|
715
|
+
if (blueprint.schedules && blueprint.schedules.length > 0) {
|
|
716
|
+
console.log();
|
|
717
|
+
out.log(`Creating ${blueprint.schedules.length} schedule${blueprint.schedules.length !== 1 ? 's' : ''}...`);
|
|
718
|
+
for (const sched of blueprint.schedules) {
|
|
719
|
+
try {
|
|
720
|
+
const entry = blueprintScheduleToEntry(sched);
|
|
721
|
+
const schedulerWfId = (0, config_1.schedulerWorkflowId)(opts.ensemble);
|
|
722
|
+
const handle = client.workflow.getHandle(schedulerWfId);
|
|
723
|
+
await handle.signal(scheduler_signals_1.addScheduleSignal, entry);
|
|
724
|
+
out.check(sched.name, true, `→ ${sched.target}`);
|
|
725
|
+
}
|
|
726
|
+
catch (err) {
|
|
727
|
+
out.warn(`Could not create schedule "${sched.name}": ${err}`);
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
await connection.close();
|
|
733
|
+
}
|
|
590
734
|
console.log();
|
|
591
735
|
out.success('You\'re all set!');
|
|
592
|
-
out.log(` Conductor launched (pid ${pid ?? 'unknown'})`);
|
|
593
736
|
out.log(` Ensemble: ${out.cyan(opts.ensemble)}`);
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
737
|
+
if (!blueprint) {
|
|
738
|
+
out.log(`\n ${out.bold('What next?')}`);
|
|
739
|
+
out.log(` ${out.dim('claude-tempo start ' + opts.ensemble)} Add a player session`);
|
|
740
|
+
out.log(` ${out.dim('claude-tempo status ' + opts.ensemble)} See who\'s active`);
|
|
741
|
+
out.log(` Or ask the conductor to ${out.dim('recruit')} players for you`);
|
|
742
|
+
}
|
|
743
|
+
else {
|
|
744
|
+
out.log(` Blueprint: ${out.dim(blueprint.name)}`);
|
|
745
|
+
out.log(` Players: ${blueprint.players.length}`);
|
|
746
|
+
if (blueprint.schedules?.length)
|
|
747
|
+
out.log(` Schedules: ${blueprint.schedules.length}`);
|
|
748
|
+
out.log(`\n ${out.dim('claude-tempo status ' + opts.ensemble)} See who\'s active`);
|
|
749
|
+
}
|
|
598
750
|
console.log();
|
|
599
751
|
}
|
|
752
|
+
/** Convert a blueprint schedule definition to a ScheduleEntry for the scheduler workflow. */
|
|
753
|
+
function blueprintScheduleToEntry(sched) {
|
|
754
|
+
const now = Date.now();
|
|
755
|
+
let nextFireAt;
|
|
756
|
+
let interval;
|
|
757
|
+
if (sched.every) {
|
|
758
|
+
interval = parseDuration(sched.every);
|
|
759
|
+
nextFireAt = sched.delay
|
|
760
|
+
? new Date(now + parseDuration(sched.delay)).toISOString()
|
|
761
|
+
: new Date(now + interval).toISOString();
|
|
762
|
+
}
|
|
763
|
+
else if (sched.at) {
|
|
764
|
+
nextFireAt = new Date(sched.at).toISOString();
|
|
765
|
+
}
|
|
766
|
+
else if (sched.delay) {
|
|
767
|
+
nextFireAt = new Date(now + parseDuration(sched.delay)).toISOString();
|
|
768
|
+
}
|
|
769
|
+
else {
|
|
770
|
+
nextFireAt = new Date(now + 60_000).toISOString(); // default: 1 minute
|
|
771
|
+
}
|
|
772
|
+
return {
|
|
773
|
+
name: sched.name,
|
|
774
|
+
message: sched.message,
|
|
775
|
+
target: sched.target,
|
|
776
|
+
createdBy: 'blueprint',
|
|
777
|
+
nextFireAt,
|
|
778
|
+
interval,
|
|
779
|
+
until: sched.until,
|
|
780
|
+
remainingCount: sched.count,
|
|
781
|
+
firedCount: 0,
|
|
782
|
+
type: interval ? 'interval' : 'once',
|
|
783
|
+
};
|
|
784
|
+
}
|
|
785
|
+
/** Parse a human duration string like "10m", "1h", "30s" to milliseconds. */
|
|
786
|
+
function parseDuration(s) {
|
|
787
|
+
const match = s.match(/^(\d+(?:\.\d+)?)\s*(s|m|h|d)$/);
|
|
788
|
+
if (!match)
|
|
789
|
+
throw new Error(`Invalid duration: "${s}"`);
|
|
790
|
+
const value = parseFloat(match[1]);
|
|
791
|
+
switch (match[2]) {
|
|
792
|
+
case 's': return value * 1_000;
|
|
793
|
+
case 'm': return value * 60_000;
|
|
794
|
+
case 'h': return value * 3_600_000;
|
|
795
|
+
case 'd': return value * 86_400_000;
|
|
796
|
+
default: throw new Error(`Unknown duration unit: "${match[2]}"`);
|
|
797
|
+
}
|
|
798
|
+
}
|
|
600
799
|
async function down(opts) {
|
|
601
800
|
const config = (0, config_1.getConfig)(opts);
|
|
602
801
|
out.heading('claude-tempo teardown');
|
|
@@ -732,7 +931,7 @@ async function stop(opts) {
|
|
|
732
931
|
continue;
|
|
733
932
|
}
|
|
734
933
|
}
|
|
735
|
-
await handle.signal(signals_1.
|
|
934
|
+
await handle.signal(signals_1.updateMetadataSignal, { status: 'terminated' });
|
|
736
935
|
stopped++;
|
|
737
936
|
out.log(` ${out.dim('stopped')} ${wf.workflowId}`);
|
|
738
937
|
}
|
|
@@ -792,9 +991,9 @@ async function stopByName(client, name, config, ensemble) {
|
|
|
792
991
|
// No conductor or conductor not running — fine
|
|
793
992
|
}
|
|
794
993
|
}
|
|
795
|
-
// Send
|
|
994
|
+
// Send termination status update (graceful)
|
|
796
995
|
try {
|
|
797
|
-
await handle.signal(signals_1.
|
|
996
|
+
await handle.signal(signals_1.updateMetadataSignal, { status: 'terminated' });
|
|
798
997
|
out.success(`Stopped "${name}"`);
|
|
799
998
|
}
|
|
800
999
|
catch {
|
|
@@ -867,6 +1066,57 @@ function killBridgeProcesses() {
|
|
|
867
1066
|
// logs dir unreadable
|
|
868
1067
|
}
|
|
869
1068
|
}
|
|
1069
|
+
async function ensembleCommand(opts) {
|
|
1070
|
+
switch (opts.subcommand) {
|
|
1071
|
+
case 'save': {
|
|
1072
|
+
const config = (0, config_1.getConfig)(opts);
|
|
1073
|
+
const connection = await (0, connection_1.createTemporalConnection)(config);
|
|
1074
|
+
const client = new client_1.Client({ connection, namespace: config.temporalNamespace });
|
|
1075
|
+
const ensemble = opts.name || config.ensemble;
|
|
1076
|
+
try {
|
|
1077
|
+
const path = await (0, saver_1.saveBlueprint)(client, ensemble);
|
|
1078
|
+
out.success(`Saved ensemble "${ensemble}" to ${path}`);
|
|
1079
|
+
}
|
|
1080
|
+
finally {
|
|
1081
|
+
await connection.close();
|
|
1082
|
+
}
|
|
1083
|
+
break;
|
|
1084
|
+
}
|
|
1085
|
+
case 'list': {
|
|
1086
|
+
const blueprints = (0, saver_1.listBlueprints)();
|
|
1087
|
+
if (blueprints.length === 0) {
|
|
1088
|
+
out.log('No saved ensembles. Use `claude-tempo ensemble save [name]` to save one.');
|
|
1089
|
+
return;
|
|
1090
|
+
}
|
|
1091
|
+
out.heading('Saved ensembles');
|
|
1092
|
+
for (const bp of blueprints) {
|
|
1093
|
+
out.log(` ${out.bold(bp.name)} ${out.dim(bp.path)}`);
|
|
1094
|
+
}
|
|
1095
|
+
console.log();
|
|
1096
|
+
break;
|
|
1097
|
+
}
|
|
1098
|
+
case 'show': {
|
|
1099
|
+
if (!opts.name) {
|
|
1100
|
+
out.error('Usage: claude-tempo ensemble show <name>');
|
|
1101
|
+
process.exit(1);
|
|
1102
|
+
}
|
|
1103
|
+
const content = (0, saver_1.readSavedBlueprint)(opts.name);
|
|
1104
|
+
if (!content) {
|
|
1105
|
+
out.error(`No saved ensemble named "${opts.name}"`);
|
|
1106
|
+
out.log(` Run ${out.dim('claude-tempo ensemble list')} to see available ensembles.`);
|
|
1107
|
+
process.exit(1);
|
|
1108
|
+
}
|
|
1109
|
+
console.log(content);
|
|
1110
|
+
break;
|
|
1111
|
+
}
|
|
1112
|
+
default:
|
|
1113
|
+
out.error('Usage: claude-tempo ensemble <save|list|show> [name]');
|
|
1114
|
+
out.log(`\n ${out.dim('claude-tempo ensemble save [name]')} Save current ensemble state`);
|
|
1115
|
+
out.log(` ${out.dim('claude-tempo ensemble list')} List saved ensembles`);
|
|
1116
|
+
out.log(` ${out.dim('claude-tempo ensemble show <name>')} Display a saved blueprint`);
|
|
1117
|
+
process.exit(1);
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
870
1120
|
function help() {
|
|
871
1121
|
console.log(`
|
|
872
1122
|
${out.bold('claude-tempo')} — Multi-session Claude Code coordination via Temporal
|
|
@@ -885,6 +1135,7 @@ ${out.bold('Commands:')}
|
|
|
885
1135
|
${out.cyan('start')} [ensemble] Start a player session
|
|
886
1136
|
${out.cyan('stop')} [ensemble] Stop sessions (-n <name> for one, or --all)
|
|
887
1137
|
${out.cyan('status')} [ensemble] Show active sessions and Temporal health
|
|
1138
|
+
${out.cyan('ensemble')} <sub> Manage saved ensemble blueprints (save/list/show)
|
|
888
1139
|
${out.cyan('config')} Configure Temporal connection settings
|
|
889
1140
|
${out.cyan('init')} Register MCP server globally (or --project for .mcp.json)
|
|
890
1141
|
${out.cyan('preflight')} Run preflight checks only
|
|
@@ -905,6 +1156,7 @@ ${out.bold('Other options:')}
|
|
|
905
1156
|
--project Use per-project .mcp.json instead of global (init only)
|
|
906
1157
|
--keep-mcp Don't remove MCP config (down only)
|
|
907
1158
|
--all Stop all sessions (stop only)
|
|
1159
|
+
--from <file> Load ensemble from a YAML blueprint (up only)
|
|
908
1160
|
--ensemble <name> Target a specific ensemble (stop only)
|
|
909
1161
|
-d, --dir <path> Target directory (default: cwd)
|
|
910
1162
|
|