@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 +0 -0
- package/extensions/job/README.md +92 -23
- package/extensions/job/commands.ts +1 -0
- package/extensions/job/config.ts +4 -0
- package/extensions/job/format.ts +17 -5
- package/extensions/job/process.ts +24 -2
- package/extensions/job/registry.ts +3 -0
- package/extensions/job/runtime.ts +253 -18
- package/extensions/job/schema.ts +34 -0
- package/extensions/job/store.ts +13 -1
- package/extensions/job/tools.ts +50 -12
- package/extensions/job/tui.ts +33 -12
- package/extensions/job/types.ts +9 -1
- package/package.json +1 -1
package/assets/chrome.png
CHANGED
|
Binary file
|
package/extensions/job/README.md
CHANGED
|
@@ -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
|
-
| `
|
|
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`,
|
|
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
|
|
120
|
-
|
|
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.
|
|
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`)
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
- **
|
|
181
|
-
every session file so ids stay unique and a peer's job is not reaped,
|
|
182
|
-
`list
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
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`),
|
|
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.
|
package/extensions/job/config.ts
CHANGED
|
@@ -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),
|
package/extensions/job/format.ts
CHANGED
|
@@ -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:
|
|
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 {
|
|
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
|
-
|
|
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: () =>
|
|
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 = {
|
|
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
|
|
647
|
-
|
|
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,
|
|
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
|
|
653
|
-
lines:
|
|
654
|
-
truncated: formatted.truncated ||
|
|
655
|
-
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,
|
package/extensions/job/schema.ts
CHANGED
|
@@ -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)) {
|
package/extensions/job/store.ts
CHANGED
|
@@ -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
|
};
|
package/extensions/job/tools.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Model-facing registration for the `job` tool.
|
|
3
3
|
*
|
|
4
|
-
* One tool with
|
|
5
|
-
* `wait`, `clear`. A background process can outlive the tool call, so
|
|
6
|
-
* returns immediately with an id; the model polls with `status`/`logs` or
|
|
7
|
-
* blocks with `wait`.
|
|
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
|
|
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 = {
|
|
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");
|
package/extensions/job/tui.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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)
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
package/extensions/job/types.ts
CHANGED
|
@@ -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.
|
|
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",
|