claude-tempo 0.23.0 → 0.24.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
@@ -78,6 +78,9 @@ src/
78
78
  │ ├── stage.ts # Define a stage — fan-out/fan-in tracking for parallel tasks (conductor only)
79
79
  │ ├── stages.ts # List stages and their status (conductor only)
80
80
  │ ├── cancel-stage.ts # Cancel an active stage (conductor only)
81
+ │ ├── release.ts # Release held players (unlock outbox + deliver deferred messages)
82
+ │ ├── pause-ensemble.ts # Pause all sessions and the scheduler ensemble-wide
83
+ │ ├── resume-ensemble.ts # Resume a paused ensemble
81
84
  │ └── helpers.ts # Zod/MCP tool registration wrapper
82
85
  ├── tui/
83
86
  │ ├── index.ts # TUI entry point — connects to Temporal and renders the Ink app
@@ -173,9 +176,12 @@ npm test
173
176
  - **Quality Gate**: A named checklist of criteria a conductor tracks to verify a task is complete. Created via `quality_gate` (conductor only), evaluated via `evaluate_gate`, and listed via `gates`. Each criterion has a `pending` → `passed` | `failed` status; the gate's aggregate status is derived automatically (all passed → `passed`, any failed → `failed`, else `open`). Gates are stored in the conductor workflow and survive `continueAsNew`.
174
177
  - **Worktree**: A git worktree provisioned by the conductor for a player, giving them an isolated checkout on a separate branch. Managed via the `worktree` tool (conductor only): `create` provisions the worktree and notifies the player, `remove` cleans up after the task, `list` shows all active worktrees. Worktree assignments are stored in the conductor workflow (`WorktreeEntry` records: player, path, branch, gitRoot, createdAt, createdBy).
175
178
  - **Stage**: A fan-out/fan-in tracking primitive for the conductor. Created via `stage` (conductor only), listing via `stages`, cancelled via `cancel_stage`. Each stage tracks a set of players; when a tracked player sends a `report`, their stage status updates automatically (`waiting` → `reported` or `blocked`). When all players have reported, the conductor is notified that the stage is complete. If `failurePolicy` is `'halt'` (default), a blocker from any player fails the entire stage. Stages are stored in the conductor workflow and survive `continueAsNew`.
179
+ - **Hold / Release**: Controlled ensemble startup mechanism. `load_lineup(hold: true)` spawns all players with locked outboxes and a standby message ("waiting for release") instead of their real task. When ready, the conductor calls `release` (MCP tool or `claude-tempo release` CLI) to unlock outboxes and deliver the actual task messages. Use case: pre-warm a full team before kicking off a long job.
180
+ - **Pause / Resume**: Ensemble-wide mid-session flow control. `pause_ensemble` locks all session outboxes and signals the scheduler to skip fires; `resume_ensemble` reverses both. `stop` outbox entries bypass the pause lock and are always dispatched. Pause state is owned by the per-ensemble Maestro (`maestroSetPaused` signal) and synced to sessions and the scheduler.
181
+ - **Outbox lock**: A workflow-level flag on each session that gates outbox dispatch independently of pause. Used by the hold mechanism (`outboxLocked` query, `releaseHeld` signal) and the pause mechanism (`setPaused` signal, `paused` query). The two flags are independent — a session can be held (locked) but not paused, or paused but not locked.
176
182
  - **Maestro**: Two Maestro workflow variants exist. The **per-ensemble** `claudeMaestroWorkflow` (ID: `claude-maestro-{ensemble}`) monitors a single ensemble — maintains a player snapshot, ring-buffer event log (max 200 entries), an aggregated ensemble chat cache (max 500 entries, refreshed every ~10s via `fetchEnsembleChat` activity), and queues commands for relay to the conductor via `maestroSendCommand`. The ensemble chat cache merges maestro + conductor traffic and is served via the `maestroEnsembleChat` query. The **global** `claudeGlobalMaestroWorkflow` (ID: `claude-maestro-global`) spans all ensembles — aggregates players by ensemble, maintains a cross-ensemble message ring buffer (max 500 entries), and exposes on-demand player/conductor history via `maestroFetchPlayerMessages` and `maestroFetchConductorHistory` updates. Both are implemented in `src/workflows/maestro.ts` with activities in `src/activities/maestro.ts`.
177
183
  - **TempoClient**: The API layer for querying ensemble state (`src/client/`). The interface and types live in `interface.ts`; the factory implementation lives in `index.ts`. Provides `discoverEnsembles`, `getPlayers`, `getMessages`, `getConductorHistory`, `sendMessage`, `sendCommand`, `getEnsembleChat`, `getGates`, `getStages`, `getWorktrees`, and `terminatePlayer`. Uses Global Maestro as the primary source with graceful fallback to per-ensemble Maestro and direct workflow list queries. `src/tui/client.ts` is a thin re-export shim for backward compatibility — new consumers should import from `src/client/` directly.
178
- - **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.
184
+ - **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. **Process**: when adding signals, queries, or updates to any workflow file (`signals.ts`, `scheduler-signals.ts`, `maestro-signals.ts`), update `docs/WIRE-PROTOCOL.md` in the same commit.
179
185
  - **Daemon**: A standalone background process (`src/daemon.ts`) that runs all Temporal workers. Auto-started by any claude-tempo command if not already running. PID stored at `~/.claude-tempo/daemon.pid`; logs at `~/.claude-tempo/daemon.log`. Sessions are now pure MCP clients — they no longer run in-process workers. Managed via `claude-tempo daemon start|stop|status|logs`.
180
186
 
181
187
  ## TUI Key Behaviors
package/README.md CHANGED
@@ -36,6 +36,7 @@ Each session registers as a **player** in Temporal. Players discover each other
36
36
  | 🎭 **Player Types** | Reusable agent definitions with 8 shipped types and three-tier lookup |
37
37
  | 🖥️ **Terminal UI** | Chat-focused TUI with slash commands, overlays, and interactive wizards |
38
38
  | 🌐 **Cross-machine** | Any session that can reach your Temporal server can join the ensemble |
39
+ | ⏸️ **Hold / Pause / Resume** | Pre-warm a full team before delivering tasks; pause and resume mid-session |
39
40
  | 🤖 **Copilot bridge** | Mix Claude Code and GitHub Copilot CLI sessions in the same ensemble |
40
41
 
41
42
  ## Installation
@@ -142,6 +143,9 @@ claude-tempo up [ensemble] # first-time setup
142
143
  claude-tempo conduct [ensemble] # start a conductor
143
144
  claude-tempo start [ensemble] # start a player
144
145
  claude-tempo status [ensemble] # list active sessions
146
+ claude-tempo release [ensemble] # release held players (unlock + deliver tasks)
147
+ claude-tempo pause [ensemble] # pause all sessions and the scheduler
148
+ claude-tempo resume [ensemble] # resume a paused ensemble
145
149
  claude-tempo tui # open the terminal UI
146
150
  claude-tempo daemon <sub> # manage the worker daemon
147
151
  claude-tempo upgrade # update to latest
@@ -233,7 +237,7 @@ claude-tempo tui --ensemble my-ensemble # direct ensemble mode
233
237
  The TUI provides a chat-focused shell for managing your ensemble:
234
238
 
235
239
  - **Ensemble chat feed** — live aggregated view of conductor + player traffic; type bare text to message the conductor, `@player message` to message directly
236
- - **Slash commands** — `/recruit`, `/status`, `/schedule`, `/gates`, `/stages`, `/worktree`, and more; type `/help` for the full list
240
+ - **Slash commands** — `/recruit`, `/status`, `/schedule`, `/gates`, `/stages`, `/worktree`, `/go` (release held), `/pause`, `/resume`, and more; type `/help` for the full list
237
241
  - **Interactive overlays and wizards** — step-by-step flows for recruiting players, creating schedules, and managing ensembles
238
242
 
239
243
  📖 [TUI reference → docs/dashboard.md](docs/dashboard.md)
@@ -33,6 +33,12 @@ export interface StartRecruitedSessionInput {
33
33
  allowedTools?: string[];
34
34
  /** Custom claude binary path (from config.claudeBin). */
35
35
  claudeBin?: string;
36
+ /** When true, spawn process but lock outbox and defer initial message until release (warm hold). */
37
+ held?: boolean;
38
+ }
39
+ export interface ReleasePlayerInput {
40
+ ensemble: string;
41
+ targetPlayerId: string;
36
42
  }
37
43
  export interface SpawnProcessInput {
38
44
  targetName: string;
@@ -92,6 +98,7 @@ export interface OutboxActivities {
92
98
  startRecruitedSession(input: StartRecruitedSessionInput): Promise<RecruitResult>;
93
99
  spawnProcess(input: SpawnProcessInput): Promise<OutboxActivityResult>;
94
100
  performEncore(input: PerformEncoreInput): Promise<EncoreResult>;
101
+ releasePlayer(input: ReleasePlayerInput): Promise<OutboxActivityResult>;
95
102
  }
96
103
  /**
97
104
  * Create outbox delivery activities bound to a Temporal client and config.
@@ -101,7 +101,7 @@ function createOutboxActivities(client, config) {
101
101
  return { success: true };
102
102
  },
103
103
  async startRecruitedSession(input) {
104
- const { ensemble, targetName, workDir, isConductor, initialMessage, fromPlayerId, agent, systemPrompt, taskQueue, agentDefinition, agentDefinitionDescription } = input;
104
+ const { ensemble, targetName, workDir, isConductor, initialMessage, fromPlayerId, agent, systemPrompt, taskQueue, agentDefinition, agentDefinitionDescription, held } = input;
105
105
  try {
106
106
  const workflowId = isConductor
107
107
  ? (0, config_1.conductorWorkflowId)(ensemble)
@@ -109,6 +109,11 @@ function createOutboxActivities(client, config) {
109
109
  const { gitRoot, gitBranch } = (0, git_info_1.getGitInfo)(workDir);
110
110
  // Generate a UUID for the session — used for deterministic --resume on encore
111
111
  const sessionId = crypto.randomUUID();
112
+ // Warm hold: process will spawn and go active, but outbox is locked and
113
+ // the initial message is deferred. A standby message is sent instead.
114
+ const standbyMessage = held
115
+ ? 'You are on standby. Your ensemble is loading — other players are still connecting. Wait for your task assignment. Do not start work or send messages yet.'
116
+ : undefined;
112
117
  const sessionInput = {
113
118
  metadata: {
114
119
  playerId: targetName,
@@ -127,15 +132,23 @@ function createOutboxActivities(client, config) {
127
132
  },
128
133
  autoSummary: `Session in ${path.basename(workDir)}`,
129
134
  disableStaleDetection: true,
130
- ...(initialMessage ? {
131
- messages: [{
135
+ // When held: store the initial message for delivery on release, inject standby message instead
136
+ ...(held ? { outboxLocked: true, heldMessage: initialMessage } : {}),
137
+ messages: held
138
+ ? [{
139
+ id: crypto.randomUUID(),
140
+ from: 'system',
141
+ text: standbyMessage,
142
+ timestamp: new Date().toISOString(),
143
+ delivered: false,
144
+ }]
145
+ : (initialMessage ? [{
132
146
  id: crypto.randomUUID(),
133
147
  from: fromPlayerId,
134
148
  text: initialMessage,
135
149
  timestamp: new Date().toISOString(),
136
150
  delivered: false,
137
- }],
138
- } : {}),
151
+ }] : undefined),
139
152
  };
140
153
  await client.workflow.start('claudeSessionWorkflow', {
141
154
  workflowId,
@@ -149,7 +162,7 @@ function createOutboxActivities(client, config) {
149
162
  ClaudeTempoPlayerId: [targetName],
150
163
  },
151
164
  });
152
- log(`Pre-created workflow ${workflowId} for recruit "${targetName}" (sessionId=${sessionId})`);
165
+ log(`Pre-created workflow ${workflowId} for recruit "${targetName}" (sessionId=${sessionId}, held=${!!held})`);
153
166
  return { success: true, sessionId };
154
167
  }
155
168
  catch (err) {
@@ -309,5 +322,28 @@ function createOutboxActivities(client, config) {
309
322
  throw activity_1.ApplicationFailure.nonRetryable(`Encore failed for "${targetPlayerId}": ${err instanceof Error ? err.message : String(err)}`);
310
323
  }
311
324
  },
325
+ async releasePlayer(input) {
326
+ const { ensemble, targetPlayerId } = input;
327
+ try {
328
+ const handle = await (0, resolve_1.resolveSession)(client, ensemble, targetPlayerId);
329
+ if (!handle) {
330
+ throw activity_1.ApplicationFailure.nonRetryable(`No session found for "${targetPlayerId}"`);
331
+ }
332
+ // Check if the session is actually held (outbox locked)
333
+ const isLocked = await handle.query('outboxLocked');
334
+ if (!isLocked) {
335
+ throw activity_1.ApplicationFailure.nonRetryable(`Cannot release "${targetPlayerId}" — session is not held (outbox not locked).`);
336
+ }
337
+ // Signal the session to release — unlocks outbox and delivers held message
338
+ await handle.signal('releaseHeld');
339
+ log(`Released held session "${targetPlayerId}"`);
340
+ return { success: true };
341
+ }
342
+ catch (err) {
343
+ if (err instanceof activity_1.ApplicationFailure)
344
+ throw err;
345
+ throw activity_1.ApplicationFailure.nonRetryable(`Release failed for "${targetPlayerId}": ${err instanceof Error ? err.message : String(err)}`);
346
+ }
347
+ },
312
348
  };
313
349
  }
@@ -77,6 +77,18 @@ interface DaemonOpts extends CliOverrides {
77
77
  subcommand?: string;
78
78
  }
79
79
  export declare function daemon(opts: DaemonOpts): Promise<void>;
80
+ interface ReleaseOpts extends CliOverrides {
81
+ ensemble: string;
82
+ }
83
+ /** Release all held sessions in an ensemble (unlock outbox, deliver initial messages). */
84
+ export declare function release(opts: ReleaseOpts): Promise<void>;
85
+ interface PauseResumeOpts extends CliOverrides {
86
+ ensemble: string;
87
+ }
88
+ /** Pause an entire ensemble — sessions, scheduler, and maestro. */
89
+ export declare function pause(opts: PauseResumeOpts): Promise<void>;
90
+ /** Resume an entire ensemble — sessions, scheduler, and maestro. */
91
+ export declare function resume(opts: PauseResumeOpts): Promise<void>;
80
92
  export declare function help(): void;
81
93
  export declare function version(): void;
82
94
  interface UpgradeOpts extends CliOverrides {
@@ -45,6 +45,9 @@ exports.broadcast = broadcast;
45
45
  exports.encore = encore;
46
46
  exports.ensembleCommand = ensembleCommand;
47
47
  exports.daemon = daemon;
48
+ exports.release = release;
49
+ exports.pause = pause;
50
+ exports.resume = resume;
48
51
  exports.help = help;
49
52
  exports.version = version;
50
53
  exports.upgrade = upgrade;
@@ -61,6 +64,7 @@ const git_info_1 = require("../git-info");
61
64
  const connection_1 = require("../connection");
62
65
  const signals_1 = require("../workflows/signals");
63
66
  const scheduler_signals_1 = require("../workflows/scheduler-signals");
67
+ const maestro_signals_1 = require("../workflows/maestro-signals");
64
68
  const preflight_1 = require("./preflight");
65
69
  const mcp_1 = require("./mcp");
66
70
  const loader_1 = require("../ensemble/loader");
@@ -1943,6 +1947,123 @@ async function daemon(opts) {
1943
1947
  process.exit(1);
1944
1948
  }
1945
1949
  }
1950
+ /** Release all held sessions in an ensemble (unlock outbox, deliver initial messages). */
1951
+ async function release(opts) {
1952
+ const config = (0, config_1.getConfig)(opts);
1953
+ let connection;
1954
+ try {
1955
+ connection = await Promise.race([
1956
+ (0, connection_1.createTemporalConnection)(config),
1957
+ new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 3000)),
1958
+ ]);
1959
+ }
1960
+ catch {
1961
+ out.error(`Cannot connect to Temporal at ${config.temporalAddress}`);
1962
+ process.exit(1);
1963
+ return;
1964
+ }
1965
+ const client = new client_1.Client({ connection, namespace: config.temporalNamespace });
1966
+ const query = `WorkflowType = "claudeSessionWorkflow" AND ExecutionStatus = "Running" AND ClaudeTempoEnsemble = "${opts.ensemble.replace(/["\\\n\r]/g, '')}"`;
1967
+ let released = 0;
1968
+ for await (const wf of client.workflow.list({ query })) {
1969
+ try {
1970
+ const handle = client.workflow.getHandle(wf.workflowId);
1971
+ const locked = await handle.query(signals_1.outboxLockedQuery);
1972
+ if (locked) {
1973
+ await handle.signal(signals_1.releaseHeldSignal);
1974
+ released++;
1975
+ const sa = wf.searchAttributes || {};
1976
+ const playerId = Array.isArray(sa.ClaudeTempoPlayerId) ? String(sa.ClaudeTempoPlayerId[0]) : wf.workflowId;
1977
+ out.log(` ${out.dim('released')} ${playerId}`);
1978
+ }
1979
+ }
1980
+ catch {
1981
+ // Skip failed queries (terminated workflows, etc.)
1982
+ }
1983
+ }
1984
+ if (released > 0) {
1985
+ out.success(`Released ${released} player${released !== 1 ? 's' : ''}`);
1986
+ }
1987
+ else {
1988
+ out.log('No held players found.');
1989
+ }
1990
+ await connection.close();
1991
+ }
1992
+ /** Pause an entire ensemble — sessions, scheduler, and maestro. */
1993
+ async function pause(opts) {
1994
+ const config = (0, config_1.getConfig)(opts);
1995
+ let connection;
1996
+ try {
1997
+ connection = await Promise.race([
1998
+ (0, connection_1.createTemporalConnection)(config),
1999
+ new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 3000)),
2000
+ ]);
2001
+ }
2002
+ catch {
2003
+ out.error(`Cannot connect to Temporal at ${config.temporalAddress}`);
2004
+ process.exit(1);
2005
+ return;
2006
+ }
2007
+ const client = new client_1.Client({ connection, namespace: config.temporalNamespace });
2008
+ await setPausedState(client, opts.ensemble, true);
2009
+ out.success(`Ensemble "${opts.ensemble}" paused`);
2010
+ await connection.close();
2011
+ }
2012
+ /** Resume an entire ensemble — sessions, scheduler, and maestro. */
2013
+ async function resume(opts) {
2014
+ const config = (0, config_1.getConfig)(opts);
2015
+ let connection;
2016
+ try {
2017
+ connection = await Promise.race([
2018
+ (0, connection_1.createTemporalConnection)(config),
2019
+ new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 3000)),
2020
+ ]);
2021
+ }
2022
+ catch {
2023
+ out.error(`Cannot connect to Temporal at ${config.temporalAddress}`);
2024
+ process.exit(1);
2025
+ return;
2026
+ }
2027
+ const client = new client_1.Client({ connection, namespace: config.temporalNamespace });
2028
+ await setPausedState(client, opts.ensemble, false);
2029
+ out.success(`Ensemble "${opts.ensemble}" resumed`);
2030
+ await connection.close();
2031
+ }
2032
+ /** Shared logic: set paused state across all ensemble components. */
2033
+ async function setPausedState(client, ensemble, paused) {
2034
+ // 1. Signal maestro hub
2035
+ try {
2036
+ const mh = client.workflow.getHandle((0, config_1.maestroWorkflowId)(ensemble));
2037
+ await mh.signal(maestro_signals_1.maestroSetPausedSignal, paused);
2038
+ }
2039
+ catch {
2040
+ // Maestro may not be running — non-critical
2041
+ }
2042
+ // 2. Signal all active sessions
2043
+ const sanitized = ensemble.replace(/["\\\n\r]/g, '');
2044
+ const query = `WorkflowType = "claudeSessionWorkflow" AND ExecutionStatus = "Running" AND ClaudeTempoEnsemble = "${sanitized}"`;
2045
+ let count = 0;
2046
+ for await (const wf of client.workflow.list({ query })) {
2047
+ try {
2048
+ const handle = client.workflow.getHandle(wf.workflowId);
2049
+ await handle.signal(signals_1.setPausedSignal, paused);
2050
+ count++;
2051
+ }
2052
+ catch {
2053
+ // Skip failed signals
2054
+ }
2055
+ }
2056
+ out.log(` ${out.dim(paused ? 'paused' : 'resumed')} ${count} session${count !== 1 ? 's' : ''}`);
2057
+ // 3. Signal scheduler
2058
+ try {
2059
+ const sh = client.workflow.getHandle((0, config_1.schedulerWorkflowId)(ensemble));
2060
+ await sh.signal(scheduler_signals_1.setSchedulerPausedSignal, paused);
2061
+ out.log(` ${out.dim(paused ? 'paused' : 'resumed')} scheduler`);
2062
+ }
2063
+ catch {
2064
+ // Scheduler may not be running — non-critical
2065
+ }
2066
+ }
1946
2067
  function help() {
1947
2068
  console.log(`
1948
2069
  ${out.bold('claude-tempo')} — Multi-session Claude Code coordination via Temporal
@@ -1964,6 +2085,9 @@ ${out.bold('Commands:')}
1964
2085
  ${out.cyan('ensemble')} <sub> Manage saved ensemble lineups (save/list/show)
1965
2086
  ${out.cyan('broadcast')} <message> Send a message to all active players
1966
2087
  ${out.cyan('encore')} <name> Revive a stale player session (reconnect with context)
2088
+ ${out.cyan('release')} [ensemble] Release all held players (unlock outbox, deliver messages)
2089
+ ${out.cyan('pause')} [ensemble] Pause an ensemble (sessions, scheduler, maestro)
2090
+ ${out.cyan('resume')} [ensemble] Resume a paused ensemble
1967
2091
  ${out.cyan('agent-types')} <sub> Manage player type definitions (list/show/init)
1968
2092
  ${out.cyan('daemon')} <sub> Manage the worker daemon (start/stop/status/logs)
1969
2093
  ${out.cyan('upgrade')} [version] Upgrade claude-tempo to latest (or specific version)
package/dist/cli.js CHANGED
@@ -260,6 +260,24 @@ async function main() {
260
260
  });
261
261
  break;
262
262
  }
263
+ case 'release':
264
+ await (0, commands_1.release)({
265
+ ensemble: args.ensemble || ensemble,
266
+ ...overrides,
267
+ });
268
+ break;
269
+ case 'pause':
270
+ await (0, commands_1.pause)({
271
+ ensemble: args.ensemble || ensemble,
272
+ ...overrides,
273
+ });
274
+ break;
275
+ case 'resume':
276
+ await (0, commands_1.resume)({
277
+ ensemble: args.ensemble || ensemble,
278
+ ...overrides,
279
+ });
280
+ break;
263
281
  case 'ensemble':
264
282
  await (0, commands_1.ensembleCommand)({
265
283
  subcommand: args.positional[1],
package/dist/server.js CHANGED
@@ -64,6 +64,9 @@ const who_am_i_1 = require("./tools/who-am-i");
64
64
  const broadcast_1 = require("./tools/broadcast");
65
65
  const recall_1 = require("./tools/recall");
66
66
  const encore_1 = require("./tools/encore");
67
+ const release_1 = require("./tools/release");
68
+ const pause_ensemble_1 = require("./tools/pause-ensemble");
69
+ const resume_ensemble_1 = require("./tools/resume-ensemble");
67
70
  const quality_gate_1 = require("./tools/quality-gate");
68
71
  const evaluate_gate_1 = require("./tools/evaluate-gate");
69
72
  const gates_1 = require("./tools/gates");
@@ -233,6 +236,18 @@ async function main() {
233
236
  // No conductor running — that's fine
234
237
  }
235
238
  }
239
+ // If ensemble is paused, inherit the paused state
240
+ try {
241
+ const maestroHandle = client.workflow.getHandle((0, config_1.maestroWorkflowId)(config.ensemble));
242
+ const isPaused = await maestroHandle.query('maestroPaused');
243
+ if (isPaused) {
244
+ await handle.signal('setPaused', true);
245
+ log('Ensemble is paused — session started in paused state');
246
+ }
247
+ }
248
+ catch {
249
+ // Maestro may not be running — that's fine
250
+ }
236
251
  // Create MCP server
237
252
  const hasRequestedName = isConductor || Boolean(requestedName && requestedName !== 'conductor');
238
253
  const playerTypeLine = playerType
@@ -283,6 +298,9 @@ async function main() {
283
298
  (0, broadcast_1.registerBroadcastTool)(mcpServer, client, config, getPlayerId, handle);
284
299
  (0, recall_1.registerRecallTool)(mcpServer, handle, getPlayerId);
285
300
  (0, encore_1.registerEncoreTool)(mcpServer, client, config, getPlayerId, handle);
301
+ (0, release_1.registerReleaseTool)(mcpServer, client, config, getPlayerId, handle);
302
+ (0, pause_ensemble_1.registerPauseEnsembleTool)(mcpServer, client, config, getPlayerId);
303
+ (0, resume_ensemble_1.registerResumeEnsembleTool)(mcpServer, client, config, getPlayerId);
286
304
  // Conductor-only tools
287
305
  if (isConductor) {
288
306
  (0, quality_gate_1.registerQualityGateTool)(mcpServer, handle, getPlayerId);
@@ -18,9 +18,11 @@ function registerLoadLineupTool(server, client, config, getPlayerId, ownAgentTyp
18
18
  (0, helpers_1.defineTool)(server, 'load_lineup', 'Load an ensemble lineup — recruits players and creates schedules.', {
19
19
  name: zod_1.z.string().max(validation_1.PLAYER_NAME_MAX).optional().describe('Name of a lineup — resolves saved lineups, then shipped examples (e.g. "tempo-dev-team")'),
20
20
  path: zod_1.z.string().max(validation_1.PATH_MAX).optional().describe('Explicit file path to a lineup YAML file'),
21
+ hold: zod_1.z.boolean().optional().describe('When true, create player workflows but do not spawn processes. Players stay in "held" status until explicitly released.'),
21
22
  }, async (args) => {
22
23
  const lineupName = args.name;
23
24
  const lineupPath = args.path;
25
+ const hold = args.hold === true;
24
26
  if (!lineupName && !lineupPath) {
25
27
  return (0, helpers_1.fail)('Provide either `name` (saved lineup) or `path` (file path). Exactly one is required.');
26
28
  }
@@ -106,6 +108,22 @@ function registerLoadLineupTool(server, client, config, getPlayerId, ownAgentTyp
106
108
  }
107
109
  }
108
110
  }
111
+ // When hold mode: tell conductor to wait for user direction.
112
+ // This runs regardless of whether the lineup has a `conductor:` section —
113
+ // what matters is that the caller IS the conductor and hold is active.
114
+ if (hold && isConductor && handle) {
115
+ try {
116
+ await handle.signal('receiveMessage', {
117
+ from: 'system',
118
+ text: 'Ensemble is loading in hold mode — players are connecting but on standby. Wait for instructions from the user or maestro before directing the ensemble. When ready, use the `release` tool to deliver task assignments to all held players.',
119
+ responseRequested: false,
120
+ });
121
+ conductorActions.push('hold mode standby');
122
+ }
123
+ catch (err) {
124
+ failed.push(`conductor hold message: ${err}`);
125
+ }
126
+ }
109
127
  // Recruit players via outbox — no polling needed
110
128
  for (const player of lineup.players) {
111
129
  const playerName = player.name;
@@ -157,6 +175,7 @@ function registerLoadLineupTool(server, client, config, getPlayerId, ownAgentTyp
157
175
  nativeResolvable: resolvedType?.nativeResolvable,
158
176
  allowedTools: player.allowedTools,
159
177
  claudeBin: config.claudeBin,
178
+ ...(hold ? { held: true } : {}),
160
179
  };
161
180
  await handle.executeUpdate(signals_1.submitOutboxUpdate, { args: [entry] });
162
181
  recruited.push(playerName);
@@ -265,7 +284,12 @@ function registerLoadLineupTool(server, client, config, getPlayerId, ownAgentTyp
265
284
  lines.push(`Conductor: ${conductorActions.join(', ')}`);
266
285
  }
267
286
  if (recruited.length > 0) {
268
- lines.push(`Recruited: ${recruited.join(', ')}`);
287
+ if (hold) {
288
+ lines.push(`Held: ${recruited.join(', ')} — ${recruited.length} player(s) held. Use \`release\` to start them.`);
289
+ }
290
+ else {
291
+ lines.push(`Recruited: ${recruited.join(', ')}`);
292
+ }
269
293
  }
270
294
  if (schedulesCreated.length > 0) {
271
295
  lines.push(`Schedules created: ${schedulesCreated.join(', ')}`);
@@ -0,0 +1,4 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import { Client } from '@temporalio/client';
3
+ import { Config } from '../config';
4
+ export declare function registerPauseEnsembleTool(server: McpServer, client: Client, config: Config, getPlayerId: () => string): void;
@@ -0,0 +1,58 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.registerPauseEnsembleTool = registerPauseEnsembleTool;
4
+ const config_1 = require("../config");
5
+ const resolve_1 = require("../activities/resolve");
6
+ const helpers_1 = require("./helpers");
7
+ const log = (...args) => console.error('[claude-tempo:pause]', ...args);
8
+ function registerPauseEnsembleTool(server, client, config, getPlayerId) {
9
+ (0, helpers_1.defineTool)(server, 'pause_ensemble', 'Pause all sessions in the ensemble — locks outbox dispatch and pauses the scheduler. Stop commands still go through. Use resume_ensemble to unpause.', {}, async () => {
10
+ try {
11
+ const results = [];
12
+ const errors = [];
13
+ // 1. Signal maestro with paused state (ground truth)
14
+ try {
15
+ const maestroId = (0, config_1.maestroWorkflowId)(config.ensemble);
16
+ const maestroHandle = client.workflow.getHandle(maestroId);
17
+ await maestroHandle.signal('maestroSetPaused', true);
18
+ results.push('maestro paused');
19
+ }
20
+ catch {
21
+ // Maestro may not be running
22
+ }
23
+ // 2. Signal all active sessions
24
+ const sessions = await (0, resolve_1.scanEnsembleSessions)(client, config.ensemble);
25
+ let sessionCount = 0;
26
+ for (const session of sessions) {
27
+ try {
28
+ const sessionHandle = client.workflow.getHandle(session.workflowId);
29
+ await sessionHandle.signal('setPaused', true);
30
+ sessionCount++;
31
+ }
32
+ catch (err) {
33
+ errors.push(`${session.playerId}: ${(0, helpers_1.formatError)(err)}`);
34
+ }
35
+ }
36
+ results.push(`${sessionCount} session(s) paused`);
37
+ // 3. Signal scheduler
38
+ try {
39
+ const schedulerId = (0, config_1.schedulerWorkflowId)(config.ensemble);
40
+ const schedulerHandle = client.workflow.getHandle(schedulerId);
41
+ await schedulerHandle.signal('setSchedulerPaused', true);
42
+ results.push('scheduler paused');
43
+ }
44
+ catch {
45
+ // Scheduler may not be running
46
+ }
47
+ const lines = [`Ensemble **${config.ensemble}** paused.`, results.join(', ')];
48
+ if (errors.length > 0) {
49
+ lines.push(`Errors:\n${errors.map((e) => ` - ${e}`).join('\n')}`);
50
+ }
51
+ log(`Paused ensemble "${config.ensemble}" by ${getPlayerId()}`);
52
+ return (0, helpers_1.ok)(lines.join('\n'));
53
+ }
54
+ catch (err) {
55
+ return (0, helpers_1.fail)(`Failed to pause ensemble: ${(0, helpers_1.formatError)(err)}`);
56
+ }
57
+ });
58
+ }
@@ -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 registerReleaseTool(server: McpServer, client: Client, config: Config, getPlayerId: () => string, handle: WorkflowHandle): void;
@@ -0,0 +1,98 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.registerReleaseTool = registerReleaseTool;
4
+ const zod_1 = require("zod");
5
+ const resolve_1 = require("../activities/resolve");
6
+ const signals_1 = require("../workflows/signals");
7
+ const helpers_1 = require("./helpers");
8
+ const validation_1 = require("../utils/validation");
9
+ function registerReleaseTool(server, client, config, getPlayerId, handle) {
10
+ (0, helpers_1.defineTool)(server, 'release', 'Release held player sessions — unlocks their outboxes and delivers deferred task messages. Without a player name, releases all held sessions.', {
11
+ player: zod_1.z.string().max(validation_1.PLAYER_NAME_MAX).optional()
12
+ .describe('Name of a specific held player to release. Omit to release all held players.'),
13
+ }, async (args) => {
14
+ const { player } = args;
15
+ if (player) {
16
+ const nameError = (0, validation_1.validatePlayerName)(player);
17
+ if (nameError)
18
+ return (0, helpers_1.fail)(nameError);
19
+ }
20
+ try {
21
+ if (player) {
22
+ // Release a specific player
23
+ const sessions = await (0, resolve_1.scanEnsembleSessions)(client, config.ensemble);
24
+ const target = sessions.find((s) => s.playerId === player);
25
+ if (!target) {
26
+ return (0, helpers_1.fail)(`No session found with name "${player}".`);
27
+ }
28
+ // Check if the session's outbox is actually locked
29
+ const targetHandle = client.workflow.getHandle(target.workflowId);
30
+ let isLocked = false;
31
+ try {
32
+ isLocked = await targetHandle.query(signals_1.outboxLockedQuery);
33
+ }
34
+ catch {
35
+ // Query may fail for old workflows without the handler — not held
36
+ }
37
+ if (!isLocked) {
38
+ return (0, helpers_1.fail)(`Session "${player}" is not held (outbox not locked). Only held sessions can be released.`);
39
+ }
40
+ const entry = {
41
+ type: 'release',
42
+ targetPlayerId: player,
43
+ };
44
+ const entryId = await handle.executeUpdate(signals_1.submitOutboxUpdate, { args: [entry] });
45
+ return (0, helpers_1.ok)(`Release request submitted for **${player}**. Task assignment will be delivered shortly. (outbox: ${entryId})`);
46
+ }
47
+ else {
48
+ // Release all held players — scan ensemble and check outboxLocked on each
49
+ const sessions = await (0, resolve_1.scanEnsembleSessions)(client, config.ensemble);
50
+ const heldSessions = [];
51
+ for (const session of sessions) {
52
+ // Skip self and conductors (conductor outbox is never locked)
53
+ if (session.playerId === getPlayerId())
54
+ continue;
55
+ try {
56
+ const sessionHandle = client.workflow.getHandle(session.workflowId);
57
+ const isLocked = await sessionHandle.query(signals_1.outboxLockedQuery);
58
+ if (isLocked) {
59
+ heldSessions.push(session);
60
+ }
61
+ }
62
+ catch {
63
+ // Skip sessions where query fails (old workflows, terminated, etc.)
64
+ }
65
+ }
66
+ if (heldSessions.length === 0) {
67
+ return (0, helpers_1.ok)('No held sessions found. Nothing to release.');
68
+ }
69
+ const released = [];
70
+ const errors = [];
71
+ for (const session of heldSessions) {
72
+ try {
73
+ const entry = {
74
+ type: 'release',
75
+ targetPlayerId: session.playerId,
76
+ };
77
+ await handle.executeUpdate(signals_1.submitOutboxUpdate, { args: [entry] });
78
+ released.push(session.playerId);
79
+ }
80
+ catch (err) {
81
+ errors.push(`${session.playerId}: ${(0, helpers_1.formatError)(err)}`);
82
+ }
83
+ }
84
+ const lines = [];
85
+ if (released.length > 0) {
86
+ lines.push(`Released ${released.length} player(s): ${released.join(', ')}`);
87
+ }
88
+ if (errors.length > 0) {
89
+ lines.push(`Errors:\n${errors.map((e) => ` - ${e}`).join('\n')}`);
90
+ }
91
+ return (0, helpers_1.ok)(lines.join('\n'));
92
+ }
93
+ }
94
+ catch (err) {
95
+ return (0, helpers_1.fail)(`Failed to release: ${(0, helpers_1.formatError)(err)}`);
96
+ }
97
+ });
98
+ }
@@ -0,0 +1,4 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import { Client } from '@temporalio/client';
3
+ import { Config } from '../config';
4
+ export declare function registerResumeEnsembleTool(server: McpServer, client: Client, config: Config, getPlayerId: () => string): void;