@norman-else/dsh-claude 0.1.41 → 0.1.43

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.mjs CHANGED
@@ -1,6 +1,6 @@
1
- import { A as CLAUDE_REPOSITORY_STATUS_PATH, B as isClaudeAlertMode, C as CLAUDE_PROJECTION_PATH, D as CLAUDE_REPOSITORY_FEEDBACK_PATH, E as CLAUDE_REPOSITORY_ACTION_PATH, F as CLAUDE_USAGE_PATH, H as isClaudeRenderMode, I as DEFAULT_CLAUDE_PROSE_MODE, L as DEFAULT_CLAUDE_RENDER_MODE, M as CLAUDE_REWIND_PATH, N as CLAUDE_UPDATE_CHECK_PATH, O as CLAUDE_REPOSITORY_FILE_PATH, P as CLAUDE_UPDATE_PATH, S as CLAUDE_PLAN_FEEDBACK_PATH, T as CLAUDE_RENDER_MODES, V as isClaudeProseMode, _ as CLAUDE_CODE_PROVIDER_IDS, a as latestClaudeTasks, b as CLAUDE_GLOBAL_SETTINGS_PATH, c as normalizeTasksEvent, d as CLAUDE_ACTIVITY_EVENT, f as CLAUDE_ALERT_MODES, g as CLAUDE_CODE_PROVIDER, h as CLAUDE_CODE_PRESET_ID, i as latestClaudeSessionBinding, j as CLAUDE_REVIEW_COMMENT_PATH, k as CLAUDE_REPOSITORY_SETUP_PATH, l as redactText, m as CLAUDE_CLIENT_DIAGNOSTICS_PATH, n as currentClaudeActivityCursor, o as normalizeActivity, p as CLAUDE_ASK_PATH, r as latestClaudeContextUsage, s as normalizeContextUsage, t as boundText, u as safeDetail, v as CLAUDE_DOCTOR_PATH, w as CLAUDE_PROSE_MODES, x as CLAUDE_JIRA_PATH, y as CLAUDE_EDITOR_OPEN_PATH, z as TASK_TOOL_NAMES } from "./events-B-FPMzI7.mjs";
2
- import { a as projectClaudeCommands, i as CLAUDE_COMMANDS_SERVICE, r as dynamicPresenterDefinition, t as CLAUDE_PRESENTER_NAMES } from "./presenters-BV42EKkB.mjs";
3
- import { a as resolveClaudeExecutable, n as ensureManagedPreset, o as runClaudeDoctor, t as ManagedPresetConflictError } from "./preset-installer-yRGfkGjd.mjs";
1
+ import { A as CLAUDE_REPOSITORY_FEEDBACK_PATH, B as DEFAULT_CLAUDE_RENDER_MODE, C as CLAUDE_PROJECTION_PATH, D as CLAUDE_PROSE_MODES, E as CLAUDE_PROMPT_REFINE_PATH, F as CLAUDE_REWIND_PATH, G as isClaudeRenderMode, H as TASK_TOOL_NAMES, I as CLAUDE_UPDATE_CHECK_PATH, L as CLAUDE_UPDATE_PATH, M as CLAUDE_REPOSITORY_SETUP_PATH, N as CLAUDE_REPOSITORY_STATUS_PATH, O as CLAUDE_RENDER_MODES, P as CLAUDE_REVIEW_COMMENT_PATH, R as CLAUDE_USAGE_PATH, S as CLAUDE_PLAN_FEEDBACK_PATH, T as CLAUDE_PROMPT_NAME_PATH, U as isClaudeAlertMode, W as isClaudeProseMode, _ as CLAUDE_CODE_PROVIDER_IDS, a as latestClaudeTasks, b as CLAUDE_GLOBAL_SETTINGS_PATH, c as normalizeTasksEvent, d as CLAUDE_ACTIVITY_EVENT, f as CLAUDE_ALERT_MODES, g as CLAUDE_CODE_PROVIDER, h as CLAUDE_CODE_PRESET_ID, i as latestClaudeSessionBinding, j as CLAUDE_REPOSITORY_FILE_PATH, k as CLAUDE_REPOSITORY_ACTION_PATH, l as redactText, m as CLAUDE_CLIENT_DIAGNOSTICS_PATH, n as currentClaudeActivityCursor, o as normalizeActivity, p as CLAUDE_ASK_PATH, r as latestClaudeContextUsage, s as normalizeContextUsage, t as boundText, u as safeDetail, v as CLAUDE_DOCTOR_PATH, w as CLAUDE_PROMPTS_PATH, x as CLAUDE_JIRA_PATH, y as CLAUDE_EDITOR_OPEN_PATH, z as DEFAULT_CLAUDE_PROSE_MODE } from "./events-oovRTmX7.mjs";
2
+ import { a as projectClaudeCommands, i as CLAUDE_COMMANDS_SERVICE, r as dynamicPresenterDefinition, t as CLAUDE_PRESENTER_NAMES } from "./presenters-BVWj7u0a.mjs";
3
+ import { a as resolveClaudeExecutable, n as ensureManagedPreset, o as runClaudeDoctor, t as ManagedPresetConflictError } from "./preset-installer-JUktnfwS.mjs";
4
4
  import z from "@deepseek-ai/schemastery";
5
5
  import { createHash, randomUUID } from "node:crypto";
6
6
  import { chmod, mkdir, opendir, readFile, readdir, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
@@ -1840,6 +1840,21 @@ async function withTimeout(operation, timeoutMs, label) {
1840
1840
  if (timer !== void 0) clearTimeout(timer);
1841
1841
  }
1842
1842
  }
1843
+ async function withAbort(operation, signal) {
1844
+ if (signal === void 0) return operation;
1845
+ if (signal.aborted) throw abortFailure();
1846
+ let abortListener;
1847
+ try {
1848
+ return await Promise.race([operation, new Promise((_resolve, reject) => {
1849
+ abortListener = () => {
1850
+ reject(abortFailure());
1851
+ };
1852
+ signal.addEventListener("abort", abortListener, { once: true });
1853
+ })]);
1854
+ } finally {
1855
+ if (abortListener !== void 0) signal.removeEventListener("abort", abortListener);
1856
+ }
1857
+ }
1843
1858
  /** The uuid of one main-chain transcript entry, or undefined for anything a
1844
1859
  * rewind must not fork at: stream partials, results, and sidechain traffic. */
1845
1860
  function chainEntryUuid(message) {
@@ -1895,6 +1910,15 @@ var ClaudeSupervisor = class {
1895
1910
  #contextWindows = /* @__PURE__ */ new Map();
1896
1911
  #disposed = false;
1897
1912
  #admissionGate = Promise.resolve();
1913
+ /** FIFO user turns live outside the gate while capacity-blocked, so
1914
+ * best-effort metadata can still enter the serialized path and fail. */
1915
+ #turnAdmissions = [];
1916
+ #metadataAdmissions = /* @__PURE__ */ new Set();
1917
+ #admissionDrainScheduled = false;
1918
+ /** Prevent a capacity change between a failed attempt and parking the head
1919
+ * from becoming a lost wake-up. */
1920
+ #admissionRevision = 0;
1921
+ #blockedAdmissionRevision;
1898
1922
  constructor(dependencies) {
1899
1923
  this.#runtime = dependencies.runtime;
1900
1924
  this.#approval = dependencies.approval;
@@ -1968,28 +1992,140 @@ var ClaudeSupervisor = class {
1968
1992
  runTurn(request) {
1969
1993
  const interruption = this.#interruptions.get(request.agent.id);
1970
1994
  if (interruption !== void 0) return interruption.then(() => this.runTurn(request));
1971
- const operation = this.#admissionGate.then(() => this.#runTurnAdmitted(request));
1995
+ return new Promise((resolve, reject) => {
1996
+ let complete;
1997
+ const completion = new Promise((done) => {
1998
+ complete = done;
1999
+ });
2000
+ const admission = {
2001
+ request,
2002
+ resolve,
2003
+ reject,
2004
+ delivered: false,
2005
+ admitting: false,
2006
+ waitedForCapacity: false,
2007
+ cancellation: new AbortController(),
2008
+ completion,
2009
+ complete: () => {
2010
+ complete?.();
2011
+ }
2012
+ };
2013
+ if (request.signal !== void 0) {
2014
+ const abortListener = () => {
2015
+ if (!admission.admitting) this.#finishTurnAdmission(admission, { error: abortFailure() });
2016
+ else if (admission.waitedForCapacity && !admission.delivered) {
2017
+ admission.delivered = true;
2018
+ admission.reject(abortFailure());
2019
+ }
2020
+ this.#admissionRevision += 1;
2021
+ this.#blockedAdmissionRevision = void 0;
2022
+ this.#scheduleTurnAdmissions();
2023
+ };
2024
+ admission.abortListener = abortListener;
2025
+ request.signal.addEventListener("abort", abortListener, { once: true });
2026
+ }
2027
+ this.#turnAdmissions.push(admission);
2028
+ this.#scheduleTurnAdmissions();
2029
+ });
2030
+ }
2031
+ async #drainTurnAdmissions() {
2032
+ while (this.#turnAdmissions.length > 0) {
2033
+ const admission = this.#turnAdmissions[0];
2034
+ if (signalAborted(admission.request.signal)) {
2035
+ this.#finishTurnAdmission(admission, { error: abortFailure() });
2036
+ continue;
2037
+ }
2038
+ const attemptedRevision = this.#admissionRevision;
2039
+ admission.admitting = true;
2040
+ try {
2041
+ const output = await this.#runTurnAdmitted(admission.request, admission.waitedForCapacity, admission.cancellation.signal);
2042
+ this.#finishTurnAdmission(admission, { output });
2043
+ } catch (error) {
2044
+ if (error instanceof ClaudeProcessLimitError && !signalAborted(admission.request.signal) && !admission.cancellation.signal.aborted && !this.#disposed) {
2045
+ admission.admitting = false;
2046
+ admission.waitedForCapacity = true;
2047
+ if (attemptedRevision === this.#admissionRevision) {
2048
+ this.#blockedAdmissionRevision = attemptedRevision;
2049
+ return;
2050
+ }
2051
+ continue;
2052
+ }
2053
+ this.#finishTurnAdmission(admission, { error });
2054
+ }
2055
+ }
2056
+ }
2057
+ #finishTurnAdmission(admission, outcome) {
2058
+ if (this.#turnAdmissions[0] === admission) this.#turnAdmissions.shift();
2059
+ else {
2060
+ const index = this.#turnAdmissions.indexOf(admission);
2061
+ if (index >= 0) this.#turnAdmissions.splice(index, 1);
2062
+ }
2063
+ admission.admitting = false;
2064
+ if (admission.request.signal !== void 0 && admission.abortListener !== void 0) admission.request.signal.removeEventListener("abort", admission.abortListener);
2065
+ if (!admission.delivered) {
2066
+ admission.delivered = true;
2067
+ if ("error" in outcome) admission.reject(outcome.error);
2068
+ else admission.resolve(outcome.output);
2069
+ }
2070
+ admission.complete();
2071
+ }
2072
+ #scheduleTurnAdmissions() {
2073
+ if (this.#admissionDrainScheduled || this.#turnAdmissions.length === 0 || this.#blockedAdmissionRevision === this.#admissionRevision) return;
2074
+ this.#admissionDrainScheduled = true;
2075
+ const operation = this.#admissionGate.then(() => this.#drainTurnAdmissions());
1972
2076
  this.#admissionGate = operation.then(() => void 0, () => void 0);
1973
- return operation;
2077
+ const finished = () => {
2078
+ this.#admissionDrainScheduled = false;
2079
+ this.#scheduleTurnAdmissions();
2080
+ };
2081
+ operation.then(finished, finished);
1974
2082
  }
1975
- async #runTurnAdmitted(request) {
2083
+ async #runTurnAdmitted(request, abortDuringAdmission, cancellationSignal) {
1976
2084
  if (this.#disposed) throw new Error("dsh-claude: supervisor is disposed");
2085
+ if (cancellationSignal.aborted) throw abortFailure();
1977
2086
  if (signalAborted(request.signal)) throw abortFailure();
1978
2087
  const sessionId = request.agent.id;
1979
2088
  let entry = this.#entries.get(sessionId);
2089
+ let createdForRequest;
2090
+ const throwIfUnavailable = async () => {
2091
+ const failure = this.#disposed ? /* @__PURE__ */ new Error("dsh-claude: supervisor is disposed") : cancellationSignal.aborted || abortDuringAdmission && signalAborted(request.signal) ? abortFailure() : void 0;
2092
+ if (failure === void 0) return;
2093
+ if (createdForRequest !== void 0) {
2094
+ if (this.#entries.get(sessionId) === createdForRequest) this.#entries.delete(sessionId);
2095
+ await this.#disposeEntry(createdForRequest);
2096
+ createdForRequest = void 0;
2097
+ }
2098
+ throw failure;
2099
+ };
1980
2100
  if (entry?.state === "disposed" || entry?.state === "disconnected" || entry?.state === "outcome-unknown") {
1981
2101
  this.#entries.delete(sessionId);
1982
2102
  await this.#disposeEntry(entry);
2103
+ await throwIfUnavailable();
1983
2104
  entry = void 0;
1984
2105
  }
1985
2106
  if (entry === void 0) {
1986
2107
  await this.#makeRoom();
1987
- entry = await this.#createEntry(request.agent, request.model ?? this.#config.defaultModel, request.thinkingMode);
2108
+ await throwIfUnavailable();
2109
+ try {
2110
+ entry = await this.#createEntry(request.agent, request.model ?? this.#config.defaultModel, request.thinkingMode, abortDuringAdmission ? request.signal : void 0, cancellationSignal);
2111
+ } catch (error) {
2112
+ await throwIfUnavailable();
2113
+ throw error;
2114
+ }
2115
+ createdForRequest = entry;
2116
+ await throwIfUnavailable();
1988
2117
  this.#entries.set(sessionId, entry);
1989
2118
  }
1990
2119
  if (entry.ownerAgent !== request.agent) throw new Error(`dsh-claude: live agent identity changed for session ${sessionId}`);
1991
2120
  if (entry.active !== void 0 || entry.state === "interrupting") throw new ClaudeTurnBusyError(sessionId);
1992
- await entry.sdkInitialization;
2121
+ try {
2122
+ const initialization = withAbort(entry.sdkInitialization, cancellationSignal);
2123
+ await (abortDuringAdmission ? withAbort(initialization, request.signal) : initialization);
2124
+ } catch (error) {
2125
+ await throwIfUnavailable();
2126
+ throw error;
2127
+ }
2128
+ await throwIfUnavailable();
1993
2129
  if (entry.idleTimer !== void 0) {
1994
2130
  clearTimeout(entry.idleTimer);
1995
2131
  entry.idleTimer = void 0;
@@ -1998,13 +2134,31 @@ var ClaudeSupervisor = class {
1998
2134
  if (request.thinkingMode !== entry.thinkingMode || model !== entry.model) {
1999
2135
  this.#entries.delete(sessionId);
2000
2136
  await this.#disposeEntry(entry);
2001
- entry = await this.#createEntry(request.agent, model, request.thinkingMode);
2137
+ if (createdForRequest === entry) createdForRequest = void 0;
2138
+ await throwIfUnavailable();
2139
+ try {
2140
+ entry = await this.#createEntry(request.agent, model, request.thinkingMode, abortDuringAdmission ? request.signal : void 0, cancellationSignal);
2141
+ } catch (error) {
2142
+ await throwIfUnavailable();
2143
+ throw error;
2144
+ }
2145
+ createdForRequest = entry;
2146
+ await throwIfUnavailable();
2002
2147
  this.#entries.set(sessionId, entry);
2003
- await entry.sdkInitialization;
2148
+ try {
2149
+ const initialization = withAbort(entry.sdkInitialization, cancellationSignal);
2150
+ await (abortDuringAdmission ? withAbort(initialization, request.signal) : initialization);
2151
+ } catch (error) {
2152
+ await throwIfUnavailable();
2153
+ throw error;
2154
+ }
2004
2155
  } else await this.#syncPermissionMode(entry);
2156
+ await throwIfUnavailable();
2005
2157
  const promptUuid = randomUUID();
2006
2158
  const cursor = currentClaudeActivityCursor(request.agent.session.events);
2007
- cursor.nextOrdinal = (await this.#sidecar.read(sessionId)).activities.reduce((next, activity) => activity.turn === cursor.turn && activity.step === cursor.step ? Math.max(next, activity.ordinal + 1) : next, 0);
2159
+ const projection = await this.#sidecar.read(sessionId);
2160
+ await throwIfUnavailable();
2161
+ cursor.nextOrdinal = projection.activities.reduce((next, activity) => activity.turn === cursor.turn && activity.step === cursor.step ? Math.max(next, activity.ordinal + 1) : next, 0);
2008
2162
  const active = {
2009
2163
  agent: request.agent,
2010
2164
  cursor,
@@ -2052,9 +2206,12 @@ var ClaudeSupervisor = class {
2052
2206
  title: "Claude Code turn cancelled before submission"
2053
2207
  });
2054
2208
  entry.active = void 0;
2055
- entry.state = "idle";
2056
- entry.lastUsedAt = Date.now();
2057
- this.#armIdleTimer(entry);
2209
+ if (!abortDuringAdmission || createdForRequest === void 0) {
2210
+ entry.state = "idle";
2211
+ entry.lastUsedAt = Date.now();
2212
+ this.#armIdleTimer(entry);
2213
+ }
2214
+ await throwIfUnavailable();
2058
2215
  return active.output;
2059
2216
  }
2060
2217
  if (request.signal !== void 0) {
@@ -2068,31 +2225,57 @@ var ClaudeSupervisor = class {
2068
2225
  return active.output;
2069
2226
  }
2070
2227
  #runMetadata(agent, model, operation) {
2228
+ if (this.#turnAdmissions.some((admission) => admission.waitedForCapacity)) return Promise.reject(new ClaudeProcessLimitError(this.#config.maxProcesses));
2229
+ let complete;
2230
+ const admission = {
2231
+ sessionId: agent.id,
2232
+ cancellation: new AbortController(),
2233
+ started: false,
2234
+ completion: new Promise((done) => {
2235
+ complete = done;
2236
+ }),
2237
+ complete: () => {
2238
+ complete?.();
2239
+ }
2240
+ };
2241
+ this.#metadataAdmissions.add(admission);
2071
2242
  const admitted = this.#admissionGate.then(async () => {
2243
+ admission.started = true;
2072
2244
  if (this.#disposed) throw new Error("dsh-claude: supervisor is disposed");
2073
- const entry = await this.#metadataEntry(agent, model);
2245
+ if (admission.cancellation.signal.aborted) throw abortFailure();
2246
+ if (this.#turnAdmissions.some((admission) => admission.waitedForCapacity)) throw new ClaudeProcessLimitError(this.#config.maxProcesses);
2247
+ const entry = await this.#metadataEntry(agent, model, admission.cancellation.signal);
2074
2248
  try {
2075
- await entry.sdkInitialization;
2076
- return await this.#control(entry, operation(entry.query, entry), "Claude metadata request");
2249
+ await withAbort(entry.sdkInitialization, admission.cancellation.signal);
2250
+ return await withAbort(this.#control(entry, operation(entry.query, entry), "Claude metadata request"), admission.cancellation.signal);
2077
2251
  } finally {
2078
2252
  entry.lastUsedAt = Date.now();
2079
2253
  if (entry.active === void 0 && entry.state === "idle") this.#armIdleTimer(entry);
2080
2254
  }
2255
+ }).finally(() => {
2256
+ this.#finishMetadataAdmission(admission);
2081
2257
  });
2082
2258
  this.#admissionGate = admitted.then(() => void 0, () => void 0);
2083
- return admitted;
2259
+ return withAbort(admitted, admission.cancellation.signal);
2084
2260
  }
2085
- async #metadataEntry(agent, model) {
2261
+ async #metadataEntry(agent, model, cancellationSignal) {
2262
+ if (cancellationSignal.aborted) throw abortFailure();
2086
2263
  const sessionId = agent.id;
2087
2264
  let entry = this.#entries.get(sessionId);
2088
2265
  if (entry?.state === "disposed" || entry?.state === "disconnected" || entry?.state === "outcome-unknown") {
2089
2266
  this.#entries.delete(sessionId);
2090
2267
  await this.#disposeEntry(entry);
2268
+ if (cancellationSignal.aborted) throw abortFailure();
2091
2269
  entry = void 0;
2092
2270
  }
2093
2271
  if (entry === void 0) {
2094
2272
  await this.#makeRoom();
2095
- entry = await this.#createEntry(agent, model);
2273
+ if (cancellationSignal.aborted) throw abortFailure();
2274
+ entry = await this.#createEntry(agent, model, void 0, void 0, cancellationSignal);
2275
+ if (cancellationSignal.aborted) {
2276
+ await this.#disposeEntry(entry);
2277
+ throw abortFailure();
2278
+ }
2096
2279
  this.#entries.set(sessionId, entry);
2097
2280
  }
2098
2281
  if (entry.ownerAgent !== agent) throw new Error(`dsh-claude: live agent identity changed for session ${sessionId}`);
@@ -2101,14 +2284,15 @@ var ClaudeSupervisor = class {
2101
2284
  clearTimeout(entry.idleTimer);
2102
2285
  entry.idleTimer = void 0;
2103
2286
  }
2104
- await this.#syncPermissionMode(entry);
2287
+ await withAbort(this.#syncPermissionMode(entry), cancellationSignal);
2288
+ if (cancellationSignal.aborted) throw abortFailure();
2105
2289
  return entry;
2106
2290
  }
2107
2291
  /** Run one SDK control request against a live entry, and discard the entry if
2108
2292
  * it does not answer.
2109
2293
  *
2110
- * Turn admission and every metadata read share one process-wide gate, so an
2111
- * unbounded control request stalls every session until the Host restarts.
2294
+ * Turn admission attempts and metadata reads share one process-wide gate,
2295
+ * so an unbounded control request stalls every session until the Host restarts.
2112
2296
  * Bounding it is only half the cure: a timeout also proves this query has
2113
2297
  * stopped answering, and keeping the entry means the next caller reuses the
2114
2298
  * same dead process — timing out again, forever. Discarding it lets the next
@@ -2129,32 +2313,97 @@ var ClaudeSupervisor = class {
2129
2313
  await this.#control(entry, entry.query.setPermissionMode(mode), "Claude Code permission mode switch");
2130
2314
  entry.permissionMode = mode;
2131
2315
  }
2316
+ #finishMetadataAdmission(admission) {
2317
+ this.#metadataAdmissions.delete(admission);
2318
+ admission.complete();
2319
+ }
2320
+ #cancelMetadataAdmissions(predicate) {
2321
+ const cancelled = [...this.#metadataAdmissions].filter(predicate);
2322
+ for (const admission of cancelled) {
2323
+ admission.cancellation.abort();
2324
+ if (!admission.started) this.#finishMetadataAdmission(admission);
2325
+ }
2326
+ return cancelled.map((admission) => admission.completion);
2327
+ }
2328
+ #cancelTurnAdmissions(predicate, error) {
2329
+ const cancelled = this.#turnAdmissions.filter(predicate);
2330
+ for (const admission of cancelled) {
2331
+ admission.cancellation.abort();
2332
+ if (!admission.delivered) {
2333
+ admission.delivered = true;
2334
+ admission.reject(error);
2335
+ }
2336
+ if (!admission.admitting) this.#finishTurnAdmission(admission, { error });
2337
+ }
2338
+ if (cancelled.length > 0) {
2339
+ this.#admissionRevision += 1;
2340
+ this.#blockedAdmissionRevision = void 0;
2341
+ this.#scheduleTurnAdmissions();
2342
+ }
2343
+ return cancelled.map((admission) => admission.completion);
2344
+ }
2345
+ limitsChanged() {
2346
+ if (this.#disposed) return;
2347
+ this.#notifyCapacityChange();
2348
+ this.#scheduleLimitReconciliation();
2349
+ }
2132
2350
  async disposeSession(sessionId) {
2351
+ const pendingAdmissions = this.#cancelTurnAdmissions((admission) => admission.request.agent.id === sessionId, abortFailure());
2352
+ const pendingMetadata = this.#cancelMetadataAdmissions((admission) => admission.sessionId === sessionId);
2133
2353
  const entry = this.#entries.get(sessionId);
2134
- if (entry === void 0) return;
2135
- this.#entries.delete(sessionId);
2136
- await this.#disposeEntry(entry);
2354
+ if (entry !== void 0) this.#entries.delete(sessionId);
2355
+ await Promise.allSettled([
2356
+ ...pendingAdmissions,
2357
+ ...pendingMetadata,
2358
+ ...entry === void 0 ? [] : [this.#disposeEntry(entry)]
2359
+ ]);
2137
2360
  }
2138
2361
  async dispose() {
2139
2362
  if (this.#disposed) return;
2140
2363
  this.#disposed = true;
2364
+ const pendingAdmissions = this.#cancelTurnAdmissions(() => true, /* @__PURE__ */ new Error("dsh-claude: supervisor is disposed"));
2365
+ const pendingMetadata = this.#cancelMetadataAdmissions(() => true);
2141
2366
  const entries = [...this.#entries.values()];
2142
2367
  this.#entries.clear();
2143
- await Promise.allSettled(entries.map((entry) => this.#disposeEntry(entry)));
2368
+ this.#notifyCapacityChange();
2369
+ await Promise.allSettled([
2370
+ ...pendingAdmissions,
2371
+ ...pendingMetadata,
2372
+ ...entries.map((entry) => this.#disposeEntry(entry))
2373
+ ]);
2144
2374
  }
2145
2375
  async #makeRoom() {
2146
- if (this.#entries.size < this.#config.maxProcesses) return;
2147
- const idle = [...this.#entries.values()].filter((entry) => entry.active === void 0 && entry.state === "idle").sort((left, right) => left.lastUsedAt - right.lastUsedAt)[0];
2148
- if (idle === void 0) throw new ClaudeProcessLimitError(this.#config.maxProcesses);
2149
- this.#entries.delete(idle.sessionId);
2150
- await this.#disposeEntry(idle);
2376
+ while (this.#entries.size >= this.#config.maxProcesses) {
2377
+ const idle = [...this.#entries.values()].filter((entry) => entry.active === void 0 && entry.state === "idle").sort((left, right) => left.lastUsedAt - right.lastUsedAt)[0];
2378
+ if (idle === void 0) throw new ClaudeProcessLimitError(this.#config.maxProcesses);
2379
+ this.#entries.delete(idle.sessionId);
2380
+ await this.#disposeEntry(idle);
2381
+ }
2382
+ }
2383
+ async #trimExcessIdle() {
2384
+ while (this.#entries.size > this.#config.maxProcesses) {
2385
+ const idle = [...this.#entries.values()].filter((entry) => entry.active === void 0 && entry.state === "idle").sort((left, right) => left.lastUsedAt - right.lastUsedAt)[0];
2386
+ if (idle === void 0) return;
2387
+ this.#entries.delete(idle.sessionId);
2388
+ await this.#disposeEntry(idle);
2389
+ }
2390
+ }
2391
+ #notifyCapacityChange() {
2392
+ this.#admissionRevision += 1;
2393
+ this.#blockedAdmissionRevision = void 0;
2394
+ this.#scheduleTurnAdmissions();
2395
+ }
2396
+ #scheduleLimitReconciliation() {
2397
+ const operation = this.#admissionGate.then(() => this.#trimExcessIdle());
2398
+ this.#admissionGate = operation.then(() => void 0, () => void 0);
2151
2399
  }
2152
- async #createEntry(agent, model, thinkingMode) {
2400
+ async #createEntry(agent, model, thinkingMode, signal, cancellationSignal) {
2153
2401
  const sessionId = agent.id;
2154
2402
  const cwd = agent.session.header.cwd ?? process.cwd();
2155
2403
  const input = new AsyncQueue();
2156
2404
  const lifetime = new AbortController();
2157
2405
  const projection = await this.#sidecar.importLegacy(sessionId, agent.session.events);
2406
+ if (signalAborted(signal) || signalAborted(cancellationSignal)) throw abortFailure();
2158
2407
  const binding = projection.binding;
2159
2408
  const pendingRewind = projection.rewind?.pending;
2160
2409
  const forkAt = pendingRewind !== void 0 && "resumeAt" in pendingRewind ? pendingRewind.resumeAt : void 0;
@@ -2627,20 +2876,7 @@ var ClaudeSupervisor = class {
2627
2876
  ...result.usage.outputTokens === void 0 ? {} : { outputTokens: result.usage.outputTokens }
2628
2877
  };
2629
2878
  }
2630
- async #completeProgressSegment(active, result, recordUsage = true) {
2631
- if (recordUsage && (result.usage.inputTokens !== void 0 || result.usage.outputTokens !== void 0 || result.usage.cumulativeCostUsd !== void 0)) {
2632
- await this.#appendSafely(active, {
2633
- kind: "usage",
2634
- phase: "completed",
2635
- title: "Claude usage",
2636
- summary: usageSummary(result.usage),
2637
- usage: this.#timedUsage(active, result.usage)
2638
- });
2639
- active.output.push({
2640
- type: "usage",
2641
- usage: this.#reportedUsage(active, result)
2642
- });
2643
- }
2879
+ async #completeProgressSegment(active, result) {
2644
2880
  if (!active.sawTextDelta && active.text.length === 0 && result.text !== void 0) {
2645
2881
  active.text = result.text;
2646
2882
  active.transcriptText = result.text;
@@ -2660,6 +2896,25 @@ var ClaudeSupervisor = class {
2660
2896
  this.#closeTranscriptTextSegment(active);
2661
2897
  active.thinking = "";
2662
2898
  }
2899
+ /** The turn's accounting, recorded once the turn is actually over.
2900
+ *
2901
+ * A turn that hands off to background tasks passes through the same result
2902
+ * handling on its way to `waiting-tasks`, and recording there drew a closing
2903
+ * total under a turn that was still running. */
2904
+ async #recordTurnUsage(active, result) {
2905
+ if (result.usage.inputTokens === void 0 && result.usage.outputTokens === void 0 && result.usage.cumulativeCostUsd === void 0) return;
2906
+ await this.#appendSafely(active, {
2907
+ kind: "usage",
2908
+ phase: "completed",
2909
+ title: "Claude usage",
2910
+ summary: usageSummary(result.usage),
2911
+ usage: this.#timedUsage(active, result.usage)
2912
+ });
2913
+ active.output.push({
2914
+ type: "usage",
2915
+ usage: this.#reportedUsage(active, result)
2916
+ });
2917
+ }
2663
2918
  async #completeTurn(entry, active, result) {
2664
2919
  if (entry.active !== active) return;
2665
2920
  if (active.aborted) {
@@ -2677,21 +2932,10 @@ var ClaudeSupervisor = class {
2677
2932
  await this.#recordChainAnchor(entry, active);
2678
2933
  this.#checkpointProjection(entry);
2679
2934
  this.#armIdleTimer(entry);
2935
+ this.#notifyCapacityChange();
2936
+ this.#scheduleLimitReconciliation();
2680
2937
  return;
2681
2938
  }
2682
- if (result.usage.inputTokens !== void 0 || result.usage.outputTokens !== void 0 || result.usage.cumulativeCostUsd !== void 0) {
2683
- await this.#appendSafely(active, {
2684
- kind: "usage",
2685
- phase: "completed",
2686
- title: "Claude usage",
2687
- summary: usageSummary(result.usage),
2688
- usage: this.#timedUsage(active, result.usage)
2689
- });
2690
- active.output.push({
2691
- type: "usage",
2692
- usage: this.#reportedUsage(active, result)
2693
- });
2694
- }
2695
2939
  const unmatchedDenials = (result.permissionDenials ?? []).filter((denial) => !active.deniedToolUseIds.has(denial.toolUseId));
2696
2940
  if (unmatchedDenials.length > 0) await this.#appendSafely(active, {
2697
2941
  kind: "permission",
@@ -2700,6 +2944,7 @@ var ClaudeSupervisor = class {
2700
2944
  summary: unmatchedDenials.map((denial) => denial.toolName).join(", ")
2701
2945
  });
2702
2946
  if (!result.success) {
2947
+ await this.#recordTurnUsage(active, result);
2703
2948
  if (!active.sawTextDelta && active.text.length === 0 && result.text !== void 0) {
2704
2949
  active.text = result.text;
2705
2950
  active.transcriptText = result.text;
@@ -2738,9 +2983,10 @@ var ClaudeSupervisor = class {
2738
2983
  phase: "updated",
2739
2984
  title: "Claude Code is waiting for background tasks"
2740
2985
  });
2741
- await this.#completeProgressSegment(active, result, false);
2986
+ await this.#completeProgressSegment(active, result);
2742
2987
  return;
2743
2988
  }
2989
+ await this.#recordTurnUsage(active, result);
2744
2990
  await this.#appendSafely(active, {
2745
2991
  kind: "status",
2746
2992
  phase: "completed",
@@ -2758,9 +3004,11 @@ var ClaudeSupervisor = class {
2758
3004
  entry.state = "idle";
2759
3005
  entry.lastUsedAt = Date.now();
2760
3006
  await this.#recordChainAnchor(entry, active);
2761
- await this.#learnContextWindow(entry);
2762
3007
  this.#checkpointProjection(entry);
2763
3008
  this.#armIdleTimer(entry);
3009
+ this.#notifyCapacityChange();
3010
+ this.#scheduleLimitReconciliation();
3011
+ await this.#learnContextWindow(entry);
2764
3012
  }
2765
3013
  /** Tell every reader where this session's delta stream ended.
2766
3014
  *
@@ -2942,6 +3190,7 @@ var ClaudeSupervisor = class {
2942
3190
  if (entry.process !== void 0) try {
2943
3191
  await entry.process.handle.waitForExit(AbortSignal.timeout(5e3));
2944
3192
  } catch {}
3193
+ this.#notifyCapacityChange();
2945
3194
  }
2946
3195
  };
2947
3196
  //#endregion
@@ -3995,7 +4244,11 @@ function registerClaudeProjectionRoute(ctx, sidecar, ownsSession, commandsForSes
3995
4244
  }
3996
4245
  }));
3997
4246
  for (const sessionId of sessionIds) writeSnapshot(sessionId).catch(() => void 0);
4247
+ let sweeping = false;
3998
4248
  const timer = setInterval(() => {
4249
+ writeLine({ type: "ping" });
4250
+ if (sweeping) return;
4251
+ sweeping = true;
3999
4252
  (async () => {
4000
4253
  for (const sessionId of sessionIds) {
4001
4254
  if (closed) return;
@@ -4004,8 +4257,9 @@ function registerClaudeProjectionRoute(ctx, sidecar, ownsSession, commandsForSes
4004
4257
  if (JSON.stringify(next) === JSON.stringify(metas.get(sessionId))) continue;
4005
4258
  writeMeta(sessionId, next);
4006
4259
  }
4007
- writeLine({ type: "ping" });
4008
- })().catch(() => void 0);
4260
+ })().catch(() => void 0).finally(() => {
4261
+ sweeping = false;
4262
+ });
4009
4263
  }, META_REFRESH_MS);
4010
4264
  timer.unref?.();
4011
4265
  await new Promise((resolve) => {
@@ -4069,6 +4323,7 @@ const GIT_TIMEOUT_MS$3 = 5e3;
4069
4323
  const GH_TIMEOUT_MS$1 = 8e3;
4070
4324
  const CACHE_TTL_MS = 5e3;
4071
4325
  const MAX_TEXT_CHARS = 1024;
4326
+ const MAX_CONFLICT_PATHS = 100;
4072
4327
  function bounded(value) {
4073
4328
  return value.trim().slice(0, MAX_TEXT_CHARS);
4074
4329
  }
@@ -4106,6 +4361,7 @@ function parseGitStatus(output) {
4106
4361
  let upstream = false;
4107
4362
  let ahead;
4108
4363
  let behind;
4364
+ const conflicts = [];
4109
4365
  for (const line of output.split(/\r?\n/u)) {
4110
4366
  if (line.startsWith("# branch.head ")) {
4111
4367
  const head = bounded(line.slice(14));
@@ -4125,6 +4381,10 @@ function parseGitStatus(output) {
4125
4381
  }
4126
4382
  continue;
4127
4383
  }
4384
+ if (line.startsWith("u ")) {
4385
+ const path = line.split(" ").slice(10).join(" ");
4386
+ if (path.length > 0 && conflicts.length < MAX_CONFLICT_PATHS) conflicts.push(bounded(path));
4387
+ }
4128
4388
  if (line.length > 0 && !line.startsWith("# ")) dirty = true;
4129
4389
  }
4130
4390
  return {
@@ -4133,9 +4393,36 @@ function parseGitStatus(output) {
4133
4393
  dirty,
4134
4394
  upstream,
4135
4395
  ...ahead === void 0 ? {} : { ahead },
4136
- ...behind === void 0 ? {} : { behind }
4396
+ ...behind === void 0 ? {} : { behind },
4397
+ ...conflicts.length === 0 ? {} : { conflicts }
4137
4398
  };
4138
4399
  }
4400
+ /** Ordered so the rebase directories win: a conflicted rebase also writes
4401
+ * MERGE_HEAD-like state, and the two are resumed by different commands. */
4402
+ const OPERATION_MARKERS = [
4403
+ ["rebase-merge", "rebase"],
4404
+ ["rebase-apply", "rebase"],
4405
+ ["MERGE_HEAD", "merge"],
4406
+ ["CHERRY_PICK_HEAD", "cherry-pick"],
4407
+ ["REVERT_HEAD", "revert"]
4408
+ ];
4409
+ /** Reads the in-progress operation out of the worktree's own git dir -- linked
4410
+ * worktrees keep their own, so this must not be handed the common dir. */
4411
+ async function detectRepositoryOperation(gitDir) {
4412
+ for (const [marker, operation] of OPERATION_MARKERS) {
4413
+ const path = join(gitDir, marker);
4414
+ try {
4415
+ await stat(path);
4416
+ } catch {
4417
+ continue;
4418
+ }
4419
+ const branch = bounded(operation === "rebase" ? await readFile(join(path, "head-name"), "utf8").catch(() => "") : "").replace(/^refs\/heads\//u, "");
4420
+ return {
4421
+ operation,
4422
+ ...branch.length === 0 || branch.includes(" ") ? {} : { branch }
4423
+ };
4424
+ }
4425
+ }
4139
4426
  function parseDiffNumstat(value) {
4140
4427
  let additions = 0;
4141
4428
  let deletions = 0;
@@ -4342,13 +4629,15 @@ var RepositoryStatusService = class {
4342
4629
  cwd: safeCwd
4343
4630
  };
4344
4631
  const status = parseGitStatus(statusResult.stdout);
4632
+ const operation = await detectRepositoryOperation(gitDir);
4633
+ const branch = operation?.branch ?? status.branch;
4345
4634
  const remoteResult = await run(this.#runtime, git, [
4346
4635
  "remote",
4347
4636
  "get-url",
4348
4637
  "origin"
4349
4638
  ], cwd, GIT_TIMEOUT_MS$3);
4350
4639
  const remote = remoteResult.exitCode === 0 ? parseGitHubRemote(remoteResult.stdout) : void 0;
4351
- const pullRequest = status.branch === void 0 || remote === void 0 ? void 0 : await this.#pullRequest(cwd, remote, status.branch);
4640
+ const pullRequest = branch === void 0 || remote === void 0 ? void 0 : await this.#pullRequest(cwd, remote, branch);
4352
4641
  const diffBase = pullRequest?.baseBranch === void 0 ? "HEAD" : await this.#mergeBase(cwd, git, pullRequest.baseBranch) ?? "HEAD";
4353
4642
  const diff = status.dirty || diffBase !== "HEAD" ? await this.#diff(cwd, git, diffBase, signal) : {
4354
4643
  additions: 0,
@@ -4362,6 +4651,8 @@ var RepositoryStatusService = class {
4362
4651
  cwd: safeCwd,
4363
4652
  root,
4364
4653
  ...status,
4654
+ ...branch === void 0 ? {} : { branch },
4655
+ ...operation === void 0 ? {} : { operation: operation.operation },
4365
4656
  worktree: normalizedPath(gitDir) !== normalizedPath(commonDir),
4366
4657
  ...remote === void 0 ? {} : { remote },
4367
4658
  ...pullRequest === void 0 ? {} : { pullRequest },
@@ -5237,6 +5528,10 @@ function parseRepositoryActionStatus(output) {
5237
5528
  }
5238
5529
  return [...files.values()].sort((left, right) => left.path.localeCompare(right.path));
5239
5530
  }
5531
+ function conflictPaths(result) {
5532
+ if (result.exitCode !== 0 || result.lossy) return [];
5533
+ return result.stdout.split(/\r?\n/u).filter((line) => line.length > 0).slice(0, 100);
5534
+ }
5240
5535
  function fallbackCommitMessage(files) {
5241
5536
  if (files.length === 1) return `Update ${files[0]?.path ?? "repository files"}`;
5242
5537
  return `Update ${files.length} repository files`;
@@ -5301,6 +5596,7 @@ var RepositoryActionService = class {
5301
5596
  return operation;
5302
5597
  }
5303
5598
  async #execute(cwd, request) {
5599
+ if (request.action === "resolve-continue" || request.action === "resolve-abort") return this.#resolve(cwd, request.action, request.push === true);
5304
5600
  const before = await this.#preview(cwd);
5305
5601
  if (before.fingerprint !== request.fingerprint) throw new RepositoryActionError("repository-changed", "Repository changes have changed. Refresh the commit panel.");
5306
5602
  if (request.action === "push") {
@@ -5363,13 +5659,12 @@ var RepositoryActionService = class {
5363
5659
  `origin/${base}`
5364
5660
  ], before.root, REMOTE_TIMEOUT_MS);
5365
5661
  if (merged.exitCode !== 0 || merged.lossy) {
5366
- const conflicted = await this.#run(git, [
5662
+ const conflicts = conflictPaths(await this.#run(git, [
5367
5663
  "diff",
5368
5664
  "--name-only",
5369
5665
  "--diff-filter=U",
5370
5666
  "--"
5371
- ], before.root, GIT_TIMEOUT_MS$1);
5372
- const conflicts = conflicted.exitCode === 0 && !conflicted.lossy ? conflicted.stdout.split(/\r?\n/u).filter((line) => line.length > 0).slice(0, 100) : [];
5667
+ ], before.root, GIT_TIMEOUT_MS$1));
5373
5668
  if (conflicts.length === 0) {
5374
5669
  await this.#run(git, [method, "--abort"], before.root, GIT_TIMEOUT_MS$1).catch(() => void 0);
5375
5670
  throw new RepositoryActionError("merge-failed", `Git could not ${method} the base branch.`);
@@ -5468,6 +5763,87 @@ var RepositoryActionService = class {
5468
5763
  pullRequestUrl: url
5469
5764
  };
5470
5765
  }
5766
+ /** Finishes or discards the merge, rebase, cherry-pick or revert git is
5767
+ * waiting on. The operation is read from the git dir rather than taken from
5768
+ * the caller: `--continue` and `--abort` are only safe against the one that
5769
+ * is actually in progress. */
5770
+ async #resolve(cwd, action, push) {
5771
+ const git = await this.#git();
5772
+ const [rootValue, gitDirValue] = (await this.#mustRun(git, [
5773
+ "rev-parse",
5774
+ "--path-format=absolute",
5775
+ "--show-toplevel",
5776
+ "--absolute-git-dir"
5777
+ ], cwd, GIT_TIMEOUT_MS$1, "not-repository", "The session directory is not a Git repository.")).stdout.split(/\r?\n/u);
5778
+ const root = (rootValue ?? "").trim();
5779
+ const gitDir = (gitDirValue ?? "").trim();
5780
+ if (root.length === 0 || gitDir.length === 0) throw new RepositoryActionError("repository-unavailable", "Repository state is unavailable.");
5781
+ const state = await detectRepositoryOperation(gitDir);
5782
+ if (state === void 0) throw new RepositoryActionError("no-operation", "No merge, rebase, cherry-pick or revert is in progress.");
5783
+ const operation = state.operation;
5784
+ if (action === "resolve-abort") {
5785
+ await this.#mustRun(git, [operation, "--abort"], root, GIT_TIMEOUT_MS$1, "abort-failed", `Git could not abort the ${operation}.`);
5786
+ this.#invalidate(root);
5787
+ return {
5788
+ commit: await this.#head(git, root),
5789
+ pushed: false
5790
+ };
5791
+ }
5792
+ if (conflictPaths(await this.#run(git, [
5793
+ "diff",
5794
+ "--name-only",
5795
+ "--diff-filter=U",
5796
+ "--"
5797
+ ], root, GIT_TIMEOUT_MS$1)).length > 0) throw new RepositoryActionError("unresolved-conflicts", "Resolve and stage every conflicted file before continuing.");
5798
+ const continued = await this.#run(git, [
5799
+ "-c",
5800
+ "core.editor=true",
5801
+ operation,
5802
+ "--continue"
5803
+ ], root, REMOTE_TIMEOUT_MS);
5804
+ this.#invalidate(root);
5805
+ if (continued.exitCode !== 0 || continued.lossy) {
5806
+ const next = conflictPaths(await this.#run(git, [
5807
+ "diff",
5808
+ "--name-only",
5809
+ "--diff-filter=U",
5810
+ "--"
5811
+ ], root, GIT_TIMEOUT_MS$1));
5812
+ if (next.length > 0) return {
5813
+ commit: await this.#head(git, root),
5814
+ pushed: false,
5815
+ conflicts: next
5816
+ };
5817
+ const reason = continued.stderr.split(/\r?\n/u).map((line) => line.trim()).filter((line) => line.length > 0).at(-1);
5818
+ throw new RepositoryActionError("continue-failed", reason === void 0 || reason.length === 0 ? `Git could not continue the ${operation}.` : reason);
5819
+ }
5820
+ const head = await this.#head(git, root);
5821
+ if (!push) return {
5822
+ commit: head,
5823
+ pushed: false
5824
+ };
5825
+ const branch = await this.#run(git, [
5826
+ "symbolic-ref",
5827
+ "--quiet",
5828
+ "--short",
5829
+ "HEAD"
5830
+ ], root, GIT_TIMEOUT_MS$1);
5831
+ if (branch.exitCode !== 0 || branch.lossy) throw new RepositoryActionError("detached-head", "The finished operation left a detached HEAD, so nothing was pushed.", head);
5832
+ try {
5833
+ await this.#push(git, root, branch.stdout.trim(), operation === "rebase");
5834
+ } catch (error) {
5835
+ throw new RepositoryActionError("push-failed", error instanceof Error ? error.message : "Git push failed.", head);
5836
+ }
5837
+ this.#invalidate(root);
5838
+ return {
5839
+ commit: head,
5840
+ pushed: true
5841
+ };
5842
+ }
5843
+ async #head(git, root) {
5844
+ const head = await this.#run(git, ["rev-parse", "HEAD"], root, GIT_TIMEOUT_MS$1);
5845
+ return head.exitCode === 0 && !head.lossy ? head.stdout.trim() : "";
5846
+ }
5471
5847
  async #preview(cwd) {
5472
5848
  const git = await this.#git();
5473
5849
  const root = (await this.#mustRun(git, [
@@ -5753,7 +6129,17 @@ const ACTIONS = /* @__PURE__ */ new Set([
5753
6129
  "push",
5754
6130
  "create-pr",
5755
6131
  "merge-pr",
5756
- "update-branch"
6132
+ "update-branch",
6133
+ "resolve-continue",
6134
+ "resolve-abort"
6135
+ ]);
6136
+ /** Actions that commit nothing of their own, so the panel sends no message. */
6137
+ const MESSAGELESS = /* @__PURE__ */ new Set([
6138
+ "push",
6139
+ "merge-pr",
6140
+ "update-branch",
6141
+ "resolve-continue",
6142
+ "resolve-abort"
5757
6143
  ]);
5758
6144
  function record$9(value) {
5759
6145
  return value !== null && typeof value === "object" && !Array.isArray(value) ? value : void 0;
@@ -5792,7 +6178,7 @@ function actionRequest(input) {
5792
6178
  return {
5793
6179
  action,
5794
6180
  fingerprint: string$1(input, "fingerprint"),
5795
- message: action === "push" || action === "merge-pr" || action === "update-branch" ? optionalString(input, "message") ?? "" : string$1(input, "message"),
6181
+ message: MESSAGELESS.has(action) ? optionalString(input, "message") ?? "" : string$1(input, "message"),
5796
6182
  includeUnstaged: input.includeUnstaged,
5797
6183
  ...optionalString(input, "prTitle") === void 0 ? {} : { prTitle: optionalString(input, "prTitle") },
5798
6184
  ...optionalString(input, "prBody") === void 0 ? {} : { prBody: optionalString(input, "prBody") },
@@ -5800,6 +6186,9 @@ function actionRequest(input) {
5800
6186
  ...input.draft === void 0 ? {} : typeof input.draft === "boolean" ? { draft: input.draft } : (() => {
5801
6187
  throw new RepositoryActionError("invalid-request", "The draft field must be a boolean.");
5802
6188
  })(),
6189
+ ...input.push === void 0 ? {} : typeof input.push === "boolean" ? { push: input.push } : (() => {
6190
+ throw new RepositoryActionError("invalid-request", "The push field must be a boolean.");
6191
+ })(),
5803
6192
  ...input.mergeMethod === void 0 ? {} : input.mergeMethod === "merge" || input.mergeMethod === "squash" || input.mergeMethod === "rebase" ? { mergeMethod: input.mergeMethod } : (() => {
5804
6193
  throw new RepositoryActionError("invalid-request", "The mergeMethod field is invalid.");
5805
6194
  })()
@@ -6062,6 +6451,358 @@ function registerEditorOpenRoute(ctx, service, cwdForSession) {
6062
6451
  });
6063
6452
  }
6064
6453
  //#endregion
6454
+ //#region src/prompts.ts
6455
+ const MAX_PROMPT_FILES = 256;
6456
+ const MAX_PROMPT_BYTES = 16384;
6457
+ const MAX_DESCRIPTION_CHARS = 120;
6458
+ const MAX_REQUEST_BYTES$1 = 32768;
6459
+ /**
6460
+ * The same shape as the output-style name in `global-settings.ts`, plus
6461
+ * `\p{M}` so a macOS-decomposed name survives. Two properties matter and both
6462
+ * are load-bearing: no member of the class is a path separator, and the first
6463
+ * character can be neither a dot nor a separator — so `join(dir, name + '.md')`
6464
+ * cannot address anything outside `dir`. Nothing else validates the path.
6465
+ */
6466
+ const PROMPT_NAME = /^[\p{L}\p{N}][\p{L}\p{M}\p{N} ._()\[\]-]{0,127}$/u;
6467
+ /** Prompt snippets live beside the rest of the user's Claude Code state. */
6468
+ function claudePromptsDir() {
6469
+ return join(homedir(), ".claude", "prompts");
6470
+ }
6471
+ /** `~/.claude/prompts/x.md` rather than the absolute path it expands to. */
6472
+ function displayPath(file) {
6473
+ const home = homedir();
6474
+ return file.startsWith(`${home}/`) ? `~${file.slice(home.length)}` : file;
6475
+ }
6476
+ /** The menu's second row: the first non-empty line, collapsed and bounded. */
6477
+ function summarize(body) {
6478
+ const line = body.split("\n").map((text) => text.trim()).find((text) => text.length > 0) ?? "";
6479
+ return redactText(line.replace(/\s+/gu, " "), MAX_DESCRIPTION_CHARS);
6480
+ }
6481
+ /**
6482
+ * Every `.md` file in the prompts directory, sorted by name.
6483
+ *
6484
+ * A missing directory is the ordinary state before the user saves anything,
6485
+ * and one unreadable file must not empty the whole menu — both answer with
6486
+ * what is there rather than throwing.
6487
+ */
6488
+ async function readClaudePrompts(directory) {
6489
+ const prompts = [];
6490
+ let entries;
6491
+ try {
6492
+ entries = await opendir(directory);
6493
+ } catch (error) {
6494
+ if (error.code === "ENOENT") return prompts;
6495
+ throw error;
6496
+ }
6497
+ for await (const entry of entries) {
6498
+ if (prompts.length >= MAX_PROMPT_FILES) break;
6499
+ if (!entry.isFile() || extname(entry.name).toLowerCase() !== ".md") continue;
6500
+ const name = entry.name.slice(0, -3);
6501
+ if (!PROMPT_NAME.test(name)) continue;
6502
+ try {
6503
+ const file = join(directory, entry.name);
6504
+ const body = await readFile(file, "utf8");
6505
+ if (body.trim().length === 0 || Buffer.byteLength(body) > MAX_PROMPT_BYTES) continue;
6506
+ prompts.push({
6507
+ name,
6508
+ description: summarize(body),
6509
+ body,
6510
+ location: displayPath(file)
6511
+ });
6512
+ } catch {}
6513
+ }
6514
+ return prompts.sort((left, right) => left.name.localeCompare(right.name));
6515
+ }
6516
+ var ClaudePromptWriteError = class extends Error {
6517
+ code;
6518
+ constructor(code, message) {
6519
+ super(message);
6520
+ this.name = "ClaudePromptWriteError";
6521
+ this.code = code;
6522
+ }
6523
+ };
6524
+ /**
6525
+ * Save one prompt, refusing to clobber an existing file.
6526
+ *
6527
+ * `wx` is the whole collision policy: a name the user already uses is theirs
6528
+ * to resolve, and overwriting a prompt they keep is not something they can
6529
+ * undo from inside DSH.
6530
+ */
6531
+ async function writeClaudePrompt(directory, name, body) {
6532
+ if (typeof name !== "string" || !PROMPT_NAME.test(name)) throw new ClaudePromptWriteError("invalid-name", "The prompt name is invalid.");
6533
+ if (typeof body !== "string" || body.trim().length === 0 || Buffer.byteLength(body) > MAX_PROMPT_BYTES) throw new ClaudePromptWriteError("invalid-body", "The prompt text is empty or too large.");
6534
+ const text = body.endsWith("\n") ? body : `${body}\n`;
6535
+ const file = join(directory, `${name}.md`);
6536
+ await mkdir(directory, {
6537
+ recursive: true,
6538
+ mode: 448
6539
+ });
6540
+ try {
6541
+ await writeFile(file, text, {
6542
+ encoding: "utf8",
6543
+ mode: 384,
6544
+ flag: "wx"
6545
+ });
6546
+ } catch (error) {
6547
+ if (error.code === "EEXIST") throw new ClaudePromptWriteError("name-taken", "A prompt with that name already exists.");
6548
+ throw error;
6549
+ }
6550
+ return {
6551
+ name,
6552
+ description: summarize(text),
6553
+ body: text,
6554
+ location: displayPath(file)
6555
+ };
6556
+ }
6557
+ const MAX_NAME_DRAFT_CHARS = 4e3;
6558
+ const MAX_NAME_OUTPUT_BYTES = 4096;
6559
+ const MAX_SUGGESTED_NAME_CHARS = 48;
6560
+ const MAX_REFINE_DRAFT_CHARS = 16e3;
6561
+ const MAX_REFINED_BYTES = 32768;
6562
+ const ASSIST_TIMEOUT_MS = 4e4;
6563
+ /**
6564
+ * Ask for a file name and nothing else, with the draft fenced as data.
6565
+ *
6566
+ * Two things here were measured rather than guessed. The instruction travels
6567
+ * in the user turn, not in `--system-prompt`: a saved prompt is itself an
6568
+ * instruction and a system prompt does not outrank it, so the model reads the
6569
+ * draft as its task and answers it instead of naming it. And the default
6570
+ * system prompt is left in place even though it is large, because the CLI
6571
+ * sends it as a cache read (~6.5k cached tokens); replacing it with a short
6572
+ * one costs a fresh 3.5k-token prefill and measured slower end to end.
6573
+ *
6574
+ * The naming spec is this specific because a vaguer one ("describe what the
6575
+ * template does, at most 40 characters") produced names that were both
6576
+ * inconsistent in style and stripped of the detail that tells two similar
6577
+ * templates apart.
6578
+ */
6579
+ function promptNamePrompt(draft) {
6580
+ return [
6581
+ "Below is a reusable prompt template a user is saving to a file. Name it.",
6582
+ "",
6583
+ `"""\n${draft.trim().slice(0, MAX_NAME_DRAFT_CHARS).replaceAll("\"\"\"", "\" \" \"")}\n"""`,
6584
+ "",
6585
+ "Answer with the file name alone: no quotes, no explanation, no extension, no leading dot, no slashes.",
6586
+ "Write it in English however the template is written, as lower-case words joined by hyphens.",
6587
+ "Name the task the template performs, keeping whatever detail distinguishes it from a",
6588
+ "similar template. At most 6 words."
6589
+ ].join("\n");
6590
+ }
6591
+ /**
6592
+ * Rewrite the draft into something an agent can act on, with the draft fenced
6593
+ * as data for the same reason the naming prompt fences it.
6594
+ *
6595
+ * The rule about people and places is not politeness. Asked to rewrite
6596
+ * "后端 assign 给我", the model reached into Claude Code's ambient system
6597
+ * prompt and substituted the operator's actual email address — inventing a
6598
+ * detail the original never carried, and putting a personal address into a
6599
+ * message the user had not written it into. Naming the failure explicitly is
6600
+ * what stopped it; stripping the ambient context with `--system-prompt` also
6601
+ * stopped it but cost a fresh prefill and produced looser rewrites.
6602
+ */
6603
+ function promptRefinePrompt(draft) {
6604
+ return [
6605
+ "Below is a prompt a user is about to send to a coding agent. Rewrite it so the agent can act on it without coming back with questions.",
6606
+ "",
6607
+ `"""\n${draft.trim().slice(0, MAX_REFINE_DRAFT_CHARS).replaceAll("\"\"\"", "\" \" \"")}\n"""`,
6608
+ "",
6609
+ "Keep the intent and every concrete detail exactly as given — names, paths, branches, identifiers, numbers.",
6610
+ "Leave every reference to a person or place as the original wrote it (\"me\", \"我\", \"the usual branch\");",
6611
+ "you do not know who or what they resolve to, and guessing puts a wrong name in the user's message.",
6612
+ "Do not invent requirements the original does not imply, do not answer the prompt, do not add a preamble or sign-off.",
6613
+ "Write the rewrite in the same language the original is written in.",
6614
+ "Reply with the rewritten prompt alone."
6615
+ ].join("\n");
6616
+ }
6617
+ /** One turn, no tools, no MCP: neither of these tasks needs them, and both
6618
+ * cost seconds of cold start. Variadic `--tools` stays last. */
6619
+ function promptAssistArguments() {
6620
+ return [
6621
+ "-p",
6622
+ "--output-format",
6623
+ "text",
6624
+ "--model",
6625
+ "haiku",
6626
+ "--strict-mcp-config",
6627
+ "--mcp-config",
6628
+ "{\"mcpServers\":{}}",
6629
+ "--tools",
6630
+ ""
6631
+ ];
6632
+ }
6633
+ /** The rewrite in a model reply, or undefined when the reply is unusable.
6634
+ *
6635
+ * A model told to answer with the text alone still sometimes wraps it in a
6636
+ * markdown fence, so one is peeled off; anything else is the user's to read
6637
+ * and edit, and is passed through untouched. */
6638
+ function refinedPrompt(output) {
6639
+ const trimmed = output.trim();
6640
+ const text = (/^```[^\n]*\n([\s\S]*?)\n?```$/u.exec(trimmed)?.[1] ?? trimmed).trim();
6641
+ return text.length === 0 || Buffer.byteLength(text) > MAX_REFINED_BYTES ? void 0 : text;
6642
+ }
6643
+ /**
6644
+ * The usable name in a model reply, or undefined when there is none.
6645
+ *
6646
+ * A one-line answer is what the prompt asks for and usually what comes back,
6647
+ * but "usually" is not a contract: the reply is scrubbed to what a file name
6648
+ * may hold and then held to the same {@link PROMPT_NAME} guard the write path
6649
+ * uses, so a chatty or malformed answer is dropped rather than offered.
6650
+ */
6651
+ function suggestedName(output) {
6652
+ const scrubbed = wordBounded((output.split("\n").map((text) => text.trim()).find((text) => text.length > 0) ?? "").replace(/^["'`]+|["'`]+$/gu, "").replace(/\.md$/iu, "").replace(/[^\p{L}\p{M}\p{N} ._()\[\]-]/gu, " ").replace(/\s+/gu, " ").trim());
6653
+ return PROMPT_NAME.test(scrubbed) ? scrubbed : void 0;
6654
+ }
6655
+ /** Cut a long name at its last word boundary. A name the user has to repair
6656
+ * ("analyze-frontend-backend-create-jira-tic") is worse than a shorter one. */
6657
+ function wordBounded(name) {
6658
+ if (name.length <= MAX_SUGGESTED_NAME_CHARS) return name;
6659
+ const cut = name.slice(0, MAX_SUGGESTED_NAME_CHARS);
6660
+ const boundary = cut.search(/[\s\-_][^\s\-_]*$/u);
6661
+ return (boundary > 0 ? cut.slice(0, boundary) : cut).trim();
6662
+ }
6663
+ /** Runs Claude Code's cheapest model over the draft: names it, or rewrites it. */
6664
+ var PromptAssistService = class {
6665
+ #runtime;
6666
+ #executablePath;
6667
+ constructor(runtime, executablePath) {
6668
+ this.#runtime = runtime;
6669
+ this.#executablePath = executablePath;
6670
+ }
6671
+ /** One bounded, tool-less turn; undefined whenever it cannot be had. */
6672
+ async #ask(prompt, maxOutputBytes, signal) {
6673
+ const executablePath = this.#executablePath();
6674
+ if (executablePath.length === 0) return void 0;
6675
+ const timeout = AbortSignal.timeout(ASSIST_TIMEOUT_MS);
6676
+ try {
6677
+ const handle = this.#runtime.spawn({
6678
+ argv: [executablePath, ...promptAssistArguments()],
6679
+ cwd: homedir(),
6680
+ stdio: {
6681
+ stdin: { data: prompt },
6682
+ stdout: { maxBytes: maxOutputBytes },
6683
+ stderr: { maxBytes: MAX_NAME_OUTPUT_BYTES }
6684
+ },
6685
+ graceMs: 1e3,
6686
+ signal: signal === void 0 ? timeout : AbortSignal.any([signal, timeout]),
6687
+ env: { MAX_THINKING_TOKENS: "0" }
6688
+ });
6689
+ if ((await handle.done).exitCode !== 0) return void 0;
6690
+ return handle.collected.stdout?.readFrom(0).text ?? "";
6691
+ } catch {
6692
+ return;
6693
+ }
6694
+ }
6695
+ /** The suggested file name, or undefined whenever one cannot be had. The
6696
+ * caller already has a name derived locally, so every failure here is a
6697
+ * non-event: nothing is reported, and nothing is retried. */
6698
+ async suggest(draft, signal) {
6699
+ if (draft.trim().length === 0) return void 0;
6700
+ const output = await this.#ask(promptNamePrompt(draft), MAX_NAME_OUTPUT_BYTES, signal);
6701
+ return output === void 0 ? void 0 : suggestedName(output);
6702
+ }
6703
+ /** The rewritten draft, or undefined when the rewrite could not be had.
6704
+ * Unlike naming, this failure is worth reporting: the user asked for it and
6705
+ * is waiting on it. */
6706
+ async refine(draft, signal) {
6707
+ if (draft.trim().length === 0) return void 0;
6708
+ const output = await this.#ask(promptRefinePrompt(draft), MAX_REFINED_BYTES, signal);
6709
+ return output === void 0 ? void 0 : refinedPrompt(output);
6710
+ }
6711
+ };
6712
+ /** List the user's prompt snippets, and save the composer draft as a new one. */
6713
+ function registerClaudePromptsRoute(ctx, directory = claudePromptsDir()) {
6714
+ registerPluginRoute(ctx, {
6715
+ mode: "unary",
6716
+ kind: "exact",
6717
+ path: CLAUDE_PROMPTS_PATH,
6718
+ methods: ["GET", "POST"],
6719
+ budget: "fast",
6720
+ handler: async (io) => {
6721
+ if (io.method === "GET") return {
6722
+ status: 200,
6723
+ value: { prompts: await readClaudePrompts(directory) }
6724
+ };
6725
+ const payload = await io.body(MAX_REQUEST_BYTES$1);
6726
+ try {
6727
+ return {
6728
+ status: 200,
6729
+ value: {
6730
+ saved: true,
6731
+ prompt: await writeClaudePrompt(directory, payload.name, payload.body)
6732
+ }
6733
+ };
6734
+ } catch (error) {
6735
+ if (error instanceof ClaudePromptWriteError) return {
6736
+ status: error.code === "name-taken" ? 409 : 400,
6737
+ value: {
6738
+ error: error.code,
6739
+ message: error.message
6740
+ }
6741
+ };
6742
+ return {
6743
+ status: 500,
6744
+ value: {
6745
+ error: "prompt-write-failed",
6746
+ message: "The prompt could not be saved."
6747
+ }
6748
+ };
6749
+ }
6750
+ }
6751
+ });
6752
+ }
6753
+ /** Suggest a file name for a draft. Answers `{}` when no name could be had:
6754
+ * the caller keeps the name it derived locally, so this is never an error. */
6755
+ function registerClaudePromptNameRoute(ctx, service) {
6756
+ registerPluginRoute(ctx, {
6757
+ mode: "unary",
6758
+ kind: "exact",
6759
+ path: CLAUDE_PROMPT_NAME_PATH,
6760
+ methods: ["POST"],
6761
+ budget: "git",
6762
+ handler: async (io) => {
6763
+ const payload = await io.body(MAX_REQUEST_BYTES$1);
6764
+ const draft = typeof payload.draft === "string" ? payload.draft : "";
6765
+ const name = await service.suggest(draft, io.signal);
6766
+ return {
6767
+ status: 200,
6768
+ value: name === void 0 ? {} : { name }
6769
+ };
6770
+ }
6771
+ });
6772
+ }
6773
+ /** Rewrite a draft into something an agent can act on. */
6774
+ function registerClaudePromptRefineRoute(ctx, service) {
6775
+ registerPluginRoute(ctx, {
6776
+ mode: "unary",
6777
+ kind: "exact",
6778
+ path: CLAUDE_PROMPT_REFINE_PATH,
6779
+ methods: ["POST"],
6780
+ budget: "git",
6781
+ handler: async (io) => {
6782
+ const payload = await io.body(MAX_REQUEST_BYTES$1);
6783
+ const draft = typeof payload.draft === "string" ? payload.draft : "";
6784
+ if (draft.trim().length === 0) return {
6785
+ status: 400,
6786
+ value: {
6787
+ error: "empty-draft",
6788
+ message: "There is nothing to rewrite."
6789
+ }
6790
+ };
6791
+ const text = await service.refine(draft, io.signal);
6792
+ return text === void 0 ? {
6793
+ status: 503,
6794
+ value: {
6795
+ error: "refine-unavailable",
6796
+ message: "Claude could not rewrite the prompt."
6797
+ }
6798
+ } : {
6799
+ status: 200,
6800
+ value: { text }
6801
+ };
6802
+ }
6803
+ });
6804
+ }
6805
+ //#endregion
6065
6806
  //#region src/github-url.ts
6066
6807
  /** Only GitHub's own image hosts; the browser loads these directly, so a URL
6067
6808
  * the API did not vouch for must never become an outbound request. */
@@ -8158,12 +8899,12 @@ const ALERTS = {
8158
8899
  function isBoundedInteger(value, min, max) {
8159
8900
  return typeof value === "number" && Number.isInteger(value) && value >= min && value <= max;
8160
8901
  }
8161
- function integerSetting(key, min, max, defaultFor) {
8902
+ function integerSetting(key, min, max, defaultFor, effect = "new-session") {
8162
8903
  return {
8163
8904
  key,
8164
8905
  kind: "text",
8165
8906
  document: "plugin",
8166
- effect: "new-session",
8907
+ effect,
8167
8908
  maxLength: String(max).length,
8168
8909
  read(document, defaults = DEFAULT_LIMITS) {
8169
8910
  const value = document[key];
@@ -8182,7 +8923,7 @@ const DESCRIPTORS = [
8182
8923
  PROSE,
8183
8924
  ALERTS,
8184
8925
  WORKTREE_BRANCH_PREFIX,
8185
- integerSetting("maxProcesses", 1, MAX_PROCESSES_LIMIT, (limits) => limits.maxProcesses),
8926
+ integerSetting("maxProcesses", 1, MAX_PROCESSES_LIMIT, (limits) => limits.maxProcesses, "immediate"),
8186
8927
  integerSetting("idleTimeoutMinutes", 1, MAX_IDLE_TIMEOUT_MINUTES, (limits) => Math.max(1, Math.round(limits.idleTimeoutMs / 6e4)))
8187
8928
  ];
8188
8929
  const DESCRIPTOR_BY_KEY = new Map(DESCRIPTORS.map((descriptor) => [descriptor.key, descriptor]));
@@ -8595,7 +9336,10 @@ async function apply(ctx, config) {
8595
9336
  registerClaudeUpdateRoutes(webCtx, webCtx.subprocess, { ...typeof desktopActions?.requestRestart === "function" ? { requestRestart: desktopActions.requestRestart.bind(desktopActions) } : {} });
8596
9337
  registerClaudeGlobalSettingsRoute(webCtx, {
8597
9338
  defaultLimits,
8598
- onUpdated: applySettingsOverrides
9339
+ onUpdated: async () => {
9340
+ await applySettingsOverrides();
9341
+ supervisor.limitsChanged();
9342
+ }
8599
9343
  });
8600
9344
  registerRepositorySetupRoute(webCtx, repositorySetup, () => sweepWorktrees?.());
8601
9345
  registerRepositoryStatusRoute(webCtx, repositoryStatus);
@@ -8609,6 +9353,10 @@ async function apply(ctx, config) {
8609
9353
  };
8610
9354
  registerRepositoryActionRoute(webCtx, repositoryActions, cwdForClaudeSession);
8611
9355
  registerEditorOpenRoute(webCtx, new EditorOpenService(webCtx.subprocess), cwdForClaudeSession);
9356
+ registerClaudePromptsRoute(webCtx);
9357
+ const promptAssist = new PromptAssistService(webCtx.subprocess, () => supervisorConfig.executablePath);
9358
+ registerClaudePromptNameRoute(webCtx, promptAssist);
9359
+ registerClaudePromptRefineRoute(webCtx, promptAssist);
8612
9360
  registerPullRequestFeedbackRoute(webCtx, new PullRequestFeedbackService(webCtx.subprocess), cwdForClaudeSession);
8613
9361
  registerAskRoute(webCtx, new AskService(webCtx.subprocess, supervisorConfig.executablePath), cwdForClaudeSession, (sessionId) => {
8614
9362
  const snapshot = supervisor.snapshots().find((item) => item.sessionId === sessionId);