@gajae-code/coding-agent 0.12.4 → 0.12.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 (139) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/README.md +4 -1
  3. package/dist/types/async/job-manager.d.ts +9 -1
  4. package/dist/types/eval/backend.d.ts +2 -2
  5. package/dist/types/exec/bash-executor.d.ts +10 -8
  6. package/dist/types/extensibility/extensions/runner.d.ts +9 -7
  7. package/dist/types/gjc-runtime/launch-tmux.d.ts +22 -3
  8. package/dist/types/gjc-runtime/psmux-detect.d.ts +3 -0
  9. package/dist/types/gjc-runtime/team-launch.d.ts +3 -1
  10. package/dist/types/gjc-runtime/team-runtime.d.ts +14 -2
  11. package/dist/types/gjc-runtime/team-store.d.ts +0 -4
  12. package/dist/types/gjc-runtime/team-workers.d.ts +5 -0
  13. package/dist/types/gjc-runtime/tmux-common.d.ts +1 -0
  14. package/dist/types/gjc-runtime/tmux-provider-context.d.ts +57 -0
  15. package/dist/types/gjc-runtime/tmux-sessions.d.ts +8 -1
  16. package/dist/types/gjc-runtime/ultragoal-change-set.d.ts +3 -0
  17. package/dist/types/gjc-runtime/ultragoal-review-source.d.ts +83 -0
  18. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +57 -4
  19. package/dist/types/internal-urls/registry-helpers.d.ts +18 -0
  20. package/dist/types/modes/components/gjc-bundle-settings.d.ts +1 -0
  21. package/dist/types/modes/components/plugin-settings.d.ts +2 -0
  22. package/dist/types/modes/components/settings-selector.d.ts +2 -0
  23. package/dist/types/sdk/broker/broker.d.ts +18 -0
  24. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +13 -0
  25. package/dist/types/sdk/broker/lifecycle.d.ts +1 -0
  26. package/dist/types/sdk/bus/chat-daemon-control.d.ts +5 -2
  27. package/dist/types/sdk/bus/index.d.ts +4 -0
  28. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +7 -3
  29. package/dist/types/sdk/bus/telegram-daemon.d.ts +25 -3
  30. package/dist/types/sdk/bus/topic-registry.d.ts +63 -3
  31. package/dist/types/sdk/host/host.d.ts +23 -2
  32. package/dist/types/sdk/host/reverse-leases.d.ts +7 -4
  33. package/dist/types/session/agent-session.d.ts +5 -4
  34. package/dist/types/session/artifacts.d.ts +8 -5
  35. package/dist/types/session/attempt-record-store.d.ts +19 -0
  36. package/dist/types/session/client-bridge.d.ts +14 -0
  37. package/dist/types/session/internal/managed-session-storage.d.ts +7 -23
  38. package/dist/types/session/messages.d.ts +4 -1
  39. package/dist/types/session/session-manager.d.ts +7 -5
  40. package/dist/types/session/session-storage.d.ts +8 -0
  41. package/dist/types/session/streaming-output.d.ts +32 -0
  42. package/dist/types/task/executor.d.ts +1 -1
  43. package/dist/types/task/index.d.ts +2 -1
  44. package/dist/types/task/receipt.d.ts +3 -0
  45. package/dist/types/task/types.d.ts +194 -0
  46. package/dist/types/task/worktree.d.ts +13 -6
  47. package/dist/types/tools/bash.d.ts +1 -1
  48. package/dist/types/tools/fetch.d.ts +3 -0
  49. package/dist/types/tools/output-meta.d.ts +37 -5
  50. package/dist/types/tools/subagent.d.ts +2 -0
  51. package/dist/types/tools/tool-result.d.ts +1 -0
  52. package/dist/types/utils/artifact-id.d.ts +2 -0
  53. package/dist/types/utils/git.d.ts +1 -0
  54. package/package.json +8 -8
  55. package/scripts/capture-sticky-viewport-showcase.ts +131 -5
  56. package/scripts/verify-gjc-sdk-canonicalization.ts +128 -183
  57. package/scripts/verify-sticky-viewport-showcase.ts +620 -93
  58. package/src/async/job-manager.ts +13 -5
  59. package/src/config/model-profiles.ts +8 -0
  60. package/src/config/settings-schema.ts +17 -3
  61. package/src/cursor.ts +20 -13
  62. package/src/defaults/gjc/skills/ultragoal/SKILL.md +5 -2
  63. package/src/eval/backend.ts +2 -2
  64. package/src/exec/bash-executor.ts +425 -151
  65. package/src/extensibility/extensions/runner.ts +80 -6
  66. package/src/extensibility/extensions/wrapper.ts +22 -15
  67. package/src/extensibility/runtime-skill-discovery.ts +4 -6
  68. package/src/gjc-runtime/launch-tmux.ts +309 -293
  69. package/src/gjc-runtime/managed-owner-supervisor.ts +6 -2
  70. package/src/gjc-runtime/psmux-detect.ts +52 -6
  71. package/src/gjc-runtime/team-launch.ts +50 -2
  72. package/src/gjc-runtime/team-runtime.ts +716 -251
  73. package/src/gjc-runtime/team-store.ts +25 -7
  74. package/src/gjc-runtime/team-workers.ts +161 -1
  75. package/src/gjc-runtime/tmux-common.ts +16 -4
  76. package/src/gjc-runtime/tmux-gc.ts +37 -5
  77. package/src/gjc-runtime/tmux-owner-isolation.ts +2 -0
  78. package/src/gjc-runtime/tmux-provider-context.ts +451 -0
  79. package/src/gjc-runtime/tmux-sessions.ts +409 -156
  80. package/src/gjc-runtime/ultragoal-change-set.ts +90 -44
  81. package/src/gjc-runtime/ultragoal-receipt-freshness.ts +7 -0
  82. package/src/gjc-runtime/ultragoal-review-source.ts +378 -0
  83. package/src/gjc-runtime/ultragoal-runtime.ts +640 -159
  84. package/src/gjc-runtime/workflow-manifest.generated.json +40 -1
  85. package/src/gjc-runtime/workflow-manifest.ts +12 -0
  86. package/src/internal-urls/agent-protocol.ts +98 -38
  87. package/src/internal-urls/artifact-protocol.ts +28 -17
  88. package/src/internal-urls/docs-index.generated.ts +6 -6
  89. package/src/internal-urls/local-protocol.ts +39 -18
  90. package/src/internal-urls/registry-helpers.ts +62 -0
  91. package/src/modes/acp/acp-agent.ts +51 -13
  92. package/src/modes/acp/acp-client-bridge.ts +154 -11
  93. package/src/modes/components/gjc-bundle-settings.ts +2 -0
  94. package/src/modes/components/plugin-settings.ts +5 -0
  95. package/src/modes/components/settings-selector.ts +4 -0
  96. package/src/modes/controllers/selector-controller.ts +1 -0
  97. package/src/prompts/tools/bash.md +2 -2
  98. package/src/prompts/tools/monitor.md +4 -4
  99. package/src/sdk/acp/adapter.ts +200 -9
  100. package/src/sdk/broker/broker.ts +93 -10
  101. package/src/sdk/broker/lifecycle-ledger.ts +227 -11
  102. package/src/sdk/broker/lifecycle.ts +692 -98
  103. package/src/sdk/bus/chat-daemon-control.ts +33 -5
  104. package/src/sdk/bus/index.ts +347 -14
  105. package/src/sdk/bus/telegram-daemon-contract.ts +7 -3
  106. package/src/sdk/bus/telegram-daemon.ts +283 -44
  107. package/src/sdk/bus/topic-registry.ts +133 -10
  108. package/src/sdk/host/host.ts +187 -9
  109. package/src/sdk/host/reverse-leases.ts +249 -42
  110. package/src/sdk/session.ts +8 -6
  111. package/src/session/agent-session.ts +930 -354
  112. package/src/session/artifacts.ts +211 -74
  113. package/src/session/attempt-record-store.ts +106 -0
  114. package/src/session/client-bridge.ts +15 -0
  115. package/src/session/internal/managed-session-scope.ts +332 -198
  116. package/src/session/internal/managed-session-storage.ts +162 -450
  117. package/src/session/messages.ts +32 -7
  118. package/src/session/session-manager.ts +31 -28
  119. package/src/session/session-storage.ts +190 -53
  120. package/src/session/streaming-output.ts +198 -40
  121. package/src/task/executor.ts +5 -2
  122. package/src/task/index.ts +277 -64
  123. package/src/task/provider-retry-status.ts +4 -1
  124. package/src/task/receipt.ts +26 -2
  125. package/src/task/render.ts +42 -1
  126. package/src/task/types.ts +52 -0
  127. package/src/task/worktree.ts +122 -43
  128. package/src/tools/bash.ts +818 -239
  129. package/src/tools/eval.ts +99 -4
  130. package/src/tools/fetch.ts +77 -15
  131. package/src/tools/find.ts +7 -3
  132. package/src/tools/monitor.ts +7 -1
  133. package/src/tools/output-meta.ts +706 -116
  134. package/src/tools/search.ts +9 -4
  135. package/src/tools/subagent-render.ts +5 -2
  136. package/src/tools/subagent.ts +8 -0
  137. package/src/tools/tool-result.ts +1 -0
  138. package/src/utils/artifact-id.ts +6 -0
  139. package/src/utils/git.ts +2 -0
@@ -23,6 +23,7 @@ function canonicalPathSync(value: string): string {
23
23
  export interface SessionStorageStat {
24
24
  dev: bigint;
25
25
  ino: bigint;
26
+ nlink?: bigint;
26
27
 
27
28
  size: number;
28
29
  mtimeMs: number;
@@ -41,6 +42,7 @@ function statFromNode(stats: fs.BigIntStats): SessionStorageStat {
41
42
  return {
42
43
  dev: stats.dev,
43
44
  ino: stats.ino,
45
+ nlink: stats.nlink,
44
46
 
45
47
  size: Number(stats.size),
46
48
  mtimeMs: Number(stats.mtimeMs),
@@ -210,6 +212,7 @@ export interface SessionStorage {
210
212
  export interface SessionStorageFileIdentity {
211
213
  dev: bigint;
212
214
  ino: bigint;
215
+ nlink?: bigint;
213
216
  size: number;
214
217
  mtimeNs: bigint;
215
218
  sha256: string;
@@ -257,12 +260,14 @@ export interface VerifiedSessionDeleteTarget {
257
260
  cwd: string;
258
261
  /** Expected transcript file `(dev, ino)` captured at authorization. */
259
262
  transcriptIdentity: SessionStorageFileIdentity;
263
+ transcriptParentIdentity?: { dev: bigint; ino: bigint };
260
264
  /**
261
265
  * For retry after an `artifacts` `cleanup_pending`: the recorded artifact
262
266
  * directory identity to re-accept. A replacement/different artifact directory
263
267
  * fails closed. Omit on first attempt or to accept recorded absence.
264
268
  */
265
269
  expectedArtifactsIdentity?: SessionStorageFileIdentity;
270
+ artifactsAbsentAtAuthorization?: true;
266
271
  /** Stable native recursive-tree evidence captured before artifact detachment. */
267
272
  expectedArtifactsTree?: NativeDirectoryTreeSnapshot;
268
273
  /** Identity-bound quarantine path retained when recursive artifact cleanup failed. */
@@ -356,8 +361,11 @@ type NativeExactUnlink = (
356
361
  identity: {
357
362
  dev: bigint;
358
363
  ino: bigint;
364
+ nlink?: bigint;
359
365
  size: bigint;
360
366
  mtimeNs: bigint;
367
+ parentDev?: bigint;
368
+ parentIno?: bigint;
361
369
  /** Required for regular-file deletion; directories are identity-bound only. */
362
370
  sha256?: string;
363
371
  directory?: boolean;
@@ -371,10 +379,13 @@ function nativeExactUnlink(
371
379
  identity: {
372
380
  dev: bigint;
373
381
  ino: bigint;
382
+ nlink?: bigint;
374
383
  size: bigint;
375
384
  mtimeNs: bigint;
376
385
  /** Required for regular-file deletion; directories are identity-bound only. */
377
386
  sha256?: string;
387
+ parentDev?: bigint;
388
+ parentIno?: bigint;
378
389
  directory?: boolean;
379
390
  quarantineName?: string;
380
391
  },
@@ -387,6 +398,7 @@ type NativeDirectoryTreeEntry = {
387
398
  kind: string;
388
399
  dev: string;
389
400
  ino: string;
401
+ nlink: string;
390
402
  size: string;
391
403
  mtimeNs: string;
392
404
  ctimeNs: string;
@@ -402,7 +414,11 @@ type NativeDirectoryTreeResult =
402
414
  | { ok: false; code: string; snapshot?: undefined };
403
415
  type NativeDirectoryTreeApi = {
404
416
  snapshotDirectoryTree(pathname: string): NativeDirectoryTreeResult;
405
- exactRemoveDirectoryTree(pathname: string, snapshot: NativeDirectoryTreeSnapshot): NativeExactUnlinkResult;
417
+ exactRemoveDirectoryTree(
418
+ pathname: string,
419
+ snapshot: NativeDirectoryTreeSnapshot,
420
+ parentIdentity: { dev: bigint; ino: bigint },
421
+ ): NativeExactUnlinkResult;
406
422
  };
407
423
  function nativeDirectoryTreeApi(): NativeDirectoryTreeApi {
408
424
  return native as unknown as NativeDirectoryTreeApi;
@@ -416,8 +432,25 @@ function snapshotDirectoryTree(pathname: string): NativeDirectoryTreeSnapshot {
416
432
  );
417
433
  return result.snapshot;
418
434
  }
419
- function removeDirectoryTreeExact(pathname: string, snapshot: NativeDirectoryTreeSnapshot): NativeExactUnlinkResult {
420
- return nativeDirectoryTreeApi().exactRemoveDirectoryTree(pathname, snapshot);
435
+ function retainedTreeDoesNotExpandAuthority(
436
+ expected: NativeDirectoryTreeSnapshot,
437
+ retained: NativeDirectoryTreeSnapshot,
438
+ ): boolean {
439
+ if (expected.rootDev !== retained.rootDev || expected.rootIno !== retained.rootIno) return false;
440
+ const expectedEntries = new Map(expected.entries.map(entry => [entry.relativePath, entry]));
441
+ return retained.entries.every(entry => {
442
+ if (entry.relativePath === "") return entry.kind === "directory";
443
+ const authorized = expectedEntries.get(entry.relativePath);
444
+ return authorized !== undefined && JSON.stringify(authorized) === JSON.stringify(entry);
445
+ });
446
+ }
447
+
448
+ function removeDirectoryTreeExact(
449
+ pathname: string,
450
+ snapshot: NativeDirectoryTreeSnapshot,
451
+ parentIdentity: { dev: bigint; ino: bigint },
452
+ ): NativeExactUnlinkResult {
453
+ return nativeDirectoryTreeApi().exactRemoveDirectoryTree(pathname, snapshot, parentIdentity);
421
454
  }
422
455
 
423
456
  function exactUnlinkFailure(result: NativeExactUnlinkResult): SessionDeleteVerificationError {
@@ -454,7 +487,7 @@ function secureOwnerOnlyFileDescriptor(
454
487
  ): void {
455
488
  if (securityContext && !isValidManagedSecurityContext(securityContext))
456
489
  throw new Error("Invalid managed session security context");
457
- if (process.platform !== "linux" && process.platform !== "win32") {
490
+ if (process.platform !== "linux" || !securityContext) {
458
491
  if (operation === "apply") {
459
492
  const applied = validateNativeSecurityResult(
460
493
  native.applyOwnerOnlyPathSecurity(pathname, "file"),
@@ -471,7 +504,7 @@ function secureOwnerOnlyFileDescriptor(
471
504
  if (!verified.ok) throw new Error(`Owner-only security rejected ${pathname}: ${verified.code}`);
472
505
  return;
473
506
  }
474
- if (securityContext && !pathIsWithin(securityContext.sessionDir, pathname))
507
+ if (!pathIsWithin(securityContext.sessionDir, pathname))
475
508
  throw new Error(`Managed writer escaped its session directory: ${pathname}`);
476
509
  const result = validateNativeSecurityResult(
477
510
  operation === "apply"
@@ -822,7 +855,9 @@ export class FileSessionStorage implements SessionStorage {
822
855
  sessionId,
823
856
  cwd,
824
857
  transcriptIdentity,
858
+ transcriptParentIdentity,
825
859
  expectedArtifactsIdentity,
860
+ artifactsAbsentAtAuthorization,
826
861
  expectedArtifactsTree,
827
862
  detachedArtifactsPath,
828
863
  retainedArtifactsSuccessorPath,
@@ -893,43 +928,16 @@ export class FileSessionStorage implements SessionStorage {
893
928
  const retainedArtifactRoot = (input: string): string =>
894
929
  input.endsWith(".removing") ? input : `${input}.removing`;
895
930
 
896
- const initial = hasDetachedTranscript ? undefined : this.#verifiedReadAndHeader(transcriptPath, sessionId, cwd);
897
- const initialStat = initial?.snapshot.stat;
898
- const initialDigest = initial ? createHash("sha256").update(initial.snapshot.bytes).digest("hex") : undefined;
899
- if (
900
- initialStat &&
901
- (initialStat.dev !== transcriptIdentity.dev ||
902
- initialStat.ino !== transcriptIdentity.ino ||
903
- initialStat.size !== transcriptIdentity.size ||
904
- initialStat.mtimeNs !== transcriptIdentity.mtimeNs ||
905
- initialDigest !== transcriptIdentity.sha256)
906
- ) {
907
- throw new SessionDeleteVerificationError("identity", "Transcript identity does not match authorization");
908
- }
909
-
910
931
  const parentIdentity = this.#directoryIdentity(path.dirname(transcriptPath));
911
- const durablyRecordArtifactPhase = (): void => {
912
- if (process.platform === "win32") return;
913
- let descriptor: number | undefined;
914
- try {
915
- descriptor = fs.openSync(
916
- path.dirname(transcriptPath),
917
- fs.constants.O_RDONLY | fs.constants.O_DIRECTORY | fs.constants.O_NOFOLLOW,
918
- );
919
- const durableParent = fs.fstatSync(descriptor, { bigint: true });
920
- if (
921
- !durableParent.isDirectory() ||
922
- durableParent.dev !== parentIdentity.dev ||
923
- durableParent.ino !== parentIdentity.ino
924
- )
925
- throw new Error("parent_changed");
926
- fs.fsyncSync(descriptor);
927
- } catch (error) {
928
- throw new SessionDeleteVerificationError("artifacts", "durability_failed", { cause: toError(error) });
929
- } finally {
930
- if (descriptor !== undefined) fs.closeSync(descriptor);
931
- }
932
- };
932
+ if (
933
+ transcriptParentIdentity &&
934
+ (parentIdentity.dev !== transcriptParentIdentity.dev || parentIdentity.ino !== transcriptParentIdentity.ino)
935
+ )
936
+ throw new SessionDeleteVerificationError(
937
+ "identity",
938
+ "Transcript parent identity does not match authorization",
939
+ );
940
+ const authorizedTranscriptParentIdentity = transcriptParentIdentity ?? parentIdentity;
933
941
  if (detachedArtifactsPath) {
934
942
  if (
935
943
  !expectedArtifactsIdentity ||
@@ -952,7 +960,11 @@ export class FileSessionStorage implements SessionStorage {
952
960
  "artifacts",
953
961
  "Detached artifact cleanup requires a persisted tree snapshot",
954
962
  );
955
- const removal = removeDirectoryTreeExact(detachedArtifactsPath, expectedArtifactsTree);
963
+ const removal = removeDirectoryTreeExact(
964
+ detachedArtifactsPath,
965
+ expectedArtifactsTree,
966
+ authorizedTranscriptParentIdentity,
967
+ );
956
968
  if (!removal.ok) {
957
969
  const retainedRoot = removal.detachedPath ?? detachedArtifactsPath;
958
970
  if (retainedRoot !== detachedArtifactsPath && retainedRoot !== retainedArtifactRoot(detachedArtifactsPath))
@@ -960,7 +972,21 @@ export class FileSessionStorage implements SessionStorage {
960
972
  "artifacts",
961
973
  "Native artifact removal returned an unauthorized root",
962
974
  );
963
- durablyRecordArtifactPhase();
975
+ const retainedTree = snapshotDirectoryTree(retainedRoot);
976
+ if (
977
+ retainedTree.rootDev !== String(expectedArtifactsIdentity.dev) ||
978
+ retainedTree.rootIno !== String(expectedArtifactsIdentity.ino)
979
+ )
980
+ throw new SessionDeleteVerificationError(
981
+ "artifacts",
982
+ "Retained artifact root identity changed during partial cleanup",
983
+ );
984
+
985
+ if (!retainedTreeDoesNotExpandAuthority(expectedArtifactsTree, retainedTree))
986
+ throw new SessionDeleteVerificationError(
987
+ "artifacts",
988
+ "Partial artifact cleanup expanded retained tree authority",
989
+ );
964
990
  return {
965
991
  kind: "cleanup_pending",
966
992
  phase: "artifacts",
@@ -970,7 +996,7 @@ export class FileSessionStorage implements SessionStorage {
970
996
  ),
971
997
  artifactsIdentity: expectedArtifactsIdentity,
972
998
  detachedArtifactsPath: retainedRoot,
973
- artifactsTree: expectedArtifactsTree,
999
+ artifactsTree: retainedTree,
974
1000
  ...((removal.retainedSuccessorPath ?? retainedArtifactsSuccessorPath)
975
1001
  ? { retainedSuccessorPath: removal.retainedSuccessorPath ?? retainedArtifactsSuccessorPath }
976
1002
  : {}),
@@ -983,10 +1009,36 @@ export class FileSessionStorage implements SessionStorage {
983
1009
  transcriptIdentity,
984
1010
  };
985
1011
  }
1012
+ return { kind: "artifacts_removed", phase: "artifacts", transcriptIdentity };
1013
+ }
1014
+ if (transcriptIdentity.nlink === undefined || transcriptIdentity.nlink !== 1n)
1015
+ throw new SessionDeleteVerificationError(
1016
+ "identity",
1017
+ "Single-link transcript authority is required for exact deletion",
1018
+ );
1019
+ const initial = hasDetachedTranscript ? undefined : this.#verifiedReadAndHeader(transcriptPath, sessionId, cwd);
1020
+ const initialStat = initial?.snapshot.stat;
1021
+ const initialDigest = initial ? createHash("sha256").update(initial.snapshot.bytes).digest("hex") : undefined;
1022
+ if (
1023
+ initialStat &&
1024
+ (initialStat.nlink === undefined ||
1025
+ initialStat.dev !== transcriptIdentity.dev ||
1026
+ initialStat.ino !== transcriptIdentity.ino ||
1027
+ initialStat.nlink !== transcriptIdentity.nlink ||
1028
+ initialStat.size !== transcriptIdentity.size ||
1029
+ initialStat.mtimeNs !== transcriptIdentity.mtimeNs ||
1030
+ initialDigest !== transcriptIdentity.sha256)
1031
+ ) {
1032
+ throw new SessionDeleteVerificationError("identity", "Transcript identity does not match authorization");
986
1033
  }
987
1034
 
988
1035
  const artifactsDir = transcriptPath.slice(0, -6);
989
1036
  const artifactsIdentity = this.#optionalDirectoryIdentity(artifactsDir);
1037
+ if (artifactsAbsentAtAuthorization && artifactsIdentity)
1038
+ throw new SessionDeleteVerificationError(
1039
+ "artifacts",
1040
+ "Artifact directory appeared after absence authorization",
1041
+ );
990
1042
  if (artifactsRemoved && artifactsIdentity) {
991
1043
  throw new SessionDeleteVerificationError(
992
1044
  "artifacts",
@@ -1001,7 +1053,6 @@ export class FileSessionStorage implements SessionStorage {
1001
1053
  "artifacts",
1002
1054
  "Authorized artifact removal root remains after restart",
1003
1055
  );
1004
- durablyRecordArtifactPhase();
1005
1056
  return { kind: "artifacts_removed", phase: "artifacts", transcriptIdentity };
1006
1057
  }
1007
1058
 
@@ -1035,8 +1086,11 @@ export class FileSessionStorage implements SessionStorage {
1035
1086
  const detach = nativeExactUnlink(artifactsDir, {
1036
1087
  dev: artifactStat.dev,
1037
1088
  ino: artifactStat.ino,
1089
+ nlink: artifactStat.nlink,
1038
1090
  size: artifactStat.size,
1039
1091
  mtimeNs: artifactStat.mtimeNs,
1092
+ parentDev: authorizedTranscriptParentIdentity.dev,
1093
+ parentIno: authorizedTranscriptParentIdentity.ino,
1040
1094
  directory: true,
1041
1095
  quarantineName: path.basename(plannedArtifactsPath),
1042
1096
  });
@@ -1046,7 +1100,27 @@ export class FileSessionStorage implements SessionStorage {
1046
1100
  `Exact artifact detach rejected: ${detach.ok ? "missing_path" : detach.code}`,
1047
1101
  );
1048
1102
  }
1049
- if (!detach.ok) durablyRecordArtifactPhase();
1103
+ if (!detach.ok && process.platform !== "win32") {
1104
+ let descriptor: number | undefined;
1105
+ try {
1106
+ descriptor = fs.openSync(
1107
+ path.dirname(transcriptPath),
1108
+ fs.constants.O_RDONLY | fs.constants.O_DIRECTORY | fs.constants.O_NOFOLLOW,
1109
+ );
1110
+ const durableParent = fs.fstatSync(descriptor, { bigint: true });
1111
+ if (
1112
+ !durableParent.isDirectory() ||
1113
+ durableParent.dev !== parentIdentity.dev ||
1114
+ durableParent.ino !== parentIdentity.ino
1115
+ )
1116
+ throw new Error("parent_changed");
1117
+ fs.fsyncSync(descriptor);
1118
+ } catch (error) {
1119
+ throw new SessionDeleteVerificationError("artifacts", "durability_failed", { cause: toError(error) });
1120
+ } finally {
1121
+ if (descriptor !== undefined) fs.closeSync(descriptor);
1122
+ }
1123
+ }
1050
1124
  if (!detach.ok) {
1051
1125
  return {
1052
1126
  kind: "cleanup_pending",
@@ -1062,7 +1136,11 @@ export class FileSessionStorage implements SessionStorage {
1062
1136
  transcriptIdentity,
1063
1137
  };
1064
1138
  }
1065
- const removal = removeDirectoryTreeExact(detach.detachedPath, artifactsTree);
1139
+ const removal = removeDirectoryTreeExact(
1140
+ detach.detachedPath,
1141
+ artifactsTree,
1142
+ authorizedTranscriptParentIdentity,
1143
+ );
1066
1144
  if (!removal.ok) {
1067
1145
  const retainedRoot = removal.detachedPath ?? detach.detachedPath;
1068
1146
  if (retainedRoot !== detach.detachedPath && retainedRoot !== retainedArtifactRoot(detach.detachedPath))
@@ -1070,7 +1148,21 @@ export class FileSessionStorage implements SessionStorage {
1070
1148
  "artifacts",
1071
1149
  "Native artifact removal returned an unauthorized root",
1072
1150
  );
1073
- durablyRecordArtifactPhase();
1151
+ const retainedTree = snapshotDirectoryTree(retainedRoot);
1152
+ if (
1153
+ retainedTree.rootDev !== String(artifactsIdentity.dev) ||
1154
+ retainedTree.rootIno !== String(artifactsIdentity.ino)
1155
+ )
1156
+ throw new SessionDeleteVerificationError(
1157
+ "artifacts",
1158
+ "Retained artifact root identity changed during partial cleanup",
1159
+ );
1160
+
1161
+ if (!retainedTreeDoesNotExpandAuthority(artifactsTree, retainedTree))
1162
+ throw new SessionDeleteVerificationError(
1163
+ "artifacts",
1164
+ "Partial artifact cleanup expanded retained tree authority",
1165
+ );
1074
1166
  return {
1075
1167
  kind: "cleanup_pending",
1076
1168
  phase: "artifacts",
@@ -1080,7 +1172,7 @@ export class FileSessionStorage implements SessionStorage {
1080
1172
  ),
1081
1173
  artifactsIdentity,
1082
1174
  detachedArtifactsPath: retainedRoot,
1083
- artifactsTree,
1175
+ artifactsTree: retainedTree,
1084
1176
  ...((removal.retainedSuccessorPath ?? retainedArtifactsSuccessorPath)
1085
1177
  ? { retainedSuccessorPath: removal.retainedSuccessorPath ?? retainedArtifactsSuccessorPath }
1086
1178
  : {}),
@@ -1095,15 +1187,38 @@ export class FileSessionStorage implements SessionStorage {
1095
1187
  }
1096
1188
  }
1097
1189
  if (!artifactsRemoved) {
1098
- durablyRecordArtifactPhase();
1190
+ if (process.platform !== "win32") {
1191
+ let descriptor: number | undefined;
1192
+ try {
1193
+ descriptor = fs.openSync(
1194
+ path.dirname(transcriptPath),
1195
+ fs.constants.O_RDONLY | fs.constants.O_DIRECTORY | fs.constants.O_NOFOLLOW,
1196
+ );
1197
+ const durableParent = fs.fstatSync(descriptor, { bigint: true });
1198
+ if (
1199
+ !durableParent.isDirectory() ||
1200
+ durableParent.dev !== parentIdentity.dev ||
1201
+ durableParent.ino !== parentIdentity.ino
1202
+ )
1203
+ throw new Error("parent_changed");
1204
+ fs.fsyncSync(descriptor);
1205
+ } catch (error) {
1206
+ throw new SessionDeleteVerificationError("artifacts", "durability_failed", { cause: toError(error) });
1207
+ } finally {
1208
+ if (descriptor !== undefined) fs.closeSync(descriptor);
1209
+ }
1210
+ }
1099
1211
  return { kind: "artifacts_removed", phase: "artifacts", transcriptIdentity };
1100
1212
  }
1101
1213
  if (hasDetachedTranscript) {
1102
1214
  const deletion = nativeExactUnlink(cleanupTranscriptPath, {
1103
1215
  dev: transcriptIdentity.dev,
1104
1216
  ino: transcriptIdentity.ino,
1217
+ nlink: transcriptIdentity.nlink,
1105
1218
  size: BigInt(transcriptIdentity.size),
1106
1219
  mtimeNs: transcriptIdentity.mtimeNs,
1220
+ parentDev: authorizedTranscriptParentIdentity.dev,
1221
+ parentIno: authorizedTranscriptParentIdentity.ino,
1107
1222
  sha256: transcriptIdentity.sha256,
1108
1223
  quarantineName: path.basename(plannedTranscriptPath),
1109
1224
  });
@@ -1164,8 +1279,11 @@ export class FileSessionStorage implements SessionStorage {
1164
1279
  const deletion = nativeExactUnlink(transcriptPath, {
1165
1280
  dev: initialStat.dev,
1166
1281
  ino: initialStat.ino,
1282
+ nlink: initialStat.nlink,
1167
1283
  size: BigInt(initialStat.size),
1168
1284
  mtimeNs: initialStat.mtimeNs,
1285
+ parentDev: authorizedTranscriptParentIdentity.dev,
1286
+ parentIno: authorizedTranscriptParentIdentity.ino,
1169
1287
  sha256: initialDigest,
1170
1288
  quarantineName: path.basename(plannedTranscriptPath),
1171
1289
  });
@@ -1267,7 +1385,14 @@ export class FileSessionStorage implements SessionStorage {
1267
1385
  if (stat.isSymbolicLink() || !stat.isDirectory()) {
1268
1386
  throw new SessionDeleteVerificationError("symlink", "Directory is a symlink or not a directory");
1269
1387
  }
1270
- return { dev: stat.dev, ino: stat.ino, size: Number(stat.size), mtimeNs: stat.mtimeNs, sha256: "" };
1388
+ return {
1389
+ dev: stat.dev,
1390
+ ino: stat.ino,
1391
+ nlink: stat.nlink,
1392
+ size: Number(stat.size),
1393
+ mtimeNs: stat.mtimeNs,
1394
+ sha256: "",
1395
+ };
1271
1396
  }
1272
1397
 
1273
1398
  #optionalDirectoryIdentity(dirPath: string): SessionStorageFileIdentity | undefined {
@@ -1290,7 +1415,14 @@ export class FileSessionStorage implements SessionStorage {
1290
1415
  // Fail closed before any mutation.
1291
1416
  throw new SessionDeleteVerificationError("artifacts", "Artifact path exists but is not a directory");
1292
1417
  }
1293
- return { dev: stat.dev, ino: stat.ino, size: Number(stat.size), mtimeNs: stat.mtimeNs, sha256: "" };
1418
+ return {
1419
+ dev: stat.dev,
1420
+ ino: stat.ino,
1421
+ nlink: stat.nlink,
1422
+ size: Number(stat.size),
1423
+ mtimeNs: stat.mtimeNs,
1424
+ sha256: "",
1425
+ };
1294
1426
  }
1295
1427
  }
1296
1428
 
@@ -1421,6 +1553,7 @@ export class MemorySessionStorage implements SessionStorage {
1421
1553
  return {
1422
1554
  dev: 0n,
1423
1555
  ino: entry.ino,
1556
+ nlink: 1n,
1424
1557
  size: entry.content.byteLength,
1425
1558
  mtimeMs: entry.mtimeMs,
1426
1559
  mtimeNs: BigInt(entry.mtimeMs) * 1_000_000n,
@@ -1558,7 +1691,11 @@ export class MemorySessionStorage implements SessionStorage {
1558
1691
  const entry = this.#files.get(transcriptPath);
1559
1692
  if (!entry) return Promise.resolve({ kind: "deleted" });
1560
1693
  const snapshot = this.readSnapshotSync(transcriptPath);
1561
- if (snapshot.stat.dev !== transcriptIdentity.dev || snapshot.stat.ino !== transcriptIdentity.ino) {
1694
+ if (
1695
+ snapshot.stat.dev !== transcriptIdentity.dev ||
1696
+ snapshot.stat.ino !== transcriptIdentity.ino ||
1697
+ snapshot.stat.nlink !== transcriptIdentity.nlink
1698
+ ) {
1562
1699
  return Promise.reject(new SessionDeleteVerificationError("identity", "Transcript identity mismatch"));
1563
1700
  }
1564
1701
  const header = parseFirstJsonlLine(snapshot.bytes);