@sayknow-cli/coding-agent 0.6.5 → 0.6.7

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 (69) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/dist/types/config/keybindings.d.ts +5 -0
  3. package/dist/types/config/settings-schema.d.ts +79 -0
  4. package/dist/types/i18n/messages/en.d.ts +6 -29
  5. package/dist/types/modes/components/welcome.d.ts +34 -40
  6. package/dist/types/modes/interactive-mode.d.ts +7 -4
  7. package/dist/types/modes/types.d.ts +4 -0
  8. package/dist/types/sdk/broker/broker.d.ts +22 -0
  9. package/dist/types/sdk/broker/process-guard.d.ts +71 -0
  10. package/dist/types/sdk/broker/transport.d.ts +2 -0
  11. package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +4 -0
  12. package/dist/types/session/agent-session.d.ts +9 -0
  13. package/dist/types/session/auth-storage-discovery.d.ts +15 -0
  14. package/dist/types/session/auto-fallback.d.ts +27 -0
  15. package/dist/types/session/fallback-chain-controller.d.ts +5 -0
  16. package/dist/types/session/response-language.d.ts +25 -0
  17. package/dist/types/setup/model-onboarding-guidance.d.ts +8 -1
  18. package/dist/types/setup/provider-onboarding.d.ts +2 -0
  19. package/dist/types/tools/debug.d.ts +2 -2
  20. package/dist/types/tools/index.d.ts +1 -0
  21. package/dist/types/tools/locate-core.d.ts +97 -0
  22. package/dist/types/tools/locate.d.ts +38 -0
  23. package/package.json +7 -7
  24. package/scripts/generate-sdk-operation-inventory.ts +4 -0
  25. package/src/cli/setup-cli.ts +7 -4
  26. package/src/commands/sdk.ts +3 -0
  27. package/src/commands/setup.ts +4 -1
  28. package/src/config/keybindings.ts +7 -0
  29. package/src/config/settings-schema.ts +82 -0
  30. package/src/decisions/typesafe-backend.ts +38 -4
  31. package/src/i18n/messages/de.settings.ts +26 -0
  32. package/src/i18n/messages/de.ts +4 -27
  33. package/src/i18n/messages/en.ts +6 -29
  34. package/src/i18n/messages/es.settings.ts +26 -0
  35. package/src/i18n/messages/es.ts +4 -27
  36. package/src/i18n/messages/fr.settings.ts +26 -0
  37. package/src/i18n/messages/fr.ts +4 -27
  38. package/src/i18n/messages/ja.settings.ts +26 -0
  39. package/src/i18n/messages/ja.ts +4 -27
  40. package/src/i18n/messages/ko.settings.ts +25 -0
  41. package/src/i18n/messages/ko.ts +5 -28
  42. package/src/i18n/messages/zh.settings.ts +23 -0
  43. package/src/i18n/messages/zh.ts +4 -27
  44. package/src/internal-urls/docs-index.generated.ts +8 -7
  45. package/src/modes/action-registry.ts +1 -0
  46. package/src/modes/components/welcome.ts +385 -387
  47. package/src/modes/controllers/input-controller.ts +15 -0
  48. package/src/modes/controllers/selector-controller.ts +13 -0
  49. package/src/modes/interactive-mode.ts +160 -98
  50. package/src/modes/types.ts +4 -0
  51. package/src/prompts/system/system-prompt.md +7 -2
  52. package/src/prompts/tools/locate.md +12 -0
  53. package/src/sdk/broker/broker.ts +75 -1
  54. package/src/sdk/broker/process-guard.ts +160 -0
  55. package/src/sdk/broker/transport.ts +15 -1
  56. package/src/sdk/bus/chat-daemon-runtime.ts +13 -1
  57. package/src/sdk/protocol/operation-inventory.generated.json +22 -0
  58. package/src/sdk/session.ts +4 -1
  59. package/src/session/agent-session.ts +118 -2
  60. package/src/session/auth-storage-discovery.ts +22 -7
  61. package/src/session/auto-fallback.ts +59 -0
  62. package/src/session/fallback-chain-controller.ts +5 -0
  63. package/src/session/response-language.ts +71 -0
  64. package/src/setup/model-onboarding-guidance.ts +29 -14
  65. package/src/setup/provider-onboarding.ts +5 -0
  66. package/src/slash-commands/builtin-registry.ts +112 -2
  67. package/src/tools/index.ts +3 -0
  68. package/src/tools/locate-core.ts +720 -0
  69. package/src/tools/locate.ts +197 -0
@@ -112,6 +112,9 @@ export class InputController {
112
112
  "app.session.tree": () => this.ctx.showTreeSelector(),
113
113
  "app.session.fork": () => this.ctx.showUserMessageSelector(),
114
114
  "app.session.resume": () => this.ctx.showSessionSelector(),
115
+ "app.session.continue": async () => {
116
+ await this.ctx.continueRecentSession();
117
+ },
115
118
  "app.session.observe": async () => {
116
119
  await this.ctx.showSessionObserver();
117
120
  },
@@ -710,6 +713,16 @@ export class InputController {
710
713
  return true;
711
714
  });
712
715
  }
716
+ this.#registerCommandPaletteAction("app.session.continue", () => {
717
+ void this.ctx.continueRecentSession();
718
+ return undefined;
719
+ });
720
+ for (const key of this.ctx.keybindings.getKeys("app.session.continue")) {
721
+ this.ctx.editor.setCustomKeyHandler(key, () => {
722
+ this.#executeAction("app.session.continue");
723
+ return true;
724
+ });
725
+ }
713
726
  this.#registerCommandPaletteAction(
714
727
  "app.message.followUp",
715
728
  () => this.#executeAction("app.message.followUp"),
@@ -835,6 +848,8 @@ export class InputController {
835
848
 
836
849
  async submitText(text: string, composer: ComposerSubmissionOptions): Promise<void> {
837
850
  text = text.trim();
851
+ // Past the launch card: give the mouse back to the terminal and stop offering its rows.
852
+ if (text) this.ctx.endWelcomeInteraction?.();
838
853
  if ((!isSettingsInitialized() || settings.get("emojiAutocomplete")) && text) text = expandEmoticons(text);
839
854
  if (this.ctx.hasActiveBtw()) {
840
855
  if (!text) return;
@@ -70,6 +70,7 @@ import {
70
70
  } from "../../sdk/bus/telegram-daemon";
71
71
  import { TelegramDaemonController } from "../../sdk/bus/telegram-daemon-control";
72
72
  import { runTelegramSetup, type TelegramSetupPreflight } from "../../sdk/bus/telegram-setup";
73
+ import { applyCredentialRankingModeSetting } from "../../session/auth-storage-discovery";
73
74
  import { type SessionInfo, SessionManager } from "../../session/session-manager";
74
75
  import { getTreeForInternalRead } from "../../session/session-manager-internal";
75
76
 
@@ -1372,6 +1373,18 @@ export class SelectorController {
1372
1373
  });
1373
1374
  break;
1374
1375
  }
1376
+ case "auth.credentialRankingMode": {
1377
+ const effective = applyCredentialRankingModeSetting(
1378
+ this.ctx.session.modelRegistry.authStorage,
1379
+ this.ctx.settings,
1380
+ );
1381
+ if (effective !== value) {
1382
+ this.ctx.showWarning(
1383
+ `SKC_CREDENTIAL_RANKING_MODE=${effective} is set, so it overrides this setting on this machine.`,
1384
+ );
1385
+ }
1386
+ break;
1387
+ }
1375
1388
  case "clearOnShrink":
1376
1389
  this.ctx.ui.setClearOnShrink(value as boolean);
1377
1390
  break;
@@ -1,3 +1,4 @@
1
+ import * as fs from "node:fs";
1
2
  import * as path from "node:path";
2
3
  import { type Agent, type AgentMessage, ThinkingLevel } from "@sayknow-cli/agent-core";
3
4
  import type { CompactionOutcome } from "@sayknow-cli/agent-core/compaction";
@@ -8,6 +9,7 @@ import {
8
9
  clearRenderCache,
9
10
  getRenderCacheRetainedBytes,
10
11
  Loader,
12
+ matchesKey,
11
13
  onImageProtocolChanged,
12
14
  ProcessTerminal,
13
15
  Spacer,
@@ -25,7 +27,6 @@ import {
25
27
  KeybindingsManager,
26
28
  type KeyDisplayContext,
27
29
  } from "../config/keybindings";
28
- import { getModelProfilePresentation } from "../config/model-profiles";
29
30
  import { isSettingsInitialized, type Settings, settings } from "../config/settings";
30
31
  import { DEFAULT_SKC_DEFINITION_NAMES } from "../defaults/skc-defaults";
31
32
  import type {
@@ -48,7 +49,6 @@ import type { AgentSession, AgentSessionEvent } from "../session/agent-session";
48
49
  import { HistoryStorage } from "../session/history-storage";
49
50
  import type { SessionContext, SessionManager } from "../session/session-manager";
50
51
  import { getRecentSessions, getSessionMessageEntryId } from "../session/session-manager";
51
- import { loadProjectContextFiles } from "../system-prompt";
52
52
  import type { LspStartupServerInfo } from "../tools";
53
53
  import { shortenPath } from "../tools/render-utils";
54
54
  import { formatPhaseDisplayName } from "../tools/todo-write";
@@ -72,19 +72,12 @@ import {
72
72
  isPetCapabilityProbePending,
73
73
  warnWhenPetCapabilitySettled,
74
74
  } from "./components/pet-capability";
75
- import { type PetMode, SayknowPetWidget } from "./components/sayknow-pet-widget";
75
+ import { type PetMode, type PetSkinId, SayknowPetWidget } from "./components/sayknow-pet-widget";
76
76
  import { resolveCurrentBranch } from "./components/status-line/git-utils";
77
77
  import type { ToolExecutionHandle } from "./components/tool-execution";
78
78
  import { StatusLineComponent } from "./components/tool-status-header";
79
79
  import { composeToolText } from "./components/tool-transcript-format";
80
- import {
81
- type RecentSession,
82
- WelcomeComponent,
83
- type WelcomeLogoMode,
84
- type LspServerInfo as WelcomeLspServerInfo,
85
- type WelcomeRoleBinding,
86
- type WelcomeSnapshot,
87
- } from "./components/welcome";
80
+ import { type RecentSession, WelcomeComponent, type WelcomeLogoMode, type WelcomeSnapshot } from "./components/welcome";
88
81
  import { BtwController } from "./controllers/btw-controller";
89
82
  import { CommandController } from "./controllers/command-controller";
90
83
  import { EventController } from "./controllers/event-controller";
@@ -107,7 +100,14 @@ import { shouldShowExtensionCommand } from "./slash-command-visibility";
107
100
  import { TasksAggregator } from "./tasks-aggregator";
108
101
  import { type ShimmerPalette, shimmerSegments, shimmerText } from "./theme/shimmer";
109
102
  import type { Theme } from "./theme/theme";
110
- import { getEditorTheme, getSymbolTheme, onTerminalAppearanceChange, onThemeChange, theme } from "./theme/theme";
103
+ import {
104
+ getCurrentThemeName,
105
+ getEditorTheme,
106
+ getSymbolTheme,
107
+ onTerminalAppearanceChange,
108
+ onThemeChange,
109
+ theme,
110
+ } from "./theme/theme";
111
111
  import { type RegisterTranscriptItem, TranscriptItemRegistry, transcriptItemId } from "./transcript-item-registry";
112
112
  import {
113
113
  type CompactionQueuedMessage,
@@ -211,13 +211,41 @@ function getShellInputPrefix(isNoContext: boolean): string {
211
211
  return `${shellLabel} `;
212
212
  }
213
213
 
214
+ /**
215
+ * The pet on the launch card: the one the user keeps beside the composer, or —
216
+ * with the pet off — the skin that matches the theme (blue for blue-octopus).
217
+ */
218
+ export function resolveWelcomePetSkin(petMode: PetMode, themeName: string | undefined): PetSkinId {
219
+ if (petMode !== "off") return petMode;
220
+ return themeName?.startsWith("blue") ? "blue" : "red";
221
+ }
222
+
214
223
  /**
215
224
  * The composer is an open rail, not a box: every input row starts with the same
216
225
  * rail glyph a submitted prompt keeps in the transcript, colored by the editor's
217
226
  * border color (session accent, thinking level, shell/python mode).
218
227
  */
228
+ /** Background band under the composer's input rows, so the line being typed stands out. */
229
+ function composerInputBackground(): string | undefined {
230
+ try {
231
+ return theme.getBgAnsi("userMessageBg");
232
+ } catch {
233
+ return undefined;
234
+ }
235
+ }
236
+
237
+ /** Canonical form of a session path, so /private/var and /var compare equal on macOS. */
238
+ function canonicalPath(file: string): string {
239
+ try {
240
+ return fs.realpathSync(file);
241
+ } catch {
242
+ return path.resolve(file);
243
+ }
244
+ }
245
+
219
246
  export function configureDefaultComposerChrome(editor: CustomEditor): void {
220
247
  editor.setBorderVisible(false);
248
+ editor.setInputBackground(composerInputBackground());
221
249
  editor.setClosedBorderBox(false);
222
250
  editor.setPromptGutter(undefined);
223
251
  editor.setRailGutter(theme.rail.user);
@@ -464,6 +492,9 @@ export class InteractiveMode implements InteractiveModeContext {
464
492
  #eventBus?: EventBus;
465
493
  #eventBusUnsubscribers: Array<() => void> = [];
466
494
  #welcomeComponent?: WelcomeComponent;
495
+ #welcomeInputUnsubscribe?: () => void;
496
+ /** Mouse reporting was switched on for the launch card and must be restored after it. */
497
+ #welcomeEnabledMouse = false;
467
498
  #ircSplitView: IrcSplitViewComponent;
468
499
  #ircSidebarAvailable = false;
469
500
  #ircSidebarRequestedVisible = false;
@@ -743,7 +774,6 @@ export class InteractiveMode implements InteractiveModeContext {
743
774
  modelName,
744
775
  providerName,
745
776
  recentSessions,
746
- this.#getWelcomeLspServers(),
747
777
  welcomeLogoMode,
748
778
  {
749
779
  getViewportRows: () => this.ui.terminal.rows,
@@ -755,11 +785,15 @@ export class InteractiveMode implements InteractiveModeContext {
755
785
  skipLogoAnimation,
756
786
  snapshot: this.#buildWelcomeSnapshot(),
757
787
  resumeKey: this.keybindings.getKeys("app.session.resume")[0],
788
+ continueKey: this.keybindings.getKeys("app.session.continue")[0],
789
+ petSkin: resolveWelcomePetSkin(settings.get("pet.mode"), getCurrentThemeName()),
790
+ onOpenSession: session => void this.#openWelcomeSession(session),
758
791
  },
759
792
  );
760
793
 
761
794
  this.ui.addChild(this.#welcomeComponent);
762
795
  this.#welcomeComponent.playIntro(() => this.ui.requestRender());
796
+ this.#startWelcomeInteraction();
763
797
  }
764
798
 
765
799
  this.ui.addChild(this.#ircSplitView);
@@ -871,11 +905,15 @@ export class InteractiveMode implements InteractiveModeContext {
871
905
  )
872
906
  .then(sessions => {
873
907
  if (this.#welcomeComponent !== welcomeComponent) return;
908
+ const current = this.#currentSessionFileCanonical();
874
909
  welcomeComponent.setRecentSessions(
875
- sessions.map(session => ({
876
- name: session.name,
877
- timeAgo: session.timeAgo,
878
- })),
910
+ sessions
911
+ .filter(session => canonicalPath(session.path) !== current)
912
+ .map(session => ({
913
+ name: session.name,
914
+ timeAgo: session.timeAgo,
915
+ path: session.path,
916
+ })),
879
917
  );
880
918
  this.ui.requestRender();
881
919
  })
@@ -1222,6 +1260,7 @@ export class InteractiveMode implements InteractiveModeContext {
1222
1260
  }
1223
1261
  // Re-read the glyph so a symbol-preset switch (unicode/nerd/ascii) reaches the rail.
1224
1262
  this.editor.setRailGutter(theme.rail.user);
1263
+ this.editor.setInputBackground(composerInputBackground());
1225
1264
  this.editor.setPlaceholder(this.#getComposerPlaceholder());
1226
1265
  this.#setComposerTopBorder();
1227
1266
  this.ui.requestRender();
@@ -1424,6 +1463,7 @@ export class InteractiveMode implements InteractiveModeContext {
1424
1463
  this.loadingAnimation.stop();
1425
1464
  this.loadingAnimation = undefined;
1426
1465
  }
1466
+ this.#endWelcomeInteraction();
1427
1467
  this.#welcomeComponent?.dispose();
1428
1468
  this.#welcomeComponent = undefined;
1429
1469
  if (this.#sttController) {
@@ -1611,36 +1651,13 @@ export class InteractiveMode implements InteractiveModeContext {
1611
1651
  }
1612
1652
 
1613
1653
  #handleLspStartupEvent(event: LspStartupEvent): void {
1614
- this.#updateWelcomeLspServers();
1615
-
1616
1654
  const warningMessage = getLspStartupWarningMessage(event);
1617
1655
  if (warningMessage) {
1618
1656
  this.showWarning(warningMessage);
1619
1657
  }
1620
1658
  }
1621
1659
 
1622
- #getWelcomeLspServers(): WelcomeLspServerInfo[] {
1623
- return (
1624
- this.lspServers?.map(server => ({
1625
- name: server.name,
1626
- status: server.status,
1627
- fileTypes: server.fileTypes,
1628
- })) ?? []
1629
- );
1630
- }
1631
-
1632
- #updateWelcomeLspServers(): void {
1633
- if (!this.#welcomeComponent) {
1634
- return;
1635
- }
1636
-
1637
- this.#welcomeComponent.setLspServers(this.#getWelcomeLspServers());
1638
- // LSP startup settles around the same time the MCP connections do.
1639
- this.#welcomeComponent.setSnapshot({ mcp: this.#welcomeMcpSummary() });
1640
- this.ui.requestRender();
1641
- }
1642
-
1643
- /** Facts the launch ledger can show synchronously, before any probe runs. */
1660
+ /** Facts the launch card can show synchronously, before the git probe runs. */
1644
1661
  #buildWelcomeSnapshot(): WelcomeSnapshot {
1645
1662
  const projectDir = getProjectDir();
1646
1663
  let branch: string | null = null;
@@ -1649,68 +1666,19 @@ export class InteractiveMode implements InteractiveModeContext {
1649
1666
  } catch {
1650
1667
  branch = null;
1651
1668
  }
1652
- const profileName = this.session.getActiveModelProfile() ?? settings.get("modelProfile.default");
1653
- return {
1654
- cwd: shortenPath(projectDir),
1655
- branch,
1656
- thinkingLevel: this.session.thinkingLevel,
1657
- profile: profileName ? getModelProfilePresentation(profileName).displayName : undefined,
1658
- roles: this.#welcomeRoleBindings(),
1659
- mcp: this.#welcomeMcpSummary(),
1660
- skills: this.session.skills.length,
1661
- };
1662
- }
1663
-
1664
- /** Role agents in their canonical order, with the provider prefix dropped for width. */
1665
- #welcomeRoleBindings(): WelcomeRoleBinding[] {
1666
- const overrides = settings.get("task.agentModelOverrides") as Record<string, unknown> | undefined;
1667
- if (!overrides) return [];
1668
- const bindings: WelcomeRoleBinding[] = [];
1669
- for (const role of ["executor", "planner", "critic", "architect"]) {
1670
- const selector = overrides[role];
1671
- if (typeof selector !== "string" || selector.length === 0) continue;
1672
- const slash = selector.indexOf("/");
1673
- bindings.push({ role, model: slash >= 0 ? selector.slice(slash + 1) : selector });
1674
- }
1675
- return bindings;
1676
- }
1677
-
1678
- #welcomeMcpSummary(): { connected: number; total: number } {
1679
- const manager = this.mcpManager;
1680
- if (!manager) return { connected: 0, total: 0 };
1681
- return { connected: manager.getConnectedServers().length, total: manager.getAllServerNames().length };
1669
+ return { cwd: shortenPath(projectDir), branch, thinkingLevel: this.session.thinkingLevel };
1682
1670
  }
1683
1671
 
1684
- /** Fill in the ledger facts that need I/O: git change counts and project instruction files. */
1672
+ /** Fill in the git change counts, which need I/O. */
1685
1673
  #probeWelcomeWorkspace(welcomeComponent: WelcomeComponent): void {
1686
- const projectDir = getProjectDir();
1687
- const apply = (patch: WelcomeSnapshot): void => {
1688
- if (this.#welcomeComponent !== welcomeComponent) return;
1689
- welcomeComponent.setSnapshot(patch);
1690
- this.ui.requestRender();
1691
- };
1692
1674
  void gitUtils.status
1693
- .summary(projectDir)
1694
- .then(gitChanges => apply({ gitChanges }))
1695
- .catch(() => apply({ gitChanges: null }));
1696
- void gitUtils.log
1697
- .onelines(projectDir, 3)
1698
- .then(recentCommits => apply({ recentCommits }))
1675
+ .summary(getProjectDir())
1676
+ .then(gitChanges => {
1677
+ if (this.#welcomeComponent !== welcomeComponent) return;
1678
+ welcomeComponent.setSnapshot({ gitChanges });
1679
+ this.ui.requestRender();
1680
+ })
1699
1681
  .catch(() => {});
1700
- void loadProjectContextFiles({ cwd: projectDir })
1701
- .then(files =>
1702
- apply({
1703
- contextFiles: files.map(file => {
1704
- const relative = path.relative(projectDir, file.path);
1705
- return relative.startsWith("..") ? shortenPath(file.path) : relative;
1706
- }),
1707
- }),
1708
- )
1709
- .catch(error => {
1710
- logger.debug("Failed to load context files for welcome screen", {
1711
- error: error instanceof Error ? error.message : String(error),
1712
- });
1713
- });
1714
1682
  }
1715
1683
 
1716
1684
  #getWorkingMessageAccent(): WorkingMessageAccent | undefined {
@@ -2246,6 +2214,100 @@ export class InteractiveMode implements InteractiveModeContext {
2246
2214
  this.#selectorController.showSessionSelector();
2247
2215
  }
2248
2216
 
2217
+ #currentSessionFileCanonical(): string | undefined {
2218
+ const currentFile = this.sessionManager.getSessionFile();
2219
+ return currentFile ? canonicalPath(currentFile) : undefined;
2220
+ }
2221
+
2222
+ /**
2223
+ * The launch card's session rows take ↓/↑/Enter/Esc and clicks until the first prompt
2224
+ * is sent. Mouse reporting is switched on for that window (unless the user already
2225
+ * runs with it) so a click reaches the card; it goes back to the configured state when
2226
+ * the card stops taking input.
2227
+ */
2228
+ #startWelcomeInteraction(): void {
2229
+ this.#releaseWelcomeInput();
2230
+ const welcome = this.#welcomeComponent;
2231
+ if (!welcome) return;
2232
+ this.#welcomeInputUnsubscribe = this.ui.addInputListener(data => this.#handleWelcomeKey(welcome, data));
2233
+ if (settings.get("startup.welcomeMouse") && !this.ui.mouseEnabled) {
2234
+ this.ui.setMouseEnabled(true);
2235
+ this.#welcomeEnabledMouse = true;
2236
+ }
2237
+ }
2238
+
2239
+ /** The card stops taking input: first prompt sent, a session opened, or the UI rebuilt. */
2240
+ endWelcomeInteraction(): void {
2241
+ this.#endWelcomeInteraction();
2242
+ }
2243
+
2244
+ /** Drop the card's key listener and give mouse reporting back to its configured state. */
2245
+ #releaseWelcomeInput(): void {
2246
+ this.#welcomeInputUnsubscribe?.();
2247
+ this.#welcomeInputUnsubscribe = undefined;
2248
+ if (this.#welcomeEnabledMouse) {
2249
+ this.#welcomeEnabledMouse = false;
2250
+ this.ui.setMouseEnabled(settings.get("mouse.enabled"));
2251
+ }
2252
+ }
2253
+
2254
+ #endWelcomeInteraction(): void {
2255
+ this.#releaseWelcomeInput();
2256
+ if (this.#welcomeComponent?.interactive) {
2257
+ this.#welcomeComponent.endInteraction();
2258
+ this.ui.requestRender();
2259
+ }
2260
+ }
2261
+
2262
+ /**
2263
+ * ↓ from an empty composer (not walking history) moves into the session rows; there,
2264
+ * ↑/↓ move, Enter opens, Esc (or ↑ past the first row) hands focus back. Any other key
2265
+ * leaves the rows and reaches the composer as usual.
2266
+ */
2267
+ #handleWelcomeKey(welcome: WelcomeComponent, data: string): { consume: true } | undefined {
2268
+ if (this.#welcomeComponent !== welcome || !welcome.interactive) return undefined;
2269
+ if (this.ui.getFocusedComponent() !== this.editor) return undefined;
2270
+ const picking = welcome.selectedIndex !== undefined;
2271
+ if (!picking) {
2272
+ if (!matchesKey(data, "down")) return undefined;
2273
+ if (this.editor.getText() !== "" || this.editor.isBrowsingHistory() || this.editor.isShowingAutocomplete())
2274
+ return undefined;
2275
+ if (!welcome.select(0)) return undefined;
2276
+ this.ui.requestRender();
2277
+ return { consume: true };
2278
+ }
2279
+ if (matchesKey(data, "down")) welcome.moveSelection(1);
2280
+ else if (matchesKey(data, "up")) welcome.moveSelection(-1);
2281
+ else if (matchesKey(data, "enter")) welcome.openSelected();
2282
+ else if (matchesKey(data, "escape")) welcome.select(undefined);
2283
+ else {
2284
+ welcome.select(undefined);
2285
+ this.ui.requestRender();
2286
+ return undefined;
2287
+ }
2288
+ this.ui.requestRender();
2289
+ return { consume: true };
2290
+ }
2291
+
2292
+ async #openWelcomeSession(session: RecentSession): Promise<void> {
2293
+ if (!session.path) return;
2294
+ this.#endWelcomeInteraction();
2295
+ await this.handleResumeSession(session.path, { requireIdle: true });
2296
+ }
2297
+
2298
+ async continueRecentSession(): Promise<void> {
2299
+ // Listing returns canonical paths (/private/var on macOS); compare canonically so the
2300
+ // live session is never "continued" into itself.
2301
+ const current = this.#currentSessionFileCanonical();
2302
+ const recent = await getRecentSessions(this.sessionManager.getSessionDir());
2303
+ const target = recent.find(session => canonicalPath(session.path) !== current);
2304
+ if (!target) {
2305
+ this.showStatus("No earlier session to continue");
2306
+ return;
2307
+ }
2308
+ await this.handleResumeSession(target.path, { requireIdle: true });
2309
+ }
2310
+
2249
2311
  handleResumeSession(sessionPath: string, options?: { requireIdle?: boolean }): Promise<boolean> {
2250
2312
  return this.#selectorController.handleResumeSession(sessionPath, options, () => {
2251
2313
  this.#btwController.dispose();
@@ -327,6 +327,10 @@ export interface InteractiveModeContext {
327
327
  showUserMessageSelector(): void;
328
328
  showTreeSelector(): void;
329
329
  showSessionSelector(): void;
330
+ /** Resume the most recent saved session other than this one (the launch card's first row). */
331
+ continueRecentSession(): Promise<void>;
332
+ /** The launch card stops taking ↓/Enter/clicks and releases mouse capture (first prompt sent). */
333
+ endWelcomeInteraction?(): void;
330
334
  showSessionsDashboard(): void;
331
335
  handleResumeSession(sessionPath: string, options?: { requireIdle?: boolean }): Promise<boolean>;
332
336
  handleSessionDeleteCommand(): Promise<void>;
@@ -43,11 +43,16 @@ Optimize for correctness first, maintainability second, and brevity third. Prefe
43
43
  - Do not defer actionable work. Underpromise and overdeliver: report only what is done or in progress, never announce remaining work instead of doing it.
44
44
  </communication>
45
45
 
46
+ <response-language>
47
+ - Write every user-facing message in the language the user writes in (their latest message); a language they explicitly asked for wins.
48
+ - This holds for the whole turn, including progress notes and the final report after long tool work. Tool output, logs, code, file contents, English skill or system text, and context summaries in another language never change it.
49
+ - Keep code, commands, file paths, identifiers, and quoted output as they are; translate the prose around them.
50
+ </response-language>
51
+
46
52
  {{#if reasoningLanguageEnglish}}
47
53
  <reasoning-language>
48
54
  - Reason through development and technical problem-solving in English.
49
- - Keep user-facing answers in the language the user requested or used.
50
- - This changes reasoning language only; it does not relax correctness, safety, or communication requirements.
55
+ - This changes reasoning language only; user-facing answers still follow <response-language>, and it does not relax correctness, safety, or communication requirements.
51
56
  </reasoning-language>
52
57
  {{/if}}
53
58
 
@@ -0,0 +1,12 @@
1
+ Find where behaviour lives by describing what the code does, when you do not know a file, symbol, or string to grep for.
2
+
3
+ <instruction>
4
+ - Ask a question about behaviour: "Where does the broker stop when idle?", "Which code picks fallback models?", "Which tests cover retry after a timeout?"
5
+ - Returns ranked files with their declaration lines and line numbers. Read the listed lines next; the result is a lead, not proof, and it can miss files.
6
+ - Use `search` or `find` instead when you already know an exact name, path, or string. They are faster and free.
7
+ - Narrow `path` to the part of the repository you care about when you can.
8
+ </instruction>
9
+
10
+ <data>
11
+ Relevance is judged by Jev (TypeSafe). It receives the question, folder paths, file names, and each candidate file's declaration lines (function, class, type, and constant signatures). It never receives function bodies or comments. Files ignored by .gitignore, hidden files, and binaries are skipped.
12
+ </data>
@@ -40,6 +40,18 @@ export interface BrokerSettings {
40
40
  packageGeneration?: string;
41
41
  port?: number;
42
42
  heartbeatTtlMs?: number;
43
+ /**
44
+ * Stop after this long with no connection, no request in flight and no live
45
+ * session. Clients call `ensureBroker` before use, so the next one starts a fresh
46
+ * broker. 0 disables. Default {@link BROKER_IDLE_SHUTDOWN_MS}.
47
+ */
48
+ idleShutdownMs?: number;
49
+ /**
50
+ * Runs at the start of every publication tick, before the tick calls into the
51
+ * native addon. The broker process uses it to stop at once when its source
52
+ * checkout has vanished (see `process-guard.ts`); return true to skip the tick.
53
+ */
54
+ beforePublicationTick?: () => boolean;
43
55
  /** Broker-owned migration policy. Client lifecycle frames cannot select it. */
44
56
  resolveDirectoryMigration?: (_cwd: string) => Promise<DirectoryMigrationPolicy>;
45
57
  }
@@ -49,6 +61,8 @@ type ResolvedBrokerSettings = {
49
61
  packageGeneration: string;
50
62
  port: number;
51
63
  heartbeatTtlMs: number;
64
+ idleShutdownMs: number;
65
+ beforePublicationTick: () => boolean;
52
66
  resolveDirectoryMigration: (_cwd: string) => Promise<DirectoryMigrationPolicy>;
53
67
  };
54
68
 
@@ -371,6 +385,12 @@ type BrokerLockSnapshot = {
371
385
  const BROKER_PUBLICATION_CADENCE_MS = 5_000;
372
386
  const BROKER_PUBLICATION_GRACE_MS = 15_000;
373
387
  const BROKER_SETTLEMENT_MS = 2_000;
388
+ /**
389
+ * A broker with nothing to serve stops after this long. Without it a broker lived
390
+ * until killed: one started by a test (or a crashed client) whose caller never came
391
+ * back stayed up for days holding a port, memory and a mapped native addon.
392
+ */
393
+ export const BROKER_IDLE_SHUTDOWN_MS = 30 * 60_000;
374
394
  type BrokerPublicationState =
375
395
  | "healthy-owned"
376
396
  | "suspect-unpublished"
@@ -400,12 +420,17 @@ export class Broker {
400
420
  #completion!: Promise<void>;
401
421
  #resolveCompletion!: () => void;
402
422
  #rejectCompletion!: (error: unknown) => void;
423
+ /** Last time a client connected, disconnected, or sent a request. Drives idle shutdown. */
424
+ #lastActivityAt = Date.now();
425
+ #idleCheckInFlight = false;
403
426
  constructor(settings: BrokerSettings) {
404
427
  this.settings = {
405
428
  agentDir: settings.agentDir,
406
429
  packageGeneration: settings.packageGeneration ?? "unknown",
407
430
  port: settings.port ?? 0,
408
431
  heartbeatTtlMs: settings.heartbeatTtlMs ?? BROKER_HEARTBEAT_TTL_MS,
432
+ idleShutdownMs: Math.max(0, settings.idleShutdownMs ?? BROKER_IDLE_SHUTDOWN_MS),
433
+ beforePublicationTick: settings.beforePublicationTick ?? (() => false),
409
434
  resolveDirectoryMigration: settings.resolveDirectoryMigration ?? (async () => "copy-retain"),
410
435
  };
411
436
  this.index = new SessionIndex(settings.agentDir);
@@ -598,7 +623,8 @@ export class Broker {
598
623
  10,
599
624
  Math.min(BROKER_PUBLICATION_CADENCE_MS, Math.floor(this.settings.heartbeatTtlMs / 3)),
600
625
  );
601
- this.#heartbeatTimer = setInterval(() => void this.#watchPublication(), cadenceMs);
626
+ this.#lastActivityAt = Date.now();
627
+ this.#heartbeatTimer = setInterval(() => void this.#publicationTick(), cadenceMs);
602
628
  return this.discovery;
603
629
  } catch (error) {
604
630
  await this.#transport?.stop();
@@ -625,6 +651,52 @@ export class Broker {
625
651
  if (kind === "suspect-unpublished") this.#lossAt ??= process.hrtime.bigint();
626
652
  else this.#lossAt = null;
627
653
  }
654
+ async #publicationTick(): Promise<void> {
655
+ // Before anything that reaches the native addon: the process guard may end the process here.
656
+ if (this.settings.beforePublicationTick()) return;
657
+ await this.#watchPublication();
658
+ if (this.#publicationState === "healthy-owned") await this.#stopIfIdle();
659
+ }
660
+ /** Record client activity; an active broker never idles out. */
661
+ noteActivity(): void {
662
+ this.#lastActivityAt = Date.now();
663
+ }
664
+ /**
665
+ * Stop once nothing has used the broker for `idleShutdownMs`: no open client
666
+ * connection, no request or lifecycle chain in flight, and no live session host
667
+ * in the index. A stop from here is the ordinary owned-root stop, so discovery
668
+ * and the lock are released and the next `ensureBroker` starts a new broker.
669
+ */
670
+ async #stopIfIdle(): Promise<void> {
671
+ const idleMs = this.settings.idleShutdownMs;
672
+ if (idleMs <= 0 || this.#stopping || this.#idleCheckInFlight) return;
673
+ if (!this.#idleCandidate(idleMs)) return;
674
+ this.#idleCheckInFlight = true;
675
+ try {
676
+ await this.index.refresh();
677
+ if (this.index.listSessions().sessions.some(session => session.live)) {
678
+ // Live hosts keep the broker; look again after another idle window.
679
+ this.#lastActivityAt = Date.now();
680
+ return;
681
+ }
682
+ // Re-check: a client may have connected while the index was read.
683
+ if (this.#stopping || !this.#idleCandidate(idleMs)) return;
684
+ void this.#complete("owned-root");
685
+ } catch {
686
+ // An unreadable index is not evidence of idleness; try again next window.
687
+ this.#lastActivityAt = Date.now();
688
+ } finally {
689
+ this.#idleCheckInFlight = false;
690
+ }
691
+ }
692
+ #idleCandidate(idleMs: number): boolean {
693
+ return (
694
+ Date.now() - this.#lastActivityAt >= idleMs &&
695
+ (this.#transport?.openConnections ?? 0) === 0 &&
696
+ this.#admitted.size === 0 &&
697
+ this.#chains.size === 0
698
+ );
699
+ }
628
700
  async #watchPublication(writeHeartbeat = true): Promise<void> {
629
701
  if (!this.#publication || this.#publicationState === "stopping") return;
630
702
  let observation: ReturnType<RetainedBrokerDiscovery["observe"]>;
@@ -747,12 +819,14 @@ export class Broker {
747
819
  handleRequest(operation: string, input: Record<string, unknown>, idempotencyKey?: string): Promise<BrokerResponse> {
748
820
  if (this.#stopping || (this.#publication !== null && this.#publicationState !== "healthy-owned"))
749
821
  return Promise.resolve(error("unavailable", "broker publication is unavailable"));
822
+ this.#lastActivityAt = Date.now();
750
823
  let release!: () => void;
751
824
  const admission = new Promise<void>(resolve => (release = resolve));
752
825
  this.#admitted.add(admission);
753
826
  return this.#handleRequest(operation, input, idempotencyKey).finally(() => {
754
827
  release();
755
828
  this.#admitted.delete(admission);
829
+ this.#lastActivityAt = Date.now();
756
830
  });
757
831
  }
758
832
  async #handleRequest(