agent-tempo 1.7.0-beta.10 → 1.7.0-beta.11
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 +1 -1
- package/dashboard/package.json +1 -1
- package/dist/cli/commands.d.ts +13 -0
- package/dist/cli/commands.js +28 -2
- package/dist/cli.js +12 -4
- package/dist/http/writes.js +11 -0
- package/dist/pi/mission-control/actions.js +22 -0
- package/dist/pi/mission-control/extension.d.ts +4 -1
- package/dist/pi/mission-control/extension.js +9 -5
- package/dist/pi/mission-control/render.d.ts +13 -0
- package/dist/pi/mission-control/render.js +53 -17
- package/package.json +1 -1
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
|
|
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)
|
package/dashboard/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-tempo-dashboard",
|
|
3
3
|
"private": true,
|
|
4
|
-
"version": "1.7.0-beta.
|
|
4
|
+
"version": "1.7.0-beta.11",
|
|
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": {
|
package/dist/cli/commands.d.ts
CHANGED
|
@@ -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.
|
package/dist/cli/commands.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
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,
|
package/dist/http/writes.js
CHANGED
|
@@ -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}` : ''}`);
|
|
@@ -177,8 +177,11 @@ export declare class Controller {
|
|
|
177
177
|
* operator who just wants to un-suspend the ensemble has a single action,
|
|
178
178
|
* without overloading `/play`'s sources-only meaning (the two axes stay
|
|
179
179
|
* distinct at the primitive/daemon level).
|
|
180
|
+
*
|
|
181
|
+
* #833 — exposed as `/unpause` (NOT `/resume` — that collides with a Pi
|
|
182
|
+
* built-in interactive command and gets skipped from autocomplete).
|
|
180
183
|
*/
|
|
181
|
-
|
|
184
|
+
cmdUnpause(_args: string, ctx: McExtensionContext): Promise<void>;
|
|
182
185
|
cmdRestart(args: string, ctx: McExtensionContext): Promise<void>;
|
|
183
186
|
cmdDestroy(args: string, ctx: McExtensionContext): Promise<void>;
|
|
184
187
|
cmdReset(args: string, ctx: McExtensionContext): Promise<void>;
|
|
@@ -406,9 +406,9 @@ class Controller {
|
|
|
406
406
|
const r = await this.actions.play(release);
|
|
407
407
|
// #821 — the two-axis wart: a sources-only `/play` that leaves players HELD
|
|
408
408
|
// reads as "nothing happened" (the real #821). Name the residual axis so the
|
|
409
|
-
// operator knows to clear it — `/
|
|
409
|
+
// operator knows to clear it — `/unpause` does both in one shot.
|
|
410
410
|
const extra = r.ok && !release && this.model.held
|
|
411
|
-
? 'ensemble resumed, but players are still HELD — use /
|
|
411
|
+
? 'ensemble resumed, but players are still HELD — use /unpause (or /play release) to free them'
|
|
412
412
|
: undefined;
|
|
413
413
|
this.report(ctx, 'play', r, extra);
|
|
414
414
|
}
|
|
@@ -418,9 +418,12 @@ class Controller {
|
|
|
418
418
|
* operator who just wants to un-suspend the ensemble has a single action,
|
|
419
419
|
* without overloading `/play`'s sources-only meaning (the two axes stay
|
|
420
420
|
* distinct at the primitive/daemon level).
|
|
421
|
+
*
|
|
422
|
+
* #833 — exposed as `/unpause` (NOT `/resume` — that collides with a Pi
|
|
423
|
+
* built-in interactive command and gets skipped from autocomplete).
|
|
421
424
|
*/
|
|
422
|
-
async
|
|
423
|
-
this.report(ctx, '
|
|
425
|
+
async cmdUnpause(_args, ctx) {
|
|
426
|
+
this.report(ctx, 'unpause', await this.actions.play(true));
|
|
424
427
|
}
|
|
425
428
|
async cmdRestart(args, ctx) {
|
|
426
429
|
const [p, reason] = Controller.splitFirst(args);
|
|
@@ -922,7 +925,8 @@ function createMissionControlExtension(deps = {}) {
|
|
|
922
925
|
pi.registerCommand('pause', { description: 'Pause the ensemble', handler: (a, ctx) => ctrl.cmdPause(a, ctx) });
|
|
923
926
|
pi.registerCommand('play', { description: 'Clear the PAUSE axis (/play [release] also frees held players)', handler: (a, ctx) => ctrl.cmdPlay(a, ctx) });
|
|
924
927
|
// #821 — the one obvious "resume everything" (clears PAUSE + HELD).
|
|
925
|
-
|
|
928
|
+
// #833 — registered as `/unpause`, NOT `/resume` (collides with a Pi built-in).
|
|
929
|
+
pi.registerCommand('unpause', { description: 'Resume everything — clears PAUSE + frees HELD players', handler: (a, ctx) => ctrl.cmdUnpause(a, ctx) });
|
|
926
930
|
pi.registerCommand('restart', { description: 'Restart a player (/restart <player> [reason])', handler: (a, ctx) => ctrl.cmdRestart(a, ctx) });
|
|
927
931
|
pi.registerCommand('destroy', { description: 'Destroy a player (/destroy <player> [reason])', handler: (a, ctx) => ctrl.cmdDestroy(a, ctx) });
|
|
928
932
|
pi.registerCommand('reset', { description: 'Clean-wipe a player (/reset <player> [reason])', handler: (a, ctx) => ctrl.cmdReset(a, ctx) });
|
|
@@ -1,4 +1,17 @@
|
|
|
1
1
|
import { type BoardModel } from './board';
|
|
2
|
+
/**
|
|
3
|
+
* #836 — Pi's `InteractiveMode` hard-caps a widget at this many lines and, beyond
|
|
4
|
+
* it, naively slices the top N and appends its own dev-speak `... (widget
|
|
5
|
+
* truncated)` (mirrors `InteractiveMode.MAX_WIDGET_LINES`, verified 0.78.0). That
|
|
6
|
+
* is doubly bad for us: (1) operators see internal "widget truncated" copy, and
|
|
7
|
+
* (2) the slice keeps the TOP and drops the BOTTOM — which is exactly the
|
|
8
|
+
* command-log acks (#821) the operator most needs to see. So we clamp to this
|
|
9
|
+
* budget OURSELVES with {@link assembleWidget}, keeping the header/banner (top)
|
|
10
|
+
* AND the command-log footer (bottom) and trimming the expendable middle
|
|
11
|
+
* (roster/tail) under an operator-friendly `⋯ N … hidden` marker. If Pi ever
|
|
12
|
+
* raises its cap this only clamps a touch early — it can never over-run it.
|
|
13
|
+
*/
|
|
14
|
+
export declare const MAX_WIDGET_LINES = 10;
|
|
2
15
|
/**
|
|
3
16
|
* Render the full board. Header + player rows (conductor first), then — when a
|
|
4
17
|
* player is selected — a fine inner-loop tail (last {@link TAIL_RENDER_LINES}).
|
|
@@ -1,9 +1,41 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.MAX_WIDGET_LINES = 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
|
+
* #836 — Pi's `InteractiveMode` hard-caps a widget at this many lines and, beyond
|
|
10
|
+
* it, naively slices the top N and appends its own dev-speak `... (widget
|
|
11
|
+
* truncated)` (mirrors `InteractiveMode.MAX_WIDGET_LINES`, verified 0.78.0). That
|
|
12
|
+
* is doubly bad for us: (1) operators see internal "widget truncated" copy, and
|
|
13
|
+
* (2) the slice keeps the TOP and drops the BOTTOM — which is exactly the
|
|
14
|
+
* command-log acks (#821) the operator most needs to see. So we clamp to this
|
|
15
|
+
* budget OURSELVES with {@link assembleWidget}, keeping the header/banner (top)
|
|
16
|
+
* AND the command-log footer (bottom) and trimming the expendable middle
|
|
17
|
+
* (roster/tail) under an operator-friendly `⋯ N … hidden` marker. If Pi ever
|
|
18
|
+
* raises its cap this only clamps a touch early — it can never over-run it.
|
|
19
|
+
*/
|
|
20
|
+
exports.MAX_WIDGET_LINES = 10;
|
|
21
|
+
/**
|
|
22
|
+
* #836 — assemble `[head, body, foot]` into a widget that fits {@link
|
|
23
|
+
* MAX_WIDGET_LINES}. `head` (header + suspension/connection banner) and `foot`
|
|
24
|
+
* (the #821 command-log acks) are always kept; only the middle `body` (roster +
|
|
25
|
+
* inner tail) is trimmed, with one line spent on a friendly hidden-count marker
|
|
26
|
+
* that replaces Pi's `... (widget truncated)` dev-speak. Pure + total — never
|
|
27
|
+
* returns more than `MAX_WIDGET_LINES` lines (head+foot is ≤ 7 by construction).
|
|
28
|
+
*/
|
|
29
|
+
function assembleWidget(head, body, foot) {
|
|
30
|
+
const budget = exports.MAX_WIDGET_LINES - head.length - foot.length;
|
|
31
|
+
if (body.length <= budget)
|
|
32
|
+
return [...head, ...body, ...foot];
|
|
33
|
+
// Spend one line on the marker; keep as much of the body top as fits.
|
|
34
|
+
const keep = Math.max(budget - 1, 0);
|
|
35
|
+
const hidden = body.length - keep;
|
|
36
|
+
const marker = hidden > 0 ? ` ⋯ ${hidden} more line${hidden === 1 ? '' : 's'} hidden` : ' ⋯ (older entries hidden)';
|
|
37
|
+
return [...head, ...body.slice(0, keep), marker, ...foot];
|
|
38
|
+
}
|
|
7
39
|
/**
|
|
8
40
|
* #821 — render the persistent command-log footer (recent acks/⚠/failures). Folds
|
|
9
41
|
* write-command results into the widget so feedback doesn't vanish like the old
|
|
@@ -81,7 +113,11 @@ function oneLine(s, max) {
|
|
|
81
113
|
*/
|
|
82
114
|
function renderBoard(model, localHost) {
|
|
83
115
|
const ids = (0, board_1.sortedPlayerIds)(model);
|
|
84
|
-
|
|
116
|
+
// #836 — three bands so the widget clamp keeps the high-value top (header +
|
|
117
|
+
// banner) and bottom (command-log acks) and only trims the expendable middle.
|
|
118
|
+
const head = [];
|
|
119
|
+
const body = [];
|
|
120
|
+
const foot = renderCommandLog(model);
|
|
85
121
|
// #823 — a GONE ensemble (hard 404 — the maestro is torn down) is the most
|
|
86
122
|
// urgent signal and makes the player list + suspension flags meaningless:
|
|
87
123
|
// render the loud teardown banner and stop, so a destructive
|
|
@@ -89,13 +125,12 @@ function renderBoard(model, localHost) {
|
|
|
89
125
|
// staring at the stale pre-destroy roster (the reported symptom). Players are
|
|
90
126
|
// already cleared by `setConnection('gone')`.
|
|
91
127
|
if (model.connection === 'gone') {
|
|
92
|
-
|
|
93
|
-
|
|
128
|
+
head.push(`MISSION CONTROL · ${model.ensemble} · ENSEMBLE GONE`);
|
|
129
|
+
head.push('!! ENSEMBLE DESTROYED — no active players. ' +
|
|
94
130
|
'/ensemble <name> to observe another, or /ensemble-up to re-create.');
|
|
95
131
|
// #821 — keep the command log visible so the `ensemble-down --destroy ✓`
|
|
96
132
|
// ack that produced this state is still on screen (the #823 scenario).
|
|
97
|
-
|
|
98
|
-
return lines;
|
|
133
|
+
return assembleWidget(head, body, foot);
|
|
99
134
|
}
|
|
100
135
|
// #752/#823 — the header marker rides one loud line so a paused/held/
|
|
101
136
|
// stream-ended ensemble can't sit unnoticed (the 5h silent-wedge incident).
|
|
@@ -128,39 +163,40 @@ function renderBoard(model, localHost) {
|
|
|
128
163
|
marker = ' · [HELD]';
|
|
129
164
|
what = 'HELD players';
|
|
130
165
|
}
|
|
131
|
-
|
|
166
|
+
head.push(`MISSION CONTROL · ${model.ensemble} · ${ids.length} player${ids.length === 1 ? '' : 's'}${marker}`);
|
|
132
167
|
if (what) {
|
|
133
168
|
if (model.connection === 'reconnecting') {
|
|
134
169
|
// Informational — no resume hint (the issue is the stream, not a suspend).
|
|
135
|
-
|
|
170
|
+
head.push(`!! ${what}`);
|
|
136
171
|
}
|
|
137
172
|
else {
|
|
138
|
-
// #821 — the one obvious resume is `/
|
|
173
|
+
// #821 — the one obvious resume is `/unpause` (clears PAUSE + HELD); `/play`
|
|
139
174
|
// (sources only) and `/play release` remain for the two-axis primitive.
|
|
140
|
-
|
|
175
|
+
// #833 — `/unpause`, NOT `/resume` (the latter collides with a Pi built-in).
|
|
176
|
+
head.push(`!! ${what} — cues queue silently; resume: /unpause (or /play release)`);
|
|
141
177
|
}
|
|
142
178
|
}
|
|
143
179
|
if (ids.length === 0) {
|
|
144
|
-
|
|
180
|
+
body.push(' (no players — waiting for the ensemble…)');
|
|
145
181
|
}
|
|
146
182
|
else {
|
|
147
183
|
for (const id of ids) {
|
|
148
184
|
const row = model.players.get(id);
|
|
149
|
-
|
|
185
|
+
body.push(renderRow(row, id === model.selected, localHost));
|
|
150
186
|
}
|
|
151
187
|
}
|
|
152
188
|
if (model.selected) {
|
|
153
|
-
|
|
189
|
+
body.push(`── tail: ${model.selected} ──`);
|
|
154
190
|
const recent = model.innerTail.slice(-TAIL_RENDER_LINES);
|
|
155
191
|
if (recent.length === 0) {
|
|
156
|
-
|
|
192
|
+
body.push(' (no inner-loop activity yet)');
|
|
157
193
|
}
|
|
158
194
|
else {
|
|
159
195
|
for (const f of recent)
|
|
160
|
-
|
|
196
|
+
body.push(renderInnerFrame(f));
|
|
161
197
|
}
|
|
162
198
|
}
|
|
163
|
-
// #821 — persistent command-result footer (recent acks/⚠/failures)
|
|
164
|
-
|
|
165
|
-
return
|
|
199
|
+
// #821 — persistent command-result footer (recent acks/⚠/failures) lives in
|
|
200
|
+
// `foot` (already computed) so the #836 clamp keeps it visible.
|
|
201
|
+
return assembleWidget(head, body, foot);
|
|
166
202
|
}
|