@gajae-code/coding-agent 0.8.0 → 0.8.2

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 (116) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/dist/types/cli/completion-cli.d.ts +41 -0
  3. package/dist/types/cli/config-cli.d.ts +1 -0
  4. package/dist/types/cli/notify-cli.d.ts +7 -0
  5. package/dist/types/cli.d.ts +124 -1
  6. package/dist/types/commands/completion.d.ts +29 -0
  7. package/dist/types/commands/config.d.ts +3 -0
  8. package/dist/types/commands/deep-interview.d.ts +3 -0
  9. package/dist/types/config/file-lock.d.ts +7 -6
  10. package/dist/types/config/keybindings.d.ts +2 -2
  11. package/dist/types/config/model-profile-activation.d.ts +6 -0
  12. package/dist/types/config/settings.d.ts +8 -0
  13. package/dist/types/extensibility/extensions/runner.d.ts +2 -1
  14. package/dist/types/extensibility/extensions/types.d.ts +8 -0
  15. package/dist/types/gjc-runtime/deep-interview-ambiguity.d.ts +39 -0
  16. package/dist/types/gjc-runtime/deep-interview-recorder.d.ts +10 -1
  17. package/dist/types/gjc-runtime/deep-interview-runtime.d.ts +1 -1
  18. package/dist/types/gjc-runtime/deep-interview-state.d.ts +12 -0
  19. package/dist/types/gjc-runtime/team-runtime.d.ts +2 -1
  20. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +1 -0
  21. package/dist/types/main.d.ts +4 -1
  22. package/dist/types/modes/components/model-selector.d.ts +4 -0
  23. package/dist/types/modes/components/queued-message-selector.d.ts +12 -0
  24. package/dist/types/modes/components/settings-selector.d.ts +2 -0
  25. package/dist/types/modes/controllers/input-controller.d.ts +1 -3
  26. package/dist/types/modes/print-mode.d.ts +13 -1
  27. package/dist/types/modes/prompt-action-autocomplete.d.ts +1 -1
  28. package/dist/types/notifications/config.d.ts +8 -1
  29. package/dist/types/notifications/daemon-paths.d.ts +1 -0
  30. package/dist/types/notifications/index.d.ts +6 -3
  31. package/dist/types/notifications/lifecycle-commands.d.ts +11 -3
  32. package/dist/types/notifications/telegram-daemon.d.ts +18 -3
  33. package/dist/types/notifications/telegram-reference.d.ts +0 -5
  34. package/dist/types/runtime-api-key.d.ts +7 -0
  35. package/dist/types/session/agent-session.d.ts +10 -0
  36. package/dist/types/session/agent-storage.d.ts +1 -0
  37. package/dist/types/task/executor.d.ts +2 -0
  38. package/dist/types/tools/computer.d.ts +2 -2
  39. package/dist/types/tools/skill.d.ts +1 -1
  40. package/dist/types/workflow/workflow-intent-diff.d.ts +8 -2
  41. package/dist/types/workflow/workflow-intent-report-consumer.d.ts +50 -0
  42. package/dist/types/workflow/workflow-intent-report-schema.d.ts +2 -0
  43. package/dist/types/workflow/workflow-intent-report.d.ts +48 -0
  44. package/package.json +7 -7
  45. package/src/cli/completion-cli.ts +234 -0
  46. package/src/cli/config-cli.ts +37 -9
  47. package/src/cli/fast-help.ts +1 -0
  48. package/src/cli/notify-cli.ts +91 -7
  49. package/src/cli.ts +3 -1
  50. package/src/commands/completion.ts +84 -0
  51. package/src/commands/config.ts +2 -0
  52. package/src/commands/deep-interview.ts +2 -1
  53. package/src/config/file-lock.ts +73 -34
  54. package/src/config/keybindings.ts +2 -2
  55. package/src/config/model-profile-activation.ts +65 -0
  56. package/src/config/model-registry.ts +14 -2
  57. package/src/config/settings.ts +22 -0
  58. package/src/coordinator-mcp/server.ts +67 -12
  59. package/src/defaults/gjc/skills/deep-interview/SKILL.md +38 -5
  60. package/src/defaults/gjc/skills/ralplan/SKILL.md +15 -11
  61. package/src/defaults/gjc/skills/team/SKILL.md +1 -1
  62. package/src/extensibility/extensions/runner.ts +2 -0
  63. package/src/extensibility/extensions/types.ts +9 -0
  64. package/src/extensibility/skills.ts +30 -2
  65. package/src/gjc-runtime/deep-interview-ambiguity.ts +206 -0
  66. package/src/gjc-runtime/deep-interview-recorder.ts +62 -8
  67. package/src/gjc-runtime/deep-interview-runtime.ts +210 -2
  68. package/src/gjc-runtime/deep-interview-state.ts +13 -0
  69. package/src/gjc-runtime/session-state-sidecar.ts +59 -11
  70. package/src/gjc-runtime/state-runtime.ts +12 -2
  71. package/src/gjc-runtime/team-runtime.ts +23 -0
  72. package/src/gjc-runtime/ultragoal-runtime.ts +73 -19
  73. package/src/hooks/native-skill-hook.ts +29 -0
  74. package/src/internal-urls/docs-index.generated.ts +2 -2
  75. package/src/main.ts +52 -41
  76. package/src/modes/acp/acp-agent.ts +7 -6
  77. package/src/modes/components/model-selector.ts +54 -11
  78. package/src/modes/components/queued-message-selector.ts +92 -0
  79. package/src/modes/components/settings-selector.ts +4 -0
  80. package/src/modes/components/welcome.ts +160 -44
  81. package/src/modes/controllers/command-controller.ts +4 -4
  82. package/src/modes/controllers/extension-ui-controller.ts +4 -10
  83. package/src/modes/controllers/input-controller.ts +186 -25
  84. package/src/modes/controllers/selector-controller.ts +122 -27
  85. package/src/modes/interactive-mode.ts +43 -16
  86. package/src/modes/print-mode.ts +43 -4
  87. package/src/modes/prompt-action-autocomplete.ts +24 -13
  88. package/src/modes/utils/hotkeys-markdown.ts +1 -0
  89. package/src/modes/utils/ui-helpers.ts +2 -2
  90. package/src/notifications/config.ts +9 -1
  91. package/src/notifications/daemon-paths.ts +2 -0
  92. package/src/notifications/index.ts +39 -9
  93. package/src/notifications/lifecycle-commands.ts +37 -7
  94. package/src/notifications/lifecycle-orchestrator.ts +19 -6
  95. package/src/notifications/telegram-daemon-control.ts +3 -1
  96. package/src/notifications/telegram-daemon.ts +231 -43
  97. package/src/notifications/telegram-reference.ts +0 -16
  98. package/src/prompts/system/system-prompt.md +3 -0
  99. package/src/prompts/tools/computer.md +2 -2
  100. package/src/runtime-api-key.ts +16 -0
  101. package/src/sdk.ts +20 -6
  102. package/src/session/agent-session.ts +225 -31
  103. package/src/session/agent-storage.ts +4 -0
  104. package/src/session/session-manager.ts +44 -2
  105. package/src/slash-commands/builtin-registry.ts +153 -71
  106. package/src/task/executor.ts +7 -4
  107. package/src/task/index.ts +9 -3
  108. package/src/task/output-manager.ts +16 -6
  109. package/src/task/receipt.ts +26 -7
  110. package/src/tools/computer.ts +246 -43
  111. package/src/tools/skill.ts +1 -1
  112. package/src/tools/telegram-send.ts +6 -0
  113. package/src/workflow/workflow-intent-diff.ts +29 -6
  114. package/src/workflow/workflow-intent-report-consumer.ts +221 -0
  115. package/src/workflow/workflow-intent-report-schema.ts +129 -0
  116. package/src/workflow/workflow-intent-report.ts +135 -0
@@ -6,7 +6,7 @@
6
6
 
7
7
  - Disabled means disabled: when the tool is disabled (`computer.alwaysOn=false` with `computer.enabled` unset/false) or the platform is unsupported, every action including `screenshot` fails with `COMPUTER_DISABLED` and captures nothing.
8
8
  - Callable only on Apple Silicon macOS (`arm64` darwin); available by default there, with `computer.alwaysOn=false` as the off-switch and `computer.enabled=true` as the manual enable path.
9
- - Native execution remains supervisor-gated. If the stop/suspend supervisor is unavailable, stale, suspended, permissioned off, display-stale, or cancelled, the action fails closed with a `COMPUTER_*` code.
9
+ - Native execution remains supervisor-gated. If the stop/suspend supervisor is unavailable, stale, suspended, permissioned off, display-stale, or cancelled, the action fails closed with a `COMPUTER_*` code. Coordinate actions carry the latest known screenshot display epoch when one is available so display-topology changes fail with `COMPUTER_DISPLAY_STALE`.
10
10
  - Respect the user's stop/suspend request immediately. Do not loop desktop actions after a stop/suspend/error.
11
11
  - The user can stop or suspend the session at any time with the configured kill-switch hotkey (default `Control+Option+Command+Escape`). If you see `COMPUTER_CANCELLED` or `COMPUTER_SUPERVISOR_NOT_LIVE`, stop and wait for the user.
12
12
 
@@ -14,7 +14,7 @@
14
14
 
15
15
  Coordinates are screenshot pixels, not CSS pixels and not normalized fractions. Use the latest successful `screenshot` dimensions and origin/scale metadata as the coordinate frame. Do not guess coordinates outside the screenshot bounds.
16
16
 
17
- When you send a sequence of actions in one `batch` call, pointer coordinates in later steps are validated against the most recent screenshot from that batch. If a coordinate is out of bounds, the batch stops and reports `COMPUTER_COORD_INVALID`. Always capture a fresh screenshot before acting if the display may have changed.
17
+ For stale-display protection to apply, derive pointer coordinates from a successful screenshot in the same tool session; a screenshot-first `batch` is preferred because later coordinate steps are validated against that screenshot and carry its display epoch into native execution. If a coordinate is out of bounds, the batch stops and reports `COMPUTER_COORD_INVALID`. Always capture a fresh screenshot before acting if the display may have changed.
18
18
 
19
19
  ## Actions
20
20
 
@@ -0,0 +1,16 @@
1
+ export interface RuntimeApiKeyTarget {
2
+ setRuntimeApiKey(provider: string, apiKey: string): void;
3
+ }
4
+
5
+ export interface RuntimeApiKeyModel {
6
+ provider: string;
7
+ }
8
+
9
+ export function applyCliRuntimeApiKeyOverride(
10
+ target: RuntimeApiKeyTarget,
11
+ apiKey: string | undefined,
12
+ model: RuntimeApiKeyModel | undefined,
13
+ ): void {
14
+ if (!apiKey || !model) return;
15
+ target.setRuntimeApiKey(model.provider, apiKey);
16
+ }
package/src/sdk.ts CHANGED
@@ -1214,8 +1214,8 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1214
1214
 
1215
1215
  const agentRegistry = options.agentRegistry ?? AgentRegistry.global();
1216
1216
  const resolvedAgentId = options.agentId ?? options.parentTaskPrefix ?? MAIN_AGENT_ID;
1217
- const resolvedAgentDisplayName =
1218
- options.agentDisplayName ?? ((options.taskDepth ?? 0) > 0 || options.parentTaskPrefix ? "sub" : "main");
1217
+ const isSubSession = taskDepth > 0 || Boolean(options.parentTaskPrefix) || Boolean(options.currentAgentType);
1218
+ const resolvedAgentDisplayName = options.agentDisplayName ?? (isSubSession ? "sub" : "main");
1219
1219
  const evalKernelOwnerId = `agent-session:${Snowflake.next()}`;
1220
1220
 
1221
1221
  try {
@@ -1520,12 +1520,20 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1520
1520
  }
1521
1521
  let notificationCfg: NotificationConfig | undefined;
1522
1522
  try {
1523
- notificationCfg = getNotificationConfig(Settings.instance);
1523
+ notificationCfg = getNotificationConfig(settings);
1524
1524
  } catch {
1525
1525
  notificationCfg = undefined;
1526
1526
  }
1527
- if (shouldRegisterNotificationsExtension({ env: process.env, cfg: notificationCfg })) {
1528
- inlineExtensions.push(createNotificationsExtension);
1527
+ if (
1528
+ shouldRegisterNotificationsExtension({
1529
+ env: process.env,
1530
+ cfg: notificationCfg,
1531
+ taskDepth,
1532
+ parentTaskPrefix: options.parentTaskPrefix,
1533
+ currentAgentType: options.currentAgentType,
1534
+ })
1535
+ ) {
1536
+ inlineExtensions.push(api => createNotificationsExtension(api, { settings }));
1529
1537
  }
1530
1538
 
1531
1539
  // Extension/module discovery is quarantined; retain only the private
@@ -1632,6 +1640,12 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1632
1640
  cwd,
1633
1641
  sessionManager,
1634
1642
  modelRegistry,
1643
+ {
1644
+ kind: isSubSession ? "sub" : "main",
1645
+ taskDepth,
1646
+ ...(options.parentTaskPrefix ? { parentTaskPrefix: options.parentTaskPrefix } : {}),
1647
+ ...(options.currentAgentType ? { currentAgentType: options.currentAgentType } : {}),
1648
+ },
1635
1649
  );
1636
1650
  }
1637
1651
 
@@ -1924,7 +1938,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1924
1938
  agentRegistry.register({
1925
1939
  id: resolvedAgentId,
1926
1940
  displayName: resolvedAgentDisplayName,
1927
- kind: (options.taskDepth ?? 0) > 0 || options.parentTaskPrefix ? "sub" : "main",
1941
+ kind: isSubSession ? "sub" : "main",
1928
1942
  parentId: options.parentTaskPrefix,
1929
1943
  session: null,
1930
1944
  sessionFile: sessionManager.getSessionFile() ?? null,
@@ -905,9 +905,17 @@ function extractPermissionLocations(
905
905
  * `tag` is set only by `enqueueCustomMessageDisplay` (used for skill-prompt
906
906
  * custom messages queued during streaming) and is matched by the custom-role
907
907
  * `message_start` dequeue branch; user-message pushes leave it undefined and
908
- * rely on the existing text-equality match. `sequence` preserves cross-queue
909
- * insertion order for one-at-a-time dequeue/edit UX. */
908
+ * rely on the existing text-equality match. `sequence` gives each queued chip a
909
+ * stable edit id while the display arrays preserve delivery order. */
910
910
  type QueuedDisplayEntry = { text: string; tag?: string; sequence: number };
911
+ export type QueuedMessageEditMode = "steer" | "followUp";
912
+
913
+ export interface QueuedMessageEditEntry {
914
+ id: string;
915
+ text: string;
916
+ mode: QueuedMessageEditMode;
917
+ label: string;
918
+ }
911
919
 
912
920
  /** A custom message contributed at the before-agent-start point. */
913
921
  export type BeforeAgentStartInternalMessage = Pick<
@@ -1134,17 +1142,19 @@ export class AgentSession {
1134
1142
  #streamingEditFileCache = new Map<string, string>();
1135
1143
  #promptInFlightCount = 0;
1136
1144
  // Wire-level agent_end emission deferred until #promptInFlightCount drops to 0.
1137
- // Internal extension hooks and post-emit work (auto-retry, auto-compaction, todo
1138
- // checks in #handleAgentEvent) still fire on the original schedule — only the
1139
- // `#emit(event)` that reaches external subscribers (rpc-mode stdout, ACP bridge,
1140
- // Cursor exec, TUI listeners) is held back. Without this, a client that resumes
1141
- // on `agent_end` can fire its next `prompt` before #promptWithMessage's finally
1142
- // has decremented #promptInFlightCount, hitting AgentBusyError. Flushed from
1143
- // both #endInFlight (normal) and #resetInFlight (abort).
1145
+ // Local subscribers and extension hooks both receive the deferred event from
1146
+ // #flushPendingAgentEnd(), preserving the local-before-extension ordering used
1147
+ // by #emitSessionEvent while still preventing external clients from resuming
1148
+ // before #promptWithMessage's finally decrements #promptInFlightCount. Without
1149
+ // this, a client that resumes on `agent_end` can fire its next `prompt` while
1150
+ // the session still reports busy and hit AgentBusyError. Flushed from both
1151
+ // #endInFlight (normal) and #resetInFlight (abort).
1144
1152
  #pendingAgentEndEmit: AgentSessionEvent | undefined;
1145
1153
  #obfuscator: SecretObfuscator | undefined;
1146
1154
  #checkpointState: CheckpointState | undefined = undefined;
1147
1155
  #providerReplaySourceCache = new WeakMap<AgentMessage, ProviderReplaySourceCacheEntry>();
1156
+ #lastOversizedAutoMaintenanceAttemptSignature: string | undefined = undefined;
1157
+
1148
1158
  #pendingRewindReport: string | undefined = undefined;
1149
1159
  #lastSuccessfulYieldToolCallId: string | undefined = undefined;
1150
1160
  #promptGeneration = 0;
@@ -1223,6 +1233,7 @@ export class AgentSession {
1223
1233
  if (!pending) return;
1224
1234
  this.#pendingAgentEndEmit = undefined;
1225
1235
  this.#emit(pending);
1236
+ void this.#queueExtensionEvent(pending);
1226
1237
  }
1227
1238
 
1228
1239
  constructor(config: AgentSessionConfig) {
@@ -1533,10 +1544,9 @@ export class AgentSession {
1533
1544
  recordSkip("unsupported-role");
1534
1545
  return undefined;
1535
1546
  }
1536
- const cloned = cloneJsonValueForForkSeed(message) as Message;
1537
- if ("providerPayload" in cloned) {
1538
- delete (cloned as { providerPayload?: unknown }).providerPayload;
1539
- }
1547
+ const messageWithoutProviderPayload = { ...message } as Message & { providerPayload?: unknown };
1548
+ delete messageWithoutProviderPayload.providerPayload;
1549
+ const cloned = cloneJsonValueForForkSeed(messageWithoutProviderPayload) as Message;
1540
1550
  if (Array.isArray(cloned.content)) {
1541
1551
  const sanitizedContent: TextContent[] = [];
1542
1552
  for (const block of cloned.content) {
@@ -1548,6 +1558,10 @@ export class AgentSession {
1548
1558
  recordSkip(`unsupported-content-${block.type}`);
1549
1559
  }
1550
1560
  }
1561
+ if (sanitizedContent.length === 0) {
1562
+ recordSkip("empty-content");
1563
+ return undefined;
1564
+ }
1551
1565
  return { ...cloned, content: sanitizedContent } as Message;
1552
1566
  }
1553
1567
  return cloned;
@@ -1561,9 +1575,14 @@ export class AgentSession {
1561
1575
  const sanitized = sanitizeMessage(providerMessages[i]!);
1562
1576
  if (!sanitized) continue;
1563
1577
  const messageTokens = estimateMessageTokensHeuristic(sanitized);
1578
+ // Stop at the first message that overflows the token budget. The loop walks
1579
+ // newest→oldest, so `break` keeps the seed a CONTIGUOUS run of the most recent
1580
+ // messages. `continue` here would skip the oversized recent message and scavenge
1581
+ // smaller OLDER ones, yielding a non-contiguous seed that misrepresents the
1582
+ // conversation and violates the recency contract of the receipt/last-turn/bounded modes.
1564
1583
  if (maxTokens > 0 && approximateTokens + messageTokens > maxTokens) {
1565
1584
  recordSkip("token-limit");
1566
- continue;
1585
+ break;
1567
1586
  }
1568
1587
  selected.unshift(sanitized);
1569
1588
  approximateTokens += messageTokens;
@@ -1645,6 +1664,9 @@ export class AgentSession {
1645
1664
  return entry;
1646
1665
  }
1647
1666
 
1667
+ #queuedMessageEditId(mode: QueuedMessageEditMode, sequence: number): string {
1668
+ return `${mode}:${sequence}`;
1669
+ }
1648
1670
  /** Register a compact display string for a custom message that the caller is
1649
1671
  * about to dispatch via `promptCustomMessage` / `sendCustomMessage`.
1650
1672
  * Returns a stable tag the caller MUST embed in
@@ -1752,17 +1774,19 @@ export class AgentSession {
1752
1774
  }
1753
1775
 
1754
1776
  async #emitSessionEvent(event: AgentSessionEvent): Promise<void> {
1755
- await persistCoordinatorRuntimeStateFromEvent(event, {
1756
- sessionId: this.sessionId,
1757
- cwd: this.sessionManager.getCwd(),
1758
- sessionFile: this.sessionManager.getSessionFile(),
1759
- });
1777
+ const persistRuntimeState = () =>
1778
+ persistCoordinatorRuntimeStateFromEvent(event, {
1779
+ sessionId: this.sessionId,
1780
+ cwd: this.sessionManager.getCwd(),
1781
+ sessionFile: this.sessionManager.getSessionFile(),
1782
+ });
1783
+
1760
1784
  if (event.type === "message_update") {
1761
1785
  this.#emit(event);
1786
+ void persistRuntimeState();
1762
1787
  void this.#queueExtensionEvent(event);
1763
1788
  return;
1764
1789
  }
1765
- await this.#emitExtensionEvent(event);
1766
1790
  // Hold the wire-level agent_end until in-flight prompts unwind. Subscribers
1767
1791
  // (rpc-mode, ACP, Cursor) treat agent_end as the "session is idle" signal;
1768
1792
  // emitting while #promptInFlightCount > 0 lets a client fire its next
@@ -1772,10 +1796,18 @@ export class AgentSession {
1772
1796
  // supersedes the pending one, which is what subscribers want — they only
1773
1797
  // care about the final settle.
1774
1798
  if (event.type === "agent_end" && this.#promptInFlightCount > 0) {
1799
+ void persistRuntimeState();
1775
1800
  this.#pendingAgentEndEmit = event;
1776
1801
  return;
1777
1802
  }
1803
+
1804
+ // Local subscribers are part of the AgentSession control path: retryNow(),
1805
+ // auto-continuation gates, goal reminders, and tests all observe these events
1806
+ // synchronously. Coordinator sidecar writes and extension hooks are secondary
1807
+ // sinks, so they must not delay or suppress local delivery.
1778
1808
  this.#emit(event);
1809
+ void persistRuntimeState();
1810
+ await this.#emitExtensionEvent(event);
1779
1811
  }
1780
1812
 
1781
1813
  // Track last assistant message for auto-compaction check
@@ -3227,11 +3259,15 @@ export class AgentSession {
3227
3259
  maxAttempts: event.maxAttempts,
3228
3260
  });
3229
3261
  } else if (event.type === "goal_updated") {
3230
- await this.#extensionRunner.emit({
3231
- type: "goal_updated",
3232
- goal: event.goal,
3233
- state: event.state,
3234
- });
3262
+ try {
3263
+ await this.#extensionRunner.emit({
3264
+ type: "goal_updated",
3265
+ goal: event.goal,
3266
+ state: event.state,
3267
+ });
3268
+ } catch (error) {
3269
+ logger.warn("Goal updated extension hook failed", { error: String(error) });
3270
+ }
3235
3271
  }
3236
3272
  }
3237
3273
 
@@ -5715,6 +5751,72 @@ export class AgentSession {
5715
5751
  };
5716
5752
  }
5717
5753
 
5754
+ getQueuedMessageEntries(): QueuedMessageEditEntry[] {
5755
+ const entries: QueuedMessageEditEntry[] = [];
5756
+ for (const entry of this.#steeringMessages) {
5757
+ entries.push({
5758
+ id: this.#queuedMessageEditId("steer", entry.sequence),
5759
+ text: entry.text,
5760
+ mode: "steer",
5761
+ label: "Steer",
5762
+ });
5763
+ }
5764
+ for (const entry of this.#followUpMessages) {
5765
+ entries.push({
5766
+ id: this.#queuedMessageEditId("followUp", entry.sequence),
5767
+ text: entry.text,
5768
+ mode: "followUp",
5769
+ label: "Queued",
5770
+ });
5771
+ }
5772
+ return entries;
5773
+ }
5774
+
5775
+ removeQueuedMessageForEditing(id: string): string | undefined {
5776
+ const [mode, sequenceText] = id.split(":");
5777
+ if ((mode !== "steer" && mode !== "followUp") || sequenceText === undefined) return undefined;
5778
+ const sequence = Number(sequenceText);
5779
+ if (!Number.isInteger(sequence)) return undefined;
5780
+
5781
+ const queue = mode === "steer" ? this.#steeringMessages : this.#followUpMessages;
5782
+ const index = queue.findIndex(entry => entry.sequence === sequence);
5783
+ if (index === -1) return undefined;
5784
+
5785
+ const [entry] = queue.splice(index, 1);
5786
+ if (mode === "steer") {
5787
+ this.agent.removeSteerAt(index);
5788
+ } else {
5789
+ this.agent.removeFollowUpAt(index);
5790
+ }
5791
+ return entry?.text;
5792
+ }
5793
+
5794
+ moveQueuedMessageForEditing(id: string, direction: "up" | "down"): boolean {
5795
+ const [mode, sequenceText] = id.split(":");
5796
+ if ((mode !== "steer" && mode !== "followUp") || sequenceText === undefined) return false;
5797
+ const sequence = Number(sequenceText);
5798
+ if (!Number.isInteger(sequence)) return false;
5799
+
5800
+ const queue = mode === "steer" ? this.#steeringMessages : this.#followUpMessages;
5801
+ const fromIndex = queue.findIndex(entry => entry.sequence === sequence);
5802
+ if (fromIndex === -1) return false;
5803
+ const toIndex = direction === "up" ? fromIndex - 1 : fromIndex + 1;
5804
+ const agentMoved =
5805
+ mode === "steer" ? this.agent.moveSteer(fromIndex, toIndex) : this.agent.moveFollowUp(fromIndex, toIndex);
5806
+ if (!agentMoved) return false;
5807
+ return this.#moveQueuedDisplayEntry(queue, fromIndex, toIndex);
5808
+ }
5809
+
5810
+ #moveQueuedDisplayEntry(queue: QueuedDisplayEntry[], fromIndex: number, toIndex: number): boolean {
5811
+ if (fromIndex < 0 || fromIndex >= queue.length) return false;
5812
+ if (toIndex < 0 || toIndex >= queue.length) return false;
5813
+ if (fromIndex === toIndex) return true;
5814
+ const [entry] = queue.splice(fromIndex, 1);
5815
+ if (!entry) return false;
5816
+ queue.splice(toIndex, 0, entry);
5817
+ return true;
5818
+ }
5819
+
5718
5820
  /**
5719
5821
  * Pop the newest queued message across steering and follow-up queues.
5720
5822
  * Used by dequeue keybinding to restore messages to editor one at a time.
@@ -5726,15 +5828,11 @@ export class AgentSession {
5726
5828
  const followUpEntry = this.#followUpMessages.at(-1);
5727
5829
 
5728
5830
  if (steeringEntry && (!followUpEntry || steeringEntry.sequence > followUpEntry.sequence)) {
5729
- const entry = this.#steeringMessages.pop();
5730
- this.agent.popLastSteer();
5731
- return entry?.text;
5831
+ return this.removeQueuedMessageForEditing(this.#queuedMessageEditId("steer", steeringEntry.sequence));
5732
5832
  }
5733
5833
 
5734
5834
  if (followUpEntry) {
5735
- const entry = this.#followUpMessages.pop();
5736
- this.agent.popLastFollowUp();
5737
- return entry?.text;
5835
+ return this.removeQueuedMessageForEditing(this.#queuedMessageEditId("followUp", followUpEntry.sequence));
5738
5836
  }
5739
5837
 
5740
5838
  return undefined;
@@ -7672,6 +7770,59 @@ export class AgentSession {
7672
7770
  return false;
7673
7771
  }
7674
7772
 
7773
+ #buildAutoMaintenanceAttemptSignature(
7774
+ action: "context-full" | "handoff",
7775
+ preparation: CompactionPreparation,
7776
+ hookPrompt: string | undefined,
7777
+ hookContext: string[] | undefined,
7778
+ candidateModels: readonly Model[],
7779
+ ): string {
7780
+ const source = JSON.stringify({
7781
+ action,
7782
+ model: this.model ? this.#getModelKey(this.model) : undefined,
7783
+ candidates: candidateModels.map(model => this.#getModelKey(model)),
7784
+ isSplitTurn: preparation.isSplitTurn,
7785
+ previousSummary: preparation.previousSummary,
7786
+ previousPreserveData: preparation.previousPreserveData,
7787
+ hookPrompt,
7788
+ hookContext,
7789
+ messagesToSummarize: preparation.messagesToSummarize.map(message =>
7790
+ this.#normalizeSessionMessageForProviderReplay(message),
7791
+ ),
7792
+ turnPrefixMessages: preparation.turnPrefixMessages.map(message =>
7793
+ this.#normalizeSessionMessageForProviderReplay(message),
7794
+ ),
7795
+ recentMessages: preparation.recentMessages.map(message =>
7796
+ this.#normalizeSessionMessageForProviderReplay(message),
7797
+ ),
7798
+ });
7799
+ return `${this.#hashProviderReplaySource(source).toString(16)}:${source.length}`;
7800
+ }
7801
+
7802
+ #isOversizedMaintenanceError(errorMessage: string): boolean {
7803
+ return isContextOverflow(
7804
+ {
7805
+ role: "assistant",
7806
+ content: [],
7807
+ api: this.model?.api ?? "anthropic-messages",
7808
+ provider: this.model?.provider ?? "unknown",
7809
+ model: this.model?.id ?? "unknown",
7810
+ stopReason: "error",
7811
+ errorMessage,
7812
+ usage: {
7813
+ input: 0,
7814
+ output: 0,
7815
+ cacheRead: 0,
7816
+ cacheWrite: 0,
7817
+ totalTokens: 0,
7818
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
7819
+ },
7820
+ timestamp: Date.now(),
7821
+ } as AssistantMessage,
7822
+ this.model?.contextWindow,
7823
+ );
7824
+ }
7825
+
7675
7826
  #getModelKey(model: Model): string {
7676
7827
  return `${model.provider}/${model.id}`;
7677
7828
  }
@@ -7942,6 +8093,7 @@ export class AgentSession {
7942
8093
  const autoCompactionAbortController = new AbortController();
7943
8094
  this.#autoCompactionAbortController = autoCompactionAbortController;
7944
8095
  const autoCompactionSignal = autoCompactionAbortController.signal;
8096
+ let maintenanceAttemptSignature: string | undefined;
7945
8097
 
7946
8098
  try {
7947
8099
  if (compactionSettings.strategy === "handoff" && reason !== "overflow") {
@@ -8089,6 +8241,26 @@ export class AgentSession {
8089
8241
  preserveData = compactionPrep.preserveData;
8090
8242
  } else {
8091
8243
  const candidates = this.#getCompactionModelCandidates(availableModels);
8244
+ maintenanceAttemptSignature = this.#buildAutoMaintenanceAttemptSignature(
8245
+ action,
8246
+ preparation,
8247
+ compactionPrep.hookPrompt,
8248
+ compactionPrep.hookContext,
8249
+ candidates,
8250
+ );
8251
+ if (this.#lastOversizedAutoMaintenanceAttemptSignature === maintenanceAttemptSignature) {
8252
+ await this.#emitSessionEvent({
8253
+ type: "auto_compaction_end",
8254
+ action,
8255
+ result: undefined,
8256
+ aborted: false,
8257
+ willRetry: false,
8258
+ skipped: true,
8259
+ errorMessage:
8260
+ "Auto-compaction skipped: previous unchanged maintenance request exceeded the model context window; change or reduce the conversation before retrying maintenance.",
8261
+ });
8262
+ return;
8263
+ }
8092
8264
  const retrySettings = this.settings.getGroup("retry");
8093
8265
  const telemetry = resolveTelemetry(this.agent.telemetry, this.sessionId);
8094
8266
  let compactResult: CompactionResult | undefined;
@@ -8221,6 +8393,8 @@ export class AgentSession {
8221
8393
  details,
8222
8394
  preserveData,
8223
8395
  };
8396
+ this.#lastOversizedAutoMaintenanceAttemptSignature = undefined;
8397
+
8224
8398
  const continuationSkipReason = willRetry ? this.#detectOverflowRetryContinuationSkip() : undefined;
8225
8399
  await this.#emitSessionEvent({
8226
8400
  type: "auto_compaction_end",
@@ -8258,6 +8432,9 @@ export class AgentSession {
8258
8432
  return;
8259
8433
  }
8260
8434
  const errorMessage = error instanceof Error ? error.message : "compaction failed";
8435
+ if (maintenanceAttemptSignature && this.#isOversizedMaintenanceError(errorMessage)) {
8436
+ this.#lastOversizedAutoMaintenanceAttemptSignature = maintenanceAttemptSignature;
8437
+ }
8261
8438
  await this.#emitSessionEvent({
8262
8439
  type: "auto_compaction_end",
8263
8440
  action,
@@ -8382,6 +8559,16 @@ export class AgentSession {
8382
8559
  );
8383
8560
  }
8384
8561
 
8562
+ #isRefusalErrorMessage(errorMessage: string): boolean {
8563
+ // Provider safety-refusal stops. Matches the engine-generated Anthropic
8564
+ // labels from packages/ai ("Refusal (<category>): …", "Refusal (no
8565
+ // details provided)", "Content flagged by safety filters") plus generic
8566
+ // usage-policy refusal copy.
8567
+ return /^refusal(\s*\(|:|$)|content flagged by safety filters|blocked under .{0,40}usage policy/i.test(
8568
+ errorMessage,
8569
+ );
8570
+ }
8571
+
8385
8572
  #extractExplicitHttpStatusFromErrorMessage(errorMessage: string): number | undefined {
8386
8573
  // Parse only explicit HTTP/status wording. Do not treat generic
8387
8574
  // `error: 400` as an HTTP status because rate-limit copy can say
@@ -8402,6 +8589,13 @@ export class AgentSession {
8402
8589
  const contextWindow = this.model?.contextWindow ?? 0;
8403
8590
  if (isContextOverflow(message, contextWindow)) return "overflow";
8404
8591
  const err = message.errorMessage;
8592
+ // Provider safety refusals (e.g. Anthropic stop_reason "refusal" /
8593
+ // "sensitive") are deterministic for the submitted context: replaying
8594
+ // the identical conversation re-triggers the identical refusal, so an
8595
+ // auto-retry loop can never succeed and only re-bills the full context
8596
+ // on every attempt (#1655). Surface immediately instead of joining the
8597
+ // unbounded "unknown" retry class.
8598
+ if (this.#isRefusalErrorMessage(err)) return "terminal";
8405
8599
  if (isLocalModelEndpoint(this.model) && this.#isLocalProviderAvailabilityErrorMessage(err)) {
8406
8600
  return "local_unavailable";
8407
8601
  }
@@ -420,6 +420,10 @@ FROM model_usage_legacy
420
420
  this.#authStore.setCache(key, value, expiresAtSec);
421
421
  }
422
422
 
423
+ deleteCachePrefix(prefix: string): void {
424
+ this.#authStore.deleteCachePrefix?.(prefix);
425
+ }
426
+
423
427
  /**
424
428
  * Deletes expired cache entries. Call periodically for cleanup.
425
429
  */
@@ -1308,6 +1308,45 @@ function isImageBlock(value: unknown): value is { type: "image"; data: string; m
1308
1308
  );
1309
1309
  }
1310
1310
 
1311
+ function stripUndefinedPlainObjectFields(value: unknown, path = "entry"): unknown {
1312
+ if (value === undefined) return undefined;
1313
+ if (value === null || typeof value !== "object") return value;
1314
+ if (Array.isArray(value)) {
1315
+ let changed = false;
1316
+ const result: unknown[] = new Array(value.length);
1317
+ for (let index = 0; index < value.length; index++) {
1318
+ const item = value[index];
1319
+ if (item === undefined) {
1320
+ throw new Error(`Session entry contains undefined array item at ${path}[${index}]`);
1321
+ }
1322
+ const next = stripUndefinedPlainObjectFields(item, `${path}[${index}]`);
1323
+ if (next !== item) changed = true;
1324
+ result[index] = next;
1325
+ }
1326
+ return changed ? result : value;
1327
+ }
1328
+
1329
+ const prototype = Object.getPrototypeOf(value);
1330
+ if (prototype !== Object.prototype && prototype !== null) return value;
1331
+
1332
+ let changed = false;
1333
+ const entries: Array<readonly [string, unknown]> = [];
1334
+ for (const [key, item] of Object.entries(value)) {
1335
+ if (item === undefined) {
1336
+ changed = true;
1337
+ continue;
1338
+ }
1339
+ const next = stripUndefinedPlainObjectFields(item, `${path}.${key}`);
1340
+ if (next !== item) changed = true;
1341
+ entries.push([key, next]);
1342
+ }
1343
+ return changed ? Object.fromEntries(entries) : value;
1344
+ }
1345
+
1346
+ function normalizeSessionEntryForStorage(entry: SessionEntry): SessionEntry {
1347
+ return stripUndefinedPlainObjectFields(entry) as SessionEntry;
1348
+ }
1349
+
1311
1350
  const RESIDENT_EXTERNALIZE_STRING_EXCLUDED_KEYS = new Set([
1312
1351
  "id",
1313
1352
  "type",
@@ -2254,10 +2293,12 @@ class NdjsonFileWriter {
2254
2293
  }
2255
2294
  }
2256
2295
 
2296
+ const DEFAULT_WELCOME_RECENT_SESSION_LIMIT = 20;
2297
+
2257
2298
  /** Get recent sessions for display in welcome screen */
2258
2299
  export async function getRecentSessions(
2259
2300
  sessionDir: string,
2260
- limit = 3,
2301
+ limit = DEFAULT_WELCOME_RECENT_SESSION_LIMIT,
2261
2302
  storage: SessionStorage = new FileSessionStorage(),
2262
2303
  ): Promise<RecentSessionInfo[]> {
2263
2304
  const sessions = await getSortedSessions(sessionDir, storage);
@@ -3734,7 +3775,8 @@ export class SessionManager {
3734
3775
  }
3735
3776
 
3736
3777
  #appendEntry(entry: SessionEntry): void {
3737
- const residentEntry = prepareEntryForResidentSync(entry, this.#residentBlobStores()) as SessionEntry;
3778
+ const normalizedEntry = normalizeSessionEntryForStorage(entry);
3779
+ const residentEntry = prepareEntryForResidentSync(normalizedEntry, this.#residentBlobStores()) as SessionEntry;
3738
3780
  this.#fileEntries.push(residentEntry);
3739
3781
  this.#byId.set(residentEntry.id, residentEntry);
3740
3782
  this.#leafId = residentEntry.id;