@gavin-hu/my-pi-agent 0.16.0 → 0.18.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 (200) hide show
  1. package/README.md +29 -15
  2. package/README.zh-CN.md +27 -15
  3. package/extensions/bytefly/README.md +195 -115
  4. package/extensions/bytefly/footer/collect/entries.ts +1 -6
  5. package/extensions/bytefly/footer/collect/snapshot.ts +23 -9
  6. package/extensions/bytefly/footer/price/price.ts +12 -8
  7. package/extensions/bytefly/footer/register.ts +13 -1
  8. package/extensions/bytefly/footer/types.ts +11 -0
  9. package/extensions/bytefly/footer/unpriced.ts +36 -0
  10. package/extensions/bytefly/header/component.ts +15 -29
  11. package/extensions/bytefly/header/identity.ts +71 -0
  12. package/extensions/bytefly/header/register.ts +9 -3
  13. package/extensions/bytefly/index.ts +13 -5
  14. package/extensions/bytefly/message/format.ts +20 -13
  15. package/extensions/bytefly/message/register.ts +8 -7
  16. package/extensions/bytefly/notify/types.ts +1 -1
  17. package/extensions/bytefly/panel/commands.ts +46 -25
  18. package/extensions/bytefly/panel/format.ts +49 -14
  19. package/extensions/bytefly/panel/join.ts +35 -9
  20. package/extensions/bytefly/panel/settings.ts +29 -39
  21. package/extensions/bytefly/panel/skills.ts +51 -0
  22. package/extensions/bytefly/panel/tui/extensions-view.ts +7 -61
  23. package/extensions/bytefly/panel/tui/frame.ts +9 -1
  24. package/extensions/bytefly/panel/tui/list-view.ts +72 -0
  25. package/extensions/bytefly/panel/tui/nav.ts +71 -3
  26. package/extensions/bytefly/panel/tui/settings-section.ts +250 -0
  27. package/extensions/bytefly/panel/tui/skills-view.ts +50 -0
  28. package/extensions/bytefly/panel/tui.ts +223 -331
  29. package/extensions/bytefly/panel/types.ts +26 -8
  30. package/extensions/bytefly/picker/discovery.ts +32 -27
  31. package/extensions/bytefly/picker/resources.ts +16 -8
  32. package/extensions/bytefly/picker/runtime.ts +62 -34
  33. package/extensions/bytefly/picker/toggle.ts +16 -10
  34. package/extensions/bytefly/picker/types.ts +31 -13
  35. package/extensions/bytefly/update/format.ts +66 -8
  36. package/extensions/bytefly/update/patch.ts +66 -0
  37. package/extensions/bytefly/update/register.ts +4 -11
  38. package/extensions/bytefly/update/release.ts +65 -0
  39. package/extensions/bytefly/update/state.ts +14 -0
  40. package/extensions/bytefly/update/suppress.ts +47 -29
  41. package/extensions/bytefly/update/types.ts +15 -5
  42. package/extensions/check/tui.ts +2 -0
  43. package/extensions/context/README.md +21 -11
  44. package/extensions/context/format.ts +10 -8
  45. package/extensions/context/report.ts +21 -8
  46. package/extensions/context/tui.ts +26 -10
  47. package/extensions/context/types.ts +5 -3
  48. package/extensions/file-browse/README.md +89 -22
  49. package/extensions/file-browse/chrome.ts +83 -0
  50. package/extensions/file-browse/client.ts +74 -459
  51. package/extensions/file-browse/config.ts +12 -3
  52. package/extensions/file-browse/diff-html.ts +94 -0
  53. package/extensions/file-browse/html.ts +26 -152
  54. package/extensions/file-browse/live-client.ts +47 -1
  55. package/extensions/file-browse/live.ts +35 -14
  56. package/extensions/file-browse/palette.ts +224 -0
  57. package/extensions/file-browse/rev.ts +42 -0
  58. package/extensions/file-browse/router.ts +27 -6
  59. package/extensions/file-browse/server.ts +7 -0
  60. package/extensions/file-browse/styles.ts +10 -3
  61. package/extensions/file-browse/tree.ts +381 -0
  62. package/extensions/file-read/README.md +4 -1
  63. package/extensions/file-read/extract/odt.ts +7 -5
  64. package/extensions/goal/plan-route.ts +8 -2
  65. package/extensions/goal/tools.ts +6 -11
  66. package/extensions/job/README.md +7 -5
  67. package/extensions/job/index.ts +35 -52
  68. package/extensions/job/tools.ts +51 -77
  69. package/extensions/memory/README.md +3 -3
  70. package/extensions/memory/tools.ts +4 -6
  71. package/extensions/memory/tui.ts +5 -1
  72. package/extensions/plan/README.md +333 -84
  73. package/extensions/plan/commands.ts +73 -7
  74. package/extensions/plan/feedback.ts +300 -63
  75. package/extensions/plan/index.ts +1 -1
  76. package/extensions/plan/plan-index.ts +4 -2
  77. package/extensions/plan/plan-text.ts +18 -1
  78. package/extensions/plan/plans.ts +184 -58
  79. package/extensions/plan/review-prompt.ts +93 -0
  80. package/extensions/plan/review.ts +95 -95
  81. package/extensions/plan/runtime.ts +4 -2
  82. package/extensions/plan/split.ts +87 -0
  83. package/extensions/plan/steps.ts +123 -107
  84. package/extensions/plan/tools/cancel.ts +79 -0
  85. package/extensions/plan/tools/enter.ts +17 -12
  86. package/extensions/plan/tools/exit.ts +148 -59
  87. package/extensions/plan/tools/steps.ts +7 -19
  88. package/extensions/plan/tools/write.ts +29 -31
  89. package/extensions/plan/tools.ts +6 -3
  90. package/extensions/plan/tui.ts +60 -15
  91. package/extensions/plan/types.ts +45 -3
  92. package/extensions/plan/web-chrome.ts +102 -0
  93. package/extensions/plan/web-client.ts +177 -446
  94. package/extensions/plan/web-collab.ts +207 -0
  95. package/extensions/plan/web-comments.ts +269 -0
  96. package/extensions/plan/web-markdown.ts +222 -0
  97. package/extensions/plan/web-page.ts +131 -19
  98. package/extensions/plan/web-router.ts +148 -32
  99. package/extensions/plan/web-share-session.ts +197 -0
  100. package/extensions/plan/web-share.ts +169 -0
  101. package/extensions/plan/web-streams.ts +124 -0
  102. package/extensions/plan/web-tokens.ts +63 -0
  103. package/extensions/plan/web.ts +193 -45
  104. package/extensions/question/README.md +3 -0
  105. package/extensions/question/dialogs.ts +91 -1
  106. package/extensions/question/tools.ts +2 -1
  107. package/extensions/rewind/checklist.ts +4 -0
  108. package/extensions/rewind/review-tui.ts +3 -1
  109. package/extensions/rewind/rewind-tui.ts +4 -0
  110. package/extensions/session-bus/README.md +247 -0
  111. package/extensions/session-bus/commands.ts +165 -0
  112. package/extensions/session-bus/format.ts +124 -0
  113. package/extensions/session-bus/index.ts +69 -0
  114. package/extensions/session-bus/notice.ts +58 -0
  115. package/extensions/session-bus/protocol.ts +134 -0
  116. package/extensions/session-bus/result.ts +243 -0
  117. package/extensions/session-bus/rows.ts +169 -0
  118. package/extensions/session-bus/runtime.ts +575 -0
  119. package/extensions/session-bus/store.ts +329 -0
  120. package/extensions/session-bus/team.ts +102 -0
  121. package/extensions/session-bus/tools.ts +227 -0
  122. package/extensions/session-bus/tui.ts +394 -0
  123. package/extensions/session-bus/types.ts +98 -0
  124. package/extensions/subagent/README.md +26 -13
  125. package/extensions/subagent/format.ts +72 -25
  126. package/extensions/subagent/index.ts +21 -47
  127. package/extensions/subagent/orchestrate.ts +18 -41
  128. package/extensions/subagent/render.ts +29 -42
  129. package/extensions/subagent/stream.ts +24 -18
  130. package/extensions/subagent/types.ts +7 -0
  131. package/extensions/todo/tools.ts +5 -6
  132. package/extensions/todo/tui.ts +5 -1
  133. package/extensions/watch/README.md +9 -5
  134. package/extensions/watch/index.ts +25 -44
  135. package/extensions/watch/tools.ts +31 -59
  136. package/extensions/wechat/README.md +45 -36
  137. package/extensions/wechat/bridge.ts +46 -48
  138. package/extensions/wechat/client.ts +1 -4
  139. package/extensions/wechat/commands.ts +3 -3
  140. package/extensions/wechat/deliver.ts +57 -37
  141. package/extensions/wechat/interaction.ts +54 -18
  142. package/extensions/wechat/login-screen.ts +2 -3
  143. package/extensions/wechat/notices.ts +9 -7
  144. package/extensions/wechat/peer-worker.ts +19 -34
  145. package/extensions/wechat/presence.ts +3 -4
  146. package/extensions/wechat/prompt.ts +53 -23
  147. package/extensions/wechat/reply-pipeline.ts +12 -4
  148. package/extensions/wechat/slash.ts +14 -0
  149. package/extensions/wechat/status-screen.ts +2 -2
  150. package/extensions/wechat/tools.ts +3 -11
  151. package/extensions/worktree/README.md +48 -12
  152. package/extensions/worktree/commands.ts +109 -65
  153. package/extensions/worktree/enter.ts +1 -1
  154. package/extensions/worktree/exit.ts +11 -1
  155. package/extensions/worktree/git.ts +5 -0
  156. package/extensions/worktree/lifecycle.ts +9 -1
  157. package/extensions/worktree/registry.ts +11 -5
  158. package/extensions/worktree/screen.ts +263 -0
  159. package/extensions/worktree/status.ts +84 -40
  160. package/lib/README.md +12 -7
  161. package/lib/claims/overlap.ts +43 -0
  162. package/lib/config.ts +12 -0
  163. package/lib/format.ts +6 -2
  164. package/lib/http/router.ts +40 -13
  165. package/lib/http/server.ts +27 -13
  166. package/lib/interaction.ts +28 -1
  167. package/lib/notice.ts +29 -2
  168. package/lib/plan-entry.ts +7 -2
  169. package/lib/plans/sections.ts +172 -0
  170. package/lib/plans/setting.ts +52 -0
  171. package/lib/plans/split.ts +215 -0
  172. package/lib/plans/store.ts +19 -0
  173. package/lib/plans/team.ts +151 -0
  174. package/lib/qr.ts +64 -0
  175. package/lib/repo-key.ts +74 -0
  176. package/lib/report.ts +117 -25
  177. package/lib/requirements.ts +1 -2
  178. package/lib/tool-result.ts +54 -4
  179. package/lib/tui/README.md +30 -8
  180. package/lib/tui/chrome.ts +27 -6
  181. package/lib/tui/dock-screen.ts +91 -19
  182. package/lib/tui/gauge.ts +9 -5
  183. package/lib/tui/line-pane.ts +15 -4
  184. package/lib/tui/tool.ts +23 -4
  185. package/lib/usage.ts +26 -0
  186. package/lib/web/README.md +131 -0
  187. package/lib/web/base.ts +19 -2
  188. package/lib/web/components.ts +10 -6
  189. package/lib/web/layout.ts +2 -0
  190. package/lib/web/markup.ts +2 -1
  191. package/lib/web/runtime.ts +103 -25
  192. package/lib/web/tokens.ts +23 -7
  193. package/package.json +5 -2
  194. package/skills/changelog/SKILL.md +57 -45
  195. package/skills/init/SKILL.md +80 -45
  196. package/skills/init/references/agents-md-quality.md +26 -2
  197. package/skills/init/references/interview-questions.md +36 -14
  198. package/skills/next/SKILL.md +209 -0
  199. package/skills/parallel-dev/SKILL.md +150 -0
  200. package/extensions/wechat/qrcode.ts +0 -46
@@ -9,10 +9,11 @@
9
9
  */
10
10
 
11
11
  import pkg from "../../../package.json";
12
- import { noticeMessage, type NoticeMessage } from "../../../lib/notice.ts";
12
+ import { sanitizeLine } from "../../../lib/format.ts";
13
+ import { noticeMessage, type NoticeItem, type NoticeMessage } from "../../../lib/notice.ts";
13
14
  import { GLYPHS } from "../../../lib/tui/glyphs.ts";
14
15
  import type { UpdateOutcome } from "./state.ts";
15
- import type { PackageUpdate } from "./types.ts";
16
+ import type { PackageUpdate, PiRelease } from "./types.ts";
16
17
 
17
18
  /** The header marker: an up arrow and this package's newest version. */
18
19
  export function updateMarker(latest: string): string {
@@ -22,6 +23,12 @@ export function updateMarker(latest: string): string {
22
23
  /** The command that resolves every row in the notice. */
23
24
  export const UPDATE_COMMAND = "pi update --extensions";
24
25
 
26
+ /** The command that updates Pi itself. */
27
+ export const PI_UPDATE_COMMAND = "pi update";
28
+
29
+ /** Where Pi publishes its own changelog; Pi hardcodes the same URL in its box. */
30
+ export const PI_CHANGELOG_URL = "https://pi.dev/changelog";
31
+
25
32
  /**
26
33
  * The release page for a version, or `undefined` when this checkout does not name
27
34
  * a GitHub repository. Derived from `package.json` so a rename cannot leave a dead
@@ -73,14 +80,66 @@ function ownRelease(ownLatest?: string): string | undefined {
73
80
  return ownLatest ? releaseUrl(ownLatest) : undefined;
74
81
  }
75
82
 
83
+ /** The row that names the command resolving an update. */
84
+ function commandRow(command: string): NoticeItem {
85
+ return { label: "update", detail: command };
86
+ }
87
+
88
+ /** The row that names a page the answer points at, e.g. a release or a changelog. */
89
+ function linkRow(label: string, url: string): NoticeItem {
90
+ return { label, detail: url };
91
+ }
92
+
93
+ /**
94
+ * The tail both answers end with: the command to run, then where the notes are
95
+ * when they are known. Each caller supplies its own sentence, because the notice
96
+ * is read in the transcript and the re-check answer is read as one printed line.
97
+ */
98
+ function actionParts(action: string, release?: string): string[] {
99
+ return release ? [action, `Release notes: ${release}`] : [action];
100
+ }
101
+
102
+ /**
103
+ * The notice that replaces Pi's own `Update Available` box.
104
+ *
105
+ * Pi resolves the release and then draws a bordered warning block; bytefly takes
106
+ * the same release and draws the shared transcript notice, so the news reads like
107
+ * every other bytefly message and states the version movement, the command, and
108
+ * the changelog. `note` is Pi's changelog excerpt when the release API sends one;
109
+ * it is a free-form paragraph, so it is sanitized to a single row rather than
110
+ * given a layout of its own.
111
+ */
112
+ export function piReleaseNotice(release: PiRelease, current: string): NoticeMessage {
113
+ const note = release.note?.trim();
114
+ const parts = [
115
+ `Pi v${release.version} is available; this session runs v${current}.`,
116
+ `Run \`${PI_UPDATE_COMMAND}\`.`,
117
+ `Changelog: ${PI_CHANGELOG_URL}`,
118
+ ];
119
+ if (note) parts.push(`Release notes: ${note}`);
120
+ return noticeMessage({
121
+ content: parts.join(" "),
122
+ details: {
123
+ source: "bytefly",
124
+ title: `pi v${release.version} is available`,
125
+ severity: "info",
126
+ items: [
127
+ { label: "pi", detail: `v${current} → v${release.version}` },
128
+ commandRow(PI_UPDATE_COMMAND),
129
+ linkRow("changelog", PI_CHANGELOG_URL),
130
+ ...(note ? [{ label: "release notes", detail: sanitizeLine(note) }] : []),
131
+ ],
132
+ },
133
+ });
134
+ }
135
+
76
136
  /** The single notice a session posts when Pi reports package updates. */
77
137
  export function updateNotice(updates: readonly PackageUpdate[], current: string, ownLatest?: string): NoticeMessage {
78
138
  const own = updates.find(isOwnPackage);
79
139
  const release = own ? ownRelease(ownLatest) : undefined;
80
140
  const parts = [namesLine(updates)];
81
141
  if (own && ownLatest) parts.push(`${pkg.name} v${current} → v${ownLatest}.`);
82
- parts.push(`Run \`${UPDATE_COMMAND}\` to update.`);
83
- if (release) parts.push(`Release notes: ${release}`);
142
+ parts.push(...actionParts(`Run \`${UPDATE_COMMAND}\` to update.`, release));
84
143
  return noticeMessage({
85
144
  content: parts.join(" "),
86
145
  details: {
@@ -89,8 +148,8 @@ export function updateNotice(updates: readonly PackageUpdate[], current: string,
89
148
  severity: "info",
90
149
  items: [
91
150
  ...updates.map((update) => packageRow(update, current, ownLatest)),
92
- { label: "update", detail: UPDATE_COMMAND },
93
- ...(release ? [{ label: "release", detail: release }] : []),
151
+ commandRow(UPDATE_COMMAND),
152
+ ...(release ? [linkRow("release", release)] : []),
94
153
  ],
95
154
  },
96
155
  });
@@ -120,8 +179,7 @@ export function updateOutcomeText(
120
179
  const release = own ? ownRelease(outcome.ownLatest) : undefined;
121
180
  const parts = [`bytefly: ${namesLine(outcome.updates)}`];
122
181
  if (own && outcome.ownLatest) parts.push(`This package is v${current} → v${outcome.ownLatest}.`);
123
- parts.push(`Run ${UPDATE_COMMAND}.`);
124
- if (release) parts.push(`Release notes: ${release}`);
182
+ parts.push(...actionParts(`Run ${UPDATE_COMMAND}.`, release));
125
183
  return { text: parts.join(" "), severity: "info" };
126
184
  }
127
185
  }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * The one way bytefly replaces a host method it has to own.
3
+ *
4
+ * Pi draws two update blocks from methods an extension can reach but cannot turn
5
+ * off: its package list from `DefaultPackageManager.prototype`, and its
6
+ * `Update Available` box from `InteractiveMode.prototype`. Both replacements are
7
+ * the same operation, so the rules that make it safe live here once instead of
8
+ * being restated per surface:
9
+ *
10
+ * - The patch is process-wide, so it is installed at `session_start` and restored
11
+ * at `session_shutdown`; the record travels on the target under a per-method
12
+ * `Symbol.for` key, which survives the isolated module caches `lib/` is loaded
13
+ * with and makes a second registration reuse the first patch.
14
+ * - A restore only puts the original back while the patch is still the method
15
+ * installed, so a host or another extension that replaced it later keeps its
16
+ * own version.
17
+ * - A method the host does not have makes the patch inert rather than an error.
18
+ * The caller's surface then falls back to what the host draws, which is always
19
+ * correct and merely less styled.
20
+ */
21
+
22
+ /** One installed patch, and the method it replaced. */
23
+ export interface MethodPatch<F> {
24
+ /** The method as it was before the patch; `undefined` when the host lacks it. */
25
+ readonly original: F | undefined;
26
+ /** Put the original back while this patch is still installed; safe to call more than once. */
27
+ restore(): void;
28
+ }
29
+
30
+ /** The marker for one method's patch, stable across isolated module caches. */
31
+ function markerFor(method: string): symbol {
32
+ return Symbol.for(`@gavin-hu/my-pi-agent/host-patch/${method}`);
33
+ }
34
+
35
+ /**
36
+ * Replace `target[method]` with `replacement`.
37
+ *
38
+ * `target` is a prototype, a seam a test can supply as a plain object. The
39
+ * returned patch carries the captured original, because a caller usually needs it
40
+ * for something: the package check still serves bytefly's own query, and the
41
+ * release notification is the fallback when bytefly's rendering throws.
42
+ */
43
+ export function patchPrototypeMethod<T extends object, K extends keyof T & string>(
44
+ target: T,
45
+ method: K,
46
+ replacement: T[K],
47
+ ): MethodPatch<T[K]> {
48
+ const marker = markerFor(method);
49
+ const store = target as unknown as Record<symbol, MethodPatch<T[K]> | undefined>;
50
+ const existing = store[marker];
51
+ if (existing) return existing;
52
+
53
+ const original = target[method];
54
+ if (typeof original !== "function") return { original: undefined, restore: () => {} };
55
+
56
+ const patch: MethodPatch<T[K]> = {
57
+ original,
58
+ restore() {
59
+ if (target[method] === replacement) target[method] = original;
60
+ delete store[marker];
61
+ },
62
+ };
63
+ target[method] = replacement;
64
+ store[marker] = patch;
65
+ return patch;
66
+ }
@@ -28,7 +28,7 @@ import {
28
28
  } from "./cache.ts";
29
29
  import { fetchAvailableUpdates, fetchLatestVersion } from "./check.ts";
30
30
  import { isOwnPackage, updateNotice } from "./format.ts";
31
- import type { UpdateOutcome, UpdateSource } from "./state.ts";
31
+ import { paintLatest, type UpdateOutcome, type UpdateSource } from "./state.ts";
32
32
  import { suppressPiPackageUpdates, type UpdateSuppression } from "./suppress.ts";
33
33
  import type { PackageUpdate, PackageUpdateCheck } from "./types.ts";
34
34
  import { isNewerVersion } from "./version.ts";
@@ -125,13 +125,6 @@ export function registerUpdate(pi: ExtensionAPI, updates: UpdateSource, deps: Up
125
125
  return entry.updates.some(isOwnPackage) ? entry.latest : undefined;
126
126
  };
127
127
 
128
- /** Paint the header marker, or clear it when this package is current. */
129
- const paint = (latest?: string): void => {
130
- if (updates.latest() === latest) return;
131
- updates.set(latest);
132
- deps.onUpdate?.();
133
- };
134
-
135
128
  /** The once-per-load transcript notice, posted only for a non-empty list. */
136
129
  const announce = (entry: UpdateCacheEntry): void => {
137
130
  if (announced || entry.updates.length === 0) return;
@@ -141,13 +134,13 @@ export function registerUpdate(pi: ExtensionAPI, updates: UpdateSource, deps: Up
141
134
 
142
135
  /** One snapshot: paint the marker and, when it has rows, announce it once. */
143
136
  const record = (entry: UpdateCacheEntry): void => {
144
- paint(ownLatestOf(entry));
137
+ paintLatest(updates, ownLatestOf(entry), deps.onUpdate);
145
138
  announce(entry);
146
139
  };
147
140
 
148
141
  /** Offline: the cached marker is advisory, but no notice is posted. */
149
142
  const recordMarkerOnly = (entry: UpdateCacheEntry): void => {
150
- paint(ownLatestOf(entry));
143
+ paintLatest(updates, ownLatestOf(entry), deps.onUpdate);
151
144
  };
152
145
 
153
146
  /** Resolve this package's version label, dropping an equal or older answer. */
@@ -238,7 +231,7 @@ export function registerUpdate(pi: ExtensionAPI, updates: UpdateSource, deps: Up
238
231
  };
239
232
  writeCache(entry);
240
233
  // The caller prints the answer, so this path never posts a notice.
241
- paint(latest);
234
+ paintLatest(updates, latest, deps.onUpdate);
242
235
  return list.length > 0
243
236
  ? { kind: "available", updates: list, ...(latest ? { ownLatest: latest } : {}) }
244
237
  : { kind: "current" };
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Pi's own release notice, re-drawn as bytefly's.
3
+ *
4
+ * Pi resolves its new version from `pi.dev`, then draws a bordered `Update
5
+ * Available` block. This lifecycle replaces the method that draws it, so the
6
+ * release Pi already fetched becomes one shared transcript notice and a `↑`
7
+ * marker on the header's Pi line. No request of bytefly's own is made, and the
8
+ * release's changelog note, when Pi's API sends one, travels with it.
9
+ *
10
+ * The patch is installed at `session_start` in `tui` mode — `run()` starts the
11
+ * version check only after the session is up — and restored at
12
+ * `session_shutdown`, the same shape as the package-update surface. A release
13
+ * that lands after the session is gone is dropped rather than posted into it.
14
+ */
15
+
16
+ import { type ExtensionAPI, VERSION } from "@earendil-works/pi-coding-agent";
17
+ import { piReleaseNotice } from "./format.ts";
18
+ import { paintLatest, type UpdateSource } from "./state.ts";
19
+ import { suppressPiReleaseNotice, type ReleaseNoticeShim } from "./suppress.ts";
20
+ import type { PiRelease, ReleaseNoticePrototype } from "./types.ts";
21
+
22
+ /** Test seams; production patches Pi's interactive mode and reads its version. */
23
+ export interface ReleaseDeps {
24
+ /** Replace the host method bytefly patches; defaults to `InteractiveMode.prototype`. */
25
+ prototype?: ReleaseNoticePrototype;
26
+ /** Replace the running Pi version; defaults to the host's `VERSION`. */
27
+ currentVersion?: string;
28
+ /** Called once a version is recorded, so a mounted header repaints. */
29
+ onUpdate?: () => void;
30
+ }
31
+
32
+ /**
33
+ * Register the release-notice lifecycle. `latest` is the store the header reads,
34
+ * so recording Pi's new version is what puts the marker on the agent line.
35
+ */
36
+ export function registerPiRelease(pi: ExtensionAPI, latest: UpdateSource, deps: ReleaseDeps = {}): void {
37
+ const current = deps.currentVersion ?? VERSION;
38
+ let shim: ReleaseNoticeShim | undefined;
39
+ let live = false;
40
+ // Pi checks once per process, but a reload re-registers this surface: a second
41
+ // notice in the same load would be a duplicate, not news.
42
+ let announced = false;
43
+
44
+ const record = (release: PiRelease): void => {
45
+ if (!live) return;
46
+ paintLatest(latest, release.version, deps.onUpdate);
47
+ if (announced) return;
48
+ announced = true;
49
+ pi.sendMessage(piReleaseNotice(release, current));
50
+ };
51
+
52
+ pi.on("session_start", (_event, ctx) => {
53
+ // Only the interactive mode draws Pi's box, so only there is there one to replace.
54
+ if (ctx.mode !== "tui") return;
55
+ live = true;
56
+ shim ??= suppressPiReleaseNotice(record, deps.prototype);
57
+ });
58
+
59
+ pi.on("session_shutdown", () => {
60
+ live = false;
61
+ shim?.restore();
62
+ shim = undefined;
63
+ latest.set();
64
+ });
65
+ }
@@ -40,3 +40,17 @@ export function createUpdateState(): UpdateSource {
40
40
  },
41
41
  };
42
42
  }
43
+
44
+ /**
45
+ * Record a version in the store and repaint the surface that reads it.
46
+ *
47
+ * The comparison is what keeps this cheap and quiet: a check that finds the same
48
+ * version again must not re-install the header, and clearing an unknown version
49
+ * must still clear the marker. `onUpdate` is the surface's repaint hook, so a
50
+ * registration that has no mounted surface can omit it.
51
+ */
52
+ export function paintLatest(store: UpdateSource, latest: string | undefined, onUpdate?: () => void): void {
53
+ if (store.latest() === latest) return;
54
+ store.set(latest);
55
+ onUpdate?.();
56
+ }
@@ -1,20 +1,24 @@
1
1
  /**
2
- * Pi suppresses its own package-update block while bytefly owns the surface.
2
+ * Pi draws two update blocks bytefly replaces, and this module expresses both
3
+ * through the install/restore rules in `patch.ts`.
3
4
  *
4
5
  * Pi draws `Package Updates Available` from `InteractiveMode.run()` after the
5
6
  * session starts, with no setting or extension event to turn it off. The one
6
7
  * public seam is the package manager it calls: replacing
7
8
  * `checkForAvailableUpdates` with an empty result suppresses that block, while
8
- * the captured original still serves bytefly's own query. The patch is
9
- * process-wide, so it is installed at `session_start` and restored at
10
- * `session_shutdown`.
9
+ * the captured original still serves bytefly's own query.
11
10
  *
12
- * A future host that changes the method shape makes the patch a no-op rather
13
- * than an error; the update surface then falls back to Pi's block.
11
+ * Pi draws `Update Available` from the same `run()`, through a public method on
12
+ * `InteractiveMode`; replacing that method hands bytefly the release Pi resolved
13
+ * (its version and notes) so it can draw the shared notice instead.
14
+ *
15
+ * A future host that changes either shape makes that patch a no-op rather than an
16
+ * error; the update surface then falls back to Pi's own block.
14
17
  */
15
18
 
16
- import { DefaultPackageManager } from "@earendil-works/pi-coding-agent";
17
- import type { PackageUpdate, PackageUpdateCheck } from "./types.ts";
19
+ import { DefaultPackageManager, InteractiveMode } from "@earendil-works/pi-coding-agent";
20
+ import { patchPrototypeMethod } from "./patch.ts";
21
+ import type { PackageUpdate, PackageUpdateCheck, PiRelease, ReleaseNoticePrototype } from "./types.ts";
18
22
 
19
23
  /** A patchable class prototype, kept narrow so a test needs no host object. */
20
24
  export interface PackageManagerPrototype {
@@ -29,12 +33,8 @@ export interface UpdateSuppression {
29
33
  restore(): void;
30
34
  }
31
35
 
32
- /** Marker for the installed patch, so a second registration reuses it. */
33
- const SUPPRESSION = Symbol.for("@gavin-hu/my-pi-agent/pi-update-suppression");
34
-
35
- interface MarkedPrototype extends PackageManagerPrototype {
36
- [SUPPRESSION]?: UpdateSuppression;
37
- }
36
+ /** The replacement the patch installs, so a test can tell it from Pi's method. */
37
+ const SUPPRESSED: PackageUpdateCheck = async () => [];
38
38
 
39
39
  /**
40
40
  * Replace Pi's package-update check with one that reports nothing.
@@ -46,21 +46,39 @@ interface MarkedPrototype extends PackageManagerPrototype {
46
46
  export function suppressPiPackageUpdates(
47
47
  prototype: PackageManagerPrototype = DefaultPackageManager.prototype as PackageManagerPrototype,
48
48
  ): UpdateSuppression {
49
- const marked = prototype as MarkedPrototype;
50
- const existing = marked[SUPPRESSION];
51
- if (existing) return existing;
49
+ const patch = patchPrototypeMethod(prototype, "checkForAvailableUpdates", SUPPRESSED);
50
+ if (!patch.original) return { check: async () => [], restore: patch.restore };
51
+
52
+ const originalCheck = patch.original as unknown as (this: DefaultPackageManager) => Promise<PackageUpdate[]>;
53
+ return { check: (manager) => originalCheck.call(manager), restore: patch.restore };
54
+ }
55
+
56
+ /** The installed release-notice shim, and how to give Pi its method back. */
57
+ export interface ReleaseNoticeShim {
58
+ /** Put Pi's method back; safe to call more than once. */
59
+ restore(): void;
60
+ }
52
61
 
53
- const original = prototype.checkForAvailableUpdates;
54
- if (typeof original !== "function") return { check: async () => [], restore: () => {} };
55
- const originalCheck = original as unknown as (this: DefaultPackageManager) => Promise<PackageUpdate[]>;
56
- const check: PackageUpdateCheck = (manager) => originalCheck.call(manager);
57
- const patched: PackageUpdateCheck = async () => [];
58
- const restore = (): void => {
59
- if (prototype.checkForAvailableUpdates === patched) prototype.checkForAvailableUpdates = original;
60
- delete marked[SUPPRESSION];
62
+ /**
63
+ * Hand Pi's `Update Available` release to `notify` instead of letting Pi draw it.
64
+ *
65
+ * `prototype` is a seam for tests; production passes `InteractiveMode.prototype`.
66
+ * When the host has no such method the shim is inert and Pi's box is what shows,
67
+ * which is exactly the state this replaces. A `notify` that throws is also a
68
+ * fallback to Pi's box: a release nobody hears about is worse than Pi's own
69
+ * rendering of it, and the error itself is not allowed to escape into `run()`.
70
+ */
71
+ export function suppressPiReleaseNotice(
72
+ notify: (release: PiRelease) => void,
73
+ prototype: ReleaseNoticePrototype = InteractiveMode.prototype as ReleaseNoticePrototype,
74
+ ): ReleaseNoticeShim {
75
+ const original = prototype.showNewVersionNotification as (this: ReleaseNoticePrototype, release: PiRelease) => void;
76
+ const patched = function (this: ReleaseNoticePrototype, release: PiRelease): void {
77
+ try {
78
+ notify(release);
79
+ } catch {
80
+ if (typeof original === "function") original.call(this, release);
81
+ }
61
82
  };
62
- const suppression: UpdateSuppression = { check, restore };
63
- prototype.checkForAvailableUpdates = patched;
64
- marked[SUPPRESSION] = suppression;
65
- return suppression;
83
+ return patchPrototypeMethod(prototype, "showNewVersionNotification", patched);
66
84
  }
@@ -1,15 +1,25 @@
1
1
  /**
2
- * The package-update shape Pi's manager returns.
2
+ * The update shapes Pi publishes, derived from the public methods that carry
3
+ * them.
3
4
  *
4
- * Pi exports the manager but not its `PackageUpdate` interface, so the type is
5
- * derived from the public method. That keeps the extension on the same contract
6
- * without reaching into a dist path or restating the fields.
5
+ * Pi exports the package manager and the interactive mode but not the interfaces
6
+ * they pass around, so each type here is read off a public signature. That keeps
7
+ * the extension on the same contract without reaching into a dist path or
8
+ * restating the fields a host version may change.
7
9
  */
8
10
 
9
- import type { DefaultPackageManager } from "@earendil-works/pi-coding-agent";
11
+ import type { DefaultPackageManager, InteractiveMode } from "@earendil-works/pi-coding-agent";
10
12
 
11
13
  /** One package Pi reports as updateable. */
12
14
  export type PackageUpdate = Awaited<ReturnType<DefaultPackageManager["checkForAvailableUpdates"]>>[number];
13
15
 
14
16
  /** Pi's check, captured before suppression and called with a manager. */
15
17
  export type PackageUpdateCheck = (manager: DefaultPackageManager) => Promise<PackageUpdate[]>;
18
+
19
+ /** A newer Pi release as Pi itself resolved it: the version, and its notes when any. */
20
+ export type PiRelease = Parameters<InteractiveMode["showNewVersionNotification"]>[0];
21
+
22
+ /** The notification method bytefly replaces, narrow enough for a test to fake. */
23
+ export interface ReleaseNoticePrototype {
24
+ showNewVersionNotification(release: PiRelease): void;
25
+ }
@@ -79,6 +79,8 @@ export class CheckRunComponent implements Component {
79
79
  });
80
80
  if (body.length === 0) body.push(...emptyState(this.theme, w, { label: "Starting…" }));
81
81
  return dockListFrame({
82
+ theme: this.theme,
83
+ width: w,
82
84
  header: titleRule(this.theme, w, "Check"),
83
85
  summary: clip(
84
86
  ` ${paint(this.theme, "meta", `running${SEPARATORS.item}${sanitizeLine(this.options.command)}${SEPARATORS.item}${formatDuration(this.options.elapsedMs())}`)}`,
@@ -16,15 +16,19 @@ pi install ./ # install the package
16
16
  - Registers one command, `/context`. In the TUI it opens a docked screen; in
17
17
  `rpc`, `json`, and `print` it notifies the same facts as plain text.
18
18
  - Reports Pi's own estimate first — tokens, window, percentage, free space, the
19
- auto-compaction threshold, and the headroom left before it — then the
20
- composition: the system prompt, the active tool definitions, the conversation
21
- by role, injected pack context grouped by type, summaries, and shell output.
19
+ auto-compaction threshold, and the headroom left before it while compaction is
20
+ on — then the composition under a header that names the composition's own
21
+ estimate: the system prompt, the active tool definitions, the conversation by
22
+ role, injected pack context grouped by type, summaries, and shell output.
22
23
  - Names the difference between Pi's total and the composition estimate instead
23
- of forcing the parts to add up: provider usage plus trailing messages is not
24
- the same measurement as characters/4 over the rows.
24
+ of forcing the parts to add up: one line with both figures, `unattributed` when
25
+ the host's total is the larger one, and `host counts messages only` before the
26
+ first reply, when Pi's fallback leaves the prompt and the tools out.
27
+ - Rounds each bar to the nearest cell, so a bar sits within half a cell of the
28
+ percentage printed beside it; the percentage itself is exact.
25
29
  - Reads the branch's compaction history: how many compactions there have been,
26
30
  what the newest one cut from and to, and how much its summary costs.
27
- - Lists the five largest single blocks, so a report that is mostly one tool
31
+ - Lists the five largest single messages, so a report that is mostly one tool
28
32
  result says so.
29
33
  - Compacts from the report: `c` on the screen asks first, `/context compact
30
34
  [instructions]` runs directly. Both refuse while the agent is busy — a
@@ -58,7 +62,7 @@ for.
58
62
 
59
63
  `/context` works everywhere. The screen needs `ctx.mode === "tui"`; the other
60
64
  modes notify the same report as text, including the composition rows and the
61
- largest blocks. `/context compact` is the same in every mode and reports through
65
+ largest messages. `/context compact` is the same in every mode and reports through
62
66
  `ctx.ui.notify` when it finishes or fails.
63
67
 
64
68
  ## How the numbers are computed
@@ -67,13 +71,14 @@ largest blocks. `/context compact` is the same in every mode and reports through
67
71
  |---|---|
68
72
  | Headline (`tokens`, `percent`) | `ctx.getContextUsage()`: the last valid provider usage plus Pi's estimate of the messages after it. `tokens` is `null` between a compaction and the next successful reply, because the last usage describes the pre-compaction context. |
69
73
  | Window | `getContextUsage().contextWindow`, then the active model's `contextWindow`. |
74
+ | Headroom | `window - reserve - tokens`, `null` when the total is unknown or automatic compaction is off — there is no threshold in effect. |
70
75
  | System prompt | A `system` message in the projection when one exists, otherwise `ctx.getSystemPrompt()` at characters/4. |
71
76
  | Tool definitions | `pi.getAllTools()` filtered to `pi.getActiveTools()`, over each active tool's name, description, and parameter schema at characters/4. `promptGuidelines` are counted in the system prompt instead, because Pi renders them into its rules section. |
72
77
  | Conversation rows | `sessionManager.buildSessionProjection().messages`, each counted with Pi's own `estimateTokens` (characters/4; an image is 4800 characters; a tool call is its name plus its JSON arguments). |
73
78
  | Injected context | `custom` messages grouped by `customType`, biggest first; the five largest types are named and the rest fold into one row. |
74
79
  | Threshold | `compaction.modelOverrides["provider/id"]`, then `compaction`, then `DEFAULT_COMPACTION_SETTINGS` — the same three-step fallback Pi uses. |
75
80
  | Compaction history | `getLatestCompactionEntry(branch)` and the branch's `compaction` count. The summary's cost is the summary text at characters/4. |
76
- | Drift | `tokens - estimate`. Positive means Pi's total includes overhead no row attributes; negative means the rows are larger, which happens before the first reply because Pi's message-only fallback omits the system prompt and tool definitions. Both numbers are estimates and the row says which direction the gap runs. |
81
+ | Drift | `tokens - estimate`, printed with both figures. A positive value appends `+… unattributed` — usage the rows do not explain; a negative one appends `host counts messages only` while the projection holds no assistant message, because Pi's message-only fallback omits the system prompt and the tool definitions. |
77
82
 
78
83
  ## Limitations
79
84
 
@@ -92,8 +97,13 @@ largest blocks. `/context compact` is the same in every mode and reports through
92
97
  Once a reply exists the host total includes them.
93
98
  - Without a model whose `contextWindow` is known there is no threshold to show;
94
99
  the report says so and leaves compaction to Pi.
95
- - Descriptors in **Largest blocks** are one line, clipped to 40 characters; a long
96
- tool result is represented by its tool name, not its content.
100
+ - A percentage is computed from unrounded tokens and shown to one decimal, while
101
+ the token column is compact (`5.1k`), so re-deriving a percentage from the
102
+ printed figures can differ by a couple of points. A bar rounds to the nearest
103
+ cell and a non-zero share always gets one, so a `0.2%` row draws one cell
104
+ beside its label.
105
+ - Descriptors in **Largest messages** are one line, clipped to 40 characters; a
106
+ long tool result is represented by its tool name, not its content.
97
107
 
98
108
  ## Non-goals
99
109
 
@@ -146,7 +156,7 @@ largest blocks. `/context compact` is the same in every mode and reports through
146
156
  | `index.ts` | Factory: the `PI_DISABLED_EXTENSIONS` guard and command registration. |
147
157
  | `commands.ts` | `/context` and `/context compact`: the TUI mount, the text fallback, and the idle guard. |
148
158
  | `collect.ts` | The host boundary: one read of usage, prompt, tools, settings, projection, and branch. |
149
- | `report.ts` | The pure builder: composition, reserve resolution, compaction history, largest blocks. |
159
+ | `report.ts` | The pure builder: composition, reserve resolution, compaction history, largest messages. |
150
160
  | `format.ts` | The plain-text report for non-TUI modes. |
151
161
  | `tui.ts` | `ContextComponent`: gauge, rows, scroll, confirmation, feedback. |
152
162
  | `types.ts` | `ContextInputs`, `ContextReport`, buckets, and the host types named through `ReturnType<…>`. |
@@ -48,11 +48,11 @@ export function formatCompaction(report: ContextReport): string {
48
48
 
49
49
  /** The difference between the host's total and the composition estimate, when it is not zero. */
50
50
  export function formatDrift(report: ContextReport): string | undefined {
51
- if (report.drift === null || report.drift === 0) return undefined;
52
- if (report.drift > 0) {
53
- return `Provider overhead · +${formatTokens(report.drift)} not attributed to a row`;
54
- }
55
- return `Estimate drift · the rows are ${formatTokens(-report.drift)} above the host's total; both are estimates`;
51
+ const tokens = report.tokens;
52
+ if (report.drift === null || report.drift === 0 || tokens === null) return undefined;
53
+ const base = `Estimate drift · rows ${formatTokens(report.estimate)} vs host ${formatTokens(tokens)}`;
54
+ if (report.drift > 0) return `${base} · +${formatTokens(report.drift)} unattributed`;
55
+ return report.beforeFirstReply ? `${base} · host counts messages only` : base;
56
56
  }
57
57
 
58
58
  /** One composition row, as plain text. */
@@ -65,10 +65,12 @@ export function formatBucket(bucket: ContextBucket): string {
65
65
  return `${indent}${label}${tokens}${share}${note}`;
66
66
  }
67
67
 
68
- /** The report as a notification body: headline, budget, rows, then what happened. */
68
+ /** The report as a notification body: headline, budget, composition, rows, then what happened. */
69
69
  export function formatReport(report: ContextReport): string {
70
70
  const lines = [formatHeadline(report), formatBudget(report)];
71
- if (report.buckets.length > 0) lines.push("", ...report.buckets.map(formatBucket));
71
+ if (report.buckets.length > 0) {
72
+ lines.push("", `Composition · ${formatTokens(report.estimate)} estimate`, ...report.buckets.map(formatBucket));
73
+ }
72
74
  const drift = formatDrift(report);
73
75
  if (drift) lines.push("", drift);
74
76
  lines.push("", `Compaction · ${formatCompaction(report)}`);
@@ -76,7 +78,7 @@ export function formatReport(report: ContextReport): string {
76
78
  const blocks = report.largest
77
79
  .map((block) => `${formatTokens(block.tokens)} ${block.role} ${block.label}`)
78
80
  .join(" · ");
79
- lines.push(`Largest · ${blocks}`);
81
+ lines.push("", `Largest · ${blocks}`);
80
82
  }
81
83
  return lines.join("\n");
82
84
  }
@@ -94,7 +94,7 @@ function firstText(content: unknown): string {
94
94
  return "";
95
95
  }
96
96
 
97
- /** A one-line, bounded descriptor for the "largest blocks" list. */
97
+ /** A one-line, bounded descriptor for the "largest messages" list. */
98
98
  function boundedLabel(text: string, maxLength = 40): string {
99
99
  const line = sanitizeLine(text);
100
100
  return line.length > maxLength ? `${line.slice(0, maxLength - 1)}…` : line;
@@ -184,7 +184,10 @@ export function buildReport(inputs: ContextInputs): ContextReport {
184
184
  const percent = inputs.usage?.percent ?? (tokens !== null && window > 0 ? (tokens / window) * 100 : null);
185
185
  const free = tokens !== null && window > 0 ? window - tokens : null;
186
186
  const autoCompactAt = window > 0 ? Math.max(0, window - resolved.reserve) : 0;
187
- const headroom = tokens !== null && free !== null && window > 0 ? autoCompactAt - tokens : null;
187
+ const headroom = resolved.enabled && tokens !== null && window > 0 ? autoCompactAt - tokens : null;
188
+ // Pi's total is a message-only estimate until an assistant reply reports real
189
+ // usage, so the drift row names that cause instead of calling the gap noise.
190
+ const beforeFirstReply = !inputs.messages.some((message) => roleOf(message) === "assistant");
188
191
 
189
192
  const messageCounts = new Map<string, number>();
190
193
  const messageTokens = new Map<string, number>();
@@ -267,12 +270,21 @@ export function buildReport(inputs: ContextInputs): ContextReport {
267
270
  const children = [...injectedTokens.entries()].sort((a, b) => b[1] - a[1]);
268
271
  const shown = children.slice(0, 5);
269
272
  const rest = children.slice(5);
270
- const childDrafts: BucketDraft[] = shown.map(([customType, estimateForType]) => ({
271
- id: `injected:${customType}`,
272
- label: customType,
273
- tokens: estimateForType,
274
- note: countNote(injectedCounts.get(customType) ?? 0),
275
- }));
273
+ // A child's count is informative only when it divides the parent's; one
274
+ // injected type would otherwise repeat the same `1 message` twice.
275
+ const childNote = (customType: string): string | undefined => {
276
+ const count = injectedCounts.get(customType) ?? 0;
277
+ return count > 0 && count < injectedCount ? countNote(count) : undefined;
278
+ };
279
+ const childDrafts: BucketDraft[] = shown.map(([customType, estimateForType]) => {
280
+ const note = childNote(customType);
281
+ return {
282
+ id: `injected:${customType}`,
283
+ label: customType,
284
+ tokens: estimateForType,
285
+ ...(note ? { note } : {}),
286
+ };
287
+ });
276
288
  if (rest.length > 0) {
277
289
  childDrafts.push({
278
290
  id: "injected:other",
@@ -313,6 +325,7 @@ export function buildReport(inputs: ContextInputs): ContextReport {
313
325
  buckets,
314
326
  estimate,
315
327
  drift: tokens !== null ? tokens - estimate : null,
328
+ beforeFirstReply,
316
329
  compaction: compactionHistory(inputs),
317
330
  largest,
318
331
  };