@gavin-hu/my-pi-agent 0.20.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 (298) hide show
  1. package/README.md +46 -30
  2. package/README.zh-CN.md +40 -25
  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 -23
  9. package/docs/extensions/board.md +397 -168
  10. package/docs/extensions/bytefly.md +234 -197
  11. package/docs/extensions/check.md +15 -17
  12. package/docs/extensions/context.md +35 -15
  13. package/docs/extensions/file-browse.md +57 -63
  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 +197 -140
  20. package/docs/extensions/question.md +16 -18
  21. package/docs/extensions/rewind.md +17 -19
  22. package/docs/extensions/serve.md +145 -0
  23. package/docs/extensions/session-bus.md +38 -16
  24. package/docs/extensions/subagent.md +23 -25
  25. package/docs/extensions/team.md +83 -44
  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 +79 -25
  32. package/extensions/board/format.ts +43 -24
  33. package/extensions/board/index.ts +17 -4
  34. package/extensions/board/input.ts +4 -9
  35. package/extensions/board/join.ts +5 -20
  36. package/extensions/board/lazy-page.ts +40 -0
  37. package/extensions/board/page.ts +65 -93
  38. package/extensions/board/result.ts +30 -39
  39. package/extensions/board/rows.ts +62 -25
  40. package/extensions/board/schema.ts +49 -11
  41. package/extensions/board/snapshot.ts +8 -6
  42. package/extensions/board/tone.ts +22 -0
  43. package/extensions/board/tools.ts +154 -55
  44. package/extensions/board/transport/router.ts +3 -5
  45. package/extensions/board/transport/server.ts +59 -28
  46. package/extensions/board/types.ts +36 -42
  47. package/extensions/board/view/card.ts +229 -0
  48. package/extensions/board/view/client.ts +35 -16
  49. package/extensions/board/view/dialog.ts +169 -6
  50. package/extensions/board/view/empty.ts +47 -0
  51. package/extensions/board/view/keys.ts +13 -2
  52. package/extensions/board/view/live.ts +90 -28
  53. package/extensions/board/view/names.ts +13 -4
  54. package/extensions/board/view/page.ts +38 -13
  55. package/extensions/board/view/render.ts +219 -277
  56. package/extensions/board/view/styles.ts +130 -32
  57. package/extensions/board/view/team.ts +297 -76
  58. package/extensions/bytefly/block/fallback.ts +1 -1
  59. package/extensions/bytefly/doctor/runtime.ts +5 -2
  60. package/extensions/bytefly/footer/collect/label.ts +60 -0
  61. package/extensions/bytefly/footer/collect/snapshot.ts +8 -1
  62. package/extensions/bytefly/footer/config.ts +7 -8
  63. package/extensions/bytefly/footer/register.ts +33 -4
  64. package/extensions/bytefly/footer/render/footer.ts +18 -4
  65. package/extensions/bytefly/footer/render/key.ts +46 -0
  66. package/extensions/bytefly/footer/render/layout.ts +13 -3
  67. package/extensions/bytefly/footer/render/lines.ts +10 -7
  68. package/extensions/bytefly/footer/render/segments.ts +41 -38
  69. package/extensions/bytefly/footer/render/status.ts +0 -11
  70. package/extensions/bytefly/footer/types.ts +7 -0
  71. package/extensions/bytefly/header/component.ts +4 -20
  72. package/extensions/bytefly/header/config.ts +1 -1
  73. package/extensions/bytefly/index.ts +32 -6
  74. package/extensions/bytefly/mode/editor.ts +115 -0
  75. package/extensions/bytefly/panel/commands.ts +210 -120
  76. package/extensions/bytefly/panel/format.ts +4 -4
  77. package/extensions/bytefly/panel/settings.ts +73 -6
  78. package/extensions/bytefly/panel/tui/keys.ts +2 -6
  79. package/extensions/bytefly/update/cache.ts +1 -1
  80. package/extensions/bytefly/update/register.ts +4 -4
  81. package/extensions/bytefly/working/config.ts +5 -7
  82. package/extensions/bytefly/working/format.ts +10 -13
  83. package/extensions/bytefly/working/register.ts +22 -27
  84. package/extensions/check/commands.ts +3 -2
  85. package/extensions/check/render.ts +10 -1
  86. package/extensions/check/{tui.ts → view/check-screens.ts} +14 -19
  87. package/extensions/context/commands.ts +2 -2
  88. package/extensions/context/{tui.ts → view/context-report.ts} +51 -19
  89. package/extensions/file-browse/config/config.ts +4 -19
  90. package/extensions/file-browse/index.ts +89 -79
  91. package/extensions/file-browse/live/live.ts +1 -1
  92. package/extensions/file-browse/transport/router.ts +12 -4
  93. package/extensions/file-browse/transport/server.ts +61 -28
  94. package/extensions/file-browse/view/chrome.ts +4 -4
  95. package/extensions/file-browse/view/client.ts +3 -1
  96. package/extensions/file-browse/view/html.ts +73 -40
  97. package/extensions/file-browse/view/live-client.ts +2 -2
  98. package/extensions/file-browse/view/markdown.ts +13 -14
  99. package/extensions/file-browse/view/palette.ts +2 -2
  100. package/extensions/file-browse/view/raw-html.ts +9 -9
  101. package/extensions/file-browse/view/tree.ts +1 -1
  102. package/extensions/file-browse/view/urls.ts +20 -6
  103. package/extensions/file-read/render.ts +4 -4
  104. package/extensions/goal/plan-route.ts +2 -2
  105. package/extensions/goal/tui.ts +12 -2
  106. package/extensions/job/child.ts +527 -0
  107. package/extensions/job/clock.ts +142 -0
  108. package/extensions/job/commands.ts +4 -2
  109. package/extensions/job/config.ts +8 -1
  110. package/extensions/job/format.ts +22 -1
  111. package/extensions/job/log-writer.ts +13 -2
  112. package/extensions/job/registry.ts +63 -7
  113. package/extensions/job/render.ts +34 -0
  114. package/extensions/job/runtime.ts +296 -655
  115. package/extensions/job/session.ts +132 -19
  116. package/extensions/job/store.ts +7 -7
  117. package/extensions/job/tools.ts +21 -9
  118. package/extensions/job/types.ts +47 -0
  119. package/extensions/job/{tui.ts → view/jobs-screen.ts} +19 -99
  120. package/extensions/job/waiters.ts +44 -1
  121. package/extensions/memory/commands.ts +4 -2
  122. package/extensions/memory/{tui.ts → view/memory-picker.ts} +12 -12
  123. package/extensions/plan/commands.ts +12 -9
  124. package/extensions/plan/feedback.ts +62 -16
  125. package/extensions/plan/format.ts +8 -2
  126. package/extensions/plan/git-bash.ts +115 -16
  127. package/extensions/plan/index.ts +58 -51
  128. package/extensions/plan/plan-index.ts +4 -2
  129. package/extensions/plan/plan-text.ts +114 -0
  130. package/extensions/plan/plans.ts +5 -54
  131. package/extensions/plan/policy.ts +8 -10
  132. package/extensions/plan/review-lazy.ts +85 -0
  133. package/extensions/plan/review.ts +2 -2
  134. package/extensions/plan/runtime.ts +61 -41
  135. package/extensions/plan/sections.ts +1 -1
  136. package/extensions/plan/steps.ts +3 -3
  137. package/extensions/plan/todos.ts +1 -1
  138. package/extensions/plan/tools/cancel.ts +27 -62
  139. package/extensions/plan/tools/enter.ts +55 -81
  140. package/extensions/plan/tools/exit.ts +221 -238
  141. package/extensions/plan/tools/refusal.ts +18 -0
  142. package/extensions/plan/tools/steps.ts +75 -92
  143. package/extensions/plan/tools/write.ts +77 -120
  144. package/extensions/plan/tools.ts +188 -30
  145. package/extensions/plan/tui.ts +5 -5
  146. package/extensions/plan/types.ts +48 -5
  147. package/extensions/plan/web-blocks.ts +76 -5
  148. package/extensions/plan/web-chrome.ts +4 -0
  149. package/extensions/plan/web-client.ts +102 -2
  150. package/extensions/plan/web-collab.ts +4 -4
  151. package/extensions/plan/web-comments.ts +39 -8
  152. package/extensions/plan/web-composer.ts +14 -3
  153. package/extensions/plan/web-keys.ts +149 -25
  154. package/extensions/plan/web-lazy.ts +53 -0
  155. package/extensions/plan/web-markdown.ts +10 -68
  156. package/extensions/plan/web-outline.ts +22 -2
  157. package/extensions/plan/web-page.ts +56 -86
  158. package/extensions/plan/web-payload.ts +188 -0
  159. package/extensions/plan/web-plan.ts +23 -13
  160. package/extensions/plan/web-router.ts +18 -9
  161. package/extensions/plan/web-settle.ts +14 -7
  162. package/extensions/plan/web-share-session.ts +24 -55
  163. package/extensions/plan/web-share.ts +19 -37
  164. package/extensions/plan/web-write.ts +112 -0
  165. package/extensions/plan/web.ts +67 -131
  166. package/extensions/question/tools.ts +6 -2
  167. package/extensions/question/{tui.ts → view/question-screen.ts} +11 -11
  168. package/extensions/rewind/commands/commands.ts +6 -3
  169. package/extensions/rewind/state/runtime.ts +1 -1
  170. package/extensions/rewind/{view/status.ts → status.ts} +4 -4
  171. package/extensions/rewind/view/review-tui.ts +4 -4
  172. package/extensions/serve/config.ts +39 -0
  173. package/extensions/serve/index.ts +85 -0
  174. package/extensions/serve/registry.ts +260 -0
  175. package/extensions/session-bus/commands.ts +11 -6
  176. package/extensions/session-bus/join-request.ts +17 -9
  177. package/extensions/session-bus/mail-watch.ts +26 -17
  178. package/extensions/session-bus/protocol.ts +15 -3
  179. package/extensions/session-bus/resolve.ts +5 -7
  180. package/extensions/session-bus/result.ts +1 -27
  181. package/extensions/session-bus/runtime.ts +40 -12
  182. package/extensions/session-bus/store.ts +101 -19
  183. package/extensions/session-bus/tools.ts +5 -4
  184. package/extensions/session-bus/types.ts +19 -0
  185. package/extensions/session-bus/{tui.ts → view/session-list.ts} +16 -16
  186. package/extensions/subagent/agents.ts +1 -1
  187. package/extensions/subagent/commands.ts +4 -2
  188. package/extensions/subagent/format.ts +15 -15
  189. package/extensions/subagent/index.ts +1 -1
  190. package/extensions/subagent/render.ts +3 -3
  191. package/extensions/subagent/{background-tui.ts → view/agents-board.ts} +11 -11
  192. package/extensions/team/commands.ts +24 -5
  193. package/extensions/team/dispatch.ts +18 -4
  194. package/extensions/team/format.ts +17 -2
  195. package/extensions/team/index.ts +9 -8
  196. package/extensions/team/schema.ts +6 -5
  197. package/extensions/team/tools.ts +107 -45
  198. package/extensions/team/types.ts +3 -2
  199. package/extensions/todo/commands.ts +4 -2
  200. package/extensions/todo/format.ts +11 -0
  201. package/extensions/todo/render.ts +57 -0
  202. package/extensions/todo/runtime.ts +1 -1
  203. package/extensions/todo/tools.ts +1 -1
  204. package/extensions/todo/{tui.ts → view/todo-screen.ts} +18 -90
  205. package/extensions/todo/widget.ts +47 -0
  206. package/extensions/watch/commands.ts +4 -2
  207. package/extensions/watch/format.ts +36 -7
  208. package/extensions/watch/render.ts +57 -0
  209. package/extensions/watch/runtime.ts +27 -22
  210. package/extensions/watch/tools.ts +4 -4
  211. package/extensions/watch/{tui.ts → view/watch-screen.ts} +18 -80
  212. package/extensions/web-access/fetch/tool.ts +12 -4
  213. package/extensions/wechat/commands/commands.ts +18 -14
  214. package/extensions/wechat/index.ts +72 -37
  215. package/extensions/wechat/state/credentials.ts +1 -1
  216. package/extensions/wechat/state/state.ts +1 -1
  217. package/extensions/wechat/text/status.ts +112 -0
  218. package/extensions/wechat/types.ts +2 -2
  219. package/extensions/wechat/view/status-screen.ts +4 -103
  220. package/extensions/worktree/commands.ts +11 -9
  221. package/extensions/worktree/enter.ts +2 -2
  222. package/extensions/worktree/exit.ts +2 -2
  223. package/extensions/worktree/index.ts +5 -5
  224. package/extensions/worktree/screen.ts +3 -3
  225. package/extensions/worktree/status.ts +2 -2
  226. package/extensions/worktree/tools.ts +113 -116
  227. package/lib/README.md +28 -13
  228. package/lib/board/actions.ts +111 -0
  229. package/lib/board/artifact.ts +149 -0
  230. package/lib/board/card.ts +163 -52
  231. package/lib/board/config.ts +58 -0
  232. package/lib/board/contract.ts +113 -0
  233. package/lib/board/join.ts +38 -0
  234. package/lib/board/lanes.ts +182 -0
  235. package/lib/board/lead.ts +130 -0
  236. package/lib/board/limits.ts +78 -0
  237. package/lib/board/links.ts +31 -10
  238. package/lib/board/page-pointer.ts +30 -5
  239. package/lib/board/read.ts +83 -0
  240. package/lib/board/reason.ts +102 -0
  241. package/lib/board/round-text.ts +68 -6
  242. package/lib/board/round.ts +31 -15
  243. package/lib/board/rules.ts +151 -307
  244. package/lib/board/seat.ts +35 -0
  245. package/lib/board/session.ts +199 -51
  246. package/lib/board/skipped.ts +22 -0
  247. package/lib/board/steps.ts +487 -0
  248. package/lib/board/store.ts +119 -36
  249. package/lib/board/tone.ts +92 -0
  250. package/lib/board/view.ts +81 -63
  251. package/lib/bus-request.ts +83 -0
  252. package/{extensions/watch → lib}/child.ts +40 -21
  253. package/lib/env.ts +65 -7
  254. package/lib/format.ts +13 -0
  255. package/lib/http/index.ts +17 -11
  256. package/lib/http/mount.ts +142 -0
  257. package/lib/http/port.ts +30 -0
  258. package/lib/http/serve.ts +170 -46
  259. package/lib/http/server.ts +3 -3
  260. package/lib/lazy.ts +45 -0
  261. package/lib/plan-entry.ts +46 -21
  262. package/lib/plans/paths.ts +82 -0
  263. package/lib/policy.ts +9 -6
  264. package/lib/report.ts +3 -2
  265. package/lib/requirements.ts +10 -10
  266. package/lib/session-label.ts +129 -7
  267. package/lib/session-roster.ts +43 -1
  268. package/lib/session-tool.ts +39 -22
  269. package/lib/stray-fields.ts +7 -2
  270. package/lib/tool-names.ts +5 -10
  271. package/lib/tui/README.md +29 -11
  272. package/lib/tui/chrome.ts +16 -15
  273. package/lib/tui/cursor.ts +18 -3
  274. package/lib/tui/glyphs.ts +11 -11
  275. package/lib/tui/render-cache.ts +29 -0
  276. package/lib/tui/tokens.ts +33 -2
  277. package/lib/tui/tool.ts +33 -4
  278. package/lib/web/README.md +21 -0
  279. package/lib/web/client-runtime.ts +27 -1
  280. package/lib/web/components.ts +5 -1
  281. package/lib/web/index.ts +1 -0
  282. package/lib/web/markdown.ts +83 -0
  283. package/lib/web/markup.ts +20 -2
  284. package/lib/web/runtime.ts +9 -10
  285. package/package.json +8 -2
  286. package/skills/init/SKILL.md +5 -5
  287. package/skills/next/SKILL.md +3 -5
  288. package/extensions/board/config.ts +0 -36
  289. package/extensions/board/seat.ts +0 -28
  290. package/lib/tui/index.ts +0 -34
  291. package/skills/project-docs/SKILL.md +0 -133
  292. package/skills/project-docs/references/audit.md +0 -131
  293. package/skills/project-docs/references/changelog.md +0 -105
  294. package/skills/project-docs/references/discovery.md +0 -127
  295. package/skills/project-docs/references/readme.md +0 -80
  296. package/skills/project-docs/references/release.md +0 -84
  297. package/skills/project-docs/references/roadmap.md +0 -93
  298. package/skills/project-docs/scripts/state.ts +0 -316
@@ -1,138 +1,67 @@
1
1
  /**
2
- * Job runtime: the session-scoped job table and process lifecycle.
2
+ * Job runtime: the session-scoped job table and the durable record.
3
3
  *
4
- * Owns the in-memory `Job` map, live child handles, callbacks, and the repaint
5
- * clock, and composes the smaller pieces: `store.ts` (durable registry),
6
- * `logs.ts` (log tails), `log-writer.ts` (the per-job stream, line split, and
7
- * rotation), `status.ts` (exit-status recovery), `ui.ts` (status chips), and
8
- * `waiters.ts`.
4
+ * Owns the in-memory `Job` map and its transitions, and composes the smaller
5
+ * pieces: `child.ts` (a job's process and its output), `clock.ts` (the repaint
6
+ * clock and the reattached-job sweep), `store.ts` (the durable registry),
7
+ * `ui.ts` (status chips), and `waiters.ts`.
9
8
  *
10
9
  * Registry model: each session is the sole writer of its own
11
10
  * `registry-<hash>.json`. `load` merges every session's file and adopts the
12
11
  * records of dead peer sessions (so a detached server reattaches and a crashed
13
- * session's history survives) before reconciling pids. The process/spawn/clock
14
- * functions are injectable so the whole runtime can be driven by a fake child in
15
- * tests.
12
+ * session's history survives) before reconciling pids. The process seams are
13
+ * injectable so the whole runtime can be driven by a fake child in tests.
16
14
  *
17
15
  * Concurrency: the runtime assumes the tool is `executionMode: "sequential"`,
18
16
  * but job completion is asynchronous, so `finalize` is guarded against running
19
17
  * twice and listeners/waiters are removed on cleanup.
20
18
  */
21
19
 
22
- import { existsSync, mkdirSync, unlinkSync, writeFileSync } from "node:fs";
23
20
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
21
+ import { KILL_GRACE_MS } from "../../lib/child.ts";
22
+ import { abortableSleep } from "../../lib/timer.ts";
24
23
  import { resolveEffectiveCwd } from "../../lib/env.ts";
24
+ import type { KillTreeFn, SpawnFn } from "../../lib/spawn.ts";
25
+ import { createJobChildren, type JobSeams } from "./child.ts";
26
+ import { createExternalSweep, createRepaintClock } from "./clock.ts";
27
+ import { DEFAULT_CONFIG, loadConfig, type JobsConfig } from "./config.ts";
28
+ import { compareJobs } from "./format.ts";
25
29
  import {
26
- defaultKillTree,
27
- defaultSpawn,
28
- isPidAlive,
29
- type KillTreeFn,
30
- type SpawnedProcess,
31
- type SpawnFn,
32
- startTokenOf,
33
- type StdinSink,
34
- } from "../../lib/spawn.ts";
35
- import { loadConfig, type JobsConfig } from "./config.ts";
36
- import { createLogWriter, type LogHandle } from "./log-writer.ts";
37
- import { sanitizeUntrustedLine } from "../../lib/format.ts";
38
- import { defaultInterval } from "../../lib/timer.ts";
39
- import { clipLabel } from "../../lib/tui/columns.ts";
40
- import { compareJobs, formatLogs } from "./format.ts";
41
- import { readLogTail } from "./logs.ts";
42
- import {
30
+ loadRecords,
43
31
  loadRegistries,
44
- mergeRecords,
45
- planReconcile,
46
32
  registryDirFor,
47
33
  removeLegacyRegistry,
48
34
  removeRegistry,
49
35
  settleGone,
50
36
  } from "./registry.ts";
37
+ import { createOwnership, removeSessionMarker } from "./session.ts";
51
38
  import {
52
- isSessionAlive,
53
- pruneSessionMarkers,
54
- readSessionMarker,
55
- removeSessionMarker,
56
- touchSessionMarker,
57
- } from "./session.ts";
58
- import { LOG_ENV, STATUS_ENV, readExitStatus, withExitTrap } from "./status.ts";
59
- import { type Job, type JobRecord, type JobStatus, type KillSignal } from "./types.ts";
39
+ type Job,
40
+ type JobRecord,
41
+ type JobStatus,
42
+ type KillSignal,
43
+ type LogResult,
44
+ type SendOptions,
45
+ type SendResult,
46
+ type StartOptions,
47
+ type WaitResult,
48
+ } from "./types.ts";
60
49
  import { createUiController } from "./ui.ts";
61
- import { createJobStore, toRecord } from "./store.ts";
62
- import { createWaiters } from "./waiters.ts";
63
-
64
- /** Characters of sanitized log sent to the model. */
65
- const LOG_MODEL_CHARS = 16 * 1024;
66
- /** Default `wait` timeout. */
67
- const DEFAULT_WAIT_MS = 30_000;
68
-
69
- export interface StartOptions {
70
- command: string;
71
- cwd?: string;
72
- label?: string;
73
- wake?: boolean;
74
- detached?: boolean;
75
- /** Give the child a stdin pipe, so `send` can write to it while it runs here. */
76
- stdin?: boolean;
77
- /** Auto-kill the job after this many milliseconds (SIGTERM, escalating to SIGKILL). */
78
- timeoutMs?: number;
79
- }
80
-
81
- export interface LogResult {
82
- job: Job;
83
- /** Model-facing render of the tail, including status header/truncation note. */
84
- text: string;
85
- /** Sanitized tail lines, for the `/jobs` log pane. */
86
- lines: string[];
87
- truncated: boolean;
88
- /** True when earlier log lines exist beyond the returned window. */
89
- more: boolean;
90
- }
91
-
92
- export interface WaitResult {
93
- job: Job;
94
- timedOut: boolean;
95
- cancelled: boolean;
96
- }
97
-
98
- export interface SendOptions {
99
- /** Text written to the job's stdin. */
100
- input?: string;
101
- /** Append a newline to `input`; defaults to true. */
102
- newline?: boolean;
103
- /** End the job's stdin after writing, so the child sees EOF. */
104
- eof?: boolean;
105
- }
106
-
107
- export interface SendResult {
108
- job: Job;
109
- /** Characters of `input` written, excluding any newline this call added. */
110
- sent: number;
111
- /** True when this call ended the job's stdin. */
112
- closed: boolean;
113
- }
50
+ import { createJobStore, type JobStore, toRecord } from "./store.ts";
51
+ import { createWaiters, waitFor } from "./waiters.ts";
114
52
 
115
53
  export interface RuntimeOptions {
116
54
  spawn?: SpawnFn;
117
- liveness?: typeof isPidAlive;
55
+ liveness?: (pid: number) => boolean;
118
56
  killTree?: KillTreeFn;
119
- startToken?: typeof startTokenOf;
57
+ startToken?: (pid: number) => string | undefined;
120
58
  now?: () => number;
121
59
  /** Platform seam: whether this is Windows. Defaults to the running platform. */
122
60
  isWindows?: () => boolean;
123
61
  /** Fixed config, for tests; production loads it per cwd. */
124
62
  config?: JobsConfig;
125
- }
126
-
127
- interface Handle {
128
- /** The job's log: stream, line split, rotation, and the repaint window. */
129
- log: LogHandle;
130
- killTimer?: ReturnType<typeof setTimeout>;
131
- timeoutTimer?: ReturnType<typeof setTimeout>;
132
- /** The child's stdin pipe, present only for a job started with `stdin: true`. */
133
- stdin?: StdinSink | null;
134
- /** True once stdin was ended, or the child closed its end. */
135
- stdinClosed?: boolean;
63
+ /** How long to wait before a shutdown's SIGKILL; the grace, injectable so a test need not sleep. */
64
+ sleep?: (ms: number) => Promise<void>;
136
65
  }
137
66
 
138
67
  export interface JobsRuntime {
@@ -142,8 +71,8 @@ export interface JobsRuntime {
142
71
  /** Effective working directory, honoring an active worktree root. */
143
72
  effectiveCwd(ctx: ExtensionContext): string;
144
73
  /** Load and reconcile the registry, then paint the UI. */
145
- load(ctx: ExtensionContext): void;
146
- start(options: StartOptions, ctx: ExtensionContext): Job;
74
+ load(ctx: ExtensionContext): Promise<void>;
75
+ start(options: StartOptions, ctx: ExtensionContext): Promise<Job>;
147
76
  list(): JobRecord[];
148
77
  get(id: string): Job | undefined;
149
78
  logs(id: string, lines?: number): LogResult | undefined;
@@ -152,7 +81,7 @@ export interface JobsRuntime {
152
81
  * gone, was started without `stdin: true`, or belongs to another session.
153
82
  */
154
83
  send(id: string, options: SendOptions): SendResult;
155
- kill(id: string, signal?: KillSignal): Job | undefined;
84
+ kill(id: string, signal?: KillSignal): Promise<Job | undefined>;
156
85
  wait(
157
86
  id: string,
158
87
  timeoutMs: number | undefined,
@@ -170,31 +99,60 @@ export interface JobsRuntime {
170
99
  /** Kill session-owned jobs (unless detached), stop the clock, persist. */
171
100
  shutdown(): Promise<void>;
172
101
  /** Called when a `wake` job finishes; the index triggers one turn. */
173
- onFinish?: (job: Job) => void;
102
+ onFinish?: (job: JobRecord) => void;
174
103
  /**
175
- * Called when any job reaches a terminal state, whether it was watched to
176
- * completion or observed gone on the clock. The index bridges it to the event
177
- * bus; the runtime itself never reaches for a listener.
104
+ * Called when any job reaches a terminal state, however it settled. The index bridges
105
+ * it to the event bus; the runtime itself never reaches for a listener.
178
106
  */
179
107
  onComplete?: (job: JobRecord) => void;
180
108
  }
181
109
 
182
110
  export function createJobsRuntime(options: RuntimeOptions = {}): JobsRuntime {
183
- const spawn = options.spawn ?? defaultSpawn;
184
- const liveness = options.liveness ?? isPidAlive;
185
- const killTree = options.killTree ?? defaultKillTree;
186
- const startToken = options.startToken ?? startTokenOf;
111
+ /**
112
+ * The process seam, loaded when a session first loads the registry.
113
+ *
114
+ * Building the runtime is registration: it happens on every Pi start, and
115
+ * `lib/spawn.ts` pulls `node:child_process` with it. The load itself has to answer
116
+ * whether recorded pids are alive, so the seam arrives with the first `load`
117
+ * rather than with the factory — and a runtime whose pids are all its own never
118
+ * needs the platform's default at all.
119
+ */
120
+ let seams: Promise<JobSeams> | undefined;
121
+ const jobSeams = (): Promise<JobSeams> => {
122
+ seams ??= Promise.all([import("../../lib/spawn.ts"), import("../../lib/lines.ts")]).then(([spawn, lines]) => ({
123
+ spawn: options.spawn ?? spawn.defaultSpawn,
124
+ liveness: options.liveness ?? spawn.isPidAlive,
125
+ killTree: options.killTree ?? spawn.defaultKillTree,
126
+ startToken: options.startToken ?? spawn.startTokenOf,
127
+ newSplitter: () => new lines.LineSplitter(),
128
+ }));
129
+ return seams;
130
+ };
131
+ /** The seam once it has loaded; every path that asks about a pid has awaited it. */
132
+ let syncSeams: JobSeams | undefined;
133
+
134
+ /** Load the seam when it is not here yet, so a pid question can be asked. */
135
+ const openSeam = async (): Promise<void> => {
136
+ syncSeams ??= await jobSeams();
137
+ };
138
+
187
139
  const now = options.now ?? (() => Date.now());
140
+ const sleep = options.sleep ?? abortableSleep;
188
141
  const isWindows = options.isWindows ?? (() => process.platform === "win32");
189
142
 
143
+ // The config is read by `load`, once the session has a cwd; until then the shipped
144
+ // defaults answer, and a test's fixed config overrides both.
145
+ let config: JobsConfig = options.config ?? DEFAULT_CONFIG;
146
+ let jobStore: JobStore | undefined;
147
+ const store = (): JobStore => {
148
+ jobStore ??= createJobStore();
149
+ return jobStore;
150
+ };
151
+
190
152
  const jobs = new Map<string, Job>();
191
- const handles = new Map<string, Handle>();
192
153
  const waiters = createWaiters();
193
- const store = createJobStore();
194
- let config: JobsConfig = options.config ?? loadConfig(process.cwd());
195
154
  let sessionId = "session";
196
155
  let disposed = false;
197
- let clock: (() => void) | undefined;
198
156
 
199
157
  /** Jobs this session owns (a dead session's adopted records included). */
200
158
  const ownJobs = (): Job[] => [...jobs.values()].filter((job) => job.sessionId === sessionId);
@@ -204,114 +162,78 @@ export function createJobsRuntime(options: RuntimeOptions = {}): JobsRuntime {
204
162
 
205
163
  const effectiveCwd = (ctx: ExtensionContext): string => resolveEffectiveCwd(ctx.cwd);
206
164
 
207
- const persist = (): void => store.persist(jobs.values());
165
+ const persist = (): void => store().persist(jobs.values());
208
166
 
209
- /** Delete a job's log file, best-effort. */
210
- const removeLog = (job: JobRecord): void => {
211
- try {
212
- const path = store.safeLogPath(job);
213
- if (path && existsSync(path)) unlinkSync(path);
214
- // Both segments are the job's own log; drop the rotated one too.
215
- const rotated = store.safeRotatedLogPath(job);
216
- if (rotated && existsSync(rotated)) unlinkSync(rotated);
217
- } catch {
218
- // Log cleanup is best-effort.
219
- }
167
+ /** A fresh line splitter for one job's log, loaded with the seam that starts it. */
168
+ const newSplitter = (): ReturnType<JobSeams["newSplitter"]> => {
169
+ if (syncSeams === undefined) throw new Error("the job process seam is not loaded yet.");
170
+ return syncSeams.newSplitter();
220
171
  };
221
172
 
222
- /** Delete a job's status file once its code has been observed, best-effort. */
223
- const removeStatus = (job: JobRecord): void => {
224
- try {
225
- const path = store.safeStatusPath(job);
226
- if (path && existsSync(path)) unlinkSync(path);
227
- } catch {
228
- // Status cleanup is best-effort.
229
- }
230
- };
173
+ /** The process seam once it has loaded; a pid question before then answers as it always did. */
174
+ const jobSeam = (): JobSeams | undefined => syncSeams;
231
175
 
232
- /** Remove every durable artifact a job leaves behind. */
233
- const removeArtifacts = (job: JobRecord): void => {
234
- store.releaseId(job.id);
235
- removeStatus(job);
236
- removeLog(job);
237
- };
238
-
239
- /** Refresh this session's liveness marker while it runs jobs. */
240
- const touchMarker = (): void => {
241
- if (disposed) return;
242
- touchSessionMarker(store.directory(), sessionId, process.pid, now());
243
- };
244
-
245
- /** Whether the session that owns a job is still alive. */
246
- const ownerAlive = (owner: string): boolean =>
247
- owner === sessionId ||
248
- isSessionAlive(readSessionMarker(store.directory(), owner), now(), config.sessionTtlMs, liveness);
176
+ /** The listeners the index arms on session start; `undefined` after shutdown. */
177
+ let onCompleteHandler: ((job: JobRecord) => void) | undefined;
178
+ let onFinishHandler: ((job: JobRecord) => void) | undefined;
249
179
 
250
- const stopClock = (): void => {
251
- clock?.();
252
- clock = undefined;
253
- };
180
+ /** Settled before its listener was armed — a load finishes first — and replayed by it. */
181
+ let heldCompletions: JobRecord[] = [];
182
+ let heldWakes: JobRecord[] = [];
254
183
 
255
- const ensureClock = (): void => {
256
- const running = [...jobs.values()].some((job) => job.status === "running");
257
- if (disposed || !running) {
258
- stopClock();
184
+ /** Publish a terminal transition unless the runtime is shutting down or was reloaded. */
185
+ const announceComplete = (job: JobRecord): void => {
186
+ if (disposed) return;
187
+ if (onCompleteHandler === undefined) {
188
+ heldCompletions.push(toRecord(job));
259
189
  return;
260
190
  }
261
- if (clock) return;
262
- clock = defaultInterval(config.repaintMs, () => {
263
- touchMarker();
264
- pollExternal();
265
- // Re-assert the chip every tick. It is otherwise only set on a job
266
- // transition, so anything that clears extension statuses (a session reload,
267
- // another UI path) would hide a still-running job's chip until the next
268
- // transition. The clock is the only periodic hook while jobs run.
269
- ui.setStatus();
270
- });
191
+ try {
192
+ onCompleteHandler(toRecord(job));
193
+ } catch {
194
+ // A completion listener must not break the process bookkeeping.
195
+ }
271
196
  };
272
197
 
273
- const paint = (): void => {
274
- ensureClock();
275
- touchMarker();
276
- if (disposed) return;
277
- ui.paint();
198
+ /** Fire the one wake turn a finished `wake` job asked for, or hold it until one is armed. */
199
+ const wakeIfNeeded = (job: JobRecord): void => {
200
+ if (!job.wake || disposed) return;
201
+ if (onFinishHandler === undefined) {
202
+ heldWakes.push(toRecord(job));
203
+ return;
204
+ }
205
+ try {
206
+ onFinishHandler(job);
207
+ } catch {
208
+ // Wake is best-effort.
209
+ }
278
210
  };
279
211
 
280
212
  /**
281
- * Whether a persisted start-time token still matches the live pid.
282
- * Returns true when either side is unavailable, so callers fall back to
283
- * liveness alone; a mismatch means the pid was reused and must not be signalled.
213
+ * Announce one terminal transition: the completion, then the wake turn it asked for. Every
214
+ * way a record settles ends here — a close event, a pid observed gone, a peer's leftover
215
+ * reaped, an orphan killed at load — so no path announces half the pair.
284
216
  */
285
- const tokenMatches = (job: JobRecord): boolean => {
286
- if (!job.startToken || job.pid === null) return true;
287
- const current = startToken(job.pid);
288
- return !current || current === job.startToken;
289
- };
290
-
291
- /** Recover an exit code from a job's status file, guarded by the trusted path. */
292
- const exitCodeFor = (job: JobRecord): number | undefined => readExitStatus(store.safeStatusPath(job));
293
-
294
- /** A completion listener, published from the index; `undefined` after shutdown. */
295
- let onCompleteHandler: ((job: JobRecord) => void) | undefined;
296
-
297
- /** Publish a terminal transition unless the runtime is shutting down or was reloaded. */
298
- const announceComplete = (job: Job): void => {
299
- if (disposed) return;
300
- try {
301
- onCompleteHandler?.(toRecord(job));
302
- } catch {
303
- // A completion listener must not break the process bookkeeping.
304
- }
217
+ const publishSettled = (job: JobRecord): void => {
218
+ announceComplete(job);
219
+ wakeIfNeeded(job);
305
220
  };
306
221
 
307
222
  /** Move a record to a settled status, resolving any waiters and announcing it. */
308
- const settle = (job: Job, patch: { status: JobStatus; exitCode?: number | null; signal?: string | null }): void => {
223
+ const settle = (
224
+ job: JobRecord,
225
+ patch: { status: JobStatus; exitCode?: number | null; signal?: string | null },
226
+ ): void => {
227
+ // A record this session settles is this session's now: the only peer record the
228
+ // table settles is one whose owner is gone, and until it is adopted its completion
229
+ // is reportable by nobody.
230
+ job.sessionId = sessionId;
309
231
  job.status = patch.status;
310
232
  job.exitCode = patch.exitCode ?? null;
311
233
  job.signal = patch.signal ?? null;
312
234
  job.finishedAt = now();
313
235
  waiters.resolve(job.id);
314
- announceComplete(job);
236
+ publishSettled(job);
315
237
  };
316
238
 
317
239
  /**
@@ -322,54 +244,20 @@ export function createJobsRuntime(options: RuntimeOptions = {}): JobsRuntime {
322
244
  * every clock tick would be a notification storm.
323
245
  */
324
246
  const settleGoneJob = (job: Job): boolean => {
325
- Object.assign(job, settleGone(job, { isAlive: liveness, tokenMatches, readExitCode: exitCodeFor, now: now() }));
247
+ Object.assign(
248
+ job,
249
+ settleGone(job, {
250
+ isAlive: children.alive,
251
+ tokenMatches: children.owns,
252
+ readExitCode: children.exitCode,
253
+ now: now(),
254
+ }),
255
+ );
326
256
  if (job.status === "running") return false;
327
- announceComplete(job);
257
+ publishSettled(job);
328
258
  return true;
329
259
  };
330
260
 
331
- /** Poll reattached (unowned) jobs, which have no `close` event to observe. */
332
- const pollExternal = (): void => {
333
- touchMarker();
334
- let changed = false;
335
- for (const job of jobs.values()) {
336
- if (job.status !== "running" || job.owned) continue;
337
- const peer = job.sessionId !== sessionId;
338
- const alive = peer ? ownerAlive(job.sessionId) : true;
339
- // A live peer session owns this job; leave its state to that owner.
340
- if (peer && alive) continue;
341
- if (job.pid === null || !liveness(job.pid)) {
342
- // A record with no pid cannot be judged gone; leave it alone rather than
343
- // announcing (and waking) on every tick.
344
- if (!settleGoneJob(job)) continue;
345
- // A reattached `wake` job has no `close` event, so this is the only place
346
- // its completion can trigger the one wake turn.
347
- wakeIfNeeded(job);
348
- changed = true;
349
- continue;
350
- }
351
- // The owner is gone and the job is not detached: reap the abandoned
352
- // process, guarding against pid reuse with the start token.
353
- if (peer && !job.detached) {
354
- if (tokenMatches(job)) {
355
- try {
356
- killTree(job.pid, "SIGTERM");
357
- } catch {
358
- // Already gone.
359
- }
360
- settle(job, { status: "killed", signal: "SIGTERM" });
361
- } else {
362
- settle(job, { status: "unknown" });
363
- }
364
- changed = true;
365
- }
366
- }
367
- if (!changed) return;
368
- pruneFinished();
369
- persist();
370
- if (!disposed) ui.paint();
371
- };
372
-
373
261
  const pruneFinished = (): void => {
374
262
  // Only this session's records are ours to drop; a live peer manages its own.
375
263
  // Prefer dropping already-reported jobs; an unseen completion is only pruned
@@ -382,97 +270,111 @@ export function createJobsRuntime(options: RuntimeOptions = {}): JobsRuntime {
382
270
  const job = finished.shift();
383
271
  if (!job) break;
384
272
  jobs.delete(job.id);
385
- removeArtifacts(job);
386
- }
387
- };
388
-
389
- /** Remember the newest non-empty output line, which `status`/`wait` report. */
390
- const recordLine = (job: Job, line: string): void => {
391
- const clean = sanitizeUntrustedLine(line);
392
- if (clean) job.lastLine = clean;
393
- };
394
-
395
- /** The per-job log sink: stream, line split, rotation, and the repaint window. */
396
- const logWriter = createLogWriter({
397
- now,
398
- logPath: (job) => store.safeLogPath(job),
399
- rotatedPath: (job) => store.safeRotatedLogPath(job),
400
- maxBytes: () => config.maxLogBytes,
401
- repaintMs: () => config.repaintMs,
402
- onLine: recordLine,
403
- onPaint: paint,
404
- });
405
-
406
- /** Clear a handle's timers, close its log stream, and drop it. */
407
- const disposeHandle = (id: string, options: { endStream: boolean }): void => {
408
- const handle = handles.get(id);
409
- if (!handle) return;
410
- if (handle.killTimer) clearTimeout(handle.killTimer);
411
- if (handle.timeoutTimer) clearTimeout(handle.timeoutTimer);
412
- logWriter.dispose(handle.log, options);
413
- // Release the stdin pipe so a child waiting on input sees EOF instead of a
414
- // half-open pipe nobody can write to.
415
- if (handle.stdin && !handle.stdinClosed) {
416
- try {
417
- handle.stdin.end();
418
- } catch {
419
- // Already closed.
420
- }
273
+ children.removeArtifacts(job);
421
274
  }
422
- handle.stdin = null;
423
- handle.stdinClosed = true;
424
- handles.delete(id);
425
- };
426
-
427
- const onChunk = (id: string, chunk: Buffer): void => {
428
- const handle = handles.get(id);
429
- const job = jobs.get(id);
430
- if (!handle || !job) return;
431
- logWriter.write(handle.log, job, chunk);
432
275
  };
433
276
 
434
277
  const finalize = (id: string, code: number | null, signal: string | null): void => {
435
- const handle = handles.get(id);
436
278
  const job = jobs.get(id);
437
- if (!handle || !job || job.status !== "running") return;
438
- // Commit any buffered output, including a final line without a newline.
439
- for (const line of logWriter.flush(handle.log)) recordLine(job, line);
440
- // A detached job keeps no parent stream; read its last line from the log.
441
- if (!handle.log.stream) {
442
- const last = readLogTail(store.safeLogPath(job), 1).lines.at(-1);
443
- if (last) job.lastLine = last;
444
- }
445
- disposeHandle(id, { endStream: true });
279
+ if (!children.get(id) || !job || job.status !== "running") return;
280
+ // Commit the child's buffered output, then let its handle go.
281
+ children.flush(job);
282
+ children.dispose(id, { endStream: true });
446
283
 
447
284
  const status: JobStatus = signal ? "killed" : code === 0 ? "exited" : "failed";
448
285
  settle(job, { status, exitCode: signal ? null : code, signal });
449
286
  // The close event already carried the code, so the status file is redundant.
450
- removeStatus(job);
287
+ children.removeStatus(job);
451
288
 
452
289
  pruneFinished();
453
290
  persist();
454
- paint();
291
+ clock.paint();
455
292
  if (!disposed && status === "failed") ui.notifyFailure(job);
456
- if (job.wake && !disposed) {
457
- try {
458
- onFinishHandler?.(job);
459
- } catch {
460
- // Wake is best-effort.
461
- }
462
- }
463
293
  };
464
294
 
465
- let onFinishHandler: ((job: Job) => void) | undefined;
295
+ /**
296
+ * A job's process and its output, over `lib/child.ts`'s supervisor.
297
+ *
298
+ * The record's questions — is this pid still ours, what did it exit with, what
299
+ * does this log say — stay in this module; the handle, the log stream, stdin and
300
+ * the escalation are the child's.
301
+ */
302
+ const children = createJobChildren({
303
+ seams: jobSeam,
304
+ store,
305
+ config: () => config,
306
+ now,
307
+ newSplitter,
308
+ table: {
309
+ record: (id) => jobs.get(id),
310
+ ids: () => jobs.keys(),
311
+ insert: (job) => jobs.set(job.id, job),
312
+ settle,
313
+ sessionId: () => sessionId,
314
+ changed: () => {
315
+ persist();
316
+ clock.paint();
317
+ },
318
+ },
319
+ onSettled: (id, outcome) => {
320
+ if (outcome.kind === "closed") finalize(id, outcome.code, outcome.signal);
321
+ else finalize(id, 1, null);
322
+ },
323
+ onPaint: () => clock.paint(),
324
+ });
466
325
 
467
- /** Fire the one wake turn for an unowned `wake` job that just finished. */
468
- const wakeIfNeeded = (job: Job): void => {
469
- if (!job.wake || disposed) return;
470
- try {
471
- onFinishHandler?.(job);
472
- } catch {
473
- // Wake is best-effort.
474
- }
475
- };
326
+ /**
327
+ * Who owns a job: `live`, `quiet`, or `gone`, and this session's own marker. `quiet` is
328
+ * not dead — the heartbeat only ticks while a job runs, so a peer that has finished its
329
+ * work stops writing one, and reading that as death adopted a live session's records.
330
+ */
331
+ const ownership = createOwnership({
332
+ directory: () => store().directory(),
333
+ sessionId: () => sessionId,
334
+ now,
335
+ ttlMs: () => config.sessionTtlMs,
336
+ alive: (pid) => children.alive(pid),
337
+ startToken: (pid) => children.startToken(pid),
338
+ });
339
+
340
+ /** Poll the jobs this session reattached, which have no `close` event to observe. */
341
+ const pollExternal = createExternalSweep({
342
+ records: () => jobs.values(),
343
+ sessionId: () => sessionId,
344
+ ownerPresent: (owner) => ownership.state(owner) !== "gone",
345
+ alive: (pid) => children.alive(pid),
346
+ owns: (job) => children.owns(job),
347
+ settleGone: settleGoneJob,
348
+ settle,
349
+ signal: (pid) => children.signal({ pid }, "SIGTERM"),
350
+ touchMarker: () => ownership.touch(),
351
+ changed: () => {
352
+ pruneFinished();
353
+ persist();
354
+ if (!disposed) ui.paint();
355
+ },
356
+ });
357
+
358
+ /**
359
+ * The repaint clock: the only periodic hook the runtime arms.
360
+ *
361
+ * Its tick touches this session's liveness marker, polls the jobs it
362
+ * reattached, and re-asserts the chip — which is otherwise only set on a job
363
+ * transition, so anything that clears extension statuses (a session reload,
364
+ * another UI path) would hide a still-running job's chip.
365
+ */
366
+ const clock = createRepaintClock({
367
+ repaintMs: () => config.repaintMs,
368
+ running: () => [...jobs.values()].some((job) => job.status === "running"),
369
+ disposed: () => disposed,
370
+ touchMarker: () => ownership.touch(),
371
+ tick: () => {
372
+ ownership.touch();
373
+ pollExternal();
374
+ ui.setStatus();
375
+ },
376
+ repaint: () => ui.paint(),
377
+ });
476
378
 
477
379
  const runtime: JobsRuntime = {
478
380
  get config() {
@@ -486,9 +388,9 @@ export function createJobsRuntime(options: RuntimeOptions = {}): JobsRuntime {
486
388
  start,
487
389
  list: () => [...jobs.values()].sort(compareJobs).map(toRecord),
488
390
  get: (id) => jobs.get(id),
489
- logs,
490
- send,
491
- kill,
391
+ logs: (id, lines) => children.logs(id, lines),
392
+ send: (id, options) => children.send(id, options),
393
+ kill: (id, signal) => children.kill(id, signal),
492
394
  wait,
493
395
  clear,
494
396
  pendingCompletions,
@@ -499,23 +401,39 @@ export function createJobsRuntime(options: RuntimeOptions = {}): JobsRuntime {
499
401
  get onFinish() {
500
402
  return onFinishHandler;
501
403
  },
502
- set onFinish(handler: ((job: Job) => void) | undefined) {
404
+ // A load settles its orphans before `session_start` arms this listener, so what it
405
+ // owed is replayed here rather than announced into nothing.
406
+ set onFinish(handler: ((job: JobRecord) => void) | undefined) {
503
407
  onFinishHandler = handler;
408
+ if (handler === undefined) return;
409
+ const held = heldWakes;
410
+ heldWakes = [];
411
+ for (const job of held) wakeIfNeeded(job);
504
412
  },
505
413
  get onComplete() {
506
414
  return onCompleteHandler;
507
415
  },
508
416
  set onComplete(handler: ((job: JobRecord) => void) | undefined) {
509
417
  onCompleteHandler = handler;
418
+ if (handler === undefined) return;
419
+ const held = heldCompletions;
420
+ heldCompletions = [];
421
+ for (const job of held) announceComplete(job);
510
422
  },
511
423
  };
512
424
 
513
- function load(ctx: ExtensionContext): void {
425
+ async function load(ctx: ExtensionContext): Promise<void> {
426
+ // The seam comes with the first load: this is the first path that asks whether a
427
+ // recorded pid is still alive, and the only one that needs `child_process`.
428
+ await openSeam();
514
429
  // A reload or session replacement can reuse this runtime after `shutdown`,
515
430
  // so reset all session state before re-reading the registry.
516
431
  disposed = false;
517
- stopClock();
518
- for (const id of [...handles.keys()]) disposeHandle(id, { endStream: true });
432
+ // Anything the last session settled unannounced belongs to its table, not this one.
433
+ heldCompletions = [];
434
+ heldWakes = [];
435
+ clock.stop();
436
+ children.disposeAll();
519
437
  waiters.resolveAll();
520
438
 
521
439
  // The durable registry is keyed by the session's own cwd, not the active
@@ -523,319 +441,59 @@ export function createJobsRuntime(options: RuntimeOptions = {}): JobsRuntime {
523
441
  // out from under the records already collected here. Jobs still run in the
524
442
  // effective root (`start` uses `effectiveCwd`).
525
443
  config = options.config ?? loadConfig(ctx.cwd);
526
- store.setDirectory(registryDirFor(ctx.cwd, config.registryDir));
444
+ store().setDirectory(registryDirFor(ctx.cwd, config.registryDir));
527
445
  sessionId = ctx.sessionManager.getSessionId();
528
- store.setSession(sessionId);
446
+ store().setSession(sessionId);
529
447
  ui.attach(ctx);
530
448
 
531
449
  // Announce this session, and drop markers for sessions that are gone.
532
- touchMarker();
533
- pruneSessionMarkers(store.directory(), now(), config.sessionTtlMs, liveness);
534
-
535
- const { files, counter } = loadRegistries(store.directory());
536
- store.setCounter(counter);
537
-
538
- // Merge every session's records. A file whose owner is gone is adopted: its
539
- // records will be rewritten under this session and its file deleted.
540
- const merged = new Map<string, JobRecord>();
541
- const deadSessions = new Set<string>();
542
- for (const file of files) {
543
- if (file.sessionId !== sessionId && !ownerAlive(file.sessionId)) deadSessions.add(file.sessionId);
544
- for (const record of file.jobs) {
545
- const existing = merged.get(record.id);
546
- merged.set(record.id, existing ? mergeRecords(existing, record, sessionId) : record);
547
- }
548
- }
450
+ ownership.touch();
451
+ ownership.prune();
549
452
 
550
- // Resolve dead pids (recovering exit codes), then classify the survivors.
551
- const settled = [...merged.values()].map((record) =>
552
- settleGone(record, { isAlive: liveness, tokenMatches, readExitCode: exitCodeFor, now: now() }),
553
- );
554
- const { jobs: reconciled, orphans } = planReconcile(settled, liveness, now(), {
555
- isOwnerAlive: ownerAlive,
556
- currentSessionId: sessionId,
453
+ const { files, counter } = loadRegistries(store().directory());
454
+ store().setCounter(counter);
455
+ const loaded = loadRecords(files, {
456
+ sessionId,
457
+ ownerState: ownership.state,
458
+ process: children,
459
+ now: now(),
557
460
  });
558
461
 
559
- for (const record of orphans) {
560
- if (record.pid !== null) {
561
- try {
562
- killTree(record.pid, "SIGTERM");
563
- } catch {
564
- // The process may already be gone.
565
- }
566
- }
567
- record.status = "killed";
568
- record.signal = "SIGTERM";
569
- record.finishedAt = now();
462
+ for (const record of loaded.orphans) {
463
+ if (record.pid !== null) children.signal({ pid: record.pid }, "SIGTERM");
464
+ // An orphan killed here is a terminal state like any other, so it is settled the
465
+ // same way — announced, and woken, once `session_start` arms the listeners.
466
+ settle(record, { status: "killed", signal: "SIGTERM" });
570
467
  }
571
468
 
572
469
  jobs.clear();
573
- for (const record of reconciled) {
574
- const adopted = deadSessions.has(record.sessionId) ? { ...record, sessionId } : record;
470
+ for (const record of loaded.records) {
471
+ const adopted = loaded.deadSessions.has(record.sessionId) ? { ...record, sessionId } : record;
575
472
  jobs.set(adopted.id, { ...adopted, owned: false });
576
473
  }
577
474
 
578
- // The adopted records now live in this session's file; drop the peers' files.
579
- for (const dead of deadSessions) removeRegistry(store.directory(), dead);
580
- removeLegacyRegistry(store.directory());
475
+ for (const dead of loaded.deadSessions) removeRegistry(store().directory(), dead);
476
+ removeLegacyRegistry(store().directory());
581
477
  // Ids now committed to a registry no longer need their reservation.
582
- store.pruneReservations(jobs.keys());
478
+ store().pruneReservations(jobs.keys());
583
479
 
584
480
  pruneFinished();
585
481
  persist();
586
- paint();
482
+ clock.paint();
587
483
  }
588
484
 
589
- function start(startOptions: StartOptions, ctx: ExtensionContext): Job {
485
+ async function start(startOptions: StartOptions, ctx: ExtensionContext): Promise<Job> {
590
486
  if (!config.enabled) throw new Error("jobs are disabled.");
591
487
  ui.attach(ctx);
592
- const wantsStdin = startOptions.stdin === true;
593
- // Windows has no process group to leave and no shell `EXIT` trap to record an
594
- // exit code, so a job there cannot outlive the session: refusing an explicit
595
- // detach is the honest answer, and it happens before any id is reserved.
596
- // `detachedByDefault` stays inert instead of failing every start with a config
597
- // the user set on another machine; `doctor` names that.
598
- const windows = isWindows();
599
- if (windows && startOptions.detached === true) {
600
- throw new Error(
601
- "A detached job cannot outlive the session on Windows: there is no process group to leave " +
602
- "and no shell EXIT trap to record its exit code. Start it with `detached: false`, " +
603
- "run it under WSL, or move the work into a `subagent`.",
604
- );
605
- }
606
- const detached = windows ? false : (startOptions.detached ?? config.detachedByDefault);
607
- // A detached job outlives this process, so a stdin pipe owned by this session
608
- // would hand it EOF the moment the session ends; refuse instead of silently
609
- // dropping the pipe (or the detach).
610
- if (wantsStdin && detached) {
611
- throw new Error("a detached job cannot take stdin; start it with `detached: false`.");
612
- }
613
488
  const cwd = startOptions.cwd ? startOptions.cwd : effectiveCwd(ctx);
614
- // The registry directory must exist before `nextId`, which reserves the id
615
- // with an `O_EXCL` lock file inside it.
616
- mkdirSync(store.directory(), { recursive: true });
617
- // `store.nextId` folds in the on-disk counters and ids from every session,
618
- // so a peer that advanced the shared counter cannot hand us a live job's id.
619
- const id = store.nextId(jobs.keys());
620
- const logPath = store.logPath(id);
621
- // Only a POSIX shell has an `EXIT` trap to record a status, and only a detached
622
- // job points its own output at the log so it keeps logging after this process
623
- // ends (which on Windows is never: `detached` is false there).
624
- const statusPath = windows ? null : store.statusPath(id);
625
- const detachOutput = detached;
626
-
627
- // Wrap the command so a POSIX shell records its exit status; the path travels
628
- // in the environment so the shell never has to quote it.
629
- const program = withExitTrap(startOptions.command, windows, detachOutput);
630
- const env: NodeJS.ProcessEnv = { ...process.env };
631
- if (statusPath) env[STATUS_ENV] = statusPath;
632
- if (detachOutput) env[LOG_ENV] = logPath;
633
- let process_: SpawnedProcess;
634
- try {
635
- process_ = spawn(program, { cwd, env, detachOutput, stdin: wantsStdin });
636
- } catch (error) {
637
- // A failed spawn must not leave the reserved id behind.
638
- store.releaseId(id);
639
- throw error;
640
- }
641
- const pid = process_.pid ?? null;
642
- if (detachOutput) process_.unref?.();
643
- // Create the file eagerly so `logs` works before the first write, then
644
- // append through a stream. A detached job's shell writes the file directly.
645
- try {
646
- writeFileSync(logPath, "", { flag: "a" });
647
- } catch {
648
- // A missing directory was already handled by mkdirSync above.
649
- }
650
- const job: Job = {
651
- id,
652
- label: sanitizeUntrustedLine((startOptions.label ?? "").trim()) || clipLabel(startOptions.command),
653
- command: startOptions.command,
654
- cwd,
655
- pid,
656
- status: "running",
657
- exitCode: null,
658
- signal: null,
659
- startedAt: now(),
660
- finishedAt: null,
661
- logPath,
662
- statusPath,
663
- detached,
664
- wake: startOptions.wake ?? config.wakeOnFinish,
665
- stdin: wantsStdin,
666
- sessionId,
667
- seen: false,
668
- lastLine: "",
669
- startToken: pid !== null ? (startToken(pid) ?? null) : null,
670
- owned: true,
671
- };
672
-
673
- const handle: Handle = {
674
- log: logWriter.create(detachOutput ? undefined : logPath),
675
- };
676
- if (wantsStdin) {
677
- const sink = process_.stdin ?? null;
678
- handle.stdin = sink;
679
- handle.stdinClosed = sink === null;
680
- // A child that exits can emit `error`/`close` on the pipe; record it so a
681
- // later `send` reports the truth instead of throwing an unhandled stream error.
682
- const markClosed = (): void => {
683
- const current = handles.get(id);
684
- if (current) current.stdinClosed = true;
685
- };
686
- sink?.on("error", markClosed);
687
- sink?.on("close", markClosed);
688
- }
689
- handles.set(id, handle);
690
- jobs.set(id, job);
691
-
692
- if (startOptions.timeoutMs !== undefined) {
693
- handle.timeoutTimer = setTimeout(() => {
694
- if (jobs.get(id)?.status === "running") kill(id, "SIGTERM");
695
- }, startOptions.timeoutMs);
696
- handle.timeoutTimer.unref?.();
697
- }
698
-
699
- process_.stdout?.on("data", (chunk: Buffer) => onChunk(id, chunk));
700
- process_.stderr?.on("data", (chunk: Buffer) => onChunk(id, chunk));
701
- process_.on("close", (code, signal) => finalize(id, code, signal));
702
- process_.on("error", () => finalize(id, 1, null));
703
-
489
+ // A start before `load` still needs the seam; load it rather than refusing.
490
+ await openSeam();
491
+ const job = children.start(startOptions, { cwd, windows: isWindows() });
704
492
  persist();
705
- paint();
706
- return job;
707
- }
708
-
709
- function logs(id: string, lines = config.maxLogLines): LogResult | undefined {
710
- const job = jobs.get(id);
711
- if (!job) return undefined;
712
- const current = readLogTail(store.safeLogPath(job), lines);
713
- let all = current.lines;
714
- let more = current.more;
715
- let truncated = current.truncated;
716
- // Keep `logs` continuous across a rotation: fill the request from the
717
- // previous segment when the live one holds fewer lines.
718
- if (all.length < lines) {
719
- const previous = readLogTail(store.safeRotatedLogPath(job), lines - all.length);
720
- if (previous.lines.length > 0) {
721
- all = [...previous.lines, ...all];
722
- more = more || previous.more;
723
- truncated = truncated || previous.truncated;
724
- }
725
- }
726
- const last = all.at(-1);
727
- if (last) job.lastLine = last;
728
- const formatted = formatLogs(job, all, LOG_MODEL_CHARS);
729
- // A rotation that outran its drain drops the oldest queued chunks; say so once
730
- // rather than letting the gap look like a program that skipped output.
731
- const dropped = handles.get(id)?.log.droppedBytes ?? 0;
732
- const text = dropped > 0 ? `${formatted.text}\n… ${dropped} bytes dropped while the log rotated.` : formatted.text;
733
- return {
734
- job,
735
- text,
736
- lines: all,
737
- truncated: formatted.truncated || truncated,
738
- more,
739
- };
740
- }
741
-
742
- function kill(id: string, signal: KillSignal = "SIGTERM"): Job | undefined {
743
- const job = jobs.get(id);
744
- if (!job) return undefined;
745
- if (job.status !== "running") return job;
746
- const pid = job.pid;
747
- if (pid === null) {
748
- settle(job, { status: "unknown" });
749
- persist();
750
- paint();
751
- return job;
752
- }
753
-
754
- // A reattached pid may have been reused; if the start token disagrees, stop
755
- // rather than signal an unrelated process.
756
- if (!tokenMatches(job)) {
757
- settle(job, { status: "unknown" });
758
- persist();
759
- paint();
760
- return job;
761
- }
762
-
763
- // Adopt a peer's job so the outcome persists in our file; the peer's stale
764
- // file is dropped on a later load.
765
- job.sessionId = sessionId;
766
-
767
- try {
768
- killTree(pid, signal, signal === "SIGKILL");
769
- } catch {
770
- // Best-effort; liveness below decides the final state.
771
- }
772
-
773
- const handle = handles.get(id);
774
- if (handle) {
775
- if (signal !== "SIGKILL") {
776
- if (handle.killTimer) clearTimeout(handle.killTimer);
777
- handle.killTimer = setTimeout(() => {
778
- if (jobs.get(id)?.status === "running") {
779
- try {
780
- killTree(pid, "SIGKILL", true);
781
- } catch {
782
- // Already gone.
783
- }
784
- }
785
- }, config.killGraceMs);
786
- handle.killTimer.unref?.();
787
- }
788
- } else {
789
- // Reattached/external Job: no `close` event will arrive, so reconcile
790
- // after the grace period instead of waiting forever.
791
- setTimeout(() => {
792
- const current = jobs.get(id);
793
- if (current?.status !== "running") return;
794
- if (!liveness(pid)) {
795
- settle(current, { status: "killed", signal });
796
- } else {
797
- try {
798
- killTree(pid, "SIGKILL", true);
799
- } catch {
800
- // Best-effort.
801
- }
802
- settle(current, { status: "killed", signal: "SIGKILL" });
803
- }
804
- persist();
805
- paint();
806
- }, config.killGraceMs).unref?.();
807
- }
493
+ clock.paint();
808
494
  return job;
809
495
  }
810
496
 
811
- function send(id: string, options: SendOptions): SendResult {
812
- const job = jobs.get(id);
813
- if (!job) throw new Error(`no job "${id}".`);
814
- if (job.status !== "running") throw new Error(`job ${id} is ${job.status}; stdin is closed.`);
815
- if (!job.stdin) throw new Error(`job ${id} was not started with stdin; restart it with \`stdin: true\`.`);
816
- const handle = handles.get(id);
817
- // A pipe this session opened but that is closed is a different fact from one
818
- // that was never ours, so state it first.
819
- if (handle?.stdinClosed) throw new Error(`job ${id} closed its stdin.`);
820
- if (!handle?.stdin) throw new Error(`job ${id}'s stdin belongs to the session that started it.`);
821
-
822
- const input = options.input ?? "";
823
- const payload = input && options.newline !== false ? `${input}\n` : input;
824
- try {
825
- if (payload) handle.stdin.write(payload);
826
- if (options.eof) {
827
- handle.stdin.end();
828
- handle.stdinClosed = true;
829
- }
830
- } catch {
831
- // The child closed its end; record it so the next `send` says so.
832
- handle.stdinClosed = true;
833
- handle.stdin = null;
834
- throw new Error(`job ${id} closed its stdin.`);
835
- }
836
- return { job, sent: input.length, closed: options.eof === true };
837
- }
838
-
839
497
  function wait(
840
498
  id: string,
841
499
  timeoutMs: number | undefined,
@@ -848,26 +506,12 @@ export function createJobsRuntime(options: RuntimeOptions = {}): JobsRuntime {
848
506
  return Promise.resolve({ job, timedOut: false, cancelled: false });
849
507
  }
850
508
  onUpdate?.();
851
-
852
- return new Promise<WaitResult>((resolve) => {
853
- let settled = false;
854
- let remove = (): void => {};
855
- const finish = (timedOut: boolean, cancelled: boolean): void => {
856
- if (settled) return;
857
- settled = true;
858
- if (timer) clearTimeout(timer);
859
- signal?.removeEventListener("abort", onAbort);
860
- remove();
861
- resolve({ job: jobs.get(id) ?? job, timedOut, cancelled });
862
- };
863
- const resolveWaiter = (): void => finish(false, false);
864
- const onAbort = (): void => finish(false, true);
865
- const timer = setTimeout(() => finish(true, false), timeoutMs ?? DEFAULT_WAIT_MS);
866
-
867
- remove = waiters.add(id, resolveWaiter);
868
- if (signal?.aborted) onAbort();
869
- else signal?.addEventListener("abort", onAbort, { once: true });
870
- });
509
+ // The record is read again at settle time, so the answer carries the job as it
510
+ // finished rather than as it was when the wait began.
511
+ return waitFor(waiters, id, { timeoutMs, signal }).then((outcome) => ({
512
+ job: jobs.get(id) ?? job,
513
+ ...outcome,
514
+ }));
871
515
  }
872
516
 
873
517
  function clear(id: string | undefined, all: boolean): { cleared: number; refused?: string } {
@@ -880,9 +524,9 @@ export function createJobsRuntime(options: RuntimeOptions = {}): JobsRuntime {
880
524
  }
881
525
  jobs.delete(id);
882
526
  waiters.resolve(id);
883
- removeArtifacts(job);
527
+ children.removeArtifacts(job);
884
528
  persist();
885
- paint();
529
+ clock.paint();
886
530
  return { cleared: 1 };
887
531
  }
888
532
 
@@ -893,23 +537,17 @@ export function createJobsRuntime(options: RuntimeOptions = {}): JobsRuntime {
893
537
  if (job.status === "running") {
894
538
  // Without `all`, running jobs are left alone; with `all`, force-remove them.
895
539
  if (!all) continue;
896
- if (job.pid !== null) {
897
- try {
898
- killTree(job.pid, "SIGKILL", true);
899
- } catch {
900
- // Best-effort.
901
- }
902
- }
540
+ if (job.pid !== null) children.signal({ pid: job.pid }, "SIGKILL");
903
541
  // Drop the handle too: the child will not deliver a `close` we want.
904
- disposeHandle(job.id, { endStream: true });
542
+ children.dispose(job.id, { endStream: true });
905
543
  }
906
544
  jobs.delete(job.id);
907
545
  waiters.resolve(job.id);
908
- removeArtifacts(job);
546
+ children.removeArtifacts(job);
909
547
  cleared++;
910
548
  }
911
549
  persist();
912
- paint();
550
+ clock.paint();
913
551
  return { cleared };
914
552
  }
915
553
 
@@ -935,21 +573,24 @@ export function createJobsRuntime(options: RuntimeOptions = {}): JobsRuntime {
935
573
 
936
574
  async function shutdown(): Promise<void> {
937
575
  disposed = true;
938
- stopClock();
576
+ clock.stop();
939
577
  const running = [...jobs.values()].filter(
940
578
  (job) => job.status === "running" && !job.detached && job.sessionId === sessionId,
941
579
  );
942
- for (const job of running) kill(job.id, "SIGTERM");
580
+ for (const job of running) await children.kill(job.id, "SIGTERM");
943
581
  if (running.length > 0) {
944
- await new Promise((resolve) => setTimeout(resolve, Math.min(config.killGraceMs, 2000)));
582
+ // The grace is the same for every running job, handled or not: a reattached job has
583
+ // no child to watch, so a close-driven wait would SIGKILL it on the heels of its
584
+ // SIGTERM. The cap is the shared default, not a second literal.
585
+ await sleep(Math.min(config.killGraceMs, KILL_GRACE_MS));
945
586
  }
946
587
  for (const job of running) {
947
- if (jobs.get(job.id)?.status === "running") kill(job.id, "SIGKILL");
588
+ if (jobs.get(job.id)?.status === "running") await children.kill(job.id, "SIGKILL");
948
589
  }
949
- for (const id of [...handles.keys()]) disposeHandle(id, { endStream: true });
590
+ children.disposeAll();
950
591
  waiters.resolveAll();
951
592
  persist();
952
- removeSessionMarker(store.directory(), sessionId);
593
+ removeSessionMarker(store().directory(), sessionId);
953
594
  ui.detach();
954
595
  }
955
596