@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
@@ -0,0 +1,142 @@
1
+ /**
2
+ * The job runtime's periodic work: the repaint clock, and the sweep that polls
3
+ * the jobs this session reattached.
4
+ *
5
+ * The clock is the only periodic hook the runtime arms. While a job runs it
6
+ * re-asserts the footer chip, touches this session's liveness marker, and lets
7
+ * the runtime poll the jobs it reattached; without it, anything that clears
8
+ * extension statuses (a session reload, another UI path) would hide a
9
+ * still-running job's chip until the next transition.
10
+ *
11
+ * Every fact it acts on is the caller's and arrives as a function: whether a job
12
+ * is running, whether the runtime was disposed, what a tick does, and what a
13
+ * repaint does. Nothing here holds state across calls — the armed timer lives in
14
+ * the returned closure, never at module scope, because Pi loads each extension
15
+ * with its own module cache.
16
+ *
17
+ * The sweep is here because it is the tick's work: a reattached job has no
18
+ * `close` event, so only a periodic pass can notice that its pid is gone.
19
+ */
20
+
21
+ import { defaultInterval, type RepeatTimer } from "../../lib/timer.ts";
22
+ import type { Job, JobRecord, JobStatus } from "./types.ts";
23
+
24
+ export interface RepaintClockDeps {
25
+ /** The repaint interval, read when the clock is armed. */
26
+ repaintMs: () => number;
27
+ /** Whether any record is running, which is the only thing that keeps the clock armed. */
28
+ running: () => boolean;
29
+ /** Whether the runtime was shut down; a disposed runtime arms nothing. */
30
+ disposed: () => boolean;
31
+ /** Refresh this session's liveness marker. */
32
+ touchMarker: () => void;
33
+ /** One tick: the external poll and the chip re-assert. */
34
+ tick: () => void;
35
+ /** One repaint of the surface. */
36
+ repaint: () => void;
37
+ /** The repeating timer, injectable so a test ticks by hand instead of waiting. */
38
+ interval?: RepeatTimer;
39
+ }
40
+
41
+ export interface RepaintClock {
42
+ /** Arm the clock when a job is running and none is armed, and stop it when nothing is. */
43
+ ensure(): void;
44
+ /** Cancel a pending tick. */
45
+ stop(): void;
46
+ /** Ensure the clock, touch the marker, then repaint — unless the runtime is disposed. */
47
+ paint(): void;
48
+ }
49
+
50
+ export function createRepaintClock(deps: RepaintClockDeps): RepaintClock {
51
+ const interval = deps.interval ?? defaultInterval;
52
+ let cancel: (() => void) | undefined;
53
+
54
+ const stop = (): void => {
55
+ cancel?.();
56
+ cancel = undefined;
57
+ };
58
+
59
+ const ensure = (): void => {
60
+ if (deps.disposed() || !deps.running()) {
61
+ stop();
62
+ return;
63
+ }
64
+ if (cancel) return;
65
+ cancel = interval(deps.repaintMs(), () => deps.tick());
66
+ };
67
+
68
+ const paint = (): void => {
69
+ ensure();
70
+ deps.touchMarker();
71
+ if (deps.disposed()) return;
72
+ deps.repaint();
73
+ };
74
+
75
+ return { ensure, stop, paint };
76
+ }
77
+
78
+ /** What the sweep asks of the table; every decision it makes is answered there. */
79
+ export interface ExternalSweepDeps {
80
+ /** Every record in the table. */
81
+ records: () => Iterable<Job>;
82
+ /** This session's id, so a peer's record is told from one's own. */
83
+ sessionId: () => string;
84
+ /**
85
+ * Whether the session that owns a record is still here: `live` and `quiet` both count,
86
+ * because a heartbeat only ticks while a job runs.
87
+ */
88
+ ownerPresent: (sessionId: string) => boolean;
89
+ /** Whether a pid is still running. */
90
+ alive: (pid: number) => boolean;
91
+ /** Whether a persisted start-time token still matches the pid it names. */
92
+ owns: (job: JobRecord) => boolean;
93
+ /** Settle a record whose process is gone; false when it cannot be judged. */
94
+ settleGone: (job: Job) => boolean;
95
+ /** Move a record to a settled status, resolving its waiters. */
96
+ settle: (job: Job, patch: { status: JobStatus; signal?: string | null }) => void;
97
+ /** SIGTERM a pid. */
98
+ signal: (pid: number) => void;
99
+ /** Refresh this session's liveness marker. */
100
+ touchMarker: () => void;
101
+ /** Prune finished records, persist, and repaint, after something changed. */
102
+ changed: () => void;
103
+ }
104
+
105
+ /**
106
+ * Poll the reattached (unowned) jobs once: settle any whose pid is gone, and reap
107
+ * an abandoned non-detached process whose owner session is gone.
108
+ */
109
+ export function createExternalSweep(deps: ExternalSweepDeps): () => void {
110
+ return () => {
111
+ deps.touchMarker();
112
+ let changed = false;
113
+ for (const job of deps.records()) {
114
+ if (job.status !== "running" || job.owned) continue;
115
+ const peer = job.sessionId !== deps.sessionId();
116
+ // A peer that is still here owns this job, a quiet one included: leave its state to
117
+ // that owner. The peer's id is asked however often the record says it is not ours.
118
+ if (peer && deps.ownerPresent(job.sessionId)) continue;
119
+ if (job.pid === null || !deps.alive(job.pid)) {
120
+ // A record with no pid cannot be judged gone; leave it alone rather than
121
+ // announcing on every tick.
122
+ if (!deps.settleGone(job)) continue;
123
+ changed = true;
124
+ continue;
125
+ }
126
+ // The owner is gone and the job is not detached: reap the abandoned
127
+ // process, guarding against pid reuse with the start token. A settle is the
128
+ // whole announcement, so the reap needs no second call of its own.
129
+ if (peer && !job.detached) {
130
+ if (deps.owns(job)) {
131
+ deps.signal(job.pid);
132
+ deps.settle(job, { status: "killed", signal: "SIGTERM" });
133
+ } else {
134
+ deps.settle(job, { status: "unknown" });
135
+ }
136
+ changed = true;
137
+ }
138
+ }
139
+ if (!changed) return;
140
+ deps.changed();
141
+ };
142
+ }
@@ -6,10 +6,8 @@
6
6
  */
7
7
 
8
8
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
9
- import { withPolledScreen } from "../../lib/screen-session.ts";
10
9
  import { formatJobList } from "./format.ts";
11
10
  import type { JobsRuntime } from "./runtime.ts";
12
- import { JobListComponent } from "./tui.ts";
13
11
 
14
12
  /** How often the open log pane re-reads the log file. */
15
13
  const LOG_POLL_MS = 500;
@@ -23,6 +21,10 @@ export function registerCommands(pi: ExtensionAPI, runtime: JobsRuntime): void {
23
21
  return;
24
22
  }
25
23
 
24
+ // The screen and the dock it mounts are the TUI's, not the listener's: the text
25
+ // listing above and registration load neither.
26
+ const { withPolledScreen } = await import("../../lib/screen-session.ts");
27
+ const { JobListComponent } = await import("./view/jobs-screen.ts");
26
28
  await withPolledScreen(
27
29
  pi,
28
30
  ctx,
@@ -27,7 +27,14 @@ export interface JobsConfig {
27
27
  killGraceMs: number;
28
28
  /** Status-chip refresh interval while jobs run. */
29
29
  repaintMs: number;
30
- /** How long a session heartbeat is trusted before its jobs are reaped. */
30
+ /**
31
+ * How old a session's heartbeat may be before its pid is re-checked against the process
32
+ * it named.
33
+ *
34
+ * The heartbeat only ticks while a job runs, so age alone means the session is idle, not
35
+ * gone: a quiet session keeps its jobs. The token check is what catches a pid that has
36
+ * since been reused by another program.
37
+ */
31
38
  sessionTtlMs: number;
32
39
  /** Override for the registry directory; defaults under the agent dir. */
33
40
  registryDir: string | undefined;
@@ -16,7 +16,7 @@ import type { Theme } from "@earendil-works/pi-coding-agent";
16
16
  import { boundedTail, formatDuration, sanitizeUntrustedLine } from "../../lib/format.ts";
17
17
  import { clip, clipLabel, LABEL_WIDTH } from "../../lib/tui/columns.ts";
18
18
  import { GLYPHS, SEPARATORS } from "../../lib/tui/glyphs.ts";
19
- import { byRunningFirst } from "../../lib/tui/rows.ts";
19
+ import { byRunningFirst, statusRow, type StatusRow } from "../../lib/tui/rows.ts";
20
20
  import { paint, type TuiRole } from "../../lib/tui/tokens.ts";
21
21
  import type { JobRecord, JobStatus } from "./types.ts";
22
22
 
@@ -74,6 +74,27 @@ export function shortLabel(job: JobRecord): string {
74
74
  return clipLabel(job.label || job.command);
75
75
  }
76
76
 
77
+ /** One row for the `/jobs` screen and its transcript mirror: selection marker,
78
+ * status glyph, id, label, and a right-aligned elapsed value, tagged `peer`
79
+ * when another session owns it. `statusRow` owns the clipping and alignment. */
80
+ export function jobRow(
81
+ job: JobRecord,
82
+ theme: Theme,
83
+ width: number,
84
+ options: Pick<StatusRow, "selected" | "marker"> & { foreign?: boolean } = {},
85
+ ): string {
86
+ const elapsed = formatDuration(elapsedMs(job, Date.now()));
87
+ return statusRow(theme, width, {
88
+ ...options,
89
+ glyph: statusGlyphChar(job.status),
90
+ glyphRole: statusRole(job.status),
91
+ label: `${job.id} ${shortLabel(job)}`,
92
+ labelRole: options.selected ? "accent" : "label",
93
+ // The tag rides the right-aligned cell, which survives a narrow terminal.
94
+ trailing: options.foreign ? `${elapsed}${SEPARATORS.item}peer` : elapsed,
95
+ });
96
+ }
97
+
77
98
  /** One-line job summary shared by the model output and the `/jobs` screen. */
78
99
  function jobSummary(job: JobRecord, now: number, ownSessionId?: string): string {
79
100
  const parts = [`${job.id}`, statusWord(job), formatDuration(elapsedMs(job, now))];
@@ -6,10 +6,14 @@
6
6
  * plumbing can be read separately. The writer owns no job state: every path and
7
7
  * limit is a callback over the runtime's live config, and a buffered line is
8
8
  * handed back through `onLine`.
9
+ *
10
+ * The line splitter is a seam rather than an import: `lib/lines.ts` is loaded with
11
+ * the process seam on the first job, so a session that never starts one never
12
+ * loads it.
9
13
  */
10
14
 
11
15
  import { createWriteStream, existsSync, renameSync, unlinkSync, type WriteStream } from "node:fs";
12
- import { LineSplitter } from "../../lib/lines.ts";
16
+ import type { LineSplitter } from "../../lib/lines.ts";
13
17
  import type { Job, JobRecord } from "./types.ts";
14
18
 
15
19
  /** Bytes held while a rotation swaps the log stream before writes resume. */
@@ -36,6 +40,13 @@ export interface LogHandle {
36
40
  export interface LogWriterOptions {
37
41
  /** The clock the repaint window is measured on. */
38
42
  now(): number;
43
+ /**
44
+ * A fresh line splitter for one job's stream.
45
+ *
46
+ * Injected rather than imported: the splitter is the only piece of `lib/lines.ts`
47
+ * a writer needs, and a runtime that never starts a job must not load it.
48
+ */
49
+ newSplitter(): LineSplitter;
39
50
  /** The live segment for a job, or undefined when it cannot be used. */
40
51
  logPath(job: JobRecord): string | undefined;
41
52
  /** The rotated segment for a job, or undefined when it cannot be used. */
@@ -163,7 +174,7 @@ export function createLogWriter(options: LogWriterOptions): LogWriter {
163
174
  stream?.on("error", () => {});
164
175
  return {
165
176
  stream,
166
- lines: new LineSplitter(),
177
+ lines: options.newSplitter(),
167
178
  queue: [],
168
179
  queuedBytes: 0,
169
180
  rotating: false,
@@ -22,6 +22,7 @@ import { createHash } from "node:crypto";
22
22
  import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
23
23
  import { join, resolve } from "node:path";
24
24
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
25
+ import type { RosterState } from "../../lib/session-roster.ts";
25
26
  import { registryPathFor } from "./paths.ts";
26
27
  import { JOB_STATUSES, type JobRecord, type JobStatus, type RegistryFile } from "./types.ts";
27
28
 
@@ -203,6 +204,59 @@ export function settleGone(record: JobRecord, options: SettleGoneOptions): JobRe
203
204
  return { ...record, status, exitCode: code, finishedAt: options.now };
204
205
  }
205
206
 
207
+ /** What a load makes of every session's file, before the runtime touches a record. */
208
+ export interface LoadedRecords {
209
+ /** Every record, merged across the session files and reconciled against liveness. */
210
+ records: JobRecord[];
211
+ /** Live, non-detached leftovers for the caller to kill. */
212
+ orphans: JobRecord[];
213
+ /** Sessions whose file may be dropped once their records are rewritten here. */
214
+ deadSessions: Set<string>;
215
+ }
216
+
217
+ /** The liveness and ownership seams a load reconciles against. */
218
+ export interface LoadSeams {
219
+ /** This session, which adopts a dead one's records. */
220
+ sessionId: string;
221
+ /** What the session that wrote a file is now. */
222
+ ownerState: (sessionId: string) => RosterState;
223
+ /** The process seams: whether a pid runs, whether its token still matches, its exit code. */
224
+ process: {
225
+ alive: (pid: number) => boolean;
226
+ owns: (record: JobRecord) => boolean;
227
+ exitCode: (record: JobRecord) => number | undefined;
228
+ };
229
+ now: number;
230
+ }
231
+
232
+ /** Merge every session's file, recover dead pids, and classify the survivors. */
233
+ export function loadRecords(files: readonly RegistryFile[], seams: LoadSeams): LoadedRecords {
234
+ const merged = new Map<string, JobRecord>();
235
+ const deadSessions = new Set<string>();
236
+ for (const file of files) {
237
+ if (file.sessionId !== seams.sessionId && seams.ownerState(file.sessionId) === "gone")
238
+ deadSessions.add(file.sessionId);
239
+ for (const record of file.jobs) {
240
+ const existing = merged.get(record.id);
241
+ merged.set(record.id, existing ? mergeRecords(existing, record, seams.sessionId) : record);
242
+ }
243
+ }
244
+ // Resolve dead pids (recovering exit codes), then classify the survivors.
245
+ const settled = [...merged.values()].map((record) =>
246
+ settleGone(record, {
247
+ isAlive: seams.process.alive,
248
+ tokenMatches: seams.process.owns,
249
+ readExitCode: seams.process.exitCode,
250
+ now: seams.now,
251
+ }),
252
+ );
253
+ const { jobs, orphans } = planReconcile(settled, seams.process.alive, seams.now, {
254
+ ownerState: seams.ownerState,
255
+ currentSessionId: seams.sessionId,
256
+ });
257
+ return { records: jobs, orphans, deadSessions };
258
+ }
259
+
206
260
  export interface ReconcileResult {
207
261
  /** The records after liveness reconciliation. */
208
262
  jobs: JobRecord[];
@@ -213,10 +267,12 @@ export interface ReconcileResult {
213
267
  /** Owner-liveness context for {@link planReconcile}. */
214
268
  export interface ReconcileOptions {
215
269
  /**
216
- * Whether the session that started a job is still alive. A live owner means
217
- * another session owns the job, so it is left running rather than reaped.
270
+ * What the session that started a job is: `live`, `quiet`, or `gone`.
271
+ *
272
+ * Only `gone` frees the job to be reaped. A `quiet` owner is an idle one — its
273
+ * heartbeat stops while it has nothing running — and its job is still its own.
218
274
  */
219
- isOwnerAlive?: (sessionId: string) => boolean;
275
+ ownerState?: (sessionId: string) => RosterState;
220
276
  /** The reconciling session; its own leftovers are always reaped. */
221
277
  currentSessionId?: string;
222
278
  }
@@ -250,12 +306,12 @@ export function planReconcile(
250
306
  jobs.push(record);
251
307
  continue;
252
308
  }
253
- // Another live session still owns this job: keep it running instead of
254
- // reaping a peer's work.
309
+ // Another session still owns this job: keep it running instead of reaping a
310
+ // peer's work. Its owner is only `gone` when its process is.
255
311
  if (
256
- options.isOwnerAlive &&
312
+ options.ownerState &&
257
313
  record.sessionId !== options.currentSessionId &&
258
- options.isOwnerAlive(record.sessionId)
314
+ options.ownerState(record.sessionId) !== "gone"
259
315
  ) {
260
316
  jobs.push(record);
261
317
  continue;
@@ -0,0 +1,34 @@
1
+ /**
2
+ * The `JobResult` transcript block: the `job` tool's list-or-log-tail result.
3
+ *
4
+ * It maps the job vocabulary onto the shared `TailResult` kit, so the tool's
5
+ * `renderResult` stays a few lines and every row is drawn by `jobRow` in
6
+ * `format.ts`. The `/jobs` screen draws the same rows through the same helper,
7
+ * which is why it lives beside the pure format helpers rather than here: this
8
+ * module is on the registration path, and the screen is not.
9
+ */
10
+
11
+ import type { TailResultInput } from "../../lib/tui/tool.ts";
12
+ import { jobRow } from "./format.ts";
13
+ import type { JobRecord } from "./types.ts";
14
+
15
+ /** Input for the transcript result: a themed job list or a log tail. */
16
+ export type JobResultInput =
17
+ | { kind: "list"; jobs: JobRecord[]; more: number; sessionId?: string }
18
+ | { kind: "logs"; lines: string[]; earlier: number };
19
+
20
+ /** Map the job vocabulary onto the shared transcript tail block. */
21
+ export function jobTail(input: JobResultInput): TailResultInput<JobRecord> {
22
+ if (input.kind === "logs") {
23
+ return { kind: "lines", lines: input.lines, earlier: input.earlier, earlierLabel: "lines" };
24
+ }
25
+ return {
26
+ kind: "list",
27
+ rows: input.jobs,
28
+ more: input.more,
29
+ rowLine: (job, width, theme) => {
30
+ const foreign = input.sessionId !== undefined && job.sessionId !== input.sessionId;
31
+ return jobRow(job, theme, width, { marker: false, foreign });
32
+ },
33
+ };
34
+ }