claude-tempo 0.19.0 → 0.20.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
@@ -20,7 +20,7 @@ src/
20
20
  ├── cli.ts # CLI entry point (claude-tempo command)
21
21
  ├── daemon.ts # Daemon entry point — runs Temporal workers as a detached background process
22
22
  ├── cli/
23
- │ ├── commands.ts # CLI command implementations (up, start, conduct, status, stop, …)
23
+ │ ├── commands.ts # CLI command implementations (up, start, conduct, status, stop, upgrade, …)
24
24
  │ ├── config-command.ts # config subcommand (interactive + set/show)
25
25
  │ ├── daemon.ts # Daemon management utilities (start, stop, status, logs, isDaemonRunning)
26
26
  │ ├── mcp.ts # MCP server registration helpers (init, global vs project)
@@ -137,7 +137,7 @@ npm test
137
137
  - **Part**: A player's description of what it's working on
138
138
  - **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.
139
139
  - **set_name**: Players start with a random hex ID; `set_name` updates the `ClaudeTempoPlayerId` search attribute to a human-readable name
140
- - **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.
140
+ - **Session status**: Each session has a status (`pending` → `active` → `stale` | `blocked`) 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. Sessions become `blocked` when they are alive (delivering messages) but have produced no response to a `responseRequested: true` message for 5+ minutes — they may be stuck or spinning. Informational messages (broadcasts, schedule-fires, heartbeats, system notifications) set `responseRequested: false` and do not trigger blocked detection. Blocked status auto-recovers to `active` on next outbound.
141
141
  - **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.
142
142
  - **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.
143
143
  - **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.
@@ -146,7 +146,7 @@ npm test
146
146
  - **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`.
147
147
  - **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).
148
148
  - **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.
149
- - **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.
149
+ - **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; `load_lineup` resolves the lineup by name using a three-tier lookup: saved lineups → shipped examples → file path. Save via `save_lineup` to snapshot a running ensemble's state for later reuse.
150
150
  - **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`.
151
151
  - **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).
152
152
  - **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`.
package/README.md CHANGED
@@ -145,7 +145,8 @@ Inside the TUI, type `/help` for slash commands. Inside Claude Code, use the `en
145
145
  | `claude-tempo start [ensemble]` | Open a player session |
146
146
  | `claude-tempo conduct [ensemble]` | Start a conductor session |
147
147
  | `claude-tempo status` | Show active sessions |
148
- | `claude-tempo down` | Stop everything |
148
+ | `claude-tempo stop [ensemble]` | Stop sessions (recoverable via `encore`) |
149
+ | `claude-tempo down` | Full teardown — sessions, daemon, and Temporal |
149
150
 
150
151
  **Lineups**
151
152
  | Command | Description |
@@ -165,6 +166,7 @@ Inside the TUI, type `/help` for slash commands. Inside Claude Code, use the `en
165
166
  | Command | Description |
166
167
  |---------|-------------|
167
168
  | `claude-tempo daemon start\|stop\|status\|logs` | Manage the worker daemon |
169
+ | `claude-tempo upgrade [version]` | Graceful self-update (stops daemon, installs, restarts) |
168
170
  | `claude-tempo config` | Configure env vars interactively |
169
171
  | `claude-tempo preflight` | Verify environment |
170
172
 
@@ -92,6 +92,7 @@ function createOutboxActivities(client, config) {
92
92
  await conductorHandle.signal('receiveMessage', {
93
93
  from: 'system',
94
94
  text: `Session "${targetPlayerId}" was terminated by ${terminatedBy}.`,
95
+ responseRequested: false,
95
96
  });
96
97
  }
97
98
  catch {
@@ -265,7 +266,7 @@ function createOutboxActivities(client, config) {
265
266
  'Resume where you left off. Use `ensemble` to see who is active.',
266
267
  ].filter(Boolean).join('\n');
267
268
  // Inject context message (status already set to pending atomically above)
268
- await handle.signal('receiveMessage', { from: fromPlayerId, text: contextMessage });
269
+ await handle.signal('receiveMessage', { from: fromPlayerId, text: contextMessage, responseRequested: false });
269
270
  log(`Encore prepared for "${targetPlayerId}" — status reset to pending, context injected`);
270
271
  // Return spawn parameters from the target's metadata
271
272
  const agentType = metadata.agentType || 'claude';
@@ -37,6 +37,7 @@ function createScheduleActivities(client) {
37
37
  text,
38
38
  isScheduled: true,
39
39
  scheduleName,
40
+ responseRequested: false,
40
41
  });
41
42
  delivered++;
42
43
  }
@@ -67,6 +68,7 @@ function createScheduleActivities(client) {
67
68
  text,
68
69
  isScheduled: true,
69
70
  scheduleName,
71
+ responseRequested: false,
70
72
  });
71
73
  return { success: true };
72
74
  }
@@ -92,6 +94,7 @@ async function notifyFailure(client, ensemble, createdBy, scheduleName, target,
92
94
  text: failureText,
93
95
  isScheduled: true,
94
96
  scheduleName,
97
+ responseRequested: false,
95
98
  });
96
99
  return;
97
100
  }
@@ -107,6 +110,7 @@ async function notifyFailure(client, ensemble, createdBy, scheduleName, target,
107
110
  from: 'scheduler',
108
111
  text: failureText,
109
112
  isScheduled: true,
113
+ responseRequested: false,
110
114
  scheduleName,
111
115
  });
112
116
  }
@@ -36,6 +36,8 @@ interface DownOpts extends CliOverrides {
36
36
  ensemble?: string;
37
37
  all: boolean;
38
38
  removeMcp: boolean;
39
+ keepDaemon: boolean;
40
+ yes: boolean;
39
41
  dir: string;
40
42
  }
41
43
  export declare function down(opts: DownOpts): Promise<void>;
@@ -77,4 +79,8 @@ interface DaemonOpts extends CliOverrides {
77
79
  export declare function daemon(opts: DaemonOpts): Promise<void>;
78
80
  export declare function help(): void;
79
81
  export declare function version(): void;
82
+ interface UpgradeOpts extends CliOverrides {
83
+ version?: string;
84
+ }
85
+ export declare function upgrade(opts: UpgradeOpts): Promise<void>;
80
86
  export {};
@@ -47,6 +47,8 @@ exports.ensembleCommand = ensembleCommand;
47
47
  exports.daemon = daemon;
48
48
  exports.help = help;
49
49
  exports.version = version;
50
+ exports.upgrade = upgrade;
51
+ const readline = __importStar(require("readline"));
50
52
  const fs_1 = require("fs");
51
53
  const path_1 = require("path");
52
54
  const child_process_1 = require("child_process");
@@ -652,59 +654,107 @@ async function up(opts) {
652
654
  let lineup;
653
655
  const lineupArg = opts.lineup;
654
656
  if (lineupArg) {
655
- // Resolve by name or file path
656
- let lineupPath;
657
- if ((0, fs_1.existsSync)((0, path_1.resolve)(lineupArg))) {
658
- // Direct file path
659
- lineupPath = (0, path_1.resolve)(lineupArg);
657
+ try {
658
+ const resolution = (0, loader_1.resolveLineupPath)(lineupArg);
659
+ lineup = (0, loader_1.loadLineup)(resolution.path);
660
660
  }
661
- else {
662
- // Try saved lineups (~/.claude-tempo/ensembles/)
663
- const ensemblesDir = (0, path_1.join)(config_1.CLAUDE_TEMPO_HOME, 'ensembles');
664
- const savedYaml = (0, path_1.join)(ensemblesDir, `${lineupArg}.yaml`);
665
- const savedYml = (0, path_1.join)(ensemblesDir, `${lineupArg}.yml`);
666
- if ((0, fs_1.existsSync)(savedYaml)) {
667
- lineupPath = savedYaml;
668
- }
669
- else if ((0, fs_1.existsSync)(savedYml)) {
670
- lineupPath = savedYml;
671
- }
672
- else {
673
- // Try shipped examples
674
- const shippedPath = (0, path_1.join)(PACKAGE_ROOT, 'examples', 'ensembles', `${lineupArg}.yaml`);
675
- const shippedYml = (0, path_1.join)(PACKAGE_ROOT, 'examples', 'ensembles', `${lineupArg}.yml`);
676
- if ((0, fs_1.existsSync)(shippedPath)) {
677
- lineupPath = shippedPath;
678
- }
679
- else if ((0, fs_1.existsSync)(shippedYml)) {
680
- lineupPath = shippedYml;
681
- }
682
- else {
683
- out.error(`Lineup "${lineupArg}" not found as file, saved lineup, or shipped example`);
684
- const saved = (0, saver_1.listLineups)();
685
- if (saved.length)
686
- out.log(` Saved: ${saved.map(b => b.name).join(', ')}`);
687
- const shipped = (0, fs_1.readdirSync)((0, path_1.join)(PACKAGE_ROOT, 'examples', 'ensembles')).filter(f => f.endsWith('.yaml') || f.endsWith('.yml')).map(f => f.replace(/\.ya?ml$/, ''));
688
- if (shipped.length)
689
- out.log(` Shipped: ${shipped.join(', ')}`);
690
- process.exit(1);
691
- }
692
- }
661
+ catch (err) {
662
+ out.error(err.message);
663
+ process.exit(1);
693
664
  }
694
- lineup = (0, loader_1.loadLineup)(lineupPath);
695
665
  }
696
666
  if (lineup) {
697
667
  out.check('Lineup loaded', true, lineup.name);
698
668
  }
699
669
  // Resolve conductor agent from lineup or CLI flags
700
670
  const conductorAgent = lineup?.conductor?.agent === 'copilot' ? 'copilot' : opts.agent;
701
- // Step 5: Connect to Temporal and pre-create conductor workflow before spawning
671
+ // Step 5: Connect to Temporal and check for existing conductor
702
672
  console.log();
703
- out.log(`Launching conductor in ensemble ${out.cyan(opts.ensemble)}${conductorAgent === 'copilot' ? out.dim(' (copilot)') : ''}...`);
704
673
  const connection = await (0, connection_1.createTemporalConnection)(config);
705
674
  const client = new client_1.Client({ connection, namespace: config.temporalNamespace });
706
- const sessionName = opts.name || lineup?.conductor?.name || (conductorAgent === 'copilot' ? `${opts.ensemble}-conductor` : 'conductor');
707
675
  const conductorWfId = (0, config_1.conductorWorkflowId)(opts.ensemble);
676
+ // Check if a conductor is already running
677
+ try {
678
+ const existingHandle = client.workflow.getHandle(conductorWfId);
679
+ const desc = await existingHandle.describe();
680
+ if (desc.status.name === 'RUNNING') {
681
+ if (!process.stdin.isTTY) {
682
+ out.error(`A conductor is already running for ensemble "${opts.ensemble}".`);
683
+ out.log(` Use ${out.dim('--resume')} to reconnect, or ${out.dim('claude-tempo start')} to join as a player.`);
684
+ process.exit(1);
685
+ }
686
+ out.warn(`A conductor is already running for ensemble "${opts.ensemble}".`);
687
+ console.log();
688
+ out.log(` 1) Join as a new player session`);
689
+ out.log(` 2) Reconnect to the existing conductor (--resume)`);
690
+ out.log(` 3) Tear down and start fresh`);
691
+ out.log(` 4) Cancel`);
692
+ console.log();
693
+ const choice = await new Promise((res) => {
694
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
695
+ rl.question(` ${out.cyan('?')} Choose an option [1-4]: `, (answer) => {
696
+ rl.close();
697
+ res(answer.trim());
698
+ });
699
+ });
700
+ switch (choice) {
701
+ case '1':
702
+ // Join as a player — delegate to start()
703
+ console.log();
704
+ out.log('Joining as a player session...');
705
+ await start({
706
+ ensemble: opts.ensemble,
707
+ conductor: false,
708
+ name: opts.name,
709
+ skipPreflight: true, // infrastructure already verified above
710
+ agent: opts.agent,
711
+ dir: process.cwd(),
712
+ });
713
+ return;
714
+ case '2':
715
+ // Reconnect to existing conductor
716
+ console.log();
717
+ out.log('Reconnecting to existing conductor...');
718
+ await start({
719
+ ensemble: opts.ensemble,
720
+ conductor: true,
721
+ resume: true,
722
+ name: opts.name,
723
+ skipPreflight: true,
724
+ agent: opts.agent,
725
+ dir: process.cwd(),
726
+ });
727
+ return;
728
+ case '3':
729
+ // Terminate existing workflows, then fall through to normal up flow
730
+ console.log();
731
+ try {
732
+ await client.workflow.getHandle(conductorWfId).terminate('up: fresh start');
733
+ }
734
+ catch { /* may not exist */ }
735
+ try {
736
+ await client.workflow.getHandle((0, config_1.schedulerWorkflowId)(opts.ensemble)).terminate('up: fresh start');
737
+ }
738
+ catch { /* may not exist */ }
739
+ try {
740
+ await client.workflow.getHandle((0, config_1.maestroWorkflowId)(opts.ensemble)).terminate('up: fresh start');
741
+ }
742
+ catch { /* may not exist */ }
743
+ out.success('Existing ensemble torn down');
744
+ // Fall through to normal up flow below
745
+ break;
746
+ case '4':
747
+ default:
748
+ out.log('Cancelled.');
749
+ process.exit(0);
750
+ }
751
+ }
752
+ }
753
+ catch {
754
+ // No existing conductor — proceed normally
755
+ }
756
+ out.log(`Launching conductor in ensemble ${out.cyan(opts.ensemble)}${conductorAgent === 'copilot' ? out.dim(' (copilot)') : ''}...`);
757
+ const sessionName = opts.name || lineup?.conductor?.name || (conductorAgent === 'copilot' ? `${opts.ensemble}-conductor` : 'conductor');
708
758
  // Resolve conductor agent type from lineup
709
759
  const conductorType = lineup?.conductor?.agent && lineup.conductor.agent !== 'default' && lineup.conductor.agent !== 'copilot'
710
760
  ? lineup.conductor.agent
@@ -989,6 +1039,20 @@ function parseDuration(s) {
989
1039
  default: throw new Error(`Unknown duration unit: "${match[2]}"`);
990
1040
  }
991
1041
  }
1042
+ /** Prompt the user for y/n confirmation. Exits with code 1 in non-TTY environments. */
1043
+ async function confirmPrompt(message) {
1044
+ if (!process.stdin.isTTY) {
1045
+ out.error('Non-interactive environment: use --yes / -y to confirm teardown.');
1046
+ process.exit(1);
1047
+ }
1048
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
1049
+ return new Promise((resolve) => {
1050
+ rl.question(`${message} [y/N] `, (answer) => {
1051
+ rl.close();
1052
+ resolve(answer.trim().toLowerCase() === 'y');
1053
+ });
1054
+ });
1055
+ }
992
1056
  async function down(opts) {
993
1057
  const config = (0, config_1.getConfig)(opts);
994
1058
  let ensembleName = opts.ensemble;
@@ -1017,14 +1081,42 @@ async function down(opts) {
1017
1081
  }
1018
1082
  else if (runningEnsembles.size === 1) {
1019
1083
  ensembleName = [...runningEnsembles][0];
1084
+ // Auto-detected ensemble requires confirmation unless --yes
1085
+ if (!opts.yes) {
1086
+ out.heading('claude-tempo teardown');
1087
+ out.log(` This will destroy ensemble ${out.bold(ensembleName)}: all sessions, daemon, and Temporal server.`);
1088
+ const confirmed = await confirmPrompt('Proceed?');
1089
+ if (!confirmed) {
1090
+ out.log('Aborted.');
1091
+ process.exit(0);
1092
+ }
1093
+ }
1020
1094
  }
1021
1095
  else {
1022
- out.error(`Multiple ensembles running. Please specify which one to tear down:`);
1023
- for (const name of [...runningEnsembles].sort()) {
1024
- out.log(` - claude-tempo down ${name}`);
1096
+ // Multiple ensembles: require confirmation or --yes for each
1097
+ if (!opts.yes) {
1098
+ out.heading('claude-tempo teardown');
1099
+ out.log(` Multiple ensembles running:`);
1100
+ for (const name of [...runningEnsembles].sort()) {
1101
+ out.log(` - ${name}`);
1102
+ }
1103
+ out.log('');
1104
+ out.log(` This will destroy ${out.bold('all')} of them: sessions, daemon, and Temporal server.`);
1105
+ const confirmed = await confirmPrompt('Proceed?');
1106
+ if (!confirmed) {
1107
+ out.log('Aborted. To tear down a specific ensemble:');
1108
+ for (const name of [...runningEnsembles].sort()) {
1109
+ out.log(` - claude-tempo down ${name}`);
1110
+ }
1111
+ process.exit(0);
1112
+ }
1113
+ // Treat as --all since user confirmed tearing down everything
1114
+ opts.all = true;
1115
+ }
1116
+ else {
1117
+ // --yes with multiple ensembles: treat as --all
1118
+ opts.all = true;
1025
1119
  }
1026
- out.log(` - claude-tempo down --all`);
1027
- process.exit(1);
1028
1120
  }
1029
1121
  }
1030
1122
  catch (err) {
@@ -1132,9 +1224,13 @@ async function down(opts) {
1132
1224
  }
1133
1225
  // Step 2: Kill bridge processes via PID files
1134
1226
  killBridgeProcesses();
1135
- // Step 2.5: Stop worker daemon — only if --all or no other workflows remain
1136
- // hasRemainingWorkflows is only meaningful when Temporal was reachable
1137
- if (opts.all || (temporalUp && !hasRemainingWorkflows)) {
1227
+ // Step 2.5: Stop worker daemon — unless --keep-daemon or other ensembles still active
1228
+ if (opts.keepDaemon) {
1229
+ if ((0, daemon_1.isDaemonRunning)()) {
1230
+ out.log(` ${out.dim('Worker daemon left running (--keep-daemon)')}`);
1231
+ }
1232
+ }
1233
+ else if (opts.all || !hasRemainingWorkflows) {
1138
1234
  if ((0, daemon_1.stopDaemon)()) {
1139
1235
  out.success('Worker daemon stopped');
1140
1236
  }
@@ -1549,6 +1645,7 @@ async function broadcast(opts) {
1549
1645
  await handle.signal('receiveMessage', {
1550
1646
  from: 'cli',
1551
1647
  text: opts.message,
1648
+ responseRequested: false,
1552
1649
  });
1553
1650
  sent++;
1554
1651
  out.log(` ${out.green('✓')} ${target.playerId}`);
@@ -1629,7 +1726,7 @@ async function encore(opts) {
1629
1726
  ].filter(Boolean).join('\n');
1630
1727
  // Reset status and inject context message
1631
1728
  await targetHandle.signal('updateMetadata', { status: 'pending' });
1632
- await targetHandle.signal('receiveMessage', { from: 'system', text: contextMessage });
1729
+ await targetHandle.signal('receiveMessage', { from: 'system', text: contextMessage, responseRequested: false });
1633
1730
  out.log(`Reviving "${opts.name}" in ${targetMeta.workDir}...`);
1634
1731
  // Resolve agent flags
1635
1732
  let agentFlags = [];
@@ -1823,7 +1920,7 @@ ${out.bold('Usage:')}
1823
1920
 
1824
1921
  ${out.bold('Commands:')}
1825
1922
  ${out.cyan('up')} [ensemble] First-time setup: start Temporal, configure MCP, launch conductor
1826
- ${out.cyan('down')} Stop Temporal, terminate sessions, remove MCP config
1923
+ ${out.cyan('down')} [ensemble] Tear down everything: sessions, daemon, Temporal server, MCP config
1827
1924
  ${out.cyan('server')} Start the Temporal dev server and register search attributes
1828
1925
  ${out.cyan('conduct')} [ensemble] Start a conductor session (resumes existing, --replace to restart)
1829
1926
  ${out.cyan('start')} [ensemble] Start a player session
@@ -1834,6 +1931,7 @@ ${out.bold('Commands:')}
1834
1931
  ${out.cyan('encore')} <name> Revive a stale player session (reconnect with context)
1835
1932
  ${out.cyan('agent-types')} <sub> Manage player type definitions (list/show/init)
1836
1933
  ${out.cyan('daemon')} <sub> Manage the worker daemon (start/stop/status/logs)
1934
+ ${out.cyan('upgrade')} [version] Upgrade claude-tempo to latest (or specific version)
1837
1935
  ${out.cyan('config')} Configure Temporal connection settings
1838
1936
  ${out.cyan('init')} Register MCP server globally (or --project for .mcp.json)
1839
1937
  ${out.cyan('preflight')} Run preflight checks only
@@ -1853,9 +1951,11 @@ ${out.bold('Other options:')}
1853
1951
  --background Run Temporal in background (server only)
1854
1952
  --project Use per-project .mcp.json instead of global (init only)
1855
1953
  --keep-mcp Don't remove MCP config (down only)
1954
+ --keep-daemon Don't stop the worker daemon (down only)
1955
+ -y, --yes Skip confirmation prompt (down only)
1856
1956
  --all Stop all sessions (stop only)
1857
1957
  --lineup <name|file> Load ensemble lineup by name or file path (up only)
1858
- --ensemble <name> Target a specific ensemble (stop only)
1958
+ --ensemble <name> Target a specific ensemble (stop/down)
1859
1959
  -d, --dir <path> Target directory (default: cwd)
1860
1960
 
1861
1961
  ${out.bold('Config command:')}
@@ -1898,3 +1998,170 @@ function version() {
1898
1998
  out.log('claude-tempo (unknown version)');
1899
1999
  }
1900
2000
  }
2001
+ async function upgrade(opts) {
2002
+ const config = (0, config_1.getConfig)(opts);
2003
+ const targetVersion = opts.version || 'latest';
2004
+ const installSpec = targetVersion === 'latest' ? 'claude-tempo' : `claude-tempo@${targetVersion}`;
2005
+ // Read current version
2006
+ let currentVersion = 'unknown';
2007
+ try {
2008
+ const pkg = JSON.parse((0, fs_1.readFileSync)((0, path_1.join)(PACKAGE_ROOT, 'package.json'), 'utf8'));
2009
+ currentVersion = pkg.version || 'unknown';
2010
+ }
2011
+ catch { /* ignore */ }
2012
+ out.heading('claude-tempo upgrade');
2013
+ out.log(` Current: v${currentVersion}`);
2014
+ out.log(` Target: ${targetVersion}`);
2015
+ console.log();
2016
+ // Check for active sessions — warn the user
2017
+ let activeSessions = 0;
2018
+ try {
2019
+ const connection = await Promise.race([
2020
+ (0, connection_1.createTemporalConnection)(config),
2021
+ new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 3000)),
2022
+ ]);
2023
+ const client = new client_1.Client({ connection, namespace: config.temporalNamespace });
2024
+ const query = 'WorkflowType = "claudeSessionWorkflow" AND ExecutionStatus = "Running"';
2025
+ for await (const _wf of client.workflow.list({ query })) {
2026
+ activeSessions++;
2027
+ }
2028
+ }
2029
+ catch {
2030
+ // Can't connect to Temporal — that's fine, proceed with upgrade
2031
+ }
2032
+ if (activeSessions > 0) {
2033
+ out.warn(`${activeSessions} active session(s) detected. They will lose daemon connectivity during upgrade.`);
2034
+ out.log(` ${out.dim('Consider running: claude-tempo stop --all')}`);
2035
+ console.log();
2036
+ }
2037
+ // Stop the daemon gracefully (releases .node file locks)
2038
+ const daemonWasRunning = (0, daemon_1.isDaemonRunning)();
2039
+ if (daemonWasRunning) {
2040
+ out.log('Stopping daemon...');
2041
+ (0, daemon_1.stopDaemon)();
2042
+ // Wait for process to exit — important on Windows for file lock release
2043
+ const deadline = Date.now() + 5000;
2044
+ while (Date.now() < deadline && (0, daemon_1.isDaemonRunning)()) {
2045
+ await new Promise(r => setTimeout(r, 200));
2046
+ }
2047
+ if ((0, daemon_1.isDaemonRunning)()) {
2048
+ out.error('Daemon did not stop in time. Try: claude-tempo daemon stop');
2049
+ process.exit(1);
2050
+ }
2051
+ out.success('Daemon stopped');
2052
+ }
2053
+ // Build the detached updater script.
2054
+ // This is a self-contained Node.js script (no native module deps) that:
2055
+ // 1. Waits for the CLI process to exit (by PID)
2056
+ // 2. Runs npm install -g
2057
+ // 3. Verifies the install
2058
+ // 4. Restarts the daemon
2059
+ const cliPid = process.pid;
2060
+ const isWin = process.platform === 'win32';
2061
+ const updaterScript = `
2062
+ const { execFileSync } = require('child_process');
2063
+ const fs = require('fs');
2064
+
2065
+ const PID = ${cliPid};
2066
+ const INSTALL_SPEC = ${JSON.stringify(installSpec)};
2067
+ const TARGET = ${JSON.stringify(targetVersion)};
2068
+ const IS_WIN = ${isWin};
2069
+ const LOG_PATH = ${JSON.stringify((0, path_1.join)(config_1.CLAUDE_TEMPO_HOME, 'upgrade.log'))};
2070
+
2071
+ function log(msg) {
2072
+ const line = new Date().toISOString() + ' ' + msg;
2073
+ try { fs.appendFileSync(LOG_PATH, line + '\\n'); } catch {}
2074
+ console.log(msg);
2075
+ }
2076
+
2077
+ function isPidAlive(pid) {
2078
+ try { process.kill(pid, 0); return true; }
2079
+ catch { return false; }
2080
+ }
2081
+
2082
+ async function main() {
2083
+ // Wait for CLI process to exit (up to 10s)
2084
+ log('Waiting for CLI process (pid ' + PID + ') to exit...');
2085
+ const deadline = Date.now() + 10000;
2086
+ while (Date.now() < deadline && isPidAlive(PID)) {
2087
+ await new Promise(r => setTimeout(r, 300));
2088
+ }
2089
+ if (isPidAlive(PID)) {
2090
+ log('WARNING: CLI process still alive after 10s, proceeding anyway');
2091
+ }
2092
+
2093
+ // Run npm install -g
2094
+ log('Installing ' + INSTALL_SPEC + '...');
2095
+ try {
2096
+ const npmCmd = IS_WIN ? 'npm.cmd' : 'npm';
2097
+ execFileSync(npmCmd, ['install', '-g', INSTALL_SPEC], {
2098
+ stdio: 'inherit',
2099
+ timeout: 120000,
2100
+ });
2101
+ log('Install completed');
2102
+ } catch (err) {
2103
+ log('Install FAILED: ' + err.message);
2104
+ log('Recovery: npm install -g ' + INSTALL_SPEC);
2105
+ process.exit(1);
2106
+ }
2107
+
2108
+ // Verify installation
2109
+ try {
2110
+ const tempoCmd = IS_WIN ? 'claude-tempo.cmd' : 'claude-tempo';
2111
+ const ver = execFileSync(tempoCmd, ['--version'], {
2112
+ encoding: 'utf8',
2113
+ timeout: 10000,
2114
+ }).trim();
2115
+ log('Verified: ' + ver);
2116
+ } catch (err) {
2117
+ log('WARNING: Could not verify installation: ' + err.message);
2118
+ log('Recovery: npm install -g claude-tempo');
2119
+ }
2120
+
2121
+ // Restart the daemon
2122
+ log('Restarting daemon...');
2123
+ try {
2124
+ const tempoCmd = IS_WIN ? 'claude-tempo.cmd' : 'claude-tempo';
2125
+ execFileSync(tempoCmd, ['daemon', 'start'], {
2126
+ stdio: 'inherit',
2127
+ timeout: 30000,
2128
+ });
2129
+ log('Daemon restarted');
2130
+ } catch (err) {
2131
+ log('WARNING: Daemon restart failed: ' + err.message);
2132
+ log('Run manually: claude-tempo daemon start');
2133
+ }
2134
+
2135
+ log('Upgrade complete!');
2136
+ }
2137
+
2138
+ main().catch(err => {
2139
+ log('Upgrade failed: ' + err.message);
2140
+ process.exit(1);
2141
+ });
2142
+ `.trim();
2143
+ // Clear previous upgrade log before spawning
2144
+ const logPath = (0, path_1.join)(config_1.CLAUDE_TEMPO_HOME, 'upgrade.log');
2145
+ try {
2146
+ (0, fs_1.writeFileSync)(logPath, '');
2147
+ }
2148
+ catch { /* ignore */ }
2149
+ // Spawn the updater as a detached child process
2150
+ out.log(`Spawning upgrade process for ${out.cyan(installSpec)}...`);
2151
+ const child = (0, child_process_1.spawn)(process.execPath, ['-e', updaterScript], {
2152
+ detached: true,
2153
+ stdio: 'ignore',
2154
+ env: { ...process.env },
2155
+ });
2156
+ child.unref();
2157
+ console.log();
2158
+ out.success('Upgrade started in background');
2159
+ out.log(` ${out.dim('Monitor progress: ')}`);
2160
+ if (isWin) {
2161
+ out.log(` ${out.dim('powershell Get-Content -Wait "' + logPath + '"')}`);
2162
+ }
2163
+ else {
2164
+ out.log(` ${out.dim('tail -f ' + logPath)}`);
2165
+ }
2166
+ out.log(` ${out.dim('If it fails, run manually: npm install -g ' + installSpec)}`);
2167
+ }
package/dist/cli.js CHANGED
@@ -47,6 +47,8 @@ function parseArgs(argv) {
47
47
  skipPreflight: false,
48
48
  background: false,
49
49
  keepMcp: false,
50
+ keepDaemon: false,
51
+ yes: false,
50
52
  all: false,
51
53
  project: false,
52
54
  replace: false,
@@ -89,6 +91,12 @@ function parseArgs(argv) {
89
91
  else if (arg === '--keep-mcp') {
90
92
  result.keepMcp = true;
91
93
  }
94
+ else if (arg === '--keep-daemon') {
95
+ result.keepDaemon = true;
96
+ }
97
+ else if (arg === '--yes' || arg === '-y') {
98
+ result.yes = true;
99
+ }
92
100
  else if (arg === '--all') {
93
101
  result.all = true;
94
102
  }
@@ -208,6 +216,8 @@ async function main() {
208
216
  ensemble: args.ensemble || args.positional[1] || process.env[config_1.ENV.ENSEMBLE],
209
217
  all: args.all,
210
218
  removeMcp: !args.keepMcp,
219
+ keepDaemon: args.keepDaemon,
220
+ yes: args.yes,
211
221
  dir: args.dir,
212
222
  ...overrides,
213
223
  });
@@ -297,6 +307,12 @@ async function main() {
297
307
  await runTui({ config, ensemble });
298
308
  break;
299
309
  }
310
+ case 'upgrade':
311
+ await (0, commands_1.upgrade)({
312
+ version: args.positional[1], // e.g. "0.20.0" or "latest" or undefined
313
+ ...overrides,
314
+ });
315
+ break;
300
316
  case 'version':
301
317
  (0, commands_1.version)();
302
318
  break;
@@ -1,4 +1,13 @@
1
1
  import { EnsembleLineup } from './schema';
2
+ export interface LineupResolution {
3
+ path: string;
4
+ source: 'saved' | 'shipped' | 'file';
5
+ }
6
+ /**
7
+ * Resolve a lineup name or file path to an absolute file path.
8
+ * Resolution order: saved lineups → shipped examples → direct file path → error.
9
+ */
10
+ export declare function resolveLineupPath(nameOrPath: string): LineupResolution;
2
11
  /**
3
12
  * Load and validate an ensemble lineup from a YAML file.
4
13
  */