@gavin-hu/my-pi-agent 0.19.0 → 0.21.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.
- package/README.md +19 -12
- package/README.zh-CN.md +15 -9
- package/docs/extensions/README.md +2 -1
- package/docs/extensions/board.md +329 -151
- package/docs/extensions/bytefly.md +427 -273
- package/docs/extensions/file-browse.md +46 -44
- package/docs/extensions/job.md +1 -2
- package/docs/extensions/plan.md +86 -24
- package/docs/extensions/serve.md +129 -0
- package/docs/extensions/session-bus.md +23 -10
- package/docs/extensions/team.md +56 -28
- package/docs/extensions/wechat.md +2 -1
- package/docs/extensions/worktree.md +30 -15
- package/extensions/board/commands.ts +48 -17
- package/extensions/board/format.ts +107 -9
- package/extensions/board/index.ts +14 -2
- package/extensions/board/join.ts +31 -10
- package/extensions/board/page.ts +202 -156
- package/extensions/board/result.ts +10 -24
- package/extensions/board/rows.ts +1 -1
- package/extensions/board/schema.ts +31 -10
- package/extensions/board/seat.ts +9 -10
- package/extensions/board/snapshot.ts +7 -5
- package/extensions/board/tools.ts +211 -34
- package/extensions/board/transport/router.ts +27 -110
- package/extensions/board/transport/server.ts +76 -56
- package/extensions/board/types.ts +57 -0
- package/extensions/board/view/client.ts +24 -13
- package/extensions/board/view/dialog.ts +43 -0
- package/extensions/board/view/keys.ts +28 -24
- package/extensions/board/view/live.ts +6 -178
- package/extensions/board/view/names.ts +91 -0
- package/extensions/board/view/page.ts +60 -89
- package/extensions/board/view/render.ts +228 -200
- package/extensions/board/view/styles.ts +72 -49
- package/extensions/board/view/team.ts +63 -41
- package/extensions/bytefly/footer/budget.ts +6 -5
- package/extensions/bytefly/footer/collect/entries.ts +12 -4
- package/extensions/bytefly/footer/collect/snapshot.ts +8 -17
- package/extensions/bytefly/footer/config.ts +19 -11
- package/extensions/bytefly/footer/price/price.ts +30 -8
- package/extensions/bytefly/footer/register.ts +5 -5
- package/extensions/bytefly/footer/render/footer.ts +18 -4
- package/extensions/bytefly/footer/render/format.ts +18 -0
- package/extensions/bytefly/footer/render/key.ts +46 -0
- package/extensions/bytefly/footer/render/layout.ts +13 -3
- package/extensions/bytefly/footer/render/lines.ts +23 -7
- package/extensions/bytefly/footer/render/segments.ts +93 -31
- package/extensions/bytefly/footer/types.ts +29 -5
- package/extensions/bytefly/footer/unpriced.ts +42 -17
- package/extensions/bytefly/header/component.ts +4 -20
- package/extensions/bytefly/panel/commands.ts +28 -1
- package/extensions/bytefly/panel/filter.ts +68 -0
- package/extensions/bytefly/panel/format.ts +16 -15
- package/extensions/bytefly/panel/join.ts +56 -13
- package/extensions/bytefly/panel/prices.ts +43 -0
- package/extensions/bytefly/panel/settings.ts +72 -23
- package/extensions/bytefly/panel/tui/entries.ts +26 -8
- package/extensions/bytefly/panel/tui/frame.ts +130 -93
- package/extensions/bytefly/panel/tui/keys.ts +352 -0
- package/extensions/bytefly/panel/tui/list-view.ts +25 -46
- package/extensions/bytefly/panel/tui/panes.ts +76 -0
- package/extensions/bytefly/panel/tui/section-view.ts +126 -0
- package/extensions/bytefly/panel/tui/settings-section.ts +54 -125
- package/extensions/bytefly/panel/tui/settings-view.ts +30 -131
- package/extensions/bytefly/panel/tui/side-pane.ts +224 -0
- package/extensions/bytefly/panel/tui.ts +306 -259
- package/extensions/check/commands.ts +2 -1
- package/extensions/check/render.ts +10 -1
- package/extensions/file-browse/config/config.ts +4 -19
- package/extensions/file-browse/files/classify.ts +136 -0
- package/extensions/file-browse/files/files.ts +8 -547
- package/extensions/file-browse/files/index.ts +90 -0
- package/extensions/file-browse/files/list.ts +261 -0
- package/extensions/file-browse/files/tree.ts +101 -0
- package/extensions/file-browse/index.ts +27 -48
- package/extensions/file-browse/live/live.ts +1 -1
- package/extensions/file-browse/transport/router.ts +12 -6
- package/extensions/file-browse/transport/server.ts +61 -28
- package/extensions/file-browse/view/chrome.ts +4 -4
- package/extensions/file-browse/view/client.ts +3 -1
- package/extensions/file-browse/view/emit.ts +51 -0
- package/extensions/file-browse/view/html.ts +78 -46
- package/extensions/file-browse/view/live-client.ts +2 -2
- package/extensions/file-browse/view/markdown.ts +23 -26
- package/extensions/file-browse/view/palette.ts +2 -2
- package/extensions/file-browse/view/raw-html.ts +27 -22
- package/extensions/file-browse/view/tree.ts +1 -1
- package/extensions/file-browse/view/urls.ts +20 -6
- package/extensions/goal/tui.ts +12 -2
- package/extensions/job/runtime.ts +4 -4
- package/extensions/job/tools.ts +19 -7
- package/extensions/job/tui.ts +2 -28
- package/extensions/plan/commands.ts +2 -2
- package/extensions/plan/feedback.ts +88 -18
- package/extensions/plan/format.ts +10 -3
- package/extensions/plan/index.ts +24 -2
- package/extensions/plan/plan-files.ts +88 -0
- package/extensions/plan/plan-text.ts +114 -0
- package/extensions/plan/plans.ts +19 -23
- package/extensions/plan/policy.ts +10 -5
- package/extensions/plan/runtime.ts +43 -4
- package/extensions/plan/todos.ts +51 -3
- package/extensions/plan/tools/exit.ts +10 -1
- package/extensions/plan/tools/todo-seed.ts +4 -2
- package/extensions/plan/tools/write.ts +12 -1
- package/extensions/plan/tools.ts +2 -2
- package/extensions/plan/types.ts +15 -0
- package/extensions/plan/web-blocks.ts +160 -0
- package/extensions/plan/web-chrome.ts +7 -5
- package/extensions/plan/web-client.ts +271 -325
- package/extensions/plan/web-collab.ts +7 -7
- package/extensions/plan/web-comments.ts +57 -16
- package/extensions/plan/web-composer.ts +91 -0
- package/extensions/plan/web-draft.ts +40 -0
- package/extensions/plan/web-keys.ts +258 -0
- package/extensions/plan/web-markdown.ts +1 -1
- package/extensions/plan/web-outline.ts +212 -0
- package/extensions/plan/web-page.ts +59 -86
- package/extensions/plan/web-payload.ts +188 -0
- package/extensions/plan/web-plan.ts +160 -0
- package/extensions/plan/web-router.ts +0 -1
- package/extensions/plan/web-settle.ts +201 -0
- package/extensions/plan/web-share-session.ts +24 -55
- package/extensions/plan/web-share.ts +19 -37
- package/extensions/plan/web-write.ts +112 -0
- package/extensions/plan/web.ts +77 -130
- package/extensions/rewind/commands/review.ts +2 -1
- package/extensions/serve/config.ts +37 -0
- package/extensions/serve/index.ts +71 -0
- package/extensions/serve/registry.ts +251 -0
- package/extensions/session-bus/join-request.ts +16 -9
- package/extensions/session-bus/result.ts +1 -27
- package/extensions/session-bus/rows.ts +7 -3
- package/extensions/session-bus/runtime.ts +100 -38
- package/extensions/session-bus/store.ts +14 -2
- package/extensions/session-bus/tools.ts +5 -4
- package/extensions/session-bus/tui.ts +1 -1
- package/extensions/session-bus/types.ts +10 -1
- package/extensions/subagent/commands.ts +2 -1
- package/extensions/team/commands.ts +44 -9
- package/extensions/team/format.ts +14 -0
- package/extensions/team/index.ts +2 -1
- package/extensions/team/tools.ts +30 -9
- package/extensions/team/types.ts +17 -0
- package/extensions/todo/index.ts +4 -1
- package/extensions/todo/nudge.ts +8 -2
- package/extensions/todo/tui.ts +21 -3
- package/extensions/wechat/bridge.ts +43 -172
- package/extensions/wechat/intake.ts +245 -0
- package/extensions/worktree/commands.ts +24 -17
- package/extensions/worktree/exit.ts +12 -6
- package/extensions/worktree/git.ts +22 -14
- package/extensions/worktree/guard.ts +15 -285
- package/extensions/worktree/handoff.ts +10 -2
- package/extensions/worktree/index.ts +2 -1
- package/extensions/worktree/lifecycle.ts +2 -0
- package/extensions/worktree/registry.ts +20 -11
- package/extensions/worktree/root-tools.ts +13 -3
- package/extensions/worktree/runtime.ts +8 -2
- package/extensions/worktree/screen.ts +15 -8
- package/extensions/worktree/shell.ts +303 -0
- package/extensions/worktree/state.ts +1 -1
- package/extensions/worktree/status.ts +42 -9
- package/extensions/worktree/tools.ts +19 -13
- package/lib/README.md +9 -7
- package/lib/board/card.ts +192 -3
- package/lib/board/links.ts +26 -2
- package/lib/board/page-pointer.ts +111 -32
- package/lib/board/round-text.ts +11 -2
- package/lib/board/round.ts +25 -20
- package/lib/board/rules.ts +144 -11
- package/lib/board/session.ts +84 -18
- package/lib/board/store.ts +268 -55
- package/lib/board/view.ts +114 -24
- package/lib/bus-request.ts +83 -0
- package/lib/format.ts +22 -0
- package/lib/http/index.ts +11 -7
- package/lib/http/mount.ts +142 -0
- package/lib/http/port.ts +25 -0
- package/lib/http/serve.ts +170 -43
- package/lib/http/server.ts +3 -3
- package/lib/http/sink.ts +34 -9
- package/lib/plan-entry.ts +20 -13
- package/lib/policy.ts +14 -1
- package/lib/requirements.ts +2 -2
- package/lib/session-label.ts +23 -7
- package/lib/session-roster.ts +74 -3
- package/lib/session-tool.ts +37 -21
- package/lib/spawn.ts +37 -3
- package/lib/stray-fields.ts +45 -0
- package/lib/tool-names.ts +13 -0
- package/lib/tui/README.md +6 -3
- package/lib/tui/glyphs.ts +3 -6
- package/lib/tui/render-cache.ts +29 -0
- package/lib/tui/tool.ts +30 -1
- package/lib/web/README.md +52 -2
- package/lib/web/client-runtime.ts +149 -0
- package/lib/web/index.ts +4 -2
- package/lib/web/markup.ts +106 -2
- package/lib/web/names.ts +42 -0
- package/package.json +3 -2
- package/extensions/board/config.ts +0 -36
- package/extensions/board/view/actions.ts +0 -306
- package/extensions/board/writes.ts +0 -99
- package/extensions/bytefly/panel/tui/extensions-view.ts +0 -40
- package/extensions/bytefly/panel/tui/skills-view.ts +0 -50
- package/extensions/job/process.ts +0 -35
|
@@ -171,7 +171,19 @@ export async function worktreeExists(pi: ExtensionAPI, dir: string): Promise<boo
|
|
|
171
171
|
.some((line) => line.startsWith("worktree ") && realPathOfNearest(line.slice("worktree ".length).trim()) === want);
|
|
172
172
|
}
|
|
173
173
|
|
|
174
|
-
/**
|
|
174
|
+
/**
|
|
175
|
+
* `rev-list --count` for one range, e.g. `main..HEAD`; 0 when git cannot answer.
|
|
176
|
+
*
|
|
177
|
+
* The three counts below are the same command with a different range, and a caller that
|
|
178
|
+
* reads a count as "no work here" must not have a failed read look like zero work.
|
|
179
|
+
*/
|
|
180
|
+
async function revListCount(pi: ExtensionAPI, dir: string, range: string): Promise<number> {
|
|
181
|
+
const result = await git(pi, ["rev-list", "--count", range], dir);
|
|
182
|
+
if (result.code !== 0) return 0;
|
|
183
|
+
const count = Number.parseInt(result.stdout.trim(), 10);
|
|
184
|
+
return Number.isFinite(count) ? count : 0;
|
|
185
|
+
}
|
|
186
|
+
|
|
175
187
|
/**
|
|
176
188
|
* Count of commits on `branch` that `base` does not contain.
|
|
177
189
|
*
|
|
@@ -179,10 +191,7 @@ export async function worktreeExists(pi: ExtensionAPI, dir: string): Promise<boo
|
|
|
179
191
|
* after the worktree may already be gone while its branch refs outlive it.
|
|
180
192
|
*/
|
|
181
193
|
export async function commitsAheadOf(pi: ExtensionAPI, dir: string, branch: string, base: string): Promise<number> {
|
|
182
|
-
|
|
183
|
-
if (result.code !== 0) return 0;
|
|
184
|
-
const count = Number.parseInt(result.stdout.trim(), 10);
|
|
185
|
-
return Number.isFinite(count) ? count : 0;
|
|
194
|
+
return revListCount(pi, dir, `${base}..${branch}`);
|
|
186
195
|
}
|
|
187
196
|
|
|
188
197
|
/** Whether `ref` resolves here: a branch, a remote-tracking ref, or a tag. */
|
|
@@ -190,6 +199,7 @@ export async function refExists(pi: ExtensionAPI, dir: string, ref: string): Pro
|
|
|
190
199
|
return (await revParse(createExecRunner(pi), dir, ref)) !== undefined;
|
|
191
200
|
}
|
|
192
201
|
|
|
202
|
+
/** Whether a local branch exists. */
|
|
193
203
|
export async function branchExists(pi: ExtensionAPI, repoRoot: string, branch: string): Promise<boolean> {
|
|
194
204
|
const result = await git(pi, ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], repoRoot);
|
|
195
205
|
return result.code === 0;
|
|
@@ -257,18 +267,12 @@ export async function statusEntries(pi: ExtensionAPI, dir: string): Promise<stri
|
|
|
257
267
|
|
|
258
268
|
/** Count of commits reachable from HEAD but not from `base`. */
|
|
259
269
|
export async function commitsAhead(pi: ExtensionAPI, dir: string, base: string): Promise<number> {
|
|
260
|
-
|
|
261
|
-
if (result.code !== 0) return 0;
|
|
262
|
-
const count = Number.parseInt(result.stdout.trim(), 10);
|
|
263
|
-
return Number.isFinite(count) ? count : 0;
|
|
270
|
+
return revListCount(pi, dir, `${base}..HEAD`);
|
|
264
271
|
}
|
|
265
272
|
|
|
266
273
|
/** Count of commits reachable from `base` but not from HEAD. */
|
|
267
274
|
export async function commitsBehind(pi: ExtensionAPI, dir: string, base: string): Promise<number> {
|
|
268
|
-
|
|
269
|
-
if (result.code !== 0) return 0;
|
|
270
|
-
const count = Number.parseInt(result.stdout.trim(), 10);
|
|
271
|
-
return Number.isFinite(count) ? count : 0;
|
|
275
|
+
return revListCount(pi, dir, `HEAD..${base}`);
|
|
272
276
|
}
|
|
273
277
|
|
|
274
278
|
/** Whether `branch` is fully contained in `ref` (already merged). */
|
|
@@ -283,7 +287,11 @@ export async function mergeBase(pi: ExtensionAPI, dir: string, ref: string): Pro
|
|
|
283
287
|
return result.code === 0 ? result.stdout.trim() || undefined : undefined;
|
|
284
288
|
}
|
|
285
289
|
|
|
286
|
-
|
|
290
|
+
/** Why a checkout is unusable, as `checkCheckout` reports it. */
|
|
291
|
+
export type CheckoutRefusal = { ok: false; reason: "gone" | "unsafe" | "unverified"; detail: string };
|
|
292
|
+
|
|
293
|
+
/** The verdict on a recorded worktree: usable, or the refusal that says why not. */
|
|
294
|
+
export type CheckoutCheck = { ok: true } | CheckoutRefusal;
|
|
287
295
|
|
|
288
296
|
/**
|
|
289
297
|
* Verify a directory is a usable, separate checkout before restoring it.
|
|
@@ -12,12 +12,24 @@
|
|
|
12
12
|
* 5. the un-re-rooted `powershell` tool, which has no guard of its own.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import {
|
|
16
|
-
import { isAbsolute, join, resolve } from "node:path";
|
|
15
|
+
import { isAbsolute, resolve } from "node:path";
|
|
17
16
|
import { isInside, isInsideReal, realPathOfNearest } from "../../lib/path.ts";
|
|
18
17
|
import { WORKSPACE_READERS } from "../../lib/policy.ts";
|
|
19
|
-
import { isShellEscapable } from "../../lib/shell.ts";
|
|
20
18
|
import type { WorktreeConfig } from "./config.ts";
|
|
19
|
+
import {
|
|
20
|
+
COMMAND_WRAPPERS,
|
|
21
|
+
isAssignment,
|
|
22
|
+
isUnverifiable,
|
|
23
|
+
pathEscapes,
|
|
24
|
+
redirectionTargets,
|
|
25
|
+
splitSegments,
|
|
26
|
+
type ShellWord,
|
|
27
|
+
tokenize,
|
|
28
|
+
} from "./shell.ts";
|
|
29
|
+
|
|
30
|
+
// The reader's public names, so a caller keeps importing the guards from here.
|
|
31
|
+
export { isUnverifiable };
|
|
32
|
+
export type { Redirect, ShellWord } from "./shell.ts";
|
|
21
33
|
|
|
22
34
|
export interface GuardBlock {
|
|
23
35
|
block: true;
|
|
@@ -80,281 +92,6 @@ export function guardFileTool(
|
|
|
80
92
|
return undefined;
|
|
81
93
|
}
|
|
82
94
|
|
|
83
|
-
// ---------------------------------------------------------------------------
|
|
84
|
-
// Shell commands (checks 2-4)
|
|
85
|
-
// ---------------------------------------------------------------------------
|
|
86
|
-
|
|
87
|
-
/** One shell word, with whether it began inside a quote (a quoted literal). */
|
|
88
|
-
interface ShellWord {
|
|
89
|
-
text: string;
|
|
90
|
-
leadingQuoted: boolean;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/** Split a command on unquoted `;`, `&`, `|`, and newlines, preserving quotes. */
|
|
94
|
-
function splitSegments(command: string): string[] {
|
|
95
|
-
const segments: string[] = [];
|
|
96
|
-
let current = "";
|
|
97
|
-
let quote: string | null = null;
|
|
98
|
-
for (let i = 0; i < command.length; i++) {
|
|
99
|
-
const ch = command[i];
|
|
100
|
-
if (quote) {
|
|
101
|
-
current += ch;
|
|
102
|
-
if (ch === "\\" && quote === '"' && i + 1 < command.length) {
|
|
103
|
-
const next = command[i + 1];
|
|
104
|
-
if (isShellEscapable(next)) current += command[++i];
|
|
105
|
-
} else if (ch === quote) {
|
|
106
|
-
quote = null;
|
|
107
|
-
}
|
|
108
|
-
continue;
|
|
109
|
-
}
|
|
110
|
-
if (ch === "'" || ch === '"') {
|
|
111
|
-
quote = ch;
|
|
112
|
-
current += ch;
|
|
113
|
-
} else if (ch === ";" || ch === "\n") {
|
|
114
|
-
segments.push(current);
|
|
115
|
-
current = "";
|
|
116
|
-
} else if (ch === "&") {
|
|
117
|
-
const prev = command[i - 1];
|
|
118
|
-
const next = command[i + 1];
|
|
119
|
-
if (next === "&") {
|
|
120
|
-
// `&&` is a command separator.
|
|
121
|
-
segments.push(current);
|
|
122
|
-
current = "";
|
|
123
|
-
i++;
|
|
124
|
-
} else if (prev === ">" || prev === "<" || next === ">") {
|
|
125
|
-
// `>&`, `<&`, `&>`: redirection, not a separator.
|
|
126
|
-
current += ch;
|
|
127
|
-
} else {
|
|
128
|
-
// Background job `&`.
|
|
129
|
-
segments.push(current);
|
|
130
|
-
current = "";
|
|
131
|
-
}
|
|
132
|
-
} else if (ch === "|") {
|
|
133
|
-
if (command[i + 1] === "&") i++; // `|&` is still a pipe separator.
|
|
134
|
-
segments.push(current);
|
|
135
|
-
current = "";
|
|
136
|
-
} else {
|
|
137
|
-
current += ch;
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
segments.push(current);
|
|
141
|
-
return segments;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
/** Tokenize a shell segment into words, removing quotes and tracking quoted leads. */
|
|
145
|
-
function tokenize(segment: string): ShellWord[] {
|
|
146
|
-
const words: ShellWord[] = [];
|
|
147
|
-
let current = "";
|
|
148
|
-
let leadingQuoted = false;
|
|
149
|
-
let started = false;
|
|
150
|
-
let quote: string | null = null;
|
|
151
|
-
const push = () => {
|
|
152
|
-
if (started) words.push({ text: current, leadingQuoted });
|
|
153
|
-
current = "";
|
|
154
|
-
leadingQuoted = false;
|
|
155
|
-
started = false;
|
|
156
|
-
};
|
|
157
|
-
for (let i = 0; i < segment.length; i++) {
|
|
158
|
-
const ch = segment[i];
|
|
159
|
-
if (quote) {
|
|
160
|
-
if (ch === "\\" && quote === '"' && i + 1 < segment.length) {
|
|
161
|
-
const next = segment[i + 1];
|
|
162
|
-
if (isShellEscapable(next)) current += segment[++i];
|
|
163
|
-
else current += ch;
|
|
164
|
-
} else if (ch === quote) {
|
|
165
|
-
quote = null;
|
|
166
|
-
} else {
|
|
167
|
-
current += ch;
|
|
168
|
-
}
|
|
169
|
-
continue;
|
|
170
|
-
}
|
|
171
|
-
if (ch === "'" || ch === '"') {
|
|
172
|
-
if (!started) {
|
|
173
|
-
started = true;
|
|
174
|
-
leadingQuoted = true;
|
|
175
|
-
}
|
|
176
|
-
quote = ch;
|
|
177
|
-
} else if (ch === " " || ch === "\t") {
|
|
178
|
-
push();
|
|
179
|
-
} else if (ch === "\\" && i + 1 < segment.length) {
|
|
180
|
-
started = true;
|
|
181
|
-
const next = segment[i + 1];
|
|
182
|
-
if (isShellEscapable(next)) current += segment[++i];
|
|
183
|
-
else current += ch;
|
|
184
|
-
} else {
|
|
185
|
-
started = true;
|
|
186
|
-
current += ch;
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
push();
|
|
190
|
-
return words;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
/**
|
|
194
|
-
* Expand `~` and environment variables so a path that only leaves the worktree
|
|
195
|
-
* after shell expansion is still caught. `unresolved` marks a value that cannot
|
|
196
|
-
* be expanded statically (unknown variable, `$`, backtick, `~user`); callers
|
|
197
|
-
* treat it as escaping, because guessing could allow a bypass.
|
|
198
|
-
*/
|
|
199
|
-
function expandShellPath(raw: string): { path?: string; unresolved: boolean } {
|
|
200
|
-
let value = raw;
|
|
201
|
-
if (value === "~") value = homedir();
|
|
202
|
-
else if (value.startsWith("~/")) value = join(homedir(), value.slice(2));
|
|
203
|
-
else if (value.startsWith("~")) return { unresolved: true };
|
|
204
|
-
|
|
205
|
-
let unresolved = false;
|
|
206
|
-
value = value.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)/g, (match, braced, plain) => {
|
|
207
|
-
const name = braced ?? plain;
|
|
208
|
-
const env = process.env[name];
|
|
209
|
-
if (env === undefined) {
|
|
210
|
-
unresolved = true;
|
|
211
|
-
return match;
|
|
212
|
-
}
|
|
213
|
-
return env;
|
|
214
|
-
});
|
|
215
|
-
if (value.includes("$") || value.includes("`")) unresolved = true;
|
|
216
|
-
return { path: value, unresolved };
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
/** Whether a shell path argument leaves the worktree once expanded. */
|
|
220
|
-
function pathEscapes(root: string, raw: string): boolean {
|
|
221
|
-
const expanded = expandShellPath(raw);
|
|
222
|
-
if (expanded.unresolved || !expanded.path) return true;
|
|
223
|
-
return !isInside(root, resolve(root, expanded.path));
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
/**
|
|
227
|
-
* Constructs whose text does not spell out the git command that runs. Exported
|
|
228
|
-
* so the `tool_call` handler counts exactly the commands this check would have
|
|
229
|
-
* refused, instead of guessing at a second list.
|
|
230
|
-
*/
|
|
231
|
-
export function isUnverifiable(command: string): boolean {
|
|
232
|
-
return /\$\(|`|\$\{!|\beval\b|\b(?:sh|bash|zsh|dash|ksh)\s+-c\b/.test(command);
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
// ---------------------------------------------------------------------------
|
|
236
|
-
// Shell redirections (check 1b)
|
|
237
|
-
// ---------------------------------------------------------------------------
|
|
238
|
-
|
|
239
|
-
interface Redirect {
|
|
240
|
-
target: string;
|
|
241
|
-
/** True for output/read-write redirections; false for input-only. */
|
|
242
|
-
write: boolean;
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
/** Read a shell word starting at `start`, skipping leading blanks, unquoting the result. */
|
|
246
|
-
function readShellWord(text: string, start: number): { raw: string; end: number } {
|
|
247
|
-
let i = start;
|
|
248
|
-
while (i < text.length && (text[i] === " " || text[i] === "\t")) i++;
|
|
249
|
-
let raw = "";
|
|
250
|
-
let quote: string | null = null;
|
|
251
|
-
while (i < text.length) {
|
|
252
|
-
const ch = text[i];
|
|
253
|
-
if (quote) {
|
|
254
|
-
if (ch === "\\" && quote === '"' && i + 1 < text.length) {
|
|
255
|
-
const next = text[i + 1];
|
|
256
|
-
if (isShellEscapable(next)) {
|
|
257
|
-
raw += next;
|
|
258
|
-
i += 2;
|
|
259
|
-
} else {
|
|
260
|
-
raw += ch;
|
|
261
|
-
i += 1;
|
|
262
|
-
}
|
|
263
|
-
continue;
|
|
264
|
-
}
|
|
265
|
-
if (ch === quote) {
|
|
266
|
-
quote = null;
|
|
267
|
-
i++;
|
|
268
|
-
continue;
|
|
269
|
-
}
|
|
270
|
-
raw += ch;
|
|
271
|
-
i++;
|
|
272
|
-
continue;
|
|
273
|
-
}
|
|
274
|
-
if (ch === "'" || ch === '"') {
|
|
275
|
-
quote = ch;
|
|
276
|
-
i++;
|
|
277
|
-
continue;
|
|
278
|
-
}
|
|
279
|
-
if (ch === "\\" && i + 1 < text.length) {
|
|
280
|
-
const next = text[i + 1];
|
|
281
|
-
if (isShellEscapable(next)) {
|
|
282
|
-
raw += next;
|
|
283
|
-
i += 2;
|
|
284
|
-
} else {
|
|
285
|
-
raw += ch;
|
|
286
|
-
i += 1;
|
|
287
|
-
}
|
|
288
|
-
continue;
|
|
289
|
-
}
|
|
290
|
-
if (ch === " " || ch === "\t") break;
|
|
291
|
-
raw += ch;
|
|
292
|
-
i++;
|
|
293
|
-
}
|
|
294
|
-
return { raw, end: i };
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
/**
|
|
298
|
-
* File redirection targets in one shell segment. File-descriptor duplications
|
|
299
|
-
* (`2>&1`), heredocs (`<<EOF`), and here-strings (`<<<x`) are skipped because
|
|
300
|
-
* their operand is not a path.
|
|
301
|
-
*/
|
|
302
|
-
function redirectionTargets(segment: string): Redirect[] {
|
|
303
|
-
const redirects: Redirect[] = [];
|
|
304
|
-
let quote: string | null = null;
|
|
305
|
-
for (let i = 0; i < segment.length; i++) {
|
|
306
|
-
const ch = segment[i];
|
|
307
|
-
if (quote) {
|
|
308
|
-
if (ch === "\\" && quote === '"' && i + 1 < segment.length) {
|
|
309
|
-
i++;
|
|
310
|
-
continue;
|
|
311
|
-
}
|
|
312
|
-
if (ch === quote) quote = null;
|
|
313
|
-
continue;
|
|
314
|
-
}
|
|
315
|
-
if (ch === "'" || ch === '"') {
|
|
316
|
-
quote = ch;
|
|
317
|
-
continue;
|
|
318
|
-
}
|
|
319
|
-
if (ch === "\\") {
|
|
320
|
-
i++;
|
|
321
|
-
continue;
|
|
322
|
-
}
|
|
323
|
-
if (ch !== ">" && ch !== "<") continue;
|
|
324
|
-
|
|
325
|
-
const two = segment.slice(i, i + 2);
|
|
326
|
-
const three = segment.slice(i, i + 3);
|
|
327
|
-
if (three === "<<<") {
|
|
328
|
-
i += 2;
|
|
329
|
-
continue;
|
|
330
|
-
}
|
|
331
|
-
if (two === "<<") {
|
|
332
|
-
// Heredoc delimiter: not a path.
|
|
333
|
-
i += 1;
|
|
334
|
-
continue;
|
|
335
|
-
}
|
|
336
|
-
if (two === ">&" || two === "<&") {
|
|
337
|
-
// File-descriptor duplication (`2>&1`, `>&-`) is not a path, but bash
|
|
338
|
-
// treats `>&file` as `&>file` (stdout+stderr to a file).
|
|
339
|
-
const operand = readShellWord(segment, i + 2);
|
|
340
|
-
if (operand.raw === "" || /^\d+$/.test(operand.raw) || operand.raw === "-") {
|
|
341
|
-
i += 1;
|
|
342
|
-
continue;
|
|
343
|
-
}
|
|
344
|
-
redirects.push({ target: operand.raw, write: ch === ">" });
|
|
345
|
-
i = operand.end - 1;
|
|
346
|
-
continue;
|
|
347
|
-
}
|
|
348
|
-
// `>`, `>>`, `>|`, `<`, `<>`: the next word is a path.
|
|
349
|
-
const write = ch === ">" || two === "<>";
|
|
350
|
-
if (two === ">>" || two === ">|") i += 1;
|
|
351
|
-
const word = readShellWord(segment, i + 1);
|
|
352
|
-
if (word.raw) redirects.push({ target: word.raw, write });
|
|
353
|
-
i = word.end - 1;
|
|
354
|
-
}
|
|
355
|
-
return redirects;
|
|
356
|
-
}
|
|
357
|
-
|
|
358
95
|
/** Refuse an output/input redirection whose target leaves the worktree. */
|
|
359
96
|
function redirectionEscapes(root: string, command: string, config: WorktreeConfig): string | undefined {
|
|
360
97
|
const checkWrites = config.guard.blockFileEscapes;
|
|
@@ -381,13 +118,6 @@ function isNullDevice(raw: string): boolean {
|
|
|
381
118
|
return raw.replace(/^(['"])(.*)\1$/, "$2") === "/dev/null";
|
|
382
119
|
}
|
|
383
120
|
|
|
384
|
-
/** Commands that wrap another command, so a following assignment is still a prefix. */
|
|
385
|
-
const COMMAND_WRAPPERS = new Set(["sudo", "doas", "env", "command", "builtin", "exec", "nohup", "nice", "time"]);
|
|
386
|
-
|
|
387
|
-
function isAssignment(word: string): boolean {
|
|
388
|
-
return /^[A-Za-z_][A-Za-z0-9_]*=/.test(word);
|
|
389
|
-
}
|
|
390
|
-
|
|
391
121
|
/** A leading `GIT_DIR=`/`GIT_WORK_TREE=` assignment that points outside. */
|
|
392
122
|
function envGitRedirect(root: string, words: ShellWord[]): string | undefined {
|
|
393
123
|
for (let i = 0; i < words.length; i++) {
|
|
@@ -20,7 +20,12 @@ export interface Handoff {
|
|
|
20
20
|
base: string | null;
|
|
21
21
|
/** Commits on the branch that the base does not contain. */
|
|
22
22
|
ahead: number;
|
|
23
|
-
/**
|
|
23
|
+
/**
|
|
24
|
+
* Commits the branch has made since the commit it was created at, when that was
|
|
25
|
+
* recorded. Absent for a worktree this package did not create.
|
|
26
|
+
*/
|
|
27
|
+
commits?: number;
|
|
28
|
+
/** Whether the work landed: every commit is in the base, and there were commits. */
|
|
24
29
|
merged: boolean;
|
|
25
30
|
}
|
|
26
31
|
|
|
@@ -43,7 +48,10 @@ export function handoffLines(handoff: Handoff): string[] {
|
|
|
43
48
|
];
|
|
44
49
|
}
|
|
45
50
|
if (handoff.merged || handoff.ahead === 0) {
|
|
46
|
-
|
|
51
|
+
// "Merged" is a claim that work landed; a branch with no commits of its own never had
|
|
52
|
+
// any to land, and saying so is what stops an empty worktree reading as finished.
|
|
53
|
+
const landed = handoff.merged && handoff.commits !== 0;
|
|
54
|
+
const state = landed ? `is merged into ${handoff.base}` : `has no commits beyond ${handoff.base}`;
|
|
47
55
|
return [`Handoff: ${handoff.branch} ${state} — nothing to merge.`];
|
|
48
56
|
}
|
|
49
57
|
|
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
isWorktreeCancelled,
|
|
35
35
|
refusalMessage,
|
|
36
36
|
worktreeFacts,
|
|
37
|
+
enteredWorktree,
|
|
37
38
|
worktreeLabel,
|
|
38
39
|
} from "./lifecycle.ts";
|
|
39
40
|
import { registerCommands } from "./commands.ts";
|
|
@@ -160,7 +161,7 @@ export function registerWorktree(pi: ExtensionAPI, { entryPath, createBuiltin }:
|
|
|
160
161
|
const name = flag.trim() === "true" ? undefined : flag.trim();
|
|
161
162
|
try {
|
|
162
163
|
const { state } = await enterWorktree(pi, ctx, { name });
|
|
163
|
-
ctx.ui.notify(
|
|
164
|
+
ctx.ui.notify(enteredWorktree(state), "info");
|
|
164
165
|
} catch (error) {
|
|
165
166
|
if (isWorktreeCancelled(error)) {
|
|
166
167
|
ctx.ui.notify(error instanceof Error ? error.message : String(error), "info");
|
|
@@ -12,6 +12,8 @@ export { exitWorktree, type ExitOptions } from "./exit.ts";
|
|
|
12
12
|
export { pruneWorktrees } from "./prune.ts";
|
|
13
13
|
export { findInactiveOverrides } from "./root-tools.ts";
|
|
14
14
|
export {
|
|
15
|
+
enteredWorktree,
|
|
16
|
+
exitedWorktree,
|
|
15
17
|
formatWorktreeStatus,
|
|
16
18
|
refusalMessage,
|
|
17
19
|
stateSummary,
|
|
@@ -6,8 +6,9 @@
|
|
|
6
6
|
* is a disposable cache: a missing or corrupt file loads as empty, writes are
|
|
7
7
|
* best-effort, and reads reconcile against `git worktree list`.
|
|
8
8
|
*
|
|
9
|
-
* `statusOf` is the single derived view
|
|
10
|
-
*
|
|
9
|
+
* `statusOf` is the single derived view the status listing reads, so the text and the
|
|
10
|
+
* screen do not each re-derive dirty/ahead/behind/merged state; exit reads the one fact it
|
|
11
|
+
* needs (`commitsAheadOf`) without it.
|
|
11
12
|
*/
|
|
12
13
|
|
|
13
14
|
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
@@ -15,14 +16,7 @@ import { dirname, join } from "node:path";
|
|
|
15
16
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
16
17
|
import { realPathOfNearest } from "../../lib/path.ts";
|
|
17
18
|
import type { WorktreeConfig } from "./config.ts";
|
|
18
|
-
import {
|
|
19
|
-
commitsAhead,
|
|
20
|
-
commitsBehind,
|
|
21
|
-
isBranchMerged,
|
|
22
|
-
isStaleLock,
|
|
23
|
-
type ManagedWorktree,
|
|
24
|
-
statusEntries,
|
|
25
|
-
} from "./git.ts";
|
|
19
|
+
import { commitsAhead, commitsBehind, isStaleLock, type ManagedWorktree, statusEntries } from "./git.ts";
|
|
26
20
|
|
|
27
21
|
const REGISTRY_VERSION = 1;
|
|
28
22
|
|
|
@@ -53,8 +47,16 @@ export interface CheckoutStatus {
|
|
|
53
47
|
state: "clean" | "dirty" | "missing";
|
|
54
48
|
/** Number of changed files (porcelain entries). */
|
|
55
49
|
changed: number;
|
|
50
|
+
/**
|
|
51
|
+
* Commits the branch has made since the commit it was created at.
|
|
52
|
+
*
|
|
53
|
+
* Absent when that commit was never recorded — a worktree the user pointed us at —
|
|
54
|
+
* because git keeps no fork point of its own to ask.
|
|
55
|
+
*/
|
|
56
|
+
commits?: number;
|
|
56
57
|
ahead: number;
|
|
57
58
|
behind: number;
|
|
59
|
+
/** Whether the work landed: every commit is in the base, and there were commits. */
|
|
58
60
|
merged: boolean;
|
|
59
61
|
locked: boolean;
|
|
60
62
|
staleLock: boolean;
|
|
@@ -218,10 +220,17 @@ export async function statusOf(
|
|
|
218
220
|
const baseRef = options.baseRef;
|
|
219
221
|
base.state = dirty.length > 0 ? "dirty" : "clean";
|
|
220
222
|
base.changed = dirty.length;
|
|
223
|
+
// The commit the worktree was created at is the only fork point git can be asked about:
|
|
224
|
+
// once a branch is merged, it and a branch that never committed are both ancestors of
|
|
225
|
+
// the base, so `ahead` reports 0 for each and cannot tell them apart. This can.
|
|
226
|
+
const since = options.record?.base.commit;
|
|
227
|
+
if (since) base.commits = await commitsAhead(pi, path, since);
|
|
221
228
|
if (baseRef) {
|
|
222
229
|
base.ahead = await commitsAhead(pi, path, baseRef);
|
|
223
230
|
base.behind = await commitsBehind(pi, path, baseRef);
|
|
224
|
-
|
|
231
|
+
// `merged` claims the work landed, so it needs commits that could have landed: a
|
|
232
|
+
// branch with none is an empty checkout, not a finished one.
|
|
233
|
+
base.merged = base.ahead === 0 && base.commits !== 0;
|
|
225
234
|
}
|
|
226
235
|
return base;
|
|
227
236
|
}
|
|
@@ -35,14 +35,24 @@ import { realPathOfNearest } from "../../lib/path.ts";
|
|
|
35
35
|
export const ROOT_TOOL_NAMES = ["read", "write", "edit", "bash", "grep", "find", "ls"] as const;
|
|
36
36
|
export type RootToolName = (typeof ROOT_TOOL_NAMES)[number];
|
|
37
37
|
|
|
38
|
-
|
|
38
|
+
/**
|
|
39
|
+
* A built-in tool definition, as the seven factories return it.
|
|
40
|
+
*
|
|
41
|
+
* Pi's own three defaults, spelled out rather than left implicit: `renderCall`'s arguments
|
|
42
|
+
* are contravariant in the parameter schema and its context in the detail type, so the
|
|
43
|
+
* seven concrete definitions do not widen to one `ToolDefinition<TSchema, unknown, ...>`
|
|
44
|
+
* — and neither does the test seam that replaces them with a stub. Erasing all three is
|
|
45
|
+
* the only alias that accepts both, which is what makes this line Pi's escape hatch
|
|
46
|
+
* rather than a missing type.
|
|
47
|
+
*/
|
|
48
|
+
type BuiltinToolDef = ToolDefinition<any, any, any>;
|
|
39
49
|
|
|
40
50
|
export interface FactoryOptions {
|
|
41
51
|
read?: { autoResizeImages?: boolean };
|
|
42
52
|
bash?: { commandPrefix?: string; shellPath?: string };
|
|
43
53
|
}
|
|
44
54
|
|
|
45
|
-
function createBuiltin(name: RootToolName, cwd: string, options: FactoryOptions):
|
|
55
|
+
function createBuiltin(name: RootToolName, cwd: string, options: FactoryOptions): BuiltinToolDef {
|
|
46
56
|
switch (name) {
|
|
47
57
|
case "read":
|
|
48
58
|
return createReadToolDefinition(cwd, options.read);
|
|
@@ -62,7 +72,7 @@ function createBuiltin(name: RootToolName, cwd: string, options: FactoryOptions)
|
|
|
62
72
|
}
|
|
63
73
|
|
|
64
74
|
/** Host built-in factory. Injectable so tests avoid `mock.module`. */
|
|
65
|
-
export type BuiltinFactory = (name: RootToolName, cwd: string, options: FactoryOptions) =>
|
|
75
|
+
export type BuiltinFactory = (name: RootToolName, cwd: string, options: FactoryOptions) => BuiltinToolDef;
|
|
66
76
|
|
|
67
77
|
export interface RootToolDeps {
|
|
68
78
|
/** Override the host built-in factories. Tests inject a stub definition. */
|
|
@@ -21,8 +21,14 @@ export const ENV_BRANCH = "PI_WORKTREE_BRANCH";
|
|
|
21
21
|
/** Main checkout a borrowed worktree forked from, so a child knows its `repoRoot`. */
|
|
22
22
|
export const ENV_MAIN = "PI_WORKTREE_MAIN";
|
|
23
23
|
|
|
24
|
-
/**
|
|
25
|
-
|
|
24
|
+
/**
|
|
25
|
+
* The channel `publishWorktree` announces on, for another extension that follows the active
|
|
26
|
+
* root without polling; `docs/extensions/worktree.md` names it as the contract.
|
|
27
|
+
*
|
|
28
|
+
* Not exported: the channel is an event name on `pi.events`, so what crosses the boundary
|
|
29
|
+
* is the string a listener subscribes to, and the constant is this module's own.
|
|
30
|
+
*/
|
|
31
|
+
const WORKTREE_EVENT_CHANNEL = "worktree:changed";
|
|
26
32
|
|
|
27
33
|
let active: WorktreeState | null = null;
|
|
28
34
|
/** Directory to use when not isolated (the session cwd at startup). */
|
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
*
|
|
5
5
|
* The frame, navigation, y/N confirmation, and pane come from the shared
|
|
6
6
|
* `DockListScreen`, so this module owns only the worktree vocabulary. Rows come
|
|
7
|
-
* from the same `worktreeSnapshot` the text status renders
|
|
8
|
-
* `list_worktrees` tool cannot disagree
|
|
7
|
+
* from the same `worktreeSnapshot` the text status renders and their marks from the
|
|
8
|
+
* same `statusMarks`, so the screen and the `list_worktrees` tool cannot disagree
|
|
9
|
+
* about a worktree.
|
|
9
10
|
*
|
|
10
11
|
* Every verb but `p` closes the screen and hands the action back to the command:
|
|
11
12
|
* entering and exiting change the session binding and `exitWorktree` asks through
|
|
@@ -16,6 +17,7 @@
|
|
|
16
17
|
import { basename } from "node:path";
|
|
17
18
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
18
19
|
import { messageOf } from "../../lib/errors.ts";
|
|
20
|
+
import { planModeRefusal } from "../../lib/plan-entry.ts";
|
|
19
21
|
import type { ScreenHint, ViewportRowsSource } from "../../lib/tui/chrome.ts";
|
|
20
22
|
import { DockListScreen, type DetailContents } from "../../lib/tui/dock-screen.ts";
|
|
21
23
|
import { GLYPHS } from "../../lib/tui/glyphs.ts";
|
|
@@ -75,11 +77,9 @@ function rowGlyph(status: CheckoutStatus | undefined): { char: string; role: Tui
|
|
|
75
77
|
return { char: GLYPHS.branch, role: "info" };
|
|
76
78
|
}
|
|
77
79
|
|
|
78
|
-
/** The row's right-hand text: its marks,
|
|
80
|
+
/** The row's right-hand text: its marks, worded once in `statusMarks`. */
|
|
79
81
|
function rowMarks(row: WorktreeRow): string {
|
|
80
|
-
|
|
81
|
-
if (marks.length === 0) marks.push("clean");
|
|
82
|
-
return marks.join(" ");
|
|
82
|
+
return (row.status ? statusMarks(row.status, row.current) : ["current"]).join(" ");
|
|
83
83
|
}
|
|
84
84
|
|
|
85
85
|
export interface WorktreeScreenOptions {
|
|
@@ -186,7 +186,13 @@ export class WorktreeListComponent extends DockListScreen<WorktreeRow> {
|
|
|
186
186
|
const bits = [status.state === "dirty" ? `dirty (${status.changed} changed)` : "clean"];
|
|
187
187
|
if (status.ahead > 0) bits.push(`${status.ahead} ahead`);
|
|
188
188
|
if (status.behind > 0) bits.push(`${status.behind} behind`);
|
|
189
|
-
|
|
189
|
+
// The row says it too, and for the same reason: a merged mark is a claim that work
|
|
190
|
+
// landed, which a worktree with no commits of its own cannot make.
|
|
191
|
+
if (status.commits === 0) bits.push("no commits");
|
|
192
|
+
else if (status.merged) {
|
|
193
|
+
if (status.commits !== undefined) bits.push(`${status.commits} commits`);
|
|
194
|
+
bits.push("merged");
|
|
195
|
+
}
|
|
190
196
|
lines.push(`state ${bits.join(", ")}`);
|
|
191
197
|
if (status.locked) {
|
|
192
198
|
lines.push(`lock ${status.staleLock ? "stale (owner is gone)" : "held by a live session"}`);
|
|
@@ -200,6 +206,7 @@ export class WorktreeListComponent extends DockListScreen<WorktreeRow> {
|
|
|
200
206
|
branch: row.branch,
|
|
201
207
|
base: this.view.baseBranch ?? null,
|
|
202
208
|
ahead: status.ahead,
|
|
209
|
+
commits: status.commits,
|
|
203
210
|
merged: status.merged,
|
|
204
211
|
}),
|
|
205
212
|
);
|
|
@@ -211,7 +218,7 @@ export class WorktreeListComponent extends DockListScreen<WorktreeRow> {
|
|
|
211
218
|
protected handleVerb(key: string, row: WorktreeRow | undefined): void {
|
|
212
219
|
if (key !== "e" && key !== "x" && key !== "p") return;
|
|
213
220
|
if (this.deps.readOnly) {
|
|
214
|
-
this.notify("
|
|
221
|
+
this.notify(planModeRefusal("changing worktrees"));
|
|
215
222
|
return;
|
|
216
223
|
}
|
|
217
224
|
if (key === "e") {
|