@gavin-hu/my-pi-agent 0.21.0 → 0.22.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 (254) hide show
  1. package/README.md +46 -31
  2. package/README.zh-CN.md +40 -26
  3. package/assets/screens/board.png +0 -0
  4. package/assets/screens/chrome.png +0 -0
  5. package/assets/screens/file-browse.png +0 -0
  6. package/assets/screens/plan-review.png +0 -0
  7. package/assets/screens/plan.png +0 -0
  8. package/docs/extensions/README.md +22 -24
  9. package/docs/extensions/board.md +361 -147
  10. package/docs/extensions/bytefly.md +152 -111
  11. package/docs/extensions/check.md +15 -17
  12. package/docs/extensions/context.md +35 -15
  13. package/docs/extensions/file-browse.md +30 -31
  14. package/docs/extensions/file-open.md +9 -11
  15. package/docs/extensions/file-read.md +12 -14
  16. package/docs/extensions/goal.md +15 -17
  17. package/docs/extensions/job.md +54 -42
  18. package/docs/extensions/memory.md +17 -19
  19. package/docs/extensions/plan.md +166 -126
  20. package/docs/extensions/question.md +16 -18
  21. package/docs/extensions/rewind.md +17 -19
  22. package/docs/extensions/serve.md +34 -18
  23. package/docs/extensions/session-bus.md +38 -16
  24. package/docs/extensions/subagent.md +23 -25
  25. package/docs/extensions/team.md +72 -33
  26. package/docs/extensions/todo.md +18 -18
  27. package/docs/extensions/watch.md +31 -31
  28. package/docs/extensions/web-access.md +2 -4
  29. package/docs/extensions/wechat.md +34 -35
  30. package/docs/extensions/worktree.md +56 -50
  31. package/extensions/board/commands.ts +56 -15
  32. package/extensions/board/format.ts +43 -24
  33. package/extensions/board/index.ts +5 -4
  34. package/extensions/board/input.ts +4 -9
  35. package/extensions/board/join.ts +4 -40
  36. package/extensions/board/lazy-page.ts +40 -0
  37. package/extensions/board/page.ts +13 -11
  38. package/extensions/board/result.ts +30 -16
  39. package/extensions/board/rows.ts +62 -25
  40. package/extensions/board/schema.ts +42 -10
  41. package/extensions/board/snapshot.ts +2 -1
  42. package/extensions/board/tone.ts +22 -0
  43. package/extensions/board/tools.ts +131 -44
  44. package/extensions/board/types.ts +36 -32
  45. package/extensions/board/view/card.ts +229 -0
  46. package/extensions/board/view/client.ts +33 -15
  47. package/extensions/board/view/dialog.ts +169 -6
  48. package/extensions/board/view/empty.ts +47 -0
  49. package/extensions/board/view/keys.ts +13 -2
  50. package/extensions/board/view/live.ts +89 -21
  51. package/extensions/board/view/names.ts +13 -4
  52. package/extensions/board/view/page.ts +23 -9
  53. package/extensions/board/view/render.ts +208 -271
  54. package/extensions/board/view/styles.ts +123 -31
  55. package/extensions/board/view/team.ts +297 -76
  56. package/extensions/bytefly/block/fallback.ts +1 -1
  57. package/extensions/bytefly/doctor/runtime.ts +5 -2
  58. package/extensions/bytefly/footer/collect/label.ts +60 -0
  59. package/extensions/bytefly/footer/collect/snapshot.ts +8 -1
  60. package/extensions/bytefly/footer/config.ts +4 -2
  61. package/extensions/bytefly/footer/register.ts +33 -4
  62. package/extensions/bytefly/footer/render/lines.ts +7 -4
  63. package/extensions/bytefly/footer/render/segments.ts +37 -10
  64. package/extensions/bytefly/footer/render/status.ts +0 -11
  65. package/extensions/bytefly/footer/types.ts +7 -0
  66. package/extensions/bytefly/header/config.ts +1 -1
  67. package/extensions/bytefly/index.ts +32 -6
  68. package/extensions/bytefly/mode/editor.ts +115 -0
  69. package/extensions/bytefly/panel/commands.ts +210 -120
  70. package/extensions/bytefly/panel/format.ts +4 -4
  71. package/extensions/bytefly/panel/settings.ts +73 -6
  72. package/extensions/bytefly/panel/tui/keys.ts +2 -6
  73. package/extensions/bytefly/update/cache.ts +1 -1
  74. package/extensions/bytefly/update/register.ts +4 -4
  75. package/extensions/bytefly/working/config.ts +5 -7
  76. package/extensions/bytefly/working/format.ts +10 -13
  77. package/extensions/bytefly/working/register.ts +22 -27
  78. package/extensions/check/commands.ts +3 -2
  79. package/extensions/check/{tui.ts → view/check-screens.ts} +14 -19
  80. package/extensions/context/commands.ts +2 -2
  81. package/extensions/context/{tui.ts → view/context-report.ts} +51 -19
  82. package/extensions/file-browse/index.ts +67 -36
  83. package/extensions/file-read/render.ts +4 -4
  84. package/extensions/goal/plan-route.ts +2 -2
  85. package/extensions/job/child.ts +527 -0
  86. package/extensions/job/clock.ts +142 -0
  87. package/extensions/job/commands.ts +4 -2
  88. package/extensions/job/config.ts +8 -1
  89. package/extensions/job/format.ts +22 -1
  90. package/extensions/job/log-writer.ts +13 -2
  91. package/extensions/job/registry.ts +63 -7
  92. package/extensions/job/render.ts +34 -0
  93. package/extensions/job/runtime.ts +296 -655
  94. package/extensions/job/session.ts +132 -19
  95. package/extensions/job/store.ts +7 -7
  96. package/extensions/job/tools.ts +3 -3
  97. package/extensions/job/types.ts +47 -0
  98. package/extensions/job/{tui.ts → view/jobs-screen.ts} +19 -73
  99. package/extensions/job/waiters.ts +44 -1
  100. package/extensions/memory/commands.ts +4 -2
  101. package/extensions/memory/{tui.ts → view/memory-picker.ts} +12 -12
  102. package/extensions/plan/commands.ts +12 -9
  103. package/extensions/plan/format.ts +8 -2
  104. package/extensions/plan/git-bash.ts +115 -16
  105. package/extensions/plan/index.ts +53 -52
  106. package/extensions/plan/plan-index.ts +4 -2
  107. package/extensions/plan/plans.ts +5 -54
  108. package/extensions/plan/policy.ts +8 -10
  109. package/extensions/plan/review-lazy.ts +85 -0
  110. package/extensions/plan/review.ts +2 -2
  111. package/extensions/plan/runtime.ts +49 -39
  112. package/extensions/plan/sections.ts +1 -1
  113. package/extensions/plan/steps.ts +3 -3
  114. package/extensions/plan/todos.ts +1 -1
  115. package/extensions/plan/tools/cancel.ts +27 -62
  116. package/extensions/plan/tools/enter.ts +55 -81
  117. package/extensions/plan/tools/exit.ts +221 -238
  118. package/extensions/plan/tools/refusal.ts +18 -0
  119. package/extensions/plan/tools/steps.ts +75 -92
  120. package/extensions/plan/tools/write.ts +77 -120
  121. package/extensions/plan/tools.ts +188 -30
  122. package/extensions/plan/tui.ts +5 -5
  123. package/extensions/plan/types.ts +48 -5
  124. package/extensions/plan/web-client.ts +3 -0
  125. package/extensions/plan/web-lazy.ts +53 -0
  126. package/extensions/plan/web-markdown.ts +9 -67
  127. package/extensions/plan/web-payload.ts +3 -3
  128. package/extensions/plan/web-router.ts +18 -9
  129. package/extensions/plan/web.ts +1 -12
  130. package/extensions/question/tools.ts +6 -2
  131. package/extensions/question/{tui.ts → view/question-screen.ts} +11 -11
  132. package/extensions/rewind/commands/commands.ts +6 -3
  133. package/extensions/rewind/state/runtime.ts +1 -1
  134. package/extensions/rewind/{view/status.ts → status.ts} +4 -4
  135. package/extensions/rewind/view/review-tui.ts +4 -4
  136. package/extensions/serve/config.ts +10 -8
  137. package/extensions/serve/index.ts +17 -3
  138. package/extensions/serve/registry.ts +26 -17
  139. package/extensions/session-bus/commands.ts +11 -6
  140. package/extensions/session-bus/join-request.ts +2 -1
  141. package/extensions/session-bus/mail-watch.ts +26 -17
  142. package/extensions/session-bus/protocol.ts +15 -3
  143. package/extensions/session-bus/resolve.ts +5 -7
  144. package/extensions/session-bus/runtime.ts +40 -12
  145. package/extensions/session-bus/store.ts +101 -19
  146. package/extensions/session-bus/types.ts +19 -0
  147. package/extensions/session-bus/{tui.ts → view/session-list.ts} +16 -16
  148. package/extensions/subagent/agents.ts +1 -1
  149. package/extensions/subagent/commands.ts +4 -2
  150. package/extensions/subagent/format.ts +15 -15
  151. package/extensions/subagent/index.ts +1 -1
  152. package/extensions/subagent/render.ts +3 -3
  153. package/extensions/subagent/{background-tui.ts → view/agents-board.ts} +11 -11
  154. package/extensions/team/commands.ts +24 -7
  155. package/extensions/team/dispatch.ts +18 -4
  156. package/extensions/team/format.ts +17 -2
  157. package/extensions/team/index.ts +9 -8
  158. package/extensions/team/schema.ts +6 -5
  159. package/extensions/team/tools.ts +107 -45
  160. package/extensions/team/types.ts +3 -2
  161. package/extensions/todo/commands.ts +4 -2
  162. package/extensions/todo/format.ts +11 -0
  163. package/extensions/todo/render.ts +57 -0
  164. package/extensions/todo/runtime.ts +1 -1
  165. package/extensions/todo/tools.ts +1 -1
  166. package/extensions/todo/view/todo-screen.ts +122 -0
  167. package/extensions/todo/widget.ts +47 -0
  168. package/extensions/watch/commands.ts +4 -2
  169. package/extensions/watch/format.ts +36 -7
  170. package/extensions/watch/render.ts +57 -0
  171. package/extensions/watch/runtime.ts +27 -22
  172. package/extensions/watch/tools.ts +4 -4
  173. package/extensions/watch/{tui.ts → view/watch-screen.ts} +18 -80
  174. package/extensions/web-access/fetch/tool.ts +12 -4
  175. package/extensions/wechat/commands/commands.ts +18 -14
  176. package/extensions/wechat/index.ts +72 -37
  177. package/extensions/wechat/state/credentials.ts +1 -1
  178. package/extensions/wechat/state/state.ts +1 -1
  179. package/extensions/wechat/text/status.ts +112 -0
  180. package/extensions/wechat/types.ts +2 -2
  181. package/extensions/wechat/view/status-screen.ts +4 -103
  182. package/extensions/worktree/commands.ts +11 -9
  183. package/extensions/worktree/enter.ts +2 -2
  184. package/extensions/worktree/exit.ts +2 -2
  185. package/extensions/worktree/index.ts +5 -5
  186. package/extensions/worktree/screen.ts +3 -3
  187. package/extensions/worktree/status.ts +2 -2
  188. package/extensions/worktree/tools.ts +113 -116
  189. package/lib/README.md +27 -13
  190. package/lib/board/actions.ts +111 -0
  191. package/lib/board/artifact.ts +149 -0
  192. package/lib/board/card.ts +163 -52
  193. package/lib/board/config.ts +58 -0
  194. package/lib/board/contract.ts +113 -0
  195. package/lib/board/join.ts +38 -0
  196. package/lib/board/lanes.ts +182 -0
  197. package/lib/board/lead.ts +130 -0
  198. package/lib/board/limits.ts +78 -0
  199. package/lib/board/links.ts +31 -10
  200. package/lib/board/page-pointer.ts +1 -1
  201. package/lib/board/read.ts +83 -0
  202. package/lib/board/reason.ts +102 -0
  203. package/lib/board/round-text.ts +68 -6
  204. package/lib/board/round.ts +31 -15
  205. package/lib/board/rules.ts +151 -307
  206. package/lib/board/seat.ts +35 -0
  207. package/lib/board/session.ts +136 -54
  208. package/lib/board/skipped.ts +22 -0
  209. package/lib/board/steps.ts +487 -0
  210. package/lib/board/store.ts +87 -30
  211. package/lib/board/tone.ts +92 -0
  212. package/lib/board/view.ts +70 -58
  213. package/{extensions/watch → lib}/child.ts +40 -21
  214. package/lib/env.ts +65 -7
  215. package/lib/format.ts +13 -0
  216. package/lib/http/index.ts +14 -12
  217. package/lib/http/port.ts +15 -10
  218. package/lib/lazy.ts +45 -0
  219. package/lib/plan-entry.ts +39 -10
  220. package/lib/plans/paths.ts +82 -0
  221. package/lib/policy.ts +9 -6
  222. package/lib/report.ts +3 -2
  223. package/lib/requirements.ts +8 -8
  224. package/lib/session-label.ts +111 -5
  225. package/lib/session-roster.ts +43 -1
  226. package/lib/session-tool.ts +5 -4
  227. package/lib/stray-fields.ts +7 -2
  228. package/lib/tool-names.ts +5 -10
  229. package/lib/tui/README.md +23 -8
  230. package/lib/tui/chrome.ts +16 -15
  231. package/lib/tui/cursor.ts +18 -3
  232. package/lib/tui/glyphs.ts +9 -7
  233. package/lib/tui/tokens.ts +33 -2
  234. package/lib/tui/tool.ts +3 -3
  235. package/lib/web/README.md +21 -0
  236. package/lib/web/components.ts +5 -1
  237. package/lib/web/index.ts +1 -0
  238. package/lib/web/markdown.ts +83 -0
  239. package/lib/web/markup.ts +2 -1
  240. package/lib/web/runtime.ts +9 -10
  241. package/package.json +6 -1
  242. package/skills/init/SKILL.md +5 -5
  243. package/skills/next/SKILL.md +3 -5
  244. package/extensions/board/seat.ts +0 -28
  245. package/extensions/todo/tui.ts +0 -212
  246. package/lib/tui/index.ts +0 -34
  247. package/skills/project-docs/SKILL.md +0 -133
  248. package/skills/project-docs/references/audit.md +0 -131
  249. package/skills/project-docs/references/changelog.md +0 -105
  250. package/skills/project-docs/references/discovery.md +0 -127
  251. package/skills/project-docs/references/readme.md +0 -80
  252. package/skills/project-docs/references/release.md +0 -84
  253. package/skills/project-docs/references/roadmap.md +0 -93
  254. package/skills/project-docs/scripts/state.ts +0 -316
@@ -0,0 +1,527 @@
1
+ /**
2
+ * A job's process and its output: the spawn policy, the handle, the log, stdin,
3
+ * the signal and its escalation, and the pid questions a durable record asks.
4
+ *
5
+ * The process side is `lib/child.ts`'s supervisor, which `watch` drives too, so
6
+ * the spawn, the merge of both output streams, the escalation timer, and the
7
+ * drain have one owner. What stays here is everything a *job* adds to that: the
8
+ * `EXIT`-trap program and its status file, a log the runtime can tail, a stdin
9
+ * pipe a later `send` writes to, and the start-time token that tells a reused pid
10
+ * from the one this record named.
11
+ *
12
+ * The table is the runtime's: this module asks for a record by id, hands back the
13
+ * one it started, and asks the runtime to settle one — it never holds the table.
14
+ */
15
+
16
+ import { existsSync, mkdirSync, unlinkSync, writeFileSync } from "node:fs";
17
+ import { createChildSupervisor, type ChildOutcome, type ChildSlot, type ChildSupervisor } from "../../lib/child.ts";
18
+ import { sanitizeUntrustedLine } from "../../lib/format.ts";
19
+ import type { LineSplitter } from "../../lib/lines.ts";
20
+ import type { KillTreeFn, SpawnFn, StartTokenFn, StdinSink } from "../../lib/spawn.ts";
21
+ import { clipLabel } from "../../lib/tui/columns.ts";
22
+ import type { JobsConfig } from "./config.ts";
23
+ import { formatLogs } from "./format.ts";
24
+ import { createLogWriter, type LogHandle, type LogWriter } from "./log-writer.ts";
25
+ import { readLogTail } from "./logs.ts";
26
+ import { LOG_ENV, STATUS_ENV, readExitStatus, withExitTrap } from "./status.ts";
27
+ import type { JobStore } from "./store.ts";
28
+ import type {
29
+ Job,
30
+ JobRecord,
31
+ JobStatus,
32
+ KillSignal,
33
+ LogResult,
34
+ SendOptions,
35
+ SendResult,
36
+ StartOptions,
37
+ } from "./types.ts";
38
+
39
+ /** Characters of a job's log a model-facing tail may carry. */
40
+ const LOG_MODEL_CHARS = 16 * 1024;
41
+
42
+ /**
43
+ * The process seam a started job uses: the spawn, the three questions about a pid,
44
+ * and the log splitter. It arrives with `load`, because asking whether a recorded
45
+ * pid is alive is the first thing a session does.
46
+ */
47
+ export interface JobSeams {
48
+ spawn: SpawnFn;
49
+ liveness: (pid: number) => boolean;
50
+ killTree: KillTreeFn;
51
+ startToken: StartTokenFn;
52
+ newSplitter: () => LineSplitter;
53
+ }
54
+
55
+ /**
56
+ * A live job's process fields.
57
+ *
58
+ * The supervisor mutates `pid`/`child`/`killTimer` in place, so the handle and
59
+ * the module cannot disagree about whether the child is still live.
60
+ */
61
+ export interface Handle extends ChildSlot {
62
+ /** The job's log: stream, line split, rotation, and the repaint window. */
63
+ log: LogHandle;
64
+ /** Escalates the job when it outlives its `timeoutMs`. */
65
+ timeoutTimer?: ReturnType<typeof setTimeout>;
66
+ /** The child's stdin pipe, present only for a job started with `stdin: true`. */
67
+ stdin?: StdinSink | null;
68
+ /** True once stdin was ended, or the child closed its end. */
69
+ stdinClosed?: boolean;
70
+ }
71
+
72
+ /** What a job's process asks of the runtime's table, and never answers itself. */
73
+ export interface JobTable {
74
+ /** One record from the table, by id. */
75
+ record(id: string): Job | undefined;
76
+ /** Every id the table holds, so a new one cannot collide with a record not yet pruned. */
77
+ ids(): Iterable<string>;
78
+ /** Put a record this module just started into the table. */
79
+ insert(job: Job): void;
80
+ /** Move a record to a settled status: waiters, listeners, and the event bus. */
81
+ settle(job: Job, patch: { status: JobStatus; exitCode?: number | null; signal?: string | null }): void;
82
+ /** This session's id, recorded as the owner of every job it starts or adopts. */
83
+ sessionId(): string;
84
+ /** Persist the table and repaint, after a record changed. */
85
+ changed(): void;
86
+ }
87
+
88
+ export interface JobChildrenDeps {
89
+ /**
90
+ * The process seam, once it has loaded: `lib/spawn.ts`'s spawn, tree-kill, liveness and
91
+ * start token. Undefined before `load`, where a pid question answers as it always did.
92
+ */
93
+ seams: () => JobSeams | undefined;
94
+ /** The durable store, for a job's log, its rotated segment, and the id it is filed under. */
95
+ store: () => JobStore;
96
+ /** The live config, read per call so a reload lands. */
97
+ config: () => JobsConfig;
98
+ now: () => number;
99
+ /** A fresh line splitter, which arrives with the process seam. */
100
+ newSplitter: () => LineSplitter;
101
+ table: JobTable;
102
+ /** The child closed or failed to spawn; the runtime settles the record. */
103
+ onSettled: (id: string, outcome: ChildOutcome) => void;
104
+ /** Repaint, at most once per repaint window while output arrives. */
105
+ onPaint: () => void;
106
+ }
107
+
108
+ export interface JobChildren {
109
+ /** Spawn a job's process, record its handle, and answer the record to insert. */
110
+ start(options: StartOptions, placement: { cwd: string; windows: boolean }): Job;
111
+ /** SIGTERM a job and escalate to SIGKILL after the grace, or reconcile a reattached one. */
112
+ kill(id: string, signal?: KillSignal): Promise<Job | undefined>;
113
+ /** Whether a pid is still running, asked through the loaded seam. */
114
+ alive(pid: number): boolean;
115
+ /**
116
+ * Whether a persisted start-time token still matches the live pid.
117
+ * True when either side is unavailable, so a caller falls back to liveness alone.
118
+ */
119
+ owns(job: JobRecord): boolean;
120
+ /** A pid's start time, when the platform can say; undefined before the seam loads. */
121
+ startToken(pid: number): string | undefined;
122
+ get(id: string): Handle | undefined;
123
+ /** Clear a handle's timers, close its log stream, and drop it. */
124
+ dispose(id: string, options: { endStream: boolean }): void;
125
+ /** Dispose every handle, for a session that is starting over. */
126
+ disposeAll(): void;
127
+ /** Signal a job's process, or a bare pid no handle holds (a reattached job). */
128
+ signal(slot: ChildSlot, signal: NodeJS.Signals): void;
129
+ /** Write to a running job's stdin, or throw the model-readable refusal. */
130
+ send(id: string, options: SendOptions): SendResult;
131
+ /** A job's log across both segments, formatted for the model. */
132
+ logs(id: string, lines?: number): LogResult | undefined;
133
+ /** Commit a settled job's buffered output and record its last line. */
134
+ flush(job: Job): void;
135
+ /** Recover an exit code from a job's status file, guarded by the trusted path. */
136
+ exitCode(job: JobRecord): number | undefined;
137
+ /** Delete a job's status file once its code has been observed, best-effort. */
138
+ removeStatus(job: JobRecord): void;
139
+ /** Delete a job's log file and any rotated segment, best-effort. */
140
+ removeLog(job: JobRecord): void;
141
+ /** Remove every durable artifact a job leaves behind, its id reservation included. */
142
+ removeArtifacts(job: JobRecord): void;
143
+ }
144
+
145
+ export function createJobChildren(deps: JobChildrenDeps): JobChildren {
146
+ const handles = new Map<string, Handle>();
147
+
148
+ /** The seam a spawn or a signal needs; a caller that reaches here has awaited `load`. */
149
+ const requireSeams = (): JobSeams => {
150
+ const seams = deps.seams();
151
+ if (seams === undefined) throw new Error("the job process seam is not loaded yet.");
152
+ return seams;
153
+ };
154
+
155
+ /** One supervisor per grace, so a reloaded config re-arms the escalation at its own speed. */
156
+ let supervisor: ChildSupervisor | undefined;
157
+ let supervisorGrace: number | undefined;
158
+ const supervisorFor = (): ChildSupervisor => {
159
+ const grace = deps.config().killGraceMs;
160
+ if (supervisor === undefined || supervisorGrace !== grace) {
161
+ const { spawn, killTree } = requireSeams();
162
+ supervisor = createChildSupervisor({ spawn, killTree, graceMs: grace });
163
+ supervisorGrace = grace;
164
+ }
165
+ return supervisor;
166
+ };
167
+
168
+ /** A pid question before `load` answers `true`, which is what the runtime always said. */
169
+ const alive = (pid: number): boolean => deps.seams()?.liveness(pid) ?? true;
170
+
171
+ /** A mismatch means the pid was reused by another program, so it must not be signalled. */
172
+ const owns = (job: JobRecord): boolean => {
173
+ if (!job.startToken || job.pid === null) return true;
174
+ const current = deps.seams()?.startToken(job.pid);
175
+ return !current || current === job.startToken;
176
+ };
177
+
178
+ /** A pid's start time, for the ownership rule's own marker. */
179
+ const startToken = (pid: number): string | undefined => deps.seams()?.startToken(pid);
180
+
181
+ /** Remember the newest non-empty output line, which `status`/`wait` report. */
182
+ const recordLine = (job: Job, line: string): void => {
183
+ const clean = sanitizeUntrustedLine(line);
184
+ if (clean) job.lastLine = clean;
185
+ };
186
+
187
+ /**
188
+ * The per-job log sink: stream, line split, rotation, and the repaint window.
189
+ *
190
+ * Built on first use, because the line splitter arrives with the process seam: a
191
+ * session that never starts a job never writes one.
192
+ */
193
+ let sink: LogWriter | undefined;
194
+ const logWriter = (): LogWriter => {
195
+ sink ??= createLogWriter({
196
+ now: deps.now,
197
+ newSplitter: deps.newSplitter,
198
+ logPath: (job) => deps.store().safeLogPath(job),
199
+ rotatedPath: (job) => deps.store().safeRotatedLogPath(job),
200
+ maxBytes: () => deps.config().maxLogBytes,
201
+ repaintMs: () => deps.config().repaintMs,
202
+ onLine: recordLine,
203
+ onPaint: deps.onPaint,
204
+ });
205
+ return sink;
206
+ };
207
+
208
+ const writeChunk = (id: string, chunk: Buffer): void => {
209
+ const handle = handles.get(id);
210
+ const job = deps.table.record(id);
211
+ if (!handle || !job) return;
212
+ logWriter().write(handle.log, job, chunk);
213
+ };
214
+
215
+ const start = (options: StartOptions, placement: { cwd: string; windows: boolean }): Job => {
216
+ const { windows } = placement;
217
+ const config = deps.config();
218
+ const wantsStdin = options.stdin === true;
219
+ // Windows has no process group to leave and no shell `EXIT` trap to record an
220
+ // exit code, so a job there cannot outlive the session: refusing an explicit
221
+ // detach is the honest answer, and it happens before any id is reserved.
222
+ // `detachedByDefault` stays inert instead of failing every start with a config
223
+ // the user set on another machine; `doctor` names that.
224
+ if (windows && options.detached === true) {
225
+ throw new Error(
226
+ "A detached job cannot outlive the session on Windows: there is no process group to leave " +
227
+ "and no shell EXIT trap to record its exit code. Start it with `detached: false`, " +
228
+ "run it under WSL, or move the work into a `subagent`.",
229
+ );
230
+ }
231
+ const detached = windows ? false : (options.detached ?? config.detachedByDefault);
232
+ // A detached job outlives this process, so a stdin pipe owned by this session
233
+ // would hand it EOF the moment the session ends; refuse instead of silently
234
+ // dropping the pipe (or the detach).
235
+ if (wantsStdin && detached) {
236
+ throw new Error("a detached job cannot take stdin; start it with `detached: false`.");
237
+ }
238
+ const store = deps.store();
239
+ // The registry directory must exist before `nextId`, which reserves the id
240
+ // with an `O_EXCL` lock file inside it.
241
+ mkdirSync(store.directory(), { recursive: true });
242
+ // `nextId` folds in the on-disk counters and ids from every session, so a peer
243
+ // that advanced the shared counter cannot hand us a live job's id — and the table's
244
+ // ids cover a record this session started and has not pruned yet.
245
+ const id = store.nextId(deps.table.ids());
246
+ const logPath = store.logPath(id);
247
+ // Only a POSIX shell has an `EXIT` trap to record a status, and only a detached
248
+ // job points its own output at the log so it keeps logging after this process
249
+ // ends (which on Windows is never: `detached` is false there).
250
+ const statusPath = windows ? null : store.statusPath(id);
251
+
252
+ // Wrap the command so a POSIX shell records its exit status; the path travels
253
+ // in the environment so the shell never has to quote it.
254
+ const program = withExitTrap(options.command, windows, detached);
255
+ const env: NodeJS.ProcessEnv = { ...process.env };
256
+ if (statusPath) env[STATUS_ENV] = statusPath;
257
+ if (detached) env[LOG_ENV] = logPath;
258
+ const handle: Handle = { log: logWriter().create(detached ? undefined : logPath) };
259
+ let error: string | undefined;
260
+ try {
261
+ error = supervisorFor().start(
262
+ handle,
263
+ program,
264
+ { cwd: placement.cwd, env, detachOutput: detached, stdin: wantsStdin },
265
+ {
266
+ onData: (chunk) => writeChunk(id, chunk),
267
+ onSettled: (outcome) => deps.onSettled(id, outcome),
268
+ },
269
+ );
270
+ } catch (thrown) {
271
+ logWriter().dispose(handle.log, { endStream: true });
272
+ throw thrown;
273
+ }
274
+ if (error !== undefined) {
275
+ // A failed spawn must not leave the reserved id or a log stream behind.
276
+ logWriter().dispose(handle.log, { endStream: true });
277
+ store.releaseId(id);
278
+ throw new Error(error);
279
+ }
280
+ handles.set(id, handle);
281
+ if (detached) handle.child?.unref?.();
282
+ // Create the file eagerly so `logs` works before the first write, then append
283
+ // through the stream. A detached job's shell writes the file directly.
284
+ try {
285
+ writeFileSync(logPath, "", { flag: "a" });
286
+ } catch {
287
+ // An unwritable log leaves `logs` empty; the job still runs.
288
+ }
289
+ if (wantsStdin) {
290
+ const pipe = handle.child?.stdin ?? null;
291
+ handle.stdin = pipe;
292
+ handle.stdinClosed = pipe === null;
293
+ // A child that exits can emit `error`/`close` on the pipe; record it so a
294
+ // later `send` reports the truth instead of throwing an unhandled stream error.
295
+ const markClosed = (): void => {
296
+ const current = handles.get(id);
297
+ if (current) current.stdinClosed = true;
298
+ };
299
+ pipe?.on("error", markClosed);
300
+ pipe?.on("close", markClosed);
301
+ }
302
+ const pid = handle.pid ?? null;
303
+ const job: Job = {
304
+ id,
305
+ label: sanitizeUntrustedLine((options.label ?? "").trim()) || clipLabel(options.command),
306
+ command: options.command,
307
+ cwd: placement.cwd,
308
+ pid,
309
+ status: "running",
310
+ exitCode: null,
311
+ signal: null,
312
+ startedAt: deps.now(),
313
+ finishedAt: null,
314
+ logPath,
315
+ statusPath,
316
+ detached,
317
+ wake: options.wake ?? config.wakeOnFinish,
318
+ stdin: wantsStdin,
319
+ sessionId: deps.table.sessionId(),
320
+ seen: false,
321
+ lastLine: "",
322
+ startToken: pid !== null ? (requireSeams().startToken(pid) ?? null) : null,
323
+ owned: true,
324
+ };
325
+ deps.table.insert(job);
326
+
327
+ if (options.timeoutMs !== undefined) {
328
+ handle.timeoutTimer = setTimeout(() => {
329
+ if (deps.table.record(id)?.status === "running") void kill(id, "SIGTERM");
330
+ }, options.timeoutMs);
331
+ handle.timeoutTimer.unref?.();
332
+ }
333
+ return job;
334
+ };
335
+
336
+ const kill = async (id: string, signal: KillSignal = "SIGTERM"): Promise<Job | undefined> => {
337
+ const job = deps.table.record(id);
338
+ if (!job) return undefined;
339
+ if (job.status !== "running") return job;
340
+ const pid = job.pid;
341
+ if (pid === null) {
342
+ deps.table.settle(job, { status: "unknown" });
343
+ deps.table.changed();
344
+ return job;
345
+ }
346
+ // A reattached pid may have been reused; if the start token disagrees, stop
347
+ // rather than signal an unrelated process.
348
+ if (!owns(job)) {
349
+ deps.table.settle(job, { status: "unknown" });
350
+ deps.table.changed();
351
+ return job;
352
+ }
353
+ // Adopt a peer's job so the outcome persists in our file; the peer's stale
354
+ // file is dropped on a later load.
355
+ job.sessionId = deps.table.sessionId();
356
+
357
+ const handle = handles.get(id);
358
+ if (handle) {
359
+ // The supervisor owns the escalation: the signal the caller asked for now (SIGINT
360
+ // included), then SIGKILL at the configured grace unless the child closes first —
361
+ // and no escalation behind a SIGKILL, which is already final.
362
+ supervisorFor().terminate(handle, signal);
363
+ return job;
364
+ }
365
+
366
+ // Reattached/external Job: no handle, so no `close` event will arrive either. Signal the
367
+ // pid and reconcile after the grace whatever the signal was — SIGKILL is final for a
368
+ // child we hold, not for one whose ending nothing reports.
369
+ supervisorFor().kill({ pid }, signal);
370
+ setTimeout(() => {
371
+ const current = deps.table.record(id);
372
+ if (current?.status !== "running") return;
373
+ if (!alive(pid)) {
374
+ deps.table.settle(current, { status: "killed", signal });
375
+ } else {
376
+ supervisorFor().kill({ pid }, "SIGKILL");
377
+ deps.table.settle(current, { status: "killed", signal: "SIGKILL" });
378
+ }
379
+ deps.table.changed();
380
+ }, deps.config().killGraceMs).unref?.();
381
+ return job;
382
+ };
383
+
384
+ const dispose = (id: string, options: { endStream: boolean }): void => {
385
+ const handle = handles.get(id);
386
+ if (!handle) return;
387
+ if (handle.killTimer) clearTimeout(handle.killTimer);
388
+ if (handle.timeoutTimer) clearTimeout(handle.timeoutTimer);
389
+ logWriter().dispose(handle.log, options);
390
+ // Release the stdin pipe so a child waiting on input sees EOF instead of a
391
+ // half-open pipe nobody can write to.
392
+ if (handle.stdin && !handle.stdinClosed) {
393
+ try {
394
+ handle.stdin.end();
395
+ } catch {
396
+ // Already closed.
397
+ }
398
+ }
399
+ handle.stdin = null;
400
+ handle.stdinClosed = true;
401
+ handles.delete(id);
402
+ };
403
+
404
+ /** Commit any buffered output, including a final line without a newline. */
405
+ const flush = (job: Job): void => {
406
+ const handle = handles.get(job.id);
407
+ if (!handle) return;
408
+ for (const line of logWriter().flush(handle.log)) recordLine(job, line);
409
+ // A detached job keeps no parent stream; read its last line from the log.
410
+ if (!handle.log.stream) {
411
+ const last = readLogTail(deps.store().safeLogPath(job), 1).lines.at(-1);
412
+ if (last) job.lastLine = last;
413
+ }
414
+ };
415
+
416
+ const logs = (id: string, lines?: number): LogResult | undefined => {
417
+ const job = deps.table.record(id);
418
+ if (!job) return undefined;
419
+ const count = lines ?? deps.config().maxLogLines;
420
+ const current = readLogTail(deps.store().safeLogPath(job), count);
421
+ let all = current.lines;
422
+ let more = current.more;
423
+ let truncated = current.truncated;
424
+ // Keep `logs` continuous across a rotation: fill the request from the
425
+ // previous segment when the live one holds fewer lines.
426
+ if (all.length < count) {
427
+ const previous = readLogTail(deps.store().safeRotatedLogPath(job), count - all.length);
428
+ if (previous.lines.length > 0) {
429
+ all = [...previous.lines, ...all];
430
+ more = more || previous.more;
431
+ truncated = truncated || previous.truncated;
432
+ }
433
+ }
434
+ const last = all.at(-1);
435
+ if (last) job.lastLine = last;
436
+ const formatted = formatLogs(job, all, LOG_MODEL_CHARS);
437
+ // A rotation that outran its drain drops the oldest queued chunks; say so once
438
+ // rather than letting the gap look like a program that skipped output.
439
+ const dropped = handles.get(id)?.log.droppedBytes ?? 0;
440
+ const text = dropped > 0 ? `${formatted.text}\n… ${dropped} bytes dropped while the log rotated.` : formatted.text;
441
+ return {
442
+ job,
443
+ text,
444
+ lines: all,
445
+ truncated: formatted.truncated || truncated,
446
+ more,
447
+ };
448
+ };
449
+
450
+ const send = (id: string, options: SendOptions): SendResult => {
451
+ const job = deps.table.record(id);
452
+ if (!job) throw new Error(`no job "${id}".`);
453
+ if (job.status !== "running") throw new Error(`job ${id} is ${job.status}; stdin is closed.`);
454
+ if (!job.stdin) throw new Error(`job ${id} was not started with stdin; restart it with \`stdin: true\`.`);
455
+ const handle = handles.get(id);
456
+ // A pipe this session opened but that is closed is a different fact from one
457
+ // that was never ours, so state it first.
458
+ if (handle?.stdinClosed) throw new Error(`job ${id} closed its stdin.`);
459
+ if (!handle?.stdin) throw new Error(`job ${id}'s stdin belongs to the session that started it.`);
460
+
461
+ const input = options.input ?? "";
462
+ const payload = input && options.newline !== false ? `${input}\n` : input;
463
+ try {
464
+ if (payload) handle.stdin.write(payload);
465
+ if (options.eof) {
466
+ handle.stdin.end();
467
+ handle.stdinClosed = true;
468
+ }
469
+ } catch {
470
+ // The child closed its end; record it so the next `send` says so.
471
+ handle.stdinClosed = true;
472
+ handle.stdin = null;
473
+ throw new Error(`job ${id} closed its stdin.`);
474
+ }
475
+ return { job, sent: input.length, closed: options.eof === true };
476
+ };
477
+
478
+ const exitCode = (job: JobRecord): number | undefined => readExitStatus(deps.store().safeStatusPath(job));
479
+
480
+ const removeStatus = (job: JobRecord): void => {
481
+ try {
482
+ const path = deps.store().safeStatusPath(job);
483
+ if (path && existsSync(path)) unlinkSync(path);
484
+ } catch {
485
+ // Status cleanup is best-effort.
486
+ }
487
+ };
488
+
489
+ const removeLog = (job: JobRecord): void => {
490
+ try {
491
+ const path = deps.store().safeLogPath(job);
492
+ if (path && existsSync(path)) unlinkSync(path);
493
+ // Both segments are the job's own log; drop the rotated one too.
494
+ const rotated = deps.store().safeRotatedLogPath(job);
495
+ if (rotated && existsSync(rotated)) unlinkSync(rotated);
496
+ } catch {
497
+ // Log cleanup is best-effort.
498
+ }
499
+ };
500
+
501
+ const removeArtifacts = (job: JobRecord): void => {
502
+ deps.store().releaseId(job.id);
503
+ removeStatus(job);
504
+ removeLog(job);
505
+ };
506
+
507
+ return {
508
+ start,
509
+ kill,
510
+ alive,
511
+ startToken,
512
+ owns,
513
+ get: (id) => handles.get(id),
514
+ dispose,
515
+ disposeAll: () => {
516
+ for (const id of [...handles.keys()]) dispose(id, { endStream: true });
517
+ },
518
+ signal: (slot, signal) => supervisorFor().kill(slot, signal),
519
+ send,
520
+ logs,
521
+ flush,
522
+ exitCode,
523
+ removeStatus,
524
+ removeLog,
525
+ removeArtifacts,
526
+ };
527
+ }
@@ -0,0 +1,142 @@
1
+ /**
2
+ * The job runtime's periodic work: the repaint clock, and the sweep that polls
3
+ * the jobs this session reattached.
4
+ *
5
+ * The clock is the only periodic hook the runtime arms. While a job runs it
6
+ * re-asserts the footer chip, touches this session's liveness marker, and lets
7
+ * the runtime poll the jobs it reattached; without it, anything that clears
8
+ * extension statuses (a session reload, another UI path) would hide a
9
+ * still-running job's chip until the next transition.
10
+ *
11
+ * Every fact it acts on is the caller's and arrives as a function: whether a job
12
+ * is running, whether the runtime was disposed, what a tick does, and what a
13
+ * repaint does. Nothing here holds state across calls — the armed timer lives in
14
+ * the returned closure, never at module scope, because Pi loads each extension
15
+ * with its own module cache.
16
+ *
17
+ * The sweep is here because it is the tick's work: a reattached job has no
18
+ * `close` event, so only a periodic pass can notice that its pid is gone.
19
+ */
20
+
21
+ import { defaultInterval, type RepeatTimer } from "../../lib/timer.ts";
22
+ import type { Job, JobRecord, JobStatus } from "./types.ts";
23
+
24
+ export interface RepaintClockDeps {
25
+ /** The repaint interval, read when the clock is armed. */
26
+ repaintMs: () => number;
27
+ /** Whether any record is running, which is the only thing that keeps the clock armed. */
28
+ running: () => boolean;
29
+ /** Whether the runtime was shut down; a disposed runtime arms nothing. */
30
+ disposed: () => boolean;
31
+ /** Refresh this session's liveness marker. */
32
+ touchMarker: () => void;
33
+ /** One tick: the external poll and the chip re-assert. */
34
+ tick: () => void;
35
+ /** One repaint of the surface. */
36
+ repaint: () => void;
37
+ /** The repeating timer, injectable so a test ticks by hand instead of waiting. */
38
+ interval?: RepeatTimer;
39
+ }
40
+
41
+ export interface RepaintClock {
42
+ /** Arm the clock when a job is running and none is armed, and stop it when nothing is. */
43
+ ensure(): void;
44
+ /** Cancel a pending tick. */
45
+ stop(): void;
46
+ /** Ensure the clock, touch the marker, then repaint — unless the runtime is disposed. */
47
+ paint(): void;
48
+ }
49
+
50
+ export function createRepaintClock(deps: RepaintClockDeps): RepaintClock {
51
+ const interval = deps.interval ?? defaultInterval;
52
+ let cancel: (() => void) | undefined;
53
+
54
+ const stop = (): void => {
55
+ cancel?.();
56
+ cancel = undefined;
57
+ };
58
+
59
+ const ensure = (): void => {
60
+ if (deps.disposed() || !deps.running()) {
61
+ stop();
62
+ return;
63
+ }
64
+ if (cancel) return;
65
+ cancel = interval(deps.repaintMs(), () => deps.tick());
66
+ };
67
+
68
+ const paint = (): void => {
69
+ ensure();
70
+ deps.touchMarker();
71
+ if (deps.disposed()) return;
72
+ deps.repaint();
73
+ };
74
+
75
+ return { ensure, stop, paint };
76
+ }
77
+
78
+ /** What the sweep asks of the table; every decision it makes is answered there. */
79
+ export interface ExternalSweepDeps {
80
+ /** Every record in the table. */
81
+ records: () => Iterable<Job>;
82
+ /** This session's id, so a peer's record is told from one's own. */
83
+ sessionId: () => string;
84
+ /**
85
+ * Whether the session that owns a record is still here: `live` and `quiet` both count,
86
+ * because a heartbeat only ticks while a job runs.
87
+ */
88
+ ownerPresent: (sessionId: string) => boolean;
89
+ /** Whether a pid is still running. */
90
+ alive: (pid: number) => boolean;
91
+ /** Whether a persisted start-time token still matches the pid it names. */
92
+ owns: (job: JobRecord) => boolean;
93
+ /** Settle a record whose process is gone; false when it cannot be judged. */
94
+ settleGone: (job: Job) => boolean;
95
+ /** Move a record to a settled status, resolving its waiters. */
96
+ settle: (job: Job, patch: { status: JobStatus; signal?: string | null }) => void;
97
+ /** SIGTERM a pid. */
98
+ signal: (pid: number) => void;
99
+ /** Refresh this session's liveness marker. */
100
+ touchMarker: () => void;
101
+ /** Prune finished records, persist, and repaint, after something changed. */
102
+ changed: () => void;
103
+ }
104
+
105
+ /**
106
+ * Poll the reattached (unowned) jobs once: settle any whose pid is gone, and reap
107
+ * an abandoned non-detached process whose owner session is gone.
108
+ */
109
+ export function createExternalSweep(deps: ExternalSweepDeps): () => void {
110
+ return () => {
111
+ deps.touchMarker();
112
+ let changed = false;
113
+ for (const job of deps.records()) {
114
+ if (job.status !== "running" || job.owned) continue;
115
+ const peer = job.sessionId !== deps.sessionId();
116
+ // A peer that is still here owns this job, a quiet one included: leave its state to
117
+ // that owner. The peer's id is asked however often the record says it is not ours.
118
+ if (peer && deps.ownerPresent(job.sessionId)) continue;
119
+ if (job.pid === null || !deps.alive(job.pid)) {
120
+ // A record with no pid cannot be judged gone; leave it alone rather than
121
+ // announcing on every tick.
122
+ if (!deps.settleGone(job)) continue;
123
+ changed = true;
124
+ continue;
125
+ }
126
+ // The owner is gone and the job is not detached: reap the abandoned
127
+ // process, guarding against pid reuse with the start token. A settle is the
128
+ // whole announcement, so the reap needs no second call of its own.
129
+ if (peer && !job.detached) {
130
+ if (deps.owns(job)) {
131
+ deps.signal(job.pid);
132
+ deps.settle(job, { status: "killed", signal: "SIGTERM" });
133
+ } else {
134
+ deps.settle(job, { status: "unknown" });
135
+ }
136
+ changed = true;
137
+ }
138
+ }
139
+ if (!changed) return;
140
+ deps.changed();
141
+ };
142
+ }
@@ -6,10 +6,8 @@
6
6
  */
7
7
 
8
8
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
9
- import { withPolledScreen } from "../../lib/screen-session.ts";
10
9
  import { formatJobList } from "./format.ts";
11
10
  import type { JobsRuntime } from "./runtime.ts";
12
- import { JobListComponent } from "./tui.ts";
13
11
 
14
12
  /** How often the open log pane re-reads the log file. */
15
13
  const LOG_POLL_MS = 500;
@@ -23,6 +21,10 @@ export function registerCommands(pi: ExtensionAPI, runtime: JobsRuntime): void {
23
21
  return;
24
22
  }
25
23
 
24
+ // The screen and the dock it mounts are the TUI's, not the listener's: the text
25
+ // listing above and registration load neither.
26
+ const { withPolledScreen } = await import("../../lib/screen-session.ts");
27
+ const { JobListComponent } = await import("./view/jobs-screen.ts");
26
28
  await withPolledScreen(
27
29
  pi,
28
30
  ctx,