@sayknow-cli/coding-agent 0.5.1 → 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 (122) hide show
  1. package/CHANGELOG.md +185 -14
  2. package/dist/types/cli/update-cli.d.ts +8 -1
  3. package/dist/types/commands/session.d.ts +7 -0
  4. package/dist/types/config/atomic-yaml-patch.d.ts +20 -1
  5. package/dist/types/config/keybindings.d.ts +10 -0
  6. package/dist/types/config/model-registry.d.ts +1 -1
  7. package/dist/types/config/models-config-schema.d.ts +4 -0
  8. package/dist/types/config/settings-schema.d.ts +9 -0
  9. package/dist/types/config/telegram-autostart.d.ts +9 -1
  10. package/dist/types/dap/client.d.ts +1 -0
  11. package/dist/types/lsp/client.d.ts +5 -0
  12. package/dist/types/modes/components/pet-capability.d.ts +8 -7
  13. package/dist/types/modes/components/pet-selector.d.ts +1 -1
  14. package/dist/types/modes/components/sayknow-pet-widget.d.ts +1 -1
  15. package/dist/types/modes/components/welcome.d.ts +1 -0
  16. package/dist/types/modes/controllers/selector-controller.d.ts +3 -1
  17. package/dist/types/modes/interactive-mode.d.ts +13 -1
  18. package/dist/types/modes/shared/agent-wire/unattended-session.d.ts +7 -0
  19. package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +2 -0
  20. package/dist/types/modes/types.d.ts +7 -1
  21. package/dist/types/runtime-mcp/transports/stdio.d.ts +2 -0
  22. package/dist/types/session/agent-session.d.ts +9 -0
  23. package/dist/types/session/internal/managed-session-scope.d.ts +3 -1
  24. package/dist/types/session/internal/managed-session-storage.d.ts +2 -0
  25. package/dist/types/session/session-manager.d.ts +9 -0
  26. package/dist/types/session/session-storage.d.ts +17 -0
  27. package/dist/types/session-import/claude.d.ts +26 -0
  28. package/dist/types/session-import/codex.d.ts +22 -0
  29. package/dist/types/session-import/command.d.ts +20 -0
  30. package/dist/types/session-import/detect.d.ts +22 -0
  31. package/dist/types/session-import/index.d.ts +7 -0
  32. package/dist/types/session-import/redact.d.ts +23 -0
  33. package/dist/types/session-import/service.d.ts +28 -0
  34. package/dist/types/session-import/types.d.ts +125 -0
  35. package/dist/types/skc-runtime/boot-generation.d.ts +59 -0
  36. package/dist/types/skc-runtime/launch-tmux.d.ts +10 -2
  37. package/dist/types/skc-runtime/launch-worktree.d.ts +17 -1
  38. package/dist/types/skc-runtime/session-restore-runtime.d.ts +41 -0
  39. package/dist/types/skc-runtime/session-restore.d.ts +99 -0
  40. package/dist/types/skc-runtime/tmux-owner-isolation.d.ts +160 -0
  41. package/dist/types/skc-runtime/tmux-sessions.d.ts +26 -1
  42. package/dist/types/slash-commands/builtin-registry.d.ts +4 -0
  43. package/dist/types/slash-commands/types.d.ts +5 -0
  44. package/dist/types/tools/ask.d.ts +169 -4
  45. package/package.json +16 -7
  46. package/scripts/generate-sdk-operation-inventory.ts +5 -6
  47. package/src/cli/config-cli.ts +23 -8
  48. package/src/cli/update-cli.ts +265 -21
  49. package/src/commands/session.ts +88 -2
  50. package/src/config/atomic-yaml-patch.ts +167 -30
  51. package/src/config/keybindings.ts +10 -0
  52. package/src/config/model-profiles.ts +69 -1
  53. package/src/config/model-registry.ts +72 -38
  54. package/src/config/models-config-schema.ts +1 -1
  55. package/src/config/settings-schema.ts +9 -0
  56. package/src/config/settings.ts +13 -2
  57. package/src/config/telegram-autostart.ts +11 -4
  58. package/src/dap/client.ts +78 -30
  59. package/src/defaults/skc/skills/deep-interview/SKILL.md +29 -3
  60. package/src/internal-urls/docs-index.generated.ts +5 -4
  61. package/src/lsp/client.ts +17 -29
  62. package/src/main.ts +1 -1
  63. package/src/modes/action-registry.ts +2 -0
  64. package/src/modes/components/pet-capability.ts +22 -13
  65. package/src/modes/components/pet-selector.ts +1 -1
  66. package/src/modes/components/sayknow-pet-widget.ts +41 -7
  67. package/src/modes/components/welcome.ts +5 -0
  68. package/src/modes/controllers/event-controller.ts +1 -1
  69. package/src/modes/controllers/goal-mode-controller.ts +7 -2
  70. package/src/modes/controllers/input-controller.ts +71 -6
  71. package/src/modes/controllers/plan-mode-controller.ts +8 -1
  72. package/src/modes/controllers/runtime-mcp-command-controller.ts +14 -0
  73. package/src/modes/controllers/selector-controller.ts +47 -19
  74. package/src/modes/interactive-mode.ts +54 -4
  75. package/src/modes/shared/agent-wire/unattended-session.ts +40 -9
  76. package/src/modes/shared/agent-wire/workflow-gate-broker.ts +2 -0
  77. package/src/modes/types.ts +5 -1
  78. package/src/notifications/lifecycle-control-runtime.ts +258 -179
  79. package/src/prompts/system/eager-todo.md +2 -0
  80. package/src/prompts/system/plan-mode-approved.md +1 -1
  81. package/src/prompts/system/system-prompt.md +4 -2
  82. package/src/runtime-mcp/transports/stdio.ts +96 -33
  83. package/src/sdk/broker/lifecycle.ts +6 -5
  84. package/src/sdk/bus/lifecycle-control-runtime.ts +189 -110
  85. package/src/sdk/protocol/operation-inventory.generated.json +33 -0
  86. package/src/sdk/session.ts +25 -18
  87. package/src/session/agent-session.ts +120 -25
  88. package/src/session/internal/managed-session-scope.ts +41 -6
  89. package/src/session/internal/managed-session-storage.ts +14 -0
  90. package/src/session/session-manager.ts +113 -11
  91. package/src/session/session-storage.ts +70 -0
  92. package/src/session-import/claude.ts +382 -0
  93. package/src/session-import/codex.ts +458 -0
  94. package/src/session-import/command.ts +61 -0
  95. package/src/session-import/detect.ts +133 -0
  96. package/src/session-import/index.ts +27 -0
  97. package/src/session-import/redact.ts +125 -0
  98. package/src/session-import/service.ts +749 -0
  99. package/src/session-import/types.ts +163 -0
  100. package/src/skc-runtime/boot-generation.ts +172 -0
  101. package/src/skc-runtime/launch-tmux.ts +219 -41
  102. package/src/skc-runtime/launch-worktree.ts +277 -4
  103. package/src/skc-runtime/session-restore-runtime.ts +120 -0
  104. package/src/skc-runtime/session-restore.ts +296 -0
  105. package/src/skc-runtime/session-state-sidecar.ts +41 -0
  106. package/src/skc-runtime/tmux-owner-isolation.ts +665 -0
  107. package/src/skc-runtime/tmux-sessions.ts +284 -108
  108. package/src/slash-commands/acp-builtins.ts +5 -1
  109. package/src/slash-commands/builtin-registry.ts +109 -5
  110. package/src/slash-commands/types.ts +5 -0
  111. package/src/tools/ask.ts +188 -10
  112. package/src/tools/eval.ts +2 -2
  113. package/src/export/html/template.css +0 -1060
  114. package/src/export/html/template.html +0 -47
  115. package/src/export/html/template.js +0 -2348
  116. package/vendor/insane-search/engine/tests/test_hardening.py +0 -57
  117. package/vendor/insane-search/engine/tests/test_smoke.py +0 -152
  118. package/vendor/insane-search/engine/tests/test_u1.py +0 -200
  119. package/vendor/insane-search/engine/tests/test_u4.py +0 -131
  120. package/vendor/insane-search/engine/tests/test_u5.py +0 -163
  121. package/vendor/insane-search/engine/tests/test_u7.py +0 -124
  122. 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
  /**
package/src/main.ts CHANGED
@@ -1331,7 +1331,7 @@ export async function runRootCommand(
1331
1331
  // Initialize discovery system with settings for provider persistence
1332
1332
  logger.time("initializeWithSettings", initializeWithSettings, settingsInstance);
1333
1333
  // Auto-start Telegram Remote gateway if enabled in settings
1334
- if (!parsedArgs.print && !autoPrint) await maybeAutostartTelegramRemote();
1334
+ if (!parsedArgs.print && !autoPrint) await maybeAutostartTelegramRemote(settingsInstance);
1335
1335
 
1336
1336
  // Apply model role overrides from CLI args or env vars (ephemeral, not persisted).
1337
1337
  // Precedence per role: CLI flag > documented SKC_*_MODEL > legacy PI_*_MODEL.
@@ -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"]),
@@ -1,10 +1,12 @@
1
1
  import {
2
+ getOverlayImageProtocol,
2
3
  ImageProtocol,
3
4
  isUnderTerminalMultiplexer,
5
+ isUnderTmux,
4
6
  onImageProtocolChanged,
5
7
  type SelectItem,
8
+ shouldProbeKittyPassthrough,
6
9
  shouldProbeSixelCapability,
7
- TERMINAL,
8
10
  } from "@sayknow-cli/tui";
9
11
 
10
12
  export type PetPixelProtocol = "sixel" | "kitty";
@@ -14,16 +16,22 @@ export const PET_SAVED_UNAVAILABLE_DESCRIPTION =
14
16
  "Saved, unavailable — requires compatible Kitty or Sixel overlay rendering";
15
17
  export const PET_UNAVAILABLE_WARNING =
16
18
  "⚠ Pets aren’t available in this terminal. Its image support isn’t compatible with Sayknow Pet’s overlay rendering yet. Try Kitty, Ghostty, WezTerm, or a terminal with compatible Sixel support.";
19
+ const PET_TMUX_UNAVAILABLE_WARNING =
20
+ "⚠ Sayknow Pet graphics are unavailable: the terminal attached to this tmux client answered neither the Kitty nor the Sixel capability query forwarded through DCS passthrough. Attach from Ghostty, Kitty, WezTerm, or a Sixel-capable terminal, and leave tmux `allow-passthrough` enabled.";
17
21
  const PET_MULTIPLEXER_UNAVAILABLE_WARNING =
18
- "⚠ Sayknow Pet graphics are unavailable inside tmux, screen, or zellij because image escapes are not forwarded end to end. Run skc outside the multiplexer, or set PI_FORCE_IMAGE_PROTOCOL=sixel only when the full terminal chain supports Sixel.";
22
+ "⚠ Sayknow Pet graphics are unavailable inside screen and zellij: neither forwards image escapes to the outer terminal. Run skc under tmux (which has DCS passthrough) or outside the multiplexer.";
19
23
 
20
24
  export function getPetUnavailableWarning(env: NodeJS.ProcessEnv = Bun.env): string {
25
+ if (isUnderTmux(env)) return PET_TMUX_UNAVAILABLE_WARNING;
21
26
  return isUnderTerminalMultiplexer(env) ? PET_MULTIPLEXER_UNAVAILABLE_WARNING : PET_UNAVAILABLE_WARNING;
22
27
  }
23
28
 
24
29
  export function getPetPixelProtocol(): PetPixelProtocol | null {
25
- if (TERMINAL.imageProtocol === ImageProtocol.Kitty) return "kitty";
26
- if (TERMINAL.imageProtocol === ImageProtocol.Sixel) return "sixel";
30
+ // Overlay channel: inline graphics when the terminal renders them directly,
31
+ // otherwise the tmux passthrough protocol proven by the startup probe.
32
+ const protocol = getOverlayImageProtocol();
33
+ if (protocol === ImageProtocol.Kitty) return "kitty";
34
+ if (protocol === ImageProtocol.Sixel) return "sixel";
27
35
  return null;
28
36
  }
29
37
 
@@ -55,24 +63,25 @@ export function createPetSelectItems(
55
63
 
56
64
  /**
57
65
  * Grace period before declaring the terminal pet-incapable at startup. The
58
- * asynchronous Sixel capability probe starts inside `TUI.start()` and answers
59
- * within its own 250 ms deadline; this margin covers probe scheduling so a
60
- * supported terminal is never told it is incompatible while the probe is
61
- * still in flight.
66
+ * asynchronous capability probes start inside `TUI.start()` and answer within
67
+ * their own deadline (250 ms directly, 600 ms through tmux passthrough); this
68
+ * margin covers probe scheduling so a supported terminal is never told it is
69
+ * incompatible while a probe is still in flight.
62
70
  */
63
- export const PET_CAPABILITY_SETTLE_MS = 1_000;
71
+ export const PET_CAPABILITY_SETTLE_MS = 1_500;
64
72
 
65
73
  /**
66
- * Whether the asynchronous startup Sixel capability probe may still enable
67
- * graphics for this session, meaning current unavailability is not final.
74
+ * Whether an asynchronous startup capability probe (Sixel, or Kitty through
75
+ * tmux passthrough) may still enable graphics for this session, meaning current
76
+ * unavailability is not final.
68
77
  */
69
78
  export function isPetCapabilityProbePending(
70
79
  env: NodeJS.ProcessEnv = Bun.env,
71
80
  platform: NodeJS.Platform = process.platform,
72
81
  ): boolean {
73
- if (TERMINAL.imageProtocol !== null) return false;
82
+ if (getPetPixelProtocol() !== null) return false;
74
83
  if (!process.stdin.isTTY || !process.stdout.isTTY) return false;
75
- return shouldProbeSixelCapability(env, platform);
84
+ return shouldProbeSixelCapability(env, platform) || shouldProbeKittyPassthrough(env);
76
85
  }
77
86
 
78
87
  /**
@@ -13,7 +13,7 @@ const PET_OPTIONS: { value: PetMode; label: string; description: string }[] = [
13
13
  ];
14
14
 
15
15
  /**
16
- * Theme-style picker for the sayknow pet skin (Off / RedSayknow / BlueSayknow). Preview
16
+ * Theme-style picker for the sayknow pet skin (Off / RedOctopus / BlueOctopus). Preview
17
17
  * fires as the selection moves; select commits, cancel restores.
18
18
  */
19
19
  export class PetSelectorComponent extends Container {
@@ -4,6 +4,7 @@ import {
4
4
  type Component,
5
5
  type Container,
6
6
  getCellDimensions,
7
+ getTmuxOverlayImageProtocol,
7
8
  isUnderTmux,
8
9
  PARA_PARA_STEPS,
9
10
  PET_SKINS,
@@ -14,8 +15,10 @@ import {
14
15
  registerAnimationCallback,
15
16
  type SayknowPixelFrameName,
16
17
  type SayknowPixelFrames,
18
+ TERMINAL,
17
19
  type TUI,
18
20
  tmuxOwnsSixel,
21
+ tmuxPaneOffset,
19
22
  wrapTmuxPassthrough,
20
23
  } from "@sayknow-cli/tui";
21
24
  import type { CustomEditor } from "./custom-editor";
@@ -216,7 +219,7 @@ export class SayknowPetWidget {
216
219
 
217
220
  /** Live preview during a selector: change the sprite without re-mounting the
218
221
  * composer editor (that would tear down the open overlay). After a short idle
219
- * eye-roll it fires the signature burst once (RedSayknow flex, BlueSayknow para-para
222
+ * eye-roll it fires the signature burst once (RedOctopus flex, BlueOctopus para-para
220
223
  * then sob) so the selector demos the animation instead of waiting the random gap. */
221
224
  previewMode(mode: PetMode): void {
222
225
  this.#applyMode(mode, false);
@@ -341,7 +344,7 @@ export class SayknowPetWidget {
341
344
  const mode = this.#mode;
342
345
  if (mode === "off") return "base";
343
346
  // Random idle burst → the skin's own animation, driven by its burst descriptor
344
- // (RedSayknow holds a flex; BlueSayknow dances the para-para then sobs).
347
+ // (RedOctopus holds a flex; BlueOctopus dances the para-para then sobs).
345
348
  if (now < this.#flexUntil) {
346
349
  const burst = PET_SKINS[mode].burst;
347
350
  const elapsed = now - (this.#flexUntil - petBurstDurationMs(burst));
@@ -384,7 +387,7 @@ export class SayknowPetWidget {
384
387
  }
385
388
  }
386
389
  // Random show-off, both while idle and while working. Each skin's burst runs for
387
- // its own length (RedSayknow a brief flex; BlueSayknow a para-para cycle plus sob).
390
+ // its own length (RedOctopus a brief flex; BlueOctopus a para-para cycle plus sob).
388
391
  if (this.#autoFlexGapMs && now >= this.#flexUntil) {
389
392
  if (this.#nextAutoFlexAt === 0) {
390
393
  this.#scheduleAutoFlex(now);
@@ -429,11 +432,35 @@ export class SayknowPetWidget {
429
432
  return out;
430
433
  }
431
434
 
435
+ /**
436
+ * Whether pet art must be smuggled to the outer terminal through tmux's DCS
437
+ * passthrough envelope. True exactly when the multiplexer suppressed inline
438
+ * graphics and the startup probe proved the outer terminal can draw them.
439
+ */
440
+ #usesTmuxPassthrough(): boolean {
441
+ return TERMINAL.imageProtocol === null && getTmuxOverlayImageProtocol() !== null;
442
+ }
443
+
444
+ /**
445
+ * Cursor-position escape for the pet's top-left cell.
446
+ *
447
+ * Passthrough payloads address the OUTER terminal's screen, so they carry the
448
+ * pane origin; tmux-level payloads stay pane-relative.
449
+ */
450
+ #cursorTo(x: number, y: number, outer: boolean): string {
451
+ const offset = outer ? tmuxPaneOffset() : { top: 0, left: 0 };
452
+ return `\x1b[${y + offset.top + 1};${x + offset.left + 1}H`;
453
+ }
454
+
432
455
  /** Pending on-screen image cleanup. Pure: authority is consumed separately, on delivery. */
433
456
  #imageCleanupPayload(): string {
434
457
  let out = "";
435
458
  if (this.#kittyCleanupPending && this.#kittyImageId !== undefined) {
436
- out += `\x1b_Ga=d,d=I,i=${this.#kittyImageId},q=2\x1b\\`;
459
+ // Deleting by image id needs no cursor addressing, but it still has to
460
+ // reach the terminal that holds the placement — the outer one when the
461
+ // art was delivered through passthrough.
462
+ const del = `\x1b_Ga=d,d=I,i=${this.#kittyImageId},q=2\x1b\\`;
463
+ out += this.#usesTmuxPassthrough() ? wrapTmuxPassthrough(del) : del;
437
464
  }
438
465
  if (this.#lastSixelFootprint) {
439
466
  out += this.#clearSixelFootprint(this.#lastSixelFootprint);
@@ -491,8 +518,10 @@ export class SayknowPetWidget {
491
518
  // A full frame supersedes any cleanup-only frame still awaiting ack.
492
519
  this.#frameCleanupAwaitingAck = false;
493
520
  const { x, y } = pos;
521
+ const frame = pixel.frames[this.#frame];
494
522
  let out = "";
495
- const positioned = `\x1b[${y + 1};${x + 1}H${pixel.frames[this.#frame]}`;
523
+ const positioned = `${this.#cursorTo(x, y, false)}${frame}`;
524
+ const positionedOuter = `${this.#cursorTo(x, y, true)}${frame}`;
496
525
 
497
526
  if (pixel.protocol === "sixel") {
498
527
  const footprint = { x, y, columns: pixel.columns, rows: pixel.rasterRows };
@@ -518,7 +547,7 @@ export class SayknowPetWidget {
518
547
  out += tmuxOwnsSixel()
519
548
  ? positioned
520
549
  : isUnderTmux()
521
- ? wrapTmuxPassthrough(`\x1b7${positioned}\x1b8`)
550
+ ? wrapTmuxPassthrough(`\x1b7${positionedOuter}\x1b8`)
522
551
  : positioned;
523
552
  return out;
524
553
  }
@@ -526,7 +555,12 @@ export class SayknowPetWidget {
526
555
  // A kitty frame (re)places the image cursor-neutrally, so cleanup is pending
527
556
  // again even if a narrow-terminal pass consumed it earlier.
528
557
  this.#kittyCleanupPending = true;
529
- out += positioned;
558
+ // Under tmux the placement is cursor-neutral for the OUTER terminal but its
559
+ // addressing is not: tmux forwards passthrough bytes without first syncing
560
+ // the physical cursor, so the frame carries its own outer-screen position,
561
+ // and the save/restore pair sits inside the envelope so the outer cursor is
562
+ // exactly where tmux believes it left it.
563
+ out += this.#usesTmuxPassthrough() ? wrapTmuxPassthrough(`\x1b7${positionedOuter}\x1b8`) : positioned;
530
564
  return out;
531
565
  }
532
566
  }
@@ -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(() => {
@@ -869,7 +869,7 @@ export class EventController {
869
869
  (content: { type: string; text?: string }) => content.type === "text",
870
870
  )?.text;
871
871
  this.ctx.showWarning(
872
- `Todo update failed${textContent ? `: ${textContent}` : ". Progress may be stale until todo_write succeeds."}`,
872
+ `Todo update failed${textContent ? `: ${textContent}` : ""}. Work continues without visible todo progress for this turn.`,
873
873
  );
874
874
  }
875
875
  if (event.toolName === "resolve" && !event.isError) {
@@ -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