@hheei/pi-subagents 0.6.0 → 0.8.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.
Files changed (60) hide show
  1. package/LICENSES/Apache-2.0.txt +202 -0
  2. package/NOTICE +10 -0
  3. package/README.md +14 -12
  4. package/dist/child-control.d.ts +0 -8
  5. package/dist/child-control.js +66 -3
  6. package/dist/commands.js +1 -1
  7. package/dist/controller-ipc.d.ts +38 -0
  8. package/dist/controller-ipc.js +292 -0
  9. package/dist/controller-main.d.ts +1 -0
  10. package/dist/controller-main.js +26 -0
  11. package/dist/controller-role.d.ts +40 -0
  12. package/dist/controller-role.js +37 -0
  13. package/dist/controller-socket.d.ts +6 -0
  14. package/dist/controller-socket.js +43 -0
  15. package/dist/controller.d.ts +25 -0
  16. package/dist/controller.js +217 -0
  17. package/dist/delegation.d.ts +73 -0
  18. package/dist/delegation.js +71 -0
  19. package/dist/domain.d.ts +20 -18
  20. package/dist/domain.js +6 -1
  21. package/dist/extension.js +43 -26
  22. package/dist/host-adapter.js +1 -1
  23. package/dist/launch-spec.d.ts +1 -3
  24. package/dist/launch-spec.js +39 -15
  25. package/dist/lease.d.ts +0 -1
  26. package/dist/lease.js +8 -11
  27. package/dist/mailbox.js +110 -34
  28. package/dist/manager.d.ts +52 -6
  29. package/dist/manager.js +585 -48
  30. package/dist/mcp/app.html +948 -0
  31. package/dist/mcp/delegate.d.ts +30 -0
  32. package/dist/mcp/delegate.js +44 -0
  33. package/dist/mcp/views.d.ts +5 -0
  34. package/dist/mcp/views.js +12 -0
  35. package/dist/mcp-server.d.ts +6 -0
  36. package/dist/mcp-server.js +287 -0
  37. package/dist/mcp-stdio.d.ts +2 -0
  38. package/dist/mcp-stdio.js +111 -0
  39. package/dist/process-lock.d.ts +1 -1
  40. package/dist/process-lock.js +4 -2
  41. package/dist/protocol.d.ts +5 -5
  42. package/dist/protocol.js +3 -4
  43. package/dist/receipts.d.ts +80 -0
  44. package/dist/receipts.js +326 -0
  45. package/dist/runtime.d.ts +5 -26
  46. package/dist/runtime.js +15 -57
  47. package/dist/session-bootstrap.js +2 -6
  48. package/dist/state-db.d.ts +11 -4
  49. package/dist/state-db.js +102 -24
  50. package/dist/state.d.ts +1 -0
  51. package/dist/state.js +29 -3
  52. package/dist/tool-activation.d.ts +2 -2
  53. package/dist/tool-activation.js +5 -5
  54. package/dist/tool-presentation.d.ts +2 -6
  55. package/dist/tool-presentation.js +75 -122
  56. package/dist/tools.d.ts +0 -3
  57. package/dist/tools.js +104 -132
  58. package/package.json +12 -5
  59. package/dist/json-lines.d.ts +0 -14
  60. package/dist/json-lines.js +0 -95
@@ -0,0 +1,217 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
3
+ import { join, resolve } from "node:path";
4
+ import { createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, SessionManager, } from "@earendil-works/pi-coding-agent";
5
+ import { isRecord } from "@hheei/pi-ext-core";
6
+ import { CONTROLLER_PROTOCOL, closeControllerIpc, serveControllerIpc } from "./controller-ipc.js";
7
+ import { beginControllerRole, endControllerRole } from "./controller-role.js";
8
+ import piSubagentsExtension, { skillCatalogFromLoaded } from "./extension.js";
9
+ import { holdProcessLock } from "./process-lock.js";
10
+ export class StandaloneControllerError extends Error {
11
+ code;
12
+ parentSessionId;
13
+ constructor(code, message, parentSessionId) {
14
+ super(message);
15
+ this.name = "StandaloneControllerError";
16
+ this.code = code;
17
+ this.parentSessionId = parentSessionId;
18
+ }
19
+ }
20
+ export function controllerSocketPath(agentDir, workspace) {
21
+ return join(controllerDirectory(resolve(agentDir)), `${workspaceKey(resolve(workspace))}.sock`);
22
+ }
23
+ export async function startStandaloneController(options) {
24
+ const workspace = resolve(options.workspace);
25
+ const agentDir = resolve(options.agentDir);
26
+ const directory = controllerDirectory(agentDir);
27
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
28
+ const id = workspaceKey(workspace);
29
+ const lock = lockController(join(directory, `${id}.lock`));
30
+ let role;
31
+ let runtime;
32
+ try {
33
+ const bindingPath = join(directory, `${id}.json`);
34
+ const existing = readBinding(bindingPath);
35
+ if (existing !== undefined && existing.workspace !== workspace) {
36
+ throw new Error("Standalone controller binding does not match this workspace");
37
+ }
38
+ const sessionDir = join(agentDir, "subagents", "sessions");
39
+ const sessionManager = existing === undefined
40
+ ? SessionManager.create(workspace, sessionDir)
41
+ : existsSync(existing.sessionFile)
42
+ ? SessionManager.open(existing.sessionFile)
43
+ : // Pi writes the session file only after a user or assistant message.
44
+ SessionManager.create(workspace, sessionDir, { id: existing.parentSessionId });
45
+ const sessionFile = sessionManager.getSessionFile();
46
+ const parentSessionId = sessionManager.getSessionId();
47
+ if (sessionFile === undefined)
48
+ throw new Error("Standalone controller session has no file");
49
+ if (existing !== undefined && existing.parentSessionId !== parentSessionId) {
50
+ throw new Error("Standalone controller restored a different parent session");
51
+ }
52
+ writeBinding(bindingPath, {
53
+ workspace,
54
+ parentSessionId,
55
+ sessionFile,
56
+ pid: process.pid,
57
+ protocol: CONTROLLER_PROTOCOL,
58
+ });
59
+ runtime = await createAgentSessionRuntime(async ({ cwd, sessionManager: bound }) => {
60
+ const services = await createAgentSessionServices({
61
+ cwd,
62
+ agentDir,
63
+ resourceLoaderOptions: {
64
+ noExtensions: true,
65
+ extensionFactories: [piSubagentsExtension],
66
+ },
67
+ });
68
+ return {
69
+ ...(await createAgentSessionFromServices({ services, sessionManager: bound })),
70
+ services,
71
+ diagnostics: services.diagnostics,
72
+ };
73
+ }, { cwd: workspace, agentDir, sessionManager });
74
+ role = beginControllerRole(skillCatalogFromLoaded(runtime.services.resourceLoader.getSkills().skills));
75
+ await runtime.session.bindExtensions({ mode: "print" });
76
+ if (!role.applied)
77
+ throw new Error("Standalone controller extension did not take ownership");
78
+ if (role.modelId === undefined || role.credential !== "present") {
79
+ throw new StandaloneControllerError("model_unavailable", "Standalone controller has no usable Pi model or credential", parentSessionId);
80
+ }
81
+ const socketPath = join(directory, `${id}.sock`);
82
+ const manager = await role.manager;
83
+ const ipc = await serveControllerIpc({ socketPath, parentSessionId, manager });
84
+ const started = { runtime, role, lock, ipc };
85
+ role = undefined;
86
+ runtime = undefined;
87
+ // Both signals and a second shutdown call reach this: dispose once, and release the role
88
+ // and lock even when closing the socket or the runtime throws.
89
+ let disposal;
90
+ return {
91
+ workspace,
92
+ parentSessionId,
93
+ sessionFile,
94
+ socketPath,
95
+ dispose() {
96
+ disposal ??= (async () => {
97
+ try {
98
+ await closeControllerIpc(started.ipc);
99
+ await started.runtime.dispose();
100
+ }
101
+ finally {
102
+ endControllerRole(started.role);
103
+ started.lock.release();
104
+ }
105
+ })();
106
+ return disposal;
107
+ },
108
+ };
109
+ }
110
+ catch (error) {
111
+ if (runtime !== undefined)
112
+ await runtime.dispose();
113
+ if (role !== undefined)
114
+ endControllerRole(role);
115
+ lock.release();
116
+ throw error;
117
+ }
118
+ }
119
+ function controllerDirectory(agentDir) {
120
+ return join(agentDir, "subagents", "controllers");
121
+ }
122
+ function workspaceKey(workspace) {
123
+ return createHash("sha256").update(workspace).digest("hex").slice(0, 32);
124
+ }
125
+ function lockController(lockPath) {
126
+ try {
127
+ return holdProcessLock(lockPath, 0);
128
+ }
129
+ catch (error) {
130
+ if (isLockBusy(error)) {
131
+ throw new StandaloneControllerError("busy", "Standalone controller is already running for this workspace");
132
+ }
133
+ throw error;
134
+ }
135
+ }
136
+ function isLockBusy(error) {
137
+ return typeof error === "object" && error !== null && "errcode" in error && error.errcode === 5;
138
+ }
139
+ function readBinding(path) {
140
+ let text;
141
+ try {
142
+ text = readFileSync(path, "utf8");
143
+ }
144
+ catch (error) {
145
+ if (isRecord(error) && error.code === "ENOENT")
146
+ return undefined;
147
+ throw error;
148
+ }
149
+ let value;
150
+ try {
151
+ value = JSON.parse(text);
152
+ }
153
+ catch (error) {
154
+ throw new Error(`Invalid standalone controller binding: ${path}`, { cause: error });
155
+ }
156
+ if (!isRecord(value) ||
157
+ typeof value.workspace !== "string" ||
158
+ typeof value.parentSessionId !== "string" ||
159
+ typeof value.sessionFile !== "string") {
160
+ throw new Error(`Invalid standalone controller binding: ${path}`);
161
+ }
162
+ return {
163
+ workspace: value.workspace,
164
+ parentSessionId: value.parentSessionId,
165
+ sessionFile: value.sessionFile,
166
+ ...(typeof value.pid === "number" ? { pid: value.pid } : {}),
167
+ ...(typeof value.protocol === "number" ? { protocol: value.protocol } : {}),
168
+ };
169
+ }
170
+ /**
171
+ * A controller left behind by an earlier build keeps this workspace's socket and lock. Its calls are
172
+ * not this build's to make, so it is stopped here and the caller starts its own.
173
+ */
174
+ export async function stopPreviousController(agentDir, workspace, expectedPid) {
175
+ const resolved = resolve(workspace);
176
+ const id = workspaceKey(resolved);
177
+ const path = join(controllerDirectory(resolve(agentDir)), `${id}.json`);
178
+ const binding = readBinding(path);
179
+ const pid = binding?.pid;
180
+ // The process that failed the probe may already have been replaced. Killing whatever pid is here
181
+ // now would stop the replacement.
182
+ if (expectedPid !== undefined && pid !== expectedPid)
183
+ return;
184
+ if (pid === undefined) {
185
+ throw new Error(`A previous pi-subagents controller owns ${resolved} and does not record its process; quit and reopen the client that started it`);
186
+ }
187
+ if (readBinding(path)?.pid !== pid)
188
+ return;
189
+ process.kill(pid, "SIGTERM");
190
+ const deadline = Date.now() + 10_000;
191
+ while (Date.now() < deadline) {
192
+ if (!isRunning(pid))
193
+ return;
194
+ await new Promise((done) => setTimeout(done, 50));
195
+ }
196
+ throw new Error(`A previous pi-subagents controller for ${resolved} did not stop`);
197
+ }
198
+ /** Pid recorded for this workspace, when the binding names one. */
199
+ export function recordedControllerPid(agentDir, workspace) {
200
+ const resolved = resolve(workspace);
201
+ const id = workspaceKey(resolved);
202
+ return readBinding(join(controllerDirectory(resolve(agentDir)), `${id}.json`))?.pid;
203
+ }
204
+ function isRunning(pid) {
205
+ try {
206
+ process.kill(pid, 0);
207
+ return true;
208
+ }
209
+ catch (error) {
210
+ return !(isRecord(error) && error.code === "ESRCH");
211
+ }
212
+ }
213
+ function writeBinding(path, binding) {
214
+ const temporary = `${path}.${process.pid}.tmp`;
215
+ writeFileSync(temporary, `${JSON.stringify(binding)}\n`, { mode: 0o600 });
216
+ renameSync(temporary, path);
217
+ }
@@ -0,0 +1,73 @@
1
+ import type { PublicSubagent, SubagentState } from "./domain.js";
2
+ import { type Receipt, type ReceiptStatus } from "./receipts.js";
3
+ /** What the agent's runtime is doing now, kept apart from the selected delegation's status. */
4
+ export interface DelegationProgress {
5
+ readonly runtimeState: SubagentState;
6
+ readonly summary?: string;
7
+ readonly activeTool?: string;
8
+ }
9
+ /** Why a delegation did not produce a result, with what it may already have done. */
10
+ export interface DelegationError {
11
+ readonly reason: string;
12
+ readonly safeToRetry: boolean;
13
+ readonly sideEffects: readonly string[];
14
+ }
15
+ /**
16
+ * Public identity of one delegation: the agent session id plus its monotonic revision. Internal
17
+ * database keys never appear here, so both consumers speak the same vocabulary.
18
+ */
19
+ export interface DelegationView {
20
+ readonly id: string;
21
+ readonly revision: number;
22
+ readonly status: ReceiptStatus;
23
+ readonly task: string;
24
+ readonly hasOutput: boolean;
25
+ readonly agent?: string;
26
+ readonly cwd?: string;
27
+ readonly resultText?: string;
28
+ readonly progress?: DelegationProgress;
29
+ readonly error?: DelegationError;
30
+ }
31
+ /**
32
+ * Projects one stored delegation. A result only exists for a completed or blocked delegation: the
33
+ * text of a failed or uncertain one is the reason it failed, not child output.
34
+ */
35
+ export declare function delegationView(receipt: Receipt, child?: PublicSubagent, options?: {
36
+ readonly includeOutput?: boolean;
37
+ }): DelegationView;
38
+ /**
39
+ * The same projection with the result body when the caller asks for it. An explicit read still
40
+ * obeys the shared output budget: the model gets the head, the tail, and the path to everything it
41
+ * did not see, never an unbounded body.
42
+ */
43
+ export declare function agentView(receipt: Receipt, child?: PublicSubagent, options?: {
44
+ readonly includeOutput?: boolean;
45
+ }): Promise<DelegationView>;
46
+ /** One agent's row in a dashboard: its runtime now, and the status of its latest delegation. */
47
+ export interface AgentSummary {
48
+ readonly id: string;
49
+ readonly agent?: string;
50
+ readonly runtimeState: SubagentState;
51
+ readonly revision: number;
52
+ readonly status: ReceiptStatus;
53
+ readonly hasOutput: boolean;
54
+ readonly summary?: string;
55
+ }
56
+ export interface DashboardView {
57
+ readonly total: number;
58
+ readonly running: number;
59
+ readonly blocked: number;
60
+ readonly completed: number;
61
+ readonly failed: number;
62
+ readonly children: readonly AgentSummary[];
63
+ }
64
+ /** One stored delegation as history shows it: identity, status and task, never the body. */
65
+ export interface DelegationHistoryEntry {
66
+ readonly id: string;
67
+ readonly revision: number;
68
+ readonly status: ReceiptStatus;
69
+ readonly task: string;
70
+ readonly hasOutput: boolean;
71
+ readonly createdAt: number;
72
+ }
73
+ export declare function historyEntry(receipt: Receipt): DelegationHistoryEntry | undefined;
@@ -0,0 +1,71 @@
1
+ import { truncateWithSpill } from "@hheei/pi-ext-core";
2
+ import { receiptCarriesOutput } from "./receipts.js";
3
+ /**
4
+ * Projects one stored delegation. A result only exists for a completed or blocked delegation: the
5
+ * text of a failed or uncertain one is the reason it failed, not child output.
6
+ */
7
+ export function delegationView(receipt, child, options) {
8
+ if (receipt.childId === undefined || receipt.revision === undefined) {
9
+ throw new Error(`Delegation ${receipt.delegationId} has no public identity`);
10
+ }
11
+ const text = receipt.resultText;
12
+ const hasOutput = receiptCarriesOutput(receipt.status, text);
13
+ const failed = text !== undefined && !hasOutput;
14
+ return {
15
+ id: receipt.childId,
16
+ revision: receipt.revision,
17
+ status: receipt.status,
18
+ task: receipt.task,
19
+ hasOutput,
20
+ ...(options?.includeOutput === true && text !== undefined && hasOutput
21
+ ? { resultText: text }
22
+ : {}),
23
+ ...(failed && text !== undefined
24
+ ? {
25
+ error: {
26
+ reason: text,
27
+ safeToRetry: receipt.safeToRetry ?? false,
28
+ sideEffects: receipt.sideEffects ?? [],
29
+ },
30
+ }
31
+ : {}),
32
+ ...(child === undefined
33
+ ? {}
34
+ : {
35
+ agent: child.agent,
36
+ cwd: child.cwd,
37
+ progress: {
38
+ runtimeState: child.state,
39
+ ...(child.summary === undefined ? {} : { summary: child.summary }),
40
+ ...(child.activeTool === undefined ? {} : { activeTool: child.activeTool }),
41
+ },
42
+ }),
43
+ };
44
+ }
45
+ /**
46
+ * The same projection with the result body when the caller asks for it. An explicit read still
47
+ * obeys the shared output budget: the model gets the head, the tail, and the path to everything it
48
+ * did not see, never an unbounded body.
49
+ */
50
+ export async function agentView(receipt, child, options) {
51
+ const view = delegationView(receipt, child);
52
+ const text = receipt.resultText;
53
+ if (options?.includeOutput !== true || !view.hasOutput || text === undefined)
54
+ return view;
55
+ const bounded = await truncateWithSpill(text, "subagent-output");
56
+ return { ...view, resultText: bounded.text };
57
+ }
58
+ export function historyEntry(receipt) {
59
+ const id = receipt.childId;
60
+ const revision = receipt.revision;
61
+ if (id === undefined || revision === undefined)
62
+ return undefined;
63
+ return {
64
+ id,
65
+ revision,
66
+ status: receipt.status,
67
+ task: receipt.task,
68
+ hasOutput: receiptCarriesOutput(receipt.status, receipt.resultText),
69
+ createdAt: receipt.createdAt,
70
+ };
71
+ }
package/dist/domain.d.ts CHANGED
@@ -8,16 +8,6 @@ export declare function normalizeSubagentState(value: unknown): SubagentState |
8
8
  * is created, because it decides who holds the process for the rest of the child's life.
9
9
  */
10
10
  export type Presentation = "panel" | "background";
11
- /**
12
- * How `agent_send` hands its message to a child.
13
- *
14
- * `steer` enters the current assistant turn once its tool calls are in, before the next model
15
- * request, so it redirects work already in flight. `follow_up` waits until the child's run has no
16
- * tool calls or steering left, then continues as a new turn in the same run. There is no "auto":
17
- * the two queues already are the two situations, so the caller picks instead of guessing from the
18
- * child's state.
19
- */
20
- export type SendMode = "steer" | "follow_up";
21
11
  export type VisualSubagentState = SubagentState;
22
12
  export declare function toVisualSubagentState(state: SubagentState, interrupted?: string): VisualSubagentState;
23
13
  export declare const VISUAL_SUBAGENT_GLYPH: Record<VisualSubagentState, string>;
@@ -33,12 +23,10 @@ export interface ChildIdentity {
33
23
  readonly subagentId: string;
34
24
  readonly runtimeIdentity: string;
35
25
  readonly endpoint: string;
36
- readonly token: string;
37
26
  }
38
27
  /**
39
- * The non-secret half of {@link ChildIdentity}: what a launch spec may contain and
40
- * what may be persisted. The controller token is added by the launcher at spawn
41
- * time and never written to a registry snapshot.
28
+ * What a launch spec may contain and what may be persisted: the child's identity minus nothing,
29
+ * because the mailbox is authenticated by the process lock its writer holds, not by a token.
42
30
  */
43
31
  export interface ChildBridgeEnvironment {
44
32
  readonly parentSessionId: string;
@@ -211,11 +199,20 @@ export interface SpawnSubagentInput {
211
199
  * Where to run this child. `auto` (the default) means a panel whenever this parent has a
212
200
  * presentation host, and the background otherwise; the Task tool pins `background` because a
213
201
  * Task is settled by its parent and never handed to a human. This is not a model-facing
214
- * parameter: `agent_spawn` always spawns with `auto`.
202
+ * parameter: `agents_delegate` always spawns with `auto`.
215
203
  */
216
204
  readonly presentation?: Presentation | "auto";
217
- /** Optional child session title; the child branch prefixes it with the subagent marker. */
218
- readonly title?: string;
205
+ }
206
+ /**
207
+ * Input for a single delegated turn. An existing child ID always takes precedence over the agent
208
+ * definition: the child already owns its launch configuration, model, and session history.
209
+ */
210
+ export interface DelegateSubagentInput {
211
+ readonly task: string;
212
+ readonly id?: string;
213
+ readonly agent?: string;
214
+ /** Only used when creating a child; ignored when id is supplied. */
215
+ readonly cwd?: string;
219
216
  }
220
217
  /** True when a manager call returned a structured failure instead of a value. */
221
218
  export declare function isOperationError(value: unknown): value is OperationError;
@@ -228,7 +225,6 @@ export declare const BRIDGE_ENVIRONMENT_KEYS: {
228
225
  readonly childId: "PI_SUBAGENTS_CHILD_ID";
229
226
  readonly runtimeId: "PI_SUBAGENTS_RUNTIME_ID";
230
227
  readonly endpoint: "PI_SUBAGENTS_ENDPOINT";
231
- readonly token: "PI_SUBAGENTS_TOKEN";
232
228
  };
233
229
  /** Non-secret agent label for the child TUI identity line. Not part of handshake. */
234
230
  export declare const CHILD_AGENT_ENV_KEY: "PI_SUBAGENTS_AGENT";
@@ -236,6 +232,12 @@ export declare const CHILD_AGENT_ENV_KEY: "PI_SUBAGENTS_AGENT";
236
232
  export declare const CHILD_TITLE_ENV_KEY: "PI_SUBAGENTS_TITLE";
237
233
  /** Bound Pi session id for the child branch. Not part of the runner handshake. */
238
234
  export declare const CHILD_SESSION_ENV_KEY: "PI_SUBAGENTS_SESSION_ID";
235
+ /**
236
+ * Set on the standalone controller process. Its children must not discover Pi settings extensions:
237
+ * another installed copy of this package registers the same bridge tool as the explicit -e entry,
238
+ * and Pi aborts the child before it connects.
239
+ */
240
+ export declare const STANDALONE_CONTROLLER_ENV_KEY: "PI_SUBAGENTS_STANDALONE_CONTROLLER";
239
241
  /** Pi session ids reach argv and file names, so they must stay in Pi's accepted alphabet. */
240
242
  export declare function isSessionId(value: unknown): value is string;
241
243
  export declare function isThinkingLevel(value: unknown): value is ThinkingLevel;
package/dist/domain.js CHANGED
@@ -60,7 +60,6 @@ export const BRIDGE_ENVIRONMENT_KEYS = {
60
60
  childId: "PI_SUBAGENTS_CHILD_ID",
61
61
  runtimeId: "PI_SUBAGENTS_RUNTIME_ID",
62
62
  endpoint: "PI_SUBAGENTS_ENDPOINT",
63
- token: "PI_SUBAGENTS_TOKEN",
64
63
  };
65
64
  /** Non-secret agent label for the child TUI identity line. Not part of handshake. */
66
65
  export const CHILD_AGENT_ENV_KEY = "PI_SUBAGENTS_AGENT";
@@ -68,6 +67,12 @@ export const CHILD_AGENT_ENV_KEY = "PI_SUBAGENTS_AGENT";
68
67
  export const CHILD_TITLE_ENV_KEY = "PI_SUBAGENTS_TITLE";
69
68
  /** Bound Pi session id for the child branch. Not part of the runner handshake. */
70
69
  export const CHILD_SESSION_ENV_KEY = "PI_SUBAGENTS_SESSION_ID";
70
+ /**
71
+ * Set on the standalone controller process. Its children must not discover Pi settings extensions:
72
+ * another installed copy of this package registers the same bridge tool as the explicit -e entry,
73
+ * and Pi aborts the child before it connects.
74
+ */
75
+ export const STANDALONE_CONTROLLER_ENV_KEY = "PI_SUBAGENTS_STANDALONE_CONTROLLER";
71
76
  const SESSION_ID_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/;
72
77
  /** Pi session ids reach argv and file names, so they must stay in Pi's accepted alphabet. */
73
78
  export function isSessionId(value) {
package/dist/extension.js CHANGED
@@ -2,14 +2,16 @@ import { createRenderThrottle, DEFAULT_MAX_BYTES, errorMessage, fitsOutputBudget
2
2
  import { discoverAgents } from "./agent-resolver.js";
3
3
  import { registerChildBridge } from "./child-bridge.js";
4
4
  import { registerParentCommands } from "./commands.js";
5
+ import { activeControllerRole } from "./controller-role.js";
5
6
  import { BRIDGE_ENVIRONMENT_KEYS, isThinkingLevel } from "./domain.js";
6
7
  import { createCmuxHostAdapter, createHerdrHostAdapter, listCmuxAttachments, listHerdrAttachments, rebindCmuxSurface, rebindHerdrTab, selectHostAdapter, systemHostCommandRunner, } from "./host-adapter.js";
7
8
  import { appendSubagentInfo, displaySubagentText, registerSubagentInfo } from "./info.js";
8
9
  import { createMailboxTransport } from "./mailbox.js";
9
10
  import { SubagentManager } from "./manager.js";
10
11
  import { transformSubagentsMarkdown } from "./markdown.js";
12
+ import { sweepUncertainDispatch } from "./receipts.js";
11
13
  import { createSubagentRegistry } from "./registry.js";
12
- import { createRuntimeTokenStore, launchChild, openChildPanel, prepareRuntimeDirectory, } from "./runtime.js";
14
+ import { launchChild, openChildPanel, prepareRuntimeDirectory } from "./runtime.js";
13
15
  import { persistSubagentIntent, resolveSubagentLaunch } from "./session-bootstrap.js";
14
16
  import { createSubagentStatus } from "./status.js";
15
17
  import { registerInteractiveToolActivation } from "./tool-activation.js";
@@ -39,7 +41,6 @@ function childIdentityFromEnv(env) {
39
41
  subagentId: requiredBridgeValue(env, BRIDGE_ENVIRONMENT_KEYS.childId),
40
42
  runtimeIdentity: requiredBridgeValue(env, BRIDGE_ENVIRONMENT_KEYS.runtimeId),
41
43
  endpoint: requiredBridgeValue(env, BRIDGE_ENVIRONMENT_KEYS.endpoint),
42
- token: requiredBridgeValue(env, BRIDGE_ENVIRONMENT_KEYS.token),
43
44
  };
44
45
  }
45
46
  /**
@@ -139,26 +140,43 @@ export default function piSubagentsExtension(pi) {
139
140
  const context = runtime.extension;
140
141
  const parentSessionId = context.sessionManager.getSessionId();
141
142
  const registry = createSubagentRegistry({ parentSessionId });
142
- const tokens = createRuntimeTokenStore({ parentSessionId, diagnose });
143
+ sweepUncertainDispatch(registry.path, parentSessionId);
143
144
  // Presentation is chosen once per parent session: herdr first, then cmux, and a parent with
144
145
  // neither runs every child in the background. Successful selection stays silent; only actual
145
146
  // failures and fallback diagnostics are reported by the launch path.
146
147
  const ownerId = parentSessionId;
147
148
  const ownsAttachment = (identity) => identity.createdBy === ownerId;
148
- const hostSelection = await selectHostAdapter({
149
- adapters: {
150
- herdr: createHerdrHostAdapter({ ownerId, ownsAttachment }),
151
- cmux: createCmuxHostAdapter({ ownerId, ownsAttachment }),
152
- },
153
- });
154
- const delivery = getBackgroundDelivery(pi);
155
- const channel = createParentChannel(pi, {
156
- isIdle: () => context.isIdle(),
157
- ui: context.ui,
158
- delivery,
159
- signal: runtime.signal,
160
- });
149
+ const controller = activeControllerRole();
150
+ const hostSelection = controller
151
+ ? {
152
+ available: false,
153
+ selectedHost: null,
154
+ reason: "standalone controller keeps children in the background",
155
+ }
156
+ : await selectHostAdapter({
157
+ adapters: {
158
+ herdr: createHerdrHostAdapter({ ownerId, ownsAttachment }),
159
+ cmux: createCmuxHostAdapter({ ownerId, ownsAttachment }),
160
+ },
161
+ });
162
+ const channel = controller ??
163
+ createParentChannel(pi, {
164
+ isIdle: () => context.isIdle(),
165
+ ui: context.ui,
166
+ delivery: getBackgroundDelivery(pi),
167
+ signal: runtime.signal,
168
+ });
161
169
  runtime.resources.add("parent-channel", () => channel.dispose());
170
+ if (controller !== undefined) {
171
+ controller.applied = true;
172
+ const model = context.model;
173
+ if (model !== undefined && model.id !== "unknown") {
174
+ controller.provider = model.provider;
175
+ controller.modelId = model.id;
176
+ const key = await context.modelRegistry.getApiKeyForProvider(model.provider);
177
+ controller.credential = typeof key === "string" && key !== "" ? "present" : "missing";
178
+ }
179
+ }
162
180
  let manager;
163
181
  // One sqlite file is the ledger and the mailbox. Presence is not liveness.
164
182
  const mailbox = createMailboxTransport({
@@ -171,17 +189,18 @@ export default function piSubagentsExtension(pi) {
171
189
  runtime.resources.add("subagent-bridge", () => mailbox.close());
172
190
  // A definition names skills the way Pi lists them, so the parent's loaded skill set is
173
191
  // the only name → path catalog; Pi hands it over before every prompt of this session.
174
- let skillCatalog = [];
175
- const unsubscribeSkillCatalog = pi.on("before_agent_start", (event) => {
176
- const skills = event.systemPromptOptions?.skills ?? [];
177
- skillCatalog = skillCatalogFromLoaded(skills);
178
- });
179
- runtime.resources.add("subagent-skill-catalog", unsubscribeSkillCatalog);
192
+ let skillCatalog = controller?.skills ?? [];
193
+ if (controller === undefined) {
194
+ const unsubscribeSkillCatalog = pi.on("before_agent_start", (event) => {
195
+ const skills = event.systemPromptOptions?.skills ?? [];
196
+ skillCatalog = skillCatalogFromLoaded(skills);
197
+ });
198
+ runtime.resources.add("subagent-skill-catalog", unsubscribeSkillCatalog);
199
+ }
180
200
  manager = new SubagentManager({
181
201
  parentSessionId,
182
202
  registry,
183
203
  channel,
184
- tokens,
185
204
  async resolve(input) {
186
205
  const model = context.model;
187
206
  if (model === undefined)
@@ -213,7 +232,6 @@ export default function piSubagentsExtension(pi) {
213
232
  return launchChild({
214
233
  registry,
215
234
  record,
216
- tokens,
217
235
  waitForBridge: (childId, signal) => mailbox.waitUntilSeen(childId, {
218
236
  timeoutMs: START_WINDOW_MS,
219
237
  ...(signal === undefined ? {} : { signal }),
@@ -229,7 +247,6 @@ export default function piSubagentsExtension(pi) {
229
247
  openPanel: (record) => openChildPanel({
230
248
  registry,
231
249
  record,
232
- tokens,
233
250
  host: hostSelection.adapter,
234
251
  waitForBridge: (childId, signal) => mailbox.waitUntilSeen(childId, {
235
252
  timeoutMs: START_WINDOW_MS,
@@ -260,7 +277,6 @@ export default function piSubagentsExtension(pi) {
260
277
  const launch = await openChildPanel({
261
278
  registry,
262
279
  record,
263
- tokens,
264
280
  host: hostSelection.adapter,
265
281
  waitForBridge: (childId, signal) => mailbox.waitUntilSeen(childId, {
266
282
  timeoutMs: START_WINDOW_MS,
@@ -288,6 +304,7 @@ export default function piSubagentsExtension(pi) {
288
304
  }
289
305
  : {}),
290
306
  });
307
+ controller?.bindManager(manager);
291
308
  // Recovery must never take the extension down with it: a registry this version cannot
292
309
  // read (for example records written by an older release) is reported in the session and
293
310
  // the user is told how to reset it.
@@ -184,7 +184,7 @@ export function createCmuxHostAdapter(options) {
184
184
  noun: "surface",
185
185
  // TODO(cmux-focus): cmux does not report which surface a human is looking at, so
186
186
  // observation cannot answer `focused` and idle reclaim fails open here: a cmux child
187
- // is only ever closed by agent_stop.
187
+ // is only ever closed by agents_stop.
188
188
  reportsFocus: false,
189
189
  observe: async () => {
190
190
  const process = await runner.run("cmux", ["--json", "list-panels"], { timeoutMs });
@@ -12,7 +12,7 @@ export interface LaunchSpec {
12
12
  readonly cwd: string;
13
13
  readonly presentation: Presentation;
14
14
  readonly stdio: "pipe" | "inherit";
15
- /** Bridge variables to add to the child environment, excluding the controller token. */
15
+ /** Bridge variables to add to the child environment. */
16
16
  readonly env: Readonly<Record<string, string>>;
17
17
  readonly config: EffectiveLaunchConfig;
18
18
  }
@@ -25,8 +25,6 @@ export interface BuildLaunchSpecOptions {
25
25
  readonly bridge: ChildBridgeEnvironment;
26
26
  }
27
27
  export declare function assembleChildPrompt(instructions: string): string;
28
- /** Adds the per-runtime controller token. Tokens stay out of launch spec snapshots and the registry. */
29
- export declare function withBridgeToken(env: Readonly<Record<string, string>>, token: string): Record<string, string>;
30
28
  /**
31
29
  * Absolute path to this package's extension entry, which every child loads with
32
30
  * `-e` so the child branch exists even when extension discovery is disabled.