@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/lib/index.js ADDED
@@ -0,0 +1,1095 @@
1
+ import { EXIT_PLAN_MODE } from "@deepseek-ai/dsh-plan-mode";
2
+ import z from "schemastery";
3
+ import { randomUUID } from "node:crypto";
4
+ import { readFile, stat } from "node:fs/promises";
5
+ import { isAbsolute, join } from "node:path";
6
+ import { createUserMessage } from "@deepseek-ai/dsh-llm";
7
+ import { defineTool } from "@deepseek-ai/dsh-tools";
8
+ import { UserQuestionError } from "@deepseek-ai/dsh-user-questions";
9
+ import { WebSocketServer } from "ws";
10
+ //#region src/plan-fold.ts
11
+ function isPlanModeActive(session) {
12
+ let active = false;
13
+ for (const event of session.snapshotEvents()) if (event.type === "plan/mode") active = event.data.active;
14
+ return active;
15
+ }
16
+ //#endregion
17
+ //#region src/config.ts
18
+ /**
19
+ * Config schema for the better-plan plugin (Schemastery, strict).
20
+ *
21
+ * The plugin injects no extra system-prompt section: the new delivery contract
22
+ * travels entirely inside the shadowed `exit_plan_mode` tool description, so
23
+ * the built-in `plan:policy` section stays verbatim and these two knobs are
24
+ * the whole configuration surface.
25
+ *
26
+ * @module @huanlin/dsh-plugin-better-plan/config
27
+ */
28
+ /** Schemastery schema validated by the cordis Loader. */
29
+ const Config = z.object({
30
+ planDir: z.string().default("docs/plans"),
31
+ maxPlanBytes: z.number().step(1).min(1).default(262144),
32
+ locale: z.union([
33
+ "auto",
34
+ "zh",
35
+ "en"
36
+ ]).default("auto")
37
+ });
38
+ /** Known config keys (for strict unknown-key rejection). */
39
+ const CONFIG_KEYS = /* @__PURE__ */ new Set([
40
+ "planDir",
41
+ "maxPlanBytes",
42
+ "locale"
43
+ ]);
44
+ /**
45
+ * Resolve a raw config patch through the schema, returning a full
46
+ * {@link BetterPlanConfig} with defaults applied. Unknown keys are rejected
47
+ * here so a mistyped cordis.yml row fails loud at load instead of being
48
+ * silently ignored.
49
+ * @param input - a partial or complete config object.
50
+ * @returns the schema-resolved config.
51
+ * @throws when the input carries an unknown key or a value the schema rejects.
52
+ */
53
+ function resolveBetterPlanConfig(input) {
54
+ if (input !== null && typeof input === "object" && !Array.isArray(input)) {
55
+ for (const key of Object.keys(input)) if (!CONFIG_KEYS.has(key)) throw new Error(`better-plan: unknown config key "${key}" — config is { planDir, maxPlanBytes, locale }`);
56
+ }
57
+ return Config(input);
58
+ }
59
+ /**
60
+ * Per-session delivery queues plus the connected views.
61
+ */
62
+ var PlanDeliveryRegistry = class {
63
+ pending = /* @__PURE__ */ new Map();
64
+ subscribers = /* @__PURE__ */ new Map();
65
+ /**
66
+ * Queue one delivery and push it immediately when a view is attached.
67
+ * @param sessionId - the session whose plan panel is targeted.
68
+ * @param path - absolute path of the plan file.
69
+ * @param title - sidebar tab title for the plan.
70
+ * @returns the delivery id and whether a connected view received it now.
71
+ */
72
+ enqueue(sessionId, path, title) {
73
+ const delivery = {
74
+ id: randomUUID(),
75
+ path,
76
+ title
77
+ };
78
+ const list = this.pending.get(sessionId) ?? [];
79
+ list.push(delivery);
80
+ if (list.length > 8) list.shift();
81
+ this.pending.set(sessionId, list);
82
+ const views = this.subscribers.get(sessionId);
83
+ if (views !== void 0 && views.size > 0) {
84
+ for (const send of views) send(delivery);
85
+ this.pending.delete(sessionId);
86
+ return {
87
+ id: delivery.id,
88
+ delivered: true
89
+ };
90
+ }
91
+ return {
92
+ id: delivery.id,
93
+ delivered: false
94
+ };
95
+ }
96
+ /**
97
+ * Attach one sidebar view; queued deliveries replay immediately
98
+ * (consume-on-send: a reconnect must never re-show a plan already open).
99
+ * @param sessionId - the session the view displays.
100
+ * @param send - the push callback for this view.
101
+ * @returns the disposer detaching the view.
102
+ */
103
+ attach(sessionId, send) {
104
+ let views = this.subscribers.get(sessionId);
105
+ if (views === void 0) {
106
+ views = /* @__PURE__ */ new Set();
107
+ this.subscribers.set(sessionId, views);
108
+ }
109
+ views.add(send);
110
+ const queued = this.pending.get(sessionId) ?? [];
111
+ if (queued.length > 0) {
112
+ for (const delivery of queued) send(delivery);
113
+ this.pending.delete(sessionId);
114
+ }
115
+ return () => {
116
+ const current = this.subscribers.get(sessionId);
117
+ current?.delete(send);
118
+ if (current !== void 0 && current.size === 0) this.subscribers.delete(sessionId);
119
+ };
120
+ }
121
+ /** Drop every queued delivery (kept for symmetry with the sidebar registry). */
122
+ drainAll() {
123
+ this.pending.clear();
124
+ }
125
+ /** Drop every queue and subscriber (plugin teardown). */
126
+ dispose() {
127
+ this.pending.clear();
128
+ this.subscribers.clear();
129
+ }
130
+ };
131
+ //#endregion
132
+ //#region src/locale.ts
133
+ /**
134
+ * Normalize one client-reported locale tag (BCP 47-style, e.g. `zh-CN`)
135
+ * to a shipped {@link PlanLocale}, or undefined when unsupported.
136
+ * @param value - the raw reported value (query param / body field).
137
+ * @returns `'zh'` / `'en'`, or undefined when the value is not a supported tag.
138
+ */
139
+ function normalizeReportedLocale(value) {
140
+ if (typeof value !== "string") return void 0;
141
+ const tag = value.trim().toLowerCase();
142
+ if (tag === "" || tag.length > 12 || !/^[a-z]{2,3}(?:-[a-z0-9]{1,8})*$/i.test(tag)) return void 0;
143
+ if (tag === "zh" || tag.startsWith("zh-")) return "zh";
144
+ if (tag === "en" || tag.startsWith("en-")) return "en";
145
+ }
146
+ /**
147
+ * Per-session directory of sidebar-reported locales. The browser view is the
148
+ * authority on the user's language (the DSH locale preference is Host-backed
149
+ * and already reflected in the client's active locale), so the host learns
150
+ * the locale from the client instead of reading settings itself.
151
+ */
152
+ var LocaleDirectory = class {
153
+ reported = /* @__PURE__ */ new Map();
154
+ /**
155
+ * Record one view-reported locale for a session (invalid values ignored).
156
+ * @param sessionId - the session the view is subscribed to.
157
+ * @param value - the raw reported locale tag.
158
+ */
159
+ report(sessionId, value) {
160
+ const locale = normalizeReportedLocale(value);
161
+ if (locale === void 0 || sessionId === "") return;
162
+ this.reported.set(sessionId, locale);
163
+ }
164
+ /**
165
+ * The locale a session's connected view reported, if any.
166
+ * @param sessionId - the session to look up.
167
+ */
168
+ known(sessionId) {
169
+ if (sessionId === void 0) return void 0;
170
+ return this.reported.get(sessionId);
171
+ }
172
+ /** Drop every report (plugin disposal). */
173
+ dispose() {
174
+ this.reported.clear();
175
+ }
176
+ };
177
+ /**
178
+ * Resolve the locale for one session's user-facing copy.
179
+ * @param setting - the plugin config's locale knob.
180
+ * @param directory - the sidebar-reported locale directory.
181
+ * @param sessionId - the session the copy is generated for.
182
+ * @returns the resolved locale (English when nothing better is known).
183
+ */
184
+ function resolvePlanLocale(setting, directory, sessionId) {
185
+ if (setting !== "auto") return setting;
186
+ return directory.known(sessionId) ?? "en";
187
+ }
188
+ /**
189
+ * The localized render content for a delivered plan (the pending and approved
190
+ * branches of the shadow tool's render). English returns undefined — the
191
+ * caller preserves the render's own baseline content.
192
+ * @param path - the delivered plan file path (for the approved note).
193
+ * @param value - the canonical exit value.
194
+ * @param locale - the resolved session locale.
195
+ * @returns the localized content blocks, or undefined to keep the baseline.
196
+ */
197
+ function localizedRenderContent(path, value, locale) {
198
+ if (locale !== "zh") return void 0;
199
+ if (value.decision === "pending") return [{
200
+ type: "text",
201
+ text: "计划已呈现在侧边栏的「计划」面板,等待用户审阅。请立即结束回合:简单说明计划已在侧边栏等待审阅,然后停止——不要再调用任何工具。用户的决定会作为下一条消息送达:批准将关闭计划模式,你可以从下一步开始执行计划;「继续规划」的反馈会要求你修改计划文件后重新提交。"
202
+ }];
203
+ if (value.decision === "approved") return [{
204
+ type: "text",
205
+ text: `计划已批准——计划模式已退出;从下一步开始执行计划。(当前未连接侧边栏计划面板;计划文件位于 ${path}。)`
206
+ }];
207
+ }
208
+ /**
209
+ * The steer message fired when the sidebar approval lands.
210
+ * @param locale - the resolved session locale.
211
+ * @returns the steer text.
212
+ */
213
+ function approvalSteerText(locale) {
214
+ if (locale === "zh") return "[计划审批] 用户已在侧边栏计划面板批准该计划。计划模式现已关闭——从这一步开始执行计划。";
215
+ return "[Plan review] The user approved the plan in the sidebar plan panel. Plan mode is now off — carry out the plan starting with this step.";
216
+ }
217
+ /**
218
+ * The steer message fired when the sidebar approval delegates execution to a
219
+ * new conversation: the planning session is closed out (plan mode off) and
220
+ * must NOT execute the plan itself.
221
+ * @param locale - the resolved session locale.
222
+ * @returns the steer text.
223
+ */
224
+ function delegatedSteerText(locale) {
225
+ if (locale === "zh") return "[计划审批] 用户已批准该计划,并选择在一个新对话中执行它。本会话的规划任务已完成——不要在此会话中执行该计划;简短确认收到后结束回合即可。";
226
+ return "[Plan review] The user approved the plan and chose to carry it out in a NEW conversation. Planning is complete in this one — do not execute the plan here; acknowledge briefly and end your turn.";
227
+ }
228
+ /**
229
+ * The steer message fired when the sidebar keeps planning.
230
+ * @param feedback - the user's optional feedback (already trimmed).
231
+ * @param locale - the resolved session locale.
232
+ * @returns the steer text.
233
+ */
234
+ function keepPlanningSteerText(feedback, locale) {
235
+ if (locale === "zh") return "[计划审批] 用户在侧边栏计划面板审阅计划后选择继续规划。" + (feedback === void 0 ? "" : ` 用户的反馈:${feedback}。`) + " 请保持计划模式:修改计划文件后重新调用 exit_plan_mode 提交。";
236
+ return "[Plan review] The user chose to keep planning after reviewing the plan in the sidebar plan panel." + (feedback === void 0 ? "" : ` Their feedback: ${feedback}.`) + " Stay in plan mode: revise the plan file and present it again with exit_plan_mode.";
237
+ }
238
+ /**
239
+ * The no-sidebar plan-review question's copy (the popup fallback surface).
240
+ * @param locale - the resolved session locale.
241
+ * @returns the question copy; labels pair with the ask intent so the
242
+ * plan-review takeover matches the approve option by label.
243
+ */
244
+ function planReviewCopy(locale) {
245
+ if (locale === "zh") return {
246
+ header: "计划审批",
247
+ question: "批准该计划并退出计划模式?",
248
+ approveLabel: "批准",
249
+ approveDescription: "退出计划模式;计划将从下一步开始执行。",
250
+ keepLabel: "继续规划",
251
+ keepDescription: "保持计划模式;反馈将回传给模型。"
252
+ };
253
+ return {
254
+ header: "Plan review",
255
+ question: "Approve this plan and leave plan mode?",
256
+ approveLabel: "Approve",
257
+ approveDescription: "Leave plan mode; the plan is carried out from the next step.",
258
+ keepLabel: "Keep planning",
259
+ keepDescription: "Stay in plan mode; feedback goes back to the model."
260
+ };
261
+ }
262
+ //#endregion
263
+ //#region src/review-gate.ts
264
+ /**
265
+ * Per-session pending decision plus the attached view set. One pending
266
+ * review per session; a newer delivery supersedes the previous one (its
267
+ * handlers never fire — the newest plan is the one under review).
268
+ */
269
+ var PlanReviewGate = class {
270
+ pending = /* @__PURE__ */ new Map();
271
+ /** Latest known state per session — attach replay + stale-window reads. */
272
+ latest = /* @__PURE__ */ new Map();
273
+ subscribers = /* @__PURE__ */ new Map();
274
+ /**
275
+ * Record one pending review and broadcast it.
276
+ * @param sessionId - the session whose plan is under review.
277
+ * @param review - the delivery identity (id from the delivery push).
278
+ * @param handlers - the decision side effects (steer back to the model).
279
+ */
280
+ begin(sessionId, review, handlers) {
281
+ const existing = this.pending.get(sessionId);
282
+ if (existing !== void 0) {
283
+ this.pending.delete(sessionId);
284
+ this.settle(sessionId, {
285
+ ...existing.review,
286
+ status: "cancelled"
287
+ });
288
+ }
289
+ const waiter = {
290
+ review: {
291
+ ...review,
292
+ status: "pending"
293
+ },
294
+ handlers
295
+ };
296
+ this.pending.set(sessionId, waiter);
297
+ this.settle(sessionId, waiter.review);
298
+ }
299
+ /**
300
+ * Settle the session's pending review from the sidebar decision.
301
+ * @param sessionId - the session under review.
302
+ * @param decision - the user's choice (approve_new_session settles as
303
+ * `delegated`: execution continues in a new conversation).
304
+ * @param feedback - optional keep-planning feedback (trimmed; forwarded to
305
+ * the model verbatim in the steer message).
306
+ * @returns the settled review state, or undefined when nothing is pending.
307
+ */
308
+ decide(sessionId, decision, feedback) {
309
+ const waiter = this.pending.get(sessionId);
310
+ if (waiter === void 0) return void 0;
311
+ this.pending.delete(sessionId);
312
+ const settled = this.settle(sessionId, {
313
+ ...waiter.review,
314
+ status: decision === "approve" ? "approved" : decision === "keep" ? "kept" : "delegated"
315
+ });
316
+ if (decision === "approve") waiter.handlers.onApprove();
317
+ else if (decision === "keep") waiter.handlers.onKeep(feedback?.trim() || void 0);
318
+ else waiter.handlers.onDelegate();
319
+ return settled;
320
+ }
321
+ /**
322
+ * Read the session's pending review (stale-click guard + GET bootstrap).
323
+ * @param sessionId - the session to inspect.
324
+ * @returns the pending review, or null when nothing is parked.
325
+ */
326
+ peek(sessionId) {
327
+ return this.pending.get(sessionId)?.review ?? null;
328
+ }
329
+ /**
330
+ * Attach one sidebar view; the latest known review state replays
331
+ * immediately (a `null` frame clears a stale bar), and later changes push.
332
+ * @param sessionId - the session the view displays.
333
+ * @param send - the review-frame sender.
334
+ * @returns the disposer detaching the view.
335
+ */
336
+ attach(sessionId, send) {
337
+ let views = this.subscribers.get(sessionId);
338
+ if (views === void 0) {
339
+ views = /* @__PURE__ */ new Set();
340
+ this.subscribers.set(sessionId, views);
341
+ }
342
+ views.add(send);
343
+ send({
344
+ kind: "review",
345
+ review: this.latest.get(sessionId) ?? null
346
+ });
347
+ return () => {
348
+ const current = this.subscribers.get(sessionId);
349
+ current?.delete(send);
350
+ if (current !== void 0 && current.size === 0) this.subscribers.delete(sessionId);
351
+ };
352
+ }
353
+ /**
354
+ * Settle every pending review as cancelled and drop the views (plugin
355
+ * teardown). Handlers do not fire: a reload discards the decision surface,
356
+ * and the user re-drives the session.
357
+ */
358
+ dispose() {
359
+ for (const [sessionId, waiter] of this.pending) {
360
+ this.pending.delete(sessionId);
361
+ this.settle(sessionId, {
362
+ ...waiter.review,
363
+ status: "cancelled"
364
+ });
365
+ }
366
+ this.subscribers.clear();
367
+ }
368
+ /**
369
+ * Record one state as latest and broadcast it to the session's views.
370
+ * @returns the recorded state.
371
+ */
372
+ settle(sessionId, review) {
373
+ this.latest.set(sessionId, review);
374
+ for (const send of this.subscribers.get(sessionId) ?? []) send({
375
+ kind: "review",
376
+ review
377
+ });
378
+ return review;
379
+ }
380
+ };
381
+ //#endregion
382
+ //#region src/prompt-override.ts
383
+ /**
384
+ * The shipped preset's delivery sentence, verbatim across the standard, ptc,
385
+ * and cordis presets. Doubles as the plan-mode-active gate for the listener.
386
+ */
387
+ const PLAN_DELIVERY_ANCHOR = "When ready, call exit_plan_mode with the complete plan markdown, starting with a # title.";
388
+ /**
389
+ * The shipped sentence right after the delivery anchor, verbatim across the
390
+ * standard, ptc, and cordis presets. With the file-first contract the write
391
+ * tool call necessarily precedes exit_plan_mode in the delivery turn, so this
392
+ * "only and final tool call" sentence reads as forbidding exactly that call —
393
+ * the observed write-then-stop failure — and must be rewritten too.
394
+ */
395
+ 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.";
396
+ const WRITE_BAN_ANCHOR = "Do not edit or write files, change configuration, run formatters or code generation that rewrites tracked files, commit, or otherwise carry out the plan.";
397
+ const OVERRIDE_CLAIM_ANCHOR = "those tools remain listed to keep the tool catalog unchanged.";
398
+ /**
399
+ * Rewrite the shipped plan-mode guidance to the file-first delivery contract.
400
+ * Each replacement is independent: a sentence whose anchor is absent (an
401
+ * older or customized preset variant) is left as-is, and already-rewritten
402
+ * text passes through unchanged, so the function is idempotent.
403
+ * @param text - one assembled prompt section's text.
404
+ * @param planDir - the plugin config's suggested plan directory.
405
+ * @returns the corrected text, or the input untouched when no anchor matches.
406
+ */
407
+ function rewritePlanPolicySection(text, planDir) {
408
+ let result = text;
409
+ const writeException = ` The one required exception is the delivery file: before calling exit_plan_mode, write the complete plan as markdown to a single file (for example ${`\`${planDir}/YYYY-MM-DD-<topic>.md\``}) with the write tool; this delivery write is allowed in plan mode, and the delivery itself is the exit_plan_mode call that must follow the write in the same turn.`;
410
+ if (result.includes(WRITE_BAN_ANCHOR) && !result.includes(writeException)) result = result.replace(WRITE_BAN_ANCHOR, WRITE_BAN_ANCHOR + writeException);
411
+ const overrideException = " The exit_plan_mode file-first contract is the single exception to that override.";
412
+ if (result.includes(OVERRIDE_CLAIM_ANCHOR) && !result.includes(overrideException)) result = result.replace(OVERRIDE_CLAIM_ANCHOR, OVERRIDE_CLAIM_ANCHOR + overrideException);
413
+ const deliveryReplacement = `Deliver the plan in the same turn you finish it: first write the COMPLETE plan as markdown to \`${planDir}/YYYY-MM-DD-<topic>.md\` (YYYY-MM-DD is today's date, <topic> a short kebab-case slug of the plan subject, e.g. \`${planDir}/2026-08-09-dsh-pet-rust-impl-spec.md\`), then call exit_plan_mode with that path — the tool takes only the path, the complete plan markdown must already be in the file starting with a # title, and the plan text is never pasted into the tool call. Writing the plan file is preparation, not delivery: a turn that ends with the file written but exit_plan_mode not called has presented nothing.`;
414
+ if (result.includes("When ready, call exit_plan_mode with the complete plan markdown, starting with a # title.")) result = result.replace(PLAN_DELIVERY_ANCHOR, deliveryReplacement);
415
+ const finalCallReplacement = "exit_plan_mode is the final tool call of the delivery turn, made immediately after the plan write — the write preceding it does not disqualify the call; nothing may follow it, and implementation begins only in a later step after approval.";
416
+ if (result.includes("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.")) result = result.replace(FINAL_CALL_ANCHOR, finalCallReplacement);
417
+ return result;
418
+ }
419
+ /**
420
+ * Register the assemble waterfall listener on one agent's scoped context.
421
+ * The agent is the loop's assemble dispatch key, so a listener tagged with
422
+ * that exact scope is admitted for every prompt this agent assembles.
423
+ * Sections without the plan-mode anchor pass through untouched, so non-plan
424
+ * requests pay one string scan per section.
425
+ * @param ctx - the agent's scoped context (`agent.ctx`); only event
426
+ * registration is required, so the plain Cordis face suffices.
427
+ * @param planDir - the plugin config's suggested plan directory.
428
+ * @returns the listener disposer (for the caller's lifecycle effect).
429
+ */
430
+ function registerPlanPolicyOverride(ctx, planDir) {
431
+ return ctx.on("system-prompt/assemble", async (assembly, _context, next) => {
432
+ const result = await next();
433
+ for (const section of result.sections) if (section.text.includes("When ready, call exit_plan_mode with the complete plan markdown, starting with a # title.") || section.text.includes("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.")) section.text = rewritePlanPolicySection(section.text, planDir);
434
+ return result;
435
+ });
436
+ }
437
+ //#endregion
438
+ //#region src/trust-fence.ts
439
+ function header(headers, name) {
440
+ const value = headers[name];
441
+ return typeof value === "string" ? value : void 0;
442
+ }
443
+ /** Normalized URL of a Host-header authority, or undefined when unparsable. */
444
+ function parseAuthority(authority) {
445
+ try {
446
+ return new URL(`http://${authority}`);
447
+ } catch {
448
+ return;
449
+ }
450
+ }
451
+ /** Whether a normalized URL hostname names the local loopback authority. */
452
+ function isLoopbackHostname(hostname) {
453
+ if (hostname === "localhost" || hostname === "[::1]") return true;
454
+ const parts = hostname.split(".");
455
+ return parts.length === 4 && parts[0] === "127" && parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255);
456
+ }
457
+ /** Canonical authority form: hostname, or hostname:port when a port was written. */
458
+ function canonicalAuthority(entry, entryUrl) {
459
+ const port = entryUrl.port !== "" ? entryUrl.port : new URL(`https://${entry}`).port;
460
+ return port === "" ? entryUrl.hostname : `${entryUrl.hostname}:${port}`;
461
+ }
462
+ /** Whether the request authority matches a trustedHosts entry (exact or port-less). */
463
+ function isTrustedAuthority(hostUrl, trustedHosts) {
464
+ return trustedHosts.some((entry) => {
465
+ const entryUrl = parseAuthority(entry);
466
+ if (entryUrl === void 0) return false;
467
+ return canonicalAuthority(entry, entryUrl) === entryUrl.hostname ? entryUrl.hostname === hostUrl.hostname : entryUrl.host === hostUrl.host;
468
+ });
469
+ }
470
+ /**
471
+ * Decide whether one request may reach the delivery WebSocket.
472
+ * @param request - the upgrade request's headers.
473
+ * @param trustedHosts - non-loopback authorities this deployment serves.
474
+ * @returns true when the Host is ours (loopback or trusted) and browser
475
+ * markers are same-origin.
476
+ */
477
+ function isTrustedDeliveryRequest(request, trustedHosts) {
478
+ const host = header(request.headers, "host");
479
+ if (host === void 0) return false;
480
+ const hostUrl = parseAuthority(host);
481
+ if (hostUrl === void 0) return false;
482
+ if (!isLoopbackHostname(hostUrl.hostname) && !isTrustedAuthority(hostUrl, trustedHosts)) return false;
483
+ if (header(request.headers, "sec-fetch-site") === "cross-site") return false;
484
+ const origin = header(request.headers, "origin");
485
+ if (origin === void 0) return true;
486
+ try {
487
+ return new URL(origin).hostname === hostUrl.hostname;
488
+ } catch {
489
+ return false;
490
+ }
491
+ }
492
+ //#endregion
493
+ //#region src/review-route.ts
494
+ /** The exact pathname the route registers on the host webServer. */
495
+ const REVIEW_API_PATH = "/better-plan/api/review";
496
+ /** Request-body cap: a decision is a handful of fields, not a plan. */
497
+ const REVIEW_BODY_LIMIT = 4096;
498
+ /** The decision values the route accepts (mirrors the gate's vocabulary). */
499
+ const DECISIONS = [
500
+ "approve",
501
+ "keep",
502
+ "approve_new_session"
503
+ ];
504
+ /**
505
+ * Parse and validate one review decision body (wire-boundary validation).
506
+ * @param raw - the request body text.
507
+ * @returns the parsed decision, or an error message.
508
+ */
509
+ function parseReviewDecisionBody(raw) {
510
+ let parsed;
511
+ try {
512
+ parsed = JSON.parse(raw);
513
+ } catch {
514
+ return { error: "malformed JSON body" };
515
+ }
516
+ if (parsed === null || typeof parsed !== "object") return { error: "the body must be a JSON object" };
517
+ const record = parsed;
518
+ if (typeof record.session !== "string" || record.session === "") return { error: "session is required" };
519
+ if (typeof record.decision !== "string" || !DECISIONS.includes(record.decision)) return { error: "decision must be \"approve\", \"keep\", or \"approve_new_session\"" };
520
+ const decision = {
521
+ session: record.session,
522
+ decision: record.decision
523
+ };
524
+ if (typeof record.feedback === "string" && record.feedback !== "") decision.feedback = record.feedback;
525
+ if (typeof record.id === "string" && record.id !== "") decision.id = record.id;
526
+ if (typeof record.locale === "string" && record.locale !== "") decision.locale = record.locale;
527
+ return { value: decision };
528
+ }
529
+ /**
530
+ * Serve one review request: GET bootstraps the plan panel's action bar with
531
+ * the current state (the WS attach replay remains the live channel); POST
532
+ * settles the pending decision. Both verbs record the submitting view's
533
+ * reported locale so the host's user-facing copy follows the browser.
534
+ *
535
+ * Error bodies carry a stable machine-readable `code` alongside the English
536
+ * `error` message: the plan panel maps known codes to localized copy and
537
+ * falls back to the raw message for unknown ones.
538
+ *
539
+ * @param gate - the review gate holding the pending review.
540
+ * @param req - the request (method/headers/body iterator).
541
+ * @param res - the response.
542
+ * @param trustedHosts - non-loopback authorities the deployment serves.
543
+ * @param directory - the locale directory view reports are recorded in.
544
+ */
545
+ async function handleReviewRequest(gate, req, res, trustedHosts, directory) {
546
+ const json = (status, code, error) => {
547
+ res.writeHead(status, { "content-type": "application/json" });
548
+ res.end(JSON.stringify({
549
+ ok: false,
550
+ code,
551
+ error
552
+ }));
553
+ };
554
+ if (req.method !== "POST" && req.method !== "GET") return json(405, "method_not_allowed", "GET or POST only");
555
+ if (!isTrustedDeliveryRequest(req, trustedHosts)) return json(403, "untrusted_origin", "untrusted origin");
556
+ if (req.method === "GET") {
557
+ const url = new URL(req.url ?? "/", "http://dsh.internal");
558
+ const sessionId = url.searchParams.get("session");
559
+ if (sessionId === null || sessionId === "") return json(400, "session_required", "session is required");
560
+ directory.report(sessionId, url.searchParams.get("locale"));
561
+ res.writeHead(200, { "content-type": "application/json" });
562
+ res.end(JSON.stringify({
563
+ ok: true,
564
+ review: gate.peek(sessionId)
565
+ }));
566
+ return;
567
+ }
568
+ let raw = "";
569
+ let bytes = 0;
570
+ for await (const chunk of req) {
571
+ bytes += typeof chunk === "string" ? Buffer.byteLength(chunk) : chunk.byteLength;
572
+ if (bytes > 4096) return json(413, "body_too_large", `review request body exceeds the ${REVIEW_BODY_LIMIT}-byte limit`);
573
+ raw += typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8");
574
+ }
575
+ const parsed = parseReviewDecisionBody(raw);
576
+ if (parsed.error !== void 0) return json(400, "invalid_body", parsed.error);
577
+ const body = parsed.value;
578
+ directory.report(body.session, body.locale);
579
+ const pending = gate.peek(body.session);
580
+ if (pending === null) return json(409, "no_pending", "no plan review is pending for this session");
581
+ if (body.id !== void 0 && body.id !== pending.id) return json(409, "stale_review", "the plan panel is stale; a newer plan delivery is under review");
582
+ const review = gate.decide(body.session, body.decision, body.feedback);
583
+ if (review === void 0) return json(409, "no_pending", "no plan review is pending for this session");
584
+ res.writeHead(200, { "content-type": "application/json" });
585
+ res.end(JSON.stringify({
586
+ ok: true,
587
+ review
588
+ }));
589
+ }
590
+ /**
591
+ * Register the review decision route on the host webServer.
592
+ * @param register - the webServer's route registrar.
593
+ * @param gate - the review gate.
594
+ * @param trustedHosts - non-loopback authorities the deployment serves.
595
+ * @param directory - the locale directory view reports are recorded in.
596
+ * @returns the route disposer.
597
+ */
598
+ function registerReviewRoute(register, gate, trustedHosts, directory) {
599
+ return register({
600
+ kind: "exact",
601
+ path: REVIEW_API_PATH,
602
+ handler: (req, res) => handleReviewRequest(gate, req, res, trustedHosts, directory)
603
+ });
604
+ }
605
+ //#endregion
606
+ //#region src/first-heading.ts
607
+ /**
608
+ * Plan-title extraction, shared by the delivery registry (sidebar tab title),
609
+ * the WS push payload, and the canonical tool value.
610
+ *
611
+ * `firstHeading` mirrors the built-in plan mode's regex so both tools name a
612
+ * plan the same way. D2 keeps validation loose: a plan without any heading is
613
+ * accepted and falls back to the file basename.
614
+ *
615
+ * @module @huanlin/dsh-plugin-better-plan/first-heading
616
+ */
617
+ /**
618
+ * The plan's first markdown heading (any level), or `undefined` when the plan
619
+ * has none. Tolerates leading whitespace before `#` and trailing whitespace
620
+ * after the text; a 7-`#` run is not a heading.
621
+ * @param plan - the full plan markdown.
622
+ * @returns the heading text, or `undefined` when no line matches.
623
+ */
624
+ function firstHeading(plan) {
625
+ for (const line of plan.split("\n")) {
626
+ const match = /^#{1,6}\s+(.+?)\s*$/.exec(line);
627
+ if (match) return match[1];
628
+ }
629
+ }
630
+ /**
631
+ * The last path segment of a POSIX or Windows path (mirror of the sidebar
632
+ * client's FileTree baseName), used as the fallback plan title.
633
+ * @param path - the path to shorten.
634
+ * @returns the basename without trailing separators.
635
+ */
636
+ function basenameOf(path) {
637
+ const trimmed = path.replace(/[\\/]+$/, "");
638
+ const at = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\"));
639
+ return at === -1 ? trimmed : trimmed.slice(at + 1);
640
+ }
641
+ //#endregion
642
+ //#region src/resolve-cwd.ts
643
+ /**
644
+ * Session working-directory resolution for relative plan paths.
645
+ *
646
+ * Chain (mirror of the sidebar's sessionCwdOf): the live session header wins;
647
+ * while that header carries no cwd (a stripped-down host or a session created
648
+ * without workspace metadata) the persistence index is consulted for cold
649
+ * sessions; the host process cwd is the final fallback. A persistence failure
650
+ * or a non-absolute persisted value also falls through to the process cwd —
651
+ * a delivery tool should not die because the index hiccups, and a wrong
652
+ * fallback surfaces as a clear "file does not exist" error from the caller's
653
+ * own stat.
654
+ *
655
+ * @module @huanlin/dsh-plugin-better-plan/resolve-cwd
656
+ */
657
+ /**
658
+ * Resolve one session's working directory.
659
+ * @param session - the calling session (its header cwd is authoritative).
660
+ * @param sessionId - the session id, for the persistence lookup.
661
+ * @param persistence - the optional session-persistence service.
662
+ * @returns an absolute working directory (never empty).
663
+ */
664
+ async function resolveSessionCwd(session, sessionId, persistence) {
665
+ const headerCwd = session?.header?.cwd;
666
+ if (typeof headerCwd === "string" && headerCwd !== "") return headerCwd;
667
+ if (persistence !== void 0) try {
668
+ const metaCwd = (await persistence.stat(sessionId))?.header?.cwd;
669
+ if (typeof metaCwd === "string" && metaCwd !== "" && isAbsolute(metaCwd)) return metaCwd;
670
+ } catch {}
671
+ return process.cwd();
672
+ }
673
+ //#endregion
674
+ //#region src/shadow-tool.ts
675
+ /**
676
+ * The shadowed `exit_plan_mode` tool — better-plan's whole model-facing
677
+ * contract.
678
+ *
679
+ * The tool keeps the built-in plan mode's name (D1): every agent preset's
680
+ * planning group mounts `@deepseek-ai/dsh-plan-mode` inside an isolate realm,
681
+ * and preset mount lines cannot be patched, so a same-name per-agent
682
+ * registration through `agent.ctx` is the only replacement seam. Shadowing
683
+ * keeps the preset's `plan:policy` section verbatim (its statements about
684
+ * `exit_plan_mode` remain true) and leaves `/plan`, the projection, and the
685
+ * composer badge working unchanged.
686
+ *
687
+ * What changes is the delivery: the model must write the COMPLETE plan to a
688
+ * markdown file first (guided by this description — D2 verifies only that the
689
+ * file exists and is readable), then pass its path. When the push reaches a
690
+ * connected sidebar view, the tool RETURNS IMMEDIATELY with `decision:
691
+ * 'pending'` — the render instructs the model to end its turn, so the
692
+ * conversation simply stops with no approval popup — and the user reviews the
693
+ * plan and decides in the sidebar plan panel. The decision is steered back as
694
+ * the next turn's message (approval also flips plan mode off, see the review
695
+ * handlers below). When no view is attached, the built-in plan-review
696
+ * question renders in chat with the full plan text and blocks exactly like
697
+ * the original tool (no-sidebar environment ⇒ the original experience).
698
+ *
699
+ * Conventions (per plugin-development-guide.md §3):
700
+ * C4 — `execute` returns one canonical JSON value; `render` is separate.
701
+ * C6 — `exec.signal.throwIfAborted()` before any fs work.
702
+ * C9 — presentCall/presentResult are pure functions of their arguments.
703
+ *
704
+ * @module @huanlin/dsh-plugin-better-plan/shadow-tool
705
+ */
706
+ /** The review question's id, echoed in the answer this tool reads. */
707
+ const REVIEW_ID = "plan-review";
708
+ /**
709
+ * The model-facing description. The model's only new knowledge: the
710
+ * file-first contract, the immediate-return + end-turn contract, and the
711
+ * decision arriving as the next message. `planDir` is interpolated into the
712
+ * example path.
713
+ * @param config - the plugin config (planDir suggestion).
714
+ * @returns the description string.
715
+ */
716
+ function exitPlanDescription(config) {
717
+ return `Use only in plan mode. MANDATORY two-step delivery, both steps in the same turn: (1) write the COMPLETE plan as markdown to \`${config.planDir}/YYYY-MM-DD-<topic>.md\` — YYYY-MM-DD is today's date and <topic> a short kebab-case slug of the plan subject (e.g. \`${config.planDir}/2026-08-09-dsh-pet-rust-impl-spec.md\`); (2) immediately after the write succeeds, call this tool with that path. Writing the file alone delivers nothing — the call is the delivery; never end the turn with the plan file written but this tool not called. The plan opens in the sidebar plan panel and the call returns immediately — end your turn right after it and wait; the user reviews and decides there. Their decision arrives as the next message: approval switches plan mode off for you to carry out the plan; keep-planning feedback asks you to revise the file and present it again.`;
718
+ }
719
+ /**
720
+ * The review question's detail: the full plan text. Only the no-sidebar
721
+ * fallback reaches the question (a delivered plan is reviewed in the sidebar
722
+ * instead), so the user always sees the whole plan on the popup card.
723
+ * @param plan - the full plan markdown.
724
+ * @returns the detail string for the plan-review question.
725
+ */
726
+ function reviewDetail(plan) {
727
+ return plan;
728
+ }
729
+ /**
730
+ * The model-facing render — the ENGLISH baseline. The pending branch IS the
731
+ * end-of-turn contract: the conversation stops because the model stops here.
732
+ * A non-English session locale swaps this content at finalize time (see
733
+ * `finalizeContent` below); the baseline stays English so the durable result
734
+ * and every test anchor remain stable.
735
+ */
736
+ function renderResult(args, value) {
737
+ if (value.decision === "pending") return [{
738
+ type: "text",
739
+ text: "Plan presented to the user in the sidebar plan panel. End your turn now: briefly note that the plan is awaiting their review there, then stop — do not call any more tools. Their decision arrives as the next message: approval switches plan mode off for you to carry out the plan; keep-planning feedback asks you to revise the file and present it again."
740
+ }];
741
+ return [{
742
+ type: "text",
743
+ text: `Plan approved — plan mode exited; carry out the plan starting with your next step. (No sidebar plan panel is connected; the plan file is at ${args.path}.)`
744
+ }];
745
+ }
746
+ /**
747
+ * Build the shadowed tool definition. Registration is the caller's job
748
+ * (`agent.ctx.tools.register` from the session-start hook).
749
+ * @param deps - the plugin-provided collaborators.
750
+ * @returns a registry-ready tool definition.
751
+ */
752
+ function defineExitPlanTool(deps) {
753
+ const { ctx, config, registry } = deps;
754
+ return defineTool({
755
+ name: EXIT_PLAN_MODE,
756
+ description: exitPlanDescription(config),
757
+ parameters: { path: {
758
+ type: "string",
759
+ required: true,
760
+ description: "Absolute or session-cwd-relative path of the markdown plan file (written with the write tool before this call)."
761
+ } },
762
+ output: {
763
+ schema: {
764
+ type: "object",
765
+ additionalProperties: false,
766
+ properties: {
767
+ delivered: {
768
+ type: "boolean",
769
+ required: true,
770
+ description: "Whether the plan was pushed to a connected sidebar plan panel at call time (false = queued for the next view attach, or no sidebar installed)."
771
+ },
772
+ decision: {
773
+ type: "string",
774
+ enum: ["pending", "approved"],
775
+ required: true,
776
+ description: "pending = the sidebar review is open; end the turn and wait. approved = the in-chat review answered approve."
777
+ }
778
+ }
779
+ },
780
+ render: renderResult
781
+ },
782
+ finalizeContent: (exec, result) => {
783
+ const agent = exec.agent;
784
+ if (agent === void 0 || result.isError) return void 0;
785
+ const value = result.value;
786
+ if (value === null || typeof value !== "object") return void 0;
787
+ const locale = deps.localeOf(agent.session.id);
788
+ if (locale === "en") return void 0;
789
+ const args = exec.arguments;
790
+ return localizedRenderContent(typeof args?.path === "string" ? args.path : "", value, locale);
791
+ },
792
+ execute: async (args, exec) => {
793
+ exec.signal.throwIfAborted();
794
+ const agent = exec.agent;
795
+ if (agent === void 0) throw new Error(`${EXIT_PLAN_MODE} requires a calling agent (no session to switch)`);
796
+ if (!isPlanModeActive(agent.session)) throw new Error(`${EXIT_PLAN_MODE} is only available in plan mode`);
797
+ const sessionId = agent.session.id;
798
+ const cwd = await resolveSessionCwd(agent.session, sessionId, ctx.get("sessionPersistence"));
799
+ const absolute = isAbsolute(args.path) ? args.path : join(cwd, args.path);
800
+ let info;
801
+ try {
802
+ info = await stat(absolute);
803
+ } catch (error) {
804
+ const code = error.code;
805
+ if (code === "ENOENT") throw new Error(`${EXIT_PLAN_MODE} could not read the plan file "${args.path}" (resolved to "${absolute}"): it does not exist. Write the COMPLETE plan as markdown to a file with the write tool first (e.g. \`${config.planDir}/YYYY-MM-DD-<topic>.md\`), then call exit_plan_mode with its path in the same turn.`);
806
+ if (code === "EACCES" || code === "EPERM") throw new Error(`the plan file "${absolute}" is not readable`);
807
+ throw new Error(`cannot read the plan file "${absolute}": ${error instanceof Error ? error.message : String(error)}`);
808
+ }
809
+ if (!info.isFile()) throw new Error(`"${absolute}" is not a file; pass the path of the markdown plan file`);
810
+ if (info.size > config.maxPlanBytes) throw new Error(`the plan file is ${info.size} bytes, over the ${config.maxPlanBytes}-byte review limit; write a more concise plan (state decisions and changes, not full file contents) and present it again`);
811
+ const plan = await readFile(absolute, "utf8");
812
+ const title = firstHeading(plan) ?? basenameOf(absolute);
813
+ const { id, delivered } = registry.enqueue(sessionId, absolute, title);
814
+ /** Flip plan mode off for a settled review: between turns the append
815
+ * lands immediately (the built-in controller does the same when the
816
+ * fold is idle); a failed durable append retries through the
817
+ * pendingExits boundary flush at the steered turn's first accepted
818
+ * pre-step. Shared by both approval paths. */
819
+ const exitPlanMode = () => {
820
+ try {
821
+ agent.session.append("plan/mode", { active: false });
822
+ } catch (error) {
823
+ ctx.logger.warn("dsh-plugin-better-plan: the approved plan exit could not be appended directly; deferring to the next boundary: %o", error);
824
+ deps.onApproved(agent.session);
825
+ }
826
+ };
827
+ /** Steer one decision outcome back as the next turn's user message. */
828
+ const steerDecision = (text) => {
829
+ agent.steer(createUserMessage({
830
+ content: [{
831
+ type: "text",
832
+ text
833
+ }],
834
+ source: { kind: "user" }
835
+ }));
836
+ };
837
+ if (delivered) {
838
+ deps.reviewGate.begin(sessionId, {
839
+ id,
840
+ path: absolute,
841
+ title
842
+ }, {
843
+ onApprove: () => {
844
+ exitPlanMode();
845
+ steerDecision(approvalSteerText(deps.localeOf(sessionId)));
846
+ },
847
+ onKeep: (feedback) => {
848
+ steerDecision(keepPlanningSteerText(feedback, deps.localeOf(sessionId)));
849
+ },
850
+ onDelegate: () => {
851
+ exitPlanMode();
852
+ steerDecision(delegatedSteerText(deps.localeOf(sessionId)));
853
+ }
854
+ });
855
+ return {
856
+ delivered: true,
857
+ decision: "pending"
858
+ };
859
+ }
860
+ const interaction = ctx.get("userQuestions");
861
+ if (interaction === void 0) throw new Error("no user-questions channel is available to review the plan; ask the user to switch the session mode instead");
862
+ const copy = planReviewCopy(deps.localeOf(sessionId));
863
+ const answer = await interaction.ask({
864
+ questions: [{
865
+ id: REVIEW_ID,
866
+ header: copy.header,
867
+ question: copy.question,
868
+ detail: reviewDetail(plan),
869
+ options: [{
870
+ label: copy.approveLabel,
871
+ description: copy.approveDescription
872
+ }, {
873
+ label: copy.keepLabel,
874
+ description: copy.keepDescription
875
+ }],
876
+ intent: {
877
+ kind: "plan-review",
878
+ approve: copy.approveLabel
879
+ }
880
+ }],
881
+ agent,
882
+ signal: exec.signal
883
+ }).catch((cause) => {
884
+ if (cause instanceof UserQuestionError && cause.code === "ASK_CANCELLED") throw new Error("The user dismissed the plan review to speak instead; stay in plan mode, stop here, and wait for their message.");
885
+ throw cause;
886
+ });
887
+ if (deps.isDisposed()) throw new Error("the better-plan plugin was reloaded while the plan was under review; write the plan and present it again");
888
+ const reviewItems = answer.answers.filter((entry) => entry.id === REVIEW_ID);
889
+ const item = reviewItems.length === 1 ? reviewItems[0] : void 0;
890
+ if (item?.selected.length !== 1 || item.selected[0] !== copy.approveLabel || item.custom !== void 0) {
891
+ const feedback = item?.custom ?? "";
892
+ throw new Error(feedback === "" ? "The user chose to keep planning; revise the plan file and present it again." : `The user chose to keep planning; their feedback: ${feedback}`);
893
+ }
894
+ deps.onApproved(agent.session);
895
+ return {
896
+ delivered: false,
897
+ decision: "approved"
898
+ };
899
+ },
900
+ presentCall: (args) => ({
901
+ card: "generic",
902
+ title: basenameOf(args.path),
903
+ kind: "other",
904
+ content: [{
905
+ type: "text",
906
+ text: "Plan file delivered for review — the complete plan opens in the sidebar plan panel; the conversation waits for the user's decision there."
907
+ }]
908
+ }),
909
+ presentResult: (_args, result) => ({
910
+ card: "generic",
911
+ title: "Plan review",
912
+ content: result.content
913
+ })
914
+ });
915
+ }
916
+ //#endregion
917
+ //#region src/ws-route.ts
918
+ /**
919
+ * The `/better-plan/ws/delivery` push WebSocket: the host→browser channel
920
+ * for the sidebar's Plan tab (query `session=<sessionId>` attaches one view).
921
+ *
922
+ * The socket exists because the host half has no `betterSidebar` service —
923
+ * host→client pushes must ride a route the plugin owns. Two tagged JSON
924
+ * frames flow server→view:
925
+ * `{ kind: 'deliver', id, path, title }` — one plan delivery;
926
+ * `{ kind: 'review', review: ReviewState|null }` — the review state
927
+ * (replayed on attach, pushed on every change).
928
+ *
929
+ * @module @huanlin/dsh-plugin-better-plan/ws-route
930
+ */
931
+ /** The exact upgrade path registered on the host webServer. */
932
+ const DELIVERY_WS_PATH = "/better-plan/ws/delivery";
933
+ /**
934
+ * Wire one delivery socket to the registries: parse `?session=` (and the
935
+ * view-reported `?locale=`, recorded so the host's user-facing copy follows
936
+ * the browser's active DSH locale), attach the delivery queue (replaying
937
+ * queued pushes) and the review gate (replaying the latest review state),
938
+ * and detach on close/error so later pushes queue instead of accumulating
939
+ * on a dead socket.
940
+ * @param registry - the delivery registry.
941
+ * @param gate - the review gate.
942
+ * @param ws - the connected socket.
943
+ * @param req - the upgrade request.
944
+ * @param directory - the locale directory the reported locale is recorded in.
945
+ */
946
+ function attachDeliverySocket(registry, gate, ws, req, directory) {
947
+ const url = new URL(req.url ?? "/", "http://dsh.internal");
948
+ const sessionId = url.searchParams.get("session");
949
+ if (sessionId === null || sessionId === "") {
950
+ ws.close(1008, "session is required");
951
+ return;
952
+ }
953
+ directory.report(sessionId, url.searchParams.get("locale"));
954
+ const send = (frame) => {
955
+ ws.send(JSON.stringify(frame));
956
+ };
957
+ const detachDelivery = registry.attach(sessionId, (delivery) => send({
958
+ kind: "deliver",
959
+ ...delivery
960
+ }));
961
+ const detachReview = gate.attach(sessionId, send);
962
+ const detach = () => {
963
+ detachDelivery();
964
+ detachReview();
965
+ };
966
+ ws.on("close", detach);
967
+ ws.on("error", detach);
968
+ }
969
+ /**
970
+ * Register the delivery upgrade route on the host webServer.
971
+ * @param registerUpgrade - the webServer's route registrar.
972
+ * @param registry - the delivery registry.
973
+ * @param gate - the review gate.
974
+ * @param trustedHosts - non-loopback authorities the deployment serves.
975
+ * @param directory - the locale directory view reports are recorded in.
976
+ * @returns the route disposer.
977
+ */
978
+ function registerDeliveryRoute(registerUpgrade, registry, gate, trustedHosts, directory) {
979
+ const wss = new WebSocketServer({ noServer: true });
980
+ const dispose = registerUpgrade({
981
+ path: DELIVERY_WS_PATH,
982
+ handler: (req, socket, head) => {
983
+ if (!isTrustedDeliveryRequest(req, trustedHosts)) {
984
+ socket.destroy();
985
+ return;
986
+ }
987
+ wss.handleUpgrade(req, socket, head, (ws) => {
988
+ attachDeliverySocket(registry, gate, ws, req, directory);
989
+ });
990
+ }
991
+ });
992
+ return () => {
993
+ dispose();
994
+ wss.close();
995
+ };
996
+ }
997
+ //#endregion
998
+ //#region src/index.ts
999
+ const name = "dsh-plugin-better-plan";
1000
+ /**
1001
+ * Services required before mounting: the tool registry (its availability
1002
+ * gates scoped registrations), the webserver (the delivery push route), and
1003
+ * the system-prompt registry (the assemble waterfall this plugin rewrites
1004
+ * plan-mode guidance through).
1005
+ */
1006
+ const inject = [
1007
+ "tools",
1008
+ "webServer",
1009
+ "systemPrompt"
1010
+ ];
1011
+ /** One approved exit awaiting the next accepted pre-step boundary. */
1012
+ const pendingExits = /* @__PURE__ */ new WeakSet();
1013
+ /**
1014
+ * Flush one pending approved exit: append the log-only `plan/mode: false`
1015
+ * event before the next request assembly. Mirrors the built-in controller's
1016
+ * boundary append — the delete happens only after a successful append, so a
1017
+ * failed durable write stays retryable at the next boundary.
1018
+ * @param agent - the agent whose step was accepted.
1019
+ * @returns whether a pending exit remains (a failed append keeps it).
1020
+ */
1021
+ function flushPendingExit(agent) {
1022
+ const session = agent.session;
1023
+ if (!pendingExits.has(session)) return false;
1024
+ if (!isPlanModeActive(session)) {
1025
+ pendingExits.delete(session);
1026
+ return false;
1027
+ }
1028
+ session.append("plan/mode", { active: false });
1029
+ pendingExits.delete(session);
1030
+ return false;
1031
+ }
1032
+ /**
1033
+ * Wire the plugin onto a host context. Everything this registers is bound to
1034
+ * `ctx`'s own fiber and cleans up on disposal (HMR-safe).
1035
+ * @param ctx - the host plugin context.
1036
+ * @param config - the resolved plugin config.
1037
+ * @returns the created delivery registry and review gate (exposed for tests).
1038
+ */
1039
+ function createBetterPlan(ctx, config) {
1040
+ const registry = new PlanDeliveryRegistry();
1041
+ const reviewGate = new PlanReviewGate();
1042
+ const locales = new LocaleDirectory();
1043
+ let disposed = false;
1044
+ const shadowed = /* @__PURE__ */ new WeakSet();
1045
+ ctx.on("agent/session-start", ({ agent }) => {
1046
+ if (shadowed.has(agent)) return;
1047
+ shadowed.add(agent);
1048
+ agent.ctx.effect(() => agent.ctx.tools.register(defineExitPlanTool({
1049
+ ctx,
1050
+ config,
1051
+ registry,
1052
+ reviewGate,
1053
+ localeOf: (sessionId) => resolvePlanLocale(config.locale, locales, sessionId),
1054
+ isDisposed: () => disposed,
1055
+ onApproved: (session) => {
1056
+ pendingExits.add(session);
1057
+ }
1058
+ })), "dsh-plugin-better-plan: shadow exit_plan_mode");
1059
+ agent.ctx.effect(() => registerPlanPolicyOverride(agent.ctx, config.planDir), "dsh-plugin-better-plan: plan policy prompt override");
1060
+ });
1061
+ ctx.on("agent/pre-step", async ({ agent, signal }, next) => {
1062
+ const decision = await next();
1063
+ if (decision.kind === "reject" || signal.aborted) return decision;
1064
+ try {
1065
+ flushPendingExit(agent);
1066
+ } catch (error) {
1067
+ ctx.logger.warn("dsh-plugin-better-plan: failed to append the approved plan exit at step start: %o", error);
1068
+ }
1069
+ return decision;
1070
+ });
1071
+ ctx.effect(() => registerDeliveryRoute((route) => ctx.webServer.registerUpgrade(route), registry, reviewGate, ctx.get("webRuntime")?.trustedHosts ?? [], locales), "dsh-plugin-better-plan: delivery WebSocket");
1072
+ ctx.effect(() => registerReviewRoute((route) => ctx.webServer.register(route), reviewGate, ctx.get("webRuntime")?.trustedHosts ?? [], locales), "dsh-plugin-better-plan: review API route");
1073
+ registerPlanPolicyOverride(ctx, config.planDir);
1074
+ ctx.effect(() => () => {
1075
+ disposed = true;
1076
+ registry.dispose();
1077
+ reviewGate.dispose();
1078
+ locales.dispose();
1079
+ }, "dsh-plugin-better-plan: service lifetime");
1080
+ return {
1081
+ registry,
1082
+ reviewGate,
1083
+ locales
1084
+ };
1085
+ }
1086
+ /**
1087
+ * Plugin entry.
1088
+ * @param ctx - the host plugin context.
1089
+ * @param config - the composition entry config (defaults fill in via the schema).
1090
+ */
1091
+ function apply(ctx, config = {}) {
1092
+ createBetterPlan(ctx, resolveBetterPlanConfig(config));
1093
+ }
1094
+ //#endregion
1095
+ export { Config, apply, createBetterPlan, inject, name };