@yusukeshib/pi-babysit 0.3.12 → 0.3.14

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yusukeshib/pi-babysit",
3
- "version": "0.3.12",
3
+ "version": "0.3.14",
4
4
  "description": "Run any shell command and pi subagents under babysit, with context-safe captured output.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -7,15 +7,45 @@
7
7
  * are useful only while streaming; `message_end` is the authoritative final
8
8
  * message. Persisting every snapshot makes babysit logs grow quadratically.
9
9
  *
10
- * This proxy forwards stdin byte-for-byte and compacts only `message_update`
11
- * lines on stdout. All final, tool, lifecycle, response, and error events pass
12
- * through byte-for-byte.
10
+ * This proxy forwards stdin byte-for-byte. `message_update` snapshots are
11
+ * always compacted. In opt-in `compact` log mode, redundant payloads on
12
+ * lifecycle/tool events are also removed while authoritative `message_end`,
13
+ * response, and error events remain intact. Set PI_BABYSIT_RPC_LOG_MODE=standard
14
+ * to retain the legacy lifecycle payloads.
13
15
  */
14
16
  import { spawn } from "node:child_process";
15
17
  import { StringDecoder } from "node:string_decoder";
16
18
  import { pathToFileURL } from "node:url";
17
19
 
18
- export function compactRpcLine(line) {
20
+ function isParkedMessages(messages) {
21
+ if (!Array.isArray(messages)) return false;
22
+ for (let index = messages.length - 1; index >= 0; index--) {
23
+ const message = messages[index];
24
+ if (message?.role !== "toolResult") continue;
25
+ if (message.toolName === "process") return true;
26
+ if (message.toolName !== "babysit_run") continue;
27
+ if (message.details?.kind === "process" && message.details.status === "started") {
28
+ return true;
29
+ }
30
+ const text = Array.isArray(message.content)
31
+ ? message.content
32
+ .filter((part) => part?.type === "text" && typeof part.text === "string")
33
+ .map((part) => part.text)
34
+ .join("")
35
+ : typeof message.content === "string"
36
+ ? message.content
37
+ : "";
38
+ if (/^Process started \(id: [^)]+\)\. \[notify-on-exit\]\nLog: /.test(text)) {
39
+ return true;
40
+ }
41
+ }
42
+ return false;
43
+ }
44
+
45
+ export function compactRpcLine(
46
+ line,
47
+ mode = process.env.PI_BABYSIT_RPC_LOG_MODE ?? "standard",
48
+ ) {
19
49
  if (!line.trimStart().startsWith("{")) return line;
20
50
 
21
51
  let event;
@@ -24,14 +54,44 @@ export function compactRpcLine(line) {
24
54
  } catch {
25
55
  return line;
26
56
  }
27
- if (event?.type !== "message_update") return line;
28
57
 
29
- const { message: _cumulativeMessage, assistantMessageEvent, ...rest } = event;
30
- if (!assistantMessageEvent || typeof assistantMessageEvent !== "object") {
31
- return JSON.stringify(rest);
58
+ if (event?.type === "message_update") {
59
+ const { message: _cumulativeMessage, assistantMessageEvent, ...rest } = event;
60
+ if (!assistantMessageEvent || typeof assistantMessageEvent !== "object") {
61
+ return JSON.stringify(rest);
62
+ }
63
+ const { partial: _cumulativePartial, ...deltaEvent } = assistantMessageEvent;
64
+ return JSON.stringify({ ...rest, assistantMessageEvent: deltaEvent });
65
+ }
66
+ if (mode !== "compact") return line;
67
+
68
+ switch (event?.type) {
69
+ case "message_start": {
70
+ const { message, ...rest } = event;
71
+ return JSON.stringify({
72
+ ...rest,
73
+ message:
74
+ message && typeof message === "object"
75
+ ? { role: message.role, toolName: message.toolName, toolCallId: message.toolCallId }
76
+ : message,
77
+ });
78
+ }
79
+ case "turn_end": {
80
+ const { message: _message, toolResults: _toolResults, ...rest } = event;
81
+ return JSON.stringify(rest);
82
+ }
83
+ case "agent_end": {
84
+ const { messages, ...rest } = event;
85
+ return JSON.stringify({ ...rest, piBabysitParked: isParkedMessages(messages) });
86
+ }
87
+ case "tool_execution_end": {
88
+ if (event.isError) return line;
89
+ const { result: _result, ...rest } = event;
90
+ return JSON.stringify(rest);
91
+ }
92
+ default:
93
+ return line;
32
94
  }
33
- const { partial: _cumulativePartial, ...deltaEvent } = assistantMessageEvent;
34
- return JSON.stringify({ ...rest, assistantMessageEvent: deltaEvent });
35
95
  }
36
96
 
37
97
  function transformRpcOutput(input, output) {
package/self-reap.ts CHANGED
@@ -13,8 +13,8 @@
13
13
  * keep talking to never dies; only a genuinely-abandoned one does.
14
14
  * 2. Process parks — a turn that ends only to await a background-process
15
15
  * exit notification (babysit_run inside the subagent, or the legacy
16
- * `process` tool) also emits `agent_end`, but pi resumes on its own. We
17
- * detect that (last message is a parked toolResult, same rule the parent
16
+ * `process` tool) also settles the agent, but pi resumes on its own. We
17
+ * detect a parked toolResult anywhere in that run (same rule the parent
18
18
  * uses) and DON'T schedule a reap, so a subagent waiting on a long
19
19
  * build/test is never false-killed.
20
20
  *
@@ -23,10 +23,8 @@
23
23
  */
24
24
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
25
25
 
26
- // Keep in sync with NOTIFY_MARKER in index.ts (not imported: this file is
27
- // loaded standalone into the subagent process via --extension).
28
- const NOTIFY_MARKER = "[notify-on-exit]";
29
-
26
+ // Loaded standalone into the subagent process via --extension, so the parked
27
+ // result-shape check below intentionally mirrors index.ts without importing it.
30
28
  function parseDurMs(s: string | undefined): number | null {
31
29
  if (!s || s === "none" || s === "off" || s === "0") return null;
32
30
  const m = /^(\d+)(ms|s|m|h)?$/.exec(s.trim());
@@ -36,22 +34,40 @@ function parseDurMs(s: string | undefined): number | null {
36
34
  return n * (u === "ms" ? 1 : u === "s" ? 1000 : u === "m" ? 60_000 : 3_600_000);
37
35
  }
38
36
 
39
- // Same parked-turn rule as the parent (see index.ts isParkedToolResult):
40
- // a babysit_run process start stamps NOTIFY_MARKER into its tool result; that
41
- // as the LAST message means "parked awaiting the exit notification".
42
- function isParked(
43
- last: { role?: string; toolName?: string; content?: unknown } | undefined,
37
+ // Same parked-turn rule as the parent: scan the run because models sometimes
38
+ // add an assistant note after the marker-bearing tool result.
39
+ export function isParked(
40
+ messages:
41
+ | {
42
+ role?: string;
43
+ toolName?: string;
44
+ content?: unknown;
45
+ details?: { kind?: string; status?: string };
46
+ }[]
47
+ | undefined,
44
48
  ): boolean {
45
- if (!last || last.role !== "toolResult") return false;
46
- if (last.toolName === "process") return true; // legacy pi-processes
47
- if (last.toolName !== "babysit_run") return false;
48
- try {
49
- const s = JSON.stringify(last.content ?? null);
50
- if (s === "null") return true; // unsure — never reap a possible build wait
51
- return s.includes(NOTIFY_MARKER);
52
- } catch {
53
- return true;
49
+ if (!messages) return false;
50
+ for (let index = messages.length - 1; index >= 0; index--) {
51
+ const message = messages[index];
52
+ if (message?.role !== "toolResult") continue;
53
+ if (message.toolName === "process") return true; // legacy pi-processes
54
+ if (message.toolName !== "babysit_run") continue;
55
+ if (message.details?.kind === "process" && message.details.status === "started") {
56
+ return true;
57
+ }
58
+ const text = Array.isArray(message.content)
59
+ ? message.content
60
+ .filter((part): part is { type: "text"; text: string } =>
61
+ Boolean(part && typeof part === "object" && part.type === "text" && typeof part.text === "string"),
62
+ )
63
+ .map((part) => part.text)
64
+ .join("")
65
+ : typeof message.content === "string"
66
+ ? message.content
67
+ : "";
68
+ if (/^Process started \(id: [^)]+\)\. \[notify-on-exit\]\nLog: /.test(text)) return true;
54
69
  }
70
+ return false;
55
71
  }
56
72
 
57
73
  export default function (pi: ExtensionAPI) {
@@ -61,6 +77,7 @@ export default function (pi: ExtensionAPI) {
61
77
  if (graceMs == null) return; // reaping disabled
62
78
 
63
79
  let timer: ReturnType<typeof setTimeout> | undefined;
80
+ let lastEndWasParked = false;
64
81
  const cancel = () => {
65
82
  if (timer) {
66
83
  clearTimeout(timer);
@@ -71,22 +88,25 @@ export default function (pi: ExtensionAPI) {
71
88
  // A new task/turn is starting — the subagent is wanted again. Stand down.
72
89
  pi.on("before_agent_start", () => {
73
90
  cancel();
91
+ lastEndWasParked = false;
74
92
  });
75
93
 
76
94
  pi.on("agent_end", (event) => {
77
- cancel(); // supersede any earlier schedule with this end's decision
78
-
79
- const msgs = (
95
+ const messages = (
80
96
  event as { messages?: { role?: string; toolName?: string; content?: unknown }[] }
81
97
  ).messages;
82
- if (isParked(msgs?.[msgs.length - 1])) return;
98
+ lastEndWasParked = isParked(messages);
99
+ });
83
100
 
101
+ // agent_end can precede automatic retries, compaction retries, and queued
102
+ // continuations. Start the grace timer only once Pi is truly settled.
103
+ pi.on("agent_settled", (_event, ctx) => {
104
+ cancel();
105
+ if (lastEndWasParked) return;
84
106
  timer = setTimeout(() => {
85
- // Clean exit: babysit sees the child exit and marks the session done,
86
- // so it drops out of the session list/widget instead of idling.
87
- process.exit(0);
107
+ // Graceful shutdown emits session_shutdown for every loaded extension.
108
+ ctx.shutdown();
88
109
  }, graceMs);
89
- // Don't let this timer keep the event loop alive on its own.
90
110
  timer.unref?.();
91
111
  });
92
112
  }