@sayknow-cli/coding-agent 0.6.6 → 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 (65) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/dist/types/config/settings-schema.d.ts +79 -0
  3. package/dist/types/i18n/messages/en.d.ts +2 -0
  4. package/dist/types/modes/components/welcome.d.ts +21 -4
  5. package/dist/types/modes/interactive-mode.d.ts +2 -5
  6. package/dist/types/modes/types.d.ts +2 -0
  7. package/dist/types/sdk/broker/broker.d.ts +22 -0
  8. package/dist/types/sdk/broker/process-guard.d.ts +71 -0
  9. package/dist/types/sdk/broker/transport.d.ts +2 -0
  10. package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +4 -0
  11. package/dist/types/session/agent-session.d.ts +9 -0
  12. package/dist/types/session/auth-storage-discovery.d.ts +15 -0
  13. package/dist/types/session/auto-fallback.d.ts +27 -0
  14. package/dist/types/session/fallback-chain-controller.d.ts +5 -0
  15. package/dist/types/session/response-language.d.ts +25 -0
  16. package/dist/types/setup/model-onboarding-guidance.d.ts +8 -1
  17. package/dist/types/setup/provider-onboarding.d.ts +2 -0
  18. package/dist/types/tools/index.d.ts +1 -0
  19. package/dist/types/tools/locate-core.d.ts +97 -0
  20. package/dist/types/tools/locate.d.ts +38 -0
  21. package/package.json +7 -7
  22. package/scripts/generate-sdk-operation-inventory.ts +4 -0
  23. package/src/cli/setup-cli.ts +7 -4
  24. package/src/commands/sdk.ts +3 -0
  25. package/src/commands/setup.ts +4 -1
  26. package/src/config/settings-schema.ts +82 -0
  27. package/src/decisions/typesafe-backend.ts +38 -4
  28. package/src/i18n/messages/de.settings.ts +26 -0
  29. package/src/i18n/messages/de.ts +2 -0
  30. package/src/i18n/messages/en.ts +2 -0
  31. package/src/i18n/messages/es.settings.ts +26 -0
  32. package/src/i18n/messages/es.ts +2 -0
  33. package/src/i18n/messages/fr.settings.ts +26 -0
  34. package/src/i18n/messages/fr.ts +2 -0
  35. package/src/i18n/messages/ja.settings.ts +26 -0
  36. package/src/i18n/messages/ja.ts +2 -0
  37. package/src/i18n/messages/ko.settings.ts +25 -0
  38. package/src/i18n/messages/ko.ts +2 -0
  39. package/src/i18n/messages/zh.settings.ts +23 -0
  40. package/src/i18n/messages/zh.ts +2 -0
  41. package/src/internal-urls/docs-index.generated.ts +7 -6
  42. package/src/modes/components/welcome.ts +240 -68
  43. package/src/modes/controllers/input-controller.ts +2 -0
  44. package/src/modes/controllers/selector-controller.ts +13 -0
  45. package/src/modes/interactive-mode.ts +118 -14
  46. package/src/modes/types.ts +2 -0
  47. package/src/prompts/system/system-prompt.md +7 -2
  48. package/src/prompts/tools/locate.md +12 -0
  49. package/src/sdk/broker/broker.ts +75 -1
  50. package/src/sdk/broker/process-guard.ts +160 -0
  51. package/src/sdk/broker/transport.ts +15 -1
  52. package/src/sdk/bus/chat-daemon-runtime.ts +13 -1
  53. package/src/sdk/protocol/operation-inventory.generated.json +22 -0
  54. package/src/sdk/session.ts +4 -1
  55. package/src/session/agent-session.ts +118 -2
  56. package/src/session/auth-storage-discovery.ts +22 -7
  57. package/src/session/auto-fallback.ts +59 -0
  58. package/src/session/fallback-chain-controller.ts +5 -0
  59. package/src/session/response-language.ts +71 -0
  60. package/src/setup/model-onboarding-guidance.ts +29 -14
  61. package/src/setup/provider-onboarding.ts +5 -0
  62. package/src/slash-commands/builtin-registry.ts +112 -2
  63. package/src/tools/index.ts +3 -0
  64. package/src/tools/locate-core.ts +720 -0
  65. package/src/tools/locate.ts +197 -0
@@ -9,6 +9,7 @@ import {
9
9
  clearRenderCache,
10
10
  getRenderCacheRetainedBytes,
11
11
  Loader,
12
+ matchesKey,
12
13
  onImageProtocolChanged,
13
14
  ProcessTerminal,
14
15
  Spacer,
@@ -224,8 +225,27 @@ export function resolveWelcomePetSkin(petMode: PetMode, themeName: string | unde
224
225
  * rail glyph a submitted prompt keeps in the transcript, colored by the editor's
225
226
  * border color (session accent, thinking level, shell/python mode).
226
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
+
227
246
  export function configureDefaultComposerChrome(editor: CustomEditor): void {
228
247
  editor.setBorderVisible(false);
248
+ editor.setInputBackground(composerInputBackground());
229
249
  editor.setClosedBorderBox(false);
230
250
  editor.setPromptGutter(undefined);
231
251
  editor.setRailGutter(theme.rail.user);
@@ -472,6 +492,9 @@ export class InteractiveMode implements InteractiveModeContext {
472
492
  #eventBus?: EventBus;
473
493
  #eventBusUnsubscribers: Array<() => void> = [];
474
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;
475
498
  #ircSplitView: IrcSplitViewComponent;
476
499
  #ircSidebarAvailable = false;
477
500
  #ircSidebarRequestedVisible = false;
@@ -764,11 +787,13 @@ export class InteractiveMode implements InteractiveModeContext {
764
787
  resumeKey: this.keybindings.getKeys("app.session.resume")[0],
765
788
  continueKey: this.keybindings.getKeys("app.session.continue")[0],
766
789
  petSkin: resolveWelcomePetSkin(settings.get("pet.mode"), getCurrentThemeName()),
790
+ onOpenSession: session => void this.#openWelcomeSession(session),
767
791
  },
768
792
  );
769
793
 
770
794
  this.ui.addChild(this.#welcomeComponent);
771
795
  this.#welcomeComponent.playIntro(() => this.ui.requestRender());
796
+ this.#startWelcomeInteraction();
772
797
  }
773
798
 
774
799
  this.ui.addChild(this.#ircSplitView);
@@ -880,11 +905,15 @@ export class InteractiveMode implements InteractiveModeContext {
880
905
  )
881
906
  .then(sessions => {
882
907
  if (this.#welcomeComponent !== welcomeComponent) return;
908
+ const current = this.#currentSessionFileCanonical();
883
909
  welcomeComponent.setRecentSessions(
884
- sessions.map(session => ({
885
- name: session.name,
886
- timeAgo: session.timeAgo,
887
- })),
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
+ })),
888
917
  );
889
918
  this.ui.requestRender();
890
919
  })
@@ -1231,6 +1260,7 @@ export class InteractiveMode implements InteractiveModeContext {
1231
1260
  }
1232
1261
  // Re-read the glyph so a symbol-preset switch (unicode/nerd/ascii) reaches the rail.
1233
1262
  this.editor.setRailGutter(theme.rail.user);
1263
+ this.editor.setInputBackground(composerInputBackground());
1234
1264
  this.editor.setPlaceholder(this.#getComposerPlaceholder());
1235
1265
  this.#setComposerTopBorder();
1236
1266
  this.ui.requestRender();
@@ -1433,6 +1463,7 @@ export class InteractiveMode implements InteractiveModeContext {
1433
1463
  this.loadingAnimation.stop();
1434
1464
  this.loadingAnimation = undefined;
1435
1465
  }
1466
+ this.#endWelcomeInteraction();
1436
1467
  this.#welcomeComponent?.dispose();
1437
1468
  this.#welcomeComponent = undefined;
1438
1469
  if (this.#sttController) {
@@ -2183,20 +2214,93 @@ export class InteractiveMode implements InteractiveModeContext {
2183
2214
  this.#selectorController.showSessionSelector();
2184
2215
  }
2185
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
+
2186
2298
  async continueRecentSession(): Promise<void> {
2187
2299
  // Listing returns canonical paths (/private/var on macOS); compare canonically so the
2188
2300
  // live session is never "continued" into itself.
2189
- const canonical = (file: string): string => {
2190
- try {
2191
- return fs.realpathSync(file);
2192
- } catch {
2193
- return path.resolve(file);
2194
- }
2195
- };
2196
- const currentFile = this.sessionManager.getSessionFile();
2197
- const current = currentFile ? canonical(currentFile) : undefined;
2301
+ const current = this.#currentSessionFileCanonical();
2198
2302
  const recent = await getRecentSessions(this.sessionManager.getSessionDir());
2199
- const target = recent.find(session => canonical(session.path) !== current);
2303
+ const target = recent.find(session => canonicalPath(session.path) !== current);
2200
2304
  if (!target) {
2201
2305
  this.showStatus("No earlier session to continue");
2202
2306
  return;
@@ -329,6 +329,8 @@ export interface InteractiveModeContext {
329
329
  showSessionSelector(): void;
330
330
  /** Resume the most recent saved session other than this one (the launch card's first row). */
331
331
  continueRecentSession(): Promise<void>;
332
+ /** The launch card stops taking ↓/Enter/clicks and releases mouse capture (first prompt sent). */
333
+ endWelcomeInteraction?(): void;
332
334
  showSessionsDashboard(): void;
333
335
  handleResumeSession(sessionPath: string, options?: { requireIdle?: boolean }): Promise<boolean>;
334
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(
@@ -0,0 +1,160 @@
1
+ import * as fs from "node:fs";
2
+
3
+ /**
4
+ * Guards for the SDK's long-lived background processes (`sdk broker-internal`,
5
+ * `sdk session-host-internal`). They are spawned detached and outlive whoever
6
+ * started them, so each one has to notice on its own when it has no reason, or no
7
+ * way, to keep running.
8
+ */
9
+
10
+ /** How often the guards run. Cheap: one `stat`, plus a small JSON read for hosts. */
11
+ export const PROCESS_GUARD_INTERVAL_MS = 5_000;
12
+
13
+ /**
14
+ * The entry script of a process run from source (`bun …/src/cli.ts sdk …`), or
15
+ * undefined for a compiled binary, whose entry lives in Bun's virtual filesystem
16
+ * and cannot disappear from under it.
17
+ */
18
+ export function sourceEntryPath(main: string | undefined = Bun.main): string | undefined {
19
+ if (!main) return undefined;
20
+ if (main.startsWith("/$bunfs/") || /^[A-Za-z]:[\\/]~BUN[\\/]/.test(main)) return undefined;
21
+ return main;
22
+ }
23
+
24
+ /** Errors that mean the file is gone, or the volume holding it is: unmounted, ejected, or failing. */
25
+ const SOURCE_GONE_CODES = new Set(["ENOENT", "ENOTDIR", "EIO", "ENXIO", "ENODEV"]);
26
+
27
+ /** Whether the source entry is still reachable. A compiled binary (no entry) always is. */
28
+ export function sourceEntryAvailable(
29
+ entry: string | undefined,
30
+ stat: (file: string) => unknown = file => fs.statSync(file),
31
+ ): boolean {
32
+ if (!entry) return true;
33
+ try {
34
+ stat(entry);
35
+ return true;
36
+ } catch (error) {
37
+ return !SOURCE_GONE_CODES.has((error as NodeJS.ErrnoException).code ?? "");
38
+ }
39
+ }
40
+
41
+ /**
42
+ * End this process at once when the source it runs from has vanished — typically
43
+ * an external drive with the checkout was unplugged. The native addon is mapped
44
+ * from that checkout and paged in lazily, so the next call into it faults on a
45
+ * page that can no longer be read, and Bun's fault handler then spins on the same
46
+ * unreadable mapping at 100% CPU forever. No cleanup is safe at that point (it
47
+ * would call into the same addon), so the process kills itself; readers already
48
+ * treat its discovery and markers as stale once the pid is gone.
49
+ *
50
+ * Call this before any native call in a periodic task. Returns true if it fired.
51
+ */
52
+ export function exitIfSourceGone(
53
+ entry: string | undefined = sourceEntryPath(),
54
+ options: { stat?: (file: string) => unknown; kill?: () => void } = {},
55
+ ): boolean {
56
+ if (sourceEntryAvailable(entry, options.stat)) return false;
57
+ (options.kill ?? (() => process.kill(process.pid, "SIGKILL")))();
58
+ return true;
59
+ }
60
+
61
+ /** What a session host needs to recognise that its session is still its own. */
62
+ export interface SessionHostAuthority {
63
+ /** `<stateRoot>/sdk/<sessionId>.lifecycle.json`, written once by the broker when it spawned this host. */
64
+ markerPath: string;
65
+ pid: number;
66
+ effectMarker: string;
67
+ incarnation: string;
68
+ /** The host's worktree. */
69
+ cwd: string;
70
+ }
71
+
72
+ /**
73
+ * `held`: the marker still names this process and the worktree exists. `lost`: the
74
+ * marker or worktree is gone, or the marker names another process — the session was
75
+ * deleted, taken over, or its whole state root was removed (a finished test). `unknown`:
76
+ * something could not be read for another reason; that is not evidence either way.
77
+ */
78
+ export type SessionHostAuthorityState = "held" | "lost" | "unknown";
79
+
80
+ const GONE_CODES = new Set(["ENOENT", "ENOTDIR"]);
81
+
82
+ export function checkSessionHostAuthority(
83
+ authority: SessionHostAuthority,
84
+ io: { readFile?: (file: string) => string; stat?: (file: string) => unknown } = {},
85
+ ): SessionHostAuthorityState {
86
+ const readFile = io.readFile ?? (file => fs.readFileSync(file, "utf8"));
87
+ const stat = io.stat ?? (file => fs.statSync(file));
88
+ const code = (error: unknown) => (error as NodeJS.ErrnoException).code ?? "";
89
+ try {
90
+ stat(authority.cwd);
91
+ } catch (error) {
92
+ return GONE_CODES.has(code(error)) ? "lost" : "unknown";
93
+ }
94
+ let raw: string;
95
+ try {
96
+ raw = readFile(authority.markerPath);
97
+ } catch (error) {
98
+ return GONE_CODES.has(code(error)) ? "lost" : "unknown";
99
+ }
100
+ let marker: { pid?: unknown; effectMarker?: unknown; incarnation?: unknown };
101
+ try {
102
+ marker = JSON.parse(raw) as typeof marker;
103
+ } catch {
104
+ // The broker publishes the marker by atomic rename, so a torn read is not
105
+ // expected; still, one unparsable read alone does not end a live session.
106
+ return "unknown";
107
+ }
108
+ return marker.pid === authority.pid &&
109
+ marker.effectMarker === authority.effectMarker &&
110
+ marker.incarnation === authority.incarnation
111
+ ? "held"
112
+ : "lost";
113
+ }
114
+
115
+ /** Consecutive `lost` checks before a host gives up its session (≈15s at the default interval). */
116
+ export const SESSION_HOST_LOST_CHECKS = 3;
117
+
118
+ /**
119
+ * Watch a running session host. It stops (through `onLost`, the host's normal
120
+ * shutdown) once its ownership marker or worktree has been gone for
121
+ * {@link SESSION_HOST_LOST_CHECKS} checks in a row, and kills itself at once if the
122
+ * source checkout it runs from disappears. Before this, a host whose session state
123
+ * had been deleted kept running until someone killed it — for days, after tests.
124
+ * Returns a function that stops the watch.
125
+ */
126
+ export function startSessionHostGuard(
127
+ options: SessionHostAuthority & {
128
+ onLost: () => void;
129
+ intervalMs?: number;
130
+ lostChecks?: number;
131
+ check?: (authority: SessionHostAuthority) => SessionHostAuthorityState;
132
+ sourceGuard?: () => boolean;
133
+ setInterval?: typeof setInterval;
134
+ clearInterval?: typeof clearInterval;
135
+ },
136
+ ): () => void {
137
+ const testInterval = Number(process.env.SKC_SDK_TEST_HOST_GUARD_MS);
138
+ const intervalMs =
139
+ options.intervalMs ??
140
+ (Number.isFinite(testInterval) && testInterval > 0 ? testInterval : PROCESS_GUARD_INTERVAL_MS);
141
+ const lostChecks = options.lostChecks ?? SESSION_HOST_LOST_CHECKS;
142
+ const check = options.check ?? checkSessionHostAuthority;
143
+ const sourceGuard = options.sourceGuard ?? (() => exitIfSourceGone());
144
+ const set = options.setInterval ?? setInterval;
145
+ const clear = options.clearInterval ?? clearInterval;
146
+ let misses = 0;
147
+ let stopped = false;
148
+ const timer = set(() => {
149
+ if (stopped || sourceGuard()) return;
150
+ misses = check(options) === "lost" ? misses + 1 : 0;
151
+ if (misses < lostChecks) return;
152
+ stopped = true;
153
+ clear(timer);
154
+ options.onLost();
155
+ }, intervalMs);
156
+ return () => {
157
+ stopped = true;
158
+ clear(timer);
159
+ };
160
+ }
@@ -45,11 +45,16 @@ export class BrokerTransport {
45
45
  readonly #requestedPort: number;
46
46
  #server: Bun.Server<undefined> | null = null;
47
47
  #port = 0;
48
+ #openConnections = 0;
48
49
  constructor(broker: Broker, token: string, port = 0) {
49
50
  this.#broker = broker;
50
51
  this.#token = token;
51
52
  this.#requestedPort = port;
52
53
  }
54
+ /** Client WebSockets currently open. The broker does not idle out while any is. */
55
+ get openConnections(): number {
56
+ return this.#openConnections;
57
+ }
53
58
  get port(): number {
54
59
  if (!this.#server) throw new Error("Broker transport is not running");
55
60
  return this.#port;
@@ -71,7 +76,15 @@ export class BrokerTransport {
71
76
  },
72
77
  websocket: {
73
78
  maxPayloadLength: MAX_BROKER_JSON_FRAME_BYTES * 2,
74
- open: socket => send(socket, { type: "broker_hello", protocolVersion: PROTOCOL_VERSION }),
79
+ open: socket => {
80
+ this.#openConnections += 1;
81
+ this.#broker.noteActivity();
82
+ send(socket, { type: "broker_hello", protocolVersion: PROTOCOL_VERSION });
83
+ },
84
+ close: () => {
85
+ this.#openConnections = Math.max(0, this.#openConnections - 1);
86
+ this.#broker.noteActivity();
87
+ },
75
88
  message: (socket, message) => void this.#handleMessage(socket, message),
76
89
  },
77
90
  });
@@ -82,6 +95,7 @@ export class BrokerTransport {
82
95
  const server = this.#server;
83
96
  this.#server = null;
84
97
  if (server) await server.stop(true);
98
+ this.#openConnections = 0;
85
99
  }
86
100
  async #handleMessage(socket: ServerWebSocket<unknown>, raw: string | Buffer): Promise<void> {
87
101
  if (Buffer.byteLength(raw) > MAX_BROKER_JSON_FRAME_BYTES) {
@@ -1,4 +1,5 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import { ensureBroker } from "../broker/ensure";
2
3
  import { type IndexedSession, SessionIndex } from "../broker/session-index";
3
4
  import { SdkClient, SdkClientError } from "../client/client";
4
5
  import { readSdkBrokerDiscovery, readSdkSessionEndpoint, type SdkSessionEndpoint } from "../client/discovery";
@@ -61,6 +62,8 @@ export interface ChatDaemonRuntimeDeps {
61
62
  createClient?: (endpoint: SdkSessionEndpoint) => Promise<ChatDaemonSdkClient>;
62
63
  createIndex?: (agentDir: string) => SessionIndex;
63
64
  createBrokerClient?: (endpoint: { url: string; token: string }) => Promise<ChatDaemonSdkClient>;
65
+ /** Starts the agent broker when none is running (default: `ensureBroker`). */
66
+ ensureBroker?: (settings: { agentDir: string }) => Promise<unknown>;
64
67
  onReconciled?: () => void;
65
68
  setInterval?: typeof setInterval;
66
69
  clearInterval?: typeof clearInterval;
@@ -480,7 +483,16 @@ export class ChatDaemonRuntime {
480
483
  input: Record<string, unknown>,
481
484
  idempotencyKey: string,
482
485
  ): Promise<Record<string, unknown>> {
483
- const discovery = await readSdkBrokerDiscovery(this.input.agentDir);
486
+ let discovery = await readSdkBrokerDiscovery(this.input.agentDir);
487
+ if (!discovery) {
488
+ // A broker with nothing to serve stops on its own; start a fresh one for this command.
489
+ try {
490
+ await (this.deps.ensureBroker ?? ensureBroker)({ agentDir: this.input.agentDir });
491
+ } catch {
492
+ throw new ChatDeliveryError("pre_send");
493
+ }
494
+ discovery = await readSdkBrokerDiscovery(this.input.agentDir);
495
+ }
484
496
  if (!discovery) throw new ChatDeliveryError("pre_send");
485
497
  let client: ChatDaemonSdkClient;
486
498
  try {
@@ -1771,6 +1771,17 @@
1771
1771
  "packages/coding-agent/test/sdk-operation-inventory.test.ts"
1772
1772
  ]
1773
1773
  },
1774
+ {
1775
+ "sourceId": "slash_command:fallback",
1776
+ "sourceFile": "packages/coding-agent/src/slash-commands/builtin-registry.ts",
1777
+ "sourceKind": "slash_command",
1778
+ "decision": "exclude",
1779
+ "rationale": "local fallback-chain configuration (fallback.models / fallback.auto settings); no SDK operation counterpart, and SDK clients observe switches via model_fallback_switched",
1780
+ "exclusionMetadata": {
1781
+ "adapterMappings": "not_applicable",
1782
+ "testIds": "not_applicable"
1783
+ }
1784
+ },
1774
1785
  {
1775
1786
  "sourceId": "slash_command:effort",
1776
1787
  "sourceFile": "packages/coding-agent/src/slash-commands/builtin-registry.ts",
@@ -4419,6 +4430,17 @@
4419
4430
  "packages/coding-agent/test/sdk-operation-inventory.test.ts"
4420
4431
  ]
4421
4432
  },
4433
+ {
4434
+ "sourceId": "agent_session:getDefaultFallbackChain",
4435
+ "sourceFile": "packages/coding-agent/src/session/agent-session.ts",
4436
+ "sourceKind": "agent_session",
4437
+ "decision": "exclude",
4438
+ "rationale": "internal profile and fallback-chain state, not a user-facing SDK control seam",
4439
+ "exclusionMetadata": {
4440
+ "adapterMappings": "not_applicable",
4441
+ "testIds": "not_applicable"
4442
+ }
4443
+ },
4422
4444
  {
4423
4445
  "sourceId": "agent_session:abortRetry",
4424
4446
  "sourceFile": "packages/coding-agent/src/session/agent-session.ts",
@@ -110,7 +110,7 @@ import {
110
110
  } from "../secrets";
111
111
  import { AgentSession, type ForkContextSeed } from "../session/agent-session";
112
112
  import type { AuthStorage } from "../session/auth-storage";
113
- import { discoverAuthStorage } from "../session/auth-storage-discovery";
113
+ import { applyCredentialRankingModeSetting, discoverAuthStorage } from "../session/auth-storage-discovery";
114
114
  import { type CustomMessage, convertToLlm } from "../session/messages";
115
115
  import { createReadonlySessionManager, SessionManager } from "../session/session-manager";
116
116
  import { formatNoModelsAvailableFallback } from "../setup/model-onboarding-guidance";
@@ -1057,6 +1057,9 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1057
1057
  }
1058
1058
  const settings = options.settings ?? (await logger.time("settings", Settings.init, { cwd, agentDir }));
1059
1059
  modelRegistry.applyConfiguredModelBindings(settings);
1060
+ // Multi-account order comes from settings (the env var still overrides it); apply it
1061
+ // before the first model-availability probe picks an account for this session.
1062
+ applyCredentialRankingModeSetting(authStorage, settings);
1060
1063
  logger.time("initializeWithSettings", initializeWithSettings, settings);
1061
1064
  const canRefreshModelsBeforeCredentialSelector =
1062
1065
  !options.credentialSelector || runtimeCredentialSelectorInstalled || options.modelRegistry !== undefined;