pi-legion 2.0.1 → 2.0.3

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/CHANGELOG.md CHANGED
@@ -11,8 +11,37 @@ summary so you can see **2.x vs 0.16** without leaving the tree.
11
11
 
12
12
  ## [Unreleased]
13
13
 
14
+ ## [2.0.3] — 2026-10-15
15
+
16
+ ### Fixed
17
+
18
+ - `maxChildrenPerNode` only counts **live/running** children; `retire` (including
19
+ Already-retired no-op) frees the budget slot so a full tree can launch again
20
+ after retire. Session keep for resume does **not** occupy the concurrent slot
21
+ (#142 / #143).
22
+ - Header tok/s uses `MIN_RATE_GENERATION_MS=1000` (same floor as tree rows): when
23
+ the in-window generation delta is under 1s, fall back to wall-clock so tiny
24
+ `dGen` cannot explode rates into the millions (#140 / #141).
25
+
26
+ ## [2.0.2] — 2026-10-16
27
+
14
28
  ### Fixed
15
29
 
30
+ - Child worktree launches are suppressed when the parent checkout is itself a
31
+ linked git worktree (`git-dir` ≠ `git-common-dir`): role defaults and unset
32
+ launch flags collapse to `worktree: false`, matching the centurion-child
33
+ precedent. Explicit `launch: true` / per-child `step` flags still win.
34
+ - `worktreeBootstrap: "npm-install"` no longer fires `npm install` on a stray
35
+ `package.json` without a lockfile (observed: Java/Maven repos carrying
36
+ package.json for docs/tooling); without a lockfile the mode records the
37
+ marker and skips, so non-npm worktrees launch instead of failing after the
38
+ 10-minute bootstrap budget.
39
+
40
+ ### Fixed in 2.0.1 (inherited from prior entries)
41
+
42
+ - Railway / `/legion` header "now" tok/s is 0 when no agent is running; only live nodes count toward now (chart buckets still use the full trailing window) (#136).
43
+ - Narrow/stacked `/legion` header truncates long run + selection badges without overwriting `⚔ /legion`; galaxy legend reserves room for failed/retired glyphs before roles (#125).
44
+ - Galaxy legend `statusBudget` counts grapheme cells (not `String.length`); selection badge min width unified for portrait + landscape (#128).
16
45
  - Dispatched children now receive `--provider` pinned from the parent's model
17
46
  registry, so a bare model id (`workbuddy/…`) that also exists under a static
18
47
  auth-less provider in `models.json` can no longer hijack the child start
@@ -36,6 +65,8 @@ summary so you can see **2.x vs 0.16** without leaving the tree.
36
65
 
37
66
  ### Changed
38
67
 
68
+ - Esc no longer retires children; only `/legion stop` or session exit retires (esc-no-retire-v3, #134).
69
+ - Status strip shows a persistent `/legion stop` hint while children are live (railway + tree; width may truncate) (#134).
39
70
  - Prefer `PI_LEGION_EXTRA_AGENT_DIRS` over legacy `PI_HERDR_SUBAGENTS_*` (alias; new name wins if both set) (#109 / #114).
40
71
  - CONTRIBUTING/CI docs aligned with 2.x RPC; verify-12/13 paths point at `test/legacy/docker/` (#95 / #100, #107 / #112).
41
72
  - README: remove duplicate Child extensions heading (#94 / #98).
@@ -112,6 +143,9 @@ branch — pin `pi-legion@0.16.x` if you still need that model. See
112
143
  Earlier 0.11–0.15 history (including the `pi-herdr-swarm` /
113
144
  `@zzjcool/pi-herdr-subagents` renames) lives in git tags and older Releases.
114
145
 
115
- [Unreleased]: https://github.com/zzjcool/pi-legion/compare/v2.0.0...HEAD
146
+ [Unreleased]: https://github.com/zzjcool/pi-legion/compare/v2.0.3...HEAD
147
+ [2.0.3]: https://github.com/zzjcool/pi-legion/compare/v2.0.2...v2.0.3
148
+ [2.0.2]: https://github.com/zzjcool/pi-legion/compare/v2.0.1...v2.0.2
149
+ [2.0.1]: https://github.com/zzjcool/pi-legion/compare/v2.0.0...v2.0.1
116
150
  [2.0.0]: https://github.com/zzjcool/pi-legion/releases/tag/v2.0.0
117
151
  [0.16.x]: https://github.com/zzjcool/pi-legion/releases
package/README.md CHANGED
@@ -96,9 +96,9 @@ prints one node — see [Status](#status-legion-status).
96
96
 
97
97
  The `subagent` control actions include event-driven `wait` and graceful `stop`:
98
98
  `subagent({ action: "wait", name: "worker-0" })` returns as soon as the child
99
- settles (or the parent turn is interrupted with Esc). Aborting a parent turn
100
- with Esc gracefully retires its tracked children, including those being waited
101
- on or collected; the cascade is silent. Launch stays async by default. Under
99
+ settles (or the parent turn is interrupted with Esc). Esc only ends the parent
100
+ wait — children keep running and settle still notifies; retire them with
101
+ `/legion stop` or by exiting the session. Launch stays async by default. Under
102
102
  `pi --print` or JSON mode the process would otherwise exit at the end of the
103
103
  parent turn and retire those children before they write an assistant message.
104
104
  That shutdown waits for each live async child to settle, bounded by the
@@ -113,23 +113,20 @@ sequence for each selected child.
113
113
 
114
114
  ### Stopping async subagents yourself
115
115
 
116
- Async children keep running after the main turn ends, so Esc-abort no longer
117
- reaches them. Three user controls run the same graceful `stop` (a stopped
118
- parent retires its own subtree on shutdown):
116
+ Esc never retires children (see `docs/contracts/esc-no-retire-v3.md`). Two user
117
+ controls run graceful `stop` (a stopped parent retires its own subtree on
118
+ shutdown); process exit / session shutdown also retires:
119
119
 
120
120
  - **`/legion stop`** stops every live subagent; **`/legion stop <name>`** stops
121
121
  one (`all` is an alias for no name). Names complete from the live children;
122
122
  nested agents complete as `parent/name` and are reported as owned by their
123
123
  parent (stop the parent to retire them).
124
- - **Esc** while the main agent is idle, the editor is empty, no dialog or
125
- overlay is open and subagents are live asks "Stop N running subagents?".
126
- In every other state Esc keeps pi's normal behaviour.
127
124
  - **`x`** on the selected live agent in the `/legion` view or its live
128
125
  overlay asks `y` to confirm in place, then stops it.
129
126
 
130
- While the main agent is idle and subagents are live, the railway header shows
131
- a dim `esc stop all · /legion stop [name]` hint (shortened on narrow terminals,
132
- hidden while the main agent is working).
127
+ While subagents are live, the railway header can show a dim
128
+ `/legion stop [name|all]` hint (shortened on narrow terminals; idle gating /
129
+ tree-strip coverage is a TUI follow-up).
133
130
 
134
131
  ### Status (`/legion status`)
135
132
 
@@ -2,6 +2,8 @@
2
2
 
3
3
  > **v2 修订(2026-10-13)**:ESC / 父 turn abort 的语义已改为级联优雅关闭由该 turn 拥有的 children,推翻 v1 下文的「ESC ≠ 杀 children」决定。下文保留 v1 冻结原文,仅供历史考古;当前语义以 `docs/contracts/esc-cascade-v2.md` 为准。
4
4
 
5
+ > **v3 说明(2026-10-15)**:v3(`esc-no-retire-v3.md`)再次回到 v1 的「ESC ≠ 杀 children」原则;与 v1 不同的是保留 session_shutdown drain/归因与显式 `/legion stop`。v2 级联语义已推翻。
6
+
5
7
  ## 目标
6
8
 
7
9
  1. 主 pi 按 ESC(或 RPC abort)打断当前 turn 时,正在执行的 `subagent` 工具调用必须尽快感知并优雅收场,不能钉住事件循环。
@@ -1,5 +1,8 @@
1
1
  # ESC 级联关闭契约(v2,2026-10-13 冻结)
2
2
 
3
+ > **v3 推翻(2026-10-15)**:ESC / 父 turn abort **不再**级联 retire children。当前语义以 `docs/contracts/esc-no-retire-v3.md` 为准。下文保留 v2 冻结原文,仅供历史考古。
4
+
5
+
3
6
  ## 背景
4
7
 
5
8
  v1(esc-abort-v1.md,已随 e66c85f 合入)冻结了「ESC ≠ 杀 children」语义。用户裁决推翻:turn 是 children 的 owner,turn 被 abort 时 children 应级联关闭,否则是资源泄漏。
@@ -0,0 +1,57 @@
1
+ # ESC 不再 retire children(v3,2026-10-15 冻结)
2
+
3
+ ## 背景
4
+
5
+ v2(`esc-cascade-v2.md`)把「父 turn abort → 级联 retire children」冻结为产品语义。2026-10-15 实测事故:主会话异步派了多个 child 后用 `wait` 阻塞,用户按 Esc 打断父 turn,级联 retire 把整棵仍在生长的 legion 树抹掉(含尚未派生子 agent 的 centurion)。Esc 是高频误触键,后果不可恢复。
6
+
7
+ 用户裁决推翻 v2:**Esc 只打断父 turn 的等待,不杀 children;停止 subagent 只有两条路——显式 `/legion stop`,或主进程退出。**
8
+
9
+ ## 冻结决定
10
+
11
+ ### A. Esc ≠ retire
12
+
13
+ 1. **Idle Esc stop 删除**:不再有 `createIdleEscStop` / `onTerminalInput` 接线。裸 Esc 在 idle 时也不再 stop all。
14
+ 2. **父 turn abort 不再级联**:工具 execute 各 `signal?.aborted` 分支只返回 interrupted 文案,**不**调用 `cascadeRetireAll`。`cascadeRetireAll` / `cascadePromise` / `agent_settled{aborted}` 级联钩子全部删除。
15
+ 3. **`abandonLaunchedChild`**:spawn 后 abort 时 **不 retire**;`followLaunched(..., { watch: true })`,child 继续跑,settle 仍通知父会话。
16
+ 4. **`runtime.waitOne` WAIT_INTERRUPTED**:**必须 re-arm watch**(`consumedByTool = false` + `watch`),否则 child 完成后无人收割、无完成通知。
17
+
18
+ ### B. 中断文案
19
+
20
+ 统一为:
21
+
22
+ ```text
23
+ interrupted (parent turn ended; children still running)
24
+ ```
25
+
26
+ 不再使用 `interrupted (parent turn aborted)`(易误导为已 abort children)。
27
+
28
+ ### C. 保留的 retire 路径(不变)
29
+
30
+ - 显式 `stop` action / `/legion stop` / `/legion` 视图 `x`
31
+ - `session_shutdown` → `shutdownRetireAll`(交互立刻 retire;`--print` / JSON 先 `drainBeforeShutdown` 再 retire)
32
+ - SIGINT / 进程退出 = 主进程退出 = retire
33
+
34
+ ### D. TUI 提示
35
+
36
+ 有 live child 时,aboveEditor 状态栏常驻 `/legion stop [name|all]`(短版 `/legion stop`):railway header 在宽度挤压下可截断摘要以保短 hint;`PI_LEGION_STRIP=tree` 在 header 下另起一行短提示。无 idle 门控。
37
+
38
+ ## 语义边界
39
+
40
+ | 动作 | children |
41
+ |---|---|
42
+ | Esc 打断 wait / 父 turn | 继续跑;re-arm watch;settle 通知 |
43
+ | `/legion stop` / `stop` action | graceful retire |
44
+ | 主进程退出 / session_shutdown | drain(print/json)+ retire |
45
+ | SIGINT | 进程退出 → retire |
46
+
47
+ ## 与 v1 / v2 的关系
48
+
49
+ - **v1**(`esc-abort-v1.md`)主张「ESC ≠ 杀 children」——v3 回到这一原则,但保留 v1 没有的 drain / shutdown 归因与显式 stop。
50
+ - **v2**(`esc-cascade-v2.md`)级联 retire —— **v3 推翻**;原文保留供考古。
51
+
52
+ ## 验收锚点
53
+
54
+ - 无 `cascadeRetireAll` / idle Esc 接线
55
+ - wait abort → children still alive + watching;完成通知仍达
56
+ - launch abort mid-spawn → child watched, not retired
57
+ - print-shutdown / session_shutdown / stop 回归绿
package/docs/design.md CHANGED
@@ -933,12 +933,14 @@ subagent({
933
933
  | `resume` | **已退出后恢复**(F12) | 新 pane + `--session <file>` |
934
934
  | `retire` | 回收 | ctrl+d / pane close / tab close |
935
935
  | `status` / `collect` / `list` | 查询 | child record / session JSONL |
936
- | `wait` | 等待指定或全部活动 child 完成;由 RPC settle 事件唤醒;ESC 中断时级联优雅关闭 children 后返回 | session runtime + RPC supervisor |
936
+ | `wait` | 等待指定或全部活动 child 完成;由 RPC settle 事件唤醒;ESC 中断只结束等待(children 继续跑并 re-arm watch) | session runtime + RPC supervisor |
937
937
  | `stop` | 优雅停止指定或全部 tracked、非 retired children | `retire` 全链 + runtime release |
938
938
 
939
939
  ### ESC abort 语义 + stop
940
940
 
941
- 父 turn 的 ESC / RPC abort signal 会立即打断当前工具等待,并静默级联优雅关闭该 session 所拥有的 tracked children(包括 vexillatio utility children)。`wait` 返回 `interrupted (parent turn aborted)` 前先完成级联 retire;`collect` / inline collect 被中断时也会级联关闭 children,不生成显式 stop 的工具结果文案。级联先取消 completion watch,再并行 retire 并释放 runtime tracking;并发 abort 复用同一级联 promise。新的 turn 会建立新的级联周期。正常完成的 `wait` 仍由 RPC settle 事件立即解除,不需要等满 timeout。
941
+ > **v3(2026-10-15)**:见 `docs/contracts/esc-no-retire-v3.md`。ESC / 父 turn abort **不再**级联 retire。
942
+
943
+ 父 turn 的 ESC / RPC abort signal 会立即打断当前工具等待,但 **children 继续运行**;`wait` 返回 `interrupted (parent turn ended; children still running)` 并 re-arm watch,settle 仍通知父会话。显式 `/legion stop` / `stop` action,或主进程退出(`session_shutdown`,print/json 先 drain)才会 retire。正常完成的 `wait` 仍由 RPC settle 事件立即解除,不需要等满 timeout。
942
944
 
943
945
  `stop` 是显式停止操作:`subagent({ action: "stop", name: "worker-0" })` 停一个 child,`subagent({ action: "stop", all: true })` 逐个停止当前跟踪的全部非 retired children。它复用 `retire` 生命周期(优雅关闭、超时后升级信号、后代清理)并释放 runtime 跟踪;`stop` 本身不接父 turn abort signal。`name` 与 `all` 互斥且必须指定其一。
944
946
 
@@ -359,7 +359,7 @@ interface LegionEventBus {
359
359
  | 项 | 默认 | 判定 |
360
360
  |---|---|---|
361
361
  | `maxDepth`(全树深度) | 4 | 硬顶 = `MAX_NESTED_PATH_ENTRIES`(血缘路径在 4 处饱和;settings/env 超 4 截断——告警在 M1-integration 的 launch 路径接入,M0 阶段截断静默可接受)。每进程生效上限 C:根 `C = min(env 运营者覆盖, settings.legion.maxDepth, 4)`;深度 d、上限 C 的父 launch 角色 R:先过闸门 `d+1 > C` → BUDGET_EXCEEDED;再经 `PI_SUBAGENT_MAX_DEPTH` 下发 `C子 = min(C, d + R, 4)`(R 缺省=不收紧;非法值按叶子 R=1 处理;语义 = 子树代数含自身,位置无关,只收紧不放宽)。不变量:C子 ≤ C父,且每进程 depth ≤ maxDepth |
362
- | `maxChildrenPerNode`(单节点扇出) | 8 | 父 supervisor 查自己子节点数 |
362
+ | `maxChildrenPerNode`(单节点扇出) | 8 | 父 supervisor 查自己 **live** 子节点数(`starting`/`running`/`blocked`;`settled`/`failed`/`retired` 不占槽,session 可保留 resume) |
363
363
  | `maxActiveNodes`(全树并发 running+starting) | 30 | **全树真闸门**(替代只当 hint 的 maxConcurrentAgents):launch 前一条 SQL,超限 BUDGET_EXCEEDED + `budget_refused` 事件 |
364
364
 
365
365
  ### 11.2 心跳与孤儿:postman 循环每次刷新直接子节点 `updated_at`。根 supervisor 每 60s 扫描:非终态 + `updated_at` 超 90s = `orphan_detected` 事件 + mail 通知 CEO。恢复 = resume 链(CEO 裁决,auto-resume 默认关)。孤儿判定可判定化:**非终态 ∧ 无活跃进程 ∧ 心跳超时**。
package/index.ts CHANGED
@@ -42,7 +42,7 @@ import {
42
42
  loadSubagentSettings,
43
43
  resolveSubagentSettings,
44
44
  } from "./src/agents/settings.ts";
45
- import { createSessionRuntime, renderLiveChildStatus, type SessionRuntime, type TrackedJob, shouldRecycleAfterCollect, type WaitResult } from "./src/extension/runtime.ts";
45
+ import { createSessionRuntime, renderLiveChildStatus, type SessionRuntime, shouldRecycleAfterCollect, type WaitResult } from "./src/extension/runtime.ts";
46
46
  import { createDbEventSource, createLegionEventBus, type DbEventSource, type LegionEventBus } from "./src/extension/event-bus.ts";
47
47
  import { LegionPostman, registerLegionTools, type LegionMailCommand } from "./src/extension/legion-tools.ts";
48
48
  import { LEGION_DB_ENV, LEGION_NODE_ID_ENV } from "./src/runs/args.ts";
@@ -66,7 +66,6 @@ import {
66
66
  type RpcEventSource,
67
67
  confirmAndStopNode,
68
68
  } from "./src/extension/legion-commands.ts";
69
- import { createIdleEscStop, liveJobs } from "./src/extension/legion-stop.ts";
70
69
  import { DEFAULT_FLUSH_MS, DEFAULT_JOIN_MODE } from "./src/extension/join.ts";
71
70
  import { registerChildGuard } from "./src/extension/child-guard.ts";
72
71
  import { registerScoutGuard } from "./src/extension/scout-guard.ts";
@@ -102,10 +101,6 @@ import {
102
101
  SubagentError,
103
102
  } from "./src/shared/types.ts";
104
103
 
105
- // The parent turn owns its child lifetime. Parallel tool aborts in one turn
106
- // share this graceful-retire cascade; turn_start begins a fresh cycle.
107
- let cascadePromise: Promise<void> | undefined;
108
-
109
104
  // ---------------------------------------------------------------------------
110
105
  // Parameters
111
106
  // ---------------------------------------------------------------------------
@@ -481,7 +476,6 @@ async function resumeLegionChild(input: { pi: ExtensionAPI; supervisor: LegionSu
481
476
  }
482
477
 
483
478
  export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps = {}) {
484
- cascadePromise = undefined;
485
479
  const isChild = process.env.PI_SUBAGENT_CHILD === "1";
486
480
  const allowNested = process.env[ALLOW_NESTED_ENV] === "1";
487
481
  if (isChild) restoreHostNodeOptions();
@@ -592,49 +586,6 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
592
586
  ]);
593
587
  stopLegionRuntime();
594
588
  };
595
- let cascadingJobs = new Set<TrackedJob>();
596
- let cascadingUtilityRetires = new Set<() => Promise<void>>();
597
- const cascadeRetireAll = (): Promise<void> => {
598
- const jobs = (sessionRuntime?.activeJobs() ?? []).filter(
599
- (job) => job.state !== "retired" && !cascadingJobs.has(job),
600
- );
601
- const utilityRetires = pendingUtilityRetires.filter(
602
- (retire) => !cascadingUtilityRetires.has(retire),
603
- );
604
- if (!cascadePromise) cascadePromise = Promise.resolve();
605
- if (jobs.length === 0 && utilityRetires.length === 0) return cascadePromise;
606
- for (const job of jobs) {
607
- cascadingJobs.add(job);
608
- sessionRuntime?.cancelWatch(job.name);
609
- }
610
- for (const retire of utilityRetires) cascadingUtilityRetires.add(retire);
611
- const retirements = [
612
- ...jobs.map((job) => async () => job.retire?.()),
613
- ...utilityRetires.map((retire) => async () => retire()),
614
- ];
615
- cascadePromise = cascadePromise.then(async () => {
616
- // allSettled waits for every graceful retire without turning a child
617
- // failure into a user-visible result or skipping runtime release.
618
- await Promise.allSettled(retirements.map((retire) => retire()));
619
- for (const job of jobs) sessionRuntime?.release(job.name);
620
- });
621
- return cascadePromise;
622
- };
623
- // Register before the child early-return paths below: a command-chain child
624
- // may use vexillatio across multiple turns without creating a session runtime.
625
- pi.on("turn_start", () => {
626
- cascadePromise = undefined;
627
- cascadingJobs = new Set();
628
- cascadingUtilityRetires = new Set();
629
- });
630
- // An async launch has already returned when ESC lands: no subagent-family
631
- // tool call is in flight to observe the abort signal, so the run's settle
632
- // event is the last chance to retire this turn's children. Never awaited —
633
- // a graceful retire may take seconds and must not stall the settle path.
634
- pi.on("agent_settled", (event) => {
635
- if (!event.aborted) return;
636
- void cascadeRetireAll();
637
- });
638
589
  if (process.env[UTILITY_CHILD_ENV] !== "1") {
639
590
  pi.registerTool({
640
591
  name: "vexillatio",
@@ -643,8 +594,7 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
643
594
  parameters: VexillatioParams,
644
595
  async execute(_id, params, signal, _onUpdate, ctx: ExtensionContext): Promise<AgentToolResult<unknown>> {
645
596
  if (signal?.aborted) {
646
- await cascadeRetireAll();
647
- return ok("interrupted (parent turn aborted)");
597
+ return ok("interrupted (parent turn ended; children still running)");
648
598
  }
649
599
  if (!subagentsEnabled(ctx.cwd)) {
650
600
  return fail("subagents are disabled (subagents.enabled: false or /legion off). Re-enable with /legion on.", ErrorCodes.DISABLED);
@@ -673,8 +623,7 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
673
623
  onUtilityChild: (retire) => pendingUtilityRetires.push(retire),
674
624
  });
675
625
  if (signal?.aborted) {
676
- await cascadeRetireAll();
677
- return ok("interrupted (parent turn aborted)");
626
+ return ok("interrupted (parent turn ended; children still running)");
678
627
  }
679
628
  return result;
680
629
  },
@@ -874,8 +823,7 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
874
823
  runtime.bind(ctx);
875
824
  const action = params.action ?? "launch";
876
825
  if (signal?.aborted && action !== "stop") {
877
- await cascadeRetireAll();
878
- return ok("interrupted (parent turn aborted)");
826
+ return ok("interrupted (parent turn ended; children still running)");
879
827
  }
880
828
  // Master switch: refuse NEW launches with a pointer to the toggle;
881
829
  // control actions (status/collect/wait/…) must stay available so a
@@ -940,7 +888,7 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
940
888
  nodeId: job.nodeId,
941
889
  })),
942
890
  stopAvailable: true,
943
- idleEscWired: true,
891
+ idleEscWired: false,
944
892
  legionTools: ["subagent", "vexillatio"],
945
893
  allowNestedEnv: process.env[ALLOW_NESTED_ENV] === "1",
946
894
  },
@@ -960,8 +908,7 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
960
908
  return fail(`legion runtime initialization failed: ${error instanceof Error ? error.message : String(error)}`, ErrorCodes.INVALID_PARAMS);
961
909
  }
962
910
  if (signal?.aborted && action !== "stop") {
963
- await cascadeRetireAll();
964
- return ok("interrupted (parent turn aborted)");
911
+ return ok("interrupted (parent turn ended; children still running)");
965
912
  }
966
913
 
967
914
  // Control actions address an EXISTING child; launch-family actions
@@ -985,8 +932,7 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
985
932
  legion: activeLegion,
986
933
  });
987
934
  if (signal?.aborted && action !== "stop") {
988
- await cascadeRetireAll();
989
- return ok("interrupted (parent turn aborted)");
935
+ return ok("interrupted (parent turn ended; children still running)");
990
936
  }
991
937
  return result;
992
938
  }
@@ -1010,41 +956,13 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
1010
956
  onUpdate,
1011
957
  });
1012
958
  if (signal?.aborted) {
1013
- await cascadeRetireAll();
1014
- return ok("interrupted (parent turn aborted)");
959
+ return ok("interrupted (parent turn ended; children still running)");
1015
960
  }
1016
961
  return result;
1017
962
  },
1018
963
  });
1019
964
 
1020
- let lastCtx: ExtensionContext | undefined;
1021
- let escUi: ExtensionUIContext | undefined;
1022
- let escUnsubscribe: (() => void) | undefined;
1023
- const idleEscStop = createIdleEscStop({
1024
- isIdle: () => lastCtx?.isIdle?.() ?? false,
1025
- editorEmpty: () => !(lastUi?.getEditorText?.() ?? "").trim(),
1026
- uiBusy: () => {
1027
- const tui = runtime.statusTui() as { hasOverlay?: () => boolean; getFocusedComponent?: () => unknown } | undefined;
1028
- if (!tui) return true;
1029
- if (tui.hasOverlay?.()) return true;
1030
- const focused = tui.getFocusedComponent?.() as { getText?: unknown } | null | undefined;
1031
- // Only the prompt editor may have focus: dialogs/selectors keep pi's Esc.
1032
- return !focused || typeof focused.getText !== "function" || !("onEscape" in focused);
1033
- },
1034
- liveCount: () => liveJobs(runtime.activeJobs()).length,
1035
- confirm: (title, message) => lastUi ? lastUi.confirm(title, message) : Promise.resolve(false),
1036
- stopAll: async () => {
1037
- const result = await stopChildren({ all: true });
1038
- lastUi?.notify(result.text, result.ok ? "info" : "error");
1039
- },
1040
- });
1041
965
  const bindUi = (_event: unknown, ctx: ExtensionContext): void => {
1042
- lastCtx = ctx;
1043
- if (ctx.hasUI && ctx.ui !== escUi && typeof ctx.ui?.onTerminalInput === "function") {
1044
- escUnsubscribe?.();
1045
- escUi = ctx.ui;
1046
- escUnsubscribe = ctx.ui.onTerminalInput(idleEscStop);
1047
- }
1048
966
  lastUi = ctx.hasUI ? ctx.ui : undefined;
1049
967
  if (ctx.cwd) lastCwd = ctx.cwd;
1050
968
  runtime.bind(ctx);
@@ -1075,7 +993,6 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
1075
993
  });
1076
994
 
1077
995
  pi.on("session_start", (event, ctx) => {
1078
- runtime.setParentIdle(ctx.isIdle?.() ?? true);
1079
996
  bindUi(event, ctx);
1080
997
  });
1081
998
  pi.on("session_info_changed", bindUi);
@@ -1088,11 +1005,9 @@ export default function piLegion(pi: ExtensionAPI, deps: ExtensionSupervisorDeps
1088
1005
  bindUi(event, ctx);
1089
1006
  });
1090
1007
  pi.on("agent_start", (event, ctx) => {
1091
- runtime.setParentIdle(false);
1092
1008
  bindUi(event, ctx);
1093
1009
  });
1094
1010
  pi.on("agent_end", (event, ctx) => {
1095
- runtime.setParentIdle(true);
1096
1011
  bindUi(event, ctx);
1097
1012
  });
1098
1013
  pi.on("input", bindUi);
@@ -1367,13 +1282,13 @@ async function collectChild(
1367
1282
  signal?: AbortSignal,
1368
1283
  ): Promise<AgentToolResult<unknown>> {
1369
1284
  try {
1370
- if (signal?.aborted) return ok("interrupted (parent turn aborted)");
1285
+ if (signal?.aborted) return ok("interrupted (parent turn ended; children still running)");
1371
1286
  const pending = signal
1372
1287
  ? ctx.runtime.consumeCollectInterruptible(ctx.name, signal)
1373
1288
  : ctx.runtime.consumeCollect(ctx.name);
1374
1289
  if (pending) {
1375
1290
  const collected = await pending;
1376
- if (!collected || signal?.aborted) return ok("interrupted (parent turn aborted)");
1291
+ if (!collected || signal?.aborted) return ok("interrupted (parent turn ended; children still running)");
1377
1292
  await persistChild(ctx.store, ctx.found.runId, ctx.orchestrator, ctx.name, { preserveRetired: true });
1378
1293
  return ok(renderCollect(ctx.name, collected));
1379
1294
  }
@@ -1392,7 +1307,7 @@ async function collectChild(
1392
1307
  ...(signal ? { signal } : {}),
1393
1308
  });
1394
1309
  if (signal?.aborted || collected.execution.reason === "aborted by parent") {
1395
- return ok("interrupted (parent turn aborted)");
1310
+ return ok("interrupted (parent turn ended; children still running)");
1396
1311
  }
1397
1312
  const retireAlreadyFinishedJob = shouldRecycleAfterCollect(collected.execution.status) && !collected.blocked;
1398
1313
  await persistChild(ctx.store, ctx.found.runId, ctx.orchestrator, ctx.name, { preserveRetired: !retireAlreadyFinishedJob });
@@ -1602,7 +1517,7 @@ async function launchFamily(input: {
1602
1517
  for (const name of session.handles) await abandonLaunchedChild(session, name);
1603
1518
  session.handles.length = 0;
1604
1519
  return {
1605
- content: [{ type: "text", text: session.results.join("\n") || "interrupted (parent turn aborted)" }],
1520
+ content: [{ type: "text", text: session.results.join("\n") || "interrupted (parent turn ended; children still running)" }],
1606
1521
  details: { runId: run.runId, handles: [], async: params.async ?? true },
1607
1522
  };
1608
1523
  };
@@ -1913,7 +1828,7 @@ function resolveStep(
1913
1828
  async function collectInline(session: LaunchSession, signal?: AbortSignal): Promise<void> {
1914
1829
  for (const name of session.handles) {
1915
1830
  if (signal?.aborted) {
1916
- session.results.push("interrupted (parent turn aborted)");
1831
+ session.results.push("interrupted (parent turn ended; children still running)");
1917
1832
  return;
1918
1833
  }
1919
1834
  try {
@@ -1922,7 +1837,7 @@ async function collectInline(session: LaunchSession, signal?: AbortSignal): Prom
1922
1837
  ...(signal ? { signal } : {}),
1923
1838
  });
1924
1839
  if (signal?.aborted || collected.execution.reason === "aborted by parent") {
1925
- session.results.push("interrupted (parent turn aborted)");
1840
+ session.results.push("interrupted (parent turn ended; children still running)");
1926
1841
  return;
1927
1842
  }
1928
1843
  session.results.push(renderCollect(name, collected));
@@ -2265,7 +2180,7 @@ async function launchUtilityFamily(input: {
2265
2180
  return `✗ ${handle.name}: collect failed: ${error instanceof Error ? error.message : String(error)}\nsessionFile: ${handle.sessionFile}`;
2266
2181
  }
2267
2182
  }));
2268
- if (input.signal?.aborted) return ok("interrupted (parent turn aborted)");
2183
+ if (input.signal?.aborted) return ok("interrupted (parent turn ended; children still running)");
2269
2184
  results.push(...collected);
2270
2185
  return {
2271
2186
  content: [{ type: "text", text: results.join("\n\n") || "No utility agents were launched." }],
@@ -2583,18 +2498,10 @@ async function abandonLaunchedChild(session: LaunchSession, name: string): Promi
2583
2498
  session.runtime.release(name);
2584
2499
  return;
2585
2500
  }
2586
- child.execution = { status: "failed", reason: "launch abandoned because parent turn aborted" };
2587
- child.acceptance = { status: "rejected", level: "none", reason: "aborted by parent" };
2588
- try {
2589
- await session.orchestrator.retire(name);
2590
- await persistChild(session.store, session.runId, session.orchestrator, name);
2591
- session.runtime.release(name);
2592
- session.results.push(`✗ ${name}: abandoned because parent turn aborted (child retired)`);
2593
- } catch (error) {
2594
- await persistChild(session.store, session.runId, session.orchestrator, name);
2595
- followLaunched(session, name, { watch: true });
2596
- session.results.push(`✗ ${name}: abandon retire failed: ${String(error)}; completion remains watched`);
2597
- }
2501
+ // Esc / parent-turn abort must not retire: child keeps running; follow+watch
2502
+ // so settle still notifies the parent (esc-no-retire-v3).
2503
+ followLaunched(session, name, { watch: true });
2504
+ session.results.push(`▶ ${name}: parent turn ended; child still running (watched)`);
2598
2505
  }
2599
2506
 
2600
2507
  // ---------------------------------------------------------------------------
@@ -2647,7 +2554,7 @@ export function renderWait(results: WaitResult[]): string {
2647
2554
  parts.push(`── ${result.name} ──\nstill running`);
2648
2555
  } else if (result.interrupted) {
2649
2556
  interrupted += 1;
2650
- parts.push(`── ${result.name} ──\ninterrupted (parent turn aborted)`);
2557
+ parts.push(`── ${result.name} ──\ninterrupted (parent turn ended; children still running)`);
2651
2558
  } else {
2652
2559
  parts.push(`── ${result.name} ──\nnot tracked (no live job, no finished cache)`);
2653
2560
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-legion",
3
- "version": "2.0.1",
3
+ "version": "2.0.3",
4
4
  "description": "Pi Legion v2: recursive centurion trees on headless Pi RPC; shared SQLite WAL legion.db. Four-stage planning/implementing/reviewing/verifying state machine, parent/child/sibling legion_mail, three-layer /legion UI, upward MR trees, squad resume.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -519,7 +519,7 @@ function buildDoctorReport(
519
519
  : undefined,
520
520
  })),
521
521
  stopAvailable: Boolean(deps.stop),
522
- idleEscWired: true,
522
+ idleEscWired: false,
523
523
  legionTools: ["subagent", "vexillatio"],
524
524
  allowNestedEnv: process.env[ALLOW_NESTED_ENV] === "1",
525
525
  };
@@ -809,7 +809,7 @@ export function runLegionDoctor(options: DoctorOptions = {}): DoctorReport {
809
809
  for (const line of LEGION_SLASH_HELP_LINES) push(` ${line}`);
810
810
  push(`- stop wired: ${runtime.stopAvailable === undefined ? "(unknown)" : runtime.stopAvailable ? "yes" : "no"}`);
811
811
  push(`- idle Esc stop wired: ${runtime.idleEscWired === undefined ? "(unknown)" : runtime.idleEscWired ? "yes" : "no"}`);
812
- push(`- idle strip hints: "${IDLE_STOP_HINT}" / short "${IDLE_STOP_HINT_SHORT}"`);
812
+ push(`- stop strip hints: "${IDLE_STOP_HINT}" / short "${IDLE_STOP_HINT_SHORT}"`);
813
813
  push(`- legion tools registered: ${(runtime.legionTools ?? ["(unknown — typically subagent, vexillatio, legion-* mail/tools)"]).join(", ")}`);
814
814
  push("- slash: `/legion doctor [section]`");
815
815
  push("- tool: `subagent({ action: \"diagnose\", section? })` — optional section filter (same as `/legion doctor [section]`)");
@@ -3,10 +3,9 @@
3
3
  * - `/legion stop` (all) / `/legion stop <name>` — thin wrapper over the
4
4
  * subagent tool's `stop` action (graceful retire; a retired parent's own
5
5
  * session shutdown retires its subtree, so stopping a parent cascades).
6
- * - Esc while the main agent is idle, the editor is empty and children are
7
- * live → confirm → stop all.
8
6
  * - `x` on the selected agent in the /legion view → confirm → stop it.
9
- * Everything here is pure/injectable so it can be unit tested.
7
+ * Esc no longer stops children (esc-no-retire-v3); only `/legion stop` or
8
+ * process exit retires. Everything here is pure/injectable for unit tests.
10
9
  */
11
10
 
12
11
  export interface StopJob {
@@ -114,37 +113,6 @@ export async function runLegionStop(arg: string, deps: StopDeps): Promise<string
114
113
  return say(result.text, result.ok ? "info" : "error");
115
114
  }
116
115
 
117
- export interface IdleEscDeps {
118
- /** Main agent not streaming / running a turn. */
119
- isIdle(): boolean;
120
- editorEmpty(): boolean;
121
- /** An overlay, dialog or non-editor component has focus. */
122
- uiBusy(): boolean;
123
- liveCount(): number;
124
- confirm(title: string, message: string): Promise<boolean>;
125
- stopAll(): Promise<void>;
126
- }
127
-
128
- /**
129
- * Raw terminal input hook: consumes a bare Esc only when the main agent is
130
- * idle, the editor is empty, nothing else owns focus and children are live.
131
- * Every other key (and Esc in any other state) passes through to pi.
132
- */
133
- export function createIdleEscStop(deps: IdleEscDeps): (data: string) => { consume: true } | undefined {
134
- let prompting = false;
135
- return (data) => {
136
- if (data !== "\x1b" || prompting) return undefined;
137
- const count = deps.liveCount();
138
- if (count === 0 || !deps.isIdle() || !deps.editorEmpty() || deps.uiBusy()) return undefined;
139
- prompting = true;
140
- void deps.confirm("Stop subagents", `Stop ${count} running subagent${count === 1 ? "" : "s"}?`)
141
- .then((yes) => (yes ? deps.stopAll() : undefined))
142
- .catch(() => {})
143
- .finally(() => { prompting = false; });
144
- return { consume: true };
145
- };
146
- }
147
-
148
- /** Dim hint shown in the railway header while the main agent is idle and children are live. */
149
- export const IDLE_STOP_HINT = "esc stop all · /legion stop [name]";
150
- export const IDLE_STOP_HINT_SHORT = "esc stop all";
116
+ /** Dim hint shown in the status strip while children are live (aboveEditor / tree). */
117
+ export const IDLE_STOP_HINT = "/legion stop [name|all]";
118
+ export const IDLE_STOP_HINT_SHORT = "/legion stop";
@@ -10,7 +10,7 @@ export const PARENT_PLAYBOOK = [
10
10
  ' One child: subagent({ agent: "<role>", task: "...", name?: "scout-a" }) // optional name= registers that handle (not auto scout-N)',
11
11
  " Parallel: subagent({ tasks: [{ agent, task }, { agent, task }] })",
12
12
  "2. Prefer tasks[] for independent parallel work. Child processes are headless RPC children; no pane or tab is created.",
13
- "3. Then return control. A completion message wakes this session when idle; if it is still working, the notice waits until the current turn ends. Use `wait` when you need blocking results, and `collect` to inspect a child's session JSONL. Pressing Esc to abort a parent turn gracefully retires its children; a wait interrupted by Esc returns `interrupted (parent turn aborted)` after cascade shutdown. In `pi --print` or JSON mode, session shutdown waits for live async children to settle (up to each child's turn timeout) before retiring them. Use same-turn `wait`/`collect` or `async: false` when that print turn must include the child's result.",
13
+ "3. Then return control. A completion message wakes this session when idle; if it is still working, the notice waits until the current turn ends. Use `wait` when you need blocking results, and `collect` to inspect a child's session JSONL. Esc / wait interrupt only ends the parent turn's wait — children keep running and settle still notifies; only an explicit `stop` or session exit retires them. A wait interrupted by Esc returns `interrupted (parent turn ended; children still running)`. In `pi --print` or JSON mode, session shutdown waits for live async children to settle (up to each child's turn timeout) before retiring them. Use same-turn `wait`/`collect` or `async: false` when that print turn must include the child's result.",
14
14
  "4. Later control is only through `subagent({ action, name, message })`; use `stop` to gracefully retire one child or all tracked children. If a child exits oddly (Model not found, missing tools, memory refused, …), call `subagent({ action: \"diagnose\" })` or ask the user to run `/legion doctor` / `/legion doctor models` — children use `--no-extensions`, so provider packages must be passed explicitly. Do not tell a child to message or prompt the parent directly.",
15
15
  "5. A collect timeout with a live RPC child is a progress signal, not a verdict. Check status or session JSONL before deciding to steer or resume.",
16
16
  "6. Isolation is YOUR call. Pass `worktree: true` when another parent may write this repo or the child must ship via MR; pass `worktree: false` to share the checkout. Omit it for the role default.",
@@ -23,7 +23,7 @@ export const TOOL_DESCRIPTION = [
23
23
  PARENT_PLAYBOOK,
24
24
  "Roles: the system prompt lists every loaded role. Prefer the matching role over doing that work yourself.",
25
25
  "Launch is async by default. Outcomes come from the child session JSONL. In non-interactive `pi --print` / JSON mode, shutdown waits for those children before retiring them; pass `async: false` or call `wait`/`collect` in the same turn to fold the result into the print output.",
26
- "Actions: launch (default), continue, steer, resume, status, collect, wait, list, retire, stop, diagnose. `diagnose` prints the Legion doctor report (host vs child extensions/providers/models/memory). `wait` is event-driven: it returns when a child settles, times out, or the parent turn is aborted; an Esc abort also gracefully retires children owned by that turn. `stop` explicitly and gracefully retires selected children.",
26
+ "Actions: launch (default), continue, steer, resume, status, collect, wait, list, retire, stop, diagnose. `diagnose` prints the Legion doctor report (host vs child extensions/providers/models/memory). `wait` is event-driven: it returns when a child settles, times out, or the parent turn is interrupted; Esc does not retire children — use `stop` (or exit the session) to retire them.",
27
27
  ].join(" ");
28
28
 
29
29
  /** Split shell commands into sequential chunks while preserving old helper API. */
@@ -300,7 +300,7 @@ export interface WaitResult {
300
300
  snapshot?: CollectSnapshot;
301
301
  /** Still running when the timeout fired. */
302
302
  stillRunning?: boolean;
303
- /** The parent turn aborted while this wait was pending. */
303
+ /** The parent turn ended while this wait was pending; children still running. */
304
304
  interrupted?: boolean;
305
305
  /** Not tracked live and no finished cache (pre-validated upstream). */
306
306
  missing?: boolean;
@@ -339,8 +339,6 @@ export interface SessionRuntime {
339
339
  refreshUi(): void;
340
340
  /** Live visualization data (nodes + token-rate sampler) for the /legion view. */
341
341
  vizSnapshot(): StatusVizSnapshot;
342
- /** Main agent idle flag for the railway stop hint. */
343
- setParentIdle(idle: boolean): void;
344
342
  /** TUI hosting the status widget (unknown shape; used for overlay/focus checks). */
345
343
  statusTui(): unknown;
346
344
  /** Forward join config to the internal coordinator (settings wiring). */
@@ -709,9 +707,10 @@ export function createSessionRuntime(deps: SessionRuntimeDeps): SessionRuntime {
709
707
  }
710
708
  const outcome = await Promise.race(racers);
711
709
  if (outcome === WAIT_INTERRUPTED) {
712
- // The session-level abort cascade retires this job, so do not arm
713
- // a completion watcher for a child that is about to be closed.
710
+ // Esc / parent-turn abort does not retire children (esc-no-retire-v3).
711
+ // Re-arm watch so settle still notifies the parent.
714
712
  job.consumedByTool = false;
713
+ if (!job.watching) runtime.watch(name);
715
714
  return { name, interrupted: true };
716
715
  }
717
716
  const snapshot = outcome;
@@ -1065,9 +1064,6 @@ export function createSessionRuntime(deps: SessionRuntimeDeps): SessionRuntime {
1065
1064
  return [...jobs.values()];
1066
1065
  },
1067
1066
 
1068
- setParentIdle(idle) {
1069
- board.setIdleHint?.(idle);
1070
- },
1071
1067
  statusTui() {
1072
1068
  return board.getTui?.();
1073
1069
  },
@@ -1,4 +1,5 @@
1
1
  import type { DatabaseSync } from "./db.ts";
2
+ import { LIVE_CHILD_STATUSES } from "./nodes.ts";
2
3
  import { withTransaction } from "./db.ts";
3
4
  import { appendEvent } from "./events.ts";
4
5
 
@@ -64,14 +65,15 @@ export function checkLaunchBudget(
64
65
  ): BudgetDecision {
65
66
  validateRequest(request);
66
67
  const limits = normalizeLimits(options.limits);
68
+ const liveStatuses = LIVE_CHILD_STATUSES.map(() => "?").join(", ");
67
69
  const row = db
68
70
  .prepare(
69
71
  `SELECT
70
- (SELECT COUNT(*) FROM nodes WHERE parent_id IS ?) AS child_count,
72
+ (SELECT COUNT(*) FROM nodes WHERE parent_id IS ? AND status IN (${liveStatuses})) AS child_count,
71
73
  (SELECT COUNT(*) FROM nodes WHERE status IN ('running', 'starting')) AS active_count,
72
74
  CASE WHEN ? IS NULL THEN 1 ELSE EXISTS(SELECT 1 FROM nodes WHERE id = ?) END AS parent_exists`,
73
75
  )
74
- .get(request.parentId, request.parentId, request.parentId);
76
+ .get(request.parentId, ...LIVE_CHILD_STATUSES, request.parentId, request.parentId);
75
77
  const counts = {
76
78
  children: Number(row?.child_count ?? 0),
77
79
  activeNodes: Number(row?.active_count ?? 0),
@@ -86,7 +88,7 @@ export function checkLaunchBudget(
86
88
  if (counts.children >= limits.maxChildrenPerNode) {
87
89
  return denied(
88
90
  "max_children_per_node",
89
- `parent already has ${counts.children} children; maxChildrenPerNode is ${limits.maxChildrenPerNode}`,
91
+ `parent already has ${counts.children} live children; maxChildrenPerNode is ${limits.maxChildrenPerNode}`,
90
92
  base,
91
93
  );
92
94
  }
@@ -21,6 +21,13 @@ export const NON_TERMINAL_NODE_STATUSES = [
21
21
  "retired",
22
22
  ] as const satisfies readonly NodeStatus[];
23
23
 
24
+ /** Statuses that occupy a maxChildrenPerNode concurrency slot (not retired/session-only). */
25
+ export const LIVE_CHILD_STATUSES = [
26
+ "starting",
27
+ "running",
28
+ "blocked",
29
+ ] as const satisfies readonly NodeStatus[];
30
+
24
31
  export interface LegionNode {
25
32
  id: string;
26
33
  parentId: string | null;
@@ -18,7 +18,7 @@ import type { RunStore } from "./store.ts";
18
18
  import type { LegionEventBus, LegionProcessEventType } from "../extension/event-bus.ts";
19
19
  import { appendEvent } from "../legion/events.ts";
20
20
  import { withLaunchBudget, type LegionBudgetLimits } from "../legion/budget.ts";
21
- import { getChildByName, getNode, insertNode, nodeIdForChild, sanitizeNodeSlug, type LegionNode, updateNode } from "../legion/nodes.ts";
21
+ import { getChildByName, getNode, insertNode, nodeIdForChild, sanitizeNodeSlug, type LegionNode, updateNode, LIVE_CHILD_STATUSES } from "../legion/nodes.ts";
22
22
  import { upsertUsage } from "../legion/usage.ts";
23
23
  import { evaluatePhaseTransition, isLegionPhase, isTerminalPhase } from "../legion/state-machine.ts";
24
24
  import { CHILD_ACCEPTANCE_ROLE_ENV, CHILD_ROLE_ENV } from "../extension/child-guard.ts";
@@ -64,6 +64,7 @@ import { nativeModelFor } from "./kind.ts";
64
64
  import { applyVerification, type VerifyRunner } from "./acceptance.ts";
65
65
  import {
66
66
  createChildWorktree,
67
+ isInsideChildWorktree,
67
68
  removeChildWorktree,
68
69
  resolveLaunchWorktree,
69
70
  } from "./worktree.ts";
@@ -340,7 +341,8 @@ export class Orchestrator {
340
341
  const depthCeiling = input.utility
341
342
  ? parent.depth + 1
342
343
  : Math.min(legion.settings?.maxDepth ?? this.maxDepthValue, MAX_NESTED_PATH_ENTRIES);
343
- const childCount = Number(legion.db.prepare("SELECT COUNT(*) AS count FROM nodes WHERE parent_id = ?").get(legion.nodeId)?.count ?? 0);
344
+ const liveStatuses = LIVE_CHILD_STATUSES.map(() => "?").join(", ");
345
+ const childCount = Number(legion.db.prepare(`SELECT COUNT(*) AS count FROM nodes WHERE parent_id = ? AND status IN (${liveStatuses})`).get(legion.nodeId, ...LIVE_CHILD_STATUSES)?.count ?? 0);
344
346
  const maxChildren = previous ? Math.max(legion.settings?.maxChildrenPerNode ?? 8, childCount + 1) : legion.settings?.maxChildrenPerNode;
345
347
  const activeReservation = previous && (previous.status === "starting" || previous.status === "running") ? 1 : 0;
346
348
  const limits: LegionBudgetLimits = { maxDepth: depthCeiling, maxChildrenPerNode: maxChildren, maxActiveNodes: (legion.settings?.maxActiveNodes ?? 30) + activeReservation };
@@ -535,7 +537,11 @@ export class Orchestrator {
535
537
  ? true
536
538
  : this.legion && parentRole === "centurion"
537
539
  ? false
538
- : resolveLaunchWorktree({ roleDefault: input.agent.worktree, launch: input.worktree });
540
+ : resolveLaunchWorktree({
541
+ roleDefault: input.agent.worktree,
542
+ launch: input.worktree,
543
+ parentInWorktree: isInsideChildWorktree(this.cwd),
544
+ });
539
545
  let worktreePath = previous?.worktreePath;
540
546
  let worktreeBranch = previous?.worktreeBranch;
541
547
  let createdWorktree = false;
@@ -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
 
@@ -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");
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,23 +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
- const statuses = new Set(nodes.map((node) => node.status));
256
- for (const [status, label, col] of [["failed", "✗ failed", "error"], ["retired", "⣿ retired", "dim"]] as const) {
257
- if (!statuses.has(status)) continue;
258
- 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;
259
265
  cv.text(lx, ly, label, col);
260
- lx += label.length + 2;
266
+ lx += lw + 2;
261
267
  }
262
268
  }
263
269
 
@@ -302,7 +308,7 @@ export function computeStats(data: ViewData, buckets: number): ChartStats {
302
308
  }
303
309
  return {
304
310
  now: data.rate,
305
- // Same definition as header "now", averaged over the chart window.
311
+ // Full-series window average (includes settled); differs from header "now" on purpose.
306
312
  avg: data.sampler.totalRate(to, Math.max(1, to - from)),
307
313
  peak,
308
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);
@@ -6,17 +6,29 @@
6
6
  *
7
7
  * numerator = Δ cumulative output tokens over the trailing window
8
8
  * denominator = Δ cumulative generationMs over the same window
9
- * (falls back to wall-clock window only when generationMs
10
- * is unavailable / zero for that node)
9
+ * (falls back to wall-clock window when generationMs is
10
+ * unavailable / zero, or when in-window ΔgenerationMs is
11
+ * below MIN_RATE_GENERATION_MS — same 1s floor as tree t/s)
11
12
  *
12
13
  * generationMs comes from session JSONL: sum of (assistant_ts − prior
13
14
  * user/toolResult_ts). Idle and tool gaps are excluded. Approximate —
14
15
  * usage lands when each assistant message completes, not mid-stream.
16
+ *
17
+ * Header / snap "now" (`nowRate` / `totalRate` with live node ids) only sums
18
+ * agents that are still `running`. Settled/retired series keep contributing to
19
+ * chart buckets/avg so the history window stays accurate; once nothing is live,
20
+ * "now" is forced to 0 even if the 60s window still holds Δtokens.
15
21
  */
16
22
 
17
23
  import type { VizNode } from "./model.ts";
18
24
 
19
25
  export const RATE_WINDOW_MS = 60_000;
26
+ /**
27
+ * Min in-window ΔgenerationMs before trusting gen as the tok/s denominator.
28
+ * Matches tree-row t/s gate (`generationMs >= 1000` in status.ts). Below this,
29
+ * fall back to wall-clock window length so a 1ms gen flush cannot spike to M.
30
+ */
31
+ export const MIN_RATE_GENERATION_MS = 1_000;
20
32
  const DEFAULT_RETENTION_MS = 6 * 60 * 60 * 1000;
21
33
  const MAX_SAMPLES_PER_NODE = 4096;
22
34
 
@@ -125,24 +137,39 @@ export class RateSampler {
125
137
 
126
138
  /**
127
139
  * Output tok/s over the trailing window: ΔtokensOut / (ΔgenerationMs/1000).
128
- * When the node has no generationMs samples in the window, falls back to
129
- * wall-clock window length so a ledger-only path still yields a number.
140
+ * When in-window ΔgenerationMs is 0 or below MIN_RATE_GENERATION_MS, falls
141
+ * back to wall-clock window length so a ledger-only path still yields a
142
+ * number and a sub-second gen flush cannot report M-level TPS.
130
143
  */
131
144
  nodeRate(nodeId: string, now: number, windowMs = RATE_WINDOW_MS): number {
132
145
  if (windowMs <= 0) return 0;
133
146
  const dOut = Math.max(0, this.valueAt(nodeId, now) - this.valueAt(nodeId, now - windowMs));
134
147
  if (dOut <= 0) return 0;
135
148
  const dGen = Math.max(0, this.genAt(nodeId, now) - this.genAt(nodeId, now - windowMs));
136
- const seconds = dGen > 0 ? dGen / 1000 : windowMs / 1000;
149
+ const seconds = dGen >= MIN_RATE_GENERATION_MS ? dGen / 1000 : windowMs / 1000;
137
150
  return dOut / seconds;
138
151
  }
139
152
 
140
- totalRate(now: number, windowMs = RATE_WINDOW_MS): number {
153
+ /**
154
+ * Sum of node rates. When `onlyNodeIds` is set (including empty), only those
155
+ * series contribute — used for header "now". Omit it for chart buckets/avg
156
+ * so settled agents still paint their trailing-window history.
157
+ */
158
+ totalRate(now: number, windowMs = RATE_WINDOW_MS, onlyNodeIds?: Iterable<string>): number {
159
+ const filter = onlyNodeIds === undefined ? undefined : new Set(onlyNodeIds);
141
160
  let sum = 0;
142
- for (const id of this.series.keys()) sum += this.nodeRate(id, now, windowMs);
161
+ for (const id of this.series.keys()) {
162
+ if (filter && !filter.has(id)) continue;
163
+ sum += this.nodeRate(id, now, windowMs);
164
+ }
143
165
  return sum;
144
166
  }
145
167
 
168
+ /** Header / snap "now" tok/s: live (running) agents only; none live → 0. */
169
+ nowRate(now: number, nodes: readonly VizNode[], windowMs = RATE_WINDOW_MS): number {
170
+ return this.totalRate(now, windowMs, liveRateNodeIds(nodes));
171
+ }
172
+
146
173
  nodeIds(): string[] {
147
174
  return [...this.series.keys()];
148
175
  }
@@ -150,8 +177,8 @@ export class RateSampler {
150
177
  /**
151
178
  * Bucketed tok/s for [from, to], stacked by role. Each bucket is the
152
179
  * trailing-window average ending at the bucket edge (window = max(bucket
153
- * span, RATE_WINDOW_MS)), so the last bucket equals the header "now" and
154
- * bursty per-turn token flushes do not render as spikes.
180
+ * span, RATE_WINDOW_MS)), including settled series so history stays painted.
181
+ * Header "now" may be lower (live-only); bursty flushes stay smoothed.
155
182
  */
156
183
  buckets(from: number, to: number, count: number, windowMs = RATE_WINDOW_MS): RateSeries {
157
184
  const n = Math.max(1, Math.floor(count));
@@ -191,6 +218,11 @@ export class RateSampler {
191
218
  }
192
219
  }
193
220
 
221
+ /** Node ids that contribute to header "now" tok/s (running agents; root excluded). */
222
+ export function liveRateNodeIds(nodes: readonly VizNode[]): string[] {
223
+ return nodes.filter((n) => n.id !== "root" && n.status === "running").map((n) => n.id);
224
+ }
225
+
194
226
  function interpolate(ts: number[], value: number[], t: number): number {
195
227
  if (ts.length === 0) return 0;
196
228
  if (t <= ts[0]!) return value[0]!;
@@ -1,7 +1,7 @@
1
1
  /** /legion full view: galaxy + tok/s chart + git-log history with linked selection. */
2
2
 
3
3
  import { Key, matchesKey } from "@earendil-works/pi-tui";
4
- import { Canvas, ellipsize } from "./canvas.ts";
4
+ import { Canvas, cellWidth, ellipsize } from "./canvas.ts";
5
5
  import { computeLayout, type LayoutKind } from "./layout.ts";
6
6
  import { formatCount, formatDuration, isAgent, type VizNode } from "./model.ts";
7
7
  import { roleColor, type VizTheme } from "./palette.ts";
@@ -22,6 +22,9 @@ export function initialViewState(): LegionViewState {
22
22
 
23
23
  const PANES: Pane[] = ["galaxy", "chart", "log"];
24
24
 
25
+ /** Min grapheme cells before painting a selection badge. Shared by portrait + landscape so a truncated stub never paints (#128). Was portrait `>4` vs landscape `>0`. */
26
+ export const MIN_SELECTION_BADGE_W = 5;
27
+
25
28
  export function renderLegionView(data: ViewData, state: LegionViewState, cols: number, rows: number): { canvas: Canvas; layout: LayoutKind } {
26
29
  const W = Math.max(1, Math.floor(cols));
27
30
  const H = Math.max(6, Math.floor(rows));
@@ -44,21 +47,25 @@ export function renderLegionView(data: ViewData, state: LegionViewState, cols: n
44
47
  `${agents.length} agents`,
45
48
  ...(failed ? [`${failed} failed`] : []),
46
49
  ];
47
- const badge = selected ? ` ${selected.name} (${selected.role} · ${statusLabel(selected)}) ` : "";
50
+ const badgeRaw = selected ? ` ${selected.name} (${selected.role} · ${statusLabel(selected)}) ` : "";
51
+ // Cap the selection badge so a long agent name never overwrites "⚔ /legion" / the run name.
52
+ const badgeCap = Math.max(0, Math.min(cellWidth(badgeRaw), Math.floor(W * 0.45), W - 14));
53
+ const badge = badgeRaw ? ellipsize(badgeRaw, badgeCap) : "";
54
+ const badgeW = cellWidth(badge);
55
+ const showBadge = badgeW >= MIN_SELECTION_BADGE_W;
48
56
  if (portrait) {
49
57
  cv.text(0, 0, "⚔ /legion ", "accent", { bold: true });
50
- cv.text(10, 0, ellipsize(root?.name ?? "", Math.max(0, W - 10)), "text", { bold: true });
58
+ const rootRoom = Math.max(0, W - 10 - (showBadge ? badgeW + 1 : 0));
59
+ cv.text(10, 0, ellipsize(root?.name ?? "", rootRoom), "text", { bold: true });
51
60
  cv.text(0, 1, ellipsize(parts.join(" · "), W), "dim");
52
- if (badge) {
53
- const b = ellipsize(badge, Math.max(0, W - 12 - Array.from(root?.name ?? "").length));
54
- if (b.length > 4) cv.text(W - Array.from(b).length, 0, b, roleColor(selected!.role), { inverse: true });
55
- }
61
+ if (showBadge) cv.text(W - badgeW, 0, badge, roleColor(selected!.role), { inverse: true });
56
62
  } else {
57
63
  let x = cv.text(0, 0, "⚔ /legion ", "accent", { bold: true });
58
- x = cv.text(x, 0, `${root?.name ?? "legion"} `, "text", { bold: true });
59
- const badgeW = Array.from(badge).length;
60
- cv.text(x, 0, ellipsize(parts.join(" · "), Math.max(0, W - x - badgeW - 1)), "dim");
61
- if (badge) cv.text(Math.max(0, W - badgeW), 0, ellipsize(badge, W), roleColor(selected!.role), { inverse: true });
64
+ const reservedBadge = showBadge ? badgeW : 0;
65
+ const rootRoom = Math.max(0, W - x - reservedBadge - 1);
66
+ x = cv.text(x, 0, ellipsize(`${root?.name ?? "legion"} `, rootRoom), "text", { bold: true });
67
+ cv.text(x, 0, ellipsize(parts.join(" · "), Math.max(0, W - x - reservedBadge - 1)), "dim");
68
+ if (showBadge) cv.text(W - badgeW, 0, badge, roleColor(selected!.role), { inverse: true });
62
69
  }
63
70
 
64
71
  const galaxyInner = frame(cv, layout.galaxy, portrait ? "galaxy" : "galaxy · structure", state.focus === "galaxy");