@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
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
* 1. file edits that resolve outside the active worktree (absolute paths);
|
|
9
9
|
* 2. a shell command whose working directory leaves the worktree;
|
|
10
10
|
* 3. git commands redirected at the main checkout;
|
|
11
|
-
* 4. (optional) commands whose text cannot be statically verified
|
|
11
|
+
* 4. (optional) commands whose text cannot be statically verified;
|
|
12
|
+
* 5. the un-re-rooted `powershell` tool, which has no guard of its own.
|
|
12
13
|
*/
|
|
13
14
|
|
|
14
15
|
import { homedir } from "node:os";
|
|
@@ -224,8 +225,12 @@ function pathEscapes(root: string, raw: string): boolean {
|
|
|
224
225
|
return !isInside(root, resolve(root, expanded.path));
|
|
225
226
|
}
|
|
226
227
|
|
|
227
|
-
/**
|
|
228
|
-
|
|
228
|
+
/**
|
|
229
|
+
* Constructs whose text does not spell out the git command that runs. Exported
|
|
230
|
+
* so the `tool_call` handler counts exactly the commands this check would have
|
|
231
|
+
* refused, instead of guessing at a second list.
|
|
232
|
+
*/
|
|
233
|
+
export function isUnverifiable(command: string): boolean {
|
|
229
234
|
return /\$\(|`|\$\{!|\beval\b|\b(?:sh|bash|zsh|dash|ksh)\s+-c\b/.test(command);
|
|
230
235
|
}
|
|
231
236
|
|
|
@@ -360,6 +365,7 @@ function redirectionEscapes(root: string, command: string, config: WorktreeConfi
|
|
|
360
365
|
for (const segment of splitSegments(command)) {
|
|
361
366
|
for (const redirect of redirectionTargets(segment)) {
|
|
362
367
|
if (redirect.write ? !checkWrites : !checkReads) continue;
|
|
368
|
+
if (isNullDevice(redirect.target)) continue;
|
|
363
369
|
if (pathEscapes(root, redirect.target)) {
|
|
364
370
|
return `${redirect.write ? "Output" : "Input"} redirection to \"${redirect.target}\" leaves the active worktree.`;
|
|
365
371
|
}
|
|
@@ -368,6 +374,15 @@ function redirectionEscapes(root: string, command: string, config: WorktreeConfi
|
|
|
368
374
|
return undefined;
|
|
369
375
|
}
|
|
370
376
|
|
|
377
|
+
/**
|
|
378
|
+
* The null device discards the stream instead of writing a file, so a redirect
|
|
379
|
+
* there escapes nothing — and `2>/dev/null` is too common to refuse. Quotes are
|
|
380
|
+
* stripped because the target keeps them as written.
|
|
381
|
+
*/
|
|
382
|
+
function isNullDevice(raw: string): boolean {
|
|
383
|
+
return raw.replace(/^(['"])(.*)\1$/, "$2") === "/dev/null";
|
|
384
|
+
}
|
|
385
|
+
|
|
371
386
|
/** Commands that wrap another command, so a following assignment is still a prefix. */
|
|
372
387
|
const COMMAND_WRAPPERS = new Set(["sudo", "doas", "env", "command", "builtin", "exec", "nohup", "nice", "time"]);
|
|
373
388
|
|
|
@@ -532,6 +547,37 @@ function cdRedirect(root: string, words: ShellWord[]): string | undefined {
|
|
|
532
547
|
return undefined;
|
|
533
548
|
}
|
|
534
549
|
|
|
550
|
+
// ---------------------------------------------------------------------------
|
|
551
|
+
// The powershell tool (check 5)
|
|
552
|
+
// ---------------------------------------------------------------------------
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* Refuse the `powershell` tool while isolated.
|
|
556
|
+
*
|
|
557
|
+
* Pi's `powershell` tool is neither re-rooted nor guarded: it is not in
|
|
558
|
+
* `ROOT_TOOL_NAMES`, and its language is a second grammar — backtick escapes,
|
|
559
|
+
* `Set-Location`, `$env:GIT_DIR`, `--%` — so `analyzeBashCommand` would be
|
|
560
|
+
* guessing. Inside a worktree its relative paths therefore resolve at the main
|
|
561
|
+
* checkout, the checkout isolation exists to protect.
|
|
562
|
+
*
|
|
563
|
+
* Re-rooting it without a parser would be worse than refusing it: it would look
|
|
564
|
+
* isolated while every one of those forms still walks out. So the isolated
|
|
565
|
+
* session refuses the tool, the same answer plan mode gives, and
|
|
566
|
+
* `guard.blockPowershell: false` is the documented opt-out — which `gaps.ts`
|
|
567
|
+
* reports and `doctor` shows as `degraded`.
|
|
568
|
+
*/
|
|
569
|
+
export function guardPowershellTool(config: WorktreeConfig): GuardBlock | undefined {
|
|
570
|
+
if (!config.guard.blockPowershell) return undefined;
|
|
571
|
+
return {
|
|
572
|
+
block: true,
|
|
573
|
+
reason:
|
|
574
|
+
"Refusing to run powershell inside an isolated worktree: it is not re-rooted or guarded, " +
|
|
575
|
+
"so relative paths and git would run against the main checkout. " +
|
|
576
|
+
"Use bash, which is re-rooted and guarded, or exit the worktree first. " +
|
|
577
|
+
"(worktree.guard.blockPowershell turns this refusal off.)",
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
|
|
535
581
|
/** Checks 2-4 for a bash command while isolated. */
|
|
536
582
|
export function analyzeBashCommand(command: string, root: string, config: WorktreeConfig): GuardBlock | undefined {
|
|
537
583
|
if (config.guard.blockGitRedirects) {
|
|
@@ -553,7 +599,7 @@ export function analyzeBashCommand(command: string, root: string, config: Worktr
|
|
|
553
599
|
return { block: true, reason: `${redirect} Exit the worktree to operate on the main checkout.` };
|
|
554
600
|
}
|
|
555
601
|
|
|
556
|
-
if (config.guard.blockUnparsableCommands &&
|
|
602
|
+
if (config.guard.blockUnparsableCommands && isUnverifiable(command)) {
|
|
557
603
|
return {
|
|
558
604
|
block: true,
|
|
559
605
|
reason:
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The handoff an exit leaves behind: what the branch holds, and the command that
|
|
3
|
+
* finishes the task.
|
|
4
|
+
*
|
|
5
|
+
* `AGENTS.md` ends a worktree task with a merge into the main checkout, or a pull
|
|
6
|
+
* request. The exit already decides the branch's fate and keeps one that holds
|
|
7
|
+
* commits; this says how many, whether the default branch already contains them,
|
|
8
|
+
* and the exact commands — so the next step is read, not remembered.
|
|
9
|
+
*
|
|
10
|
+
* Pure: the caller has the facts, this shapes them. The tool never commits, merges,
|
|
11
|
+
* pushes, or opens a pull request, because each of those acts on a checkout the
|
|
12
|
+
* session is no longer isolated in.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** What a leaving worktree hands back to the main checkout. */
|
|
16
|
+
export interface Handoff {
|
|
17
|
+
/** The worktree branch, when the session had one. */
|
|
18
|
+
branch: string | null;
|
|
19
|
+
/** The branch a merge would target, when it could be resolved. */
|
|
20
|
+
base: string | null;
|
|
21
|
+
/** Commits on the branch that the base does not contain. */
|
|
22
|
+
ahead: number;
|
|
23
|
+
/** Whether the base already contains every commit on the branch. */
|
|
24
|
+
merged: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Pluralized commit count, e.g. `1 commit` / `3 commits`. */
|
|
28
|
+
function commits(count: number): string {
|
|
29
|
+
return `${count} commit${count === 1 ? "" : "s"}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The `Handoff:` block: one headline and, only when there is something to do, the
|
|
34
|
+
* two commands. A merged or empty branch prints no command — inviting a merge of
|
|
35
|
+
* work that is already in the base is how a branch gets merged twice.
|
|
36
|
+
*/
|
|
37
|
+
export function handoffLines(handoff: Handoff): string[] {
|
|
38
|
+
if (!handoff.branch) return ["Handoff: the worktree had no branch to hand off."];
|
|
39
|
+
|
|
40
|
+
if (handoff.base === null) {
|
|
41
|
+
return [
|
|
42
|
+
`Handoff: branch ${handoff.branch} holds ${commits(handoff.ahead)}; its base branch could not be resolved.`,
|
|
43
|
+
];
|
|
44
|
+
}
|
|
45
|
+
if (handoff.merged || handoff.ahead === 0) {
|
|
46
|
+
const state = handoff.merged ? `is merged into ${handoff.base}` : `has no commits beyond ${handoff.base}`;
|
|
47
|
+
return [`Handoff: ${handoff.branch} ${state} — nothing to merge.`];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return [
|
|
51
|
+
`Handoff: ${handoff.branch} is ${commits(handoff.ahead)} ahead of ${handoff.base}.`,
|
|
52
|
+
` merge: git switch ${handoff.base} && git merge --no-ff ${handoff.branch}`,
|
|
53
|
+
` or PR: gh pr create --head ${handoff.branch} --fill`,
|
|
54
|
+
];
|
|
55
|
+
}
|
|
@@ -26,7 +26,7 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
|
26
26
|
import { isExtensionEnabled } from "../../lib/env.ts";
|
|
27
27
|
import { GLYPHS } from "../../lib/tui/glyphs.ts";
|
|
28
28
|
import { canonicalize, checkCheckout, repoRoot, unlockWorktree } from "./git.ts";
|
|
29
|
-
import { analyzeBashCommand, guardFileTool } from "./guard.ts";
|
|
29
|
+
import { analyzeBashCommand, guardFileTool, guardPowershellTool, isUnverifiable } from "./guard.ts";
|
|
30
30
|
import {
|
|
31
31
|
enterWorktree,
|
|
32
32
|
findInactiveOverrides,
|
|
@@ -50,7 +50,9 @@ import {
|
|
|
50
50
|
getInactiveOverrides,
|
|
51
51
|
getRoot,
|
|
52
52
|
getSkipOverrides,
|
|
53
|
+
noteUnverifiableCommand,
|
|
53
54
|
publishWorktree,
|
|
55
|
+
resetDiagnostics,
|
|
54
56
|
setActive,
|
|
55
57
|
setBaseCwd,
|
|
56
58
|
setInactiveOverrides,
|
|
@@ -88,6 +90,7 @@ export function registerWorktree(pi: ExtensionAPI, { entryPath, createBuiltin }:
|
|
|
88
90
|
setBaseCwd(ctx.cwd);
|
|
89
91
|
setActive(null);
|
|
90
92
|
clearConfigCache();
|
|
93
|
+
resetDiagnostics();
|
|
91
94
|
|
|
92
95
|
// A child `pi` process (subagent) inherits an active worktree. Bind to
|
|
93
96
|
// it without creating, locking, or persisting anything; the parent owns
|
|
@@ -203,8 +206,15 @@ export function registerWorktree(pi: ExtensionAPI, { entryPath, createBuiltin }:
|
|
|
203
206
|
const config = configFor(state.repoRoot);
|
|
204
207
|
if (event.toolName === "bash") {
|
|
205
208
|
const command = (event.input as { command?: string }).command ?? "";
|
|
206
|
-
|
|
209
|
+
const block = analyzeBashCommand(command, state.path, config);
|
|
210
|
+
// A command the guard lets through unverified is the one gap the transcript
|
|
211
|
+
// cannot show, so count it for `/worktree` instead of losing it.
|
|
212
|
+
if (!block && isUnverifiable(command)) noteUnverifiableCommand();
|
|
213
|
+
return block;
|
|
207
214
|
}
|
|
215
|
+
// The Windows shell has no re-rooting and no guard, so it is refused rather
|
|
216
|
+
// than checked; the switch that allows it is reported as a gap.
|
|
217
|
+
if (event.toolName === "powershell") return guardPowershellTool(config);
|
|
208
218
|
return guardFileTool(event.toolName, event.input, state.path, config);
|
|
209
219
|
});
|
|
210
220
|
|
|
@@ -29,6 +29,8 @@ let active: WorktreeState | null = null;
|
|
|
29
29
|
let baseCwd = process.cwd();
|
|
30
30
|
let inactiveOverrides: string[] = [];
|
|
31
31
|
let gitignoreOffered = false;
|
|
32
|
+
/** Commands this session let through although the guard could not verify them. */
|
|
33
|
+
let unverifiableCommands = 0;
|
|
32
34
|
const configCache = new Map<string, WorktreeConfig>();
|
|
33
35
|
|
|
34
36
|
export function getActive(): WorktreeState | null {
|
|
@@ -68,6 +70,24 @@ export function markGitignoreOffered(): void {
|
|
|
68
70
|
gitignoreOffered = true;
|
|
69
71
|
}
|
|
70
72
|
|
|
73
|
+
/**
|
|
74
|
+
* Count a bash command the guard let through without being able to verify it.
|
|
75
|
+
* Session-scoped on purpose: it is the one gap a user cannot see in the
|
|
76
|
+
* transcript, so `/worktree` reports how often it happened.
|
|
77
|
+
*/
|
|
78
|
+
export function noteUnverifiableCommand(): void {
|
|
79
|
+
unverifiableCommands += 1;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function getUnverifiableCommands(): number {
|
|
83
|
+
return unverifiableCommands;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Forget the previous session's diagnostics; called from `session_start`. */
|
|
87
|
+
export function resetDiagnostics(): void {
|
|
88
|
+
unverifiableCommands = 0;
|
|
89
|
+
}
|
|
90
|
+
|
|
71
91
|
/** Cached config for the hot `tool_call` path; cleared on enter and exit. */
|
|
72
92
|
export function configFor(root: string): WorktreeConfig {
|
|
73
93
|
let config = configCache.get(root);
|
|
@@ -6,9 +6,10 @@
|
|
|
6
6
|
import { basename } from "node:path";
|
|
7
7
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
8
8
|
import { loadConfig } from "./config.ts";
|
|
9
|
+
import { gapLines, isolationGaps } from "./gaps.ts";
|
|
9
10
|
import { canonicalize, type CheckoutCheck, defaultBranch, listManagedWorktrees, repoRoot } from "./git.ts";
|
|
10
11
|
import { reconcileRegistry, statusOf, type CheckoutStatus } from "./registry.ts";
|
|
11
|
-
import { getActive, getInactiveOverrides } from "./runtime.ts";
|
|
12
|
+
import { configFor, getActive, getInactiveOverrides, getUnverifiableCommands } from "./runtime.ts";
|
|
12
13
|
import type { WorktreeState } from "./state.ts";
|
|
13
14
|
|
|
14
15
|
export function worktreeLabel(state: WorktreeState): string {
|
|
@@ -79,7 +80,7 @@ function relativeDay(at: number): string {
|
|
|
79
80
|
return `${days}d ago`;
|
|
80
81
|
}
|
|
81
82
|
|
|
82
|
-
/** Human-readable status: current worktree, override conflicts, managed worktrees. */
|
|
83
|
+
/** Human-readable status: current worktree, override conflicts, gaps, managed worktrees. */
|
|
83
84
|
export async function worktreeStatus(pi: ExtensionAPI, ctx: ExtensionContext): Promise<string> {
|
|
84
85
|
const current = getActive();
|
|
85
86
|
const lines: string[] = [];
|
|
@@ -93,6 +94,18 @@ export async function worktreeStatus(pi: ExtensionAPI, ctx: ExtensionContext): P
|
|
|
93
94
|
if (inactive.length > 0) {
|
|
94
95
|
lines.push("", `Isolation not active for: ${inactive.join(", ")} (their default implementation is in use).`);
|
|
95
96
|
}
|
|
97
|
+
if (current) {
|
|
98
|
+
// What isolation does not cover, from the same rules `doctor` reports: a gap
|
|
99
|
+
// the user cannot see is a gap they cannot weigh.
|
|
100
|
+
const gaps = isolationGaps({
|
|
101
|
+
platform: process.platform,
|
|
102
|
+
activeTools: pi.getActiveTools(),
|
|
103
|
+
config: configFor(current.repoRoot),
|
|
104
|
+
unverifiableCommands: getUnverifiableCommands(),
|
|
105
|
+
});
|
|
106
|
+
const gapBlock = gapLines(gaps);
|
|
107
|
+
if (gapBlock.length > 0) lines.push("", "Not isolated:", ...gapBlock);
|
|
108
|
+
}
|
|
96
109
|
|
|
97
110
|
const root = current?.repoRoot ?? (await repoRoot(pi, ctx.cwd));
|
|
98
111
|
if (root) {
|
|
@@ -75,7 +75,8 @@ export function registerTools(pi: ExtensionAPI): void {
|
|
|
75
75
|
label: "Exit worktree",
|
|
76
76
|
description:
|
|
77
77
|
"Leave the current git worktree and return to the main checkout. The worktree is removed when clean; with " +
|
|
78
|
-
"uncommitted changes or new commits the user chooses whether to keep it."
|
|
78
|
+
"uncommitted changes or new commits the user chooses whether to keep it. The result names the branch's " +
|
|
79
|
+
"commits and the merge or pull-request command that finishes it.",
|
|
79
80
|
promptSnippet: "Leave the current git worktree and return to the main checkout.",
|
|
80
81
|
promptGuidelines: [
|
|
81
82
|
"Use exit_worktree after work started with enter_worktree; it is the only way back to the main checkout.",
|
|
@@ -93,7 +94,7 @@ export function registerTools(pi: ExtensionAPI): void {
|
|
|
93
94
|
ctx.ui.notify(`Exited worktree ${worktreeLabel(result.state)}`, "info");
|
|
94
95
|
return {
|
|
95
96
|
content: [{ type: "text", text: result.output }],
|
|
96
|
-
details: { state: result.state, removed: result.removed },
|
|
97
|
+
details: { state: result.state, removed: result.removed, handoff: result.handoff },
|
|
97
98
|
};
|
|
98
99
|
} catch (error) {
|
|
99
100
|
if (isWorktreeCancelled(error)) {
|
package/lib/README.md
CHANGED
|
@@ -19,19 +19,20 @@ belongs in that extension instead — see `extensions/web-access/http.ts` and
|
|
|
19
19
|
| [`config.ts`](./config.ts) | Config loading, two ways: `loadConfigFile` for a whole file, and the shared `bytefly.json` (`SHARED_CONFIG_FILE`) via `configSection` / `configValueAt` / `loadConfigSection` (global, then project), plus the atomic `writeConfigFile` / `writeConfigSection` / `writeConfigValue` / `writeConfigValueAt` (the last two edit one leaf, flat or nested, preserving siblings and metadata). Includes `clampInteger` / `clampNumber` / `cleanString` / `asRecord` / `readJson` / `readJsonResult`. |
|
|
20
20
|
| [`env.ts`](./env.ts) | Env-var contracts shared across extensions: the worktree environment (`ENV_ROOT`, `worktreeRoot`, `resolveEffectiveCwd`) used by worktree, file-browse, job, and rewind, plus `isExtensionEnabled` / `ENV_DISABLED_EXTENSIONS` for the package-wide disable list. |
|
|
21
21
|
| [`escape.ts`](./escape.ts) | `escapeHtml`: the single HTML text/attribute escape used by the browse page renderers and the Markdown and syntax highlighters. |
|
|
22
|
-
| [`events.ts`](./events.ts) | Inter-extension `pi.events` channels and payloads: `JOB_FINISHED_CHANNEL`
|
|
22
|
+
| [`events.ts`](./events.ts) | Inter-extension `pi.events` channels and payloads: `JOB_FINISHED_CHANNEL` / `JobFinishedEvent` (published by `job`, consumed by bytefly's notify capability), `SUBAGENT_FINISHED_CHANNEL` / `SubagentFinishedEvent` (published by `subagent`, consumed by bytefly's footer roll-up), and `WEB_SEARCH_PROVIDER_CHANNEL` / `WebSearchProviderEvent` (published by `web-access` at session start, consumed by bytefly's doctor so it can report the resolved provider without importing the extension — the key itself is never in the payload). |
|
|
23
23
|
| [`notice.ts`](./notice.ts) | The shared transcript notice: `NOTICE_TYPE`, the `NoticeDetails` / `NoticeItem` shape, `noticeMessage` (the `display: true` message both send paths accept), `isNotice`, and `pruneStaleNotices` — posted by `job` and `watch`, rendered by bytefly. |
|
|
24
24
|
| [`format.ts`](./format.ts) | Pure text/number formatting shared by renderers: `formatTokens`, `formatCount`, `formatDuration` (`450ms`, `3.2s`, `1m05s`, `2h03m`), `stripControlChars`, `sanitize`, `sanitizeLine` — the boundary for untrusted, single-line text — and `shortenPath`. |
|
|
25
25
|
| [`interaction.ts`](./interaction.ts) | Turn-affinity prompt contract: the channel a remote transport marks on the UI context it owns (`INTERACTION_TURN_KEY`), the `InteractionChannel` interface (`request` / `post` / `postFile`), and `remoteChannel` / `isRemoteTurn` / `supportsCustomUI` / `askHuman` (with an optional file, fallback text, and digest preface) plus the shared “Other” label. |
|
|
26
26
|
| [`git/`](./git/) | Shared git plumbing. [`git/runner.ts`](./git/runner.ts) is the `RunGit` seam (`createExecRunner` with timeout/env handling, `runGitOrThrow`, `GitError`); [`git/read.ts`](./git/read.ts) holds the read helpers (`repoRoot`, `repoRootFor`, `gitDir`, `revParse`, `hasCommits`, `currentBranch`); [`git/index.ts`](./git/index.ts) is the barrel. |
|
|
27
27
|
| [`highlight.ts`](./highlight.ts) | First-party, escape-first syntax tokenizer (`detectLanguage`, `fenceLanguage`, `highlightLines`, `HighlightLanguage`, `TokenClass`) for js/ts, json, css, html, shell, python, java, yaml, and sql; shared by `file-browse` and plan's code blocks. |
|
|
28
|
-
| [`tui/`](./tui/) | The Bytefly terminal design system shared by every interactive surface: [`tui/tokens.ts`](./tui/tokens.ts) (semantic role -> theme color, `paint`), [`tui/glyphs.ts`](./tui/glyphs.ts) (`GLYPHS`, `SEPARATORS`, `STATUS_KEYS`, `EXPAND_KEYBINDING` with `expandKey` / `expandHint`, `GLYPH_GAP` — the vocabulary both sides of a status must agree on), [`tui/chrome.ts`](./tui/chrome.ts) (`screenHint`, `dockListFrame` (the docked-list frame), `viewportRows`, `FULL_SCREEN_OVERLAY`, `reuseText`, `editorTheme`), [`tui/rules.ts`](./tui/rules.ts) (the rule glyph `RULE_GLYPH`, and `plainRule` / `titleRule` / `centeredRule`), [`tui/indent.ts`](./tui/indent.ts) (the transcript block indent: `BODY_INDENT`, an optional rail prefix that a wrapped call line is kept out of, and `bodyLines` / `indentBlockLines` / `indentedLines`), [`tui/rows.ts`](./tui/rows.ts) (`listRow`, `checkbox`, `chip`, `keyValueRow`, `emptyState`), [`tui/columns.ts`](./tui/columns.ts) (`splitRow`, `fitCells`, `flowCells`, `padTo`, `clip`, `clipMiddle`), [`tui/gauge.ts`](./tui/gauge.ts) (`computeGauge`, `gaugeRole`, `gaugeBar`), [`tui/line-pane.ts`](./tui/line-pane.ts) (`LinePane`, the scrollable log/match pane), [`tui/status-controller.ts`](./tui/status-controller.ts) (`createStatusController`, the footer chips `job` and `watch` share), [`tui/tool.ts`](./tui/tool.ts) (`toolCallTitle`, the width-safe `ToolCallText` / `reuseCallText`, sanitized `errorLine` / `rejectionLine`, `moreHint`, `syncElapsedTimer`, `elapsedSuffix` — the tool-transcript kit), [`tui/render-cache.ts`](./tui/render-cache.ts) (`RenderCache`: the per-instance line memo a component that derives its lines from another component's output renders through, so a repeat frame is one comparison), and [`tui/cursor.ts`](./tui/cursor.ts) (scroll and navigation: `fitRows`, `keepVisible`, `clampScroll`, `navIntent`, `navigateList`, `navigateScroll`, `wheelDelta`, `scrollList`, `isCloseKey`, `selectionMarker`, `formatRange`, and the `ListCursor` (by index) and `IdCursor` (by stable id) state). |
|
|
28
|
+
| [`tui/`](./tui/) | The Bytefly terminal design system shared by every interactive surface, whose conventions are its own doc, [`tui/README.md`](./tui/README.md). Modules: [`tui/tokens.ts`](./tui/tokens.ts) (semantic role -> theme color, `paint`), [`tui/glyphs.ts`](./tui/glyphs.ts) (`GLYPHS`, `SEPARATORS`, `STATUS_KEYS`, `EXPAND_KEYBINDING` with `expandKey` / `expandHint`, `GLYPH_GAP` — the vocabulary both sides of a status must agree on), [`tui/chrome.ts`](./tui/chrome.ts) (`screenHint`, `dockListFrame` (the docked-list frame), `viewportRows`, `FULL_SCREEN_OVERLAY`, `reuseText`, `editorTheme`), [`tui/rules.ts`](./tui/rules.ts) (the rule glyph `RULE_GLYPH`, and `plainRule` / `titleRule` / `centeredRule`), [`tui/indent.ts`](./tui/indent.ts) (the transcript block indent: `BODY_INDENT`, an optional rail prefix that a wrapped call line is kept out of, and `bodyLines` / `indentBlockLines` / `indentedLines`), [`tui/rows.ts`](./tui/rows.ts) (`listRow`, `checkbox`, `chip`, `keyValueRow`, `emptyState`), [`tui/columns.ts`](./tui/columns.ts) (`splitRow`, `fitCells`, `flowCells`, `padTo`, `clip`, `clipMiddle`), [`tui/gauge.ts`](./tui/gauge.ts) (`computeGauge`, `gaugeRole`, `gaugeBar`), [`tui/line-pane.ts`](./tui/line-pane.ts) (`LinePane`, the scrollable log/match pane), [`tui/status-controller.ts`](./tui/status-controller.ts) (`createStatusController`, the footer chips `job` and `watch` share), [`tui/tool.ts`](./tui/tool.ts) (`toolCallTitle`, the width-safe `ToolCallText` / `reuseCallText`, sanitized `errorLine` / `rejectionLine`, `moreHint`, `syncElapsedTimer`, `elapsedSuffix` — the tool-transcript kit), [`tui/render-cache.ts`](./tui/render-cache.ts) (`RenderCache`: the per-instance line memo a component that derives its lines from another component's output renders through, so a repeat frame is one comparison), and [`tui/cursor.ts`](./tui/cursor.ts) (scroll and navigation: `fitRows`, `keepVisible`, `clampScroll`, `navIntent`, `navigateList`, `navigateScroll`, `wheelDelta`, `scrollList`, `isCloseKey`, `selectionMarker`, `formatRange`, and the `ListCursor` (by index) and `IdCursor` (by stable id) state). |
|
|
29
29
|
| [`path.ts`](./path.ts) | `isInside` (string containment) plus `realPathOfNearest` / `isInsideReal` (symlink-aware): the containment predicates the file-browse, worktree, and doc guards share. |
|
|
30
30
|
| [`pi-invocation.ts`](./pi-invocation.ts) | `getPiInvocation`: how to launch a nested `pi` process, shared by `subagent` and bytefly's doctor surface. |
|
|
31
31
|
| [`policy.ts`](./policy.ts) | `createReadOnlyPolicy`: default-deny tool classification from `readOnlyHint` + explicit allow/deny. |
|
|
32
32
|
| [`rails.ts`](./rails.ts) | Above-editor widget ordering (`goal` → `todo`) and dock-screen suppression, carried on `pi.events`. |
|
|
33
|
-
| [`requirements.ts`](./requirements.ts) | Per-extension runtime prerequisite table plus a pure `probeExtension(seams)` over injected seams (exec, config reads, optional imports): bytefly's doctor surface reports on it and the extensions that own a dependency format their errors from it. |
|
|
33
|
+
| [`requirements.ts`](./requirements.ts) | Per-extension runtime prerequisite table plus a pure `probeExtension(seams)` over injected seams (exec, config reads, optional imports, the published search provider, env, and a refresh-only reachability probe): bytefly's doctor surface reports on it and the extensions that own a dependency format their errors from it. A check kind is either a prerequisite (`binary`, `module`, `state`, `invocation`, `notify`, `provider`) or a `safety` switch whose off value trades a protection away, read from `bytefly.json` global-then-project. |
|
|
34
34
|
| [`shell.ts`](./shell.ts) | Shell escape rule shared by the worktree isolation guard and plan mode's read-only git guard: `SHELL_ESCAPABLE`, `isShellEscapable`, `hasShellEscape`. |
|
|
35
|
+
| [`goal-state.ts`](./goal-state.ts) | The published `goal` state replayed from the session branch: `PublishedGoal`, `GOAL_ENTRY_TYPE`, `MAX_GOAL_OBJECTIVE`, and `lastGoal(entries)` (the last valid goal, skipping a rejected call and honouring an explicit clear), so `plan` can restate an active goal while planning and offer to record one on approval — with no extension importing another's modules. |
|
|
35
36
|
| [`todo-state.ts`](./todo-state.ts) | The published `todo` state replayed from the session branch: `ExistingTodo` and `lastTodos(entries)` (the last valid list, skipping a rejected call), so `plan` can merge an approved plan's steps into it and `goal` can name the items still open when a goal is marked achieved — with no extension importing another's modules. |
|
|
36
37
|
| [`tool-names.ts`](./tool-names.ts) | Tool names and one parameter name that more than one extension must agree on, so an orchestrating `ctx.executeTool()` call or schema probe breaks the build on a rename. |
|
|
37
38
|
| [`web/`](./web/) | The Bytefly web brand system shared by the browser surfaces: [`web/tokens.ts`](./web/tokens.ts) (palette derived from the Bytefly terminal themes, scales, and the `tok-*` map), [`web/base.ts`](./web/base.ts), [`web/components.ts`](./web/components.ts), [`web/layout.ts`](./web/layout.ts), [`web/runtime.ts`](./web/runtime.ts) (`WEB_THEME_STORAGE_KEY`, `THEME_JS`, `THEME_TOGGLE_JS`), [`web/markup.ts`](./web/markup.ts), and the [`web/index.ts`](./web/index.ts) barrel. |
|
|
@@ -47,8 +48,9 @@ belongs in that extension instead — see `extensions/web-access/http.ts` and
|
|
|
47
48
|
glyph and the `setStatus` key are one contract, not two literals. Tool names
|
|
48
49
|
shared by an orchestrating tool and the tool it calls — and parameter names
|
|
49
50
|
one extension probes on another — live in `tool-names.ts`.
|
|
50
|
-
- **A color is a role, not a token.**
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
Pi's tool-rendering vocabulary
|
|
54
|
-
those tokens style a tool
|
|
51
|
+
- **A color is a role, not a token.** The rules and the primitives are in
|
|
52
|
+
[`tui/README.md`](./tui/README.md#color-is-a-role): draw through
|
|
53
|
+
[`tui/tokens.ts`](./tui/tokens.ts) (`paint(theme, role, text)`) so a theme swap
|
|
54
|
+
re-skins every surface. The one exception is Pi's tool-rendering vocabulary
|
|
55
|
+
(`toolOutput`, `toolTitle`), which stays direct: those tokens style a tool
|
|
56
|
+
block, and no role maps to them.
|
package/lib/events.ts
CHANGED
|
@@ -10,7 +10,9 @@
|
|
|
10
10
|
*
|
|
11
11
|
* The bus carries no state. `rails.ts` and `worktree` publish their own channels
|
|
12
12
|
* from the modules that own them; a channel crosses an extension boundary only
|
|
13
|
-
* when the payload is the whole contract, which is the case here
|
|
13
|
+
* when the payload is the whole contract, which is the case here: `job` publishes
|
|
14
|
+
* a finished job for bytefly's notify capability, and `subagent` publishes a
|
|
15
|
+
* finished child run for bytefly's footer roll-up.
|
|
14
16
|
*/
|
|
15
17
|
|
|
16
18
|
/** EventBus channel: a background job reached a terminal state. */
|
|
@@ -25,6 +27,89 @@ export const JOB_FINISHED_CHANNEL = "my-pi-agent/job-finished";
|
|
|
25
27
|
*/
|
|
26
28
|
export type FinishedJobStatus = "exited" | "failed" | "killed" | "unknown";
|
|
27
29
|
|
|
30
|
+
/** EventBus channel: a `watch` wake match fired while the agent was idle. */
|
|
31
|
+
export const WATCH_MATCH_CHANNEL = "my-pi-agent/watch-match";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Payload on {@link WATCH_MATCH_CHANNEL}: what a wake match saw.
|
|
35
|
+
*
|
|
36
|
+
* Emitted by `watch` and consumed by bytefly's notify capability, so the two can
|
|
37
|
+
* agree on an alert without `watch` learning about delivery methods. The line is
|
|
38
|
+
* **raw** — the matched text is arbitrary file or process output — and the
|
|
39
|
+
* consumer sanitizes it before it reaches a notification, exactly as it does for a
|
|
40
|
+
* job label. `count` covers the whole wake cycle, so one notification can say how
|
|
41
|
+
* many lines matched while the body describes the newest one.
|
|
42
|
+
*/
|
|
43
|
+
export interface WatchMatchEvent {
|
|
44
|
+
/** How many matches were pending when the wake fired. */
|
|
45
|
+
count: number;
|
|
46
|
+
/** The watcher that matched, e.g. `w3`. */
|
|
47
|
+
watcherId: string;
|
|
48
|
+
/** Its label, e.g. `build`. */
|
|
49
|
+
label: string;
|
|
50
|
+
/** The newest matched line, unsanitized. */
|
|
51
|
+
line: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** EventBus channel: one subagent run finished (a child `pi` process exited). */
|
|
55
|
+
export const SUBAGENT_FINISHED_CHANNEL = "my-pi-agent/subagent-finished";
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Payload on {@link SUBAGENT_FINISHED_CHANNEL}: what one finished child run spent.
|
|
59
|
+
*
|
|
60
|
+
* It carries the *pricing inputs* rather than a total, because the consumer prices
|
|
61
|
+
* them with the same rate table and currency as the session's own cost, and a
|
|
62
|
+
* currency change must re-price them rather than leave a figure in the old unit.
|
|
63
|
+
* `recordedCost` is Pi's own dollar figure for the child's assistant calls.
|
|
64
|
+
*
|
|
65
|
+
* A run that produced no assistant message (an unknown agent, a spawn failure)
|
|
66
|
+
* publishes nothing: there is no spend to account for.
|
|
67
|
+
*/
|
|
68
|
+
export interface SubagentFinishedEvent {
|
|
69
|
+
/** Agent that ran, e.g. `explorer`. */
|
|
70
|
+
agent: string;
|
|
71
|
+
/** Provider and model the child reported, when it reported one. */
|
|
72
|
+
provider: string | null;
|
|
73
|
+
model: string | null;
|
|
74
|
+
input: number;
|
|
75
|
+
output: number;
|
|
76
|
+
cacheRead: number;
|
|
77
|
+
cacheWrite: number;
|
|
78
|
+
/** Pi's recorded cost for the child's calls, in USD. */
|
|
79
|
+
recordedCost: number;
|
|
80
|
+
/** Epoch ms when the run finished, or `null` when unknown. */
|
|
81
|
+
at: number | null;
|
|
82
|
+
/** Child process exit code; a model-level failure still exits 0. */
|
|
83
|
+
exitCode: number;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** EventBus channel: `web-access` resolved its search provider for this session. */
|
|
87
|
+
export const WEB_SEARCH_PROVIDER_CHANNEL = "my-pi-agent/web-search-provider";
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Payload on {@link WEB_SEARCH_PROVIDER_CHANNEL}: what `web_search` will use.
|
|
91
|
+
*
|
|
92
|
+
* `web-access` owns the provider registry, each provider's `isConfigured`, and the
|
|
93
|
+
* `auto` resolution rule, so it is the only place those facts can come from: this
|
|
94
|
+
* event is how `doctor` learns them without importing an extension. The key
|
|
95
|
+
* itself is never published — only the variable name and whether a literal key is
|
|
96
|
+
* configured — so a diagnostic can never leak a secret into a log or a notice.
|
|
97
|
+
*/
|
|
98
|
+
export interface WebSearchProviderEvent {
|
|
99
|
+
/** Resolved backend id, e.g. `searxng`. */
|
|
100
|
+
provider: string;
|
|
101
|
+
/** Why this provider won: an explicit choice, one that is configured, or the keyless default. */
|
|
102
|
+
reason: "explicit" | "configured" | "default";
|
|
103
|
+
/** Whether the resolved provider's configuration is enough to call it. */
|
|
104
|
+
configured: boolean;
|
|
105
|
+
/** URL a refresh may probe, when the provider has a configurable endpoint. */
|
|
106
|
+
endpoint: string | null;
|
|
107
|
+
/** Environment variable a key would come from, when the provider takes one. */
|
|
108
|
+
apiKeyEnv: string | null;
|
|
109
|
+
/** Whether a literal key is configured, so a missing variable is not the whole story. */
|
|
110
|
+
keyConfigured: boolean;
|
|
111
|
+
}
|
|
112
|
+
|
|
28
113
|
/** Payload on {@link JOB_FINISHED_CHANNEL}: one job that just stopped. */
|
|
29
114
|
export interface JobFinishedEvent {
|
|
30
115
|
/** Job id, e.g. `j3`. */
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The published goal state, read back from the session branch (pure).
|
|
3
|
+
*
|
|
4
|
+
* `goal` publishes its objective two ways: every `goal` tool result carries the
|
|
5
|
+
* goal in `details` — a rejected call carries the unchanged goal *and* an
|
|
6
|
+
* `error`, which is not a write — and the `/goal` command appends a `goal` custom
|
|
7
|
+
* entry whose `data.goal` is `null` when the goal is cleared. Replaying the branch
|
|
8
|
+
* in order is how another extension reads the goal without importing goal's
|
|
9
|
+
* modules: `plan` restates an active goal in its planning context and offers to
|
|
10
|
+
* record one when a plan is approved with none.
|
|
11
|
+
*
|
|
12
|
+
* Stored values are re-validated and re-sanitized here. Branch data can be
|
|
13
|
+
* tampered with, migrated, or written by an older version, and a reader must not
|
|
14
|
+
* pass escapes or unbounded text onward; anything that fails validation is
|
|
15
|
+
* ignored rather than clearing a goal that is still valid.
|
|
16
|
+
*
|
|
17
|
+
* The entry type and the objective limit live here and are imported by `goal`, so
|
|
18
|
+
* the publisher and the reader cannot drift. `goal` keeps its own reconstruction
|
|
19
|
+
* (`extensions/goal/state.ts`) because it also drives the widget; this module is
|
|
20
|
+
* the minimal contract other extensions may depend on.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { sanitizeLine } from "./format.ts";
|
|
24
|
+
import { GOAL_TOOL } from "./tool-names.ts";
|
|
25
|
+
|
|
26
|
+
/** Custom-entry type `goal` uses to persist a goal set outside the tool. */
|
|
27
|
+
export const GOAL_ENTRY_TYPE = "goal";
|
|
28
|
+
|
|
29
|
+
/** Longest objective the goal tool accepts, and the most a reader will replay. */
|
|
30
|
+
export const MAX_GOAL_OBJECTIVE = 2000;
|
|
31
|
+
|
|
32
|
+
/** Whether the goal is still being pursued, or already done. */
|
|
33
|
+
export type PublishedGoalStatus = "active" | "achieved";
|
|
34
|
+
|
|
35
|
+
/** The goal as another extension may read it. */
|
|
36
|
+
export interface PublishedGoal {
|
|
37
|
+
/** The objective, sanitized to a single line. */
|
|
38
|
+
objective: string;
|
|
39
|
+
status: PublishedGoalStatus;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Minimal shape of a session entry, used for runtime narrowing. */
|
|
43
|
+
interface BranchEntryLike {
|
|
44
|
+
type?: string;
|
|
45
|
+
customType?: string;
|
|
46
|
+
data?: unknown;
|
|
47
|
+
message?: {
|
|
48
|
+
role?: string;
|
|
49
|
+
toolName?: string;
|
|
50
|
+
details?: unknown;
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* A stored goal value: `undefined` when it cannot be read, `null` for an explicit
|
|
56
|
+
* clear, otherwise the validated goal.
|
|
57
|
+
*/
|
|
58
|
+
function readGoal(value: unknown): PublishedGoal | null | undefined {
|
|
59
|
+
if (value === null) return null;
|
|
60
|
+
if (!value || typeof value !== "object") return undefined;
|
|
61
|
+
const goal = value as { objective?: unknown; status?: unknown };
|
|
62
|
+
if (typeof goal.objective !== "string") return undefined;
|
|
63
|
+
if (goal.status !== "active" && goal.status !== "achieved") return undefined;
|
|
64
|
+
const objective = sanitizeLine(goal.objective);
|
|
65
|
+
if (!objective || objective.length > MAX_GOAL_OBJECTIVE) return undefined;
|
|
66
|
+
return { objective, status: goal.status };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The last goal written on the branch, or `null` when the session has none. */
|
|
70
|
+
export function lastGoal(entries: Iterable<unknown>): PublishedGoal | null {
|
|
71
|
+
let goal: PublishedGoal | null = null;
|
|
72
|
+
for (const raw of entries) {
|
|
73
|
+
const entry = raw as BranchEntryLike;
|
|
74
|
+
|
|
75
|
+
// The `/goal` command records a custom entry; the tool records a result below.
|
|
76
|
+
// Both replay in branch order, so the last write wins.
|
|
77
|
+
if (entry?.type === "custom" && entry.customType === GOAL_ENTRY_TYPE) {
|
|
78
|
+
const data = entry.data as { goal?: unknown } | null | undefined;
|
|
79
|
+
if (!data || typeof data !== "object" || !("goal" in data)) continue;
|
|
80
|
+
const next = readGoal(data.goal);
|
|
81
|
+
if (next !== undefined) goal = next;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (entry?.type !== "message") continue;
|
|
86
|
+
const message = entry.message;
|
|
87
|
+
if (message?.role !== "toolResult" || message.toolName !== GOAL_TOOL) continue;
|
|
88
|
+
const details = message.details as { goal?: unknown; error?: unknown } | undefined;
|
|
89
|
+
if (!details || details.error) continue;
|
|
90
|
+
if (!("goal" in details)) continue;
|
|
91
|
+
const next = readGoal(details.goal);
|
|
92
|
+
if (next !== undefined) goal = next;
|
|
93
|
+
}
|
|
94
|
+
return goal;
|
|
95
|
+
}
|