@gavin-hu/my-pi-agent 0.14.0 → 0.15.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 (222) hide show
  1. package/README.md +55 -33
  2. package/README.zh-CN.md +44 -27
  3. package/extensions/bytefly/README.md +193 -140
  4. package/extensions/bytefly/block/component.ts +7 -15
  5. package/extensions/bytefly/block/register.ts +6 -1
  6. package/extensions/bytefly/doctor/register.ts +24 -5
  7. package/extensions/bytefly/doctor/tools.ts +4 -0
  8. package/extensions/bytefly/footer/collect/snapshot.ts +6 -1
  9. package/extensions/bytefly/footer/config.ts +10 -6
  10. package/extensions/bytefly/footer/price/price.ts +5 -3
  11. package/extensions/bytefly/footer/price/rates.ts +63 -26
  12. package/extensions/bytefly/footer/render/status.ts +3 -3
  13. package/extensions/bytefly/header/component.ts +3 -2
  14. package/extensions/bytefly/index.ts +3 -3
  15. package/extensions/bytefly/notice/component.ts +8 -3
  16. package/extensions/bytefly/notify/config.ts +6 -11
  17. package/extensions/bytefly/notify/dispatch.ts +13 -5
  18. package/extensions/bytefly/panel/commands.ts +21 -1
  19. package/extensions/bytefly/panel/format.ts +5 -2
  20. package/extensions/bytefly/panel/settings-runtime.ts +26 -6
  21. package/extensions/bytefly/panel/settings.ts +41 -4
  22. package/extensions/bytefly/panel/themes.ts +32 -0
  23. package/extensions/bytefly/panel/tui/editing.ts +77 -0
  24. package/extensions/bytefly/panel/tui/entries.ts +24 -0
  25. package/extensions/bytefly/panel/tui/extensions-view.ts +13 -8
  26. package/extensions/bytefly/panel/tui/frame.ts +121 -4
  27. package/extensions/bytefly/panel/tui/settings-view.ts +54 -15
  28. package/extensions/bytefly/panel/tui.ts +94 -152
  29. package/extensions/bytefly/picker/discovery.ts +1 -0
  30. package/extensions/bytefly/picker/types.ts +2 -0
  31. package/extensions/bytefly/separator/component.ts +14 -4
  32. package/extensions/bytefly/update/cache.ts +58 -19
  33. package/extensions/bytefly/update/check.ts +35 -0
  34. package/extensions/bytefly/update/format.ts +76 -35
  35. package/extensions/bytefly/update/register.ts +144 -55
  36. package/extensions/bytefly/update/state.ts +4 -2
  37. package/extensions/bytefly/update/suppress.ts +66 -0
  38. package/extensions/bytefly/update/types.ts +15 -0
  39. package/extensions/check/README.md +253 -0
  40. package/extensions/check/commands.ts +128 -0
  41. package/extensions/check/config.ts +39 -0
  42. package/extensions/check/detect.ts +269 -0
  43. package/extensions/check/failures.ts +230 -0
  44. package/extensions/check/format.ts +135 -0
  45. package/extensions/check/index.ts +26 -0
  46. package/extensions/check/render.ts +185 -0
  47. package/extensions/check/runner.ts +176 -0
  48. package/extensions/check/runtime.ts +244 -0
  49. package/extensions/check/source.ts +80 -0
  50. package/extensions/check/tools.ts +173 -0
  51. package/extensions/check/tui.ts +195 -0
  52. package/extensions/check/types.ts +99 -0
  53. package/extensions/document/README.md +1 -1
  54. package/extensions/document/extract/pdf.ts +8 -16
  55. package/extensions/document/formats.ts +2 -2
  56. package/extensions/file-browse/README.md +66 -32
  57. package/extensions/file-browse/client.ts +95 -32
  58. package/extensions/file-browse/config.ts +9 -9
  59. package/extensions/file-browse/git.ts +37 -47
  60. package/extensions/file-browse/html.ts +39 -58
  61. package/extensions/file-browse/markdown.ts +110 -92
  62. package/extensions/file-browse/raw-html.ts +478 -0
  63. package/extensions/file-browse/router.ts +32 -70
  64. package/extensions/file-browse/server.ts +18 -56
  65. package/extensions/file-browse/urls.ts +80 -0
  66. package/extensions/goal/README.md +103 -47
  67. package/extensions/goal/clarify-parse.ts +4 -9
  68. package/extensions/goal/clarify.ts +27 -4
  69. package/extensions/goal/commands.ts +24 -11
  70. package/extensions/goal/format.ts +41 -10
  71. package/extensions/goal/index.ts +39 -16
  72. package/extensions/goal/plan-route.ts +51 -0
  73. package/extensions/goal/schema.ts +6 -23
  74. package/extensions/goal/state.ts +39 -57
  75. package/extensions/goal/tools.ts +26 -20
  76. package/extensions/goal/types.ts +11 -1
  77. package/extensions/job/README.md +20 -6
  78. package/extensions/job/format.ts +18 -68
  79. package/extensions/job/index.ts +26 -41
  80. package/extensions/job/log-writer.ts +208 -0
  81. package/extensions/job/logs.ts +3 -2
  82. package/extensions/job/process.ts +6 -99
  83. package/extensions/job/runtime.ts +42 -181
  84. package/extensions/job/schema.ts +1 -4
  85. package/extensions/job/tools.ts +1 -15
  86. package/extensions/job/tui.ts +117 -307
  87. package/extensions/job/ui.ts +7 -33
  88. package/extensions/memory/README.md +1 -1
  89. package/extensions/memory/errors.ts +5 -6
  90. package/extensions/memory/tui.ts +14 -24
  91. package/extensions/plan/README.md +94 -18
  92. package/extensions/plan/commands.ts +9 -5
  93. package/extensions/plan/format.ts +87 -0
  94. package/extensions/plan/index.ts +31 -15
  95. package/extensions/plan/plans.ts +2 -3
  96. package/extensions/plan/policy.ts +0 -6
  97. package/extensions/plan/runtime.ts +4 -2
  98. package/extensions/plan/steps.ts +37 -8
  99. package/extensions/plan/todos.ts +44 -3
  100. package/extensions/plan/tools/enter.ts +94 -0
  101. package/extensions/plan/tools/exit.ts +296 -0
  102. package/extensions/plan/tools/nested.ts +21 -0
  103. package/extensions/plan/tools/steps.ts +136 -0
  104. package/extensions/plan/tools/todo-seed.ts +76 -0
  105. package/extensions/plan/tools/write.ts +127 -0
  106. package/extensions/plan/tools.ts +18 -703
  107. package/extensions/plan/web-client.ts +6 -22
  108. package/extensions/plan/web-page.ts +19 -30
  109. package/extensions/plan/web-router.ts +34 -71
  110. package/extensions/plan/web.ts +13 -40
  111. package/extensions/question/answers.ts +3 -8
  112. package/extensions/question/tui.ts +6 -6
  113. package/extensions/rewind/README.md +95 -22
  114. package/extensions/rewind/checklist.ts +185 -0
  115. package/extensions/rewind/commands.ts +8 -0
  116. package/extensions/rewind/config.ts +3 -11
  117. package/extensions/rewind/format.ts +220 -2
  118. package/extensions/rewind/git.ts +74 -7
  119. package/extensions/rewind/hunk-picker.ts +114 -0
  120. package/extensions/rewind/patch.ts +88 -0
  121. package/extensions/rewind/path-picker.ts +67 -100
  122. package/extensions/rewind/paths.ts +93 -19
  123. package/extensions/rewind/restore.ts +53 -10
  124. package/extensions/rewind/review-tui.ts +320 -0
  125. package/extensions/rewind/review.ts +397 -0
  126. package/extensions/rewind/rewind-tui.ts +4 -3
  127. package/extensions/rewind/runtime.ts +94 -17
  128. package/extensions/subagent/README.md +61 -6
  129. package/extensions/subagent/background-tui.ts +208 -0
  130. package/extensions/subagent/background.ts +470 -0
  131. package/extensions/subagent/commands.ts +80 -0
  132. package/extensions/subagent/config.ts +4 -0
  133. package/extensions/subagent/format.ts +24 -1
  134. package/extensions/subagent/index.ts +91 -2
  135. package/extensions/subagent/render.ts +13 -0
  136. package/extensions/subagent/run.ts +17 -16
  137. package/extensions/subagent/schema.ts +12 -0
  138. package/extensions/subagent/types.ts +11 -0
  139. package/extensions/subagent/ui.ts +50 -0
  140. package/extensions/todo/README.md +18 -13
  141. package/extensions/todo/format.ts +3 -2
  142. package/extensions/todo/runtime.ts +3 -2
  143. package/extensions/todo/schema.ts +8 -84
  144. package/extensions/todo/state.ts +5 -46
  145. package/extensions/todo/tools.ts +2 -1
  146. package/extensions/todo/types.ts +6 -15
  147. package/extensions/watch/README.md +14 -6
  148. package/extensions/watch/child.ts +150 -0
  149. package/extensions/watch/format.ts +8 -48
  150. package/extensions/watch/index.ts +42 -58
  151. package/extensions/watch/runtime.ts +49 -128
  152. package/extensions/watch/schema.ts +1 -4
  153. package/extensions/watch/tools.ts +1 -11
  154. package/extensions/watch/tui.ts +103 -273
  155. package/extensions/watch/ui.ts +5 -32
  156. package/extensions/web-access/README.md +1 -1
  157. package/extensions/web-access/fetch/page.ts +1 -1
  158. package/extensions/web-access/fetch/tool.ts +6 -2
  159. package/extensions/web-access/search/search.ts +2 -5
  160. package/extensions/wechat/README.md +1 -1
  161. package/extensions/wechat/client.ts +23 -0
  162. package/extensions/wechat/commands.ts +57 -42
  163. package/extensions/wechat/deliver.ts +5 -9
  164. package/extensions/wechat/errors.ts +3 -6
  165. package/extensions/wechat/inbound.ts +2 -5
  166. package/extensions/wechat/index.ts +2 -8
  167. package/extensions/wechat/outbound.ts +2 -4
  168. package/extensions/wechat/peer-worker.ts +3 -8
  169. package/extensions/wechat/poll.ts +10 -9
  170. package/extensions/wechat/presence.ts +35 -62
  171. package/extensions/wechat/reply-pipeline.ts +3 -1
  172. package/extensions/wechat/status-screen.ts +1 -1
  173. package/extensions/wechat/timer.ts +57 -0
  174. package/extensions/worktree/config.ts +8 -20
  175. package/extensions/worktree/enter.ts +7 -12
  176. package/extensions/worktree/git.ts +38 -69
  177. package/extensions/worktree/index.ts +5 -4
  178. package/extensions/worktree/registry.ts +7 -7
  179. package/extensions/worktree/root-tools.ts +2 -2
  180. package/extensions/worktree/status.ts +3 -2
  181. package/lib/README.md +22 -8
  182. package/lib/config.ts +50 -3
  183. package/lib/errors.ts +12 -0
  184. package/lib/format.ts +50 -0
  185. package/lib/git/read.ts +8 -0
  186. package/lib/goal-state.ts +23 -8
  187. package/lib/http/headers.ts +54 -0
  188. package/lib/http/index.ts +13 -0
  189. package/lib/http/response.ts +46 -0
  190. package/lib/http/router.ts +52 -0
  191. package/lib/http/server.ts +91 -0
  192. package/lib/lines.ts +77 -0
  193. package/lib/pdf.ts +82 -0
  194. package/lib/plan-entry.ts +71 -0
  195. package/lib/plan-state.ts +34 -0
  196. package/lib/report.ts +99 -0
  197. package/lib/requirements.ts +25 -19
  198. package/lib/spawn.ts +170 -0
  199. package/lib/todo-state.ts +110 -23
  200. package/lib/tool-names.ts +7 -0
  201. package/lib/tool-result.ts +33 -0
  202. package/lib/tui/README.md +68 -3
  203. package/lib/tui/chrome.ts +28 -4
  204. package/lib/tui/columns.ts +6 -1
  205. package/lib/tui/confirm.ts +65 -0
  206. package/lib/tui/dock-screen.ts +410 -0
  207. package/lib/tui/glyphs.ts +2 -1
  208. package/lib/tui/indent.ts +1 -1
  209. package/lib/tui/index.ts +2 -0
  210. package/lib/tui/rows.ts +21 -0
  211. package/lib/tui/status-controller.ts +35 -1
  212. package/lib/tui/tool.ts +59 -1
  213. package/lib/web/components.ts +20 -1
  214. package/lib/web/markup.ts +32 -0
  215. package/lib/web/runtime.ts +50 -0
  216. package/package.json +22 -5
  217. package/skills/init/SKILL.md +165 -0
  218. package/skills/init/references/agents-md-quality.md +103 -0
  219. package/skills/init/references/interview-questions.md +81 -0
  220. package/skills/refactor/SKILL.md +141 -0
  221. package/extensions/watch/process.ts +0 -63
  222. package/extensions/web-access/fetch/pdf.ts +0 -45
@@ -7,51 +7,19 @@
7
7
  *
8
8
  * Raw log output is untrusted: it can contain ANSI escapes, carriage-return
9
9
  * progress rewrites, and control characters. Every boundary that surfaces log
10
- * text goes through `sanitizeLogLine`/`sanitizeLogText`, which strip those so a
11
- * background process cannot restyle or corrupt the terminal. The raw log file
12
- * is never modified.
10
+ * text goes through `sanitizeUntrustedLine`/`sanitizeUntrustedText` from
11
+ * `lib/format.ts`, so a background process cannot restyle or corrupt the
12
+ * terminal. The raw log file is never modified.
13
13
  */
14
14
 
15
15
  import type { Theme } from "@earendil-works/pi-coding-agent";
16
- import { formatDuration } from "../../lib/format.ts";
17
- import { clip, LABEL_WIDTH, labelLine } from "../../lib/tui/columns.ts";
16
+ import { boundedTail, formatDuration, sanitizeUntrustedLine } from "../../lib/format.ts";
17
+ import { clip, clipLabel, LABEL_WIDTH } from "../../lib/tui/columns.ts";
18
18
  import { GLYPHS, SEPARATORS } from "../../lib/tui/glyphs.ts";
19
+ import { byRunningFirst } from "../../lib/tui/rows.ts";
19
20
  import { paint, type TuiRole } from "../../lib/tui/tokens.ts";
20
21
  import type { JobRecord, JobStatus } from "./types.ts";
21
22
 
22
- /** Longest sanitized log line kept for display; longer lines are clipped. */
23
- const MAX_LOG_LINE = 200;
24
-
25
- /** ESC-introduced sequences: OSC, CSI, and single-character escapes. */
26
- const OSC = /\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)/g;
27
- const CSI = /\u001b\[[0-?]*[ -/]*[@-~]/g;
28
- const OTHER_ESC = /\u001b[@-Z\\-_]/g;
29
- const CONTROL = /[\u0000-\u001f\u007f-\u009f]/g;
30
-
31
- /** Remove ANSI/OSC escape sequences and control characters from one line. */
32
- function stripEscapes(raw: string): string {
33
- return raw.replace(OSC, "").replace(CSI, "").replace(OTHER_ESC, "");
34
- }
35
-
36
- /**
37
- * Make one line of untrusted log output safe to render.
38
- *
39
- * Resolves carriage returns the way a terminal would (keep the text after the
40
- * last `\r`, so progress rewrites collapse to their final state), strips
41
- * escapes and control characters, collapses whitespace, and clips the result.
42
- */
43
- export function sanitizeLogLine(raw: string): string {
44
- const withoutAnsi = stripEscapes(raw);
45
- const afterCr = withoutAnsi.slice(withoutAnsi.lastIndexOf("\r") + 1);
46
- const cleaned = afterCr.replace(CONTROL, " ").replace(/\s+/g, " ").trim();
47
- return cleaned.length > MAX_LOG_LINE ? `${cleaned.slice(0, MAX_LOG_LINE - 1)}…` : cleaned;
48
- }
49
-
50
- /** Sanitize a block of log text line by line. */
51
- export function sanitizeLogText(raw: string): string[] {
52
- return raw.split("\n").map(sanitizeLogLine);
53
- }
54
-
55
23
  /** The last `count` entries of `lines`. */
56
24
  export function tailLines(lines: string[], count: number): string[] {
57
25
  if (count <= 0) return [];
@@ -95,22 +63,12 @@ export function statusGlyphChar(status: JobStatus): string {
95
63
  }
96
64
  }
97
65
 
98
- /** Width-1 themed glyph for a status. */
99
- export function statusGlyph(status: JobStatus, theme: Theme): string {
100
- return paint(theme, statusRole(status), statusGlyphChar(status));
101
- }
102
-
103
66
  /** Short status word for text output. */
104
67
  function statusWord(job: JobRecord): string {
105
68
  if (job.status === "failed" && job.exitCode !== null) return `failed (exit ${job.exitCode})`;
106
69
  return job.status;
107
70
  }
108
71
 
109
- /** A label clipped to one safe display line. */
110
- export function clipLabel(text: string): string {
111
- return labelLine(text, sanitizeLogLine);
112
- }
113
-
114
72
  /** A job's label (falling back to its command) clipped to one safe display line. */
115
73
  export function shortLabel(job: JobRecord): string {
116
74
  return clipLabel(job.label || job.command);
@@ -138,12 +96,12 @@ export function formatJobList(jobs: JobRecord[], now = Date.now(), ownSessionId?
138
96
  export function formatJobStatus(job: JobRecord, now = Date.now(), ownSessionId?: string): string {
139
97
  const lines = [
140
98
  `${job.id}: ${statusWord(job)}`,
141
- `command: ${sanitizeLogLine(job.command)}`,
142
- `cwd: ${sanitizeLogLine(job.cwd)}`,
99
+ `command: ${sanitizeUntrustedLine(job.command)}`,
100
+ `cwd: ${sanitizeUntrustedLine(job.cwd)}`,
143
101
  `elapsed: ${formatDuration(elapsedMs(job, now))}`,
144
102
  ];
145
103
  if (job.pid) lines.push(`pid: ${job.pid}`);
146
- if (job.logPath) lines.push(`log: ${sanitizeLogLine(job.logPath)}`);
104
+ if (job.logPath) lines.push(`log: ${sanitizeUntrustedLine(job.logPath)}`);
147
105
  if (ownSessionId !== undefined && job.sessionId !== ownSessionId) lines.push("session: another session");
148
106
  if (job.lastLine) lines.push(`last: ${job.lastLine}`);
149
107
  return lines.join("\n");
@@ -154,12 +112,7 @@ export function formatLogs(job: JobRecord, lines: string[], maxChars: number): {
154
112
  if (lines.length === 0) {
155
113
  return { text: `${job.id}: no output yet${job.logPath ? ` (log: ${job.logPath})` : ""}.`, truncated: false };
156
114
  }
157
- let body = lines.join("\n");
158
- let truncated = false;
159
- if (body.length > maxChars) {
160
- body = body.slice(body.length - maxChars);
161
- truncated = true;
162
- }
115
+ const { text: body, truncated } = boundedTail(lines, maxChars);
163
116
  const suffix = truncated ? `\n… truncated; full log: ${job.logPath}` : "";
164
117
  return { text: `${job.id} (${statusWord(job)}) output:\n${body}${suffix}`, truncated };
165
118
  }
@@ -172,10 +125,10 @@ export function formatCompletion(job: JobRecord, now = Date.now()): string {
172
125
 
173
126
  /** Transcript call text for the `job` tool. */
174
127
  export function formatCallText(action: string, args: Record<string, unknown>, argsComplete = true): string {
175
- const target = typeof args.id === "string" && args.id ? sanitizeLogLine(args.id) : undefined;
128
+ const target = typeof args.id === "string" && args.id ? sanitizeUntrustedLine(args.id) : undefined;
176
129
  switch (action) {
177
130
  case "start": {
178
- const command = typeof args.command === "string" ? sanitizeLogLine(args.command.trim()) : "";
131
+ const command = typeof args.command === "string" ? sanitizeUntrustedLine(args.command.trim()) : "";
179
132
  if (!command) return argsComplete ? "start" : "start …";
180
133
  const preview = clip(command, LABEL_WIDTH);
181
134
  return `start → ${preview}`;
@@ -184,7 +137,7 @@ export function formatCallText(action: string, args: Record<string, unknown>, ar
184
137
  return `kill ${target ?? ""}`.trim();
185
138
  case "send": {
186
139
  if (!target) return "send";
187
- const input = typeof args.input === "string" ? sanitizeLogLine(args.input) : "";
140
+ const input = typeof args.input === "string" ? sanitizeUntrustedLine(args.input) : "";
188
141
  const preview = input ? clip(input, LABEL_WIDTH) : "";
189
142
  const parts = [preview, args.eof === true ? "EOF" : ""].filter(Boolean).join(" + ");
190
143
  return parts ? `send ${target} → ${parts}` : `send ${target}`;
@@ -201,10 +154,7 @@ export function formatCallText(action: string, args: Record<string, unknown>, ar
201
154
  }
202
155
 
203
156
  /** Order jobs for display: running first, then by most recent start. */
204
- export function compareJobs(a: JobRecord, b: JobRecord): number {
205
- const rank = (job: JobRecord): number => (job.status === "running" ? 0 : 1);
206
- return rank(a) - rank(b) || b.startedAt - a.startedAt;
207
- }
157
+ export const compareJobs = byRunningFirst<JobRecord>((job) => job.status === "running");
208
158
 
209
159
  export interface JobCounts {
210
160
  total: number;
@@ -252,7 +202,7 @@ export function formatJobOutcomeLine(job: JobRecord, theme: Theme, now = Date.no
252
202
  const outcome =
253
203
  job.status === "failed" ? paint(theme, "bad", jobOutcome(job)) : paint(theme, "faint", jobOutcome(job));
254
204
  return (
255
- statusGlyph(job.status, theme) +
205
+ paint(theme, statusRole(job.status), statusGlyphChar(job.status)) +
256
206
  " " +
257
207
  paint(theme, "accent", job.id) +
258
208
  " " +
@@ -267,11 +217,11 @@ export function formatJobOutcomeLine(job: JobRecord, theme: Theme, now = Date.no
267
217
  /** Unstyled, sanitized detail lines for the `/jobs` focus pane. */
268
218
  export function formatJobDetail(job: JobRecord, now = Date.now(), ownSessionId?: string): string[] {
269
219
  const lines = [
270
- sanitizeLogLine(`command: ${job.command}`),
271
- sanitizeLogLine(`cwd: ${job.cwd} · ${jobOutcome(job)} · ${formatDuration(elapsedMs(job, now))}`),
220
+ sanitizeUntrustedLine(`command: ${job.command}`),
221
+ sanitizeUntrustedLine(`cwd: ${job.cwd} · ${jobOutcome(job)} · ${formatDuration(elapsedMs(job, now))}`),
272
222
  ];
273
223
  if (ownSessionId !== undefined && job.sessionId !== ownSessionId) lines.push("owner: another session");
274
- const last = sanitizeLogLine(job.lastLine);
224
+ const last = sanitizeUntrustedLine(job.lastLine);
275
225
  if (last) lines.push(`last: ${last}`);
276
226
  return lines;
277
227
  }
@@ -13,13 +13,13 @@
13
13
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
14
14
  import { isExtensionEnabled } from "../../lib/env.ts";
15
15
  import { JOB_FINISHED_CHANNEL, type JobFinishedEvent } from "../../lib/events.ts";
16
- import { noticeMessage, type NoticeMessage, pruneStaleNotices } from "../../lib/notice.ts";
17
- import { STATUS_KEYS } from "../../lib/tui/glyphs.ts";
16
+ import { noticeMessage, type NoticeMessage } from "../../lib/notice.ts";
17
+ import { createPendingReporter } from "../../lib/report.ts";
18
18
  import { registerCommands } from "./commands.ts";
19
19
  import { formatCompletion, jobOutcome, shortLabel } from "./format.ts";
20
20
  import { createJobsRuntime, type JobsRuntime } from "./runtime.ts";
21
21
  import { registerTools } from "./tools.ts";
22
- import type { Job, JobRecord } from "./types.ts";
22
+ import type { JobRecord } from "./types.ts";
23
23
 
24
24
  /** Marker embedded in the injected completion context. */
25
25
  const JOB_CONTEXT_MARKER = "[BACKGROUND JOBS]";
@@ -65,33 +65,27 @@ export interface JobsDeps {
65
65
  export default function jobs(pi: ExtensionAPI, deps: JobsDeps = {}): void {
66
66
  if (!isExtensionEnabled("job")) return;
67
67
  const runtime = deps.runtime ?? createJobsRuntime();
68
- let busy = false;
68
+
69
+ // Opt-in wake: a `wake` job finished while the agent is idle. The reporter
70
+ // reports *all* pending completions, not just the waking one, so a job that
71
+ // finished earlier is not silently marked seen and dropped.
72
+ const reporter = createPendingReporter<JobRecord>({
73
+ wake: (notice) => pi.sendMessage(notice, { triggerTurn: true }),
74
+ pending: () => runtime.pendingCompletions(),
75
+ markReported: (records) => runtime.markReported(records.map((job) => job.id)),
76
+ buildNotice: jobNotice,
77
+ source: "job",
78
+ });
69
79
 
70
80
  registerTools(pi, runtime);
71
81
  registerCommands(pi, runtime);
72
82
 
73
- // Opt-in wake: a `wake` job finished while the agent is idle. Report *all*
74
- // pending completions, not just the waking one, so a non-wake job that
75
- // finished earlier is not silently marked seen and dropped.
76
- const handleFinish = (_job: Job): void => {
77
- if (busy) return;
78
- const pending = runtime.pendingCompletions();
79
- if (pending.length === 0) return;
80
- try {
81
- pi.sendMessage(jobNotice(pending), { triggerTurn: true });
82
- // Mark only once the send succeeded; a throw leaves them for the next turn.
83
- runtime.markReported(pending.map((job) => job.id));
84
- } catch {
85
- // Waking is best-effort; the next turn still reports the completion.
86
- }
87
- };
88
-
89
83
  pi.on("session_start", (_event, ctx) => {
90
- busy = false;
84
+ reporter.sessionStart();
91
85
  runtime.load(ctx);
92
- // `shutdown` clears this; a reload or session replacement reuses the
93
- // runtime, so re-arm it on every session start.
94
- runtime.onFinish = handleFinish;
86
+ // `shutdown` clears these; a reload or session replacement reuses the
87
+ // runtime, so re-arm them on every session start.
88
+ runtime.onFinish = () => reporter.report();
95
89
  // Notify-listening extensions follow completions on the shared bus; the
96
90
  // runtime only knows the callback, never the channel.
97
91
  runtime.onComplete = (job) => {
@@ -109,37 +103,28 @@ export default function jobs(pi: ExtensionAPI, deps: JobsDeps = {}): void {
109
103
  });
110
104
 
111
105
  pi.on("agent_start", () => {
112
- busy = true;
106
+ reporter.agentStart();
113
107
  });
114
108
  pi.on("agent_settled", () => {
115
- busy = false;
109
+ reporter.agentSettled();
116
110
  });
117
111
 
118
112
  // Report completions to the model at the start of the next turn.
119
113
  pi.on("before_agent_start", (_event, ctx) => {
120
- const pending = runtime.pendingCompletions();
121
- if (pending.length === 0) return undefined;
122
- runtime.markReported(pending.map((job) => job.id));
114
+ const reported = reporter.beforeAgentStart();
115
+ if (!reported) return undefined;
123
116
  runtime.setStatus(ctx);
124
- return { message: jobNotice(pending) };
117
+ return reported;
125
118
  });
126
119
 
127
120
  // Keep stale completion notices out of later turns. Only this extension's
128
121
  // notices are pruned; another source's are left for its own handler.
129
- pi.on("context", (event) => {
130
- const pruned = pruneStaleNotices(event.messages, "job");
131
- return pruned ? { messages: pruned } : undefined;
132
- });
122
+ pi.on("context", (event) => reporter.filterContext(event.messages));
133
123
 
134
- pi.on("session_shutdown", async (_event, ctx) => {
124
+ // `shutdown` detaches the UI controller, which clears both chips.
125
+ pi.on("session_shutdown", async () => {
135
126
  runtime.onFinish = undefined;
136
127
  runtime.onComplete = undefined;
137
128
  await runtime.shutdown();
138
- try {
139
- ctx.ui.setStatus(STATUS_KEYS.jobs, undefined);
140
- ctx.ui.setStatus(STATUS_KEYS.jobsFailure, undefined);
141
- } catch {
142
- // UI may already be gone.
143
- }
144
129
  });
145
130
  }
@@ -0,0 +1,208 @@
1
+ /**
2
+ * The per-job log sink: a write stream, the line splitter that feeds the job's
3
+ * `lastLine`, and rotation when the segment reaches its cap.
4
+ *
5
+ * Extracted from the job runtime so the process/table lifecycle and the log
6
+ * plumbing can be read separately. The writer owns no job state: every path and
7
+ * limit is a callback over the runtime's live config, and a buffered line is
8
+ * handed back through `onLine`.
9
+ */
10
+
11
+ import { createWriteStream, existsSync, renameSync, unlinkSync, type WriteStream } from "node:fs";
12
+ import { LineSplitter } from "../../lib/lines.ts";
13
+ import type { Job, JobRecord } from "./types.ts";
14
+
15
+ /** Bytes held while a rotation swaps the log stream before writes resume. */
16
+ const MAX_ROTATION_QUEUE_BYTES = 2 * 1024 * 1024;
17
+
18
+ /** One job's log state: the live segment, the queue, and the counters. */
19
+ export interface LogHandle {
20
+ stream?: WriteStream;
21
+ lines: LineSplitter;
22
+ /** Chunks that arrived while the log stream was being swapped. */
23
+ queue: Buffer[];
24
+ /** Bytes currently in `queue`. */
25
+ queuedBytes: number;
26
+ /** True between ending the old stream and reopening the log. */
27
+ rotating: boolean;
28
+ /** Bytes dropped because a rotation could not keep up; reported by `logs`. */
29
+ droppedBytes: number;
30
+ /** Bytes written to the live segment, counted exactly as chunks arrive. */
31
+ logBytes: number;
32
+ /** True once the handle is disposed; a rotation in flight never reopens. */
33
+ disposed: boolean;
34
+ }
35
+
36
+ export interface LogWriterOptions {
37
+ /** The clock the repaint window is measured on. */
38
+ now(): number;
39
+ /** The live segment for a job, or undefined when it cannot be used. */
40
+ logPath(job: JobRecord): string | undefined;
41
+ /** The rotated segment for a job, or undefined when it cannot be used. */
42
+ rotatedPath(job: JobRecord): string | undefined;
43
+ /** Bytes a segment may reach before it rotates; 0 disables rotation. */
44
+ maxBytes(): number;
45
+ /** The repaint window for output-driven painting. */
46
+ repaintMs(): number;
47
+ /** Record one output line; the runtime sanitizes it onto the job. */
48
+ onLine(job: Job, line: string): void;
49
+ /** Repaint, at most once per repaint window while output arrives. */
50
+ onPaint(): void;
51
+ }
52
+
53
+ export interface LogWriter {
54
+ /** A handle for one job's log; `logPath` is undefined for a detached job. */
55
+ create(logPath: string | undefined): LogHandle;
56
+ /** Append child output, splitting lines and rotating at the cap. */
57
+ write(handle: LogHandle, job: Job, chunk: Buffer): void;
58
+ /** The buffered final lines, including a final line without a newline. */
59
+ flush(handle: LogHandle): string[];
60
+ /** Close the stream and mark the handle dead; idempotent. */
61
+ dispose(handle: LogHandle, options: { endStream: boolean }): void;
62
+ }
63
+
64
+ export function createLogWriter(options: LogWriterOptions): LogWriter {
65
+ /** Last output-driven repaint, so a busy child cannot repaint per chunk. */
66
+ let lastPaint = 0;
67
+
68
+ const writeLog = (handle: LogHandle, chunk: Buffer): void => {
69
+ try {
70
+ handle.stream?.write(chunk);
71
+ } catch {
72
+ // A closed stream must not break output handling.
73
+ }
74
+ // Count what we handed the stream: `WriteStream.bytesWritten` only updates on
75
+ // its completion callback, which can lag a tick behind the file on disk.
76
+ if (handle.stream) handle.logBytes += chunk.length;
77
+ };
78
+
79
+ /** Buffer a chunk while the log stream is being swapped, dropping the oldest past the cap. */
80
+ const enqueue = (handle: LogHandle, chunk: Buffer, front = false): void => {
81
+ if (front) handle.queue.unshift(chunk);
82
+ else handle.queue.push(chunk);
83
+ handle.queuedBytes += chunk.length;
84
+ while (handle.queuedBytes > MAX_ROTATION_QUEUE_BYTES && handle.queue.length > 1) {
85
+ const dropped = handle.queue.shift();
86
+ if (!dropped) break;
87
+ handle.queuedBytes -= dropped.length;
88
+ handle.droppedBytes += dropped.length;
89
+ }
90
+ };
91
+
92
+ /**
93
+ * Move the live segment aside, queueing `chunk` ahead of the backlog, and reopen.
94
+ *
95
+ * Returns false when no rotation can start (no stream, or a record whose log
96
+ * paths do not validate), so the caller writes the chunk instead of stranding it
97
+ * in a queue nothing will drain.
98
+ *
99
+ * The old stream is ended and its `close` awaited before the rename, because
100
+ * Windows refuses to rename an open file; chunks that arrive in that window
101
+ * are queued rather than written, so nothing lands in the renamed inode.
102
+ */
103
+ const rotateLog = (handle: LogHandle, job: Job, chunk: Buffer): boolean => {
104
+ const stream = handle.stream;
105
+ const logPath = options.logPath(job);
106
+ const rotated = options.rotatedPath(job);
107
+ if (!stream || !logPath || !rotated || handle.rotating) return false;
108
+ // This chunk precedes whatever queued while the previous segment was closing.
109
+ enqueue(handle, chunk, true);
110
+ handle.rotating = true;
111
+ stream.once("close", () => {
112
+ // A killed or cleared job disposes its handle; never reopen for it.
113
+ if (handle.disposed) return;
114
+ try {
115
+ if (existsSync(rotated)) unlinkSync(rotated);
116
+ renameSync(logPath, rotated);
117
+ } catch {
118
+ // A failed rename leaves the oversized segment in place; keep appending.
119
+ }
120
+ const reopened = createWriteStream(logPath, { flags: "a" });
121
+ reopened.on("error", () => {});
122
+ handle.stream = reopened;
123
+ handle.rotating = false;
124
+ handle.logBytes = 0;
125
+ drain(handle, job);
126
+ });
127
+ try {
128
+ stream.end();
129
+ } catch {
130
+ // Already ended.
131
+ }
132
+ return true;
133
+ };
134
+
135
+ /** Write one output chunk, rotating the segment first when it reached the cap. */
136
+ const writeChunk = (handle: LogHandle, job: Job, chunk: Buffer): void => {
137
+ if (handle.rotating) {
138
+ // Later than everything already queued.
139
+ enqueue(handle, chunk);
140
+ return;
141
+ }
142
+ const cap = options.maxBytes();
143
+ if (handle.stream && cap > 0 && handle.logBytes >= cap && rotateLog(handle, job, chunk)) return;
144
+ writeLog(handle, chunk);
145
+ };
146
+
147
+ /**
148
+ * Replay the queue through `writeChunk` after a rotation, so a backlog that is
149
+ * itself larger than the cap rotates again instead of filling one segment.
150
+ */
151
+ const drain = (handle: LogHandle, job: Job): void => {
152
+ while (!handle.rotating && handle.queue.length > 0) {
153
+ const chunk = handle.queue.shift();
154
+ if (!chunk) break;
155
+ handle.queuedBytes -= chunk.length;
156
+ writeChunk(handle, job, chunk);
157
+ }
158
+ };
159
+
160
+ return {
161
+ create(logPath) {
162
+ const stream = logPath === undefined ? undefined : createWriteStream(logPath, { flags: "a" });
163
+ stream?.on("error", () => {});
164
+ return {
165
+ stream,
166
+ lines: new LineSplitter(),
167
+ queue: [],
168
+ queuedBytes: 0,
169
+ rotating: false,
170
+ droppedBytes: 0,
171
+ logBytes: 0,
172
+ disposed: false,
173
+ };
174
+ },
175
+
176
+ write(handle, job, chunk) {
177
+ if (handle.disposed) return;
178
+ writeChunk(handle, job, chunk);
179
+ for (const line of handle.lines.push(chunk)) options.onLine(job, line);
180
+ const time = options.now();
181
+ if (time - lastPaint >= options.repaintMs()) {
182
+ lastPaint = time;
183
+ options.onPaint();
184
+ }
185
+ },
186
+
187
+ flush(handle) {
188
+ return handle.lines.flush();
189
+ },
190
+
191
+ dispose(handle, { endStream }) {
192
+ if (handle.disposed) return;
193
+ handle.disposed = true;
194
+ // A rotation in flight checks `disposed` before reopening, so dropping the
195
+ // queue here cannot leak a stream for a dead job.
196
+ handle.queue = [];
197
+ handle.queuedBytes = 0;
198
+ if (endStream) {
199
+ try {
200
+ handle.stream?.end();
201
+ } catch {
202
+ // Already closed.
203
+ }
204
+ }
205
+ handle.stream = undefined;
206
+ },
207
+ };
208
+ }
@@ -7,7 +7,8 @@
7
7
  */
8
8
 
9
9
  import { closeSync, existsSync, openSync, readSync, statSync } from "node:fs";
10
- import { sanitizeLogText, tailLines } from "./format.ts";
10
+ import { sanitizeUntrustedText } from "../../lib/format.ts";
11
+ import { tailLines } from "./format.ts";
11
12
 
12
13
  /** Bytes of log read for a `logs` call. */
13
14
  const LOG_READ_BYTES = 64 * 1024;
@@ -50,7 +51,7 @@ export function readLogTail(path: string | undefined, lines: number): LogTail {
50
51
  } catch {
51
52
  // A missing/unreadable log yields an empty tail.
52
53
  }
53
- const all = dropTrailingBlank(sanitizeLogText(text));
54
+ const all = dropTrailingBlank(sanitizeUntrustedText(text));
54
55
  const more = start > 0 || all.length > lines;
55
56
  return { lines: tailLines(all, lines), truncated, more };
56
57
  }
@@ -1,64 +1,17 @@
1
1
  /**
2
- * Process plumbing for the jobs extension.
2
+ * Process plumbing that only `job` needs.
3
3
  *
4
- * Background jobs must outlive a single tool call, so they are spawned with
5
- * Node's `child_process` rather than `pi.exec` (which resolves only when the
6
- * command finishes). Every function here is injectable, so tests can drive a
7
- * scripted child without launching a real process.
8
- *
9
- * On POSIX the child is started detached, making it a process-group leader;
10
- * signals go to the whole group (`-pid`) so a shell and its descendants die
11
- * together. On Windows the tree is killed with `taskkill /T`.
4
+ * The shared spawn, tree-kill, and liveness seams live in `lib/spawn.ts`, so
5
+ * what stays here is the job-specific remainder: the pid liveness and start-time
6
+ * seams the runtime injects, and the `ps` token that guards against pid reuse
7
+ * when a reattached job is settled.
12
8
  */
13
9
 
14
- import { execFileSync, spawn as nodeSpawn, spawnSync } from "node:child_process";
15
- import type { Readable } from "node:stream";
16
-
17
- /**
18
- * The writable end of a child's stdin pipe.
19
- *
20
- * Only the operations `send` needs, so tests can hand the runtime a recording
21
- * double instead of a real socket.
22
- */
23
- export interface StdinSink {
24
- write(chunk: string): boolean;
25
- end(): void;
26
- destroy?(): void;
27
- on(event: "error" | "close", listener: (error?: Error) => void): unknown;
28
- }
29
-
30
- /** The subset of a spawned child the runtime uses. */
31
- export interface SpawnedProcess {
32
- pid?: number;
33
- stdout: Readable | null;
34
- stderr: Readable | null;
35
- /** Present only when spawned with `stdin: true`. */
36
- stdin?: StdinSink | null;
37
- on(event: "close", listener: (code: number | null, signal: NodeJS.Signals | null) => void): unknown;
38
- on(event: "error", listener: (error: Error) => void): unknown;
39
- kill(signal?: NodeJS.Signals): boolean;
40
- /** Detach from the parent's event loop, so a background job cannot hold Pi open. */
41
- unref?(): void;
42
- }
43
-
44
- export type SpawnFn = (
45
- command: string,
46
- options: {
47
- cwd: string;
48
- env?: NodeJS.ProcessEnv;
49
- /** The command writes its own output to the log; keep no parent pipe. */
50
- detachOutput?: boolean;
51
- /** Give the child a stdin pipe instead of `/dev/null` (no immediate EOF). */
52
- stdin?: boolean;
53
- },
54
- ) => SpawnedProcess;
10
+ import { execFileSync } from "node:child_process";
55
11
 
56
12
  /** Whether a pid is still alive. */
57
13
  export type LivenessFn = (pid: number) => boolean;
58
14
 
59
- /** Send `signal` to a pid's process tree, escalating to SIGKILL when `force`. */
60
- export type KillTreeFn = (pid: number, signal: NodeJS.Signals, force?: boolean) => void;
61
-
62
15
  /**
63
16
  * Best-effort OS start-time token for a pid, used to detect pid reuse.
64
17
  * Returns undefined when the platform cannot provide one (for example Windows,
@@ -67,32 +20,6 @@ export type KillTreeFn = (pid: number, signal: NodeJS.Signals, force?: boolean)
67
20
  */
68
21
  export type StartTokenFn = (pid: number) => string | undefined;
69
22
 
70
- /** Default spawn: a shell command in its own process group. A detached job
71
- * redirects its own output to the log, so it needs no pipe to this process.
72
- * Stdin stays closed unless the job asked for it: a closed stdin gives the
73
- * child EOF, so `read` exits instead of blocking forever. */
74
- export const defaultSpawn: SpawnFn = (command, options) =>
75
- nodeSpawn(command, {
76
- cwd: options.cwd,
77
- shell: true,
78
- detached: process.platform !== "win32",
79
- stdio: [
80
- options.stdin ? "pipe" : "ignore",
81
- ...(options.detachOutput ? (["ignore", "ignore"] as const) : (["pipe", "pipe"] as const)),
82
- ],
83
- env: options.env,
84
- });
85
-
86
- /** Default liveness check. EPERM means the pid exists but is owned by someone else. */
87
- export const defaultLiveness: LivenessFn = (pid) => {
88
- try {
89
- process.kill(pid, 0);
90
- return true;
91
- } catch (error) {
92
- return (error as NodeJS.ErrnoException).code === "EPERM";
93
- }
94
- };
95
-
96
23
  /** Default start-time token via `ps`. Undefined where `ps` is unavailable. */
97
24
  export const defaultStartToken: StartTokenFn = (pid) => {
98
25
  try {
@@ -106,23 +33,3 @@ export const defaultStartToken: StartTokenFn = (pid) => {
106
33
  return undefined;
107
34
  }
108
35
  };
109
-
110
- /** Default tree kill: process group on POSIX, `taskkill /T` on Windows. */
111
- export const defaultKillTree: KillTreeFn = (pid, signal, force = false) => {
112
- if (process.platform === "win32") {
113
- spawnSync("taskkill", ["/pid", String(pid), "/T", ...(force ? ["/F"] : [])], { stdio: "ignore" });
114
- return;
115
- }
116
- try {
117
- process.kill(-pid, signal);
118
- } catch (error) {
119
- if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error;
120
- // Not a group leader (for example a reattached pid); signal it directly.
121
- // Callers verify the start token first, so this cannot hit a reused pid.
122
- try {
123
- process.kill(pid, signal);
124
- } catch {
125
- // Already gone.
126
- }
127
- }
128
- };