@gavin-hu/my-pi-agent 0.12.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -21
- package/README.zh-CN.md +30 -22
- package/extensions/bytefly/README.md +139 -67
- package/extensions/bytefly/chrome.ts +7 -2
- package/extensions/bytefly/doctor/register.ts +6 -0
- package/extensions/bytefly/doctor/runtime.ts +36 -6
- package/extensions/bytefly/footer/collect/snapshot.ts +12 -2
- package/extensions/bytefly/footer/config.ts +7 -1
- package/extensions/bytefly/footer/delegation.ts +100 -0
- package/extensions/bytefly/footer/register.ts +13 -2
- 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 +4 -3
- package/extensions/bytefly/notify/config.ts +2 -0
- package/extensions/bytefly/notify/format.ts +19 -1
- package/extensions/bytefly/notify/register.ts +37 -13
- package/extensions/bytefly/notify/types.ts +2 -0
- package/extensions/bytefly/panel/commands.ts +34 -13
- package/extensions/bytefly/panel/settings.ts +98 -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/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/watch/README.md +8 -3
- package/extensions/watch/index.ts +19 -0
- 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 +74 -15
- package/extensions/worktree/config.ts +11 -0
- package/extensions/worktree/exit.ts +35 -3
- package/extensions/worktree/gaps.ts +74 -0
- package/extensions/worktree/guard.ts +50 -4
- package/extensions/worktree/handoff.ts +55 -0
- package/extensions/worktree/index.ts +12 -2
- package/extensions/worktree/runtime.ts +20 -0
- package/extensions/worktree/status.ts +15 -2
- package/extensions/worktree/tools.ts +3 -2
- package/lib/README.md +10 -8
- package/lib/events.ts +86 -1
- package/lib/goal-state.ts +95 -0
- package/lib/requirements.ts +282 -62
- package/lib/tui/README.md +91 -0
- package/lib/tui/glyphs.ts +1 -0
- 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
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The child-run ledger: what this session's delegated runs spent.
|
|
3
|
+
*
|
|
4
|
+
* `subagent` publishes one event per finished child process on
|
|
5
|
+
* `SUBAGENT_FINISHED_CHANNEL`. The footer shows that spend as its own number, so
|
|
6
|
+
* the session's total keeps meaning what it always did.
|
|
7
|
+
*
|
|
8
|
+
* The ledger keeps *pricing inputs*, not a total: `currency.code` can change
|
|
9
|
+
* between sessions (a settings edit re-installs the footer and rebuilds the
|
|
10
|
+
* pricer), and a stored figure would then be in the wrong unit. It also keeps the
|
|
11
|
+
* peak tariff honest, because each record carries the time the run finished.
|
|
12
|
+
*
|
|
13
|
+
* Bounded on purpose: the last {@link MAX_DELEGATION_RECORDS} runs are retained —
|
|
14
|
+
* each is a full child `pi` process, so hundreds in one session is already
|
|
15
|
+
* extraordinary — while the run count keeps counting past the window. Every field
|
|
16
|
+
* of an event is re-validated, because the bus is not a trusted channel.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { SubagentFinishedEvent } from "../../../lib/events.ts";
|
|
20
|
+
import type { UsageRecord } from "./types.ts";
|
|
21
|
+
|
|
22
|
+
/** Runs whose pricing inputs are retained. */
|
|
23
|
+
export const MAX_DELEGATION_RECORDS = 500;
|
|
24
|
+
|
|
25
|
+
export interface DelegationLedger {
|
|
26
|
+
/** Note one finished child run; a malformed payload is ignored. */
|
|
27
|
+
note(event: unknown): void;
|
|
28
|
+
/** How many runs finished this session, including ones past the window. */
|
|
29
|
+
runs(): number;
|
|
30
|
+
/** Pricing inputs for the retained runs, oldest first. */
|
|
31
|
+
records(): UsageRecord[];
|
|
32
|
+
/** Changes on every noted run, so a reader can tell its totals are stale. */
|
|
33
|
+
version(): number;
|
|
34
|
+
/** Forget everything; called at session start. */
|
|
35
|
+
reset(): void;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** A finite, non-negative number, or `null` when the field is not one. */
|
|
39
|
+
function count(value: unknown): number | null {
|
|
40
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A non-empty string, or `null`. */
|
|
44
|
+
function text(value: unknown): string | null {
|
|
45
|
+
return typeof value === "string" && value.trim() !== "" ? value : null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The event as a usage record, or `null` when it is not one we can price. */
|
|
49
|
+
function toRecord(event: SubagentFinishedEvent): UsageRecord | null {
|
|
50
|
+
if (text(event.agent) === null) return null;
|
|
51
|
+
const input = count(event.input);
|
|
52
|
+
const output = count(event.output);
|
|
53
|
+
const cacheRead = count(event.cacheRead);
|
|
54
|
+
const cacheWrite = count(event.cacheWrite);
|
|
55
|
+
const recordedCost = count(event.recordedCost);
|
|
56
|
+
// A type-mismatched field is a malformed event, not a zero: refuse the whole
|
|
57
|
+
// record rather than counting a run whose spend is unknown.
|
|
58
|
+
if (input === null || output === null || cacheRead === null || cacheWrite === null || recordedCost === null) {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
return {
|
|
62
|
+
provider: text(event.provider),
|
|
63
|
+
model: text(event.model),
|
|
64
|
+
input,
|
|
65
|
+
output,
|
|
66
|
+
cacheRead,
|
|
67
|
+
cacheWrite,
|
|
68
|
+
recordedCost,
|
|
69
|
+
at: typeof event.at === "number" && Number.isFinite(event.at) ? event.at : null,
|
|
70
|
+
isAssistant: true,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function createDelegationLedger(): DelegationLedger {
|
|
75
|
+
let retained: UsageRecord[] = [];
|
|
76
|
+
let total = 0;
|
|
77
|
+
let version = 0;
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
note(event) {
|
|
81
|
+
if (!event || typeof event !== "object") return;
|
|
82
|
+
const record = toRecord(event as SubagentFinishedEvent);
|
|
83
|
+
if (!record) return;
|
|
84
|
+
total += 1;
|
|
85
|
+
version += 1;
|
|
86
|
+
retained.push(record);
|
|
87
|
+
// Keep the window at the cap without re-allocating the whole array per run
|
|
88
|
+
// until it is actually exceeded.
|
|
89
|
+
if (retained.length > MAX_DELEGATION_RECORDS) retained = retained.slice(-MAX_DELEGATION_RECORDS);
|
|
90
|
+
},
|
|
91
|
+
runs: () => total,
|
|
92
|
+
records: () => [...retained],
|
|
93
|
+
version: () => version,
|
|
94
|
+
reset() {
|
|
95
|
+
retained = [];
|
|
96
|
+
total = 0;
|
|
97
|
+
version = 0;
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|
|
@@ -11,16 +11,23 @@
|
|
|
11
11
|
|
|
12
12
|
import { homedir } from "node:os";
|
|
13
13
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
14
|
+
import { SUBAGENT_FINISHED_CHANNEL } from "../../../lib/events.ts";
|
|
14
15
|
import { createSnapshotReader } from "./collect/snapshot.ts";
|
|
16
|
+
import { createDelegationLedger } from "./delegation.ts";
|
|
15
17
|
import { loadByteflyConfig } from "./price/config.ts";
|
|
16
18
|
import { createFooter } from "./render/footer.ts";
|
|
17
19
|
|
|
18
20
|
/** Register the footer install/restore lifecycle. */
|
|
19
21
|
export function registerFooter(pi: ExtensionAPI): void {
|
|
22
|
+
// One ledger per load: it is this session's child-run accounting, priced by
|
|
23
|
+
// whichever reader is installed (a config change installs a new one).
|
|
24
|
+
const delegation = createDelegationLedger();
|
|
25
|
+
pi.events.on(SUBAGENT_FINISHED_CHANNEL, (event) => delegation.note(event));
|
|
26
|
+
|
|
20
27
|
const install = (ctx: ExtensionContext): void => {
|
|
21
28
|
if (ctx.mode !== "tui") return;
|
|
22
29
|
const config = loadByteflyConfig(ctx.cwd);
|
|
23
|
-
const read = createSnapshotReader(config);
|
|
30
|
+
const read = createSnapshotReader(config, delegation);
|
|
24
31
|
|
|
25
32
|
ctx.ui.setFooter((tui, _theme, footerData) =>
|
|
26
33
|
createFooter(
|
|
@@ -33,7 +40,11 @@ export function registerFooter(pi: ExtensionAPI): void {
|
|
|
33
40
|
);
|
|
34
41
|
};
|
|
35
42
|
|
|
36
|
-
pi.on("session_start", (_event, ctx) =>
|
|
43
|
+
pi.on("session_start", (_event, ctx) => {
|
|
44
|
+
// A new session in this process starts from zero: the ledger is not branch state.
|
|
45
|
+
delegation.reset();
|
|
46
|
+
install(ctx);
|
|
47
|
+
});
|
|
37
48
|
pi.on("session_tree", (_event, ctx) => install(ctx));
|
|
38
49
|
pi.on("session_shutdown", (_event, ctx) => {
|
|
39
50
|
if (ctx.mode !== "tui") return;
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
cacheSegment,
|
|
13
13
|
contextSegment,
|
|
14
14
|
costSegment,
|
|
15
|
+
delegationSegment,
|
|
15
16
|
modelSegment,
|
|
16
17
|
modesSegment,
|
|
17
18
|
pwdSegment,
|
|
@@ -39,6 +40,7 @@ export function buildLines(snapshot: StatusSnapshot, theme: Theme, home: string
|
|
|
39
40
|
contextSegment(snapshot, theme),
|
|
40
41
|
windowSegment(snapshot, theme),
|
|
41
42
|
costSegment(snapshot, theme),
|
|
43
|
+
delegationSegment(snapshot, theme),
|
|
42
44
|
tokensSegment(snapshot, theme),
|
|
43
45
|
cacheSegment(snapshot, theme),
|
|
44
46
|
modesSegment(snapshot, theme),
|
|
@@ -182,6 +182,33 @@ export function costSegment(snapshot: StatusSnapshot, theme: Theme): Segment | n
|
|
|
182
182
|
};
|
|
183
183
|
}
|
|
184
184
|
|
|
185
|
+
/**
|
|
186
|
+
* Child-run spend, next to the session's own cost and never inside it: a
|
|
187
|
+
* subagent runs in its own process, so its tokens are not the session's. The
|
|
188
|
+
* roll-up covers the ledger's retained window (the last 500 runs).
|
|
189
|
+
*/
|
|
190
|
+
export function delegationSegment(snapshot: StatusSnapshot, theme: Theme): Segment | null {
|
|
191
|
+
const { runs, usage } = snapshot.delegation;
|
|
192
|
+
if (runs === 0) return null;
|
|
193
|
+
const icon = CONFIG.icons.delegation;
|
|
194
|
+
const cost = formatCost(usage.cost, snapshot.currency);
|
|
195
|
+
// Detailed first; the run count survives longer than the price, because "some
|
|
196
|
+
// delegation happened" is the part a narrow bar can still say.
|
|
197
|
+
const forms = [
|
|
198
|
+
`${paint(theme, "meta", `${icon} ${runs} · ${cost}`)}`,
|
|
199
|
+
`${paint(theme, "meta", `${icon} ${cost}`)}`,
|
|
200
|
+
paint(theme, "meta", `${icon} ${runs}`),
|
|
201
|
+
paint(theme, "meta", icon),
|
|
202
|
+
];
|
|
203
|
+
return {
|
|
204
|
+
id: "delegation",
|
|
205
|
+
weight: 4,
|
|
206
|
+
droppable: true,
|
|
207
|
+
separator: faint(theme, CONFIG.separators.item),
|
|
208
|
+
forms,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
185
212
|
export function tokensSegment(snapshot: StatusSnapshot, theme: Theme): Segment | null {
|
|
186
213
|
const { input, output } = snapshot.usage;
|
|
187
214
|
if (input === 0 && output === 0) return null;
|
|
@@ -70,6 +70,17 @@ export interface StatusSnapshot {
|
|
|
70
70
|
/** Latest cache hit rate as a percentage, or `null` when unknown. */
|
|
71
71
|
cacheHitRate: number | null;
|
|
72
72
|
providerCount: number;
|
|
73
|
+
/**
|
|
74
|
+
* What this session's delegated child runs spent, priced like `usage` but kept
|
|
75
|
+
* apart from it: the session total never includes child spend.
|
|
76
|
+
*/
|
|
77
|
+
delegation: DelegationTotals;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Child-run totals: how many runs finished, and what the retained window spent. */
|
|
81
|
+
export interface DelegationTotals {
|
|
82
|
+
runs: number;
|
|
83
|
+
usage: UsageTotals;
|
|
73
84
|
}
|
|
74
85
|
|
|
75
86
|
/** One renderable piece of a line. */
|
|
@@ -72,8 +72,9 @@ export default function bytefly(pi: ExtensionAPI, deps: ByteflyDeps = {}): void
|
|
|
72
72
|
registerTurnSeparator(pi, pad);
|
|
73
73
|
registerWorkingTimer(pi);
|
|
74
74
|
const doctor = registerDoctor(pi, deps.doctor);
|
|
75
|
-
registerPanel(pi, { doctor, discover: deps.extensions?.discover });
|
|
76
75
|
registerNotify(pi, deps.notify);
|
|
77
|
-
//
|
|
78
|
-
|
|
76
|
+
// The returned handle is what `/bytefly update` re-runs, so the update surface is
|
|
77
|
+
// registered before the panel that offers the command.
|
|
78
|
+
const update = registerUpdate(pi, updates, { ...deps.update, onUpdate: () => header.refresh() });
|
|
79
|
+
registerPanel(pi, { doctor, update, discover: deps.extensions?.discover });
|
|
79
80
|
}
|
|
@@ -25,6 +25,7 @@ export const DEFAULT_NOTIFY_CONFIG: NotifyConfig = {
|
|
|
25
25
|
turnFinished: true,
|
|
26
26
|
needsInput: true,
|
|
27
27
|
jobFinished: true,
|
|
28
|
+
matchFound: true,
|
|
28
29
|
method: "auto",
|
|
29
30
|
title: "Pi",
|
|
30
31
|
};
|
|
@@ -49,6 +50,7 @@ export function normalizeNotifyConfig(raw: Record<string, unknown> | undefined,
|
|
|
49
50
|
turnFinished: bool(raw.turnFinished, base.turnFinished),
|
|
50
51
|
needsInput: bool(raw.needsInput, base.needsInput),
|
|
51
52
|
jobFinished: bool(raw.jobFinished, base.jobFinished),
|
|
53
|
+
matchFound: bool(raw.matchFound, base.matchFound),
|
|
52
54
|
method: method(raw.method, base.method),
|
|
53
55
|
title: cleanString(raw.title, base.title).slice(0, MAX_TITLE_LENGTH),
|
|
54
56
|
};
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { basename } from "node:path";
|
|
11
|
-
import type { JobFinishedEvent } from "../../../lib/events.ts";
|
|
11
|
+
import type { JobFinishedEvent, WatchMatchEvent } from "../../../lib/events.ts";
|
|
12
12
|
import { sanitizeLine } from "../../../lib/format.ts";
|
|
13
13
|
|
|
14
14
|
/** A short session label, so concurrent unattended runs can be told apart. */
|
|
@@ -37,3 +37,21 @@ export function formatJobBody(job: JobFinishedEvent): string {
|
|
|
37
37
|
const label = sanitizeLine(job.label);
|
|
38
38
|
return `Job ${job.id} ${verb}${code}${label ? ` · ${label}` : ""}`;
|
|
39
39
|
}
|
|
40
|
+
|
|
41
|
+
/** How much of a matched line an OS notification carries. */
|
|
42
|
+
export const MAX_WATCH_LINE = 80;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Body for a `watch` wake match: the newest line that matched, and how many did.
|
|
46
|
+
*
|
|
47
|
+
* The line is watched output rather than something this package wrote, so it is
|
|
48
|
+
* sanitized and clipped here — the one place a notification's text is shaped.
|
|
49
|
+
*/
|
|
50
|
+
export function formatWatchBody(event: WatchMatchEvent): string {
|
|
51
|
+
const count = Math.max(1, event.count);
|
|
52
|
+
const label = sanitizeLine(event.label);
|
|
53
|
+
const line = sanitizeLine(event.line).slice(0, MAX_WATCH_LINE).trimEnd();
|
|
54
|
+
const head = `${count} watched line${count === 1 ? "" : "s"} matched`;
|
|
55
|
+
const source = `${sanitizeLine(event.watcherId)}${label ? ` ${label}` : ""}`;
|
|
56
|
+
return line ? `${head} · ${source}: ${line}` : `${head} · ${source}`;
|
|
57
|
+
}
|
|
@@ -9,8 +9,9 @@
|
|
|
9
9
|
* asking for input. A prompt while idle is a command the user typed, so it
|
|
10
10
|
* does not fire;
|
|
11
11
|
* - a finished background job, announced by the `job` extension on
|
|
12
|
-
* {@link JOB_FINISHED_CHANNEL}
|
|
13
|
-
*
|
|
12
|
+
* {@link JOB_FINISHED_CHANNEL}; and a `watch` wake match, announced by the
|
|
13
|
+
* `watch` extension on {@link WATCH_MATCH_CHANNEL}. The three extensions never
|
|
14
|
+
* import each other; the bus and its payload are the whole contract.
|
|
14
15
|
*
|
|
15
16
|
* The config is read at `session_start` and the bus subscription is a
|
|
16
17
|
* long-lived resource, so it is created there too and released in an
|
|
@@ -18,10 +19,15 @@
|
|
|
18
19
|
*/
|
|
19
20
|
|
|
20
21
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
21
|
-
import {
|
|
22
|
+
import {
|
|
23
|
+
JOB_FINISHED_CHANNEL,
|
|
24
|
+
type JobFinishedEvent,
|
|
25
|
+
WATCH_MATCH_CHANNEL,
|
|
26
|
+
type WatchMatchEvent,
|
|
27
|
+
} from "../../../lib/events.ts";
|
|
22
28
|
import { DEFAULT_NOTIFY_CONFIG, loadNotifyConfig } from "./config.ts";
|
|
23
29
|
import { defaultSeams, type NotifySeams, sendNotification } from "./dispatch.ts";
|
|
24
|
-
import { formatInputBody, formatJobBody, formatTurnBody, sessionLabel } from "./format.ts";
|
|
30
|
+
import { formatInputBody, formatJobBody, formatTurnBody, formatWatchBody, sessionLabel } from "./format.ts";
|
|
25
31
|
import type { NotifyConfig } from "./types.ts";
|
|
26
32
|
|
|
27
33
|
/** Host seams, injected by tests. */
|
|
@@ -37,12 +43,25 @@ function isJobFinished(value: unknown): value is JobFinishedEvent {
|
|
|
37
43
|
return typeof event.id === "string" && typeof event.label === "string" && typeof event.status === "string";
|
|
38
44
|
}
|
|
39
45
|
|
|
40
|
-
/**
|
|
46
|
+
/** Narrow an untrusted bus payload to a wake match. */
|
|
47
|
+
function isWatchMatch(value: unknown): value is WatchMatchEvent {
|
|
48
|
+
if (!value || typeof value !== "object") return false;
|
|
49
|
+
const event = value as Partial<WatchMatchEvent>;
|
|
50
|
+
return (
|
|
51
|
+
typeof event.count === "number" &&
|
|
52
|
+
typeof event.watcherId === "string" &&
|
|
53
|
+
typeof event.label === "string" &&
|
|
54
|
+
typeof event.line === "string"
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Register the notification triggers. */
|
|
41
59
|
export function registerNotify(pi: ExtensionAPI, deps: NotifyDeps = {}): void {
|
|
42
60
|
const seams = deps.seams ?? defaultSeams();
|
|
43
61
|
let config: NotifyConfig = DEFAULT_NOTIFY_CONFIG;
|
|
44
62
|
let busy = false;
|
|
45
|
-
|
|
63
|
+
/** Bus subscriptions: one per emitting extension, all created at session start. */
|
|
64
|
+
const unsubscribes: Array<() => void> = [];
|
|
46
65
|
|
|
47
66
|
const fire = (body: string): void => {
|
|
48
67
|
sendNotification(config, config.title, body, seams);
|
|
@@ -59,11 +78,17 @@ export function registerNotify(pi: ExtensionAPI, deps: NotifyDeps = {}): void {
|
|
|
59
78
|
pi.on("session_start", (_event, ctx) => {
|
|
60
79
|
config = loadNotifyConfig(ctx.cwd);
|
|
61
80
|
busy = false;
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
81
|
+
while (unsubscribes.length > 0) unsubscribes.pop()?.();
|
|
82
|
+
unsubscribes.push(
|
|
83
|
+
pi.events.on(JOB_FINISHED_CHANNEL, (data) => {
|
|
84
|
+
if (!config.jobFinished || !isJobFinished(data)) return;
|
|
85
|
+
fire(formatJobBody(data));
|
|
86
|
+
}),
|
|
87
|
+
pi.events.on(WATCH_MATCH_CHANNEL, (data) => {
|
|
88
|
+
if (!config.matchFound || !isWatchMatch(data)) return;
|
|
89
|
+
fire(formatWatchBody(data));
|
|
90
|
+
}),
|
|
91
|
+
);
|
|
67
92
|
});
|
|
68
93
|
|
|
69
94
|
pi.on("agent_settled", (event, ctx) => {
|
|
@@ -78,8 +103,7 @@ export function registerNotify(pi: ExtensionAPI, deps: NotifyDeps = {}): void {
|
|
|
78
103
|
});
|
|
79
104
|
|
|
80
105
|
pi.on("session_shutdown", () => {
|
|
81
|
-
|
|
82
|
-
unsubscribe = undefined;
|
|
106
|
+
while (unsubscribes.length > 0) unsubscribes.pop()?.();
|
|
83
107
|
busy = false;
|
|
84
108
|
});
|
|
85
109
|
}
|
|
@@ -21,6 +21,8 @@ export interface NotifyConfig {
|
|
|
21
21
|
needsInput: boolean;
|
|
22
22
|
/** Notify when a background `job` finishes. */
|
|
23
23
|
jobFinished: boolean;
|
|
24
|
+
/** Notify when a `watch` wake match fires while the agent is idle. */
|
|
25
|
+
matchFound: boolean;
|
|
24
26
|
/** Delivery method; `auto` chooses by terminal and platform. */
|
|
25
27
|
method: NotifyMethod;
|
|
26
28
|
/** Notification title, e.g. `Pi`. */
|
|
@@ -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,33 @@ 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
|
+
/** The rail's rule: the loader drops anything that is not one column. */
|
|
177
|
+
const RAIL_RULE: TextRule = {
|
|
178
|
+
maxLength: 4,
|
|
179
|
+
validate: (raw) =>
|
|
180
|
+
isRailGlyph(raw) ? undefined : "Use exactly one column: a single glyph, with no spaces or control characters.",
|
|
181
|
+
};
|
|
182
|
+
|
|
163
183
|
/** The v1 catalogue, in screen order. */
|
|
164
184
|
export const SETTING_ROWS: readonly SettingDescriptor[] = [
|
|
165
185
|
{
|
|
@@ -185,6 +205,54 @@ export const SETTING_ROWS: readonly SettingDescriptor[] = [
|
|
|
185
205
|
options: ON_OFF,
|
|
186
206
|
config: { section: "notify", path: ["enabled"] },
|
|
187
207
|
},
|
|
208
|
+
{
|
|
209
|
+
id: "notify.turnFinished",
|
|
210
|
+
group: "notifications",
|
|
211
|
+
label: "Turn finished",
|
|
212
|
+
description: "Announce it when a full agent run settles.",
|
|
213
|
+
source: "bytefly",
|
|
214
|
+
kind: "boolean",
|
|
215
|
+
fallback: "true",
|
|
216
|
+
json: "boolean",
|
|
217
|
+
options: ON_OFF,
|
|
218
|
+
config: { section: "notify", path: ["turnFinished"] },
|
|
219
|
+
},
|
|
220
|
+
{
|
|
221
|
+
id: "notify.needsInput",
|
|
222
|
+
group: "notifications",
|
|
223
|
+
label: "Needs input",
|
|
224
|
+
description: "Announce it when the agent opens a prompt and waits for an answer.",
|
|
225
|
+
source: "bytefly",
|
|
226
|
+
kind: "boolean",
|
|
227
|
+
fallback: "true",
|
|
228
|
+
json: "boolean",
|
|
229
|
+
options: ON_OFF,
|
|
230
|
+
config: { section: "notify", path: ["needsInput"] },
|
|
231
|
+
},
|
|
232
|
+
{
|
|
233
|
+
id: "notify.jobFinished",
|
|
234
|
+
group: "notifications",
|
|
235
|
+
label: "Job finished",
|
|
236
|
+
description: "Announce it when a background job or watcher settles.",
|
|
237
|
+
source: "bytefly",
|
|
238
|
+
kind: "boolean",
|
|
239
|
+
fallback: "true",
|
|
240
|
+
json: "boolean",
|
|
241
|
+
options: ON_OFF,
|
|
242
|
+
config: { section: "notify", path: ["jobFinished"] },
|
|
243
|
+
},
|
|
244
|
+
{
|
|
245
|
+
id: "notify.matchFound",
|
|
246
|
+
group: "notifications",
|
|
247
|
+
label: "Watcher matched",
|
|
248
|
+
description: "Announce it when a `watch` whose `wake` is on matches while the agent is idle.",
|
|
249
|
+
source: "bytefly",
|
|
250
|
+
kind: "boolean",
|
|
251
|
+
fallback: "true",
|
|
252
|
+
json: "boolean",
|
|
253
|
+
options: ON_OFF,
|
|
254
|
+
config: { section: "notify", path: ["matchFound"] },
|
|
255
|
+
},
|
|
188
256
|
{
|
|
189
257
|
id: "notify.method",
|
|
190
258
|
group: "notifications",
|
|
@@ -223,6 +291,19 @@ export const SETTING_ROWS: readonly SettingDescriptor[] = [
|
|
|
223
291
|
options: ON_OFF,
|
|
224
292
|
config: { section: "doctor", path: ["proactive"] },
|
|
225
293
|
},
|
|
294
|
+
{
|
|
295
|
+
id: "doctor.timeoutMs",
|
|
296
|
+
group: "diagnostics",
|
|
297
|
+
label: "Probe timeout",
|
|
298
|
+
description: "How long one doctor probe may take, in milliseconds; 100 to 30000.",
|
|
299
|
+
source: "bytefly",
|
|
300
|
+
kind: "text",
|
|
301
|
+
fallback: String(DEFAULT_DOCTOR_CONFIG.timeoutMs),
|
|
302
|
+
json: "number",
|
|
303
|
+
options: [],
|
|
304
|
+
text: integerRule(100, 30_000),
|
|
305
|
+
config: { section: "doctor", path: ["timeoutMs"] },
|
|
306
|
+
},
|
|
226
307
|
{
|
|
227
308
|
id: "price.currency.code",
|
|
228
309
|
group: "pricing",
|
|
@@ -262,11 +343,12 @@ export const SETTING_ROWS: readonly SettingDescriptor[] = [
|
|
|
262
343
|
id: "chrome.rail",
|
|
263
344
|
group: "transcript",
|
|
264
345
|
label: "Rail glyph",
|
|
265
|
-
description: "The single-column glyph that leads a tool result or notice body.",
|
|
346
|
+
description: "The single-column glyph that leads a tool result or notice body, e.g. ╎ │ ┃ ▏.",
|
|
266
347
|
source: "bytefly",
|
|
267
|
-
kind: "
|
|
348
|
+
kind: "text",
|
|
268
349
|
fallback: GLYPHS.divider,
|
|
269
|
-
options:
|
|
350
|
+
options: [],
|
|
351
|
+
text: RAIL_RULE,
|
|
270
352
|
config: { section: "chrome", path: ["rail"] },
|
|
271
353
|
},
|
|
272
354
|
{
|