pi-legion 2.0.0 → 2.0.2

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.
@@ -31,6 +31,7 @@ import {
31
31
  import { canUseCachedCollect } from "../extension/recycle.ts";
32
32
  import {
33
33
  countAssistantMessages,
34
+ attributeParentSessionShutdown,
34
35
  deriveOutcome,
35
36
  emptyParsedSession,
36
37
  extractVerdict,
@@ -51,6 +52,7 @@ import {
51
52
  MAX_NESTED_PATH_ENTRIES,
52
53
  type ModelOrigin,
53
54
  type NestedPathEntry,
55
+ type RetireOptions,
54
56
  type RunRecord,
55
57
  SubagentError,
56
58
  type Usage,
@@ -62,6 +64,7 @@ import { nativeModelFor } from "./kind.ts";
62
64
  import { applyVerification, type VerifyRunner } from "./acceptance.ts";
63
65
  import {
64
66
  createChildWorktree,
67
+ isInsideChildWorktree,
65
68
  removeChildWorktree,
66
69
  resolveLaunchWorktree,
67
70
  } from "./worktree.ts";
@@ -239,6 +242,8 @@ export class Orchestrator {
239
242
  private readonly children = new Map<string, ChildRecord>();
240
243
  private readonly nameCounter = new Map<string, number>();
241
244
  private readonly pendingWaits = new Map<string, Promise<SettleResult>>();
245
+ /** Children retired because the parent session is shutting down. */
246
+ private readonly parentShutdownRetire = new Set<string>();
242
247
 
243
248
  constructor(deps: OrchestratorDeps) {
244
249
  this.supervisor = deps.supervisor ?? new RpcSupervisor();
@@ -491,6 +496,8 @@ export class Orchestrator {
491
496
  name?: string;
492
497
  team?: string;
493
498
  model?: string;
499
+ /** Provider id pinning `--provider` for an ambiguous bare model id (#123). */
500
+ modelProvider?: string;
494
501
  thinking?: string | false;
495
502
  worktree?: boolean;
496
503
  modelOrigin?: ModelOrigin;
@@ -529,7 +536,11 @@ export class Orchestrator {
529
536
  ? true
530
537
  : this.legion && parentRole === "centurion"
531
538
  ? false
532
- : resolveLaunchWorktree({ roleDefault: input.agent.worktree, launch: input.worktree });
539
+ : resolveLaunchWorktree({
540
+ roleDefault: input.agent.worktree,
541
+ launch: input.worktree,
542
+ parentInWorktree: isInsideChildWorktree(this.cwd),
543
+ });
533
544
  let worktreePath = previous?.worktreePath;
534
545
  let worktreeBranch = previous?.worktreeBranch;
535
546
  let createdWorktree = false;
@@ -598,6 +609,7 @@ export class Orchestrator {
598
609
  cwd: worktreePath ?? this.cwd,
599
610
  env: childEnv,
600
611
  ...(model ? { model } : {}),
612
+ ...(input.modelProvider ? { modelProvider: input.modelProvider } : {}),
601
613
  ...(input.stripLegionIdentity ? { stripLegionIdentity: true } : {}),
602
614
  ...(input.thinking !== undefined ? { thinking: input.thinking } : input.agent.thinking !== undefined ? { thinking: input.agent.thinking } : {}),
603
615
  ...(input.agent.allowNestedSubagents !== undefined && !input.agent.utility ? { allowNestedSubagents: input.agent.allowNestedSubagents } : {}),
@@ -860,6 +872,11 @@ export class Orchestrator {
860
872
  execution = { status: "aborted", reason: "RPC child exited before writing session JSONL", model: child.model ?? null };
861
873
  }
862
874
  }
875
+ // A parent shutdown can resolve the in-flight wait by killing the child.
876
+ // That is not the child settling a turn with no assistant reply.
877
+ if (this.parentShutdownRetire.has(name) && wait !== "settled") {
878
+ execution = attributeParentSessionShutdown(execution, parsed);
879
+ }
863
880
  return this.finishCollect(child, parsed, execution, deadline);
864
881
  }
865
882
 
@@ -1098,10 +1115,18 @@ export class Orchestrator {
1098
1115
  return cached;
1099
1116
  }
1100
1117
 
1101
- async retire(name: string): Promise<ChildRecord> {
1118
+ async retire(name: string, opts?: RetireOptions): Promise<ChildRecord> {
1102
1119
  const child = this.requireChild(name);
1120
+ if (opts?.cause === "session-shutdown") this.parentShutdownRetire.add(name);
1103
1121
  if (child.state === "retired") return child;
1104
- if (!child.execution && fs.existsSync(child.sessionFile)) child.execution = deriveOutcome(parseSessionFile(child.sessionFile));
1122
+ const sessionExists = fs.existsSync(child.sessionFile);
1123
+ const parsed = sessionExists ? parseSessionFile(child.sessionFile) : emptyParsedSession();
1124
+ if (!child.execution && sessionExists) child.execution = deriveOutcome(parsed);
1125
+ // Stamp the cause before killing so an in-flight collect cannot
1126
+ // overwrite it with "no assistant message" after the process dies.
1127
+ if (opts?.cause === "session-shutdown" && this.supervisor.isAlive(name)) {
1128
+ child.execution = attributeParentSessionShutdown(child.execution ?? deriveOutcome(parsed), parsed);
1129
+ }
1105
1130
  if (this.supervisor.isAlive(name)) await this.supervisor.retire(name);
1106
1131
  if (child.nodeId && this.legion) {
1107
1132
  if (child.execution && child.execution.status !== "unknown" && child.execution.status !== "running") {
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Provider pinning for ambiguous bare model ids (#123).
3
+ *
4
+ * A model id like `workbuddy/hy4-preview-ioa` can exist under more than one
5
+ * provider: one registered live by an extension (any-endpoint) and one static
6
+ * in models.json without usable auth. Children start with `--no-extensions`,
7
+ * so the live provider only exists when the role lists its extension — and a
8
+ * bare `--model workbuddy/...` still resolves into the FIRST provider that
9
+ * declares the id, which may be the auth-less static one. The child then dies
10
+ * instantly with "No API key found for <provider>".
11
+ *
12
+ * Fix: at dispatch time, ask the PARENT's registry which provider actually
13
+ * owns the resolved model and pass it as `--provider` alongside `--model`.
14
+ * The parent registry reflects the live extension providers, so the pinned
15
+ * provider is the one that can authenticate.
16
+ */
17
+
18
+ /** Minimal registry surface `providerForModel` needs (subset of ModelRegistry). */
19
+ export interface ProviderLookup {
20
+ getAll(): Array<{ provider: string; id: string }>;
21
+ }
22
+
23
+ /**
24
+ * Find the provider that owns `model` in the parent's registry.
25
+ *
26
+ * Returns `undefined` when the model cannot be found or when more than one
27
+ * provider declares the same id AND the id carries no provider prefix — in
28
+ * that ambiguous case pinning a wrong provider is worse than leaving the
29
+ * child to resolve it. Models with a `provider/id` prefix are matched on the
30
+ * full prefixed id first (extension providers register such ids verbatim),
31
+ * then on the suffix after the first slash.
32
+ */
33
+ export function providerForModel(
34
+ registry: ProviderLookup | undefined,
35
+ model: string | undefined,
36
+ ): string | undefined {
37
+ if (!registry || !model) return undefined;
38
+ const bare = model.trim();
39
+ if (!bare) return undefined;
40
+
41
+ const models = registry.getAll();
42
+ // Candidates: the id verbatim (extension providers may register prefixed
43
+ // ids) and, when `bare` carries a provider prefix, the id after stripping it
44
+ // (`any-endpoint/workbuddy/x` ↔ registry id `workbuddy/x`).
45
+ const suffix = bare.includes("/") ? bare.slice(bare.indexOf("/") + 1) : undefined;
46
+ const matches = models.filter(
47
+ (m) => m.id === bare || (suffix !== undefined && m.id === suffix),
48
+ );
49
+ if (matches.length === 1) {
50
+ return matches[0]!.provider;
51
+ }
52
+ if (matches.length > 1) {
53
+ // Prefer the provider named by the id's own prefix (`any-endpoint/...`).
54
+ const prefix = bare.split("/")[0]!;
55
+ const prefixed = matches.find((m) => m.provider === prefix);
56
+ return prefixed?.provider;
57
+ }
58
+ return undefined;
59
+ }
@@ -58,7 +58,13 @@ function writeMarker(worktreePath: string, mode: string): void {
58
58
  fs.renameSync(tmp, file);
59
59
  }
60
60
 
61
- /** Repo-relative manifests, in resolution order, for the npm-install mode. */
61
+ /** Repo-relative manifests, in resolution order, for the npm-install mode.
62
+ *
63
+ * The bare package.json fallback was removed: a package.json without any
64
+ * lockfile is far more often a non-npm repo caught by mistake (Java/Maven
65
+ * repos carry stray package.json files for docs/tooling) than a real npm
66
+ * project (which always has a lockfile). Without a lockfile we skip
67
+ * provisioning instead of firing npm install into a Java checkout. */
62
68
  function installCommandFor(worktreePath: string): string | null {
63
69
  if (fs.existsSync(path.join(worktreePath, "pnpm-lock.yaml"))) {
64
70
  return "pnpm install --frozen-lockfile";
@@ -72,9 +78,6 @@ function installCommandFor(worktreePath: string): string | null {
72
78
  if (fs.existsSync(path.join(worktreePath, "package-lock.json"))) {
73
79
  return "npm ci";
74
80
  }
75
- if (fs.existsSync(path.join(worktreePath, "package.json"))) {
76
- return "npm install";
77
- }
78
81
  return null;
79
82
  }
80
83
 
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Extensible doctor gates: role `tools:` that need an extension under
3
+ * `--no-extensions` (#70 lens, #89/#97 codemode, future builtins).
4
+ *
5
+ * Add a row here when a new tool would otherwise false-promise under
6
+ * `--no-extensions`. Keep stable `code` values when tests/docs depend on them.
7
+ */
8
+
9
+ import { CHILD_EXTENSIONS_ENV, BUILTIN_CODEMODE, isCodemodeExtension } from "./args.ts";
10
+ import type { AgentConfig } from "../shared/types.ts";
11
+
12
+ export interface RoleToolExtensionGate {
13
+ /** Stable issue code (lens/codemode keep specific codes for back-compat). */
14
+ code: string;
15
+ /** Match against role `tools:` entries. */
16
+ matchesTools: (tools: readonly string[]) => boolean;
17
+ /** True when the effective child extension list satisfies the requirement. */
18
+ extensionsSatisfy: (exts: readonly string[]) => boolean;
19
+ toolLabel: string;
20
+ extensionLabel: string;
21
+ fix: (roleName: string) => string;
22
+ nextStep: string;
23
+ }
24
+
25
+ /**
26
+ * Configurable tool → extension requirements for `/legion doctor` (#97).
27
+ *
28
+ * Order is the issue emission order. Prefer specific codes over a single
29
+ * `ROLE_TOOL_UNAVAILABLE` so Next steps and existing tests stay precise.
30
+ */
31
+ export const ROLE_TOOL_EXTENSION_GATES: readonly RoleToolExtensionGate[] = [
32
+ {
33
+ code: "ROLE_MISSING_LENS",
34
+ matchesTools: (tools) =>
35
+ tools.some((t) =>
36
+ /lens|symbol_search|project_report|module_report|read_symbol|read_enclosing/i.test(t),
37
+ ),
38
+ extensionsSatisfy: (exts) => exts.some((e) => /pi-lens/i.test(e)),
39
+ toolLabel: "lens tools",
40
+ extensionLabel: "pi-lens",
41
+ fix: (roleName) =>
42
+ `Add pi-lens to ${roleName}'s extensions: frontmatter, or to ${CHILD_EXTENSIONS_ENV}.`,
43
+ nextStep:
44
+ "Add pi-lens extension path to roles that list lens_* tools, or to PI_LEGION_CHILD_EXTENSIONS.",
45
+ },
46
+ {
47
+ code: "ROLE_MISSING_CODEMODE",
48
+ matchesTools: (tools) => tools.some((t) => t === "codemode"),
49
+ extensionsSatisfy: (exts) => exts.some(isCodemodeExtension),
50
+ toolLabel: "codemode",
51
+ extensionLabel: BUILTIN_CODEMODE,
52
+ fix: (roleName) =>
53
+ `Add ${BUILTIN_CODEMODE} to ${roleName}'s extensions: frontmatter, or ensure tools: includes codemode so Legion auto-loads it.`,
54
+ nextStep: `Add ${BUILTIN_CODEMODE} to roles that list codemode (or keep tools: codemode so Legion auto-loads it).`,
55
+ },
56
+ ];
57
+
58
+ /** Plain issue shape so this module stays free of doctor imports. */
59
+ export interface RoleToolGateIssue {
60
+ severity: "warning";
61
+ code: string;
62
+ section: "extensions";
63
+ message: string;
64
+ fix: string;
65
+ }
66
+
67
+ /**
68
+ * Collect role-tool / extension mismatch issues for one agent.
69
+ *
70
+ * `effectiveExts` should be the same list the child argv would load
71
+ * (e.g. `collectChildExtensions`). When `inheritsExtensions` is true the
72
+ * child loads host discovery — gates do not fire.
73
+ */
74
+ export function collectRoleToolExtensionIssues(
75
+ agent: Pick<AgentConfig, "name" | "tools">,
76
+ effectiveExts: readonly string[],
77
+ opts: { inheritsExtensions: boolean },
78
+ ): RoleToolGateIssue[] {
79
+ if (opts.inheritsExtensions) return [];
80
+ const tools = agent.tools ?? [];
81
+ const out: RoleToolGateIssue[] = [];
82
+ for (const gate of ROLE_TOOL_EXTENSION_GATES) {
83
+ if (!gate.matchesTools(tools)) continue;
84
+ if (gate.extensionsSatisfy(effectiveExts)) continue;
85
+ out.push({
86
+ severity: "warning",
87
+ code: gate.code,
88
+ section: "extensions",
89
+ message: `Role "${agent.name}" lists ${gate.toolLabel} but would not load ${gate.extensionLabel} under --no-extensions.`,
90
+ fix: gate.fix(agent.name),
91
+ });
92
+ }
93
+ return out;
94
+ }
95
+
96
+ /** Next-step lines for any gate codes present in `issues`. */
97
+ export function roleToolGateNextSteps(issues: readonly { code: string }[]): string[] {
98
+ const steps: string[] = [];
99
+ for (const gate of ROLE_TOOL_EXTENSION_GATES) {
100
+ if (issues.some((i) => i.code === gate.code)) steps.push(gate.nextStep);
101
+ }
102
+ return steps;
103
+ }
@@ -24,17 +24,49 @@ export function worktreePathFor(runDir: string, name: string): string {
24
24
  * Per-step tool field wins, then the top-level `subagent({ worktree })`
25
25
  * argument, then the role default. The parent (group) agent is supposed to
26
26
  * pick this; the role default is only the fallback when it omits the field.
27
+ *
28
+ * Nesting suppression: when the parent cwd is itself a child worktree
29
+ * (git-dir ≠ git-common-dir), the role default and launch fallback collapse
30
+ * to false — a worktree of a worktree only re-pays the bootstrap tax.
31
+ * Explicit `launch: true`/`step: true` still wins: the caller saw the
32
+ * suppression and insisted.
27
33
  */
28
34
  export function resolveLaunchWorktree(opts: {
29
35
  roleDefault?: boolean;
30
36
  launch?: boolean;
31
37
  step?: boolean;
38
+ parentInWorktree?: boolean;
32
39
  }): boolean {
33
40
  if (typeof opts.step === "boolean") return opts.step;
34
41
  if (typeof opts.launch === "boolean") return opts.launch;
42
+ if (opts.parentInWorktree) return false;
35
43
  return opts.roleDefault === true;
36
44
  }
37
45
 
46
+ /**
47
+ * True when cwd is itself a linked git worktree (child of a main checkout).
48
+ * Detected via `git rev-parse --git-dir` vs `--git-common-dir`: they differ
49
+ * inside any linked worktree, including ones created by other agent runs.
50
+ * Non-git directories report both as ".git" and are treated as not-in-worktree;
51
+ * the later createChildWorktree call owns the "not a repo" error.
52
+ */
53
+ export function isInsideChildWorktree(cwd: string): boolean {
54
+ try {
55
+ const gitDir = execFileSync("git", ["-C", cwd, "rev-parse", "--git-dir"], {
56
+ stdio: ["ignore", "pipe", "pipe"],
57
+ encoding: "utf8",
58
+ }).trim();
59
+ const commonDir = execFileSync(
60
+ "git",
61
+ ["-C", cwd, "rev-parse", "--git-common-dir"],
62
+ { stdio: ["ignore", "pipe", "pipe"], encoding: "utf8" },
63
+ ).trim();
64
+ return gitDir !== commonDir && gitDir !== "" && commonDir !== "";
65
+ } catch {
66
+ return false;
67
+ }
68
+ }
69
+
38
70
  /** Branch the child commits on. Unique per launch so two workers never collide. */
39
71
  export function worktreeBranchFor(name: string, nonce?: string): string {
40
72
  const tag = nonce ?? randomBytes(4).toString("hex");
@@ -513,6 +513,40 @@ export function isTransientConnectionFailure(
513
513
  return /connection error/i.test(text);
514
514
  }
515
515
 
516
+ /**
517
+ * Session ended with a user prompt and no assistant reply.
518
+ * This is a model/stream failure when the child finished on its own.
519
+ * A parent `session_shutdown` that kills the child first must not reuse it;
520
+ * see `attributeParentSessionShutdown`.
521
+ */
522
+ export const NO_ASSISTANT_ABORT_REASON = "no assistant message for the last prompt";
523
+
524
+ /**
525
+ * Parent process shutdown retired the child before any assistant message
526
+ * was written. Distinct from {@link NO_ASSISTANT_ABORT_REASON}.
527
+ */
528
+ export const PARENT_SESSION_SHUTDOWN_ABORT_REASON =
529
+ "parent session shutdown retired the child before it produced an assistant message";
530
+
531
+ /** True when the last turn (or the whole file) has no assistant message. */
532
+ export function lastTurnHasNoAssistant(parsed: ParsedSession): boolean {
533
+ const last = parsed.turns.at(-1);
534
+ return parsed.turns.length === 0 || !last || last.assistants.length === 0;
535
+ }
536
+
537
+ /**
538
+ * Rewrite a missing-assistant abort when the parent session shut down and
539
+ * retired the child. A turn the child actually settled keeps `deriveOutcome`.
540
+ */
541
+ export function attributeParentSessionShutdown(execution: Execution, parsed: ParsedSession): Execution {
542
+ if (execution.status === "success" || !lastTurnHasNoAssistant(parsed)) return execution;
543
+ return {
544
+ ...execution,
545
+ status: "aborted",
546
+ reason: PARENT_SESSION_SHUTDOWN_ABORT_REASON,
547
+ };
548
+ }
549
+
516
550
  /**
517
551
  * Derive the execution outcome from a parsed session.
518
552
  *
@@ -542,10 +576,11 @@ export function deriveOutcome(parsed: ParsedSession): Execution {
542
576
  }
543
577
 
544
578
  // F29: hard kill / interrupt before any reply was persisted.
579
+ // Parent-session cascade shutdown rewrites this in the orchestrator.
545
580
  if (last.assistants.length === 0) {
546
581
  return {
547
582
  status: "aborted",
548
- reason: "no assistant message for the last prompt",
583
+ reason: NO_ASSISTANT_ABORT_REASON,
549
584
  turns: turns.length,
550
585
  toolErrors: parsed.toolErrors,
551
586
  lastTurn: { stopReason: null, toolErrors: last.toolErrors },
@@ -558,7 +593,7 @@ export function deriveOutcome(parsed: ParsedSession): Execution {
558
593
  if (!final) {
559
594
  return {
560
595
  status: "aborted",
561
- reason: "no assistant message for the last prompt",
596
+ reason: NO_ASSISTANT_ABORT_REASON,
562
597
  turns: turns.length,
563
598
  toolErrors: parsed.toolErrors,
564
599
  };
@@ -246,6 +246,14 @@ export interface Execution {
246
246
  completedAt?: string;
247
247
  }
248
248
 
249
+ /**
250
+ * Why a still-live child is being retired.
251
+ * `session-shutdown` is the parent process leaving, not a model failure.
252
+ */
253
+ export interface RetireOptions {
254
+ cause?: "session-shutdown";
255
+ }
256
+
249
257
  export interface Usage {
250
258
  input: number;
251
259
  output: number;
package/src/tui/status.ts CHANGED
@@ -598,7 +598,7 @@ export interface StatusVizSnapshot {
598
598
  nodes: VizNode[];
599
599
  sampler: RateSampler;
600
600
  now: number;
601
- /** Shared output tok/s (ΔtokensOut / ΔgenerationMs over trailing 60s). */
601
+ /** Header "now" tok/s (live agents only; 0 when none running). */
602
602
  rate: number;
603
603
  }
604
604
 
@@ -608,8 +608,6 @@ export interface StatusBoard {
608
608
  vizSnapshot?(now?: number): StatusVizSnapshot;
609
609
  paint(entries: StatusEntry[], now: number): void;
610
610
  clear(): void;
611
- /** Show the idle stop hint in the railway header (main agent idle, children live). */
612
- setIdleHint?(on: boolean): void;
613
611
  /** The TUI the widget is mounted in, when registered (for focus/overlay checks). */
614
612
  getTui?(): unknown;
615
613
  }
@@ -646,7 +644,6 @@ export function createStatusBoard(options: {
646
644
  let tui: WidgetTui | undefined;
647
645
  let railScroll: RailScroll = { offset: 0 };
648
646
  let railWindow: { offset: number; total: number; maxLanes: number } | undefined;
649
- let idleHint = false;
650
647
 
651
648
  const renderLines = (now: number, theme: Theme, width?: number): {
652
649
  lines: string[];
@@ -655,8 +652,9 @@ export function createStatusBoard(options: {
655
652
  } => {
656
653
  if (strip === "railway" && hasTreeIdentity(entries) && width !== undefined && width >= 20) {
657
654
  const nodes = nodesFromEntries(entries);
658
- const hint = idleHint ? { hint: IDLE_STOP_HINT, hintShort: IDLE_STOP_HINT_SHORT } : {};
659
- const rendered = railwayLines({ nodes, now, width, rate: sampler.totalRate(now), scroll: railScroll, ...hint }, theme);
655
+ // Live-child gate: railway only renders when lanes exist; always show stop hint.
656
+ const hint = { hint: IDLE_STOP_HINT, hintShort: IDLE_STOP_HINT_SHORT };
657
+ const rendered = railwayLines({ nodes, now, width, rate: sampler.nowRate(now, nodes), scroll: railScroll, ...hint }, theme);
660
658
  // Persist the clamped window (anchored on the top lane's agent) so later
661
659
  // renders follow ends/starts without jumping; resets to 0 when all fit.
662
660
  railWindow = rendered.window;
@@ -666,10 +664,14 @@ export function createStatusBoard(options: {
666
664
  railWindow = undefined;
667
665
  const visible = visiblePanelEntries(entries);
668
666
  const totals = statusTotals(entries);
669
- const rows = formatWidgetRows(visible, now, totals);
667
+ let rows = formatWidgetRows(visible, now, totals);
668
+ // Tree strip + Legion identity: short stop hint on its own row (survives width squeeze).
669
+ if (strip === "tree" && hasTreeIdentity(entries) && rows.length > 0) {
670
+ rows = [rows[0]!, { text: IDLE_STOP_HINT_SHORT }, ...rows.slice(1)];
671
+ }
670
672
  const nodeIdsByRow = rows.map((row) => row.nodeId);
671
673
  return {
672
- lines: themeLines(formatWidgetLines(visible, now, totals), theme),
674
+ lines: themeLines(rows.map((row) => row.text), theme),
673
675
  hitAt: (_x, y) => nodeIdsByRow[y],
674
676
  };
675
677
  };
@@ -796,16 +798,12 @@ export function createStatusBoard(options: {
796
798
  if (hasTreeIdentity(next)) sampler.record(_now, nodesFromEntries(next));
797
799
  paint();
798
800
  },
799
- setIdleHint(on) {
800
- if (idleHint === on) return;
801
- idleHint = on;
802
- tui?.requestRender();
803
- },
804
801
  getTui() {
805
802
  return tui;
806
803
  },
807
804
  vizSnapshot(now = Date.now()) {
808
- return { nodes: nodesFromEntries(entries), sampler, now, rate: sampler.totalRate(now) };
805
+ const nodes = nodesFromEntries(entries);
806
+ return { nodes, sampler, now, rate: sampler.nowRate(now, nodes) };
809
807
  },
810
808
  clear() {
811
809
  entries = [];
@@ -87,6 +87,11 @@ function isWide(code: number): boolean {
87
87
  );
88
88
  }
89
89
 
90
+ /** Grapheme / cell count (UTF-16-safe). Prefer over `String.length` for layout budgets. */
91
+ export function cellWidth(value: string): number {
92
+ return Array.from(value).length;
93
+ }
94
+
90
95
  /** Truncate plain text to `width` single-width cells with an ellipsis. */
91
96
  export function ellipsize(value: string, width: number): string {
92
97
  const chars = Array.from(value).map(sanitizeChar);
@@ -1,6 +1,6 @@
1
1
  /** Panel renderers for the /legion full view. All drawing is clipped to its rect. */
2
2
 
3
- import { Canvas, ellipsize } from "./canvas.ts";
3
+ import { Canvas, cellWidth, ellipsize } from "./canvas.ts";
4
4
  import type { Rect } from "./layout.ts";
5
5
  import { formatCount, formatDuration, isAgent, type VizNode } from "./model.ts";
6
6
  import { nodeToken, roleColor } from "./palette.ts";
@@ -12,7 +12,7 @@ export interface ViewData {
12
12
  nodes: readonly VizNode[];
13
13
  sampler: RateSampler;
14
14
  now: number;
15
- /** Shared output tok/s (ΔtokensOut / ΔgenerationMs over trailing 60s). */
15
+ /** Header "now" tok/s (live agents only; 0 when none running). Chart avg/buckets keep full-series windows. */
16
16
  rate: number;
17
17
  }
18
18
 
@@ -241,21 +241,29 @@ export function drawGalaxy(cv: Canvas, r: Rect, data: ViewData, selectedId: stri
241
241
  }
242
242
  }
243
243
  // Legend: roles that actually appear (dynamic), then status hints.
244
+ // Reserve room for failed/retired glyphs first so a narrow panel still matches live scout (#102/#125).
244
245
  const ly = r.y + r.h - 1;
245
246
  let lx = r.x + 1;
246
247
  const roles: string[] = [];
247
248
  for (const node of dfsOrder(nodes)) if (isAgent(node) && !roles.includes(node.role)) roles.push(node.role);
249
+ const statuses = new Set(nodes.map((node) => node.status));
250
+ const statusItems = ([["failed", "✗ failed", "error"], ["retired", "⣿ retired", "dim"]] as const)
251
+ .filter(([status]) => statuses.has(status));
252
+ // Budget in grapheme cells (not String.length) so multi-byte / emoji labels do not squeeze roles (#128).
253
+ const statusBudget = statusItems.reduce((sum, [, label]) => sum + cellWidth(label) + 2, 0);
254
+ const roleLimit = r.x + r.w - 1 - statusBudget;
248
255
  for (const role of roles) {
249
- const w = Array.from(role).length + 4;
250
- if (lx + w > r.x + r.w - 1) break;
256
+ const w = cellWidth(role) + 4;
257
+ if (lx + w > roleLimit) break;
251
258
  cv.set(lx, ly, "⣿", roleColor(role));
252
259
  cv.text(lx + 2, ly, role, "dim");
253
260
  lx += w;
254
261
  }
255
- for (const [label, col] of [["✗ failed", "error"], ["⣿ retired", "dim"]] as const) {
256
- if (lx + label.length + 1 > r.x + r.w - 1) break;
262
+ for (const [, label, col] of statusItems) {
263
+ const lw = cellWidth(label);
264
+ if (lx + lw + 1 > r.x + r.w - 1) break;
257
265
  cv.text(lx, ly, label, col);
258
- lx += label.length + 2;
266
+ lx += lw + 2;
259
267
  }
260
268
  }
261
269
 
@@ -300,7 +308,7 @@ export function computeStats(data: ViewData, buckets: number): ChartStats {
300
308
  }
301
309
  return {
302
310
  now: data.rate,
303
- // Same definition as header "now", averaged over the chart window.
311
+ // Full-series window average (includes settled); differs from header "now" on purpose.
304
312
  avg: data.sampler.totalRate(to, Math.max(1, to - from)),
305
313
  peak,
306
314
  peakAt,
@@ -39,14 +39,14 @@ export interface RailInput {
39
39
  nodes: readonly VizNode[];
40
40
  now: number;
41
41
  width: number;
42
- /** Output tok/s over the shared 60s generation window. */
42
+ /** Header "now" tok/s (live agents only; 0 when none running). */
43
43
  rate: number;
44
44
  windowMs?: number;
45
45
  /** Lane scroll position when more agents are live than lanes fit. */
46
46
  scroll?: RailScroll;
47
- /** Dim hint on the header rule (e.g. stop controls while the main agent is idle). */
47
+ /** Dim hint on the header rule (e.g. `/legion stop` while children are live). */
48
48
  hint?: string;
49
- /** Shorter variant used when the full hint does not fit. */
49
+ /** Shorter variant used when the full hint does not fit; truncated further under squeeze. */
50
50
  hintShort?: string;
51
51
  }
52
52
 
@@ -234,18 +234,41 @@ export function renderRailway(input: RailInput): RailRender | undefined {
234
234
  const winLabel = ` −${Math.max(1, Math.round(windowMs / 60000))}m `;
235
235
  canvas.text(LX + 1, 0, winLabel, "dim");
236
236
  const range = above || below ? ` · ${scrollIndicator(current.offset, rows, live)} ${above ? "▲" : " "}${below ? "▼" : " "}` : "";
237
- const header = portrait
237
+ const headerFull = portrait
238
238
  ? ` ${live} live${range} · ${rateText} `
239
239
  : ` legion ${live} live${range} · ${rateText} `;
240
- const headerX = Math.max(LX + 1 + winLabel.length, width - header.length - 1);
241
- canvas.text(headerX, 0, header, "accent", { bold: true, maxX: width - 1 });
240
+ // Hint sits between the window label and the right-hand summary. Prefer the
241
+ // full string, then short; when width squeezes, shrink the summary so the
242
+ // short `/legion stop` still fits, and only then ellipsize the hint.
243
+ const labelEnd = LX + 1 + winLabel.length;
244
+ const hx = labelEnd + 1;
245
+ let header = headerFull;
246
+ let headerX = Math.max(labelEnd, width - Array.from(header).length - 1);
247
+ let hintPick: string | undefined;
242
248
  if (input.hint) {
243
- // Between the window label and the summary, with a rule gap on both sides.
244
- const hx = LX + 1 + winLabel.length + 1;
245
- const room = headerX - 1 - hx;
246
- const pick = [input.hint, input.hintShort].find((text) => text && Array.from(text).length + 2 <= room);
247
- if (pick) canvas.text(hx, 0, ` ${pick} `, "dim");
249
+ const short = input.hintShort ?? input.hint;
250
+ const shortBlock = Array.from(short).length + 2; // leading+trailing spaces
251
+ const gap = 1; // rule cell between hint and summary
252
+ const needHeaderX = hx + shortBlock + gap;
253
+ if (headerX < needHeaderX) {
254
+ // Pull/truncate the summary so short hint keeps a slot.
255
+ headerX = Math.min(width - 1, Math.max(needHeaderX, labelEnd));
256
+ const avail = Math.max(0, width - 1 - headerX);
257
+ header = avail >= 3 ? ` ${ellipsize(headerFull.trim(), avail - 2)} ` : "";
258
+ if (header) headerX = Math.max(labelEnd, width - Array.from(header).length - 1);
259
+ // Re-assert short reservation if summary still crowds the hint.
260
+ if (header && headerX < needHeaderX) {
261
+ headerX = needHeaderX;
262
+ const clipped = Math.max(0, width - 1 - headerX);
263
+ header = clipped >= 3 ? ` ${ellipsize(headerFull.trim(), clipped - 2)} ` : "";
264
+ }
265
+ }
266
+ const room = Math.max(0, (header ? headerX - 1 : width - 1) - hx);
267
+ hintPick = [input.hint, input.hintShort].find((text) => text && Array.from(text).length + 2 <= room);
268
+ if (!hintPick && room >= 3) hintPick = ellipsize(short, room - 2);
248
269
  }
270
+ if (header) canvas.text(headerX, 0, header, "accent", { bold: true, maxX: width - 1 });
271
+ if (hintPick) canvas.text(hx, 0, ` ${hintPick} `, "dim");
249
272
  for (const [c, tick] of headerTicks) {
250
273
  if (canvas.get(LX + c, 0)?.ch !== "─") continue; // never draw over header text
251
274
  canvas.set(LX + c, 0, "┬", tick.color);