@gajae-code/coding-agent 0.12.10 → 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 (120) hide show
  1. package/CHANGELOG.md +47 -2
  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 +13 -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 +25 -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/internal/managed-session-storage.d.ts +12 -2
  42. package/dist/types/session/session-manager.d.ts +40 -5
  43. package/dist/types/session/session-storage.d.ts +1 -0
  44. package/dist/types/tools/computer.d.ts +35 -2
  45. package/dist/types/tools/index.d.ts +8 -0
  46. package/dist/types/tools/todo-write.d.ts +2 -0
  47. package/dist/types/utils/npm-registry.d.ts +71 -0
  48. package/examples/extensions/README.md +38 -4
  49. package/examples/extensions/ooo-bridge.ts +15 -0
  50. package/package.json +8 -8
  51. package/scripts/generate-sdk-operation-inventory.ts +4 -0
  52. package/src/async/job-manager.ts +9 -2
  53. package/src/cli/notify-cli.ts +226 -2
  54. package/src/cli/update-cli.ts +90 -23
  55. package/src/commands/notify.ts +27 -3
  56. package/src/commands/sdk.ts +1 -0
  57. package/src/config/file-lock-gc.ts +18 -0
  58. package/src/config/file-lock.ts +61 -16
  59. package/src/config/model-profiles.ts +31 -15
  60. package/src/coordinator/contract.ts +1 -0
  61. package/src/coordinator-mcp/question-state.ts +2 -0
  62. package/src/coordinator-mcp/server.ts +358 -7
  63. package/src/defaults/gjc/skills/ultragoal/SKILL.md +2 -0
  64. package/src/extensibility/extensions/index.ts +1 -0
  65. package/src/extensibility/extensions/ouroboros-ooo-bridge.ts +235 -0
  66. package/src/extensibility/extensions/prefix-command-bridge.ts +4 -9
  67. package/src/extensibility/extensions/runner.ts +15 -2
  68. package/src/extensibility/extensions/types.ts +2 -0
  69. package/src/gjc-runtime/launch-tmux.ts +53 -1
  70. package/src/internal-urls/docs-index.generated.ts +13 -12
  71. package/src/main.ts +203 -67
  72. package/src/modes/DESIGN.md +8 -5
  73. package/src/modes/components/footer.ts +10 -18
  74. package/src/modes/components/session-selector.ts +26 -3
  75. package/src/modes/components/status-line/gh.ts +23 -2
  76. package/src/modes/controllers/event-controller.ts +3 -0
  77. package/src/modes/controllers/extension-ui-controller.ts +2 -0
  78. package/src/modes/controllers/input-controller.ts +11 -0
  79. package/src/modes/controllers/selector-controller.ts +8 -2
  80. package/src/modes/interactive-mode.ts +7 -1
  81. package/src/modes/utils/host-status.ts +38 -0
  82. package/src/prompts/tools/computer.md +6 -1
  83. package/src/runtime-mcp/types.ts +2 -0
  84. package/src/sdk/broker/lifecycle.ts +60 -3
  85. package/src/sdk/bus/chat-daemon-cli.ts +42 -0
  86. package/src/sdk/bus/chat-daemon-command-channel.ts +921 -0
  87. package/src/sdk/bus/chat-daemon-control.ts +18 -3
  88. package/src/sdk/bus/chat-daemon-runtime.ts +257 -21
  89. package/src/sdk/bus/control-plane-frames.ts +51 -0
  90. package/src/sdk/bus/conversation-store.ts +29 -1
  91. package/src/sdk/bus/existing-thread-readiness.ts +155 -0
  92. package/src/sdk/bus/index.ts +70 -0
  93. package/src/sdk/bus/notification-service.ts +28 -1
  94. package/src/sdk/bus/rich-render.ts +3 -2
  95. package/src/sdk/bus/slack-daemon.ts +99 -4
  96. package/src/sdk/bus/slack-live-provider.ts +32 -2
  97. package/src/sdk/bus/slack-provider.ts +11 -0
  98. package/src/sdk/bus/slack-thread-binding.ts +641 -0
  99. package/src/sdk/bus/telegram-daemon-cli.ts +11 -0
  100. package/src/sdk/bus/telegram-daemon-contract.ts +25 -7
  101. package/src/sdk/bus/telegram-daemon.ts +1961 -497
  102. package/src/sdk/bus/telegram-reference.ts +42 -6
  103. package/src/sdk/bus/topic-registry.ts +674 -181
  104. package/src/sdk/host/host.ts +180 -1
  105. package/src/sdk/lifecycle-session.ts +8 -2
  106. package/src/sdk/protocol/operation-inventory.generated.json +22 -0
  107. package/src/sdk/session-activation.ts +127 -0
  108. package/src/sdk/session.ts +139 -28
  109. package/src/sdk/startup-capability.ts +10 -1
  110. package/src/session/agent-session.ts +331 -30
  111. package/src/session/artifacts.ts +56 -13
  112. package/src/session/internal/managed-session-storage.ts +440 -243
  113. package/src/session/session-manager.ts +288 -44
  114. package/src/session/session-storage.ts +3 -0
  115. package/src/setup/provider-presets.json +1 -1
  116. package/src/task/index.ts +92 -44
  117. package/src/tools/computer.ts +322 -45
  118. package/src/tools/index.ts +8 -0
  119. package/src/tools/todo-write.ts +29 -11
  120. package/src/utils/npm-registry.ts +660 -0
@@ -899,6 +899,7 @@ export interface ResumeSessionIdentity {
899
899
  size: number;
900
900
  mtimeMs: number;
901
901
  mtimeNs: bigint;
902
+ ctimeNs?: bigint;
902
903
  sha256: string;
903
904
  }
904
905
 
@@ -918,6 +919,21 @@ export interface ResumeTailError {
918
919
  }
919
920
 
920
921
  export type SessionDirectoryMigrationPolicy = ManagedMigrationPolicy;
922
+ export type SessionAppendPersistenceFailurePhase = "current_append" | "prior_failure";
923
+
924
+ export class SessionAppendPersistenceError extends Error {
925
+ readonly phase: SessionAppendPersistenceFailurePhase;
926
+ readonly entryId: string;
927
+ readonly persistenceError: Error;
928
+
929
+ constructor(phase: SessionAppendPersistenceFailurePhase, entryId: string, persistenceError: Error) {
930
+ super(persistenceError.message, { cause: persistenceError });
931
+ this.name = "SessionAppendPersistenceError";
932
+ this.phase = phase;
933
+ this.entryId = entryId;
934
+ this.persistenceError = persistenceError;
935
+ }
936
+ }
921
937
 
922
938
  export class SessionManagedStorageError extends Error {
923
939
  readonly code = "managed_storage_unsupported";
@@ -2161,6 +2177,7 @@ function sameResumeIdentity(left: ResumeSessionIdentity, right: ResumeSessionIde
2161
2177
  left.size === right.size &&
2162
2178
  left.mtimeMs === right.mtimeMs &&
2163
2179
  left.mtimeNs === right.mtimeNs &&
2180
+ (left.ctimeNs === undefined || right.ctimeNs === undefined || left.ctimeNs === right.ctimeNs) &&
2164
2181
  left.sha256 === right.sha256
2165
2182
  );
2166
2183
  }
@@ -2174,7 +2191,8 @@ function sameResumeStat(left: SessionStorageStat, right: SessionStorageStat): bo
2174
2191
  left.nlink === right.nlink &&
2175
2192
  left.size === right.size &&
2176
2193
  left.mtimeMs === right.mtimeMs &&
2177
- left.mtimeNs === right.mtimeNs
2194
+ left.mtimeNs === right.mtimeNs &&
2195
+ left.ctimeNs === right.ctimeNs
2178
2196
  );
2179
2197
  }
2180
2198
 
@@ -2372,6 +2390,7 @@ function inspectResumeSessionFile(
2372
2390
  size: snapshot.size,
2373
2391
  mtimeMs: snapshot.mtimeMs,
2374
2392
  mtimeNs: snapshot.mtimeNs,
2393
+ ctimeNs: snapshot.ctimeNs,
2375
2394
  sha256: crypto.createHash("sha256").update(bytes).digest("hex"),
2376
2395
  };
2377
2396
  return { identity, content: bytes, entries, context, migrationApplied };
@@ -2380,6 +2399,46 @@ function inspectResumeSessionFile(
2380
2399
  }
2381
2400
  }
2382
2401
 
2402
+ function fsyncResumeSessionIdentity(expected: ResumeSessionIdentity): void {
2403
+ const fd = fs.openSync(expected.canonicalPath, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
2404
+ try {
2405
+ const before = fs.fstatSync(fd, { bigint: true });
2406
+ if (
2407
+ before.dev !== expected.dev ||
2408
+ before.ino !== expected.ino ||
2409
+ (expected.nlink !== undefined && before.nlink !== expected.nlink) ||
2410
+ Number(before.size) !== expected.size ||
2411
+ before.mtimeNs !== expected.mtimeNs ||
2412
+ (expected.ctimeNs !== undefined && before.ctimeNs !== expected.ctimeNs)
2413
+ ) {
2414
+ throw new Error("session_persistence_recovery_identity_mismatch");
2415
+ }
2416
+ fs.fsyncSync(fd);
2417
+ const after = fs.fstatSync(fd, { bigint: true });
2418
+ const named = fs.lstatSync(expected.canonicalPath, { bigint: true });
2419
+ if (
2420
+ after.dev !== before.dev ||
2421
+ after.ino !== before.ino ||
2422
+ after.nlink !== before.nlink ||
2423
+ after.size !== before.size ||
2424
+ after.mtimeNs !== before.mtimeNs ||
2425
+ after.ctimeNs !== before.ctimeNs ||
2426
+ named.dev !== after.dev ||
2427
+ named.ino !== after.ino ||
2428
+ named.nlink !== after.nlink
2429
+ ) {
2430
+ throw new Error("session_persistence_recovery_identity_mismatch");
2431
+ }
2432
+ } finally {
2433
+ fs.closeSync(fd);
2434
+ }
2435
+ const parentFd = fs.openSync(path.dirname(expected.canonicalPath), fs.constants.O_RDONLY | fs.constants.O_DIRECTORY);
2436
+ try {
2437
+ fs.fsyncSync(parentFd);
2438
+ } finally {
2439
+ fs.closeSync(parentFd);
2440
+ }
2441
+ }
2383
2442
  /** Revalidate previously parsed authority without another decode, parse, migration, or context build. */
2384
2443
  function revalidateResumeSessionIdentity(
2385
2444
  filePath: string,
@@ -2410,6 +2469,7 @@ function revalidateResumeSessionIdentity(
2410
2469
  size: snapshot.stat.size,
2411
2470
  mtimeMs: snapshot.stat.mtimeMs,
2412
2471
  mtimeNs: snapshot.stat.mtimeNs,
2472
+ ctimeNs: snapshot.stat.ctimeNs,
2413
2473
  sha256: crypto.createHash("sha256").update(snapshot.bytes).digest("hex"),
2414
2474
  };
2415
2475
  return sameResumeIdentity(expected, observed) ? { kind: "valid" } : { kind: "error", reason: "identity-mismatch" };
@@ -4705,6 +4765,7 @@ interface SessionManagerStateSnapshot {
4705
4765
  needsFullRewriteOnNextPersist: boolean;
4706
4766
  fileEntries: FileEntry[];
4707
4767
  materializedFileEntries: FileEntry[];
4768
+ adoptedArtifactManager: ArtifactManager | null;
4708
4769
  }
4709
4770
 
4710
4771
  /** Benchmark-derived cap for strong materialized session snapshots. */
@@ -4715,6 +4776,7 @@ export const SessionManagerTestHooks: {
4715
4776
  materializedCacheMaxBytesOverride?: number;
4716
4777
  beforeResidentTransitionIndexBuild?: () => void;
4717
4778
  afterForkSnapshot?: () => void | Promise<void>;
4779
+ beforeEphemeralArtifactManagerInstall?: (dir: string) => void | Promise<void>;
4718
4780
  } = {};
4719
4781
 
4720
4782
  function materializedCacheMaxBytes(): number {
@@ -4751,7 +4813,9 @@ export class SessionManager {
4751
4813
  #recoveryPromotionTranscriptPath: string | undefined;
4752
4814
  #memoryGuardParticipantIngressToken: symbol | undefined;
4753
4815
  #fileEntries: FileEntry[] = [];
4754
- #pendingStrictAdoption: { canonicalPath: string; identity: ResumeSessionIdentity } | undefined;
4816
+ #pendingStrictAdoption:
4817
+ | { canonicalPath: string; identity: ResumeSessionIdentity; inspection?: ResumeInspectionSnapshot }
4818
+ | undefined;
4755
4819
  #byId: Map<string, SessionEntry> = new Map();
4756
4820
  #labelsById: Map<string, string> = new Map();
4757
4821
  #leafId: string | null = null;
@@ -4779,10 +4843,13 @@ export class SessionManager {
4779
4843
  // Subagents adopt the parent's manager so artifact IDs are unique across the
4780
4844
  // whole agent tree and all files land in the parent's artifacts dir.
4781
4845
  #adoptedArtifactManager: ArtifactManager | null = null;
4782
- // In-memory artifact fallback for non-persistent sessions (persist=false).
4783
- // Keyed by sequential numeric ID string; mirrors the file-based ArtifactManager ID scheme.
4784
- #inMemoryArtifacts: Map<string, string> | null = null;
4785
- #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>>();
4786
4853
  readonly #blobStore: BlobStore;
4787
4854
  #residentTextBlobStore: BlobStore = new MemoryBlobStore();
4788
4855
  #residentImageBlobStore: BlobStore;
@@ -5298,6 +5365,7 @@ export class SessionManager {
5298
5365
  // Rollback snapshots must own resident data before another session reset disposes
5299
5366
  // the ephemeral store backing the resident sentinels above.
5300
5367
  materializedFileEntries,
5368
+ adoptedArtifactManager: this.#adoptedArtifactManager,
5301
5369
  };
5302
5370
  }
5303
5371
 
@@ -5335,7 +5403,7 @@ export class SessionManager {
5335
5403
  this.#persistErrorReported = false;
5336
5404
  this.#artifactManager = null;
5337
5405
  this.#artifactManagerSessionFile = null;
5338
- this.#adoptedArtifactManager = null;
5406
+ this.#adoptedArtifactManager = snapshot.adoptedArtifactManager;
5339
5407
  this.#commitResidentTextStoreTransition(prepared);
5340
5408
  if (this.#sessionFile) writeTerminalBreadcrumb(this.cwd, this.#sessionFile);
5341
5409
  }
@@ -5376,8 +5444,7 @@ export class SessionManager {
5376
5444
  this.#flushed = false;
5377
5445
  this.#needsFullRewriteOnNextPersist = false;
5378
5446
  this.#ensuredOnDisk = false;
5379
- this.#inMemoryArtifacts = null;
5380
- this.#inMemoryArtifactCounter = 0;
5447
+
5381
5448
  this.#artifactManager = null;
5382
5449
  this.#artifactManagerSessionFile = null;
5383
5450
  this.#adoptedArtifactManager = null;
@@ -5409,6 +5476,7 @@ export class SessionManager {
5409
5476
  const prepared = this.#prepareFreshSessionTransition(fresh, "memory-fallback");
5410
5477
  this.#applyFreshSessionMetadata(fresh);
5411
5478
  this.#commitResidentTextStoreTransition(prepared);
5479
+ this.#retireEphemeralArtifacts();
5412
5480
  writeTerminalBreadcrumb(this.cwd, resolvedSessionFile);
5413
5481
  await this.#rewriteFile();
5414
5482
  this.#flushed = true;
@@ -5482,7 +5550,7 @@ export class SessionManager {
5482
5550
  }
5483
5551
 
5484
5552
  /** Switch to a different session file (used for resume and branching). */
5485
- async setSessionFile(sessionFile: string): Promise<void> {
5553
+ async setSessionFile(sessionFile: string, options?: { deferEphemeralArtifactRetirement?: boolean }): Promise<void> {
5486
5554
  this.#assertRecoveryHydrationWritable();
5487
5555
  const resolvedSessionFile = this.storage instanceof FileSessionStorage ? path.resolve(sessionFile) : sessionFile;
5488
5556
  const strictAdoption = this.#pendingStrictAdoption;
@@ -5501,11 +5569,14 @@ export class SessionManager {
5501
5569
  if (strictAdoption && resolvedSessionFile === strictAdoption.canonicalPath) {
5502
5570
  const inspected = inspectResumeSessionFile(resolvedSessionFile, this.storage);
5503
5571
  if ("kind" in inspected || !sameResumeIdentity(strictAdoption.identity, inspected.identity))
5504
- throw new Error("Prepared managed session changed before strict adoption.");
5572
+ throw new Error("Prepared session changed before strict adoption.");
5505
5573
  }
5506
5574
  let entries: FileEntry[];
5507
5575
  let candidateMigrationApplied = false;
5508
- if (this.storage.existsSync(resolvedSessionFile)) {
5576
+ if (strictAdoption?.inspection && resolvedSessionFile === strictAdoption.canonicalPath) {
5577
+ entries = strictAdoption.inspection.entries;
5578
+ candidateMigrationApplied = strictAdoption.inspection.migrationApplied;
5579
+ } else if (this.storage.existsSync(resolvedSessionFile)) {
5509
5580
  const inspected = inspectResumeSessionFile(resolvedSessionFile, this.storage);
5510
5581
  if ("kind" in inspected) throw new Error(`Could not switch session: ${inspected.reason}`);
5511
5582
  entries = inspected.entries;
@@ -5516,7 +5587,7 @@ export class SessionManager {
5516
5587
  if (strictAdoption) {
5517
5588
  const inspected = inspectResumeSessionFile(resolvedSessionFile, this.storage);
5518
5589
  if ("kind" in inspected || !sameResumeIdentity(strictAdoption.identity, inspected.identity))
5519
- throw new Error("Prepared managed session changed during strict adoption.");
5590
+ throw new Error("Prepared session changed during strict adoption.");
5520
5591
  }
5521
5592
  if (entries.length > 0) {
5522
5593
  const header = entries.find(entry => entry.type === "session") as SessionHeader | undefined;
@@ -5541,6 +5612,13 @@ export class SessionManager {
5541
5612
  prepared.dispose();
5542
5613
  throw error;
5543
5614
  }
5615
+ if (strictAdoption) {
5616
+ const inspected = inspectResumeSessionFile(resolvedSessionFile, this.storage);
5617
+ if ("kind" in inspected || !sameResumeIdentity(strictAdoption.identity, inspected.identity)) {
5618
+ prepared.dispose();
5619
+ throw new Error("Prepared session changed before strict adoption commit.");
5620
+ }
5621
+ }
5544
5622
  const previous = {
5545
5623
  sessionId: this.#sessionId,
5546
5624
  sessionName: this.#sessionName,
@@ -5569,6 +5647,7 @@ export class SessionManager {
5569
5647
  prepared.dispose();
5570
5648
  throw error;
5571
5649
  }
5650
+ if (!options?.deferEphemeralArtifactRetirement) this.#retireEphemeralArtifacts();
5572
5651
  managedTransition?.settle();
5573
5652
  this.#pendingStrictAdoption = undefined;
5574
5653
  this.#flushed = true;
@@ -5596,8 +5675,6 @@ export class SessionManager {
5596
5675
  flushed: this.#flushed,
5597
5676
  needsFullRewriteOnNextPersist: this.#needsFullRewriteOnNextPersist,
5598
5677
  ensuredOnDisk: this.#ensuredOnDisk,
5599
- inMemoryArtifacts: this.#inMemoryArtifacts,
5600
- inMemoryArtifactCounter: this.#inMemoryArtifactCounter,
5601
5678
  artifactManager: this.#artifactManager,
5602
5679
  artifactManagerSessionFile: this.#artifactManagerSessionFile,
5603
5680
  adoptedArtifactManager: this.#adoptedArtifactManager,
@@ -5607,6 +5684,7 @@ export class SessionManager {
5607
5684
  managedTransition?.adopt();
5608
5685
  writeTerminalBreadcrumb(this.cwd, resolvedSessionFile);
5609
5686
  this.#commitResidentTextStoreTransition(prepared);
5687
+ if (!options?.deferEphemeralArtifactRetirement) this.#retireEphemeralArtifacts();
5610
5688
  } catch (error) {
5611
5689
  managedTransition?.rollback();
5612
5690
  this.#lifecycleIdAdopted = previous.lifecycleIdAdopted;
@@ -5620,8 +5698,6 @@ export class SessionManager {
5620
5698
  this.#flushed = previous.flushed;
5621
5699
  this.#needsFullRewriteOnNextPersist = previous.needsFullRewriteOnNextPersist;
5622
5700
  this.#ensuredOnDisk = previous.ensuredOnDisk;
5623
- this.#inMemoryArtifacts = previous.inMemoryArtifacts;
5624
- this.#inMemoryArtifactCounter = previous.inMemoryArtifactCounter;
5625
5701
  this.#artifactManager = previous.artifactManager;
5626
5702
  this.#artifactManagerSessionFile = previous.artifactManagerSessionFile;
5627
5703
  this.#adoptedArtifactManager = previous.adoptedArtifactManager;
@@ -6067,12 +6143,12 @@ export class SessionManager {
6067
6143
  this.#flushed = stage.flushed;
6068
6144
  this.#needsFullRewriteOnNextPersist = false;
6069
6145
  this.#ensuredOnDisk = stage.flushed;
6070
- this.#inMemoryArtifacts = null;
6071
- this.#inMemoryArtifactCounter = 0;
6146
+
6072
6147
  this.#artifactManager = null;
6073
6148
  this.#artifactManagerSessionFile = null;
6074
6149
  this.#adoptedArtifactManager = null;
6075
6150
  this.#commitResidentTextStoreTransition(transition);
6151
+ this.#retireEphemeralArtifacts();
6076
6152
  stage.committed = true;
6077
6153
  this.#preparedNewSessions.delete(stage);
6078
6154
  }
@@ -6883,6 +6959,7 @@ export class SessionManager {
6883
6959
  const prepared = this.#prepareFreshSessionTransition(fresh, "memory-fallback");
6884
6960
  this.#applyFreshSessionMetadata(fresh);
6885
6961
  this.#commitResidentTextStoreTransition(prepared);
6962
+ this.#retireEphemeralArtifacts();
6886
6963
  if (writeBreadcrumb && fresh.sessionFile) writeTerminalBreadcrumb(this.cwd, fresh.sessionFile);
6887
6964
  return fresh.sessionFile;
6888
6965
  }
@@ -7605,6 +7682,8 @@ export class SessionManager {
7605
7682
  this.#flushed = true;
7606
7683
  }
7607
7684
  });
7685
+ this.#retireEphemeralArtifacts();
7686
+ await this.#drainEphemeralArtifactCleanups();
7608
7687
  this.#releaseResidentTextStore();
7609
7688
  this.#releaseOwnedManagedAuthority();
7610
7689
  if (this.#persistError) throw this.#persistError;
@@ -7664,6 +7743,12 @@ export class SessionManager {
7664
7743
  // close leaves the session live for a genuine retry.
7665
7744
  if (!this.#persistWriter) {
7666
7745
  this.#releaseResidentTextStore();
7746
+ this.#retireEphemeralArtifacts();
7747
+ try {
7748
+ await this.#drainEphemeralArtifactCleanups();
7749
+ } catch (error) {
7750
+ outcome = { kind: "close_unknown", error: toError(error) };
7751
+ }
7667
7752
  }
7668
7753
  return outcome;
7669
7754
  }
@@ -7952,14 +8037,40 @@ export class SessionManager {
7952
8037
  this.#adoptedArtifactManager = manager;
7953
8038
  }
7954
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
+
7955
8059
  /**
7956
8060
  * Returns the ArtifactManager this session writes through. Lazily creates
7957
8061
  * one bound to the current session file unless an external manager was
7958
- * adopted via `adoptArtifactManager`. Returns null only for non-persistent
7959
- * 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.
7960
8066
  */
7961
8067
  getArtifactManager(): ArtifactManager | null {
7962
- 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());
7963
8074
  }
7964
8075
 
7965
8076
  /**
@@ -8002,28 +8113,89 @@ export class SessionManager {
8002
8113
 
8003
8114
  /**
8004
8115
  * Save artifact content under the current session and return artifact ID.
8005
- * 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.
8006
8119
  */
8007
8120
  async saveArtifact(content: string, toolType: string): Promise<string | undefined> {
8008
- const manager = this.#getOrCreateArtifactManager();
8009
- if (manager) return manager.save(content, toolType);
8010
- // Non-persistent session: store in memory so spill truncation can proceed.
8011
- if (!this.#inMemoryArtifacts) this.#inMemoryArtifacts = new Map();
8012
- const id = String(this.#inMemoryArtifactCounter++);
8013
- this.#inMemoryArtifacts.set(id, content);
8014
- return id;
8121
+ const manager = this.#getOrCreateArtifactManager() ?? (await this.#ensureEphemeralArtifactManager());
8122
+ return manager ? manager.save(content, toolType) : undefined;
8015
8123
  }
8016
8124
 
8017
8125
  /**
8018
8126
  * Resolve an artifact ID to an on-disk path for the current session.
8019
- * Returns null when missing or when the session is not persisted.
8127
+ * Returns null when the artifact is missing.
8020
8128
  */
8021
8129
  async getArtifactPath(id: string): Promise<string | null> {
8022
- const manager = this.#getOrCreateArtifactManager();
8130
+ const manager = this.getArtifactManager();
8023
8131
  if (!manager) return null;
8024
8132
  return manager.getPath(id);
8025
8133
  }
8026
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
+
8027
8199
  /**
8028
8200
  * Path to the unsent-input draft sidecar for an explicit session. It lives
8029
8201
  * inside the artifacts directory so `dropSession` removes it with its owner.
@@ -8157,20 +8329,26 @@ export class SessionManager {
8157
8329
  await this.#persistPatch({ type: "header_patch", patch });
8158
8330
  }
8159
8331
 
8160
- #appendManagedRecordSync(record: FileEntry | SessionPatchRecord): void {
8332
+ #appendManagedRecordsSync(records: readonly (FileEntry | SessionPatchRecord)[]): void {
8161
8333
  if (!this.#sessionFile) throw new Error("Managed transcript path is unavailable");
8162
8334
  const store = this.#managedTranscriptStore();
8163
8335
  const relativePath = path.basename(this.#sessionFile);
8164
- 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);
8165
8338
  }
8166
8339
 
8167
8340
  async #persistPatch(record: SessionPatchRecord): Promise<void> {
8168
- 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;
8169
8347
  await this.#queuePersistTask(async () => {
8170
8348
  const header = this.#fileEntries.find(entry => entry.type === "session") as SessionHeader | undefined;
8171
8349
  if (this.#needsFullRewriteOnNextPersist || !this.#flushed || (header?.version ?? 1) < CURRENT_SESSION_VERSION)
8172
8350
  return this.#rewriteFileContents();
8173
- const persistedRecord =
8351
+ const persistedRecords = records.map(record =>
8174
8352
  record.type === "entry_patch"
8175
8353
  ? (prepareEntryForPersistenceSync(
8176
8354
  materializeResidentEntryForPersistenceSync(
@@ -8180,14 +8358,17 @@ export class SessionManager {
8180
8358
  ),
8181
8359
  this.#blobStore,
8182
8360
  ) as unknown as SessionPatchRecord)
8183
- : record;
8361
+ : record,
8362
+ );
8184
8363
  if (this.destination.kind === "managed") {
8185
- this.#appendManagedRecordSync(persistedRecord);
8364
+ this.#appendManagedRecordsSync(persistedRecords);
8186
8365
  return;
8187
8366
  }
8188
8367
  const writer = this.#ensurePersistWriter();
8189
8368
  if (!writer) return this.#rewriteFileContents();
8190
- await writer.write(persistedRecord);
8369
+ for (const persistedRecord of persistedRecords) {
8370
+ await writer.write(persistedRecord);
8371
+ }
8191
8372
  });
8192
8373
  }
8193
8374
  _persist(entry: SessionEntry): void {
@@ -8236,7 +8417,7 @@ export class SessionManager {
8236
8417
  new Map(),
8237
8418
  );
8238
8419
  const persistedEntry = prepareEntryForPersistenceSync(materializedEntry, this.#blobStore);
8239
- this.#appendManagedRecordSync(persistedEntry);
8420
+ this.#appendManagedRecordsSync([persistedEntry]);
8240
8421
  return;
8241
8422
  }
8242
8423
  const writer = this.#ensurePersistWriter();
@@ -8265,13 +8446,31 @@ export class SessionManager {
8265
8446
  this.#assertRecoveryHydrationWritable();
8266
8447
  const normalizedEntry = normalizeSessionEntryForStorage(entry);
8267
8448
  const residentEntry = this.#prepareEntryForCurrentResidentStore(normalizedEntry) as SessionEntry;
8449
+ const previousLeafId = this.#leafId;
8450
+ const priorPersistenceError = this.#persistError;
8268
8451
  this.#fileEntries.push(residentEntry);
8269
8452
  this.#byId.set(residentEntry.id, residentEntry);
8270
8453
  this.#leafId = residentEntry.id;
8271
8454
  this.#bumpEntryRevision();
8272
8455
  this.#leafRevision++;
8273
8456
  if (entry.type === "label") this.#labelRevision++;
8274
- this._persist(residentEntry);
8457
+ try {
8458
+ this._persist(residentEntry);
8459
+ } catch (error) {
8460
+ const removed = this.#fileEntries.pop();
8461
+ if (removed !== residentEntry)
8462
+ throw new Error("Session append rollback lost resident ordering.", { cause: error });
8463
+ this.#byId.delete(residentEntry.id);
8464
+ this.#leafId = previousLeafId;
8465
+ this.#bumpEntryRevision();
8466
+ this.#leafRevision++;
8467
+ if (entry.type === "label") this.#labelRevision++;
8468
+ throw new SessionAppendPersistenceError(
8469
+ priorPersistenceError ? "prior_failure" : "current_append",
8470
+ residentEntry.id,
8471
+ this.#persistError ?? toError(error),
8472
+ );
8473
+ }
8275
8474
  // Same validated, overflow-guarded aggregation as the resume path (#buildIndex): a
8276
8475
  // malformed task-result or assistant usage shape reaching the append path must not
8277
8476
  // crash or poison getUsageStatistics() either.
@@ -8513,6 +8712,50 @@ export class SessionManager {
8513
8712
  this.#replayMetadataRevision++;
8514
8713
  }
8515
8714
 
8715
+ /**
8716
+ * Rehydrate the canonical transcript after a synchronous persistence failure.
8717
+ *
8718
+ * The failed append may have committed before reporting an uncertain outcome,
8719
+ * so callers must not clear the sticky error or retry against the resident
8720
+ * branch. Reloading the exact session file is the only supported recovery
8721
+ * boundary for both managed and explicit persistent destinations.
8722
+ */
8723
+ async recoverPersistenceFailure(): Promise<void> {
8724
+ const persistenceError = this.#persistError;
8725
+ if (!persistenceError) return;
8726
+ if (!this.#sessionFile) throw persistenceError;
8727
+ const sessionFile = this.#sessionFile;
8728
+ try {
8729
+ await this.#closePersistWriter();
8730
+ } catch {
8731
+ // A writer reports its prior write failure after a confirmed close. A
8732
+ // second close clears that terminal writer; retryable close failures are
8733
+ // genuinely re-dispatched and still fail if the descriptor cannot settle.
8734
+ }
8735
+ await this.#closePersistWriter();
8736
+ const inspected = inspectResumeSessionFile(sessionFile, this.storage);
8737
+ if ("kind" in inspected) throw persistenceError;
8738
+ try {
8739
+ fsyncResumeSessionIdentity(inspected.identity);
8740
+ } catch {
8741
+ throw persistenceError;
8742
+ }
8743
+ const durable = inspectResumeSessionFile(sessionFile, this.storage);
8744
+ if ("kind" in durable || !sameResumeIdentity(inspected.identity, durable.identity)) {
8745
+ throw persistenceError;
8746
+ }
8747
+ const adoption = {
8748
+ canonicalPath: durable.identity.canonicalPath,
8749
+ identity: durable.identity,
8750
+ inspection: durable,
8751
+ };
8752
+ this.#pendingStrictAdoption = adoption;
8753
+ try {
8754
+ await this.setSessionFile(durable.identity.canonicalPath);
8755
+ } finally {
8756
+ if (this.#pendingStrictAdoption === adoption) this.#pendingStrictAdoption = undefined;
8757
+ }
8758
+ }
8516
8759
  /**
8517
8760
  * Rewrite the session file after in-place entry updates.
8518
8761
  * Use sparingly (e.g., pruning old tool outputs).
@@ -9094,9 +9337,7 @@ export class SessionManager {
9094
9337
 
9095
9338
  async #sanitizeLoadedOpenAIResponsesReplayMetadataAndPersist(): Promise<boolean> {
9096
9339
  const patches = this.#sanitizeLoadedOpenAIResponsesReplayMetadata();
9097
- for (const patch of patches) {
9098
- await this.#persistPatch(patch);
9099
- }
9340
+ await this.#persistPatches(patches);
9100
9341
  return patches.length > 0;
9101
9342
  }
9102
9343
 
@@ -9669,6 +9910,7 @@ export class SessionManager {
9669
9910
  );
9670
9911
  manager.#applyFreshSessionMetadata(fresh);
9671
9912
  manager.#commitResidentTextStoreTransition(transition);
9913
+ manager.#retireEphemeralArtifacts();
9672
9914
  if (fresh.sessionFile) writeTerminalBreadcrumb(manager.cwd, fresh.sessionFile);
9673
9915
  manager.sanitizeLoadedOpenAIResponsesReplayMetadata();
9674
9916
  await manager.#rewriteFile();
@@ -9771,6 +10013,7 @@ export class SessionManager {
9771
10013
  const transition = manager.#prepareFreshSessionTransition(fresh, "memory-fallback");
9772
10014
  manager.#applyFreshSessionMetadata(fresh);
9773
10015
  manager.#commitResidentTextStoreTransition(transition);
10016
+ manager.#retireEphemeralArtifacts();
9774
10017
  writeTerminalBreadcrumb(manager.cwd, resolved);
9775
10018
  await manager.#rewriteFile();
9776
10019
  manager.#flushed = true;
@@ -9962,6 +10205,7 @@ export class SessionManager {
9962
10205
  );
9963
10206
  manager.#applyFreshSessionMetadata(fresh);
9964
10207
  manager.#commitResidentTextStoreTransition(transition);
10208
+ manager.#retireEphemeralArtifacts();
9965
10209
  manager.sanitizeLoadedOpenAIResponsesReplayMetadata();
9966
10210
  const beforeWrite = inspectResumeSessionFile(snapshot.sourcePath, snapshot.storage);
9967
10211
  if ("kind" in beforeWrite) {
@@ -28,6 +28,7 @@ export interface SessionStorageStat {
28
28
  size: number;
29
29
  mtimeMs: number;
30
30
  mtimeNs: bigint;
31
+ ctimeNs: bigint;
31
32
  mtime: Date;
32
33
  isFile: boolean;
33
34
  }
@@ -47,6 +48,7 @@ function statFromNode(stats: fs.BigIntStats): SessionStorageStat {
47
48
  size: Number(stats.size),
48
49
  mtimeMs: Number(stats.mtimeMs),
49
50
  mtimeNs: stats.mtimeNs,
51
+ ctimeNs: stats.ctimeNs,
50
52
  mtime: stats.mtime,
51
53
  isFile: stats.isFile(),
52
54
  };
@@ -1624,6 +1626,7 @@ export class MemorySessionStorage implements SessionStorage {
1624
1626
  size: entry.content.byteLength,
1625
1627
  mtimeMs: entry.mtimeMs,
1626
1628
  mtimeNs: BigInt(entry.mtimeMs) * 1_000_000n,
1629
+ ctimeNs: BigInt(entry.mtimeMs) * 1_000_000n,
1627
1630
  mtime: new Date(entry.mtimeMs),
1628
1631
  isFile: true,
1629
1632
  };
@@ -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
  ]