@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
@@ -1,10 +1,11 @@
1
1
  /**
2
2
  * Job runtime: the session-scoped job table and process lifecycle.
3
3
  *
4
- * Owns the in-memory `Job` map, live child handles, log streams, callbacks, and
5
- * the repaint clock, and composes the smaller pieces: `store.ts` (durable
6
- * registry), `logs.ts` (log tails), `status.ts` (exit-status recovery),
7
- * `ui.ts` (status chips), and `waiters.ts`.
4
+ * Owns the in-memory `Job` map, live child handles, callbacks, and the repaint
5
+ * clock, and composes the smaller pieces: `store.ts` (durable registry),
6
+ * `logs.ts` (log tails), `log-writer.ts` (the per-job stream, line split, and
7
+ * rotation), `status.ts` (exit-status recovery), `ui.ts` (status chips), and
8
+ * `waiters.ts`.
8
9
  *
9
10
  * Registry model: each session is the sole writer of its own
10
11
  * `registry-<hash>.json`. `load` merges every session's file and adopts the
@@ -18,32 +19,25 @@
18
19
  * twice and listeners/waiters are removed on cleanup.
19
20
  */
20
21
 
21
- import {
22
- createWriteStream,
23
- existsSync,
24
- mkdirSync,
25
- renameSync,
26
- unlinkSync,
27
- writeFileSync,
28
- type WriteStream,
29
- } from "node:fs";
22
+ import { existsSync, mkdirSync, unlinkSync, writeFileSync } from "node:fs";
30
23
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
31
24
  import { resolveEffectiveCwd } from "../../lib/env.ts";
32
- import { loadConfig, type JobsConfig } from "./config.ts";
33
- import { clipLabel, compareJobs, formatLogs, sanitizeLogLine } from "./format.ts";
34
- import { readLogTail } from "./logs.ts";
35
25
  import {
36
26
  defaultKillTree,
37
- defaultLiveness,
38
27
  defaultSpawn,
39
- defaultStartToken,
28
+ isPidAlive,
40
29
  type KillTreeFn,
41
- type LivenessFn,
42
- type SpawnFn,
43
30
  type SpawnedProcess,
44
- type StartTokenFn,
31
+ type SpawnFn,
45
32
  type StdinSink,
46
- } from "./process.ts";
33
+ } from "../../lib/spawn.ts";
34
+ import { loadConfig, type JobsConfig } from "./config.ts";
35
+ import { createLogWriter, type LogHandle } from "./log-writer.ts";
36
+ import { sanitizeUntrustedLine } from "../../lib/format.ts";
37
+ import { clipLabel } from "../../lib/tui/columns.ts";
38
+ import { compareJobs, formatLogs } from "./format.ts";
39
+ import { readLogTail } from "./logs.ts";
40
+ import { defaultStartToken, type LivenessFn, type StartTokenFn } from "./process.ts";
47
41
  import {
48
42
  loadRegistries,
49
43
  mergeRecords,
@@ -68,10 +62,6 @@ import { createWaiters } from "./waiters.ts";
68
62
 
69
63
  /** Characters of sanitized log sent to the model. */
70
64
  const LOG_MODEL_CHARS = 16 * 1024;
71
- /** Cap on the buffered partial line used to track the latest output. */
72
- const MAX_PENDING = 16 * 1024;
73
- /** Bytes held while a rotation swaps the log stream before writes resume. */
74
- const MAX_ROTATION_QUEUE_BYTES = 2 * 1024 * 1024;
75
65
  /** Default `wait` timeout. */
76
66
  const DEFAULT_WAIT_MS = 30_000;
77
67
 
@@ -134,24 +124,14 @@ export interface RuntimeOptions {
134
124
  }
135
125
 
136
126
  interface Handle {
137
- stream?: WriteStream;
138
- pending: string;
127
+ /** The job's log: stream, line split, rotation, and the repaint window. */
128
+ log: LogHandle;
139
129
  killTimer?: ReturnType<typeof setTimeout>;
140
130
  timeoutTimer?: ReturnType<typeof setTimeout>;
141
131
  /** The child's stdin pipe, present only for a job started with `stdin: true`. */
142
132
  stdin?: StdinSink | null;
143
133
  /** True once stdin was ended, or the child closed its end. */
144
134
  stdinClosed?: boolean;
145
- /** Chunks that arrived while the log stream was being swapped. */
146
- queue: Buffer[];
147
- /** Bytes currently in `queue`. */
148
- queuedBytes: number;
149
- /** True between ending the old stream and reopening the log. */
150
- rotating: boolean;
151
- /** Bytes dropped because a rotation could not keep up; reported by `logs`. */
152
- droppedBytes: number;
153
- /** Bytes written to the live segment, counted exactly as chunks arrive. */
154
- logBytes: number;
155
135
  }
156
136
 
157
137
  export interface JobsRuntime {
@@ -200,7 +180,7 @@ export interface JobsRuntime {
200
180
 
201
181
  export function createJobsRuntime(options: RuntimeOptions = {}): JobsRuntime {
202
182
  const spawn = options.spawn ?? defaultSpawn;
203
- const liveness = options.liveness ?? defaultLiveness;
183
+ const liveness = options.liveness ?? isPidAlive;
204
184
  const killTree = options.killTree ?? defaultKillTree;
205
185
  const startToken = options.startToken ?? defaultStartToken;
206
186
  const now = options.now ?? (() => Date.now());
@@ -214,7 +194,6 @@ export function createJobsRuntime(options: RuntimeOptions = {}): JobsRuntime {
214
194
  let sessionId = "session";
215
195
  let disposed = false;
216
196
  let clock: ReturnType<typeof setInterval> | undefined;
217
- let lastPaint = 0;
218
197
 
219
198
  /** Jobs this session owns (a dead session's adopted records included). */
220
199
  const ownJobs = (): Job[] => [...jobs.values()].filter((job) => job.sessionId === sessionId);
@@ -409,27 +388,30 @@ export function createJobsRuntime(options: RuntimeOptions = {}): JobsRuntime {
409
388
  }
410
389
  };
411
390
 
412
- const consume = (handle: Handle, job: Job): void => {
413
- const text = handle.pending;
414
- const lastBreak = Math.max(text.lastIndexOf("\n"), text.lastIndexOf("\r"));
415
- if (lastBreak === -1) return;
416
- handle.pending = text.slice(lastBreak + 1);
417
- for (const line of text.slice(0, lastBreak).split(/[\r\n]+/)) {
418
- const clean = sanitizeLogLine(line);
419
- if (clean) job.lastLine = clean;
420
- }
391
+ /** Remember the newest non-empty output line, which `status`/`wait` report. */
392
+ const recordLine = (job: Job, line: string): void => {
393
+ const clean = sanitizeUntrustedLine(line);
394
+ if (clean) job.lastLine = clean;
421
395
  };
422
396
 
423
- /** Clear a handle's timers, optionally close its log stream, and drop it. */
397
+ /** The per-job log sink: stream, line split, rotation, and the repaint window. */
398
+ const logWriter = createLogWriter({
399
+ now,
400
+ logPath: (job) => store.safeLogPath(job),
401
+ rotatedPath: (job) => store.safeRotatedLogPath(job),
402
+ maxBytes: () => config.maxLogBytes,
403
+ repaintMs: () => config.repaintMs,
404
+ onLine: recordLine,
405
+ onPaint: paint,
406
+ });
407
+
408
+ /** Clear a handle's timers, close its log stream, and drop it. */
424
409
  const disposeHandle = (id: string, options: { endStream: boolean }): void => {
425
410
  const handle = handles.get(id);
426
411
  if (!handle) return;
427
412
  if (handle.killTimer) clearTimeout(handle.killTimer);
428
413
  if (handle.timeoutTimer) clearTimeout(handle.timeoutTimer);
429
- // A rotation still in flight checks the handle map before reopening, so
430
- // dropping the queue here cannot leak a reopened stream for a dead job.
431
- handle.queue = [];
432
- handle.queuedBytes = 0;
414
+ logWriter.dispose(handle.log, options);
433
415
  // Release the stdin pipe so a child waiting on input sees EOF instead of a
434
416
  // half-open pipe nobody can write to.
435
417
  if (handle.stdin && !handle.stdinClosed) {
@@ -441,13 +423,6 @@ export function createJobsRuntime(options: RuntimeOptions = {}): JobsRuntime {
441
423
  }
442
424
  handle.stdin = null;
443
425
  handle.stdinClosed = true;
444
- if (options.endStream) {
445
- try {
446
- handle.stream?.end();
447
- } catch {
448
- // Already closed.
449
- }
450
- }
451
426
  handles.delete(id);
452
427
  };
453
428
 
@@ -455,107 +430,7 @@ export function createJobsRuntime(options: RuntimeOptions = {}): JobsRuntime {
455
430
  const handle = handles.get(id);
456
431
  const job = jobs.get(id);
457
432
  if (!handle || !job) return;
458
- writeChunk(handle, job, chunk);
459
- handle.pending += chunk.toString("utf8");
460
- if (handle.pending.length > MAX_PENDING) handle.pending = handle.pending.slice(-MAX_PENDING);
461
- consume(handle, job);
462
- const time = now();
463
- if (time - lastPaint >= config.repaintMs) {
464
- lastPaint = time;
465
- paint();
466
- }
467
- };
468
-
469
- const writeLog = (handle: Handle, chunk: Buffer): void => {
470
- try {
471
- handle.stream?.write(chunk);
472
- } catch {
473
- // A closed stream must not break output handling.
474
- }
475
- // Count what we handed the stream: `WriteStream.bytesWritten` only updates on
476
- // its completion callback, which can lag a tick behind the file on disk.
477
- if (handle.stream) handle.logBytes += chunk.length;
478
- };
479
-
480
- /** Buffer a chunk while the log stream is being swapped, dropping the oldest past the cap. */
481
- const enqueue = (handle: Handle, chunk: Buffer, front = false): void => {
482
- if (front) handle.queue.unshift(chunk);
483
- else handle.queue.push(chunk);
484
- handle.queuedBytes += chunk.length;
485
- while (handle.queuedBytes > MAX_ROTATION_QUEUE_BYTES && handle.queue.length > 1) {
486
- const dropped = handle.queue.shift();
487
- if (!dropped) break;
488
- handle.queuedBytes -= dropped.length;
489
- handle.droppedBytes += dropped.length;
490
- }
491
- };
492
-
493
- /**
494
- * Move the live segment aside, queueing `chunk` ahead of the backlog, and reopen.
495
- *
496
- * Returns false when no rotation can start (no stream, or a record whose log
497
- * paths do not validate), so the caller writes the chunk instead of stranding it
498
- * in a queue nothing will drain.
499
- *
500
- * The old stream is ended and its `close` awaited before the rename, because
501
- * Windows refuses to rename an open file; chunks that arrive in that window
502
- * are queued rather than written, so nothing lands in the renamed inode.
503
- */
504
- const rotateLog = (handle: Handle, job: Job, chunk: Buffer): boolean => {
505
- const stream = handle.stream;
506
- const logPath = store.safeLogPath(job);
507
- const rotated = store.safeRotatedLogPath(job);
508
- if (!stream || !logPath || !rotated || handle.rotating) return false;
509
- // This chunk precedes whatever queued while the previous segment was closing.
510
- enqueue(handle, chunk, true);
511
- handle.rotating = true;
512
- stream.once("close", () => {
513
- // A killed or cleared job drops its handle; never reopen for it.
514
- if (handles.get(job.id) !== handle) return;
515
- try {
516
- if (existsSync(rotated)) unlinkSync(rotated);
517
- renameSync(logPath, rotated);
518
- } catch {
519
- // A failed rename leaves the oversized segment in place; keep appending.
520
- }
521
- const reopened = createWriteStream(logPath, { flags: "a" });
522
- reopened.on("error", () => {});
523
- handle.stream = reopened;
524
- handle.rotating = false;
525
- handle.logBytes = 0;
526
- drain(handle, job);
527
- });
528
- try {
529
- stream.end();
530
- } catch {
531
- // Already ended.
532
- }
533
- return true;
534
- };
535
-
536
- /** Write one output chunk, rotating the segment first when it reached the cap. */
537
- const writeChunk = (handle: Handle, job: Job, chunk: Buffer): void => {
538
- if (handle.rotating) {
539
- // Later than everything already queued.
540
- enqueue(handle, chunk);
541
- return;
542
- }
543
- const cap = config.maxLogBytes;
544
- if (handle.stream && cap > 0 && handle.logBytes >= cap && rotateLog(handle, job, chunk)) return;
545
- writeLog(handle, chunk);
546
- };
547
-
548
- /**
549
- * Replay the queue through `writeChunk` after a rotation, so a backlog that is
550
- * itself larger than the cap rotates again instead of filling one segment.
551
- */
552
- const drain = (handle: Handle, job: Job): void => {
553
- while (!handle.rotating && handle.queue.length > 0) {
554
- const chunk = handle.queue.shift();
555
- if (!chunk) break;
556
- handle.queuedBytes -= chunk.length;
557
- writeChunk(handle, job, chunk);
558
- }
433
+ logWriter.write(handle.log, job, chunk);
559
434
  };
560
435
 
561
436
  const finalize = (id: string, code: number | null, signal: string | null): void => {
@@ -563,11 +438,9 @@ export function createJobsRuntime(options: RuntimeOptions = {}): JobsRuntime {
563
438
  const job = jobs.get(id);
564
439
  if (!handle || !job || job.status !== "running") return;
565
440
  // Commit any buffered output, including a final line without a newline.
566
- consume(handle, job);
567
- const tail = sanitizeLogLine(handle.pending);
568
- if (tail) job.lastLine = tail;
441
+ for (const line of logWriter.flush(handle.log)) recordLine(job, line);
569
442
  // A detached job keeps no parent stream; read its last line from the log.
570
- if (!handle.stream) {
443
+ if (!handle.log.stream) {
571
444
  const last = readLogTail(store.safeLogPath(job), 1).lines.at(-1);
572
445
  if (last) job.lastLine = last;
573
446
  }
@@ -646,7 +519,6 @@ export function createJobsRuntime(options: RuntimeOptions = {}): JobsRuntime {
646
519
  stopClock();
647
520
  for (const id of [...handles.keys()]) disposeHandle(id, { endStream: true });
648
521
  waiters.resolveAll();
649
- lastPaint = 0;
650
522
 
651
523
  // The durable registry is keyed by the session's own cwd, not the active
652
524
  // worktree root: entering or leaving a worktree mid-session must not move it
@@ -777,14 +649,9 @@ export function createJobsRuntime(options: RuntimeOptions = {}): JobsRuntime {
777
649
  } catch {
778
650
  // A missing directory was already handled by mkdirSync above.
779
651
  }
780
- let stream: WriteStream | undefined;
781
- if (!detachOutput) {
782
- stream = createWriteStream(logPath, { flags: "a" });
783
- stream.on("error", () => {});
784
- }
785
652
  const job: Job = {
786
653
  id,
787
- label: sanitizeLogLine((startOptions.label ?? "").trim()) || clipLabel(startOptions.command),
654
+ label: sanitizeUntrustedLine((startOptions.label ?? "").trim()) || clipLabel(startOptions.command),
788
655
  command: startOptions.command,
789
656
  cwd,
790
657
  pid,
@@ -806,13 +673,7 @@ export function createJobsRuntime(options: RuntimeOptions = {}): JobsRuntime {
806
673
  };
807
674
 
808
675
  const handle: Handle = {
809
- stream,
810
- pending: "",
811
- queue: [],
812
- queuedBytes: 0,
813
- rotating: false,
814
- droppedBytes: 0,
815
- logBytes: 0,
676
+ log: logWriter.create(detachOutput ? undefined : logPath),
816
677
  };
817
678
  if (wantsStdin) {
818
679
  const sink = process_.stdin ?? null;
@@ -869,7 +730,7 @@ export function createJobsRuntime(options: RuntimeOptions = {}): JobsRuntime {
869
730
  const formatted = formatLogs(job, all, LOG_MODEL_CHARS);
870
731
  // A rotation that outran its drain drops the oldest queued chunks; say so once
871
732
  // rather than letting the gap look like a program that skipped output.
872
- const dropped = handles.get(id)?.droppedBytes ?? 0;
733
+ const dropped = handles.get(id)?.log.droppedBytes ?? 0;
873
734
  const text = dropped > 0 ? `${formatted.text}\n… ${dropped} bytes dropped while the log rotated.` : formatted.text;
874
735
  return {
875
736
  job,
@@ -8,6 +8,7 @@
8
8
 
9
9
  import { StringEnum } from "@earendil-works/pi-ai";
10
10
  import { Type, type Static } from "typebox";
11
+ import { optionalString } from "../../lib/config.ts";
11
12
  import { JOB_ACTIONS, KILL_SIGNALS, type JobAction, type KillSignal } from "./types.ts";
12
13
 
13
14
  /** Longest accepted command string. */
@@ -87,10 +88,6 @@ export type JobCall =
87
88
  | { action: "wait"; id: string; timeoutMs?: number }
88
89
  | { action: "clear"; id?: string; all?: boolean };
89
90
 
90
- function optionalString(value: unknown): string | undefined {
91
- return typeof value === "string" && value.trim() ? value.trim() : undefined;
92
- }
93
-
94
91
  /** Validate and normalize a raw `job` call. Throws before any side effect. */
95
92
  export function normalizeCall(raw: unknown): JobCall {
96
93
  const args = (raw ?? {}) as JobArgs;
@@ -10,6 +10,7 @@
10
10
 
11
11
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
12
12
  import { sanitizeLine } from "../../lib/format.ts";
13
+ import { errorResult } from "../../lib/tool-result.ts";
13
14
  import { reuseText, toolDetails } from "../../lib/tui/chrome.ts";
14
15
  import {
15
16
  elapsedSuffix,
@@ -41,21 +42,6 @@ export const TOOL_NAME = "job";
41
42
  /** Collapsed transcript rows before the result is truncated. */
42
43
  const RESULT_ROWS = 8;
43
44
 
44
- function errorResult(
45
- action: JobDetails["action"],
46
- message: string,
47
- ): {
48
- content: Array<{ type: "text"; text: string }>;
49
- details: JobDetails;
50
- isError: true;
51
- } {
52
- return {
53
- content: [{ type: "text", text: `Error: ${message}` }],
54
- details: { action, error: message },
55
- isError: true,
56
- };
57
- }
58
-
59
45
  const GUIDELINES = [
60
46
  "Use `job start` for long-running or background commands (builds, test suites, servers, watchers) so the turn is not blocked.",
61
47
  "Poll with `job status`/`job logs`, or use `job wait` when you must have the result before continuing.",