@konglx/rotom 2.21.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.
Files changed (189) hide show
  1. package/README.md +417 -0
  2. package/bin/mesh-master.sh +439 -0
  3. package/bin/rotom +29 -0
  4. package/bin/rotom-link.sh +136 -0
  5. package/bin/rotom-send-with-status +57 -0
  6. package/bin/rotom-up.sh +428 -0
  7. package/dist/cli/ask.js +62 -0
  8. package/dist/cli/common.js +321 -0
  9. package/dist/cli/config.js +65 -0
  10. package/dist/cli/directory.js +17 -0
  11. package/dist/cli/executor.js +58 -0
  12. package/dist/cli/fed.js +91 -0
  13. package/dist/cli/group.js +273 -0
  14. package/dist/cli/identity.js +62 -0
  15. package/dist/cli/init.js +268 -0
  16. package/dist/cli/issue.js +202 -0
  17. package/dist/cli/join.js +170 -0
  18. package/dist/cli/link.js +47 -0
  19. package/dist/cli/master.js +51 -0
  20. package/dist/cli/memory.js +307 -0
  21. package/dist/cli/note.js +68 -0
  22. package/dist/cli/repo.js +77 -0
  23. package/dist/cli/rotom.js +277 -0
  24. package/dist/cli/routes.js +118 -0
  25. package/dist/cli/run.js +45 -0
  26. package/dist/cli/schedule.js +237 -0
  27. package/dist/cli/skill.js +173 -0
  28. package/dist/cli/team.js +106 -0
  29. package/dist/executor/claude-code-hook.cjs +80 -0
  30. package/dist/executor/cli-executor.js +8 -0
  31. package/dist/executor/executors/claude-code.js +780 -0
  32. package/dist/executor/executors/codex.js +719 -0
  33. package/dist/executor/executors/hermes-cli.js +855 -0
  34. package/dist/executor/executors/openclaw.js +467 -0
  35. package/dist/executor/executors/pi.js +514 -0
  36. package/dist/executor/index.js +269 -0
  37. package/dist/executor/jsonrpc-transport.js +125 -0
  38. package/dist/executor/process-runner.js +101 -0
  39. package/dist/executor/reasoning-status.js +83 -0
  40. package/dist/executor/repo-cache.js +502 -0
  41. package/dist/executor/session-store.js +188 -0
  42. package/dist/executor/worker-chat.js +257 -0
  43. package/dist/executor/worker-connection.js +89 -0
  44. package/dist/executor/worker-issue.js +264 -0
  45. package/dist/executor/worker.js +877 -0
  46. package/dist/link/pending-requests.js +72 -0
  47. package/dist/link/server.js +233 -0
  48. package/dist/link/visibility-store.js +58 -0
  49. package/dist/master/api/agents.js +333 -0
  50. package/dist/master/api/artifacts.js +271 -0
  51. package/dist/master/api/domains.js +64 -0
  52. package/dist/master/api/groups.js +635 -0
  53. package/dist/master/api/guidance-templates.js +147 -0
  54. package/dist/master/api/index.js +89 -0
  55. package/dist/master/api/issues-patrol.js +172 -0
  56. package/dist/master/api/issues.js +663 -0
  57. package/dist/master/api/links-patrol.js +168 -0
  58. package/dist/master/api/links.js +114 -0
  59. package/dist/master/api/memory.js +259 -0
  60. package/dist/master/api/messages.js +157 -0
  61. package/dist/master/api/notes.js +77 -0
  62. package/dist/master/api/schedule-patterns.js +133 -0
  63. package/dist/master/api/schedules.js +272 -0
  64. package/dist/master/api/sessions.js +158 -0
  65. package/dist/master/api/share.js +269 -0
  66. package/dist/master/api/skills.js +190 -0
  67. package/dist/master/api/teams.js +122 -0
  68. package/dist/master/api/uploads.js +245 -0
  69. package/dist/master/auth.js +134 -0
  70. package/dist/master/dashboard/animations/calico-dozing.apng +0 -0
  71. package/dist/master/dashboard/animations/calico-error.apng +0 -0
  72. package/dist/master/dashboard/animations/calico-happy.apng +0 -0
  73. package/dist/master/dashboard/animations/calico-notification.apng +0 -0
  74. package/dist/master/dashboard/animations/calico-sleeping.apng +0 -0
  75. package/dist/master/dashboard/animations/calico-thinking.apng +0 -0
  76. package/dist/master/dashboard/animations/calico-waking.apng +0 -0
  77. package/dist/master/dashboard/assets/ApprovalCard-C38VV6ko.css +1 -0
  78. package/dist/master/dashboard/assets/ApprovalCard-CHPh2dmE.js +17 -0
  79. package/dist/master/dashboard/assets/ArtifactPanel-P_2gAP7v.js +1 -0
  80. package/dist/master/dashboard/assets/ArtifactPanel-aGHySny5.css +1 -0
  81. package/dist/master/dashboard/assets/css.worker-DaIe3gwK.js +84 -0
  82. package/dist/master/dashboard/assets/editor.worker-BCzxt1at.js +12 -0
  83. package/dist/master/dashboard/assets/html.worker-CKrFyw_2.js +461 -0
  84. package/dist/master/dashboard/assets/index-CChrTn81.css +32 -0
  85. package/dist/master/dashboard/assets/index-Dhu4SN1z.js +181 -0
  86. package/dist/master/dashboard/assets/json.worker-B7c_PmGb.js +49 -0
  87. package/dist/master/dashboard/assets/markdown-CeN5IgdF.js +29 -0
  88. package/dist/master/dashboard/assets/monaco-core-DyX1CsEw.css +1 -0
  89. package/dist/master/dashboard/assets/monaco-core-oQiQUisy.js +833 -0
  90. package/dist/master/dashboard/assets/monaco-setup-CiOPQdmo.js +1 -0
  91. package/dist/master/dashboard/assets/react-vendor-C8IxlyCR.js +67 -0
  92. package/dist/master/dashboard/assets/ts.worker-BhkL8olL.js +51334 -0
  93. package/dist/master/dashboard/assets/useMonaco-ILb4vyPh.js +12 -0
  94. package/dist/master/dashboard/assets/vite-preload-CxJPbCTl.js +1 -0
  95. package/dist/master/dashboard/debug-auth.html +197 -0
  96. package/dist/master/dashboard/favicon.ico +0 -0
  97. package/dist/master/dashboard/index.html +20 -0
  98. package/dist/master/dashboard/rotom-avatar.png +0 -0
  99. package/dist/master/db/agent-sessions.js +60 -0
  100. package/dist/master/db/agent-visibility.js +64 -0
  101. package/dist/master/db/agents.js +119 -0
  102. package/dist/master/db/ask-bridges.js +157 -0
  103. package/dist/master/db/build-update.js +59 -0
  104. package/dist/master/db/core.js +82 -0
  105. package/dist/master/db/domains.js +80 -0
  106. package/dist/master/db/groups.js +316 -0
  107. package/dist/master/db/guidance-templates.js +58 -0
  108. package/dist/master/db/index.js +12 -0
  109. package/dist/master/db/internal.js +45 -0
  110. package/dist/master/db/issues-patrol.js +81 -0
  111. package/dist/master/db/issues.js +373 -0
  112. package/dist/master/db/links.js +221 -0
  113. package/dist/master/db/master-node.js +43 -0
  114. package/dist/master/db/memory.js +272 -0
  115. package/dist/master/db/messages.js +210 -0
  116. package/dist/master/db/notes.js +55 -0
  117. package/dist/master/db/schedule-patterns.js +56 -0
  118. package/dist/master/db/schedules.js +135 -0
  119. package/dist/master/db/skills.js +144 -0
  120. package/dist/master/db/team.js +88 -0
  121. package/dist/master/db/types.js +10 -0
  122. package/dist/master/db.js +12 -0
  123. package/dist/master/embedded.js +133 -0
  124. package/dist/master/federation/client.js +283 -0
  125. package/dist/master/federation/identity.js +133 -0
  126. package/dist/master/federation/manager.js +267 -0
  127. package/dist/master/federation/publisher.js +87 -0
  128. package/dist/master/federation/self-publisher.js +69 -0
  129. package/dist/master/federation/server.js +487 -0
  130. package/dist/master/group-paths.js +208 -0
  131. package/dist/master/offline-queue.js +38 -0
  132. package/dist/master/opc-bootstrap.js +245 -0
  133. package/dist/master/patrol-terminal.js +275 -0
  134. package/dist/master/repo-scan.js +188 -0
  135. package/dist/master/router.js +214 -0
  136. package/dist/master/scheduler-handlers.js +510 -0
  137. package/dist/master/scheduler.js +201 -0
  138. package/dist/master/server.js +203 -0
  139. package/dist/master/services/link-collector.js +82 -0
  140. package/dist/master/services/link-patrol-bootstrap.js +50 -0
  141. package/dist/master/services/memory-extract-prompt.js +34 -0
  142. package/dist/master/services/patrol-bootstrap.js +63 -0
  143. package/dist/master/share-tokens.js +56 -0
  144. package/dist/master/terminal-hub.js +300 -0
  145. package/dist/master/uploads.js +108 -0
  146. package/dist/master/util/fs.js +100 -0
  147. package/dist/master/util/paths.js +50 -0
  148. package/dist/master/util/persona.js +10 -0
  149. package/dist/master/ws-hub/connection.js +928 -0
  150. package/dist/master/ws-hub/conversation.js +290 -0
  151. package/dist/master/ws-hub/directory.js +70 -0
  152. package/dist/master/ws-hub/dispatch-enrich.js +34 -0
  153. package/dist/master/ws-hub/hub.js +136 -0
  154. package/dist/master/ws-hub/index.js +9 -0
  155. package/dist/master/ws-hub/internal.js +35 -0
  156. package/dist/master/ws-hub/routing.js +295 -0
  157. package/dist/master/ws-hub/sessions.js +130 -0
  158. package/dist/master/ws-hub.js +11 -0
  159. package/dist/shared/agent-profile.js +44 -0
  160. package/dist/shared/constants.js +55 -0
  161. package/dist/shared/dedup.js +33 -0
  162. package/dist/shared/group-context.js +62 -0
  163. package/dist/shared/json-codec.js +33 -0
  164. package/dist/shared/logger.js +136 -0
  165. package/dist/shared/mention.js +22 -0
  166. package/dist/shared/network.js +40 -0
  167. package/dist/shared/parse.js +18 -0
  168. package/dist/shared/prompt-composer.js +171 -0
  169. package/dist/shared/protocol/client-messages.js +8 -0
  170. package/dist/shared/protocol/enums.js +6 -0
  171. package/dist/shared/protocol/federation.js +62 -0
  172. package/dist/shared/protocol/guards.js +87 -0
  173. package/dist/shared/protocol/server-messages.js +8 -0
  174. package/dist/shared/protocol/types.js +8 -0
  175. package/dist/shared/protocol.js +19 -0
  176. package/dist/shared/readonly-allowlist.js +122 -0
  177. package/dist/shared/rotom-cli-prompt.js +23 -0
  178. package/dist/shared/skill-context.js +19 -0
  179. package/dist/shared/skill-md.js +43 -0
  180. package/dist/shared/slash-commands.js +50 -0
  181. package/dist/shared/time.js +80 -0
  182. package/dist/shared/title.js +46 -0
  183. package/dist/shared/url-extractor.js +99 -0
  184. package/migrations/001-schema.sql +942 -0
  185. package/package.json +68 -0
  186. package/scripts/fix-node-pty-perms.mjs +46 -0
  187. package/skill/rotom-a2a-communicate/SKILL.md +257 -0
  188. package/skill/rotom-bus-host/SKILL.md +78 -0
  189. package/skill/rotom-bus-host/scripts/poll-replies.sh +148 -0
@@ -0,0 +1,514 @@
1
+ /**
2
+ * Pi CLI Executor — port of multica/server/pkg/agent/pi.go.
3
+ *
4
+ * Spawns `pi -p --mode json --session <path> <prompt>` per turn
5
+ * (spawn-and-exit, mirrors the openclaw template structurally).
6
+ *
7
+ * - -p / --print non-interactive mode; prompt is positional
8
+ * - --mode json emit one JSON event per line on stdout, then exit
9
+ * - --session <path> file path where Pi appends event JSONL; doubles as
10
+ * our opaque session id (returned + reused on resume)
11
+ *
12
+ * Session as file path: we pre-create an empty file at the path before
13
+ * spawning (Pi refuses to start when --session points at a missing file).
14
+ * On resume we pass the same path back; Pi reads it to reconstruct history
15
+ * and appends new events. readSessionContent reads the file directly — no
16
+ * tree walk.
17
+ *
18
+ * stdin close (#2188): Pi's event loop polls stdin even in print mode. When
19
+ * run under a daemon (no interactive TTY), Pi blocks awaiting stdin events
20
+ * instead of progressing to "done". We close stdin immediately after spawn
21
+ * to deliver an explicit EOF. Without this the process hangs for the full
22
+ * wall-clock timeout.
23
+ *
24
+ * Tool-call markup sanitization: Pi's text_delta events can embed structured
25
+ * tool-call markup (`<|call:bash{...}|>`, `<|response:{...}|>`) and control
26
+ * tokens (`<|foo|>`) for providers that emit tool calls inline as text.
27
+ * Providers using the anthropic-messages API emit tool calls as separate
28
+ * toolcall_* events and text is clean, but we strip defensively so the user
29
+ * never sees raw protocol markup. Ported from multica's
30
+ * drainPiSanitizedText / stripPiToolCallMarkup.
31
+ *
32
+ * Usage: captured from turn_end.message.usage (accumulated across turns,
33
+ * keyed by model). Pi may emit multiple turn_end events within one
34
+ * execution; we sum them.
35
+ */
36
+ import { runProcess } from "../process-runner.js";
37
+ import fs from "node:fs";
38
+ import os from "node:os";
39
+ import path from "node:path";
40
+ import { buildPlanModeInstruction } from "../../shared/slash-commands.js";
41
+ import { emitStatus } from "../reasoning-status.js";
42
+ // ── Pi tool-call markup sanitization ───────────────────────────────────
43
+ // Ported from multica/server/pkg/agent/pi.go (stripPiToolCallMarkup et al.).
44
+ const PI_CONTROL_TOKEN_RE = /<\|[A-Za-z0-9_-]+>[A-Za-z0-9_-]*|<[A-Za-z0-9_-]+\|>/g;
45
+ function stripPiControlTokens(s) {
46
+ return s.replace(PI_CONTROL_TOKEN_RE, "");
47
+ }
48
+ function isPiToolNameByte(b) {
49
+ return /^[A-Za-z0-9_-]$/.test(b);
50
+ }
51
+ /** Find the next `call:` or `response:` prefix from index `from`. Returns
52
+ * `[index, prefixLen]`; index=-1 when none found. */
53
+ function nextPiToolMarkupPrefix(s, from) {
54
+ let best = -1;
55
+ let bestLen = 0;
56
+ for (const prefix of ["call:", "response:"]) {
57
+ const i = s.indexOf(prefix, from);
58
+ if (i >= 0 && (best === -1 || i < best)) {
59
+ best = i;
60
+ bestLen = prefix.length;
61
+ }
62
+ }
63
+ return [best, bestLen];
64
+ }
65
+ /** Scan from the byte after a `call:`/`response:` prefix to the matching
66
+ * closing `}`. Handles `<|"|>` quote escaping and nested braces. Returns
67
+ * `[endIndex, ok]`; ok=false when the block is unterminated. */
68
+ function scanPiToolMarkupEnd(s, i) {
69
+ const nameStart = i;
70
+ while (i < s.length && isPiToolNameByte(s[i]))
71
+ i++;
72
+ if (i === nameStart || i >= s.length || s[i] !== "{")
73
+ return [0, false];
74
+ const quoteMarker = '<|"|>';
75
+ let depth = 0;
76
+ let inQuote = false;
77
+ while (i < s.length) {
78
+ if (s.startsWith(quoteMarker, i)) {
79
+ inQuote = !inQuote;
80
+ i += quoteMarker.length;
81
+ continue;
82
+ }
83
+ if (!inQuote) {
84
+ if (s[i] === "{") {
85
+ depth++;
86
+ }
87
+ else if (s[i] === "}") {
88
+ depth--;
89
+ if (depth === 0) {
90
+ i++;
91
+ if (s.startsWith("<tool_call|>", i))
92
+ i += "<tool_call|>".length;
93
+ return [i, true];
94
+ }
95
+ }
96
+ }
97
+ i++;
98
+ }
99
+ return [0, false];
100
+ }
101
+ function stripPiStructuredToolMarkup(s) {
102
+ let out = "";
103
+ let i = 0;
104
+ while (i < s.length) {
105
+ const [start, prefixLen] = nextPiToolMarkupPrefix(s, i);
106
+ if (start === -1) {
107
+ out += s.slice(i);
108
+ break;
109
+ }
110
+ out += s.slice(i, start);
111
+ const [end, ok] = scanPiToolMarkupEnd(s, start + prefixLen);
112
+ if (!ok) {
113
+ out += s.slice(start);
114
+ break;
115
+ }
116
+ i = end;
117
+ }
118
+ return out;
119
+ }
120
+ function stripPiToolCallMarkup(s) {
121
+ return stripPiControlTokens(stripPiStructuredToolMarkup(s));
122
+ }
123
+ /** Detect a partial control-token prefix at end of buffer (`<|foo` without
124
+ * closing `|>`), so we can hold it back until we see more deltas. */
125
+ function looksLikePiControlTokenPrefix(s) {
126
+ if (s.length === 0 || s[0] !== "<" || s.length > 64)
127
+ return false;
128
+ for (let i = 1; i < s.length; i++) {
129
+ if (!/[A-Za-z0-9_|>-]/.test(s[i]))
130
+ return false;
131
+ }
132
+ return true;
133
+ }
134
+ /** How much of the tail we can safely emit now without cutting a markup
135
+ * prefix in half. Holds back any suffix that looks like the start of
136
+ * `call:` / `response:` or a partial control token. */
137
+ function safePiTextEmitLen(s) {
138
+ let hold = 0;
139
+ for (const prefix of ["call:", "response:"]) {
140
+ for (let n = 1; n < prefix.length && n <= s.length; n++) {
141
+ if (s.endsWith(prefix.slice(0, n)) && n > hold)
142
+ hold = n;
143
+ }
144
+ }
145
+ const lt = s.lastIndexOf("<");
146
+ if (lt >= 0 && looksLikePiControlTokenPrefix(s.slice(lt))) {
147
+ if (s.length - lt > hold)
148
+ hold = s.length - lt;
149
+ }
150
+ return s.length - hold;
151
+ }
152
+ /** Core drain: emit sanitized text, return `[emit, pending]`. `pending` is
153
+ * the un-emittable tail (partial markup prefix or unterminated block). */
154
+ function drainPiSanitizedText(s) {
155
+ let out = "";
156
+ let i = 0;
157
+ while (i < s.length) {
158
+ const [start, prefixLen] = nextPiToolMarkupPrefix(s, i);
159
+ if (start === -1) {
160
+ const safeLen = safePiTextEmitLen(s.slice(i));
161
+ out += s.slice(i, i + safeLen);
162
+ return [stripPiControlTokens(out), s.slice(i + safeLen)];
163
+ }
164
+ out += s.slice(i, start);
165
+ const [end, ok] = scanPiToolMarkupEnd(s, start + prefixLen);
166
+ if (!ok) {
167
+ return [stripPiControlTokens(out), s.slice(start)];
168
+ }
169
+ i = end;
170
+ }
171
+ return [stripPiControlTokens(out), ""];
172
+ }
173
+ /**
174
+ * Running text buffer that accumulates text_delta chunks and emits sanitized
175
+ * prose, holding back partial markup prefixes so we don't emit `<|call:bash`
176
+ * then realize on the next delta it was the start of a tool-call block.
177
+ */
178
+ class PiTextBuffer {
179
+ buf = "";
180
+ append(delta) {
181
+ this.buf += delta;
182
+ const [emit, pending] = drainPiSanitizedText(this.buf);
183
+ this.buf = pending;
184
+ return emit;
185
+ }
186
+ flush() {
187
+ const s = this.buf;
188
+ this.buf = "";
189
+ const [emit, pending] = drainPiSanitizedText(s);
190
+ return emit + stripPiControlTokens(pending);
191
+ }
192
+ }
193
+ // ── Executor ────────────────────────────────────────────────────────────
194
+ const ROTOM_HOME = process.env.ROTOM_HOME || path.join(os.homedir(), ".rotom");
195
+ const PI_SESSIONS_DIR = path.join(ROTOM_HOME, "pi-sessions");
196
+ function newPiSessionPath() {
197
+ const stamp = new Date().toISOString().replace(/[:.]/g, "").replace("T", "T");
198
+ const rand = Math.random().toString(36).slice(2, 8);
199
+ return path.join(PI_SESSIONS_DIR, `${stamp}-${rand}.jsonl`);
200
+ }
201
+ function ensurePiSessionFile(p) {
202
+ fs.mkdirSync(path.dirname(p), { recursive: true });
203
+ // Pi refuses to start when --session points at a missing file. Create an
204
+ // empty file if none exists; leave existing files (resumed sessions)
205
+ // untouched so Pi can append.
206
+ if (!fs.existsSync(p)) {
207
+ const f = fs.openSync(p, "w");
208
+ fs.closeSync(f);
209
+ }
210
+ }
211
+ export class PiExecutor {
212
+ async execute(prompt, workingDir, onOutput, options) {
213
+ return new Promise((resolve) => {
214
+ // Session path: reuse the cached one if its file still exists;
215
+ // otherwise mint a new one (the old session was pruned by pi or
216
+ // invalidated, so we start fresh).
217
+ let sessionPath = options?.sessionId ?? "";
218
+ if (sessionPath && !fs.existsSync(sessionPath)) {
219
+ console.log(`[pi] cached session file missing, starting fresh: ${sessionPath}`);
220
+ sessionPath = "";
221
+ }
222
+ if (!sessionPath) {
223
+ sessionPath = newPiSessionPath();
224
+ }
225
+ ensurePiSessionFile(sessionPath);
226
+ // prompt 已由 worker 用 composePrompt() 拼好,executor 不再二次包装。
227
+ const args = ["-p", "--mode", "json", "--session", sessionPath];
228
+ // /plan → pi 没有原生 plan 模式,通过 --append-system-prompt 注入开发者级
229
+ // 系统指令,引导其"先方案后落盘"。注册表见 src/shared/slash-commands.ts。
230
+ if (options?.slashCommand === "/plan") {
231
+ args.push("--append-system-prompt", buildPlanModeInstruction());
232
+ }
233
+ args.push(prompt);
234
+ const spawnEnv = { ...process.env, ...options?.env };
235
+ const timeoutMs = options?.timeoutMs;
236
+ console.log(`[pi] Spawning pi -p --mode json (cwd: ${workingDir}, session: ${sessionPath}, slash: ${options?.slashCommand ?? "(none)"}, timeoutMs=${timeoutMs ?? "none"})`);
237
+ const { proc, done: procDone } = runProcess({
238
+ bin: "pi",
239
+ args,
240
+ cwd: workingDir,
241
+ env: spawnEnv,
242
+ label: "pi",
243
+ signal: options?.signal,
244
+ timeoutMs: timeoutMs && timeoutMs > 0 ? timeoutMs + 5_000 : undefined,
245
+ });
246
+ // #2188 fix: close stdin immediately. Pi's event loop polls stdin even
247
+ // in print mode; under a daemon (no TTY) it blocks awaiting stdin
248
+ // events instead of finishing. EOF unblocks the readable side.
249
+ try {
250
+ proc.stdin?.end();
251
+ }
252
+ catch { /* already closed */ }
253
+ // ── Per-run state ──
254
+ let timedOut = false;
255
+ let killedByUser = false;
256
+ let done = false;
257
+ let fullOutput = "";
258
+ let capturedModel;
259
+ let capturedUsage;
260
+ let failed = false;
261
+ let errorMessage;
262
+ let sawTurnEnd = false;
263
+ const textBuffer = new PiTextBuffer();
264
+ if (options?.signal) {
265
+ if (options.signal.aborted)
266
+ killedByUser = true;
267
+ else
268
+ options.signal.addEventListener("abort", () => { killedByUser = true; }, { once: true });
269
+ }
270
+ function handleEvent(event) {
271
+ switch (event.type) {
272
+ case "session":
273
+ // Header line (emitted in json mode). We already know our
274
+ // session path — nothing to capture.
275
+ return;
276
+ case "agent_start":
277
+ emitStatus(onOutput, "Working");
278
+ return;
279
+ case "message_update": {
280
+ const sub = event.assistantMessageEvent;
281
+ if (!sub)
282
+ return;
283
+ switch (sub.type) {
284
+ case "text_delta": {
285
+ if (sub.delta) {
286
+ const emit = textBuffer.append(sub.delta);
287
+ if (emit) {
288
+ fullOutput += emit;
289
+ onOutput(emit);
290
+ }
291
+ }
292
+ return;
293
+ }
294
+ case "thinking_delta": {
295
+ // v1: 不把 thinking 流推给 dashboard(只在 stderr 记日志),
296
+ // 避免和正文混杂。后续可考虑用 [thinking]…[/thinking] 块。
297
+ if (sub.delta && (process.env.PI_VERBOSE || options?.env?.PI_VERBOSE)) {
298
+ console.error(`[pi:thinking] ${sub.delta}`);
299
+ }
300
+ return;
301
+ }
302
+ default:
303
+ // text_start / text_end / thinking_start / thinking_end /
304
+ // toolcall_* — 不需要,v1 只消费 text_delta 和独立的
305
+ // tool_execution_* 事件。
306
+ return;
307
+ }
308
+ }
309
+ case "tool_execution_start":
310
+ onOutput(`[tool:exec]${JSON.stringify(event.args ?? {})}[/tool:exec]\n`);
311
+ emitStatus(onOutput, toolStatusFor(event.toolName));
312
+ return;
313
+ case "tool_execution_end": {
314
+ const text = extractToolResultText(event.result);
315
+ if (text) {
316
+ const truncated = text.length > 500 ? `${text.slice(0, 500)}...` : text;
317
+ onOutput(`[tool-result:exec]${truncated}[/tool-result:exec]\n`);
318
+ }
319
+ if (event.isError) {
320
+ console.warn(`[pi] tool ${event.toolName} (${event.toolCallId}) returned isError=true`);
321
+ }
322
+ emitStatus(onOutput, "Working");
323
+ return;
324
+ }
325
+ case "turn_end": {
326
+ sawTurnEnd = true;
327
+ const msg = event.message;
328
+ if (msg && typeof msg === "object" && msg.usage) {
329
+ const u = msg.usage;
330
+ capturedUsage = {
331
+ inputTokens: (capturedUsage?.inputTokens ?? 0) + u.input,
332
+ outputTokens: (capturedUsage?.outputTokens ?? 0) + u.output,
333
+ cacheReadTokens: (capturedUsage?.cacheReadTokens ?? 0) + u.cacheRead,
334
+ cacheCreationTokens: (capturedUsage?.cacheCreationTokens ?? 0) + u.cacheWrite,
335
+ totalCostUsd: capturedUsage?.totalCostUsd,
336
+ };
337
+ if (msg.model && !capturedModel)
338
+ capturedModel = msg.model;
339
+ }
340
+ // Flush any buffered text in case the turn ended without a final
341
+ // text_delta closing the buffer.
342
+ const flushed = textBuffer.flush();
343
+ if (flushed) {
344
+ fullOutput += flushed;
345
+ onOutput(flushed);
346
+ }
347
+ emitStatus(onOutput, failed ? "Failed" : "Answered");
348
+ return;
349
+ }
350
+ case "error": {
351
+ const msg = typeof event.message === "string" ? event.message : "pi error";
352
+ console.error(`[pi] error event: ${msg}`);
353
+ onOutput(`[error] ${msg}\n`);
354
+ if (!failed) {
355
+ failed = true;
356
+ errorMessage = msg;
357
+ }
358
+ emitStatus(onOutput, "Failed");
359
+ return;
360
+ }
361
+ case "auto_retry_end": {
362
+ if (event.success === false && !failed) {
363
+ failed = true;
364
+ errorMessage = event.finalError || "pi exhausted automatic retries";
365
+ console.error(`[pi] auto_retry_end failed: ${errorMessage}`);
366
+ emitStatus(onOutput, "Failed");
367
+ }
368
+ return;
369
+ }
370
+ // agent_end / turn_start / message_start / message_end /
371
+ // tool_execution_update / compaction_* / queue_update /
372
+ // extension_ui_request — v1 不消费。usage 从 turn_end 拿(更可靠);
373
+ // 工具执行从 tool_execution_* 拿(不靠 message_end)。
374
+ default:
375
+ return;
376
+ }
377
+ }
378
+ function handleLine(line) {
379
+ const trimmed = line.trim();
380
+ if (!trimmed)
381
+ return;
382
+ if (trimmed[0] !== "{")
383
+ return; // non-JSON log noise on stdout (rare)
384
+ let parsed;
385
+ try {
386
+ parsed = JSON.parse(trimmed);
387
+ }
388
+ catch {
389
+ return;
390
+ }
391
+ handleEvent(parsed);
392
+ }
393
+ function finalize(code, reason) {
394
+ if (done)
395
+ return;
396
+ done = true;
397
+ const flushed = textBuffer.flush();
398
+ if (flushed) {
399
+ fullOutput += flushed;
400
+ onOutput(flushed);
401
+ }
402
+ const finalCode = code ?? 0;
403
+ // 进程退出但没发 turn_end(被 SIGKILL / 崩溃) → 视为失败,让 worker
404
+ // 丢缓存 sessionId 下次重开。
405
+ if (!sawTurnEnd && !killedByUser && finalCode !== 0 && !failed) {
406
+ failed = true;
407
+ errorMessage = `pi exited with code ${finalCode} without producing turn_end`;
408
+ }
409
+ if (!fullOutput && failed) {
410
+ if (timedOut) {
411
+ fullOutput = `[错误] pi 执行超时 (>${timeoutMs}ms),已强制结束`;
412
+ }
413
+ else if (reason === "error") {
414
+ fullOutput = `[错误] pi 启动失败`;
415
+ }
416
+ else {
417
+ fullOutput = `[错误] pi 返回内容为空 (exit=${finalCode})`;
418
+ }
419
+ }
420
+ console.log(`[pi] Exited code=${finalCode} reason=${reason}, output=${fullOutput.length} chars, session=${sessionPath}, turn_end=${sawTurnEnd}`);
421
+ resolve({
422
+ exitCode: failed && finalCode === 0 ? 1 : finalCode,
423
+ fullOutput,
424
+ sessionId: sessionPath,
425
+ usage: capturedUsage,
426
+ model: capturedModel,
427
+ failed: failed || undefined,
428
+ errorMessage,
429
+ });
430
+ }
431
+ let stdoutBuffer = "";
432
+ proc.stdout.on("data", (data) => {
433
+ stdoutBuffer += data.toString();
434
+ let idx;
435
+ while ((idx = stdoutBuffer.indexOf("\n")) !== -1) {
436
+ const line = stdoutBuffer.slice(0, idx);
437
+ stdoutBuffer = stdoutBuffer.slice(idx + 1);
438
+ handleLine(line);
439
+ }
440
+ });
441
+ // stderr: log-only. Pi 在 verbose 模式下会把诊断/堆栈打到 stderr,不含 JSON。
442
+ proc.stderr.on("data", (data) => {
443
+ if (process.env.PI_VERBOSE || options?.env?.PI_VERBOSE) {
444
+ console.error(`[pi:stderr] ${data.toString().trimEnd()}`);
445
+ }
446
+ });
447
+ procDone.then(({ exitCode, signal }) => {
448
+ if (signal === "SIGKILL" && !killedByUser && timeoutMs && timeoutMs > 0) {
449
+ timedOut = true;
450
+ console.warn(`[pi] Wall-clock timeout (${timeoutMs}ms + 5_000ms grace) reached, SIGKILL pid=${proc.pid}`);
451
+ }
452
+ finalize(exitCode, "close");
453
+ });
454
+ });
455
+ }
456
+ /**
457
+ * Read the tail of pi's session transcript. Since we own the session file
458
+ * path (passed to `pi --session <path>`), we read it directly — no tree
459
+ * walk. Tolerant of missing files (pi may prune, or the session was
460
+ * invalidated) — returns empty content + an explanatory `error`.
461
+ */
462
+ async readSessionContent(args) {
463
+ const file = args.sessionId;
464
+ if (!file || !fs.existsSync(file)) {
465
+ return {
466
+ format: "jsonl",
467
+ content: "",
468
+ error: "pi session 文件不存在(可能已被清理或会话失效)",
469
+ };
470
+ }
471
+ const text = fs.readFileSync(file, "utf-8");
472
+ const lines = text.split("\n");
473
+ const tail = args.tailLines ?? 200;
474
+ const sliced = lines.length > tail ? lines.slice(-tail).join("\n") : text;
475
+ return { format: "jsonl", content: sliced };
476
+ }
477
+ }
478
+ // ── Helpers ─────────────────────────────────────────────────────────────
479
+ function toolStatusFor(name) {
480
+ switch (name) {
481
+ case "edit":
482
+ case "write":
483
+ return "Patching";
484
+ case "bash":
485
+ return "Running";
486
+ case "read":
487
+ case "grep":
488
+ case "find":
489
+ case "ls":
490
+ return "Reading";
491
+ default:
492
+ return "Running";
493
+ }
494
+ }
495
+ function extractToolResultText(result) {
496
+ if (result == null)
497
+ return undefined;
498
+ if (typeof result === "string")
499
+ return result;
500
+ if (typeof result === "object") {
501
+ const r = result;
502
+ if (typeof r.text === "string")
503
+ return r.text;
504
+ if (typeof r.output === "string")
505
+ return r.output;
506
+ }
507
+ try {
508
+ const s = JSON.stringify(result);
509
+ return s && s.length > 0 ? s : undefined;
510
+ }
511
+ catch {
512
+ return undefined;
513
+ }
514
+ }