@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.
Files changed (167) hide show
  1. package/README.md +28 -21
  2. package/README.zh-CN.md +30 -22
  3. package/extensions/bytefly/README.md +162 -70
  4. package/extensions/bytefly/chrome.ts +7 -2
  5. package/extensions/bytefly/doctor/register.ts +6 -0
  6. package/extensions/bytefly/doctor/runtime.ts +38 -6
  7. package/extensions/bytefly/footer/budget.ts +64 -0
  8. package/extensions/bytefly/footer/collect/snapshot.ts +33 -5
  9. package/extensions/bytefly/footer/config.ts +7 -1
  10. package/extensions/bytefly/footer/delegation.ts +100 -0
  11. package/extensions/bytefly/footer/price/config.ts +16 -0
  12. package/extensions/bytefly/footer/price/types.ts +5 -0
  13. package/extensions/bytefly/footer/register.ts +46 -4
  14. package/extensions/bytefly/footer/render/lines.ts +2 -0
  15. package/extensions/bytefly/footer/render/segments.ts +27 -0
  16. package/extensions/bytefly/footer/types.ts +11 -0
  17. package/extensions/bytefly/index.ts +8 -5
  18. package/extensions/bytefly/notify/config.ts +2 -0
  19. package/extensions/bytefly/notify/format.ts +19 -1
  20. package/extensions/bytefly/notify/register.ts +52 -14
  21. package/extensions/bytefly/notify/types.ts +2 -0
  22. package/extensions/bytefly/panel/commands.ts +34 -13
  23. package/extensions/bytefly/panel/settings.ts +129 -16
  24. package/extensions/bytefly/update/cache.ts +73 -0
  25. package/extensions/bytefly/update/format.ts +65 -10
  26. package/extensions/bytefly/update/register.ts +99 -13
  27. package/extensions/bytefly/update/state.ts +13 -1
  28. package/extensions/document/README.md +22 -12
  29. package/extensions/document/config.ts +4 -0
  30. package/extensions/document/deadline.ts +61 -0
  31. package/extensions/document/tool.ts +13 -2
  32. package/extensions/file-browse/README.md +12 -8
  33. package/extensions/file-browse/client.ts +6 -7
  34. package/extensions/file-browse/files.ts +0 -4
  35. package/extensions/file-browse/git.ts +9 -10
  36. package/extensions/file-browse/html.ts +17 -7
  37. package/extensions/file-browse/router.ts +24 -15
  38. package/extensions/goal/README.md +7 -1
  39. package/extensions/goal/schema.ts +6 -2
  40. package/extensions/goal/types.ts +6 -2
  41. package/extensions/job/README.md +16 -12
  42. package/extensions/job/format.ts +4 -7
  43. package/extensions/job/runtime.ts +23 -6
  44. package/extensions/job/status.ts +4 -2
  45. package/extensions/memory/README.md +55 -13
  46. package/extensions/memory/commands.ts +20 -2
  47. package/extensions/memory/doctor.ts +137 -0
  48. package/extensions/memory/format.ts +24 -7
  49. package/extensions/memory/runtime.ts +49 -0
  50. package/extensions/memory/store.ts +31 -0
  51. package/extensions/memory/tui.ts +136 -12
  52. package/extensions/plan/README.md +60 -29
  53. package/extensions/plan/commands.ts +67 -8
  54. package/extensions/plan/index.ts +35 -11
  55. package/extensions/plan/plan-index.ts +234 -0
  56. package/extensions/plan/plan-text.ts +95 -0
  57. package/extensions/plan/plans.ts +90 -1
  58. package/extensions/plan/review.ts +12 -9
  59. package/extensions/plan/runtime.ts +72 -27
  60. package/extensions/plan/steps.ts +35 -25
  61. package/extensions/plan/todos.ts +24 -2
  62. package/extensions/plan/tools.ts +124 -24
  63. package/extensions/plan/tui.ts +7 -28
  64. package/extensions/plan/types.ts +9 -0
  65. package/extensions/plan/web-page.ts +2 -2
  66. package/extensions/plan/web.ts +12 -11
  67. package/extensions/rewind/README.md +28 -8
  68. package/extensions/rewind/commands.ts +1 -1
  69. package/extensions/rewind/format.ts +4 -4
  70. package/extensions/rewind/git.ts +56 -18
  71. package/extensions/rewind/index.ts +3 -5
  72. package/extensions/rewind/path-picker.ts +198 -0
  73. package/extensions/rewind/paths.ts +128 -0
  74. package/extensions/rewind/restore.ts +69 -28
  75. package/extensions/rewind/rewind.ts +80 -3
  76. package/extensions/rewind/runtime.ts +13 -5
  77. package/extensions/rewind/snapshot-run.ts +5 -2
  78. package/extensions/rewind/types.ts +2 -0
  79. package/extensions/subagent/README.md +7 -1
  80. package/extensions/subagent/accounting.ts +34 -0
  81. package/extensions/subagent/index.ts +23 -1
  82. package/extensions/subagent/stream.ts +2 -0
  83. package/extensions/subagent/types.ts +2 -0
  84. package/extensions/todo/nudge.ts +5 -1
  85. package/extensions/watch/README.md +19 -8
  86. package/extensions/watch/commands.ts +4 -3
  87. package/extensions/watch/config.ts +1 -1
  88. package/extensions/watch/format.ts +4 -7
  89. package/extensions/watch/index.ts +19 -0
  90. package/extensions/watch/runtime.ts +83 -12
  91. package/extensions/web-access/README.md +43 -13
  92. package/extensions/web-access/charset.ts +58 -0
  93. package/extensions/web-access/fetch/find.ts +11 -7
  94. package/extensions/web-access/fetch/format.ts +64 -18
  95. package/extensions/web-access/fetch/page.ts +5 -1
  96. package/extensions/web-access/fetch/tool.ts +19 -18
  97. package/extensions/web-access/http.ts +49 -8
  98. package/extensions/web-access/search/facts.ts +38 -0
  99. package/extensions/web-access/search/tool.ts +13 -0
  100. package/extensions/web-access/text.ts +50 -11
  101. package/extensions/worktree/README.md +81 -16
  102. package/extensions/worktree/config.ts +11 -0
  103. package/extensions/worktree/enter.ts +10 -3
  104. package/extensions/worktree/exit.ts +55 -3
  105. package/extensions/worktree/gaps.ts +74 -0
  106. package/extensions/worktree/git.ts +28 -0
  107. package/extensions/worktree/guard.ts +50 -4
  108. package/extensions/worktree/handoff.ts +55 -0
  109. package/extensions/worktree/index.ts +14 -2
  110. package/extensions/worktree/root-tools.ts +1 -1
  111. package/extensions/worktree/runtime.ts +25 -0
  112. package/extensions/worktree/status.ts +15 -2
  113. package/extensions/worktree/tools.ts +3 -2
  114. package/lib/README.md +14 -10
  115. package/lib/events.ts +86 -1
  116. package/lib/goal-state.ts +95 -0
  117. package/lib/requirements.ts +361 -62
  118. package/lib/tui/README.md +91 -0
  119. package/lib/tui/columns.ts +17 -0
  120. package/lib/tui/glyphs.ts +1 -0
  121. package/lib/tui/tool.ts +6 -2
  122. package/lib/web/layout.ts +12 -2
  123. package/package.json +5 -3
  124. package/themes/bytefly-dark.json +1 -1
  125. package/assets/chrome.png +0 -0
  126. package/assets/file-browse.png +0 -0
  127. package/assets/job.png +0 -0
  128. package/assets/plan-review.png +0 -0
  129. package/assets/question.png +0 -0
  130. package/assets/rewind.png +0 -0
  131. package/assets/todo.png +0 -0
  132. package/assets/tool-renderings.png +0 -0
  133. package/assets/tools/ask_user_question-cancelled.png +0 -0
  134. package/assets/tools/ask_user_question.png +0 -0
  135. package/assets/tools/bash.png +0 -0
  136. package/assets/tools/edit.png +0 -0
  137. package/assets/tools/enter_plan_mode.png +0 -0
  138. package/assets/tools/enter_worktree.png +0 -0
  139. package/assets/tools/exit_plan_mode.png +0 -0
  140. package/assets/tools/exit_worktree.png +0 -0
  141. package/assets/tools/find.png +0 -0
  142. package/assets/tools/goal.png +0 -0
  143. package/assets/tools/grep.png +0 -0
  144. package/assets/tools/job.png +0 -0
  145. package/assets/tools/list_worktrees.png +0 -0
  146. package/assets/tools/ls.png +0 -0
  147. package/assets/tools/memory-long-subject.png +0 -0
  148. package/assets/tools/memory.png +0 -0
  149. package/assets/tools/notice.png +0 -0
  150. package/assets/tools/open_path.png +0 -0
  151. package/assets/tools/prune_worktrees.png +0 -0
  152. package/assets/tools/read.png +0 -0
  153. package/assets/tools/read_doc-running.png +0 -0
  154. package/assets/tools/read_doc.png +0 -0
  155. package/assets/tools/run_doctor.png +0 -0
  156. package/assets/tools/send_wechat.png +0 -0
  157. package/assets/tools/set_plan_steps.png +0 -0
  158. package/assets/tools/subagent.png +0 -0
  159. package/assets/tools/todo.png +0 -0
  160. package/assets/tools/turn-rule.png +0 -0
  161. package/assets/tools/user-message.png +0 -0
  162. package/assets/tools/watch.png +0 -0
  163. package/assets/tools/web_fetch-rejected.png +0 -0
  164. package/assets/tools/web_fetch.png +0 -0
  165. package/assets/tools/web_search.png +0 -0
  166. package/assets/tools/write.png +0 -0
  167. 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` brings tracked files back to the snapshot (restoring
5
- * modifications and deletions and removing paths the snapshot never had). Files
6
- * created after the snapshot and never tracked are then deleted explicitly,
7
- * because `read-tree` leaves untracked files alone. HEAD, branches, and reflogs
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 { cur, added, changed } = await previewRestore(deps, input);
55
- const diff = await diffStat(deps.runGit, input.root, input.target.commit, cur);
56
- return { ok: true, plan: { diff, changed, removed: added.length } };
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 and compare it against the
61
- * snapshot. `cur` is the staged commit, `added` the paths created since the
62
- * snapshot, and `changed` the tracked files modified or deleted.
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 previewRestore(
65
- deps: RestoreDeps,
66
- input: RestoreInput,
67
- ): Promise<{ cur: string; added: string[]; changed: number }> {
68
- // Compare tree-to-tree, so untracked files captured in the snapshot are not
69
- // mistaken for deletions against the real index.
70
- const cur = await treeFromWorkingTree(deps.runGit, input.root, input.indexFile, input.target.includeUntracked);
71
- const added = await addedPaths(deps.runGit, input.root, input.target.commit, cur);
72
- const changed = await changedCount(deps.runGit, input.root, input.target.commit, cur, "MD");
73
- return { cur, added, changed };
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
- /** Apply the restore. Recomputes the removal set immediately before mutating. */
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 { added, changed } = await previewRestore(deps, input);
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
- await readTreeReset(deps.runGit, input.root, input.target.commit, input.indexFile);
93
- for (const path of added) deleteFile(input.root, path);
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 { id: input.target.id, commit: input.target.commit, root: input.root, changed, removed: added.length };
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(scope: RewindScope, changeSummary: string | undefined, diff: string | undefined): string {
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(scope, plan ? formatChangeSummary(plan.changed, plan.removed) : undefined, plan?.diff),
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
- plan(root: string, target: Snapshot): Promise<PlanResult>;
37
- restore(ctx: ExtensionContext, root: string, target: Snapshot, config: RewindConfig): Promise<RestoreSummary>;
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
- /** Start a run for `prompt`: reset run state and resolve the snapshot decision. */
19
- beginRun(prompt: string, ctx: ExtensionContext): Promise<void>;
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 run = deps.run ?? runSingleAgent;
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;
@@ -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
- /** Marker on the injected reminder, so it is recognizable and filterable. */
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
- - Watchers are session-only: `shutdown` flushes a partial line, kills every
34
- command's process tree, and clears the chips.
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` | File to tail (`add`); exactly one of `path`/`command`. |
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` | Track watchers at all. |
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 POLL_MS = 500;
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
- }, POLL_MS);
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
- /** Track watchers at all. Default: true. */
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 truncateToWidth(sanitizeWatchLine(text), MAX_DISPLAY_LABEL, "…");
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 = truncateToWidth(sanitizeLine(source), MAX_DISPLAY_LABEL, "…");
177
+ const preview = labelLine(source);
181
178
  const pattern = typeof args.pattern === "string" ? args.pattern.trim() : "";
182
- return pattern ? `add ${preview} /${truncateToWidth(sanitizeLine(pattern), 24, "…")}/` : `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