claude-tempo 0.4.1 → 0.6.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 +43 -3
- package/dist/activities/schedule-fire.d.ts +21 -0
- package/dist/activities/schedule-fire.js +93 -0
- package/dist/cli/commands.d.ts +2 -0
- package/dist/cli/commands.js +143 -52
- package/dist/cli/mcp.js +1 -1
- package/dist/cli.js +10 -0
- package/dist/config.d.ts +2 -0
- package/dist/config.js +12 -4
- package/dist/copilot-bridge.js +10 -5
- package/dist/server.js +20 -5
- package/dist/tools/ensemble.js +11 -5
- package/dist/tools/recruit.js +42 -17
- package/dist/tools/resolve.d.ts +6 -0
- package/dist/tools/resolve.js +6 -14
- package/dist/tools/schedule.d.ts +4 -0
- package/dist/tools/schedule.js +148 -0
- package/dist/tools/schedules.d.ts +4 -0
- package/dist/tools/schedules.js +66 -0
- package/dist/tools/terminate.js +10 -6
- package/dist/tools/unschedule.d.ts +4 -0
- package/dist/tools/unschedule.js +30 -0
- package/dist/types.d.ts +24 -0
- package/dist/worker.js +9 -1
- package/dist/workflows/index.d.ts +2 -0
- package/dist/workflows/index.js +8 -0
- package/dist/workflows/scheduler-signals.d.ts +6 -0
- package/dist/workflows/scheduler-signals.js +10 -0
- package/dist/workflows/scheduler.d.ts +7 -0
- package/dist/workflows/scheduler.js +99 -0
- package/dist/workflows/session.js +11 -0
- package/dist/workflows/signals.d.ts +1 -0
- package/package.json +29 -12
- package/workflow-bundle.js +4132 -682
package/README.md
CHANGED
|
@@ -94,7 +94,7 @@ claude-tempo <command> [options]
|
|
|
94
94
|
| `up [ensemble]` | First-time setup: start Temporal, configure MCP, launch conductor |
|
|
95
95
|
| `down` | Stop Temporal, terminate sessions, remove MCP config |
|
|
96
96
|
| `server` | Start the Temporal dev server and register search attributes |
|
|
97
|
-
| `conduct [ensemble]` | Start a conductor session (one per ensemble) |
|
|
97
|
+
| `conduct [ensemble]` | Start a conductor session (one per ensemble). Use `--resume` or `--replace` if one exists. |
|
|
98
98
|
| `start [ensemble]` | Start a player session |
|
|
99
99
|
| `status [ensemble]` | Show active sessions and Temporal health |
|
|
100
100
|
| `config` | Configure Temporal connection settings (interactive or `set`/`show`) |
|
|
@@ -115,6 +115,8 @@ claude-tempo <command> [options]
|
|
|
115
115
|
--skip-preflight Skip preflight checks (start/conduct)
|
|
116
116
|
-d, --dir <path> Target directory (default: cwd)
|
|
117
117
|
--background Run Temporal in background (server only)
|
|
118
|
+
--resume Resume an existing conductor session (conduct only)
|
|
119
|
+
--replace Stop existing conductor and start fresh (conduct only)
|
|
118
120
|
```
|
|
119
121
|
|
|
120
122
|
### `claude-tempo up`
|
|
@@ -173,6 +175,9 @@ Ensemble: myband
|
|
|
173
175
|
bob
|
|
174
176
|
Working on the dashboard
|
|
175
177
|
/Users/me/projects/app feat/ui my-machine.local
|
|
178
|
+
|
|
179
|
+
1 active schedule
|
|
180
|
+
deploy-watch → ops | every 1h | next: 3:00:00 PM
|
|
176
181
|
```
|
|
177
182
|
|
|
178
183
|
### `claude-tempo preflight`
|
|
@@ -201,9 +206,43 @@ These tools are available inside Claude Code sessions connected to claude-tempo:
|
|
|
201
206
|
| `set_name` | Set a human-readable name for this session. |
|
|
202
207
|
| `set_part` | Describe what you're working on. Visible to others via `ensemble`. |
|
|
203
208
|
| `listen` | Manually check for pending messages. |
|
|
204
|
-
| `recruit` | Spawn a new Claude Code session in a directory.
|
|
209
|
+
| `recruit` | Spawn a new Claude Code session in a directory. Can recruit a conductor with `conductor: true`. |
|
|
205
210
|
| `report` | Send updates to the conductor. No-op if no conductor exists. |
|
|
206
211
|
| `terminate` | Terminate a player session by name. |
|
|
212
|
+
| `schedule` | Create a one-shot or recurring schedule to cue a player. |
|
|
213
|
+
| `unschedule` | Cancel a named schedule. |
|
|
214
|
+
| `schedules` | List all active schedules. |
|
|
215
|
+
|
|
216
|
+
## Scheduling
|
|
217
|
+
|
|
218
|
+
Players can set up schedules to send messages on timers — useful for periodic checks, reminders, and recurring coordination.
|
|
219
|
+
|
|
220
|
+
Three tools are available:
|
|
221
|
+
- **`schedule`** — Create a named schedule (one-shot or recurring)
|
|
222
|
+
- **`unschedule`** — Remove a schedule by name
|
|
223
|
+
- **`schedules`** — List all active schedules
|
|
224
|
+
|
|
225
|
+
### Examples
|
|
226
|
+
|
|
227
|
+
Tell your session things like:
|
|
228
|
+
|
|
229
|
+
- *"Schedule a check every hour called 'deploy-watch' — cue ops to check deployment status"*
|
|
230
|
+
- *"Remind me in 30 minutes to review PR #42"*
|
|
231
|
+
- *"Every 5 minutes for the next hour, ping frontend to check their progress"*
|
|
232
|
+
- *"Set up a daily standup reminder at 9am UTC for the conductor"*
|
|
233
|
+
- *"Cancel the deploy-watch schedule"*
|
|
234
|
+
- *"Show me all active schedules"*
|
|
235
|
+
|
|
236
|
+
Schedules support one-shot delays, fixed times, and recurring intervals with optional bounds (max count or end time).
|
|
237
|
+
|
|
238
|
+
### How it works
|
|
239
|
+
|
|
240
|
+
- Scheduled messages arrive with a `[scheduled: name]` prefix so recipients can distinguish them from direct cues
|
|
241
|
+
- The `from` field is set to the schedule creator, so replies go to the right person
|
|
242
|
+
- If the target player is gone when a schedule fires, the creator is notified so they can re-recruit if needed. Falls back to notifying the conductor if the creator is also unavailable
|
|
243
|
+
- Messages include `isScheduled` metadata for dashboard integrations
|
|
244
|
+
- `claude-tempo status` shows active schedules alongside sessions
|
|
245
|
+
- A single durable scheduler workflow per ensemble manages all schedules using Temporal timers
|
|
207
246
|
|
|
208
247
|
## Conductors
|
|
209
248
|
|
|
@@ -274,11 +313,12 @@ Inside a session, try:
|
|
|
274
313
|
|
|
275
314
|
Sessions start with a random 8-character hex ID. Set a name at launch with `-n` or use `set_name` inside a session.
|
|
276
315
|
|
|
277
|
-
- Names are stored
|
|
316
|
+
- Names are stored in workflow metadata and discoverable via metadata queries. Search attributes are also set for Temporal UI visibility.
|
|
278
317
|
- Other players use names to send messages via `cue`
|
|
279
318
|
- `recruit` automatically tells new sessions to set their name
|
|
280
319
|
- Names must be unique within an ensemble
|
|
281
320
|
- Names must contain only letters, numbers, hyphens, and underscores
|
|
321
|
+
- The name "conductor" is reserved for conductor sessions
|
|
282
322
|
|
|
283
323
|
### Terminal support
|
|
284
324
|
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { Client } from '@temporalio/client';
|
|
2
|
+
export interface FireScheduleInput {
|
|
3
|
+
ensemble: string;
|
|
4
|
+
scheduleName: string;
|
|
5
|
+
message: string;
|
|
6
|
+
target: string;
|
|
7
|
+
createdBy: string;
|
|
8
|
+
}
|
|
9
|
+
export interface FireScheduleResult {
|
|
10
|
+
success: boolean;
|
|
11
|
+
error?: string;
|
|
12
|
+
}
|
|
13
|
+
/** Activity interface — used by proxyActivities in the scheduler workflow. */
|
|
14
|
+
export interface ScheduleActivities {
|
|
15
|
+
fireSchedule(input: FireScheduleInput): Promise<FireScheduleResult>;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Create the schedule-fire activity bound to a Temporal client.
|
|
19
|
+
* The returned object is registered with the worker as activities.
|
|
20
|
+
*/
|
|
21
|
+
export declare function createScheduleActivities(client: Client): ScheduleActivities;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createScheduleActivities = createScheduleActivities;
|
|
4
|
+
/**
|
|
5
|
+
* Create the schedule-fire activity bound to a Temporal client.
|
|
6
|
+
* The returned object is registered with the worker as activities.
|
|
7
|
+
*/
|
|
8
|
+
function createScheduleActivities(client) {
|
|
9
|
+
return {
|
|
10
|
+
async fireSchedule(input) {
|
|
11
|
+
const { ensemble, scheduleName, message, target, createdBy } = input;
|
|
12
|
+
try {
|
|
13
|
+
// Resolve target player by querying running session workflows
|
|
14
|
+
const handle = await resolveSession(client, ensemble, target);
|
|
15
|
+
if (!handle) {
|
|
16
|
+
// Notify the creator (or conductor as fallback) about the failure
|
|
17
|
+
await notifyFailure(client, ensemble, createdBy, scheduleName, target, `Player "${target}" not found — session may have been terminated.`);
|
|
18
|
+
return { success: false, error: `No active session found for "${target}"` };
|
|
19
|
+
}
|
|
20
|
+
// Send cue signal with from set to the original creator's name
|
|
21
|
+
const text = `[scheduled: ${scheduleName}] ${message}`;
|
|
22
|
+
await handle.signal('receiveMessage', {
|
|
23
|
+
from: createdBy,
|
|
24
|
+
text,
|
|
25
|
+
isScheduled: true,
|
|
26
|
+
scheduleName,
|
|
27
|
+
});
|
|
28
|
+
return { success: true };
|
|
29
|
+
}
|
|
30
|
+
catch (err) {
|
|
31
|
+
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
32
|
+
return { success: false, error: errorMsg };
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Notify the schedule creator (or conductor as fallback) that delivery failed.
|
|
39
|
+
* This lets the creator's AI session decide whether to re-recruit the target.
|
|
40
|
+
*/
|
|
41
|
+
async function notifyFailure(client, ensemble, createdBy, scheduleName, target, reason) {
|
|
42
|
+
const failureText = `[scheduled: ${scheduleName}] Delivery to "${target}" failed — ${reason}`;
|
|
43
|
+
// Try the creator first
|
|
44
|
+
const creatorHandle = await resolveSession(client, ensemble, createdBy);
|
|
45
|
+
if (creatorHandle) {
|
|
46
|
+
try {
|
|
47
|
+
await creatorHandle.signal('receiveMessage', {
|
|
48
|
+
from: 'scheduler',
|
|
49
|
+
text: failureText,
|
|
50
|
+
isScheduled: true,
|
|
51
|
+
scheduleName,
|
|
52
|
+
});
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
// creator signal failed, fall through to conductor
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
// Fallback: notify the conductor
|
|
60
|
+
try {
|
|
61
|
+
const conductorId = `claude-session-${ensemble}-conductor`;
|
|
62
|
+
const conductorHandle = client.workflow.getHandle(conductorId);
|
|
63
|
+
await conductorHandle.signal('receiveMessage', {
|
|
64
|
+
from: 'scheduler',
|
|
65
|
+
text: failureText,
|
|
66
|
+
isScheduled: true,
|
|
67
|
+
scheduleName,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
// Nobody available to notify — logged by the workflow
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Resolve a session by player name — mirrors src/tools/resolve.ts logic.
|
|
76
|
+
* We duplicate here because activities run in Node.js and need their own copy.
|
|
77
|
+
*/
|
|
78
|
+
async function resolveSession(client, ensemble, playerName) {
|
|
79
|
+
const query = `WorkflowType = "claudeSessionWorkflow" AND ExecutionStatus = "Running"`;
|
|
80
|
+
for await (const wf of client.workflow.list({ query })) {
|
|
81
|
+
try {
|
|
82
|
+
const handle = client.workflow.getHandle(wf.workflowId);
|
|
83
|
+
const metadata = await handle.query('getMetadata');
|
|
84
|
+
if (metadata.ensemble === ensemble && metadata.playerId === playerName) {
|
|
85
|
+
return handle;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
// Workflow may have just completed — skip
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return null;
|
|
93
|
+
}
|
package/dist/cli/commands.d.ts
CHANGED
package/dist/cli/commands.js
CHANGED
|
@@ -55,6 +55,15 @@ const mcp_1 = require("./mcp");
|
|
|
55
55
|
const out = __importStar(require("./output"));
|
|
56
56
|
/** Package root is two levels up from dist/cli/ */
|
|
57
57
|
const PACKAGE_ROOT = (0, path_1.resolve)(__dirname, '..', '..');
|
|
58
|
+
function formatDurationMs(ms) {
|
|
59
|
+
if (ms >= 86_400_000)
|
|
60
|
+
return `${ms / 86_400_000}d`;
|
|
61
|
+
if (ms >= 3_600_000)
|
|
62
|
+
return `${ms / 3_600_000}h`;
|
|
63
|
+
if (ms >= 60_000)
|
|
64
|
+
return `${ms / 60_000}m`;
|
|
65
|
+
return `${ms / 1000}s`;
|
|
66
|
+
}
|
|
58
67
|
async function start(opts) {
|
|
59
68
|
const config = (0, config_1.getConfig)(opts);
|
|
60
69
|
const workDir = opts.dir || process.cwd();
|
|
@@ -76,12 +85,42 @@ async function start(opts) {
|
|
|
76
85
|
if (opts.conductor) {
|
|
77
86
|
try {
|
|
78
87
|
const connection = await (0, connection_1.createTemporalConnection)(config);
|
|
79
|
-
const client = new client_1.Client({ connection });
|
|
88
|
+
const client = new client_1.Client({ connection, namespace: config.temporalNamespace });
|
|
80
89
|
const conductorWfId = (0, config_1.conductorWorkflowId)(opts.ensemble);
|
|
81
90
|
const handle = client.workflow.getHandle(conductorWfId);
|
|
82
91
|
const desc = await handle.describe();
|
|
83
92
|
if (desc.status.name === 'RUNNING') {
|
|
84
|
-
|
|
93
|
+
if (opts.replace) {
|
|
94
|
+
out.log(`Stopping existing conductor for ensemble "${opts.ensemble}"...`);
|
|
95
|
+
try {
|
|
96
|
+
await handle.signal(signals_1.shutdownSignal);
|
|
97
|
+
// Wait briefly for graceful shutdown
|
|
98
|
+
for (let i = 0; i < 10; i++) {
|
|
99
|
+
await new Promise(r => setTimeout(r, 500));
|
|
100
|
+
const check = await handle.describe();
|
|
101
|
+
if (check.status.name !== 'RUNNING')
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
// Force cancel if signal fails
|
|
107
|
+
try {
|
|
108
|
+
await handle.cancel();
|
|
109
|
+
}
|
|
110
|
+
catch { /* already gone */ }
|
|
111
|
+
}
|
|
112
|
+
out.success('Existing conductor stopped');
|
|
113
|
+
}
|
|
114
|
+
else if (opts.resume) {
|
|
115
|
+
out.log(`Resuming conductor for ensemble "${opts.ensemble}" — reconnecting to existing workflow state.\n`);
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
out.error(`A conductor is already running for ensemble "${opts.ensemble}".`);
|
|
119
|
+
out.log(` ${out.dim('claude-tempo conduct --resume')} Reconnect a new session to the existing workflow`);
|
|
120
|
+
out.log(` ${out.dim('claude-tempo conduct --replace')} Stop the existing conductor and start fresh`);
|
|
121
|
+
await connection.close();
|
|
122
|
+
process.exit(1);
|
|
123
|
+
}
|
|
85
124
|
}
|
|
86
125
|
await connection.close();
|
|
87
126
|
}
|
|
@@ -117,25 +156,27 @@ async function start(opts) {
|
|
|
117
156
|
out.success(`Launched copilot bridge${opts.name ? ` "${opts.name}"` : ''} (pid ${pid ?? 'unknown'})`);
|
|
118
157
|
}
|
|
119
158
|
else {
|
|
159
|
+
// Default conductor name to "conductor" so the Claude Code session name matches
|
|
160
|
+
const sessionName = opts.name || (opts.conductor ? 'conductor' : undefined);
|
|
120
161
|
const claudeArgs = [
|
|
121
162
|
'--dangerously-skip-permissions',
|
|
122
163
|
'--dangerously-load-development-channels', 'server:claude-tempo',
|
|
123
164
|
];
|
|
124
|
-
if (opts.
|
|
125
|
-
|
|
165
|
+
if (opts.resume && sessionName) {
|
|
166
|
+
// Resume the previous Claude Code conversation by name
|
|
167
|
+
claudeArgs.push('--resume', sessionName);
|
|
168
|
+
}
|
|
169
|
+
else if (sessionName) {
|
|
170
|
+
claudeArgs.push('-n', sessionName);
|
|
126
171
|
}
|
|
127
172
|
const envVars = {
|
|
128
173
|
...temporalEnvVars,
|
|
129
174
|
[config_1.ENV.ENSEMBLE]: opts.ensemble,
|
|
175
|
+
[config_1.ENV.CONDUCTOR]: opts.conductor ? 'true' : '',
|
|
176
|
+
[config_1.ENV.PLAYER_NAME]: sessionName || '',
|
|
130
177
|
};
|
|
131
|
-
if (opts.conductor) {
|
|
132
|
-
envVars[config_1.ENV.CONDUCTOR] = 'true';
|
|
133
|
-
}
|
|
134
|
-
if (opts.name) {
|
|
135
|
-
envVars[config_1.ENV.PLAYER_NAME] = opts.name;
|
|
136
|
-
}
|
|
137
178
|
const { pid } = (0, spawn_1.spawnInTerminal)(claudeArgs, workDir, envVars);
|
|
138
|
-
out.success(`Launched ${role} session${
|
|
179
|
+
out.success(`Launched ${role} session${sessionName ? ` "${sessionName}"` : ''} (pid ${pid ?? 'unknown'})`);
|
|
139
180
|
}
|
|
140
181
|
out.log(` Ensemble: ${opts.ensemble}`);
|
|
141
182
|
out.log(` Directory: ${workDir}`);
|
|
@@ -157,11 +198,9 @@ async function status(opts) {
|
|
|
157
198
|
return; // unreachable, helps TS
|
|
158
199
|
}
|
|
159
200
|
const client = new client_1.Client({ connection, namespace: config.temporalNamespace });
|
|
160
|
-
//
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
query += ` AND ClaudeTempoEnsemble = "${opts.ensemble}"`;
|
|
164
|
-
}
|
|
201
|
+
// List all running session workflows, filter by ensemble using metadata queries.
|
|
202
|
+
// This avoids depending on custom search attributes which are eventually consistent.
|
|
203
|
+
const query = 'WorkflowType = "claudeSessionWorkflow" AND ExecutionStatus = "Running"';
|
|
165
204
|
const sessions = [];
|
|
166
205
|
for await (const wf of client.workflow.list({ query })) {
|
|
167
206
|
try {
|
|
@@ -171,11 +210,15 @@ async function status(opts) {
|
|
|
171
210
|
handle.query('getPart').catch(() => ''),
|
|
172
211
|
]);
|
|
173
212
|
const meta = metadata;
|
|
213
|
+
const ensemble = meta.ensemble || '?';
|
|
214
|
+
// Filter by ensemble if specified
|
|
215
|
+
if (opts.ensemble && ensemble !== opts.ensemble)
|
|
216
|
+
continue;
|
|
174
217
|
sessions.push({
|
|
175
218
|
id: wf.workflowId,
|
|
176
219
|
name: meta.playerId || wf.workflowId.split('-').pop() || '?',
|
|
177
220
|
part: part || '',
|
|
178
|
-
ensemble
|
|
221
|
+
ensemble,
|
|
179
222
|
workDir: meta.workDir || '?',
|
|
180
223
|
branch: meta.gitBranch || '',
|
|
181
224
|
host: meta.hostname || '',
|
|
@@ -187,8 +230,27 @@ async function status(opts) {
|
|
|
187
230
|
// workflow may have closed between list and query
|
|
188
231
|
}
|
|
189
232
|
}
|
|
233
|
+
// Query scheduler workflows for active schedules
|
|
234
|
+
const schedulesByEnsemble = new Map();
|
|
235
|
+
const schedulerQuery = 'WorkflowType = "claudeSchedulerWorkflow" AND ExecutionStatus = "Running"';
|
|
236
|
+
for await (const wf of client.workflow.list({ query: schedulerQuery })) {
|
|
237
|
+
try {
|
|
238
|
+
const handle = client.workflow.getHandle(wf.workflowId);
|
|
239
|
+
const entries = await handle.query('getSchedules');
|
|
240
|
+
if (entries.length > 0) {
|
|
241
|
+
// Extract ensemble from workflow ID: claude-scheduler-{ensemble}
|
|
242
|
+
const ensemble = wf.workflowId.replace('claude-scheduler-', '');
|
|
243
|
+
if (opts.ensemble && ensemble !== opts.ensemble)
|
|
244
|
+
continue;
|
|
245
|
+
schedulesByEnsemble.set(ensemble, entries);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
// scheduler may have just completed
|
|
250
|
+
}
|
|
251
|
+
}
|
|
190
252
|
await connection.close();
|
|
191
|
-
if (sessions.length === 0) {
|
|
253
|
+
if (sessions.length === 0 && schedulesByEnsemble.size === 0) {
|
|
192
254
|
out.log(opts.ensemble
|
|
193
255
|
? `No active sessions in ensemble "${opts.ensemble}".`
|
|
194
256
|
: 'No active sessions found.');
|
|
@@ -222,6 +284,23 @@ async function status(opts) {
|
|
|
222
284
|
if (details)
|
|
223
285
|
out.log(` ${out.dim(details)}`);
|
|
224
286
|
}
|
|
287
|
+
// Show schedules for this ensemble
|
|
288
|
+
const ensembleSchedules = schedulesByEnsemble.get(ensemble);
|
|
289
|
+
if (ensembleSchedules && ensembleSchedules.length > 0) {
|
|
290
|
+
console.log();
|
|
291
|
+
out.log(` ${out.dim(`${ensembleSchedules.length} active schedule${ensembleSchedules.length !== 1 ? 's' : ''}`)}`);
|
|
292
|
+
for (const sched of ensembleSchedules) {
|
|
293
|
+
const recur = sched.interval
|
|
294
|
+
? `every ${formatDurationMs(sched.interval)}`
|
|
295
|
+
: 'one-shot';
|
|
296
|
+
const next = new Date(sched.nextFireAt).toLocaleTimeString();
|
|
297
|
+
const bounds = [];
|
|
298
|
+
if (sched.remainingCount != null)
|
|
299
|
+
bounds.push(`${sched.firedCount}/${sched.firedCount + sched.remainingCount} fired`);
|
|
300
|
+
const boundsStr = bounds.length ? ` (${bounds.join(', ')})` : '';
|
|
301
|
+
out.log(` ${out.bold(sched.name)} → ${sched.target} | ${recur}${boundsStr} | next: ${next}`);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
225
304
|
}
|
|
226
305
|
console.log();
|
|
227
306
|
}
|
|
@@ -494,16 +573,18 @@ async function up(opts) {
|
|
|
494
573
|
}));
|
|
495
574
|
}
|
|
496
575
|
else {
|
|
576
|
+
// Default conductor name so the Claude Code session name matches the ensemble role
|
|
577
|
+
const sessionName = opts.name || 'conductor';
|
|
497
578
|
const claudeArgs = [
|
|
498
579
|
'--dangerously-skip-permissions',
|
|
499
580
|
'--dangerously-load-development-channels', 'server:claude-tempo',
|
|
581
|
+
'-n', sessionName,
|
|
500
582
|
];
|
|
501
|
-
if (opts.name)
|
|
502
|
-
claudeArgs.push('-n', opts.name);
|
|
503
583
|
({ pid } = (0, spawn_1.spawnInTerminal)(claudeArgs, process.cwd(), {
|
|
504
584
|
...temporalEnvVars,
|
|
505
585
|
[config_1.ENV.ENSEMBLE]: opts.ensemble,
|
|
506
586
|
[config_1.ENV.CONDUCTOR]: 'true',
|
|
587
|
+
[config_1.ENV.PLAYER_NAME]: sessionName,
|
|
507
588
|
}));
|
|
508
589
|
}
|
|
509
590
|
console.log();
|
|
@@ -635,14 +716,22 @@ async function stop(opts) {
|
|
|
635
716
|
}
|
|
636
717
|
else {
|
|
637
718
|
// Stop multiple sessions (--ensemble or --all)
|
|
638
|
-
|
|
639
|
-
if (opts.ensemble) {
|
|
640
|
-
query += ` AND ClaudeTempoEnsemble = "${opts.ensemble}"`;
|
|
641
|
-
}
|
|
719
|
+
const query = 'WorkflowType = "claudeSessionWorkflow" AND ExecutionStatus = "Running"';
|
|
642
720
|
let stopped = 0;
|
|
643
721
|
for await (const wf of client.workflow.list({ query })) {
|
|
644
722
|
try {
|
|
645
723
|
const handle = client.workflow.getHandle(wf.workflowId);
|
|
724
|
+
// Filter by ensemble using metadata if specified
|
|
725
|
+
if (opts.ensemble) {
|
|
726
|
+
try {
|
|
727
|
+
const meta = (await handle.query('getMetadata'));
|
|
728
|
+
if (meta.ensemble !== opts.ensemble)
|
|
729
|
+
continue;
|
|
730
|
+
}
|
|
731
|
+
catch {
|
|
732
|
+
continue;
|
|
733
|
+
}
|
|
734
|
+
}
|
|
646
735
|
await handle.signal(signals_1.shutdownSignal);
|
|
647
736
|
stopped++;
|
|
648
737
|
out.log(` ${out.dim('stopped')} ${wf.workflowId}`);
|
|
@@ -667,39 +756,41 @@ async function stop(opts) {
|
|
|
667
756
|
await connection.close();
|
|
668
757
|
}
|
|
669
758
|
async function stopByName(client, name, config, ensemble) {
|
|
670
|
-
// Find the workflow by player name
|
|
671
|
-
|
|
672
|
-
if (ensemble) {
|
|
673
|
-
query += ` AND ClaudeTempoEnsemble = "${ensemble}"`;
|
|
674
|
-
}
|
|
759
|
+
// Find the workflow by player name using metadata queries (not search attributes).
|
|
760
|
+
const query = `WorkflowType = "claudeSessionWorkflow" AND ExecutionStatus = "Running"`;
|
|
675
761
|
let found = false;
|
|
676
762
|
for await (const wf of client.workflow.list({ query })) {
|
|
677
|
-
found = true;
|
|
678
763
|
const handle = client.workflow.getHandle(wf.workflowId);
|
|
679
|
-
// Check
|
|
764
|
+
// Check metadata to match by name and ensemble
|
|
765
|
+
let metadata;
|
|
680
766
|
try {
|
|
681
|
-
|
|
682
|
-
if (metadata.
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
if (!metadata.isConductor && metadata.ensemble) {
|
|
687
|
-
try {
|
|
688
|
-
const conductorWfId = (0, config_1.conductorWorkflowId)(metadata.ensemble);
|
|
689
|
-
const conductorHandle = client.workflow.getHandle(conductorWfId);
|
|
690
|
-
await conductorHandle.signal(signals_1.playerReportSignal, {
|
|
691
|
-
playerId: name,
|
|
692
|
-
text: 'Session stopped by CLI',
|
|
693
|
-
type: 'result',
|
|
694
|
-
});
|
|
695
|
-
}
|
|
696
|
-
catch {
|
|
697
|
-
// No conductor or conductor not running — fine
|
|
698
|
-
}
|
|
699
|
-
}
|
|
767
|
+
metadata = (await handle.query('getMetadata'));
|
|
768
|
+
if (metadata.playerId !== name)
|
|
769
|
+
continue;
|
|
770
|
+
if (ensemble && metadata.ensemble !== ensemble)
|
|
771
|
+
continue;
|
|
700
772
|
}
|
|
701
773
|
catch {
|
|
702
|
-
|
|
774
|
+
continue;
|
|
775
|
+
}
|
|
776
|
+
found = true;
|
|
777
|
+
if (metadata.isConductor) {
|
|
778
|
+
out.warn(`"${name}" is a conductor session`);
|
|
779
|
+
}
|
|
780
|
+
// Notify the conductor that this session was stopped (if it's not the conductor itself)
|
|
781
|
+
if (!metadata.isConductor && metadata.ensemble) {
|
|
782
|
+
try {
|
|
783
|
+
const conductorWfId = (0, config_1.conductorWorkflowId)(metadata.ensemble);
|
|
784
|
+
const conductorHandle = client.workflow.getHandle(conductorWfId);
|
|
785
|
+
await conductorHandle.signal(signals_1.playerReportSignal, {
|
|
786
|
+
playerId: name,
|
|
787
|
+
text: 'Session stopped by CLI',
|
|
788
|
+
type: 'result',
|
|
789
|
+
});
|
|
790
|
+
}
|
|
791
|
+
catch {
|
|
792
|
+
// No conductor or conductor not running — fine
|
|
793
|
+
}
|
|
703
794
|
}
|
|
704
795
|
// Send shutdown signal (graceful)
|
|
705
796
|
try {
|
|
@@ -790,7 +881,7 @@ ${out.bold('Commands:')}
|
|
|
790
881
|
${out.cyan('up')} [ensemble] First-time setup: start Temporal, configure MCP, launch conductor
|
|
791
882
|
${out.cyan('down')} Stop Temporal, terminate sessions, remove MCP config
|
|
792
883
|
${out.cyan('server')} Start the Temporal dev server and register search attributes
|
|
793
|
-
${out.cyan('conduct')} [ensemble] Start a conductor session (
|
|
884
|
+
${out.cyan('conduct')} [ensemble] Start a conductor session (resumes existing, --replace to restart)
|
|
794
885
|
${out.cyan('start')} [ensemble] Start a player session
|
|
795
886
|
${out.cyan('stop')} [ensemble] Stop sessions (-n <name> for one, or --all)
|
|
796
887
|
${out.cyan('status')} [ensemble] Show active sessions and Temporal health
|
package/dist/cli/mcp.js
CHANGED
package/dist/cli.js
CHANGED
|
@@ -49,6 +49,8 @@ function parseArgs(argv) {
|
|
|
49
49
|
keepMcp: false,
|
|
50
50
|
all: false,
|
|
51
51
|
project: false,
|
|
52
|
+
replace: false,
|
|
53
|
+
resume: false,
|
|
52
54
|
};
|
|
53
55
|
let i = 0;
|
|
54
56
|
while (i < argv.length) {
|
|
@@ -89,6 +91,12 @@ function parseArgs(argv) {
|
|
|
89
91
|
else if (arg === '--project') {
|
|
90
92
|
result.project = true;
|
|
91
93
|
}
|
|
94
|
+
else if (arg === '--replace') {
|
|
95
|
+
result.replace = true;
|
|
96
|
+
}
|
|
97
|
+
else if (arg === '--resume') {
|
|
98
|
+
result.resume = true;
|
|
99
|
+
}
|
|
92
100
|
else if (arg === '--ensemble' && i + 1 < argv.length) {
|
|
93
101
|
result.ensemble = argv[++i];
|
|
94
102
|
}
|
|
@@ -142,6 +150,8 @@ async function main() {
|
|
|
142
150
|
await (0, commands_1.start)({
|
|
143
151
|
ensemble,
|
|
144
152
|
conductor: true,
|
|
153
|
+
replace: args.replace,
|
|
154
|
+
resume: args.resume,
|
|
145
155
|
name: args.name,
|
|
146
156
|
skipPreflight: args.skipPreflight,
|
|
147
157
|
agent: resolvedAgent(),
|
package/dist/config.d.ts
CHANGED
|
@@ -89,3 +89,5 @@ export declare function getConfigWithSources(overrides?: CliOverrides): ConfigWi
|
|
|
89
89
|
export declare function sessionWorkflowId(ensemble: string, playerId: string): string;
|
|
90
90
|
/** Build a workflow ID for a conductor: claude-session-{ensemble}-conductor */
|
|
91
91
|
export declare function conductorWorkflowId(ensemble: string): string;
|
|
92
|
+
/** Build a workflow ID for the scheduler: claude-scheduler-{ensemble} */
|
|
93
|
+
export declare function schedulerWorkflowId(ensemble: string): string;
|
package/dist/config.js
CHANGED
|
@@ -9,9 +9,14 @@ exports.getConfig = getConfig;
|
|
|
9
9
|
exports.getConfigWithSources = getConfigWithSources;
|
|
10
10
|
exports.sessionWorkflowId = sessionWorkflowId;
|
|
11
11
|
exports.conductorWorkflowId = conductorWorkflowId;
|
|
12
|
+
exports.schedulerWorkflowId = schedulerWorkflowId;
|
|
12
13
|
const fs_1 = require("fs");
|
|
13
14
|
const path_1 = require("path");
|
|
14
15
|
const os_1 = require("os");
|
|
16
|
+
const VALID_AGENTS = ['claude', 'copilot'];
|
|
17
|
+
function validAgent(value) {
|
|
18
|
+
return VALID_AGENTS.includes(value) ? value : 'claude';
|
|
19
|
+
}
|
|
15
20
|
/** Environment variable name constants — use these instead of string literals. */
|
|
16
21
|
exports.ENV = {
|
|
17
22
|
ENSEMBLE: 'CLAUDE_TEMPO_ENSEMBLE',
|
|
@@ -180,10 +185,9 @@ function getConfig(overrides = {}) {
|
|
|
180
185
|
temporalApiKey: resolveOpt(overrides.temporalApiKey, exports.ENV.TEMPORAL_API_KEY, configFile.temporalApiKey, temporalCli.temporalApiKey),
|
|
181
186
|
temporalTlsCertPath: resolveOpt(overrides.temporalTlsCertPath, exports.ENV.TEMPORAL_TLS_CERT_PATH, configFile.temporalTlsCertPath, temporalCli.temporalTlsCertPath),
|
|
182
187
|
temporalTlsKeyPath: resolveOpt(overrides.temporalTlsKeyPath, exports.ENV.TEMPORAL_TLS_KEY_PATH, configFile.temporalTlsKeyPath, temporalCli.temporalTlsKeyPath),
|
|
183
|
-
defaultAgent: (overrides.defaultAgent
|
|
188
|
+
defaultAgent: validAgent(overrides.defaultAgent
|
|
184
189
|
|| process.env[exports.ENV.DEFAULT_AGENT]
|
|
185
|
-
|| configFile.defaultAgent
|
|
186
|
-
|| 'claude'),
|
|
190
|
+
|| configFile.defaultAgent),
|
|
187
191
|
taskQueue: process.env[exports.ENV.TASK_QUEUE] ?? 'claude-tempo',
|
|
188
192
|
ensemble: process.env[exports.ENV.ENSEMBLE] ?? 'default',
|
|
189
193
|
};
|
|
@@ -221,7 +225,7 @@ function getConfigWithSources(overrides = {}) {
|
|
|
221
225
|
temporalApiKey: apiKey.value,
|
|
222
226
|
temporalTlsCertPath: tlsCert.value,
|
|
223
227
|
temporalTlsKeyPath: tlsKey.value,
|
|
224
|
-
defaultAgent: (defaultAgent.value
|
|
228
|
+
defaultAgent: validAgent(defaultAgent.value),
|
|
225
229
|
taskQueue: process.env[exports.ENV.TASK_QUEUE] ?? 'claude-tempo',
|
|
226
230
|
ensemble: process.env[exports.ENV.ENSEMBLE] ?? 'default',
|
|
227
231
|
},
|
|
@@ -243,3 +247,7 @@ function sessionWorkflowId(ensemble, playerId) {
|
|
|
243
247
|
function conductorWorkflowId(ensemble) {
|
|
244
248
|
return `claude-session-${ensemble}-conductor`;
|
|
245
249
|
}
|
|
250
|
+
/** Build a workflow ID for the scheduler: claude-scheduler-{ensemble} */
|
|
251
|
+
function schedulerWorkflowId(ensemble) {
|
|
252
|
+
return `claude-scheduler-${ensemble}`;
|
|
253
|
+
}
|
package/dist/copilot-bridge.js
CHANGED
|
@@ -116,10 +116,11 @@ async function main() {
|
|
|
116
116
|
// `claude-session-{ensemble}-{playerId}`, where playerId comes from
|
|
117
117
|
// CLAUDE_TEMPO_PLAYER_NAME or a random hex. We pass CLAUDE_TEMPO_PLAYER_NAME
|
|
118
118
|
// to the MCP server env so both sides agree on the ID.
|
|
119
|
-
const isConductor =
|
|
119
|
+
const isConductor = process.env[config_1.ENV.CONDUCTOR] === 'true';
|
|
120
|
+
const requestedName = process.env[config_1.ENV.PLAYER_NAME] || playerName || '';
|
|
120
121
|
const playerIdForWorkflow = isConductor
|
|
121
122
|
? 'conductor'
|
|
122
|
-
: (
|
|
123
|
+
: (requestedName && requestedName !== 'conductor' ? requestedName : '') || `copilot-${Date.now()}`;
|
|
123
124
|
const expectedWorkflowId = `claude-session-${config.ensemble}-${playerIdForWorkflow}`;
|
|
124
125
|
// Build the MCP server command — always use the compiled dist/server.js
|
|
125
126
|
// Run `npm run build` (or `pnpm build`) before using the bridge.
|
|
@@ -137,7 +138,7 @@ async function main() {
|
|
|
137
138
|
[config_1.ENV.TEMPORAL_ADDRESS]: config.temporalAddress,
|
|
138
139
|
[config_1.ENV.TEMPORAL_NAMESPACE]: config.temporalNamespace,
|
|
139
140
|
[config_1.ENV.TASK_QUEUE]: config.taskQueue,
|
|
140
|
-
[config_1.ENV.CONDUCTOR]:
|
|
141
|
+
[config_1.ENV.CONDUCTOR]: isConductor ? 'true' : '',
|
|
141
142
|
[config_1.ENV.BRIDGE_MODE]: '1', // disable MCP server's message poller — bridge handles delivery
|
|
142
143
|
[config_1.ENV.PLAYER_NAME]: playerIdForWorkflow, // ensures MCP server uses same workflow ID
|
|
143
144
|
...(config.temporalApiKey ? { [config_1.ENV.TEMPORAL_API_KEY]: config.temporalApiKey } : {}),
|
|
@@ -286,6 +287,7 @@ async function main() {
|
|
|
286
287
|
await session.sendAndWait({ prompt: `Call set_name("${playerName}") immediately. Respond in one short sentence.` }, 120_000);
|
|
287
288
|
log(`set_name completed in ${Date.now() - t0}ms`);
|
|
288
289
|
}
|
|
290
|
+
const MAESTRO_ACK = '\n\n[IMPORTANT: This message is from a human (Maestro). Immediately cue the sender back with a brief acknowledgment and your planned next step before doing the work.]';
|
|
289
291
|
// Start message poller — inject messages into the Copilot session.
|
|
290
292
|
// Tracks consecutive failures and attempts session recreation before giving up.
|
|
291
293
|
let polling = true;
|
|
@@ -331,9 +333,12 @@ async function main() {
|
|
|
331
333
|
processing = true;
|
|
332
334
|
const ids = messages.map((m) => m.id);
|
|
333
335
|
await handle.signal('markDelivered', ids);
|
|
334
|
-
// Format messages into a single prompt
|
|
336
|
+
// Format messages into a single prompt, appending ack instruction for Maestro messages
|
|
335
337
|
const prompt = messages
|
|
336
|
-
.map((m) =>
|
|
338
|
+
.map((m) => {
|
|
339
|
+
const line = `[Message from ${m.from}]: ${m.text}`;
|
|
340
|
+
return m.isMaestro ? line + MAESTRO_ACK : line;
|
|
341
|
+
})
|
|
337
342
|
.join('\n\n');
|
|
338
343
|
log(`Injecting ${messages.length} message(s) into Copilot session`);
|
|
339
344
|
log(`Prompt: ${prompt.substring(0, 300)}`);
|