@norman-else/dsh-claude 0.1.34 → 0.1.35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -27,6 +27,9 @@ window.__ModuleLoader__.load({
27
27
  const CLAUDE_ASK_PATH = "/plugins/dsh-claude/ask";
28
28
  const CLAUDE_EDITOR_OPEN_PATH = "/plugins/dsh-claude/editor/open";
29
29
  const CLAUDE_REWIND_PATH = "/plugins/dsh-claude/rewind";
30
+ function isClaudeRenderMode(value) {
31
+ return value === "plugin" || value === "native";
32
+ }
30
33
  //#endregion
31
34
  //#region src/client/task-projection.ts
32
35
  /** Tasks UI is reserved for detached work and genuine Claude subagents. */
@@ -239,7 +242,7 @@ window.__ModuleLoader__.load({
239
242
  case "WebFetch": {
240
243
  const url = inputString(input, "url") ?? "a web page";
241
244
  completed = `Fetched ${url}`;
242
- failedAction = `fetch ${url}`;
245
+ failedAction = `load ${url}`;
243
246
  break;
244
247
  }
245
248
  case "WebSearch":
@@ -267,8 +270,20 @@ window.__ModuleLoader__.load({
267
270
  }
268
271
  return failed ? `Failed to ${failedAction}` : completed;
269
272
  }
273
+ /** Whether the Host drew this step with DSH's own renderer.
274
+ *
275
+ * The stamp rides on the records themselves rather than on a Client-side copy
276
+ * of the setting, because the two can disagree: the Host switches on the next
277
+ * turn while a running Client keeps whatever it decided at boot, and the
278
+ * failure mode of disagreeing is drawing every step twice. Reading it back per
279
+ * step also keeps history honest in both directions -- a turn recorded under
280
+ * one renderer keeps it after the setting changes. */
281
+ function nativelyRenderedStep(activities, turn, step) {
282
+ return activities.some((activity) => activity.turn === turn && activity.step === step && activity.renderer === "native");
283
+ }
270
284
  /** Fold one step's shared ordinal stream into Claude Code-style prose and tool groups. */
271
285
  function transcriptItemsForStep(activities, turn, step, tasks = []) {
286
+ if (nativelyRenderedStep(activities, turn, step)) return [];
272
287
  const ordered = activities.filter((activity) => activity.turn === turn && activity.step === step && isProjectedTaskActivity(activity, tasks)).slice().sort((left, right) => left.ordinal - right.ordinal);
273
288
  const rows = foldedRows(ordered, tasks);
274
289
  const placed = /* @__PURE__ */ new Set();
@@ -674,30 +689,65 @@ window.__ModuleLoader__.load({
674
689
  fontSize: 14,
675
690
  lineHeight: "22px"
676
691
  };
677
- const settingSelectTrigger = {
678
- width: "100%",
679
- minHeight: 38,
680
- display: "flex",
681
- alignItems: "center",
682
- justifyContent: "space-between",
683
- gap: 12,
684
- padding: "7px 11px 7px 13px",
685
- border: "1px solid var(--dsw-alias-border-l2, color-mix(in srgb, currentColor 16%, transparent))",
686
- borderRadius: 10,
687
- background: "var(--dsw-alias-bg-layer-2)",
688
- color: "var(--dsw-alias-label-primary)",
689
- boxShadow: "0 1px 2px color-mix(in srgb, var(--dsw-alias-label-primary) 5%, transparent)",
690
- font: "inherit",
691
- fontSize: 14,
692
- lineHeight: "22px",
693
- textAlign: "left",
694
- cursor: "pointer",
695
- transition: "border-color 120ms ease, box-shadow 120ms ease, background 120ms ease"
696
- };
697
- const settingSelectTriggerOpen = {
698
- borderColor: "var(--dsw-static-blue-450)",
699
- boxShadow: "0 0 0 3px color-mix(in srgb, var(--dsw-static-blue-450) 15%, transparent)"
700
- };
692
+ const settingSelectTriggerClass = "dshClaudeSettingSelectTrigger";
693
+ const settingSelectChevronClass = "dshClaudeSettingSelectChevron";
694
+ /**
695
+ * The listbox trigger and its chevron, in a stylesheet rather than inline
696
+ * because the states it has to draw cannot be expressed inline.
697
+ *
698
+ * Focus is the reason. An inline style carries no pseudo-class, so the blue
699
+ * ring used to ride on the open state alone: choosing an option closed the
700
+ * menu and took the ring with it while the button kept DOM focus, leaving the
701
+ * UA's own focus ring -- white on a dark theme, drawn outside the radius --
702
+ * as the only indicator. Focus and open are separate states; both draw the
703
+ * ring here, and `outline: none` retires the UA's.
704
+ *
705
+ * The open state reads `aria-expanded` instead of a second class because the
706
+ * attribute is already on the element and already correct. Its value is left
707
+ * unquoted: a valid identifier needs no quotes, and the sheet then survives
708
+ * React's server escaping, which would otherwise write `"` into markup a
709
+ * browser reads as raw text.
710
+ */
711
+ const settingSelectCss = `
712
+ .${settingSelectTriggerClass} {
713
+ width: 100%;
714
+ min-height: 38px;
715
+ display: flex;
716
+ align-items: center;
717
+ justify-content: space-between;
718
+ gap: 12px;
719
+ padding: 7px 11px 7px 13px;
720
+ border: 1px solid var(--dsw-alias-border-l2, color-mix(in srgb, currentColor 16%, transparent));
721
+ border-radius: 10px;
722
+ background: var(--dsw-alias-bg-layer-2);
723
+ color: var(--dsw-alias-label-primary);
724
+ box-shadow: 0 1px 2px color-mix(in srgb, var(--dsw-alias-label-primary) 5%, transparent);
725
+ font: inherit;
726
+ font-size: 14px;
727
+ line-height: 22px;
728
+ text-align: left;
729
+ cursor: pointer;
730
+ transition: border-color 120ms ease, box-shadow 120ms ease, background 120ms ease;
731
+ }
732
+ .${settingSelectTriggerClass}:focus-visible,
733
+ .${settingSelectTriggerClass}[aria-expanded=true] {
734
+ outline: none;
735
+ border-color: var(--dsw-static-blue-450);
736
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--dsw-static-blue-450) 15%, transparent);
737
+ }
738
+ .${settingSelectChevronClass} {
739
+ flex: none;
740
+ display: grid;
741
+ place-items: center;
742
+ width: 16px;
743
+ height: 16px;
744
+ color: var(--dsw-alias-label-tertiary);
745
+ transition: transform 120ms ease;
746
+ }
747
+ .${settingSelectTriggerClass}[aria-expanded=true] .${settingSelectChevronClass} {
748
+ transform: rotate(180deg);
749
+ }
750
+ `;
701
751
  const settingSelectValue = {
702
752
  minWidth: 0,
703
753
  flex: 1,
@@ -705,15 +755,6 @@ window.__ModuleLoader__.load({
705
755
  textOverflow: "ellipsis",
706
756
  whiteSpace: "nowrap"
707
757
  };
708
- const settingSelectChevron = {
709
- flex: "none",
710
- color: "var(--dsw-alias-label-tertiary)",
711
- fontSize: 16,
712
- lineHeight: 1,
713
- transform: "translateY(-1px)",
714
- transition: "transform 120ms ease"
715
- };
716
- const settingSelectChevronOpen = { transform: "translateY(1px) rotate(180deg)" };
717
758
  const settingSelectMenu = {
718
759
  position: "absolute",
719
760
  zIndex: 20,
@@ -3736,6 +3777,203 @@ window.__ModuleLoader__.load({
3736
3777
  });
3737
3778
  }
3738
3779
  //#endregion
3780
+ //#region src/client/markdown-theme.ts
3781
+ /** Claude Code's code presentation over the Host's Markdown renderer.
3782
+ *
3783
+ * This package renders no Markdown of its own — prose, fenced blocks and the
3784
+ * copy affordance all come from the Host's `MarkdownText` primitive. What it
3785
+ * can own is what that primitive reads: the palette (custom properties) and
3786
+ * the block's chrome (CSS over the primitive's own markup).
3787
+ *
3788
+ * PARITY IS PARTIAL BY CONSTRUCTION. Both renderers highlight with shiki, but
3789
+ * Claude Code's desktop build loads a full TextMate theme (Pierre Dark /
3790
+ * Pierre Light Soft: 248 tokenColor rules over 424 scopes) and bakes the
3791
+ * resolved colour into every span, while the Host loads shiki's legacy
3792
+ * `css-variables` theme, which collapses every scope into the eleven buckets
3793
+ * below. Those eleven carry the colours that dominate a code block —
3794
+ * keywords, strings, comments, functions, numbers — and nothing here can
3795
+ * recover the rest: `constant.numeric` and `constant` are two different
3796
+ * colours in Pierre and one bucket here, and Pierre's string-coloured string
3797
+ * delimiters share this sheet's single punctuation bucket. Matching the rest
3798
+ * means this package running its own shiki, which is a different decision
3799
+ * with a different cost.
3800
+ *
3801
+ * Every rule fails open the way `host-chrome` does: the palette is
3802
+ * declarations on this package's own wrapper, and the chrome rules match the
3803
+ * primitive's CSS Module local names, so a Host that renames either simply
3804
+ * stops matching and its stock presentation comes back.
3805
+ */
3806
+ /** Wrapper class carrying the palette and scoping the chrome rules.
3807
+ * `display:contents` so the extra element generates no box — custom
3808
+ * properties inherit through it regardless. */
3809
+ const CLAUDE_MARKDOWN_SCOPE = "dsh-claude-markdown";
3810
+ /** Pierre mapped onto the Host's eleven buckets. Each entry names the theme
3811
+ * scope the value was taken from, because the mapping — not the colour — is
3812
+ * the part a reader has to check. */
3813
+ const PIERRE_DARK = [
3814
+ [
3815
+ "--shiki-background",
3816
+ "#1a1a19",
3817
+ "UI surface (--cds-surface-2)"
3818
+ ],
3819
+ [
3820
+ "--shiki-foreground",
3821
+ "#fafafa",
3822
+ "editor.foreground"
3823
+ ],
3824
+ [
3825
+ "--shiki-token-comment",
3826
+ "#737373",
3827
+ "comment"
3828
+ ],
3829
+ [
3830
+ "--shiki-token-keyword",
3831
+ "#ff678d",
3832
+ "keyword, storage.type"
3833
+ ],
3834
+ [
3835
+ "--shiki-token-string",
3836
+ "#5ecc71",
3837
+ "string"
3838
+ ],
3839
+ [
3840
+ "--shiki-token-string-expression",
3841
+ "#ffa359",
3842
+ "punctuation.section.embedded"
3843
+ ],
3844
+ [
3845
+ "--shiki-token-function",
3846
+ "#9d6afb",
3847
+ "entity.name.function"
3848
+ ],
3849
+ [
3850
+ "--shiki-token-constant",
3851
+ "#68cdf2",
3852
+ "constant.numeric, constant.language"
3853
+ ],
3854
+ [
3855
+ "--shiki-token-parameter",
3856
+ "#a3a3a3",
3857
+ "variable.parameter"
3858
+ ],
3859
+ [
3860
+ "--shiki-token-punctuation",
3861
+ "#636363",
3862
+ "punctuation"
3863
+ ],
3864
+ [
3865
+ "--shiki-token-link",
3866
+ "#ff678d",
3867
+ "markup.underline.link.markdown"
3868
+ ]
3869
+ ];
3870
+ const PIERRE_LIGHT = [
3871
+ [
3872
+ "--shiki-background",
3873
+ "#ffffff",
3874
+ "UI surface (--cds-surface-2)"
3875
+ ],
3876
+ [
3877
+ "--shiki-foreground",
3878
+ "#525252",
3879
+ "editor.foreground"
3880
+ ],
3881
+ [
3882
+ "--shiki-token-comment",
3883
+ "#8a8a8a",
3884
+ "comment"
3885
+ ],
3886
+ [
3887
+ "--shiki-token-keyword",
3888
+ "#ff678d",
3889
+ "keyword, storage.type"
3890
+ ],
3891
+ [
3892
+ "--shiki-token-string",
3893
+ "#0dbe4e",
3894
+ "string"
3895
+ ],
3896
+ [
3897
+ "--shiki-token-string-expression",
3898
+ "#fe8c2c",
3899
+ "punctuation.section.embedded"
3900
+ ],
3901
+ [
3902
+ "--shiki-token-function",
3903
+ "#9d6afb",
3904
+ "entity.name.function"
3905
+ ],
3906
+ [
3907
+ "--shiki-token-constant",
3908
+ "#08c0ef",
3909
+ "constant.numeric, constant.language"
3910
+ ],
3911
+ [
3912
+ "--shiki-token-parameter",
3913
+ "#737373",
3914
+ "variable.parameter"
3915
+ ],
3916
+ [
3917
+ "--shiki-token-punctuation",
3918
+ "#737373",
3919
+ "punctuation"
3920
+ ],
3921
+ [
3922
+ "--shiki-token-link",
3923
+ "#ff678d",
3924
+ "markup.underline.link.markdown"
3925
+ ]
3926
+ ];
3927
+ /** Claude's brand clay (`--cds-hsl-clay`), its inline-code TEXT colour; the
3928
+ * emphasized ramp is the light-theme variant. The chip's fill is deliberately
3929
+ * NOT tinted with it — see {@link INLINE_FILL_DARK}. */
3930
+ const CLAY = "#d97757";
3931
+ const CLAY_EMPHASIZED = "#c8603f";
3932
+ /** The inline-code chip fill: a neutral 4% wash (Claude's `--t1`), not a tint
3933
+ * of the text colour. A clay-tinted fill reads as a coloured box around every
3934
+ * identifier; the neutral one disappears into the surface and lets the text
3935
+ * carry the accent, which is what the chip is for. */
3936
+ const INLINE_FILL_DARK = "hsl(0 0% 100% / .04)";
3937
+ const INLINE_FILL_LIGHT = "hsl(0 0% 4.3% / .04)";
3938
+ /** Block corner radius (Claude's `--r6`), against the Host's own 12px. */
3939
+ const BLOCK_RADIUS = "8px";
3940
+ /** @param banner - equal to `surface` on purpose: the bar is floated out of
3941
+ * the way below, so this colour is only reached if those chrome selectors
3942
+ * miss, and a bar that matches the block is the neutral fallback. */
3943
+ function palette(entries, surface, banner, inlineFill) {
3944
+ return [
3945
+ ...entries.map(([name, value]) => `${name}:${value};`),
3946
+ `--dsw-alias-markdown-code-block:${surface};`,
3947
+ `--dsw-alias-markdown-code-block-banner:${banner};`,
3948
+ `--dsw-alias-markdown-inline-code:${inlineFill}`
3949
+ ].join("");
3950
+ }
3951
+ const CLAUDE_MARKDOWN_THEME_CSS = [
3952
+ `.${CLAUDE_MARKDOWN_SCOPE}{display:contents;`,
3953
+ palette(PIERRE_LIGHT, "#ffffff", "#ffffff", INLINE_FILL_LIGHT),
3954
+ "}",
3955
+ `body[data-ds-dark-theme] .${CLAUDE_MARKDOWN_SCOPE}{`,
3956
+ palette(PIERRE_DARK, "#1a1a19", "#1a1a19", INLINE_FILL_DARK),
3957
+ "}",
3958
+ `.${CLAUDE_MARKDOWN_SCOPE} :not(pre)>code{color:${CLAY_EMPHASIZED};border-radius:4px;padding:1px 2px}`,
3959
+ `body[data-ds-dark-theme] .${CLAUDE_MARKDOWN_SCOPE} :not(pre)>code{color:${CLAY}}`,
3960
+ `.${CLAUDE_MARKDOWN_SCOPE} [class*="bannerWrap"]{position:absolute;top:0;right:0;z-index:7;background:transparent;border-radius:0}`,
3961
+ `.${CLAUDE_MARKDOWN_SCOPE} [class*="banner"]:not([class*="bannerWrap"]){background:transparent;padding:6px 8px}`,
3962
+ `.${CLAUDE_MARKDOWN_SCOPE} [class*="infostring"]{display:none}`,
3963
+ `.${CLAUDE_MARKDOWN_SCOPE} pre{white-space:pre;word-break:normal;overflow-x:auto}`,
3964
+ `.${CLAUDE_MARKDOWN_SCOPE} [class*="block"]{--dsl-code-block-border-radius:${BLOCK_RADIUS}}`
3965
+ ].join("");
3966
+ let injected = false;
3967
+ /** Attach the sheet once per page. */
3968
+ function ensureClaudeMarkdownTheme() {
3969
+ if (injected || typeof document === "undefined") return;
3970
+ injected = true;
3971
+ const element = document.createElement("style");
3972
+ element.dataset.dshClaudeMarkdownTheme = "";
3973
+ element.textContent = CLAUDE_MARKDOWN_THEME_CSS;
3974
+ document.head.appendChild(element);
3975
+ }
3976
+ //#endregion
3739
3977
  //#region src/client/markdown-labels.tsx
3740
3978
  /** Stable across renders: MarkdownText keys its streaming renderer on this
3741
3979
  * object's identity and reparses from scratch whenever it changes. */
@@ -3755,18 +3993,298 @@ window.__ModuleLoader__.load({
3755
3993
  * than repeated at every call site. Drop it once the installed
3756
3994
  * @deepseek-ai/dsh-client-ui-primitives matches the Desktop build. */
3757
3995
  function ClaudeMarkdown({ text, labels, streaming }) {
3758
- return /* @__PURE__ */ jsx(MarkdownText, {
3996
+ const props = {
3759
3997
  text,
3760
3998
  labels,
3761
3999
  ...streaming === void 0 ? {} : { streaming }
4000
+ };
4001
+ const Renderer = MarkdownText;
4002
+ ensureClaudeMarkdownTheme();
4003
+ return /* @__PURE__ */ jsx("div", {
4004
+ className: CLAUDE_MARKDOWN_SCOPE,
4005
+ children: /* @__PURE__ */ jsx(Renderer, { ...props })
3762
4006
  });
3763
4007
  }
3764
4008
  //#endregion
4009
+ //#region src/plugin-budget.ts
4010
+ /**
4011
+ * The plugin's connection budget, in one table.
4012
+ *
4013
+ * A browser opens a small fixed number of connections to one origin — six for
4014
+ * HTTP/1.1 in Chromium — and shares them with the Host's own traffic. Every
4015
+ * response this plugin holds open costs one of them for its lifetime, and a
4016
+ * request that cannot get one waits in the browser's queue where no server-side
4017
+ * deadline can reach it. When the pool is exhausted the panels that would
4018
+ * *diagnose* the problem are the first thing to stop answering, which is how
4019
+ * this failure has always presented: four settings cards timing out at once
4020
+ * against a Host that is demonstrably healthy.
4021
+ *
4022
+ * So the budget is a fixed constant rather than a function of how much work is
4023
+ * in flight. Steady state is one connection (the multiplexed projection
4024
+ * carrier); the peak is `PLUGIN_GLOBAL_PERMITS`, whatever the session count.
4025
+ *
4026
+ * Both halves read this file, which is the point: a route declares a budget
4027
+ * class and the client derives its wait from the same entry, so a server
4028
+ * deadline can never be quietly longer than the client's patience.
4029
+ */
4030
+ /** Server-side budget classes. A route declares a class, never a number. */
4031
+ const ROUTE_BUDGET_MS = {
4032
+ /** Answers from memory or a single bounded probe. */
4033
+ fast: 5e3,
4034
+ /** Chains local Git work. */
4035
+ git: 45e3,
4036
+ /** Reaches the network: remote Git, `gh`, the npm registry. */
4037
+ remote: 15e4
4038
+ };
4039
+ /** The client waits one round trip longer, so the route's own 504 wins the
4040
+ * race and the caller learns which budget elapsed instead of guessing. */
4041
+ const CLIENT_GRACE_MS = 3e3;
4042
+ function clientBudgetMs(budget) {
4043
+ return ROUTE_BUDGET_MS[budget] + CLIENT_GRACE_MS;
4044
+ }
4045
+ /** A request that never got a permit fails fast and says so, rather than
4046
+ * spending its whole budget queued behind work it cannot see. */
4047
+ const QUEUE_WAIT_BUDGET_MS = 4e3;
4048
+ /** Per-lane ceilings inside the global budget.
4049
+ *
4050
+ * The projection carrier holds a permanently reserved permit outside these,
4051
+ * so three remain. `write + stream <= 2` leaves one permit that only a read
4052
+ * can take: a diagnostic read always has somewhere to go, however many slow
4053
+ * actions are in flight. That invariant is what stops a 150s repository
4054
+ * action from reproducing the original symptom through a new mechanism. */
4055
+ const PLUGIN_LANE_CAPS = {
4056
+ read: 2,
4057
+ write: 1,
4058
+ stream: 1
4059
+ };
4060
+ //#endregion
3765
4061
  //#region src/rewind.ts
3766
4062
  function isRewound(ranges, seq) {
3767
4063
  return ranges.some((range) => seq >= range.start && seq <= range.end);
3768
4064
  }
3769
4065
  //#endregion
4066
+ //#region src/client/plugin-transport.ts
4067
+ /**
4068
+ * The plugin's only door to the network.
4069
+ *
4070
+ * Every request the Client makes goes through here, and the shape of the
4071
+ * public functions is the point. There is no `signal` property, no `headers`,
4072
+ * no `RequestInit` and no millisecond number in any parameter: a caller cannot
4073
+ * hand this module an options object whose later spread silently overwrites
4074
+ * the deadline sitting above it. That is not a hypothetical — it is exactly
4075
+ * how four call sites lost their deadlines while looking completely ordinary,
4076
+ * because `exactOptionalPropertyTypes` forbids writing `signal: undefined` and
4077
+ * the spread idiom is what people reach for instead. `cancel` is positional
4078
+ * here, so `undefined` is simply passable and the idiom has nothing to do.
4079
+ *
4080
+ * The second seal is arithmetic. The browser shares a small fixed connection
4081
+ * budget between this plugin and the Host, and the plugin used to spend it
4082
+ * proportionally to how many Claude sessions existed. Here every request takes
4083
+ * a permit from a fixed pool first, so more call sites and more sessions
4084
+ * cannot become more sockets — a saturated plugin queues, and says `starved`
4085
+ * within `QUEUE_WAIT_BUDGET_MS` instead of dying silently at its full budget.
4086
+ *
4087
+ * Lane caps guarantee a read always has somewhere to go: the projection
4088
+ * carrier holds a reserved permit, and `write + stream` can occupy at most two
4089
+ * of the remaining three. The panel that diagnoses a saturated pool is
4090
+ * therefore the one request that cannot be starved by it.
4091
+ */
4092
+ var PluginRequestError = class extends Error {
4093
+ reason;
4094
+ status;
4095
+ code;
4096
+ constructor(reason, message, status, code) {
4097
+ super(message);
4098
+ this.name = "PluginRequestError";
4099
+ this.reason = reason;
4100
+ if (status !== void 0) this.status = status;
4101
+ if (code !== void 0) this.code = code;
4102
+ }
4103
+ };
4104
+ let send = (...args) => fetch(...args);
4105
+ let held = 0;
4106
+ const laneHeld = {
4107
+ read: 0,
4108
+ write: 0,
4109
+ stream: 0
4110
+ };
4111
+ let projectionHeld = false;
4112
+ let queue = [];
4113
+ const inFlight = /* @__PURE__ */ new Map();
4114
+ /** The projection carrier owns a permit of its own, outside the lane caps, so
4115
+ * the transcript stream and the panels never compete for the same slot. */
4116
+ function laneHasRoom(lane) {
4117
+ return held < 4 && laneHeld[lane] < PLUGIN_LANE_CAPS[lane];
4118
+ }
4119
+ function pump() {
4120
+ for (let index = 0; index < queue.length; index += 1) {
4121
+ const waiter = queue[index];
4122
+ if (waiter === void 0 || !laneHasRoom(waiter.lane)) continue;
4123
+ queue.splice(index, 1);
4124
+ index -= 1;
4125
+ clearTimeout(waiter.timer);
4126
+ held += 1;
4127
+ laneHeld[waiter.lane] += 1;
4128
+ waiter.admit();
4129
+ }
4130
+ }
4131
+ function release(lane) {
4132
+ held -= 1;
4133
+ laneHeld[lane] -= 1;
4134
+ pump();
4135
+ }
4136
+ function acquire(lane) {
4137
+ let released = false;
4138
+ const releaseOnce = () => {
4139
+ if (released) return;
4140
+ released = true;
4141
+ release(lane);
4142
+ };
4143
+ if (laneHasRoom(lane)) {
4144
+ held += 1;
4145
+ laneHeld[lane] += 1;
4146
+ return Promise.resolve(releaseOnce);
4147
+ }
4148
+ return new Promise((resolve, reject) => {
4149
+ const waiter = {
4150
+ lane,
4151
+ admit: () => resolve(releaseOnce),
4152
+ reject,
4153
+ timer: setTimeout(() => {
4154
+ queue = queue.filter((item) => item !== waiter);
4155
+ reject(new PluginRequestError("starved", "The plugin is holding every connection it is allowed to open."));
4156
+ }, QUEUE_WAIT_BUDGET_MS)
4157
+ };
4158
+ queue.push(waiter);
4159
+ });
4160
+ }
4161
+ function withQuery(path, query) {
4162
+ if (query === void 0) return path;
4163
+ const encoded = new URLSearchParams(query).toString();
4164
+ return encoded.length === 0 ? path : `${path}?${encoded}`;
4165
+ }
4166
+ function failureOf(error, cancel) {
4167
+ if (error instanceof PluginRequestError) return error;
4168
+ if (cancel?.aborted === true) return new PluginRequestError("cancelled", "The caller cancelled the request.");
4169
+ const name = error instanceof Error ? error.name : "";
4170
+ if (name === "TimeoutError" || name === "AbortError") return new PluginRequestError("timeout", "The plugin route did not answer inside its budget.");
4171
+ return new PluginRequestError("http", error instanceof Error ? error.message : String(error));
4172
+ }
4173
+ async function decode(response) {
4174
+ let payload;
4175
+ try {
4176
+ payload = await response.json();
4177
+ } catch {
4178
+ if (response.ok) throw new PluginRequestError("shape", "The plugin route answered with a body this build cannot read.");
4179
+ throw new PluginRequestError("http", `HTTP ${response.status}`, response.status);
4180
+ }
4181
+ if (response.ok) return payload;
4182
+ if (response.status === 404) throw new PluginRequestError("route-missing", "The Host is running an older plugin build without this route.", 404);
4183
+ const record = typeof payload === "object" && payload !== null ? payload : void 0;
4184
+ const message = typeof record?.message === "string" ? record.message : typeof record?.error === "string" ? record.error : `HTTP ${response.status}`;
4185
+ const code = typeof record?.error === "string" ? record.error : void 0;
4186
+ throw new PluginRequestError("http", message, response.status, code);
4187
+ }
4188
+ async function dispatch(lane, method, path, budget, cancel, options) {
4189
+ const url = withQuery(path, options?.query);
4190
+ const key = options?.key ?? `${method} ${url}`;
4191
+ const existing = inFlight.get(key);
4192
+ if (existing !== void 0) return await existing;
4193
+ const run = (async () => {
4194
+ const free = await acquire(lane);
4195
+ try {
4196
+ const timeout = AbortSignal.timeout(clientBudgetMs(budget));
4197
+ const signal = cancel === void 0 ? timeout : AbortSignal.any([cancel, timeout]);
4198
+ return await decode(await send(url, {
4199
+ method,
4200
+ credentials: "same-origin",
4201
+ signal,
4202
+ headers: options?.json === void 0 ? { accept: "application/json" } : {
4203
+ accept: "application/json",
4204
+ "content-type": "application/json"
4205
+ },
4206
+ ...options?.json === void 0 ? {} : { body: JSON.stringify(options.json) }
4207
+ }));
4208
+ } catch (error) {
4209
+ throw failureOf(error, cancel);
4210
+ } finally {
4211
+ free();
4212
+ }
4213
+ })();
4214
+ inFlight.set(key, run);
4215
+ try {
4216
+ return await run;
4217
+ } finally {
4218
+ if (inFlight.get(key) === run) inFlight.delete(key);
4219
+ }
4220
+ }
4221
+ /** A bounded read of a plugin route. */
4222
+ function pluginRead(path, budget, cancel, options) {
4223
+ return dispatch("read", "GET", path, budget, cancel, options);
4224
+ }
4225
+ /** A bounded write. Writes never coalesce by default: two of them are two
4226
+ * intents, even when their bodies match. */
4227
+ function pluginWrite(path, budget, cancel, options) {
4228
+ const method = options?.method ?? "POST";
4229
+ return dispatch("write", method, path, budget, cancel, {
4230
+ ...options,
4231
+ key: options?.key ?? `${method} ${withQuery(path, options?.query)} #${nextWriteId()}`
4232
+ });
4233
+ }
4234
+ let writeId = 0;
4235
+ function nextWriteId() {
4236
+ writeId += 1;
4237
+ return writeId;
4238
+ }
4239
+ async function openStream(lane, path, cancel, options, reserved) {
4240
+ const url = withQuery(path, options?.query);
4241
+ const free = reserved ? () => {
4242
+ projectionHeld = false;
4243
+ } : await acquire(lane);
4244
+ try {
4245
+ const response = await send(url, {
4246
+ method: options?.method ?? "GET",
4247
+ credentials: "same-origin",
4248
+ signal: cancel,
4249
+ headers: options?.json === void 0 ? { accept: "application/x-ndjson" } : {
4250
+ accept: "application/x-ndjson",
4251
+ "content-type": "application/json"
4252
+ },
4253
+ ...options?.json === void 0 ? {} : { body: JSON.stringify(options.json) }
4254
+ });
4255
+ if (!response.ok || response.body === null) {
4256
+ free();
4257
+ if (response.status === 404) throw new PluginRequestError("route-missing", "The Host is running an older plugin build without this route.", 404);
4258
+ throw new PluginRequestError("http", `HTTP ${response.status}`, response.status);
4259
+ }
4260
+ cancel.addEventListener("abort", free, { once: true });
4261
+ return response.body.getReader();
4262
+ } catch (error) {
4263
+ free();
4264
+ throw failureOf(error, cancel);
4265
+ }
4266
+ }
4267
+ /** A long-lived NDJSON response. No deadline — it is meant to stay open — but
4268
+ * it takes a counted permit for its whole life, which is the bound that
4269
+ * matters. */
4270
+ function pluginNdjson(path, cancel, options) {
4271
+ return openStream("stream", path, cancel, options, false);
4272
+ }
4273
+ /** The reserved projection carrier: exactly one live connection, ever,
4274
+ * whatever the session count. */
4275
+ function pluginProjectionStream(path, cancel, options) {
4276
+ if (projectionHeld) return Promise.reject(new PluginRequestError("starved", "The projection carrier is already open."));
4277
+ projectionHeld = true;
4278
+ return openStream("stream", path, cancel, options, true);
4279
+ }
4280
+ /** Fire-and-forget diagnostics. Dropped rather than queued when saturated:
4281
+ * the channel that reports the plugin's own failures must never be the
4282
+ * traffic that causes them. */
4283
+ function pluginBeacon(path, body) {
4284
+ if (!laneHasRoom("write")) return;
4285
+ pluginWrite(path, "fast", void 0, { json: body }).catch(() => void 0);
4286
+ }
4287
+ //#endregion
3770
4288
  //#region src/client/projection.ts
3771
4289
  const EMPTY_CLAUDE_PROJECTION = {
3772
4290
  schemaVersion: 1,
@@ -3776,6 +4294,13 @@ window.__ModuleLoader__.load({
3776
4294
  activities: []
3777
4295
  };
3778
4296
  const RETRY_DELAY_MS = 2e3;
4297
+ /** Floor between carrier reopens forced by a desync. A carrier that is losing
4298
+ * lines must not be answered with a reconnect per lost line. */
4299
+ const RESYNC_COOLDOWN_MS = 5e3;
4300
+ /** Wait for the subscribed set to stop moving before reopening the carrier:
4301
+ * mounting a session list changes it once per row. */
4302
+ const SUBSCRIPTION_SETTLE_MS = 250;
4303
+ const NDJSON_SEPARATOR = String.fromCharCode(10);
3779
4304
  /** Coalesce stream deltas into at most one React notification per frame. */
3780
4305
  const FRAME_MS = 16;
3781
4306
  /** Typewriter smoothing: drain newly arrived prose over roughly this window,
@@ -3899,11 +4424,19 @@ window.__ModuleLoader__.load({
3899
4424
  setTimeout(resolve, ms).unref?.();
3900
4425
  });
3901
4426
  }
3902
- /** Create one lazy source: active subscribers open the Host NDJSON stream, and
3903
- * a dropped stream reconnects with a fresh snapshot after a bounded delay. */
3904
- function createClaudeProjectionSource(sessionId, fetchProjection = fetch, retryDelayMs = RETRY_DELAY_MS) {
4427
+ /** One session's reducer over the shared carrier's lines.
4428
+ *
4429
+ * A source opens nothing. It used to hold a stream of its own, which made the
4430
+ * plugin's connection count a function of how many sessions existed;
4431
+ * {@link ClaudeProjectionStore} now owns the single carrier and feeds each
4432
+ * session's lines here. `onDemand` reports whether anyone is watching, which
4433
+ * is what the store uses to decide which sessions the carrier subscribes to. */
4434
+ function createClaudeProjectionSource(sessionId, onDemand = () => {}, onDesync = () => {}) {
3905
4435
  let snapshot = EMPTY_CLAUDE_PROJECTION;
3906
4436
  let revision = 0;
4437
+ /** Last carrier line this reducer applied, by the server's count. Undefined
4438
+ * until a snapshot states where the stream stands. */
4439
+ let seq;
3907
4440
  let owned = false;
3908
4441
  let commands = [];
3909
4442
  let contextUsage;
@@ -3916,8 +4449,6 @@ window.__ModuleLoader__.load({
3916
4449
  /** Streaming prose still being revealed: full arrived text plus shown chars. */
3917
4450
  const reveal = /* @__PURE__ */ new Map();
3918
4451
  let disposed = false;
3919
- let running = false;
3920
- let controller;
3921
4452
  let frame;
3922
4453
  let usedAnimationFrame = false;
3923
4454
  const listeners = /* @__PURE__ */ new Set();
@@ -4016,7 +4547,7 @@ window.__ModuleLoader__.load({
4016
4547
  }
4017
4548
  };
4018
4549
  const applyText = (event) => {
4019
- const { turn, step, ordinal, append, text } = event;
4550
+ const { turn, step, ordinal, append, text, renderer } = event;
4020
4551
  if (!nonNegativeInteger(turn) || !nonNegativeInteger(step) || !nonNegativeInteger(ordinal)) return false;
4021
4552
  if (append !== void 0 && (typeof append !== "string" || append.length > MAX_TRANSCRIPT_CHARS)) return false;
4022
4553
  if (text !== void 0 && (typeof text !== "string" || text.length > MAX_TRANSCRIPT_CHARS)) return false;
@@ -4033,7 +4564,8 @@ window.__ModuleLoader__.load({
4033
4564
  step,
4034
4565
  ordinal,
4035
4566
  kind: "text",
4036
- phase: "updated"
4567
+ phase: "updated",
4568
+ ...isClaudeRenderMode(renderer) ? { renderer } : {}
4037
4569
  },
4038
4570
  text: fullText
4039
4571
  };
@@ -4053,6 +4585,17 @@ window.__ModuleLoader__.load({
4053
4585
  });
4054
4586
  return true;
4055
4587
  };
4588
+ /** This reducer is behind the server and cannot catch up on its own: only a
4589
+ * fresh snapshot can. Reported as well as acted on, because a silent
4590
+ * self-heal hides how often the carrier is losing lines.
4591
+ *
4592
+ * Numbering stops until that snapshot arrives and states it again; every
4593
+ * line in between would only be measured against a count already known to
4594
+ * be wrong. */
4595
+ const desync = (kind, detail) => {
4596
+ seq = void 0;
4597
+ onDesync(kind, `${sessionId}: ${detail}`);
4598
+ };
4056
4599
  const applyLine = (line) => {
4057
4600
  const trimmed = line.trim();
4058
4601
  if (trimmed.length === 0) return;
@@ -4064,10 +4607,16 @@ window.__ModuleLoader__.load({
4064
4607
  }
4065
4608
  const event = record$7(value);
4066
4609
  if (event === void 0 || typeof event.type !== "string") return;
4610
+ if (event.type === "checkpoint") {
4611
+ if (nonNegativeInteger(event.seq) && seq !== void 0 && event.seq !== seq) desync("projection-gap", `checkpoint at ${event.seq}, applied ${seq}`);
4612
+ return;
4613
+ }
4614
+ if (event.type !== "snapshot" && nonNegativeInteger(event.seq) && seq !== void 0 && event.seq !== seq + 1) desync("projection-gap", `expected ${seq + 1}, received ${event.seq}`);
4067
4615
  try {
4068
4616
  switch (event.type) {
4069
4617
  case "snapshot": {
4070
4618
  const next = parseClaudeClientProjection(event);
4619
+ seq = nonNegativeInteger(event.seq) ? event.seq : void 0;
4071
4620
  revision = next.revision;
4072
4621
  owned = next.owned;
4073
4622
  commands = next.commands;
@@ -4080,7 +4629,10 @@ window.__ModuleLoader__.load({
4080
4629
  break;
4081
4630
  }
4082
4631
  case "text":
4083
- if (!applyText(event)) return;
4632
+ if (!applyText(event)) {
4633
+ if (seq !== void 0) desync("projection-delta-rejected", "text not applied");
4634
+ return;
4635
+ }
4084
4636
  revision += 1;
4085
4637
  break;
4086
4638
  case "activity":
@@ -4114,87 +4666,185 @@ window.__ModuleLoader__.load({
4114
4666
  default: return;
4115
4667
  }
4116
4668
  } catch {
4669
+ desync("projection-delta-rejected", `rejected ${event.type}`);
4117
4670
  return;
4118
4671
  }
4672
+ if (nonNegativeInteger(event.seq)) seq = event.seq;
4119
4673
  schedulePublish();
4120
4674
  };
4121
- const run = async () => {
4122
- if (running) return;
4123
- running = true;
4124
- try {
4125
- while (!disposed && listeners.size > 0) {
4126
- controller = new AbortController();
4127
- try {
4128
- const response = await fetchProjection(`${CLAUDE_PROJECTION_PATH}/${encodeURIComponent(sessionId)}/stream`, {
4129
- headers: { accept: "application/x-ndjson" },
4130
- signal: controller.signal
4131
- });
4132
- if (!response.ok) throw new Error(`Claude projection stream failed (${response.status})`);
4133
- if (response.body === null) throw new Error("Claude projection stream is unavailable");
4134
- const reader = response.body.getReader();
4135
- const decoder = new TextDecoder();
4136
- let buffer = "";
4137
- while (true) {
4138
- if (disposed || listeners.size === 0) {
4139
- await reader.cancel().catch(() => void 0);
4140
- break;
4141
- }
4142
- const chunk = await reader.read();
4143
- buffer += decoder.decode(chunk.value, { stream: !chunk.done });
4144
- const lines = buffer.split("\n");
4145
- buffer = lines.pop() ?? "";
4146
- for (const line of lines) applyLine(line);
4147
- if (chunk.done) break;
4148
- }
4149
- } catch (error) {
4150
- if (isAbort(error)) return;
4151
- } finally {
4152
- controller = void 0;
4153
- }
4154
- if (disposed || listeners.size === 0) return;
4155
- await delay(retryDelayMs);
4156
- }
4157
- } finally {
4158
- running = false;
4159
- }
4160
- };
4161
4675
  return {
4162
4676
  getSnapshot: () => snapshot,
4677
+ feed: applyLine,
4163
4678
  subscribe(listener) {
4164
4679
  if (disposed) return () => {};
4165
4680
  const wasIdle = listeners.size === 0;
4166
4681
  listeners.add(listener);
4167
- if (wasIdle) run();
4682
+ if (wasIdle) onDemand(true);
4168
4683
  return () => {
4169
4684
  listeners.delete(listener);
4170
4685
  if (listeners.size !== 0) return;
4171
- controller?.abort();
4172
- controller = void 0;
4173
4686
  cancelFrame();
4687
+ onDemand(false);
4174
4688
  };
4175
4689
  },
4176
4690
  dispose() {
4177
4691
  disposed = true;
4178
4692
  listeners.clear();
4179
- controller?.abort();
4180
- controller = void 0;
4181
4693
  cancelFrame();
4694
+ onDemand(false);
4182
4695
  }
4183
4696
  };
4184
4697
  }
4698
+ /**
4699
+ * Every session's projection over ONE connection.
4700
+ *
4701
+ * The plugin used to open an NDJSON stream per session, so its share of the
4702
+ * browser's small per-origin connection budget grew with the number of Claude
4703
+ * sessions — and the overview panel subscribes one per LISTED session, not per
4704
+ * open one. Past a handful of sessions the plugin's own settings panel could no
4705
+ * longer get a connection at all, which is the failure this class exists to
4706
+ * make impossible: the carrier is one connection whatever the session count.
4707
+ *
4708
+ * `source(sessionId)` keeps its shape, so consumers are unaware of any of this.
4709
+ */
4185
4710
  var ClaudeProjectionStore = class {
4186
4711
  #sources = /* @__PURE__ */ new Map();
4712
+ /** Sessions with at least one live subscriber, newest interest last. */
4713
+ #wanted = /* @__PURE__ */ new Set();
4714
+ #open;
4715
+ #retryDelayMs;
4716
+ #settleMs;
4717
+ #report;
4718
+ #resyncCooldownMs;
4719
+ #resyncedAt = 0;
4720
+ #controller;
4721
+ #settle;
4722
+ #running = false;
4723
+ #disposed = false;
4724
+ constructor(options = {}) {
4725
+ this.#open = options.open ?? ((path, cancel) => pluginProjectionStream(path, cancel));
4726
+ this.#retryDelayMs = options.retryDelayMs ?? RETRY_DELAY_MS;
4727
+ this.#settleMs = options.settleMs ?? SUBSCRIPTION_SETTLE_MS;
4728
+ this.#report = options.report ?? (() => {});
4729
+ this.#resyncCooldownMs = options.resyncCooldownMs ?? RESYNC_COOLDOWN_MS;
4730
+ }
4187
4731
  source(sessionId) {
4188
4732
  let source = this.#sources.get(sessionId);
4189
4733
  if (source === void 0) {
4190
- source = createClaudeProjectionSource(sessionId);
4734
+ source = createClaudeProjectionSource(sessionId, (active) => {
4735
+ this.#demand(sessionId, active);
4736
+ }, (kind, detail) => {
4737
+ this.#resync(kind, detail);
4738
+ });
4191
4739
  this.#sources.set(sessionId, source);
4192
4740
  }
4193
4741
  return source;
4194
4742
  }
4743
+ /** Reopen the carrier so every lane is restated from a fresh snapshot.
4744
+ *
4745
+ * One session noticed the hole, but the carrier is shared and a dropped
4746
+ * line is a property of the carrier, so the others are suspect too --
4747
+ * reopening restates all of them for the price of the one reconnect. */
4748
+ #resync(kind, detail) {
4749
+ if (this.#disposed) return;
4750
+ this.#report(kind, detail);
4751
+ const now = Date.now();
4752
+ if (now - this.#resyncedAt < this.#resyncCooldownMs) return;
4753
+ this.#resyncedAt = now;
4754
+ this.#reopen();
4755
+ }
4195
4756
  dispose() {
4757
+ this.#disposed = true;
4758
+ if (this.#settle !== void 0) clearTimeout(this.#settle);
4759
+ this.#settle = void 0;
4760
+ this.#controller?.abort();
4761
+ this.#controller = void 0;
4196
4762
  for (const source of this.#sources.values()) source.dispose();
4197
4763
  this.#sources.clear();
4764
+ this.#wanted.clear();
4765
+ }
4766
+ /** Note interest and reopen the carrier once the set stops moving. Mounting
4767
+ * a session list would otherwise reopen it once per row. */
4768
+ #demand(sessionId, active) {
4769
+ if (this.#disposed) return;
4770
+ if (active) {
4771
+ this.#wanted.delete(sessionId);
4772
+ this.#wanted.add(sessionId);
4773
+ } else if (!this.#wanted.delete(sessionId)) return;
4774
+ if (this.#settle !== void 0) clearTimeout(this.#settle);
4775
+ const timer = setTimeout(() => {
4776
+ this.#settle = void 0;
4777
+ this.#reopen();
4778
+ }, this.#settleMs);
4779
+ timer.unref?.();
4780
+ this.#settle = timer;
4781
+ }
4782
+ #lanes() {
4783
+ const wanted = [...this.#wanted];
4784
+ return wanted.slice(Math.max(0, wanted.length - 16));
4785
+ }
4786
+ #reopen() {
4787
+ this.#controller?.abort();
4788
+ this.#controller = void 0;
4789
+ if (this.#disposed || this.#wanted.size === 0) return;
4790
+ this.#run();
4791
+ }
4792
+ async #run() {
4793
+ if (this.#running) return;
4794
+ this.#running = true;
4795
+ try {
4796
+ while (!this.#disposed && this.#wanted.size > 0) {
4797
+ const controller = new AbortController();
4798
+ this.#controller = controller;
4799
+ const lanes = this.#lanes();
4800
+ let superseded = false;
4801
+ try {
4802
+ const reader = await this.#open(`${CLAUDE_PROJECTION_PATH}/multi?sessions=${lanes.map(encodeURIComponent).join(",")}`, controller.signal);
4803
+ const stop = () => {
4804
+ superseded = true;
4805
+ reader.cancel().catch(() => void 0);
4806
+ };
4807
+ controller.signal.addEventListener("abort", stop, { once: true });
4808
+ const decoder = new TextDecoder();
4809
+ let buffer = "";
4810
+ while (!controller.signal.aborted) {
4811
+ const chunk = await reader.read();
4812
+ buffer += decoder.decode(chunk.value, { stream: !chunk.done });
4813
+ const lines = buffer.split(NDJSON_SEPARATOR);
4814
+ buffer = lines.pop() ?? "";
4815
+ for (const line of lines) this.#dispatch(line);
4816
+ if (chunk.done) break;
4817
+ }
4818
+ controller.signal.removeEventListener("abort", stop);
4819
+ await reader.cancel().catch(() => void 0);
4820
+ } catch (error) {
4821
+ if (isAbort(error)) {
4822
+ if (this.#controller !== controller) continue;
4823
+ return;
4824
+ }
4825
+ } finally {
4826
+ if (this.#controller === controller) this.#controller = void 0;
4827
+ }
4828
+ if (this.#disposed || this.#wanted.size === 0) return;
4829
+ if (superseded) continue;
4830
+ await delay(this.#retryDelayMs);
4831
+ }
4832
+ } finally {
4833
+ this.#running = false;
4834
+ }
4835
+ }
4836
+ /** Route one carrier line to the session that owns it. A line for a session
4837
+ * nobody is watching any more is dropped rather than reviving its lane. */
4838
+ #dispatch(line) {
4839
+ if (line.length === 0) return;
4840
+ let session;
4841
+ try {
4842
+ session = JSON.parse(line).session;
4843
+ } catch {
4844
+ return;
4845
+ }
4846
+ if (typeof session !== "string") return;
4847
+ this.#sources.get(session)?.feed(line);
4198
4848
  }
4199
4849
  };
4200
4850
  //#endregion
@@ -4628,29 +5278,6 @@ window.__ModuleLoader__.load({
4628
5278
  });
4629
5279
  }
4630
5280
  //#endregion
4631
- //#region src/client/plugin-request.ts
4632
- /** Deadlines for the plugin's own HTTP routes.
4633
- *
4634
- * A browser opens at most six connections to one origin, and a request that
4635
- * never settles holds one of them for the life of the page. A handful of
4636
- * those and every plugin route — the projection stream included — stops
4637
- * answering, which reads as the whole plugin freezing until the Host is
4638
- * restarted. So no plain request is allowed to wait forever: the deadline is
4639
- * generous enough that a slow answer still arrives, and short enough that a
4640
- * wedged one gives its connection back.
4641
- *
4642
- * Streaming routes (projection, ask, repository setup) are legitimately
4643
- * long-lived and carry their own cancellation instead. */
4644
- /** Reads: the route answers from cache or a short-timeout Git call. */
4645
- const PLUGIN_READ_TIMEOUT_MS = 3e4;
4646
- /** Writes: the route may chain several remote Git/gh calls of its own. */
4647
- const PLUGIN_ACTION_TIMEOUT_MS = 18e4;
4648
- /** Combine a caller's cancellation with this deadline. */
4649
- function pluginRequestSignal(timeoutMs, signal) {
4650
- const timeout = AbortSignal.timeout(timeoutMs);
4651
- return signal === void 0 ? timeout : AbortSignal.any([signal, timeout]);
4652
- }
4653
- //#endregion
4654
5281
  //#region src/client/jira-api.ts
4655
5282
  var JiraClientError = class extends Error {
4656
5283
  code;
@@ -4663,68 +5290,75 @@ window.__ModuleLoader__.load({
4663
5290
  function record$5(value) {
4664
5291
  return value !== null && typeof value === "object" && !Array.isArray(value) ? value : void 0;
4665
5292
  }
4666
- async function call(path, init) {
4667
- const response = await fetch(`${CLAUDE_JIRA_PATH}${path}`, {
4668
- credentials: "same-origin",
4669
- signal: pluginRequestSignal(PLUGIN_READ_TIMEOUT_MS),
4670
- ...init
4671
- });
4672
- const body = record$5(await response.json());
4673
- if (!response.ok) throw new JiraClientError(typeof body?.message === "string" ? body.message : "Jira is unavailable.", typeof body?.error === "string" ? body.error : void 0);
5293
+ /**
5294
+ * Every Jira failure the panels catch is a `JiraClientError`, whatever the
5295
+ * transport threw: `ClaudeHeroRepositoryControls` branches on `code` to tell
5296
+ * 'not-connected' apart from a real outage, and the settings card renders the
5297
+ * message verbatim.
5298
+ *
5299
+ * The routes answer `{ error, message }`, so `message` is already the sentence
5300
+ * to show. A body carrying only a code a 405, a bad JSON body used to read
5301
+ * 'Jira is unavailable.' rather than leaking the code as prose, and it still
5302
+ * does. Transport failures (a starved pool, an elapsed budget, an older Host
5303
+ * without the route) carry their own wording and keep it.
5304
+ */
5305
+ function jiraFailure(cause) {
5306
+ if (cause instanceof JiraClientError) return cause;
5307
+ if (!(cause instanceof PluginRequestError)) return new JiraClientError(cause instanceof Error ? cause.message : String(cause));
5308
+ return new JiraClientError(cause.message === cause.code ? "Jira is unavailable." : cause.message, cause.code);
5309
+ }
5310
+ function payload(value) {
5311
+ const body = record$5(value);
4674
5312
  if (body === void 0) throw new JiraClientError("Invalid Jira response.");
4675
5313
  return body;
4676
5314
  }
4677
- function status(body) {
5315
+ function status(value) {
5316
+ const body = payload(value);
4678
5317
  if (typeof body.connected !== "boolean") throw new JiraClientError("Invalid Jira status response.");
4679
5318
  return body;
4680
5319
  }
4681
5320
  async function loadJiraStatus(signal) {
4682
- return status(await call("/status", {
4683
- method: "GET",
4684
- headers: { accept: "application/json" },
4685
- ...signal === void 0 ? {} : { signal }
4686
- }));
5321
+ try {
5322
+ return status(await pluginRead(`${CLAUDE_JIRA_PATH}/status`, "remote", signal));
5323
+ } catch (cause) {
5324
+ throw jiraFailure(cause);
5325
+ }
4687
5326
  }
4688
5327
  async function connectJira(input) {
4689
- return status(await call("/connect", {
4690
- method: "POST",
4691
- headers: {
4692
- accept: "application/json",
4693
- "content-type": "application/json"
4694
- },
4695
- body: JSON.stringify(input)
4696
- }));
5328
+ try {
5329
+ return status(await pluginWrite(`${CLAUDE_JIRA_PATH}/connect`, "remote", void 0, { json: input }));
5330
+ } catch (cause) {
5331
+ throw jiraFailure(cause);
5332
+ }
4697
5333
  }
4698
5334
  async function disconnectJira() {
4699
- await call("/disconnect", {
4700
- method: "POST",
4701
- headers: { accept: "application/json" }
4702
- });
5335
+ try {
5336
+ await pluginWrite(`${CLAUDE_JIRA_PATH}/disconnect`, "remote");
5337
+ } catch (cause) {
5338
+ throw jiraFailure(cause);
5339
+ }
4703
5340
  }
4704
5341
  async function searchJiraTickets(query, signal) {
4705
- const body = await call(`/search?query=${encodeURIComponent(query)}`, {
4706
- method: "GET",
4707
- headers: { accept: "application/json" },
4708
- ...signal === void 0 ? {} : { signal }
4709
- });
4710
- if (!Array.isArray(body.tickets)) throw new JiraClientError("Invalid Jira search response.");
4711
- const tickets = [];
4712
- for (const item of body.tickets) {
4713
- const ticket = record$5(item);
4714
- if (ticket === void 0 || typeof ticket.key !== "string" || typeof ticket.summary !== "string" || typeof ticket.url !== "string") continue;
4715
- tickets.push(ticket);
5342
+ try {
5343
+ const body = payload(await pluginRead(`${CLAUDE_JIRA_PATH}/search`, "remote", signal, { query: { query } }));
5344
+ if (!Array.isArray(body.tickets)) throw new JiraClientError("Invalid Jira search response.");
5345
+ const tickets = [];
5346
+ for (const item of body.tickets) {
5347
+ const ticket = record$5(item);
5348
+ if (ticket === void 0 || typeof ticket.key !== "string" || typeof ticket.summary !== "string" || typeof ticket.url !== "string") continue;
5349
+ tickets.push(ticket);
5350
+ }
5351
+ return tickets;
5352
+ } catch (cause) {
5353
+ throw jiraFailure(cause);
4716
5354
  }
4717
- return tickets;
4718
5355
  }
4719
5356
  async function assignJiraTicket(key) {
4720
- await call("/assign", {
4721
- method: "POST",
4722
- headers: {
4723
- accept: "application/json",
4724
- "content-type": "application/json"
4725
- },
4726
- body: JSON.stringify({ key })
4727
- });
5357
+ try {
5358
+ await pluginWrite(`${CLAUDE_JIRA_PATH}/assign`, "remote", void 0, { json: { key } });
5359
+ } catch (cause) {
5360
+ throw jiraFailure(cause);
5361
+ }
4728
5362
  }
4729
5363
  /** Draft seeded into the composer when a session starts from a ticket. */
4730
5364
  function ticketPrompt(ticket) {
@@ -4761,6 +5395,7 @@ window.__ModuleLoader__.load({
4761
5395
  const item = setting;
4762
5396
  if (typeof item.key !== "string" || typeof item.value !== "string" || ![
4763
5397
  "new-session",
5398
+ "next-turn",
4764
5399
  "next-worktree",
4765
5400
  "restart"
4766
5401
  ].includes(String(item.effect))) return false;
@@ -4775,6 +5410,27 @@ window.__ModuleLoader__.load({
4775
5410
  function value(status, detail) {
4776
5411
  return detail === void 0 ? status : `${status} · ${detail}`;
4777
5412
  }
5413
+ /** The trigger's disclosure chevron.
5414
+ *
5415
+ * Geometry rather than a character: the ink spans y 6 to 10 in a 16-unit box,
5416
+ * so it is centred on the box's own centre and the open state's 180-degree
5417
+ * flip lands exactly where the closed state sat. A text arrowhead carries its
5418
+ * ink below the centre of the em box, which is why this needed a hand-tuned
5419
+ * nudge that could only be right in one of the two states. */
5420
+ function SelectChevron() {
5421
+ return /* @__PURE__ */ jsx("svg", {
5422
+ width: "16",
5423
+ height: "16",
5424
+ viewBox: "0 0 16 16",
5425
+ fill: "none",
5426
+ stroke: "currentColor",
5427
+ strokeWidth: "1.8",
5428
+ strokeLinecap: "round",
5429
+ strokeLinejoin: "round",
5430
+ "aria-hidden": "true",
5431
+ children: /* @__PURE__ */ jsx("path", { d: "M4 6l4 4 4-4" })
5432
+ });
5433
+ }
4778
5434
  function GlobalSettingText({ setting, disabled, onChange }) {
4779
5435
  const [draft, setDraft] = useState(setting.value);
4780
5436
  useEffect(() => {
@@ -4805,7 +5461,7 @@ window.__ModuleLoader__.load({
4805
5461
  }
4806
5462
  });
4807
5463
  }
4808
- function GlobalSettingSelect({ setting, disabled, onChange }) {
5464
+ function GlobalSettingSelect({ setting, disabled, onChange, labelFor = (option) => option.label }) {
4809
5465
  const [open, setOpen] = useState(false);
4810
5466
  const [activeIndex, setActiveIndex] = useState(0);
4811
5467
  const rootRef = useRef(null);
@@ -4843,80 +5499,81 @@ window.__ModuleLoader__.load({
4843
5499
  onBlur: (event) => {
4844
5500
  if (!event.currentTarget.contains(event.relatedTarget)) setOpen(false);
4845
5501
  },
4846
- children: [/* @__PURE__ */ jsxs("button", {
4847
- ref: triggerRef,
4848
- type: "button",
4849
- "aria-haspopup": "listbox",
4850
- "aria-expanded": open,
4851
- "aria-controls": open ? listboxId : void 0,
4852
- "aria-activedescendant": open ? `${listboxId}-${activeIndex}` : void 0,
4853
- disabled: disabled || setting.options.length === 0,
4854
- style: {
4855
- ...settingSelectTrigger,
4856
- ...open ? settingSelectTriggerOpen : {}
4857
- },
4858
- onClick: () => {
4859
- if (open) setOpen(false);
4860
- else openMenu();
4861
- },
4862
- onKeyDown: (event) => {
4863
- if (event.key === "ArrowDown" || event.key === "ArrowUp") {
4864
- event.preventDefault();
4865
- if (!open) openMenu(event.key === "ArrowDown" ? selectedIndex : Math.max(0, setting.options.length - 1));
4866
- else move(event.key === "ArrowDown" ? 1 : -1);
4867
- } else if (event.key === "Home" && open) {
4868
- event.preventDefault();
4869
- setActiveIndex(0);
4870
- } else if (event.key === "End" && open) {
4871
- event.preventDefault();
4872
- setActiveIndex(Math.max(0, setting.options.length - 1));
4873
- } else if ((event.key === "Enter" || event.key === " ") && open) {
4874
- event.preventDefault();
4875
- choose(activeIndex);
4876
- } else if (event.key === "Escape" && open) {
4877
- event.preventDefault();
4878
- setOpen(false);
4879
- }
4880
- },
4881
- children: [/* @__PURE__ */ jsx("span", {
4882
- style: settingSelectValue,
4883
- children: selectedOption?.label ?? setting.value
4884
- }), /* @__PURE__ */ jsx("span", {
4885
- "aria-hidden": "true",
4886
- style: {
4887
- ...settingSelectChevron,
4888
- ...open ? settingSelectChevronOpen : {}
5502
+ children: [
5503
+ /* @__PURE__ */ jsx("style", {
5504
+ "data-dsh-claude-setting-select-styles": true,
5505
+ children: settingSelectCss
5506
+ }),
5507
+ /* @__PURE__ */ jsxs("button", {
5508
+ ref: triggerRef,
5509
+ type: "button",
5510
+ "aria-haspopup": "listbox",
5511
+ "aria-expanded": open,
5512
+ "aria-controls": open ? listboxId : void 0,
5513
+ "aria-activedescendant": open ? `${listboxId}-${activeIndex}` : void 0,
5514
+ disabled: disabled || setting.options.length === 0,
5515
+ className: settingSelectTriggerClass,
5516
+ onClick: () => {
5517
+ if (open) setOpen(false);
5518
+ else openMenu();
4889
5519
  },
4890
- children: "⌄"
4891
- })]
4892
- }), open ? /* @__PURE__ */ jsx("div", {
4893
- id: listboxId,
4894
- role: "listbox",
4895
- "aria-activedescendant": `${listboxId}-${activeIndex}`,
4896
- style: settingSelectMenu,
4897
- children: setting.options.map((option, index) => {
4898
- const selected = option.value === setting.value;
4899
- const active = index === activeIndex;
4900
- return /* @__PURE__ */ jsxs("button", {
4901
- id: `${listboxId}-${index}`,
4902
- type: "button",
4903
- role: "option",
4904
- "aria-selected": selected,
4905
- style: {
4906
- ...settingSelectOption,
4907
- ...active ? settingSelectOptionActive : {}
4908
- },
4909
- onMouseEnter: () => setActiveIndex(index),
4910
- onMouseDown: (event) => event.preventDefault(),
4911
- onClick: () => choose(index),
4912
- children: [/* @__PURE__ */ jsx("span", {
4913
- style: settingSelectCheck,
4914
- "aria-hidden": "true",
4915
- children: selected ? "✓" : ""
4916
- }), /* @__PURE__ */ jsx("span", { children: option.label })]
4917
- }, `${option.source}:${option.value}`);
4918
- })
4919
- }) : null]
5520
+ onKeyDown: (event) => {
5521
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
5522
+ event.preventDefault();
5523
+ if (!open) openMenu(event.key === "ArrowDown" ? selectedIndex : Math.max(0, setting.options.length - 1));
5524
+ else move(event.key === "ArrowDown" ? 1 : -1);
5525
+ } else if (event.key === "Home" && open) {
5526
+ event.preventDefault();
5527
+ setActiveIndex(0);
5528
+ } else if (event.key === "End" && open) {
5529
+ event.preventDefault();
5530
+ setActiveIndex(Math.max(0, setting.options.length - 1));
5531
+ } else if ((event.key === "Enter" || event.key === " ") && open) {
5532
+ event.preventDefault();
5533
+ choose(activeIndex);
5534
+ } else if (event.key === "Escape" && open) {
5535
+ event.preventDefault();
5536
+ setOpen(false);
5537
+ }
5538
+ },
5539
+ children: [/* @__PURE__ */ jsx("span", {
5540
+ style: settingSelectValue,
5541
+ children: selectedOption === void 0 ? setting.value : labelFor(selectedOption)
5542
+ }), /* @__PURE__ */ jsx("span", {
5543
+ "aria-hidden": "true",
5544
+ className: settingSelectChevronClass,
5545
+ children: /* @__PURE__ */ jsx(SelectChevron, {})
5546
+ })]
5547
+ }),
5548
+ open ? /* @__PURE__ */ jsx("div", {
5549
+ id: listboxId,
5550
+ role: "listbox",
5551
+ "aria-activedescendant": `${listboxId}-${activeIndex}`,
5552
+ style: settingSelectMenu,
5553
+ children: setting.options.map((option, index) => {
5554
+ const selected = option.value === setting.value;
5555
+ const active = index === activeIndex;
5556
+ return /* @__PURE__ */ jsxs("button", {
5557
+ id: `${listboxId}-${index}`,
5558
+ type: "button",
5559
+ role: "option",
5560
+ "aria-selected": selected,
5561
+ style: {
5562
+ ...settingSelectOption,
5563
+ ...active ? settingSelectOptionActive : {}
5564
+ },
5565
+ onMouseEnter: () => setActiveIndex(index),
5566
+ onMouseDown: (event) => event.preventDefault(),
5567
+ onClick: () => choose(index),
5568
+ children: [/* @__PURE__ */ jsx("span", {
5569
+ style: settingSelectCheck,
5570
+ "aria-hidden": "true",
5571
+ children: selected ? "✓" : ""
5572
+ }), /* @__PURE__ */ jsx("span", { children: labelFor(option) })]
5573
+ }, `${option.source}:${option.value}`);
5574
+ })
5575
+ }) : null
5576
+ ]
4920
5577
  });
4921
5578
  }
4922
5579
  /** Fixed windows carry a translated label; server-named model buckets (e.g.
@@ -4934,6 +5591,10 @@ window.__ModuleLoader__.load({
4934
5591
  label: "outputStyle",
4935
5592
  hint: "globalSettingsNewSession"
4936
5593
  },
5594
+ renderer: {
5595
+ label: "renderer",
5596
+ hint: "rendererEffect"
5597
+ },
4937
5598
  worktreeBranchPrefix: {
4938
5599
  label: "worktreeBranchPrefix",
4939
5600
  hint: "worktreeBranchPrefixEffect"
@@ -4947,6 +5608,17 @@ window.__ModuleLoader__.load({
4947
5608
  hint: "idleTimeoutEffect"
4948
5609
  }
4949
5610
  };
5611
+ /** Translated display text for the option vocabularies this plugin owns,
5612
+ * keyed `<setting>:<option>`. Options discovered on the machine (output style
5613
+ * names) carry no entry and keep the label the route reported. */
5614
+ const SETTING_OPTION_COPY = {
5615
+ "renderer:plugin": "rendererPlugin",
5616
+ "renderer:native": "rendererNative"
5617
+ };
5618
+ function settingOptionLabel(settingKey, option, t) {
5619
+ const key = SETTING_OPTION_COPY[`${settingKey}:${option.value}`];
5620
+ return key === void 0 ? option.label : t(key);
5621
+ }
4950
5622
  /** '?' badge that reveals a setting's effect note on hover or keyboard focus. */
4951
5623
  function SettingHint({ text, label }) {
4952
5624
  return /* @__PURE__ */ jsx(Tooltip, {
@@ -4968,8 +5640,9 @@ window.__ModuleLoader__.load({
4968
5640
  const report = value;
4969
5641
  return typeof report.available === "boolean" && typeof report.fetchedAt === "number" && Array.isArray(report.windows) && report.windows.every((entry) => typeof entry === "object" && entry !== null && typeof entry.id === "string");
4970
5642
  }
4971
- /** Coarse duration for a reset countdown or a fetch age: minutes below an
4972
- * hour, then hours, then days. Never negative — a passed reset reads '0m'. */
5643
+ /** Coarse duration for a reset countdown or the age of a reading: minutes
5644
+ * below an hour, then hours, then days. Never negative — a passed reset
5645
+ * reads '0m'. */
4973
5646
  function durationLabel(milliseconds) {
4974
5647
  const minutes = Math.max(0, Math.round(milliseconds / 6e4));
4975
5648
  if (minutes < 60) return `${minutes}m`;
@@ -4999,23 +5672,31 @@ window.__ModuleLoader__.load({
4999
5672
  })]
5000
5673
  });
5001
5674
  }
5002
- /** Every panel read is a local plugin route, so a request still outstanding
5003
- * after this long is a wedged host, not a slow one — and a settings card that
5004
- * says so beats one that says "Loading…" forever. */
5005
- const SETTINGS_REQUEST_TIMEOUT_MS = 2e4;
5006
- /** Fetch the plan usage report; POST forces the backend to re-read it. */
5007
- async function loadPlanUsage(refresh) {
5008
- const response = await fetch(CLAUDE_USAGE_PATH, {
5009
- method: refresh ? "POST" : "GET",
5010
- credentials: "same-origin",
5011
- signal: AbortSignal.timeout(SETTINGS_REQUEST_TIMEOUT_MS),
5012
- headers: { accept: "application/json" }
5675
+ function cardFailure(cause) {
5676
+ return {
5677
+ detail: cause instanceof Error ? cause.message : String(cause),
5678
+ starved: cause instanceof PluginRequestError && cause.reason === "starved"
5679
+ };
5680
+ }
5681
+ function FailureNotice({ label, failure }) {
5682
+ return /* @__PURE__ */ jsxs("p", {
5683
+ role: "alert",
5684
+ style: {
5685
+ ...notice,
5686
+ color: failure.starved ? "var(--dsw-alias-state-warning-primary, #d69e2e)" : "var(--dsw-alias-state-error-primary)"
5687
+ },
5688
+ children: [
5689
+ label,
5690
+ ": ",
5691
+ failure.detail
5692
+ ]
5013
5693
  });
5014
- const payload = await response.json();
5015
- if (!response.ok) {
5016
- const message = typeof payload === "object" && payload !== null && "error" in payload && typeof payload.error === "string" ? payload.error : `HTTP ${response.status}`;
5017
- throw new Error(message);
5018
- }
5694
+ }
5695
+ /** The plan usage report. A plain read serves the cached reading from memory;
5696
+ * the refresh spawns a probe process, which is why only it pays the remote
5697
+ * budget. */
5698
+ async function loadPlanUsage(refresh) {
5699
+ const payload = refresh ? await pluginWrite(CLAUDE_USAGE_PATH, "remote") : await pluginRead(CLAUDE_USAGE_PATH, "fast");
5019
5700
  if (!isPlanUsageReport(payload)) throw new Error("Invalid plan usage response");
5020
5701
  return payload;
5021
5702
  }
@@ -5029,7 +5710,7 @@ window.__ModuleLoader__.load({
5029
5710
  try {
5030
5711
  setReport(await load(refresh));
5031
5712
  } catch (cause) {
5032
- setError(cause instanceof Error ? cause.message : String(cause));
5713
+ setError(cardFailure(cause));
5033
5714
  } finally {
5034
5715
  setBusy(false);
5035
5716
  }
@@ -5086,17 +5767,9 @@ window.__ModuleLoader__.load({
5086
5767
  style: notice,
5087
5768
  children: t("planUsageUpdated", { age: durationLabel(now - report.fetchedAt) })
5088
5769
  }) : null,
5089
- error === void 0 ? null : /* @__PURE__ */ jsxs("p", {
5090
- role: "alert",
5091
- style: {
5092
- ...notice,
5093
- color: "var(--dsw-alias-state-error-primary)"
5094
- },
5095
- children: [
5096
- t("planUsageError"),
5097
- ": ",
5098
- error
5099
- ]
5770
+ error === void 0 ? null : /* @__PURE__ */ jsx(FailureNotice, {
5771
+ label: t("planUsageError"),
5772
+ failure: error
5100
5773
  })
5101
5774
  ]
5102
5775
  });
@@ -5120,7 +5793,7 @@ window.__ModuleLoader__.load({
5120
5793
  useEffect(() => {
5121
5794
  const controller = new AbortController();
5122
5795
  loadJiraStatus(controller.signal).then(setJiraStatus, (reason) => {
5123
- if (!controller.signal.aborted) setJiraError(reason instanceof Error ? reason.message : String(reason));
5796
+ if (!controller.signal.aborted) setJiraError(cardFailure(reason));
5124
5797
  });
5125
5798
  return () => {
5126
5799
  controller.abort();
@@ -5137,7 +5810,7 @@ window.__ModuleLoader__.load({
5137
5810
  }));
5138
5811
  setJiraToken("");
5139
5812
  } catch (cause) {
5140
- setJiraError(cause instanceof Error ? cause.message : String(cause));
5813
+ setJiraError(cardFailure(cause));
5141
5814
  } finally {
5142
5815
  setJiraBusy(false);
5143
5816
  }
@@ -5149,7 +5822,7 @@ window.__ModuleLoader__.load({
5149
5822
  await disconnectJira();
5150
5823
  setJiraStatus({ connected: false });
5151
5824
  } catch (cause) {
5152
- setJiraError(cause instanceof Error ? cause.message : String(cause));
5825
+ setJiraError(cardFailure(cause));
5153
5826
  } finally {
5154
5827
  setJiraBusy(false);
5155
5828
  }
@@ -5159,16 +5832,9 @@ window.__ModuleLoader__.load({
5159
5832
  setError(void 0);
5160
5833
  setReport(void 0);
5161
5834
  try {
5162
- const response = await fetch(CLAUDE_DOCTOR_PATH, {
5163
- credentials: "same-origin",
5164
- signal: AbortSignal.timeout(SETTINGS_REQUEST_TIMEOUT_MS),
5165
- headers: { accept: "application/json" }
5166
- });
5167
- const payload = await response.json();
5168
- if (!response.ok) throw new Error("error" in payload ? payload.error : `HTTP ${response.status}`);
5169
- setReport(payload);
5835
+ setReport(await pluginRead(CLAUDE_DOCTOR_PATH, "fast"));
5170
5836
  } catch (cause) {
5171
- setError(cause instanceof Error ? cause.message : String(cause));
5837
+ setError(cardFailure(cause));
5172
5838
  } finally {
5173
5839
  setBusy(false);
5174
5840
  }
@@ -5180,25 +5846,14 @@ window.__ModuleLoader__.load({
5180
5846
  setGlobalSettingsBusy(true);
5181
5847
  setGlobalSettingsError(void 0);
5182
5848
  try {
5183
- const response = await fetch(CLAUDE_GLOBAL_SETTINGS_PATH, {
5184
- method: changes === void 0 ? "GET" : "PATCH",
5185
- credentials: "same-origin",
5186
- signal: AbortSignal.timeout(SETTINGS_REQUEST_TIMEOUT_MS),
5187
- headers: {
5188
- accept: "application/json",
5189
- ...changes === void 0 ? {} : { "content-type": "application/json" }
5190
- },
5191
- ...changes === void 0 ? {} : { body: JSON.stringify({ changes }) }
5849
+ const payload = changes === void 0 ? await pluginRead(CLAUDE_GLOBAL_SETTINGS_PATH, "fast") : await pluginWrite(CLAUDE_GLOBAL_SETTINGS_PATH, "fast", void 0, {
5850
+ method: "PATCH",
5851
+ json: { changes }
5192
5852
  });
5193
- const payload = await response.json();
5194
- if (!response.ok) {
5195
- const message = typeof payload === "object" && payload !== null && "error" in payload && typeof payload.error === "string" ? payload.error : `HTTP ${response.status}`;
5196
- throw new Error(message);
5197
- }
5198
5853
  if (!isGlobalSettingsView(payload)) throw new Error("Invalid global settings response");
5199
5854
  setGlobalSettings(payload);
5200
5855
  } catch (cause) {
5201
- setGlobalSettingsError(cause instanceof Error ? cause.message : String(cause));
5856
+ setGlobalSettingsError(cardFailure(cause));
5202
5857
  } finally {
5203
5858
  setGlobalSettingsBusy(false);
5204
5859
  }
@@ -5210,20 +5865,11 @@ window.__ModuleLoader__.load({
5210
5865
  setUpdateBusy(action);
5211
5866
  setUpdateError(void 0);
5212
5867
  try {
5213
- const response = await fetch(action === "check" ? CLAUDE_UPDATE_CHECK_PATH : CLAUDE_UPDATE_PATH, {
5214
- method: action === "check" ? "GET" : "POST",
5215
- credentials: "same-origin",
5216
- headers: { accept: "application/json" }
5217
- });
5218
- const payload = await response.json();
5219
- if (!response.ok) {
5220
- const message = typeof payload === "object" && payload !== null && "error" in payload && typeof payload.error === "string" ? payload.error : `HTTP ${response.status}`;
5221
- throw new Error(message);
5222
- }
5868
+ const payload = action === "check" ? await pluginRead(CLAUDE_UPDATE_CHECK_PATH, "remote") : await pluginWrite(CLAUDE_UPDATE_PATH, "remote");
5223
5869
  if (!isPluginUpdateStatus(payload)) throw new Error("Invalid update response");
5224
5870
  setUpdateStatus(payload);
5225
5871
  } catch (cause) {
5226
- setUpdateError(cause instanceof Error ? cause.message : String(cause));
5872
+ setUpdateError(cardFailure(cause));
5227
5873
  } finally {
5228
5874
  setUpdateBusy(void 0);
5229
5875
  }
@@ -5293,17 +5939,9 @@ window.__ModuleLoader__.load({
5293
5939
  children: rowValue
5294
5940
  }, `${label}-value`)])
5295
5941
  }),
5296
- error === void 0 ? null : /* @__PURE__ */ jsxs("p", {
5297
- role: "alert",
5298
- style: {
5299
- ...notice,
5300
- color: "var(--dsw-alias-state-error-primary)"
5301
- },
5302
- children: [
5303
- t("error"),
5304
- ": ",
5305
- error
5306
- ]
5942
+ error === void 0 ? null : /* @__PURE__ */ jsx(FailureNotice, {
5943
+ label: t("error"),
5944
+ failure: error
5307
5945
  })
5308
5946
  ]
5309
5947
  }),
@@ -5337,6 +5975,7 @@ window.__ModuleLoader__.load({
5337
5975
  }), setting.kind === "select" ? /* @__PURE__ */ jsx(GlobalSettingSelect, {
5338
5976
  setting,
5339
5977
  disabled: globalSettingsBusy,
5978
+ labelFor: (option) => settingOptionLabel(setting.key, option, t),
5340
5979
  onChange: (nextValue) => {
5341
5980
  requestGlobalSettings({ [setting.key]: nextValue });
5342
5981
  }
@@ -5349,17 +5988,9 @@ window.__ModuleLoader__.load({
5349
5988
  })]
5350
5989
  }, setting.key);
5351
5990
  }),
5352
- globalSettingsError === void 0 ? null : /* @__PURE__ */ jsxs("p", {
5353
- role: "alert",
5354
- style: {
5355
- ...notice,
5356
- color: "var(--dsw-alias-state-error-primary)"
5357
- },
5358
- children: [
5359
- t("globalSettingsError"),
5360
- ": ",
5361
- globalSettingsError
5362
- ]
5991
+ globalSettingsError === void 0 ? null : /* @__PURE__ */ jsx(FailureNotice, {
5992
+ label: t("globalSettingsError"),
5993
+ failure: globalSettingsError
5363
5994
  })
5364
5995
  ]
5365
5996
  }),
@@ -5465,17 +6096,9 @@ window.__ModuleLoader__.load({
5465
6096
  })]
5466
6097
  })
5467
6098
  ] }),
5468
- jiraError === void 0 ? null : /* @__PURE__ */ jsxs("p", {
5469
- role: "alert",
5470
- style: {
5471
- ...notice,
5472
- color: "var(--dsw-alias-state-error-primary)"
5473
- },
5474
- children: [
5475
- t("jiraError"),
5476
- ": ",
5477
- jiraError
5478
- ]
6099
+ jiraError === void 0 ? null : /* @__PURE__ */ jsx(FailureNotice, {
6100
+ label: t("jiraError"),
6101
+ failure: jiraError
5479
6102
  })
5480
6103
  ]
5481
6104
  }),
@@ -5536,17 +6159,9 @@ window.__ModuleLoader__.load({
5536
6159
  style: notice,
5537
6160
  children: t("restartRequired")
5538
6161
  }),
5539
- updateError === void 0 ? null : /* @__PURE__ */ jsxs("p", {
5540
- role: "alert",
5541
- style: {
5542
- ...notice,
5543
- color: "var(--dsw-alias-state-error-primary)"
5544
- },
5545
- children: [
5546
- t("updateError"),
5547
- ": ",
5548
- updateError
5549
- ]
6162
+ updateError === void 0 ? null : /* @__PURE__ */ jsx(FailureNotice, {
6163
+ label: t("updateError"),
6164
+ failure: updateError
5550
6165
  }),
5551
6166
  /* @__PURE__ */ jsxs("div", {
5552
6167
  style: settingsActions,
@@ -5598,14 +6213,14 @@ window.__ModuleLoader__.load({
5598
6213
  function record$4(value) {
5599
6214
  return value !== null && typeof value === "object" && !Array.isArray(value) ? value : void 0;
5600
6215
  }
5601
- async function response$1(pending) {
5602
- const result = await pending;
5603
- const body = await result.json();
5604
- if (!result.ok) {
5605
- const error = record$4(body);
5606
- throw new RepositoryActionClientError(typeof error?.message === "string" ? error.message : "Repository action failed.", typeof error?.error === "string" ? error.error : void 0, typeof error?.commit === "string" ? error.commit : void 0);
5607
- }
5608
- return body;
6216
+ /** The dialog branches on `code`, so a route refusal keeps arriving as this
6217
+ * class rather than as the transport's own error.
6218
+ *
6219
+ * `commit` cannot be carried across: the transport forwards a failed route's
6220
+ * message and error code, not the rest of its body, so a commit that survived
6221
+ * a failed push no longer reaches the dialog that offers its hash. */
6222
+ function actionError(error) {
6223
+ return error instanceof PluginRequestError ? new RepositoryActionClientError(error.message, error.code) : error;
5609
6224
  }
5610
6225
  function preview(value) {
5611
6226
  const input = record$4(value);
@@ -5625,42 +6240,35 @@ window.__ModuleLoader__.load({
5625
6240
  if (input === void 0 || typeof input.commit !== "string" || typeof input.pushed !== "boolean" || input.pullRequestUrl !== void 0 && typeof input.pullRequestUrl !== "string" || input.conflicts !== void 0 && (!Array.isArray(input.conflicts) || input.conflicts.some((item) => typeof item !== "string"))) throw new Error("Invalid repository action result.");
5626
6241
  return input;
5627
6242
  }
5628
- function endpoint(path, sessionId) {
5629
- return `${CLAUDE_REPOSITORY_ACTION_PATH}${path}?sessionId=${encodeURIComponent(sessionId)}`;
5630
- }
6243
+ /** The preview only chains local Git; everything that writes may reach a remote. */
5631
6244
  async function loadRepositoryActionPreview(sessionId, signal) {
5632
- return preview(await response$1(fetch(endpoint("/preview", sessionId), {
5633
- method: "GET",
5634
- credentials: "same-origin",
5635
- headers: { accept: "application/json" },
5636
- signal: pluginRequestSignal(PLUGIN_READ_TIMEOUT_MS, signal)
5637
- })));
6245
+ try {
6246
+ return preview(await pluginRead(`${CLAUDE_REPOSITORY_ACTION_PATH}/preview`, "git", signal, { query: { sessionId } }));
6247
+ } catch (error) {
6248
+ throw actionError(error);
6249
+ }
5638
6250
  }
5639
6251
  async function generateCommitMessage(sessionId, fingerprint, signal) {
5640
- const value = record$4(await response$1(fetch(endpoint("/message", sessionId), {
5641
- method: "POST",
5642
- credentials: "same-origin",
5643
- headers: {
5644
- accept: "application/json",
5645
- "content-type": "application/json"
5646
- },
5647
- body: JSON.stringify({ fingerprint }),
5648
- signal: pluginRequestSignal(PLUGIN_ACTION_TIMEOUT_MS, signal)
5649
- })));
5650
- if (typeof value?.message !== "string") throw new Error("Invalid generated commit message.");
5651
- return value.message;
5652
- }
5653
- function executeRepositoryAction(sessionId, request) {
5654
- return response$1(fetch(endpoint("", sessionId), {
5655
- method: "POST",
5656
- credentials: "same-origin",
5657
- headers: {
5658
- accept: "application/json",
5659
- "content-type": "application/json"
5660
- },
5661
- body: JSON.stringify(request),
5662
- signal: pluginRequestSignal(PLUGIN_ACTION_TIMEOUT_MS)
5663
- })).then(result);
6252
+ try {
6253
+ const value = record$4(await pluginWrite(`${CLAUDE_REPOSITORY_ACTION_PATH}/message`, "remote", signal, {
6254
+ query: { sessionId },
6255
+ json: { fingerprint }
6256
+ }));
6257
+ if (typeof value?.message !== "string") throw new Error("Invalid generated commit message.");
6258
+ return value.message;
6259
+ } catch (error) {
6260
+ throw actionError(error);
6261
+ }
6262
+ }
6263
+ async function executeRepositoryAction(sessionId, request) {
6264
+ try {
6265
+ return result(await pluginWrite(CLAUDE_REPOSITORY_ACTION_PATH, "remote", void 0, {
6266
+ query: { sessionId },
6267
+ json: request
6268
+ }));
6269
+ } catch (error) {
6270
+ throw actionError(error);
6271
+ }
5664
6272
  }
5665
6273
  //#endregion
5666
6274
  //#region src/client/action-toast.tsx
@@ -5722,130 +6330,79 @@ window.__ModuleLoader__.load({
5722
6330
  if (event?.type === "error" && typeof event.message === "string") throw new Error(event.message);
5723
6331
  throw new Error("Invalid repository setup progress response.");
5724
6332
  }
5725
- async function response(pending) {
5726
- const result = await pending;
5727
- const body = await result.json();
5728
- if (!result.ok) {
5729
- const error = body;
5730
- throw new Error(error.message ?? error.error ?? "Repository setup failed.");
5731
- }
5732
- return body;
5733
- }
5734
- /** A wedged host must surface as an error; the hero has no other way out of its loading state. */
5735
- const BRANCH_LOAD_TIMEOUT_MS = 15e3;
5736
6333
  function loadRepositoryBranches(cwd, signal) {
5737
- return response(fetch(`${CLAUDE_REPOSITORY_SETUP_PATH}/branches?cwd=${encodeURIComponent(cwd)}`, {
5738
- method: "GET",
5739
- credentials: "same-origin",
5740
- headers: { accept: "application/json" },
5741
- signal: pluginRequestSignal(BRANCH_LOAD_TIMEOUT_MS, signal)
5742
- }));
6334
+ return pluginRead(`${CLAUDE_REPOSITORY_SETUP_PATH}/branches`, "git", signal, { query: { cwd } });
5743
6335
  }
5744
6336
  /** Pull remote refs down first, then list: a POST because `--prune` rewrites
5745
- * this checkout's remote-tracking refs, and on the action deadline because the
6337
+ * this checkout's remote-tracking refs, and on the remote budget because the
5746
6338
  * host's own `git fetch` runs for up to a minute. */
5747
6339
  async function refreshRepositoryBranches(cwd, signal) {
5748
- const result = await fetch(`${CLAUDE_REPOSITORY_SETUP_PATH}/branches/refresh`, {
5749
- method: "POST",
5750
- credentials: "same-origin",
5751
- headers: {
5752
- accept: "application/json",
5753
- "content-type": "application/json"
5754
- },
5755
- signal: pluginRequestSignal(PLUGIN_ACTION_TIMEOUT_MS, signal),
5756
- body: JSON.stringify({ cwd })
5757
- });
5758
- if (result.status === 404) throw new Error("route-missing");
5759
- return response(Promise.resolve(result));
6340
+ try {
6341
+ return await pluginWrite(`${CLAUDE_REPOSITORY_SETUP_PATH}/branches/refresh`, "remote", signal, { json: { cwd } });
6342
+ } catch (error) {
6343
+ if (error instanceof PluginRequestError && error.reason === "route-missing") throw new Error("route-missing");
6344
+ throw error;
6345
+ }
5760
6346
  }
5761
6347
  async function prepareRepository(cwd, branch, worktree, branchName, onProgress = () => {}) {
5762
- const result = await fetch(CLAUDE_REPOSITORY_SETUP_PATH, {
5763
- method: "POST",
5764
- credentials: "same-origin",
5765
- headers: {
5766
- accept: "application/x-ndjson",
5767
- "content-type": "application/json"
5768
- },
5769
- body: JSON.stringify({
5770
- cwd,
5771
- branch,
5772
- worktree,
5773
- ...branchName === void 0 ? {} : { branchName }
5774
- })
5775
- });
5776
- if (!result.ok) {
5777
- const body = await result.json();
5778
- throw new Error(body.message ?? body.error ?? "Repository setup failed.");
5779
- }
5780
- if (result.body === null) throw new Error("Repository setup progress stream is unavailable.");
5781
- const reader = result.body.getReader();
5782
- const decoder = new TextDecoder();
5783
- let buffer = "";
5784
- let completed;
5785
- while (true) {
5786
- const chunk = await reader.read();
5787
- buffer += decoder.decode(chunk.value, { stream: !chunk.done });
5788
- const lines = buffer.split("\n");
5789
- buffer = lines.pop() ?? "";
5790
- for (const line of lines) {
5791
- if (line.trim().length === 0) continue;
5792
- const value = parseRepositorySetupEvent(line, onProgress);
6348
+ const carrier = new AbortController();
6349
+ try {
6350
+ const reader = await pluginNdjson(CLAUDE_REPOSITORY_SETUP_PATH, carrier.signal, {
6351
+ method: "POST",
6352
+ json: {
6353
+ cwd,
6354
+ branch,
6355
+ worktree,
6356
+ ...branchName === void 0 ? {} : { branchName }
6357
+ }
6358
+ });
6359
+ const decoder = new TextDecoder();
6360
+ let buffer = "";
6361
+ let completed;
6362
+ while (true) {
6363
+ const chunk = await reader.read();
6364
+ buffer += decoder.decode(chunk.value, { stream: !chunk.done });
6365
+ const lines = buffer.split("\n");
6366
+ buffer = lines.pop() ?? "";
6367
+ for (const line of lines) {
6368
+ if (line.trim().length === 0) continue;
6369
+ const value = parseRepositorySetupEvent(line, onProgress);
6370
+ if (value !== void 0) completed = value;
6371
+ }
6372
+ if (chunk.done) break;
6373
+ }
6374
+ if (buffer.trim().length > 0) {
6375
+ const value = parseRepositorySetupEvent(buffer, onProgress);
5793
6376
  if (value !== void 0) completed = value;
5794
6377
  }
5795
- if (chunk.done) break;
5796
- }
5797
- if (buffer.trim().length > 0) {
5798
- const value = parseRepositorySetupEvent(buffer, onProgress);
5799
- if (value !== void 0) completed = value;
6378
+ if (completed === void 0) throw new Error("Repository setup progress ended before completion.");
6379
+ return completed;
6380
+ } finally {
6381
+ carrier.abort();
5800
6382
  }
5801
- if (completed === void 0) throw new Error("Repository setup progress ended before completion.");
5802
- return completed;
5803
6383
  }
5804
- /** Bookkeeping, not a gate: a wedged host must fail this instead of hanging on
5805
- * a connection the rest of the flow is waiting behind. */
5806
- const LEASE_BIND_TIMEOUT_MS = 1e4;
5807
6384
  async function bindRepositoryLease(leaseId, sessionId) {
5808
- await response(fetch(`${CLAUDE_REPOSITORY_SETUP_PATH}/bind`, {
5809
- method: "POST",
5810
- credentials: "same-origin",
5811
- signal: pluginRequestSignal(LEASE_BIND_TIMEOUT_MS),
5812
- headers: {
5813
- accept: "application/json",
5814
- "content-type": "application/json"
5815
- },
5816
- body: JSON.stringify({
5817
- leaseId,
5818
- sessionId
5819
- })
5820
- }));
6385
+ await pluginWrite(`${CLAUDE_REPOSITORY_SETUP_PATH}/bind`, "remote", void 0, { json: {
6386
+ leaseId,
6387
+ sessionId
6388
+ } });
5821
6389
  }
5822
6390
  async function cleanupMergedRepository(path, baseBranch) {
5823
- const body = await response(fetch(`${CLAUDE_REPOSITORY_SETUP_PATH}/cleanup`, {
5824
- signal: pluginRequestSignal(PLUGIN_ACTION_TIMEOUT_MS),
5825
- method: "POST",
5826
- credentials: "same-origin",
5827
- headers: {
5828
- accept: "application/json",
5829
- "content-type": "application/json"
5830
- },
5831
- body: JSON.stringify({
5832
- path,
5833
- baseBranch
5834
- })
5835
- }));
6391
+ const body = await pluginWrite(`${CLAUDE_REPOSITORY_SETUP_PATH}/cleanup`, "remote", void 0, { json: {
6392
+ path,
6393
+ baseBranch
6394
+ } });
5836
6395
  if (body.mode !== "worktree" && body.mode !== "checkout" || typeof body.root !== "string" || typeof body.branch !== "string") throw new Error("Invalid repository cleanup response.");
5837
6396
  return body;
5838
6397
  }
5839
6398
  /** Lines [from, to] of a working-tree file plus its total line count, for expanding unmodified diff context. */
5840
6399
  async function loadRepositoryFileLines(cwd, path, from, to, signal) {
5841
- const query = `cwd=${encodeURIComponent(cwd)}&path=${encodeURIComponent(path)}&from=${from}&to=${to}`;
5842
- const body = await response(fetch(`${CLAUDE_REPOSITORY_FILE_PATH}?${query}`, {
5843
- signal: pluginRequestSignal(PLUGIN_READ_TIMEOUT_MS),
5844
- method: "GET",
5845
- credentials: "same-origin",
5846
- headers: { accept: "application/json" },
5847
- ...signal === void 0 ? {} : { signal }
5848
- }));
6400
+ const body = await pluginRead(CLAUDE_REPOSITORY_FILE_PATH, "git", signal, { query: {
6401
+ cwd,
6402
+ path,
6403
+ from: String(from),
6404
+ to: String(to)
6405
+ } });
5849
6406
  if (!Array.isArray(body.lines) || typeof body.total !== "number") throw new Error("Invalid repository file response.");
5850
6407
  return {
5851
6408
  lines: body.lines.map(String),
@@ -5853,13 +6410,7 @@ window.__ModuleLoader__.load({
5853
6410
  };
5854
6411
  }
5855
6412
  async function loadRepositoryStatusFor(cwd, signal) {
5856
- const body = await response(fetch(`${CLAUDE_REPOSITORY_STATUS_PATH}?cwd=${encodeURIComponent(cwd)}`, {
5857
- signal: pluginRequestSignal(PLUGIN_READ_TIMEOUT_MS),
5858
- method: "GET",
5859
- credentials: "same-origin",
5860
- headers: { accept: "application/json" },
5861
- ...signal === void 0 ? {} : { signal }
5862
- }));
6413
+ const body = await pluginRead(CLAUDE_REPOSITORY_STATUS_PATH, "git", signal, { query: { cwd } });
5863
6414
  if (typeof body.status !== "string" || typeof body.cwd !== "string") throw new Error("Invalid repository status response.");
5864
6415
  return body;
5865
6416
  }
@@ -5896,34 +6447,27 @@ window.__ModuleLoader__.load({
5896
6447
  function record$2(value) {
5897
6448
  return value !== null && typeof value === "object" && !Array.isArray(value) ? value : void 0;
5898
6449
  }
5899
- function feedbackUrl(path, sessionId, pullNumber, extra = "") {
5900
- return `${CLAUDE_REPOSITORY_FEEDBACK_PATH}${path}?sessionId=${encodeURIComponent(sessionId)}&number=${pullNumber}${extra}`;
6450
+ function feedbackQuery(sessionId, pullNumber, extra) {
6451
+ return {
6452
+ sessionId,
6453
+ number: String(pullNumber),
6454
+ ...extra
6455
+ };
5901
6456
  }
5902
- async function answer(response) {
5903
- const body = record$2(await response.json());
5904
- if (!response.ok) throw new Error(typeof body?.message === "string" ? body.message : "Pull request feedback is unavailable.");
6457
+ function answer(value) {
6458
+ const body = record$2(value);
5905
6459
  if (body === void 0) throw new Error("Invalid pull request feedback response.");
5906
6460
  return body;
5907
6461
  }
5908
- async function loadJson(path, sessionId, pullNumber, signal, extra = "") {
5909
- return answer(await fetch(feedbackUrl(path, sessionId, pullNumber, extra), {
5910
- method: "GET",
5911
- credentials: "same-origin",
5912
- headers: { accept: "application/json" },
5913
- signal: pluginRequestSignal(PLUGIN_READ_TIMEOUT_MS, signal)
5914
- }));
6462
+ /** Every arm of this route shells out to `gh`, so reads and writes alike take
6463
+ * the remote budget. */
6464
+ async function loadJson(path, sessionId, pullNumber, signal, extra) {
6465
+ return answer(await pluginRead(`${CLAUDE_REPOSITORY_FEEDBACK_PATH}${path}`, "remote", signal, { query: feedbackQuery(sessionId, pullNumber, extra) }));
5915
6466
  }
5916
- /** Writes reach GitHub through `gh`, so they get the action deadline. */
5917
6467
  async function postJson(path, sessionId, pullNumber, input) {
5918
- return answer(await fetch(feedbackUrl(path, sessionId, pullNumber), {
5919
- method: "POST",
5920
- credentials: "same-origin",
5921
- headers: {
5922
- accept: "application/json",
5923
- "content-type": "application/json"
5924
- },
5925
- signal: pluginRequestSignal(PLUGIN_ACTION_TIMEOUT_MS),
5926
- body: JSON.stringify(input)
6468
+ return answer(await pluginWrite(`${CLAUDE_REPOSITORY_FEEDBACK_PATH}${path}`, "remote", void 0, {
6469
+ query: feedbackQuery(sessionId, pullNumber),
6470
+ json: input
5927
6471
  }));
5928
6472
  }
5929
6473
  function reviewComment(value) {
@@ -5972,7 +6516,7 @@ window.__ModuleLoader__.load({
5972
6516
  }
5973
6517
  /** Logins GitHub would notify, for the reply composer's `@` completion. */
5974
6518
  async function loadMentionableUsers(sessionId, pullNumber, query, signal) {
5975
- const body = await loadJson("/mentionables", sessionId, pullNumber, signal, `&q=${encodeURIComponent(query)}`);
6519
+ const body = await loadJson("/mentionables", sessionId, pullNumber, signal, { q: query });
5976
6520
  if (!Array.isArray(body.users)) return [];
5977
6521
  const users = [];
5978
6522
  for (const item of body.users) {
@@ -7128,22 +7672,10 @@ window.__ModuleLoader__.load({
7128
7672
  return value !== null && typeof value === "object" && !Array.isArray(value) ? value : void 0;
7129
7673
  }
7130
7674
  async function post(path, sessionId, body) {
7131
- const response = await fetch(`${CLAUDE_REVIEW_COMMENT_PATH}${path}?sessionId=${encodeURIComponent(sessionId)}`, {
7132
- method: "POST",
7133
- credentials: "same-origin",
7134
- headers: {
7135
- accept: "application/json",
7136
- "content-type": "application/json"
7137
- },
7138
- body: JSON.stringify(body),
7139
- signal: pluginRequestSignal(PLUGIN_READ_TIMEOUT_MS)
7675
+ return await pluginWrite(`${CLAUDE_REVIEW_COMMENT_PATH}${path}`, "fast", void 0, {
7676
+ query: { sessionId },
7677
+ json: body
7140
7678
  });
7141
- const value = await response.json();
7142
- if (!response.ok) {
7143
- const error = record$1(value);
7144
- throw new Error(typeof error?.message === "string" ? error.message : "Review comment request failed.");
7145
- }
7146
- return value;
7147
7679
  }
7148
7680
  async function addReviewComment(sessionId, comment) {
7149
7681
  const created = record$1(record$1(await post("", sessionId, comment))?.comment);
@@ -13302,6 +13834,16 @@ window.__ModuleLoader__.load({
13302
13834
  });
13303
13835
  }
13304
13836
  //#endregion
13837
+ //#region src/client/session-preset.ts
13838
+ /**
13839
+ * Resolve one row's preset id, newest seat first.
13840
+ * @param row - a session-list row, or undefined when the id is not listed.
13841
+ * @returns the preset id, or undefined when neither source carries one.
13842
+ */
13843
+ function sessionRowPreset(row) {
13844
+ return row?.agentPreset ?? row?.projectionValues?.agentPreset;
13845
+ }
13846
+ //#endregion
13305
13847
  //#region src/client/ClaudePullRequestsPanel.tsx
13306
13848
  const NO_WORKSPACE_STATE = {};
13307
13849
  const NO_WORKSPACES = {
@@ -13324,7 +13866,7 @@ window.__ModuleLoader__.load({
13324
13866
  function claudeSessionRows(state, archivedSessionIds = []) {
13325
13867
  const rows = state.ids === void 0 ? Object.values(state.byId) : state.ids.map((id) => state.byId[id]);
13326
13868
  const archived = new Set(archivedSessionIds);
13327
- return rows.filter((row) => row !== void 0 && row.agentPreset === "claude" && row.blank !== true && row.origin !== "subagent" && !archived.has(row.id) && typeof row.cwd === "string").sort((left, right) => Number(right.running === true) - Number(left.running === true) || (left.displayTitle ?? left.id).localeCompare(right.displayTitle ?? right.id));
13869
+ return rows.filter((row) => row !== void 0 && sessionRowPreset(row) === "claude" && row.blank !== true && row.origin !== "subagent" && !archived.has(row.id) && typeof row.cwd === "string").sort((left, right) => Number(right.running === true) - Number(left.running === true) || (left.displayTitle ?? left.id).localeCompare(right.displayTitle ?? right.id));
13328
13870
  }
13329
13871
  function Badge({ label, tone = "neutral" }) {
13330
13872
  const toneStyle = tone === "success" ? repositoryItemSuccess : tone === "warning" ? repositoryItemWarning : tone === "error" ? repositoryItemError : tone === "merged" ? { color: "#a78bfa" } : {};
@@ -13519,56 +14061,64 @@ window.__ModuleLoader__.load({
13519
14061
  };
13520
14062
  throw new Error("Invalid ask stream event.");
13521
14063
  }
14064
+ async function openAnswer(sessionId, request, cancel) {
14065
+ try {
14066
+ return await pluginNdjson(CLAUDE_ASK_PATH, cancel, {
14067
+ method: "POST",
14068
+ query: { sessionId },
14069
+ json: request
14070
+ });
14071
+ } catch (error) {
14072
+ if (error instanceof PluginRequestError && error.reason === "http") throw new Error("The question could not be sent.");
14073
+ throw error;
14074
+ }
14075
+ }
13522
14076
  /** Stream an answer about selected reply text; resolves when the answer completes. */
13523
14077
  async function askAboutSelection(sessionId, request, onProgress, signal) {
13524
- const response = await fetch(`${CLAUDE_ASK_PATH}?sessionId=${encodeURIComponent(sessionId)}`, {
13525
- method: "POST",
13526
- credentials: "same-origin",
13527
- headers: {
13528
- accept: "application/x-ndjson",
13529
- "content-type": "application/json"
13530
- },
13531
- body: JSON.stringify(request),
13532
- ...signal === void 0 ? {} : { signal }
13533
- });
13534
- if (!response.ok) {
13535
- const body = record(await response.json().catch(() => void 0));
13536
- throw new Error(typeof body?.message === "string" ? body.message : "The question could not be sent.");
13537
- }
13538
- if (response.body === null) throw new Error("The answer stream is unavailable.");
13539
- const reader = response.body.getReader();
13540
- const decoder = new TextDecoder();
13541
- let buffer = "";
13542
- let finished = false;
13543
- const handle = (line) => {
13544
- if (line.trim().length === 0) return;
13545
- const event = parseAskEvent(line);
13546
- if (event.type === "delta") onProgress({
13547
- type: "text",
13548
- text: event.text
13549
- });
13550
- else if (event.type === "thinking") onProgress({
13551
- type: "thinking",
13552
- text: event.text
13553
- });
13554
- else if (event.type === "status") onProgress({
13555
- type: "status",
13556
- text: event.text
13557
- });
13558
- else if (event.type === "tool") onProgress(event);
13559
- else if (event.type === "done") finished = true;
13560
- else throw new Error(event.message);
14078
+ const carrier = new AbortController();
14079
+ const stop = () => {
14080
+ carrier.abort();
13561
14081
  };
13562
- while (true) {
13563
- const chunk = await reader.read();
13564
- buffer += decoder.decode(chunk.value, { stream: !chunk.done });
13565
- const lines = buffer.split("\n");
13566
- buffer = lines.pop() ?? "";
13567
- for (const line of lines) handle(line);
13568
- if (chunk.done) break;
14082
+ signal?.addEventListener("abort", stop, { once: true });
14083
+ if (signal?.aborted === true) carrier.abort();
14084
+ try {
14085
+ const reader = await openAnswer(sessionId, request, carrier.signal);
14086
+ const decoder = new TextDecoder();
14087
+ let buffer = "";
14088
+ let finished = false;
14089
+ const handle = (line) => {
14090
+ if (line.trim().length === 0) return;
14091
+ const event = parseAskEvent(line);
14092
+ if (event.type === "delta") onProgress({
14093
+ type: "text",
14094
+ text: event.text
14095
+ });
14096
+ else if (event.type === "thinking") onProgress({
14097
+ type: "thinking",
14098
+ text: event.text
14099
+ });
14100
+ else if (event.type === "status") onProgress({
14101
+ type: "status",
14102
+ text: event.text
14103
+ });
14104
+ else if (event.type === "tool") onProgress(event);
14105
+ else if (event.type === "done") finished = true;
14106
+ else throw new Error(event.message);
14107
+ };
14108
+ while (true) {
14109
+ const chunk = await reader.read();
14110
+ buffer += decoder.decode(chunk.value, { stream: !chunk.done });
14111
+ const lines = buffer.split("\n");
14112
+ buffer = lines.pop() ?? "";
14113
+ for (const line of lines) handle(line);
14114
+ if (chunk.done) break;
14115
+ }
14116
+ handle(buffer);
14117
+ if (!finished) throw new Error("The answer ended unexpectedly.");
14118
+ } finally {
14119
+ signal?.removeEventListener("abort", stop);
14120
+ carrier.abort();
13569
14121
  }
13570
- handle(buffer);
13571
- if (!finished) throw new Error("The answer ended unexpectedly.");
13572
14122
  }
13573
14123
  //#endregion
13574
14124
  //#region src/client/ClaudeSelectionAsk.tsx
@@ -14124,13 +14674,12 @@ window.__ModuleLoader__.load({
14124
14674
  });
14125
14675
  return stop;
14126
14676
  }
14677
+ /** A beacon rather than a request: a finding is dropped when the connection
14678
+ * budget is spent, because the channel that reports the plugin's own failures
14679
+ * must never be the traffic that causes them — and must never queue behind
14680
+ * them either. */
14127
14681
  function postToHost(report) {
14128
- fetch(CLAUDE_CLIENT_DIAGNOSTICS_PATH, {
14129
- method: "POST",
14130
- credentials: "same-origin",
14131
- headers: { "content-type": "application/json" },
14132
- body: JSON.stringify(report)
14133
- }).catch(() => {});
14682
+ pluginBeacon(CLAUDE_CLIENT_DIAGNOSTICS_PATH, report);
14134
14683
  }
14135
14684
  /** Renderer-side findings reach the Host log through here.
14136
14685
  *
@@ -14217,23 +14766,15 @@ window.__ModuleLoader__.load({
14217
14766
  /** Drop one user message and everything after it: the rows are hidden from
14218
14767
  * this session's transcript and Claude resumes before that turn. */
14219
14768
  async function rewindSession(sessionId, seq) {
14220
- const result = await fetch(CLAUDE_REWIND_PATH, {
14221
- method: "POST",
14222
- credentials: "same-origin",
14223
- signal: pluginRequestSignal(PLUGIN_READ_TIMEOUT_MS),
14224
- headers: {
14225
- "content-type": "application/json",
14226
- accept: "application/json"
14227
- },
14228
- body: JSON.stringify({
14769
+ try {
14770
+ await pluginWrite(CLAUDE_REWIND_PATH, "fast", void 0, { json: {
14229
14771
  sessionId,
14230
14772
  seq
14231
- })
14232
- });
14233
- if (result.ok) return;
14234
- if (result.status === 404) throw new Error("route-missing");
14235
- const body = await result.json().catch(() => void 0);
14236
- throw new Error(typeof body?.error === "string" ? body.error : `http-${result.status}`);
14773
+ } });
14774
+ } catch (error) {
14775
+ if (!(error instanceof PluginRequestError)) throw error;
14776
+ throw new Error(error.code ?? error.reason);
14777
+ }
14237
14778
  }
14238
14779
  //#endregion
14239
14780
  //#region src/client/ClaudeRewind.tsx
@@ -15449,15 +15990,10 @@ window.__ModuleLoader__.load({
15449
15990
  //#region src/client/editor-open-api.ts
15450
15991
  /** Launch the session's project in a desktop editor on the host machine. */
15451
15992
  async function openProjectInEditor(sessionId, editor) {
15452
- const result = await fetch(`${CLAUDE_EDITOR_OPEN_PATH}?sessionId=${encodeURIComponent(sessionId)}&editor=${encodeURIComponent(editor)}`, {
15453
- method: "POST",
15454
- credentials: "same-origin",
15455
- signal: pluginRequestSignal(PLUGIN_READ_TIMEOUT_MS),
15456
- headers: { accept: "application/json" }
15457
- });
15458
- if (result.ok) return;
15459
- const body = await result.json().catch(() => void 0);
15460
- throw new Error(typeof body?.message === "string" ? body.message : "The editor could not be launched.");
15993
+ await pluginWrite(CLAUDE_EDITOR_OPEN_PATH, "fast", void 0, { query: {
15994
+ sessionId,
15995
+ editor
15996
+ } });
15461
15997
  }
15462
15998
  //#endregion
15463
15999
  //#region src/client/ClaudeSessionMenu.tsx
@@ -15729,7 +16265,7 @@ window.__ModuleLoader__.load({
15729
16265
  document.head.appendChild(element);
15730
16266
  }
15731
16267
  function ClaudeAgentPresetLabel({ t, hostT, roster, sessionId, useSessions }) {
15732
- const preset = useSessions((state) => state.byId[sessionId]?.agentPreset);
16268
+ const preset = useSessions((state) => sessionRowPreset(state.byId[sessionId]));
15733
16269
  const rows = useSyncExternalStore(roster.subscribe, roster.getSnapshot, roster.getSnapshot);
15734
16270
  const { load } = roster;
15735
16271
  useEffect(() => {
@@ -16162,6 +16698,10 @@ window.__ModuleLoader__.load({
16162
16698
  globalSettingsNewSession: "修改仅对新建 Claude 会话生效。",
16163
16699
  globalSettingsError: "全局设置保存失败",
16164
16700
  outputStyle: "Output Style",
16701
+ renderer: "AI 输出渲染器",
16702
+ rendererPlugin: "插件渲染器",
16703
+ rendererNative: "DSH 原生渲染器",
16704
+ rendererEffect: "插件渲染器沿用本插件自带的转录视图:交错的正文、成组的工具卡片与活动行。DSH 原生渲染器改由 DSH 自身绘制:正文作为普通助手文本块,思考作为推理块,Claude 的顶层工具会镜像成原生工具卡片。修改从下一个回合起生效;已经产生的回合仍按记录时的渲染器显示,不会重绘。",
16165
16705
  worktreeBranchPrefix: "Worktree 分支前缀",
16166
16706
  worktreeBranchPrefixEffect: "分支前缀修改会应用于之后自动创建的 Worktree 分支;显式指定的完整分支名不会被修改。",
16167
16707
  maxProcessesSetting: "Claude 进程上限",
@@ -16522,6 +17062,10 @@ window.__ModuleLoader__.load({
16522
17062
  globalSettingsNewSession: "Changes apply only to new Claude sessions.",
16523
17063
  globalSettingsError: "Global settings save failed",
16524
17064
  outputStyle: "Output Style",
17065
+ renderer: "AI output renderer",
17066
+ rendererPlugin: "Plugin renderer",
17067
+ rendererNative: "DSH native renderer",
17068
+ rendererEffect: "The plugin renderer keeps this package’s own transcript: interleaved prose, grouped tool cards, and activity rows. The DSH native renderer hands the same turn to DSH itself — prose as ordinary assistant text blocks, thinking as reasoning blocks, and root Claude tools mirrored into native tool cards. A change applies from the next turn; turns already recorded keep the renderer they were recorded with and are not redrawn.",
16525
17069
  worktreeBranchPrefix: "Worktree branch prefix",
16526
17070
  worktreeBranchPrefixEffect: "Prefix changes apply to subsequently generated Worktree branches. Explicit full branch names remain unchanged.",
16527
17071
  maxProcessesSetting: "Claude process limit",
@@ -16884,7 +17428,9 @@ window.__ModuleLoader__.load({
16884
17428
  }), "dsh-claude: client copy");
16885
17429
  const t = ctx.locale.bind(namespace);
16886
17430
  ctx.effect(() => restyleHostChrome(), "dsh-claude: Host chrome restyling");
16887
- const projections = new ClaudeProjectionStore();
17431
+ const projections = new ClaudeProjectionStore({ report: (kind, detail) => {
17432
+ diagnostics.report(kind, detail);
17433
+ } });
16888
17434
  ctx.effect(() => ctx.inputTriggers.registerSource(createClaudeCommandSource(ctx, projections)), "dsh-claude: Claude slash source");
16889
17435
  const sessions = ctx.get("sessions");
16890
17436
  const workspaces = ctx.get("workspaces");