@gavin-hu/my-pi-agent 0.11.3 → 0.12.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 (86) hide show
  1. package/README.md +13 -8
  2. package/README.zh-CN.md +5 -3
  3. package/assets/chrome.png +0 -0
  4. package/assets/plan-review.png +0 -0
  5. package/extensions/bytefly/README.md +291 -207
  6. package/extensions/bytefly/doctor/runtime.ts +16 -0
  7. package/extensions/bytefly/doctor/tools.ts +1 -3
  8. package/extensions/bytefly/footer/price/config.ts +54 -15
  9. package/extensions/bytefly/footer/price/price.ts +20 -7
  10. package/extensions/bytefly/footer/price/types.ts +13 -7
  11. package/extensions/bytefly/footer/render/format.ts +3 -2
  12. package/extensions/bytefly/footer/render/segments.ts +3 -2
  13. package/extensions/bytefly/footer/types.ts +1 -0
  14. package/extensions/bytefly/header/component.ts +21 -6
  15. package/extensions/bytefly/header/config.ts +3 -0
  16. package/extensions/bytefly/header/register.ts +34 -3
  17. package/extensions/bytefly/index.ts +13 -3
  18. package/extensions/bytefly/panel/settings-runtime.ts +71 -28
  19. package/extensions/bytefly/panel/settings.ts +201 -38
  20. package/extensions/bytefly/panel/tui/entries.ts +58 -0
  21. package/extensions/bytefly/panel/tui/extensions-view.ts +86 -0
  22. package/extensions/bytefly/panel/tui/frame.ts +71 -0
  23. package/extensions/bytefly/panel/tui/nav.ts +75 -0
  24. package/extensions/bytefly/panel/tui/settings-view.ts +115 -0
  25. package/extensions/bytefly/panel/tui.ts +205 -319
  26. package/extensions/bytefly/picker/runtime.ts +61 -2
  27. package/extensions/bytefly/picker/toggle.ts +56 -0
  28. package/extensions/bytefly/update/check.ts +59 -0
  29. package/extensions/bytefly/update/format.ts +32 -0
  30. package/extensions/bytefly/update/register.ts +72 -0
  31. package/extensions/bytefly/update/state.ts +28 -0
  32. package/extensions/bytefly/update/version.ts +83 -0
  33. package/extensions/document/README.md +2 -2
  34. package/extensions/document/tool.ts +1 -3
  35. package/extensions/file-open/README.md +30 -15
  36. package/extensions/file-open/index.ts +4 -3
  37. package/extensions/file-open/open.ts +52 -21
  38. package/extensions/goal/README.md +39 -13
  39. package/extensions/goal/clarify-prompt.ts +5 -36
  40. package/extensions/goal/clarify.ts +55 -86
  41. package/extensions/goal/commands.ts +41 -35
  42. package/extensions/goal/format.ts +12 -4
  43. package/extensions/goal/tools.ts +28 -8
  44. package/extensions/goal/tui.ts +1 -1
  45. package/extensions/memory/README.md +1 -1
  46. package/extensions/plan/README.md +24 -8
  47. package/extensions/plan/blocks.ts +1 -35
  48. package/extensions/plan/commands.ts +18 -12
  49. package/extensions/plan/plans.ts +1 -1
  50. package/extensions/plan/policy.ts +4 -3
  51. package/extensions/plan/review.ts +243 -0
  52. package/extensions/plan/runtime.ts +4 -0
  53. package/extensions/plan/steps.ts +34 -23
  54. package/extensions/plan/todos.ts +5 -58
  55. package/extensions/plan/tools.ts +66 -222
  56. package/extensions/plan/tui.ts +17 -9
  57. package/extensions/plan/types.ts +3 -0
  58. package/extensions/plan/web-client.ts +51 -18
  59. package/extensions/plan/web-page.ts +5 -0
  60. package/extensions/plan/web-router.ts +11 -3
  61. package/extensions/plan/web.ts +100 -36
  62. package/extensions/question/README.md +1 -1
  63. package/extensions/question/tools.ts +1 -1
  64. package/extensions/subagent/index.ts +0 -2
  65. package/extensions/todo/README.md +19 -11
  66. package/extensions/todo/index.ts +5 -3
  67. package/extensions/todo/nudge.ts +58 -9
  68. package/extensions/todo/runtime.ts +28 -17
  69. package/extensions/web-access/README.md +3 -3
  70. package/extensions/web-access/fetch/tool.ts +1 -3
  71. package/extensions/web-access/search/tool.ts +1 -3
  72. package/extensions/wechat/README.md +22 -11
  73. package/extensions/wechat/bridge.ts +90 -22
  74. package/extensions/wechat/commands.ts +45 -1
  75. package/extensions/wechat/doctor.ts +1 -0
  76. package/extensions/wechat/index.ts +3 -1
  77. package/extensions/wechat/status-screen.ts +32 -2
  78. package/extensions/wechat/tools.ts +0 -2
  79. package/lib/README.md +2 -1
  80. package/lib/config.ts +43 -1
  81. package/lib/env.ts +12 -0
  82. package/lib/requirements.ts +76 -2
  83. package/lib/todo-state.ts +62 -0
  84. package/lib/tui/glyphs.ts +1 -0
  85. package/package.json +1 -1
  86. package/assets/bytefly.schema.json +0 -785
@@ -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,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,32 @@
1
+ /**
2
+ * Wording for the update check: the header marker and the one transcript notice.
3
+ *
4
+ * Both read the same two facts — the version this session loaded and the version
5
+ * the registry publishes — and neither adds a third. The marker is the shortest
6
+ * honest form (`↑0.12.0`), and the notice names the command instead of printing
7
+ * a changelog link the notice renderer cannot open.
8
+ */
9
+
10
+ import { noticeMessage, type NoticeMessage } from "../../../lib/notice.ts";
11
+ import { GLYPHS } from "../../../lib/tui/glyphs.ts";
12
+
13
+ /** The header marker: an up arrow and the newest version, e.g. `↑0.12.0`. */
14
+ export function updateMarker(latest: string): string {
15
+ return `${GLYPHS.update}${latest}`;
16
+ }
17
+
18
+ /** The single notice a session posts when a newer version is published. */
19
+ export function updateNotice(current: string, latest: string): NoticeMessage {
20
+ return noticeMessage({
21
+ content: `bytefly ${latest} is available (current ${current}). Run \`pi update --extensions\` to update.`,
22
+ details: {
23
+ source: "bytefly",
24
+ title: `${latest} is available`,
25
+ severity: "info",
26
+ items: [
27
+ { label: "current", detail: `v${current}` },
28
+ { label: "update", detail: "pi update --extensions" },
29
+ ],
30
+ },
31
+ });
32
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * The update check's lifecycle: read the published version once per interactive
3
+ * session, record it when it is newer, 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
+ * `/bytefly` and `doctor` do not report this: an available update is not a
12
+ * prerequisite problem, and the header plus one notice are the whole surface.
13
+ */
14
+
15
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
16
+ import pkg from "../../../package.json";
17
+ import { isOffline } from "../../../lib/env.ts";
18
+ import { fetchLatestVersion } from "./check.ts";
19
+ import { updateNotice } from "./format.ts";
20
+ import type { UpdateSource } from "./state.ts";
21
+ import { isNewerVersion } from "./version.ts";
22
+
23
+ /** Test seams; production uses the registry read and `PI_OFFLINE`. */
24
+ export interface UpdateDeps {
25
+ /** Replace the registry read; a rejection counts as no answer. */
26
+ fetchLatest?: () => Promise<string | undefined>;
27
+ /** Replace the `PI_OFFLINE` check. */
28
+ isOffline?: () => boolean;
29
+ /** Called once a newer version is recorded, so a mounted surface repaints. */
30
+ onUpdate?: () => void;
31
+ }
32
+
33
+ /**
34
+ * Register the check. `updates` is the same store the header reads, so recording
35
+ * a version and calling `onUpdate` is what puts the marker on screen.
36
+ */
37
+ export function registerUpdate(pi: ExtensionAPI, updates: UpdateSource, deps: UpdateDeps = {}): void {
38
+ const readLatest = deps.fetchLatest ?? (() => fetchLatestVersion());
39
+ const offline = deps.isOffline ?? isOffline;
40
+ // A session is announced at most once per load, even if `session_start` fires
41
+ // again after a resume or a session replacement.
42
+ let announced = false;
43
+ let generation = 0;
44
+
45
+ const check = async (token: number): Promise<void> => {
46
+ try {
47
+ const latest = await readLatest();
48
+ if (latest === undefined || token !== generation) return;
49
+ if (announced || !isNewerVersion(latest, pkg.version)) return;
50
+ announced = true;
51
+ updates.set(latest);
52
+ deps.onUpdate?.();
53
+ pi.sendMessage(updateNotice(pkg.version, latest));
54
+ } catch {
55
+ // A background check never surfaces an error: the session does not know it ran.
56
+ }
57
+ };
58
+
59
+ pi.on("session_start", (_event, ctx) => {
60
+ // The marker lives in bytefly's header and the notice in a transcript, so a
61
+ // non-interactive run has nothing to draw and makes no request.
62
+ if (ctx.mode !== "tui") return;
63
+ if (announced || offline()) return;
64
+ void check(generation);
65
+ });
66
+
67
+ pi.on("session_shutdown", () => {
68
+ // Invalidate any request still in flight and drop what this session found.
69
+ generation++;
70
+ updates.set();
71
+ });
72
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Where a surface reads the newest published version this load found.
3
+ *
4
+ * The check resolves after the session started, so the header cannot be handed
5
+ * the value up front: it reads through this store on every apply, and the
6
+ * update registration re-installs the header once the value arrives. The store
7
+ * is created once per load by the composition root — no module state — and
8
+ * cleared on shutdown, so a reload starts from nothing.
9
+ */
10
+
11
+ /** The store the header reads and the update lifecycle writes. */
12
+ export interface UpdateSource {
13
+ /** The newer published version, or `undefined` when none is known. */
14
+ latest(): string | undefined;
15
+ /** Record a version, or clear it with no argument. */
16
+ set(latest?: string): void;
17
+ }
18
+
19
+ /** A store holding at most one version. */
20
+ export function createUpdateState(): UpdateSource {
21
+ let latest: string | undefined;
22
+ return {
23
+ latest: () => latest,
24
+ set(next) {
25
+ latest = next || undefined;
26
+ },
27
+ };
28
+ }
@@ -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
+ }
@@ -44,7 +44,7 @@ them along. Then ask the model to read a document, or call the tool directly:
44
44
 
45
45
  ## Tool
46
46
 
47
- `read_doc` — `exposure: "direct"`, active by default, read-only, closed-world
47
+ `read_doc` — direct exposure, active by default, read-only, closed-world
48
48
  (no network).
49
49
 
50
50
  | Parameter | Type | Meaning |
@@ -226,7 +226,7 @@ config defaults, and error listing all derive from it.
226
226
  | Aspect | Contract |
227
227
  |---|---|
228
228
  | Registration | `document(pi)` in `index.ts` calls `registerDocTool(pi)`, which registers `read_doc`. The factory only registers; no parser loads until a read needs it. |
229
- | Tool | `read_doc`, `exposure: "direct"`, `defaultActive: true`, default (`parallel`) execution, `annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false }`, TypeBox `parameters` and `outputSchema`, and `renderCall`/`renderResult` (reuse `context.lastComponent`; read `context.cwd` and `context.isError`). |
229
+ | Tool | `read_doc`, direct exposure and active on registration (the defaults), default (`parallel`) execution, `annotations: { readOnlyHint: true, openWorldHint: false }`, TypeBox `parameters` and `outputSchema`, and `renderCall`/`renderResult` (reuse `context.lastComponent`; read `context.cwd` and `context.isError`). |
230
230
  | State | None persisted. Per-result state lives in the tool-result `details` / `structuredContent`; the renderer reads only that. |
231
231
  | Lifecycle | No `session_start` / `session_shutdown` hooks: each read imports a parser, runs it, and holds no long-lived resources. |
232
232
  | Host seams | `resolveEffectiveCwd` (`lib/env.ts`), `isInsideReal` / `realPathOfNearest` (`lib/path.ts`), `loadConfigSection` (`lib/config.ts`), `stripControlChars` / `sanitize` / `formatTokens` (`lib/format.ts`), `SEPARATORS` (`lib/tui/glyphs.ts`). |
@@ -79,9 +79,7 @@ export function registerDocTool(pi: ExtensionAPI, deps: DocToolDeps = {}): void
79
79
  ],
80
80
  parameters: DocParams,
81
81
  outputSchema: DocOutput,
82
- exposure: "direct",
83
- defaultActive: true,
84
- annotations: { readOnlyHint: true, openWorldHint: false, destructiveHint: false },
82
+ annotations: { readOnlyHint: true, openWorldHint: false },
85
83
 
86
84
  async execute(_toolCallId, params, signal, onUpdate, ctx) {
87
85
  const args = params as DocArgs;
@@ -28,12 +28,14 @@ pi install ./ # install the package for a user
28
28
  ## What it does
29
29
 
30
30
  - Opens a file or directory with the OS-native application: `open` on macOS,
31
- `cmd /c start` on Windows, `xdg-open` on Linux.
31
+ `explorer` on Windows, `xdg-open` on Linux.
32
32
  - Reveals it in the file manager instead with `-r` (or `reveal: true`): `open -R`
33
33
  on macOS, `explorer /select,` on Windows, and the containing directory on
34
34
  Linux, which has no "select" verb.
35
35
  - Unwraps the editor's `@` file-mention prefix and surrounding quotes, so
36
36
  `/open @src/app.ts` and `/open @"my file.txt"` both work.
37
+ - Reports a launcher that cannot be started, and (on macOS and Linux) a launcher
38
+ that exits non-zero because no handler accepted the target.
37
39
  - Resolves relative paths against the effective working directory, so under the
38
40
  `worktree` extension `/open .` opens the isolated worktree.
39
41
  - Launches the handler detached, so it outlives the Pi session.
@@ -47,14 +49,17 @@ pi install ./ # install the package for a user
47
49
 
48
50
  The `-r` flag must come first. A missing path is reported through a notification
49
51
  and nothing is launched. An empty argument means the working directory. The
50
- command needs an interactive terminal UI.
52
+ command first reports `Opening <path>` (or `Revealing <path>`); a launcher that
53
+ starts but then fails adds a `Could not open` error notification. The command
54
+ needs an interactive terminal UI.
51
55
 
52
56
  ## Tool
53
57
 
54
58
  `open_path` — `exposure: "codemode"`, inactive by default, not read-only,
55
- open-world (`openWorldHint: true` because it starts an OS process). It is
56
- listed by the `codemode` tool and reachable through `ctx.executeTool`; it is not
57
- declared to the model unless activated.
59
+ open-world (`openWorldHint: true` because it starts an OS process). It is listed
60
+ by the `codemode` tool for a script to call as `tools.open_path(...)`, and an
61
+ extension can reach it through `ctx.executeTool`; it is not declared to the
62
+ model unless activated.
58
63
 
59
64
  | Parameter | Type | Meaning |
60
65
  |---|---|---|
@@ -68,10 +73,11 @@ Result (`details` / `structuredContent`):
68
73
  | `target` | The absolute path that was handled. |
69
74
  | `reveal` | Whether it was revealed rather than opened. |
70
75
 
71
- The model-facing `content` is `Opened <target>` or `Revealed <target>`. A path
72
- that does not exist is thrown as an error naming the resolved path; a launch
73
- failure is reported through `ctx.ui.notify` because the handler runs detached,
74
- and the tool result still names the target.
76
+ The model-facing `content` is `Opened <target>` or `Revealed <target>` — the
77
+ handler was started, not proof that it succeeded. A path that does not exist is
78
+ thrown as an error naming the resolved path; a launcher that cannot be started,
79
+ or (on macOS and Linux) exits non-zero, is reported through `ctx.ui.notify`
80
+ because the handler runs detached, and the tool result still names the target.
75
81
 
76
82
  ## Behaviour by mode
77
83
 
@@ -94,12 +100,18 @@ JSON, and print modes; a codemode script can call it in any of them. Only the
94
100
  - **Not confined.** Unlike `read_doc`, the tool opens any path on the machine:
95
101
  confinement would defeat its purpose of showing the user a file wherever it
96
102
  lives. Relative paths resolve against the effective worktree root.
97
- - **Best-effort launch.** A missing handler or a refused launch is reported, not
98
- retried.
103
+ - **Best-effort launch.** A launcher that cannot be started is reported, and so
104
+ is a non-zero exit on macOS and Linux, where `open` and `xdg-open` return a
105
+ failure status when no handler accepts the target. Windows `explorer` can exit
106
+ non-zero after a successful open, so only a failure to start is reported
107
+ there. Nothing is retried.
99
108
 
100
109
  ## Limitations
101
110
 
102
111
  - Reveal has no native meaning on Linux, so it opens the containing directory.
112
+ - On Windows a refused target is not reported: `explorer` does not relay the
113
+ failure and its exit status is unreliable, so `/open` and `open_path` can only
114
+ report that the launcher started.
103
115
  - A launch that the OS accepts but the user never sees (a backgrounded app, a
104
116
  hidden window) is not detected: `spawn` reports only that the process started.
105
117
  - `-r` is parsed only as the first token of `/open`, so a path that genuinely
@@ -115,7 +127,7 @@ JSON, and print modes; a codemode script can call it in any of them. Only the
115
127
  | Command | `/open [path]` and `/open -r [path]`; reports through `ctx.ui.notify`. |
116
128
  | State | None persisted, and none needed: every call is independent. |
117
129
  | Lifecycle | No `session_start` / `session_shutdown` hooks; the factory holds no long-lived resources. |
118
- | Host seams | `resolveEffectiveCwd` and `isExtensionEnabled` (`lib/env.ts`), `stripControlChars` (`lib/format.ts`), `spawn` / `open` / `xdg-open` / `cmd` / `explorer` (via the injected launcher in `open.ts`). |
130
+ | Host seams | `resolveEffectiveCwd` and `isExtensionEnabled` (`lib/env.ts`), `stripControlChars` (`lib/format.ts`), `spawn` / `open` / `xdg-open` / `explorer` (via the injected launcher in `open.ts`). |
119
131
  | Config | None. |
120
132
 
121
133
  ## Design notes
@@ -123,8 +135,11 @@ JSON, and print modes; a codemode script can call it in any of them. Only the
123
135
  `buildCommand` and `normalizeTarget` are pure and `openTarget` takes an injected
124
136
  launcher, so the command for each platform is asserted without a process and a
125
137
  test launches nothing. The launch is best-effort: `spawn` reports a missing
126
- handler asynchronously, so the failure goes to `onError` (and a notification)
127
- rather than being thrown from a synchronous call the tool could catch.
138
+ launcher asynchronously and the child reports its exit, so both failures go to
139
+ `onError` (and a notification) rather than being thrown from a synchronous call
140
+ the tool could catch. `exitMeansFailure` opts Windows out because `explorer` can
141
+ exit non-zero after a successful open. Windows opens through `explorer` rather
142
+ than `cmd /c start` so cmd cannot re-parse a path containing `%`.
128
143
 
129
144
  The tool is `codemode`-exposed, matching how it was configured before the move:
130
145
  opening a native app is a side effect the user should trigger, so it stays out
@@ -135,7 +150,7 @@ of the model's active tool set unless a script explicitly reaches for it.
135
150
  | Module | Responsibility |
136
151
  |---|---|
137
152
  | `index.ts` | The extension factory: registers `open_path` and the `/open` command, resolves the target, and routes a launch failure to `ctx.ui.notify`. |
138
- | `open.ts` | `normalizeTarget` and `buildCommand` (pure), `resolveTarget` (existence check), and `openTarget` with the injectable launcher. |
153
+ | `open.ts` | `normalizeTarget` and `buildCommand` (pure), `resolveTarget` (existence check), and `openTarget` with the injectable launcher, which reports a start failure and a meaningful non-zero exit. |
139
154
  | `schema.ts` | `TOOL_NAME`, the TypeBox `parameters` / `outputSchema`, and the `OpenPathArgs` / `OpenPathResult` types. |
140
155
  | `open.test.ts` | Pure-module tests for normalization, resolution, per-platform commands, and the launcher seam. |
141
156
  | `extension.test.ts` | Extension-surface tests: registration, disabling, the tool call, and the command. |
@@ -25,8 +25,9 @@ export default function fileOpen(pi: ExtensionAPI, deps: FileOpenDeps = {}): voi
25
25
 
26
26
  /**
27
27
  * Resolve `raw` against the effective root and hand it to the native
28
- * handler. A launch failure is reported through `ctx.ui.notify` because the
29
- * handler runs detached; the target is returned for the caller's message.
28
+ * handler. A start failure, or a non-zero exit where the platform reports
29
+ * one, goes through `ctx.ui.notify` because the handler runs detached; the
30
+ * target is returned for the caller's message.
30
31
  */
31
32
  const launch = (mode: OpenMode, raw: string, ctx: ExtensionContext): string => {
32
33
  const root = resolveEffectiveCwd(ctx.cwd);
@@ -84,7 +85,7 @@ export default function fileOpen(pi: ExtensionAPI, deps: FileOpenDeps = {}): voi
84
85
  const rest = trimmed.replace(/^-r\b\s*/, "").trim();
85
86
  try {
86
87
  const target = launch(reveal ? "reveal" : "open", rest, ctx);
87
- ctx.ui.notify(`${reveal ? "Revealed" : "Opened"} ${target}`, "info");
88
+ ctx.ui.notify(`${reveal ? "Revealing" : "Opening"} ${target}`, "info");
88
89
  } catch (error) {
89
90
  ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
90
91
  }