@gavin-hu/my-pi-agent 0.21.0 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (254) hide show
  1. package/README.md +46 -31
  2. package/README.zh-CN.md +40 -26
  3. package/assets/screens/board.png +0 -0
  4. package/assets/screens/chrome.png +0 -0
  5. package/assets/screens/file-browse.png +0 -0
  6. package/assets/screens/plan-review.png +0 -0
  7. package/assets/screens/plan.png +0 -0
  8. package/docs/extensions/README.md +22 -24
  9. package/docs/extensions/board.md +361 -147
  10. package/docs/extensions/bytefly.md +152 -111
  11. package/docs/extensions/check.md +15 -17
  12. package/docs/extensions/context.md +35 -15
  13. package/docs/extensions/file-browse.md +30 -31
  14. package/docs/extensions/file-open.md +9 -11
  15. package/docs/extensions/file-read.md +12 -14
  16. package/docs/extensions/goal.md +15 -17
  17. package/docs/extensions/job.md +54 -42
  18. package/docs/extensions/memory.md +17 -19
  19. package/docs/extensions/plan.md +166 -126
  20. package/docs/extensions/question.md +16 -18
  21. package/docs/extensions/rewind.md +17 -19
  22. package/docs/extensions/serve.md +34 -18
  23. package/docs/extensions/session-bus.md +38 -16
  24. package/docs/extensions/subagent.md +23 -25
  25. package/docs/extensions/team.md +72 -33
  26. package/docs/extensions/todo.md +18 -18
  27. package/docs/extensions/watch.md +31 -31
  28. package/docs/extensions/web-access.md +2 -4
  29. package/docs/extensions/wechat.md +34 -35
  30. package/docs/extensions/worktree.md +56 -50
  31. package/extensions/board/commands.ts +56 -15
  32. package/extensions/board/format.ts +43 -24
  33. package/extensions/board/index.ts +5 -4
  34. package/extensions/board/input.ts +4 -9
  35. package/extensions/board/join.ts +4 -40
  36. package/extensions/board/lazy-page.ts +40 -0
  37. package/extensions/board/page.ts +13 -11
  38. package/extensions/board/result.ts +30 -16
  39. package/extensions/board/rows.ts +62 -25
  40. package/extensions/board/schema.ts +42 -10
  41. package/extensions/board/snapshot.ts +2 -1
  42. package/extensions/board/tone.ts +22 -0
  43. package/extensions/board/tools.ts +131 -44
  44. package/extensions/board/types.ts +36 -32
  45. package/extensions/board/view/card.ts +229 -0
  46. package/extensions/board/view/client.ts +33 -15
  47. package/extensions/board/view/dialog.ts +169 -6
  48. package/extensions/board/view/empty.ts +47 -0
  49. package/extensions/board/view/keys.ts +13 -2
  50. package/extensions/board/view/live.ts +89 -21
  51. package/extensions/board/view/names.ts +13 -4
  52. package/extensions/board/view/page.ts +23 -9
  53. package/extensions/board/view/render.ts +208 -271
  54. package/extensions/board/view/styles.ts +123 -31
  55. package/extensions/board/view/team.ts +297 -76
  56. package/extensions/bytefly/block/fallback.ts +1 -1
  57. package/extensions/bytefly/doctor/runtime.ts +5 -2
  58. package/extensions/bytefly/footer/collect/label.ts +60 -0
  59. package/extensions/bytefly/footer/collect/snapshot.ts +8 -1
  60. package/extensions/bytefly/footer/config.ts +4 -2
  61. package/extensions/bytefly/footer/register.ts +33 -4
  62. package/extensions/bytefly/footer/render/lines.ts +7 -4
  63. package/extensions/bytefly/footer/render/segments.ts +37 -10
  64. package/extensions/bytefly/footer/render/status.ts +0 -11
  65. package/extensions/bytefly/footer/types.ts +7 -0
  66. package/extensions/bytefly/header/config.ts +1 -1
  67. package/extensions/bytefly/index.ts +32 -6
  68. package/extensions/bytefly/mode/editor.ts +115 -0
  69. package/extensions/bytefly/panel/commands.ts +210 -120
  70. package/extensions/bytefly/panel/format.ts +4 -4
  71. package/extensions/bytefly/panel/settings.ts +73 -6
  72. package/extensions/bytefly/panel/tui/keys.ts +2 -6
  73. package/extensions/bytefly/update/cache.ts +1 -1
  74. package/extensions/bytefly/update/register.ts +4 -4
  75. package/extensions/bytefly/working/config.ts +5 -7
  76. package/extensions/bytefly/working/format.ts +10 -13
  77. package/extensions/bytefly/working/register.ts +22 -27
  78. package/extensions/check/commands.ts +3 -2
  79. package/extensions/check/{tui.ts → view/check-screens.ts} +14 -19
  80. package/extensions/context/commands.ts +2 -2
  81. package/extensions/context/{tui.ts → view/context-report.ts} +51 -19
  82. package/extensions/file-browse/index.ts +67 -36
  83. package/extensions/file-read/render.ts +4 -4
  84. package/extensions/goal/plan-route.ts +2 -2
  85. package/extensions/job/child.ts +527 -0
  86. package/extensions/job/clock.ts +142 -0
  87. package/extensions/job/commands.ts +4 -2
  88. package/extensions/job/config.ts +8 -1
  89. package/extensions/job/format.ts +22 -1
  90. package/extensions/job/log-writer.ts +13 -2
  91. package/extensions/job/registry.ts +63 -7
  92. package/extensions/job/render.ts +34 -0
  93. package/extensions/job/runtime.ts +296 -655
  94. package/extensions/job/session.ts +132 -19
  95. package/extensions/job/store.ts +7 -7
  96. package/extensions/job/tools.ts +3 -3
  97. package/extensions/job/types.ts +47 -0
  98. package/extensions/job/{tui.ts → view/jobs-screen.ts} +19 -73
  99. package/extensions/job/waiters.ts +44 -1
  100. package/extensions/memory/commands.ts +4 -2
  101. package/extensions/memory/{tui.ts → view/memory-picker.ts} +12 -12
  102. package/extensions/plan/commands.ts +12 -9
  103. package/extensions/plan/format.ts +8 -2
  104. package/extensions/plan/git-bash.ts +115 -16
  105. package/extensions/plan/index.ts +53 -52
  106. package/extensions/plan/plan-index.ts +4 -2
  107. package/extensions/plan/plans.ts +5 -54
  108. package/extensions/plan/policy.ts +8 -10
  109. package/extensions/plan/review-lazy.ts +85 -0
  110. package/extensions/plan/review.ts +2 -2
  111. package/extensions/plan/runtime.ts +49 -39
  112. package/extensions/plan/sections.ts +1 -1
  113. package/extensions/plan/steps.ts +3 -3
  114. package/extensions/plan/todos.ts +1 -1
  115. package/extensions/plan/tools/cancel.ts +27 -62
  116. package/extensions/plan/tools/enter.ts +55 -81
  117. package/extensions/plan/tools/exit.ts +221 -238
  118. package/extensions/plan/tools/refusal.ts +18 -0
  119. package/extensions/plan/tools/steps.ts +75 -92
  120. package/extensions/plan/tools/write.ts +77 -120
  121. package/extensions/plan/tools.ts +188 -30
  122. package/extensions/plan/tui.ts +5 -5
  123. package/extensions/plan/types.ts +48 -5
  124. package/extensions/plan/web-client.ts +3 -0
  125. package/extensions/plan/web-lazy.ts +53 -0
  126. package/extensions/plan/web-markdown.ts +9 -67
  127. package/extensions/plan/web-payload.ts +3 -3
  128. package/extensions/plan/web-router.ts +18 -9
  129. package/extensions/plan/web.ts +1 -12
  130. package/extensions/question/tools.ts +6 -2
  131. package/extensions/question/{tui.ts → view/question-screen.ts} +11 -11
  132. package/extensions/rewind/commands/commands.ts +6 -3
  133. package/extensions/rewind/state/runtime.ts +1 -1
  134. package/extensions/rewind/{view/status.ts → status.ts} +4 -4
  135. package/extensions/rewind/view/review-tui.ts +4 -4
  136. package/extensions/serve/config.ts +10 -8
  137. package/extensions/serve/index.ts +17 -3
  138. package/extensions/serve/registry.ts +26 -17
  139. package/extensions/session-bus/commands.ts +11 -6
  140. package/extensions/session-bus/join-request.ts +2 -1
  141. package/extensions/session-bus/mail-watch.ts +26 -17
  142. package/extensions/session-bus/protocol.ts +15 -3
  143. package/extensions/session-bus/resolve.ts +5 -7
  144. package/extensions/session-bus/runtime.ts +40 -12
  145. package/extensions/session-bus/store.ts +101 -19
  146. package/extensions/session-bus/types.ts +19 -0
  147. package/extensions/session-bus/{tui.ts → view/session-list.ts} +16 -16
  148. package/extensions/subagent/agents.ts +1 -1
  149. package/extensions/subagent/commands.ts +4 -2
  150. package/extensions/subagent/format.ts +15 -15
  151. package/extensions/subagent/index.ts +1 -1
  152. package/extensions/subagent/render.ts +3 -3
  153. package/extensions/subagent/{background-tui.ts → view/agents-board.ts} +11 -11
  154. package/extensions/team/commands.ts +24 -7
  155. package/extensions/team/dispatch.ts +18 -4
  156. package/extensions/team/format.ts +17 -2
  157. package/extensions/team/index.ts +9 -8
  158. package/extensions/team/schema.ts +6 -5
  159. package/extensions/team/tools.ts +107 -45
  160. package/extensions/team/types.ts +3 -2
  161. package/extensions/todo/commands.ts +4 -2
  162. package/extensions/todo/format.ts +11 -0
  163. package/extensions/todo/render.ts +57 -0
  164. package/extensions/todo/runtime.ts +1 -1
  165. package/extensions/todo/tools.ts +1 -1
  166. package/extensions/todo/view/todo-screen.ts +122 -0
  167. package/extensions/todo/widget.ts +47 -0
  168. package/extensions/watch/commands.ts +4 -2
  169. package/extensions/watch/format.ts +36 -7
  170. package/extensions/watch/render.ts +57 -0
  171. package/extensions/watch/runtime.ts +27 -22
  172. package/extensions/watch/tools.ts +4 -4
  173. package/extensions/watch/{tui.ts → view/watch-screen.ts} +18 -80
  174. package/extensions/web-access/fetch/tool.ts +12 -4
  175. package/extensions/wechat/commands/commands.ts +18 -14
  176. package/extensions/wechat/index.ts +72 -37
  177. package/extensions/wechat/state/credentials.ts +1 -1
  178. package/extensions/wechat/state/state.ts +1 -1
  179. package/extensions/wechat/text/status.ts +112 -0
  180. package/extensions/wechat/types.ts +2 -2
  181. package/extensions/wechat/view/status-screen.ts +4 -103
  182. package/extensions/worktree/commands.ts +11 -9
  183. package/extensions/worktree/enter.ts +2 -2
  184. package/extensions/worktree/exit.ts +2 -2
  185. package/extensions/worktree/index.ts +5 -5
  186. package/extensions/worktree/screen.ts +3 -3
  187. package/extensions/worktree/status.ts +2 -2
  188. package/extensions/worktree/tools.ts +113 -116
  189. package/lib/README.md +27 -13
  190. package/lib/board/actions.ts +111 -0
  191. package/lib/board/artifact.ts +149 -0
  192. package/lib/board/card.ts +163 -52
  193. package/lib/board/config.ts +58 -0
  194. package/lib/board/contract.ts +113 -0
  195. package/lib/board/join.ts +38 -0
  196. package/lib/board/lanes.ts +182 -0
  197. package/lib/board/lead.ts +130 -0
  198. package/lib/board/limits.ts +78 -0
  199. package/lib/board/links.ts +31 -10
  200. package/lib/board/page-pointer.ts +1 -1
  201. package/lib/board/read.ts +83 -0
  202. package/lib/board/reason.ts +102 -0
  203. package/lib/board/round-text.ts +68 -6
  204. package/lib/board/round.ts +31 -15
  205. package/lib/board/rules.ts +151 -307
  206. package/lib/board/seat.ts +35 -0
  207. package/lib/board/session.ts +136 -54
  208. package/lib/board/skipped.ts +22 -0
  209. package/lib/board/steps.ts +487 -0
  210. package/lib/board/store.ts +87 -30
  211. package/lib/board/tone.ts +92 -0
  212. package/lib/board/view.ts +70 -58
  213. package/{extensions/watch → lib}/child.ts +40 -21
  214. package/lib/env.ts +65 -7
  215. package/lib/format.ts +13 -0
  216. package/lib/http/index.ts +14 -12
  217. package/lib/http/port.ts +15 -10
  218. package/lib/lazy.ts +45 -0
  219. package/lib/plan-entry.ts +39 -10
  220. package/lib/plans/paths.ts +82 -0
  221. package/lib/policy.ts +9 -6
  222. package/lib/report.ts +3 -2
  223. package/lib/requirements.ts +8 -8
  224. package/lib/session-label.ts +111 -5
  225. package/lib/session-roster.ts +43 -1
  226. package/lib/session-tool.ts +5 -4
  227. package/lib/stray-fields.ts +7 -2
  228. package/lib/tool-names.ts +5 -10
  229. package/lib/tui/README.md +23 -8
  230. package/lib/tui/chrome.ts +16 -15
  231. package/lib/tui/cursor.ts +18 -3
  232. package/lib/tui/glyphs.ts +9 -7
  233. package/lib/tui/tokens.ts +33 -2
  234. package/lib/tui/tool.ts +3 -3
  235. package/lib/web/README.md +21 -0
  236. package/lib/web/components.ts +5 -1
  237. package/lib/web/index.ts +1 -0
  238. package/lib/web/markdown.ts +83 -0
  239. package/lib/web/markup.ts +2 -1
  240. package/lib/web/runtime.ts +9 -10
  241. package/package.json +6 -1
  242. package/skills/init/SKILL.md +5 -5
  243. package/skills/next/SKILL.md +3 -5
  244. package/extensions/board/seat.ts +0 -28
  245. package/extensions/todo/tui.ts +0 -212
  246. package/lib/tui/index.ts +0 -34
  247. package/skills/project-docs/SKILL.md +0 -133
  248. package/skills/project-docs/references/audit.md +0 -131
  249. package/skills/project-docs/references/changelog.md +0 -105
  250. package/skills/project-docs/references/discovery.md +0 -127
  251. package/skills/project-docs/references/readme.md +0 -80
  252. package/skills/project-docs/references/release.md +0 -84
  253. package/skills/project-docs/references/roadmap.md +0 -93
  254. package/skills/project-docs/scripts/state.ts +0 -316
@@ -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
  }
@@ -34,7 +34,7 @@ import {
34
34
  import type { JobsRuntime } from "./runtime.ts";
35
35
  import { JobParams, normalizeCall, type JobArgs, type JobCall } from "./schema.ts";
36
36
  import { toRecord } from "./store.ts";
37
- import { jobTail, type JobResultInput } from "./tui.ts";
37
+ import { jobTail, type JobResultInput } from "./render.ts";
38
38
  import type { JobDetails, JobRecord } from "./types.ts";
39
39
 
40
40
  export const TOOL_NAME = "job";
@@ -73,7 +73,7 @@ export function registerTools(pi: ExtensionAPI, runtime: JobsRuntime): void {
73
73
  try {
74
74
  switch (call.action) {
75
75
  case "start": {
76
- const job = runtime.start(
76
+ const job = await runtime.start(
77
77
  {
78
78
  command: call.command,
79
79
  cwd: call.cwd,
@@ -145,7 +145,7 @@ export function registerTools(pi: ExtensionAPI, runtime: JobsRuntime): void {
145
145
  case "kill": {
146
146
  const before = runtime.get(call.id);
147
147
  if (!before) return errorResult("kill", `no job "${call.id}".`);
148
- const job = runtime.kill(call.id, call.signal);
148
+ const job = await runtime.kill(call.id, call.signal);
149
149
  if (!job) return errorResult("kill", `no job "${call.id}".`);
150
150
  const wasRunning = before.status === "running";
151
151
  return textResult(
@@ -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,80 +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 { formatDuration, 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 { SEPARATORS } from "../../lib/tui/glyphs.ts";
22
- import { statusRow, type StatusRow } from "../../lib/tui/rows.ts";
23
- import type { TailResultInput } from "../../lib/tui/tool.ts";
24
- import { paint } from "../../lib/tui/tokens.ts";
25
- import {
26
- compareJobs,
27
- elapsedMs,
28
- formatJobDetail,
29
- jobCounts,
30
- shortLabel,
31
- statusGlyphChar,
32
- statusRole,
33
- type JobCounts,
34
- } from "./format.ts";
35
- import type { JobRecord } from "./types.ts";
36
-
37
- /** One job row: selection marker, status glyph, id, label, and a right-aligned
38
- * elapsed value, tagged `peer` when another session owns it. `statusRow` owns
39
- * the clipping and the alignment. */
40
- function jobRow(
41
- job: JobRecord,
42
- theme: Theme,
43
- width: number,
44
- options: Pick<StatusRow, "selected" | "marker"> & { foreign?: boolean } = {},
45
- ): string {
46
- const elapsed = formatDuration(elapsedMs(job, Date.now()));
47
- return statusRow(theme, width, {
48
- ...options,
49
- glyph: statusGlyphChar(job.status),
50
- glyphRole: statusRole(job.status),
51
- label: `${job.id} ${shortLabel(job)}`,
52
- labelRole: options.selected ? "accent" : "label",
53
- // The tag rides the right-aligned cell, which survives a narrow terminal.
54
- trailing: options.foreign ? `${elapsed}${SEPARATORS.item}peer` : elapsed,
55
- });
56
- }
57
-
58
- /** Input for the transcript result: a themed job list or a log tail. */
59
- export type JobResultInput =
60
- | { kind: "list"; jobs: JobRecord[]; more: number; sessionId?: string }
61
- | { kind: "logs"; lines: string[]; earlier: number };
62
-
63
- /** Map the job vocabulary onto the shared transcript tail block. */
64
- export function jobTail(input: JobResultInput): TailResultInput<JobRecord> {
65
- if (input.kind === "logs") {
66
- return { kind: "lines", lines: input.lines, earlier: input.earlier, earlierLabel: "lines" };
67
- }
68
- return {
69
- kind: "list",
70
- rows: input.jobs,
71
- more: input.more,
72
- rowLine: (job, width, theme) => {
73
- const foreign = input.sessionId !== undefined && job.sessionId !== input.sessionId;
74
- return jobRow(job, theme, width, { marker: false, foreign });
75
- },
76
- };
77
- }
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";
78
24
 
79
25
  export interface JobListCallbacks {
80
26
  /** This session's id: a record with another id belongs to a peer session. */
@@ -161,8 +107,8 @@ export class JobListComponent extends DockListScreen<JobRecord> {
161
107
  "Esc close",
162
108
  "↑/↓ or j/k select",
163
109
  { text: "Enter or l logs", color: "accent" },
164
- { text: "d or K kill", color: "warning" },
165
- { text: "x clear finished", color: "warning" },
110
+ { text: "d or K kill", color: "warn" },
111
+ { text: "x clear finished", color: "warn" },
166
112
  "PgUp/PgDn",
167
113
  ];
168
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",
@@ -2,7 +2,7 @@
2
2
  * Slash command for the plan-mode extension.
3
3
  *
4
4
  * `/plan` toggles plan mode, or enters it and sends an optional task;
5
- * `Ctrl+Alt+P` toggles too. Four subcommands are reserved: `/plan review [path]`
5
+ * Shift+Tab toggles too, through bytefly's editor. Four subcommands are reserved: `/plan review [path]`
6
6
  * opens a plan in the browser (and sends saved comments to the model as a
7
7
  * refinement), `/plan share [stop]` prints or revokes the link a teammate opens,
8
8
  * `/plan list` shows the plans directory, newest first with Enter opening a
@@ -15,11 +15,11 @@ import { READ_ONLY_SUMMARY } from "../../lib/plan-entry.ts";
15
15
  import { qrLines } from "../../lib/qr.ts";
16
16
  import { withRailsSuppressed } from "../../lib/rails.ts";
17
17
  import { feedbackSummary } from "./feedback.ts";
18
- import { PlanIndexComponent, formatPlanIndex } from "./plan-index.ts";
19
- import { runBrowserReview } from "./review.ts";
18
+ import { runBrowserReview } from "./review-lazy.ts";
20
19
  import type { PlanRuntime } from "./runtime.ts";
21
20
  import type { PlanSummary } from "./plans.ts";
22
- import { createPlanWebReview, NO_SERVER_DEPS, type PlanWebReview } from "./web.ts";
21
+ import { createLazyPlanWebReview, NO_SERVER_DEPS } from "./web-lazy.ts";
22
+ import type { PlanWebReview } from "./web.ts";
23
23
 
24
24
  const ENABLED_NOTICE = `Plan mode enabled — ${READ_ONLY_SUMMARY}.`;
25
25
  const DISABLED_NOTICE = "Plan mode disabled — full access restored.";
@@ -28,7 +28,7 @@ const SHARE_PREFIX = "share";
28
28
  const LIST_PREFIX = "list";
29
29
  const CANCEL_PREFIX = "cancel";
30
30
 
31
- /** Notice text for the current mode; shared by `/plan` and the `Ctrl+Alt+P` shortcut. */
31
+ /** Notice text for the current mode; shared by `/plan` and the toggle request. */
32
32
  export function planModeNotice(enabled: boolean): string {
33
33
  return enabled ? ENABLED_NOTICE : DISABLED_NOTICE;
34
34
  }
@@ -126,7 +126,8 @@ async function reviewInBrowser(
126
126
  const file = path ? await runtime.plans.read(ctx.cwd, path) : await runtime.plans.latest(ctx.cwd);
127
127
  if (!file) {
128
128
  if (path) ctx.ui.notify(`Plan review: no plan file at ${path}`, "error");
129
- else ctx.ui.notify("Plan review: no plan to review; write one with write_plan or pass a path.", "warning");
129
+ else
130
+ ctx.ui.notify("Plan review: no plan to review; write one with `plan` action `write` or pass a path.", "warning");
130
131
  return;
131
132
  }
132
133
  // The server is loopback-only, so a remote turn cannot drive it.
@@ -161,7 +162,7 @@ async function reviewInBrowser(
161
162
  return;
162
163
  }
163
164
  const message = runtime.isEnabled()
164
- ? `The user reviewed the plan ${result.plan.relativePath} in the browser and left feedback:\n${summary}\nStay in plan mode, revise the plan (write_plan, reusing plan_path), then call exit_plan_mode again.`
165
+ ? `The user reviewed the plan ${result.plan.relativePath} in the browser and left feedback:\n${summary}\nStay in plan mode, revise the plan (\`plan\` action \`write\`, reusing plan_path), then call \`plan\` action \`exit\` again.`
165
166
  : `The user reviewed the plan ${result.plan.relativePath} in the browser and left feedback:\n${summary}\nUpdate the plan file to address the feedback.`;
166
167
  if (ctx.isIdle()) await pi.sendUserMessage(message);
167
168
  else await pi.sendUserMessage(message, { deliverAs: "followUp" });
@@ -187,6 +188,7 @@ async function openPlanIndex(
187
188
  const summaries = await summarise();
188
189
 
189
190
  if (ctx.mode !== "tui") {
191
+ const { formatPlanIndex } = await import("./plan-index.ts");
190
192
  ctx.ui.notify(formatPlanIndex(summaries, dir), "info");
191
193
  return;
192
194
  }
@@ -195,6 +197,7 @@ async function openPlanIndex(
195
197
  let selected: (PlanSummary | undefined) | undefined;
196
198
  // A dock list shares the editor slot with the goal and todo rails, so it takes
197
199
  // the dock to itself for as long as it is open, like the other list screens.
200
+ const { PlanIndexComponent } = await import("./plan-index.ts");
198
201
  await withRailsSuppressed(pi, () =>
199
202
  ctx.ui.custom<void>((tui, theme, _keybindings, done) => {
200
203
  const component = new PlanIndexComponent({
@@ -219,7 +222,7 @@ async function openPlanIndex(
219
222
  export function registerCommands(
220
223
  pi: ExtensionAPI,
221
224
  runtime: PlanRuntime,
222
- web: PlanWebReview = createPlanWebReview(runtime.plans, NO_SERVER_DEPS),
225
+ web: PlanWebReview = createLazyPlanWebReview(runtime.plans, NO_SERVER_DEPS),
223
226
  ): void {
224
227
  pi.registerCommand("plan", {
225
228
  description:
@@ -259,7 +262,7 @@ export function registerCommands(
259
262
  return;
260
263
  }
261
264
 
262
- // No task: toggle, exactly like Ctrl+Alt+P.
265
+ // No task: toggle, exactly like Shift+Tab.
263
266
  if (!prompt) {
264
267
  runtime.toggle(ctx);
265
268
  ctx.ui.notify(planModeNotice(runtime.isEnabled()), "info");
@@ -5,10 +5,16 @@
5
5
  * the cap diagnosis, the preview, and the merge summary in one place.
6
6
  */
7
7
 
8
- import { SET_PLAN_STEPS_TOOL } from "./runtime.ts";
9
8
  import { MAX_STEP_LENGTH, MAX_STEPS, type PlanStep, type StepUpdate } from "./steps.ts";
10
9
  import type { TodoMerge } from "./todos.ts";
11
10
 
11
+ /**
12
+ * How every message names the tick-off call. One spelling, because the tool is now
13
+ * one verb among five and a message that still spelled the old name would teach the
14
+ * model a tool that no longer exists.
15
+ */
16
+ export const TICK_CALL = "`plan` action `steps`";
17
+
12
18
  /** Lines of plan shown in an unexpanded result. */
13
19
  const PREVIEW_LINES = 8;
14
20
 
@@ -80,7 +86,7 @@ export function mergeSummary(merged: TodoMerge, steps: PlanStep[]): string {
80
86
  merged.deferred > 0 ? `${merged.deferred} deferred, list full` : "",
81
87
  ].filter(Boolean);
82
88
  const suffix = notes.length > 0 ? ` (${notes.join(", ")})` : "";
83
- const tick = `Tick each one off in the file with ${SET_PLAN_STEPS_TOOL} as you finish it, passing the step's text as \`expected\`.`;
89
+ const tick = `Tick each one off in the file with ${TICK_CALL} as you finish it, passing the step's text as \`expected\`.`;
84
90
  const touched =
85
91
  merged.stale > 0
86
92
  ? ` ${merged.stale} older item${merged.stale === 1 ? "" : "s"} no longer match${merged.stale === 1 ? "es" : ""} this plan but ${merged.stale === 1 ? "was" : "were"} left alone: ${merged.stale === 1 ? "it was" : "they were"} started or finished, so check ${merged.stale === 1 ? "it" : "them"} yourself.`