pi-legion 2.0.1 → 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.
- package/CHANGELOG.md +19 -0
- package/README.md +9 -12
- package/docs/contracts/esc-abort-v1.md +2 -0
- package/docs/contracts/esc-cascade-v2.md +3 -0
- package/docs/contracts/esc-no-retire-v3.md +57 -0
- package/docs/design.md +4 -2
- package/index.ts +20 -113
- package/package.json +1 -1
- package/src/extension/legion-commands.ts +1 -1
- package/src/extension/legion-doctor.ts +1 -1
- package/src/extension/legion-stop.ts +5 -37
- package/src/extension/playbook.ts +2 -2
- package/src/extension/runtime.ts +4 -8
- package/src/runs/orchestrator.ts +6 -1
- package/src/runs/provision.ts +7 -4
- package/src/runs/worktree.ts +32 -0
- package/src/tui/status.ts +12 -14
- package/src/tui/viz/canvas.ts +5 -0
- package/src/tui/viz/panels.ts +16 -10
- package/src/tui/viz/railway.ts +34 -11
- package/src/tui/viz/rate.ts +41 -9
- package/src/tui/viz/view.ts +18 -11
package/CHANGELOG.md
CHANGED
|
@@ -11,8 +11,25 @@ summary so you can see **2.x vs 0.16** without leaving the tree.
|
|
|
11
11
|
|
|
12
12
|
## [Unreleased]
|
|
13
13
|
|
|
14
|
+
## [2.0.2] — 2026-10-16
|
|
15
|
+
|
|
14
16
|
### Fixed
|
|
15
17
|
|
|
18
|
+
- Child worktree launches are suppressed when the parent checkout is itself a
|
|
19
|
+
linked git worktree (`git-dir` ≠ `git-common-dir`): role defaults and unset
|
|
20
|
+
launch flags collapse to `worktree: false`, matching the centurion-child
|
|
21
|
+
precedent. Explicit `launch: true` / per-child `step` flags still win.
|
|
22
|
+
- `worktreeBootstrap: "npm-install"` no longer fires `npm install` on a stray
|
|
23
|
+
`package.json` without a lockfile (observed: Java/Maven repos carrying
|
|
24
|
+
package.json for docs/tooling); without a lockfile the mode records the
|
|
25
|
+
marker and skips, so non-npm worktrees launch instead of failing after the
|
|
26
|
+
10-minute bootstrap budget.
|
|
27
|
+
|
|
28
|
+
### Fixed in 2.0.1 (inherited from prior entries)
|
|
29
|
+
|
|
30
|
+
- 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).
|
|
31
|
+
- Narrow/stacked `/legion` header truncates long run + selection badges without overwriting `⚔ /legion`; galaxy legend reserves room for failed/retired glyphs before roles (#125).
|
|
32
|
+
- Galaxy legend `statusBudget` counts grapheme cells (not `String.length`); selection badge min width unified for portrait + landscape (#128).
|
|
16
33
|
- Dispatched children now receive `--provider` pinned from the parent's model
|
|
17
34
|
registry, so a bare model id (`workbuddy/…`) that also exists under a static
|
|
18
35
|
auth-less provider in `models.json` can no longer hijack the child start
|
|
@@ -36,6 +53,8 @@ summary so you can see **2.x vs 0.16** without leaving the tree.
|
|
|
36
53
|
|
|
37
54
|
### Changed
|
|
38
55
|
|
|
56
|
+
- Esc no longer retires children; only `/legion stop` or session exit retires (esc-no-retire-v3, #134).
|
|
57
|
+
- Status strip shows a persistent `/legion stop` hint while children are live (railway + tree; width may truncate) (#134).
|
|
39
58
|
- Prefer `PI_LEGION_EXTRA_AGENT_DIRS` over legacy `PI_HERDR_SUBAGENTS_*` (alias; new name wins if both set) (#109 / #114).
|
|
40
59
|
- CONTRIBUTING/CI docs aligned with 2.x RPC; verify-12/13 paths point at `test/legacy/docker/` (#95 / #100, #107 / #112).
|
|
41
60
|
- README: remove duplicate Child extensions heading (#94 / #98).
|
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).
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
|
131
|
-
|
|
132
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
2587
|
-
|
|
2588
|
-
|
|
2589
|
-
|
|
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
|
|
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.
|
|
3
|
+
"version": "2.0.2",
|
|
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:
|
|
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(`-
|
|
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
|
-
*
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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.
|
|
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
|
|
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. */
|
package/src/extension/runtime.ts
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
713
|
-
//
|
|
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
|
},
|
package/src/runs/orchestrator.ts
CHANGED
|
@@ -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";
|
|
@@ -535,7 +536,11 @@ export class Orchestrator {
|
|
|
535
536
|
? true
|
|
536
537
|
: this.legion && parentRole === "centurion"
|
|
537
538
|
? false
|
|
538
|
-
: resolveLaunchWorktree({
|
|
539
|
+
: resolveLaunchWorktree({
|
|
540
|
+
roleDefault: input.agent.worktree,
|
|
541
|
+
launch: input.worktree,
|
|
542
|
+
parentInWorktree: isInsideChildWorktree(this.cwd),
|
|
543
|
+
});
|
|
539
544
|
let worktreePath = previous?.worktreePath;
|
|
540
545
|
let worktreeBranch = previous?.worktreeBranch;
|
|
541
546
|
let createdWorktree = false;
|
package/src/runs/provision.ts
CHANGED
|
@@ -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
|
|
package/src/runs/worktree.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
659
|
-
const
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
805
|
+
const nodes = nodesFromEntries(entries);
|
|
806
|
+
return { nodes, sampler, now, rate: sampler.nowRate(now, nodes) };
|
|
809
807
|
},
|
|
810
808
|
clear() {
|
|
811
809
|
entries = [];
|
package/src/tui/viz/canvas.ts
CHANGED
|
@@ -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);
|
package/src/tui/viz/panels.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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 =
|
|
250
|
-
if (lx + w >
|
|
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
|
|
256
|
-
|
|
257
|
-
if (
|
|
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 +=
|
|
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
|
-
//
|
|
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,
|
package/src/tui/viz/railway.ts
CHANGED
|
@@ -39,14 +39,14 @@ export interface RailInput {
|
|
|
39
39
|
nodes: readonly VizNode[];
|
|
40
40
|
now: number;
|
|
41
41
|
width: number;
|
|
42
|
-
/**
|
|
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
|
|
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
|
|
237
|
+
const headerFull = portrait
|
|
238
238
|
? ` ${live} live${range} · ${rateText} `
|
|
239
239
|
: ` legion ${live} live${range} · ${rateText} `;
|
|
240
|
-
|
|
241
|
-
|
|
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
|
-
|
|
244
|
-
const
|
|
245
|
-
const
|
|
246
|
-
const
|
|
247
|
-
if (
|
|
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);
|
package/src/tui/viz/rate.ts
CHANGED
|
@@ -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
|
|
10
|
-
*
|
|
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
|
|
129
|
-
* wall-clock window length so a ledger-only path still yields a
|
|
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
|
|
149
|
+
const seconds = dGen >= MIN_RATE_GENERATION_MS ? dGen / 1000 : windowMs / 1000;
|
|
137
150
|
return dOut / seconds;
|
|
138
151
|
}
|
|
139
152
|
|
|
140
|
-
|
|
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())
|
|
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)),
|
|
154
|
-
*
|
|
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]!;
|
package/src/tui/viz/view.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
59
|
-
const
|
|
60
|
-
cv.text(x, 0, ellipsize(
|
|
61
|
-
|
|
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");
|