pi-legion 2.0.4 → 3.0.0
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 +71 -1
- package/CONTRIBUTING.md +27 -24
- package/README.md +103 -19
- package/docs/contracts/osc-busy-plugin.md +72 -0
- package/docs/contracts/spawn-default-inprocess-v3.md +322 -0
- package/docs/contracts/wait-not-stop.md +30 -0
- package/docs/design.md +2 -2
- package/docs/headless-child-contract.md +83 -0
- package/docs/inprocess-supervisor.md +140 -0
- package/docs/qa-hunting-round.md +150 -0
- package/docs/subagents-teams.md +55 -18
- package/docs/tui-qa-playbook.md +284 -0
- package/index.ts +315 -49
- package/package.json +15 -8
- package/skills/pi-legion/SKILL.md +5 -0
- package/src/agents/agents.ts +34 -6
- package/src/agents/context.ts +83 -1
- package/src/agents/overrides.ts +1 -0
- package/src/agents/settings.ts +122 -1
- package/src/api.ts +23 -0
- package/src/extension/child-guard.ts +42 -3
- package/src/extension/legion-commands.ts +9 -2
- package/src/extension/legion-doctor.ts +54 -0
- package/src/extension/legion-stop.ts +6 -2
- package/src/extension/playbook.ts +3 -3
- package/src/extension/runtime.ts +57 -8
- package/src/extension/scout-guard.ts +39 -1
- package/src/extension/session-errors.ts +191 -0
- package/src/runs/args.ts +8 -0
- package/src/runs/orchestrator.ts +125 -16
- package/src/shared/output-cap.ts +2 -2
- package/src/shared/session.ts +2 -1
- package/src/shared/types.ts +74 -0
- package/src/supervisor/child-context.ts +90 -0
- package/src/supervisor/child-memory.ts +27 -3
- package/src/supervisor/inprocess-depth.ts +71 -0
- package/src/supervisor/inprocess-supervisor.ts +1011 -0
- package/src/supervisor/routing-supervisor.ts +179 -0
- package/src/supervisor/rpc-supervisor.ts +14 -0
- package/src/supervisor/spawn-mode.ts +35 -0
- package/src/supervisor/types.ts +13 -0
- package/src/supervisor/ui-proxy.ts +78 -0
- package/src/tui/live-overlay.ts +147 -55
- package/src/tui/status.ts +71 -35
- package/src/tui/viz/model.ts +6 -0
- package/src/tui/viz/panels.ts +8 -9
- package/src/tui/viz/railway.ts +154 -21
- package/src/tui/viz/rate.ts +15 -14
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thin LegionSupervisor facade that routes by AgentConfig.spawnMode (#197).
|
|
3
|
+
*
|
|
4
|
+
* rpc → RpcSupervisor (unchanged internals)
|
|
5
|
+
* inprocess → InProcessSupervisor
|
|
6
|
+
*
|
|
7
|
+
* Same name must not be registered on both backends.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
ErrorCodes,
|
|
12
|
+
SubagentError,
|
|
13
|
+
type InProcessLimits,
|
|
14
|
+
} from "../shared/types.ts";
|
|
15
|
+
import { InProcessSupervisor } from "./inprocess-supervisor.ts";
|
|
16
|
+
import { RpcSupervisor } from "./rpc-supervisor.ts";
|
|
17
|
+
import { resolveSpawnMode } from "./spawn-mode.ts";
|
|
18
|
+
import type { UIProxy } from "./ui-proxy.ts";
|
|
19
|
+
import type {
|
|
20
|
+
ChildHandle,
|
|
21
|
+
EventSubscriptionOptions,
|
|
22
|
+
LegionSupervisor,
|
|
23
|
+
SettleResult,
|
|
24
|
+
SpawnInput,
|
|
25
|
+
SupervisorEventListener,
|
|
26
|
+
UsageSnapshot,
|
|
27
|
+
} from "./types.ts";
|
|
28
|
+
|
|
29
|
+
export type SupervisorBackend = "rpc" | "inprocess";
|
|
30
|
+
|
|
31
|
+
export interface RoutingSupervisorOptions {
|
|
32
|
+
rpc?: RpcSupervisor;
|
|
33
|
+
inprocess?: InProcessSupervisor;
|
|
34
|
+
/** Shared UI proxy preferred; otherwise each backend keeps its own. */
|
|
35
|
+
uiProxy?: UIProxy;
|
|
36
|
+
getInprocessLimits?: () => InProcessLimits | undefined;
|
|
37
|
+
onSpawnRefused?: (message: string) => void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Routes LegionSupervisor calls to RpcSupervisor or InProcessSupervisor
|
|
42
|
+
* based on the resolved spawnMode of the target agent / registered child.
|
|
43
|
+
*/
|
|
44
|
+
export class RoutingSupervisor implements LegionSupervisor {
|
|
45
|
+
readonly rpc: RpcSupervisor;
|
|
46
|
+
readonly inprocess: InProcessSupervisor;
|
|
47
|
+
private readonly owners = new Map<string, SupervisorBackend>();
|
|
48
|
+
readonly uiProxy: UIProxy;
|
|
49
|
+
|
|
50
|
+
constructor(options: RoutingSupervisorOptions = {}) {
|
|
51
|
+
const onSpawnRefused = options.onSpawnRefused;
|
|
52
|
+
this.rpc =
|
|
53
|
+
options.rpc ??
|
|
54
|
+
new RpcSupervisor({
|
|
55
|
+
...(options.uiProxy ? { uiProxy: options.uiProxy } : {}),
|
|
56
|
+
...(onSpawnRefused ? { onSpawnRefused } : {}),
|
|
57
|
+
});
|
|
58
|
+
this.inprocess =
|
|
59
|
+
options.inprocess ??
|
|
60
|
+
new InProcessSupervisor({
|
|
61
|
+
...(options.uiProxy ? { uiProxy: options.uiProxy } : {}),
|
|
62
|
+
...(options.getInprocessLimits
|
|
63
|
+
? { getLimits: options.getInprocessLimits }
|
|
64
|
+
: {}),
|
|
65
|
+
...(onSpawnRefused ? { onSpawnRefused } : {}),
|
|
66
|
+
});
|
|
67
|
+
this.uiProxy = options.uiProxy ?? this.rpc.uiProxy;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async spawnChild(input: SpawnInput): Promise<ChildHandle> {
|
|
71
|
+
const mode = resolveSpawnMode(input.agent);
|
|
72
|
+
if (mode === "inprocess") {
|
|
73
|
+
if (input.agent.kind !== "pi") {
|
|
74
|
+
throw new SubagentError(
|
|
75
|
+
`spawnMode "inprocess" requires kind "pi" (received '${input.agent.kind}')`,
|
|
76
|
+
ErrorCodes.INVALID_PARAMS,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
if (input.agent.worktree === true) {
|
|
80
|
+
throw new SubagentError(
|
|
81
|
+
`spawnMode "inprocess" cannot combine with worktree: true for '${input.name}'`,
|
|
82
|
+
ErrorCodes.INVALID_PARAMS,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const existing = this.owners.get(input.name);
|
|
87
|
+
if (existing && existing !== mode) {
|
|
88
|
+
const alive =
|
|
89
|
+
existing === "rpc"
|
|
90
|
+
? this.rpc.isAlive(input.name)
|
|
91
|
+
: this.inprocess.isAlive(input.name);
|
|
92
|
+
if (alive) {
|
|
93
|
+
throw new SubagentError(
|
|
94
|
+
`child '${input.name}' is already registered on ${existing}; cannot spawn as ${mode}`,
|
|
95
|
+
ErrorCodes.NAME_TAKEN,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
const backend = mode === "inprocess" ? this.inprocess : this.rpc;
|
|
100
|
+
const handle = await backend.spawnChild(input);
|
|
101
|
+
this.owners.set(input.name, mode);
|
|
102
|
+
return handle;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async prompt(name: string, text: string): Promise<void> {
|
|
106
|
+
await this.backendFor(name).prompt(name, text);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async steer(name: string, text: string): Promise<void> {
|
|
110
|
+
await this.backendFor(name).steer(name, text);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async followUp(name: string, text: string): Promise<void> {
|
|
114
|
+
await this.backendFor(name).followUp(name, text);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async abort(name: string): Promise<void> {
|
|
118
|
+
await this.backendFor(name).abort(name);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
waitSettled(
|
|
122
|
+
name: string,
|
|
123
|
+
timeoutMs?: number,
|
|
124
|
+
signal?: AbortSignal,
|
|
125
|
+
): Promise<SettleResult> {
|
|
126
|
+
return this.backendFor(name).waitSettled(name, timeoutMs, signal);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
isAlive(name: string): boolean {
|
|
130
|
+
const owner = this.owners.get(name);
|
|
131
|
+
if (owner === "inprocess") return this.inprocess.isAlive(name);
|
|
132
|
+
if (owner === "rpc") return this.rpc.isAlive(name);
|
|
133
|
+
return this.rpc.isAlive(name) || this.inprocess.isAlive(name);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async stats(name: string): Promise<UsageSnapshot | null> {
|
|
137
|
+
return this.backendFor(name).stats(name);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
retire(name: string, opts?: { graceful?: boolean }): Promise<void> {
|
|
141
|
+
return this.backendFor(name).retire(name, opts);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
onEvent(
|
|
145
|
+
name: string,
|
|
146
|
+
listener: SupervisorEventListener,
|
|
147
|
+
options: EventSubscriptionOptions = {},
|
|
148
|
+
): () => void {
|
|
149
|
+
const owner = this.owners.get(name);
|
|
150
|
+
if (owner === "inprocess") {
|
|
151
|
+
return this.inprocess.onEvent(name, listener, options);
|
|
152
|
+
}
|
|
153
|
+
if (owner === "rpc") {
|
|
154
|
+
return this.rpc.onEvent(name, listener, options);
|
|
155
|
+
}
|
|
156
|
+
// Pre-spawn: subscribe on both so whichever backend wins still delivers.
|
|
157
|
+
const offRpc = this.rpc.onEvent(name, listener, options);
|
|
158
|
+
const offIn = this.inprocess.onEvent(name, listener, options);
|
|
159
|
+
return () => {
|
|
160
|
+
offRpc();
|
|
161
|
+
offIn();
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
private backendFor(name: string): LegionSupervisor {
|
|
166
|
+
const owner = this.owners.get(name);
|
|
167
|
+
if (owner === "inprocess") return this.inprocess;
|
|
168
|
+
if (owner === "rpc") return this.rpc;
|
|
169
|
+
if (this.inprocess.isAlive(name)) {
|
|
170
|
+
this.owners.set(name, "inprocess");
|
|
171
|
+
return this.inprocess;
|
|
172
|
+
}
|
|
173
|
+
if (this.rpc.isAlive(name)) {
|
|
174
|
+
this.owners.set(name, "rpc");
|
|
175
|
+
return this.rpc;
|
|
176
|
+
}
|
|
177
|
+
throw new SubagentError(`unknown child: ${name}`, ErrorCodes.NOT_FOUND);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
@@ -17,6 +17,8 @@ import { enrichFailureWithAutoDoctor } from "../extension/auto-doctor.ts";
|
|
|
17
17
|
import { ErrorCodes, SubagentError } from "../shared/types.ts";
|
|
18
18
|
import {
|
|
19
19
|
InMemoryUIProxy,
|
|
20
|
+
extensionActivityToUIRequest,
|
|
21
|
+
isExtensionActivity,
|
|
20
22
|
isExtensionUIRequest,
|
|
21
23
|
type ExtensionUIResponse,
|
|
22
24
|
type UIProxy,
|
|
@@ -295,6 +297,9 @@ export class RpcSupervisor implements LegionSupervisor {
|
|
|
295
297
|
cliPath: _cliPath,
|
|
296
298
|
tempDir: _tempDir,
|
|
297
299
|
stripLegionIdentity: _stripLegionIdentity,
|
|
300
|
+
// In-process identity never travels over the RPC channel; drop it so an
|
|
301
|
+
// accidental spread cannot leak a lineage object into argv building.
|
|
302
|
+
childLaunchContext: _childLaunchContext,
|
|
298
303
|
...buildInput
|
|
299
304
|
} = input;
|
|
300
305
|
const built = buildPiArgs({
|
|
@@ -634,6 +639,15 @@ export class RpcSupervisor implements LegionSupervisor {
|
|
|
634
639
|
return;
|
|
635
640
|
}
|
|
636
641
|
|
|
642
|
+
// Child-side setActivity / setProgramRecord → parent OSC busy fan-in (no PTY OSC parse).
|
|
643
|
+
if (isExtensionActivity(event)) {
|
|
644
|
+
this.uiProxy.enqueue({
|
|
645
|
+
childName: child.name,
|
|
646
|
+
request: extensionActivityToUIRequest(event),
|
|
647
|
+
});
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
|
|
637
651
|
this.updateTurnState(child, record);
|
|
638
652
|
|
|
639
653
|
this.publishEvent(child, record);
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spawn-mode resolution helpers for #197 (rpc | inprocess).
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { AgentConfig, SpawnMode } from "../shared/types.ts";
|
|
6
|
+
import { SPAWN_MODES } from "../shared/types.ts";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Default backend when `spawnMode` is omitted.
|
|
10
|
+
*
|
|
11
|
+
* `inprocess` is only the *intent* here: `Orchestrator.launch` arbitrates an
|
|
12
|
+
* omitted mode down to `rpc` when the launch actually needs worktree
|
|
13
|
+
* isolation or the role is not `kind: pi` (see the arbitration comment in
|
|
14
|
+
* `src/runs/orchestrator.ts`). This resolver stays a pure config read and
|
|
15
|
+
* must never inspect `worktree`.
|
|
16
|
+
*/
|
|
17
|
+
export const DEFAULT_SPAWN_MODE: SpawnMode = "inprocess";
|
|
18
|
+
|
|
19
|
+
/** Safe default for `inprocess.maxActive` when the inprocess block is set without it. */
|
|
20
|
+
export const DEFAULT_INPROCESS_MAX_ACTIVE = 8;
|
|
21
|
+
|
|
22
|
+
export function resolveSpawnMode(
|
|
23
|
+
agent: Pick<AgentConfig, "spawnMode"> | undefined,
|
|
24
|
+
): SpawnMode {
|
|
25
|
+
const mode = agent?.spawnMode;
|
|
26
|
+
if (mode === undefined) return DEFAULT_SPAWN_MODE;
|
|
27
|
+
if ((SPAWN_MODES as readonly string[]).includes(mode)) return mode;
|
|
28
|
+
return DEFAULT_SPAWN_MODE;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function isInProcessSpawn(
|
|
32
|
+
agent: Pick<AgentConfig, "spawnMode"> | undefined,
|
|
33
|
+
): boolean {
|
|
34
|
+
return resolveSpawnMode(agent) === "inprocess";
|
|
35
|
+
}
|
package/src/supervisor/types.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import type { RpcClientOptions as PiRpcClientOptions } from "@earendil-works/pi-coding-agent";
|
|
6
6
|
import type { Writable } from "node:stream";
|
|
7
7
|
import type { BuildArgsInput } from "../runs/args.ts";
|
|
8
|
+
import type { ChildLaunchContext } from "./child-context.ts";
|
|
8
9
|
import type { UIProxy } from "./ui-proxy.ts";
|
|
9
10
|
|
|
10
11
|
/**
|
|
@@ -17,6 +18,18 @@ export interface SpawnInput extends Omit<BuildArgsInput, "cwd" | "includeTask">
|
|
|
17
18
|
cwd: string;
|
|
18
19
|
/** Environment overlay passed to RpcClient. */
|
|
19
20
|
env?: Record<string, string>;
|
|
21
|
+
/**
|
|
22
|
+
* In-process lineage identity (spawn-default-inprocess-v3 D5).
|
|
23
|
+
*
|
|
24
|
+
* Deliberately NOT named `childContext`: that inherited `BuildArgsInput`
|
|
25
|
+
* field is the *string* settings-injected prompt context, and an interface
|
|
26
|
+
* cannot narrow an inherited member to an object type (TS2430). The two
|
|
27
|
+
* channels are unrelated — one is prompt text, this one is identity.
|
|
28
|
+
*
|
|
29
|
+
* Only the inprocess backend consumes it; RPC keeps using env lineage.
|
|
30
|
+
* Absent → InProcessSupervisor derives a minimal fallback context.
|
|
31
|
+
*/
|
|
32
|
+
childLaunchContext?: ChildLaunchContext;
|
|
20
33
|
/** Whether to remove Legion lineage identity from the inherited environment. */
|
|
21
34
|
stripLegionIdentity?: boolean;
|
|
22
35
|
/** Optional Pi CLI entry point override. */
|
|
@@ -68,6 +68,25 @@ export type ExtensionUIRequest =
|
|
|
68
68
|
id: string;
|
|
69
69
|
method: "set_editor_text";
|
|
70
70
|
text: string;
|
|
71
|
+
}
|
|
72
|
+
| {
|
|
73
|
+
type: "extension_ui_request";
|
|
74
|
+
id: string;
|
|
75
|
+
method: "setActivity";
|
|
76
|
+
source: string;
|
|
77
|
+
activity?: { message?: string };
|
|
78
|
+
}
|
|
79
|
+
| {
|
|
80
|
+
type: "extension_ui_request";
|
|
81
|
+
id: string;
|
|
82
|
+
method: "setProgramRecord";
|
|
83
|
+
record: {
|
|
84
|
+
state: "idle" | "working" | "blocked" | "done" | "error" | "clear";
|
|
85
|
+
id: string;
|
|
86
|
+
app?: string;
|
|
87
|
+
kind?: "permission" | "question" | "auth";
|
|
88
|
+
message?: string;
|
|
89
|
+
};
|
|
71
90
|
};
|
|
72
91
|
|
|
73
92
|
/** Response payload; UIProxy adds the RPC envelope and request id. */
|
|
@@ -143,6 +162,14 @@ export function isExtensionUIRequest(value: unknown): value is ExtensionUIReques
|
|
|
143
162
|
return typeof record.title === "string";
|
|
144
163
|
case "set_editor_text":
|
|
145
164
|
return typeof record.text === "string";
|
|
165
|
+
case "setActivity":
|
|
166
|
+
return typeof record.source === "string";
|
|
167
|
+
case "setProgramRecord": {
|
|
168
|
+
const rec = record.record;
|
|
169
|
+
if (!rec || typeof rec !== "object" || Array.isArray(rec)) return false;
|
|
170
|
+
const body = rec as Record<string, unknown>;
|
|
171
|
+
return typeof body.id === "string" && typeof body.state === "string";
|
|
172
|
+
}
|
|
146
173
|
default:
|
|
147
174
|
return false;
|
|
148
175
|
}
|
|
@@ -224,3 +251,54 @@ export class InMemoryUIProxy implements UIProxy {
|
|
|
224
251
|
return () => this.listeners.delete(listener);
|
|
225
252
|
}
|
|
226
253
|
}
|
|
254
|
+
|
|
255
|
+
/** Top-level RPC event from Pi child `ui.setActivity` / `ui.setProgramRecord` (OSC busy fan-in). */
|
|
256
|
+
export type ExtensionActivityEvent =
|
|
257
|
+
| {
|
|
258
|
+
type: "extension_activity";
|
|
259
|
+
id: string;
|
|
260
|
+
source: string;
|
|
261
|
+
activity: { message?: string } | null;
|
|
262
|
+
record?: undefined;
|
|
263
|
+
}
|
|
264
|
+
| {
|
|
265
|
+
type: "extension_activity";
|
|
266
|
+
id: string;
|
|
267
|
+
record: {
|
|
268
|
+
state: "idle" | "working" | "blocked" | "done" | "error" | "clear";
|
|
269
|
+
id: string;
|
|
270
|
+
app?: string;
|
|
271
|
+
kind?: "permission" | "question" | "auth";
|
|
272
|
+
message?: string;
|
|
273
|
+
};
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
export function isExtensionActivity(value: unknown): value is ExtensionActivityEvent {
|
|
277
|
+
if (!value || typeof value !== "object") return false;
|
|
278
|
+
const record = value as Record<string, unknown>;
|
|
279
|
+
if (record.type !== "extension_activity" || typeof record.id !== "string") return false;
|
|
280
|
+
if (record.record && typeof record.record === "object" && !Array.isArray(record.record)) {
|
|
281
|
+
const body = record.record as Record<string, unknown>;
|
|
282
|
+
return typeof body.id === "string" && typeof body.state === "string";
|
|
283
|
+
}
|
|
284
|
+
return typeof record.source === "string";
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Map a child extension_activity event into the UIProxy request shape. */
|
|
288
|
+
export function extensionActivityToUIRequest(event: ExtensionActivityEvent): ExtensionUIRequest {
|
|
289
|
+
if ("record" in event && event.record) {
|
|
290
|
+
return {
|
|
291
|
+
type: "extension_ui_request",
|
|
292
|
+
id: event.id,
|
|
293
|
+
method: "setProgramRecord",
|
|
294
|
+
record: event.record,
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
return {
|
|
298
|
+
type: "extension_ui_request",
|
|
299
|
+
id: event.id,
|
|
300
|
+
method: "setActivity",
|
|
301
|
+
source: event.source,
|
|
302
|
+
...(event.activity ? { activity: event.activity } : {}),
|
|
303
|
+
};
|
|
304
|
+
}
|