@gavin-hu/my-pi-agent 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. package/README.md +28 -2
  2. package/README.zh-CN.md +27 -2
  3. package/assets/bytefly.schema.json +17 -1
  4. package/assets/chrome.png +0 -0
  5. package/assets/job.png +0 -0
  6. package/assets/plan-review.png +0 -0
  7. package/assets/question.png +0 -0
  8. package/assets/rewind.png +0 -0
  9. package/assets/todo.png +0 -0
  10. package/assets/tool-renderings.png +0 -0
  11. package/assets/tools/ask_user_question.png +0 -0
  12. package/assets/tools/bash.png +0 -0
  13. package/assets/tools/edit.png +0 -0
  14. package/assets/tools/enter_plan_mode.png +0 -0
  15. package/assets/tools/enter_worktree.png +0 -0
  16. package/assets/tools/exit_plan_mode.png +0 -0
  17. package/assets/tools/exit_worktree.png +0 -0
  18. package/assets/tools/find.png +0 -0
  19. package/assets/tools/goal.png +0 -0
  20. package/assets/tools/grep.png +0 -0
  21. package/assets/tools/job.png +0 -0
  22. package/assets/tools/list_worktrees.png +0 -0
  23. package/assets/tools/ls.png +0 -0
  24. package/assets/tools/memory.png +0 -0
  25. package/assets/tools/notice.png +0 -0
  26. package/assets/tools/open_path.png +0 -0
  27. package/assets/tools/prune_worktrees.png +0 -0
  28. package/assets/tools/read.png +0 -0
  29. package/assets/tools/read_doc.png +0 -0
  30. package/assets/tools/run_doctor.png +0 -0
  31. package/assets/tools/send_wechat.png +0 -0
  32. package/assets/tools/set_plan_steps.png +0 -0
  33. package/assets/tools/subagent.png +0 -0
  34. package/assets/tools/todo.png +0 -0
  35. package/assets/tools/turn-rule.png +0 -0
  36. package/assets/tools/user-message.png +0 -0
  37. package/assets/tools/watch.png +0 -0
  38. package/assets/tools/web_fetch.png +0 -0
  39. package/assets/tools/web_search.png +0 -0
  40. package/assets/tools/write.png +0 -0
  41. package/assets/tools/write_plan.png +0 -0
  42. package/extensions/bytefly/README.md +261 -36
  43. package/extensions/bytefly/block/component.ts +282 -0
  44. package/extensions/bytefly/block/config.ts +24 -0
  45. package/extensions/bytefly/block/fallback.ts +120 -0
  46. package/extensions/bytefly/block/register.ts +164 -0
  47. package/extensions/bytefly/chrome.ts +97 -0
  48. package/extensions/bytefly/doctor/tools.ts +5 -2
  49. package/extensions/bytefly/header/component.ts +21 -10
  50. package/extensions/bytefly/header/config.ts +0 -3
  51. package/extensions/bytefly/header/register.ts +6 -1
  52. package/extensions/bytefly/index.ts +32 -12
  53. package/extensions/bytefly/message/config.ts +15 -0
  54. package/extensions/bytefly/message/format.ts +42 -0
  55. package/extensions/bytefly/message/register.ts +15 -0
  56. package/extensions/bytefly/notice/component.ts +107 -0
  57. package/extensions/bytefly/notice/config.ts +41 -0
  58. package/extensions/bytefly/notice/register.ts +27 -0
  59. package/extensions/bytefly/pad.ts +42 -0
  60. package/extensions/bytefly/separator/component.ts +2 -1
  61. package/extensions/bytefly/separator/format.ts +17 -10
  62. package/extensions/bytefly/separator/register.ts +3 -2
  63. package/extensions/bytefly/working/config.ts +20 -0
  64. package/extensions/bytefly/working/format.ts +25 -0
  65. package/extensions/bytefly/working/register.ts +88 -0
  66. package/extensions/document/README.md +11 -4
  67. package/extensions/document/paging.ts +10 -10
  68. package/extensions/document/render.ts +7 -7
  69. package/extensions/file-browse/README.md +3 -1
  70. package/extensions/file-browse/client.ts +9 -2
  71. package/extensions/file-browse/git.ts +4 -7
  72. package/extensions/file-browse/html.ts +27 -17
  73. package/extensions/file-open/README.md +6 -0
  74. package/extensions/goal/README.md +16 -10
  75. package/extensions/goal/format.ts +7 -20
  76. package/extensions/goal/tools.ts +5 -17
  77. package/extensions/goal/tui.ts +12 -40
  78. package/extensions/job/README.md +113 -35
  79. package/extensions/job/commands.ts +1 -0
  80. package/extensions/job/config.ts +4 -0
  81. package/extensions/job/format.ts +19 -7
  82. package/extensions/job/index.ts +25 -20
  83. package/extensions/job/process.ts +24 -2
  84. package/extensions/job/registry.ts +3 -0
  85. package/extensions/job/runtime.ts +253 -18
  86. package/extensions/job/schema.ts +34 -0
  87. package/extensions/job/store.ts +13 -1
  88. package/extensions/job/tools.ts +55 -15
  89. package/extensions/job/tui.ts +41 -15
  90. package/extensions/job/types.ts +9 -1
  91. package/extensions/memory/README.md +6 -0
  92. package/extensions/memory/format.ts +5 -3
  93. package/extensions/plan/README.md +32 -12
  94. package/extensions/plan/tools.ts +20 -5
  95. package/extensions/plan/web-client.ts +58 -17
  96. package/extensions/plan/web-page.ts +34 -9
  97. package/extensions/plan/web-router.ts +40 -9
  98. package/extensions/plan/web.ts +22 -2
  99. package/extensions/question/README.md +13 -1
  100. package/extensions/question/answers.ts +25 -18
  101. package/extensions/question/tools.ts +11 -6
  102. package/extensions/rewind/README.md +2 -0
  103. package/extensions/rewind/format.ts +6 -1
  104. package/extensions/rewind/index.ts +3 -1
  105. package/extensions/rewind/snapshot-run.ts +1 -1
  106. package/extensions/subagent/README.md +6 -0
  107. package/extensions/subagent/index.ts +1 -1
  108. package/extensions/subagent/render.ts +56 -29
  109. package/extensions/todo/README.md +19 -12
  110. package/extensions/todo/format.ts +17 -25
  111. package/extensions/todo/tools.ts +2 -1
  112. package/extensions/todo/tui.ts +3 -2
  113. package/extensions/watch/README.md +14 -6
  114. package/extensions/watch/format.ts +2 -2
  115. package/extensions/watch/index.ts +28 -23
  116. package/extensions/watch/tools.ts +5 -3
  117. package/extensions/watch/tui.ts +12 -7
  118. package/extensions/web-access/README.md +16 -7
  119. package/extensions/web-access/fetch/tool.ts +1 -1
  120. package/extensions/web-access/search/tool.ts +3 -3
  121. package/extensions/wechat/README.md +6 -0
  122. package/extensions/worktree/README.md +6 -0
  123. package/lib/README.md +2 -1
  124. package/lib/format.ts +17 -0
  125. package/lib/notice.ts +87 -0
  126. package/lib/tui/chrome.ts +8 -4
  127. package/lib/tui/cursor.ts +2 -1
  128. package/lib/tui/glyphs.ts +10 -11
  129. package/lib/tui/indent.ts +166 -0
  130. package/lib/tui/index.ts +3 -1
  131. package/lib/tui/rows.ts +39 -5
  132. package/lib/tui/rules.ts +9 -3
  133. package/lib/tui/tokens.ts +14 -0
  134. package/lib/web/base.ts +2 -1
  135. package/lib/web/components.ts +3 -1
  136. package/lib/web/layout.ts +5 -1
  137. package/package.json +7 -3
  138. package/themes/bytefly-dark.json +7 -8
  139. package/themes/bytefly-light.json +10 -11
  140. package/assets/bytefly-chrome.png +0 -0
@@ -1,20 +1,16 @@
1
1
  /**
2
2
  * Terminal rendering for the goal.
3
3
  *
4
- * Two surfaces share one vocabulary. `goalWidgetLines` renders the persistent
5
- * above-editor widget as a single label-first rail line (`Goal · active ·
6
- * <objective>`, dimmed once achieved), matching the `todo` and `jobs` widgets.
7
- * `goalRailLines` renders the transcript result as a header (`Goal · active`)
8
- * plus the objective wrapped and led by the status glyph, with continuation rows
9
- * aligned under the text; it matches the todo transcript result's indent + glyph
10
- * grammar so the two read as a pair.
11
- * `GoalResult` wraps `goalRailLines` uncapped for the transcript.
4
+ * Two surfaces share one vocabulary. The persistent above-editor widget is a
5
+ * single label-first rail line (`Goal · active · <objective>`, dimmed once
6
+ * achieved), matching the `todo` and `jobs` widgets. The transcript result is
7
+ * the status header alone (`Goal · active`): the call line already names the
8
+ * objective, so the result only states the new status.
12
9
  */
13
10
 
14
11
  import type { Theme } from "@earendil-works/pi-coding-agent";
15
- import { truncateToWidth, visibleWidth, wrapTextWithAnsi, type Component } from "@earendil-works/pi-tui";
16
- import { BODY_INDENT, GLYPH_GAP } from "../../lib/tui/glyphs.ts";
17
- import { goalGlyph, goalHeader, goalLine } from "./format.ts";
12
+ import { truncateToWidth, type Component } from "@earendil-works/pi-tui";
13
+ import { goalHeader, goalLine } from "./format.ts";
18
14
  import type { AchievedStyle, Goal } from "./types.ts";
19
15
 
20
16
  /** Widget key used with `ctx.ui.setWidget()`. */
@@ -25,27 +21,6 @@ export interface GoalWidgetOptions {
25
21
  achieved?: AchievedStyle;
26
22
  }
27
23
 
28
- /**
29
- * Rows for the transcript goal rail: the themed header plus `body` wrapped and
30
- * led by the status glyph. Continuation rows are padded to the text column.
31
- */
32
- export function goalRailLines(goal: Goal, body: string, theme: Theme, width: number): string[] {
33
- const w = Math.max(1, width);
34
- const glyph = goalGlyph(goal, theme);
35
- const glyphWidth = visibleWidth(glyph);
36
- const textColumn = BODY_INDENT + glyphWidth + GLYPH_GAP;
37
- const inner = Math.max(1, w - textColumn);
38
-
39
- const wrapped = wrapTextWithAnsi(body, inner);
40
- const pad = " ".repeat(BODY_INDENT);
41
- const continuation = " ".repeat(textColumn);
42
- const lines = [goalHeader(goal, theme)];
43
- wrapped.forEach((line, index) => {
44
- lines.push(index === 0 ? `${pad}${glyph}${" ".repeat(GLYPH_GAP)}${line}` : `${continuation}${line}`);
45
- });
46
- return lines.map((line) => truncateToWidth(line, w, "…"));
47
- }
48
-
49
24
  /** Rows for the goal widget: a single rail line, or nothing when hidden. */
50
25
  export function goalWidgetLines(goal: Goal, theme: Theme, width: number, options: GoalWidgetOptions = {}): string[] {
51
26
  const w = Math.max(1, width);
@@ -68,30 +43,27 @@ export class GoalWidget implements Component {
68
43
  }
69
44
  }
70
45
 
71
- /** Transcript result block: the rail, uncapped, with the themed body. */
46
+ /** Transcript result block: the status header, separated from the call line. */
72
47
  export class GoalResult implements Component {
73
48
  private goal: Goal;
74
- private body: string;
75
49
  private theme: Theme;
76
50
 
77
- constructor(goal: Goal, body: string, theme: Theme) {
51
+ constructor(goal: Goal, theme: Theme) {
78
52
  this.goal = goal;
79
- this.body = body;
80
53
  this.theme = theme;
81
54
  }
82
55
 
83
56
  /** Update in place so the transcript can reuse this component across renders. */
84
- update(goal: Goal, body: string, theme: Theme): void {
57
+ update(goal: Goal, theme: Theme): void {
85
58
  this.goal = goal;
86
- this.body = body;
87
59
  this.theme = theme;
88
60
  }
89
61
 
90
62
  invalidate(): void {}
91
63
 
92
64
  render(width: number): string[] {
93
- // The leading blank line separates the rail from the call header, matching
65
+ // The leading blank line separates the header from the call line, matching
94
66
  // the built-ins' header/output gap.
95
- return ["", ...goalRailLines(this.goal, this.body, this.theme, Math.max(1, width))];
67
+ return ["", truncateToWidth(goalHeader(this.goal, this.theme), Math.max(1, width), "…")];
96
68
  }
97
69
  }
@@ -5,18 +5,34 @@ the background and manage them without blocking the turn. A job starts
5
5
  immediately and keeps running across tool calls; poll it, tail its output, wait
6
6
  for it, or kill it.
7
7
 
8
+ <img src="../../assets/job.png" alt="The /jobs screen: a summary line, the job rows with status glyphs and elapsed times, and the focused job's command, cwd, and last output line" width="100%">
9
+
8
10
  ```
9
11
  pi --extension ./extensions/job # load just this extension
10
12
  pi -e . # load the whole @gavin-hu/my-pi-agent package
11
13
  pi install ./ # install the package
12
14
  ```
13
15
 
16
+ ## Tool rendering
17
+
18
+ How the `job` call and its result look in the transcript — the call line as a headline and the body indented under it:
19
+
20
+ <img src="../../assets/tools/job.png" alt="The job tool's result: the call line and an indented body with a finished and a failed job row, each with its status glyph and elapsed time" width="100%">
21
+
14
22
  ## What it does
15
23
 
16
24
  - Registers one model-callable tool, `job`, with actions `start`, `list`,
17
- `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.
18
29
  - Runs each command in its own process group, streaming stdout and stderr to an
19
- 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.
20
36
  - Shows a `▸ N` running status chip and a separate `✗ N` unreported-failure
21
37
  chip for this session's jobs, plus a `/jobs` screen for selecting, tailing,
22
38
  killing, and clearing.
@@ -32,14 +48,18 @@ pi install ./ # install the package
32
48
  | Field | Value |
33
49
  |---|---|
34
50
  | `name` | `job` |
35
- | `action` | `start` \| `list` \| `status` \| `logs` \| `kill` \| `wait` \| `clear` |
51
+ | `action` | `start` \| `list` \| `status` \| `logs` \| `send` \| `kill` \| `wait` \| `clear` |
36
52
  | `command` | Shell command to run (`start`). |
37
53
  | `cwd` | Working directory (`start`); defaults to the session cwd. |
38
54
  | `label` | Short display label (`start`); defaults to the command. |
39
55
  | `wake` | Wake the agent with one triggered turn when it finishes (`start`). |
40
56
  | `detached` | Leave it running when the session ends (`start`). |
41
- | `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`). |
42
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`). |
43
63
  | `signal` | `SIGTERM` \| `SIGKILL` \| `SIGINT` (`kill`; default `SIGTERM`). |
44
64
  | `timeoutMs` | `start`: kill the job after this many ms. `wait`: how long to block (default 30s). |
45
65
  | `all` | With `clear`, also remove running jobs. |
@@ -60,20 +80,38 @@ leaves the job running. Validation is pure and runs before any side effect, so
60
80
  `codemode` callers that bypass the TypeBox schema cannot start a process with
61
81
  missing arguments.
62
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
+
63
98
  Each result carries a `JobDetails` object in tool-result `details`:
64
99
 
65
100
  ```
66
101
  JobDetails {
67
102
  action: JobAction
68
- job?: JobRecord // start/status/logs/kill/wait
103
+ job?: JobRecord // start/status/logs/send/kill/wait
69
104
  jobs?: JobRecord[] // list/clear
70
105
  logs?: string // sanitized tail for `logs`
71
106
  truncated?: boolean
72
107
  cleared?: number
73
108
  signalled?: boolean // kill sent a signal to a running job
74
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)
75
112
  timedOut?: boolean // wait hit its timeout
76
113
  cancelled?: boolean // wait was aborted; job still running
114
+ sessionId?: string // list/status: who made the call, for tagging peers
77
115
  error?: string
78
116
  }
79
117
  ```
@@ -81,15 +119,15 @@ JobDetails {
81
119
  ### Transcript
82
120
 
83
121
  The call line names the action and its target. The result is themed and reuses
84
- the slot's component: `list` draws the same row rail as `/jobs`, `logs` draws a
122
+ the slot's component: `list` draws the same rows as `/jobs`, `logs` draws a
85
123
  blank line and the sanitized tail, and a single-job action draws one outcome
86
124
  line. A `kill` that sent a signal appends it (`· SIGTERM sent`) instead of
87
125
  showing the job's pre-kill running state, a `start` badges `detached` and
88
- `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.`
89
128
 
90
129
  ```
91
130
  job list
92
-
93
131
  ▸ j1 tests 3.2s
94
132
  ✗ j2 build 0.4s
95
133
  ```
@@ -109,23 +147,29 @@ job status j1
109
147
  ### Command: `/jobs`
110
148
 
111
149
  `/jobs` opens an interactive list in the TUI, and prints a text summary
112
- elsewhere. The list is **session-scoped**: it shows the jobs this session
113
- 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
114
152
  the screen: `↑`/`↓` or `j`/`k` select, `PgUp`/`PgDn` page, `Home`/`End` jump,
115
153
  `Enter`/`l` opens the log pane, `d` (or `K`) kills the selected running job,
116
154
  `x` clears finished jobs, `Esc`/`q` closes. `d` and `x` ask for a `y`/`N`
117
155
  confirmation before acting, so a stray key cannot kill a build; when `x` would
118
156
  drop an unreported result, the prompt says how many. A `d` on a finished job (or
119
- 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,
120
161
  `↑`/`↓`, `PgUp`/`PgDn`, `Home`/`End`, and `g`/`G` scroll; the view follows the
121
162
  tail until you scroll up, and `Esc`/`Backspace`/`q` returns to the list. The
122
163
  pane's subheader names the job's log file, notes when only a bounded tail is
123
164
  loaded, and shows whether the view is `following` the tail or `paused`.
124
165
 
125
- The screen shows a counts summary (`N jobs · R running · F failed`), running
126
- jobs first then recent finished jobs, and a focused detail pane (command, cwd,
127
- outcome, and the last output line). Selection is tracked by job id, so the live
128
- 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.
129
173
  Any dock screen (`/todos`, `/jobs`, `/rewind`, or the
130
174
  `question` questionnaire) hides the above-editor goal/todo rails for its
131
175
  lifetime and restores them on close.
@@ -149,6 +193,7 @@ document, project wins):
149
193
  | `detachedByDefault` | `false` | Leave jobs running when the session ends. |
150
194
  | `maxJobs` | `20` | Finished jobs kept before the oldest are pruned. |
151
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. |
152
197
  | `killGraceMs` | `5000` | Grace before SIGTERM escalates to SIGKILL. |
153
198
  | `repaintMs` | `1000` | Status-chip refresh interval while jobs run. |
154
199
  | `sessionTtlMs` | `60000` | How long a session heartbeat is trusted before its jobs may be reaped. |
@@ -170,14 +215,26 @@ rewritten.
170
215
  reattached after its session died reports its real exit code. `exec cmd`
171
216
  replaces the shell and bypasses the trap, and Windows has no equivalent, so
172
217
  those jobs settle as `unknown`.
173
- - **A live peer session's jobs are hidden.** Records are still merged from
174
- every session file so ids stay unique and a peer's job is not reaped, but
175
- `list` and `/jobs` show only this session's jobs (a dead session's records
176
- are adopted into it). A peer's job can still be reached by an explicit id
177
- (`status`/`logs`/`kill`; `kill` adopts it).
178
- - **No stdin to running jobs.** Jobs are fire-and-observe; interactive programs
179
- (a REPL, a debugger prompt) are out of scope.
180
- - **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.
181
238
  - **Windows is best-effort.** Tree-kill uses `taskkill /T`; POSIX uses process
182
239
  groups. A detached job only escapes the parent process on POSIX, where its
183
240
  shell writes the log directly; on Windows it keeps the parent pipe and is
@@ -199,15 +256,32 @@ rewritten.
199
256
  | Execution mode | `sequential` — actions share the in-memory job table. |
200
257
  | Annotations | `readOnlyHint: false`, `destructiveHint: true`, `idempotentHint: false`, `openWorldHint: true` — plan mode blocks it. |
201
258
  | `outputSchema` / `structuredContent` | None. Results return `JobDetails` in tool-result `details` only. |
202
- | State storage | One durable registry file per session, `~/.pi/agent/jobs/<projectKey>/registry-<sessionHash>.json` (keyed by the session cwd, so entering a worktree does not move it), merged from every session file on load; a dead session's records are adopted and a legacy shared `registry.json` is migrated once. Ids are reserved with an atomic `O_EXCL` `<id>.lock` before `start`, and a POSIX job's exit code is written to a per-job `.status` file. Tool results carry `JobDetails`; unreported completions arrive as hidden `job-context` messages at `before_agent_start`. |
203
- | Lifecycle | `session_start` loads and reconciles the registry and arms `onFinish` and `onComplete`; `session_shutdown` kills session-owned jobs and clears both status chips. `session_tree` repaints; `agent_start`/`agent_settled` track busy; `context` prunes stale `job-context` messages. |
259
+ | State storage | One durable registry file per session, `~/.pi/agent/jobs/<projectKey>/registry-<sessionHash>.json` (keyed by the session cwd, so entering a worktree does not move it), merged from every session file on load; a dead session's records are adopted and a legacy shared `registry.json` is migrated once. Ids are reserved with an atomic `O_EXCL` `<id>.lock` before `start`, and a POSIX job's exit code is written to a per-job `.status` file. Tool results carry `JobDetails`; unreported completions arrive as a shared transcript notice (`my-pi-agent/notice`) at `before_agent_start`. |
260
+ | Lifecycle | `session_start` loads and reconciles the registry and arms `onFinish` and `onComplete`; `session_shutdown` kills session-owned jobs and clears both status chips. `session_tree` repaints; `agent_start`/`agent_settled` track busy; `context` prunes stale notices of its own source. |
204
261
  | Event bus | Each job that reaches a terminal state is published on `my-pi-agent/job-finished` (`lib/events.ts`) with its id, label, status, exit code, and signal, from the one `settle` path every transition goes through. bytefly's notify capability subscribes there instead of importing this extension. |
205
262
 
206
263
  ## Design notes
207
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.
208
270
  - **Shell commands only, one action-based tool.** A single `job` tool with
209
- seven actions mirrors `rewind` and keeps the model surface small. Validation
271
+ eight actions mirrors `rewind` and keeps the model surface small. Validation
210
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.
211
285
  - **`child_process.spawn`, not `pi.exec`.** `pi.exec` resolves on exit, the
212
286
  opposite of a background job. Jobs are spawned with a shell in their own
213
287
  process group, and signals go to the whole group so a shell and its
@@ -248,11 +322,11 @@ rewritten.
248
322
  settles it (recovering an exit code from the status file, else `unknown`)
249
323
  when the pid disappears.
250
324
  - **Notify by default, wake opt-in.** A failed job raises one `ctx.ui.notify`
251
- (a clean exit is left to the list and chips). Finished jobs are also injected
252
- as a hidden `job-context` message at the next `before_agent_start`. A
253
- `wake: true` job (or `wakeOnFinish`) additionally triggers one turn when the
254
- agent is idle; auto-wake is not the default because it easily loops and burns
255
- tokens.
325
+ (a clean exit is left to the list and chips). Finished jobs are also posted
326
+ as a shared transcript notice — model-facing text plus the notice block — at the
327
+ next `before_agent_start`. A `wake: true` job (or `wakeOnFinish`) additionally
328
+ triggers one turn when the agent is idle; auto-wake is not the default because
329
+ it easily loops and burns tokens.
256
330
  - **Width-1 glyphs and cached latest line.** The running chip is `▸ N` and the
257
331
  failure chip `✗ N`, each a two-token badge under its own status key, so the
258
332
  status bar compacts each independently (`▸N` / `✗N`) and an unreported
@@ -264,8 +338,10 @@ rewritten.
264
338
  spawn/liveness/kill/clock functions, so the whole suite runs without
265
339
  launching a process.
266
340
  - **The transcript reuses the `/jobs` grammar.** `job list` renders the same
267
- `jobRow` rail as the screen (running first, capped, `… N more`), `job logs`
268
- draws a themed tail with a `… N earlier lines` note, and single-job actions
341
+ `jobRow` rows as the screen (running first, capped, `… N more`), minus the
342
+ selection-marker column (`listRow`'s `marker: false`), which only the
343
+ interactive screen needs; `job logs` draws a themed tail with a `… N earlier
344
+ lines` note, and single-job actions
269
345
  draw one `formatJobOutcomeLine` (plus a signal note on `kill` and
270
346
  `detached`/`wake` badges on `start`). A capped collapsed result ends with the
271
347
  `app.tools.expand` hint, and an expanded list shows every job. The renderers
@@ -292,7 +368,7 @@ rewritten.
292
368
  | `ui.ts` | Footer status chips, over the shared `lib/tui/status-controller.ts`. |
293
369
  | `waiters.ts` | `wait` resolver bookkeeping. |
294
370
  | `runtime.ts` | Composition: job table, handles, process events, repaint clock. |
295
- | `tui.ts` | The `/jobs` screen and the transcript `JobResult` rail, drawn from the `lib/tui/` kit (`titleRule`, `listRow`, `emptyState`, `IdCursor`, `LinePane`). |
371
+ | `tui.ts` | The `/jobs` screen and the transcript `JobResult` rows, drawn from the `lib/tui/` kit (`titleRule`, `listRow`, `emptyState`, `IdCursor`, `LinePane`). |
296
372
  | `tools.ts` | The `job` tool and its transcript rendering. |
297
373
  | `commands.ts` | The `/jobs` command. |
298
374
  | `types.ts` | Shared types. |
@@ -304,6 +380,8 @@ sanitization (`format`), the durable store (`store`), per-session reconciliation
304
380
  (`registry`), id reservation (`reservations`), exit-status recovery (`status`),
305
381
  session markers (`session`), bounded log reads and tail flags (`logs`), wait
306
382
  bookkeeping (`waiters`), the `job` tool (`tools`), the `/jobs` screen (`tui`),
307
- 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
308
386
  install/resume/shutdown lifecycle (`extension`). Tests drive a scripted
309
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),
@@ -14,7 +14,7 @@
14
14
 
15
15
  import type { Theme } from "@earendil-works/pi-coding-agent";
16
16
  import { truncateToWidth } from "@earendil-works/pi-tui";
17
- import { GLYPHS } from "../../lib/tui/glyphs.ts";
17
+ import { GLYPHS, SEPARATORS } from "../../lib/tui/glyphs.ts";
18
18
  import { paint, type TuiRole } from "../../lib/tui/tokens.ts";
19
19
  import type { JobRecord, JobStatus } from "./types.ts";
20
20
 
@@ -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":
@@ -251,7 +262,7 @@ export function jobOutcome(job: JobRecord): string {
251
262
 
252
263
  /** One-line outcome for a single job, themed for the transcript result. */
253
264
  export function formatJobOutcomeLine(job: JobRecord, theme: Theme, now = Date.now()): string {
254
- const sep = paint(theme, "faint", " · ");
265
+ const sep = paint(theme, "faint", SEPARATORS.item);
255
266
  const outcome =
256
267
  job.status === "failed" ? paint(theme, "bad", jobOutcome(job)) : paint(theme, "faint", jobOutcome(job));
257
268
  return (
@@ -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;
@@ -4,18 +4,19 @@
4
4
  * Registers the `job` tool and `/jobs` command. Jobs run as detached shell
5
5
  * processes with their output streamed to log files; the status chip shows
6
6
  * running and unreported-failure counts, and finished jobs are reported to the
7
- * model at the next turn (or, for `wake` jobs, by triggering one turn).
7
+ * model — and shown as a bytefly notice — at the next turn, or by triggering one
8
+ * turn for a `wake` job.
8
9
  *
9
10
  * Load with: pi --extension ./extensions/job
10
11
  */
11
12
 
12
- import type { AgentMessage } from "@earendil-works/pi-agent-core";
13
13
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
14
14
  import { isExtensionEnabled } from "../../lib/env.ts";
15
15
  import { JOB_FINISHED_CHANNEL, type JobFinishedEvent } from "../../lib/events.ts";
16
+ import { noticeMessage, type NoticeMessage, pruneStaleNotices } from "../../lib/notice.ts";
16
17
  import { STATUS_KEYS } from "../../lib/tui/glyphs.ts";
17
18
  import { registerCommands } from "./commands.ts";
18
- import { formatCompletion } from "./format.ts";
19
+ import { formatCompletion, jobOutcome, shortLabel } from "./format.ts";
19
20
  import { createJobsRuntime, type JobsRuntime } from "./runtime.ts";
20
21
  import { registerTools } from "./tools.ts";
21
22
  import type { Job, JobRecord } from "./types.ts";
@@ -23,16 +24,26 @@ import type { Job, JobRecord } from "./types.ts";
23
24
  /** Marker embedded in the injected completion context. */
24
25
  const JOB_CONTEXT_MARKER = "[BACKGROUND JOBS]";
25
26
 
26
- /** Custom-entry type used for the injected completion message. */
27
- export const JOB_CONTEXT_TYPE = "job-context";
28
-
29
27
  function buildContext(jobs: JobRecord[]): string {
30
28
  const lines = jobs.map((job) => formatCompletion(job));
31
29
  return `${JOB_CONTEXT_MARKER}\n${jobs.length} background job${jobs.length === 1 ? "" : "s"} finished:\n${lines.join("\n")}\nRun \`job logs <id>\` for output.`;
32
30
  }
33
31
 
34
- function isJobContext(message: AgentMessage): boolean {
35
- return (message as AgentMessage & { customType?: string }).customType === JOB_CONTEXT_TYPE;
32
+ /** The shared notice for pending completions: model text plus renderer details. */
33
+ function jobNotice(jobs: JobRecord[]): NoticeMessage {
34
+ const count = jobs.length;
35
+ return noticeMessage({
36
+ content: buildContext(jobs),
37
+ details: {
38
+ source: "job",
39
+ title: `${count} background job${count === 1 ? "" : "s"} finished`,
40
+ items: jobs.map((job) => ({
41
+ label: `${job.id} ${shortLabel(job)}`,
42
+ detail: jobOutcome(job),
43
+ severity: job.status === "failed" ? "bad" : job.status === "exited" ? "ok" : "warn",
44
+ })),
45
+ },
46
+ });
36
47
  }
37
48
 
38
49
  /** Trim a finished record to the fields the event bus carries. */
@@ -67,10 +78,7 @@ export default function jobs(pi: ExtensionAPI, deps: JobsDeps = {}): void {
67
78
  const pending = runtime.pendingCompletions();
68
79
  if (pending.length === 0) return;
69
80
  try {
70
- pi.sendMessage(
71
- { customType: JOB_CONTEXT_TYPE, content: buildContext(pending), display: false },
72
- { triggerTurn: true },
73
- );
81
+ pi.sendMessage(jobNotice(pending), { triggerTurn: true });
74
82
  // Mark only once the send succeeded; a throw leaves them for the next turn.
75
83
  runtime.markReported(pending.map((job) => job.id));
76
84
  } catch {
@@ -113,17 +121,14 @@ export default function jobs(pi: ExtensionAPI, deps: JobsDeps = {}): void {
113
121
  if (pending.length === 0) return undefined;
114
122
  runtime.markReported(pending.map((job) => job.id));
115
123
  runtime.setStatus(ctx);
116
- return {
117
- message: { customType: JOB_CONTEXT_TYPE, content: buildContext(pending), display: false },
118
- };
124
+ return { message: jobNotice(pending) };
119
125
  });
120
126
 
121
- // Keep stale completion notes out of later turns.
127
+ // Keep stale completion notices out of later turns. Only this extension's
128
+ // notices are pruned; another source's are left for its own handler.
122
129
  pi.on("context", (event) => {
123
- const contexts = event.messages.filter(isJobContext);
124
- if (contexts.length <= 1) return undefined;
125
- const last = contexts[contexts.length - 1];
126
- return { messages: event.messages.filter((message) => !isJobContext(message) || message === last) };
130
+ const pruned = pruneStaleNotices(event.messages, "job");
131
+ return pruned ? { messages: pruned } : undefined;
127
132
  });
128
133
 
129
134
  pi.on("session_shutdown", async (_event, ctx) => {
@@ -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