@gavin-hu/my-pi-agent 0.12.0 → 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.
Files changed (153) hide show
  1. package/README.md +28 -21
  2. package/README.zh-CN.md +30 -22
  3. package/extensions/bytefly/README.md +139 -67
  4. package/extensions/bytefly/chrome.ts +7 -2
  5. package/extensions/bytefly/doctor/register.ts +6 -0
  6. package/extensions/bytefly/doctor/runtime.ts +36 -6
  7. package/extensions/bytefly/footer/collect/snapshot.ts +12 -2
  8. package/extensions/bytefly/footer/config.ts +7 -1
  9. package/extensions/bytefly/footer/delegation.ts +100 -0
  10. package/extensions/bytefly/footer/register.ts +13 -2
  11. package/extensions/bytefly/footer/render/lines.ts +2 -0
  12. package/extensions/bytefly/footer/render/segments.ts +27 -0
  13. package/extensions/bytefly/footer/types.ts +11 -0
  14. package/extensions/bytefly/index.ts +4 -3
  15. package/extensions/bytefly/notify/config.ts +2 -0
  16. package/extensions/bytefly/notify/format.ts +19 -1
  17. package/extensions/bytefly/notify/register.ts +37 -13
  18. package/extensions/bytefly/notify/types.ts +2 -0
  19. package/extensions/bytefly/panel/commands.ts +34 -13
  20. package/extensions/bytefly/panel/settings.ts +98 -16
  21. package/extensions/bytefly/update/cache.ts +73 -0
  22. package/extensions/bytefly/update/format.ts +65 -10
  23. package/extensions/bytefly/update/register.ts +99 -13
  24. package/extensions/bytefly/update/state.ts +13 -1
  25. package/extensions/document/README.md +22 -12
  26. package/extensions/document/config.ts +4 -0
  27. package/extensions/document/deadline.ts +61 -0
  28. package/extensions/document/tool.ts +13 -2
  29. package/extensions/file-browse/README.md +12 -8
  30. package/extensions/file-browse/client.ts +6 -7
  31. package/extensions/file-browse/files.ts +0 -4
  32. package/extensions/file-browse/git.ts +9 -10
  33. package/extensions/file-browse/html.ts +17 -7
  34. package/extensions/file-browse/router.ts +24 -15
  35. package/extensions/goal/README.md +7 -1
  36. package/extensions/goal/schema.ts +6 -2
  37. package/extensions/goal/types.ts +6 -2
  38. package/extensions/job/README.md +16 -12
  39. package/extensions/job/runtime.ts +23 -6
  40. package/extensions/job/status.ts +4 -2
  41. package/extensions/memory/README.md +55 -13
  42. package/extensions/memory/commands.ts +20 -2
  43. package/extensions/memory/doctor.ts +137 -0
  44. package/extensions/memory/format.ts +24 -7
  45. package/extensions/memory/runtime.ts +49 -0
  46. package/extensions/memory/store.ts +31 -0
  47. package/extensions/memory/tui.ts +136 -12
  48. package/extensions/plan/README.md +60 -29
  49. package/extensions/plan/commands.ts +67 -8
  50. package/extensions/plan/index.ts +35 -11
  51. package/extensions/plan/plan-index.ts +234 -0
  52. package/extensions/plan/plan-text.ts +95 -0
  53. package/extensions/plan/plans.ts +90 -1
  54. package/extensions/plan/review.ts +12 -9
  55. package/extensions/plan/runtime.ts +72 -27
  56. package/extensions/plan/steps.ts +35 -25
  57. package/extensions/plan/todos.ts +24 -2
  58. package/extensions/plan/tools.ts +124 -24
  59. package/extensions/plan/tui.ts +7 -28
  60. package/extensions/plan/types.ts +9 -0
  61. package/extensions/plan/web-page.ts +2 -2
  62. package/extensions/plan/web.ts +12 -11
  63. package/extensions/rewind/README.md +28 -8
  64. package/extensions/rewind/commands.ts +1 -1
  65. package/extensions/rewind/format.ts +4 -4
  66. package/extensions/rewind/git.ts +56 -18
  67. package/extensions/rewind/index.ts +3 -5
  68. package/extensions/rewind/path-picker.ts +198 -0
  69. package/extensions/rewind/paths.ts +128 -0
  70. package/extensions/rewind/restore.ts +69 -28
  71. package/extensions/rewind/rewind.ts +80 -3
  72. package/extensions/rewind/runtime.ts +13 -5
  73. package/extensions/rewind/snapshot-run.ts +5 -2
  74. package/extensions/rewind/types.ts +2 -0
  75. package/extensions/subagent/README.md +7 -1
  76. package/extensions/subagent/accounting.ts +34 -0
  77. package/extensions/subagent/index.ts +23 -1
  78. package/extensions/subagent/stream.ts +2 -0
  79. package/extensions/subagent/types.ts +2 -0
  80. package/extensions/watch/README.md +8 -3
  81. package/extensions/watch/index.ts +19 -0
  82. package/extensions/web-access/README.md +43 -13
  83. package/extensions/web-access/charset.ts +58 -0
  84. package/extensions/web-access/fetch/find.ts +11 -7
  85. package/extensions/web-access/fetch/format.ts +64 -18
  86. package/extensions/web-access/fetch/page.ts +5 -1
  87. package/extensions/web-access/fetch/tool.ts +19 -18
  88. package/extensions/web-access/http.ts +49 -8
  89. package/extensions/web-access/search/facts.ts +38 -0
  90. package/extensions/web-access/search/tool.ts +13 -0
  91. package/extensions/web-access/text.ts +50 -11
  92. package/extensions/worktree/README.md +74 -15
  93. package/extensions/worktree/config.ts +11 -0
  94. package/extensions/worktree/exit.ts +35 -3
  95. package/extensions/worktree/gaps.ts +74 -0
  96. package/extensions/worktree/guard.ts +50 -4
  97. package/extensions/worktree/handoff.ts +55 -0
  98. package/extensions/worktree/index.ts +12 -2
  99. package/extensions/worktree/runtime.ts +20 -0
  100. package/extensions/worktree/status.ts +15 -2
  101. package/extensions/worktree/tools.ts +3 -2
  102. package/lib/README.md +10 -8
  103. package/lib/events.ts +86 -1
  104. package/lib/goal-state.ts +95 -0
  105. package/lib/requirements.ts +282 -62
  106. package/lib/tui/README.md +91 -0
  107. package/lib/tui/glyphs.ts +1 -0
  108. package/lib/web/layout.ts +12 -2
  109. package/package.json +5 -3
  110. package/themes/bytefly-dark.json +1 -1
  111. package/assets/chrome.png +0 -0
  112. package/assets/file-browse.png +0 -0
  113. package/assets/job.png +0 -0
  114. package/assets/plan-review.png +0 -0
  115. package/assets/question.png +0 -0
  116. package/assets/rewind.png +0 -0
  117. package/assets/todo.png +0 -0
  118. package/assets/tool-renderings.png +0 -0
  119. package/assets/tools/ask_user_question-cancelled.png +0 -0
  120. package/assets/tools/ask_user_question.png +0 -0
  121. package/assets/tools/bash.png +0 -0
  122. package/assets/tools/edit.png +0 -0
  123. package/assets/tools/enter_plan_mode.png +0 -0
  124. package/assets/tools/enter_worktree.png +0 -0
  125. package/assets/tools/exit_plan_mode.png +0 -0
  126. package/assets/tools/exit_worktree.png +0 -0
  127. package/assets/tools/find.png +0 -0
  128. package/assets/tools/goal.png +0 -0
  129. package/assets/tools/grep.png +0 -0
  130. package/assets/tools/job.png +0 -0
  131. package/assets/tools/list_worktrees.png +0 -0
  132. package/assets/tools/ls.png +0 -0
  133. package/assets/tools/memory-long-subject.png +0 -0
  134. package/assets/tools/memory.png +0 -0
  135. package/assets/tools/notice.png +0 -0
  136. package/assets/tools/open_path.png +0 -0
  137. package/assets/tools/prune_worktrees.png +0 -0
  138. package/assets/tools/read.png +0 -0
  139. package/assets/tools/read_doc-running.png +0 -0
  140. package/assets/tools/read_doc.png +0 -0
  141. package/assets/tools/run_doctor.png +0 -0
  142. package/assets/tools/send_wechat.png +0 -0
  143. package/assets/tools/set_plan_steps.png +0 -0
  144. package/assets/tools/subagent.png +0 -0
  145. package/assets/tools/todo.png +0 -0
  146. package/assets/tools/turn-rule.png +0 -0
  147. package/assets/tools/user-message.png +0 -0
  148. package/assets/tools/watch.png +0 -0
  149. package/assets/tools/web_fetch-rejected.png +0 -0
  150. package/assets/tools/web_fetch.png +0 -0
  151. package/assets/tools/web_search.png +0 -0
  152. package/assets/tools/write.png +0 -0
  153. package/assets/tools/write_plan.png +0 -0
@@ -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
- /** Check 4: constructs whose text does not spell out the git command that runs. */
228
- function looksUnparsable(command: string): boolean {
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 && looksUnparsable(command)) {
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
- return analyzeBashCommand(command, state.path, config);
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)) {
package/lib/README.md CHANGED
@@ -19,19 +19,20 @@ belongs in that extension instead — see `extensions/web-access/http.ts` and
19
19
  | [`config.ts`](./config.ts) | Config loading, two ways: `loadConfigFile` for a whole file, and the shared `bytefly.json` (`SHARED_CONFIG_FILE`) via `configSection` / `configValueAt` / `loadConfigSection` (global, then project), plus the atomic `writeConfigFile` / `writeConfigSection` / `writeConfigValue` / `writeConfigValueAt` (the last two edit one leaf, flat or nested, preserving siblings and metadata). Includes `clampInteger` / `clampNumber` / `cleanString` / `asRecord` / `readJson` / `readJsonResult`. |
20
20
  | [`env.ts`](./env.ts) | Env-var contracts shared across extensions: the worktree environment (`ENV_ROOT`, `worktreeRoot`, `resolveEffectiveCwd`) used by worktree, file-browse, job, and rewind, plus `isExtensionEnabled` / `ENV_DISABLED_EXTENSIONS` for the package-wide disable list. |
21
21
  | [`escape.ts`](./escape.ts) | `escapeHtml`: the single HTML text/attribute escape used by the browse page renderers and the Markdown and syntax highlighters. |
22
- | [`events.ts`](./events.ts) | Inter-extension `pi.events` channels and payloads: `JOB_FINISHED_CHANNEL` and `JobFinishedEvent`, published by `job` and consumed by bytefly's notify capability. |
22
+ | [`events.ts`](./events.ts) | Inter-extension `pi.events` channels and payloads: `JOB_FINISHED_CHANNEL` / `JobFinishedEvent` (published by `job`, consumed by bytefly's notify capability), `SUBAGENT_FINISHED_CHANNEL` / `SubagentFinishedEvent` (published by `subagent`, consumed by bytefly's footer roll-up), and `WEB_SEARCH_PROVIDER_CHANNEL` / `WebSearchProviderEvent` (published by `web-access` at session start, consumed by bytefly's doctor so it can report the resolved provider without importing the extension — the key itself is never in the payload). |
23
23
  | [`notice.ts`](./notice.ts) | The shared transcript notice: `NOTICE_TYPE`, the `NoticeDetails` / `NoticeItem` shape, `noticeMessage` (the `display: true` message both send paths accept), `isNotice`, and `pruneStaleNotices` — posted by `job` and `watch`, rendered by bytefly. |
24
24
  | [`format.ts`](./format.ts) | Pure text/number formatting shared by renderers: `formatTokens`, `formatCount`, `formatDuration` (`450ms`, `3.2s`, `1m05s`, `2h03m`), `stripControlChars`, `sanitize`, `sanitizeLine` — the boundary for untrusted, single-line text — and `shortenPath`. |
25
25
  | [`interaction.ts`](./interaction.ts) | Turn-affinity prompt contract: the channel a remote transport marks on the UI context it owns (`INTERACTION_TURN_KEY`), the `InteractionChannel` interface (`request` / `post` / `postFile`), and `remoteChannel` / `isRemoteTurn` / `supportsCustomUI` / `askHuman` (with an optional file, fallback text, and digest preface) plus the shared “Other” label. |
26
26
  | [`git/`](./git/) | Shared git plumbing. [`git/runner.ts`](./git/runner.ts) is the `RunGit` seam (`createExecRunner` with timeout/env handling, `runGitOrThrow`, `GitError`); [`git/read.ts`](./git/read.ts) holds the read helpers (`repoRoot`, `repoRootFor`, `gitDir`, `revParse`, `hasCommits`, `currentBranch`); [`git/index.ts`](./git/index.ts) is the barrel. |
27
27
  | [`highlight.ts`](./highlight.ts) | First-party, escape-first syntax tokenizer (`detectLanguage`, `fenceLanguage`, `highlightLines`, `HighlightLanguage`, `TokenClass`) for js/ts, json, css, html, shell, python, java, yaml, and sql; shared by `file-browse` and plan's code blocks. |
28
- | [`tui/`](./tui/) | The Bytefly terminal design system shared by every interactive surface: [`tui/tokens.ts`](./tui/tokens.ts) (semantic role -> theme color, `paint`), [`tui/glyphs.ts`](./tui/glyphs.ts) (`GLYPHS`, `SEPARATORS`, `STATUS_KEYS`, `EXPAND_KEYBINDING` with `expandKey` / `expandHint`, `GLYPH_GAP` — the vocabulary both sides of a status must agree on), [`tui/chrome.ts`](./tui/chrome.ts) (`screenHint`, `dockListFrame` (the docked-list frame), `viewportRows`, `FULL_SCREEN_OVERLAY`, `reuseText`, `editorTheme`), [`tui/rules.ts`](./tui/rules.ts) (the rule glyph `RULE_GLYPH`, and `plainRule` / `titleRule` / `centeredRule`), [`tui/indent.ts`](./tui/indent.ts) (the transcript block indent: `BODY_INDENT`, an optional rail prefix that a wrapped call line is kept out of, and `bodyLines` / `indentBlockLines` / `indentedLines`), [`tui/rows.ts`](./tui/rows.ts) (`listRow`, `checkbox`, `chip`, `keyValueRow`, `emptyState`), [`tui/columns.ts`](./tui/columns.ts) (`splitRow`, `fitCells`, `flowCells`, `padTo`, `clip`, `clipMiddle`), [`tui/gauge.ts`](./tui/gauge.ts) (`computeGauge`, `gaugeRole`, `gaugeBar`), [`tui/line-pane.ts`](./tui/line-pane.ts) (`LinePane`, the scrollable log/match pane), [`tui/status-controller.ts`](./tui/status-controller.ts) (`createStatusController`, the footer chips `job` and `watch` share), [`tui/tool.ts`](./tui/tool.ts) (`toolCallTitle`, the width-safe `ToolCallText` / `reuseCallText`, sanitized `errorLine` / `rejectionLine`, `moreHint`, `syncElapsedTimer`, `elapsedSuffix` — the tool-transcript kit), [`tui/render-cache.ts`](./tui/render-cache.ts) (`RenderCache`: the per-instance line memo a component that derives its lines from another component's output renders through, so a repeat frame is one comparison), and [`tui/cursor.ts`](./tui/cursor.ts) (scroll and navigation: `fitRows`, `keepVisible`, `clampScroll`, `navIntent`, `navigateList`, `navigateScroll`, `wheelDelta`, `scrollList`, `isCloseKey`, `selectionMarker`, `formatRange`, and the `ListCursor` (by index) and `IdCursor` (by stable id) state). |
28
+ | [`tui/`](./tui/) | The Bytefly terminal design system shared by every interactive surface, whose conventions are its own doc, [`tui/README.md`](./tui/README.md). Modules: [`tui/tokens.ts`](./tui/tokens.ts) (semantic role -> theme color, `paint`), [`tui/glyphs.ts`](./tui/glyphs.ts) (`GLYPHS`, `SEPARATORS`, `STATUS_KEYS`, `EXPAND_KEYBINDING` with `expandKey` / `expandHint`, `GLYPH_GAP` — the vocabulary both sides of a status must agree on), [`tui/chrome.ts`](./tui/chrome.ts) (`screenHint`, `dockListFrame` (the docked-list frame), `viewportRows`, `FULL_SCREEN_OVERLAY`, `reuseText`, `editorTheme`), [`tui/rules.ts`](./tui/rules.ts) (the rule glyph `RULE_GLYPH`, and `plainRule` / `titleRule` / `centeredRule`), [`tui/indent.ts`](./tui/indent.ts) (the transcript block indent: `BODY_INDENT`, an optional rail prefix that a wrapped call line is kept out of, and `bodyLines` / `indentBlockLines` / `indentedLines`), [`tui/rows.ts`](./tui/rows.ts) (`listRow`, `checkbox`, `chip`, `keyValueRow`, `emptyState`), [`tui/columns.ts`](./tui/columns.ts) (`splitRow`, `fitCells`, `flowCells`, `padTo`, `clip`, `clipMiddle`), [`tui/gauge.ts`](./tui/gauge.ts) (`computeGauge`, `gaugeRole`, `gaugeBar`), [`tui/line-pane.ts`](./tui/line-pane.ts) (`LinePane`, the scrollable log/match pane), [`tui/status-controller.ts`](./tui/status-controller.ts) (`createStatusController`, the footer chips `job` and `watch` share), [`tui/tool.ts`](./tui/tool.ts) (`toolCallTitle`, the width-safe `ToolCallText` / `reuseCallText`, sanitized `errorLine` / `rejectionLine`, `moreHint`, `syncElapsedTimer`, `elapsedSuffix` — the tool-transcript kit), [`tui/render-cache.ts`](./tui/render-cache.ts) (`RenderCache`: the per-instance line memo a component that derives its lines from another component's output renders through, so a repeat frame is one comparison), and [`tui/cursor.ts`](./tui/cursor.ts) (scroll and navigation: `fitRows`, `keepVisible`, `clampScroll`, `navIntent`, `navigateList`, `navigateScroll`, `wheelDelta`, `scrollList`, `isCloseKey`, `selectionMarker`, `formatRange`, and the `ListCursor` (by index) and `IdCursor` (by stable id) state). |
29
29
  | [`path.ts`](./path.ts) | `isInside` (string containment) plus `realPathOfNearest` / `isInsideReal` (symlink-aware): the containment predicates the file-browse, worktree, and doc guards share. |
30
30
  | [`pi-invocation.ts`](./pi-invocation.ts) | `getPiInvocation`: how to launch a nested `pi` process, shared by `subagent` and bytefly's doctor surface. |
31
31
  | [`policy.ts`](./policy.ts) | `createReadOnlyPolicy`: default-deny tool classification from `readOnlyHint` + explicit allow/deny. |
32
32
  | [`rails.ts`](./rails.ts) | Above-editor widget ordering (`goal` → `todo`) and dock-screen suppression, carried on `pi.events`. |
33
- | [`requirements.ts`](./requirements.ts) | Per-extension runtime prerequisite table plus a pure `probeExtension(seams)` over injected seams (exec, config reads, optional imports): bytefly's doctor surface reports on it and the extensions that own a dependency format their errors from it. |
33
+ | [`requirements.ts`](./requirements.ts) | Per-extension runtime prerequisite table plus a pure `probeExtension(seams)` over injected seams (exec, config reads, optional imports, the published search provider, env, and a refresh-only reachability probe): bytefly's doctor surface reports on it and the extensions that own a dependency format their errors from it. A check kind is either a prerequisite (`binary`, `module`, `state`, `invocation`, `notify`, `provider`) or a `safety` switch whose off value trades a protection away, read from `bytefly.json` global-then-project. |
34
34
  | [`shell.ts`](./shell.ts) | Shell escape rule shared by the worktree isolation guard and plan mode's read-only git guard: `SHELL_ESCAPABLE`, `isShellEscapable`, `hasShellEscape`. |
35
+ | [`goal-state.ts`](./goal-state.ts) | The published `goal` state replayed from the session branch: `PublishedGoal`, `GOAL_ENTRY_TYPE`, `MAX_GOAL_OBJECTIVE`, and `lastGoal(entries)` (the last valid goal, skipping a rejected call and honouring an explicit clear), so `plan` can restate an active goal while planning and offer to record one on approval — with no extension importing another's modules. |
35
36
  | [`todo-state.ts`](./todo-state.ts) | The published `todo` state replayed from the session branch: `ExistingTodo` and `lastTodos(entries)` (the last valid list, skipping a rejected call), so `plan` can merge an approved plan's steps into it and `goal` can name the items still open when a goal is marked achieved — with no extension importing another's modules. |
36
37
  | [`tool-names.ts`](./tool-names.ts) | Tool names and one parameter name that more than one extension must agree on, so an orchestrating `ctx.executeTool()` call or schema probe breaks the build on a rename. |
37
38
  | [`web/`](./web/) | The Bytefly web brand system shared by the browser surfaces: [`web/tokens.ts`](./web/tokens.ts) (palette derived from the Bytefly terminal themes, scales, and the `tok-*` map), [`web/base.ts`](./web/base.ts), [`web/components.ts`](./web/components.ts), [`web/layout.ts`](./web/layout.ts), [`web/runtime.ts`](./web/runtime.ts) (`WEB_THEME_STORAGE_KEY`, `THEME_JS`, `THEME_TOGGLE_JS`), [`web/markup.ts`](./web/markup.ts), and the [`web/index.ts`](./web/index.ts) barrel. |
@@ -47,8 +48,9 @@ belongs in that extension instead — see `extensions/web-access/http.ts` and
47
48
  glyph and the `setStatus` key are one contract, not two literals. Tool names
48
49
  shared by an orchestrating tool and the tool it calls — and parameter names
49
50
  one extension probes on another — live in `tool-names.ts`.
50
- - **A color is a role, not a token.** Draw through [`tui/tokens.ts`](./tui/tokens.ts)
51
- (`paint(theme, role, text)`) so a theme swap re-skins every surface; the
52
- primitives in `tui/` already do, and a new one should too. The one exception is
53
- Pi's tool-rendering vocabulary (`toolOutput`, `toolTitle`), which stays direct:
54
- those tokens style a tool block, and no role maps to them.
51
+ - **A color is a role, not a token.** The rules and the primitives are in
52
+ [`tui/README.md`](./tui/README.md#color-is-a-role): draw through
53
+ [`tui/tokens.ts`](./tui/tokens.ts) (`paint(theme, role, text)`) so a theme swap
54
+ re-skins every surface. The one exception is Pi's tool-rendering vocabulary
55
+ (`toolOutput`, `toolTitle`), which stays direct: those tokens style a tool
56
+ block, and no role maps to them.
package/lib/events.ts CHANGED
@@ -10,7 +10,9 @@
10
10
  *
11
11
  * The bus carries no state. `rails.ts` and `worktree` publish their own channels
12
12
  * from the modules that own them; a channel crosses an extension boundary only
13
- * when the payload is the whole contract, which is the case here.
13
+ * when the payload is the whole contract, which is the case here: `job` publishes
14
+ * a finished job for bytefly's notify capability, and `subagent` publishes a
15
+ * finished child run for bytefly's footer roll-up.
14
16
  */
15
17
 
16
18
  /** EventBus channel: a background job reached a terminal state. */
@@ -25,6 +27,89 @@ export const JOB_FINISHED_CHANNEL = "my-pi-agent/job-finished";
25
27
  */
26
28
  export type FinishedJobStatus = "exited" | "failed" | "killed" | "unknown";
27
29
 
30
+ /** EventBus channel: a `watch` wake match fired while the agent was idle. */
31
+ export const WATCH_MATCH_CHANNEL = "my-pi-agent/watch-match";
32
+
33
+ /**
34
+ * Payload on {@link WATCH_MATCH_CHANNEL}: what a wake match saw.
35
+ *
36
+ * Emitted by `watch` and consumed by bytefly's notify capability, so the two can
37
+ * agree on an alert without `watch` learning about delivery methods. The line is
38
+ * **raw** — the matched text is arbitrary file or process output — and the
39
+ * consumer sanitizes it before it reaches a notification, exactly as it does for a
40
+ * job label. `count` covers the whole wake cycle, so one notification can say how
41
+ * many lines matched while the body describes the newest one.
42
+ */
43
+ export interface WatchMatchEvent {
44
+ /** How many matches were pending when the wake fired. */
45
+ count: number;
46
+ /** The watcher that matched, e.g. `w3`. */
47
+ watcherId: string;
48
+ /** Its label, e.g. `build`. */
49
+ label: string;
50
+ /** The newest matched line, unsanitized. */
51
+ line: string;
52
+ }
53
+
54
+ /** EventBus channel: one subagent run finished (a child `pi` process exited). */
55
+ export const SUBAGENT_FINISHED_CHANNEL = "my-pi-agent/subagent-finished";
56
+
57
+ /**
58
+ * Payload on {@link SUBAGENT_FINISHED_CHANNEL}: what one finished child run spent.
59
+ *
60
+ * It carries the *pricing inputs* rather than a total, because the consumer prices
61
+ * them with the same rate table and currency as the session's own cost, and a
62
+ * currency change must re-price them rather than leave a figure in the old unit.
63
+ * `recordedCost` is Pi's own dollar figure for the child's assistant calls.
64
+ *
65
+ * A run that produced no assistant message (an unknown agent, a spawn failure)
66
+ * publishes nothing: there is no spend to account for.
67
+ */
68
+ export interface SubagentFinishedEvent {
69
+ /** Agent that ran, e.g. `explorer`. */
70
+ agent: string;
71
+ /** Provider and model the child reported, when it reported one. */
72
+ provider: string | null;
73
+ model: string | null;
74
+ input: number;
75
+ output: number;
76
+ cacheRead: number;
77
+ cacheWrite: number;
78
+ /** Pi's recorded cost for the child's calls, in USD. */
79
+ recordedCost: number;
80
+ /** Epoch ms when the run finished, or `null` when unknown. */
81
+ at: number | null;
82
+ /** Child process exit code; a model-level failure still exits 0. */
83
+ exitCode: number;
84
+ }
85
+
86
+ /** EventBus channel: `web-access` resolved its search provider for this session. */
87
+ export const WEB_SEARCH_PROVIDER_CHANNEL = "my-pi-agent/web-search-provider";
88
+
89
+ /**
90
+ * Payload on {@link WEB_SEARCH_PROVIDER_CHANNEL}: what `web_search` will use.
91
+ *
92
+ * `web-access` owns the provider registry, each provider's `isConfigured`, and the
93
+ * `auto` resolution rule, so it is the only place those facts can come from: this
94
+ * event is how `doctor` learns them without importing an extension. The key
95
+ * itself is never published — only the variable name and whether a literal key is
96
+ * configured — so a diagnostic can never leak a secret into a log or a notice.
97
+ */
98
+ export interface WebSearchProviderEvent {
99
+ /** Resolved backend id, e.g. `searxng`. */
100
+ provider: string;
101
+ /** Why this provider won: an explicit choice, one that is configured, or the keyless default. */
102
+ reason: "explicit" | "configured" | "default";
103
+ /** Whether the resolved provider's configuration is enough to call it. */
104
+ configured: boolean;
105
+ /** URL a refresh may probe, when the provider has a configurable endpoint. */
106
+ endpoint: string | null;
107
+ /** Environment variable a key would come from, when the provider takes one. */
108
+ apiKeyEnv: string | null;
109
+ /** Whether a literal key is configured, so a missing variable is not the whole story. */
110
+ keyConfigured: boolean;
111
+ }
112
+
28
113
  /** Payload on {@link JOB_FINISHED_CHANNEL}: one job that just stopped. */
29
114
  export interface JobFinishedEvent {
30
115
  /** Job id, e.g. `j3`. */
@@ -0,0 +1,95 @@
1
+ /**
2
+ * The published goal state, read back from the session branch (pure).
3
+ *
4
+ * `goal` publishes its objective two ways: every `goal` tool result carries the
5
+ * goal in `details` — a rejected call carries the unchanged goal *and* an
6
+ * `error`, which is not a write — and the `/goal` command appends a `goal` custom
7
+ * entry whose `data.goal` is `null` when the goal is cleared. Replaying the branch
8
+ * in order is how another extension reads the goal without importing goal's
9
+ * modules: `plan` restates an active goal in its planning context and offers to
10
+ * record one when a plan is approved with none.
11
+ *
12
+ * Stored values are re-validated and re-sanitized here. Branch data can be
13
+ * tampered with, migrated, or written by an older version, and a reader must not
14
+ * pass escapes or unbounded text onward; anything that fails validation is
15
+ * ignored rather than clearing a goal that is still valid.
16
+ *
17
+ * The entry type and the objective limit live here and are imported by `goal`, so
18
+ * the publisher and the reader cannot drift. `goal` keeps its own reconstruction
19
+ * (`extensions/goal/state.ts`) because it also drives the widget; this module is
20
+ * the minimal contract other extensions may depend on.
21
+ */
22
+
23
+ import { sanitizeLine } from "./format.ts";
24
+ import { GOAL_TOOL } from "./tool-names.ts";
25
+
26
+ /** Custom-entry type `goal` uses to persist a goal set outside the tool. */
27
+ export const GOAL_ENTRY_TYPE = "goal";
28
+
29
+ /** Longest objective the goal tool accepts, and the most a reader will replay. */
30
+ export const MAX_GOAL_OBJECTIVE = 2000;
31
+
32
+ /** Whether the goal is still being pursued, or already done. */
33
+ export type PublishedGoalStatus = "active" | "achieved";
34
+
35
+ /** The goal as another extension may read it. */
36
+ export interface PublishedGoal {
37
+ /** The objective, sanitized to a single line. */
38
+ objective: string;
39
+ status: PublishedGoalStatus;
40
+ }
41
+
42
+ /** Minimal shape of a session entry, used for runtime narrowing. */
43
+ interface BranchEntryLike {
44
+ type?: string;
45
+ customType?: string;
46
+ data?: unknown;
47
+ message?: {
48
+ role?: string;
49
+ toolName?: string;
50
+ details?: unknown;
51
+ };
52
+ }
53
+
54
+ /**
55
+ * A stored goal value: `undefined` when it cannot be read, `null` for an explicit
56
+ * clear, otherwise the validated goal.
57
+ */
58
+ function readGoal(value: unknown): PublishedGoal | null | undefined {
59
+ if (value === null) return null;
60
+ if (!value || typeof value !== "object") return undefined;
61
+ const goal = value as { objective?: unknown; status?: unknown };
62
+ if (typeof goal.objective !== "string") return undefined;
63
+ if (goal.status !== "active" && goal.status !== "achieved") return undefined;
64
+ const objective = sanitizeLine(goal.objective);
65
+ if (!objective || objective.length > MAX_GOAL_OBJECTIVE) return undefined;
66
+ return { objective, status: goal.status };
67
+ }
68
+
69
+ /** The last goal written on the branch, or `null` when the session has none. */
70
+ export function lastGoal(entries: Iterable<unknown>): PublishedGoal | null {
71
+ let goal: PublishedGoal | null = null;
72
+ for (const raw of entries) {
73
+ const entry = raw as BranchEntryLike;
74
+
75
+ // The `/goal` command records a custom entry; the tool records a result below.
76
+ // Both replay in branch order, so the last write wins.
77
+ if (entry?.type === "custom" && entry.customType === GOAL_ENTRY_TYPE) {
78
+ const data = entry.data as { goal?: unknown } | null | undefined;
79
+ if (!data || typeof data !== "object" || !("goal" in data)) continue;
80
+ const next = readGoal(data.goal);
81
+ if (next !== undefined) goal = next;
82
+ continue;
83
+ }
84
+
85
+ if (entry?.type !== "message") continue;
86
+ const message = entry.message;
87
+ if (message?.role !== "toolResult" || message.toolName !== GOAL_TOOL) continue;
88
+ const details = message.details as { goal?: unknown; error?: unknown } | undefined;
89
+ if (!details || details.error) continue;
90
+ if (!("goal" in details)) continue;
91
+ const next = readGoal(details.goal);
92
+ if (next !== undefined) goal = next;
93
+ }
94
+ return goal;
95
+ }