@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.
- package/README.md +141 -0
- package/cordis.patch.yml +27 -0
- package/lib/client.js +869 -0
- package/lib/index.js +1095 -0
- package/lib/types/client/PlanView.d.ts +35 -0
- package/lib/types/client/execution-launch.d.ts +65 -0
- package/lib/types/client/icons.d.ts +16 -0
- package/lib/types/client/index.d.ts +56 -0
- package/lib/types/client/locales.d.ts +94 -0
- package/lib/types/client/markdown-props.d.ts +44 -0
- package/lib/types/client/review-store.d.ts +76 -0
- package/lib/types/config.d.ts +53 -0
- package/lib/types/context.d.ts +80 -0
- package/lib/types/delivery-registry.d.ts +59 -0
- package/lib/types/first-heading.d.ts +25 -0
- package/lib/types/index.d.ts +74 -0
- package/lib/types/locale.d.ts +112 -0
- package/lib/types/plan-fold.d.ts +17 -0
- package/lib/types/prompt-override.d.ts +62 -0
- package/lib/types/resolve-cwd.d.ts +41 -0
- package/lib/types/review-gate.d.ts +107 -0
- package/lib/types/review-route.d.ts +90 -0
- package/lib/types/shadow-tool.d.ts +93 -0
- package/lib/types/trust-fence.d.ts +25 -0
- package/lib/types/ws-route.d.ts +58 -0
- package/package.json +116 -0
|
@@ -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;
|