@sayknow-cli/coding-agent 0.6.6 → 0.6.8

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 +29 -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 +44 -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 +241 -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 +123 -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 +203 -0
@@ -8,7 +8,9 @@ import {
8
8
  Container,
9
9
  clearRenderCache,
10
10
  getRenderCacheRetainedBytes,
11
+ isKeyRelease,
11
12
  Loader,
13
+ matchesKey,
12
14
  onImageProtocolChanged,
13
15
  ProcessTerminal,
14
16
  Spacer,
@@ -224,8 +226,27 @@ export function resolveWelcomePetSkin(petMode: PetMode, themeName: string | unde
224
226
  * rail glyph a submitted prompt keeps in the transcript, colored by the editor's
225
227
  * border color (session accent, thinking level, shell/python mode).
226
228
  */
229
+ /** Background band under the composer's input rows, so the line being typed stands out. */
230
+ function composerInputBackground(): string | undefined {
231
+ try {
232
+ return theme.getBgAnsi("userMessageBg");
233
+ } catch {
234
+ return undefined;
235
+ }
236
+ }
237
+
238
+ /** Canonical form of a session path, so /private/var and /var compare equal on macOS. */
239
+ function canonicalPath(file: string): string {
240
+ try {
241
+ return fs.realpathSync(file);
242
+ } catch {
243
+ return path.resolve(file);
244
+ }
245
+ }
246
+
227
247
  export function configureDefaultComposerChrome(editor: CustomEditor): void {
228
248
  editor.setBorderVisible(false);
249
+ editor.setInputBackground(composerInputBackground());
229
250
  editor.setClosedBorderBox(false);
230
251
  editor.setPromptGutter(undefined);
231
252
  editor.setRailGutter(theme.rail.user);
@@ -472,6 +493,9 @@ export class InteractiveMode implements InteractiveModeContext {
472
493
  #eventBus?: EventBus;
473
494
  #eventBusUnsubscribers: Array<() => void> = [];
474
495
  #welcomeComponent?: WelcomeComponent;
496
+ #welcomeInputUnsubscribe?: () => void;
497
+ /** Mouse reporting was switched on for the launch card and must be restored after it. */
498
+ #welcomeEnabledMouse = false;
475
499
  #ircSplitView: IrcSplitViewComponent;
476
500
  #ircSidebarAvailable = false;
477
501
  #ircSidebarRequestedVisible = false;
@@ -764,11 +788,13 @@ export class InteractiveMode implements InteractiveModeContext {
764
788
  resumeKey: this.keybindings.getKeys("app.session.resume")[0],
765
789
  continueKey: this.keybindings.getKeys("app.session.continue")[0],
766
790
  petSkin: resolveWelcomePetSkin(settings.get("pet.mode"), getCurrentThemeName()),
791
+ onOpenSession: session => void this.#openWelcomeSession(session),
767
792
  },
768
793
  );
769
794
 
770
795
  this.ui.addChild(this.#welcomeComponent);
771
796
  this.#welcomeComponent.playIntro(() => this.ui.requestRender());
797
+ this.#startWelcomeInteraction();
772
798
  }
773
799
 
774
800
  this.ui.addChild(this.#ircSplitView);
@@ -880,11 +906,15 @@ export class InteractiveMode implements InteractiveModeContext {
880
906
  )
881
907
  .then(sessions => {
882
908
  if (this.#welcomeComponent !== welcomeComponent) return;
909
+ const current = this.#currentSessionFileCanonical();
883
910
  welcomeComponent.setRecentSessions(
884
- sessions.map(session => ({
885
- name: session.name,
886
- timeAgo: session.timeAgo,
887
- })),
911
+ sessions
912
+ .filter(session => canonicalPath(session.path) !== current)
913
+ .map(session => ({
914
+ name: session.name,
915
+ timeAgo: session.timeAgo,
916
+ path: session.path,
917
+ })),
888
918
  );
889
919
  this.ui.requestRender();
890
920
  })
@@ -1231,6 +1261,7 @@ export class InteractiveMode implements InteractiveModeContext {
1231
1261
  }
1232
1262
  // Re-read the glyph so a symbol-preset switch (unicode/nerd/ascii) reaches the rail.
1233
1263
  this.editor.setRailGutter(theme.rail.user);
1264
+ this.editor.setInputBackground(composerInputBackground());
1234
1265
  this.editor.setPlaceholder(this.#getComposerPlaceholder());
1235
1266
  this.#setComposerTopBorder();
1236
1267
  this.ui.requestRender();
@@ -1433,6 +1464,7 @@ export class InteractiveMode implements InteractiveModeContext {
1433
1464
  this.loadingAnimation.stop();
1434
1465
  this.loadingAnimation = undefined;
1435
1466
  }
1467
+ this.#endWelcomeInteraction();
1436
1468
  this.#welcomeComponent?.dispose();
1437
1469
  this.#welcomeComponent = undefined;
1438
1470
  if (this.#sttController) {
@@ -2183,20 +2215,97 @@ export class InteractiveMode implements InteractiveModeContext {
2183
2215
  this.#selectorController.showSessionSelector();
2184
2216
  }
2185
2217
 
2218
+ #currentSessionFileCanonical(): string | undefined {
2219
+ const currentFile = this.sessionManager.getSessionFile();
2220
+ return currentFile ? canonicalPath(currentFile) : undefined;
2221
+ }
2222
+
2223
+ /**
2224
+ * The launch card's session rows take ↓/↑/Enter/Esc and clicks until the first prompt
2225
+ * is sent. Mouse reporting is switched on for that window (unless the user already
2226
+ * runs with it) so a click reaches the card; it goes back to the configured state when
2227
+ * the card stops taking input.
2228
+ */
2229
+ #startWelcomeInteraction(): void {
2230
+ this.#releaseWelcomeInput();
2231
+ const welcome = this.#welcomeComponent;
2232
+ if (!welcome) return;
2233
+ this.#welcomeInputUnsubscribe = this.ui.addInputListener(data => this.#handleWelcomeKey(welcome, data));
2234
+ if (settings.get("startup.welcomeMouse") && !this.ui.mouseEnabled) {
2235
+ this.ui.setMouseEnabled(true);
2236
+ this.#welcomeEnabledMouse = true;
2237
+ }
2238
+ }
2239
+
2240
+ /** The card stops taking input: first prompt sent, a session opened, or the UI rebuilt. */
2241
+ endWelcomeInteraction(): void {
2242
+ this.#endWelcomeInteraction();
2243
+ }
2244
+
2245
+ /** Drop the card's key listener and give mouse reporting back to its configured state. */
2246
+ #releaseWelcomeInput(): void {
2247
+ this.#welcomeInputUnsubscribe?.();
2248
+ this.#welcomeInputUnsubscribe = undefined;
2249
+ if (this.#welcomeEnabledMouse) {
2250
+ this.#welcomeEnabledMouse = false;
2251
+ this.ui.setMouseEnabled(settings.get("mouse.enabled"));
2252
+ }
2253
+ }
2254
+
2255
+ #endWelcomeInteraction(): void {
2256
+ this.#releaseWelcomeInput();
2257
+ if (this.#welcomeComponent?.interactive) {
2258
+ this.#welcomeComponent.endInteraction();
2259
+ this.ui.requestRender();
2260
+ }
2261
+ }
2262
+
2263
+ /**
2264
+ * ↓ from an empty composer (not walking history) moves into the session rows; there,
2265
+ * ↑/↓ move, Enter opens, Esc (or ↑ past the first row) hands focus back. Any other key
2266
+ * leaves the rows and reaches the composer as usual.
2267
+ */
2268
+ #handleWelcomeKey(welcome: WelcomeComponent, data: string): { consume: true } | undefined {
2269
+ if (this.#welcomeComponent !== welcome || !welcome.interactive) return undefined;
2270
+ if (this.ui.getFocusedComponent() !== this.editor) return undefined;
2271
+ // Terminals with the kitty keyboard protocol (Ghostty, kitty, WezTerm) send a release
2272
+ // event after every press. It is not a keystroke: treating it as "another key" cancelled
2273
+ // the pick a ↓ had just made, so a single tap never moved the highlight.
2274
+ if (isKeyRelease(data)) return undefined;
2275
+ const picking = welcome.selectedIndex !== undefined;
2276
+ if (!picking) {
2277
+ if (!matchesKey(data, "down")) return undefined;
2278
+ if (this.editor.getText() !== "" || this.editor.isBrowsingHistory() || this.editor.isShowingAutocomplete())
2279
+ return undefined;
2280
+ if (!welcome.select(0)) return undefined;
2281
+ this.ui.requestRender();
2282
+ return { consume: true };
2283
+ }
2284
+ if (matchesKey(data, "down")) welcome.moveSelection(1);
2285
+ else if (matchesKey(data, "up")) welcome.moveSelection(-1);
2286
+ else if (matchesKey(data, "enter")) welcome.openSelected();
2287
+ else if (matchesKey(data, "escape")) welcome.select(undefined);
2288
+ else {
2289
+ welcome.select(undefined);
2290
+ this.ui.requestRender();
2291
+ return undefined;
2292
+ }
2293
+ this.ui.requestRender();
2294
+ return { consume: true };
2295
+ }
2296
+
2297
+ async #openWelcomeSession(session: RecentSession): Promise<void> {
2298
+ if (!session.path) return;
2299
+ this.#endWelcomeInteraction();
2300
+ await this.handleResumeSession(session.path, { requireIdle: true });
2301
+ }
2302
+
2186
2303
  async continueRecentSession(): Promise<void> {
2187
2304
  // Listing returns canonical paths (/private/var on macOS); compare canonically so the
2188
2305
  // 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;
2306
+ const current = this.#currentSessionFileCanonical();
2198
2307
  const recent = await getRecentSessions(this.sessionManager.getSessionDir());
2199
- const target = recent.find(session => canonical(session.path) !== current);
2308
+ const target = recent.find(session => canonicalPath(session.path) !== current);
2200
2309
  if (!target) {
2201
2310
  this.showStatus("No earlier session to continue");
2202
2311
  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;