@gavin-hu/my-pi-agent 0.13.1 → 0.15.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 (237) hide show
  1. package/README.md +66 -41
  2. package/README.zh-CN.md +53 -36
  3. package/extensions/bytefly/README.md +321 -186
  4. package/extensions/bytefly/block/component.ts +7 -15
  5. package/extensions/bytefly/block/register.ts +6 -1
  6. package/extensions/bytefly/doctor/config.ts +4 -1
  7. package/extensions/bytefly/doctor/register.ts +24 -5
  8. package/extensions/bytefly/doctor/tools.ts +4 -0
  9. package/extensions/bytefly/footer/collect/snapshot.ts +6 -1
  10. package/extensions/bytefly/footer/config.ts +10 -6
  11. package/extensions/bytefly/footer/price/config.ts +3 -2
  12. package/extensions/bytefly/footer/price/price.ts +5 -3
  13. package/extensions/bytefly/footer/price/rates.ts +63 -26
  14. package/extensions/bytefly/footer/render/status.ts +3 -3
  15. package/extensions/bytefly/header/component.ts +33 -10
  16. package/extensions/bytefly/header/config.ts +22 -0
  17. package/extensions/bytefly/header/register.ts +2 -1
  18. package/extensions/bytefly/index.ts +6 -6
  19. package/extensions/bytefly/notice/component.ts +8 -3
  20. package/extensions/bytefly/notify/config.ts +7 -12
  21. package/extensions/bytefly/notify/dispatch.ts +13 -5
  22. package/extensions/bytefly/panel/commands.ts +22 -2
  23. package/extensions/bytefly/panel/format.ts +17 -21
  24. package/extensions/bytefly/panel/settings-runtime.ts +86 -22
  25. package/extensions/bytefly/panel/settings.ts +279 -46
  26. package/extensions/bytefly/panel/themes.ts +32 -0
  27. package/extensions/bytefly/panel/tui/editing.ts +77 -0
  28. package/extensions/bytefly/panel/tui/entries.ts +24 -0
  29. package/extensions/bytefly/panel/tui/extensions-view.ts +13 -8
  30. package/extensions/bytefly/panel/tui/frame.ts +121 -4
  31. package/extensions/bytefly/panel/tui/settings-view.ts +98 -13
  32. package/extensions/bytefly/panel/tui.ts +176 -166
  33. package/extensions/bytefly/picker/discovery.ts +1 -0
  34. package/extensions/bytefly/picker/types.ts +2 -0
  35. package/extensions/bytefly/separator/component.ts +17 -6
  36. package/extensions/bytefly/separator/config.ts +15 -0
  37. package/extensions/bytefly/separator/format.ts +60 -6
  38. package/extensions/bytefly/separator/register.ts +68 -9
  39. package/extensions/bytefly/separator/types.ts +8 -0
  40. package/extensions/bytefly/update/cache.ts +58 -19
  41. package/extensions/bytefly/update/check.ts +35 -0
  42. package/extensions/bytefly/update/format.ts +76 -35
  43. package/extensions/bytefly/update/register.ts +144 -55
  44. package/extensions/bytefly/update/state.ts +4 -2
  45. package/extensions/bytefly/update/suppress.ts +66 -0
  46. package/extensions/bytefly/update/types.ts +15 -0
  47. package/extensions/bytefly/working/config.ts +4 -5
  48. package/extensions/bytefly/working/format.ts +24 -7
  49. package/extensions/bytefly/working/register.ts +34 -3
  50. package/extensions/check/README.md +253 -0
  51. package/extensions/check/commands.ts +128 -0
  52. package/extensions/check/config.ts +39 -0
  53. package/extensions/check/detect.ts +269 -0
  54. package/extensions/check/failures.ts +230 -0
  55. package/extensions/check/format.ts +135 -0
  56. package/extensions/check/index.ts +26 -0
  57. package/extensions/check/render.ts +185 -0
  58. package/extensions/check/runner.ts +176 -0
  59. package/extensions/check/runtime.ts +244 -0
  60. package/extensions/check/source.ts +80 -0
  61. package/extensions/check/tools.ts +173 -0
  62. package/extensions/check/tui.ts +195 -0
  63. package/extensions/check/types.ts +99 -0
  64. package/extensions/document/README.md +1 -1
  65. package/extensions/document/extract/pdf.ts +8 -16
  66. package/extensions/document/formats.ts +2 -2
  67. package/extensions/file-browse/README.md +66 -32
  68. package/extensions/file-browse/client.ts +95 -32
  69. package/extensions/file-browse/config.ts +9 -9
  70. package/extensions/file-browse/git.ts +37 -47
  71. package/extensions/file-browse/html.ts +39 -58
  72. package/extensions/file-browse/markdown.ts +110 -92
  73. package/extensions/file-browse/raw-html.ts +478 -0
  74. package/extensions/file-browse/router.ts +32 -70
  75. package/extensions/file-browse/server.ts +18 -56
  76. package/extensions/file-browse/urls.ts +80 -0
  77. package/extensions/goal/README.md +103 -47
  78. package/extensions/goal/clarify-parse.ts +4 -9
  79. package/extensions/goal/clarify.ts +27 -4
  80. package/extensions/goal/commands.ts +24 -11
  81. package/extensions/goal/format.ts +41 -10
  82. package/extensions/goal/index.ts +39 -16
  83. package/extensions/goal/plan-route.ts +51 -0
  84. package/extensions/goal/schema.ts +6 -23
  85. package/extensions/goal/state.ts +39 -57
  86. package/extensions/goal/tools.ts +26 -20
  87. package/extensions/goal/types.ts +11 -1
  88. package/extensions/job/README.md +20 -6
  89. package/extensions/job/format.ts +18 -68
  90. package/extensions/job/index.ts +26 -41
  91. package/extensions/job/log-writer.ts +208 -0
  92. package/extensions/job/logs.ts +3 -2
  93. package/extensions/job/process.ts +6 -99
  94. package/extensions/job/runtime.ts +42 -181
  95. package/extensions/job/schema.ts +1 -4
  96. package/extensions/job/tools.ts +1 -15
  97. package/extensions/job/tui.ts +117 -307
  98. package/extensions/job/ui.ts +7 -33
  99. package/extensions/memory/README.md +3 -1
  100. package/extensions/memory/errors.ts +5 -6
  101. package/extensions/memory/tui.ts +14 -24
  102. package/extensions/plan/README.md +106 -18
  103. package/extensions/plan/commands.ts +9 -5
  104. package/extensions/plan/format.ts +87 -0
  105. package/extensions/plan/index.ts +31 -15
  106. package/extensions/plan/plans.ts +2 -3
  107. package/extensions/plan/policy.ts +0 -6
  108. package/extensions/plan/runtime.ts +4 -2
  109. package/extensions/plan/steps.ts +37 -8
  110. package/extensions/plan/todos.ts +44 -3
  111. package/extensions/plan/tools/enter.ts +94 -0
  112. package/extensions/plan/tools/exit.ts +296 -0
  113. package/extensions/plan/tools/nested.ts +21 -0
  114. package/extensions/plan/tools/steps.ts +136 -0
  115. package/extensions/plan/tools/todo-seed.ts +76 -0
  116. package/extensions/plan/tools/write.ts +127 -0
  117. package/extensions/plan/tools.ts +18 -703
  118. package/extensions/plan/web-client.ts +6 -22
  119. package/extensions/plan/web-page.ts +19 -30
  120. package/extensions/plan/web-router.ts +34 -71
  121. package/extensions/plan/web.ts +13 -40
  122. package/extensions/question/answers.ts +3 -8
  123. package/extensions/question/tui.ts +6 -6
  124. package/extensions/rewind/README.md +95 -22
  125. package/extensions/rewind/checklist.ts +185 -0
  126. package/extensions/rewind/commands.ts +8 -0
  127. package/extensions/rewind/config.ts +3 -11
  128. package/extensions/rewind/format.ts +220 -2
  129. package/extensions/rewind/git.ts +74 -7
  130. package/extensions/rewind/hunk-picker.ts +114 -0
  131. package/extensions/rewind/patch.ts +88 -0
  132. package/extensions/rewind/path-picker.ts +67 -100
  133. package/extensions/rewind/paths.ts +93 -19
  134. package/extensions/rewind/restore.ts +53 -10
  135. package/extensions/rewind/review-tui.ts +320 -0
  136. package/extensions/rewind/review.ts +397 -0
  137. package/extensions/rewind/rewind-tui.ts +4 -3
  138. package/extensions/rewind/runtime.ts +94 -17
  139. package/extensions/subagent/README.md +118 -8
  140. package/extensions/subagent/background-tui.ts +208 -0
  141. package/extensions/subagent/background.ts +470 -0
  142. package/extensions/subagent/commands.ts +80 -0
  143. package/extensions/subagent/config.ts +62 -0
  144. package/extensions/subagent/format.ts +24 -1
  145. package/extensions/subagent/index.ts +116 -1
  146. package/extensions/subagent/orchestrate.ts +16 -3
  147. package/extensions/subagent/render.ts +25 -0
  148. package/extensions/subagent/run.ts +113 -49
  149. package/extensions/subagent/schema.ts +12 -0
  150. package/extensions/subagent/transcripts.ts +202 -0
  151. package/extensions/subagent/types.ts +23 -0
  152. package/extensions/subagent/ui.ts +50 -0
  153. package/extensions/todo/README.md +18 -13
  154. package/extensions/todo/format.ts +3 -2
  155. package/extensions/todo/runtime.ts +3 -2
  156. package/extensions/todo/schema.ts +8 -84
  157. package/extensions/todo/state.ts +5 -46
  158. package/extensions/todo/tools.ts +2 -1
  159. package/extensions/todo/types.ts +6 -15
  160. package/extensions/watch/README.md +16 -6
  161. package/extensions/watch/child.ts +150 -0
  162. package/extensions/watch/format.ts +8 -48
  163. package/extensions/watch/index.ts +42 -58
  164. package/extensions/watch/runtime.ts +49 -128
  165. package/extensions/watch/schema.ts +1 -4
  166. package/extensions/watch/tools.ts +1 -11
  167. package/extensions/watch/tui.ts +103 -273
  168. package/extensions/watch/ui.ts +5 -32
  169. package/extensions/web-access/README.md +1 -1
  170. package/extensions/web-access/fetch/page.ts +1 -1
  171. package/extensions/web-access/fetch/tool.ts +6 -2
  172. package/extensions/web-access/search/search.ts +2 -5
  173. package/extensions/wechat/README.md +3 -1
  174. package/extensions/wechat/client.ts +23 -0
  175. package/extensions/wechat/commands.ts +57 -42
  176. package/extensions/wechat/deliver.ts +5 -9
  177. package/extensions/wechat/errors.ts +3 -6
  178. package/extensions/wechat/inbound.ts +2 -5
  179. package/extensions/wechat/index.ts +2 -8
  180. package/extensions/wechat/outbound.ts +2 -4
  181. package/extensions/wechat/peer-worker.ts +3 -8
  182. package/extensions/wechat/poll.ts +10 -9
  183. package/extensions/wechat/presence.ts +35 -62
  184. package/extensions/wechat/reply-pipeline.ts +3 -1
  185. package/extensions/wechat/status-screen.ts +1 -1
  186. package/extensions/wechat/timer.ts +57 -0
  187. package/extensions/worktree/config.ts +8 -20
  188. package/extensions/worktree/enter.ts +7 -12
  189. package/extensions/worktree/git.ts +38 -69
  190. package/extensions/worktree/index.ts +5 -4
  191. package/extensions/worktree/registry.ts +7 -7
  192. package/extensions/worktree/root-tools.ts +2 -2
  193. package/extensions/worktree/status.ts +3 -2
  194. package/lib/README.md +22 -8
  195. package/lib/config.ts +83 -12
  196. package/lib/errors.ts +12 -0
  197. package/lib/format.ts +56 -0
  198. package/lib/git/read.ts +8 -0
  199. package/lib/goal-state.ts +23 -8
  200. package/lib/http/headers.ts +54 -0
  201. package/lib/http/index.ts +13 -0
  202. package/lib/http/response.ts +46 -0
  203. package/lib/http/router.ts +52 -0
  204. package/lib/http/server.ts +91 -0
  205. package/lib/lines.ts +77 -0
  206. package/lib/pdf.ts +82 -0
  207. package/lib/plan-entry.ts +71 -0
  208. package/lib/plan-state.ts +34 -0
  209. package/lib/report.ts +99 -0
  210. package/lib/requirements.ts +25 -19
  211. package/lib/spawn.ts +170 -0
  212. package/lib/todo-state.ts +110 -23
  213. package/lib/tool-names.ts +7 -0
  214. package/lib/tool-result.ts +33 -0
  215. package/lib/tui/README.md +68 -3
  216. package/lib/tui/chrome.ts +28 -4
  217. package/lib/tui/columns.ts +6 -1
  218. package/lib/tui/confirm.ts +65 -0
  219. package/lib/tui/dock-screen.ts +410 -0
  220. package/lib/tui/glyphs.ts +2 -1
  221. package/lib/tui/indent.ts +1 -1
  222. package/lib/tui/index.ts +2 -0
  223. package/lib/tui/rows.ts +21 -0
  224. package/lib/tui/status-controller.ts +35 -1
  225. package/lib/tui/tool.ts +59 -1
  226. package/lib/web/components.ts +20 -1
  227. package/lib/web/markup.ts +32 -0
  228. package/lib/web/runtime.ts +50 -0
  229. package/package.json +23 -5
  230. package/skills/init/SKILL.md +165 -0
  231. package/skills/init/references/agents-md-quality.md +103 -0
  232. package/skills/init/references/interview-questions.md +81 -0
  233. package/skills/refactor/SKILL.md +141 -0
  234. package/themes/bytefly-dark.json +2 -3
  235. package/themes/bytefly-light.json +2 -3
  236. package/extensions/watch/process.ts +0 -63
  237. package/extensions/web-access/fetch/pdf.ts +0 -45
@@ -7,7 +7,7 @@
7
7
  */
8
8
 
9
9
  import { CONFIG_DIR_NAME, getAgentDir } from "@earendil-works/pi-coding-agent";
10
- import { cleanString, clampInteger, loadConfigSection } from "../../lib/config.ts";
10
+ import { asBoolean, cleanString, clampInteger, loadConfigSection, stringList } from "../../lib/config.ts";
11
11
 
12
12
  export interface WorktreeGuardConfig {
13
13
  /** Block write/edit whose resolved path leaves the active worktree. Default: true */
@@ -70,28 +70,16 @@ export const DEFAULT_CONFIG: WorktreeConfig = {
70
70
  },
71
71
  };
72
72
 
73
- function booleanOr(value: unknown, fallback: boolean): boolean {
74
- return typeof value === "boolean" ? value : fallback;
75
- }
76
-
77
- /** Keep only trimmed, non-empty strings from an untrusted array. */
78
- function stringList(value: unknown, fallback: string[]): string[] {
79
- if (!Array.isArray(value)) return fallback;
80
- return value
81
- .filter((item): item is string => typeof item === "string" && item.trim().length > 0)
82
- .map((item) => item.trim());
83
- }
84
-
85
73
  function normalizeGuard(raw: unknown, base: WorktreeGuardConfig): WorktreeGuardConfig {
86
74
  if (!raw || typeof raw !== "object" || Array.isArray(raw)) return base;
87
75
  const value = raw as Record<string, unknown>;
88
76
  return {
89
- blockFileEscapes: booleanOr(value.blockFileEscapes, base.blockFileEscapes),
90
- blockReadEscapes: booleanOr(value.blockReadEscapes, base.blockReadEscapes),
91
- blockGitRedirects: booleanOr(value.blockGitRedirects, base.blockGitRedirects),
92
- blockSymlinkEscapes: booleanOr(value.blockSymlinkEscapes, base.blockSymlinkEscapes),
93
- blockUnparsableCommands: booleanOr(value.blockUnparsableCommands, base.blockUnparsableCommands),
94
- blockPowershell: booleanOr(value.blockPowershell, base.blockPowershell),
77
+ blockFileEscapes: asBoolean(value.blockFileEscapes, base.blockFileEscapes),
78
+ blockReadEscapes: asBoolean(value.blockReadEscapes, base.blockReadEscapes),
79
+ blockGitRedirects: asBoolean(value.blockGitRedirects, base.blockGitRedirects),
80
+ blockSymlinkEscapes: asBoolean(value.blockSymlinkEscapes, base.blockSymlinkEscapes),
81
+ blockUnparsableCommands: asBoolean(value.blockUnparsableCommands, base.blockUnparsableCommands),
82
+ blockPowershell: asBoolean(value.blockPowershell, base.blockPowershell),
95
83
  };
96
84
  }
97
85
 
@@ -102,7 +90,7 @@ export function normalizeConfig(raw: Record<string, unknown> | undefined, base:
102
90
  dir: cleanString(raw.dir, base.dir),
103
91
  baseRef: raw.baseRef === "head" || raw.baseRef === "fresh" ? raw.baseRef : base.baseRef,
104
92
  branchPrefix: cleanString(raw.branchPrefix, base.branchPrefix),
105
- fetchRemote: booleanOr(raw.fetchRemote, base.fetchRemote),
93
+ fetchRemote: asBoolean(raw.fetchRemote, base.fetchRemote),
106
94
  fetchTimeoutMs: clampInteger(raw.fetchTimeoutMs, base.fetchTimeoutMs, 0, 600_000),
107
95
  onExit: raw.onExit === "ask" || raw.onExit === "keep" || raw.onExit === "remove" ? raw.onExit : base.onExit,
108
96
  pruneAfterDays: clampInteger(raw.pruneAfterDays, base.pruneAfterDays, 0, 3650),
@@ -6,13 +6,13 @@
6
6
  import { appendFileSync, existsSync, readFileSync } from "node:fs";
7
7
  import { basename, join, resolve } from "node:path";
8
8
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
9
- import { isInside } from "../../lib/path.ts";
9
+ import { createExecRunner, revParse } from "../../lib/git/index.ts";
10
+ import { isInside, realPathOfNearest } from "../../lib/path.ts";
10
11
  import { GLYPHS } from "../../lib/tui/glyphs.ts";
11
12
  import { loadConfig, type WorktreeConfig } from "./config.ts";
12
13
  import { WorktreeCancelledError } from "./errors.ts";
13
14
  import {
14
15
  branchExists,
15
- canonicalize,
16
16
  commitsAhead,
17
17
  currentBranch,
18
18
  fetchPrRef,
@@ -95,12 +95,7 @@ export interface EnterOptions {
95
95
  }
96
96
 
97
97
  async function resolveCommit(pi: ExtensionAPI, dir: string, ref: string): Promise<string | undefined> {
98
- try {
99
- const result = await pi.exec("git", ["rev-parse", ref], { cwd: dir });
100
- return result.code === 0 ? result.stdout.trim() : undefined;
101
- } catch {
102
- return undefined;
103
- }
98
+ return revParse(createExecRunner(pi), dir, ref);
104
99
  }
105
100
 
106
101
  /** Create or enter a worktree and rebind the session's effective root. */
@@ -139,11 +134,11 @@ export async function enterWorktree(
139
134
  let createdByUs = true;
140
135
 
141
136
  if (pathArg) {
142
- target = canonicalize(resolve(ctx.cwd, pathArg));
143
- if (target === canonicalize(root)) {
137
+ target = realPathOfNearest(resolve(ctx.cwd, pathArg));
138
+ if (target === realPathOfNearest(root)) {
144
139
  throw new Error("The main checkout is not a worktree; enter a separate worktree path.");
145
140
  }
146
- const managedRoot = canonicalize(resolve(root, config.dir));
141
+ const managedRoot = realPathOfNearest(resolve(root, config.dir));
147
142
  if (!isInside(managedRoot, target)) {
148
143
  if (!ctx.hasUI) throw new Error(`Refusing to enter ${target} outside ${managedRoot} without confirmation.`);
149
144
  const ok = await ctx.ui.confirm(
@@ -163,7 +158,7 @@ export async function enterWorktree(
163
158
  target = worktreePath(root, config, name);
164
159
  // `name` becomes both a directory and a branch, so an absolute name or one
165
160
  // with `..` can otherwise place the worktree outside the managed root.
166
- const managedRoot = canonicalize(resolve(root, config.dir));
161
+ const managedRoot = realPathOfNearest(resolve(root, config.dir));
167
162
  if (!isInside(managedRoot, target)) {
168
163
  throw new Error(
169
164
  `Refusing to create a worktree outside ${managedRoot}: ${target}. ` +
@@ -5,19 +5,26 @@
5
5
  * configuration (and abort/timeout handling) as the rest of Pi.
6
6
  */
7
7
 
8
- import { existsSync, realpathSync, statSync } from "node:fs";
9
- import { basename, dirname, join, resolve } from "node:path";
8
+ import { existsSync, statSync } from "node:fs";
9
+ import { resolve } from "node:path";
10
10
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
11
11
  import {
12
12
  createExecRunner,
13
13
  currentBranch as sharedCurrentBranch,
14
+ gitCommonDir,
14
15
  hasCommits as sharedHasCommits,
15
16
  repoRootFor,
17
+ revParse,
18
+ type RunGitOptions,
16
19
  } from "../../lib/git/index.ts";
20
+ import { isInside, realPathOfNearest } from "../../lib/path.ts";
21
+ import { isPidAlive } from "../../lib/spawn.ts";
17
22
  import type { WorktreeConfig } from "./config.ts";
18
23
 
19
- async function git(pi: ExtensionAPI, args: string[], cwd: string, timeout?: number) {
20
- return createExecRunner(pi)(args, { cwd, ...(timeout ? { timeoutMs: timeout } : {}) });
24
+ type GitCallOptions = Omit<RunGitOptions, "cwd">;
25
+
26
+ async function git(pi: ExtensionAPI, args: string[], cwd: string, options: GitCallOptions = {}) {
27
+ return createExecRunner(pi)(args, { cwd, ...options });
21
28
  }
22
29
 
23
30
  type GitOutcome = { ok: true; stdout: string } | { ok: false; code: number; error: string };
@@ -30,46 +37,15 @@ async function gitOutcome(pi: ExtensionAPI, args: string[], cwd: string): Promis
30
37
  }
31
38
 
32
39
  /**
33
- * Run a network git operation with terminal prompting disabled, so a fetch that
34
- * would ask for credentials fails fast instead of hanging until its timeout.
35
- * The environment is restored afterwards.
40
+ * A network fetch: terminal prompting is disabled through the runner's env seam,
41
+ * so a credentials prompt fails fast instead of hanging until its timeout.
36
42
  */
37
- export async function withGitNoPrompt<T>(fn: () => Promise<T>): Promise<T> {
38
- const previous = process.env.GIT_TERMINAL_PROMPT;
39
- process.env.GIT_TERMINAL_PROMPT = "0";
40
- try {
41
- return await fn();
42
- } finally {
43
- if (previous === undefined) delete process.env.GIT_TERMINAL_PROMPT;
44
- else process.env.GIT_TERMINAL_PROMPT = previous;
45
- }
46
- }
47
-
48
- /**
49
- * Canonicalize a path, resolving symlinks. Handles paths that do not exist yet
50
- * by canonicalizing the nearest existing ancestor, so `/tmp/x` and
51
- * `/private/tmp/x` compare equal on macOS.
52
- */
53
- export function canonicalize(path: string): string {
54
- const absolute = resolve(path);
55
- try {
56
- // `.native` resolves Windows 8.3 short names (e.g. HSPCAD~1) to their
57
- // long form, so a temp path from os.tmpdir() compares equal to git's
58
- // expanded output. It is equivalent to realpathSync on other platforms.
59
- return realpathSync.native(absolute);
60
- } catch {
61
- try {
62
- return join(realpathSync.native(dirname(absolute)), basename(absolute));
63
- } catch {
64
- return absolute;
65
- }
66
- }
67
- }
43
+ const NO_TERMINAL_PROMPT: GitCallOptions = { env: { GIT_TERMINAL_PROMPT: "0" } };
68
44
 
69
45
  /** Absolute path of the repository root containing `cwd`, or undefined when not a git repo. */
70
46
  export async function repoRoot(pi: ExtensionAPI, cwd: string): Promise<string | undefined> {
71
47
  const root = await repoRootFor(pi, cwd);
72
- return root ? canonicalize(root) : undefined;
48
+ return root ? realPathOfNearest(root) : undefined;
73
49
  }
74
50
 
75
51
  /** Whether the repository has at least one commit (a worktree needs a commit to branch from). */
@@ -101,11 +77,13 @@ export async function resolveBaseRef(
101
77
  if (branch) {
102
78
  if (config.fetchRemote && (await fetchStale(pi, repoRoot))) {
103
79
  // Never block on credentials: treat an error or timeout as "keep using the cache".
104
- await withGitNoPrompt(() => git(pi, ["fetch", "--no-tags", "origin", branch], repoRoot, config.fetchTimeoutMs));
80
+ await git(pi, ["fetch", "--no-tags", "origin", branch], repoRoot, {
81
+ ...NO_TERMINAL_PROMPT,
82
+ timeoutMs: config.fetchTimeoutMs,
83
+ });
105
84
  }
106
85
  const remoteRef = `origin/${branch}`;
107
- const exists = await git(pi, ["rev-parse", "--verify", "--quiet", remoteRef], repoRoot);
108
- if (exists.code === 0 && exists.stdout.trim()) return { ref: remoteRef, mode: "fresh" };
86
+ if (await refExists(pi, repoRoot, remoteRef)) return { ref: remoteRef, mode: "fresh" };
109
87
  }
110
88
  return { ref: "HEAD", mode: "head" };
111
89
  }
@@ -121,9 +99,9 @@ const FETCH_MAX_AGE_MS = 24 * 60 * 60 * 1000;
121
99
 
122
100
  /** Whether the repository has not been fetched in the last 24 hours. */
123
101
  export async function fetchStale(pi: ExtensionAPI, repoRoot: string, maxAgeMs = FETCH_MAX_AGE_MS): Promise<boolean> {
124
- const common = await git(pi, ["rev-parse", "--path-format=absolute", "--git-common-dir"], repoRoot);
125
- if (common.code !== 0) return true;
126
- const fetchHead = resolve(common.stdout.trim(), "FETCH_HEAD");
102
+ const common = await gitCommonDir(createExecRunner(pi), repoRoot);
103
+ if (common === undefined) return true;
104
+ const fetchHead = resolve(common, "FETCH_HEAD");
127
105
  try {
128
106
  return Date.now() - statSync(fetchHead).mtimeMs > maxAgeMs;
129
107
  } catch {
@@ -170,7 +148,10 @@ export async function fetchPrRef(
170
148
  ? [`merge-requests/${pr.number}/head`]
171
149
  : [`pull/${pr.number}/head`, `merge-requests/${pr.number}/head`];
172
150
  for (const spec of specs) {
173
- const result = await withGitNoPrompt(() => git(pi, ["fetch", "--no-tags", "origin", spec], repoRoot, timeoutMs));
151
+ const result = await git(pi, ["fetch", "--no-tags", "origin", spec], repoRoot, {
152
+ ...NO_TERMINAL_PROMPT,
153
+ timeoutMs,
154
+ });
174
155
  if (result.code === 0) return { ok: true };
175
156
  }
176
157
  return { ok: false, error: `Failed to fetch PR/MR #${pr.number}` };
@@ -178,16 +159,16 @@ export async function fetchPrRef(
178
159
 
179
160
  /** Path of a managed worktree directory from its name. */
180
161
  export function worktreePath(repoRoot: string, config: WorktreeConfig, name: string): string {
181
- return canonicalize(resolve(repoRoot, config.dir, name));
162
+ return realPathOfNearest(resolve(repoRoot, config.dir, name));
182
163
  }
183
164
 
184
165
  export async function worktreeExists(pi: ExtensionAPI, dir: string): Promise<boolean> {
185
- const want = canonicalize(dir);
166
+ const want = realPathOfNearest(dir);
186
167
  const result = await git(pi, ["worktree", "list", "--porcelain"], dir);
187
168
  if (result.code !== 0) return false;
188
169
  return result.stdout
189
170
  .split("\n")
190
- .some((line) => line.startsWith("worktree ") && canonicalize(line.slice("worktree ".length).trim()) === want);
171
+ .some((line) => line.startsWith("worktree ") && realPathOfNearest(line.slice("worktree ".length).trim()) === want);
191
172
  }
192
173
 
193
174
  /** Whether a local branch exists. */
@@ -206,8 +187,7 @@ export async function commitsAheadOf(pi: ExtensionAPI, dir: string, branch: stri
206
187
 
207
188
  /** Whether `ref` resolves here: a branch, a remote-tracking ref, or a tag. */
208
189
  export async function refExists(pi: ExtensionAPI, dir: string, ref: string): Promise<boolean> {
209
- const result = await git(pi, ["rev-parse", "--verify", "--quiet", ref], dir);
210
- return result.code === 0 && result.stdout.trim() !== "";
190
+ return (await revParse(createExecRunner(pi), dir, ref)) !== undefined;
211
191
  }
212
192
 
213
193
  export async function branchExists(pi: ExtensionAPI, repoRoot: string, branch: string): Promise<boolean> {
@@ -317,15 +297,15 @@ export async function checkCheckout(pi: ExtensionAPI, dir: string, mainRoot: str
317
297
  if (!top) {
318
298
  return { ok: false, reason: "unsafe", detail: `${dir} is not a git worktree.` };
319
299
  }
320
- if (canonicalize(top) === canonicalize(mainRoot)) {
300
+ if (realPathOfNearest(top) === realPathOfNearest(mainRoot)) {
321
301
  return {
322
302
  ok: false,
323
303
  reason: "unsafe",
324
304
  detail: `${dir} resolves its working tree to the main checkout ${mainRoot}.`,
325
305
  };
326
306
  }
327
- const commonDir = await git(pi, ["rev-parse", "--path-format=absolute", "--git-common-dir"], dir);
328
- if (commonDir.code !== 0) {
307
+ const commonDir = await gitCommonDir(createExecRunner(pi), dir);
308
+ if (commonDir === undefined) {
329
309
  return { ok: false, reason: "unverified", detail: `Could not read git metadata for ${dir}.` };
330
310
  }
331
311
  return { ok: true };
@@ -390,13 +370,13 @@ export async function listManagedWorktrees(
390
370
  ): Promise<ManagedWorktree[]> {
391
371
  const result = await git(pi, ["worktree", "list", "--porcelain"], repoRoot);
392
372
  if (result.code !== 0) return [];
393
- const managedRoot = canonicalize(resolve(repoRoot, config.dir));
373
+ const managedRoot = realPathOfNearest(resolve(repoRoot, config.dir));
394
374
  const entries: ManagedWorktree[] = [];
395
375
  let current: ManagedWorktree = { path: "" };
396
376
  const flush = () => {
397
377
  if (!current.path) return;
398
- const path = canonicalize(current.path);
399
- if (path === managedRoot || path.startsWith(`${managedRoot}/`) || path.startsWith(`${managedRoot}\\`)) {
378
+ const path = realPathOfNearest(current.path);
379
+ if (isInside(managedRoot, path)) {
400
380
  entries.push({ ...current, path });
401
381
  }
402
382
  current = { path: "" };
@@ -433,17 +413,6 @@ export async function unlockWorktree(pi: ExtensionAPI, repoRoot: string, dir: st
433
413
  await git(pi, ["worktree", "unlock", dir], repoRoot);
434
414
  }
435
415
 
436
- /** Whether a process id is alive on this host. */
437
- export function isProcessAlive(pid: number): boolean {
438
- if (!Number.isFinite(pid) || pid <= 0) return false;
439
- try {
440
- process.kill(pid, 0);
441
- return true;
442
- } catch (error) {
443
- return (error as NodeJS.ErrnoException).code === "EPERM";
444
- }
445
- }
446
-
447
416
  /** Owning pid from a `pi:<pid>:<session>` lock reason, when present. */
448
417
  export function lockOwnerPid(reason: string): number | undefined {
449
418
  const match = reason.match(/^pi:(\d+):/);
@@ -453,5 +422,5 @@ export function lockOwnerPid(reason: string): number | undefined {
453
422
  /** Whether a lock reason names a `pi` process that is no longer alive. */
454
423
  export function isStaleLock(reason: string): boolean {
455
424
  const pid = lockOwnerPid(reason);
456
- return pid !== undefined && !isProcessAlive(pid);
425
+ return pid !== undefined && !isPidAlive(pid);
457
426
  }
@@ -24,8 +24,9 @@ import { existsSync } from "node:fs";
24
24
  import { fileURLToPath } from "node:url";
25
25
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
26
26
  import { isExtensionEnabled } from "../../lib/env.ts";
27
+ import { realPathOfNearest } from "../../lib/path.ts";
27
28
  import { GLYPHS } from "../../lib/tui/glyphs.ts";
28
- import { canonicalize, checkCheckout, repoRoot, unlockWorktree } from "./git.ts";
29
+ import { checkCheckout, repoRoot, unlockWorktree } from "./git.ts";
29
30
  import { analyzeBashCommand, guardFileTool, guardPowershellTool, isUnverifiable } from "./guard.ts";
30
31
  import {
31
32
  enterWorktree,
@@ -100,9 +101,9 @@ export function registerWorktree(pi: ExtensionAPI, { entryPath, createBuiltin }:
100
101
  // factory inside the parent, which must restore its own recorded state.
101
102
  const envRoot = event.reason === "startup" ? process.env[ENV_ROOT] : undefined;
102
103
  if (envRoot) {
103
- const borrowedPath = canonicalize(envRoot);
104
+ const borrowedPath = realPathOfNearest(envRoot);
104
105
  if (existsSync(borrowedPath) && (await repoRoot(pi, borrowedPath))) {
105
- const borrowedMain = process.env[ENV_MAIN] ? canonicalize(process.env[ENV_MAIN]) : borrowedPath;
106
+ const borrowedMain = process.env[ENV_MAIN] ? realPathOfNearest(process.env[ENV_MAIN]) : borrowedPath;
106
107
  const borrowed: WorktreeState = {
107
108
  active: true,
108
109
  borrowed: true,
@@ -229,5 +230,5 @@ export function registerWorktree(pi: ExtensionAPI, { entryPath, createBuiltin }:
229
230
  /** Pi extension entrypoint. */
230
231
  export default function worktreeExtension(pi: ExtensionAPI): void {
231
232
  if (!isExtensionEnabled("worktree")) return;
232
- registerWorktree(pi, { entryPath: canonicalize(fileURLToPath(import.meta.url)) });
233
+ registerWorktree(pi, { entryPath: realPathOfNearest(fileURLToPath(import.meta.url)) });
233
234
  }
@@ -13,9 +13,9 @@
13
13
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
14
14
  import { dirname, join } from "node:path";
15
15
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
16
+ import { realPathOfNearest } from "../../lib/path.ts";
16
17
  import type { WorktreeConfig } from "./config.ts";
17
18
  import {
18
- canonicalize,
19
19
  commitsAhead,
20
20
  commitsBehind,
21
21
  isBranchMerged,
@@ -102,7 +102,7 @@ function saveRegistry(repoRoot: string, config: WorktreeConfig, registry: Worktr
102
102
  /** Insert or replace a record, preserving an existing `createdAt`. */
103
103
  export function upsertRecord(repoRoot: string, config: WorktreeConfig, record: WorktreeRecord): void {
104
104
  const registry = loadRegistry(repoRoot, config);
105
- const path = canonicalize(record.path);
105
+ const path = realPathOfNearest(record.path);
106
106
  const existing = registry.worktrees.find((candidate) => candidate.path === path);
107
107
  const next: WorktreeRecord = existing
108
108
  ? { ...record, path, createdAt: existing.createdAt || record.createdAt }
@@ -116,7 +116,7 @@ export function upsertRecord(repoRoot: string, config: WorktreeConfig, record: W
116
116
  /** Mark a worktree as just used. No-op when it is not registered. */
117
117
  export function touchRecord(repoRoot: string, config: WorktreeConfig, path: string, at = Date.now()): void {
118
118
  const registry = loadRegistry(repoRoot, config);
119
- const wanted = canonicalize(path);
119
+ const wanted = realPathOfNearest(path);
120
120
  const record = registry.worktrees.find((candidate) => candidate.path === wanted);
121
121
  if (!record) return;
122
122
  record.lastUsedAt = at;
@@ -125,7 +125,7 @@ export function touchRecord(repoRoot: string, config: WorktreeConfig, path: stri
125
125
 
126
126
  export function removeRecord(repoRoot: string, config: WorktreeConfig, path: string): void {
127
127
  const registry = loadRegistry(repoRoot, config);
128
- const wanted = canonicalize(path);
128
+ const wanted = realPathOfNearest(path);
129
129
  const next = registry.worktrees.filter((candidate) => candidate.path !== wanted);
130
130
  if (next.length === registry.worktrees.length) return;
131
131
  registry.worktrees = next;
@@ -145,7 +145,7 @@ export function reconcileRegistry(
145
145
  options: { persist?: boolean } = {},
146
146
  ): { registry: WorktreeRegistry; changed: boolean } {
147
147
  const registry = loadRegistry(repoRoot, config);
148
- const live = new Set(managed.map((entry) => canonicalize(entry.path)));
148
+ const live = new Set(managed.map((entry) => realPathOfNearest(entry.path)));
149
149
  const byPath = new Map(registry.worktrees.map((record) => [record.path, record] as const));
150
150
 
151
151
  let changed = false;
@@ -158,7 +158,7 @@ export function reconcileRegistry(
158
158
  }
159
159
  }
160
160
  for (const entry of managed) {
161
- const path = canonicalize(entry.path);
161
+ const path = realPathOfNearest(entry.path);
162
162
  if (byPath.has(path)) continue;
163
163
  next.push({
164
164
  path,
@@ -189,7 +189,7 @@ export async function statusOf(
189
189
  entry: ManagedWorktree,
190
190
  options: StatusOptions = {},
191
191
  ): Promise<CheckoutStatus> {
192
- const path = canonicalize(entry.path);
192
+ const path = realPathOfNearest(entry.path);
193
193
  const locked = entry.locked !== undefined;
194
194
  const staleLock = locked && entry.locked ? isStaleLock(entry.locked) : false;
195
195
  const base: CheckoutStatus = {
@@ -30,7 +30,7 @@ import {
30
30
  createReadToolDefinition,
31
31
  createWriteToolDefinition,
32
32
  } from "@earendil-works/pi-coding-agent";
33
- import { canonicalize } from "./git.ts";
33
+ import { realPathOfNearest } from "../../lib/path.ts";
34
34
 
35
35
  export const ROOT_TOOL_NAMES = ["read", "write", "edit", "bash", "grep", "find", "ls"] as const;
36
36
  export type RootToolName = (typeof ROOT_TOOL_NAMES)[number];
@@ -155,7 +155,7 @@ export function findInactiveOverrides(pi: ExtensionAPI, self: string | undefined
155
155
  const info = pi.getAllTools().find((entry) => entry.name === name);
156
156
  if (!info) continue;
157
157
  const path = info.sourceInfo?.path;
158
- const isOurs = path && !path.startsWith("builtin:") && (!self || canonicalize(path) === self);
158
+ const isOurs = path && !path.startsWith("builtin:") && (!self || realPathOfNearest(path) === self);
159
159
  if (!isOurs) inactive.push(name);
160
160
  }
161
161
  return inactive;
@@ -5,9 +5,10 @@
5
5
 
6
6
  import { basename } from "node:path";
7
7
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
8
+ import { realPathOfNearest } from "../../lib/path.ts";
8
9
  import { loadConfig } from "./config.ts";
9
10
  import { gapLines, isolationGaps } from "./gaps.ts";
10
- import { canonicalize, type CheckoutCheck, defaultBranch, listManagedWorktrees, repoRoot } from "./git.ts";
11
+ import { type CheckoutCheck, defaultBranch, listManagedWorktrees, repoRoot } from "./git.ts";
11
12
  import { reconcileRegistry, statusOf, type CheckoutStatus } from "./registry.ts";
12
13
  import { configFor, getActive, getInactiveOverrides, getUnverifiableCommands } from "./runtime.ts";
13
14
  import type { WorktreeState } from "./state.ts";
@@ -116,7 +117,7 @@ export async function worktreeStatus(pi: ExtensionAPI, ctx: ExtensionContext): P
116
117
  const baseRef = baseBranch ? `origin/${baseBranch}` : undefined;
117
118
  lines.push("", `Managed worktrees (${managed.length}):`);
118
119
  for (const entry of managed) {
119
- const record = registry.worktrees.find((candidate) => candidate.path === canonicalize(entry.path));
120
+ const record = registry.worktrees.find((candidate) => candidate.path === realPathOfNearest(entry.path));
120
121
  const status = await statusOf(pi, entry, { baseRef, record });
121
122
  const marks = statusMarks(status, current?.path === entry.path);
122
123
  lines.push(
package/lib/README.md CHANGED
@@ -18,26 +18,35 @@ belongs in that extension instead — see `extensions/web-access/http.ts` and
18
18
  | Module | Responsibility |
19
19
  |---|---|
20
20
  | [`browser.ts`](./browser.ts) | Cross-platform default-browser opener shared by `file-browse` and `plan`: pure `browserCommand(url, platform)` plus best-effort `openInBrowser(url, { platform, spawn })` with an injectable spawn. |
21
- | [`config.ts`](./config.ts) | Config loading, two ways: `loadConfigFile` for a whole file, and the shared `bytefly.json` (`SHARED_CONFIG_FILE`) via `configSection` / `configValueAt` / `loadConfigSection` (global, then project), plus the atomic `writeConfigFile` / `writeConfigSection` / `writeConfigValue` / `writeConfigValueAt` (the last two edit one leaf, flat or nested, preserving siblings and metadata). Includes `clampInteger` / `clampNumber` / `cleanString` / `asRecord` / `readJson` / `readJsonResult`. |
21
+ | [`config.ts`](./config.ts) | Config loading, two ways: `loadConfigFile` for a whole file, and the shared `bytefly.json` (`SHARED_CONFIG_FILE`) via `configSection` / `configValueAt` / `loadConfigSection` (global, then project), plus the atomic `writeConfigFile` / `writeConfigSection` / `writeConfigValue` / `writeConfigValueAt` / `writeConfigValuesAt` / `unsetConfigValueAt` (the leaf writers preserve siblings and metadata, `writeConfigValuesAt` applies several leaves in one document pass, and the last removes one and prunes what it emptied). Includes `clampInteger` / `clampNumber` / `cleanString` / `optionalString` / `asBoolean` / `stringList` / `asRecord` / `readJson` / `readJsonResult`. |
22
22
  | [`env.ts`](./env.ts) | Env-var contracts shared across extensions: the worktree environment (`ENV_ROOT`, `worktreeRoot`, `resolveEffectiveCwd`) used by worktree, file-browse, job, and rewind, plus `isExtensionEnabled` / `ENV_DISABLED_EXTENSIONS` for the package-wide disable list. |
23
23
  | [`escape.ts`](./escape.ts) | `escapeHtml`: the single HTML text/attribute escape used by the browse page renderers and the Markdown and syntax highlighters. |
24
24
  | [`events.ts`](./events.ts) | Inter-extension `pi.events` channels and payloads: `JOB_FINISHED_CHANNEL` / `JobFinishedEvent` (published by `job`, consumed by bytefly's notify capability), `SUBAGENT_FINISHED_CHANNEL` / `SubagentFinishedEvent` (published by `subagent`, consumed by bytefly's footer roll-up), and `WEB_SEARCH_PROVIDER_CHANNEL` / `WebSearchProviderEvent` (published by `web-access` at session start, consumed by bytefly's doctor so it can report the resolved provider without importing the extension — the key itself is never in the payload). |
25
25
  | [`notice.ts`](./notice.ts) | The shared transcript notice: `NOTICE_TYPE`, the `NoticeDetails` / `NoticeItem` shape, `noticeMessage` (the `display: true` message both send paths accept), `isNotice`, and `pruneStaleNotices` — posted by `job` and `watch`, rendered by bytefly. |
26
- | [`format.ts`](./format.ts) | Pure text/number formatting shared by renderers: `formatTokens`, `formatCount`, `formatDuration` (`450ms`, `3.2s`, `1m05s`, `2h03m`), `stripControlChars`, `sanitize`, `sanitizeLine` — the boundary for untrusted, single-line text — and `shortenPath`. |
26
+ | [`report.ts`](./report.ts) | The pending-report coordinator: `createPendingReporter` with `wake` / `pending` / `markReported` / `buildNotice` / `source` / `onWoke`, returning the lifecycle handlers that report at the next turn, raise at most one triggered turn per wake window, and prune only their own notices. Shared by `job` and `watch`. |
27
+ | [`errors.ts`](./errors.ts) | `messageOf`: the one boundary that turns an unknown thrown value into a user-facing line (an `Error` yields its message, anything else is stringified). |
28
+ | [`format.ts`](./format.ts) | Pure text/number formatting shared by renderers: `formatTokens`, `formatCount`, `formatDuration` (`450ms`, `3.2s`, `1m05s`, `2h03m`), `boundedTail` (the end of a joined tail with a `truncated` flag), `stripControlChars`, `sanitize`, `sanitizeLine` — the boundary for untrusted, single-line text — `lineOf` (a value read as one sanitized line), `sanitizeUntrustedLine` / `sanitizeUntrustedText` with `MAX_OUTPUT_LINE` for **program output** (OSC/CSI escapes removed whole, `\r` progress resolved, clipped), and `shortenPath`. |
29
+ | [`lines.ts`](./lines.ts) | `LineSplitter`, the byte-safe splitter for a stream of program output: buffers bytes and decodes only at a newline, so a UTF-8 character split across two reads survives, and caps an over-long line instead of dropping it. Shared by `job`, `watch`, `check`, and `subagent`. |
27
30
  | [`interaction.ts`](./interaction.ts) | Turn-affinity prompt contract: the channel a remote transport marks on the UI context it owns (`INTERACTION_TURN_KEY`), the `InteractionChannel` interface (`request` / `post` / `postFile`), and `remoteChannel` / `isRemoteTurn` / `supportsCustomUI` / `askHuman` (with an optional file, fallback text, and digest preface) plus the shared “Other” label. |
28
- | [`git/`](./git/) | Shared git plumbing. [`git/runner.ts`](./git/runner.ts) is the `RunGit` seam (`createExecRunner` with timeout/env handling, `runGitOrThrow`, `GitError`); [`git/read.ts`](./git/read.ts) holds the read helpers (`repoRoot`, `repoRootFor`, `gitDir`, `revParse`, `hasCommits`, `currentBranch`); [`git/index.ts`](./git/index.ts) is the barrel. |
31
+ | [`git/`](./git/) | Shared git plumbing. [`git/runner.ts`](./git/runner.ts) is the `RunGit` seam (`createExecRunner` with timeout/env handling, `runGitOrThrow`, `GitError`); [`git/read.ts`](./git/read.ts) holds the read helpers (`repoRoot`, `repoRootFor`, `gitDir`, `gitCommonDir`, `revParse`, `hasCommits`, `currentBranch`); [`git/index.ts`](./git/index.ts) is the barrel. |
29
32
  | [`highlight.ts`](./highlight.ts) | First-party, escape-first syntax tokenizer (`detectLanguage`, `fenceLanguage`, `highlightLines`, `HighlightLanguage`, `TokenClass`) for js/ts, json, css, html, shell, python, java, yaml, and sql; shared by `file-browse` and plan's code blocks. |
30
- | [`tui/`](./tui/) | The Bytefly terminal design system shared by every interactive surface, whose conventions are its own doc, [`tui/README.md`](./tui/README.md). Modules: [`tui/tokens.ts`](./tui/tokens.ts) (semantic role -> theme color, `paint`), [`tui/glyphs.ts`](./tui/glyphs.ts) (`GLYPHS`, `SEPARATORS`, `STATUS_KEYS`, `EXPAND_KEYBINDING` with `expandKey` / `expandHint`, `GLYPH_GAP` — the vocabulary both sides of a status must agree on), [`tui/chrome.ts`](./tui/chrome.ts) (`screenHint`, `dockListFrame` (the docked-list frame), `viewportRows`, `FULL_SCREEN_OVERLAY`, `reuseText`, `editorTheme`), [`tui/rules.ts`](./tui/rules.ts) (the rule glyph `RULE_GLYPH`, and `plainRule` / `titleRule` / `centeredRule`), [`tui/indent.ts`](./tui/indent.ts) (the transcript block indent: `BODY_INDENT`, an optional rail prefix that a wrapped call line is kept out of, and `bodyLines` / `indentBlockLines` / `indentedLines`), [`tui/rows.ts`](./tui/rows.ts) (`listRow`, `checkbox`, `chip`, `keyValueRow`, `emptyState`), [`tui/columns.ts`](./tui/columns.ts) (`splitRow`, `fitCells`, `flowCells`, `padTo`, `clip`, `clipMiddle`), [`tui/gauge.ts`](./tui/gauge.ts) (`computeGauge`, `gaugeRole`, `gaugeBar`), [`tui/line-pane.ts`](./tui/line-pane.ts) (`LinePane`, the scrollable log/match pane), [`tui/status-controller.ts`](./tui/status-controller.ts) (`createStatusController`, the footer chips `job` and `watch` share), [`tui/tool.ts`](./tui/tool.ts) (`toolCallTitle`, the width-safe `ToolCallText` / `reuseCallText`, sanitized `errorLine` / `rejectionLine`, `moreHint`, `syncElapsedTimer`, `elapsedSuffix` — the tool-transcript kit), [`tui/render-cache.ts`](./tui/render-cache.ts) (`RenderCache`: the per-instance line memo a component that derives its lines from another component's output renders through, so a repeat frame is one comparison), and [`tui/cursor.ts`](./tui/cursor.ts) (scroll and navigation: `fitRows`, `keepVisible`, `clampScroll`, `navIntent`, `navigateList`, `navigateScroll`, `wheelDelta`, `scrollList`, `isCloseKey`, `selectionMarker`, `formatRange`, and the `ListCursor` (by index) and `IdCursor` (by stable id) state). |
31
- | [`path.ts`](./path.ts) | `isInside` (string containment) plus `realPathOfNearest` / `isInsideReal` (symlink-aware): the containment predicates the file-browse, worktree, and doc guards share. |
33
+ | [`http/`](./http/) | The loopback HTTP layer shared by `file-browse` and `plan`: [`http/headers.ts`](./http/headers.ts) (the page policy `PAGE_CSP` and the `pageHeaders` / `scriptHeaders` / `jsonHeaders` sets), [`http/response.ts`](./http/response.ts) (`ServeResponse` — a buffered body, a `filePath` stream, or an SSE `stream` — plus `htmlResponse` / `jsonResponse` / `jsonError` / `textResponse`), [`http/router.ts`](./http/router.ts) (`parseRequestUrl`, and `isLocalHostHeader` with the `required` mode each server chooses), [`http/server.ts`](./http/server.ts) (`bindLoopback` on `127.0.0.1` with the accept-error listener, `respond` for HEAD / streamed file / body, and the idempotent `createCloser`), and the [`http/index.ts`](./http/index.ts) barrel. |
34
+ | [`tui/`](./tui/) | The Bytefly terminal design system shared by every interactive surface, whose conventions are its own doc, [`tui/README.md`](./tui/README.md). Modules: [`tui/tokens.ts`](./tui/tokens.ts) (semantic role -> theme color, `paint`), [`tui/glyphs.ts`](./tui/glyphs.ts) (`GLYPHS`, `SEPARATORS`, `STATUS_KEYS`, `EXPAND_KEYBINDING` with `expandKey` / `expandHint`, `GLYPH_GAP` — the vocabulary both sides of a status must agree on), [`tui/chrome.ts`](./tui/chrome.ts) (`screenHint`, `dockListFrame` (the docked-list frame) with `detailBlockRows` (the rows its detail block costs), `viewportRows`, `FULL_SCREEN_OVERLAY`, `reuseText`, `editorTheme`), [`tui/rules.ts`](./tui/rules.ts) (the rule glyph `RULE_GLYPH`, and `plainRule` / `titleRule` / `centeredRule`), [`tui/indent.ts`](./tui/indent.ts) (the transcript block indent: `BODY_INDENT`, an optional rail prefix that a wrapped call line is kept out of, and `bodyLines` / `indentBlockLines` / `indentedLines`), [`tui/rows.ts`](./tui/rows.ts) (`listRow`, `statusRow`, `checkbox`, `chip`, `keyValueRow`, `emptyState`, `byRunningFirst` for a live list's order), [`tui/columns.ts`](./tui/columns.ts) (`splitRow`, `fitCells`, `flowCells`, `padTo`, `clip`, `clipMiddle`, `labelLine` / `clipLabel` with `LABEL_WIDTH`), [`tui/gauge.ts`](./tui/gauge.ts) (`computeGauge`, `gaugeRole`, `gaugeBar`), [`tui/line-pane.ts`](./tui/line-pane.ts) (`LinePane`, the scrollable log/match pane), [`tui/status-controller.ts`](./tui/status-controller.ts) (`createStatusController`, the footer chips `job`, `watch`, and `subagent` share, with the attached-context `notify` and a `detach` that clears its keys), [`tui/dock-screen.ts`](./tui/dock-screen.ts) (`DockListScreen`, the two-mode docked list + detail-pane screen with navigation, wheel, range, and confirm), [`tui/confirm.ts`](./tui/confirm.ts) (`ConfirmState`, the y/N layer behind every destructive screen action), [`tui/tool.ts`](./tui/tool.ts) (`toolCallTitle`, the width-safe `ToolCallText` / `reuseCallText`, sanitized `errorLine` / `rejectionLine`, `moreHint` / `overflowHint` / `earlierHint`, the `TailResult` transcript block, `syncElapsedTimer`, `elapsedSuffix` — the tool-transcript kit), [`tui/render-cache.ts`](./tui/render-cache.ts) (`RenderCache`: the per-instance line memo a component that derives its lines from another component's output renders through, so a repeat frame is one comparison), and [`tui/cursor.ts`](./tui/cursor.ts) (scroll and navigation: `fitRows`, `keepVisible`, `clampScroll`, `navIntent`, `navigateList`, `navigateScroll`, `wheelDelta`, `scrollList`, `isCloseKey`, `selectionMarker`, `formatRange`, and the `ListCursor` (by index) and `IdCursor` (by stable id) state). |
35
+ | [`path.ts`](./path.ts) | `isInside` (string containment) plus `realPathOfNearest` / `isInsideReal` (symlink-aware): the containment predicates the file-browse, worktree, doc, and plan guards share, and the canonical-path helper the worktree registry keys records by. |
36
+ | [`pdf.ts`](./pdf.ts) | The optional `unpdf` seam shared by `web-access` and `document`: `pdfGuidance`, `PdfUnavailableError`, `pdfTextFromResult`, and the lazy `loadPdfExtractor` / `extractPdfText` (an injected extractor wins), so a missing package and a merged-pages result read the same way through either surface. |
32
37
  | [`pi-invocation.ts`](./pi-invocation.ts) | `getPiInvocation`: how to launch a nested `pi` process, shared by `subagent` and bytefly's doctor surface. |
33
38
  | [`policy.ts`](./policy.ts) | `createReadOnlyPolicy`: default-deny tool classification from `readOnlyHint` + explicit allow/deny. |
39
+ | [`plan-state.ts`](./plan-state.ts) | The plan-mode flag replayed from the session branch: `PLAN_MODE_ENTRY_TYPE` and `isPlanModeEnabled(entries)`, so `rewind` can refuse a write while planning without importing the plan extension. |
40
+ | [`plan-entry.ts`](./plan-entry.ts) | Plan-mode entry shared by the extension that owns the mode and the one that offers it: `READ_ONLY_SUMMARY` and `PLAN_CONSENT` (one wording for the read-only guarantee), the `PLAN_ENTER_REQUEST` channel with its `PlanEnterRequest` payload, and `requestPlanMode(pi, ctx)` — a request/response over `pi.events` that resolves `false` instead of hanging when `plan` is not loaded, so a command context (which has no `executeTool`) can still enter the mode after asking the user once. |
34
41
  | [`rails.ts`](./rails.ts) | Above-editor widget ordering (`goal` → `todo`) and dock-screen suppression, carried on `pi.events`. |
35
42
  | [`requirements.ts`](./requirements.ts) | Per-extension runtime prerequisite table plus a pure `probeExtension(seams)` over injected seams (exec, config reads, optional imports, the published search provider, env, and a refresh-only reachability probe): bytefly's doctor surface reports on it and the extensions that own a dependency format their errors from it. A check kind is either a prerequisite (`binary`, `module`, `state`, `invocation`, `notify`, `provider`) or a `safety` switch whose off value trades a protection away, read from `bytefly.json` global-then-project. |
36
43
  | [`shell.ts`](./shell.ts) | Shell escape rule shared by the worktree isolation guard and plan mode's read-only git guard: `SHELL_ESCAPABLE`, `isShellEscapable`, `hasShellEscape`. |
37
- | [`goal-state.ts`](./goal-state.ts) | The published `goal` state replayed from the session branch: `PublishedGoal`, `GOAL_ENTRY_TYPE`, `MAX_GOAL_OBJECTIVE`, and `lastGoal(entries)` (the last valid goal, skipping a rejected call and honouring an explicit clear), so `plan` can restate an active goal while planning and offer to record one on approval — with no extension importing another's modules. |
38
- | [`todo-state.ts`](./todo-state.ts) | The published `todo` state replayed from the session branch: `ExistingTodo` and `lastTodos(entries)` (the last valid list, skipping a rejected call), so `plan` can merge an approved plan's steps into it and `goal` can name the items still open when a goal is marked achieved — with no extension importing another's modules. |
44
+ | [`spawn.ts`](./spawn.ts) | The process seam for extensions that run a shell command: `SpawnedProcess`, `SpawnFn`, `KillTreeFn`, the pure `spawnOptions` / `killPlan`, `applyKillPlan` over injected seams, `isPidAlive`, and the `defaultSpawn` / `defaultKillTree` that execute them (process group on POSIX, `taskkill /T` on Windows). Shared by `job`, `watch`, `check`, `worktree`, and `wechat`. |
45
+ | [`goal-state.ts`](./goal-state.ts) | The published `goal` state replayed from the session branch: `PublishedGoal`, `PublishedGoalStatus` (`planning` / `active` / `achieved`), `GOAL_ENTRY_TYPE`, `MAX_GOAL_OBJECTIVE`, `isPursuedGoal(goal)` and `lastGoal(entries)` (the last valid goal, skipping a rejected call and honouring an explicit clear), so `plan` can restate a pursued goal while planning, promote a `planning` one at approval, and offer to record one when a plan is approved with none, and `goal` itself reads the branch through the same replay — with no extension importing another's modules. |
46
+ | [`todo-state.ts`](./todo-state.ts) | The todo item contract, its parser, and the published state replayed from the session branch: `TODO_STATUSES` / `TodoStatus`, `ExistingTodo`, `MAX_TODOS` / `MAX_CONTENT`, `normalizeTodos`, and `lastTodos(entries)` (the last write that fully validates, skipping a rejected call; a malformed write leaves the previous list). `plan` merges steps into that list, `goal` counts the items still open, and `todo` itself replays through it — so one rule decides which writes are state writes, with no extension importing another's modules. |
39
47
  | [`tool-names.ts`](./tool-names.ts) | Tool names and one parameter name that more than one extension must agree on, so an orchestrating `ctx.executeTool()` call or schema probe breaks the build on a rename. |
40
- | [`web/`](./web/) | The Bytefly web brand system shared by the browser surfaces: [`web/tokens.ts`](./web/tokens.ts) (palette derived from the Bytefly terminal themes, scales, and the `tok-*` map), [`web/base.ts`](./web/base.ts), [`web/components.ts`](./web/components.ts), [`web/layout.ts`](./web/layout.ts), [`web/runtime.ts`](./web/runtime.ts) (`WEB_THEME_STORAGE_KEY`, `THEME_JS`, `THEME_TOGGLE_JS`), [`web/markup.ts`](./web/markup.ts), and the [`web/index.ts`](./web/index.ts) barrel. |
48
+ | [`tool-result.ts`](./tool-result.ts) | The rejected-tool-call shape: `toolError` builds the `isError: true` content plus the caller's `details`, and `errorResult` is the action-and-message form `job` and `watch` share. |
49
+ | [`web/`](./web/) | The Bytefly web brand system shared by the browser surfaces: [`web/tokens.ts`](./web/tokens.ts) (palette derived from the Bytefly terminal themes, scales, and the `tok-*` map), [`web/base.ts`](./web/base.ts), [`web/components.ts`](./web/components.ts) (the `bf-*` kit, including the dialog, shortcut-sheet, and `bf-kbd` rules), [`web/layout.ts`](./web/layout.ts), [`web/runtime.ts`](./web/runtime.ts) (`WEB_THEME_STORAGE_KEY`, `THEME_JS`, `THEME_TOGGLE_JS`, and `keysDialogScript(triggerId)` — the shared shortcut-sheet wiring, whose contract is `window.bfKeys`), [`web/markup.ts`](./web/markup.ts) (the HTML atoms, including `keysSheet` and its `KeyHint` rows), and the [`web/index.ts`](./web/index.ts) barrel. |
41
50
 
42
51
  ## Conventions
43
52
 
@@ -50,6 +59,11 @@ belongs in that extension instead — see `extensions/web-access/http.ts` and
50
59
  glyph and the `setStatus` key are one contract, not two literals. Tool names
51
60
  shared by an orchestrating tool and the tool it calls — and parameter names
52
61
  one extension probes on another — live in `tool-names.ts`.
62
+ - **A helper that exists here must be imported, not redefined.**
63
+ `test/shared-helpers.test.ts` fails a top-level extension declaration whose
64
+ name is also exported by `lib/`, and a body copied from `lib/` or from another
65
+ extension. A deliberate wrapper goes on that test's reasoned exception list; a
66
+ copy belongs here with one co-located test.
53
67
  - **A color is a role, not a token.** The rules and the primitives are in
54
68
  [`tui/README.md`](./tui/README.md#color-is-a-role): draw through
55
69
  [`tui/tokens.ts`](./tui/tokens.ts) (`paint(theme, role, text)`) so a theme swap