@gavin-hu/my-pi-agent 0.12.0 → 0.13.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 +28 -21
- package/README.zh-CN.md +30 -22
- package/extensions/bytefly/README.md +162 -70
- package/extensions/bytefly/chrome.ts +7 -2
- package/extensions/bytefly/doctor/register.ts +6 -0
- package/extensions/bytefly/doctor/runtime.ts +38 -6
- package/extensions/bytefly/footer/budget.ts +64 -0
- package/extensions/bytefly/footer/collect/snapshot.ts +33 -5
- package/extensions/bytefly/footer/config.ts +7 -1
- package/extensions/bytefly/footer/delegation.ts +100 -0
- package/extensions/bytefly/footer/price/config.ts +16 -0
- package/extensions/bytefly/footer/price/types.ts +5 -0
- package/extensions/bytefly/footer/register.ts +46 -4
- package/extensions/bytefly/footer/render/lines.ts +2 -0
- package/extensions/bytefly/footer/render/segments.ts +27 -0
- package/extensions/bytefly/footer/types.ts +11 -0
- package/extensions/bytefly/index.ts +8 -5
- package/extensions/bytefly/notify/config.ts +2 -0
- package/extensions/bytefly/notify/format.ts +19 -1
- package/extensions/bytefly/notify/register.ts +52 -14
- package/extensions/bytefly/notify/types.ts +2 -0
- package/extensions/bytefly/panel/commands.ts +34 -13
- package/extensions/bytefly/panel/settings.ts +129 -16
- package/extensions/bytefly/update/cache.ts +73 -0
- package/extensions/bytefly/update/format.ts +65 -10
- package/extensions/bytefly/update/register.ts +99 -13
- package/extensions/bytefly/update/state.ts +13 -1
- package/extensions/document/README.md +22 -12
- package/extensions/document/config.ts +4 -0
- package/extensions/document/deadline.ts +61 -0
- package/extensions/document/tool.ts +13 -2
- package/extensions/file-browse/README.md +12 -8
- package/extensions/file-browse/client.ts +6 -7
- package/extensions/file-browse/files.ts +0 -4
- package/extensions/file-browse/git.ts +9 -10
- package/extensions/file-browse/html.ts +17 -7
- package/extensions/file-browse/router.ts +24 -15
- package/extensions/goal/README.md +7 -1
- package/extensions/goal/schema.ts +6 -2
- package/extensions/goal/types.ts +6 -2
- package/extensions/job/README.md +16 -12
- package/extensions/job/format.ts +4 -7
- package/extensions/job/runtime.ts +23 -6
- package/extensions/job/status.ts +4 -2
- package/extensions/memory/README.md +55 -13
- package/extensions/memory/commands.ts +20 -2
- package/extensions/memory/doctor.ts +137 -0
- package/extensions/memory/format.ts +24 -7
- package/extensions/memory/runtime.ts +49 -0
- package/extensions/memory/store.ts +31 -0
- package/extensions/memory/tui.ts +136 -12
- package/extensions/plan/README.md +60 -29
- package/extensions/plan/commands.ts +67 -8
- package/extensions/plan/index.ts +35 -11
- package/extensions/plan/plan-index.ts +234 -0
- package/extensions/plan/plan-text.ts +95 -0
- package/extensions/plan/plans.ts +90 -1
- package/extensions/plan/review.ts +12 -9
- package/extensions/plan/runtime.ts +72 -27
- package/extensions/plan/steps.ts +35 -25
- package/extensions/plan/todos.ts +24 -2
- package/extensions/plan/tools.ts +124 -24
- package/extensions/plan/tui.ts +7 -28
- package/extensions/plan/types.ts +9 -0
- package/extensions/plan/web-page.ts +2 -2
- package/extensions/plan/web.ts +12 -11
- package/extensions/rewind/README.md +28 -8
- package/extensions/rewind/commands.ts +1 -1
- package/extensions/rewind/format.ts +4 -4
- package/extensions/rewind/git.ts +56 -18
- package/extensions/rewind/index.ts +3 -5
- package/extensions/rewind/path-picker.ts +198 -0
- package/extensions/rewind/paths.ts +128 -0
- package/extensions/rewind/restore.ts +69 -28
- package/extensions/rewind/rewind.ts +80 -3
- package/extensions/rewind/runtime.ts +13 -5
- package/extensions/rewind/snapshot-run.ts +5 -2
- package/extensions/rewind/types.ts +2 -0
- package/extensions/subagent/README.md +7 -1
- package/extensions/subagent/accounting.ts +34 -0
- package/extensions/subagent/index.ts +23 -1
- package/extensions/subagent/stream.ts +2 -0
- package/extensions/subagent/types.ts +2 -0
- package/extensions/todo/nudge.ts +5 -1
- package/extensions/watch/README.md +19 -8
- package/extensions/watch/commands.ts +4 -3
- package/extensions/watch/config.ts +1 -1
- package/extensions/watch/format.ts +4 -7
- package/extensions/watch/index.ts +19 -0
- package/extensions/watch/runtime.ts +83 -12
- package/extensions/web-access/README.md +43 -13
- package/extensions/web-access/charset.ts +58 -0
- package/extensions/web-access/fetch/find.ts +11 -7
- package/extensions/web-access/fetch/format.ts +64 -18
- package/extensions/web-access/fetch/page.ts +5 -1
- package/extensions/web-access/fetch/tool.ts +19 -18
- package/extensions/web-access/http.ts +49 -8
- package/extensions/web-access/search/facts.ts +38 -0
- package/extensions/web-access/search/tool.ts +13 -0
- package/extensions/web-access/text.ts +50 -11
- package/extensions/worktree/README.md +81 -16
- package/extensions/worktree/config.ts +11 -0
- package/extensions/worktree/enter.ts +10 -3
- package/extensions/worktree/exit.ts +55 -3
- package/extensions/worktree/gaps.ts +74 -0
- package/extensions/worktree/git.ts +28 -0
- package/extensions/worktree/guard.ts +50 -4
- package/extensions/worktree/handoff.ts +55 -0
- package/extensions/worktree/index.ts +14 -2
- package/extensions/worktree/root-tools.ts +1 -1
- package/extensions/worktree/runtime.ts +25 -0
- package/extensions/worktree/status.ts +15 -2
- package/extensions/worktree/tools.ts +3 -2
- package/lib/README.md +14 -10
- package/lib/events.ts +86 -1
- package/lib/goal-state.ts +95 -0
- package/lib/requirements.ts +361 -62
- package/lib/tui/README.md +91 -0
- package/lib/tui/columns.ts +17 -0
- package/lib/tui/glyphs.ts +1 -0
- package/lib/tui/tool.ts +6 -2
- package/lib/web/layout.ts +12 -2
- package/package.json +5 -3
- package/themes/bytefly-dark.json +1 -1
- package/assets/chrome.png +0 -0
- package/assets/file-browse.png +0 -0
- package/assets/job.png +0 -0
- package/assets/plan-review.png +0 -0
- package/assets/question.png +0 -0
- package/assets/rewind.png +0 -0
- package/assets/todo.png +0 -0
- package/assets/tool-renderings.png +0 -0
- package/assets/tools/ask_user_question-cancelled.png +0 -0
- package/assets/tools/ask_user_question.png +0 -0
- package/assets/tools/bash.png +0 -0
- package/assets/tools/edit.png +0 -0
- package/assets/tools/enter_plan_mode.png +0 -0
- package/assets/tools/enter_worktree.png +0 -0
- package/assets/tools/exit_plan_mode.png +0 -0
- package/assets/tools/exit_worktree.png +0 -0
- package/assets/tools/find.png +0 -0
- package/assets/tools/goal.png +0 -0
- package/assets/tools/grep.png +0 -0
- package/assets/tools/job.png +0 -0
- package/assets/tools/list_worktrees.png +0 -0
- package/assets/tools/ls.png +0 -0
- package/assets/tools/memory-long-subject.png +0 -0
- package/assets/tools/memory.png +0 -0
- package/assets/tools/notice.png +0 -0
- package/assets/tools/open_path.png +0 -0
- package/assets/tools/prune_worktrees.png +0 -0
- package/assets/tools/read.png +0 -0
- package/assets/tools/read_doc-running.png +0 -0
- package/assets/tools/read_doc.png +0 -0
- package/assets/tools/run_doctor.png +0 -0
- package/assets/tools/send_wechat.png +0 -0
- package/assets/tools/set_plan_steps.png +0 -0
- package/assets/tools/subagent.png +0 -0
- package/assets/tools/todo.png +0 -0
- package/assets/tools/turn-rule.png +0 -0
- package/assets/tools/user-message.png +0 -0
- package/assets/tools/watch.png +0 -0
- package/assets/tools/web_fetch-rejected.png +0 -0
- package/assets/tools/web_fetch.png +0 -0
- package/assets/tools/web_search.png +0 -0
- package/assets/tools/write.png +0 -0
- package/assets/tools/write_plan.png +0 -0
|
@@ -3,10 +3,12 @@
|
|
|
3
3
|
*
|
|
4
4
|
* No `curl`, no dependencies: one `fetch` call per request (with optional
|
|
5
5
|
* manual redirect handling) with a combined caller-signal and timeout, optional
|
|
6
|
-
* request body, and a size cap
|
|
7
|
-
* network.
|
|
6
|
+
* request body, and a size cap enforced while the body is read. `HttpRunner` is
|
|
7
|
+
* the seam tests use to avoid the network.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
import { bomCharset, charsetOf, decodeBytes } from "./charset.ts";
|
|
11
|
+
|
|
10
12
|
/** A request to send. */
|
|
11
13
|
interface HttpRequest {
|
|
12
14
|
url: string;
|
|
@@ -15,7 +17,7 @@ interface HttpRequest {
|
|
|
15
17
|
/** Raw request body (for example a JSON-RPC payload). */
|
|
16
18
|
body?: string;
|
|
17
19
|
timeoutMs: number;
|
|
18
|
-
/** Maximum response size in bytes
|
|
20
|
+
/** Maximum response size in bytes, enforced while the body is read. */
|
|
19
21
|
maxBytes?: number;
|
|
20
22
|
/** Redirect handling: `"follow"` (default) or `"manual"` to inspect each hop. */
|
|
21
23
|
redirect?: "follow" | "manual";
|
|
@@ -63,6 +65,48 @@ export class HttpUnavailableError extends Error {
|
|
|
63
65
|
}
|
|
64
66
|
}
|
|
65
67
|
|
|
68
|
+
/**
|
|
69
|
+
* Concatenate chunks into one buffer. `total` is the caller's running byte count,
|
|
70
|
+
* so the allocation happens once rather than growing an array per chunk.
|
|
71
|
+
*/
|
|
72
|
+
function concatChunks(chunks: Uint8Array[], total: number): Uint8Array {
|
|
73
|
+
const merged = new Uint8Array(total);
|
|
74
|
+
let offset = 0;
|
|
75
|
+
for (const chunk of chunks) {
|
|
76
|
+
merged.set(chunk, offset);
|
|
77
|
+
offset += chunk.byteLength;
|
|
78
|
+
}
|
|
79
|
+
return merged;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Read a response body to completion, aborting the read as soon as it exceeds
|
|
84
|
+
* `maxBytes`. `response.arrayBuffer()` would buffer a whole chunked or
|
|
85
|
+
* compressed response first, which is the case the cap exists to prevent.
|
|
86
|
+
*/
|
|
87
|
+
async function readCappedBody(response: Response, maxBytes?: number): Promise<Uint8Array> {
|
|
88
|
+
if (!response.body) {
|
|
89
|
+
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
90
|
+
if (maxBytes !== undefined && bytes.byteLength > maxBytes) throw new HttpTooLargeError(maxBytes);
|
|
91
|
+
return bytes;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const reader = response.body.getReader();
|
|
95
|
+
const chunks: Uint8Array[] = [];
|
|
96
|
+
let total = 0;
|
|
97
|
+
for (;;) {
|
|
98
|
+
const { done, value } = await reader.read();
|
|
99
|
+
if (done) break;
|
|
100
|
+
total += value.byteLength;
|
|
101
|
+
if (maxBytes !== undefined && total > maxBytes) {
|
|
102
|
+
await reader.cancel().catch(() => undefined);
|
|
103
|
+
throw new HttpTooLargeError(maxBytes);
|
|
104
|
+
}
|
|
105
|
+
chunks.push(value);
|
|
106
|
+
}
|
|
107
|
+
return concatChunks(chunks, total);
|
|
108
|
+
}
|
|
109
|
+
|
|
66
110
|
/**
|
|
67
111
|
* Create a runner backed by `fetch`.
|
|
68
112
|
*
|
|
@@ -103,11 +147,8 @@ export function createFetchRunner(fetchImpl: typeof fetch = globalThis.fetch): H
|
|
|
103
147
|
throw new HttpTooLargeError(request.maxBytes);
|
|
104
148
|
}
|
|
105
149
|
|
|
106
|
-
const bytes =
|
|
107
|
-
|
|
108
|
-
throw new HttpTooLargeError(request.maxBytes);
|
|
109
|
-
}
|
|
110
|
-
const body = new TextDecoder().decode(bytes);
|
|
150
|
+
const bytes = await readCappedBody(response, request.maxBytes);
|
|
151
|
+
const body = decodeBytes(bytes, bomCharset(bytes) ?? charsetOf(contentType));
|
|
111
152
|
const location = response.headers.get("location") ?? undefined;
|
|
112
153
|
|
|
113
154
|
return {
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What `web_search` resolved to, published for `doctor`.
|
|
3
|
+
*
|
|
4
|
+
* The provider registry, each provider's `isConfigured`, and the `auto`
|
|
5
|
+
* resolution rule all live in this extension, so this module is where the facts
|
|
6
|
+
* for a diagnostic come from. It publishes a summary only — the key itself never
|
|
7
|
+
* leaves the extension, just the variable name and whether a literal key exists.
|
|
8
|
+
*
|
|
9
|
+
* `endpoint` is deliberately null for the providers without a configurable
|
|
10
|
+
* endpoint: probing an API host would report a 401 as a failure for a provider
|
|
11
|
+
* that works, and there is nothing for the user to fix.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { WebSearchProviderEvent } from "../../../lib/events.ts";
|
|
15
|
+
import type { WebSearchConfig } from "./config.ts";
|
|
16
|
+
import { DEFAULT_PROVIDER_ID, resolveProvider } from "./registry.ts";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The facts for one session's config. `reason` distinguishes an explicit choice
|
|
20
|
+
* from the auto rule, so a report can tell "you asked for this and it is not
|
|
21
|
+
* configured" from "this is the keyless fallback".
|
|
22
|
+
*/
|
|
23
|
+
export function providerFacts(config: WebSearchConfig): WebSearchProviderEvent {
|
|
24
|
+
const provider = resolveProvider(config);
|
|
25
|
+
// `auto` only leaves the keyless default when another provider is configured,
|
|
26
|
+
// so the resolved id tells the two apart.
|
|
27
|
+
const reason: WebSearchProviderEvent["reason"] =
|
|
28
|
+
config.provider !== "auto" ? "explicit" : provider.id === DEFAULT_PROVIDER_ID ? "default" : "configured";
|
|
29
|
+
return {
|
|
30
|
+
provider: provider.id,
|
|
31
|
+
reason,
|
|
32
|
+
configured: provider.isConfigured(config),
|
|
33
|
+
// Only SearXNG's base URL is a setting a user can get wrong.
|
|
34
|
+
endpoint: provider.id === "searxng" && config.endpoint !== "" ? config.endpoint : null,
|
|
35
|
+
apiKeyEnv: config.apiKeyEnv || null,
|
|
36
|
+
keyConfigured: config.apiKey.trim() !== "",
|
|
37
|
+
};
|
|
38
|
+
}
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
11
|
+
import { WEB_SEARCH_PROVIDER_CHANNEL } from "../../../lib/events.ts";
|
|
11
12
|
import { loadSearchConfig } from "../config.ts";
|
|
12
13
|
import { oneLine } from "../transcript.ts";
|
|
13
14
|
import { reuseText } from "../../../lib/tui/chrome.ts";
|
|
@@ -21,6 +22,7 @@ import {
|
|
|
21
22
|
} from "../../../lib/tui/tool.ts";
|
|
22
23
|
import { paint } from "../../../lib/tui/tokens.ts";
|
|
23
24
|
import { expandHint, SEPARATORS } from "../../../lib/tui/glyphs.ts";
|
|
25
|
+
import { providerFacts } from "./facts.ts";
|
|
24
26
|
import { formatResults } from "./format.ts";
|
|
25
27
|
import { createThrottle, runSearch, type SearchDeps } from "./search.ts";
|
|
26
28
|
import { resolveRequest, WebSearchOutput, WebSearchParams, type SearchResponse, type WebSearchArgs } from "./schema.ts";
|
|
@@ -32,6 +34,17 @@ export function registerSearchTool(pi: ExtensionAPI, deps: SearchDeps = {}): voi
|
|
|
32
34
|
// without any module-level state.
|
|
33
35
|
const runDeps: SearchDeps = { http: deps.http, throttle: deps.throttle ?? createThrottle() };
|
|
34
36
|
|
|
37
|
+
// Publish what this session resolved to, so `doctor` can report the provider,
|
|
38
|
+
// its key, and its endpoint without importing this extension. Accounting only:
|
|
39
|
+
// a bus listener that throws must not break the tool.
|
|
40
|
+
pi.on("session_start", (_event, ctx) => {
|
|
41
|
+
try {
|
|
42
|
+
pi.events.emit(WEB_SEARCH_PROVIDER_CHANNEL, providerFacts(loadSearchConfig(ctx.cwd)));
|
|
43
|
+
} catch {
|
|
44
|
+
// Diagnostics are best-effort.
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
|
|
35
48
|
pi.registerTool({
|
|
36
49
|
name: TOOL_NAME,
|
|
37
50
|
label: "Web search",
|
|
@@ -3,24 +3,63 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Character budgets are counted in code points, not UTF-16 units, so a slice
|
|
5
5
|
* never splits a CJK character or an emoji, and a declared limit is honoured for
|
|
6
|
-
* the text the model sees.
|
|
7
|
-
*
|
|
6
|
+
* the text the model sees. Everything here walks the string in place: a
|
|
7
|
+
* multi-megabyte page is never materialised as an array of one-character
|
|
8
|
+
* strings, which is what `Array.from(text)` would cost on every call.
|
|
8
9
|
*/
|
|
9
10
|
|
|
10
|
-
|
|
11
|
-
export function toChars(text: string): string[] {
|
|
12
|
-
return Array.from(text);
|
|
13
|
-
}
|
|
11
|
+
const SURROGATE = /[\uD800-\uDFFF]/;
|
|
14
12
|
|
|
15
13
|
/** Length of text in code points. */
|
|
16
14
|
export function charLength(text: string): number {
|
|
17
|
-
|
|
15
|
+
// A native scan with no allocation covers the common all-BMP page.
|
|
16
|
+
if (!SURROGATE.test(text)) return text.length;
|
|
17
|
+
|
|
18
|
+
let count = 0;
|
|
19
|
+
for (let i = 0; i < text.length; i++) {
|
|
20
|
+
const code = text.charCodeAt(i);
|
|
21
|
+
if (code >= 0xd800 && code <= 0xdbff) {
|
|
22
|
+
const next = text.charCodeAt(i + 1);
|
|
23
|
+
if (next >= 0xdc00 && next <= 0xdfff) i++;
|
|
24
|
+
}
|
|
25
|
+
count++;
|
|
26
|
+
}
|
|
27
|
+
return count;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Slice text by code-point offsets, like `Array.from(text).slice(from, to).join("")`
|
|
32
|
+
* without building the array. Out-of-range offsets clamp, and `to` defaults to
|
|
33
|
+
* the end of the string.
|
|
34
|
+
*/
|
|
35
|
+
export function sliceChars(text: string, from: number, to: number = Number.POSITIVE_INFINITY): string {
|
|
36
|
+
const start = Math.max(0, Math.floor(from));
|
|
37
|
+
const end = Math.max(start, Math.floor(to));
|
|
38
|
+
if (start === 0 && end >= text.length) return text;
|
|
39
|
+
|
|
40
|
+
let startIndex = text.length;
|
|
41
|
+
let endIndex = text.length;
|
|
42
|
+
let codePoint = 0;
|
|
43
|
+
for (let i = 0; i < text.length; i++) {
|
|
44
|
+
if (codePoint === start && startIndex === text.length) startIndex = i;
|
|
45
|
+
if (codePoint === end) {
|
|
46
|
+
endIndex = i;
|
|
47
|
+
break;
|
|
48
|
+
}
|
|
49
|
+
const code = text.charCodeAt(i);
|
|
50
|
+
if (code >= 0xd800 && code <= 0xdbff) {
|
|
51
|
+
const next = text.charCodeAt(i + 1);
|
|
52
|
+
if (next >= 0xdc00 && next <= 0xdfff) i++;
|
|
53
|
+
}
|
|
54
|
+
codePoint++;
|
|
55
|
+
}
|
|
56
|
+
return text.slice(startIndex, endIndex);
|
|
18
57
|
}
|
|
19
58
|
|
|
20
59
|
/** Truncate to `maxChars` code points, appending `marker` when anything was cut. */
|
|
21
60
|
export function truncateChars(text: string, maxChars: number, marker = "…"): string {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
if (maxChars <=
|
|
25
|
-
return `${
|
|
61
|
+
if (charLength(text) <= maxChars) return text;
|
|
62
|
+
const markerLength = charLength(marker);
|
|
63
|
+
if (maxChars <= markerLength) return marker;
|
|
64
|
+
return `${sliceChars(text, 0, maxChars - markerLength)}${marker}`;
|
|
26
65
|
}
|
|
@@ -23,11 +23,16 @@ How the `list_worktrees` call and its result look in the transcript — the call
|
|
|
23
23
|
## What it does
|
|
24
24
|
|
|
25
25
|
- `enter_worktree` creates `<repo>/.pi/worktrees/<name>` on branch
|
|
26
|
-
`worktree-<name>` (
|
|
27
|
-
session's working root
|
|
26
|
+
`worktree-<name>` cut from `origin/main` (which can lag the local `main`), or
|
|
27
|
+
enters an existing worktree by `path`, and rebinds the session's working root
|
|
28
|
+
to it.
|
|
28
29
|
- `exit_worktree` returns to the main checkout. A clean worktree is removed
|
|
29
30
|
automatically; one with uncommitted changes or new commits prompts (or is
|
|
30
|
-
kept in non-interactive mode).
|
|
31
|
+
kept in non-interactive mode). Its result ends with a **handoff**: the branch,
|
|
32
|
+
how many commits it holds beyond the merge target, whether that target already
|
|
33
|
+
contains them, and the `git merge --no-ff` and `gh pr create` commands that
|
|
34
|
+
finish the task. The tool never commits, merges, pushes, or opens the PR. A
|
|
35
|
+
branch that still holds work (uncommitted
|
|
31
36
|
changes, or commits not reachable from the recorded base) is never
|
|
32
37
|
force-deleted, even with `remove: true`.
|
|
33
38
|
- While isolated, `read`/`write`/`edit`/`bash`/`grep`/`find`/`ls` all resolve
|
|
@@ -56,14 +61,32 @@ changes; a branch that still holds work is kept. Declining a confirmation
|
|
|
56
61
|
(entering an outside path, exiting a worktree with work) is reported as normal
|
|
57
62
|
feedback, not an error.
|
|
58
63
|
|
|
64
|
+
`/worktree status` (and `list_worktrees`) reports the active worktree, any
|
|
65
|
+
override that is not re-rooted, what isolation does not cover, and the managed
|
|
66
|
+
worktrees:
|
|
67
|
+
|
|
68
|
+
```text
|
|
69
|
+
Not isolated:
|
|
70
|
+
! shell runs in the main checkout; Pi has no mutable session cwd
|
|
71
|
+
powershell allowed by worktree.guard.blockPowershell: false; not re-rooted or guarded
|
|
72
|
+
unverifiable $(...), backticks, eval, and sh -c pass through (blockUnparsableCommands is off); 2 ran this session
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
`powershell` appears only on Windows, with that tool active, and only when
|
|
76
|
+
`guard.blockPowershell` lets it through — by default the guard refuses the call
|
|
77
|
+
instead, so there is no gap to report. `unverifiable` appears only while
|
|
78
|
+
`guard.blockUnparsableCommands` is off; its trailing count is this session's,
|
|
79
|
+
reset at session start. `doctor` reports the `powershell` switch as `degraded`
|
|
80
|
+
when it is off and notes the two structural gaps, which no setting closes.
|
|
81
|
+
|
|
59
82
|
The model surface mirrors this with four tools:
|
|
60
83
|
|
|
61
84
|
| Tool | Usage |
|
|
62
85
|
|---|---|
|
|
63
86
|
| `enter_worktree` | `name` to create a worktree, or `path` to enter an existing one. Errors if already isolated. |
|
|
64
|
-
| `exit_worktree` | optional `remove` and `keepBranch`. Returns the clean/keep/remove decision. |
|
|
87
|
+
| `exit_worktree` | optional `remove` and `keepBranch`. Returns the clean/keep/remove decision and a `handoff` (`branch`, `base`, `ahead`, `merged`). |
|
|
65
88
|
| `prune_worktrees` | no parameters. Removes eligible managed worktrees and reports the rest. |
|
|
66
|
-
| `list_worktrees` | no parameters. Read-only status: active root, inactive overrides, managed worktrees. |
|
|
89
|
+
| `list_worktrees` | no parameters. Read-only status: active root, inactive overrides, isolation gaps, managed worktrees. |
|
|
67
90
|
|
|
68
91
|
## Configuration
|
|
69
92
|
|
|
@@ -87,7 +110,8 @@ The `worktree` section of the shared `bytefly.json` (global document, then
|
|
|
87
110
|
"blockReadEscapes": false,
|
|
88
111
|
"blockGitRedirects": true,
|
|
89
112
|
"blockSymlinkEscapes": true,
|
|
90
|
-
"blockUnparsableCommands": false
|
|
113
|
+
"blockUnparsableCommands": false,
|
|
114
|
+
"blockPowershell": true
|
|
91
115
|
}
|
|
92
116
|
}
|
|
93
117
|
```
|
|
@@ -98,9 +122,15 @@ exclude the name here to load alongside it. It is read when the extension
|
|
|
98
122
|
loads, from the process working directory; reload after changing it. `/worktree`
|
|
99
123
|
and `list_worktrees` list any overridden tool that is not re-rooted.
|
|
100
124
|
|
|
101
|
-
|
|
125
|
+
`.gitignore` must cover the managed directory, or worktrees show up as untracked
|
|
126
|
+
files in the main checkout. `enter_worktree` offers once per session to append
|
|
127
|
+
`.pi/worktrees/`, and skips the offer when `git check-ignore` already reports the
|
|
128
|
+
directory as ignored, so a wider rule is enough and no literal line is needed:
|
|
102
129
|
|
|
103
130
|
```
|
|
131
|
+
# either, when the rest of `.pi/` is disposable
|
|
132
|
+
.pi/
|
|
133
|
+
# or, when other `.pi/` state must stay tracked
|
|
104
134
|
.pi/worktrees/
|
|
105
135
|
```
|
|
106
136
|
|
|
@@ -118,7 +148,8 @@ variable, `~user`, a backtick) is treated as escaping. File-descriptor
|
|
|
118
148
|
duplications (`2>&1`, `>&-`), heredocs, and here-strings are not treated as
|
|
119
149
|
paths, and options are only recognized in real argument position, so
|
|
120
150
|
`git log --grep='--git-dir=/tmp/x'` and `git log -C` are not false positives.
|
|
121
|
-
|
|
151
|
+
The null device is exempt: `>/dev/null` and `< /dev/null` write and read nothing,
|
|
152
|
+
so they are allowed. `GIT_DIR=`/`GIT_WORK_TREE=` are refused both as a command prefix and when
|
|
122
153
|
exported (`export GIT_DIR=/main`), and a `sh -c`/`bash -c` sub-command is refused
|
|
123
154
|
when `blockUnparsableCommands` is enabled.
|
|
124
155
|
|
|
@@ -126,6 +157,15 @@ This guard covers the built-in file tools and these common shell forms. It
|
|
|
126
157
|
cannot contain a program that computes and writes an absolute path itself (for
|
|
127
158
|
example a script or an inlined interpreter).
|
|
128
159
|
|
|
160
|
+
Windows `powershell` is refused outright while the session is isolated rather
|
|
161
|
+
than parsed. It is not one of the tools this extension re-roots, and its language
|
|
162
|
+
is a second grammar — backtick escapes, `Set-Location`, `$env:GIT_DIR`, `--%` —
|
|
163
|
+
so the bash checks would be guessing about a shell that starts in the main
|
|
164
|
+
checkout. Refusing it is the same answer plan mode gives, and `bash` (re-rooted
|
|
165
|
+
and guarded) stays available. `guard.blockPowershell: false` allows the tool
|
|
166
|
+
again; that is a deliberate trade, so it is reported: `/worktree` lists the gap
|
|
167
|
+
and `doctor` reports the switch as `degraded`.
|
|
168
|
+
|
|
129
169
|
## Gitignored files
|
|
130
170
|
|
|
131
171
|
A worktree is a fresh checkout, so untracked, gitignored files such as `.env`
|
|
@@ -187,13 +227,22 @@ use, falling back to directory mtime for a worktree with no record.
|
|
|
187
227
|
writes are guarded (relative paths are not isolated), and `list_worktrees`
|
|
188
228
|
reports it. `skipOverrides` is read at load time, from the process working
|
|
189
229
|
directory.
|
|
230
|
+
- `git branch --merged main` calls a worktree branch merged whenever it matches
|
|
231
|
+
its base, so a worktree that never committed looks landed while its
|
|
232
|
+
uncommitted work is still there. Read `git -C <path> status` before removing
|
|
233
|
+
one; `list_worktrees` reports the same clean/merged flags.
|
|
190
234
|
- The guard is best-effort at the shell level: command substitution, `eval`, and
|
|
191
235
|
`sh -c` are only covered when `blockUnparsableCommands` is enabled, and real
|
|
192
|
-
isolation of arbitrary shell writes needs an OS sandbox.
|
|
236
|
+
isolation of arbitrary shell writes needs an OS sandbox. `/worktree` names the
|
|
237
|
+
gap and counts the commands that passed through it this session.
|
|
193
238
|
- Only the model's `bash` tool is re-rooted and guarded. A shell command the
|
|
194
239
|
user runs directly (`!` in the TUI / the `user_bash` event) is not re-rooted
|
|
195
240
|
and still runs in the main checkout; Pi has no mutable session cwd to change
|
|
196
|
-
that. `/worktree` and `list_worktrees` report the active root
|
|
241
|
+
that. `/worktree` and `list_worktrees` report the active root, and list the
|
|
242
|
+
shell under `Not isolated:` on every status read.
|
|
243
|
+
- Windows `powershell` is refused while isolated, or — with
|
|
244
|
+
`guard.blockPowershell: false` — allowed and unguarded. There is no third
|
|
245
|
+
state: a parsed PowerShell command that keeps the tool.
|
|
197
246
|
|
|
198
247
|
## Non-goals
|
|
199
248
|
|
|
@@ -202,19 +251,19 @@ use, falling back to directory mtime for a worktree with no record.
|
|
|
202
251
|
knows about. It does not rename, rebase, merge, or push branches.
|
|
203
252
|
- **No user-shell re-rooting.** A shell the user runs directly (`!` /
|
|
204
253
|
`user_bash`) is not isolated.
|
|
205
|
-
- **No
|
|
206
|
-
|
|
254
|
+
- **No PowerShell guard.** `powershell` is refused while isolated, not parsed;
|
|
255
|
+
teaching the guard a second grammar belongs with an OS sandbox, not here.
|
|
207
256
|
|
|
208
257
|
## Pi integration
|
|
209
258
|
|
|
210
259
|
| Contract | Detail |
|
|
211
260
|
|---|---|
|
|
212
261
|
| Registration | The default export calls `registerWorktree`: root-tool overrides, the `--worktree` flag, lifecycle listeners, model tools, and the `/worktree` command. Nothing long-lived starts at load. |
|
|
213
|
-
| Tools | `enter_worktree`, `exit_worktree`, `prune_worktrees`, `list_worktrees` via `registerTool`. All `executionMode: "sequential"`. Annotations: `enter_worktree` destructive + open-world; `exit_worktree`/`prune_worktrees` destructive; `list_worktrees` read-only. No `outputSchema`; structured state is returned in tool-result `details`. |
|
|
214
|
-
| Built-in overrides | `read`/`write`/`edit`/`bash`/`grep`/`find`/`ls` are re-registered under the same names with `defaultActive: false`; each spreads the built-in definition and replaces only `execute`. `skipOverrides` is applied at load time. |
|
|
262
|
+
| Tools | `enter_worktree`, `exit_worktree`, `prune_worktrees`, `list_worktrees` via `registerTool`. All `executionMode: "sequential"`. Annotations: `enter_worktree` destructive + open-world; `exit_worktree`/`prune_worktrees` destructive; `list_worktrees` read-only. No `outputSchema`; structured state is returned in tool-result `details` — `exit_worktree` carries `{ state, removed, handoff }`, where `handoff` is `{ branch, base, ahead, merged }`. |
|
|
263
|
+
| Built-in overrides | `read`/`write`/`edit`/`bash`/`grep`/`find`/`ls` are re-registered under the same names with `defaultActive: false`; each spreads the built-in definition and replaces only `execute`. `skipOverrides` is applied at load time. `powershell` is deliberately not overridden: it cannot be re-rooted without a guard, so the `tool_call` handler refuses it (see `guard.blockPowershell`). |
|
|
215
264
|
| Commands | `/worktree` via `registerCommand`, with argument completions for the subcommands and exit flags. |
|
|
216
265
|
| Flags | `--worktree` via `registerFlag`. |
|
|
217
|
-
| Events | `session_start` (bind inherited env, restore or refuse recorded state, read the flag), `session_shutdown` (release the lock, clear status), `before_agent_start` (set the prompt cwd and worktree section), `tool_call` (guard). |
|
|
266
|
+
| Events | `session_start` (bind inherited env, restore or refuse recorded state, read the flag), `session_shutdown` (release the lock, clear status), `before_agent_start` (set the prompt cwd and worktree section), `tool_call` (guard file tools and `bash`, refuse `powershell`, and count a command it lets through unverified). |
|
|
218
267
|
| State | The binding is a custom session entry (`worktree`) written with `pi.appendEntry` and rebuilt from `ctx.sessionManager.getBranch()` on `session_start`. `.pi/worktrees/index.json` is a disposable managed-record cache, reconciled against `git worktree list`. Child processes inherit `PI_WORKTREE_*` env. |
|
|
219
268
|
| UI/events | Status chip via `ctx.ui.setStatus`; `worktree:changed` published on `pi.events`. |
|
|
220
269
|
|
|
@@ -229,6 +278,14 @@ use, falling back to directory mtime for a worktree with no record.
|
|
|
229
278
|
- **Two layers: re-root and guard.** Re-rooting relative paths is not enough on
|
|
230
279
|
its own — an absolute path, `cd`, or `git -C` still escapes. The guard runs
|
|
231
280
|
centrally from the `tool_call` handler, so nested tool calls are covered too.
|
|
281
|
+
- **A gap the user cannot see is a gap they cannot weigh.** Re-rooting is not
|
|
282
|
+
isolating: Pi has no mutable session cwd, so the user's own `!` shell stays in
|
|
283
|
+
the main checkout, an unverifiable command passes by default, and `powershell`
|
|
284
|
+
would run unguarded if it were allowed. Those rules live in `gaps.ts` as data,
|
|
285
|
+
the status text renders them, and the `tool_call` handler counts the one gap the
|
|
286
|
+
transcript cannot show: a command the guard let through without verifying it.
|
|
287
|
+
The two gaps a setting can close are closed — `powershell` is refused, and an
|
|
288
|
+
opt-out is reported by the status and by `doctor`.
|
|
232
289
|
- **Core model: Checkout / Managed record / Binding.** A *Checkout* is a real
|
|
233
290
|
`git worktree` and git is authoritative about it. A *Managed record* is
|
|
234
291
|
provenance and usage in a disposable index. A *Binding* is this session's
|
|
@@ -245,6 +302,12 @@ use, falling back to directory mtime for a worktree with no record.
|
|
|
245
302
|
- **Never force out work.** `exit_worktree` never force-deletes a branch that
|
|
246
303
|
still holds uncommitted changes or commits not reachable from the recorded
|
|
247
304
|
base, even with `remove: true`.
|
|
305
|
+
- **A handoff is read, not performed.** The exit reports the branch's state and
|
|
306
|
+
the command that finishes it, because a merge touches the main checkout and can
|
|
307
|
+
conflict — the user should be the one in that state. The merge target is
|
|
308
|
+
`origin/HEAD`'s branch, falling back to the main checkout's own branch when the
|
|
309
|
+
repository has no remote; when neither resolves, the block names the branch and
|
|
310
|
+
prints no command rather than guessing `main`.
|
|
248
311
|
- **Lock, then prune.** Entering a managed worktree takes a `git worktree lock`
|
|
249
312
|
so a concurrent sweep cannot remove it; prune releases stale locks first and
|
|
250
313
|
keeps any worktree it cannot safely remove, with a reason.
|
|
@@ -262,10 +325,12 @@ use, falling back to directory mtime for a worktree with no record.
|
|
|
262
325
|
| `lifecycle.ts` | Barrel: `enterWorktree` / `exitWorktree` / `pruneWorktrees` / status |
|
|
263
326
|
| `errors.ts` | Cancellation error that distinguishes a user decline from a failure |
|
|
264
327
|
| `enter.ts` / `exit.ts` / `prune.ts` | The lifecycle phases |
|
|
328
|
+
| `handoff.ts` | The `Handoff:` block an exit leaves: the branch's lead, whether the base contains it, and the merge or PR command. |
|
|
265
329
|
| `tools.ts` / `commands.ts` | Model tools / slash commands |
|
|
266
330
|
| `root-tools.ts` | Load-time built-in overrides bound to the active root |
|
|
267
331
|
| `git.ts` | Git plumbing (shared runner + read helpers) |
|
|
268
|
-
| `guard.ts` | Path, shell, and git-redirect isolation checks |
|
|
332
|
+
| `guard.ts` | Path, shell, and git-redirect isolation checks, plus the `powershell` refusal |
|
|
333
|
+
| `gaps.ts` | The isolation gaps a session carries, and how they read (pure) |
|
|
269
334
|
| `include.ts` | `.worktreeinclude` matcher and copy |
|
|
270
335
|
| `config.ts` / `state.ts` / `status.ts` | Config, session entry shape, status text |
|
|
271
336
|
| `registry.ts` | Disposable managed-worktree index + derived status |
|
|
@@ -20,6 +20,15 @@ export interface WorktreeGuardConfig {
|
|
|
20
20
|
blockSymlinkEscapes: boolean;
|
|
21
21
|
/** Block commands whose git behavior cannot be verified. Default: false */
|
|
22
22
|
blockUnparsableCommands: boolean;
|
|
23
|
+
/**
|
|
24
|
+
* Refuse the `powershell` tool while isolated. Default: true
|
|
25
|
+
*
|
|
26
|
+
* Pi's `powershell` tool is neither re-rooted nor guarded — a second grammar
|
|
27
|
+
* (backtick escapes, `Set-Location`, `$env:GIT_DIR`) would need a second parser —
|
|
28
|
+
* so inside a worktree its relative paths resolve at the main checkout. Refusing
|
|
29
|
+
* it is the only honest isolation; `false` allows it and is reported as a gap.
|
|
30
|
+
*/
|
|
31
|
+
blockPowershell: boolean;
|
|
23
32
|
}
|
|
24
33
|
|
|
25
34
|
export interface WorktreeConfig {
|
|
@@ -57,6 +66,7 @@ export const DEFAULT_CONFIG: WorktreeConfig = {
|
|
|
57
66
|
blockGitRedirects: true,
|
|
58
67
|
blockSymlinkEscapes: true,
|
|
59
68
|
blockUnparsableCommands: false,
|
|
69
|
+
blockPowershell: true,
|
|
60
70
|
},
|
|
61
71
|
};
|
|
62
72
|
|
|
@@ -81,6 +91,7 @@ function normalizeGuard(raw: unknown, base: WorktreeGuardConfig): WorktreeGuardC
|
|
|
81
91
|
blockGitRedirects: booleanOr(value.blockGitRedirects, base.blockGitRedirects),
|
|
82
92
|
blockSymlinkEscapes: booleanOr(value.blockSymlinkEscapes, base.blockSymlinkEscapes),
|
|
83
93
|
blockUnparsableCommands: booleanOr(value.blockUnparsableCommands, base.blockUnparsableCommands),
|
|
94
|
+
blockPowershell: booleanOr(value.blockPowershell, base.blockPowershell),
|
|
84
95
|
};
|
|
85
96
|
}
|
|
86
97
|
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
currentBranch,
|
|
18
18
|
fetchPrRef,
|
|
19
19
|
hasCommits,
|
|
20
|
+
isIgnored,
|
|
20
21
|
lockWorktree,
|
|
21
22
|
mergeBase,
|
|
22
23
|
parsePrReference,
|
|
@@ -54,12 +55,19 @@ function generateName(): string {
|
|
|
54
55
|
}
|
|
55
56
|
|
|
56
57
|
/** Offer once per session to ignore the managed worktree directory. */
|
|
57
|
-
async function maybeIgnoreWorktreeDir(
|
|
58
|
+
async function maybeIgnoreWorktreeDir(
|
|
59
|
+
pi: ExtensionAPI,
|
|
60
|
+
ctx: ExtensionContext,
|
|
61
|
+
root: string,
|
|
62
|
+
config: WorktreeConfig,
|
|
63
|
+
): Promise<void> {
|
|
58
64
|
if (isGitignoreOffered()) return;
|
|
59
65
|
if (!ctx.hasUI) return;
|
|
60
66
|
markGitignoreOffered();
|
|
61
67
|
|
|
62
68
|
const entry = `${config.dir.replace(/\/+$/, "")}/`;
|
|
69
|
+
if (await isIgnored(pi, root, entry)) return;
|
|
70
|
+
|
|
63
71
|
const gitignorePath = join(root, ".gitignore");
|
|
64
72
|
let content = "";
|
|
65
73
|
try {
|
|
@@ -67,7 +75,6 @@ async function maybeIgnoreWorktreeDir(ctx: ExtensionContext, root: string, confi
|
|
|
67
75
|
} catch {
|
|
68
76
|
return;
|
|
69
77
|
}
|
|
70
|
-
if (content.split(/\r?\n/).some((line) => line.trim() === entry || line.trim() === config.dir)) return;
|
|
71
78
|
|
|
72
79
|
const confirmed = await ctx.ui.confirm(
|
|
73
80
|
"Add the worktree directory to .gitignore?",
|
|
@@ -225,7 +232,7 @@ export async function enterWorktree(
|
|
|
225
232
|
let copiedIncludes: string[] = [];
|
|
226
233
|
if (!existed) {
|
|
227
234
|
copiedIncludes = await copyIncludes(pi, root, target, includePatterns(root, config.include), config.dir);
|
|
228
|
-
await maybeIgnoreWorktreeDir(ctx, root, config);
|
|
235
|
+
await maybeIgnoreWorktreeDir(pi, ctx, root, config);
|
|
229
236
|
}
|
|
230
237
|
|
|
231
238
|
const state: WorktreeState = {
|
|
@@ -6,7 +6,19 @@
|
|
|
6
6
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
7
7
|
import { loadConfig } from "./config.ts";
|
|
8
8
|
import { WorktreeCancelledError } from "./errors.ts";
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
commitsAheadOf,
|
|
11
|
+
currentBranch,
|
|
12
|
+
defaultBranch,
|
|
13
|
+
deleteBranch,
|
|
14
|
+
inspectWork,
|
|
15
|
+
isBranchMerged,
|
|
16
|
+
lockWorktree,
|
|
17
|
+
refExists,
|
|
18
|
+
unlockWorktree,
|
|
19
|
+
worktreeRemove,
|
|
20
|
+
} from "./git.ts";
|
|
21
|
+
import { handoffLines, type Handoff } from "./handoff.ts";
|
|
10
22
|
import { removeRecord, touchRecord } from "./registry.ts";
|
|
11
23
|
import { applyWorktreeEnv, clearConfigCache, getActive, publishWorktree, setActive, setStatus } from "./runtime.ts";
|
|
12
24
|
import { persistState, type WorktreeState } from "./state.ts";
|
|
@@ -17,11 +29,48 @@ export interface ExitOptions {
|
|
|
17
29
|
keepBranch?: boolean;
|
|
18
30
|
}
|
|
19
31
|
|
|
32
|
+
/**
|
|
33
|
+
* What the branch holds and how to finish it, computed after the outcome is known.
|
|
34
|
+
* A failed lookup degrades to the facts already in hand: an exit that succeeded
|
|
35
|
+
* must not fail because the default branch could not be read.
|
|
36
|
+
*/
|
|
37
|
+
async function describeHandoff(pi: ExtensionAPI, state: WorktreeState, recorded: number): Promise<Handoff> {
|
|
38
|
+
if (!state.branch) return { branch: null, base: null, ahead: recorded, merged: false };
|
|
39
|
+
try {
|
|
40
|
+
// `origin/HEAD` names the merge target when the repository has a remote; a
|
|
41
|
+
// local-only repository has none, and the main checkout's own branch is the
|
|
42
|
+
// branch a merge would actually land on.
|
|
43
|
+
const base = (await defaultBranch(pi, state.repoRoot)) ?? (await currentBranch(pi, state.repoRoot)) ?? null;
|
|
44
|
+
if (!base) return { branch: state.branch, base: null, ahead: recorded, merged: false };
|
|
45
|
+
// Count against the resolved base branch, not the commit recorded at entry:
|
|
46
|
+
// a worktree branch rebased onto a newer local branch would otherwise report
|
|
47
|
+
// every commit the base gained since, as its own.
|
|
48
|
+
// Every lookup runs in the main checkout: the handoff is read after the exit,
|
|
49
|
+
// when the worktree directory may already be gone, while the branch refs it
|
|
50
|
+
// describes outlive it.
|
|
51
|
+
const root = state.repoRoot;
|
|
52
|
+
const ref = (await refExists(pi, root, base))
|
|
53
|
+
? base
|
|
54
|
+
: (await refExists(pi, root, `origin/${base}`))
|
|
55
|
+
? `origin/${base}`
|
|
56
|
+
: null;
|
|
57
|
+
if (!ref) return { branch: state.branch, base: null, ahead: recorded, merged: false };
|
|
58
|
+
return {
|
|
59
|
+
branch: state.branch,
|
|
60
|
+
base,
|
|
61
|
+
ahead: await commitsAheadOf(pi, root, state.branch, ref),
|
|
62
|
+
merged: await isBranchMerged(pi, root, state.branch, ref),
|
|
63
|
+
};
|
|
64
|
+
} catch {
|
|
65
|
+
return { branch: state.branch, base: null, ahead: recorded, merged: false };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
20
69
|
export async function exitWorktree(
|
|
21
70
|
pi: ExtensionAPI,
|
|
22
71
|
ctx: ExtensionContext,
|
|
23
72
|
options: ExitOptions,
|
|
24
|
-
): Promise<{ state: WorktreeState; removed: boolean; output: string }> {
|
|
73
|
+
): Promise<{ state: WorktreeState; removed: boolean; handoff: Handoff; output: string }> {
|
|
25
74
|
const state = getActive();
|
|
26
75
|
if (!state) throw new Error("Not currently in a worktree.");
|
|
27
76
|
if (state.borrowed) {
|
|
@@ -118,5 +167,8 @@ export async function exitWorktree(
|
|
|
118
167
|
touchRecord(state.repoRoot, config, state.path);
|
|
119
168
|
}
|
|
120
169
|
|
|
121
|
-
|
|
170
|
+
const handoff = await describeHandoff(pi, state, ahead);
|
|
171
|
+
lines.push(...handoffLines(handoff));
|
|
172
|
+
|
|
173
|
+
return { state, removed, handoff, output: lines.join("\n") };
|
|
122
174
|
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The isolation gaps a worktree session carries, as data (pure).
|
|
3
|
+
*
|
|
4
|
+
* Re-rooting is not the same as isolating: Pi has no mutable session cwd, so a
|
|
5
|
+
* shell the user runs directly still runs in the main checkout; a command whose
|
|
6
|
+
* git behavior cannot be verified passes the guard unless
|
|
7
|
+
* `blockUnparsableCommands` is set; and the Windows `powershell` tool has no
|
|
8
|
+
* worktree surface, so the guard refuses it unless `blockPowershell` is off.
|
|
9
|
+
* The README documents all three, which is no help mid-task, so the session
|
|
10
|
+
* names them where the user already looks: `/worktree` status, and `doctor`.
|
|
11
|
+
*
|
|
12
|
+
* Only the unverifiable-command gap carries a count. A `!` shell or a
|
|
13
|
+
* `powershell` call is visible in the transcript; a command the guard let
|
|
14
|
+
* through unverified is otherwise silent, so `index.ts` counts those per
|
|
15
|
+
* session and this module says how many ran.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { WorktreeConfig } from "./config.ts";
|
|
19
|
+
|
|
20
|
+
/** One surface isolation does not cover. */
|
|
21
|
+
export interface IsolationGap {
|
|
22
|
+
/** Short label for the surface, e.g. `! shell`. */
|
|
23
|
+
surface: string;
|
|
24
|
+
/** What is not isolated, and why. */
|
|
25
|
+
detail: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The session facts the gap rules read. */
|
|
29
|
+
export interface GapInput {
|
|
30
|
+
/** `process.platform`; `powershell` only exists on Windows. */
|
|
31
|
+
platform: string;
|
|
32
|
+
/** Tool names active in this session, to tell whether `powershell` is offered. */
|
|
33
|
+
activeTools: readonly string[];
|
|
34
|
+
config: WorktreeConfig;
|
|
35
|
+
/** Commands this session let through although their git behavior could not be verified. */
|
|
36
|
+
unverifiableCommands: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The gaps a session carries, in the order a reader should meet them. */
|
|
40
|
+
export function isolationGaps(input: GapInput): IsolationGap[] {
|
|
41
|
+
const gaps: IsolationGap[] = [
|
|
42
|
+
{
|
|
43
|
+
surface: "! shell",
|
|
44
|
+
detail: "runs in the main checkout; Pi has no mutable session cwd",
|
|
45
|
+
},
|
|
46
|
+
];
|
|
47
|
+
// Only while the guard lets the tool through: refusing it closes the gap, and a
|
|
48
|
+
// switch that reopens it is exactly what this row is for.
|
|
49
|
+
if (input.platform === "win32" && input.activeTools.includes("powershell") && !input.config.guard.blockPowershell) {
|
|
50
|
+
gaps.push({
|
|
51
|
+
surface: "powershell",
|
|
52
|
+
detail: "allowed by worktree.guard.blockPowershell: false; not re-rooted or guarded",
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
if (!input.config.guard.blockUnparsableCommands) {
|
|
56
|
+
const ran = input.unverifiableCommands > 0 ? `; ${input.unverifiableCommands} ran this session` : "";
|
|
57
|
+
gaps.push({
|
|
58
|
+
surface: "unverifiable",
|
|
59
|
+
detail: `$(...), backticks, eval, and sh -c pass through (blockUnparsableCommands is off)${ran}`,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
return gaps;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The rendered `Not isolated:` block: one indented line per gap, labels padded
|
|
67
|
+
* to the longest one. No gaps, no lines — a section that says nothing should
|
|
68
|
+
* not be printed.
|
|
69
|
+
*/
|
|
70
|
+
export function gapLines(gaps: readonly IsolationGap[]): string[] {
|
|
71
|
+
if (gaps.length === 0) return [];
|
|
72
|
+
const column = Math.max(...gaps.map((gap) => gap.surface.length));
|
|
73
|
+
return gaps.map((gap) => ` ${gap.surface.padEnd(column)} ${gap.detail}`);
|
|
74
|
+
}
|