@gavin-hu/my-pi-agent 0.12.0 → 0.13.1
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 +28 -21
- package/README.zh-CN.md +30 -22
- package/extensions/bytefly/README.md +162 -70
- package/extensions/bytefly/chrome.ts +7 -2
- package/extensions/bytefly/doctor/register.ts +6 -0
- package/extensions/bytefly/doctor/runtime.ts +38 -6
- package/extensions/bytefly/footer/budget.ts +64 -0
- package/extensions/bytefly/footer/collect/snapshot.ts +33 -5
- package/extensions/bytefly/footer/config.ts +7 -1
- package/extensions/bytefly/footer/delegation.ts +100 -0
- package/extensions/bytefly/footer/price/config.ts +16 -0
- package/extensions/bytefly/footer/price/types.ts +5 -0
- package/extensions/bytefly/footer/register.ts +46 -4
- package/extensions/bytefly/footer/render/lines.ts +2 -0
- package/extensions/bytefly/footer/render/segments.ts +27 -0
- package/extensions/bytefly/footer/types.ts +11 -0
- package/extensions/bytefly/index.ts +8 -5
- package/extensions/bytefly/notify/config.ts +2 -0
- package/extensions/bytefly/notify/format.ts +19 -1
- package/extensions/bytefly/notify/register.ts +52 -14
- package/extensions/bytefly/notify/types.ts +2 -0
- package/extensions/bytefly/panel/commands.ts +34 -13
- package/extensions/bytefly/panel/settings.ts +129 -16
- package/extensions/bytefly/update/cache.ts +73 -0
- package/extensions/bytefly/update/format.ts +65 -10
- package/extensions/bytefly/update/register.ts +99 -13
- package/extensions/bytefly/update/state.ts +13 -1
- package/extensions/document/README.md +22 -12
- package/extensions/document/config.ts +4 -0
- package/extensions/document/deadline.ts +61 -0
- package/extensions/document/tool.ts +13 -2
- package/extensions/file-browse/README.md +12 -8
- package/extensions/file-browse/client.ts +6 -7
- package/extensions/file-browse/files.ts +0 -4
- package/extensions/file-browse/git.ts +9 -10
- package/extensions/file-browse/html.ts +17 -7
- package/extensions/file-browse/router.ts +24 -15
- package/extensions/goal/README.md +7 -1
- package/extensions/goal/schema.ts +6 -2
- package/extensions/goal/types.ts +6 -2
- package/extensions/job/README.md +16 -12
- package/extensions/job/format.ts +4 -7
- package/extensions/job/runtime.ts +23 -6
- package/extensions/job/status.ts +4 -2
- package/extensions/memory/README.md +55 -13
- package/extensions/memory/commands.ts +20 -2
- package/extensions/memory/doctor.ts +137 -0
- package/extensions/memory/format.ts +24 -7
- package/extensions/memory/runtime.ts +49 -0
- package/extensions/memory/store.ts +31 -0
- package/extensions/memory/tui.ts +136 -12
- package/extensions/plan/README.md +60 -29
- package/extensions/plan/commands.ts +67 -8
- package/extensions/plan/index.ts +35 -11
- package/extensions/plan/plan-index.ts +234 -0
- package/extensions/plan/plan-text.ts +95 -0
- package/extensions/plan/plans.ts +90 -1
- package/extensions/plan/review.ts +12 -9
- package/extensions/plan/runtime.ts +72 -27
- package/extensions/plan/steps.ts +35 -25
- package/extensions/plan/todos.ts +24 -2
- package/extensions/plan/tools.ts +124 -24
- package/extensions/plan/tui.ts +7 -28
- package/extensions/plan/types.ts +9 -0
- package/extensions/plan/web-page.ts +2 -2
- package/extensions/plan/web.ts +12 -11
- package/extensions/rewind/README.md +28 -8
- package/extensions/rewind/commands.ts +1 -1
- package/extensions/rewind/format.ts +4 -4
- package/extensions/rewind/git.ts +56 -18
- package/extensions/rewind/index.ts +3 -5
- package/extensions/rewind/path-picker.ts +198 -0
- package/extensions/rewind/paths.ts +128 -0
- package/extensions/rewind/restore.ts +69 -28
- package/extensions/rewind/rewind.ts +80 -3
- package/extensions/rewind/runtime.ts +13 -5
- package/extensions/rewind/snapshot-run.ts +5 -2
- package/extensions/rewind/types.ts +2 -0
- package/extensions/subagent/README.md +7 -1
- package/extensions/subagent/accounting.ts +34 -0
- package/extensions/subagent/index.ts +23 -1
- package/extensions/subagent/stream.ts +2 -0
- package/extensions/subagent/types.ts +2 -0
- package/extensions/todo/nudge.ts +5 -1
- package/extensions/watch/README.md +19 -8
- package/extensions/watch/commands.ts +4 -3
- package/extensions/watch/config.ts +1 -1
- package/extensions/watch/format.ts +4 -7
- package/extensions/watch/index.ts +19 -0
- package/extensions/watch/runtime.ts +83 -12
- package/extensions/web-access/README.md +43 -13
- package/extensions/web-access/charset.ts +58 -0
- package/extensions/web-access/fetch/find.ts +11 -7
- package/extensions/web-access/fetch/format.ts +64 -18
- package/extensions/web-access/fetch/page.ts +5 -1
- package/extensions/web-access/fetch/tool.ts +19 -18
- package/extensions/web-access/http.ts +49 -8
- package/extensions/web-access/search/facts.ts +38 -0
- package/extensions/web-access/search/tool.ts +13 -0
- package/extensions/web-access/text.ts +50 -11
- package/extensions/worktree/README.md +81 -16
- package/extensions/worktree/config.ts +11 -0
- package/extensions/worktree/enter.ts +10 -3
- package/extensions/worktree/exit.ts +55 -3
- package/extensions/worktree/gaps.ts +74 -0
- package/extensions/worktree/git.ts +28 -0
- package/extensions/worktree/guard.ts +50 -4
- package/extensions/worktree/handoff.ts +55 -0
- package/extensions/worktree/index.ts +14 -2
- package/extensions/worktree/root-tools.ts +1 -1
- package/extensions/worktree/runtime.ts +25 -0
- package/extensions/worktree/status.ts +15 -2
- package/extensions/worktree/tools.ts +3 -2
- package/lib/README.md +14 -10
- package/lib/events.ts +86 -1
- package/lib/goal-state.ts +95 -0
- package/lib/requirements.ts +361 -62
- package/lib/tui/README.md +91 -0
- package/lib/tui/columns.ts +17 -0
- package/lib/tui/glyphs.ts +1 -0
- package/lib/tui/tool.ts +6 -2
- package/lib/web/layout.ts +12 -2
- package/package.json +5 -3
- package/themes/bytefly-dark.json +1 -1
- package/assets/chrome.png +0 -0
- package/assets/file-browse.png +0 -0
- package/assets/job.png +0 -0
- package/assets/plan-review.png +0 -0
- package/assets/question.png +0 -0
- package/assets/rewind.png +0 -0
- package/assets/todo.png +0 -0
- package/assets/tool-renderings.png +0 -0
- package/assets/tools/ask_user_question-cancelled.png +0 -0
- package/assets/tools/ask_user_question.png +0 -0
- package/assets/tools/bash.png +0 -0
- package/assets/tools/edit.png +0 -0
- package/assets/tools/enter_plan_mode.png +0 -0
- package/assets/tools/enter_worktree.png +0 -0
- package/assets/tools/exit_plan_mode.png +0 -0
- package/assets/tools/exit_worktree.png +0 -0
- package/assets/tools/find.png +0 -0
- package/assets/tools/goal.png +0 -0
- package/assets/tools/grep.png +0 -0
- package/assets/tools/job.png +0 -0
- package/assets/tools/list_worktrees.png +0 -0
- package/assets/tools/ls.png +0 -0
- package/assets/tools/memory-long-subject.png +0 -0
- package/assets/tools/memory.png +0 -0
- package/assets/tools/notice.png +0 -0
- package/assets/tools/open_path.png +0 -0
- package/assets/tools/prune_worktrees.png +0 -0
- package/assets/tools/read.png +0 -0
- package/assets/tools/read_doc-running.png +0 -0
- package/assets/tools/read_doc.png +0 -0
- package/assets/tools/run_doctor.png +0 -0
- package/assets/tools/send_wechat.png +0 -0
- package/assets/tools/set_plan_steps.png +0 -0
- package/assets/tools/subagent.png +0 -0
- package/assets/tools/todo.png +0 -0
- package/assets/tools/turn-rule.png +0 -0
- package/assets/tools/user-message.png +0 -0
- package/assets/tools/watch.png +0 -0
- package/assets/tools/web_fetch-rejected.png +0 -0
- package/assets/tools/web_fetch.png +0 -0
- package/assets/tools/web_search.png +0 -0
- package/assets/tools/write.png +0 -0
- package/assets/tools/write_plan.png +0 -0
|
@@ -1,23 +1,28 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The `/bytefly` surface: one command, `/bytefly [doctor] [settings] [extension] [refresh]`.
|
|
2
|
+
* The `/bytefly` surface: one command, `/bytefly [doctor] [settings] [update] [extension] [refresh]`.
|
|
3
3
|
*
|
|
4
4
|
* Without `doctor` it opens the extension screen: the dock in `tui`, the
|
|
5
5
|
* grouped listing elsewhere. `settings` opens the Settings section, or prints
|
|
6
|
-
* its grouped listing outside `tui` without resolving packages.
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* the
|
|
6
|
+
* its grouped listing outside `tui` without resolving packages. `update`
|
|
7
|
+
* re-runs the published-version check and reports what it found, which is the
|
|
8
|
+
* one place a request is made on demand. With `doctor` it produces the doctor
|
|
9
|
+
* report — the same text `run_doctor` returns — for the whole set or one named
|
|
10
|
+
* extension; in `tui` the report is the dock screen. Discovery, the doctor
|
|
11
|
+
* probe, and the dirty-to-reload confirm all live here. The doctor runtime and
|
|
12
|
+
* the update handle are injected by the composition root, so `run_doctor` and
|
|
13
|
+
* the screen keep reading one cache, and the command paints the marker it finds.
|
|
12
14
|
*/
|
|
13
15
|
|
|
14
16
|
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
15
17
|
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
18
|
+
import pkg from "../../../package.json";
|
|
16
19
|
import { sanitizeLine } from "../../../lib/format.ts";
|
|
17
20
|
import { withRailsSuppressed } from "../../../lib/rails.ts";
|
|
18
21
|
import { REQUIREMENTS, type ExtensionReport } from "../../../lib/requirements.ts";
|
|
19
22
|
import { formatExtension, formatReport, hasProblems } from "../doctor/format.ts";
|
|
20
23
|
import type { DoctorRuntime } from "../doctor/runtime.ts";
|
|
24
|
+
import { updateOutcomeText } from "../update/format.ts";
|
|
25
|
+
import type { UpdateHandle } from "../update/register.ts";
|
|
21
26
|
import { createSettingsStores, discoverExtensions, type DiscoveryDeps } from "../picker/discovery.ts";
|
|
22
27
|
import { createExtensionsPickerRuntime, type ExtensionsPickerRuntime } from "../picker/runtime.ts";
|
|
23
28
|
import type { Discovery } from "../picker/types.ts";
|
|
@@ -31,6 +36,8 @@ import type { PanelSection } from "./types.ts";
|
|
|
31
36
|
export interface PanelCommandDeps {
|
|
32
37
|
/** The doctor runtime created by `registerDoctor`; shared with `run_doctor`. */
|
|
33
38
|
doctor: DoctorRuntime;
|
|
39
|
+
/** The update handle `registerUpdate` returns, for `/bytefly update`. */
|
|
40
|
+
update: UpdateHandle;
|
|
34
41
|
/** The picker's discovery seam. */
|
|
35
42
|
discover?: (deps: DiscoveryDeps) => Promise<Discovery>;
|
|
36
43
|
/** The Settings runtime seam. */
|
|
@@ -49,13 +56,14 @@ function selectableNames(discovery: Discovery): Set<string> {
|
|
|
49
56
|
}
|
|
50
57
|
|
|
51
58
|
/** The values a bare `/bytefly <tab>` offers, in screen order. */
|
|
52
|
-
const FIRST_TOKENS = ["doctor", "settings", ...Object.keys(REQUIREMENTS), "refresh"] as const;
|
|
59
|
+
const FIRST_TOKENS = ["doctor", "settings", "update", ...Object.keys(REQUIREMENTS), "refresh"] as const;
|
|
53
60
|
|
|
54
61
|
/**
|
|
55
62
|
* Argument completions. The host replaces the whole argument text with the
|
|
56
63
|
* returned `value`, so a second token returns the full `doctor <name>` or
|
|
57
|
-
* `<name> refresh` argument rather than just the word. `/bytefly settings`
|
|
58
|
-
* no name, and `refresh` is never followed by another
|
|
64
|
+
* `<name> refresh` argument rather than just the word. `/bytefly settings` and
|
|
65
|
+
* `/bytefly update` take no name, and `refresh` is never followed by another
|
|
66
|
+
* token.
|
|
59
67
|
*/
|
|
60
68
|
function completions(prefix: string): Array<{ value: string; label: string }> | null {
|
|
61
69
|
const [first = "", second, ...rest] = prefix.trimStart().split(/\s+/);
|
|
@@ -64,7 +72,7 @@ function completions(prefix: string): Array<{ value: string; label: string }> |
|
|
|
64
72
|
const options = FIRST_TOKENS.filter((option) => option.startsWith(first));
|
|
65
73
|
return options.length > 0 ? options.map((option) => ({ value: `${option} `, label: option })) : null;
|
|
66
74
|
}
|
|
67
|
-
if (first === "settings" || first === "refresh") return null;
|
|
75
|
+
if (first === "settings" || first === "refresh" || first === "update") return null;
|
|
68
76
|
const options = (first === "doctor" ? [...Object.keys(REQUIREMENTS), "refresh"] : ["refresh"]).filter((option) =>
|
|
69
77
|
option.startsWith(second),
|
|
70
78
|
);
|
|
@@ -72,7 +80,7 @@ function completions(prefix: string): Array<{ value: string; label: string }> |
|
|
|
72
80
|
}
|
|
73
81
|
|
|
74
82
|
/** Which bytefly surface the arguments select. */
|
|
75
|
-
type PanelMode = "screen" | "doctor";
|
|
83
|
+
type PanelMode = "screen" | "doctor" | "update";
|
|
76
84
|
|
|
77
85
|
/** `/bytefly [doctor] [settings] [extension] [refresh]`, parsed. */
|
|
78
86
|
interface PanelArgs {
|
|
@@ -85,12 +93,15 @@ interface PanelArgs {
|
|
|
85
93
|
refresh: boolean;
|
|
86
94
|
}
|
|
87
95
|
|
|
88
|
-
/** Parse `/bytefly [doctor] [settings] [extension] [refresh]`; extra tokens are ignored. */
|
|
96
|
+
/** Parse `/bytefly [doctor] [settings] [update] [extension] [refresh]`; extra tokens are ignored. */
|
|
89
97
|
function parseArgs(args: string): PanelArgs {
|
|
90
98
|
const parts = args.trim().split(/\s+/).filter(Boolean);
|
|
91
99
|
const refresh = parts.includes("refresh");
|
|
92
100
|
const rest = parts.filter((part) => part !== "refresh");
|
|
93
101
|
if (rest[0] === "doctor") return { mode: "doctor", section: "extensions", name: rest[1], refresh };
|
|
102
|
+
// `update` is already a refresh of the published version, so a stray `refresh`
|
|
103
|
+
// beside it is accepted and ignored rather than refused.
|
|
104
|
+
if (rest[0] === "update") return { mode: "update", section: "extensions", refresh };
|
|
94
105
|
if (rest[0] === "settings") return { mode: "screen", section: "settings", name: rest[1], refresh };
|
|
95
106
|
return { mode: "screen", section: "extensions", name: rest[0], refresh };
|
|
96
107
|
}
|
|
@@ -146,6 +157,16 @@ export function registerPanel(pi: ExtensionAPI, deps: PanelCommandDeps): void {
|
|
|
146
157
|
};
|
|
147
158
|
const settingsNotice = (message: string): void => surfaceNotice(message, "warning");
|
|
148
159
|
|
|
160
|
+
// `/bytefly update` answers one question and resolves nothing: no package
|
|
161
|
+
// discovery, no doctor probe, no screen. It is the one path a version check
|
|
162
|
+
// takes on demand, so the request happens here and nowhere else.
|
|
163
|
+
if (mode === "update") {
|
|
164
|
+
const outcome = await deps.update.recheck();
|
|
165
|
+
const { text, severity } = updateOutcomeText(outcome, pkg.version);
|
|
166
|
+
ctx.ui.notify(text, severity);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
|
|
149
170
|
// Settings outside `tui` prints its grouped listing without resolving
|
|
150
171
|
// packages or probing the doctor.
|
|
151
172
|
if (section === "settings" && ctx.mode !== "tui") {
|
|
@@ -6,15 +6,16 @@
|
|
|
6
6
|
* views, cycling, validation, and grouping — so the screen and the runtime share
|
|
7
7
|
* one definition and the tests need no host.
|
|
8
8
|
*
|
|
9
|
-
* The catalogue
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
9
|
+
* The catalogue covers every scalar preference: booleans, enums, short strings,
|
|
10
|
+
* and — with `doctor.timeoutMs` — a number. Each row states its own rule, so a
|
|
11
|
+
* value the screen accepts is a value the loader keeps. Arrays (`doctor.skip`),
|
|
12
|
+
* the `rates` table, and `price.peak` stay JSON edits: a list, a table, and a
|
|
13
|
+
* nested object are not one row.
|
|
13
14
|
*/
|
|
14
15
|
|
|
15
16
|
import { configSection, configValueAt } from "../../../lib/config.ts";
|
|
16
17
|
import { GLYPHS } from "../../../lib/tui/glyphs.ts";
|
|
17
|
-
import { DEFAULT_CHROME_CONFIG, normalizeChromeConfig } from "../chrome.ts";
|
|
18
|
+
import { DEFAULT_CHROME_CONFIG, isRailGlyph, normalizeChromeConfig } from "../chrome.ts";
|
|
18
19
|
import { DEFAULT_CONFIG as DEFAULT_DOCTOR_CONFIG, normalizeConfig as normalizeDoctorConfig } from "../doctor/config.ts";
|
|
19
20
|
import {
|
|
20
21
|
CURRENCY_DEFAULTS,
|
|
@@ -136,14 +137,6 @@ const ON_OFF: readonly SettingOption[] = [
|
|
|
136
137
|
{ value: "true", label: "On" },
|
|
137
138
|
];
|
|
138
139
|
|
|
139
|
-
/** Rail glyphs offered in the curated picker; every one is one column. */
|
|
140
|
-
const RAIL_OPTIONS: readonly SettingOption[] = [
|
|
141
|
-
{ value: GLYPHS.divider, label: GLYPHS.divider },
|
|
142
|
-
{ value: "│", label: "│" },
|
|
143
|
-
{ value: "┃", label: "┃" },
|
|
144
|
-
{ value: "▏", label: "▏" },
|
|
145
|
-
];
|
|
146
|
-
|
|
147
140
|
/** The notify section's delivery methods, labeled by the surface each reaches. */
|
|
148
141
|
const METHOD_OPTIONS: readonly SettingOption[] = [
|
|
149
142
|
{ value: "auto", label: "Auto" },
|
|
@@ -160,6 +153,51 @@ const DECIMALS_OPTIONS: readonly SettingOption[] = [0, 1, 2, 3, 4].map((value) =
|
|
|
160
153
|
label: String(value),
|
|
161
154
|
}));
|
|
162
155
|
|
|
156
|
+
/**
|
|
157
|
+
* A text rule for a whole number inside an inclusive range.
|
|
158
|
+
*
|
|
159
|
+
* The bounds are the loader's own (`doctor.timeoutMs` is clamped to 100-30000),
|
|
160
|
+
* so an accepted value is never silently clamped on the next read. `maxLength`
|
|
161
|
+
* only stops a runaway buffer; `validate` is the rule.
|
|
162
|
+
*/
|
|
163
|
+
function integerRule(min: number, max: number): TextRule {
|
|
164
|
+
return {
|
|
165
|
+
maxLength: String(max).length + 2,
|
|
166
|
+
validate(raw) {
|
|
167
|
+
const text = raw.trim();
|
|
168
|
+
if (!/^\d+$/.test(text)) return `Use a whole number, ${min} to ${max}.`;
|
|
169
|
+
const value = Number(text);
|
|
170
|
+
if (value < min || value > max) return `Out of range: ${min} to ${max}.`;
|
|
171
|
+
return undefined;
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* A text rule for a money amount inside an inclusive range: a whole number or a
|
|
178
|
+
* decimal with up to four places, matching the loader's clamp for `price.budget`.
|
|
179
|
+
* `0` is a legitimate value (the guardrail is off), so it is not rejected.
|
|
180
|
+
*/
|
|
181
|
+
function amountRule(min: number, max: number): TextRule {
|
|
182
|
+
return {
|
|
183
|
+
maxLength: 16,
|
|
184
|
+
validate(raw) {
|
|
185
|
+
const text = raw.trim();
|
|
186
|
+
if (!/^\d+(?:\.\d{1,4})?$/.test(text)) return `Use an amount like 5 or 2.50, ${min} to ${max}.`;
|
|
187
|
+
const value = Number(text);
|
|
188
|
+
if (!Number.isFinite(value) || value < min || value > max) return `Out of range: ${min} to ${max}.`;
|
|
189
|
+
return undefined;
|
|
190
|
+
},
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** The rail's rule: the loader drops anything that is not one column. */
|
|
195
|
+
const RAIL_RULE: TextRule = {
|
|
196
|
+
maxLength: 4,
|
|
197
|
+
validate: (raw) =>
|
|
198
|
+
isRailGlyph(raw) ? undefined : "Use exactly one column: a single glyph, with no spaces or control characters.",
|
|
199
|
+
};
|
|
200
|
+
|
|
163
201
|
/** The v1 catalogue, in screen order. */
|
|
164
202
|
export const SETTING_ROWS: readonly SettingDescriptor[] = [
|
|
165
203
|
{
|
|
@@ -185,6 +223,54 @@ export const SETTING_ROWS: readonly SettingDescriptor[] = [
|
|
|
185
223
|
options: ON_OFF,
|
|
186
224
|
config: { section: "notify", path: ["enabled"] },
|
|
187
225
|
},
|
|
226
|
+
{
|
|
227
|
+
id: "notify.turnFinished",
|
|
228
|
+
group: "notifications",
|
|
229
|
+
label: "Turn finished",
|
|
230
|
+
description: "Announce it when a full agent run settles.",
|
|
231
|
+
source: "bytefly",
|
|
232
|
+
kind: "boolean",
|
|
233
|
+
fallback: "true",
|
|
234
|
+
json: "boolean",
|
|
235
|
+
options: ON_OFF,
|
|
236
|
+
config: { section: "notify", path: ["turnFinished"] },
|
|
237
|
+
},
|
|
238
|
+
{
|
|
239
|
+
id: "notify.needsInput",
|
|
240
|
+
group: "notifications",
|
|
241
|
+
label: "Needs input",
|
|
242
|
+
description: "Announce it when the agent opens a prompt and waits for an answer.",
|
|
243
|
+
source: "bytefly",
|
|
244
|
+
kind: "boolean",
|
|
245
|
+
fallback: "true",
|
|
246
|
+
json: "boolean",
|
|
247
|
+
options: ON_OFF,
|
|
248
|
+
config: { section: "notify", path: ["needsInput"] },
|
|
249
|
+
},
|
|
250
|
+
{
|
|
251
|
+
id: "notify.jobFinished",
|
|
252
|
+
group: "notifications",
|
|
253
|
+
label: "Job finished",
|
|
254
|
+
description: "Announce it when a background job or watcher settles.",
|
|
255
|
+
source: "bytefly",
|
|
256
|
+
kind: "boolean",
|
|
257
|
+
fallback: "true",
|
|
258
|
+
json: "boolean",
|
|
259
|
+
options: ON_OFF,
|
|
260
|
+
config: { section: "notify", path: ["jobFinished"] },
|
|
261
|
+
},
|
|
262
|
+
{
|
|
263
|
+
id: "notify.matchFound",
|
|
264
|
+
group: "notifications",
|
|
265
|
+
label: "Watcher matched",
|
|
266
|
+
description: "Announce it when a `watch` whose `wake` is on matches while the agent is idle.",
|
|
267
|
+
source: "bytefly",
|
|
268
|
+
kind: "boolean",
|
|
269
|
+
fallback: "true",
|
|
270
|
+
json: "boolean",
|
|
271
|
+
options: ON_OFF,
|
|
272
|
+
config: { section: "notify", path: ["matchFound"] },
|
|
273
|
+
},
|
|
188
274
|
{
|
|
189
275
|
id: "notify.method",
|
|
190
276
|
group: "notifications",
|
|
@@ -223,6 +309,19 @@ export const SETTING_ROWS: readonly SettingDescriptor[] = [
|
|
|
223
309
|
options: ON_OFF,
|
|
224
310
|
config: { section: "doctor", path: ["proactive"] },
|
|
225
311
|
},
|
|
312
|
+
{
|
|
313
|
+
id: "doctor.timeoutMs",
|
|
314
|
+
group: "diagnostics",
|
|
315
|
+
label: "Probe timeout",
|
|
316
|
+
description: "How long one doctor probe may take, in milliseconds; 100 to 30000.",
|
|
317
|
+
source: "bytefly",
|
|
318
|
+
kind: "text",
|
|
319
|
+
fallback: String(DEFAULT_DOCTOR_CONFIG.timeoutMs),
|
|
320
|
+
json: "number",
|
|
321
|
+
options: [],
|
|
322
|
+
text: integerRule(100, 30_000),
|
|
323
|
+
config: { section: "doctor", path: ["timeoutMs"] },
|
|
324
|
+
},
|
|
226
325
|
{
|
|
227
326
|
id: "price.currency.code",
|
|
228
327
|
group: "pricing",
|
|
@@ -258,15 +357,29 @@ export const SETTING_ROWS: readonly SettingDescriptor[] = [
|
|
|
258
357
|
options: DECIMALS_OPTIONS,
|
|
259
358
|
config: { section: "price", path: ["currency", "decimals"] },
|
|
260
359
|
},
|
|
360
|
+
{
|
|
361
|
+
id: "price.budget",
|
|
362
|
+
group: "pricing",
|
|
363
|
+
label: "Session budget",
|
|
364
|
+
description: "Warn once when the session's cost reaches this amount; 0 is off. It never stops a turn.",
|
|
365
|
+
source: "bytefly",
|
|
366
|
+
kind: "text",
|
|
367
|
+
fallback: "0",
|
|
368
|
+
json: "number",
|
|
369
|
+
options: [],
|
|
370
|
+
text: amountRule(0, 10_000_000),
|
|
371
|
+
config: { section: "price", path: ["budget"] },
|
|
372
|
+
},
|
|
261
373
|
{
|
|
262
374
|
id: "chrome.rail",
|
|
263
375
|
group: "transcript",
|
|
264
376
|
label: "Rail glyph",
|
|
265
|
-
description: "The single-column glyph that leads a tool result or notice body.",
|
|
377
|
+
description: "The single-column glyph that leads a tool result or notice body, e.g. ╎ │ ┃ ▏.",
|
|
266
378
|
source: "bytefly",
|
|
267
|
-
kind: "
|
|
379
|
+
kind: "text",
|
|
268
380
|
fallback: GLYPHS.divider,
|
|
269
|
-
options:
|
|
381
|
+
options: [],
|
|
382
|
+
text: RAIL_RULE,
|
|
270
383
|
config: { section: "chrome", path: ["rail"] },
|
|
271
384
|
},
|
|
272
385
|
{
|
|
@@ -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
|
+
}
|
|
@@ -1,32 +1,87 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Wording for the update check: the header marker
|
|
2
|
+
* Wording for the update check: the header marker, the one transcript notice, and
|
|
3
|
+
* the answer an explicit re-check prints.
|
|
3
4
|
*
|
|
4
|
-
*
|
|
5
|
-
* the registry publishes — and
|
|
6
|
-
* honest form (`↑0.12.0`)
|
|
7
|
-
*
|
|
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).
|
|
8
11
|
*/
|
|
9
12
|
|
|
13
|
+
import pkg from "../../../package.json";
|
|
10
14
|
import { noticeMessage, type NoticeMessage } from "../../../lib/notice.ts";
|
|
11
15
|
import { GLYPHS } from "../../../lib/tui/glyphs.ts";
|
|
16
|
+
import type { UpdateOutcome } from "./state.ts";
|
|
12
17
|
|
|
13
18
|
/** The header marker: an up arrow and the newest version, e.g. `↑0.12.0`. */
|
|
14
19
|
export function updateMarker(latest: string): string {
|
|
15
20
|
return `${GLYPHS.update}${latest}`;
|
|
16
21
|
}
|
|
17
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
|
+
|
|
18
35
|
/** The single notice a session posts when a newer version is published. */
|
|
19
36
|
export function updateNotice(current: string, latest: string): NoticeMessage {
|
|
37
|
+
const release = releaseUrl(latest);
|
|
38
|
+
const command = "pi update --extensions";
|
|
20
39
|
return noticeMessage({
|
|
21
|
-
content:
|
|
40
|
+
content:
|
|
41
|
+
`bytefly ${latest} is available (current ${current}). Run \`${command}\` to update.` +
|
|
42
|
+
(release ? ` Release notes: ${release}` : ""),
|
|
22
43
|
details: {
|
|
23
44
|
source: "bytefly",
|
|
24
45
|
title: `${latest} is available`,
|
|
25
46
|
severity: "info",
|
|
26
|
-
items:
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
+
],
|
|
30
57
|
},
|
|
31
58
|
});
|
|
32
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
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The update check's lifecycle:
|
|
3
|
-
* session, record
|
|
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
4
|
*
|
|
5
5
|
* The request is fired without awaiting. Pi awaits `session_start` handlers, so
|
|
6
6
|
* a slow registry would otherwise hold the first prompt; Pi's own version check
|
|
@@ -8,49 +8,106 @@
|
|
|
8
8
|
* version is silent, and a generation counter keeps a request that resolves
|
|
9
9
|
* after shutdown from writing into the next session's state.
|
|
10
10
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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.
|
|
13
16
|
*/
|
|
14
17
|
|
|
15
18
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
19
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
16
20
|
import pkg from "../../../package.json";
|
|
17
21
|
import { isOffline } from "../../../lib/env.ts";
|
|
22
|
+
import { isFresh, readUpdateCache, updateCachePath, writeUpdateCache, type UpdateCacheEntry } from "./cache.ts";
|
|
18
23
|
import { fetchLatestVersion } from "./check.ts";
|
|
19
24
|
import { updateNotice } from "./format.ts";
|
|
20
|
-
import type { UpdateSource } from "./state.ts";
|
|
25
|
+
import type { UpdateOutcome, UpdateSource } from "./state.ts";
|
|
21
26
|
import { isNewerVersion } from "./version.ts";
|
|
22
27
|
|
|
23
|
-
/**
|
|
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. */
|
|
24
43
|
export interface UpdateDeps {
|
|
25
44
|
/** Replace the registry read; a rejection counts as no answer. */
|
|
26
45
|
fetchLatest?: () => Promise<string | undefined>;
|
|
27
46
|
/** Replace the `PI_OFFLINE` check. */
|
|
28
47
|
isOffline?: () => boolean;
|
|
48
|
+
/** Replace the disk cache. */
|
|
49
|
+
cache?: UpdateCache;
|
|
50
|
+
/** Replace the clock the cache timestamp is read from. */
|
|
51
|
+
now?: () => number;
|
|
29
52
|
/** Called once a newer version is recorded, so a mounted surface repaints. */
|
|
30
53
|
onUpdate?: () => void;
|
|
31
54
|
}
|
|
32
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
|
+
|
|
33
70
|
/**
|
|
34
71
|
* Register the check. `updates` is the same store the header reads, so recording
|
|
35
72
|
* a version and calling `onUpdate` is what puts the marker on screen.
|
|
36
73
|
*/
|
|
37
|
-
export function registerUpdate(pi: ExtensionAPI, updates: UpdateSource, deps: UpdateDeps = {}):
|
|
74
|
+
export function registerUpdate(pi: ExtensionAPI, updates: UpdateSource, deps: UpdateDeps = {}): UpdateHandle {
|
|
38
75
|
const readLatest = deps.fetchLatest ?? (() => fetchLatestVersion());
|
|
39
76
|
const offline = deps.isOffline ?? isOffline;
|
|
77
|
+
const cache = deps.cache ?? diskCache();
|
|
78
|
+
const now = deps.now ?? Date.now;
|
|
40
79
|
// A session is announced at most once per load, even if `session_start` fires
|
|
41
80
|
// again after a resume or a session replacement.
|
|
42
81
|
let announced = false;
|
|
43
82
|
let generation = 0;
|
|
44
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
|
+
|
|
45
104
|
const check = async (token: number): Promise<void> => {
|
|
46
105
|
try {
|
|
47
106
|
const latest = await readLatest();
|
|
48
107
|
if (latest === undefined || token !== generation) return;
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
deps.onUpdate?.();
|
|
53
|
-
pi.sendMessage(updateNotice(pkg.version, latest));
|
|
108
|
+
const entry = { latest, checkedAt: now() };
|
|
109
|
+
cache.write(entry);
|
|
110
|
+
record(entry);
|
|
54
111
|
} catch {
|
|
55
112
|
// A background check never surfaces an error: the session does not know it ran.
|
|
56
113
|
}
|
|
@@ -60,7 +117,19 @@ export function registerUpdate(pi: ExtensionAPI, updates: UpdateSource, deps: Up
|
|
|
60
117
|
// The marker lives in bytefly's header and the notice in a transcript, so a
|
|
61
118
|
// non-interactive run has nothing to draw and makes no request.
|
|
62
119
|
if (ctx.mode !== "tui") return;
|
|
63
|
-
if (
|
|
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;
|
|
64
133
|
void check(generation);
|
|
65
134
|
});
|
|
66
135
|
|
|
@@ -69,4 +138,21 @@ export function registerUpdate(pi: ExtensionAPI, updates: UpdateSource, deps: Up
|
|
|
69
138
|
generation++;
|
|
70
139
|
updates.set();
|
|
71
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
|
+
};
|
|
72
158
|
}
|