@gajae-code/coding-agent 0.7.2 → 0.7.4

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 (154) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/bin/gjc.js +4 -0
  3. package/dist/types/cli/mcp-cli.d.ts +25 -0
  4. package/dist/types/cli/plugin-cli.d.ts +2 -0
  5. package/dist/types/cli.d.ts +6 -0
  6. package/dist/types/commands/mcp.d.ts +70 -0
  7. package/dist/types/commands/plugin.d.ts +6 -0
  8. package/dist/types/commands/session.d.ts +6 -0
  9. package/dist/types/config/keybindings.d.ts +2 -2
  10. package/dist/types/config/model-profile-activation.d.ts +8 -1
  11. package/dist/types/deep-interview/plaintext-gate-guard.d.ts +11 -0
  12. package/dist/types/extensibility/gjc-plugins/compiler.d.ts +19 -0
  13. package/dist/types/extensibility/gjc-plugins/constrained-hooks.d.ts +29 -0
  14. package/dist/types/extensibility/gjc-plugins/index.d.ts +9 -0
  15. package/dist/types/extensibility/gjc-plugins/injection.d.ts +9 -0
  16. package/dist/types/extensibility/gjc-plugins/installer.d.ts +13 -0
  17. package/dist/types/extensibility/gjc-plugins/mcp-policy.d.ts +26 -0
  18. package/dist/types/extensibility/gjc-plugins/observability.d.ts +27 -0
  19. package/dist/types/extensibility/gjc-plugins/prompt-appendix.d.ts +16 -0
  20. package/dist/types/extensibility/gjc-plugins/registry.d.ts +32 -0
  21. package/dist/types/extensibility/gjc-plugins/runtime-adapters.d.ts +64 -0
  22. package/dist/types/extensibility/gjc-plugins/session-validation.d.ts +42 -0
  23. package/dist/types/extensibility/gjc-plugins/types.d.ts +158 -2
  24. package/dist/types/extensibility/gjc-plugins/validation.d.ts +8 -1
  25. package/dist/types/gjc-runtime/launch-tmux.d.ts +1 -0
  26. package/dist/types/gjc-runtime/psmux-detect.d.ts +78 -0
  27. package/dist/types/gjc-runtime/team-runtime.d.ts +2 -0
  28. package/dist/types/gjc-runtime/tmux-common.d.ts +20 -1
  29. package/dist/types/gjc-runtime/tmux-sessions.d.ts +18 -0
  30. package/dist/types/main.d.ts +2 -0
  31. package/dist/types/modes/components/custom-editor.d.ts +1 -1
  32. package/dist/types/modes/components/model-selector.d.ts +8 -0
  33. package/dist/types/modes/components/status-line/git-utils.d.ts +6 -0
  34. package/dist/types/modes/theme/defaults/index.d.ts +99 -0
  35. package/dist/types/notifications/html-format.d.ts +11 -0
  36. package/dist/types/notifications/index.d.ts +149 -1
  37. package/dist/types/notifications/lifecycle-commands.d.ts +72 -0
  38. package/dist/types/notifications/lifecycle-control-runtime.d.ts +98 -0
  39. package/dist/types/notifications/lifecycle-orchestrator.d.ts +144 -0
  40. package/dist/types/notifications/operator-runtime.d.ts +52 -0
  41. package/dist/types/notifications/rate-limit-pool.d.ts +2 -0
  42. package/dist/types/notifications/recent-activity.d.ts +35 -0
  43. package/dist/types/notifications/telegram-daemon.d.ts +114 -16
  44. package/dist/types/notifications/telegram-reference.d.ts +3 -1
  45. package/dist/types/notifications/topic-registry.d.ts +12 -9
  46. package/dist/types/runtime-mcp/types.d.ts +7 -0
  47. package/dist/types/sdk.d.ts +2 -0
  48. package/dist/types/session/agent-session.d.ts +14 -4
  49. package/dist/types/session/blob-store.d.ts +25 -0
  50. package/dist/types/session/session-manager.d.ts +57 -0
  51. package/dist/types/slash-commands/helpers/fast-status-report.d.ts +6 -0
  52. package/dist/types/system-prompt.d.ts +2 -0
  53. package/dist/types/task/executor.d.ts +9 -1
  54. package/dist/types/tools/composer-bash-policy.d.ts +14 -0
  55. package/dist/types/tools/index.d.ts +3 -1
  56. package/dist/types/utils/changelog.d.ts +1 -0
  57. package/dist/types/web/insane/url-guard.d.ts +6 -3
  58. package/dist/types/web/scrapers/types.d.ts +5 -0
  59. package/dist/types/web/scrapers/utils.d.ts +7 -1
  60. package/package.json +11 -9
  61. package/scripts/g004-tmux-smoke.ts +100 -0
  62. package/scripts/g005-daemon-smoke.ts +181 -0
  63. package/scripts/g011-daemon-path-smoke.ts +153 -0
  64. package/src/cli/mcp-cli.ts +272 -0
  65. package/src/cli/plugin-cli.ts +66 -3
  66. package/src/cli.ts +27 -6
  67. package/src/commands/mcp.ts +117 -0
  68. package/src/commands/plugin.ts +4 -0
  69. package/src/commands/session.ts +18 -0
  70. package/src/config/keybindings.ts +2 -2
  71. package/src/config/model-profile-activation.ts +55 -7
  72. package/src/deep-interview/plaintext-gate-guard.ts +94 -0
  73. package/src/defaults/gjc/extensions/grok-cli-vendor/biome.json +1 -1
  74. package/src/defaults/gjc/skills/deep-interview/SKILL.md +7 -6
  75. package/src/defaults/gjc/skills/team/SKILL.md +5 -3
  76. package/src/defaults/gjc/skills/ultragoal/SKILL.md +41 -13
  77. package/src/export/html/index.ts +2 -2
  78. package/src/extensibility/extensions/runner.ts +1 -0
  79. package/src/extensibility/gjc-plugins/compiler.ts +351 -0
  80. package/src/extensibility/gjc-plugins/constrained-hooks.ts +170 -0
  81. package/src/extensibility/gjc-plugins/index.ts +9 -0
  82. package/src/extensibility/gjc-plugins/injection.ts +109 -0
  83. package/src/extensibility/gjc-plugins/installer.ts +434 -0
  84. package/src/extensibility/gjc-plugins/loader.ts +3 -1
  85. package/src/extensibility/gjc-plugins/mcp-policy.ts +239 -0
  86. package/src/extensibility/gjc-plugins/observability.ts +84 -0
  87. package/src/extensibility/gjc-plugins/paths.ts +1 -1
  88. package/src/extensibility/gjc-plugins/prompt-appendix.ts +109 -0
  89. package/src/extensibility/gjc-plugins/registry.ts +180 -0
  90. package/src/extensibility/gjc-plugins/runtime-adapters.ts +234 -0
  91. package/src/extensibility/gjc-plugins/schema.ts +250 -20
  92. package/src/extensibility/gjc-plugins/session-validation.ts +147 -0
  93. package/src/extensibility/gjc-plugins/types.ts +199 -3
  94. package/src/extensibility/gjc-plugins/validation.ts +80 -0
  95. package/src/extensibility/skills.ts +15 -0
  96. package/src/gjc-runtime/launch-tmux.ts +61 -7
  97. package/src/gjc-runtime/psmux-detect.ts +239 -0
  98. package/src/gjc-runtime/team-runtime.ts +56 -23
  99. package/src/gjc-runtime/tmux-common.ts +30 -3
  100. package/src/gjc-runtime/tmux-sessions.ts +51 -1
  101. package/src/gjc-runtime/ultragoal-guard.ts +25 -8
  102. package/src/gjc-runtime/ultragoal-runtime.ts +75 -15
  103. package/src/hooks/skill-state.ts +57 -0
  104. package/src/internal-urls/docs-index.generated.ts +12 -8
  105. package/src/main.ts +14 -3
  106. package/src/modes/bridge/bridge-mode.ts +11 -0
  107. package/src/modes/components/custom-editor.ts +2 -0
  108. package/src/modes/components/footer.ts +2 -3
  109. package/src/modes/components/hook-editor.ts +1 -1
  110. package/src/modes/components/hook-selector.ts +67 -43
  111. package/src/modes/components/model-selector.ts +56 -11
  112. package/src/modes/components/status-line/git-utils.ts +25 -0
  113. package/src/modes/components/status-line.ts +10 -11
  114. package/src/modes/components/welcome.ts +2 -3
  115. package/src/modes/controllers/extension-ui-controller.ts +0 -27
  116. package/src/modes/controllers/selector-controller.ts +53 -11
  117. package/src/modes/interactive-mode.ts +4 -1
  118. package/src/modes/shared/agent-wire/scopes.ts +1 -1
  119. package/src/modes/theme/defaults/gruvbox-dark.json +99 -0
  120. package/src/modes/theme/defaults/index.ts +2 -0
  121. package/src/modes/utils/hotkeys-markdown.ts +1 -1
  122. package/src/notifications/html-format.ts +38 -0
  123. package/src/notifications/index.ts +242 -12
  124. package/src/notifications/lifecycle-commands.ts +228 -0
  125. package/src/notifications/lifecycle-control-runtime.ts +400 -0
  126. package/src/notifications/lifecycle-orchestrator.ts +358 -0
  127. package/src/notifications/operator-runtime.ts +171 -0
  128. package/src/notifications/rate-limit-pool.ts +19 -0
  129. package/src/notifications/recent-activity.ts +132 -0
  130. package/src/notifications/telegram-daemon.ts +778 -257
  131. package/src/notifications/telegram-reference.ts +25 -7
  132. package/src/notifications/topic-registry.ts +23 -9
  133. package/src/prompts/agents/executor.md +2 -2
  134. package/src/runtime-mcp/transports/stdio.ts +38 -4
  135. package/src/runtime-mcp/types.ts +7 -0
  136. package/src/sdk.ts +157 -10
  137. package/src/session/agent-session.ts +166 -74
  138. package/src/session/blob-store.ts +196 -8
  139. package/src/session/session-manager.ts +678 -7
  140. package/src/slash-commands/builtin-registry.ts +23 -3
  141. package/src/slash-commands/helpers/fast-status-report.ts +13 -3
  142. package/src/slash-commands/helpers/parse.ts +2 -1
  143. package/src/system-prompt.ts +9 -0
  144. package/src/task/executor.ts +31 -7
  145. package/src/task/index.ts +2 -0
  146. package/src/tools/ask.ts +5 -1
  147. package/src/tools/bash.ts +9 -0
  148. package/src/tools/composer-bash-policy.ts +96 -0
  149. package/src/tools/fetch.ts +18 -2
  150. package/src/tools/index.ts +3 -1
  151. package/src/utils/changelog.ts +8 -0
  152. package/src/web/insane/url-guard.ts +18 -14
  153. package/src/web/scrapers/types.ts +143 -45
  154. package/src/web/scrapers/utils.ts +70 -19
@@ -50,7 +50,11 @@ import {
50
50
  type SummaryOptions,
51
51
  shouldCompact,
52
52
  } from "@gajae-code/agent-core/compaction";
53
- import { DEFAULT_PRUNE_CONFIG, pruneToolOutputs } from "@gajae-code/agent-core/compaction/pruning";
53
+ import {
54
+ DEFAULT_PRUNE_CONFIG,
55
+ pruneAssistantToolArguments,
56
+ pruneToolOutputs,
57
+ } from "@gajae-code/agent-core/compaction/pruning";
54
58
  import type {
55
59
  AssistantMessage,
56
60
  Context,
@@ -127,7 +131,7 @@ import {
127
131
  import { type AsyncJob, type AsyncJobDeliveryState, AsyncJobManager } from "../async";
128
132
  import { reset as resetCapabilities } from "../capability";
129
133
  import type { Rule } from "../capability/rule";
130
- import { MODEL_ROLE_IDS, type ModelRegistry } from "../config/model-registry";
134
+ import { GJC_MODEL_ASSIGNMENT_TARGETS, MODEL_ROLE_IDS, type ModelRegistry } from "../config/model-registry";
131
135
  import {
132
136
  extractExplicitThinkingSelector,
133
137
  formatModelSelectorValue,
@@ -421,6 +425,13 @@ export interface AgentSessionConfig {
421
425
  * **MUST NOT** dispose it on their own teardown.
422
426
  */
423
427
  ownedAsyncJobManager?: AsyncJobManager;
428
+ /**
429
+ * MCPManager whose lifecycle this session owns (top-level sessions that
430
+ * connected plugin-bundle MCP servers). Only the owned manager is
431
+ * disconnected on dispose; subagents and callers that merely observe the
432
+ * process-global manager **MUST NOT** dispose it on their own teardown.
433
+ */
434
+ ownedMcpManager?: MCPManager;
424
435
  /** Optional fork-context seed used to initialize a child session before its first prompt. */
425
436
  forkContextSeed?: ForkContextSeed;
426
437
  /** Optional provider state override. Fork-context children should omit this by default. */
@@ -958,6 +969,7 @@ export class AgentSession {
958
969
  * this undefined and **MUST NOT** dispose the global instance on teardown.
959
970
  */
960
971
  readonly #ownedAsyncJobManager: AsyncJobManager | undefined;
972
+ readonly #ownedMcpManager: MCPManager | undefined;
961
973
  #pendingPythonMessages: PythonExecutionMessage[] = [];
962
974
  #activeEvalExecutions = new Set<Promise<unknown>>();
963
975
  #evalExecutionDisposing = false;
@@ -1087,6 +1099,16 @@ export class AgentSession {
1087
1099
  #lastSuccessfulYieldToolCallId: string | undefined = undefined;
1088
1100
  #promptGeneration = 0;
1089
1101
  #providerSessionState = new Map<string, ProviderSessionState>();
1102
+ /**
1103
+ * Provider keys for which the Anthropic fast-mode auto-fallback fired this
1104
+ * session (the provider rejected `speed:"fast"` and we retried without it).
1105
+ * Provider/API-session scoped — matching the provider's own per-session
1106
+ * `fastModeDisabled` flag — NOT model-keyed. Transient (never persisted): it
1107
+ * suppresses the current-model fast indicator and dedups the one-time warning
1108
+ * WITHOUT mutating the user's intended `serviceTier`, so task subagents still
1109
+ * inherit the intended tier and a different provider still shows fast.
1110
+ */
1111
+ #fastModeAutoDisabledProviderKeys = new Set<string>();
1090
1112
  #hindsightSessionState: HindsightSessionState | undefined = undefined;
1091
1113
  readonly rawSseDebugBuffer: RawSseDebugBuffer;
1092
1114
 
@@ -1161,6 +1183,7 @@ export class AgentSession {
1161
1183
  // Power assertions are taken per turn (see #beginInFlight); nothing acquired here.
1162
1184
  this.#evalKernelOwnerId = config.evalKernelOwnerId ?? `agent-session:${Snowflake.next()}`;
1163
1185
  this.#ownedAsyncJobManager = config.ownedAsyncJobManager;
1186
+ this.#ownedMcpManager = config.ownedMcpManager;
1164
1187
  this.#scopedModels = config.scopedModels ?? [];
1165
1188
  this.#thinkingLevel = config.thinkingLevel;
1166
1189
  this.#promptTemplates = config.promptTemplates ?? [];
@@ -1976,12 +1999,18 @@ export class AgentSession {
1976
1999
  const currentGrantsAnthropicPriority =
1977
2000
  this.serviceTier === "priority" || this.serviceTier === "claude-only";
1978
2001
  if (assistantMsg.disabledFeatures?.includes("priority") && currentGrantsAnthropicPriority) {
1979
- this.setServiceTier(undefined);
1980
- this.emitNotice(
1981
- "warning",
1982
- "Priority/fast mode rejected for this model; retried without it. Fast mode is now off.",
1983
- "priority",
1984
- );
2002
+ // The provider auto-dropped `speed:"fast"` for the current model's
2003
+ // provider this turn. Record a transient, provider-scoped marker
2004
+ // instead of clearing the user's intended tier, so task subagents
2005
+ // still inherit it and a different provider still gets fast mode.
2006
+ // Warn once per provider until the user re-arms with `/fast on`.
2007
+ if (this.#markFastModeAutoDisabledForCurrentModel()) {
2008
+ this.emitNotice(
2009
+ "warning",
2010
+ "Priority/fast mode rejected for this model; retried without it. Fast mode is off for this model until you re-enable it with /fast on.",
2011
+ "priority",
2012
+ );
2013
+ }
1985
2014
  }
1986
2015
  // Resolve TTSR resume gate before checking for new deferred injections.
1987
2016
  // Gate on #ttsrAbortPending, not stopReason: a non-TTSR abort (e.g. streaming
@@ -3240,10 +3269,14 @@ export class AgentSession {
3240
3269
  AsyncJobManager.setInstance(undefined);
3241
3270
  }
3242
3271
  }
3243
- const mcpManager = MCPManager.instance();
3244
- if (mcpManager) {
3245
- await mcpManager.disconnectAll();
3246
- if (MCPManager.instance() === mcpManager) {
3272
+ // Only disconnect the MCP manager THIS session owns (top-level sessions that
3273
+ // connected plugin-bundle MCP servers). Subagents and callers that merely
3274
+ // observe the process-global manager must never tear down a manager they do
3275
+ // not own. Mirrors the ownedAsyncJobManager rule above.
3276
+ const ownedMcpManager = this.#ownedMcpManager;
3277
+ if (ownedMcpManager) {
3278
+ await ownedMcpManager.disconnectAll();
3279
+ if (MCPManager.instance() === ownedMcpManager) {
3247
3280
  MCPManager.setInstance(undefined);
3248
3281
  }
3249
3282
  }
@@ -5604,7 +5637,7 @@ export class AgentSession {
5604
5637
  }
5605
5638
 
5606
5639
  #syncTodoPhasesFromBranch(): void {
5607
- const phases = getLatestTodoPhasesFromEntries(this.sessionManager.getBranch());
5640
+ const phases = getLatestTodoPhasesFromEntries(this.sessionManager.getActivePathEntriesCanonical());
5608
5641
  // Strip completed/abandoned tasks — they were done in a previous run,
5609
5642
  // so they have no bearing on progress tracking for the new turn.
5610
5643
  for (const phase of phases) {
@@ -5613,6 +5646,42 @@ export class AgentSession {
5613
5646
  this.setTodoPhases(phases.filter(p => p.tasks.length > 0));
5614
5647
  }
5615
5648
 
5649
+ async #applyCompactionPostAppend(
5650
+ compactionEntryId: string,
5651
+ firstKeptEntryId: string,
5652
+ fromExtension?: boolean,
5653
+ ): Promise<CompactionEntry | undefined> {
5654
+ const eviction = this.sessionManager.evictCompactedContent(firstKeptEntryId, compactionEntryId);
5655
+ if (eviction.evictedEntries > 0) await this.sessionManager.rewriteEntries();
5656
+ const sessionContext = this.buildDisplaySessionContext();
5657
+ this.agent.replaceMessages(sessionContext.messages);
5658
+ this.#syncTodoPhasesFromBranch();
5659
+ this.#closeCodexProviderSessionsForHistoryRewrite();
5660
+
5661
+ // Get the saved compaction entry for the hook without materializing all entries.
5662
+ const savedCompactionEntry = this.sessionManager.getEntryForFidelity(compactionEntryId) as
5663
+ | CompactionEntry
5664
+ | undefined;
5665
+
5666
+ if (this.#extensionRunner && savedCompactionEntry) {
5667
+ await this.#extensionRunner.emit({
5668
+ type: "session_compact",
5669
+ compactionEntry: savedCompactionEntry,
5670
+ fromExtension: fromExtension ?? false,
5671
+ });
5672
+ }
5673
+
5674
+ return savedCompactionEntry;
5675
+ }
5676
+
5677
+ async applyCompactionPostAppendForTests(
5678
+ compactionEntryId: string,
5679
+ firstKeptEntryId: string,
5680
+ fromExtension?: boolean,
5681
+ ): Promise<CompactionEntry | undefined> {
5682
+ return this.#applyCompactionPostAppend(compactionEntryId, firstKeptEntryId, fromExtension);
5683
+ }
5684
+
5616
5685
  #cloneTodoPhases(phases: TodoPhase[]): TodoPhase[] {
5617
5686
  return phases.map(phase => ({
5618
5687
  name: phase.name,
@@ -6242,33 +6311,81 @@ export class AgentSession {
6242
6311
  return resolveServiceTier(this.#subagentServiceTier(), provider) === "priority";
6243
6312
  }
6244
6313
 
6314
+ /**
6315
+ * Provider/API-session key used to scope the fast-mode auto-disable marker.
6316
+ * Mirrors the provider's own per-session `fastModeDisabled` scope (the key in
6317
+ * {@link #providerSessionState} cleared by `clearAnthropicFastModeFallback`),
6318
+ * so the marker is provider-scoped, never model-keyed. Returns `undefined`
6319
+ * when no model/provider is selected.
6320
+ */
6321
+ #fastModeProviderKey(provider: string | undefined = this.model?.provider): string | undefined {
6322
+ return provider;
6323
+ }
6324
+
6325
+ /**
6326
+ * Record that the current model's provider had fast mode auto-dropped this
6327
+ * session. Returns `true` only when the provider key is newly marked, so the
6328
+ * caller emits the one-time warning exactly once per provider until re-arm.
6329
+ */
6330
+ #markFastModeAutoDisabledForCurrentModel(): boolean {
6331
+ const key = this.#fastModeProviderKey();
6332
+ if (key === undefined) return false;
6333
+ if (this.#fastModeAutoDisabledProviderKeys.has(key)) return false;
6334
+ this.#fastModeAutoDisabledProviderKeys.add(key);
6335
+ return true;
6336
+ }
6337
+
6338
+ /** True when `provider`'s fast mode was auto-disabled this session. */
6339
+ #isFastModeAutoDisabledForProvider(provider?: string): boolean {
6340
+ const key = this.#fastModeProviderKey(provider);
6341
+ return key !== undefined && this.#fastModeAutoDisabledProviderKeys.has(key);
6342
+ }
6343
+
6344
+ /**
6345
+ * Re-arm fast mode after an auto-disable: clear the provider's sticky
6346
+ * `fastModeDisabled` fallback flag and the session auto-disable markers so the
6347
+ * next request carries `speed:"fast"` again and a future rejection can warn
6348
+ * once more. Called on explicit re-enable (`/fast on`, re-arming tier change),
6349
+ * never by the transient Q1 auto-disable path.
6350
+ */
6351
+ #rearmFastMode(): void {
6352
+ clearAnthropicFastModeFallback(this.#providerSessionState);
6353
+ this.#fastModeAutoDisabledProviderKeys.clear();
6354
+ }
6355
+
6245
6356
  /**
6246
6357
  * True when the configured `serviceTier` resolves to `"priority"` for the
6247
- * *currently selected model's provider*. Returns false for scoped tiers
6248
- * that don't match (e.g. `"openai-only"` on an anthropic model) and when
6249
- * no model is selected.
6358
+ * *currently selected model's provider* AND fast mode was not auto-disabled
6359
+ * for that provider this session. This is the current-model EFFECTIVE
6360
+ * predicate (what the next request actually does); use {@link isFastForProvider}
6361
+ * for pure configured intent (e.g. subagent/`modelRoles` display rows).
6250
6362
  */
6251
6363
  isFastModeActive(): boolean {
6252
- return this.isFastForProvider(this.model?.provider);
6364
+ const provider = this.model?.provider;
6365
+ return this.isFastForProvider(provider) && !this.#isFastModeAutoDisabledForProvider(provider);
6253
6366
  }
6254
6367
 
6255
6368
  setServiceTier(serviceTier: ServiceTier | undefined): void {
6256
- if (this.serviceTier === serviceTier) return;
6257
- // Re-arming priority on Anthropic? Clear the per-session auto-fallback
6258
- // sticky disable so the next request actually carries `speed: "fast"`
6259
- // again. Without this, `/fast on` (or user switching to a tier that
6260
- // grants anthropic priority) after an auto-disable is a silent no-op
6261
- // and the warning notice fires every turn.
6369
+ // Re-arming a priority-granting tier always clears the per-session
6370
+ // auto-fallback sticky disable AND the auto-disable markers so the next
6371
+ // request carries `speed: "fast"` again — even when the tier is unchanged
6372
+ // (re-selecting the same tier is a deliberate re-arm), and before the
6373
+ // no-op early-return below.
6262
6374
  if (serviceTier === "priority" || serviceTier === "claude-only") {
6263
- clearAnthropicFastModeFallback(this.#providerSessionState);
6375
+ this.#rearmFastMode();
6264
6376
  }
6377
+ if (this.serviceTier === serviceTier) return;
6265
6378
  this.agent.serviceTier = serviceTier;
6266
6379
  this.sessionManager.appendServiceTierChange(serviceTier ?? null);
6267
6380
  }
6268
6381
 
6269
6382
  setFastMode(enabled: boolean): void {
6270
6383
  if (enabled && this.isFastModeEnabled()) {
6271
- // Already on under any scope — keep the user's scoped value.
6384
+ // Intent already grants fast mode under some scope — keep the user's
6385
+ // scoped value but still re-arm, so an explicit `/fast on` after a
6386
+ // provider auto-disable actually clears the sticky fallback + markers
6387
+ // (otherwise it is a silent no-op). No history append: intent is unchanged.
6388
+ this.#rearmFastMode();
6272
6389
  return;
6273
6390
  }
6274
6391
  this.setServiceTier(enabled ? "priority" : undefined);
@@ -6326,19 +6443,23 @@ export class AgentSession {
6326
6443
  async #pruneToolOutputs(): Promise<{ prunedCount: number; tokensSaved: number } | undefined> {
6327
6444
  const branchEntries = this.sessionManager.getBranch();
6328
6445
  const result = pruneToolOutputs(branchEntries, DEFAULT_PRUNE_CONFIG);
6329
- if (result.prunedCount === 0) {
6446
+ const argumentResult = pruneAssistantToolArguments(branchEntries, DEFAULT_PRUNE_CONFIG);
6447
+ const tokensSaved = result.tokensSaved + argumentResult.argumentTokensSaved;
6448
+ const prunedCount = result.prunedCount + argumentResult.argumentPrunedCount;
6449
+ if (prunedCount === 0) {
6330
6450
  return undefined;
6331
6451
  }
6332
6452
 
6333
6453
  // getBranch() returns materialized copies for blob-externalized entries, so
6334
6454
  // the pruning mutations must be written back into the canonical store.
6335
- this.sessionManager.applyEntryMessageUpdates(result.prunedEntries);
6455
+ const combined = [...result.prunedEntries, ...argumentResult.prunedEntries];
6456
+ this.sessionManager.applyEntryMessageUpdates(combined);
6336
6457
  await this.sessionManager.rewriteEntries();
6337
6458
  const sessionContext = this.buildDisplaySessionContext();
6338
6459
  this.agent.replaceMessages(sessionContext.messages);
6339
6460
  this.#syncTodoPhasesFromBranch();
6340
6461
  this.#closeCodexProviderSessionsForHistoryRewrite();
6341
- return result;
6462
+ return { prunedCount, tokensSaved };
6342
6463
  }
6343
6464
 
6344
6465
  /**
@@ -6456,7 +6577,7 @@ export class AgentSession {
6456
6577
  throw new CompactionCancelledError();
6457
6578
  }
6458
6579
 
6459
- this.sessionManager.appendCompaction(
6580
+ const compactionEntryId = this.sessionManager.appendCompaction(
6460
6581
  summary,
6461
6582
  shortSummary,
6462
6583
  firstKeptEntryId,
@@ -6465,24 +6586,7 @@ export class AgentSession {
6465
6586
  fromExtension,
6466
6587
  preserveData,
6467
6588
  );
6468
- const newEntries = this.sessionManager.getEntries();
6469
- const sessionContext = this.buildDisplaySessionContext();
6470
- this.agent.replaceMessages(sessionContext.messages);
6471
- this.#syncTodoPhasesFromBranch();
6472
- this.#closeCodexProviderSessionsForHistoryRewrite();
6473
-
6474
- // Get the saved compaction entry for the hook
6475
- const savedCompactionEntry = newEntries.find(e => e.type === "compaction" && e.summary === summary) as
6476
- | CompactionEntry
6477
- | undefined;
6478
-
6479
- if (this.#extensionRunner && savedCompactionEntry) {
6480
- await this.#extensionRunner.emit({
6481
- type: "session_compact",
6482
- compactionEntry: savedCompactionEntry,
6483
- fromExtension,
6484
- });
6485
- }
6589
+ await this.#applyCompactionPostAppend(compactionEntryId, firstKeptEntryId, fromExtension);
6486
6590
 
6487
6591
  const compactionResult: CompactionResult = {
6488
6592
  summary,
@@ -7480,11 +7584,14 @@ export class AgentSession {
7480
7584
  availableModels: Model[],
7481
7585
  currentModel: Model | undefined,
7482
7586
  ): ResolvedModelRoleValue {
7587
+ const target = GJC_MODEL_ASSIGNMENT_TARGETS[role as keyof typeof GJC_MODEL_ASSIGNMENT_TARGETS];
7483
7588
  const roleModelStr =
7484
- role === "default"
7485
- ? (this.settings.getModelRole("default") ??
7486
- (currentModel ? `${currentModel.provider}/${currentModel.id}` : undefined))
7487
- : this.settings.getModelRole(role);
7589
+ target?.settingsPath === "task.agentModelOverrides"
7590
+ ? this.settings.get("task.agentModelOverrides")[role]
7591
+ : role === "default"
7592
+ ? (this.settings.getModelRole("default") ??
7593
+ (currentModel ? `${currentModel.provider}/${currentModel.id}` : undefined))
7594
+ : this.settings.getModelRole(role);
7488
7595
 
7489
7596
  if (!roleModelStr) {
7490
7597
  return { model: undefined, thinkingLevel: undefined, explicitThinkingLevel: false, warning: undefined };
@@ -7969,7 +8076,7 @@ export class AgentSession {
7969
8076
  return;
7970
8077
  }
7971
8078
 
7972
- this.sessionManager.appendCompaction(
8079
+ const compactionEntryId = this.sessionManager.appendCompaction(
7973
8080
  summary,
7974
8081
  shortSummary,
7975
8082
  firstKeptEntryId,
@@ -7978,24 +8085,7 @@ export class AgentSession {
7978
8085
  fromExtension,
7979
8086
  preserveData,
7980
8087
  );
7981
- const newEntries = this.sessionManager.getEntries();
7982
- const sessionContext = this.buildDisplaySessionContext();
7983
- this.agent.replaceMessages(sessionContext.messages);
7984
- this.#syncTodoPhasesFromBranch();
7985
- this.#closeCodexProviderSessionsForHistoryRewrite();
7986
-
7987
- // Get the saved compaction entry for the hook
7988
- const savedCompactionEntry = newEntries.find(e => e.type === "compaction" && e.summary === summary) as
7989
- | CompactionEntry
7990
- | undefined;
7991
-
7992
- if (this.#extensionRunner && savedCompactionEntry) {
7993
- await this.#extensionRunner.emit({
7994
- type: "session_compact",
7995
- compactionEntry: savedCompactionEntry,
7996
- fromExtension,
7997
- });
7998
- }
8088
+ await this.#applyCompactionPostAppend(compactionEntryId, firstKeptEntryId, fromExtension);
7999
8089
 
8000
8090
  const result: CompactionResult = {
8001
8091
  summary,
@@ -9613,7 +9703,7 @@ export class AgentSession {
9613
9703
  cancelled: boolean;
9614
9704
  }> {
9615
9705
  const previousSessionFile = this.sessionFile;
9616
- const selectedEntry = this.sessionManager.getEntry(entryId);
9706
+ const selectedEntry = this.sessionManager.getEntryForFidelity(entryId);
9617
9707
 
9618
9708
  if (selectedEntry?.type !== "message" || selectedEntry.message.role !== "user") {
9619
9709
  throw new Error("Invalid entry ID for branching");
@@ -9711,7 +9801,7 @@ export class AgentSession {
9711
9801
  throw new Error("No model available for summarization");
9712
9802
  }
9713
9803
 
9714
- const targetEntry = this.sessionManager.getEntry(targetId);
9804
+ const targetEntry = this.sessionManager.getEntryForFidelity(targetId);
9715
9805
  if (!targetEntry) {
9716
9806
  throw new Error(`Entry ${targetId} not found`);
9717
9807
  }
@@ -9867,9 +9957,11 @@ export class AgentSession {
9867
9957
 
9868
9958
  for (const entry of entries) {
9869
9959
  if (entry.type !== "message") continue;
9870
- if (entry.message.role !== "user") continue;
9960
+ const fidelityEntry = this.sessionManager.getEntryForFidelity(entry.id);
9961
+ if (fidelityEntry?.type !== "message") continue;
9962
+ if (fidelityEntry.message.role !== "user") continue;
9871
9963
 
9872
- const text = this.#extractUserMessageText(entry.message.content);
9964
+ const text = this.#extractUserMessageText(fidelityEntry.message.content);
9873
9965
  if (text) {
9874
9966
  result.push({ entryId: entry.id, text });
9875
9967
  }
@@ -11,6 +11,82 @@ export interface BlobPutResult {
11
11
  get ref(): string;
12
12
  }
13
13
 
14
+ export interface CheckedBlobPutResult extends BlobPutResult {
15
+ bytes: number;
16
+ }
17
+
18
+ export class BlobCorruptError extends Error {
19
+ constructor(
20
+ readonly hash: string,
21
+ readonly path: string,
22
+ ) {
23
+ super(`Blob ${hash} at ${path} failed SHA-256 verification`);
24
+ this.name = "BlobCorruptError";
25
+ }
26
+ }
27
+
28
+ function sha256Hex(data: Buffer): string {
29
+ return new Bun.SHA256().update(data).digest("hex");
30
+ }
31
+
32
+ function makeBlobPutResult(hash: string, blobPath: string): BlobPutResult;
33
+ function makeBlobPutResult(hash: string, blobPath: string, bytes: number): CheckedBlobPutResult;
34
+ function makeBlobPutResult(hash: string, blobPath: string, bytes?: number): BlobPutResult | CheckedBlobPutResult {
35
+ const result = {
36
+ hash,
37
+ path: blobPath,
38
+ get ref() {
39
+ return `${BLOB_PREFIX}${hash}`;
40
+ },
41
+ };
42
+ if (bytes === undefined) return result;
43
+ return { ...result, bytes };
44
+ }
45
+
46
+ function fsyncDirBestEffortSync(dir: string): void {
47
+ let fd: number | null = null;
48
+ try {
49
+ fd = fs.openSync(dir, "r");
50
+ fs.fsyncSync(fd);
51
+ } catch {
52
+ // Best-effort only: some platforms/filesystems do not support fsync on directories.
53
+ } finally {
54
+ if (fd !== null) fs.closeSync(fd);
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Best-effort fsync of an installed blob file. Used on install paths that
60
+ * create the destination by copy (not hard-link from an already-fsynced temp),
61
+ * so the durable-install contract holds there too. Failures are best-effort:
62
+ * some platforms/filesystems reject fsync on read-only handles.
63
+ */
64
+ function fsyncFileBestEffortSync(filePath: string): void {
65
+ let fd: number | null = null;
66
+ try {
67
+ fd = fs.openSync(filePath, "r");
68
+ fs.fsyncSync(fd);
69
+ } catch {
70
+ // Best-effort only.
71
+ } finally {
72
+ if (fd !== null) fs.closeSync(fd);
73
+ }
74
+ }
75
+
76
+ function uniqueTempBlobPath(dir: string, hash: string): string {
77
+ return path.join(dir, `${hash}.tmp.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}`);
78
+ }
79
+
80
+ function verifyBlobBytesSync(hash: string, blobPath: string, data: Buffer): void {
81
+ if (sha256Hex(data) !== hash) throw new BlobCorruptError(hash, blobPath);
82
+ }
83
+
84
+ function verifyBlobFileSync(hash: string, blobPath: string): Buffer {
85
+ const data = fs.readFileSync(blobPath);
86
+ verifyBlobBytesSync(hash, blobPath, data);
87
+ return data;
88
+ }
89
+
14
90
  /**
15
91
  * Content-addressed blob store for externalizing large binary data (images) from session JSONL files.
16
92
  *
@@ -60,6 +136,79 @@ export class BlobStore {
60
136
  return result;
61
137
  }
62
138
 
139
+ /**
140
+ * Durably install binary data as an immutable content-addressed blob.
141
+ *
142
+ * Callers that persist references to this blob must mutate canonical session entries only
143
+ * after this method returns successfully. A corrupt pre-existing target is reported with
144
+ * {@link BlobCorruptError}; it is never silently overwritten or trusted.
145
+ */
146
+ putImmutableSync(data: Buffer): CheckedBlobPutResult {
147
+ const hash = sha256Hex(data);
148
+ const blobPath = path.join(this.dir, hash);
149
+ const result = makeBlobPutResult(hash, blobPath, data.byteLength);
150
+ fs.mkdirSync(this.dir, { recursive: true });
151
+
152
+ if (fs.existsSync(blobPath)) {
153
+ verifyBlobFileSync(hash, blobPath);
154
+ return result;
155
+ }
156
+
157
+ const tempPath = uniqueTempBlobPath(this.dir, hash);
158
+ try {
159
+ const fd = fs.openSync(tempPath, "wx");
160
+ try {
161
+ fs.writeFileSync(fd, data);
162
+ fs.fsyncSync(fd);
163
+ } finally {
164
+ fs.closeSync(fd);
165
+ }
166
+
167
+ try {
168
+ fs.linkSync(tempPath, blobPath);
169
+ } catch (err) {
170
+ if (isEnoent(err)) throw err;
171
+ const code = typeof err === "object" && err !== null && "code" in err ? err.code : undefined;
172
+ if (code === "EEXIST") {
173
+ verifyBlobFileSync(hash, blobPath);
174
+ return result;
175
+ }
176
+ if (code === "EPERM" || code === "ENOTSUP" || code === "EOPNOTSUPP") {
177
+ // Hard links unsupported (e.g. cross-device / some network FS). Use an
178
+ // EXCLUSIVE copy so a concurrently installed winner is never overwritten;
179
+ // verify it by hash on EEXIST instead of clobbering it.
180
+ try {
181
+ fs.copyFileSync(tempPath, blobPath, fs.constants.COPYFILE_EXCL);
182
+ // The temp was fsync'd before install, but copyFileSync creates a
183
+ // fresh destination whose bytes are not yet durable — fsync it so the
184
+ // durable-install contract holds on hard-link-unsupported paths too.
185
+ fsyncFileBestEffortSync(blobPath);
186
+ } catch (copyErr) {
187
+ const copyCode =
188
+ typeof copyErr === "object" && copyErr !== null && "code" in copyErr ? copyErr.code : undefined;
189
+ if (copyCode === "EEXIST") {
190
+ verifyBlobFileSync(hash, blobPath);
191
+ return result;
192
+ }
193
+ throw copyErr;
194
+ }
195
+ } else {
196
+ throw err;
197
+ }
198
+ }
199
+
200
+ verifyBlobFileSync(hash, blobPath);
201
+ fsyncDirBestEffortSync(this.dir);
202
+ return result;
203
+ } finally {
204
+ try {
205
+ fs.unlinkSync(tempPath);
206
+ } catch {
207
+ // Best-effort temp cleanup: never mask the primary result or throw.
208
+ }
209
+ }
210
+ }
211
+
63
212
  /** Read blob by hash, returns Buffer or null if not found. */
64
213
  async get(hash: string): Promise<Buffer | null> {
65
214
  const blobPath = path.join(this.dir, hash);
@@ -84,6 +233,22 @@ export class BlobStore {
84
233
  }
85
234
  }
86
235
 
236
+ /** Read blob by hash and verify its content hash; returns null if not found. */
237
+ async getChecked(hash: string): Promise<Buffer | null> {
238
+ return this.getCheckedSync(hash);
239
+ }
240
+
241
+ /** Synchronously read blob by hash and verify its content hash; returns null if not found. */
242
+ getCheckedSync(hash: string): Buffer | null {
243
+ const blobPath = path.join(this.dir, hash);
244
+ try {
245
+ return verifyBlobFileSync(hash, blobPath);
246
+ } catch (err) {
247
+ if (isEnoent(err)) return null;
248
+ throw err;
249
+ }
250
+ }
251
+
87
252
  /** Check if a blob exists. */
88
253
  async has(hash: string): Promise<boolean> {
89
254
  try {
@@ -134,6 +299,12 @@ export class EphemeralBlobStore extends BlobStore {
134
299
  return result;
135
300
  }
136
301
 
302
+ putImmutableSync(data: Buffer): CheckedBlobPutResult {
303
+ const result = super.putImmutableSync(data);
304
+ this.#cachePut(result.hash, Buffer.from(data));
305
+ return result;
306
+ }
307
+
137
308
  getSync(hash: string): Buffer | null {
138
309
  const cached = this.#bufferCache.get(hash);
139
310
  if (cached) {
@@ -152,6 +323,12 @@ export class EphemeralBlobStore extends BlobStore {
152
323
  return data;
153
324
  }
154
325
 
326
+ getCheckedSync(hash: string): Buffer | null {
327
+ const data = super.getCheckedSync(hash);
328
+ if (data) this.#cachePut(hash, Buffer.from(data));
329
+ return data;
330
+ }
331
+
155
332
  clear(): void {
156
333
  this.#bufferCache.clear();
157
334
  this.#bufferCacheBytes = 0;
@@ -208,15 +385,15 @@ export class MemoryBlobStore extends BlobStore {
208
385
  }
209
386
 
210
387
  putSync(data: Buffer): BlobPutResult {
211
- const hash = new Bun.SHA256().update(data).digest("hex");
388
+ const hash = sha256Hex(data);
212
389
  this.#store(hash, Buffer.from(data));
213
- return {
214
- hash,
215
- path: `memory:${hash}`,
216
- get ref() {
217
- return `${BLOB_PREFIX}${hash}`;
218
- },
219
- };
390
+ return makeBlobPutResult(hash, `memory:${hash}`);
391
+ }
392
+
393
+ putImmutableSync(data: Buffer): CheckedBlobPutResult {
394
+ const hash = sha256Hex(data);
395
+ this.#store(hash, Buffer.from(data));
396
+ return makeBlobPutResult(hash, `memory:${hash}`, data.byteLength);
220
397
  }
221
398
 
222
399
  async get(hash: string): Promise<Buffer | null> {
@@ -232,6 +409,17 @@ export class MemoryBlobStore extends BlobStore {
232
409
  return Buffer.from(data);
233
410
  }
234
411
 
412
+ async getChecked(hash: string): Promise<Buffer | null> {
413
+ return this.getCheckedSync(hash);
414
+ }
415
+
416
+ getCheckedSync(hash: string): Buffer | null {
417
+ const data = this.getSync(hash);
418
+ if (!data) return null;
419
+ verifyBlobBytesSync(hash, `memory:${hash}`, data);
420
+ return data;
421
+ }
422
+
235
423
  async has(hash: string): Promise<boolean> {
236
424
  return this.#blobs.has(hash);
237
425
  }