@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
@@ -25,10 +25,10 @@ export const PREVIEW_LINES = 20;
25
25
 
26
26
  /** Render the argument path: body colour, `~`-shortened, OSC-8 linked when supported. */
27
27
  export function renderReadPath(rawPath: string | undefined, theme: Theme, cwd: string, argsComplete = true): string {
28
- if (rawPath === undefined && !argsComplete) return theme.fg("toolOutput", "...");
28
+ if (rawPath === undefined && !argsComplete) return paint(theme, "toolOutput", "...");
29
29
  if (typeof rawPath !== "string") return paint(theme, "bad", "[invalid arg]");
30
30
  const value = sanitizeLine(rawPath);
31
- if (!value) return theme.fg("toolOutput", "...");
31
+ if (!value) return paint(theme, "toolOutput", "...");
32
32
  const styled = paint(theme, "label", shortenPath(value));
33
33
  if (!getCapabilities().hyperlinks) return styled;
34
34
  const abs = isAbsolute(value) ? value : resolve(cwd, value);
@@ -93,14 +93,14 @@ export function formatReadResult(result: ReadRenderResult, options: ReadRenderOp
93
93
 
94
94
  const summary = summarizeRead(details);
95
95
  const sep = paint(theme, "faint", SEPARATORS.item);
96
- let text = paint(theme, "meta", summary.format) + sep + theme.fg("toolOutput", summary.detail);
96
+ let text = paint(theme, "meta", summary.format) + sep + paint(theme, "toolOutput", summary.detail);
97
97
  if (summary.note) text += paint(theme, summary.truncated ? "accent" : "faint", SEPARATORS.item + summary.note);
98
98
 
99
99
  if (details.text.length > 0) {
100
100
  if (options.expanded) {
101
101
  const { body, moreLines } = previewText(details.text, PREVIEW_LINES);
102
102
  // Blank line before the body, matching the built-ins' header/output gap.
103
- text += `\n\n${theme.fg("toolOutput", body)}`;
103
+ text += `\n\n${paint(theme, "toolOutput", body)}`;
104
104
  if (moreLines > 0) text += `\n${paint(theme, "meta", `... (${moreLines} more lines)`)}`;
105
105
  } else {
106
106
  const hint = expandKey(theme);
@@ -22,7 +22,7 @@ import { isPursuedGoal } from "../../lib/goal-state.ts";
22
22
  import { isRemoteTurn, remoteLanguage } from "../../lib/interaction.ts";
23
23
  import { PLAN_CONSENT, PLAN_CONSENT_SHORT, PLAN_CONSENT_SHORT_ZH, requestPlanMode } from "../../lib/plan-entry.ts";
24
24
  import { isPlanModeEnabled } from "../../lib/plan-state.ts";
25
- import { PLAN_ENTER_TOOL } from "../../lib/tool-names.ts";
25
+ import { PLAN_TOOL } from "../../lib/tool-names.ts";
26
26
  import type { Goal, GoalStatus } from "./types.ts";
27
27
 
28
28
  /** Dialog title: the question the phase answers. */
@@ -45,7 +45,7 @@ export async function goalPhase(
45
45
  if (isPlanModeEnabled(ctx.sessionManager.getBranch())) return "planning";
46
46
  if (!options.ask || !ctx.hasUI) return "active";
47
47
  // Do not ask about a phase this session cannot enter.
48
- if (!pi.getAllTools().some((tool) => tool.name === PLAN_ENTER_TOOL)) return "active";
48
+ if (!pi.getAllTools().some((tool) => tool.name === PLAN_TOOL)) return "active";
49
49
  const consent = isRemoteTurn(ctx)
50
50
  ? remoteLanguage(ctx) === "zh"
51
51
  ? PLAN_CONSENT_SHORT_ZH
@@ -10,6 +10,7 @@
10
10
 
11
11
  import type { Theme } from "@earendil-works/pi-coding-agent";
12
12
  import { truncateToWidth, type Component } from "@earendil-works/pi-tui";
13
+ import { RenderCache } from "../../lib/tui/render-cache.ts";
13
14
  import { goalHeader, goalLine } from "./format.ts";
14
15
  import type { AchievedStyle, Goal } from "./types.ts";
15
16
 
@@ -47,6 +48,7 @@ export class GoalWidget implements Component {
47
48
  export class GoalHeader implements Component {
48
49
  private goal: Goal;
49
50
  private theme: Theme;
51
+ private readonly cache = new RenderCache();
50
52
 
51
53
  constructor(goal: Goal, theme: Theme) {
52
54
  this.goal = goal;
@@ -57,13 +59,21 @@ export class GoalHeader implements Component {
57
59
  update(goal: Goal, theme: Theme): void {
58
60
  this.goal = goal;
59
61
  this.theme = theme;
62
+ this.cache.invalidate();
60
63
  }
61
64
 
62
- invalidate(): void {}
65
+ invalidate(): void {
66
+ this.cache.invalidate();
67
+ }
63
68
 
64
69
  render(width: number): string[] {
70
+ const w = Math.max(1, width);
71
+ return this.cache.lines(w, undefined, () => this.build(w));
72
+ }
73
+
74
+ private build(w: number): string[] {
65
75
  // The leading blank line separates the header from the call line, matching
66
76
  // the built-ins' header/output gap.
67
- return ["", truncateToWidth(goalHeader(this.goal, this.theme), Math.max(1, width), "…")];
77
+ return ["", truncateToWidth(goalHeader(this.goal, this.theme), w, "…")];
68
78
  }
69
79
  }
@@ -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
+ }