pi-daddy 0.48.1 → 0.48.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 (41) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +25 -6
  3. package/dist/executors/captured-execution.d.ts.map +1 -1
  4. package/dist/executors/captured-execution.js +12 -1
  5. package/dist/executors/captured-execution.js.map +1 -1
  6. package/dist/executors/herdr-agent-display.d.ts +10 -0
  7. package/dist/executors/herdr-agent-display.d.ts.map +1 -0
  8. package/dist/executors/herdr-agent-display.js +68 -0
  9. package/dist/executors/herdr-agent-display.js.map +1 -0
  10. package/dist/executors/herdr-launcher.d.ts.map +1 -1
  11. package/dist/executors/herdr-launcher.js +30 -4
  12. package/dist/executors/herdr-launcher.js.map +1 -1
  13. package/dist/executors/herdr-owned.d.ts +2 -0
  14. package/dist/executors/herdr-owned.d.ts.map +1 -1
  15. package/dist/executors/herdr-owned.js +33 -3
  16. package/dist/executors/herdr-owned.js.map +1 -1
  17. package/dist/executors/owned-pi-ui.d.ts +3 -0
  18. package/dist/executors/owned-pi-ui.d.ts.map +1 -0
  19. package/dist/executors/owned-pi-ui.js +114 -0
  20. package/dist/executors/owned-pi-ui.js.map +1 -0
  21. package/dist/executors/owned-pi-view.d.ts +5 -0
  22. package/dist/executors/owned-pi-view.d.ts.map +1 -0
  23. package/dist/executors/owned-pi-view.js +60 -0
  24. package/dist/executors/owned-pi-view.js.map +1 -0
  25. package/dist/executors/owned-worker.d.ts +2 -0
  26. package/dist/executors/owned-worker.d.ts.map +1 -1
  27. package/dist/executors/owned-worker.js +27 -11
  28. package/dist/executors/owned-worker.js.map +1 -1
  29. package/dist/governance/approval-prompt.d.ts +1 -1
  30. package/extensions/approvals.ts +3 -2
  31. package/extensions/execute-child.ts +2 -0
  32. package/extensions/session.ts +3 -8
  33. package/package.json +1 -1
  34. package/src/executors/captured-execution.ts +13 -1
  35. package/src/executors/herdr-agent-display.ts +72 -0
  36. package/src/executors/herdr-launcher.ts +23 -3
  37. package/src/executors/herdr-owned.ts +36 -3
  38. package/src/executors/owned-pi-ui.ts +116 -0
  39. package/src/executors/owned-pi-view.ts +58 -0
  40. package/src/executors/owned-worker.ts +29 -11
  41. package/src/governance/approval-prompt.ts +1 -1
@@ -24,6 +24,8 @@ export interface HerdrOwnedOptions {
24
24
  onDisplay?: (display: (text: string) => void) => void;
25
25
  /** Tests may select a freshly built launcher. Production always uses the packaged build. */
26
26
  launcherPath?: string;
27
+ /** Human-readable role; display only, never a dispatch selector. */
28
+ displayName?: string;
27
29
  }
28
30
  export async function runHerdrOwned(
29
31
  request: OwnedChildRunRequest,
@@ -33,11 +35,16 @@ export async function runHerdrOwned(
33
35
  const directory = await mkdtemp(join(tmpdir(), "pd-herdr-"));
34
36
  const path = join(directory, "owner.sock"),
35
37
  token = randomUUID();
38
+ const role = (options.displayName ?? "child").replace(/[^a-zA-Z0-9_-]/g, "-").slice(0, 48) || "child";
39
+ const display = { source: "pi-daddy:" + randomUUID(), role };
36
40
  const launcher =
37
41
  options.launcherPath ?? fileURLToPath(new URL("../../dist/executors/herdr-launcher.js", import.meta.url));
38
42
  const helper = fileURLToPath(new URL("../../native/linux-x64/worker", import.meta.url));
39
43
  let socket: Socket | undefined, send: ((value: unknown) => void) | undefined;
40
44
  let identity: CapturedWorkerIdentity | undefined, outcome: OwnedChildRunResult | undefined, error: string | undefined;
45
+ let paneId: string | undefined,
46
+ settled = false,
47
+ displayReleased = false;
41
48
  let tabId: string | undefined,
42
49
  entered = false,
43
50
  done = false,
@@ -86,7 +93,7 @@ export async function runHerdrOwned(
86
93
  activityProbe: _probe,
87
94
  ...serializable
88
95
  } = request;
89
- send!({ type: "request", request: serializable });
96
+ send!({ type: "request", request: serializable, display });
90
97
  return;
91
98
  }
92
99
  if (message?.type === "ownership") {
@@ -142,6 +149,7 @@ export async function runHerdrOwned(
142
149
  }
143
150
  if (message?.type === "result" && !outcome) {
144
151
  outcome = message.result;
152
+ displayReleased = message.displayReleased === true;
145
153
  return;
146
154
  }
147
155
  if (message?.type === "failure") {
@@ -190,7 +198,7 @@ export async function runHerdrOwned(
190
198
  "--cwd",
191
199
  request.cwd,
192
200
  "--label",
193
- "pi-daddy governed child",
201
+ "pi-daddy " + role,
194
202
  "--no-focus",
195
203
  ...(options.workspace ? ["--workspace", options.workspace] : []),
196
204
  ]),
@@ -199,7 +207,8 @@ export async function runHerdrOwned(
199
207
  const pane = created.result?.root_pane as { pane_id?: string; tab_id?: string } | undefined;
200
208
  tabId = pane?.tab_id;
201
209
  if (!pane?.pane_id || !tabId) throw Error("Herdr creation returned no exact pane/tab identity");
202
- options.onPane?.(pane.pane_id, tabId);
210
+ paneId = pane.pane_id;
211
+ options.onPane?.(paneId, tabId);
203
212
  if (error || request.signal?.aborted) throw Error(error ?? "cancelled before Herdr command launch");
204
213
  entered = true;
205
214
  const launchReply = await exec([
@@ -218,6 +227,7 @@ export async function runHerdrOwned(
218
227
  if (!outcome || !identity) throw Error(error ?? "Herdr launcher closed without a complete result/ownership");
219
228
  const receipt = await readCapturedWorkerReceipt(identity);
220
229
  if (!receipt) throw Error("Herdr worker supplied no independently verified cleanup receipt");
230
+ settled = true;
221
231
  return { ...outcome, cleanup: { state: "settled", identity, receipt }, ...(error ? { spawnError: error } : {}) };
222
232
  } catch (cause) {
223
233
  fail(cause);
@@ -229,6 +239,7 @@ export async function runHerdrOwned(
229
239
  await new Promise((resolve) => setTimeout(resolve, 50));
230
240
  receipt = await readCapturedWorkerReceipt(identity);
231
241
  }
242
+ settled = Boolean(receipt);
232
243
  return {
233
244
  code: receipt?.workerCode ?? null,
234
245
  text: "",
@@ -250,6 +261,28 @@ export async function runHerdrOwned(
250
261
  request.signal?.removeEventListener("abort", abort);
251
262
  socket?.destroy();
252
263
  await new Promise<void>((resolve) => server.close(() => resolve()));
264
+ // Launcher SIGKILL can bypass its display release. Only the exact native receipt permits this fallback.
265
+ // Sequence ordering applies to an existing source; UI delivery remains best effort and never changes native results.
266
+ if (paneId && identity && !(settled && displayReleased)) {
267
+ try {
268
+ const reply = await exec([
269
+ "pane",
270
+ settled ? "release-agent" : "report-agent",
271
+ paneId,
272
+ "--source",
273
+ display.source,
274
+ "--agent",
275
+ "pi",
276
+ "--seq",
277
+ "3",
278
+ ...(settled ? [] : ["--state", "unknown", "--message", "Native cleanup is unverified"]),
279
+ ]);
280
+ if (reply.code !== 0 || (reply.stdout.trim() && parseReply(reply).error))
281
+ console.error("pi-daddy: Herdr agent sidebar cleanup report unavailable");
282
+ } catch {
283
+ console.error("pi-daddy: Herdr agent sidebar cleanup report unavailable");
284
+ }
285
+ }
253
286
  if (tabId && !options.keepPane) {
254
287
  const closed = parseReply(await exec(["tab", "close", tabId]));
255
288
  if (closed.error && !/tab .* not found|tab_not_found/.test(closed.error))
@@ -0,0 +1,116 @@
1
+ /** Install LAST: observe final extension-transformed Pi events through the private inherited pipe. */
2
+ import { fstatSync } from "node:fs";
3
+ import { Socket } from "node:net";
4
+ import { getAgentDir, ProjectTrustStore, SettingsManager } from "@earendil-works/pi-coding-agent";
5
+ import type { ExtensionAPI, MessageUpdateEvent } from "@earendil-works/pi-coding-agent";
6
+
7
+ import { OWNED_PI_VIEW_READY, failOwnedPiUi as fail } from "./owned-pi-view.ts";
8
+
9
+ const EVENT_FD = 6;
10
+
11
+ export default function ownedPiUi(pi: ExtensionAPI): void {
12
+ try {
13
+ const channel = fstatSync(EVENT_FD);
14
+ if (!channel.isFIFO() && !channel.isSocket()) throw Error("fd 6 is not an inherited event pipe");
15
+ const output = new Socket({ fd: EVENT_FD, readable: false, writable: true });
16
+ output.on("error", fail);
17
+ let pending = Promise.resolve();
18
+ const send = (event: unknown): Promise<void> => {
19
+ let record: string;
20
+ try {
21
+ record = JSON.stringify(event) + "\n";
22
+ } catch (error) {
23
+ return fail(error);
24
+ }
25
+ pending = pending
26
+ .then(
27
+ () =>
28
+ new Promise<void>((resolve) => {
29
+ output.write(record, (error) => (error ? fail(error) : resolve()));
30
+ }),
31
+ )
32
+ .catch(fail);
33
+ return pending;
34
+ };
35
+ // Keep the previous noninteractive trust policy; native UI must not introduce a blocking trust dialog.
36
+ pi.on("project_trust", (event) => {
37
+ try {
38
+ const agentDir = getAgentDir();
39
+ const remembered = new ProjectTrustStore(agentDir).get(event.cwd);
40
+ const trusted =
41
+ remembered ??
42
+ SettingsManager.create(event.cwd, agentDir, { projectTrusted: false }).getDefaultProjectTrust() === "always";
43
+ return { trusted: trusted ? "yes" : "no", remember: false };
44
+ } catch (error) {
45
+ return fail(error);
46
+ }
47
+ });
48
+ let guardedSession: { sessionManager: unknown; sessionId: unknown } | undefined;
49
+ pi.events.on(OWNED_PI_VIEW_READY, (ready) => {
50
+ if (ready && typeof ready === "object" && "sessionManager" in ready && "sessionId" in ready)
51
+ guardedSession = { sessionManager: ready.sessionManager, sessionId: ready.sessionId };
52
+ });
53
+ let started = false;
54
+ pi.on("session_start", (_event, ctx) => {
55
+ try {
56
+ if (started) throw Error("the owned session cannot be replaced");
57
+ if (
58
+ ctx.mode !== "tui" ||
59
+ guardedSession?.sessionManager !== ctx.sessionManager ||
60
+ guardedSession.sessionId !== ctx.sessionManager.getSessionId()
61
+ )
62
+ throw Error("the native Pi view guard is not ready for this session");
63
+ const header = ctx.sessionManager.getHeader();
64
+ if (
65
+ !header ||
66
+ header.type !== "session" ||
67
+ header.version !== 3 ||
68
+ header.id !== ctx.sessionManager.getSessionId()
69
+ )
70
+ throw Error("missing or mismatched actual Pi session header");
71
+ started = true;
72
+ return send(header);
73
+ } catch (error) {
74
+ return fail(error);
75
+ }
76
+ });
77
+ const forward = (event: unknown): Promise<void> => {
78
+ if (!started) fail("Pi event arrived before the guarded session");
79
+ return send(event);
80
+ };
81
+ pi.on("agent_start", forward);
82
+ pi.on("agent_end", forward);
83
+ pi.on("turn_start", forward);
84
+ pi.on("turn_end", forward);
85
+ pi.on("message_start", forward);
86
+ pi.on("message_end", forward);
87
+ pi.on("tool_execution_start", forward);
88
+ pi.on("tool_execution_update", forward);
89
+ pi.on("tool_execution_end", forward);
90
+ pi.on("message_update", (event: MessageUpdateEvent) => {
91
+ // Match Pi's JSON delta projection without copying growing partial-message snapshots.
92
+ if (event.message.role !== "assistant") fail("message_update is not an assistant message");
93
+ const usage = event.message.usage,
94
+ update = event.assistantMessageEvent;
95
+ if (!("partial" in update)) return forward({ type: event.type, usage, assistantMessageEvent: update });
96
+ const { partial, ...delta } = update;
97
+ if (update.type === "toolcall_start") {
98
+ const tool = partial.content[update.contentIndex];
99
+ if (tool?.type !== "toolCall") fail("toolcall_start lacks its actual tool call");
100
+ return forward({
101
+ type: event.type,
102
+ usage,
103
+ assistantMessageEvent: { ...delta, id: tool.id, toolName: tool.name },
104
+ });
105
+ }
106
+ return forward({ type: event.type, usage, assistantMessageEvent: delta });
107
+ });
108
+ pi.on("agent_settled", async (event, ctx) => {
109
+ await forward(event);
110
+ // The pipe has flushed before orderly shutdown; native receipt/session checks remain authoritative.
111
+ ctx.shutdown();
112
+ });
113
+ } catch (error) {
114
+ fail(error);
115
+ }
116
+ }
@@ -0,0 +1,58 @@
1
+ /** Install FIRST: native Pi remains observable, but only its parent can control work. */
2
+ import { writeSync } from "node:fs";
3
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
+
5
+ export const OWNED_PI_VIEW_READY = "pi-daddy:owned-pi-view-ready";
6
+ export function failOwnedPiUi(error: unknown): never {
7
+ try {
8
+ writeSync(2, "pi-daddy: owned Pi UI refused: " + String(error) + "\n");
9
+ } finally {
10
+ process.exit(70);
11
+ }
12
+ }
13
+
14
+ export default function ownedPiView(pi: ExtensionAPI): void {
15
+ try {
16
+ if (typeof pi.registerMarkdownTransformer !== "function") throw Error("Pi lacks its public markdown renderer hook");
17
+ pi.registerMarkdownTransformer((markdown, context) =>
18
+ context.messageType === "assistant-thinking" ? "Thinking hidden" : markdown,
19
+ );
20
+ let guarded = false;
21
+ // No await here: the first session handler installs the guard before another handler can yield.
22
+ pi.on("session_start", (_event, ctx) => {
23
+ try {
24
+ if (guarded) throw Error("the owned session cannot be replaced");
25
+ if (
26
+ ctx.mode !== "tui" ||
27
+ typeof ctx.ui.onTerminalInput !== "function" ||
28
+ typeof ctx.ui.getToolsExpanded !== "function" ||
29
+ typeof ctx.ui.setToolsExpanded !== "function"
30
+ )
31
+ throw Error("Pi lacks the required native TUI controls");
32
+ const unsubscribe = ctx.ui.onTerminalInput((data) => {
33
+ // The renderer consumes this exact terminal response before dispatching any user action.
34
+ if (/^\x1b\[6;\d+;\d+t$/.test(data)) return;
35
+ try {
36
+ if (["\x0f", "\x1b[111;5u", "\x1b[111;5:1u", "\x1b[111;5:2u", "\x1b[27;5;111~"].includes(data))
37
+ ctx.ui.setToolsExpanded(!ctx.ui.getToolsExpanded());
38
+ } catch (error) {
39
+ failOwnedPiUi(error);
40
+ }
41
+ return { consume: true };
42
+ });
43
+ if (typeof unsubscribe !== "function") throw Error("Pi did not install the terminal input guard");
44
+ ctx.ui.setEditorText("");
45
+ ctx.ui.setStatus("pi-daddy-owned", "View only · parent controls the task · Ctrl+O expands tools");
46
+ guarded = true;
47
+ pi.events.emit(OWNED_PI_VIEW_READY, {
48
+ sessionManager: ctx.sessionManager,
49
+ sessionId: ctx.sessionManager.getSessionId(),
50
+ });
51
+ } catch (error) {
52
+ failOwnedPiUi(error);
53
+ }
54
+ });
55
+ } catch (error) {
56
+ failOwnedPiUi(error);
57
+ }
58
+ }
@@ -25,6 +25,8 @@ export interface OwnedChildRunRequest extends ChildRunRequest {
25
25
  onOwnership?: (identity: CapturedWorkerIdentity) => Promise<void> | void;
26
26
  /** JSON protocol consumers observe all bytes without capturing enormous replay streams as answer text. */
27
27
  captureStdout?: boolean;
28
+ /** Actual pane terminal for Pi's native view. Protocol events arrive separately on fd 6. */
29
+ terminalUi?: boolean;
28
30
  /** Bounded diagnostics may truncate without stopping independently captured primary output. */
29
31
  stopOnOutputLimit?: boolean;
30
32
  }
@@ -46,6 +48,8 @@ export async function runOwnedChild(request: OwnedChildRunRequest): Promise<Owne
46
48
  if (request.signal?.aborted) return notStarted("cancelled before helper launch", true);
47
49
  if (process.platform !== "linux" || process.arch !== "x64")
48
50
  return notStarted("captured worker requires qualified Linux x64 helper");
51
+ if (request.terminalUi && (!process.stdin.isTTY || !process.stdout.isTTY || !process.stderr.isTTY))
52
+ return notStarted("native Pi view requires a real pane terminal on stdin, stdout and stderr");
49
53
  let helper: string, root: string, hash: string;
50
54
  const nonce = randomUUID();
51
55
  let binary: import("node:fs/promises").FileHandle | undefined;
@@ -95,13 +99,26 @@ export async function runOwnedChild(request: OwnedChildRunRequest): Promise<Owne
95
99
  {
96
100
  cwd: root,
97
101
  env: request.env,
98
- stdio: ["ignore", "pipe", "pipe", "pipe", "pipe", binary!.fd],
99
- detached: true,
102
+ stdio: request.terminalUi
103
+ ? ["inherit", "inherit", "inherit", "pipe", "pipe", binary!.fd, "pipe"]
104
+ : ["ignore", "pipe", "pipe", "pipe", "pipe", binary!.fd],
105
+ // The UI shares the pane's foreground process group for raw input and SIGWINCH.
106
+ // Cleanup still targets owned descendants by pidfd, never by process group.
107
+ detached: !request.terminalUi,
100
108
  },
101
109
  );
102
110
  void binary!.close();
103
111
  const control = child.stdio[3] as import("node:stream").Duplex;
104
112
  const status = child.stdio[4] as import("node:stream").Readable;
113
+ const streams = {
114
+ stdout: request.terminalUi ? (child.stdio.at(6) as import("node:stream").Readable) : child.stdout,
115
+ stderr: child.stderr,
116
+ };
117
+ const closeObservations = () => {
118
+ streams.stdout?.destroy();
119
+ streams.stderr?.destroy();
120
+ status.destroy();
121
+ };
105
122
  let identity: CapturedWorkerIdentity | undefined;
106
123
  let text = "",
107
124
  count = 0,
@@ -146,9 +163,7 @@ export async function runOwnedChild(request: OwnedChildRunRequest): Promise<Owne
146
163
  stopWatchdog = setTimeout(() => {
147
164
  failure ??= "worker helper failed to establish cleanup before its bound";
148
165
  child.kill("SIGKILL");
149
- child.stdout?.destroy();
150
- child.stderr?.destroy();
151
- status.destroy();
166
+ closeObservations();
152
167
  }, cleanupCeiling + 1000);
153
168
  };
154
169
  function armIdle() {
@@ -160,10 +175,15 @@ export async function runOwnedChild(request: OwnedChildRunRequest): Promise<Owne
160
175
  stop();
161
176
  }, request.idleTimeoutMs);
162
177
  }
163
- child.stdout?.on("data", (bytes) => observe("stdout", bytes));
164
- child.stderr?.on("data", (bytes) => observe("stderr", bytes));
178
+ streams.stdout?.on("data", (bytes: Buffer) => observe("stdout", bytes));
179
+ streams.stderr?.on("data", (bytes: Buffer) => observe("stderr", bytes));
180
+ for (const stream of Object.values(streams))
181
+ stream?.on("error", (error) => {
182
+ failure ??= "child event channel failed: " + String(error);
183
+ stop();
184
+ });
165
185
  for (const stream of ["stdout", "stderr"] as const)
166
- child[stream]?.on("end", () => {
186
+ streams[stream]?.on("end", () => {
167
187
  const tail = decoders[stream].end();
168
188
  if (tail && !(stream === "stdout" && request.captureStdout === false)) {
169
189
  const kept = takeBytes(tail, Math.max(0, (request.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES) - count));
@@ -276,9 +296,7 @@ export async function runOwnedChild(request: OwnedChildRunRequest): Promise<Owne
276
296
  void (async () => {
277
297
  if (!identity || !(await readCapturedWorkerReceipt(identity))) {
278
298
  failure ??= "helper exited without an independent settlement receipt";
279
- child.stdout?.destroy();
280
- child.stderr?.destroy();
281
- status.destroy();
299
+ closeObservations();
282
300
  }
283
301
  })();
284
302
  });
@@ -80,7 +80,7 @@ export interface PromptOutcome {
80
80
 
81
81
  export interface ApprovalGateOptions {
82
82
  ui: ApprovalUI;
83
- /** pi's `ctx.hasUI` — false in print/json mode, and therefore in every governed child. */
83
+ /** Whether a human can answer here. False in print/json mode and view-only child tabs. */
84
84
  hasUI: boolean;
85
85
  /** pi's `ctx.mode`, quoted back in the refusal so an operator can see why. */
86
86
  mode: string;