@gavin-hu/my-pi-agent 0.11.4 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (197) hide show
  1. package/README.md +38 -26
  2. package/README.zh-CN.md +33 -23
  3. package/extensions/bytefly/README.md +381 -225
  4. package/extensions/bytefly/chrome.ts +7 -2
  5. package/extensions/bytefly/doctor/register.ts +6 -0
  6. package/extensions/bytefly/doctor/runtime.ts +52 -6
  7. package/extensions/bytefly/doctor/tools.ts +1 -3
  8. package/extensions/bytefly/footer/collect/snapshot.ts +12 -2
  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 +54 -15
  12. package/extensions/bytefly/footer/price/price.ts +20 -7
  13. package/extensions/bytefly/footer/price/types.ts +13 -7
  14. package/extensions/bytefly/footer/register.ts +13 -2
  15. package/extensions/bytefly/footer/render/format.ts +3 -2
  16. package/extensions/bytefly/footer/render/lines.ts +2 -0
  17. package/extensions/bytefly/footer/render/segments.ts +30 -2
  18. package/extensions/bytefly/footer/types.ts +12 -0
  19. package/extensions/bytefly/header/component.ts +21 -6
  20. package/extensions/bytefly/header/config.ts +3 -0
  21. package/extensions/bytefly/header/register.ts +34 -3
  22. package/extensions/bytefly/index.ts +15 -4
  23. package/extensions/bytefly/notify/config.ts +2 -0
  24. package/extensions/bytefly/notify/format.ts +19 -1
  25. package/extensions/bytefly/notify/register.ts +37 -13
  26. package/extensions/bytefly/notify/types.ts +2 -0
  27. package/extensions/bytefly/panel/commands.ts +34 -13
  28. package/extensions/bytefly/panel/settings-runtime.ts +71 -28
  29. package/extensions/bytefly/panel/settings.ts +293 -48
  30. package/extensions/bytefly/panel/tui/entries.ts +58 -0
  31. package/extensions/bytefly/panel/tui/extensions-view.ts +86 -0
  32. package/extensions/bytefly/panel/tui/frame.ts +71 -0
  33. package/extensions/bytefly/panel/tui/nav.ts +75 -0
  34. package/extensions/bytefly/panel/tui/settings-view.ts +115 -0
  35. package/extensions/bytefly/panel/tui.ts +205 -319
  36. package/extensions/bytefly/picker/runtime.ts +61 -2
  37. package/extensions/bytefly/picker/toggle.ts +56 -0
  38. package/extensions/bytefly/update/cache.ts +73 -0
  39. package/extensions/bytefly/update/check.ts +59 -0
  40. package/extensions/bytefly/update/format.ts +87 -0
  41. package/extensions/bytefly/update/register.ts +158 -0
  42. package/extensions/bytefly/update/state.ts +40 -0
  43. package/extensions/bytefly/update/version.ts +83 -0
  44. package/extensions/document/README.md +24 -14
  45. package/extensions/document/config.ts +4 -0
  46. package/extensions/document/deadline.ts +61 -0
  47. package/extensions/document/tool.ts +14 -5
  48. package/extensions/file-browse/README.md +12 -8
  49. package/extensions/file-browse/client.ts +6 -7
  50. package/extensions/file-browse/files.ts +0 -4
  51. package/extensions/file-browse/git.ts +9 -10
  52. package/extensions/file-browse/html.ts +17 -7
  53. package/extensions/file-browse/router.ts +24 -15
  54. package/extensions/file-open/README.md +30 -15
  55. package/extensions/file-open/index.ts +4 -3
  56. package/extensions/file-open/open.ts +52 -21
  57. package/extensions/goal/README.md +45 -13
  58. package/extensions/goal/clarify-prompt.ts +5 -36
  59. package/extensions/goal/clarify.ts +55 -86
  60. package/extensions/goal/commands.ts +41 -35
  61. package/extensions/goal/format.ts +12 -4
  62. package/extensions/goal/schema.ts +6 -2
  63. package/extensions/goal/tools.ts +28 -8
  64. package/extensions/goal/tui.ts +1 -1
  65. package/extensions/goal/types.ts +6 -2
  66. package/extensions/job/README.md +16 -12
  67. package/extensions/job/runtime.ts +23 -6
  68. package/extensions/job/status.ts +4 -2
  69. package/extensions/memory/README.md +56 -14
  70. package/extensions/memory/commands.ts +20 -2
  71. package/extensions/memory/doctor.ts +137 -0
  72. package/extensions/memory/format.ts +24 -7
  73. package/extensions/memory/runtime.ts +49 -0
  74. package/extensions/memory/store.ts +31 -0
  75. package/extensions/memory/tui.ts +136 -12
  76. package/extensions/plan/README.md +83 -36
  77. package/extensions/plan/blocks.ts +1 -35
  78. package/extensions/plan/commands.ts +84 -19
  79. package/extensions/plan/index.ts +35 -11
  80. package/extensions/plan/plan-index.ts +234 -0
  81. package/extensions/plan/plan-text.ts +95 -0
  82. package/extensions/plan/plans.ts +91 -2
  83. package/extensions/plan/policy.ts +4 -3
  84. package/extensions/plan/review.ts +246 -0
  85. package/extensions/plan/runtime.ts +73 -24
  86. package/extensions/plan/steps.ts +60 -39
  87. package/extensions/plan/todos.ts +29 -60
  88. package/extensions/plan/tools.ts +188 -244
  89. package/extensions/plan/tui.ts +18 -31
  90. package/extensions/plan/types.ts +12 -0
  91. package/extensions/plan/web-client.ts +51 -18
  92. package/extensions/plan/web-page.ts +7 -2
  93. package/extensions/plan/web-router.ts +11 -3
  94. package/extensions/plan/web.ts +106 -41
  95. package/extensions/question/README.md +1 -1
  96. package/extensions/question/tools.ts +1 -1
  97. package/extensions/rewind/README.md +28 -8
  98. package/extensions/rewind/commands.ts +1 -1
  99. package/extensions/rewind/format.ts +4 -4
  100. package/extensions/rewind/git.ts +56 -18
  101. package/extensions/rewind/index.ts +3 -5
  102. package/extensions/rewind/path-picker.ts +198 -0
  103. package/extensions/rewind/paths.ts +128 -0
  104. package/extensions/rewind/restore.ts +69 -28
  105. package/extensions/rewind/rewind.ts +80 -3
  106. package/extensions/rewind/runtime.ts +13 -5
  107. package/extensions/rewind/snapshot-run.ts +5 -2
  108. package/extensions/rewind/types.ts +2 -0
  109. package/extensions/subagent/README.md +7 -1
  110. package/extensions/subagent/accounting.ts +34 -0
  111. package/extensions/subagent/index.ts +23 -3
  112. package/extensions/subagent/stream.ts +2 -0
  113. package/extensions/subagent/types.ts +2 -0
  114. package/extensions/todo/README.md +19 -11
  115. package/extensions/todo/index.ts +5 -3
  116. package/extensions/todo/nudge.ts +58 -9
  117. package/extensions/todo/runtime.ts +28 -17
  118. package/extensions/watch/README.md +8 -3
  119. package/extensions/watch/index.ts +19 -0
  120. package/extensions/web-access/README.md +46 -16
  121. package/extensions/web-access/charset.ts +58 -0
  122. package/extensions/web-access/fetch/find.ts +11 -7
  123. package/extensions/web-access/fetch/format.ts +64 -18
  124. package/extensions/web-access/fetch/page.ts +5 -1
  125. package/extensions/web-access/fetch/tool.ts +20 -21
  126. package/extensions/web-access/http.ts +49 -8
  127. package/extensions/web-access/search/facts.ts +38 -0
  128. package/extensions/web-access/search/tool.ts +14 -3
  129. package/extensions/web-access/text.ts +50 -11
  130. package/extensions/wechat/README.md +5 -4
  131. package/extensions/wechat/tools.ts +0 -2
  132. package/extensions/worktree/README.md +74 -15
  133. package/extensions/worktree/config.ts +11 -0
  134. package/extensions/worktree/exit.ts +35 -3
  135. package/extensions/worktree/gaps.ts +74 -0
  136. package/extensions/worktree/guard.ts +50 -4
  137. package/extensions/worktree/handoff.ts +55 -0
  138. package/extensions/worktree/index.ts +12 -2
  139. package/extensions/worktree/runtime.ts +20 -0
  140. package/extensions/worktree/status.ts +15 -2
  141. package/extensions/worktree/tools.ts +3 -2
  142. package/lib/README.md +12 -9
  143. package/lib/config.ts +43 -1
  144. package/lib/env.ts +12 -0
  145. package/lib/events.ts +86 -1
  146. package/lib/goal-state.ts +95 -0
  147. package/lib/requirements.ts +340 -46
  148. package/lib/todo-state.ts +62 -0
  149. package/lib/tui/README.md +91 -0
  150. package/lib/tui/glyphs.ts +2 -0
  151. package/lib/web/layout.ts +12 -2
  152. package/package.json +5 -3
  153. package/themes/bytefly-dark.json +1 -1
  154. package/assets/bytefly.schema.json +0 -785
  155. package/assets/chrome.png +0 -0
  156. package/assets/file-browse.png +0 -0
  157. package/assets/job.png +0 -0
  158. package/assets/plan-review.png +0 -0
  159. package/assets/question.png +0 -0
  160. package/assets/rewind.png +0 -0
  161. package/assets/todo.png +0 -0
  162. package/assets/tool-renderings.png +0 -0
  163. package/assets/tools/ask_user_question-cancelled.png +0 -0
  164. package/assets/tools/ask_user_question.png +0 -0
  165. package/assets/tools/bash.png +0 -0
  166. package/assets/tools/edit.png +0 -0
  167. package/assets/tools/enter_plan_mode.png +0 -0
  168. package/assets/tools/enter_worktree.png +0 -0
  169. package/assets/tools/exit_plan_mode.png +0 -0
  170. package/assets/tools/exit_worktree.png +0 -0
  171. package/assets/tools/find.png +0 -0
  172. package/assets/tools/goal.png +0 -0
  173. package/assets/tools/grep.png +0 -0
  174. package/assets/tools/job.png +0 -0
  175. package/assets/tools/list_worktrees.png +0 -0
  176. package/assets/tools/ls.png +0 -0
  177. package/assets/tools/memory-long-subject.png +0 -0
  178. package/assets/tools/memory.png +0 -0
  179. package/assets/tools/notice.png +0 -0
  180. package/assets/tools/open_path.png +0 -0
  181. package/assets/tools/prune_worktrees.png +0 -0
  182. package/assets/tools/read.png +0 -0
  183. package/assets/tools/read_doc-running.png +0 -0
  184. package/assets/tools/read_doc.png +0 -0
  185. package/assets/tools/run_doctor.png +0 -0
  186. package/assets/tools/send_wechat.png +0 -0
  187. package/assets/tools/set_plan_steps.png +0 -0
  188. package/assets/tools/subagent.png +0 -0
  189. package/assets/tools/todo.png +0 -0
  190. package/assets/tools/turn-rule.png +0 -0
  191. package/assets/tools/user-message.png +0 -0
  192. package/assets/tools/watch.png +0 -0
  193. package/assets/tools/web_fetch-rejected.png +0 -0
  194. package/assets/tools/web_fetch.png +0 -0
  195. package/assets/tools/web_search.png +0 -0
  196. package/assets/tools/write.png +0 -0
  197. package/assets/tools/write_plan.png +0 -0
@@ -7,8 +7,15 @@
7
7
  * reads `items()` on every render, so it stays a derived view of this state.
8
8
  */
9
9
 
10
- import { getPackageResourcePattern, getResourcePattern } from "./resources.ts";
11
- import { nextGlobalPackages, nextProjectPackages, nextProjectTopLevelList, nextTopLevelList } from "./toggle.ts";
10
+ import { getDisplayName, getPackageResourcePattern, getResourcePattern } from "./resources.ts";
11
+ import {
12
+ clearProjectPackages,
13
+ clearProjectTopLevelList,
14
+ nextGlobalPackages,
15
+ nextProjectPackages,
16
+ nextProjectTopLevelList,
17
+ nextTopLevelList,
18
+ } from "./toggle.ts";
12
19
  import type { Discovery, ResourceItem, SettingsStore, WriteScope } from "./types.ts";
13
20
 
14
21
  export interface RuntimeDeps {
@@ -26,6 +33,8 @@ export interface ExtensionsPickerRuntime {
26
33
  projectAvailable(): boolean;
27
34
  isDirty(): boolean;
28
35
  toggle(item: ResourceItem): Promise<void>;
36
+ /** Drop `item`'s project override in Project scope; a no-op when it has none. */
37
+ clearOverride(item: ResourceItem): Promise<void>;
29
38
  onChange(listener: () => void): () => void;
30
39
  }
31
40
 
@@ -34,6 +43,11 @@ function storeFor(discovery: Discovery, scope: WriteScope): SettingsStore {
34
43
  return scope === "global" ? discovery.stores.global : discovery.stores.project;
35
44
  }
36
45
 
46
+ /** Whether two rows name the same resource, for an inherited-state lookup. */
47
+ function sameResource(left: ResourceItem, right: ResourceItem): boolean {
48
+ return left.metadata.source === right.metadata.source && left.path === right.path;
49
+ }
50
+
37
51
  export function createExtensionsPickerRuntime(deps: RuntimeDeps): ExtensionsPickerRuntime {
38
52
  const { discovery, cwd, agentDir } = deps;
39
53
  let scope: WriteScope = "global";
@@ -72,6 +86,27 @@ export function createExtensionsPickerRuntime(deps: RuntimeDeps): ExtensionsPick
72
86
  store.setProjectPackages(nextProjectPackages(store.packages(), item, enabled, cwd, agentDir));
73
87
  };
74
88
 
89
+ /**
90
+ * Drop `item`'s project override, reporting whether anything was written. An
91
+ * absent override returns `false` without touching the store, so a stray key
92
+ * press cannot dirty the file.
93
+ */
94
+ const clearFromStore = (item: ResourceItem): boolean => {
95
+ const store = storeFor(discovery, "project");
96
+ if (item.metadata.origin === "top-level") {
97
+ const paths = store.projectSettings().extensions ?? [];
98
+ const next = clearProjectTopLevelList(paths, item, cwd, agentDir);
99
+ if (next === paths) return false;
100
+ store.setProjectExtensionPaths(next);
101
+ return true;
102
+ }
103
+ const packages = store.packages();
104
+ const next = clearProjectPackages(packages, item, cwd, agentDir);
105
+ if (next === packages) return false;
106
+ store.setProjectPackages(next);
107
+ return true;
108
+ };
109
+
75
110
  return {
76
111
  scope: () => scope,
77
112
  setScope: (next) => {
@@ -100,6 +135,30 @@ export function createExtensionsPickerRuntime(deps: RuntimeDeps): ExtensionsPick
100
135
  dirty = true;
101
136
  emit();
102
137
  },
138
+ clearOverride: async (item) => {
139
+ if (scope !== "project") {
140
+ deps.onError?.("Overrides are cleared in Project scope; switch with Tab.");
141
+ return;
142
+ }
143
+ const store = storeFor(discovery, "project");
144
+ try {
145
+ if (!clearFromStore(item)) {
146
+ deps.onError?.(`No project override for ${getDisplayName(item)}.`);
147
+ return;
148
+ }
149
+ await store.flush();
150
+ for (const error of store.drainErrors()) deps.onError?.(error.error.message);
151
+ } catch (error) {
152
+ deps.onError?.(error instanceof Error ? error.message : String(error));
153
+ return;
154
+ }
155
+ // With the override gone the row shows the inherited state again; a
156
+ // project-only resource has no global twin and keeps its value.
157
+ const inherited = views.global.find((entry) => sameResource(entry, item));
158
+ if (inherited) item.enabled = inherited.enabled;
159
+ dirty = true;
160
+ emit();
161
+ },
103
162
  onChange: (listener) => {
104
163
  listeners.add(listener);
105
164
  return () => listeners.delete(listener);
@@ -143,3 +143,59 @@ export function nextProjectPackages(
143
143
  }
144
144
  return updated;
145
145
  }
146
+
147
+ /**
148
+ * Remove the project top-level override a toggle wrote for `item`.
149
+ *
150
+ * Drops the `+`/`-` entry for the item's target patterns, and the bare path
151
+ * entry {@link nextProjectTopLevelList} adds beside it for an inherited personal
152
+ * resource. Returns `current` unchanged — the same array — when no override is
153
+ * present, so a caller can tell "nothing to clear" without comparing contents.
154
+ */
155
+ export function clearProjectTopLevelList(
156
+ current: string[],
157
+ item: ResourceItem,
158
+ cwd: string,
159
+ agentDir: string,
160
+ ): string[] {
161
+ const inherited = getItemScope(item) === "user";
162
+ const pattern = inherited ? item.path : getResourcePatternForScope(item, "project", cwd, agentDir);
163
+ const patterns = getTopLevelOverridePatterns(item, "project", cwd, agentDir);
164
+ const isOverride = (entry: string): boolean =>
165
+ (entry.startsWith("!") || entry.startsWith("+") || entry.startsWith("-")) &&
166
+ patterns.has(getPatternEntryTarget(entry));
167
+ if (!current.some(isOverride)) return current;
168
+ return current.filter((entry) => !isOverride(entry) && !(inherited && entry === pattern));
169
+ }
170
+
171
+ /**
172
+ * Remove the project package filter a toggle wrote for `item`.
173
+ *
174
+ * An entry the toggle created with `autoload: false` is dropped once it holds no
175
+ * filters, and one that predates the toggle collapses back to its plain source,
176
+ * mirroring {@link nextProjectPackages}. Returns `packages` unchanged — the same
177
+ * array — when the item has no project filter.
178
+ */
179
+ export function clearProjectPackages(
180
+ packages: PackageSource[],
181
+ item: ResourceItem,
182
+ cwd: string,
183
+ agentDir: string,
184
+ ): PackageSource[] {
185
+ const index = packages.findIndex((entry) =>
186
+ packageSourceMatches(item, typeof entry === "string" ? entry : entry.source, "project", cwd, agentDir),
187
+ );
188
+ const entry = index === -1 ? undefined : packages[index];
189
+ if (entry === undefined || typeof entry === "string") return packages;
190
+
191
+ const pattern = getPackageResourcePattern(item);
192
+ const filters = entry.extensions ?? [];
193
+ if (!filters.some((filter) => getPatternEntryTarget(filter) === pattern)) return packages;
194
+
195
+ const updated = [...packages];
196
+ const remaining = filters.filter((filter) => getPatternEntryTarget(filter) !== pattern);
197
+ if (remaining.length > 0) updated[index] = { ...entry, extensions: remaining };
198
+ else if (entry.autoload === false) updated.splice(index, 1);
199
+ else updated[index] = entry.source;
200
+ return updated;
201
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * The published-version answer, cached on disk with the time it was read.
3
+ *
4
+ * The check already fires once per session, which leaves two gaps: a session open
5
+ * for days never re-reads npm, and every new session pays for a request it made
6
+ * minutes earlier. A timestamp is what fixes the second, so the cache lives in the
7
+ * agent directory rather than in memory — one entry, one window, shared by every
8
+ * session that starts inside it.
9
+ *
10
+ * A cache can only ever be a miss. An absent, unreadable, or wrong-typed file
11
+ * reads as `undefined` and the caller fetches; nothing here throws, because a
12
+ * stale file must never become a session error.
13
+ */
14
+
15
+ import { readFileSync } from "node:fs";
16
+ import { join } from "node:path";
17
+ import { writeConfigFile } from "../../../lib/config.ts";
18
+ import { parseVersion } from "./version.ts";
19
+
20
+ /** The file name under `<agentDir>/bytefly/`. */
21
+ export const UPDATE_CACHE_FILE = "update.json";
22
+
23
+ /** How long a cached answer is reused before the next session re-reads npm. */
24
+ export const UPDATE_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
25
+
26
+ /** One cached answer: the newest published version and when it was read. */
27
+ export interface UpdateCacheEntry {
28
+ /** The version npm's `latest` tag pointed at. */
29
+ latest: string;
30
+ /** Epoch milliseconds of the read that produced `latest`. */
31
+ checkedAt: number;
32
+ }
33
+
34
+ /** Where the cache lives for an agent directory. */
35
+ export function updateCachePath(agentDir: string): string {
36
+ return join(agentDir, "bytefly", UPDATE_CACHE_FILE);
37
+ }
38
+
39
+ /** Read the cache; a missing, corrupt, or mistyped entry is `undefined`. */
40
+ export function readUpdateCache(path: string): UpdateCacheEntry | undefined {
41
+ try {
42
+ const raw = JSON.parse(readFileSync(path, "utf-8")) as Record<string, unknown> | undefined;
43
+ if (!raw || typeof raw !== "object") return undefined;
44
+ const latest = typeof raw.latest === "string" ? raw.latest.trim() : "";
45
+ const checkedAt = typeof raw.checkedAt === "number" && Number.isFinite(raw.checkedAt) ? raw.checkedAt : 0;
46
+ if (!parseVersion(latest) || checkedAt <= 0) return undefined;
47
+ return { latest, checkedAt };
48
+ } catch {
49
+ return undefined;
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Write the cache atomically (a sibling temp file, then a rename). The caller
55
+ * treats a throw as a miss: a read-only agent directory must not break a session.
56
+ */
57
+ export function writeUpdateCache(path: string, entry: UpdateCacheEntry): void {
58
+ writeConfigFile(path, { latest: entry.latest, checkedAt: entry.checkedAt });
59
+ }
60
+
61
+ /**
62
+ * Whether an entry is still inside the window. The boundary is inclusive: an
63
+ * entry read exactly `ttlMs` ago is reused, so the window never flickers.
64
+ */
65
+ export function isFresh(
66
+ entry: UpdateCacheEntry | undefined,
67
+ now: number,
68
+ ttlMs: number = UPDATE_CACHE_TTL_MS,
69
+ ): boolean {
70
+ if (!entry) return false;
71
+ const age = now - entry.checkedAt;
72
+ return age >= 0 && age <= ttlMs;
73
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * The one registry read the update check makes.
3
+ *
4
+ * A fixed, unauthenticated GET of the published `latest` document for this
5
+ * package. It is deliberately not `web-access`'s page runner: there are no
6
+ * redirects to follow, no HTML to extract, and no cache shared with a tool, and
7
+ * extensions may not import one another. Every failure mode — a non-ok status, a
8
+ * body that is not JSON, a payload without a real version, a network error, an
9
+ * abort — resolves to `undefined`, so the caller has one branch and the session
10
+ * never sees an error from a check nobody asked for.
11
+ */
12
+
13
+ import { parseVersion } from "./version.ts";
14
+
15
+ /** The published document holding the version npm's `latest` tag points at. */
16
+ export const LATEST_VERSION_URL = "https://registry.npmjs.org/@gavin-hu%2Fmy-pi-agent/latest";
17
+
18
+ /** How long the request may take before it is abandoned. */
19
+ export const UPDATE_TIMEOUT_MS = 5_000;
20
+
21
+ /** The subset of a `fetch` response this check reads. */
22
+ export interface LatestResponse {
23
+ readonly ok: boolean;
24
+ json(): Promise<unknown>;
25
+ }
26
+
27
+ /** The fetch seam: narrow enough that a test needs no real response object. */
28
+ export type FetchLike = (
29
+ url: string,
30
+ init: { signal: AbortSignal; headers: Record<string, string> },
31
+ ) => Promise<LatestResponse>;
32
+
33
+ export interface UpdateCheckDeps {
34
+ /** Replace the network call; defaults to the platform `fetch`. */
35
+ fetch?: FetchLike;
36
+ /** Override {@link UPDATE_TIMEOUT_MS}. */
37
+ timeoutMs?: number;
38
+ }
39
+
40
+ /** The latest published version, or `undefined` when it cannot be read. */
41
+ export async function fetchLatestVersion(deps: UpdateCheckDeps = {}): Promise<string | undefined> {
42
+ const request: FetchLike = deps.fetch ?? ((url, init) => globalThis.fetch(url, init));
43
+ const controller = new AbortController();
44
+ const timer = setTimeout(() => controller.abort(), deps.timeoutMs ?? UPDATE_TIMEOUT_MS);
45
+ try {
46
+ const response = await request(LATEST_VERSION_URL, {
47
+ signal: controller.signal,
48
+ headers: { accept: "application/json" },
49
+ });
50
+ if (!response.ok) return undefined;
51
+ const body = (await response.json()) as { version?: unknown } | undefined;
52
+ const version = typeof body?.version === "string" ? body.version.trim() : "";
53
+ return parseVersion(version) ? version : undefined;
54
+ } catch {
55
+ return undefined;
56
+ } finally {
57
+ clearTimeout(timer);
58
+ }
59
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Wording for the update check: the header marker, the one transcript notice, and
3
+ * the answer an explicit re-check prints.
4
+ *
5
+ * All three read the same two facts — the version this session loaded and the
6
+ * version the registry publishes — and none adds a third. The marker is the
7
+ * shortest honest form (`↑0.12.0`); the notice names the command and the release
8
+ * URL as text, because the notice renderer has no link support; and the re-check
9
+ * says what it just found, including the two cases it cannot answer (offline, and
10
+ * a registry it could not read).
11
+ */
12
+
13
+ import pkg from "../../../package.json";
14
+ import { noticeMessage, type NoticeMessage } from "../../../lib/notice.ts";
15
+ import { GLYPHS } from "../../../lib/tui/glyphs.ts";
16
+ import type { UpdateOutcome } from "./state.ts";
17
+
18
+ /** The header marker: an up arrow and the newest version, e.g. `↑0.12.0`. */
19
+ export function updateMarker(latest: string): string {
20
+ return `${GLYPHS.update}${latest}`;
21
+ }
22
+
23
+ /**
24
+ * The release page for a version, or `undefined` when this checkout does not name
25
+ * a GitHub repository. Derived from `package.json` so a rename cannot leave a dead
26
+ * link behind in the notice.
27
+ */
28
+ export function releaseUrl(version: string): string | undefined {
29
+ const url = (pkg.repository as { url?: string } | undefined)?.url ?? "";
30
+ const base = url.replace(/\.git$/, "");
31
+ if (!/^https:\/\/github\.com\/[^/]+\/[^/]+$/.test(base)) return undefined;
32
+ return `${base}/releases/tag/v${version}`;
33
+ }
34
+
35
+ /** The single notice a session posts when a newer version is published. */
36
+ export function updateNotice(current: string, latest: string): NoticeMessage {
37
+ const release = releaseUrl(latest);
38
+ const command = "pi update --extensions";
39
+ return noticeMessage({
40
+ content:
41
+ `bytefly ${latest} is available (current ${current}). Run \`${command}\` to update.` +
42
+ (release ? ` Release notes: ${release}` : ""),
43
+ details: {
44
+ source: "bytefly",
45
+ title: `${latest} is available`,
46
+ severity: "info",
47
+ items: release
48
+ ? [
49
+ { label: "current", detail: `v${current}` },
50
+ { label: "update", detail: command },
51
+ { label: "release", detail: release },
52
+ ]
53
+ : [
54
+ { label: "current", detail: `v${current}` },
55
+ { label: "update", detail: command },
56
+ ],
57
+ },
58
+ });
59
+ }
60
+
61
+ /**
62
+ * What `/bytefly update` prints. The background check is silent because nobody
63
+ * asked; here someone did, so a skip and a failed read are answers too.
64
+ */
65
+ export function updateOutcomeText(
66
+ outcome: UpdateOutcome,
67
+ current: string,
68
+ ): { text: string; severity: "info" | "warning" } {
69
+ switch (outcome.kind) {
70
+ case "offline":
71
+ return { text: "bytefly: the update check was skipped (PI_OFFLINE is set).", severity: "info" };
72
+ case "failed":
73
+ return {
74
+ text: "bytefly: could not read the published version — check the network and try again.",
75
+ severity: "warning",
76
+ };
77
+ case "current":
78
+ return { text: `bytefly: up to date — v${outcome.latest} is the newest published version.`, severity: "info" };
79
+ case "available": {
80
+ const release = releaseUrl(outcome.latest);
81
+ return {
82
+ text: `bytefly: v${outcome.latest} is available (current ${current}).${release ? ` ${release}` : ""}`,
83
+ severity: "info",
84
+ };
85
+ }
86
+ }
87
+ }
@@ -0,0 +1,158 @@
1
+ /**
2
+ * The update check's lifecycle: reuse the cached answer or read the registry once
3
+ * per interactive session, record a newer version, and announce it once per load.
4
+ *
5
+ * The request is fired without awaiting. Pi awaits `session_start` handlers, so
6
+ * a slow registry would otherwise hold the first prompt; Pi's own version check
7
+ * is asynchronous for the same reason. Every result that is not a strictly newer
8
+ * version is silent, and a generation counter keeps a request that resolves
9
+ * after shutdown from writing into the next session's state.
10
+ *
11
+ * A cached answer with a timestamp is what makes a long-lived session different
12
+ * from a fresh one: a session that starts inside the window reuses the last read
13
+ * instead of repeating it, and `recheck()` ignores the window so `/bytefly update`
14
+ * can look again without a restart. `/bytefly` and `doctor` do not report the
15
+ * version: an available update is not a prerequisite problem.
16
+ */
17
+
18
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
19
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
20
+ import pkg from "../../../package.json";
21
+ import { isOffline } from "../../../lib/env.ts";
22
+ import { isFresh, readUpdateCache, updateCachePath, writeUpdateCache, type UpdateCacheEntry } from "./cache.ts";
23
+ import { fetchLatestVersion } from "./check.ts";
24
+ import { updateNotice } from "./format.ts";
25
+ import type { UpdateOutcome, UpdateSource } from "./state.ts";
26
+ import { isNewerVersion } from "./version.ts";
27
+
28
+ /** The cache, as the lifecycle sees it: read on start, written after a read. */
29
+ export interface UpdateCache {
30
+ /** The last recorded answer, or `undefined` for a miss. */
31
+ read(): UpdateCacheEntry | undefined;
32
+ /** Record an answer; a throw is treated as a miss. */
33
+ write(entry: UpdateCacheEntry): void;
34
+ }
35
+
36
+ /** What `registerUpdate` returns, so the panel can re-run the check on demand. */
37
+ export interface UpdateHandle {
38
+ /** Read the registry now, ignoring the cache window. Never throws. */
39
+ recheck(): Promise<UpdateOutcome>;
40
+ }
41
+
42
+ /** Test seams; production uses the registry read, `PI_OFFLINE`, and the agent dir. */
43
+ export interface UpdateDeps {
44
+ /** Replace the registry read; a rejection counts as no answer. */
45
+ fetchLatest?: () => Promise<string | undefined>;
46
+ /** Replace the `PI_OFFLINE` check. */
47
+ isOffline?: () => boolean;
48
+ /** Replace the disk cache. */
49
+ cache?: UpdateCache;
50
+ /** Replace the clock the cache timestamp is read from. */
51
+ now?: () => number;
52
+ /** Called once a newer version is recorded, so a mounted surface repaints. */
53
+ onUpdate?: () => void;
54
+ }
55
+
56
+ /** The on-disk cache under the agent directory, built lazily so it is per call. */
57
+ function diskCache(): UpdateCache {
58
+ return {
59
+ read: () => readUpdateCache(updateCachePath(getAgentDir())),
60
+ write: (entry) => {
61
+ try {
62
+ writeUpdateCache(updateCachePath(getAgentDir()), entry);
63
+ } catch {
64
+ // A read-only agent directory costs the next session one request, nothing more.
65
+ }
66
+ },
67
+ };
68
+ }
69
+
70
+ /**
71
+ * Register the check. `updates` is the same store the header reads, so recording
72
+ * a version and calling `onUpdate` is what puts the marker on screen.
73
+ */
74
+ export function registerUpdate(pi: ExtensionAPI, updates: UpdateSource, deps: UpdateDeps = {}): UpdateHandle {
75
+ const readLatest = deps.fetchLatest ?? (() => fetchLatestVersion());
76
+ const offline = deps.isOffline ?? isOffline;
77
+ const cache = deps.cache ?? diskCache();
78
+ const now = deps.now ?? Date.now;
79
+ // A session is announced at most once per load, even if `session_start` fires
80
+ // again after a resume or a session replacement.
81
+ let announced = false;
82
+ let generation = 0;
83
+
84
+ /** Record a newer version and repaint the marker. */
85
+ const paint = (latest: string): void => {
86
+ if (!isNewerVersion(latest, pkg.version)) return;
87
+ updates.set(latest);
88
+ deps.onUpdate?.();
89
+ };
90
+
91
+ /** The once-per-load transcript notice, posted only for a strictly newer version. */
92
+ const announce = (latest: string): void => {
93
+ if (announced || !isNewerVersion(latest, pkg.version)) return;
94
+ announced = true;
95
+ pi.sendMessage(updateNotice(pkg.version, latest));
96
+ };
97
+
98
+ /** One answer, from the cache or the registry: paint the marker, announce it once. */
99
+ const record = (entry: UpdateCacheEntry): void => {
100
+ paint(entry.latest);
101
+ announce(entry.latest);
102
+ };
103
+
104
+ const check = async (token: number): Promise<void> => {
105
+ try {
106
+ const latest = await readLatest();
107
+ if (latest === undefined || token !== generation) return;
108
+ const entry = { latest, checkedAt: now() };
109
+ cache.write(entry);
110
+ record(entry);
111
+ } catch {
112
+ // A background check never surfaces an error: the session does not know it ran.
113
+ }
114
+ };
115
+
116
+ pi.on("session_start", (_event, ctx) => {
117
+ // The marker lives in bytefly's header and the notice in a transcript, so a
118
+ // non-interactive run has nothing to draw and makes no request.
119
+ if (ctx.mode !== "tui") return;
120
+ if (offline()) {
121
+ // No request, so the window does not matter: the cached answer is the best
122
+ // one available, and the marker is advisory.
123
+ const cached = cache.read();
124
+ if (cached) record(cached);
125
+ return;
126
+ }
127
+ const cached = cache.read();
128
+ if (cached && isFresh(cached, now())) {
129
+ record(cached);
130
+ return;
131
+ }
132
+ if (announced) return;
133
+ void check(generation);
134
+ });
135
+
136
+ pi.on("session_shutdown", () => {
137
+ // Invalidate any request still in flight and drop what this session found.
138
+ generation++;
139
+ updates.set();
140
+ });
141
+
142
+ return {
143
+ async recheck() {
144
+ if (offline()) return { kind: "offline" };
145
+ let latest: string | undefined;
146
+ try {
147
+ latest = await readLatest();
148
+ } catch {
149
+ return { kind: "failed" };
150
+ }
151
+ if (latest === undefined) return { kind: "failed" };
152
+ cache.write({ latest, checkedAt: now() });
153
+ // The caller prints the answer, so this path never posts a notice.
154
+ paint(latest);
155
+ return isNewerVersion(latest, pkg.version) ? { kind: "available", latest } : { kind: "current", latest };
156
+ },
157
+ };
158
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Where a surface reads the newest published version this load found, and what an
3
+ * explicit re-check reported.
4
+ *
5
+ * The check resolves after the session started, so the header cannot be handed
6
+ * the value up front: it reads through this store on every apply, and the
7
+ * update registration re-installs the header once the value arrives. The store
8
+ * is created once per load by the composition root — no module state — and
9
+ * cleared on shutdown, so a reload starts from nothing.
10
+ */
11
+
12
+ /** The store the header reads and the update lifecycle writes. */
13
+ export interface UpdateSource {
14
+ /** The newer published version, or `undefined` when none is known. */
15
+ latest(): string | undefined;
16
+ /** Record a version, or clear it with no argument. */
17
+ set(latest?: string): void;
18
+ }
19
+
20
+ /**
21
+ * What one explicit re-check found. The background check reports nothing — nobody
22
+ * asked — so this vocabulary exists only for `/bytefly update`, where a failure or
23
+ * a skip is an answer rather than silence.
24
+ */
25
+ export type UpdateOutcome =
26
+ | { kind: "offline" }
27
+ | { kind: "failed" }
28
+ | { kind: "current"; latest: string }
29
+ | { kind: "available"; latest: string };
30
+
31
+ /** A store holding at most one version. */
32
+ export function createUpdateState(): UpdateSource {
33
+ let latest: string | undefined;
34
+ return {
35
+ latest: () => latest,
36
+ set(next) {
37
+ latest = next || undefined;
38
+ },
39
+ };
40
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Version comparison for the update check.
3
+ *
4
+ * The registry answers with whatever string the published manifest carries, so
5
+ * both sides of the comparison are untrusted input: a payload that does not
6
+ * parse is never an upgrade. Only the parts that decide ordering are read — the
7
+ * numeric core and the prerelease identifiers — and build metadata is ignored,
8
+ * as semver says it must be for precedence.
9
+ */
10
+
11
+ /** A version split into the parts that decide precedence. */
12
+ export interface ParsedVersion {
13
+ /** Major, minor, and patch; a missing segment is 0. */
14
+ readonly core: readonly [number, number, number];
15
+ /** Prerelease identifiers, empty for a release version. */
16
+ readonly pre: readonly string[];
17
+ }
18
+
19
+ /** `[v]X[.Y[.Z]][-pre][+build]`, with no leading zeros left to interpretation. */
20
+ const VERSION_SHAPE = /^v?(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/;
21
+
22
+ /** Whether a prerelease identifier is a dot-separated, non-empty semver identifier run. */
23
+ function isPrerelease(value: string): boolean {
24
+ return value.split(".").every((identifier) => identifier.length > 0 && /^[0-9A-Za-z-]+$/.test(identifier));
25
+ }
26
+
27
+ /** Parse a version string, or `undefined` when it is not one. */
28
+ export function parseVersion(value: string): ParsedVersion | undefined {
29
+ const match = VERSION_SHAPE.exec(value.trim());
30
+ if (!match) return undefined;
31
+ const prerelease = match[4];
32
+ if (prerelease !== undefined && !isPrerelease(prerelease)) return undefined;
33
+ return {
34
+ core: [Number(match[1]), Number(match[2] ?? 0), Number(match[3] ?? 0)],
35
+ pre: prerelease === undefined ? [] : prerelease.split("."),
36
+ };
37
+ }
38
+
39
+ /** `a`'s precedence relative to `b`: negative, zero, or positive. */
40
+ function compareCore(a: ParsedVersion, b: ParsedVersion): number {
41
+ for (const index of [0, 1, 2] as const) {
42
+ const difference = a.core[index] - b.core[index];
43
+ if (difference !== 0) return difference;
44
+ }
45
+ return 0;
46
+ }
47
+
48
+ /**
49
+ * Prerelease precedence, per semver: identifiers are compared left to right,
50
+ * numeric ones numerically and below alphanumeric ones, a longer set wins over
51
+ * its own prefix, and any prerelease sits below the release it leads to.
52
+ */
53
+ function comparePre(a: ParsedVersion, b: ParsedVersion): number {
54
+ if (a.pre.length === 0 || b.pre.length === 0) {
55
+ return b.pre.length - a.pre.length;
56
+ }
57
+ for (let index = 0; index < Math.max(a.pre.length, b.pre.length); index++) {
58
+ const left = a.pre[index];
59
+ const right = b.pre[index];
60
+ if (left === undefined) return -1;
61
+ if (right === undefined) return 1;
62
+ const leftNumeric = /^\d+$/.test(left);
63
+ const rightNumeric = /^\d+$/.test(right);
64
+ if (leftNumeric && rightNumeric) {
65
+ const difference = Number(left) - Number(right);
66
+ if (difference !== 0) return difference;
67
+ continue;
68
+ }
69
+ if (leftNumeric) return -1;
70
+ if (rightNumeric) return 1;
71
+ if (left !== right) return left < right ? -1 : 1;
72
+ }
73
+ return 0;
74
+ }
75
+
76
+ /** Whether `candidate` is a strictly newer version than `current`; false when either does not parse. */
77
+ export function isNewerVersion(candidate: string, current: string): boolean {
78
+ const next = parseVersion(candidate);
79
+ const loaded = parseVersion(current);
80
+ if (!next || !loaded) return false;
81
+ const core = compareCore(next, loaded);
82
+ return core !== 0 ? core > 0 : comparePre(next, loaded) > 0;
83
+ }