claude-tempo 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +79 -1
- package/dist/activities/schedule-fire.js +37 -2
- package/dist/cli/commands.d.ts +6 -0
- package/dist/cli/commands.js +254 -7
- package/dist/cli.js +11 -0
- 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/server.js +4 -0
- package/dist/tools/load-ensemble.d.ts +5 -0
- package/dist/tools/load-ensemble.js +282 -0
- package/dist/tools/recruit.js +4 -0
- package/dist/tools/save-ensemble.d.ts +4 -0
- package/dist/tools/save-ensemble.js +44 -0
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -91,7 +91,7 @@ claude-tempo <command> [options]
|
|
|
91
91
|
|
|
92
92
|
| Command | Description |
|
|
93
93
|
|---------|-------------|
|
|
94
|
-
| `up [ensemble]` | First-time setup: start Temporal, configure MCP, launch conductor |
|
|
94
|
+
| `up [ensemble]` | First-time setup: start Temporal, configure MCP, launch conductor. Use `--from` to load a blueprint. |
|
|
95
95
|
| `down` | Stop Temporal, terminate sessions, remove MCP config |
|
|
96
96
|
| `server` | Start the Temporal dev server and register search attributes |
|
|
97
97
|
| `conduct [ensemble]` | Start a conductor session (one per ensemble). Use `--resume` or `--replace` if one exists. |
|
|
@@ -101,6 +101,7 @@ claude-tempo <command> [options]
|
|
|
101
101
|
| `stop [ensemble]` | Stop sessions (`-n <name>` for one, `--all` for everything) |
|
|
102
102
|
| `init` | Register claude-tempo MCP server globally (`--project` for per-directory) |
|
|
103
103
|
| `preflight` | Run environment checks |
|
|
104
|
+
| `ensemble <sub>` | Manage saved blueprints (`save`, `list`, `show`) |
|
|
104
105
|
| `help` | Show usage info |
|
|
105
106
|
|
|
106
107
|
### Global options
|
|
@@ -115,6 +116,7 @@ claude-tempo <command> [options]
|
|
|
115
116
|
--skip-preflight Skip preflight checks (start/conduct)
|
|
116
117
|
-d, --dir <path> Target directory (default: cwd)
|
|
117
118
|
--background Run Temporal in background (server only)
|
|
119
|
+
--from <file> Load an ensemble blueprint on startup (up only)
|
|
118
120
|
--resume Resume an existing conductor session (conduct only)
|
|
119
121
|
--replace Stop existing conductor and start fresh (conduct only)
|
|
120
122
|
```
|
|
@@ -212,6 +214,8 @@ These tools are available inside Claude Code sessions connected to claude-tempo:
|
|
|
212
214
|
| `schedule` | Create a one-shot or recurring schedule to cue a player. |
|
|
213
215
|
| `unschedule` | Cancel a named schedule. |
|
|
214
216
|
| `schedules` | List all active schedules. |
|
|
217
|
+
| `save_ensemble` | Save the current ensemble as a YAML blueprint (conductor only). |
|
|
218
|
+
| `load_ensemble` | Load a blueprint to recruit players and create schedules. |
|
|
215
219
|
|
|
216
220
|
## Scheduling
|
|
217
221
|
|
|
@@ -244,6 +248,80 @@ Schedules support one-shot delays, fixed times, and recurring intervals with opt
|
|
|
244
248
|
- `claude-tempo status` shows active schedules alongside sessions
|
|
245
249
|
- A single durable scheduler workflow per ensemble manages all schedules using Temporal timers
|
|
246
250
|
|
|
251
|
+
## Ensemble Blueprints
|
|
252
|
+
|
|
253
|
+
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.
|
|
254
|
+
|
|
255
|
+
### Example blueprint
|
|
256
|
+
|
|
257
|
+
```yaml
|
|
258
|
+
name: my-project
|
|
259
|
+
conductor:
|
|
260
|
+
instructions: "Coordinate the frontend and backend teams"
|
|
261
|
+
players:
|
|
262
|
+
- name: frontend
|
|
263
|
+
workDir: /repos/my-app
|
|
264
|
+
instructions: "Build the React dashboard in src/components"
|
|
265
|
+
- name: backend
|
|
266
|
+
workDir: /repos/my-api
|
|
267
|
+
instructions: "Implement the REST endpoints in src/routes"
|
|
268
|
+
- name: ops
|
|
269
|
+
workDir: /repos/infra
|
|
270
|
+
agent: agents/ops-agent.md
|
|
271
|
+
instructions: "Monitor deployments and run health checks"
|
|
272
|
+
schedules:
|
|
273
|
+
- name: status-check
|
|
274
|
+
message: "Report your current progress and any blockers"
|
|
275
|
+
target: all
|
|
276
|
+
every: 30m
|
|
277
|
+
- name: deploy-reminder
|
|
278
|
+
message: "Check if the staging deploy succeeded"
|
|
279
|
+
target: ops
|
|
280
|
+
delay: 10m
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
### Three ways to use blueprints
|
|
284
|
+
|
|
285
|
+
1. **From the CLI** — load a blueprint when starting an ensemble:
|
|
286
|
+
|
|
287
|
+
```bash
|
|
288
|
+
claude-tempo up --from my-blueprint.yaml
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
2. **From inside a session** — use the `load_ensemble` tool:
|
|
292
|
+
|
|
293
|
+
*"Load the blueprint from ~/.claude-tempo/ensembles/my-project.yaml"*
|
|
294
|
+
|
|
295
|
+
3. **Save the current state** — snapshot a running ensemble as a blueprint (conductor only):
|
|
296
|
+
|
|
297
|
+
*"Save this ensemble as a blueprint called my-project"*
|
|
298
|
+
|
|
299
|
+
### Natural language examples
|
|
300
|
+
|
|
301
|
+
Tell your session things like:
|
|
302
|
+
|
|
303
|
+
- *"Load the my-project blueprint"*
|
|
304
|
+
- *"Save this ensemble as a blueprint"*
|
|
305
|
+
- *"Load the blueprint from /repos/configs/team.yaml"*
|
|
306
|
+
|
|
307
|
+
### Fan-out schedules
|
|
308
|
+
|
|
309
|
+
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:
|
|
310
|
+
|
|
311
|
+
- *"Schedule a message every 30 minutes to all players asking for a progress update"*
|
|
312
|
+
|
|
313
|
+
### Custom agents
|
|
314
|
+
|
|
315
|
+
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:
|
|
316
|
+
|
|
317
|
+
```yaml
|
|
318
|
+
players:
|
|
319
|
+
- name: security-reviewer
|
|
320
|
+
workDir: /repos/my-app
|
|
321
|
+
agent: agents/security-review.md
|
|
322
|
+
instructions: "Review the latest PR for security issues"
|
|
323
|
+
```
|
|
324
|
+
|
|
247
325
|
## Conductors
|
|
248
326
|
|
|
249
327
|
A **conductor** is an optional special player that acts as an orchestration hub. Use one when you want:
|
|
@@ -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, '..', '..');
|
|
@@ -555,11 +559,18 @@ async function up(opts) {
|
|
|
555
559
|
temporalEnvVars[config_1.ENV.TEMPORAL_TLS_CERT_PATH] = config.temporalTlsCertPath;
|
|
556
560
|
if (config.temporalTlsKeyPath)
|
|
557
561
|
temporalEnvVars[config_1.ENV.TEMPORAL_TLS_KEY_PATH] = config.temporalTlsKeyPath;
|
|
562
|
+
// Load blueprint if --from is provided
|
|
563
|
+
const blueprint = opts.from ? (0, loader_1.loadBlueprint)((0, path_1.resolve)(opts.from)) : undefined;
|
|
564
|
+
if (blueprint) {
|
|
565
|
+
out.check('Blueprint loaded', true, blueprint.name);
|
|
566
|
+
}
|
|
567
|
+
// Resolve conductor agent from blueprint or CLI flags
|
|
568
|
+
const conductorAgent = blueprint?.conductor?.agent === 'copilot' ? 'copilot' : opts.agent;
|
|
558
569
|
// Step 5: Launch conductor
|
|
559
570
|
console.log();
|
|
560
|
-
out.log(`Launching conductor in ensemble ${out.cyan(opts.ensemble)}${
|
|
571
|
+
out.log(`Launching conductor in ensemble ${out.cyan(opts.ensemble)}${conductorAgent === 'copilot' ? out.dim(' (copilot)') : ''}...`);
|
|
561
572
|
let pid;
|
|
562
|
-
if (
|
|
573
|
+
if (conductorAgent === 'copilot') {
|
|
563
574
|
({ pid } = (0, spawn_1.spawnCopilotBridge)({
|
|
564
575
|
name: opts.name || `${opts.ensemble}-conductor`,
|
|
565
576
|
ensemble: opts.ensemble,
|
|
@@ -587,16 +598,199 @@ async function up(opts) {
|
|
|
587
598
|
[config_1.ENV.PLAYER_NAME]: sessionName,
|
|
588
599
|
}));
|
|
589
600
|
}
|
|
601
|
+
out.success(`Conductor launched (pid ${pid ?? 'unknown'})`);
|
|
602
|
+
// Step 6: If blueprint provided, recruit players and create schedules
|
|
603
|
+
if (blueprint) {
|
|
604
|
+
// Connect to Temporal to send signals
|
|
605
|
+
const connection = await (0, connection_1.createTemporalConnection)(config);
|
|
606
|
+
const client = new client_1.Client({ connection, namespace: config.temporalNamespace });
|
|
607
|
+
// Wait for conductor workflow to appear
|
|
608
|
+
out.log(`\n Waiting for conductor to register...`);
|
|
609
|
+
const conductorWfId = (0, config_1.conductorWorkflowId)(opts.ensemble);
|
|
610
|
+
let conductorReady = false;
|
|
611
|
+
for (let i = 0; i < 30; i++) {
|
|
612
|
+
await new Promise(r => setTimeout(r, 500));
|
|
613
|
+
try {
|
|
614
|
+
const handle = client.workflow.getHandle(conductorWfId);
|
|
615
|
+
const desc = await handle.describe();
|
|
616
|
+
if (desc.status.name === 'RUNNING') {
|
|
617
|
+
conductorReady = true;
|
|
618
|
+
break;
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
catch { /* not yet */ }
|
|
622
|
+
}
|
|
623
|
+
if (!conductorReady) {
|
|
624
|
+
out.warn('Conductor did not register within 15s — skipping blueprint players/schedules');
|
|
625
|
+
}
|
|
626
|
+
else {
|
|
627
|
+
out.check('Conductor registered', true);
|
|
628
|
+
// Send conductor instructions if provided
|
|
629
|
+
if (blueprint.conductor?.instructions) {
|
|
630
|
+
try {
|
|
631
|
+
const handle = client.workflow.getHandle(conductorWfId);
|
|
632
|
+
await handle.signal('receiveMessage', {
|
|
633
|
+
from: 'blueprint',
|
|
634
|
+
text: blueprint.conductor.instructions,
|
|
635
|
+
});
|
|
636
|
+
out.check('Conductor instructions sent', true);
|
|
637
|
+
}
|
|
638
|
+
catch (err) {
|
|
639
|
+
out.warn(`Could not send conductor instructions: ${err}`);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
// Recruit players sequentially (each polls for ~15s)
|
|
643
|
+
if (blueprint.players.length > 0) {
|
|
644
|
+
console.log();
|
|
645
|
+
out.log(`Recruiting ${blueprint.players.length} player${blueprint.players.length !== 1 ? 's' : ''} from blueprint...`);
|
|
646
|
+
for (const player of blueprint.players) {
|
|
647
|
+
const playerAgent = player.agent === 'copilot' ? 'copilot' : 'claude';
|
|
648
|
+
const playerWorkDir = player.workDir || process.cwd();
|
|
649
|
+
// Record existing workflows to detect the new one
|
|
650
|
+
const existingIds = new Set();
|
|
651
|
+
const listQuery = `WorkflowType = "claudeSessionWorkflow" AND ExecutionStatus = "Running"`;
|
|
652
|
+
for await (const wf of client.workflow.list({ query: listQuery })) {
|
|
653
|
+
existingIds.add(wf.workflowId);
|
|
654
|
+
}
|
|
655
|
+
// Spawn the player
|
|
656
|
+
if (playerAgent === 'copilot') {
|
|
657
|
+
(0, spawn_1.spawnCopilotBridge)({
|
|
658
|
+
name: player.name,
|
|
659
|
+
ensemble: opts.ensemble,
|
|
660
|
+
temporalAddress: config.temporalAddress,
|
|
661
|
+
temporalNamespace: config.temporalNamespace,
|
|
662
|
+
temporalApiKey: config.temporalApiKey,
|
|
663
|
+
temporalTlsCertPath: config.temporalTlsCertPath,
|
|
664
|
+
temporalTlsKeyPath: config.temporalTlsKeyPath,
|
|
665
|
+
isConductor: false,
|
|
666
|
+
workDir: playerWorkDir,
|
|
667
|
+
});
|
|
668
|
+
}
|
|
669
|
+
else {
|
|
670
|
+
const claudeArgs = [
|
|
671
|
+
'--dangerously-skip-permissions',
|
|
672
|
+
'--dangerously-load-development-channels', 'server:claude-tempo',
|
|
673
|
+
'-n', player.name,
|
|
674
|
+
];
|
|
675
|
+
(0, spawn_1.spawnInTerminal)(claudeArgs, playerWorkDir, {
|
|
676
|
+
...temporalEnvVars,
|
|
677
|
+
[config_1.ENV.ENSEMBLE]: opts.ensemble,
|
|
678
|
+
[config_1.ENV.CONDUCTOR]: '',
|
|
679
|
+
[config_1.ENV.PLAYER_NAME]: player.name,
|
|
680
|
+
});
|
|
681
|
+
}
|
|
682
|
+
// Poll for the new workflow to appear (up to ~15s)
|
|
683
|
+
let newWorkflowId = null;
|
|
684
|
+
for (let attempt = 0; attempt < 30; attempt++) {
|
|
685
|
+
await new Promise(r => setTimeout(r, 500));
|
|
686
|
+
for await (const wf of client.workflow.list({ query: listQuery })) {
|
|
687
|
+
if (!existingIds.has(wf.workflowId)) {
|
|
688
|
+
newWorkflowId = wf.workflowId;
|
|
689
|
+
break;
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
if (newWorkflowId)
|
|
693
|
+
break;
|
|
694
|
+
}
|
|
695
|
+
if (newWorkflowId && player.instructions) {
|
|
696
|
+
try {
|
|
697
|
+
const handle = client.workflow.getHandle(newWorkflowId);
|
|
698
|
+
await handle.signal('receiveMessage', {
|
|
699
|
+
from: 'blueprint',
|
|
700
|
+
text: player.instructions,
|
|
701
|
+
});
|
|
702
|
+
}
|
|
703
|
+
catch { /* best effort */ }
|
|
704
|
+
}
|
|
705
|
+
const status = newWorkflowId ? out.green('ok') : out.yellow('slow');
|
|
706
|
+
out.log(` ${status} ${out.bold(player.name)} in ${playerWorkDir}`);
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
// Create schedules
|
|
710
|
+
if (blueprint.schedules && blueprint.schedules.length > 0) {
|
|
711
|
+
console.log();
|
|
712
|
+
out.log(`Creating ${blueprint.schedules.length} schedule${blueprint.schedules.length !== 1 ? 's' : ''}...`);
|
|
713
|
+
for (const sched of blueprint.schedules) {
|
|
714
|
+
try {
|
|
715
|
+
const entry = blueprintScheduleToEntry(sched);
|
|
716
|
+
const schedulerWfId = (0, config_1.schedulerWorkflowId)(opts.ensemble);
|
|
717
|
+
const handle = client.workflow.getHandle(schedulerWfId);
|
|
718
|
+
await handle.signal(scheduler_signals_1.addScheduleSignal, entry);
|
|
719
|
+
out.check(sched.name, true, `→ ${sched.target}`);
|
|
720
|
+
}
|
|
721
|
+
catch (err) {
|
|
722
|
+
out.warn(`Could not create schedule "${sched.name}": ${err}`);
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
await connection.close();
|
|
728
|
+
}
|
|
590
729
|
console.log();
|
|
591
730
|
out.success('You\'re all set!');
|
|
592
|
-
out.log(` Conductor launched (pid ${pid ?? 'unknown'})`);
|
|
593
731
|
out.log(` Ensemble: ${out.cyan(opts.ensemble)}`);
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
732
|
+
if (!blueprint) {
|
|
733
|
+
out.log(`\n ${out.bold('What next?')}`);
|
|
734
|
+
out.log(` ${out.dim('claude-tempo start ' + opts.ensemble)} Add a player session`);
|
|
735
|
+
out.log(` ${out.dim('claude-tempo status ' + opts.ensemble)} See who\'s active`);
|
|
736
|
+
out.log(` Or ask the conductor to ${out.dim('recruit')} players for you`);
|
|
737
|
+
}
|
|
738
|
+
else {
|
|
739
|
+
out.log(` Blueprint: ${out.dim(blueprint.name)}`);
|
|
740
|
+
out.log(` Players: ${blueprint.players.length}`);
|
|
741
|
+
if (blueprint.schedules?.length)
|
|
742
|
+
out.log(` Schedules: ${blueprint.schedules.length}`);
|
|
743
|
+
out.log(`\n ${out.dim('claude-tempo status ' + opts.ensemble)} See who\'s active`);
|
|
744
|
+
}
|
|
598
745
|
console.log();
|
|
599
746
|
}
|
|
747
|
+
/** Convert a blueprint schedule definition to a ScheduleEntry for the scheduler workflow. */
|
|
748
|
+
function blueprintScheduleToEntry(sched) {
|
|
749
|
+
const now = Date.now();
|
|
750
|
+
let nextFireAt;
|
|
751
|
+
let interval;
|
|
752
|
+
if (sched.every) {
|
|
753
|
+
interval = parseDuration(sched.every);
|
|
754
|
+
nextFireAt = sched.delay
|
|
755
|
+
? new Date(now + parseDuration(sched.delay)).toISOString()
|
|
756
|
+
: new Date(now + interval).toISOString();
|
|
757
|
+
}
|
|
758
|
+
else if (sched.at) {
|
|
759
|
+
nextFireAt = new Date(sched.at).toISOString();
|
|
760
|
+
}
|
|
761
|
+
else if (sched.delay) {
|
|
762
|
+
nextFireAt = new Date(now + parseDuration(sched.delay)).toISOString();
|
|
763
|
+
}
|
|
764
|
+
else {
|
|
765
|
+
nextFireAt = new Date(now + 60_000).toISOString(); // default: 1 minute
|
|
766
|
+
}
|
|
767
|
+
return {
|
|
768
|
+
name: sched.name,
|
|
769
|
+
message: sched.message,
|
|
770
|
+
target: sched.target,
|
|
771
|
+
createdBy: 'blueprint',
|
|
772
|
+
nextFireAt,
|
|
773
|
+
interval,
|
|
774
|
+
until: sched.until,
|
|
775
|
+
remainingCount: sched.count,
|
|
776
|
+
firedCount: 0,
|
|
777
|
+
type: interval ? 'interval' : 'once',
|
|
778
|
+
};
|
|
779
|
+
}
|
|
780
|
+
/** Parse a human duration string like "10m", "1h", "30s" to milliseconds. */
|
|
781
|
+
function parseDuration(s) {
|
|
782
|
+
const match = s.match(/^(\d+(?:\.\d+)?)\s*(s|m|h|d)$/);
|
|
783
|
+
if (!match)
|
|
784
|
+
throw new Error(`Invalid duration: "${s}"`);
|
|
785
|
+
const value = parseFloat(match[1]);
|
|
786
|
+
switch (match[2]) {
|
|
787
|
+
case 's': return value * 1_000;
|
|
788
|
+
case 'm': return value * 60_000;
|
|
789
|
+
case 'h': return value * 3_600_000;
|
|
790
|
+
case 'd': return value * 86_400_000;
|
|
791
|
+
default: throw new Error(`Unknown duration unit: "${match[2]}"`);
|
|
792
|
+
}
|
|
793
|
+
}
|
|
600
794
|
async function down(opts) {
|
|
601
795
|
const config = (0, config_1.getConfig)(opts);
|
|
602
796
|
out.heading('claude-tempo teardown');
|
|
@@ -867,6 +1061,57 @@ function killBridgeProcesses() {
|
|
|
867
1061
|
// logs dir unreadable
|
|
868
1062
|
}
|
|
869
1063
|
}
|
|
1064
|
+
async function ensembleCommand(opts) {
|
|
1065
|
+
switch (opts.subcommand) {
|
|
1066
|
+
case 'save': {
|
|
1067
|
+
const config = (0, config_1.getConfig)(opts);
|
|
1068
|
+
const connection = await (0, connection_1.createTemporalConnection)(config);
|
|
1069
|
+
const client = new client_1.Client({ connection, namespace: config.temporalNamespace });
|
|
1070
|
+
const ensemble = opts.name || config.ensemble;
|
|
1071
|
+
try {
|
|
1072
|
+
const path = await (0, saver_1.saveBlueprint)(client, ensemble);
|
|
1073
|
+
out.success(`Saved ensemble "${ensemble}" to ${path}`);
|
|
1074
|
+
}
|
|
1075
|
+
finally {
|
|
1076
|
+
await connection.close();
|
|
1077
|
+
}
|
|
1078
|
+
break;
|
|
1079
|
+
}
|
|
1080
|
+
case 'list': {
|
|
1081
|
+
const blueprints = (0, saver_1.listBlueprints)();
|
|
1082
|
+
if (blueprints.length === 0) {
|
|
1083
|
+
out.log('No saved ensembles. Use `claude-tempo ensemble save [name]` to save one.');
|
|
1084
|
+
return;
|
|
1085
|
+
}
|
|
1086
|
+
out.heading('Saved ensembles');
|
|
1087
|
+
for (const bp of blueprints) {
|
|
1088
|
+
out.log(` ${out.bold(bp.name)} ${out.dim(bp.path)}`);
|
|
1089
|
+
}
|
|
1090
|
+
console.log();
|
|
1091
|
+
break;
|
|
1092
|
+
}
|
|
1093
|
+
case 'show': {
|
|
1094
|
+
if (!opts.name) {
|
|
1095
|
+
out.error('Usage: claude-tempo ensemble show <name>');
|
|
1096
|
+
process.exit(1);
|
|
1097
|
+
}
|
|
1098
|
+
const content = (0, saver_1.readSavedBlueprint)(opts.name);
|
|
1099
|
+
if (!content) {
|
|
1100
|
+
out.error(`No saved ensemble named "${opts.name}"`);
|
|
1101
|
+
out.log(` Run ${out.dim('claude-tempo ensemble list')} to see available ensembles.`);
|
|
1102
|
+
process.exit(1);
|
|
1103
|
+
}
|
|
1104
|
+
console.log(content);
|
|
1105
|
+
break;
|
|
1106
|
+
}
|
|
1107
|
+
default:
|
|
1108
|
+
out.error('Usage: claude-tempo ensemble <save|list|show> [name]');
|
|
1109
|
+
out.log(`\n ${out.dim('claude-tempo ensemble save [name]')} Save current ensemble state`);
|
|
1110
|
+
out.log(` ${out.dim('claude-tempo ensemble list')} List saved ensembles`);
|
|
1111
|
+
out.log(` ${out.dim('claude-tempo ensemble show <name>')} Display a saved blueprint`);
|
|
1112
|
+
process.exit(1);
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
870
1115
|
function help() {
|
|
871
1116
|
console.log(`
|
|
872
1117
|
${out.bold('claude-tempo')} — Multi-session Claude Code coordination via Temporal
|
|
@@ -885,6 +1130,7 @@ ${out.bold('Commands:')}
|
|
|
885
1130
|
${out.cyan('start')} [ensemble] Start a player session
|
|
886
1131
|
${out.cyan('stop')} [ensemble] Stop sessions (-n <name> for one, or --all)
|
|
887
1132
|
${out.cyan('status')} [ensemble] Show active sessions and Temporal health
|
|
1133
|
+
${out.cyan('ensemble')} <sub> Manage saved ensemble blueprints (save/list/show)
|
|
888
1134
|
${out.cyan('config')} Configure Temporal connection settings
|
|
889
1135
|
${out.cyan('init')} Register MCP server globally (or --project for .mcp.json)
|
|
890
1136
|
${out.cyan('preflight')} Run preflight checks only
|
|
@@ -905,6 +1151,7 @@ ${out.bold('Other options:')}
|
|
|
905
1151
|
--project Use per-project .mcp.json instead of global (init only)
|
|
906
1152
|
--keep-mcp Don't remove MCP config (down only)
|
|
907
1153
|
--all Stop all sessions (stop only)
|
|
1154
|
+
--from <file> Load ensemble from a YAML blueprint (up only)
|
|
908
1155
|
--ensemble <name> Target a specific ensemble (stop only)
|
|
909
1156
|
-d, --dir <path> Target directory (default: cwd)
|
|
910
1157
|
|
package/dist/cli.js
CHANGED
|
@@ -70,6 +70,9 @@ function parseArgs(argv) {
|
|
|
70
70
|
else if (arg === '--temporal-tls-key' && i + 1 < argv.length) {
|
|
71
71
|
result.temporalTlsKeyPath = argv[++i];
|
|
72
72
|
}
|
|
73
|
+
else if (arg === '--from' && i + 1 < argv.length) {
|
|
74
|
+
result.from = argv[++i];
|
|
75
|
+
}
|
|
73
76
|
else if ((arg === '-n' || arg === '--name') && i + 1 < argv.length) {
|
|
74
77
|
result.name = argv[++i];
|
|
75
78
|
}
|
|
@@ -201,10 +204,18 @@ async function main() {
|
|
|
201
204
|
await (0, commands_1.up)({
|
|
202
205
|
ensemble,
|
|
203
206
|
name: args.name,
|
|
207
|
+
from: args.from,
|
|
204
208
|
agent: resolvedAgent(),
|
|
205
209
|
...overrides,
|
|
206
210
|
});
|
|
207
211
|
break;
|
|
212
|
+
case 'ensemble':
|
|
213
|
+
await (0, commands_1.ensembleCommand)({
|
|
214
|
+
subcommand: args.positional[1],
|
|
215
|
+
name: args.positional[2],
|
|
216
|
+
...overrides,
|
|
217
|
+
});
|
|
218
|
+
break;
|
|
208
219
|
case 'init':
|
|
209
220
|
await (0, commands_1.init)({ dir: args.dir, project: args.project });
|
|
210
221
|
break;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.loadBlueprint = loadBlueprint;
|
|
4
|
+
const fs_1 = require("fs");
|
|
5
|
+
const yaml_1 = require("yaml");
|
|
6
|
+
/**
|
|
7
|
+
* Load and validate an ensemble blueprint from a YAML file.
|
|
8
|
+
*/
|
|
9
|
+
function loadBlueprint(filePath) {
|
|
10
|
+
const raw = (0, fs_1.readFileSync)(filePath, 'utf8');
|
|
11
|
+
const doc = (0, yaml_1.parse)(raw);
|
|
12
|
+
if (!doc || typeof doc !== 'object') {
|
|
13
|
+
throw new Error(`Invalid blueprint: file does not contain a YAML object`);
|
|
14
|
+
}
|
|
15
|
+
// Required: name
|
|
16
|
+
if (typeof doc.name !== 'string' || !doc.name) {
|
|
17
|
+
throw new Error(`Invalid blueprint: "name" is required and must be a non-empty string`);
|
|
18
|
+
}
|
|
19
|
+
// Required: players array
|
|
20
|
+
if (!Array.isArray(doc.players)) {
|
|
21
|
+
throw new Error(`Invalid blueprint: "players" must be an array`);
|
|
22
|
+
}
|
|
23
|
+
for (let i = 0; i < doc.players.length; i++) {
|
|
24
|
+
const p = doc.players[i];
|
|
25
|
+
if (typeof p.name !== 'string' || !p.name) {
|
|
26
|
+
throw new Error(`Invalid blueprint: players[${i}].name is required`);
|
|
27
|
+
}
|
|
28
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(p.name)) {
|
|
29
|
+
throw new Error(`Invalid blueprint: players[${i}].name "${p.name}" contains invalid characters`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
// Validate schedules if present
|
|
33
|
+
if (doc.schedules != null) {
|
|
34
|
+
if (!Array.isArray(doc.schedules)) {
|
|
35
|
+
throw new Error(`Invalid blueprint: "schedules" must be an array`);
|
|
36
|
+
}
|
|
37
|
+
for (let i = 0; i < doc.schedules.length; i++) {
|
|
38
|
+
const s = doc.schedules[i];
|
|
39
|
+
if (typeof s.name !== 'string' || !s.name) {
|
|
40
|
+
throw new Error(`Invalid blueprint: schedules[${i}].name is required`);
|
|
41
|
+
}
|
|
42
|
+
if (typeof s.message !== 'string' || !s.message) {
|
|
43
|
+
throw new Error(`Invalid blueprint: schedules[${i}].message is required`);
|
|
44
|
+
}
|
|
45
|
+
if (typeof s.target !== 'string' || !s.target) {
|
|
46
|
+
throw new Error(`Invalid blueprint: schedules[${i}].target is required`);
|
|
47
|
+
}
|
|
48
|
+
if (!s.at && !s.delay && !s.every) {
|
|
49
|
+
throw new Error(`Invalid blueprint: schedules[${i}] must have at least one of: at, delay, every`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
name: doc.name,
|
|
55
|
+
description: doc.description,
|
|
56
|
+
conductor: doc.conductor,
|
|
57
|
+
players: doc.players,
|
|
58
|
+
schedules: doc.schedules,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Client } from '@temporalio/client';
|
|
2
|
+
/**
|
|
3
|
+
* Save the current live ensemble state to a YAML blueprint file.
|
|
4
|
+
* Queries all running sessions and active schedules from Temporal.
|
|
5
|
+
*/
|
|
6
|
+
export declare function saveBlueprint(client: Client, ensemble: string, filePath?: string): Promise<string>;
|
|
7
|
+
/**
|
|
8
|
+
* List all saved ensemble blueprints in ~/.claude-tempo/ensembles/.
|
|
9
|
+
*/
|
|
10
|
+
export declare function listBlueprints(): Array<{
|
|
11
|
+
name: string;
|
|
12
|
+
path: string;
|
|
13
|
+
}>;
|
|
14
|
+
/**
|
|
15
|
+
* Read a saved blueprint by name from ~/.claude-tempo/ensembles/.
|
|
16
|
+
*/
|
|
17
|
+
export declare function readSavedBlueprint(name: string): string | null;
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.saveBlueprint = saveBlueprint;
|
|
4
|
+
exports.listBlueprints = listBlueprints;
|
|
5
|
+
exports.readSavedBlueprint = readSavedBlueprint;
|
|
6
|
+
const fs_1 = require("fs");
|
|
7
|
+
const path_1 = require("path");
|
|
8
|
+
const yaml_1 = require("yaml");
|
|
9
|
+
const config_1 = require("../config");
|
|
10
|
+
const ENSEMBLES_DIR = (0, path_1.join)(config_1.CLAUDE_TEMPO_HOME, 'ensembles');
|
|
11
|
+
function ensemblesDir() {
|
|
12
|
+
(0, fs_1.mkdirSync)(ENSEMBLES_DIR, { recursive: true });
|
|
13
|
+
return ENSEMBLES_DIR;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Save the current live ensemble state to a YAML blueprint file.
|
|
17
|
+
* Queries all running sessions and active schedules from Temporal.
|
|
18
|
+
*/
|
|
19
|
+
async function saveBlueprint(client, ensemble, filePath) {
|
|
20
|
+
const outputPath = filePath || (0, path_1.join)(ensemblesDir(), `${ensemble}.yaml`);
|
|
21
|
+
// Query all running session workflows
|
|
22
|
+
const query = 'WorkflowType = "claudeSessionWorkflow" AND ExecutionStatus = "Running"';
|
|
23
|
+
const players = [];
|
|
24
|
+
let conductor;
|
|
25
|
+
for await (const wf of client.workflow.list({ query })) {
|
|
26
|
+
try {
|
|
27
|
+
const handle = client.workflow.getHandle(wf.workflowId);
|
|
28
|
+
const [metadata, part] = await Promise.all([
|
|
29
|
+
handle.query('getMetadata').catch(() => ({})),
|
|
30
|
+
handle.query('getPart').catch(() => ''),
|
|
31
|
+
]);
|
|
32
|
+
const meta = metadata;
|
|
33
|
+
if (meta.ensemble !== ensemble)
|
|
34
|
+
continue;
|
|
35
|
+
const isConductor = meta.isConductor || false;
|
|
36
|
+
const agentType = meta.agentType || 'claude';
|
|
37
|
+
const workDir = meta.workDir || undefined;
|
|
38
|
+
if (isConductor) {
|
|
39
|
+
conductor = {
|
|
40
|
+
agent: agentType === 'copilot' ? 'copilot' : undefined,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
const name = meta.playerId || wf.workflowId.split('-').pop() || 'unknown';
|
|
45
|
+
players.push({
|
|
46
|
+
name,
|
|
47
|
+
workDir,
|
|
48
|
+
agent: agentType === 'copilot' ? 'copilot' : undefined,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
// workflow may have closed between list and query
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
// Query active schedules
|
|
57
|
+
const schedules = [];
|
|
58
|
+
try {
|
|
59
|
+
const schedulerWfId = (0, config_1.schedulerWorkflowId)(ensemble);
|
|
60
|
+
const handle = client.workflow.getHandle(schedulerWfId);
|
|
61
|
+
const entries = await handle.query('getSchedules');
|
|
62
|
+
for (const entry of entries) {
|
|
63
|
+
const sched = {
|
|
64
|
+
name: entry.name,
|
|
65
|
+
message: entry.message,
|
|
66
|
+
target: entry.target,
|
|
67
|
+
};
|
|
68
|
+
if (entry.interval) {
|
|
69
|
+
sched.every = formatDurationMs(entry.interval);
|
|
70
|
+
}
|
|
71
|
+
if (entry.until) {
|
|
72
|
+
sched.until = entry.until;
|
|
73
|
+
}
|
|
74
|
+
if (entry.remainingCount != null) {
|
|
75
|
+
sched.count = entry.remainingCount;
|
|
76
|
+
}
|
|
77
|
+
schedules.push(sched);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
// No scheduler running or no schedules
|
|
82
|
+
}
|
|
83
|
+
const blueprint = {
|
|
84
|
+
name: ensemble,
|
|
85
|
+
conductor,
|
|
86
|
+
players,
|
|
87
|
+
...(schedules.length > 0 ? { schedules } : {}),
|
|
88
|
+
};
|
|
89
|
+
// Ensure parent directory exists
|
|
90
|
+
const parentDir = outputPath.substring(0, outputPath.lastIndexOf('/') >= 0 ? outputPath.lastIndexOf('/') : outputPath.lastIndexOf('\\'));
|
|
91
|
+
if (parentDir)
|
|
92
|
+
(0, fs_1.mkdirSync)(parentDir, { recursive: true });
|
|
93
|
+
(0, fs_1.writeFileSync)(outputPath, (0, yaml_1.stringify)(blueprint));
|
|
94
|
+
return outputPath;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* List all saved ensemble blueprints in ~/.claude-tempo/ensembles/.
|
|
98
|
+
*/
|
|
99
|
+
function listBlueprints() {
|
|
100
|
+
const dir = ensemblesDir();
|
|
101
|
+
if (!(0, fs_1.existsSync)(dir))
|
|
102
|
+
return [];
|
|
103
|
+
return (0, fs_1.readdirSync)(dir)
|
|
104
|
+
.filter(f => f.endsWith('.yaml') || f.endsWith('.yml'))
|
|
105
|
+
.map(f => ({
|
|
106
|
+
name: f.replace(/\.ya?ml$/, ''),
|
|
107
|
+
path: (0, path_1.join)(dir, f),
|
|
108
|
+
}));
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Read a saved blueprint by name from ~/.claude-tempo/ensembles/.
|
|
112
|
+
*/
|
|
113
|
+
function readSavedBlueprint(name) {
|
|
114
|
+
const dir = ensemblesDir();
|
|
115
|
+
for (const ext of ['.yaml', '.yml']) {
|
|
116
|
+
const path = (0, path_1.join)(dir, name + ext);
|
|
117
|
+
if ((0, fs_1.existsSync)(path))
|
|
118
|
+
return (0, fs_1.readFileSync)(path, 'utf8');
|
|
119
|
+
}
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
function formatDurationMs(ms) {
|
|
123
|
+
if (ms >= 86_400_000 && ms % 86_400_000 === 0)
|
|
124
|
+
return `${ms / 86_400_000}d`;
|
|
125
|
+
if (ms >= 3_600_000 && ms % 3_600_000 === 0)
|
|
126
|
+
return `${ms / 3_600_000}h`;
|
|
127
|
+
if (ms >= 60_000 && ms % 60_000 === 0)
|
|
128
|
+
return `${ms / 60_000}m`;
|
|
129
|
+
return `${ms / 1000}s`;
|
|
130
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export interface EnsembleBlueprint {
|
|
2
|
+
name: string;
|
|
3
|
+
description?: string;
|
|
4
|
+
conductor?: {
|
|
5
|
+
agent?: string;
|
|
6
|
+
instructions?: string;
|
|
7
|
+
};
|
|
8
|
+
players: Array<{
|
|
9
|
+
name: string;
|
|
10
|
+
workDir?: string;
|
|
11
|
+
agent?: string;
|
|
12
|
+
instructions?: string;
|
|
13
|
+
}>;
|
|
14
|
+
schedules?: Array<{
|
|
15
|
+
name: string;
|
|
16
|
+
message: string;
|
|
17
|
+
target: string;
|
|
18
|
+
at?: string;
|
|
19
|
+
delay?: string;
|
|
20
|
+
every?: string;
|
|
21
|
+
until?: string;
|
|
22
|
+
count?: number;
|
|
23
|
+
}>;
|
|
24
|
+
}
|
package/dist/server.js
CHANGED
|
@@ -55,6 +55,8 @@ const set_name_1 = require("./tools/set-name");
|
|
|
55
55
|
const schedule_1 = require("./tools/schedule");
|
|
56
56
|
const unschedule_1 = require("./tools/unschedule");
|
|
57
57
|
const schedules_1 = require("./tools/schedules");
|
|
58
|
+
const save_ensemble_1 = require("./tools/save-ensemble");
|
|
59
|
+
const load_ensemble_1 = require("./tools/load-ensemble");
|
|
58
60
|
const channel_1 = require("./channel");
|
|
59
61
|
const log = (...args) => console.error('[claude-tempo]', ...args);
|
|
60
62
|
function getGitInfo(workDir) {
|
|
@@ -195,6 +197,8 @@ async function main() {
|
|
|
195
197
|
(0, schedule_1.registerScheduleTool)(mcpServer, client, config, getPlayerId);
|
|
196
198
|
(0, unschedule_1.registerUnscheduleTool)(mcpServer, client, config);
|
|
197
199
|
(0, schedules_1.registerSchedulesTool)(mcpServer, client, config);
|
|
200
|
+
(0, save_ensemble_1.registerSaveEnsembleTool)(mcpServer, client, config, getPlayerId, isConductor);
|
|
201
|
+
(0, load_ensemble_1.registerLoadEnsembleTool)(mcpServer, client, config, getPlayerId, isBridgeMode ? 'copilot' : 'claude');
|
|
198
202
|
const MAESTRO_ACK = '\n\n[IMPORTANT: This message is from a human (Maestro). Immediately cue the sender back with a brief acknowledgment and your planned next step before doing the work.]';
|
|
199
203
|
// Start message poller — push messages into Claude Code via channel notifications.
|
|
200
204
|
// Skip when running under the Copilot bridge: the bridge has its own poller that
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import { Client } from '@temporalio/client';
|
|
3
|
+
import { Config } from '../config';
|
|
4
|
+
import { AgentType } from '../types';
|
|
5
|
+
export declare function registerLoadEnsembleTool(server: McpServer, client: Client, config: Config, getPlayerId: () => string, ownAgentType?: AgentType): void;
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.registerLoadEnsembleTool = registerLoadEnsembleTool;
|
|
4
|
+
const zod_1 = require("zod");
|
|
5
|
+
const path_1 = require("path");
|
|
6
|
+
const client_1 = require("@temporalio/client");
|
|
7
|
+
const config_1 = require("../config");
|
|
8
|
+
const loader_1 = require("../ensemble/loader");
|
|
9
|
+
const saver_1 = require("../ensemble/saver");
|
|
10
|
+
const resolve_1 = require("./resolve");
|
|
11
|
+
const spawn_1 = require("../spawn");
|
|
12
|
+
const helpers_1 = require("./helpers");
|
|
13
|
+
const log = (...args) => console.error('[claude-tempo:load-ensemble]', ...args);
|
|
14
|
+
function sleep(ms) {
|
|
15
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
16
|
+
}
|
|
17
|
+
/** Parse a duration string like "30s", "10m", "2h", "1d" into milliseconds. */
|
|
18
|
+
function parseDuration(dur) {
|
|
19
|
+
const match = dur.match(/^(\d+(?:\.\d+)?)\s*(s|m|h|d)$/i);
|
|
20
|
+
if (!match)
|
|
21
|
+
return null;
|
|
22
|
+
const value = parseFloat(match[1]);
|
|
23
|
+
switch (match[2].toLowerCase()) {
|
|
24
|
+
case 's': return value * 1000;
|
|
25
|
+
case 'm': return value * 60_000;
|
|
26
|
+
case 'h': return value * 3_600_000;
|
|
27
|
+
case 'd': return value * 86_400_000;
|
|
28
|
+
default: return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
function registerLoadEnsembleTool(server, client, config, getPlayerId, ownAgentType = 'claude') {
|
|
32
|
+
(0, helpers_1.defineTool)(server, 'load_ensemble', 'Load an ensemble blueprint — recruits players and creates schedules.', {
|
|
33
|
+
name: zod_1.z.string().optional().describe('Name of a saved blueprint (from ~/.claude-tempo/ensembles/)'),
|
|
34
|
+
path: zod_1.z.string().optional().describe('Explicit file path to a blueprint YAML file'),
|
|
35
|
+
}, async (args) => {
|
|
36
|
+
const blueprintName = args.name;
|
|
37
|
+
const blueprintPath = args.path;
|
|
38
|
+
if (!blueprintName && !blueprintPath) {
|
|
39
|
+
return {
|
|
40
|
+
content: [{
|
|
41
|
+
type: 'text',
|
|
42
|
+
text: 'Provide either `name` (saved blueprint) or `path` (file path). Exactly one is required.',
|
|
43
|
+
}],
|
|
44
|
+
isError: true,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
if (blueprintName && blueprintPath) {
|
|
48
|
+
return {
|
|
49
|
+
content: [{
|
|
50
|
+
type: 'text',
|
|
51
|
+
text: 'Provide either `name` or `path`, not both.',
|
|
52
|
+
}],
|
|
53
|
+
isError: true,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
try {
|
|
57
|
+
// Resolve the file path
|
|
58
|
+
let filePath;
|
|
59
|
+
if (blueprintPath) {
|
|
60
|
+
filePath = blueprintPath;
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
// Try to find saved blueprint by name
|
|
64
|
+
const savedContent = (0, saver_1.readSavedBlueprint)(blueprintName);
|
|
65
|
+
if (!savedContent) {
|
|
66
|
+
return {
|
|
67
|
+
content: [{
|
|
68
|
+
type: 'text',
|
|
69
|
+
text: `No saved blueprint found with name "${blueprintName}". Check ~/.claude-tempo/ensembles/.`,
|
|
70
|
+
}],
|
|
71
|
+
isError: true,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
// readSavedBlueprint returns content, but loadBlueprint wants a path.
|
|
75
|
+
// Construct the path directly.
|
|
76
|
+
const ensemblesDir = (0, path_1.join)(config_1.CLAUDE_TEMPO_HOME, 'ensembles');
|
|
77
|
+
// Try both extensions
|
|
78
|
+
const { existsSync } = require('fs');
|
|
79
|
+
filePath = (0, path_1.join)(ensemblesDir, `${blueprintName}.yaml`);
|
|
80
|
+
if (!existsSync(filePath)) {
|
|
81
|
+
filePath = (0, path_1.join)(ensemblesDir, `${blueprintName}.yml`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const blueprint = (0, loader_1.loadBlueprint)(filePath);
|
|
85
|
+
const recruited = [];
|
|
86
|
+
const failed = [];
|
|
87
|
+
// Recruit players sequentially
|
|
88
|
+
for (const player of blueprint.players) {
|
|
89
|
+
const playerName = player.name;
|
|
90
|
+
const workDir = player.workDir || process.cwd();
|
|
91
|
+
const agentType = player.agent === 'copilot' ? 'copilot' : 'claude';
|
|
92
|
+
const isCustomAgent = player.agent && player.agent !== 'default' && player.agent !== 'copilot';
|
|
93
|
+
const systemPrompt = isCustomAgent ? player.agent : undefined;
|
|
94
|
+
// Skip if already active
|
|
95
|
+
const existing = await (0, resolve_1.resolveSession)(client, config.ensemble, playerName);
|
|
96
|
+
if (existing) {
|
|
97
|
+
log(`Player "${playerName}" already active — skipping recruit`);
|
|
98
|
+
recruited.push(`${playerName} (already active)`);
|
|
99
|
+
// Still send instructions if provided
|
|
100
|
+
if (player.instructions) {
|
|
101
|
+
try {
|
|
102
|
+
await existing.signal('receiveMessage', {
|
|
103
|
+
from: getPlayerId(),
|
|
104
|
+
text: player.instructions,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
// best effort
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
// Record existing workflows to detect the new one
|
|
114
|
+
const existingIds = new Set();
|
|
115
|
+
const listQuery = `WorkflowType = "claudeSessionWorkflow" AND ExecutionStatus = "Running"`;
|
|
116
|
+
for await (const wf of client.workflow.list({ query: listQuery })) {
|
|
117
|
+
existingIds.add(wf.workflowId);
|
|
118
|
+
}
|
|
119
|
+
// Spawn
|
|
120
|
+
try {
|
|
121
|
+
if (agentType === 'copilot') {
|
|
122
|
+
(0, spawn_1.spawnCopilotBridge)({
|
|
123
|
+
name: playerName,
|
|
124
|
+
ensemble: config.ensemble,
|
|
125
|
+
temporalAddress: config.temporalAddress,
|
|
126
|
+
temporalNamespace: config.temporalNamespace,
|
|
127
|
+
temporalApiKey: config.temporalApiKey,
|
|
128
|
+
temporalTlsCertPath: config.temporalTlsCertPath,
|
|
129
|
+
temporalTlsKeyPath: config.temporalTlsKeyPath,
|
|
130
|
+
isConductor: false,
|
|
131
|
+
workDir,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
else {
|
|
135
|
+
const spawnArgs = [
|
|
136
|
+
'--dangerously-skip-permissions',
|
|
137
|
+
'--dangerously-load-development-channels', 'server:claude-tempo',
|
|
138
|
+
'-n', playerName,
|
|
139
|
+
...(systemPrompt ? ['--system-prompt', systemPrompt] : []),
|
|
140
|
+
];
|
|
141
|
+
const envVars = {
|
|
142
|
+
[config_1.ENV.ENSEMBLE]: config.ensemble,
|
|
143
|
+
[config_1.ENV.CONDUCTOR]: '',
|
|
144
|
+
[config_1.ENV.PLAYER_NAME]: playerName,
|
|
145
|
+
[config_1.ENV.TEMPORAL_ADDRESS]: config.temporalAddress,
|
|
146
|
+
[config_1.ENV.TEMPORAL_NAMESPACE]: config.temporalNamespace,
|
|
147
|
+
};
|
|
148
|
+
if (config.temporalApiKey)
|
|
149
|
+
envVars[config_1.ENV.TEMPORAL_API_KEY] = config.temporalApiKey;
|
|
150
|
+
if (config.temporalTlsCertPath)
|
|
151
|
+
envVars[config_1.ENV.TEMPORAL_TLS_CERT_PATH] = config.temporalTlsCertPath;
|
|
152
|
+
if (config.temporalTlsKeyPath)
|
|
153
|
+
envVars[config_1.ENV.TEMPORAL_TLS_KEY_PATH] = config.temporalTlsKeyPath;
|
|
154
|
+
(0, spawn_1.spawnInTerminal)(spawnArgs, workDir, envVars);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
catch (err) {
|
|
158
|
+
failed.push(`${playerName}: spawn failed — ${err}`);
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
// Poll for the new workflow to appear (up to ~15s)
|
|
162
|
+
let newWorkflowId = null;
|
|
163
|
+
for (let attempt = 0; attempt < 30; attempt++) {
|
|
164
|
+
await sleep(500);
|
|
165
|
+
for await (const wf of client.workflow.list({ query: listQuery })) {
|
|
166
|
+
if (!existingIds.has(wf.workflowId)) {
|
|
167
|
+
newWorkflowId = wf.workflowId;
|
|
168
|
+
break;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
if (newWorkflowId)
|
|
172
|
+
break;
|
|
173
|
+
}
|
|
174
|
+
if (!newWorkflowId) {
|
|
175
|
+
failed.push(`${playerName}: spawned but did not register within 15s`);
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
// Send initial instructions if provided
|
|
179
|
+
if (player.instructions) {
|
|
180
|
+
try {
|
|
181
|
+
const newHandle = client.workflow.getHandle(newWorkflowId);
|
|
182
|
+
await newHandle.signal('receiveMessage', {
|
|
183
|
+
from: getPlayerId(),
|
|
184
|
+
text: player.instructions,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
// best effort
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
recruited.push(playerName);
|
|
192
|
+
log(`Recruited "${playerName}" in ${workDir}`);
|
|
193
|
+
}
|
|
194
|
+
// Create schedules
|
|
195
|
+
const schedulesCreated = [];
|
|
196
|
+
if (blueprint.schedules && blueprint.schedules.length > 0) {
|
|
197
|
+
for (const sched of blueprint.schedules) {
|
|
198
|
+
try {
|
|
199
|
+
const now = Date.now();
|
|
200
|
+
let nextFireAt;
|
|
201
|
+
let interval;
|
|
202
|
+
if (sched.at) {
|
|
203
|
+
nextFireAt = Date.parse(sched.at);
|
|
204
|
+
}
|
|
205
|
+
else if (sched.delay) {
|
|
206
|
+
const ms = parseDuration(sched.delay);
|
|
207
|
+
if (!ms)
|
|
208
|
+
throw new Error(`Invalid delay: ${sched.delay}`);
|
|
209
|
+
nextFireAt = now + ms;
|
|
210
|
+
}
|
|
211
|
+
else if (sched.every) {
|
|
212
|
+
const ms = parseDuration(sched.every);
|
|
213
|
+
if (!ms)
|
|
214
|
+
throw new Error(`Invalid interval: ${sched.every}`);
|
|
215
|
+
nextFireAt = now + ms;
|
|
216
|
+
interval = ms;
|
|
217
|
+
}
|
|
218
|
+
else {
|
|
219
|
+
throw new Error('No timing specified');
|
|
220
|
+
}
|
|
221
|
+
const scheduleEntry = {
|
|
222
|
+
name: sched.name,
|
|
223
|
+
message: sched.message,
|
|
224
|
+
target: sched.target,
|
|
225
|
+
type: sched.every ? 'interval' : 'once',
|
|
226
|
+
nextFireAt: new Date(nextFireAt).toISOString(),
|
|
227
|
+
interval,
|
|
228
|
+
until: sched.until,
|
|
229
|
+
remainingCount: sched.count,
|
|
230
|
+
firedCount: 0,
|
|
231
|
+
createdBy: getPlayerId(),
|
|
232
|
+
};
|
|
233
|
+
const wfId = (0, config_1.schedulerWorkflowId)(config.ensemble);
|
|
234
|
+
try {
|
|
235
|
+
const handle = client.workflow.getHandle(wfId);
|
|
236
|
+
await handle.describe();
|
|
237
|
+
await handle.signal('addSchedule', scheduleEntry);
|
|
238
|
+
}
|
|
239
|
+
catch {
|
|
240
|
+
await client.workflow.start('claudeSchedulerWorkflow', {
|
|
241
|
+
workflowId: wfId,
|
|
242
|
+
taskQueue: config.taskQueue,
|
|
243
|
+
args: [{ ensemble: config.ensemble, entries: [scheduleEntry] }],
|
|
244
|
+
workflowIdConflictPolicy: client_1.WorkflowIdConflictPolicy.USE_EXISTING,
|
|
245
|
+
searchAttributes: {
|
|
246
|
+
ClaudeTempoEnsemble: [config.ensemble],
|
|
247
|
+
},
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
schedulesCreated.push(sched.name);
|
|
251
|
+
}
|
|
252
|
+
catch (err) {
|
|
253
|
+
failed.push(`schedule "${sched.name}": ${err}`);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
// Build summary
|
|
258
|
+
const lines = [`Loaded blueprint **${blueprint.name}**.`];
|
|
259
|
+
if (recruited.length > 0) {
|
|
260
|
+
lines.push(`Recruited: ${recruited.join(', ')}`);
|
|
261
|
+
}
|
|
262
|
+
if (schedulesCreated.length > 0) {
|
|
263
|
+
lines.push(`Schedules created: ${schedulesCreated.join(', ')}`);
|
|
264
|
+
}
|
|
265
|
+
if (failed.length > 0) {
|
|
266
|
+
lines.push(`Failures:\n${failed.map(f => ` - ${f}`).join('\n')}`);
|
|
267
|
+
}
|
|
268
|
+
return {
|
|
269
|
+
content: [{
|
|
270
|
+
type: 'text',
|
|
271
|
+
text: lines.join('\n'),
|
|
272
|
+
}],
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
catch (err) {
|
|
276
|
+
return {
|
|
277
|
+
content: [{ type: 'text', text: `Failed to load blueprint: ${err}` }],
|
|
278
|
+
isError: true,
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
});
|
|
282
|
+
}
|
package/dist/tools/recruit.js
CHANGED
|
@@ -20,10 +20,13 @@ function registerRecruitTool(server, client, config, getPlayerId, ownAgentType =
|
|
|
20
20
|
.describe('Optional task or message for the new session (sent after it sets its name)'),
|
|
21
21
|
agent: zod_1.z.enum(['claude', 'copilot']).optional()
|
|
22
22
|
.describe(`Which agent to use (default: "${ownAgentType}", same as this session)`),
|
|
23
|
+
systemPrompt: zod_1.z.string().optional()
|
|
24
|
+
.describe('Path to a .md file to use as custom agent system prompt (--system-prompt)'),
|
|
23
25
|
}, async (args) => {
|
|
24
26
|
const { workDir, name, initialMessage } = args;
|
|
25
27
|
const isConductor = args.conductor === true;
|
|
26
28
|
const agent = args.agent || ownAgentType;
|
|
29
|
+
const systemPrompt = args.systemPrompt;
|
|
27
30
|
// Validate name
|
|
28
31
|
if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
|
|
29
32
|
return {
|
|
@@ -101,6 +104,7 @@ function registerRecruitTool(server, client, config, getPlayerId, ownAgentType =
|
|
|
101
104
|
'--dangerously-skip-permissions',
|
|
102
105
|
'--dangerously-load-development-channels', 'server:claude-tempo',
|
|
103
106
|
'-n', name,
|
|
107
|
+
...(systemPrompt ? ['--system-prompt', systemPrompt] : []),
|
|
104
108
|
];
|
|
105
109
|
const envVars = {
|
|
106
110
|
[config_1.ENV.ENSEMBLE]: config.ensemble,
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import { Client } from '@temporalio/client';
|
|
3
|
+
import { Config } from '../config';
|
|
4
|
+
export declare function registerSaveEnsembleTool(server: McpServer, client: Client, config: Config, getPlayerId: () => string, isConductor: boolean): void;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.registerSaveEnsembleTool = registerSaveEnsembleTool;
|
|
4
|
+
const zod_1 = require("zod");
|
|
5
|
+
const saver_1 = require("../ensemble/saver");
|
|
6
|
+
const helpers_1 = require("./helpers");
|
|
7
|
+
const log = (...args) => console.error('[claude-tempo:save-ensemble]', ...args);
|
|
8
|
+
function registerSaveEnsembleTool(server, client, config, getPlayerId, isConductor) {
|
|
9
|
+
(0, helpers_1.defineTool)(server, 'save_ensemble', 'Save the current ensemble state as a YAML blueprint. Only available to the conductor.', {
|
|
10
|
+
name: zod_1.z.string().optional().describe('Blueprint name (defaults to ensemble name)'),
|
|
11
|
+
path: zod_1.z.string().optional().describe('Explicit file path to save to'),
|
|
12
|
+
}, async (args) => {
|
|
13
|
+
if (!isConductor) {
|
|
14
|
+
return {
|
|
15
|
+
content: [{
|
|
16
|
+
type: 'text',
|
|
17
|
+
text: 'Only the conductor can save ensemble blueprints.',
|
|
18
|
+
}],
|
|
19
|
+
isError: true,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
const blueprintName = args.name;
|
|
23
|
+
const filePath = args.path;
|
|
24
|
+
try {
|
|
25
|
+
// If a custom name is provided but no path, we don't override —
|
|
26
|
+
// saveBlueprint uses the ensemble name for the default path.
|
|
27
|
+
// We pass filePath through directly.
|
|
28
|
+
const outputPath = await (0, saver_1.saveBlueprint)(client, config.ensemble, filePath);
|
|
29
|
+
log(`Saved blueprint to ${outputPath}`);
|
|
30
|
+
return {
|
|
31
|
+
content: [{
|
|
32
|
+
type: 'text',
|
|
33
|
+
text: `Ensemble blueprint saved to **${outputPath}**.`,
|
|
34
|
+
}],
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
catch (err) {
|
|
38
|
+
return {
|
|
39
|
+
content: [{ type: 'text', text: `Failed to save ensemble: ${err}` }],
|
|
40
|
+
isError: true,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-tempo",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "MCP server for multi-session Claude Code coordination via Temporal",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -53,6 +53,7 @@
|
|
|
53
53
|
"@temporalio/client": "~1.15.0",
|
|
54
54
|
"@temporalio/worker": "~1.15.0",
|
|
55
55
|
"@temporalio/workflow": "~1.15.0",
|
|
56
|
+
"yaml": "^2.8.3",
|
|
56
57
|
"zod": "^3.25.76"
|
|
57
58
|
},
|
|
58
59
|
"devDependencies": {
|