@norman-else/dsh-claude 0.1.33 → 0.1.35

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.mjs CHANGED
@@ -1,6 +1,6 @@
1
- import { A as CLAUDE_UPDATE_PATH, C as CLAUDE_REPOSITORY_FEEDBACK_PATH, D as CLAUDE_REVIEW_COMMENT_PATH, E as CLAUDE_REPOSITORY_STATUS_PATH, N as TASK_TOOL_NAMES, O as CLAUDE_REWIND_PATH, S as CLAUDE_REPOSITORY_ACTION_PATH, T as CLAUDE_REPOSITORY_SETUP_PATH, _ as CLAUDE_DOCTOR_PATH, a as latestClaudeTasks, b as CLAUDE_JIRA_PATH, c as normalizeTasksEvent, d as CLAUDE_ACTIVITY_EVENT, f as CLAUDE_ASK_PATH, g as CLAUDE_CODE_PROVIDER_IDS, h as CLAUDE_CODE_PROVIDER, i as latestClaudeSessionBinding, j as CLAUDE_USAGE_PATH, k as CLAUDE_UPDATE_CHECK_PATH, l as redactText, m as CLAUDE_CODE_PRESET_ID, n as currentClaudeActivityCursor, o as normalizeActivity, p as CLAUDE_CLIENT_DIAGNOSTICS_PATH, r as latestClaudeContextUsage, s as normalizeContextUsage, t as boundText, u as safeDetail, v as CLAUDE_EDITOR_OPEN_PATH, w as CLAUDE_REPOSITORY_FILE_PATH, x as CLAUDE_PROJECTION_PATH, y as CLAUDE_GLOBAL_SETTINGS_PATH } from "./events-DeSV0S1-.mjs";
2
- import { n as projectClaudeCommands, t as CLAUDE_COMMANDS_SERVICE } from "./command-bridge-DXI6nWhB.mjs";
3
- import { a as resolveClaudeExecutable, n as ensureManagedPreset, o as runClaudeDoctor, t as ManagedPresetConflictError } from "./preset-installer-DbmlhBXI.mjs";
1
+ import { A as CLAUDE_UPDATE_CHECK_PATH, C as CLAUDE_REPOSITORY_ACTION_PATH, D as CLAUDE_REPOSITORY_STATUS_PATH, E as CLAUDE_REPOSITORY_SETUP_PATH, F as TASK_TOOL_NAMES, I as isClaudeRenderMode, M as CLAUDE_USAGE_PATH, N as DEFAULT_CLAUDE_RENDER_MODE, O as CLAUDE_REVIEW_COMMENT_PATH, S as CLAUDE_RENDER_MODES, T as CLAUDE_REPOSITORY_FILE_PATH, _ as CLAUDE_DOCTOR_PATH, a as latestClaudeTasks, b as CLAUDE_JIRA_PATH, c as normalizeTasksEvent, d as CLAUDE_ACTIVITY_EVENT, f as CLAUDE_ASK_PATH, g as CLAUDE_CODE_PROVIDER_IDS, h as CLAUDE_CODE_PROVIDER, i as latestClaudeSessionBinding, j as CLAUDE_UPDATE_PATH, k as CLAUDE_REWIND_PATH, l as redactText, m as CLAUDE_CODE_PRESET_ID, n as currentClaudeActivityCursor, o as normalizeActivity, p as CLAUDE_CLIENT_DIAGNOSTICS_PATH, r as latestClaudeContextUsage, s as normalizeContextUsage, t as boundText, u as safeDetail, v as CLAUDE_EDITOR_OPEN_PATH, w as CLAUDE_REPOSITORY_FEEDBACK_PATH, x as CLAUDE_PROJECTION_PATH, y as CLAUDE_GLOBAL_SETTINGS_PATH } from "./events-Doid-tq7.mjs";
2
+ import { a as projectClaudeCommands, i as CLAUDE_COMMANDS_SERVICE, r as dynamicPresenterDefinition, t as CLAUDE_PRESENTER_NAMES } from "./presenters-CXtE8qve.mjs";
3
+ import { a as resolveClaudeExecutable, n as ensureManagedPreset, o as runClaudeDoctor, t as ManagedPresetConflictError } from "./preset-installer-BMyKr5eQ.mjs";
4
4
  import z from "@deepseek-ai/schemastery";
5
5
  import { createHash, randomUUID } from "node:crypto";
6
6
  import { chmod, mkdir, opendir, readFile, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
@@ -8,10 +8,11 @@ import { basename, dirname, extname, isAbsolute, join, resolve, sep } from "node
8
8
  import { dshHomePath, resolveDshHome } from "@deepseek-ai/dsh-home-paths";
9
9
  import { homedir } from "node:os";
10
10
  import { query } from "@anthropic-ai/claude-agent-sdk";
11
+ import { CallId, LlmAdapter, ReasoningEffortId, createToolResultMessage } from "@deepseek-ai/dsh-llm";
11
12
  import { EventEmitter } from "node:events";
12
13
  import { DSH_ENV_PREFIX, SENSITIVE_ENV_PATTERN } from "@deepseek-ai/dsh-subprocess";
13
- import { LlmAdapter, ReasoningEffortId } from "@deepseek-ai/dsh-llm";
14
14
  import { fileURLToPath } from "node:url";
15
+ import { deadline } from "@deepseek-ai/dsh-timeout";
15
16
  import { StringDecoder } from "node:string_decoder";
16
17
  //#region src/rewind.ts
17
18
  const EMPTY_REWIND_STATE = {
@@ -227,6 +228,8 @@ var ClaudeSidecarRepository = class {
227
228
  /** Latest durable projection per session; disk is read once and written through. */
228
229
  #latest = /* @__PURE__ */ new Map();
229
230
  #listeners = /* @__PURE__ */ new Map();
231
+ /** Notifications published per session, so a reader can spot a hole. */
232
+ #seq = /* @__PURE__ */ new Map();
230
233
  /** Streaming transcript segments not yet persisted, keyed by activity key. */
231
234
  #live = /* @__PURE__ */ new Map();
232
235
  /** Monotonic revision boost so merged reads advance while text stays in memory. */
@@ -275,7 +278,8 @@ var ClaudeSidecarRepository = class {
275
278
  const base = {
276
279
  turn: normalized.turn,
277
280
  step: normalized.step,
278
- ordinal: normalized.ordinal
281
+ ordinal: normalized.ordinal,
282
+ ...normalized.renderer === void 0 ? {} : { renderer: normalized.renderer }
279
283
  };
280
284
  this.#notify(sessionId, previous?.text !== void 0 && text.startsWith(previous.text) ? {
281
285
  kind: "text",
@@ -297,11 +301,35 @@ var ClaudeSidecarRepository = class {
297
301
  }
298
302
  return this.#flushLive(sessionId);
299
303
  }
304
+ /** How many notifications this session has published. */
305
+ sequence(sessionId) {
306
+ return this.#seq.get(sessionId) ?? 0;
307
+ }
308
+ /** Restate where the stream stands without changing anything.
309
+ *
310
+ * A reader detects a lost delta from the hole the NEXT one leaves, which
311
+ * never comes when the lost delta was the last of a turn -- exactly the
312
+ * case that leaves a finished tool group pulsing forever. Called at turn
313
+ * settlement, this gives that reader the one line it needs to disagree. */
314
+ checkpoint(sessionId) {
315
+ this.#deliver(sessionId, {
316
+ kind: "checkpoint",
317
+ seq: this.sequence(sessionId)
318
+ });
319
+ }
300
320
  #notify(sessionId, delta) {
321
+ const seq = this.sequence(sessionId) + 1;
322
+ this.#seq.set(sessionId, seq);
323
+ this.#deliver(sessionId, {
324
+ ...delta,
325
+ seq
326
+ });
327
+ }
328
+ #deliver(sessionId, notification) {
301
329
  const set = this.#listeners.get(sessionId);
302
330
  if (set === void 0) return;
303
331
  for (const listener of [...set]) try {
304
- listener(delta);
332
+ listener(notification);
305
333
  } catch {}
306
334
  }
307
335
  #merged(sessionId, base) {
@@ -1451,6 +1479,7 @@ var ClaudeSupervisor = class {
1451
1479
  #queryFactory;
1452
1480
  #runDetached;
1453
1481
  #sidecar;
1482
+ #dynamicPresenterNames = /* @__PURE__ */ new WeakMap();
1454
1483
  #contextWindows = /* @__PURE__ */ new Map();
1455
1484
  #disposed = false;
1456
1485
  #admissionGate = Promise.resolve();
@@ -1900,6 +1929,10 @@ var ClaudeSupervisor = class {
1900
1929
  title: "Claude thinking",
1901
1930
  summary: message.text
1902
1931
  });
1932
+ if (this.#nativeRendering()) active.output.push({
1933
+ type: "thinking",
1934
+ text: message.text
1935
+ });
1903
1936
  return;
1904
1937
  case "tool-call":
1905
1938
  if (message.parentToolUseId === void 0) this.#closeTranscriptTextSegment(active);
@@ -1913,7 +1946,13 @@ var ClaudeSupervisor = class {
1913
1946
  summary: message.parentToolUseId === void 0 ? rootCallSummary(message.toolName, message.input) : `Subagent called ${message.toolName}`,
1914
1947
  detail: message.input
1915
1948
  });
1916
- if (message.parentToolUseId === void 0) active.callNames.set(message.toolUseId, message.toolName);
1949
+ if (message.parentToolUseId === void 0) {
1950
+ active.callNames.set(message.toolUseId, message.toolName);
1951
+ if (this.#nativeRendering()) {
1952
+ this.#ensureDynamicPresenter(active.agent, message.toolName);
1953
+ await this.#appendNativeToolCall(active, message);
1954
+ }
1955
+ }
1917
1956
  return;
1918
1957
  case "tool-result":
1919
1958
  await this.#appendActivity(active, {
@@ -1925,6 +1964,7 @@ var ClaudeSupervisor = class {
1925
1964
  detail: message.output,
1926
1965
  isError: message.isError
1927
1966
  });
1967
+ if (message.parentToolUseId === void 0 && this.#nativeRendering()) await this.#appendNativeToolResult(active, message);
1928
1968
  return;
1929
1969
  case "subagent":
1930
1970
  await this.#appendActivity(active, {
@@ -1974,9 +2014,66 @@ var ClaudeSupervisor = class {
1974
2014
  title: message.toolName,
1975
2015
  summary: message.summary
1976
2016
  });
2017
+ if (this.#nativeRendering()) await this.#appendNativeToolResult(active, {
2018
+ kind: "tool-result",
2019
+ toolUseId: message.toolUseId,
2020
+ output: message.summary,
2021
+ isError: true
2022
+ });
1977
2023
  return;
1978
2024
  }
1979
2025
  }
2026
+ #nativeRendering() {
2027
+ return (this.#config.renderMode ?? "plugin") === "native";
2028
+ }
2029
+ /** Register one presenter-only mirror for a tool name the static preset
2030
+ * registry does not cover (MCP tools, newly added built-ins). Runs in the
2031
+ * agent scope so the mirror is visible only to this preset's sessions and
2032
+ * unwinds with the agent; failure keeps the generic card, never the turn. */
2033
+ #ensureDynamicPresenter(agent, name) {
2034
+ if (CLAUDE_PRESENTER_NAMES.has(name)) return;
2035
+ let known = this.#dynamicPresenterNames.get(agent);
2036
+ if (known === void 0) {
2037
+ known = /* @__PURE__ */ new Set();
2038
+ this.#dynamicPresenterNames.set(agent, known);
2039
+ }
2040
+ if (known.has(name)) return;
2041
+ try {
2042
+ agent.ctx.tools.register(dynamicPresenterDefinition(name));
2043
+ known.add(name);
2044
+ } catch {}
2045
+ }
2046
+ /** Mirror one root Claude tool call into the durable native tool channel so
2047
+ * the host's tool presentation renders it exactly like a DSH-executed call.
2048
+ * Presentation duplication is best-effort and never unsettles the turn. */
2049
+ async #appendNativeToolCall(active, message) {
2050
+ try {
2051
+ await active.agent.session.append("tool/call", {
2052
+ turn: active.cursor.turn,
2053
+ step: active.cursor.step,
2054
+ callId: CallId(message.toolUseId),
2055
+ name: message.toolName,
2056
+ arguments: safeDetail(message.input) ?? "{}"
2057
+ });
2058
+ } catch {}
2059
+ }
2060
+ async #appendNativeToolResult(active, message) {
2061
+ const text = typeof message.output === "string" ? redactText(message.output) : safeDetail(message.output) ?? "";
2062
+ try {
2063
+ await active.agent.session.append("tool/result", {
2064
+ turn: active.cursor.turn,
2065
+ step: active.cursor.step,
2066
+ message: createToolResultMessage({
2067
+ callId: CallId(message.toolUseId),
2068
+ content: [{
2069
+ type: "text",
2070
+ text
2071
+ }],
2072
+ isError: message.isError
2073
+ })
2074
+ }, { surfaceOp: "append" });
2075
+ } catch {}
2076
+ }
1980
2077
  /** Merge one task lifecycle message into the session's task board. */
1981
2078
  async #trackTask(entry, message, taskId, originTurn) {
1982
2079
  const previous = entry.tasks.get(taskId);
@@ -2157,6 +2254,7 @@ var ClaudeSupervisor = class {
2157
2254
  entry.state = "idle";
2158
2255
  entry.lastUsedAt = Date.now();
2159
2256
  await this.#recordChainAnchor(entry, active);
2257
+ this.#checkpointProjection(entry);
2160
2258
  this.#armIdleTimer(entry);
2161
2259
  return;
2162
2260
  }
@@ -2240,8 +2338,20 @@ var ClaudeSupervisor = class {
2240
2338
  entry.lastUsedAt = Date.now();
2241
2339
  await this.#recordChainAnchor(entry, active);
2242
2340
  await this.#learnContextWindow(entry);
2341
+ this.#checkpointProjection(entry);
2243
2342
  this.#armIdleTimer(entry);
2244
2343
  }
2344
+ /** Tell every reader where this session's delta stream ended.
2345
+ *
2346
+ * A reader that lost the turn's last delta has nothing later to reveal the
2347
+ * hole, and a settled turn produces nothing further -- so a finished tool
2348
+ * group would keep pulsing until the session was reopened by hand. Last
2349
+ * line of the turn, best effort: presentation must never unsettle it. */
2350
+ #checkpointProjection(entry) {
2351
+ try {
2352
+ this.#sidecar.checkpoint(entry.sessionId);
2353
+ } catch {}
2354
+ }
2245
2355
  /** Pin where Claude's chain ended for the DSH turn that just settled, so a
2246
2356
  * later rewind of the following turn can fork exactly here. Best effort:
2247
2357
  * a missing anchor only makes a rewind fall back to an earlier turn. */
@@ -2259,6 +2369,7 @@ var ClaudeSupervisor = class {
2259
2369
  try {
2260
2370
  this.#sidecar.appendTranscriptText(active.agent.id, {
2261
2371
  text: active.transcriptText,
2372
+ ...this.#nativeRendering() ? { renderer: "native" } : {},
2262
2373
  turn: active.cursor.turn,
2263
2374
  step: active.cursor.step,
2264
2375
  ordinal
@@ -2277,6 +2388,7 @@ var ClaudeSupervisor = class {
2277
2388
  const ordinal = active.cursor.nextOrdinal++;
2278
2389
  await this.#sidecar.appendActivity(active.agent.id, {
2279
2390
  ...activity,
2391
+ ...this.#nativeRendering() ? { renderer: "native" } : {},
2280
2392
  turn: active.cursor.turn,
2281
2393
  step: active.cursor.step,
2282
2394
  ordinal
@@ -2640,13 +2752,15 @@ var ClaudeCodeAdapter = class extends LlmAdapter {
2640
2752
  #attachments;
2641
2753
  #presetIdFor;
2642
2754
  #drainReviewComments;
2643
- constructor(supervisor, agents, attachments, presetIdFor, drainReviewComments = () => []) {
2755
+ #renderMode;
2756
+ constructor(supervisor, agents, attachments, presetIdFor, drainReviewComments = () => [], renderMode = () => DEFAULT_CLAUDE_RENDER_MODE) {
2644
2757
  super();
2645
2758
  this.#supervisor = supervisor;
2646
2759
  this.#agents = agents;
2647
2760
  this.#attachments = attachments;
2648
2761
  this.#presetIdFor = presetIdFor;
2649
2762
  this.#drainReviewComments = drainReviewComments;
2763
+ this.#renderMode = renderMode;
2650
2764
  }
2651
2765
  providerInfo(provider) {
2652
2766
  return {
@@ -2718,15 +2832,74 @@ var ClaudeCodeAdapter = class extends LlmAdapter {
2718
2832
  ...thinkingMode === void 0 ? {} : { thinkingMode },
2719
2833
  ...options.signal === void 0 ? {} : { signal: options.signal }
2720
2834
  });
2835
+ const native = this.#renderMode() === "native";
2721
2836
  let pendingUsage;
2722
2837
  let completed = false;
2838
+ let blockIndex = 0;
2839
+ let text = "";
2840
+ /** Settle the buffered prose as one text block. Each Claude result is one
2841
+ * block so tool activity stays ahead of the prose it explains and a
2842
+ * background-task report can follow in a block of its own. */
2843
+ function* flushText() {
2844
+ if (text.length === 0) return;
2845
+ const settled = text;
2846
+ text = "";
2847
+ yield {
2848
+ type: "block-start",
2849
+ index: blockIndex,
2850
+ blockType: "text"
2851
+ };
2852
+ yield {
2853
+ type: "text-delta",
2854
+ index: blockIndex,
2855
+ text: settled
2856
+ };
2857
+ yield {
2858
+ type: "block-end",
2859
+ index: blockIndex,
2860
+ block: {
2861
+ type: "text",
2862
+ text: settled
2863
+ }
2864
+ };
2865
+ blockIndex += 1;
2866
+ }
2723
2867
  try {
2724
2868
  for await (const event of events) {
2725
- if (event.type === "text-delta" || event.type === "segment-complete") continue;
2726
2869
  if (event.type === "usage") {
2727
2870
  pendingUsage = tokenUsage(event.usage);
2728
2871
  continue;
2729
2872
  }
2873
+ if (event.type === "text-delta") {
2874
+ if (native) text += event.text;
2875
+ continue;
2876
+ }
2877
+ if (event.type === "thinking") {
2878
+ if (!native) continue;
2879
+ yield* flushText();
2880
+ yield {
2881
+ type: "block-start",
2882
+ index: blockIndex,
2883
+ blockType: "reasoning"
2884
+ };
2885
+ yield {
2886
+ type: "reasoning-delta",
2887
+ index: blockIndex,
2888
+ text: event.text
2889
+ };
2890
+ yield {
2891
+ type: "block-end",
2892
+ index: blockIndex,
2893
+ block: {
2894
+ type: "reasoning",
2895
+ text: event.text
2896
+ }
2897
+ };
2898
+ blockIndex += 1;
2899
+ continue;
2900
+ }
2901
+ yield* flushText();
2902
+ if (event.type === "segment-complete") continue;
2730
2903
  completed = true;
2731
2904
  if (pendingUsage !== void 0) yield {
2732
2905
  type: "usage",
@@ -2740,6 +2913,7 @@ var ClaudeCodeAdapter = class extends LlmAdapter {
2740
2913
  } catch (error) {
2741
2914
  if (error.name === "AbortError") {
2742
2915
  completed = true;
2916
+ yield* flushText();
2743
2917
  yield {
2744
2918
  type: "finish",
2745
2919
  reason: {
@@ -2757,10 +2931,41 @@ var ClaudeCodeAdapter = class extends LlmAdapter {
2757
2931
  if (!completed) throw new Error("dsh-claude: Claude turn stream ended without a result");
2758
2932
  }
2759
2933
  };
2760
- function createClaudeCodeAdapter(supervisor, agents, attachments, presetIdFor, drainReviewComments = () => []) {
2761
- return new ClaudeCodeAdapter(supervisor, agents, attachments, presetIdFor, drainReviewComments);
2934
+ function createClaudeCodeAdapter(supervisor, agents, attachments, presetIdFor, drainReviewComments = () => [], renderMode = () => DEFAULT_CLAUDE_RENDER_MODE) {
2935
+ return new ClaudeCodeAdapter(supervisor, agents, attachments, presetIdFor, drainReviewComments, renderMode);
2762
2936
  }
2763
2937
  //#endregion
2938
+ //#region src/plugin-budget.ts
2939
+ /**
2940
+ * The plugin's connection budget, in one table.
2941
+ *
2942
+ * A browser opens a small fixed number of connections to one origin — six for
2943
+ * HTTP/1.1 in Chromium — and shares them with the Host's own traffic. Every
2944
+ * response this plugin holds open costs one of them for its lifetime, and a
2945
+ * request that cannot get one waits in the browser's queue where no server-side
2946
+ * deadline can reach it. When the pool is exhausted the panels that would
2947
+ * *diagnose* the problem are the first thing to stop answering, which is how
2948
+ * this failure has always presented: four settings cards timing out at once
2949
+ * against a Host that is demonstrably healthy.
2950
+ *
2951
+ * So the budget is a fixed constant rather than a function of how much work is
2952
+ * in flight. Steady state is one connection (the multiplexed projection
2953
+ * carrier); the peak is `PLUGIN_GLOBAL_PERMITS`, whatever the session count.
2954
+ *
2955
+ * Both halves read this file, which is the point: a route declares a budget
2956
+ * class and the client derives its wait from the same entry, so a server
2957
+ * deadline can never be quietly longer than the client's patience.
2958
+ */
2959
+ /** Server-side budget classes. A route declares a class, never a number. */
2960
+ const ROUTE_BUDGET_MS = {
2961
+ /** Answers from memory or a single bounded probe. */
2962
+ fast: 5e3,
2963
+ /** Chains local Git work. */
2964
+ git: 45e3,
2965
+ /** Reaches the network: remote Git, `gh`, the npm registry. */
2966
+ remote: 15e4
2967
+ };
2968
+ //#endregion
2764
2969
  //#region src/http.ts
2765
2970
  /** Accept only loopback, same-origin browser requests to plugin-private routes. */
2766
2971
  function trustedRequest(req) {
@@ -2789,6 +2994,147 @@ function json(res, status, value) {
2789
2994
  });
2790
2995
  res.end(JSON.stringify(value));
2791
2996
  }
2997
+ const ROUTE_TIMEOUT_CODE = "DSH_CLAUDE_ROUTE";
2998
+ const DEFAULT_BODY_BYTES = 1048576;
2999
+ /** Bounds live streaming responses per path, so a teardown bug cannot become a
3000
+ * permanent connection leak. Registration is per path, not global, because a
3001
+ * wedged projection stream must not evict an in-flight repository setup. */
3002
+ var StreamRegistry = class {
3003
+ #open = /* @__PURE__ */ new Map();
3004
+ admit(path, key, max, close) {
3005
+ let live = this.#open.get(path);
3006
+ if (live === void 0) {
3007
+ live = /* @__PURE__ */ new Map();
3008
+ this.#open.set(path, live);
3009
+ }
3010
+ live.get(key)?.();
3011
+ live.set(key, close);
3012
+ while (live.size > max) {
3013
+ const oldest = live.keys().next();
3014
+ if (oldest.done === true) break;
3015
+ const evict = live.get(oldest.value);
3016
+ live.delete(oldest.value);
3017
+ evict?.();
3018
+ }
3019
+ return () => {
3020
+ const current = this.#open.get(path);
3021
+ if (current?.get(key) === close) current.delete(key);
3022
+ };
3023
+ }
3024
+ };
3025
+ const streams = new StreamRegistry();
3026
+ function isPluginMethod(value) {
3027
+ return value === "GET" || value === "POST" || value === "PATCH" || value === "DELETE";
3028
+ }
3029
+ /** Distinguishable so the wrapper can answer 413 rather than folding an
3030
+ * oversized body into whatever generic failure the handler reports. */
3031
+ var PluginBodyTooLargeError = class extends Error {
3032
+ constructor() {
3033
+ super("Request body is too large");
3034
+ this.name = "PluginBodyTooLargeError";
3035
+ }
3036
+ };
3037
+ async function readBody(req, maxBytes) {
3038
+ const declared = Number(req.headers["content-length"] ?? 0);
3039
+ if (!Number.isFinite(declared) || declared < 0 || declared > maxBytes) throw new PluginBodyTooLargeError();
3040
+ const chunks = [];
3041
+ let bytes = 0;
3042
+ for await (const chunk of req) {
3043
+ const data = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
3044
+ bytes += data.byteLength;
3045
+ if (bytes > maxBytes) throw new PluginBodyTooLargeError();
3046
+ chunks.push(data);
3047
+ }
3048
+ if (bytes === 0) return {};
3049
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
3050
+ }
3051
+ function rejectOn(signal) {
3052
+ return new Promise((_resolve, reject) => {
3053
+ if (signal.aborted) {
3054
+ reject(signal.reason);
3055
+ return;
3056
+ }
3057
+ signal.addEventListener("abort", () => reject(signal.reason), { once: true });
3058
+ });
3059
+ }
3060
+ /**
3061
+ * Register one plugin route.
3062
+ *
3063
+ * This is the only place in the package that calls `ctx.webServer.register`,
3064
+ * and the ordering inside it is the fix rather than an implementation detail:
3065
+ * the disconnect listeners are attached before the first `await`, so a client
3066
+ * that goes away while the handler is still assembling its first response
3067
+ * still tears the route's work down. The previous per-route code attached
3068
+ * `res.on('close')` after awaiting an unbounded repository probe, which is how
3069
+ * the projection stream reached 53 opens against 33 closes.
3070
+ */
3071
+ function registerPluginRoute(ctx, route) {
3072
+ const label = `dsh-claude: ${route.path}`;
3073
+ ctx.effect(() => ctx.webServer.register({
3074
+ kind: route.kind,
3075
+ path: route.path,
3076
+ handler: async (req, res) => {
3077
+ const method = req.method;
3078
+ if (!isPluginMethod(method) || !route.methods.includes(method)) return json(res, 405, { error: "method not allowed" });
3079
+ if (!trustedRequest(req)) return json(res, 403, { error: "forbidden" });
3080
+ const aborted = new AbortController();
3081
+ const abort = () => {
3082
+ aborted.abort();
3083
+ };
3084
+ req.on("close", () => {
3085
+ if (!req.complete) abort();
3086
+ });
3087
+ res.on("close", abort);
3088
+ const url = new URL(req.url ?? "/", "http://localhost");
3089
+ if (route.mode === "stream") {
3090
+ const release = streams.admit(route.path, route.streamKey(url), route.maxConcurrent, abort);
3091
+ const io = {
3092
+ signal: aborted.signal,
3093
+ method,
3094
+ url,
3095
+ body: async (maxBytes = DEFAULT_BODY_BYTES) => await readBody(req, maxBytes)
3096
+ };
3097
+ try {
3098
+ await route.handler(res, io);
3099
+ } catch (error) {
3100
+ if (!res.headersSent) json(res, 500, { error: "stream-failed" });
3101
+ ctx.logger.warn(`dsh-claude: ${route.path} stream failed: ${error instanceof Error ? error.message : String(error)}`);
3102
+ } finally {
3103
+ release();
3104
+ res.end();
3105
+ }
3106
+ return;
3107
+ }
3108
+ const budgetMs = ROUTE_BUDGET_MS[route.budget];
3109
+ const bounded = deadline(aborted.signal, budgetMs, ROUTE_TIMEOUT_CODE);
3110
+ const io = {
3111
+ signal: bounded.signal,
3112
+ method,
3113
+ url,
3114
+ body: async (maxBytes = DEFAULT_BODY_BYTES) => await readBody(req, maxBytes)
3115
+ };
3116
+ try {
3117
+ const result = await Promise.race([route.handler(io), rejectOn(bounded.signal)]);
3118
+ if (!res.writableEnded) json(res, result.status, result.value);
3119
+ } catch (error) {
3120
+ if (res.writableEnded || res.headersSent) return;
3121
+ if (bounded.signal.aborted && !aborted.signal.aborted) return json(res, 504, {
3122
+ error: "deadline",
3123
+ budget: route.budget,
3124
+ ms: budgetMs
3125
+ });
3126
+ if (aborted.signal.aborted) return;
3127
+ if (error instanceof PluginBodyTooLargeError) return json(res, 413, {
3128
+ error: "body-too-large",
3129
+ message: "The request body is too large."
3130
+ });
3131
+ json(res, 400, { error: error instanceof Error ? error.message : "request failed" });
3132
+ } finally {
3133
+ bounded[Symbol.dispose]();
3134
+ }
3135
+ }
3136
+ }), label);
3137
+ }
2792
3138
  //#endregion
2793
3139
  //#region src/doctor-routes.ts
2794
3140
  const CLAUDE_DOCTOR_PROBE_TIMEOUT_MS = 15e3;
@@ -2828,60 +3174,70 @@ function commandDiagnostics(ctx) {
2828
3174
  }
2829
3175
  }
2830
3176
  function registerClaudeDoctorRoutes(ctx, runtime, supervisor, config, resolutionError) {
2831
- ctx.effect(() => ctx.webServer.register({
3177
+ registerPluginRoute(ctx, {
3178
+ mode: "unary",
2832
3179
  kind: "exact",
2833
3180
  path: CLAUDE_DOCTOR_PATH,
2834
- handler: async (req, res) => {
2835
- if (req.method !== "GET") return json(res, 405, { error: "method not allowed" });
2836
- if (!trustedRequest(req)) return json(res, 403, { error: "forbidden" });
3181
+ methods: ["GET"],
3182
+ budget: "git",
3183
+ handler: async (io) => {
2837
3184
  try {
2838
- if (resolutionError !== void 0) return json(res, 200, {
2839
- executable: {
2840
- status: "missing",
2841
- searched: config.executablePath.length > 0 ? [config.executablePath] : [
2842
- "claude",
2843
- "~/.local/bin/claude",
2844
- "/opt/homebrew/bin/claude",
2845
- "/usr/local/bin/claude"
2846
- ]
2847
- },
2848
- version: { status: "not-run" },
2849
- authentication: { status: "not-run" },
2850
- handshake: "not-run",
2851
- message: safeMessage$2(resolutionError),
2852
- limits: {
2853
- idleTimeoutMs: config.idleTimeoutMs,
2854
- maxProcesses: config.maxProcesses
2855
- },
2856
- processes: {
2857
- count: 0,
2858
- active: 0
3185
+ if (resolutionError !== void 0) return {
3186
+ status: 200,
3187
+ value: {
3188
+ executable: {
3189
+ status: "missing",
3190
+ searched: config.executablePath.length > 0 ? [config.executablePath] : [
3191
+ "claude",
3192
+ "~/.local/bin/claude",
3193
+ "/opt/homebrew/bin/claude",
3194
+ "/usr/local/bin/claude"
3195
+ ]
3196
+ },
3197
+ version: { status: "not-run" },
3198
+ authentication: { status: "not-run" },
3199
+ handshake: "not-run",
3200
+ message: safeMessage$2(resolutionError),
3201
+ limits: {
3202
+ idleTimeoutMs: config.idleTimeoutMs,
3203
+ maxProcesses: config.maxProcesses
3204
+ },
3205
+ processes: {
3206
+ count: 0,
3207
+ active: 0
3208
+ }
2859
3209
  }
2860
- });
3210
+ };
2861
3211
  const report = await runClaudeDoctor(runtime, {
2862
3212
  configuredPath: config.executablePath,
2863
3213
  cwd: process.cwd(),
2864
- signal: AbortSignal.timeout(CLAUDE_DOCTOR_PROBE_TIMEOUT_MS)
3214
+ signal: AbortSignal.any([io.signal, AbortSignal.timeout(CLAUDE_DOCTOR_PROBE_TIMEOUT_MS)])
2865
3215
  });
2866
3216
  const processes = supervisor.snapshots();
2867
3217
  if (processes.some((process) => process.claudeSessionId !== void 0)) report.handshake = "ok";
2868
- json(res, 200, {
2869
- ...report,
2870
- limits: {
2871
- idleTimeoutMs: config.idleTimeoutMs,
2872
- maxProcesses: config.maxProcesses
2873
- },
2874
- processes: {
2875
- count: processes.length,
2876
- active: processes.filter((process) => process.state === "running" || process.state === "starting").length
2877
- },
2878
- commandBridge: commandDiagnostics(ctx)
2879
- });
3218
+ return {
3219
+ status: 200,
3220
+ value: {
3221
+ ...report,
3222
+ limits: {
3223
+ idleTimeoutMs: config.idleTimeoutMs,
3224
+ maxProcesses: config.maxProcesses
3225
+ },
3226
+ processes: {
3227
+ count: processes.length,
3228
+ active: processes.filter((process) => process.state === "running" || process.state === "starting").length
3229
+ },
3230
+ commandBridge: commandDiagnostics(ctx)
3231
+ }
3232
+ };
2880
3233
  } catch (error) {
2881
- json(res, 500, { error: safeMessage$2(error) });
3234
+ return {
3235
+ status: 500,
3236
+ value: { error: safeMessage$2(error) }
3237
+ };
2882
3238
  }
2883
3239
  }
2884
- }), "dsh-claude: Doctor route");
3240
+ });
2885
3241
  }
2886
3242
  //#endregion
2887
3243
  //#region src/projection-routes.ts
@@ -2889,21 +3245,34 @@ const MAX_SESSION_ID_CHARS$6 = 1024;
2889
3245
  /** Slow-moving metadata refresh and stream heartbeat cadence; deliberately off
2890
3246
  * the transcript hot path so git/gh latency never delays visible text. */
2891
3247
  const META_REFRESH_MS = 5e3;
2892
- function targetFromUrl(rawUrl) {
2893
- try {
2894
- const pathname = new URL(rawUrl ?? "/", "http://localhost").pathname;
2895
- const prefix = `${CLAUDE_PROJECTION_PATH}/`;
2896
- if (!pathname.startsWith(prefix)) return void 0;
2897
- let encoded = pathname.slice(prefix.length);
2898
- const stream = encoded.endsWith("/stream");
2899
- if (stream) encoded = encoded.slice(0, -7);
2900
- if (encoded.length === 0 || encoded.includes("/")) return void 0;
2901
- const sessionId = decodeURIComponent(encoded);
2902
- if (sessionId.length === 0 || sessionId.length > MAX_SESSION_ID_CHARS$6) return void 0;
3248
+ /** The multiplexed carrier's path segment. Session ids are `session-<uuid>`,
3249
+ * so this cannot collide with one. */
3250
+ const MULTI_SEGMENT = "multi";
3251
+ function validSessionId(value) {
3252
+ return value.length > 0 && value.length <= MAX_SESSION_ID_CHARS$6;
3253
+ }
3254
+ function targetFromUrl(url) {
3255
+ const prefix = `${CLAUDE_PROJECTION_PATH}/`;
3256
+ if (!url.pathname.startsWith(prefix)) return void 0;
3257
+ const encoded = url.pathname.slice(prefix.length);
3258
+ if (encoded.length === 0 || encoded.includes("/")) return void 0;
3259
+ if (encoded === MULTI_SEGMENT) {
3260
+ const raw = url.searchParams.get("sessions");
3261
+ if (raw === null) return void 0;
3262
+ const sessionIds = [...new Set(raw.split(",").filter((id) => id.length > 0))];
3263
+ if (sessionIds.length === 0 || sessionIds.length > 16) return void 0;
3264
+ if (!sessionIds.every(validSessionId)) return void 0;
2903
3265
  return {
2904
- sessionId,
2905
- stream
3266
+ kind: "multi",
3267
+ sessionIds
2906
3268
  };
3269
+ }
3270
+ try {
3271
+ const sessionId = decodeURIComponent(encoded);
3272
+ return validSessionId(sessionId) ? {
3273
+ kind: "snapshot",
3274
+ sessionId
3275
+ } : void 0;
2907
3276
  } catch {
2908
3277
  return;
2909
3278
  }
@@ -2923,9 +3292,16 @@ function envelope(projection, meta) {
2923
3292
  };
2924
3293
  }
2925
3294
  /** Register the browser-readable, credential-free sidecar projection endpoint.
2926
- * `GET <path>/:sessionId` returns one snapshot; `GET <path>/:sessionId/stream`
2927
- * returns an NDJSON stream: a full snapshot line followed by incremental
2928
- * transcript/activity deltas and periodic metadata/heartbeat lines. */
3295
+ *
3296
+ * `GET <path>/:sessionId` returns one snapshot. `GET <path>/multi?sessions=a,b`
3297
+ * returns ONE NDJSON stream carrying every listed session, each line stamped
3298
+ * with its `session`.
3299
+ *
3300
+ * There is deliberately no per-session stream URL. The browser shares a small
3301
+ * fixed connection budget between this plugin and the Host, and a stream per
3302
+ * session spent it in proportion to how many Claude sessions existed — which
3303
+ * is what left the settings panel unable to get a connection at all. One
3304
+ * carrier is one connection, whatever the session count. */
2929
3305
  function registerClaudeProjectionRoute(ctx, sidecar, ownsSession, commandsForSession = () => [], repositoryForSession = async () => void 0, reviewCommentsForSession = () => []) {
2930
3306
  const info = (message) => {
2931
3307
  ctx.logger?.info?.(message);
@@ -2940,15 +3316,14 @@ function registerClaudeProjectionRoute(ctx, sidecar, ownsSession, commandsForSes
2940
3316
  reviewComments: owned ? reviewCommentsForSession(sessionId) : []
2941
3317
  };
2942
3318
  };
2943
- const streamProjection = async (res, sessionId) => {
2944
- info(`dsh-claude: projection stream opened for ${sessionId.slice(0, 64)}`);
3319
+ const streamMulti = async (res, io, sessionIds) => {
3320
+ info(`dsh-claude: projection stream opened for ${sessionIds.length} session(s)`);
2945
3321
  let textDeltas = 0;
2946
3322
  let textBytes = 0;
2947
3323
  let textSince = Date.now();
2948
- let meta = await assembleMeta(sessionId);
2949
3324
  res.writeHead(200, {
2950
3325
  "content-type": "application/x-ndjson; charset=utf-8",
2951
- "cache-control": "no-store",
3326
+ "cache-control": "no-store, no-transform",
2952
3327
  "x-content-type-options": "nosniff"
2953
3328
  });
2954
3329
  res.flushHeaders?.();
@@ -2961,102 +3336,146 @@ function registerClaudeProjectionRoute(ctx, sidecar, ownsSession, commandsForSes
2961
3336
  closed = true;
2962
3337
  }
2963
3338
  };
2964
- const writeSnapshot = async () => {
2965
- const projection = await sidecar.read(sessionId);
3339
+ const metas = /* @__PURE__ */ new Map();
3340
+ const writeSnapshot = async (sessionId) => {
3341
+ const [projection, meta] = await Promise.all([sidecar.read(sessionId), assembleMeta(sessionId)]);
3342
+ metas.set(sessionId, meta);
2966
3343
  writeLine({
2967
3344
  type: "snapshot",
3345
+ session: sessionId,
3346
+ seq: sidecar.sequence(sessionId),
2968
3347
  ...envelope(projection, meta)
2969
3348
  });
2970
3349
  };
2971
- await writeSnapshot();
2972
- const unsubscribe = sidecar.subscribe(sessionId, (delta) => {
3350
+ const unsubscribes = sessionIds.map((sessionId) => sidecar.subscribe(sessionId, (delta) => {
2973
3351
  switch (delta.kind) {
2974
3352
  case "text":
2975
3353
  textDeltas += 1;
2976
3354
  textBytes += (delta.append ?? delta.text ?? "").length;
2977
3355
  if (textDeltas % 25 === 0) {
2978
3356
  const elapsed = Date.now() - textSince;
2979
- info(`dsh-claude: stream ${sessionId.slice(0, 24)} 25 text deltas ${textBytes}B in ${elapsed}ms`);
3357
+ info(`dsh-claude: stream 25 text deltas ${textBytes}B in ${elapsed}ms`);
2980
3358
  textBytes = 0;
2981
3359
  textSince = Date.now();
2982
3360
  }
2983
3361
  writeLine({
2984
3362
  type: "text",
3363
+ session: sessionId,
2985
3364
  turn: delta.turn,
2986
3365
  step: delta.step,
2987
3366
  ordinal: delta.ordinal,
2988
3367
  ...delta.append === void 0 ? {} : { append: delta.append },
2989
- ...delta.text === void 0 ? {} : { text: delta.text }
3368
+ ...delta.text === void 0 ? {} : { text: delta.text },
3369
+ ...delta.renderer === void 0 ? {} : { renderer: delta.renderer },
3370
+ seq: delta.seq
2990
3371
  });
2991
3372
  return;
2992
3373
  case "activity":
2993
3374
  writeLine({
2994
3375
  type: "activity",
2995
- activity: delta.activity
3376
+ session: sessionId,
3377
+ activity: delta.activity,
3378
+ seq: delta.seq
2996
3379
  });
2997
3380
  return;
2998
3381
  case "contextUsage":
2999
3382
  writeLine({
3000
3383
  type: "contextUsage",
3001
- value: delta.value
3384
+ session: sessionId,
3385
+ value: delta.value,
3386
+ seq: delta.seq
3002
3387
  });
3003
3388
  return;
3004
3389
  case "tasks":
3005
3390
  writeLine({
3006
3391
  type: "tasks",
3007
- value: delta.value
3392
+ session: sessionId,
3393
+ value: delta.value,
3394
+ seq: delta.seq
3008
3395
  });
3009
3396
  return;
3010
- case "sync": writeSnapshot().catch(() => void 0);
3397
+ case "checkpoint":
3398
+ writeLine({
3399
+ type: "checkpoint",
3400
+ session: sessionId,
3401
+ seq: delta.seq
3402
+ });
3403
+ return;
3404
+ case "sync": writeSnapshot(sessionId).catch(() => void 0);
3011
3405
  }
3012
- });
3406
+ }));
3407
+ for (const sessionId of sessionIds) writeSnapshot(sessionId).catch(() => void 0);
3013
3408
  const timer = setInterval(() => {
3014
3409
  (async () => {
3015
- const next = await assembleMeta(sessionId);
3016
- if (closed) return;
3017
- if (JSON.stringify(next) === JSON.stringify(meta)) {
3018
- writeLine({ type: "ping" });
3019
- return;
3410
+ for (const sessionId of sessionIds) {
3411
+ if (closed) return;
3412
+ const next = await assembleMeta(sessionId);
3413
+ if (closed) return;
3414
+ if (JSON.stringify(next) === JSON.stringify(metas.get(sessionId))) continue;
3415
+ metas.set(sessionId, next);
3416
+ writeLine({
3417
+ type: "meta",
3418
+ session: sessionId,
3419
+ owned: next.owned,
3420
+ commands: next.commands,
3421
+ ...next.repository === void 0 ? {} : { repository: next.repository },
3422
+ reviewComments: next.reviewComments
3423
+ });
3020
3424
  }
3021
- meta = next;
3022
- writeLine({
3023
- type: "meta",
3024
- owned: meta.owned,
3025
- commands: meta.commands,
3026
- ...meta.repository === void 0 ? {} : { repository: meta.repository },
3027
- reviewComments: meta.reviewComments
3028
- });
3425
+ writeLine({ type: "ping" });
3029
3426
  })().catch(() => void 0);
3030
3427
  }, META_REFRESH_MS);
3031
3428
  timer.unref?.();
3032
3429
  await new Promise((resolve) => {
3033
- res.on("close", () => {
3430
+ const finish = () => {
3431
+ if (closed) return;
3034
3432
  closed = true;
3035
3433
  clearInterval(timer);
3036
- unsubscribe();
3037
- info(`dsh-claude: projection stream closed for ${sessionId.slice(0, 64)} after ${textDeltas} text deltas`);
3434
+ for (const unsubscribe of unsubscribes) unsubscribe();
3435
+ info(`dsh-claude: projection stream closed after ${textDeltas} text deltas`);
3038
3436
  resolve();
3039
- });
3437
+ };
3438
+ if (io.signal.aborted) finish();
3439
+ else io.signal.addEventListener("abort", finish, { once: true });
3040
3440
  });
3041
3441
  };
3042
- ctx.effect(() => ctx.webServer.register({
3442
+ registerPluginRoute(ctx, {
3443
+ mode: "stream",
3043
3444
  kind: "prefix",
3044
3445
  path: CLAUDE_PROJECTION_PATH,
3045
- handler: async (req, res) => {
3046
- if (req.method !== "GET") return json(res, 405, { error: "method not allowed" });
3047
- if (!trustedRequest(req)) return json(res, 403, { error: "forbidden" });
3048
- const target = targetFromUrl(req.url);
3049
- if (target === void 0) return json(res, 400, { error: "invalid session id" });
3446
+ methods: ["GET"],
3447
+ maxConcurrent: 1,
3448
+ streamKey: () => MULTI_SEGMENT,
3449
+ handler: async (res, io) => {
3450
+ const target = targetFromUrl(io.url);
3451
+ if (target === void 0) {
3452
+ res.writeHead(400, {
3453
+ "content-type": "application/json; charset=utf-8",
3454
+ "cache-control": "no-store"
3455
+ });
3456
+ res.write(JSON.stringify({ error: "invalid session id" }));
3457
+ return;
3458
+ }
3459
+ if (target.kind === "multi") return await streamMulti(res, io, target.sessionIds);
3460
+ info(`dsh-claude: projection poll for ${target.sessionId.slice(0, 64)}`);
3050
3461
  try {
3051
- if (target.stream) return await streamProjection(res, target.sessionId);
3052
- info(`dsh-claude: projection poll for ${target.sessionId.slice(0, 64)}`);
3053
- return json(res, 200, envelope(await sidecar.read(target.sessionId), await assembleMeta(target.sessionId)));
3462
+ const body = envelope(await sidecar.read(target.sessionId), await assembleMeta(target.sessionId));
3463
+ res.writeHead(200, {
3464
+ "content-type": "application/json; charset=utf-8",
3465
+ "cache-control": "no-store",
3466
+ "x-content-type-options": "nosniff"
3467
+ });
3468
+ res.write(JSON.stringify(body));
3054
3469
  } catch {
3055
- if (!res.headersSent) return json(res, 500, { error: "projection unavailable" });
3056
- res.end();
3470
+ if (res.headersSent) return;
3471
+ res.writeHead(500, {
3472
+ "content-type": "application/json; charset=utf-8",
3473
+ "cache-control": "no-store"
3474
+ });
3475
+ res.write(JSON.stringify({ error: "projection unavailable" }));
3057
3476
  }
3058
3477
  }
3059
- }), "dsh-claude: sidecar projection route");
3478
+ });
3060
3479
  }
3061
3480
  //#endregion
3062
3481
  //#region src/repository-status.ts
@@ -3235,10 +3654,13 @@ var RepositoryStatusService = class {
3235
3654
  this.#runtime = runtime;
3236
3655
  this.#cacheTtlMs = cacheTtlMs;
3237
3656
  }
3238
- inspect(cwd) {
3657
+ /** `signal` bounds the scan itself, not just the caller's patience: the
3658
+ * untracked-file diff below is a serial spawn loop that can outlive any
3659
+ * route budget, and work nobody is waiting for still occupies the process. */
3660
+ inspect(cwd, signal) {
3239
3661
  const current = this.#cache.get(cwd);
3240
3662
  if (current !== void 0 && current.expiresAt > Date.now()) return current.value;
3241
- const value = this.#inspect(cwd).then((next) => this.#stabilize(cwd, next));
3663
+ const value = this.#inspect(cwd, signal).then((next) => this.#stabilize(cwd, next));
3242
3664
  this.#cache.set(cwd, {
3243
3665
  expiresAt: Date.now() + this.#cacheTtlMs,
3244
3666
  value
@@ -3296,7 +3718,7 @@ var RepositoryStatusService = class {
3296
3718
  this.#ghExecutable ??= this.#runtime.resolveExecutable("gh").catch(() => void 0);
3297
3719
  return this.#ghExecutable;
3298
3720
  }
3299
- async #inspect(cwd) {
3721
+ async #inspect(cwd, signal) {
3300
3722
  const safeCwd = bounded(cwd);
3301
3723
  let git;
3302
3724
  try {
@@ -3346,7 +3768,7 @@ var RepositoryStatusService = class {
3346
3768
  const remote = remoteResult.exitCode === 0 ? parseGitHubRemote(remoteResult.stdout) : void 0;
3347
3769
  const pullRequest = status.branch === void 0 || remote === void 0 ? void 0 : await this.#pullRequest(cwd, remote, status.branch);
3348
3770
  const diffBase = pullRequest?.baseBranch === void 0 ? "HEAD" : await this.#mergeBase(cwd, git, pullRequest.baseBranch) ?? "HEAD";
3349
- const diff = status.dirty || diffBase !== "HEAD" ? await this.#diff(cwd, git, diffBase) : {
3771
+ const diff = status.dirty || diffBase !== "HEAD" ? await this.#diff(cwd, git, diffBase, signal) : {
3350
3772
  additions: 0,
3351
3773
  deletions: 0,
3352
3774
  files: 0,
@@ -3400,7 +3822,7 @@ var RepositoryStatusService = class {
3400
3822
  return;
3401
3823
  }
3402
3824
  }
3403
- async #diff(cwd, git, base) {
3825
+ async #diff(cwd, git, base, signal) {
3404
3826
  try {
3405
3827
  const numstat = await run(this.#runtime, git, [
3406
3828
  "diff",
@@ -3423,7 +3845,7 @@ var RepositoryStatusService = class {
3423
3845
  ...summary,
3424
3846
  truncated: true
3425
3847
  };
3426
- const untracked = await this.#untrackedDiff(cwd, git);
3848
+ const untracked = await this.#untrackedDiff(cwd, git, signal);
3427
3849
  const combinedPatch = `${patch.stdout}${untracked.patch}`;
3428
3850
  return {
3429
3851
  additions: summary.additions + untracked.additions,
@@ -3436,7 +3858,7 @@ var RepositoryStatusService = class {
3436
3858
  return;
3437
3859
  }
3438
3860
  }
3439
- async #untrackedDiff(cwd, git) {
3861
+ async #untrackedDiff(cwd, git, signal) {
3440
3862
  const listed = await run(this.#runtime, git, [
3441
3863
  "ls-files",
3442
3864
  "--others",
@@ -3454,6 +3876,12 @@ var RepositoryStatusService = class {
3454
3876
  let patch = "";
3455
3877
  let truncated = paths.length > MAX_UNTRACKED_DIFFS;
3456
3878
  for (const path of paths.slice(0, MAX_UNTRACKED_DIFFS)) {
3879
+ if (signal?.aborted === true) return {
3880
+ additions,
3881
+ files: paths.length,
3882
+ patch,
3883
+ truncated: true
3884
+ };
3457
3885
  const result = await run(this.#runtime, git, [
3458
3886
  "diff",
3459
3887
  "--no-ext-diff",
@@ -4445,16 +4873,15 @@ const MAX_BODY_BYTES$6 = 16384;
4445
4873
  function record$9(value) {
4446
4874
  return value !== null && typeof value === "object" && !Array.isArray(value) ? value : void 0;
4447
4875
  }
4448
- async function readJson$6(req) {
4449
- const chunks = [];
4450
- let size = 0;
4451
- for await (const chunk of req) {
4452
- const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
4453
- size += buffer.length;
4454
- if (size > MAX_BODY_BYTES$6) throw new RepositorySetupError("body-too-large", "The request body is too large.");
4455
- chunks.push(buffer);
4876
+ async function readJson$5(io) {
4877
+ let parsed;
4878
+ try {
4879
+ parsed = await io.body(MAX_BODY_BYTES$6);
4880
+ } catch (error) {
4881
+ if (error instanceof SyntaxError) throw error;
4882
+ throw new RepositorySetupError("body-too-large", "The request body is too large.");
4456
4883
  }
4457
- const value = record$9(JSON.parse(Buffer.concat(chunks).toString("utf8")));
4884
+ const value = record$9(parsed);
4458
4885
  if (value === void 0) throw new RepositorySetupError("invalid-request", "The request body is invalid.");
4459
4886
  return value;
4460
4887
  }
@@ -4505,41 +4932,48 @@ async function streamSetup(res, service, input) {
4505
4932
  res.end();
4506
4933
  }
4507
4934
  }
4508
- /** Register trusted browser routes for safe pre-session Git setup. */
4935
+ /** Register trusted browser routes for safe pre-session Git setup.
4936
+ *
4937
+ * The prefix is registered as a stream because the setup POST holds its
4938
+ * connection open for the whole worktree build; the short sibling paths ride
4939
+ * the same registration and answer with `json` before releasing it. */
4509
4940
  function registerRepositorySetupRoute(ctx, service) {
4510
- ctx.effect(() => ctx.webServer.register({
4941
+ registerPluginRoute(ctx, {
4942
+ mode: "stream",
4511
4943
  kind: "prefix",
4512
4944
  path: CLAUDE_REPOSITORY_SETUP_PATH,
4513
- handler: async (req, res) => {
4514
- if (!trustedRequest(req)) return json(res, 403, { error: "forbidden" });
4515
- const pathname = new URL(req.url ?? "/", "http://localhost").pathname;
4945
+ methods: ["GET", "POST"],
4946
+ maxConcurrent: 2,
4947
+ streamKey: (url) => `${url.pathname}?${url.searchParams.get("cwd") ?? url.searchParams.get("branch") ?? ""}`,
4948
+ handler: async (res, io) => {
4949
+ const pathname = io.url.pathname;
4516
4950
  try {
4517
4951
  if (pathname === `/plugins/dsh-claude/repository/setup/branches/refresh`) {
4518
- if (req.method !== "POST") return json(res, 405, { error: "method not allowed" });
4519
- const input = await readJson$6(req);
4952
+ if (io.method !== "POST") return json(res, 405, { error: "method not allowed" });
4953
+ const input = await readJson$5(io);
4520
4954
  return json(res, 200, await service.refreshBranches(string$2(input, "cwd")));
4521
4955
  }
4522
4956
  if (pathname === `/plugins/dsh-claude/repository/setup/branches`) {
4523
- if (req.method !== "GET") return json(res, 405, { error: "method not allowed" });
4524
- const cwd = new URL(req.url ?? "/", "http://localhost").searchParams.get("cwd");
4957
+ if (io.method !== "GET") return json(res, 405, { error: "method not allowed" });
4958
+ const cwd = io.url.searchParams.get("cwd");
4525
4959
  if (cwd === null) throw new RepositorySetupError("invalid-request", "The cwd query parameter is required.");
4526
4960
  return json(res, 200, await service.listBranches(cwd));
4527
4961
  }
4528
4962
  if (pathname === "/plugins/dsh-claude/repository/setup") {
4529
- if (req.method !== "POST") return json(res, 405, { error: "method not allowed" });
4530
- const input = await readJson$6(req);
4963
+ if (io.method !== "POST") return json(res, 405, { error: "method not allowed" });
4964
+ const input = await readJson$5(io);
4531
4965
  if (typeof input.worktree !== "boolean") throw new RepositorySetupError("invalid-request", "The worktree field is required.");
4532
4966
  await streamSetup(res, service, input);
4533
4967
  return;
4534
4968
  }
4535
4969
  if (pathname === `/plugins/dsh-claude/repository/setup/cleanup`) {
4536
- if (req.method !== "POST") return json(res, 405, { error: "method not allowed" });
4537
- const input = await readJson$6(req);
4970
+ if (io.method !== "POST") return json(res, 405, { error: "method not allowed" });
4971
+ const input = await readJson$5(io);
4538
4972
  return json(res, 200, await service.cleanupMerged(string$2(input, "path"), string$2(input, "baseBranch")));
4539
4973
  }
4540
4974
  if (pathname === `/plugins/dsh-claude/repository/setup/bind`) {
4541
- if (req.method !== "POST") return json(res, 405, { error: "method not allowed" });
4542
- const input = await readJson$6(req);
4975
+ if (io.method !== "POST") return json(res, 405, { error: "method not allowed" });
4976
+ const input = await readJson$5(io);
4543
4977
  await service.bindLease(string$2(input, "leaseId"), string$2(input, "sessionId"));
4544
4978
  return json(res, 200, { ok: true });
4545
4979
  }
@@ -4553,7 +4987,7 @@ function registerRepositorySetupRoute(ctx, service) {
4553
4987
  return json(res, 500, { error: "repository setup unavailable" });
4554
4988
  }
4555
4989
  }
4556
- }), "dsh-claude: repository setup route");
4990
+ });
4557
4991
  }
4558
4992
  //#endregion
4559
4993
  //#region src/repository-action-routes.ts
@@ -4570,16 +5004,15 @@ const ACTIONS = /* @__PURE__ */ new Set([
4570
5004
  function record$8(value) {
4571
5005
  return value !== null && typeof value === "object" && !Array.isArray(value) ? value : void 0;
4572
5006
  }
4573
- async function readJson$5(req) {
4574
- const chunks = [];
4575
- let size = 0;
4576
- for await (const chunk of req) {
4577
- const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
4578
- size += buffer.length;
4579
- if (size > MAX_BODY_BYTES$5) throw new RepositoryActionError("body-too-large", "The request body is too large.");
4580
- chunks.push(buffer);
5007
+ async function readJson$4(io) {
5008
+ let parsed;
5009
+ try {
5010
+ parsed = await io.body(MAX_BODY_BYTES$5);
5011
+ } catch (error) {
5012
+ if (error instanceof SyntaxError) throw error;
5013
+ throw new RepositoryActionError("body-too-large", "The request body is too large.");
4581
5014
  }
4582
- const value = record$8(JSON.parse(Buffer.concat(chunks).toString("utf8")));
5015
+ const value = record$8(parsed);
4583
5016
  if (value === void 0) throw new RepositoryActionError("invalid-request", "The request body is invalid.");
4584
5017
  return value;
4585
5018
  }
@@ -4618,44 +5051,80 @@ function actionRequest(input) {
4618
5051
  })()
4619
5052
  };
4620
5053
  }
5054
+ /** One prefix serves the local-Git preview and the POST arms that push, open
5055
+ * and merge pull requests, so the registration takes the wider of the two
5056
+ * budgets: a `remote` arm cut short at the `git` deadline would abandon work
5057
+ * that had already reached GitHub. */
4621
5058
  function registerRepositoryActionRoute(ctx, service, cwdForSession) {
4622
- ctx.effect(() => ctx.webServer.register({
5059
+ registerPluginRoute(ctx, {
5060
+ mode: "unary",
4623
5061
  kind: "prefix",
4624
5062
  path: CLAUDE_REPOSITORY_ACTION_PATH,
4625
- handler: async (req, res) => {
4626
- if (!trustedRequest(req)) return json(res, 403, { error: "forbidden" });
4627
- const url = new URL(req.url ?? "/", "http://localhost");
5063
+ methods: ["GET", "POST"],
5064
+ budget: "remote",
5065
+ handler: async (io) => {
5066
+ const url = io.url;
4628
5067
  try {
4629
5068
  const cwd = cwdForSession(sessionId$1(url));
4630
5069
  if (cwd === void 0) throw new RepositoryActionError("session-unavailable", "The Claude session is unavailable.");
4631
5070
  if (url.pathname === `/plugins/dsh-claude/repository/action/preview`) {
4632
- if (req.method !== "GET") return json(res, 405, { error: "method not allowed" });
4633
- return json(res, 200, await service.preview(cwd));
5071
+ if (io.method !== "GET") return {
5072
+ status: 405,
5073
+ value: { error: "method not allowed" }
5074
+ };
5075
+ return {
5076
+ status: 200,
5077
+ value: await service.preview(cwd)
5078
+ };
4634
5079
  }
4635
5080
  if (url.pathname === `/plugins/dsh-claude/repository/action/message`) {
4636
- if (req.method !== "POST") return json(res, 405, { error: "method not allowed" });
4637
- const input = await readJson$5(req);
4638
- return json(res, 200, { message: await service.generateMessage(cwd, string$1(input, "fingerprint")) });
5081
+ if (io.method !== "POST") return {
5082
+ status: 405,
5083
+ value: { error: "method not allowed" }
5084
+ };
5085
+ const input = await readJson$4(io);
5086
+ return {
5087
+ status: 200,
5088
+ value: { message: await service.generateMessage(cwd, string$1(input, "fingerprint")) }
5089
+ };
4639
5090
  }
4640
5091
  if (url.pathname === "/plugins/dsh-claude/repository/action") {
4641
- if (req.method !== "POST") return json(res, 405, { error: "method not allowed" });
4642
- return json(res, 200, await service.execute(cwd, actionRequest(await readJson$5(req))));
5092
+ if (io.method !== "POST") return {
5093
+ status: 405,
5094
+ value: { error: "method not allowed" }
5095
+ };
5096
+ return {
5097
+ status: 200,
5098
+ value: await service.execute(cwd, actionRequest(await readJson$4(io)))
5099
+ };
4643
5100
  }
4644
- return json(res, 404, { error: "not found" });
5101
+ return {
5102
+ status: 404,
5103
+ value: { error: "not found" }
5104
+ };
4645
5105
  } catch (error) {
4646
- if (error instanceof RepositoryActionError) return json(res, 409, {
4647
- error: error.code,
4648
- message: error.message,
4649
- ...error.commit === void 0 ? {} : { commit: error.commit }
4650
- });
4651
- if (error instanceof SyntaxError) return json(res, 400, { error: "invalid-json" });
4652
- return json(res, 500, {
4653
- error: "repository-action-unavailable",
4654
- message: "Repository action is unavailable."
4655
- });
5106
+ if (error instanceof RepositoryActionError) return {
5107
+ status: 409,
5108
+ value: {
5109
+ error: error.code,
5110
+ message: error.message,
5111
+ ...error.commit === void 0 ? {} : { commit: error.commit }
5112
+ }
5113
+ };
5114
+ if (error instanceof SyntaxError) return {
5115
+ status: 400,
5116
+ value: { error: "invalid-json" }
5117
+ };
5118
+ return {
5119
+ status: 500,
5120
+ value: {
5121
+ error: "repository-action-unavailable",
5122
+ message: "Repository action is unavailable."
5123
+ }
5124
+ };
4656
5125
  }
4657
5126
  }
4658
- }), "dsh-claude: repository action route");
5127
+ });
4659
5128
  }
4660
5129
  //#endregion
4661
5130
  //#region src/editor-open.ts
@@ -4781,43 +5250,62 @@ const MAX_SESSION_ID_CHARS$4 = 1024;
4781
5250
  /** Open the session's working directory in a desktop editor. Query-only: the
4782
5251
  * request carries two enum-ish values, so there is no body to parse. */
4783
5252
  function registerEditorOpenRoute(ctx, service, cwdForSession) {
4784
- ctx.effect(() => ctx.webServer.register({
5253
+ registerPluginRoute(ctx, {
5254
+ mode: "unary",
4785
5255
  kind: "exact",
4786
5256
  path: CLAUDE_EDITOR_OPEN_PATH,
4787
- handler: async (req, res) => {
4788
- if (!trustedRequest(req)) return json(res, 403, { error: "forbidden" });
4789
- if (req.method !== "POST") return json(res, 405, { error: "method not allowed" });
4790
- const params = new URL(req.url ?? "/", "http://localhost").searchParams;
5257
+ methods: ["POST"],
5258
+ budget: "fast",
5259
+ handler: async (io) => {
5260
+ const params = io.url.searchParams;
4791
5261
  const id = params.get("sessionId");
4792
5262
  const editor = params.get("editor");
4793
- if (id === null || id.length === 0 || id.length > MAX_SESSION_ID_CHARS$4) return json(res, 400, {
4794
- error: "invalid-session",
4795
- message: "The session is invalid."
4796
- });
4797
- if (editor === null || !EDITOR_IDS.has(editor)) return json(res, 400, {
4798
- error: "invalid-editor",
4799
- message: "The editor is invalid."
4800
- });
5263
+ if (id === null || id.length === 0 || id.length > MAX_SESSION_ID_CHARS$4) return {
5264
+ status: 400,
5265
+ value: {
5266
+ error: "invalid-session",
5267
+ message: "The session is invalid."
5268
+ }
5269
+ };
5270
+ if (editor === null || !EDITOR_IDS.has(editor)) return {
5271
+ status: 400,
5272
+ value: {
5273
+ error: "invalid-editor",
5274
+ message: "The editor is invalid."
5275
+ }
5276
+ };
4801
5277
  const cwd = cwdForSession(id);
4802
- if (cwd === void 0) return json(res, 409, {
4803
- error: "session-unavailable",
4804
- message: "The Claude session is unavailable."
4805
- });
5278
+ if (cwd === void 0) return {
5279
+ status: 409,
5280
+ value: {
5281
+ error: "session-unavailable",
5282
+ message: "The Claude session is unavailable."
5283
+ }
5284
+ };
4806
5285
  try {
4807
5286
  await service.open(cwd, editor);
4808
- return json(res, 200, { opened: true });
5287
+ return {
5288
+ status: 200,
5289
+ value: { opened: true }
5290
+ };
4809
5291
  } catch (error) {
4810
- if (error instanceof EditorOpenError) return json(res, 409, {
4811
- error: error.code,
4812
- message: error.message
4813
- });
4814
- return json(res, 500, {
4815
- error: "editor-open-unavailable",
4816
- message: "The editor could not be launched."
4817
- });
5292
+ if (error instanceof EditorOpenError) return {
5293
+ status: 409,
5294
+ value: {
5295
+ error: error.code,
5296
+ message: error.message
5297
+ }
5298
+ };
5299
+ return {
5300
+ status: 500,
5301
+ value: {
5302
+ error: "editor-open-unavailable",
5303
+ message: "The editor could not be launched."
5304
+ }
5305
+ };
4818
5306
  }
4819
5307
  }
4820
- }), "dsh-claude: editor open route");
5308
+ });
4821
5309
  }
4822
5310
  //#endregion
4823
5311
  //#region src/github-url.ts
@@ -5110,7 +5598,10 @@ var PullRequestFeedbackService = class {
5110
5598
  return [];
5111
5599
  }
5112
5600
  }
5113
- async failingChecks(cwd, pullNumber) {
5601
+ /** `signal` bounds the log fetches below: each is capped on its own, but they
5602
+ * run one after another, so the honest worst case is their sum rather than
5603
+ * any single ceiling — more than a route is allowed to hold a connection. */
5604
+ async failingChecks(cwd, pullNumber, signal) {
5114
5605
  const gh = await this.#gh();
5115
5606
  const result = await this.#run(gh, [
5116
5607
  "pr",
@@ -5132,6 +5623,10 @@ var PullRequestFeedbackService = class {
5132
5623
  detailed.push(check);
5133
5624
  continue;
5134
5625
  }
5626
+ if (signal?.aborted === true) {
5627
+ detailed.push(check);
5628
+ continue;
5629
+ }
5135
5630
  const log = await this.#run(gh, [
5136
5631
  "run",
5137
5632
  "view",
@@ -5204,16 +5699,15 @@ const MAX_MENTION_QUERY_CHARS = 64;
5204
5699
  function record$6(value) {
5205
5700
  return value !== null && typeof value === "object" && !Array.isArray(value) ? value : void 0;
5206
5701
  }
5207
- async function readJson$4(req) {
5208
- const chunks = [];
5209
- let size = 0;
5210
- for await (const chunk of req) {
5211
- const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
5212
- size += buffer.length;
5213
- if (size > MAX_BODY_BYTES$4) throw new PullRequestFeedbackError("body-too-large", "The request body is too large.");
5214
- chunks.push(buffer);
5702
+ async function readJson$3(io) {
5703
+ let parsed;
5704
+ try {
5705
+ parsed = await io.body(MAX_BODY_BYTES$4);
5706
+ } catch (error) {
5707
+ if (error instanceof SyntaxError) throw error;
5708
+ throw new PullRequestFeedbackError("body-too-large", "The request body is too large.");
5215
5709
  }
5216
- const value = record$6(JSON.parse(Buffer.concat(chunks).toString("utf8")));
5710
+ const value = record$6(parsed);
5217
5711
  if (value === void 0) throw new PullRequestFeedbackError("invalid-request", "The request body is invalid.");
5218
5712
  return value;
5219
5713
  }
@@ -5242,123 +5736,199 @@ function pullNumber(url) {
5242
5736
  if (!Number.isSafeInteger(value) || value <= 0 || value > 1e9) throw new PullRequestFeedbackError("invalid-request", "The pull request number is invalid.");
5243
5737
  return value;
5244
5738
  }
5739
+ /** Every arm shells out to `gh`, so the whole prefix carries the network budget. */
5245
5740
  function registerPullRequestFeedbackRoute(ctx, service, cwdForSession) {
5246
- ctx.effect(() => ctx.webServer.register({
5741
+ registerPluginRoute(ctx, {
5742
+ mode: "unary",
5247
5743
  kind: "prefix",
5248
5744
  path: CLAUDE_REPOSITORY_FEEDBACK_PATH,
5249
- handler: async (req, res) => {
5250
- if (!trustedRequest(req)) return json(res, 403, { error: "forbidden" });
5251
- const url = new URL(req.url ?? "/", "http://localhost");
5252
- const reads = req.method === "GET";
5253
- const writes = req.method === "POST";
5745
+ methods: ["GET", "POST"],
5746
+ budget: "remote",
5747
+ handler: async (io) => {
5748
+ const url = io.url;
5749
+ const reads = io.method === "GET";
5750
+ const writes = io.method === "POST";
5254
5751
  try {
5255
5752
  const cwd = cwdForSession(sessionId(url));
5256
5753
  if (cwd === void 0) throw new PullRequestFeedbackError("session-unavailable", "The Claude session is unavailable.");
5257
5754
  if (url.pathname === `/plugins/dsh-claude/repository/feedback/comments`) {
5258
- if (!reads) return json(res, 405, { error: "method not allowed" });
5259
- return json(res, 200, { threads: await service.threads(cwd, pullNumber(url)) });
5755
+ if (!reads) return {
5756
+ status: 405,
5757
+ value: { error: "method not allowed" }
5758
+ };
5759
+ return {
5760
+ status: 200,
5761
+ value: { threads: await service.threads(cwd, pullNumber(url)) }
5762
+ };
5260
5763
  }
5261
5764
  if (url.pathname === `/plugins/dsh-claude/repository/feedback/checks`) {
5262
- if (!reads) return json(res, 405, { error: "method not allowed" });
5263
- return json(res, 200, { checks: await service.failingChecks(cwd, pullNumber(url)) });
5765
+ if (!reads) return {
5766
+ status: 405,
5767
+ value: { error: "method not allowed" }
5768
+ };
5769
+ return {
5770
+ status: 200,
5771
+ value: { checks: await service.failingChecks(cwd, pullNumber(url), io.signal) }
5772
+ };
5264
5773
  }
5265
5774
  if (url.pathname === `/plugins/dsh-claude/repository/feedback/mentionables`) {
5266
- if (!reads) return json(res, 405, { error: "method not allowed" });
5775
+ if (!reads) return {
5776
+ status: 405,
5777
+ value: { error: "method not allowed" }
5778
+ };
5267
5779
  const query = (url.searchParams.get("q") ?? "").slice(0, MAX_MENTION_QUERY_CHARS);
5268
- return json(res, 200, { users: await service.mentionables(cwd, query) });
5780
+ return {
5781
+ status: 200,
5782
+ value: { users: await service.mentionables(cwd, query) }
5783
+ };
5269
5784
  }
5270
5785
  if (url.pathname === `/plugins/dsh-claude/repository/feedback/reply`) {
5271
- if (!writes) return json(res, 405, { error: "method not allowed" });
5272
- const input = await readJson$4(req);
5273
- return json(res, 200, { comment: await service.reply(cwd, pullNumber(url), commentId(input), replyBody(input)) });
5786
+ if (!writes) return {
5787
+ status: 405,
5788
+ value: { error: "method not allowed" }
5789
+ };
5790
+ const input = await readJson$3(io);
5791
+ return {
5792
+ status: 200,
5793
+ value: { comment: await service.reply(cwd, pullNumber(url), commentId(input), replyBody(input)) }
5794
+ };
5274
5795
  }
5275
5796
  if (url.pathname === `/plugins/dsh-claude/repository/feedback/resolve`) {
5276
- if (!writes) return json(res, 405, { error: "method not allowed" });
5277
- const input = await readJson$4(req);
5797
+ if (!writes) return {
5798
+ status: 405,
5799
+ value: { error: "method not allowed" }
5800
+ };
5801
+ const input = await readJson$3(io);
5278
5802
  if (typeof input.resolved !== "boolean") throw new PullRequestFeedbackError("invalid-request", "The resolved field is required.");
5279
- return json(res, 200, { resolved: await service.setResolved(cwd, threadId(input), input.resolved) });
5803
+ return {
5804
+ status: 200,
5805
+ value: { resolved: await service.setResolved(cwd, threadId(input), input.resolved) }
5806
+ };
5280
5807
  }
5281
- return json(res, 404, { error: "not found" });
5808
+ return {
5809
+ status: 404,
5810
+ value: { error: "not found" }
5811
+ };
5282
5812
  } catch (error) {
5283
- if (error instanceof PullRequestFeedbackError) return json(res, 409, {
5284
- error: error.code,
5285
- message: error.message
5286
- });
5287
- if (error instanceof SyntaxError) return json(res, 400, { error: "invalid-json" });
5288
- return json(res, 500, {
5289
- error: "pr-feedback-unavailable",
5290
- message: "Pull request feedback is unavailable."
5291
- });
5813
+ if (error instanceof PullRequestFeedbackError) return {
5814
+ status: 409,
5815
+ value: {
5816
+ error: error.code,
5817
+ message: error.message
5818
+ }
5819
+ };
5820
+ if (error instanceof SyntaxError) return {
5821
+ status: 400,
5822
+ value: { error: "invalid-json" }
5823
+ };
5824
+ return {
5825
+ status: 500,
5826
+ value: {
5827
+ error: "pr-feedback-unavailable",
5828
+ message: "Pull request feedback is unavailable."
5829
+ }
5830
+ };
5292
5831
  }
5293
5832
  }
5294
- }), "dsh-claude: pull request feedback route");
5833
+ });
5295
5834
  }
5296
5835
  //#endregion
5297
5836
  //#region src/repository-status-routes.ts
5298
5837
  const MAX_PATH_CHARS$1 = 4096;
5299
5838
  /** Read-only repository status for an arbitrary directory (the overview panel
5300
- * aggregates every Claude session's checkout through this). */
5839
+ * aggregates every Claude session's checkout through this).
5840
+ *
5841
+ * The route signal is threaded into the service because this is the one read
5842
+ * the overview polls per distinct checkout every 30 seconds: freeing the
5843
+ * socket at the budget while the Host kept scanning would just grow a queue
5844
+ * of work nobody is waiting for any more. */
5301
5845
  function registerRepositoryStatusRoute(ctx, service) {
5302
- ctx.effect(() => ctx.webServer.register({
5846
+ registerPluginRoute(ctx, {
5847
+ mode: "unary",
5303
5848
  kind: "exact",
5304
5849
  path: CLAUDE_REPOSITORY_STATUS_PATH,
5305
- handler: async (req, res) => {
5306
- if (!trustedRequest(req)) return json(res, 403, { error: "forbidden" });
5307
- if (req.method !== "GET") return json(res, 405, { error: "method not allowed" });
5308
- const cwd = new URL(req.url ?? "/", "http://localhost").searchParams.get("cwd");
5309
- if (cwd === null || cwd.length === 0 || cwd.length > MAX_PATH_CHARS$1 || !isAbsolute(cwd) || cwd.includes("\0")) return json(res, 400, {
5310
- error: "invalid-request",
5311
- message: "The cwd query parameter is invalid."
5312
- });
5850
+ methods: ["GET"],
5851
+ budget: "git",
5852
+ handler: async (io) => {
5853
+ const cwd = io.url.searchParams.get("cwd");
5854
+ if (cwd === null || cwd.length === 0 || cwd.length > MAX_PATH_CHARS$1 || !isAbsolute(cwd) || cwd.includes("\0")) return {
5855
+ status: 400,
5856
+ value: {
5857
+ error: "invalid-request",
5858
+ message: "The cwd query parameter is invalid."
5859
+ }
5860
+ };
5313
5861
  try {
5314
- return json(res, 200, await service.inspect(cwd));
5862
+ return {
5863
+ status: 200,
5864
+ value: await service.inspect(cwd, io.signal)
5865
+ };
5315
5866
  } catch {
5316
- return json(res, 500, {
5317
- error: "repository-status-unavailable",
5318
- message: "Repository status is unavailable."
5319
- });
5867
+ return {
5868
+ status: 500,
5869
+ value: {
5870
+ error: "repository-status-unavailable",
5871
+ message: "Repository status is unavailable."
5872
+ }
5873
+ };
5320
5874
  }
5321
5875
  }
5322
- }), "dsh-claude: repository status route");
5876
+ });
5323
5877
  }
5324
5878
  //#endregion
5325
5879
  //#region src/repository-file-routes.ts
5326
5880
  const MAX_PATH_CHARS = 4096;
5327
5881
  /** Read-only slice of a working-tree file so the diff panel can expand unmodified lines around hunks. */
5328
5882
  function registerRepositoryFileRoute(ctx, service) {
5329
- ctx.effect(() => ctx.webServer.register({
5883
+ registerPluginRoute(ctx, {
5884
+ mode: "unary",
5330
5885
  kind: "exact",
5331
5886
  path: CLAUDE_REPOSITORY_FILE_PATH,
5332
- handler: async (req, res) => {
5333
- if (!trustedRequest(req)) return json(res, 403, { error: "forbidden" });
5334
- if (req.method !== "GET") return json(res, 405, { error: "method not allowed" });
5335
- const params = new URL(req.url ?? "/", "http://localhost").searchParams;
5887
+ methods: ["GET"],
5888
+ budget: "git",
5889
+ handler: async (io) => {
5890
+ const params = io.url.searchParams;
5336
5891
  const cwd = params.get("cwd");
5337
5892
  const path = params.get("path");
5338
5893
  const from = Number(params.get("from"));
5339
5894
  const to = Number(params.get("to"));
5340
- if (cwd === null || cwd.length === 0 || cwd.length > MAX_PATH_CHARS || !isAbsolute(cwd) || cwd.includes("\0")) return json(res, 400, {
5341
- error: "invalid-request",
5342
- message: "The cwd query parameter is invalid."
5343
- });
5344
- if (path === null || path.length === 0 || path.length > MAX_PATH_CHARS) return json(res, 400, {
5345
- error: "invalid-request",
5346
- message: "The path query parameter is invalid."
5347
- });
5895
+ if (cwd === null || cwd.length === 0 || cwd.length > MAX_PATH_CHARS || !isAbsolute(cwd) || cwd.includes("\0")) return {
5896
+ status: 400,
5897
+ value: {
5898
+ error: "invalid-request",
5899
+ message: "The cwd query parameter is invalid."
5900
+ }
5901
+ };
5902
+ if (path === null || path.length === 0 || path.length > MAX_PATH_CHARS) return {
5903
+ status: 400,
5904
+ value: {
5905
+ error: "invalid-request",
5906
+ message: "The path query parameter is invalid."
5907
+ }
5908
+ };
5348
5909
  try {
5349
- return json(res, 200, await service.fileLines(cwd, path, from, to));
5910
+ return {
5911
+ status: 200,
5912
+ value: await service.fileLines(cwd, path, from, to)
5913
+ };
5350
5914
  } catch (error) {
5351
- if (error instanceof RepositoryFileError) return json(res, error.code === "invalid-request" ? 400 : 409, {
5352
- error: error.code,
5353
- message: error.message
5354
- });
5355
- return json(res, 500, {
5356
- error: "repository-file-unavailable",
5357
- message: "The file could not be read."
5358
- });
5915
+ if (error instanceof RepositoryFileError) return {
5916
+ status: error.code === "invalid-request" ? 400 : 409,
5917
+ value: {
5918
+ error: error.code,
5919
+ message: error.message
5920
+ }
5921
+ };
5922
+ return {
5923
+ status: 500,
5924
+ value: {
5925
+ error: "repository-file-unavailable",
5926
+ message: "The file could not be read."
5927
+ }
5928
+ };
5359
5929
  }
5360
5930
  }
5361
- }), "dsh-claude: repository file route");
5931
+ });
5362
5932
  }
5363
5933
  //#endregion
5364
5934
  //#region src/jira.ts
@@ -5604,16 +6174,17 @@ const MAX_BODY_BYTES$3 = 8192;
5604
6174
  function record$4(value) {
5605
6175
  return value !== null && typeof value === "object" && !Array.isArray(value) ? value : void 0;
5606
6176
  }
5607
- async function readJson$3(req) {
5608
- const chunks = [];
5609
- let size = 0;
5610
- for await (const chunk of req) {
5611
- const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
5612
- size += buffer.length;
5613
- if (size > MAX_BODY_BYTES$3) throw new JiraError("body-too-large", "The request body is too large.");
5614
- chunks.push(buffer);
6177
+ /** The wrapper enforces the byte cap; its plain rejection is translated back
6178
+ * into the JiraError shape the panel already knows how to render. */
6179
+ async function readJson$2(io) {
6180
+ let body;
6181
+ try {
6182
+ body = await io.body(MAX_BODY_BYTES$3);
6183
+ } catch (error) {
6184
+ if (error instanceof SyntaxError) throw error;
6185
+ throw new JiraError("body-too-large", "The request body is too large.");
5615
6186
  }
5616
- const value = record$4(JSON.parse(Buffer.concat(chunks).toString("utf8")));
6187
+ const value = record$4(body);
5617
6188
  if (value === void 0) throw new JiraError("invalid-request", "The request body is invalid.");
5618
6189
  return value;
5619
6190
  }
@@ -5623,55 +6194,99 @@ function string(input, key) {
5623
6194
  return value;
5624
6195
  }
5625
6196
  function registerJiraRoute(ctx, service) {
5626
- ctx.effect(() => ctx.webServer.register({
6197
+ registerPluginRoute(ctx, {
6198
+ mode: "unary",
5627
6199
  kind: "prefix",
5628
6200
  path: CLAUDE_JIRA_PATH,
5629
- handler: async (req, res) => {
5630
- if (!trustedRequest(req)) return json(res, 403, { error: "forbidden" });
5631
- const url = new URL(req.url ?? "/", "http://localhost");
6201
+ methods: ["GET", "POST"],
6202
+ budget: "git",
6203
+ handler: async (io) => {
6204
+ const url = io.url;
5632
6205
  try {
5633
6206
  if (url.pathname === `/plugins/dsh-claude/jira/status`) {
5634
- if (req.method !== "GET") return json(res, 405, { error: "method not allowed" });
5635
- return json(res, 200, await service.status());
6207
+ if (io.method !== "GET") return {
6208
+ status: 405,
6209
+ value: { error: "method not allowed" }
6210
+ };
6211
+ return {
6212
+ status: 200,
6213
+ value: await service.status()
6214
+ };
5636
6215
  }
5637
6216
  if (url.pathname === `/plugins/dsh-claude/jira/connect`) {
5638
- if (req.method !== "POST") return json(res, 405, { error: "method not allowed" });
5639
- const input = await readJson$3(req);
5640
- return json(res, 200, await service.connect({
5641
- siteUrl: string(input, "siteUrl"),
5642
- email: string(input, "email"),
5643
- apiToken: string(input, "apiToken")
5644
- }));
6217
+ if (io.method !== "POST") return {
6218
+ status: 405,
6219
+ value: { error: "method not allowed" }
6220
+ };
6221
+ const input = await readJson$2(io);
6222
+ return {
6223
+ status: 200,
6224
+ value: await service.connect({
6225
+ siteUrl: string(input, "siteUrl"),
6226
+ email: string(input, "email"),
6227
+ apiToken: string(input, "apiToken")
6228
+ })
6229
+ };
5645
6230
  }
5646
6231
  if (url.pathname === `/plugins/dsh-claude/jira/disconnect`) {
5647
- if (req.method !== "POST") return json(res, 405, { error: "method not allowed" });
6232
+ if (io.method !== "POST") return {
6233
+ status: 405,
6234
+ value: { error: "method not allowed" }
6235
+ };
5648
6236
  await service.disconnect();
5649
- return json(res, 200, { connected: false });
6237
+ return {
6238
+ status: 200,
6239
+ value: { connected: false }
6240
+ };
5650
6241
  }
5651
6242
  if (url.pathname === `/plugins/dsh-claude/jira/assign`) {
5652
- if (req.method !== "POST") return json(res, 405, { error: "method not allowed" });
5653
- const input = await readJson$3(req);
6243
+ if (io.method !== "POST") return {
6244
+ status: 405,
6245
+ value: { error: "method not allowed" }
6246
+ };
6247
+ const input = await readJson$2(io);
5654
6248
  await service.assignToMe(string(input, "key"));
5655
- return json(res, 200, { assigned: true });
6249
+ return {
6250
+ status: 200,
6251
+ value: { assigned: true }
6252
+ };
5656
6253
  }
5657
6254
  if (url.pathname === `/plugins/dsh-claude/jira/search`) {
5658
- if (req.method !== "GET") return json(res, 405, { error: "method not allowed" });
5659
- return json(res, 200, { tickets: await service.search(url.searchParams.get("query") ?? "") });
6255
+ if (io.method !== "GET") return {
6256
+ status: 405,
6257
+ value: { error: "method not allowed" }
6258
+ };
6259
+ return {
6260
+ status: 200,
6261
+ value: { tickets: await service.search(url.searchParams.get("query") ?? "") }
6262
+ };
5660
6263
  }
5661
- return json(res, 404, { error: "not found" });
6264
+ return {
6265
+ status: 404,
6266
+ value: { error: "not found" }
6267
+ };
5662
6268
  } catch (error) {
5663
- if (error instanceof JiraError) return json(res, 409, {
5664
- error: error.code,
5665
- message: error.message
5666
- });
5667
- if (error instanceof SyntaxError) return json(res, 400, { error: "invalid-json" });
5668
- return json(res, 500, {
5669
- error: "jira-unavailable",
5670
- message: "Jira is unavailable."
5671
- });
6269
+ if (error instanceof JiraError) return {
6270
+ status: 409,
6271
+ value: {
6272
+ error: error.code,
6273
+ message: error.message
6274
+ }
6275
+ };
6276
+ if (error instanceof SyntaxError) return {
6277
+ status: 400,
6278
+ value: { error: "invalid-json" }
6279
+ };
6280
+ return {
6281
+ status: 500,
6282
+ value: {
6283
+ error: "jira-unavailable",
6284
+ message: "Jira is unavailable."
6285
+ }
6286
+ };
5672
6287
  }
5673
6288
  }
5674
- }), "dsh-claude: jira route");
6289
+ });
5675
6290
  }
5676
6291
  //#endregion
5677
6292
  //#region src/ask.ts
@@ -5899,22 +6514,11 @@ var AskService = class {
5899
6514
  //#region src/ask-routes.ts
5900
6515
  const MAX_BODY_BYTES$2 = 131072;
5901
6516
  const MAX_SESSION_ID_CHARS$2 = 1024;
6517
+ /** Two sessions may await an answer at once; a third evicts the oldest. */
6518
+ const MAX_CONCURRENT_ASKS = 2;
5902
6519
  function record$2(value) {
5903
6520
  return value !== null && typeof value === "object" && !Array.isArray(value) ? value : void 0;
5904
6521
  }
5905
- async function readJson$2(req) {
5906
- const chunks = [];
5907
- let size = 0;
5908
- for await (const chunk of req) {
5909
- const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
5910
- size += buffer.length;
5911
- if (size > MAX_BODY_BYTES$2) throw new AskError("body-too-large", "The request body is too large.");
5912
- chunks.push(buffer);
5913
- }
5914
- const value = record$2(JSON.parse(Buffer.concat(chunks).toString("utf8")));
5915
- if (value === void 0) throw new AskError("invalid-request", "The request body is invalid.");
5916
- return value;
5917
- }
5918
6522
  function askRequest(input) {
5919
6523
  if (typeof input.selection !== "string" || typeof input.question !== "string" || input.context !== void 0 && typeof input.context !== "string") throw new AskError("invalid-request", "The selection and question fields are required.");
5920
6524
  return {
@@ -5926,25 +6530,32 @@ function askRequest(input) {
5926
6530
  function ndjson(res, value) {
5927
6531
  res.write(`${JSON.stringify(value)}\n`);
5928
6532
  }
6533
+ /** A stream route rather than a unary one: the answer is written as it arrives,
6534
+ * so the deadline stays where the work is — `ASK_TIMEOUT_MS` inside the
6535
+ * service, fused there with the disconnect signal — instead of a route budget
6536
+ * that would cut a legitimate long answer off mid-sentence. */
5929
6537
  function registerAskRoute(ctx, service, cwdForSession, preferencesFor) {
5930
- ctx.effect(() => ctx.webServer.register({
6538
+ registerPluginRoute(ctx, {
6539
+ mode: "stream",
5931
6540
  kind: "exact",
5932
6541
  path: CLAUDE_ASK_PATH,
5933
- handler: async (req, res) => {
5934
- if (!trustedRequest(req)) return json(res, 403, { error: "forbidden" });
5935
- if (req.method !== "POST") return json(res, 405, { error: "method not allowed" });
5936
- const url = new URL(req.url ?? "/", "http://localhost");
6542
+ methods: ["POST"],
6543
+ maxConcurrent: MAX_CONCURRENT_ASKS,
6544
+ streamKey: (url) => url.searchParams.get("sessionId") ?? "",
6545
+ handler: async (res, io) => {
5937
6546
  let cwd;
5938
6547
  let request;
5939
6548
  let sessionId;
5940
6549
  try {
5941
- const value = url.searchParams.get("sessionId");
6550
+ const value = io.url.searchParams.get("sessionId");
5942
6551
  if (value === null || value.length === 0 || value.length > MAX_SESSION_ID_CHARS$2) throw new AskError("invalid-session", "The session is invalid.");
5943
6552
  sessionId = value;
5944
6553
  const resolved = cwdForSession(sessionId);
5945
6554
  if (resolved === void 0) throw new AskError("session-unavailable", "The Claude session is unavailable.");
5946
6555
  cwd = resolved;
5947
- request = askRequest(await readJson$2(req));
6556
+ const body = record$2(await io.body(MAX_BODY_BYTES$2));
6557
+ if (body === void 0) throw new AskError("invalid-request", "The request body is invalid.");
6558
+ request = askRequest(body);
5948
6559
  } catch (error) {
5949
6560
  if (error instanceof AskError) return json(res, 409, {
5950
6561
  error: error.code,
@@ -5962,17 +6573,13 @@ function registerAskRoute(ctx, service, cwdForSession, preferencesFor) {
5962
6573
  "x-content-type-options": "nosniff"
5963
6574
  });
5964
6575
  res.flushHeaders?.();
5965
- const controller = new AbortController();
5966
- req.on("close", () => {
5967
- controller.abort();
5968
- });
5969
6576
  try {
5970
6577
  await service.ask(cwd, request, preferencesFor(sessionId) ?? {}, (event) => {
5971
6578
  ndjson(res, event.type === "text" ? {
5972
6579
  type: "delta",
5973
6580
  text: event.text
5974
6581
  } : event);
5975
- }, controller.signal);
6582
+ }, io.signal);
5976
6583
  ndjson(res, { type: "done" });
5977
6584
  } catch (error) {
5978
6585
  ndjson(res, {
@@ -5980,11 +6587,9 @@ function registerAskRoute(ctx, service, cwdForSession, preferencesFor) {
5980
6587
  code: error instanceof AskError ? error.code : "ask-unavailable",
5981
6588
  message: error instanceof AskError ? error.message : "The question could not be answered."
5982
6589
  });
5983
- } finally {
5984
- res.end();
5985
6590
  }
5986
6591
  }
5987
- }), "dsh-claude: ask route");
6592
+ });
5988
6593
  }
5989
6594
  //#endregion
5990
6595
  //#region src/review-comment-routes.ts
@@ -5993,16 +6598,15 @@ const MAX_SESSION_ID_CHARS$1 = 1024;
5993
6598
  function record$1(value) {
5994
6599
  return value !== null && typeof value === "object" && !Array.isArray(value) ? value : void 0;
5995
6600
  }
5996
- async function readJson$1(req) {
5997
- const chunks = [];
5998
- let size = 0;
5999
- for await (const chunk of req) {
6000
- const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
6001
- size += buffer.length;
6002
- if (size > MAX_BODY_BYTES$1) throw new ReviewCommentError("body-too-large", "The request body is too large.");
6003
- chunks.push(buffer);
6601
+ async function readJson$1(io) {
6602
+ let parsed;
6603
+ try {
6604
+ parsed = await io.body(MAX_BODY_BYTES$1);
6605
+ } catch (error) {
6606
+ if (error instanceof SyntaxError) throw error;
6607
+ throw new ReviewCommentError("body-too-large", "The request body is too large.");
6004
6608
  }
6005
- const value = record$1(JSON.parse(Buffer.concat(chunks).toString("utf8")));
6609
+ const value = record$1(parsed);
6006
6610
  if (value === void 0) throw new ReviewCommentError("invalid-request", "The request body is invalid.");
6007
6611
  return value;
6008
6612
  }
@@ -6012,45 +6616,68 @@ function sessionIdFromUrl(url) {
6012
6616
  return value;
6013
6617
  }
6014
6618
  function registerReviewCommentRoute(ctx, store, ownsSession) {
6015
- ctx.effect(() => ctx.webServer.register({
6619
+ registerPluginRoute(ctx, {
6620
+ mode: "unary",
6016
6621
  kind: "prefix",
6017
6622
  path: CLAUDE_REVIEW_COMMENT_PATH,
6018
- handler: async (req, res) => {
6019
- if (!trustedRequest(req)) return json(res, 403, { error: "forbidden" });
6020
- const url = new URL(req.url ?? "/", "http://localhost");
6623
+ methods: ["POST"],
6624
+ budget: "fast",
6625
+ handler: async (io) => {
6021
6626
  try {
6022
- const sessionId = sessionIdFromUrl(url);
6627
+ const sessionId = sessionIdFromUrl(io.url);
6023
6628
  if (!ownsSession(sessionId)) throw new ReviewCommentError("session-unavailable", "The Claude session is unavailable.");
6024
- if (url.pathname === "/plugins/dsh-claude/review-comments" && req.method === "POST") {
6025
- const input = await readJson$1(req);
6026
- return json(res, 200, { comment: store.add(sessionId, {
6027
- path: input.path,
6028
- line: input.line,
6029
- startLine: input.startLine,
6030
- side: input.side,
6031
- text: input.text
6032
- }) });
6629
+ const pathname = io.url.pathname;
6630
+ if (pathname === "/plugins/dsh-claude/review-comments") {
6631
+ const input = await readJson$1(io);
6632
+ return {
6633
+ status: 200,
6634
+ value: { comment: store.add(sessionId, {
6635
+ path: input.path,
6636
+ line: input.line,
6637
+ startLine: input.startLine,
6638
+ side: input.side,
6639
+ text: input.text
6640
+ }) }
6641
+ };
6033
6642
  }
6034
- if (url.pathname === `/plugins/dsh-claude/review-comments/clear` && req.method === "POST") return json(res, 200, { removed: store.drain(sessionId).length });
6035
- if (url.pathname === `/plugins/dsh-claude/review-comments/remove` && req.method === "POST") {
6036
- const input = await readJson$1(req);
6643
+ if (pathname === `/plugins/dsh-claude/review-comments/clear`) return {
6644
+ status: 200,
6645
+ value: { removed: store.drain(sessionId).length }
6646
+ };
6647
+ if (pathname === `/plugins/dsh-claude/review-comments/remove`) {
6648
+ const input = await readJson$1(io);
6037
6649
  if (typeof input.id !== "string" || input.id.length === 0 || input.id.length > 128) throw new ReviewCommentError("invalid-request", "The comment id is invalid.");
6038
- return json(res, 200, { removed: store.remove(sessionId, input.id) });
6650
+ return {
6651
+ status: 200,
6652
+ value: { removed: store.remove(sessionId, input.id) }
6653
+ };
6039
6654
  }
6040
- return json(res, 404, { error: "not found" });
6655
+ return {
6656
+ status: 404,
6657
+ value: { error: "not found" }
6658
+ };
6041
6659
  } catch (error) {
6042
- if (error instanceof ReviewCommentError) return json(res, 409, {
6043
- error: error.code,
6044
- message: error.message
6045
- });
6046
- if (error instanceof SyntaxError) return json(res, 400, { error: "invalid-json" });
6047
- return json(res, 500, {
6048
- error: "review-comment-unavailable",
6049
- message: "Review comments are unavailable."
6050
- });
6660
+ if (error instanceof ReviewCommentError) return {
6661
+ status: 409,
6662
+ value: {
6663
+ error: error.code,
6664
+ message: error.message
6665
+ }
6666
+ };
6667
+ if (error instanceof SyntaxError) return {
6668
+ status: 400,
6669
+ value: { error: "invalid-json" }
6670
+ };
6671
+ return {
6672
+ status: 500,
6673
+ value: {
6674
+ error: "review-comment-unavailable",
6675
+ message: "Review comments are unavailable."
6676
+ }
6677
+ };
6051
6678
  }
6052
6679
  }
6053
- }), "dsh-claude: review comment route");
6680
+ });
6054
6681
  }
6055
6682
  //#endregion
6056
6683
  //#region src/client-diagnostics-routes.ts
@@ -6058,17 +6685,6 @@ function registerReviewCommentRoute(ctx, store, ownsSession) {
6058
6685
  const MAX_DIAGNOSTIC_BYTES = 8192;
6059
6686
  const MAX_DETAIL_CHARS = 2e3;
6060
6687
  const MAX_KIND_CHARS = 60;
6061
- async function readBody(req) {
6062
- const chunks = [];
6063
- let size = 0;
6064
- for await (const chunk of req) {
6065
- const buffer = chunk;
6066
- size += buffer.length;
6067
- if (size > MAX_DIAGNOSTIC_BYTES) throw new Error("diagnostic too large");
6068
- chunks.push(buffer);
6069
- }
6070
- return JSON.parse(Buffer.concat(chunks).toString("utf8"));
6071
- }
6072
6688
  /** `POST <path>` with `{ kind, detail }`: write one renderer finding to the Host log.
6073
6689
  *
6074
6690
  * The renderer has no other way to speak. A Slot entry that throws is caught
@@ -6078,25 +6694,38 @@ async function readBody(req) {
6078
6694
  * Every finding here is data written by this package's own client half, but
6079
6695
  * it is still bounded and redacted like any other untrusted input. */
6080
6696
  function registerClaudeClientDiagnosticsRoute(ctx) {
6081
- ctx.effect(() => ctx.webServer.register({
6697
+ registerPluginRoute(ctx, {
6698
+ mode: "unary",
6082
6699
  kind: "exact",
6083
6700
  path: CLAUDE_CLIENT_DIAGNOSTICS_PATH,
6084
- handler: async (req, res) => {
6085
- if (req.method !== "POST") return json(res, 405, { error: "method not allowed" });
6086
- if (!trustedRequest(req)) return json(res, 403, { error: "forbidden" });
6701
+ methods: ["POST"],
6702
+ budget: "fast",
6703
+ handler: async (io) => {
6087
6704
  try {
6088
- const body = await readBody(req);
6705
+ const body = await io.body(MAX_DIAGNOSTIC_BYTES);
6089
6706
  const kind = typeof body?.kind === "string" ? body.kind.slice(0, MAX_KIND_CHARS) : "unknown";
6090
6707
  const detail = typeof body?.detail === "string" ? body.detail : "";
6091
- if (detail === "") return json(res, 400, { error: "invalid-request" });
6708
+ if (detail === "") return {
6709
+ status: 400,
6710
+ value: { error: "invalid-request" }
6711
+ };
6092
6712
  ctx.logger.warn(`dsh-claude client [${kind}]: ${redactText(detail, MAX_DETAIL_CHARS)}`);
6093
- return json(res, 200, { ok: true });
6713
+ return {
6714
+ status: 200,
6715
+ value: { ok: true }
6716
+ };
6094
6717
  } catch (error) {
6095
- if (error instanceof SyntaxError) return json(res, 400, { error: "invalid-json" });
6096
- return json(res, 400, { error: "invalid-request" });
6718
+ if (error instanceof SyntaxError) return {
6719
+ status: 400,
6720
+ value: { error: "invalid-json" }
6721
+ };
6722
+ return {
6723
+ status: 400,
6724
+ value: { error: "invalid-request" }
6725
+ };
6097
6726
  }
6098
6727
  }
6099
- }), "dsh-claude: client diagnostics route");
6728
+ });
6100
6729
  }
6101
6730
  //#endregion
6102
6731
  //#region src/rewind-routes.ts
@@ -6105,55 +6734,78 @@ const MAX_SESSION_ID_CHARS = 1024;
6105
6734
  function record(value) {
6106
6735
  return value !== null && typeof value === "object" && !Array.isArray(value) ? value : void 0;
6107
6736
  }
6108
- async function readJson(req) {
6109
- const chunks = [];
6110
- let size = 0;
6111
- for await (const chunk of req) {
6112
- const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
6113
- size += buffer.length;
6114
- if (size > MAX_BODY_BYTES) return void 0;
6115
- chunks.push(buffer);
6737
+ /** An oversized body fails the same field checks as a missing one; only
6738
+ * malformed JSON is worth reporting separately. */
6739
+ async function readJson(io) {
6740
+ try {
6741
+ return record(await io.body(MAX_BODY_BYTES));
6742
+ } catch (error) {
6743
+ if (error instanceof SyntaxError) throw error;
6744
+ return;
6116
6745
  }
6117
- return record(JSON.parse(Buffer.concat(chunks).toString("utf8")));
6118
6746
  }
6119
6747
  /** `POST <path>` with `{ sessionId, seq }`: hide that surface event and every
6120
6748
  * later one, and arm Claude to resume before the turn it opened. */
6121
6749
  function registerClaudeRewindRoute(ctx, sidecar, access) {
6122
- ctx.effect(() => ctx.webServer.register({
6750
+ registerPluginRoute(ctx, {
6751
+ mode: "unary",
6123
6752
  kind: "exact",
6124
6753
  path: CLAUDE_REWIND_PATH,
6125
- handler: async (req, res) => {
6126
- if (req.method !== "POST") return json(res, 405, { error: "method not allowed" });
6127
- if (!trustedRequest(req)) return json(res, 403, { error: "forbidden" });
6754
+ methods: ["POST"],
6755
+ budget: "git",
6756
+ handler: async (io) => {
6128
6757
  try {
6129
- const input = await readJson(req);
6758
+ const input = await readJson(io);
6130
6759
  const sessionId = input?.sessionId;
6131
6760
  const seq = input?.seq;
6132
- if (typeof sessionId !== "string" || sessionId.length === 0 || sessionId.length > MAX_SESSION_ID_CHARS || typeof seq !== "number" || !Number.isSafeInteger(seq) || seq < 0) return json(res, 400, { error: "invalid-request" });
6761
+ if (typeof sessionId !== "string" || sessionId.length === 0 || sessionId.length > MAX_SESSION_ID_CHARS || typeof seq !== "number" || !Number.isSafeInteger(seq) || seq < 0) return {
6762
+ status: 400,
6763
+ value: { error: "invalid-request" }
6764
+ };
6133
6765
  const events = access.eventsFor(sessionId);
6134
- if (events === void 0) return json(res, 409, { error: "session-unavailable" });
6135
- if (access.busy(sessionId)) return json(res, 409, { error: "session-busy" });
6766
+ if (events === void 0) return {
6767
+ status: 409,
6768
+ value: { error: "session-unavailable" }
6769
+ };
6770
+ if (access.busy(sessionId)) return {
6771
+ status: 409,
6772
+ value: { error: "session-busy" }
6773
+ };
6136
6774
  const planned = planRewind((await sidecar.read(sessionId)).rewind ?? EMPTY_REWIND_STATE, events, seq);
6137
- if (planned === void 0) return json(res, 409, { error: "seq-unavailable" });
6775
+ if (planned === void 0) return {
6776
+ status: 409,
6777
+ value: { error: "seq-unavailable" }
6778
+ };
6138
6779
  await sidecar.writeRewind(sessionId, planned);
6139
6780
  await access.reset(sessionId);
6140
- return json(res, 200, { ranges: planned.ranges });
6781
+ return {
6782
+ status: 200,
6783
+ value: { ranges: planned.ranges }
6784
+ };
6141
6785
  } catch (error) {
6142
- if (error instanceof SyntaxError) return json(res, 400, { error: "invalid-json" });
6143
- return json(res, 500, { error: "rewind-unavailable" });
6786
+ if (error instanceof SyntaxError) return {
6787
+ status: 400,
6788
+ value: { error: "invalid-json" }
6789
+ };
6790
+ return {
6791
+ status: 500,
6792
+ value: { error: "rewind-unavailable" }
6793
+ };
6144
6794
  }
6145
6795
  }
6146
- }), "dsh-claude: rewind route");
6796
+ });
6147
6797
  }
6148
6798
  //#endregion
6149
6799
  //#region src/update-routes.ts
6150
6800
  const PLUGIN_PACKAGE_NAME = "@norman-else/dsh-claude";
6151
- const UPDATE_TIMEOUT_MS = 3e4;
6152
- const CHECK_TIMEOUT_MS = 1e4;
6153
6801
  const MAX_MANIFEST_BYTES = 262144;
6154
6802
  const MAX_UPDATE_OUTPUT_BYTES = 32768;
6155
6803
  const SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/;
6156
6804
  const PROFILE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
6805
+ /** The route owns the deadline now. A direct caller with nothing to cancel
6806
+ * against gets a signal that never fires, rather than a second timeout
6807
+ * competing with the budget the route already declared. */
6808
+ const NEVER_ABORTS = new AbortController().signal;
6157
6809
  function safeMessage$1(error) {
6158
6810
  return redactText(error instanceof Error ? error.message : String(error), 500);
6159
6811
  }
@@ -6272,7 +6924,7 @@ async function packageContext(deps) {
6272
6924
  ...installation === void 0 ? {} : { installation }
6273
6925
  };
6274
6926
  }
6275
- async function checkPluginUpdate(deps = {}) {
6927
+ async function checkPluginUpdate(deps = {}, signal = NEVER_ABORTS) {
6276
6928
  try {
6277
6929
  const { version, installation } = await packageContext(deps);
6278
6930
  if (installation === void 0) return {
@@ -6299,7 +6951,7 @@ async function checkPluginUpdate(deps = {}) {
6299
6951
  restartRequired: false,
6300
6952
  message: "This installation source cannot be updated from the npm registry"
6301
6953
  };
6302
- const latest = await (deps.fetchLatest ?? registryLatest)(AbortSignal.timeout(CHECK_TIMEOUT_MS));
6954
+ const latest = await (deps.fetchLatest ?? registryLatest)(signal);
6303
6955
  const comparison = compareVersions(version, latest);
6304
6956
  return {
6305
6957
  currentVersion: version,
@@ -6325,10 +6977,10 @@ async function verifyInstalledVersion(installation, expectedVersion) {
6325
6977
  const installedManifest = await readManifest(join(installation.profileDir, "node_modules", ...PLUGIN_PACKAGE_NAME.split("/"), "package.json"));
6326
6978
  if (installedManifest.name !== "@norman-else/dsh-claude" || installedManifest.version !== expectedVersion) throw new Error("DSH plugin update completed without installing the requested version");
6327
6979
  }
6328
- async function updatePlugin(deps = {}) {
6980
+ async function updatePlugin(deps = {}, signal = NEVER_ABORTS) {
6329
6981
  const { version, installation } = await packageContext(deps);
6330
6982
  if (installation === void 0 || installation.source !== "registry") throw new Error("Plugin update is unavailable for this installation");
6331
- const latest = await (deps.fetchLatest ?? registryLatest)(AbortSignal.timeout(CHECK_TIMEOUT_MS));
6983
+ const latest = await (deps.fetchLatest ?? registryLatest)(signal);
6332
6984
  if (compareVersions(version, latest) >= 0) return {
6333
6985
  currentVersion: version,
6334
6986
  latestVersion: latest,
@@ -6340,7 +6992,6 @@ async function updatePlugin(deps = {}) {
6340
6992
  const resolveExecutable = deps.resolveExecutable;
6341
6993
  const spawn = deps.spawn;
6342
6994
  if (resolveExecutable === void 0 || spawn === void 0) throw new Error("DSH update runtime is unavailable");
6343
- const signal = AbortSignal.timeout(UPDATE_TIMEOUT_MS);
6344
6995
  const handle = spawn({
6345
6996
  argv: [
6346
6997
  await resolveExecutable("dsh", {}, signal),
@@ -6383,27 +7034,35 @@ function registerClaudeUpdateRoutes(ctx, runtime, deps = {}) {
6383
7034
  resolveExecutable: runtime.resolveExecutable.bind(runtime),
6384
7035
  spawn: runtime.spawn.bind(runtime)
6385
7036
  };
6386
- for (const route of [{
7037
+ const routes = [{
6387
7038
  path: CLAUDE_UPDATE_CHECK_PATH,
6388
7039
  method: "GET",
6389
- run: () => checkPluginUpdate(shared)
7040
+ run: (signal) => checkPluginUpdate(shared, signal)
6390
7041
  }, {
6391
7042
  path: CLAUDE_UPDATE_PATH,
6392
7043
  method: "POST",
6393
- run: () => updatePlugin(shared)
6394
- }]) ctx.effect(() => ctx.webServer.register({
7044
+ run: (signal) => updatePlugin(shared, signal)
7045
+ }];
7046
+ for (const route of routes) registerPluginRoute(ctx, {
7047
+ mode: "unary",
6395
7048
  kind: "exact",
6396
7049
  path: route.path,
6397
- handler: async (req, res) => {
6398
- if (req.method !== route.method) return json(res, 405, { error: "method not allowed" });
6399
- if (!trustedRequest(req)) return json(res, 403, { error: "forbidden" });
7050
+ methods: [route.method],
7051
+ budget: "remote",
7052
+ handler: async (io) => {
6400
7053
  try {
6401
- json(res, 200, await route.run());
7054
+ return {
7055
+ status: 200,
7056
+ value: await route.run(io.signal)
7057
+ };
6402
7058
  } catch (error) {
6403
- json(res, 500, { error: safeMessage$1(error) });
7059
+ return {
7060
+ status: 500,
7061
+ value: { error: safeMessage$1(error) }
7062
+ };
6404
7063
  }
6405
7064
  }
6406
- }), `dsh-claude: ${route.method} ${route.path}`);
7065
+ });
6407
7066
  }
6408
7067
  //#endregion
6409
7068
  //#region src/plan-usage-routes.ts
@@ -6420,22 +7079,32 @@ function safeMessage(error) {
6420
7079
  * throwaway probe process so a refresh never depends on, or disturbs, a live
6421
7080
  * Claude session. */
6422
7081
  function registerPlanUsageRoute(ctx, probe, now = Date.now) {
6423
- ctx.effect(() => ctx.webServer.register({
7082
+ registerPluginRoute(ctx, {
7083
+ mode: "unary",
6424
7084
  kind: "exact",
6425
7085
  path: CLAUDE_USAGE_PATH,
6426
- handler: async (req, res) => {
6427
- if (req.method !== "GET" && req.method !== "POST") return json(res, 405, { error: "method not allowed" });
6428
- if (!trustedRequest(req)) return json(res, 403, { error: "forbidden" });
6429
- if (req.method === "GET") return json(res, 200, latestPlanUsage() ?? EMPTY);
7086
+ methods: ["GET", "POST"],
7087
+ budget: "git",
7088
+ handler: async (io) => {
7089
+ if (io.method === "GET") return {
7090
+ status: 200,
7091
+ value: latestPlanUsage() ?? EMPTY
7092
+ };
6430
7093
  try {
6431
7094
  const report = await probe(now());
6432
7095
  recordPlanUsage(report);
6433
- json(res, 200, report);
7096
+ return {
7097
+ status: 200,
7098
+ value: report
7099
+ };
6434
7100
  } catch (error) {
6435
- json(res, 500, { error: safeMessage(error) });
7101
+ return {
7102
+ status: 500,
7103
+ value: { error: safeMessage(error) }
7104
+ };
6436
7105
  }
6437
7106
  }
6438
- }), "dsh-claude: plan usage route");
7107
+ });
6439
7108
  }
6440
7109
  //#endregion
6441
7110
  //#region src/global-settings.ts
@@ -6564,6 +7233,31 @@ const WORKTREE_BRANCH_PREFIX = {
6564
7233
  else document.worktreeBranchPrefix = value;
6565
7234
  }
6566
7235
  };
7236
+ /** Which renderer draws Claude's visible output. Plugin settings, not Claude's:
7237
+ * the CLI has no opinion about how DSH paints a turn. The option labels stay
7238
+ * machine-readable ids; the Client translates the two known values. */
7239
+ const RENDERER = {
7240
+ key: "renderer",
7241
+ kind: "select",
7242
+ document: "plugin",
7243
+ effect: "next-turn",
7244
+ async options() {
7245
+ return CLAUDE_RENDER_MODES.map((value) => ({
7246
+ value,
7247
+ label: value,
7248
+ source: "built-in"
7249
+ }));
7250
+ },
7251
+ read(document) {
7252
+ const value = document.renderer;
7253
+ return isClaudeRenderMode(value) ? value : DEFAULT_CLAUDE_RENDER_MODE;
7254
+ },
7255
+ apply(document, value) {
7256
+ if (!isClaudeRenderMode(value)) throw new Error("Invalid value for global setting renderer");
7257
+ if (value === "plugin") delete document.renderer;
7258
+ else document.renderer = value;
7259
+ }
7260
+ };
6567
7261
  function isBoundedInteger(value, min, max) {
6568
7262
  return typeof value === "number" && Number.isInteger(value) && value >= min && value <= max;
6569
7263
  }
@@ -6587,6 +7281,7 @@ function integerSetting(key, min, max, defaultFor) {
6587
7281
  }
6588
7282
  const DESCRIPTORS = [
6589
7283
  OUTPUT_STYLE,
7284
+ RENDERER,
6590
7285
  WORKTREE_BRANCH_PREFIX,
6591
7286
  integerSetting("maxProcesses", 1, MAX_PROCESSES_LIMIT, (limits) => limits.maxProcesses),
6592
7287
  integerSetting("idleTimeoutMinutes", 1, MAX_IDLE_TIMEOUT_MINUTES, (limits) => Math.max(1, Math.round(limits.idleTimeoutMs / 6e4)))
@@ -6648,6 +7343,17 @@ async function readSupervisorLimitOverrides(deps = {}) {
6648
7343
  ...isBoundedInteger(idleTimeoutMinutes, 1, MAX_IDLE_TIMEOUT_MINUTES) ? { idleTimeoutMs: idleTimeoutMinutes * 6e4 } : {}
6649
7344
  };
6650
7345
  }
7346
+ /** The renderer the Host should produce output for. A missing, unreadable, or
7347
+ * malformed plugin settings file keeps today's plugin-owned transcript. */
7348
+ async function readRenderMode(deps = {}) {
7349
+ let document;
7350
+ try {
7351
+ document = await readDocument(pathsFor(deps).pluginSettingsFile);
7352
+ } catch {
7353
+ return DEFAULT_CLAUDE_RENDER_MODE;
7354
+ }
7355
+ return isClaudeRenderMode(document.renderer) ? document.renderer : DEFAULT_CLAUDE_RENDER_MODE;
7356
+ }
6651
7357
  async function readWorktreeBranchPrefix(deps = {}) {
6652
7358
  const paths = pathsFor(deps);
6653
7359
  return WORKTREE_BRANCH_PREFIX.read(await readDocument(paths.pluginSettingsFile));
@@ -6693,37 +7399,34 @@ function updateGlobalSettings(changes, deps = {}) {
6693
7399
  pendingWrite = operation;
6694
7400
  return operation;
6695
7401
  }
6696
- async function requestJson(req) {
6697
- const declared = Number(req.headers["content-length"] ?? 0);
6698
- if (!Number.isFinite(declared) || declared < 0 || declared > MAX_REQUEST_BYTES) throw new Error("Request body is too large");
6699
- const chunks = [];
6700
- let bytes = 0;
6701
- for await (const chunk of req) {
6702
- const data = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
6703
- bytes += data.byteLength;
6704
- if (bytes > MAX_REQUEST_BYTES) throw new Error("Request body is too large");
6705
- chunks.push(data);
6706
- }
6707
- const body = object(JSON.parse(Buffer.concat(chunks).toString("utf8")));
7402
+ async function requestJson(io) {
7403
+ const body = object(await io.body(MAX_REQUEST_BYTES));
6708
7404
  if (body === void 0 || Object.keys(body).some((key) => key !== "changes")) throw new Error("Invalid global settings request");
6709
7405
  return body.changes;
6710
7406
  }
6711
7407
  function registerClaudeGlobalSettingsRoute(ctx, deps = {}) {
6712
- ctx.effect(() => ctx.webServer.register({
7408
+ registerPluginRoute(ctx, {
7409
+ mode: "unary",
6713
7410
  kind: "exact",
6714
7411
  path: CLAUDE_GLOBAL_SETTINGS_PATH,
6715
- handler: async (req, res) => {
6716
- if (req.method !== "GET" && req.method !== "PATCH") return json(res, 405, { error: "method not allowed" });
6717
- if (!trustedRequest(req)) return json(res, 403, { error: "forbidden" });
7412
+ methods: ["GET", "PATCH"],
7413
+ budget: "fast",
7414
+ handler: async (io) => {
6718
7415
  try {
6719
- const result = req.method === "GET" ? await readGlobalSettings(deps) : await updateGlobalSettings(await requestJson(req), deps);
6720
- if (req.method === "PATCH") await deps.onUpdated?.();
6721
- json(res, 200, result);
7416
+ const result = io.method === "GET" ? await readGlobalSettings(deps) : await updateGlobalSettings(await requestJson(io), deps);
7417
+ if (io.method === "PATCH") await deps.onUpdated?.();
7418
+ return {
7419
+ status: 200,
7420
+ value: result
7421
+ };
6722
7422
  } catch (error) {
6723
- json(res, 400, { error: error instanceof Error ? error.message : "Invalid global settings request" });
7423
+ return {
7424
+ status: 400,
7425
+ value: { error: error instanceof Error ? error.message : "Invalid global settings request" }
7426
+ };
6724
7427
  }
6725
7428
  }
6726
- }), "dsh-claude: global settings");
7429
+ });
6727
7430
  }
6728
7431
  //#endregion
6729
7432
  //#region src/index.ts
@@ -6862,14 +7565,16 @@ async function apply(ctx, config) {
6862
7565
  const supervisorConfig = {
6863
7566
  executablePath: "",
6864
7567
  defaultModel: config.model ?? "default",
7568
+ renderMode: DEFAULT_CLAUDE_RENDER_MODE,
6865
7569
  ...defaultLimits
6866
7570
  };
6867
- const applyLimitOverrides = async () => {
7571
+ const applySettingsOverrides = async () => {
6868
7572
  const overrides = await readSupervisorLimitOverrides();
6869
7573
  supervisorConfig.idleTimeoutMs = overrides.idleTimeoutMs ?? defaultLimits.idleTimeoutMs;
6870
7574
  supervisorConfig.maxProcesses = overrides.maxProcesses ?? defaultLimits.maxProcesses;
7575
+ supervisorConfig.renderMode = await readRenderMode();
6871
7576
  };
6872
- await applyLimitOverrides();
7577
+ await applySettingsOverrides();
6873
7578
  const sidecar = new ClaudeSidecarRepository();
6874
7579
  const repositoryStatus = new RepositoryStatusService(ctx.subprocess);
6875
7580
  const repositorySetup = new RepositorySetupService(ctx.subprocess, { branchPrefix: () => readWorktreeBranchPrefix() });
@@ -6886,7 +7591,7 @@ async function apply(ctx, config) {
6886
7591
  let resolutionError;
6887
7592
  try {
6888
7593
  supervisorConfig.executablePath = (await resolveClaudeExecutable(ctx.subprocess, config.executablePath === void 0 || config.executablePath.length === 0 ? void 0 : config.executablePath)).path;
6889
- ctx.llm.registerAdapter([...CLAUDE_CODE_PROVIDER_IDS], createClaudeCodeAdapter(supervisor, ctx.agents, ctx.attachments, (agent) => ctx.agentPresets.composedPreset(agent.ctx), (sessionId) => reviewComments.drain(sessionId)));
7594
+ ctx.llm.registerAdapter([...CLAUDE_CODE_PROVIDER_IDS], createClaudeCodeAdapter(supervisor, ctx.agents, ctx.attachments, (agent) => ctx.agentPresets.composedPreset(agent.ctx), (sessionId) => reviewComments.drain(sessionId), () => supervisorConfig.renderMode));
6890
7595
  ctx.effect(() => {
6891
7596
  const mounted = /* @__PURE__ */ new Map();
6892
7597
  const pending = /* @__PURE__ */ new Set();
@@ -6975,7 +7680,7 @@ async function apply(ctx, config) {
6975
7680
  registerClaudeUpdateRoutes(webCtx, webCtx.subprocess, { ...typeof desktopActions?.requestRestart === "function" ? { requestRestart: desktopActions.requestRestart.bind(desktopActions) } : {} });
6976
7681
  registerClaudeGlobalSettingsRoute(webCtx, {
6977
7682
  defaultLimits,
6978
- onUpdated: applyLimitOverrides
7683
+ onUpdated: applySettingsOverrides
6979
7684
  });
6980
7685
  registerRepositorySetupRoute(webCtx, repositorySetup);
6981
7686
  registerRepositoryStatusRoute(webCtx, repositoryStatus);