claude-tempo 0.26.0-beta.7 → 0.26.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
@@ -70,6 +70,7 @@ src/
70
70
  │ ├── load-lineup.ts / save-lineup.ts / agent-types.ts / resolve.ts
71
71
  │ ├── set-name.ts / set-part.ts / who-am-i.ts / release.ts
72
72
  │ ├── pause-ensemble.ts / resume-ensemble.ts
73
+ │ ├── hosts.ts
73
74
  │ └── helpers.ts # Zod/MCP tool registration wrapper
74
75
  ├── tui/
75
76
  │ ├── App.tsx / store.ts / commands.ts # TUI root, state, slash commands
@@ -78,6 +79,8 @@ src/
78
79
  │ └── utils/ # format, platform, theme, fullscreen, history
79
80
  ├── utils/
80
81
  │ ├── validation.ts / worktree.ts / safe-path.ts / duration.ts / search-attributes.ts
82
+ │ ├── attachment-format.ts / recall-format.ts # Shared display formatters (attachment-info, recall)
83
+ │ ├── hosts.ts / format-hosts.ts # Host enumeration + shared hosts display formatter (#274)
81
84
  ├── types.ts # Shared type definitions
82
85
  ├── git-info.ts # Git repository detection helper
83
86
  └── config.ts # Env var handling
@@ -118,11 +121,12 @@ daemon worker notes, `npx ts-node` dev runner).
118
121
  - **Outbox**: Outbound requests (cue, report, recruit, restart, detach, destroy, …) go through the session's workflow outbox instead of directly signaling other workflows. The dispatch loop processes entries via activities, decoupling tools from cross-workflow signaling.
119
122
  - **Attachment phase** (v0.26): Seven phases tracked on the session workflow — `booting → attached → processing | awaiting → draining → detached → gone`. The phase is authoritative for lifecycle truth: adapters drive it via `claimAttachment` / `adapterExited` / `forceDetach` / `destroy`, and the workflow publishes it on the `ClaudeTempoAttachmentState` search attribute. Replaced the v0.25 `ClaudeTempoStatus` heuristic (removed in v0.26). See [docs/concepts.md](docs/concepts.md) for the phase table and [docs/ops/v0.26-migration.md](docs/ops/v0.26-migration.md) for the upgrade path.
120
123
  - **Adapter heartbeat observability** (#249): After `claimAttachment`, the base adapter logs `first heartbeat scheduled in Xms` then `heartbeat#1 delivered` on the first tick. Every 10 ticks it emits `heartbeats-delivered=N / phase-ticks=N` breadcrumbs. Any silent guard trip in `tickHeartbeat` / `tickPhaseWatcher` now emits a structured `guard tripped: {stopped, reconnecting, …}` log instead of silently orphaning the timer. The phase-watcher emits `WARNING: heartbeat staleness` when `lastHeartbeatAt` falls more than 2× `heartbeatMs` behind `now`. Grep `[claude-tempo:adapter]` to confirm loop health without parsing Temporal history.
121
- - **Per-host task queues**: `host` param on `recruit`/`restart`/`migrate` routes to `claude-tempo-{hostname}` task queue. See [docs/concepts.md](docs/concepts.md) for cross-machine recruiting details.
124
+ - **Per-host task queues**: `host` param on `recruit`/`restart`/`migrate` routes to `claude-tempo-{hostname}` task queue. When `host` is set on `recruit`, a pre-flight check validates the target daemon is live and supports the requested agent type (`force: true` bypasses). Daemon boot profiles (hostname, platform, available player types) are advertised via the `hostProfile` signal and maintained in the global maestro's `hostProfiles` map — surfaced by the `hosts` MCP tool, `claude-tempo hosts` CLI, and `/hosts` TUI command (#274). See [docs/concepts.md](docs/concepts.md) for cross-machine recruiting details.
122
125
  - **Wire protocol**: All signal/query/update names are documented in [`docs/WIRE-PROTOCOL.md`](docs/WIRE-PROTOCOL.md) and are stable — renaming or removing any is a breaking change. **Process**: update `docs/WIRE-PROTOCOL.md` in the same commit as any new signal, query, or update.
123
126
  - **Daemon**: Standalone background process (`src/daemon.ts`) that runs all Temporal workers. Auto-started by any `claude-tempo` command. PID at `~/.claude-tempo/daemon.pid`; logs at `~/.claude-tempo/daemon.log`.
124
127
  - **Player types**: Reusable agent definitions in Claude Code's subagent format (`.md` files with YAML frontmatter). Three-tier lookup: project `.claude/agents/` → user `~/.claude/agents/` → shipped `examples/agents/`. Discover via `agent_types` tool or `claude-tempo agent-types` CLI. Shipped types: tempo-conductor, tempo-composer, tempo-soloist, tempo-tuner, tempo-critic, tempo-roadie, tempo-improv, tempo-liner.
125
128
  - **Lineup examples**: Four pre-built ensemble YAML files in `examples/ensembles/` — `tempo-big-band`, `tempo-dev-team`, `tempo-review-squad`, `tempo-jam-session`. Load with `claude-tempo up --lineup <name>` or the `load_lineup` tool.
129
+ - **GitHub App identity** (`claude-tempo[bot]`): When a player writes to GitHub — issue comments, PR creation/merge, commits, labels, check runs — **use `./scripts/ensemble-gh`** instead of `gh`. The wrapper mints a short-lived installation token so the action is attributed to `claude-tempo[bot]`, not to the human maintainer, making the AI authorship visible. Plain `gh` is still correct for read-only local dev (`gh pr view`, `gh repo clone`, `gh auth status`). Every bot-authored comment/PR body must include the AI attribution footer documented in [docs/github-app.md](docs/github-app.md).
126
130
 
127
131
  See [docs/concepts.md](docs/concepts.md) for the full glossary (Adapter, Attachment phases, Restart, Detach/Destroy, Migrate, Broadcast, Recall, Schedule, Lineup, Quality Gate, Worktree, Stage, Hold/Release, Pause/Resume, Maestro, TempoClient, and more).
128
132
 
package/README.md CHANGED
@@ -143,6 +143,7 @@ claude-tempo up [ensemble] # first-time setup
143
143
  claude-tempo conduct [ensemble] # start a conductor
144
144
  claude-tempo start [ensemble] # start a player
145
145
  claude-tempo status [ensemble] # list active sessions
146
+ claude-tempo hosts # list daemons polling this Temporal namespace (--all/--json)
146
147
  claude-tempo recall <name> # read a player's message history (--limit/--offset/--preview/--from/--since/--include-sent/--json)
147
148
  claude-tempo attachment-info <name> # inspect a session's phase, holder, lease, and heartbeat age
148
149
  claude-tempo release [ensemble] # release held players (unlock + deliver tasks)
Binary file
@@ -106,6 +106,30 @@ export declare function detach(opts: DetachCliOpts): Promise<void>;
106
106
  export declare function destroy(opts: DestroyCliOpts): Promise<void>;
107
107
  export declare function migrate(opts: MigrateCliOpts): Promise<void>;
108
108
  export declare function attachmentInfo(opts: VerbOpts): Promise<void>;
109
+ export interface HostsCliOpts extends CliOverrides {
110
+ ensemble?: string;
111
+ /** Include stale hosts (those not seen in the last minute). CLI default: false. */
112
+ all?: boolean;
113
+ /** Emit raw `HostInfo[]` JSON instead of the formatted table. */
114
+ json?: boolean;
115
+ }
116
+ export declare function hosts(opts: HostsCliOpts): Promise<void>;
117
+ export interface RefreshHostProfileCliOpts extends CliOverrides {
118
+ ensemble?: string;
119
+ /** Max wait for the new profile to appear in the maestro `hostProfiles` map. Default 10s. */
120
+ confirmTimeoutMs?: number;
121
+ }
122
+ /**
123
+ * #274 AC5d (M12) — manual re-signal of this host's profile to the
124
+ * global maestro. The daemon otherwise only re-signals on boot; this
125
+ * subcommand re-computes the profile + signals fresh.
126
+ *
127
+ * Exit semantics (per my implementation-time call to the conductor,
128
+ * approved): await ensureGlobalMaestro → signal → short poll on
129
+ * `hostProfiles()` to confirm the new version is visible → exit 0.
130
+ * Exits nonzero if the poll timeout elapses without confirmation.
131
+ */
132
+ export declare function refreshHostProfile(opts: RefreshHostProfileCliOpts): Promise<void>;
109
133
  export interface RecallCliOpts extends VerbOpts {
110
134
  limit?: number;
111
135
  offset?: number;
@@ -47,6 +47,8 @@ exports.detach = detach;
47
47
  exports.destroy = destroy;
48
48
  exports.migrate = migrate;
49
49
  exports.attachmentInfo = attachmentInfo;
50
+ exports.hosts = hosts;
51
+ exports.refreshHostProfile = refreshHostProfile;
50
52
  exports.recall = recall;
51
53
  exports.restore = restore;
52
54
  exports.ensembleCommand = ensembleCommand;
@@ -2128,6 +2130,83 @@ async function attachmentInfo(opts) {
2128
2130
  await connection.close();
2129
2131
  }
2130
2132
  }
2133
+ async function hosts(opts) {
2134
+ const { config, connection, client } = await verbClient(opts);
2135
+ try {
2136
+ const { listHosts } = await Promise.resolve().then(() => __importStar(require('../utils/hosts')));
2137
+ const { formatHostList } = await Promise.resolve().then(() => __importStar(require('../utils/format-hosts')));
2138
+ const list = await listHosts(client, {
2139
+ force: true, // CLI always bypasses the cache — freshness expectation is "right now".
2140
+ namespace: config.temporalNamespace,
2141
+ taskQueue: config.taskQueue,
2142
+ });
2143
+ if (opts.json) {
2144
+ process.stdout.write(JSON.stringify(list, null, 2) + '\n');
2145
+ }
2146
+ else {
2147
+ out.log(formatHostList(list, { includeStale: opts.all }));
2148
+ }
2149
+ }
2150
+ catch (err) {
2151
+ out.error(err?.message || String(err));
2152
+ process.exit(1);
2153
+ }
2154
+ finally {
2155
+ await connection.close();
2156
+ }
2157
+ }
2158
+ /**
2159
+ * #274 AC5d (M12) — manual re-signal of this host's profile to the
2160
+ * global maestro. The daemon otherwise only re-signals on boot; this
2161
+ * subcommand re-computes the profile + signals fresh.
2162
+ *
2163
+ * Exit semantics (per my implementation-time call to the conductor,
2164
+ * approved): await ensureGlobalMaestro → signal → short poll on
2165
+ * `hostProfiles()` to confirm the new version is visible → exit 0.
2166
+ * Exits nonzero if the poll timeout elapses without confirmation.
2167
+ */
2168
+ async function refreshHostProfile(opts) {
2169
+ const { config, connection, client } = await verbClient(opts);
2170
+ try {
2171
+ const { computeHostProfile, scrubHostProfile, advertiseHostProfile } = await Promise.resolve().then(() => __importStar(require('../daemon')));
2172
+ const { GLOBAL_MAESTRO_WORKFLOW_ID } = await Promise.resolve().then(() => __importStar(require('../config')));
2173
+ const profile = scrubHostProfile(computeHostProfile(config));
2174
+ const result = await advertiseHostProfile(client, profile, { log: (...a) => out.log(a.map(String).join(' ')) });
2175
+ if (!result.ok) {
2176
+ out.error(`hostProfile signal failed after ${result.attempts} attempts. Global Maestro may be unreachable.`);
2177
+ process.exit(1);
2178
+ }
2179
+ // Short confirmation poll — give the workflow a moment to apply the
2180
+ // signal and respond to the query with the fresh version. If the
2181
+ // maestro is absent entirely, the query will throw and we exit 1.
2182
+ const deadline = Date.now() + (opts.confirmTimeoutMs ?? 10_000);
2183
+ const target = profile.version;
2184
+ const handle = client.workflow.getHandle(GLOBAL_MAESTRO_WORKFLOW_ID);
2185
+ while (Date.now() < deadline) {
2186
+ try {
2187
+ const profiles = (await handle.query('hostProfiles'));
2188
+ const live = profiles[profile.hostname];
2189
+ if (live && live.version === target) {
2190
+ out.success(`Host profile for "${profile.hostname}" refreshed (version ${target}).`);
2191
+ return;
2192
+ }
2193
+ }
2194
+ catch {
2195
+ // retry until deadline
2196
+ }
2197
+ await new Promise((r) => setTimeout(r, 500));
2198
+ }
2199
+ out.error(`Signal sent but not yet reflected in hostProfiles() query after ${opts.confirmTimeoutMs ?? 10_000}ms. May succeed shortly; re-run to confirm.`);
2200
+ process.exit(1);
2201
+ }
2202
+ catch (err) {
2203
+ out.error(err?.message || String(err));
2204
+ process.exit(1);
2205
+ }
2206
+ finally {
2207
+ await connection.close();
2208
+ }
2209
+ }
2131
2210
  async function recall(opts) {
2132
2211
  const { config, connection, client } = await verbClient(opts);
2133
2212
  const ensemble = opts.ensemble || config.ensemble;
@@ -76,6 +76,8 @@ ${out.bold('Commands:')}
76
76
  ${out.cyan('migrate')} <name> --host Move a session to a different host
77
77
  ${out.cyan('attachment-info')} <name> Inspect the V2 attachment phase + current holder
78
78
  ${out.cyan('recall')} <name> Read a player's message history (--limit/--offset/--preview/--from/--since/--include-sent/--json)
79
+ ${out.cyan('hosts')} List daemons polling this Temporal namespace with advertised capabilities (--all/--json)
80
+ ${out.cyan('refresh-host-profile')} Re-advertise this daemon's capability profile to the global Maestro
79
81
  ${out.cyan('restore')} [name] Restore orphaned session(s) — interactive picker, or --all / --from-host / --dry-run
80
82
  ${out.cyan('release')} [ensemble] Release all held players (unlock outbox, deliver messages)
81
83
  ${out.cyan('pause')} [ensemble] Pause an ensemble (sessions, scheduler, maestro)
package/dist/cli.js CHANGED
@@ -326,7 +326,7 @@ async function main() {
326
326
  return;
327
327
  }
328
328
  // All other commands: lazy-load the full command surface now.
329
- const { start, status, init, server, up, down, stop, ensembleCommand, agentTypesCommand, broadcast, release, pause, resume, restart, detach, destroy, migrate, attachmentInfo, recall, restore, } = await Promise.resolve().then(() => __importStar(require('./cli/commands')));
329
+ const { start, status, init, server, up, down, stop, ensembleCommand, agentTypesCommand, broadcast, release, pause, resume, restart, detach, destroy, migrate, attachmentInfo, recall, hosts, refreshHostProfile, restore, } = await Promise.resolve().then(() => __importStar(require('./cli/commands')));
330
330
  const ensemble = args.positional[1] || process.env[config_1.ENV.ENSEMBLE] || 'default';
331
331
  // Resolve the default agent from config (only needed for commands that use it)
332
332
  const resolvedAgent = () => args.agent ?? (0, config_1.getConfig)(overrides).defaultAgent;
@@ -505,6 +505,22 @@ async function main() {
505
505
  });
506
506
  break;
507
507
  }
508
+ case 'hosts': {
509
+ await hosts({
510
+ ensemble: args.ensemble || ensemble,
511
+ ...(args.all ? { all: true } : {}),
512
+ ...(args.json ? { json: true } : {}),
513
+ ...overrides,
514
+ });
515
+ break;
516
+ }
517
+ case 'refresh-host-profile': {
518
+ await refreshHostProfile({
519
+ ensemble: args.ensemble || ensemble,
520
+ ...overrides,
521
+ });
522
+ break;
523
+ }
508
524
  case 'recall': {
509
525
  // Positional player is required — matches the #128 design.
510
526
  const name = args.positional[1] || args.name;
@@ -1,4 +1,37 @@
1
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
+ })();
2
35
  Object.defineProperty(exports, "__esModule", { value: true });
3
36
  exports.createTempoClient = createTempoClient;
4
37
  /**
@@ -311,6 +344,15 @@ function createTempoClient(client) {
311
344
  throw new Error(`No session found with name "${playerId}" in ensemble "${ensemble}".`);
312
345
  return target.query(signals_1.attachmentInfoQuery);
313
346
  },
347
+ async listHosts(opts = {}) {
348
+ // Lazy import so this doesn't drag utils/hosts into every
349
+ // consumer of TempoClient at module-load time.
350
+ const { listHosts } = await Promise.resolve().then(() => __importStar(require('../utils/hosts')));
351
+ return listHosts(client, {
352
+ force: Boolean(opts.force),
353
+ namespace: client.options.namespace,
354
+ });
355
+ },
314
356
  async recall(ensemble, playerId) {
315
357
  // #128: direct session queries, no maestro round-trip. Throws rather
316
358
  // than returning empties so the CLI / TUI wrappers can surface a
@@ -4,7 +4,7 @@
4
4
  * Extracted from `src/tui/client.ts` so that non-TUI consumers (CLI, tests,
5
5
  * external integrations) can depend on the interface without pulling in Ink/React.
6
6
  */
7
- import type { MaestroPlayerInfo, MaestroRelayMessage, HistoryEntry, Message, SentMessage, SessionMetadata, ScheduleEntry, QualityGate, StageEntry, WorktreeEntry, EnsembleChatResult, AttachmentInfo } from '../types';
7
+ import type { MaestroPlayerInfo, MaestroRelayMessage, HistoryEntry, Message, SentMessage, SessionMetadata, ScheduleEntry, QualityGate, StageEntry, WorktreeEntry, EnsembleChatResult, AttachmentInfo, HostInfo } from '../types';
8
8
  /**
9
9
  * Raw (unfiltered, unsorted, unsliced) output of `TempoClient.recall`.
10
10
  * The shared formatter at `src/utils/recall-format.ts` turns this into a
@@ -79,6 +79,15 @@ export interface TempoClient {
79
79
  * slice / render; the client stays presentation-free.
80
80
  */
81
81
  recall(ensemble: string, playerId: string): Promise<RecallClientResult>;
82
+ /**
83
+ * #274: List all daemons polling this Temporal namespace, joined with
84
+ * their boot-signaled capability profiles. Consumers typically feed
85
+ * the result through `formatHostList` for a consistent UX across
86
+ * CLI / TUI / MCP. `force: true` bypasses the 3-second result cache.
87
+ */
88
+ listHosts(opts?: {
89
+ force?: boolean;
90
+ }): Promise<HostInfo[]>;
82
91
  /** Get active schedules for an ensemble. */
83
92
  getSchedules(ensemble: string): Promise<ScheduleEntry[]>;
84
93
  /** Cancel a named schedule in an ensemble. */
package/dist/daemon.d.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  import { Client } from '@temporalio/client';
3
- import { type DaemonConfig } from './config';
3
+ import { type Config, type DaemonConfig } from './config';
4
4
  import { type OrphanCandidate } from './reconcile/orphans';
5
+ import type { HostProfile } from './types';
5
6
  /**
6
7
  * Atomically write the daemon PID file via `writeFile(tmp) + rename(tmp, final)`.
7
8
  *
@@ -15,6 +16,105 @@ import { type OrphanCandidate } from './reconcile/orphans';
15
16
  * Exported for unit testing.
16
17
  */
17
18
  export declare function writePidFileAtomic(pidFilePath: string, pid: number): Promise<void>;
19
+ /**
20
+ * Build the daemon's capability profile from its config + runtime env.
21
+ * Result is NOT scrubbed — call `scrubHostProfile` before signaling.
22
+ *
23
+ * Exported for testability; production callers go through
24
+ * `runDaemonBoot(client, deps)` which provides this as the default
25
+ * `computeHostProfile` dep.
26
+ */
27
+ export declare function computeHostProfile(config: Config): HostProfile;
28
+ /**
29
+ * #274 AC5c / M10 — HARD REQUIREMENT privacy scrub.
30
+ *
31
+ * Strips absolute paths and file extensions from every `HostProfile` field
32
+ * before the payload crosses the signal boundary. The global maestro is
33
+ * namespace-wide; a multi-tenant or multi-ensemble corporate setup would
34
+ * leak username-containing paths across ensembles if this is ever
35
+ * violated. Unit-tested in `test/daemon-boot.test.ts` with a dedicated
36
+ * "no `/` or `\\` in any string" invariant assertion against pathological
37
+ * inputs.
38
+ *
39
+ * Contract per architect AC5c:
40
+ * - `claudeBin` — basename only (e.g. `claude`), never absolute
41
+ * - `availableAgentTypes` — names only, never paths
42
+ * - `availablePlayerTypes` — names only, never paths
43
+ * - No env var values, no `workDir`, no user directories in any field
44
+ *
45
+ * The scrub is defense-in-depth: production callers (`computeHostProfile`)
46
+ * already produce clean inputs from `listAgentTypes().map(a => a.name)`.
47
+ * If a future code path accidentally passes a path, this function catches
48
+ * it before the workflow handler ever sees it.
49
+ */
50
+ export declare function scrubHostProfile(raw: HostProfile): HostProfile;
51
+ /**
52
+ * Signal `hostProfile` with bounded retry (AC5b / M11).
53
+ *
54
+ * Default backoff: `[0, 5000, 15000]` ms → 3 attempts, ≤20 s wall-clock,
55
+ * well under the 30 s budget. Tests override to `[0, 0, 0]` for fast
56
+ * execution. On total failure, logs a warning and returns — the daemon
57
+ * stays alive without its profile advertised.
58
+ *
59
+ * Exported for reuse by the Phase 5 `claude-tempo refresh-host-profile`
60
+ * CLI subcommand, which re-signals without needing the full
61
+ * `runDaemonBoot` sequence (the global maestro is already up).
62
+ */
63
+ export declare function advertiseHostProfile(client: Client, profile: HostProfile, opts?: {
64
+ retryBackoffsMs?: number[];
65
+ log?: (...args: unknown[]) => void;
66
+ sendSignal?: (client: Client, profile: HostProfile) => Promise<void>;
67
+ }): Promise<{
68
+ ok: boolean;
69
+ attempts: number;
70
+ lastError?: unknown;
71
+ }>;
72
+ /**
73
+ * #274 M14 — boot-sequence deps. Injected at `runDaemonBoot` so the
74
+ * ordering + retry + scrub invariants are all unit-testable without
75
+ * subprocess fixtures. Production callers pass the default impls
76
+ * already exported from this module.
77
+ */
78
+ export interface DaemonBootDeps {
79
+ /** Ensure the global maestro workflow is running. Awaited before any signal. */
80
+ ensureGlobalMaestro: () => Promise<void>;
81
+ /** Signal the global maestro with the scrubbed host profile. */
82
+ sendHostProfileSignal: (client: Client, profile: HostProfile) => Promise<void>;
83
+ /**
84
+ * Compute the daemon's capability profile. Output is fed into
85
+ * `scrubHostProfile` before signaling — computeHostProfile itself does
86
+ * not need to scrub, the dep-swap pattern makes the pipeline testable.
87
+ */
88
+ computeHostProfile: () => HostProfile;
89
+ /**
90
+ * Retry backoffs for the `hostProfile` signal (ms). Production uses
91
+ * `[0, 5000, 15000]`; tests override to `[0, 0, 0]` for speed.
92
+ */
93
+ retryBackoffsMs?: number[];
94
+ /** Log sink. Tests stub to capture output. Defaults to module-level `log`. */
95
+ log?: (...args: unknown[]) => void;
96
+ }
97
+ /**
98
+ * #274 M14 — daemon boot sequence: ensure global maestro is running,
99
+ * then advertise the (scrubbed) capability profile with bounded retry.
100
+ *
101
+ * Ordering is load-bearing (AC5a / M11): the `hostProfile` signal MUST
102
+ * NOT fire until `ensureGlobalMaestro` has resolved. Otherwise the
103
+ * signal races the workflow-start and gets silently dropped by Temporal
104
+ * (WorkflowNotFound on an unknown workflow id).
105
+ *
106
+ * Hard-failure behavior (AC5b): if `ensureGlobalMaestro` rejects, the
107
+ * daemon stays alive WITHOUT advertising its profile. Next opportunity
108
+ * is the next daemon restart OR a manual `claude-tempo refresh-host-profile`
109
+ * invocation (Phase 5).
110
+ *
111
+ * Tests in `test/daemon-boot.test.ts` exercise:
112
+ * - ensure-before-signal ordering via deferred promises
113
+ * - retry success on 3rd attempt
114
+ * - all-retries-exhausted stays alive
115
+ * - ensure-fails-stays-alive
116
+ */
117
+ export declare function runDaemonBoot(client: Client, deps: DaemonBootDeps): Promise<void>;
18
118
  /**
19
119
  * PR-E reconcile-on-boot — design §10.1.
20
120
  *
package/dist/daemon.js CHANGED
@@ -35,6 +35,10 @@ var __importStar = (this && this.__importStar) || (function () {
35
35
  })();
36
36
  Object.defineProperty(exports, "__esModule", { value: true });
37
37
  exports.writePidFileAtomic = writePidFileAtomic;
38
+ exports.computeHostProfile = computeHostProfile;
39
+ exports.scrubHostProfile = scrubHostProfile;
40
+ exports.advertiseHostProfile = advertiseHostProfile;
41
+ exports.runDaemonBoot = runDaemonBoot;
38
42
  exports.reconcileOnBoot = reconcileOnBoot;
39
43
  exports.selectStaleDetachedOrphans = selectStaleDetachedOrphans;
40
44
  exports.cleanupLoop = cleanupLoop;
@@ -50,6 +54,7 @@ exports.startCleanupLoop = startCleanupLoop;
50
54
  */
51
55
  const fs = __importStar(require("fs"));
52
56
  const os = __importStar(require("os"));
57
+ const path = __importStar(require("path"));
53
58
  const promises_1 = require("timers/promises");
54
59
  const client_1 = require("@temporalio/client");
55
60
  const client_2 = require("@temporalio/client");
@@ -59,6 +64,7 @@ const connection_1 = require("./connection");
59
64
  const daemon_1 = require("./cli/daemon");
60
65
  const client_3 = require("./client");
61
66
  const orphans_1 = require("./reconcile/orphans");
67
+ const agent_types_1 = require("./ensemble/agent-types");
62
68
  const log = (...args) => console.error(`[claude-tempo:daemon ${new Date().toISOString()}]`, ...args);
63
69
  /**
64
70
  * Atomically write the daemon PID file via `writeFile(tmp) + rename(tmp, final)`.
@@ -121,6 +127,186 @@ async function ensureGlobalMaestro(config) {
121
127
  log('Failed to ensure global Maestro (non-fatal):', err instanceof Error ? err.message : String(err));
122
128
  }
123
129
  }
130
+ // ────────────────────────────────────────────────────────────────────────
131
+ // #274 — host capability profile: compute → scrub → signal
132
+ // ────────────────────────────────────────────────────────────────────────
133
+ /**
134
+ * Daemon package version, lazily read from `package.json` so the test
135
+ * build (which compiles daemon.ts into `dist-test/src/` where
136
+ * `../package.json` doesn't resolve) can exercise daemon-boot logic
137
+ * without MODULE_NOT_FOUND. Tests that exercise `computeHostProfile`
138
+ * pass a stubbed version via `HostProfile.version` on the input.
139
+ */
140
+ function daemonVersion() {
141
+ try {
142
+ const { version } = require('../package.json');
143
+ return version;
144
+ }
145
+ catch {
146
+ return 'unknown';
147
+ }
148
+ }
149
+ /**
150
+ * Build the daemon's capability profile from its config + runtime env.
151
+ * Result is NOT scrubbed — call `scrubHostProfile` before signaling.
152
+ *
153
+ * Exported for testability; production callers go through
154
+ * `runDaemonBoot(client, deps)` which provides this as the default
155
+ * `computeHostProfile` dep.
156
+ */
157
+ function computeHostProfile(config) {
158
+ const agentTypes = (() => {
159
+ try {
160
+ return (0, agent_types_1.listAgentTypes)().map((a) => a.name);
161
+ }
162
+ catch {
163
+ // listAgentTypes reads the filesystem; treat any failure as "no
164
+ // discoverable types" rather than crashing boot.
165
+ return [];
166
+ }
167
+ })();
168
+ return {
169
+ hostname: os.hostname(),
170
+ version: daemonVersion(),
171
+ defaultAgent: config.defaultAgent,
172
+ // Currently the daemon advertises only the configured default as an
173
+ // "available runtime"; future work can probe for Copilot bridge via
174
+ // `require.resolve('@github/copilot-sdk')`. Recording as an array
175
+ // keeps the wire shape forward-compatible.
176
+ availableAgentTypes: [config.defaultAgent],
177
+ availablePlayerTypes: agentTypes,
178
+ claudeBin: config.claudeBin,
179
+ platform: process.platform,
180
+ capabilities: [],
181
+ };
182
+ }
183
+ /**
184
+ * #274 AC5c / M10 — HARD REQUIREMENT privacy scrub.
185
+ *
186
+ * Strips absolute paths and file extensions from every `HostProfile` field
187
+ * before the payload crosses the signal boundary. The global maestro is
188
+ * namespace-wide; a multi-tenant or multi-ensemble corporate setup would
189
+ * leak username-containing paths across ensembles if this is ever
190
+ * violated. Unit-tested in `test/daemon-boot.test.ts` with a dedicated
191
+ * "no `/` or `\\` in any string" invariant assertion against pathological
192
+ * inputs.
193
+ *
194
+ * Contract per architect AC5c:
195
+ * - `claudeBin` — basename only (e.g. `claude`), never absolute
196
+ * - `availableAgentTypes` — names only, never paths
197
+ * - `availablePlayerTypes` — names only, never paths
198
+ * - No env var values, no `workDir`, no user directories in any field
199
+ *
200
+ * The scrub is defense-in-depth: production callers (`computeHostProfile`)
201
+ * already produce clean inputs from `listAgentTypes().map(a => a.name)`.
202
+ * If a future code path accidentally passes a path, this function catches
203
+ * it before the workflow handler ever sees it.
204
+ */
205
+ function scrubHostProfile(raw) {
206
+ const stripPath = (s) => {
207
+ // Platform-independent basename: `path.basename` is runtime-bound —
208
+ // on POSIX it doesn't recognise `\` as a separator, so a Windows
209
+ // daemon's signal leaking `'C:\Users\alice\bin\claude.exe'` into
210
+ // a Linux-hosted global maestro would bypass the scrub entirely
211
+ // (CI caught exactly this on Ubuntu shard-2). Normalize first,
212
+ // then use `path.posix.basename` explicitly so the scrub is
213
+ // deterministic regardless of where the daemon or maestro runs.
214
+ //
215
+ // Also strip a single trailing `.md` — player-type files are
216
+ // shipped as e.g. `tempo-soloist.md` but the name should be just
217
+ // `tempo-soloist` on the wire.
218
+ const normalized = s.replace(/\\/g, '/');
219
+ const base = path.posix.basename(normalized);
220
+ return base.endsWith('.md') ? base.slice(0, -3) : base;
221
+ };
222
+ const scrubList = (list) => list?.map(stripPath);
223
+ return {
224
+ hostname: raw.hostname,
225
+ version: raw.version,
226
+ defaultAgent: raw.defaultAgent,
227
+ availableAgentTypes: scrubList(raw.availableAgentTypes),
228
+ availablePlayerTypes: scrubList(raw.availablePlayerTypes),
229
+ claudeBin: raw.claudeBin ? stripPath(raw.claudeBin) : undefined,
230
+ platform: raw.platform,
231
+ capabilities: raw.capabilities,
232
+ };
233
+ }
234
+ /** Production default: signal the global maestro with the profile. */
235
+ async function realSendHostProfileSignal(client, profile) {
236
+ const handle = client.workflow.getHandle(config_1.GLOBAL_MAESTRO_WORKFLOW_ID);
237
+ await handle.signal('hostProfile', profile);
238
+ }
239
+ /**
240
+ * Signal `hostProfile` with bounded retry (AC5b / M11).
241
+ *
242
+ * Default backoff: `[0, 5000, 15000]` ms → 3 attempts, ≤20 s wall-clock,
243
+ * well under the 30 s budget. Tests override to `[0, 0, 0]` for fast
244
+ * execution. On total failure, logs a warning and returns — the daemon
245
+ * stays alive without its profile advertised.
246
+ *
247
+ * Exported for reuse by the Phase 5 `claude-tempo refresh-host-profile`
248
+ * CLI subcommand, which re-signals without needing the full
249
+ * `runDaemonBoot` sequence (the global maestro is already up).
250
+ */
251
+ async function advertiseHostProfile(client, profile, opts = {}) {
252
+ const backoffs = opts.retryBackoffsMs ?? [0, 5000, 15000];
253
+ const logFn = opts.log ?? log;
254
+ const send = opts.sendSignal ?? realSendHostProfileSignal;
255
+ let lastError;
256
+ for (let attempt = 0; attempt < backoffs.length; attempt++) {
257
+ const delay = backoffs[attempt];
258
+ if (delay > 0)
259
+ await (0, promises_1.setTimeout)(delay);
260
+ try {
261
+ await send(client, profile);
262
+ logFn(`Advertised host profile for "${profile.hostname}" (attempt ${attempt + 1}/${backoffs.length})`);
263
+ return { ok: true, attempts: attempt + 1 };
264
+ }
265
+ catch (err) {
266
+ lastError = err;
267
+ logFn(`hostProfile signal attempt ${attempt + 1}/${backoffs.length} failed:`, err instanceof Error ? err.message : err);
268
+ }
269
+ }
270
+ logFn(`Failed to advertise host profile after ${backoffs.length} attempts (non-fatal; daemon stays alive):`, lastError instanceof Error ? lastError.message : lastError);
271
+ return { ok: false, attempts: backoffs.length, lastError };
272
+ }
273
+ /**
274
+ * #274 M14 — daemon boot sequence: ensure global maestro is running,
275
+ * then advertise the (scrubbed) capability profile with bounded retry.
276
+ *
277
+ * Ordering is load-bearing (AC5a / M11): the `hostProfile` signal MUST
278
+ * NOT fire until `ensureGlobalMaestro` has resolved. Otherwise the
279
+ * signal races the workflow-start and gets silently dropped by Temporal
280
+ * (WorkflowNotFound on an unknown workflow id).
281
+ *
282
+ * Hard-failure behavior (AC5b): if `ensureGlobalMaestro` rejects, the
283
+ * daemon stays alive WITHOUT advertising its profile. Next opportunity
284
+ * is the next daemon restart OR a manual `claude-tempo refresh-host-profile`
285
+ * invocation (Phase 5).
286
+ *
287
+ * Tests in `test/daemon-boot.test.ts` exercise:
288
+ * - ensure-before-signal ordering via deferred promises
289
+ * - retry success on 3rd attempt
290
+ * - all-retries-exhausted stays alive
291
+ * - ensure-fails-stays-alive
292
+ */
293
+ async function runDaemonBoot(client, deps) {
294
+ const logFn = deps.log ?? log;
295
+ const raw = deps.computeHostProfile();
296
+ const profile = scrubHostProfile(raw);
297
+ try {
298
+ await deps.ensureGlobalMaestro();
299
+ }
300
+ catch (err) {
301
+ logFn('ensureGlobalMaestro failed (non-fatal); host profile not advertised this boot:', err instanceof Error ? err.message : err);
302
+ return;
303
+ }
304
+ await advertiseHostProfile(client, profile, {
305
+ retryBackoffsMs: deps.retryBackoffsMs,
306
+ log: logFn,
307
+ sendSignal: deps.sendHostProfileSignal,
308
+ });
309
+ }
124
310
  // ── Reconcile-on-boot (PR-E §10.1) ──
125
311
  /**
126
312
  * PR-E reconcile-on-boot — design §10.1.
@@ -404,10 +590,28 @@ async function main() {
404
590
  sharedWorker = workers.sharedWorker;
405
591
  hostWorker = workers.hostWorker;
406
592
  log('Workers created — processing tasks');
407
- // Auto-start the global Maestro workflow (non-blocking, non-fatal)
408
- ensureGlobalMaestro(config).catch((err) => {
409
- log('ensureGlobalMaestro background error:', err);
410
- });
593
+ // #274 — daemon boot sequence: ensure the global maestro is running,
594
+ // then advertise this host's capability profile with bounded retry.
595
+ // Fire-and-forget from main's perspective (the workers above are
596
+ // already polling tasks; we don't block the run loop on maestro
597
+ // ensure + profile signaling). Ordering INSIDE runDaemonBoot is
598
+ // load-bearing — see M11 / AC5a — so the outer `.catch` only
599
+ // handles unexpected throws (both ensure and signal paths log +
600
+ // return gracefully on their own).
601
+ (async () => {
602
+ try {
603
+ const bootConnection = await (0, connection_1.createTemporalConnection)(config);
604
+ const bootClient = new client_1.Client({ connection: bootConnection, namespace: config.temporalNamespace });
605
+ await runDaemonBoot(bootClient, {
606
+ ensureGlobalMaestro: () => ensureGlobalMaestro(config),
607
+ sendHostProfileSignal: realSendHostProfileSignal,
608
+ computeHostProfile: () => computeHostProfile(config),
609
+ });
610
+ }
611
+ catch (err) {
612
+ log('runDaemonBoot background error:', err);
613
+ }
614
+ })();
411
615
  // PR-E reconcile-on-boot + cleanup loop (design §10, §13.4). Both run
412
616
  // against their own Temporal Client, not the worker connection — they
413
617
  // call `workflow.list` + `workflow.getHandle().query(...)` which are