@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.
- package/README.md +13 -8
- package/README.zh-CN.md +5 -3
- package/assets/chrome.png +0 -0
- package/assets/plan-review.png +0 -0
- package/extensions/bytefly/README.md +291 -207
- package/extensions/bytefly/doctor/runtime.ts +16 -0
- package/extensions/bytefly/doctor/tools.ts +1 -3
- package/extensions/bytefly/footer/price/config.ts +54 -15
- package/extensions/bytefly/footer/price/price.ts +20 -7
- package/extensions/bytefly/footer/price/types.ts +13 -7
- package/extensions/bytefly/footer/render/format.ts +3 -2
- package/extensions/bytefly/footer/render/segments.ts +3 -2
- package/extensions/bytefly/footer/types.ts +1 -0
- package/extensions/bytefly/header/component.ts +21 -6
- package/extensions/bytefly/header/config.ts +3 -0
- package/extensions/bytefly/header/register.ts +34 -3
- package/extensions/bytefly/index.ts +13 -3
- package/extensions/bytefly/panel/settings-runtime.ts +71 -28
- package/extensions/bytefly/panel/settings.ts +201 -38
- package/extensions/bytefly/panel/tui/entries.ts +58 -0
- package/extensions/bytefly/panel/tui/extensions-view.ts +86 -0
- package/extensions/bytefly/panel/tui/frame.ts +71 -0
- package/extensions/bytefly/panel/tui/nav.ts +75 -0
- package/extensions/bytefly/panel/tui/settings-view.ts +115 -0
- package/extensions/bytefly/panel/tui.ts +205 -319
- package/extensions/bytefly/picker/runtime.ts +61 -2
- package/extensions/bytefly/picker/toggle.ts +56 -0
- package/extensions/bytefly/update/check.ts +59 -0
- package/extensions/bytefly/update/format.ts +32 -0
- package/extensions/bytefly/update/register.ts +72 -0
- package/extensions/bytefly/update/state.ts +28 -0
- package/extensions/bytefly/update/version.ts +83 -0
- package/extensions/document/README.md +2 -2
- package/extensions/document/tool.ts +1 -3
- package/extensions/file-open/README.md +30 -15
- package/extensions/file-open/index.ts +4 -3
- package/extensions/file-open/open.ts +52 -21
- package/extensions/goal/README.md +39 -13
- package/extensions/goal/clarify-prompt.ts +5 -36
- package/extensions/goal/clarify.ts +55 -86
- package/extensions/goal/commands.ts +41 -35
- package/extensions/goal/format.ts +12 -4
- package/extensions/goal/tools.ts +28 -8
- package/extensions/goal/tui.ts +1 -1
- package/extensions/memory/README.md +1 -1
- package/extensions/plan/README.md +24 -8
- package/extensions/plan/blocks.ts +1 -35
- package/extensions/plan/commands.ts +18 -12
- package/extensions/plan/plans.ts +1 -1
- package/extensions/plan/policy.ts +4 -3
- package/extensions/plan/review.ts +243 -0
- package/extensions/plan/runtime.ts +4 -0
- package/extensions/plan/steps.ts +34 -23
- package/extensions/plan/todos.ts +5 -58
- package/extensions/plan/tools.ts +66 -222
- package/extensions/plan/tui.ts +17 -9
- package/extensions/plan/types.ts +3 -0
- package/extensions/plan/web-client.ts +51 -18
- package/extensions/plan/web-page.ts +5 -0
- package/extensions/plan/web-router.ts +11 -3
- package/extensions/plan/web.ts +100 -36
- package/extensions/question/README.md +1 -1
- package/extensions/question/tools.ts +1 -1
- package/extensions/subagent/index.ts +0 -2
- package/extensions/todo/README.md +19 -11
- package/extensions/todo/index.ts +5 -3
- package/extensions/todo/nudge.ts +58 -9
- package/extensions/todo/runtime.ts +28 -17
- package/extensions/web-access/README.md +3 -3
- package/extensions/web-access/fetch/tool.ts +1 -3
- package/extensions/web-access/search/tool.ts +1 -3
- package/extensions/wechat/README.md +22 -11
- package/extensions/wechat/bridge.ts +90 -22
- package/extensions/wechat/commands.ts +45 -1
- package/extensions/wechat/doctor.ts +1 -0
- package/extensions/wechat/index.ts +3 -1
- package/extensions/wechat/status-screen.ts +32 -2
- package/extensions/wechat/tools.ts +0 -2
- package/lib/README.md +2 -1
- package/lib/config.ts +43 -1
- package/lib/env.ts +12 -0
- package/lib/requirements.ts +76 -2
- package/lib/todo-state.ts +62 -0
- package/lib/tui/glyphs.ts +1 -0
- package/package.json +1 -1
- 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 {
|
|
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` —
|
|
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`,
|
|
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
|
-
|
|
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
|
-
`
|
|
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
|
|
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
|
-
|
|
57
|
-
|
|
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
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
and
|
|
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
|
|
98
|
-
|
|
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` / `
|
|
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
|
-
|
|
127
|
-
rather than being thrown from a synchronous call
|
|
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
|
|
29
|
-
*
|
|
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 ? "
|
|
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
|
}
|