agent-tempo 1.7.0-beta.9 → 1.7.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.
Files changed (39) hide show
  1. package/CLAUDE.md +17 -1
  2. package/README.md +2 -1
  3. package/dashboard/package.json +1 -1
  4. package/dist/activities/outbox.js +10 -0
  5. package/dist/activities/resolve.d.ts +83 -18
  6. package/dist/activities/resolve.js +139 -26
  7. package/dist/cli/command-center-command.js +25 -2
  8. package/dist/cli/commands.d.ts +28 -0
  9. package/dist/cli/commands.js +88 -17
  10. package/dist/cli/mcp.d.ts +26 -2
  11. package/dist/cli/mcp.js +33 -4
  12. package/dist/cli/startup.js +8 -2
  13. package/dist/cli.js +17 -5
  14. package/dist/client/subscribe.d.ts +10 -0
  15. package/dist/client/subscribe.js +2 -0
  16. package/dist/daemon.d.ts +44 -7
  17. package/dist/daemon.js +54 -8
  18. package/dist/http/deliverability.d.ts +68 -0
  19. package/dist/http/deliverability.js +78 -0
  20. package/dist/http/qa.js +8 -1
  21. package/dist/http/writes.js +24 -2
  22. package/dist/pi/install.d.ts +15 -0
  23. package/dist/pi/install.js +35 -0
  24. package/dist/pi/mission-control/actions.d.ts +30 -2
  25. package/dist/pi/mission-control/actions.js +92 -3
  26. package/dist/pi/mission-control/board.d.ts +84 -0
  27. package/dist/pi/mission-control/board.js +62 -1
  28. package/dist/pi/mission-control/extension.d.ts +122 -1
  29. package/dist/pi/mission-control/extension.js +332 -13
  30. package/dist/pi/mission-control/render.d.ts +25 -0
  31. package/dist/pi/mission-control/render.js +131 -12
  32. package/dist/spawn.d.ts +43 -26
  33. package/dist/spawn.js +64 -37
  34. package/dist/tools/cue.js +12 -0
  35. package/dist/tools/ensemble.js +33 -2
  36. package/dist/tools/recruit.js +8 -26
  37. package/dist/utils/sdk-probe.d.ts +12 -0
  38. package/dist/utils/sdk-probe.js +28 -0
  39. package/package.json +8 -4
@@ -35,7 +35,9 @@ var __importStar = (this && this.__importStar) || (function () {
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.status = status;
37
37
  exports.init = init;
38
+ exports.initProject = initProject;
38
39
  exports.server = server;
40
+ exports.formatEnsembleReadyLines = formatEnsembleReadyLines;
39
41
  exports.up = up;
40
42
  exports.formatScheduleRecurrence = formatScheduleRecurrence;
41
43
  exports.lineupScheduleToEntry = lineupScheduleToEntry;
@@ -63,6 +65,7 @@ const croner_1 = require("croner");
63
65
  const client_1 = require("@temporalio/client");
64
66
  const spawn_1 = require("../spawn");
65
67
  const probe_1 = require("../pi/probe");
68
+ const install_1 = require("../pi/install");
66
69
  const config_1 = require("../config");
67
70
  const git_info_1 = require("../git-info");
68
71
  const connection_1 = require("../connection");
@@ -709,7 +712,10 @@ async function start(opts) {
709
712
  out.log(`\nCheck status: ${out.dim('agent-tempo status ' + opts.ensemble)}`);
710
713
  if (startLineup && startInitialStartup) {
711
714
  console.log();
712
- out.log(` ${(0, constants_1.ensembleReadyBanner)(startLineup.name, startLineup.players.length)}`);
715
+ // #832 — name the RESOLVED ensemble (opts.ensemble), not the lineup's `name:`.
716
+ const [readyLine, connectLine] = formatEnsembleReadyLines(opts.ensemble, startLineup.name, startLineup.players.length);
717
+ out.success(readyLine);
718
+ out.log(` ${out.dim(connectLine)}`);
713
719
  }
714
720
  }
715
721
  async function status(opts) {
@@ -857,7 +863,7 @@ async function status(opts) {
857
863
  async function init(opts) {
858
864
  if (opts.project) {
859
865
  // Per-project .mcp.json mode
860
- return initProject(opts.dir);
866
+ return initProject(opts.dir, opts.suppressNextSteps);
861
867
  }
862
868
  // Default: global install via `claude mcp add`
863
869
  if ((0, mcp_1.isGlobalMcpRegistered)() || (0, mcp_1.isMcpConfigured)(opts.dir)) {
@@ -868,22 +874,35 @@ async function init(opts) {
868
874
  const claudePath = (0, spawn_1.resolveClaudePath)();
869
875
  if (claudePath === 'claude') {
870
876
  out.warn('claude binary not found — falling back to project-level .mcp.json');
871
- return initProject(opts.dir);
877
+ return initProject(opts.dir, opts.suppressNextSteps);
872
878
  }
873
- if ((0, mcp_1.addGlobalMcp)()) {
879
+ const reg = (0, mcp_1.addGlobalMcp)();
880
+ if (reg.ok) {
874
881
  out.success('Registered agent-tempo globally (user scope)');
875
882
  out.log(` ${out.dim('Available in all Claude Code sessions')}`);
876
883
  }
877
884
  else {
878
885
  out.warn('Failed to register globally — falling back to project-level .mcp.json');
879
- return initProject(opts.dir);
886
+ // #818 — surface WHY (the captured `claude mcp add` stderr) so a fresh-install
887
+ // failure is diagnosable instead of silent. Show the first line; `DEBUG=1` logs full.
888
+ if (reg.error)
889
+ out.log(` ${out.dim(reg.error.split('\n')[0])}`);
890
+ return initProject(opts.dir, opts.suppressNextSteps);
891
+ }
892
+ if (!opts.suppressNextSteps) {
893
+ out.log(`\nNext steps:`);
894
+ out.log(` 1. Start Temporal: ${out.dim('temporal server start-dev')}`);
895
+ out.log(` 2. Start conductor: ${out.dim('agent-tempo conduct')}`);
880
896
  }
881
- out.log(`\nNext steps:`);
882
- out.log(` 1. Start Temporal: ${out.dim('temporal server start-dev')}`);
883
- out.log(` 2. Start conductor: ${out.dim('agent-tempo conduct')}`);
884
897
  }
885
- /** Per-project .mcp.json install (legacy, used with --project flag). */
886
- function initProject(dir) {
898
+ /**
899
+ * Per-project .mcp.json install (legacy, used with --project flag).
900
+ *
901
+ * Exported for #818 regression coverage (the `suppressNextSteps` gate) — it only
902
+ * touches the filesystem + stdout, so it is unit-testable without shelling out to
903
+ * `claude`. Not part of the public CLI surface.
904
+ */
905
+ function initProject(dir, suppressNextSteps) {
887
906
  const mcpPath = (0, path_1.join)(dir, '.mcp.json');
888
907
  const entry = {
889
908
  command: 'agent-tempo-server',
@@ -919,9 +938,13 @@ function initProject(dir) {
919
938
  out.success('Created .mcp.json with agent-tempo config');
920
939
  }
921
940
  out.log(` ${out.dim(mcpPath)}`);
922
- out.log(`\nNext steps:`);
923
- out.log(` 1. Start Temporal: ${out.dim('temporal server start-dev')}`);
924
- out.log(` 2. Start conductor: ${out.dim('agent-tempo conduct')}`);
941
+ // #818 — `up` suppresses this stale manual-setup block (it already started
942
+ // Temporal + the conductor); the manual `agent-tempo init` verb still shows it.
943
+ if (!suppressNextSteps) {
944
+ out.log(`\nNext steps:`);
945
+ out.log(` 1. Start Temporal: ${out.dim('temporal server start-dev')}`);
946
+ out.log(` 2. Start conductor: ${out.dim('agent-tempo conduct')}`);
947
+ }
925
948
  }
926
949
  // --- Temporal server management ---
927
950
  // #700 P1 — DEFAULT_DB_PATH, isTemporalReachable, and registerSearchAttributes
@@ -1018,6 +1041,25 @@ async function server(opts) {
1018
1041
  out.success('Temporal ready');
1019
1042
  }
1020
1043
  }
1044
+ /**
1045
+ * #832 — the operator-facing "ensemble ready" lines for `up` / `conduct`.
1046
+ *
1047
+ * Keyed on the RESOLVED ensemble name (what `command-center` / `status` take —
1048
+ * `--ensemble` flag > positional > env > `default`), NOT the lineup's `name:`
1049
+ * field. The two diverge whenever any of those overrides the lineup name, so the
1050
+ * old `Ensemble <lineup-name> is ready` line named a thing the operator can't
1051
+ * actually `connect` to. Surfaces the connect command so the next step is one
1052
+ * copy-paste away. Pure + exported for unit testing (the surrounding `up` does
1053
+ * I/O and isn't unit-testable). The shared markdown {@link ensembleReadyBanner}
1054
+ * stays lineup-keyed for the conductor's seeded directive (a different surface).
1055
+ */
1056
+ function formatEnsembleReadyLines(ensemble, lineupName, playerCount) {
1057
+ const plural = playerCount === 1 ? '' : 's';
1058
+ return [
1059
+ `Ensemble "${ensemble}" is ready (from lineup ${lineupName}). ${playerCount} player${plural} on standby.`,
1060
+ `Connect: agent-tempo command-center ${ensemble}`,
1061
+ ];
1062
+ }
1021
1063
  async function up(opts) {
1022
1064
  const config = (0, config_1.getConfig)(opts);
1023
1065
  // #689 — best-effort sweep of stale 0600 secret env files (residual from a shell
@@ -1074,7 +1116,10 @@ async function up(opts) {
1074
1116
  out.check('MCP configured', true);
1075
1117
  }
1076
1118
  else {
1077
- await init({ dir: process.cwd() });
1119
+ // #818 — suppress init's manual "Next steps" block: `up` has already started
1120
+ // Temporal + the conductor above, so printing "1. Start Temporal / 2. Start
1121
+ // conductor" here is stale and misleading.
1122
+ await init({ dir: process.cwd(), suppressNextSteps: true });
1078
1123
  out.check('MCP configured', true);
1079
1124
  }
1080
1125
  // Always forward all resolved Temporal settings to child processes.
@@ -1274,10 +1319,33 @@ async function up(opts) {
1274
1319
  if (!process.env.ANTHROPIC_API_KEY) {
1275
1320
  out.warn('ANTHROPIC_API_KEY is not set — the Pi conductor will fall back to Pi\'s own auth/default model. Set it if Pi needs an Anthropic key.');
1276
1321
  }
1322
+ // #825 — extension-registration guard (mirrors command-center's #820 Bug-2
1323
+ // guard). `up --agent pi` no longer passes an inline `-e` (that risked a
1324
+ // divergent-copy double-load, #825); it now relies on the player extension
1325
+ // being registered in Pi's settings.json. On a box that never ran `install-pi`,
1326
+ // a plain `pi` would launch with NO extension — no claim/heartbeat, a silent
1327
+ // non-conductor (the #820 Bug-2 failure, transplanted to the conductor). So
1328
+ // auto-install idempotently before spawning; fail loud with the manual command
1329
+ // if the write fails. (Checks the GLOBAL settings.json, like command-center; a
1330
+ // user who ran `install-pi --project` still works — `pi` loads the project
1331
+ // path and the same realpath dedupes, so the redundant global install is a
1332
+ // harmless idempotent write, never a second load.)
1333
+ if (!(0, install_1.arePiExtensionsRegistered)()) {
1334
+ try {
1335
+ const result = (0, install_1.installPiExtensions)();
1336
+ out.log(out.dim(` Registered the Pi extensions in ${result.settingsPath} (first-run install-pi).`));
1337
+ }
1338
+ catch (err) {
1339
+ out.error('Cannot start Pi conductor — the Pi extensions are not registered and auto-install failed: ' +
1340
+ `${err instanceof Error ? err.message : String(err)}. Run \`agent-tempo install-pi\` manually, then retry.`);
1341
+ process.exit(1);
1342
+ }
1343
+ }
1277
1344
  let piSpawn;
1278
1345
  try {
1279
- // resolvePiInteractiveBinary / resolvePiExtensionPath throw fail-clean
1280
- // (Pi CLI missing / extension unbuilt) caught here, no terminal launched.
1346
+ // resolvePiInteractiveBinary throws fail-clean (Pi CLI missing) — caught
1347
+ // here, no terminal launched. #825: no more `-e`/extension resolution the
1348
+ // player extension loads from settings.json, registered + guarded just above.
1281
1349
  piSpawn = (0, spawn_1.buildPiConductorSpawn)({
1282
1350
  ensemble: opts.ensemble,
1283
1351
  sessionName,
@@ -1362,7 +1430,10 @@ async function up(opts) {
1362
1430
  // nothing is deferred — we only surface the banner on initial-startup paths.
1363
1431
  if (lineup && initialStartup) {
1364
1432
  console.log();
1365
- out.log(` ${(0, constants_1.ensembleReadyBanner)(lineup.name, lineup.players.length)}`);
1433
+ // #832 — name the RESOLVED ensemble (opts.ensemble), not the lineup's `name:`.
1434
+ const [readyLine, connectLine] = formatEnsembleReadyLines(opts.ensemble, lineup.name, lineup.players.length);
1435
+ out.success(readyLine);
1436
+ out.log(` ${out.dim(connectLine)}`);
1366
1437
  }
1367
1438
  console.log();
1368
1439
  }
package/dist/cli/mcp.d.ts CHANGED
@@ -1,7 +1,31 @@
1
1
  /** Check if agent-tempo is registered in `claude mcp list` (global user scope). */
2
2
  export declare function isGlobalMcpRegistered(): boolean;
3
- /** Register agent-tempo globally via `claude mcp add`. */
4
- export declare function addGlobalMcp(): boolean;
3
+ /** Result of {@link addGlobalMcp} `ok` plus the captured failure reason. */
4
+ export interface AddGlobalMcpResult {
5
+ ok: boolean;
6
+ /**
7
+ * #818 — when `ok` is false, the captured failure detail (the `claude mcp add`
8
+ * stderr, or the error message). Lets the caller surface WHY global registration
9
+ * failed instead of a bare "fell back to project .mcp.json" with no diagnosis.
10
+ */
11
+ error?: string;
12
+ }
13
+ /**
14
+ * Register agent-tempo globally via `claude mcp add -s user`.
15
+ *
16
+ * #818 — global registration was observed FAILING on a fresh published-package
17
+ * install (Windows), and the old `catch { return false }` swallowed the reason,
18
+ * leaving the silent project-`.mcp.json` fallback undiagnosable. We now capture the
19
+ * `claude mcp add` stderr and return it so the caller can surface it; a DEBUG-gated
20
+ * breadcrumb (`[agent-tempo:mcp]`) records the full detail for `DEBUG=1` runs.
21
+ *
22
+ * Windows hypothesis (worth confirming when reproducing): `claude mcp add -s user`
23
+ * may fail to resolve/write the user-scope config — e.g. the `claude` shim on PATH
24
+ * resolves to a `.cmd`/`.ps1` wrapper that execFileSync can't invoke directly, or
25
+ * the user-scope config path under `%APPDATA%`/`%USERPROFILE%` isn't writable in
26
+ * that shell. The captured stderr is the first concrete signal to disambiguate.
27
+ */
28
+ export declare function addGlobalMcp(): AddGlobalMcpResult;
5
29
  /** Remove agent-tempo from global MCP config via `claude mcp remove`. */
6
30
  export declare function removeGlobalMcp(): boolean;
7
31
  /** Check if agent-tempo MCP is configured (global or project-level). */
package/dist/cli/mcp.js CHANGED
@@ -22,17 +22,46 @@ function isGlobalMcpRegistered() {
22
22
  return false;
23
23
  }
24
24
  }
25
- /** Register agent-tempo globally via `claude mcp add`. */
25
+ /** Pull the most useful failure detail off a thrown execFileSync error. */
26
+ function execFailureDetail(err) {
27
+ const e = err;
28
+ const stderr = e?.stderr;
29
+ if (stderr) {
30
+ const s = (Buffer.isBuffer(stderr) ? stderr.toString('utf8') : String(stderr)).trim();
31
+ if (s)
32
+ return s;
33
+ }
34
+ return e?.message ? String(e.message).trim() : String(err);
35
+ }
36
+ /**
37
+ * Register agent-tempo globally via `claude mcp add -s user`.
38
+ *
39
+ * #818 — global registration was observed FAILING on a fresh published-package
40
+ * install (Windows), and the old `catch { return false }` swallowed the reason,
41
+ * leaving the silent project-`.mcp.json` fallback undiagnosable. We now capture the
42
+ * `claude mcp add` stderr and return it so the caller can surface it; a DEBUG-gated
43
+ * breadcrumb (`[agent-tempo:mcp]`) records the full detail for `DEBUG=1` runs.
44
+ *
45
+ * Windows hypothesis (worth confirming when reproducing): `claude mcp add -s user`
46
+ * may fail to resolve/write the user-scope config — e.g. the `claude` shim on PATH
47
+ * resolves to a `.cmd`/`.ps1` wrapper that execFileSync can't invoke directly, or
48
+ * the user-scope config path under `%APPDATA%`/`%USERPROFILE%` isn't writable in
49
+ * that shell. The captured stderr is the first concrete signal to disambiguate.
50
+ */
26
51
  function addGlobalMcp() {
27
52
  try {
28
53
  (0, child_process_1.execFileSync)('claude', [
29
54
  'mcp', 'add', 'agent-tempo', '-s', 'user',
30
55
  '--', 'agent-tempo-server',
31
56
  ], { stdio: ['ignore', 'ignore', 'pipe'] });
32
- return true;
57
+ return { ok: true };
33
58
  }
34
- catch {
35
- return false;
59
+ catch (err) {
60
+ const error = execFailureDetail(err);
61
+ if (process.env.DEBUG) {
62
+ console.error('[agent-tempo:mcp] `claude mcp add -s user` failed:', error);
63
+ }
64
+ return { ok: false, error };
36
65
  }
37
66
  }
38
67
  /** Remove agent-tempo from global MCP config via `claude mcp remove`. */
@@ -321,8 +321,14 @@ async function stepMcpConfig(cache, cwd, now) {
321
321
  return { status: 'ok', durationMs: 0 };
322
322
  }
323
323
  // Install into global user scope (matches current `init` behavior).
324
- if (!(0, mcp_1.isGlobalMcpRegistered)() && (0, mcp_1.addGlobalMcp)()) {
325
- return { status: 'action-taken', durationMs: 0, detail: 'registered agent-tempo in user MCP scope' };
324
+ if (!(0, mcp_1.isGlobalMcpRegistered)()) {
325
+ const reg = (0, mcp_1.addGlobalMcp)();
326
+ if (reg.ok) {
327
+ return { status: 'action-taken', durationMs: 0, detail: 'registered agent-tempo in user MCP scope' };
328
+ }
329
+ // #818 — include the captured failure reason so the bootstrap step is diagnosable.
330
+ const why = reg.error ? ` (${reg.error.split('\n')[0]})` : '';
331
+ return { status: 'failed', durationMs: 0, detail: `Could not register agent-tempo MCP${why}. Run \`agent-tempo init\` manually.` };
326
332
  }
327
333
  return { status: 'failed', durationMs: 0, detail: 'Could not register agent-tempo MCP. Run `agent-tempo init` manually.' };
328
334
  });
package/dist/cli.js CHANGED
@@ -443,7 +443,11 @@ async function main() {
443
443
  // import — an operator can open the board even when the Temporal SDK is broken.
444
444
  const { commandCenterCommand } = await Promise.resolve().then(() => __importStar(require('./cli/command-center-command')));
445
445
  await commandCenterCommand({
446
- ensemble: args.positional[1],
446
+ // #820 (Bug 3) — route through the canonical resolver (flag > positional >
447
+ // env > 'default'), mirroring `up` post-#685. Previously passed the bare
448
+ // `positional[1]`, which command-center-command then dropped in favor of
449
+ // `config.ensemble` (env/default) → `command-center <ensemble>` watched 'default'.
450
+ ensemble: (0, resolve_ensemble_1.resolveEnsemble)(args),
447
451
  ...overrides,
448
452
  });
449
453
  return;
@@ -540,10 +544,18 @@ async function main() {
540
544
  });
541
545
  break;
542
546
  case 'destroy': {
543
- const target = args.positional[1] || args.ensemble || process.env[config_1.ENV.ENSEMBLE];
544
- if (!target) {
545
- out.error('Usage: agent-tempo destroy <ensemble> [-y]');
546
- process.exit(1);
547
+ // #835 use the SHARED resolver (flag > positional > env > default) so
548
+ // `destroy` matches every other verb. It previously hand-rolled INVERTED
549
+ // precedence (`positional || ensemble || env`), so `destroy foo --ensemble
550
+ // bar` destroyed `foo` — the lone outlier. `destroy` is gated by a typed
551
+ // confirmation (or `--yes`), so the resolver's `default` fallback is safe.
552
+ const target = (0, resolve_ensemble_1.resolveEnsemble)(args);
553
+ // #835 — surface a flag-over-positional override as a non-fatal note so the
554
+ // flag silently winning over a DIFFERENT positional isn't a silent mask
555
+ // (flag wins by design — this only warns, never errors).
556
+ const positional = args.positional[1];
557
+ if (args.ensemble && positional && args.ensemble !== positional) {
558
+ out.warn(`note: --ensemble "${args.ensemble}" overrides positional "${positional}"`);
547
559
  }
548
560
  await destroy({
549
561
  ensemble: target,
@@ -81,6 +81,16 @@ export interface SubscribeDeps {
81
81
  * present (Node 20), the wrapper falls back to fetch.
82
82
  */
83
83
  EventSourceImpl?: typeof EventSource;
84
+ /**
85
+ * #826 — force the fetch transport even when a native `EventSource` is
86
+ * available and no token is set. The fetch path is the only one that
87
+ * surfaces a permanent **401/404** as a thrown {@link SubscribeHttpError};
88
+ * native `EventSource` swallows those into its own silent reconnect cycle.
89
+ * The mission-control board needs that hard-error visibility (404 → `gone`,
90
+ * 401 → auth hint), so it sets this. TUI / dashboard leave it unset and keep
91
+ * the auto-selection (native `EventSource` on a tokenless loopback board).
92
+ */
93
+ forceFetch?: boolean;
84
94
  /**
85
95
  * Override sleep — used by tests to fast-forward backoff. Accepts an
86
96
  * `AbortSignal` so the wrapper can wake early on abort.
@@ -216,6 +216,8 @@ function makeIterator(args) {
216
216
  * for `Authorization: Bearer …` and is the only option in Node 20.
217
217
  */
218
218
  function canUseEventSource(deps) {
219
+ if (deps.forceFetch)
220
+ return false; // #826 — caller needs throw-on-permanent
219
221
  if (deps.token)
220
222
  return false;
221
223
  return resolveEventSource(deps) !== undefined;
package/dist/daemon.d.ts CHANGED
@@ -74,14 +74,13 @@ export declare function ensureDevNamespace(connection: Pick<Connection, 'workflo
74
74
  * Test-only dependency-injection seam for {@link computeHostProfile}.
75
75
  *
76
76
  * Production callers omit `deps` entirely; the defaults resolve to the
77
- * real probes in `daemon-adapter-versions.ts`. Tests inject stubs to
78
- * exercise installed-vs-not-installed scenarios deterministically
79
- * without touching the host filesystem. Mirrors the
80
- * `ProbeAdapterVersionsDeps` shape from `daemon-adapter-versions.ts`,
81
- * but only includes synchronous probes — `computeHostProfile` is sync
82
- * by contract.
77
+ * real probes. Tests inject stubs to exercise installed-vs-not-installed
78
+ * scenarios deterministically without touching the host filesystem or
79
+ * spawning subprocesses. All probes are synchronous — `computeHostProfile`
80
+ * is sync by contract.
83
81
  *
84
- * Added in #532 PR-2 for the copilot host-profile probe.
82
+ * Added in #532 PR-2 for the copilot probe; extended in #819 for pi,
83
+ * opencode, and claude-api probes.
85
84
  */
86
85
  export interface ComputeHostProfileDeps {
87
86
  /**
@@ -91,6 +90,44 @@ export interface ComputeHostProfileDeps {
91
90
  * `undefined` when missing or unresolvable.
92
91
  */
93
92
  resolveCopilotSdkVersionSync?: () => string | undefined;
93
+ /**
94
+ * #819 — Synchronous Pi availability probe. Default: checks Node ≥ 22.19
95
+ * floor via {@link checkPiNodeFloor} AND `@earendil-works/pi-coding-agent`
96
+ * install via {@link probeSdkInstall}. Returns `{ available: true }` when
97
+ * both pass, `{ available: false }` otherwise. Tests inject a stub to
98
+ * avoid filesystem walks and Node-version coupling.
99
+ */
100
+ probePiSync?: () => {
101
+ available: boolean;
102
+ };
103
+ /**
104
+ * #819 — Synchronous opencode availability probe. Default: checks
105
+ * `@opencode-ai/sdk` install via {@link probeSdkInstall} AND
106
+ * `opencode` binary on PATH via {@link hasOpencodeCliOnPath}. Returns
107
+ * `true` when both pass. Tests inject a stub.
108
+ */
109
+ probeOpencodeSync?: () => boolean;
110
+ /**
111
+ * #819 — Synchronous claude-api availability probe. Default: checks
112
+ * `@anthropic-ai/sdk` install via {@link probeSdkInstall} AND
113
+ * `ANTHROPIC_API_KEY` in the daemon's process env. Returns `true` when
114
+ * both pass. Tests inject a stub.
115
+ *
116
+ * Why the key is required (not SDK-only like copilot):
117
+ * The claude-api adapter is spawned BY the daemon and inherits the
118
+ * DAEMON's env (`spawn.ts` passes no explicit env; the adapter process
119
+ * reads its own `process.env.ANTHROPIC_API_KEY` at execution time —
120
+ * `src/adapters/claude-api/adapter.ts:270`). Recruit does NOT plumb the
121
+ * key from the recruiting session. Therefore: the daemon having the key
122
+ * IS the deliverability predicate — advertising when the daemon env has
123
+ * the key makes this probe agree exactly with what recruit can deliver.
124
+ * This is not a conservative heuristic; it is the precise gate.
125
+ *
126
+ * The daemon inherits its env at spawn time (`...process.env` in
127
+ * `src/cli/daemon.ts`). A user adding the key after the daemon is
128
+ * already running must restart the daemon for it to be advertised.
129
+ */
130
+ probeClaudeApiSync?: () => boolean;
94
131
  }
95
132
  /**
96
133
  * Build the daemon's capability profile from its config + runtime env.
package/dist/daemon.js CHANGED
@@ -82,6 +82,8 @@ const orphans_1 = require("./reconcile/orphans");
82
82
  const agent_types_1 = require("./ensemble/agent-types");
83
83
  const pre_flight_1 = require("./adapters/claude-code-headless/pre-flight");
84
84
  const daemon_adapter_versions_1 = require("./daemon-adapter-versions");
85
+ const sdk_probe_1 = require("./utils/sdk-probe");
86
+ const probe_1 = require("./pi/probe");
85
87
  const log = (...args) => console.error(`[agent-tempo:daemon ${new Date().toISOString()}]`, ...args);
86
88
  /**
87
89
  * Daemon process start time, captured at module load. Issue #399 Q5.3b
@@ -304,6 +306,16 @@ function daemonVersion() {
304
306
  */
305
307
  function computeHostProfile(config, deps = {}) {
306
308
  const resolveCopilotSync = deps.resolveCopilotSdkVersionSync ?? daemon_adapter_versions_1.resolveCopilotSdkVersionSync;
309
+ // #819 — production defaults for new optional-adapter probes. Each default
310
+ // is a one-liner wrapping the real probe; tests inject stubs.
311
+ const probePiSyncFn = deps.probePiSync ??
312
+ (() => (0, probe_1.checkPiNodeFloor)().ok && (0, sdk_probe_1.probeSdkInstall)(probe_1.PI_PACKAGE)
313
+ ? { available: true }
314
+ : { available: false });
315
+ const probeOpencodeSyncFn = deps.probeOpencodeSync ??
316
+ (() => (0, sdk_probe_1.probeSdkInstall)('@opencode-ai/sdk') && (0, sdk_probe_1.hasOpencodeCliOnPath)());
317
+ const probeClaudeApiSyncFn = deps.probeClaudeApiSync ??
318
+ (() => (0, sdk_probe_1.probeSdkInstall)('@anthropic-ai/sdk') && !!process.env.ANTHROPIC_API_KEY);
307
319
  const agentTypes = (() => {
308
320
  try {
309
321
  return (0, agent_types_1.listAgentTypes)().map((a) => a.name);
@@ -353,18 +365,52 @@ function computeHostProfile(config, deps = {}) {
353
365
  // require failures, but the boot path must not crash on any
354
366
  // surprise here.
355
367
  }
368
+ // #819 — pi probe. Delegates to `probePiSyncFn` (injected for tests;
369
+ // production default checks Node ≥ 22.19 floor + SDK install). Mirrors
370
+ // the recruit pre-flight: both conditions must pass before advertising.
371
+ try {
372
+ if (probePiSyncFn().available && !availableAgentTypes.includes('pi')) {
373
+ availableAgentTypes.push('pi');
374
+ }
375
+ }
376
+ catch {
377
+ // Boot path must not crash on any probe failure.
378
+ }
379
+ // #819 — opencode probe. Delegates to `probeOpencodeSyncFn` (injected for
380
+ // tests; production default checks SDK install + `opencode` binary on PATH).
381
+ try {
382
+ if (probeOpencodeSyncFn() && !availableAgentTypes.includes('opencode')) {
383
+ availableAgentTypes.push('opencode');
384
+ }
385
+ }
386
+ catch {
387
+ // Boot path must not crash on any probe failure.
388
+ }
389
+ // #819 — claude-api probe. Delegates to `probeClaudeApiSyncFn` (injected
390
+ // for tests; production default checks SDK install + ANTHROPIC_API_KEY in
391
+ // daemon env). The key check is not a conservative heuristic — the daemon
392
+ // env IS the deliverability predicate (adapter inherits daemon env; recruit
393
+ // does not plumb a key from the caller). See ComputeHostProfileDeps for the
394
+ // full rationale.
395
+ try {
396
+ if (probeClaudeApiSyncFn() && !availableAgentTypes.includes('claude-api')) {
397
+ availableAgentTypes.push('claude-api');
398
+ }
399
+ }
400
+ catch {
401
+ // Boot path must not crash on any probe failure.
402
+ }
356
403
  return {
357
404
  hostname: os.hostname(),
358
405
  version: daemonVersion(),
359
406
  defaultAgent: config.defaultAgent,
360
- // #520 + #532 PR-2 — was: `[config.defaultAgent]`. Now grows when
361
- // the optional probes pass: `claude-code-headless` (when `claude`
362
- // is on PATH AND logged in), `copilot` (when `@github/copilot-sdk`
363
- // is installed). Future PRs can extend the same pattern for
364
- // `claude-api` (probe `@anthropic-ai/sdk` install +
365
- // ANTHROPIC_API_KEY env) and `opencode` (probe `@opencode-ai/sdk`
366
- // install + `opencode` binary on PATH). Recording as an array
367
- // keeps the wire shape forward-compatible.
407
+ // #520 + #532 PR-2 + #819 — was: `[config.defaultAgent]`. Now grows when
408
+ // the optional probes pass: `claude-code-headless` (when `claude` is on
409
+ // PATH AND logged in), `copilot` (when `@github/copilot-sdk` is installed),
410
+ // `pi` (when `@earendil-works/pi-coding-agent` installed + Node 22.19),
411
+ // `opencode` (when `@opencode-ai/sdk` installed + `opencode` on PATH),
412
+ // `claude-api` (when `@anthropic-ai/sdk` installed + ANTHROPIC_API_KEY in
413
+ // daemon env). Recording as an array keeps the wire shape forward-compatible.
368
414
  availableAgentTypes,
369
415
  availablePlayerTypes: agentTypes,
370
416
  claudeBin: config.claudeBin,
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Daemon-side cue-deliverability preflight (#822).
3
+ *
4
+ * The maestro-outbox write endpoints (`POST .../cue`, `.../ask`, `.../reset`,
5
+ * and the board `/handoff` which routes through `.../cue`) historically enqueued
6
+ * to ANY target phase and returned a bare `202 { ok: true }` — so the
7
+ * command-center board rendered a confident `✓` even when the target had no live
8
+ * adapter (phase `detached`/`gone`) and the message merely queued on the
9
+ * workflow inbox, undelivered until re-attach. The MCP `cue` tool already guards
10
+ * this (`UNDELIVERABLE_PHASES` in `src/tools/cue.ts`) but it runs in-process
11
+ * before the Temporal write; the HTTP surface skipped the preflight entirely.
12
+ *
13
+ * This helper mirrors that phase check for the HTTP write surface, with one
14
+ * deliberate product divergence (design note Part 2, Ruling A): the operator
15
+ * board is **warn-but-queue**, NOT hard-fail like the MCP tool. The MCP tool
16
+ * serves an autonomous LLM that should re-route; the board serves a human
17
+ * operator who wants the message to durably land and just needs to KNOW it's
18
+ * queued, not delivered. So the endpoint keeps the enqueue and returns the
19
+ * target's deliverability so the board can render `⚠ queued (detached — delivers
20
+ * on re-attach)` instead of `✓`.
21
+ *
22
+ * Soft-failing: the preflight is bounded (1s) and never blocks the enqueue. On
23
+ * timeout / query error / unresolvable target it reports `'live'` (best-effort —
24
+ * the same posture as the MCP tool's preflight catch).
25
+ */
26
+ import type { TempoClient } from '../client/interface';
27
+ import type { AttachmentPhase } from '../types';
28
+ /**
29
+ * Phases where the target has no live adapter, so a cue/ask/reset queues on the
30
+ * workflow inbox undelivered (auto-redelivers on re-attach). Mirrors
31
+ * `UNDELIVERABLE_PHASES` in `src/tools/cue.ts` — `draining` is deliberately
32
+ * EXCLUDED (a brief mid-shutdown state whose adapter is still consuming pending
33
+ * messages; flagging it would false-positive on normal teardown).
34
+ */
35
+ export declare const UNDELIVERABLE_PHASES: ReadonlySet<AttachmentPhase>;
36
+ /** Bounded preflight window — matches the MCP cue tool's 1s operator-interactive cap. */
37
+ export declare const DELIVERABILITY_PREFLIGHT_TIMEOUT_MS = 1000;
38
+ /** `'queued'` = enqueued but no live adapter to receive now; `'live'` = a live
39
+ * adapter is (or is best-effort assumed) present. */
40
+ export type Delivery = 'queued' | 'live';
41
+ export interface DeliverabilityResult {
42
+ delivery: Delivery;
43
+ /** The observed phase, when the preflight query succeeded. */
44
+ phase?: AttachmentPhase;
45
+ }
46
+ /**
47
+ * Bounded, soft-failing phase preflight. Returns `{ delivery: 'queued', phase }`
48
+ * ONLY when an undeliverable phase is positively observed; on timeout / query
49
+ * error / unresolvable target it returns `{ delivery: 'live' }` (best-effort —
50
+ * never block the enqueue). Pure of HTTP concerns so `writes.ts` and `qa.ts`
51
+ * share one implementation.
52
+ */
53
+ export declare function checkDeliverability(client: TempoClient, ensemble: string, playerId: string): Promise<DeliverabilityResult>;
54
+ /**
55
+ * Build the additive deliverability fields folded into the `202` JSON body of an
56
+ * outbox-enqueue endpoint. Superset shape satisfying both the design note
57
+ * (`delivery`/`phase`) and the QA regression bar (`queued`/`warning`):
58
+ *
59
+ * - undeliverable → `{ delivery:'queued', phase, queued:true, warning:'queued-undeliverable (<phase>)' }`
60
+ * - live → `{ delivery:'live', queued:false }` (NO `warning` field — a healthy
61
+ * target must read as a clean success)
62
+ */
63
+ export declare function deliverabilityResponseFields(r: DeliverabilityResult): {
64
+ delivery: Delivery;
65
+ queued: boolean;
66
+ phase?: AttachmentPhase;
67
+ warning?: string;
68
+ };
@@ -0,0 +1,78 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DELIVERABILITY_PREFLIGHT_TIMEOUT_MS = exports.UNDELIVERABLE_PHASES = void 0;
4
+ exports.checkDeliverability = checkDeliverability;
5
+ exports.deliverabilityResponseFields = deliverabilityResponseFields;
6
+ /**
7
+ * Phases where the target has no live adapter, so a cue/ask/reset queues on the
8
+ * workflow inbox undelivered (auto-redelivers on re-attach). Mirrors
9
+ * `UNDELIVERABLE_PHASES` in `src/tools/cue.ts` — `draining` is deliberately
10
+ * EXCLUDED (a brief mid-shutdown state whose adapter is still consuming pending
11
+ * messages; flagging it would false-positive on normal teardown).
12
+ */
13
+ exports.UNDELIVERABLE_PHASES = new Set(['detached', 'gone']);
14
+ /** Bounded preflight window — matches the MCP cue tool's 1s operator-interactive cap. */
15
+ exports.DELIVERABILITY_PREFLIGHT_TIMEOUT_MS = 1000;
16
+ /** Resolve `p`, or reject with a timeout after `ms`. */
17
+ function withTimeout(p, ms) {
18
+ return new Promise((resolve, reject) => {
19
+ const timer = setTimeout(() => reject(new Error('deliverability preflight timed out')), ms);
20
+ if (typeof timer.unref === 'function') {
21
+ timer.unref();
22
+ }
23
+ p.then((v) => {
24
+ clearTimeout(timer);
25
+ resolve(v);
26
+ }, (err) => {
27
+ clearTimeout(timer);
28
+ reject(err);
29
+ });
30
+ });
31
+ }
32
+ /**
33
+ * Bounded, soft-failing phase preflight. Returns `{ delivery: 'queued', phase }`
34
+ * ONLY when an undeliverable phase is positively observed; on timeout / query
35
+ * error / unresolvable target it returns `{ delivery: 'live' }` (best-effort —
36
+ * never block the enqueue). Pure of HTTP concerns so `writes.ts` and `qa.ts`
37
+ * share one implementation.
38
+ */
39
+ async function checkDeliverability(client, ensemble, playerId) {
40
+ try {
41
+ const info = await withTimeout(client.attachmentInfo(ensemble, playerId), exports.DELIVERABILITY_PREFLIGHT_TIMEOUT_MS);
42
+ const phase = info?.phase;
43
+ if (phase !== undefined && exports.UNDELIVERABLE_PHASES.has(phase)) {
44
+ return { delivery: 'queued', phase };
45
+ }
46
+ return { delivery: 'live', ...(phase !== undefined ? { phase } : {}) };
47
+ }
48
+ catch {
49
+ // Timed out / query threw / no session — proceed best-effort (the message
50
+ // still enqueues; auto-redelivery on re-attach applies). Matches the MCP
51
+ // cue tool's soft-fail-to-best-effort posture.
52
+ return { delivery: 'live' };
53
+ }
54
+ }
55
+ /**
56
+ * Build the additive deliverability fields folded into the `202` JSON body of an
57
+ * outbox-enqueue endpoint. Superset shape satisfying both the design note
58
+ * (`delivery`/`phase`) and the QA regression bar (`queued`/`warning`):
59
+ *
60
+ * - undeliverable → `{ delivery:'queued', phase, queued:true, warning:'queued-undeliverable (<phase>)' }`
61
+ * - live → `{ delivery:'live', queued:false }` (NO `warning` field — a healthy
62
+ * target must read as a clean success)
63
+ */
64
+ function deliverabilityResponseFields(r) {
65
+ if (r.delivery === 'queued') {
66
+ return {
67
+ delivery: 'queued',
68
+ queued: true,
69
+ ...(r.phase !== undefined ? { phase: r.phase } : {}),
70
+ warning: `queued-undeliverable${r.phase !== undefined ? ` (${r.phase})` : ''}`,
71
+ };
72
+ }
73
+ return {
74
+ delivery: 'live',
75
+ queued: false,
76
+ ...(r.phase !== undefined ? { phase: r.phase } : {}),
77
+ };
78
+ }