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 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 # TempoClient interface and implementation wraps Temporal queries via Maestro
86
+ │ ├── client.ts # Thin re-export shimre-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 TUI's API layer (`src/tui/client.ts`) a TypeScript interface and implementation that wraps Temporal queries to the Maestro and conductor workflows. 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.
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
@@ -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
+ }
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
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 './client';
13
+ import type { TempoClient } from '../client';
14
14
  interface AppProps {
15
15
  api: TempoClient;
16
16
  /** If provided, start directly in ensemble view. */