claude-tempo 0.22.1 → 0.23.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 +5 -2
- package/README.md +15 -0
- package/dist/cli/commands.js +1 -0
- package/dist/client/index.d.ts +10 -0
- package/dist/client/index.js +482 -0
- package/dist/client/interface.d.ts +66 -0
- package/dist/client/interface.js +2 -0
- package/dist/tui/App.d.ts +1 -1
- package/dist/tui/client.d.ts +4 -67
- package/dist/tui/client.js +5 -468
- package/dist/tui/commands.d.ts +1 -1
- package/dist/tui/index.js +1 -1
- package/dist/tui/store.d.ts +1 -1
- package/dist/workflows/session.js +2 -0
- package/package.json +1 -1
- package/workflow-bundle.js +3 -1
package/CLAUDE.md
CHANGED
|
@@ -27,6 +27,9 @@ src/
|
|
|
27
27
|
│ ├── output.ts # Shared CLI output formatting helpers
|
|
28
28
|
│ └── preflight.ts # Environment preflight checks
|
|
29
29
|
├── copilot-bridge.ts # Copilot SDK bridge for Copilot CLI players
|
|
30
|
+
├── client/
|
|
31
|
+
│ ├── interface.ts # TempoClient TypeScript interface and related types
|
|
32
|
+
│ └── index.ts # TempoClient factory implementation and barrel re-exports
|
|
30
33
|
├── worker.ts # Temporal worker setup (used by daemon only)
|
|
31
34
|
├── connection.ts # Temporal connection factory (shared by server + CLI)
|
|
32
35
|
├── spawn.ts # Cross-platform process spawning helpers
|
|
@@ -80,7 +83,7 @@ src/
|
|
|
80
83
|
│ ├── index.ts # TUI entry point — connects to Temporal and renders the Ink app
|
|
81
84
|
│ ├── App.tsx # Root TUI component — chat-focused shell with slash commands
|
|
82
85
|
│ ├── store.ts # TUI state reducer (phase, players, messages, schedules, static history)
|
|
83
|
-
│ ├── client.ts #
|
|
86
|
+
│ ├── client.ts # Thin re-export shim — re-exports createTempoClient from src/client/ for backward compatibility
|
|
84
87
|
│ ├── commands.ts # Slash command parser and registry (/player, /broadcast, /status, etc.)
|
|
85
88
|
│ ├── ink-loader.ts # Dynamic ESM loader for Ink (avoids CJS/ESM conflicts)
|
|
86
89
|
│ ├── ink-context.tsx # React context for injected Ink primitives
|
|
@@ -171,7 +174,7 @@ npm test
|
|
|
171
174
|
- **Worktree**: A git worktree provisioned by the conductor for a player, giving them an isolated checkout on a separate branch. Managed via the `worktree` tool (conductor only): `create` provisions the worktree and notifies the player, `remove` cleans up after the task, `list` shows all active worktrees. Worktree assignments are stored in the conductor workflow (`WorktreeEntry` records: player, path, branch, gitRoot, createdAt, createdBy).
|
|
172
175
|
- **Stage**: A fan-out/fan-in tracking primitive for the conductor. Created via `stage` (conductor only), listing via `stages`, cancelled via `cancel_stage`. Each stage tracks a set of players; when a tracked player sends a `report`, their stage status updates automatically (`waiting` → `reported` or `blocked`). When all players have reported, the conductor is notified that the stage is complete. If `failurePolicy` is `'halt'` (default), a blocker from any player fails the entire stage. Stages are stored in the conductor workflow and survive `continueAsNew`.
|
|
173
176
|
- **Maestro**: Two Maestro workflow variants exist. The **per-ensemble** `claudeMaestroWorkflow` (ID: `claude-maestro-{ensemble}`) monitors a single ensemble — maintains a player snapshot, ring-buffer event log (max 200 entries), an aggregated ensemble chat cache (max 500 entries, refreshed every ~10s via `fetchEnsembleChat` activity), and queues commands for relay to the conductor via `maestroSendCommand`. The ensemble chat cache merges maestro + conductor traffic and is served via the `maestroEnsembleChat` query. The **global** `claudeGlobalMaestroWorkflow` (ID: `claude-maestro-global`) spans all ensembles — aggregates players by ensemble, maintains a cross-ensemble message ring buffer (max 500 entries), and exposes on-demand player/conductor history via `maestroFetchPlayerMessages` and `maestroFetchConductorHistory` updates. Both are implemented in `src/workflows/maestro.ts` with activities in `src/activities/maestro.ts`.
|
|
174
|
-
- **TempoClient**: The
|
|
177
|
+
- **TempoClient**: The API layer for querying ensemble state (`src/client/`). The interface and types live in `interface.ts`; the factory implementation lives in `index.ts`. Provides `discoverEnsembles`, `getPlayers`, `getMessages`, `getConductorHistory`, `sendMessage`, `sendCommand`, `getEnsembleChat`, `getGates`, `getStages`, `getWorktrees`, and `terminatePlayer`. Uses Global Maestro as the primary source with graceful fallback to per-ensemble Maestro and direct workflow list queries. `src/tui/client.ts` is a thin re-export shim for backward compatibility — new consumers should import from `src/client/` directly.
|
|
175
178
|
- **Wire protocol**: All Temporal signal, query, update, and workflow names are documented in [`docs/WIRE-PROTOCOL.md`](docs/WIRE-PROTOCOL.md). These names are stable as of v0.10 — renaming or removing any is a breaking change requiring a major version bump.
|
|
176
179
|
- **Daemon**: A standalone background process (`src/daemon.ts`) that runs all Temporal workers. Auto-started by any claude-tempo command if not already running. PID stored at `~/.claude-tempo/daemon.pid`; logs at `~/.claude-tempo/daemon.log`. Sessions are now pure MCP clients — they no longer run in-process workers. Managed via `claude-tempo daemon start|stop|status|logs`.
|
|
177
180
|
|
package/README.md
CHANGED
|
@@ -86,6 +86,21 @@ Stops the daemon, installs the latest version, and restarts automatically. To up
|
|
|
86
86
|
claude-tempo upgrade 0.22.0
|
|
87
87
|
```
|
|
88
88
|
|
|
89
|
+
## Stopping & Tear Down
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
# Stop a specific player session
|
|
93
|
+
claude-tempo stop my-ensemble player-name
|
|
94
|
+
|
|
95
|
+
# Tear down everything (all sessions, schedulers, and Maestro workflows)
|
|
96
|
+
claude-tempo down --all
|
|
97
|
+
|
|
98
|
+
# Stop the background daemon
|
|
99
|
+
claude-tempo daemon stop
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
📖 [Full CLI reference → docs/cli.md](docs/cli.md)
|
|
103
|
+
|
|
89
104
|
---
|
|
90
105
|
|
|
91
106
|
## Core Concepts
|
package/dist/cli/commands.js
CHANGED
|
@@ -437,6 +437,7 @@ const SEARCH_ATTRIBUTES = [
|
|
|
437
437
|
{ name: 'ClaudeTempoPlayerId', type: 'Keyword' },
|
|
438
438
|
{ name: 'ClaudeTempoStatus', type: 'Keyword' },
|
|
439
439
|
{ name: 'ClaudeTempoPlayerType', type: 'Keyword' },
|
|
440
|
+
{ name: 'ClaudeTempoIsConductor', type: 'Bool' },
|
|
440
441
|
];
|
|
441
442
|
async function isTemporalReachable(config) {
|
|
442
443
|
try {
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TempoClient — factory implementation and barrel re-exports.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from `src/tui/client.ts` so that non-TUI consumers (CLI, tests,
|
|
5
|
+
* external integrations) can create a TempoClient without depending on Ink/React.
|
|
6
|
+
*/
|
|
7
|
+
import { Client } from '@temporalio/client';
|
|
8
|
+
import type { TempoClient } from './interface';
|
|
9
|
+
export type { TempoClient, EnsembleSummary } from './interface';
|
|
10
|
+
export declare function createTempoClient(client: Client): TempoClient;
|
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createTempoClient = createTempoClient;
|
|
4
|
+
/**
|
|
5
|
+
* TempoClient — factory implementation and barrel re-exports.
|
|
6
|
+
*
|
|
7
|
+
* Extracted from `src/tui/client.ts` so that non-TUI consumers (CLI, tests,
|
|
8
|
+
* external integrations) can create a TempoClient without depending on Ink/React.
|
|
9
|
+
*/
|
|
10
|
+
const client_1 = require("@temporalio/client");
|
|
11
|
+
const config_1 = require("../config");
|
|
12
|
+
const signals_1 = require("../workflows/signals");
|
|
13
|
+
// ── Helpers ──
|
|
14
|
+
/** Escape a value for use in Temporal visibility query strings.
|
|
15
|
+
* Strips characters that could break or inject into the query. */
|
|
16
|
+
function sanitizeQueryValue(value) {
|
|
17
|
+
return value.replace(/["\\\n\r]/g, '');
|
|
18
|
+
}
|
|
19
|
+
// ── Factory ──
|
|
20
|
+
function createTempoClient(client) {
|
|
21
|
+
const globalMaestroId = config_1.GLOBAL_MAESTRO_WORKFLOW_ID;
|
|
22
|
+
/** Helper: get a workflow handle by ID. */
|
|
23
|
+
function handle(workflowId) {
|
|
24
|
+
return client.workflow.getHandle(workflowId);
|
|
25
|
+
}
|
|
26
|
+
return {
|
|
27
|
+
async discoverEnsembles() {
|
|
28
|
+
// Strategy 1: Global Maestro playersByEnsemble query
|
|
29
|
+
try {
|
|
30
|
+
const h = handle(globalMaestroId);
|
|
31
|
+
const byEnsemble = await h.query('maestroPlayersByEnsemble');
|
|
32
|
+
const results = Object.entries(byEnsemble).map(([name, players]) => {
|
|
33
|
+
const conductor = players.find(p => p.isConductor);
|
|
34
|
+
return {
|
|
35
|
+
name,
|
|
36
|
+
playerCount: players.length,
|
|
37
|
+
hasConductor: !!conductor,
|
|
38
|
+
conductorStatus: conductor?.status,
|
|
39
|
+
};
|
|
40
|
+
});
|
|
41
|
+
// Only trust Maestro if it has discovered ensembles; fall through to
|
|
42
|
+
// Strategy 2 when empty — the Maestro may not have refreshed yet.
|
|
43
|
+
if (results.length > 0)
|
|
44
|
+
return results;
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
// Global Maestro not available — fall through
|
|
48
|
+
}
|
|
49
|
+
// Strategy 2: Direct workflow list scan
|
|
50
|
+
try {
|
|
51
|
+
const query = 'WorkflowType = "claudeSessionWorkflow" AND ExecutionStatus = "Running"';
|
|
52
|
+
const ensembleMap = new Map();
|
|
53
|
+
for await (const wf of client.workflow.list({ query })) {
|
|
54
|
+
const vals = wf.searchAttributes?.ClaudeTempoEnsemble;
|
|
55
|
+
if (!Array.isArray(vals) || vals.length === 0)
|
|
56
|
+
continue;
|
|
57
|
+
const name = String(vals[0]);
|
|
58
|
+
const entry = ensembleMap.get(name) || { count: 0, hasConductor: false };
|
|
59
|
+
entry.count++;
|
|
60
|
+
// Preferred: ClaudeTempoIsConductor search attribute (canonical, queryable).
|
|
61
|
+
// Fallback: workflow ID convention — covers the brief window after a
|
|
62
|
+
// conductor spawn before the search attribute is indexed.
|
|
63
|
+
const saFlag = wf.searchAttributes?.ClaudeTempoIsConductor;
|
|
64
|
+
const isConductorFromSA = Array.isArray(saFlag) && saFlag[0] === true;
|
|
65
|
+
const isConductorFromId = wf.workflowId?.endsWith('-conductor') ?? false;
|
|
66
|
+
if (isConductorFromSA || isConductorFromId) {
|
|
67
|
+
entry.hasConductor = true;
|
|
68
|
+
const statusArr = wf.searchAttributes?.ClaudeTempoStatus;
|
|
69
|
+
entry.conductorStatus = Array.isArray(statusArr) ? String(statusArr[0]) : undefined;
|
|
70
|
+
}
|
|
71
|
+
ensembleMap.set(name, entry);
|
|
72
|
+
}
|
|
73
|
+
return [...ensembleMap.entries()].map(([name, info]) => ({
|
|
74
|
+
name,
|
|
75
|
+
playerCount: info.count,
|
|
76
|
+
hasConductor: info.hasConductor,
|
|
77
|
+
conductorStatus: info.conductorStatus,
|
|
78
|
+
}));
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return [];
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
async getPlayers(ensemble) {
|
|
85
|
+
// Strategy 1: Global Maestro — filter by ensemble
|
|
86
|
+
try {
|
|
87
|
+
const h = handle(globalMaestroId);
|
|
88
|
+
const byEnsemble = await h.query('maestroPlayersByEnsemble');
|
|
89
|
+
if (byEnsemble[ensemble])
|
|
90
|
+
return byEnsemble[ensemble];
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
// Fall through
|
|
94
|
+
}
|
|
95
|
+
// Strategy 2: Per-ensemble Maestro
|
|
96
|
+
try {
|
|
97
|
+
const h = handle((0, config_1.maestroWorkflowId)(ensemble));
|
|
98
|
+
return await h.query('maestroPlayers');
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
// Fall through
|
|
102
|
+
}
|
|
103
|
+
// Strategy 3: Direct workflow list
|
|
104
|
+
try {
|
|
105
|
+
const query = `WorkflowType = "claudeSessionWorkflow" AND ExecutionStatus = "Running" AND ClaudeTempoEnsemble = "${sanitizeQueryValue(ensemble)}"`;
|
|
106
|
+
const players = [];
|
|
107
|
+
for await (const wf of client.workflow.list({ query })) {
|
|
108
|
+
const sa = wf.searchAttributes || {};
|
|
109
|
+
const playerId = Array.isArray(sa.ClaudeTempoPlayerId) ? String(sa.ClaudeTempoPlayerId[0]) : wf.workflowId;
|
|
110
|
+
// Preferred: ClaudeTempoIsConductor search attribute (canonical, queryable).
|
|
111
|
+
// Fallback: workflow ID convention — covers the brief window after a
|
|
112
|
+
// conductor spawn before the search attribute is indexed.
|
|
113
|
+
const isConductorFromSA = Array.isArray(sa.ClaudeTempoIsConductor) && sa.ClaudeTempoIsConductor[0] === true;
|
|
114
|
+
const isConductorFromId = wf.workflowId?.endsWith('-conductor') ?? false;
|
|
115
|
+
players.push({
|
|
116
|
+
playerId,
|
|
117
|
+
ensemble,
|
|
118
|
+
part: '',
|
|
119
|
+
hostname: Array.isArray(sa.ClaudeTempoHostname) ? String(sa.ClaudeTempoHostname[0]) : '',
|
|
120
|
+
workDir: '',
|
|
121
|
+
isConductor: isConductorFromSA || isConductorFromId,
|
|
122
|
+
agentType: 'claude',
|
|
123
|
+
status: Array.isArray(sa.ClaudeTempoStatus) ? String(sa.ClaudeTempoStatus[0]) : undefined,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
return players;
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
return [];
|
|
130
|
+
}
|
|
131
|
+
},
|
|
132
|
+
async getMessages(ensemble, limit) {
|
|
133
|
+
try {
|
|
134
|
+
const h = handle(globalMaestroId);
|
|
135
|
+
const all = await h.query('maestroRecentMessages');
|
|
136
|
+
const filtered = all.filter(m => m.ensemble === ensemble);
|
|
137
|
+
return limit ? filtered.slice(-limit) : filtered;
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
return [];
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
async getConductorHistory(ensemble) {
|
|
144
|
+
try {
|
|
145
|
+
const h = handle(globalMaestroId);
|
|
146
|
+
const result = await h.executeUpdate('maestroFetchConductorHistory', {
|
|
147
|
+
args: [{ ensemble }],
|
|
148
|
+
});
|
|
149
|
+
if (result.success)
|
|
150
|
+
return result.history;
|
|
151
|
+
return [];
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
return [];
|
|
155
|
+
}
|
|
156
|
+
},
|
|
157
|
+
async getPlayerMessages(ensemble, playerId) {
|
|
158
|
+
try {
|
|
159
|
+
const h = handle(globalMaestroId);
|
|
160
|
+
return await h.executeUpdate('maestroFetchPlayerMessages', {
|
|
161
|
+
args: [{ ensemble, playerId }],
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
return [];
|
|
166
|
+
}
|
|
167
|
+
},
|
|
168
|
+
async getPlayerMetadata(ensemble, playerId) {
|
|
169
|
+
try {
|
|
170
|
+
// Query the player's workflow directly for metadata
|
|
171
|
+
const query = `WorkflowType = "claudeSessionWorkflow" AND ExecutionStatus = "Running" AND ClaudeTempoEnsemble = "${sanitizeQueryValue(ensemble)}" AND ClaudeTempoPlayerId = "${sanitizeQueryValue(playerId)}"`;
|
|
172
|
+
for await (const wf of client.workflow.list({ query })) {
|
|
173
|
+
const h = handle(wf.workflowId);
|
|
174
|
+
return await h.query('getMetadata');
|
|
175
|
+
}
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
},
|
|
182
|
+
async sendCommand(ensemble, text, source) {
|
|
183
|
+
// Route commands through Maestro hub → conductor's commandSignal
|
|
184
|
+
let result;
|
|
185
|
+
try {
|
|
186
|
+
const h = handle(globalMaestroId);
|
|
187
|
+
result = await h.executeUpdate('maestroGlobalSendCommand', {
|
|
188
|
+
args: [{ ensemble, text, source }],
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
const h = handle((0, config_1.maestroWorkflowId)(ensemble));
|
|
193
|
+
result = await h.executeUpdate('maestroSendCommand', {
|
|
194
|
+
args: [{ text, source }],
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
// Record on maestro workflow for history persistence
|
|
198
|
+
try {
|
|
199
|
+
const maestroId = (0, config_1.sessionWorkflowId)(ensemble, 'maestro');
|
|
200
|
+
const mh = handle(maestroId);
|
|
201
|
+
await mh.signal('recordSentMessage', { to: 'conductor', text });
|
|
202
|
+
}
|
|
203
|
+
catch { /* best effort */ }
|
|
204
|
+
return result;
|
|
205
|
+
},
|
|
206
|
+
async sendMessage(ensemble, to, text, source) {
|
|
207
|
+
// Direct signal with isMaestro flag — matches web Maestro pattern
|
|
208
|
+
const query = `WorkflowType = "claudeSessionWorkflow" AND ExecutionStatus = "Running" AND ClaudeTempoEnsemble = "${sanitizeQueryValue(ensemble)}" AND ClaudeTempoPlayerId = "${sanitizeQueryValue(to)}"`;
|
|
209
|
+
let sent = false;
|
|
210
|
+
for await (const wf of client.workflow.list({ query })) {
|
|
211
|
+
const h = handle(wf.workflowId);
|
|
212
|
+
await h.signal('receiveMessage', {
|
|
213
|
+
from: source,
|
|
214
|
+
text,
|
|
215
|
+
isMaestro: true,
|
|
216
|
+
});
|
|
217
|
+
sent = true;
|
|
218
|
+
break;
|
|
219
|
+
}
|
|
220
|
+
if (!sent) {
|
|
221
|
+
// Fallback: try via Maestro hub if direct resolution fails
|
|
222
|
+
try {
|
|
223
|
+
const h = handle(globalMaestroId);
|
|
224
|
+
await h.executeUpdate('maestroSendMessage', {
|
|
225
|
+
args: [{ ensemble, to, text, source }],
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
catch {
|
|
229
|
+
throw new Error(`Player "${to}" not found in ensemble "${ensemble}"`);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
// Record on maestro workflow for history persistence
|
|
233
|
+
try {
|
|
234
|
+
const maestroId = (0, config_1.sessionWorkflowId)(ensemble, 'maestro');
|
|
235
|
+
const mh = handle(maestroId);
|
|
236
|
+
await mh.signal('recordSentMessage', { to, text });
|
|
237
|
+
}
|
|
238
|
+
catch { /* best effort */ }
|
|
239
|
+
return `maestro-msg-${Date.now()}`;
|
|
240
|
+
},
|
|
241
|
+
async terminatePlayer(ensemble, playerId) {
|
|
242
|
+
const query = `WorkflowType = "claudeSessionWorkflow" AND ExecutionStatus = "Running" AND ClaudeTempoEnsemble = "${sanitizeQueryValue(ensemble)}" AND ClaudeTempoPlayerId = "${sanitizeQueryValue(playerId)}"`;
|
|
243
|
+
for await (const wf of client.workflow.list({ query })) {
|
|
244
|
+
const h = handle(wf.workflowId);
|
|
245
|
+
await h.terminate('terminated via TUI');
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
throw new Error(`Player "${playerId}" not found in ensemble "${ensemble}"`);
|
|
249
|
+
},
|
|
250
|
+
async encorePlayer(ensemble, playerId) {
|
|
251
|
+
// Submit an encore outbox entry through the TUI's maestro session workflow.
|
|
252
|
+
// This works without a conductor — the maestro session's outbox dispatches the encore activity directly.
|
|
253
|
+
const maestroId = (0, config_1.sessionWorkflowId)(ensemble, 'maestro');
|
|
254
|
+
const h = handle(maestroId);
|
|
255
|
+
const entry = {
|
|
256
|
+
type: 'encore',
|
|
257
|
+
targetPlayerId: playerId,
|
|
258
|
+
};
|
|
259
|
+
await h.executeUpdate(signals_1.submitOutboxUpdate, { args: [entry] });
|
|
260
|
+
},
|
|
261
|
+
async disbandEnsemble(ensemble) {
|
|
262
|
+
let terminated = 0;
|
|
263
|
+
// Terminate all session workflows in the ensemble
|
|
264
|
+
const sessionQuery = `WorkflowType = "claudeSessionWorkflow" AND ExecutionStatus = "Running" AND ClaudeTempoEnsemble = "${sanitizeQueryValue(ensemble)}"`;
|
|
265
|
+
for await (const wf of client.workflow.list({ query: sessionQuery })) {
|
|
266
|
+
try {
|
|
267
|
+
const h = handle(wf.workflowId);
|
|
268
|
+
await h.terminate('disbanded via TUI');
|
|
269
|
+
terminated++;
|
|
270
|
+
}
|
|
271
|
+
catch { /* already closed */ }
|
|
272
|
+
}
|
|
273
|
+
// Terminate scheduler workflow
|
|
274
|
+
try {
|
|
275
|
+
const h = handle((0, config_1.schedulerWorkflowId)(ensemble));
|
|
276
|
+
await h.terminate('disbanded via TUI');
|
|
277
|
+
terminated++;
|
|
278
|
+
}
|
|
279
|
+
catch { /* no scheduler or already closed */ }
|
|
280
|
+
// Terminate per-ensemble maestro workflow
|
|
281
|
+
try {
|
|
282
|
+
const h = handle((0, config_1.maestroWorkflowId)(ensemble));
|
|
283
|
+
await h.terminate('disbanded via TUI');
|
|
284
|
+
terminated++;
|
|
285
|
+
}
|
|
286
|
+
catch { /* no maestro or already closed */ }
|
|
287
|
+
return { terminated };
|
|
288
|
+
},
|
|
289
|
+
async isConnected() {
|
|
290
|
+
try {
|
|
291
|
+
// Lightweight check: list with limit 1
|
|
292
|
+
const query = 'ExecutionStatus = "Running"';
|
|
293
|
+
for await (const _ of client.workflow.list({ query })) {
|
|
294
|
+
return true;
|
|
295
|
+
}
|
|
296
|
+
return true; // Connected but no workflows
|
|
297
|
+
}
|
|
298
|
+
catch {
|
|
299
|
+
return false;
|
|
300
|
+
}
|
|
301
|
+
},
|
|
302
|
+
async getSchedules(ensemble) {
|
|
303
|
+
try {
|
|
304
|
+
const h = handle((0, config_1.schedulerWorkflowId)(ensemble));
|
|
305
|
+
return await h.query('getSchedules');
|
|
306
|
+
}
|
|
307
|
+
catch {
|
|
308
|
+
return [];
|
|
309
|
+
}
|
|
310
|
+
},
|
|
311
|
+
async cancelSchedule(ensemble, name) {
|
|
312
|
+
const h = handle((0, config_1.schedulerWorkflowId)(ensemble));
|
|
313
|
+
await h.signal('removeSchedule', name);
|
|
314
|
+
},
|
|
315
|
+
async getEnsembleChat(ensemble, offset, limit) {
|
|
316
|
+
try {
|
|
317
|
+
const h = handle((0, config_1.maestroWorkflowId)(ensemble));
|
|
318
|
+
return await h.query('maestroEnsembleChat', { offset, limit });
|
|
319
|
+
}
|
|
320
|
+
catch {
|
|
321
|
+
return { messages: [], total: 0, hasMore: false, hasConductor: false };
|
|
322
|
+
}
|
|
323
|
+
},
|
|
324
|
+
async getGates(ensemble) {
|
|
325
|
+
// Gates are stored on the conductor's workflow
|
|
326
|
+
try {
|
|
327
|
+
const h = handle((0, config_1.conductorWorkflowId)(ensemble));
|
|
328
|
+
return await h.query('qualityGates');
|
|
329
|
+
}
|
|
330
|
+
catch {
|
|
331
|
+
return [];
|
|
332
|
+
}
|
|
333
|
+
},
|
|
334
|
+
async getStages(ensemble) {
|
|
335
|
+
try {
|
|
336
|
+
const h = handle((0, config_1.conductorWorkflowId)(ensemble));
|
|
337
|
+
return await h.query('stages');
|
|
338
|
+
}
|
|
339
|
+
catch {
|
|
340
|
+
return [];
|
|
341
|
+
}
|
|
342
|
+
},
|
|
343
|
+
async getWorktrees(ensemble) {
|
|
344
|
+
try {
|
|
345
|
+
const h = handle((0, config_1.conductorWorkflowId)(ensemble));
|
|
346
|
+
return await h.query('worktrees');
|
|
347
|
+
}
|
|
348
|
+
catch {
|
|
349
|
+
return [];
|
|
350
|
+
}
|
|
351
|
+
},
|
|
352
|
+
async hasGlobalMaestro() {
|
|
353
|
+
try {
|
|
354
|
+
const h = handle(globalMaestroId);
|
|
355
|
+
const desc = await h.describe();
|
|
356
|
+
return desc.status.name === 'RUNNING';
|
|
357
|
+
}
|
|
358
|
+
catch {
|
|
359
|
+
return false;
|
|
360
|
+
}
|
|
361
|
+
},
|
|
362
|
+
// ── Maestro session (TUI-owned workflow for two-way messaging) ──
|
|
363
|
+
async ensureMaestroSession(ensemble) {
|
|
364
|
+
const workflowId = (0, config_1.sessionWorkflowId)(ensemble, 'maestro');
|
|
365
|
+
const sessionInput = {
|
|
366
|
+
metadata: {
|
|
367
|
+
playerId: 'maestro',
|
|
368
|
+
ensemble,
|
|
369
|
+
hostname: 'dashboard',
|
|
370
|
+
workDir: process.cwd(),
|
|
371
|
+
isConductor: false,
|
|
372
|
+
status: 'active',
|
|
373
|
+
agentType: 'claude',
|
|
374
|
+
playerType: 'maestro',
|
|
375
|
+
playerTypeDescription: 'TUI dashboard — human operator interface',
|
|
376
|
+
},
|
|
377
|
+
part: 'Dashboard interface (human operator)',
|
|
378
|
+
disableStaleDetection: true,
|
|
379
|
+
};
|
|
380
|
+
try {
|
|
381
|
+
const wfHandle = await client.workflow.start('claudeSessionWorkflow', {
|
|
382
|
+
workflowId,
|
|
383
|
+
taskQueue: 'claude-tempo',
|
|
384
|
+
args: [sessionInput],
|
|
385
|
+
workflowIdConflictPolicy: client_1.WorkflowIdConflictPolicy.USE_EXISTING,
|
|
386
|
+
workflowExecutionTimeout: '24 hours',
|
|
387
|
+
searchAttributes: {
|
|
388
|
+
ClaudeTempoHostname: ['dashboard'],
|
|
389
|
+
ClaudeTempoEnsemble: [ensemble],
|
|
390
|
+
ClaudeTempoPlayerId: ['maestro'],
|
|
391
|
+
ClaudeTempoPlayerType: ['maestro'],
|
|
392
|
+
},
|
|
393
|
+
});
|
|
394
|
+
console.error(`[tui:client] Maestro session started: ${wfHandle.workflowId}`);
|
|
395
|
+
// Also ensure the per-ensemble Maestro hub workflow exists.
|
|
396
|
+
// Without this, getEnsembleChat returns empty when the hub wasn't
|
|
397
|
+
// previously created by a CLI command.
|
|
398
|
+
const maestroHubId = (0, config_1.maestroWorkflowId)(ensemble);
|
|
399
|
+
try {
|
|
400
|
+
await client.workflow.start('claudeMaestroWorkflow', {
|
|
401
|
+
workflowId: maestroHubId,
|
|
402
|
+
taskQueue: 'claude-tempo',
|
|
403
|
+
args: [{ ensemble }],
|
|
404
|
+
workflowIdConflictPolicy: client_1.WorkflowIdConflictPolicy.USE_EXISTING,
|
|
405
|
+
searchAttributes: {
|
|
406
|
+
ClaudeTempoEnsemble: [ensemble],
|
|
407
|
+
},
|
|
408
|
+
});
|
|
409
|
+
console.error(`[tui:client] Maestro hub ensured: ${maestroHubId}`);
|
|
410
|
+
}
|
|
411
|
+
catch {
|
|
412
|
+
// Maestro hub is non-critical — log but don't fail
|
|
413
|
+
console.error(`[tui:client] Maestro hub start skipped (may already exist): ${maestroHubId}`);
|
|
414
|
+
}
|
|
415
|
+
return wfHandle.workflowId;
|
|
416
|
+
}
|
|
417
|
+
catch (err) {
|
|
418
|
+
console.error('[tui:client] Failed to start maestro session:', err);
|
|
419
|
+
throw err;
|
|
420
|
+
}
|
|
421
|
+
},
|
|
422
|
+
async sendAsMaestro(ensemble, targetPlayer, text) {
|
|
423
|
+
// Resolve target player workflow via search attributes
|
|
424
|
+
const query = `WorkflowType = "claudeSessionWorkflow" AND ExecutionStatus = "Running" AND ClaudeTempoEnsemble = "${sanitizeQueryValue(ensemble)}" AND ClaudeTempoPlayerId = "${sanitizeQueryValue(targetPlayer)}"`;
|
|
425
|
+
let targetHandle;
|
|
426
|
+
for await (const wf of client.workflow.list({ query })) {
|
|
427
|
+
targetHandle = handle(wf.workflowId);
|
|
428
|
+
break;
|
|
429
|
+
}
|
|
430
|
+
if (!targetHandle) {
|
|
431
|
+
throw new Error(`Player "${targetPlayer}" not found in ensemble "${ensemble}"`);
|
|
432
|
+
}
|
|
433
|
+
// Signal the target with the message
|
|
434
|
+
await targetHandle.signal('receiveMessage', { from: 'maestro', text, isMaestro: true });
|
|
435
|
+
// Record outbound on maestro's own workflow
|
|
436
|
+
const maestroId = (0, config_1.sessionWorkflowId)(ensemble, 'maestro');
|
|
437
|
+
try {
|
|
438
|
+
const maestroHandle = handle(maestroId);
|
|
439
|
+
await maestroHandle.signal('recordSentMessage', { to: targetPlayer, text });
|
|
440
|
+
}
|
|
441
|
+
catch {
|
|
442
|
+
// Best-effort — maestro workflow may not exist yet
|
|
443
|
+
}
|
|
444
|
+
},
|
|
445
|
+
async getMaestroMessages(ensemble) {
|
|
446
|
+
const maestroId = (0, config_1.sessionWorkflowId)(ensemble, 'maestro');
|
|
447
|
+
try {
|
|
448
|
+
const h = handle(maestroId);
|
|
449
|
+
// Query received messages (allMessages preferred, pendingMessages fallback)
|
|
450
|
+
let received;
|
|
451
|
+
try {
|
|
452
|
+
received = await h.query('allMessages');
|
|
453
|
+
}
|
|
454
|
+
catch {
|
|
455
|
+
received = await h.query('pendingMessages');
|
|
456
|
+
}
|
|
457
|
+
// Auto-mark undelivered messages as delivered (maestro has no listener)
|
|
458
|
+
const undeliveredIds = received.filter(m => !m.delivered).map(m => m.id);
|
|
459
|
+
if (undeliveredIds.length > 0) {
|
|
460
|
+
try {
|
|
461
|
+
await h.signal('markDelivered', undeliveredIds);
|
|
462
|
+
}
|
|
463
|
+
catch {
|
|
464
|
+
// Best-effort
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
// Query sent messages
|
|
468
|
+
let sent;
|
|
469
|
+
try {
|
|
470
|
+
sent = await h.query('allSentMessages');
|
|
471
|
+
}
|
|
472
|
+
catch {
|
|
473
|
+
sent = [];
|
|
474
|
+
}
|
|
475
|
+
return { received, sent };
|
|
476
|
+
}
|
|
477
|
+
catch {
|
|
478
|
+
return { received: [], sent: [] };
|
|
479
|
+
}
|
|
480
|
+
},
|
|
481
|
+
};
|
|
482
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TempoClient — public interface and related types.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from `src/tui/client.ts` so that non-TUI consumers (CLI, tests,
|
|
5
|
+
* external integrations) can depend on the interface without pulling in Ink/React.
|
|
6
|
+
*/
|
|
7
|
+
import type { MaestroPlayerInfo, MaestroRelayMessage, HistoryEntry, Message, SentMessage, SessionMetadata, ScheduleEntry, QualityGate, StageEntry, WorktreeEntry, EnsembleChatResult } from '../types';
|
|
8
|
+
export interface EnsembleSummary {
|
|
9
|
+
name: string;
|
|
10
|
+
playerCount: number;
|
|
11
|
+
hasConductor: boolean;
|
|
12
|
+
conductorStatus?: string;
|
|
13
|
+
}
|
|
14
|
+
export interface TempoClient {
|
|
15
|
+
/** Discover all running ensembles across the cluster. */
|
|
16
|
+
discoverEnsembles(): Promise<EnsembleSummary[]>;
|
|
17
|
+
/** Get current player snapshot for an ensemble. */
|
|
18
|
+
getPlayers(ensemble: string): Promise<MaestroPlayerInfo[]>;
|
|
19
|
+
/** Get recent messages for an ensemble. */
|
|
20
|
+
getMessages(ensemble: string, limit?: number): Promise<MaestroRelayMessage[]>;
|
|
21
|
+
/** Get conductor command/report history for an ensemble. */
|
|
22
|
+
getConductorHistory(ensemble: string): Promise<HistoryEntry[]>;
|
|
23
|
+
/** Get a player's message history (received + sent). */
|
|
24
|
+
getPlayerMessages(ensemble: string, playerId: string): Promise<Array<Message | (SentMessage & {
|
|
25
|
+
direction: 'sent';
|
|
26
|
+
})>>;
|
|
27
|
+
/** Get a player's workflow metadata. */
|
|
28
|
+
getPlayerMetadata(ensemble: string, playerId: string): Promise<SessionMetadata | null>;
|
|
29
|
+
/** Send a command to an ensemble's conductor via Maestro. Returns command ID. */
|
|
30
|
+
sendCommand(ensemble: string, text: string, source: string): Promise<string>;
|
|
31
|
+
/** Send a message to a specific player in an ensemble. Returns message ID. */
|
|
32
|
+
sendMessage(ensemble: string, to: string, text: string, source: string): Promise<string>;
|
|
33
|
+
/** Terminate a player's workflow. */
|
|
34
|
+
terminatePlayer(ensemble: string, playerId: string): Promise<void>;
|
|
35
|
+
/** Get active schedules for an ensemble. */
|
|
36
|
+
getSchedules(ensemble: string): Promise<ScheduleEntry[]>;
|
|
37
|
+
/** Cancel a named schedule in an ensemble. */
|
|
38
|
+
cancelSchedule(ensemble: string, name: string): Promise<void>;
|
|
39
|
+
/** Get quality gates from the conductor workflow. */
|
|
40
|
+
getGates(ensemble: string): Promise<QualityGate[]>;
|
|
41
|
+
/** Get stages from the conductor workflow. */
|
|
42
|
+
getStages(ensemble: string): Promise<StageEntry[]>;
|
|
43
|
+
/** Get worktrees from the conductor workflow. */
|
|
44
|
+
getWorktrees(ensemble: string): Promise<WorktreeEntry[]>;
|
|
45
|
+
/** Get aggregated ensemble chat (maestro + conductor traffic). */
|
|
46
|
+
getEnsembleChat(ensemble: string, offset?: number, limit?: number): Promise<EnsembleChatResult>;
|
|
47
|
+
/** Encore (revive) a stale player directly via the maestro session's outbox. */
|
|
48
|
+
encorePlayer(ensemble: string, playerId: string): Promise<void>;
|
|
49
|
+
/** Disband an ensemble: terminate all sessions, scheduler, and maestro workflows. */
|
|
50
|
+
disbandEnsemble(ensemble: string): Promise<{
|
|
51
|
+
terminated: number;
|
|
52
|
+
}>;
|
|
53
|
+
/** Check if the Temporal connection is alive. */
|
|
54
|
+
isConnected(): Promise<boolean>;
|
|
55
|
+
/** Check if the Global Maestro workflow is running. */
|
|
56
|
+
hasGlobalMaestro(): Promise<boolean>;
|
|
57
|
+
/** Ensure a maestro session workflow exists for the ensemble (create or reuse). */
|
|
58
|
+
ensureMaestroSession(ensemble: string): Promise<string>;
|
|
59
|
+
/** Send a message as the maestro to a target player. */
|
|
60
|
+
sendAsMaestro(ensemble: string, targetPlayer: string, text: string): Promise<void>;
|
|
61
|
+
/** Get messages received + sent by the maestro session. */
|
|
62
|
+
getMaestroMessages(ensemble: string): Promise<{
|
|
63
|
+
received: Message[];
|
|
64
|
+
sent: SentMessage[];
|
|
65
|
+
}>;
|
|
66
|
+
}
|
package/dist/tui/App.d.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* - PromptArea (pinned)
|
|
11
11
|
*/
|
|
12
12
|
import React from 'react';
|
|
13
|
-
import type { TempoClient } from '
|
|
13
|
+
import type { TempoClient } from '../client';
|
|
14
14
|
interface AppProps {
|
|
15
15
|
api: TempoClient;
|
|
16
16
|
/** If provided, start directly in ensemble view. */
|