@norman-else/dsh-claude 0.1.34 → 0.1.36

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-BY4846KK.mjs";
1
+ import { A as CLAUDE_REWIND_PATH, C as CLAUDE_RENDER_MODES, D as CLAUDE_REPOSITORY_SETUP_PATH, E as CLAUDE_REPOSITORY_FILE_PATH, F as DEFAULT_CLAUDE_RENDER_MODE, L as TASK_TOOL_NAMES, M as CLAUDE_UPDATE_PATH, N as CLAUDE_USAGE_PATH, O as CLAUDE_REPOSITORY_STATUS_PATH, P as DEFAULT_CLAUDE_PROSE_MODE, R as isClaudeProseMode, S as CLAUDE_PROSE_MODES, T as CLAUDE_REPOSITORY_FEEDBACK_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_CHECK_PATH, k as CLAUDE_REVIEW_COMMENT_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_ACTION_PATH, x as CLAUDE_PROJECTION_PATH, y as CLAUDE_GLOBAL_SETTINGS_PATH, z as isClaudeRenderMode } from "./events-OhBoFNKO.mjs";
2
+ import { a as projectClaudeCommands, i as CLAUDE_COMMANDS_SERVICE, r as dynamicPresenterDefinition, t as CLAUDE_PRESENTER_NAMES } from "./presenters-BBoM1Ju1.mjs";
3
+ import { a as resolveClaudeExecutable, n as ensureManagedPreset, o as runClaudeDoctor, t as ManagedPresetConflictError } from "./preset-installer-loenwnLS.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();
@@ -1581,9 +1610,11 @@ var ClaudeSupervisor = class {
1581
1610
  transcriptTextOrdinal: void 0,
1582
1611
  thinking: "",
1583
1612
  requestUsage: void 0,
1613
+ startedAt: Date.now(),
1614
+ firstOutputAt: void 0,
1584
1615
  aborted: false,
1585
1616
  deniedToolUseIds: /* @__PURE__ */ new Set(),
1586
- callNames: /* @__PURE__ */ new Map(),
1617
+ openCalls: /* @__PURE__ */ new Map(),
1587
1618
  ...request.signal === void 0 ? {} : { signal: request.signal }
1588
1619
  };
1589
1620
  entry.active = active;
@@ -1869,6 +1900,7 @@ var ClaudeSupervisor = class {
1869
1900
  active.text += message.text;
1870
1901
  active.transcriptText += message.text;
1871
1902
  await this.#upsertTranscriptText(active);
1903
+ active.firstOutputAt ??= Date.now();
1872
1904
  active.output.push({
1873
1905
  type: "text-delta",
1874
1906
  text: message.text
@@ -1881,6 +1913,7 @@ var ClaudeSupervisor = class {
1881
1913
  active.text += message.text;
1882
1914
  active.transcriptText += message.text;
1883
1915
  await this.#upsertTranscriptText(active);
1916
+ active.firstOutputAt ??= Date.now();
1884
1917
  active.output.push({
1885
1918
  type: "text-delta",
1886
1919
  text: message.text
@@ -1900,6 +1933,10 @@ var ClaudeSupervisor = class {
1900
1933
  title: "Claude thinking",
1901
1934
  summary: message.text
1902
1935
  });
1936
+ if (this.#nativeRendering()) active.output.push({
1937
+ type: "thinking",
1938
+ text: message.text
1939
+ });
1903
1940
  return;
1904
1941
  case "tool-call":
1905
1942
  if (message.parentToolUseId === void 0) this.#closeTranscriptTextSegment(active);
@@ -1913,9 +1950,16 @@ var ClaudeSupervisor = class {
1913
1950
  summary: message.parentToolUseId === void 0 ? rootCallSummary(message.toolName, message.input) : `Subagent called ${message.toolName}`,
1914
1951
  detail: message.input
1915
1952
  });
1916
- if (message.parentToolUseId === void 0) active.callNames.set(message.toolUseId, message.toolName);
1953
+ if (message.parentToolUseId === void 0) {
1954
+ active.openCalls.set(message.toolUseId, message.toolName);
1955
+ if (this.#nativeRendering()) {
1956
+ this.#ensureDynamicPresenter(active.agent, message.toolName);
1957
+ await this.#appendNativeToolCall(active, message);
1958
+ }
1959
+ }
1917
1960
  return;
1918
1961
  case "tool-result":
1962
+ active.openCalls.delete(message.toolUseId);
1919
1963
  await this.#appendActivity(active, {
1920
1964
  kind: message.parentToolUseId === void 0 ? "tool-result" : "subagent",
1921
1965
  phase: message.isError ? "failed" : "completed",
@@ -1925,6 +1969,7 @@ var ClaudeSupervisor = class {
1925
1969
  detail: message.output,
1926
1970
  isError: message.isError
1927
1971
  });
1972
+ if (message.parentToolUseId === void 0 && this.#nativeRendering()) await this.#appendNativeToolResult(active, message);
1928
1973
  return;
1929
1974
  case "subagent":
1930
1975
  await this.#appendActivity(active, {
@@ -1974,9 +2019,77 @@ var ClaudeSupervisor = class {
1974
2019
  title: message.toolName,
1975
2020
  summary: message.summary
1976
2021
  });
2022
+ if (this.#nativeRendering()) await this.#appendNativeToolResult(active, {
2023
+ kind: "tool-result",
2024
+ toolUseId: message.toolUseId,
2025
+ output: message.summary,
2026
+ isError: true
2027
+ });
1977
2028
  return;
1978
2029
  }
1979
2030
  }
2031
+ /** The turn's accounting with its wall clock attached. The transcript draws
2032
+ * this line itself under the plugin renderer: activities carry no
2033
+ * timestamps, so a duration it did not measure is a duration nobody has. */
2034
+ #timedUsage(active, usage) {
2035
+ const now = Date.now();
2036
+ return {
2037
+ ...usage,
2038
+ durationMs: Math.max(0, now - active.startedAt),
2039
+ ...active.firstOutputAt === void 0 ? {} : { ttftMs: Math.max(0, active.firstOutputAt - active.startedAt) }
2040
+ };
2041
+ }
2042
+ #nativeRendering() {
2043
+ return (this.#config.renderMode ?? "plugin") === "native";
2044
+ }
2045
+ /** Register one presenter-only mirror for a tool name the static preset
2046
+ * registry does not cover (MCP tools, newly added built-ins). Runs in the
2047
+ * agent scope so the mirror is visible only to this preset's sessions and
2048
+ * unwinds with the agent; failure keeps the generic card, never the turn. */
2049
+ #ensureDynamicPresenter(agent, name) {
2050
+ if (CLAUDE_PRESENTER_NAMES.has(name)) return;
2051
+ let known = this.#dynamicPresenterNames.get(agent);
2052
+ if (known === void 0) {
2053
+ known = /* @__PURE__ */ new Set();
2054
+ this.#dynamicPresenterNames.set(agent, known);
2055
+ }
2056
+ if (known.has(name)) return;
2057
+ try {
2058
+ agent.ctx.tools.register(dynamicPresenterDefinition(name));
2059
+ known.add(name);
2060
+ } catch {}
2061
+ }
2062
+ /** Mirror one root Claude tool call into the durable native tool channel so
2063
+ * the host's tool presentation renders it exactly like a DSH-executed call.
2064
+ * Presentation duplication is best-effort and never unsettles the turn. */
2065
+ async #appendNativeToolCall(active, message) {
2066
+ try {
2067
+ await active.agent.session.append("tool/call", {
2068
+ turn: active.cursor.turn,
2069
+ step: active.cursor.step,
2070
+ callId: CallId(message.toolUseId),
2071
+ name: message.toolName,
2072
+ arguments: safeDetail(message.input) ?? "{}"
2073
+ });
2074
+ } catch {}
2075
+ }
2076
+ async #appendNativeToolResult(active, message) {
2077
+ const text = typeof message.output === "string" ? redactText(message.output) : safeDetail(message.output) ?? "";
2078
+ try {
2079
+ await active.agent.session.append("tool/result", {
2080
+ turn: active.cursor.turn,
2081
+ step: active.cursor.step,
2082
+ message: createToolResultMessage({
2083
+ callId: CallId(message.toolUseId),
2084
+ content: [{
2085
+ type: "text",
2086
+ text
2087
+ }],
2088
+ isError: message.isError
2089
+ })
2090
+ }, { surfaceOp: "append" });
2091
+ } catch {}
2092
+ }
1980
2093
  /** Merge one task lifecycle message into the session's task board. */
1981
2094
  async #trackTask(entry, message, taskId, originTurn) {
1982
2095
  const previous = entry.tasks.get(taskId);
@@ -2117,7 +2230,7 @@ var ClaudeSupervisor = class {
2117
2230
  phase: "completed",
2118
2231
  title: "Claude usage",
2119
2232
  summary: usageSummary(result.usage),
2120
- usage: result.usage
2233
+ usage: this.#timedUsage(active, result.usage)
2121
2234
  });
2122
2235
  active.output.push({
2123
2236
  type: "usage",
@@ -2148,6 +2261,7 @@ var ClaudeSupervisor = class {
2148
2261
  if (active.aborted) {
2149
2262
  await this.#upsertTranscriptText(active);
2150
2263
  await this.#flushTranscript(active);
2264
+ await this.#settleOpenCalls(active, "Cancelled with the turn");
2151
2265
  await this.#appendSafely(active, {
2152
2266
  kind: "status",
2153
2267
  phase: "failed",
@@ -2157,6 +2271,7 @@ var ClaudeSupervisor = class {
2157
2271
  entry.state = "idle";
2158
2272
  entry.lastUsedAt = Date.now();
2159
2273
  await this.#recordChainAnchor(entry, active);
2274
+ this.#checkpointProjection(entry);
2160
2275
  this.#armIdleTimer(entry);
2161
2276
  return;
2162
2277
  }
@@ -2166,7 +2281,7 @@ var ClaudeSupervisor = class {
2166
2281
  phase: "completed",
2167
2282
  title: "Claude usage",
2168
2283
  summary: usageSummary(result.usage),
2169
- usage: result.usage
2284
+ usage: this.#timedUsage(active, result.usage)
2170
2285
  });
2171
2286
  active.output.push({
2172
2287
  type: "usage",
@@ -2240,8 +2355,20 @@ var ClaudeSupervisor = class {
2240
2355
  entry.lastUsedAt = Date.now();
2241
2356
  await this.#recordChainAnchor(entry, active);
2242
2357
  await this.#learnContextWindow(entry);
2358
+ this.#checkpointProjection(entry);
2243
2359
  this.#armIdleTimer(entry);
2244
2360
  }
2361
+ /** Tell every reader where this session's delta stream ended.
2362
+ *
2363
+ * A reader that lost the turn's last delta has nothing later to reveal the
2364
+ * hole, and a settled turn produces nothing further -- so a finished tool
2365
+ * group would keep pulsing until the session was reopened by hand. Last
2366
+ * line of the turn, best effort: presentation must never unsettle it. */
2367
+ #checkpointProjection(entry) {
2368
+ try {
2369
+ this.#sidecar.checkpoint(entry.sessionId);
2370
+ } catch {}
2371
+ }
2245
2372
  /** Pin where Claude's chain ended for the DSH turn that just settled, so a
2246
2373
  * later rewind of the following turn can fork exactly here. Best effort:
2247
2374
  * a missing anchor only makes a rewind fall back to an earlier turn. */
@@ -2259,6 +2386,7 @@ var ClaudeSupervisor = class {
2259
2386
  try {
2260
2387
  this.#sidecar.appendTranscriptText(active.agent.id, {
2261
2388
  text: active.transcriptText,
2389
+ ...this.#nativeRendering() ? { renderer: "native" } : {},
2262
2390
  turn: active.cursor.turn,
2263
2391
  step: active.cursor.step,
2264
2392
  ordinal
@@ -2277,6 +2405,7 @@ var ClaudeSupervisor = class {
2277
2405
  const ordinal = active.cursor.nextOrdinal++;
2278
2406
  await this.#sidecar.appendActivity(active.agent.id, {
2279
2407
  ...activity,
2408
+ ...this.#nativeRendering() ? { renderer: "native" } : {},
2280
2409
  turn: active.cursor.turn,
2281
2410
  step: active.cursor.step,
2282
2411
  ordinal
@@ -2296,6 +2425,33 @@ var ClaudeSupervisor = class {
2296
2425
  this.#interruptions.set(entry.sessionId, interruption);
2297
2426
  return interruption;
2298
2427
  }
2428
+ /** Close out the root tool calls a turn is ending without answers for.
2429
+ *
2430
+ * A tool result is the only thing that ever settles a call, and a turn that
2431
+ * is cancelled or disconnected produces none: the transcript would keep
2432
+ * drawing those calls as running for as long as the session lives, and no
2433
+ * later event would ever correct it. Written as the failures they are. */
2434
+ async #settleOpenCalls(active, summary) {
2435
+ const open = [...active.openCalls];
2436
+ active.openCalls.clear();
2437
+ for (const [toolUseId, toolName] of open) {
2438
+ await this.#appendSafely(active, {
2439
+ kind: "tool-result",
2440
+ phase: "failed",
2441
+ toolUseId,
2442
+ toolName,
2443
+ title: "Tool cancelled",
2444
+ summary,
2445
+ isError: true
2446
+ });
2447
+ if (this.#nativeRendering()) await this.#appendNativeToolResult(active, {
2448
+ kind: "tool-result",
2449
+ toolUseId,
2450
+ output: summary,
2451
+ isError: true
2452
+ });
2453
+ }
2454
+ }
2299
2455
  async #interrupt(entry) {
2300
2456
  const active = entry.active;
2301
2457
  if (active === void 0 || entry.state === "interrupting") return;
@@ -2309,6 +2465,7 @@ var ClaudeSupervisor = class {
2309
2465
  } catch (error) {
2310
2466
  interruptError = error;
2311
2467
  }
2468
+ await this.#settleOpenCalls(active, "Cancelled with the turn").catch(() => void 0);
2312
2469
  try {
2313
2470
  await this.#appendActivity(active, {
2314
2471
  kind: "status",
@@ -2330,6 +2487,7 @@ var ClaudeSupervisor = class {
2330
2487
  const unknown = active.sawActivity;
2331
2488
  entry.state = unknown ? "outcome-unknown" : "disconnected";
2332
2489
  const failure = unknown ? new ClaudeOutcomeUnknownError(stderr === void 0 || stderr.length === 0 ? void 0 : `Claude Code exited after activity; outcome unknown. ${stderr}`) : new Error(stderr === void 0 || stderr.length === 0 ? errorSummary(error) : stderr);
2490
+ await this.#settleOpenCalls(active, "Claude Code stopped before the tool answered").catch(() => void 0);
2333
2491
  await this.#appendSafely(active, {
2334
2492
  kind: "error",
2335
2493
  phase: "failed",
@@ -2640,13 +2798,15 @@ var ClaudeCodeAdapter = class extends LlmAdapter {
2640
2798
  #attachments;
2641
2799
  #presetIdFor;
2642
2800
  #drainReviewComments;
2643
- constructor(supervisor, agents, attachments, presetIdFor, drainReviewComments = () => []) {
2801
+ #renderMode;
2802
+ constructor(supervisor, agents, attachments, presetIdFor, drainReviewComments = () => [], renderMode = () => DEFAULT_CLAUDE_RENDER_MODE) {
2644
2803
  super();
2645
2804
  this.#supervisor = supervisor;
2646
2805
  this.#agents = agents;
2647
2806
  this.#attachments = attachments;
2648
2807
  this.#presetIdFor = presetIdFor;
2649
2808
  this.#drainReviewComments = drainReviewComments;
2809
+ this.#renderMode = renderMode;
2650
2810
  }
2651
2811
  providerInfo(provider) {
2652
2812
  return {
@@ -2718,15 +2878,74 @@ var ClaudeCodeAdapter = class extends LlmAdapter {
2718
2878
  ...thinkingMode === void 0 ? {} : { thinkingMode },
2719
2879
  ...options.signal === void 0 ? {} : { signal: options.signal }
2720
2880
  });
2881
+ const native = this.#renderMode() === "native";
2721
2882
  let pendingUsage;
2722
2883
  let completed = false;
2884
+ let blockIndex = 0;
2885
+ let text = "";
2886
+ /** Settle the buffered prose as one text block. Each Claude result is one
2887
+ * block so tool activity stays ahead of the prose it explains and a
2888
+ * background-task report can follow in a block of its own. */
2889
+ function* flushText() {
2890
+ if (text.length === 0) return;
2891
+ const settled = text;
2892
+ text = "";
2893
+ yield {
2894
+ type: "block-start",
2895
+ index: blockIndex,
2896
+ blockType: "text"
2897
+ };
2898
+ yield {
2899
+ type: "text-delta",
2900
+ index: blockIndex,
2901
+ text: settled
2902
+ };
2903
+ yield {
2904
+ type: "block-end",
2905
+ index: blockIndex,
2906
+ block: {
2907
+ type: "text",
2908
+ text: settled
2909
+ }
2910
+ };
2911
+ blockIndex += 1;
2912
+ }
2723
2913
  try {
2724
2914
  for await (const event of events) {
2725
- if (event.type === "text-delta" || event.type === "segment-complete") continue;
2726
2915
  if (event.type === "usage") {
2727
2916
  pendingUsage = tokenUsage(event.usage);
2728
2917
  continue;
2729
2918
  }
2919
+ if (event.type === "text-delta") {
2920
+ if (native) text += event.text;
2921
+ continue;
2922
+ }
2923
+ if (event.type === "thinking") {
2924
+ if (!native) continue;
2925
+ yield* flushText();
2926
+ yield {
2927
+ type: "block-start",
2928
+ index: blockIndex,
2929
+ blockType: "reasoning"
2930
+ };
2931
+ yield {
2932
+ type: "reasoning-delta",
2933
+ index: blockIndex,
2934
+ text: event.text
2935
+ };
2936
+ yield {
2937
+ type: "block-end",
2938
+ index: blockIndex,
2939
+ block: {
2940
+ type: "reasoning",
2941
+ text: event.text
2942
+ }
2943
+ };
2944
+ blockIndex += 1;
2945
+ continue;
2946
+ }
2947
+ yield* flushText();
2948
+ if (event.type === "segment-complete") continue;
2730
2949
  completed = true;
2731
2950
  if (pendingUsage !== void 0) yield {
2732
2951
  type: "usage",
@@ -2740,6 +2959,7 @@ var ClaudeCodeAdapter = class extends LlmAdapter {
2740
2959
  } catch (error) {
2741
2960
  if (error.name === "AbortError") {
2742
2961
  completed = true;
2962
+ yield* flushText();
2743
2963
  yield {
2744
2964
  type: "finish",
2745
2965
  reason: {
@@ -2757,10 +2977,41 @@ var ClaudeCodeAdapter = class extends LlmAdapter {
2757
2977
  if (!completed) throw new Error("dsh-claude: Claude turn stream ended without a result");
2758
2978
  }
2759
2979
  };
2760
- function createClaudeCodeAdapter(supervisor, agents, attachments, presetIdFor, drainReviewComments = () => []) {
2761
- return new ClaudeCodeAdapter(supervisor, agents, attachments, presetIdFor, drainReviewComments);
2980
+ function createClaudeCodeAdapter(supervisor, agents, attachments, presetIdFor, drainReviewComments = () => [], renderMode = () => DEFAULT_CLAUDE_RENDER_MODE) {
2981
+ return new ClaudeCodeAdapter(supervisor, agents, attachments, presetIdFor, drainReviewComments, renderMode);
2762
2982
  }
2763
2983
  //#endregion
2984
+ //#region src/plugin-budget.ts
2985
+ /**
2986
+ * The plugin's connection budget, in one table.
2987
+ *
2988
+ * A browser opens a small fixed number of connections to one origin — six for
2989
+ * HTTP/1.1 in Chromium — and shares them with the Host's own traffic. Every
2990
+ * response this plugin holds open costs one of them for its lifetime, and a
2991
+ * request that cannot get one waits in the browser's queue where no server-side
2992
+ * deadline can reach it. When the pool is exhausted the panels that would
2993
+ * *diagnose* the problem are the first thing to stop answering, which is how
2994
+ * this failure has always presented: four settings cards timing out at once
2995
+ * against a Host that is demonstrably healthy.
2996
+ *
2997
+ * So the budget is a fixed constant rather than a function of how much work is
2998
+ * in flight. Steady state is one connection (the multiplexed projection
2999
+ * carrier); the peak is `PLUGIN_GLOBAL_PERMITS`, whatever the session count.
3000
+ *
3001
+ * Both halves read this file, which is the point: a route declares a budget
3002
+ * class and the client derives its wait from the same entry, so a server
3003
+ * deadline can never be quietly longer than the client's patience.
3004
+ */
3005
+ /** Server-side budget classes. A route declares a class, never a number. */
3006
+ const ROUTE_BUDGET_MS = {
3007
+ /** Answers from memory or a single bounded probe. */
3008
+ fast: 5e3,
3009
+ /** Chains local Git work. */
3010
+ git: 45e3,
3011
+ /** Reaches the network: remote Git, `gh`, the npm registry. */
3012
+ remote: 15e4
3013
+ };
3014
+ //#endregion
2764
3015
  //#region src/http.ts
2765
3016
  /** Accept only loopback, same-origin browser requests to plugin-private routes. */
2766
3017
  function trustedRequest(req) {
@@ -2789,6 +3040,147 @@ function json(res, status, value) {
2789
3040
  });
2790
3041
  res.end(JSON.stringify(value));
2791
3042
  }
3043
+ const ROUTE_TIMEOUT_CODE = "DSH_CLAUDE_ROUTE";
3044
+ const DEFAULT_BODY_BYTES = 1048576;
3045
+ /** Bounds live streaming responses per path, so a teardown bug cannot become a
3046
+ * permanent connection leak. Registration is per path, not global, because a
3047
+ * wedged projection stream must not evict an in-flight repository setup. */
3048
+ var StreamRegistry = class {
3049
+ #open = /* @__PURE__ */ new Map();
3050
+ admit(path, key, max, close) {
3051
+ let live = this.#open.get(path);
3052
+ if (live === void 0) {
3053
+ live = /* @__PURE__ */ new Map();
3054
+ this.#open.set(path, live);
3055
+ }
3056
+ live.get(key)?.();
3057
+ live.set(key, close);
3058
+ while (live.size > max) {
3059
+ const oldest = live.keys().next();
3060
+ if (oldest.done === true) break;
3061
+ const evict = live.get(oldest.value);
3062
+ live.delete(oldest.value);
3063
+ evict?.();
3064
+ }
3065
+ return () => {
3066
+ const current = this.#open.get(path);
3067
+ if (current?.get(key) === close) current.delete(key);
3068
+ };
3069
+ }
3070
+ };
3071
+ const streams = new StreamRegistry();
3072
+ function isPluginMethod(value) {
3073
+ return value === "GET" || value === "POST" || value === "PATCH" || value === "DELETE";
3074
+ }
3075
+ /** Distinguishable so the wrapper can answer 413 rather than folding an
3076
+ * oversized body into whatever generic failure the handler reports. */
3077
+ var PluginBodyTooLargeError = class extends Error {
3078
+ constructor() {
3079
+ super("Request body is too large");
3080
+ this.name = "PluginBodyTooLargeError";
3081
+ }
3082
+ };
3083
+ async function readBody(req, maxBytes) {
3084
+ const declared = Number(req.headers["content-length"] ?? 0);
3085
+ if (!Number.isFinite(declared) || declared < 0 || declared > maxBytes) throw new PluginBodyTooLargeError();
3086
+ const chunks = [];
3087
+ let bytes = 0;
3088
+ for await (const chunk of req) {
3089
+ const data = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
3090
+ bytes += data.byteLength;
3091
+ if (bytes > maxBytes) throw new PluginBodyTooLargeError();
3092
+ chunks.push(data);
3093
+ }
3094
+ if (bytes === 0) return {};
3095
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
3096
+ }
3097
+ function rejectOn(signal) {
3098
+ return new Promise((_resolve, reject) => {
3099
+ if (signal.aborted) {
3100
+ reject(signal.reason);
3101
+ return;
3102
+ }
3103
+ signal.addEventListener("abort", () => reject(signal.reason), { once: true });
3104
+ });
3105
+ }
3106
+ /**
3107
+ * Register one plugin route.
3108
+ *
3109
+ * This is the only place in the package that calls `ctx.webServer.register`,
3110
+ * and the ordering inside it is the fix rather than an implementation detail:
3111
+ * the disconnect listeners are attached before the first `await`, so a client
3112
+ * that goes away while the handler is still assembling its first response
3113
+ * still tears the route's work down. The previous per-route code attached
3114
+ * `res.on('close')` after awaiting an unbounded repository probe, which is how
3115
+ * the projection stream reached 53 opens against 33 closes.
3116
+ */
3117
+ function registerPluginRoute(ctx, route) {
3118
+ const label = `dsh-claude: ${route.path}`;
3119
+ ctx.effect(() => ctx.webServer.register({
3120
+ kind: route.kind,
3121
+ path: route.path,
3122
+ handler: async (req, res) => {
3123
+ const method = req.method;
3124
+ if (!isPluginMethod(method) || !route.methods.includes(method)) return json(res, 405, { error: "method not allowed" });
3125
+ if (!trustedRequest(req)) return json(res, 403, { error: "forbidden" });
3126
+ const aborted = new AbortController();
3127
+ const abort = () => {
3128
+ aborted.abort();
3129
+ };
3130
+ req.on("close", () => {
3131
+ if (!req.complete) abort();
3132
+ });
3133
+ res.on("close", abort);
3134
+ const url = new URL(req.url ?? "/", "http://localhost");
3135
+ if (route.mode === "stream") {
3136
+ const release = streams.admit(route.path, route.streamKey(url), route.maxConcurrent, abort);
3137
+ const io = {
3138
+ signal: aborted.signal,
3139
+ method,
3140
+ url,
3141
+ body: async (maxBytes = DEFAULT_BODY_BYTES) => await readBody(req, maxBytes)
3142
+ };
3143
+ try {
3144
+ await route.handler(res, io);
3145
+ } catch (error) {
3146
+ if (!res.headersSent) json(res, 500, { error: "stream-failed" });
3147
+ ctx.logger.warn(`dsh-claude: ${route.path} stream failed: ${error instanceof Error ? error.message : String(error)}`);
3148
+ } finally {
3149
+ release();
3150
+ res.end();
3151
+ }
3152
+ return;
3153
+ }
3154
+ const budgetMs = ROUTE_BUDGET_MS[route.budget];
3155
+ const bounded = deadline(aborted.signal, budgetMs, ROUTE_TIMEOUT_CODE);
3156
+ const io = {
3157
+ signal: bounded.signal,
3158
+ method,
3159
+ url,
3160
+ body: async (maxBytes = DEFAULT_BODY_BYTES) => await readBody(req, maxBytes)
3161
+ };
3162
+ try {
3163
+ const result = await Promise.race([route.handler(io), rejectOn(bounded.signal)]);
3164
+ if (!res.writableEnded) json(res, result.status, result.value);
3165
+ } catch (error) {
3166
+ if (res.writableEnded || res.headersSent) return;
3167
+ if (bounded.signal.aborted && !aborted.signal.aborted) return json(res, 504, {
3168
+ error: "deadline",
3169
+ budget: route.budget,
3170
+ ms: budgetMs
3171
+ });
3172
+ if (aborted.signal.aborted) return;
3173
+ if (error instanceof PluginBodyTooLargeError) return json(res, 413, {
3174
+ error: "body-too-large",
3175
+ message: "The request body is too large."
3176
+ });
3177
+ json(res, 400, { error: error instanceof Error ? error.message : "request failed" });
3178
+ } finally {
3179
+ bounded[Symbol.dispose]();
3180
+ }
3181
+ }
3182
+ }), label);
3183
+ }
2792
3184
  //#endregion
2793
3185
  //#region src/doctor-routes.ts
2794
3186
  const CLAUDE_DOCTOR_PROBE_TIMEOUT_MS = 15e3;
@@ -2828,60 +3220,70 @@ function commandDiagnostics(ctx) {
2828
3220
  }
2829
3221
  }
2830
3222
  function registerClaudeDoctorRoutes(ctx, runtime, supervisor, config, resolutionError) {
2831
- ctx.effect(() => ctx.webServer.register({
3223
+ registerPluginRoute(ctx, {
3224
+ mode: "unary",
2832
3225
  kind: "exact",
2833
3226
  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" });
3227
+ methods: ["GET"],
3228
+ budget: "git",
3229
+ handler: async (io) => {
2837
3230
  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
3231
+ if (resolutionError !== void 0) return {
3232
+ status: 200,
3233
+ value: {
3234
+ executable: {
3235
+ status: "missing",
3236
+ searched: config.executablePath.length > 0 ? [config.executablePath] : [
3237
+ "claude",
3238
+ "~/.local/bin/claude",
3239
+ "/opt/homebrew/bin/claude",
3240
+ "/usr/local/bin/claude"
3241
+ ]
3242
+ },
3243
+ version: { status: "not-run" },
3244
+ authentication: { status: "not-run" },
3245
+ handshake: "not-run",
3246
+ message: safeMessage$2(resolutionError),
3247
+ limits: {
3248
+ idleTimeoutMs: config.idleTimeoutMs,
3249
+ maxProcesses: config.maxProcesses
3250
+ },
3251
+ processes: {
3252
+ count: 0,
3253
+ active: 0
3254
+ }
2859
3255
  }
2860
- });
3256
+ };
2861
3257
  const report = await runClaudeDoctor(runtime, {
2862
3258
  configuredPath: config.executablePath,
2863
3259
  cwd: process.cwd(),
2864
- signal: AbortSignal.timeout(CLAUDE_DOCTOR_PROBE_TIMEOUT_MS)
3260
+ signal: AbortSignal.any([io.signal, AbortSignal.timeout(CLAUDE_DOCTOR_PROBE_TIMEOUT_MS)])
2865
3261
  });
2866
3262
  const processes = supervisor.snapshots();
2867
3263
  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
- });
3264
+ return {
3265
+ status: 200,
3266
+ value: {
3267
+ ...report,
3268
+ limits: {
3269
+ idleTimeoutMs: config.idleTimeoutMs,
3270
+ maxProcesses: config.maxProcesses
3271
+ },
3272
+ processes: {
3273
+ count: processes.length,
3274
+ active: processes.filter((process) => process.state === "running" || process.state === "starting").length
3275
+ },
3276
+ commandBridge: commandDiagnostics(ctx)
3277
+ }
3278
+ };
2880
3279
  } catch (error) {
2881
- json(res, 500, { error: safeMessage$2(error) });
3280
+ return {
3281
+ status: 500,
3282
+ value: { error: safeMessage$2(error) }
3283
+ };
2882
3284
  }
2883
3285
  }
2884
- }), "dsh-claude: Doctor route");
3286
+ });
2885
3287
  }
2886
3288
  //#endregion
2887
3289
  //#region src/projection-routes.ts
@@ -2889,21 +3291,34 @@ const MAX_SESSION_ID_CHARS$6 = 1024;
2889
3291
  /** Slow-moving metadata refresh and stream heartbeat cadence; deliberately off
2890
3292
  * the transcript hot path so git/gh latency never delays visible text. */
2891
3293
  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;
3294
+ /** The multiplexed carrier's path segment. Session ids are `session-<uuid>`,
3295
+ * so this cannot collide with one. */
3296
+ const MULTI_SEGMENT = "multi";
3297
+ function validSessionId(value) {
3298
+ return value.length > 0 && value.length <= MAX_SESSION_ID_CHARS$6;
3299
+ }
3300
+ function targetFromUrl(url) {
3301
+ const prefix = `${CLAUDE_PROJECTION_PATH}/`;
3302
+ if (!url.pathname.startsWith(prefix)) return void 0;
3303
+ const encoded = url.pathname.slice(prefix.length);
3304
+ if (encoded.length === 0 || encoded.includes("/")) return void 0;
3305
+ if (encoded === MULTI_SEGMENT) {
3306
+ const raw = url.searchParams.get("sessions");
3307
+ if (raw === null) return void 0;
3308
+ const sessionIds = [...new Set(raw.split(",").filter((id) => id.length > 0))];
3309
+ if (sessionIds.length === 0 || sessionIds.length > 16) return void 0;
3310
+ if (!sessionIds.every(validSessionId)) return void 0;
2903
3311
  return {
2904
- sessionId,
2905
- stream
3312
+ kind: "multi",
3313
+ sessionIds
2906
3314
  };
3315
+ }
3316
+ try {
3317
+ const sessionId = decodeURIComponent(encoded);
3318
+ return validSessionId(sessionId) ? {
3319
+ kind: "snapshot",
3320
+ sessionId
3321
+ } : void 0;
2907
3322
  } catch {
2908
3323
  return;
2909
3324
  }
@@ -2923,9 +3338,16 @@ function envelope(projection, meta) {
2923
3338
  };
2924
3339
  }
2925
3340
  /** 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. */
3341
+ *
3342
+ * `GET <path>/:sessionId` returns one snapshot. `GET <path>/multi?sessions=a,b`
3343
+ * returns ONE NDJSON stream carrying every listed session, each line stamped
3344
+ * with its `session`.
3345
+ *
3346
+ * There is deliberately no per-session stream URL. The browser shares a small
3347
+ * fixed connection budget between this plugin and the Host, and a stream per
3348
+ * session spent it in proportion to how many Claude sessions existed — which
3349
+ * is what left the settings panel unable to get a connection at all. One
3350
+ * carrier is one connection, whatever the session count. */
2929
3351
  function registerClaudeProjectionRoute(ctx, sidecar, ownsSession, commandsForSession = () => [], repositoryForSession = async () => void 0, reviewCommentsForSession = () => []) {
2930
3352
  const info = (message) => {
2931
3353
  ctx.logger?.info?.(message);
@@ -2940,15 +3362,14 @@ function registerClaudeProjectionRoute(ctx, sidecar, ownsSession, commandsForSes
2940
3362
  reviewComments: owned ? reviewCommentsForSession(sessionId) : []
2941
3363
  };
2942
3364
  };
2943
- const streamProjection = async (res, sessionId) => {
2944
- info(`dsh-claude: projection stream opened for ${sessionId.slice(0, 64)}`);
3365
+ const streamMulti = async (res, io, sessionIds) => {
3366
+ info(`dsh-claude: projection stream opened for ${sessionIds.length} session(s)`);
2945
3367
  let textDeltas = 0;
2946
3368
  let textBytes = 0;
2947
3369
  let textSince = Date.now();
2948
- let meta = await assembleMeta(sessionId);
2949
3370
  res.writeHead(200, {
2950
3371
  "content-type": "application/x-ndjson; charset=utf-8",
2951
- "cache-control": "no-store",
3372
+ "cache-control": "no-store, no-transform",
2952
3373
  "x-content-type-options": "nosniff"
2953
3374
  });
2954
3375
  res.flushHeaders?.();
@@ -2961,102 +3382,146 @@ function registerClaudeProjectionRoute(ctx, sidecar, ownsSession, commandsForSes
2961
3382
  closed = true;
2962
3383
  }
2963
3384
  };
2964
- const writeSnapshot = async () => {
2965
- const projection = await sidecar.read(sessionId);
3385
+ const metas = /* @__PURE__ */ new Map();
3386
+ const writeSnapshot = async (sessionId) => {
3387
+ const [projection, meta] = await Promise.all([sidecar.read(sessionId), assembleMeta(sessionId)]);
3388
+ metas.set(sessionId, meta);
2966
3389
  writeLine({
2967
3390
  type: "snapshot",
3391
+ session: sessionId,
3392
+ seq: sidecar.sequence(sessionId),
2968
3393
  ...envelope(projection, meta)
2969
3394
  });
2970
3395
  };
2971
- await writeSnapshot();
2972
- const unsubscribe = sidecar.subscribe(sessionId, (delta) => {
3396
+ const unsubscribes = sessionIds.map((sessionId) => sidecar.subscribe(sessionId, (delta) => {
2973
3397
  switch (delta.kind) {
2974
3398
  case "text":
2975
3399
  textDeltas += 1;
2976
3400
  textBytes += (delta.append ?? delta.text ?? "").length;
2977
3401
  if (textDeltas % 25 === 0) {
2978
3402
  const elapsed = Date.now() - textSince;
2979
- info(`dsh-claude: stream ${sessionId.slice(0, 24)} 25 text deltas ${textBytes}B in ${elapsed}ms`);
3403
+ info(`dsh-claude: stream 25 text deltas ${textBytes}B in ${elapsed}ms`);
2980
3404
  textBytes = 0;
2981
3405
  textSince = Date.now();
2982
3406
  }
2983
3407
  writeLine({
2984
3408
  type: "text",
3409
+ session: sessionId,
2985
3410
  turn: delta.turn,
2986
3411
  step: delta.step,
2987
3412
  ordinal: delta.ordinal,
2988
3413
  ...delta.append === void 0 ? {} : { append: delta.append },
2989
- ...delta.text === void 0 ? {} : { text: delta.text }
3414
+ ...delta.text === void 0 ? {} : { text: delta.text },
3415
+ ...delta.renderer === void 0 ? {} : { renderer: delta.renderer },
3416
+ seq: delta.seq
2990
3417
  });
2991
3418
  return;
2992
3419
  case "activity":
2993
3420
  writeLine({
2994
3421
  type: "activity",
2995
- activity: delta.activity
3422
+ session: sessionId,
3423
+ activity: delta.activity,
3424
+ seq: delta.seq
2996
3425
  });
2997
3426
  return;
2998
3427
  case "contextUsage":
2999
3428
  writeLine({
3000
3429
  type: "contextUsage",
3001
- value: delta.value
3430
+ session: sessionId,
3431
+ value: delta.value,
3432
+ seq: delta.seq
3002
3433
  });
3003
3434
  return;
3004
3435
  case "tasks":
3005
3436
  writeLine({
3006
3437
  type: "tasks",
3007
- value: delta.value
3438
+ session: sessionId,
3439
+ value: delta.value,
3440
+ seq: delta.seq
3008
3441
  });
3009
3442
  return;
3010
- case "sync": writeSnapshot().catch(() => void 0);
3443
+ case "checkpoint":
3444
+ writeLine({
3445
+ type: "checkpoint",
3446
+ session: sessionId,
3447
+ seq: delta.seq
3448
+ });
3449
+ return;
3450
+ case "sync": writeSnapshot(sessionId).catch(() => void 0);
3011
3451
  }
3012
- });
3452
+ }));
3453
+ for (const sessionId of sessionIds) writeSnapshot(sessionId).catch(() => void 0);
3013
3454
  const timer = setInterval(() => {
3014
3455
  (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;
3456
+ for (const sessionId of sessionIds) {
3457
+ if (closed) return;
3458
+ const next = await assembleMeta(sessionId);
3459
+ if (closed) return;
3460
+ if (JSON.stringify(next) === JSON.stringify(metas.get(sessionId))) continue;
3461
+ metas.set(sessionId, next);
3462
+ writeLine({
3463
+ type: "meta",
3464
+ session: sessionId,
3465
+ owned: next.owned,
3466
+ commands: next.commands,
3467
+ ...next.repository === void 0 ? {} : { repository: next.repository },
3468
+ reviewComments: next.reviewComments
3469
+ });
3020
3470
  }
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
- });
3471
+ writeLine({ type: "ping" });
3029
3472
  })().catch(() => void 0);
3030
3473
  }, META_REFRESH_MS);
3031
3474
  timer.unref?.();
3032
3475
  await new Promise((resolve) => {
3033
- res.on("close", () => {
3476
+ const finish = () => {
3477
+ if (closed) return;
3034
3478
  closed = true;
3035
3479
  clearInterval(timer);
3036
- unsubscribe();
3037
- info(`dsh-claude: projection stream closed for ${sessionId.slice(0, 64)} after ${textDeltas} text deltas`);
3480
+ for (const unsubscribe of unsubscribes) unsubscribe();
3481
+ info(`dsh-claude: projection stream closed after ${textDeltas} text deltas`);
3038
3482
  resolve();
3039
- });
3483
+ };
3484
+ if (io.signal.aborted) finish();
3485
+ else io.signal.addEventListener("abort", finish, { once: true });
3040
3486
  });
3041
3487
  };
3042
- ctx.effect(() => ctx.webServer.register({
3488
+ registerPluginRoute(ctx, {
3489
+ mode: "stream",
3043
3490
  kind: "prefix",
3044
3491
  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" });
3492
+ methods: ["GET"],
3493
+ maxConcurrent: 1,
3494
+ streamKey: () => MULTI_SEGMENT,
3495
+ handler: async (res, io) => {
3496
+ const target = targetFromUrl(io.url);
3497
+ if (target === void 0) {
3498
+ res.writeHead(400, {
3499
+ "content-type": "application/json; charset=utf-8",
3500
+ "cache-control": "no-store"
3501
+ });
3502
+ res.write(JSON.stringify({ error: "invalid session id" }));
3503
+ return;
3504
+ }
3505
+ if (target.kind === "multi") return await streamMulti(res, io, target.sessionIds);
3506
+ info(`dsh-claude: projection poll for ${target.sessionId.slice(0, 64)}`);
3050
3507
  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)));
3508
+ const body = envelope(await sidecar.read(target.sessionId), await assembleMeta(target.sessionId));
3509
+ res.writeHead(200, {
3510
+ "content-type": "application/json; charset=utf-8",
3511
+ "cache-control": "no-store",
3512
+ "x-content-type-options": "nosniff"
3513
+ });
3514
+ res.write(JSON.stringify(body));
3054
3515
  } catch {
3055
- if (!res.headersSent) return json(res, 500, { error: "projection unavailable" });
3056
- res.end();
3516
+ if (res.headersSent) return;
3517
+ res.writeHead(500, {
3518
+ "content-type": "application/json; charset=utf-8",
3519
+ "cache-control": "no-store"
3520
+ });
3521
+ res.write(JSON.stringify({ error: "projection unavailable" }));
3057
3522
  }
3058
3523
  }
3059
- }), "dsh-claude: sidecar projection route");
3524
+ });
3060
3525
  }
3061
3526
  //#endregion
3062
3527
  //#region src/repository-status.ts
@@ -3235,10 +3700,13 @@ var RepositoryStatusService = class {
3235
3700
  this.#runtime = runtime;
3236
3701
  this.#cacheTtlMs = cacheTtlMs;
3237
3702
  }
3238
- inspect(cwd) {
3703
+ /** `signal` bounds the scan itself, not just the caller's patience: the
3704
+ * untracked-file diff below is a serial spawn loop that can outlive any
3705
+ * route budget, and work nobody is waiting for still occupies the process. */
3706
+ inspect(cwd, signal) {
3239
3707
  const current = this.#cache.get(cwd);
3240
3708
  if (current !== void 0 && current.expiresAt > Date.now()) return current.value;
3241
- const value = this.#inspect(cwd).then((next) => this.#stabilize(cwd, next));
3709
+ const value = this.#inspect(cwd, signal).then((next) => this.#stabilize(cwd, next));
3242
3710
  this.#cache.set(cwd, {
3243
3711
  expiresAt: Date.now() + this.#cacheTtlMs,
3244
3712
  value
@@ -3296,7 +3764,7 @@ var RepositoryStatusService = class {
3296
3764
  this.#ghExecutable ??= this.#runtime.resolveExecutable("gh").catch(() => void 0);
3297
3765
  return this.#ghExecutable;
3298
3766
  }
3299
- async #inspect(cwd) {
3767
+ async #inspect(cwd, signal) {
3300
3768
  const safeCwd = bounded(cwd);
3301
3769
  let git;
3302
3770
  try {
@@ -3346,7 +3814,7 @@ var RepositoryStatusService = class {
3346
3814
  const remote = remoteResult.exitCode === 0 ? parseGitHubRemote(remoteResult.stdout) : void 0;
3347
3815
  const pullRequest = status.branch === void 0 || remote === void 0 ? void 0 : await this.#pullRequest(cwd, remote, status.branch);
3348
3816
  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) : {
3817
+ const diff = status.dirty || diffBase !== "HEAD" ? await this.#diff(cwd, git, diffBase, signal) : {
3350
3818
  additions: 0,
3351
3819
  deletions: 0,
3352
3820
  files: 0,
@@ -3400,7 +3868,7 @@ var RepositoryStatusService = class {
3400
3868
  return;
3401
3869
  }
3402
3870
  }
3403
- async #diff(cwd, git, base) {
3871
+ async #diff(cwd, git, base, signal) {
3404
3872
  try {
3405
3873
  const numstat = await run(this.#runtime, git, [
3406
3874
  "diff",
@@ -3423,7 +3891,7 @@ var RepositoryStatusService = class {
3423
3891
  ...summary,
3424
3892
  truncated: true
3425
3893
  };
3426
- const untracked = await this.#untrackedDiff(cwd, git);
3894
+ const untracked = await this.#untrackedDiff(cwd, git, signal);
3427
3895
  const combinedPatch = `${patch.stdout}${untracked.patch}`;
3428
3896
  return {
3429
3897
  additions: summary.additions + untracked.additions,
@@ -3436,7 +3904,7 @@ var RepositoryStatusService = class {
3436
3904
  return;
3437
3905
  }
3438
3906
  }
3439
- async #untrackedDiff(cwd, git) {
3907
+ async #untrackedDiff(cwd, git, signal) {
3440
3908
  const listed = await run(this.#runtime, git, [
3441
3909
  "ls-files",
3442
3910
  "--others",
@@ -3454,6 +3922,12 @@ var RepositoryStatusService = class {
3454
3922
  let patch = "";
3455
3923
  let truncated = paths.length > MAX_UNTRACKED_DIFFS;
3456
3924
  for (const path of paths.slice(0, MAX_UNTRACKED_DIFFS)) {
3925
+ if (signal?.aborted === true) return {
3926
+ additions,
3927
+ files: paths.length,
3928
+ patch,
3929
+ truncated: true
3930
+ };
3457
3931
  const result = await run(this.#runtime, git, [
3458
3932
  "diff",
3459
3933
  "--no-ext-diff",
@@ -4445,16 +4919,15 @@ const MAX_BODY_BYTES$6 = 16384;
4445
4919
  function record$9(value) {
4446
4920
  return value !== null && typeof value === "object" && !Array.isArray(value) ? value : void 0;
4447
4921
  }
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);
4922
+ async function readJson$5(io) {
4923
+ let parsed;
4924
+ try {
4925
+ parsed = await io.body(MAX_BODY_BYTES$6);
4926
+ } catch (error) {
4927
+ if (error instanceof SyntaxError) throw error;
4928
+ throw new RepositorySetupError("body-too-large", "The request body is too large.");
4456
4929
  }
4457
- const value = record$9(JSON.parse(Buffer.concat(chunks).toString("utf8")));
4930
+ const value = record$9(parsed);
4458
4931
  if (value === void 0) throw new RepositorySetupError("invalid-request", "The request body is invalid.");
4459
4932
  return value;
4460
4933
  }
@@ -4505,41 +4978,48 @@ async function streamSetup(res, service, input) {
4505
4978
  res.end();
4506
4979
  }
4507
4980
  }
4508
- /** Register trusted browser routes for safe pre-session Git setup. */
4981
+ /** Register trusted browser routes for safe pre-session Git setup.
4982
+ *
4983
+ * The prefix is registered as a stream because the setup POST holds its
4984
+ * connection open for the whole worktree build; the short sibling paths ride
4985
+ * the same registration and answer with `json` before releasing it. */
4509
4986
  function registerRepositorySetupRoute(ctx, service) {
4510
- ctx.effect(() => ctx.webServer.register({
4987
+ registerPluginRoute(ctx, {
4988
+ mode: "stream",
4511
4989
  kind: "prefix",
4512
4990
  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;
4991
+ methods: ["GET", "POST"],
4992
+ maxConcurrent: 2,
4993
+ streamKey: (url) => `${url.pathname}?${url.searchParams.get("cwd") ?? url.searchParams.get("branch") ?? ""}`,
4994
+ handler: async (res, io) => {
4995
+ const pathname = io.url.pathname;
4516
4996
  try {
4517
4997
  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);
4998
+ if (io.method !== "POST") return json(res, 405, { error: "method not allowed" });
4999
+ const input = await readJson$5(io);
4520
5000
  return json(res, 200, await service.refreshBranches(string$2(input, "cwd")));
4521
5001
  }
4522
5002
  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");
5003
+ if (io.method !== "GET") return json(res, 405, { error: "method not allowed" });
5004
+ const cwd = io.url.searchParams.get("cwd");
4525
5005
  if (cwd === null) throw new RepositorySetupError("invalid-request", "The cwd query parameter is required.");
4526
5006
  return json(res, 200, await service.listBranches(cwd));
4527
5007
  }
4528
5008
  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);
5009
+ if (io.method !== "POST") return json(res, 405, { error: "method not allowed" });
5010
+ const input = await readJson$5(io);
4531
5011
  if (typeof input.worktree !== "boolean") throw new RepositorySetupError("invalid-request", "The worktree field is required.");
4532
5012
  await streamSetup(res, service, input);
4533
5013
  return;
4534
5014
  }
4535
5015
  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);
5016
+ if (io.method !== "POST") return json(res, 405, { error: "method not allowed" });
5017
+ const input = await readJson$5(io);
4538
5018
  return json(res, 200, await service.cleanupMerged(string$2(input, "path"), string$2(input, "baseBranch")));
4539
5019
  }
4540
5020
  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);
5021
+ if (io.method !== "POST") return json(res, 405, { error: "method not allowed" });
5022
+ const input = await readJson$5(io);
4543
5023
  await service.bindLease(string$2(input, "leaseId"), string$2(input, "sessionId"));
4544
5024
  return json(res, 200, { ok: true });
4545
5025
  }
@@ -4553,7 +5033,7 @@ function registerRepositorySetupRoute(ctx, service) {
4553
5033
  return json(res, 500, { error: "repository setup unavailable" });
4554
5034
  }
4555
5035
  }
4556
- }), "dsh-claude: repository setup route");
5036
+ });
4557
5037
  }
4558
5038
  //#endregion
4559
5039
  //#region src/repository-action-routes.ts
@@ -4570,16 +5050,15 @@ const ACTIONS = /* @__PURE__ */ new Set([
4570
5050
  function record$8(value) {
4571
5051
  return value !== null && typeof value === "object" && !Array.isArray(value) ? value : void 0;
4572
5052
  }
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);
5053
+ async function readJson$4(io) {
5054
+ let parsed;
5055
+ try {
5056
+ parsed = await io.body(MAX_BODY_BYTES$5);
5057
+ } catch (error) {
5058
+ if (error instanceof SyntaxError) throw error;
5059
+ throw new RepositoryActionError("body-too-large", "The request body is too large.");
4581
5060
  }
4582
- const value = record$8(JSON.parse(Buffer.concat(chunks).toString("utf8")));
5061
+ const value = record$8(parsed);
4583
5062
  if (value === void 0) throw new RepositoryActionError("invalid-request", "The request body is invalid.");
4584
5063
  return value;
4585
5064
  }
@@ -4618,44 +5097,80 @@ function actionRequest(input) {
4618
5097
  })()
4619
5098
  };
4620
5099
  }
5100
+ /** One prefix serves the local-Git preview and the POST arms that push, open
5101
+ * and merge pull requests, so the registration takes the wider of the two
5102
+ * budgets: a `remote` arm cut short at the `git` deadline would abandon work
5103
+ * that had already reached GitHub. */
4621
5104
  function registerRepositoryActionRoute(ctx, service, cwdForSession) {
4622
- ctx.effect(() => ctx.webServer.register({
5105
+ registerPluginRoute(ctx, {
5106
+ mode: "unary",
4623
5107
  kind: "prefix",
4624
5108
  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");
5109
+ methods: ["GET", "POST"],
5110
+ budget: "remote",
5111
+ handler: async (io) => {
5112
+ const url = io.url;
4628
5113
  try {
4629
5114
  const cwd = cwdForSession(sessionId$1(url));
4630
5115
  if (cwd === void 0) throw new RepositoryActionError("session-unavailable", "The Claude session is unavailable.");
4631
5116
  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));
5117
+ if (io.method !== "GET") return {
5118
+ status: 405,
5119
+ value: { error: "method not allowed" }
5120
+ };
5121
+ return {
5122
+ status: 200,
5123
+ value: await service.preview(cwd)
5124
+ };
4634
5125
  }
4635
5126
  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")) });
5127
+ if (io.method !== "POST") return {
5128
+ status: 405,
5129
+ value: { error: "method not allowed" }
5130
+ };
5131
+ const input = await readJson$4(io);
5132
+ return {
5133
+ status: 200,
5134
+ value: { message: await service.generateMessage(cwd, string$1(input, "fingerprint")) }
5135
+ };
4639
5136
  }
4640
5137
  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))));
5138
+ if (io.method !== "POST") return {
5139
+ status: 405,
5140
+ value: { error: "method not allowed" }
5141
+ };
5142
+ return {
5143
+ status: 200,
5144
+ value: await service.execute(cwd, actionRequest(await readJson$4(io)))
5145
+ };
4643
5146
  }
4644
- return json(res, 404, { error: "not found" });
5147
+ return {
5148
+ status: 404,
5149
+ value: { error: "not found" }
5150
+ };
4645
5151
  } 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
- });
5152
+ if (error instanceof RepositoryActionError) return {
5153
+ status: 409,
5154
+ value: {
5155
+ error: error.code,
5156
+ message: error.message,
5157
+ ...error.commit === void 0 ? {} : { commit: error.commit }
5158
+ }
5159
+ };
5160
+ if (error instanceof SyntaxError) return {
5161
+ status: 400,
5162
+ value: { error: "invalid-json" }
5163
+ };
5164
+ return {
5165
+ status: 500,
5166
+ value: {
5167
+ error: "repository-action-unavailable",
5168
+ message: "Repository action is unavailable."
5169
+ }
5170
+ };
4656
5171
  }
4657
5172
  }
4658
- }), "dsh-claude: repository action route");
5173
+ });
4659
5174
  }
4660
5175
  //#endregion
4661
5176
  //#region src/editor-open.ts
@@ -4781,43 +5296,62 @@ const MAX_SESSION_ID_CHARS$4 = 1024;
4781
5296
  /** Open the session's working directory in a desktop editor. Query-only: the
4782
5297
  * request carries two enum-ish values, so there is no body to parse. */
4783
5298
  function registerEditorOpenRoute(ctx, service, cwdForSession) {
4784
- ctx.effect(() => ctx.webServer.register({
5299
+ registerPluginRoute(ctx, {
5300
+ mode: "unary",
4785
5301
  kind: "exact",
4786
5302
  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;
5303
+ methods: ["POST"],
5304
+ budget: "fast",
5305
+ handler: async (io) => {
5306
+ const params = io.url.searchParams;
4791
5307
  const id = params.get("sessionId");
4792
5308
  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
- });
5309
+ if (id === null || id.length === 0 || id.length > MAX_SESSION_ID_CHARS$4) return {
5310
+ status: 400,
5311
+ value: {
5312
+ error: "invalid-session",
5313
+ message: "The session is invalid."
5314
+ }
5315
+ };
5316
+ if (editor === null || !EDITOR_IDS.has(editor)) return {
5317
+ status: 400,
5318
+ value: {
5319
+ error: "invalid-editor",
5320
+ message: "The editor is invalid."
5321
+ }
5322
+ };
4801
5323
  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
- });
5324
+ if (cwd === void 0) return {
5325
+ status: 409,
5326
+ value: {
5327
+ error: "session-unavailable",
5328
+ message: "The Claude session is unavailable."
5329
+ }
5330
+ };
4806
5331
  try {
4807
5332
  await service.open(cwd, editor);
4808
- return json(res, 200, { opened: true });
5333
+ return {
5334
+ status: 200,
5335
+ value: { opened: true }
5336
+ };
4809
5337
  } 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
- });
5338
+ if (error instanceof EditorOpenError) return {
5339
+ status: 409,
5340
+ value: {
5341
+ error: error.code,
5342
+ message: error.message
5343
+ }
5344
+ };
5345
+ return {
5346
+ status: 500,
5347
+ value: {
5348
+ error: "editor-open-unavailable",
5349
+ message: "The editor could not be launched."
5350
+ }
5351
+ };
4818
5352
  }
4819
5353
  }
4820
- }), "dsh-claude: editor open route");
5354
+ });
4821
5355
  }
4822
5356
  //#endregion
4823
5357
  //#region src/github-url.ts
@@ -5110,7 +5644,10 @@ var PullRequestFeedbackService = class {
5110
5644
  return [];
5111
5645
  }
5112
5646
  }
5113
- async failingChecks(cwd, pullNumber) {
5647
+ /** `signal` bounds the log fetches below: each is capped on its own, but they
5648
+ * run one after another, so the honest worst case is their sum rather than
5649
+ * any single ceiling — more than a route is allowed to hold a connection. */
5650
+ async failingChecks(cwd, pullNumber, signal) {
5114
5651
  const gh = await this.#gh();
5115
5652
  const result = await this.#run(gh, [
5116
5653
  "pr",
@@ -5132,6 +5669,10 @@ var PullRequestFeedbackService = class {
5132
5669
  detailed.push(check);
5133
5670
  continue;
5134
5671
  }
5672
+ if (signal?.aborted === true) {
5673
+ detailed.push(check);
5674
+ continue;
5675
+ }
5135
5676
  const log = await this.#run(gh, [
5136
5677
  "run",
5137
5678
  "view",
@@ -5204,16 +5745,15 @@ const MAX_MENTION_QUERY_CHARS = 64;
5204
5745
  function record$6(value) {
5205
5746
  return value !== null && typeof value === "object" && !Array.isArray(value) ? value : void 0;
5206
5747
  }
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);
5748
+ async function readJson$3(io) {
5749
+ let parsed;
5750
+ try {
5751
+ parsed = await io.body(MAX_BODY_BYTES$4);
5752
+ } catch (error) {
5753
+ if (error instanceof SyntaxError) throw error;
5754
+ throw new PullRequestFeedbackError("body-too-large", "The request body is too large.");
5215
5755
  }
5216
- const value = record$6(JSON.parse(Buffer.concat(chunks).toString("utf8")));
5756
+ const value = record$6(parsed);
5217
5757
  if (value === void 0) throw new PullRequestFeedbackError("invalid-request", "The request body is invalid.");
5218
5758
  return value;
5219
5759
  }
@@ -5242,123 +5782,199 @@ function pullNumber(url) {
5242
5782
  if (!Number.isSafeInteger(value) || value <= 0 || value > 1e9) throw new PullRequestFeedbackError("invalid-request", "The pull request number is invalid.");
5243
5783
  return value;
5244
5784
  }
5785
+ /** Every arm shells out to `gh`, so the whole prefix carries the network budget. */
5245
5786
  function registerPullRequestFeedbackRoute(ctx, service, cwdForSession) {
5246
- ctx.effect(() => ctx.webServer.register({
5787
+ registerPluginRoute(ctx, {
5788
+ mode: "unary",
5247
5789
  kind: "prefix",
5248
5790
  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";
5791
+ methods: ["GET", "POST"],
5792
+ budget: "remote",
5793
+ handler: async (io) => {
5794
+ const url = io.url;
5795
+ const reads = io.method === "GET";
5796
+ const writes = io.method === "POST";
5254
5797
  try {
5255
5798
  const cwd = cwdForSession(sessionId(url));
5256
5799
  if (cwd === void 0) throw new PullRequestFeedbackError("session-unavailable", "The Claude session is unavailable.");
5257
5800
  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)) });
5801
+ if (!reads) return {
5802
+ status: 405,
5803
+ value: { error: "method not allowed" }
5804
+ };
5805
+ return {
5806
+ status: 200,
5807
+ value: { threads: await service.threads(cwd, pullNumber(url)) }
5808
+ };
5260
5809
  }
5261
5810
  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)) });
5811
+ if (!reads) return {
5812
+ status: 405,
5813
+ value: { error: "method not allowed" }
5814
+ };
5815
+ return {
5816
+ status: 200,
5817
+ value: { checks: await service.failingChecks(cwd, pullNumber(url), io.signal) }
5818
+ };
5264
5819
  }
5265
5820
  if (url.pathname === `/plugins/dsh-claude/repository/feedback/mentionables`) {
5266
- if (!reads) return json(res, 405, { error: "method not allowed" });
5821
+ if (!reads) return {
5822
+ status: 405,
5823
+ value: { error: "method not allowed" }
5824
+ };
5267
5825
  const query = (url.searchParams.get("q") ?? "").slice(0, MAX_MENTION_QUERY_CHARS);
5268
- return json(res, 200, { users: await service.mentionables(cwd, query) });
5826
+ return {
5827
+ status: 200,
5828
+ value: { users: await service.mentionables(cwd, query) }
5829
+ };
5269
5830
  }
5270
5831
  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)) });
5832
+ if (!writes) return {
5833
+ status: 405,
5834
+ value: { error: "method not allowed" }
5835
+ };
5836
+ const input = await readJson$3(io);
5837
+ return {
5838
+ status: 200,
5839
+ value: { comment: await service.reply(cwd, pullNumber(url), commentId(input), replyBody(input)) }
5840
+ };
5274
5841
  }
5275
5842
  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);
5843
+ if (!writes) return {
5844
+ status: 405,
5845
+ value: { error: "method not allowed" }
5846
+ };
5847
+ const input = await readJson$3(io);
5278
5848
  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) });
5849
+ return {
5850
+ status: 200,
5851
+ value: { resolved: await service.setResolved(cwd, threadId(input), input.resolved) }
5852
+ };
5280
5853
  }
5281
- return json(res, 404, { error: "not found" });
5854
+ return {
5855
+ status: 404,
5856
+ value: { error: "not found" }
5857
+ };
5282
5858
  } 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
- });
5859
+ if (error instanceof PullRequestFeedbackError) return {
5860
+ status: 409,
5861
+ value: {
5862
+ error: error.code,
5863
+ message: error.message
5864
+ }
5865
+ };
5866
+ if (error instanceof SyntaxError) return {
5867
+ status: 400,
5868
+ value: { error: "invalid-json" }
5869
+ };
5870
+ return {
5871
+ status: 500,
5872
+ value: {
5873
+ error: "pr-feedback-unavailable",
5874
+ message: "Pull request feedback is unavailable."
5875
+ }
5876
+ };
5292
5877
  }
5293
5878
  }
5294
- }), "dsh-claude: pull request feedback route");
5879
+ });
5295
5880
  }
5296
5881
  //#endregion
5297
5882
  //#region src/repository-status-routes.ts
5298
5883
  const MAX_PATH_CHARS$1 = 4096;
5299
5884
  /** Read-only repository status for an arbitrary directory (the overview panel
5300
- * aggregates every Claude session's checkout through this). */
5885
+ * aggregates every Claude session's checkout through this).
5886
+ *
5887
+ * The route signal is threaded into the service because this is the one read
5888
+ * the overview polls per distinct checkout every 30 seconds: freeing the
5889
+ * socket at the budget while the Host kept scanning would just grow a queue
5890
+ * of work nobody is waiting for any more. */
5301
5891
  function registerRepositoryStatusRoute(ctx, service) {
5302
- ctx.effect(() => ctx.webServer.register({
5892
+ registerPluginRoute(ctx, {
5893
+ mode: "unary",
5303
5894
  kind: "exact",
5304
5895
  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
- });
5896
+ methods: ["GET"],
5897
+ budget: "git",
5898
+ handler: async (io) => {
5899
+ const cwd = io.url.searchParams.get("cwd");
5900
+ if (cwd === null || cwd.length === 0 || cwd.length > MAX_PATH_CHARS$1 || !isAbsolute(cwd) || cwd.includes("\0")) return {
5901
+ status: 400,
5902
+ value: {
5903
+ error: "invalid-request",
5904
+ message: "The cwd query parameter is invalid."
5905
+ }
5906
+ };
5313
5907
  try {
5314
- return json(res, 200, await service.inspect(cwd));
5908
+ return {
5909
+ status: 200,
5910
+ value: await service.inspect(cwd, io.signal)
5911
+ };
5315
5912
  } catch {
5316
- return json(res, 500, {
5317
- error: "repository-status-unavailable",
5318
- message: "Repository status is unavailable."
5319
- });
5913
+ return {
5914
+ status: 500,
5915
+ value: {
5916
+ error: "repository-status-unavailable",
5917
+ message: "Repository status is unavailable."
5918
+ }
5919
+ };
5320
5920
  }
5321
5921
  }
5322
- }), "dsh-claude: repository status route");
5922
+ });
5323
5923
  }
5324
5924
  //#endregion
5325
5925
  //#region src/repository-file-routes.ts
5326
5926
  const MAX_PATH_CHARS = 4096;
5327
5927
  /** Read-only slice of a working-tree file so the diff panel can expand unmodified lines around hunks. */
5328
5928
  function registerRepositoryFileRoute(ctx, service) {
5329
- ctx.effect(() => ctx.webServer.register({
5929
+ registerPluginRoute(ctx, {
5930
+ mode: "unary",
5330
5931
  kind: "exact",
5331
5932
  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;
5933
+ methods: ["GET"],
5934
+ budget: "git",
5935
+ handler: async (io) => {
5936
+ const params = io.url.searchParams;
5336
5937
  const cwd = params.get("cwd");
5337
5938
  const path = params.get("path");
5338
5939
  const from = Number(params.get("from"));
5339
5940
  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
- });
5941
+ if (cwd === null || cwd.length === 0 || cwd.length > MAX_PATH_CHARS || !isAbsolute(cwd) || cwd.includes("\0")) return {
5942
+ status: 400,
5943
+ value: {
5944
+ error: "invalid-request",
5945
+ message: "The cwd query parameter is invalid."
5946
+ }
5947
+ };
5948
+ if (path === null || path.length === 0 || path.length > MAX_PATH_CHARS) return {
5949
+ status: 400,
5950
+ value: {
5951
+ error: "invalid-request",
5952
+ message: "The path query parameter is invalid."
5953
+ }
5954
+ };
5348
5955
  try {
5349
- return json(res, 200, await service.fileLines(cwd, path, from, to));
5956
+ return {
5957
+ status: 200,
5958
+ value: await service.fileLines(cwd, path, from, to)
5959
+ };
5350
5960
  } 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
- });
5961
+ if (error instanceof RepositoryFileError) return {
5962
+ status: error.code === "invalid-request" ? 400 : 409,
5963
+ value: {
5964
+ error: error.code,
5965
+ message: error.message
5966
+ }
5967
+ };
5968
+ return {
5969
+ status: 500,
5970
+ value: {
5971
+ error: "repository-file-unavailable",
5972
+ message: "The file could not be read."
5973
+ }
5974
+ };
5359
5975
  }
5360
5976
  }
5361
- }), "dsh-claude: repository file route");
5977
+ });
5362
5978
  }
5363
5979
  //#endregion
5364
5980
  //#region src/jira.ts
@@ -5604,16 +6220,17 @@ const MAX_BODY_BYTES$3 = 8192;
5604
6220
  function record$4(value) {
5605
6221
  return value !== null && typeof value === "object" && !Array.isArray(value) ? value : void 0;
5606
6222
  }
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);
6223
+ /** The wrapper enforces the byte cap; its plain rejection is translated back
6224
+ * into the JiraError shape the panel already knows how to render. */
6225
+ async function readJson$2(io) {
6226
+ let body;
6227
+ try {
6228
+ body = await io.body(MAX_BODY_BYTES$3);
6229
+ } catch (error) {
6230
+ if (error instanceof SyntaxError) throw error;
6231
+ throw new JiraError("body-too-large", "The request body is too large.");
5615
6232
  }
5616
- const value = record$4(JSON.parse(Buffer.concat(chunks).toString("utf8")));
6233
+ const value = record$4(body);
5617
6234
  if (value === void 0) throw new JiraError("invalid-request", "The request body is invalid.");
5618
6235
  return value;
5619
6236
  }
@@ -5623,55 +6240,99 @@ function string(input, key) {
5623
6240
  return value;
5624
6241
  }
5625
6242
  function registerJiraRoute(ctx, service) {
5626
- ctx.effect(() => ctx.webServer.register({
6243
+ registerPluginRoute(ctx, {
6244
+ mode: "unary",
5627
6245
  kind: "prefix",
5628
6246
  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");
6247
+ methods: ["GET", "POST"],
6248
+ budget: "git",
6249
+ handler: async (io) => {
6250
+ const url = io.url;
5632
6251
  try {
5633
6252
  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());
6253
+ if (io.method !== "GET") return {
6254
+ status: 405,
6255
+ value: { error: "method not allowed" }
6256
+ };
6257
+ return {
6258
+ status: 200,
6259
+ value: await service.status()
6260
+ };
5636
6261
  }
5637
6262
  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
- }));
6263
+ if (io.method !== "POST") return {
6264
+ status: 405,
6265
+ value: { error: "method not allowed" }
6266
+ };
6267
+ const input = await readJson$2(io);
6268
+ return {
6269
+ status: 200,
6270
+ value: await service.connect({
6271
+ siteUrl: string(input, "siteUrl"),
6272
+ email: string(input, "email"),
6273
+ apiToken: string(input, "apiToken")
6274
+ })
6275
+ };
5645
6276
  }
5646
6277
  if (url.pathname === `/plugins/dsh-claude/jira/disconnect`) {
5647
- if (req.method !== "POST") return json(res, 405, { error: "method not allowed" });
6278
+ if (io.method !== "POST") return {
6279
+ status: 405,
6280
+ value: { error: "method not allowed" }
6281
+ };
5648
6282
  await service.disconnect();
5649
- return json(res, 200, { connected: false });
6283
+ return {
6284
+ status: 200,
6285
+ value: { connected: false }
6286
+ };
5650
6287
  }
5651
6288
  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);
6289
+ if (io.method !== "POST") return {
6290
+ status: 405,
6291
+ value: { error: "method not allowed" }
6292
+ };
6293
+ const input = await readJson$2(io);
5654
6294
  await service.assignToMe(string(input, "key"));
5655
- return json(res, 200, { assigned: true });
6295
+ return {
6296
+ status: 200,
6297
+ value: { assigned: true }
6298
+ };
5656
6299
  }
5657
6300
  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") ?? "") });
6301
+ if (io.method !== "GET") return {
6302
+ status: 405,
6303
+ value: { error: "method not allowed" }
6304
+ };
6305
+ return {
6306
+ status: 200,
6307
+ value: { tickets: await service.search(url.searchParams.get("query") ?? "") }
6308
+ };
5660
6309
  }
5661
- return json(res, 404, { error: "not found" });
6310
+ return {
6311
+ status: 404,
6312
+ value: { error: "not found" }
6313
+ };
5662
6314
  } 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
- });
6315
+ if (error instanceof JiraError) return {
6316
+ status: 409,
6317
+ value: {
6318
+ error: error.code,
6319
+ message: error.message
6320
+ }
6321
+ };
6322
+ if (error instanceof SyntaxError) return {
6323
+ status: 400,
6324
+ value: { error: "invalid-json" }
6325
+ };
6326
+ return {
6327
+ status: 500,
6328
+ value: {
6329
+ error: "jira-unavailable",
6330
+ message: "Jira is unavailable."
6331
+ }
6332
+ };
5672
6333
  }
5673
6334
  }
5674
- }), "dsh-claude: jira route");
6335
+ });
5675
6336
  }
5676
6337
  //#endregion
5677
6338
  //#region src/ask.ts
@@ -5899,22 +6560,11 @@ var AskService = class {
5899
6560
  //#region src/ask-routes.ts
5900
6561
  const MAX_BODY_BYTES$2 = 131072;
5901
6562
  const MAX_SESSION_ID_CHARS$2 = 1024;
6563
+ /** Two sessions may await an answer at once; a third evicts the oldest. */
6564
+ const MAX_CONCURRENT_ASKS = 2;
5902
6565
  function record$2(value) {
5903
6566
  return value !== null && typeof value === "object" && !Array.isArray(value) ? value : void 0;
5904
6567
  }
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
6568
  function askRequest(input) {
5919
6569
  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
6570
  return {
@@ -5926,25 +6576,32 @@ function askRequest(input) {
5926
6576
  function ndjson(res, value) {
5927
6577
  res.write(`${JSON.stringify(value)}\n`);
5928
6578
  }
6579
+ /** A stream route rather than a unary one: the answer is written as it arrives,
6580
+ * so the deadline stays where the work is — `ASK_TIMEOUT_MS` inside the
6581
+ * service, fused there with the disconnect signal — instead of a route budget
6582
+ * that would cut a legitimate long answer off mid-sentence. */
5929
6583
  function registerAskRoute(ctx, service, cwdForSession, preferencesFor) {
5930
- ctx.effect(() => ctx.webServer.register({
6584
+ registerPluginRoute(ctx, {
6585
+ mode: "stream",
5931
6586
  kind: "exact",
5932
6587
  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");
6588
+ methods: ["POST"],
6589
+ maxConcurrent: MAX_CONCURRENT_ASKS,
6590
+ streamKey: (url) => url.searchParams.get("sessionId") ?? "",
6591
+ handler: async (res, io) => {
5937
6592
  let cwd;
5938
6593
  let request;
5939
6594
  let sessionId;
5940
6595
  try {
5941
- const value = url.searchParams.get("sessionId");
6596
+ const value = io.url.searchParams.get("sessionId");
5942
6597
  if (value === null || value.length === 0 || value.length > MAX_SESSION_ID_CHARS$2) throw new AskError("invalid-session", "The session is invalid.");
5943
6598
  sessionId = value;
5944
6599
  const resolved = cwdForSession(sessionId);
5945
6600
  if (resolved === void 0) throw new AskError("session-unavailable", "The Claude session is unavailable.");
5946
6601
  cwd = resolved;
5947
- request = askRequest(await readJson$2(req));
6602
+ const body = record$2(await io.body(MAX_BODY_BYTES$2));
6603
+ if (body === void 0) throw new AskError("invalid-request", "The request body is invalid.");
6604
+ request = askRequest(body);
5948
6605
  } catch (error) {
5949
6606
  if (error instanceof AskError) return json(res, 409, {
5950
6607
  error: error.code,
@@ -5962,17 +6619,13 @@ function registerAskRoute(ctx, service, cwdForSession, preferencesFor) {
5962
6619
  "x-content-type-options": "nosniff"
5963
6620
  });
5964
6621
  res.flushHeaders?.();
5965
- const controller = new AbortController();
5966
- req.on("close", () => {
5967
- controller.abort();
5968
- });
5969
6622
  try {
5970
6623
  await service.ask(cwd, request, preferencesFor(sessionId) ?? {}, (event) => {
5971
6624
  ndjson(res, event.type === "text" ? {
5972
6625
  type: "delta",
5973
6626
  text: event.text
5974
6627
  } : event);
5975
- }, controller.signal);
6628
+ }, io.signal);
5976
6629
  ndjson(res, { type: "done" });
5977
6630
  } catch (error) {
5978
6631
  ndjson(res, {
@@ -5980,11 +6633,9 @@ function registerAskRoute(ctx, service, cwdForSession, preferencesFor) {
5980
6633
  code: error instanceof AskError ? error.code : "ask-unavailable",
5981
6634
  message: error instanceof AskError ? error.message : "The question could not be answered."
5982
6635
  });
5983
- } finally {
5984
- res.end();
5985
6636
  }
5986
6637
  }
5987
- }), "dsh-claude: ask route");
6638
+ });
5988
6639
  }
5989
6640
  //#endregion
5990
6641
  //#region src/review-comment-routes.ts
@@ -5993,16 +6644,15 @@ const MAX_SESSION_ID_CHARS$1 = 1024;
5993
6644
  function record$1(value) {
5994
6645
  return value !== null && typeof value === "object" && !Array.isArray(value) ? value : void 0;
5995
6646
  }
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);
6647
+ async function readJson$1(io) {
6648
+ let parsed;
6649
+ try {
6650
+ parsed = await io.body(MAX_BODY_BYTES$1);
6651
+ } catch (error) {
6652
+ if (error instanceof SyntaxError) throw error;
6653
+ throw new ReviewCommentError("body-too-large", "The request body is too large.");
6004
6654
  }
6005
- const value = record$1(JSON.parse(Buffer.concat(chunks).toString("utf8")));
6655
+ const value = record$1(parsed);
6006
6656
  if (value === void 0) throw new ReviewCommentError("invalid-request", "The request body is invalid.");
6007
6657
  return value;
6008
6658
  }
@@ -6012,45 +6662,68 @@ function sessionIdFromUrl(url) {
6012
6662
  return value;
6013
6663
  }
6014
6664
  function registerReviewCommentRoute(ctx, store, ownsSession) {
6015
- ctx.effect(() => ctx.webServer.register({
6665
+ registerPluginRoute(ctx, {
6666
+ mode: "unary",
6016
6667
  kind: "prefix",
6017
6668
  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");
6669
+ methods: ["POST"],
6670
+ budget: "fast",
6671
+ handler: async (io) => {
6021
6672
  try {
6022
- const sessionId = sessionIdFromUrl(url);
6673
+ const sessionId = sessionIdFromUrl(io.url);
6023
6674
  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
- }) });
6675
+ const pathname = io.url.pathname;
6676
+ if (pathname === "/plugins/dsh-claude/review-comments") {
6677
+ const input = await readJson$1(io);
6678
+ return {
6679
+ status: 200,
6680
+ value: { comment: store.add(sessionId, {
6681
+ path: input.path,
6682
+ line: input.line,
6683
+ startLine: input.startLine,
6684
+ side: input.side,
6685
+ text: input.text
6686
+ }) }
6687
+ };
6033
6688
  }
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);
6689
+ if (pathname === `/plugins/dsh-claude/review-comments/clear`) return {
6690
+ status: 200,
6691
+ value: { removed: store.drain(sessionId).length }
6692
+ };
6693
+ if (pathname === `/plugins/dsh-claude/review-comments/remove`) {
6694
+ const input = await readJson$1(io);
6037
6695
  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) });
6696
+ return {
6697
+ status: 200,
6698
+ value: { removed: store.remove(sessionId, input.id) }
6699
+ };
6039
6700
  }
6040
- return json(res, 404, { error: "not found" });
6701
+ return {
6702
+ status: 404,
6703
+ value: { error: "not found" }
6704
+ };
6041
6705
  } 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
- });
6706
+ if (error instanceof ReviewCommentError) return {
6707
+ status: 409,
6708
+ value: {
6709
+ error: error.code,
6710
+ message: error.message
6711
+ }
6712
+ };
6713
+ if (error instanceof SyntaxError) return {
6714
+ status: 400,
6715
+ value: { error: "invalid-json" }
6716
+ };
6717
+ return {
6718
+ status: 500,
6719
+ value: {
6720
+ error: "review-comment-unavailable",
6721
+ message: "Review comments are unavailable."
6722
+ }
6723
+ };
6051
6724
  }
6052
6725
  }
6053
- }), "dsh-claude: review comment route");
6726
+ });
6054
6727
  }
6055
6728
  //#endregion
6056
6729
  //#region src/client-diagnostics-routes.ts
@@ -6058,17 +6731,6 @@ function registerReviewCommentRoute(ctx, store, ownsSession) {
6058
6731
  const MAX_DIAGNOSTIC_BYTES = 8192;
6059
6732
  const MAX_DETAIL_CHARS = 2e3;
6060
6733
  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
6734
  /** `POST <path>` with `{ kind, detail }`: write one renderer finding to the Host log.
6073
6735
  *
6074
6736
  * The renderer has no other way to speak. A Slot entry that throws is caught
@@ -6078,25 +6740,38 @@ async function readBody(req) {
6078
6740
  * Every finding here is data written by this package's own client half, but
6079
6741
  * it is still bounded and redacted like any other untrusted input. */
6080
6742
  function registerClaudeClientDiagnosticsRoute(ctx) {
6081
- ctx.effect(() => ctx.webServer.register({
6743
+ registerPluginRoute(ctx, {
6744
+ mode: "unary",
6082
6745
  kind: "exact",
6083
6746
  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" });
6747
+ methods: ["POST"],
6748
+ budget: "fast",
6749
+ handler: async (io) => {
6087
6750
  try {
6088
- const body = await readBody(req);
6751
+ const body = await io.body(MAX_DIAGNOSTIC_BYTES);
6089
6752
  const kind = typeof body?.kind === "string" ? body.kind.slice(0, MAX_KIND_CHARS) : "unknown";
6090
6753
  const detail = typeof body?.detail === "string" ? body.detail : "";
6091
- if (detail === "") return json(res, 400, { error: "invalid-request" });
6754
+ if (detail === "") return {
6755
+ status: 400,
6756
+ value: { error: "invalid-request" }
6757
+ };
6092
6758
  ctx.logger.warn(`dsh-claude client [${kind}]: ${redactText(detail, MAX_DETAIL_CHARS)}`);
6093
- return json(res, 200, { ok: true });
6759
+ return {
6760
+ status: 200,
6761
+ value: { ok: true }
6762
+ };
6094
6763
  } catch (error) {
6095
- if (error instanceof SyntaxError) return json(res, 400, { error: "invalid-json" });
6096
- return json(res, 400, { error: "invalid-request" });
6764
+ if (error instanceof SyntaxError) return {
6765
+ status: 400,
6766
+ value: { error: "invalid-json" }
6767
+ };
6768
+ return {
6769
+ status: 400,
6770
+ value: { error: "invalid-request" }
6771
+ };
6097
6772
  }
6098
6773
  }
6099
- }), "dsh-claude: client diagnostics route");
6774
+ });
6100
6775
  }
6101
6776
  //#endregion
6102
6777
  //#region src/rewind-routes.ts
@@ -6105,55 +6780,78 @@ const MAX_SESSION_ID_CHARS = 1024;
6105
6780
  function record(value) {
6106
6781
  return value !== null && typeof value === "object" && !Array.isArray(value) ? value : void 0;
6107
6782
  }
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);
6783
+ /** An oversized body fails the same field checks as a missing one; only
6784
+ * malformed JSON is worth reporting separately. */
6785
+ async function readJson(io) {
6786
+ try {
6787
+ return record(await io.body(MAX_BODY_BYTES));
6788
+ } catch (error) {
6789
+ if (error instanceof SyntaxError) throw error;
6790
+ return;
6116
6791
  }
6117
- return record(JSON.parse(Buffer.concat(chunks).toString("utf8")));
6118
6792
  }
6119
6793
  /** `POST <path>` with `{ sessionId, seq }`: hide that surface event and every
6120
6794
  * later one, and arm Claude to resume before the turn it opened. */
6121
6795
  function registerClaudeRewindRoute(ctx, sidecar, access) {
6122
- ctx.effect(() => ctx.webServer.register({
6796
+ registerPluginRoute(ctx, {
6797
+ mode: "unary",
6123
6798
  kind: "exact",
6124
6799
  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" });
6800
+ methods: ["POST"],
6801
+ budget: "git",
6802
+ handler: async (io) => {
6128
6803
  try {
6129
- const input = await readJson(req);
6804
+ const input = await readJson(io);
6130
6805
  const sessionId = input?.sessionId;
6131
6806
  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" });
6807
+ if (typeof sessionId !== "string" || sessionId.length === 0 || sessionId.length > MAX_SESSION_ID_CHARS || typeof seq !== "number" || !Number.isSafeInteger(seq) || seq < 0) return {
6808
+ status: 400,
6809
+ value: { error: "invalid-request" }
6810
+ };
6133
6811
  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" });
6812
+ if (events === void 0) return {
6813
+ status: 409,
6814
+ value: { error: "session-unavailable" }
6815
+ };
6816
+ if (access.busy(sessionId)) return {
6817
+ status: 409,
6818
+ value: { error: "session-busy" }
6819
+ };
6136
6820
  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" });
6821
+ if (planned === void 0) return {
6822
+ status: 409,
6823
+ value: { error: "seq-unavailable" }
6824
+ };
6138
6825
  await sidecar.writeRewind(sessionId, planned);
6139
6826
  await access.reset(sessionId);
6140
- return json(res, 200, { ranges: planned.ranges });
6827
+ return {
6828
+ status: 200,
6829
+ value: { ranges: planned.ranges }
6830
+ };
6141
6831
  } catch (error) {
6142
- if (error instanceof SyntaxError) return json(res, 400, { error: "invalid-json" });
6143
- return json(res, 500, { error: "rewind-unavailable" });
6832
+ if (error instanceof SyntaxError) return {
6833
+ status: 400,
6834
+ value: { error: "invalid-json" }
6835
+ };
6836
+ return {
6837
+ status: 500,
6838
+ value: { error: "rewind-unavailable" }
6839
+ };
6144
6840
  }
6145
6841
  }
6146
- }), "dsh-claude: rewind route");
6842
+ });
6147
6843
  }
6148
6844
  //#endregion
6149
6845
  //#region src/update-routes.ts
6150
6846
  const PLUGIN_PACKAGE_NAME = "@norman-else/dsh-claude";
6151
- const UPDATE_TIMEOUT_MS = 3e4;
6152
- const CHECK_TIMEOUT_MS = 1e4;
6153
6847
  const MAX_MANIFEST_BYTES = 262144;
6154
6848
  const MAX_UPDATE_OUTPUT_BYTES = 32768;
6155
6849
  const SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/;
6156
6850
  const PROFILE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
6851
+ /** The route owns the deadline now. A direct caller with nothing to cancel
6852
+ * against gets a signal that never fires, rather than a second timeout
6853
+ * competing with the budget the route already declared. */
6854
+ const NEVER_ABORTS = new AbortController().signal;
6157
6855
  function safeMessage$1(error) {
6158
6856
  return redactText(error instanceof Error ? error.message : String(error), 500);
6159
6857
  }
@@ -6272,7 +6970,7 @@ async function packageContext(deps) {
6272
6970
  ...installation === void 0 ? {} : { installation }
6273
6971
  };
6274
6972
  }
6275
- async function checkPluginUpdate(deps = {}) {
6973
+ async function checkPluginUpdate(deps = {}, signal = NEVER_ABORTS) {
6276
6974
  try {
6277
6975
  const { version, installation } = await packageContext(deps);
6278
6976
  if (installation === void 0) return {
@@ -6299,7 +6997,7 @@ async function checkPluginUpdate(deps = {}) {
6299
6997
  restartRequired: false,
6300
6998
  message: "This installation source cannot be updated from the npm registry"
6301
6999
  };
6302
- const latest = await (deps.fetchLatest ?? registryLatest)(AbortSignal.timeout(CHECK_TIMEOUT_MS));
7000
+ const latest = await (deps.fetchLatest ?? registryLatest)(signal);
6303
7001
  const comparison = compareVersions(version, latest);
6304
7002
  return {
6305
7003
  currentVersion: version,
@@ -6325,10 +7023,10 @@ async function verifyInstalledVersion(installation, expectedVersion) {
6325
7023
  const installedManifest = await readManifest(join(installation.profileDir, "node_modules", ...PLUGIN_PACKAGE_NAME.split("/"), "package.json"));
6326
7024
  if (installedManifest.name !== "@norman-else/dsh-claude" || installedManifest.version !== expectedVersion) throw new Error("DSH plugin update completed without installing the requested version");
6327
7025
  }
6328
- async function updatePlugin(deps = {}) {
7026
+ async function updatePlugin(deps = {}, signal = NEVER_ABORTS) {
6329
7027
  const { version, installation } = await packageContext(deps);
6330
7028
  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));
7029
+ const latest = await (deps.fetchLatest ?? registryLatest)(signal);
6332
7030
  if (compareVersions(version, latest) >= 0) return {
6333
7031
  currentVersion: version,
6334
7032
  latestVersion: latest,
@@ -6340,7 +7038,6 @@ async function updatePlugin(deps = {}) {
6340
7038
  const resolveExecutable = deps.resolveExecutable;
6341
7039
  const spawn = deps.spawn;
6342
7040
  if (resolveExecutable === void 0 || spawn === void 0) throw new Error("DSH update runtime is unavailable");
6343
- const signal = AbortSignal.timeout(UPDATE_TIMEOUT_MS);
6344
7041
  const handle = spawn({
6345
7042
  argv: [
6346
7043
  await resolveExecutable("dsh", {}, signal),
@@ -6383,27 +7080,35 @@ function registerClaudeUpdateRoutes(ctx, runtime, deps = {}) {
6383
7080
  resolveExecutable: runtime.resolveExecutable.bind(runtime),
6384
7081
  spawn: runtime.spawn.bind(runtime)
6385
7082
  };
6386
- for (const route of [{
7083
+ const routes = [{
6387
7084
  path: CLAUDE_UPDATE_CHECK_PATH,
6388
7085
  method: "GET",
6389
- run: () => checkPluginUpdate(shared)
7086
+ run: (signal) => checkPluginUpdate(shared, signal)
6390
7087
  }, {
6391
7088
  path: CLAUDE_UPDATE_PATH,
6392
7089
  method: "POST",
6393
- run: () => updatePlugin(shared)
6394
- }]) ctx.effect(() => ctx.webServer.register({
7090
+ run: (signal) => updatePlugin(shared, signal)
7091
+ }];
7092
+ for (const route of routes) registerPluginRoute(ctx, {
7093
+ mode: "unary",
6395
7094
  kind: "exact",
6396
7095
  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" });
7096
+ methods: [route.method],
7097
+ budget: "remote",
7098
+ handler: async (io) => {
6400
7099
  try {
6401
- json(res, 200, await route.run());
7100
+ return {
7101
+ status: 200,
7102
+ value: await route.run(io.signal)
7103
+ };
6402
7104
  } catch (error) {
6403
- json(res, 500, { error: safeMessage$1(error) });
7105
+ return {
7106
+ status: 500,
7107
+ value: { error: safeMessage$1(error) }
7108
+ };
6404
7109
  }
6405
7110
  }
6406
- }), `dsh-claude: ${route.method} ${route.path}`);
7111
+ });
6407
7112
  }
6408
7113
  //#endregion
6409
7114
  //#region src/plan-usage-routes.ts
@@ -6420,22 +7125,32 @@ function safeMessage(error) {
6420
7125
  * throwaway probe process so a refresh never depends on, or disturbs, a live
6421
7126
  * Claude session. */
6422
7127
  function registerPlanUsageRoute(ctx, probe, now = Date.now) {
6423
- ctx.effect(() => ctx.webServer.register({
7128
+ registerPluginRoute(ctx, {
7129
+ mode: "unary",
6424
7130
  kind: "exact",
6425
7131
  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);
7132
+ methods: ["GET", "POST"],
7133
+ budget: "git",
7134
+ handler: async (io) => {
7135
+ if (io.method === "GET") return {
7136
+ status: 200,
7137
+ value: latestPlanUsage() ?? EMPTY
7138
+ };
6430
7139
  try {
6431
7140
  const report = await probe(now());
6432
7141
  recordPlanUsage(report);
6433
- json(res, 200, report);
7142
+ return {
7143
+ status: 200,
7144
+ value: report
7145
+ };
6434
7146
  } catch (error) {
6435
- json(res, 500, { error: safeMessage(error) });
7147
+ return {
7148
+ status: 500,
7149
+ value: { error: safeMessage(error) }
7150
+ };
6436
7151
  }
6437
7152
  }
6438
- }), "dsh-claude: plan usage route");
7153
+ });
6439
7154
  }
6440
7155
  //#endregion
6441
7156
  //#region src/global-settings.ts
@@ -6564,6 +7279,57 @@ const WORKTREE_BRANCH_PREFIX = {
6564
7279
  else document.worktreeBranchPrefix = value;
6565
7280
  }
6566
7281
  };
7282
+ /** Which renderer draws Claude's visible output. Plugin settings, not Claude's:
7283
+ * the CLI has no opinion about how DSH paints a turn. The option labels stay
7284
+ * machine-readable ids; the Client translates the two known values. */
7285
+ const RENDERER = {
7286
+ key: "renderer",
7287
+ kind: "select",
7288
+ document: "plugin",
7289
+ effect: "next-turn",
7290
+ async options() {
7291
+ return CLAUDE_RENDER_MODES.map((value) => ({
7292
+ value,
7293
+ label: value,
7294
+ source: "built-in"
7295
+ }));
7296
+ },
7297
+ read(document) {
7298
+ const value = document.renderer;
7299
+ return isClaudeRenderMode(value) ? value : DEFAULT_CLAUDE_RENDER_MODE;
7300
+ },
7301
+ apply(document, value) {
7302
+ if (!isClaudeRenderMode(value)) throw new Error("Invalid value for global setting renderer");
7303
+ if (value === "plugin") delete document.renderer;
7304
+ else document.renderer = value;
7305
+ }
7306
+ };
7307
+ /** Whether Claude's prose gets the highlight palette. Presentation only: no
7308
+ * record carries it and nothing on the server reads it back, so unlike
7309
+ * {@link RENDERER} the switch lands the moment the Client rewrites its own
7310
+ * stylesheet — hence 'immediate' rather than 'next-turn'. */
7311
+ const PROSE = {
7312
+ key: "prose",
7313
+ kind: "select",
7314
+ document: "plugin",
7315
+ effect: "immediate",
7316
+ async options() {
7317
+ return CLAUDE_PROSE_MODES.map((value) => ({
7318
+ value,
7319
+ label: value,
7320
+ source: "built-in"
7321
+ }));
7322
+ },
7323
+ read(document) {
7324
+ const value = document.prose;
7325
+ return isClaudeProseMode(value) ? value : DEFAULT_CLAUDE_PROSE_MODE;
7326
+ },
7327
+ apply(document, value) {
7328
+ if (!isClaudeProseMode(value)) throw new Error("Invalid value for global setting prose");
7329
+ if (value === "plain") delete document.prose;
7330
+ else document.prose = value;
7331
+ }
7332
+ };
6567
7333
  function isBoundedInteger(value, min, max) {
6568
7334
  return typeof value === "number" && Number.isInteger(value) && value >= min && value <= max;
6569
7335
  }
@@ -6587,6 +7353,8 @@ function integerSetting(key, min, max, defaultFor) {
6587
7353
  }
6588
7354
  const DESCRIPTORS = [
6589
7355
  OUTPUT_STYLE,
7356
+ RENDERER,
7357
+ PROSE,
6590
7358
  WORKTREE_BRANCH_PREFIX,
6591
7359
  integerSetting("maxProcesses", 1, MAX_PROCESSES_LIMIT, (limits) => limits.maxProcesses),
6592
7360
  integerSetting("idleTimeoutMinutes", 1, MAX_IDLE_TIMEOUT_MINUTES, (limits) => Math.max(1, Math.round(limits.idleTimeoutMs / 6e4)))
@@ -6648,6 +7416,17 @@ async function readSupervisorLimitOverrides(deps = {}) {
6648
7416
  ...isBoundedInteger(idleTimeoutMinutes, 1, MAX_IDLE_TIMEOUT_MINUTES) ? { idleTimeoutMs: idleTimeoutMinutes * 6e4 } : {}
6649
7417
  };
6650
7418
  }
7419
+ /** The renderer the Host should produce output for. A missing, unreadable, or
7420
+ * malformed plugin settings file keeps today's plugin-owned transcript. */
7421
+ async function readRenderMode(deps = {}) {
7422
+ let document;
7423
+ try {
7424
+ document = await readDocument(pathsFor(deps).pluginSettingsFile);
7425
+ } catch {
7426
+ return DEFAULT_CLAUDE_RENDER_MODE;
7427
+ }
7428
+ return isClaudeRenderMode(document.renderer) ? document.renderer : DEFAULT_CLAUDE_RENDER_MODE;
7429
+ }
6651
7430
  async function readWorktreeBranchPrefix(deps = {}) {
6652
7431
  const paths = pathsFor(deps);
6653
7432
  return WORKTREE_BRANCH_PREFIX.read(await readDocument(paths.pluginSettingsFile));
@@ -6693,37 +7472,34 @@ function updateGlobalSettings(changes, deps = {}) {
6693
7472
  pendingWrite = operation;
6694
7473
  return operation;
6695
7474
  }
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")));
7475
+ async function requestJson(io) {
7476
+ const body = object(await io.body(MAX_REQUEST_BYTES));
6708
7477
  if (body === void 0 || Object.keys(body).some((key) => key !== "changes")) throw new Error("Invalid global settings request");
6709
7478
  return body.changes;
6710
7479
  }
6711
7480
  function registerClaudeGlobalSettingsRoute(ctx, deps = {}) {
6712
- ctx.effect(() => ctx.webServer.register({
7481
+ registerPluginRoute(ctx, {
7482
+ mode: "unary",
6713
7483
  kind: "exact",
6714
7484
  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" });
7485
+ methods: ["GET", "PATCH"],
7486
+ budget: "fast",
7487
+ handler: async (io) => {
6718
7488
  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);
7489
+ const result = io.method === "GET" ? await readGlobalSettings(deps) : await updateGlobalSettings(await requestJson(io), deps);
7490
+ if (io.method === "PATCH") await deps.onUpdated?.();
7491
+ return {
7492
+ status: 200,
7493
+ value: result
7494
+ };
6722
7495
  } catch (error) {
6723
- json(res, 400, { error: error instanceof Error ? error.message : "Invalid global settings request" });
7496
+ return {
7497
+ status: 400,
7498
+ value: { error: error instanceof Error ? error.message : "Invalid global settings request" }
7499
+ };
6724
7500
  }
6725
7501
  }
6726
- }), "dsh-claude: global settings");
7502
+ });
6727
7503
  }
6728
7504
  //#endregion
6729
7505
  //#region src/index.ts
@@ -6862,14 +7638,16 @@ async function apply(ctx, config) {
6862
7638
  const supervisorConfig = {
6863
7639
  executablePath: "",
6864
7640
  defaultModel: config.model ?? "default",
7641
+ renderMode: DEFAULT_CLAUDE_RENDER_MODE,
6865
7642
  ...defaultLimits
6866
7643
  };
6867
- const applyLimitOverrides = async () => {
7644
+ const applySettingsOverrides = async () => {
6868
7645
  const overrides = await readSupervisorLimitOverrides();
6869
7646
  supervisorConfig.idleTimeoutMs = overrides.idleTimeoutMs ?? defaultLimits.idleTimeoutMs;
6870
7647
  supervisorConfig.maxProcesses = overrides.maxProcesses ?? defaultLimits.maxProcesses;
7648
+ supervisorConfig.renderMode = await readRenderMode();
6871
7649
  };
6872
- await applyLimitOverrides();
7650
+ await applySettingsOverrides();
6873
7651
  const sidecar = new ClaudeSidecarRepository();
6874
7652
  const repositoryStatus = new RepositoryStatusService(ctx.subprocess);
6875
7653
  const repositorySetup = new RepositorySetupService(ctx.subprocess, { branchPrefix: () => readWorktreeBranchPrefix() });
@@ -6886,7 +7664,7 @@ async function apply(ctx, config) {
6886
7664
  let resolutionError;
6887
7665
  try {
6888
7666
  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)));
7667
+ 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
7668
  ctx.effect(() => {
6891
7669
  const mounted = /* @__PURE__ */ new Map();
6892
7670
  const pending = /* @__PURE__ */ new Set();
@@ -6975,7 +7753,7 @@ async function apply(ctx, config) {
6975
7753
  registerClaudeUpdateRoutes(webCtx, webCtx.subprocess, { ...typeof desktopActions?.requestRestart === "function" ? { requestRestart: desktopActions.requestRestart.bind(desktopActions) } : {} });
6976
7754
  registerClaudeGlobalSettingsRoute(webCtx, {
6977
7755
  defaultLimits,
6978
- onUpdated: applyLimitOverrides
7756
+ onUpdated: applySettingsOverrides
6979
7757
  });
6980
7758
  registerRepositorySetupRoute(webCtx, repositorySetup);
6981
7759
  registerRepositoryStatusRoute(webCtx, repositoryStatus);