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 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. Opens a new terminal window. |
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 as Temporal search attributes (`ClaudeTempoPlayerId`)
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
+ }
@@ -3,6 +3,8 @@ import { AgentType } from '../types';
3
3
  interface StartOpts extends CliOverrides {
4
4
  ensemble: string;
5
5
  conductor: boolean;
6
+ replace?: boolean;
7
+ resume?: boolean;
6
8
  name?: string;
7
9
  skipPreflight?: boolean;
8
10
  agent: AgentType;
@@ -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
- out.warn(`A conductor workflow already exists for ensemble "${opts.ensemble}". Reconnecting...`);
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.name) {
125
- claudeArgs.push('-n', opts.name);
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${opts.name ? ` "${opts.name}"` : ''} (pid ${pid ?? 'unknown'})`);
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
- // Build query
161
- let query = 'WorkflowType = "claudeSessionWorkflow" AND ExecutionStatus = "Running"';
162
- if (opts.ensemble) {
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: meta.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
- let query = 'WorkflowType = "claudeSessionWorkflow" AND ExecutionStatus = "Running"';
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 via search attribute
671
- let query = `WorkflowType = "claudeSessionWorkflow" AND ExecutionStatus = "Running" AND ClaudeTempoPlayerId = "${name}"`;
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 if this is a conductor warn about it
764
+ // Check metadata to match by name and ensemble
765
+ let metadata;
680
766
  try {
681
- const metadata = (await handle.query('getMetadata'));
682
- if (metadata.isConductor) {
683
- out.warn(`"${name}" is a conductor session`);
684
- }
685
- // Notify the conductor that this session was stopped (if it's not the conductor itself)
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
- // Query failed — proceed with shutdown anyway
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 (one per ensemble)
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
@@ -14,7 +14,7 @@ function isGlobalMcpRegistered() {
14
14
  encoding: 'utf8',
15
15
  stdio: ['ignore', 'pipe', 'ignore'],
16
16
  });
17
- return output.includes('claude-tempo');
17
+ return /\bclaude-tempo\b/.test(output);
18
18
  }
19
19
  catch {
20
20
  return false;
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 || 'claude'),
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
+ }
@@ -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 = !!process.env[config_1.ENV.CONDUCTOR];
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
- : (process.env[config_1.ENV.PLAYER_NAME] || playerName || `copilot-${Date.now()}`);
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]: process.env[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) => `[Message from ${m.from}]: ${m.text}`)
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)}`);