@gajae-code/coding-agent 0.12.11 → 0.12.12

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 (112) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/dist/types/cli/notify-cli.d.ts +42 -1
  3. package/dist/types/cli/update-cli.d.ts +9 -3
  4. package/dist/types/commands/notify.d.ts +6 -0
  5. package/dist/types/config/file-lock.d.ts +3 -0
  6. package/dist/types/coordinator/contract.d.ts +1 -1
  7. package/dist/types/coordinator-mcp/question-state.d.ts +3 -1
  8. package/dist/types/extensibility/extensions/index.d.ts +1 -0
  9. package/dist/types/extensibility/extensions/ouroboros-ooo-bridge.d.ts +13 -0
  10. package/dist/types/extensibility/extensions/prefix-command-bridge.d.ts +0 -1
  11. package/dist/types/extensibility/extensions/types.d.ts +2 -0
  12. package/dist/types/main.d.ts +6 -2
  13. package/dist/types/modes/components/session-selector.d.ts +1 -1
  14. package/dist/types/modes/utils/host-status.d.ts +23 -0
  15. package/dist/types/runtime-mcp/types.d.ts +2 -0
  16. package/dist/types/sdk/broker/lifecycle.d.ts +12 -0
  17. package/dist/types/sdk/bus/chat-daemon-cli.d.ts +3 -0
  18. package/dist/types/sdk/bus/chat-daemon-command-channel.d.ts +244 -0
  19. package/dist/types/sdk/bus/chat-daemon-control.d.ts +11 -1
  20. package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +7 -0
  21. package/dist/types/sdk/bus/control-plane-frames.d.ts +43 -0
  22. package/dist/types/sdk/bus/conversation-store.d.ts +18 -0
  23. package/dist/types/sdk/bus/existing-thread-readiness.d.ts +71 -0
  24. package/dist/types/sdk/bus/notification-service.d.ts +10 -0
  25. package/dist/types/sdk/bus/slack-daemon.d.ts +27 -0
  26. package/dist/types/sdk/bus/slack-live-provider.d.ts +11 -0
  27. package/dist/types/sdk/bus/slack-provider.d.ts +14 -0
  28. package/dist/types/sdk/bus/slack-thread-binding.d.ts +140 -0
  29. package/dist/types/sdk/bus/telegram-daemon-cli.d.ts +2 -0
  30. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +24 -7
  31. package/dist/types/sdk/bus/telegram-daemon.d.ts +104 -16
  32. package/dist/types/sdk/bus/telegram-reference.d.ts +10 -1
  33. package/dist/types/sdk/bus/topic-registry.d.ts +130 -85
  34. package/dist/types/sdk/host/host.d.ts +52 -0
  35. package/dist/types/sdk/lifecycle-session.d.ts +6 -0
  36. package/dist/types/sdk/session-activation.d.ts +41 -0
  37. package/dist/types/sdk/session.d.ts +18 -0
  38. package/dist/types/sdk/startup-capability.d.ts +8 -1
  39. package/dist/types/session/agent-session.d.ts +7 -0
  40. package/dist/types/session/artifacts.d.ts +2 -2
  41. package/dist/types/session/session-manager.d.ts +23 -5
  42. package/dist/types/tools/index.d.ts +8 -0
  43. package/dist/types/utils/npm-registry.d.ts +71 -0
  44. package/examples/extensions/README.md +38 -4
  45. package/examples/extensions/ooo-bridge.ts +15 -0
  46. package/package.json +8 -8
  47. package/scripts/generate-sdk-operation-inventory.ts +4 -0
  48. package/src/async/job-manager.ts +9 -2
  49. package/src/cli/notify-cli.ts +226 -2
  50. package/src/cli/update-cli.ts +90 -23
  51. package/src/commands/notify.ts +27 -3
  52. package/src/commands/sdk.ts +1 -0
  53. package/src/config/file-lock-gc.ts +18 -0
  54. package/src/config/file-lock.ts +61 -16
  55. package/src/config/model-profiles.ts +31 -15
  56. package/src/coordinator/contract.ts +1 -0
  57. package/src/coordinator-mcp/question-state.ts +2 -0
  58. package/src/coordinator-mcp/server.ts +358 -7
  59. package/src/defaults/gjc/skills/ultragoal/SKILL.md +2 -0
  60. package/src/extensibility/extensions/index.ts +1 -0
  61. package/src/extensibility/extensions/ouroboros-ooo-bridge.ts +235 -0
  62. package/src/extensibility/extensions/prefix-command-bridge.ts +4 -9
  63. package/src/extensibility/extensions/runner.ts +15 -2
  64. package/src/extensibility/extensions/types.ts +2 -0
  65. package/src/gjc-runtime/launch-tmux.ts +53 -1
  66. package/src/internal-urls/docs-index.generated.ts +11 -10
  67. package/src/main.ts +203 -67
  68. package/src/modes/DESIGN.md +8 -5
  69. package/src/modes/components/footer.ts +10 -18
  70. package/src/modes/components/session-selector.ts +26 -3
  71. package/src/modes/components/status-line/gh.ts +23 -2
  72. package/src/modes/controllers/event-controller.ts +3 -0
  73. package/src/modes/controllers/extension-ui-controller.ts +2 -0
  74. package/src/modes/controllers/input-controller.ts +11 -0
  75. package/src/modes/controllers/selector-controller.ts +8 -2
  76. package/src/modes/interactive-mode.ts +7 -1
  77. package/src/modes/utils/host-status.ts +38 -0
  78. package/src/runtime-mcp/types.ts +2 -0
  79. package/src/sdk/broker/lifecycle.ts +60 -3
  80. package/src/sdk/bus/chat-daemon-cli.ts +42 -0
  81. package/src/sdk/bus/chat-daemon-command-channel.ts +921 -0
  82. package/src/sdk/bus/chat-daemon-control.ts +16 -3
  83. package/src/sdk/bus/chat-daemon-runtime.ts +257 -21
  84. package/src/sdk/bus/control-plane-frames.ts +51 -0
  85. package/src/sdk/bus/conversation-store.ts +29 -1
  86. package/src/sdk/bus/existing-thread-readiness.ts +155 -0
  87. package/src/sdk/bus/index.ts +70 -0
  88. package/src/sdk/bus/notification-service.ts +28 -1
  89. package/src/sdk/bus/rich-render.ts +3 -2
  90. package/src/sdk/bus/slack-daemon.ts +99 -4
  91. package/src/sdk/bus/slack-live-provider.ts +32 -2
  92. package/src/sdk/bus/slack-provider.ts +11 -0
  93. package/src/sdk/bus/slack-thread-binding.ts +641 -0
  94. package/src/sdk/bus/telegram-daemon-cli.ts +11 -0
  95. package/src/sdk/bus/telegram-daemon-contract.ts +24 -7
  96. package/src/sdk/bus/telegram-daemon.ts +1961 -497
  97. package/src/sdk/bus/telegram-reference.ts +42 -6
  98. package/src/sdk/bus/topic-registry.ts +674 -181
  99. package/src/sdk/host/host.ts +180 -1
  100. package/src/sdk/lifecycle-session.ts +8 -2
  101. package/src/sdk/protocol/operation-inventory.generated.json +22 -0
  102. package/src/sdk/session-activation.ts +127 -0
  103. package/src/sdk/session.ts +139 -28
  104. package/src/sdk/startup-capability.ts +10 -1
  105. package/src/session/agent-session.ts +220 -9
  106. package/src/session/artifacts.ts +56 -13
  107. package/src/session/internal/managed-session-storage.ts +206 -7
  108. package/src/session/session-manager.ts +148 -38
  109. package/src/setup/provider-presets.json +1 -1
  110. package/src/task/index.ts +92 -44
  111. package/src/tools/index.ts +8 -0
  112. package/src/utils/npm-registry.ts +660 -0
@@ -217,8 +217,84 @@ type ReplacementCleanupReceipt = {
217
217
  predecessor: SerializedReplacementIdentity;
218
218
  };
219
219
 
220
+ type LegacyReplacementCleanupIdentity = {
221
+ dev: bigint;
222
+ ino: bigint;
223
+ nlink: bigint;
224
+ size: bigint;
225
+ mtimeNs: bigint;
226
+ ctimeNs: bigint;
227
+ sha256: string;
228
+ };
229
+
220
230
  const U64_MAX = 18_446_744_073_709_551_615n;
221
231
 
232
+ function parseCanonicalU64(value: unknown): bigint | undefined {
233
+ if (typeof value !== "string" || !/^(0|[1-9][0-9]*)$/.test(value) || value.length > 20) return undefined;
234
+ const parsed = BigInt(value);
235
+ return parsed <= U64_MAX ? parsed : undefined;
236
+ }
237
+
238
+ function parseLegacyHexU64(value: string): bigint | undefined {
239
+ if (!/^[0-9a-f]+$/.test(value) || value.length > 16) return undefined;
240
+ const parsed = BigInt(`0x${value}`);
241
+ return parsed <= U64_MAX ? parsed : undefined;
242
+ }
243
+
244
+ function asRecord(value: unknown): Record<string, unknown> | undefined {
245
+ return value !== null && typeof value === "object" && !Array.isArray(value)
246
+ ? (value as Record<string, unknown>)
247
+ : undefined;
248
+ }
249
+
250
+ function legacyReplacementCleanupReceiptBinding(name: string): { dev: bigint; ino: bigint } | undefined {
251
+ const match = /^\.gjc-replace-cleanup-([0-9a-f]+)-([0-9a-f]+)\.json$/.exec(name);
252
+ if (!match?.[1] || !match[2]) return undefined;
253
+ const dev = parseLegacyHexU64(match[1]);
254
+ const ino = parseLegacyHexU64(match[2]);
255
+ return dev !== undefined && ino !== undefined ? { dev, ino } : undefined;
256
+ }
257
+
258
+ function parseLegacyReplacementCleanupIdentity(value: unknown): LegacyReplacementCleanupIdentity | undefined {
259
+ const identity = asRecord(value);
260
+ if (!identity) return undefined;
261
+ const dev = parseCanonicalU64(identity.dev);
262
+ const ino = parseCanonicalU64(identity.ino);
263
+ const nlink = parseCanonicalU64(identity.nlink);
264
+ const size = parseCanonicalU64(identity.size);
265
+ const mtimeNs = parseCanonicalU64(identity.mtimeNs);
266
+ const ctimeNs = parseCanonicalU64(identity.ctimeNs);
267
+ const sha256 = identity.sha256;
268
+ if (
269
+ dev === undefined ||
270
+ ino === undefined ||
271
+ nlink === undefined ||
272
+ size === undefined ||
273
+ mtimeNs === undefined ||
274
+ ctimeNs === undefined ||
275
+ typeof sha256 !== "string" ||
276
+ !/^[0-9a-f]{64}$/.test(sha256)
277
+ )
278
+ return undefined;
279
+ return { dev, ino, nlink, size, mtimeNs, ctimeNs, sha256 };
280
+ }
281
+
282
+ function parseLegacyReplacementCleanupReceipt(
283
+ bytes: Uint8Array,
284
+ ): { predecessor: string; successor: string; identity: LegacyReplacementCleanupIdentity } | undefined {
285
+ let value: unknown;
286
+ try {
287
+ value = JSON.parse(Buffer.from(bytes).toString("utf8")) as unknown;
288
+ } catch {
289
+ return undefined;
290
+ }
291
+ const receipt = asRecord(value);
292
+ if (receipt?.version !== 1 || typeof receipt.predecessor !== "string" || typeof receipt.successor !== "string")
293
+ return undefined;
294
+ const identity = parseLegacyReplacementCleanupIdentity(receipt.identity);
295
+ return identity ? { predecessor: receipt.predecessor, successor: receipt.successor, identity } : undefined;
296
+ }
297
+
222
298
  function replacementCleanupReceiptBinding(
223
299
  name: string,
224
300
  ): { predecessor: { dev: bigint; ino: bigint }; receipt: { dev: bigint; ino: bigint } } | undefined {
@@ -258,12 +334,20 @@ function replacementReceiptPath(
258
334
  }
259
335
 
260
336
  function replacementReceiptRetirementName(
261
- receipt: ManagedFileSnapshot["identity"],
262
- predecessor: ManagedFileSnapshot["identity"],
337
+ receipt: { dev: bigint; ino: bigint },
338
+ predecessor: { dev: bigint; ino: bigint },
263
339
  ): string {
264
340
  return `.gjc-receipt-remove-${receipt.dev.toString(16)}-${receipt.ino.toString(16)}-${predecessor.dev.toString(16)}-${predecessor.ino.toString(16)}`;
265
341
  }
266
342
 
343
+ function replacementReceiptPlaceholderRetirementName(
344
+ placeholder: { dev: bigint; ino: bigint },
345
+ predecessor: { dev: bigint; ino: bigint },
346
+ receipt: { dev: bigint; ino: bigint },
347
+ ): string {
348
+ return `.gjc-receipt-placeholder-remove-${placeholder.dev.toString(16)}-${placeholder.ino.toString(16)}-${predecessor.dev.toString(16)}-${predecessor.ino.toString(16)}-${receipt.dev.toString(16)}-${receipt.ino.toString(16)}`;
349
+ }
350
+
267
351
  const ACL_FAILURE_CODES = new Set(["acl_denied", "acl_io_error", "acl_present", "acl_malformed", "acl_unknown"]);
268
352
  const ACL_CLEAR_EVIDENCE = new Set(["cleared", "already_absent", "unsupported", "not_run"]);
269
353
  const GENERAL_FAILURE_CODES = new Set([
@@ -785,14 +869,68 @@ export class ManagedSessionDescendantStore {
785
869
  }
786
870
  }
787
871
 
872
+ #recoverReplacementCleanupPlaceholder(
873
+ receiptPath: string,
874
+ binding: { predecessor: { dev: bigint; ino: bigint }; receipt: { dev: bigint; ino: bigint } },
875
+ placeholder: ManagedFileSnapshot,
876
+ ): boolean {
877
+ if (placeholder.bytes.byteLength !== 0) return false;
878
+ const detachedReceiptPath = path.join(
879
+ this.#baseDir,
880
+ replacementReceiptRetirementName(binding.receipt, binding.predecessor),
881
+ );
882
+ let detachedReceipt: ManagedFileSnapshot;
883
+ try {
884
+ detachedReceipt = captureManagedFileNoFollow(detachedReceiptPath);
885
+ } catch (error) {
886
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
887
+ throw error;
888
+ }
889
+ if (detachedReceipt.identity.dev !== binding.receipt.dev || detachedReceipt.identity.ino !== binding.receipt.ino)
890
+ return false;
891
+
892
+ const quarantineName = replacementReceiptPlaceholderRetirementName(
893
+ placeholder.identity,
894
+ binding.predecessor,
895
+ binding.receipt,
896
+ );
897
+ const removed = exactUnlink(receiptPath, {
898
+ dev: placeholder.identity.dev,
899
+ ino: placeholder.identity.ino,
900
+ nlink: placeholder.identity.nlink,
901
+ parentDev: this.#subtreeRoot.dev,
902
+ parentIno: this.#subtreeRoot.ino,
903
+ size: BigInt(placeholder.identity.size),
904
+ mtimeNs: placeholder.identity.mtimeNs,
905
+ sha256: placeholder.identity.sha256,
906
+ quarantineName,
907
+ });
908
+ if (
909
+ (!exactUnlinkCompleted(removed) && removed.code !== "not_found") ||
910
+ removed.retainedSuccessorPath !== undefined ||
911
+ removed.retainedUnknownPath !== undefined
912
+ )
913
+ throw new Error(`managed_replace_receipt_cleanup_pending:${removed.code ?? "unknown"}`);
914
+ fsyncDirectory(this.#baseDir);
915
+ return true;
916
+ }
917
+
788
918
  #detachReplacementCleanupReceipt(receiptPath: string): void {
789
919
  const binding = replacementCleanupReceiptBinding(path.basename(receiptPath));
790
920
  if (!binding) throw new Error("managed_replace_cleanup_receipt_invalid");
791
- const receipt = captureManagedFileNoFollow(receiptPath);
792
- if (receipt.identity.dev !== binding.receipt.dev || receipt.identity.ino !== binding.receipt.ino)
921
+ let receipt: ManagedFileSnapshot;
922
+ try {
923
+ receipt = captureManagedFileNoFollow(receiptPath);
924
+ } catch (error) {
925
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
926
+ throw error;
927
+ }
928
+ if (receipt.identity.dev !== binding.receipt.dev || receipt.identity.ino !== binding.receipt.ino) {
929
+ if (this.#recoverReplacementCleanupPlaceholder(receiptPath, binding, receipt)) return;
793
930
  throw new Error("managed_replace_cleanup_receipt_invalid");
931
+ }
794
932
  const predecessor = binding.predecessor;
795
- const quarantineName = `.gjc-receipt-remove-${receipt.identity.dev.toString(16)}-${receipt.identity.ino.toString(16)}-${predecessor.dev.toString(16)}-${predecessor.ino.toString(16)}`;
933
+ const quarantineName = replacementReceiptRetirementName(receipt.identity, predecessor);
796
934
  const detached = exactUnlink(receiptPath, {
797
935
  dev: receipt.identity.dev,
798
936
  ino: receipt.identity.ino,
@@ -815,14 +953,75 @@ export class ManagedSessionDescendantStore {
815
953
  fsyncDirectory(this.#baseDir);
816
954
  }
817
955
 
956
+ #reconcileLegacyReplacementCleanupReceipt(receiptPath: string, name: string): void {
957
+ const binding = legacyReplacementCleanupReceiptBinding(name);
958
+ if (!binding) throw new Error("managed_replace_cleanup_receipt_invalid");
959
+ const receipt = captureManagedFileNoFollow(receiptPath);
960
+ const parsed = parseLegacyReplacementCleanupReceipt(receipt.bytes);
961
+ const expectedPredecessor = path.join(
962
+ this.#baseDir,
963
+ `.gjc-exact-replace-destination-${binding.dev.toString(16)}-${binding.ino.toString(16)}`,
964
+ );
965
+ if (
966
+ !parsed ||
967
+ parsed.identity.dev !== binding.dev ||
968
+ parsed.identity.ino !== binding.ino ||
969
+ path.resolve(parsed.predecessor) !== expectedPredecessor ||
970
+ path.dirname(path.resolve(parsed.successor)) !== this.#baseDir
971
+ )
972
+ throw new Error("managed_replace_cleanup_receipt_invalid");
973
+
974
+ const retired = exactUnlink(expectedPredecessor, {
975
+ dev: parsed.identity.dev,
976
+ ino: parsed.identity.ino,
977
+ nlink: parsed.identity.nlink,
978
+ parentDev: this.#subtreeRoot.dev,
979
+ parentIno: this.#subtreeRoot.ino,
980
+ size: parsed.identity.size,
981
+ mtimeNs: parsed.identity.mtimeNs,
982
+ sha256: parsed.identity.sha256,
983
+ quarantineName: `.gjc-replace-retry-${parsed.identity.dev.toString(16)}-${parsed.identity.ino.toString(16)}`,
984
+ });
985
+ if (!exactUnlinkCompleted(retired) && retired.code !== "not_found")
986
+ throw new Error(`managed_replace_cleanup_pending:${retired.code ?? "unknown"}`);
987
+
988
+ const currentReceipt = captureManagedFileNoFollow(receiptPath);
989
+ if (
990
+ !sameIdentity(currentReceipt.identity, receipt.identity) ||
991
+ currentReceipt.identity.sha256 !== receipt.identity.sha256
992
+ )
993
+ throw new Error("managed_replace_cleanup_receipt_invalid");
994
+ const removed = exactUnlink(receiptPath, {
995
+ dev: currentReceipt.identity.dev,
996
+ ino: currentReceipt.identity.ino,
997
+ nlink: currentReceipt.identity.nlink,
998
+ parentDev: this.#subtreeRoot.dev,
999
+ parentIno: this.#subtreeRoot.ino,
1000
+ size: BigInt(currentReceipt.identity.size),
1001
+ mtimeNs: currentReceipt.identity.mtimeNs,
1002
+ sha256: currentReceipt.identity.sha256,
1003
+ quarantineName: `.gjc-receipt-remove-${currentReceipt.identity.dev.toString(16)}-${currentReceipt.identity.ino.toString(16)}`,
1004
+ });
1005
+ if (!exactUnlinkCompleted(removed) && removed.code !== "not_found")
1006
+ throw new Error(`managed_replace_receipt_cleanup_pending:${removed.code ?? "unknown"}`);
1007
+ fsyncDirectory(this.#baseDir);
1008
+ }
1009
+
818
1010
  #reconcileReplacementCleanupReceipts(): void {
819
1011
  if (this.#reconcilingReplacementCleanup) return;
820
1012
  this.#reconcilingReplacementCleanup = true;
821
1013
  try {
822
1014
  for (const name of fs.readdirSync(this.#baseDir)) {
823
1015
  if (!name.startsWith(".gjc-replace-cleanup-")) continue;
824
- if (!replacementCleanupReceiptBinding(name)) throw new Error("managed_replace_cleanup_receipt_invalid");
825
- this.#detachReplacementCleanupReceipt(path.join(this.#baseDir, name));
1016
+ if (replacementCleanupReceiptBinding(name)) {
1017
+ this.#detachReplacementCleanupReceipt(path.join(this.#baseDir, name));
1018
+ continue;
1019
+ }
1020
+ if (legacyReplacementCleanupReceiptBinding(name)) {
1021
+ this.#reconcileLegacyReplacementCleanupReceipt(path.join(this.#baseDir, name), name);
1022
+ continue;
1023
+ }
1024
+ throw new Error("managed_replace_cleanup_receipt_invalid");
826
1025
  }
827
1026
  } finally {
828
1027
  this.#reconcilingReplacementCleanup = false;
@@ -4765,6 +4765,7 @@ interface SessionManagerStateSnapshot {
4765
4765
  needsFullRewriteOnNextPersist: boolean;
4766
4766
  fileEntries: FileEntry[];
4767
4767
  materializedFileEntries: FileEntry[];
4768
+ adoptedArtifactManager: ArtifactManager | null;
4768
4769
  }
4769
4770
 
4770
4771
  /** Benchmark-derived cap for strong materialized session snapshots. */
@@ -4775,6 +4776,7 @@ export const SessionManagerTestHooks: {
4775
4776
  materializedCacheMaxBytesOverride?: number;
4776
4777
  beforeResidentTransitionIndexBuild?: () => void;
4777
4778
  afterForkSnapshot?: () => void | Promise<void>;
4779
+ beforeEphemeralArtifactManagerInstall?: (dir: string) => void | Promise<void>;
4778
4780
  } = {};
4779
4781
 
4780
4782
  function materializedCacheMaxBytes(): number {
@@ -4841,10 +4843,13 @@ export class SessionManager {
4841
4843
  // Subagents adopt the parent's manager so artifact IDs are unique across the
4842
4844
  // whole agent tree and all files land in the parent's artifacts dir.
4843
4845
  #adoptedArtifactManager: ArtifactManager | null = null;
4844
- // In-memory artifact fallback for non-persistent sessions (persist=false).
4845
- // Keyed by sequential numeric ID string; mirrors the file-based ArtifactManager ID scheme.
4846
- #inMemoryArtifacts: Map<string, string> | null = null;
4847
- #inMemoryArtifactCounter = 0;
4846
+ // Filesystem-backed artifact fallback for non-persistent sessions (persist=false).
4847
+ // The directory is created lazily on first save so artifact content is read back
4848
+ // from disk on demand instead of being retained in memory for the session lifetime.
4849
+ #ephemeralArtifactManager: ArtifactManager | null = null;
4850
+ #ephemeralArtifactDir: string | null = null;
4851
+ #ephemeralArtifactInit: Promise<ArtifactManager | null> | null = null;
4852
+ #ephemeralArtifactCleanups = new Set<Promise<void>>();
4848
4853
  readonly #blobStore: BlobStore;
4849
4854
  #residentTextBlobStore: BlobStore = new MemoryBlobStore();
4850
4855
  #residentImageBlobStore: BlobStore;
@@ -5360,6 +5365,7 @@ export class SessionManager {
5360
5365
  // Rollback snapshots must own resident data before another session reset disposes
5361
5366
  // the ephemeral store backing the resident sentinels above.
5362
5367
  materializedFileEntries,
5368
+ adoptedArtifactManager: this.#adoptedArtifactManager,
5363
5369
  };
5364
5370
  }
5365
5371
 
@@ -5397,7 +5403,7 @@ export class SessionManager {
5397
5403
  this.#persistErrorReported = false;
5398
5404
  this.#artifactManager = null;
5399
5405
  this.#artifactManagerSessionFile = null;
5400
- this.#adoptedArtifactManager = null;
5406
+ this.#adoptedArtifactManager = snapshot.adoptedArtifactManager;
5401
5407
  this.#commitResidentTextStoreTransition(prepared);
5402
5408
  if (this.#sessionFile) writeTerminalBreadcrumb(this.cwd, this.#sessionFile);
5403
5409
  }
@@ -5438,8 +5444,7 @@ export class SessionManager {
5438
5444
  this.#flushed = false;
5439
5445
  this.#needsFullRewriteOnNextPersist = false;
5440
5446
  this.#ensuredOnDisk = false;
5441
- this.#inMemoryArtifacts = null;
5442
- this.#inMemoryArtifactCounter = 0;
5447
+
5443
5448
  this.#artifactManager = null;
5444
5449
  this.#artifactManagerSessionFile = null;
5445
5450
  this.#adoptedArtifactManager = null;
@@ -5471,6 +5476,7 @@ export class SessionManager {
5471
5476
  const prepared = this.#prepareFreshSessionTransition(fresh, "memory-fallback");
5472
5477
  this.#applyFreshSessionMetadata(fresh);
5473
5478
  this.#commitResidentTextStoreTransition(prepared);
5479
+ this.#retireEphemeralArtifacts();
5474
5480
  writeTerminalBreadcrumb(this.cwd, resolvedSessionFile);
5475
5481
  await this.#rewriteFile();
5476
5482
  this.#flushed = true;
@@ -5544,7 +5550,7 @@ export class SessionManager {
5544
5550
  }
5545
5551
 
5546
5552
  /** Switch to a different session file (used for resume and branching). */
5547
- async setSessionFile(sessionFile: string): Promise<void> {
5553
+ async setSessionFile(sessionFile: string, options?: { deferEphemeralArtifactRetirement?: boolean }): Promise<void> {
5548
5554
  this.#assertRecoveryHydrationWritable();
5549
5555
  const resolvedSessionFile = this.storage instanceof FileSessionStorage ? path.resolve(sessionFile) : sessionFile;
5550
5556
  const strictAdoption = this.#pendingStrictAdoption;
@@ -5641,6 +5647,7 @@ export class SessionManager {
5641
5647
  prepared.dispose();
5642
5648
  throw error;
5643
5649
  }
5650
+ if (!options?.deferEphemeralArtifactRetirement) this.#retireEphemeralArtifacts();
5644
5651
  managedTransition?.settle();
5645
5652
  this.#pendingStrictAdoption = undefined;
5646
5653
  this.#flushed = true;
@@ -5668,8 +5675,6 @@ export class SessionManager {
5668
5675
  flushed: this.#flushed,
5669
5676
  needsFullRewriteOnNextPersist: this.#needsFullRewriteOnNextPersist,
5670
5677
  ensuredOnDisk: this.#ensuredOnDisk,
5671
- inMemoryArtifacts: this.#inMemoryArtifacts,
5672
- inMemoryArtifactCounter: this.#inMemoryArtifactCounter,
5673
5678
  artifactManager: this.#artifactManager,
5674
5679
  artifactManagerSessionFile: this.#artifactManagerSessionFile,
5675
5680
  adoptedArtifactManager: this.#adoptedArtifactManager,
@@ -5679,6 +5684,7 @@ export class SessionManager {
5679
5684
  managedTransition?.adopt();
5680
5685
  writeTerminalBreadcrumb(this.cwd, resolvedSessionFile);
5681
5686
  this.#commitResidentTextStoreTransition(prepared);
5687
+ if (!options?.deferEphemeralArtifactRetirement) this.#retireEphemeralArtifacts();
5682
5688
  } catch (error) {
5683
5689
  managedTransition?.rollback();
5684
5690
  this.#lifecycleIdAdopted = previous.lifecycleIdAdopted;
@@ -5692,8 +5698,6 @@ export class SessionManager {
5692
5698
  this.#flushed = previous.flushed;
5693
5699
  this.#needsFullRewriteOnNextPersist = previous.needsFullRewriteOnNextPersist;
5694
5700
  this.#ensuredOnDisk = previous.ensuredOnDisk;
5695
- this.#inMemoryArtifacts = previous.inMemoryArtifacts;
5696
- this.#inMemoryArtifactCounter = previous.inMemoryArtifactCounter;
5697
5701
  this.#artifactManager = previous.artifactManager;
5698
5702
  this.#artifactManagerSessionFile = previous.artifactManagerSessionFile;
5699
5703
  this.#adoptedArtifactManager = previous.adoptedArtifactManager;
@@ -6139,12 +6143,12 @@ export class SessionManager {
6139
6143
  this.#flushed = stage.flushed;
6140
6144
  this.#needsFullRewriteOnNextPersist = false;
6141
6145
  this.#ensuredOnDisk = stage.flushed;
6142
- this.#inMemoryArtifacts = null;
6143
- this.#inMemoryArtifactCounter = 0;
6146
+
6144
6147
  this.#artifactManager = null;
6145
6148
  this.#artifactManagerSessionFile = null;
6146
6149
  this.#adoptedArtifactManager = null;
6147
6150
  this.#commitResidentTextStoreTransition(transition);
6151
+ this.#retireEphemeralArtifacts();
6148
6152
  stage.committed = true;
6149
6153
  this.#preparedNewSessions.delete(stage);
6150
6154
  }
@@ -6955,6 +6959,7 @@ export class SessionManager {
6955
6959
  const prepared = this.#prepareFreshSessionTransition(fresh, "memory-fallback");
6956
6960
  this.#applyFreshSessionMetadata(fresh);
6957
6961
  this.#commitResidentTextStoreTransition(prepared);
6962
+ this.#retireEphemeralArtifacts();
6958
6963
  if (writeBreadcrumb && fresh.sessionFile) writeTerminalBreadcrumb(this.cwd, fresh.sessionFile);
6959
6964
  return fresh.sessionFile;
6960
6965
  }
@@ -7677,6 +7682,8 @@ export class SessionManager {
7677
7682
  this.#flushed = true;
7678
7683
  }
7679
7684
  });
7685
+ this.#retireEphemeralArtifacts();
7686
+ await this.#drainEphemeralArtifactCleanups();
7680
7687
  this.#releaseResidentTextStore();
7681
7688
  this.#releaseOwnedManagedAuthority();
7682
7689
  if (this.#persistError) throw this.#persistError;
@@ -7736,6 +7743,12 @@ export class SessionManager {
7736
7743
  // close leaves the session live for a genuine retry.
7737
7744
  if (!this.#persistWriter) {
7738
7745
  this.#releaseResidentTextStore();
7746
+ this.#retireEphemeralArtifacts();
7747
+ try {
7748
+ await this.#drainEphemeralArtifactCleanups();
7749
+ } catch (error) {
7750
+ outcome = { kind: "close_unknown", error: toError(error) };
7751
+ }
7739
7752
  }
7740
7753
  return outcome;
7741
7754
  }
@@ -8024,14 +8037,40 @@ export class SessionManager {
8024
8037
  this.#adoptedArtifactManager = manager;
8025
8038
  }
8026
8039
 
8040
+ /** Release only the matching externally adopted manager. */
8041
+ releaseArtifactManager(manager: ArtifactManager): void {
8042
+ if (this.#adoptedArtifactManager === manager) this.#adoptedArtifactManager = null;
8043
+ }
8044
+
8045
+ /** Temporarily release adopted authority while an outer transition validates its successor. */
8046
+ stageAdoptedArtifactManagerForTransition(): void {
8047
+ this.#adoptedArtifactManager = null;
8048
+ }
8049
+
8050
+ /** Prove manager authority by exact object identity, never by pathname shape. */
8051
+ isArtifactManagerAuthorized(manager: ArtifactManager): boolean {
8052
+ return (
8053
+ manager === this.#adoptedArtifactManager ||
8054
+ manager === this.#artifactManager ||
8055
+ manager === this.#ephemeralArtifactManager
8056
+ );
8057
+ }
8058
+
8027
8059
  /**
8028
8060
  * Returns the ArtifactManager this session writes through. Lazily creates
8029
8061
  * one bound to the current session file unless an external manager was
8030
- * adopted via `adoptArtifactManager`. Returns null only for non-persistent
8031
- * sessions with no adopted manager.
8062
+ * adopted via `adoptArtifactManager`. Falls back to the lazily created
8063
+ * ephemeral filesystem store once a non-persistent session has saved an
8064
+ * artifact, so `artifact://` stays resolvable. Returns null only when no
8065
+ * store has been established yet.
8032
8066
  */
8033
8067
  getArtifactManager(): ArtifactManager | null {
8034
- return this.#getOrCreateArtifactManager();
8068
+ return this.#getOrCreateArtifactManager() ?? this.#ephemeralArtifactManager;
8069
+ }
8070
+
8071
+ /** Linearizably establish this session's persistent or ephemeral artifact manager. */
8072
+ async ensureArtifactManager(): Promise<ArtifactManager | null> {
8073
+ return this.#getOrCreateArtifactManager() ?? (await this.#ensureEphemeralArtifactManager());
8035
8074
  }
8036
8075
 
8037
8076
  /**
@@ -8074,28 +8113,89 @@ export class SessionManager {
8074
8113
 
8075
8114
  /**
8076
8115
  * Save artifact content under the current session and return artifact ID.
8077
- * Returns an artifact ID for all sessions (file-backed for persistent, in-memory fallback otherwise).
8116
+ * Persistent sessions write into the session artifact directory; non-persistent
8117
+ * sessions write into a lazily created temporary directory so the content is
8118
+ * read back from the filesystem instead of being retained in memory.
8078
8119
  */
8079
8120
  async saveArtifact(content: string, toolType: string): Promise<string | undefined> {
8080
- const manager = this.#getOrCreateArtifactManager();
8081
- if (manager) return manager.save(content, toolType);
8082
- // Non-persistent session: store in memory so spill truncation can proceed.
8083
- if (!this.#inMemoryArtifacts) this.#inMemoryArtifacts = new Map();
8084
- const id = String(this.#inMemoryArtifactCounter++);
8085
- this.#inMemoryArtifacts.set(id, content);
8086
- return id;
8121
+ const manager = this.#getOrCreateArtifactManager() ?? (await this.#ensureEphemeralArtifactManager());
8122
+ return manager ? manager.save(content, toolType) : undefined;
8087
8123
  }
8088
8124
 
8089
8125
  /**
8090
8126
  * Resolve an artifact ID to an on-disk path for the current session.
8091
- * Returns null when missing or when the session is not persisted.
8127
+ * Returns null when the artifact is missing.
8092
8128
  */
8093
8129
  async getArtifactPath(id: string): Promise<string | null> {
8094
- const manager = this.#getOrCreateArtifactManager();
8130
+ const manager = this.getArtifactManager();
8095
8131
  if (!manager) return null;
8096
8132
  return manager.getPath(id);
8097
8133
  }
8098
8134
 
8135
+ /**
8136
+ * Create the non-persistent session's temporary artifact directory on first use.
8137
+ * Returns null when the directory cannot be created; callers then report no artifact.
8138
+ */
8139
+ async #ensureEphemeralArtifactManager(): Promise<ArtifactManager | null> {
8140
+ if (!this.#ephemeralArtifactInit) {
8141
+ let init: Promise<ArtifactManager | null>;
8142
+ init = fs.promises
8143
+ .mkdtemp(path.join(os.tmpdir(), "gjc-session-artifacts-"))
8144
+ .then(async dir => {
8145
+ try {
8146
+ await SessionManagerTestHooks.beforeEphemeralArtifactManagerInstall?.(dir);
8147
+ } catch (error) {
8148
+ await fs.promises.rm(dir, { recursive: true, force: true });
8149
+ throw error;
8150
+ }
8151
+ const manager = new ArtifactManager(dir);
8152
+ if (this.#ephemeralArtifactInit !== init) {
8153
+ await fs.promises.rm(dir, { recursive: true, force: true });
8154
+ return null;
8155
+ }
8156
+ this.#ephemeralArtifactDir = dir;
8157
+ this.#ephemeralArtifactManager = manager;
8158
+ return manager;
8159
+ })
8160
+ .catch(() => null);
8161
+ this.#ephemeralArtifactInit = init;
8162
+ }
8163
+ const init = this.#ephemeralArtifactInit;
8164
+ const manager = await init;
8165
+ if (!manager && this.#ephemeralArtifactInit === init) this.#ephemeralArtifactInit = null;
8166
+ return manager;
8167
+ }
8168
+
8169
+ /** Unbind the active ephemeral root only after a successor transition commits. */
8170
+ #retireEphemeralArtifacts(): void {
8171
+ const dir = this.#ephemeralArtifactDir;
8172
+ const init = this.#ephemeralArtifactInit;
8173
+ if (!dir && !init) return;
8174
+ this.#ephemeralArtifactManager = null;
8175
+ this.#ephemeralArtifactDir = null;
8176
+ this.#ephemeralArtifactInit = null;
8177
+ const cleanup = (async () => {
8178
+ const initialized = await init;
8179
+ const cleanupDir = dir ?? initialized?.dir;
8180
+ if (cleanupDir) await fs.promises.rm(cleanupDir, { recursive: true, force: true });
8181
+ })();
8182
+ void cleanup.catch(() => {});
8183
+ this.#ephemeralArtifactCleanups.add(cleanup);
8184
+ }
8185
+
8186
+ /** Retire predecessor ephemeral artifacts after an outer logical transition commits. */
8187
+ retireEphemeralArtifactsAfterTransition(): void {
8188
+ this.#retireEphemeralArtifacts();
8189
+ }
8190
+
8191
+ async #drainEphemeralArtifactCleanups(): Promise<void> {
8192
+ while (this.#ephemeralArtifactCleanups.size > 0) {
8193
+ const pending = Array.from(this.#ephemeralArtifactCleanups);
8194
+ this.#ephemeralArtifactCleanups.clear();
8195
+ await Promise.all(pending);
8196
+ }
8197
+ }
8198
+
8099
8199
  /**
8100
8200
  * Path to the unsent-input draft sidecar for an explicit session. It lives
8101
8201
  * inside the artifacts directory so `dropSession` removes it with its owner.
@@ -8229,20 +8329,26 @@ export class SessionManager {
8229
8329
  await this.#persistPatch({ type: "header_patch", patch });
8230
8330
  }
8231
8331
 
8232
- #appendManagedRecordSync(record: FileEntry | SessionPatchRecord): void {
8332
+ #appendManagedRecordsSync(records: readonly (FileEntry | SessionPatchRecord)[]): void {
8233
8333
  if (!this.#sessionFile) throw new Error("Managed transcript path is unavailable");
8234
8334
  const store = this.#managedTranscriptStore();
8235
8335
  const relativePath = path.basename(this.#sessionFile);
8236
- store.appendSync(relativePath, Buffer.from(`${JSON.stringify(record)}\n`, "utf8"));
8336
+ const bytes = Buffer.from(`${records.map(record => JSON.stringify(record)).join("\n")}\n`, "utf8");
8337
+ store.appendSync(relativePath, bytes);
8237
8338
  }
8238
8339
 
8239
8340
  async #persistPatch(record: SessionPatchRecord): Promise<void> {
8240
- if (!this.persist || !this.#sessionFile || !this.storage.existsSync(this.#sessionFile)) return;
8341
+ await this.#persistPatches([record]);
8342
+ }
8343
+
8344
+ async #persistPatches(records: readonly SessionPatchRecord[]): Promise<void> {
8345
+ if (records.length === 0 || !this.persist || !this.#sessionFile || !this.storage.existsSync(this.#sessionFile))
8346
+ return;
8241
8347
  await this.#queuePersistTask(async () => {
8242
8348
  const header = this.#fileEntries.find(entry => entry.type === "session") as SessionHeader | undefined;
8243
8349
  if (this.#needsFullRewriteOnNextPersist || !this.#flushed || (header?.version ?? 1) < CURRENT_SESSION_VERSION)
8244
8350
  return this.#rewriteFileContents();
8245
- const persistedRecord =
8351
+ const persistedRecords = records.map(record =>
8246
8352
  record.type === "entry_patch"
8247
8353
  ? (prepareEntryForPersistenceSync(
8248
8354
  materializeResidentEntryForPersistenceSync(
@@ -8252,14 +8358,17 @@ export class SessionManager {
8252
8358
  ),
8253
8359
  this.#blobStore,
8254
8360
  ) as unknown as SessionPatchRecord)
8255
- : record;
8361
+ : record,
8362
+ );
8256
8363
  if (this.destination.kind === "managed") {
8257
- this.#appendManagedRecordSync(persistedRecord);
8364
+ this.#appendManagedRecordsSync(persistedRecords);
8258
8365
  return;
8259
8366
  }
8260
8367
  const writer = this.#ensurePersistWriter();
8261
8368
  if (!writer) return this.#rewriteFileContents();
8262
- await writer.write(persistedRecord);
8369
+ for (const persistedRecord of persistedRecords) {
8370
+ await writer.write(persistedRecord);
8371
+ }
8263
8372
  });
8264
8373
  }
8265
8374
  _persist(entry: SessionEntry): void {
@@ -8308,7 +8417,7 @@ export class SessionManager {
8308
8417
  new Map(),
8309
8418
  );
8310
8419
  const persistedEntry = prepareEntryForPersistenceSync(materializedEntry, this.#blobStore);
8311
- this.#appendManagedRecordSync(persistedEntry);
8420
+ this.#appendManagedRecordsSync([persistedEntry]);
8312
8421
  return;
8313
8422
  }
8314
8423
  const writer = this.#ensurePersistWriter();
@@ -9228,9 +9337,7 @@ export class SessionManager {
9228
9337
 
9229
9338
  async #sanitizeLoadedOpenAIResponsesReplayMetadataAndPersist(): Promise<boolean> {
9230
9339
  const patches = this.#sanitizeLoadedOpenAIResponsesReplayMetadata();
9231
- for (const patch of patches) {
9232
- await this.#persistPatch(patch);
9233
- }
9340
+ await this.#persistPatches(patches);
9234
9341
  return patches.length > 0;
9235
9342
  }
9236
9343
 
@@ -9803,6 +9910,7 @@ export class SessionManager {
9803
9910
  );
9804
9911
  manager.#applyFreshSessionMetadata(fresh);
9805
9912
  manager.#commitResidentTextStoreTransition(transition);
9913
+ manager.#retireEphemeralArtifacts();
9806
9914
  if (fresh.sessionFile) writeTerminalBreadcrumb(manager.cwd, fresh.sessionFile);
9807
9915
  manager.sanitizeLoadedOpenAIResponsesReplayMetadata();
9808
9916
  await manager.#rewriteFile();
@@ -9905,6 +10013,7 @@ export class SessionManager {
9905
10013
  const transition = manager.#prepareFreshSessionTransition(fresh, "memory-fallback");
9906
10014
  manager.#applyFreshSessionMetadata(fresh);
9907
10015
  manager.#commitResidentTextStoreTransition(transition);
10016
+ manager.#retireEphemeralArtifacts();
9908
10017
  writeTerminalBreadcrumb(manager.cwd, resolved);
9909
10018
  await manager.#rewriteFile();
9910
10019
  manager.#flushed = true;
@@ -10096,6 +10205,7 @@ export class SessionManager {
10096
10205
  );
10097
10206
  manager.#applyFreshSessionMetadata(fresh);
10098
10207
  manager.#commitResidentTextStoreTransition(transition);
10208
+ manager.#retireEphemeralArtifacts();
10099
10209
  manager.sanitizeLoadedOpenAIResponsesReplayMetadata();
10100
10210
  const beforeWrite = inspectResumeSessionFile(snapshot.sourcePath, snapshot.storage);
10101
10211
  if ("kind" in beforeWrite) {
@@ -2,5 +2,5 @@
2
2
  {"id":"minimax","aliases":["minimax-code"],"name":"MiniMax Coding Plan","description":"OpenAI-compatible MiniMax Coding Plan endpoint","compatibility":"openai","api":"openai-completions","providerId":"minimax-code","baseUrl":"https://api.minimax.io/v1","apiKeyEnv":"MINIMAX_CODE_API_KEY","models":["minimax-m3"],"compat":{"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"reasoningContentField":"reasoning_content"}},
3
3
  {"id":"minimax-cn","aliases":["minimax-code-cn","minimaxi"],"name":"MiniMax Coding Plan (China)","description":"OpenAI-compatible MiniMax China endpoint","compatibility":"openai","api":"openai-completions","providerId":"minimax-code-cn","baseUrl":"https://api.minimaxi.com/v1","apiKeyEnv":"MINIMAX_CODE_CN_API_KEY","models":["minimax-m3"],"compat":{"supportsStore":false,"supportsDeveloperRole":false,"supportsReasoningEffort":false,"reasoningContentField":"reasoning_content"}},
4
4
  {"id":"glm","aliases":["zai","z-ai","bigmodel"],"name":"GLM / zAI","description":"OpenAI-compatible GLM endpoint from zAI/BigModel","compatibility":"openai","api":"openai-completions","providerId":"glm-proxy","baseUrl":"https://api.z.ai/api/paas/v4","apiKeyEnv":"ZAI_API_KEY","models":["glm-4.6"],"compat":{"supportsDeveloperRole":false,"supportsReasoningEffort":false,"thinkingFormat":"zai","reasoningContentField":"reasoning_content"}},
5
- {"id":"alibaba-token-plan","aliases":["alibaba","token-plan"],"name":"Alibaba Token Plan","description":"OpenAI-compatible Alibaba Token Plan endpoint","compatibility":"openai","api":"openai-completions","providerId":"alibaba-token-plan","baseUrl":"https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1","apiKeyEnv":"ALIBABA_TOKEN_PLAN_API_KEY","models":["qwen3.8-max-preview","glm-5.2","deepseek-v4-pro"],"modelApi":{"qwen3.8-max-preview":"openai-responses","glm-5.2":"openai-completions","deepseek-v4-pro":"openai-completions"},"compat":{"supportsDeveloperRole":false}}
5
+ {"id":"alibaba-token-plan","aliases":["alibaba","token-plan"],"name":"Alibaba Token Plan","description":"OpenAI-compatible Alibaba Token Plan endpoint","compatibility":"openai","api":"openai-completions","providerId":"alibaba-token-plan","baseUrl":"https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1","apiKeyEnv":"ALIBABA_TOKEN_PLAN_API_KEY","models":["qwen3.8-max-preview","qwen-3.8-max","glm-5.2","deepseek-v4-pro"],"modelApi":{"qwen3.8-max-preview":"openai-responses","qwen-3.8-max":"openai-responses","glm-5.2":"openai-completions","deepseek-v4-pro":"openai-completions"},"compat":{"supportsDeveloperRole":false}}
6
6
  ]