claude-tempo 0.11.0 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLAUDE.md CHANGED
@@ -34,7 +34,7 @@ src/
34
34
  │ ├── scheduler-signals.ts # Scheduler signal/query type definitions
35
35
  │ └── signals.ts # Session signal/query type definitions
36
36
  ├── activities/
37
- │ ├── outbox.ts # Outbox delivery activities (cue, report, stop, recruit)
37
+ │ ├── outbox.ts # Outbox delivery activities (cue, report, stop, recruit, encore)
38
38
  │ └── schedule-fire.ts # Schedule fire activity
39
39
  ├── ensemble/
40
40
  │ ├── schema.ts # Lineup type definitions
@@ -53,12 +53,17 @@ src/
53
53
  │ ├── recruit.ts # Spawn new session (via outbox), supports `type` param
54
54
  │ ├── report.ts # Report to conductor (via outbox)
55
55
  │ ├── stop.ts # Stop a session (via outbox)
56
+ │ ├── broadcast.ts # Send message to all active players (via outbox fan-out)
57
+ │ ├── encore.ts # Revive a stale session (via outbox)
58
+ │ ├── recall.ts # Read own message history (received + sent)
56
59
  │ ├── load-lineup.ts # Load an ensemble lineup, recruit players
57
60
  │ ├── save-lineup.ts # Save current ensemble state as a lineup
58
61
  │ ├── schedule.ts # Create one-shot or recurring schedules
59
62
  │ ├── unschedule.ts # Cancel a named schedule
60
63
  │ ├── schedules.ts # List active schedules
61
64
  │ └── helpers.ts # Zod/MCP tool registration wrapper
65
+ ├── utils/
66
+ │ └── validation.ts # Shared validation constants (name/message/path limits, encore defaults) and helpers
62
67
  ├── types.ts # Shared type definitions
63
68
  ├── channel.ts # Claude channel notification helper
64
69
  ├── git-info.ts # Git repository detection helper
@@ -101,7 +106,10 @@ npm test
101
106
  - **Recruit**: Spawning a new Claude Code session as a player. The workflow is pre-created with the initial message before the process spawns, ensuring reliable delivery.
102
107
  - **set_name**: Players start with a random hex ID; `set_name` updates the `ClaudeTempoPlayerId` search attribute to a human-readable name
103
108
  - **Session status**: Each session has a status (`pending` → `active` → `stale`) tracked via `ClaudeTempoStatus` search attribute. Pre-created workflows start as `pending`, transition to `active` when the process connects, and become `stale` if messages go undelivered for 3+ minutes.
104
- - **Outbox**: Outbound requests (cue, report, stop, recruit) go through the session's own workflow outbox instead of directly signaling other workflows. The workflow's dispatch loop processes entries via activities, decoupling tools from cross-workflow signaling.
109
+ - **Outbox**: Outbound requests (cue, report, stop, recruit, encore) go through the session's own workflow outbox instead of directly signaling other workflows. The workflow's dispatch loop processes entries via activities, decoupling tools from cross-workflow signaling.
110
+ - **Encore**: Revives a `stale` player session by restarting the Claude process and reconnecting to the existing Temporal workflow, with recent message context restored. Cannot encore `active`, `pending`, or `terminated` sessions — use `cue`, wait, or `recruit` respectively.
111
+ - **Broadcast**: Fan-out variant of `cue` — sends a message to all active players in the ensemble in a single call. Optionally filtered by player type. Skips the sender, pending sessions, and (by default) stale sessions.
112
+ - **Recall**: Queries a session's own message history from the Temporal workflow. Shows received messages by default; pass `includeSent: true` to also see sent messages. Supports `limit`, `since`, and `from` filters.
105
113
  - **Per-host task queues**: Each host runs a `claude-tempo-{hostname}` activity worker for local-only operations (e.g., `spawnProcess`). This enables cross-machine recruiting — the `recruit` tool accepts an optional `host` parameter to route the spawn to a remote machine's task queue.
106
114
  - **Player types**: Reusable agent definitions in Claude Code's standard subagent format (`.md` files with YAML frontmatter). Ensemble lineups can reference types by name via a `type` field on players. Three-tier lookup: project `.claude/agents/` → user `~/.claude/agents/` → shipped `examples/agents/`. Players know their type via workflow metadata and the `who_am_i` tool.
107
115
  - **Agent type discovery**: The `agent_types` MCP tool and `claude-tempo agent-types` CLI command let conductors discover available player types. Shipped examples (tempo-conductor, tempo-composer, tempo-soloist, tempo-tuner, tempo-critic, tempo-roadie, tempo-improv, tempo-liner) work out of the box. Ensemble lineups: tempo-big-band (full lifecycle), tempo-dev-team (feature work), tempo-review-squad (parallel review), tempo-jam-session (exploration).
@@ -123,3 +131,14 @@ Examples:
123
131
  - `feat(tools): add ensemble discovery tool`
124
132
  - `fix(workflow): handle signal delivery edge case`
125
133
  - `docs: update getting started guide`
134
+
135
+ ## Release Process
136
+
137
+ **Correct order — never deviate:**
138
+
139
+ 1. Merge the feature PR into `main` (squash merge)
140
+ 2. Bump `version` in `package.json` and add a `## [x.y.z]` entry in `CHANGELOG.md` on `main`
141
+ 3. Commit: `chore: bump version to vX.Y.Z`
142
+ 4. Tag the bump commit: `git tag vX.Y.Z && git push origin vX.Y.Z`
143
+
144
+ The release workflow triggers on `v*` tag pushes and publishes to npm. **Never tag before the version bump commit exists on main, and never tag a commit that doesn't match the version in `package.json`.** Tagging prematurely (e.g., before a feature PR merges) publishes the old version to npm and forces a patch bump to recover.
package/README.md CHANGED
@@ -130,6 +130,9 @@ These tools are available inside Claude Code sessions connected to claude-tempo:
130
130
  | `agent_types` | List available player types with name, description, and source. |
131
131
  | `save_lineup` | Save the current ensemble as a YAML lineup (conductor only). |
132
132
  | `load_lineup` | Load a lineup to recruit players and create schedules. |
133
+ | `broadcast` | Send a message to all active players. Optional `type` filter limits to a specific player type. |
134
+ | `encore` | Revive a stale player session — restarts the process and reconnects to the existing workflow with context restored. |
135
+ | `recall` | Read your own message history. Shows received messages by default; pass `includeSent: true` for the full timeline. |
133
136
 
134
137
  ## Scheduling
135
138
 
@@ -43,6 +43,25 @@ export interface SpawnProcessInput {
43
43
  agentDefinition?: string;
44
44
  agentDefinitionPath?: string;
45
45
  nativeResolvable?: boolean;
46
+ /** When true, use --resume instead of -n (reconnect to existing session). */
47
+ resume?: boolean;
48
+ }
49
+ export interface PerformEncoreInput {
50
+ ensemble: string;
51
+ targetPlayerId: string;
52
+ fromPlayerId: string;
53
+ contextMessageCount?: number;
54
+ }
55
+ export interface EncoreResult {
56
+ workDir: string;
57
+ hostname: string;
58
+ isConductor: boolean;
59
+ agent: AgentType;
60
+ agentDefinition?: string;
61
+ agentDefinitionPath?: string;
62
+ nativeResolvable?: boolean;
63
+ temporalAddress: string;
64
+ temporalNamespace: string;
46
65
  }
47
66
  export interface OutboxActivityResult {
48
67
  success: boolean;
@@ -54,6 +73,7 @@ export interface OutboxActivities {
54
73
  terminateSession(input: TerminateSessionInput): Promise<OutboxActivityResult>;
55
74
  startRecruitedSession(input: StartRecruitedSessionInput): Promise<OutboxActivityResult>;
56
75
  spawnProcess(input: SpawnProcessInput): Promise<OutboxActivityResult>;
76
+ performEncore(input: PerformEncoreInput): Promise<EncoreResult>;
57
77
  }
58
78
  /**
59
79
  * Create outbox delivery activities bound to a Temporal client and config.
@@ -40,10 +40,12 @@ const os = __importStar(require("os"));
40
40
  const path = __importStar(require("path"));
41
41
  const crypto = __importStar(require("crypto"));
42
42
  const config_1 = require("../config");
43
+ const validation_1 = require("../utils/validation");
43
44
  const git_info_1 = require("../git-info");
44
45
  const spawn_1 = require("../spawn");
45
46
  const config_2 = require("../config");
46
47
  const resolve_1 = require("./resolve");
48
+ const agent_types_1 = require("../ensemble/agent-types");
47
49
  const log = (...args) => console.error('[claude-tempo:outbox]', ...args);
48
50
  /**
49
51
  * Create outbox delivery activities bound to a Temporal client and config.
@@ -143,7 +145,7 @@ function createOutboxActivities(client, config) {
143
145
  }
144
146
  },
145
147
  async spawnProcess(input) {
146
- const { targetName, workDir, isConductor, agent, systemPrompt, ensemble, temporalAddress, temporalNamespace, agentDefinition, agentDefinitionPath, nativeResolvable } = input;
148
+ const { targetName, workDir, isConductor, agent, systemPrompt, ensemble, temporalAddress, temporalNamespace, agentDefinition, agentDefinitionPath, nativeResolvable, resume } = input;
147
149
  // Read secrets from the worker's config closure — never from workflow state
148
150
  const { temporalApiKey, temporalTlsCertPath, temporalTlsKeyPath } = config;
149
151
  try {
@@ -173,10 +175,12 @@ function createOutboxActivities(client, config) {
173
175
  else if (systemPrompt) {
174
176
  agentFlags = ['--system-prompt', systemPrompt];
175
177
  }
178
+ // Use --resume for encore (reconnect to existing session) or -n for new sessions
179
+ const nameArgs = resume ? ['--resume', targetName] : ['-n', targetName];
176
180
  const spawnArgs = [
177
181
  '--dangerously-skip-permissions',
178
182
  '--dangerously-load-development-channels', 'server:claude-tempo',
179
- '-n', targetName,
183
+ ...nameArgs,
180
184
  ...agentFlags,
181
185
  ];
182
186
  const envVars = {
@@ -195,7 +199,7 @@ function createOutboxActivities(client, config) {
195
199
  if (temporalTlsKeyPath)
196
200
  envVars[config_2.ENV.TEMPORAL_TLS_KEY_PATH] = temporalTlsKeyPath;
197
201
  const { pid } = (0, spawn_1.spawnInTerminal)(spawnArgs, workDir, envVars);
198
- log(`Spawned claude process (pid ${pid}) in ${workDir} as "${targetName}"`);
202
+ log(`Spawned claude process (pid ${pid}) in ${workDir} as "${targetName}" (resume=${!!resume})`);
199
203
  }
200
204
  return { success: true };
201
205
  }
@@ -203,5 +207,77 @@ function createOutboxActivities(client, config) {
203
207
  throw activity_1.ApplicationFailure.nonRetryable(`Failed to spawn process for "${targetName}": ${err instanceof Error ? err.message : String(err)}`);
204
208
  }
205
209
  },
210
+ async performEncore(input) {
211
+ const { ensemble, targetPlayerId, fromPlayerId, contextMessageCount = validation_1.ENCORE_DEFAULT_CONTEXT_MESSAGES } = input;
212
+ try {
213
+ const handle = await (0, resolve_1.resolveSession)(client, ensemble, targetPlayerId);
214
+ if (!handle) {
215
+ throw activity_1.ApplicationFailure.nonRetryable(`No session found for "${targetPlayerId}"`);
216
+ }
217
+ // Atomically transition status from 'stale' to 'pending' — prevents double-spawn races
218
+ const transitioned = await handle.executeUpdate('checkAndSetStatus', {
219
+ args: [{ expectedStatus: 'stale', newStatus: 'pending' }],
220
+ });
221
+ if (!transitioned) {
222
+ // Read current status for a useful error message
223
+ const metadata = await handle.query('getMetadata');
224
+ const status = metadata.status;
225
+ throw activity_1.ApplicationFailure.nonRetryable(`Cannot encore "${targetPlayerId}" — status is "${status}" (must be "stale"). Another encore may already be in progress.`);
226
+ }
227
+ // Query context (status is now locked to 'pending', safe from races)
228
+ const metadata = await handle.query('getMetadata');
229
+ const part = await handle.query('getPart');
230
+ const allMessages = await handle.query('allMessages');
231
+ // Build context message from recent messages
232
+ const recentMessages = allMessages.slice(-contextMessageCount);
233
+ const msgSummary = recentMessages.length > 0
234
+ ? recentMessages.map(m => `[${m.from}] ${m.text.slice(0, validation_1.PREVIEW_MAX_LENGTH)}`).join('\n')
235
+ : '(no recent messages)';
236
+ const contextMessage = [
237
+ `🎵 **Encore** — you've been revived by ${fromPlayerId}.`,
238
+ part ? `Your last status: ${part}` : '',
239
+ `Recent messages (last ${recentMessages.length}):`,
240
+ msgSummary,
241
+ '',
242
+ 'Resume where you left off. Use `ensemble` to see who is active.',
243
+ ].filter(Boolean).join('\n');
244
+ // Inject context message (status already set to pending atomically above)
245
+ await handle.signal('receiveMessage', { from: fromPlayerId, text: contextMessage });
246
+ log(`Encore prepared for "${targetPlayerId}" — status reset to pending, context injected`);
247
+ // Return spawn parameters from the target's metadata
248
+ const agentType = metadata.agentType || 'claude';
249
+ const playerType = metadata.playerType;
250
+ let agentDefinitionPath;
251
+ let nativeResolvable;
252
+ if (playerType) {
253
+ try {
254
+ const info = (0, agent_types_1.resolveAgentType)(playerType);
255
+ if (info) {
256
+ agentDefinitionPath = info.path;
257
+ nativeResolvable = info.nativeResolvable;
258
+ }
259
+ }
260
+ catch {
261
+ // Agent type resolution failure is non-fatal
262
+ }
263
+ }
264
+ return {
265
+ workDir: metadata.workDir,
266
+ hostname: metadata.hostname,
267
+ isConductor: metadata.isConductor,
268
+ agent: agentType === 'copilot' ? 'copilot' : 'claude',
269
+ agentDefinition: playerType,
270
+ agentDefinitionPath,
271
+ nativeResolvable,
272
+ temporalAddress: config.temporalAddress,
273
+ temporalNamespace: config.temporalNamespace,
274
+ };
275
+ }
276
+ catch (err) {
277
+ if (err instanceof activity_1.ApplicationFailure)
278
+ throw err;
279
+ throw activity_1.ApplicationFailure.nonRetryable(`Encore failed for "${targetPlayerId}": ${err instanceof Error ? err.message : String(err)}`);
280
+ }
281
+ },
206
282
  };
207
283
  }
@@ -50,6 +50,19 @@ interface AgentTypesCommandOpts {
50
50
  name?: string;
51
51
  }
52
52
  export declare function agentTypesCommand(opts: AgentTypesCommandOpts): Promise<void>;
53
+ interface BroadcastOpts extends CliOverrides {
54
+ ensemble?: string;
55
+ message: string;
56
+ type?: string;
57
+ includeStale?: boolean;
58
+ }
59
+ export declare function broadcast(opts: BroadcastOpts): Promise<void>;
60
+ interface EncoreOpts extends CliOverrides {
61
+ name: string;
62
+ ensemble?: string;
63
+ host?: string;
64
+ }
65
+ export declare function encore(opts: EncoreOpts): Promise<void>;
53
66
  interface EnsembleCommandOpts extends CliOverrides {
54
67
  subcommand?: string;
55
68
  name?: string;
@@ -41,6 +41,8 @@ exports.up = up;
41
41
  exports.down = down;
42
42
  exports.stop = stop;
43
43
  exports.agentTypesCommand = agentTypesCommand;
44
+ exports.broadcast = broadcast;
45
+ exports.encore = encore;
44
46
  exports.ensembleCommand = ensembleCommand;
45
47
  exports.help = help;
46
48
  exports.version = version;
@@ -59,6 +61,7 @@ const mcp_1 = require("./mcp");
59
61
  const loader_1 = require("../ensemble/loader");
60
62
  const saver_1 = require("../ensemble/saver");
61
63
  const agent_types_1 = require("../ensemble/agent-types");
64
+ const validation_1 = require("../utils/validation");
62
65
  const out = __importStar(require("./output"));
63
66
  /** Package root is two levels up from dist/cli/ */
64
67
  const PACKAGE_ROOT = (0, path_1.resolve)(__dirname, '..', '..');
@@ -1227,6 +1230,178 @@ async function agentTypesCommand(opts) {
1227
1230
  process.exit(1);
1228
1231
  }
1229
1232
  }
1233
+ async function broadcast(opts) {
1234
+ const config = (0, config_1.getConfig)(opts);
1235
+ let connection;
1236
+ try {
1237
+ connection = await Promise.race([
1238
+ (0, connection_1.createTemporalConnection)(config),
1239
+ new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 3000)),
1240
+ ]);
1241
+ }
1242
+ catch {
1243
+ out.error(`Cannot connect to Temporal at ${config.temporalAddress}`);
1244
+ process.exit(1);
1245
+ return;
1246
+ }
1247
+ const client = new client_1.Client({ connection, namespace: config.temporalNamespace });
1248
+ const ensemble = opts.ensemble || config.ensemble;
1249
+ const query = `WorkflowType = "claudeSessionWorkflow" AND ExecutionStatus = "Running"`;
1250
+ const targets = [];
1251
+ for await (const wf of client.workflow.list({ query })) {
1252
+ try {
1253
+ const handle = client.workflow.getHandle(wf.workflowId);
1254
+ const metadata = await handle.query('getMetadata');
1255
+ if (metadata.ensemble !== ensemble)
1256
+ continue;
1257
+ // Filter by status
1258
+ if (!(0, validation_1.shouldIncludeInBroadcast)(metadata.status, !!opts.includeStale))
1259
+ continue;
1260
+ // Filter by player type if specified
1261
+ if (opts.type && metadata.playerType !== opts.type)
1262
+ continue;
1263
+ targets.push({ playerId: metadata.playerId, workflowId: wf.workflowId });
1264
+ }
1265
+ catch {
1266
+ // Workflow may have just completed — skip it
1267
+ }
1268
+ }
1269
+ if (targets.length === 0) {
1270
+ out.warn('No active players matched the broadcast filter.');
1271
+ await connection.close();
1272
+ return;
1273
+ }
1274
+ // Signal each target directly (CLI bypasses outbox)
1275
+ let sent = 0;
1276
+ for (const target of targets) {
1277
+ try {
1278
+ const handle = client.workflow.getHandle(target.workflowId);
1279
+ await handle.signal('receiveMessage', {
1280
+ from: 'cli',
1281
+ text: opts.message,
1282
+ });
1283
+ sent++;
1284
+ out.log(` ${out.green('✓')} ${target.playerId}`);
1285
+ }
1286
+ catch (err) {
1287
+ out.warn(` ${target.playerId}: ${err instanceof Error ? err.message : String(err)}`);
1288
+ }
1289
+ }
1290
+ out.success(`Broadcast sent to ${sent}/${targets.length} player${targets.length === 1 ? '' : 's'}`);
1291
+ await connection.close();
1292
+ }
1293
+ async function encore(opts) {
1294
+ if (opts.host) {
1295
+ out.error('Cross-machine encore is not supported via the CLI. Use the MCP `encore` tool with --host instead (it routes through the outbox and per-host task queues).');
1296
+ process.exit(1);
1297
+ return;
1298
+ }
1299
+ const config = (0, config_1.getConfig)(opts);
1300
+ let connection;
1301
+ try {
1302
+ connection = await Promise.race([
1303
+ (0, connection_1.createTemporalConnection)(config),
1304
+ new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 3000)),
1305
+ ]);
1306
+ }
1307
+ catch {
1308
+ out.error(`Cannot connect to Temporal at ${config.temporalAddress}`);
1309
+ process.exit(1);
1310
+ return;
1311
+ }
1312
+ const client = new client_1.Client({ connection, namespace: config.temporalNamespace });
1313
+ const ensemble = opts.ensemble || config.ensemble;
1314
+ // Resolve the target session
1315
+ const query = `WorkflowType = "claudeSessionWorkflow" AND ExecutionStatus = "Running"`;
1316
+ let targetHandle = null;
1317
+ let targetMeta = null;
1318
+ for await (const wf of client.workflow.list({ query })) {
1319
+ try {
1320
+ const handle = client.workflow.getHandle(wf.workflowId);
1321
+ const metadata = await handle.query('getMetadata');
1322
+ if (metadata.ensemble === ensemble && metadata.playerId === opts.name) {
1323
+ targetHandle = handle;
1324
+ targetMeta = metadata;
1325
+ break;
1326
+ }
1327
+ }
1328
+ catch {
1329
+ // skip
1330
+ }
1331
+ }
1332
+ if (!targetHandle || !targetMeta) {
1333
+ out.error(`No session found with name "${opts.name}" in ensemble "${ensemble}".`);
1334
+ await connection.close();
1335
+ process.exit(1);
1336
+ return;
1337
+ }
1338
+ const status = targetMeta.status || 'active';
1339
+ if (status !== 'stale') {
1340
+ out.error(`Session "${opts.name}" is ${status}, not stale. Encore only works on stale sessions.`);
1341
+ await connection.close();
1342
+ process.exit(1);
1343
+ return;
1344
+ }
1345
+ // Query context
1346
+ const part = await targetHandle.query('getPart');
1347
+ const allMessages = await targetHandle.query('allMessages');
1348
+ const recentMessages = allMessages.slice(-validation_1.ENCORE_DEFAULT_CONTEXT_MESSAGES);
1349
+ const msgSummary = recentMessages.length > 0
1350
+ ? recentMessages.map(m => `[${m.from}] ${m.text.slice(0, validation_1.PREVIEW_MAX_LENGTH)}`).join('\n')
1351
+ : '(no recent messages)';
1352
+ const contextMessage = [
1353
+ `🎵 **Encore** — you've been revived via CLI.`,
1354
+ part ? `Your last status: ${part}` : '',
1355
+ `Recent messages (last ${recentMessages.length}):`,
1356
+ msgSummary,
1357
+ '',
1358
+ 'Resume where you left off. Use `ensemble` to see who is active.',
1359
+ ].filter(Boolean).join('\n');
1360
+ // Reset status and inject context message
1361
+ await targetHandle.signal('updateMetadata', { status: 'pending' });
1362
+ await targetHandle.signal('receiveMessage', { from: 'system', text: contextMessage });
1363
+ out.log(`Reviving "${opts.name}" in ${targetMeta.workDir}...`);
1364
+ // Resolve agent flags
1365
+ let agentFlags = [];
1366
+ if (targetMeta.playerType) {
1367
+ try {
1368
+ const info = (0, agent_types_1.resolveAgentType)(targetMeta.playerType);
1369
+ if (info?.nativeResolvable) {
1370
+ agentFlags = ['--agent', targetMeta.playerType];
1371
+ }
1372
+ else if (info?.path) {
1373
+ agentFlags = ['--system-prompt', info.path];
1374
+ }
1375
+ }
1376
+ catch {
1377
+ // non-fatal
1378
+ }
1379
+ }
1380
+ const spawnArgs = [
1381
+ '--dangerously-skip-permissions',
1382
+ '--dangerously-load-development-channels', 'server:claude-tempo',
1383
+ '--resume', opts.name,
1384
+ ...agentFlags,
1385
+ ];
1386
+ const envVars = {
1387
+ [config_1.ENV.ENSEMBLE]: ensemble,
1388
+ [config_1.ENV.CONDUCTOR]: targetMeta.isConductor ? 'true' : '',
1389
+ [config_1.ENV.PLAYER_NAME]: opts.name,
1390
+ [config_1.ENV.TEMPORAL_ADDRESS]: config.temporalAddress,
1391
+ [config_1.ENV.TEMPORAL_NAMESPACE]: config.temporalNamespace,
1392
+ };
1393
+ if (targetMeta.playerType)
1394
+ envVars[config_1.ENV.PLAYER_TYPE] = targetMeta.playerType;
1395
+ if (config.temporalApiKey)
1396
+ envVars[config_1.ENV.TEMPORAL_API_KEY] = config.temporalApiKey;
1397
+ if (config.temporalTlsCertPath)
1398
+ envVars[config_1.ENV.TEMPORAL_TLS_CERT_PATH] = config.temporalTlsCertPath;
1399
+ if (config.temporalTlsKeyPath)
1400
+ envVars[config_1.ENV.TEMPORAL_TLS_KEY_PATH] = config.temporalTlsKeyPath;
1401
+ const { pid } = (0, spawn_1.spawnInTerminal)(spawnArgs, targetMeta.workDir, envVars);
1402
+ out.success(`Encore! "${opts.name}" revived (pid ${pid})`);
1403
+ await connection.close();
1404
+ }
1230
1405
  async function ensembleCommand(opts) {
1231
1406
  switch (opts.subcommand) {
1232
1407
  case 'save': {
@@ -1297,6 +1472,8 @@ ${out.bold('Commands:')}
1297
1472
  ${out.cyan('stop')} [ensemble] Stop sessions (-n <name> for one, or --all)
1298
1473
  ${out.cyan('status')} [ensemble] Show active sessions and Temporal health
1299
1474
  ${out.cyan('ensemble')} <sub> Manage saved ensemble lineups (save/list/show)
1475
+ ${out.cyan('broadcast')} <message> Send a message to all active players
1476
+ ${out.cyan('encore')} <name> Revive a stale player session (reconnect with context)
1300
1477
  ${out.cyan('agent-types')} <sub> Manage player type definitions (list/show/init)
1301
1478
  ${out.cyan('config')} Configure Temporal connection settings
1302
1479
  ${out.cyan('init')} Register MCP server globally (or --project for .mcp.json)
package/dist/cli.js CHANGED
@@ -51,6 +51,7 @@ function parseArgs(argv) {
51
51
  project: false,
52
52
  replace: false,
53
53
  resume: false,
54
+ includeStale: false,
54
55
  };
55
56
  let i = 0;
56
57
  while (i < argv.length) {
@@ -103,6 +104,15 @@ function parseArgs(argv) {
103
104
  else if (arg === '--ensemble' && i + 1 < argv.length) {
104
105
  result.ensemble = argv[++i];
105
106
  }
107
+ else if (arg === '--type' && i + 1 < argv.length) {
108
+ result.type = argv[++i];
109
+ }
110
+ else if (arg === '--include-stale') {
111
+ result.includeStale = true;
112
+ }
113
+ else if (arg === '--host' && i + 1 < argv.length) {
114
+ result.host = argv[++i];
115
+ }
106
116
  else if (arg === '--agent' && i + 1 < argv.length) {
107
117
  const val = argv[++i];
108
118
  if (val !== 'claude' && val !== 'copilot') {
@@ -209,6 +219,35 @@ async function main() {
209
219
  ...overrides,
210
220
  });
211
221
  break;
222
+ case 'broadcast': {
223
+ const msg = args.positional.slice(1).join(' ');
224
+ if (!msg) {
225
+ out.error('Usage: claude-tempo broadcast <message> [--ensemble <name>] [--type <player-type>] [--include-stale]');
226
+ process.exit(1);
227
+ }
228
+ await (0, commands_1.broadcast)({
229
+ message: msg,
230
+ ensemble: args.ensemble || ensemble,
231
+ type: args.type,
232
+ includeStale: args.includeStale,
233
+ ...overrides,
234
+ });
235
+ break;
236
+ }
237
+ case 'encore': {
238
+ const encoreName = args.positional[1] || args.name;
239
+ if (!encoreName) {
240
+ out.error('Usage: claude-tempo encore <name> [--ensemble <name>] [--host <hostname>]');
241
+ process.exit(1);
242
+ }
243
+ await (0, commands_1.encore)({
244
+ name: encoreName,
245
+ ensemble: args.ensemble || ensemble,
246
+ host: args.host,
247
+ ...overrides,
248
+ });
249
+ break;
250
+ }
212
251
  case 'ensemble':
213
252
  await (0, commands_1.ensembleCommand)({
214
253
  subcommand: args.positional[1],
package/dist/server.js CHANGED
@@ -61,6 +61,9 @@ const save_lineup_1 = require("./tools/save-lineup");
61
61
  const load_lineup_1 = require("./tools/load-lineup");
62
62
  const agent_types_1 = require("./tools/agent-types");
63
63
  const who_am_i_1 = require("./tools/who-am-i");
64
+ const broadcast_1 = require("./tools/broadcast");
65
+ const recall_1 = require("./tools/recall");
66
+ const encore_1 = require("./tools/encore");
64
67
  const channel_1 = require("./channel");
65
68
  const agent_types_2 = require("./ensemble/agent-types");
66
69
  const log = (...args) => console.error('[claude-tempo]', ...args);
@@ -273,6 +276,9 @@ async function main() {
273
276
  (0, load_lineup_1.registerLoadLineupTool)(mcpServer, client, config, getPlayerId, isBridgeMode ? 'copilot' : 'claude');
274
277
  (0, agent_types_1.registerAgentTypesTool)(mcpServer);
275
278
  (0, who_am_i_1.registerWhoAmITool)(mcpServer, handle, getPlayerId);
279
+ (0, broadcast_1.registerBroadcastTool)(mcpServer, client, config, getPlayerId, handle);
280
+ (0, recall_1.registerRecallTool)(mcpServer, handle, getPlayerId);
281
+ (0, encore_1.registerEncoreTool)(mcpServer, client, config, getPlayerId, handle);
276
282
  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.]';
277
283
  // Start message poller — push messages into Claude Code via channel notifications.
278
284
  // Skip when running under the Copilot bridge: the bridge has its own poller that
@@ -0,0 +1,4 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import { Client, WorkflowHandle } from '@temporalio/client';
3
+ import { Config } from '../config';
4
+ export declare function registerBroadcastTool(server: McpServer, client: Client, config: Config, getPlayerId: () => string, handle: WorkflowHandle): void;
@@ -0,0 +1,78 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.registerBroadcastTool = registerBroadcastTool;
4
+ const zod_1 = require("zod");
5
+ const signals_1 = require("../workflows/signals");
6
+ const helpers_1 = require("./helpers");
7
+ const validation_1 = require("../utils/validation");
8
+ function registerBroadcastTool(server, client, config, getPlayerId, handle) {
9
+ (0, helpers_1.defineTool)(server, 'broadcast', 'Send a message to all active players in the ensemble. Optionally filter by player type.', {
10
+ message: zod_1.z.string().max(validation_1.MESSAGE_MAX).describe('The message to broadcast'),
11
+ type: zod_1.z.string().optional().describe('Only send to players of this type (e.g., "tempo-soloist")'),
12
+ includeStale: zod_1.z.boolean().optional().describe('Include stale sessions (default: false)'),
13
+ }, async (args) => {
14
+ const { message, type: playerType, includeStale: rawIncludeStale } = args;
15
+ const includeStale = rawIncludeStale === true;
16
+ try {
17
+ const query = `WorkflowType = "claudeSessionWorkflow" AND ExecutionStatus = "Running"`;
18
+ const targets = [];
19
+ for await (const workflow of client.workflow.list({ query })) {
20
+ try {
21
+ const wfHandle = client.workflow.getHandle(workflow.workflowId);
22
+ const metadata = await wfHandle.query('getMetadata');
23
+ // Filter by ensemble
24
+ if (metadata.ensemble !== config.ensemble)
25
+ continue;
26
+ // Exclude sender
27
+ if (metadata.playerId === getPlayerId())
28
+ continue;
29
+ // Filter by status
30
+ if (!(0, validation_1.shouldIncludeInBroadcast)(metadata.status, includeStale))
31
+ continue;
32
+ // Filter by player type if specified
33
+ if (playerType && metadata.playerType !== playerType)
34
+ continue;
35
+ targets.push({
36
+ playerId: metadata.playerId,
37
+ playerType: metadata.playerType,
38
+ });
39
+ }
40
+ catch {
41
+ // Workflow may have just completed — skip it
42
+ }
43
+ }
44
+ if (targets.length === 0) {
45
+ return {
46
+ content: [{
47
+ type: 'text',
48
+ text: 'No active players matched the broadcast filter.',
49
+ }],
50
+ };
51
+ }
52
+ // Fan out cue outbox entries for each target
53
+ const entryIds = [];
54
+ for (const target of targets) {
55
+ const entry = {
56
+ type: 'cue',
57
+ targetPlayerId: target.playerId,
58
+ message,
59
+ };
60
+ const entryId = await handle.executeUpdate(signals_1.submitOutboxUpdate, { args: [entry] });
61
+ entryIds.push(entryId);
62
+ }
63
+ const names = targets.map((t) => t.playerId);
64
+ return {
65
+ content: [{
66
+ type: 'text',
67
+ text: `Broadcast sent to ${targets.length} player${targets.length === 1 ? '' : 's'}: ${names.join(', ')}`,
68
+ }],
69
+ };
70
+ }
71
+ catch (err) {
72
+ return {
73
+ content: [{ type: 'text', text: `Failed to broadcast: ${err}` }],
74
+ isError: true,
75
+ };
76
+ }
77
+ });
78
+ }
@@ -0,0 +1,4 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import { Client, WorkflowHandle } from '@temporalio/client';
3
+ import { Config } from '../config';
4
+ export declare function registerEncoreTool(server: McpServer, client: Client, config: Config, getPlayerId: () => string, handle: WorkflowHandle): void;