@gavin-hu/my-pi-agent 0.15.0 → 0.16.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 (106) hide show
  1. package/README.md +12 -11
  2. package/README.zh-CN.md +9 -7
  3. package/extensions/bytefly/README.md +98 -116
  4. package/extensions/bytefly/notice/component.ts +6 -4
  5. package/extensions/bytefly/notice/config.ts +16 -2
  6. package/extensions/bytefly/panel/format.ts +3 -8
  7. package/extensions/bytefly/panel/join.ts +1 -6
  8. package/extensions/bytefly/panel/settings.ts +74 -377
  9. package/extensions/bytefly/panel/tui/entries.ts +16 -14
  10. package/extensions/bytefly/panel/tui/extensions-view.ts +3 -0
  11. package/extensions/bytefly/panel/tui/frame.ts +2 -2
  12. package/extensions/bytefly/panel/tui/settings-view.ts +8 -12
  13. package/extensions/bytefly/panel/tui.ts +4 -6
  14. package/extensions/bytefly/picker/resources.ts +7 -3
  15. package/extensions/bytefly/working/register.ts +2 -8
  16. package/extensions/check/tools.ts +2 -11
  17. package/extensions/context/README.md +162 -0
  18. package/extensions/context/collect.ts +53 -0
  19. package/extensions/context/commands.ts +100 -0
  20. package/extensions/context/format.ts +82 -0
  21. package/extensions/context/index.ts +16 -0
  22. package/extensions/context/report.ts +319 -0
  23. package/extensions/context/tui.ts +268 -0
  24. package/extensions/context/types.ts +122 -0
  25. package/extensions/file-browse/README.md +83 -8
  26. package/extensions/file-browse/client.ts +3 -1
  27. package/extensions/file-browse/config.ts +23 -0
  28. package/extensions/file-browse/files.ts +183 -48
  29. package/extensions/file-browse/fuzzy.ts +65 -11
  30. package/extensions/file-browse/html.ts +78 -133
  31. package/extensions/file-browse/index.ts +17 -2
  32. package/extensions/file-browse/live-client.ts +158 -0
  33. package/extensions/file-browse/live.ts +256 -0
  34. package/extensions/file-browse/markdown.ts +3 -10
  35. package/extensions/file-browse/raw-html.ts +3 -10
  36. package/extensions/file-browse/router.ts +33 -8
  37. package/extensions/file-browse/server.ts +78 -13
  38. package/extensions/file-browse/styles.ts +124 -0
  39. package/extensions/file-open/README.md +1 -2
  40. package/extensions/{document → file-read}/README.md +72 -56
  41. package/extensions/{document → file-read}/config.ts +12 -9
  42. package/extensions/{document → file-read}/extract/doc.ts +2 -2
  43. package/extensions/{document → file-read}/extract/docx.ts +3 -3
  44. package/extensions/{document → file-read}/extract/file.ts +9 -9
  45. package/extensions/{document → file-read}/extract/odt.ts +3 -3
  46. package/extensions/{document → file-read}/extract/pdf.ts +3 -3
  47. package/extensions/{document → file-read}/extract/rtf.ts +3 -3
  48. package/extensions/{document → file-read}/extract/xlsx.ts +4 -4
  49. package/extensions/{document → file-read}/formats.ts +11 -11
  50. package/extensions/file-read/index.ts +17 -0
  51. package/extensions/{document → file-read}/paging.ts +15 -13
  52. package/extensions/{document → file-read}/render.ts +17 -13
  53. package/extensions/{document → file-read}/schema.ts +7 -7
  54. package/extensions/{document → file-read}/tool.ts +36 -28
  55. package/extensions/goal/tools.ts +10 -14
  56. package/extensions/job/commands.ts +32 -46
  57. package/extensions/job/runtime.ts +6 -8
  58. package/extensions/job/tools.ts +7 -26
  59. package/extensions/memory/commands.ts +30 -44
  60. package/extensions/memory/tools.ts +6 -14
  61. package/extensions/plan/tools/enter.ts +6 -7
  62. package/extensions/plan/tools/exit.ts +6 -7
  63. package/extensions/plan/tools/steps.ts +6 -7
  64. package/extensions/plan/tools/write.ts +8 -9
  65. package/extensions/plan/web-router.ts +2 -12
  66. package/extensions/plan/web.ts +30 -15
  67. package/extensions/question/tools.ts +5 -4
  68. package/extensions/rewind/checklist.ts +10 -3
  69. package/extensions/rewind/git.ts +27 -14
  70. package/extensions/rewind/hunk-picker.ts +1 -1
  71. package/extensions/rewind/path-picker.ts +1 -1
  72. package/extensions/rewind/review-tui.ts +3 -3
  73. package/extensions/rewind/snapshot.ts +13 -4
  74. package/extensions/subagent/background-tui.ts +1 -1
  75. package/extensions/subagent/background.ts +1 -7
  76. package/extensions/todo/tools.ts +10 -14
  77. package/extensions/watch/README.md +5 -5
  78. package/extensions/watch/commands.ts +33 -47
  79. package/extensions/watch/runtime.ts +5 -7
  80. package/extensions/watch/tools.ts +7 -18
  81. package/extensions/web-access/README.md +3 -3
  82. package/extensions/web-access/search/search.ts +9 -18
  83. package/extensions/wechat/timer.ts +9 -30
  84. package/extensions/wechat/tools.ts +4 -7
  85. package/extensions/worktree/status.ts +14 -9
  86. package/lib/README.md +12 -8
  87. package/lib/escape.ts +15 -2
  88. package/lib/git/read.ts +20 -0
  89. package/lib/http/headers.ts +20 -0
  90. package/lib/http/server.ts +22 -4
  91. package/lib/pdf.ts +2 -2
  92. package/lib/pool.ts +62 -0
  93. package/lib/requirements.ts +2 -2
  94. package/lib/screen-session.ts +85 -0
  95. package/lib/timer.ts +61 -0
  96. package/lib/tui/README.md +3 -3
  97. package/lib/tui/columns.ts +33 -8
  98. package/lib/tui/dock-screen.ts +12 -4
  99. package/lib/tui/rows.ts +15 -6
  100. package/lib/tui/rules.ts +4 -8
  101. package/lib/tui/tool.ts +55 -2
  102. package/package.json +8 -4
  103. package/skills/changelog/SKILL.md +136 -0
  104. package/extensions/document/index.ts +0 -17
  105. /package/extensions/{document → file-read}/deadline.ts +0 -0
  106. /package/extensions/{document → file-read}/extract/vendor.d.ts +0 -0
@@ -8,7 +8,15 @@
8
8
  * touched: all staging happens in a temporary index.
9
9
  */
10
10
 
11
- import { commitTree, currentBranch, revParse, treeFromWorkingTree, updateRef, GitError, type RunGit } from "./git.ts";
11
+ import {
12
+ commitTree,
13
+ currentBranch,
14
+ treeFromWorkingTree,
15
+ updateRef,
16
+ GitError,
17
+ revParseMany,
18
+ type RunGit,
19
+ } from "./git.ts";
12
20
  import { encodeMessage, refFor, META_VERSION, type SnapshotMeta } from "./store.ts";
13
21
  import type { Snapshot, SnapshotReason } from "./types.ts";
14
22
 
@@ -44,11 +52,12 @@ function defaultId(now: number): string {
44
52
  /** Point a ref at the current working tree and return the recorded snapshot. */
45
53
  export async function createSnapshot(deps: SnapshotDeps, input: SnapshotInput): Promise<Snapshot> {
46
54
  const { runGit } = deps;
47
- const head = await revParse(runGit, input.root, "HEAD");
48
- if (!head) throw new GitError("the repository has no commits to snapshot.", 1);
55
+ // One `rev-parse` for both ids: the snapshot needs HEAD to parent the commit
56
+ // and HEAD's tree to know whether the working tree is clean.
57
+ const [head, headTree] = await revParseMany(runGit, input.root, ["HEAD", "HEAD^{tree}"]);
58
+ if (!head || !headTree) throw new GitError("the repository has no commits to snapshot.", 1);
49
59
 
50
60
  const tree = await treeFromWorkingTree(runGit, input.root, input.indexFile, input.includeUntracked);
51
- const headTree = await revParse(runGit, input.root, "HEAD^{tree}");
52
61
  const clean = tree === headTree;
53
62
 
54
63
  const now = deps.now?.() ?? Date.now();
@@ -161,7 +161,7 @@ export class AgentsBoardComponent extends DockListScreen<BackgroundRun> {
161
161
  }
162
162
 
163
163
  protected listHints(): ScreenHint[] {
164
- const focused = this.focused;
164
+ const focused = this.focusedRow;
165
165
  const hints: ScreenHint[] = ["Esc close", { text: "Enter detail", color: "accent" }];
166
166
  if (focused && canCollect(focused)) hints.push({ text: "c collect", color: "accent" });
167
167
  if (focused?.status === "running") hints.push({ text: "x cancel", color: "warning" });
@@ -9,7 +9,7 @@
9
9
  */
10
10
 
11
11
  import { resolve } from "node:path";
12
- import { formatDuration, sanitizeLine, stripControlChars } from "../../lib/format.ts";
12
+ import { sanitizeLine, stripControlChars } from "../../lib/format.ts";
13
13
  import type { AgentConfig } from "./agents.ts";
14
14
  import { formatUsageStats } from "./format.ts";
15
15
  import type { RunOptions } from "./run.ts";
@@ -458,12 +458,6 @@ export function createBackgroundRuntime(deps: BackgroundDeps): BackgroundRuntime
458
458
  return { start, list, collect, cancel, shutdown, reset, subscribe };
459
459
  }
460
460
 
461
- /** A human duration for a run; kept here so the board and collect agree. */
462
- export function backgroundDuration(run: BackgroundRun, now = Date.now()): string {
463
- const end = run.finishedAt ?? now;
464
- return formatDuration(Math.max(0, end - run.startedAt));
465
- }
466
-
467
461
  /** Whether a run can be collected. */
468
462
  export function canCollect(run: BackgroundRun): boolean {
469
463
  return run.status !== "running" && run.status !== "interrupted";
@@ -12,12 +12,11 @@
12
12
 
13
13
  import type { JsonValue } from "@earendil-works/pi-ai";
14
14
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
15
- import { sanitizeLine } from "../../lib/format.ts";
16
15
  import { TODO_TOOL } from "../../lib/tool-names.ts";
17
16
  import { GLYPHS } from "../../lib/tui/glyphs.ts";
18
17
  import { normalizeTodos } from "../../lib/todo-state.ts";
18
+ import { callLine, detailsOrLine, rejectionLine } from "../../lib/tui/tool.ts";
19
19
  import { reuseText, toolDetails } from "../../lib/tui/chrome.ts";
20
- import { rejectionLine, reuseCallText, toolCallTitle } from "../../lib/tui/tool.ts";
21
20
  import { paint } from "../../lib/tui/tokens.ts";
22
21
  import { compareByActivity, formatCallText, formatTodoText, type TodoRailInput } from "./format.ts";
23
22
  import type { TodoRuntime } from "./runtime.ts";
@@ -98,24 +97,21 @@ export function registerTools(pi: ExtensionAPI, runtime: TodoRuntime): void {
98
97
  },
99
98
 
100
99
  renderCall(args, theme, context) {
101
- const text = reuseCallText(context.lastComponent);
102
- text.setText(
103
- toolCallTitle(theme, `${TOOL_NAME} `) +
104
- paint(theme, "meta", formatCallText(args.todos as Todo[] | undefined, context.argsComplete)),
105
- );
106
- return text;
100
+ const body = formatCallText(args.todos as Todo[] | undefined, context.argsComplete);
101
+ return callLine(theme, context.lastComponent, TOOL_NAME, body);
107
102
  },
108
103
 
109
104
  renderResult(result, { expanded }, theme, context) {
110
105
  const text = reuseText(context.lastComponent);
111
- const details = toolDetails<TodoDetails>(result.details);
112
- if (details?.error) {
113
- text.setText(paint(theme, "bad", `Error: ${sanitizeLine(details.error)}`));
106
+ const outcome = detailsOrLine(theme, result, expanded, toolDetails<TodoDetails>(result.details));
107
+ if ("line" in outcome) {
108
+ text.setText(outcome.line);
114
109
  return text;
115
110
  }
116
- // A call rejected before `execute` (argument validation or a permission
117
- // block) carries `details: {}` with no list, so fall back to its message.
118
- if (!details || !Array.isArray(details.todos)) {
111
+ const details = outcome.details;
112
+ // A details block can still arrive without the list: the host rejected the
113
+ // arguments, so its message is the whole result.
114
+ if (!Array.isArray(details.todos)) {
119
115
  text.setText(rejectionLine(theme, result, expanded));
120
116
  return text;
121
117
  }
@@ -236,8 +236,8 @@ outlive the session.
236
236
 
237
237
  ## Testing
238
238
 
239
- Pure-module tests cover `config`, `schema`, `format`, and `tui`; `runtime.test.ts`
240
- drives the tailer with a fake clock and fake children; `tools.test.ts`,
241
- `commands.test.ts`, and `extension.test.ts` exercise the tool, command, and
242
- lifecycle through the shared `createFakePi`/`fakeCtx` doubles. The test harness
243
- is `test/helpers/fixtures/watch.ts`.
239
+ `runtime.test.ts` covers config, schema, the child supervisor, and the tailer
240
+ with a fake clock and fake children; `tools.test.ts` covers the tool and command;
241
+ `ui.test.ts` covers registration, formatting, the status chips, and the screen
242
+ through the shared `createFakePi`/`fakeCtx` doubles. The test harness is
243
+ `test/helpers/fixtures/watch.ts`.
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
11
- import { withRailsSuppressed } from "../../lib/rails.ts";
11
+ import { withPolledScreen } from "../../lib/screen-session.ts";
12
12
  import { formatWatchList, shortLabel } from "./format.ts";
13
13
  import type { WatchRuntime } from "./runtime.ts";
14
14
  import { WatchListComponent } from "./tui.ts";
@@ -92,54 +92,40 @@ export function registerCommands(pi: ExtensionAPI, runtime: WatchRuntime): void
92
92
  return;
93
93
  }
94
94
 
95
- let timer: ReturnType<typeof setInterval> | undefined;
96
- const stopTimer = (): void => {
97
- if (timer) clearInterval(timer);
98
- timer = undefined;
99
- };
100
-
101
- // The dock screen owns the editor slot; hide the rails for its lifetime.
102
- try {
103
- await withRailsSuppressed(pi, () =>
104
- ctx.ui.custom<void>((tui, theme, _keybindings, done) => {
105
- const component = new WatchListComponent(
106
- () => runtime.list(),
107
- theme,
108
- {
109
- lines: (watchId) => runtime.matches(watchId, 500),
110
- stop: (watchId) => {
111
- runtime.stop(watchId);
112
- },
113
- clear: () => {
114
- const result = runtime.clear(undefined, false);
115
- if (result.cleared > 0) {
116
- ctx.ui.notify(`Cleared ${result.cleared} watcher${result.cleared === 1 ? "" : "s"}.`, "info");
117
- }
118
- },
95
+ await withPolledScreen(
96
+ pi,
97
+ ctx,
98
+ (screen) =>
99
+ new WatchListComponent(
100
+ () => runtime.list(),
101
+ screen.theme,
102
+ {
103
+ lines: (watchId) => runtime.matches(watchId, 500),
104
+ stop: (watchId) => {
105
+ runtime.stop(watchId);
119
106
  },
120
- () => {
121
- stopTimer();
122
- done();
107
+ clear: () => {
108
+ const result = runtime.clear(undefined, false);
109
+ if (result.cleared > 0) {
110
+ ctx.ui.notify(`Cleared ${result.cleared} watcher${result.cleared === 1 ? "" : "s"}.`, "info");
111
+ }
123
112
  },
124
- () => tui.requestRender(),
125
- () => tui.terminal?.rows,
126
- );
127
-
128
- // The runtime's own clock owns file polling; this only refreshes
129
- // what the screen shows, so opening it cannot change the poll cadence.
130
- timer = setInterval(() => {
131
- const open = component.currentLineId();
132
- if (open) component.refreshLines(open);
133
- tui.requestRender();
134
- }, REFRESH_MS);
135
- timer.unref?.();
136
-
137
- return component;
138
- }),
139
- );
140
- } finally {
141
- stopTimer();
142
- }
113
+ },
114
+ screen.close,
115
+ screen.requestRender,
116
+ screen.viewportRows,
117
+ ),
118
+ {
119
+ pollMs: REFRESH_MS,
120
+ // The runtime's own clock owns file polling; this only refreshes what
121
+ // the screen shows, so opening it cannot change the poll cadence.
122
+ poll: (component, handles) => {
123
+ const open = component.currentLineId();
124
+ if (open) component.refreshLines(open);
125
+ handles.requestRender();
126
+ },
127
+ },
128
+ );
143
129
  },
144
130
  });
145
131
  }
@@ -17,6 +17,7 @@ import type { KillTreeFn, SpawnFn, SpawnedProcess } from "../../lib/spawn.ts";
17
17
  import { createChildSupervisor } from "./child.ts";
18
18
  import { DEFAULT_CONFIG, loadConfig, type WatchConfig } from "./config.ts";
19
19
  import { sanitizeUntrustedLine } from "../../lib/format.ts";
20
+ import { defaultInterval } from "../../lib/timer.ts";
20
21
  import { compileRegex, sourceLabel } from "./format.ts";
21
22
  import { createUiController, type UiController } from "./ui.ts";
22
23
  import type { PendingMatch, WatchMatch, WatchRecord, WatchSource } from "./types.ts";
@@ -118,7 +119,7 @@ export function createWatchRuntime(options: WatchRuntimeOptions = {}): WatchRunt
118
119
  const watches = new Map<string, Internal>();
119
120
  let counter = 0;
120
121
  let disposed = false;
121
- let clock: ReturnType<typeof setInterval> | undefined;
122
+ let clock: (() => void) | undefined;
122
123
  let onMatchHandler: ((matches: PendingMatch[]) => void) | undefined;
123
124
 
124
125
  const record = (w: Internal): WatchRecord => ({
@@ -163,16 +164,13 @@ export function createWatchRuntime(options: WatchRuntimeOptions = {}): WatchRunt
163
164
  });
164
165
 
165
166
  const stopClock = (): void => {
166
- if (clock) {
167
- clearInterval(clock);
168
- clock = undefined;
169
- }
167
+ clock?.();
168
+ clock = undefined;
170
169
  };
171
170
 
172
171
  const ensureClock = (): void => {
173
172
  if (disposed || !autoClock || clock) return;
174
- clock = setInterval(() => tick(), config.pollMs);
175
- clock.unref?.();
173
+ clock = defaultInterval(config.pollMs, () => tick());
176
174
  };
177
175
 
178
176
  const pendingMatches = (): PendingMatch[] => {
@@ -11,7 +11,7 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
11
11
  import { sanitizeLine } from "../../lib/format.ts";
12
12
  import { errorResult } from "../../lib/tool-result.ts";
13
13
  import { reuseText, toolDetails } from "../../lib/tui/chrome.ts";
14
- import { rejectionLine, reuseCallText, toolCallTitle } from "../../lib/tui/tool.ts";
14
+ import { callLine, detailsOrLine, rejectionLine } from "../../lib/tui/tool.ts";
15
15
  import { SEPARATORS } from "../../lib/tui/glyphs.ts";
16
16
  import { paint } from "../../lib/tui/tokens.ts";
17
17
  import {
@@ -182,29 +182,18 @@ export function registerTools(pi: ExtensionAPI, runtime: WatchRuntime): void {
182
182
 
183
183
  renderCall(args, theme, context) {
184
184
  const action = typeof args.action === "string" ? args.action : "watch";
185
- const text = reuseCallText(context.lastComponent, "", "middle");
186
- text.setText(
187
- toolCallTitle(theme, `${TOOL_NAME} `) +
188
- paint(
189
- theme,
190
- "meta",
191
- sanitizeLine(formatCallText(action, args as Record<string, unknown>, context.argsComplete)),
192
- ),
193
- );
194
- return text;
185
+ const subject = formatCallText(action, args as Record<string, unknown>, context.argsComplete);
186
+ return callLine(theme, context.lastComponent, TOOL_NAME, subject, "middle");
195
187
  },
196
188
 
197
189
  renderResult(result, options, theme, context) {
198
190
  const text = reuseText(context.lastComponent);
199
- const details = toolDetails<WatchDetails>(result.details);
200
- if (!details) {
201
- text.setText(rejectionLine(theme, result, options.expanded));
202
- return text;
203
- }
204
- if (details.error) {
205
- text.setText(paint(theme, "bad", `Error: ${sanitizeLine(details.error)}`));
191
+ const outcome = detailsOrLine(theme, result, options.expanded, toolDetails<WatchDetails>(result.details));
192
+ if ("line" in outcome) {
193
+ text.setText(outcome.line);
206
194
  return text;
207
195
  }
196
+ const details = outcome.details;
208
197
 
209
198
  if (details.action === "list" && details.watches) {
210
199
  if (details.watches.length === 0) {
@@ -378,9 +378,9 @@ the content type and size.
378
378
 
379
379
  ## Testing
380
380
 
381
- `extensions/web-access/` mirrors the halves (`search/`, `fetch/`) plus top-level
382
- `config.test.ts` and `index.test.ts`. It covers the shared runner (via injected
383
- `fetch`), the provider registry and resolution, each provider's URL builder and
381
+ `extensions/web-access/` mirrors the halves (`search/`, `fetch/`) plus the
382
+ `http.test.ts` suite for the shared runner, charset, config, and registration.
383
+ It covers the injected `fetch` runner, the provider registry and resolution, each provider's URL builder and
384
384
  parser (DuckDuckGo, SearXNG, Brave), the orchestration pipeline and throttle,
385
385
  charset decoding (declared, `<meta>`, and BOM precedence), a streamed body over
386
386
  the byte cap, redirect re-validation (per-hop SSRF with literal IPs),
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import { messageOf } from "../../../lib/errors.ts";
11
+ import { abortableSleep } from "../../../lib/timer.ts";
11
12
  import { createFetchRunner, type HttpRunner } from "../http.ts";
12
13
  import { resolveSearchApiKey, type WebSearchConfig } from "./config.ts";
13
14
  import { resolveProvider } from "./registry.ts";
@@ -40,24 +41,14 @@ export interface SearchDeps {
40
41
  /** A sleeper, injectable so throttle scheduling is testable without wall time. */
41
42
  type ThrottleSleep = (ms: number, signal?: AbortSignal) => Promise<void>;
42
43
 
43
- function delay(ms: number, signal: AbortSignal | undefined): Promise<void> {
44
- return new Promise((resolve, reject) => {
45
- const finish = () => {
46
- clearTimeout(timer);
47
- signal?.removeEventListener("abort", onAbort);
48
- };
49
- const onAbort = () => {
50
- finish();
51
- reject(new WebSearchError("Search aborted."));
52
- };
53
- const timer = setTimeout(() => {
54
- finish();
55
- resolve();
56
- }, ms);
57
- if (signal?.aborted) return onAbort();
58
- signal?.addEventListener("abort", onAbort, { once: true });
59
- });
60
- }
44
+ /** The shared abortable sleep, mapped to the one error this extension throws. */
45
+ const delay: ThrottleSleep = async (ms, signal) => {
46
+ try {
47
+ await abortableSleep(ms, signal);
48
+ } catch {
49
+ throw new WebSearchError("Search aborted.");
50
+ }
51
+ };
61
52
 
62
53
  /**
63
54
  * A stateful throttle that spaces requests by `minIntervalMs`. `now` and `sleep`
@@ -3,20 +3,18 @@
3
3
  *
4
4
  * The bridge and the channel need a cancellable one-shot timer that a test can
5
5
  * drive without waiting (the delayed acknowledgement, the typing keep-alive, the
6
- * prompt timeout); login needs an abortable sleep between polls. The defaults use
7
- * a real `setTimeout`, `unref`'d for the one-shot so a pending timer never holds
8
- * the process open.
6
+ * prompt timeout), and login needs an abortable sleep between polls; both seams
7
+ * and their production defaults are in [`lib/timer.ts`](../../lib/timer.ts),
8
+ * because job, watch, file-browse, plan, and web-access arm the same two. This
9
+ * module adds the per-key map those timers are held in.
9
10
  */
10
11
 
11
- /** Arm a one-shot timer; the returned function cancels it. */
12
- export type ArmTimer = (ms: number, fn: () => void) => () => void;
12
+ import { abortableSleep, type ArmTimer } from "../../lib/timer.ts";
13
13
 
14
- /** The production timer: `setTimeout`, detached from the event loop. */
15
- export const defaultTimer: ArmTimer = (ms, fn) => {
16
- const timer = setTimeout(fn, ms);
17
- (timer as { unref?: () => void }).unref?.();
18
- return () => clearTimeout(timer);
19
- };
14
+ export { type ArmTimer, defaultTimer } from "../../lib/timer.ts";
15
+
16
+ /** The shared abortable sleep, under the name the bridge and login already use. */
17
+ export const defaultSleep = abortableSleep;
20
18
 
21
19
  /**
22
20
  * A set of one-shot timers keyed by a caller's id (a peer, a watcher).
@@ -74,22 +72,3 @@ export function createTimerMap(arm: ArmTimer): TimerMap {
74
72
  };
75
73
  return map;
76
74
  }
77
-
78
- /** An abortable sleep: resolves after `ms`, rejects when `signal` aborts. */
79
- export function defaultSleep(ms: number, signal?: AbortSignal): Promise<void> {
80
- return new Promise((resolve, reject) => {
81
- if (signal?.aborted) {
82
- reject(new Error("aborted"));
83
- return;
84
- }
85
- const timer = setTimeout(() => {
86
- signal?.removeEventListener("abort", onAbort);
87
- resolve();
88
- }, ms);
89
- const onAbort = () => {
90
- clearTimeout(timer);
91
- reject(new Error("aborted"));
92
- };
93
- signal?.addEventListener("abort", onAbort, { once: true });
94
- });
95
- }
@@ -10,9 +10,8 @@
10
10
  import type { JsonValue } from "@earendil-works/pi-ai";
11
11
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
12
12
  import { Type, type Static } from "typebox";
13
- import { sanitizeLine } from "../../lib/format.ts";
14
13
  import { reuseText, toolDetails } from "../../lib/tui/chrome.ts";
15
- import { rejectionLine, reuseCallText, toolCallTitle } from "../../lib/tui/tool.ts";
14
+ import { callLine, errorLine, rejectionLine } from "../../lib/tui/tool.ts";
16
15
  import { paint } from "../../lib/tui/tokens.ts";
17
16
  import type { Bridge } from "./bridge.ts";
18
17
 
@@ -82,11 +81,9 @@ export function registerTools(pi: ExtensionAPI, deps: ToolDeps): void {
82
81
  },
83
82
 
84
83
  renderCall(args, theme, context) {
85
- const text = reuseCallText(context.lastComponent);
86
84
  const body = (args as { text?: unknown }).text;
87
- const label = context.argsComplete && typeof body === "string" ? sanitizeLine(body) : "…";
88
- text.setText(toolCallTitle(theme, `${TOOL_NAME} `) + paint(theme, "meta", label));
89
- return text;
85
+ const label = context.argsComplete && typeof body === "string" ? body : "…";
86
+ return callLine(theme, context.lastComponent, TOOL_NAME, label);
90
87
  },
91
88
 
92
89
  renderResult(result, options, theme, context) {
@@ -97,7 +94,7 @@ export function registerTools(pi: ExtensionAPI, deps: ToolDeps): void {
97
94
  return text;
98
95
  }
99
96
  if (details?.error) {
100
- text.setText(paint(theme, "bad", `Error: ${sanitizeLine(details.error)}`));
97
+ text.setText(errorLine(theme, details.error));
101
98
  return text;
102
99
  }
103
100
  // Pi's rejection result carries `{}`; show its sanitized message.
@@ -6,9 +6,10 @@
6
6
  import { basename } from "node:path";
7
7
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
8
8
  import { realPathOfNearest } from "../../lib/path.ts";
9
+ import { mapConcurrent } from "../../lib/pool.ts";
9
10
  import { loadConfig } from "./config.ts";
10
11
  import { gapLines, isolationGaps } from "./gaps.ts";
11
- import { type CheckoutCheck, defaultBranch, listManagedWorktrees, repoRoot } from "./git.ts";
12
+ import { type CheckoutCheck, defaultBranch, listManagedWorktrees, type ManagedWorktree, repoRoot } from "./git.ts";
12
13
  import { reconcileRegistry, statusOf, type CheckoutStatus } from "./registry.ts";
13
14
  import { configFor, getActive, getInactiveOverrides, getUnverifiableCommands } from "./runtime.ts";
14
15
  import type { WorktreeState } from "./state.ts";
@@ -115,15 +116,19 @@ export async function worktreeStatus(pi: ExtensionAPI, ctx: ExtensionContext): P
115
116
  const { registry } = reconcileRegistry(root, config, managed, { persist: false });
116
117
  const baseBranch = await defaultBranch(pi, root);
117
118
  const baseRef = baseBranch ? `origin/${baseBranch}` : undefined;
118
- lines.push("", `Managed worktrees (${managed.length}):`);
119
- for (const entry of managed) {
120
- const record = registry.worktrees.find((candidate) => candidate.path === realPathOfNearest(entry.path));
121
- const status = await statusOf(pi, entry, { baseRef, record });
119
+ // Keyed by the canonical path the registry stores, so a row's record is one
120
+ // lookup instead of a scan of every record.
121
+ const records = new Map(registry.worktrees.map((record) => [record.path, record]));
122
+ const row = async (entry: ManagedWorktree): Promise<string> => {
123
+ const path = realPathOfNearest(entry.path);
124
+ const status = await statusOf(pi, entry, { baseRef, record: records.get(path) });
122
125
  const marks = statusMarks(status, current?.path === entry.path);
123
- lines.push(
124
- ` ${entry.path} ${entry.branch ?? ""}${marks.length > 0 ? ` [${marks.join(", ")}]` : ""}`.trimEnd(),
125
- );
126
- }
126
+ return ` ${entry.path} ${entry.branch ?? ""}${marks.length > 0 ? ` [${marks.join(", ")}]` : ""}`.trimEnd();
127
+ };
128
+ // Each row is up to four git reads; four at a time keeps the list in the order
129
+ // git reports while overlapping the waits. `statusOf` passes no `env`, which is
130
+ // what makes concurrent git calls safe here (see `RunGitOptions.env`).
131
+ lines.push("", `Managed worktrees (${managed.length}):`, ...(await mapConcurrent(managed, 4, row)));
127
132
  }
128
133
  return lines.join("\n");
129
134
  }
package/lib/README.md CHANGED
@@ -20,25 +20,28 @@ belongs in that extension instead — see `extensions/web-access/http.ts` and
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
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
- | [`escape.ts`](./escape.ts) | `escapeHtml`: the single HTML text/attribute escape used by the browse page renderers and the Markdown and syntax highlighters. |
23
+ | [`escape.ts`](./escape.ts) | `escapeHtml`: the single HTML text/attribute escape used by the browse page renderers and the Markdown and syntax highlighters, plus `decodeCodePoint`, the other half of the same boundary — one numeric character reference as text, with a surrogate or out-of-range value decoded to U+FFFD. |
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
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
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
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
+ | [`timer.ts`](./timer.ts) | The three timer seams and their production defaults, each cancellable and each built so a pending timer never holds the process open: `ArmTimer` / `defaultTimer` (one shot), `RepeatTimer` / `defaultInterval` (repeating), and `abortableSleep` (resolve after a delay, reject `Error("aborted")` when a signal wins). Shared by `wechat`'s bridge and prompt channel, plan's review page, `job`'s and `watch`'s repaint clocks, `bytefly`'s working tick, the browse live channel, and the search throttle — so a test drives a deadline instead of waiting on one. |
29
30
  | [`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`. |
31
+ | [`pool.ts`](./pool.ts) | `mapConcurrent`: one async read per entry with at most `limit` calls in flight and results in input order, so a directory listing or a status sweep fans out without an unbounded burst or a per-entry round trip. Shared by `file-browse` and `worktree`. |
30
32
  | [`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. |
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. |
33
+ | [`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`, `revParseMany` for several revisions in one call, `hasCommits`, `currentBranch`); [`git/index.ts`](./git/index.ts) is the barrel. |
32
34
  | [`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. |
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
+ | [`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, plus `eventHeaders` for the one long-lived stream), [`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, `openEventStream` for the head and reconnect hint of an SSE response, and the idempotent `createCloser`), and the [`http/index.ts`](./http/index.ts) barrel. |
36
+ | [`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` with its `align` option for a value held beside its label, `statusRow`, `checkbox`, `chip`, `keyValueRow`, `emptyState`, `byRunningFirst` for a live list's order), [`tui/columns.ts`](./tui/columns.ts) (`splitRow` for two cells at the two edges, `inlineRow` for two cells held together, `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`, `callLine` — the one headline every renderer draws — `detailsOrLine` (a result's details, or the one `errorLine` / `rejectionLine` a failure shows), 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
37
  | [`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. |
38
+ | [`pdf.ts`](./pdf.ts) | The optional `unpdf` seam shared by `web-access` and `file-read`: `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. |
37
39
  | [`pi-invocation.ts`](./pi-invocation.ts) | `getPiInvocation`: how to launch a nested `pi` process, shared by `subagent` and bytefly's doctor surface. |
38
40
  | [`policy.ts`](./policy.ts) | `createReadOnlyPolicy`: default-deny tool classification from `readOnlyHint` + explicit allow/deny. |
39
41
  | [`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
42
  | [`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. |
41
43
  | [`rails.ts`](./rails.ts) | Above-editor widget ordering (`goal` → `todo`) and dock-screen suppression, carried on `pi.events`. |
44
+ | [`screen-session.ts`](./screen-session.ts) | `withPolledScreen`: mount one `ctx.ui.custom` dock screen — rails suppressed for its lifetime, an optional repaint tick through an injected `RepeatTimer`, and one teardown — handing the mount the host's `tui`, `theme`, `requestRender`, `close`, and `viewportRows`. Shared by `/jobs`, `/watch`, and `/memory`, whose rails window and timer window were the same one. |
42
45
  | [`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. |
43
46
  | [`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`. |
44
47
  | [`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`. |
@@ -61,9 +64,10 @@ belongs in that extension instead — see `extensions/web-access/http.ts` and
61
64
  one extension probes on another — live in `tool-names.ts`.
62
65
  - **A helper that exists here must be imported, not redefined.**
63
66
  `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.
67
+ name is also exported by `lib/`, and a body copied from `lib/` or from any
68
+ other file — a second extension, a sibling module, or `lib/` itself. A
69
+ deliberate wrapper goes on that test's reasoned exception list; a copy belongs
70
+ here with its declared capability suite.
67
71
  - **A color is a role, not a token.** The rules and the primitives are in
68
72
  [`tui/README.md`](./tui/README.md#color-is-a-role): draw through
69
73
  [`tui/tokens.ts`](./tui/tokens.ts) (`paint(theme, role, text)`) so a theme swap
package/lib/escape.ts CHANGED
@@ -1,11 +1,14 @@
1
1
  /**
2
- * HTML text escaping for the browse pages.
2
+ * HTML text escaping, and the inverse for one character reference.
3
3
  *
4
4
  * The single escape used by every page renderer and by the Markdown renderer:
5
5
  * model- and file-authored text becomes safe text nodes and double-quoted
6
6
  * attributes. Keeping it here (rather than in `html.ts`) lets `markdown.ts`
7
7
  * escape without importing the page renderer, so the module graph stays
8
- * acyclic.
8
+ * acyclic. `decodeCodePoint` is the other half of the same boundary and lives
9
+ * beside it for the same reason: the Markdown inline scan and the raw-HTML
10
+ * attribute decoder both resolve `&#N;` and `&#xN;` references, and one copy
11
+ * cannot drift from the other.
9
12
  */
10
13
 
11
14
  /** Escape text for an HTML text node or double-quoted attribute. */
@@ -25,3 +28,13 @@ export function escapeHtml(value: string): string {
25
28
  }
26
29
  });
27
30
  }
31
+
32
+ /**
33
+ * One numeric character reference as text: U+FFFD when it is a surrogate, zero,
34
+ * or out of range, so an entity cannot inject an unpaired half of a pair.
35
+ */
36
+ export function decodeCodePoint(value: number): string {
37
+ if (!Number.isFinite(value) || value <= 0 || value > 0x10ffff || (value >= 0xd800 && value <= 0xdfff))
38
+ return "\uFFFD";
39
+ return String.fromCodePoint(value);
40
+ }
package/lib/git/read.ts CHANGED
@@ -70,6 +70,26 @@ export async function revParse(runGit: RunGit, cwd: string, rev: string): Promis
70
70
  return id || undefined;
71
71
  }
72
72
 
73
+ /**
74
+ * Resolve several revisions in one `git rev-parse`, in the order given.
75
+ *
76
+ * A snapshot needs `HEAD` and its tree, and two calls pay two spawns for one
77
+ * answer; `rev-parse` prints one line per argument, so one call is enough. A
78
+ * revision that does not resolve is not a shorter list — it is no answer, since
79
+ * the caller's indices would shift — so the result is `[]` unless every one
80
+ * resolved.
81
+ */
82
+ export async function revParseMany(runGit: RunGit, cwd: string, revs: readonly string[]): Promise<string[]> {
83
+ if (revs.length === 0) return [];
84
+ const result = await runGit(["rev-parse", ...revs], { cwd });
85
+ if (result.code !== 0) return [];
86
+ const ids = result.stdout
87
+ .split("\n")
88
+ .map((line) => line.trim())
89
+ .filter((line) => line !== "");
90
+ return ids.length === revs.length ? ids : [];
91
+ }
92
+
73
93
  /** Whether the repository has at least one commit. */
74
94
  export async function hasCommits(runGit: RunGit, cwd: string): Promise<boolean> {
75
95
  return (await revParse(runGit, cwd, "HEAD")) !== undefined;