@gajae-code/coding-agent 0.13.0 → 0.13.1

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 (113) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/types/config/file-lock.d.ts +2 -0
  3. package/dist/types/config/keybindings.d.ts +2 -2
  4. package/dist/types/config/model-registry.d.ts +4 -0
  5. package/dist/types/config/models-config-schema.d.ts +6 -0
  6. package/dist/types/config/settings-schema.d.ts +17 -5
  7. package/dist/types/config/settings.d.ts +10 -1
  8. package/dist/types/edit/streaming.d.ts +5 -5
  9. package/dist/types/eval/py/kernel.d.ts +2 -2
  10. package/dist/types/eval/py/runtime.d.ts +5 -1
  11. package/dist/types/gjc-runtime/session-state-sidecar.d.ts +3 -0
  12. package/dist/types/gjc-runtime/tmux-sessions.d.ts +3 -1
  13. package/dist/types/modes/components/model-selector.d.ts +3 -2
  14. package/dist/types/runtime/process-lifecycle.d.ts +12 -31
  15. package/dist/types/sdk/broker/identity.d.ts +2 -1
  16. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +17 -1
  17. package/dist/types/sdk/broker/lifecycle.d.ts +11 -6
  18. package/dist/types/sdk/broker/startup-failure.d.ts +32 -0
  19. package/dist/types/sdk/bus/chat-daemon-control.d.ts +2 -1
  20. package/dist/types/sdk/bus/conversation-store.d.ts +1 -0
  21. package/dist/types/sdk/bus/index.d.ts +4 -0
  22. package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +10 -1
  23. package/dist/types/sdk/bus/reconciliation-store.d.ts +2 -0
  24. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +11 -2
  25. package/dist/types/sdk/bus/telegram-daemon.d.ts +17 -1
  26. package/dist/types/sdk/host/query/cursor.d.ts +4 -0
  27. package/dist/types/sdk/host/query/handlers.d.ts +6 -0
  28. package/dist/types/sdk/host/session-runtime.d.ts +7 -6
  29. package/dist/types/sdk/host/websocket-transport.d.ts +1 -1
  30. package/dist/types/sdk/index.d.ts +1 -0
  31. package/dist/types/sdk/turn-result.d.ts +37 -0
  32. package/dist/types/session/agent-session.d.ts +8 -11
  33. package/dist/types/session/blob-store.d.ts +6 -3
  34. package/dist/types/slash-commands/helpers/fast-status-report.d.ts +12 -3
  35. package/dist/types/task/executor.d.ts +4 -7
  36. package/dist/types/tools/index.d.ts +1 -1
  37. package/dist/types/tools/read.d.ts +1 -1
  38. package/dist/types/tools/search.d.ts +1 -1
  39. package/dist/types/utils/edit-mode.d.ts +69 -0
  40. package/package.json +8 -8
  41. package/scripts/generate-hotkeys-docs.ts +8 -6
  42. package/src/commands/sdk.ts +23 -2
  43. package/src/config/file-lock.ts +25 -5
  44. package/src/config/keybindings.ts +4 -2
  45. package/src/config/model-registry.ts +20 -0
  46. package/src/config/models-config-schema.ts +1 -0
  47. package/src/config/settings-schema.ts +29 -22
  48. package/src/config/settings.ts +42 -10
  49. package/src/coordinator-mcp/server.ts +84 -106
  50. package/src/edit/index.ts +5 -19
  51. package/src/edit/renderer.ts +3 -2
  52. package/src/edit/streaming.ts +28 -15
  53. package/src/eval/py/executor.ts +108 -10
  54. package/src/eval/py/kernel.ts +86 -12
  55. package/src/eval/py/runtime.ts +200 -16
  56. package/src/exec/bash-executor.ts +36 -37
  57. package/src/gjc-runtime/gc-runtime.ts +25 -3
  58. package/src/gjc-runtime/managed-owner-supervisor.ts +27 -1
  59. package/src/gjc-runtime/session-state-sidecar.ts +110 -4
  60. package/src/gjc-runtime/tmux-sessions.ts +34 -5
  61. package/src/internal-urls/docs-index.generated.ts +6 -6
  62. package/src/modes/acp/acp-agent.ts +10 -2
  63. package/src/modes/components/model-selector.ts +28 -8
  64. package/src/modes/controllers/plan-mode-controller.ts +3 -3
  65. package/src/modes/controllers/selector-controller.ts +4 -2
  66. package/src/runtime/process-lifecycle.ts +126 -27
  67. package/src/runtime-mcp/transports/stdio.ts +11 -6
  68. package/src/sdk/acp/adapter.ts +18 -10
  69. package/src/sdk/broker/broker.ts +55 -4
  70. package/src/sdk/broker/ensure.ts +10 -7
  71. package/src/sdk/broker/identity.ts +10 -0
  72. package/src/sdk/broker/lifecycle-ledger.ts +56 -6
  73. package/src/sdk/broker/lifecycle.ts +53 -10
  74. package/src/sdk/broker/session-index.ts +28 -11
  75. package/src/sdk/broker/startup-failure.ts +101 -0
  76. package/src/sdk/broker/transport.ts +1 -0
  77. package/src/sdk/bus/chat-daemon-control.ts +4 -3
  78. package/src/sdk/bus/chat-daemon-runtime.ts +8 -1
  79. package/src/sdk/bus/conversation-store.ts +171 -50
  80. package/src/sdk/bus/index.ts +101 -47
  81. package/src/sdk/bus/kind-aware-reconciliation.ts +52 -2
  82. package/src/sdk/bus/notification-service.ts +3 -3
  83. package/src/sdk/bus/reconciliation-store.ts +16 -0
  84. package/src/sdk/bus/telegram-daemon-contract.ts +11 -2
  85. package/src/sdk/bus/telegram-daemon.ts +131 -29
  86. package/src/sdk/bus/topic-registry.ts +38 -9
  87. package/src/sdk/cli/session-cli.ts +20 -7
  88. package/src/sdk/client/discovery.ts +23 -2
  89. package/src/sdk/host/query/cursor.ts +4 -0
  90. package/src/sdk/host/query/handlers.ts +99 -100
  91. package/src/sdk/host/sdk-surface-parity.test.ts +1 -1
  92. package/src/sdk/host/session-runtime.test.ts +98 -1
  93. package/src/sdk/host/session-runtime.ts +57 -15
  94. package/src/sdk/host/websocket-transport.lifecycle.test.ts +192 -2
  95. package/src/sdk/host/websocket-transport.ts +54 -30
  96. package/src/sdk/index.ts +1 -0
  97. package/src/sdk/mcp/server.ts +19 -6
  98. package/src/sdk/protocol/operation-inventory.generated.json +1 -1
  99. package/src/sdk/protocol/operation-registry.ts +8 -12
  100. package/src/sdk/session.ts +5 -14
  101. package/src/sdk/turn-result.ts +53 -0
  102. package/src/session/agent-session.ts +112 -69
  103. package/src/session/blob-store.ts +91 -21
  104. package/src/session/session-manager.ts +408 -81
  105. package/src/session/session-storage.ts +68 -10
  106. package/src/slash-commands/helpers/fast-status-report.ts +24 -8
  107. package/src/task/executor.ts +25 -8
  108. package/src/task/index.ts +0 -2
  109. package/src/tools/index.ts +1 -1
  110. package/src/tools/read.ts +12 -11
  111. package/src/tools/search.ts +7 -7
  112. package/src/utils/edit-mode.ts +180 -9
  113. package/src/web/search/providers/insane.ts +55 -1
@@ -107,9 +107,10 @@ import {
107
107
  classifyContextOverflow,
108
108
  getSupportedEfforts,
109
109
  isContextOverflow,
110
+ isFastModeEffectiveForProvider,
110
111
  isUsageLimitError,
112
+ modelSupportsServiceTier,
111
113
  modelsAreEqual,
112
- resolveServiceTier,
113
114
  streamSimple,
114
115
  } from "@gajae-code/ai/core";
115
116
  import {
@@ -752,6 +753,13 @@ type AutoCompactionTerminalStatus =
752
753
  | { kind: "skipped"; continuationScheduled?: boolean }
753
754
  | { kind: "failed" };
754
755
 
756
+ type ToolOutputPruneResult = {
757
+ prunedCount: number;
758
+ tokensSaved: number;
759
+ committed: boolean;
760
+ failure?: "artifact_persistence";
761
+ };
762
+
755
763
  /**
756
764
  * R3.2 pre-submit seam: `build` assembles the attempt messages (Phase A once,
757
765
  * Phase B per attempt) and may throw the typed overflow error; `reset` drops the
@@ -3233,7 +3241,7 @@ export class AgentSession {
3233
3241
  }
3234
3242
 
3235
3243
  /** Restore a temporary scope, unwinding any auto-owned scopes above it. */
3236
- restoreTemporaryProviderSessionScope(token: TemporaryProviderSessionScope): boolean {
3244
+ async restoreTemporaryProviderSessionScope(token: TemporaryProviderSessionScope): Promise<boolean> {
3237
3245
  const scopeIndex = this.#temporaryProviderSessionScopes.findLastIndex(scope => scope.token === token);
3238
3246
  if (
3239
3247
  scopeIndex < 0 ||
@@ -3242,12 +3250,12 @@ export class AgentSession {
3242
3250
  return false;
3243
3251
  }
3244
3252
  while (this.#temporaryProviderSessionScopes.length > scopeIndex) {
3245
- this.#restoreTopTemporaryProviderSessionScope();
3253
+ await this.#restoreTopTemporaryProviderSessionScope();
3246
3254
  }
3247
3255
  return true;
3248
3256
  }
3249
3257
 
3250
- #restoreTopTemporaryProviderSessionScope(): void {
3258
+ async #restoreTopTemporaryProviderSessionScope(): Promise<void> {
3251
3259
  const scope = this.#temporaryProviderSessionScopes.pop();
3252
3260
  if (!scope) return;
3253
3261
  this.#closeProviderSessionMap(this.#providerSessionState, "temporary scope restore");
@@ -3266,7 +3274,7 @@ export class AgentSession {
3266
3274
  this.#pendingThinkingVisibilityControlFailure = undefined;
3267
3275
  this.#thinkingLevel = scope.thinkingLevel;
3268
3276
  this.agent.setThinkingLevel(toReasoningEffort(scope.thinkingLevel));
3269
- void this.#syncEditToolModeAfterModelChange(previousEditMode);
3277
+ await this.#syncEditToolModeAfterModelChange(previousEditMode);
3270
3278
  }
3271
3279
 
3272
3280
  /** Promote a temporary scope. The suspended provider state is permanently closed. */
@@ -6787,17 +6795,9 @@ export class AgentSession {
6787
6795
  return resolveEditMode(this.#getEditModeSession());
6788
6796
  }
6789
6797
 
6790
- #resolveEditModeForModel(model: Model): EditMode {
6791
- return resolveEditMode({
6792
- settings: this.settings,
6793
- getActiveModelString: () => formatModelString(model),
6794
- });
6795
- }
6796
-
6797
6798
  async #prepareDefaultModelSelectionPrompt(model: Model): Promise<string[] | undefined> {
6798
6799
  if (!this.#rebuildSystemPrompt) return undefined;
6799
6800
  if (!this.getActiveToolNames().includes("edit")) return undefined;
6800
- if (this.#resolveActiveEditMode() === this.#resolveEditModeForModel(model)) return undefined;
6801
6801
  const built = await this.#rebuildSystemPrompt(this.getActiveToolNames(), this.#toolRegistry, model);
6802
6802
  return built.systemPrompt;
6803
6803
  }
@@ -6836,11 +6836,8 @@ export class AgentSession {
6836
6836
  this.#lastAppliedToolSignature = this.#computeAppliedToolSignature(activeToolNames, activeTools);
6837
6837
  }
6838
6838
 
6839
- async #syncEditToolModeAfterModelChange(previousEditMode: EditMode): Promise<void> {
6840
- const currentEditMode = this.#resolveActiveEditMode();
6841
- if (previousEditMode !== currentEditMode && this.getActiveToolNames().includes("edit")) {
6842
- await this.refreshBaseSystemPrompt();
6843
- }
6839
+ async #syncEditToolModeAfterModelChange(_previousEditMode: EditMode): Promise<void> {
6840
+ if (this.getActiveToolNames().includes("edit")) await this.refreshBaseSystemPrompt();
6844
6841
  }
6845
6842
 
6846
6843
  getSelectedMCPToolNames(): string[] {
@@ -10912,8 +10909,31 @@ export class AgentSession {
10912
10909
  await this.#settleOwnAsyncJobsBeforeArtifactRetirement();
10913
10910
  } catch (error) {
10914
10911
  const forkedFile = forkedManager.getSessionFile();
10915
- await forkedManager.close();
10916
- if (forkedFile) await previousManager.discardUncommittedSession(forkedFile);
10912
+ const cleanupErrors: unknown[] = [];
10913
+ try {
10914
+ await forkedManager.close();
10915
+ } catch (closeError) {
10916
+ cleanupErrors.push(closeError);
10917
+ logger.warn("Bounded fork successor close failed after initialization failure", {
10918
+ error: closeError instanceof Error ? closeError.message : String(closeError),
10919
+ });
10920
+ }
10921
+ if (forkedFile) {
10922
+ try {
10923
+ await previousManager.discardUncommittedSession(forkedFile);
10924
+ } catch (cleanupError) {
10925
+ cleanupErrors.push(cleanupError);
10926
+ logger.warn("Bounded fork successor cleanup failed after initialization failure", {
10927
+ error: cleanupError instanceof Error ? cleanupError.message : String(cleanupError),
10928
+ });
10929
+ }
10930
+ }
10931
+ if (cleanupErrors.length > 0) {
10932
+ throw new AggregateError(
10933
+ [error, ...cleanupErrors],
10934
+ "Bounded fork initialization and successor cleanup both failed.",
10935
+ );
10936
+ }
10917
10937
  throw error;
10918
10938
  }
10919
10939
  this.sessionManager = forkedManager;
@@ -11450,7 +11470,7 @@ export class AgentSession {
11450
11470
  currentAutoScope !== undefined &&
11451
11471
  this.#temporaryProviderSessionScopes.at(-1) === currentAutoScope;
11452
11472
  if (replaceAutoScope && currentAutoScope) {
11453
- this.#restoreTopTemporaryProviderSessionScope();
11473
+ await this.#restoreTopTemporaryProviderSessionScope();
11454
11474
  }
11455
11475
  const scope = isTemporaryOperation
11456
11476
  ? (suppliedScope ??
@@ -11481,7 +11501,7 @@ export class AgentSession {
11481
11501
  if (options?.persistAsSessionDefault === true) this.#clearActiveModelProfileForConcreteDefault(options?.cause);
11482
11502
  await this.#syncEditToolModeAfterModelChange(previousEditMode);
11483
11503
  } catch (error) {
11484
- if (ownsScope) this.restoreTemporaryProviderSessionScope(scope);
11504
+ if (ownsScope) await this.restoreTemporaryProviderSessionScope(scope);
11485
11505
  throw error;
11486
11506
  }
11487
11507
  return scope;
@@ -12196,17 +12216,12 @@ export class AgentSession {
12196
12216
  }
12197
12217
 
12198
12218
  /**
12199
- * True when the configured `serviceTier` resolves to `"priority"` for the
12200
- * given model `provider`. Returns false for scoped tiers that don't match
12201
- * (e.g. `"openai-only"` on an anthropic provider) and when `provider` is
12202
- * undefined. This is the canonical provider-aware fast-mode predicate.
12219
+ * True when the configured tier is realized as a fast-mode field on the
12220
+ * provider's wire protocol. Providers that silently drop unscoped priority
12221
+ * intent return false so UI indicators match the request that is sent.
12203
12222
  */
12204
- isFastForProvider(provider?: string): boolean {
12205
- // Fast mode applies to a concrete model's provider. With no provider
12206
- // (no model selected) it cannot apply, even under an unscoped `priority`
12207
- // tier that `resolveServiceTier` would otherwise pass through.
12208
- if (provider === undefined) return false;
12209
- return resolveServiceTier(this.serviceTier, provider) === "priority";
12223
+ isFastForProvider(provider?: string, supportsServiceTier = false): boolean {
12224
+ return isFastModeEffectiveForProvider(this.serviceTier, provider, supportsServiceTier);
12210
12225
  }
12211
12226
 
12212
12227
  /**
@@ -12223,14 +12238,11 @@ export class AgentSession {
12223
12238
  }
12224
12239
 
12225
12240
  /**
12226
- * Provider-aware fast-mode predicate for task-tool subagent roles, evaluated
12227
- * against the effective subagent tier (`task.serviceTier`) rather than the
12228
- * main session tier. Use this for `task.agentModelOverrides` role rows so the
12229
- * ⚡ glyph reflects the tier the subagent actually runs under.
12241
+ * Wire-effective fast-mode predicate for task-tool subagent roles, evaluated
12242
+ * against `task.serviceTier` rather than the main session tier.
12230
12243
  */
12231
- isFastForSubagentProvider(provider?: string): boolean {
12232
- if (provider === undefined) return false;
12233
- return resolveServiceTier(this.#subagentServiceTier(), provider) === "priority";
12244
+ isFastForSubagentProvider(provider?: string, supportsServiceTier = false): boolean {
12245
+ return isFastModeEffectiveForProvider(this.#subagentServiceTier(), provider, supportsServiceTier);
12234
12246
  }
12235
12247
 
12236
12248
  /**
@@ -12286,7 +12298,10 @@ export class AgentSession {
12286
12298
  */
12287
12299
  isFastModeActive(): boolean {
12288
12300
  const provider = this.model?.provider;
12289
- return this.isFastForProvider(provider) && !this.#isFastModeAutoDisabledForProvider(provider);
12301
+ const supportsServiceTier = modelSupportsServiceTier(this.model);
12302
+ return (
12303
+ this.isFastForProvider(provider, supportsServiceTier) && !this.#isFastModeAutoDisabledForProvider(provider)
12304
+ );
12290
12305
  }
12291
12306
 
12292
12307
  setServiceTier(serviceTier: ServiceTier | undefined): void {
@@ -12381,19 +12396,34 @@ export class AgentSession {
12381
12396
  signal?: AbortSignal,
12382
12397
  overThreshold = false,
12383
12398
  options?: { commitGate?: (actual: { prunedCount: number; tokensSaved: number }) => boolean },
12384
- ): Promise<{ prunedCount: number; tokensSaved: number; committed: boolean } | undefined> {
12399
+ ): Promise<ToolOutputPruneResult | undefined> {
12385
12400
  const branchEntries = this.sessionManager.getBranch();
12401
+ const artifactManager = await this.sessionManager.ensureArtifactManager();
12402
+ // Over-threshold callers have already proven tool-output savings before entering
12403
+ // this path. If exact persistence is unavailable, fail closed before hashing and
12404
+ // planning large outputs so maintenance cannot drift into timeout-driven abort.
12405
+ if (overThreshold && !artifactManager)
12406
+ return { prunedCount: 0, tokensSaved: 0, committed: false, failure: "artifact_persistence" };
12386
12407
  const plan = planToolOutputPrune(branchEntries, {
12387
12408
  ...DEFAULT_PRUNE_CONFIG,
12388
12409
  minimumSavings: overThreshold ? 0 : DEFAULT_PRUNE_CONFIG.minimumSavings,
12389
12410
  });
12390
- const artifactManager = await this.sessionManager.ensureArtifactManager();
12391
12411
  const published = new Map<string, ToolOutputPruneEvictionHandle>();
12392
12412
  // Fail closed when tool-output eviction is planned but no artifact store can be
12393
12413
  // established: do not report a successful prune that skipped durable eviction.
12394
12414
  if (!artifactManager && plan.digests.length > 0) {
12395
- return undefined;
12415
+ return { prunedCount: 0, tokensSaved: 0, committed: false, failure: "artifact_persistence" };
12396
12416
  }
12417
+ const removePublishedArtifacts = async (): Promise<void> => {
12418
+ for (const handle of published.values()) {
12419
+ const removed = await artifactManager?.removeNamedBestEffort(`${handle.artifactId}.evicted.log`);
12420
+ if (removed === false) {
12421
+ logger.warn("Failed to remove unpublished tool-output eviction artifact", {
12422
+ artifactId: handle.artifactId,
12423
+ });
12424
+ }
12425
+ }
12426
+ };
12397
12427
 
12398
12428
  // Publish exact text one candidate at a time. The plan carries only digests and
12399
12429
  // replacement proposals; original output bytes exist only in this iteration.
@@ -12416,22 +12446,19 @@ export class AgentSession {
12416
12446
  outcome: outcome.outcome,
12417
12447
  diagnostic: "diagnostic" in outcome ? outcome.diagnostic : undefined,
12418
12448
  });
12419
- if (outcome.outcome === "failed") break;
12449
+ if (outcome.outcome === "failed") {
12450
+ await removePublishedArtifacts();
12451
+ return {
12452
+ prunedCount: published.size,
12453
+ tokensSaved: 0,
12454
+ committed: false,
12455
+ failure: "artifact_persistence",
12456
+ };
12457
+ }
12420
12458
  }
12421
12459
  }
12422
12460
  }
12423
12461
 
12424
- const removePublishedArtifacts = async (): Promise<void> => {
12425
- for (const handle of published.values()) {
12426
- const removed = await artifactManager?.removeNamedBestEffort(`${handle.artifactId}.evicted.log`);
12427
- if (removed === false) {
12428
- logger.warn("Failed to remove unpublished tool-output eviction artifact", {
12429
- artifactId: handle.artifactId,
12430
- });
12431
- }
12432
- }
12433
- };
12434
-
12435
12462
  // Evaluate non-tool pruning on a disposable copy so the gate runs before any
12436
12463
  // live entry is mutated. This avoids retaining originals for rollback.
12437
12464
  const estimateEntries = structuredClone(branchEntries) as typeof branchEntries;
@@ -13513,7 +13540,7 @@ export class AgentSession {
13513
13540
  relaxedMinimum: 0,
13514
13541
  artifactRefMaxChars: PRUNED_ARTIFACT_REF_MAX_CHARS,
13515
13542
  });
13516
- let pruneResult: { prunedCount: number; tokensSaved: number; committed: boolean } | undefined;
13543
+ let pruneResult: ToolOutputPruneResult | undefined;
13517
13544
  if (
13518
13545
  pruneEstimate.tokensSaved > 0 &&
13519
13546
  !shouldCompact(
@@ -13525,6 +13552,7 @@ export class AgentSession {
13525
13552
  ) {
13526
13553
  pruneResult = await this.#pruneToolOutputs(maintenanceSignal, true);
13527
13554
  if (isAborted()) return result("aborted");
13555
+ if (pruneResult?.failure === "artifact_persistence") return result("failed");
13528
13556
  if (pruneResult) contextTokens = Math.max(0, contextTokens - pruneResult.tokensSaved);
13529
13557
  }
13530
13558
  if (!shouldCompact(contextTokens, contextWindow, compactionSettings, autoCompactionOutputReserveTokens)) {
@@ -14132,7 +14160,7 @@ export class AgentSession {
14132
14160
  signal,
14133
14161
  });
14134
14162
  if (signal?.aborted) {
14135
- if (scope) this.restoreTemporaryProviderSessionScope(scope);
14163
+ if (scope) await this.restoreTemporaryProviderSessionScope(scope);
14136
14164
  return false;
14137
14165
  }
14138
14166
  logger.debug("Context promotion switched model on overflow", {
@@ -15641,8 +15669,10 @@ export class AgentSession {
15641
15669
  controller.seedResolution(activeIndex, [...controller.skips, ...resolution.skips]);
15642
15670
  }
15643
15671
  if (!resolution.model) throw new Error(this.#fallbackExhaustionError(controller));
15672
+ const previousEditMode = this.#resolveActiveEditMode();
15644
15673
  this.#setModelAuthoritatively(resolution.model, "restore");
15645
15674
  this.setThinkingLevel(resolution.explicitThinkingLevel ? resolution.thinkingLevel : this.thinkingLevel);
15675
+ await this.#syncEditToolModeAfterModelChange(previousEditMode);
15646
15676
  }
15647
15677
 
15648
15678
  /**
@@ -15839,8 +15869,10 @@ export class AgentSession {
15839
15869
  const from =
15840
15870
  controller.tried.at(-1)?.selector ?? controller.chain.entries[controller.activeIndex - 1] ?? selector;
15841
15871
  const to = selector;
15872
+ const previousEditMode = this.#resolveActiveEditMode();
15842
15873
  this.#setModelAuthoritatively(resolved.model, "fallback-switch");
15843
15874
  this.setThinkingLevel(resolved.explicitThinkingLevel ? resolved.thinkingLevel : this.thinkingLevel);
15875
+ await this.#syncEditToolModeAfterModelChange(previousEditMode);
15844
15876
  if (from !== to) {
15845
15877
  this.#emit({
15846
15878
  type: "model_fallback_switched",
@@ -16158,18 +16190,27 @@ export class AgentSession {
16158
16190
  this.#retryAbortController?.abort();
16159
16191
  this.#retryAbortController = retryAbortController;
16160
16192
  this.#retryNowRequested = false;
16161
- await this.#emitSessionEvent({
16162
- type: "auto_retry_start",
16163
- attempt: this.#retryAttempt,
16164
- maxAttempts: managedFallback
16165
- ? controller.maxAttempts
16166
- : firstEventTimeout
16167
- ? retrySettings.maxRetries + 1
16168
- : retrySettings.maxRetries,
16169
- delayMs,
16170
- errorMessage,
16171
- unbounded: managedFallback ? false : legacyUnbounded,
16172
- });
16193
+ try {
16194
+ await this.#emitSessionEvent({
16195
+ type: "auto_retry_start",
16196
+ attempt: this.#retryAttempt,
16197
+ maxAttempts: managedFallback
16198
+ ? controller.maxAttempts
16199
+ : firstEventTimeout
16200
+ ? retrySettings.maxRetries + 1
16201
+ : retrySettings.maxRetries,
16202
+ delayMs,
16203
+ errorMessage,
16204
+ unbounded: managedFallback ? false : legacyUnbounded,
16205
+ });
16206
+ } catch (error) {
16207
+ if (this.#retryAbortController === retryAbortController) this.#retryAbortController = undefined;
16208
+ retryAbortController.abort();
16209
+ this.#failRetryRecovery(
16210
+ `Retry start delivery failed: ${error instanceof Error ? error.message : String(error)}`,
16211
+ );
16212
+ throw error;
16213
+ }
16173
16214
 
16174
16215
  const messages = this.agent.state.messages;
16175
16216
  if (messages.length > 0 && messages[messages.length - 1].role === "assistant") {
@@ -17658,6 +17699,7 @@ export class AgentSession {
17658
17699
  : didReloadConversationChange
17659
17700
  ? "conversation-reload"
17660
17701
  : undefined;
17702
+ const previousEditMode = this.#resolveActiveEditMode();
17661
17703
  await this.#restoreMCPSelectionsForSessionContext(sessionContext);
17662
17704
 
17663
17705
  // The target session is loaded and MCP selections are restored: discard
@@ -17731,6 +17773,7 @@ export class AgentSession {
17731
17773
  if (!this.model || !modelsAreEqual(this.model, resolution.model)) {
17732
17774
  this.#setModelAuthoritatively(resolution.model, "restore");
17733
17775
  }
17776
+ await this.#syncEditToolModeAfterModelChange(previousEditMode);
17734
17777
  if (resolution.explicitThinkingLevel && resolution.thinkingLevel !== undefined) {
17735
17778
  this.setThinkingLevel(resolution.thinkingLevel);
17736
17779
  }
@@ -2,6 +2,7 @@ import * as crypto from "node:crypto";
2
2
  import * as fs from "node:fs";
3
3
  import * as fsp from "node:fs/promises";
4
4
  import * as path from "node:path";
5
+ import { exactUnlink } from "@gajae-code/natives";
5
6
  import { isEnoent, logger, postmortem } from "@gajae-code/utils";
6
7
 
7
8
  const BLOB_PREFIX = "blob:sha256:";
@@ -387,9 +388,35 @@ function removeResidentCacheTreeNoFollow(pathname: string): void {
387
388
  fs.unlinkSync(pathname);
388
389
  }
389
390
 
390
- export function disposeVerifiedResidentCacheInstanceDir(instanceDir: string): void {
391
+ function assertResidentCacheDisposalParent(instanceDir: string, parentDescriptor: number): void {
392
+ const parentDir = path.dirname(instanceDir);
393
+ try {
394
+ assertResidentCacheDirectoryPathMatchesDescriptor(parentDir, parentDescriptor, residentCacheOwnerUid(parentDir));
395
+ } catch (error) {
396
+ throw residentCacheTrustError("parent_authority_changed", instanceDir, error);
397
+ }
398
+ }
399
+
400
+ export function disposeVerifiedResidentCacheInstanceDir(instanceDir: string, parentDescriptor?: number): void {
391
401
  const instanceKey = path.resolve(instanceDir);
392
402
  const uid = residentCacheOwnerUid(instanceDir);
403
+ let parentVerified = false;
404
+ if (parentDescriptor !== undefined) {
405
+ assertResidentCacheDisposalParent(instanceDir, parentDescriptor);
406
+ parentVerified = true;
407
+ }
408
+ // ENOENT is authoritative only while the retained parent still names the
409
+ // directory or after this process has already retired its ownership record.
410
+ try {
411
+ fs.lstatSync(instanceDir);
412
+ } catch (error) {
413
+ if (errorCode(error) !== "ENOENT") throw residentCacheTrustError("directory_unverifiable", instanceDir, error);
414
+ if (parentDescriptor !== undefined) assertResidentCacheDisposalParent(instanceDir, parentDescriptor);
415
+ if (!parentVerified && ownedResidentCacheInstanceDirs.has(instanceKey))
416
+ throw residentCacheTrustError("directory_absence_unverified", instanceDir, error);
417
+ ownedResidentCacheInstanceDirs.delete(instanceKey);
418
+ return;
419
+ }
393
420
  const quarantineDir = path.join(
394
421
  path.dirname(instanceDir),
395
422
  `${path.basename(instanceDir)}.dispose.${crypto.randomUUID()}`,
@@ -408,7 +435,9 @@ export function disposeVerifiedResidentCacheInstanceDir(instanceDir: string): vo
408
435
  descriptor = openVerifiedResidentCacheDirectory(instanceDir, uid);
409
436
  assertResidentCacheDirectoryPathMatchesDescriptor(instanceDir, descriptor, uid);
410
437
  const expected = fs.fstatSync(descriptor);
438
+ if (parentDescriptor !== undefined) assertResidentCacheDisposalParent(instanceDir, parentDescriptor);
411
439
  fs.renameSync(instanceDir, quarantineDir);
440
+ if (parentDescriptor !== undefined) assertResidentCacheDisposalParent(instanceDir, parentDescriptor);
412
441
  const moved = fs.lstatSync(quarantineDir);
413
442
  if (
414
443
  !moved.isDirectory() ||
@@ -1051,6 +1080,7 @@ export class BlobStore {
1051
1080
 
1052
1081
  interface EphemeralBlobStoreOptions {
1053
1082
  readonly adoptVerifiedDir?: boolean;
1083
+ readonly disposalParentDescriptor?: number;
1054
1084
  }
1055
1085
 
1056
1086
  /**
@@ -1097,10 +1127,12 @@ export class EphemeralBlobStore extends BlobStore {
1097
1127
  #bufferCacheBytes = 0;
1098
1128
  #adoptedVerifiedDir = false;
1099
1129
  #disposed = false;
1130
+ #disposalParentDescriptor: number | null = null;
1100
1131
 
1101
1132
  constructor(dir: string, options: EphemeralBlobStoreOptions = {}) {
1102
1133
  super(dir);
1103
1134
  this.#adoptedVerifiedDir = options.adoptVerifiedDir === true;
1135
+ this.#disposalParentDescriptor = options.disposalParentDescriptor ?? null;
1104
1136
  if (this.#adoptedVerifiedDir) return;
1105
1137
  fs.rmSync(dir, { recursive: true, force: true });
1106
1138
  fs.mkdirSync(dir, { recursive: true, mode: BLOB_DIR_MODE });
@@ -1114,10 +1146,15 @@ export class EphemeralBlobStore extends BlobStore {
1114
1146
  static adoptVerifiedDir(dir: string): EphemeralBlobStore {
1115
1147
  const uid = residentCacheOwnerUid(dir);
1116
1148
  let descriptor: number | null = null;
1149
+ let parentDescriptor: number | null = null;
1117
1150
  let failure: unknown;
1118
1151
  try {
1119
1152
  descriptor = openVerifiedResidentCacheDirectory(dir, uid);
1120
1153
  assertResidentCacheDirectoryPathMatchesDescriptor(dir, descriptor, uid);
1154
+ const parentDir = path.dirname(dir);
1155
+ const parentUid = residentCacheOwnerUid(parentDir);
1156
+ parentDescriptor = openVerifiedResidentCacheDirectory(parentDir, parentUid);
1157
+ assertResidentCacheDirectoryPathMatchesDescriptor(parentDir, parentDescriptor, parentUid);
1121
1158
  } catch (error) {
1122
1159
  failure = error;
1123
1160
  }
@@ -1129,10 +1166,18 @@ export class EphemeralBlobStore extends BlobStore {
1129
1166
  }
1130
1167
  }
1131
1168
  if (failure !== undefined) {
1169
+ if (parentDescriptor !== null) {
1170
+ try {
1171
+ fs.closeSync(parentDescriptor);
1172
+ } catch {
1173
+ // Preserve the adoption failure.
1174
+ }
1175
+ }
1132
1176
  removeEmptyResidentCacheInstanceDir(dir);
1133
1177
  throw residentCacheTrustError("instance_adoption_failed", dir, failure);
1134
1178
  }
1135
- return new EphemeralBlobStore(dir, { adoptVerifiedDir: true });
1179
+ if (parentDescriptor === null) throw new ResidentCacheTrustError("parent_authority_unavailable", dir);
1180
+ return new EphemeralBlobStore(dir, { adoptVerifiedDir: true, disposalParentDescriptor: parentDescriptor });
1136
1181
  }
1137
1182
 
1138
1183
  #cachePut(hash: string, data: Buffer): void {
@@ -1258,7 +1303,15 @@ export class EphemeralBlobStore extends BlobStore {
1258
1303
  this.#bufferCache.clear();
1259
1304
  this.#bufferCacheBytes = 0;
1260
1305
  if (this.#adoptedVerifiedDir) {
1261
- disposeVerifiedResidentCacheInstanceDir(this.dir);
1306
+ disposeVerifiedResidentCacheInstanceDir(this.dir, this.#disposalParentDescriptor ?? undefined);
1307
+ if (this.#disposalParentDescriptor !== null) {
1308
+ try {
1309
+ fs.closeSync(this.#disposalParentDescriptor);
1310
+ } catch {
1311
+ // Disposal is already complete; descriptor cleanup cannot restore the tree.
1312
+ }
1313
+ this.#disposalParentDescriptor = null;
1314
+ }
1262
1315
  this.#disposed = true;
1263
1316
  return;
1264
1317
  }
@@ -1566,8 +1619,10 @@ export interface CanonicalBlobEntry {
1566
1619
  readonly path: string;
1567
1620
  readonly bytes: number;
1568
1621
  readonly mtimeMs: number;
1569
- readonly dev: number;
1570
- readonly ino: number;
1622
+ readonly mtimeNs: bigint;
1623
+ readonly dev: bigint;
1624
+ readonly ino: bigint;
1625
+ readonly nlink: bigint;
1571
1626
  }
1572
1627
 
1573
1628
  const CANONICAL_BLOB_NAME = /^[0-9a-f]{64}$/;
@@ -1599,20 +1654,22 @@ export async function listCanonicalBlobs(dir: string): Promise<CanonicalBlobEntr
1599
1654
  for (const name of names) {
1600
1655
  if (!CANONICAL_BLOB_NAME.test(name)) continue;
1601
1656
  const blobPath = path.join(dir, name);
1602
- let stat: fs.Stats;
1657
+ let stat: fs.BigIntStats;
1603
1658
  try {
1604
- stat = await fsp.lstat(blobPath);
1659
+ stat = await fsp.lstat(blobPath, { bigint: true });
1605
1660
  } catch {
1606
1661
  continue;
1607
1662
  }
1608
- if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1) continue;
1663
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1n) continue;
1609
1664
  entries.push({
1610
1665
  hash: name,
1611
1666
  path: blobPath,
1612
- bytes: stat.size,
1613
- mtimeMs: stat.mtimeMs,
1667
+ bytes: Number(stat.size),
1668
+ mtimeMs: Number(stat.mtimeMs),
1669
+ mtimeNs: stat.mtimeNs,
1614
1670
  dev: stat.dev,
1615
1671
  ino: stat.ino,
1672
+ nlink: stat.nlink,
1616
1673
  });
1617
1674
  }
1618
1675
  return entries;
@@ -1635,32 +1692,45 @@ export async function removeCanonicalBlob(
1635
1692
  entry: CanonicalBlobEntry,
1636
1693
  options: { beforeUnlink?: () => Promise<boolean> } = {},
1637
1694
  ): Promise<{ removed: true } | { removed: false; reason: string; failed?: true }> {
1638
- let stat: fs.Stats;
1695
+ let stat: fs.BigIntStats;
1639
1696
  try {
1640
- stat = await fsp.lstat(entry.path);
1697
+ stat = await fsp.lstat(entry.path, { bigint: true });
1641
1698
  } catch (error) {
1642
1699
  if (isEnoent(error)) return { removed: false, reason: "blob_disappeared" };
1643
1700
  return { removed: false, reason: `blob_unverifiable: ${String(error)}`, failed: true };
1644
1701
  }
1645
- if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1) {
1702
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1n) {
1646
1703
  return { removed: false, reason: "blob_not_plain_file" };
1647
1704
  }
1648
1705
  if (
1649
1706
  stat.dev !== entry.dev ||
1650
1707
  stat.ino !== entry.ino ||
1651
- stat.size !== entry.bytes ||
1652
- stat.mtimeMs !== entry.mtimeMs
1708
+ stat.size !== BigInt(entry.bytes) ||
1709
+ stat.mtimeNs !== entry.mtimeNs
1653
1710
  ) {
1654
1711
  return { removed: false, reason: "blob_changed" };
1655
1712
  }
1656
1713
  if (options.beforeUnlink && !(await options.beforeUnlink())) {
1657
1714
  return { removed: false, reason: "blob_reference_evidence_changed" };
1658
1715
  }
1659
- try {
1660
- await fsp.unlink(entry.path);
1661
- return { removed: true };
1662
- } catch (error) {
1663
- if (isEnoent(error)) return { removed: false, reason: "blob_disappeared" };
1664
- return { removed: false, reason: `blob_unlink_failed: ${String(error)}`, failed: true };
1716
+ const removed = exactUnlink(entry.path, {
1717
+ dev: entry.dev,
1718
+ ino: entry.ino,
1719
+ nlink: entry.nlink,
1720
+ size: BigInt(entry.bytes),
1721
+ mtimeNs: entry.mtimeNs,
1722
+ sha256: entry.hash,
1723
+ quarantineName: `.gjc-gc-blob-${entry.hash}`,
1724
+ });
1725
+ if (removed.ok) return { removed: true };
1726
+ if (removed.code === "cleanup_pending" && removed.detachedPath) {
1727
+ try {
1728
+ await fsp.unlink(removed.detachedPath);
1729
+ return { removed: true };
1730
+ } catch (error) {
1731
+ return { removed: false, reason: `blob_cleanup_failed: ${String(error)}`, failed: true };
1732
+ }
1665
1733
  }
1734
+ if (removed.code === "not_found") return { removed: false, reason: "blob_disappeared" };
1735
+ return { removed: false, reason: `blob_unlink_failed: ${removed.code ?? "unknown"}`, failed: true };
1666
1736
  }