pi-daddy 0.48.2 → 0.49.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 (87) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +74 -8
  3. package/dist/cli.d.ts +3 -1
  4. package/dist/cli.d.ts.map +1 -1
  5. package/dist/cli.js +18 -0
  6. package/dist/cli.js.map +1 -1
  7. package/dist/executors/captured-execution.d.ts.map +1 -1
  8. package/dist/executors/captured-execution.js +12 -1
  9. package/dist/executors/captured-execution.js.map +1 -1
  10. package/dist/executors/herdr-launcher.d.ts.map +1 -1
  11. package/dist/executors/herdr-launcher.js +22 -3
  12. package/dist/executors/herdr-launcher.js.map +1 -1
  13. package/dist/executors/owned-pi-ui.d.ts +3 -0
  14. package/dist/executors/owned-pi-ui.d.ts.map +1 -0
  15. package/dist/executors/owned-pi-ui.js +114 -0
  16. package/dist/executors/owned-pi-ui.js.map +1 -0
  17. package/dist/executors/owned-pi-view.d.ts +5 -0
  18. package/dist/executors/owned-pi-view.d.ts.map +1 -0
  19. package/dist/executors/owned-pi-view.js +60 -0
  20. package/dist/executors/owned-pi-view.js.map +1 -0
  21. package/dist/executors/owned-worker.d.ts +2 -0
  22. package/dist/executors/owned-worker.d.ts.map +1 -1
  23. package/dist/executors/owned-worker.js +27 -11
  24. package/dist/executors/owned-worker.js.map +1 -1
  25. package/dist/governance/approval-prompt.d.ts +1 -1
  26. package/dist/index.d.ts +1 -0
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +1 -0
  29. package/dist/index.js.map +1 -1
  30. package/dist/kernel/selected-definitions.d.ts.map +1 -1
  31. package/dist/kernel/selected-definitions.js +6 -3
  32. package/dist/kernel/selected-definitions.js.map +1 -1
  33. package/dist/products/dashboard-cli.d.ts.map +1 -1
  34. package/dist/products/dashboard-cli.js +46 -1
  35. package/dist/products/dashboard-cli.js.map +1 -1
  36. package/dist/products/dashboard-display-controls.d.ts +1 -1
  37. package/dist/products/dashboard-display-controls.d.ts.map +1 -1
  38. package/dist/products/dashboard-display-controls.js +11 -3
  39. package/dist/products/dashboard-display-controls.js.map +1 -1
  40. package/dist/products/dashboard-screen.d.ts +3 -1
  41. package/dist/products/dashboard-screen.d.ts.map +1 -1
  42. package/dist/products/dashboard-screen.js +21 -8
  43. package/dist/products/dashboard-screen.js.map +1 -1
  44. package/dist/products/dashboard-session-client.d.ts +9 -0
  45. package/dist/products/dashboard-session-client.d.ts.map +1 -1
  46. package/dist/products/dashboard-session-client.js.map +1 -1
  47. package/dist/products/dashboard-settings.d.ts +28 -0
  48. package/dist/products/dashboard-settings.d.ts.map +1 -0
  49. package/dist/products/dashboard-settings.js +42 -0
  50. package/dist/products/dashboard-settings.js.map +1 -0
  51. package/dist/products/diagnostic-export.d.ts +23 -0
  52. package/dist/products/diagnostic-export.d.ts.map +1 -0
  53. package/dist/products/diagnostic-export.js +264 -0
  54. package/dist/products/diagnostic-export.js.map +1 -0
  55. package/dist/products/diagnostic-filter.d.ts +7 -0
  56. package/dist/products/diagnostic-filter.d.ts.map +1 -0
  57. package/dist/products/diagnostic-filter.js +135 -0
  58. package/dist/products/diagnostic-filter.js.map +1 -0
  59. package/extensions/approvals.ts +3 -2
  60. package/extensions/dashboard-session-server.ts +17 -2
  61. package/extensions/dashboard-settings.ts +149 -0
  62. package/extensions/delegation-operations.ts +81 -2
  63. package/extensions/execute-child.ts +10 -11
  64. package/extensions/execution-controls.ts +7 -2
  65. package/extensions/grants-command.ts +3 -1
  66. package/extensions/grants.ts +3 -0
  67. package/extensions/reload-environment.ts +3 -0
  68. package/extensions/run-delegation.ts +3 -1
  69. package/extensions/runtime-snapshot.ts +9 -1
  70. package/extensions/session.ts +3 -8
  71. package/package.json +1 -1
  72. package/src/cli.ts +24 -1
  73. package/src/executors/captured-execution.ts +13 -1
  74. package/src/executors/herdr-launcher.ts +15 -2
  75. package/src/executors/owned-pi-ui.ts +116 -0
  76. package/src/executors/owned-pi-view.ts +58 -0
  77. package/src/executors/owned-worker.ts +29 -11
  78. package/src/governance/approval-prompt.ts +1 -1
  79. package/src/index.ts +2 -0
  80. package/src/kernel/selected-definitions.ts +5 -3
  81. package/src/products/dashboard-cli.ts +38 -1
  82. package/src/products/dashboard-display-controls.ts +10 -4
  83. package/src/products/dashboard-screen.ts +21 -10
  84. package/src/products/dashboard-session-client.ts +7 -1
  85. package/src/products/dashboard-settings.ts +72 -0
  86. package/src/products/diagnostic-export.ts +288 -0
  87. package/src/products/diagnostic-filter.ts +140 -0
@@ -2,6 +2,7 @@ import {
2
2
  claimDelegationOperation,
3
3
  existingOperationOutcome,
4
4
  finishDelegationOperation,
5
+ operationFinalScope,
5
6
  } from "./delegation-operations.ts";
6
7
  import type { OperationClaim } from "../src/kernel/dispatch-operation.ts";
7
8
  import { reconcileDelegationCapacity, reserveDelegationCapacity } from "./session-capacity.ts";
@@ -552,6 +553,7 @@ export async function runOneDelegation(
552
553
  ...args: Parameters<typeof runOneDelegationImplementation>
553
554
  ): Promise<DelegationOutcome> {
554
555
  const [session, spec, ids, , ctx, signal] = args;
556
+ const finalScope = operationFinalScope(session);
555
557
  const options = args[6] ?? {};
556
558
  const claim = options.operationClaim ?? (await claimDelegationOperation(session, spec, ids, ctx, signal));
557
559
  if (claim?.reused) {
@@ -571,7 +573,7 @@ export async function runOneDelegation(
571
573
  const result = await runOneDelegationImplementation(...args);
572
574
  if (claim) {
573
575
  try {
574
- await finishDelegationOperation(claim, result);
576
+ await finishDelegationOperation(claim, result, session, finalScope);
575
577
  } catch (error) {
576
578
  // A post-execution observation failure must not erase the native final or settlement receipt.
577
579
  result.control = "failed";
@@ -5,6 +5,7 @@ import { openRuntimeSettlement, settlementHash } from "../src/governance/runtime
5
5
  import { reconcileDelegationCapacity } from "./session-capacity.ts";
6
6
  import { agentDir } from "../src/kernel/project-paths.ts";
7
7
  import type { GrantsSession } from "./session.ts";
8
+ import { bindOperationFinals, operationFinal } from "./delegation-operations.ts";
8
9
  export const OPERATION_STATUS_EVENT = "pi-daddy:operation-status:v1";
9
10
  export const RUNTIME_SNAPSHOT_EVENT = "pi-daddy:runtime-snapshot:v1";
10
11
  export function registerRuntimeSnapshot(
@@ -99,6 +100,7 @@ export function registerRuntimeSnapshot(
99
100
  sessionId?: string;
100
101
  cwd?: string;
101
102
  operationId?: string;
103
+ includeFinal?: boolean;
102
104
  reply?: (value: unknown) => void;
103
105
  };
104
106
  if (
@@ -139,7 +141,12 @@ export function registerRuntimeSnapshot(
139
141
  lifecycle.runtimeSnapshotGeneration !== generation
140
142
  )
141
143
  throw Error("operation owner changed during read");
142
- request.reply!({ ...envelope, qualified: true, operation });
144
+ request.reply!({
145
+ ...envelope,
146
+ qualified: true,
147
+ operation,
148
+ ...(request.includeFinal === true && operation ? { final: operationFinal(session, operation) } : {}),
149
+ });
143
150
  } catch (error) {
144
151
  request.reply!({ ...envelope, qualified: false, operation: null, reason: String(error) });
145
152
  }
@@ -172,6 +179,7 @@ export function registerRuntimeSnapshot(
172
179
  }
173
180
  if (sequence !== bindSequence || session.reloadLifecycle !== lifecycle) return;
174
181
  lifecycle.runtimeSettlement = runtime;
182
+ bindOperationFinals(session, sessionId, cwd);
175
183
  bound = { sessionId, cwd, lifecycle: session.reloadLifecycle };
176
184
  } catch (error) {
177
185
  if (sequence === bindSequence && session.reloadLifecycle === lifecycle) problem = String(error);
@@ -694,14 +694,9 @@ export function createGrantsSession(
694
694
  skillPaths: skillPathsFromCatalog(await delegationCatalog(session)),
695
695
  // ADR-0016: operator-authored SKILL.md definitions, so `delegate({agent})` can name one.
696
696
  definitions: session.definitions,
697
- // The herdr executor drives the child after starting it, so its plan must NOT carry `--print`.
698
- // Threaded through the plan rather than patched afterwards: the argv is what the ledger records, and
699
- // an executor quietly rewriting it would make the record describe a spawn that did not happen.
700
- //
701
- // Read live off `session.executor` (ADR-0031) rather than a boolean captured in the factory: the probe
702
- // has not run when this session object is built, so a captured value would plan `--print` for a session
703
- // that turns out to use panes — and `runHerdrPane` refuses a plan containing `--print` by design.
704
- interactive: false, // Both qualified backends use the same one-shot Pi JSON protocol.
697
+ // Plan the real terminal invocation explicitly; the captured backend remains one-shot JSON.
698
+ // Read the selected executor at dispatch, after its live qualification completes.
699
+ interactive: session.executor.kind === "herdr",
705
700
  ...(approved ? { approved } : {}),
706
701
  };
707
702
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-daddy",
3
- "version": "0.48.2",
3
+ "version": "0.49.0",
4
4
  "description": "Capability governance for pi sub-agents: spawn Agent Skills (SKILL.md) definitions whose allowed-tools becomes a grant that can only narrow going down a delegation tree, enforced by pi's own --tools allowlist, with an append-only ledger.",
5
5
  "keywords": [
6
6
  "pi-package",
package/src/cli.ts CHANGED
@@ -50,6 +50,7 @@ Usage:
50
50
  pi-daddy report [--since <date>] [--definition <name>] [--model <id>]
51
51
  [--group-by definition|model|thinking] [--json]
52
52
  pi-daddy outcomes append changed episode outcomes from Git and CI
53
+ pi-daddy diagnostics export <selection.json> <new-directory> private filtered local evidence
53
54
  pi-daddy --help | --version
54
55
 
55
56
  init references skills already enabled in Pi at their installed or local paths. Legacy unregistered npm
@@ -61,8 +62,11 @@ stays unspawnable. Capabilities that can change your machine
61
62
  It never rewrites ${SETTINGS_REL} — delete that file if you want it regenerated.`;
62
63
 
63
64
  export interface ParsedArgs {
64
- command: "init" | "ledger-repair" | "ledger-import" | "report" | "outcomes" | "help" | "version";
65
+ command:
66
+ "init" | "ledger-repair" | "ledger-import" | "report" | "outcomes" | "diagnostics-export" | "help" | "version";
65
67
  importTarget?: string;
68
+ selectionPath?: string;
69
+ exportDirectory?: string;
66
70
  /** `ledger repair <path>`: the ledger file; `yes` applies, otherwise preview only. */
67
71
  ledgerPath?: string;
68
72
  yes?: boolean;
@@ -91,6 +95,19 @@ export function parseArgs(argv: string[]): ParsedArgs {
91
95
  if (args.includes("--version") || args.includes("-v")) return { command: "version", force: false, errors: [] };
92
96
 
93
97
  const [command, ...tail] = args;
98
+ if (command === "diagnostics") {
99
+ const [verb, selectionPath, exportDirectory, ...extra] = tail;
100
+ if (
101
+ verb !== "export" ||
102
+ !selectionPath ||
103
+ !exportDirectory ||
104
+ selectionPath.startsWith("-") ||
105
+ exportDirectory.startsWith("-") ||
106
+ extra.length
107
+ )
108
+ return { command: "help", force: false, errors: ["diagnostics needs: export <selection.json> <new-directory>"] };
109
+ return { command: "diagnostics-export", force: false, errors: [], selectionPath, exportDirectory };
110
+ }
94
111
  if (command === "ledger") {
95
112
  // ADR-0076 PR 3d: `ledger repair <path> [--yes]` previews, --yes truncates; `ledger import <source> <target>`.
96
113
  const [verb, target, ...flags] = tail;
@@ -388,6 +405,12 @@ export async function main(
388
405
  console.log(USAGE);
389
406
  return 0;
390
407
  }
408
+ if (parsed.command === "diagnostics-export") {
409
+ const { exportDiagnostics } = await import("./products/diagnostic-export.ts");
410
+ const result = await exportDiagnostics(parsed.selectionPath!, parsed.exportDirectory!);
411
+ console.log(JSON.stringify(result));
412
+ return result.missing ? 1 : 0;
413
+ }
391
414
  if (parsed.command === "outcomes") {
392
415
  const cwd = process.cwd();
393
416
  const result = await updateEpisodeOutcomes({
@@ -2,6 +2,7 @@
2
2
  import { mkdtemp } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
5
6
  import { runOwnedChild, type OwnedChildRunRequest, type OwnedChildRunResult } from "./owned-worker.ts";
6
7
  import type { BoundedReadCleanupError } from "../kernel/bounded-read.ts";
7
8
  import { ChildFinalCapture, type ChildFinal } from "./child-final.ts";
@@ -36,7 +37,18 @@ export async function runCapturedExecution(
36
37
  };
37
38
  }
38
39
  const capture = new ChildFinalCapture(request.onOutput, request.onReadCleanup);
39
- const args = [...request.args.slice(0, -1), "--mode", "json", request.args.at(-1)!];
40
+ const args = [...request.args];
41
+ if (request.terminalUi) {
42
+ const extension = (name: string) =>
43
+ fileURLToPath(new URL("./" + name + (import.meta.url.endsWith(".ts") ? ".ts" : ".js"), import.meta.url));
44
+ // Pi enables editor commands before awaiting session_start handlers. The view guard must be first;
45
+ // the event observer remains last so earlier message transformations are captured exactly.
46
+ const firstExtension = args.findIndex(
47
+ (arg, index) => index < args.length - 1 && ["--no-extensions", "-ne", "-e", "--extension"].includes(arg),
48
+ );
49
+ args.splice(firstExtension < 0 ? args.length - 1 : firstExtension, 0, "-e", extension("owned-pi-view"));
50
+ args.splice(args.length - 1, 0, "-e", extension("owned-pi-ui"), "--tui-mode", "regular");
51
+ } else args.splice(args.length - 1, 0, "--mode", "json");
40
52
  const output = await runOwned({
41
53
  ...request,
42
54
  args,
@@ -10,6 +10,7 @@ export async function launchHerdrWorker(socketPath: string, token: string): Prom
10
10
  const abort = new AbortController();
11
11
  let started = false,
12
12
  authenticated = false,
13
+ terminalUi = false,
13
14
  acknowledge: (() => void) | undefined,
14
15
  rejectGate: ((error: Error) => void) | undefined;
15
16
  const lost = (error: Error) => {
@@ -32,7 +33,7 @@ export async function launchHerdrWorker(socketPath: string, token: string): Prom
32
33
  return;
33
34
  }
34
35
  if (message?.type === "display" && authenticated && typeof message.text === "string") {
35
- process.stdout.write(message.text);
36
+ if (!terminalUi) process.stdout.write(message.text);
36
37
  return;
37
38
  }
38
39
  if (message?.type !== "request" || started) {
@@ -43,11 +44,14 @@ export async function launchHerdrWorker(socketPath: string, token: string): Prom
43
44
  started = true;
44
45
  authenticated = true;
45
46
  const request = message.request as OwnedChildRunRequest & { sessionPath?: string };
47
+ terminalUi = request.terminalUi === true;
46
48
  let childPid: number | undefined;
47
49
  const display = herdrAgentDisplay(message.display);
48
50
  let settled = false;
49
51
  void (async () => {
52
+ const wasRaw = process.stdin.isRaw;
50
53
  try {
54
+ if (terminalUi && process.stdin.isTTY) process.stdin.setRawMode(true);
51
55
  const result = await runOwnedChild({
52
56
  ...request,
53
57
  signal: abort.signal,
@@ -88,10 +92,13 @@ export async function launchHerdrWorker(socketPath: string, token: string): Prom
88
92
  childPid = pid;
89
93
  void display.working();
90
94
  send({ type: "spawned", pid });
91
- process.stdout.write("pi-daddy: governed child running\n");
95
+ if (!terminalUi) process.stdout.write("pi-daddy: governed child running\n");
92
96
  },
93
97
  });
94
98
  settled = result.cleanup.state === "settled" || result.cleanup.state === "not-started";
99
+ // Mark only this owned terminal as settled; this is cosmetic, not process or sidebar authority.
100
+ // Unknown cleanup keeps its existing title, and a killed launcher cannot reset it.
101
+ if (terminalUi && settled) process.stdout.write("\x1b]0;Task settled\x07");
95
102
  const displayReleased = await display.finish(settled);
96
103
  if (!socket.destroyed) {
97
104
  send({ type: "result", result, displayReleased: settled && displayReleased });
@@ -107,6 +114,12 @@ export async function launchHerdrWorker(socketPath: string, token: string): Prom
107
114
  socket.end();
108
115
  }
109
116
  }
117
+ } finally {
118
+ try {
119
+ if (terminalUi && process.stdin.isTTY) process.stdin.setRawMode(wasRaw);
120
+ } catch {
121
+ // A lost pane cannot be restored; native cleanup remains independently recorded.
122
+ }
110
123
  }
111
124
  })();
112
125
  },
@@ -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;
package/src/index.ts CHANGED
@@ -191,3 +191,5 @@ export {
191
191
  type WorkspacePins,
192
192
  type ParsedPin,
193
193
  } from "./kernel/workspace-pin.ts";
194
+
195
+ export { exportDiagnostics, parseDiagnosticSelection, type DiagnosticSelection } from "./products/diagnostic-export.ts";
@@ -12,7 +12,8 @@ export interface SelectedCommand {
12
12
  name: string;
13
13
  sourceInfo?: { path?: string };
14
14
  }
15
- const phases = ["plan", "build", "review", "debug", "investigate"];
15
+ const requiredPhases = ["plan", "build", "review", "debug", "investigate"];
16
+ const phases = [...requiredPhases, "test-review"];
16
17
  const hash = (value: string) => createHash("sha256").update(value, "utf8").digest("hex");
17
18
  const object = (x: unknown): x is Record<string, unknown> => !!x && typeof x === "object" && !Array.isArray(x);
18
19
  const exact = (x: Record<string, unknown>, keys: string[]) =>
@@ -57,10 +58,10 @@ async function principalBinding(
57
58
  manifest.version !== 1 ||
58
59
  manifest.package !== "principal-pi-skills" ||
59
60
  !object(manifest.bindings) ||
60
- !exact(manifest.bindings, phases)
61
+ !exact(manifest.bindings, Object.hasOwn(manifest.bindings, "test-review") ? phases : requiredPhases)
61
62
  )
62
63
  throw Error("invalid Principal binding manifest");
63
- for (const name of phases) {
64
+ for (const name of Object.keys(manifest.bindings)) {
64
65
  const row = manifest.bindings[name];
65
66
  if (
66
67
  !object(row) ||
@@ -74,6 +75,7 @@ async function principalBinding(
74
75
  )
75
76
  throw Error("invalid Principal binding row");
76
77
  }
78
+ if (!Object.hasOwn(manifest.bindings, phase)) throw Error("selected Principal phase has no binding row");
77
79
  const row = manifest.bindings[phase] as Record<string, string>;
78
80
  const agentPath = join(root, row.agent);
79
81
  if (
@@ -376,6 +376,17 @@ export async function runDashboard(argv = process.argv.slice(2), env: NodeJS.Pro
376
376
  notice = (await connection.change({ action: "set", edits: trimmed.slice(2).trim() }))
377
377
  ? "Session models updated"
378
378
  : connection.state.error || "Session controls unavailable";
379
+ } else if (trimmed.startsWith("limit ")) {
380
+ const edit = /^limit (wall|idle) ([0-9]+)$/.exec(trimmed);
381
+ if (!edit) notice = "Use limit wall <seconds> or limit idle <seconds>; 0 restores default";
382
+ else
383
+ notice = (await connection.change({
384
+ action: "set-limit",
385
+ key: edit[1] as "wall" | "idle",
386
+ seconds: Number(edit[2]),
387
+ }))
388
+ ? "Session limit updated for future children; running children unchanged"
389
+ : connection.state.error || "Settings unavailable";
379
390
  } else notice = display.input(line) ? "" : `Unknown command: ${trimmed}. Press ? for help.`;
380
391
  redraw();
381
392
  };
@@ -397,7 +408,7 @@ export async function runDashboard(argv = process.argv.slice(2), env: NodeJS.Pro
397
408
  redraw();
398
409
  return;
399
410
  }
400
- const action = display.key(
411
+ let action = display.key(
401
412
  key.name === "return"
402
413
  ? "return"
403
414
  : ["up", "down", "escape", "tab"].includes(key.name || "")
@@ -409,6 +420,32 @@ export async function runDashboard(argv = process.argv.slice(2), env: NodeJS.Pro
409
420
  return;
410
421
  }
411
422
  if (action === "command") command = "";
423
+ if (action === "setting") {
424
+ const selected = display.screen.settingKey ?? "auto";
425
+ const settings = connection.state.snapshot?.settings;
426
+ if (!settings?.editable) notice = "Settings can only be changed in the connected owning parent";
427
+ else if (selected === "auto") action = "auto";
428
+ else if (selected === "wall" || selected === "idle") command = `limit ${selected} `;
429
+ else if (selected === "descendants") notice = "Startup only: set PI_DADDY_FANOUT before restarting Pi";
430
+ else if (selected === "depth") notice = "Startup only: set PI_DADDY_MAX_DEPTH before restarting Pi";
431
+ else if (selected === "perCall") notice = "Children per call is a fixed safety bound";
432
+ else if (!settings.jev.available)
433
+ notice = "JEV controls unavailable; load skill-harness in the parent Pi session";
434
+ else {
435
+ void connection
436
+ .change({ action: "set-jev", enabled: !(settings.jev.enabled || settings.jev.pending) })
437
+ .then((changed) => {
438
+ const jev = connection.state.snapshot?.settings?.jev;
439
+ notice = !changed
440
+ ? connection.state.error || "JEV change already pending"
441
+ : jev?.pending
442
+ ? "Complete JEV paid-call and LoRA-storage choices in the parent Pi session"
443
+ : jev?.error ||
444
+ `JEV ${jev?.enabled ? "enabled" : "disabled"}; ${jev?.availability ?? "status unknown"}`;
445
+ redraw();
446
+ });
447
+ }
448
+ }
412
449
  if (action === "auto") {
413
450
  const snapshot = connection.state.snapshot;
414
451
  if (!snapshot) notice = "Daddy permission controls unavailable";
@@ -1,3 +1,4 @@
1
+ import { DASHBOARD_SETTING_KEYS } from "./dashboard-settings.ts";
1
2
  /** Local display controls; these never dispatch actions to an owner or change ledger state. */
2
3
  import { dashboardScreenState } from "./dashboard-screen.ts";
3
4
  import { ActivityTimelineAliases } from "./activity-timeline.ts";
@@ -16,16 +17,17 @@ export function createDashboardDisplayControls(details: boolean, ledger: boolean
16
17
  return {
17
18
  state,
18
19
  screen,
19
- key(key: string): "auto" | "quit" | "command" | undefined {
20
+ key(key: string): "auto" | "quit" | "command" | "setting" | undefined {
20
21
  if (key === "a") return "auto";
21
22
  if (key === "q" || key === "ctrl-c") return "quit";
22
23
  if (key === ":") return "command";
24
+ if (screen.view === "settings" && (key === "return" || key === "d")) return "setting";
23
25
  if (key === "escape") {
24
26
  screen.view = screen.view === "version-details" ? "versions" : "main";
25
27
  state.activityDetail = undefined;
26
28
  screen.offset = 0;
27
- } else if (key === "?" || key === "m" || key === "v") {
28
- screen.view = key === "?" ? "help" : key === "v" ? "versions" : "models";
29
+ } else if (key === "?" || key === "m" || key === "v" || key === "s") {
30
+ screen.view = key === "?" ? "help" : key === "v" ? "versions" : key === "s" ? "settings" : "models";
29
31
  state.activityDetail = undefined;
30
32
  screen.offset = 0;
31
33
  } else if (key === "return" || key === "d") {
@@ -33,7 +35,11 @@ export function createDashboardDisplayControls(details: boolean, ledger: boolean
33
35
  screen.offset = 0;
34
36
  } else if (["up", "down", "j", "k"].includes(key)) {
35
37
  const delta = key === "up" || key === "k" ? -1 : 1;
36
- if (screen.view === "versions") {
38
+ if (screen.view === "settings") {
39
+ const index = DASHBOARD_SETTING_KEYS.indexOf(screen.settingKey ?? "auto");
40
+ screen.settingKey =
41
+ DASHBOARD_SETTING_KEYS[Math.max(0, Math.min(DASHBOARD_SETTING_KEYS.length - 1, index + delta))];
42
+ } else if (screen.view === "versions") {
37
43
  const rows = screen.versionRows ?? [],
38
44
  index = rows.findIndex((row) => row.id === screen.versionId);
39
45
  screen.versionId = rows[Math.max(0, Math.min(rows.length - 1, index + delta))]?.id;