@yusukeshib/pi-babysit 0.2.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Yusuke Shibata
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,105 @@
1
+ # pi-babysit
2
+
3
+ A [pi](https://github.com/earendil-works/pi) extension that runs **anything
4
+ long-lived** under [babysit](https://github.com/yusukeshib/babysit)-supervised
5
+ PTY sessions — one substrate for background processes **and** pi subagents.
6
+ It retires both `@mjakl/pi-processes` (the `process` tool) and the old
7
+ `pi-subagent` extension.
8
+
9
+ ## Install
10
+
11
+ ```sh
12
+ pi install npm:@yusukeshib/pi-babysit
13
+ ```
14
+
15
+ Then install the [`babysit`](https://github.com/yusukeshib/babysit) binary
16
+ (the extension does **not** auto-install it):
17
+
18
+ ```sh
19
+ cargo install --git https://github.com/yusukeshib/babysit
20
+ ```
21
+
22
+ or grab a prebuilt binary from the
23
+ [releases](https://github.com/yusukeshib/babysit/releases) and put it on your
24
+ `PATH`. If it's missing, every tool and the `/babysit` command fail with these
25
+ instructions.
26
+
27
+ ## The model
28
+
29
+ Every session is a babysit worker owning a PTY, recording all output to a log,
30
+ reachable from anywhere (`~/.pi-babysit/<pi-session-id>/`). Two kinds:
31
+
32
+ | kind | started by | completion | on completion |
33
+ | ---- | ---------- | ---------- | ------------- |
34
+ | **process** | `babysit_run { command }` | process **exit** | automatic notification message (`triggerTurn`) — the agent may end its turn after starting and is resumed on exit, same contract as the old `process` tool |
35
+ | **subagent** | `babysit_run { profile: "subagent", task }` | `agent_end` in the RPC event stream (process stays alive) | none — the agent polls `babysit_check` or blocks on `babysit_wait`; the idle session accepts follow-up tasks |
36
+
37
+ The **profile is a tool parameter, not a separate tool set**: domain knowledge
38
+ (RPC bookkeeping, per-task byte offsets, parked-turn detection, PTY-safe
39
+ message delivery, spawn validation) lives in code, while the LLM sees one small
40
+ generic surface.
41
+
42
+ Because sessions are real PTYs, the agent can also **drive interactive
43
+ programs** (installers, wizards, REPLs): type with `babysit_send`
44
+ (text or named keys) and read the rendered screen with
45
+ `babysit_check { screen: true }` — a capability neither retired extension had.
46
+
47
+ ## Tools (LLM-callable)
48
+
49
+ | Tool | What it does |
50
+ | ---- | ------------ |
51
+ | `babysit_run` | Start a process (`command`, optional `name`/`pty`/`timeout`/`idleTimeout`) or a subagent (`profile: "subagent"`, `task`, optional `agent`/`model`/`tools`). Non-blocking; returns a session id |
52
+ | `babysit_check` | List all sessions, or inspect one: process → state + log tail (or `screen: true` for TUIs); subagent → live progress (turns, recent tool calls, partial answer) |
53
+ | `babysit_send` | Process: type `text` / press `keys` into the PTY. Subagent: steer mid-run, or send a follow-up task when idle (`mode: auto/steer/task`) |
54
+ | `babysit_wait` | Block until done: process exit (or `expect: "regex"` readiness marker), subagent task completion. Multi-wait: `ids` + `mode: "any"\|"all"` |
55
+ | `babysit_kill` | Terminate a session (suppresses the exit notification) |
56
+
57
+ A `tool_call` hook also blocks bash commands that background themselves
58
+ (`… &`, `nohup`, `setsid`, `disown`) and points the agent at `babysit_run`
59
+ (carried over from pi-processes' `blockBackgroundCommands`).
60
+
61
+ ## Commands (human)
62
+
63
+ | Command | What it does |
64
+ | ------- | ------------ |
65
+ | `/babysit` | Arrow-key picker over all sessions. Renders an **inline snapshot** (no tmux): running **process** → current rendered screen + recent output + a copy-paste `babysit attach` take-over hint (detach `Ctrl-\ Ctrl-\`); running **subagent** → read-only progress (RPC stdin stays untouchable); finished → summary. Re-run `/babysit` to refresh |
66
+
67
+ A minimal widget above the editor shows live counts
68
+ (`N processes · M subagents working · K idle`).
69
+
70
+ ## How completion detection works
71
+
72
+ - **Process**: a 2.5s poller watches for running→exited transitions and injects
73
+ one `pi.sendMessage(…, { triggerTurn: true, deliverAs: "steer" })` per ended
74
+ session (deduped via `meta/<id>.json`). `babysit_kill` and an exit already
75
+ reported by `babysit_wait` suppress the notification.
76
+ - **Subagent**: `babysit_wait` blocks on `babysit expect '"type":"agent_end"'`.
77
+ An `agent_end` whose last message is a **parked** toolResult — a
78
+ `babysit_run { command }` result carrying the `[notify-on-exit]` marker (or
79
+ the legacy `process` tool) — only means "turn parked awaiting a process-exit
80
+ notification; pi resumes on its own", so the wait continues. Any other
81
+ `agent_end` is real completion. Per-task byte offsets scope check/wait to the
82
+ CURRENT task, which is what makes follow-up tasks work.
83
+
84
+ Subagents load `self-reap.ts`, which exits an idle finished subagent after a
85
+ grace window (`PI_BABYSIT_REAP_AFTER`, default 120s) using the same parked-turn
86
+ rule, so a subagent waiting on a long build is never false-killed.
87
+
88
+ ## Environment overrides
89
+
90
+ | Var | Default | Purpose |
91
+ | --- | ------- | ------- |
92
+ | `PI_BABYSIT_DIR` | `~/.pi-babysit` | babysit state root (namespaced per pi session) |
93
+ | `PI_BABYSIT_BIN` | `pi` | agent binary for subagents |
94
+ | `PI_BABYSIT_CLI` | `babysit` | babysit binary |
95
+ | `PI_BABYSIT_VIEW_CMD` | bundled `format-stream.mjs` | live-attach pretty printer for subagent JSONL (`""` disables) |
96
+ | `PI_BABYSIT_REAP_AFTER` | `120s` | idle grace before a finished subagent self-exits (`off`/`none`/`0` disables) |
97
+
98
+ Requires `babysit` and `pi` on `PATH`. The extension does **not** auto-install
99
+ `babysit`: if the binary is missing, every tool and the `/babysit` command fail
100
+ with install instructions (`cargo install --git https://github.com/yusukeshib/babysit`
101
+ or a prebuilt release), and a warning is shown at session start. Point
102
+ `$PI_BABYSIT_CLI` at a custom binary path if needed.
103
+
104
+ (No tmux dependency — `/babysit` renders inline; take over a live process
105
+ manually with the `babysit attach` command it shows.)
package/agents.ts ADDED
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Agent discovery. Adapted from pi's official subagent example.
3
+ * Loads markdown agent definitions with YAML frontmatter from
4
+ * ~/.pi/agent/agents/*.md (user, always)
5
+ * <project>/.pi/agents/*.md (project, only with scope project|both)
6
+ */
7
+
8
+ import * as fs from "node:fs";
9
+ import * as path from "node:path";
10
+ import {
11
+ CONFIG_DIR_NAME,
12
+ getAgentDir,
13
+ parseFrontmatter,
14
+ } from "@earendil-works/pi-coding-agent";
15
+
16
+ export type AgentScope = "user" | "project" | "both";
17
+
18
+ export interface AgentConfig {
19
+ name: string;
20
+ description: string;
21
+ tools?: string[];
22
+ model?: string;
23
+ systemPrompt: string;
24
+ source: "user" | "project";
25
+ filePath: string;
26
+ }
27
+
28
+ export interface AgentDiscoveryResult {
29
+ agents: AgentConfig[];
30
+ projectAgentsDir: string | null;
31
+ }
32
+
33
+ function loadAgentsFromDir(
34
+ dir: string,
35
+ source: "user" | "project",
36
+ ): AgentConfig[] {
37
+ const agents: AgentConfig[] = [];
38
+ if (!fs.existsSync(dir)) return agents;
39
+
40
+ let entries: fs.Dirent[];
41
+ try {
42
+ entries = fs.readdirSync(dir, { withFileTypes: true });
43
+ } catch {
44
+ return agents;
45
+ }
46
+
47
+ for (const entry of entries) {
48
+ if (!entry.name.endsWith(".md")) continue;
49
+ if (!entry.isFile() && !entry.isSymbolicLink()) continue;
50
+
51
+ const filePath = path.join(dir, entry.name);
52
+ let content: string;
53
+ try {
54
+ content = fs.readFileSync(filePath, "utf-8");
55
+ } catch {
56
+ continue;
57
+ }
58
+
59
+ const { frontmatter, body } =
60
+ parseFrontmatter<Record<string, string>>(content);
61
+ if (!frontmatter.name || !frontmatter.description) continue;
62
+
63
+ const tools = frontmatter.tools
64
+ ?.split(",")
65
+ .map((t) => t.trim())
66
+ .filter(Boolean);
67
+
68
+ agents.push({
69
+ name: frontmatter.name,
70
+ description: frontmatter.description,
71
+ tools: tools && tools.length > 0 ? tools : undefined,
72
+ model: frontmatter.model,
73
+ systemPrompt: body,
74
+ source,
75
+ filePath,
76
+ });
77
+ }
78
+ return agents;
79
+ }
80
+
81
+ function isDirectory(p: string): boolean {
82
+ try {
83
+ return fs.statSync(p).isDirectory();
84
+ } catch {
85
+ return false;
86
+ }
87
+ }
88
+
89
+ function findNearestProjectAgentsDir(cwd: string): string | null {
90
+ let currentDir = cwd;
91
+ while (true) {
92
+ const candidate = path.join(currentDir, CONFIG_DIR_NAME, "agents");
93
+ if (isDirectory(candidate)) return candidate;
94
+ const parentDir = path.dirname(currentDir);
95
+ if (parentDir === currentDir) return null;
96
+ currentDir = parentDir;
97
+ }
98
+ }
99
+
100
+ export function discoverAgents(
101
+ cwd: string,
102
+ scope: AgentScope,
103
+ ): AgentDiscoveryResult {
104
+ const userDir = path.join(getAgentDir(), "agents");
105
+ const projectAgentsDir = findNearestProjectAgentsDir(cwd);
106
+
107
+ const userAgents = scope === "project" ? [] : loadAgentsFromDir(userDir, "user");
108
+ const projectAgents =
109
+ scope === "user" || !projectAgentsDir
110
+ ? []
111
+ : loadAgentsFromDir(projectAgentsDir, "project");
112
+
113
+ const agentMap = new Map<string, AgentConfig>();
114
+ if (scope === "both") {
115
+ for (const a of userAgents) agentMap.set(a.name, a);
116
+ for (const a of projectAgents) agentMap.set(a.name, a);
117
+ } else if (scope === "user") {
118
+ for (const a of userAgents) agentMap.set(a.name, a);
119
+ } else {
120
+ for (const a of projectAgents) agentMap.set(a.name, a);
121
+ }
122
+
123
+ return { agents: Array.from(agentMap.values()), projectAgentsDir };
124
+ }
@@ -0,0 +1,167 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * format-stream.mjs — human-readable view for a `pi --mode json` event stream.
4
+ *
5
+ * babysit records the raw JSONL to its log (machine scrapers + `babysit log`
6
+ * stay untouched); this filter only sits on the interactive attach path via
7
+ * `babysit run --view-cmd`. It reads the JSONL on stdin and prints a colored,
8
+ * incrementally-streamed transcript on stdout so a human attaching the tmux
9
+ * pane sees turns, thinking, tool calls and answers instead of a JSON firehose.
10
+ */
11
+ import * as readline from "node:readline";
12
+
13
+ const C = {
14
+ reset: "\x1b[0m",
15
+ dim: "\x1b[2m",
16
+ bold: "\x1b[1m",
17
+ gray: "\x1b[90m",
18
+ cyan: "\x1b[36m",
19
+ green: "\x1b[32m",
20
+ yellow: "\x1b[33m",
21
+ red: "\x1b[31m",
22
+ blue: "\x1b[34m",
23
+ };
24
+ // The attach client terminal is in raw mode (OPOST off), so a bare "\n"
25
+ // moves down without returning to column 0 and the transcript stair-steps.
26
+ // Normalize every newline (including ones inside model text) to "\r\n".
27
+ const out = (s) => process.stdout.write(String(s).replace(/\r?\n/g, "\r\n"));
28
+ const trunc = (s, n = 72) => {
29
+ s = String(s ?? "");
30
+ return s.length > n ? `${s.slice(0, n - 1)}\u2026` : s;
31
+ };
32
+
33
+ function summarizeTool(name, a = {}) {
34
+ switch (name) {
35
+ case "bash":
36
+ return `$ ${trunc(a.command)}`;
37
+ case "read":
38
+ return `read ${trunc(a.file_path ?? a.path)}`;
39
+ case "write":
40
+ return `write ${trunc(a.file_path ?? a.path)}`;
41
+ case "edit":
42
+ return `edit ${trunc(a.file_path ?? a.path)}`;
43
+ case "grep":
44
+ return `grep /${trunc(a.pattern, 40)}/`;
45
+ case "find":
46
+ return `find ${trunc(a.pattern ?? a.path, 40)}`;
47
+ case "ls":
48
+ return `ls ${trunc(a.path)}`;
49
+ default:
50
+ return trunc(JSON.stringify(a), 48);
51
+ }
52
+ }
53
+
54
+ // Join the text of a given block kind ("text" or "thinking") in a message's
55
+ // content array. message_update re-emits the whole (growing) message, so we
56
+ // compare lengths against what we've already printed to append only the delta.
57
+ function blockText(content, kind) {
58
+ if (!Array.isArray(content)) return "";
59
+ let s = "";
60
+ for (const c of content) {
61
+ if (kind === "text" && c.type === "text" && c.text) s += c.text;
62
+ if (kind === "thinking" && c.type === "thinking") s += c.thinking ?? c.text ?? "";
63
+ }
64
+ return s;
65
+ }
66
+
67
+ const emitted = new Map(); // id -> {think, text, hThink, hText}
68
+ let turn = 0;
69
+
70
+ // Every visual unit (thinking, answer, tool call, error) is a "block": it
71
+ // starts with a marker glyph on its own line. openBlock() is the single
72
+ // place that emits the line break, so spacing stays consistent no matter
73
+ // the event order — one block per line group, no blank lines in between.
74
+ const openBlock = () => {
75
+ out("\n");
76
+ };
77
+
78
+ function streamMessage(msg, fresh = false) {
79
+ if (!msg || msg.role !== "assistant") return;
80
+ const id = msg.id ?? `t${turn}`;
81
+ if (fresh) emitted.delete(id);
82
+ let st = emitted.get(id);
83
+ if (!st) {
84
+ st = { think: 0, text: 0, hThink: false, hText: false };
85
+ emitted.set(id, st);
86
+ }
87
+ const think = blockText(msg.content, "thinking");
88
+ if (think.length > st.think) {
89
+ if (!st.hThink) {
90
+ openBlock();
91
+ out(`${C.gray}[think] `);
92
+ st.hThink = true;
93
+ }
94
+ out(C.gray + think.slice(st.think) + C.reset);
95
+ st.think = think.length;
96
+ }
97
+ const text = blockText(msg.content, "text");
98
+ if (text.length > st.text) {
99
+ if (!st.hText) {
100
+ openBlock();
101
+ out(`${C.green}[text]${C.reset} `);
102
+ st.hText = true;
103
+ }
104
+ out(text.slice(st.text));
105
+ st.text = text.length;
106
+ }
107
+ }
108
+
109
+ function usageLine(msg) {
110
+ const u = msg?.usage;
111
+ if (!u) return;
112
+ const tok = u.totalTokens ?? u.total_tokens;
113
+ const cost = u.cost?.total;
114
+ const bits = [];
115
+ if (tok != null) bits.push(`${tok} tok`);
116
+ if (cost != null) bits.push(`$${Number(cost).toFixed(4)}`);
117
+ if (msg.stopReason && msg.stopReason !== "stop") bits.push(msg.stopReason);
118
+ if (bits.length) out(`\n${C.gray}${C.dim}[usage] ${bits.join(" \u00b7 ")}${C.reset}`);
119
+ }
120
+
121
+ const rl = readline.createInterface({ input: process.stdin });
122
+ rl.on("line", (line) => {
123
+ line = line.trim();
124
+ if (!line.startsWith("{")) return;
125
+ let ev;
126
+ try {
127
+ ev = JSON.parse(line);
128
+ } catch {
129
+ return;
130
+ }
131
+ switch (ev.type) {
132
+ case "turn_start":
133
+ // Turn separators are noise in the attach view; message/tool blocks
134
+ // already delimit the flow. `turn` still feeds streamMessage's id fallback.
135
+ turn++;
136
+ break;
137
+ case "message_start":
138
+ streamMessage(ev.message, true);
139
+ break;
140
+ case "message_update":
141
+ streamMessage(ev.message);
142
+ break;
143
+ case "message_end":
144
+ streamMessage(ev.message);
145
+ usageLine(ev.message);
146
+ break;
147
+ case "tool_execution_start":
148
+ openBlock();
149
+ out(
150
+ `${C.cyan}[tool]${C.reset} ${ev.toolName} ${C.dim}${summarizeTool(ev.toolName, ev.args ?? {})}${C.reset}`,
151
+ );
152
+ break;
153
+ case "tool_execution_end": {
154
+ const err = ev.isError || ev.error;
155
+ if (err)
156
+ out(`\n${C.red}[error] ${trunc(ev.error?.message ?? ev.error ?? "tool error", 80)}${C.reset}`);
157
+ break;
158
+ }
159
+ case "error":
160
+ openBlock();
161
+ out(`${C.red}[error] ${trunc(ev.message ?? ev.error ?? JSON.stringify(ev), 120)}${C.reset}`);
162
+ break;
163
+ default:
164
+ break; // session_start/info/shutdown/tree/user/compact: quietly ignored
165
+ }
166
+ });
167
+ rl.on("close", () => out("\n"));