@huanlin/dsh-plugin-better-plan 0.4.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.
@@ -0,0 +1,74 @@
1
+ /**
2
+ * @huanlin/dsh-plugin-better-plan — replaces the built-in plan mode's plan
3
+ * DELIVERY while keeping everything else about plan mode intact.
4
+ *
5
+ * The model writes the complete plan to a markdown file (guided by the tool
6
+ * description) and calls the same-name `exit_plan_mode` with its path. This
7
+ * plugin registers that same-name tool into EVERY agent's scope at
8
+ * `agent/session-start` (`agent.ctx.tools.register`) — per-agent scoped
9
+ * registrations shadow the preset-mounted built-in across scope layers, so
10
+ * the built-in plan-mode plugin stays mounted and untouched (its `plan:policy`
11
+ * section, `/plan` command, projection, and composer badge keep working).
12
+ *
13
+ * Delivery pipeline (execute): validate plan mode → resolve the path against
14
+ * the session cwd → stat/read with a byte cap → enqueue a push on the
15
+ * per-session delivery registry (consumed by the `/better-plan/ws/delivery`
16
+ * WebSocket when a sidebar view is attached) → when the push reached a view,
17
+ * PARK the call on the review gate: the conversation stops with no approval
18
+ * popup and the user decides in the plan panel (`POST /better-plan/api/review`
19
+ * settles it); when no view is attached, ask the SAME plan-review question
20
+ * the built-in tool asks, with the full plan as the detail (D3: without the
21
+ * sidebar the user still reviews the plan on the card).
22
+ *
23
+ * Approval queueing the mode flip: the preset realm's `planMode` service is
24
+ * invisible to this plugin, so the approved `plan/mode: false` append is
25
+ * deferred to the next accepted `agent/pre-step` boundary here — the same
26
+ * mechanism the built-in controller uses (the tool result is the narration).
27
+ *
28
+ * The preset's static `plan:policy` section still teaches the ORIGINAL
29
+ * inline-plan contract and bans file writes, so a `system-prompt/assemble`
30
+ * waterfall listener rewrites those sentences to the file-first contract on
31
+ * every assembled prompt (see `prompt-override.ts`).
32
+ *
33
+ * User-facing copy follows the session's locale (see `locale.ts`): the
34
+ * delivery render text (via the tool's `finalizeContent` seam), the steer
35
+ * messages, and the no-sidebar review question localize to the sidebar
36
+ * view's reported locale; the model contract (description, prompt rewrite,
37
+ * execute errors) stays English.
38
+ *
39
+ * @module @huanlin/dsh-plugin-better-plan
40
+ */
41
+ import type { Context } from './context.ts';
42
+ import { type BetterPlanConfig } from './config.ts';
43
+ import { PlanDeliveryRegistry } from './delivery-registry.ts';
44
+ import { LocaleDirectory } from './locale.ts';
45
+ import { PlanReviewGate } from './review-gate.ts';
46
+ export declare const name = "dsh-plugin-better-plan";
47
+ /**
48
+ * Services required before mounting: the tool registry (its availability
49
+ * gates scoped registrations), the webserver (the delivery push route), and
50
+ * the system-prompt registry (the assemble waterfall this plugin rewrites
51
+ * plan-mode guidance through).
52
+ */
53
+ export declare const inject: string[];
54
+ /** Loader schema (schemastery, strict) — validated by the cordis Loader. */
55
+ export { Config } from './config.ts';
56
+ export type { BetterPlanConfig } from './config.ts';
57
+ /**
58
+ * Wire the plugin onto a host context. Everything this registers is bound to
59
+ * `ctx`'s own fiber and cleans up on disposal (HMR-safe).
60
+ * @param ctx - the host plugin context.
61
+ * @param config - the resolved plugin config.
62
+ * @returns the created delivery registry and review gate (exposed for tests).
63
+ */
64
+ export declare function createBetterPlan(ctx: Context, config: BetterPlanConfig): {
65
+ registry: PlanDeliveryRegistry;
66
+ reviewGate: PlanReviewGate;
67
+ locales: LocaleDirectory;
68
+ };
69
+ /**
70
+ * Plugin entry.
71
+ * @param ctx - the host plugin context.
72
+ * @param config - the composition entry config (defaults fill in via the schema).
73
+ */
74
+ export declare function apply(ctx: Context, config?: Partial<BetterPlanConfig>): void;
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Locale vocabulary and resolution for the host half, plus the localized
3
+ * user-facing copy the host generates: the delivery render text (through the
4
+ * tool's `finalizeContent` seam), the steer messages that announce the
5
+ * sidebar decision, and the no-sidebar review question.
6
+ *
7
+ * Deliberately NOT localized (model contract, English only): the shadow
8
+ * tool's description, the `plan:policy` prompt rewrite, and every execute
9
+ * error message — those are instructions the model must parse reliably, and
10
+ * the built-in presets keep the same English-only contract.
11
+ *
12
+ * The active locale resolves per session: the plugin config's `locale`
13
+ * override wins, else the locale the sidebar view reported (the browser's
14
+ * active DSH locale, carried on the delivery WS connect and every review
15
+ * request), else English.
16
+ *
17
+ * @module @huanlin/dsh-plugin-better-plan/locale
18
+ */
19
+ /** The locales this plugin ships copy for. */
20
+ export type PlanLocale = 'zh' | 'en';
21
+ /** The plugin config's locale knob: a forced locale or browser following. */
22
+ export type LocaleSetting = 'auto' | PlanLocale;
23
+ /**
24
+ * Normalize one client-reported locale tag (BCP 47-style, e.g. `zh-CN`)
25
+ * to a shipped {@link PlanLocale}, or undefined when unsupported.
26
+ * @param value - the raw reported value (query param / body field).
27
+ * @returns `'zh'` / `'en'`, or undefined when the value is not a supported tag.
28
+ */
29
+ export declare function normalizeReportedLocale(value: unknown): PlanLocale | undefined;
30
+ /**
31
+ * Per-session directory of sidebar-reported locales. The browser view is the
32
+ * authority on the user's language (the DSH locale preference is Host-backed
33
+ * and already reflected in the client's active locale), so the host learns
34
+ * the locale from the client instead of reading settings itself.
35
+ */
36
+ export declare class LocaleDirectory {
37
+ private reported;
38
+ /**
39
+ * Record one view-reported locale for a session (invalid values ignored).
40
+ * @param sessionId - the session the view is subscribed to.
41
+ * @param value - the raw reported locale tag.
42
+ */
43
+ report(sessionId: string, value: unknown): void;
44
+ /**
45
+ * The locale a session's connected view reported, if any.
46
+ * @param sessionId - the session to look up.
47
+ */
48
+ known(sessionId: string | undefined): PlanLocale | undefined;
49
+ /** Drop every report (plugin disposal). */
50
+ dispose(): void;
51
+ }
52
+ /**
53
+ * Resolve the locale for one session's user-facing copy.
54
+ * @param setting - the plugin config's locale knob.
55
+ * @param directory - the sidebar-reported locale directory.
56
+ * @param sessionId - the session the copy is generated for.
57
+ * @returns the resolved locale (English when nothing better is known).
58
+ */
59
+ export declare function resolvePlanLocale(setting: LocaleSetting, directory: LocaleDirectory, sessionId: string | undefined): PlanLocale;
60
+ /**
61
+ * The localized render content for a delivered plan (the pending and approved
62
+ * branches of the shadow tool's render). English returns undefined — the
63
+ * caller preserves the render's own baseline content.
64
+ * @param path - the delivered plan file path (for the approved note).
65
+ * @param value - the canonical exit value.
66
+ * @param locale - the resolved session locale.
67
+ * @returns the localized content blocks, or undefined to keep the baseline.
68
+ */
69
+ export declare function localizedRenderContent(path: string, value: {
70
+ delivered?: unknown;
71
+ decision?: unknown;
72
+ }, locale: PlanLocale): {
73
+ type: 'text';
74
+ text: string;
75
+ }[] | undefined;
76
+ /**
77
+ * The steer message fired when the sidebar approval lands.
78
+ * @param locale - the resolved session locale.
79
+ * @returns the steer text.
80
+ */
81
+ export declare function approvalSteerText(locale: PlanLocale): string;
82
+ /**
83
+ * The steer message fired when the sidebar approval delegates execution to a
84
+ * new conversation: the planning session is closed out (plan mode off) and
85
+ * must NOT execute the plan itself.
86
+ * @param locale - the resolved session locale.
87
+ * @returns the steer text.
88
+ */
89
+ export declare function delegatedSteerText(locale: PlanLocale): string;
90
+ /**
91
+ * The steer message fired when the sidebar keeps planning.
92
+ * @param feedback - the user's optional feedback (already trimmed).
93
+ * @param locale - the resolved session locale.
94
+ * @returns the steer text.
95
+ */
96
+ export declare function keepPlanningSteerText(feedback: string | undefined, locale: PlanLocale): string;
97
+ /** The localized copy of the no-sidebar plan-review question. */
98
+ export interface PlanReviewCopy {
99
+ header: string;
100
+ question: string;
101
+ approveLabel: string;
102
+ approveDescription: string;
103
+ keepLabel: string;
104
+ keepDescription: string;
105
+ }
106
+ /**
107
+ * The no-sidebar plan-review question's copy (the popup fallback surface).
108
+ * @param locale - the resolved session locale.
109
+ * @returns the question copy; labels pair with the ask intent so the
110
+ * plan-review takeover matches the approve option by label.
111
+ */
112
+ export declare function planReviewCopy(locale: PlanLocale): PlanReviewCopy;
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Local fold of the logged plan-mode state (last `plan/mode` wins; a log
3
+ * with none folds to inactive). Replaces the `foldPlanMode` export that
4
+ * dsh 0.1.2-alpha.2 removed from @deepseek-ai/dsh-plan-mode when plan
5
+ * state moved into the `plan` session projection.
6
+ *
7
+ * dsh 0.1.2-rc.1 removed the public `session.events` array (seq/offset
8
+ * split, commit 27bf1039db): callers now pass the session itself and the
9
+ * fold reads `snapshotEvents()`.
10
+ * @module @huanlin/dsh-plugin-better-plan/plan-fold
11
+ */
12
+ import type { SessionEvent } from '@deepseek-ai/dsh-session';
13
+ /** The session-log face this fold reads (structural mirror of `Session`). */
14
+ export interface SessionEventLog {
15
+ snapshotEvents(): readonly SessionEvent[];
16
+ }
17
+ export declare function isPlanModeActive(session: SessionEventLog): boolean;
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Prompt-surface override for the built-in plan mode's `plan:policy` section.
3
+ *
4
+ * The preset ships the section as static config text teaching the ORIGINAL
5
+ * delivery contract ("call exit_plan_mode with the complete plan markdown")
6
+ * while forbidding file writes — both directly contradict this plugin's
7
+ * file-first tool contract (`exit_plan_mode({ path })`). The section text is
8
+ * unreachable as config (isolated preset realm), so the correction runs on the
9
+ * assembled prompt instead: a `system-prompt/assemble` waterfall listener
10
+ * rewrites the offending sentences in place, leaving every other section —
11
+ * and any deployment-customized wording around them — untouched.
12
+ *
13
+ * The anchor sentence only appears in the assembly while plan mode is active
14
+ * (the built-in provider returns `''` otherwise), so the listener needs no
15
+ * plan-state tracking of its own. The mutation is visible to the session log
16
+ * through the per-request `request/header` event, which records the rendered
17
+ * system prompt after this waterfall.
18
+ *
19
+ * Registration is per-agent, on the agent's own scope: the loop assembles
20
+ * every prompt with the agent as the dispatch key (`assembleContextFor`), and
21
+ * scope-chain admission flows events UP the chain only — a listener on this
22
+ * plugin's own fiber is a sibling of the composition scopes and would never
23
+ * be admitted. This mirrors the tool shadow's per-agent registration.
24
+ *
25
+ * @module @huanlin/dsh-plugin-better-plan
26
+ */
27
+ import type { Context as CordisContext } from '@deepseek-ai/cordis';
28
+ /**
29
+ * The shipped preset's delivery sentence, verbatim across the standard, ptc,
30
+ * and cordis presets. Doubles as the plan-mode-active gate for the listener.
31
+ */
32
+ export declare const PLAN_DELIVERY_ANCHOR = "When ready, call exit_plan_mode with the complete plan markdown, starting with a # title.";
33
+ /**
34
+ * The shipped sentence right after the delivery anchor, verbatim across the
35
+ * standard, ptc, and cordis presets. With the file-first contract the write
36
+ * tool call necessarily precedes exit_plan_mode in the delivery turn, so this
37
+ * "only and final tool call" sentence reads as forbidding exactly that call —
38
+ * the observed write-then-stop failure — and must be rewritten too.
39
+ */
40
+ export declare const FINAL_CALL_ANCHOR = "Make exit_plan_mode the only and final tool call in that assistant response: it presents the plan for approval, and implementation begins only in a later step after approval.";
41
+ /**
42
+ * Rewrite the shipped plan-mode guidance to the file-first delivery contract.
43
+ * Each replacement is independent: a sentence whose anchor is absent (an
44
+ * older or customized preset variant) is left as-is, and already-rewritten
45
+ * text passes through unchanged, so the function is idempotent.
46
+ * @param text - one assembled prompt section's text.
47
+ * @param planDir - the plugin config's suggested plan directory.
48
+ * @returns the corrected text, or the input untouched when no anchor matches.
49
+ */
50
+ export declare function rewritePlanPolicySection(text: string, planDir: string): string;
51
+ /**
52
+ * Register the assemble waterfall listener on one agent's scoped context.
53
+ * The agent is the loop's assemble dispatch key, so a listener tagged with
54
+ * that exact scope is admitted for every prompt this agent assembles.
55
+ * Sections without the plan-mode anchor pass through untouched, so non-plan
56
+ * requests pay one string scan per section.
57
+ * @param ctx - the agent's scoped context (`agent.ctx`); only event
58
+ * registration is required, so the plain Cordis face suffices.
59
+ * @param planDir - the plugin config's suggested plan directory.
60
+ * @returns the listener disposer (for the caller's lifecycle effect).
61
+ */
62
+ export declare function registerPlanPolicyOverride(ctx: CordisContext, planDir: string): () => void;
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Session working-directory resolution for relative plan paths.
3
+ *
4
+ * Chain (mirror of the sidebar's sessionCwdOf): the live session header wins;
5
+ * while that header carries no cwd (a stripped-down host or a session created
6
+ * without workspace metadata) the persistence index is consulted for cold
7
+ * sessions; the host process cwd is the final fallback. A persistence failure
8
+ * or a non-absolute persisted value also falls through to the process cwd —
9
+ * a delivery tool should not die because the index hiccups, and a wrong
10
+ * fallback surfaces as a clear "file does not exist" error from the caller's
11
+ * own stat.
12
+ *
13
+ * @module @huanlin/dsh-plugin-better-plan/resolve-cwd
14
+ */
15
+ /** The session-header slice this resolver reads. */
16
+ export interface SessionCwdProbe {
17
+ readonly header?: {
18
+ readonly cwd?: string;
19
+ };
20
+ }
21
+ /**
22
+ * The session-persistence face this resolver consults (structural mirror).
23
+ * dsh 0.1.2-rc.1 rewrote the contract as handle-based snapshots: the old
24
+ * `inspect(id)` became `stat(id)` returning a `SessionPersistenceSnapshot`
25
+ * (the header lives under `snapshot.header`), or undefined when unknown.
26
+ */
27
+ export interface PersistenceStat {
28
+ stat(sessionId: string): Promise<{
29
+ header: {
30
+ cwd?: string;
31
+ };
32
+ } | undefined>;
33
+ }
34
+ /**
35
+ * Resolve one session's working directory.
36
+ * @param session - the calling session (its header cwd is authoritative).
37
+ * @param sessionId - the session id, for the persistence lookup.
38
+ * @param persistence - the optional session-persistence service.
39
+ * @returns an absolute working directory (never empty).
40
+ */
41
+ export declare function resolveSessionCwd(session: SessionCwdProbe | undefined, sessionId: string, persistence: PersistenceStat | undefined): Promise<string>;
@@ -0,0 +1,107 @@
1
+ /**
2
+ * The plan-review gate: the per-session record of a delivered plan that
3
+ * awaits the user's decision IN THE SIDEBAR (no chat popup).
4
+ *
5
+ * The shadowed `exit_plan_mode` returns immediately after a delivered push
6
+ * (the model ends its turn on the render's instruction — the conversation
7
+ * simply stops), and the user decides later in the plan panel. The gate
8
+ * holds that pending decision and its handlers; `decide()` (driven by the
9
+ * review HTTP route) settles the state, broadcasts it to the attached
10
+ * sidebar views, and fires the handlers that steer the decision back to the
11
+ * model. Handlers never throw into the route: they are defensive at their
12
+ * definition site.
13
+ *
14
+ * Every state change broadcasts a `{ kind: 'review', review }` frame, and
15
+ * `attach()` replays the latest state (including a pending review) so a page
16
+ * refresh restores the action bar.
17
+ *
18
+ * @module @huanlin/dsh-plugin-better-plan/review-gate
19
+ */
20
+ /** Lifecycle of one plan review (the wire status the plan panel renders). */
21
+ export type ReviewStatus = 'pending' | 'approved' | 'delegated' | 'kept' | 'cancelled';
22
+ /** The sidebar decision vocabulary: how the plan panel settles a review. */
23
+ export type ReviewDecision = 'approve' | 'keep' | 'approve_new_session';
24
+ /** One review's user-visible state (the wire face of a review frame). */
25
+ export interface ReviewState {
26
+ /** The delivery id this review belongs to (stale-click guard). */
27
+ id: string;
28
+ /** Absolute path of the plan file under review. */
29
+ path: string;
30
+ /** The plan panel tab title. */
31
+ title: string;
32
+ status: ReviewStatus;
33
+ }
34
+ /** The server→view frame carrying review state (null = nothing to show). */
35
+ export interface ReviewFrame {
36
+ kind: 'review';
37
+ review: ReviewState | null;
38
+ }
39
+ /** One attached sidebar view's review-frame sender. */
40
+ export type ReviewSender = (frame: ReviewFrame) => void;
41
+ /**
42
+ * The decision side effects, wired by the tool that delivered the plan:
43
+ * steer the outcome back to the model (approval also flips plan mode off;
44
+ * delegation flips it off too but hands execution to a new conversation).
45
+ */
46
+ export interface ReviewHandlers {
47
+ onApprove(): void;
48
+ onKeep(feedback: string | undefined): void;
49
+ onDelegate(): void;
50
+ }
51
+ /**
52
+ * Per-session pending decision plus the attached view set. One pending
53
+ * review per session; a newer delivery supersedes the previous one (its
54
+ * handlers never fire — the newest plan is the one under review).
55
+ */
56
+ export declare class PlanReviewGate {
57
+ private pending;
58
+ /** Latest known state per session — attach replay + stale-window reads. */
59
+ private latest;
60
+ private subscribers;
61
+ /**
62
+ * Record one pending review and broadcast it.
63
+ * @param sessionId - the session whose plan is under review.
64
+ * @param review - the delivery identity (id from the delivery push).
65
+ * @param handlers - the decision side effects (steer back to the model).
66
+ */
67
+ begin(sessionId: string, review: {
68
+ id: string;
69
+ path: string;
70
+ title: string;
71
+ }, handlers: ReviewHandlers): void;
72
+ /**
73
+ * Settle the session's pending review from the sidebar decision.
74
+ * @param sessionId - the session under review.
75
+ * @param decision - the user's choice (approve_new_session settles as
76
+ * `delegated`: execution continues in a new conversation).
77
+ * @param feedback - optional keep-planning feedback (trimmed; forwarded to
78
+ * the model verbatim in the steer message).
79
+ * @returns the settled review state, or undefined when nothing is pending.
80
+ */
81
+ decide(sessionId: string, decision: ReviewDecision, feedback?: string): ReviewState | undefined;
82
+ /**
83
+ * Read the session's pending review (stale-click guard + GET bootstrap).
84
+ * @param sessionId - the session to inspect.
85
+ * @returns the pending review, or null when nothing is parked.
86
+ */
87
+ peek(sessionId: string): ReviewState | null;
88
+ /**
89
+ * Attach one sidebar view; the latest known review state replays
90
+ * immediately (a `null` frame clears a stale bar), and later changes push.
91
+ * @param sessionId - the session the view displays.
92
+ * @param send - the review-frame sender.
93
+ * @returns the disposer detaching the view.
94
+ */
95
+ attach(sessionId: string, send: ReviewSender): () => void;
96
+ /**
97
+ * Settle every pending review as cancelled and drop the views (plugin
98
+ * teardown). Handlers do not fire: a reload discards the decision surface,
99
+ * and the user re-drives the session.
100
+ */
101
+ dispose(): void;
102
+ /**
103
+ * Record one state as latest and broadcast it to the session's views.
104
+ * @returns the recorded state.
105
+ */
106
+ private settle;
107
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * The `POST /better-plan/api/review` route: the browser→host command channel
3
+ * that settles a parked plan review (the tab→host half of the sidebar
4
+ * approval flow the chat popup used to own).
5
+ *
6
+ * The plan panel posts `{ session, decision, feedback?, id? }` (decision:
7
+ * approve / keep / approve_new_session); the route settles the session's
8
+ * pending review through the {@link PlanReviewGate} and echoes the settled
9
+ * state back so the submitting view (and, over the delivery WebSocket,
10
+ * every other view) reflects the decision. `approve_new_session` settles
11
+ * as `delegated`: the planning session is closed out with a handoff steer
12
+ * and the panel itself launches the execution conversation.
13
+ *
14
+ * The same browser-trust fence as the delivery WebSocket guards the route:
15
+ * this is a DNS-rebinding / cross-site defense for a session-scoped command,
16
+ * not authentication.
17
+ *
18
+ * @module @huanlin/dsh-plugin-better-plan/review-route
19
+ */
20
+ import type { PlanReviewGate, ReviewDecision } from './review-gate.ts';
21
+ import type { LocaleDirectory } from './locale.ts';
22
+ import { type FenceRequest } from './trust-fence.ts';
23
+ /** The exact pathname the route registers on the host webServer. */
24
+ export declare const REVIEW_API_PATH = "/better-plan/api/review";
25
+ /** Request-body cap: a decision is a handful of fields, not a plan. */
26
+ export declare const REVIEW_BODY_LIMIT = 4096;
27
+ /** The request face the handler consumes (node:http IncomingMessage subset). */
28
+ export interface ReviewHttpRequest extends FenceRequest {
29
+ method?: string;
30
+ url?: string;
31
+ [Symbol.asyncIterator](): AsyncIterableIterator<Uint8Array | string>;
32
+ }
33
+ /** The response face the handler writes (node:http ServerResponse subset). */
34
+ export interface ReviewHttpResponse {
35
+ writeHead(status: number, headers?: Record<string, string>): unknown;
36
+ end(body?: string): unknown;
37
+ }
38
+ /** The exact HTTP route this plugin registers. */
39
+ export interface ReviewApiRoute {
40
+ kind: 'exact';
41
+ path: string;
42
+ handler: (req: ReviewHttpRequest, res: ReviewHttpResponse) => void | Promise<void>;
43
+ }
44
+ /** One parsed review decision body. */
45
+ export interface ReviewDecisionBody {
46
+ session: string;
47
+ decision: ReviewDecision;
48
+ feedback?: string;
49
+ id?: string;
50
+ /** The submitting view's active locale tag (BCP 47-style; optional). */
51
+ locale?: string;
52
+ }
53
+ /**
54
+ * Parse and validate one review decision body (wire-boundary validation).
55
+ * @param raw - the request body text.
56
+ * @returns the parsed decision, or an error message.
57
+ */
58
+ export declare function parseReviewDecisionBody(raw: string): {
59
+ value: ReviewDecisionBody;
60
+ error?: undefined;
61
+ } | {
62
+ value?: undefined;
63
+ error: string;
64
+ };
65
+ /**
66
+ * Serve one review request: GET bootstraps the plan panel's action bar with
67
+ * the current state (the WS attach replay remains the live channel); POST
68
+ * settles the pending decision. Both verbs record the submitting view's
69
+ * reported locale so the host's user-facing copy follows the browser.
70
+ *
71
+ * Error bodies carry a stable machine-readable `code` alongside the English
72
+ * `error` message: the plan panel maps known codes to localized copy and
73
+ * falls back to the raw message for unknown ones.
74
+ *
75
+ * @param gate - the review gate holding the pending review.
76
+ * @param req - the request (method/headers/body iterator).
77
+ * @param res - the response.
78
+ * @param trustedHosts - non-loopback authorities the deployment serves.
79
+ * @param directory - the locale directory view reports are recorded in.
80
+ */
81
+ export declare function handleReviewRequest(gate: PlanReviewGate, req: ReviewHttpRequest, res: ReviewHttpResponse, trustedHosts: readonly string[], directory: LocaleDirectory): Promise<void>;
82
+ /**
83
+ * Register the review decision route on the host webServer.
84
+ * @param register - the webServer's route registrar.
85
+ * @param gate - the review gate.
86
+ * @param trustedHosts - non-loopback authorities the deployment serves.
87
+ * @param directory - the locale directory view reports are recorded in.
88
+ * @returns the route disposer.
89
+ */
90
+ export declare function registerReviewRoute(register: (route: ReviewApiRoute) => () => void, gate: PlanReviewGate, trustedHosts: readonly string[], directory: LocaleDirectory): () => void;
@@ -0,0 +1,93 @@
1
+ /**
2
+ * The shadowed `exit_plan_mode` tool — better-plan's whole model-facing
3
+ * contract.
4
+ *
5
+ * The tool keeps the built-in plan mode's name (D1): every agent preset's
6
+ * planning group mounts `@deepseek-ai/dsh-plan-mode` inside an isolate realm,
7
+ * and preset mount lines cannot be patched, so a same-name per-agent
8
+ * registration through `agent.ctx` is the only replacement seam. Shadowing
9
+ * keeps the preset's `plan:policy` section verbatim (its statements about
10
+ * `exit_plan_mode` remain true) and leaves `/plan`, the projection, and the
11
+ * composer badge working unchanged.
12
+ *
13
+ * What changes is the delivery: the model must write the COMPLETE plan to a
14
+ * markdown file first (guided by this description — D2 verifies only that the
15
+ * file exists and is readable), then pass its path. When the push reaches a
16
+ * connected sidebar view, the tool RETURNS IMMEDIATELY with `decision:
17
+ * 'pending'` — the render instructs the model to end its turn, so the
18
+ * conversation simply stops with no approval popup — and the user reviews the
19
+ * plan and decides in the sidebar plan panel. The decision is steered back as
20
+ * the next turn's message (approval also flips plan mode off, see the review
21
+ * handlers below). When no view is attached, the built-in plan-review
22
+ * question renders in chat with the full plan text and blocks exactly like
23
+ * the original tool (no-sidebar environment ⇒ the original experience).
24
+ *
25
+ * Conventions (per plugin-development-guide.md §3):
26
+ * C4 — `execute` returns one canonical JSON value; `render` is separate.
27
+ * C6 — `exec.signal.throwIfAborted()` before any fs work.
28
+ * C9 — presentCall/presentResult are pure functions of their arguments.
29
+ *
30
+ * @module @huanlin/dsh-plugin-better-plan/shadow-tool
31
+ */
32
+ import type { ToolDefinition } from '@deepseek-ai/dsh-tools';
33
+ import type { BetterPlanConfig } from './config.ts';
34
+ import type { Context } from './context.ts';
35
+ import type { PlanDeliveryRegistry } from './delivery-registry.ts';
36
+ import { type PlanLocale } from './locale.ts';
37
+ import type { PlanReviewGate } from './review-gate.ts';
38
+ /** The review question's id, echoed in the answer this tool reads. */
39
+ export declare const REVIEW_ID = "plan-review";
40
+ /**
41
+ * The canonical tool value: `pending` = the plan reached the sidebar and the
42
+ * decision comes later (the model must end its turn); `approved` = the
43
+ * no-sidebar popup path answered in-turn. `delivered` distinguishes the
44
+ * push outcome for the render's context note.
45
+ */
46
+ export interface ExitPlanValue {
47
+ delivered: boolean;
48
+ decision: 'pending' | 'approved';
49
+ }
50
+ /**
51
+ * The model-facing description. The model's only new knowledge: the
52
+ * file-first contract, the immediate-return + end-turn contract, and the
53
+ * decision arriving as the next message. `planDir` is interpolated into the
54
+ * example path.
55
+ * @param config - the plugin config (planDir suggestion).
56
+ * @returns the description string.
57
+ */
58
+ export declare function exitPlanDescription(config: BetterPlanConfig): string;
59
+ /**
60
+ * The review question's detail: the full plan text. Only the no-sidebar
61
+ * fallback reaches the question (a delivered plan is reviewed in the sidebar
62
+ * instead), so the user always sees the whole plan on the popup card.
63
+ * @param plan - the full plan markdown.
64
+ * @returns the detail string for the plan-review question.
65
+ */
66
+ export declare function reviewDetail(plan: string): string;
67
+ /** Dependencies the shadow tool closes over (all provided by the plugin entry). */
68
+ export interface ShadowToolDeps {
69
+ /** The host plugin context (service reads at execute time). */
70
+ ctx: Context;
71
+ /** Resolved plugin config. */
72
+ config: BetterPlanConfig;
73
+ /** The delivery registry (per-session queue + views). */
74
+ registry: PlanDeliveryRegistry;
75
+ /** The sidebar review gate (records the pending decision + handlers). */
76
+ reviewGate: PlanReviewGate;
77
+ /**
78
+ * The session's resolved locale for user-facing copy (config override →
79
+ * sidebar-reported → en). Model-contract text never localizes.
80
+ */
81
+ localeOf: (sessionId: string | undefined) => PlanLocale;
82
+ /** Whether the plugin fiber was disposed while a review may be pending. */
83
+ isDisposed: () => boolean;
84
+ /** Queue the approved mode flip for the next accepted pre-step boundary. */
85
+ onApproved: (session: object) => void;
86
+ }
87
+ /**
88
+ * Build the shadowed tool definition. Registration is the caller's job
89
+ * (`agent.ctx.tools.register` from the session-start hook).
90
+ * @param deps - the plugin-provided collaborators.
91
+ * @returns a registry-ready tool definition.
92
+ */
93
+ export declare function defineExitPlanTool(deps: ShadowToolDeps): ToolDefinition;
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Browser-trust fence for the delivery WebSocket, behaviorally identical to
3
+ * the sidebar's fence (and to the /api gateway's fence in
4
+ * @deepseek-ai/dsh-client-connection, from which both derive): Host-header
5
+ * loopback or a configured trusted authority passes; cross-site browser
6
+ * markers refuse. This is a DNS-rebinding / cross-site defense, not
7
+ * authentication — the push payload (plan path + title) is chat-adjacent
8
+ * data, so the fence keeps off-host pages from harvesting it.
9
+ *
10
+ * @module @huanlin/dsh-plugin-better-plan/trust-fence
11
+ */
12
+ /** The request facts the fence reads. */
13
+ export interface FenceRequest {
14
+ headers: Record<string, string | string[] | undefined>;
15
+ }
16
+ /** Whether a normalized URL hostname names the local loopback authority. */
17
+ export declare function isLoopbackHostname(hostname: string): boolean;
18
+ /**
19
+ * Decide whether one request may reach the delivery WebSocket.
20
+ * @param request - the upgrade request's headers.
21
+ * @param trustedHosts - non-loopback authorities this deployment serves.
22
+ * @returns true when the Host is ours (loopback or trusted) and browser
23
+ * markers are same-origin.
24
+ */
25
+ export declare function isTrustedDeliveryRequest(request: FenceRequest, trustedHosts: readonly string[]): boolean;