@gavin-hu/my-pi-agent 0.10.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.
package/assets/chrome.png CHANGED
Binary file
@@ -22,9 +22,17 @@ How the `job` call and its result look in the transcript — the call line as a
22
22
  ## What it does
23
23
 
24
24
  - Registers one model-callable tool, `job`, with actions `start`, `list`,
25
- `status`, `logs`, `kill`, `wait`, and `clear`.
25
+ `status`, `logs`, `send`, `kill`, `wait`, and `clear`.
26
+ - Shows this session's jobs, and — tagged `peer` — a live peer session's, so two
27
+ Pi sessions on one repo can see each other's builds; the peer's rows are
28
+ read-only on the screen.
26
29
  - Runs each command in its own process group, streaming stdout and stderr to an
27
- append-only log file under `~/.pi/agent/jobs/<project>/`.
30
+ append-only log file under `~/.pi/agent/jobs/<project>/`. A segment rotates
31
+ to `<id>.log.1` at `maxLogBytes` (8 MiB by default), so a chatty job cannot
32
+ fill the disk, and `logs` reads across the boundary.
33
+ - Feeds a running job input when it was started with `stdin: true`: `send`
34
+ writes a line (or a bare keystroke) to its stdin, and `eof: true` closes it, so
35
+ a REPL, a debugger prompt, or a `read` can be driven without a TTY.
28
36
  - Shows a `▸ N` running status chip and a separate `✗ N` unreported-failure
29
37
  chip for this session's jobs, plus a `/jobs` screen for selecting, tailing,
30
38
  killing, and clearing.
@@ -40,14 +48,18 @@ How the `job` call and its result look in the transcript — the call line as a
40
48
  | Field | Value |
41
49
  |---|---|
42
50
  | `name` | `job` |
43
- | `action` | `start` \| `list` \| `status` \| `logs` \| `kill` \| `wait` \| `clear` |
51
+ | `action` | `start` \| `list` \| `status` \| `logs` \| `send` \| `kill` \| `wait` \| `clear` |
44
52
  | `command` | Shell command to run (`start`). |
45
53
  | `cwd` | Working directory (`start`); defaults to the session cwd. |
46
54
  | `label` | Short display label (`start`); defaults to the command. |
47
55
  | `wake` | Wake the agent with one triggered turn when it finishes (`start`). |
48
56
  | `detached` | Leave it running when the session ends (`start`). |
49
- | `id` | Job id such as `j1` (`status`/`logs`/`kill`/`wait`/`clear`). |
57
+ | `stdin` | Give the job a stdin pipe so `send` can write to it (`start`); not with `detached`. |
58
+ | `id` | Job id such as `j1` (`status`/`logs`/`send`/`kill`/`wait`/`clear`). |
50
59
  | `lines` | Log lines to return (`logs`, 1–2000). |
60
+ | `input` | Text to write to a running job's stdin (`send`; max 8192 characters). |
61
+ | `newline` | Append a newline to `input` (`send`; default `true`). |
62
+ | `eof` | End the job's stdin after writing, so it sees EOF (`send`). |
51
63
  | `signal` | `SIGTERM` \| `SIGKILL` \| `SIGINT` (`kill`; default `SIGTERM`). |
52
64
  | `timeoutMs` | `start`: kill the job after this many ms. `wait`: how long to block (default 30s). |
53
65
  | `all` | With `clear`, also remove running jobs. |
@@ -68,20 +80,38 @@ leaves the job running. Validation is pure and runs before any side effect, so
68
80
  `codemode` callers that bypass the TypeBox schema cannot start a process with
69
81
  missing arguments.
70
82
 
83
+ Example: drive an interactive program. Only a job started with `stdin: true`
84
+ accepts input, and `eof` closes it once you are done.
85
+
86
+ ```
87
+ job start { "command": "python3 -q", "stdin": true, "label": "repl" } → j2
88
+ job send { "id": "j2", "input": "print(1 + 1)" }
89
+ job logs { "id": "j2" }
90
+ job send { "id": "j2", "eof": true }
91
+ ```
92
+
93
+ `send` writes `input` plus a newline by default, so a line-oriented program
94
+ sees one line per call; pass `newline: false` to deliver a bare keystroke.
95
+ Writing to a finished job, a job without `stdin: true`, or a job this session
96
+ does not hold the pipe for is an error, and the call changes nothing.
97
+
71
98
  Each result carries a `JobDetails` object in tool-result `details`:
72
99
 
73
100
  ```
74
101
  JobDetails {
75
102
  action: JobAction
76
- job?: JobRecord // start/status/logs/kill/wait
103
+ job?: JobRecord // start/status/logs/send/kill/wait
77
104
  jobs?: JobRecord[] // list/clear
78
105
  logs?: string // sanitized tail for `logs`
79
106
  truncated?: boolean
80
107
  cleared?: number
81
108
  signalled?: boolean // kill sent a signal to a running job
82
109
  signal?: KillSignal // signal sent by kill (SIGTERM/SIGKILL/SIGINT)
110
+ sent?: number // characters of `input` written by send
111
+ stdinClosed?: boolean // send ended the job's stdin (EOF)
83
112
  timedOut?: boolean // wait hit its timeout
84
113
  cancelled?: boolean // wait was aborted; job still running
114
+ sessionId?: string // list/status: who made the call, for tagging peers
85
115
  error?: string
86
116
  }
87
117
  ```
@@ -93,7 +123,8 @@ the slot's component: `list` draws the same rows as `/jobs`, `logs` draws a
93
123
  blank line and the sanitized tail, and a single-job action draws one outcome
94
124
  line. A `kill` that sent a signal appends it (`· SIGTERM sent`) instead of
95
125
  showing the job's pre-kill running state, a `start` badges `detached` and
96
- `wake`, and a `logs` call on a job with no output yet renders `No output yet.`
126
+ `wake`, a `send` appends what it did (`· sent 5 · stdin closed`), and a `logs`
127
+ call on a job with no output yet renders `No output yet.`
97
128
 
98
129
  ```
99
130
  job list
@@ -116,23 +147,29 @@ job status j1
116
147
  ### Command: `/jobs`
117
148
 
118
149
  `/jobs` opens an interactive list in the TUI, and prints a text summary
119
- elsewhere. The list is **session-scoped**: it shows the jobs this session
120
- started, plus a dead session's records adopted into it, not a live peer's. On
150
+ elsewhere. The list shows the jobs this session started, a dead session's
151
+ records adopted into it, and a live peer session's jobs tagged `peer`. On
121
152
  the screen: `↑`/`↓` or `j`/`k` select, `PgUp`/`PgDn` page, `Home`/`End` jump,
122
153
  `Enter`/`l` opens the log pane, `d` (or `K`) kills the selected running job,
123
154
  `x` clears finished jobs, `Esc`/`q` closes. `d` and `x` ask for a `y`/`N`
124
155
  confirmation before acting, so a stray key cannot kill a build; when `x` would
125
156
  drop an unreported result, the prompt says how many. A `d` on a finished job (or
126
- an `x` with nothing finished) says so instead of doing nothing. In the log pane,
157
+ an `x` with nothing finished) says so instead of doing nothing. Another
158
+ session's row opens its log but is otherwise left alone: `d` answers
159
+ `j9 is another session's job; not killed.`, and `x` counts and clears only this
160
+ session's finished jobs. In the log pane,
127
161
  `↑`/`↓`, `PgUp`/`PgDn`, `Home`/`End`, and `g`/`G` scroll; the view follows the
128
162
  tail until you scroll up, and `Esc`/`Backspace`/`q` returns to the list. The
129
163
  pane's subheader names the job's log file, notes when only a bounded tail is
130
164
  loaded, and shows whether the view is `following` the tail or `paused`.
131
165
 
132
- The screen shows a counts summary (`N jobs · R running · F failed`), running
133
- jobs first then recent finished jobs, and a focused detail pane (command, cwd,
134
- outcome, and the last output line). Selection is tracked by job id, so the live
135
- re-sort cannot move the cursor or switch the open log out from under the user.
166
+ The screen shows a counts summary (`N jobs · R running · F failed · P peer`) over
167
+ the rows it shows, so it includes a peer's running job even though the footer
168
+ chips count only this session's. Running jobs come first, then recent finished
169
+ ones, and a focused detail pane follows (command, cwd, outcome, the owner when
170
+ it is another session, and the last output line). Selection is tracked by job
171
+ id, so the live re-sort cannot move the cursor or switch the open log out from
172
+ under the user.
136
173
  Any dock screen (`/todos`, `/jobs`, `/rewind`, or the
137
174
  `question` questionnaire) hides the above-editor goal/todo rails for its
138
175
  lifetime and restores them on close.
@@ -156,6 +193,7 @@ document, project wins):
156
193
  | `detachedByDefault` | `false` | Leave jobs running when the session ends. |
157
194
  | `maxJobs` | `20` | Finished jobs kept before the oldest are pruned. |
158
195
  | `maxLogLines` | `100` | Log lines returned by `logs`. |
196
+ | `maxLogBytes` | `8388608` | Bytes a log segment may reach before it rotates to `<id>.log.1`; `0` keeps logs unbounded. |
159
197
  | `killGraceMs` | `5000` | Grace before SIGTERM escalates to SIGKILL. |
160
198
  | `repaintMs` | `1000` | Status-chip refresh interval while jobs run. |
161
199
  | `sessionTtlMs` | `60000` | How long a session heartbeat is trusted before its jobs may be reaped. |
@@ -177,14 +215,26 @@ rewritten.
177
215
  reattached after its session died reports its real exit code. `exec cmd`
178
216
  replaces the shell and bypasses the trap, and Windows has no equivalent, so
179
217
  those jobs settle as `unknown`.
180
- - **A live peer session's jobs are hidden.** Records are still merged from
181
- every session file so ids stay unique and a peer's job is not reaped, but
182
- `list` and `/jobs` show only this session's jobs (a dead session's records
183
- are adopted into it). A peer's job can still be reached by an explicit id
184
- (`status`/`logs`/`kill`; `kill` adopts it).
185
- - **No stdin to running jobs.** Jobs are fire-and-observe; interactive programs
186
- (a REPL, a debugger prompt) are out of scope.
187
- - **No log rotation.** Logs are append-only files; `logs` reads a bounded tail.
218
+ - **Another session's jobs are shown, but not managed here.** Records are merged
219
+ from every session file so ids stay unique and a peer's job is not reaped, and
220
+ `list`/`/jobs` show them tagged `peer` (a dead session's records are adopted
221
+ into this one and lose the tag). The screen opens a peer's log but refuses to
222
+ kill or clear it, and the tool's `clear` refuses that record too; the tool can
223
+ still take one over by explicit id (`status`/`logs`/`kill`, where `kill`
224
+ adopts it). Chips, completion notices, and pruning stay this session's own, so
225
+ a peer's finished job waits for that session to clear it.
226
+ - **stdin is a pipe, not a TTY.** A job started with `stdin: true` can be fed
227
+ with `send`, but the child sees a pipe: line-oriented programs (a REPL, a
228
+ debugger prompt, `read`, `npm init`) work, while a full-screen program that
229
+ requires a terminal does not. Only the session holding the child's pipe can
230
+ write to it, so a reattached or peer job cannot be fed, and a `detached` job
231
+ cannot take stdin at all.
232
+ - **A detached job's log is not rotated.** A POSIX `detached` job points its
233
+ own stdout at the log file (`exec >> …`), so the file has a writer this
234
+ process does not own and cannot safely rename or truncate: the child would
235
+ keep appending to the moved inode, or lose everything. Its log therefore grows
236
+ without the `maxLogBytes` cap; `logs` still reads a bounded tail, and the file
237
+ can be emptied by hand.
188
238
  - **Windows is best-effort.** Tree-kill uses `taskkill /T`; POSIX uses process
189
239
  groups. A detached job only escapes the parent process on POSIX, where its
190
240
  shell writes the log directly; on Windows it keeps the parent pipe and is
@@ -212,9 +262,26 @@ rewritten.
212
262
 
213
263
  ## Design notes
214
264
 
265
+ - **Visibility is not ownership.** `list` shows every record in the merged
266
+ table, but each mutation keeps its own rule: the screen refuses a `peer` row,
267
+ `clear` refuses another session's record, `kill` adopts it deliberately, and
268
+ `send` has no pipe to reach it. The chips, completion notices, and pruning
269
+ stay own-only, so showing a peer never makes this session responsible for it.
215
270
  - **Shell commands only, one action-based tool.** A single `job` tool with
216
- seven actions mirrors `rewind` and keeps the model surface small. Validation
271
+ eight actions mirrors `rewind` and keeps the model surface small. Validation
217
272
  is pure and runs before any side effect.
273
+ - **The log rotates under the stream, not behind its back.** A parent-owned log
274
+ is a `WriteStream`; when the live segment reaches `maxLogBytes` the stream is
275
+ ended, the file renamed to `<id>.log.1`, and a fresh stream opened, with
276
+ chunks that arrive during the swap queued (and bounded) rather than written
277
+ into the renamed inode. `logs` fills a request from the previous segment when
278
+ the live one is shorter, so the rotation is invisible to the model and to the
279
+ `/jobs` pane. A detached job writes its own log and is exempt.
280
+ - **Stdin is an opt-in pipe.** `start` keeps the child's stdin closed by
281
+ default, so it gets EOF immediately and a `read` exits instead of blocking;
282
+ `stdin: true` swaps in a pipe that `send` writes to and `eof` closes. The pipe
283
+ belongs to this process, so it never outlives the handle that owns it, and a
284
+ `detached` job is refused rather than handed EOF when the session ends.
218
285
  - **`child_process.spawn`, not `pi.exec`.** `pi.exec` resolves on exit, the
219
286
  opposite of a background job. Jobs are spawned with a shell in their own
220
287
  process group, and signals go to the whole group so a shell and its
@@ -313,6 +380,8 @@ sanitization (`format`), the durable store (`store`), per-session reconciliation
313
380
  (`registry`), id reservation (`reservations`), exit-status recovery (`status`),
314
381
  session markers (`session`), bounded log reads and tail flags (`logs`), wait
315
382
  bookkeeping (`waiters`), the `job` tool (`tools`), the `/jobs` screen (`tui`),
316
- the runtime's session-scoped list and failure notification (`runtime`), and the
383
+ the runtime's session-scoped list and failure notification (`runtime`), `send`
384
+ to a job's stdin and the pipe's lifecycle (`runtime`), log rotation, its two
385
+ segments, and the cross-segment read (`runtime`), and the
317
386
  install/resume/shutdown lifecycle (`extension`). Tests drive a scripted
318
387
  `FakeChild` and a temp registry, so the suite runs without launching a process.
@@ -37,6 +37,7 @@ export function registerCommands(pi: ExtensionAPI, runtime: JobsRuntime): void {
37
37
  () => runtime.list(),
38
38
  theme,
39
39
  {
40
+ sessionId: () => runtime.sessionId,
40
41
  logs: (id) => runtime.logs(id, 200),
41
42
  kill: (id) => {
42
43
  runtime.kill(id);
@@ -21,6 +21,8 @@ export interface JobsConfig {
21
21
  maxJobs: number;
22
22
  /** Log lines returned to the model by the `logs` action. */
23
23
  maxLogLines: number;
24
+ /** Bytes a log segment may reach before it rotates; 0 keeps logs unbounded. */
25
+ maxLogBytes: number;
24
26
  /** Grace period before a SIGTERM escalates to SIGKILL. */
25
27
  killGraceMs: number;
26
28
  /** Status-chip refresh interval while jobs run. */
@@ -38,6 +40,7 @@ export const DEFAULT_CONFIG: JobsConfig = {
38
40
  detachedByDefault: false,
39
41
  maxJobs: 20,
40
42
  maxLogLines: 100,
43
+ maxLogBytes: 8 * 1024 * 1024,
41
44
  killGraceMs: 5000,
42
45
  repaintMs: 1000,
43
46
  sessionTtlMs: 60_000,
@@ -55,6 +58,7 @@ export function normalizeConfig(raw: Record<string, unknown> | undefined, base:
55
58
  detachedByDefault: typeof raw.detachedByDefault === "boolean" ? raw.detachedByDefault : base.detachedByDefault,
56
59
  maxJobs: clampInteger(raw.maxJobs, base.maxJobs, 1, 500),
57
60
  maxLogLines: clampInteger(raw.maxLogLines, base.maxLogLines, 1, 5000),
61
+ maxLogBytes: clampInteger(raw.maxLogBytes, base.maxLogBytes, 0, 1024 * 1024 * 1024),
58
62
  killGraceMs: clampInteger(raw.killGraceMs, base.killGraceMs, 0, 60_000),
59
63
  repaintMs: clampInteger(raw.repaintMs, base.repaintMs, 100, 10_000),
60
64
  sessionTtlMs: clampInteger(raw.sessionTtlMs, base.sessionTtlMs, 5_000, 600_000),
@@ -131,22 +131,25 @@ export function shortLabel(job: JobRecord): string {
131
131
  }
132
132
 
133
133
  /** One-line job summary shared by the model output and the `/jobs` screen. */
134
- function jobSummary(job: JobRecord, now: number): string {
134
+ function jobSummary(job: JobRecord, now: number, ownSessionId?: string): string {
135
135
  const parts = [`${job.id}`, statusWord(job), formatDuration(elapsedMs(job, now))];
136
136
  if (job.status === "running" && job.pid) parts.push(`pid ${job.pid}`);
137
137
  parts.push(shortLabel(job));
138
+ // A peer session's job is shown but belongs to it: name the owner so the
139
+ // `clear` refusal reads as intent rather than a bug.
140
+ if (ownSessionId !== undefined && job.sessionId !== ownSessionId) parts.push("(another session)");
138
141
  return parts.join(" ");
139
142
  }
140
143
 
141
144
  /** Model-facing list of jobs, or a note when there are none. */
142
- export function formatJobList(jobs: JobRecord[], now = Date.now()): string {
145
+ export function formatJobList(jobs: JobRecord[], now = Date.now(), ownSessionId?: string): string {
143
146
  if (jobs.length === 0) return "No jobs.";
144
- const lines = jobs.map((job) => ` ${jobSummary(job, now)}`);
147
+ const lines = jobs.map((job) => ` ${jobSummary(job, now, ownSessionId)}`);
145
148
  return `Jobs (${jobs.length}):\n${lines.join("\n")}`;
146
149
  }
147
150
 
148
151
  /** Model-facing detail for one job, including its last output line. */
149
- export function formatJobStatus(job: JobRecord, now = Date.now()): string {
152
+ export function formatJobStatus(job: JobRecord, now = Date.now(), ownSessionId?: string): string {
150
153
  const lines = [
151
154
  `${job.id}: ${statusWord(job)}`,
152
155
  `command: ${sanitizeLogLine(job.command)}`,
@@ -155,6 +158,7 @@ export function formatJobStatus(job: JobRecord, now = Date.now()): string {
155
158
  ];
156
159
  if (job.pid) lines.push(`pid: ${job.pid}`);
157
160
  if (job.logPath) lines.push(`log: ${sanitizeLogLine(job.logPath)}`);
161
+ if (ownSessionId !== undefined && job.sessionId !== ownSessionId) lines.push("session: another session");
158
162
  if (job.lastLine) lines.push(`last: ${job.lastLine}`);
159
163
  return lines.join("\n");
160
164
  }
@@ -192,6 +196,13 @@ export function formatCallText(action: string, args: Record<string, unknown>, ar
192
196
  }
193
197
  case "kill":
194
198
  return `kill ${target ?? ""}`.trim();
199
+ case "send": {
200
+ if (!target) return "send";
201
+ const input = typeof args.input === "string" ? sanitizeLogLine(args.input) : "";
202
+ const preview = input ? truncateToWidth(input, MAX_DISPLAY_LABEL, "…") : "";
203
+ const parts = [preview, args.eof === true ? "EOF" : ""].filter(Boolean).join(" + ");
204
+ return parts ? `send ${target} → ${parts}` : `send ${target}`;
205
+ }
195
206
  case "logs":
196
207
  case "status":
197
208
  case "wait":
@@ -268,11 +279,12 @@ export function formatJobOutcomeLine(job: JobRecord, theme: Theme, now = Date.no
268
279
  }
269
280
 
270
281
  /** Unstyled, sanitized detail lines for the `/jobs` focus pane. */
271
- export function formatJobDetail(job: JobRecord, now = Date.now()): string[] {
282
+ export function formatJobDetail(job: JobRecord, now = Date.now(), ownSessionId?: string): string[] {
272
283
  const lines = [
273
284
  sanitizeLogLine(`command: ${job.command}`),
274
285
  sanitizeLogLine(`cwd: ${job.cwd} · ${jobOutcome(job)} · ${formatDuration(elapsedMs(job, now))}`),
275
286
  ];
287
+ if (ownSessionId !== undefined && job.sessionId !== ownSessionId) lines.push("owner: another session");
276
288
  const last = sanitizeLogLine(job.lastLine);
277
289
  if (last) lines.push(`last: ${last}`);
278
290
  return lines;
@@ -14,11 +14,26 @@
14
14
  import { execFileSync, spawn as nodeSpawn, spawnSync } from "node:child_process";
15
15
  import type { Readable } from "node:stream";
16
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
+
17
30
  /** The subset of a spawned child the runtime uses. */
18
31
  export interface SpawnedProcess {
19
32
  pid?: number;
20
33
  stdout: Readable | null;
21
34
  stderr: Readable | null;
35
+ /** Present only when spawned with `stdin: true`. */
36
+ stdin?: StdinSink | null;
22
37
  on(event: "close", listener: (code: number | null, signal: NodeJS.Signals | null) => void): unknown;
23
38
  on(event: "error", listener: (error: Error) => void): unknown;
24
39
  kill(signal?: NodeJS.Signals): boolean;
@@ -33,6 +48,8 @@ export type SpawnFn = (
33
48
  env?: NodeJS.ProcessEnv;
34
49
  /** The command writes its own output to the log; keep no parent pipe. */
35
50
  detachOutput?: boolean;
51
+ /** Give the child a stdin pipe instead of `/dev/null` (no immediate EOF). */
52
+ stdin?: boolean;
36
53
  },
37
54
  ) => SpawnedProcess;
38
55
 
@@ -51,13 +68,18 @@ export type KillTreeFn = (pid: number, signal: NodeJS.Signals, force?: boolean)
51
68
  export type StartTokenFn = (pid: number) => string | undefined;
52
69
 
53
70
  /** Default spawn: a shell command in its own process group. A detached job
54
- * redirects its own output to the log, so it needs no pipe to this process. */
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. */
55
74
  export const defaultSpawn: SpawnFn = (command, options) =>
56
75
  nodeSpawn(command, {
57
76
  cwd: options.cwd,
58
77
  shell: true,
59
78
  detached: process.platform !== "win32",
60
- stdio: options.detachOutput ? ["ignore", "ignore", "ignore"] : ["ignore", "pipe", "pipe"],
79
+ stdio: [
80
+ options.stdin ? "pipe" : "ignore",
81
+ ...(options.detachOutput ? (["ignore", "ignore"] as const) : (["pipe", "pipe"] as const)),
82
+ ],
61
83
  env: options.env,
62
84
  });
63
85
 
@@ -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,10 +1,11 @@
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";
@@ -19,6 +20,7 @@ import {
19
20
  formatJobList,
20
21
  formatJobOutcomeLine,
21
22
  formatJobStatus,
23
+ shortLabel,
22
24
  } from "./format.ts";
23
25
  import type { JobsRuntime } from "./runtime.ts";
24
26
  import { JobParams, normalizeCall, type JobArgs, type JobCall } from "./schema.ts";
@@ -49,6 +51,7 @@ function errorResult(
49
51
  const GUIDELINES = [
50
52
  "Use `job start` for long-running or background commands (builds, test suites, servers, watchers) so the turn is not blocked.",
51
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.",
52
55
  "Use `job kill` to stop a running job and `job clear` to drop finished ones.",
53
56
  ];
54
57
 
@@ -57,9 +60,9 @@ export function registerTools(pi: ExtensionAPI, runtime: JobsRuntime): void {
57
60
  name: TOOL_NAME,
58
61
  label: "Job",
59
62
  description:
60
- "Manage background shell-command jobs. Actions: `start` (returns a job id immediately), `list`, `status`/`logs`/" +
61
- "`kill`/`wait` (by id), and `clear`. Jobs are killed when the session ends unless started `detached`.",
62
- 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).",
63
66
  promptGuidelines: GUIDELINES,
64
67
  parameters: JobParams,
65
68
  annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
@@ -83,6 +86,7 @@ export function registerTools(pi: ExtensionAPI, runtime: JobsRuntime): void {
83
86
  label: call.label,
84
87
  wake: call.wake,
85
88
  detached: call.detached,
89
+ stdin: call.stdin,
86
90
  timeoutMs: call.timeoutMs,
87
91
  },
88
92
  ctx,
@@ -101,8 +105,8 @@ export function registerTools(pi: ExtensionAPI, runtime: JobsRuntime): void {
101
105
  case "list": {
102
106
  const jobs = runtime.list();
103
107
  return {
104
- content: [{ type: "text" as const, text: formatJobList(jobs) }],
105
- 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,
106
110
  };
107
111
  }
108
112
 
@@ -110,8 +114,8 @@ export function registerTools(pi: ExtensionAPI, runtime: JobsRuntime): void {
110
114
  const job = runtime.get(call.id);
111
115
  if (!job) return errorResult("status", `no job "${call.id}".`);
112
116
  return {
113
- content: [{ type: "text" as const, text: formatJobStatus(job) }],
114
- 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,
115
119
  };
116
120
  }
117
121
 
@@ -129,6 +133,30 @@ export function registerTools(pi: ExtensionAPI, runtime: JobsRuntime): void {
129
133
  };
130
134
  }
131
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
+
132
160
  case "kill": {
133
161
  const before = runtime.get(call.id);
134
162
  if (!before) return errorResult("kill", `no job "${call.id}".`);
@@ -258,7 +286,12 @@ export function registerTools(pi: ExtensionAPI, runtime: JobsRuntime): void {
258
286
  }
259
287
  const ordered = [...details.jobs].sort(compareJobs);
260
288
  const shown = options.expanded ? ordered : ordered.slice(0, RESULT_ROWS);
261
- 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
+ };
262
295
  const view = context.lastComponent instanceof JobResult ? context.lastComponent : new JobResult(input, theme);
263
296
  view.setInput(input, theme);
264
297
  return view;
@@ -280,6 +313,11 @@ export function registerTools(pi: ExtensionAPI, runtime: JobsRuntime): void {
280
313
  line += paint(theme, "warn", " · wait cancelled; job still running");
281
314
  } else if (details.action === "kill" && details.signalled) {
282
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)}`);
283
321
  } else if (details.action === "start") {
284
322
  const badges: string[] = [];
285
323
  if (details.job.detached) badges.push("detached");
@@ -51,25 +51,28 @@ const LOG_CHROME_ROWS = 6;
51
51
  const jobId = (job: JobRecord): string => job.id;
52
52
 
53
53
  /** One job row: selection marker, status glyph, id, label, and a right-aligned
54
- * elapsed value. `statusRow` owns the clipping and the alignment. */
54
+ * elapsed value, tagged `peer` when another session owns it. `statusRow` owns
55
+ * the clipping and the alignment. */
55
56
  function jobRow(
56
57
  job: JobRecord,
57
58
  theme: Theme,
58
59
  width: number,
59
- options: Pick<StatusRow, "selected" | "marker"> = {},
60
+ options: Pick<StatusRow, "selected" | "marker"> & { foreign?: boolean } = {},
60
61
  ): string {
62
+ const elapsed = formatDuration(elapsedMs(job, Date.now()));
61
63
  return statusRow(theme, width, {
62
64
  ...options,
63
65
  glyph: statusGlyphChar(job.status),
64
66
  glyphRole: statusRole(job.status),
65
67
  label: `${job.id} ${shortLabel(job)}`,
66
- trailing: formatDuration(elapsedMs(job, Date.now())),
68
+ // The tag rides the right-aligned cell, which survives a narrow terminal.
69
+ trailing: options.foreign ? `${elapsed}${SEPARATORS.item}peer` : elapsed,
67
70
  });
68
71
  }
69
72
 
70
73
  /** Input for the transcript result: a themed job list or a log tail. */
71
74
  export type JobResultInput =
72
- | { kind: "list"; jobs: JobRecord[]; more: number }
75
+ | { kind: "list"; jobs: JobRecord[]; more: number; sessionId?: string }
73
76
  | { kind: "logs"; lines: string[]; earlier: number };
74
77
 
75
78
  /**
@@ -99,7 +102,10 @@ export class JobResult implements Component {
99
102
  // A leading blank line separates the result from the call header.
100
103
  const lines: string[] = [""];
101
104
  if (this.input.kind === "list") {
102
- for (const job of this.input.jobs) lines.push(jobRow(job, this.theme, w, { marker: false }));
105
+ for (const job of this.input.jobs) {
106
+ const foreign = this.input.sessionId !== undefined && job.sessionId !== this.input.sessionId;
107
+ lines.push(jobRow(job, this.theme, w, { marker: false, foreign }));
108
+ }
103
109
  if (this.input.more > 0) {
104
110
  const note = `${paint(this.theme, "faint", `… ${this.input.more} more`)} ${expandHint(this.theme)}`;
105
111
  lines.push(clip(` ${note}`, w));
@@ -118,6 +124,8 @@ export class JobResult implements Component {
118
124
  }
119
125
 
120
126
  export interface JobListCallbacks {
127
+ /** This session's id: a record with another id belongs to a peer session. */
128
+ sessionId(): string;
121
129
  /** Current sanitized log lines for a job, or undefined when unknown. */
122
130
  logs(id: string): { lines: string[]; more?: boolean } | undefined;
123
131
  kill(id: string): void;
@@ -175,6 +183,11 @@ export class JobListComponent implements Component {
175
183
  return [...this.jobs()].sort(compareJobs);
176
184
  }
177
185
 
186
+ /** Whether another session owns this job, so the screen leaves it alone. */
187
+ private isForeign(job: JobRecord): boolean {
188
+ return job.sessionId !== this.callbacks.sessionId();
189
+ }
190
+
178
191
  /** Focus job `next` (clamped) and keep it visible. */
179
192
  private select(next: number): void {
180
193
  const jobs = this.ordered;
@@ -256,7 +269,10 @@ export class JobListComponent implements Component {
256
269
  else if (data === "d" || data === "K") {
257
270
  const job = ordered[index];
258
271
  if (!job) return;
259
- if (job.status === "running") {
272
+ if (this.isForeign(job)) {
273
+ // Another session's job is shown here, not managed from here.
274
+ this.notice = `${job.id} is another session's job; not killed.`;
275
+ } else if (job.status === "running") {
260
276
  this.confirm = { kind: "kill", id: job.id, label: shortLabel(job) };
261
277
  } else {
262
278
  // A finished job has nothing to kill; say so instead of a silent no-op.
@@ -264,7 +280,9 @@ export class JobListComponent implements Component {
264
280
  }
265
281
  this.requestRender();
266
282
  } else if (data === "x") {
267
- const finished = ordered.filter((job) => job.status !== "running");
283
+ // Only this session's finished jobs are ours to clear, so the prompt and the
284
+ // result agree.
285
+ const finished = ordered.filter((job) => !this.isForeign(job) && job.status !== "running");
268
286
  if (finished.length > 0) {
269
287
  this.confirm = {
270
288
  kind: "clear",
@@ -320,10 +338,11 @@ export class JobListComponent implements Component {
320
338
  if (this.pane.handleKey(data)) this.requestRender();
321
339
  }
322
340
 
323
- private summary(counts: JobCounts, width: number): string {
341
+ private summary(counts: JobCounts, width: number, foreign: number): string {
324
342
  const cells = [paint(this.theme, "meta", `${counts.total} job${counts.total === 1 ? "" : "s"}`)];
325
343
  if (counts.running > 0) cells.push(paint(this.theme, "meta", `${counts.running} running`));
326
344
  if (counts.failed > 0) cells.push(paint(this.theme, "bad", `${counts.failed} failed`));
345
+ if (foreign > 0) cells.push(paint(this.theme, "faint", `${foreign} peer`));
327
346
  const separator = paint(this.theme, "meta", SEPARATORS.item);
328
347
  return clip(` ${fitCells(cells, Math.max(1, width - 2), separator)}`, width);
329
348
  }
@@ -338,7 +357,7 @@ export class JobListComponent implements Component {
338
357
  this.cursor.clamp(jobs, jobId);
339
358
  const index = this.cursor.indexOf(jobs, jobId);
340
359
  const focused = jobs[index];
341
- const detail = focused ? formatJobDetail(focused, Date.now()) : [];
360
+ const detail = focused ? formatJobDetail(focused, Date.now(), this.callbacks.sessionId()) : [];
342
361
  this.cursor.visible = fitRows(
343
362
  this.viewportRowsSource,
344
363
  jobs.length,
@@ -354,8 +373,10 @@ export class JobListComponent implements Component {
354
373
  body.push(...emptyState(this.theme, w, { label: "No background jobs." }));
355
374
  } else {
356
375
  const end = Math.min(jobs.length, this.cursor.scroll + this.cursor.visible);
357
- for (let i = this.cursor.scroll; i < end; i++)
358
- body.push(jobRow(jobs[i], this.theme, w, { selected: i === index }));
376
+ for (let i = this.cursor.scroll; i < end; i++) {
377
+ const row = jobs[i];
378
+ body.push(jobRow(row, this.theme, w, { selected: i === index, foreign: this.isForeign(row) }));
379
+ }
359
380
  if (this.cursor.scroll > 0 || end < jobs.length) {
360
381
  range = formatRange(this.theme, w, { start: this.cursor.scroll, end, total: jobs.length });
361
382
  }
@@ -376,7 +397,7 @@ export class JobListComponent implements Component {
376
397
 
377
398
  return dockListFrame({
378
399
  header: titleRule(this.theme, w, "Jobs"),
379
- summary: this.summary(jobCounts(jobs), w),
400
+ summary: this.summary(jobCounts(jobs), w, jobs.filter((job) => this.isForeign(job)).length),
380
401
  body,
381
402
  range,
382
403
  // The detail pane is separated from the list by a blank line.
@@ -10,7 +10,7 @@ export const JOB_STATUSES = ["running", "exited", "killed", "failed", "unknown"]
10
10
 
11
11
  export type JobStatus = (typeof JOB_STATUSES)[number];
12
12
 
13
- export const JOB_ACTIONS = ["start", "list", "status", "logs", "kill", "wait", "clear"] as const;
13
+ export const JOB_ACTIONS = ["start", "list", "status", "logs", "send", "kill", "wait", "clear"] as const;
14
14
 
15
15
  export type JobAction = (typeof JOB_ACTIONS)[number];
16
16
 
@@ -40,6 +40,8 @@ export interface JobRecord {
40
40
  detached: boolean;
41
41
  /** Wake the agent with one triggered turn when the job finishes. */
42
42
  wake: boolean;
43
+ /** Started with a stdin pipe, so `send` can write to it while this session holds the child. */
44
+ stdin: boolean;
43
45
  /** Session that started the job. */
44
46
  sessionId: string;
45
47
  /** Whether the completion has already been reported to the model. */
@@ -83,10 +85,16 @@ export interface JobDetails {
83
85
  signalled?: boolean;
84
86
  /** Signal sent by `kill`; defaults to SIGTERM. */
85
87
  signal?: KillSignal;
88
+ /** Characters of `input` written by `send`, excluding the added newline. */
89
+ sent?: number;
90
+ /** True when `send` ended the job's stdin (EOF). */
91
+ stdinClosed?: boolean;
86
92
  /** True when `wait` hit its timeout. */
87
93
  timedOut?: boolean;
88
94
  /** True when `wait` was cancelled by the tool's abort signal. */
89
95
  cancelled?: boolean;
96
+ /** Session that made the call, so a transcript can mark another session's rows. */
97
+ sessionId?: string;
90
98
  /** Model-readable validation message when the call was rejected. */
91
99
  error?: string;
92
100
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gavin-hu/my-pi-agent",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "Personal Pi customizations, packaged as a Pi package: worktree isolation and rewind snapshots, question, todo, goal, plan, subagent, job, watch, web-access, file-browse, file-open, document, memory, wechat, and bytefly.",
5
5
  "keywords": [
6
6
  "pi-package",