@gavin-hu/my-pi-agent 0.11.4 → 0.13.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 +38 -26
- package/README.zh-CN.md +33 -23
- package/extensions/bytefly/README.md +381 -225
- package/extensions/bytefly/chrome.ts +7 -2
- package/extensions/bytefly/doctor/register.ts +6 -0
- package/extensions/bytefly/doctor/runtime.ts +52 -6
- package/extensions/bytefly/doctor/tools.ts +1 -3
- package/extensions/bytefly/footer/collect/snapshot.ts +12 -2
- package/extensions/bytefly/footer/config.ts +7 -1
- package/extensions/bytefly/footer/delegation.ts +100 -0
- package/extensions/bytefly/footer/price/config.ts +54 -15
- package/extensions/bytefly/footer/price/price.ts +20 -7
- package/extensions/bytefly/footer/price/types.ts +13 -7
- package/extensions/bytefly/footer/register.ts +13 -2
- package/extensions/bytefly/footer/render/format.ts +3 -2
- package/extensions/bytefly/footer/render/lines.ts +2 -0
- package/extensions/bytefly/footer/render/segments.ts +30 -2
- package/extensions/bytefly/footer/types.ts +12 -0
- package/extensions/bytefly/header/component.ts +21 -6
- package/extensions/bytefly/header/config.ts +3 -0
- package/extensions/bytefly/header/register.ts +34 -3
- package/extensions/bytefly/index.ts +15 -4
- package/extensions/bytefly/notify/config.ts +2 -0
- package/extensions/bytefly/notify/format.ts +19 -1
- package/extensions/bytefly/notify/register.ts +37 -13
- package/extensions/bytefly/notify/types.ts +2 -0
- package/extensions/bytefly/panel/commands.ts +34 -13
- package/extensions/bytefly/panel/settings-runtime.ts +71 -28
- package/extensions/bytefly/panel/settings.ts +293 -48
- package/extensions/bytefly/panel/tui/entries.ts +58 -0
- package/extensions/bytefly/panel/tui/extensions-view.ts +86 -0
- package/extensions/bytefly/panel/tui/frame.ts +71 -0
- package/extensions/bytefly/panel/tui/nav.ts +75 -0
- package/extensions/bytefly/panel/tui/settings-view.ts +115 -0
- package/extensions/bytefly/panel/tui.ts +205 -319
- package/extensions/bytefly/picker/runtime.ts +61 -2
- package/extensions/bytefly/picker/toggle.ts +56 -0
- package/extensions/bytefly/update/cache.ts +73 -0
- package/extensions/bytefly/update/check.ts +59 -0
- package/extensions/bytefly/update/format.ts +87 -0
- package/extensions/bytefly/update/register.ts +158 -0
- package/extensions/bytefly/update/state.ts +40 -0
- package/extensions/bytefly/update/version.ts +83 -0
- package/extensions/document/README.md +24 -14
- package/extensions/document/config.ts +4 -0
- package/extensions/document/deadline.ts +61 -0
- package/extensions/document/tool.ts +14 -5
- 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/file-open/README.md +30 -15
- package/extensions/file-open/index.ts +4 -3
- package/extensions/file-open/open.ts +52 -21
- package/extensions/goal/README.md +45 -13
- package/extensions/goal/clarify-prompt.ts +5 -36
- package/extensions/goal/clarify.ts +55 -86
- package/extensions/goal/commands.ts +41 -35
- package/extensions/goal/format.ts +12 -4
- package/extensions/goal/schema.ts +6 -2
- package/extensions/goal/tools.ts +28 -8
- package/extensions/goal/tui.ts +1 -1
- package/extensions/goal/types.ts +6 -2
- package/extensions/job/README.md +16 -12
- package/extensions/job/runtime.ts +23 -6
- package/extensions/job/status.ts +4 -2
- package/extensions/memory/README.md +56 -14
- 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 +83 -36
- package/extensions/plan/blocks.ts +1 -35
- package/extensions/plan/commands.ts +84 -19
- 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 +91 -2
- package/extensions/plan/policy.ts +4 -3
- package/extensions/plan/review.ts +246 -0
- package/extensions/plan/runtime.ts +73 -24
- package/extensions/plan/steps.ts +60 -39
- package/extensions/plan/todos.ts +29 -60
- package/extensions/plan/tools.ts +188 -244
- package/extensions/plan/tui.ts +18 -31
- package/extensions/plan/types.ts +12 -0
- package/extensions/plan/web-client.ts +51 -18
- package/extensions/plan/web-page.ts +7 -2
- package/extensions/plan/web-router.ts +11 -3
- package/extensions/plan/web.ts +106 -41
- package/extensions/question/README.md +1 -1
- package/extensions/question/tools.ts +1 -1
- 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 -3
- package/extensions/subagent/stream.ts +2 -0
- package/extensions/subagent/types.ts +2 -0
- package/extensions/todo/README.md +19 -11
- package/extensions/todo/index.ts +5 -3
- package/extensions/todo/nudge.ts +58 -9
- package/extensions/todo/runtime.ts +28 -17
- package/extensions/watch/README.md +8 -3
- package/extensions/watch/index.ts +19 -0
- package/extensions/web-access/README.md +46 -16
- 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 +20 -21
- package/extensions/web-access/http.ts +49 -8
- package/extensions/web-access/search/facts.ts +38 -0
- package/extensions/web-access/search/tool.ts +14 -3
- package/extensions/web-access/text.ts +50 -11
- package/extensions/wechat/README.md +5 -4
- package/extensions/wechat/tools.ts +0 -2
- package/extensions/worktree/README.md +74 -15
- package/extensions/worktree/config.ts +11 -0
- package/extensions/worktree/exit.ts +35 -3
- package/extensions/worktree/gaps.ts +74 -0
- package/extensions/worktree/guard.ts +50 -4
- package/extensions/worktree/handoff.ts +55 -0
- package/extensions/worktree/index.ts +12 -2
- package/extensions/worktree/runtime.ts +20 -0
- package/extensions/worktree/status.ts +15 -2
- package/extensions/worktree/tools.ts +3 -2
- package/lib/README.md +12 -9
- package/lib/config.ts +43 -1
- package/lib/env.ts +12 -0
- package/lib/events.ts +86 -1
- package/lib/goal-state.ts +95 -0
- package/lib/requirements.ts +340 -46
- package/lib/todo-state.ts +62 -0
- package/lib/tui/README.md +91 -0
- package/lib/tui/glyphs.ts +2 -0
- package/lib/web/layout.ts +12 -2
- package/package.json +5 -3
- package/themes/bytefly-dark.json +1 -1
- package/assets/bytefly.schema.json +0 -785
- 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
|
@@ -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
|
```
|
|
@@ -118,7 +142,8 @@ variable, `~user`, a backtick) is treated as escaping. File-descriptor
|
|
|
118
142
|
duplications (`2>&1`, `>&-`), heredocs, and here-strings are not treated as
|
|
119
143
|
paths, and options are only recognized in real argument position, so
|
|
120
144
|
`git log --grep='--git-dir=/tmp/x'` and `git log -C` are not false positives.
|
|
121
|
-
|
|
145
|
+
The null device is exempt: `>/dev/null` and `< /dev/null` write and read nothing,
|
|
146
|
+
so they are allowed. `GIT_DIR=`/`GIT_WORK_TREE=` are refused both as a command prefix and when
|
|
122
147
|
exported (`export GIT_DIR=/main`), and a `sh -c`/`bash -c` sub-command is refused
|
|
123
148
|
when `blockUnparsableCommands` is enabled.
|
|
124
149
|
|
|
@@ -126,6 +151,15 @@ This guard covers the built-in file tools and these common shell forms. It
|
|
|
126
151
|
cannot contain a program that computes and writes an absolute path itself (for
|
|
127
152
|
example a script or an inlined interpreter).
|
|
128
153
|
|
|
154
|
+
Windows `powershell` is refused outright while the session is isolated rather
|
|
155
|
+
than parsed. It is not one of the tools this extension re-roots, and its language
|
|
156
|
+
is a second grammar — backtick escapes, `Set-Location`, `$env:GIT_DIR`, `--%` —
|
|
157
|
+
so the bash checks would be guessing about a shell that starts in the main
|
|
158
|
+
checkout. Refusing it is the same answer plan mode gives, and `bash` (re-rooted
|
|
159
|
+
and guarded) stays available. `guard.blockPowershell: false` allows the tool
|
|
160
|
+
again; that is a deliberate trade, so it is reported: `/worktree` lists the gap
|
|
161
|
+
and `doctor` reports the switch as `degraded`.
|
|
162
|
+
|
|
129
163
|
## Gitignored files
|
|
130
164
|
|
|
131
165
|
A worktree is a fresh checkout, so untracked, gitignored files such as `.env`
|
|
@@ -187,13 +221,22 @@ use, falling back to directory mtime for a worktree with no record.
|
|
|
187
221
|
writes are guarded (relative paths are not isolated), and `list_worktrees`
|
|
188
222
|
reports it. `skipOverrides` is read at load time, from the process working
|
|
189
223
|
directory.
|
|
224
|
+
- `git branch --merged main` calls a worktree branch merged whenever it matches
|
|
225
|
+
its base, so a worktree that never committed looks landed while its
|
|
226
|
+
uncommitted work is still there. Read `git -C <path> status` before removing
|
|
227
|
+
one; `list_worktrees` reports the same clean/merged flags.
|
|
190
228
|
- The guard is best-effort at the shell level: command substitution, `eval`, and
|
|
191
229
|
`sh -c` are only covered when `blockUnparsableCommands` is enabled, and real
|
|
192
|
-
isolation of arbitrary shell writes needs an OS sandbox.
|
|
230
|
+
isolation of arbitrary shell writes needs an OS sandbox. `/worktree` names the
|
|
231
|
+
gap and counts the commands that passed through it this session.
|
|
193
232
|
- Only the model's `bash` tool is re-rooted and guarded. A shell command the
|
|
194
233
|
user runs directly (`!` in the TUI / the `user_bash` event) is not re-rooted
|
|
195
234
|
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
|
|
235
|
+
that. `/worktree` and `list_worktrees` report the active root, and list the
|
|
236
|
+
shell under `Not isolated:` on every status read.
|
|
237
|
+
- Windows `powershell` is refused while isolated, or — with
|
|
238
|
+
`guard.blockPowershell: false` — allowed and unguarded. There is no third
|
|
239
|
+
state: a parsed PowerShell command that keeps the tool.
|
|
197
240
|
|
|
198
241
|
## Non-goals
|
|
199
242
|
|
|
@@ -202,19 +245,19 @@ use, falling back to directory mtime for a worktree with no record.
|
|
|
202
245
|
knows about. It does not rename, rebase, merge, or push branches.
|
|
203
246
|
- **No user-shell re-rooting.** A shell the user runs directly (`!` /
|
|
204
247
|
`user_bash`) is not isolated.
|
|
205
|
-
- **No
|
|
206
|
-
|
|
248
|
+
- **No PowerShell guard.** `powershell` is refused while isolated, not parsed;
|
|
249
|
+
teaching the guard a second grammar belongs with an OS sandbox, not here.
|
|
207
250
|
|
|
208
251
|
## Pi integration
|
|
209
252
|
|
|
210
253
|
| Contract | Detail |
|
|
211
254
|
|---|---|
|
|
212
255
|
| 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. |
|
|
256
|
+
| 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 }`. |
|
|
257
|
+
| 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
258
|
| Commands | `/worktree` via `registerCommand`, with argument completions for the subcommands and exit flags. |
|
|
216
259
|
| 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). |
|
|
260
|
+
| 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
261
|
| 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
262
|
| UI/events | Status chip via `ctx.ui.setStatus`; `worktree:changed` published on `pi.events`. |
|
|
220
263
|
|
|
@@ -229,6 +272,14 @@ use, falling back to directory mtime for a worktree with no record.
|
|
|
229
272
|
- **Two layers: re-root and guard.** Re-rooting relative paths is not enough on
|
|
230
273
|
its own — an absolute path, `cd`, or `git -C` still escapes. The guard runs
|
|
231
274
|
centrally from the `tool_call` handler, so nested tool calls are covered too.
|
|
275
|
+
- **A gap the user cannot see is a gap they cannot weigh.** Re-rooting is not
|
|
276
|
+
isolating: Pi has no mutable session cwd, so the user's own `!` shell stays in
|
|
277
|
+
the main checkout, an unverifiable command passes by default, and `powershell`
|
|
278
|
+
would run unguarded if it were allowed. Those rules live in `gaps.ts` as data,
|
|
279
|
+
the status text renders them, and the `tool_call` handler counts the one gap the
|
|
280
|
+
transcript cannot show: a command the guard let through without verifying it.
|
|
281
|
+
The two gaps a setting can close are closed — `powershell` is refused, and an
|
|
282
|
+
opt-out is reported by the status and by `doctor`.
|
|
232
283
|
- **Core model: Checkout / Managed record / Binding.** A *Checkout* is a real
|
|
233
284
|
`git worktree` and git is authoritative about it. A *Managed record* is
|
|
234
285
|
provenance and usage in a disposable index. A *Binding* is this session's
|
|
@@ -245,6 +296,12 @@ use, falling back to directory mtime for a worktree with no record.
|
|
|
245
296
|
- **Never force out work.** `exit_worktree` never force-deletes a branch that
|
|
246
297
|
still holds uncommitted changes or commits not reachable from the recorded
|
|
247
298
|
base, even with `remove: true`.
|
|
299
|
+
- **A handoff is read, not performed.** The exit reports the branch's state and
|
|
300
|
+
the command that finishes it, because a merge touches the main checkout and can
|
|
301
|
+
conflict — the user should be the one in that state. The merge target is
|
|
302
|
+
`origin/HEAD`'s branch, falling back to the main checkout's own branch when the
|
|
303
|
+
repository has no remote; when neither resolves, the block names the branch and
|
|
304
|
+
prints no command rather than guessing `main`.
|
|
248
305
|
- **Lock, then prune.** Entering a managed worktree takes a `git worktree lock`
|
|
249
306
|
so a concurrent sweep cannot remove it; prune releases stale locks first and
|
|
250
307
|
keeps any worktree it cannot safely remove, with a reason.
|
|
@@ -262,10 +319,12 @@ use, falling back to directory mtime for a worktree with no record.
|
|
|
262
319
|
| `lifecycle.ts` | Barrel: `enterWorktree` / `exitWorktree` / `pruneWorktrees` / status |
|
|
263
320
|
| `errors.ts` | Cancellation error that distinguishes a user decline from a failure |
|
|
264
321
|
| `enter.ts` / `exit.ts` / `prune.ts` | The lifecycle phases |
|
|
322
|
+
| `handoff.ts` | The `Handoff:` block an exit leaves: the branch's lead, whether the base contains it, and the merge or PR command. |
|
|
265
323
|
| `tools.ts` / `commands.ts` | Model tools / slash commands |
|
|
266
324
|
| `root-tools.ts` | Load-time built-in overrides bound to the active root |
|
|
267
325
|
| `git.ts` | Git plumbing (shared runner + read helpers) |
|
|
268
|
-
| `guard.ts` | Path, shell, and git-redirect isolation checks |
|
|
326
|
+
| `guard.ts` | Path, shell, and git-redirect isolation checks, plus the `powershell` refusal |
|
|
327
|
+
| `gaps.ts` | The isolation gaps a session carries, and how they read (pure) |
|
|
269
328
|
| `include.ts` | `.worktreeinclude` matcher and copy |
|
|
270
329
|
| `config.ts` / `state.ts` / `status.ts` | Config, session entry shape, status text |
|
|
271
330
|
| `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
|
|
|
@@ -6,7 +6,17 @@
|
|
|
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
|
+
currentBranch,
|
|
11
|
+
defaultBranch,
|
|
12
|
+
deleteBranch,
|
|
13
|
+
inspectWork,
|
|
14
|
+
isBranchMerged,
|
|
15
|
+
lockWorktree,
|
|
16
|
+
unlockWorktree,
|
|
17
|
+
worktreeRemove,
|
|
18
|
+
} from "./git.ts";
|
|
19
|
+
import { handoffLines, type Handoff } from "./handoff.ts";
|
|
10
20
|
import { removeRecord, touchRecord } from "./registry.ts";
|
|
11
21
|
import { applyWorktreeEnv, clearConfigCache, getActive, publishWorktree, setActive, setStatus } from "./runtime.ts";
|
|
12
22
|
import { persistState, type WorktreeState } from "./state.ts";
|
|
@@ -17,11 +27,30 @@ export interface ExitOptions {
|
|
|
17
27
|
keepBranch?: boolean;
|
|
18
28
|
}
|
|
19
29
|
|
|
30
|
+
/**
|
|
31
|
+
* What the branch holds and how to finish it, computed after the outcome is known.
|
|
32
|
+
* A failed lookup degrades to the facts already in hand: an exit that succeeded
|
|
33
|
+
* must not fail because the default branch could not be read.
|
|
34
|
+
*/
|
|
35
|
+
async function describeHandoff(pi: ExtensionAPI, state: WorktreeState, ahead: number): Promise<Handoff> {
|
|
36
|
+
if (!state.branch) return { branch: null, base: null, ahead, merged: false };
|
|
37
|
+
try {
|
|
38
|
+
// `origin/HEAD` names the merge target when the repository has a remote; a
|
|
39
|
+
// local-only repository has none, and the main checkout's own branch is the
|
|
40
|
+
// branch a merge would actually land on.
|
|
41
|
+
const base = (await defaultBranch(pi, state.repoRoot)) ?? (await currentBranch(pi, state.repoRoot)) ?? null;
|
|
42
|
+
const merged = base ? await isBranchMerged(pi, state.repoRoot, state.branch, base) : false;
|
|
43
|
+
return { branch: state.branch, base, ahead, merged };
|
|
44
|
+
} catch {
|
|
45
|
+
return { branch: state.branch, base: null, ahead, merged: false };
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
20
49
|
export async function exitWorktree(
|
|
21
50
|
pi: ExtensionAPI,
|
|
22
51
|
ctx: ExtensionContext,
|
|
23
52
|
options: ExitOptions,
|
|
24
|
-
): Promise<{ state: WorktreeState; removed: boolean; output: string }> {
|
|
53
|
+
): Promise<{ state: WorktreeState; removed: boolean; handoff: Handoff; output: string }> {
|
|
25
54
|
const state = getActive();
|
|
26
55
|
if (!state) throw new Error("Not currently in a worktree.");
|
|
27
56
|
if (state.borrowed) {
|
|
@@ -118,5 +147,8 @@ export async function exitWorktree(
|
|
|
118
147
|
touchRecord(state.repoRoot, config, state.path);
|
|
119
148
|
}
|
|
120
149
|
|
|
121
|
-
|
|
150
|
+
const handoff = await describeHandoff(pi, state, ahead);
|
|
151
|
+
lines.push(...handoffLines(handoff));
|
|
152
|
+
|
|
153
|
+
return { state, removed, handoff, output: lines.join("\n") };
|
|
122
154
|
}
|
|
@@ -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
|
+
}
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
* 1. file edits that resolve outside the active worktree (absolute paths);
|
|
9
9
|
* 2. a shell command whose working directory leaves the worktree;
|
|
10
10
|
* 3. git commands redirected at the main checkout;
|
|
11
|
-
* 4. (optional) commands whose text cannot be statically verified
|
|
11
|
+
* 4. (optional) commands whose text cannot be statically verified;
|
|
12
|
+
* 5. the un-re-rooted `powershell` tool, which has no guard of its own.
|
|
12
13
|
*/
|
|
13
14
|
|
|
14
15
|
import { homedir } from "node:os";
|
|
@@ -224,8 +225,12 @@ function pathEscapes(root: string, raw: string): boolean {
|
|
|
224
225
|
return !isInside(root, resolve(root, expanded.path));
|
|
225
226
|
}
|
|
226
227
|
|
|
227
|
-
/**
|
|
228
|
-
|
|
228
|
+
/**
|
|
229
|
+
* Constructs whose text does not spell out the git command that runs. Exported
|
|
230
|
+
* so the `tool_call` handler counts exactly the commands this check would have
|
|
231
|
+
* refused, instead of guessing at a second list.
|
|
232
|
+
*/
|
|
233
|
+
export function isUnverifiable(command: string): boolean {
|
|
229
234
|
return /\$\(|`|\$\{!|\beval\b|\b(?:sh|bash|zsh|dash|ksh)\s+-c\b/.test(command);
|
|
230
235
|
}
|
|
231
236
|
|
|
@@ -360,6 +365,7 @@ function redirectionEscapes(root: string, command: string, config: WorktreeConfi
|
|
|
360
365
|
for (const segment of splitSegments(command)) {
|
|
361
366
|
for (const redirect of redirectionTargets(segment)) {
|
|
362
367
|
if (redirect.write ? !checkWrites : !checkReads) continue;
|
|
368
|
+
if (isNullDevice(redirect.target)) continue;
|
|
363
369
|
if (pathEscapes(root, redirect.target)) {
|
|
364
370
|
return `${redirect.write ? "Output" : "Input"} redirection to \"${redirect.target}\" leaves the active worktree.`;
|
|
365
371
|
}
|
|
@@ -368,6 +374,15 @@ function redirectionEscapes(root: string, command: string, config: WorktreeConfi
|
|
|
368
374
|
return undefined;
|
|
369
375
|
}
|
|
370
376
|
|
|
377
|
+
/**
|
|
378
|
+
* The null device discards the stream instead of writing a file, so a redirect
|
|
379
|
+
* there escapes nothing — and `2>/dev/null` is too common to refuse. Quotes are
|
|
380
|
+
* stripped because the target keeps them as written.
|
|
381
|
+
*/
|
|
382
|
+
function isNullDevice(raw: string): boolean {
|
|
383
|
+
return raw.replace(/^(['"])(.*)\1$/, "$2") === "/dev/null";
|
|
384
|
+
}
|
|
385
|
+
|
|
371
386
|
/** Commands that wrap another command, so a following assignment is still a prefix. */
|
|
372
387
|
const COMMAND_WRAPPERS = new Set(["sudo", "doas", "env", "command", "builtin", "exec", "nohup", "nice", "time"]);
|
|
373
388
|
|
|
@@ -532,6 +547,37 @@ function cdRedirect(root: string, words: ShellWord[]): string | undefined {
|
|
|
532
547
|
return undefined;
|
|
533
548
|
}
|
|
534
549
|
|
|
550
|
+
// ---------------------------------------------------------------------------
|
|
551
|
+
// The powershell tool (check 5)
|
|
552
|
+
// ---------------------------------------------------------------------------
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* Refuse the `powershell` tool while isolated.
|
|
556
|
+
*
|
|
557
|
+
* Pi's `powershell` tool is neither re-rooted nor guarded: it is not in
|
|
558
|
+
* `ROOT_TOOL_NAMES`, and its language is a second grammar — backtick escapes,
|
|
559
|
+
* `Set-Location`, `$env:GIT_DIR`, `--%` — so `analyzeBashCommand` would be
|
|
560
|
+
* guessing. Inside a worktree its relative paths therefore resolve at the main
|
|
561
|
+
* checkout, the checkout isolation exists to protect.
|
|
562
|
+
*
|
|
563
|
+
* Re-rooting it without a parser would be worse than refusing it: it would look
|
|
564
|
+
* isolated while every one of those forms still walks out. So the isolated
|
|
565
|
+
* session refuses the tool, the same answer plan mode gives, and
|
|
566
|
+
* `guard.blockPowershell: false` is the documented opt-out — which `gaps.ts`
|
|
567
|
+
* reports and `doctor` shows as `degraded`.
|
|
568
|
+
*/
|
|
569
|
+
export function guardPowershellTool(config: WorktreeConfig): GuardBlock | undefined {
|
|
570
|
+
if (!config.guard.blockPowershell) return undefined;
|
|
571
|
+
return {
|
|
572
|
+
block: true,
|
|
573
|
+
reason:
|
|
574
|
+
"Refusing to run powershell inside an isolated worktree: it is not re-rooted or guarded, " +
|
|
575
|
+
"so relative paths and git would run against the main checkout. " +
|
|
576
|
+
"Use bash, which is re-rooted and guarded, or exit the worktree first. " +
|
|
577
|
+
"(worktree.guard.blockPowershell turns this refusal off.)",
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
|
|
535
581
|
/** Checks 2-4 for a bash command while isolated. */
|
|
536
582
|
export function analyzeBashCommand(command: string, root: string, config: WorktreeConfig): GuardBlock | undefined {
|
|
537
583
|
if (config.guard.blockGitRedirects) {
|
|
@@ -553,7 +599,7 @@ export function analyzeBashCommand(command: string, root: string, config: Worktr
|
|
|
553
599
|
return { block: true, reason: `${redirect} Exit the worktree to operate on the main checkout.` };
|
|
554
600
|
}
|
|
555
601
|
|
|
556
|
-
if (config.guard.blockUnparsableCommands &&
|
|
602
|
+
if (config.guard.blockUnparsableCommands && isUnverifiable(command)) {
|
|
557
603
|
return {
|
|
558
604
|
block: true,
|
|
559
605
|
reason:
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The handoff an exit leaves behind: what the branch holds, and the command that
|
|
3
|
+
* finishes the task.
|
|
4
|
+
*
|
|
5
|
+
* `AGENTS.md` ends a worktree task with a merge into the main checkout, or a pull
|
|
6
|
+
* request. The exit already decides the branch's fate and keeps one that holds
|
|
7
|
+
* commits; this says how many, whether the default branch already contains them,
|
|
8
|
+
* and the exact commands — so the next step is read, not remembered.
|
|
9
|
+
*
|
|
10
|
+
* Pure: the caller has the facts, this shapes them. The tool never commits, merges,
|
|
11
|
+
* pushes, or opens a pull request, because each of those acts on a checkout the
|
|
12
|
+
* session is no longer isolated in.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** What a leaving worktree hands back to the main checkout. */
|
|
16
|
+
export interface Handoff {
|
|
17
|
+
/** The worktree branch, when the session had one. */
|
|
18
|
+
branch: string | null;
|
|
19
|
+
/** The branch a merge would target, when it could be resolved. */
|
|
20
|
+
base: string | null;
|
|
21
|
+
/** Commits on the branch that the base does not contain. */
|
|
22
|
+
ahead: number;
|
|
23
|
+
/** Whether the base already contains every commit on the branch. */
|
|
24
|
+
merged: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Pluralized commit count, e.g. `1 commit` / `3 commits`. */
|
|
28
|
+
function commits(count: number): string {
|
|
29
|
+
return `${count} commit${count === 1 ? "" : "s"}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The `Handoff:` block: one headline and, only when there is something to do, the
|
|
34
|
+
* two commands. A merged or empty branch prints no command — inviting a merge of
|
|
35
|
+
* work that is already in the base is how a branch gets merged twice.
|
|
36
|
+
*/
|
|
37
|
+
export function handoffLines(handoff: Handoff): string[] {
|
|
38
|
+
if (!handoff.branch) return ["Handoff: the worktree had no branch to hand off."];
|
|
39
|
+
|
|
40
|
+
if (handoff.base === null) {
|
|
41
|
+
return [
|
|
42
|
+
`Handoff: branch ${handoff.branch} holds ${commits(handoff.ahead)}; its base branch could not be resolved.`,
|
|
43
|
+
];
|
|
44
|
+
}
|
|
45
|
+
if (handoff.merged || handoff.ahead === 0) {
|
|
46
|
+
const state = handoff.merged ? `is merged into ${handoff.base}` : `has no commits beyond ${handoff.base}`;
|
|
47
|
+
return [`Handoff: ${handoff.branch} ${state} — nothing to merge.`];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return [
|
|
51
|
+
`Handoff: ${handoff.branch} is ${commits(handoff.ahead)} ahead of ${handoff.base}.`,
|
|
52
|
+
` merge: git switch ${handoff.base} && git merge --no-ff ${handoff.branch}`,
|
|
53
|
+
` or PR: gh pr create --head ${handoff.branch} --fill`,
|
|
54
|
+
];
|
|
55
|
+
}
|
|
@@ -26,7 +26,7 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
|
26
26
|
import { isExtensionEnabled } from "../../lib/env.ts";
|
|
27
27
|
import { GLYPHS } from "../../lib/tui/glyphs.ts";
|
|
28
28
|
import { canonicalize, checkCheckout, repoRoot, unlockWorktree } from "./git.ts";
|
|
29
|
-
import { analyzeBashCommand, guardFileTool } from "./guard.ts";
|
|
29
|
+
import { analyzeBashCommand, guardFileTool, guardPowershellTool, isUnverifiable } from "./guard.ts";
|
|
30
30
|
import {
|
|
31
31
|
enterWorktree,
|
|
32
32
|
findInactiveOverrides,
|
|
@@ -50,7 +50,9 @@ import {
|
|
|
50
50
|
getInactiveOverrides,
|
|
51
51
|
getRoot,
|
|
52
52
|
getSkipOverrides,
|
|
53
|
+
noteUnverifiableCommand,
|
|
53
54
|
publishWorktree,
|
|
55
|
+
resetDiagnostics,
|
|
54
56
|
setActive,
|
|
55
57
|
setBaseCwd,
|
|
56
58
|
setInactiveOverrides,
|
|
@@ -88,6 +90,7 @@ export function registerWorktree(pi: ExtensionAPI, { entryPath, createBuiltin }:
|
|
|
88
90
|
setBaseCwd(ctx.cwd);
|
|
89
91
|
setActive(null);
|
|
90
92
|
clearConfigCache();
|
|
93
|
+
resetDiagnostics();
|
|
91
94
|
|
|
92
95
|
// A child `pi` process (subagent) inherits an active worktree. Bind to
|
|
93
96
|
// it without creating, locking, or persisting anything; the parent owns
|
|
@@ -203,8 +206,15 @@ export function registerWorktree(pi: ExtensionAPI, { entryPath, createBuiltin }:
|
|
|
203
206
|
const config = configFor(state.repoRoot);
|
|
204
207
|
if (event.toolName === "bash") {
|
|
205
208
|
const command = (event.input as { command?: string }).command ?? "";
|
|
206
|
-
|
|
209
|
+
const block = analyzeBashCommand(command, state.path, config);
|
|
210
|
+
// A command the guard lets through unverified is the one gap the transcript
|
|
211
|
+
// cannot show, so count it for `/worktree` instead of losing it.
|
|
212
|
+
if (!block && isUnverifiable(command)) noteUnverifiableCommand();
|
|
213
|
+
return block;
|
|
207
214
|
}
|
|
215
|
+
// The Windows shell has no re-rooting and no guard, so it is refused rather
|
|
216
|
+
// than checked; the switch that allows it is reported as a gap.
|
|
217
|
+
if (event.toolName === "powershell") return guardPowershellTool(config);
|
|
208
218
|
return guardFileTool(event.toolName, event.input, state.path, config);
|
|
209
219
|
});
|
|
210
220
|
|
|
@@ -29,6 +29,8 @@ let active: WorktreeState | null = null;
|
|
|
29
29
|
let baseCwd = process.cwd();
|
|
30
30
|
let inactiveOverrides: string[] = [];
|
|
31
31
|
let gitignoreOffered = false;
|
|
32
|
+
/** Commands this session let through although the guard could not verify them. */
|
|
33
|
+
let unverifiableCommands = 0;
|
|
32
34
|
const configCache = new Map<string, WorktreeConfig>();
|
|
33
35
|
|
|
34
36
|
export function getActive(): WorktreeState | null {
|
|
@@ -68,6 +70,24 @@ export function markGitignoreOffered(): void {
|
|
|
68
70
|
gitignoreOffered = true;
|
|
69
71
|
}
|
|
70
72
|
|
|
73
|
+
/**
|
|
74
|
+
* Count a bash command the guard let through without being able to verify it.
|
|
75
|
+
* Session-scoped on purpose: it is the one gap a user cannot see in the
|
|
76
|
+
* transcript, so `/worktree` reports how often it happened.
|
|
77
|
+
*/
|
|
78
|
+
export function noteUnverifiableCommand(): void {
|
|
79
|
+
unverifiableCommands += 1;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function getUnverifiableCommands(): number {
|
|
83
|
+
return unverifiableCommands;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Forget the previous session's diagnostics; called from `session_start`. */
|
|
87
|
+
export function resetDiagnostics(): void {
|
|
88
|
+
unverifiableCommands = 0;
|
|
89
|
+
}
|
|
90
|
+
|
|
71
91
|
/** Cached config for the hot `tool_call` path; cleared on enter and exit. */
|
|
72
92
|
export function configFor(root: string): WorktreeConfig {
|
|
73
93
|
let config = configCache.get(root);
|
|
@@ -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 { loadConfig } from "./config.ts";
|
|
9
|
+
import { gapLines, isolationGaps } from "./gaps.ts";
|
|
9
10
|
import { canonicalize, type CheckoutCheck, defaultBranch, listManagedWorktrees, repoRoot } from "./git.ts";
|
|
10
11
|
import { reconcileRegistry, statusOf, type CheckoutStatus } from "./registry.ts";
|
|
11
|
-
import { getActive, getInactiveOverrides } from "./runtime.ts";
|
|
12
|
+
import { configFor, getActive, getInactiveOverrides, getUnverifiableCommands } from "./runtime.ts";
|
|
12
13
|
import type { WorktreeState } from "./state.ts";
|
|
13
14
|
|
|
14
15
|
export function worktreeLabel(state: WorktreeState): string {
|
|
@@ -79,7 +80,7 @@ function relativeDay(at: number): string {
|
|
|
79
80
|
return `${days}d ago`;
|
|
80
81
|
}
|
|
81
82
|
|
|
82
|
-
/** Human-readable status: current worktree, override conflicts, managed worktrees. */
|
|
83
|
+
/** Human-readable status: current worktree, override conflicts, gaps, managed worktrees. */
|
|
83
84
|
export async function worktreeStatus(pi: ExtensionAPI, ctx: ExtensionContext): Promise<string> {
|
|
84
85
|
const current = getActive();
|
|
85
86
|
const lines: string[] = [];
|
|
@@ -93,6 +94,18 @@ export async function worktreeStatus(pi: ExtensionAPI, ctx: ExtensionContext): P
|
|
|
93
94
|
if (inactive.length > 0) {
|
|
94
95
|
lines.push("", `Isolation not active for: ${inactive.join(", ")} (their default implementation is in use).`);
|
|
95
96
|
}
|
|
97
|
+
if (current) {
|
|
98
|
+
// What isolation does not cover, from the same rules `doctor` reports: a gap
|
|
99
|
+
// the user cannot see is a gap they cannot weigh.
|
|
100
|
+
const gaps = isolationGaps({
|
|
101
|
+
platform: process.platform,
|
|
102
|
+
activeTools: pi.getActiveTools(),
|
|
103
|
+
config: configFor(current.repoRoot),
|
|
104
|
+
unverifiableCommands: getUnverifiableCommands(),
|
|
105
|
+
});
|
|
106
|
+
const gapBlock = gapLines(gaps);
|
|
107
|
+
if (gapBlock.length > 0) lines.push("", "Not isolated:", ...gapBlock);
|
|
108
|
+
}
|
|
96
109
|
|
|
97
110
|
const root = current?.repoRoot ?? (await repoRoot(pi, ctx.cwd));
|
|
98
111
|
if (root) {
|
|
@@ -75,7 +75,8 @@ export function registerTools(pi: ExtensionAPI): void {
|
|
|
75
75
|
label: "Exit worktree",
|
|
76
76
|
description:
|
|
77
77
|
"Leave the current git worktree and return to the main checkout. The worktree is removed when clean; with " +
|
|
78
|
-
"uncommitted changes or new commits the user chooses whether to keep it."
|
|
78
|
+
"uncommitted changes or new commits the user chooses whether to keep it. The result names the branch's " +
|
|
79
|
+
"commits and the merge or pull-request command that finishes it.",
|
|
79
80
|
promptSnippet: "Leave the current git worktree and return to the main checkout.",
|
|
80
81
|
promptGuidelines: [
|
|
81
82
|
"Use exit_worktree after work started with enter_worktree; it is the only way back to the main checkout.",
|
|
@@ -93,7 +94,7 @@ export function registerTools(pi: ExtensionAPI): void {
|
|
|
93
94
|
ctx.ui.notify(`Exited worktree ${worktreeLabel(result.state)}`, "info");
|
|
94
95
|
return {
|
|
95
96
|
content: [{ type: "text", text: result.output }],
|
|
96
|
-
details: { state: result.state, removed: result.removed },
|
|
97
|
+
details: { state: result.state, removed: result.removed, handoff: result.handoff },
|
|
97
98
|
};
|
|
98
99
|
} catch (error) {
|
|
99
100
|
if (isWorktreeCancelled(error)) {
|