@oldflag2333333/pi-subagent 0.1.6 → 0.1.7

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/README.md CHANGED
@@ -191,7 +191,9 @@ Messages use custom inbox cards and Pi’s native run preparation, without extra
191
191
  - **Subs have separate conversations.** They receive their task, profile, and normal project context—not Main's chat history. Include the details they need in the task.
192
192
  - **Reloading Main does not close Subs.** Close tabs you no longer need. Hard crashes may require manual cleanup.
193
193
 
194
- Before a fresh or resumed Sub launch, the plugin submits a short shell probe and waits up to 60 seconds for it to execute before submitting the full Pi command. This avoids macOS truncating long commands (especially resume paths) and dropping Enter while the shell is still initializing. The wait is cancellable; launch failure attempts to close the new tab rather than sending Enter to a partial command. See [Herdr issue #4950](https://github.com/herdrdev/herdr/issues/4950).
194
+ Fresh and resumed Subs start from a private `0600` POSIX shell script in their existing runtime communication directory. The terminal receives only the fixed short command `sh "$PI_SUBAGENT_LAUNCH"`; the script path travels through the tab environment, and Pi's full arguments are read from the file. No shell probe or long terminal input is needed, avoiding the macOS canonical line-input limit even while the shell initializes. The plugin waits up to 60 seconds for the expected Pi in the same pane and terminal to become idle before naming it and submitting the task. Startup remains cancellable, and failures attempt to close the new tab. See [Herdr issue #4950](https://github.com/herdrdev/herdr/issues/4950).
195
+
196
+ The script unlinks itself after the shell opens it; Main also removes it once Pi is ready or a launch fails, times out, or is cancelled—even if tab rollback fails. Each new launch reclaims leftover launch scripts at least five minutes old only when their creator process is confirmed gone. Live processes, uncertain liveness, symlinks, unrelated files, and communication directories are preserved. Failures to inspect or remove abandoned scripts are warned about and skipped without blocking new launches or the rest of the sweep. Normal channel cleanup is unchanged; script cleanup does not depend on the operating system purging temporary files.
195
197
 
196
198
  If a profile is missing or fails to start, run `/profiles` first. Check its directory/name match, project trust, and any referenced tools, skills, or extensions. If Herdr is unavailable, confirm Pi is running inside a Herdr workspace.
197
199
 
package/README.zh-CN.md CHANGED
@@ -191,7 +191,9 @@ reviewer/
191
191
  - **子助手拥有独立对话。** 它会收到任务、角色配置和常规项目上下文,但不会收到主对话的聊天记录。请在任务中提供必要信息。
192
192
  - **重载主 Pi 不会关闭子助手。** 不再需要时请关闭标签页;异常崩溃后可能需要手动清理。
193
193
 
194
- 新建或恢复子助手时,插件先发送一条短 shell 探针,并等待最多 60 秒确认它已执行,再提交完整 Pi 命令。这样可避免 macOS 上 shell 仍在初始化时,长命令(尤其是包含会话路径的 resume)被截断、末尾 Enter 丢失的问题。探针等待支持取消;启动失败会尝试关闭新建的标签页,不会向半截命令补发 Enter。参见 [Herdr 已知问题 #4950](https://github.com/herdrdev/herdr/issues/4950)。
194
+ 新建或恢复子助手时,插件会在已有的运行通信目录中创建权限为 `0600` 的私有 POSIX shell 启动脚本。终端只接收固定短命令 `sh "$PI_SUBAGENT_LAUNCH"`,脚本路径通过标签页环境变量传入,完整 Pi 参数从文件读取。不再使用 shell 探针,也不再向终端输入长行,因此即使 shell 仍在初始化,也不会触发 macOS 的 canonical 行输入长度限制。插件等待最多 60 秒,确认同一 pane 和 terminal 中检测到的 Pi 已进入空闲状态,再命名并提交任务。等待支持取消,启动失败会尝试关闭新建标签页。参见 [Herdr 已知问题 #4950](https://github.com/herdrdev/herdr/issues/4950)。
195
+
196
+ 脚本在被 shell 打开后自行删除;Main 也会在 Pi 就绪或启动失败、超时、取消时兜底删除,即使标签页关闭失败也会尝试回收。每次新启动还会回收已超过 5 分钟、且创建进程确认不存在的残留启动脚本;仍存活的进程、存活状态不明的进程、符号链接、其他文件和通信目录不会被扫描清理逻辑删除。检查或删除遗留脚本失败时会输出警告并跳过,不会阻塞新启动或其他条目的回收。原有通信目录的生命周期保持不变,脚本回收不依赖操作系统定期清空临时目录。
195
197
 
196
198
  如果找不到角色或启动失败,先运行 `/profiles`,检查目录名与 `name` 是否一致、项目是否受信任,以及引用的工具、技能和扩展是否可用。如果提示 Herdr 不可用,请确认 Pi 正在 Herdr 工作区内运行。
197
199
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oldflag2333333/pi-subagent",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "description": "Reusable specialist profiles and independent sub-agents for Pi",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,9 +1,10 @@
1
- import { randomUUID } from "node:crypto";
2
1
  import * as fs from "node:fs";
3
2
  import * as path from "node:path";
3
+ import { setTimeout as delay } from "node:timers/promises";
4
4
  import { getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
5
5
  import { subSessionName } from "../manual-context.js";
6
6
  import { subCapabilityArgs } from "../profiles/launch-args.js";
7
+ import { LAUNCH_COMMAND, launchScriptContent, launchScriptPath, reapAbandonedLaunchScripts } from "./launch-script.js";
7
8
  import type { SubAgentStatus, SubLaunchSpec, SubSurfaceAdapter, SurfaceHandle } from "../types.js";
8
9
 
9
10
  function parseEnvelope(stdout: string): Record<string, unknown> {
@@ -70,6 +71,8 @@ export class HerdrTabAdapter implements SubSurfaceAdapter {
70
71
  const workspaceId = process.env.HERDR_WORKSPACE_ID;
71
72
  if (!workspaceId) throw new Error("HERDR_WORKSPACE_ID is unavailable; Pi is not running in a Herdr workspace.");
72
73
  signal?.throwIfAborted();
74
+ reapAbandonedLaunchScripts();
75
+ const launcher = launchScriptPath(spec.channelDir);
73
76
  const label = `↳ pi · ${safeLabel(spec.title)}`;
74
77
  const created = await this.pi.exec("herdr", [
75
78
  "tab", "create",
@@ -79,6 +82,7 @@ export class HerdrTabAdapter implements SubSurfaceAdapter {
79
82
  "--env", "PI_SUBAGENT_ROLE=sub",
80
83
  "--env", `PI_SUBAGENT_CHANNEL=${spec.channelDir}`,
81
84
  "--env", `PI_SUBAGENT_TOKEN=${spec.token}`,
85
+ "--env", `PI_SUBAGENT_LAUNCH=${launcher}`,
82
86
  "--no-focus",
83
87
  ], { timeout: 15_000, signal });
84
88
  let payload: Record<string, unknown>;
@@ -90,29 +94,34 @@ export class HerdrTabAdapter implements SubSurfaceAdapter {
90
94
  const paneId = nestedString(payload, "root_pane", "pane_id") ?? nestedString(payload, "pane", "pane_id");
91
95
  if (!tabId) throw new Error("Herdr tab creation response did not contain a tab ID; automatic cleanup is unavailable.");
92
96
  const handle: SurfaceHandle = { adapter: "herdr", tabId, ...(paneId ? { paneId } : {}) };
97
+ const terminalId = nestedString(payload, "root_pane", "terminal_id") ?? nestedString(payload, "pane", "terminal_id");
98
+ let launcherOwned = false;
99
+ const removeLauncher = () => {
100
+ if (!launcherOwned) return;
101
+ fs.rmSync(launcher, { force: true });
102
+ launcherOwned = false;
103
+ };
93
104
  const step = async (args: string[], timeout: number, failure: string) => {
94
105
  signal?.throwIfAborted();
95
106
  const result = await this.pi.exec("herdr", args, { timeout, signal });
96
- if (result.code !== 0 || result.killed) throw new Error(result.stderr || failure);
97
107
  signal?.throwIfAborted();
108
+ if (result.code !== 0 || result.killed) throw new Error(result.stderr || failure);
98
109
  };
99
110
  try {
100
111
  if (created.code !== 0 || created.killed) throw new Error(created.stderr || "Failed to create Herdr tab.");
101
112
  if (!paneId) throw new Error("Herdr tab creation response did not contain a root pane ID.");
102
- // A new pane's shell can still be initializing in canonical mode.
103
- // On macOS a long launch line can lose its tail (including Enter).
104
- // Execute a short probe first; match only its output, never the echoed
105
- // command. Unwrapped rows keep a wrapped command from matching early.
106
- const readyMarker = `pi-subagent-shell-ready-${randomUUID()}`;
107
- await step(["pane", "run", paneId, `echo ${readyMarker}`], 10_000, "Failed to probe the Sub shell.");
108
- await step([
109
- "pane", "wait-output", paneId, "--regex", `^${readyMarker}$`,
110
- "--source", "recent-unwrapped", "--timeout", "60000",
111
- ], 65_000, "The Sub shell did not become ready.");
113
+ // The file is private and exclusive; even a partial write is owned and
114
+ // cleaned up on failure. Its path travels via tab env, not PTY input.
115
+ const fd = fs.openSync(launcher, "wx", 0o600);
116
+ launcherOwned = true;
117
+ try { fs.writeFileSync(fd, launchScriptContent(subArgs(spec)), "utf8"); }
118
+ finally { fs.closeSync(fd); }
119
+ await step(["pane", "run", paneId, LAUNCH_COMMAND], 10_000, "Failed to launch Sub Pi in Herdr tab.");
120
+ await this.waitForPi(handle, terminalId, signal);
121
+ // pane run only acknowledges admission; never unlink on that alone.
122
+ removeLauncher();
112
123
  const name = agentName(spec.runId);
113
- await step([
114
- "agent", "start", name, "--kind", "pi", "--pane", paneId, "--timeout", "60000", "--", ...subArgs(spec),
115
- ], 70_000, "Failed to start Sub Pi in Herdr tab.");
124
+ await step(["agent", "rename", paneId, name], 10_000, "Failed to name the Sub Pi agent.");
116
125
  await step([
117
126
  "pane", "report-metadata", paneId, "--source", "subagent",
118
127
  "--token", "subagent_role=sub", "--token", `subagent_main_session=${spec.mainSessionId}`,
@@ -121,16 +130,53 @@ export class HerdrTabAdapter implements SubSurfaceAdapter {
121
130
  await step(["agent", "prompt", name, spec.task], 20_000, "Failed to submit the delegated task to the Herdr agent.");
122
131
  return handle;
123
132
  } catch (error) {
133
+ let rollback: unknown;
124
134
  try {
125
135
  // Rollback must not inherit the cancellation that caused the failure.
126
136
  await this.close(handle);
127
- } catch (cleanup) {
128
- throw new HerdrLaunchCleanupError(handle, error, cleanup);
137
+ } catch (cleanup) { rollback = cleanup; }
138
+ try { removeLauncher(); }
139
+ catch (cleanup) {
140
+ error = new AggregateError([error, cleanup], `Sub launch failed: ${error instanceof Error ? error.message : String(error)}. Launch script cleanup also failed (${launcher}): ${cleanup instanceof Error ? cleanup.message : String(cleanup)}.`, { cause: error });
129
141
  }
142
+ if (rollback) throw new HerdrLaunchCleanupError(handle, error, rollback);
130
143
  throw error;
131
144
  }
132
145
  }
133
146
 
147
+ private async waitForPi(handle: SurfaceHandle, terminalId: string | undefined, signal?: AbortSignal): Promise<void> {
148
+ const deadline = Date.now() + 60_000;
149
+ while (true) {
150
+ signal?.throwIfAborted();
151
+ const remaining = deadline - Date.now();
152
+ if (remaining <= 0) throw new Error("Sub Pi did not become ready within 60 seconds.");
153
+ const response = await this.pi.exec("herdr", ["agent", "get", handle.paneId!], { timeout: Math.min(3000, remaining), signal });
154
+ signal?.throwIfAborted();
155
+ if (Date.now() >= deadline) throw new Error("Sub Pi did not become ready within 60 seconds.");
156
+ if (response.killed) throw new Error(response.stderr || "Herdr agent readiness check was interrupted.");
157
+ let payload: Record<string, unknown>;
158
+ try { payload = parseEnvelope(`${response.stdout}\n${response.stderr}`); }
159
+ catch (error) { throw new Error(response.stderr || "Invalid Herdr agent readiness response.", { cause: error }); }
160
+ if (response.code !== 0) {
161
+ if (nestedString(payload, "error", "code") !== "agent_not_found") {
162
+ throw new Error(response.stderr || "Failed to inspect the starting Sub Pi agent.");
163
+ }
164
+ } else {
165
+ const agent = payload.agent as Record<string, unknown> | undefined;
166
+ if (!agent || typeof agent !== "object") throw new Error("Herdr readiness response did not contain an agent.");
167
+ if (agent.pane_id !== handle.paneId || agent.tab_id !== handle.tabId
168
+ || (terminalId && agent.terminal_id !== terminalId)) throw new Error("The Sub Pi terminal changed during startup.");
169
+ if (agent.agent && agent.agent !== "pi") throw new Error(`Expected Sub Pi, but Herdr detected ${agent.agent}.`);
170
+ if (agent.agent_status === "blocked") throw new Error("Sub Pi is blocked during startup and is not ready for prompts.");
171
+ // interactive_ready is a managed-agent-start-only field in Herdr.
172
+ // Unmanaged Pi readiness comes from its lifecycle hook or UI detector.
173
+ if (agent.agent === "pi" && !agent.launch_pending && (agent.agent_status === "idle" || agent.agent_status === "done")) return;
174
+ }
175
+ try { await delay(Math.max(0, Math.min(100, deadline - Date.now())), undefined, { signal }); }
176
+ catch (error) { signal?.throwIfAborted(); throw error; }
177
+ }
178
+ }
179
+
134
180
  async status(handle: SurfaceHandle | undefined): Promise<SubAgentStatus> {
135
181
  if (!handle?.paneId) return "unknown";
136
182
  try {
@@ -0,0 +1,71 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
4
+ import { runtimeRoot } from "../channel.js";
5
+
6
+ // Only this fixed-size command crosses the PTY, never the path or Pi arguments.
7
+ export const LAUNCH_COMMAND = 'sh "$PI_SUBAGENT_LAUNCH"';
8
+ export const ABANDONED_LAUNCH_AGE_MS = 5 * 60_000;
9
+ const LAUNCH_FILE = /^launch-([1-9]\d*)-[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}\.sh$/;
10
+
11
+ export function launchScriptPath(channelDir: string): string {
12
+ return path.join(channelDir, `launch-${process.pid}-${randomUUID()}.sh`);
13
+ }
14
+
15
+ export function launchScriptContent(args: readonly string[]): string {
16
+ const quoted = args.map((arg) => `'${arg.replaceAll("'", "'\\''")}'`);
17
+ // The shell has opened the file by this point and can keep reading its FD
18
+ // after unlinking it. Self-removal also works if Main has already crashed.
19
+ return `#!/bin/sh\nrm -f -- "$0"\nunset PI_SUBAGENT_LAUNCH\nexec pi ${quoted.join(" ")}\n`;
20
+ }
21
+
22
+ function warnReapError(target: string, error: unknown): void {
23
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
24
+ console.warn(`Pi Subagent: abandoned launch script cleanup skipped (${target}): ${error instanceof Error ? error.message : String(error)}`);
25
+ }
26
+
27
+ function entries(directory: string): fs.Dirent[] {
28
+ try { return fs.readdirSync(directory, { withFileTypes: true }); }
29
+ catch (error) {
30
+ warnReapError(directory, error);
31
+ return [];
32
+ }
33
+ }
34
+
35
+ /** Best-effort sweep of expired launch files belonging to dead processes, never channels. */
36
+ export function reapAbandonedLaunchScripts(root = runtimeRoot(), now = Date.now()): void {
37
+ // Directory entries, rather than stat-followed paths, keep symlinked trees
38
+ // out of the sweep. Each run's normal channel cleanup remains independent.
39
+ try { if (!fs.lstatSync(root).isDirectory()) return; }
40
+ catch (error) {
41
+ warnReapError(root, error);
42
+ return;
43
+ }
44
+ for (const owner of entries(root)) {
45
+ if (!owner.isDirectory()) continue;
46
+ const ownerDir = path.join(root, owner.name);
47
+ for (const run of entries(ownerDir)) {
48
+ if (!run.isDirectory()) continue;
49
+ const directory = path.join(ownerDir, run.name);
50
+ for (const entry of entries(directory)) {
51
+ const match = LAUNCH_FILE.exec(entry.name);
52
+ if (!entry.isFile() || !match) continue;
53
+ const file = path.join(directory, entry.name);
54
+ try {
55
+ const stat = fs.lstatSync(file);
56
+ if (!stat.isFile() || now - stat.mtimeMs < ABANDONED_LAUNCH_AGE_MS
57
+ || (typeof process.getuid === "function" && stat.uid !== process.getuid())) continue;
58
+ const pid = Number(match[1]);
59
+ if (!Number.isSafeInteger(pid) || pid > 0x7fffffff) continue;
60
+ try { process.kill(pid, 0); }
61
+ catch (error) {
62
+ // EPERM and other ambiguous liveness failures must preserve it.
63
+ if ((error as NodeJS.ErrnoException).code === "ESRCH") fs.unlinkSync(file);
64
+ }
65
+ } catch (error) {
66
+ warnReapError(file, error);
67
+ }
68
+ }
69
+ }
70
+ }
71
+ }