@gavin-hu/my-pi-agent 0.20.0 → 0.22.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 +46 -30
- package/README.zh-CN.md +40 -25
- package/assets/screens/board.png +0 -0
- package/assets/screens/chrome.png +0 -0
- package/assets/screens/file-browse.png +0 -0
- package/assets/screens/plan-review.png +0 -0
- package/assets/screens/plan.png +0 -0
- package/docs/extensions/README.md +22 -23
- package/docs/extensions/board.md +397 -168
- package/docs/extensions/bytefly.md +234 -197
- package/docs/extensions/check.md +15 -17
- package/docs/extensions/context.md +35 -15
- package/docs/extensions/file-browse.md +57 -63
- package/docs/extensions/file-open.md +9 -11
- package/docs/extensions/file-read.md +12 -14
- package/docs/extensions/goal.md +15 -17
- package/docs/extensions/job.md +54 -42
- package/docs/extensions/memory.md +17 -19
- package/docs/extensions/plan.md +197 -140
- package/docs/extensions/question.md +16 -18
- package/docs/extensions/rewind.md +17 -19
- package/docs/extensions/serve.md +145 -0
- package/docs/extensions/session-bus.md +38 -16
- package/docs/extensions/subagent.md +23 -25
- package/docs/extensions/team.md +83 -44
- package/docs/extensions/todo.md +18 -18
- package/docs/extensions/watch.md +31 -31
- package/docs/extensions/web-access.md +2 -4
- package/docs/extensions/wechat.md +34 -35
- package/docs/extensions/worktree.md +56 -50
- package/extensions/board/commands.ts +79 -25
- package/extensions/board/format.ts +43 -24
- package/extensions/board/index.ts +17 -4
- package/extensions/board/input.ts +4 -9
- package/extensions/board/join.ts +5 -20
- package/extensions/board/lazy-page.ts +40 -0
- package/extensions/board/page.ts +65 -93
- package/extensions/board/result.ts +30 -39
- package/extensions/board/rows.ts +62 -25
- package/extensions/board/schema.ts +49 -11
- package/extensions/board/snapshot.ts +8 -6
- package/extensions/board/tone.ts +22 -0
- package/extensions/board/tools.ts +154 -55
- package/extensions/board/transport/router.ts +3 -5
- package/extensions/board/transport/server.ts +59 -28
- package/extensions/board/types.ts +36 -42
- package/extensions/board/view/card.ts +229 -0
- package/extensions/board/view/client.ts +35 -16
- package/extensions/board/view/dialog.ts +169 -6
- package/extensions/board/view/empty.ts +47 -0
- package/extensions/board/view/keys.ts +13 -2
- package/extensions/board/view/live.ts +90 -28
- package/extensions/board/view/names.ts +13 -4
- package/extensions/board/view/page.ts +38 -13
- package/extensions/board/view/render.ts +219 -277
- package/extensions/board/view/styles.ts +130 -32
- package/extensions/board/view/team.ts +297 -76
- package/extensions/bytefly/block/fallback.ts +1 -1
- package/extensions/bytefly/doctor/runtime.ts +5 -2
- package/extensions/bytefly/footer/collect/label.ts +60 -0
- package/extensions/bytefly/footer/collect/snapshot.ts +8 -1
- package/extensions/bytefly/footer/config.ts +7 -8
- package/extensions/bytefly/footer/register.ts +33 -4
- package/extensions/bytefly/footer/render/footer.ts +18 -4
- package/extensions/bytefly/footer/render/key.ts +46 -0
- package/extensions/bytefly/footer/render/layout.ts +13 -3
- package/extensions/bytefly/footer/render/lines.ts +10 -7
- package/extensions/bytefly/footer/render/segments.ts +41 -38
- package/extensions/bytefly/footer/render/status.ts +0 -11
- package/extensions/bytefly/footer/types.ts +7 -0
- package/extensions/bytefly/header/component.ts +4 -20
- package/extensions/bytefly/header/config.ts +1 -1
- package/extensions/bytefly/index.ts +32 -6
- package/extensions/bytefly/mode/editor.ts +115 -0
- package/extensions/bytefly/panel/commands.ts +210 -120
- package/extensions/bytefly/panel/format.ts +4 -4
- package/extensions/bytefly/panel/settings.ts +73 -6
- package/extensions/bytefly/panel/tui/keys.ts +2 -6
- package/extensions/bytefly/update/cache.ts +1 -1
- package/extensions/bytefly/update/register.ts +4 -4
- package/extensions/bytefly/working/config.ts +5 -7
- package/extensions/bytefly/working/format.ts +10 -13
- package/extensions/bytefly/working/register.ts +22 -27
- package/extensions/check/commands.ts +3 -2
- package/extensions/check/render.ts +10 -1
- package/extensions/check/{tui.ts → view/check-screens.ts} +14 -19
- package/extensions/context/commands.ts +2 -2
- package/extensions/context/{tui.ts → view/context-report.ts} +51 -19
- package/extensions/file-browse/config/config.ts +4 -19
- package/extensions/file-browse/index.ts +89 -79
- package/extensions/file-browse/live/live.ts +1 -1
- package/extensions/file-browse/transport/router.ts +12 -4
- package/extensions/file-browse/transport/server.ts +61 -28
- package/extensions/file-browse/view/chrome.ts +4 -4
- package/extensions/file-browse/view/client.ts +3 -1
- package/extensions/file-browse/view/html.ts +73 -40
- package/extensions/file-browse/view/live-client.ts +2 -2
- package/extensions/file-browse/view/markdown.ts +13 -14
- package/extensions/file-browse/view/palette.ts +2 -2
- package/extensions/file-browse/view/raw-html.ts +9 -9
- package/extensions/file-browse/view/tree.ts +1 -1
- package/extensions/file-browse/view/urls.ts +20 -6
- package/extensions/file-read/render.ts +4 -4
- package/extensions/goal/plan-route.ts +2 -2
- package/extensions/goal/tui.ts +12 -2
- package/extensions/job/child.ts +527 -0
- package/extensions/job/clock.ts +142 -0
- package/extensions/job/commands.ts +4 -2
- package/extensions/job/config.ts +8 -1
- package/extensions/job/format.ts +22 -1
- package/extensions/job/log-writer.ts +13 -2
- package/extensions/job/registry.ts +63 -7
- package/extensions/job/render.ts +34 -0
- package/extensions/job/runtime.ts +296 -655
- package/extensions/job/session.ts +132 -19
- package/extensions/job/store.ts +7 -7
- package/extensions/job/tools.ts +21 -9
- package/extensions/job/types.ts +47 -0
- package/extensions/job/{tui.ts → view/jobs-screen.ts} +19 -99
- package/extensions/job/waiters.ts +44 -1
- package/extensions/memory/commands.ts +4 -2
- package/extensions/memory/{tui.ts → view/memory-picker.ts} +12 -12
- package/extensions/plan/commands.ts +12 -9
- package/extensions/plan/feedback.ts +62 -16
- package/extensions/plan/format.ts +8 -2
- package/extensions/plan/git-bash.ts +115 -16
- package/extensions/plan/index.ts +58 -51
- package/extensions/plan/plan-index.ts +4 -2
- package/extensions/plan/plan-text.ts +114 -0
- package/extensions/plan/plans.ts +5 -54
- package/extensions/plan/policy.ts +8 -10
- package/extensions/plan/review-lazy.ts +85 -0
- package/extensions/plan/review.ts +2 -2
- package/extensions/plan/runtime.ts +61 -41
- package/extensions/plan/sections.ts +1 -1
- package/extensions/plan/steps.ts +3 -3
- package/extensions/plan/todos.ts +1 -1
- package/extensions/plan/tools/cancel.ts +27 -62
- package/extensions/plan/tools/enter.ts +55 -81
- package/extensions/plan/tools/exit.ts +221 -238
- package/extensions/plan/tools/refusal.ts +18 -0
- package/extensions/plan/tools/steps.ts +75 -92
- package/extensions/plan/tools/write.ts +77 -120
- package/extensions/plan/tools.ts +188 -30
- package/extensions/plan/tui.ts +5 -5
- package/extensions/plan/types.ts +48 -5
- package/extensions/plan/web-blocks.ts +76 -5
- package/extensions/plan/web-chrome.ts +4 -0
- package/extensions/plan/web-client.ts +102 -2
- package/extensions/plan/web-collab.ts +4 -4
- package/extensions/plan/web-comments.ts +39 -8
- package/extensions/plan/web-composer.ts +14 -3
- package/extensions/plan/web-keys.ts +149 -25
- package/extensions/plan/web-lazy.ts +53 -0
- package/extensions/plan/web-markdown.ts +10 -68
- package/extensions/plan/web-outline.ts +22 -2
- package/extensions/plan/web-page.ts +56 -86
- package/extensions/plan/web-payload.ts +188 -0
- package/extensions/plan/web-plan.ts +23 -13
- package/extensions/plan/web-router.ts +18 -9
- package/extensions/plan/web-settle.ts +14 -7
- package/extensions/plan/web-share-session.ts +24 -55
- package/extensions/plan/web-share.ts +19 -37
- package/extensions/plan/web-write.ts +112 -0
- package/extensions/plan/web.ts +67 -131
- package/extensions/question/tools.ts +6 -2
- package/extensions/question/{tui.ts → view/question-screen.ts} +11 -11
- package/extensions/rewind/commands/commands.ts +6 -3
- package/extensions/rewind/state/runtime.ts +1 -1
- package/extensions/rewind/{view/status.ts → status.ts} +4 -4
- package/extensions/rewind/view/review-tui.ts +4 -4
- package/extensions/serve/config.ts +39 -0
- package/extensions/serve/index.ts +85 -0
- package/extensions/serve/registry.ts +260 -0
- package/extensions/session-bus/commands.ts +11 -6
- package/extensions/session-bus/join-request.ts +17 -9
- package/extensions/session-bus/mail-watch.ts +26 -17
- package/extensions/session-bus/protocol.ts +15 -3
- package/extensions/session-bus/resolve.ts +5 -7
- package/extensions/session-bus/result.ts +1 -27
- package/extensions/session-bus/runtime.ts +40 -12
- package/extensions/session-bus/store.ts +101 -19
- package/extensions/session-bus/tools.ts +5 -4
- package/extensions/session-bus/types.ts +19 -0
- package/extensions/session-bus/{tui.ts → view/session-list.ts} +16 -16
- package/extensions/subagent/agents.ts +1 -1
- package/extensions/subagent/commands.ts +4 -2
- package/extensions/subagent/format.ts +15 -15
- package/extensions/subagent/index.ts +1 -1
- package/extensions/subagent/render.ts +3 -3
- package/extensions/subagent/{background-tui.ts → view/agents-board.ts} +11 -11
- package/extensions/team/commands.ts +24 -5
- package/extensions/team/dispatch.ts +18 -4
- package/extensions/team/format.ts +17 -2
- package/extensions/team/index.ts +9 -8
- package/extensions/team/schema.ts +6 -5
- package/extensions/team/tools.ts +107 -45
- package/extensions/team/types.ts +3 -2
- package/extensions/todo/commands.ts +4 -2
- package/extensions/todo/format.ts +11 -0
- package/extensions/todo/render.ts +57 -0
- package/extensions/todo/runtime.ts +1 -1
- package/extensions/todo/tools.ts +1 -1
- package/extensions/todo/{tui.ts → view/todo-screen.ts} +18 -90
- package/extensions/todo/widget.ts +47 -0
- package/extensions/watch/commands.ts +4 -2
- package/extensions/watch/format.ts +36 -7
- package/extensions/watch/render.ts +57 -0
- package/extensions/watch/runtime.ts +27 -22
- package/extensions/watch/tools.ts +4 -4
- package/extensions/watch/{tui.ts → view/watch-screen.ts} +18 -80
- package/extensions/web-access/fetch/tool.ts +12 -4
- package/extensions/wechat/commands/commands.ts +18 -14
- package/extensions/wechat/index.ts +72 -37
- package/extensions/wechat/state/credentials.ts +1 -1
- package/extensions/wechat/state/state.ts +1 -1
- package/extensions/wechat/text/status.ts +112 -0
- package/extensions/wechat/types.ts +2 -2
- package/extensions/wechat/view/status-screen.ts +4 -103
- package/extensions/worktree/commands.ts +11 -9
- package/extensions/worktree/enter.ts +2 -2
- package/extensions/worktree/exit.ts +2 -2
- package/extensions/worktree/index.ts +5 -5
- package/extensions/worktree/screen.ts +3 -3
- package/extensions/worktree/status.ts +2 -2
- package/extensions/worktree/tools.ts +113 -116
- package/lib/README.md +28 -13
- package/lib/board/actions.ts +111 -0
- package/lib/board/artifact.ts +149 -0
- package/lib/board/card.ts +163 -52
- package/lib/board/config.ts +58 -0
- package/lib/board/contract.ts +113 -0
- package/lib/board/join.ts +38 -0
- package/lib/board/lanes.ts +182 -0
- package/lib/board/lead.ts +130 -0
- package/lib/board/limits.ts +78 -0
- package/lib/board/links.ts +31 -10
- package/lib/board/page-pointer.ts +30 -5
- package/lib/board/read.ts +83 -0
- package/lib/board/reason.ts +102 -0
- package/lib/board/round-text.ts +68 -6
- package/lib/board/round.ts +31 -15
- package/lib/board/rules.ts +151 -307
- package/lib/board/seat.ts +35 -0
- package/lib/board/session.ts +199 -51
- package/lib/board/skipped.ts +22 -0
- package/lib/board/steps.ts +487 -0
- package/lib/board/store.ts +119 -36
- package/lib/board/tone.ts +92 -0
- package/lib/board/view.ts +81 -63
- package/lib/bus-request.ts +83 -0
- package/{extensions/watch → lib}/child.ts +40 -21
- package/lib/env.ts +65 -7
- package/lib/format.ts +13 -0
- package/lib/http/index.ts +17 -11
- package/lib/http/mount.ts +142 -0
- package/lib/http/port.ts +30 -0
- package/lib/http/serve.ts +170 -46
- package/lib/http/server.ts +3 -3
- package/lib/lazy.ts +45 -0
- package/lib/plan-entry.ts +46 -21
- package/lib/plans/paths.ts +82 -0
- package/lib/policy.ts +9 -6
- package/lib/report.ts +3 -2
- package/lib/requirements.ts +10 -10
- package/lib/session-label.ts +129 -7
- package/lib/session-roster.ts +43 -1
- package/lib/session-tool.ts +39 -22
- package/lib/stray-fields.ts +7 -2
- package/lib/tool-names.ts +5 -10
- package/lib/tui/README.md +29 -11
- package/lib/tui/chrome.ts +16 -15
- package/lib/tui/cursor.ts +18 -3
- package/lib/tui/glyphs.ts +11 -11
- package/lib/tui/render-cache.ts +29 -0
- package/lib/tui/tokens.ts +33 -2
- package/lib/tui/tool.ts +33 -4
- package/lib/web/README.md +21 -0
- package/lib/web/client-runtime.ts +27 -1
- package/lib/web/components.ts +5 -1
- package/lib/web/index.ts +1 -0
- package/lib/web/markdown.ts +83 -0
- package/lib/web/markup.ts +20 -2
- package/lib/web/runtime.ts +9 -10
- package/package.json +8 -2
- package/skills/init/SKILL.md +5 -5
- package/skills/next/SKILL.md +3 -5
- package/extensions/board/config.ts +0 -36
- package/extensions/board/seat.ts +0 -28
- package/lib/tui/index.ts +0 -34
- package/skills/project-docs/SKILL.md +0 -133
- package/skills/project-docs/references/audit.md +0 -131
- package/skills/project-docs/references/changelog.md +0 -105
- package/skills/project-docs/references/discovery.md +0 -127
- package/skills/project-docs/references/readme.md +0 -80
- package/skills/project-docs/references/release.md +0 -84
- package/skills/project-docs/references/roadmap.md +0 -93
- package/skills/project-docs/scripts/state.ts +0 -316
package/lib/http/serve.ts
CHANGED
|
@@ -1,16 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
* close.
|
|
2
|
+
* The one loopback server a session serves its pages on: bind once, dispatch by
|
|
3
|
+
* mount, stream, idle, close.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
5
|
+
* A session has one port, so the socket cannot belong to a page. A page *mounts* a
|
|
6
|
+
* path prefix on it — `/board`, `/files`, `/plan/<id>` — and the server owns
|
|
7
|
+
* everything a request does not decide: the longest-prefix dispatch, the body cap
|
|
8
|
+
* of whichever mount was reached, `HEAD` of a stream, the event-stream head, the
|
|
9
|
+
* port retry, and a close that is safe to call twice. A mount's handler is handed
|
|
10
|
+
* the request target *relative to its own prefix*, so a router keeps matching the
|
|
11
|
+
* paths it always matched.
|
|
10
12
|
*
|
|
11
|
-
* The
|
|
12
|
-
* a
|
|
13
|
-
*
|
|
13
|
+
* The idle grace is per mount, not per socket: one page going quiet must not close
|
|
14
|
+
* another's, so a mount carries its own open-stream registry and release, and the
|
|
15
|
+
* socket closes when its last mount leaves.
|
|
16
|
+
*
|
|
17
|
+
* The `Host` rule stays with each router: the allowlist is per extension, and for a
|
|
18
|
+
* shared review it grows while the server is up, so it cannot be passed in once.
|
|
14
19
|
*
|
|
15
20
|
* Value-only: the only state is what a returned closure carries.
|
|
16
21
|
*/
|
|
@@ -25,6 +30,7 @@ import {
|
|
|
25
30
|
import type { ArmTimer } from "../timer.ts";
|
|
26
31
|
import { jsonHeaders } from "./headers.ts";
|
|
27
32
|
import { type ServeResponse, textResponse } from "./response.ts";
|
|
33
|
+
import { parseRequestUrl } from "./router.ts";
|
|
28
34
|
import { bindHost, createCloser, openEventStream, respond } from "./server.ts";
|
|
29
35
|
import { createSinks, type EventSink, type IdleGrace, type SinkRegistry } from "./sink.ts";
|
|
30
36
|
|
|
@@ -32,7 +38,7 @@ import { createSinks, type EventSink, type IdleGrace, type SinkRegistry } from "
|
|
|
32
38
|
export interface LoopbackRequest {
|
|
33
39
|
/** Upper-cased method, e.g. `GET`. */
|
|
34
40
|
method: string;
|
|
35
|
-
/** The request target, path and query. */
|
|
41
|
+
/** The request target, path and query, relative to the mount's prefix. */
|
|
36
42
|
url: string;
|
|
37
43
|
/** Headers exactly as the socket carried them; the router applies its `Host` rule. */
|
|
38
44
|
headers: IncomingHttpHeaders;
|
|
@@ -57,35 +63,67 @@ export interface LoopbackReply {
|
|
|
57
63
|
/** One request in, one reply out. A throw is answered as a plain 500. */
|
|
58
64
|
export type LoopbackHandler = (request: LoopbackRequest) => Promise<LoopbackReply> | LoopbackReply;
|
|
59
65
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
ports: readonly number[];
|
|
66
|
+
/** One page's claim on a path prefix. */
|
|
67
|
+
export interface ServedMount {
|
|
68
|
+
/** The prefix it answers under: `/board`, or `""` for the whole origin. */
|
|
69
|
+
prefix: string;
|
|
65
70
|
handler: LoopbackHandler;
|
|
66
|
-
/** Largest body accepted; absent, no body is read and a route sees `""`. */
|
|
71
|
+
/** Largest body accepted here; absent, no body is read and a route sees `""`. */
|
|
67
72
|
maxBodyBytes?: number;
|
|
68
|
-
/** Grace after
|
|
73
|
+
/** Grace after this mount's last stream leaves; omitted, it waits for `release()`. */
|
|
69
74
|
idle?: IdleGrace;
|
|
70
|
-
/** The timer
|
|
75
|
+
/** The timer this mount's idle grace is armed on. */
|
|
71
76
|
timer?: ArmTimer;
|
|
72
77
|
}
|
|
73
78
|
|
|
74
|
-
/** A
|
|
75
|
-
export interface
|
|
79
|
+
/** A prefix served on a running server. */
|
|
80
|
+
export interface Mount {
|
|
81
|
+
/** The normalized prefix, e.g. `/board`. */
|
|
82
|
+
base: string;
|
|
83
|
+
/** The port the server bound. */
|
|
84
|
+
port: number;
|
|
85
|
+
/** `http://<host>:<port>`, the address this mount is reached on. */
|
|
86
|
+
origin: string;
|
|
87
|
+
/**
|
|
88
|
+
* End this mount's streams and free its prefix. Idempotent, and the server closes
|
|
89
|
+
* with its last mount.
|
|
90
|
+
*/
|
|
91
|
+
release(): Promise<void>;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface ServeOptions {
|
|
95
|
+
/** The address to bind; defaults to `127.0.0.1`. */
|
|
96
|
+
host?: string;
|
|
97
|
+
/** Ports tried in order, so a remembered port can fall back to an OS-chosen one. */
|
|
98
|
+
ports: readonly number[];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** A bound server, before any page has mounted on it. */
|
|
102
|
+
export interface ServedServer {
|
|
76
103
|
/** The port it actually bound. */
|
|
77
104
|
port: number;
|
|
78
105
|
/** `http://<host>:<port>`, the address the caller prints. */
|
|
79
106
|
origin: string;
|
|
80
107
|
/**
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
|
|
84
|
-
|
|
108
|
+
* Add one prefix. `undefined` means the prefix is already taken, which a caller
|
|
109
|
+
* reports rather than lets two pages answer as one.
|
|
110
|
+
*/
|
|
111
|
+
mount(spec: ServedMount): Mount | undefined;
|
|
112
|
+
/**
|
|
113
|
+
* End every mount's streams and close the socket. Idempotent, and what the last
|
|
114
|
+
* mount's release does on its own.
|
|
85
115
|
*/
|
|
86
116
|
close(): Promise<void>;
|
|
87
117
|
}
|
|
88
118
|
|
|
119
|
+
/** One prefix as the server holds it: its spec, its streams, and its mount handle. */
|
|
120
|
+
interface Route {
|
|
121
|
+
spec: ServedMount;
|
|
122
|
+
base: string;
|
|
123
|
+
sinks: SinkRegistry;
|
|
124
|
+
mount: Mount;
|
|
125
|
+
}
|
|
126
|
+
|
|
89
127
|
/** The one refusal this layer answers itself: a body it will not read. */
|
|
90
128
|
const BODY_TOO_LARGE: ServeResponse = {
|
|
91
129
|
status: 413,
|
|
@@ -99,6 +137,10 @@ const STREAM_WITHOUT_SINK: ServeResponse = textResponse(500, "Internal error");
|
|
|
99
137
|
/** A handler that threw, or a socket that failed after its head was written. */
|
|
100
138
|
const INTERNAL_ERROR: ServeResponse = textResponse(500, "Internal error");
|
|
101
139
|
|
|
140
|
+
/** A prefix no mount claims, and a target no URL parser accepts. */
|
|
141
|
+
const NOT_FOUND: ServeResponse = textResponse(404, "Nothing is served at this path.");
|
|
142
|
+
const MALFORMED: ServeResponse = textResponse(400, "Malformed request URL.");
|
|
143
|
+
|
|
102
144
|
/**
|
|
103
145
|
* Bind the first port that answers, falling back in order. Rejects with the last
|
|
104
146
|
* bind error, so a caller can still name the reason (`EADDRINUSE` and friends).
|
|
@@ -115,6 +157,31 @@ async function bindFirst(server: Server, host: string, ports: readonly number[])
|
|
|
115
157
|
throw failure instanceof Error ? failure : new Error(`could not bind ${host}`);
|
|
116
158
|
}
|
|
117
159
|
|
|
160
|
+
/** One prefix, spelled the way dispatch compares it: no trailing slash, a leading one. */
|
|
161
|
+
function normalizeBase(prefix: string): string {
|
|
162
|
+
const trimmed = prefix.replace(/\/+$/, "");
|
|
163
|
+
if (trimmed === "" || trimmed === "/") return "";
|
|
164
|
+
return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Whether `base` is a prefix of `pathname` at a segment boundary; `""` covers everything. */
|
|
168
|
+
function prefixCovers(base: string, pathname: string): boolean {
|
|
169
|
+
return base === "" || pathname === base || pathname.startsWith(`${base}/`);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* The mount a path belongs to: the longest prefix that covers it, so `/plan/a` wins
|
|
174
|
+
* over a bare `/plan` and a root mount is the fallback rather than the rival.
|
|
175
|
+
*/
|
|
176
|
+
function routeFor(routes: Map<string, Route>, pathname: string): Route | undefined {
|
|
177
|
+
let best: Route | undefined;
|
|
178
|
+
for (const route of routes.values()) {
|
|
179
|
+
if (!prefixCovers(route.base, pathname)) continue;
|
|
180
|
+
if (best === undefined || route.base.length > best.base.length) best = route;
|
|
181
|
+
}
|
|
182
|
+
return best;
|
|
183
|
+
}
|
|
184
|
+
|
|
118
185
|
/**
|
|
119
186
|
* Read a request body, stopping at `limit`. An oversize body is reported rather
|
|
120
187
|
* than destroyed, so it can still be answered 413 over the open socket instead of
|
|
@@ -157,22 +224,45 @@ function once(fn: (() => void) | undefined): (() => void) | undefined {
|
|
|
157
224
|
};
|
|
158
225
|
}
|
|
159
226
|
|
|
160
|
-
/**
|
|
227
|
+
/**
|
|
228
|
+
* Route one request: pick the mount, read, dispatch, then answer, stream, or refuse.
|
|
229
|
+
*
|
|
230
|
+
* A request that names a prefix without its slash is redirected to the slashed form,
|
|
231
|
+
* because a page's own links are relative to its directory and `/board` is not one.
|
|
232
|
+
*/
|
|
161
233
|
async function answer(
|
|
162
234
|
request: IncomingMessage,
|
|
163
235
|
response: ServerResponse,
|
|
164
|
-
|
|
165
|
-
|
|
236
|
+
routes: Map<string, Route>,
|
|
237
|
+
redirect: (to: string) => ServeResponse,
|
|
166
238
|
): Promise<void> {
|
|
167
239
|
const method = request.method ?? "GET";
|
|
168
|
-
const
|
|
240
|
+
const target = request.url ?? "/";
|
|
241
|
+
const parsed = parseRequestUrl(target);
|
|
242
|
+
if (!parsed) {
|
|
243
|
+
respond(request, response, MALFORMED);
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
const route = routeFor(routes, parsed.pathname);
|
|
247
|
+
if (route === undefined) {
|
|
248
|
+
respond(request, response, NOT_FOUND);
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
if (route.base !== "" && parsed.pathname === route.base) {
|
|
252
|
+
respond(request, response, redirect(`${route.base}/${parsed.search.toString() === "" ? "" : `?${parsed.search}`}`));
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
const rest = target.slice(route.base.length);
|
|
256
|
+
const url = rest === "" ? "/" : rest.startsWith("?") ? `/${rest}` : rest;
|
|
257
|
+
const limit = route.spec.maxBodyBytes;
|
|
258
|
+
const body = limit === undefined ? "" : await readBody(request, limit);
|
|
169
259
|
if (body === undefined) {
|
|
170
260
|
respond(request, response, BODY_TOO_LARGE);
|
|
171
261
|
return;
|
|
172
262
|
}
|
|
173
263
|
let reply: LoopbackReply;
|
|
174
264
|
try {
|
|
175
|
-
reply = await
|
|
265
|
+
reply = await route.spec.handler({ method, url, headers: request.headers, body });
|
|
176
266
|
} catch {
|
|
177
267
|
reply = { result: INTERNAL_ERROR };
|
|
178
268
|
}
|
|
@@ -186,7 +276,7 @@ async function answer(
|
|
|
186
276
|
// the client would otherwise wait on frames it asked not to get.
|
|
187
277
|
if (reply.openSink !== undefined && method.toUpperCase() !== "HEAD") {
|
|
188
278
|
openEventStream(response, reply.result);
|
|
189
|
-
const sink = sinks.open(response);
|
|
279
|
+
const sink = route.sinks.open(response);
|
|
190
280
|
const detach = reply.openSink(sink);
|
|
191
281
|
let closed = false;
|
|
192
282
|
const finish = (): void => {
|
|
@@ -205,33 +295,67 @@ async function answer(
|
|
|
205
295
|
}
|
|
206
296
|
|
|
207
297
|
/** Start a server on the first port that binds, and close it idempotently. */
|
|
208
|
-
export async function
|
|
298
|
+
export async function serveHttp(options: ServeOptions): Promise<ServedServer> {
|
|
209
299
|
const host = options.host ?? "127.0.0.1";
|
|
210
|
-
const
|
|
300
|
+
const routes = new Map<string, Route>();
|
|
301
|
+
let closed = false;
|
|
302
|
+
let closeServer: (() => Promise<void>) | undefined;
|
|
303
|
+
|
|
304
|
+
const close = async (): Promise<void> => {
|
|
305
|
+
if (closed) return;
|
|
306
|
+
closed = true;
|
|
307
|
+
// Streams end first: a connected page must not hold the close open.
|
|
308
|
+
const open = [...routes.values()];
|
|
309
|
+
routes.clear();
|
|
310
|
+
for (const route of open) route.sinks.endAll();
|
|
311
|
+
await closeServer?.();
|
|
312
|
+
};
|
|
313
|
+
|
|
211
314
|
const server = createServer((request, response) => {
|
|
212
315
|
// A page that walks away mid-frame must not take the session down.
|
|
213
316
|
response.on("error", () => {});
|
|
214
|
-
void answer(request, response,
|
|
317
|
+
void answer(request, response, routes, (to) => ({
|
|
318
|
+
status: 301,
|
|
319
|
+
headers: { ...jsonHeaders(), Location: to },
|
|
320
|
+
body: "",
|
|
321
|
+
})).catch(() => {
|
|
215
322
|
// A route that failed after its head was written can only be cut off; ending
|
|
216
323
|
// the socket beats a client waiting on a reply that will not come.
|
|
217
324
|
if (!response.writableEnded) response.end();
|
|
218
325
|
});
|
|
219
326
|
});
|
|
220
327
|
const port = await bindFirst(server, host, options.ports);
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
const closeServer = createCloser(server);
|
|
225
|
-
let closed = false;
|
|
328
|
+
closeServer = createCloser(server);
|
|
329
|
+
const origin = `http://${host}:${port}`;
|
|
330
|
+
|
|
226
331
|
return {
|
|
227
332
|
port,
|
|
228
|
-
origin
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
333
|
+
origin,
|
|
334
|
+
mount(spec) {
|
|
335
|
+
const base = normalizeBase(spec.prefix);
|
|
336
|
+
if (routes.has(base)) return undefined;
|
|
337
|
+
const sinks = createSinks({ idle: spec.idle, timer: spec.timer });
|
|
338
|
+
const route: Route = {
|
|
339
|
+
spec: { ...spec, prefix: base },
|
|
340
|
+
base,
|
|
341
|
+
sinks,
|
|
342
|
+
mount: {
|
|
343
|
+
base,
|
|
344
|
+
port,
|
|
345
|
+
origin,
|
|
346
|
+
release: async () => {
|
|
347
|
+
if (!routes.delete(base)) return;
|
|
348
|
+
sinks.endAll();
|
|
349
|
+
if (routes.size === 0) await close();
|
|
350
|
+
},
|
|
351
|
+
},
|
|
352
|
+
};
|
|
353
|
+
routes.set(base, route);
|
|
354
|
+
// A mount that asked for a first grace arms it here, so a browser that never
|
|
355
|
+
// came up cannot hold the prefix for the rest of the session.
|
|
356
|
+
sinks.start();
|
|
357
|
+
return route.mount;
|
|
235
358
|
},
|
|
359
|
+
close,
|
|
236
360
|
};
|
|
237
361
|
}
|
package/lib/http/server.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The socket primitives `
|
|
3
|
-
*
|
|
2
|
+
* The socket primitives `serveHttp` is built from: bind, write a response out, and
|
|
3
|
+
* close.
|
|
4
4
|
*
|
|
5
5
|
* Internal to the layer: an extension starts a server through
|
|
6
|
-
* `
|
|
6
|
+
* `serveHttp`, which composes these, so this module is reached from `lib/http`
|
|
7
7
|
* and its own suite — not from the barrel. Everything here is behavior a caller
|
|
8
8
|
* cannot get right by accident: a bind on an address the caller names (loopback
|
|
9
9
|
* unless it asked for more), an accept-level `error` listener so a transient
|
package/lib/lazy.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One async load, run at most once.
|
|
3
|
+
*
|
|
4
|
+
* A surface a command needs but registration does not — a browser page, a
|
|
5
|
+
* review server, a screen — is imported through this: `get()` runs the loader
|
|
6
|
+
* and remembers the value, and `peek()` answers the value already loaded, or
|
|
7
|
+
* `undefined`, so a shutdown path never starts a load the session never used.
|
|
8
|
+
*
|
|
9
|
+
* A rejected load is not remembered. A transient failure can be retried, and a
|
|
10
|
+
* caller that sees the error can ask again; the alternative would make one bad
|
|
11
|
+
* import permanent for the process.
|
|
12
|
+
*
|
|
13
|
+
* State lives in this closure, not at module scope, because `lib/` is loaded
|
|
14
|
+
* with isolated module caches and is value-only.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** A value loaded on first use, with a way to ask whether it is already here. */
|
|
18
|
+
export interface Lazy<T> {
|
|
19
|
+
/** Load the value, or return the one already loaded; concurrent calls share one load. */
|
|
20
|
+
get(): Promise<T>;
|
|
21
|
+
/** The loaded value, or `undefined` before a load and after a failed one. */
|
|
22
|
+
peek(): T | undefined;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function lazy<T>(load: () => Promise<T>): Lazy<T> {
|
|
26
|
+
let loaded = false;
|
|
27
|
+
let value: T;
|
|
28
|
+
let pending: Promise<T> | undefined;
|
|
29
|
+
const get = (): Promise<T> => {
|
|
30
|
+
if (loaded) return Promise.resolve(value);
|
|
31
|
+
pending ??= load().then(
|
|
32
|
+
(result) => {
|
|
33
|
+
loaded = true;
|
|
34
|
+
value = result;
|
|
35
|
+
return result;
|
|
36
|
+
},
|
|
37
|
+
(error: unknown) => {
|
|
38
|
+
pending = undefined;
|
|
39
|
+
throw error;
|
|
40
|
+
},
|
|
41
|
+
);
|
|
42
|
+
return pending;
|
|
43
|
+
};
|
|
44
|
+
return { get, peek: () => (loaded ? value : undefined) };
|
|
45
|
+
}
|
package/lib/plan-entry.ts
CHANGED
|
@@ -16,21 +16,26 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
19
|
+
import { type BusAnswer, requestOverBus } from "./bus-request.ts";
|
|
19
20
|
import { isPlanModeEnabled } from "./plan-state.ts";
|
|
20
|
-
import {
|
|
21
|
+
import { PLAN_TOOL } from "./tool-names.ts";
|
|
21
22
|
|
|
22
23
|
/** EventBus channel on which a caller asks `plan` to enable plan mode. */
|
|
23
24
|
export const PLAN_ENTER_REQUEST = "my-pi-agent/plan-mode/enter";
|
|
24
25
|
|
|
26
|
+
/** EventBus channel on which a caller asks `plan` to flip the mode. */
|
|
27
|
+
export const PLAN_TOGGLE_REQUEST = "my-pi-agent/plan-mode/toggle";
|
|
28
|
+
|
|
25
29
|
/** One clause describing the read-only guarantee, reused across prompts. */
|
|
26
30
|
export const READ_ONLY_SUMMARY =
|
|
27
31
|
"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
|
|
29
|
-
"directory
|
|
32
|
+
"subagent delegation is forced read-only, and the only file write is the plan file, saved by the `plan` tool's " +
|
|
33
|
+
"`write` action in the agent directory";
|
|
30
34
|
|
|
31
35
|
/**
|
|
32
|
-
* The consent question, as the user sees it. Shown by `
|
|
33
|
-
* any caller that offers plan mode itself, so both describe the same
|
|
36
|
+
* The consent question, as the user sees it. Shown by the `plan` tool's `enter`
|
|
37
|
+
* action and by any caller that offers plan mode itself, so both describe the same
|
|
38
|
+
* guarantee.
|
|
34
39
|
*/
|
|
35
40
|
export const PLAN_CONSENT =
|
|
36
41
|
`Plan mode is read-only exploration: ${READ_ONLY_SUMMARY}. ` +
|
|
@@ -52,12 +57,19 @@ export function planModeRefusal(action: string): string {
|
|
|
52
57
|
return `Plan mode is read-only. Approve the plan before ${action}.`;
|
|
53
58
|
}
|
|
54
59
|
|
|
55
|
-
/** What a caller sends on {@link PLAN_ENTER_REQUEST}. */
|
|
56
|
-
export interface
|
|
60
|
+
/** What a caller sends on {@link PLAN_ENTER_REQUEST} or {@link PLAN_TOGGLE_REQUEST}. */
|
|
61
|
+
export interface PlanModeRequest {
|
|
57
62
|
/** The caller's context; `plan` uses it for the footer status. */
|
|
58
63
|
ctx: ExtensionContext;
|
|
59
|
-
/**
|
|
60
|
-
respond(
|
|
64
|
+
/** Claimed once, with whether plan mode is on; the caller settles on that claim. */
|
|
65
|
+
respond(enabled: BusAnswer<boolean>): void;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Whether the `plan` extension registered its tool in this session.
|
|
70
|
+
*/
|
|
71
|
+
export function isPlanExtensionLoaded(pi: Pick<ExtensionAPI, "getAllTools">): boolean {
|
|
72
|
+
return pi.getAllTools().some((tool) => tool.name === PLAN_TOOL);
|
|
61
73
|
}
|
|
62
74
|
|
|
63
75
|
/**
|
|
@@ -65,23 +77,36 @@ export interface PlanEnterRequest {
|
|
|
65
77
|
*
|
|
66
78
|
* Resolves `true` without asking when the branch is already planning, and
|
|
67
79
|
* `false` when the `plan` tool is not registered — so `goal` loaded alone keeps
|
|
68
|
-
* working and nothing waits on a listener that is not there. The listener
|
|
69
|
-
* synchronously; the
|
|
80
|
+
* working and nothing waits on a listener that is not there. The listener claims
|
|
81
|
+
* synchronously; the shared handshake is what guarantees a resolution.
|
|
70
82
|
*/
|
|
71
83
|
export function requestPlanMode(
|
|
72
84
|
pi: Pick<ExtensionAPI, "events" | "getAllTools">,
|
|
73
85
|
ctx: ExtensionContext,
|
|
74
86
|
): Promise<boolean> {
|
|
75
87
|
if (isPlanModeEnabled(ctx.sessionManager.getBranch())) return Promise.resolve(true);
|
|
76
|
-
if (!pi
|
|
77
|
-
return
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
88
|
+
if (!isPlanExtensionLoaded(pi)) return Promise.resolve(false);
|
|
89
|
+
return requestOverBus<boolean, PlanModeRequest>(pi, {
|
|
90
|
+
channel: PLAN_ENTER_REQUEST,
|
|
91
|
+
payload: (respond) => ({ ctx, respond }),
|
|
92
|
+
unclaimed: () => false,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Ask `plan` to flip the mode, and report the state it left behind.
|
|
98
|
+
*
|
|
99
|
+
* A toggle has no branch precondition: the owner reads the flag it persisted and
|
|
100
|
+
* resolves the opposite. `undefined` means nothing is listening (the `plan`
|
|
101
|
+
* extension is not loaded), so the caller can leave its own key alone.
|
|
102
|
+
*/
|
|
103
|
+
export function requestPlanToggle(
|
|
104
|
+
pi: Pick<ExtensionAPI, "events">,
|
|
105
|
+
ctx: ExtensionContext,
|
|
106
|
+
): Promise<boolean | undefined> {
|
|
107
|
+
return requestOverBus<boolean | undefined, PlanModeRequest>(pi, {
|
|
108
|
+
channel: PLAN_TOGGLE_REQUEST,
|
|
109
|
+
payload: (respond) => ({ ctx, respond }),
|
|
110
|
+
unclaimed: () => undefined,
|
|
86
111
|
});
|
|
87
112
|
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a plan lives, and which paths may be read or written as one.
|
|
3
|
+
*
|
|
4
|
+
* A plan file is the artifact a card is executed against, so two extensions have to
|
|
5
|
+
* agree about it: `plan` writes and reads the store, and `board` records the file a
|
|
6
|
+
* card promises. The store is `<agent-dir>/plans/<repo-key>` for a repository and
|
|
7
|
+
* `<agent-dir>/plans` outside one — the key is the same one `repo-key.ts` hands
|
|
8
|
+
* every other per-repository store, which is why the keyed branch lives here rather
|
|
9
|
+
* than in either caller.
|
|
10
|
+
*
|
|
11
|
+
* The rules are one body for the same reason: a copy of `isContainedPlan` that
|
|
12
|
+
* dropped the symlink half would look identical at a glance and would be the one
|
|
13
|
+
* that let a link inside the store point outside it. `plan` passes a path here
|
|
14
|
+
* before it writes; `board` passes the path it is about to record, and asks whether
|
|
15
|
+
* it is a plan of this repository.
|
|
16
|
+
*
|
|
17
|
+
* Value-only: no module state, and the only I/O is the `lstat` a symlink check needs.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { lstatSync } from "node:fs";
|
|
21
|
+
import { homedir } from "node:os";
|
|
22
|
+
import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
|
|
23
|
+
import { isInside, isInsideReal } from "../path.ts";
|
|
24
|
+
import type { RepoKey } from "../repo-key.ts";
|
|
25
|
+
|
|
26
|
+
/** The agent-directory entry plans are filed under. */
|
|
27
|
+
export const PLANS_DIR_NAME = "plans";
|
|
28
|
+
|
|
29
|
+
/** The plan store: `<agent-dir>/plans`, or `<agent-dir>/plans/<key>` inside a repository. */
|
|
30
|
+
export function plansDir(agentDir: string, key?: string): string {
|
|
31
|
+
return key === undefined ? join(agentDir, PLANS_DIR_NAME) : join(agentDir, PLANS_DIR_NAME, key);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The store for a resolved repository: keyed when git answered, unkeyed outside one.
|
|
36
|
+
*
|
|
37
|
+
* The `fromGit` branch lives here and nowhere else — that is the line a copy gets wrong,
|
|
38
|
+
* and both the plan tool and the board reach it through this one function.
|
|
39
|
+
*/
|
|
40
|
+
export function plansDirFor(agentDir: string, repo: Pick<RepoKey, "key" | "fromGit">): string {
|
|
41
|
+
return plansDir(agentDir, repo.fromGit ? repo.key : undefined);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** A plan file name: markdown and not a dotfile, so the self-ignore file is never a target. */
|
|
45
|
+
export function isPlanFileName(path: string): boolean {
|
|
46
|
+
const name = basename(path);
|
|
47
|
+
return name.toLowerCase().endsWith(".md") && !name.startsWith(".");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Whether `path` may be read, written, or deleted as a plan. Beyond lexical
|
|
52
|
+
* containment (shared with the other path guards through `lib/path.ts`), this
|
|
53
|
+
* rejects dotfiles (including the directory's own `.gitignore`) and resolves
|
|
54
|
+
* symlinks, so a link placed inside the plans directory cannot redirect a read
|
|
55
|
+
* or write outside it.
|
|
56
|
+
*/
|
|
57
|
+
export function isContainedPlan(dir: string, path: string): boolean {
|
|
58
|
+
if (!isInside(dir, path) || !isPlanFileName(path)) return false;
|
|
59
|
+
try {
|
|
60
|
+
// A symlink itself must never be the target; a missing file is a new plan.
|
|
61
|
+
if (lstatSync(path).isSymbolicLink()) return false;
|
|
62
|
+
} catch {
|
|
63
|
+
// Missing (ENOENT) is expected; any other error falls through to the
|
|
64
|
+
// real-path check, which rejects the path if it cannot be resolved.
|
|
65
|
+
}
|
|
66
|
+
return isInsideReal(dir, dirname(path));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Resolve a `plan_path` argument against the store.
|
|
71
|
+
*
|
|
72
|
+
* A plan lives outside the project, so a path relative to the working directory
|
|
73
|
+
* can no longer name one. Absolute paths and the `~/…` form the tools display are
|
|
74
|
+
* accepted, and a bare name resolves inside the store — which is what a reader of
|
|
75
|
+
* a tool result has in hand.
|
|
76
|
+
*/
|
|
77
|
+
export function resolvePlanArg(dir: string, planPath: string): string {
|
|
78
|
+
const text = planPath.trim();
|
|
79
|
+
if (text === "~") return homedir();
|
|
80
|
+
if (text.startsWith("~/") || text.startsWith(`~${sep}`)) return resolve(homedir(), text.slice(2));
|
|
81
|
+
return isAbsolute(text) ? resolve(text) : resolve(dir, text);
|
|
82
|
+
}
|
package/lib/policy.ts
CHANGED
|
@@ -10,24 +10,27 @@
|
|
|
10
10
|
* `write`/`edit` being the only mutators.
|
|
11
11
|
*
|
|
12
12
|
* No host dependency beyond the annotation type: the caller supplies the
|
|
13
|
-
* annotation lookup,
|
|
14
|
-
* module stays value-only.
|
|
13
|
+
* annotation lookup, the one reader name comes from `tool-names.ts`, and which board
|
|
14
|
+
* actions read comes from `board/actions.ts`, so this module stays value-only.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import type { ToolAnnotations } from "@earendil-works/pi-coding-agent";
|
|
18
|
-
import {
|
|
18
|
+
import { isBoardReadAction } from "./board/actions.ts";
|
|
19
|
+
import { READ_DOC_TOOL } from "./tool-names.ts";
|
|
19
20
|
|
|
20
21
|
/**
|
|
21
22
|
* Whether a call to the `board` tool only reads it.
|
|
22
23
|
*
|
|
23
|
-
* The board is one tool over
|
|
24
|
+
* The board is one tool over many actions, so its `readOnlyHint` says "may write" and
|
|
24
25
|
* cannot answer this. The action can, and it travels in the call's input — and in the
|
|
25
26
|
* result's `details`, which is all a `tool_execution_end` event carries. Plan mode
|
|
26
|
-
* asks before the call and the todo nudge after it, so the answer lives here once
|
|
27
|
+
* asks before the call and the todo nudge after it, so the answer lives here once —
|
|
28
|
+
* and the answer is the board's own table, because an action list kept here is a list
|
|
29
|
+
* that drifts.
|
|
27
30
|
*/
|
|
28
31
|
export function isBoardRead(call: unknown): boolean {
|
|
29
32
|
if (typeof call !== "object" || call === null) return false;
|
|
30
|
-
return (call as { action?: unknown }).action
|
|
33
|
+
return isBoardReadAction((call as { action?: unknown }).action);
|
|
31
34
|
}
|
|
32
35
|
|
|
33
36
|
/**
|
package/lib/report.ts
CHANGED
|
@@ -147,8 +147,9 @@ export interface PendingChannelOptions {
|
|
|
147
147
|
* The order inside `session_start` is the contract: the reporter forgets the
|
|
148
148
|
* previous session's busy window, the runtime loads the records, and only then
|
|
149
149
|
* is the report trigger armed — a reload or a replaced session reuses the same
|
|
150
|
-
* runtime
|
|
151
|
-
*
|
|
150
|
+
* runtime, so a runtime that can settle an outcome while loading holds what it
|
|
151
|
+
* settled until this arms. A shutdown clears the trigger before the runtime
|
|
152
|
+
* releases the table it was calling into.
|
|
152
153
|
*/
|
|
153
154
|
export function registerPendingChannel(
|
|
154
155
|
pi: Pick<ExtensionAPI, "on">,
|