@gajae-code/coding-agent 0.16.4 → 0.16.6

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 (198) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/dist/types/capability/types.d.ts +4 -0
  3. package/dist/types/cli/customize-doctor.d.ts +22 -0
  4. package/dist/types/config/file-lock.d.ts +31 -3
  5. package/dist/types/coordinator-mcp/question-state.d.ts +7 -0
  6. package/dist/types/coordinator-mcp/server.d.ts +7 -0
  7. package/dist/types/discovery/helpers.d.ts +33 -0
  8. package/dist/types/exec/bash-executor.d.ts +17 -1
  9. package/dist/types/exec/bash-shell-guardian.d.ts +15 -0
  10. package/dist/types/exec/bash-shell-runtime-entry.d.ts +2 -0
  11. package/dist/types/exec/bash-shell-signals.d.ts +2 -0
  12. package/dist/types/exec/bash-shell-supervisor-entry.d.ts +1 -0
  13. package/dist/types/exec/bash-shell-supervisor.d.ts +10 -0
  14. package/dist/types/exec/bash-shell-worker-entry.d.ts +2 -0
  15. package/dist/types/exec/bash-shell-worker-protocol.d.ts +63 -0
  16. package/dist/types/exec/bash-shell-worker.d.ts +1 -0
  17. package/dist/types/exec/isolated-shell.d.ts +19 -0
  18. package/dist/types/extensibility/extensions/function-hooks-internal.d.ts +9 -0
  19. package/dist/types/extensibility/extensions/function-hooks.d.ts +169 -0
  20. package/dist/types/extensibility/extensions/index.d.ts +1 -0
  21. package/dist/types/extensibility/extensions/runner.d.ts +46 -6
  22. package/dist/types/extensibility/extensions/types.d.ts +16 -0
  23. package/dist/types/extensibility/extensions/wrapper.d.ts +3 -1
  24. package/dist/types/extensibility/gjc-plugins/constrained-hooks.d.ts +35 -2
  25. package/dist/types/extensibility/gjc-plugins/types.d.ts +25 -0
  26. package/dist/types/extensibility/hooks/types.d.ts +4 -0
  27. package/dist/types/extensibility/runtime-skill-discovery.d.ts +4 -1
  28. package/dist/types/extensibility/skill-management.d.ts +9 -4
  29. package/dist/types/extensibility/skills.d.ts +2 -0
  30. package/dist/types/gjc-runtime/empty-delete-gc.d.ts +4 -1
  31. package/dist/types/gjc-runtime/linux-proc.d.ts +2 -0
  32. package/dist/types/gjc-runtime/session-state-lock.d.ts +12 -0
  33. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +41 -3
  34. package/dist/types/gjc-runtime/ultragoal-succession.d.ts +251 -0
  35. package/dist/types/modes/components/bash-execution.d.ts +1 -0
  36. package/dist/types/modes/components/execution-shared.d.ts +1 -0
  37. package/dist/types/modes/interactive-mode.d.ts +2 -0
  38. package/dist/types/modes/types.d.ts +6 -0
  39. package/dist/types/sdk/broker/ensure.d.ts +32 -0
  40. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +0 -1
  41. package/dist/types/sdk/broker/lifecycle.d.ts +6 -0
  42. package/dist/types/sdk/broker/startup-failure.d.ts +4 -1
  43. package/dist/types/sdk/bus/chat-daemon-control.d.ts +2 -0
  44. package/dist/types/sdk/bus/chat-effect-journal.d.ts +17 -2
  45. package/dist/types/sdk/bus/conversation-store.d.ts +11 -0
  46. package/dist/types/sdk/bus/lifecycle-commands.d.ts +8 -8
  47. package/dist/types/sdk/bus/slack-daemon.d.ts +1 -1
  48. package/dist/types/sdk/bus/topic-registry.d.ts +8 -0
  49. package/dist/types/sdk/client/client.d.ts +6 -0
  50. package/dist/types/sdk/host/host.d.ts +3 -1
  51. package/dist/types/sdk/host/session-runtime.d.ts +4 -2
  52. package/dist/types/sdk/lifecycle/service.d.ts +1 -0
  53. package/dist/types/sdk/router/session-router.d.ts +10 -2
  54. package/dist/types/sdk/session.d.ts +5 -1
  55. package/dist/types/session/agent-session.d.ts +2 -0
  56. package/dist/types/session/fallback-chain-controller.d.ts +15 -0
  57. package/dist/types/session/internal/managed-session-storage.d.ts +7 -0
  58. package/dist/types/session/messages.d.ts +1 -0
  59. package/dist/types/session/session-manager.d.ts +3 -1
  60. package/dist/types/setup/paseo/announce.d.ts +4 -3
  61. package/dist/types/skill-state/canonical-skills.d.ts +4 -0
  62. package/dist/types/system-prompt.d.ts +8 -0
  63. package/dist/types/task/executor.d.ts +3 -0
  64. package/dist/types/tools/bash.d.ts +1 -0
  65. package/dist/types/tools/github-cache.d.ts +2 -1
  66. package/dist/types/tools/index.d.ts +2 -0
  67. package/package.json +9 -7
  68. package/scripts/generate-sdk-operation-inventory.ts +2 -0
  69. package/src/capability/index.ts +14 -2
  70. package/src/capability/types.ts +4 -0
  71. package/src/cli/customize-doctor.ts +155 -12
  72. package/src/cli.ts +22 -0
  73. package/src/commands/sdk.ts +74 -31
  74. package/src/config/file-lock-gc.ts +40 -2
  75. package/src/config/file-lock.ts +198 -14
  76. package/src/config/model-preset-registry.ts +115 -16
  77. package/src/config/model-registry.ts +16 -12
  78. package/src/coordinator-mcp/question-state.ts +33 -2
  79. package/src/coordinator-mcp/server.ts +477 -101
  80. package/src/crash/sanitize.ts +1 -1
  81. package/src/defaults/gjc/skills/ultragoal/SKILL.md +22 -0
  82. package/src/discovery/builtin.ts +50 -62
  83. package/src/discovery/helpers.ts +297 -32
  84. package/src/exec/bash-executor.ts +330 -50
  85. package/src/exec/bash-shell-guardian.ts +388 -0
  86. package/src/exec/bash-shell-runtime-entry.ts +5 -0
  87. package/src/exec/bash-shell-signals.ts +29 -0
  88. package/src/exec/bash-shell-supervisor-entry.ts +3 -0
  89. package/src/exec/bash-shell-supervisor.ts +359 -0
  90. package/src/exec/bash-shell-worker-entry.ts +5 -0
  91. package/src/exec/bash-shell-worker-protocol.ts +36 -0
  92. package/src/exec/bash-shell-worker.ts +116 -0
  93. package/src/exec/isolated-shell.ts +393 -0
  94. package/src/extensibility/extensions/function-hooks-internal.ts +73 -0
  95. package/src/extensibility/extensions/function-hooks.ts +959 -0
  96. package/src/extensibility/extensions/index.ts +22 -0
  97. package/src/extensibility/extensions/loader.ts +53 -2
  98. package/src/extensibility/extensions/runner.ts +895 -264
  99. package/src/extensibility/extensions/types.ts +42 -0
  100. package/src/extensibility/extensions/wrapper.ts +56 -15
  101. package/src/extensibility/gjc-plugins/compiler.ts +20 -2
  102. package/src/extensibility/gjc-plugins/constrained-hooks.ts +321 -23
  103. package/src/extensibility/gjc-plugins/registry.ts +113 -1
  104. package/src/extensibility/gjc-plugins/runtime-quarantine.ts +20 -2
  105. package/src/extensibility/gjc-plugins/schema.ts +121 -4
  106. package/src/extensibility/gjc-plugins/types.ts +25 -0
  107. package/src/extensibility/hooks/loader.ts +133 -16
  108. package/src/extensibility/hooks/types.ts +28 -0
  109. package/src/extensibility/runtime-skill-discovery.ts +80 -47
  110. package/src/extensibility/skill-management.ts +200 -52
  111. package/src/extensibility/skills.ts +29 -9
  112. package/src/gjc-runtime/empty-delete-gc.ts +94 -4
  113. package/src/gjc-runtime/gc-runtime.ts +29 -0
  114. package/src/gjc-runtime/linux-proc.ts +14 -0
  115. package/src/gjc-runtime/session-state-lock.ts +51 -8
  116. package/src/gjc-runtime/session-state-sidecar.ts +27 -11
  117. package/src/gjc-runtime/state-writer.ts +30 -29
  118. package/src/gjc-runtime/ultragoal-runtime.ts +189 -47
  119. package/src/gjc-runtime/ultragoal-succession.ts +1667 -0
  120. package/src/gjc-runtime/workflow-manifest.generated.json +52 -1
  121. package/src/gjc-runtime/workflow-manifest.ts +16 -0
  122. package/src/harness-control-plane/session-lease.ts +2 -2
  123. package/src/internal-urls/docs-index.generated.ts +6 -6
  124. package/src/lsp/config.ts +10 -3
  125. package/src/lsp/index.ts +1 -1
  126. package/src/lsp/path-trust.ts +69 -14
  127. package/src/main.ts +6 -1
  128. package/src/modes/acp/acp-agent.ts +16 -3
  129. package/src/modes/components/bash-execution.ts +4 -1
  130. package/src/modes/components/execution-shared.ts +4 -1
  131. package/src/modes/components/session-observer-overlay.ts +1 -1
  132. package/src/modes/components/tool-status-header.ts +3 -1
  133. package/src/modes/controllers/command-controller.ts +1 -0
  134. package/src/modes/controllers/event-controller.ts +3 -2
  135. package/src/modes/controllers/extension-ui-controller.ts +1 -0
  136. package/src/modes/controllers/input-controller.ts +6 -3
  137. package/src/modes/interactive-mode.ts +6 -5
  138. package/src/modes/print-mode.ts +5 -1
  139. package/src/modes/prompt-action-autocomplete.ts +54 -27
  140. package/src/modes/runtime-init.ts +2 -0
  141. package/src/modes/types.ts +16 -3
  142. package/src/modes/utils/ui-helpers.ts +1 -0
  143. package/src/prompts/tools/subagent.md +1 -0
  144. package/src/runtime-mcp/manager.ts +20 -2
  145. package/src/runtime-mcp/pool.test.ts +20 -11
  146. package/src/sdk/broker/broker.ts +119 -28
  147. package/src/sdk/broker/ensure.ts +248 -41
  148. package/src/sdk/broker/lifecycle-ledger.ts +7 -17
  149. package/src/sdk/broker/lifecycle.ts +56 -6
  150. package/src/sdk/broker/startup-failure.ts +15 -6
  151. package/src/sdk/bus/chat-daemon-control.ts +3 -1
  152. package/src/sdk/bus/chat-daemon-runtime.ts +35 -5
  153. package/src/sdk/bus/chat-effect-journal.ts +132 -7
  154. package/src/sdk/bus/conversation-store.ts +29 -0
  155. package/src/sdk/bus/lifecycle-commands.ts +17 -11
  156. package/src/sdk/bus/slack-conversation.ts +1 -0
  157. package/src/sdk/bus/slack-daemon.ts +387 -48
  158. package/src/sdk/bus/telegram-daemon.ts +16 -2
  159. package/src/sdk/bus/topic-registry.ts +19 -0
  160. package/src/sdk/cli/master-cli.ts +1 -1
  161. package/src/sdk/cli/session-cli.ts +2 -2
  162. package/src/sdk/client/client.ts +66 -17
  163. package/src/sdk/host/host.ts +29 -9
  164. package/src/sdk/host/query/revision-store.ts +19 -13
  165. package/src/sdk/host/reverse-leases.ts +7 -4
  166. package/src/sdk/host/session-runtime.test.ts +50 -8
  167. package/src/sdk/host/session-runtime.ts +158 -51
  168. package/src/sdk/lifecycle/service.ts +4 -0
  169. package/src/sdk/protocol/operation-inventory.generated.json +11 -0
  170. package/src/sdk/router/session-router.ts +89 -38
  171. package/src/sdk/session.ts +155 -26
  172. package/src/sdk/transport/relay.ts +35 -13
  173. package/src/session/agent-session.ts +117 -52
  174. package/src/session/fallback-chain-controller.ts +38 -0
  175. package/src/session/internal/managed-session-storage.ts +19 -5
  176. package/src/session/messages.ts +5 -0
  177. package/src/session/session-manager.ts +29 -5
  178. package/src/session-import/redact.ts +5 -1
  179. package/src/setup/paseo/announce.ts +49 -43
  180. package/src/setup/paseo/setup-deps.ts +1 -1
  181. package/src/skill-state/canonical-skills.ts +12 -0
  182. package/src/skills/skills.test.ts +41 -21
  183. package/src/system-prompt.ts +25 -3
  184. package/src/task/executor.ts +5 -0
  185. package/src/task/index.ts +4 -0
  186. package/src/tools/bash.ts +28 -11
  187. package/src/tools/gh.ts +26 -3
  188. package/src/tools/github-cache.ts +68 -16
  189. package/src/tools/index.ts +2 -0
  190. package/src/tools/job.ts +13 -2
  191. package/src/tools/read.ts +14 -2
  192. package/src/tools/render-utils.ts +1 -3
  193. package/src/tools/resource-gc.ts +28 -8
  194. package/src/tools/skill-discovery.ts +5 -0
  195. package/src/tools/skill.ts +15 -0
  196. package/src/tools/subagent.ts +3 -2
  197. package/src/tools/tool-catalog.generated.ts +2 -2
  198. package/src/tools/ultragoal-ask-guard.ts +2 -2
@@ -1,5 +1,11 @@
1
1
  import * as fs from "node:fs/promises";
2
- import { getProjectDir, getTrustedHomeDir } from "@gajae-code/utils";
2
+ import {
3
+ getAgentDir,
4
+ getAgentProfileAuthority,
5
+ getProjectDir,
6
+ getTrustedHomeDir,
7
+ normalizePathForComparison,
8
+ } from "@gajae-code/utils";
3
9
  import { findRepoRoot } from "../capability/fs";
4
10
  import { skillCapability } from "../capability/skill";
5
11
  import type { SourceMeta } from "../capability/types";
@@ -7,9 +13,12 @@ import type { SkillsSettings } from "../config/settings";
7
13
  import { resolveSkillScopeTrust } from "../config/skill-settings-defaults";
8
14
  import { type Skill as CapabilitySkill, getCapability } from "../discovery";
9
15
  import { loadMarketplaceSkills } from "../discovery/claude-plugins";
10
- import { compareSkillOrder, scanSkillsFromDir } from "../discovery/helpers";
16
+ import { compareSkillOrder, resolveUserAgentDir, scanSkillsFromDir } from "../discovery/helpers";
11
17
  import type { SkillPromptDetails } from "../session/messages";
12
- import { CANONICAL_GJC_WORKFLOW_SKILLS } from "../skill-state/canonical-skills";
18
+ import {
19
+ getSkillFilesystemIdentity,
20
+ isCanonicalGjcWorkflowSkillFilesystemAlias,
21
+ } from "../skill-state/canonical-skills";
13
22
  import { expandTilde } from "../tools/path-utils";
14
23
  import type { LoadedSubskillActivation } from "./gjc-plugins";
15
24
  import { buildSubskillInjection } from "./gjc-plugins/injection";
@@ -114,6 +123,8 @@ export interface LoadSkillsOptions extends SkillsSettings {
114
123
  home?: string;
115
124
  /** Explicit user agent directory for native user-scope skill discovery. */
116
125
  agentDir?: string;
126
+ /** Resolver-owned classification for the selected agent directory. */
127
+ profileAuthority?: "default" | "custom";
117
128
  }
118
129
 
119
130
  /**
@@ -124,8 +135,6 @@ export interface LoadSkillsOptions extends SkillsSettings {
124
135
  */
125
136
  const LOADABLE_SKILL_PROVIDERS = new Set(["native", "claude-plugins"]);
126
137
 
127
- const BUILT_IN_SKILL_NAMES = new Set<string>(CANONICAL_GJC_WORKFLOW_SKILLS.map(name => name.toLowerCase()));
128
-
129
138
  /**
130
139
  * Load skills from all configured locations.
131
140
  * Returns skills and any validation warnings.
@@ -144,7 +153,17 @@ export async function loadSkills(options: LoadSkillsOptions = {}): Promise<LoadS
144
153
  if (!enabled) {
145
154
  return { skills: [], warnings: [] };
146
155
  }
156
+ const homeWasInjected = options.home !== undefined;
147
157
  const home = options.home ?? getTrustedHomeDir();
158
+ const agentDir = options.agentDir ?? (homeWasInjected ? resolveUserAgentDir(home) : getAgentDir());
159
+ const profileAuthority =
160
+ options.profileAuthority ??
161
+ (!homeWasInjected
162
+ ? options.agentDir !== undefined &&
163
+ normalizePathForComparison(options.agentDir) !== normalizePathForComparison(getAgentDir())
164
+ ? "custom"
165
+ : getAgentProfileAuthority()
166
+ : undefined);
148
167
 
149
168
  const projectTrusted = resolveSkillScopeTrust(options, "project");
150
169
  const userTrusted = resolveSkillScopeTrust(options, "user");
@@ -175,7 +194,8 @@ export async function loadSkills(options: LoadSkillsOptions = {}): Promise<LoadS
175
194
  const loadContext = {
176
195
  cwd,
177
196
  home,
178
- userAgentDir: options.agentDir,
197
+ userAgentDir: agentDir,
198
+ profileAuthority,
179
199
  repoRoot: await findRepoRoot(cwd),
180
200
  };
181
201
  const providerResults = await Promise.all(
@@ -258,14 +278,14 @@ export async function loadSkills(options: LoadSkillsOptions = {}): Promise<LoadS
258
278
  // workflow-routing hijack: warn, and let the session merge keep the bundled
259
279
  // definition authoritative (sdk/session.ts). User-scope copies are not
260
280
  // warned about: installed defaults legitimately mirror bundled skills.
261
- if (capSkill.level === "project" && BUILT_IN_SKILL_NAMES.has(capSkill.name.toLowerCase())) {
281
+ if (capSkill.level === "project" && isCanonicalGjcWorkflowSkillFilesystemAlias(capSkill.name)) {
262
282
  collisionWarnings.push({
263
283
  skillPath: capSkill.path,
264
284
  message: `name collision: "${capSkill.name}" is a bundled GJC workflow skill; the bundled definition takes precedence in sessions and this project copy is never used`,
265
285
  });
266
286
  }
267
287
 
268
- const skillKey = capSkill.name.toLowerCase();
288
+ const skillKey = getSkillFilesystemIdentity(capSkill.name);
269
289
  const existing = skillMap.get(skillKey);
270
290
  if (existing) {
271
291
  collisionWarnings.push({
@@ -342,7 +362,7 @@ export async function loadSkills(options: LoadSkillsOptions = {}): Promise<LoadS
342
362
  const resolvedPath = customRealPaths[i];
343
363
  if (realPathSet.has(resolvedPath)) continue;
344
364
 
345
- const skillKey = skill.name.toLowerCase();
365
+ const skillKey = getSkillFilesystemIdentity(skill.name);
346
366
  const existing = skillMap.get(skillKey);
347
367
  if (existing) {
348
368
  collisionWarnings.push({
@@ -7,11 +7,15 @@ import { randomUUID } from "node:crypto";
7
7
  import type { BigIntStats } from "node:fs";
8
8
  import * as fs from "node:fs/promises";
9
9
  import * as path from "node:path";
10
- import { exactUnlinkDirect, type NativeExactUnlinkResult } from "@gajae-code/natives";
10
+ import { exactUnlinkDirect, type NativeExactUnlinkResult, snapshotDirectoryTree } from "@gajae-code/natives";
11
11
 
12
12
  export const EMPTY_DELETE_PREFIX = ".gjc-delete-";
13
13
  const EMPTY_DELETE_RECEIPT_PATTERN =
14
14
  /^\.gjc-delete-session-state-lock-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.json$/u;
15
+ // session-storage uses a GC UUID; managed-session-scope uses stableOperationName's
16
+ // SHA-256 plus a numbered attempt. Neither arbitrary labels nor other receipts qualify.
17
+ const EMPTY_ARTIFACT_REMOVAL_ROOT_PATTERN =
18
+ /^\.gjc-delete-(?:gc-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}-artifacts|[0-9a-f]{64}-artifacts-[1-9]\d*)\.removing$/u;
15
19
  const EMPTY_FILE_SHA256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
16
20
 
17
21
  export interface EmptyDeleteIdentity {
@@ -22,7 +26,7 @@ export interface EmptyDeleteIdentity {
22
26
  mtimeNs: bigint;
23
27
  parentDev: bigint;
24
28
  parentIno: bigint;
25
- sha256: string;
29
+ sha256?: string;
26
30
  }
27
31
 
28
32
  export interface EmptyDeleteGcRecord {
@@ -51,12 +55,15 @@ export interface EmptyDeleteGcReport {
51
55
  export interface EmptyDeleteGcOptions {
52
56
  roots: string[];
53
57
  prune: boolean;
58
+ /** Disk artifact GC must not remove unrelated file receipts. */
59
+ artifactRootsOnly?: boolean;
54
60
  }
55
61
 
56
62
  export interface EmptyDeleteGcDependencies {
57
63
  /** Internal seam for deterministic collect-to-prune race tests. */
58
64
  collect?: (root: string) => Promise<EmptyDeleteGcRecord[]>;
59
65
  exactUnlinkDirect?: typeof exactUnlinkDirect;
66
+ rmdir?: (directory: string) => Promise<void>;
60
67
  }
61
68
 
62
69
  function isUnsafeName(name: string): boolean {
@@ -100,6 +107,64 @@ function isEmptyDeleteReceiptName(name: string): boolean {
100
107
  return EMPTY_DELETE_RECEIPT_PATTERN.test(name);
101
108
  }
102
109
 
110
+ function isEmptyArtifactRemovalRootName(name: string): boolean {
111
+ return EMPTY_ARTIFACT_REMOVAL_ROOT_PATTERN.test(name);
112
+ }
113
+
114
+ function artifactRootReason(file: string, stat: BigIntStats): string {
115
+ if (!stat.isDirectory()) return "not_directory";
116
+ // The native snapshot opens every component with O_NOFOLLOW and enumerates
117
+ // with fdopendir/readdir on the retained directory handle. A root-only snapshot
118
+ // proves zero entries; pathname readdir alone cannot supply that authority.
119
+ const result = snapshotDirectoryTree(file);
120
+ if (!result.ok || !result.snapshot) return `entry_unverifiable: ${result.code ?? "unknown"}`;
121
+ const snapshot = result.snapshot;
122
+ if (snapshot.rootDev !== stat.dev.toString() || snapshot.rootIno !== stat.ino.toString()) return "identity_drift";
123
+ return snapshot.entries.length === 1 && snapshot.entries[0].relativePath === ""
124
+ ? "empty_artifact_removal_root"
125
+ : "non_empty";
126
+ }
127
+
128
+ async function removeEmptyArtifactRoot(
129
+ record: EmptyDeleteGcRecord,
130
+ rmdir: (directory: string) => Promise<void>,
131
+ ): Promise<void> {
132
+ try {
133
+ const expected = record.identity;
134
+ if (!expected) {
135
+ record.action = "kept";
136
+ record.reason = "identity_missing";
137
+ return;
138
+ }
139
+ const parent = await fs.lstat(path.dirname(record.path), { bigint: true });
140
+ const stat = await fs.lstat(record.path, { bigint: true });
141
+ if (
142
+ !parent.isDirectory() ||
143
+ parent.dev !== expected.parentDev ||
144
+ parent.ino !== expected.parentIno ||
145
+ !stat.isDirectory() ||
146
+ stat.dev !== expected.dev ||
147
+ stat.ino !== expected.ino
148
+ ) {
149
+ record.action = "kept";
150
+ record.reason = "identity_drift";
151
+ return;
152
+ }
153
+ const reason = artifactRootReason(record.path, stat);
154
+ if (reason !== "empty_artifact_removal_root") {
155
+ record.action = "kept";
156
+ record.reason = reason;
157
+ return;
158
+ }
159
+ // Never recursive: a writer winning after the empty check makes this fail closed.
160
+ await rmdir(record.path);
161
+ record.action = "removed";
162
+ } catch (error) {
163
+ record.action = "kept";
164
+ record.reason = `entry_remove_failed: ${error instanceof Error ? error.message : String(error)}`;
165
+ }
166
+ }
167
+
103
168
  function sameRootIdentity(left: BigIntStats, right: BigIntStats): boolean {
104
169
  return left.isDirectory() && right.isDirectory() && left.dev === right.dev && left.ino === right.ino;
105
170
  }
@@ -150,7 +215,7 @@ export async function collectEmptyDeleteReceipts(
150
215
  throw error;
151
216
  }
152
217
  for (const name of names) {
153
- if (isUnsafeName(name) || !isEmptyDeleteReceiptName(name)) continue;
218
+ if (isUnsafeName(name) || (!isEmptyDeleteReceiptName(name) && !isEmptyArtifactRemovalRootName(name))) continue;
154
219
  const file = path.join(resolvedRoot, name);
155
220
  let stat: BigIntStats;
156
221
  try {
@@ -169,6 +234,17 @@ export async function collectEmptyDeleteReceipts(
169
234
  records.push({ root, path: file, action: "kept", reason: "symlink" });
170
235
  continue;
171
236
  }
237
+ if (isEmptyArtifactRemovalRootName(name)) {
238
+ const reason = artifactRootReason(file, stat);
239
+ records.push({
240
+ root,
241
+ path: file,
242
+ action: reason === "empty_artifact_removal_root" ? "would_remove" : "kept",
243
+ reason,
244
+ identity: { ...identityOf(stat), parentDev: rootStat.dev, parentIno: rootStat.ino },
245
+ });
246
+ continue;
247
+ }
172
248
  if (!stat.isFile()) {
173
249
  records.push({ root, path: file, action: "kept", reason: "not_regular" });
174
250
  continue;
@@ -232,6 +308,12 @@ export async function runEmptyDeleteGc(
232
308
  continue;
233
309
  }
234
310
  for (const record of records) {
311
+ if (
312
+ options.artifactRootsOnly &&
313
+ record.path !== root &&
314
+ !isEmptyArtifactRemovalRootName(path.basename(record.path))
315
+ )
316
+ continue;
235
317
  if (record.action === "would_remove" && !options.prune) record.observationOnly = true;
236
318
  if (
237
319
  record.action === "skipped" &&
@@ -244,7 +326,15 @@ export async function runEmptyDeleteGc(
244
326
  ) {
245
327
  report.errors.push(`${record.path}: ${record.reason}`);
246
328
  }
247
- if (record.action === "would_remove" && options.prune) {
329
+ if (
330
+ record.action === "would_remove" &&
331
+ options.prune &&
332
+ isEmptyArtifactRemovalRootName(path.basename(record.path))
333
+ ) {
334
+ await removeEmptyArtifactRoot(record, deps.rmdir ?? fs.rmdir);
335
+ if (record.reason.startsWith("entry_remove_failed:"))
336
+ report.errors.push(`${record.path}: ${record.reason}`);
337
+ } else if (record.action === "would_remove" && options.prune) {
248
338
  try {
249
339
  if (!record.identity) {
250
340
  record.action = "kept";
@@ -1704,6 +1704,35 @@ async function runGcDiskArtifacts(input: {
1704
1704
  const sessionIndex = new SessionIndex(agentDir);
1705
1705
  try {
1706
1706
  await sessionIndex.withLocked(async () => {
1707
+ // Quarantine roots can outlive every transcript, so enumerate project
1708
+ // directories independently of survivors, under the same index lock.
1709
+ let projects: Dirent[] = [];
1710
+ try {
1711
+ projects = await fsp.readdir(surface.root, { withFileTypes: true });
1712
+ } catch (error) {
1713
+ if (!isEnoent(error))
1714
+ errors.push({ surface: "artifacts", scope: surface.root, message: gcDiskErrorText(error) });
1715
+ }
1716
+ for (const project of projects) {
1717
+ if (!project.isDirectory() || project.isSymbolicLink()) continue;
1718
+ const directory = path.join(surface.root, project.name);
1719
+ const cleanup = await runEmptyDeleteGc({ roots: [directory], prune, artifactRootsOnly: true });
1720
+ for (const message of cleanup.errors) errors.push({ surface: "artifacts", scope: directory, message });
1721
+ for (const receipt of cleanup.records) {
1722
+ const failed = receipt.reason.startsWith("entry_remove_failed:");
1723
+ surface.records.push({
1724
+ surface: "artifacts",
1725
+ id: `${project.name}/${path.basename(receipt.path)}`,
1726
+ path: receipt.path,
1727
+ bytes: 0,
1728
+ age_days: receipt.identity ? gcDiskAgeDays(now, Number(receipt.identity.mtimeNs / 1_000_000n)) : 0,
1729
+ action: receipt.action === "removed" ? "reclaimed" : failed ? "reclaim_failed" : "keep",
1730
+ reason: receipt.reason,
1731
+ ...(receipt.action === "kept" || receipt.action === "skipped" ? { withheld: true as const } : {}),
1732
+ ...(failed ? { error: receipt.reason } : {}),
1733
+ });
1734
+ }
1735
+ }
1707
1736
  // Recomputed over survivors: whichever transcript the sessions surface left
1708
1737
  // newest in a project directory is the `--continue` target, and a resumed
1709
1738
  // session still reads its own `artifact://` handles.
@@ -66,6 +66,20 @@ export function parseLinuxProcState(stat: string | null | undefined): string | n
66
66
  return parseLinuxProcIdentity(stat)?.state ?? null;
67
67
  }
68
68
 
69
+ /** Parse field 4 (`ppid`) from a `/proc/<pid>/stat` record. */
70
+ export function parseLinuxProcParentPid(stat: string | null | undefined): number | null {
71
+ if (!stat || stat.includes("\0") || stat.includes("\r")) return null;
72
+ const record = stat.endsWith("\n") ? stat.slice(0, -1) : stat;
73
+ const close = record.lastIndexOf(")");
74
+ if (close < 0) return null;
75
+ const fields = record
76
+ .slice(close + 1)
77
+ .trim()
78
+ .split(/[ \t]+/);
79
+ const parentPid = Number.parseInt(fields[1] ?? "", 10);
80
+ return Number.isSafeInteger(parentPid) && parentPid >= 0 ? parentPid : null;
81
+ }
82
+
69
83
  export function probeLinuxProcPidSync(pid: number): LinuxProcPidProbeResult {
70
84
  if (!Number.isSafeInteger(pid) || pid <= 0) return { kind: "unverifiable", reason: "invalid_pid" };
71
85
  if (process.platform !== "linux") return { kind: "unverifiable", reason: "unsupported_platform" };
@@ -37,18 +37,20 @@ const EMPTY_FILE_SHA256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca4959
37
37
  */
38
38
  const LOCK_ACQUIRE_RETRY_MS = 5;
39
39
  const LOCK_ACQUIRE_MAX_RETRY_MS = 100;
40
- const LOCK_ACQUIRE_TIMEOUT_MS = 2_000;
40
+ const LOCK_ACQUIRE_TIMEOUT_MS = 5_000;
41
41
  const LOCK_STALE_MS = 30_000;
42
42
  const RELEASED_TRANSITION_GRACE_MS = 1_000;
43
+ const SESSION_STATE_LOCK_BUSY = Symbol("session-state-lock-busy");
43
44
 
44
45
  interface LockRetryBudget {
45
46
  startedAt: number;
46
47
  nextDelayMs: number;
47
48
  attempts: number;
49
+ nonBlocking: boolean;
48
50
  }
49
51
 
50
- function lockRetryBudget(): LockRetryBudget {
51
- return { startedAt: performance.now(), nextDelayMs: LOCK_ACQUIRE_RETRY_MS, attempts: 0 };
52
+ function lockRetryBudget(nonBlocking = false): LockRetryBudget {
53
+ return { startedAt: performance.now(), nextDelayMs: LOCK_ACQUIRE_RETRY_MS, attempts: 0, nonBlocking };
52
54
  }
53
55
 
54
56
  function lockRetryElapsedMs(budget: LockRetryBudget): number {
@@ -160,6 +162,7 @@ async function joinLocalTransitionQueue(
160
162
  ): Promise<LocalTransitionQueue | undefined> {
161
163
  const queue = localTransitionQueues.get(key);
162
164
  if (!queue) return undefined;
165
+ if (retryBudget?.nonBlocking) throw SESSION_STATE_LOCK_BUSY;
163
166
  const ready = Promise.withResolvers<void>();
164
167
  const queuedAt = performance.now();
165
168
  SessionStateLockTestHooks.afterLocalTransitionQueued?.(transitionDir);
@@ -1819,6 +1822,7 @@ async function recoverPendingTransitionRelease(
1819
1822
  transitionDir: string,
1820
1823
  recoveryKey: string,
1821
1824
  quarantineName: string,
1825
+ nonBlocking = false,
1822
1826
  ): Promise<boolean> {
1823
1827
  let pendingKey = recoveryKey;
1824
1828
  let pending = pendingTransitionReleases.get(pendingKey);
@@ -1851,7 +1855,10 @@ async function recoverPendingTransitionRelease(
1851
1855
  // for a contender while that release is still in progress. The owner that armed
1852
1856
  // it flips this bit only when a phase actually throws.
1853
1857
  if (!pending.recoverable) return false;
1854
- if (pending.recovery) return await pending.recovery;
1858
+ if (pending.recovery) {
1859
+ if (nonBlocking) throw SESSION_STATE_LOCK_BUSY;
1860
+ return await pending.recovery;
1861
+ }
1855
1862
  const recovery = (async (): Promise<boolean> => {
1856
1863
  const ownerFile = `${transitionDir}.owner`;
1857
1864
  if (!pending.generation) {
@@ -2097,7 +2104,7 @@ async function runLockPathTransition<T>(
2097
2104
  for (;;) {
2098
2105
  if (ownerAccessStrategy() === "unsupported" && fsSync.existsSync(transitionDir))
2099
2106
  throw new SessionStateLockUnavailableError(new Error("Safe transition ownership is unsupported."));
2100
- if (await recoverPendingTransitionRelease(transitionDir, recoveryKey, quarantineName)) {
2107
+ if (await recoverPendingTransitionRelease(transitionDir, recoveryKey, quarantineName, budget.nonBlocking)) {
2101
2108
  // The claim this process stranded in an earlier failed release is gone;
2102
2109
  // fall through and retry the mkdir immediately.
2103
2110
  }
@@ -2108,10 +2115,15 @@ async function runLockPathTransition<T>(
2108
2115
  await fs.chmod(transitionDir, 0o700);
2109
2116
  } catch (error) {
2110
2117
  const code = (error as NodeJS.ErrnoException).code;
2111
- if (code !== "EEXIST" && !isTransientLockError(error)) throw new SessionStateLockUnavailableError(error);
2118
+ if (code !== "EEXIST" && (budget.nonBlocking || !isTransientLockError(error)))
2119
+ throw new SessionStateLockUnavailableError(error);
2112
2120
  if (await joinLocalTurn()) continue;
2113
2121
  await SessionStateLockTestHooks.afterTransitionClaimContention?.(transitionDir);
2114
- if (await reclaimStaleTransitionClaim(transitionDir, quarantineName)) {
2122
+ const reclaimed = await reclaimStaleTransitionClaim(transitionDir, quarantineName);
2123
+ // One safe reclaim may make progress, but a try-acquire never follows a
2124
+ // succession of contenders. A later maintenance pass can take the freed claim.
2125
+ if (budget.nonBlocking) throw SESSION_STATE_LOCK_BUSY;
2126
+ if (reclaimed) {
2115
2127
  if (lockRetryExhausted(budget))
2116
2128
  throw lockUnavailable(transitionDir, "transition_claim_timeout", budget, error);
2117
2129
  continue;
@@ -2551,8 +2563,33 @@ export async function reclaimStaleSessionStateLock(lockFile: string, quarantineN
2551
2563
  * lock file itself and may run for as long as it needs.
2552
2564
  */
2553
2565
  export async function withSessionStateFileLock<T>(stateFile: string, operation: () => Promise<T>): Promise<T> {
2566
+ return await runWithSessionStateFileLock(stateFile, operation, lockRetryBudget());
2567
+ }
2568
+
2569
+ /**
2570
+ * Try one admission without joining a live owner's queue or backing off on contention.
2571
+ * A stale reclaim may return `acquired: false` after making the next attempt possible.
2572
+ * Once admitted, the operation and identity-safe release use the normal lock protocol;
2573
+ * their failures propagate rather than being reported as contention.
2574
+ */
2575
+ export async function tryWithSessionStateFileLock<T>(
2576
+ stateFile: string,
2577
+ operation: () => Promise<T>,
2578
+ ): Promise<{ acquired: true; value: T } | { acquired: false }> {
2579
+ try {
2580
+ return { acquired: true, value: await runWithSessionStateFileLock(stateFile, operation, lockRetryBudget(true)) };
2581
+ } catch (error) {
2582
+ if (error === SESSION_STATE_LOCK_BUSY) return { acquired: false };
2583
+ throw error;
2584
+ }
2585
+ }
2586
+
2587
+ async function runWithSessionStateFileLock<T>(
2588
+ stateFile: string,
2589
+ operation: () => Promise<T>,
2590
+ budget: LockRetryBudget,
2591
+ ): Promise<T> {
2554
2592
  const lockFile = `${stateFile}.lock`;
2555
- const budget = lockRetryBudget();
2556
2593
  let owner: SessionStateLockOwner;
2557
2594
  try {
2558
2595
  owner = await newLockOwner();
@@ -2608,6 +2645,11 @@ export async function withSessionStateFileLock<T>(stateFile: string, operation:
2608
2645
  if (callbackFailure && error === callbackError) throw error;
2609
2646
  // A fault after the lock was taken belongs to the operation, not to acquisition.
2610
2647
  if (held) throw lockDiagnostic(error, lockFile, "lock_release_failed");
2648
+ if (error === SESSION_STATE_LOCK_BUSY) throw error;
2649
+ // Typed protocol failures include transition setup/release faults. A
2650
+ // try-acquire must not downgrade those into a successful busy response.
2651
+ if (budget.nonBlocking && error instanceof SessionStateLockUnavailableError)
2652
+ throw lockDiagnostic(error, lockFile, "lock_inspection_failed", budget);
2611
2653
  // Without a safe owner-record access strategy, retrying cannot make the
2612
2654
  // transition claim removable. Preserve it as a fail-closed fence instead
2613
2655
  // of spinning until the acquisition budget expires.
@@ -2630,6 +2672,7 @@ export async function withSessionStateFileLock<T>(stateFile: string, operation:
2630
2672
  );
2631
2673
  if (reclaim === "owner_unprovenanced")
2632
2674
  throw lockUnavailable(lockFile, "lock_owner_record_unprovenanced", budget);
2675
+ if (budget.nonBlocking) throw SESSION_STATE_LOCK_BUSY;
2633
2676
  if (reclaim === "legacy_directory_unprovenanced") lastReason = "legacy_directory_owner_unprovenanced";
2634
2677
  else if (reclaim === "owner_live_or_unverifiable") lastReason = "lock_owner_live_or_unverifiable";
2635
2678
  else if (reclaim === "owner_record_fresh") lastReason = "lock_owner_record_fresh";
@@ -1004,9 +1004,13 @@ export function eventAffectsCoordinatorRuntimeState(event: RuntimeStateEvent): b
1004
1004
  }
1005
1005
 
1006
1006
  class PreviousRuntimeStateReadError extends Error {
1007
- constructor(cause?: unknown) {
1007
+ constructor(cause?: unknown, detail?: string) {
1008
1008
  const lockDetail = cause instanceof SessionStateLockUnavailableError ? ` ${cause.message}` : "";
1009
- super(`Existing runtime state marker is invalid or unreadable; refusing to overwrite.${lockDetail}`);
1009
+ super(
1010
+ detail
1011
+ ? `Existing runtime state marker violates the lifecycle contract: ${detail}; refusing to overwrite.`
1012
+ : `Existing runtime state marker is invalid or unreadable; refusing to overwrite.${lockDetail}`,
1013
+ );
1010
1014
  this.name = "PreviousRuntimeStateReadError";
1011
1015
  if (cause !== undefined) this.cause = cause;
1012
1016
  }
@@ -1016,7 +1020,7 @@ class PreviousRuntimeStateReadError extends Error {
1016
1020
  * A readable, well-formed marker recorded against a different workspace path.
1017
1021
  *
1018
1022
  * Separate from {@link PreviousRuntimeStateReadError} because the two demand different
1019
- * operator responses: an unreadable marker points at file damage, while a foreign marker
1023
+ * operator responses: an invalid marker points at file damage or lifecycle contradictions, while a foreign marker
1020
1024
  * points at the same session directory being reachable from two checkouts.
1021
1025
  */
1022
1026
  class ForeignRuntimeStateError extends Error {
@@ -1084,11 +1088,29 @@ function isAbsentStateFileError(error: unknown): boolean {
1084
1088
 
1085
1089
  function parsePreviousPayload(raw: string): Record<string, unknown> {
1086
1090
  const payload: unknown = JSON.parse(raw);
1087
- if (!validPreviousRuntimeStatePayload(payload)) throw new PreviousRuntimeStateReadError();
1091
+ if (!validPreviousRuntimeStateShape(payload)) throw new PreviousRuntimeStateReadError();
1092
+ // Structural validation above bounds every interpolated value to a known state
1093
+ // or boolean; never include arbitrary marker contents in a terminal diagnostic.
1094
+ if (payload.ready_for_input !== undefined) {
1095
+ const expectedReady = payload.state === "ready_for_input";
1096
+ if (payload.ready_for_input !== expectedReady)
1097
+ throw new PreviousRuntimeStateReadError(
1098
+ undefined,
1099
+ `ready_for_input must be ${expectedReady} when state is ${payload.state} (received ${payload.ready_for_input})`,
1100
+ );
1101
+ }
1102
+ if (payload.live !== undefined && payload.live !== null) {
1103
+ const expectedLive = payload.state === "running";
1104
+ if (payload.live !== expectedLive)
1105
+ throw new PreviousRuntimeStateReadError(
1106
+ undefined,
1107
+ `live must be ${expectedLive} when state is ${payload.state} (received ${payload.live})`,
1108
+ );
1109
+ }
1088
1110
  return payload;
1089
1111
  }
1090
1112
 
1091
- function validPreviousRuntimeStatePayload(value: unknown): value is Record<string, unknown> {
1113
+ function validPreviousRuntimeStateShape(value: unknown): value is Record<string, unknown> {
1092
1114
  if (!value || typeof value !== "object" || Array.isArray(value)) return false;
1093
1115
  const payload = value as Record<string, unknown>;
1094
1116
  if (
@@ -1125,12 +1147,6 @@ function validPreviousRuntimeStatePayload(value: unknown): value is Record<strin
1125
1147
  (typeof payload.updated_at !== "string" || !Number.isFinite(Date.parse(payload.updated_at)))
1126
1148
  )
1127
1149
  return false;
1128
- if (payload.ready_for_input !== undefined) {
1129
- const expectedReady = payload.state === "ready_for_input";
1130
- if (payload.ready_for_input !== expectedReady) return false;
1131
- }
1132
- if (payload.live !== undefined && payload.live !== null && payload.live !== (payload.state === "running"))
1133
- return false;
1134
1150
  return true;
1135
1151
  }
1136
1152
 
@@ -533,40 +533,41 @@ async function writeGuardedResolvedJsonAtomic(
533
533
  value: unknown,
534
534
  options: GuardedStateWriterOptions,
535
535
  ): Promise<GuardedWriteResult> {
536
- return lockResolvedWorkflowTarget(
537
- filePath,
538
- async () => {
539
- const current = await readJsonIfPresentTolerant(filePath);
540
- const currentRevision = persistedStateRevision(current);
541
-
542
- if (options.policy === "source") {
543
- if (options.expectedRevision !== undefined && options.expectedRevision !== currentRevision) {
544
- throw new StateWriteConflictError(filePath, options.expectedRevision, currentRevision);
545
- }
546
- const next = stampStateRevision(withWorkflowReceipt(value, buildReceipt(options)), currentRevision + 1);
547
- await atomicWrite(filePath, jsonText(next));
548
- await maybeAudit(filePath, options);
549
- return { path: filePath, written: true, revision: currentRevision + 1, stamped: next };
550
- }
536
+ const write = async (): Promise<GuardedWriteResult> => {
537
+ const current = await readJsonIfPresentTolerant(filePath);
538
+ const currentRevision = persistedStateRevision(current);
551
539
 
552
- const valueSourceRevision = isPlainObject(value) ? persistedSourceRevision(value) : 0;
553
- const incomingSourceRevision =
554
- options.sourceRevision ??
555
- (valueSourceRevision || (options.advanceSourceRevision ? persistedSourceRevision(current) + 1 : 0));
556
- if (current !== undefined && incomingSourceRevision <= persistedSourceRevision(current)) {
557
- return { path: filePath, written: false, reason: "stale-skip", revision: currentRevision };
540
+ if (options.policy === "source") {
541
+ if (options.expectedRevision !== undefined && options.expectedRevision !== currentRevision) {
542
+ throw new StateWriteConflictError(filePath, options.expectedRevision, currentRevision);
558
543
  }
559
- const next = stampStateRevision(
560
- withWorkflowReceipt(value, buildReceipt(options)),
561
- currentRevision + 1,
562
- incomingSourceRevision,
563
- );
544
+ const next = stampStateRevision(withWorkflowReceipt(value, buildReceipt(options)), currentRevision + 1);
564
545
  await atomicWrite(filePath, jsonText(next));
565
546
  await maybeAudit(filePath, options);
566
547
  return { path: filePath, written: true, revision: currentRevision + 1, stamped: next };
567
- },
568
- options.lock,
569
- );
548
+ }
549
+
550
+ const valueSourceRevision = isPlainObject(value) ? persistedSourceRevision(value) : 0;
551
+ const incomingSourceRevision =
552
+ options.sourceRevision ??
553
+ (valueSourceRevision || (options.advanceSourceRevision ? persistedSourceRevision(current) + 1 : 0));
554
+ if (current !== undefined && incomingSourceRevision <= persistedSourceRevision(current)) {
555
+ return { path: filePath, written: false, reason: "stale-skip", revision: currentRevision };
556
+ }
557
+ const next = stampStateRevision(
558
+ withWorkflowReceipt(value, buildReceipt(options)),
559
+ currentRevision + 1,
560
+ incomingSourceRevision,
561
+ );
562
+ await atomicWrite(filePath, jsonText(next));
563
+ await maybeAudit(filePath, options);
564
+ return { path: filePath, written: true, revision: currentRevision + 1, stamped: next };
565
+ };
566
+ // `withFileLock` deliberately serializes same-process callers too, so a caller
567
+ // that already holds this target's lock (via `withWorkflowStateLock`) must say
568
+ // so rather than deadlocking against itself — the same `lockHeld` contract the
569
+ // envelope writers already use.
570
+ return options.lockHeld ? write() : lockResolvedWorkflowTarget(filePath, write, options.lock);
570
571
  }
571
572
 
572
573
  export async function writeGuardedJsonAtomic(