claude-tempo 0.7.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 +16 -5
- package/README.md +32 -5
- package/assets/icon-32.png +0 -0
- package/assets/icon-64.png +0 -0
- package/assets/icon-dark-32.png +0 -0
- package/assets/icon-dark-64.png +0 -0
- package/assets/icon-dark.svg +9 -0
- package/assets/icon.svg +9 -0
- package/assets/logo-dark.svg +11 -0
- package/assets/logo-light.svg +11 -0
- package/dist/activities/outbox.d.ts +60 -0
- package/dist/activities/outbox.js +203 -0
- package/dist/cli/commands.js +10 -5
- package/dist/config.d.ts +2 -0
- package/dist/config.js +5 -0
- package/dist/copilot-bridge.js +2 -2
- package/dist/git-info.d.ts +4 -0
- package/dist/git-info.js +28 -0
- package/dist/server.js +64 -46
- package/dist/spawn.d.ts +10 -0
- package/dist/spawn.js +155 -2
- package/dist/tools/cue.d.ts +2 -2
- package/dist/tools/cue.js +11 -18
- package/dist/tools/ensemble.js +3 -0
- package/dist/tools/recruit.d.ts +2 -2
- package/dist/tools/recruit.js +19 -85
- package/dist/tools/report.d.ts +2 -3
- package/dist/tools/report.js +11 -19
- package/dist/tools/stop.d.ts +4 -0
- package/dist/tools/stop.js +43 -0
- package/dist/types.d.ts +51 -0
- package/dist/worker.d.ts +13 -0
- package/dist/worker.js +36 -3
- package/dist/workflows/session.js +161 -31
- package/dist/workflows/signals.d.ts +12 -3
- package/dist/workflows/signals.js +5 -2
- package/package.json +2 -1
- package/workflow-bundle.js +167 -34
- package/dist/tools/terminate.d.ts +0 -4
- package/dist/tools/terminate.js +0 -52
package/CLAUDE.md
CHANGED
|
@@ -21,19 +21,23 @@ 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
|
-
│ ├──
|
|
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
|
|
40
|
+
├── git-info.ts # Git repository detection helper
|
|
37
41
|
└── config.ts # Env var handling
|
|
38
42
|
```
|
|
39
43
|
|
|
@@ -59,6 +63,10 @@ npm test
|
|
|
59
63
|
> **Important**: Always run `npm run build` after changing workflow code (`src/workflows/`).
|
|
60
64
|
> The build pre-bundles workflows into `workflow-bundle.js` so all workers use identical code.
|
|
61
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
|
+
|
|
62
70
|
## Key Concepts
|
|
63
71
|
|
|
64
72
|
- **Player**: A Claude Code session registered as a Temporal workflow
|
|
@@ -66,8 +74,11 @@ npm test
|
|
|
66
74
|
- **Ensemble**: The set of all active players, namespaced by `CLAUDE_TEMPO_ENSEMBLE`
|
|
67
75
|
- **Cue**: A message sent to a player by name via Temporal signal
|
|
68
76
|
- **Part**: A player's description of what it's working on
|
|
69
|
-
- **Recruit**: Spawning a new Claude Code session as a player
|
|
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.
|
|
70
78
|
- **set_name**: Players start with a random hex ID; `set_name` updates the `ClaudeTempoPlayerId` search attribute to a human-readable name
|
|
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.
|
|
71
82
|
|
|
72
83
|
## Dashboard
|
|
73
84
|
|
package/README.md
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
<p align="center">
|
|
2
|
+
<picture>
|
|
3
|
+
<source media="(prefers-color-scheme: dark)" srcset="assets/logo-dark.svg">
|
|
4
|
+
<source media="(prefers-color-scheme: light)" srcset="assets/logo-light.svg">
|
|
5
|
+
<img alt="claude-tempo" src="assets/logo-light.svg" height="140">
|
|
6
|
+
</picture>
|
|
7
|
+
</p>
|
|
8
|
+
<p align="center">
|
|
9
|
+
Multi-session <a href="https://claude.ai/code">Claude Code</a> coordination via <a href="https://temporal.io">Temporal</a>.
|
|
10
|
+
</p>
|
|
4
11
|
|
|
5
12
|
Multiple Claude Code sessions discover each other, exchange messages in real time, and coordinate work — across machines, not just localhost.
|
|
6
13
|
|
|
@@ -174,7 +181,7 @@ Ensemble: myband
|
|
|
174
181
|
Building the REST endpoints
|
|
175
182
|
/Users/me/projects/app feat/api my-machine.local
|
|
176
183
|
|
|
177
|
-
bob
|
|
184
|
+
bob (pending)
|
|
178
185
|
Working on the dashboard
|
|
179
186
|
/Users/me/projects/app feat/ui my-machine.local
|
|
180
187
|
|
|
@@ -210,7 +217,7 @@ These tools are available inside Claude Code sessions connected to claude-tempo:
|
|
|
210
217
|
| `listen` | Manually check for pending messages. |
|
|
211
218
|
| `recruit` | Spawn a new Claude Code session in a directory. Can recruit a conductor with `conductor: true`. |
|
|
212
219
|
| `report` | Send updates to the conductor. No-op if no conductor exists. |
|
|
213
|
-
| `
|
|
220
|
+
| `stop` | Stop a player session by name. |
|
|
214
221
|
| `schedule` | Create a one-shot or recurring schedule to cue a player. |
|
|
215
222
|
| `unschedule` | Cancel a named schedule. |
|
|
216
223
|
| `schedules` | List all active schedules. |
|
|
@@ -398,6 +405,23 @@ Sessions start with a random 8-character hex ID. Set a name at launch with `-n`
|
|
|
398
405
|
- Names must contain only letters, numbers, hyphens, and underscores
|
|
399
406
|
- The name "conductor" is reserved for conductor sessions
|
|
400
407
|
|
|
408
|
+
### Session status lifecycle
|
|
409
|
+
|
|
410
|
+
Each session has a status that tracks its connection state:
|
|
411
|
+
|
|
412
|
+
| Status | Meaning |
|
|
413
|
+
|--------|---------|
|
|
414
|
+
| `pending` | Workflow created by `recruit`, but the Claude Code process hasn't connected yet |
|
|
415
|
+
| `active` | Session is running and responsive |
|
|
416
|
+
| `stale` | Messages have gone undelivered for 3+ minutes — the session is likely disconnected |
|
|
417
|
+
|
|
418
|
+
Status transitions:
|
|
419
|
+
- **`pending` → `active`** — when the spawned session connects and sends its `updateMetadata` signal
|
|
420
|
+
- **`active` → `stale`** — when undelivered messages exceed the stale threshold (3 minutes)
|
|
421
|
+
- Any status → **terminated** — on graceful shutdown or `stop`
|
|
422
|
+
|
|
423
|
+
`claude-tempo status` shows `(pending)` and `(stale)` indicators next to player names. The `ClaudeTempoStatus` search attribute is also set, so you can filter sessions by status in the Temporal UI (e.g., `ClaudeTempoStatus = "stale"`).
|
|
424
|
+
|
|
401
425
|
### Terminal support
|
|
402
426
|
|
|
403
427
|
`recruit` and the CLI detect your terminal automatically:
|
|
@@ -409,10 +433,13 @@ Sessions start with a random 8-character hex ID. Set a name at launch with `-n`
|
|
|
409
433
|
| Terminal.app | ✓ | — | — |
|
|
410
434
|
| gnome-terminal | — | ✓ | — |
|
|
411
435
|
| konsole / xterm | — | ✓ | — |
|
|
436
|
+
| Windows Terminal | — | — | ✓ (tabs) |
|
|
412
437
|
| cmd.exe / PowerShell | — | — | ✓ |
|
|
413
438
|
|
|
414
439
|
macOS terminals preserve the full shell environment (fish, zsh, bash) including node version managers (fnm, nvm).
|
|
415
440
|
|
|
441
|
+
Windows Terminal is detected automatically via the `WT_SESSION` environment variable. When running inside Windows Terminal, recruited sessions open as new tabs (with the player name as the tab title) instead of separate cmd.exe windows.
|
|
442
|
+
|
|
416
443
|
## Configuration
|
|
417
444
|
|
|
418
445
|
Run `claude-tempo config` to save Temporal connection settings so you don't need flags or env vars every time:
|
|
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>
|
package/assets/icon.svg
ADDED
|
@@ -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/cli/commands.js
CHANGED
|
@@ -97,7 +97,7 @@ async function start(opts) {
|
|
|
97
97
|
if (opts.replace) {
|
|
98
98
|
out.log(`Stopping existing conductor for ensemble "${opts.ensemble}"...`);
|
|
99
99
|
try {
|
|
100
|
-
await handle.signal(signals_1.
|
|
100
|
+
await handle.signal(signals_1.updateMetadataSignal, { status: 'terminated' });
|
|
101
101
|
// Wait briefly for graceful shutdown
|
|
102
102
|
for (let i = 0; i < 10; i++) {
|
|
103
103
|
await new Promise(r => setTimeout(r, 500));
|
|
@@ -228,6 +228,7 @@ async function status(opts) {
|
|
|
228
228
|
host: meta.hostname || '',
|
|
229
229
|
conductor: meta.isConductor || false,
|
|
230
230
|
agentType: meta.agentType || 'claude',
|
|
231
|
+
status: meta.status || 'active',
|
|
231
232
|
});
|
|
232
233
|
}
|
|
233
234
|
catch {
|
|
@@ -280,8 +281,11 @@ async function status(opts) {
|
|
|
280
281
|
for (const s of members) {
|
|
281
282
|
const role = s.conductor ? out.yellow(' (conductor)') : '';
|
|
282
283
|
const agent = s.agentType === 'copilot' ? out.dim(' [copilot]') : '';
|
|
284
|
+
const statusLabel = s.status === 'stale' ? out.yellow(' (stale)')
|
|
285
|
+
: s.status === 'pending' ? out.dim(' (pending)')
|
|
286
|
+
: '';
|
|
283
287
|
const name = out.bold(s.name);
|
|
284
|
-
out.log(` ${name}${role}${agent}`);
|
|
288
|
+
out.log(` ${name}${role}${statusLabel}${agent}`);
|
|
285
289
|
if (s.part)
|
|
286
290
|
out.log(` ${out.dim(s.part)}`);
|
|
287
291
|
const details = [s.workDir, s.branch, s.host].filter(Boolean).join(' ');
|
|
@@ -382,6 +386,7 @@ const SEARCH_ATTRIBUTES = [
|
|
|
382
386
|
{ name: 'ClaudeTempoGitRoot', type: 'Keyword' },
|
|
383
387
|
{ name: 'ClaudeTempoEnsemble', type: 'Keyword' },
|
|
384
388
|
{ name: 'ClaudeTempoPlayerId', type: 'Keyword' },
|
|
389
|
+
{ name: 'ClaudeTempoStatus', type: 'Keyword' },
|
|
385
390
|
];
|
|
386
391
|
function isTemporalReachable(config) {
|
|
387
392
|
return (0, connection_1.createTemporalConnection)(config)
|
|
@@ -926,7 +931,7 @@ async function stop(opts) {
|
|
|
926
931
|
continue;
|
|
927
932
|
}
|
|
928
933
|
}
|
|
929
|
-
await handle.signal(signals_1.
|
|
934
|
+
await handle.signal(signals_1.updateMetadataSignal, { status: 'terminated' });
|
|
930
935
|
stopped++;
|
|
931
936
|
out.log(` ${out.dim('stopped')} ${wf.workflowId}`);
|
|
932
937
|
}
|
|
@@ -986,9 +991,9 @@ async function stopByName(client, name, config, ensemble) {
|
|
|
986
991
|
// No conductor or conductor not running — fine
|
|
987
992
|
}
|
|
988
993
|
}
|
|
989
|
-
// Send
|
|
994
|
+
// Send termination status update (graceful)
|
|
990
995
|
try {
|
|
991
|
-
await handle.signal(signals_1.
|
|
996
|
+
await handle.signal(signals_1.updateMetadataSignal, { status: 'terminated' });
|
|
992
997
|
out.success(`Stopped "${name}"`);
|
|
993
998
|
}
|
|
994
999
|
catch {
|
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/copilot-bridge.js
CHANGED
|
@@ -179,7 +179,7 @@ async function main() {
|
|
|
179
179
|
`- listen: Check for pending messages\n` +
|
|
180
180
|
`- recruit: Spawn a new player session\n` +
|
|
181
181
|
`- report: Report to the conductor\n` +
|
|
182
|
-
`-
|
|
182
|
+
`- stop: Stop a session\n\n` +
|
|
183
183
|
`When you receive a message from another session, treat it like a coworker asking for help — respond promptly using your MCP tools.`,
|
|
184
184
|
},
|
|
185
185
|
excludedTools: ['write_powershell', 'read_powershell', 'list_powershell'],
|
|
@@ -391,7 +391,7 @@ async function main() {
|
|
|
391
391
|
polling = false;
|
|
392
392
|
clearInterval(interval);
|
|
393
393
|
try {
|
|
394
|
-
await handle.signal('
|
|
394
|
+
await handle.signal('updateMetadata', { status: 'terminated', terminatedBy: 'system' });
|
|
395
395
|
}
|
|
396
396
|
catch {
|
|
397
397
|
// workflow may already be gone
|
package/dist/git-info.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.getGitInfo = getGitInfo;
|
|
4
|
+
const child_process_1 = require("child_process");
|
|
5
|
+
function getGitInfo(workDir) {
|
|
6
|
+
try {
|
|
7
|
+
const gitRoot = (0, child_process_1.execSync)('git rev-parse --show-toplevel', {
|
|
8
|
+
cwd: workDir,
|
|
9
|
+
encoding: 'utf-8',
|
|
10
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
11
|
+
}).trim();
|
|
12
|
+
let gitBranch;
|
|
13
|
+
try {
|
|
14
|
+
gitBranch = (0, child_process_1.execSync)('git rev-parse --abbrev-ref HEAD', {
|
|
15
|
+
cwd: workDir,
|
|
16
|
+
encoding: 'utf-8',
|
|
17
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
18
|
+
}).trim();
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
// not on a branch
|
|
22
|
+
}
|
|
23
|
+
return { gitRoot, gitBranch };
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return {};
|
|
27
|
+
}
|
|
28
|
+
}
|