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
package/src/cli.ts
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { start, status, init, server, up, down, help, version } from './cli/commands';
|
|
4
|
+
import { runPreflight } from './cli/preflight';
|
|
5
|
+
import * as out from './cli/output';
|
|
6
|
+
|
|
7
|
+
interface ParsedArgs {
|
|
8
|
+
command: string;
|
|
9
|
+
positional: string[];
|
|
10
|
+
temporalAddress: string;
|
|
11
|
+
name?: string;
|
|
12
|
+
dir: string;
|
|
13
|
+
skipPreflight: boolean;
|
|
14
|
+
background: boolean;
|
|
15
|
+
keepMcp: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function parseArgs(argv: string[]): ParsedArgs {
|
|
19
|
+
const result: ParsedArgs = {
|
|
20
|
+
command: 'help',
|
|
21
|
+
positional: [],
|
|
22
|
+
temporalAddress: process.env.TEMPORAL_ADDRESS || 'localhost:7233',
|
|
23
|
+
dir: process.cwd(),
|
|
24
|
+
skipPreflight: false,
|
|
25
|
+
background: false,
|
|
26
|
+
keepMcp: false,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
let i = 0;
|
|
30
|
+
while (i < argv.length) {
|
|
31
|
+
const arg = argv[i];
|
|
32
|
+
if (arg === '--temporal-address' && i + 1 < argv.length) {
|
|
33
|
+
result.temporalAddress = argv[++i];
|
|
34
|
+
} else if ((arg === '-n' || arg === '--name') && i + 1 < argv.length) {
|
|
35
|
+
result.name = argv[++i];
|
|
36
|
+
} else if (arg === '--dir' && i + 1 < argv.length) {
|
|
37
|
+
result.dir = argv[++i];
|
|
38
|
+
} else if (arg === '--skip-preflight') {
|
|
39
|
+
result.skipPreflight = true;
|
|
40
|
+
} else if (arg === '--background' || arg === '-d') {
|
|
41
|
+
result.background = true;
|
|
42
|
+
} else if (arg === '--keep-mcp') {
|
|
43
|
+
result.keepMcp = true;
|
|
44
|
+
} else if (arg === '--help' || arg === '-h') {
|
|
45
|
+
result.command = 'help';
|
|
46
|
+
} else if (arg === '--version' || arg === '-v') {
|
|
47
|
+
result.command = 'version';
|
|
48
|
+
} else if (!arg.startsWith('-')) {
|
|
49
|
+
result.positional.push(arg);
|
|
50
|
+
} else {
|
|
51
|
+
out.error(`Unknown option: ${arg}`);
|
|
52
|
+
out.log(`Run ${out.dim('claude-tempo help')} for usage.`);
|
|
53
|
+
process.exit(1);
|
|
54
|
+
}
|
|
55
|
+
i++;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (result.positional.length > 0) {
|
|
59
|
+
result.command = result.positional[0];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return result;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function main() {
|
|
66
|
+
const args = parseArgs(process.argv.slice(2));
|
|
67
|
+
const ensemble = args.positional[1] || process.env.CLAUDE_TEMPO_ENSEMBLE || 'default';
|
|
68
|
+
|
|
69
|
+
switch (args.command) {
|
|
70
|
+
case 'conduct':
|
|
71
|
+
await start({
|
|
72
|
+
ensemble,
|
|
73
|
+
conductor: true,
|
|
74
|
+
temporalAddress: args.temporalAddress,
|
|
75
|
+
name: args.name,
|
|
76
|
+
skipPreflight: args.skipPreflight,
|
|
77
|
+
});
|
|
78
|
+
break;
|
|
79
|
+
|
|
80
|
+
case 'start':
|
|
81
|
+
await start({
|
|
82
|
+
ensemble,
|
|
83
|
+
conductor: false,
|
|
84
|
+
temporalAddress: args.temporalAddress,
|
|
85
|
+
name: args.name,
|
|
86
|
+
skipPreflight: args.skipPreflight,
|
|
87
|
+
});
|
|
88
|
+
break;
|
|
89
|
+
|
|
90
|
+
case 'status':
|
|
91
|
+
await status({
|
|
92
|
+
ensemble: args.positional[1], // undefined = show all
|
|
93
|
+
temporalAddress: args.temporalAddress,
|
|
94
|
+
});
|
|
95
|
+
break;
|
|
96
|
+
|
|
97
|
+
case 'server':
|
|
98
|
+
await server({
|
|
99
|
+
temporalAddress: args.temporalAddress,
|
|
100
|
+
background: args.background,
|
|
101
|
+
});
|
|
102
|
+
break;
|
|
103
|
+
|
|
104
|
+
case 'down':
|
|
105
|
+
await down({
|
|
106
|
+
temporalAddress: args.temporalAddress,
|
|
107
|
+
removeMcp: !args.keepMcp,
|
|
108
|
+
dir: args.dir,
|
|
109
|
+
});
|
|
110
|
+
break;
|
|
111
|
+
|
|
112
|
+
case 'up':
|
|
113
|
+
await up({
|
|
114
|
+
ensemble,
|
|
115
|
+
temporalAddress: args.temporalAddress,
|
|
116
|
+
name: args.name,
|
|
117
|
+
});
|
|
118
|
+
break;
|
|
119
|
+
|
|
120
|
+
case 'init':
|
|
121
|
+
await init({ dir: args.dir });
|
|
122
|
+
break;
|
|
123
|
+
|
|
124
|
+
case 'preflight':
|
|
125
|
+
const result = await runPreflight({
|
|
126
|
+
temporalAddress: args.temporalAddress,
|
|
127
|
+
projectDir: args.dir,
|
|
128
|
+
});
|
|
129
|
+
for (const w of result.warnings) out.warn(w);
|
|
130
|
+
if (!result.ok) {
|
|
131
|
+
for (const e of result.errors) out.error(e);
|
|
132
|
+
process.exit(1);
|
|
133
|
+
}
|
|
134
|
+
out.success('All checks passed');
|
|
135
|
+
break;
|
|
136
|
+
|
|
137
|
+
case 'version':
|
|
138
|
+
version();
|
|
139
|
+
break;
|
|
140
|
+
|
|
141
|
+
case 'help':
|
|
142
|
+
default:
|
|
143
|
+
help();
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
main().catch((err) => {
|
|
149
|
+
out.error(err.message || String(err));
|
|
150
|
+
process.exit(1);
|
|
151
|
+
});
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export interface Config {
|
|
2
|
+
temporalAddress: string;
|
|
3
|
+
temporalNamespace: string;
|
|
4
|
+
taskQueue: string;
|
|
5
|
+
ensemble: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function getConfig(): Config {
|
|
9
|
+
return {
|
|
10
|
+
temporalAddress: process.env.TEMPORAL_ADDRESS ?? 'localhost:7233',
|
|
11
|
+
temporalNamespace: process.env.TEMPORAL_NAMESPACE ?? 'default',
|
|
12
|
+
taskQueue: process.env.CLAUDE_TEMPO_TASK_QUEUE ?? 'claude-tempo',
|
|
13
|
+
ensemble: process.env.CLAUDE_TEMPO_ENSEMBLE ?? 'default',
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Build a workflow ID for a player session: claude-session-{ensemble}-{playerId} */
|
|
18
|
+
export function sessionWorkflowId(ensemble: string, playerId: string): string {
|
|
19
|
+
return `claude-session-${ensemble}-${playerId}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Build a workflow ID for a conductor: claude-session-{ensemble}-conductor */
|
|
23
|
+
export function conductorWorkflowId(ensemble: string): string {
|
|
24
|
+
return `claude-session-${ensemble}-conductor`;
|
|
25
|
+
}
|
package/src/server.ts
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import * as crypto from 'crypto';
|
|
3
|
+
import * as os from 'os';
|
|
4
|
+
import * as path from 'path';
|
|
5
|
+
import { execSync } from 'child_process';
|
|
6
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
7
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
8
|
+
import { Client, Connection, WorkflowIdConflictPolicy } from '@temporalio/client';
|
|
9
|
+
import { getConfig, conductorWorkflowId } from './config';
|
|
10
|
+
import { createWorker } from './worker';
|
|
11
|
+
import { SessionInput } from './types';
|
|
12
|
+
import { registerEnsembleTool } from './tools/ensemble';
|
|
13
|
+
import { registerCueTool } from './tools/cue';
|
|
14
|
+
import { registerSetPartTool } from './tools/set-part';
|
|
15
|
+
import { registerListenTool } from './tools/listen';
|
|
16
|
+
import { registerRecruitTool } from './tools/recruit';
|
|
17
|
+
import { registerReportTool } from './tools/report';
|
|
18
|
+
import { registerTerminateTool } from './tools/terminate';
|
|
19
|
+
import { registerSetNameTool } from './tools/set-name';
|
|
20
|
+
import { startMessagePoller } from './channel';
|
|
21
|
+
|
|
22
|
+
const log = (...args: unknown[]) => console.error('[claude-tempo]', ...args);
|
|
23
|
+
|
|
24
|
+
function getGitInfo(workDir: string): { gitRoot?: string; gitBranch?: string } {
|
|
25
|
+
try {
|
|
26
|
+
const gitRoot = execSync('git rev-parse --show-toplevel', {
|
|
27
|
+
cwd: workDir,
|
|
28
|
+
encoding: 'utf-8',
|
|
29
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
30
|
+
}).trim();
|
|
31
|
+
let gitBranch: string | undefined;
|
|
32
|
+
try {
|
|
33
|
+
gitBranch = execSync('git rev-parse --abbrev-ref HEAD', {
|
|
34
|
+
cwd: workDir,
|
|
35
|
+
encoding: 'utf-8',
|
|
36
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
37
|
+
}).trim();
|
|
38
|
+
} catch {
|
|
39
|
+
// not on a branch
|
|
40
|
+
}
|
|
41
|
+
return { gitRoot, gitBranch };
|
|
42
|
+
} catch {
|
|
43
|
+
return {};
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function main() {
|
|
48
|
+
// Only activate when explicitly opted in via CLAUDE_TEMPO_ENSEMBLE
|
|
49
|
+
if (!process.env.CLAUDE_TEMPO_ENSEMBLE) {
|
|
50
|
+
log('CLAUDE_TEMPO_ENSEMBLE not set — MCP server idle (no workflow started)');
|
|
51
|
+
// Keep the process alive so Claude Code doesn't see a crash, but do nothing
|
|
52
|
+
const transport = new StdioServerTransport();
|
|
53
|
+
const idleServer = new McpServer({ name: 'claude-tempo', version: '0.1.0' });
|
|
54
|
+
await idleServer.connect(transport);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const config = getConfig();
|
|
59
|
+
const isConductor = process.env.CLAUDE_TEMPO_CONDUCTOR === 'true';
|
|
60
|
+
let playerId = isConductor ? 'conductor' : crypto.randomBytes(4).toString('hex');
|
|
61
|
+
const getPlayerId = () => playerId;
|
|
62
|
+
const setPlayerId = (id: string) => { playerId = id; };
|
|
63
|
+
const workDir = process.cwd();
|
|
64
|
+
const { gitRoot, gitBranch } = getGitInfo(workDir);
|
|
65
|
+
|
|
66
|
+
log(`Starting ${isConductor ? 'conductor' : `peer ${playerId}`} in ${workDir}`);
|
|
67
|
+
|
|
68
|
+
// Connect Temporal client
|
|
69
|
+
const connection = await Connection.connect({
|
|
70
|
+
address: config.temporalAddress,
|
|
71
|
+
});
|
|
72
|
+
const client = new Client({
|
|
73
|
+
connection,
|
|
74
|
+
namespace: config.temporalNamespace,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// Start the Temporal worker (runs in background)
|
|
78
|
+
const worker = await createWorker(config);
|
|
79
|
+
const workerRunPromise = worker.run();
|
|
80
|
+
workerRunPromise.catch((err) => {
|
|
81
|
+
log('Worker error:', err);
|
|
82
|
+
process.exit(1);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// Start the session workflow
|
|
86
|
+
const workflowId = isConductor
|
|
87
|
+
? conductorWorkflowId(config.ensemble)
|
|
88
|
+
: `claude-session-${config.ensemble}-${playerId}`;
|
|
89
|
+
|
|
90
|
+
const sessionInput: SessionInput = {
|
|
91
|
+
metadata: {
|
|
92
|
+
playerId,
|
|
93
|
+
ensemble: config.ensemble,
|
|
94
|
+
hostname: os.hostname(),
|
|
95
|
+
workDir,
|
|
96
|
+
gitRoot,
|
|
97
|
+
gitBranch,
|
|
98
|
+
isConductor,
|
|
99
|
+
},
|
|
100
|
+
autoSummary: `Session in ${path.basename(workDir)}`,
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const handle = await client.workflow.start('claudeSessionWorkflow', {
|
|
104
|
+
workflowId,
|
|
105
|
+
taskQueue: config.taskQueue,
|
|
106
|
+
args: [sessionInput],
|
|
107
|
+
workflowIdConflictPolicy: WorkflowIdConflictPolicy.USE_EXISTING,
|
|
108
|
+
workflowExecutionTimeout: '24 hours',
|
|
109
|
+
searchAttributes: {
|
|
110
|
+
...(gitRoot ? { ClaudeTempoGitRoot: [gitRoot] } : {}),
|
|
111
|
+
ClaudeTempoHostname: [os.hostname()],
|
|
112
|
+
ClaudeTempoEnsemble: [config.ensemble],
|
|
113
|
+
ClaudeTempoPlayerId: [playerId],
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
log(`Workflow ${workflowId} started (or reconnected)`);
|
|
117
|
+
|
|
118
|
+
// If there's a conductor running, announce ourselves
|
|
119
|
+
if (!isConductor) {
|
|
120
|
+
try {
|
|
121
|
+
const conductorHandle = client.workflow.getHandle(conductorWorkflowId(config.ensemble));
|
|
122
|
+
await conductorHandle.signal('receiveMessage', {
|
|
123
|
+
from: playerId,
|
|
124
|
+
text: `Player ${playerId} joined from ${workDir}`,
|
|
125
|
+
});
|
|
126
|
+
} catch {
|
|
127
|
+
// No conductor running — that's fine
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Create MCP server
|
|
132
|
+
const serverInstructions = `You are part of the "${config.ensemble}" ensemble of Claude Code sessions coordinated via Temporal. ` +
|
|
133
|
+
`Your temporary player ID is "${playerId}". ` +
|
|
134
|
+
`IMPORTANT: If you receive a message instructing you to call \`set_name\`, do so immediately before anything else. ` +
|
|
135
|
+
`When you receive a message from another session, treat it like a coworker asking for help — respond promptly, then resume your work. ` +
|
|
136
|
+
`Use \`set_name\` to give yourself a human-readable name. ` +
|
|
137
|
+
`Use \`ensemble\` to see who else is active. ` +
|
|
138
|
+
`Use \`cue\` to reply directly to the player who messaged you, or to ask others for help. ` +
|
|
139
|
+
`Use \`recruit\` if you need a session in a directory where none exists. ` +
|
|
140
|
+
`Use \`report\` to notify the conductor of task completion, blockers, or questions — always report when you finish a recruited task.`;
|
|
141
|
+
|
|
142
|
+
const mcpServer = new McpServer({
|
|
143
|
+
name: 'claude-tempo',
|
|
144
|
+
version: '0.1.0',
|
|
145
|
+
}, {
|
|
146
|
+
capabilities: {
|
|
147
|
+
experimental: { 'claude/channel': {} },
|
|
148
|
+
},
|
|
149
|
+
instructions: serverInstructions,
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// Register tools
|
|
153
|
+
registerEnsembleTool(mcpServer, client, config, getPlayerId, workflowId);
|
|
154
|
+
registerCueTool(mcpServer, client, config, getPlayerId);
|
|
155
|
+
registerSetPartTool(mcpServer, handle);
|
|
156
|
+
registerSetNameTool(mcpServer, client, config, handle, getPlayerId, setPlayerId);
|
|
157
|
+
registerListenTool(mcpServer, handle);
|
|
158
|
+
registerRecruitTool(mcpServer, client, config, getPlayerId);
|
|
159
|
+
registerReportTool(mcpServer, client, config, getPlayerId);
|
|
160
|
+
registerTerminateTool(mcpServer, client, config, getPlayerId);
|
|
161
|
+
|
|
162
|
+
// Start message poller — push messages into Claude Code via channel notifications
|
|
163
|
+
const stopPoller = startMessagePoller(handle, async (messages) => {
|
|
164
|
+
for (const msg of messages) {
|
|
165
|
+
log(`Message from ${msg.from}: ${msg.text}`);
|
|
166
|
+
try {
|
|
167
|
+
await mcpServer.server.notification({
|
|
168
|
+
method: 'notifications/claude/channel',
|
|
169
|
+
params: {
|
|
170
|
+
content: msg.text,
|
|
171
|
+
meta: {
|
|
172
|
+
from_player: msg.from,
|
|
173
|
+
sent_at: msg.timestamp,
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
} catch (err) {
|
|
178
|
+
log('Channel notification error:', err);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// Connect MCP transport
|
|
184
|
+
const transport = new StdioServerTransport();
|
|
185
|
+
await mcpServer.connect(transport);
|
|
186
|
+
log('MCP server connected');
|
|
187
|
+
|
|
188
|
+
// Graceful shutdown
|
|
189
|
+
const shutdown = async () => {
|
|
190
|
+
log('Shutting down...');
|
|
191
|
+
stopPoller();
|
|
192
|
+
try {
|
|
193
|
+
await handle.signal('shutdown');
|
|
194
|
+
} catch {
|
|
195
|
+
try {
|
|
196
|
+
await handle.cancel();
|
|
197
|
+
} catch {
|
|
198
|
+
// workflow may already be gone
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
worker.shutdown();
|
|
202
|
+
await workerRunPromise.catch(() => {});
|
|
203
|
+
process.exit(0);
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
process.on('SIGINT', shutdown);
|
|
207
|
+
process.on('SIGTERM', shutdown);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
main().catch((err) => {
|
|
211
|
+
console.error('Fatal error:', err);
|
|
212
|
+
process.exit(1);
|
|
213
|
+
});
|
package/src/spawn.ts
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { spawn, execFileSync } from 'child_process';
|
|
2
|
+
import { writeFileSync } from 'fs';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import { tmpdir } from 'os';
|
|
5
|
+
|
|
6
|
+
const log = (...args: unknown[]) => console.error('[claude-tempo:spawn]', ...args);
|
|
7
|
+
|
|
8
|
+
/** POSIX shell-safe single-quoting (works in bash, zsh, and fish) */
|
|
9
|
+
export function shellQuote(s: string): string {
|
|
10
|
+
return `'${s.replace(/'/g, "'\\''")}'`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Resolve the absolute path to the `claude` binary */
|
|
14
|
+
export function resolveClaudePath(): string {
|
|
15
|
+
const cmd = process.platform === 'win32' ? 'where' : 'which';
|
|
16
|
+
try {
|
|
17
|
+
return execFileSync(cmd, ['claude'], { encoding: 'utf8' }).trim().split('\n')[0];
|
|
18
|
+
} catch {
|
|
19
|
+
return 'claude';
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Detect the macOS terminal the user is actually running in.
|
|
25
|
+
*
|
|
26
|
+
* Priority:
|
|
27
|
+
* 1. TERM_PROGRAM env var (most reliable when available — set by the terminal itself)
|
|
28
|
+
* 2. Check frontmost app via AppleScript (detects what the user is actively using)
|
|
29
|
+
* 3. Fall back to Terminal.app
|
|
30
|
+
*/
|
|
31
|
+
export function detectMacTerminal(): 'ghostty' | 'iterm2' | 'terminal' {
|
|
32
|
+
const termProgram = (process.env.TERM_PROGRAM || '').toLowerCase();
|
|
33
|
+
if (termProgram === 'ghostty') return 'ghostty';
|
|
34
|
+
if (termProgram === 'iterm.app' || termProgram === 'iterm2') return 'iterm2';
|
|
35
|
+
if (termProgram === 'apple_terminal') return 'terminal';
|
|
36
|
+
|
|
37
|
+
// MCP servers may not inherit TERM_PROGRAM — check which terminal app is running
|
|
38
|
+
// Prefer frontmost app detection over pgrep to avoid false positives
|
|
39
|
+
try {
|
|
40
|
+
const frontApp = execFileSync('osascript', ['-e',
|
|
41
|
+
'tell application "System Events" to get name of first application process whose frontmost is true',
|
|
42
|
+
], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim().toLowerCase();
|
|
43
|
+
if (frontApp === 'ghostty') return 'ghostty';
|
|
44
|
+
if (frontApp === 'iterm2') return 'iterm2';
|
|
45
|
+
if (frontApp === 'terminal') return 'terminal';
|
|
46
|
+
} catch { /* ignore */ }
|
|
47
|
+
|
|
48
|
+
// Last resort: check running processes
|
|
49
|
+
try {
|
|
50
|
+
execFileSync('pgrep', ['-x', 'ghostty'], { stdio: 'ignore' });
|
|
51
|
+
return 'ghostty';
|
|
52
|
+
} catch { /* not running */ }
|
|
53
|
+
try {
|
|
54
|
+
execFileSync('pgrep', ['-x', 'iTerm2'], { stdio: 'ignore' });
|
|
55
|
+
return 'iterm2';
|
|
56
|
+
} catch { /* not running */ }
|
|
57
|
+
|
|
58
|
+
return 'terminal';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Find the first available terminal emulator on Linux */
|
|
62
|
+
export function findLinuxTerminal(): string | null {
|
|
63
|
+
const candidates = ['gnome-terminal', 'konsole', 'x-terminal-emulator', 'xfce4-terminal', 'xterm'];
|
|
64
|
+
for (const term of candidates) {
|
|
65
|
+
try {
|
|
66
|
+
execFileSync('which', [term], { stdio: 'ignore' });
|
|
67
|
+
return term;
|
|
68
|
+
} catch {
|
|
69
|
+
// not found, try next
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Build a shell command string that sets env vars and runs claude.
|
|
77
|
+
* Uses inline `KEY=val` syntax which works in bash, zsh, AND fish.
|
|
78
|
+
*/
|
|
79
|
+
export function buildClaudeCommand(
|
|
80
|
+
claudeBin: string,
|
|
81
|
+
claudeArgs: string[],
|
|
82
|
+
envVars: Record<string, string>,
|
|
83
|
+
): string {
|
|
84
|
+
const envInline = Object.entries(envVars)
|
|
85
|
+
.map(([k, v]) => `${k}=${shellQuote(v)}`)
|
|
86
|
+
.join(' ');
|
|
87
|
+
const args = claudeArgs.map(a => shellQuote(a)).join(' ');
|
|
88
|
+
return envInline ? `${envInline} ${claudeBin} ${args}` : `${claudeBin} ${args}`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Spawn a Claude Code session in a visible terminal window.
|
|
93
|
+
*
|
|
94
|
+
* Strategy per terminal:
|
|
95
|
+
* - Ghostty: `initial input` into a normal window (preserves full shell env)
|
|
96
|
+
* - iTerm2: `write text` via AppleScript (same approach)
|
|
97
|
+
* - Terminal.app: .command script with shell profile sourcing
|
|
98
|
+
* - Windows: shell:true with env vars
|
|
99
|
+
* - Linux: terminal emulator with -e flag
|
|
100
|
+
*/
|
|
101
|
+
export function spawnInTerminal(
|
|
102
|
+
claudeArgs: string[],
|
|
103
|
+
workDir: string,
|
|
104
|
+
envVars: Record<string, string>,
|
|
105
|
+
): { pid: number | undefined } {
|
|
106
|
+
const claudeBin = resolveClaudePath();
|
|
107
|
+
const claudeInvocation = buildClaudeCommand(claudeBin, claudeArgs, envVars);
|
|
108
|
+
|
|
109
|
+
if (process.platform === 'darwin') {
|
|
110
|
+
const detected = detectMacTerminal();
|
|
111
|
+
log(`Terminal detection: TERM_PROGRAM=${JSON.stringify(process.env.TERM_PROGRAM)}, detected=${detected}`);
|
|
112
|
+
|
|
113
|
+
if (detected === 'ghostty') {
|
|
114
|
+
const osaScript = `
|
|
115
|
+
tell application "Ghostty"
|
|
116
|
+
set cfg to new surface configuration
|
|
117
|
+
set initial working directory of cfg to ${JSON.stringify(workDir)}
|
|
118
|
+
set initial input of cfg to ${JSON.stringify(claudeInvocation + '\n')}
|
|
119
|
+
set win to new window with configuration cfg
|
|
120
|
+
end tell`;
|
|
121
|
+
log('Using Ghostty initial-input path');
|
|
122
|
+
const child = spawn('osascript', ['-e', osaScript], {
|
|
123
|
+
detached: true, stdio: ['ignore', 'pipe', 'pipe'],
|
|
124
|
+
});
|
|
125
|
+
child.stderr?.on('data', (d: Buffer) => log('osascript stderr:', d.toString()));
|
|
126
|
+
child.stdout?.on('data', (d: Buffer) => log('osascript stdout:', d.toString()));
|
|
127
|
+
child.unref();
|
|
128
|
+
return { pid: child.pid };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (detected === 'iterm2') {
|
|
132
|
+
const osaScript = `
|
|
133
|
+
tell application "iTerm2"
|
|
134
|
+
set newWindow to (create window with default profile)
|
|
135
|
+
tell current session of newWindow
|
|
136
|
+
write text "cd ${shellQuote(workDir)} && ${claudeInvocation}"
|
|
137
|
+
end tell
|
|
138
|
+
end tell`;
|
|
139
|
+
log('Using iTerm2 write-text path');
|
|
140
|
+
const child = spawn('osascript', ['-e', osaScript], {
|
|
141
|
+
detached: true, stdio: ['ignore', 'pipe', 'pipe'],
|
|
142
|
+
});
|
|
143
|
+
child.stderr?.on('data', (d: Buffer) => log('osascript stderr:', d.toString()));
|
|
144
|
+
child.unref();
|
|
145
|
+
return { pid: child.pid };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Terminal.app: .command file with shell profile sourcing
|
|
149
|
+
const userShell = process.env.SHELL || '/bin/zsh';
|
|
150
|
+
const scriptPath = join(tmpdir(), `claude-tempo-recruit-${Date.now()}.command`);
|
|
151
|
+
let profileSource: string;
|
|
152
|
+
if (userShell.endsWith('/fish')) {
|
|
153
|
+
profileSource = `exec fish -c "cd ${shellQuote(workDir)} && ${claudeInvocation}"`;
|
|
154
|
+
} else {
|
|
155
|
+
profileSource = [
|
|
156
|
+
`[ -f "$HOME/.zshrc" ] && source "$HOME/.zshrc" 2>/dev/null`,
|
|
157
|
+
`[ -f "$HOME/.bashrc" ] && source "$HOME/.bashrc" 2>/dev/null`,
|
|
158
|
+
`[ -f "$HOME/.nvm/nvm.sh" ] && source "$HOME/.nvm/nvm.sh" 2>/dev/null`,
|
|
159
|
+
`command -v fnm >/dev/null && eval "$(fnm env)" 2>/dev/null`,
|
|
160
|
+
].join('\n');
|
|
161
|
+
}
|
|
162
|
+
const envExports = Object.entries(envVars)
|
|
163
|
+
.map(([k, v]) => `export ${k}=${shellQuote(v)}`)
|
|
164
|
+
.join('\n');
|
|
165
|
+
const lines = [
|
|
166
|
+
'#!/bin/bash',
|
|
167
|
+
profileSource,
|
|
168
|
+
envExports,
|
|
169
|
+
`cd ${shellQuote(workDir)}`,
|
|
170
|
+
`${shellQuote(claudeBin)} ${claudeArgs.map(a => shellQuote(a)).join(' ')}`,
|
|
171
|
+
];
|
|
172
|
+
writeFileSync(scriptPath, lines.join('\n') + '\n', { mode: 0o755 });
|
|
173
|
+
log('Using Terminal.app .command path:', scriptPath);
|
|
174
|
+
const child = spawn('open', [scriptPath], { detached: true, stdio: 'ignore' });
|
|
175
|
+
child.unref();
|
|
176
|
+
return { pid: child.pid };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (process.platform === 'win32') {
|
|
180
|
+
const child = spawn(claudeBin, claudeArgs, {
|
|
181
|
+
cwd: workDir,
|
|
182
|
+
detached: true,
|
|
183
|
+
stdio: 'ignore',
|
|
184
|
+
shell: true,
|
|
185
|
+
env: { ...process.env, ...envVars },
|
|
186
|
+
});
|
|
187
|
+
child.unref();
|
|
188
|
+
return { pid: child.pid };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Linux
|
|
192
|
+
const envExports = Object.entries(envVars)
|
|
193
|
+
.map(([k, v]) => `export ${k}=${shellQuote(v)}`)
|
|
194
|
+
.join('; ');
|
|
195
|
+
const fullCmd = `${envExports}; cd ${shellQuote(workDir)} && ${shellQuote(claudeBin)} ${claudeArgs.map(a => shellQuote(a)).join(' ')}`;
|
|
196
|
+
|
|
197
|
+
const terminal = findLinuxTerminal();
|
|
198
|
+
if (!terminal) {
|
|
199
|
+
log('No terminal emulator found on Linux, falling back to headless spawn');
|
|
200
|
+
const child = spawn('bash', ['-c', fullCmd], { detached: true, stdio: 'ignore' });
|
|
201
|
+
child.unref();
|
|
202
|
+
return { pid: child.pid };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
let child;
|
|
206
|
+
if (terminal === 'gnome-terminal') {
|
|
207
|
+
child = spawn(terminal, ['--', 'bash', '-c', fullCmd], { detached: true, stdio: 'ignore' });
|
|
208
|
+
} else {
|
|
209
|
+
child = spawn(terminal, ['-e', 'bash', '-c', fullCmd], { detached: true, stdio: 'ignore' });
|
|
210
|
+
}
|
|
211
|
+
child.unref();
|
|
212
|
+
return { pid: child.pid };
|
|
213
|
+
}
|
package/src/tools/cue.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
3
|
+
import { Client } from '@temporalio/client';
|
|
4
|
+
import { Config, sessionWorkflowId } from '../config';
|
|
5
|
+
import { resolveSession } from './resolve';
|
|
6
|
+
import { defineTool } from './helpers';
|
|
7
|
+
|
|
8
|
+
const log = (...args: unknown[]) => console.error('[claude-tempo:cue]', ...args);
|
|
9
|
+
|
|
10
|
+
export function registerCueTool(
|
|
11
|
+
server: McpServer,
|
|
12
|
+
client: Client,
|
|
13
|
+
config: Config,
|
|
14
|
+
getPlayerId: () => string,
|
|
15
|
+
) {
|
|
16
|
+
defineTool(
|
|
17
|
+
server,
|
|
18
|
+
'cue',
|
|
19
|
+
'Send a message to another Claude Code session by player name. Delivered instantly via Temporal signal.',
|
|
20
|
+
{
|
|
21
|
+
playerId: z.string().describe('The player name of the target session'),
|
|
22
|
+
message: z.string().describe('The message to send'),
|
|
23
|
+
},
|
|
24
|
+
async (args) => {
|
|
25
|
+
const { playerId, message } = args as { playerId: string; message: string };
|
|
26
|
+
try {
|
|
27
|
+
const handle = await resolveSession(client, config.ensemble, playerId);
|
|
28
|
+
if (!handle) {
|
|
29
|
+
return {
|
|
30
|
+
content: [{ type: 'text' as const, text: `No active session found with name "${playerId}".` }],
|
|
31
|
+
isError: true,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
await handle.signal('receiveMessage', {
|
|
35
|
+
from: getPlayerId(),
|
|
36
|
+
text: message,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// Record outbound message on sender's own workflow
|
|
40
|
+
try {
|
|
41
|
+
const senderHandle = client.workflow.getHandle(
|
|
42
|
+
sessionWorkflowId(config.ensemble, getPlayerId()),
|
|
43
|
+
);
|
|
44
|
+
await senderHandle.signal('recordSentMessage', { to: playerId, text: message });
|
|
45
|
+
} catch (err) {
|
|
46
|
+
log('Failed to record sent message:', err);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
content: [{ type: 'text' as const, text: `Message sent to ${playerId}.` }],
|
|
51
|
+
};
|
|
52
|
+
} catch (err) {
|
|
53
|
+
return {
|
|
54
|
+
content: [{ type: 'text' as const, text: `Failed to send message to ${playerId}: ${err}` }],
|
|
55
|
+
isError: true,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
);
|
|
60
|
+
}
|