@gajae-code/coding-agent 0.11.9 → 0.11.11

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 (150) hide show
  1. package/CHANGELOG.md +56 -1
  2. package/dist/types/cli.d.ts +8 -1
  3. package/dist/types/config/model-profile-activation.d.ts +1 -0
  4. package/dist/types/config/model-profile-contract.d.ts +2 -0
  5. package/dist/types/config/model-registry.d.ts +2 -0
  6. package/dist/types/config/settings-schema.d.ts +57 -2
  7. package/dist/types/config/settings.d.ts +1 -0
  8. package/dist/types/extensibility/extensions/types.d.ts +21 -0
  9. package/dist/types/gjc-runtime/linux-proc.d.ts +22 -14
  10. package/dist/types/gjc-runtime/ralplan-runtime.d.ts +60 -0
  11. package/dist/types/gjc-runtime/state-runtime.d.ts +3 -1
  12. package/dist/types/gjc-runtime/state-writer.d.ts +1 -0
  13. package/dist/types/modes/acp/acp-agent.d.ts +14 -9
  14. package/dist/types/modes/components/queue-pane.d.ts +8 -0
  15. package/dist/types/modes/components/queued-message-selector.d.ts +6 -0
  16. package/dist/types/modes/components/welcome.d.ts +1 -0
  17. package/dist/types/modes/controllers/selector-controller.d.ts +16 -8
  18. package/dist/types/modes/interactive-mode.d.ts +5 -1
  19. package/dist/types/runtime/memory-domain.d.ts +9 -0
  20. package/dist/types/runtime/memory-guard-contract.d.ts +71 -0
  21. package/dist/types/runtime/memory-guard.d.ts +39 -0
  22. package/dist/types/runtime/memory-limit.d.ts +11 -0
  23. package/dist/types/runtime-mcp/manager.d.ts +8 -0
  24. package/dist/types/sdk/acp/final-text.d.ts +19 -0
  25. package/dist/types/sdk/acp/mcp.d.ts +23 -0
  26. package/dist/types/sdk/broker/lifecycle.d.ts +2 -0
  27. package/dist/types/sdk/bus/chat-daemon-control.d.ts +2 -1
  28. package/dist/types/sdk/bus/daemon-paths.d.ts +1 -0
  29. package/dist/types/sdk/bus/index.d.ts +11 -2
  30. package/dist/types/sdk/bus/operator-runtime.d.ts +1 -0
  31. package/dist/types/sdk/bus/rate-limit-pool.d.ts +2 -0
  32. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +14 -7
  33. package/dist/types/sdk/bus/telegram-daemon.d.ts +97 -8
  34. package/dist/types/sdk/host/control/operations.d.ts +1 -0
  35. package/dist/types/sdk/lifecycle-session.d.ts +10 -1
  36. package/dist/types/sdk/session.d.ts +7 -0
  37. package/dist/types/sdk/startup-capability.d.ts +6 -0
  38. package/dist/types/session/agent-session.d.ts +11 -11
  39. package/dist/types/session/fallback-chain-controller.d.ts +18 -2
  40. package/dist/types/session/internal/managed-session-scope.d.ts +28 -1
  41. package/dist/types/session/session-manager.d.ts +34 -0
  42. package/dist/types/task/executor.d.ts +6 -0
  43. package/dist/types/task/provider-retry-status.d.ts +13 -0
  44. package/dist/types/task/types.d.ts +39 -0
  45. package/dist/types/task/ultragoal-redteam-activation.d.ts +33 -0
  46. package/dist/types/tools/index.d.ts +3 -1
  47. package/dist/types/tools/resource-gc.d.ts +19 -0
  48. package/package.json +8 -8
  49. package/scripts/generate-sdk-operation-inventory.ts +3 -0
  50. package/scripts/run-test-manifest.ts +6 -4
  51. package/src/cli/args.ts +14 -8
  52. package/src/cli/fast-help.ts +1 -1
  53. package/src/cli/notify-cli.ts +1 -0
  54. package/src/cli.ts +48 -2
  55. package/src/commands/sdk.ts +91 -7
  56. package/src/config/model-profile-activation.ts +1 -0
  57. package/src/config/model-profile-contract.ts +13 -0
  58. package/src/config/model-profiles.ts +31 -7
  59. package/src/config/model-registry.ts +4 -2
  60. package/src/config/settings-schema.ts +61 -2
  61. package/src/config/settings.ts +8 -0
  62. package/src/defaults/gjc/skills/ralplan/SKILL.md +21 -2
  63. package/src/defaults/gjc/skills/ultragoal/SKILL.md +1 -1
  64. package/src/eval/js/tool-bridge.ts +1 -1
  65. package/src/extensibility/extensions/runner.ts +4 -0
  66. package/src/extensibility/extensions/types.ts +14 -0
  67. package/src/gjc-runtime/deep-interview-ambiguity.ts +16 -2
  68. package/src/gjc-runtime/deep-interview-draft.ts +2 -1
  69. package/src/gjc-runtime/deep-interview-recorder.ts +16 -1
  70. package/src/gjc-runtime/deep-interview-state.ts +25 -3
  71. package/src/gjc-runtime/linux-proc.ts +70 -36
  72. package/src/gjc-runtime/ralplan-runtime.ts +252 -1
  73. package/src/gjc-runtime/state-runtime.ts +48 -11
  74. package/src/gjc-runtime/state-writer.ts +1 -1
  75. package/src/hooks/skill-state.ts +23 -1
  76. package/src/internal-urls/docs-index.generated.ts +7 -7
  77. package/src/internal-urls/local-protocol.ts +23 -4
  78. package/src/modes/acp/acp-agent.ts +554 -60
  79. package/src/modes/acp/acp-event-mapper.ts +93 -6
  80. package/src/modes/components/model-selector.ts +15 -1
  81. package/src/modes/components/notifications-settings-editor.ts +1 -1
  82. package/src/modes/components/queue-pane.ts +55 -9
  83. package/src/modes/components/queued-message-selector.ts +53 -10
  84. package/src/modes/components/tool-status-header.ts +1 -4
  85. package/src/modes/components/welcome.ts +18 -3
  86. package/src/modes/controllers/command-controller.ts +4 -0
  87. package/src/modes/controllers/extension-ui-controller.ts +4 -10
  88. package/src/modes/controllers/input-controller.ts +44 -3
  89. package/src/modes/controllers/selector-controller.ts +38 -8
  90. package/src/modes/interactive-mode.ts +65 -26
  91. package/src/modes/runtime-init.ts +1 -0
  92. package/src/prompts/agent-fragments/restricted-bash.md +1 -1
  93. package/src/prompts/agents/architect.md +8 -1
  94. package/src/prompts/agents/critic.md +8 -1
  95. package/src/prompts/agents/executor.md +1 -1
  96. package/src/prompts/agents/planner.md +8 -1
  97. package/src/runtime/memory-domain.ts +66 -0
  98. package/src/runtime/memory-guard-contract.ts +73 -0
  99. package/src/runtime/memory-guard.ts +292 -0
  100. package/src/runtime/memory-limit.ts +50 -0
  101. package/src/runtime-mcp/manager.ts +26 -6
  102. package/src/sdk/acp/adapter.ts +8 -1
  103. package/src/sdk/acp/final-text.ts +40 -0
  104. package/src/sdk/acp/mcp.ts +26 -0
  105. package/src/sdk/broker/identity.ts +22 -12
  106. package/src/sdk/broker/lifecycle.ts +82 -1
  107. package/src/sdk/bus/chat-daemon-cli.ts +2 -2
  108. package/src/sdk/bus/chat-daemon-control.ts +4 -3
  109. package/src/sdk/bus/config.ts +1 -1
  110. package/src/sdk/bus/daemon-paths.ts +2 -0
  111. package/src/sdk/bus/index.ts +222 -27
  112. package/src/sdk/bus/notification-service.ts +19 -5
  113. package/src/sdk/bus/operator-runtime.ts +31 -5
  114. package/src/sdk/bus/rate-limit-pool.ts +8 -0
  115. package/src/sdk/bus/telegram-cli.ts +6 -13
  116. package/src/sdk/bus/telegram-daemon-cli.ts +8 -3
  117. package/src/sdk/bus/telegram-daemon-contract.ts +15 -7
  118. package/src/sdk/bus/telegram-daemon-control.ts +14 -5
  119. package/src/sdk/bus/telegram-daemon.ts +1191 -164
  120. package/src/sdk/host/control/dispatch.ts +2 -0
  121. package/src/sdk/host/control/operations.ts +1 -0
  122. package/src/sdk/host/host.ts +2 -2
  123. package/src/sdk/lifecycle-session.ts +19 -3
  124. package/src/sdk/protocol/operation-inventory.generated.json +58 -0
  125. package/src/sdk/protocol/operation-registry.ts +1 -0
  126. package/src/sdk/session.ts +24 -1
  127. package/src/sdk/startup-capability.ts +6 -0
  128. package/src/session/agent-session.ts +290 -132
  129. package/src/session/fallback-chain-controller.ts +47 -8
  130. package/src/session/internal/managed-session-scope.ts +173 -127
  131. package/src/session/internal/native-publish-outcome.ts +8 -3
  132. package/src/session/session-manager.ts +608 -25
  133. package/src/skill-state/workflow-mutation-guard.ts +41 -7
  134. package/src/task/executor.ts +152 -42
  135. package/src/task/index.ts +2 -0
  136. package/src/task/provider-retry-status.ts +88 -0
  137. package/src/task/render.ts +45 -26
  138. package/src/task/types.ts +25 -11
  139. package/src/task/ultragoal-redteam-activation.ts +75 -0
  140. package/src/tools/bash-allowed-prefixes.ts +45 -1
  141. package/src/tools/index.ts +3 -1
  142. package/src/tools/resource-gc.ts +496 -120
  143. package/src/tools/subagent-render.ts +74 -16
  144. package/src/tools/subagent.ts +2 -0
  145. package/src/workflow/workflow-intent-diff.ts +4 -1
  146. package/vendor/insane-search/MANIFEST.json +4 -1
  147. package/vendor/insane-search/engine/bias_check.py +5 -0
  148. package/vendor/insane-search/engine/fetch_chain.py +67 -1
  149. package/vendor/insane-search/engine/transport.py +13 -2
  150. package/vendor/insane-search/engine/validators.py +4 -1
@@ -134,6 +134,36 @@ export interface NewSessionOptions {
134
134
  drop?: boolean;
135
135
  }
136
136
 
137
+ /** Internal successor prepared without changing the manager's visible identity. */
138
+ export interface PreparedNewSession {
139
+ readonly sessionId: string;
140
+ readonly sessionFile: string | undefined;
141
+ readonly artifactsDir: string | null;
142
+ readonly managedLegacyLocalMigrationSource: ManagedLegacyLocalMigrationSource | null;
143
+ }
144
+
145
+ interface PreparedSessionIndex {
146
+ readonly byId: Map<string, SessionEntry>;
147
+ readonly labelsById: Map<string, string>;
148
+ readonly leafId: string | null;
149
+ readonly usageStatistics: UsageStatistics;
150
+ }
151
+
152
+ interface PreparedNewSessionState extends PreparedNewSession {
153
+ readonly header: SessionHeader;
154
+ readonly fileEntries: FileEntry[];
155
+ readonly sessionName: string | undefined;
156
+ readonly titleSource: "auto" | "user" | undefined;
157
+ residentFileEntries?: FileEntry[];
158
+ residentTextBlobStore?: BlobStore;
159
+ index?: PreparedSessionIndex;
160
+ flushed: boolean;
161
+ committed: boolean;
162
+ discarded: boolean;
163
+ persistenceWriter?: NdjsonFileWriter;
164
+ persistenceTempPath?: string;
165
+ }
166
+
137
167
  export interface SessionEntryBase {
138
168
  type: string;
139
169
  id: string;
@@ -3977,6 +4007,7 @@ export class SessionManager {
3977
4007
  #sessionId: string = "";
3978
4008
  /** True once a lifecycle pre-allocated id has been adopted (consume-once). */
3979
4009
  #lifecycleIdAdopted: boolean = false;
4010
+ #preparedNewSessions = new Set<PreparedNewSessionState>();
3980
4011
  #sessionName: string | undefined;
3981
4012
  #titleSource: "auto" | "user" | undefined;
3982
4013
  #sessionFile: string | undefined;
@@ -4002,6 +4033,8 @@ export class SessionManager {
4002
4033
  #persistChain: Promise<void> = Promise.resolve();
4003
4034
  #persistError: Error | undefined;
4004
4035
  #persistErrorReported = false;
4036
+ /** Failed staged persistence retains its exact writer and temporary pathname for retryable cleanup. */
4037
+ #preparedNewSessionCleanupInProgress = false;
4005
4038
 
4006
4039
  #artifactManager: ArtifactManager | null = null;
4007
4040
  #artifactManagerSessionFile: string | null = null;
@@ -4106,6 +4139,21 @@ export class SessionManager {
4106
4139
  this.#buildIndex();
4107
4140
  }
4108
4141
 
4142
+ #preparePreparedNewSessionForCommit(stage: PreparedNewSessionState): void {
4143
+ const residentTextBlobStore = new MemoryBlobStore();
4144
+ const residentFileEntries = stage.fileEntries.map(entry =>
4145
+ prepareEntryForResidentSync(entry, {
4146
+ textStore: residentTextBlobStore,
4147
+ imageStore: this.#residentImageBlobStore,
4148
+ sessionId: stage.sessionId,
4149
+ sessionFile: stage.sessionFile,
4150
+ }),
4151
+ );
4152
+ stage.residentTextBlobStore = residentTextBlobStore;
4153
+ stage.residentFileEntries = residentFileEntries;
4154
+ stage.index = this.#buildIndexForEntries(residentFileEntries, stage.sessionFile);
4155
+ }
4156
+
4109
4157
  #resetMaterializedCaches(): void {
4110
4158
  this.#materializedEntriesRevision = -1;
4111
4159
  this.#materializedEntriesCache = undefined;
@@ -4313,10 +4361,492 @@ export class SessionManager {
4313
4361
 
4314
4362
  /** Start a new session. Closes any existing writer first. */
4315
4363
  async newSession(options?: NewSessionOptions): Promise<string | undefined> {
4364
+ const prepared = await this.prepareNewSession(options);
4365
+ try {
4366
+ this.commitPreparedNewSession(prepared);
4367
+ } catch (error) {
4368
+ try {
4369
+ await this.discardPreparedNewSession(prepared);
4370
+ } catch (cleanupError) {
4371
+ throw new AggregateError(
4372
+ [error, cleanupError],
4373
+ "New session adoption and staged session cleanup both failed.",
4374
+ );
4375
+ }
4376
+ throw error;
4377
+ }
4378
+ return prepared.sessionFile;
4379
+ }
4380
+
4381
+ /**
4382
+ * Allocate a fresh successor without publishing it through the manager's public
4383
+ * getters. The returned authority is deliberately immutable so readiness work
4384
+ * can resolve local:// against the successor while the predecessor stays live.
4385
+ * @internal
4386
+ */
4387
+ async prepareNewSession(options?: NewSessionOptions): Promise<PreparedNewSession> {
4388
+ await this.#retryPreparedNewSessionCleanups();
4389
+ await this.#closePersistWriter();
4390
+ const preallocated = this.#lifecycleIdAdopted ? undefined : lifecyclePreallocatedSessionId();
4391
+ const sessionId = preallocated ?? createSessionId();
4392
+ const timestamp = new Date().toISOString();
4393
+ const sessionFile = this.persist
4394
+ ? path.join(this.getSessionDir(), `${timestamp.replace(/[:.]/g, "-")}_${sessionId}.jsonl`)
4395
+ : undefined;
4396
+ const header: SessionHeader = {
4397
+ type: "session",
4398
+ version: CURRENT_SESSION_VERSION,
4399
+ id: sessionId,
4400
+ timestamp,
4401
+ cwd: this.cwd,
4402
+ parentSession: options?.parentSession,
4403
+ };
4404
+ const stage: PreparedNewSessionState = {
4405
+ sessionId,
4406
+ sessionFile,
4407
+ artifactsDir: sessionFile ? sessionFile.slice(0, -6) : null,
4408
+ managedLegacyLocalMigrationSource: this.#managedLegacyLocalMigrationSourceFor(sessionFile),
4409
+ header,
4410
+ fileEntries: [header],
4411
+ sessionName: undefined,
4412
+ titleSource: undefined,
4413
+ flushed: false,
4414
+ committed: false,
4415
+ discarded: false,
4416
+ };
4417
+ this.#preparedNewSessions.add(stage);
4418
+ this.#freezePreparedNewSessionIdentity(stage);
4419
+ return stage;
4420
+ }
4421
+
4422
+ /** Append a model selection to an unpublished successor. @internal */
4423
+ appendPreparedModelChange(prepared: PreparedNewSession, model: string): string {
4424
+ const stage = this.#getPreparedNewSessionStage(prepared);
4425
+ const entry: ModelChangeEntry = {
4426
+ type: "model_change",
4427
+ id: this.#nextPreparedNewSessionEntryId(stage),
4428
+ parentId: this.#preparedNewSessionLeafId(stage),
4429
+ timestamp: new Date().toISOString(),
4430
+ model,
4431
+ };
4432
+ stage.fileEntries.push(entry);
4433
+ return entry.id;
4434
+ }
4435
+
4436
+ /** Append a thinking-level selection to an unpublished successor. @internal */
4437
+ appendPreparedThinkingLevelChange(prepared: PreparedNewSession, thinkingLevel?: string): string {
4438
+ const stage = this.#getPreparedNewSessionStage(prepared);
4439
+ const entry: ThinkingLevelChangeEntry = {
4440
+ type: "thinking_level_change",
4441
+ id: this.#nextPreparedNewSessionEntryId(stage),
4442
+ parentId: this.#preparedNewSessionLeafId(stage),
4443
+ timestamp: new Date().toISOString(),
4444
+ thinkingLevel: thinkingLevel ?? null,
4445
+ };
4446
+ stage.fileEntries.push(entry);
4447
+ return entry.id;
4448
+ }
4449
+
4450
+ /** Append a service-tier selection to an unpublished successor. @internal */
4451
+ appendPreparedServiceTierChange(prepared: PreparedNewSession, serviceTier: ServiceTier | null): string {
4452
+ const stage = this.#getPreparedNewSessionStage(prepared);
4453
+ const entry: ServiceTierChangeEntry = {
4454
+ type: "service_tier_change",
4455
+ id: this.#nextPreparedNewSessionEntryId(stage),
4456
+ parentId: this.#preparedNewSessionLeafId(stage),
4457
+ timestamp: new Date().toISOString(),
4458
+ serviceTier,
4459
+ };
4460
+ stage.fileEntries.push(entry);
4461
+ return entry.id;
4462
+ }
4463
+
4464
+ /** Append a displayable custom message to an unpublished successor. @internal */
4465
+ appendPreparedCustomMessageEntry<T = unknown>(
4466
+ prepared: PreparedNewSession,
4467
+ customType: string,
4468
+ content: string | (TextContent | ImageContent)[],
4469
+ display: boolean,
4470
+ details?: T,
4471
+ attribution: MessageAttribution = "agent",
4472
+ ): string {
4473
+ const stage = this.#getPreparedNewSessionStage(prepared);
4474
+ const entry: CustomMessageEntry<T> = {
4475
+ type: "custom_message",
4476
+ customType,
4477
+ content,
4478
+ display,
4479
+ details: stripInternalDetailsFields(details),
4480
+ attribution,
4481
+ id: this.#nextPreparedNewSessionEntryId(stage),
4482
+ parentId: this.#preparedNewSessionLeafId(stage),
4483
+ timestamp: new Date().toISOString(),
4484
+ };
4485
+ stage.fileEntries.push(entry);
4486
+ return entry.id;
4487
+ }
4488
+
4489
+ /** Persist an unpublished successor without adopting it. @internal */
4490
+ async ensurePreparedNewSessionOnDisk(prepared: PreparedNewSession): Promise<void> {
4491
+ const stage = this.#getPreparedNewSessionStage(prepared);
4492
+ if (!this.persist || !stage.sessionFile || stage.flushed) return;
4493
+ const entries = await Promise.all(
4494
+ stage.fileEntries.map(entry => prepareEntryForPersistence(entry, this.#blobStore)),
4495
+ );
4496
+ if (this.destination.kind === "managed") {
4497
+ const bytes = Buffer.from(`${entries.map(entry => JSON.stringify(entry)).join("\n")}\n`, "utf8");
4498
+ await this.#managedTranscriptStore(stage.sessionFile).replace(path.basename(stage.sessionFile), bytes);
4499
+ } else {
4500
+ const staleCleanupError = await this.#cleanupPreparedNewSessionPersistence(stage);
4501
+ if (staleCleanupError) throw staleCleanupError;
4502
+ const dir = path.resolve(stage.sessionFile, "..");
4503
+ const tempPath = path.join(dir, `.${path.basename(stage.sessionFile)}.${Snowflake.next()}.tmp`);
4504
+ const writer = new NdjsonFileWriter(this.storage, tempPath, { flags: "w" });
4505
+ stage.persistenceTempPath = tempPath;
4506
+ stage.persistenceWriter = writer;
4507
+ try {
4508
+ for (const entry of entries) await writer.write(entry);
4509
+ await writer.flush();
4510
+ await writer.fsync();
4511
+ await writer.close();
4512
+ stage.persistenceWriter = undefined;
4513
+ await this.#replaceSessionFile(tempPath, stage.sessionFile);
4514
+ stage.persistenceTempPath = undefined;
4515
+ } catch (error) {
4516
+ const cleanupError = await this.#cleanupPreparedNewSessionPersistence(stage);
4517
+ if (cleanupError)
4518
+ throw new AggregateError(
4519
+ [toError(error), cleanupError],
4520
+ "Prepared session persistence and temporary-file cleanup both failed.",
4521
+ );
4522
+ throw toError(error);
4523
+ }
4524
+ }
4525
+ stage.flushed = true;
4526
+ }
4527
+
4528
+ /** Build context from an unpublished successor without reading active manager state. @internal */
4529
+ buildPreparedNewSessionContext(prepared: PreparedNewSession): SessionContext {
4530
+ const stage = this.#getPreparedNewSessionStage(prepared);
4531
+ return buildSessionContext(
4532
+ stage.fileEntries.filter((entry): entry is SessionEntry => entry.type !== "session"),
4533
+ undefined,
4534
+ undefined,
4535
+ stage.sessionId,
4536
+ );
4537
+ }
4538
+
4539
+ #freezePreparedNewSessionIdentity(stage: PreparedNewSessionState): void {
4540
+ // Prevent identity ABA: callers must not rebind staged sessionId/file after prepare.
4541
+ Object.defineProperty(stage, "sessionId", {
4542
+ value: stage.sessionId,
4543
+ writable: false,
4544
+ enumerable: true,
4545
+ configurable: false,
4546
+ });
4547
+ Object.defineProperty(stage, "sessionFile", {
4548
+ value: stage.sessionFile,
4549
+ writable: false,
4550
+ enumerable: true,
4551
+ configurable: false,
4552
+ });
4553
+ Object.defineProperty(stage, "artifactsDir", {
4554
+ value: stage.artifactsDir,
4555
+ writable: false,
4556
+ enumerable: true,
4557
+ configurable: false,
4558
+ });
4559
+ Object.defineProperty(stage, "managedLegacyLocalMigrationSource", {
4560
+ value: stage.managedLegacyLocalMigrationSource,
4561
+ writable: false,
4562
+ enumerable: true,
4563
+ configurable: false,
4564
+ });
4565
+ }
4566
+
4567
+ #getPreparedNewSessionStage(prepared: PreparedNewSession): PreparedNewSessionState {
4568
+ const stage = prepared as PreparedNewSessionState;
4569
+ if (!this.#preparedNewSessions.has(stage) || stage.committed || stage.discarded)
4570
+ throw new Error("Prepared session is no longer available.");
4571
+ return stage;
4572
+ }
4573
+
4574
+ #preparedNewSessionLeafId(stage: PreparedNewSessionState): string | null {
4575
+ for (let index = stage.fileEntries.length - 1; index >= 0; index--) {
4576
+ const entry = stage.fileEntries[index];
4577
+ if (entry.type !== "session") return entry.id;
4578
+ }
4579
+ return null;
4580
+ }
4581
+
4582
+ #nextPreparedNewSessionEntryId(stage: PreparedNewSessionState): string {
4583
+ const ids = new Map<string, SessionEntry>();
4584
+ for (const entry of stage.fileEntries) {
4585
+ if (entry.type !== "session") ids.set(entry.id, entry);
4586
+ }
4587
+ return generateId(ids);
4588
+ }
4589
+
4590
+ /** Prepare a forked successor without publishing it through public manager state. @internal */
4591
+ async prepareFork(): Promise<PreparedNewSession | undefined> {
4592
+ await this.#retryPreparedNewSessionCleanups();
4593
+ if (!this.persist || !this.#sessionFile) return undefined;
4316
4594
  await this.#closePersistWriter();
4317
- const sessionFile = this.#newSessionSync(options);
4595
+ const sourceFile = this.#sessionFile;
4596
+ const sourceId = this.#sessionId;
4597
+ const timestamp = new Date().toISOString();
4598
+ const sessionId = createSessionId();
4599
+ const sessionFile = path.join(this.getSessionDir(), `${timestamp.replace(/[:.]/g, "-")}_${sessionId}.jsonl`);
4600
+ const header: SessionHeader = {
4601
+ type: "session",
4602
+ version: CURRENT_SESSION_VERSION,
4603
+ id: sessionId,
4604
+ title: this.#sessionName,
4605
+ titleSource: this.#titleSource,
4606
+ timestamp,
4607
+ cwd: this.cwd,
4608
+ parentSession: sourceId,
4609
+ };
4610
+ const entries: FileEntry[] = [
4611
+ header,
4612
+ ...materializeResidentEntriesForReadSync(this.#fileEntries, this.#residentBlobStores()).filter(
4613
+ (entry): entry is SessionEntry => entry.type !== "session",
4614
+ ),
4615
+ ];
4616
+ const stage: PreparedNewSessionState = {
4617
+ sessionId,
4618
+ sessionFile,
4619
+ artifactsDir: sessionFile.slice(0, -6),
4620
+ managedLegacyLocalMigrationSource: this.#managedLegacyLocalMigrationSourceFor(sessionFile),
4621
+ header,
4622
+ fileEntries: entries,
4623
+ sessionName: this.#sessionName,
4624
+ titleSource: this.#titleSource,
4625
+ flushed: true,
4626
+ committed: false,
4627
+ discarded: false,
4628
+ };
4629
+ this.#preparedNewSessions.add(stage);
4630
+ this.#freezePreparedNewSessionIdentity(stage);
4631
+ try {
4632
+ await this.copyArtifactsForFork(sourceFile, sessionFile);
4633
+ const content = `${entries.map(entry => JSON.stringify(prepareEntryForPersistenceSync(entry, this.#blobStore))).join("\n")}\n`;
4634
+ if (this.destination.kind === "managed") {
4635
+ this.#managedTranscriptStore(sessionFile).publishNoReplaceSync(
4636
+ path.basename(sessionFile),
4637
+ Buffer.from(content, "utf8"),
4638
+ );
4639
+ } else {
4640
+ this.storage.writeTextSync(sessionFile, content);
4641
+ }
4642
+ } catch (error) {
4643
+ try {
4644
+ await this.discardPreparedNewSession(stage);
4645
+ } catch (cleanupError) {
4646
+ throw new AggregateError(
4647
+ [toError(error), toError(cleanupError)],
4648
+ "Fork preparation and staged session cleanup both failed.",
4649
+ );
4650
+ }
4651
+ throw toError(error);
4652
+ }
4653
+ return stage;
4654
+ }
4655
+
4656
+ /** Prepare a path-only branch successor without publishing it through public manager state. @internal */
4657
+ async prepareBranchedSession(leafId: string): Promise<PreparedNewSession> {
4658
+ await this.#retryPreparedNewSessionCleanups();
4659
+ const branchPath = this.#getCanonicalBranchClones(leafId);
4660
+ if (branchPath.length === 0) throw new Error(`Entry ${leafId} not found`);
4661
+ await this.#closePersistWriter();
4662
+ const timestamp = new Date().toISOString();
4663
+ const sessionId = createSessionId();
4664
+ const sessionFile = this.persist
4665
+ ? path.join(this.getSessionDir(), `${timestamp.replace(/[:.]/g, "-")}_${sessionId}.jsonl`)
4666
+ : undefined;
4667
+ const header: SessionHeader = {
4668
+ type: "session",
4669
+ version: CURRENT_SESSION_VERSION,
4670
+ id: sessionId,
4671
+ timestamp,
4672
+ cwd: this.cwd,
4673
+ parentSession: this.persist ? this.#sessionFile : undefined,
4674
+ };
4675
+ const pathWithoutLabels = branchPath.filter(entry => entry.type !== "label");
4676
+ const pathEntryIds = new Set(pathWithoutLabels.map(entry => entry.id));
4677
+ const labelEntries: LabelEntry[] = [];
4678
+ let labelParentId = pathWithoutLabels[pathWithoutLabels.length - 1]?.id ?? null;
4679
+ for (const [targetId, label] of this.#labelsById) {
4680
+ if (!pathEntryIds.has(targetId)) continue;
4681
+ const labelEntry: LabelEntry = {
4682
+ type: "label",
4683
+ id: generateId({ has: id => pathEntryIds.has(id) }),
4684
+ parentId: labelParentId,
4685
+ timestamp: new Date().toISOString(),
4686
+ targetId,
4687
+ label,
4688
+ };
4689
+ pathEntryIds.add(labelEntry.id);
4690
+ labelEntries.push(labelEntry);
4691
+ labelParentId = labelEntry.id;
4692
+ }
4693
+ const entries: FileEntry[] = [
4694
+ header,
4695
+ ...materializeResidentEntriesForReadSync(pathWithoutLabels, this.#residentBlobStores()),
4696
+ ...labelEntries,
4697
+ ];
4698
+ const stage: PreparedNewSessionState = {
4699
+ sessionId,
4700
+ sessionFile,
4701
+ artifactsDir: sessionFile ? sessionFile.slice(0, -6) : null,
4702
+ managedLegacyLocalMigrationSource: this.#managedLegacyLocalMigrationSourceFor(sessionFile),
4703
+ header,
4704
+ fileEntries: entries,
4705
+ sessionName: undefined,
4706
+ titleSource: undefined,
4707
+ flushed: this.persist,
4708
+ committed: false,
4709
+ discarded: false,
4710
+ };
4711
+ this.#preparedNewSessions.add(stage);
4712
+ this.#freezePreparedNewSessionIdentity(stage);
4713
+ try {
4714
+ if (sessionFile) {
4715
+ const content = `${entries.map(entry => JSON.stringify(prepareEntryForPersistenceSync(entry, this.#blobStore))).join("\n")}\n`;
4716
+ if (this.destination.kind === "managed") {
4717
+ this.#managedTranscriptStore(sessionFile).publishNoReplaceSync(
4718
+ path.basename(sessionFile),
4719
+ Buffer.from(content, "utf8"),
4720
+ );
4721
+ } else {
4722
+ this.storage.writeTextSync(sessionFile, content);
4723
+ }
4724
+ }
4725
+ return stage;
4726
+ } catch (error) {
4727
+ try {
4728
+ await this.discardPreparedNewSession(stage);
4729
+ } catch (cleanupError) {
4730
+ throw new AggregateError(
4731
+ [error, cleanupError],
4732
+ "Branch preparation and staged session cleanup both failed.",
4733
+ );
4734
+ }
4735
+ throw error;
4736
+ }
4737
+ }
4738
+
4739
+ /** Publish a prepared successor synchronously after all readiness awaits succeed. @internal */
4740
+ commitPreparedNewSession(prepared: PreparedNewSession): void {
4741
+ const stage = prepared as PreparedNewSessionState;
4742
+ if (!this.#preparedNewSessions.has(stage) || stage.committed || stage.discarded) {
4743
+ throw new Error("Prepared session is no longer available.");
4744
+ }
4745
+ this.#preparePreparedNewSessionForCommit(stage);
4746
+ const residentFileEntries = stage.residentFileEntries;
4747
+ const residentTextBlobStore = stage.residentTextBlobStore;
4748
+ const index = stage.index;
4749
+ if (!residentFileEntries || !residentTextBlobStore || !index)
4750
+ throw new Error("Prepared session adoption is incomplete.");
4751
+ if (stage.sessionFile) writeTerminalBreadcrumb(this.cwd, stage.sessionFile);
4752
+
4753
+ stage.committed = true;
4754
+ this.#preparedNewSessions.delete(stage);
4755
+ if (!this.#lifecycleIdAdopted && stage.sessionId === lifecyclePreallocatedSessionId())
4756
+ this.#lifecycleIdAdopted = true;
4757
+ this.#persistChain = Promise.resolve();
4758
+ this.#persistError = undefined;
4759
+ this.#persistErrorReported = false;
4760
+ this.#sessionId = stage.sessionId;
4761
+ this.#sessionName = stage.sessionName;
4762
+ this.#titleSource = stage.titleSource;
4763
+ this.#sessionFile = stage.sessionFile;
4764
+ this.#fileEntries = residentFileEntries;
4765
+ this.#residentTextBlobStore = residentTextBlobStore;
4766
+ this.#byId = index.byId;
4767
+ this.#labelsById = index.labelsById;
4768
+ this.#leafId = index.leafId;
4769
+ this.#usageStatistics = index.usageStatistics;
4770
+ this.#flushed = stage.flushed;
4771
+ this.#needsFullRewriteOnNextPersist = false;
4772
+ this.#ensuredOnDisk = stage.flushed;
4773
+ this.#inMemoryArtifacts = null;
4774
+ this.#inMemoryArtifactCounter = 0;
4775
+ this.#artifactManager = null;
4776
+ this.#artifactManagerSessionFile = null;
4777
+ this.#adoptedArtifactManager = null;
4318
4778
  this.#bumpAllRevisions();
4319
- return sessionFile;
4779
+ }
4780
+
4781
+ /** Exact-discard only an uncommitted successor prepared by this manager. @internal */
4782
+ async discardPreparedNewSession(prepared: PreparedNewSession): Promise<void> {
4783
+ const stage = prepared as PreparedNewSessionState;
4784
+ if (!this.#preparedNewSessions.has(stage) || stage.committed || stage.discarded) return;
4785
+ const persistenceCleanupError = await this.#cleanupPreparedNewSessionPersistence(stage);
4786
+ let sessionCleanupError: Error | undefined;
4787
+ if (stage.sessionFile) {
4788
+ try {
4789
+ await this.discardUncommittedSession(stage.sessionFile);
4790
+ } catch (error) {
4791
+ sessionCleanupError = toError(error);
4792
+ }
4793
+ }
4794
+ if (persistenceCleanupError || sessionCleanupError) {
4795
+ const errors = [persistenceCleanupError, sessionCleanupError].filter(
4796
+ (error): error is Error => error !== undefined,
4797
+ );
4798
+ throw errors.length === 1 ? errors[0] : new AggregateError(errors, "Prepared session cleanup failed.");
4799
+ }
4800
+ this.#preparedNewSessions.delete(stage);
4801
+ stage.discarded = true;
4802
+ }
4803
+
4804
+ async #retryPreparedNewSessionCleanups(): Promise<void> {
4805
+ if (this.#preparedNewSessionCleanupInProgress) return;
4806
+ this.#preparedNewSessionCleanupInProgress = true;
4807
+ try {
4808
+ const errors: Error[] = [];
4809
+ for (const stage of [...this.#preparedNewSessions]) {
4810
+ if (stage.committed || stage.discarded) continue;
4811
+ try {
4812
+ await this.discardPreparedNewSession(stage);
4813
+ } catch (error) {
4814
+ errors.push(toError(error));
4815
+ }
4816
+ }
4817
+ if (errors.length > 0)
4818
+ throw errors.length === 1
4819
+ ? errors[0]
4820
+ : new AggregateError(errors, "Prepared session cleanup retry failed.");
4821
+ } finally {
4822
+ this.#preparedNewSessionCleanupInProgress = false;
4823
+ }
4824
+ }
4825
+
4826
+ async #cleanupPreparedNewSessionPersistence(stage: PreparedNewSessionState): Promise<Error | undefined> {
4827
+ const errors: Error[] = [];
4828
+ if (stage.persistenceWriter) {
4829
+ try {
4830
+ await stage.persistenceWriter.close();
4831
+ stage.persistenceWriter = undefined;
4832
+ } catch (error) {
4833
+ errors.push(toError(error));
4834
+ }
4835
+ }
4836
+ if (stage.persistenceTempPath && !stage.persistenceWriter) {
4837
+ try {
4838
+ await this.storage.unlink(stage.persistenceTempPath);
4839
+ stage.persistenceTempPath = undefined;
4840
+ } catch (error) {
4841
+ if (!isEnoent(error)) errors.push(toError(error));
4842
+ else stage.persistenceTempPath = undefined;
4843
+ }
4844
+ }
4845
+ return errors.length === 0
4846
+ ? undefined
4847
+ : errors.length === 1
4848
+ ? errors[0]
4849
+ : new AggregateError(errors, "Prepared session temporary-file cleanup failed.");
4320
4850
  }
4321
4851
 
4322
4852
  /** Tombstone and exact-delete managed transcripts, detaching the active transcript first. */
@@ -4962,41 +5492,73 @@ export class SessionManager {
4962
5492
  }
4963
5493
 
4964
5494
  #buildIndex(): void {
4965
- this.#byId.clear();
4966
- this.#labelsById.clear();
4967
- this.#leafId = null;
4968
- this.#usageStatistics = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, premiumRequests: 0, cost: 0 };
5495
+ const index = this.#buildIndexForEntries(this.#fileEntries, this.#sessionFile);
5496
+ this.#byId = index.byId;
5497
+ this.#labelsById = index.labelsById;
5498
+ this.#leafId = index.leafId;
5499
+ this.#usageStatistics = index.usageStatistics;
5500
+ }
5501
+
5502
+ #buildIndexForEntries(fileEntries: FileEntry[], sessionFile: string | undefined): PreparedSessionIndex {
5503
+ const byId = new Map<string, SessionEntry>();
5504
+ const labelsById = new Map<string, string>();
5505
+ let leafId: string | null = null;
5506
+ const usageStatistics: UsageStatistics = {
5507
+ input: 0,
5508
+ output: 0,
5509
+ cacheRead: 0,
5510
+ cacheWrite: 0,
5511
+ premiumRequests: 0,
5512
+ cost: 0,
5513
+ };
5514
+ const addUsage = (totals: ValidatedUsageTotals): boolean => {
5515
+ const next = {
5516
+ input: usageStatistics.input + totals.input,
5517
+ output: usageStatistics.output + totals.output,
5518
+ cacheRead: usageStatistics.cacheRead + totals.cacheRead,
5519
+ cacheWrite: usageStatistics.cacheWrite + totals.cacheWrite,
5520
+ premiumRequests: usageStatistics.premiumRequests + totals.premiumRequests,
5521
+ cost: usageStatistics.cost + totals.cost,
5522
+ };
5523
+ if (Object.values(next).some(value => !Number.isFinite(value))) return false;
5524
+ Object.assign(usageStatistics, next);
5525
+ return true;
5526
+ };
5527
+ const accumulateUsage = (entry: SessionEntry): boolean => {
5528
+ if (entry.type !== "message") return false;
5529
+ const usage =
5530
+ entry.message.role === "assistant"
5531
+ ? entry.message.usage
5532
+ : entry.message.role === "toolResult" && entry.message.toolName === "task"
5533
+ ? getTaskToolUsage(entry.message.details)
5534
+ : undefined;
5535
+ if (usage === undefined) return false;
5536
+ const totals = validatePersistedUsageTotals(usage);
5537
+ return !(totals && addUsage(totals));
5538
+ };
4969
5539
  let malformedUsageRecords = 0;
4970
5540
  const malformedUsageSample: string[] = [];
4971
- for (const entry of this.#fileEntries) {
5541
+ for (const entry of fileEntries) {
4972
5542
  if (entry.type === "session") continue;
4973
- this.#byId.set(entry.id, entry);
4974
- this.#leafId = entry.id;
5543
+ byId.set(entry.id, entry);
5544
+ leafId = entry.id;
4975
5545
  if (entry.type === "label") {
4976
- if (entry.label) {
4977
- this.#labelsById.set(entry.targetId, entry.label);
4978
- } else {
4979
- this.#labelsById.delete(entry.targetId);
4980
- }
5546
+ if (entry.label) labelsById.set(entry.targetId, entry.label);
5547
+ else labelsById.delete(entry.targetId);
4981
5548
  }
4982
- // Aggregate this entry's persisted usage through the shared validated,
4983
- // overflow-guarded path (see #accumulateEntryUsage). parseSessionEntries accepts
4984
- // any parseable JSON, so a torn/corrupt record can carry a still-valid-JSON but
4985
- // malformed `usage` (absent, {}, numeric strings, negatives, a non-record cost, or
4986
- // a cumulative overflow); such a record is skipped and reported below instead of
4987
- // poisoning every getUsageStatistics() consumer.
4988
- if (this.#accumulateEntryUsage(entry)) {
5549
+ if (accumulateUsage(entry)) {
4989
5550
  malformedUsageRecords++;
4990
5551
  if (malformedUsageSample.length < MALFORMED_USAGE_SAMPLE_LIMIT) malformedUsageSample.push(entry.id);
4991
5552
  }
4992
5553
  }
4993
5554
  if (malformedUsageRecords > 0) {
4994
5555
  logger.warn("Skipped malformed or overflowing persisted usage records during resume aggregation", {
4995
- sessionFile: this.#sessionFile,
5556
+ sessionFile,
4996
5557
  count: malformedUsageRecords,
4997
5558
  sampleEntryIds: malformedUsageSample,
4998
5559
  });
4999
5560
  }
5561
+ return { byId, labelsById, leafId, usageStatistics };
5000
5562
  }
5001
5563
 
5002
5564
  /**
@@ -5542,6 +6104,15 @@ export class SessionManager {
5542
6104
 
5543
6105
  /** Close the persistent writer after flushing all pending data. */
5544
6106
  async close(): Promise<void> {
6107
+ // Drain any uncommitted prepared successors before releasing resources so
6108
+ // dispose/shutdown retains exact cleanup authority (#3138).
6109
+ try {
6110
+ await this.#retryPreparedNewSessionCleanups();
6111
+ } catch (error) {
6112
+ logger.warn("Prepared session cleanup during close failed; retained for retry", {
6113
+ error: toError(error).message,
6114
+ });
6115
+ }
5545
6116
  await this.#queuePersistTask(async () => {
5546
6117
  if (this.#persistWriter) {
5547
6118
  await this.#closePersistWriterInternal();
@@ -5566,6 +6137,14 @@ export class SessionManager {
5566
6137
  * writer closure before any destructive operation.
5567
6138
  */
5568
6139
  async closeStrict(): Promise<SessionManagerCloseOutcome> {
6140
+ // Drain staged successors on the strict ACP dispose path as well as best-effort close (#3138).
6141
+ try {
6142
+ await this.#retryPreparedNewSessionCleanups();
6143
+ } catch (error) {
6144
+ logger.warn("Prepared session cleanup during closeStrict failed; retained for retry", {
6145
+ error: toError(error).message,
6146
+ });
6147
+ }
5569
6148
  let outcome: SessionManagerCloseOutcome = { kind: "closed" };
5570
6149
  await this.#queuePersistTask(async () => {
5571
6150
  const writer = this.#persistWriter;
@@ -5670,9 +6249,13 @@ export class SessionManager {
5670
6249
 
5671
6250
  /** Supplies opaque retained authority for mandatory managed legacy local migration. */
5672
6251
  getManagedLegacyLocalMigrationSource(): ManagedLegacyLocalMigrationSource | null {
5673
- if (this.destination.kind !== "managed" || !this.#sessionFile || this.#adoptedArtifactManager) return null;
5674
- const store = this.#managedTranscriptStore();
5675
- const legacyArtifactsRoot = path.basename(this.#sessionFile.slice(0, -6));
6252
+ return this.#managedLegacyLocalMigrationSourceFor(this.#sessionFile);
6253
+ }
6254
+
6255
+ #managedLegacyLocalMigrationSourceFor(sessionFile: string | undefined): ManagedLegacyLocalMigrationSource | null {
6256
+ if (this.destination.kind !== "managed" || !sessionFile || this.#adoptedArtifactManager) return null;
6257
+ const store = this.#managedTranscriptStore(sessionFile);
6258
+ const legacyArtifactsRoot = path.basename(sessionFile.slice(0, -6));
5676
6259
  const legacyLocalRoot = path.posix.join(legacyArtifactsRoot, "local");
5677
6260
  return {
5678
6261
  capture: async () => {