@buyi1net/pi-toolkit 0.0.1

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 (129) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +45 -0
  3. package/assembler.ts +127 -0
  4. package/config.ts +201 -0
  5. package/i18n.ts +693 -0
  6. package/index.ts +99 -0
  7. package/menu/items.ts +264 -0
  8. package/menu/panels.ts +91 -0
  9. package/menu/settings-list.ts +84 -0
  10. package/menu/theme.ts +31 -0
  11. package/menu/toolkit-menu.ts +139 -0
  12. package/module.ts +156 -0
  13. package/modules/eyes/chain.ts +173 -0
  14. package/modules/eyes/config.ts +319 -0
  15. package/modules/eyes/index.ts +161 -0
  16. package/modules/eyes/menu.ts +828 -0
  17. package/modules/eyes/pi-model-backend.ts +245 -0
  18. package/modules/eyes/resilience.ts +161 -0
  19. package/modules/eyes/vision-bridge.ts +232 -0
  20. package/modules/eyes/vision-cache.ts +86 -0
  21. package/modules/eyes/vision-json.ts +19 -0
  22. package/modules/eyes/vision-preprocess.ts +192 -0
  23. package/modules/eyes/vision-probe.ts +21 -0
  24. package/modules/eyes/vision-prompt.ts +90 -0
  25. package/modules/eyes/vision-tool.ts +59 -0
  26. package/modules/eyes/vision-types.ts +33 -0
  27. package/modules/index.ts +10 -0
  28. package/modules/subagents/agents/researcher.md +51 -0
  29. package/modules/subagents/agents/scout.md +40 -0
  30. package/modules/subagents/agents/worker.md +79 -0
  31. package/modules/subagents/config.json.example +6 -0
  32. package/modules/subagents/config.ts +144 -0
  33. package/modules/subagents/index.ts +91 -0
  34. package/modules/subagents/menu.ts +388 -0
  35. package/modules/subagents/src/activity.ts +511 -0
  36. package/modules/subagents/src/agents.ts +126 -0
  37. package/modules/subagents/src/command.ts +37 -0
  38. package/modules/subagents/src/dependencies.ts +246 -0
  39. package/modules/subagents/src/diagnostics.ts +13 -0
  40. package/modules/subagents/src/display.ts +94 -0
  41. package/modules/subagents/src/headless.ts +342 -0
  42. package/modules/subagents/src/herdr.ts +203 -0
  43. package/modules/subagents/src/index.ts +2798 -0
  44. package/modules/subagents/src/inspect-tool.ts +839 -0
  45. package/modules/subagents/src/launch-config.ts +196 -0
  46. package/modules/subagents/src/layout-budget.ts +26 -0
  47. package/modules/subagents/src/list-tool.ts +292 -0
  48. package/modules/subagents/src/message-tool.ts +808 -0
  49. package/modules/subagents/src/names.ts +17 -0
  50. package/modules/subagents/src/pane-layout.ts +49 -0
  51. package/modules/subagents/src/params.ts +145 -0
  52. package/modules/subagents/src/renderers.ts +181 -0
  53. package/modules/subagents/src/result.ts +43 -0
  54. package/modules/subagents/src/retention.ts +114 -0
  55. package/modules/subagents/src/route-error.ts +212 -0
  56. package/modules/subagents/src/routing.ts +235 -0
  57. package/modules/subagents/src/runtime-registry.ts +159 -0
  58. package/modules/subagents/src/session.ts +801 -0
  59. package/modules/subagents/src/status.ts +513 -0
  60. package/modules/subagents/src/stop-tool.ts +235 -0
  61. package/modules/subagents/src/subagent-done.ts +590 -0
  62. package/modules/subagents/src/subagent-tool.ts +1155 -0
  63. package/modules/subagents/src/surface.ts +355 -0
  64. package/modules/subagents/src/team-dispatch-tool.ts +219 -0
  65. package/modules/subagents/src/team.ts +232 -0
  66. package/modules/subagents/src/tmux.ts +210 -0
  67. package/modules/subagents/src/tools/safe-bash.ts +72 -0
  68. package/modules/subagents/src/types.ts +131 -0
  69. package/modules/tui/adapter/provider-usage.ts +143 -0
  70. package/modules/tui/config.ts +50 -0
  71. package/modules/tui/index.ts +120 -0
  72. package/modules/tui/kernel/pkg/shared/grok-subscription.ts +169 -0
  73. package/modules/tui/kernel/pkg/shared/official-subscription.ts +237 -0
  74. package/modules/tui/kernel/pkg/shared/provider-catalog.ts +367 -0
  75. package/modules/tui/kernel/pkg/shared/provider-contracts.ts +150 -0
  76. package/modules/tui/kernel/pkg/shared/provider-display.ts +55 -0
  77. package/modules/tui/kernel/pkg/shared/provider-parsers.ts +171 -0
  78. package/modules/tui/kernel/pkg/shared/volcengine.ts +191 -0
  79. package/modules/tui/kernel/pkg/shared/zhipu.ts +149 -0
  80. package/modules/tui/kernel/pkg/usage-core/index.ts +335 -0
  81. package/modules/tui/kernel/pkg/usage-core/provider-routes.ts +187 -0
  82. package/modules/tui/kernel/pkg/usage-node/index.ts +635 -0
  83. package/modules/tui/kernel/pkg/usage-node/provider-usage.ts +388 -0
  84. package/modules/tui/kernel/usage-core.ts +2 -0
  85. package/modules/tui/kernel/usage-node.ts +2 -0
  86. package/modules/tui/menu.ts +418 -0
  87. package/modules/tui/plugin/editor.ts +396 -0
  88. package/modules/tui/plugin/footer.ts +161 -0
  89. package/modules/tui/plugin/index.ts +30 -0
  90. package/modules/tui/plugin/lifecycle.ts +637 -0
  91. package/modules/tui/plugin/package-order.ts +169 -0
  92. package/modules/tui/plugin/screen-transition.ts +203 -0
  93. package/modules/tui/plugin/settings-config.ts +297 -0
  94. package/modules/tui/plugin/status-sources.ts +49 -0
  95. package/modules/tui/plugin/transition-gate.ts +261 -0
  96. package/modules/tui/renderer/custom-header.ts +157 -0
  97. package/modules/tui/renderer/editor.ts +70 -0
  98. package/modules/tui/renderer/header.ts +72 -0
  99. package/modules/tui/renderer/icons.ts +149 -0
  100. package/modules/tui/renderer/pi-installer-logo.ts +194 -0
  101. package/modules/tui/status/auto-compaction.ts +67 -0
  102. package/modules/tui/status/project-status.ts +655 -0
  103. package/modules/tui/status/provider-status.ts +120 -0
  104. package/modules/tui/status/runtime-status.ts +307 -0
  105. package/modules/tui/status/session-status.ts +325 -0
  106. package/modules/tui/status/status-config.ts +95 -0
  107. package/modules/tui/status/status-segments.ts +263 -0
  108. package/modules/tui/status/turn-telemetry.ts +466 -0
  109. package/modules/tui/themes/LICENSE.pi-themes-bundle +21 -0
  110. package/modules/tui/themes/UPSTREAM.md +7 -0
  111. package/modules/tui/themes/catppuccin-latte.json +80 -0
  112. package/modules/tui/themes/catppuccin-mocha.json +79 -0
  113. package/modules/tui/themes/crimson-noir.json +85 -0
  114. package/modules/tui/themes/dracula.json +79 -0
  115. package/modules/tui/themes/everforest-dark.json +85 -0
  116. package/modules/tui/themes/gruvbox-dark.json +85 -0
  117. package/modules/tui/themes/gruvbox-light.json +85 -0
  118. package/modules/tui/themes/matrix.json +85 -0
  119. package/modules/tui/themes/nord.json +85 -0
  120. package/modules/tui/themes/one-dark.json +85 -0
  121. package/modules/tui/themes/rose-pine-dawn.json +85 -0
  122. package/modules/tui/themes/rose-pine.json +85 -0
  123. package/modules/tui/themes/solarized-dark.json +85 -0
  124. package/modules/tui/themes/solarized-light.json +85 -0
  125. package/modules/tui/themes/tokyo-night-storm.json +79 -0
  126. package/modules/tui/themes/tokyo-night.json +79 -0
  127. package/package.json +28 -0
  128. package/services.ts +38 -0
  129. package/toolkit.ts +147 -0
@@ -0,0 +1,232 @@
1
+ /**
2
+ * 持久团队 MVP 的父会话侧协议文件(全部位于父会话 artifactDir 下):
3
+ *
4
+ * team-roster.json 成员名册:每个 member 的运行状态(idle/dispatched/offline)。
5
+ * 父进程是唯一写者(成员侧只读),读-改-写 + tmp/rename 原子落盘。
6
+ * team-mailbox/<name>/ 每成员一个收件箱目录;成员侧 team_send 原子投递
7
+ * (<uuid>.msg.json,tmp+rename),父侧 round watcher 认领并经
8
+ * RPC steer 注入目标成员后删除。
9
+ *
10
+ * 诚实边界(README 同步):fire-and-forget——发送成功只代表落盘成功,不保证
11
+ * 目标在线期间消费;成员 offline 后残留消息不自动补投。无阻塞 request/response,
12
+ * 天然不存在 A↔B 互等死锁。
13
+ */
14
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
15
+ import { dirname, join } from "node:path";
16
+ import { randomUUID } from "node:crypto";
17
+ import { debugLog } from "./diagnostics.ts";
18
+ import { normalizeSubagentName } from "./names.ts";
19
+
20
+ /** 成员每轮结束写入的 sidecar(subagent-done.ts 的 agent_end 写入)。 */
21
+ export interface RoundSignal {
22
+ version: 1;
23
+ name: string;
24
+ /** 进程内递增轮次序号(成员侧内存计数,仅供诊断)。 */
25
+ seq: number;
26
+ at: number;
27
+ }
28
+
29
+ // ── 派单轮关联 nonce ──────────────────────────────────────────────────
30
+ // team_dispatch 的每一轮生成唯一 roundId,经 steer 文本携带进入成员会话
31
+ // (成员侧无需感知);父侧 round watcher 在 .round 认领时检索会话用户消息,
32
+ // 只有确实出现过本次派单 marker 的 .round 才属于派单轮——自发轮(成员间
33
+ // 消息/ask 回复触发)的 .round 不会错误消费派单结果(见 index.ts
34
+ // watchMemberRound 的关联判定)。首轮(spawn 时的初始 prompt)同样带 marker,
35
+ // 保证语义统一。
36
+
37
+ /** 派单轮关联标记文本(出现在成员会话的用户消息里)。 */
38
+ export function teamRoundMarker(roundId: string): string {
39
+ return `[team-round:${roundId}]`;
40
+ }
41
+
42
+ /** 组装带关联标记的派单 prompt(标记行是协议噪音,明确标注可忽略)。 */
43
+ export function buildTeamRoundPrompt(roundId: string, task: string): string {
44
+ return `${teamRoundMarker(roundId)} (team round protocol marker — ignore this line)\n\n${task}`;
45
+ }
46
+
47
+ /** 原子写轮次结束信号(成员侧,tmp+rename)。 */
48
+ export function writeRoundSignal(sessionFile: string, signal: Omit<RoundSignal, "version">): void {
49
+ const tmp = `${sessionFile}.round.${process.pid}.tmp`;
50
+ writeFileSync(tmp, JSON.stringify({ version: 1, ...signal } satisfies RoundSignal), "utf-8");
51
+ renameSync(tmp, `${sessionFile}.round`);
52
+ }
53
+
54
+ /**
55
+ * 父侧认领并消费轮次结束信号(atomic rename claim):消费恰好一次;
56
+ * 读取失败/格式非法时删除并返回 null(坏信号不会重复触发回注)。
57
+ */
58
+ export function claimRoundSignal(sessionFile: string): RoundSignal | null {
59
+ const path = `${sessionFile}.round`;
60
+ const claimed = `${path}.${process.pid}.${randomUUID()}.processing`;
61
+ try {
62
+ renameSync(path, claimed);
63
+ const parsed = JSON.parse(readFileSync(claimed, "utf-8")) as RoundSignal;
64
+ rmSync(claimed, { force: true });
65
+ if (parsed?.version === 1 && typeof parsed.name === "string") return parsed;
66
+ return null;
67
+ } catch (error) {
68
+ try { rmSync(claimed, { force: true }); } catch {}
69
+ if (existsSync(path)) debugLog(`Could not claim round signal ${path}`, error);
70
+ return null;
71
+ }
72
+ }
73
+
74
+ export type MemberStatus = "idle" | "dispatched" | "offline";
75
+
76
+ export interface RosterMember {
77
+ name: string;
78
+ agent?: string;
79
+ sessionFile: string;
80
+ pid?: number;
81
+ status: MemberStatus;
82
+ /** 当前在途轮次开始的时间戳(仅 dispatched)。 */
83
+ dispatchedAt?: number;
84
+ /** 最近一次轮次完成时间戳。 */
85
+ lastRoundAt?: number;
86
+ /** offline 原因(含 process-exited / prompt-rejected / round-association-failed / stopped / host-shutdown / host-reload)。 */
87
+ offlineReason?: string;
88
+ }
89
+
90
+ interface RosterFile {
91
+ version: 1;
92
+ members: RosterMember[];
93
+ }
94
+
95
+ export function rosterPath(artifactDir: string): string {
96
+ return join(artifactDir, "team-roster.json");
97
+ }
98
+
99
+ export function mailboxDir(artifactDir: string, memberName: string): string {
100
+ return join(artifactDir, "team-mailbox", normalizeSubagentName(memberName, "member"));
101
+ }
102
+
103
+ function readRosterRaw(path: string): RosterFile {
104
+ try {
105
+ if (!existsSync(path)) return { version: 1, members: [] };
106
+ const parsed = JSON.parse(readFileSync(path, "utf-8")) as RosterFile;
107
+ if (parsed?.version !== 1 || !Array.isArray(parsed.members)) return { version: 1, members: [] };
108
+ return parsed;
109
+ } catch (error) {
110
+ debugLog(`Could not read team roster ${path}`, error);
111
+ return { version: 1, members: [] };
112
+ }
113
+ }
114
+
115
+ function writeRosterRaw(path: string, roster: RosterFile): void {
116
+ mkdirSync(dirname(path), { recursive: true });
117
+ const tmp = `${path}.${process.pid}.tmp`;
118
+ writeFileSync(tmp, JSON.stringify(roster), "utf-8");
119
+ renameSync(tmp, path);
120
+ }
121
+
122
+ export function readRoster(path: string): RosterMember[] {
123
+ return readRosterRaw(path).members.filter((member) =>
124
+ member && typeof member.name === "string" && typeof member.sessionFile === "string" &&
125
+ (member.status === "idle" || member.status === "dispatched" || member.status === "offline"),
126
+ );
127
+ }
128
+
129
+ export function findRosterMember(path: string, name: string): RosterMember | null {
130
+ const key = normalizeSubagentName(name, "");
131
+ return readRoster(path).find((member) => member.name === key) ?? null;
132
+ }
133
+
134
+ /**
135
+ * 幂等 upsert + 状态转换。父进程单写者,无需锁;offline 是吸收态——
136
+ * 再次登记(重新 spawn 同名 member)按新生命周期覆盖。
137
+ */
138
+ export function upsertRosterMember(
139
+ path: string,
140
+ entry: {
141
+ name: string;
142
+ agent?: string;
143
+ sessionFile: string;
144
+ pid?: number;
145
+ status: MemberStatus;
146
+ dispatchedAt?: number;
147
+ lastRoundAt?: number;
148
+ offlineReason?: string;
149
+ },
150
+ ): RosterMember {
151
+ const roster = readRosterRaw(path);
152
+ const key = normalizeSubagentName(entry.name, "");
153
+ const next: RosterMember = {
154
+ name: key,
155
+ ...(entry.agent ? { agent: entry.agent } : {}),
156
+ sessionFile: entry.sessionFile,
157
+ ...(entry.pid != null ? { pid: entry.pid } : {}),
158
+ status: entry.status,
159
+ ...(entry.dispatchedAt != null ? { dispatchedAt: entry.dispatchedAt } : {}),
160
+ ...(entry.lastRoundAt != null ? { lastRoundAt: entry.lastRoundAt } : {}),
161
+ ...(entry.offlineReason ? { offlineReason: entry.offlineReason } : {}),
162
+ };
163
+ const idx = roster.members.findIndex((member) => member.name === key);
164
+ if (idx >= 0) roster.members[idx] = next;
165
+ else roster.members.push(next);
166
+ writeRosterRaw(path, roster);
167
+ return next;
168
+ }
169
+
170
+ export function removeRosterMember(path: string, name: string): void {
171
+ const roster = readRosterRaw(path);
172
+ const key = normalizeSubagentName(name, "");
173
+ const filtered = roster.members.filter((member) => member.name !== key);
174
+ if (filtered.length === roster.members.length) return;
175
+ writeRosterRaw(path, { version: 1, members: filtered });
176
+ }
177
+
178
+ // ── 成员间消息(mailbox)──────────────────────────────────────────────
179
+
180
+ export interface TeamMessage {
181
+ version: 1;
182
+ /** 发送方成员名(成员进程 env 注入,父侧信任来源)。 */
183
+ from: string;
184
+ /** 接收方成员名。 */
185
+ to: string;
186
+ text: string;
187
+ sentAt: number;
188
+ }
189
+
190
+ // 同毫秒内多次投递的单调序号:文件名排序即投递顺序(父侧按序注入)。
191
+ let mailboxSeq = 0;
192
+
193
+ /** 原子投递一条 fire-and-forget 消息(tmp+rename;目录按需创建)。 */
194
+ export function postTeamMessage(artifactDir: string, message: Omit<TeamMessage, "version">): string {
195
+ const dir = mailboxDir(artifactDir, message.to);
196
+ mkdirSync(dir, { recursive: true });
197
+ mailboxSeq += 1;
198
+ const file = join(dir, `${Date.now()}-${String(mailboxSeq).padStart(6, "0")}-${randomUUID()}.msg.json`);
199
+ const tmp = `${file}.tmp`;
200
+ writeFileSync(tmp, JSON.stringify({ version: 1, ...message } satisfies TeamMessage), "utf-8");
201
+ renameSync(tmp, file);
202
+ return file;
203
+ }
204
+
205
+ /**
206
+ * 认领并读取目标成员的全部待投递消息(按文件名时间升序),认领即移出
207
+ * mailbox(rename 到 .processing,读完删除)——消费恰好一次;失败文件
208
+ * 留在 .processing 供人工排查,不会重复注入。
209
+ */
210
+ export function claimTeamMessages(artifactDir: string, memberName: string): TeamMessage[] {
211
+ const dir = mailboxDir(artifactDir, memberName);
212
+ if (!existsSync(dir)) return [];
213
+ const messages: TeamMessage[] = [];
214
+ const files = readdirSync(dir)
215
+ .filter((file) => file.endsWith(".msg.json"))
216
+ .sort();
217
+ for (const file of files) {
218
+ const src = join(dir, file);
219
+ const claimed = `${src}.${process.pid}.processing`;
220
+ try {
221
+ renameSync(src, claimed);
222
+ const parsed = JSON.parse(readFileSync(claimed, "utf-8")) as TeamMessage;
223
+ if (parsed?.version === 1 && typeof parsed.from === "string" && typeof parsed.text === "string") {
224
+ messages.push(parsed);
225
+ }
226
+ rmSync(claimed, { force: true });
227
+ } catch (error) {
228
+ debugLog(`Could not claim team message ${src}`, error);
229
+ }
230
+ }
231
+ return messages;
232
+ }
@@ -0,0 +1,210 @@
1
+ /**
2
+ * tmux 表面 —— IO 原语。语法族判定(shellEscape/sentinelSuffix)由
3
+ * surface.ts 统一分派(tmux 恒为 POSIX),本模块只负责对 tmux CLI 的操作。
4
+ *
5
+ * 实现源自上游 amosblomqvist/pi-interactive-subagents 的 tmux.ts,接口
6
+ * 对齐到统一表面签名(cwd/env 经 split-window 的 -c / -e 传入,tmux
7
+ * 3.2+ 支持,替代上游的 cd 前缀 + 环境变量前缀拼装)。
8
+ *
9
+ * Panes are identified by tmux pane ids (e.g. `%12`). The parent pane keeps
10
+ * one fixed side: only the first subagent pane is split off the parent, and
11
+ * later splits subdivide the managed side only (see pane-layout.ts). The
12
+ * user's focus is never stolen.
13
+ */
14
+ import { execFile, execFileSync } from "node:child_process";
15
+ import { promisify } from "node:util";
16
+ import { MIN_PANE_COLS, MIN_PANE_ROWS, rectMeetsBudget, type PaneRect } from "./layout-budget.ts";
17
+ import { chooseSplitDirection, chooseSplitTarget, type LayoutPane, type SplitDirection } from "./pane-layout.ts";
18
+
19
+ const execFileAsync = promisify(execFile);
20
+
21
+ // ── Availability ──
22
+
23
+ const commandAvailability = new Map<string, boolean>();
24
+
25
+ function hasCommand(command: string): boolean {
26
+ if (commandAvailability.has(command)) {
27
+ return commandAvailability.get(command)!;
28
+ }
29
+
30
+ let available = false;
31
+ try {
32
+ execFileSync("sh", ["-c", `command -v ${command}`], { stdio: "ignore" });
33
+ available = true;
34
+ } catch {
35
+ available = false;
36
+ }
37
+
38
+ commandAvailability.set(command, available);
39
+ return available;
40
+ }
41
+
42
+ /**
43
+ * True when running inside tmux with the tmux binary on PATH.
44
+ * `TMUX` is set by tmux in every process it spawns (shell or pane).
45
+ */
46
+ export function isAvailable(): boolean {
47
+ return !!process.env.TMUX && hasCommand("tmux");
48
+ }
49
+
50
+ export function setupHint(): string {
51
+ return "Start pi inside tmux (`tmux new -A -s pi 'pi'`).";
52
+ }
53
+
54
+ // ── Pane layout ──
55
+
56
+ const managedPaneIds = new Set<string>();
57
+
58
+ // 有意不使用 tmux select-layout(如 tiled)重排:全窗口重排会把主 pane 从
59
+ // 固定侧挪走或挤小,违背“主 pane 稳定占一侧”的布局契约;split 按 50% 均分
60
+ // 目标 pane,几何可预期,pane 关闭后 tmux 自动把空间归还相邻 pane,无需重排。
61
+
62
+ // ── Surface primitives ──
63
+
64
+ /**
65
+ * Create a new pane in the parent's window. Split target selection lives in
66
+ * pane-layout.ts:首次从主 pane 切出,之后只在 managed 区域内继续切分;
67
+ * split 沿能保住最小尺寸的轴进行。
68
+ * opts.cwd → split-window -c;opts.env → split-window -e(tmux 3.2+)。
69
+ * Returns the new pane id (e.g. `%12`).
70
+ */
71
+ export function createSurface(
72
+ name: string,
73
+ opts?: { cwd?: string; env?: Record<string, string> },
74
+ ): string {
75
+ void name; // tmux panes are not named; the pi process inside shows its own title.
76
+
77
+ const parentId = process.env.TMUX_PANE;
78
+ if (!parentId) throw new Error("TMUX_PANE is missing; cannot place a subagent pane.");
79
+
80
+ const before = queryWindowLayout(parentId);
81
+ const target = chooseSplitTarget(before, parentId, managedPaneIds);
82
+ if (!target) {
83
+ throw new Error("Cannot inspect the current tmux window layout for a subagent pane.");
84
+ }
85
+
86
+ const direction = chooseSplitDirection(target.rect, MIN_PANE_COLS, MIN_PANE_ROWS);
87
+ if (!direction) {
88
+ throw new Error(
89
+ `Terminal too small for another subagent pane (current pane is ${target.rect.width}x${target.rect.height}, ` +
90
+ `need >= ${MIN_PANE_COLS * 2}x${MIN_PANE_ROWS * 2} for another split).`,
91
+ );
92
+ }
93
+ const paneId = splitPane(target.pane_id, direction, opts);
94
+ managedPaneIds.add(paneId);
95
+
96
+ const after = queryWindowLayout(parentId);
97
+ const relevantIds = new Set([parentId, ...managedPaneIds]);
98
+ const invalidPane = after?.find(
99
+ (pane) => relevantIds.has(pane.pane_id) && !rectMeetsBudget(pane.rect),
100
+ );
101
+ if (!after || invalidPane) {
102
+ managedPaneIds.delete(paneId);
103
+ try { execFileSync("tmux", ["kill-pane", "-t", paneId], { encoding: "utf8" }); } catch {}
104
+ const rect = invalidPane?.rect;
105
+ throw new Error(
106
+ `Terminal too small for another subagent pane (got ${rect?.width ?? "?"}x${rect?.height ?? "?"}, ` +
107
+ `need >= ${MIN_PANE_COLS}x${MIN_PANE_ROWS}). Reduce subagents or enlarge the window.`,
108
+ );
109
+ }
110
+ return paneId;
111
+ }
112
+
113
+ function splitPane(
114
+ target: string,
115
+ direction: SplitDirection,
116
+ opts?: { cwd?: string; env?: Record<string, string> },
117
+ ): string {
118
+ const args = ["split-window", "-d", direction === "right" ? "-h" : "-v", "-t", target, "-p", "50"];
119
+ if (opts?.cwd) {
120
+ args.push("-c", opts.cwd);
121
+ }
122
+ for (const [key, value] of Object.entries(opts?.env ?? {})) {
123
+ args.push("-e", `${key}=${value}`);
124
+ }
125
+ args.push("-P", "-F", "#{pane_id}");
126
+
127
+ const pane = execFileSync("tmux", args, { encoding: "utf8" }).trim();
128
+ if (!pane.startsWith("%")) {
129
+ throw new Error(`Unexpected tmux split-window output: ${pane}`);
130
+ }
131
+ return pane;
132
+ }
133
+
134
+ /** 查询当前 window 的 pane 布局。 */
135
+ function queryWindowLayout(paneId: string): LayoutPane[] | null {
136
+ try {
137
+ const output = execFileSync(
138
+ "tmux",
139
+ ["list-panes", "-t", paneId, "-F", "#{pane_id} #{pane_width} #{pane_height}"],
140
+ { encoding: "utf8" },
141
+ );
142
+ return output.split(/\r?\n/).flatMap((line) => {
143
+ const [id, widthText, heightText] = line.trim().split(/\s+/);
144
+ const width = Number(widthText);
145
+ const height = Number(heightText);
146
+ return id && Number.isFinite(width) && Number.isFinite(height)
147
+ ? [{ pane_id: id, rect: { width, height } }]
148
+ : [];
149
+ });
150
+ } catch {
151
+ return null;
152
+ }
153
+ }
154
+
155
+ /**
156
+ * Send a command string to a pane and execute it.
157
+ * Typed literally (`-l`) so special characters are not interpreted as keys,
158
+ * then submitted with Enter.
159
+ */
160
+ export function sendCommand(surface: string, command: string): void {
161
+ execFileSync("tmux", ["send-keys", "-t", surface, "-l", command], { encoding: "utf8" });
162
+ execFileSync("tmux", ["send-keys", "-t", surface, "Enter"], { encoding: "utf8" });
163
+ }
164
+
165
+ /** Read the screen contents of a pane (sync). */
166
+ export function readScreen(surface: string, lines = 50): string {
167
+ return execFileSync(
168
+ "tmux",
169
+ ["capture-pane", "-p", "-t", surface, "-S", `-${Math.max(1, lines)}`],
170
+ {
171
+ encoding: "utf8",
172
+ },
173
+ );
174
+ }
175
+
176
+ /** Read the screen contents of a pane (async). */
177
+ export async function readScreenAsync(surface: string, lines = 50): Promise<string> {
178
+ const { stdout } = await execFileAsync(
179
+ "tmux",
180
+ ["capture-pane", "-p", "-t", surface, "-S", `-${Math.max(1, lines)}`],
181
+ { encoding: "utf8" },
182
+ );
183
+ return stdout;
184
+ }
185
+
186
+ /** 重载恢复后,把已有 pane 纳入当前 window 的布局目标集合。 */
187
+ export function trackSurface(surface: string): void {
188
+ managedPaneIds.add(surface);
189
+ }
190
+
191
+ /** 查询 pane 是否仍然存在,用于 /reload 恢复时丢弃 stale 记录。 */
192
+ export function surfaceExists(surface: string): boolean {
193
+ try {
194
+ execFileSync("tmux", ["display-message", "-p", "-t", surface, "#{pane_id}"], { stdio: "ignore" });
195
+ return true;
196
+ } catch {
197
+ return false;
198
+ }
199
+ }
200
+
201
+ /** Close a pane. tmux 自动把空间归还相邻 pane,无需重排。 */
202
+ export function closeSurface(surface: string): void {
203
+ execFileSync("tmux", ["kill-pane", "-t", surface], { encoding: "utf8" });
204
+ managedPaneIds.delete(surface);
205
+ }
206
+
207
+ /** 给 pane 设置可识别标签(best-effort,失败不致命)。 */
208
+ export function labelSurface(surface: string, label: string): void {
209
+ execFileSync("tmux", ["select-pane", "-t", surface, "-T", label], { encoding: "utf8" });
210
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Safe bash extension for the worker subagent.
3
+ * Wraps the built-in bash tool with dangerous command blocking.
4
+ *
5
+ * Loaded into a child pi process via `--extension` when an agent's `tools`
6
+ * frontmatter lists `safe_bash`. See CUSTOM_TOOL_EXTENSIONS in ../index.ts.
7
+ */
8
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
9
+ import { createBashTool } from "@earendil-works/pi-coding-agent";
10
+ import { Type } from "@sinclair/typebox";
11
+
12
+ const DANGEROUS_PATTERNS = [
13
+ /\brm\s+(-[a-zA-Z]*f[a-zA-Z]*\s+)?(-[a-zA-Z]*r[a-zA-Z]*\s+)?(\/|~\/?\s|~\/?\b)/,
14
+ /\brm\s+(-[a-zA-Z]*r[a-zA-Z]*\s+)?(-[a-zA-Z]*f[a-zA-Z]*\s+)?(\/|~\/?\s|~\/?\b)/,
15
+ /\bsudo\b/,
16
+ /\bmkfs\b/,
17
+ /\bdd\s+if=/,
18
+ /:\(\)\s*\{\s*:\|:&\s*\}\s*;:/,
19
+ />\s*\/dev\/[sh]d[a-z]/,
20
+ /\bchmod\s+(-[a-zA-Z]+\s+)?777\s+\//,
21
+ /\bchown\s+(-[a-zA-Z]+\s+)?root/,
22
+ /\bcurl\s.*\|\s*(ba)?sh/,
23
+ /\bwget\s.*\|\s*(ba)?sh/,
24
+ /\bshutdown\b/,
25
+ /\breboot\b/,
26
+ /\binit\s+0\b/,
27
+ /\bkill\s+-9\s+1\b/,
28
+ /\bkillall\b/,
29
+ // Long-form flags and shell indirection are common ways to evade a short
30
+ // option-only check. This remains a heuristic, not a security boundary.
31
+ /\brm\b(?=[^;\n]*(?:--recursive|--force|--no-preserve-root))(?=[^;\n]*(?:^|\s)(?:\/(?:\s|$)|~(?:\/|\s|$)))[^;\n]*/i,
32
+ /\b(?:bash|sh|zsh)\s+(?:-[a-z]*c|--command)\b/i,
33
+ /\b(?:python|python3|node|perl|ruby)\s+-[ce]\b/i,
34
+ /\|\s*(?:ba)?sh\b/i,
35
+ /\bxargs\b/i,
36
+ ];
37
+
38
+ function isDangerous(command: string): string | null {
39
+ const normalized = command.replace(/\\\n/g, " ");
40
+ for (const pattern of DANGEROUS_PATTERNS) {
41
+ if (pattern.test(normalized)) {
42
+ return `Command blocked by safe_bash: matches dangerous pattern ${pattern}`;
43
+ }
44
+ }
45
+ return null;
46
+ }
47
+
48
+ export const __test__ = { isDangerous };
49
+
50
+ export default function (pi: ExtensionAPI) {
51
+ const bashTool = createBashTool(process.cwd());
52
+
53
+ pi.registerTool({
54
+ name: "safe_bash",
55
+ label: "Safe Bash",
56
+ description:
57
+ "Execute a bash command with heuristic blocking for dangerous commands. This is not a security sandbox.",
58
+ parameters: Type.Object({
59
+ command: Type.String({ description: "Bash command to execute" }),
60
+ timeout: Type.Optional(
61
+ Type.Number({ description: "Timeout in seconds (optional)" }),
62
+ ),
63
+ }),
64
+ async execute(toolCallId, params, signal, onUpdate, ctx) {
65
+ const danger = isDangerous(params.command);
66
+ if (danger) {
67
+ throw new Error(danger);
68
+ }
69
+ return bashTool.execute(toolCallId, params, signal, onUpdate);
70
+ },
71
+ });
72
+ }
@@ -0,0 +1,131 @@
1
+ import type { SubagentActivityState } from "./activity.ts";
2
+ import type { SubagentStatusState } from "./status.ts";
3
+ import type { SessionStats } from "./session.ts";
4
+ import type { HeadlessChild } from "./headless.ts";
5
+
6
+ export interface SubagentResult {
7
+ name: string;
8
+ task: string;
9
+ /** 仅用于展示和结果聚合的并行分组标签,不改变生命周期语义。 */
10
+ cohortId?: string;
11
+ summary: string;
12
+ sessionFile?: string;
13
+ sessionId?: string;
14
+ exitCode: number;
15
+ elapsed: number;
16
+ errorMessage?: string;
17
+ stats?: SessionStats;
18
+ /**
19
+ * pane 被外部关闭且无 .exit sidecar(典型:用户直接关掉演示 pane)。
20
+ * 这是稳定的非错误终态分类:不算 provider/agent error(不生成
21
+ * route_exception),依赖注册表按 cancelled settle。
22
+ */
23
+ userClosed?: boolean;
24
+ /**
25
+ * 显式停止(subagent_stop)兑出的取消终态:与意外失败区分,迟到回注
26
+ * 与依赖 settle 都按 cancelled 语义呈现,不报 failed。
27
+ */
28
+ stopped?: boolean;
29
+ /**
30
+ * /reload(module replaced)移交标记:宿主重载后旧模块的 watcher 已把该
31
+ * 子代理移交给运行态记录恢复逻辑(recoverRuntimeSubagents),本结果不是
32
+ * 终态——真实结果由恢复路径在子代理结束后回注。调用方不得把它当作
33
+ * cancelled/failed 处理或再次回注,只能返回明确的 handed-off 说明。
34
+ * 宿主会话关闭(/new、/resume 切走)的 detach 也复用该语义:进程/pane
35
+ * 与 runtime record 全保留,由本会话的恢复路径接管。
36
+ */
37
+ handedOff?: boolean;
38
+ }
39
+
40
+ export type SubagentWaitMode = "hard-barrier" | "interactive" | "member-round" | "recovered";
41
+ export type SubagentWaitRelease = "escape" | "timeout";
42
+
43
+ export interface RunningSubagent {
44
+ id: string;
45
+ name: string;
46
+ task: string;
47
+ /** 仅用于展示和结果聚合的并行分组标签。 */
48
+ cohortId?: string;
49
+ agent?: string;
50
+ /**
51
+ * 解析后的模型 id(含 ":thinking" 后缀;来自显式 params、tier 解析或 agent
52
+ * frontmatter)。route_exception 的 provider/model 拆分来源;旧记录/mock 可缺省。
53
+ */
54
+ model?: string | null;
55
+ /** 直接父子代理 run id;顶层子代理为 null。 */
56
+ parentId?: string | null;
57
+ /** 本次硬屏障等待上限;未配置时缺省。 */
58
+ timeoutMs?: number;
59
+ /** 当前运行的等待载体与恢复状态。 */
60
+ waitMode?: SubagentWaitMode;
61
+ /** 主工具等待已被 Escape 或 timeout 解除,但子代理仍在运行。 */
62
+ waitReleased?: SubagentWaitRelease;
63
+ surface: string;
64
+ startTime: number;
65
+ sessionFile: string;
66
+ activityFile?: string;
67
+ activity?: SubagentActivityState;
68
+ activityRead?: {
69
+ ok: boolean;
70
+ reason?: "missing" | "invalid" | "wrong-id";
71
+ error?: string;
72
+ };
73
+ abortController?: AbortController;
74
+ statusState: SubagentStatusState;
75
+ interactive: boolean;
76
+ sentinelToken: string;
77
+ runtimeFile: string;
78
+ /**
79
+ * 运行载体:"pane"(herdr/tmux,默认——兼容未带此字段的旧测试 mock 与旧记录)
80
+ * 或 "headless"(独立 pi --mode rpc 后台进程)。
81
+ */
82
+ kind?: "pane" | "headless";
83
+ /** headless 运行的子进程 PID(仅 kind="headless")。 */
84
+ pid?: number;
85
+ /** headless 运行的子进程句柄(仅 kind="headless",运行中存在)。 */
86
+ headlessChild?: HeadlessChild;
87
+ /** 本次 spawn 是否成功写入父侧锚定 loadout;用于 resume 信任链。 */
88
+ anchoredLoadout?: boolean;
89
+ /**
90
+ * 轮次信号在会话文件暂时不可读时的内存保留副本,避免 atomic claim
91
+ * 后因解析竞态静默丢失结果。
92
+ */
93
+ pendingRoundSignal?: { name: string; seq: number; at: number };
94
+ /** 轮次关联连续读失败次数,达到上限后转为明确失败终态。 */
95
+ roundAssociationAttempts?: number;
96
+ /**
97
+ * 本次 spawn 写入父会话 artifactDir/context/ 的任务与系统提示文件
98
+ * (retention 清理用;resume 重启的新 spawn 会记录自己的新文件)。
99
+ */
100
+ contextFiles?: string[];
101
+ /**
102
+ * /reload 恢复后丢失 stdin 时的降级标记:无法再向该子代理 steer/投递
103
+ * ask 回复,只能等进程退出后从 session 文件提取结果。显式降级,不伪称完整恢复。
104
+ */
105
+ stdinLost?: boolean;
106
+ /**
107
+ * 持久团队成员(member: true):headless 常驻进程,不走硬屏障;每轮
108
+ * 经 team_dispatch 派单,.round sidecar 标记轮次结束。强制 headless +
109
+ * preserve + 无 timeoutMs/dependsOn(工具层校验)。
110
+ */
111
+ member?: boolean;
112
+ /** 当前是否有在途轮次(team_dispatch 置位,.round 消费后清除)。 */
113
+ dispatchedRound?: boolean;
114
+ /** 在途轮次的关联 nonce(spawn 首轮与 team_dispatch 各生成一个;与
115
+ * .round 认领时的会话 marker 检索配合,区分派单轮与自发轮)。 */
116
+ dispatchedRoundId?: string;
117
+ /** 在途轮次开始时的会话行数(轮次结果提取基线)。 */
118
+ roundEntryBaseline?: number;
119
+ /** 父会话 team roster 文件路径(成员状态转换的单一真源,父进程独写)。 */
120
+ rosterFile?: string;
121
+ /**
122
+ * watcher 的终态 promise(subagent_stop 等待停止落定用)。由启动 watcher
123
+ * 的工具路径(spawn/resume)写入;resolve 单次,promise 可被多消费。
124
+ */
125
+ watchPromise?: Promise<SubagentResult>;
126
+ /**
127
+ * 显式停止(subagent_stop)标记:置位后 watcher 把 pane 消失等观察到的
128
+ * 中断一律按显式 cancelled 终态处理,不再误分类为 user_closed。
129
+ */
130
+ stopRequested?: boolean;
131
+ }