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 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.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": {
@@ -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,
@@ -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
- cmdResume(_args: string, ctx: McExtensionContext): Promise<void>;
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 — `/resume` does both in one shot.
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 /resume (or /play release) to free them'
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 cmdResume(_args, ctx) {
423
- this.report(ctx, 'resume', await this.actions.play(true));
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
- pi.registerCommand('resume', { description: 'Resume everything clears PAUSE + frees HELD players', handler: (a, ctx) => ctrl.cmdResume(a, ctx) });
928
+ // #833registered 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
- const lines = [];
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
- lines.push(`MISSION CONTROL · ${model.ensemble} · ENSEMBLE GONE`);
93
- lines.push('!! ENSEMBLE DESTROYED — no active players. ' +
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
- lines.push(...renderCommandLog(model));
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
- lines.push(`MISSION CONTROL · ${model.ensemble} · ${ids.length} player${ids.length === 1 ? '' : 's'}${marker}`);
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
- lines.push(`!! ${what}`);
170
+ head.push(`!! ${what}`);
136
171
  }
137
172
  else {
138
- // #821 — the one obvious resume is `/resume` (clears PAUSE + HELD); `/play`
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
- lines.push(`!! ${what}cues queue silently; resume: /resume (or /play release)`);
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
- lines.push(' (no players — waiting for the ensemble…)');
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
- lines.push(renderRow(row, id === model.selected, localHost));
185
+ body.push(renderRow(row, id === model.selected, localHost));
150
186
  }
151
187
  }
152
188
  if (model.selected) {
153
- lines.push(`── tail: ${model.selected} ──`);
189
+ body.push(`── tail: ${model.selected} ──`);
154
190
  const recent = model.innerTail.slice(-TAIL_RENDER_LINES);
155
191
  if (recent.length === 0) {
156
- lines.push(' (no inner-loop activity yet)');
192
+ body.push(' (no inner-loop activity yet)');
157
193
  }
158
194
  else {
159
195
  for (const f of recent)
160
- lines.push(renderInnerFrame(f));
196
+ body.push(renderInnerFrame(f));
161
197
  }
162
198
  }
163
- // #821 — persistent command-result footer (recent acks/⚠/failures).
164
- lines.push(...renderCommandLog(model));
165
- return lines;
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
  }
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.11",
4
4
  "description": "Many agents, one tempo. Durable coordination for multi-agent work via Temporal.",
5
5
  "keywords": [
6
6
  "mcp",