@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
|
@@ -1,24 +1,31 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Rewind: restore the working tree and index to a snapshot without moving HEAD.
|
|
3
3
|
*
|
|
4
|
-
* `read-tree --reset -u`
|
|
5
|
-
* modifications and deletions and removing
|
|
6
|
-
* created after the snapshot and never
|
|
7
|
-
* because `read-tree` leaves untracked files
|
|
8
|
-
* are untouched.
|
|
4
|
+
* The whole-tree restore runs `read-tree --reset -u` against the snapshot, which
|
|
5
|
+
* brings tracked files back (restoring modifications and deletions and removing
|
|
6
|
+
* paths the snapshot never had). Files created after the snapshot and never
|
|
7
|
+
* tracked are then deleted explicitly, because `read-tree` leaves untracked files
|
|
8
|
+
* alone. HEAD, branches, and reflogs are untouched.
|
|
9
|
+
*
|
|
10
|
+
* A **partial** restore takes the same shape over a subset: `read-tree` accepts no
|
|
11
|
+
* pathspec, so the selected paths are checked out of the snapshot instead, and
|
|
12
|
+
* only the selected additions are deleted. Everything else is left exactly as it
|
|
13
|
+
* is, and the same temporary index is used, so the real index stays untouched
|
|
14
|
+
* either way.
|
|
9
15
|
*/
|
|
10
16
|
|
|
11
17
|
import { lstatSync, rmSync } from "node:fs";
|
|
12
18
|
import { resolve, sep } from "node:path";
|
|
13
19
|
import {
|
|
14
|
-
addedPaths,
|
|
15
20
|
busyGitState,
|
|
16
|
-
changedCount,
|
|
17
21
|
diffStat,
|
|
22
|
+
nameStatus,
|
|
18
23
|
readTreeReset,
|
|
24
|
+
restorePaths,
|
|
19
25
|
treeFromWorkingTree,
|
|
20
26
|
type RunGit,
|
|
21
27
|
} from "./git.ts";
|
|
28
|
+
import { summarize, type ChangedPath } from "./paths.ts";
|
|
22
29
|
import type { Snapshot, RestoreSummary } from "./types.ts";
|
|
23
30
|
|
|
24
31
|
export interface RestoreDeps {
|
|
@@ -32,6 +39,8 @@ export interface RestoreInput {
|
|
|
32
39
|
indexFile: string;
|
|
33
40
|
/** Snapshot to restore. */
|
|
34
41
|
target: Snapshot;
|
|
42
|
+
/** Restore only these paths; omitted means the whole tree. */
|
|
43
|
+
paths?: readonly string[];
|
|
35
44
|
}
|
|
36
45
|
|
|
37
46
|
export interface RestorePlan {
|
|
@@ -41,6 +50,8 @@ export interface RestorePlan {
|
|
|
41
50
|
changed: number;
|
|
42
51
|
/** Paths created since the snapshot that the restore removes. */
|
|
43
52
|
removed: number;
|
|
53
|
+
/** Every changed path, with how the restore treats it. */
|
|
54
|
+
paths: ChangedPath[];
|
|
44
55
|
}
|
|
45
56
|
|
|
46
57
|
export type PlanResult = { ok: true; plan: RestorePlan } | { ok: false; reason: string };
|
|
@@ -51,26 +62,32 @@ export async function planRestore(deps: RestoreDeps, input: RestoreInput): Promi
|
|
|
51
62
|
if (busy) {
|
|
52
63
|
return { ok: false, reason: `a git ${busy} is in progress; finish or abort it before restoring.` };
|
|
53
64
|
}
|
|
54
|
-
const
|
|
55
|
-
const
|
|
56
|
-
|
|
65
|
+
const cur = await stagedTree(deps, input);
|
|
66
|
+
const paths = await changedPaths(deps, input, cur);
|
|
67
|
+
const diff = await diffStat(deps.runGit, input.root, input.target.commit, cur, selectionOf(paths));
|
|
68
|
+
return { ok: true, plan: { diff, ...summarize(paths), paths } };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The selection narrowed to paths the plan actually found, or undefined for all. */
|
|
72
|
+
function selectionOf(paths: readonly ChangedPath[]): string[] | undefined {
|
|
73
|
+
return paths.length > 0 ? paths.map((entry) => entry.path) : undefined;
|
|
57
74
|
}
|
|
58
75
|
|
|
59
76
|
/**
|
|
60
|
-
* Stage the working tree into the temporary index
|
|
61
|
-
*
|
|
62
|
-
*
|
|
77
|
+
* Stage the working tree into the temporary index, so previews compare
|
|
78
|
+
* tree-to-tree: untracked files captured in the snapshot are not mistaken for
|
|
79
|
+
* deletions against the real index.
|
|
63
80
|
*/
|
|
64
|
-
async function
|
|
65
|
-
deps
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
const
|
|
73
|
-
return
|
|
81
|
+
async function stagedTree(deps: RestoreDeps, input: RestoreInput): Promise<string> {
|
|
82
|
+
return treeFromWorkingTree(deps.runGit, input.root, input.indexFile, input.target.includeUntracked);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Every changed path, narrowed to the caller's selection when one is given. */
|
|
86
|
+
async function changedPaths(deps: RestoreDeps, input: RestoreInput, cur: string): Promise<ChangedPath[]> {
|
|
87
|
+
const all = await nameStatus(deps.runGit, input.root, input.target.commit, cur);
|
|
88
|
+
if (!input.paths) return all;
|
|
89
|
+
const selected = new Set(input.paths);
|
|
90
|
+
return all.filter((entry) => selected.has(entry.path));
|
|
74
91
|
}
|
|
75
92
|
|
|
76
93
|
/** Delete a path under `root`, refusing to escape it or remove a directory. */
|
|
@@ -85,12 +102,36 @@ function deleteFile(root: string, relative: string): void {
|
|
|
85
102
|
}
|
|
86
103
|
}
|
|
87
104
|
|
|
88
|
-
/**
|
|
105
|
+
/**
|
|
106
|
+
* Apply the restore. Recomputes the changed set immediately before mutating, so a
|
|
107
|
+
* file edited while the confirmation was open is still handled correctly.
|
|
108
|
+
*/
|
|
89
109
|
export async function applyRestore(deps: RestoreDeps, input: RestoreInput): Promise<RestoreSummary> {
|
|
90
|
-
const
|
|
110
|
+
const cur = await stagedTree(deps, input);
|
|
111
|
+
const paths = await changedPaths(deps, input, cur);
|
|
112
|
+
const { changed, removed } = summarize(paths);
|
|
113
|
+
const rewritten = paths.filter((entry) => entry.change !== "added").map((entry) => entry.path);
|
|
114
|
+
const created = paths.filter((entry) => entry.change === "added").map((entry) => entry.path);
|
|
115
|
+
|
|
116
|
+
if (input.paths) {
|
|
117
|
+
await restorePaths(deps.runGit, input.root, input.target.commit, input.indexFile, rewritten);
|
|
118
|
+
} else {
|
|
119
|
+
// The whole tree in one call: this also removes files the snapshot lacks,
|
|
120
|
+
// which a pathspec checkout of every changed path would not.
|
|
121
|
+
await readTreeReset(deps.runGit, input.root, input.target.commit, input.indexFile);
|
|
122
|
+
}
|
|
123
|
+
for (const path of created) deleteFile(input.root, path);
|
|
91
124
|
|
|
92
|
-
|
|
93
|
-
|
|
125
|
+
// A subset notice says "2 of 5 files", so it needs the whole diff's size; the
|
|
126
|
+
// all-path query is already in hand when nothing was narrowed.
|
|
127
|
+
const of = input.paths ? (await nameStatus(deps.runGit, input.root, input.target.commit, cur)).length : paths.length;
|
|
94
128
|
|
|
95
|
-
return {
|
|
129
|
+
return {
|
|
130
|
+
id: input.target.id,
|
|
131
|
+
commit: input.target.commit,
|
|
132
|
+
root: input.root,
|
|
133
|
+
changed,
|
|
134
|
+
removed,
|
|
135
|
+
...(input.paths ? { of } : {}),
|
|
136
|
+
};
|
|
96
137
|
}
|
|
@@ -9,7 +9,11 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
12
|
+
import { withRailsSuppressed } from "../../lib/rails.ts";
|
|
13
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
12
14
|
import { formatChangeSummary, formatRestoreText, previewDiffText } from "./format.ts";
|
|
15
|
+
import { PathPickerComponent } from "./path-picker.ts";
|
|
16
|
+
import type { ChangedPath } from "./paths.ts";
|
|
13
17
|
import type { RewindRuntime } from "./runtime.ts";
|
|
14
18
|
import { rewindScopes, type RewindPoint, type RewindScope } from "./timeline.ts";
|
|
15
19
|
|
|
@@ -47,19 +51,67 @@ async function chooseScope(
|
|
|
47
51
|
}
|
|
48
52
|
|
|
49
53
|
/** Build the combined confirm body, including the diff preview when code moves. */
|
|
50
|
-
function confirmBody(
|
|
54
|
+
function confirmBody(
|
|
55
|
+
scope: RewindScope,
|
|
56
|
+
changeSummary: string | undefined,
|
|
57
|
+
diff: string | undefined,
|
|
58
|
+
subset?: { selected: number; total: number },
|
|
59
|
+
): string {
|
|
51
60
|
const lines = [scopeDescription(scope)];
|
|
61
|
+
if (subset) lines.push(`Restoring ${subset.selected} of ${subset.total} changed files.`);
|
|
52
62
|
if (changeSummary) lines.push(`${changeSummary} since the snapshot. HEAD is untouched.`);
|
|
53
63
|
if (diff) lines.push("", previewDiffText(diff));
|
|
54
64
|
if (scope !== "code") lines.push("", "This prompt returns to the editor so you can edit and resubmit.");
|
|
55
65
|
return lines.join("\n");
|
|
56
66
|
}
|
|
57
67
|
|
|
68
|
+
/** What the path step decided: the whole tree, a chosen subset, or nothing. */
|
|
69
|
+
export type PathChoice = { kind: "all" } | { kind: "some"; paths: string[] } | { kind: "cancel" };
|
|
70
|
+
|
|
71
|
+
/** The two answers the path step offers before the checklist opens. */
|
|
72
|
+
const ALL_LABEL = (count: number): string => `Everything (${count} file${count === 1 ? "" : "s"})`;
|
|
73
|
+
const SOME_LABEL = "Choose paths…";
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Ask which paths to restore. The checklist needs a list only a TUI can mount, so
|
|
77
|
+
* every other mode keeps the whole-tree restore rather than inventing a typed
|
|
78
|
+
* syntax with its own parser and preview.
|
|
79
|
+
*/
|
|
80
|
+
export async function choosePaths(
|
|
81
|
+
pi: ExtensionAPI,
|
|
82
|
+
ctx: ExtensionCommandContext,
|
|
83
|
+
paths: readonly ChangedPath[],
|
|
84
|
+
): Promise<PathChoice> {
|
|
85
|
+
if (ctx.mode !== "tui" || paths.length === 0) return { kind: "all" };
|
|
86
|
+
|
|
87
|
+
const choice = await ctx.ui.select("Restore what?", [ALL_LABEL(paths.length), SOME_LABEL]);
|
|
88
|
+
if (choice === undefined) return { kind: "cancel" };
|
|
89
|
+
if (choice !== SOME_LABEL) return { kind: "all" };
|
|
90
|
+
|
|
91
|
+
const selected = await withRailsSuppressed(pi, () =>
|
|
92
|
+
ctx.ui.custom<string[] | undefined>(
|
|
93
|
+
(tui, theme, _keybindings, done) =>
|
|
94
|
+
new PathPickerComponent({
|
|
95
|
+
paths,
|
|
96
|
+
theme,
|
|
97
|
+
onClose: (subset) => done(subset),
|
|
98
|
+
requestRender: () => tui.requestRender(),
|
|
99
|
+
viewportRows: () => tui.terminal?.rows,
|
|
100
|
+
}),
|
|
101
|
+
),
|
|
102
|
+
);
|
|
103
|
+
if (!selected) return { kind: "cancel" };
|
|
104
|
+
// An empty checklist is a choice, not a cancel: it restores no files and still
|
|
105
|
+
// moves the conversation, which is what the confirm describes.
|
|
106
|
+
return { kind: "some", paths: selected };
|
|
107
|
+
}
|
|
108
|
+
|
|
58
109
|
/**
|
|
59
110
|
* Rewind to `point` with the chosen scope. Interactive only: callers guard on
|
|
60
111
|
* `ctx.hasUI`. Returns true when something was applied.
|
|
61
112
|
*/
|
|
62
113
|
export async function rewindTo(
|
|
114
|
+
pi: ExtensionAPI,
|
|
63
115
|
runtime: RewindRuntime,
|
|
64
116
|
ctx: ExtensionCommandContext,
|
|
65
117
|
root: string | undefined,
|
|
@@ -81,6 +133,9 @@ export async function rewindTo(
|
|
|
81
133
|
}
|
|
82
134
|
|
|
83
135
|
let plan;
|
|
136
|
+
/** The subset to restore, when the path step narrowed the plan. */
|
|
137
|
+
let selection: string[] | undefined;
|
|
138
|
+
let total = 0;
|
|
84
139
|
if (snapshot && root) {
|
|
85
140
|
const result = await runtime.plan(root, snapshot);
|
|
86
141
|
if (!result.ok) {
|
|
@@ -88,12 +143,34 @@ export async function rewindTo(
|
|
|
88
143
|
return false;
|
|
89
144
|
}
|
|
90
145
|
plan = result.plan;
|
|
146
|
+
total = plan.paths.length;
|
|
147
|
+
if (plan.paths.length > 0) {
|
|
148
|
+
const choice = await choosePaths(pi, ctx, plan.paths);
|
|
149
|
+
if (choice.kind === "cancel") {
|
|
150
|
+
ctx.ui.notify("Rewind cancelled.", "info");
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
if (choice.kind === "some") {
|
|
154
|
+
selection = choice.paths;
|
|
155
|
+
const narrowed = await runtime.plan(root, snapshot, selection);
|
|
156
|
+
if (!narrowed.ok) {
|
|
157
|
+
ctx.ui.notify(narrowed.reason, "warning");
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
plan = narrowed.plan;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
91
163
|
}
|
|
92
164
|
|
|
93
165
|
const label = point.summary || point.prompt || "this prompt";
|
|
94
166
|
const approved = await ctx.ui.confirm(
|
|
95
167
|
`Rewind to "${label}"?`,
|
|
96
|
-
confirmBody(
|
|
168
|
+
confirmBody(
|
|
169
|
+
scope,
|
|
170
|
+
plan ? formatChangeSummary(plan.changed, plan.removed) : undefined,
|
|
171
|
+
plan?.diff,
|
|
172
|
+
selection ? { selected: plan?.paths.length ?? 0, total } : undefined,
|
|
173
|
+
),
|
|
97
174
|
);
|
|
98
175
|
if (!approved) {
|
|
99
176
|
ctx.ui.notify("Rewind cancelled.", "info");
|
|
@@ -103,7 +180,7 @@ export async function rewindTo(
|
|
|
103
180
|
const notes: string[] = [];
|
|
104
181
|
if (snapshot && root) {
|
|
105
182
|
try {
|
|
106
|
-
const restored = await runtime.restore(ctx, root, snapshot, runtime.configFor(root));
|
|
183
|
+
const restored = await runtime.restore(ctx, root, snapshot, runtime.configFor(root), selection);
|
|
107
184
|
await runtime.setStatus(ctx);
|
|
108
185
|
notes.push(formatRestoreText(restored));
|
|
109
186
|
} catch (error) {
|
|
@@ -33,8 +33,15 @@ export interface RewindRuntime {
|
|
|
33
33
|
/** Create and store a snapshot of the current working tree. */
|
|
34
34
|
snapshot(ctx: ExtensionContext, options: SnapshotOptions): Promise<Snapshot>;
|
|
35
35
|
list(root: string): Promise<Snapshot[]>;
|
|
36
|
-
|
|
37
|
-
|
|
36
|
+
/** Preview a restore; `paths` narrows it to a subset of the changed paths. */
|
|
37
|
+
plan(root: string, target: Snapshot, paths?: readonly string[]): Promise<PlanResult>;
|
|
38
|
+
restore(
|
|
39
|
+
ctx: ExtensionContext,
|
|
40
|
+
root: string,
|
|
41
|
+
target: Snapshot,
|
|
42
|
+
config: RewindConfig,
|
|
43
|
+
paths?: readonly string[],
|
|
44
|
+
): Promise<RestoreSummary>;
|
|
38
45
|
/** Repaint the `↺ N` chip from the prompt count on the active branch (`/rewind` list size). */
|
|
39
46
|
setStatus(ctx: ExtensionContext): Promise<void>;
|
|
40
47
|
clearStatus(ctx: ExtensionContext): void;
|
|
@@ -110,10 +117,10 @@ export function createRuntime(pi: ExtensionAPI): RewindRuntime {
|
|
|
110
117
|
const list = (root: string): Promise<Snapshot[]> =>
|
|
111
118
|
listSnapshots(runGit, root, configFor(root).refNamespace, { root });
|
|
112
119
|
|
|
113
|
-
const plan = (root: string, target: Snapshot): Promise<PlanResult> =>
|
|
120
|
+
const plan = (root: string, target: Snapshot, paths?: readonly string[]): Promise<PlanResult> =>
|
|
114
121
|
queue.enqueue(async () => {
|
|
115
122
|
if (!belongsToRoot(target, root)) return { ok: false, reason: foreignRootReason(target, root) };
|
|
116
|
-
return planRestore({ runGit }, { root, indexFile: await indexStore.pathFor(root), target });
|
|
123
|
+
return planRestore({ runGit }, { root, indexFile: await indexStore.pathFor(root), target, paths });
|
|
117
124
|
});
|
|
118
125
|
|
|
119
126
|
const restore = (
|
|
@@ -121,11 +128,12 @@ export function createRuntime(pi: ExtensionAPI): RewindRuntime {
|
|
|
121
128
|
root: string,
|
|
122
129
|
target: Snapshot,
|
|
123
130
|
config: RewindConfig,
|
|
131
|
+
paths?: readonly string[],
|
|
124
132
|
): Promise<RestoreSummary> =>
|
|
125
133
|
queue.enqueue(async () => {
|
|
126
134
|
if (!belongsToRoot(target, root)) throw new Error(foreignRootReason(target, root));
|
|
127
135
|
const indexFile = await indexStore.pathFor(root);
|
|
128
|
-
const input: RestoreInput = { root, indexFile, target };
|
|
136
|
+
const input: RestoreInput = { root, indexFile, target, paths };
|
|
129
137
|
let safety: string | undefined;
|
|
130
138
|
if (config.safetySnapshot) {
|
|
131
139
|
const before = await createSnapshot(
|
|
@@ -15,8 +15,11 @@ import { createSnapshotPolicy, type SnapshotPolicy } from "./policy.ts";
|
|
|
15
15
|
import type { RewindRuntime } from "./runtime.ts";
|
|
16
16
|
|
|
17
17
|
export interface SnapshotRun {
|
|
18
|
-
/**
|
|
19
|
-
|
|
18
|
+
/**
|
|
19
|
+
* Start a run for `prompt` — `undefined` when a custom message started it: reset
|
|
20
|
+
* run state and resolve the snapshot decision.
|
|
21
|
+
*/
|
|
22
|
+
beginRun(prompt: string | undefined, ctx: ExtensionContext): Promise<void>;
|
|
20
23
|
/** Take the run's single snapshot before the first mutating call, at most once. */
|
|
21
24
|
onToolCall(toolName: string, ctx: ExtensionContext): Promise<void>;
|
|
22
25
|
/** Drop run-scoped state (on session shutdown). */
|
|
@@ -53,6 +53,8 @@ export interface RestoreSummary {
|
|
|
53
53
|
changed: number;
|
|
54
54
|
/** Files deleted because they were created after the snapshot. */
|
|
55
55
|
removed: number;
|
|
56
|
+
/** Paths the whole diff touched, present only when a subset was restored. */
|
|
57
|
+
of?: number;
|
|
56
58
|
/** Id of the pre-restore safety snapshot, when one was taken. */
|
|
57
59
|
safety?: string;
|
|
58
60
|
}
|
|
@@ -236,7 +236,13 @@ step's `Output`.
|
|
|
236
236
|
they are not written to the parent session.
|
|
237
237
|
- **No cost attribution to the parent session.** A subagent's tokens and cost are
|
|
238
238
|
captured in the tool result and shown in the transcript, but they belong to a
|
|
239
|
-
separate process and are not added to the dispatching session's totals.
|
|
239
|
+
separate process and are not added to the dispatching session's totals. Instead,
|
|
240
|
+
each finished run publishes `my-pi-agent/subagent-finished`
|
|
241
|
+
(`SubagentFinishedEvent` in `lib/events.ts`) and bytefly's footer shows the
|
|
242
|
+
session's child runs as their own `↳` roll-up, priced separately from the
|
|
243
|
+
session total (the last 500 runs). The roll-up is per process: a resumed session
|
|
244
|
+
starts from zero, and a run that never produced an assistant message publishes
|
|
245
|
+
nothing.
|
|
240
246
|
|
|
241
247
|
## Pi integration
|
|
242
248
|
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The accounting event a finished child run publishes.
|
|
3
|
+
*
|
|
4
|
+
* `subagent` runs each child in its own `pi` process, so the dispatcher's own
|
|
5
|
+
* usage totals never include what the child spent. The event carries the child's
|
|
6
|
+
* pricing inputs (provider, model, token counts, Pi's recorded dollar cost, finish
|
|
7
|
+
* time) rather than a total, so the consumer can price them with the same rate
|
|
8
|
+
* table and currency it uses for the session — and re-price them after a currency
|
|
9
|
+
* change.
|
|
10
|
+
*
|
|
11
|
+
* A run with no assistant message spent nothing observable (an unknown agent, a
|
|
12
|
+
* spawn failure), so it publishes nothing at all.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { SubagentFinishedEvent } from "../../lib/events.ts";
|
|
16
|
+
import type { SingleResult } from "./types.ts";
|
|
17
|
+
|
|
18
|
+
/** The event for one finished run, or `null` when it produced no usage. */
|
|
19
|
+
export function finishedEvent(result: SingleResult): SubagentFinishedEvent | null {
|
|
20
|
+
const { usage } = result;
|
|
21
|
+
if (usage.turns === 0) return null;
|
|
22
|
+
return {
|
|
23
|
+
agent: result.agent,
|
|
24
|
+
provider: result.provider ?? null,
|
|
25
|
+
model: result.model ?? null,
|
|
26
|
+
input: usage.input,
|
|
27
|
+
output: usage.output,
|
|
28
|
+
cacheRead: usage.cacheRead,
|
|
29
|
+
cacheWrite: usage.cacheWrite,
|
|
30
|
+
recordedCost: usage.cost,
|
|
31
|
+
at: result.finishedAt ?? null,
|
|
32
|
+
exitCode: result.exitCode,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
@@ -27,6 +27,8 @@ import {
|
|
|
27
27
|
} from "./agents.ts";
|
|
28
28
|
import { runChainMode, runParallelMode, runSingleMode, type ModeContext } from "./orchestrate.ts";
|
|
29
29
|
import { renderSubagentCall, renderSubagentResult } from "./render.ts";
|
|
30
|
+
import { SUBAGENT_FINISHED_CHANNEL } from "../../lib/events.ts";
|
|
31
|
+
import { finishedEvent } from "./accounting.ts";
|
|
30
32
|
import { runSingleAgent, type RunOptions } from "./run.ts";
|
|
31
33
|
import { SubagentParams, resolveMode, type SubagentArgs } from "./schema.ts";
|
|
32
34
|
import type { DispatchDefaults, SingleResult, SubagentDetails } from "./types.ts";
|
|
@@ -65,7 +67,27 @@ function requestedAgentNames(args: SubagentArgs): string[] {
|
|
|
65
67
|
|
|
66
68
|
export default function subagent(pi: ExtensionAPI, deps: SubagentDeps = {}): void {
|
|
67
69
|
if (!isExtensionEnabled("subagent")) return;
|
|
68
|
-
const
|
|
70
|
+
const runner = deps.run ?? runSingleAgent;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Account for each finished child, then hand the result on unchanged.
|
|
74
|
+
*
|
|
75
|
+
* Every mode (`single`, `parallel` per task, `chain` per step) calls this once
|
|
76
|
+
* per child, so one wrapper reports each run exactly once. The emit is guarded:
|
|
77
|
+
* a bus listener that throws must not fail a delegation that already ran.
|
|
78
|
+
*/
|
|
79
|
+
const run = async (options: RunOptions): Promise<SingleResult> => {
|
|
80
|
+
const result = await runner(options);
|
|
81
|
+
const event = finishedEvent(result);
|
|
82
|
+
if (event) {
|
|
83
|
+
try {
|
|
84
|
+
pi.events.emit(SUBAGENT_FINISHED_CHANNEL, event);
|
|
85
|
+
} catch {
|
|
86
|
+
// Accounting is best-effort; the run's own result is what matters.
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return result;
|
|
90
|
+
};
|
|
69
91
|
|
|
70
92
|
pi.registerTool({
|
|
71
93
|
name: TOOL_NAME,
|
|
@@ -47,6 +47,7 @@ export function applyEvent(result: SingleResult, event: unknown): boolean {
|
|
|
47
47
|
|
|
48
48
|
if (message.role === "assistant") {
|
|
49
49
|
const assistant = message as Message & {
|
|
50
|
+
provider?: string;
|
|
50
51
|
model?: string;
|
|
51
52
|
stopReason?: string;
|
|
52
53
|
errorMessage?: string;
|
|
@@ -69,6 +70,7 @@ export function applyEvent(result: SingleResult, event: unknown): boolean {
|
|
|
69
70
|
result.usage.cost += usage.cost?.total ?? 0;
|
|
70
71
|
result.usage.contextTokens = usage.totalTokens ?? result.usage.contextTokens;
|
|
71
72
|
}
|
|
73
|
+
if (!result.provider && assistant.provider) result.provider = assistant.provider;
|
|
72
74
|
if (!result.model && assistant.model) result.model = assistant.model;
|
|
73
75
|
if (assistant.stopReason) result.stopReason = assistant.stopReason;
|
|
74
76
|
if (assistant.errorMessage) result.errorMessage = assistant.errorMessage;
|
|
@@ -37,6 +37,8 @@ export interface SingleResult {
|
|
|
37
37
|
messages: Message[];
|
|
38
38
|
stderr: string;
|
|
39
39
|
usage: UsageStats;
|
|
40
|
+
/** Provider and model the child reported; published for the accounting event. */
|
|
41
|
+
provider?: string;
|
|
40
42
|
model?: string;
|
|
41
43
|
stopReason?: string;
|
|
42
44
|
errorMessage?: string;
|
package/extensions/todo/nudge.ts
CHANGED
|
@@ -86,7 +86,11 @@ export function shouldNudge({ mutations, writes, touched, nudged, todos }: Nudge
|
|
|
86
86
|
return todos.length === 0 && !touched && writes > 0 && mutations >= EMPTY_LIST_MUTATIONS;
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
-
/**
|
|
89
|
+
/**
|
|
90
|
+
* Marker on the injected reminder, so it is recognizable and filterable.
|
|
91
|
+
*
|
|
92
|
+
* allow:todo the marker is text the model reads, not a to-do for a human.
|
|
93
|
+
*/
|
|
90
94
|
export const TODO_NUDGE_MARKER = "[TODO SYNC]";
|
|
91
95
|
|
|
92
96
|
/**
|
|
@@ -29,9 +29,13 @@ How the `watch` call and its result look in the transcript — the call line as
|
|
|
29
29
|
- Shows a `⊚ N` running-watcher chip and a `! N` unreported-match chip, raises a
|
|
30
30
|
`ctx.ui.notify` alert once per unreported burst, and offers a `/watch` screen.
|
|
31
31
|
- Reports pending matches to the model at the start of the next turn, or wakes a
|
|
32
|
-
turn for a watcher added with `wake: true`.
|
|
33
|
-
|
|
34
|
-
|
|
32
|
+
turn for a watcher added with `wake: true`. A wake also leaves the terminal: the
|
|
33
|
+
match is announced on `my-pi-agent/watch-match`, which bytefly's
|
|
34
|
+
[notify](../bytefly/README.md#notifications) capability turns into a desktop
|
|
35
|
+
notification when `notify.enabled` and `notify.matchFound` are on.
|
|
36
|
+
- Watchers are session-only: `shutdown` flushes a partial line, sends SIGTERM to
|
|
37
|
+
every command's process tree (escalating to SIGKILL after a short grace), and
|
|
38
|
+
clears the chips.
|
|
35
39
|
|
|
36
40
|
## Tool
|
|
37
41
|
|
|
@@ -39,7 +43,7 @@ How the `watch` call and its result look in the transcript — the call line as
|
|
|
39
43
|
|---|---|
|
|
40
44
|
| `name` | `watch` |
|
|
41
45
|
| `action` | `add` \| `list` \| `status` \| `matches` \| `logs` \| `stop` \| `clear` |
|
|
42
|
-
| `path` |
|
|
46
|
+
| `path` | Regular file to tail (`add`); exactly one of `path`/`command`. |
|
|
43
47
|
| `command` | Shell command whose output to tail (`add`). |
|
|
44
48
|
| `cwd` | Working directory for a command source; defaults to the effective cwd. |
|
|
45
49
|
| `pattern` | Regular expression a line must match (`add`, required). |
|
|
@@ -128,7 +132,7 @@ value is clamped; a missing or malformed document is ignored.
|
|
|
128
132
|
|
|
129
133
|
| Key | Default | Meaning |
|
|
130
134
|
|---|---|---|
|
|
131
|
-
| `enabled` | `true` |
|
|
135
|
+
| `enabled` | `true` | Allow `add`; `false` refuses to start a watcher. |
|
|
132
136
|
| `showStatus` | `true` | Show the two status chips. |
|
|
133
137
|
| `wakeOnMatch` | `false` | Default for a new watcher's `wake`; a per-watcher `wake` overrides it. |
|
|
134
138
|
| `maxWatchers` | `20` | Live watchers before `add` is refused. |
|
|
@@ -157,17 +161,21 @@ other.
|
|
|
157
161
|
|
|
158
162
|
- A file watcher polls, so a new line can take up to `pollMs` to surface; it does
|
|
159
163
|
not use `fs.watch`.
|
|
164
|
+
- `add` requires a path that exists and is a regular file; a directory, FIFO, or
|
|
165
|
+
a file created after the call is refused.
|
|
160
166
|
- A watcher holds live state in memory and is stopped on shutdown; a detached or
|
|
161
167
|
resumed session starts with no watchers.
|
|
162
168
|
- `matches` and `logs` keep bounded histories (`maxMatches`/`maxLogLines`), so
|
|
163
169
|
older lines are dropped.
|
|
164
170
|
- Matching is per line; a pattern cannot span a newline.
|
|
171
|
+
- Only a **wake** match raises an OS notification, and only when bytefly's
|
|
172
|
+
`notify.enabled` and `notify.matchFound` are on. A match collected while the
|
|
173
|
+
agent was busy is reported at the next turn without a notification, and `watch`
|
|
174
|
+
itself never sends one — it announces the match and bytefly owns delivery.
|
|
165
175
|
|
|
166
176
|
## Non-goals
|
|
167
177
|
|
|
168
178
|
- Persisting or reattaching watchers across sessions.
|
|
169
|
-
- Notifying through bytefly's OS-notification capability (the `job` extension
|
|
170
|
-
owns that channel; `watch` alerts in-session with `ctx.ui.notify`).
|
|
171
179
|
- Multiline or stateful matching, log rotation hooks, and per-watcher poll
|
|
172
180
|
intervals.
|
|
173
181
|
|
|
@@ -195,7 +203,10 @@ UTF-8 sequence split across a read boundary is never mangled. State is
|
|
|
195
203
|
session-only by design: a watcher is a live process or file position, not
|
|
196
204
|
transcript state, so it cannot be faithfully rebuilt from a `/tree` fork. Match
|
|
197
205
|
delivery mirrors `job` — reported at the next turn, with `wake` triggering one
|
|
198
|
-
turn — so a match is never dropped when the wake window is busy.
|
|
206
|
+
turn — so a match is never dropped when the wake window is busy. Stopping a
|
|
207
|
+
command sends SIGTERM to its process group and escalates to SIGKILL after a 2 s
|
|
208
|
+
grace; shutdown waits the same grace, so a command that ignores SIGTERM cannot
|
|
209
|
+
outlive the session.
|
|
199
210
|
|
|
200
211
|
## Files
|
|
201
212
|
|
|
@@ -14,7 +14,7 @@ import type { WatchRuntime } from "./runtime.ts";
|
|
|
14
14
|
import { WatchListComponent } from "./tui.ts";
|
|
15
15
|
|
|
16
16
|
/** How often the open screen re-reads live state. */
|
|
17
|
-
const
|
|
17
|
+
const REFRESH_MS = 500;
|
|
18
18
|
|
|
19
19
|
const SUBCOMMANDS = [
|
|
20
20
|
{ value: "list", label: "list" },
|
|
@@ -125,12 +125,13 @@ export function registerCommands(pi: ExtensionAPI, runtime: WatchRuntime): void
|
|
|
125
125
|
() => tui.terminal?.rows,
|
|
126
126
|
);
|
|
127
127
|
|
|
128
|
+
// The runtime's own clock owns file polling; this only refreshes
|
|
129
|
+
// what the screen shows, so opening it cannot change the poll cadence.
|
|
128
130
|
timer = setInterval(() => {
|
|
129
|
-
runtime.tick();
|
|
130
131
|
const open = component.currentLineId();
|
|
131
132
|
if (open) component.refreshLines(open);
|
|
132
133
|
tui.requestRender();
|
|
133
|
-
},
|
|
134
|
+
}, REFRESH_MS);
|
|
134
135
|
timer.unref?.();
|
|
135
136
|
|
|
136
137
|
return component;
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import { clampInteger, loadConfigSection } from "../../lib/config.ts";
|
|
10
10
|
|
|
11
11
|
export interface WatchConfig {
|
|
12
|
-
/**
|
|
12
|
+
/** Allow starting watchers; false refuses `add`. Default: true. */
|
|
13
13
|
enabled: boolean;
|
|
14
14
|
/** Show the status chips while watchers run or a match is unreported. */
|
|
15
15
|
showStatus: boolean;
|
|
@@ -10,15 +10,12 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
13
|
-
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
14
13
|
import { sanitizeLine } from "../../lib/format.ts";
|
|
14
|
+
import { clip, labelLine } from "../../lib/tui/columns.ts";
|
|
15
15
|
import { GLYPHS, SEPARATORS } from "../../lib/tui/glyphs.ts";
|
|
16
16
|
import { paint, type TuiRole } from "../../lib/tui/tokens.ts";
|
|
17
17
|
import type { PendingMatch, WatchRecord, WatchStatus } from "./types.ts";
|
|
18
18
|
|
|
19
|
-
/** Longest label shown in the transcript or `/watch` screen. */
|
|
20
|
-
const MAX_DISPLAY_LABEL = 48;
|
|
21
|
-
|
|
22
19
|
/** Longest sanitized watched line kept for display; longer lines are clipped. */
|
|
23
20
|
const MAX_WATCH_LINE = 200;
|
|
24
21
|
|
|
@@ -50,7 +47,7 @@ export function compileRegex(pattern: string, caseSensitive: boolean): RegExp {
|
|
|
50
47
|
|
|
51
48
|
/** A label clipped to one safe display line. */
|
|
52
49
|
export function clipLabel(text: string): string {
|
|
53
|
-
return
|
|
50
|
+
return labelLine(text, sanitizeWatchLine);
|
|
54
51
|
}
|
|
55
52
|
|
|
56
53
|
/** What a watcher reads: a path or a command, clipped for display. */
|
|
@@ -177,9 +174,9 @@ export function formatCallText(action: string, args: Record<string, unknown>, ar
|
|
|
177
174
|
const command = typeof args.command === "string" ? args.command.trim() : "";
|
|
178
175
|
const source = path ? path : command ? `$ ${command}` : "";
|
|
179
176
|
if (!source) return argsComplete ? "add" : "add …";
|
|
180
|
-
const preview =
|
|
177
|
+
const preview = labelLine(source);
|
|
181
178
|
const pattern = typeof args.pattern === "string" ? args.pattern.trim() : "";
|
|
182
|
-
return pattern ? `add ${preview} /${
|
|
179
|
+
return pattern ? `add ${preview} /${clip(sanitizeLine(pattern), 24)}/` : `add ${preview}`;
|
|
183
180
|
}
|
|
184
181
|
case "status":
|
|
185
182
|
case "matches":
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
14
14
|
import { isExtensionEnabled } from "../../lib/env.ts";
|
|
15
|
+
import { WATCH_MATCH_CHANNEL, type WatchMatchEvent } from "../../lib/events.ts";
|
|
15
16
|
import { sanitizeLine } from "../../lib/format.ts";
|
|
16
17
|
import { noticeMessage, type NoticeMessage, pruneStaleNotices } from "../../lib/notice.ts";
|
|
17
18
|
import { STATUS_KEYS } from "../../lib/tui/glyphs.ts";
|
|
@@ -80,6 +81,24 @@ export default function watch(pi: ExtensionAPI, deps: WatchDeps = {}): void {
|
|
|
80
81
|
waking = true;
|
|
81
82
|
} catch {
|
|
82
83
|
// Waking is best-effort; the next turn still reports the matches.
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
// A wake means the user is away from the terminal, so the alert also leaves
|
|
87
|
+
// the process. bytefly's notify capability owns delivery; this publishes the
|
|
88
|
+
// fact and never learns how it is sent. The newest match is the one an alert
|
|
89
|
+
// is about, while the count covers the whole cycle.
|
|
90
|
+
const newest = pending[pending.length - 1];
|
|
91
|
+
if (!newest) return;
|
|
92
|
+
try {
|
|
93
|
+
const event: WatchMatchEvent = {
|
|
94
|
+
count: pending.length,
|
|
95
|
+
watcherId: newest.watcherId,
|
|
96
|
+
label: newest.label,
|
|
97
|
+
line: newest.line,
|
|
98
|
+
};
|
|
99
|
+
pi.events.emit(WATCH_MATCH_CHANNEL, event);
|
|
100
|
+
} catch {
|
|
101
|
+
// A throwing listener must not cost the wake that already happened.
|
|
83
102
|
}
|
|
84
103
|
};
|
|
85
104
|
|