@gavin-hu/my-pi-agent 0.9.0 → 0.11.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 (140) hide show
  1. package/README.md +28 -2
  2. package/README.zh-CN.md +27 -2
  3. package/assets/bytefly.schema.json +17 -1
  4. package/assets/chrome.png +0 -0
  5. package/assets/job.png +0 -0
  6. package/assets/plan-review.png +0 -0
  7. package/assets/question.png +0 -0
  8. package/assets/rewind.png +0 -0
  9. package/assets/todo.png +0 -0
  10. package/assets/tool-renderings.png +0 -0
  11. package/assets/tools/ask_user_question.png +0 -0
  12. package/assets/tools/bash.png +0 -0
  13. package/assets/tools/edit.png +0 -0
  14. package/assets/tools/enter_plan_mode.png +0 -0
  15. package/assets/tools/enter_worktree.png +0 -0
  16. package/assets/tools/exit_plan_mode.png +0 -0
  17. package/assets/tools/exit_worktree.png +0 -0
  18. package/assets/tools/find.png +0 -0
  19. package/assets/tools/goal.png +0 -0
  20. package/assets/tools/grep.png +0 -0
  21. package/assets/tools/job.png +0 -0
  22. package/assets/tools/list_worktrees.png +0 -0
  23. package/assets/tools/ls.png +0 -0
  24. package/assets/tools/memory.png +0 -0
  25. package/assets/tools/notice.png +0 -0
  26. package/assets/tools/open_path.png +0 -0
  27. package/assets/tools/prune_worktrees.png +0 -0
  28. package/assets/tools/read.png +0 -0
  29. package/assets/tools/read_doc.png +0 -0
  30. package/assets/tools/run_doctor.png +0 -0
  31. package/assets/tools/send_wechat.png +0 -0
  32. package/assets/tools/set_plan_steps.png +0 -0
  33. package/assets/tools/subagent.png +0 -0
  34. package/assets/tools/todo.png +0 -0
  35. package/assets/tools/turn-rule.png +0 -0
  36. package/assets/tools/user-message.png +0 -0
  37. package/assets/tools/watch.png +0 -0
  38. package/assets/tools/web_fetch.png +0 -0
  39. package/assets/tools/web_search.png +0 -0
  40. package/assets/tools/write.png +0 -0
  41. package/assets/tools/write_plan.png +0 -0
  42. package/extensions/bytefly/README.md +261 -36
  43. package/extensions/bytefly/block/component.ts +282 -0
  44. package/extensions/bytefly/block/config.ts +24 -0
  45. package/extensions/bytefly/block/fallback.ts +120 -0
  46. package/extensions/bytefly/block/register.ts +164 -0
  47. package/extensions/bytefly/chrome.ts +97 -0
  48. package/extensions/bytefly/doctor/tools.ts +5 -2
  49. package/extensions/bytefly/header/component.ts +21 -10
  50. package/extensions/bytefly/header/config.ts +0 -3
  51. package/extensions/bytefly/header/register.ts +6 -1
  52. package/extensions/bytefly/index.ts +32 -12
  53. package/extensions/bytefly/message/config.ts +15 -0
  54. package/extensions/bytefly/message/format.ts +42 -0
  55. package/extensions/bytefly/message/register.ts +15 -0
  56. package/extensions/bytefly/notice/component.ts +107 -0
  57. package/extensions/bytefly/notice/config.ts +41 -0
  58. package/extensions/bytefly/notice/register.ts +27 -0
  59. package/extensions/bytefly/pad.ts +42 -0
  60. package/extensions/bytefly/separator/component.ts +2 -1
  61. package/extensions/bytefly/separator/format.ts +17 -10
  62. package/extensions/bytefly/separator/register.ts +3 -2
  63. package/extensions/bytefly/working/config.ts +20 -0
  64. package/extensions/bytefly/working/format.ts +25 -0
  65. package/extensions/bytefly/working/register.ts +88 -0
  66. package/extensions/document/README.md +11 -4
  67. package/extensions/document/paging.ts +10 -10
  68. package/extensions/document/render.ts +7 -7
  69. package/extensions/file-browse/README.md +3 -1
  70. package/extensions/file-browse/client.ts +9 -2
  71. package/extensions/file-browse/git.ts +4 -7
  72. package/extensions/file-browse/html.ts +27 -17
  73. package/extensions/file-open/README.md +6 -0
  74. package/extensions/goal/README.md +16 -10
  75. package/extensions/goal/format.ts +7 -20
  76. package/extensions/goal/tools.ts +5 -17
  77. package/extensions/goal/tui.ts +12 -40
  78. package/extensions/job/README.md +113 -35
  79. package/extensions/job/commands.ts +1 -0
  80. package/extensions/job/config.ts +4 -0
  81. package/extensions/job/format.ts +19 -7
  82. package/extensions/job/index.ts +25 -20
  83. package/extensions/job/process.ts +24 -2
  84. package/extensions/job/registry.ts +3 -0
  85. package/extensions/job/runtime.ts +253 -18
  86. package/extensions/job/schema.ts +34 -0
  87. package/extensions/job/store.ts +13 -1
  88. package/extensions/job/tools.ts +55 -15
  89. package/extensions/job/tui.ts +41 -15
  90. package/extensions/job/types.ts +9 -1
  91. package/extensions/memory/README.md +6 -0
  92. package/extensions/memory/format.ts +5 -3
  93. package/extensions/plan/README.md +32 -12
  94. package/extensions/plan/tools.ts +20 -5
  95. package/extensions/plan/web-client.ts +58 -17
  96. package/extensions/plan/web-page.ts +34 -9
  97. package/extensions/plan/web-router.ts +40 -9
  98. package/extensions/plan/web.ts +22 -2
  99. package/extensions/question/README.md +13 -1
  100. package/extensions/question/answers.ts +25 -18
  101. package/extensions/question/tools.ts +11 -6
  102. package/extensions/rewind/README.md +2 -0
  103. package/extensions/rewind/format.ts +6 -1
  104. package/extensions/rewind/index.ts +3 -1
  105. package/extensions/rewind/snapshot-run.ts +1 -1
  106. package/extensions/subagent/README.md +6 -0
  107. package/extensions/subagent/index.ts +1 -1
  108. package/extensions/subagent/render.ts +56 -29
  109. package/extensions/todo/README.md +19 -12
  110. package/extensions/todo/format.ts +17 -25
  111. package/extensions/todo/tools.ts +2 -1
  112. package/extensions/todo/tui.ts +3 -2
  113. package/extensions/watch/README.md +14 -6
  114. package/extensions/watch/format.ts +2 -2
  115. package/extensions/watch/index.ts +28 -23
  116. package/extensions/watch/tools.ts +5 -3
  117. package/extensions/watch/tui.ts +12 -7
  118. package/extensions/web-access/README.md +16 -7
  119. package/extensions/web-access/fetch/tool.ts +1 -1
  120. package/extensions/web-access/search/tool.ts +3 -3
  121. package/extensions/wechat/README.md +6 -0
  122. package/extensions/worktree/README.md +6 -0
  123. package/lib/README.md +2 -1
  124. package/lib/format.ts +17 -0
  125. package/lib/notice.ts +87 -0
  126. package/lib/tui/chrome.ts +8 -4
  127. package/lib/tui/cursor.ts +2 -1
  128. package/lib/tui/glyphs.ts +10 -11
  129. package/lib/tui/indent.ts +166 -0
  130. package/lib/tui/index.ts +3 -1
  131. package/lib/tui/rows.ts +39 -5
  132. package/lib/tui/rules.ts +9 -3
  133. package/lib/tui/tokens.ts +14 -0
  134. package/lib/web/base.ts +2 -1
  135. package/lib/web/components.ts +3 -1
  136. package/lib/web/layout.ts +5 -1
  137. package/package.json +7 -3
  138. package/themes/bytefly-dark.json +7 -8
  139. package/themes/bytefly-light.json +10 -11
  140. package/assets/bytefly-chrome.png +0 -0
@@ -66,6 +66,9 @@ function normalizeRecord(record: JobRecord): JobRecord {
66
66
  ...record,
67
67
  statusPath: record.statusPath ?? null,
68
68
  startToken: record.startToken ?? null,
69
+ // A registry written before stdin support has no such flag; such a job was
70
+ // started without a pipe, and its session is long gone anyway.
71
+ stdin: record.stdin === true,
69
72
  };
70
73
  }
71
74
 
@@ -18,11 +18,19 @@
18
18
  * twice and listeners/waiters are removed on cleanup.
19
19
  */
20
20
 
21
- import { createWriteStream, existsSync, mkdirSync, unlinkSync, writeFileSync, type WriteStream } from "node:fs";
21
+ import {
22
+ createWriteStream,
23
+ existsSync,
24
+ mkdirSync,
25
+ renameSync,
26
+ unlinkSync,
27
+ writeFileSync,
28
+ type WriteStream,
29
+ } from "node:fs";
22
30
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
23
31
  import { resolveEffectiveCwd } from "../../lib/env.ts";
24
32
  import { loadConfig, type JobsConfig } from "./config.ts";
25
- import { clipLabel, formatLogs, sanitizeLogLine } from "./format.ts";
33
+ import { clipLabel, compareJobs, formatLogs, sanitizeLogLine } from "./format.ts";
26
34
  import { readLogTail } from "./logs.ts";
27
35
  import {
28
36
  defaultKillTree,
@@ -34,6 +42,7 @@ import {
34
42
  type SpawnFn,
35
43
  type SpawnedProcess,
36
44
  type StartTokenFn,
45
+ type StdinSink,
37
46
  } from "./process.ts";
38
47
  import {
39
48
  loadRegistries,
@@ -61,6 +70,8 @@ import { createWaiters } from "./waiters.ts";
61
70
  const LOG_MODEL_CHARS = 16 * 1024;
62
71
  /** Cap on the buffered partial line used to track the latest output. */
63
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;
64
75
  /** Default `wait` timeout. */
65
76
  const DEFAULT_WAIT_MS = 30_000;
66
77
 
@@ -70,6 +81,8 @@ export interface StartOptions {
70
81
  label?: string;
71
82
  wake?: boolean;
72
83
  detached?: boolean;
84
+ /** Give the child a stdin pipe, so `send` can write to it while it runs here. */
85
+ stdin?: boolean;
73
86
  /** Auto-kill the job after this many milliseconds (SIGTERM, escalating to SIGKILL). */
74
87
  timeoutMs?: number;
75
88
  }
@@ -91,6 +104,23 @@ export interface WaitResult {
91
104
  cancelled: boolean;
92
105
  }
93
106
 
107
+ export interface SendOptions {
108
+ /** Text written to the job's stdin. */
109
+ input?: string;
110
+ /** Append a newline to `input`; defaults to true. */
111
+ newline?: boolean;
112
+ /** End the job's stdin after writing, so the child sees EOF. */
113
+ eof?: boolean;
114
+ }
115
+
116
+ export interface SendResult {
117
+ job: Job;
118
+ /** Characters of `input` written, excluding any newline this call added. */
119
+ sent: number;
120
+ /** True when this call ended the job's stdin. */
121
+ closed: boolean;
122
+ }
123
+
94
124
  export interface RuntimeOptions {
95
125
  spawn?: SpawnFn;
96
126
  liveness?: LivenessFn;
@@ -106,10 +136,26 @@ interface Handle {
106
136
  pending: string;
107
137
  killTimer?: ReturnType<typeof setTimeout>;
108
138
  timeoutTimer?: ReturnType<typeof setTimeout>;
139
+ /** The child's stdin pipe, present only for a job started with `stdin: true`. */
140
+ stdin?: StdinSink | null;
141
+ /** True once stdin was ended, or the child closed its end. */
142
+ stdinClosed?: boolean;
143
+ /** Chunks that arrived while the log stream was being swapped. */
144
+ queue: Buffer[];
145
+ /** Bytes currently in `queue`. */
146
+ queuedBytes: number;
147
+ /** True between ending the old stream and reopening the log. */
148
+ rotating: boolean;
149
+ /** Bytes dropped because a rotation could not keep up; reported by `logs`. */
150
+ droppedBytes: number;
151
+ /** Bytes written to the live segment, counted exactly as chunks arrive. */
152
+ logBytes: number;
109
153
  }
110
154
 
111
155
  export interface JobsRuntime {
112
156
  readonly config: JobsConfig;
157
+ /** This session's id, so a caller can tell its own records from a peer's. */
158
+ readonly sessionId: string;
113
159
  /** Effective working directory, honoring an active worktree root. */
114
160
  effectiveCwd(ctx: ExtensionContext): string;
115
161
  /** Load and reconcile the registry, then paint the UI. */
@@ -118,6 +164,11 @@ export interface JobsRuntime {
118
164
  list(): JobRecord[];
119
165
  get(id: string): Job | undefined;
120
166
  logs(id: string, lines?: number): LogResult | undefined;
167
+ /**
168
+ * Write to a running job's stdin. Throws a model-readable error when the job is
169
+ * gone, was started without `stdin: true`, or belongs to another session.
170
+ */
171
+ send(id: string, options: SendOptions): SendResult;
121
172
  kill(id: string, signal?: KillSignal): Job | undefined;
122
173
  wait(
123
174
  id: string,
@@ -177,6 +228,9 @@ export function createJobsRuntime(options: RuntimeOptions = {}): JobsRuntime {
177
228
  try {
178
229
  const path = store.safeLogPath(job);
179
230
  if (path && existsSync(path)) unlinkSync(path);
231
+ // Both segments are the job's own log; drop the rotated one too.
232
+ const rotated = store.safeRotatedLogPath(job);
233
+ if (rotated && existsSync(rotated)) unlinkSync(rotated);
180
234
  } catch {
181
235
  // Log cleanup is best-effort.
182
236
  }
@@ -369,6 +423,21 @@ export function createJobsRuntime(options: RuntimeOptions = {}): JobsRuntime {
369
423
  if (!handle) return;
370
424
  if (handle.killTimer) clearTimeout(handle.killTimer);
371
425
  if (handle.timeoutTimer) clearTimeout(handle.timeoutTimer);
426
+ // A rotation still in flight checks the handle map before reopening, so
427
+ // dropping the queue here cannot leak a reopened stream for a dead job.
428
+ handle.queue = [];
429
+ handle.queuedBytes = 0;
430
+ // Release the stdin pipe so a child waiting on input sees EOF instead of a
431
+ // half-open pipe nobody can write to.
432
+ if (handle.stdin && !handle.stdinClosed) {
433
+ try {
434
+ handle.stdin.end();
435
+ } catch {
436
+ // Already closed.
437
+ }
438
+ }
439
+ handle.stdin = null;
440
+ handle.stdinClosed = true;
372
441
  if (options.endStream) {
373
442
  try {
374
443
  handle.stream?.end();
@@ -383,11 +452,7 @@ export function createJobsRuntime(options: RuntimeOptions = {}): JobsRuntime {
383
452
  const handle = handles.get(id);
384
453
  const job = jobs.get(id);
385
454
  if (!handle || !job) return;
386
- try {
387
- handle.stream?.write(chunk);
388
- } catch {
389
- // A closed stream must not break output handling.
390
- }
455
+ writeChunk(handle, job, chunk);
391
456
  handle.pending += chunk.toString("utf8");
392
457
  if (handle.pending.length > MAX_PENDING) handle.pending = handle.pending.slice(-MAX_PENDING);
393
458
  consume(handle, job);
@@ -398,6 +463,98 @@ export function createJobsRuntime(options: RuntimeOptions = {}): JobsRuntime {
398
463
  }
399
464
  };
400
465
 
466
+ const writeLog = (handle: Handle, chunk: Buffer): void => {
467
+ try {
468
+ handle.stream?.write(chunk);
469
+ } catch {
470
+ // A closed stream must not break output handling.
471
+ }
472
+ // Count what we handed the stream: `WriteStream.bytesWritten` only updates on
473
+ // its completion callback, which can lag a tick behind the file on disk.
474
+ if (handle.stream) handle.logBytes += chunk.length;
475
+ };
476
+
477
+ /** Buffer a chunk while the log stream is being swapped, dropping the oldest past the cap. */
478
+ const enqueue = (handle: Handle, chunk: Buffer, front = false): void => {
479
+ if (front) handle.queue.unshift(chunk);
480
+ else handle.queue.push(chunk);
481
+ handle.queuedBytes += chunk.length;
482
+ while (handle.queuedBytes > MAX_ROTATION_QUEUE_BYTES && handle.queue.length > 1) {
483
+ const dropped = handle.queue.shift();
484
+ if (!dropped) break;
485
+ handle.queuedBytes -= dropped.length;
486
+ handle.droppedBytes += dropped.length;
487
+ }
488
+ };
489
+
490
+ /**
491
+ * Move the live segment aside, queueing `chunk` ahead of the backlog, and reopen.
492
+ *
493
+ * Returns false when no rotation can start (no stream, or a record whose log
494
+ * paths do not validate), so the caller writes the chunk instead of stranding it
495
+ * in a queue nothing will drain.
496
+ *
497
+ * The old stream is ended and its `close` awaited before the rename, because
498
+ * Windows refuses to rename an open file; chunks that arrive in that window
499
+ * are queued rather than written, so nothing lands in the renamed inode.
500
+ */
501
+ const rotateLog = (handle: Handle, job: Job, chunk: Buffer): boolean => {
502
+ const stream = handle.stream;
503
+ const logPath = store.safeLogPath(job);
504
+ const rotated = store.safeRotatedLogPath(job);
505
+ if (!stream || !logPath || !rotated || handle.rotating) return false;
506
+ // This chunk precedes whatever queued while the previous segment was closing.
507
+ enqueue(handle, chunk, true);
508
+ handle.rotating = true;
509
+ stream.once("close", () => {
510
+ // A killed or cleared job drops its handle; never reopen for it.
511
+ if (handles.get(job.id) !== handle) return;
512
+ try {
513
+ if (existsSync(rotated)) unlinkSync(rotated);
514
+ renameSync(logPath, rotated);
515
+ } catch {
516
+ // A failed rename leaves the oversized segment in place; keep appending.
517
+ }
518
+ const reopened = createWriteStream(logPath, { flags: "a" });
519
+ reopened.on("error", () => {});
520
+ handle.stream = reopened;
521
+ handle.rotating = false;
522
+ handle.logBytes = 0;
523
+ drain(handle, job);
524
+ });
525
+ try {
526
+ stream.end();
527
+ } catch {
528
+ // Already ended.
529
+ }
530
+ return true;
531
+ };
532
+
533
+ /** Write one output chunk, rotating the segment first when it reached the cap. */
534
+ const writeChunk = (handle: Handle, job: Job, chunk: Buffer): void => {
535
+ if (handle.rotating) {
536
+ // Later than everything already queued.
537
+ enqueue(handle, chunk);
538
+ return;
539
+ }
540
+ const cap = config.maxLogBytes;
541
+ if (handle.stream && cap > 0 && handle.logBytes >= cap && rotateLog(handle, job, chunk)) return;
542
+ writeLog(handle, chunk);
543
+ };
544
+
545
+ /**
546
+ * Replay the queue through `writeChunk` after a rotation, so a backlog that is
547
+ * itself larger than the cap rotates again instead of filling one segment.
548
+ */
549
+ const drain = (handle: Handle, job: Job): void => {
550
+ while (!handle.rotating && handle.queue.length > 0) {
551
+ const chunk = handle.queue.shift();
552
+ if (!chunk) break;
553
+ handle.queuedBytes -= chunk.length;
554
+ writeChunk(handle, job, chunk);
555
+ }
556
+ };
557
+
401
558
  const finalize = (id: string, code: number | null, signal: string | null): void => {
402
559
  const handle = handles.get(id);
403
560
  const job = jobs.get(id);
@@ -447,12 +604,16 @@ export function createJobsRuntime(options: RuntimeOptions = {}): JobsRuntime {
447
604
  get config() {
448
605
  return config;
449
606
  },
607
+ get sessionId() {
608
+ return sessionId;
609
+ },
450
610
  effectiveCwd,
451
611
  load,
452
612
  start,
453
- list: () => ownJobs().map(toRecord),
613
+ list: () => [...jobs.values()].sort(compareJobs).map(toRecord),
454
614
  get: (id) => jobs.get(id),
455
615
  logs,
616
+ send,
456
617
  kill,
457
618
  wait,
458
619
  clear,
@@ -555,6 +716,14 @@ export function createJobsRuntime(options: RuntimeOptions = {}): JobsRuntime {
555
716
  function start(startOptions: StartOptions, ctx: ExtensionContext): Job {
556
717
  if (!config.enabled) throw new Error("jobs are disabled.");
557
718
  ui.attach(ctx);
719
+ const wantsStdin = startOptions.stdin === true;
720
+ const detached = startOptions.detached ?? config.detachedByDefault;
721
+ // A detached job outlives this process, so a stdin pipe owned by this session
722
+ // would hand it EOF the moment the session ends; refuse instead of silently
723
+ // dropping the pipe (or the detach).
724
+ if (wantsStdin && detached) {
725
+ throw new Error("a detached job cannot take stdin; start it with `detached: false`.");
726
+ }
558
727
  const cwd = startOptions.cwd ? startOptions.cwd : effectiveCwd(ctx);
559
728
  // The registry directory must exist before `nextId`, which reserves the id
560
729
  // with an `O_EXCL` lock file inside it.
@@ -564,7 +733,6 @@ export function createJobsRuntime(options: RuntimeOptions = {}): JobsRuntime {
564
733
  const id = store.nextId(jobs.keys());
565
734
  const logPath = store.logPath(id);
566
735
  const statusPath = process.platform === "win32" ? null : store.statusPath(id);
567
- const detached = startOptions.detached ?? config.detachedByDefault;
568
736
  // A detached POSIX job points its own output at the log, so it keeps logging
569
737
  // and survives after this process ends; Windows keeps the parent pipe.
570
738
  const detachOutput = detached && process.platform !== "win32";
@@ -577,7 +745,7 @@ export function createJobsRuntime(options: RuntimeOptions = {}): JobsRuntime {
577
745
  if (detachOutput) env[LOG_ENV] = logPath;
578
746
  let process_: SpawnedProcess;
579
747
  try {
580
- process_ = spawn(program, { cwd, env, detachOutput });
748
+ process_ = spawn(program, { cwd, env, detachOutput, stdin: wantsStdin });
581
749
  } catch (error) {
582
750
  // A failed spawn must not leave the reserved id behind.
583
751
  store.releaseId(id);
@@ -612,6 +780,7 @@ export function createJobsRuntime(options: RuntimeOptions = {}): JobsRuntime {
612
780
  statusPath,
613
781
  detached,
614
782
  wake: startOptions.wake ?? config.wakeOnFinish,
783
+ stdin: wantsStdin,
615
784
  sessionId,
616
785
  seen: false,
617
786
  lastLine: "",
@@ -619,7 +788,28 @@ export function createJobsRuntime(options: RuntimeOptions = {}): JobsRuntime {
619
788
  owned: true,
620
789
  };
621
790
 
622
- const handle: Handle = { stream, pending: "" };
791
+ const handle: Handle = {
792
+ stream,
793
+ pending: "",
794
+ queue: [],
795
+ queuedBytes: 0,
796
+ rotating: false,
797
+ droppedBytes: 0,
798
+ logBytes: 0,
799
+ };
800
+ if (wantsStdin) {
801
+ const sink = process_.stdin ?? null;
802
+ handle.stdin = sink;
803
+ handle.stdinClosed = sink === null;
804
+ // A child that exits can emit `error`/`close` on the pipe; record it so a
805
+ // later `send` reports the truth instead of throwing an unhandled stream error.
806
+ const markClosed = (): void => {
807
+ const current = handles.get(id);
808
+ if (current) current.stdinClosed = true;
809
+ };
810
+ sink?.on("error", markClosed);
811
+ sink?.on("close", markClosed);
812
+ }
623
813
  handles.set(id, handle);
624
814
  jobs.set(id, job);
625
815
 
@@ -643,16 +833,33 @@ export function createJobsRuntime(options: RuntimeOptions = {}): JobsRuntime {
643
833
  function logs(id: string, lines = config.maxLogLines): LogResult | undefined {
644
834
  const job = jobs.get(id);
645
835
  if (!job) return undefined;
646
- const tail = readLogTail(store.safeLogPath(job), lines);
647
- const last = tail.lines.at(-1);
836
+ const current = readLogTail(store.safeLogPath(job), lines);
837
+ let all = current.lines;
838
+ let more = current.more;
839
+ let truncated = current.truncated;
840
+ // Keep `logs` continuous across a rotation: fill the request from the
841
+ // previous segment when the live one holds fewer lines.
842
+ if (all.length < lines) {
843
+ const previous = readLogTail(store.safeRotatedLogPath(job), lines - all.length);
844
+ if (previous.lines.length > 0) {
845
+ all = [...previous.lines, ...all];
846
+ more = more || previous.more;
847
+ truncated = truncated || previous.truncated;
848
+ }
849
+ }
850
+ const last = all.at(-1);
648
851
  if (last) job.lastLine = last;
649
- const formatted = formatLogs(job, tail.lines, LOG_MODEL_CHARS);
852
+ const formatted = formatLogs(job, all, LOG_MODEL_CHARS);
853
+ // A rotation that outran its drain drops the oldest queued chunks; say so once
854
+ // rather than letting the gap look like a program that skipped output.
855
+ const dropped = handles.get(id)?.droppedBytes ?? 0;
856
+ const text = dropped > 0 ? `${formatted.text}\n… ${dropped} bytes dropped while the log rotated.` : formatted.text;
650
857
  return {
651
858
  job,
652
- text: formatted.text,
653
- lines: tail.lines,
654
- truncated: formatted.truncated || tail.truncated,
655
- more: tail.more,
859
+ text,
860
+ lines: all,
861
+ truncated: formatted.truncated || truncated,
862
+ more,
656
863
  };
657
864
  }
658
865
 
@@ -725,6 +932,34 @@ export function createJobsRuntime(options: RuntimeOptions = {}): JobsRuntime {
725
932
  return job;
726
933
  }
727
934
 
935
+ function send(id: string, options: SendOptions): SendResult {
936
+ const job = jobs.get(id);
937
+ if (!job) throw new Error(`no job "${id}".`);
938
+ if (job.status !== "running") throw new Error(`job ${id} is ${job.status}; stdin is closed.`);
939
+ if (!job.stdin) throw new Error(`job ${id} was not started with stdin; restart it with \`stdin: true\`.`);
940
+ const handle = handles.get(id);
941
+ // A pipe this session opened but that is closed is a different fact from one
942
+ // that was never ours, so state it first.
943
+ if (handle?.stdinClosed) throw new Error(`job ${id} closed its stdin.`);
944
+ if (!handle?.stdin) throw new Error(`job ${id}'s stdin belongs to the session that started it.`);
945
+
946
+ const input = options.input ?? "";
947
+ const payload = input && options.newline !== false ? `${input}\n` : input;
948
+ try {
949
+ if (payload) handle.stdin.write(payload);
950
+ if (options.eof) {
951
+ handle.stdin.end();
952
+ handle.stdinClosed = true;
953
+ }
954
+ } catch {
955
+ // The child closed its end; record it so the next `send` says so.
956
+ handle.stdinClosed = true;
957
+ handle.stdin = null;
958
+ throw new Error(`job ${id} closed its stdin.`);
959
+ }
960
+ return { job, sent: input.length, closed: options.eof === true };
961
+ }
962
+
728
963
  function wait(
729
964
  id: string,
730
965
  timeoutMs: number | undefined,
@@ -14,6 +14,8 @@ import { JOB_ACTIONS, KILL_SIGNALS, type JobAction, type KillSignal } from "./ty
14
14
  export const MAX_COMMAND = 4000;
15
15
  /** Longest accepted label. */
16
16
  export const MAX_LABEL = 120;
17
+ /** Longest accepted `send.input`, in characters. */
18
+ export const MAX_STDIN = 8192;
17
19
  /** Bounds for `logs.lines`. */
18
20
  const MIN_LOG_LINES = 1;
19
21
  const MAX_LOG_LINES = 2000;
@@ -31,10 +33,20 @@ export const JobParams = Type.Object({
31
33
  label: Type.Optional(Type.String({ description: "Short display label; defaults to the command." })),
32
34
  wake: Type.Optional(Type.Boolean({ description: "Wake the agent with one triggered turn when the job finishes." })),
33
35
  detached: Type.Optional(Type.Boolean({ description: "Leave the job running when the session ends." })),
36
+ stdin: Type.Optional(
37
+ Type.Boolean({
38
+ description: "Give the job a stdin pipe so `send` can write to it (for `start`); not with `detached`.",
39
+ }),
40
+ ),
34
41
  id: Type.Optional(Type.String({ description: "Job id, e.g. `j1`." })),
35
42
  lines: Type.Optional(
36
43
  Type.Integer({ minimum: MIN_LOG_LINES, maximum: MAX_LOG_LINES, description: "Log lines to return." }),
37
44
  ),
45
+ input: Type.Optional(
46
+ Type.String({ description: `Text to write to a running job's stdin (for \`send\`; max ${MAX_STDIN} characters).` }),
47
+ ),
48
+ newline: Type.Optional(Type.Boolean({ description: "Append a newline to `input` (for `send`; default true)." })),
49
+ eof: Type.Optional(Type.Boolean({ description: "End the job's stdin after writing, so it sees EOF (for `send`)." })),
38
50
  signal: Type.Optional(StringEnum(KILL_SIGNALS, { description: "Signal for `kill`; defaults to SIGTERM." })),
39
51
  timeoutMs: Type.Optional(
40
52
  Type.Integer({
@@ -64,11 +76,13 @@ export type JobCall =
64
76
  label?: string;
65
77
  wake?: boolean;
66
78
  detached?: boolean;
79
+ stdin?: boolean;
67
80
  timeoutMs?: number;
68
81
  }
69
82
  | { action: "list" }
70
83
  | { action: "status"; id: string }
71
84
  | { action: "logs"; id: string; lines?: number }
85
+ | { action: "send"; id: string; input?: string; newline?: boolean; eof?: boolean }
72
86
  | { action: "kill"; id: string; signal?: KillSignal }
73
87
  | { action: "wait"; id: string; timeoutMs?: number }
74
88
  | { action: "clear"; id?: string; all?: boolean };
@@ -110,6 +124,7 @@ export function normalizeCall(raw: unknown): JobCall {
110
124
  ...(cwd ? { cwd } : {}),
111
125
  ...(typeof args.wake === "boolean" ? { wake: args.wake } : {}),
112
126
  ...(typeof args.detached === "boolean" ? { detached: args.detached } : {}),
127
+ ...(typeof args.stdin === "boolean" ? { stdin: args.stdin } : {}),
113
128
  ...(timeoutMs !== undefined ? { timeoutMs } : {}),
114
129
  };
115
130
  }
@@ -124,6 +139,25 @@ export function normalizeCall(raw: unknown): JobCall {
124
139
  }
125
140
  return { action: "logs", id: requireId(), ...(lines !== undefined ? { lines } : {}) };
126
141
  }
142
+ case "send": {
143
+ const jobId = requireId();
144
+ if (args.input !== undefined && typeof args.input !== "string") {
145
+ throw new Error('"send" input must be a string.');
146
+ }
147
+ const input = typeof args.input === "string" ? args.input : undefined;
148
+ if (input !== undefined && input.length > MAX_STDIN) {
149
+ throw new Error(`input is longer than ${MAX_STDIN} characters.`);
150
+ }
151
+ const eof = args.eof === true;
152
+ if (!input && !eof) throw new Error('"send" requires `input` or `eof: true`.');
153
+ return {
154
+ action: "send",
155
+ id: jobId,
156
+ ...(input ? { input } : {}),
157
+ ...(typeof args.newline === "boolean" ? { newline: args.newline } : {}),
158
+ ...(eof ? { eof } : {}),
159
+ };
160
+ }
127
161
  case "kill": {
128
162
  const signal = args.signal;
129
163
  if (signal !== undefined && !(KILL_SIGNALS as readonly string[]).includes(signal)) {
@@ -11,7 +11,7 @@
11
11
  * is otherwise running fine.
12
12
  */
13
13
 
14
- import { join, resolve } from "node:path";
14
+ import { join, resolve, sep } from "node:path";
15
15
  import { REGISTRY_VERSION, loadRegistries, registryDirFor, saveRegistry } from "./registry.ts";
16
16
  import { listReservedIds, pruneReservations, releaseReservation, reserveId } from "./reservations.ts";
17
17
  import { statusPathFor } from "./paths.ts";
@@ -38,6 +38,10 @@ export interface JobStore {
38
38
  logPath(id: string): string;
39
39
  /** The log path only when it is exactly this job's file in the directory. */
40
40
  safeLogPath(job: JobRecord): string | undefined;
41
+ /** Absolute path of the segment a rotation moved the previous log to. */
42
+ rotatedLogPath(id: string): string;
43
+ /** The rotated path only when it stays inside the directory. */
44
+ safeRotatedLogPath(job: JobRecord): string | undefined;
41
45
  /** Absolute status path for a job id in the current directory. */
42
46
  statusPath(id: string): string;
43
47
  /** The status path only when it is exactly this job's file in the directory. */
@@ -84,6 +88,14 @@ export function createJobStore(initialDir = registryDirFor(process.cwd())): JobS
84
88
  },
85
89
  logPath: (id) => join(dir, `${id}.log`),
86
90
  safeLogPath: (job) => safeArtifact(job.logPath, join(dir, `${job.id}.log`)),
91
+ rotatedLogPath: (id) => join(dir, `${id}.log.1`),
92
+ // No record field names this file, so the id is the only input: refuse an id
93
+ // that would resolve outside the registry directory.
94
+ safeRotatedLogPath: (job) => {
95
+ const root = resolve(dir);
96
+ const target = resolve(root, `${job.id}.log.1`);
97
+ return target.startsWith(`${root}${sep}`) ? target : undefined;
98
+ },
87
99
  statusPath: (id) => statusPathFor(dir, id),
88
100
  safeStatusPath: (job) => safeArtifact(job.statusPath ?? undefined, join(dir, `${job.id}.status`)),
89
101
  };
@@ -1,15 +1,17 @@
1
1
  /**
2
2
  * Model-facing registration for the `job` tool.
3
3
  *
4
- * One tool with seven actions: `start`, `list`, `status`, `logs`, `kill`,
5
- * `wait`, `clear`. A background process can outlive the tool call, so `start`
6
- * returns immediately with an id; the model polls with `status`/`logs` or
7
- * blocks with `wait`. Failures come back as an error result.
4
+ * One tool with eight actions: `start`, `list`, `status`, `logs`, `send`,
5
+ * `kill`, `wait`, `clear`. A background process can outlive the tool call, so
6
+ * `start` returns immediately with an id; the model polls with `status`/`logs` or
7
+ * blocks with `wait`. `stdin: true` opens a pipe that `send` writes to. Failures
8
+ * come back as an error result.
8
9
  */
9
10
 
10
11
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
11
12
  import { sanitizeLine } from "../../lib/format.ts";
12
13
  import { reuseText, toolDetails } from "../../lib/tui/chrome.ts";
14
+ import { SEPARATORS } from "../../lib/tui/glyphs.ts";
13
15
  import { paint } from "../../lib/tui/tokens.ts";
14
16
  import {
15
17
  compareJobs,
@@ -18,6 +20,7 @@ import {
18
20
  formatJobList,
19
21
  formatJobOutcomeLine,
20
22
  formatJobStatus,
23
+ shortLabel,
21
24
  } from "./format.ts";
22
25
  import type { JobsRuntime } from "./runtime.ts";
23
26
  import { JobParams, normalizeCall, type JobArgs, type JobCall } from "./schema.ts";
@@ -48,6 +51,7 @@ function errorResult(
48
51
  const GUIDELINES = [
49
52
  "Use `job start` for long-running or background commands (builds, test suites, servers, watchers) so the turn is not blocked.",
50
53
  "Poll with `job status`/`job logs`, or use `job wait` when you must have the result before continuing.",
54
+ "Start a job with `stdin: true` to drive an interactive program: `job send` writes a line to it and `eof: true` closes its input.",
51
55
  "Use `job kill` to stop a running job and `job clear` to drop finished ones.",
52
56
  ];
53
57
 
@@ -56,9 +60,9 @@ export function registerTools(pi: ExtensionAPI, runtime: JobsRuntime): void {
56
60
  name: TOOL_NAME,
57
61
  label: "Job",
58
62
  description:
59
- "Manage background shell-command jobs. Actions: `start` (returns a job id immediately), `list`, `status`/`logs`/" +
60
- "`kill`/`wait` (by id), and `clear`. Jobs are killed when the session ends unless started `detached`.",
61
- promptSnippet: "Run and manage background shell-command jobs (start, list, status, logs, kill, wait, clear).",
63
+ "Manage background shell-command jobs. Actions: `start` (returns a job id immediately; `stdin: true` opens a stdin pipe), " +
64
+ "`list`, `status`/`logs`/`send`/`kill`/`wait` (by id), and `clear`. Jobs are killed when the session ends unless started `detached`.",
65
+ promptSnippet: "Run and manage background shell-command jobs (start, list, status, logs, send, kill, wait, clear).",
62
66
  promptGuidelines: GUIDELINES,
63
67
  parameters: JobParams,
64
68
  annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
@@ -82,6 +86,7 @@ export function registerTools(pi: ExtensionAPI, runtime: JobsRuntime): void {
82
86
  label: call.label,
83
87
  wake: call.wake,
84
88
  detached: call.detached,
89
+ stdin: call.stdin,
85
90
  timeoutMs: call.timeoutMs,
86
91
  },
87
92
  ctx,
@@ -100,8 +105,8 @@ export function registerTools(pi: ExtensionAPI, runtime: JobsRuntime): void {
100
105
  case "list": {
101
106
  const jobs = runtime.list();
102
107
  return {
103
- content: [{ type: "text" as const, text: formatJobList(jobs) }],
104
- details: { action: "list", jobs } satisfies JobDetails,
108
+ content: [{ type: "text" as const, text: formatJobList(jobs, Date.now(), runtime.sessionId) }],
109
+ details: { action: "list", jobs, sessionId: runtime.sessionId } satisfies JobDetails,
105
110
  };
106
111
  }
107
112
 
@@ -109,8 +114,8 @@ export function registerTools(pi: ExtensionAPI, runtime: JobsRuntime): void {
109
114
  const job = runtime.get(call.id);
110
115
  if (!job) return errorResult("status", `no job "${call.id}".`);
111
116
  return {
112
- content: [{ type: "text" as const, text: formatJobStatus(job) }],
113
- details: { action: "status", job: toRecord(job) } satisfies JobDetails,
117
+ content: [{ type: "text" as const, text: formatJobStatus(job, Date.now(), runtime.sessionId) }],
118
+ details: { action: "status", job: toRecord(job), sessionId: runtime.sessionId } satisfies JobDetails,
114
119
  };
115
120
  }
116
121
 
@@ -128,6 +133,30 @@ export function registerTools(pi: ExtensionAPI, runtime: JobsRuntime): void {
128
133
  };
129
134
  }
130
135
 
136
+ case "send": {
137
+ const result = runtime.send(call.id, {
138
+ input: call.input,
139
+ newline: call.newline,
140
+ eof: call.eof,
141
+ });
142
+ const job = result.job;
143
+ const text =
144
+ result.sent > 0
145
+ ? `Sent ${result.sent} character${result.sent === 1 ? "" : "s"} to ${job.id} (${shortLabel(job)})${
146
+ result.closed ? " and closed its stdin" : ""
147
+ }.`
148
+ : `Closed ${job.id}'s stdin.`;
149
+ return {
150
+ content: [{ type: "text" as const, text }],
151
+ details: {
152
+ action: "send",
153
+ job: toRecord(job),
154
+ sent: result.sent,
155
+ stdinClosed: result.closed,
156
+ } satisfies JobDetails,
157
+ };
158
+ }
159
+
131
160
  case "kill": {
132
161
  const before = runtime.get(call.id);
133
162
  if (!before) return errorResult("kill", `no job "${call.id}".`);
@@ -209,8 +238,9 @@ export function registerTools(pi: ExtensionAPI, runtime: JobsRuntime): void {
209
238
  const text = reuseText(context.lastComponent);
210
239
  text.setText(
211
240
  theme.fg("toolTitle", theme.bold(`${TOOL_NAME} `)) +
212
- theme.fg(
213
- "muted",
241
+ paint(
242
+ theme,
243
+ "meta",
214
244
  sanitizeLine(formatCallText(action, args as Record<string, unknown>, context.argsComplete)),
215
245
  ),
216
246
  );
@@ -256,7 +286,12 @@ export function registerTools(pi: ExtensionAPI, runtime: JobsRuntime): void {
256
286
  }
257
287
  const ordered = [...details.jobs].sort(compareJobs);
258
288
  const shown = options.expanded ? ordered : ordered.slice(0, RESULT_ROWS);
259
- const input: JobResultInput = { kind: "list", jobs: shown, more: ordered.length - shown.length };
289
+ const input: JobResultInput = {
290
+ kind: "list",
291
+ jobs: shown,
292
+ more: ordered.length - shown.length,
293
+ sessionId: details.sessionId,
294
+ };
260
295
  const view = context.lastComponent instanceof JobResult ? context.lastComponent : new JobResult(input, theme);
261
296
  view.setInput(input, theme);
262
297
  return view;
@@ -278,11 +313,16 @@ export function registerTools(pi: ExtensionAPI, runtime: JobsRuntime): void {
278
313
  line += paint(theme, "warn", " · wait cancelled; job still running");
279
314
  } else if (details.action === "kill" && details.signalled) {
280
315
  line += paint(theme, "warn", ` · ${details.signal ?? "SIGTERM"} sent`);
316
+ } else if (details.action === "send") {
317
+ const badges: string[] = [];
318
+ if (details.sent) badges.push(`sent ${details.sent}`);
319
+ if (details.stdinClosed) badges.push("stdin closed");
320
+ if (badges.length > 0) line += paint(theme, "faint", `${SEPARATORS.item}${badges.join(SEPARATORS.item)}`);
281
321
  } else if (details.action === "start") {
282
322
  const badges: string[] = [];
283
323
  if (details.job.detached) badges.push("detached");
284
324
  if (details.job.wake) badges.push("wake");
285
- if (badges.length > 0) line += paint(theme, "faint", ` · ${badges.join(" · ")}`);
325
+ if (badges.length > 0) line += paint(theme, "faint", `${SEPARATORS.item}${badges.join(SEPARATORS.item)}`);
286
326
  }
287
327
  text.setText(line);
288
328
  return text;