claude-tempo 0.1.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/.mcp.json +9 -0
- package/CLAUDE.md +84 -0
- package/README.md +400 -0
- package/dist/cli.js +169 -0
- package/dist/server.js +234 -0
- package/package.json +34 -0
- package/src/channel.ts +35 -0
- package/src/cli/commands.ts +579 -0
- package/src/cli/output.ts +36 -0
- package/src/cli/preflight.ts +77 -0
- package/src/cli.ts +151 -0
- package/src/config.ts +25 -0
- package/src/server.ts +213 -0
- package/src/spawn.ts +213 -0
- package/src/tools/cue.ts +60 -0
- package/src/tools/ensemble.ts +102 -0
- package/src/tools/helpers.ts +16 -0
- package/src/tools/listen.ts +43 -0
- package/src/tools/recruit.ts +129 -0
- package/src/tools/report.ts +55 -0
- package/src/tools/resolve.ts +39 -0
- package/src/tools/set-name.ts +57 -0
- package/src/tools/set-part.ts +32 -0
- package/src/tools/terminate.ts +61 -0
- package/src/types.ts +64 -0
- package/src/worker.ts +34 -0
- package/src/workflows/session.ts +204 -0
- package/src/workflows/signals.ts +44 -0
- package/tests/recruit-terminal-test-plan.md +201 -0
- package/tsconfig.json +18 -0
|
@@ -0,0 +1,579 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
|
2
|
+
import { join, resolve } from 'path';
|
|
3
|
+
import { execFileSync, spawn as cpSpawn, ChildProcess } from 'child_process';
|
|
4
|
+
import { homedir } from 'os';
|
|
5
|
+
import { Connection, Client } from '@temporalio/client';
|
|
6
|
+
import { spawnInTerminal } from '../spawn';
|
|
7
|
+
import { runPreflight } from './preflight';
|
|
8
|
+
import * as out from './output';
|
|
9
|
+
|
|
10
|
+
/** Package root is two levels up from dist/cli/ */
|
|
11
|
+
const PACKAGE_ROOT = resolve(__dirname, '..', '..');
|
|
12
|
+
|
|
13
|
+
interface StartOpts {
|
|
14
|
+
ensemble: string;
|
|
15
|
+
conductor: boolean;
|
|
16
|
+
temporalAddress: string;
|
|
17
|
+
name?: string;
|
|
18
|
+
skipPreflight?: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function start(opts: StartOpts) {
|
|
22
|
+
const workDir = process.cwd();
|
|
23
|
+
|
|
24
|
+
if (!opts.skipPreflight) {
|
|
25
|
+
const result = await runPreflight({
|
|
26
|
+
temporalAddress: opts.temporalAddress,
|
|
27
|
+
projectDir: workDir,
|
|
28
|
+
});
|
|
29
|
+
for (const w of result.warnings) out.warn(w);
|
|
30
|
+
if (!result.ok) {
|
|
31
|
+
for (const e of result.errors) out.error(e);
|
|
32
|
+
process.exit(1);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const role = opts.conductor ? 'conductor' : 'player';
|
|
37
|
+
out.log(`Starting ${out.bold(role)} in ensemble ${out.cyan(opts.ensemble)}`);
|
|
38
|
+
|
|
39
|
+
const claudeArgs = [
|
|
40
|
+
'--dangerously-skip-permissions',
|
|
41
|
+
'--dangerously-load-development-channels', 'server:claude-tempo',
|
|
42
|
+
];
|
|
43
|
+
if (opts.name) {
|
|
44
|
+
claudeArgs.push('-n', opts.name);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const envVars: Record<string, string> = {
|
|
48
|
+
CLAUDE_TEMPO_ENSEMBLE: opts.ensemble,
|
|
49
|
+
};
|
|
50
|
+
if (opts.conductor) {
|
|
51
|
+
envVars.CLAUDE_TEMPO_CONDUCTOR = 'true';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const { pid } = spawnInTerminal(claudeArgs, workDir, envVars);
|
|
55
|
+
out.success(`Launched ${role} session${opts.name ? ` "${opts.name}"` : ''} (pid ${pid ?? 'unknown'})`);
|
|
56
|
+
out.log(` Ensemble: ${opts.ensemble}`);
|
|
57
|
+
out.log(` Directory: ${workDir}`);
|
|
58
|
+
out.log(`\nCheck status: ${out.dim('claude-tempo status ' + opts.ensemble)}`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface StatusOpts {
|
|
62
|
+
ensemble?: string;
|
|
63
|
+
temporalAddress: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function status(opts: StatusOpts) {
|
|
67
|
+
let connection: Connection;
|
|
68
|
+
try {
|
|
69
|
+
connection = await Promise.race([
|
|
70
|
+
Connection.connect({ address: opts.temporalAddress }),
|
|
71
|
+
new Promise<never>((_, reject) => setTimeout(() => reject(new Error('timeout')), 3000)),
|
|
72
|
+
]);
|
|
73
|
+
} catch {
|
|
74
|
+
out.error(`Cannot connect to Temporal at ${opts.temporalAddress}`);
|
|
75
|
+
out.log(` Run: ${out.dim('temporal server start-dev')}`);
|
|
76
|
+
process.exit(1);
|
|
77
|
+
return; // unreachable, helps TS
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const client = new Client({ connection });
|
|
81
|
+
|
|
82
|
+
// Build query
|
|
83
|
+
let query = 'WorkflowType = "claudeSessionWorkflow" AND ExecutionStatus = "Running"';
|
|
84
|
+
if (opts.ensemble) {
|
|
85
|
+
query += ` AND ClaudeTempoEnsemble = "${opts.ensemble}"`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const sessions: Array<{
|
|
89
|
+
id: string;
|
|
90
|
+
name: string;
|
|
91
|
+
part: string;
|
|
92
|
+
ensemble: string;
|
|
93
|
+
workDir: string;
|
|
94
|
+
branch: string;
|
|
95
|
+
host: string;
|
|
96
|
+
conductor: boolean;
|
|
97
|
+
}> = [];
|
|
98
|
+
|
|
99
|
+
for await (const wf of client.workflow.list({ query })) {
|
|
100
|
+
try {
|
|
101
|
+
const handle = client.workflow.getHandle(wf.workflowId);
|
|
102
|
+
const [metadata, part] = await Promise.all([
|
|
103
|
+
handle.query('getMetadata').catch(() => ({})),
|
|
104
|
+
handle.query('getPart').catch(() => ''),
|
|
105
|
+
]);
|
|
106
|
+
const meta = metadata as Record<string, unknown>;
|
|
107
|
+
sessions.push({
|
|
108
|
+
id: wf.workflowId,
|
|
109
|
+
name: (meta.playerId as string) || wf.workflowId.split('-').pop() || '?',
|
|
110
|
+
part: (part as string) || '',
|
|
111
|
+
ensemble: (meta.ensemble as string) || '?',
|
|
112
|
+
workDir: (meta.workDir as string) || '?',
|
|
113
|
+
branch: (meta.gitBranch as string) || '',
|
|
114
|
+
host: (meta.hostname as string) || '',
|
|
115
|
+
conductor: (meta.isConductor as boolean) || false,
|
|
116
|
+
});
|
|
117
|
+
} catch {
|
|
118
|
+
// workflow may have closed between list and query
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
await connection.close();
|
|
123
|
+
|
|
124
|
+
if (sessions.length === 0) {
|
|
125
|
+
out.log(opts.ensemble
|
|
126
|
+
? `No active sessions in ensemble "${opts.ensemble}".`
|
|
127
|
+
: 'No active sessions found.');
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Group by ensemble
|
|
132
|
+
const byEnsemble = new Map<string, typeof sessions>();
|
|
133
|
+
for (const s of sessions) {
|
|
134
|
+
const list = byEnsemble.get(s.ensemble) || [];
|
|
135
|
+
list.push(s);
|
|
136
|
+
byEnsemble.set(s.ensemble, list);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
for (const [ensemble, members] of byEnsemble) {
|
|
140
|
+
out.heading(`Ensemble: ${ensemble}`);
|
|
141
|
+
out.log(` ${out.dim(`${members.length} active session${members.length !== 1 ? 's' : ''}`)}`);
|
|
142
|
+
console.log();
|
|
143
|
+
|
|
144
|
+
// Sort: conductor first, then alphabetical
|
|
145
|
+
members.sort((a, b) => {
|
|
146
|
+
if (a.conductor !== b.conductor) return a.conductor ? -1 : 1;
|
|
147
|
+
return a.name.localeCompare(b.name);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
for (const s of members) {
|
|
151
|
+
const role = s.conductor ? out.yellow(' (conductor)') : '';
|
|
152
|
+
const name = out.bold(s.name);
|
|
153
|
+
out.log(` ${name}${role}`);
|
|
154
|
+
if (s.part) out.log(` ${out.dim(s.part)}`);
|
|
155
|
+
const details = [s.workDir, s.branch, s.host].filter(Boolean).join(' ');
|
|
156
|
+
if (details) out.log(` ${out.dim(details)}`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
console.log();
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
interface InitOpts {
|
|
163
|
+
dir: string;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export async function init(opts: InitOpts) {
|
|
167
|
+
const mcpPath = join(opts.dir, '.mcp.json');
|
|
168
|
+
|
|
169
|
+
const entry = {
|
|
170
|
+
command: 'claude-tempo-server',
|
|
171
|
+
args: [] as string[],
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
if (existsSync(mcpPath)) {
|
|
175
|
+
try {
|
|
176
|
+
const existing = JSON.parse(readFileSync(mcpPath, 'utf8'));
|
|
177
|
+
if (existing?.mcpServers?.['claude-tempo']) {
|
|
178
|
+
out.success('.mcp.json already has a claude-tempo entry');
|
|
179
|
+
out.log(` ${out.dim(mcpPath)}`);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
// Merge into existing config
|
|
183
|
+
existing.mcpServers = existing.mcpServers || {};
|
|
184
|
+
existing.mcpServers['claude-tempo'] = entry;
|
|
185
|
+
writeFileSync(mcpPath, JSON.stringify(existing, null, 2) + '\n');
|
|
186
|
+
out.success('Added claude-tempo to existing .mcp.json');
|
|
187
|
+
} catch {
|
|
188
|
+
out.error(`Failed to parse ${mcpPath}. Fix the JSON or delete it and re-run.`);
|
|
189
|
+
process.exit(1);
|
|
190
|
+
}
|
|
191
|
+
} else {
|
|
192
|
+
const config = {
|
|
193
|
+
mcpServers: {
|
|
194
|
+
'claude-tempo': entry,
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
writeFileSync(mcpPath, JSON.stringify(config, null, 2) + '\n');
|
|
198
|
+
out.success('Created .mcp.json with claude-tempo config');
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
out.log(` ${out.dim(mcpPath)}`);
|
|
202
|
+
out.log(`\nNext steps:`);
|
|
203
|
+
out.log(` 1. Start Temporal: ${out.dim('temporal server start-dev')}`);
|
|
204
|
+
out.log(` 2. Start conductor: ${out.dim('claude-tempo conduct')}`);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// --- Temporal server management ---
|
|
208
|
+
|
|
209
|
+
const CLAUDE_TEMPO_HOME = join(homedir(), '.claude-tempo');
|
|
210
|
+
const DEFAULT_DB_PATH = join(CLAUDE_TEMPO_HOME, 'temporal-data.db');
|
|
211
|
+
|
|
212
|
+
const SEARCH_ATTRIBUTES = [
|
|
213
|
+
{ name: 'ClaudeTempoHostname', type: 'Keyword' },
|
|
214
|
+
{ name: 'ClaudeTempoGitRoot', type: 'Keyword' },
|
|
215
|
+
{ name: 'ClaudeTempoEnsemble', type: 'Keyword' },
|
|
216
|
+
{ name: 'ClaudeTempoPlayerId', type: 'Keyword' },
|
|
217
|
+
];
|
|
218
|
+
|
|
219
|
+
function isTemporalReachable(address: string): Promise<boolean> {
|
|
220
|
+
return Connection.connect({ address })
|
|
221
|
+
.then(conn => { conn.close(); return true; })
|
|
222
|
+
.catch(() => false);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function temporalCliExists(): boolean {
|
|
226
|
+
const cmd = process.platform === 'win32' ? 'where' : 'which';
|
|
227
|
+
try {
|
|
228
|
+
execFileSync(cmd, ['temporal'], { stdio: 'ignore' });
|
|
229
|
+
return true;
|
|
230
|
+
} catch {
|
|
231
|
+
return false;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function registerSearchAttributes(temporalAddress: string) {
|
|
236
|
+
for (const attr of SEARCH_ATTRIBUTES) {
|
|
237
|
+
try {
|
|
238
|
+
execFileSync('temporal', [
|
|
239
|
+
'operator', 'search-attribute', 'create',
|
|
240
|
+
'--address', temporalAddress,
|
|
241
|
+
'--name', attr.name,
|
|
242
|
+
'--type', attr.type,
|
|
243
|
+
], { stdio: ['ignore', 'ignore', 'ignore'] });
|
|
244
|
+
out.success(`Registered search attribute: ${attr.name}`);
|
|
245
|
+
} catch {
|
|
246
|
+
// Already exists or other error — safe to ignore
|
|
247
|
+
out.dim(` ${attr.name} (already exists)`);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
interface ServerOpts {
|
|
253
|
+
temporalAddress: string;
|
|
254
|
+
background: boolean;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export async function server(opts: ServerOpts) {
|
|
258
|
+
if (!temporalCliExists()) {
|
|
259
|
+
out.error('temporal CLI not found on PATH');
|
|
260
|
+
out.log(` Install: ${out.dim('https://docs.temporal.io/cli')}`);
|
|
261
|
+
process.exit(1);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Check if already running
|
|
265
|
+
const alreadyRunning = await isTemporalReachable(opts.temporalAddress);
|
|
266
|
+
if (alreadyRunning) {
|
|
267
|
+
out.success(`Temporal already running at ${opts.temporalAddress}`);
|
|
268
|
+
out.log(' Registering search attributes...');
|
|
269
|
+
registerSearchAttributes(opts.temporalAddress);
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Ensure data directory exists
|
|
274
|
+
mkdirSync(CLAUDE_TEMPO_HOME, { recursive: true });
|
|
275
|
+
|
|
276
|
+
const port = opts.temporalAddress.split(':')[1] || '7233';
|
|
277
|
+
const args = [
|
|
278
|
+
'server', 'start-dev',
|
|
279
|
+
'--port', port,
|
|
280
|
+
'--db-filename', DEFAULT_DB_PATH,
|
|
281
|
+
];
|
|
282
|
+
|
|
283
|
+
out.log(`Starting Temporal dev server on port ${port}...`);
|
|
284
|
+
out.log(` Data: ${out.dim(DEFAULT_DB_PATH)}`);
|
|
285
|
+
|
|
286
|
+
if (opts.background) {
|
|
287
|
+
const child = cpSpawn('temporal', args, {
|
|
288
|
+
detached: true,
|
|
289
|
+
stdio: 'ignore',
|
|
290
|
+
});
|
|
291
|
+
child.unref();
|
|
292
|
+
out.success(`Temporal started in background (pid ${child.pid})`);
|
|
293
|
+
|
|
294
|
+
// Wait for it to be ready
|
|
295
|
+
for (let i = 0; i < 20; i++) {
|
|
296
|
+
await new Promise(r => setTimeout(r, 500));
|
|
297
|
+
if (await isTemporalReachable(opts.temporalAddress)) break;
|
|
298
|
+
}
|
|
299
|
+
} else {
|
|
300
|
+
// Foreground — register attributes after startup, then hand over stdio
|
|
301
|
+
const child = cpSpawn('temporal', args, {
|
|
302
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
// Wait for ready, then register attributes
|
|
306
|
+
const waitForReady = async () => {
|
|
307
|
+
for (let i = 0; i < 20; i++) {
|
|
308
|
+
await new Promise(r => setTimeout(r, 500));
|
|
309
|
+
if (await isTemporalReachable(opts.temporalAddress)) {
|
|
310
|
+
out.success(`Temporal running at ${opts.temporalAddress}`);
|
|
311
|
+
out.log(' Registering search attributes...');
|
|
312
|
+
registerSearchAttributes(opts.temporalAddress);
|
|
313
|
+
out.log(`\n ${out.dim('Press Ctrl+C to stop')}\n`);
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
out.warn('Temporal started but not responding — search attributes not registered');
|
|
318
|
+
};
|
|
319
|
+
waitForReady();
|
|
320
|
+
|
|
321
|
+
// Pipe output through
|
|
322
|
+
child.stdout?.pipe(process.stdout);
|
|
323
|
+
child.stderr?.pipe(process.stderr);
|
|
324
|
+
|
|
325
|
+
// Forward signals for clean shutdown
|
|
326
|
+
const forward = (sig: NodeJS.Signals) => { child.kill(sig); };
|
|
327
|
+
process.on('SIGINT', () => forward('SIGINT'));
|
|
328
|
+
process.on('SIGTERM', () => forward('SIGTERM'));
|
|
329
|
+
|
|
330
|
+
await new Promise<void>((resolve) => {
|
|
331
|
+
child.on('exit', (code) => {
|
|
332
|
+
if (code && code !== 0) out.error(`Temporal exited with code ${code}`);
|
|
333
|
+
resolve();
|
|
334
|
+
});
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Register search attributes (for background mode — foreground does it inline)
|
|
339
|
+
if (opts.background) {
|
|
340
|
+
out.log(' Registering search attributes...');
|
|
341
|
+
registerSearchAttributes(opts.temporalAddress);
|
|
342
|
+
out.success('Temporal ready');
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// --- First-time setup: `up` command ---
|
|
347
|
+
|
|
348
|
+
interface UpOpts {
|
|
349
|
+
ensemble: string;
|
|
350
|
+
temporalAddress: string;
|
|
351
|
+
name?: string;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
export async function up(opts: UpOpts) {
|
|
355
|
+
out.heading('claude-tempo setup');
|
|
356
|
+
|
|
357
|
+
// Step 1: Check temporal CLI
|
|
358
|
+
if (!temporalCliExists()) {
|
|
359
|
+
out.error('temporal CLI not found');
|
|
360
|
+
out.log(`\n Install the Temporal CLI first:`);
|
|
361
|
+
out.log(` ${out.dim('https://docs.temporal.io/cli')}\n`);
|
|
362
|
+
process.exit(1);
|
|
363
|
+
}
|
|
364
|
+
out.check('temporal CLI installed', true);
|
|
365
|
+
|
|
366
|
+
// Step 2: Start Temporal if needed
|
|
367
|
+
const temporalUp = await isTemporalReachable(opts.temporalAddress);
|
|
368
|
+
if (temporalUp) {
|
|
369
|
+
out.check('Temporal running', true, opts.temporalAddress);
|
|
370
|
+
} else {
|
|
371
|
+
out.log(` ${out.dim('...')} Starting Temporal dev server...`);
|
|
372
|
+
mkdirSync(CLAUDE_TEMPO_HOME, { recursive: true });
|
|
373
|
+
const port = opts.temporalAddress.split(':')[1] || '7233';
|
|
374
|
+
const child = cpSpawn('temporal', [
|
|
375
|
+
'server', 'start-dev',
|
|
376
|
+
'--port', port,
|
|
377
|
+
'--db-filename', DEFAULT_DB_PATH,
|
|
378
|
+
], { detached: true, stdio: 'ignore' });
|
|
379
|
+
child.unref();
|
|
380
|
+
|
|
381
|
+
// Wait for ready
|
|
382
|
+
let ready = false;
|
|
383
|
+
for (let i = 0; i < 20; i++) {
|
|
384
|
+
await new Promise(r => setTimeout(r, 500));
|
|
385
|
+
if (await isTemporalReachable(opts.temporalAddress)) { ready = true; break; }
|
|
386
|
+
}
|
|
387
|
+
if (!ready) {
|
|
388
|
+
out.error('Temporal did not start within 10 seconds');
|
|
389
|
+
process.exit(1);
|
|
390
|
+
}
|
|
391
|
+
out.check('Temporal started', true, `pid ${child.pid}, data in ~/.claude-tempo/`);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// Step 3: Register search attributes
|
|
395
|
+
registerSearchAttributes(opts.temporalAddress);
|
|
396
|
+
|
|
397
|
+
// Step 4: Init .mcp.json if needed
|
|
398
|
+
const mcpPath = join(process.cwd(), '.mcp.json');
|
|
399
|
+
let mcpExists = false;
|
|
400
|
+
if (existsSync(mcpPath)) {
|
|
401
|
+
try {
|
|
402
|
+
const mcp = JSON.parse(readFileSync(mcpPath, 'utf8'));
|
|
403
|
+
mcpExists = !!mcp?.mcpServers?.['claude-tempo'];
|
|
404
|
+
} catch { /* invalid */ }
|
|
405
|
+
}
|
|
406
|
+
if (mcpExists) {
|
|
407
|
+
out.check('.mcp.json configured', true);
|
|
408
|
+
} else {
|
|
409
|
+
await init({ dir: process.cwd() });
|
|
410
|
+
out.check('.mcp.json created', true);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// Step 5: Launch conductor
|
|
414
|
+
console.log();
|
|
415
|
+
out.log(`Launching conductor in ensemble ${out.cyan(opts.ensemble)}...`);
|
|
416
|
+
const claudeArgs = [
|
|
417
|
+
'--dangerously-skip-permissions',
|
|
418
|
+
'--dangerously-load-development-channels', 'server:claude-tempo',
|
|
419
|
+
];
|
|
420
|
+
if (opts.name) claudeArgs.push('-n', opts.name);
|
|
421
|
+
|
|
422
|
+
const { pid } = spawnInTerminal(claudeArgs, process.cwd(), {
|
|
423
|
+
CLAUDE_TEMPO_ENSEMBLE: opts.ensemble,
|
|
424
|
+
CLAUDE_TEMPO_CONDUCTOR: 'true',
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
console.log();
|
|
428
|
+
out.success('You\'re all set!');
|
|
429
|
+
out.log(` Conductor launched (pid ${pid ?? 'unknown'})`);
|
|
430
|
+
out.log(` Ensemble: ${out.cyan(opts.ensemble)}`);
|
|
431
|
+
out.log(`\n ${out.bold('What next?')}`);
|
|
432
|
+
out.log(` ${out.dim('claude-tempo start ' + opts.ensemble)} Add a player session`);
|
|
433
|
+
out.log(` ${out.dim('claude-tempo status ' + opts.ensemble)} See who\'s active`);
|
|
434
|
+
out.log(` Or ask the conductor to ${out.dim('recruit')} players for you`);
|
|
435
|
+
console.log();
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// --- Teardown: `down` command ---
|
|
439
|
+
|
|
440
|
+
interface DownOpts {
|
|
441
|
+
temporalAddress: string;
|
|
442
|
+
removeMcp: boolean;
|
|
443
|
+
dir: string;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
export async function down(opts: DownOpts) {
|
|
447
|
+
out.heading('claude-tempo teardown');
|
|
448
|
+
|
|
449
|
+
// Step 1: Terminate all active workflows
|
|
450
|
+
const temporalUp = await isTemporalReachable(opts.temporalAddress);
|
|
451
|
+
if (temporalUp) {
|
|
452
|
+
try {
|
|
453
|
+
const connection = await Connection.connect({ address: opts.temporalAddress });
|
|
454
|
+
const client = new Client({ connection });
|
|
455
|
+
const query = 'WorkflowType = "claudeSessionWorkflow" AND ExecutionStatus = "Running"';
|
|
456
|
+
let terminated = 0;
|
|
457
|
+
for await (const wf of client.workflow.list({ query })) {
|
|
458
|
+
try {
|
|
459
|
+
const handle = client.workflow.getHandle(wf.workflowId);
|
|
460
|
+
await handle.terminate('claude-tempo down');
|
|
461
|
+
terminated++;
|
|
462
|
+
} catch { /* already closed */ }
|
|
463
|
+
}
|
|
464
|
+
await connection.close();
|
|
465
|
+
if (terminated > 0) {
|
|
466
|
+
out.success(`Terminated ${terminated} active session${terminated !== 1 ? 's' : ''}`);
|
|
467
|
+
} else {
|
|
468
|
+
out.log(` ${out.dim('No active sessions to terminate')}`);
|
|
469
|
+
}
|
|
470
|
+
} catch {
|
|
471
|
+
out.warn('Could not terminate active sessions');
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// Step 2: Stop Temporal server
|
|
476
|
+
if (temporalUp) {
|
|
477
|
+
// Find and kill the temporal dev server process
|
|
478
|
+
try {
|
|
479
|
+
if (process.platform === 'win32') {
|
|
480
|
+
execFileSync('taskkill', ['/F', '/IM', 'temporal.exe'], { stdio: 'ignore' });
|
|
481
|
+
} else {
|
|
482
|
+
// Kill temporal server processes started by start-dev
|
|
483
|
+
execFileSync('pkill', ['-f', 'temporal server start-dev'], { stdio: 'ignore' });
|
|
484
|
+
}
|
|
485
|
+
out.success('Temporal server stopped');
|
|
486
|
+
} catch {
|
|
487
|
+
out.warn('Could not stop Temporal server (may need to stop it manually)');
|
|
488
|
+
}
|
|
489
|
+
} else {
|
|
490
|
+
out.log(` ${out.dim('Temporal not running')}`);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// Step 3: Remove .mcp.json entry
|
|
494
|
+
if (opts.removeMcp) {
|
|
495
|
+
const mcpPath = join(opts.dir, '.mcp.json');
|
|
496
|
+
if (existsSync(mcpPath)) {
|
|
497
|
+
try {
|
|
498
|
+
const existing = JSON.parse(readFileSync(mcpPath, 'utf8'));
|
|
499
|
+
if (existing?.mcpServers?.['claude-tempo']) {
|
|
500
|
+
delete existing.mcpServers['claude-tempo'];
|
|
501
|
+
// If no other MCP servers remain, remove the file entirely
|
|
502
|
+
if (Object.keys(existing.mcpServers).length === 0) {
|
|
503
|
+
const { unlinkSync } = require('fs');
|
|
504
|
+
unlinkSync(mcpPath);
|
|
505
|
+
out.success('Removed .mcp.json (no other servers configured)');
|
|
506
|
+
} else {
|
|
507
|
+
writeFileSync(mcpPath, JSON.stringify(existing, null, 2) + '\n');
|
|
508
|
+
out.success('Removed claude-tempo from .mcp.json');
|
|
509
|
+
}
|
|
510
|
+
} else {
|
|
511
|
+
out.log(` ${out.dim('.mcp.json has no claude-tempo entry')}`);
|
|
512
|
+
}
|
|
513
|
+
} catch {
|
|
514
|
+
out.warn(`Could not update ${mcpPath}`);
|
|
515
|
+
}
|
|
516
|
+
} else {
|
|
517
|
+
out.log(` ${out.dim('No .mcp.json found')}`);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
console.log();
|
|
522
|
+
out.success('claude-tempo is shut down');
|
|
523
|
+
out.log(` ${out.dim('Temporal data preserved in ~/.claude-tempo/ (delete manually to reset)')}`);
|
|
524
|
+
console.log();
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
export function help() {
|
|
528
|
+
console.log(`
|
|
529
|
+
${out.bold('claude-tempo')} — Multi-session Claude Code coordination via Temporal
|
|
530
|
+
|
|
531
|
+
${out.bold('Getting started:')}
|
|
532
|
+
${out.cyan('claude-tempo up')} Set up everything and launch a conductor
|
|
533
|
+
|
|
534
|
+
${out.bold('Usage:')}
|
|
535
|
+
claude-tempo <command> [options]
|
|
536
|
+
|
|
537
|
+
${out.bold('Commands:')}
|
|
538
|
+
${out.cyan('up')} [ensemble] First-time setup: start Temporal, configure MCP, launch conductor
|
|
539
|
+
${out.cyan('down')} Stop Temporal, terminate sessions, remove MCP config
|
|
540
|
+
${out.cyan('server')} Start the Temporal dev server and register search attributes
|
|
541
|
+
${out.cyan('conduct')} [ensemble] Start a conductor session (one per ensemble)
|
|
542
|
+
${out.cyan('start')} [ensemble] Start a player session
|
|
543
|
+
${out.cyan('status')} [ensemble] Show active sessions and Temporal health
|
|
544
|
+
${out.cyan('init')} Create .mcp.json config in the current directory
|
|
545
|
+
${out.cyan('preflight')} Run preflight checks only
|
|
546
|
+
${out.cyan('help')} Show this help message
|
|
547
|
+
|
|
548
|
+
${out.bold('Options:')}
|
|
549
|
+
--temporal-address <addr> Temporal server address (default: localhost:7233)
|
|
550
|
+
-n, --name <name> Set the session window name (start/conduct/up only)
|
|
551
|
+
--skip-preflight Skip preflight checks (start/conduct only)
|
|
552
|
+
--background Run Temporal in background (server only)
|
|
553
|
+
--keep-mcp Don't remove .mcp.json entry (down only)
|
|
554
|
+
--dir <path> Target directory for init/down (default: cwd)
|
|
555
|
+
|
|
556
|
+
${out.bold('First time? Run this:')}
|
|
557
|
+
${out.dim('cd your-project')}
|
|
558
|
+
${out.dim('claude-tempo up')}
|
|
559
|
+
|
|
560
|
+
${out.bold('Typical workflow:')}
|
|
561
|
+
${out.dim('claude-tempo server')} Start Temporal (once, keep running)
|
|
562
|
+
${out.dim('claude-tempo conduct myband')} Start a conductor
|
|
563
|
+
${out.dim('claude-tempo start myband')} Add player sessions
|
|
564
|
+
${out.dim('claude-tempo status myband')} Check who's active
|
|
565
|
+
|
|
566
|
+
${out.bold('Environment:')}
|
|
567
|
+
CLAUDE_TEMPO_ENSEMBLE Default ensemble name (fallback: "default")
|
|
568
|
+
TEMPORAL_ADDRESS Default Temporal address (fallback: localhost:7233)
|
|
569
|
+
`);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
export function version() {
|
|
573
|
+
try {
|
|
574
|
+
const pkg = JSON.parse(readFileSync(join(PACKAGE_ROOT, 'package.json'), 'utf8'));
|
|
575
|
+
out.log(`claude-tempo v${pkg.version}`);
|
|
576
|
+
} catch {
|
|
577
|
+
out.log('claude-tempo (unknown version)');
|
|
578
|
+
}
|
|
579
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
const isColor = process.stdout.isTTY && !process.env.NO_COLOR;
|
|
2
|
+
|
|
3
|
+
const ansi = (code: string) => (s: string) => isColor ? `\x1b[${code}m${s}\x1b[0m` : s;
|
|
4
|
+
|
|
5
|
+
export const bold = ansi('1');
|
|
6
|
+
export const dim = ansi('2');
|
|
7
|
+
export const red = ansi('31');
|
|
8
|
+
export const green = ansi('32');
|
|
9
|
+
export const yellow = ansi('33');
|
|
10
|
+
export const cyan = ansi('36');
|
|
11
|
+
|
|
12
|
+
export function log(msg: string) {
|
|
13
|
+
console.log(msg);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function error(msg: string) {
|
|
17
|
+
console.error(`${red('error')} ${msg}`);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function success(msg: string) {
|
|
21
|
+
console.log(`${green('ok')} ${msg}`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function warn(msg: string) {
|
|
25
|
+
console.log(`${yellow('warn')} ${msg}`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function heading(msg: string) {
|
|
29
|
+
console.log(`\n${bold(msg)}`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function check(label: string, ok: boolean, detail?: string) {
|
|
33
|
+
const icon = ok ? green('pass') : red('FAIL');
|
|
34
|
+
const suffix = detail ? ` ${dim(`(${detail})`)}` : '';
|
|
35
|
+
console.log(` ${icon} ${label}${suffix}`);
|
|
36
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'fs';
|
|
2
|
+
import { execFileSync } from 'child_process';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import { Connection } from '@temporalio/client';
|
|
5
|
+
import { resolveClaudePath } from '../spawn';
|
|
6
|
+
import * as out from './output';
|
|
7
|
+
|
|
8
|
+
export interface PreflightResult {
|
|
9
|
+
ok: boolean;
|
|
10
|
+
errors: string[];
|
|
11
|
+
warnings: string[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function whichSync(cmd: string): string | null {
|
|
15
|
+
const bin = process.platform === 'win32' ? 'where' : 'which';
|
|
16
|
+
try {
|
|
17
|
+
return execFileSync(bin, [cmd], { encoding: 'utf8' }).trim().split('\n')[0];
|
|
18
|
+
} catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function runPreflight(opts: {
|
|
24
|
+
temporalAddress: string;
|
|
25
|
+
projectDir: string;
|
|
26
|
+
}): Promise<PreflightResult> {
|
|
27
|
+
const errors: string[] = [];
|
|
28
|
+
const warnings: string[] = [];
|
|
29
|
+
|
|
30
|
+
out.heading('Preflight checks');
|
|
31
|
+
|
|
32
|
+
// 1. Node.js version
|
|
33
|
+
const major = parseInt(process.version.slice(1), 10);
|
|
34
|
+
const nodeOk = major >= 18;
|
|
35
|
+
out.check('Node.js >= 18', nodeOk, process.version);
|
|
36
|
+
if (!nodeOk) errors.push(`Node.js 18+ required, found ${process.version}`);
|
|
37
|
+
|
|
38
|
+
// 2. Temporal reachable
|
|
39
|
+
let temporalOk = false;
|
|
40
|
+
try {
|
|
41
|
+
const conn = await Promise.race([
|
|
42
|
+
Connection.connect({ address: opts.temporalAddress }),
|
|
43
|
+
new Promise<never>((_, reject) => setTimeout(() => reject(new Error('timeout')), 3000)),
|
|
44
|
+
]);
|
|
45
|
+
await conn.close();
|
|
46
|
+
temporalOk = true;
|
|
47
|
+
} catch { /* unreachable */ }
|
|
48
|
+
out.check('Temporal reachable', temporalOk, opts.temporalAddress);
|
|
49
|
+
if (!temporalOk) errors.push(`Cannot connect to Temporal at ${opts.temporalAddress}. Run: temporal server start-dev`);
|
|
50
|
+
|
|
51
|
+
// 3. claude binary
|
|
52
|
+
const claudePath = resolveClaudePath();
|
|
53
|
+
const claudeOk = claudePath !== 'claude';
|
|
54
|
+
out.check('claude binary found', claudeOk, claudeOk ? claudePath : 'not on PATH');
|
|
55
|
+
if (!claudeOk) errors.push('claude binary not found on PATH. Install Claude Code first.');
|
|
56
|
+
|
|
57
|
+
// 4. claude-tempo-server binary (the MCP server)
|
|
58
|
+
const serverPath = whichSync('claude-tempo-server');
|
|
59
|
+
const serverOk = !!serverPath;
|
|
60
|
+
out.check('claude-tempo-server found', serverOk, serverOk ? serverPath! : 'not on PATH');
|
|
61
|
+
if (!serverOk) errors.push('claude-tempo-server not found on PATH. Run: npm install -g claude-tempo');
|
|
62
|
+
|
|
63
|
+
// 5. MCP config in project
|
|
64
|
+
const mcpPath = join(opts.projectDir, '.mcp.json');
|
|
65
|
+
let mcpOk = false;
|
|
66
|
+
if (existsSync(mcpPath)) {
|
|
67
|
+
try {
|
|
68
|
+
const mcp = JSON.parse(readFileSync(mcpPath, 'utf8'));
|
|
69
|
+
mcpOk = !!mcp?.mcpServers?.['claude-tempo'];
|
|
70
|
+
} catch { /* invalid json */ }
|
|
71
|
+
}
|
|
72
|
+
out.check('.mcp.json configured', mcpOk, mcpOk ? mcpPath : 'missing or no claude-tempo entry');
|
|
73
|
+
if (!mcpOk) warnings.push(`No claude-tempo MCP config in ${opts.projectDir}. Run: claude-tempo init`);
|
|
74
|
+
|
|
75
|
+
console.log();
|
|
76
|
+
return { ok: errors.length === 0, errors, warnings };
|
|
77
|
+
}
|