@gavin-hu/my-pi-agent 0.14.0 → 0.15.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 +55 -33
- package/README.zh-CN.md +44 -27
- package/extensions/bytefly/README.md +193 -140
- package/extensions/bytefly/block/component.ts +7 -15
- package/extensions/bytefly/block/register.ts +6 -1
- package/extensions/bytefly/doctor/register.ts +24 -5
- package/extensions/bytefly/doctor/tools.ts +4 -0
- package/extensions/bytefly/footer/collect/snapshot.ts +6 -1
- package/extensions/bytefly/footer/config.ts +10 -6
- package/extensions/bytefly/footer/price/price.ts +5 -3
- package/extensions/bytefly/footer/price/rates.ts +63 -26
- package/extensions/bytefly/footer/render/status.ts +3 -3
- package/extensions/bytefly/header/component.ts +3 -2
- package/extensions/bytefly/index.ts +3 -3
- package/extensions/bytefly/notice/component.ts +8 -3
- package/extensions/bytefly/notify/config.ts +6 -11
- package/extensions/bytefly/notify/dispatch.ts +13 -5
- package/extensions/bytefly/panel/commands.ts +21 -1
- package/extensions/bytefly/panel/format.ts +5 -2
- package/extensions/bytefly/panel/settings-runtime.ts +26 -6
- package/extensions/bytefly/panel/settings.ts +41 -4
- package/extensions/bytefly/panel/themes.ts +32 -0
- package/extensions/bytefly/panel/tui/editing.ts +77 -0
- package/extensions/bytefly/panel/tui/entries.ts +24 -0
- package/extensions/bytefly/panel/tui/extensions-view.ts +13 -8
- package/extensions/bytefly/panel/tui/frame.ts +121 -4
- package/extensions/bytefly/panel/tui/settings-view.ts +54 -15
- package/extensions/bytefly/panel/tui.ts +94 -152
- package/extensions/bytefly/picker/discovery.ts +1 -0
- package/extensions/bytefly/picker/types.ts +2 -0
- package/extensions/bytefly/separator/component.ts +14 -4
- package/extensions/bytefly/update/cache.ts +58 -19
- package/extensions/bytefly/update/check.ts +35 -0
- package/extensions/bytefly/update/format.ts +76 -35
- package/extensions/bytefly/update/register.ts +144 -55
- package/extensions/bytefly/update/state.ts +4 -2
- package/extensions/bytefly/update/suppress.ts +66 -0
- package/extensions/bytefly/update/types.ts +15 -0
- package/extensions/check/README.md +253 -0
- package/extensions/check/commands.ts +128 -0
- package/extensions/check/config.ts +39 -0
- package/extensions/check/detect.ts +269 -0
- package/extensions/check/failures.ts +230 -0
- package/extensions/check/format.ts +135 -0
- package/extensions/check/index.ts +26 -0
- package/extensions/check/render.ts +185 -0
- package/extensions/check/runner.ts +176 -0
- package/extensions/check/runtime.ts +244 -0
- package/extensions/check/source.ts +80 -0
- package/extensions/check/tools.ts +173 -0
- package/extensions/check/tui.ts +195 -0
- package/extensions/check/types.ts +99 -0
- package/extensions/document/README.md +1 -1
- package/extensions/document/extract/pdf.ts +8 -16
- package/extensions/document/formats.ts +2 -2
- package/extensions/file-browse/README.md +66 -32
- package/extensions/file-browse/client.ts +95 -32
- package/extensions/file-browse/config.ts +9 -9
- package/extensions/file-browse/git.ts +37 -47
- package/extensions/file-browse/html.ts +39 -58
- package/extensions/file-browse/markdown.ts +110 -92
- package/extensions/file-browse/raw-html.ts +478 -0
- package/extensions/file-browse/router.ts +32 -70
- package/extensions/file-browse/server.ts +18 -56
- package/extensions/file-browse/urls.ts +80 -0
- package/extensions/goal/README.md +103 -47
- package/extensions/goal/clarify-parse.ts +4 -9
- package/extensions/goal/clarify.ts +27 -4
- package/extensions/goal/commands.ts +24 -11
- package/extensions/goal/format.ts +41 -10
- package/extensions/goal/index.ts +39 -16
- package/extensions/goal/plan-route.ts +51 -0
- package/extensions/goal/schema.ts +6 -23
- package/extensions/goal/state.ts +39 -57
- package/extensions/goal/tools.ts +26 -20
- package/extensions/goal/types.ts +11 -1
- package/extensions/job/README.md +20 -6
- package/extensions/job/format.ts +18 -68
- package/extensions/job/index.ts +26 -41
- package/extensions/job/log-writer.ts +208 -0
- package/extensions/job/logs.ts +3 -2
- package/extensions/job/process.ts +6 -99
- package/extensions/job/runtime.ts +42 -181
- package/extensions/job/schema.ts +1 -4
- package/extensions/job/tools.ts +1 -15
- package/extensions/job/tui.ts +117 -307
- package/extensions/job/ui.ts +7 -33
- package/extensions/memory/README.md +1 -1
- package/extensions/memory/errors.ts +5 -6
- package/extensions/memory/tui.ts +14 -24
- package/extensions/plan/README.md +94 -18
- package/extensions/plan/commands.ts +9 -5
- package/extensions/plan/format.ts +87 -0
- package/extensions/plan/index.ts +31 -15
- package/extensions/plan/plans.ts +2 -3
- package/extensions/plan/policy.ts +0 -6
- package/extensions/plan/runtime.ts +4 -2
- package/extensions/plan/steps.ts +37 -8
- package/extensions/plan/todos.ts +44 -3
- package/extensions/plan/tools/enter.ts +94 -0
- package/extensions/plan/tools/exit.ts +296 -0
- package/extensions/plan/tools/nested.ts +21 -0
- package/extensions/plan/tools/steps.ts +136 -0
- package/extensions/plan/tools/todo-seed.ts +76 -0
- package/extensions/plan/tools/write.ts +127 -0
- package/extensions/plan/tools.ts +18 -703
- package/extensions/plan/web-client.ts +6 -22
- package/extensions/plan/web-page.ts +19 -30
- package/extensions/plan/web-router.ts +34 -71
- package/extensions/plan/web.ts +13 -40
- package/extensions/question/answers.ts +3 -8
- package/extensions/question/tui.ts +6 -6
- package/extensions/rewind/README.md +95 -22
- package/extensions/rewind/checklist.ts +185 -0
- package/extensions/rewind/commands.ts +8 -0
- package/extensions/rewind/config.ts +3 -11
- package/extensions/rewind/format.ts +220 -2
- package/extensions/rewind/git.ts +74 -7
- package/extensions/rewind/hunk-picker.ts +114 -0
- package/extensions/rewind/patch.ts +88 -0
- package/extensions/rewind/path-picker.ts +67 -100
- package/extensions/rewind/paths.ts +93 -19
- package/extensions/rewind/restore.ts +53 -10
- package/extensions/rewind/review-tui.ts +320 -0
- package/extensions/rewind/review.ts +397 -0
- package/extensions/rewind/rewind-tui.ts +4 -3
- package/extensions/rewind/runtime.ts +94 -17
- package/extensions/subagent/README.md +61 -6
- package/extensions/subagent/background-tui.ts +208 -0
- package/extensions/subagent/background.ts +470 -0
- package/extensions/subagent/commands.ts +80 -0
- package/extensions/subagent/config.ts +4 -0
- package/extensions/subagent/format.ts +24 -1
- package/extensions/subagent/index.ts +91 -2
- package/extensions/subagent/render.ts +13 -0
- package/extensions/subagent/run.ts +17 -16
- package/extensions/subagent/schema.ts +12 -0
- package/extensions/subagent/types.ts +11 -0
- package/extensions/subagent/ui.ts +50 -0
- package/extensions/todo/README.md +18 -13
- package/extensions/todo/format.ts +3 -2
- package/extensions/todo/runtime.ts +3 -2
- package/extensions/todo/schema.ts +8 -84
- package/extensions/todo/state.ts +5 -46
- package/extensions/todo/tools.ts +2 -1
- package/extensions/todo/types.ts +6 -15
- package/extensions/watch/README.md +14 -6
- package/extensions/watch/child.ts +150 -0
- package/extensions/watch/format.ts +8 -48
- package/extensions/watch/index.ts +42 -58
- package/extensions/watch/runtime.ts +49 -128
- package/extensions/watch/schema.ts +1 -4
- package/extensions/watch/tools.ts +1 -11
- package/extensions/watch/tui.ts +103 -273
- package/extensions/watch/ui.ts +5 -32
- package/extensions/web-access/README.md +1 -1
- package/extensions/web-access/fetch/page.ts +1 -1
- package/extensions/web-access/fetch/tool.ts +6 -2
- package/extensions/web-access/search/search.ts +2 -5
- package/extensions/wechat/README.md +1 -1
- package/extensions/wechat/client.ts +23 -0
- package/extensions/wechat/commands.ts +57 -42
- package/extensions/wechat/deliver.ts +5 -9
- package/extensions/wechat/errors.ts +3 -6
- package/extensions/wechat/inbound.ts +2 -5
- package/extensions/wechat/index.ts +2 -8
- package/extensions/wechat/outbound.ts +2 -4
- package/extensions/wechat/peer-worker.ts +3 -8
- package/extensions/wechat/poll.ts +10 -9
- package/extensions/wechat/presence.ts +35 -62
- package/extensions/wechat/reply-pipeline.ts +3 -1
- package/extensions/wechat/status-screen.ts +1 -1
- package/extensions/wechat/timer.ts +57 -0
- package/extensions/worktree/config.ts +8 -20
- package/extensions/worktree/enter.ts +7 -12
- package/extensions/worktree/git.ts +38 -69
- package/extensions/worktree/index.ts +5 -4
- package/extensions/worktree/registry.ts +7 -7
- package/extensions/worktree/root-tools.ts +2 -2
- package/extensions/worktree/status.ts +3 -2
- package/lib/README.md +22 -8
- package/lib/config.ts +50 -3
- package/lib/errors.ts +12 -0
- package/lib/format.ts +50 -0
- package/lib/git/read.ts +8 -0
- package/lib/goal-state.ts +23 -8
- package/lib/http/headers.ts +54 -0
- package/lib/http/index.ts +13 -0
- package/lib/http/response.ts +46 -0
- package/lib/http/router.ts +52 -0
- package/lib/http/server.ts +91 -0
- package/lib/lines.ts +77 -0
- package/lib/pdf.ts +82 -0
- package/lib/plan-entry.ts +71 -0
- package/lib/plan-state.ts +34 -0
- package/lib/report.ts +99 -0
- package/lib/requirements.ts +25 -19
- package/lib/spawn.ts +170 -0
- package/lib/todo-state.ts +110 -23
- package/lib/tool-names.ts +7 -0
- package/lib/tool-result.ts +33 -0
- package/lib/tui/README.md +68 -3
- package/lib/tui/chrome.ts +28 -4
- package/lib/tui/columns.ts +6 -1
- package/lib/tui/confirm.ts +65 -0
- package/lib/tui/dock-screen.ts +410 -0
- package/lib/tui/glyphs.ts +2 -1
- package/lib/tui/indent.ts +1 -1
- package/lib/tui/index.ts +2 -0
- package/lib/tui/rows.ts +21 -0
- package/lib/tui/status-controller.ts +35 -1
- package/lib/tui/tool.ts +59 -1
- package/lib/web/components.ts +20 -1
- package/lib/web/markup.ts +32 -0
- package/lib/web/runtime.ts +50 -0
- package/package.json +22 -5
- package/skills/init/SKILL.md +165 -0
- package/skills/init/references/agents-md-quality.md +103 -0
- package/skills/init/references/interview-questions.md +81 -0
- package/skills/refactor/SKILL.md +141 -0
- package/extensions/watch/process.ts +0 -63
- package/extensions/web-access/fetch/pdf.ts +0 -45
package/lib/format.ts
CHANGED
|
@@ -55,6 +55,17 @@ export function formatToolCount(count: number): string {
|
|
|
55
55
|
return `${formatCount(n)} ${n === 1 ? "tool" : "tools"}`;
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
/**
|
|
59
|
+
* A tail bounded to `maxChars`: the end of the joined lines, with the truncation
|
|
60
|
+
* flag. The header and the truncation suffix belong to the caller, because each
|
|
61
|
+
* surface says something different about where the rest of the output lives.
|
|
62
|
+
*/
|
|
63
|
+
export function boundedTail(lines: readonly string[], maxChars: number): { text: string; truncated: boolean } {
|
|
64
|
+
const body = lines.join("\n");
|
|
65
|
+
if (body.length <= maxChars) return { text: body, truncated: false };
|
|
66
|
+
return { text: body.slice(body.length - maxChars), truncated: true };
|
|
67
|
+
}
|
|
68
|
+
|
|
58
69
|
/**
|
|
59
70
|
* Replace terminal control characters (including ESC and the C1 block) with
|
|
60
71
|
* spaces, keeping `\n` and `\t` for callers that render multi-line text.
|
|
@@ -96,6 +107,45 @@ export function sanitizeLine(text: string): string {
|
|
|
96
107
|
return sanitize(stripControlChars(text));
|
|
97
108
|
}
|
|
98
109
|
|
|
110
|
+
/** A string read as one sanitized line; any other value reads empty. */
|
|
111
|
+
export function lineOf(value: unknown): string {
|
|
112
|
+
return typeof value === "string" ? sanitizeLine(value) : "";
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Longest untrusted line kept for display; longer lines are clipped. */
|
|
116
|
+
export const MAX_OUTPUT_LINE = 200;
|
|
117
|
+
|
|
118
|
+
/** ESC-introduced sequences: OSC, CSI, and single-character escapes. */
|
|
119
|
+
const OSC = /\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)/g;
|
|
120
|
+
const CSI = /\u001b\[[0-?]*[ -/]*[@-~]/g;
|
|
121
|
+
const OTHER_ESC = /\u001b[@-Z\\-_]/g;
|
|
122
|
+
const CONTROL = /[\u0000-\u001f\u007f-\u009f]/g;
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The boundary for one line of **program output**: a log file, a child's
|
|
126
|
+
* stdout, a tailed file.
|
|
127
|
+
*
|
|
128
|
+
* Stronger than {@link sanitizeLine}, which strips control characters but leaves
|
|
129
|
+
* the printable tail of an ANSI sequence behind. This removes whole OSC, CSI,
|
|
130
|
+
* and single-character escapes first, then resolves carriage returns the way a
|
|
131
|
+
* terminal would (keep the text after the last `\r`, so progress rewrites
|
|
132
|
+
* collapse to their final state), squeezes whitespace, and clips the result.
|
|
133
|
+
*
|
|
134
|
+
* The escape-stripping has to happen before the control-character pass: once
|
|
135
|
+
* ESC has become a space, the rest of the sequence is ordinary text.
|
|
136
|
+
*/
|
|
137
|
+
export function sanitizeUntrustedLine(raw: string, maxLength: number = MAX_OUTPUT_LINE): string {
|
|
138
|
+
const withoutAnsi = raw.replace(OSC, "").replace(CSI, "").replace(OTHER_ESC, "");
|
|
139
|
+
const afterCr = withoutAnsi.slice(withoutAnsi.lastIndexOf("\r") + 1);
|
|
140
|
+
const cleaned = afterCr.replace(CONTROL, " ").replace(/\s+/g, " ").trim();
|
|
141
|
+
return cleaned.length > maxLength ? `${cleaned.slice(0, maxLength - 1)}…` : cleaned;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** {@link sanitizeUntrustedLine} for a block of output, one entry per line. */
|
|
145
|
+
export function sanitizeUntrustedText(raw: string, maxLength: number = MAX_OUTPUT_LINE): string[] {
|
|
146
|
+
return raw.split("\n").map((line) => sanitizeUntrustedLine(line, maxLength));
|
|
147
|
+
}
|
|
148
|
+
|
|
99
149
|
/**
|
|
100
150
|
* Replace a home-directory prefix with `~`, matching at a path boundary so
|
|
101
151
|
* `/home/bob` does not shorten `/home/bobby`. Separators are normalized to `/`
|
package/lib/git/read.ts
CHANGED
|
@@ -54,6 +54,14 @@ export async function gitDir(runGit: RunGit, cwd: string): Promise<string | unde
|
|
|
54
54
|
return dir || undefined;
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
/** Absolute path of the repository's common git directory, shared by its worktrees. */
|
|
58
|
+
export async function gitCommonDir(runGit: RunGit, cwd: string): Promise<string | undefined> {
|
|
59
|
+
const result = await runGit(["rev-parse", "--path-format=absolute", "--git-common-dir"], { cwd });
|
|
60
|
+
if (result.code !== 0) return undefined;
|
|
61
|
+
const dir = result.stdout.trim();
|
|
62
|
+
return dir || undefined;
|
|
63
|
+
}
|
|
64
|
+
|
|
57
65
|
/** Resolve a revision to a full object id, or undefined when it does not exist. */
|
|
58
66
|
export async function revParse(runGit: RunGit, cwd: string, rev: string): Promise<string | undefined> {
|
|
59
67
|
const result = await runGit(["rev-parse", "--verify", "--quiet", rev], { cwd });
|
package/lib/goal-state.ts
CHANGED
|
@@ -6,8 +6,13 @@
|
|
|
6
6
|
* `error`, which is not a write — and the `/goal` command appends a `goal` custom
|
|
7
7
|
* entry whose `data.goal` is `null` when the goal is cleared. Replaying the branch
|
|
8
8
|
* in order is how another extension reads the goal without importing goal's
|
|
9
|
-
* modules: `plan` restates
|
|
10
|
-
* record one when a plan is approved
|
|
9
|
+
* modules: `plan` restates a pursued goal in its planning context, promotes a
|
|
10
|
+
* `planning` goal at plan exit, and offers to record one when a plan is approved
|
|
11
|
+
* with none.
|
|
12
|
+
*
|
|
13
|
+
* The status is the goal's phase: `planning` while a plan is being designed,
|
|
14
|
+
* `active` once the objective is final, `achieved` when it is done. Only `goal`
|
|
15
|
+
* writes it; the promotion out of `planning` runs through the `goal` tool.
|
|
11
16
|
*
|
|
12
17
|
* Stored values are re-validated and re-sanitized here. Branch data can be
|
|
13
18
|
* tampered with, migrated, or written by an older version, and a reader must not
|
|
@@ -15,9 +20,10 @@
|
|
|
15
20
|
* ignored rather than clearing a goal that is still valid.
|
|
16
21
|
*
|
|
17
22
|
* The entry type and the objective limit live here and are imported by `goal`, so
|
|
18
|
-
* the publisher and the reader cannot drift. `goal`
|
|
19
|
-
*
|
|
20
|
-
* the
|
|
23
|
+
* the publisher and the reader cannot drift. `goal` reads the branch through
|
|
24
|
+
* {@link lastGoal} as well — [`extensions/goal/state.ts`](../extensions/goal/state.ts)
|
|
25
|
+
* adds only the widget's active check and the open-todo count on top — so there
|
|
26
|
+
* is one replay, not two.
|
|
21
27
|
*/
|
|
22
28
|
|
|
23
29
|
import { sanitizeLine } from "./format.ts";
|
|
@@ -29,8 +35,8 @@ export const GOAL_ENTRY_TYPE = "goal";
|
|
|
29
35
|
/** Longest objective the goal tool accepts, and the most a reader will replay. */
|
|
30
36
|
export const MAX_GOAL_OBJECTIVE = 2000;
|
|
31
37
|
|
|
32
|
-
/**
|
|
33
|
-
export type PublishedGoalStatus = "active" | "achieved";
|
|
38
|
+
/** The goal's phase: being designed, final and pursued, or done. */
|
|
39
|
+
export type PublishedGoalStatus = "planning" | "active" | "achieved";
|
|
34
40
|
|
|
35
41
|
/** The goal as another extension may read it. */
|
|
36
42
|
export interface PublishedGoal {
|
|
@@ -60,12 +66,21 @@ function readGoal(value: unknown): PublishedGoal | null | undefined {
|
|
|
60
66
|
if (!value || typeof value !== "object") return undefined;
|
|
61
67
|
const goal = value as { objective?: unknown; status?: unknown };
|
|
62
68
|
if (typeof goal.objective !== "string") return undefined;
|
|
63
|
-
if (goal.status !== "active" && goal.status !== "achieved") return undefined;
|
|
69
|
+
if (goal.status !== "planning" && goal.status !== "active" && goal.status !== "achieved") return undefined;
|
|
64
70
|
const objective = sanitizeLine(goal.objective);
|
|
65
71
|
if (!objective || objective.length > MAX_GOAL_OBJECTIVE) return undefined;
|
|
66
72
|
return { objective, status: goal.status };
|
|
67
73
|
}
|
|
68
74
|
|
|
75
|
+
/**
|
|
76
|
+
* True while the goal steers the session: `planning` or `active`, never
|
|
77
|
+
* `achieved`. Both the reminder and the plan-mode context read this, so a goal
|
|
78
|
+
* being designed is restated like one being executed.
|
|
79
|
+
*/
|
|
80
|
+
export function isPursuedGoal(goal: PublishedGoal | null): goal is PublishedGoal {
|
|
81
|
+
return goal?.status === "planning" || goal?.status === "active";
|
|
82
|
+
}
|
|
83
|
+
|
|
69
84
|
/** The last goal written on the branch, or `null` when the session has none. */
|
|
70
85
|
export function lastGoal(entries: Iterable<unknown>): PublishedGoal | null {
|
|
71
86
|
let goal: PublishedGoal | null = null;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The response header sets both loopback servers answer with.
|
|
3
|
+
*
|
|
4
|
+
* One policy, declared once: the pages `file-browse` and `plan` serve are the
|
|
5
|
+
* same kind of document — a same-origin script, inline styles, and no network
|
|
6
|
+
* — so they must not drift into different envelopes, and a page that refuses to
|
|
7
|
+
* be framed cannot quietly stop doing so on one surface only.
|
|
8
|
+
*
|
|
9
|
+
* Every builder returns a fresh object, so a caller may add to a response
|
|
10
|
+
* without writing into another one. The policy itself is a code-authored
|
|
11
|
+
* constant; nothing here is model- or file-derived.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The page policy. `default-src 'none'` makes every fetch opt-in; the inline
|
|
16
|
+
* style allowance is the page's own `<style>` block, `script-src 'self'` the
|
|
17
|
+
* same-origin client, and `connect-src 'self'` the page's own API routes.
|
|
18
|
+
*
|
|
19
|
+
* The last three directives are the ones that make a local page safe to leave
|
|
20
|
+
* open: `frame-ancestors 'none'` (with the legacy `X-Frame-Options`) stops a
|
|
21
|
+
* local page from framing the surface and clickjacking a control with a side
|
|
22
|
+
* effect, `base-uri 'self'` stops an injected `<base>` from re-pointing every
|
|
23
|
+
* relative URL, and `form-action 'none'` stops a submission anywhere.
|
|
24
|
+
*/
|
|
25
|
+
export const PAGE_CSP =
|
|
26
|
+
"default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; script-src 'self'; " +
|
|
27
|
+
"connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'none'";
|
|
28
|
+
|
|
29
|
+
/** The security and caching headers every response carries, whatever its body. */
|
|
30
|
+
function secured(contentType: string): Record<string, string> {
|
|
31
|
+
return {
|
|
32
|
+
"Content-Type": contentType,
|
|
33
|
+
"Content-Security-Policy": PAGE_CSP,
|
|
34
|
+
"X-Frame-Options": "DENY",
|
|
35
|
+
"X-Content-Type-Options": "nosniff",
|
|
36
|
+
"Referrer-Policy": "no-referrer",
|
|
37
|
+
"Cache-Control": "no-store",
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Headers for a served HTML page. */
|
|
42
|
+
export function pageHeaders(): Record<string, string> {
|
|
43
|
+
return secured("text/html; charset=utf-8");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Headers for a same-origin script: the page policy, with the script's own type. */
|
|
47
|
+
export function scriptHeaders(): Record<string, string> {
|
|
48
|
+
return secured("text/javascript; charset=utf-8");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Headers for a JSON API reply: the same envelope, a JSON type. */
|
|
52
|
+
export function jsonHeaders(): Record<string, string> {
|
|
53
|
+
return secured("application/json; charset=utf-8");
|
|
54
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The loopback HTTP layer shared by `file-browse` and `plan`: the response
|
|
3
|
+
* shape, the header policy, the request-envelope helpers, and the socket seam.
|
|
4
|
+
*
|
|
5
|
+
* Import from here rather than a leaf module, so the layer reads as one kit.
|
|
6
|
+
* Value-only, like the rest of `lib/`: the only state is what a returned closure
|
|
7
|
+
* carries (`createCloser`).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export * from "./headers.ts";
|
|
11
|
+
export * from "./response.ts";
|
|
12
|
+
export * from "./router.ts";
|
|
13
|
+
export * from "./server.ts";
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The response shape both loopback servers answer with, and the builders that
|
|
3
|
+
* pair a body with the header set it needs.
|
|
4
|
+
*
|
|
5
|
+
* A router returns one of these instead of touching a socket, so every route is
|
|
6
|
+
* testable without a server, and `server.ts` in this directory is the only code
|
|
7
|
+
* that writes one out. The shape carries the two ways a response is not a
|
|
8
|
+
* buffered body — a file to stream and a stream to hold open — because those are
|
|
9
|
+
* properties of the answer, not of the server that serves it.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { jsonHeaders, pageHeaders } from "./headers.ts";
|
|
13
|
+
|
|
14
|
+
/** One HTTP response, before it reaches a socket. */
|
|
15
|
+
export interface ServeResponse {
|
|
16
|
+
/** HTTP status code. */
|
|
17
|
+
status: number;
|
|
18
|
+
/** Headers to send verbatim. */
|
|
19
|
+
headers: Record<string, string>;
|
|
20
|
+
/** The buffered body; omitted for an empty response or a streamed file. */
|
|
21
|
+
body?: string | Buffer;
|
|
22
|
+
/** When set, the server streams this file instead of `body`. */
|
|
23
|
+
filePath?: string;
|
|
24
|
+
/** When true, the server keeps the connection open as a Server-Sent Events stream. */
|
|
25
|
+
stream?: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** An HTML page, with any headers the route needs to add to the page policy. */
|
|
29
|
+
export function htmlResponse(status: number, body: string, extra?: Record<string, string>): ServeResponse {
|
|
30
|
+
return { status, headers: { ...pageHeaders(), ...extra }, body };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A JSON body, serialized once. */
|
|
34
|
+
export function jsonResponse(status: number, value: unknown): ServeResponse {
|
|
35
|
+
return { status, headers: jsonHeaders(), body: JSON.stringify(value) };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The JSON error envelope both API surfaces share: `{ error }`. */
|
|
39
|
+
export function jsonError(status: number, message: string): ServeResponse {
|
|
40
|
+
return jsonResponse(status, { error: message });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A plain-text body, for the internal-error fallback a server sends itself. */
|
|
44
|
+
export function textResponse(status: number, body: string): ServeResponse {
|
|
45
|
+
return { status, headers: { ...pageHeaders(), "Content-Type": "text/plain; charset=utf-8" }, body };
|
|
46
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The request-envelope helpers both loopback routers apply before a route runs:
|
|
3
|
+
* the request target, and the `Host` allowlist.
|
|
4
|
+
*
|
|
5
|
+
* Both surfaces bind `127.0.0.1`, so a `Host` naming anything else is a
|
|
6
|
+
* DNS-rebinding attempt against a local page. How strictly a missing `Host` is
|
|
7
|
+
* treated is the caller's decision: the browse server accepts one, because its
|
|
8
|
+
* router is also driven directly from tests and a plain `GET` with no header is
|
|
9
|
+
* not a rebinding, while the review server fails closed.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** The `Host` names a loopback interface, with or without a port. */
|
|
13
|
+
const LOCAL_HOST = /^(127\.0\.0\.1|localhost|\[::1\])(:\d+)?$/i;
|
|
14
|
+
|
|
15
|
+
/** A request target split into the parts a router matches on. */
|
|
16
|
+
export interface ParsedRequestUrl {
|
|
17
|
+
/** The path, with the leading slash and no query. */
|
|
18
|
+
pathname: string;
|
|
19
|
+
/** The parsed query string. */
|
|
20
|
+
search: URLSearchParams;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Parse a raw request target (`path?query`) against a loopback base. Returns
|
|
25
|
+
* `undefined` for a target that cannot be parsed, which a router answers with
|
|
26
|
+
* `400` rather than a route.
|
|
27
|
+
*/
|
|
28
|
+
export function parseRequestUrl(rawUrl: string): ParsedRequestUrl | undefined {
|
|
29
|
+
try {
|
|
30
|
+
const url = new URL(rawUrl, "http://127.0.0.1");
|
|
31
|
+
return { pathname: url.pathname, search: url.searchParams };
|
|
32
|
+
} catch {
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Whether the request's `Host` names this loopback server.
|
|
39
|
+
*
|
|
40
|
+
* With `required: false`, only a present, non-empty foreign name is refused, so
|
|
41
|
+
* a header-less request from another caller still routes. With `required: true`,
|
|
42
|
+
* a missing or non-string `Host` is refused too, so the guard cannot be skipped
|
|
43
|
+
* by omitting the header.
|
|
44
|
+
*/
|
|
45
|
+
export function isLocalHostHeader(
|
|
46
|
+
headers: Record<string, string | string[] | undefined>,
|
|
47
|
+
options: { required: boolean },
|
|
48
|
+
): boolean {
|
|
49
|
+
const host = headers.host;
|
|
50
|
+
if (typeof host !== "string" || host === "") return !options.required;
|
|
51
|
+
return LOCAL_HOST.test(host);
|
|
52
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The socket seam shared by the two loopback servers: bind, write a response
|
|
3
|
+
* out, and close.
|
|
4
|
+
*
|
|
5
|
+
* Everything here is behavior a caller cannot get right by accident: a bind on
|
|
6
|
+
* `127.0.0.1` only, an accept-level `error` listener so a transient failure
|
|
7
|
+
* cannot take the host down, a `HEAD` that sends headers and no body, a `filePath`
|
|
8
|
+
* response streamed instead of buffered, and a close that is safe to call twice
|
|
9
|
+
* and drops keep-alive connections. What each server does *around* this — SSE
|
|
10
|
+
* streams, request bodies, session settling — stays with that server.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { createReadStream } from "node:fs";
|
|
14
|
+
import type { IncomingMessage, Server, ServerResponse } from "node:http";
|
|
15
|
+
import type { ServeResponse } from "./response.ts";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Bind `server` to `127.0.0.1:port` and resolve the port it actually bound
|
|
19
|
+
* (`0` asks the OS to pick one). Rejects when the bind fails, so a caller that
|
|
20
|
+
* wants a fallback can retry with another port.
|
|
21
|
+
*/
|
|
22
|
+
export async function bindLoopback(server: Server, port: number): Promise<number> {
|
|
23
|
+
await new Promise<void>((resolve, reject) => {
|
|
24
|
+
const onError = (error: Error) => reject(error);
|
|
25
|
+
server.once("error", onError);
|
|
26
|
+
server.listen(port, "127.0.0.1", () => {
|
|
27
|
+
server.off("error", onError);
|
|
28
|
+
resolve();
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
// An accept-level failure (EMFILE and friends) is emitted on the server, and an
|
|
32
|
+
// `error` event with no listener would take the host down mid-session. The
|
|
33
|
+
// affected request fails on its own and is answered as an error by the caller.
|
|
34
|
+
server.on("error", () => {});
|
|
35
|
+
const address = server.address();
|
|
36
|
+
return typeof address === "object" && address ? address.port : port;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Write one routed response: headers first, then no body for `HEAD`, the file
|
|
41
|
+
* for a `filePath` response, or the buffered body.
|
|
42
|
+
*
|
|
43
|
+
* A `stream` response is deliberately not handled here: keeping a connection
|
|
44
|
+
* open is the owning server's lifecycle, so it must check `stream` before
|
|
45
|
+
* calling this.
|
|
46
|
+
*
|
|
47
|
+
* `afterFlush` runs once a buffered body is on the wire, which is the point a
|
|
48
|
+
* server may resolve a session without the client seeing a dropped connection.
|
|
49
|
+
* A streamed file has no such single point, so `respond` never calls it for one.
|
|
50
|
+
*/
|
|
51
|
+
export function respond(
|
|
52
|
+
request: IncomingMessage,
|
|
53
|
+
response: ServerResponse,
|
|
54
|
+
result: ServeResponse,
|
|
55
|
+
afterFlush?: () => void,
|
|
56
|
+
): void {
|
|
57
|
+
response.writeHead(result.status, result.headers);
|
|
58
|
+
if ((request.method ?? "GET").toUpperCase() === "HEAD") {
|
|
59
|
+
response.end(afterFlush);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (result.filePath) {
|
|
63
|
+
const stream = createReadStream(result.filePath);
|
|
64
|
+
stream.on("error", () => response.destroy());
|
|
65
|
+
stream.pipe(response);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
response.end(result.body ?? "", afterFlush);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* An idempotent `close()` for `server`.
|
|
73
|
+
*
|
|
74
|
+
* The second call resolves at once, so a shutdown path and a stop path can both
|
|
75
|
+
* await it. `closeAllConnections` is not on the `Server` type of every supported
|
|
76
|
+
* Node version, so it is probed: without it a keep-alive connection could hold
|
|
77
|
+
* the close open.
|
|
78
|
+
*/
|
|
79
|
+
export function createCloser(server: Server): () => Promise<void> {
|
|
80
|
+
let closed = false;
|
|
81
|
+
return () =>
|
|
82
|
+
new Promise<void>((resolve) => {
|
|
83
|
+
if (closed) {
|
|
84
|
+
resolve();
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
closed = true;
|
|
88
|
+
server.close(() => resolve());
|
|
89
|
+
(server as { closeAllConnections?: () => void }).closeAllConnections?.();
|
|
90
|
+
});
|
|
91
|
+
}
|
package/lib/lines.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Line splitting for a stream of untrusted output.
|
|
3
|
+
*
|
|
4
|
+
* A child's stdout, or a file read, arrives in arbitrary byte chunks: a chunk
|
|
5
|
+
* boundary can fall in the middle of a line, and — the case a naive
|
|
6
|
+
* `chunk.toString()` gets wrong — in the middle of a multi-byte UTF-8
|
|
7
|
+
* character. This buffers the bytes and decodes only at a newline, so a split
|
|
8
|
+
* character survives intact.
|
|
9
|
+
*
|
|
10
|
+
* `\r` is not treated as a break: a progress rewrite belongs to the sanitizer
|
|
11
|
+
* (`sanitizeUntrustedLine` in `lib/format.ts`, which keeps the text after the
|
|
12
|
+
* last carriage return), and breaking on it here would turn one rewritten line
|
|
13
|
+
* into several.
|
|
14
|
+
*
|
|
15
|
+
* Value-only: the buffer lives on the instance, so each reader owns one.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** Bytes buffered for one incomplete line before it is emitted anyway. */
|
|
19
|
+
export const MAX_LINE_BYTES = 1024 * 1024;
|
|
20
|
+
|
|
21
|
+
export interface LineSplitterOptions {
|
|
22
|
+
/** Emit an unfinished line once it grows past this many bytes. Default 1 MiB. */
|
|
23
|
+
maxLineBytes?: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export class LineSplitter {
|
|
27
|
+
private pending: Buffer = Buffer.alloc(0);
|
|
28
|
+
private readonly maxLineBytes: number;
|
|
29
|
+
|
|
30
|
+
constructor(options: LineSplitterOptions = {}) {
|
|
31
|
+
this.maxLineBytes = options.maxLineBytes ?? MAX_LINE_BYTES;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Bytes held for the unfinished line. */
|
|
35
|
+
get pendingBytes(): number {
|
|
36
|
+
return this.pending.length;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Feed one chunk and take every complete line it finishes.
|
|
41
|
+
*
|
|
42
|
+
* A line far longer than any display can show is emitted once it reaches the
|
|
43
|
+
* cap instead of being dropped, so a program that never prints a newline still
|
|
44
|
+
* produces output.
|
|
45
|
+
*/
|
|
46
|
+
push(chunk: Uint8Array | string): string[] {
|
|
47
|
+
const bytes = typeof chunk === "string" ? Buffer.from(chunk, "utf8") : Buffer.from(chunk);
|
|
48
|
+
const combined = this.pending.length > 0 ? Buffer.concat([this.pending, bytes]) : bytes;
|
|
49
|
+
const lines: string[] = [];
|
|
50
|
+
let start = 0;
|
|
51
|
+
for (let newline = combined.indexOf(0x0a); newline >= 0; newline = combined.indexOf(0x0a, start)) {
|
|
52
|
+
lines.push(combined.subarray(start, newline).toString("utf8"));
|
|
53
|
+
start = newline + 1;
|
|
54
|
+
}
|
|
55
|
+
let rest = combined.subarray(start);
|
|
56
|
+
if (rest.length > this.maxLineBytes) {
|
|
57
|
+
lines.push(rest.toString("utf8"));
|
|
58
|
+
rest = combined.subarray(0, 0);
|
|
59
|
+
}
|
|
60
|
+
// Copy, so a caller that reuses its read buffer cannot rewrite the tail.
|
|
61
|
+
this.pending = Buffer.from(rest);
|
|
62
|
+
return lines;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Take the unfinished line, for a stream that has ended. */
|
|
66
|
+
flush(): string[] {
|
|
67
|
+
if (this.pending.length === 0) return [];
|
|
68
|
+
const line = this.pending.toString("utf8");
|
|
69
|
+
this.pending = Buffer.alloc(0);
|
|
70
|
+
return [line];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Drop the unfinished line, for a source that was truncated or replaced. */
|
|
74
|
+
reset(): void {
|
|
75
|
+
this.pending = Buffer.alloc(0);
|
|
76
|
+
}
|
|
77
|
+
}
|
package/lib/pdf.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The optional `unpdf` seam: lazy load, module-shape handling, and text
|
|
3
|
+
* extraction, shared by `web-access` and `document`.
|
|
4
|
+
*
|
|
5
|
+
* `unpdf` bundles a server-friendly build of PDF.js, so it is imported lazily:
|
|
6
|
+
* an extension registers on every session, and a parser should load only when a
|
|
7
|
+
* read actually needs it. `web-access` treats the package as optional and
|
|
8
|
+
* reports {@link pdfGuidance} when it is absent; `document` ships it as a
|
|
9
|
+
* dependency. Both get the same loader, the same merged-pages call, and the same
|
|
10
|
+
* result unwrapping, so a PDF reads the same way through either surface.
|
|
11
|
+
*
|
|
12
|
+
* Value-only: no module state, and no import of `unpdf` until a caller asks.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** Install command for the optional PDF extractor package. */
|
|
16
|
+
const UNPDF_INSTALL = "npm i unpdf";
|
|
17
|
+
|
|
18
|
+
/** Model-facing guidance when the optional `unpdf` package is absent. */
|
|
19
|
+
export function pdfGuidance(): string {
|
|
20
|
+
return `PDF extraction requires the optional 'unpdf' package (${UNPDF_INSTALL}).`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Raised when `unpdf` is missing or does not expose `extractText`. */
|
|
24
|
+
export class PdfUnavailableError extends Error {
|
|
25
|
+
constructor(message: string) {
|
|
26
|
+
super(message);
|
|
27
|
+
this.name = "PdfUnavailableError";
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Extract merged page text from PDF bytes. */
|
|
32
|
+
export type PdfExtractor = (bytes: Uint8Array) => Promise<string>;
|
|
33
|
+
|
|
34
|
+
/** Pull the merged text out of `unpdf`'s `{ totalPages, text }` result. Pure. */
|
|
35
|
+
export function pdfTextFromResult(result: unknown): string {
|
|
36
|
+
if (typeof result === "string") return result;
|
|
37
|
+
const text = (result as { text?: unknown } | undefined)?.text;
|
|
38
|
+
return typeof text === "string" ? text : "";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The `extractText` function of an imported `unpdf` module, when it exposes one. */
|
|
42
|
+
function extractTextOf(module: unknown): ((bytes: Uint8Array, options?: unknown) => Promise<unknown>) | undefined {
|
|
43
|
+
if (typeof module !== "object" || module === null) return undefined;
|
|
44
|
+
const named = (module as { extractText?: unknown }).extractText;
|
|
45
|
+
if (typeof named === "function") return named as (bytes: Uint8Array, options?: unknown) => Promise<unknown>;
|
|
46
|
+
const fallback = (module as { default?: unknown }).default;
|
|
47
|
+
if (typeof fallback !== "object" || fallback === null) return undefined;
|
|
48
|
+
const nested = (fallback as { extractText?: unknown }).extractText;
|
|
49
|
+
if (typeof nested !== "function") return undefined;
|
|
50
|
+
return nested as (bytes: Uint8Array, options?: unknown) => Promise<unknown>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Build an extractor from an imported `unpdf` module.
|
|
55
|
+
*
|
|
56
|
+
* `unpdf` exports `extractText` directly; the default-export fallback tolerates
|
|
57
|
+
* a build that wraps it. Anything else is a broken install, reported with the
|
|
58
|
+
* same error the missing-package path uses.
|
|
59
|
+
*/
|
|
60
|
+
export function pdfExtractorFromModule(module: unknown): PdfExtractor {
|
|
61
|
+
const extractText = extractTextOf(module);
|
|
62
|
+
if (!extractText) throw new PdfUnavailableError("The installed 'unpdf' package does not export extractText.");
|
|
63
|
+
return async (bytes) => pdfTextFromResult(await extractText(bytes, { mergePages: true }));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Load `unpdf` lazily, or throw {@link PdfUnavailableError} with install guidance. */
|
|
67
|
+
export async function loadPdfExtractor(): Promise<PdfExtractor> {
|
|
68
|
+
let module: unknown;
|
|
69
|
+
try {
|
|
70
|
+
module = await import("unpdf");
|
|
71
|
+
} catch {
|
|
72
|
+
throw new PdfUnavailableError(pdfGuidance());
|
|
73
|
+
}
|
|
74
|
+
return pdfExtractorFromModule(module);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Extract merged page text from PDF bytes, using `extractor` when given. */
|
|
78
|
+
export async function extractPdfText(bytes: Uint8Array, extractor?: PdfExtractor): Promise<string> {
|
|
79
|
+
if (extractor) return extractor(bytes);
|
|
80
|
+
const loaded = await loadPdfExtractor();
|
|
81
|
+
return loaded(bytes);
|
|
82
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plan-mode entry, shared by the extension that owns the mode and the one that
|
|
3
|
+
* offers it as a phase of the goal.
|
|
4
|
+
*
|
|
5
|
+
* Plan mode has one owner: `plan` gates the tools, persists the flag, and draws
|
|
6
|
+
* the footer. A tool context can reach it through `ctx.executeTool`, but a
|
|
7
|
+
* command context cannot (`ExtensionCommandContext` has no `executeTool`), so the
|
|
8
|
+
* request travels on `pi.events` as an in-process request/response. The caller
|
|
9
|
+
* asks the user — with the consent sentence below — and `plan` enables without a
|
|
10
|
+
* second dialog, so the session never asks twice for one decision.
|
|
11
|
+
*
|
|
12
|
+
* The consent sentence and the read-only summary live here because two
|
|
13
|
+
* extensions show them: one copy each is how a trust guarantee drifts apart.
|
|
14
|
+
* Value-only, as `lib/` must be: the state is the caller's context and a closure,
|
|
15
|
+
* never a module variable.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
19
|
+
import { isPlanModeEnabled } from "./plan-state.ts";
|
|
20
|
+
import { PLAN_ENTER_TOOL } from "./tool-names.ts";
|
|
21
|
+
|
|
22
|
+
/** EventBus channel on which a caller asks `plan` to enable plan mode. */
|
|
23
|
+
export const PLAN_ENTER_REQUEST = "my-pi-agent/plan-mode/enter";
|
|
24
|
+
|
|
25
|
+
/** One clause describing the read-only guarantee, reused across prompts. */
|
|
26
|
+
export const READ_ONLY_SUMMARY =
|
|
27
|
+
"write and edit are disabled, bash is limited to read-only git commands, and other mutating tools are blocked; " +
|
|
28
|
+
"subagent delegation is forced read-only, and the only file write is write_plan (under .pi/plans, or the agent " +
|
|
29
|
+
"directory outside a repository)";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The consent question, as the user sees it. Shown by `enter_plan_mode` and by
|
|
33
|
+
* any caller that offers plan mode itself, so both describe the same guarantee.
|
|
34
|
+
*/
|
|
35
|
+
export const PLAN_CONSENT =
|
|
36
|
+
`Plan mode is read-only exploration: ${READ_ONLY_SUMMARY}. ` +
|
|
37
|
+
"Enter plan mode to investigate and propose an approach before making changes?";
|
|
38
|
+
|
|
39
|
+
/** What a caller sends on {@link PLAN_ENTER_REQUEST}. */
|
|
40
|
+
export interface PlanEnterRequest {
|
|
41
|
+
/** The caller's context; `plan` uses it for the footer status. */
|
|
42
|
+
ctx: ExtensionContext;
|
|
43
|
+
/** Called once, synchronously, with whether plan mode is on. */
|
|
44
|
+
respond(entered: boolean): void;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Ask `plan` to turn plan mode on, and report whether it is on afterwards.
|
|
49
|
+
*
|
|
50
|
+
* Resolves `true` without asking when the branch is already planning, and
|
|
51
|
+
* `false` when the `plan` tool is not registered — so `goal` loaded alone keeps
|
|
52
|
+
* working and nothing waits on a listener that is not there. The listener answers
|
|
53
|
+
* synchronously; the microtask is the fallback that guarantees resolution.
|
|
54
|
+
*/
|
|
55
|
+
export function requestPlanMode(
|
|
56
|
+
pi: Pick<ExtensionAPI, "events" | "getAllTools">,
|
|
57
|
+
ctx: ExtensionContext,
|
|
58
|
+
): Promise<boolean> {
|
|
59
|
+
if (isPlanModeEnabled(ctx.sessionManager.getBranch())) return Promise.resolve(true);
|
|
60
|
+
if (!pi.getAllTools().some((tool) => tool.name === PLAN_ENTER_TOOL)) return Promise.resolve(false);
|
|
61
|
+
return new Promise<boolean>((resolve) => {
|
|
62
|
+
let settled = false;
|
|
63
|
+
const respond = (entered: boolean): void => {
|
|
64
|
+
if (settled) return;
|
|
65
|
+
settled = true;
|
|
66
|
+
resolve(entered);
|
|
67
|
+
};
|
|
68
|
+
pi.events.emit(PLAN_ENTER_REQUEST, { ctx, respond } satisfies PlanEnterRequest);
|
|
69
|
+
queueMicrotask(() => respond(false));
|
|
70
|
+
});
|
|
71
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The plan-mode flag as the session branch carries it.
|
|
3
|
+
*
|
|
4
|
+
* Plan mode persists one custom entry (`plan-mode`) whose `enabled` field is the
|
|
5
|
+
* last word on the branch. `plan` writes it; `rewind` reads it through this
|
|
6
|
+
* module so `/review` cannot write while planning, without importing another
|
|
7
|
+
* extension's modules.
|
|
8
|
+
*
|
|
9
|
+
* Value-only: a constant and a pure replay over untrusted branch entries.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** Custom-entry type plan mode persists. */
|
|
13
|
+
export const PLAN_MODE_ENTRY_TYPE = "plan-mode";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Whether the branch's last valid plan-mode entry is enabled.
|
|
17
|
+
*
|
|
18
|
+
* Branch data is untrusted: an entry with the wrong type, a malformed `data`, or
|
|
19
|
+
* a non-boolean `enabled` is skipped rather than read as false. The last valid
|
|
20
|
+
* entry wins, so a later disable turns it off and a `/tree` restore follows the
|
|
21
|
+
* branch it lands on.
|
|
22
|
+
*/
|
|
23
|
+
export function isPlanModeEnabled(entries: readonly unknown[]): boolean {
|
|
24
|
+
let enabled = false;
|
|
25
|
+
for (const raw of entries) {
|
|
26
|
+
if (!raw || typeof raw !== "object") continue;
|
|
27
|
+
const entry = raw as { type?: unknown; customType?: unknown; data?: unknown };
|
|
28
|
+
if (entry.type !== "custom" || entry.customType !== PLAN_MODE_ENTRY_TYPE) continue;
|
|
29
|
+
if (!entry.data || typeof entry.data !== "object") continue;
|
|
30
|
+
const value = (entry.data as { enabled?: unknown }).enabled;
|
|
31
|
+
if (typeof value === "boolean") enabled = value;
|
|
32
|
+
}
|
|
33
|
+
return enabled;
|
|
34
|
+
}
|