@hheei/pi-subagents 0.1.2 → 0.2.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.
Files changed (65) hide show
  1. package/README.md +42 -137
  2. package/dist/agent-resolver.d.ts +20 -0
  3. package/dist/agent-resolver.js +155 -21
  4. package/dist/bridge-client.d.ts +55 -0
  5. package/dist/bridge-client.js +453 -0
  6. package/dist/bridge-server.d.ts +72 -0
  7. package/dist/bridge-server.js +490 -0
  8. package/dist/builtin-agents.d.ts +24 -0
  9. package/dist/builtin-agents.js +152 -0
  10. package/dist/child-bridge.d.ts +31 -15
  11. package/dist/child-bridge.js +95 -117
  12. package/dist/child-control.d.ts +58 -0
  13. package/dist/child-control.js +176 -0
  14. package/dist/child-process.d.ts +21 -0
  15. package/dist/child-process.js +93 -0
  16. package/dist/commands.d.ts +2 -2
  17. package/dist/commands.js +52 -52
  18. package/dist/diagnostics.d.ts +0 -2
  19. package/dist/diagnostics.js +0 -8
  20. package/dist/domain.d.ts +57 -27
  21. package/dist/domain.js +60 -11
  22. package/dist/extension.d.ts +26 -1
  23. package/dist/extension.js +243 -46
  24. package/dist/host-adapter.d.ts +37 -12
  25. package/dist/host-adapter.js +214 -63
  26. package/dist/json-lines.js +2 -1
  27. package/dist/launch-spec.d.ts +8 -6
  28. package/dist/launch-spec.js +61 -18
  29. package/dist/manager.d.ts +93 -49
  30. package/dist/manager.js +1175 -528
  31. package/dist/markdown.d.ts +8 -0
  32. package/dist/markdown.js +21 -0
  33. package/dist/protocol.d.ts +33 -52
  34. package/dist/protocol.js +41 -52
  35. package/dist/registry.d.ts +3 -8
  36. package/dist/registry.js +41 -163
  37. package/dist/rpc-events.d.ts +3 -5
  38. package/dist/rpc-events.js +5 -9
  39. package/dist/runtime.d.ts +66 -11
  40. package/dist/runtime.js +182 -208
  41. package/dist/session-bootstrap.d.ts +23 -7
  42. package/dist/session-bootstrap.js +79 -15
  43. package/dist/session-fork.d.ts +16 -0
  44. package/dist/session-fork.js +67 -0
  45. package/dist/state.d.ts +7 -0
  46. package/dist/state.js +60 -25
  47. package/dist/tool-activation.d.ts +18 -0
  48. package/dist/tool-activation.js +227 -0
  49. package/dist/tools.d.ts +6 -3
  50. package/dist/tools.js +213 -72
  51. package/dist/widget.d.ts +4 -8
  52. package/dist/widget.js +55 -64
  53. package/package.json +51 -52
  54. package/dist/child-nudge.d.ts +0 -24
  55. package/dist/child-nudge.js +0 -102
  56. package/dist/connector.d.ts +0 -53
  57. package/dist/connector.js +0 -283
  58. package/dist/pause-gate.d.ts +0 -14
  59. package/dist/pause-gate.js +0 -64
  60. package/dist/rpc-adapter.d.ts +0 -40
  61. package/dist/rpc-adapter.js +0 -194
  62. package/dist/runner-entry.d.ts +0 -24
  63. package/dist/runner-entry.js +0 -166
  64. package/dist/runner.d.ts +0 -52
  65. package/dist/runner.js +0 -930
@@ -2,26 +2,16 @@
2
2
  * Child-branch session binding. The TUI process may /reload the same session,
3
3
  * /quit, or switch to an unrelated session B. Only a confirmed leave of A
4
4
  * unbinds the bridge; shutdown or switch requests alone do not.
5
+ *
6
+ * Everything the parent asks of this child arrives over the bridge connection created here, and
7
+ * every Pi event, lifecycle notice and report this child produces leaves over that same connection.
5
8
  */
6
- import type { ExtensionAPI, SessionStartEvent } from "@earendil-works/pi-coding-agent";
9
+ import type { ExtensionAPI, ExtensionContext, SessionShutdownEvent, SessionStartEvent } from "@earendil-works/pi-coding-agent";
7
10
  import type { ChildIdentity } from "./domain.js";
8
- import { PauseGate } from "./pause-gate.js";
9
11
  export type ChildLifecycleKind = "left_session" | "tui_quit" | "user_interrupt";
10
12
  export interface ChildBridgeState {
11
13
  bound: boolean;
12
14
  boundSessionId: string;
13
- readonly gate: PauseGate;
14
- }
15
- export interface ChildPauseSocket {
16
- connect?(signal?: AbortSignal): Promise<void>;
17
- reportPaused(generation: number, signal?: AbortSignal): Promise<void>;
18
- onEvent(listener: (event: unknown) => void): () => void;
19
- close(): void;
20
- }
21
- export interface ChildBridgeOptions {
22
- readonly pauseSocket?: ChildPauseSocket;
23
- readonly gate?: PauseGate;
24
- readonly connect?: (socket: ChildPauseSocket, signal?: AbortSignal) => Promise<void>;
25
15
  }
26
16
  export declare function boundSessionIdFromEnv(env?: NodeJS.ProcessEnv): string | undefined;
27
17
  export declare function shouldLeaveBoundSession(params: {
@@ -29,6 +19,28 @@ export declare function shouldLeaveBoundSession(params: {
29
19
  readonly currentSessionId: string;
30
20
  readonly reason: SessionStartEvent["reason"];
31
21
  }): boolean;
22
+ /**
23
+ * The child's session title. A delegated session is kept out of the human's session list by
24
+ * writing it into the child-scoped session directory; this title is what identifies the session
25
+ * when someone opens a child session deliberately.
26
+ */
27
+ export declare function childSessionTitle(params: {
28
+ /** Title requested at spawn; an absent or empty one is derived from the child itself. */
29
+ readonly title: string | undefined;
30
+ readonly subagentId: string;
31
+ readonly agent: string;
32
+ }): string;
33
+ /** Only a session a human operates owns a user-visible quit. */
34
+ /**
35
+ * The shutdown reasons that end this process's bridge connection. A reload loads this extension
36
+ * again in a fresh instance: the old client has to stop dialing, or two clients would compete for
37
+ * one bridge connection. The session binding survives either way.
38
+ */
39
+ export declare function shouldDisposeBridge(reason: SessionShutdownEvent["reason"]): boolean;
40
+ export declare function shouldReportTuiQuit(params: {
41
+ readonly reason: SessionShutdownEvent["reason"];
42
+ readonly mode: ExtensionContext["mode"];
43
+ }): boolean;
32
44
  export declare function isConfirmedUserInterrupt(messages: readonly {
33
45
  role?: string;
34
46
  stopReason?: string;
@@ -37,5 +49,9 @@ export declare function lastActivityText(messages: readonly {
37
49
  role?: string;
38
50
  content?: unknown;
39
51
  }[] | undefined): string;
40
- /** Registers the child-only tools, identity widget, and session-leave reporting. */
52
+ export interface ChildBridgeOptions {
53
+ /** Where connection problems are reported; defaults to this process's stderr. */
54
+ readonly diagnose?: (message: string) => void;
55
+ }
56
+ /** Registers the child-only tools, identity widget, bridge control plane and session reporting. */
41
57
  export declare function registerChildBridge(pi: ExtensionAPI, identity: ChildIdentity, options?: ChildBridgeOptions): ChildBridgeState;
@@ -1,10 +1,7 @@
1
- import { registerChildNudge } from "./child-nudge.js";
2
- import { connectWithRetry, RunnerConnection, sendLifecycleToRunner } from "./connector.js";
3
- import { CHILD_AGENT_ENV_KEY, CHILD_SESSION_ENV_KEY, isRecord } from "./domain.js";
4
- import { PauseGate } from "./pause-gate.js";
5
- import { CANCEL_PAUSE_EVENT, PAUSE_EVENT } from "./protocol.js";
1
+ import { isRecord } from "@hheei/pi-ext-core";
2
+ import { registerChildControl } from "./child-control.js";
3
+ import { CHILD_AGENT_ENV_KEY, CHILD_SESSION_ENV_KEY, CHILD_TITLE_ENV_KEY } from "./domain.js";
6
4
  import { registerChildTools } from "./tools.js";
7
- import { createChildIdentityWidget } from "./widget.js";
8
5
  const USER_INTERRUPT_STOP_REASON = "aborted";
9
6
  export function boundSessionIdFromEnv(env = process.env) {
10
7
  const value = env[CHILD_SESSION_ENV_KEY];
@@ -15,6 +12,34 @@ export function shouldLeaveBoundSession(params) {
15
12
  return false;
16
13
  return params.reason === "new" || params.reason === "resume" || params.reason === "fork";
17
14
  }
15
+ /** Every delegated session title carries this marker, so a child session is recognizable at a glance. */
16
+ const SUBAGENT_TITLE_MARKER = "🤖";
17
+ /**
18
+ * The child's session title. A delegated session is kept out of the human's session list by
19
+ * writing it into the child-scoped session directory; this title is what identifies the session
20
+ * when someone opens a child session deliberately.
21
+ */
22
+ export function childSessionTitle(params) {
23
+ const title = params.title?.trim();
24
+ if (title !== undefined && title !== "")
25
+ return `${SUBAGENT_TITLE_MARKER} ${title}`;
26
+ const agent = params.agent.trim();
27
+ return agent === ""
28
+ ? `${SUBAGENT_TITLE_MARKER} ${params.subagentId}`
29
+ : `${SUBAGENT_TITLE_MARKER} ${agent} · ${params.subagentId}`;
30
+ }
31
+ /** Only a session a human operates owns a user-visible quit. */
32
+ /**
33
+ * The shutdown reasons that end this process's bridge connection. A reload loads this extension
34
+ * again in a fresh instance: the old client has to stop dialing, or two clients would compete for
35
+ * one bridge connection. The session binding survives either way.
36
+ */
37
+ export function shouldDisposeBridge(reason) {
38
+ return reason === "quit" || reason === "reload";
39
+ }
40
+ export function shouldReportTuiQuit(params) {
41
+ return params.reason === "quit" && params.mode === "tui";
42
+ }
18
43
  export function isConfirmedUserInterrupt(messages) {
19
44
  if (messages === undefined)
20
45
  return false;
@@ -53,81 +78,80 @@ function messageText(content) {
53
78
  function currentSessionId(ctx) {
54
79
  return ctx.sessionManager.getSessionId();
55
80
  }
56
- function reportLifecycle(identity, kind, sessionId, message) {
57
- void sendLifecycleToRunner(identity, {
58
- type: "child_lifecycle",
59
- parentSessionId: identity.parentSessionId,
60
- childId: identity.subagentId,
61
- runtimeIdentity: identity.runtimeIdentity,
62
- kind,
63
- sessionId,
64
- ...(message === undefined || message === "" ? {} : { message }),
65
- }).catch((error) => {
66
- console.error(`pi-subagents: failed to report ${kind}: ${error instanceof Error ? error.message : String(error)}`);
67
- });
68
- }
69
- /** Registers the child-only tools, identity widget, and session-leave reporting. */
81
+ /** Registers the child-only tools, identity widget, bridge control plane and session reporting. */
70
82
  export function registerChildBridge(pi, identity, options = {}) {
71
83
  const boundSessionId = boundSessionIdFromEnv() ?? "";
72
- const gate = options.gate ?? new PauseGate();
73
84
  const state = {
74
85
  bound: boundSessionId !== "",
75
86
  boundSessionId,
76
- gate,
77
87
  };
78
- const nudge = registerChildNudge(pi);
88
+ const isBound = (sessionId) => state.bound && (state.boundSessionId === "" || sessionId === state.boundSessionId);
89
+ const control = registerChildControl(pi, {
90
+ endpoint: identity.endpoint,
91
+ identity,
92
+ isActive: () => state.bound,
93
+ diagnose: options.diagnose ??
94
+ ((message) => {
95
+ if (process.env.DEBUG || process.env.PI_SUBAGENTS_DEBUG) {
96
+ console.error(`pi-subagents: ${message}`);
97
+ }
98
+ }),
99
+ });
100
+ const report = async (operation, payload, signal) => await control.report(operation, payload, signal);
79
101
  registerChildTools(pi, identity, {
80
- onReport: () => nudge.markReported(),
81
- isBound: (sessionId) => state.bound && (state.boundSessionId === "" || sessionId === state.boundSessionId),
102
+ report,
103
+ isBound,
82
104
  });
83
105
  const stop = new AbortController();
84
- const pauseSocket = options.pauseSocket ?? openChildPauseSocket(identity);
85
- pauseSocket.onEvent((event) => {
86
- if (!isRecord(event) || typeof event.type !== "string")
87
- return;
88
- if (event.type === PAUSE_EVENT && typeof event.generation === "number") {
89
- gate.request(event.generation);
90
- return;
91
- }
92
- if (event.type === CANCEL_PAUSE_EVENT) {
93
- gate.cancel(typeof event.generation === "number" ? event.generation : undefined);
94
- }
95
- });
96
- const connectPause = options.connect ?? defaultConnectPause;
97
- void connectPause(pauseSocket, stop.signal).catch((error) => {
98
- console.error(`pi-subagents: pause bridge failed: ${error instanceof Error ? error.message : String(error)}`);
99
- });
100
- let identityWidget;
101
- const disposeWidget = () => {
102
- identityWidget?.dispose();
103
- identityWidget = undefined;
104
- };
105
- const showIdentity = (ctx) => {
106
- disposeWidget();
107
- if (!state.bound || ctx.mode !== "tui")
108
- return;
109
- const tools = pi.getAllTools();
110
- identityWidget = createChildIdentityWidget(pi, ctx, stop.signal, {
111
- agent: process.env[CHILD_AGENT_ENV_KEY] ?? "",
112
- toolCount: tools.length,
106
+ const reportLifecycle = (kind, sessionId, message) => {
107
+ control.sendEvent({
108
+ type: "child_lifecycle",
109
+ parentSessionId: identity.parentSessionId,
110
+ childId: identity.subagentId,
111
+ runtimeIdentity: identity.runtimeIdentity,
112
+ kind,
113
+ sessionId,
114
+ ...(message === undefined || message === "" ? {} : { message }),
113
115
  });
114
116
  };
115
117
  const leave = (sessionId) => {
116
118
  if (!state.bound)
117
119
  return;
118
120
  state.bound = false;
119
- gate.cancel();
120
- pauseSocket.close();
121
- disposeWidget();
122
- nudge.dispose();
123
- reportLifecycle(identity, "left_session", sessionId);
121
+ reportLifecycle("left_session", sessionId);
122
+ // This process serves another session from now on, so it is never this child again: closing the
123
+ // bridge for good is what keeps a parent that missed the notice (its socket was down, or it
124
+ // restarted) from adopting this process — and later stopping the panel the human is using.
125
+ control.dispose();
126
+ };
127
+ /**
128
+ * A launch already knows which session it was started for, so the title is written the first
129
+ * time this process sees that session. One write per session is enough: the name lives in the
130
+ * session file, and re-appending it on every reload would only add entries.
131
+ */
132
+ let titledSessionId = "";
133
+ const titleSession = (sessionId) => {
134
+ if (titledSessionId === sessionId)
135
+ return;
136
+ titledSessionId = sessionId;
137
+ pi.setSessionName(childSessionTitle({
138
+ title: process.env[CHILD_TITLE_ENV_KEY],
139
+ subagentId: identity.subagentId,
140
+ agent: process.env[CHILD_AGENT_ENV_KEY] ?? "",
141
+ }));
124
142
  };
125
143
  pi.on("session_start", (event, ctx) => {
126
144
  const sessionId = currentSessionId(ctx);
145
+ // Connecting here is what makes a live bridge mean a live session: the parent's first request
146
+ // can never arrive before this process has a session to answer it with.
147
+ control.start(ctx);
148
+ if (ctx.ui?.setToolsExpanded) {
149
+ ctx.ui.setToolsExpanded(false);
150
+ }
127
151
  if (!state.bound && state.boundSessionId === "") {
128
152
  state.boundSessionId = sessionId;
129
153
  state.bound = true;
130
- showIdentity(ctx);
154
+ titleSession(sessionId);
131
155
  return;
132
156
  }
133
157
  if (shouldLeaveBoundSession({
@@ -138,24 +162,19 @@ export function registerChildBridge(pi, identity, options = {}) {
138
162
  leave(sessionId);
139
163
  return;
140
164
  }
141
- if (state.bound && sessionId === state.boundSessionId)
142
- showIdentity(ctx);
143
- });
144
- pi.on("session_shutdown", (event) => {
145
- if (event.reason === "quit" && state.bound) {
146
- reportLifecycle(identity, "tui_quit", state.boundSessionId);
165
+ if (state.bound && sessionId === state.boundSessionId) {
166
+ titleSession(sessionId);
147
167
  }
148
- disposeWidget();
149
- if (event.reason === "quit" ||
150
- event.reason === "new" ||
151
- event.reason === "resume" ||
152
- event.reason === "fork") {
153
- nudge.dispose();
168
+ });
169
+ pi.on("session_shutdown", (event, ctx) => {
170
+ if (state.bound && shouldReportTuiQuit({ reason: event.reason, mode: ctx.mode })) {
171
+ reportLifecycle("tui_quit", state.boundSessionId);
154
172
  }
155
- if (event.reason === "quit") {
156
- gate.cancel();
157
- pauseSocket.close();
173
+ if (shouldDisposeBridge(event.reason)) {
174
+ // The process is going away (quit) or this extension is about to be loaded again (reload):
175
+ // either way this client stops dialing, and the reloaded bridge serves the same session.
158
176
  stop.abort();
177
+ control.dispose();
159
178
  }
160
179
  });
161
180
  pi.on("agent_end", (event, ctx) => {
@@ -165,50 +184,9 @@ export function registerChildBridge(pi, identity, options = {}) {
165
184
  if (!isConfirmedUserInterrupt(messages))
166
185
  return;
167
186
  const activity = lastActivityText(messages);
168
- reportLifecycle(identity, "user_interrupt", state.boundSessionId, activity === ""
187
+ reportLifecycle("user_interrupt", state.boundSessionId, activity === ""
169
188
  ? "Task is unfinished and waiting for user intent."
170
189
  : `Task is unfinished and waiting for user intent. Last activity: ${activity}`);
171
190
  });
172
- pi.on("turn_end", async () => {
173
- if (!state.bound || !gate.holding)
174
- return;
175
- const generation = gate.generation;
176
- try {
177
- await pauseSocket.reportPaused(generation, stop.signal);
178
- }
179
- catch (error) {
180
- if (stop.signal.aborted)
181
- return;
182
- console.error(`pi-subagents: failed to ack pause: ${error instanceof Error ? error.message : String(error)}`);
183
- }
184
- await gate.wait(stop.signal);
185
- });
186
191
  return state;
187
192
  }
188
- function openChildPauseSocket(identity) {
189
- const connection = new RunnerConnection({
190
- endpoint: identity.endpoint,
191
- identity,
192
- token: identity.token,
193
- role: "bridge",
194
- });
195
- return {
196
- connect: (signal) => connection.connect(signal),
197
- async reportPaused(generation, signal) {
198
- await connection.request("report_paused", {
199
- type: "report_paused",
200
- parentSessionId: identity.parentSessionId,
201
- childId: identity.subagentId,
202
- runtimeIdentity: identity.runtimeIdentity,
203
- generation,
204
- }, signal === undefined ? {} : { signal });
205
- },
206
- onEvent: (listener) => connection.onEvent(listener),
207
- close: () => connection.close(),
208
- };
209
- }
210
- async function defaultConnectPause(socket, signal) {
211
- if (socket.connect === undefined)
212
- return;
213
- await connectWithRetry({ connect: (abort) => socket.connect?.(abort) ?? Promise.resolve() }, signal === undefined ? {} : { signal });
214
- }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Child-side control plane.
3
+ *
4
+ * The parent drives a child, and the child reports back, over one bridge connection. Every parent
5
+ * request is executed with this process's own Pi primitives, so a child presented in a host panel
6
+ * and a child running headless are controlled identically and no second transport (an RPC writer,
7
+ * a pause handshake) has to be kept in sync with the child's real state.
8
+ */
9
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
10
+ import type { ChildIdentity } from "./domain.js";
11
+ import { type ChildInputSource } from "./protocol.js";
12
+ export interface ChildControl {
13
+ /**
14
+ * Opens the bridge connection for the session this process is now serving. Called from
15
+ * `session_start`, so a connected bridge always means a live, ready child session.
16
+ */
17
+ start(context?: ExtensionContext): void;
18
+ /**
19
+ * Sends a report the parent must acknowledge: `contact_parent`, `task_result`. An aborted
20
+ * signal gives up on it, so a cancelled tool call does not wait for an unreachable parent.
21
+ */
22
+ report(operation: string, payload: unknown, signal?: AbortSignal): Promise<unknown>;
23
+ /** Sends an advisory frame: child lifecycle signals. */
24
+ sendEvent(event: unknown): void;
25
+ dispose(): void;
26
+ }
27
+ export interface ChildControlOptions {
28
+ /** The parent's bridge socket. */
29
+ readonly endpoint: string;
30
+ readonly identity: ChildIdentity;
31
+ /**
32
+ * False while this process is serving a session the parent did not launch. A bridge belongs to
33
+ * one child, so whatever session the process serves next must neither forward its events nor
34
+ * count as that child's input.
35
+ */
36
+ readonly isActive?: () => boolean;
37
+ readonly diagnose?: (message: string) => void;
38
+ }
39
+ /** The source of a Pi input event, when it is one the parent is allowed to hear about. */
40
+ export declare function childInputSource(event: unknown): ChildInputSource | undefined;
41
+ /** Reads the `{ message }` payload both prompt-shaped operations use. */
42
+ export declare function promptMessage(payload: unknown): string;
43
+ /**
44
+ * Answers a parent request with this child's Pi state. Idleness is the child's own answer: the
45
+ * parent never infers it from how long ago it last heard something.
46
+ */
47
+ export declare function childState(ctx: ExtensionContext): Record<string, unknown>;
48
+ /**
49
+ * Truncates bulky data (like huge file reads, grep blocks, or base64 images) in advisory events
50
+ * before they are serialized and pushed over the bridge. The parent projector only requires
51
+ * turn-level signals, not multi-megabyte payloads.
52
+ */
53
+ export declare function sanitizeEventForBridge(event: unknown): unknown;
54
+ /**
55
+ * Registers the bridge connection and the Pi primitives behind it. The connection keeps dialing
56
+ * while the parent is away, so a panel child survives a parent restart without losing its reports.
57
+ */
58
+ export declare function registerChildControl(pi: ExtensionAPI, options: ChildControlOptions): ChildControl;
@@ -0,0 +1,176 @@
1
+ import { errorMessage, isRecord } from "@hheei/pi-ext-core";
2
+ import { ChildBridgeClient } from "./bridge-client.js";
3
+ import { BridgeError } from "./protocol.js";
4
+ import { FORWARDED_PI_EVENT_TYPES, shouldForwardPiEvent } from "./rpc-events.js";
5
+ const CHILD_INPUT_SOURCES = ["interactive", "extension", "rpc"];
6
+ /** The source of a Pi input event, when it is one the parent is allowed to hear about. */
7
+ export function childInputSource(event) {
8
+ if (!isRecord(event) || typeof event.source !== "string")
9
+ return undefined;
10
+ return CHILD_INPUT_SOURCES.includes(event.source)
11
+ ? event.source
12
+ : undefined;
13
+ }
14
+ /** Reads the `{ message }` payload both prompt-shaped operations use. */
15
+ export function promptMessage(payload) {
16
+ if (!isRecord(payload) || typeof payload.message !== "string" || payload.message.trim() === "") {
17
+ throw new BridgeError("invalid_payload", "A prompt request requires a non-empty message");
18
+ }
19
+ return payload.message;
20
+ }
21
+ /**
22
+ * Answers a parent request with this child's Pi state. Idleness is the child's own answer: the
23
+ * parent never infers it from how long ago it last heard something.
24
+ */
25
+ export function childState(ctx) {
26
+ const sessionPath = ctx.sessionManager.getSessionFile();
27
+ return {
28
+ idle: ctx.isIdle(),
29
+ pendingMessages: ctx.hasPendingMessages(),
30
+ sessionId: ctx.sessionManager.getSessionId(),
31
+ ...(sessionPath ? { sessionPath } : {}),
32
+ };
33
+ }
34
+ /**
35
+ * Truncates bulky data (like huge file reads, grep blocks, or base64 images) in advisory events
36
+ * before they are serialized and pushed over the bridge. The parent projector only requires
37
+ * turn-level signals, not multi-megabyte payloads.
38
+ */
39
+ export function sanitizeEventForBridge(event) {
40
+ if (!isRecord(event))
41
+ return event;
42
+ if (event.type === "tool_execution_end" && isRecord(event.result)) {
43
+ const result = event.result;
44
+ if (Array.isArray(result.content)) {
45
+ const sanitizedContent = result.content.map((part) => {
46
+ if (isRecord(part) &&
47
+ part.type === "text" &&
48
+ typeof part.text === "string" &&
49
+ part.text.length > 2000) {
50
+ return {
51
+ ...part,
52
+ text: `${part.text.slice(0, 1000)}\n... [truncated for bridge event]`,
53
+ };
54
+ }
55
+ if (isRecord(part) && part.type === "image") {
56
+ return {
57
+ type: "image",
58
+ mimeType: part.mimeType,
59
+ data: "[image omitted for bridge event]",
60
+ };
61
+ }
62
+ return part;
63
+ });
64
+ return { ...event, result: { ...result, content: sanitizedContent } };
65
+ }
66
+ }
67
+ return event;
68
+ }
69
+ /**
70
+ * Registers the bridge connection and the Pi primitives behind it. The connection keeps dialing
71
+ * while the parent is away, so a panel child survives a parent restart without losing its reports.
72
+ */
73
+ export function registerChildControl(pi, options) {
74
+ const isActive = options.isActive ?? (() => true);
75
+ let ctx;
76
+ const requireContext = () => {
77
+ if (ctx === undefined) {
78
+ throw new BridgeError("session_not_ready", "This child has no active Pi session yet");
79
+ }
80
+ return ctx;
81
+ };
82
+ const deliverPrompt = (payload, deliverAs) => {
83
+ const message = promptMessage(payload);
84
+ // A message needs a session to land in: while the process is between sessions the parent
85
+ // hears that instead of a silent drop.
86
+ requireContext();
87
+ if (deliverAs !== undefined)
88
+ pi.sendUserMessage(message, { deliverAs });
89
+ else
90
+ pi.sendUserMessage(message);
91
+ return { accepted: true };
92
+ };
93
+ const handleRequest = async (request) => {
94
+ if (!isActive()) {
95
+ // The process serves another session now, so it is not this child's runtime: answering
96
+ // would drive a session the parent never launched.
97
+ throw new BridgeError("inactive_session", "This process no longer serves the delegated session");
98
+ }
99
+ switch (request.operation) {
100
+ case "prompt":
101
+ return deliverPrompt(request.payload, undefined);
102
+ case "steer":
103
+ return deliverPrompt(request.payload, "steer");
104
+ case "follow_up":
105
+ return deliverPrompt(request.payload, "followUp");
106
+ case "get_state":
107
+ return childState(requireContext());
108
+ case "get_entries":
109
+ return { entries: requireContext().sessionManager.getEntries() };
110
+ case "abort":
111
+ requireContext().abort();
112
+ return { accepted: true };
113
+ case "shutdown":
114
+ requireContext().shutdown();
115
+ return { accepted: true };
116
+ default:
117
+ throw new BridgeError("unsupported_operation", `This child does not implement ${request.operation}`);
118
+ }
119
+ };
120
+ const client = new ChildBridgeClient({
121
+ endpoint: options.endpoint,
122
+ identity: options.identity,
123
+ handleRequest,
124
+ ...(options.diagnose === undefined ? {} : { diagnose: options.diagnose }),
125
+ });
126
+ const dispose = [];
127
+ // `pi.on` is declared as literal-name overloads, so a loop over the forwarded event types cannot
128
+ // be typed through them; every name in FORWARDED_PI_EVENT_TYPES is a real Pi event.
129
+ const onPiEvent = pi.on;
130
+ for (const type of FORWARDED_PI_EVENT_TYPES) {
131
+ dispose.push(onPiEvent(type, (event) => {
132
+ if (!isActive())
133
+ return;
134
+ if (shouldForwardPiEvent(event))
135
+ client.sendEvent(sanitizeEventForBridge(event));
136
+ }));
137
+ }
138
+ dispose.push(pi.on("input", (event) => {
139
+ if (!isActive())
140
+ return;
141
+ const source = childInputSource(event);
142
+ if (source !== undefined)
143
+ client.sendChildInput(source);
144
+ }));
145
+ dispose.push(pi.on("session_start", (_event, context) => {
146
+ ctx = context;
147
+ }));
148
+ dispose.push(pi.on("session_shutdown", () => {
149
+ ctx = undefined;
150
+ }));
151
+ let started = false;
152
+ return {
153
+ start(context) {
154
+ if (context !== undefined)
155
+ ctx = context;
156
+ if (started)
157
+ return;
158
+ started = true;
159
+ client.start();
160
+ },
161
+ report: (operation, payload, signal) => client.sendReport(operation, payload, signal),
162
+ sendEvent: (event) => client.sendEvent(event),
163
+ dispose: () => {
164
+ for (const off of dispose) {
165
+ try {
166
+ off();
167
+ }
168
+ catch (error) {
169
+ options.diagnose?.(`could not remove a bridge listener: ${errorMessage(error)}`);
170
+ }
171
+ }
172
+ started = false;
173
+ client.close();
174
+ },
175
+ };
176
+ }
@@ -0,0 +1,21 @@
1
+ export declare const DEFAULT_TERMINATE_GRACE_MS = 2000;
2
+ export interface ChildProcessOptions {
3
+ readonly command: string;
4
+ readonly args: readonly string[];
5
+ readonly cwd: string;
6
+ readonly env: NodeJS.ProcessEnv;
7
+ readonly diagnose?: (message: string) => void;
8
+ }
9
+ export interface ChildRuntime {
10
+ readonly pid: number | undefined;
11
+ readonly alive: boolean;
12
+ /** Resolves when the process is gone; a signal death reports a null code. */
13
+ readonly exited: Promise<number | null>;
14
+ /** SIGTERM, then SIGKILL after the grace window. Idempotent. */
15
+ terminate(graceMs?: number): Promise<void>;
16
+ }
17
+ /**
18
+ * Starts a detached-from-the-terminal child Pi. The caller keeps the handle: this package has no
19
+ * supervisor process, so nothing else can end a background child after its parent is gone.
20
+ */
21
+ export declare function spawnChildRuntime(options: ChildProcessOptions): ChildRuntime;