claude-tempo 0.8.0 → 0.9.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
@@ -21,16 +21,19 @@ src/
21
21
  ├── workflows/
22
22
  │ ├── session.ts # claude-session workflow
23
23
  │ └── signals.ts # Signal/query type definitions
24
+ ├── activities/
25
+ │ ├── outbox.ts # Outbox delivery activities (cue, report, stop, recruit)
26
+ │ └── schedule-fire.ts # Schedule fire activity
24
27
  ├── tools/
25
28
  │ ├── ensemble.ts # Discover active sessions
26
- │ ├── cue.ts # Send message to peer
29
+ │ ├── cue.ts # Send message to peer (via outbox)
27
30
  │ ├── set-name.ts # Set session name
28
31
  │ ├── set-part.ts # Update own summary
29
32
  │ ├── resolve.ts # Search-attribute session lookup
30
33
  │ ├── listen.ts # Manual message check
31
- │ ├── recruit.ts # Spawn new session
32
- │ ├── report.ts # Report to conductor
33
- │ ├── stop.ts # Stop a session
34
+ │ ├── recruit.ts # Spawn new session (via outbox)
35
+ │ ├── report.ts # Report to conductor (via outbox)
36
+ │ ├── stop.ts # Stop a session (via outbox)
34
37
  │ └── helpers.ts # Zod/MCP tool registration wrapper
35
38
  ├── types.ts # Shared type definitions
36
39
  ├── channel.ts # Claude channel notification helper
@@ -60,6 +63,10 @@ npm test
60
63
  > **Important**: Always run `npm run build` after changing workflow code (`src/workflows/`).
61
64
  > The build pre-bundles workflows into `workflow-bundle.js` so all workers use identical code.
62
65
 
66
+ > **Dual workers**: Each session runs two Temporal workers — a shared `claude-tempo` queue
67
+ > (workflows + delivery activities) and a per-host `claude-tempo-{hostname}` queue (spawn activities only).
68
+ > Both are created via `createWorkers()` in `src/worker.ts`.
69
+
63
70
  ## Key Concepts
64
71
 
65
72
  - **Player**: A Claude Code session registered as a Temporal workflow
@@ -70,6 +77,8 @@ npm test
70
77
  - **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.
71
78
  - **set_name**: Players start with a random hex ID; `set_name` updates the `ClaudeTempoPlayerId` search attribute to a human-readable name
72
79
  - **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.
80
+ - **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.
81
+ - **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.
73
82
 
74
83
  ## Dashboard
75
84
 
Binary file
Binary file
Binary file
Binary file
@@ -0,0 +1,9 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
2
+ <!-- Ultra-minimal metronome icon: triangle + pendulum (dark mode) -->
3
+ <!-- Metronome body — single-stroke triangle -->
4
+ <path d="M32 8 L14 54 L50 54 Z" stroke="#FAF3EE" stroke-width="3" fill="none" stroke-linejoin="round"/>
5
+ <!-- Pendulum arm (angled right) -->
6
+ <line x1="32" y1="46" x2="44" y2="14" stroke="#E07A5F" stroke-width="3" stroke-linecap="round"/>
7
+ <!-- Pivot dot -->
8
+ <circle cx="32" cy="46" r="3" fill="#E07A5F"/>
9
+ </svg>
@@ -0,0 +1,9 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
2
+ <!-- Ultra-minimal metronome icon: triangle + pendulum -->
3
+ <!-- Metronome body — single-stroke triangle -->
4
+ <path d="M32 8 L14 54 L50 54 Z" stroke="#1B2838" stroke-width="3" fill="none" stroke-linejoin="round"/>
5
+ <!-- Pendulum arm (angled right) -->
6
+ <line x1="32" y1="46" x2="44" y2="14" stroke="#E07A5F" stroke-width="3" stroke-linecap="round"/>
7
+ <!-- Pivot dot -->
8
+ <circle cx="32" cy="46" r="3" fill="#E07A5F"/>
9
+ </svg>
@@ -0,0 +1,11 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 140" fill="none">
2
+ <!-- Ultra-minimal metronome: triangle outline + pendulum line (dark mode) -->
3
+ <!-- Metronome body — single-stroke triangle -->
4
+ <path d="M160 18 L122 100 L198 100 Z" stroke="#FAF3EE" stroke-width="3" fill="none" stroke-linejoin="round"/>
5
+ <!-- Pendulum arm (angled right ~18deg) -->
6
+ <line x1="160" y1="88" x2="182" y2="24" stroke="#E07A5F" stroke-width="3" stroke-linecap="round"/>
7
+ <!-- Pivot dot -->
8
+ <circle cx="160" cy="88" r="3.5" fill="#E07A5F"/>
9
+ <!-- Text -->
10
+ <text x="160" y="132" text-anchor="middle" font-family="'JetBrains Mono','SF Mono','Consolas',monospace" font-size="18" font-weight="600" fill="#FAF3EE" letter-spacing="-0.5">claude-tempo</text>
11
+ </svg>
@@ -0,0 +1,11 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 140" fill="none">
2
+ <!-- Ultra-minimal metronome: triangle outline + pendulum line -->
3
+ <!-- Metronome body — single-stroke triangle -->
4
+ <path d="M160 18 L122 100 L198 100 Z" stroke="#1B2838" stroke-width="3" fill="none" stroke-linejoin="round"/>
5
+ <!-- Pendulum arm (angled right ~18deg) -->
6
+ <line x1="160" y1="88" x2="182" y2="24" stroke="#E07A5F" stroke-width="3" stroke-linecap="round"/>
7
+ <!-- Pivot dot -->
8
+ <circle cx="160" cy="88" r="3.5" fill="#E07A5F"/>
9
+ <!-- Text -->
10
+ <text x="160" y="132" text-anchor="middle" font-family="'JetBrains Mono','SF Mono','Consolas',monospace" font-size="18" font-weight="600" fill="#1B2838" letter-spacing="-0.5">claude-tempo</text>
11
+ </svg>
@@ -0,0 +1,60 @@
1
+ import { Client } from '@temporalio/client';
2
+ import { Config } from '../config';
3
+ import { AgentType } from '../types';
4
+ export interface DeliverCueInput {
5
+ ensemble: string;
6
+ fromPlayerId: string;
7
+ targetPlayerId: string;
8
+ message: string;
9
+ }
10
+ export interface DeliverReportInput {
11
+ ensemble: string;
12
+ fromPlayerId: string;
13
+ text: string;
14
+ reportType: 'result' | 'blocker' | 'question';
15
+ }
16
+ export interface TerminateSessionInput {
17
+ ensemble: string;
18
+ targetPlayerId: string;
19
+ terminatedBy: string;
20
+ }
21
+ export interface StartRecruitedSessionInput {
22
+ ensemble: string;
23
+ targetName: string;
24
+ workDir: string;
25
+ isConductor: boolean;
26
+ initialMessage?: string;
27
+ fromPlayerId: string;
28
+ agent: AgentType;
29
+ systemPrompt?: string;
30
+ taskQueue: string;
31
+ }
32
+ export interface SpawnProcessInput {
33
+ targetName: string;
34
+ workDir: string;
35
+ isConductor: boolean;
36
+ agent: AgentType;
37
+ systemPrompt?: string;
38
+ ensemble: string;
39
+ temporalAddress: string;
40
+ temporalNamespace: string;
41
+ temporalApiKey?: string;
42
+ temporalTlsCertPath?: string;
43
+ temporalTlsKeyPath?: string;
44
+ }
45
+ export interface OutboxActivityResult {
46
+ success: boolean;
47
+ error?: string;
48
+ }
49
+ export interface OutboxActivities {
50
+ deliverCue(input: DeliverCueInput): Promise<OutboxActivityResult>;
51
+ deliverReport(input: DeliverReportInput): Promise<OutboxActivityResult>;
52
+ terminateSession(input: TerminateSessionInput): Promise<OutboxActivityResult>;
53
+ startRecruitedSession(input: StartRecruitedSessionInput): Promise<OutboxActivityResult>;
54
+ spawnProcess(input: SpawnProcessInput): Promise<OutboxActivityResult>;
55
+ }
56
+ /**
57
+ * Create outbox delivery activities bound to a Temporal client and config.
58
+ * The returned object is registered with the worker as activities.
59
+ */
60
+ export declare function createOutboxActivities(client: Client, config: Config): OutboxActivities;
@@ -0,0 +1,203 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.createOutboxActivities = createOutboxActivities;
37
+ const client_1 = require("@temporalio/client");
38
+ const activity_1 = require("@temporalio/activity");
39
+ const os = __importStar(require("os"));
40
+ const config_1 = require("../config");
41
+ const git_info_1 = require("../git-info");
42
+ const spawn_1 = require("../spawn");
43
+ const config_2 = require("../config");
44
+ const log = (...args) => console.error('[claude-tempo:outbox]', ...args);
45
+ // ── Helper: resolve session by player name ──
46
+ async function resolveSession(client, ensemble, playerName) {
47
+ const query = `WorkflowType = "claudeSessionWorkflow" AND ExecutionStatus = "Running"`;
48
+ for await (const wf of client.workflow.list({ query })) {
49
+ try {
50
+ const handle = client.workflow.getHandle(wf.workflowId);
51
+ const metadata = await handle.query('getMetadata');
52
+ if (metadata.ensemble === ensemble && metadata.playerId === playerName) {
53
+ return handle;
54
+ }
55
+ }
56
+ catch {
57
+ // Workflow may have just completed — skip
58
+ }
59
+ }
60
+ return null;
61
+ }
62
+ /**
63
+ * Create outbox delivery activities bound to a Temporal client and config.
64
+ * The returned object is registered with the worker as activities.
65
+ */
66
+ function createOutboxActivities(client, config) {
67
+ return {
68
+ async deliverCue(input) {
69
+ const { ensemble, fromPlayerId, targetPlayerId, message } = input;
70
+ const handle = await resolveSession(client, ensemble, targetPlayerId);
71
+ if (!handle) {
72
+ throw activity_1.ApplicationFailure.nonRetryable(`No active session found for "${targetPlayerId}"`);
73
+ }
74
+ await handle.signal('receiveMessage', { from: fromPlayerId, text: message });
75
+ return { success: true };
76
+ },
77
+ async deliverReport(input) {
78
+ const { ensemble, fromPlayerId, text, reportType } = input;
79
+ const conductorId = (0, config_1.conductorWorkflowId)(ensemble);
80
+ const handle = client.workflow.getHandle(conductorId);
81
+ await handle.signal('playerReport', { playerId: fromPlayerId, text, type: reportType });
82
+ return { success: true };
83
+ },
84
+ async terminateSession(input) {
85
+ const { ensemble, targetPlayerId, terminatedBy } = input;
86
+ const handle = await resolveSession(client, ensemble, targetPlayerId);
87
+ if (!handle) {
88
+ throw activity_1.ApplicationFailure.nonRetryable(`No active session found for "${targetPlayerId}"`);
89
+ }
90
+ // Signal target to mark as terminated
91
+ await handle.signal('updateMetadata', { status: 'terminated', terminatedBy });
92
+ // Notify conductor about the termination (best effort)
93
+ try {
94
+ const conductorId = (0, config_1.conductorWorkflowId)(ensemble);
95
+ const conductorHandle = client.workflow.getHandle(conductorId);
96
+ await conductorHandle.signal('receiveMessage', {
97
+ from: 'system',
98
+ text: `Session "${targetPlayerId}" was terminated by ${terminatedBy}.`,
99
+ });
100
+ }
101
+ catch {
102
+ // Conductor may not exist — that's fine
103
+ }
104
+ return { success: true };
105
+ },
106
+ async startRecruitedSession(input) {
107
+ const { ensemble, targetName, workDir, isConductor, initialMessage, fromPlayerId, agent, systemPrompt, taskQueue } = input;
108
+ try {
109
+ const workflowId = isConductor
110
+ ? (0, config_1.conductorWorkflowId)(ensemble)
111
+ : (0, config_1.sessionWorkflowId)(ensemble, targetName);
112
+ const { gitRoot, gitBranch } = (0, git_info_1.getGitInfo)(workDir);
113
+ const sessionInput = {
114
+ metadata: {
115
+ playerId: targetName,
116
+ ensemble,
117
+ hostname: os.hostname(),
118
+ workDir,
119
+ gitRoot,
120
+ gitBranch,
121
+ isConductor,
122
+ agentType: agent,
123
+ status: 'pending',
124
+ },
125
+ autoSummary: `Session in ${require('path').basename(workDir)}`,
126
+ disableStaleDetection: true,
127
+ ...(initialMessage ? {
128
+ messages: [{
129
+ id: require('crypto').randomUUID(),
130
+ from: fromPlayerId,
131
+ text: initialMessage,
132
+ timestamp: new Date().toISOString(),
133
+ delivered: false,
134
+ }],
135
+ } : {}),
136
+ };
137
+ await client.workflow.start('claudeSessionWorkflow', {
138
+ workflowId,
139
+ taskQueue,
140
+ args: [sessionInput],
141
+ workflowIdConflictPolicy: client_1.WorkflowIdConflictPolicy.USE_EXISTING,
142
+ searchAttributes: {
143
+ ...(gitRoot ? { ClaudeTempoGitRoot: [gitRoot] } : {}),
144
+ ClaudeTempoHostname: [os.hostname()],
145
+ ClaudeTempoEnsemble: [ensemble],
146
+ ClaudeTempoPlayerId: [targetName],
147
+ },
148
+ });
149
+ log(`Pre-created workflow ${workflowId} for recruit "${targetName}"`);
150
+ return { success: true };
151
+ }
152
+ catch (err) {
153
+ throw activity_1.ApplicationFailure.nonRetryable(`Failed to start recruited session "${targetName}": ${err instanceof Error ? err.message : String(err)}`);
154
+ }
155
+ },
156
+ async spawnProcess(input) {
157
+ const { targetName, workDir, isConductor, agent, systemPrompt, ensemble, temporalAddress, temporalNamespace, temporalApiKey, temporalTlsCertPath, temporalTlsKeyPath } = input;
158
+ try {
159
+ if (agent === 'copilot') {
160
+ const { pid } = (0, spawn_1.spawnCopilotBridge)({
161
+ name: targetName,
162
+ ensemble,
163
+ temporalAddress,
164
+ temporalNamespace,
165
+ temporalApiKey,
166
+ temporalTlsCertPath,
167
+ temporalTlsKeyPath,
168
+ isConductor,
169
+ workDir,
170
+ });
171
+ log(`Spawned copilot-bridge (pid ${pid}) in ${workDir} as "${targetName}"`);
172
+ }
173
+ else {
174
+ const spawnArgs = [
175
+ '--dangerously-skip-permissions',
176
+ '--dangerously-load-development-channels', 'server:claude-tempo',
177
+ '-n', targetName,
178
+ ...(systemPrompt ? ['--system-prompt', systemPrompt] : []),
179
+ ];
180
+ const envVars = {
181
+ [config_2.ENV.ENSEMBLE]: ensemble,
182
+ [config_2.ENV.CONDUCTOR]: isConductor ? 'true' : '',
183
+ [config_2.ENV.PLAYER_NAME]: targetName,
184
+ [config_2.ENV.TEMPORAL_ADDRESS]: temporalAddress,
185
+ [config_2.ENV.TEMPORAL_NAMESPACE]: temporalNamespace,
186
+ };
187
+ if (temporalApiKey)
188
+ envVars[config_2.ENV.TEMPORAL_API_KEY] = temporalApiKey;
189
+ if (temporalTlsCertPath)
190
+ envVars[config_2.ENV.TEMPORAL_TLS_CERT_PATH] = temporalTlsCertPath;
191
+ if (temporalTlsKeyPath)
192
+ envVars[config_2.ENV.TEMPORAL_TLS_KEY_PATH] = temporalTlsKeyPath;
193
+ const { pid } = (0, spawn_1.spawnInTerminal)(spawnArgs, workDir, envVars);
194
+ log(`Spawned claude process (pid ${pid}) in ${workDir} as "${targetName}"`);
195
+ }
196
+ return { success: true };
197
+ }
198
+ catch (err) {
199
+ throw activity_1.ApplicationFailure.nonRetryable(`Failed to spawn process for "${targetName}": ${err instanceof Error ? err.message : String(err)}`);
200
+ }
201
+ },
202
+ };
203
+ }
package/dist/config.d.ts CHANGED
@@ -85,6 +85,8 @@ export interface ConfigWithSources {
85
85
  * Used by `claude-tempo config show` to help users debug.
86
86
  */
87
87
  export declare function getConfigWithSources(overrides?: CliOverrides): ConfigWithSources;
88
+ /** Build a per-host task queue name for cross-machine activities: {taskQueue}-{hostname} */
89
+ export declare function hostTaskQueue(taskQueue: string, hostname: string): string;
88
90
  /** Build a workflow ID for a player session: claude-session-{ensemble}-{playerId} */
89
91
  export declare function sessionWorkflowId(ensemble: string, playerId: string): string;
90
92
  /** Build a workflow ID for a conductor: claude-session-{ensemble}-conductor */
package/dist/config.js CHANGED
@@ -7,6 +7,7 @@ exports.loadTemporalCliConfig = loadTemporalCliConfig;
7
7
  exports.parseTemporalYaml = parseTemporalYaml;
8
8
  exports.getConfig = getConfig;
9
9
  exports.getConfigWithSources = getConfigWithSources;
10
+ exports.hostTaskQueue = hostTaskQueue;
10
11
  exports.sessionWorkflowId = sessionWorkflowId;
11
12
  exports.conductorWorkflowId = conductorWorkflowId;
12
13
  exports.schedulerWorkflowId = schedulerWorkflowId;
@@ -239,6 +240,10 @@ function getConfigWithSources(overrides = {}) {
239
240
  },
240
241
  };
241
242
  }
243
+ /** Build a per-host task queue name for cross-machine activities: {taskQueue}-{hostname} */
244
+ function hostTaskQueue(taskQueue, hostname) {
245
+ return `${taskQueue}-${hostname}`;
246
+ }
242
247
  /** Build a workflow ID for a player session: claude-session-{ensemble}-{playerId} */
243
248
  function sessionWorkflowId(ensemble, playerId) {
244
249
  return `claude-session-${ensemble}-${playerId}`;
package/dist/server.js CHANGED
@@ -88,11 +88,16 @@ async function main() {
88
88
  connection,
89
89
  namespace: config.temporalNamespace,
90
90
  });
91
- // Start the Temporal worker (runs in background)
92
- const worker = await (0, worker_1.createWorker)(config);
93
- const workerRunPromise = worker.run();
94
- workerRunPromise.catch((err) => {
95
- log('Worker error:', err);
91
+ // Start the Temporal workers (runs in background)
92
+ const { sharedWorker, hostWorker } = await (0, worker_1.createWorkers)(config);
93
+ const sharedWorkerRunPromise = sharedWorker.run();
94
+ const hostWorkerRunPromise = hostWorker.run();
95
+ sharedWorkerRunPromise.catch((err) => {
96
+ log('Shared worker error:', err);
97
+ process.exit(1);
98
+ });
99
+ hostWorkerRunPromise.catch((err) => {
100
+ log('Host worker error:', err);
96
101
  process.exit(1);
97
102
  });
98
103
  // Start the session workflow
@@ -112,6 +117,14 @@ async function main() {
112
117
  agentType: isBridgeMode ? 'copilot' : 'claude',
113
118
  },
114
119
  autoSummary: `Session in ${path.basename(workDir)}`,
120
+ temporalConfig: {
121
+ temporalAddress: config.temporalAddress,
122
+ temporalNamespace: config.temporalNamespace,
123
+ temporalApiKey: config.temporalApiKey,
124
+ temporalTlsCertPath: config.temporalTlsCertPath,
125
+ temporalTlsKeyPath: config.temporalTlsKeyPath,
126
+ taskQueue: config.taskQueue,
127
+ },
115
128
  };
116
129
  const handle = await client.workflow.start('claudeSessionWorkflow', {
117
130
  workflowId,
@@ -129,19 +142,25 @@ async function main() {
129
142
  log(`Workflow ${workflowId} started (or reconnected)`);
130
143
  // Watch for workflow completion — exit the process when the workflow ends
131
144
  // (e.g., via stop tool setting status to 'terminated')
145
+ const shutdownWorkers = () => {
146
+ sharedWorker.shutdown();
147
+ hostWorker.shutdown();
148
+ return Promise.all([
149
+ sharedWorkerRunPromise.catch(() => { }),
150
+ hostWorkerRunPromise.catch(() => { }),
151
+ ]);
152
+ };
132
153
  handle.result().then(() => {
133
154
  log('Workflow completed — shutting down');
134
155
  stopPoller();
135
- worker.shutdown();
136
- workerRunPromise.catch(() => { }).then(() => process.exit(0));
156
+ shutdownWorkers().then(() => process.exit(0));
137
157
  }).catch((err) => {
138
158
  // Only exit on workflow-level errors (cancelled, failed), not transient connection errors
139
159
  const name = err?.name || '';
140
160
  if (name.includes('WorkflowFailed') || name.includes('WorkflowCancelled') || name.includes('WorkflowNotFound')) {
141
161
  log('Workflow ended unexpectedly — shutting down');
142
162
  stopPoller();
143
- worker.shutdown();
144
- workerRunPromise.catch(() => { }).then(() => process.exit(1));
163
+ shutdownWorkers().then(() => process.exit(1));
145
164
  }
146
165
  else {
147
166
  log('Transient error watching workflow result:', err?.message || err);
@@ -192,13 +211,13 @@ async function main() {
192
211
  });
193
212
  // Register tools
194
213
  (0, ensemble_1.registerEnsembleTool)(mcpServer, client, config, getPlayerId, workflowId);
195
- (0, cue_1.registerCueTool)(mcpServer, client, config, getPlayerId);
214
+ (0, cue_1.registerCueTool)(mcpServer, client, config, getPlayerId, handle);
196
215
  (0, set_part_1.registerSetPartTool)(mcpServer, handle);
197
216
  (0, set_name_1.registerSetNameTool)(mcpServer, client, config, handle, getPlayerId, setPlayerId);
198
217
  (0, listen_1.registerListenTool)(mcpServer, handle);
199
- (0, recruit_1.registerRecruitTool)(mcpServer, client, config, getPlayerId, isBridgeMode ? 'copilot' : 'claude');
200
- (0, report_1.registerReportTool)(mcpServer, client, config, getPlayerId);
201
- (0, stop_1.registerStopTool)(mcpServer, client, config, getPlayerId);
218
+ (0, recruit_1.registerRecruitTool)(mcpServer, client, config, getPlayerId, handle, isBridgeMode ? 'copilot' : 'claude');
219
+ (0, report_1.registerReportTool)(mcpServer, handle);
220
+ (0, stop_1.registerStopTool)(mcpServer, client, config, getPlayerId, handle);
202
221
  (0, schedule_1.registerScheduleTool)(mcpServer, client, config, getPlayerId);
203
222
  (0, unschedule_1.registerUnscheduleTool)(mcpServer, client, config);
204
223
  (0, schedules_1.registerSchedulesTool)(mcpServer, client, config);
@@ -246,8 +265,7 @@ async function main() {
246
265
  catch {
247
266
  // workflow may already be gone
248
267
  }
249
- worker.shutdown();
250
- await workerRunPromise.catch(() => { });
268
+ await shutdownWorkers();
251
269
  process.exit(0);
252
270
  };
253
271
  process.on('SIGINT', shutdown);
package/dist/spawn.d.ts CHANGED
@@ -1,3 +1,13 @@
1
+ /** Resolve the absolute path to the package's icon file (PNG for Windows Terminal). */
2
+ export declare function resolveIconPath(): string;
3
+ /**
4
+ * Ensure a "claude-tempo" profile exists in Windows Terminal settings.json
5
+ * with our icon. Returns true if the profile is ready for use.
6
+ *
7
+ * Windows Terminal settings path:
8
+ * %LOCALAPPDATA%/Packages/Microsoft.WindowsTerminal_8wekyb3d8bbwe/LocalState/settings.json
9
+ */
10
+ export declare function ensureWindowsTerminalProfile(): boolean;
1
11
  /** POSIX shell-safe single-quoting (works in bash, zsh, and fish) */
2
12
  export declare function shellQuote(s: string): string;
3
13
  /** Resolve the absolute path to the `claude` binary */
package/dist/spawn.js CHANGED
@@ -1,5 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.resolveIconPath = resolveIconPath;
4
+ exports.ensureWindowsTerminalProfile = ensureWindowsTerminalProfile;
3
5
  exports.shellQuote = shellQuote;
4
6
  exports.resolveClaudePath = resolveClaudePath;
5
7
  exports.detectMacTerminal = detectMacTerminal;
@@ -13,6 +15,117 @@ const path_1 = require("path");
13
15
  const os_1 = require("os");
14
16
  const config_1 = require("./config");
15
17
  const log = (...args) => console.error('[claude-tempo:spawn]', ...args);
18
+ /** Stable GUID for the claude-tempo Windows Terminal profile. */
19
+ const WT_PROFILE_GUID = '{c1a0d300-0e30-4000-a000-c1a0de00e300}';
20
+ const WT_PROFILE_NAME = 'claude-tempo';
21
+ /** Resolve the absolute path to the package's icon file (PNG for Windows Terminal). */
22
+ function resolveIconPath() {
23
+ // __dirname is src/ in dev or dist/ in production; assets/ is at the package root
24
+ const packageRoot = (0, path_1.resolve)(__dirname, '..');
25
+ return (0, path_1.join)(packageRoot, 'assets', 'icon-dark-32.png');
26
+ }
27
+ /**
28
+ * Strip // and /* comments from JSON-with-comments (JSONC), leaving strings intact.
29
+ * Handles escaped quotes inside strings correctly.
30
+ */
31
+ function stripJsonComments(text) {
32
+ let result = '';
33
+ let i = 0;
34
+ while (i < text.length) {
35
+ // String literal — copy verbatim until closing quote
36
+ if (text[i] === '"') {
37
+ result += '"';
38
+ i++;
39
+ while (i < text.length && text[i] !== '"') {
40
+ if (text[i] === '\\') {
41
+ result += text[i++];
42
+ } // skip escaped char
43
+ if (i < text.length) {
44
+ result += text[i++];
45
+ }
46
+ }
47
+ if (i < text.length) {
48
+ result += text[i++];
49
+ } // closing quote
50
+ // Line comment
51
+ }
52
+ else if (text[i] === '/' && text[i + 1] === '/') {
53
+ while (i < text.length && text[i] !== '\n')
54
+ i++;
55
+ // Block comment
56
+ }
57
+ else if (text[i] === '/' && text[i + 1] === '*') {
58
+ i += 2;
59
+ while (i < text.length && !(text[i] === '*' && text[i + 1] === '/'))
60
+ i++;
61
+ i += 2; // skip closing */
62
+ }
63
+ else {
64
+ result += text[i++];
65
+ }
66
+ }
67
+ return result;
68
+ }
69
+ /**
70
+ * Ensure a "claude-tempo" profile exists in Windows Terminal settings.json
71
+ * with our icon. Returns true if the profile is ready for use.
72
+ *
73
+ * Windows Terminal settings path:
74
+ * %LOCALAPPDATA%/Packages/Microsoft.WindowsTerminal_8wekyb3d8bbwe/LocalState/settings.json
75
+ */
76
+ function ensureWindowsTerminalProfile() {
77
+ if (process.platform !== 'win32')
78
+ return false;
79
+ const localAppData = process.env.LOCALAPPDATA;
80
+ if (!localAppData)
81
+ return false;
82
+ const settingsPath = (0, path_1.join)(localAppData, 'Packages', 'Microsoft.WindowsTerminal_8wekyb3d8bbwe', 'LocalState', 'settings.json');
83
+ if (!(0, fs_1.existsSync)(settingsPath)) {
84
+ log('Windows Terminal settings.json not found at', settingsPath);
85
+ return false;
86
+ }
87
+ try {
88
+ const raw = (0, fs_1.readFileSync)(settingsPath, 'utf8');
89
+ // Windows Terminal settings.json may contain comments — strip them for JSON.parse.
90
+ // Naive regex would eat "//" inside strings (e.g., URLs). Walk char-by-char instead.
91
+ const settings = JSON.parse(stripJsonComments(raw));
92
+ if (!settings.profiles?.list)
93
+ return false;
94
+ const iconPath = resolveIconPath().replace(/\\/g, '/');
95
+ if (!(0, fs_1.existsSync)(iconPath.replace(/\//g, '\\'))) {
96
+ log('Icon file not found at', iconPath);
97
+ return false;
98
+ }
99
+ const profiles = settings.profiles.list;
100
+ // Check if our profile already exists (by GUID or name)
101
+ const existing = profiles.find((p) => p.guid === WT_PROFILE_GUID || p.name === WT_PROFILE_NAME);
102
+ if (existing) {
103
+ // Update icon path if it changed (e.g. package moved)
104
+ if (existing.icon !== iconPath) {
105
+ existing.icon = iconPath;
106
+ (0, fs_1.writeFileSync)(settingsPath, JSON.stringify(settings, null, 4) + '\n');
107
+ log('Updated claude-tempo profile icon in Windows Terminal');
108
+ }
109
+ return true;
110
+ }
111
+ // Add new profile
112
+ profiles.push({
113
+ guid: WT_PROFILE_GUID,
114
+ name: WT_PROFILE_NAME,
115
+ commandline: 'cmd.exe',
116
+ icon: iconPath,
117
+ hidden: true, // Hide from dropdown — only used programmatically
118
+ });
119
+ // Write back with original formatting style (4-space indent to match WT default)
120
+ (0, fs_1.writeFileSync)(settingsPath, JSON.stringify(settings, null, 4) + '\n');
121
+ log('Created claude-tempo profile in Windows Terminal with icon:', iconPath);
122
+ return true;
123
+ }
124
+ catch (e) {
125
+ log('Failed to update Windows Terminal settings:', e);
126
+ return false;
127
+ }
128
+ }
16
129
  /** POSIX shell-safe single-quoting (works in bash, zsh, and fish) */
17
130
  function shellQuote(s) {
18
131
  return `'${s.replace(/'/g, "'\\''")}'`;
@@ -186,6 +299,8 @@ function spawnInTerminal(claudeArgs, workDir, envVars) {
186
299
  const tabTitle = nameIdx !== -1 && nameIdx + 1 < claudeArgs.length
187
300
  ? claudeArgs[nameIdx + 1]
188
301
  : 'claude-tempo';
302
+ // Ensure our profile with icon exists in Windows Terminal settings
303
+ const hasProfile = ensureWindowsTerminalProfile();
189
304
  // Build inline env var assignments for cmd /c since wt.exe spawns
190
305
  // a new process that won't inherit our env.
191
306
  // Escape values for cmd.exe: wrap in quotes and escape inner special chars.
@@ -198,14 +313,17 @@ function spawnInTerminal(claudeArgs, workDir, envVars) {
198
313
  ? `${setCmds} && ${claudeCmd}`
199
314
  : claudeCmd;
200
315
  // Use `cmd.exe /c start "" wt.exe ...` to resolve the UWP app alias
201
- const child = (0, child_process_1.spawn)('cmd.exe', [
316
+ // When our profile exists, use --profile to get the tab icon
317
+ const wtArgs = [
202
318
  '/c', 'start', '',
203
319
  'wt.exe', '-w', '0',
204
320
  'new-tab',
321
+ ...(hasProfile ? ['--profile', WT_PROFILE_NAME] : []),
205
322
  '--title', tabTitle,
206
323
  '-d', workDir,
207
324
  'cmd', '/k', innerCmd,
208
- ], {
325
+ ];
326
+ const child = (0, child_process_1.spawn)('cmd.exe', wtArgs, {
209
327
  detached: true,
210
328
  stdio: 'ignore',
211
329
  });