@cjhyy/code-shell-capability-coding 0.9.44 → 0.9.47

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.
Files changed (58) hide show
  1. package/dist/capability-runtime.js +4 -1
  2. package/dist/cc-orchestrator/codex-session-history.d.ts +3 -1
  3. package/dist/cc-orchestrator/codex-session-history.js +11 -4
  4. package/dist/cc-orchestrator/external-agent-changes.js +7 -65
  5. package/dist/cc-orchestrator/external-agent-driver.js +61 -3
  6. package/dist/cc-orchestrator/session-discovery.d.ts +4 -2
  7. package/dist/cc-orchestrator/session-discovery.js +63 -6
  8. package/dist/cc-orchestrator/session-history.js +5 -0
  9. package/dist/external-runtimes/claude-code/event-translator.d.ts +7 -0
  10. package/dist/external-runtimes/claude-code/event-translator.js +58 -9
  11. package/dist/external-runtimes/claude-code/mcp-config.d.ts +8 -0
  12. package/dist/external-runtimes/claude-code/mcp-config.js +16 -3
  13. package/dist/external-runtimes/claude-code/permissions.d.ts +69 -0
  14. package/dist/external-runtimes/claude-code/permissions.js +120 -0
  15. package/dist/external-runtimes/claude-code/runtime.d.ts +43 -4
  16. package/dist/external-runtimes/claude-code/runtime.js +155 -39
  17. package/dist/external-runtimes/claude-code/text-inference.d.ts +22 -0
  18. package/dist/external-runtimes/claude-code/text-inference.js +240 -0
  19. package/dist/external-runtimes/codex/app-server-client.d.ts +15 -0
  20. package/dist/external-runtimes/codex/app-server-client.js +69 -6
  21. package/dist/external-runtimes/codex/event-translator.js +16 -2
  22. package/dist/external-runtimes/codex/runtime.d.ts +14 -0
  23. package/dist/external-runtimes/codex/runtime.js +152 -16
  24. package/dist/external-runtimes/index.d.ts +4 -0
  25. package/dist/external-runtimes/index.js +2 -0
  26. package/dist/external-runtimes/session-factory.d.ts +16 -0
  27. package/dist/external-runtimes/session-factory.js +43 -1
  28. package/dist/external-runtimes/shared/mcp-bridge.d.ts +36 -1
  29. package/dist/external-runtimes/shared/mcp-bridge.js +93 -13
  30. package/dist/git/parse-log.d.ts +6 -2
  31. package/dist/git/parse-log.js +7 -3
  32. package/dist/git/utils.d.ts +4 -0
  33. package/dist/git/utils.js +29 -10
  34. package/dist/git/worktree/crud.d.ts +2 -0
  35. package/dist/git/worktree/crud.js +29 -1
  36. package/dist/git/worktree/diff.js +57 -29
  37. package/dist/index.capability.js +14 -1
  38. package/dist/index.git.d.ts +1 -0
  39. package/dist/index.git.js +1 -0
  40. package/dist/lsp/client.d.ts +3 -0
  41. package/dist/lsp/client.js +96 -25
  42. package/dist/lsp/manager.d.ts +2 -0
  43. package/dist/lsp/manager.js +56 -15
  44. package/dist/prompt/coding.md +1 -0
  45. package/dist/quota/index.d.ts +2 -1
  46. package/dist/quota/index.js +27 -7
  47. package/dist/quota/types.d.ts +5 -2
  48. package/dist/review/review-prompt.d.ts +2 -0
  49. package/dist/review/review-prompt.js +70 -29
  50. package/dist/review/review-target.d.ts +29 -0
  51. package/dist/review/review-target.js +353 -0
  52. package/dist/tools/apply-patch/applier.js +64 -39
  53. package/dist/tools/apply-patch/backup-targets.d.ts +8 -0
  54. package/dist/tools/apply-patch/backup-targets.js +24 -0
  55. package/dist/tools/apply-patch/parser.js +4 -2
  56. package/dist/tools/check-quota.js +3 -1
  57. package/dist/tools/lsp.js +36 -5
  58. package/package.json +2 -2
package/dist/git/utils.js CHANGED
@@ -9,14 +9,18 @@
9
9
  */
10
10
  import { execFileSync } from "node:child_process";
11
11
  import { realpathSync } from "node:fs";
12
- import { parseGitLog } from "./parse-log.js";
12
+ import { GIT_LOG_FORMAT, parseGitLog } from "./parse-log.js";
13
13
  import { resolveExecutable, resolveGit } from "@cjhyy/code-shell-core/extension";
14
14
  // Resolve git/gh through PATH×PATHEXT on Windows so a .cmd/.exe shim is found
15
15
  // (bare execFile doesn't walk PATHEXT). No-op on POSIX. See utils/exec.ts.
16
16
  const GH_BIN = resolveExecutable("gh");
17
+ /** Run git with an argv array and return its untrimmed stdout. */
18
+ function gitRaw(cwd, args, timeoutMs = 10000) {
19
+ return execFileSync(resolveGit(), args, { cwd, encoding: "utf-8", timeout: timeoutMs });
20
+ }
17
21
  /** Run git with an argv array and return its trimmed stdout. */
18
22
  function git(cwd, args, timeoutMs = 10000) {
19
- return execFileSync(resolveGit(), args, { cwd, encoding: "utf-8", timeout: timeoutMs }).trim();
23
+ return gitRaw(cwd, args, timeoutMs).trim();
20
24
  }
21
25
  /** Run gh with an argv array and return its trimmed stdout. */
22
26
  function gh(cwd, args, timeoutMs = 10000) {
@@ -62,13 +66,22 @@ export function getCurrentBranch(cwd) {
62
66
  return git(cwd, ["branch", "--show-current"], 5000);
63
67
  }
64
68
  export function getGitStatus(cwd) {
65
- const raw = git(cwd, ["status", "--porcelain"], 10000);
66
- if (!raw)
67
- return [];
68
- return raw.split("\n").map((line) => ({
69
- status: line.slice(0, 2).trim(),
70
- path: line.slice(3),
71
- }));
69
+ // `-z` output is NUL-terminated and unquoted. It must not be trimmed: the
70
+ // fixed-width `XY ` prefix of an unstaged entry starts with a space.
71
+ const records = gitRaw(cwd, ["status", "--porcelain", "-z"], 10000).split("\0");
72
+ const entries = [];
73
+ for (let i = 0; i < records.length; i++) {
74
+ const record = records[i];
75
+ if (record.length < 4)
76
+ continue;
77
+ const xy = record.slice(0, 2);
78
+ const entry = { status: xy.trim(), path: record.slice(3) };
79
+ // Rename/copy entries are followed by a separate source-path record.
80
+ if (xy.includes("R") || xy.includes("C"))
81
+ entry.origPath = records[++i] ?? "";
82
+ entries.push(entry);
83
+ }
84
+ return entries;
72
85
  }
73
86
  export function getGitDiff(cwd, opts) {
74
87
  const args = ["diff", "--no-color"];
@@ -88,11 +101,17 @@ export function getGitDiffStat(cwd, opts) {
88
101
  args.push("--", opts.file);
89
102
  return git(cwd, args, 10000);
90
103
  }
104
+ /** Untracked, non-ignored files under `cwd`, relative to `cwd` (what `git add .` would add). */
105
+ export function getUntrackedFiles(cwd) {
106
+ return gitRaw(cwd, ["ls-files", "--others", "--exclude-standard", "-z"], 10000)
107
+ .split("\0")
108
+ .filter(Boolean);
109
+ }
91
110
  export function getGitLog(cwd, n = 10) {
92
111
  // `n` is numeric — coerce/validate to keep the argv clean even if a caller
93
112
  // hands us a string.
94
113
  const count = Number.isFinite(n) && n > 0 ? Math.floor(n) : 10;
95
- const raw = git(cwd, ["log", "--oneline", "--format=%H|%s|%an|%ci", `-${count}`], 10000);
114
+ const raw = git(cwd, ["log", `--format=${GIT_LOG_FORMAT}`, `-${count}`], 10000);
96
115
  if (!raw)
97
116
  return [];
98
117
  return parseGitLog(raw);
@@ -89,6 +89,8 @@ export interface RemoveWorktreeResult {
89
89
  }
90
90
  export interface RemoveWorktreeOptions {
91
91
  prefix?: string;
92
+ /** False lets Git reject working-tree changes made after the caller's preflight. */
93
+ force?: boolean;
92
94
  }
93
95
  export interface WorktreeChangeState {
94
96
  uncommitted: boolean;
@@ -48,6 +48,30 @@ export function selectPlatformScript(scripts, platform = process.platform) {
48
48
  const trimmed = candidate?.trim();
49
49
  return trimmed ? candidate : undefined;
50
50
  }
51
+ /** Tail of the pending creations per canonical repository root. */
52
+ const creationQueues = new Map();
53
+ /**
54
+ * Run one worktree creation at a time per repository. The prechecks and the
55
+ * rollback below assume that an absent branch/path seen by this call stays
56
+ * this call's to create; two concurrent identical requests would otherwise
57
+ * both pass the prechecks, and the loser's rollback would force-remove the
58
+ * winner's worktree, branch and uncommitted files.
59
+ */
60
+ async function withRepositoryCreationLock(gitRoot, run) {
61
+ const previous = creationQueues.get(gitRoot) ?? Promise.resolve();
62
+ let release;
63
+ const tail = previous.then(() => new Promise((resolveRelease) => (release = resolveRelease)));
64
+ creationQueues.set(gitRoot, tail);
65
+ await previous;
66
+ try {
67
+ return await run();
68
+ }
69
+ finally {
70
+ release();
71
+ if (creationQueues.get(gitRoot) === tail)
72
+ creationQueues.delete(gitRoot);
73
+ }
74
+ }
51
75
  /**
52
76
  * Create an isolated git worktree for an agent session.
53
77
  */
@@ -55,6 +79,10 @@ export async function createWorktree(cwd, slug, sessionId, opts = {}) {
55
79
  validateWorktreeSlug(slug);
56
80
  throwIfAborted(opts.signal);
57
81
  const gitRoot = await findGitRoot(cwd);
82
+ throwIfAborted(opts.signal);
83
+ return await withRepositoryCreationLock(gitRoot, () => createWorktreeLocked(cwd, gitRoot, slug, sessionId, opts));
84
+ }
85
+ async function createWorktreeLocked(cwd, gitRoot, slug, sessionId, opts) {
58
86
  throwIfAborted(opts.signal);
59
87
  const branchName = applyPrefix(opts.prefix, slug, sessionId);
60
88
  const worktreePath = resolve(gitRoot, "..", `.worktrees/${slug}-${sessionId.slice(0, 8)}`);
@@ -328,7 +356,7 @@ export function removeWorktree(worktreePath, removeBranch = false, opts = {}) {
328
356
  }
329
357
  }
330
358
  try {
331
- execGitSync(mainRoot, ["worktree", "remove", worktreePath, "--force"], 30000);
359
+ execGitSync(mainRoot, ["worktree", "remove", worktreePath, ...(opts.force === false ? [] : ["--force"])], 30000);
332
360
  }
333
361
  catch (err) {
334
362
  throw new Error(`failed to remove worktree ${worktreePath}: ${gitErrorMessage(err)}`, {
@@ -1,9 +1,7 @@
1
1
  /** Git worktree diff support owned by the coding capability. */
2
- import { gitOutput } from "./git-exec.js";
2
+ import { execGit, gitErrorMessage, gitOutput } from "./git-exec.js";
3
3
  export async function getWorktreeDiff(worktreePath, baseRef) {
4
- const comparisonBase = baseRef && (await commitRefExists(worktreePath, baseRef))
5
- ? baseRef
6
- : await findComparisonBaseRef(worktreePath);
4
+ const comparisonBase = await findSummaryComparisonBase(worktreePath, baseRef);
7
5
  return diffSummary(worktreePath, comparisonBase);
8
6
  }
9
7
  export async function worktreeHasUncommittedChanges(worktreePath) {
@@ -29,11 +27,33 @@ async function commitRefExists(cwd, ref) {
29
27
  const out = await gitOutput(cwd, ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`], 5000);
30
28
  return out !== undefined;
31
29
  }
30
+ async function findSummaryComparisonBase(cwd, baseRef) {
31
+ const candidates = new Set([
32
+ ...(baseRef ? [baseRef] : []),
33
+ "main",
34
+ "master",
35
+ "origin/main",
36
+ "origin/master",
37
+ ]);
38
+ for (const ref of candidates) {
39
+ try {
40
+ await execGit(cwd, ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`], 5000);
41
+ return ref;
42
+ }
43
+ catch (error) {
44
+ // --verify --quiet exits with 1 for an absent ref. Infrastructure errors
45
+ // must reach the UI instead of being reported as a clean workspace.
46
+ if (error.code !== 1)
47
+ throw error;
48
+ }
49
+ }
50
+ return undefined;
51
+ }
32
52
  async function diffSummary(cwd, baseRef) {
33
53
  const [dirtyFiles, committedFiles, aheadCommits] = await Promise.all([
34
54
  statusFileSet(cwd),
35
55
  baseRef ? changedFilesSinceBase(cwd, baseRef) : Promise.resolve(new Set()),
36
- baseRef ? aheadCommitCount(cwd, baseRef) : Promise.resolve(0),
56
+ baseRef ? readAheadCommitCount(cwd, baseRef) : Promise.resolve(0),
37
57
  ]);
38
58
  const changedFiles = new Set([...dirtyFiles, ...committedFiles]);
39
59
  return {
@@ -44,12 +64,26 @@ async function diffSummary(cwd, baseRef) {
44
64
  };
45
65
  }
46
66
  async function changedFilesSinceBase(cwd, baseRef) {
47
- const out = await gitOutput(cwd, ["diff", "--name-only", `${baseRef}...HEAD`]);
48
- const raw = out ?? (await gitOutput(cwd, ["diff", "--name-only", `${baseRef}..HEAD`])) ?? "";
49
- return new Set(raw
50
- .split("\n")
51
- .map((line) => line.trim())
52
- .filter(Boolean));
67
+ let raw;
68
+ try {
69
+ raw = await execGit(cwd, ["diff", "--name-only", "-z", `${baseRef}...HEAD`]);
70
+ }
71
+ catch (error) {
72
+ // Unrelated histories have no merge base. Keep the existing two-dot
73
+ // comparison for that case, while surfacing timeouts and other failures.
74
+ if (!/no merge base/i.test(gitErrorMessage(error)))
75
+ throw error;
76
+ raw = await execGit(cwd, ["diff", "--name-only", "-z", `${baseRef}..HEAD`]);
77
+ }
78
+ return new Set(raw.split("\0").filter(Boolean));
79
+ }
80
+ async function readAheadCommitCount(cwd, baseRef) {
81
+ const out = await execGit(cwd, ["rev-list", "--count", `${baseRef}..HEAD`]);
82
+ const count = Number(out.trim());
83
+ if (!Number.isSafeInteger(count) || count < 0) {
84
+ throw new Error("Git returned an invalid ahead commit count");
85
+ }
86
+ return count;
53
87
  }
54
88
  async function aheadCommitCount(cwd, baseRef) {
55
89
  const out = await gitOutput(cwd, ["rev-list", "--count", `${baseRef}..HEAD`]);
@@ -57,27 +91,21 @@ async function aheadCommitCount(cwd, baseRef) {
57
91
  return Number.isFinite(n) ? n : 0;
58
92
  }
59
93
  async function statusFileSet(cwd) {
60
- const raw = (await gitOutput(cwd, ["status", "--porcelain=v1"])) ?? "";
94
+ // Background summaries must not contend with a foreground Git operation for
95
+ // the optional index refresh lock. NUL records preserve arbitrary file names.
96
+ const raw = await execGit(cwd, ["--no-optional-locks", "status", "--porcelain=v1", "-z"]);
61
97
  const files = new Set();
62
- for (const line of raw.split("\n")) {
63
- if (!line.trim())
64
- continue;
65
- const path = line.slice(3).trim();
98
+ const records = raw.split("\0");
99
+ for (let i = 0; i < records.length; i += 1) {
100
+ const record = records[i];
101
+ const path = record.slice(3);
66
102
  if (!path)
67
103
  continue;
68
- const renameTarget = path.includes(" -> ") ? path.split(" -> ").at(-1) : path;
69
- if (renameTarget)
70
- files.add(unquoteGitPath(renameTarget));
104
+ files.add(path);
105
+ // In porcelain -z, the destination comes first and the following NUL
106
+ // record contains the source of a rename or copy.
107
+ if (/[RC]/.test(record.slice(0, 2)))
108
+ i += 1;
71
109
  }
72
110
  return files;
73
111
  }
74
- function unquoteGitPath(path) {
75
- if (!path.startsWith('"') || !path.endsWith('"'))
76
- return path;
77
- try {
78
- return JSON.parse(path);
79
- }
80
- catch {
81
- return path.slice(1, -1);
82
- }
83
- }
@@ -5,7 +5,7 @@ import { lspTool, lspToolDef } from "./tools/lsp.js";
5
5
  import { notebookEditTool, notebookEditToolDef } from "./tools/notebook-edit.js";
6
6
  import { applyPatchTool, applyPatchToolDef } from "./tools/apply-patch/index.js";
7
7
  import { parsePatch } from "./tools/apply-patch/parser.js";
8
- import { patchBackupTargets } from "./tools/apply-patch/backup-targets.js";
8
+ import { patchBackupTargets, patchDiffTargets } from "./tools/apply-patch/backup-targets.js";
9
9
  import { enterWorktreeTool, enterWorktreeToolDef, exitWorktreeTool, exitWorktreeToolDef, switchSessionWorkspaceTool, switchSessionWorkspaceToolDef, } from "./tools/worktree.js";
10
10
  import { branchExists, isGitWorktreeRoot } from "./git/worktree.js";
11
11
  import { checkQuotaTool, checkQuotaToolDef } from "./tools/check-quota.js";
@@ -235,10 +235,23 @@ export const TERMINAL_CODING_PRESET = {
235
235
  const CODING_PROMPT_SECTIONS = {
236
236
  coding: readFileSync(new URL("./prompt/coding.md", import.meta.url), "utf-8"),
237
237
  };
238
+ function notebookMutationTargets(args, cwd) {
239
+ return typeof args.file_path === "string" &&
240
+ args.file_path.length > 0 &&
241
+ (args.action === "insert" || args.action === "replace" || args.action === "delete")
242
+ ? [resolve(cwd, args.file_path)]
243
+ : [];
244
+ }
238
245
  const CODING_FILE_HISTORY = [
239
246
  {
240
247
  toolName: "ApplyPatch",
241
248
  resolveTargets: (args, cwd) => typeof args.patch === "string" ? patchBackupTargets(args.patch, cwd) : [],
249
+ resolveDiffTargets: (args, cwd) => typeof args.patch === "string" ? patchDiffTargets(args.patch, cwd) : [],
250
+ },
251
+ {
252
+ toolName: "NotebookEdit",
253
+ resolveTargets: notebookMutationTargets,
254
+ resolveDiffTargets: notebookMutationTargets,
242
255
  },
243
256
  ];
244
257
  const CODING_SESSION_WORKSPACE = {
@@ -2,3 +2,4 @@
2
2
  export * from "./git/worktree.js";
3
3
  export * from "./git/utils.js";
4
4
  export * from "./review/review-prompt.js";
5
+ export * from "./review/review-target.js";
package/dist/index.git.js CHANGED
@@ -2,3 +2,4 @@
2
2
  export * from "./git/worktree.js";
3
3
  export * from "./git/utils.js";
4
4
  export * from "./review/review-prompt.js";
5
+ export * from "./review/review-target.js";
@@ -28,6 +28,7 @@ export declare class LSPClient extends EventEmitter {
28
28
  private buffer;
29
29
  private contentLength;
30
30
  private initialized;
31
+ private failure;
31
32
  constructor(command: string, args?: string[], cwd?: string | undefined);
32
33
  start(): Promise<void>;
33
34
  initialize(rootUri: string): Promise<unknown>;
@@ -36,6 +37,8 @@ export declare class LSPClient extends EventEmitter {
36
37
  shutdown(): Promise<void>;
37
38
  get isInitialized(): boolean;
38
39
  get isAlive(): boolean;
40
+ private assertWritable;
41
+ private fail;
39
42
  private handleData;
40
43
  private rejectAll;
41
44
  }
@@ -18,6 +18,7 @@ export class LSPClient extends EventEmitter {
18
18
  buffer = Buffer.alloc(0);
19
19
  contentLength = -1;
20
20
  initialized = false;
21
+ failure;
21
22
  constructor(command, args = [], cwd) {
22
23
  super();
23
24
  this.command = command;
@@ -25,21 +26,56 @@ export class LSPClient extends EventEmitter {
25
26
  this.cwd = cwd;
26
27
  }
27
28
  async start() {
28
- this.process = spawn(this.command, this.args, {
29
+ if (this.isAlive)
30
+ return;
31
+ this.failure = undefined;
32
+ this.initialized = false;
33
+ this.buffer = Buffer.alloc(0);
34
+ this.contentLength = -1;
35
+ const child = spawn(this.command, this.args, {
29
36
  cwd: this.cwd,
30
37
  stdio: ["pipe", "pipe", "pipe"],
31
38
  });
32
- this.process.stdout?.on("data", (data) => this.handleData(data));
33
- this.process.stderr?.on("data", (_data) => {
39
+ this.process = child;
40
+ const fail = (error) => {
41
+ if (this.process !== child)
42
+ return;
43
+ this.fail(error);
44
+ };
45
+ child.stdout?.on("data", (data) => {
46
+ if (this.process === child && !this.failure)
47
+ this.handleData(data);
48
+ });
49
+ child.stderr?.on("data", (_data) => {
34
50
  // LSP servers often log to stderr — ignore
35
51
  });
36
- this.process.on("exit", (code) => {
52
+ // Pipe errors are emitted on the streams, not on ChildProcess. Keep these
53
+ // listeners through teardown so late EPIPE events cannot escape the host.
54
+ child.stdin?.on("error", fail);
55
+ child.stdout?.on("error", fail);
56
+ child.stderr?.on("error", fail);
57
+ child.on("error", fail);
58
+ child.on("exit", (code, signal) => {
59
+ if (this.process !== child)
60
+ return;
61
+ this.fail(new Error(`LSP server exited with ${signal ? `signal ${signal}` : `code ${code}`}`));
37
62
  this.emit("exit", code);
38
- this.rejectAll(new Error(`LSP server exited with code ${code}`));
39
63
  });
40
64
  // Don't keep the event loop alive solely for the LSP child.
41
65
  // Call after listeners are wired so event registration doesn't re-ref.
42
- this.process.unref();
66
+ child.unref();
67
+ await new Promise((resolve, reject) => {
68
+ const onSpawn = () => {
69
+ child.off("error", onError);
70
+ resolve();
71
+ };
72
+ const onError = (error) => {
73
+ child.off("spawn", onSpawn);
74
+ reject(error);
75
+ };
76
+ child.once("spawn", onSpawn);
77
+ child.once("error", onError);
78
+ });
43
79
  }
44
80
  async initialize(rootUri) {
45
81
  const result = await this.request("initialize", {
@@ -63,8 +99,7 @@ export class LSPClient extends EventEmitter {
63
99
  return result;
64
100
  }
65
101
  async request(method, params) {
66
- if (!this.process?.stdin)
67
- throw new Error("LSP server not started");
102
+ this.assertWritable();
68
103
  const id = this.nextId++;
69
104
  const message = { jsonrpc: "2.0", id, method, params };
70
105
  const content = JSON.stringify(message);
@@ -88,43 +123,79 @@ export class LSPClient extends EventEmitter {
88
123
  reject(err);
89
124
  },
90
125
  });
91
- this.process.stdin.write(header + content);
126
+ try {
127
+ this.process.stdin.write(header + content);
128
+ }
129
+ catch (error) {
130
+ this.fail(error instanceof Error ? error : new Error(String(error)));
131
+ }
92
132
  });
93
133
  }
94
134
  async notify(method, params) {
95
- if (!this.process?.stdin)
96
- throw new Error("LSP server not started");
135
+ this.assertWritable();
97
136
  const message = { jsonrpc: "2.0", method, params };
98
137
  const content = JSON.stringify(message);
99
138
  const header = `Content-Length: ${Buffer.byteLength(content)}\r\n\r\n`;
100
- this.process.stdin.write(header + content);
139
+ try {
140
+ this.process.stdin.write(header + content);
141
+ }
142
+ catch (error) {
143
+ this.fail(error instanceof Error ? error : new Error(String(error)));
144
+ throw error;
145
+ }
101
146
  }
102
147
  async shutdown() {
103
- if (!this.process)
148
+ const child = this.process;
149
+ if (!child)
104
150
  return;
105
151
  try {
106
- await this.request("shutdown", {});
107
- await this.notify("exit", {});
152
+ if (this.isAlive && this.initialized) {
153
+ await this.request("shutdown", {});
154
+ await this.notify("exit", {});
155
+ }
108
156
  }
109
157
  catch {
110
158
  // Force kill
111
159
  }
112
- this.process.stdout?.removeAllListeners();
113
- this.process.stderr?.removeAllListeners();
114
- this.process.removeAllListeners();
115
- this.process.kill();
160
+ if (this.process !== child)
161
+ return;
162
+ this.fail(new Error("LSP server shut down"));
116
163
  this.process = undefined;
117
- // Drain pending requests
118
- for (const { reject } of this.pending.values()) {
119
- reject(new Error("LSP server shut down"));
120
- }
121
- this.pending.clear();
164
+ child.stdin?.destroy();
165
+ child.stdout?.destroy();
166
+ child.stderr?.destroy();
122
167
  }
123
168
  get isInitialized() {
124
169
  return this.initialized;
125
170
  }
126
171
  get isAlive() {
127
- return this.process !== undefined && !this.process.killed;
172
+ return (!!this.process &&
173
+ !this.failure &&
174
+ !this.process.killed &&
175
+ this.process.exitCode === null &&
176
+ this.process.signalCode === null);
177
+ }
178
+ assertWritable() {
179
+ if (!this.isAlive || !this.process?.stdin || this.process.stdin.destroyed)
180
+ throw this.failure ?? new Error("LSP server is not running");
181
+ }
182
+ fail(error) {
183
+ if (this.failure)
184
+ return;
185
+ this.failure = error;
186
+ this.initialized = false;
187
+ this.rejectAll(error);
188
+ const child = this.process;
189
+ if (child?.pid && child.exitCode === null && child.signalCode === null) {
190
+ child.kill();
191
+ const timer = setTimeout(() => {
192
+ if (child.exitCode === null && child.signalCode === null)
193
+ child.kill("SIGKILL");
194
+ }, 500);
195
+ timer.unref();
196
+ child.once("exit", () => clearTimeout(timer));
197
+ }
198
+ this.emit("failure", error);
128
199
  }
129
200
  handleData(data) {
130
201
  // Append the new chunk as bytes. Buffer.concat avoids the UTF-8
@@ -7,6 +7,7 @@ type ServerState = "stopped" | "starting" | "ready" | "error";
7
7
  export declare class LSPServerManager {
8
8
  private servers;
9
9
  private rootUri;
10
+ private stopping;
10
11
  constructor(cwd: string);
11
12
  /**
12
13
  * Get or start a language server by name.
@@ -33,6 +34,7 @@ export declare class LSPServerManager {
33
34
  * Shutdown all servers.
34
35
  */
35
36
  shutdownAll(): Promise<void>;
37
+ private stopServers;
36
38
  }
37
39
  export declare function isCommandAvailable(command: string, env?: NodeJS.ProcessEnv): boolean;
38
40
  export declare function initializeLSPManager(cwd: string): LSPServerManager;
@@ -11,6 +11,7 @@ import { rootUriToPath } from "./root-path.js";
11
11
  export class LSPServerManager {
12
12
  servers = new Map();
13
13
  rootUri;
14
+ stopping;
14
15
  constructor(cwd) {
15
16
  this.rootUri = pathToFileURL(cwd).href;
16
17
  // Register all built-in servers
@@ -22,19 +23,25 @@ export class LSPServerManager {
22
23
  * Get or start a language server by name.
23
24
  */
24
25
  async getClient(serverName) {
26
+ if (this.stopping)
27
+ return undefined;
25
28
  const managed = this.servers.get(serverName);
26
29
  if (!managed)
27
30
  return undefined;
28
31
  if (managed.state === "ready" && managed.client?.isAlive) {
29
32
  return managed.client;
30
33
  }
31
- if (managed.state === "starting") {
32
- // Wait for it to be ready
33
- await new Promise((r) => setTimeout(r, 2000));
34
- return managed.client;
34
+ if (managed.starting)
35
+ return managed.starting;
36
+ const starting = this.startServer(serverName);
37
+ managed.starting = starting;
38
+ try {
39
+ return await starting;
40
+ }
41
+ finally {
42
+ if (managed.starting === starting)
43
+ managed.starting = undefined;
35
44
  }
36
- // Try to start
37
- return this.startServer(serverName);
38
45
  }
39
46
  /**
40
47
  * Start a specific language server.
@@ -50,17 +57,37 @@ export class LSPServerManager {
50
57
  return undefined;
51
58
  }
52
59
  managed.state = "starting";
60
+ managed.error = undefined;
61
+ const client = new LSPClient(managed.config.command, managed.config.args, rootUriToPath(this.rootUri));
62
+ managed.client = client;
63
+ client.on("failure", (error) => {
64
+ if (managed.client !== client)
65
+ return;
66
+ managed.state = "error";
67
+ managed.error = error.message;
68
+ });
53
69
  try {
54
- const client = new LSPClient(managed.config.command, managed.config.args, rootUriToPath(this.rootUri));
55
70
  await client.start();
71
+ if (managed.client !== client) {
72
+ await client.shutdown();
73
+ return undefined;
74
+ }
56
75
  await client.initialize(this.rootUri);
57
- managed.client = client;
76
+ // Shutdown can retire a startup while initialize is in flight.
77
+ if (managed.client !== client) {
78
+ await client.shutdown();
79
+ return undefined;
80
+ }
58
81
  managed.state = "ready";
59
82
  return client;
60
83
  }
61
84
  catch (err) {
62
- managed.state = "error";
63
- managed.error = err.message;
85
+ if (managed.client === client) {
86
+ managed.state = "error";
87
+ managed.error = err.message;
88
+ managed.client = undefined;
89
+ }
90
+ await client.shutdown();
64
91
  return undefined;
65
92
  }
66
93
  }
@@ -88,18 +115,32 @@ export class LSPServerManager {
88
115
  /**
89
116
  * Shutdown all servers.
90
117
  */
91
- async shutdownAll() {
118
+ shutdownAll() {
119
+ // Fence new starts for the entire sweep, including while a previous
120
+ // server's shutdown handshake is pending. Registry eviction must not leave
121
+ // a replacement process in a manager that no longer has an owner.
122
+ if (!this.stopping) {
123
+ this.stopping = Promise.resolve()
124
+ .then(() => this.stopServers())
125
+ .finally(() => {
126
+ this.stopping = undefined;
127
+ });
128
+ }
129
+ return this.stopping;
130
+ }
131
+ async stopServers() {
92
132
  for (const server of this.servers.values()) {
93
- if (server.client?.isAlive) {
133
+ const client = server.client;
134
+ server.state = "stopped";
135
+ server.client = undefined;
136
+ if (client) {
94
137
  try {
95
- await server.client.shutdown();
138
+ await client.shutdown();
96
139
  }
97
140
  catch {
98
141
  // Force killed
99
142
  }
100
143
  }
101
- server.state = "stopped";
102
- server.client = undefined;
103
144
  }
104
145
  }
105
146
  }
@@ -30,6 +30,7 @@ The default job in this preset is software engineering in the current working di
30
30
 
31
31
  - When referencing specific functions or pieces of code include the pattern file_path:line_number to allow the user to easily navigate to the source code location.
32
32
  - When referencing GitHub issues or pull requests, use the owner/repo#123 format so they render as clickable links.
33
+ - Before deleting a task branch, check both its merge target and configured upstream. `git branch -d` can refuse deletion when the branch is merged into local `HEAD` but its upstream still lags. Preserve that protection and report local versus remote integration accurately; do not automatically retry with `-D` or treat the refusal as a failed merge.
33
34
 
34
35
  ## Opening URLs and local previews
35
36
 
@@ -3,7 +3,8 @@
3
3
  *
4
4
  * Both lookups are verified against the real backends (2026-07-01):
5
5
  * - Codex: GET https://chatgpt.com/backend-api/wham/usage → JSON
6
- * rate_limit.{primary_window,secondary_window}.{used_percent,reset_at}.
6
+ * rate_limit.{primary_window,secondary_window} contains
7
+ * limit_window_seconds, used_percent and reset_at.
7
8
  * Zero cost (no message sent).
8
9
  * - Claude: POST /v1/messages (max_tokens:1) → response headers
9
10
  * anthropic-ratelimit-unified-<window>-{utilization,reset}, where