@sayknow-cli/coding-agent 0.5.2 → 0.5.6

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 (86) hide show
  1. package/CHANGELOG.md +55 -16
  2. package/dist/types/cli/update-cli.d.ts +8 -1
  3. package/dist/types/config/atomic-yaml-patch.d.ts +20 -1
  4. package/dist/types/config/keybindings.d.ts +10 -0
  5. package/dist/types/config/model-registry.d.ts +1 -1
  6. package/dist/types/config/models-config-schema.d.ts +4 -0
  7. package/dist/types/config/settings-schema.d.ts +9 -0
  8. package/dist/types/dap/client.d.ts +1 -0
  9. package/dist/types/lsp/client.d.ts +5 -0
  10. package/dist/types/modes/components/welcome.d.ts +1 -0
  11. package/dist/types/modes/controllers/selector-controller.d.ts +3 -1
  12. package/dist/types/modes/interactive-mode.d.ts +13 -1
  13. package/dist/types/modes/types.d.ts +7 -1
  14. package/dist/types/runtime-mcp/transports/stdio.d.ts +2 -0
  15. package/dist/types/session/agent-session.d.ts +8 -0
  16. package/dist/types/session/internal/managed-session-scope.d.ts +3 -1
  17. package/dist/types/session/internal/managed-session-storage.d.ts +2 -0
  18. package/dist/types/session/session-manager.d.ts +9 -0
  19. package/dist/types/session/session-storage.d.ts +17 -0
  20. package/dist/types/session-import/claude.d.ts +26 -0
  21. package/dist/types/session-import/codex.d.ts +22 -0
  22. package/dist/types/session-import/command.d.ts +20 -0
  23. package/dist/types/session-import/detect.d.ts +22 -0
  24. package/dist/types/session-import/index.d.ts +7 -0
  25. package/dist/types/session-import/redact.d.ts +23 -0
  26. package/dist/types/session-import/service.d.ts +28 -0
  27. package/dist/types/session-import/types.d.ts +125 -0
  28. package/dist/types/skc-runtime/launch-worktree.d.ts +17 -1
  29. package/dist/types/slash-commands/builtin-registry.d.ts +4 -0
  30. package/dist/types/slash-commands/types.d.ts +5 -0
  31. package/dist/types/tools/ask.d.ts +5 -0
  32. package/package.json +13 -7
  33. package/scripts/generate-sdk-operation-inventory.ts +5 -6
  34. package/src/cli/config-cli.ts +23 -8
  35. package/src/cli/update-cli.ts +265 -21
  36. package/src/config/atomic-yaml-patch.ts +167 -30
  37. package/src/config/keybindings.ts +10 -0
  38. package/src/config/model-profiles.ts +69 -1
  39. package/src/config/model-registry.ts +60 -38
  40. package/src/config/models-config-schema.ts +1 -1
  41. package/src/config/settings-schema.ts +9 -0
  42. package/src/config/settings.ts +13 -2
  43. package/src/dap/client.ts +78 -30
  44. package/src/internal-urls/docs-index.generated.ts +5 -4
  45. package/src/lsp/client.ts +17 -29
  46. package/src/modes/action-registry.ts +2 -0
  47. package/src/modes/components/welcome.ts +5 -0
  48. package/src/modes/controllers/goal-mode-controller.ts +7 -2
  49. package/src/modes/controllers/input-controller.ts +71 -6
  50. package/src/modes/controllers/plan-mode-controller.ts +8 -1
  51. package/src/modes/controllers/runtime-mcp-command-controller.ts +14 -0
  52. package/src/modes/controllers/selector-controller.ts +47 -19
  53. package/src/modes/interactive-mode.ts +54 -4
  54. package/src/modes/types.ts +5 -1
  55. package/src/runtime-mcp/transports/stdio.ts +96 -33
  56. package/src/sdk/broker/lifecycle.ts +6 -5
  57. package/src/sdk/protocol/operation-inventory.generated.json +33 -0
  58. package/src/sdk/session.ts +25 -18
  59. package/src/session/agent-session.ts +89 -14
  60. package/src/session/internal/managed-session-scope.ts +41 -6
  61. package/src/session/internal/managed-session-storage.ts +14 -0
  62. package/src/session/session-manager.ts +113 -11
  63. package/src/session/session-storage.ts +70 -0
  64. package/src/session-import/claude.ts +382 -0
  65. package/src/session-import/codex.ts +458 -0
  66. package/src/session-import/command.ts +61 -0
  67. package/src/session-import/detect.ts +133 -0
  68. package/src/session-import/index.ts +27 -0
  69. package/src/session-import/redact.ts +125 -0
  70. package/src/session-import/service.ts +749 -0
  71. package/src/session-import/types.ts +163 -0
  72. package/src/skc-runtime/launch-worktree.ts +277 -4
  73. package/src/slash-commands/acp-builtins.ts +5 -1
  74. package/src/slash-commands/builtin-registry.ts +100 -1
  75. package/src/slash-commands/types.ts +5 -0
  76. package/src/tools/ask.ts +5 -0
  77. package/src/export/html/template.css +0 -1060
  78. package/src/export/html/template.html +0 -47
  79. package/src/export/html/template.js +0 -2348
  80. package/vendor/insane-search/engine/tests/test_hardening.py +0 -57
  81. package/vendor/insane-search/engine/tests/test_smoke.py +0 -152
  82. package/vendor/insane-search/engine/tests/test_u1.py +0 -200
  83. package/vendor/insane-search/engine/tests/test_u4.py +0 -131
  84. package/vendor/insane-search/engine/tests/test_u5.py +0 -163
  85. package/vendor/insane-search/engine/tests/test_u7.py +0 -124
  86. package/vendor/insane-search/engine/tests/test_u8.py +0 -216
package/src/lsp/client.ts CHANGED
@@ -89,12 +89,10 @@ function deleteClientLock(key: string, clientPromise: Promise<LspClient>): void
89
89
  }
90
90
  }
91
91
 
92
- function evictDeadCachedClient(key: string, client: LspClient): void {
92
+ function evictDeadCachedClient(client: LspClient): void {
93
93
  if (client.proc.exitCode === null && !client.proc.killed && !client.owner?.disposed && !killedClients.has(client))
94
94
  return;
95
- deleteCachedClient(key, client);
96
- client.resolveProjectLoaded();
97
- rejectPendingRequests(client, new Error("LSP server exited"));
95
+ terminalizeTransport(client, new Error("LSP server exited"));
98
96
  }
99
97
 
100
98
  function terminalizeTransport(client: LspClient, cause: unknown): Error {
@@ -275,7 +273,9 @@ async function writeMessage(
275
273
  if (terminalError) throw terminalError;
276
274
 
277
275
  try {
278
- client.proc.stdin.write(`Content-Length: ${Buffer.byteLength(content, "utf-8")}\r\n\r\n${content}`);
276
+ await Promise.resolve(
277
+ client.proc.stdin.write(`Content-Length: ${Buffer.byteLength(content, "utf-8")}\r\n\r\n${content}`),
278
+ );
279
279
  await client.proc.stdin.flush();
280
280
  } catch (error) {
281
281
  if (isKnownSinkPeerClosedError(error)) {
@@ -302,7 +302,7 @@ function queueWriteMessage(
302
302
  * Start background message reader for a client.
303
303
  * Routes responses to pending requests and handles notifications.
304
304
  */
305
- async function startMessageReader(client: LspClient): Promise<void> {
305
+ export async function startMessageReader(client: LspClient): Promise<void> {
306
306
  if (client.isReading) return;
307
307
  client.isReading = true;
308
308
 
@@ -311,7 +311,10 @@ async function startMessageReader(client: LspClient): Promise<void> {
311
311
  try {
312
312
  while (true) {
313
313
  const { done, value } = await reader.read();
314
- if (done) break;
314
+ if (done) {
315
+ terminalizeTransport(client, new Error("LSP stdout closed"));
316
+ break;
317
+ }
315
318
 
316
319
  // Atomically update buffer before processing
317
320
  const currentBuffer: Buffer = Buffer.concat([client.messageBuffer, value]);
@@ -368,11 +371,7 @@ async function startMessageReader(client: LspClient): Promise<void> {
368
371
  client.messageBuffer = workingBuffer;
369
372
  }
370
373
  } catch (err) {
371
- // Connection closed or error - reject all pending requests
372
- for (const pending of Array.from(client.pendingRequests.values())) {
373
- pending.reject(new Error(`LSP connection closed: ${err}`));
374
- }
375
- client.pendingRequests.clear();
374
+ terminalizeTransport(client, err);
376
375
  } finally {
377
376
  reader.releaseLock();
378
377
  client.isReading = false;
@@ -488,7 +487,7 @@ export async function getOrCreateClient(config: ServerConfig, cwd: string, initT
488
487
  // Check if client already exists
489
488
  const existingClient = clients.get(key);
490
489
  if (existingClient) {
491
- evictDeadCachedClient(key, existingClient);
490
+ evictDeadCachedClient(existingClient);
492
491
  if (clients.has(key)) {
493
492
  existingClient.lastActivity = Date.now();
494
493
  return existingClient;
@@ -558,13 +557,10 @@ export async function getOrCreateClient(config: ServerConfig, cwd: string, initT
558
557
  };
559
558
  clients.set(key, client);
560
559
 
561
- // Register crash recovery - remove client on process exit
560
+ // Register crash recovery and durably terminalize retained references on exit.
562
561
  proc.exited.then(async () => {
563
- deleteCachedClient(key, client);
564
562
  deleteClientLock(key, clientPromise);
565
- client.resolveProjectLoaded();
566
-
567
- // Reject any pending requests — the server is gone, they will never complete.
563
+ let error = new Error(`LSP server exited unexpectedly (code ${proc.exitCode})`);
568
564
  if (client.pendingRequests.size > 0) {
569
565
  // Strip informational log lines (e.g. marksman's [INF]/[DBG] prefix)
570
566
  // — they are startup noise, not actionable errors.
@@ -588,16 +584,13 @@ export async function getOrCreateClient(config: ServerConfig, cwd: string, initT
588
584
  );
589
585
  const diagnosticSuffix = crashNotice ? `\n${crashNotice}` : "";
590
586
  const code = proc.exitCode;
591
- const err = new Error(
587
+ error = new Error(
592
588
  stderr
593
589
  ? `LSP server exited (code ${code}): ${stderr}${diagnosticSuffix}`
594
590
  : `LSP server exited unexpectedly (code ${code})${diagnosticSuffix}`,
595
591
  );
596
- for (const pending of client.pendingRequests.values()) {
597
- pending.reject(err);
598
- }
599
- client.pendingRequests.clear();
600
592
  }
593
+ terminalizeTransport(client, error);
601
594
  });
602
595
 
603
596
  // Start background message reader
@@ -980,12 +973,7 @@ export async function sendNotification(client: LspClient, method: string, params
980
973
  };
981
974
 
982
975
  client.lastActivity = Date.now();
983
- try {
984
- await queueWriteMessage(client, notification);
985
- } catch (error) {
986
- if (transportClosedErrors.get(client) === error) return;
987
- throw error;
988
- }
976
+ await queueWriteMessage(client, notification);
989
977
  }
990
978
 
991
979
  /**
@@ -39,6 +39,7 @@ export const APP_ACTION_METADATA: readonly ActionMetadata[] = [
39
39
  action("app.model.select", "Select model", "Model", ["composer"]),
40
40
  action("app.model.selectTemporary", "Select temporary model", "Model", ["composer"]),
41
41
  action("app.tools.expand", "Toggle tool expansion", "Tools", ["composer"]),
42
+ action("app.todo.toggle", "Toggle todo expansion", "Tasks", ["composer"]),
42
43
  action("app.tool.backgroundFold", "Fold foreground tool", "Tools", ["composer"]),
43
44
  action("app.editor.external", "Open external editor", "Editor", ["composer"]),
44
45
  action("app.message.followUp", "Send follow-up", "Messages", ["composer"]),
@@ -47,6 +48,7 @@ export const APP_ACTION_METADATA: readonly ActionMetadata[] = [
47
48
  action("app.clipboard.pasteImage", "Paste image", "Clipboard", ["composer"]),
48
49
  action("app.clipboard.copyLine", "Copy line", "Clipboard", ["composer"]),
49
50
  action("app.clipboard.copyPrompt", "Copy prompt", "Clipboard", ["composer"]),
51
+ action("app.oauth.copyUrl", "Copy OAuth authorization URL", "OAuth", ["composer"]),
50
52
  action("app.session.new", "New session", "Session", ["composer"]),
51
53
  action("app.session.tree", "Session tree", "Session", ["composer"]),
52
54
  action("app.session.fork", "Branch from message", "Session", ["composer"]),
@@ -25,6 +25,7 @@ export interface WelcomeComponentOptions {
25
25
  collapseChangelog?: boolean;
26
26
  buildLabel?: string;
27
27
  keyDisplayContext?: KeyDisplayContext;
28
+ skipLogoAnimation?: boolean;
28
29
  }
29
30
 
30
31
  const WELCOME_STATIC_RIGHT_ROWS_EXCLUDING_DYNAMIC_SECTIONS = 15;
@@ -75,6 +76,10 @@ export class WelcomeComponent implements Component {
75
76
  */
76
77
  playIntro(requestRender: () => void): void {
77
78
  this.#stopAnimation();
79
+ if (this.options.skipLogoAnimation) {
80
+ requestRender();
81
+ return;
82
+ }
78
83
  this.#animStart = performance.now();
79
84
  requestRender();
80
85
  this.#animTimer = setInterval(() => {
@@ -264,12 +264,15 @@ export class GoalModeController {
264
264
  if (this.#enabled || this.#paused) this.ctx.modeGate.enter("goal");
265
265
  if (restored?.goal) {
266
266
  this.#previousTools = this.ctx.session.getActiveToolNames();
267
- await this.ctx.session.setActiveToolsByName([...new Set([...this.#previousTools, "goal"])]);
267
+ if (!this.ctx.session.hasExplicitlyDisabledTools()) {
268
+ await this.ctx.session.setActiveToolsByName([...new Set([...this.#previousTools, "goal"])]);
269
+ }
268
270
  }
269
271
  this.#updateStatus();
270
272
  return true;
271
273
  }
272
274
  if (recoveryHydration) return false;
275
+ if (this.ctx.session.hasExplicitlyDisabledTools()) return false;
273
276
  const pendingGoal = goalEnabled
274
277
  ? await consumePendingGoalModeRequest(this.ctx.sessionManager.getCwd(), this.ctx.sessionManager.getSessionId())
275
278
  : null;
@@ -321,7 +324,9 @@ export class GoalModeController {
321
324
  objective: options.objective ?? "",
322
325
  provenance: options.provenance,
323
326
  });
324
- await this.ctx.session.setActiveToolsByName([...new Set([...this.#previousTools, "goal"])]);
327
+ if (!this.ctx.session.hasExplicitlyDisabledTools()) {
328
+ await this.ctx.session.setActiveToolsByName([...new Set([...this.#previousTools, "goal"])]);
329
+ }
325
330
  this.ctx.session.setGoalModeState(state);
326
331
  this.#enabled = true;
327
332
  this.#resetContinuationSuppression();
@@ -91,6 +91,7 @@ export class InputController {
91
91
  "app.model.select": () => this.ctx.showModelSelector(),
92
92
  "app.model.selectTemporary": () => this.ctx.showModelSelector({ temporaryOnly: true }),
93
93
  "app.tools.expand": () => this.toggleToolOutputExpansion(),
94
+ "app.todo.toggle": () => this.ctx.toggleTodoExpansion(),
94
95
  "app.tool.backgroundFold": () => {
95
96
  this.handleForegroundToolBackgroundFold();
96
97
  },
@@ -103,6 +104,7 @@ export class InputController {
103
104
  },
104
105
  "app.clipboard.copyLine": () => this.handleCopyCurrentLine(),
105
106
  "app.clipboard.copyPrompt": () => this.handleCopyPrompt(),
107
+ "app.oauth.copyUrl": () => this.ctx.copyOAuthAuthorizationUrl(),
106
108
  "app.session.new": async () => {
107
109
  await this.ctx.handleClearCommand();
108
110
  },
@@ -153,6 +155,8 @@ export class InputController {
153
155
  return this.ctx.session.getRoleModelCycleCandidateCount() > 1;
154
156
  case "app.tools.expand":
155
157
  return this.ctx.chatContainer.children.some(isExpandable);
158
+ case "app.todo.toggle":
159
+ return this.ctx.todoPhases.some(phase => phase.tasks.length > 0);
156
160
  case "app.tool.backgroundFold":
157
161
  return Boolean(this.ctx.session.hasForegroundBashBackgroundRequestHandler?.());
158
162
  case "app.editor.external":
@@ -178,6 +182,8 @@ export class InputController {
178
182
  );
179
183
  case "app.clipboard.copyPrompt":
180
184
  return this.ctx.editor.getText().length > 0;
185
+ case "app.oauth.copyUrl":
186
+ return this.ctx.hasOAuthAuthorizationUrl();
181
187
  case "app.session.tree":
182
188
  case "app.session.fork":
183
189
  return this.ctx.session.messages.length > 0;
@@ -258,14 +264,20 @@ export class InputController {
258
264
  * so abort cleanup going idle cannot turn the second Esc into an idle action. */
259
265
  #steerConsumePending = false;
260
266
  #commandPaletteActions = new Map<AppKeybinding, CommandPaletteAction>();
267
+ readonly #availabilityGatedPaletteActions = new Set<AppKeybinding>();
261
268
  #paletteCommandInFlight = false;
262
269
 
263
- #registerCommandPaletteAction(action: AppKeybinding, handler: () => void | Promise<void>): void {
270
+ #registerCommandPaletteAction(
271
+ action: AppKeybinding,
272
+ handler: () => void | Promise<void>,
273
+ availabilityGated = false,
274
+ ): void {
264
275
  this.#commandPaletteActions.set(action, {
265
276
  id: action,
266
277
  label: KEYBINDINGS[action].description,
267
278
  handler,
268
279
  });
280
+ if (availabilityGated) this.#availabilityGatedPaletteActions.add(action);
269
281
  }
270
282
 
271
283
  #globalInterruptUnsubscribe: (() => void) | undefined;
@@ -623,14 +635,12 @@ export class InputController {
623
635
  this.ctx.editor.setActionKeys("app.tools.expand", this.ctx.keybindings.getKeys("app.tools.expand"));
624
636
  this.ctx.editor.onExpandTools = () => this.#executeAction("app.tools.expand");
625
637
  this.#registerCommandPaletteAction("app.tools.expand", expandTools);
626
- const dequeue = () => this.handleDequeue();
627
638
  this.ctx.editor.setActionKeys("app.message.dequeue", this.ctx.keybindings.getKeys("app.message.dequeue"));
628
639
  this.ctx.editor.onDequeue = () => this.#executeAction("app.message.dequeue");
629
- this.#registerCommandPaletteAction("app.message.dequeue", dequeue);
630
- const queue = () => this.handleQueueSubmit();
640
+ this.#registerCommandPaletteAction("app.message.dequeue", () => this.#executeAction("app.message.dequeue"), true);
631
641
  this.ctx.editor.setActionKeys("app.message.queue", this.ctx.keybindings.getKeys("app.message.queue"));
632
642
  this.ctx.editor.onQueue = () => this.#executeAction("app.message.queue");
633
- this.#registerCommandPaletteAction("app.message.queue", queue);
643
+ this.#registerCommandPaletteAction("app.message.queue", () => this.#executeAction("app.message.queue"), true);
634
644
 
635
645
  this.ctx.editor.onViewportPageScroll = direction => this.ctx.ui.scrollViewportPages(direction);
636
646
  this.ctx.editor.onViewportFollowLive = () => {
@@ -699,6 +709,11 @@ export class InputController {
699
709
  return true;
700
710
  });
701
711
  }
712
+ this.#registerCommandPaletteAction(
713
+ "app.message.followUp",
714
+ () => this.#executeAction("app.message.followUp"),
715
+ true,
716
+ );
702
717
  for (const key of this.ctx.keybindings.getKeys("app.message.followUp")) {
703
718
  this.ctx.editor.setCustomKeyHandler(key, () => {
704
719
  if (!this.actionRegistry.isAvailable("app.message.followUp")) return false;
@@ -706,6 +721,46 @@ export class InputController {
706
721
  return true;
707
722
  });
708
723
  }
724
+ this.#registerCommandPaletteAction(
725
+ "app.transcript.prevTurn",
726
+ () => this.#executeAction("app.transcript.prevTurn"),
727
+ true,
728
+ );
729
+ for (const key of this.ctx.keybindings.getKeys("app.transcript.prevTurn")) {
730
+ this.ctx.editor.setCustomKeyHandler(key, () => {
731
+ if (!this.actionRegistry.isAvailable("app.transcript.prevTurn")) return false;
732
+ this.#executeAction("app.transcript.prevTurn");
733
+ return true;
734
+ });
735
+ }
736
+ this.#registerCommandPaletteAction(
737
+ "app.transcript.nextTurn",
738
+ () => this.#executeAction("app.transcript.nextTurn"),
739
+ true,
740
+ );
741
+ for (const key of this.ctx.keybindings.getKeys("app.transcript.nextTurn")) {
742
+ this.ctx.editor.setCustomKeyHandler(key, () => {
743
+ if (!this.actionRegistry.isAvailable("app.transcript.nextTurn")) return false;
744
+ this.#executeAction("app.transcript.nextTurn");
745
+ return true;
746
+ });
747
+ }
748
+ this.#registerCommandPaletteAction("app.message.sendNow", () => this.#executeAction("app.message.sendNow"), true);
749
+ for (const key of this.ctx.keybindings.getKeys("app.message.sendNow")) {
750
+ this.ctx.editor.setCustomKeyHandler(key, () => {
751
+ if (!this.actionRegistry.isAvailable("app.message.sendNow")) return false;
752
+ this.#executeAction("app.message.sendNow");
753
+ return true;
754
+ });
755
+ }
756
+ this.#registerCommandPaletteAction("app.todo.toggle", () => this.#executeAction("app.todo.toggle"), true);
757
+ for (const key of this.ctx.keybindings.getKeys("app.todo.toggle")) {
758
+ this.ctx.editor.setCustomKeyHandler(key, () => {
759
+ if (!this.actionRegistry.isAvailable("app.todo.toggle")) return false;
760
+ this.#executeAction("app.todo.toggle");
761
+ return true;
762
+ });
763
+ }
709
764
  for (const key of this.ctx.keybindings.getKeys("app.stt.toggle")) {
710
765
  this.ctx.editor.setCustomKeyHandler(key, () => {
711
766
  this.#executeAction("app.stt.toggle");
@@ -717,6 +772,14 @@ export class InputController {
717
772
  this.#executeAction("app.clipboard.copyLine");
718
773
  });
719
774
  }
775
+ this.#registerCommandPaletteAction("app.oauth.copyUrl", () => this.#executeAction("app.oauth.copyUrl"), true);
776
+ for (const key of this.ctx.keybindings.getKeys("app.oauth.copyUrl")) {
777
+ this.ctx.editor.setCustomKeyHandler(key, () => {
778
+ if (!this.actionRegistry.isAvailable("app.oauth.copyUrl")) return false;
779
+ this.#executeAction("app.oauth.copyUrl");
780
+ return true;
781
+ });
782
+ }
720
783
  for (const key of this.ctx.keybindings.getKeys("app.session.observe")) {
721
784
  this.ctx.editor.setCustomKeyHandler(key, () => {
722
785
  this.#executeAction("app.session.observe");
@@ -1900,7 +1963,9 @@ export class InputController {
1900
1963
  return;
1901
1964
  }
1902
1965
 
1903
- const actions = [...this.#commandPaletteActions.values()];
1966
+ const actions = [...this.#commandPaletteActions.entries()]
1967
+ .filter(([id]) => !this.#availabilityGatedPaletteActions.has(id) || this.actionRegistry.isAvailable(id))
1968
+ .map(([, action]) => action);
1904
1969
  const slashCommands = [...(this.ctx.getSlashCommands?.() ?? this.#slashCommands)];
1905
1970
  if (!this.ctx.showCommandPalette) {
1906
1971
  let overlayHandle: ReturnType<typeof this.ctx.ui.showOverlay> | undefined;
@@ -103,6 +103,11 @@ export class PlanModeController {
103
103
  this.ctx.sessionManager.appendModeChange("none");
104
104
  return;
105
105
  }
106
+ if (this.ctx.session.hasExplicitlyDisabledTools()) {
107
+ if (sessionContext.mode === "plan" || sessionContext.mode === "plan_paused")
108
+ this.ctx.sessionManager.appendModeChange("none");
109
+ return;
110
+ }
106
111
  if (sessionContext.mode === "plan") {
107
112
  await this.enter({ planFilePath: sessionContext.modeData?.planFilePath as string | undefined });
108
113
  } else if (sessionContext.mode === "plan_paused") {
@@ -115,6 +120,8 @@ export class PlanModeController {
115
120
 
116
121
  async enter(options?: { planFilePath?: string; workflow?: "parallel" | "iterative" }): Promise<void> {
117
122
  if (this.#enabled) return;
123
+ if (this.ctx.session.hasExplicitlyDisabledTools())
124
+ return this.ctx.showWarning("Plan mode requires the resolve tool.");
118
125
  if (!this.ctx.modeGate.enter("plan")) return this.ctx.showWarning("Exit goal mode first.");
119
126
  this.#paused = false;
120
127
  const planFilePath = options?.planFilePath ?? "local://PLAN.md";
@@ -143,7 +150,7 @@ export class PlanModeController {
143
150
  async exit(options?: { silent?: boolean; paused?: boolean }): Promise<void> {
144
151
  if (!this.#enabled) return;
145
152
  await this.ctx.session.abort({ timeoutMs: ABORT_TIMEOUT_MS });
146
- if (this.#previousTools?.length) await this.ctx.session.setActiveToolsByName(this.#previousTools);
153
+ if (this.#previousTools !== undefined) await this.ctx.session.setActiveToolsByName(this.#previousTools);
147
154
  if (this.#providerSessionScope && !this.ctx.session.isStreaming) {
148
155
  if (this.ctx.session.restoreTemporaryProviderSessionScope(this.#providerSessionScope))
149
156
  this.#providerSessionScope = undefined;
@@ -540,6 +540,7 @@ export class MCPCommandController {
540
540
 
541
541
  const resolvedClientId = clientId.trim() || parsedAuthUrl.searchParams.get("client_id") || undefined;
542
542
  const resolvedClientSecret = clientSecret.trim() || undefined;
543
+ let authorizationUrlLease: number | undefined;
543
544
 
544
545
  try {
545
546
  // Create OAuth flow
@@ -556,6 +557,7 @@ export class MCPCommandController {
556
557
  },
557
558
  {
558
559
  onAuth: (info: { url: string; instructions?: string }) => {
560
+ authorizationUrlLease = this.ctx.setOAuthAuthorizationUrl(info.url);
559
561
  // Show auth URL prominently in chat
560
562
  this.ctx.chatContainer.addChild(new Spacer(1));
561
563
  this.ctx.chatContainer.addChild(
@@ -565,6 +567,16 @@ export class MCPCommandController {
565
567
  this.ctx.chatContainer.addChild(
566
568
  new Text(theme.fg("muted", "Preparing browser authorization..."), 1, 0),
567
569
  );
570
+ this.ctx.chatContainer.addChild(
571
+ new Text(
572
+ theme.fg(
573
+ "muted",
574
+ `Press ${this.ctx.keybindings.getDisplayString("app.oauth.copyUrl")} to copy the authorization URL.`,
575
+ ),
576
+ 1,
577
+ 0,
578
+ ),
579
+ );
568
580
  this.ctx.chatContainer.addChild(new Spacer(1));
569
581
  this.ctx.chatContainer.addChild(
570
582
  new Text(
@@ -661,6 +673,8 @@ export class MCPCommandController {
661
673
  } else {
662
674
  throw new Error(`OAuth authentication failed: ${errorMsg}`);
663
675
  }
676
+ } finally {
677
+ if (authorizationUrlLease !== undefined) this.ctx.clearOAuthAuthorizationUrl(authorizationUrlLease);
664
678
  }
665
679
  }
666
680
 
@@ -40,6 +40,7 @@ import {
40
40
  theme,
41
41
  } from "../../modes/theme/theme";
42
42
  import type { InteractiveModeContext, OAuthSelectorOptions } from "../../modes/types";
43
+ import { acquireResumeProgressLease } from "../../modes/utils/ui-helpers";
43
44
  import { getNotificationConfig, isTelegramConfigured, maskToken } from "../../sdk/bus/config";
44
45
  import {
45
46
  clearTelegramActivationMarker,
@@ -2052,26 +2053,40 @@ export class SelectorController {
2052
2053
  return true;
2053
2054
  }
2054
2055
 
2055
- async handleResumeSession(sessionPath: string): Promise<void> {
2056
- const previousSessionId = this.ctx.sessionManager.getSessionId();
2057
- this.#clearTransientSessionUi();
2058
- const migrationPolicy =
2059
- this.ctx.settings?.get("session.directoryMigration") === "disabled" ? "disabled" : "copy-retain";
2060
- const writableSessionPath = await this.ctx.sessionManager.prepareManagedCandidateForWrite(
2061
- sessionPath,
2062
- migrationPolicy,
2063
- );
2064
-
2065
- // Switch session via AgentSession (emits hook and tool session events)
2066
- if (!(await this.ctx.session.switchSession(writableSessionPath))) return;
2067
- const switchingToDifferentSession = previousSessionId !== this.ctx.sessionManager.getSessionId();
2068
- if (switchingToDifferentSession) this.ctx.resetIrcSidebarSession();
2069
- this.#refreshSessionTerminalTitle();
2070
- this.ctx.updateEditorBorderColor();
2056
+ async handleResumeSession(
2057
+ sessionPath: string,
2058
+ options?: { requireIdle?: boolean },
2059
+ onSwitched?: () => void,
2060
+ ): Promise<boolean> {
2061
+ const progress = acquireResumeProgressLease(this.ctx);
2062
+ await progress.committed;
2063
+ try {
2064
+ const previousSessionId = this.ctx.sessionManager.getSessionId();
2065
+ const migrationPolicy =
2066
+ this.ctx.settings?.get("session.directoryMigration") === "disabled" ? "disabled" : "copy-retain";
2067
+ const writableSessionPath = await this.ctx.sessionManager.prepareManagedCandidateForWrite(
2068
+ sessionPath,
2069
+ migrationPolicy,
2070
+ );
2071
2071
 
2072
- this.ctx.rebuildInitialMessages(switchingToDifferentSession ? "replace-identity" : "reconcile-same-transcript");
2073
- await this.ctx.reloadTodos();
2074
- this.ctx.showStatus("Resumed session");
2072
+ // Switch session via AgentSession (emits hook and tool session events)
2073
+ if (!(await this.ctx.session.switchSession(writableSessionPath, options))) return false;
2074
+ onSwitched?.();
2075
+ this.#clearTransientSessionUi();
2076
+ const switchingToDifferentSession = previousSessionId !== this.ctx.sessionManager.getSessionId();
2077
+ if (switchingToDifferentSession) this.ctx.resetIrcSidebarSession();
2078
+ this.#refreshSessionTerminalTitle();
2079
+ this.ctx.updateEditorBorderColor();
2080
+
2081
+ this.ctx.rebuildInitialMessages(
2082
+ switchingToDifferentSession ? "replace-identity" : "reconcile-same-transcript",
2083
+ );
2084
+ await this.ctx.reloadTodos();
2085
+ this.ctx.showStatus("Resumed session");
2086
+ return true;
2087
+ } finally {
2088
+ progress.clear();
2089
+ }
2075
2090
  }
2076
2091
 
2077
2092
  async handleSessionDeleteCommand(): Promise<void> {
@@ -2158,6 +2173,7 @@ export class SelectorController {
2158
2173
  }
2159
2174
 
2160
2175
  this.ctx.showStatus(`Logging in to ${providerId}… (Esc to cancel)`);
2176
+ let authorizationUrlLease: number | undefined;
2161
2177
  const manualInput = this.ctx.oauthManualInput;
2162
2178
  const useManualInput = CALLBACK_SERVER_PROVIDERS.has(providerId as OAuthProvider);
2163
2179
  const abort = new AbortController();
@@ -2172,6 +2188,7 @@ export class SelectorController {
2172
2188
  await this.ctx.session.modelRegistry.authStorage.login(providerId as OAuthProvider, {
2173
2189
  signal: abort.signal,
2174
2190
  onAuth: (info: { url: string; instructions?: string }) => {
2191
+ authorizationUrlLease = this.ctx.setOAuthAuthorizationUrl(info.url);
2175
2192
  this.ctx.chatContainer.addChild(new Spacer(1));
2176
2193
  this.ctx.chatContainer.addChild(new Text(theme.fg("dim", info.url), 1, 0));
2177
2194
  const hyperlink = `\x1b]8;;${info.url}\x07Click here to login\x1b]8;;\x07`;
@@ -2184,6 +2201,16 @@ export class SelectorController {
2184
2201
  this.ctx.chatContainer.addChild(new Spacer(1));
2185
2202
  this.ctx.chatContainer.addChild(new Text(theme.fg("dim", MANUAL_LOGIN_TIP), 1, 0));
2186
2203
  }
2204
+ this.ctx.chatContainer.addChild(
2205
+ new Text(
2206
+ theme.fg(
2207
+ "dim",
2208
+ `Press ${this.ctx.keybindings.getDisplayString("app.oauth.copyUrl")} to copy the authorization URL.`,
2209
+ ),
2210
+ 1,
2211
+ 0,
2212
+ ),
2213
+ );
2187
2214
  this.ctx.ui.requestRender();
2188
2215
  this.ctx.openInBrowser(info.url);
2189
2216
  },
@@ -2235,6 +2262,7 @@ export class SelectorController {
2235
2262
  if (useManualInput) {
2236
2263
  manualInput.clear(`Manual OAuth input cleared for ${providerId}`);
2237
2264
  }
2265
+ if (authorizationUrlLease !== undefined) this.ctx.clearOAuthAuthorizationUrl(authorizationUrlLease);
2238
2266
  }
2239
2267
  }
2240
2268
 
@@ -312,6 +312,25 @@ export function selectShutdownDraft(editorText: string, hasActiveBtw: boolean):
312
312
  return hasActiveBtw ? "" : editorText;
313
313
  }
314
314
 
315
+ export class OAuthAuthorizationUrlState {
316
+ #active: { url: string; lease: number } | undefined;
317
+ #nextLease = 0;
318
+
319
+ set(url: string): number {
320
+ const lease = ++this.#nextLease;
321
+ this.#active = { url, lease };
322
+ return lease;
323
+ }
324
+
325
+ clear(lease: number): void {
326
+ if (this.#active?.lease === lease) this.#active = undefined;
327
+ }
328
+
329
+ get current(): string | undefined {
330
+ return this.#active?.url;
331
+ }
332
+ }
333
+
315
334
  export class InteractiveMode implements InteractiveModeContext {
316
335
  session: AgentSession;
317
336
  sessionManager: SessionManager;
@@ -384,6 +403,7 @@ export class InteractiveMode implements InteractiveModeContext {
384
403
  fileSlashCommands: Set<string> = new Set();
385
404
  skillCommands: Map<string, Skill> = new Map();
386
405
  oauthManualInput: OAuthManualInputManager = new OAuthManualInputManager();
406
+ #oauthAuthorizationUrl = new OAuthAuthorizationUrlState();
387
407
  promptSuggestion: PromptSuggestionController;
388
408
 
389
409
  #baseSlashCommands: SlashCommand[] = [];
@@ -697,6 +717,7 @@ export class InteractiveMode implements InteractiveModeContext {
697
717
 
698
718
  const startupQuiet = settings.get("startup.quiet");
699
719
  const welcomeLogoMode = resolveWelcomeLogoMode(settings.get("startup.welcomeBannerMode"));
720
+ const skipLogoAnimation = settings.get("startup.skipLogoAnimation");
700
721
  this.#welcomeComponent = undefined;
701
722
 
702
723
  for (const warning of this.session.configWarnings) {
@@ -722,6 +743,7 @@ export class InteractiveMode implements InteractiveModeContext {
722
743
  rightGutterWidth: COMPOSER_RIGHT_GUTTER_WIDTH,
723
744
  collapseChangelog: settings.get("collapseChangelog"),
724
745
  keyDisplayContext: this.#keyDisplayContext,
746
+ skipLogoAnimation,
725
747
  },
726
748
  );
727
749
 
@@ -2042,6 +2064,33 @@ export class InteractiveMode implements InteractiveModeContext {
2042
2064
  openInBrowser(urlOrPath: string): void {
2043
2065
  this.#commandController.openInBrowser(urlOrPath);
2044
2066
  }
2067
+ setOAuthAuthorizationUrl(url: string): number {
2068
+ return this.#oauthAuthorizationUrl.set(url);
2069
+ }
2070
+
2071
+ clearOAuthAuthorizationUrl(lease: number): void {
2072
+ this.#oauthAuthorizationUrl.clear(lease);
2073
+ }
2074
+
2075
+ hasOAuthAuthorizationUrl(): boolean {
2076
+ return this.#oauthAuthorizationUrl.current !== undefined;
2077
+ }
2078
+
2079
+ async copyOAuthAuthorizationUrl(): Promise<void> {
2080
+ const url = this.#oauthAuthorizationUrl.current;
2081
+ if (!url) {
2082
+ this.showWarning("No OAuth authorization URL is available to copy.");
2083
+ return;
2084
+ }
2085
+ try {
2086
+ await copyToClipboard(url);
2087
+ this.showStatus("OAuth authorization URL copied to clipboard");
2088
+ } catch (error) {
2089
+ this.showError(
2090
+ `Failed to copy OAuth authorization URL: ${error instanceof Error ? error.message : String(error)}`,
2091
+ );
2092
+ }
2093
+ }
2045
2094
 
2046
2095
  // Selector handling
2047
2096
  showCommandPalette(
@@ -2104,10 +2153,11 @@ export class InteractiveMode implements InteractiveModeContext {
2104
2153
  this.#selectorController.showSessionSelector();
2105
2154
  }
2106
2155
 
2107
- handleResumeSession(sessionPath: string): Promise<void> {
2108
- this.#btwController.dispose();
2109
- this.resetObserverRegistry();
2110
- return this.#selectorController.handleResumeSession(sessionPath);
2156
+ handleResumeSession(sessionPath: string, options?: { requireIdle?: boolean }): Promise<boolean> {
2157
+ return this.#selectorController.handleResumeSession(sessionPath, options, () => {
2158
+ this.#btwController.dispose();
2159
+ this.resetObserverRegistry();
2160
+ });
2111
2161
  }
2112
2162
 
2113
2163
  handleSessionDeleteCommand(): Promise<void> {
@@ -298,6 +298,10 @@ export interface InteractiveModeContext {
298
298
  ): Promise<CompactionOutcome>;
299
299
 
300
300
  openInBrowser(urlOrPath: string): void;
301
+ setOAuthAuthorizationUrl(url: string): number;
302
+ hasOAuthAuthorizationUrl(): boolean;
303
+ clearOAuthAuthorizationUrl(lease: number): void;
304
+ copyOAuthAuthorizationUrl(): Promise<void>;
301
305
  /** Resolved source of truth for slash autocomplete and command palette entries. */
302
306
  getSlashCommands?(): readonly SlashCommand[];
303
307
  refreshSlashCommandState(cwd?: string): Promise<void>;
@@ -322,7 +326,7 @@ export interface InteractiveModeContext {
322
326
  showTreeSelector(): void;
323
327
  showSessionSelector(): void;
324
328
  showSessionsDashboard(): void;
325
- handleResumeSession(sessionPath: string): Promise<void>;
329
+ handleResumeSession(sessionPath: string, options?: { requireIdle?: boolean }): Promise<boolean>;
326
330
  handleSessionDeleteCommand(): Promise<void>;
327
331
  showOAuthSelector(mode: "login" | "logout", providerId?: string, options?: OAuthSelectorOptions): Promise<void>;
328
332
  showHookConfirm(title: string, message: string): Promise<boolean>;