@gavin-hu/my-pi-agent 0.15.0 → 0.15.1
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.
- package/README.md +6 -8
- package/README.zh-CN.md +4 -5
- package/extensions/file-browse/README.md +61 -6
- package/extensions/file-browse/client.ts +3 -1
- package/extensions/file-browse/config.ts +15 -0
- package/extensions/file-browse/files.ts +183 -48
- package/extensions/file-browse/fuzzy.ts +65 -11
- package/extensions/file-browse/html.ts +78 -133
- package/extensions/file-browse/live-client.ts +158 -0
- package/extensions/file-browse/live.ts +222 -0
- package/extensions/file-browse/router.ts +33 -8
- package/extensions/file-browse/server.ts +66 -12
- package/extensions/file-browse/styles.ts +124 -0
- package/extensions/plan/web-router.ts +2 -12
- package/extensions/plan/web.ts +9 -3
- package/extensions/rewind/checklist.ts +10 -3
- package/extensions/rewind/git.ts +27 -14
- package/extensions/rewind/hunk-picker.ts +1 -1
- package/extensions/rewind/path-picker.ts +1 -1
- package/extensions/rewind/review-tui.ts +3 -3
- package/extensions/rewind/snapshot.ts +13 -4
- package/extensions/subagent/background-tui.ts +1 -1
- package/extensions/worktree/status.ts +14 -9
- package/lib/README.md +3 -2
- package/lib/git/read.ts +20 -0
- package/lib/http/headers.ts +20 -0
- package/lib/http/server.ts +22 -4
- package/lib/pool.ts +62 -0
- package/lib/tui/dock-screen.ts +12 -4
- package/package.json +1 -1
package/extensions/rewind/git.ts
CHANGED
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
gitDir,
|
|
21
21
|
repoRoot,
|
|
22
22
|
revParse,
|
|
23
|
+
revParseMany,
|
|
23
24
|
runGitOrThrow as run,
|
|
24
25
|
type RunGit,
|
|
25
26
|
} from "../../lib/git/index.ts";
|
|
@@ -27,7 +28,7 @@ import { parseNameStatus, parseNumStat, type ChangedPath, type PathStat } from "
|
|
|
27
28
|
|
|
28
29
|
// Re-export exactly the shared helpers this extension consumes, so rewind code
|
|
29
30
|
// has one git import surface instead of reaching into `lib/git` directly.
|
|
30
|
-
export { GitError, currentBranch, gitDir, repoRoot, revParse, type RunGit };
|
|
31
|
+
export { GitError, currentBranch, gitDir, repoRoot, revParse, revParseMany, type RunGit };
|
|
31
32
|
|
|
32
33
|
/** Timeout budget for one git command issued by this extension. */
|
|
33
34
|
const SNAPSHOT_TIMEOUT_MS = 30_000;
|
|
@@ -262,20 +263,32 @@ export async function applyPatchReverse(runGit: RunGit, cwd: string, patch: stri
|
|
|
262
263
|
}
|
|
263
264
|
}
|
|
264
265
|
|
|
265
|
-
/**
|
|
266
|
+
/**
|
|
267
|
+
* The markers an interrupted git operation leaves, in precedence order: the
|
|
268
|
+
* first one present names the operation a rewind would clobber.
|
|
269
|
+
*/
|
|
270
|
+
const BUSY_MARKERS: ReadonlyArray<readonly [string, string]> = [
|
|
271
|
+
["MERGE_HEAD", "merge"],
|
|
272
|
+
["rebase-merge", "rebase"],
|
|
273
|
+
["rebase-apply", "rebase"],
|
|
274
|
+
["CHERRY_PICK_HEAD", "cherry-pick"],
|
|
275
|
+
["REVERT_HEAD", "revert"],
|
|
276
|
+
["BISECT_LOG", "bisect"],
|
|
277
|
+
];
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Label of an in-progress git operation that a rewind must not clobber, if any.
|
|
281
|
+
*
|
|
282
|
+
* `rev-parse --git-path` prints one path per argument, so all six markers are one
|
|
283
|
+
* git call: a clean repository — the common case, where every one of them is
|
|
284
|
+
* missing — would otherwise pay six spawns to learn nothing is in progress.
|
|
285
|
+
*/
|
|
266
286
|
export async function busyGitState(runGit: RunGit, cwd: string): Promise<string | undefined> {
|
|
267
|
-
const
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
[
|
|
272
|
-
["REVERT_HEAD", "revert"],
|
|
273
|
-
["BISECT_LOG", "bisect"],
|
|
274
|
-
];
|
|
275
|
-
for (const [name, label] of markers) {
|
|
276
|
-
const result = await runGit(["rev-parse", "--git-path", name], { cwd });
|
|
277
|
-
if (result.code !== 0) continue;
|
|
278
|
-
const path = result.stdout.trim();
|
|
287
|
+
const result = await runGit(["rev-parse", ...BUSY_MARKERS.flatMap(([name]) => ["--git-path", name])], { cwd });
|
|
288
|
+
if (result.code !== 0) return undefined;
|
|
289
|
+
const paths = result.stdout.split("\n");
|
|
290
|
+
for (const [index, [, label]] of BUSY_MARKERS.entries()) {
|
|
291
|
+
const path = paths[index]?.trim();
|
|
279
292
|
if (path && existsSync(resolve(cwd, path))) return label;
|
|
280
293
|
}
|
|
281
294
|
return undefined;
|
|
@@ -94,7 +94,7 @@ export class HunkPickerComponent extends ChecklistScreen<HunkRow> {
|
|
|
94
94
|
|
|
95
95
|
/** The focused hunk's first removed and added line. */
|
|
96
96
|
protected detailLines(width: number): string[] {
|
|
97
|
-
const focused = this.
|
|
97
|
+
const focused = this.focusedRow;
|
|
98
98
|
if (!focused) return [];
|
|
99
99
|
return previewOf(focused.hunk).map((line) => {
|
|
100
100
|
const role = line.startsWith("+") ? "ok" : line.startsWith("-") ? "bad" : "meta";
|
|
@@ -137,7 +137,7 @@ export class PathPickerComponent extends ChecklistScreen<Row> {
|
|
|
137
137
|
|
|
138
138
|
/** What the focused row will do, in words. */
|
|
139
139
|
protected detailLines(width: number): string[] {
|
|
140
|
-
const row = this.
|
|
140
|
+
const row = this.focusedRow;
|
|
141
141
|
if (!row) return [];
|
|
142
142
|
const text =
|
|
143
143
|
row.kind === "group"
|
|
@@ -133,7 +133,7 @@ export class ReviewListComponent extends ChecklistScreen<ReviewEntry> {
|
|
|
133
133
|
}
|
|
134
134
|
|
|
135
135
|
protected onEnter(): void {
|
|
136
|
-
const entry = this.
|
|
136
|
+
const entry = this.focusedRow;
|
|
137
137
|
if (entry) this.close({ kind: "diff", path: entry.path });
|
|
138
138
|
}
|
|
139
139
|
|
|
@@ -142,7 +142,7 @@ export class ReviewListComponent extends ChecklistScreen<ReviewEntry> {
|
|
|
142
142
|
}
|
|
143
143
|
|
|
144
144
|
protected verb(data: string): void {
|
|
145
|
-
const entry = this.
|
|
145
|
+
const entry = this.focusedRow;
|
|
146
146
|
if (!entry) return;
|
|
147
147
|
if (data === "r") {
|
|
148
148
|
const paths = this.chosen.size > 0 ? [...this.chosen] : [entry.path];
|
|
@@ -174,7 +174,7 @@ export class ReviewListComponent extends ChecklistScreen<ReviewEntry> {
|
|
|
174
174
|
|
|
175
175
|
protected detailLines(width: number): string[] {
|
|
176
176
|
const detail: string[] = [];
|
|
177
|
-
const focused = this.
|
|
177
|
+
const focused = this.focusedRow;
|
|
178
178
|
if (focused) detail.push(clip(` ${paint(this.theme, "meta", formatReviewDetail(focused))}`, width));
|
|
179
179
|
const selectionLine =
|
|
180
180
|
this.chosen.size > 0
|
|
@@ -8,7 +8,15 @@
|
|
|
8
8
|
* touched: all staging happens in a temporary index.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import {
|
|
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
|
-
|
|
48
|
-
|
|
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.
|
|
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" });
|
|
@@ -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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
124
|
-
|
|
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
|
@@ -27,10 +27,11 @@ belongs in that extension instead — see `extensions/web-access/http.ts` and
|
|
|
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
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`. |
|
|
30
|
+
| [`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
31
|
| [`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. |
|
|
32
|
+
| [`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
33
|
| [`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
|
+
| [`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. |
|
|
34
35
|
| [`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
36
|
| [`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
37
|
| [`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. |
|
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;
|
package/lib/http/headers.ts
CHANGED
|
@@ -52,3 +52,23 @@ export function scriptHeaders(): Record<string, string> {
|
|
|
52
52
|
export function jsonHeaders(): Record<string, string> {
|
|
53
53
|
return secured("application/json; charset=utf-8");
|
|
54
54
|
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Headers for a Server-Sent Events stream: the one response that is not the page
|
|
58
|
+
* policy, because it is a long-lived event stream rather than a document. It
|
|
59
|
+
* carries no policy of its own to relax — the page that opens it is the one
|
|
60
|
+
* governed by `PAGE_CSP` — but it is declared here so both loopback servers
|
|
61
|
+
* answer a stream the same way.
|
|
62
|
+
*
|
|
63
|
+
* `no-store` stops a cached stream from replaying stale status, `keep-alive`
|
|
64
|
+
* names the connection's intent, and `X-Accel-Buffering: no` is for any
|
|
65
|
+
* buffering reverse proxy in front of it, which would otherwise hold the frames.
|
|
66
|
+
*/
|
|
67
|
+
export function eventHeaders(): Record<string, string> {
|
|
68
|
+
return {
|
|
69
|
+
"Content-Type": "text/event-stream; charset=utf-8",
|
|
70
|
+
"Cache-Control": "no-store",
|
|
71
|
+
Connection: "keep-alive",
|
|
72
|
+
"X-Accel-Buffering": "no",
|
|
73
|
+
};
|
|
74
|
+
}
|
package/lib/http/server.ts
CHANGED
|
@@ -5,9 +5,10 @@
|
|
|
5
5
|
* Everything here is behavior a caller cannot get right by accident: a bind on
|
|
6
6
|
* `127.0.0.1` only, an accept-level `error` listener so a transient failure
|
|
7
7
|
* cannot take the host down, a `HEAD` that sends headers and no body, a `filePath`
|
|
8
|
-
* response streamed instead of buffered,
|
|
9
|
-
* and
|
|
10
|
-
*
|
|
8
|
+
* response streamed instead of buffered, a `stream` response opened as an event
|
|
9
|
+
* stream, and a close that is safe to call twice and drops keep-alive
|
|
10
|
+
* connections. What each server does *around* this — which frames an event
|
|
11
|
+
* stream carries, request bodies, session settling — stays with that server.
|
|
11
12
|
*/
|
|
12
13
|
|
|
13
14
|
import { createReadStream } from "node:fs";
|
|
@@ -42,7 +43,7 @@ export async function bindLoopback(server: Server, port: number): Promise<number
|
|
|
42
43
|
*
|
|
43
44
|
* A `stream` response is deliberately not handled here: keeping a connection
|
|
44
45
|
* open is the owning server's lifecycle, so it must check `stream` before
|
|
45
|
-
* calling this.
|
|
46
|
+
* calling this and open the stream itself through {@link openEventStream}.
|
|
46
47
|
*
|
|
47
48
|
* `afterFlush` runs once a buffered body is on the wire, which is the point a
|
|
48
49
|
* server may resolve a session without the client seeing a dropped connection.
|
|
@@ -68,6 +69,23 @@ export function respond(
|
|
|
68
69
|
response.end(result.body ?? "", afterFlush);
|
|
69
70
|
}
|
|
70
71
|
|
|
72
|
+
/**
|
|
73
|
+
* Write the head of a Server-Sent Events response and start its stream: the
|
|
74
|
+
* status and headers, then a `retry:` hint for the browser's own reconnect.
|
|
75
|
+
*
|
|
76
|
+
* The connection itself stays the calling server's lifecycle — `respond` must
|
|
77
|
+
* not be called after this, and the caller owns ending the response and any
|
|
78
|
+
* periodic frames.
|
|
79
|
+
*/
|
|
80
|
+
export function openEventStream(
|
|
81
|
+
response: ServerResponse,
|
|
82
|
+
result: Pick<ServeResponse, "status" | "headers">,
|
|
83
|
+
retryMs = 2000,
|
|
84
|
+
): void {
|
|
85
|
+
response.writeHead(result.status, result.headers);
|
|
86
|
+
response.write(`retry: ${retryMs}\n\n`);
|
|
87
|
+
}
|
|
88
|
+
|
|
71
89
|
/**
|
|
72
90
|
* An idempotent `close()` for `server`.
|
|
73
91
|
*
|
package/lib/pool.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounded concurrency for a fan-out over I/O.
|
|
3
|
+
*
|
|
4
|
+
* A directory listing and a managed-worktree status sweep both run one small
|
|
5
|
+
* read per entry — a `stat`, a git query — and each read is dominated by
|
|
6
|
+
* waiting, not by work. Run strictly in sequence, a directory of three thousand
|
|
7
|
+
* files pays three thousand round trips; run all at once, it hands the
|
|
8
|
+
* filesystem (or a git index lock) an unbounded burst. This is the middle: at
|
|
9
|
+
* most `limit` calls in flight, results in input order.
|
|
10
|
+
*
|
|
11
|
+
* The first rejection stops new work and is rethrown once every worker has
|
|
12
|
+
* settled, so a failure cannot leave a rejected promise unobserved.
|
|
13
|
+
*
|
|
14
|
+
* Value-only: one pure function over its arguments, no module state.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Run `fn` over `items` with at most `limit` calls in flight, in input order.
|
|
19
|
+
*
|
|
20
|
+
* `limit` is clamped to `[1, items.length]`, so a caller may pass a configured
|
|
21
|
+
* width without guarding it. `fn` receives the item and its index; the returned
|
|
22
|
+
* array is in `items` order regardless of completion order. An empty input never
|
|
23
|
+
* calls `fn`.
|
|
24
|
+
*/
|
|
25
|
+
export async function mapConcurrent<T, R>(
|
|
26
|
+
items: readonly T[],
|
|
27
|
+
limit: number,
|
|
28
|
+
fn: (item: T, index: number) => Promise<R>,
|
|
29
|
+
): Promise<R[]> {
|
|
30
|
+
const total = items.length;
|
|
31
|
+
if (total === 0) return [];
|
|
32
|
+
const width = Math.min(Math.max(1, Math.floor(limit)), total);
|
|
33
|
+
const results = new Array<R>(total);
|
|
34
|
+
|
|
35
|
+
let next = 0;
|
|
36
|
+
let failed = false;
|
|
37
|
+
let failure: unknown;
|
|
38
|
+
|
|
39
|
+
/** Take the next unclaimed index and run it; stop taking after a failure. */
|
|
40
|
+
const worker = async (): Promise<void> => {
|
|
41
|
+
for (;;) {
|
|
42
|
+
if (failed) return;
|
|
43
|
+
const index = next++;
|
|
44
|
+
if (index >= total) return;
|
|
45
|
+
try {
|
|
46
|
+
results[index] = await fn(items[index], index);
|
|
47
|
+
} catch (error) {
|
|
48
|
+
// Keep the first failure: a later one is a consequence of the work
|
|
49
|
+
// already in flight, not the cause the caller needs to read.
|
|
50
|
+
if (!failed) {
|
|
51
|
+
failed = true;
|
|
52
|
+
failure = error;
|
|
53
|
+
}
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
await Promise.all(Array.from({ length: width }, worker));
|
|
60
|
+
if (failed) throw failure;
|
|
61
|
+
return results;
|
|
62
|
+
}
|
package/lib/tui/dock-screen.ts
CHANGED
|
@@ -136,8 +136,16 @@ export abstract class DockListScreen<T> implements Component {
|
|
|
136
136
|
return this.options.theme;
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
-
/**
|
|
140
|
-
|
|
139
|
+
/**
|
|
140
|
+
* The row under the cursor, if any.
|
|
141
|
+
*
|
|
142
|
+
* Not named `focused`: Pi's `Focusable` check is `"focused" in component`, and a
|
|
143
|
+
* component that answers it is handed the focus flag by assignment
|
|
144
|
+
* (`component.focused = true`). A read-only accessor under that name throws a
|
|
145
|
+
* `TypeError` on the way back to the editor and takes the process with it. This
|
|
146
|
+
* screen paints its own selection marker and needs no hardware cursor.
|
|
147
|
+
*/
|
|
148
|
+
protected get focusedRow(): T | undefined {
|
|
141
149
|
const rows = this.rows();
|
|
142
150
|
return rows[this.cursor.indexOf(rows, (row) => this.idOf(row))];
|
|
143
151
|
}
|
|
@@ -229,7 +237,7 @@ export abstract class DockListScreen<T> implements Component {
|
|
|
229
237
|
}
|
|
230
238
|
|
|
231
239
|
private openDetail(): void {
|
|
232
|
-
const row = this.
|
|
240
|
+
const row = this.focusedRow;
|
|
233
241
|
if (!row) return;
|
|
234
242
|
const contents = this.detailFor(row);
|
|
235
243
|
this.mode = "detail";
|
|
@@ -274,7 +282,7 @@ export abstract class DockListScreen<T> implements Component {
|
|
|
274
282
|
this.openDetail();
|
|
275
283
|
return;
|
|
276
284
|
}
|
|
277
|
-
this.handleVerb(data, this.
|
|
285
|
+
this.handleVerb(data, this.focusedRow);
|
|
278
286
|
}
|
|
279
287
|
|
|
280
288
|
private handleConfirmKey(data: string): void {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gavin-hu/my-pi-agent",
|
|
3
|
-
"version": "0.15.
|
|
3
|
+
"version": "0.15.1",
|
|
4
4
|
"description": "Personal Pi customizations, packaged as a Pi package: worktree isolation and rewind snapshots, question, todo, goal, plan, subagent, job, watch, web-access, file-browse, file-open, document, check, memory, wechat, and bytefly.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|