@hheei/pi-subagents 0.1.2 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +45 -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 -60
  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
@@ -1,6 +1,4 @@
1
- import { execFile } from "node:child_process";
2
- import { promisify } from "node:util";
3
- const execFileAsync = promisify(execFile);
1
+ import { errorMessage, runCommand, shellQuote } from "@hheei/pi-ext-core";
4
2
  const DEFAULT_HOST_ORDER = ["herdr", "cmux"];
5
3
  /** Selects a presentation host visibly; an explicit unavailable host never falls back. */
6
4
  export async function selectHostAdapter(options) {
@@ -20,11 +18,9 @@ export async function selectHostAdapter(options) {
20
18
  available: true,
21
19
  selectedHost: host,
22
20
  adapter,
23
- explicit,
24
21
  reason: priorFailures === ""
25
22
  ? `${explicit ? "explicit" : "default"} host ${host} is available`
26
23
  : `selected ${host} after ${priorFailures}`,
27
- attempts,
28
24
  };
29
25
  }
30
26
  }
@@ -32,36 +28,38 @@ export async function selectHostAdapter(options) {
32
28
  return {
33
29
  available: false,
34
30
  selectedHost: null,
35
- explicit,
36
31
  reason: explicit
37
32
  ? `explicit host unavailable: ${failure}`
38
33
  : `no presentation host available: ${failure}`,
39
- attempts,
40
34
  };
41
35
  }
42
- export class HostCommandError extends Error {
43
- kind;
44
- result;
45
- constructor(kind, message, result) {
46
- super(message);
47
- this.name = "HostCommandError";
48
- this.kind = kind;
49
- this.result = result;
50
- }
36
+ const HOST_COMMAND_MAX_STDOUT_BYTES = 1024 * 1024;
37
+ function errnoCode(error) {
38
+ // Node's spawn errors are plain Errors carrying a `code` string; narrowing here
39
+ // keeps the ENOENT decision below honest about the caught value being unknown.
40
+ const code = error.code;
41
+ return typeof code === "string" ? code : undefined;
51
42
  }
52
43
  export const systemHostCommandRunner = {
53
44
  async run(command, args, options = {}) {
54
45
  try {
55
- const result = await execFileAsync(command, [...args], {
56
- timeout: options.timeoutMs,
57
- maxBuffer: 1024 * 1024,
58
- windowsHide: true,
46
+ const result = await runCommand(command, [...args], {
47
+ ...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }),
48
+ maxStdoutBytes: HOST_COMMAND_MAX_STDOUT_BYTES,
59
49
  });
60
- return { stdout: result.stdout, stderr: result.stderr, exitCode: 0, timedOut: false };
50
+ return {
51
+ stdout: result.stdout.toString("utf8"),
52
+ stderr: result.stdoutTruncated
53
+ ? `output exceeded ${HOST_COMMAND_MAX_STDOUT_BYTES} bytes`
54
+ : result.stderr.toString("utf8"),
55
+ // A command killed by a signal reports no exit code; the kill still counts as
56
+ // a failure through `timedOut` or `hostCleanupFailed`.
57
+ exitCode: result.signal === null ? result.code : null,
58
+ timedOut: result.timedOut,
59
+ };
61
60
  }
62
61
  catch (error) {
63
- const value = error;
64
- if (value.code === "ENOENT") {
62
+ if (errnoCode(error) === "ENOENT") {
65
63
  return {
66
64
  stdout: "",
67
65
  stderr: `command not found: ${command}`,
@@ -70,10 +68,10 @@ export const systemHostCommandRunner = {
70
68
  };
71
69
  }
72
70
  return {
73
- stdout: value.stdout ?? "",
74
- stderr: value.stderr ?? value.message ?? String(error),
75
- exitCode: typeof value.code === "number" ? value.code : null,
76
- timedOut: value.killed === true,
71
+ stdout: "",
72
+ stderr: errorMessage(error),
73
+ exitCode: null,
74
+ timedOut: false,
77
75
  };
78
76
  }
79
77
  },
@@ -106,27 +104,47 @@ export function createHerdrHostAdapter(options) {
106
104
  ? { host: "herdr", available: false, reason: "herdr probe returned no current pane id" }
107
105
  : { host: "herdr", available: true, reason: `current pane ${pane}` };
108
106
  },
109
- async attach(spec) {
110
- const current = await runner.run("herdr", ["pane", "current", "--current"], { timeoutMs });
111
- const parentPane = parseHerdrPaneId(current.stdout);
112
- if (parentPane === undefined) {
113
- throw new HostCommandError("pane", "herdr current pane unavailable", current);
114
- }
115
- const split = await runner.run("herdr", herdrSplitArgs(parentPane, spec), { timeoutMs });
116
- const childPane = parseHerdrPaneId(split.stdout);
117
- if (childPane === undefined) {
118
- throw new HostCommandError("pane", "herdr split returned no child pane id", split);
107
+ async open(spec) {
108
+ // A dedicated tab, not a split of the parent pane: the child TUI owns its own tab, the
109
+ // parent session keeps its layout and focus, and the tab is labeled with the child id so
110
+ // a human can tell whose panel it is.
111
+ const workspaceId = await resolveHerdrProcessWorkspaceId(runner, timeoutMs, options.env ?? process.env);
112
+ const created = await runner.run("herdr", herdrTabCreateArgs(spec, workspaceId), {
113
+ timeoutMs,
114
+ });
115
+ const target = parseHerdrTab(created.stdout);
116
+ if (target === undefined) {
117
+ throw new Error("herdr tab create returned no tab and root pane id");
119
118
  }
120
- // pane run sends text+Enter to the new shell. Timeout is not rollback: the pane
119
+ // pane run sends text+Enter to the tab's root shell. Timeout is not rollback: the tab
121
120
  // already exists and the process may still start after the CLI deadline.
122
- const launch = await runner.run("herdr", ["pane", "run", childPane, quotedArgv(spec.command, spec.argv)], { timeoutMs });
121
+ const launch = await runner.run("herdr", ["pane", "run", target.paneId, quotedArgv(spec.command, spec.argv)], { timeoutMs });
123
122
  return hostAttachment({
124
- identity: { host: "herdr", attachmentId: childPane, createdBy: options.ownerId },
123
+ identity: { host: "herdr", attachmentId: target.tabId, createdBy: options.ownerId },
125
124
  launch,
126
- noun: "pane",
127
- observe: () => runner.run("herdr", ["pane", "process-info", "--pane", childPane], { timeoutMs }),
128
- isAlive: (result) => result.exitCode === 0 && herdrChildProcessIsRunning(result.stdout),
129
- close: () => runner.run("herdr", ["pane", "close", childPane], { timeoutMs }),
125
+ noun: "tab",
126
+ reportsFocus: true,
127
+ observe: async () => {
128
+ // Aliveness is the pane's foreground process; focus is a fact about the tab *and*
129
+ // its workspace, so both listings are read together with it.
130
+ const [process, tabs, workspaces] = await Promise.all([
131
+ runner.run("herdr", ["pane", "process-info", "--pane", target.paneId], { timeoutMs }),
132
+ runner.run("herdr", ["tab", "list"], { timeoutMs }),
133
+ runner.run("herdr", ["workspace", "list"], { timeoutMs }),
134
+ ]);
135
+ // A listing that did not answer says nothing about focus: reading it anyway turns a
136
+ // failed command into "unfocused", and the caller closes a panel on that answer.
137
+ const listed = herdrListingSucceeded(tabs) && herdrListingSucceeded(workspaces)
138
+ ? herdrPanelFocused(tabs.stdout, workspaces.stdout, target.tabId)
139
+ : undefined;
140
+ const alive = herdrPaneAlive(process);
141
+ return {
142
+ process,
143
+ ...(alive === undefined ? {} : { alive }),
144
+ ...(listed === undefined ? {} : { focused: listed }),
145
+ };
146
+ },
147
+ close: () => runner.run("herdr", ["tab", "close", target.tabId], { timeoutMs }),
130
148
  ownsAttachment: options.ownsAttachment,
131
149
  });
132
150
  },
@@ -150,18 +168,26 @@ export function createCmuxHostAdapter(options) {
150
168
  }
151
169
  return { host: "cmux", available: true, reason: "cmux responded and exposed capabilities" };
152
170
  },
153
- async attach(spec) {
154
- const launch = await runner.run("cmux", ["--json", "new-split", "right", "--command", cmuxLaunchCommand(spec)], { timeoutMs });
171
+ async open(spec) {
172
+ const launch = await runner.run("cmux", ["--json", "new-surface", "--command", cmuxLaunchCommand(spec)], { timeoutMs });
155
173
  const attachmentId = parseCmuxSurfaceId(launch.stdout);
156
174
  if (attachmentId === undefined) {
157
- throw new HostCommandError("pane", "cmux split returned no surface id; process state is unknown", launch);
175
+ throw new Error("cmux new-surface returned no surface id; process state is unknown");
158
176
  }
159
177
  return hostAttachment({
160
178
  identity: { host: "cmux", attachmentId, createdBy: options.ownerId },
161
179
  launch,
162
180
  noun: "surface",
163
- observe: () => runner.run("cmux", ["--json", "list-panels"], { timeoutMs }),
164
- isAlive: (result) => result.exitCode === 0 && containsString(result.stdout, attachmentId),
181
+ // TODO(cmux-focus): cmux does not report which surface a human is looking at, so
182
+ // observation cannot answer `focused` and idle reclaim fails open here: a cmux child
183
+ // is only ever closed by stop_agent.
184
+ reportsFocus: false,
185
+ observe: async () => {
186
+ const process = await runner.run("cmux", ["--json", "list-panels"], { timeoutMs });
187
+ if (process.timedOut || process.exitCode !== 0)
188
+ return { process };
189
+ return { process, alive: containsString(process.stdout, attachmentId) };
190
+ },
165
191
  close: () => runner.run("cmux", ["--json", "close-surface", "--surface", attachmentId], {
166
192
  timeoutMs,
167
193
  }),
@@ -173,16 +199,21 @@ export function createCmuxHostAdapter(options) {
173
199
  function hostAttachment(options) {
174
200
  const observe = async () => {
175
201
  const result = await options.observe();
202
+ const focused = options.reportsFocus ? result.focused : undefined;
176
203
  return {
177
204
  identity: options.identity,
178
- alive: !result.timedOut && options.isAlive(result),
179
- known: !result.timedOut,
180
- detail: result.stderr || result.stdout,
205
+ // A host that could not answer is not reporting a dead child: `alive: false` is what lets
206
+ // the caller drop the panel and the runtime evidence, so it is only ever said on purpose.
207
+ alive: result.alive === true,
208
+ known: result.alive !== undefined,
209
+ ...(focused === undefined ? {} : { focused }),
210
+ detail: result.process.stderr || result.process.stdout,
181
211
  };
182
212
  };
183
213
  return {
184
214
  identity: options.identity,
185
215
  launch: options.launch,
216
+ reportsFocus: options.reportsFocus,
186
217
  observe,
187
218
  async cleanup() {
188
219
  if (!options.ownsAttachment(options.identity)) {
@@ -199,29 +230,124 @@ function hostAttachment(options) {
199
230
  },
200
231
  };
201
232
  }
202
- function herdrSplitArgs(parentPane, spec) {
233
+ /**
234
+ * Herdr tab creation carries cwd, env, label, and explicit workspace id matching the parent
235
+ * process, ensuring the child tab spawns in the process's own workspace rather than whatever
236
+ * workspace happens to have user focus.
237
+ */
238
+ export function herdrTabCreateArgs(spec, workspaceId) {
203
239
  const args = [
204
- "pane",
205
- "split",
206
- "--pane",
207
- parentPane,
208
- "--direction",
209
- "right",
240
+ "tab",
241
+ "create",
210
242
  "--cwd",
211
243
  spec.cwd,
244
+ "--label",
245
+ spec.config.subagentId,
212
246
  "--no-focus",
213
247
  ];
248
+ if (workspaceId !== undefined && workspaceId !== "") {
249
+ args.push("--workspace", workspaceId);
250
+ }
214
251
  for (const [key, value] of Object.entries(spec.env)) {
215
252
  args.push("--env", `${key}=${value}`);
216
253
  }
217
254
  return args;
218
255
  }
256
+ export function parseHerdrWorkspaceId(output) {
257
+ return parseJsonId(output, ["workspace_id", "workspaceId"]);
258
+ }
259
+ /**
260
+ * Resolves the workspace id of the current process so new tabs open in the same workspace.
261
+ * Reads environment variables first, falling back to herdr pane queries.
262
+ */
263
+ export async function resolveHerdrProcessWorkspaceId(runner, timeoutMs, env = process.env) {
264
+ const envWorkspace = env.HERDR_WORKSPACE_ID?.trim();
265
+ if (envWorkspace)
266
+ return envWorkspace;
267
+ const envPane = env.HERDR_PANE_ID?.trim();
268
+ if (envPane) {
269
+ const res = await runner.run("herdr", ["pane", "get", envPane], { timeoutMs });
270
+ if (res.exitCode === 0) {
271
+ const ws = parseHerdrWorkspaceId(res.stdout);
272
+ if (ws)
273
+ return ws;
274
+ }
275
+ }
276
+ const current = await runner.run("herdr", ["pane", "current", "--current"], { timeoutMs });
277
+ if (current.exitCode === 0) {
278
+ return parseHerdrWorkspaceId(current.stdout);
279
+ }
280
+ return undefined;
281
+ }
282
+ /**
283
+ * Focus is answered from two listings: `tab list` says whether the child's tab is focused and
284
+ * which workspace owns it, `workspace list` says whether that workspace is the rendered one. A
285
+ * child is being watched only when both are focused. Anything missing — a failed listing, an absent
286
+ * field, a tab that is not there — is unknown, never "unfocused", because the caller closes a panel
287
+ * on an unfocused answer.
288
+ */
289
+ export function herdrPanelFocused(tabList, workspaceList, tabId) {
290
+ const tab = herdrListEntry(tabList, "tabs", "tab_id", tabId);
291
+ if (tab === undefined)
292
+ return undefined;
293
+ const tabFocused = tab.focused;
294
+ if (typeof tabFocused !== "boolean")
295
+ return undefined;
296
+ if (!tabFocused)
297
+ return false;
298
+ const workspaceId = tab.workspace_id;
299
+ if (typeof workspaceId !== "string" || workspaceId === "")
300
+ return undefined;
301
+ const workspace = herdrListEntry(workspaceList, "workspaces", "workspace_id", workspaceId);
302
+ const workspaceFocused = workspace?.focused;
303
+ return typeof workspaceFocused === "boolean" ? workspaceFocused : undefined;
304
+ }
305
+ function herdrListEntry(output, collection, key, id) {
306
+ let parsed;
307
+ try {
308
+ parsed = JSON.parse(output);
309
+ }
310
+ catch {
311
+ return undefined;
312
+ }
313
+ const list = findNamedObject(parsed, "result")?.[collection];
314
+ if (!Array.isArray(list))
315
+ return undefined;
316
+ for (const entry of list) {
317
+ if (typeof entry !== "object" || entry === null)
318
+ continue;
319
+ const record = entry;
320
+ if (record[key] === id)
321
+ return record;
322
+ }
323
+ return undefined;
324
+ }
325
+ /**
326
+ * `herdr tab create` answers with the tab and its root pane: the pane runs the LaunchSpec and is
327
+ * what observation watches, while the tab is what cleanup closes.
328
+ */
329
+ function parseHerdrTab(output) {
330
+ let parsed;
331
+ try {
332
+ parsed = JSON.parse(output);
333
+ }
334
+ catch {
335
+ return undefined;
336
+ }
337
+ const tabId = findNamedObject(parsed, "tab")?.tab_id;
338
+ const paneId = findNamedObject(parsed, "root_pane")?.pane_id;
339
+ if (typeof tabId !== "string" || typeof paneId !== "string")
340
+ return undefined;
341
+ return { tabId, paneId };
342
+ }
219
343
  /** Herdr `pane run` concatenates extra argv with spaces, so quoting happens here once. */
220
344
  function quotedArgv(command, argv) {
221
345
  return [command, ...argv].map(shellQuote).join(" ");
222
346
  }
223
347
  /**
224
- * cmux `new-split --command` is shell text. Encode cwd + env + argv without inventing Pi flags.
348
+ * cmux `new-surface --command` is shell text. Encode cwd + env + argv without inventing Pi flags.
349
+ * A surface is cmux's own name for a panel: attaching adds one to the current pane instead of
350
+ * splitting the parent's layout, and cleanup closes exactly that surface.
225
351
  */
226
352
  function cmuxLaunchCommand(spec) {
227
353
  const directory = `cd ${shellQuote(spec.cwd)}`;
@@ -232,9 +358,6 @@ function cmuxLaunchCommand(spec) {
232
358
  .filter((part) => part.length > 0)
233
359
  .join(" ");
234
360
  }
235
- function shellQuote(value) {
236
- return `'${value.replaceAll("'", "'\\''")}'`;
237
- }
238
361
  function containsString(output, target) {
239
362
  try {
240
363
  return valueContainsString(JSON.parse(output), target);
@@ -284,6 +407,34 @@ function findString(value, keys, accept) {
284
407
  }
285
408
  return undefined;
286
409
  }
410
+ /** A listing that failed or timed out is not evidence, whatever its stdout happens to contain. */
411
+ function herdrListingSucceeded(result) {
412
+ return !result.timedOut && result.exitCode === 0;
413
+ }
414
+ /**
415
+ * Reads the pane's process state. A pane that no longer exists is a definite "not alive" — that is
416
+ * how a closed panel looks — while a query that never ran or failed for any other reason is
417
+ * unknown, because the caller closes the panel on "not alive".
418
+ */
419
+ export function herdrPaneAlive(result) {
420
+ if (herdrListingSucceeded(result))
421
+ return herdrChildProcessIsRunning(result.stdout);
422
+ // The CLI writes the error as JSON on stderr.
423
+ const code = herdrErrorCode(result.stderr) ?? herdrErrorCode(result.stdout);
424
+ if (code === "pane_not_found" || code === "tab_not_found")
425
+ return false;
426
+ return undefined;
427
+ }
428
+ /** The error code of a herdr CLI failure, which reports it as JSON. */
429
+ function herdrErrorCode(output) {
430
+ try {
431
+ const parsed = JSON.parse(output);
432
+ return typeof parsed.error?.code === "string" ? parsed.error.code : undefined;
433
+ }
434
+ catch {
435
+ return undefined;
436
+ }
437
+ }
287
438
  function herdrChildProcessIsRunning(output) {
288
439
  try {
289
440
  const info = findNamedObject(JSON.parse(output), "process_info");
@@ -1,3 +1,4 @@
1
+ import { errorMessage } from "@hheei/pi-ext-core";
1
2
  export class JsonLineError extends Error {
2
3
  code;
3
4
  constructor(code, message) {
@@ -47,7 +48,7 @@ export function attachJsonLineReader(stream, options) {
47
48
  options.onValue(JSON.parse(frame.toString("utf8")));
48
49
  }
49
50
  catch (error) {
50
- fail(new JsonLineError("malformed_json", `Malformed JSON line: ${error instanceof Error ? error.message : String(error)}`));
51
+ fail(new JsonLineError("malformed_json", `Malformed JSON line: ${errorMessage(error)}`));
51
52
  }
52
53
  }
53
54
  };
@@ -1,8 +1,8 @@
1
- import type { ChildBridgeEnvironment, EffectiveLaunchConfig, ExecutionMode, PersistenceState, PiInvocation } from "./domain.js";
1
+ import type { ChildBridgeEnvironment, EffectiveLaunchConfig, PersistenceState, PiInvocation, Presentation } from "./domain.js";
2
2
  /**
3
- * The one description of how a child Pi process starts. RPC spawn, native TUI
4
- * attach, and replacement runtimes all build from this; only `mode` and `stdio`
5
- * differ, so no path can silently invent different Pi flags.
3
+ * The one description of how a child Pi process starts. A panel child and a background child are
4
+ * the same Pi invocation; only the presentation (and therefore stdio) differs, so no path can
5
+ * silently invent different Pi flags.
6
6
  */
7
7
  export interface LaunchSpec {
8
8
  /** Runtime binary (node/bun/compiled pi), spawned without a shell. */
@@ -10,16 +10,18 @@ export interface LaunchSpec {
10
10
  /** Full argv: host prefix args plus every Pi flag this child needs. */
11
11
  readonly argv: readonly string[];
12
12
  readonly cwd: string;
13
- readonly mode: ExecutionMode;
13
+ readonly presentation: Presentation;
14
14
  readonly stdio: "pipe" | "inherit";
15
15
  /** Bridge variables to add to the child environment, excluding the controller token. */
16
16
  readonly env: Readonly<Record<string, string>>;
17
17
  readonly config: EffectiveLaunchConfig;
18
18
  }
19
+ export declare const HINDSIGHT_TOOLS: readonly ["hindsight_search_knowledge_pages", "hindsight_list_knowledge_pages", "hindsight_read_knowledge_page", "hindsight_reflect", "hindsight_capture_initiative", "hindsight_ingest_document", "hindsight_sync_status", "hindsight_diagnose"];
20
+ export declare function stripHindsightContent(text: string): string;
19
21
  export interface BuildLaunchSpecOptions {
20
22
  readonly config: EffectiveLaunchConfig;
21
23
  readonly invocation: PiInvocation;
22
- readonly mode: ExecutionMode;
24
+ readonly presentation: Presentation;
23
25
  readonly persistence: PersistenceState;
24
26
  readonly bridge: ChildBridgeEnvironment;
25
27
  }
@@ -2,23 +2,42 @@ import { existsSync } from "node:fs";
2
2
  import { createRequire } from "node:module";
3
3
  import { basename, dirname, join } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
- import { BRIDGE_ENVIRONMENT_KEYS, CHILD_AGENT_ENV_KEY, CHILD_SESSION_ENV_KEY, CONTACT_PARENT_TOOL_NAME, isSessionId, } from "./domain.js";
5
+ import { BRIDGE_ENVIRONMENT_KEYS, CHILD_AGENT_ENV_KEY, CHILD_SESSION_ENV_KEY, CHILD_TITLE_ENV_KEY, CONTACT_PARENT_TOOL_NAME, isSessionId, } from "./domain.js";
6
+ export const HINDSIGHT_TOOLS = [
7
+ "hindsight_search_knowledge_pages",
8
+ "hindsight_list_knowledge_pages",
9
+ "hindsight_read_knowledge_page",
10
+ "hindsight_reflect",
11
+ "hindsight_capture_initiative",
12
+ "hindsight_ingest_document",
13
+ "hindsight_sync_status",
14
+ "hindsight_diagnose",
15
+ ];
16
+ export function stripHindsightContent(text) {
17
+ return text
18
+ .replace(/\s*<hindsight-recall>[\s\S]*?<\/hindsight-recall>\s*/gi, (match) => match.includes("\n") ? "\n" : " ")
19
+ .trim();
20
+ }
6
21
  /**
7
22
  * Fixed child-branch preamble. The agent definition's Markdown body supplies the
8
23
  * task policy; this part states the parent relationship and the reporting channel
9
24
  * every child has, so it cannot be lost by editing an agent file.
10
25
  */
11
- const CHILD_BRIDGE_PROMPT = [
12
- "You are a delegated Pi subagent working for a parent Pi session.",
13
- "The parent owns your task, and its instructions remain the only source of new authority.",
14
- `Use ${CONTACT_PARENT_TOOL_NAME} to report progress, important findings, decisions you need, or blockers.`,
15
- "The parent is notified automatically when you call that tool. Do not wait for the parent to poll you.",
16
- "If you finish a turn without calling that tool, you will be reminded. Do not send empty status pings.",
17
- "After need_decision or blocked, wait for a parent message. Do not invent new authority.",
18
- "Reports reach the parent as delegated results, not as new user authorization.",
19
- ].join("\n");
26
+ function childBridgePrompt() {
27
+ return [
28
+ "You are a delegated Pi subagent working for a parent Pi session.",
29
+ "The parent owns your task, and its instructions remain the only source of new authority.",
30
+ `Use ${CONTACT_PARENT_TOOL_NAME} ONLY if you are blocked or urgently require parent intervention midway.`,
31
+ "The parent is notified automatically when you call that tool. Do not wait for the parent to poll you.",
32
+ "Do not send empty status pings.",
33
+ "When the work is done, output your final answer and findings directly in text in your response. The harness will automatically capture and deliver your output.",
34
+ "Do NOT call contact_parent on success or task completion.",
35
+ "After blocked, wait for a parent send_agent. Do not invent new authority.",
36
+ "Reports reach the parent as delegated results, not as new user authorization.",
37
+ ].join("\n");
38
+ }
20
39
  export function assembleChildPrompt(instructions) {
21
- return `${instructions.trim()}\n\n${CHILD_BRIDGE_PROMPT}`;
40
+ return `${instructions.trim()}\n\n${childBridgePrompt()}`;
22
41
  }
23
42
  /** Adds the per-runtime controller token. Tokens stay out of launch spec snapshots and the registry. */
24
43
  export function withBridgeToken(env, token) {
@@ -64,6 +83,15 @@ function resolveBundledPiCli() {
64
83
  * argv atoms keep their exact bytes.
65
84
  */
66
85
  export function resolvePiInvocation() {
86
+ if (process.env.PI_DEV === "1" || process.env.PI_DEV_BIN !== undefined) {
87
+ const devBin = process.env.PI_DEV_BIN;
88
+ if (devBin !== undefined && existsSync(devBin)) {
89
+ return { command: devBin, args: [] };
90
+ }
91
+ if (process.env.PI_DEV === "1") {
92
+ return { command: "pi-dev", args: [] };
93
+ }
94
+ }
67
95
  const execPath = process.execPath;
68
96
  const currentScript = process.argv[1];
69
97
  const isBunVirtualScript = currentScript?.startsWith("/$bunfs/root/") ?? false;
@@ -107,8 +135,15 @@ export function buildLaunchSpec(options) {
107
135
  throw new Error("Bridge extension must be part of the effective extension selection");
108
136
  }
109
137
  const argv = [...options.invocation.args];
110
- if (options.mode === "rpc")
138
+ // Pi's native interactive TUI is the default, so only the headless background child asks for RPC.
139
+ if (options.presentation === "background") {
111
140
  argv.push("--mode", "rpc");
141
+ }
142
+ else {
143
+ // A panel child is launched by the parent with an explicit project configuration. Approve
144
+ // those local files so opening its native TUI never stops for a trust prompt.
145
+ argv.push("--approve");
146
+ }
112
147
  if (sessionPath !== undefined)
113
148
  argv.push("--session", sessionPath);
114
149
  else
@@ -116,10 +151,14 @@ export function buildLaunchSpec(options) {
116
151
  argv.push("--session-dir", config.sessionDir);
117
152
  argv.push("--provider", config.model.provider, "--model", config.model.id);
118
153
  argv.push("--thinking", config.thinking.level);
119
- if (config.tools.length > 0)
120
- argv.push("--tools", config.tools.join(","));
121
- if (config.excludeTools.length > 0)
122
- argv.push("--exclude-tools", config.excludeTools.join(","));
154
+ const effectiveTools = config.codemodeOnly && !config.tools.includes("codemode")
155
+ ? [...config.tools, "codemode"]
156
+ : config.tools;
157
+ if (effectiveTools.length > 0)
158
+ argv.push("--tools", effectiveTools.join(","));
159
+ const excludeTools = Array.from(new Set([...config.excludeTools, ...HINDSIGHT_TOOLS]));
160
+ if (excludeTools.length > 0)
161
+ argv.push("--exclude-tools", excludeTools.join(","));
123
162
  if (!config.extensions.discovery)
124
163
  argv.push("--no-extensions");
125
164
  for (const path of config.extensions.paths)
@@ -136,13 +175,17 @@ export function buildLaunchSpec(options) {
136
175
  [BRIDGE_ENVIRONMENT_KEYS.endpoint]: options.bridge.endpoint,
137
176
  [CHILD_AGENT_ENV_KEY]: config.agent.displayName ?? config.agent.name,
138
177
  [CHILD_SESSION_ENV_KEY]: config.sessionId,
178
+ // Empty is the defined "derive the title in the child" case, mirroring the task channel below.
179
+ [CHILD_TITLE_ENV_KEY]: config.title ?? "",
180
+ PI_HINDSIGHT_DISABLE: "1",
181
+ ...(config.codemodeOnly ? { PI_CODEMODE_MODE: "only" } : {}),
139
182
  };
140
183
  return Object.freeze({
141
184
  command: options.invocation.command,
142
185
  argv: Object.freeze(argv),
143
186
  cwd: config.cwd,
144
- mode: options.mode,
145
- stdio: options.mode === "rpc" ? "pipe" : "inherit",
187
+ presentation: options.presentation,
188
+ stdio: options.presentation === "background" ? "pipe" : "inherit",
146
189
  env: Object.freeze(env),
147
190
  config,
148
191
  });