@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
@@ -57,7 +57,7 @@ export interface PlanReviewSession {
57
57
  export interface PlanReviewOptions {
58
58
  /** Abort signal; aborting closes the session as cancelled. */
59
59
  signal?: AbortSignal;
60
- /** Whether the browser may approve the plan (only the `exit_plan_mode` flow). */
60
+ /** Whether the browser may approve the plan (only the `plan` tool's `exit` flow). */
61
61
  canApprove?: boolean;
62
62
  /** The section digests the previous review showed, so this page can mark changed sections. */
63
63
  baseline?: SectionDigest[];
@@ -131,17 +131,6 @@ const DROP_GRACE_MS = 1500;
131
131
  */
132
132
  const PLAN_POLL_MS = 1000;
133
133
 
134
- /**
135
- * The deps a caller that injects its own review leaves out.
136
- *
137
- * A review needs a place on the session's server, so a caller with no wiring says so
138
- * rather than binding a socket of its own: the start is refused with a sentence.
139
- */
140
- export const NO_SERVER_DEPS: PlanWebReviewDeps = {
141
- mount: async () => ({ kind: "refused", reason: "this review has no server wired" }),
142
- session: () => "",
143
- };
144
-
145
134
  export function createPlanWebReview(store: PlanStore, deps: PlanWebReviewDeps): PlanWebReview {
146
135
  const sessions = new Set<PlanReviewSession>();
147
136
  // What each running review can do about sharing; the session itself stays the
@@ -14,7 +14,6 @@ import { ASK_USER_QUESTION_TOOL } from "../../lib/tool-names.ts";
14
14
  import { UNAVAILABLE_TEXT, formatAnswerText, optionSummary, questionHeaders, unavailableResult } from "./answers.ts";
15
15
  import { askViaDialogs, type QuestionUI } from "./dialogs.ts";
16
16
  import { AskUserQuestionParams, normalizeQuestions, type AskUserQuestionArgs } from "./schema.ts";
17
- import { askViaTui } from "./tui.ts";
18
17
  import type { AskResult } from "./types.ts";
19
18
 
20
19
  export const TOOL_NAME = ASK_USER_QUESTION_TOOL;
@@ -59,7 +58,12 @@ export function registerTools(pi: ExtensionAPI): void {
59
58
  // A remote-answered turn falls back to dialogs; only a local TUI turn uses
60
59
  // the full-screen questionnaire.
61
60
  result = await askHuman(ctx, {
62
- custom: () => withRailsSuppressed(pi, () => askViaTui(ctx, questions, signal)),
61
+ custom: async () => {
62
+ // The questionnaire is the TUI's: a remote or headless answer below loads
63
+ // neither it nor the rails wrapper.
64
+ const { askViaTui } = await import("./view/question-screen.ts");
65
+ return withRailsSuppressed(pi, () => askViaTui(ctx, questions, signal));
66
+ },
63
67
  dialogs: () => askViaDialogs(dialogUI(ctx), questions, signal),
64
68
  });
65
69
  }
@@ -15,15 +15,15 @@ import {
15
15
  type TuiMouseEvent,
16
16
  type TuiMouseEventResult,
17
17
  } from "@earendil-works/pi-tui";
18
- import { sanitizeLine } from "../../lib/format.ts";
19
- import { flowCells } from "../../lib/tui/columns.ts";
20
- import { clampScroll, formatRange, keepVisible, selectionMarker, wheelDelta } from "../../lib/tui/cursor.ts";
21
- import { editorTheme, screenHint, type ScreenHint } from "../../lib/tui/chrome.ts";
22
- import { GLYPHS } from "../../lib/tui/glyphs.ts";
23
- import { checkbox } from "../../lib/tui/rows.ts";
24
- import { plainRule, titleRule } from "../../lib/tui/rules.ts";
25
- import { paint } from "../../lib/tui/tokens.ts";
26
- import { OTHER_LABEL, cancelledResult, collectAnswers, completedResult, describeSelection } from "./answers.ts";
18
+ import { sanitizeLine } from "../../../lib/format.ts";
19
+ import { flowCells } from "../../../lib/tui/columns.ts";
20
+ import { clampScroll, formatRange, keepVisible, selectionMarker, wheelDelta } from "../../../lib/tui/cursor.ts";
21
+ import { editorTheme, screenHint, type ScreenHint } from "../../../lib/tui/chrome.ts";
22
+ import { GLYPHS } from "../../../lib/tui/glyphs.ts";
23
+ import { checkbox } from "../../../lib/tui/rows.ts";
24
+ import { plainRule, titleRule } from "../../../lib/tui/rules.ts";
25
+ import { paint } from "../../../lib/tui/tokens.ts";
26
+ import { OTHER_LABEL, cancelledResult, collectAnswers, completedResult, describeSelection } from "../answers.ts";
27
27
  import {
28
28
  allAnswered,
29
29
  applyCustomText,
@@ -34,8 +34,8 @@ import {
34
34
  type Effect,
35
35
  type KeyAction,
36
36
  type TuiState,
37
- } from "./tui-state.ts";
38
- import type { AskResult, Question, Selection } from "./types.ts";
37
+ } from "../tui-state.ts";
38
+ import type { AskResult, Question, Selection } from "../types.ts";
39
39
 
40
40
  type Outcome =
41
41
  | { type: "cancelled"; selections: Map<string, Selection> }
@@ -10,15 +10,14 @@
10
10
  import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
11
11
  import { withRailsSuppressed } from "../../../lib/rails.ts";
12
12
  import { formatRewindListText, formatRewindRow } from "../text/format.ts";
13
- import { openReview } from "./review.ts";
14
- import { rewindTo } from "./rewind.ts";
15
13
  import type { RewindRuntime } from "../state/runtime.ts";
16
- import { RewindListComponent } from "../view/rewind-tui.ts";
17
14
  import { buildRewindPoints, type RewindPoint } from "../state/timeline.ts";
18
15
 
19
16
  /** Pick a point from the timeline, or undefined when the user cancels. */
20
17
  async function choosePoint(ctx: ExtensionCommandContext, points: RewindPoint[]): Promise<RewindPoint | undefined> {
21
18
  if (ctx.mode === "tui") {
19
+ // The timeline is the TUI's: the picker below and registration load neither.
20
+ const { RewindListComponent } = await import("../view/rewind-tui.ts");
22
21
  return ctx.ui.custom<RewindPoint | undefined>(
23
22
  (tui, theme, _keybindings, done) =>
24
23
  new RewindListComponent({
@@ -62,6 +61,9 @@ async function openRewind(pi: ExtensionAPI, runtime: RewindRuntime, ctx: Extensi
62
61
 
63
62
  const point = await withRailsSuppressed(pi, () => choosePoint(ctx, points));
64
63
  if (!point) return;
64
+ // The rewind run and the review gate both load on the verb that needs them: the
65
+ // listing above and registration load neither.
66
+ const { rewindTo } = await import("./rewind.ts");
65
67
  await rewindTo(pi, runtime, ctx, root, point);
66
68
  }
67
69
 
@@ -76,6 +78,7 @@ export function registerCommands(pi: ExtensionAPI, runtime: RewindRuntime): void
76
78
  pi.registerCommand("review", {
77
79
  description: "Review the latest turn's changes and revert a file or the whole turn",
78
80
  handler: async (_args, ctx) => {
81
+ const { openReview } = await import("./review.ts");
79
82
  await openReview(pi, runtime, ctx);
80
83
  },
81
84
  });
@@ -24,7 +24,7 @@ import { createOperationQueue } from "../transport/queue.ts";
24
24
  import { applyRestore, planRestore, type PlanResult, type RestoreInput } from "../transport/restore.ts";
25
25
  import type { PathStat } from "../text/paths.ts";
26
26
  import { createSnapshot } from "../transport/snapshot.ts";
27
- import { createStatusChip } from "../view/status.ts";
27
+ import { createStatusChip } from "../status.ts";
28
28
  import { belongsToRoot, listSnapshots, pruneSnapshots } from "../transport/store.ts";
29
29
  import { createTempIndexStore } from "../transport/temp-index.ts";
30
30
  import { lastUserEntryId } from "./timeline.ts";
@@ -8,10 +8,10 @@
8
8
  */
9
9
 
10
10
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
11
- import { GLYPHS, STATUS_KEYS } from "../../../lib/tui/glyphs.ts";
12
- import { paint } from "../../../lib/tui/tokens.ts";
13
- import type { RewindConfig } from "../config/config.ts";
14
- import { userMessagesFromBranch } from "../state/timeline.ts";
11
+ import { GLYPHS, STATUS_KEYS } from "../../lib/tui/glyphs.ts";
12
+ import { paint } from "../../lib/tui/tokens.ts";
13
+ import type { RewindConfig } from "./config/config.ts";
14
+ import { userMessagesFromBranch } from "./state/timeline.ts";
15
15
 
16
16
  const STATUS_KEY = STATUS_KEYS.rewind;
17
17
  const STATUS_GLYPH = GLYPHS.rewind;
@@ -193,10 +193,10 @@ export class ReviewListComponent extends ChecklistScreen<ReviewEntry> {
193
193
  protected hint(width: number): string {
194
194
  return screenHint(this.theme, width, [
195
195
  "Esc close",
196
- { text: "Enter diff", color: "accent" },
196
+ { text: "Enter diff", color: "accent" as const },
197
197
  "Space select",
198
- { text: "r revert", color: "warning" },
199
- { text: "R all", color: "warning" },
198
+ { text: "r revert", color: "warn" },
199
+ { text: "R all", color: "warn" },
200
200
  "a/n all/none",
201
201
  ]);
202
202
  }
@@ -314,7 +314,7 @@ export class ReviewDiffComponent implements Component {
314
314
  return screenHint(this.theme, width, [
315
315
  "Esc back",
316
316
  ...(this.options.canRevertHunks ? [{ text: "h hunks", color: "accent" as const }] : []),
317
- { text: "r revert file", color: "warning" },
317
+ { text: "r revert file", color: "warn" },
318
318
  "j/k scroll",
319
319
  "g/G top/end",
320
320
  ]);
@@ -2,21 +2,23 @@
2
2
  * The session's one port: the `serve` section of the shared `bytefly.json` (global
3
3
  * then project, project wins).
4
4
  *
5
- * `0` — the default — takes one free port for the session and remembers it, which
6
- * is what a session that never bookmarks wants. A positive value pins the session's
7
- * server, so every page it serves keeps the address a bookmark already points at.
8
- * A pinned port that is taken fails the start rather than moving the pages out from
9
- * under the bookmark.
5
+ * `0` — the default — takes the project's own port: its repository key hashed into the
6
+ * band `lib/http/port.ts` holds, so a session that starts again lands where a bookmark
7
+ * already points and two repositories do not compete for one number. A positive value
8
+ * pins the session's server, so every page it serves keeps the address a bookmark
9
+ * already points at. Either way a port another process holds costs the start its
10
+ * OS-chosen fallback — unless it was pinned, which fails the start rather than moving
11
+ * the pages out from under the bookmark.
10
12
  *
11
- * Pin it in the project document when more than one repository runs pages on the
12
- * machine: a global value is one port for every session.
13
+ * Pin it in the project document when a repository needs a number of its own: a global
14
+ * value is one port for every session.
13
15
  */
14
16
 
15
17
  import { clampInteger, loadConfigSection } from "../../lib/config.ts";
16
18
 
17
19
  /** The `serve` section. */
18
20
  export interface ServeConfig {
19
- /** TCP port on `127.0.0.1`; `0` picks one for the session, a positive value pins it. */
21
+ /** TCP port on `127.0.0.1`; `0` takes the project's port, a positive value pins it. */
20
22
  port: number;
21
23
  }
22
24
 
@@ -5,7 +5,9 @@
5
5
  * own. `plan`, `board`, and `file-browse` ask for a path prefix on
6
6
  * {@link HTTP_MOUNT_CHANNEL} and the registry answers with the session's origin, so
7
7
  * a session has one port, one close path, and one chip instead of one socket per
8
- * extension. Binding happens on the first page that asks, never in the factory.
8
+ * extension. Binding happens on the first page that asks, never in the factory, and the
9
+ * port it binds is the project's own number unless `serve.port` pins another — so a
10
+ * bookmarked page URL survives a restart.
9
11
  *
10
12
  * Load with: pi --extension ./extensions/serve
11
13
  */
@@ -13,7 +15,7 @@
13
15
  import { networkInterfaces } from "node:os";
14
16
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
15
17
  import { isExtensionEnabled } from "../../lib/env.ts";
16
- import { HTTP_MOUNT_CHANNEL, type MountRequest } from "../../lib/http/index.ts";
18
+ import { HTTP_MOUNT_CHANNEL, type MountRequest } from "../../lib/http/mount.ts";
17
19
  import { GLYPHS, STATUS_KEYS } from "../../lib/tui/glyphs.ts";
18
20
  import { paint } from "../../lib/tui/tokens.ts";
19
21
  import { loadServeConfig } from "./config.ts";
@@ -26,6 +28,9 @@ export default function serve(pi: ExtensionAPI): void {
26
28
  /** Each session's context, which is where the port chip is drawn. */
27
29
  const contexts = new Map<string, ExtensionContext>();
28
30
 
31
+ /** The directory a session serves: its config and its port both come from it. */
32
+ const cwdOf = (session: string): string => cwds.get(session) ?? process.cwd();
33
+
29
34
  /**
30
35
  * Publish the session's port, or clear the chip with `undefined`. The chip belongs
31
36
  * to the server, not to a page: one number, shown while anything is served on it.
@@ -45,7 +50,16 @@ export default function serve(pi: ExtensionAPI): void {
45
50
  };
46
51
 
47
52
  const registry = createServeRegistry({
48
- portFor: (session) => loadServeConfig(cwds.get(session) ?? process.cwd()).port,
53
+ portFor: (session) => loadServeConfig(cwdOf(session)).port,
54
+ pickPort: async (session) => {
55
+ // The repository key is a git answer, so it is paid on the first bind rather than
56
+ // at registration: a session that opens no page never asks for it.
57
+ const [{ repoKeyFor }, { projectPort }] = await Promise.all([
58
+ import("../../lib/repo-key.ts"),
59
+ import("../../lib/http/port.ts"),
60
+ ]);
61
+ return projectPort((await repoKeyFor(pi, cwdOf(session))).key);
62
+ },
49
63
  interfaces: () => networkInterfaces(),
50
64
  onPort: setChip,
51
65
  });
@@ -8,26 +8,26 @@
8
8
  * and closes a session's socket with its last mount. A page that goes away takes
9
9
  * only its own prefix with it.
10
10
  *
11
- * Pinning is the session's, not a page's: `serve.port` belongs to the first session
12
- * that binds, so a second session in this process (a WeChat peer loads every other
13
- * extension, `extensions/wechat/peer/peer-session.ts`) falls back to an OS-chosen
11
+ * The port is a preference, not a reservation: a session's first bind tries the port
12
+ * it is pinned to, or the project's own number, and falls back to an OS-chosen one when
13
+ * that port is taken. Pinning is the session's, not a page's: `serve.port` belongs to the
14
+ * first session that binds, so a second session in this process (a WeChat peer loads every
15
+ * other extension, `extensions/wechat/peer/peer-session.ts`) falls back to an OS-chosen
14
16
  * port instead of failing on the host's pin, and a second *process* is told the pin
15
17
  * is in use rather than moved off the address a bookmark names.
16
18
  *
17
19
  * Value-only: every byte of state is in the returned closure.
18
20
  */
19
21
 
20
- import {
21
- type HttpMount,
22
- lanAddresses,
23
- type MountOutcome,
24
- type MountRequest,
25
- type ServedMount,
26
- type ServedServer,
27
- serveHttp,
28
- type SharedListener,
29
- randomPort,
30
- type NetworkInterfaces,
22
+ import { lazy } from "../../lib/lazy.ts";
23
+ import type {
24
+ HttpMount,
25
+ MountOutcome,
26
+ MountRequest,
27
+ NetworkInterfaces,
28
+ ServedMount,
29
+ ServedServer,
30
+ SharedListener,
31
31
  } from "../../lib/http/index.ts";
32
32
 
33
33
  /** One prefix as this session's server holds it: the mount, and the spec that remounts it. */
@@ -52,12 +52,17 @@ interface Served {
52
52
  export interface ServeRegistryDeps {
53
53
  /** The port a session is pinned to, or `0` to pick one. */
54
54
  portFor(session: string): number;
55
- /** The candidate port for a session's first bind; defaults to the shared band. */
56
- pickPort?: () => number;
55
+ /**
56
+ * The port a session's first bind tries, before the OS-chosen fallback: the project's
57
+ * own number, so a session that starts again lands on the one a bookmark names.
58
+ */
59
+ pickPort(session: string): number | Promise<number>;
57
60
  /** The machine's interfaces; a test injects a snapshot instead. */
58
61
  interfaces: () => NetworkInterfaces;
59
62
  /** Told when a session's port comes up and when it goes away, so a chip can say so. */
60
63
  onPort?: (session: string, port: number | undefined) => void;
64
+ /** The served-page kit; loaded on the first bind, so registration never pays for it. */
65
+ loadServe?: () => Promise<typeof import("../../lib/http/index.ts")>;
61
66
  }
62
67
 
63
68
  /** The session's servers, as the extension's bus listener drives them. */
@@ -91,12 +96,15 @@ export function createServeRegistry(deps: ServeRegistryDeps): ServeRegistry {
91
96
  const remembered = new Map<string, number>();
92
97
  /** The session the pinned port belongs to; every later session picks its own. */
93
98
  let pinnedBy: string | undefined;
99
+ /** The served-page kit: one load, on the first bind, shared by every later one. */
100
+ const http = lazy(deps.loadServe ?? (() => import("../../lib/http/index.ts")));
94
101
 
95
102
  /** Bind `session`'s server on the pin when it is this session's to take. */
96
103
  const bind = async (session: string): Promise<Opened> => {
104
+ const { serveHttp } = await http.get();
97
105
  const pinned = deps.portFor(session);
98
106
  const usePin = pinned > 0 && (pinnedBy === undefined || pinnedBy === session);
99
- const preferred = remembered.get(session) ?? (deps.pickPort ?? randomPort)();
107
+ const preferred = remembered.get(session) ?? (await deps.pickPort(session));
100
108
  try {
101
109
  const server = await serveHttp({ ports: usePin ? [pinned] : [preferred, 0] });
102
110
  if (usePin) pinnedBy = session;
@@ -163,6 +171,7 @@ export function createServeRegistry(deps: ServeRegistryDeps): ServeRegistry {
163
171
  };
164
172
 
165
173
  const share = async (session: string, base: string): Promise<SharedListener | undefined> => {
174
+ const { serveHttp, lanAddresses } = await http.get();
166
175
  const served = sessions.get(session);
167
176
  const held = served?.mounts.get(base);
168
177
  if (served === undefined || held === undefined) return undefined;
@@ -12,11 +12,9 @@
12
12
  import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
13
13
  import { messageOf } from "../../lib/errors.ts";
14
14
  import { sanitizeLine } from "../../lib/format.ts";
15
- import { withPolledScreen } from "../../lib/screen-session.ts";
16
15
  import { formatJoin, formatStatus } from "./format.ts";
17
16
  import { COMMAND_NAME } from "./protocol.ts";
18
17
  import type { SessionBus } from "./runtime.ts";
19
- import { SessionListComponent } from "./tui.ts";
20
18
 
21
19
  /** How often the open board re-reads the bus. */
22
20
  const BOARD_POLL_MS = 1_000;
@@ -29,7 +27,7 @@ const BOARD_POLL_MS = 1_000;
29
27
  * record fresh, so a poll never has to write. Both reads are read-only: the
30
28
  * snapshot neither prunes nor refreshes, and the peek neither consumes nor marks.
31
29
  */
32
- function createBoardReader(bus: SessionBus): (screen: SessionListComponent) => Promise<void> {
30
+ function createBoardReader(bus: SessionBus): (screen: SessionsScreen) => Promise<void> {
33
31
  let busy = false;
34
32
  return async (screen) => {
35
33
  if (busy) return;
@@ -45,12 +43,19 @@ function createBoardReader(bus: SessionBus): (screen: SessionListComponent) => P
45
43
  };
46
44
  }
47
45
 
46
+ /** The mounted board's type, without loading the screen at registration. */
47
+ type SessionsScreen = InstanceType<typeof import("./view/session-list.ts")["SessionListComponent"]>;
48
+
48
49
  /** Open the TUI board, and resolve when it closes. */
49
50
  async function openSessionsScreen(pi: ExtensionAPI, ctx: ExtensionCommandContext, bus: SessionBus): Promise<void> {
51
+ // The board is the TUI's: the text paths below and registration load neither it
52
+ // nor the dock screen's poll loop.
53
+ const { withPolledScreen } = await import("../../lib/screen-session.ts");
54
+ const { SessionListComponent } = await import("./view/session-list.ts");
50
55
  await bus.start(ctx);
51
56
  const [status, messages] = await Promise.all([bus.snapshot(), bus.peek()]);
52
57
  const first = { status, messages };
53
- let read: ((screen: SessionListComponent) => Promise<void>) | undefined;
58
+ let read: ((screen: SessionsScreen) => Promise<void>) | undefined;
54
59
  await withPolledScreen(
55
60
  pi,
56
61
  ctx,
@@ -59,9 +64,9 @@ async function openSessionsScreen(pi: ExtensionAPI, ctx: ExtensionCommandContext
59
64
  read = reader;
60
65
  // The verbs repaint the board they belong to; their callbacks only run after
61
66
  // construction, so a holder keeps the wiring one-directional.
62
- const holder: { screen?: SessionListComponent } = {};
67
+ const holder: { screen?: SessionsScreen } = {};
63
68
  const component = new SessionListComponent({
64
- latest: () => first,
69
+ board: first,
65
70
  theme: screen.theme,
66
71
  callbacks: {
67
72
  toggleWatch: async (armed) => {
@@ -10,7 +10,8 @@
10
10
  * The answer always comes: every path here returns a result, including a failure, so
11
11
  * a caller never waits on a listener that threw. A caller that asks for `name` also gets
12
12
  * Pi's session name, because `/team join <label>` is the one door that means "call me
13
- * this from now on"; every other join leaves the footer alone.
13
+ * this from now on"; the footer draws the roster label after every join either way, so a
14
+ * plain join changes the name on the bar without renaming the session.
14
15
  *
15
16
  * The claim is the answer, not the result: the bus starts and joins asynchronously, so
16
17
  * this answers with the promise and stays claimed while it runs — which is what keeps
@@ -12,11 +12,14 @@
12
12
  */
13
13
 
14
14
  import type { RepeatTimer } from "../../lib/timer.ts";
15
- import type { BusMessage } from "./types.ts";
15
+ import type { BusMessage, InboxPeek } from "./types.ts";
16
16
 
17
17
  export interface MailWatchOptions {
18
- /** This session's unread mail, read-only: nothing is consumed or marked. */
19
- peek(): Promise<BusMessage[]>;
18
+ /**
19
+ * This session's inbox, read-only: up to a page of mail whose ids are not `known`, and
20
+ * the ids of every message still there. Nothing is consumed or marked.
21
+ */
22
+ peek(known: ReadonlySet<string>): Promise<InboxPeek>;
20
23
  /** The repeating timer behind the poll. */
21
24
  interval: RepeatTimer;
22
25
  /** Poll interval; 0 disables the ticker (tests). */
@@ -54,7 +57,10 @@ export function createMailWatch(options: MailWatchOptions): MailWatch {
54
57
  let wake = false;
55
58
  let stopTicker: (() => void) | undefined;
56
59
  let pendingMail: BusMessage[] = [];
57
- /** Ids already queued, reported, or consumed; a broadcast shares one id. */
60
+ /**
61
+ * Ids already queued, reported, or consumed — pruned by what is still on disk, so it
62
+ * holds exactly the announced mail an `inbox` call has not removed yet.
63
+ */
58
64
  let reported = new Set<string>();
59
65
  let onWake: (() => void) | undefined;
60
66
 
@@ -68,24 +74,27 @@ export function createMailWatch(options: MailWatchOptions): MailWatch {
68
74
  }
69
75
 
70
76
  /**
71
- * Queue what arrived since the last poll, once, and wake a turn when this
72
- * session asked for one.
77
+ * Queue what this watch has not announced, once, and wake a turn when this session
78
+ * asked for one.
73
79
  *
74
- * The reported set is narrowed to the mail still on disk, so it is bounded by
75
- * the unread count rather than by the session's whole history; an id is unique
76
- * for good, so a message that was consumed can never be queued again.
80
+ * The queue and the memory are pruned by what is still on disk, so `reported` is
81
+ * bounded by the inbox rather than by a count: an id that leaves the inbox leaves the
82
+ * set, and no id still on disk can be forgotten while it is there. A count was how a
83
+ * backlog larger than one read made the watch forget an id it had already announced and
84
+ * announce it again.
77
85
  */
78
86
  async function pollOnce(): Promise<void> {
79
87
  if (!armed) return;
80
- const mail = await peek();
81
- const unread = new Set(mail.map((message) => message.id));
82
- reported = new Set([...reported].filter((id) => unread.has(id)));
83
- // A message that expired or was consumed leaves the queue with its file.
88
+ const known = new Set(reported);
89
+ for (const message of pendingMail) known.add(message.id);
90
+ const { mail, unread } = await peek(known);
91
+ // The read names everything still in the inbox, which is a different question from
92
+ // what one page covered: a message that expired or was consumed leaves the queue and
93
+ // the memory with its file.
94
+ for (const id of reported) if (!unread.has(id)) reported.delete(id);
84
95
  pendingMail = pendingMail.filter((message) => unread.has(message.id));
85
- const queued = new Set(pendingMail.map((message) => message.id));
86
- const fresh = mail.filter((message) => !reported.has(message.id) && !queued.has(message.id));
87
- if (fresh.length === 0) return;
88
- pendingMail = [...pendingMail, ...fresh];
96
+ if (mail.length === 0) return;
97
+ pendingMail = [...pendingMail, ...mail];
89
98
  if (wake) onWake?.();
90
99
  }
91
100
 
@@ -65,16 +65,28 @@ export function normalizeLimit(raw: unknown): number {
65
65
  return Math.min(MAX_LIMIT, Math.max(1, Math.floor(raw)));
66
66
  }
67
67
 
68
- /** Repository-relative, posix-normalized paths, or an error. */
68
+ /**
69
+ * Repository-relative, posix-normalized paths, or an error.
70
+ *
71
+ * A path that resolves to the repository root is handed on in the caller's own spelling
72
+ * rather than refused here: what it resolves to is the whole repository, and that
73
+ * refusal, with the sentence it is worded in, belongs to `resolve.ts` — one rule, one
74
+ * wording, and one module that owns both.
75
+ */
69
76
  export function normalizePaths(repoRoot: string, cwd: string, raw: unknown): { paths: string[] } | { error: string } {
70
77
  if (!Array.isArray(raw) || raw.length === 0) return { error: "paths must contain at least one path." };
71
78
  if (raw.length > MAX_PATHS) return { error: `at most ${MAX_PATHS} paths are allowed.` };
72
79
  const paths: string[] = [];
73
80
  for (const entry of raw) {
74
81
  if (typeof entry !== "string" || entry.trim() === "") return { error: "every path must be a non-empty string." };
75
- const absolute = resolve(cwd, entry.trim());
82
+ const given = entry.trim();
83
+ const absolute = resolve(cwd, given);
76
84
  const rel = relative(repoRoot, absolute);
77
- if (rel === "" || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
85
+ if (rel === "") {
86
+ if (!paths.includes(given)) paths.push(given);
87
+ continue;
88
+ }
89
+ if (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
78
90
  return { error: `path "${entry}" is outside the repository.` };
79
91
  }
80
92
  const normalized = rel.split(sep).join("/");
@@ -6,10 +6,14 @@
6
6
  * spelling inside the repository and the file it names are one claim, and a path
7
7
  * that leaves the repository is refused before anything is written.
8
8
  *
9
+ * The caller hands in paths that are already relative to the repository (the
10
+ * spelling rule is `protocol.ts`'s), so what is decided here is what they *resolve*
11
+ * to: the root itself, a target outside it, or a claim.
12
+ *
9
13
  * `lib/path.ts` owns the containment predicates; this owns the claim's shape.
10
14
  */
11
15
 
12
- import { isAbsolute, join, relative, sep } from "node:path";
16
+ import { join, relative, sep } from "node:path";
13
17
  import { isInside, realPathOfNearest } from "../../lib/path.ts";
14
18
 
15
19
  /** A path this module declined, with the reason a caller can print. */
@@ -40,9 +44,6 @@ const ROOT_ITSELF = "is the repository, not a path in it";
40
44
  /** What a path outside the root resolves to. */
41
45
  const OUTSIDE = "does not resolve to a file in the repository";
42
46
 
43
- /** What an absolute path is refused for: it is not the claim's to place. */
44
- const ABSOLUTE = "is absolute; a claim is relative to the repository root";
45
-
46
47
  /**
47
48
  * Resolve `paths` against `root`, or refuse the first one that is not in it.
48
49
  *
@@ -53,9 +54,6 @@ export function resolveClaimPaths(root: string, paths: readonly string[]): Resol
53
54
  const realRoot = realPathOfNearest(root);
54
55
  const canonical: string[] = [];
55
56
  for (const path of paths) {
56
- // `join` would re-root an absolute path under the repository, which is not what
57
- // a caller meant: refuse it rather than claim a directory it never named.
58
- if (isAbsolute(path)) return { error: { path, reason: ABSOLUTE } };
59
57
  const target = realPathOfNearest(join(root, path));
60
58
  const rel = relative(realRoot, target);
61
59
  if (rel === "") return { error: { path, reason: ROOT_ITSELF } };