claude-tempo 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLAUDE.md CHANGED
@@ -9,7 +9,8 @@ claude-tempo is an MCP server that enables multiple Claude Code sessions to coor
9
9
  - **Runtime**: Node.js 18+ with TypeScript
10
10
  - **MCP**: `@modelcontextprotocol/sdk` (stdio transport)
11
11
  - **Temporal**: `@temporalio/client`, `@temporalio/worker`, `@temporalio/workflow`, `@temporalio/activity`
12
- - **No other dependencies** — no database, no custom broker
12
+ - **croner** — cron expression parsing and next-fire computation (used by `schedule` tool)
13
+ - **yaml**, **zod** — lineup parsing and schema validation
13
14
 
14
15
  ## Project Structure
15
16
 
@@ -34,7 +35,7 @@ src/
34
35
  │ ├── scheduler-signals.ts # Scheduler signal/query type definitions
35
36
  │ └── signals.ts # Session signal/query type definitions
36
37
  ├── activities/
37
- │ ├── outbox.ts # Outbox delivery activities (cue, report, stop, recruit)
38
+ │ ├── outbox.ts # Outbox delivery activities (cue, report, stop, recruit, encore)
38
39
  │ └── schedule-fire.ts # Schedule fire activity
39
40
  ├── ensemble/
40
41
  │ ├── schema.ts # Lineup type definitions
@@ -53,12 +54,17 @@ src/
53
54
  │ ├── recruit.ts # Spawn new session (via outbox), supports `type` param
54
55
  │ ├── report.ts # Report to conductor (via outbox)
55
56
  │ ├── stop.ts # Stop a session (via outbox)
57
+ │ ├── broadcast.ts # Send message to all active players (via outbox fan-out)
58
+ │ ├── encore.ts # Revive a stale session (via outbox)
59
+ │ ├── recall.ts # Read own message history (received + sent)
56
60
  │ ├── load-lineup.ts # Load an ensemble lineup, recruit players
57
61
  │ ├── save-lineup.ts # Save current ensemble state as a lineup
58
62
  │ ├── schedule.ts # Create one-shot or recurring schedules
59
63
  │ ├── unschedule.ts # Cancel a named schedule
60
64
  │ ├── schedules.ts # List active schedules
61
65
  │ └── helpers.ts # Zod/MCP tool registration wrapper
66
+ ├── utils/
67
+ │ └── validation.ts # Shared validation constants (name/message/path limits, encore defaults) and helpers
62
68
  ├── types.ts # Shared type definitions
63
69
  ├── channel.ts # Claude channel notification helper
64
70
  ├── git-info.ts # Git repository detection helper
@@ -101,11 +107,14 @@ npm test
101
107
  - **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
108
  - **set_name**: Players start with a random hex ID; `set_name` updates the `ClaudeTempoPlayerId` search attribute to a human-readable name
103
109
  - **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.
110
+ - **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.
111
+ - **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.
112
+ - **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.
113
+ - **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
114
  - **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
- - **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.
115
+ - **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. Agent type frontmatter may include an `allowedTools` array to restrict which MCP/CLI tools the spawned session can use (e.g., `allowedTools: [Read, Glob, Grep]`). When present, the type's `allowedTools` overrides any lineup-level setting and is passed to the Claude Code session via `--allowedTools`.
107
116
  - **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).
108
- - **Schedule**: A one-shot or recurring message delivery configured via the `schedule` tool. Backed by a durable `claudeSchedulerWorkflow` — survives restarts. Supports delay, cron-style intervals, and time-bounded delivery. Managed via `schedule`, `unschedule`, and `schedules` tools.
117
+ - **Schedule**: A one-shot or recurring message delivery configured via the `schedule` tool. Backed by a durable `claudeSchedulerWorkflow` — survives restarts. Supports delay (`delay`), fixed time (`at`), recurring interval (`every`), and cron expressions (`cron`) with optional IANA timezone (`timezone`). Cron schedules use `croner` for expression parsing and next-fire computation. Managed via `schedule`, `unschedule`, and `schedules` tools.
109
118
  - **Lineup**: A YAML file defining an ensemble configuration — which players to recruit, their types, working directories, and optional startup messages. Load via `load_lineup` to bootstrap a full ensemble in one step; save via `save_lineup` to snapshot a running ensemble's state for later reuse.
110
119
  - **Wire protocol**: All Temporal signal, query, update, and workflow names are documented in [`docs/WIRE-PROTOCOL.md`](docs/WIRE-PROTOCOL.md). These names are stable as of v0.10 — renaming or removing any is a breaking change requiring a major version bump.
111
120
 
@@ -123,3 +132,14 @@ Examples:
123
132
  - `feat(tools): add ensemble discovery tool`
124
133
  - `fix(workflow): handle signal delivery edge case`
125
134
  - `docs: update getting started guide`
135
+
136
+ ## Release Process
137
+
138
+ **Correct order — never deviate:**
139
+
140
+ 1. Merge the feature PR into `main` (squash merge)
141
+ 2. Bump `version` in `package.json` and add a `## [x.y.z]` entry in `CHANGELOG.md` on `main`
142
+ 3. Commit: `chore: bump version to vX.Y.Z`
143
+ 4. Tag the bump commit: `git tag vX.Y.Z && git push origin vX.Y.Z`
144
+
145
+ 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
 
@@ -147,11 +150,20 @@ Tell your session things like:
147
150
  - *"Schedule a check every hour called 'deploy-watch' — cue ops to check deployment status"*
148
151
  - *"Remind me in 30 minutes to review PR #42"*
149
152
  - *"Every 5 minutes for the next hour, ping frontend to check their progress"*
150
- - *"Set up a daily standup reminder at 9am UTC for the conductor"*
153
+ - *"Set up a daily standup at 9am New York time, weekdays only"*
151
154
  - *"Cancel the deploy-watch schedule"*
152
155
  - *"Show me all active schedules"*
153
156
 
154
- Schedules support one-shot delays, fixed times, and recurring intervals with optional bounds (max count or end time).
157
+ Schedules support four timing modes all accept optional bounds (`count` max fires, `until` end time):
158
+
159
+ | Mode | Parameter | Example |
160
+ |------|-----------|---------|
161
+ | One-shot delay | `delay` | `"10m"`, `"2h"`, `"1d"` |
162
+ | Fixed time | `at` | `"2026-04-03T20:00:00Z"` |
163
+ | Recurring interval | `every` | `"5m"`, `"1h"` |
164
+ | Cron expression | `cron` + optional `timezone` | `"0 9 * * 1-5"` (weekdays 9am) |
165
+
166
+ The `timezone` parameter accepts any IANA timezone (e.g. `"America/New_York"`, `"Europe/London"`). Defaults to UTC when omitted.
155
167
 
156
168
  ### How it works
157
169
 
@@ -238,7 +250,7 @@ players:
238
250
 
239
251
  ## Player Types
240
252
 
241
- Player types are reusable agent definitions in Claude Code's standard subagent format — `.md` files with YAML frontmatter specifying name, description, and optional model. They let you define specialized roles once and reuse them across lineups.
253
+ Player types are reusable agent definitions in Claude Code's standard subagent format — `.md` files with YAML frontmatter specifying name, description, optional model, and optional tool restrictions. They let you define specialized roles once and reuse them across lineups.
242
254
 
243
255
  ### How player types work
244
256
 
@@ -254,6 +266,23 @@ players:
254
266
 
255
267
  When a player is recruited with a type, the agent definition is resolved and passed to the session. Players know their type via the `who_am_i` tool.
256
268
 
269
+ ### Tool restrictions (`allowedTools`)
270
+
271
+ Agent type frontmatter may include an `allowedTools` array to restrict which tools the spawned session can use. When present, it is passed to the Claude Code session via `--allowedTools` and overrides any lineup-level setting.
272
+
273
+ ```yaml
274
+ ---
275
+ name: tempo-reviewer
276
+ description: Read-only code reviewer
277
+ allowedTools:
278
+ - Read
279
+ - Glob
280
+ - Grep
281
+ ---
282
+ ```
283
+
284
+ This is useful for security-sensitive roles (read-only reviewers, auditors) or to prevent specific players from making changes outside their scope. Sessions launched without a type, or with a type that omits `allowedTools`, receive no tool restrictions.
285
+
257
286
  ### Three-tier lookup
258
287
 
259
288
  Player types are resolved in order (first match wins):
@@ -30,6 +30,7 @@ export interface StartRecruitedSessionInput {
30
30
  taskQueue: string;
31
31
  agentDefinition?: string;
32
32
  agentDefinitionDescription?: string;
33
+ allowedTools?: string[];
33
34
  }
34
35
  export interface SpawnProcessInput {
35
36
  targetName: string;
@@ -43,6 +44,28 @@ export interface SpawnProcessInput {
43
44
  agentDefinition?: string;
44
45
  agentDefinitionPath?: string;
45
46
  nativeResolvable?: boolean;
47
+ /** When true, use --resume instead of -n (reconnect to existing session). */
48
+ resume?: boolean;
49
+ /** Tool restrictions from the agent definition frontmatter. */
50
+ allowedTools?: string[];
51
+ }
52
+ export interface PerformEncoreInput {
53
+ ensemble: string;
54
+ targetPlayerId: string;
55
+ fromPlayerId: string;
56
+ contextMessageCount?: number;
57
+ }
58
+ export interface EncoreResult {
59
+ workDir: string;
60
+ hostname: string;
61
+ isConductor: boolean;
62
+ agent: AgentType;
63
+ agentDefinition?: string;
64
+ agentDefinitionPath?: string;
65
+ nativeResolvable?: boolean;
66
+ allowedTools?: string[];
67
+ temporalAddress: string;
68
+ temporalNamespace: string;
46
69
  }
47
70
  export interface OutboxActivityResult {
48
71
  success: boolean;
@@ -54,6 +77,7 @@ export interface OutboxActivities {
54
77
  terminateSession(input: TerminateSessionInput): Promise<OutboxActivityResult>;
55
78
  startRecruitedSession(input: StartRecruitedSessionInput): Promise<OutboxActivityResult>;
56
79
  spawnProcess(input: SpawnProcessInput): Promise<OutboxActivityResult>;
80
+ performEncore(input: PerformEncoreInput): Promise<EncoreResult>;
57
81
  }
58
82
  /**
59
83
  * 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,11 +145,14 @@ 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, allowedTools } = 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 {
150
152
  if (agent === 'copilot') {
153
+ if (allowedTools && allowedTools.length > 0) {
154
+ log(`Warning: allowedTools [${allowedTools.join(', ')}] specified for copilot agent "${targetName}" — copilot bridge does not support --allowedTools, skipping`);
155
+ }
151
156
  const { pid } = (0, spawn_1.spawnCopilotBridge)({
152
157
  name: targetName,
153
158
  ensemble,
@@ -173,11 +178,18 @@ function createOutboxActivities(client, config) {
173
178
  else if (systemPrompt) {
174
179
  agentFlags = ['--system-prompt', systemPrompt];
175
180
  }
181
+ // Use --resume for encore (reconnect to existing session) or -n for new sessions
182
+ const nameArgs = resume ? ['--resume', targetName] : ['-n', targetName];
183
+ // Build --allowedTools flag from agent definition frontmatter
184
+ const allowedToolsFlags = allowedTools && allowedTools.length > 0
185
+ ? ['--allowedTools', ...allowedTools]
186
+ : [];
176
187
  const spawnArgs = [
177
188
  '--dangerously-skip-permissions',
178
189
  '--dangerously-load-development-channels', 'server:claude-tempo',
179
- '-n', targetName,
190
+ ...nameArgs,
180
191
  ...agentFlags,
192
+ ...allowedToolsFlags,
181
193
  ];
182
194
  const envVars = {
183
195
  [config_2.ENV.ENSEMBLE]: ensemble,
@@ -195,7 +207,7 @@ function createOutboxActivities(client, config) {
195
207
  if (temporalTlsKeyPath)
196
208
  envVars[config_2.ENV.TEMPORAL_TLS_KEY_PATH] = temporalTlsKeyPath;
197
209
  const { pid } = (0, spawn_1.spawnInTerminal)(spawnArgs, workDir, envVars);
198
- log(`Spawned claude process (pid ${pid}) in ${workDir} as "${targetName}"`);
210
+ log(`Spawned claude process (pid ${pid}) in ${workDir} as "${targetName}" (resume=${!!resume})`);
199
211
  }
200
212
  return { success: true };
201
213
  }
@@ -203,5 +215,80 @@ function createOutboxActivities(client, config) {
203
215
  throw activity_1.ApplicationFailure.nonRetryable(`Failed to spawn process for "${targetName}": ${err instanceof Error ? err.message : String(err)}`);
204
216
  }
205
217
  },
218
+ async performEncore(input) {
219
+ const { ensemble, targetPlayerId, fromPlayerId, contextMessageCount = validation_1.ENCORE_DEFAULT_CONTEXT_MESSAGES } = input;
220
+ try {
221
+ const handle = await (0, resolve_1.resolveSession)(client, ensemble, targetPlayerId);
222
+ if (!handle) {
223
+ throw activity_1.ApplicationFailure.nonRetryable(`No session found for "${targetPlayerId}"`);
224
+ }
225
+ // Atomically transition status from 'stale' to 'pending' — prevents double-spawn races
226
+ const transitioned = await handle.executeUpdate('checkAndSetStatus', {
227
+ args: [{ expectedStatus: 'stale', newStatus: 'pending' }],
228
+ });
229
+ if (!transitioned) {
230
+ // Read current status for a useful error message
231
+ const metadata = await handle.query('getMetadata');
232
+ const status = metadata.status;
233
+ throw activity_1.ApplicationFailure.nonRetryable(`Cannot encore "${targetPlayerId}" — status is "${status}" (must be "stale"). Another encore may already be in progress.`);
234
+ }
235
+ // Query context (status is now locked to 'pending', safe from races)
236
+ const metadata = await handle.query('getMetadata');
237
+ const part = await handle.query('getPart');
238
+ const allMessages = await handle.query('allMessages');
239
+ // Build context message from recent messages
240
+ const recentMessages = allMessages.slice(-contextMessageCount);
241
+ const msgSummary = recentMessages.length > 0
242
+ ? recentMessages.map(m => `[${m.from}] ${m.text.slice(0, validation_1.PREVIEW_MAX_LENGTH)}`).join('\n')
243
+ : '(no recent messages)';
244
+ const contextMessage = [
245
+ `🎵 **Encore** — you've been revived by ${fromPlayerId}.`,
246
+ part ? `Your last status: ${part}` : '',
247
+ `Recent messages (last ${recentMessages.length}):`,
248
+ msgSummary,
249
+ '',
250
+ 'Resume where you left off. Use `ensemble` to see who is active.',
251
+ ].filter(Boolean).join('\n');
252
+ // Inject context message (status already set to pending atomically above)
253
+ await handle.signal('receiveMessage', { from: fromPlayerId, text: contextMessage });
254
+ log(`Encore prepared for "${targetPlayerId}" — status reset to pending, context injected`);
255
+ // Return spawn parameters from the target's metadata
256
+ const agentType = metadata.agentType || 'claude';
257
+ const playerType = metadata.playerType;
258
+ let agentDefinitionPath;
259
+ let nativeResolvable;
260
+ let allowedTools;
261
+ if (playerType) {
262
+ try {
263
+ const info = (0, agent_types_1.resolveAgentType)(playerType);
264
+ if (info) {
265
+ agentDefinitionPath = info.path;
266
+ nativeResolvable = info.nativeResolvable;
267
+ allowedTools = info.allowedTools;
268
+ }
269
+ }
270
+ catch {
271
+ // Agent type resolution failure is non-fatal
272
+ }
273
+ }
274
+ return {
275
+ workDir: metadata.workDir,
276
+ hostname: metadata.hostname,
277
+ isConductor: metadata.isConductor,
278
+ agent: agentType === 'copilot' ? 'copilot' : 'claude',
279
+ agentDefinition: playerType,
280
+ agentDefinitionPath,
281
+ nativeResolvable,
282
+ allowedTools,
283
+ temporalAddress: config.temporalAddress,
284
+ temporalNamespace: config.temporalNamespace,
285
+ };
286
+ }
287
+ catch (err) {
288
+ if (err instanceof activity_1.ApplicationFailure)
289
+ throw err;
290
+ throw activity_1.ApplicationFailure.nonRetryable(`Encore failed for "${targetPlayerId}": ${err instanceof Error ? err.message : String(err)}`);
291
+ }
292
+ },
206
293
  };
207
294
  }
@@ -10,9 +10,14 @@ export interface FireScheduleResult {
10
10
  success: boolean;
11
11
  error?: string;
12
12
  }
13
+ export interface ComputeNextCronInput {
14
+ cronExpression: string;
15
+ timezone?: string;
16
+ }
13
17
  /** Activity interface — used by proxyActivities in the scheduler workflow. */
14
18
  export interface ScheduleActivities {
15
19
  fireSchedule(input: FireScheduleInput): Promise<FireScheduleResult>;
20
+ computeNextCronFire(input: ComputeNextCronInput): Promise<string | null>;
16
21
  }
17
22
  /**
18
23
  * Create the schedule-fire activity bound to a Temporal client.
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.createScheduleActivities = createScheduleActivities;
4
+ const croner_1 = require("croner");
4
5
  const config_1 = require("../config");
5
6
  const resolve_1 = require("./resolve");
6
7
  /**
@@ -9,6 +10,11 @@ const resolve_1 = require("./resolve");
9
10
  */
10
11
  function createScheduleActivities(client) {
11
12
  return {
13
+ async computeNextCronFire(input) {
14
+ const job = new croner_1.Cron(input.cronExpression, { timezone: input.timezone || 'UTC' });
15
+ const next = job.nextRun();
16
+ return next ? next.toISOString() : null;
17
+ },
12
18
  async fireSchedule(input) {
13
19
  const { ensemble, scheduleName, message, target, createdBy } = input;
14
20
  try {
@@ -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],