@oldflag2333333/pi-subagent 0.1.5 → 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
@@ -77,7 +77,7 @@ Ask the reviewer profile to review src/auth for bugs and missing tests.
77
77
  | Finish with a specialist | Ask Pi to close the Sub, or close its Herdr tab |
78
78
  | Remove Sub sessions and communication files left by deleted Mains | `/clean-sub` |
79
79
 
80
- Within the same Main session, repeated `/sub:reviewer` commands reuse that profile's open Sub. If it is busy, your next task is queued. Finishing a task does not automatically close the tab.
80
+ Within the same Main session, repeated `/sub:reviewer` commands reuse that profile's open Sub. If it is busy, your next task is queued. Finishing a task does not automatically close the tab. Subs can report progress through `talk` without stopping; `endTurn: true` explicitly ends their current turn when finished or waiting for Main.
81
81
 
82
82
  The **Sub-agents** list shows only assistants created by the current Main: all **Open** Subs (including idle or unknown status) and **Resumable** persistent sessions confirmed closed with saved files still available. Other Main sessions and forks do not inherit them or gain access to resume them. Reloading or resuming the same Main preserves ownership.
83
83
 
@@ -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
@@ -77,7 +77,7 @@ mkdir -p ~/.pi/agent/subagent/profiles/reviewer
77
77
  | 结束使用 | 让 Pi 关闭子助手,或手动关闭其 Herdr 标签页 |
78
78
  | 清理已删除 Main 遗留的 Sub 会话和通信文件 | `/clean-sub` |
79
79
 
80
- 在同一个主会话(Main)中,多次调用 `/sub:reviewer` 会复用该角色已打开的子助手(Sub)。如果它正在工作,新任务会排队。任务完成后,标签页不会自动关闭。
80
+ 在同一个主会话(Main)中,多次调用 `/sub:reviewer` 会复用该角色已打开的子助手(Sub)。如果它正在工作,新任务会排队。任务完成后,标签页不会自动关闭。Sub 通过 `talk` 汇报进度后默认继续工作;只有完成任务或需要等待 Main 回复时,才用 `endTurn: true` 显式结束本轮。
81
81
 
82
82
  **Sub-agents(子助手)** 列表只显示当前 Main 创建的助手:**Open** 包含所有已打开的子助手,包括空闲或状态未知的;**Resumable** 包含已确认关闭、且保存文件仍存在的持久化会话。其他 Main 或分叉会话不会继承这些助手,也不能恢复它们。重载或恢复同一个 Main 则会保留关联。
83
83
 
@@ -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/docs/messaging.md CHANGED
@@ -7,6 +7,15 @@ Pi Subagent requires **Pi 1.2.0 or newer**. Main ↔ Sub messages use `pi.sendMe
7
7
 
8
8
  There are no synthetic user-role wake messages, version fallbacks, or patches to Pi internals. Custom messages do not pass through user-input processing or execute peer text as slash commands or templates.
9
9
 
10
+ ## Sub talk and turn control
11
+
12
+ Sub's `talk` accepts an optional `endTurn` boolean, defaulting to `false`:
13
+
14
+ - Omitted or `false`: send the message and continue working. Use this for progress updates; no acknowledgement or "continue" reply from Main is required.
15
+ - `true`: send the message and end the current turn. Use this for completed work or a blocking question that requires Main's reply. Send a terminating `talk` on its own: Pi only ends the tool batch early when every tool result in it is terminating.
16
+
17
+ Ending a turn does not close the Sub or discard queued follow-ups. Main can still send further work through `talk`. Message text should make clear whether it reports progress, asks a blocking question, or delivers completed work; Main must not treat every message as task completion. Main's own `talk` does not end its turn.
18
+
10
19
  ## Delivery guarantees
11
20
 
12
21
  - `content` carries the peer message and routing instructions; `details` carries rendering and receipt metadata and is not sent to the model.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oldflag2333333/pi-subagent",
3
- "version": "0.1.5",
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
+ }
@@ -13,7 +13,9 @@ export function bindPromptSections(pi: ExtensionAPI, keys: readonly string[], ge
13
13
  }
14
14
 
15
15
  export const SUB_PROTOCOL = `## Pi Subagent Sub protocol
16
- You are an isolated Sub Pi working with a Main Pi. You have not received the Main's conversation and must not read Pi session files. Use talk whenever you need information from the Main or need to deliver work. Each talk sends one message to the Main and ends the current turn. Remain available after delivery. The Main alone decides whether to respond, request more work, or close this Sub session. Never close the session yourself.`;
16
+ You are an isolated Sub Pi working with a Main Pi. You have not received the Main's conversation and must not read Pi session files.
17
+ Use talk to report progress, ask for information, or deliver completed work. Make clear in the message whether it is a progress update, a blocking question, or completed work. By default, talk only sends the message. After a progress update, continue the assigned task without waiting for Main to acknowledge or say 'continue'.
18
+ Set endTurn=true only when the task is complete or you must wait for Main's reply before proceeding. Call terminating talk on its own, without other tools in the same batch. Ending a turn does not close the session; remain available for follow-ups. The Main alone decides whether to respond, request more work, or close this Sub session. Never close the session yourself.`;
17
19
 
18
20
  export function applySubPromptSections(sections: Record<string, string>, profile: ResolvedProfile): void {
19
21
  sections.subagent_sub_protocol = SUB_PROTOCOL;
package/src/tools/main.ts CHANGED
@@ -58,7 +58,7 @@ export function registerMainTools(pi: ExtensionAPI, manager: MainRunManager): vo
58
58
  "Use delegate for work assigned by MAIN.md or a separable task that benefits from an independent context; select an explicitly configured profile and provide a short informative title.",
59
59
  "After delegate launches, do not wait or repeatedly poll. Pi Subagent will wake the Main when the Sub uses talk.",
60
60
  "For Subs labeled user-invoked specialists, follow up only on the user's task and results. Do not assign unrelated work or substitute them for general consulting profiles. New tasks are initiated by the user through a profile command.",
61
- "Use talk to respond to an existing Sub, interrupt_sub to stop its current turn without closing it, and close_sub only after its delivery is accepted or the user asks to close it.",
61
+ "Use talk to respond to an existing Sub, interrupt_sub to stop its current turn without closing it, and close_sub only after its completed work is accepted or the user asks to close it.",
62
62
  ],
63
63
  parameters: Type.Object({
64
64
  title: Type.String({ description: "Short Herdr tab title, up to 48 displayed characters." }),
@@ -131,6 +131,7 @@ export function registerMainTools(pi: ExtensionAPI, manager: MainRunManager): vo
131
131
  label: "talk",
132
132
  description: "Send one message to an existing Sub Pi. Messages enter the Sub's native follow-up queue and are processed after its current work. Format non-trivial messages as readable Markdown with paragraph breaks and lists.",
133
133
  promptSnippet: "Send a message to an existing Sub Pi",
134
+ promptGuidelines: ["Sub talk messages may be progress updates while the Sub keeps working. Do not send routine 'continue' replies or close a Sub merely for reporting progress; respond with talk when input is needed, and use close_sub only after completed work is accepted or the user asks to close it."],
134
135
  executionMode: "sequential",
135
136
  parameters: Type.Object({
136
137
  runId: Type.String({ description: "Full Sub run ID or a unique prefix." }),
package/src/tools/sub.ts CHANGED
@@ -183,12 +183,16 @@ export function registerSub(pi: ExtensionAPI): void {
183
183
  name: "talk",
184
184
  label: "talk",
185
185
  exposure: "model-only",
186
- description: "Send one message to the Main Pi and end the current turn. Use it to ask for information or deliver work. Format non-trivial messages as readable Markdown with paragraph breaks and lists.",
187
- promptSnippet: "Send a message to the Main Pi",
188
- promptGuidelines: ["Use talk whenever the Sub needs to communicate with the Main; the Main decides when to close the Sub session."],
186
+ description: "Send one message to the Main Pi. Continue working by default; set endTurn=true only when the task is complete or you must wait for Main's reply. Format non-trivial messages as readable Markdown with paragraph breaks and lists.",
187
+ promptSnippet: "Send a message to the Main Pi, optionally ending the current turn",
188
+ promptGuidelines: [
189
+ "Use talk for progress updates, questions, and completed work. After progress updates, continue the task without waiting for an acknowledgement.",
190
+ "Set talk endTurn=true only when the task is complete or you must wait for Main's reply. Call terminating talk on its own, without other tools in the same batch. The Main decides when to close the Sub session.",
191
+ ],
189
192
  executionMode: "sequential",
190
193
  parameters: Type.Object({
191
194
  message: Type.String({ description: "Message to the other Agent. For non-trivial content, use readable Markdown with paragraph breaks and lists." }),
195
+ endTurn: Type.Optional(Type.Boolean({ description: "End the current turn after sending. Defaults to false: continue working. Set true only when the task is complete or you must wait for Main's reply." })),
192
196
  }),
193
197
  renderCall(args, theme, context) {
194
198
  const message = typeof args.message === "string" ? args.message : "";
@@ -205,7 +209,7 @@ export function registerSub(pi: ExtensionAPI): void {
205
209
  return {
206
210
  content: [{ type: "text", text: "Message queued for the Main Pi." }],
207
211
  details: { messageId: message.id },
208
- terminate: true,
212
+ terminate: params.endTurn === true,
209
213
  };
210
214
  },
211
215
  renderResult(result, _options, theme, context) {