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.
@@ -0,0 +1,204 @@
1
+ import {
2
+ setHandler,
3
+ condition,
4
+ continueAsNew,
5
+ workflowInfo,
6
+ allHandlersFinished,
7
+ upsertSearchAttributes,
8
+ getExternalWorkflowHandle,
9
+ uuid4,
10
+ } from '@temporalio/workflow';
11
+
12
+ import {
13
+ SessionInput,
14
+ Message,
15
+ SentMessage,
16
+ Command,
17
+ PlayerReport,
18
+ HistoryEntry,
19
+ receiveMessageSignal,
20
+ setPartSignal,
21
+ setNameSignal,
22
+ shutdownSignal,
23
+ markDeliveredSignal,
24
+ getPartQuery,
25
+ getMetadataQuery,
26
+ pendingMessagesQuery,
27
+ allMessagesQuery,
28
+ recordSentMessageSignal,
29
+ allSentMessagesQuery,
30
+ commandSignal,
31
+ playerReportSignal,
32
+ historyQuery,
33
+ } from './signals';
34
+
35
+ export async function claudeSessionWorkflow(input: SessionInput): Promise<void> {
36
+ const STALE_MESSAGE_MS = 3 * 60 * 1000; // 3 minutes
37
+
38
+ // State (carried across continue-as-new)
39
+ let part = input.part ?? input.autoSummary ?? 'No description set';
40
+ const messages: Message[] = input.messages ?? [];
41
+ const sentMessages: SentMessage[] = input.sentMessages ?? [];
42
+ let shuttingDown = false;
43
+
44
+ // ── Player Signal Handlers ──
45
+
46
+ setHandler(receiveMessageSignal, (msg) => {
47
+ messages.push({
48
+ id: uuid4(),
49
+ from: msg.from,
50
+ text: msg.text,
51
+ timestamp: new Date().toISOString(),
52
+ delivered: false,
53
+ });
54
+ });
55
+
56
+ setHandler(setPartSignal, (newPart) => {
57
+ part = newPart;
58
+ });
59
+
60
+ setHandler(setNameSignal, (newName) => {
61
+ input.metadata.playerId = newName;
62
+ upsertSearchAttributes({ ClaudeTempoPlayerId: [newName] });
63
+ });
64
+
65
+ setHandler(shutdownSignal, () => {
66
+ shuttingDown = true;
67
+ });
68
+
69
+ setHandler(markDeliveredSignal, (ids) => {
70
+ for (const msg of messages) {
71
+ if (ids.includes(msg.id)) {
72
+ msg.delivered = true;
73
+ }
74
+ }
75
+ });
76
+
77
+ setHandler(recordSentMessageSignal, (msg) => {
78
+ sentMessages.push({
79
+ id: uuid4(),
80
+ to: msg.to,
81
+ text: msg.text,
82
+ timestamp: new Date().toISOString(),
83
+ });
84
+ });
85
+
86
+ // ── Player Query Handlers ──
87
+
88
+ setHandler(getPartQuery, () => part);
89
+ setHandler(getMetadataQuery, () => input.metadata);
90
+ setHandler(pendingMessagesQuery, () => messages.filter((m) => !m.delivered));
91
+ setHandler(allMessagesQuery, () => messages);
92
+ setHandler(allSentMessagesQuery, () => sentMessages);
93
+
94
+ // ── Conductor State ──
95
+
96
+ const commandHistory: Command[] = input.commandHistory ?? [];
97
+ const reportHistory: PlayerReport[] = input.reportHistory ?? [];
98
+
99
+ // ── Conductor-specific Handlers ──
100
+
101
+ if (input.metadata.isConductor) {
102
+
103
+ setHandler(commandSignal, (cmd) => {
104
+ commandHistory.push({
105
+ ...cmd,
106
+ timestamp: new Date().toISOString(),
107
+ });
108
+ // Deliver command as a message to self so the conductor's Claude session sees it
109
+ messages.push({
110
+ id: uuid4(),
111
+ from: cmd.source,
112
+ text: cmd.text,
113
+ timestamp: new Date().toISOString(),
114
+ delivered: false,
115
+ });
116
+ });
117
+
118
+ setHandler(playerReportSignal, (report) => {
119
+ reportHistory.push({
120
+ ...report,
121
+ timestamp: new Date().toISOString(),
122
+ });
123
+ // Deliver report as a message to self
124
+ messages.push({
125
+ id: uuid4(),
126
+ from: report.playerId,
127
+ text: `[${report.type}] ${report.text}`,
128
+ timestamp: new Date().toISOString(),
129
+ delivered: false,
130
+ });
131
+ });
132
+
133
+ setHandler(historyQuery, (): HistoryEntry[] => {
134
+ const entries: HistoryEntry[] = [
135
+ ...commandHistory.map((c): HistoryEntry => ({
136
+ type: 'command',
137
+ timestamp: c.timestamp,
138
+ data: c,
139
+ })),
140
+ ...reportHistory.map((r): HistoryEntry => ({
141
+ type: 'report',
142
+ timestamp: r.timestamp,
143
+ data: r,
144
+ })),
145
+ ];
146
+ return entries.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
147
+ });
148
+ }
149
+
150
+ // ── Main Loop ──
151
+
152
+ let staleExit = false;
153
+
154
+ while (!shuttingDown) {
155
+ await condition(() => shuttingDown, '5 minutes');
156
+
157
+ if (shuttingDown) break;
158
+
159
+ // Detect stale session: messages pending longer than threshold means poller is dead
160
+ if (!input.disableStaleDetection) {
161
+ const now = Date.now();
162
+ const staleMessages = messages.filter(
163
+ (m) => !m.delivered && now - new Date(m.timestamp).getTime() > STALE_MESSAGE_MS,
164
+ );
165
+ if (staleMessages.length > 0) {
166
+ staleExit = true;
167
+ break;
168
+ }
169
+ }
170
+
171
+ // Prevent unbounded history growth
172
+ const info = workflowInfo();
173
+ if (info.continueAsNewSuggested || info.historyLength > 10_000) {
174
+ await condition(allHandlersFinished);
175
+ await continueAsNew<typeof claudeSessionWorkflow>({
176
+ ...input,
177
+ part,
178
+ messages: messages.filter((m) => !m.delivered),
179
+ sentMessages: sentMessages.slice(-50),
180
+ ...(input.metadata.isConductor ? { commandHistory, reportHistory } : {}),
181
+ });
182
+ }
183
+ }
184
+
185
+ // Notify conductor with undelivered messages before exiting
186
+ if (staleExit && !input.metadata.isConductor) {
187
+ try {
188
+ const undelivered = messages.filter((m) => !m.delivered);
189
+ const summary = undelivered.map((m) => ` From ${m.from}: ${m.text}`).join('\n');
190
+ const conductorWfId = `claude-session-${input.metadata.ensemble}-conductor`;
191
+ const handle = getExternalWorkflowHandle(conductorWfId);
192
+ await handle.signal(playerReportSignal, {
193
+ playerId: input.metadata.playerId,
194
+ text: `Session ended — ${undelivered.length} undelivered message(s):\n${summary}`,
195
+ type: 'blocker',
196
+ });
197
+ } catch {
198
+ // No conductor running — that's fine
199
+ }
200
+ }
201
+
202
+ // Graceful shutdown — wait for in-flight handlers
203
+ await condition(allHandlersFinished);
204
+ }
@@ -0,0 +1,44 @@
1
+ import { defineSignal, defineQuery } from '@temporalio/workflow';
2
+ import type {
3
+ SessionMetadata,
4
+ Message,
5
+ SentMessage,
6
+ HistoryEntry,
7
+ } from '../types';
8
+
9
+ // Re-export types for convenience within workflow code
10
+ export type {
11
+ SessionMetadata,
12
+ SessionInput,
13
+ Message,
14
+ Command,
15
+ PlayerReport,
16
+ SentMessage,
17
+ HistoryEntry,
18
+ } from '../types';
19
+
20
+ // ── Player Signals ──
21
+
22
+ export const receiveMessageSignal = defineSignal<[{ from: string; text: string }]>('receiveMessage');
23
+ export const recordSentMessageSignal = defineSignal<[{ to: string; text: string }]>('recordSentMessage');
24
+ export const setPartSignal = defineSignal<[string]>('setPart');
25
+ export const shutdownSignal = defineSignal('shutdown');
26
+ export const markDeliveredSignal = defineSignal<[string[]]>('markDelivered');
27
+ export const setNameSignal = defineSignal<[string]>('setName');
28
+
29
+ // ── Player Queries ──
30
+
31
+ export const getPartQuery = defineQuery<string>('getPart');
32
+ export const getMetadataQuery = defineQuery<SessionMetadata>('getMetadata');
33
+ export const pendingMessagesQuery = defineQuery<Message[]>('pendingMessages');
34
+ export const allMessagesQuery = defineQuery<Message[]>('allMessages');
35
+ export const allSentMessagesQuery = defineQuery<SentMessage[]>('allSentMessages');
36
+
37
+ // ── Conductor Signals ──
38
+
39
+ export const commandSignal = defineSignal<[{ text: string; source: string; replyTo?: string }]>('command');
40
+ export const playerReportSignal = defineSignal<[{ playerId: string; text: string; type: 'result' | 'blocker' | 'question' }]>('playerReport');
41
+
42
+ // ── Conductor Queries ──
43
+
44
+ export const historyQuery = defineQuery<HistoryEntry[]>('history');
@@ -0,0 +1,201 @@
1
+ # Recruit Terminal Spawn — Manual Test Plan
2
+
3
+ Tests for `src/tools/recruit.ts` terminal spawning across platforms and configurations.
4
+
5
+ ## Prerequisites
6
+
7
+ - Temporal dev server running (`temporal server start-dev`)
8
+ - Project built (`npm run build`)
9
+ - Claude Code installed and on PATH
10
+ - `CLAUDE_TEMPO_ENSEMBLE` set in the conductor session
11
+
12
+ ---
13
+
14
+ ## macOS — Ghostty
15
+
16
+ ### GT-1: Basic recruit opens Ghostty window
17
+ - **Setup**: Ghostty is the active terminal
18
+ - **Steps**: Call `recruit` with a name and workDir
19
+ - **Expected**: A new Ghostty window opens, fish (or default shell) initializes, `claude` launches with the correct name in the title bar
20
+ - **Verify**: `ensemble` shows the new session within 30 seconds
21
+
22
+ ### GT-2: Environment variables are passed through
23
+ - **Setup**: Conductor is in ensemble "myband"
24
+ - **Steps**: Recruit a new session
25
+ - **Expected**: The recruited session's MCP server activates (not idle mode), confirming `CLAUDE_TEMPO_ENSEMBLE=myband` was received
26
+ - **Verify**: New session appears in `ensemble` output with matching ensemble
27
+
28
+ ### GT-3: Node manager (fnm/nvm) PATH is preserved
29
+ - **Setup**: Node is managed via fnm or nvm (not a system install)
30
+ - **Steps**: Recruit a new session
31
+ - **Expected**: The MCP server starts successfully (requires `node` on PATH)
32
+ - **Verify**: `/mcp` in the new session shows claude-tempo connected, not failed
33
+
34
+ ### GT-4: Shell with slow startup
35
+ - **Setup**: Add `sleep 2` to fish config (`~/.config/fish/config.fish`)
36
+ - **Steps**: Recruit a new session
37
+ - **Expected**: `initial input` waits for the shell prompt; claude command runs correctly after shell init completes
38
+ - **Verify**: Session registers in ensemble (may take longer than usual)
39
+ - **Cleanup**: Remove the `sleep 2` from fish config
40
+
41
+ ### GT-5: Working directory is set correctly
42
+ - **Setup**: Use a workDir different from the conductor's (e.g., `/tmp`)
43
+ - **Steps**: `recruit({ workDir: "/tmp", name: "test-dir" })`
44
+ - **Expected**: New Ghostty window opens in `/tmp`
45
+ - **Verify**: `ensemble` shows `Dir: /tmp` for the new session
46
+
47
+ ### GT-6: Special characters in workDir
48
+ - **Setup**: Create a directory with spaces: `mkdir -p "/tmp/my project"`
49
+ - **Steps**: `recruit({ workDir: "/tmp/my project", name: "test-spaces" })`
50
+ - **Expected**: Claude launches in the correct directory without shell quoting errors
51
+ - **Cleanup**: `rm -rf "/tmp/my project"`
52
+
53
+ ---
54
+
55
+ ## macOS — iTerm2
56
+
57
+ ### IT-1: Basic recruit opens iTerm2 window
58
+ - **Setup**: iTerm2 is the active terminal (Ghostty not running)
59
+ - **Steps**: Call `recruit`
60
+ - **Expected**: A new iTerm2 window opens with default profile, shell initializes, `claude` launches
61
+ - **Verify**: `ensemble` shows the new session
62
+
63
+ ### IT-2: Environment and PATH preserved
64
+ - **Setup**: Node managed via nvm/fnm, iTerm2 as terminal
65
+ - **Steps**: Recruit a new session
66
+ - **Expected**: MCP server connects to Temporal (node is on PATH, env vars passed)
67
+ - **Verify**: `/mcp` in new session shows claude-tempo connected
68
+
69
+ ### IT-3: Uses `write text` not `command`
70
+ - **Setup**: iTerm2 active
71
+ - **Steps**: Recruit and observe the new window
72
+ - **Expected**: Shell prompt appears briefly, then the claude command is typed in (not a direct command execution). Shell profile is fully loaded before claude starts.
73
+
74
+ ---
75
+
76
+ ## macOS — Terminal.app
77
+
78
+ ### TA-1: Basic recruit opens Terminal.app window
79
+ - **Setup**: Neither Ghostty nor iTerm2 running; Terminal.app is default
80
+ - **Steps**: Call `recruit`
81
+ - **Expected**: Terminal.app opens via `.command` file, shell profiles are sourced, `claude` launches
82
+ - **Verify**: `ensemble` shows the new session
83
+
84
+ ### TA-2: zsh user with nvm
85
+ - **Setup**: Terminal.app, zsh default shell, nvm installed
86
+ - **Steps**: Recruit a new session
87
+ - **Expected**: `.command` script sources `.zshrc` and `.nvm/nvm.sh`, node is available
88
+ - **Verify**: MCP server connects successfully
89
+
90
+ ### TA-3: fish user on Terminal.app
91
+ - **Setup**: Terminal.app, `SHELL=/usr/local/bin/fish` (or `/opt/homebrew/bin/fish`)
92
+ - **Steps**: Recruit a new session
93
+ - **Expected**: `.command` script detects fish and re-execs into `fish -c` for proper environment
94
+ - **Verify**: MCP server connects, node is on PATH
95
+
96
+ ### TA-4: .command file cleanup
97
+ - **Steps**: Recruit a session, note the timestamp
98
+ - **Expected**: A `.command` file exists in `$TMPDIR` named `claude-tempo-recruit-<timestamp>.command`
99
+ - **Note**: These are not auto-cleaned; consider periodic cleanup in future
100
+
101
+ ---
102
+
103
+ ## Terminal Detection
104
+
105
+ ### TD-1: TERM_PROGRAM takes priority
106
+ - **Setup**: `TERM_PROGRAM=ghostty` in environment, iTerm2 also running
107
+ - **Steps**: Recruit a new session
108
+ - **Expected**: Ghostty path is used (not iTerm2)
109
+ - **Verify**: Check MCP server logs for "Using Ghostty initial-input path"
110
+
111
+ ### TD-2: Frontmost app fallback when TERM_PROGRAM missing
112
+ - **Setup**: Unset `TERM_PROGRAM` in MCP server env, Ghostty is frontmost app
113
+ - **Steps**: Recruit a new session
114
+ - **Expected**: AppleScript detects Ghostty as frontmost app, uses Ghostty path
115
+ - **Verify**: Check MCP server logs
116
+
117
+ ### TD-3: pgrep fallback
118
+ - **Setup**: `TERM_PROGRAM` unset, AppleScript frontmost detection returns a non-terminal app (e.g., Finder is focused)
119
+ - **Steps**: Recruit a new session
120
+ - **Expected**: Falls back to pgrep, finds the correct terminal
121
+ - **Verify**: Check MCP server logs
122
+
123
+ ### TD-4: No terminal detected defaults to Terminal.app
124
+ - **Setup**: Neither Ghostty nor iTerm2 installed/running
125
+ - **Steps**: Recruit a new session
126
+ - **Expected**: Falls through to Terminal.app `.command` file path
127
+ - **Verify**: Terminal.app opens
128
+
129
+ ---
130
+
131
+ ## Claude Binary Resolution
132
+
133
+ ### CB-1: claude on PATH via node manager
134
+ - **Setup**: `claude` installed in `~/.local/bin/` or via npm global
135
+ - **Steps**: Recruit a new session
136
+ - **Expected**: `resolveClaudePath()` returns the absolute path, used in spawn command
137
+
138
+ ### CB-2: claude not on MCP server PATH
139
+ - **Setup**: `claude` is only on the user's shell PATH (via fnm), not in the MCP server's PATH
140
+ - **Steps**: Recruit a new session
141
+ - **Expected**: `resolveClaudePath()` falls back to bare `claude`, but the `initial input` approach runs in the user's shell where `claude` is on PATH
142
+ - **Verify**: Session starts successfully despite MCP server not finding claude
143
+
144
+ ---
145
+
146
+ ## Error Handling
147
+
148
+ ### EH-1: Duplicate name rejected
149
+ - **Steps**: Recruit "Alice", then recruit "Alice" again
150
+ - **Expected**: Second recruit returns error: "Session Alice is already active"
151
+
152
+ ### EH-2: Invalid name rejected
153
+ - **Steps**: `recruit({ name: "bad name!", workDir: "/tmp" })`
154
+ - **Expected**: Returns error about invalid characters
155
+
156
+ ### EH-3: Session spawned but slow to register
157
+ - **Steps**: Recruit with a very slow workDir (e.g., network mount)
158
+ - **Expected**: Returns "spawned but did not register within 15 seconds" message
159
+ - **Verify**: Session eventually appears in `ensemble`
160
+
161
+ ### EH-4: Initial message delivered after registration
162
+ - **Steps**: Recruit with an `initialMessage`
163
+ - **Expected**: After the session registers, it receives the name instruction AND the initial message
164
+ - **Verify**: New session calls `set_name` and then acts on the initial message
165
+
166
+ ---
167
+
168
+ ## Cross-Platform (if applicable)
169
+
170
+ ### WIN-1: Windows recruit opens cmd.exe window
171
+ - **Setup**: Windows with Claude Code installed
172
+ - **Steps**: Call `recruit`
173
+ - **Expected**: New cmd.exe window opens with `claude` running, env vars set via process env
174
+
175
+ ### LIN-1: Linux with gnome-terminal
176
+ - **Setup**: GNOME desktop with gnome-terminal
177
+ - **Steps**: Call `recruit`
178
+ - **Expected**: New gnome-terminal window opens with `claude` running
179
+
180
+ ### LIN-2: Linux headless fallback
181
+ - **Setup**: No GUI terminal emulator available
182
+ - **Steps**: Call `recruit`
183
+ - **Expected**: Falls back to headless `bash -c` spawn, logs warning
184
+ - **Verify**: Session registers in ensemble (no visible window)
185
+
186
+ ---
187
+
188
+ ## Regression Checks
189
+
190
+ ### RG-1: Ghostty `command` property is NOT used
191
+ - **Verify**: The Ghostty AppleScript uses `initial input`, not `set command of cfg`
192
+ - **Why**: `command` bypasses shell init, breaking node managers
193
+
194
+ ### RG-2: No `export` syntax in Ghostty/iTerm2 paths
195
+ - **Verify**: The command typed into the shell uses inline `KEY=val` syntax, not `export KEY=val`
196
+ - **Why**: `export` is not valid fish syntax
197
+
198
+ ### RG-3: Shell quoting handles single quotes in paths
199
+ - **Steps**: Create dir `/tmp/it's-a-test`, recruit into it
200
+ - **Expected**: shellQuote escapes the apostrophe correctly
201
+ - **Cleanup**: Remove the directory
package/tsconfig.json ADDED
@@ -0,0 +1,18 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "commonjs",
5
+ "lib": ["ES2022"],
6
+ "outDir": "dist",
7
+ "rootDir": "src",
8
+ "strict": true,
9
+ "esModuleInterop": true,
10
+ "declaration": true,
11
+ "skipLibCheck": true,
12
+ "forceConsistentCasingInFileNames": true,
13
+ "resolveJsonModule": true,
14
+ "moduleResolution": "node"
15
+ },
16
+ "include": ["src/**/*"],
17
+ "exclude": ["node_modules", "dist"]
18
+ }