agent-tempo 1.7.0-beta.8 → 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 (118) hide show
  1. package/CLAUDE.md +29 -6
  2. package/README.md +2 -1
  3. package/dashboard/package.json +1 -1
  4. package/dist/activities/maestro.d.ts +46 -1
  5. package/dist/activities/maestro.js +69 -20
  6. package/dist/activities/outbox.d.ts +14 -1
  7. package/dist/activities/outbox.js +71 -10
  8. package/dist/activities/resolve.d.ts +118 -17
  9. package/dist/activities/resolve.js +256 -28
  10. package/dist/activities/schedule-fire.js +5 -2
  11. package/dist/adapters/base.d.ts +2 -0
  12. package/dist/adapters/base.js +9 -0
  13. package/dist/adapters/claude-api/adapter.js +26 -4
  14. package/dist/adapters/claude-code/adapter.js +15 -9
  15. package/dist/adapters/claude-code-headless/adapter.js +22 -4
  16. package/dist/adapters/copilot/adapter.js +58 -8
  17. package/dist/adapters/opencode/adapter.js +26 -6
  18. package/dist/adapters/sdk/base.d.ts +60 -0
  19. package/dist/adapters/sdk/base.js +83 -0
  20. package/dist/adapters/sdk/doorbell-client.d.ts +106 -0
  21. package/dist/adapters/sdk/doorbell-client.js +261 -0
  22. package/dist/adapters/sdk/idle-backoff.d.ts +71 -0
  23. package/dist/adapters/sdk/idle-backoff.js +136 -0
  24. package/dist/cli/command-center-command.js +25 -2
  25. package/dist/cli/commands.d.ts +28 -0
  26. package/dist/cli/commands.js +111 -20
  27. package/dist/cli/config-command.js +14 -0
  28. package/dist/cli/daemon-command.js +41 -5
  29. package/dist/cli/daemon.d.ts +245 -5
  30. package/dist/cli/daemon.js +505 -44
  31. package/dist/cli/help-text.js +3 -1
  32. package/dist/cli/mcp.d.ts +26 -2
  33. package/dist/cli/mcp.js +33 -4
  34. package/dist/cli/sa-preflight.d.ts +27 -1
  35. package/dist/cli/sa-preflight.js +29 -5
  36. package/dist/cli/startup.js +8 -2
  37. package/dist/cli/upgrade-to-2-command.d.ts +10 -0
  38. package/dist/cli/upgrade-to-2-command.js +159 -0
  39. package/dist/cli.js +37 -5
  40. package/dist/client/core.d.ts +6 -0
  41. package/dist/client/core.js +81 -34
  42. package/dist/client/subscribe.d.ts +10 -0
  43. package/dist/client/subscribe.js +2 -0
  44. package/dist/config.d.ts +53 -0
  45. package/dist/config.js +70 -1
  46. package/dist/daemon.d.ts +78 -7
  47. package/dist/daemon.js +313 -29
  48. package/dist/ensemble/saver.d.ts +10 -0
  49. package/dist/ensemble/saver.js +18 -6
  50. package/dist/http/aggregate.d.ts +88 -1
  51. package/dist/http/aggregate.js +157 -13
  52. package/dist/http/deliverability.d.ts +68 -0
  53. package/dist/http/deliverability.js +78 -0
  54. package/dist/http/doorbell-routes.d.ts +32 -0
  55. package/dist/http/doorbell-routes.js +72 -0
  56. package/dist/http/doorbell.d.ts +45 -0
  57. package/dist/http/doorbell.js +162 -0
  58. package/dist/http/inner-loop-routes.d.ts +9 -0
  59. package/dist/http/inner-loop-routes.js +4 -0
  60. package/dist/http/qa.js +8 -1
  61. package/dist/http/server.d.ts +9 -0
  62. package/dist/http/server.js +45 -1
  63. package/dist/http/snapshot.d.ts +28 -5
  64. package/dist/http/snapshot.js +16 -6
  65. package/dist/http/writes.js +24 -2
  66. package/dist/pi/cue-pump.d.ts +107 -7
  67. package/dist/pi/cue-pump.js +131 -24
  68. package/dist/pi/extension.js +11 -0
  69. package/dist/pi/headless.js +49 -13
  70. package/dist/pi/install.d.ts +15 -0
  71. package/dist/pi/install.js +35 -0
  72. package/dist/pi/mission-control/actions.d.ts +52 -3
  73. package/dist/pi/mission-control/actions.js +112 -3
  74. package/dist/pi/mission-control/board.d.ts +104 -0
  75. package/dist/pi/mission-control/board.js +103 -2
  76. package/dist/pi/mission-control/extension.d.ts +166 -1
  77. package/dist/pi/mission-control/extension.js +477 -15
  78. package/dist/pi/mission-control/render.d.ts +25 -0
  79. package/dist/pi/mission-control/render.js +142 -8
  80. package/dist/pi/workflow-client.d.ts +29 -0
  81. package/dist/pi/workflow-client.js +87 -3
  82. package/dist/reconcile/orphans.js +8 -9
  83. package/dist/server-tools.js +1 -1
  84. package/dist/server.js +11 -1
  85. package/dist/spawn.d.ts +78 -26
  86. package/dist/spawn.js +69 -37
  87. package/dist/tools/broadcast.js +15 -1
  88. package/dist/tools/cue.js +52 -11
  89. package/dist/tools/ensemble.js +43 -3
  90. package/dist/tools/recruit.js +11 -29
  91. package/dist/tools/who-am-i.d.ts +3 -2
  92. package/dist/tools/who-am-i.js +9 -2
  93. package/dist/types.d.ts +42 -0
  94. package/dist/upgrade/phase-engine.d.ts +160 -0
  95. package/dist/upgrade/phase-engine.js +555 -0
  96. package/dist/upgrade/snapshot-v1.d.ts +214 -0
  97. package/dist/upgrade/snapshot-v1.js +165 -0
  98. package/dist/utils/action-counters.d.ts +75 -0
  99. package/dist/utils/action-counters.js +279 -0
  100. package/dist/utils/format-hosts.js +7 -0
  101. package/dist/utils/hosts.js +5 -0
  102. package/dist/utils/sdk-probe.d.ts +12 -0
  103. package/dist/utils/sdk-probe.js +28 -0
  104. package/dist/utils/search-attributes.d.ts +76 -4
  105. package/dist/utils/search-attributes.js +89 -4
  106. package/dist/utils/suspension.d.ts +99 -0
  107. package/dist/utils/suspension.js +128 -0
  108. package/dist/utils/visibility-deadline.js +5 -0
  109. package/dist/worker.d.ts +16 -1
  110. package/dist/worker.js +25 -4
  111. package/dist/workflows/maestro.d.ts +10 -0
  112. package/dist/workflows/maestro.js +126 -21
  113. package/dist/workflows/scheduler.js +17 -2
  114. package/dist/workflows/session.js +251 -14
  115. package/dist/workflows/signals.d.ts +8 -0
  116. package/dist/workflows/signals.js +3 -2
  117. package/package.json +8 -4
  118. package/workflow-bundle.js +580 -40
@@ -0,0 +1,106 @@
1
+ /** Reconnect backoff: 1s doubling to a 30s cap (same family as the daemon's bind retry, #768). */
2
+ export declare function doorbellReconnectDelayMs(retry: number, baseMs?: number, capMs?: number): number;
3
+ /**
4
+ * Minimal streaming-fetch shape this client needs — injectable for tests.
5
+ * `body` must be an async-iterable of byte chunks (Node 18+ global `fetch`
6
+ * response bodies are).
7
+ */
8
+ export type DoorbellFetch = (url: string, init: {
9
+ headers: Record<string, string>;
10
+ signal: AbortSignal;
11
+ }) => Promise<{
12
+ status: number;
13
+ body: AsyncIterable<Uint8Array> | null;
14
+ }>;
15
+ export interface DoorbellClientOptions {
16
+ /** The player's ensemble (URL path segment). */
17
+ ensemble: string;
18
+ /** The player's id (URL path segment). */
19
+ playerId: string;
20
+ /** Fired once per `ding` event (already coalesced daemon-side). */
21
+ onDing: () => void;
22
+ /**
23
+ * Fired on connected/disconnected transitions. Drives the idle-poll
24
+ * ceiling (connected ⇒ 60s, disconnected ⇒ the T0.2 30s floor). Never
25
+ * fired redundantly (true→true / false→false).
26
+ */
27
+ onConnectionChange?: (connected: boolean) => void;
28
+ /** Ingest token. Defaults to `process.env[ENV.INGEST_TOKEN]`. */
29
+ ingestToken?: string;
30
+ /** Daemon port discovery. Defaults to {@link readPortFile}. */
31
+ readPort?: () => number | null;
32
+ /** HTTP transport. Defaults to global `fetch` (client disabled if absent). */
33
+ fetchFn?: DoorbellFetch;
34
+ /** Test seam — defaults to a real setTimeout sleep. */
35
+ sleep?: (ms: number) => Promise<void>;
36
+ log?: (...args: unknown[]) => void;
37
+ /** Reconnect backoff knobs (tests). */
38
+ baseDelayMs?: number;
39
+ maxDelayMs?: number;
40
+ }
41
+ /**
42
+ * Incremental SSE block parser for the doorbell stream. Feed raw text
43
+ * chunks; returns the number of `ding` events completed by this chunk.
44
+ * Keepalive comments (`:ka`) and the `:closed` marker carry no event name
45
+ * and are ignored. Exported @internal for unit tests.
46
+ */
47
+ export declare class DingParser {
48
+ private buffer;
49
+ feed(chunk: string): number;
50
+ }
51
+ /**
52
+ * Wakeable sleep — the ding→immediate-poll primitive, shared by
53
+ * `SdkAttachment` (PR-2) and the Pi cue pump (PR-3, which is NOT an
54
+ * SdkAttachment). Semantics:
55
+ *
56
+ * - `sleep(ms)` parks until the timer fires OR `wake()` pops it early.
57
+ * - `wake()` with no parked sleep sets a level-triggered pending bit so
58
+ * the NEXT `sleep` returns immediately — a ding that lands while the
59
+ * consumer is mid-tick is consumed, not lost (mirrors the daemon
60
+ * registry's coalescing; extra wakes coalesce into one).
61
+ * - One parked sleep at a time (one poll loop per consumer). A STALE
62
+ * timer from an abandoned sleep (e.g. a `Promise.race` loser) is
63
+ * identity-guarded so it can never clobber a newer sleep's waker.
64
+ */
65
+ export declare class WakeableSleep {
66
+ private wakeFn;
67
+ private pending;
68
+ sleep(ms: number): Promise<void>;
69
+ wake(): void;
70
+ }
71
+ /**
72
+ * Reconnecting SSE consumer for the daemon doorbell. `start()` launches the
73
+ * detached connect loop; `stop()` aborts the live stream and ends it. All
74
+ * callbacks fire on the client's own async path — keep them cheap (the
75
+ * adapters' `onDing` just wakes a sleeping poll loop).
76
+ */
77
+ export declare class DoorbellClient {
78
+ private readonly opts;
79
+ private readonly token;
80
+ private readonly readPort;
81
+ private readonly fetchFn;
82
+ private readonly sleep;
83
+ private readonly log;
84
+ private stopped;
85
+ private connected;
86
+ private abort;
87
+ private loop;
88
+ constructor(opts: DoorbellClientOptions);
89
+ /** Whether a doorbell stream is currently live (drives the poll ceiling). */
90
+ get isConnected(): boolean;
91
+ /**
92
+ * Launch the connect loop. No token / no fetch ⇒ logs one breadcrumb and
93
+ * never subscribes (§5: pure T0.2 behavior). Idempotent.
94
+ */
95
+ start(): void;
96
+ /** End the loop and abort any live stream. Idempotent. */
97
+ stop(): void;
98
+ private setConnected;
99
+ private runLoop;
100
+ /**
101
+ * One subscribe attempt. Returns `true` when the stream OPENED (HTTP 200)
102
+ * regardless of how it ended. Every failure path is silent-by-design
103
+ * below the transition breadcrumb.
104
+ */
105
+ private connectOnce;
106
+ }
@@ -0,0 +1,261 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DoorbellClient = exports.WakeableSleep = exports.DingParser = void 0;
4
+ exports.doorbellReconnectDelayMs = doorbellReconnectDelayMs;
5
+ /**
6
+ * DoorbellClient — adapter-side consumer of the daemon's cue doorbell
7
+ * (T1.1 PR-2, design: docs/design/t11-cue-doorbell.md §2.3/§2.4).
8
+ *
9
+ * Subscribes to `GET /doorbell/:ensemble/:playerId` (SSE; loopback +
10
+ * `X-Ingest-Token`) and fires `onDing` per coalesced ring. The §1 invariant
11
+ * governs everything here: **doorbell loss must be indistinguishable from
12
+ * doorbell-never-sent** —
13
+ *
14
+ * - A ding is a HINT, not a guarantee: the daemon's pending bit is
15
+ * level-triggered and coalesced, so a ding can arrive for a message the
16
+ * poll loop already fetched (one wasted query, §5 row 2) and a ring can
17
+ * be lost entirely (fallback poll delivers within the ceiling, §5 row 1).
18
+ * - EVERY failure (no token, port file missing, route 404/403/500, daemon
19
+ * down, stream drop) degrades to silent disconnected state + reconnect
20
+ * with capped backoff. NO error escalation, ever — a player must behave
21
+ * identically whether the doorbell never existed or just died (§5).
22
+ * - Connection-state transitions emit exactly one `[agent-tempo:doorbell]`
23
+ * breadcrumb each (the #249 observability lesson) — that is the only
24
+ * above-debug logging in this file.
25
+ *
26
+ * Discovery/auth follow the locked inner-loop ingress contract
27
+ * (`src/pi/inner-loop-client.ts`): port via {@link readPortFile} (`null` ⇒
28
+ * daemon HTTP not up), loopback base `http://127.0.0.1:{port}`, token from
29
+ * `AGENT_TEMPO_INGEST_TOKEN` (threaded at spawn; absent ⇒ never subscribes ⇒
30
+ * pure T0.2 behavior).
31
+ *
32
+ * **This file runs in the Node.js adapter process, NOT the Temporal workflow
33
+ * sandbox.**
34
+ */
35
+ const config_1 = require("../../config");
36
+ const port_file_1 = require("../../http/port-file");
37
+ const defaultLog = (...args) => console.error('[agent-tempo:doorbell]', ...args);
38
+ /** Reconnect backoff: 1s doubling to a 30s cap (same family as the daemon's bind retry, #768). */
39
+ function doorbellReconnectDelayMs(retry, baseMs = 1_000, capMs = 30_000) {
40
+ return Math.min(capMs, baseMs * 2 ** Math.min(retry, 31));
41
+ }
42
+ /** Default transport — global `fetch` adapted to {@link DoorbellFetch}, or `null`. */
43
+ function resolveFetch() {
44
+ const g = globalThis.fetch;
45
+ if (typeof g !== 'function')
46
+ return null;
47
+ return g;
48
+ }
49
+ /**
50
+ * Cap on the partial-block buffer. Real doorbell blocks are tens of bytes;
51
+ * the daemon is trusted + loopback, so this is a cheap backstop against a
52
+ * pathological stream without `\n\n` terminators growing memory unboundedly
53
+ * — overflow just drops the buffer (a lost ding ≡ never-sent, §1).
54
+ */
55
+ const MAX_PARSER_BUFFER = 8 * 1024;
56
+ /**
57
+ * Incremental SSE block parser for the doorbell stream. Feed raw text
58
+ * chunks; returns the number of `ding` events completed by this chunk.
59
+ * Keepalive comments (`:ka`) and the `:closed` marker carry no event name
60
+ * and are ignored. Exported @internal for unit tests.
61
+ */
62
+ class DingParser {
63
+ buffer = '';
64
+ feed(chunk) {
65
+ this.buffer += chunk;
66
+ let dings = 0;
67
+ // SSE events are separated by a blank line. Process every complete
68
+ // block; keep the trailing partial in the buffer.
69
+ let sep = this.buffer.indexOf('\n\n');
70
+ while (sep !== -1) {
71
+ const block = this.buffer.slice(0, sep);
72
+ this.buffer = this.buffer.slice(sep + 2);
73
+ if (/^event:\s*ding\s*$/m.test(block))
74
+ dings++;
75
+ sep = this.buffer.indexOf('\n\n');
76
+ }
77
+ if (this.buffer.length > MAX_PARSER_BUFFER)
78
+ this.buffer = '';
79
+ return dings;
80
+ }
81
+ }
82
+ exports.DingParser = DingParser;
83
+ /**
84
+ * Wakeable sleep — the ding→immediate-poll primitive, shared by
85
+ * `SdkAttachment` (PR-2) and the Pi cue pump (PR-3, which is NOT an
86
+ * SdkAttachment). Semantics:
87
+ *
88
+ * - `sleep(ms)` parks until the timer fires OR `wake()` pops it early.
89
+ * - `wake()` with no parked sleep sets a level-triggered pending bit so
90
+ * the NEXT `sleep` returns immediately — a ding that lands while the
91
+ * consumer is mid-tick is consumed, not lost (mirrors the daemon
92
+ * registry's coalescing; extra wakes coalesce into one).
93
+ * - One parked sleep at a time (one poll loop per consumer). A STALE
94
+ * timer from an abandoned sleep (e.g. a `Promise.race` loser) is
95
+ * identity-guarded so it can never clobber a newer sleep's waker.
96
+ */
97
+ class WakeableSleep {
98
+ wakeFn = null;
99
+ pending = false;
100
+ sleep(ms) {
101
+ if (this.pending) {
102
+ this.pending = false;
103
+ return Promise.resolve();
104
+ }
105
+ return new Promise((resolve) => {
106
+ const wake = () => {
107
+ clearTimeout(timer);
108
+ if (this.wakeFn === wake)
109
+ this.wakeFn = null;
110
+ resolve();
111
+ };
112
+ const timer = setTimeout(() => {
113
+ if (this.wakeFn === wake)
114
+ this.wakeFn = null;
115
+ resolve();
116
+ }, ms);
117
+ timer.unref?.();
118
+ this.wakeFn = wake;
119
+ });
120
+ }
121
+ wake() {
122
+ if (this.wakeFn) {
123
+ this.wakeFn();
124
+ }
125
+ else {
126
+ this.pending = true;
127
+ }
128
+ }
129
+ }
130
+ exports.WakeableSleep = WakeableSleep;
131
+ /**
132
+ * Reconnecting SSE consumer for the daemon doorbell. `start()` launches the
133
+ * detached connect loop; `stop()` aborts the live stream and ends it. All
134
+ * callbacks fire on the client's own async path — keep them cheap (the
135
+ * adapters' `onDing` just wakes a sleeping poll loop).
136
+ */
137
+ class DoorbellClient {
138
+ opts;
139
+ token;
140
+ readPort;
141
+ fetchFn;
142
+ sleep;
143
+ log;
144
+ stopped = false;
145
+ connected = false;
146
+ abort = null;
147
+ loop = null;
148
+ constructor(opts) {
149
+ this.opts = opts;
150
+ this.token = opts.ingestToken ?? process.env[config_1.ENV.INGEST_TOKEN];
151
+ this.readPort = opts.readPort ?? (() => (0, port_file_1.readPortFile)());
152
+ this.fetchFn = opts.fetchFn ?? resolveFetch();
153
+ this.sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms).unref?.()));
154
+ this.log = opts.log ?? defaultLog;
155
+ }
156
+ /** Whether a doorbell stream is currently live (drives the poll ceiling). */
157
+ get isConnected() {
158
+ return this.connected;
159
+ }
160
+ /**
161
+ * Launch the connect loop. No token / no fetch ⇒ logs one breadcrumb and
162
+ * never subscribes (§5: pure T0.2 behavior). Idempotent.
163
+ */
164
+ start() {
165
+ if (this.loop)
166
+ return;
167
+ if (!this.token || !this.fetchFn) {
168
+ this.log(`doorbell disabled for ${this.opts.ensemble}/${this.opts.playerId} ` +
169
+ `(${!this.token ? 'no ingest token' : 'no fetch transport'}) — polling only`);
170
+ return;
171
+ }
172
+ this.loop = this.runLoop().catch((err) => {
173
+ // The loop swallows everything itself — this is a last-resort guard so
174
+ // an unexpected throw can never escalate out of the doorbell (§5).
175
+ this.log('doorbell loop ended unexpectedly (polling covers):', err?.message ?? err);
176
+ });
177
+ }
178
+ /** End the loop and abort any live stream. Idempotent. */
179
+ stop() {
180
+ if (this.stopped)
181
+ return;
182
+ this.stopped = true;
183
+ try {
184
+ this.abort?.abort();
185
+ }
186
+ catch { /* already settled */ }
187
+ this.setConnected(false);
188
+ }
189
+ setConnected(connected) {
190
+ if (this.connected === connected)
191
+ return;
192
+ this.connected = connected;
193
+ this.log(`doorbell ${connected ? 'connected' : 'disconnected'} ` +
194
+ `(${this.opts.ensemble}/${this.opts.playerId})`);
195
+ try {
196
+ this.opts.onConnectionChange?.(connected);
197
+ }
198
+ catch (err) {
199
+ this.log('onConnectionChange threw (ignored):', err?.message ?? err);
200
+ }
201
+ }
202
+ async runLoop() {
203
+ for (let retry = 0; !this.stopped; retry++) {
204
+ const streamed = await this.connectOnce();
205
+ if (this.stopped)
206
+ return;
207
+ // A stream that delivered ANY bytes proves the daemon was reachable —
208
+ // reset the reconnect curve so a healthy-but-bounced daemon comes back
209
+ // at 1s, not wherever a prior outage left the counter.
210
+ if (streamed)
211
+ retry = 0;
212
+ await this.sleep(doorbellReconnectDelayMs(retry, this.opts.baseDelayMs, this.opts.maxDelayMs));
213
+ }
214
+ }
215
+ /**
216
+ * One subscribe attempt. Returns `true` when the stream OPENED (HTTP 200)
217
+ * regardless of how it ended. Every failure path is silent-by-design
218
+ * below the transition breadcrumb.
219
+ */
220
+ async connectOnce() {
221
+ const port = this.readPort();
222
+ if (port === null)
223
+ return false; // daemon HTTP not up — §5 "daemon down" row
224
+ const url = `http://127.0.0.1:${port}/doorbell/${encodeURIComponent(this.opts.ensemble)}/${encodeURIComponent(this.opts.playerId)}`;
225
+ this.abort = new AbortController();
226
+ try {
227
+ const res = await this.fetchFn(url, {
228
+ headers: { 'x-ingest-token': this.token },
229
+ signal: this.abort.signal,
230
+ });
231
+ if (res.status !== 200 || !res.body)
232
+ return false; // 403/404/5xx — §5 "route" rows
233
+ this.setConnected(true);
234
+ const parser = new DingParser();
235
+ const decoder = new TextDecoder();
236
+ for await (const chunk of res.body) {
237
+ if (this.stopped)
238
+ break;
239
+ const dings = parser.feed(decoder.decode(chunk, { stream: true }));
240
+ for (let i = 0; i < dings; i++) {
241
+ try {
242
+ this.opts.onDing();
243
+ }
244
+ catch (err) {
245
+ this.log('onDing threw (ignored):', err?.message ?? err);
246
+ }
247
+ }
248
+ }
249
+ return true;
250
+ }
251
+ catch {
252
+ // Connect refused / aborted / read error — disconnected state covers it.
253
+ return false;
254
+ }
255
+ finally {
256
+ this.abort = null;
257
+ this.setConnected(false);
258
+ }
259
+ }
260
+ }
261
+ exports.DoorbellClient = DoorbellClient;
@@ -0,0 +1,71 @@
1
+ /** Fast-poll cadence while a conversation is active (legacy behavior). */
2
+ export declare const SDK_POLL_BASE_MS = 2000;
3
+ /** Growth factor per consecutive empty poll. Matches the interactive
4
+ * claude-code poller's error-backoff factor (adapter.ts) — one family of
5
+ * curves across the codebase. */
6
+ export declare const SDK_POLL_BACKOFF_FACTOR = 1.5;
7
+ /** Idle ceiling. 15× the base — the steady-state idle cadence. */
8
+ export declare const SDK_POLL_MAX_MS = 30000;
9
+ /**
10
+ * T1.1 PR-2 — idle ceiling while the cue doorbell is CONNECTED. Latency is
11
+ * the doorbell's job; the 60s poll is reconciliation AND the only bound on
12
+ * worst-case delivery after a lost ding (§2.4 of the design doc — at-most-
13
+ * once, no replay), so it stays operator-tolerable rather than stretching
14
+ * to minutes. Disconnected ⇒ the T0.2 30s ceiling above (today's shipped
15
+ * behavior is the floor).
16
+ */
17
+ export declare const SDK_POLL_DOORBELL_MAX_MS = 60000;
18
+ export interface IdleBackoffConfig {
19
+ baseMs: number;
20
+ factor: number;
21
+ maxMs: number;
22
+ }
23
+ /**
24
+ * Resolve the poll-backoff config, honoring the `AGENT_TEMPO_SDK_POLL_BASE_MS`
25
+ * / `AGENT_TEMPO_SDK_POLL_MAX_MS` env overrides (read at call time, not module
26
+ * load, so tests and spawners can set them per-process). Setting
27
+ * `..._MAX_MS` equal to `..._BASE_MS` pins the legacy fixed cadence —
28
+ * the dev/test escape hatch.
29
+ */
30
+ export declare function resolveIdleBackoffConfig(): IdleBackoffConfig;
31
+ /**
32
+ * T1.1 PR-2 — the ceiling to apply while the doorbell is connected
33
+ * (`AGENT_TEMPO_SDK_POLL_DOORBELL_MAX_MS` override; clamped ≥ the resolved
34
+ * base so a misconfigured override can never invert base/max).
35
+ */
36
+ export declare function resolveDoorbellCeilingMs(): number;
37
+ /**
38
+ * Pure delay computer for an idle-backoff poll loop.
39
+ *
40
+ * Usage per tick:
41
+ *
42
+ * ```ts
43
+ * const delay = backoff.next(messages.length > 0);
44
+ * await sleep(delay);
45
+ * ```
46
+ *
47
+ * - `next(true)` → conversation is live: reset to `baseMs` and return it.
48
+ * - `next(false)` → idle tick: grow the delay by `factor` (capped at `maxMs`)
49
+ * and return the grown value. Matches the reference
50
+ * poller's grow-then-sleep shape.
51
+ * - `reset()` → external "activity happened" signal (e.g. an SDK turn
52
+ * just finished and follow-ups are likely).
53
+ */
54
+ export declare class IdleBackoff {
55
+ private readonly cfg;
56
+ private currentMs;
57
+ constructor(cfg?: IdleBackoffConfig);
58
+ /**
59
+ * T1.1 PR-2 — swap the idle ceiling (doorbell connected ⇒ 60s,
60
+ * disconnected ⇒ the 30s T0.2 floor). Base/factor/reset semantics are
61
+ * untouched; a `currentMs` above a LOWERED ceiling self-heals on the next
62
+ * `next()` (Math.min clamps it). Clamped ≥ base so the curve can't invert.
63
+ */
64
+ setCeiling(maxMs: number): void;
65
+ /** The delay the next idle sleep would use (read-only; for logs/tests). */
66
+ get current(): number;
67
+ /** Compute the next poll delay from this tick's outcome. */
68
+ next(hadActivity: boolean): number;
69
+ /** Snap back to the fast cadence. */
70
+ reset(): void;
71
+ }
@@ -0,0 +1,136 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.IdleBackoff = exports.SDK_POLL_DOORBELL_MAX_MS = exports.SDK_POLL_MAX_MS = exports.SDK_POLL_BACKOFF_FACTOR = exports.SDK_POLL_BASE_MS = void 0;
4
+ exports.resolveIdleBackoffConfig = resolveIdleBackoffConfig;
5
+ exports.resolveDoorbellCeilingMs = resolveDoorbellCeilingMs;
6
+ /**
7
+ * Idle poll backoff (#749, T0.2 of the #747 Tier-0 cost epic).
8
+ *
9
+ * Every SDK-class adapter (copilot, claude-api, opencode,
10
+ * claude-code-headless, mock) drives a `pendingMessages` poll loop against
11
+ * its session workflow. Pre-#749 all five polled at a fixed 2s cadence —
12
+ * 43,200 billable queries/day/player even when the player sat idle
13
+ * overnight. Temporal Cloud bills queries as actions (1 each), so idle
14
+ * polling was the dominant per-player line item
15
+ * (docs/design/temporal-cost-rearchitecture.md, driver #3).
16
+ *
17
+ * This helper implements true IDLE backoff: each empty poll stretches the
18
+ * next delay by {@link SDK_POLL_BACKOFF_FACTOR} up to {@link SDK_POLL_MAX_MS};
19
+ * any delivered message snaps back to {@link SDK_POLL_BASE_MS} so an active
20
+ * conversation keeps the legacy 2s responsiveness. Steady-state idle lands
21
+ * at one query per 30s = 2,880/day — a 15× reduction (43,200 ÷ 2,880;
22
+ * ratio locked by a unit test).
23
+ *
24
+ * Trade-off (accepted in #749): the FIRST cue to a long-idle player waits up
25
+ * to 30s before the adapter notices. Delivery itself is unchanged — the
26
+ * workflow inbox is durable and the next poll drains the full batch. The
27
+ * T1.1 doorbell (push delivery) later removes even that latency.
28
+ *
29
+ * Pure and timer-free — callers own the `setTimeout`/`sleep`; this class
30
+ * only computes delays. That keeps it unit-testable without fake timers.
31
+ *
32
+ * **This file runs in the Node.js adapter process, NOT the Temporal workflow
33
+ * sandbox.**
34
+ */
35
+ const config_1 = require("../../config");
36
+ /** Fast-poll cadence while a conversation is active (legacy behavior). */
37
+ exports.SDK_POLL_BASE_MS = 2_000;
38
+ /** Growth factor per consecutive empty poll. Matches the interactive
39
+ * claude-code poller's error-backoff factor (adapter.ts) — one family of
40
+ * curves across the codebase. */
41
+ exports.SDK_POLL_BACKOFF_FACTOR = 1.5;
42
+ /** Idle ceiling. 15× the base — the steady-state idle cadence. */
43
+ exports.SDK_POLL_MAX_MS = 30_000;
44
+ /**
45
+ * T1.1 PR-2 — idle ceiling while the cue doorbell is CONNECTED. Latency is
46
+ * the doorbell's job; the 60s poll is reconciliation AND the only bound on
47
+ * worst-case delivery after a lost ding (§2.4 of the design doc — at-most-
48
+ * once, no replay), so it stays operator-tolerable rather than stretching
49
+ * to minutes. Disconnected ⇒ the T0.2 30s ceiling above (today's shipped
50
+ * behavior is the floor).
51
+ */
52
+ exports.SDK_POLL_DOORBELL_MAX_MS = 60_000;
53
+ /** Parse a positive-integer env override; fall back on absent/garbage. */
54
+ function envInt(name, fallback) {
55
+ const raw = process.env[name];
56
+ if (raw === undefined || raw === '')
57
+ return fallback;
58
+ const n = Number.parseInt(raw, 10);
59
+ return Number.isFinite(n) && n > 0 ? n : fallback;
60
+ }
61
+ /**
62
+ * Resolve the poll-backoff config, honoring the `AGENT_TEMPO_SDK_POLL_BASE_MS`
63
+ * / `AGENT_TEMPO_SDK_POLL_MAX_MS` env overrides (read at call time, not module
64
+ * load, so tests and spawners can set them per-process). Setting
65
+ * `..._MAX_MS` equal to `..._BASE_MS` pins the legacy fixed cadence —
66
+ * the dev/test escape hatch.
67
+ */
68
+ function resolveIdleBackoffConfig() {
69
+ const baseMs = envInt(config_1.ENV.SDK_POLL_BASE_MS, exports.SDK_POLL_BASE_MS);
70
+ const maxMs = Math.max(baseMs, envInt(config_1.ENV.SDK_POLL_MAX_MS, exports.SDK_POLL_MAX_MS));
71
+ return { baseMs, factor: exports.SDK_POLL_BACKOFF_FACTOR, maxMs };
72
+ }
73
+ /**
74
+ * T1.1 PR-2 — the ceiling to apply while the doorbell is connected
75
+ * (`AGENT_TEMPO_SDK_POLL_DOORBELL_MAX_MS` override; clamped ≥ the resolved
76
+ * base so a misconfigured override can never invert base/max).
77
+ */
78
+ function resolveDoorbellCeilingMs() {
79
+ const baseMs = envInt(config_1.ENV.SDK_POLL_BASE_MS, exports.SDK_POLL_BASE_MS);
80
+ return Math.max(baseMs, envInt(config_1.ENV.SDK_POLL_DOORBELL_MAX_MS, exports.SDK_POLL_DOORBELL_MAX_MS));
81
+ }
82
+ /**
83
+ * Pure delay computer for an idle-backoff poll loop.
84
+ *
85
+ * Usage per tick:
86
+ *
87
+ * ```ts
88
+ * const delay = backoff.next(messages.length > 0);
89
+ * await sleep(delay);
90
+ * ```
91
+ *
92
+ * - `next(true)` → conversation is live: reset to `baseMs` and return it.
93
+ * - `next(false)` → idle tick: grow the delay by `factor` (capped at `maxMs`)
94
+ * and return the grown value. Matches the reference
95
+ * poller's grow-then-sleep shape.
96
+ * - `reset()` → external "activity happened" signal (e.g. an SDK turn
97
+ * just finished and follow-ups are likely).
98
+ */
99
+ class IdleBackoff {
100
+ cfg;
101
+ currentMs;
102
+ constructor(cfg = resolveIdleBackoffConfig()) {
103
+ // Copy — `setCeiling` mutates maxMs and must never write through to a
104
+ // shared config object a caller handed in.
105
+ this.cfg = { ...cfg };
106
+ this.currentMs = cfg.baseMs;
107
+ }
108
+ /**
109
+ * T1.1 PR-2 — swap the idle ceiling (doorbell connected ⇒ 60s,
110
+ * disconnected ⇒ the 30s T0.2 floor). Base/factor/reset semantics are
111
+ * untouched; a `currentMs` above a LOWERED ceiling self-heals on the next
112
+ * `next()` (Math.min clamps it). Clamped ≥ base so the curve can't invert.
113
+ */
114
+ setCeiling(maxMs) {
115
+ this.cfg.maxMs = Math.max(this.cfg.baseMs, maxMs);
116
+ }
117
+ /** The delay the next idle sleep would use (read-only; for logs/tests). */
118
+ get current() {
119
+ return this.currentMs;
120
+ }
121
+ /** Compute the next poll delay from this tick's outcome. */
122
+ next(hadActivity) {
123
+ if (hadActivity) {
124
+ this.currentMs = this.cfg.baseMs;
125
+ }
126
+ else {
127
+ this.currentMs = Math.min(this.currentMs * this.cfg.factor, this.cfg.maxMs);
128
+ }
129
+ return this.currentMs;
130
+ }
131
+ /** Snap back to the fast cadence. */
132
+ reset() {
133
+ this.currentMs = this.cfg.baseMs;
134
+ }
135
+ }
136
+ exports.IdleBackoff = IdleBackoff;
@@ -54,6 +54,7 @@ exports.commandCenterCommand = commandCenterCommand;
54
54
  const config_1 = require("../config");
55
55
  const spawn_1 = require("../spawn");
56
56
  const probe_1 = require("../pi/probe");
57
+ const install_1 = require("../pi/install");
57
58
  const out = __importStar(require("./output"));
58
59
  /**
59
60
  * Public entry point — invoked by the CLI dispatcher. Launches the board in a
@@ -62,7 +63,12 @@ const out = __importStar(require("./output"));
62
63
  */
63
64
  async function commandCenterCommand(args) {
64
65
  const config = (0, config_1.getConfig)(args);
65
- const ensemble = config.ensemble;
66
+ // #820 (Bug 3) — honor the positional / `--ensemble` flag. `args.ensemble` is
67
+ // resolved by the CLI dispatch via the canonical `resolveEnsemble` (flag >
68
+ // positional > env > 'default'), mirroring `up` post-#685. `config.ensemble`
69
+ // IGNORES `overrides.ensemble` (config.ts hard-codes it from env/default), so
70
+ // reading it dropped the positional and the board silently watched 'default'.
71
+ const ensemble = args.ensemble ?? config.ensemble;
66
72
  // Preflight — fail BEFORE launching a terminal that would die. checkPiNodeFloor
67
73
  // is a best-effort Node-version proxy; buildPiCommandCenterSpawn's default
68
74
  // resolver throws fail-clean when the Pi CLI is missing.
@@ -71,6 +77,24 @@ async function commandCenterCommand(args) {
71
77
  out.error(`Cannot start command-center — ${nodeFloor.reason}`);
72
78
  process.exit(1);
73
79
  }
80
+ // #820 (Bug 2) — extension-registration guard. command-center relies on the
81
+ // GLOBAL Pi extension registration (it deliberately passes NO inline `-e`, unlike
82
+ // `up --agent pi`). On a fresh box that never ran `install-pi`, the extensions are
83
+ // absent from `~/.pi/agent/settings.json`, so a plain `pi` launches with NO board
84
+ // and NO error. Auto-install (idempotent — matches `up --agent pi`'s out-of-box
85
+ // behavior) before spawning; fail loudly with the exact command if the write fails.
86
+ if (!(0, install_1.arePiExtensionsRegistered)()) {
87
+ try {
88
+ const result = (0, install_1.installPiExtensions)();
89
+ out.log(out.dim(` Registered the Pi extensions in ${result.settingsPath} (first-run install-pi).`));
90
+ }
91
+ catch (err) {
92
+ out.error('Cannot start command-center — the Pi extensions are not registered and auto-install failed: ' +
93
+ `${err instanceof Error ? err.message : String(err)}. ` +
94
+ 'Run `agent-tempo install-pi` manually, then retry.');
95
+ process.exit(1);
96
+ }
97
+ }
74
98
  // Admin (T3) token — mission-control's operator write/gate surface reads it.
75
99
  // #54: a LOCAL (loopback) daemon grants full trust tokenless, so a tokenless
76
100
  // board is fully functional locally; only a REMOTE / 0.0.0.0 daemon requires the
@@ -112,5 +136,4 @@ async function commandCenterCommand(args) {
112
136
  const { pid } = (0, spawn_1.launchInTerminal)(spawn.cmd, spawn.args, process.cwd(), spawn.env);
113
137
  out.success(`Command-center launched for ensemble ${out.cyan(ensemble)} (pid ${pid ?? 'unknown'})`);
114
138
  out.log(` ${out.dim('Observer-only Pi board — the player extension stays dormant in this session.')}`);
115
- out.log(` ${out.dim('Requires `agent-tempo install-pi` to have registered the extensions.')}`);
116
139
  }
@@ -8,8 +8,23 @@ export declare function status(opts: StatusOpts): Promise<void>;
8
8
  interface InitOpts {
9
9
  dir: string;
10
10
  project?: boolean;
11
+ /**
12
+ * #818 — suppress the manual-setup "Next steps" block (start Temporal / start
13
+ * conductor). Set by the automated `up` flow, which has ALREADY started Temporal
14
+ * and launched the conductor — printing those steps there is stale and misleading.
15
+ * The manual `agent-tempo init` verb leaves it unset so the guidance still shows.
16
+ */
17
+ suppressNextSteps?: boolean;
11
18
  }
12
19
  export declare function init(opts: InitOpts): Promise<void>;
20
+ /**
21
+ * Per-project .mcp.json install (legacy, used with --project flag).
22
+ *
23
+ * Exported for #818 regression coverage (the `suppressNextSteps` gate) — it only
24
+ * touches the filesystem + stdout, so it is unit-testable without shelling out to
25
+ * `claude`. Not part of the public CLI surface.
26
+ */
27
+ export declare function initProject(dir: string, suppressNextSteps?: boolean): void;
13
28
  interface ServerOpts extends CliOverrides {
14
29
  background: boolean;
15
30
  }
@@ -36,6 +51,19 @@ interface UpOpts extends CliOverrides {
36
51
  */
37
52
  scenario?: string;
38
53
  }
54
+ /**
55
+ * #832 — the operator-facing "ensemble ready" lines for `up` / `conduct`.
56
+ *
57
+ * Keyed on the RESOLVED ensemble name (what `command-center` / `status` take —
58
+ * `--ensemble` flag > positional > env > `default`), NOT the lineup's `name:`
59
+ * field. The two diverge whenever any of those overrides the lineup name, so the
60
+ * old `Ensemble <lineup-name> is ready` line named a thing the operator can't
61
+ * actually `connect` to. Surfaces the connect command so the next step is one
62
+ * copy-paste away. Pure + exported for unit testing (the surrounding `up` does
63
+ * I/O and isn't unit-testable). The shared markdown {@link ensembleReadyBanner}
64
+ * stays lineup-keyed for the conductor's seeded directive (a different surface).
65
+ */
66
+ export declare function formatEnsembleReadyLines(ensemble: string, lineupName: string, playerCount: number): [readyLine: string, connectLine: string];
39
67
  export declare function up(opts: UpOpts): Promise<void>;
40
68
  /**
41
69
  * Format a `ScheduleEntry` recurrence for the `status` display.