@gajae-code/coding-agent 0.16.0 → 0.16.3

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 (164) hide show
  1. package/CHANGELOG.md +52 -1
  2. package/dist/types/async/job-manager.d.ts +32 -1
  3. package/dist/types/browser-backend.d.ts +12 -0
  4. package/dist/types/cli/update-cli.d.ts +1 -0
  5. package/dist/types/commands/sdk.d.ts +2 -0
  6. package/dist/types/config/billing-path.d.ts +25 -0
  7. package/dist/types/config/config-schema-version.d.ts +13 -1
  8. package/dist/types/config/model-registry.d.ts +16 -0
  9. package/dist/types/config/settings-schema.d.ts +17 -7
  10. package/dist/types/extensibility/extensions/types.d.ts +8 -3
  11. package/dist/types/gjc-runtime/launch-worktree.d.ts +25 -1
  12. package/dist/types/gjc-runtime/session-state-lock.d.ts +22 -0
  13. package/dist/types/main.d.ts +1 -0
  14. package/dist/types/modes/acp/acp-agent.d.ts +21 -21
  15. package/dist/types/modes/components/model-selector.d.ts +4 -0
  16. package/dist/types/modes/components/session-selector.d.ts +1 -1
  17. package/dist/types/modes/components/sessions-dashboard.d.ts +2 -1
  18. package/dist/types/modes/interactive-mode.d.ts +16 -2
  19. package/dist/types/modes/theme/theme.d.ts +2 -1
  20. package/dist/types/modes/types.d.ts +2 -0
  21. package/dist/types/sdk/broker/broker.d.ts +2 -1
  22. package/dist/types/sdk/broker/ensure.d.ts +3 -0
  23. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +6 -0
  24. package/dist/types/sdk/broker/lifecycle.d.ts +14 -0
  25. package/dist/types/sdk/broker/startup-budget.d.ts +52 -0
  26. package/dist/types/sdk/broker/state-version.d.ts +17 -0
  27. package/dist/types/sdk/bus/discord-daemon.d.ts +1 -1
  28. package/dist/types/sdk/bus/index.d.ts +3 -1
  29. package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +1 -1
  30. package/dist/types/sdk/bus/prompt-reconciliation.d.ts +1 -1
  31. package/dist/types/sdk/bus/slack-conversation.d.ts +1 -1
  32. package/dist/types/sdk/bus/slack-daemon.d.ts +1 -1
  33. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +1 -1
  34. package/dist/types/sdk/cli/rows.d.ts +15 -2
  35. package/dist/types/sdk/cli/session-cli.d.ts +2 -1
  36. package/dist/types/sdk/host/events.d.ts +2 -0
  37. package/dist/types/sdk/host/host.d.ts +2 -0
  38. package/dist/types/sdk/host/query/cursor.d.ts +4 -2
  39. package/dist/types/sdk/host/query/handlers.d.ts +1 -0
  40. package/dist/types/sdk/host/query/revision-store.d.ts +1 -0
  41. package/dist/types/sdk/host/session-runtime.d.ts +18 -17
  42. package/dist/types/sdk/lifecycle-session.d.ts +1 -1
  43. package/dist/types/sdk/prompt-deadline-manager.d.ts +2 -5
  44. package/dist/types/sdk/receipt-state.d.ts +2 -0
  45. package/dist/types/sdk/router/session-router.d.ts +4 -0
  46. package/dist/types/sdk/turn-result.d.ts +6 -0
  47. package/dist/types/session/agent-session.d.ts +47 -7
  48. package/dist/types/session/fold-coordinator.d.ts +29 -2
  49. package/dist/types/session/session-manager.d.ts +14 -2
  50. package/dist/types/tools/ask.d.ts +1 -0
  51. package/dist/types/tools/bash.d.ts +12 -0
  52. package/dist/types/tools/browser/launch.d.ts +10 -0
  53. package/dist/types/tools/browser/registry.d.ts +8 -0
  54. package/dist/types/tools/index.d.ts +4 -10
  55. package/dist/types/tools/job.d.ts +3 -1
  56. package/dist/types/utils/external-editor.d.ts +12 -0
  57. package/examples/extensions/README.md +1 -1
  58. package/examples/extensions/pirate.ts +7 -4
  59. package/package.json +7 -7
  60. package/scripts/generate-sdk-operation-inventory.ts +7 -1
  61. package/src/async/job-manager.ts +107 -9
  62. package/src/browser-backend.ts +29 -0
  63. package/src/cli/plugin-cli.ts +29 -0
  64. package/src/cli/update-cli.ts +63 -12
  65. package/src/commands/sdk.ts +22 -7
  66. package/src/config/billing-path.ts +42 -0
  67. package/src/config/config-schema-version.ts +18 -1
  68. package/src/config/model-preset-registry.ts +177 -18
  69. package/src/config/model-registry.ts +268 -42
  70. package/src/config/settings-schema.ts +19 -7
  71. package/src/config/settings.ts +46 -15
  72. package/src/coordinator-mcp/projection-scan.ts +6 -6
  73. package/src/coordinator-mcp/server.ts +21 -1
  74. package/src/defaults/gjc/skills/deep-interview/SKILL.md +1 -1
  75. package/src/extensibility/extensions/runner.ts +27 -0
  76. package/src/extensibility/extensions/types.ts +8 -3
  77. package/src/extensibility/gjc-plugins/installer.ts +51 -11
  78. package/src/extensibility/runtime-skill-discovery.ts +110 -3
  79. package/src/extensibility/skills.ts +22 -12
  80. package/src/gjc-runtime/launch-worktree.ts +285 -4
  81. package/src/gjc-runtime/session-state-lock.ts +156 -32
  82. package/src/gjc-runtime/session-state-sidecar.ts +10 -17
  83. package/src/gjc-runtime/tmux-owner-isolation.ts +16 -1
  84. package/src/internal-urls/docs-index.generated.ts +11 -11
  85. package/src/main.ts +4 -1
  86. package/src/modes/acp/acp-agent.ts +34 -19
  87. package/src/modes/components/model-selector.ts +76 -8
  88. package/src/modes/components/session-selector.ts +23 -15
  89. package/src/modes/components/sessions-dashboard.ts +8 -5
  90. package/src/modes/controllers/command-controller.ts +1 -1
  91. package/src/modes/controllers/event-controller.ts +30 -4
  92. package/src/modes/controllers/extension-ui-controller.ts +13 -13
  93. package/src/modes/controllers/input-controller.ts +2 -2
  94. package/src/modes/controllers/selector-controller.ts +10 -3
  95. package/src/modes/interactive-mode.ts +36 -12
  96. package/src/modes/prompt-action-autocomplete.ts +30 -4
  97. package/src/modes/runtime-init.ts +12 -9
  98. package/src/modes/theme/theme.ts +5 -0
  99. package/src/modes/types.ts +2 -0
  100. package/src/modes/utils/ui-helpers.ts +22 -3
  101. package/src/prompts/agent-fragments/browser-aside.md +22 -0
  102. package/src/prompts/tools/ask.md +1 -1
  103. package/src/prompts/tools/bash.md +1 -0
  104. package/src/prompts/tools/job.md +1 -1
  105. package/src/sdk/acp/adapter.ts +6 -1
  106. package/src/sdk/broker/broker.ts +10 -1
  107. package/src/sdk/broker/ensure.ts +76 -3
  108. package/src/sdk/broker/lifecycle-ledger.ts +6 -0
  109. package/src/sdk/broker/lifecycle.ts +254 -34
  110. package/src/sdk/broker/spawn-substrate.ts +45 -2
  111. package/src/sdk/broker/startup-budget.ts +144 -2
  112. package/src/sdk/broker/state-version.ts +17 -0
  113. package/src/sdk/bus/chat-daemon-control.ts +2 -2
  114. package/src/sdk/bus/chat-daemon-runtime.ts +118 -24
  115. package/src/sdk/bus/discord-daemon.ts +50 -10
  116. package/src/sdk/bus/index.ts +33 -3
  117. package/src/sdk/bus/kind-aware-reconciliation.ts +296 -48
  118. package/src/sdk/bus/lifecycle-commands.ts +4 -0
  119. package/src/sdk/bus/notification-service.ts +22 -5
  120. package/src/sdk/bus/prompt-reconciliation.ts +7 -2
  121. package/src/sdk/bus/reconciliation-store.ts +55 -31
  122. package/src/sdk/bus/slack-conversation.ts +1 -1
  123. package/src/sdk/bus/slack-daemon.ts +72 -38
  124. package/src/sdk/bus/telegram-daemon-contract.ts +1 -1
  125. package/src/sdk/cli/rows.ts +75 -7
  126. package/src/sdk/cli/session-cli.ts +68 -35
  127. package/src/sdk/host/control/dispatch.ts +2 -2
  128. package/src/sdk/host/events.ts +26 -2
  129. package/src/sdk/host/host.ts +6 -1
  130. package/src/sdk/host/query/cursor.ts +52 -7
  131. package/src/sdk/host/query/handlers.ts +33 -12
  132. package/src/sdk/host/query/revision-store.ts +12 -0
  133. package/src/sdk/host/session-runtime.test.ts +497 -31
  134. package/src/sdk/host/session-runtime.ts +387 -54
  135. package/src/sdk/host/surface-policy.ts +3 -3
  136. package/src/sdk/lifecycle-session.ts +7 -2
  137. package/src/sdk/prompt-deadline-manager.ts +2 -1
  138. package/src/sdk/protocol/operation-inventory.generated.json +54 -3
  139. package/src/sdk/protocol/operation-registry.ts +1 -1
  140. package/src/sdk/receipt-state.ts +10 -0
  141. package/src/sdk/router/session-router.ts +210 -94
  142. package/src/sdk/session.ts +59 -41
  143. package/src/sdk/turn-result.ts +22 -0
  144. package/src/session/agent-session.ts +1033 -260
  145. package/src/session/fold-coordinator.ts +40 -3
  146. package/src/session/session-manager.ts +517 -77
  147. package/src/slash-commands/builtin-registry.ts +41 -0
  148. package/src/task/executor.ts +9 -4
  149. package/src/tools/ask-contract.test.ts +64 -0
  150. package/src/tools/ask-contract.ts +36 -1
  151. package/src/tools/ask.ts +10 -8
  152. package/src/tools/bash-interactive.ts +11 -2
  153. package/src/tools/bash.ts +158 -33
  154. package/src/tools/browser/launch.ts +37 -0
  155. package/src/tools/browser/registry.ts +99 -9
  156. package/src/tools/cron.ts +6 -4
  157. package/src/tools/descriptors.test.ts +15 -0
  158. package/src/tools/descriptors.ts +5 -1
  159. package/src/tools/index.ts +4 -6
  160. package/src/tools/job.ts +4 -1
  161. package/src/tools/monitor.ts +12 -9
  162. package/src/tools/resolve.ts +30 -10
  163. package/src/tools/tool-catalog.generated.ts +2 -2
  164. package/src/utils/external-editor.ts +86 -1
@@ -29,6 +29,27 @@ export interface GjcLaunchWorktreeResult extends GjcLaunchWorktreePlan {
29
29
  createdBranch: boolean;
30
30
  dirty?: boolean;
31
31
  }
32
+ export interface LaunchWorktreeAbortOptions {
33
+ signal?: AbortSignal;
34
+ deadlineAt?: number;
35
+ now?: () => number;
36
+ }
37
+
38
+ export class WorktreePreparationTimeoutError extends Error {
39
+ readonly code = "worktree_preparation_timeout";
40
+ constructor(message = "Worktree preparation exceeded its deadline before the session host was spawned.") {
41
+ super(message);
42
+ this.name = "WorktreePreparationTimeoutError";
43
+ }
44
+ }
45
+
46
+ export class DependencyPreparationTimeoutError extends Error {
47
+ readonly code = "dependency_preparation_timeout";
48
+ constructor(message = "Dependency preparation exceeded its deadline before the session host was spawned.") {
49
+ super(message);
50
+ this.name = "DependencyPreparationTimeoutError";
51
+ }
52
+ }
32
53
 
33
54
  interface GitWorktreeEntry {
34
55
  path: string;
@@ -39,10 +60,93 @@ interface GitWorktreeEntry {
39
60
 
40
61
  const BRANCH_IN_USE_PATTERN = /already checked out|already used by worktree|is already checked out/i;
41
62
 
63
+ function throwIfAborted(options: LaunchWorktreeAbortOptions | undefined, timeout: Error): void {
64
+ if (options?.signal?.aborted) throw timeout;
65
+ if (options?.deadlineAt !== undefined && (options.now ?? Date.now)() >= options.deadlineAt) throw timeout;
66
+ }
67
+
68
+ function sanitizeWorktreeDiagnostic(value: string): string {
69
+ return value
70
+ .replace(/([?&](?:token|secret|password|key|api[_-]?key)=[^\s&]*)/giu, "[redacted-query]")
71
+ .replace(
72
+ /(^|[^\p{L}\p{N}_-])[\p{L}\p{N}_-]*(?:token|secret|password|api[_-]?key|key|credential|auth)\s*[=:]\s*[^\s,;]+/giu,
73
+ "$1[redacted-secret]",
74
+ )
75
+ .replace(/\b(?:https?|wss?):\/\/[^\s]+/giu, "[redacted-url]")
76
+ .slice(0, 512);
77
+ }
78
+
79
+ async function spawnProcessGroup(
80
+ command: string[],
81
+ cwd: string,
82
+ options: LaunchWorktreeAbortOptions | undefined,
83
+ timeout: Error,
84
+ ): Promise<{ stdout: string; stderr: string; exitCode: number }> {
85
+ throwIfAborted(options, timeout);
86
+ const [file, ...args] = command;
87
+ if (!file) throw timeout;
88
+ const proc = Bun.spawn([file, ...args], {
89
+ cwd,
90
+ stdout: "pipe",
91
+ stderr: "pipe",
92
+ stdin: "ignore",
93
+ // Windows has no POSIX process-group kill: parent-only proc.kill() is the
94
+ // documented seam. Leftover classify still runs after wait on both OS.
95
+ detached: process.platform !== "win32",
96
+ ...(process.platform === "win32" ? { windowsHide: true } : {}),
97
+ });
98
+ const killGroup = (): void => {
99
+ try {
100
+ if (process.platform === "win32") proc.kill();
101
+ else if (proc.pid) process.kill(-proc.pid, "SIGKILL");
102
+ else proc.kill();
103
+ } catch {
104
+ try {
105
+ proc.kill();
106
+ } catch {
107
+ // already gone
108
+ }
109
+ }
110
+ };
111
+ const onAbort = (): void => {
112
+ killGroup();
113
+ };
114
+ options?.signal?.addEventListener("abort", onAbort, { once: true });
115
+ let deadlineTimer: ReturnType<typeof setTimeout> | undefined;
116
+ if (options?.deadlineAt !== undefined) {
117
+ const remaining = options.deadlineAt - (options.now ?? Date.now)();
118
+ if (remaining <= 0) {
119
+ killGroup();
120
+ options?.signal?.removeEventListener("abort", onAbort);
121
+ await proc.exited.catch(() => undefined);
122
+ throw timeout;
123
+ }
124
+ deadlineTimer = setTimeout(() => {
125
+ killGroup();
126
+ }, remaining);
127
+ }
128
+ try {
129
+ const [stdoutBytes, stderrBytes, exitCode] = await Promise.all([
130
+ new Response(proc.stdout).arrayBuffer(),
131
+ new Response(proc.stderr).arrayBuffer(),
132
+ proc.exited,
133
+ ]);
134
+ throwIfAborted(options, timeout);
135
+ return {
136
+ stdout: new TextDecoder().decode(stdoutBytes),
137
+ stderr: new TextDecoder().decode(stderrBytes),
138
+ exitCode: exitCode ?? 1,
139
+ };
140
+ } finally {
141
+ if (deadlineTimer) clearTimeout(deadlineTimer);
142
+ options?.signal?.removeEventListener("abort", onAbort);
143
+ }
144
+ }
145
+
42
146
  function runGit(cwd: string, args: string[]): string {
43
147
  const result = Bun.spawnSync(["git", ...args], { cwd, stdout: "pipe", stderr: "pipe" });
44
148
  if (result.exitCode === 0) return result.stdout.toString().trim();
45
- const stderr = result.stderr.toString().trim();
149
+ const stderr = sanitizeWorktreeDiagnostic(result.stderr.toString().trim());
46
150
  throw new Error(stderr || `git ${args.join(" ")} failed`);
47
151
  }
48
152
 
@@ -408,6 +512,16 @@ export function planLaunchWorktree(
408
512
 
409
513
  export function ensureLaunchWorktree(
410
514
  plan: GjcLaunchWorktreePlan | { enabled: false },
515
+ options?: LaunchWorktreeAbortOptions,
516
+ ): GjcLaunchWorktreeResult | { enabled: false } {
517
+ if (options?.signal || options?.deadlineAt !== undefined) {
518
+ throw new Error("ensureLaunchWorktree is synchronous; use ensureLaunchWorktreeCancellable for abortable prep.");
519
+ }
520
+ return ensureLaunchWorktreeSync(plan);
521
+ }
522
+
523
+ function ensureLaunchWorktreeSync(
524
+ plan: GjcLaunchWorktreePlan | { enabled: false },
411
525
  ): GjcLaunchWorktreeResult | { enabled: false } {
412
526
  if (!plan.enabled) return { enabled: false };
413
527
  const bucketPath = path.dirname(plan.worktreePath);
@@ -473,7 +587,93 @@ export function ensureLaunchWorktree(
473
587
 
474
588
  const result = Bun.spawnSync(["git", ...args], { cwd: plan.repoRoot, stdout: "pipe", stderr: "pipe" });
475
589
  if (result.exitCode !== 0) {
476
- const stderr = result.stderr.toString().trim();
590
+ const stderr = sanitizeWorktreeDiagnostic(result.stderr.toString().trim());
591
+ if (plan.branchName && BRANCH_IN_USE_PATTERN.test(stderr)) throw new Error(`branch_in_use:${plan.branchName}`);
592
+ throw new Error(stderr || `worktree_add_failed:${args.join(" ")}`);
593
+ }
594
+
595
+ return {
596
+ ...plan,
597
+ worktreePath: path.resolve(plan.worktreePath),
598
+ created: true,
599
+ reused: false,
600
+ createdBranch: Boolean(plan.branchName && !branchAlreadyExisted),
601
+ };
602
+ }
603
+
604
+ export async function ensureLaunchWorktreeCancellable(
605
+ plan: GjcLaunchWorktreePlan | { enabled: false },
606
+ options: LaunchWorktreeAbortOptions = {},
607
+ ): Promise<GjcLaunchWorktreeResult | { enabled: false }> {
608
+ if (!plan.enabled) return { enabled: false };
609
+ const timeout = new WorktreePreparationTimeoutError();
610
+ throwIfAborted(options, timeout);
611
+ const bucketPath = path.dirname(plan.worktreePath);
612
+ inspectBucketDir(bucketPath);
613
+ ensureRepositoryBucketIgnored(plan.repoRoot, bucketPath);
614
+ let allWorktrees = listWorktrees(plan.repoRoot);
615
+ const staleAtPath = findWorktreeByPath(allWorktrees, plan.worktreePath);
616
+ if (staleAtPath && !fs.existsSync(staleAtPath.path)) {
617
+ pruneStaleWorktreePath(plan.repoRoot);
618
+ allWorktrees = listWorktrees(plan.repoRoot);
619
+ }
620
+ throwIfAborted(options, timeout);
621
+
622
+ const listedAtPath = findWorktreeByPath(allWorktrees, plan.worktreePath);
623
+ const existingAtPath = readWorktreeEntryFromPath(plan.repoRoot, plan.worktreePath);
624
+ if (listedAtPath && !existingAtPath) {
625
+ if (fs.existsSync(plan.worktreePath)) throw new Error(`worktree_path_conflict:${plan.worktreePath}`);
626
+ throw new Error(
627
+ [
628
+ "worktree_path_unavailable",
629
+ "The requested launch worktree is still registered by Git but its directory is unavailable or locked.",
630
+ `Path: ${formatBucketPath(plan.worktreePath)}`,
631
+ "Safe remediation: inspect the worktree lock and remove or repair it with git worktree remove/prune when it is no longer needed, then relaunch. GJC did not delete or replace the entry.",
632
+ ].join("\n"),
633
+ );
634
+ }
635
+ const expectedBranchRef = plan.branchName ? `refs/heads/${plan.branchName}` : null;
636
+
637
+ if (existingAtPath) {
638
+ let dirty = isWorktreeDirty(plan.worktreePath);
639
+ if (plan.detached) {
640
+ if (!existingAtPath.detached) {
641
+ throw new Error(formatWorktreeTargetMismatch(plan, existingAtPath));
642
+ }
643
+ if (existingAtPath.head !== plan.baseRef) {
644
+ if (dirty) throw new Error(`worktree_dirty:${plan.worktreePath}`);
645
+ runGit(plan.worktreePath, ["checkout", "--detach", plan.baseRef]);
646
+ dirty = false;
647
+ }
648
+ } else if (existingAtPath.branchRef !== expectedBranchRef) {
649
+ throw new Error(formatWorktreeTargetMismatch(plan, existingAtPath));
650
+ }
651
+ return {
652
+ ...plan,
653
+ worktreePath: path.resolve(plan.worktreePath),
654
+ created: false,
655
+ reused: true,
656
+ createdBranch: false,
657
+ ...(dirty ? { dirty: true } : {}),
658
+ };
659
+ }
660
+
661
+ if (fs.existsSync(plan.worktreePath)) throw new Error(`worktree_path_conflict:${plan.worktreePath}`);
662
+ if (plan.branchName && hasBranchInUse(allWorktrees, plan.branchName, plan.worktreePath)) {
663
+ throw new Error(`branch_in_use:${plan.branchName}`);
664
+ }
665
+
666
+ ensureBucketDirUsable(path.dirname(plan.worktreePath));
667
+ throwIfAborted(options, timeout);
668
+ const branchAlreadyExisted = plan.branchName ? branchExists(plan.repoRoot, plan.branchName) : false;
669
+ const args = ["worktree", "add"];
670
+ if (plan.detached) args.push("--detach", plan.worktreePath, plan.baseRef);
671
+ else if (branchAlreadyExisted) args.push(plan.worktreePath, plan.branchName ?? "");
672
+ else args.push("-b", plan.branchName ?? "", plan.worktreePath, plan.baseRef);
673
+
674
+ const result = await spawnProcessGroup(["git", ...args], plan.repoRoot, options, timeout);
675
+ if (result.exitCode !== 0) {
676
+ const stderr = sanitizeWorktreeDiagnostic(result.stderr.trim());
477
677
  if (plan.branchName && BRANCH_IN_USE_PATTERN.test(stderr)) throw new Error(`branch_in_use:${plan.branchName}`);
478
678
  throw new Error(stderr || `worktree_add_failed:${args.join(" ")}`);
479
679
  }
@@ -598,10 +798,49 @@ function installWorkspaceDependencies(
598
798
  stderr: "pipe",
599
799
  });
600
800
  } catch (error) {
601
- throw new Error(`worktree_dependency_install_failed:${manager.name}:${String(error)}`);
801
+ throw new Error(
802
+ `worktree_dependency_install_failed:${manager.name}:${sanitizeWorktreeDiagnostic(String(error))}`,
803
+ );
804
+ }
805
+ if (result.exitCode !== 0) {
806
+ throw new Error(
807
+ `worktree_dependency_install_failed:${manager.name}:${sanitizeWorktreeDiagnostic(result.stderr.toString().trim())}`,
808
+ );
809
+ }
810
+ let installed: fs.Stats;
811
+ try {
812
+ installed = fs.lstatSync(path.join(worktreePath, "node_modules"));
813
+ } catch (error) {
814
+ if (fileSystemErrorCode(error) === "ENOENT") return;
815
+ throw error;
816
+ }
817
+ if (!installed.isDirectory() || installed.isSymbolicLink()) throw new Error("worktree_dependency_install_not_local");
818
+ }
819
+
820
+ async function installWorkspaceDependenciesCancellable(
821
+ sourceRoot: string,
822
+ worktreePath: string,
823
+ manifest: WorkspacePackageManifest | null,
824
+ options: LaunchWorktreeAbortOptions = {},
825
+ ): Promise<void> {
826
+ const timeout = new DependencyPreparationTimeoutError();
827
+ throwIfAborted(options, timeout);
828
+ removeLegacySourceNodeModulesLink(sourceRoot, worktreePath);
829
+ const manager = resolveWorkspacePackageManager(worktreePath, manifest);
830
+ const command = workspaceInstallCommand(manager);
831
+ let result: { stdout: string; stderr: string; exitCode: number };
832
+ try {
833
+ result = await spawnProcessGroup(command, worktreePath, options, timeout);
834
+ } catch (error) {
835
+ if (error instanceof DependencyPreparationTimeoutError) throw error;
836
+ throw new Error(
837
+ `worktree_dependency_install_failed:${manager.name}:${sanitizeWorktreeDiagnostic(String(error))}`,
838
+ );
602
839
  }
603
840
  if (result.exitCode !== 0) {
604
- throw new Error(`worktree_dependency_install_failed:${manager.name}:${result.stderr.toString().trim()}`);
841
+ throw new Error(
842
+ `worktree_dependency_install_failed:${manager.name}:${sanitizeWorktreeDiagnostic(result.stderr.trim())}`,
843
+ );
605
844
  }
606
845
  let installed: fs.Stats;
607
846
  try {
@@ -628,6 +867,26 @@ export function ensureReusableNodeModules(sourceRoot: string, worktreePath: stri
628
867
  return "symlink";
629
868
  }
630
869
 
870
+ export async function ensureReusableNodeModulesCancellable(
871
+ sourceRoot: string,
872
+ worktreePath: string,
873
+ options: LaunchWorktreeAbortOptions = {},
874
+ ): Promise<"symlink" | "present" | "missing"> {
875
+ const timeout = new DependencyPreparationTimeoutError();
876
+ throwIfAborted(options, timeout);
877
+ const target = path.join(worktreePath, "node_modules");
878
+ const manifest = readWorkspacePackageManifest(worktreePath);
879
+ if (isWorkspaceRoot(worktreePath, manifest)) {
880
+ await installWorkspaceDependenciesCancellable(sourceRoot, worktreePath, manifest, options);
881
+ return "present";
882
+ }
883
+ if (fs.existsSync(target)) return "present";
884
+ const source = path.join(sourceRoot, "node_modules");
885
+ if (!fs.existsSync(source)) return "missing";
886
+ fs.symlinkSync(source, target, "junction");
887
+ return "symlink";
888
+ }
889
+
631
890
  /** Result of {@link prepareLaunchWorktree}: the effective working directory, remaining args, and resolved worktree plan. */
632
891
  export interface PreparedLaunchWorktree {
633
892
  cwd: string;
@@ -643,3 +902,25 @@ export function prepareLaunchWorktree(cwd: string, args: string[]): PreparedLaun
643
902
  ensureReusableNodeModules(ensured.repoRoot, ensured.worktreePath);
644
903
  return { cwd: ensured.worktreePath, args: parsed.remainingArgs, worktree: ensured };
645
904
  }
905
+ export function removeOwnedLaunchWorktree(
906
+ plan: GjcLaunchWorktreePlan,
907
+ receipt: { created: boolean; reused: boolean; createdBranch: boolean },
908
+ ): void {
909
+ if (!receipt.created || receipt.reused) return;
910
+ try {
911
+ runGit(plan.repoRoot, ["worktree", "remove", "--force", plan.worktreePath]);
912
+ } catch {
913
+ try {
914
+ runGit(plan.repoRoot, ["worktree", "prune"]);
915
+ } catch {
916
+ // Proof stays on the ledger; leftover classify is best-effort after abort.
917
+ }
918
+ }
919
+ if (receipt.createdBranch && plan.branchName) {
920
+ try {
921
+ runGit(plan.repoRoot, ["branch", "-D", plan.branchName]);
922
+ } catch {
923
+ // Owned-branch delete is best-effort; collisions keep the pre-existing branch.
924
+ }
925
+ }
926
+ }
@@ -55,6 +55,10 @@ function lockRetryElapsedMs(budget: LockRetryBudget): number {
55
55
  return Math.max(0, performance.now() - budget.startedAt);
56
56
  }
57
57
 
58
+ function lockRetryExhausted(budget: LockRetryBudget): boolean {
59
+ return lockRetryElapsedMs(budget) >= LOCK_ACQUIRE_TIMEOUT_MS;
60
+ }
61
+
58
62
  async function waitForLockRetry(budget: LockRetryBudget): Promise<boolean> {
59
63
  budget.attempts++;
60
64
  const remainingMs = LOCK_ACQUIRE_TIMEOUT_MS - lockRetryElapsedMs(budget);
@@ -78,8 +82,8 @@ async function waitForLockRetry(budget: LockRetryBudget): Promise<boolean> {
78
82
  * owner record is the sibling `<file>.lock.transition.owner`. `mkdir` admits exactly one
79
83
  * contender, while the held directory prevents a successor until release unlinks the
80
84
  * validated sidecar and atomically `rmdir`s the claim. A crash before either step leaves a
81
- * fail-closed directory for explicit recovery; concurrent automatic reclaim cannot prove
82
- * that a directory + sibling sidecar still name the claim it inspected.
85
+ * directory that a successor may reclaim only with proof: a host-qualified owner whose pid
86
+ * is proven dead, plus an exact directory-generation and tree match at removal time.
83
87
  *
84
88
  * The separate sidecar keeps the claim directory empty, which is what makes `rmdir` the
85
89
  * identity-safe portable release primitive. The path stays distinct from `<file>.lock`
@@ -237,8 +241,7 @@ function removeOwnedTransitionClaim(nativePath: string, generation: TransitionDi
237
241
  if (!captured.ok || !captured.snapshot) return captured.code === "not_found";
238
242
  const root = captured.snapshot.entries.find(entry => entry.relativePath === "");
239
243
  if (
240
- !root ||
241
- root.kind !== "directory" ||
244
+ root?.kind !== "directory" ||
242
245
  root.dev !== String(generation.dev) ||
243
246
  root.ino !== String(generation.ino) ||
244
247
  root.nlink !== String(generation.nlink) ||
@@ -410,6 +413,8 @@ export const SessionStateLockTestHooks: {
410
413
  forcedQuarantineName?: string;
411
414
  /** @internal Observes bounded acquisition retries without changing their timing. */
412
415
  afterAcquireContention?: (lockFile: string, attempt: number, elapsedMs: number) => void;
416
+ /** @internal Runs after transition mkdir contention and before stale-claim inspection. */
417
+ afterTransitionClaimContention?: (transitionDir: string) => void | Promise<void>;
413
418
  } = {};
414
419
 
415
420
  /** Raised when the lock could not be acquired; callers map it to their own refusal. */
@@ -465,6 +470,112 @@ export class SessionStateLockUnavailableError extends Error {
465
470
  }
466
471
  }
467
472
 
473
+ /**
474
+ * Log fields describing a persist failure: the error text plus, when a session state
475
+ * lock refusal sits anywhere in the cause chain, its typed `reason` and `lockPath`.
476
+ * Wrapping errors (`PreviousRuntimeStateReadError`, `AggregateError`) are searched
477
+ * so the actionable detail is never buried behind a generic message.
478
+ */
479
+ export function sessionStateLockFailureFields(error: unknown): {
480
+ error: string;
481
+ reason?: SessionStateLockUnavailableReason;
482
+ lockPath?: string;
483
+ } {
484
+ const fields: { error: string; reason?: SessionStateLockUnavailableReason; lockPath?: string } = {
485
+ error: String(error),
486
+ };
487
+ const seen = new Set<unknown>();
488
+ const queue: unknown[] = [error];
489
+ while (queue.length > 0) {
490
+ const current = queue.shift();
491
+ if (current === null || typeof current !== "object" || seen.has(current)) continue;
492
+ seen.add(current);
493
+ if (current instanceof SessionStateLockUnavailableError) {
494
+ if (current.reason !== undefined) fields.reason = current.reason;
495
+ if (current.lockPath !== undefined) fields.lockPath = current.lockPath;
496
+ if (fields.reason !== undefined && fields.lockPath !== undefined) return fields;
497
+ }
498
+ if (current instanceof AggregateError) queue.push(...current.errors);
499
+ if ("cause" in current) queue.push((current as { cause?: unknown }).cause);
500
+ }
501
+ return fields;
502
+ }
503
+
504
+ function boundedPersistFailureClassPart(value: unknown, fallback: string): string {
505
+ if (typeof value !== "string" && typeof value !== "number") return fallback;
506
+ const normalized = String(value)
507
+ .replace(/[^A-Za-z0-9_.-]/gu, "_")
508
+ .slice(0, 64);
509
+ return normalized || fallback;
510
+ }
511
+
512
+ function persistFailureProperty(value: unknown, name: string): unknown {
513
+ if (value === null || (typeof value !== "object" && typeof value !== "function")) return undefined;
514
+ try {
515
+ return Reflect.get(value, name);
516
+ } catch {
517
+ return undefined;
518
+ }
519
+ }
520
+
521
+ function persistFailureConstructorName(error: unknown): string {
522
+ if (error === null) return "null";
523
+ if (typeof error !== "object" && typeof error !== "function") return typeof error;
524
+ const ctor = persistFailureProperty(error, "constructor");
525
+ const name = persistFailureProperty(ctor, "name");
526
+ return typeof name === "string" && name.length > 0 ? name : "Object";
527
+ }
528
+
529
+ /**
530
+ * Build a bounded warn-window partition without including arbitrary error text. Typed lock
531
+ * refusals use their stable reason; all other failures use the root constructor and the
532
+ * first structured `code`/`errno` found in the error/cause chain.
533
+ */
534
+ function persistFailureClass(error: unknown): string {
535
+ const lockFields = sessionStateLockFailureFields(error);
536
+ if (lockFields.reason !== undefined) return `reason:${lockFields.reason}`;
537
+ const seen = new Set<unknown>();
538
+ const queue: unknown[] = [error];
539
+ let code: unknown;
540
+ let errno: unknown;
541
+ while (queue.length > 0) {
542
+ const current = queue.shift();
543
+ if (current === null || typeof current !== "object" || seen.has(current)) continue;
544
+ seen.add(current);
545
+ code ??= persistFailureProperty(current, "code");
546
+ errno ??= persistFailureProperty(current, "errno");
547
+ if (code !== undefined && errno !== undefined) break;
548
+ if (current instanceof AggregateError) queue.push(...current.errors);
549
+ const cause = persistFailureProperty(current, "cause");
550
+ if (cause !== undefined) queue.push(cause);
551
+ }
552
+ return `error:${boundedPersistFailureClassPart(persistFailureConstructorName(error), "Object")}:code=${boundedPersistFailureClassPart(code, "none")}:errno=${boundedPersistFailureClassPart(errno, "none")}`;
553
+ }
554
+
555
+ export const PERSIST_FAILURE_WARN_WINDOW_MS = 30_000;
556
+ const persistFailureLastWarnedAt = new Map<string, number>();
557
+
558
+ /**
559
+ * Whether a persist failure should be logged at warn level. The first failure for each
560
+ * document and stable failure class in a 30s window warns; repeats are the caller's cue
561
+ * to log at debug instead.
562
+ */
563
+ export function shouldWarnPersistFailure(key: string, error: unknown, now = Date.now()): boolean {
564
+ for (const [otherKey, warnedAt] of persistFailureLastWarnedAt) {
565
+ if (now - warnedAt >= PERSIST_FAILURE_WARN_WINDOW_MS) persistFailureLastWarnedAt.delete(otherKey);
566
+ }
567
+ const scopedKey = `${key}\u0000${persistFailureClass(error)}`;
568
+ const last = persistFailureLastWarnedAt.get(scopedKey);
569
+ if (last !== undefined && now - last < PERSIST_FAILURE_WARN_WINDOW_MS) return false;
570
+ persistFailureLastWarnedAt.set(scopedKey, now);
571
+ return true;
572
+ }
573
+
574
+ /** @internal Test seam: forget every persist-failure warn window. */
575
+ export function resetPersistFailureWarnWindows(): void {
576
+ persistFailureLastWarnedAt.clear();
577
+ }
578
+
468
579
  function lockUnavailable(
469
580
  lockPath: string,
470
581
  reason: SessionStateLockUnavailableReason,
@@ -1388,6 +1499,7 @@ async function releaseOwnerLock(file: string, held: LockOwnerSnapshot): Promise<
1388
1499
  type SessionStateLockReclaimResult =
1389
1500
  | "absent_or_changed"
1390
1501
  | "legacy_directory_unprovenanced"
1502
+ | "owner_removed"
1391
1503
  | "owner_live_or_unverifiable"
1392
1504
  | "owner_record_fresh"
1393
1505
  | "owner_unprovenanced"
@@ -1426,7 +1538,8 @@ async function reclaimStaleOwnerRecord(
1426
1538
  // A successor that took the path in the final window keeps it; this call just loses.
1427
1539
  if (outcome === "refused")
1428
1540
  throw new SessionStateLockUnavailableError(new Error("Stale owner record could not be reclaimed."));
1429
- return outcome === "removed" || outcome === "absent" ? "reclaimed" : "absent_or_changed";
1541
+ if (outcome === "removed") return "owner_removed";
1542
+ return "absent_or_changed";
1430
1543
  }
1431
1544
 
1432
1545
  /**
@@ -1558,14 +1671,18 @@ async function releaseTransitionClaim(
1558
1671
  }
1559
1672
  }
1560
1673
 
1561
- async function reclaimStaleTransitionClaim(transitionDir: string, quarantineName: string): Promise<void> {
1562
- const stat = await fs.lstat(transitionDir, { bigint: true }).catch(() => null);
1563
- if (!stat) return;
1674
+ async function reclaimStaleTransitionClaim(transitionDir: string, quarantineName: string): Promise<boolean> {
1675
+ let stat: fsSync.BigIntStats;
1676
+ try {
1677
+ stat = await fs.lstat(transitionDir, { bigint: true });
1678
+ } catch {
1679
+ return false;
1680
+ }
1564
1681
  // Regular-file claims belong to the superseded protocol. They retain the old
1565
1682
  // exact-identity stale path; released PID-1 tombstones deliberately require
1566
1683
  // explicit cleanup before this atomic-directory protocol can take over.
1567
1684
  if (stat.isFile()) {
1568
- await reclaimStaleOwnerRecord(
1685
+ const reclaimed = await reclaimStaleOwnerRecord(
1569
1686
  transitionDir,
1570
1687
  {
1571
1688
  afterInspection: SessionStateLockTestHooks.afterTransitionStaleInspection,
@@ -1573,53 +1690,56 @@ async function reclaimStaleTransitionClaim(transitionDir: string, quarantineName
1573
1690
  },
1574
1691
  quarantineName,
1575
1692
  );
1576
- return;
1693
+ return reclaimed === "owner_removed";
1577
1694
  }
1578
1695
  if (!stat.isDirectory()) throw new SessionStateLockUnavailableError();
1579
1696
  const ownerSnapshot = await captureRegularLockOwner(`${transitionDir}.owner`);
1580
- if (!ownerSnapshot) return;
1697
+ if (!ownerSnapshot) return false;
1581
1698
  let owner: unknown;
1582
1699
  try {
1583
1700
  owner = JSON.parse(ownerSnapshot.bytes);
1584
1701
  } catch {
1585
- return;
1702
+ return false;
1586
1703
  }
1587
- if (!validLockOwner(owner)) return;
1704
+ if (!validLockOwner(owner)) return false;
1705
+ await SessionStateLockTestHooks.afterTransitionStaleInspection?.(transitionDir);
1588
1706
  if (owner.released === true) {
1589
1707
  // A released record is safe only when it is qualified by this installation.
1590
1708
  // Never let a shared-volume tombstone, an absent identity, or a legacy foreign
1591
1709
  // identity authorize removal of a claim this process cannot own.
1592
- if (owner.owner_host_id === undefined) return;
1710
+ if (owner.owner_host_id === undefined) return false;
1593
1711
  const currentHost = await currentOwnerHostId();
1594
1712
  const legacyHost = await currentLegacyOwnerHostId();
1595
- if (owner.owner_host_id !== currentHost && owner.owner_host_id !== legacyHost) return;
1596
- if (Date.now() - Number(ownerSnapshot.mtimeNs / 1_000_000n) < RELEASED_TRANSITION_GRACE_MS) return;
1597
- } else {
1598
- if (process.platform !== "win32") return;
1599
- if (await lockOwnerIsAlive(owner)) return;
1713
+ if (owner.owner_host_id !== currentHost && owner.owner_host_id !== legacyHost) return false;
1714
+ if (Date.now() - Number(ownerSnapshot.mtimeNs / 1_000_000n) < RELEASED_TRANSITION_GRACE_MS) return false;
1715
+ } else if (await lockOwnerIsAlive(owner)) {
1716
+ // Only a host-qualified owner whose pid is PROVEN dead (ESRCH, or a live pid
1717
+ // with a provably different incarnation) authorizes reclaim. The generation
1718
+ // + exact-tree checks below then bind the removal to the very directory
1719
+ // inspected here, on every platform; a claim stranded by a force-quit
1720
+ // (`postmortem.quit`) would otherwise wall the session directory forever.
1721
+ return false;
1600
1722
  }
1601
1723
  const generation = transitionGenerationFromStat(stat);
1602
1724
  const nativePath = await canonicalOwnedTransitionPath(transitionDir).catch(() => null);
1603
- if (!nativePath) return;
1725
+ if (!nativePath) return false;
1604
1726
  const captured = nativeSessionStateLock().snapshotDirectoryTree(nativePath);
1605
- if (!captured.ok || !captured.snapshot) return;
1727
+ if (!captured.ok || !captured.snapshot) return false;
1606
1728
  const root = captured.snapshot.entries.find(entry => entry.relativePath === "");
1607
1729
  if (
1608
- !root ||
1609
- root.kind !== "directory" ||
1730
+ root?.kind !== "directory" ||
1610
1731
  root.dev !== String(generation.dev) ||
1611
1732
  root.ino !== String(generation.ino) ||
1612
1733
  root.nlink !== String(generation.nlink) ||
1613
1734
  root.mtimeNs !== String(generation.mtimeNs) ||
1614
1735
  root.ctimeNs !== String(generation.ctimeNs)
1615
1736
  )
1616
- return;
1617
- if (captured.snapshot.entries.length !== 1) return;
1737
+ return false;
1738
+ if (captured.snapshot.entries.length !== 1) return false;
1618
1739
  const removed = nativeSessionStateLock().exactRemoveDirectoryTree(nativePath, captured.snapshot);
1619
1740
  if (removed.ok || removed.code === "not_found") {
1620
- const ownerRemoval = exactUnlinkOwnerRecord(`${transitionDir}.owner`, ownerSnapshot, quarantineName);
1621
- if (ownerRemoval !== "removed" && ownerRemoval !== "absent") return;
1622
- return;
1741
+ exactUnlinkOwnerRecord(`${transitionDir}.owner`, ownerSnapshot, quarantineName);
1742
+ return removed.ok;
1623
1743
  }
1624
1744
  if (
1625
1745
  removed.code === "cleanup_pending" &&
@@ -1635,9 +1755,8 @@ async function reclaimStaleTransitionClaim(transitionDir: string, quarantineName
1635
1755
  // collision on POSIX. rmdir never follows a directory's contents and refuses if a
1636
1756
  // successor populated the detached path.
1637
1757
  await removeTransitionDir(removed.detachedPath);
1638
- const ownerRemoval = exactUnlinkOwnerRecord(`${transitionDir}.owner`, ownerSnapshot, quarantineName);
1639
- if (ownerRemoval !== "removed" && ownerRemoval !== "absent") return;
1640
- return;
1758
+ exactUnlinkOwnerRecord(`${transitionDir}.owner`, ownerSnapshot, quarantineName);
1759
+ return true;
1641
1760
  }
1642
1761
  throw new SessionStateLockUnavailableError(
1643
1762
  new Error(`Stale transition claim could not be reclaimed (${removed.code ?? "unknown"}).`),
@@ -1901,7 +2020,12 @@ async function withLockPathTransition<T>(
1901
2020
  } catch (error) {
1902
2021
  const code = (error as NodeJS.ErrnoException).code;
1903
2022
  if (code !== "EEXIST" && !isTransientLockError(error)) throw new SessionStateLockUnavailableError(error);
1904
- await reclaimStaleTransitionClaim(transitionDir, quarantineName);
2023
+ await SessionStateLockTestHooks.afterTransitionClaimContention?.(transitionDir);
2024
+ if (await reclaimStaleTransitionClaim(transitionDir, quarantineName)) {
2025
+ if (lockRetryExhausted(budget))
2026
+ throw lockUnavailable(transitionDir, "transition_claim_timeout", budget, error);
2027
+ continue;
2028
+ }
1905
2029
  if (!(await waitForLockRetry(budget)))
1906
2030
  throw lockUnavailable(transitionDir, "transition_claim_timeout", budget, error);
1907
2031
  continue;
@@ -1632,12 +1632,9 @@ async function writeStateFileSyncConditional(
1632
1632
  * than faulting forever.
1633
1633
  */
1634
1634
  async function withStateFileLock<T>(stateFile: string, operation: () => Promise<T>): Promise<T> {
1635
- try {
1636
- return await withSessionStateFileLock(stateFile, operation);
1637
- } catch (error) {
1638
- if (error instanceof SessionStateLockUnavailableError) throw new PreviousRuntimeStateReadError(error);
1639
- throw error;
1640
- }
1635
+ // Preserve the lock's typed path/reason diagnostics. A transition timeout means the
1636
+ // state file was never entered, not that its existing JSON marker was unreadable.
1637
+ return await withSessionStateFileLock(stateFile, operation);
1641
1638
  }
1642
1639
 
1643
1640
  function coordinatorTransactionLockFile(stateFile: string): string {
@@ -1650,17 +1647,13 @@ function coordinatorTransactionLockFile(stateFile: string): string {
1650
1647
  * protocol.
1651
1648
  */
1652
1649
  async function withCoordinatorTransactionLock<T>(stateFile: string, operation: () => Promise<T>): Promise<T> {
1653
- try {
1654
- return await withFileLock(coordinatorTransactionLockFile(stateFile), operation, {
1655
- staleMs: 30_000,
1656
- retries: 12_000,
1657
- retryDelayMs: 5,
1658
- });
1659
- } catch (error) {
1660
- if (error instanceof Error && error.message.startsWith("Failed to acquire lock"))
1661
- throw new PreviousRuntimeStateReadError();
1662
- throw error;
1663
- }
1650
+ // Namespace-lock failures are contention or filesystem failures, not evidence that
1651
+ // runtime-state.json is malformed. Let the original diagnostic reach the caller.
1652
+ return await withFileLock(coordinatorTransactionLockFile(stateFile), operation, {
1653
+ staleMs: 30_000,
1654
+ retries: 12_000,
1655
+ retryDelayMs: 5,
1656
+ });
1664
1657
  }
1665
1658
 
1666
1659
  function orderedDistinctStateFiles(stateFiles: readonly string[], platform: NodeJS.Platform): string[] {