agent-tempo 1.7.0-beta.10 → 1.7.0-beta.12

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/README.md CHANGED
@@ -148,7 +148,7 @@ agent-tempo # launch TUI (auto-provisions on first run)
148
148
  agent-tempo up [ensemble] # provision infrastructure and launch conductor
149
149
  agent-tempo down [--destroy] # tear down infrastructure (--destroy also terminates workflows)
150
150
  agent-tempo status [ensemble] # list active sessions
151
- agent-tempo destroy <ensemble> # terminate all sessions in an ensemble
151
+ agent-tempo destroy [ensemble] # terminate all sessions in an ensemble (defaults to "default")
152
152
  agent-tempo restore <ensemble> # restore orphaned sessions on this host
153
153
  agent-tempo hosts # list daemons polling this Temporal namespace (--all/--json)
154
154
  agent-tempo recall <name> # read a player's message history (--limit/--offset/--preview/--json)
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "agent-tempo-dashboard",
3
3
  "private": true,
4
- "version": "1.7.0-beta.10",
4
+ "version": "1.7.0-beta.12",
5
5
  "type": "module",
6
6
  "description": "Web dashboard for agent-tempo. Bundled into the npm package; served by the daemon at /dashboard/*.",
7
7
  "scripts": {
@@ -51,6 +51,19 @@ interface UpOpts extends CliOverrides {
51
51
  */
52
52
  scenario?: string;
53
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];
54
67
  export declare function up(opts: UpOpts): Promise<void>;
55
68
  /**
56
69
  * Format a `ScheduleEntry` recurrence for the `status` display.
@@ -37,6 +37,7 @@ exports.status = status;
37
37
  exports.init = init;
38
38
  exports.initProject = initProject;
39
39
  exports.server = server;
40
+ exports.formatEnsembleReadyLines = formatEnsembleReadyLines;
40
41
  exports.up = up;
41
42
  exports.formatScheduleRecurrence = formatScheduleRecurrence;
42
43
  exports.lineupScheduleToEntry = lineupScheduleToEntry;
@@ -710,7 +711,10 @@ async function start(opts) {
710
711
  out.log(`\nCheck status: ${out.dim('agent-tempo status ' + opts.ensemble)}`);
711
712
  if (startLineup && startInitialStartup) {
712
713
  console.log();
713
- out.log(` ${(0, constants_1.ensembleReadyBanner)(startLineup.name, startLineup.players.length)}`);
714
+ // #832 — name the RESOLVED ensemble (opts.ensemble), not the lineup's `name:`.
715
+ const [readyLine, connectLine] = formatEnsembleReadyLines(opts.ensemble, startLineup.name, startLineup.players.length);
716
+ out.success(readyLine);
717
+ out.log(` ${out.dim(connectLine)}`);
714
718
  }
715
719
  }
716
720
  async function status(opts) {
@@ -1036,6 +1040,25 @@ async function server(opts) {
1036
1040
  out.success('Temporal ready');
1037
1041
  }
1038
1042
  }
1043
+ /**
1044
+ * #832 — the operator-facing "ensemble ready" lines for `up` / `conduct`.
1045
+ *
1046
+ * Keyed on the RESOLVED ensemble name (what `command-center` / `status` take —
1047
+ * `--ensemble` flag > positional > env > `default`), NOT the lineup's `name:`
1048
+ * field. The two diverge whenever any of those overrides the lineup name, so the
1049
+ * old `Ensemble <lineup-name> is ready` line named a thing the operator can't
1050
+ * actually `connect` to. Surfaces the connect command so the next step is one
1051
+ * copy-paste away. Pure + exported for unit testing (the surrounding `up` does
1052
+ * I/O and isn't unit-testable). The shared markdown {@link ensembleReadyBanner}
1053
+ * stays lineup-keyed for the conductor's seeded directive (a different surface).
1054
+ */
1055
+ function formatEnsembleReadyLines(ensemble, lineupName, playerCount) {
1056
+ const plural = playerCount === 1 ? '' : 's';
1057
+ return [
1058
+ `Ensemble "${ensemble}" is ready (from lineup ${lineupName}). ${playerCount} player${plural} on standby.`,
1059
+ `Connect: agent-tempo command-center ${ensemble}`,
1060
+ ];
1061
+ }
1039
1062
  async function up(opts) {
1040
1063
  const config = (0, config_1.getConfig)(opts);
1041
1064
  // #689 — best-effort sweep of stale 0600 secret env files (residual from a shell
@@ -1383,7 +1406,10 @@ async function up(opts) {
1383
1406
  // nothing is deferred — we only surface the banner on initial-startup paths.
1384
1407
  if (lineup && initialStartup) {
1385
1408
  console.log();
1386
- out.log(` ${(0, constants_1.ensembleReadyBanner)(lineup.name, lineup.players.length)}`);
1409
+ // #832 — name the RESOLVED ensemble (opts.ensemble), not the lineup's `name:`.
1410
+ const [readyLine, connectLine] = formatEnsembleReadyLines(opts.ensemble, lineup.name, lineup.players.length);
1411
+ out.success(readyLine);
1412
+ out.log(` ${out.dim(connectLine)}`);
1387
1413
  }
1388
1414
  console.log();
1389
1415
  }
package/dist/cli.js CHANGED
@@ -544,10 +544,18 @@ async function main() {
544
544
  });
545
545
  break;
546
546
  case 'destroy': {
547
- const target = args.positional[1] || args.ensemble || process.env[config_1.ENV.ENSEMBLE];
548
- if (!target) {
549
- out.error('Usage: agent-tempo destroy <ensemble> [-y]');
550
- 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}"`);
551
559
  }
552
560
  await destroy({
553
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;
@@ -276,6 +276,17 @@ function mapWriteError(res, action, ensemble, err) {
276
276
  if (/no session found|no maestro|workflow not found/i.test(message)) {
277
277
  return (0, responses_1.errorResponse)(res, 404, { error: 'session-not-found', action, ensemble, detail: message });
278
278
  }
279
+ // #834 — a cue/ask to a player whose workflow is ABSENT (destroyed / never
280
+ // existed) throws `Player "<to>" not found in ensemble "<ens>"` from
281
+ // `sendAsMaestro` (core.ts). That's a 404, not a 500 — the operator typo'd a
282
+ // name or the player was destroyed. NOTE: a detached/gone-but-Running player
283
+ // never reaches here — its workflow still matches the `ExecutionStatus =
284
+ // "Running"` query, so `sendAsMaestro` signals it and the soft-fail 202/queued
285
+ // path (checkDeliverability) handles it (no throw). So this 404 cannot
286
+ // regress the #822/#827 warn-but-queue contract.
287
+ if (/player ".*" not found in ensemble/i.test(message)) {
288
+ return (0, responses_1.errorResponse)(res, 404, { error: 'player-not-found', action, ensemble, detail: message });
289
+ }
279
290
  if (/Unknown agent type/i.test(message)) {
280
291
  return (0, responses_1.errorResponse)(res, 400, { error: 'unknown-agent-type', action, ensemble, detail: message });
281
292
  }
@@ -63,6 +63,28 @@ class MissionControlActions {
63
63
  * the daemon's own body detail (it already returns good 403/503 hints).
64
64
  */
65
65
  httpError(status, detail) {
66
+ // #834 — the daemon returns a structured `player-not-found` 404 (a cue/ask to
67
+ // a destroyed / never-existed player). Surface a clean operator string rather
68
+ // than leaking the raw JSON blob the user saw. The `⚠ cue <name> — ` prefix
69
+ // is the extension footer formatter's job; actions.ts just stops leaking JSON.
70
+ let parsedError;
71
+ let parsedDetail;
72
+ try {
73
+ const j = JSON.parse(detail);
74
+ if (typeof j.error === 'string')
75
+ parsedError = j.error;
76
+ if (typeof j.detail === 'string')
77
+ parsedDetail = j.detail;
78
+ }
79
+ catch {
80
+ /* not JSON (or truncated past the 200-char slice) — fall back to raw match */
81
+ }
82
+ if (parsedError === 'player-not-found' || /"player-not-found"/.test(detail)) {
83
+ // Handles both the unescaped parsed detail (`Player "bob" ...`) and the raw
84
+ // JSON body where the quotes are escaped (`Player \"bob\" ...`).
85
+ const who = /Player \\?"([^"\\]+)\\?"/.exec(parsedDetail ?? detail)?.[1];
86
+ return who ? `no such player "${who}"` : 'no such player';
87
+ }
66
88
  if (!this.adminToken && (status === 401 || status === 403 || status === 503)) {
67
89
  return (`HTTP ${status}: operator actions need ${exports.ADMIN_TOKEN_ENV} for a remote / 0.0.0.0 daemon ` +
68
90
  `(a local loopback daemon needs none)${detail ? ` — ${detail}` : ''}`);
@@ -41,14 +41,15 @@ export declare const DEFAULT_TAIL_LIMIT = 200;
41
41
  *
42
42
  * - `'connecting'` — initial / post-rebind, before the first coarse event lands.
43
43
  * - `'live'` — at least one coarse event has arrived on the current connection.
44
- * - `'reconnecting'` — the coarse stream ENDED (a non-404 error, a 401, or a
45
- * defensive normal-end) and the loop has exited. Rows are KEPT (rendered stale,
46
- * not cleared). NOTE (#827 review): this is terminal-until-rebind today — the
47
- * stream does NOT auto-resubscribe (genuine transient blips are swallowed
48
- * INSIDE `createSubscribe`, so the board stays `'live'` through them). The
49
- * renderer therefore labels it "STREAM ENDED reopens on re-bind", not
50
- * "reconnecting". Auto-re-arm with backoff is tracked in #828; restore the
51
- * reconnecting wording if/when the loop re-subscribes on this transition.
44
+ * - `'reconnecting'` — the coarse stream ended OR went silent past the watchdog
45
+ * threshold (#826), and the board is RE-ARMING. Rows are KEPT (rendered stale,
46
+ * not cleared). #828: the extension now auto-re-subscribes with bounded
47
+ * equal-jitter backoff (genuine transient blips are still swallowed INSIDE
48
+ * `createSubscribe`, so the board only reaches here on a real stream-death).
49
+ * The variant is carried on `connectionDetail` (no new enum value): an arming
50
+ * detail `[RECONNECTING]`, a settled detail (re-arm capped at 30s) →
51
+ * `[STREAM DOWN]`, and the 401-auth path (which does NOT auto-re-arm a
52
+ * re-sub would just 401 again) keeps the `[STREAM ENDED]` + set-token hint.
52
53
  * - `'gone'` — a hard 404 on the per-ensemble stream: the ensemble's maestro is
53
54
  * gone. {@link setConnection} CLEARS the player list on this transition and the
54
55
  * extension STOPS the stream; the renderer shows "ENSEMBLE DESTROYED".
@@ -1,4 +1,5 @@
1
1
  import { type PiRole } from '../../config';
2
+ import { createSubscribe } from '../../client/subscribe';
2
3
  import { type BoardModel, type CommandLevel } from './board';
3
4
  import { MissionControlActions, type ActionResult } from './actions';
4
5
  import { type InfraProgress } from '../../cli/ensure-infra';
@@ -32,6 +33,14 @@ export interface MissionControlDeps {
32
33
  * `'player'` and `'none'` both keep it dormant.
33
34
  */
34
35
  role?: PiRole;
36
+ /**
37
+ * #826/#828 — override the coarse-stream subscribe factory (test seam).
38
+ * Defaults to {@link createSubscribe}. Lets a fake-timer test inject a mock
39
+ * `subscribe` generator to drive the watchdog + re-arm loop deterministically
40
+ * and assert the single-loop invariant (subscribe called exactly N times, not
41
+ * N+1). Production never sets it.
42
+ */
43
+ createSubscribeImpl?: typeof createSubscribe;
35
44
  }
36
45
  /**
37
46
  * Infra-bootstrap seam (#700 P1). Defaults to the real {@link ensureInfra}; the
@@ -93,6 +102,61 @@ export declare function classifyCoarseStreamEnd(err: unknown, aborted: boolean):
93
102
  connection: 'gone' | 'reconnecting';
94
103
  detail?: string;
95
104
  } | null;
105
+ /** #826 — watchdog poll cadence (how often we compare now − lastCoarseEventAt). */
106
+ export declare const WATCHDOG_TICK_MS = 5000;
107
+ /**
108
+ * #826 — board-level staleness threshold. The daemon emits a `heartbeat` SSE
109
+ * event every ≤10s on a live `/v1/events` stream, so >35s of TOTAL silence
110
+ * (3.5× heartbeat) means the stream is wedged/dead — a half-open socket from a
111
+ * hard `agent-tempo down` (ECONNREFUSED / dead TCP), which neither a 404 nor
112
+ * force-fetch's INTERNAL retry surfaces (that loop reconnects forever, never
113
+ * throws). Sits ABOVE the fetch loop's 30s internal backoff cap, so a healthy
114
+ * cycling loop still receiving heartbeats never trips it — this gap IS the
115
+ * no-double-retry boundary (watchdog = safety net ABOVE the transport).
116
+ */
117
+ export declare const COARSE_STALE_MS = 35000;
118
+ /**
119
+ * #828 — after this many consecutive failed re-arms the board stops claiming
120
+ * it's actively "reconnecting" and settles to the honest "[STREAM DOWN] —
121
+ * retrying every 30s" wording. Re-arm itself NEVER stops (a permanently silent
122
+ * wedge is the #752 silent-wedge class); only the label changes. ~5 steps takes
123
+ * the backoff ramp to its 30s cap.
124
+ */
125
+ export declare const REARM_SETTLE_THRESHOLD = 5;
126
+ /**
127
+ * #828 — equal-jitter backoff for the Nth re-arm attempt: `b/2 + rand(0, b/2)`
128
+ * where `b = min(1s·2^attempt, 30s)`. `Math.random()` is fine here — this is
129
+ * client code, not workflow code (the determinism rule does not apply). Jitter
130
+ * spreads re-arms so a fleet of boards doesn't thundering-herd a recovering
131
+ * daemon. `randomFn` is injectable for deterministic tests.
132
+ */
133
+ export declare function rearmDelayMs(attempt: number, randomFn?: () => number): number;
134
+ /**
135
+ * #828 — the reconnecting sub-variant wording for the Nth re-arm attempt: still
136
+ * ramping (< {@link REARM_SETTLE_THRESHOLD}) → "attempting to reconnect…";
137
+ * settled (≥) → "retrying every 30s". Carried on the model's `connectionDetail`
138
+ * (NO new BoardConnection enum value) and read by the renderer to pick the
139
+ * marker. Pure + exported for unit testing.
140
+ */
141
+ export declare function reconnectDetailForAttempt(attempt: number): string;
142
+ /**
143
+ * #828 — should a coarse stream-END auto-re-arm? Gate (architect ruling):
144
+ * - `null` (aborted teardown/rebind) → no
145
+ * - `gone` (404 — maestro torn down; a re-sub just 404s) → no (terminal by design)
146
+ * - `reconnecting` WITH a detail (the 401 auth path — tight-looping a
147
+ * guaranteed-fail) → no; keep the set-token hint
148
+ * - `reconnecting` WITHOUT a detail (generic stream-drop / normal-end) → yes
149
+ * Pure + exported for unit testing.
150
+ */
151
+ export declare function shouldRearmOnStreamEnd(end: {
152
+ connection: 'gone' | 'reconnecting';
153
+ detail?: string;
154
+ } | null): boolean;
155
+ /**
156
+ * #826 — is the coarse stream stale (silent past {@link COARSE_STALE_MS})?
157
+ * `lastEventAt === 0` means "not connected yet" → never stale. Pure.
158
+ */
159
+ export declare function isCoarseStale(lastEventAt: number, now: number): boolean;
96
160
  /**
97
161
  * The operator-command + board controller. Holds the model + the action client;
98
162
  * command methods are independently unit-testable with a fake actions + ctx.
@@ -177,8 +241,11 @@ export declare class Controller {
177
241
  * operator who just wants to un-suspend the ensemble has a single action,
178
242
  * without overloading `/play`'s sources-only meaning (the two axes stay
179
243
  * distinct at the primitive/daemon level).
244
+ *
245
+ * #833 — exposed as `/unpause` (NOT `/resume` — that collides with a Pi
246
+ * built-in interactive command and gets skipped from autocomplete).
180
247
  */
181
- cmdResume(_args: string, ctx: McExtensionContext): Promise<void>;
248
+ cmdUnpause(_args: string, ctx: McExtensionContext): Promise<void>;
182
249
  cmdRestart(args: string, ctx: McExtensionContext): Promise<void>;
183
250
  cmdDestroy(args: string, ctx: McExtensionContext): Promise<void>;
184
251
  cmdReset(args: string, ctx: McExtensionContext): Promise<void>;
@@ -33,13 +33,17 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.Controller = void 0;
36
+ exports.Controller = exports.REARM_SETTLE_THRESHOLD = exports.COARSE_STALE_MS = exports.WATCHDOG_TICK_MS = void 0;
37
37
  exports.buildAnswerWake = buildAnswerWake;
38
38
  exports.parseEnsembleUpArgs = parseEnsembleUpArgs;
39
39
  exports.parseRecruitArgs = parseRecruitArgs;
40
40
  exports.parseReleaseArg = parseReleaseArg;
41
41
  exports.formatOutcome = formatOutcome;
42
42
  exports.classifyCoarseStreamEnd = classifyCoarseStreamEnd;
43
+ exports.rearmDelayMs = rearmDelayMs;
44
+ exports.reconnectDetailForAttempt = reconnectDetailForAttempt;
45
+ exports.shouldRearmOnStreamEnd = shouldRearmOnStreamEnd;
46
+ exports.isCoarseStale = isCoarseStale;
43
47
  exports.registerPlannerTools = registerPlannerTools;
44
48
  exports.createMissionControlExtension = createMissionControlExtension;
45
49
  /**
@@ -217,6 +221,73 @@ function classifyCoarseStreamEnd(err, aborted) {
217
221
  }
218
222
  return { connection: 'reconnecting' };
219
223
  }
224
+ // ── #826/#828 — coarse-stream watchdog + auto-re-arm ───────────────────────
225
+ /** #826 — watchdog poll cadence (how often we compare now − lastCoarseEventAt). */
226
+ exports.WATCHDOG_TICK_MS = 5_000;
227
+ /**
228
+ * #826 — board-level staleness threshold. The daemon emits a `heartbeat` SSE
229
+ * event every ≤10s on a live `/v1/events` stream, so >35s of TOTAL silence
230
+ * (3.5× heartbeat) means the stream is wedged/dead — a half-open socket from a
231
+ * hard `agent-tempo down` (ECONNREFUSED / dead TCP), which neither a 404 nor
232
+ * force-fetch's INTERNAL retry surfaces (that loop reconnects forever, never
233
+ * throws). Sits ABOVE the fetch loop's 30s internal backoff cap, so a healthy
234
+ * cycling loop still receiving heartbeats never trips it — this gap IS the
235
+ * no-double-retry boundary (watchdog = safety net ABOVE the transport).
236
+ */
237
+ exports.COARSE_STALE_MS = 35_000;
238
+ /** #828 re-arm backoff: base 1s, ×2, cap 30s. */
239
+ const REARM_BASE_MS = 1_000;
240
+ const REARM_MAX_MS = 30_000;
241
+ /**
242
+ * #828 — after this many consecutive failed re-arms the board stops claiming
243
+ * it's actively "reconnecting" and settles to the honest "[STREAM DOWN] —
244
+ * retrying every 30s" wording. Re-arm itself NEVER stops (a permanently silent
245
+ * wedge is the #752 silent-wedge class); only the label changes. ~5 steps takes
246
+ * the backoff ramp to its 30s cap.
247
+ */
248
+ exports.REARM_SETTLE_THRESHOLD = 5;
249
+ /**
250
+ * #828 — equal-jitter backoff for the Nth re-arm attempt: `b/2 + rand(0, b/2)`
251
+ * where `b = min(1s·2^attempt, 30s)`. `Math.random()` is fine here — this is
252
+ * client code, not workflow code (the determinism rule does not apply). Jitter
253
+ * spreads re-arms so a fleet of boards doesn't thundering-herd a recovering
254
+ * daemon. `randomFn` is injectable for deterministic tests.
255
+ */
256
+ function rearmDelayMs(attempt, randomFn = Math.random) {
257
+ const b = Math.min(REARM_BASE_MS * 2 ** Math.max(0, attempt), REARM_MAX_MS);
258
+ return b / 2 + randomFn() * (b / 2);
259
+ }
260
+ /**
261
+ * #828 — the reconnecting sub-variant wording for the Nth re-arm attempt: still
262
+ * ramping (< {@link REARM_SETTLE_THRESHOLD}) → "attempting to reconnect…";
263
+ * settled (≥) → "retrying every 30s". Carried on the model's `connectionDetail`
264
+ * (NO new BoardConnection enum value) and read by the renderer to pick the
265
+ * marker. Pure + exported for unit testing.
266
+ */
267
+ function reconnectDetailForAttempt(attempt) {
268
+ return attempt >= exports.REARM_SETTLE_THRESHOLD ? render_1.STREAM_DOWN_DETAIL : render_1.RECONNECT_ARMING_DETAIL;
269
+ }
270
+ /**
271
+ * #828 — should a coarse stream-END auto-re-arm? Gate (architect ruling):
272
+ * - `null` (aborted teardown/rebind) → no
273
+ * - `gone` (404 — maestro torn down; a re-sub just 404s) → no (terminal by design)
274
+ * - `reconnecting` WITH a detail (the 401 auth path — tight-looping a
275
+ * guaranteed-fail) → no; keep the set-token hint
276
+ * - `reconnecting` WITHOUT a detail (generic stream-drop / normal-end) → yes
277
+ * Pure + exported for unit testing.
278
+ */
279
+ function shouldRearmOnStreamEnd(end) {
280
+ return end !== null && end.connection === 'reconnecting' && end.detail === undefined;
281
+ }
282
+ /**
283
+ * #826 — is the coarse stream stale (silent past {@link COARSE_STALE_MS})?
284
+ * `lastEventAt === 0` means "not connected yet" → never stale. Pure.
285
+ */
286
+ function isCoarseStale(lastEventAt, now) {
287
+ if (lastEventAt === 0)
288
+ return false;
289
+ return now - lastEventAt > exports.COARSE_STALE_MS;
290
+ }
220
291
  /**
221
292
  * The operator-command + board controller. Holds the model + the action client;
222
293
  * command methods are independently unit-testable with a fake actions + ctx.
@@ -406,9 +477,9 @@ class Controller {
406
477
  const r = await this.actions.play(release);
407
478
  // #821 — the two-axis wart: a sources-only `/play` that leaves players HELD
408
479
  // reads as "nothing happened" (the real #821). Name the residual axis so the
409
- // operator knows to clear it — `/resume` does both in one shot.
480
+ // operator knows to clear it — `/unpause` does both in one shot.
410
481
  const extra = r.ok && !release && this.model.held
411
- ? 'ensemble resumed, but players are still HELD — use /resume (or /play release) to free them'
482
+ ? 'ensemble resumed, but players are still HELD — use /unpause (or /play release) to free them'
412
483
  : undefined;
413
484
  this.report(ctx, 'play', r, extra);
414
485
  }
@@ -418,9 +489,12 @@ class Controller {
418
489
  * operator who just wants to un-suspend the ensemble has a single action,
419
490
  * without overloading `/play`'s sources-only meaning (the two axes stay
420
491
  * distinct at the primitive/daemon level).
492
+ *
493
+ * #833 — exposed as `/unpause` (NOT `/resume` — that collides with a Pi
494
+ * built-in interactive command and gets skipped from autocomplete).
421
495
  */
422
- async cmdResume(_args, ctx) {
423
- this.report(ctx, 'resume', await this.actions.play(true));
496
+ async cmdUnpause(_args, ctx) {
497
+ this.report(ctx, 'unpause', await this.actions.play(true));
424
498
  }
425
499
  async cmdRestart(args, ctx) {
426
500
  const [p, reason] = Controller.splitFirst(args);
@@ -780,6 +854,15 @@ function createMissionControlExtension(deps = {}) {
780
854
  let renderTimer = null;
781
855
  let lastRenderedRevision = -1;
782
856
  let activeCtx = null;
857
+ // #826/#828 — coarse-stream liveness + auto-re-arm state.
858
+ // `lastCoarseEventAt` is stamped on EVERY received coarse event (incl. the
859
+ // daemon's ≤10s `heartbeat`), so the watchdog measures true silence. `0` =
860
+ // not connected yet. `rearmAttempt` drives the #828 backoff + settle wording;
861
+ // `rearmTimer` is the single pending re-arm (one at a time — no stacking).
862
+ let watchdogTimer = null;
863
+ let rearmTimer = null;
864
+ let rearmAttempt = 0;
865
+ let lastCoarseEventAt = 0;
783
866
  // #790 — the CURRENT ensemble binding lives on `ctrl.model.ensemble`
784
867
  // (re-keyed by Controller.rebind BEFORE onRebind fires), so the SSE
785
868
  // closures below read it at (re)open time instead of capturing the
@@ -792,7 +875,58 @@ function createMissionControlExtension(deps = {}) {
792
875
  lastRenderedRevision = ctrl.model.revision;
793
876
  activeCtx.ui.setWidget(WIDGET_KEY, (0, render_1.renderBoard)(ctrl.model, ctrl.localHost), { placement: 'aboveEditor' });
794
877
  };
878
+ // #823 — flip the board's connection state + render the banner immediately
879
+ // (don't wait for the throttle tick when the stream's liveness changes).
880
+ const markConnection = (state, detail) => {
881
+ (0, board_1.setConnection)(ctrl.model, state, detail);
882
+ renderNow();
883
+ };
884
+ // #828 — cancel the single pending re-arm timer (idempotent).
885
+ const cancelRearm = () => {
886
+ if (rearmTimer) {
887
+ clearTimeout(rearmTimer);
888
+ rearmTimer = null;
889
+ }
890
+ };
891
+ // #828 — schedule the next coarse re-arm with bounded equal-jitter backoff.
892
+ // At most ONE pending at a time (the `rearmTimer` guard stops the watchdog and
893
+ // a stream-end both stacking re-arms). Reflects the arming/settled wording on
894
+ // the banner immediately, then re-opens the stream after the delay. NEVER
895
+ // gives up — the delay caps at 30s and `rearmAttempt` keeps the cadence there.
896
+ const scheduleRearm = () => {
897
+ if (rearmTimer)
898
+ return;
899
+ markConnection('reconnecting', reconnectDetailForAttempt(rearmAttempt));
900
+ const delay = rearmDelayMs(rearmAttempt);
901
+ rearmAttempt++;
902
+ rearmTimer = setTimeout(() => {
903
+ rearmTimer = null;
904
+ startCoarse(); // aborts the old/wedged loop at its top, opens a fresh one
905
+ }, delay);
906
+ if (typeof rearmTimer.unref === 'function')
907
+ rearmTimer.unref();
908
+ };
909
+ // #828 — apply a classified coarse stream-END to the board. A generic
910
+ // stream-drop re-arms (backoff); `gone` (404) is terminal (clear roster +
911
+ // cancel any pending re-arm); a 401 keeps the auth hint WITHOUT auto-re-arm
912
+ // (tight-looping a guaranteed-fail). `null` = aborted teardown/rebind/re-arm.
913
+ const handleStreamEnd = (end) => {
914
+ if (!end)
915
+ return;
916
+ if (shouldRearmOnStreamEnd(end)) {
917
+ scheduleRearm();
918
+ return;
919
+ }
920
+ if (end.connection === 'gone')
921
+ cancelRearm();
922
+ markConnection(end.connection, end.detail);
923
+ };
795
924
  const startCoarse = () => {
925
+ // #828 — guarantee EXACTLY ONE coarse loop alive: abort any prior stream (a
926
+ // wedged one being re-armed, or one that just ended) before opening a fresh
927
+ // one. The aborted prior loop exits via classifyCoarseStreamEnd(_, true)→null
928
+ // (no state change). session_start's first call has none (abort is a no-op).
929
+ coarseAbort?.abort();
796
930
  // #54 — accurate posture: a tokenless board is FULLY functional against a
797
931
  // local (loopback) daemon, which grants full trust. Only a REMOTE / 0.0.0.0
798
932
  // daemon requires the admin token (it 401s tokenless reads + actions).
@@ -804,25 +938,33 @@ function createMissionControlExtension(deps = {}) {
804
938
  // expected teardown abort log a spurious "coarse SSE ended: AbortError".
805
939
  const ac = new AbortController();
806
940
  coarseAbort = ac;
807
- // H5: omit baseUrl createSubscribe re-resolves the daemon port per
808
- // (re)connect, so a daemon restart on a new port self-heals.
809
- const subscribe = (0, subscribe_1.createSubscribe)({
941
+ // #826 FORCE the fetch transport: only it throws on a permanent 401/404
942
+ // (native EventSource swallows those into a silent reconnect cycle), and the
943
+ // board needs that to flip to `gone` / surface the auth hint. H5: omit baseUrl
944
+ // → createSubscribe re-resolves the daemon port per (re)connect, so a daemon
945
+ // restart on a new port self-heals.
946
+ const subscribe = (deps.createSubscribeImpl ?? subscribe_1.createSubscribe)({
947
+ forceFetch: true,
810
948
  ...(deps.baseUrl ? { baseUrl: deps.baseUrl } : {}),
811
949
  ...(adminToken ? { token: adminToken } : {}),
812
950
  });
813
- // #823flip the board's connection state + render the banner immediately
814
- // (don't wait for the throttle tick when the stream's liveness changes).
815
- const markConnection = (state, detail) => {
816
- (0, board_1.setConnection)(ctrl.model, state, detail);
817
- renderNow();
818
- };
951
+ // #826fresh connect: reset the staleness clock so the watchdog measures
952
+ // silence on THIS attempt, not the gap accrued since the last dead stream.
953
+ lastCoarseEventAt = Date.now();
819
954
  void (async () => {
820
955
  try {
821
956
  for await (const ev of subscribe(ctrl.model.ensemble, { signal: ac.signal })) {
822
- // #823the first event on this connection proves the stream is
823
- // live (clears a prior reconnecting/connecting banner).
824
- if (ctrl.model.connection !== 'live')
957
+ // #826stamp liveness on EVERY received event (incl. the no-op
958
+ // `heartbeat` the daemon emits ≤10s) so the watchdog sees the pulse.
959
+ lastCoarseEventAt = Date.now();
960
+ // #823/#828 — the first event on this connection proves the stream is
961
+ // live: clear a prior reconnecting banner AND reset the re-arm backoff
962
+ // (a recovered stream starts the next failure's ramp from scratch).
963
+ if (ctrl.model.connection !== 'live') {
825
964
  markConnection('live');
965
+ rearmAttempt = 0;
966
+ cancelRearm();
967
+ }
826
968
  // #700 P2 — an `answer` event isn't a board event; it WAKES the
827
969
  // planner (its only inbound channel is this SSE stream). Inject via
828
970
  // pi.sendMessage(triggerTurn) — feature-detected (a fake/older Pi
@@ -836,24 +978,41 @@ function createMissionControlExtension(deps = {}) {
836
978
  }
837
979
  (0, board_1.applyTempoEvent)(ctrl.model, ev);
838
980
  }
839
- // #823 the stream ENDED without throwing. In production `createSubscribe`
840
- // only ends normally on abort (it auto-retries transient drops), so a
841
- // non-aborted normal end is a defensive transient path → reconnecting.
842
- const end = classifyCoarseStreamEnd(undefined, ac.signal.aborted);
843
- if (end)
844
- markConnection(end.connection, end.detail);
981
+ // Stream ended without throwing classify + (maybe) re-arm.
982
+ handleStreamEnd(classifyCoarseStreamEnd(undefined, ac.signal.aborted));
845
983
  }
846
984
  catch (err) {
847
- // #823 — map the (permanent) stream error to a board connection state.
848
- // Aborted teardown/rebind → null (no change, no spurious log).
985
+ // Map the (permanent) stream error to a board transition.
986
+ // Aborted teardown/rebind/re-arm → null (no change, no spurious log).
849
987
  const end = classifyCoarseStreamEnd(err, ac.signal.aborted);
850
988
  if (!end)
851
989
  return;
852
990
  log(end.connection === 'gone' ? 'coarse SSE — ensemble gone:' : 'coarse SSE ended:', err instanceof Error ? err.message : err);
853
- markConnection(end.connection, end.detail);
991
+ handleStreamEnd(end);
854
992
  }
855
993
  })();
856
994
  };
995
+ // #826 — watchdog: a wedged/dead socket (half-open from a hard `agent-tempo
996
+ // down`) never throws and never ends the loop, so neither the 404 path nor
997
+ // force-fetch's internal retry catches it. Detect it by TOTAL silence past
998
+ // COARSE_STALE_MS (no heartbeat) and re-arm. Skips when already `gone`
999
+ // (terminal), in the 401 auth-ended state (no auto-re-arm by design — its
1000
+ // connectionDetail is the auth hint, not an arming/settled marker), or when a
1001
+ // re-arm is already pending (no stacking with the stream-end path).
1002
+ const checkStale = () => {
1003
+ if (ctrl.model.connection === 'gone')
1004
+ return;
1005
+ if (ctrl.model.connection === 'reconnecting' &&
1006
+ ctrl.model.connectionDetail !== render_1.RECONNECT_ARMING_DETAIL &&
1007
+ ctrl.model.connectionDetail !== render_1.STREAM_DOWN_DETAIL)
1008
+ return;
1009
+ if (rearmTimer)
1010
+ return;
1011
+ if (!isCoarseStale(lastCoarseEventAt, Date.now()))
1012
+ return;
1013
+ log(`coarse stream stale — no daemon heartbeat for >${exports.COARSE_STALE_MS / 1000}s; re-arming`);
1014
+ scheduleRearm();
1015
+ };
857
1016
  const openTail = (playerId) => {
858
1017
  tailAbort?.abort();
859
1018
  tailAbort = null;
@@ -886,6 +1045,8 @@ function createMissionControlExtension(deps = {}) {
886
1045
  coarseAbort = null;
887
1046
  tailAbort?.abort();
888
1047
  tailAbort = null;
1048
+ cancelRearm();
1049
+ rearmAttempt = 0; // #828 — fresh ensemble: drop any pending re-arm + reset the backoff ramp
889
1050
  startCoarse(); // reads the already-re-keyed ctrl.model.ensemble
890
1051
  renderNow(); // show the re-keyed (empty) board immediately, not at the next throttle tick
891
1052
  };
@@ -894,6 +1055,11 @@ function createMissionControlExtension(deps = {}) {
894
1055
  coarseAbort = null;
895
1056
  tailAbort?.abort();
896
1057
  tailAbort = null;
1058
+ cancelRearm(); // #828 — drop any pending re-arm
1059
+ if (watchdogTimer) {
1060
+ clearInterval(watchdogTimer);
1061
+ watchdogTimer = null;
1062
+ } // #826
897
1063
  if (renderTimer) {
898
1064
  clearInterval(renderTimer);
899
1065
  renderTimer = null;
@@ -905,10 +1071,16 @@ function createMissionControlExtension(deps = {}) {
905
1071
  pi.on('session_start', (_event, ctx) => {
906
1072
  activeCtx = ctx;
907
1073
  lastRenderedRevision = -1;
1074
+ rearmAttempt = 0; // #828 — fresh session
908
1075
  startCoarse();
909
1076
  renderTimer = setInterval(renderNow, throttleMs);
910
1077
  if (typeof renderTimer.unref === 'function')
911
1078
  renderTimer.unref();
1079
+ // #826 — coarse-stream staleness watchdog (catches a wedged/dead socket
1080
+ // that never throws). `.unref()` so it can't keep the process alive.
1081
+ watchdogTimer = setInterval(checkStale, exports.WATCHDOG_TICK_MS);
1082
+ if (typeof watchdogTimer.unref === 'function')
1083
+ watchdogTimer.unref();
912
1084
  renderNow();
913
1085
  });
914
1086
  pi.on('session_shutdown', () => teardown());
@@ -922,7 +1094,8 @@ function createMissionControlExtension(deps = {}) {
922
1094
  pi.registerCommand('pause', { description: 'Pause the ensemble', handler: (a, ctx) => ctrl.cmdPause(a, ctx) });
923
1095
  pi.registerCommand('play', { description: 'Clear the PAUSE axis (/play [release] also frees held players)', handler: (a, ctx) => ctrl.cmdPlay(a, ctx) });
924
1096
  // #821 — the one obvious "resume everything" (clears PAUSE + HELD).
925
- pi.registerCommand('resume', { description: 'Resume everything clears PAUSE + frees HELD players', handler: (a, ctx) => ctrl.cmdResume(a, ctx) });
1097
+ // #833registered as `/unpause`, NOT `/resume` (collides with a Pi built-in).
1098
+ pi.registerCommand('unpause', { description: 'Resume everything — clears PAUSE + frees HELD players', handler: (a, ctx) => ctrl.cmdUnpause(a, ctx) });
926
1099
  pi.registerCommand('restart', { description: 'Restart a player (/restart <player> [reason])', handler: (a, ctx) => ctrl.cmdRestart(a, ctx) });
927
1100
  pi.registerCommand('destroy', { description: 'Destroy a player (/destroy <player> [reason])', handler: (a, ctx) => ctrl.cmdDestroy(a, ctx) });
928
1101
  pi.registerCommand('reset', { description: 'Clean-wipe a player (/reset <player> [reason])', handler: (a, ctx) => ctrl.cmdReset(a, ctx) });
@@ -1,4 +1,29 @@
1
1
  import { type BoardModel } from './board';
2
+ /**
3
+ * #828 — `connectionDetail` sentinels that select the `reconnecting` sub-marker
4
+ * (the variant is carried on the model's `connectionDetail`, so NO new
5
+ * `BoardConnection` enum value is needed). The extension sets one of these while
6
+ * the coarse stream auto-re-arms:
7
+ * - {@link RECONNECT_ARMING_DETAIL} — still ramping (< settle threshold) → `[RECONNECTING]`
8
+ * - {@link STREAM_DOWN_DETAIL} — settled (re-arm capped at 30s, daemon still dead) → `[STREAM DOWN]`
9
+ * Any OTHER reconnecting detail (e.g. the 401 auth hint) renders as `[STREAM
10
+ * ENDED]` — that path does NOT auto-re-arm.
11
+ */
12
+ export declare const RECONNECT_ARMING_DETAIL = "attempting to reconnect\u2026";
13
+ export declare const STREAM_DOWN_DETAIL = "retrying every 30s \u2014 /ensemble to rebind";
14
+ /**
15
+ * #836 — Pi's `InteractiveMode` hard-caps a widget at this many lines and, beyond
16
+ * it, naively slices the top N and appends its own dev-speak `... (widget
17
+ * truncated)` (mirrors `InteractiveMode.MAX_WIDGET_LINES`, verified 0.78.0). That
18
+ * is doubly bad for us: (1) operators see internal "widget truncated" copy, and
19
+ * (2) the slice keeps the TOP and drops the BOTTOM — which is exactly the
20
+ * command-log acks (#821) the operator most needs to see. So we clamp to this
21
+ * budget OURSELVES with {@link assembleWidget}, keeping the header/banner (top)
22
+ * AND the command-log footer (bottom) and trimming the expendable middle
23
+ * (roster/tail) under an operator-friendly `⋯ N … hidden` marker. If Pi ever
24
+ * raises its cap this only clamps a touch early — it can never over-run it.
25
+ */
26
+ export declare const MAX_WIDGET_LINES = 10;
2
27
  /**
3
28
  * Render the full board. Header + player rows (conductor first), then — when a
4
29
  * player is selected — a fine inner-loop tail (last {@link TAIL_RENDER_LINES}).
@@ -1,9 +1,53 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.MAX_WIDGET_LINES = exports.STREAM_DOWN_DETAIL = exports.RECONNECT_ARMING_DETAIL = void 0;
3
4
  exports.renderBoard = renderBoard;
4
5
  const board_1 = require("./board");
5
6
  /** How many recent fine-tail frames to show under the selected player. */
6
7
  const TAIL_RENDER_LINES = 12;
8
+ /**
9
+ * #828 — `connectionDetail` sentinels that select the `reconnecting` sub-marker
10
+ * (the variant is carried on the model's `connectionDetail`, so NO new
11
+ * `BoardConnection` enum value is needed). The extension sets one of these while
12
+ * the coarse stream auto-re-arms:
13
+ * - {@link RECONNECT_ARMING_DETAIL} — still ramping (< settle threshold) → `[RECONNECTING]`
14
+ * - {@link STREAM_DOWN_DETAIL} — settled (re-arm capped at 30s, daemon still dead) → `[STREAM DOWN]`
15
+ * Any OTHER reconnecting detail (e.g. the 401 auth hint) renders as `[STREAM
16
+ * ENDED]` — that path does NOT auto-re-arm.
17
+ */
18
+ exports.RECONNECT_ARMING_DETAIL = 'attempting to reconnect…';
19
+ exports.STREAM_DOWN_DETAIL = 'retrying every 30s — /ensemble to rebind';
20
+ /**
21
+ * #836 — Pi's `InteractiveMode` hard-caps a widget at this many lines and, beyond
22
+ * it, naively slices the top N and appends its own dev-speak `... (widget
23
+ * truncated)` (mirrors `InteractiveMode.MAX_WIDGET_LINES`, verified 0.78.0). That
24
+ * is doubly bad for us: (1) operators see internal "widget truncated" copy, and
25
+ * (2) the slice keeps the TOP and drops the BOTTOM — which is exactly the
26
+ * command-log acks (#821) the operator most needs to see. So we clamp to this
27
+ * budget OURSELVES with {@link assembleWidget}, keeping the header/banner (top)
28
+ * AND the command-log footer (bottom) and trimming the expendable middle
29
+ * (roster/tail) under an operator-friendly `⋯ N … hidden` marker. If Pi ever
30
+ * raises its cap this only clamps a touch early — it can never over-run it.
31
+ */
32
+ exports.MAX_WIDGET_LINES = 10;
33
+ /**
34
+ * #836 — assemble `[head, body, foot]` into a widget that fits {@link
35
+ * MAX_WIDGET_LINES}. `head` (header + suspension/connection banner) and `foot`
36
+ * (the #821 command-log acks) are always kept; only the middle `body` (roster +
37
+ * inner tail) is trimmed, with one line spent on a friendly hidden-count marker
38
+ * that replaces Pi's `... (widget truncated)` dev-speak. Pure + total — never
39
+ * returns more than `MAX_WIDGET_LINES` lines (head+foot is ≤ 7 by construction).
40
+ */
41
+ function assembleWidget(head, body, foot) {
42
+ const budget = exports.MAX_WIDGET_LINES - head.length - foot.length;
43
+ if (body.length <= budget)
44
+ return [...head, ...body, ...foot];
45
+ // Spend one line on the marker; keep as much of the body top as fits.
46
+ const keep = Math.max(budget - 1, 0);
47
+ const hidden = body.length - keep;
48
+ const marker = hidden > 0 ? ` ⋯ ${hidden} more line${hidden === 1 ? '' : 's'} hidden` : ' ⋯ (older entries hidden)';
49
+ return [...head, ...body.slice(0, keep), marker, ...foot];
50
+ }
7
51
  /**
8
52
  * #821 — render the persistent command-log footer (recent acks/⚠/failures). Folds
9
53
  * write-command results into the widget so feedback doesn't vanish like the old
@@ -81,7 +125,11 @@ function oneLine(s, max) {
81
125
  */
82
126
  function renderBoard(model, localHost) {
83
127
  const ids = (0, board_1.sortedPlayerIds)(model);
84
- const lines = [];
128
+ // #836 three bands so the widget clamp keeps the high-value top (header +
129
+ // banner) and bottom (command-log acks) and only trims the expendable middle.
130
+ const head = [];
131
+ const body = [];
132
+ const foot = renderCommandLog(model);
85
133
  // #823 — a GONE ensemble (hard 404 — the maestro is torn down) is the most
86
134
  // urgent signal and makes the player list + suspension flags meaningless:
87
135
  // render the loud teardown banner and stop, so a destructive
@@ -89,36 +137,48 @@ function renderBoard(model, localHost) {
89
137
  // staring at the stale pre-destroy roster (the reported symptom). Players are
90
138
  // already cleared by `setConnection('gone')`.
91
139
  if (model.connection === 'gone') {
92
- lines.push(`MISSION CONTROL · ${model.ensemble} · ENSEMBLE GONE`);
93
- lines.push('!! ENSEMBLE DESTROYED — no active players. ' +
140
+ head.push(`MISSION CONTROL · ${model.ensemble} · ENSEMBLE GONE`);
141
+ head.push('!! ENSEMBLE DESTROYED — no active players. ' +
94
142
  '/ensemble <name> to observe another, or /ensemble-up to re-create.');
95
143
  // #821 — keep the command log visible so the `ensemble-down --destroy ✓`
96
144
  // ack that produced this state is still on screen (the #823 scenario).
97
- lines.push(...renderCommandLog(model));
98
- return lines;
145
+ return assembleWidget(head, body, foot);
99
146
  }
100
147
  // #752/#823 — the header marker rides one loud line so a paused/held/
101
148
  // stream-ended ensemble can't sit unnoticed (the 5h silent-wedge incident).
102
149
  // A dropped stream outranks PAUSED/HELD: the suspension flags below are then
103
150
  // last-known-only and the operator needs to know the view itself may be stale.
104
151
  //
105
- // HONEST LABEL (#827 review): the `'reconnecting'` connection state does NOT
106
- // auto-reconnect today — `createSubscribe` swallows genuine transient blips
107
- // internally (the board stays `live` through them), so this state is only
108
- // reached when the coarse stream has actually ended and the loop has exited.
109
- // It reopens on an `/ensemble` re-bind, not on its own. We therefore label it
110
- // "STREAM ENDED reopens on re-bind" rather than the misleading "RECONNECTING"
111
- // (shipping a reconnecting badge that doesn't reconnect is the exact
112
- // misleading-feedback class this PR fixes). Auto-re-arm is tracked in #828;
113
- // if/when the loop re-subscribes with backoff, restore the reconnecting wording.
152
+ // #828 the coarse stream now AUTO-RE-ARMS (bounded-backoff re-subscribe), so
153
+ // the `reconnecting` state has three honest variants, discriminated by
154
+ // `connectionDetail` (no new `BoardConnection` enum value):
155
+ // - arming (re-arm in flight, < settle) `[RECONNECTING]` "attempting to reconnect…"
156
+ // - settled (re-arm capped at 30s, still dead) `[STREAM DOWN]` "retrying every 30s"
157
+ // - other detail (the 401 auth path — does NOT auto-re-arm) `[STREAM ENDED]` + hint
158
+ // The #827 "STREAM ENDED, reopens on re-bind" honest-label note is now reversed
159
+ // for the arming/settled variants because re-arm is real (#828); only the 401
160
+ // path keeps the stream-ended wording (it genuinely needs a manual re-bind /
161
+ // new token).
114
162
  let marker = '';
115
163
  let what = '';
116
164
  if (model.connection === 'reconnecting') {
117
- marker = ' · [STREAM ENDED]';
118
- const tail = 'last-known state, reopens on /ensemble re-bind';
119
- what = model.connectionDetail
120
- ? `STREAM ENDED — ${model.connectionDetail}; ${tail}`
121
- : `STREAM ENDED coarse stream dropped; ${tail}`;
165
+ if (model.connectionDetail === exports.RECONNECT_ARMING_DETAIL) {
166
+ marker = ' · [RECONNECTING]';
167
+ what = `RECONNECTING — ${exports.RECONNECT_ARMING_DETAIL}`;
168
+ }
169
+ else if (model.connectionDetail === exports.STREAM_DOWN_DETAIL) {
170
+ marker = ' · [STREAM DOWN]';
171
+ what = `STREAM DOWN — ${exports.STREAM_DOWN_DETAIL}`;
172
+ }
173
+ else {
174
+ // 401 auth (or any non-re-arming reconnecting): the stream ended and only a
175
+ // manual re-bind / new token recovers it — keep the honest stream-ended copy.
176
+ marker = ' · [STREAM ENDED]';
177
+ const tail = 'last-known state, reopens on /ensemble re-bind';
178
+ what = model.connectionDetail
179
+ ? `STREAM ENDED — ${model.connectionDetail}; ${tail}`
180
+ : `STREAM ENDED — coarse stream dropped; ${tail}`;
181
+ }
122
182
  }
123
183
  else if (model.paused) {
124
184
  marker = ' · [PAUSED]';
@@ -128,39 +188,40 @@ function renderBoard(model, localHost) {
128
188
  marker = ' · [HELD]';
129
189
  what = 'HELD players';
130
190
  }
131
- lines.push(`MISSION CONTROL · ${model.ensemble} · ${ids.length} player${ids.length === 1 ? '' : 's'}${marker}`);
191
+ head.push(`MISSION CONTROL · ${model.ensemble} · ${ids.length} player${ids.length === 1 ? '' : 's'}${marker}`);
132
192
  if (what) {
133
193
  if (model.connection === 'reconnecting') {
134
194
  // Informational — no resume hint (the issue is the stream, not a suspend).
135
- lines.push(`!! ${what}`);
195
+ head.push(`!! ${what}`);
136
196
  }
137
197
  else {
138
- // #821 — the one obvious resume is `/resume` (clears PAUSE + HELD); `/play`
198
+ // #821 — the one obvious resume is `/unpause` (clears PAUSE + HELD); `/play`
139
199
  // (sources only) and `/play release` remain for the two-axis primitive.
140
- lines.push(`!! ${what}cues queue silently; resume: /resume (or /play release)`);
200
+ // #833`/unpause`, NOT `/resume` (the latter collides with a Pi built-in).
201
+ head.push(`!! ${what} — cues queue silently; resume: /unpause (or /play release)`);
141
202
  }
142
203
  }
143
204
  if (ids.length === 0) {
144
- lines.push(' (no players — waiting for the ensemble…)');
205
+ body.push(' (no players — waiting for the ensemble…)');
145
206
  }
146
207
  else {
147
208
  for (const id of ids) {
148
209
  const row = model.players.get(id);
149
- lines.push(renderRow(row, id === model.selected, localHost));
210
+ body.push(renderRow(row, id === model.selected, localHost));
150
211
  }
151
212
  }
152
213
  if (model.selected) {
153
- lines.push(`── tail: ${model.selected} ──`);
214
+ body.push(`── tail: ${model.selected} ──`);
154
215
  const recent = model.innerTail.slice(-TAIL_RENDER_LINES);
155
216
  if (recent.length === 0) {
156
- lines.push(' (no inner-loop activity yet)');
217
+ body.push(' (no inner-loop activity yet)');
157
218
  }
158
219
  else {
159
220
  for (const f of recent)
160
- lines.push(renderInnerFrame(f));
221
+ body.push(renderInnerFrame(f));
161
222
  }
162
223
  }
163
- // #821 — persistent command-result footer (recent acks/⚠/failures).
164
- lines.push(...renderCommandLog(model));
165
- return lines;
224
+ // #821 — persistent command-result footer (recent acks/⚠/failures) lives in
225
+ // `foot` (already computed) so the #836 clamp keeps it visible.
226
+ return assembleWidget(head, body, foot);
166
227
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-tempo",
3
- "version": "1.7.0-beta.10",
3
+ "version": "1.7.0-beta.12",
4
4
  "description": "Many agents, one tempo. Durable coordination for multi-agent work via Temporal.",
5
5
  "keywords": [
6
6
  "mcp",
@@ -82,13 +82,14 @@
82
82
  "test": "mocha && vitest run",
83
83
  "lint:surface-drift": "node scripts/check-surface-drift.js",
84
84
  "lint:no-stale-scaffold": "node scripts/check-no-stale-scaffold.js",
85
+ "lint:no-stray-src-js": "node scripts/check-no-stray-src-js.js",
85
86
  "lint:test-ensemble-literals": "bash scripts/check-test-ensemble-literals.sh",
86
87
  "lint:skip-reasons": "node scripts/lint-skip-reasons.js",
87
88
  "lint:lockstep-version": "node -e \"const r=require('./package.json').version,d=require('./dashboard/package.json').version;if(r!==d){console.error('Version drift: root='+r+' dashboard='+d+'. Bump dashboard/package.json#version to match root.');process.exit(1);}console.log('Lockstep OK: '+r);\"",
88
89
  "lint:lockfile-canonical": "bash scripts/check-lockfile-canonical.sh",
89
90
  "lint:dashboard-css-sync": "npm run build:scripts && node dist/scripts/check-components-css-sync.js",
90
91
  "lint:pi-drift": "node scripts/check-pi-drift.js",
91
- "check:all": "npm run lint:test-ensemble-literals && npm run lint:skip-reasons && npm run lint:lockstep-version && npm run lint:lockfile-canonical && npm run lint:surface-drift && npm run lint:no-stale-scaffold && npm run build && npm run lint:pi-drift && npm run lint:dashboard-css-sync && npm test && npm --prefix dashboard run lint && npm --prefix dashboard run test && npm run size-limit && npm run verify-tarball"
92
+ "check:all": "npm run lint:test-ensemble-literals && npm run lint:skip-reasons && npm run lint:lockstep-version && npm run lint:lockfile-canonical && npm run lint:surface-drift && npm run lint:no-stale-scaffold && npm run lint:no-stray-src-js && npm run build && npm run lint:pi-drift && npm run lint:dashboard-css-sync && npm test && npm --prefix dashboard run lint && npm --prefix dashboard run test && npm run size-limit && npm run verify-tarball"
92
93
  },
93
94
  "optionalDependencies": {
94
95
  "@anthropic-ai/sdk": "~0.91.1",