@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
@@ -4,18 +4,30 @@
4
4
  * Each session owns one registry file per project, but a job is still owned by
5
5
  * the session that started it. To tell a live owner from a dead one — so a new
6
6
  * session can adopt a dead peer's records instead of reaping them blindly —
7
- * each session writes a small heartbeat marker (its session id, pid, and
8
- * last-seen time) beside the registry while it runs jobs. Adoption reads those
7
+ * each session writes a small heartbeat marker (its session id, pid, the process's
8
+ * start time, and last-seen time) beside the registry. Adoption reads those
9
9
  * markers, so a live peer's jobs are left alone and a dead peer's are taken
10
10
  * over.
11
11
  *
12
+ * The heartbeat only ticks while a job runs, because that is when the repaint
13
+ * clock runs: a session that has finished its work stops writing, and its marker
14
+ * ages. That is why age alone is not death — the rule is the roster's own
15
+ * (`lib/session-roster.ts`'s `live` / `quiet` / `gone`), read here over a
16
+ * marker, and *only* `gone` lets another session take anything over. A marker
17
+ * whose process is alive is evidence of an owner, not litter, so pruning keeps
18
+ * it; the start token is what tells an idle session apart from a reused pid.
19
+ *
12
20
  * Markers are best-effort: a missing directory or an unwritable file degrades
13
- * to "owner dead", which is the conservative default. The liveness rule is pure
14
- * (`isSessionAlive`) so it is unit-tested without real processes.
21
+ * to "no marker", which reads as an owner that is gone — the conservative
22
+ * default for adoption. The rule is pure (`sessionStateOf`) so it is unit-tested
23
+ * without real processes, and `createOwnership` is that rule wired to one
24
+ * runtime's directory, clock, and seams: the state of a peer, this session's own
25
+ * marker, and the pruning of the markers that are left.
15
26
  */
16
27
 
17
28
  import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
18
29
  import { join } from "node:path";
30
+ import { type RosterSeams, type RosterState, rosterStateOf } from "../../lib/session-roster.ts";
19
31
  import { sessionMarkerPath } from "./paths.ts";
20
32
 
21
33
  /** Prefix shared by every marker file, so log/registry files are never parsed. */
@@ -27,6 +39,14 @@ export interface SessionMarker {
27
39
  sessionId: string;
28
40
  /** Host process pid, checked for liveness before trusting the timestamp. */
29
41
  pid: number;
42
+ /**
43
+ * That process's start time, when the platform can say.
44
+ *
45
+ * A pid that is alive may be a different program than the one the marker named, and
46
+ * nothing cheaper than the start time tells them apart — so this is what keeps a
47
+ * reused pid from reading as an idle session forever.
48
+ */
49
+ startToken?: string;
30
50
  /** Millisecond timestamp of the last heartbeat. */
31
51
  updatedAt: number;
32
52
  }
@@ -39,16 +59,33 @@ function parseMarker(raw: unknown): SessionMarker | undefined {
39
59
  if (typeof marker.sessionId !== "string" || typeof marker.pid !== "number" || typeof marker.updatedAt !== "number") {
40
60
  return undefined;
41
61
  }
42
- return { sessionId: marker.sessionId, pid: marker.pid, updatedAt: marker.updatedAt };
62
+ return {
63
+ sessionId: marker.sessionId,
64
+ pid: marker.pid,
65
+ ...(typeof marker.startToken === "string" && marker.startToken !== "" ? { startToken: marker.startToken } : {}),
66
+ updatedAt: marker.updatedAt,
67
+ };
43
68
  }
44
69
 
45
70
  /** Write (or refresh) a session's marker. Failures are non-fatal for the caller. */
46
- export function touchSessionMarker(dir: string, sessionId: string, pid: number, now: number): void {
71
+ export function touchSessionMarker(
72
+ dir: string,
73
+ sessionId: string,
74
+ pid: number,
75
+ now: number,
76
+ startToken?: string,
77
+ ): void {
47
78
  try {
48
79
  mkdirSync(dir, { recursive: true });
49
80
  const path = sessionMarkerPath(dir, sessionId);
50
81
  const tmp = `${path}.tmp`;
51
- writeFileSync(tmp, JSON.stringify({ sessionId, pid, updatedAt: now } satisfies SessionMarker), "utf-8");
82
+ const marker: SessionMarker = {
83
+ sessionId,
84
+ pid,
85
+ ...(startToken === undefined ? {} : { startToken }),
86
+ updatedAt: now,
87
+ };
88
+ writeFileSync(tmp, JSON.stringify(marker), "utf-8");
52
89
  renameSync(tmp, path);
53
90
  } catch {
54
91
  // A marker is best-effort; a missing one reads as a dead owner.
@@ -70,7 +107,7 @@ export function removeSessionMarker(dir: string, sessionId: string): void {
70
107
  const path = sessionMarkerPath(dir, sessionId);
71
108
  if (existsSync(path)) unlinkSync(path);
72
109
  } catch {
73
- // Best-effort; a leftover marker expires on its own.
110
+ // Best-effort; a leftover marker is reaped when its session is gone.
74
111
  }
75
112
  }
76
113
 
@@ -94,23 +131,99 @@ export function listSessionMarkers(dir: string): SessionMarker[] {
94
131
  }
95
132
 
96
133
  /**
97
- * Whether a marker still represents a live session: it must exist, its pid must
98
- * be alive, and its heartbeat must be within `ttlMs`. A missing marker is dead.
134
+ * What a marker says about its session: `live`, `quiet`, or `gone`.
135
+ *
136
+ * The rule is the roster's, asked over a marker, so `session-bus`, the board's page, and
137
+ * this extension cannot answer "is that session around" three ways. A dead pid is `gone`
138
+ * however fresh the heartbeat; a live pid with an aged heartbeat is `quiet`, which is the
139
+ * ordinary state of a session that has finished its jobs; and a pid that has since been
140
+ * reused is `gone` once the start token disagrees. A missing marker is `gone`: no file is
141
+ * no evidence of an owner.
99
142
  */
100
- export function isSessionAlive(
143
+ export function sessionStateOf(
101
144
  marker: SessionMarker | undefined,
102
145
  now: number,
103
146
  ttlMs: number,
104
- isAlive: (pid: number) => boolean,
105
- ): boolean {
106
- if (!marker) return false;
107
- if (marker.pid > 0 && !isAlive(marker.pid)) return false;
108
- return now - marker.updatedAt <= ttlMs;
147
+ seams: RosterSeams = {},
148
+ ): RosterState {
149
+ if (marker === undefined) return "gone";
150
+ return rosterStateOf(
151
+ {
152
+ lastSeen: marker.updatedAt,
153
+ // A marker naming no usable process is judged by its heartbeat alone, which is the
154
+ // roster's answer for a record that cannot say which process owns it.
155
+ ...(marker.pid > 0 ? { pid: marker.pid } : {}),
156
+ ...(marker.startToken === undefined ? {} : { startToken: marker.startToken }),
157
+ },
158
+ now,
159
+ ttlMs,
160
+ seams,
161
+ );
109
162
  }
110
163
 
111
- /** Delete markers whose owner is no longer alive. */
112
- export function pruneSessionMarkers(dir: string, now: number, ttlMs: number, isAlive: (pid: number) => boolean): void {
164
+ /**
165
+ * Delete the markers of sessions that are gone.
166
+ *
167
+ * A marker whose process is alive is kept, including one whose heartbeat has aged: that is
168
+ * what every idle session's marker looks like, and removing it would let a peer adopt a
169
+ * session that is still there. Only a session that has really gone — a dead pid, or a
170
+ * reused one — loses its marker.
171
+ */
172
+ export function pruneSessionMarkers(dir: string, now: number, ttlMs: number, seams: RosterSeams = {}): void {
113
173
  for (const marker of listSessionMarkers(dir)) {
114
- if (!isSessionAlive(marker, now, ttlMs, isAlive)) removeSessionMarker(dir, marker.sessionId);
174
+ if (sessionStateOf(marker, now, ttlMs, seams) === "gone") removeSessionMarker(dir, marker.sessionId);
115
175
  }
116
176
  }
177
+
178
+ /** What the ownership rule needs from the runtime it serves. */
179
+ export interface OwnershipOptions {
180
+ /** The directory the markers sit in. */
181
+ directory(): string;
182
+ /** This session's id; it is never `gone`. */
183
+ sessionId(): string;
184
+ /** The clock the rule is read against. */
185
+ now(): number;
186
+ /** How old a beat may be before the marker's pid is re-checked for reuse. */
187
+ ttlMs(): number;
188
+ /** Whether a pid is still running. */
189
+ alive(pid: number): boolean;
190
+ /** That pid's start time, when the platform can say. */
191
+ startToken(pid: number): string | undefined;
192
+ }
193
+
194
+ /** One runtime's view of the sessions that own jobs. */
195
+ export interface Ownership {
196
+ /** `live`, `quiet`, or `gone` for the session that owns a job. */
197
+ state(owner: string): RosterState;
198
+ /** Write this session's marker, naming its process and that process's start time. */
199
+ touch(): void;
200
+ /** Drop the markers of sessions that are gone, keeping a live or a quiet one. */
201
+ prune(): void;
202
+ }
203
+
204
+ /**
205
+ * Wire the rule to one runtime: what a peer's session is, this session's own marker, and
206
+ * the pruning of the markers nobody owns any more.
207
+ */
208
+ export function createOwnership(options: OwnershipOptions): Ownership {
209
+ const seams = (): RosterSeams => ({ alive: options.alive, token: options.startToken });
210
+ // This process's token cannot change while the session runs, and on a platform without
211
+ // `ps` the ask costs a failed spawn, so it is asked once and kept, missing or not.
212
+ let token: string | undefined;
213
+ let asked = false;
214
+ const ownToken = (): string | undefined => {
215
+ if (!asked) {
216
+ asked = true;
217
+ token = options.startToken(process.pid);
218
+ }
219
+ return token;
220
+ };
221
+ return {
222
+ state: (owner) =>
223
+ owner === options.sessionId()
224
+ ? "live"
225
+ : sessionStateOf(readSessionMarker(options.directory(), owner), options.now(), options.ttlMs(), seams()),
226
+ touch: () => touchSessionMarker(options.directory(), options.sessionId(), process.pid, options.now(), ownToken()),
227
+ prune: () => pruneSessionMarkers(options.directory(), options.now(), options.ttlMs(), seams()),
228
+ };
229
+ }
@@ -1,11 +1,11 @@
1
1
  /**
2
2
  * Durable job storage: the on-disk registry that outlives a session.
3
3
  *
4
- * The live process table (the `Job` map, including the `owned` child handles)
5
- * stays in the runtime because process events mutate it; this owns the parts
6
- * that survive a session — the registry directory, the id counter, the atomic
7
- * write of this session's own registry file, and the trusted artifact-path
8
- * rules. Each session writes only its own file, so peers never race.
4
+ * The live table (the `Job` map) stays in the runtime and the handles stay in
5
+ * `child.ts`, because process events mutate both; this owns the parts that
6
+ * survive a session — the registry directory, the id counter, the atomic write of
7
+ * this session's own registry file, and the trusted artifact-path rules. Each
8
+ * session writes only its own file, so peers never race.
9
9
  *
10
10
  * Persistence is best-effort: an unwritable registry must never break a job that
11
11
  * is otherwise running fine.
@@ -101,8 +101,8 @@ export function createJobStore(initialDir = registryDirFor(process.cwd())): JobS
101
101
  };
102
102
  }
103
103
 
104
- /** Strip runtime-only fields for persistence. */
105
- export function toRecord(job: Job): JobRecord {
104
+ /** Strip runtime-only fields for persistence: the record, with or without a live handle. */
105
+ export function toRecord(job: JobRecord & { owned?: boolean }): JobRecord {
106
106
  const { owned: _owned, ...record } = job;
107
107
  return record;
108
108
  }
@@ -11,7 +11,15 @@
11
11
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
12
12
  import { errorResult, textResult } from "../../lib/tool-result.ts";
13
13
  import { reuseText, toolDetails } from "../../lib/tui/chrome.ts";
14
- import { callLine, detailsOrLine, elapsedSuffix, rejectionLine, syncElapsedTimer } from "../../lib/tui/tool.ts";
14
+ import {
15
+ callLine,
16
+ detailsOrLine,
17
+ elapsedSuffix,
18
+ isTailResult,
19
+ rejectionLine,
20
+ syncElapsedTimer,
21
+ TailResult,
22
+ } from "../../lib/tui/tool.ts";
15
23
  import { SEPARATORS } from "../../lib/tui/glyphs.ts";
16
24
  import { paint } from "../../lib/tui/tokens.ts";
17
25
  import {
@@ -26,8 +34,8 @@ import {
26
34
  import type { JobsRuntime } from "./runtime.ts";
27
35
  import { JobParams, normalizeCall, type JobArgs, type JobCall } from "./schema.ts";
28
36
  import { toRecord } from "./store.ts";
29
- import { JobResult, type JobResultInput } from "./tui.ts";
30
- import type { JobDetails } from "./types.ts";
37
+ import { jobTail, type JobResultInput } from "./render.ts";
38
+ import type { JobDetails, JobRecord } from "./types.ts";
31
39
 
32
40
  export const TOOL_NAME = "job";
33
41
 
@@ -65,7 +73,7 @@ export function registerTools(pi: ExtensionAPI, runtime: JobsRuntime): void {
65
73
  try {
66
74
  switch (call.action) {
67
75
  case "start": {
68
- const job = runtime.start(
76
+ const job = await runtime.start(
69
77
  {
70
78
  command: call.command,
71
79
  cwd: call.cwd,
@@ -137,7 +145,7 @@ export function registerTools(pi: ExtensionAPI, runtime: JobsRuntime): void {
137
145
  case "kill": {
138
146
  const before = runtime.get(call.id);
139
147
  if (!before) return errorResult("kill", `no job "${call.id}".`);
140
- const job = runtime.kill(call.id, call.signal);
148
+ const job = await runtime.kill(call.id, call.signal);
141
149
  if (!job) return errorResult("kill", `no job "${call.id}".`);
142
150
  const wasRunning = before.status === "running";
143
151
  return textResult(
@@ -224,8 +232,10 @@ export function registerTools(pi: ExtensionAPI, runtime: JobsRuntime): void {
224
232
  }
225
233
  const shown = options.expanded ? all : all.slice(Math.max(0, all.length - RESULT_ROWS));
226
234
  const input: JobResultInput = { kind: "logs", lines: shown, earlier: all.length - shown.length };
227
- const view = context.lastComponent instanceof JobResult ? context.lastComponent : new JobResult(input, theme);
228
- view.setInput(input, theme);
235
+ const previous = context.lastComponent;
236
+ const tail = jobTail(input);
237
+ const view = isTailResult<JobRecord>(previous) ? previous : new TailResult<JobRecord>(tail, theme);
238
+ view.setInput(tail, theme);
229
239
  return view;
230
240
  }
231
241
 
@@ -242,8 +252,10 @@ export function registerTools(pi: ExtensionAPI, runtime: JobsRuntime): void {
242
252
  more: ordered.length - shown.length,
243
253
  sessionId: details.sessionId,
244
254
  };
245
- const view = context.lastComponent instanceof JobResult ? context.lastComponent : new JobResult(input, theme);
246
- view.setInput(input, theme);
255
+ const previous = context.lastComponent;
256
+ const tail = jobTail(input);
257
+ const view = isTailResult<JobRecord>(previous) ? previous : new TailResult<JobRecord>(tail, theme);
258
+ view.setInput(tail, theme);
247
259
  return view;
248
260
  }
249
261
 
@@ -59,6 +59,53 @@ export interface Job extends JobRecord {
59
59
  owned: boolean;
60
60
  }
61
61
 
62
+ export interface StartOptions {
63
+ command: string;
64
+ cwd?: string;
65
+ label?: string;
66
+ wake?: boolean;
67
+ detached?: boolean;
68
+ /** Give the child a stdin pipe, so `send` can write to it while it runs here. */
69
+ stdin?: boolean;
70
+ /** Auto-kill the job after this many milliseconds (SIGTERM, escalating to SIGKILL). */
71
+ timeoutMs?: number;
72
+ }
73
+
74
+ export interface WaitResult {
75
+ job: Job;
76
+ timedOut: boolean;
77
+ cancelled: boolean;
78
+ }
79
+
80
+ /** A job's log as `logs` returns it: the model-facing render plus the raw tail. */
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 SendOptions {
93
+ /** Text written to the job's stdin. */
94
+ input?: string;
95
+ /** Append a newline to `input`; defaults to true. */
96
+ newline?: boolean;
97
+ /** End the job's stdin after writing, so the child sees EOF. */
98
+ eof?: boolean;
99
+ }
100
+
101
+ export interface SendResult {
102
+ job: Job;
103
+ /** Characters of `input` written, excluding any newline this call added. */
104
+ sent: number;
105
+ /** True when this call ended the job's stdin. */
106
+ closed: boolean;
107
+ }
108
+
62
109
  /** Registry file contents. One file exists per session. */
63
110
  export interface RegistryFile {
64
111
  version: 1;
@@ -1,106 +1,26 @@
1
1
  /**
2
- * Terminal rendering for jobs' `/jobs` screen.
2
+ * The `/jobs` screen: a dismissible list with a focused detail pane and a log
3
+ * view, drawn as a `DockListScreen`.
3
4
  *
4
- * `JobListComponent` is the dismissible `/jobs` screen with a focused detail
5
- * pane and a log view. It is stateless with respect to time: elapsed values are
6
- * computed from `Date.now()` at render, so the command can refresh the screen
7
- * without rebuilding the component.
5
+ * It is stateless with respect to time: elapsed values are computed from
6
+ * `Date.now()` at render, so the command can refresh the screen without
7
+ * rebuilding the component. The frame, the navigation keys, the y/N
8
+ * confirmation, and the log pane come from the shared `DockListScreen`, so this
9
+ * module owns only the job vocabulary: the rows, the focus block, the counts,
10
+ * the hints, and what the verbs do.
8
11
  *
9
- * The frame, the navigation keys, the y/N confirmation, and the log pane come
10
- * from the shared `DockListScreen`, so this module owns only the job vocabulary:
11
- * the row, the focus block, the counts, the hints, and what the verbs do.
12
- *
13
- * Log text arrives already sanitized by the runtime, so no untrusted escape
14
- * sequence reaches the terminal through here.
12
+ * Full-screen surfaces live under `view/` and load on first use: the `/jobs`
13
+ * command imports this module behind its TUI branch, so registration never
14
+ * pays for it. See `docs/extensions/job.md`.
15
15
  */
16
16
 
17
17
  import type { Theme } from "@earendil-works/pi-coding-agent";
18
- import type { Component } from "@earendil-works/pi-tui";
19
- import { formatDuration, sanitizeUntrustedLine } from "../../lib/format.ts";
20
- import type { ScreenHint, ViewportRowsSource } from "../../lib/tui/chrome.ts";
21
- import { DockListScreen, type DetailContents } from "../../lib/tui/dock-screen.ts";
22
- import { SEPARATORS } from "../../lib/tui/glyphs.ts";
23
- import { statusRow, type StatusRow } from "../../lib/tui/rows.ts";
24
- import { TailResult, type TailResultInput } from "../../lib/tui/tool.ts";
25
- import { paint } from "../../lib/tui/tokens.ts";
26
- import {
27
- compareJobs,
28
- elapsedMs,
29
- formatJobDetail,
30
- jobCounts,
31
- shortLabel,
32
- statusGlyphChar,
33
- statusRole,
34
- type JobCounts,
35
- } from "./format.ts";
36
- import type { JobRecord } from "./types.ts";
37
-
38
- /** One job row: selection marker, status glyph, id, label, and a right-aligned
39
- * elapsed value, tagged `peer` when another session owns it. `statusRow` owns
40
- * the clipping and the alignment. */
41
- function jobRow(
42
- job: JobRecord,
43
- theme: Theme,
44
- width: number,
45
- options: Pick<StatusRow, "selected" | "marker"> & { foreign?: boolean } = {},
46
- ): string {
47
- const elapsed = formatDuration(elapsedMs(job, Date.now()));
48
- return statusRow(theme, width, {
49
- ...options,
50
- glyph: statusGlyphChar(job.status),
51
- glyphRole: statusRole(job.status),
52
- label: `${job.id} ${shortLabel(job)}`,
53
- labelRole: options.selected ? "accent" : "label",
54
- // The tag rides the right-aligned cell, which survives a narrow terminal.
55
- trailing: options.foreign ? `${elapsed}${SEPARATORS.item}peer` : elapsed,
56
- });
57
- }
58
-
59
- /** Input for the transcript result: a themed job list or a log tail. */
60
- export type JobResultInput =
61
- | { kind: "list"; jobs: JobRecord[]; more: number; sessionId?: string }
62
- | { kind: "logs"; lines: string[]; earlier: number };
63
-
64
- /** Map the job vocabulary onto the shared transcript tail block. */
65
- function jobTail(input: JobResultInput): TailResultInput<JobRecord> {
66
- if (input.kind === "logs") {
67
- return { kind: "lines", lines: input.lines, earlier: input.earlier, earlierLabel: "lines" };
68
- }
69
- return {
70
- kind: "list",
71
- rows: input.jobs,
72
- more: input.more,
73
- rowLine: (job, width, theme) => {
74
- const foreign = input.sessionId !== undefined && job.sessionId !== input.sessionId;
75
- return jobRow(job, theme, width, { marker: false, foreign });
76
- },
77
- };
78
- }
79
-
80
- /**
81
- * Transcript result block for the `job` tool: the shared tail block over the
82
- * `/jobs`-style row rail or the themed log tail. Reused across renders.
83
- */
84
- export class JobResult implements Component {
85
- private readonly view: TailResult<JobRecord>;
86
-
87
- constructor(input: JobResultInput, theme: Theme) {
88
- this.view = new TailResult(jobTail(input), theme);
89
- }
90
-
91
- /** Update in place so the transcript can reuse this component across renders. */
92
- setInput(input: JobResultInput, theme: Theme): void {
93
- this.view.setInput(jobTail(input), theme);
94
- }
95
-
96
- invalidate(): void {
97
- this.view.invalidate();
98
- }
99
-
100
- render(width: number): string[] {
101
- return this.view.render(width);
102
- }
103
- }
18
+ import { sanitizeUntrustedLine } from "../../../lib/format.ts";
19
+ import type { ScreenHint, ViewportRowsSource } from "../../../lib/tui/chrome.ts";
20
+ import { DockListScreen, type DetailContents } from "../../../lib/tui/dock-screen.ts";
21
+ import { paint } from "../../../lib/tui/tokens.ts";
22
+ import { compareJobs, formatJobDetail, jobCounts, type JobCounts, jobRow, shortLabel } from "../format.ts";
23
+ import type { JobRecord } from "../types.ts";
104
24
 
105
25
  export interface JobListCallbacks {
106
26
  /** This session's id: a record with another id belongs to a peer session. */
@@ -187,8 +107,8 @@ export class JobListComponent extends DockListScreen<JobRecord> {
187
107
  "Esc close",
188
108
  "↑/↓ or j/k select",
189
109
  { text: "Enter or l logs", color: "accent" },
190
- { text: "d or K kill", color: "warning" },
191
- { text: "x clear finished", color: "warning" },
110
+ { text: "d or K kill", color: "warn" },
111
+ { text: "x clear finished", color: "warn" },
192
112
  "PgUp/PgDn",
193
113
  ];
194
114
  }
@@ -1,11 +1,22 @@
1
1
  /**
2
- * Waiter bookkeeping for the `wait` action.
2
+ * The `wait` action: waiter bookkeeping, and the promise a caller waits on.
3
3
  *
4
4
  * `wait` registers a resolver per job; a job's completion (or a kill/clear)
5
5
  * wakes every waiter for that id. Kept separate from the runtime so the map and
6
6
  * its cleanup rules are testable on their own and the runtime holds one field.
7
+ * The race the caller sees — the record, the timeout, or an abort — is here for
8
+ * the same reason: it is bookkeeping, not a decision about a record.
7
9
  */
8
10
 
11
+ /** Default `wait` timeout, for a caller that names none. */
12
+ export const DEFAULT_WAIT_MS = 30_000;
13
+
14
+ /** Why a `wait` stopped waiting; the caller reads the record itself. */
15
+ export interface WaitOutcome {
16
+ timedOut: boolean;
17
+ cancelled: boolean;
18
+ }
19
+
9
20
  /** Resolvers waiting on a job id. */
10
21
  export interface Waiters {
11
22
  /** Register `resolve` for `id`; returns a function that removes it again. */
@@ -44,3 +55,35 @@ export function createWaiters(): Waiters {
44
55
  },
45
56
  };
46
57
  }
58
+
59
+ /**
60
+ * Resolve when `id` settles, or when the timeout or the abort signal wins.
61
+ *
62
+ * One promise, one cleanup: the timer is cleared, the abort listener removed, and
63
+ * the waiter dropped, whichever of the three settles it.
64
+ */
65
+ export function waitFor(
66
+ waiters: Waiters,
67
+ id: string,
68
+ options: { timeoutMs?: number; signal?: AbortSignal } = {},
69
+ ): Promise<WaitOutcome> {
70
+ return new Promise<WaitOutcome>((resolve) => {
71
+ let settled = false;
72
+ let remove = (): void => {};
73
+ const finish = (timedOut: boolean, cancelled: boolean): void => {
74
+ if (settled) return;
75
+ settled = true;
76
+ if (timer) clearTimeout(timer);
77
+ options.signal?.removeEventListener("abort", onAbort);
78
+ remove();
79
+ resolve({ timedOut, cancelled });
80
+ };
81
+ const resolveWaiter = (): void => finish(false, false);
82
+ const onAbort = (): void => finish(false, true);
83
+ const timer = setTimeout(() => finish(true, false), options.timeoutMs ?? DEFAULT_WAIT_MS);
84
+
85
+ remove = waiters.add(id, resolveWaiter);
86
+ if (options.signal?.aborted) onAbort();
87
+ else options.signal?.addEventListener("abort", onAbort, { once: true });
88
+ });
89
+ }
@@ -10,12 +10,10 @@
10
10
  */
11
11
 
12
12
  import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
13
- import { withPolledScreen } from "../../lib/screen-session.ts";
14
13
  import { messageOf } from "./errors.ts";
15
14
  import { formatHealth, memoryHealth } from "./doctor.ts";
16
15
  import { formatNotice, scopeLabel } from "./format.ts";
17
16
  import type { MemoryRuntime } from "./runtime.ts";
18
- import { MemoryPickerComponent } from "./tui.ts";
19
17
  import { MEMORY_SCOPES, type MemoryScope } from "./types.ts";
20
18
 
21
19
  /** How often the open picker re-checks the stores for an external edit. */
@@ -55,6 +53,10 @@ async function openPicker(pi: ExtensionAPI, runtime: MemoryRuntime, ctx: Extensi
55
53
  return;
56
54
  }
57
55
 
56
+ // The dock screen and its poll loop are the TUI's: the plain listing above and
57
+ // registration load neither.
58
+ const { withPolledScreen } = await import("../../lib/screen-session.ts");
59
+ const { MemoryPickerComponent } = await import("./view/memory-picker.ts");
58
60
  await withPolledScreen(
59
61
  pi,
60
62
  ctx,
@@ -25,17 +25,17 @@ import {
25
25
  type TuiMouseEvent,
26
26
  type TuiMouseEventResult,
27
27
  } from "@earendil-works/pi-tui";
28
- import { sanitizeLine } from "../../lib/format.ts";
29
- import { ConfirmState } from "../../lib/tui/confirm.ts";
30
- import { fitRows, formatRange, isCloseKey, ListCursor, navigateList, scrollList } from "../../lib/tui/cursor.ts";
31
- import { dockListFrame, editorTheme, screenHint, type ViewportRowsSource } from "../../lib/tui/chrome.ts";
32
- import { clip } from "../../lib/tui/columns.ts";
33
- import { emptyState, listRow } from "../../lib/tui/rows.ts";
34
- import { titleRule } from "../../lib/tui/rules.ts";
35
- import { paint } from "../../lib/tui/tokens.ts";
36
- import { messageOf } from "./errors.ts";
37
- import { scopeLabel, type MemoryEntries } from "./format.ts";
38
- import type { MemoryScope } from "./types.ts";
28
+ import { sanitizeLine } from "../../../lib/format.ts";
29
+ import { ConfirmState } from "../../../lib/tui/confirm.ts";
30
+ import { fitRows, formatRange, isCloseKey, ListCursor, navigateList, scrollList } from "../../../lib/tui/cursor.ts";
31
+ import { dockListFrame, editorTheme, screenHint, type ViewportRowsSource } from "../../../lib/tui/chrome.ts";
32
+ import { clip } from "../../../lib/tui/columns.ts";
33
+ import { emptyState, listRow } from "../../../lib/tui/rows.ts";
34
+ import { titleRule } from "../../../lib/tui/rules.ts";
35
+ import { paint } from "../../../lib/tui/tokens.ts";
36
+ import { messageOf } from "../errors.ts";
37
+ import { scopeLabel, type MemoryEntries } from "../format.ts";
38
+ import type { MemoryScope } from "../types.ts";
39
39
 
40
40
  /** Note limit enforced by the `memory` tool; the picker mirrors it so a save is never rejected late. */
41
41
  const MAX_NOTE_CHARS = 500;
@@ -521,7 +521,7 @@ export class MemoryPickerComponent implements Component {
521
521
  "Esc close",
522
522
  { text: "Enter/e edit", color: "accent" },
523
523
  { text: "a add", color: "accent" },
524
- { text: "d forget", color: "warning" },
524
+ { text: "d forget", color: "warn" },
525
525
  "m merge",
526
526
  "p move",
527
527
  "s/Tab scope",