@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.
- package/CHANGELOG.md +29 -0
- package/dist/types/config/settings-schema.d.ts +79 -0
- package/dist/types/i18n/messages/en.d.ts +2 -0
- package/dist/types/modes/components/welcome.d.ts +21 -4
- package/dist/types/modes/interactive-mode.d.ts +2 -5
- package/dist/types/modes/types.d.ts +2 -0
- package/dist/types/sdk/broker/broker.d.ts +22 -0
- package/dist/types/sdk/broker/process-guard.d.ts +71 -0
- package/dist/types/sdk/broker/transport.d.ts +2 -0
- package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +4 -0
- package/dist/types/session/agent-session.d.ts +9 -0
- package/dist/types/session/auth-storage-discovery.d.ts +15 -0
- package/dist/types/session/auto-fallback.d.ts +27 -0
- package/dist/types/session/fallback-chain-controller.d.ts +5 -0
- package/dist/types/session/response-language.d.ts +25 -0
- package/dist/types/setup/model-onboarding-guidance.d.ts +8 -1
- package/dist/types/setup/provider-onboarding.d.ts +2 -0
- package/dist/types/tools/index.d.ts +1 -0
- package/dist/types/tools/locate-core.d.ts +97 -0
- package/dist/types/tools/locate.d.ts +44 -0
- package/package.json +7 -7
- package/scripts/generate-sdk-operation-inventory.ts +4 -0
- package/src/cli/setup-cli.ts +7 -4
- package/src/commands/sdk.ts +3 -0
- package/src/commands/setup.ts +4 -1
- package/src/config/settings-schema.ts +82 -0
- package/src/decisions/typesafe-backend.ts +38 -4
- package/src/i18n/messages/de.settings.ts +26 -0
- package/src/i18n/messages/de.ts +2 -0
- package/src/i18n/messages/en.ts +2 -0
- package/src/i18n/messages/es.settings.ts +26 -0
- package/src/i18n/messages/es.ts +2 -0
- package/src/i18n/messages/fr.settings.ts +26 -0
- package/src/i18n/messages/fr.ts +2 -0
- package/src/i18n/messages/ja.settings.ts +26 -0
- package/src/i18n/messages/ja.ts +2 -0
- package/src/i18n/messages/ko.settings.ts +25 -0
- package/src/i18n/messages/ko.ts +2 -0
- package/src/i18n/messages/zh.settings.ts +23 -0
- package/src/i18n/messages/zh.ts +2 -0
- package/src/internal-urls/docs-index.generated.ts +7 -6
- package/src/modes/components/welcome.ts +241 -68
- package/src/modes/controllers/input-controller.ts +2 -0
- package/src/modes/controllers/selector-controller.ts +13 -0
- package/src/modes/interactive-mode.ts +123 -14
- package/src/modes/types.ts +2 -0
- package/src/prompts/system/system-prompt.md +7 -2
- package/src/prompts/tools/locate.md +12 -0
- package/src/sdk/broker/broker.ts +75 -1
- package/src/sdk/broker/process-guard.ts +160 -0
- package/src/sdk/broker/transport.ts +15 -1
- package/src/sdk/bus/chat-daemon-runtime.ts +13 -1
- package/src/sdk/protocol/operation-inventory.generated.json +22 -0
- package/src/sdk/session.ts +4 -1
- package/src/session/agent-session.ts +118 -2
- package/src/session/auth-storage-discovery.ts +22 -7
- package/src/session/auto-fallback.ts +59 -0
- package/src/session/fallback-chain-controller.ts +5 -0
- package/src/session/response-language.ts +71 -0
- package/src/setup/model-onboarding-guidance.ts +29 -14
- package/src/setup/provider-onboarding.ts +5 -0
- package/src/slash-commands/builtin-registry.ts +112 -2
- package/src/tools/index.ts +3 -0
- package/src/tools/locate-core.ts +720 -0
- 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
|
|
885
|
-
|
|
886
|
-
|
|
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
|
|
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 =>
|
|
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;
|
package/src/modes/types.ts
CHANGED
|
@@ -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
|
-
-
|
|
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>
|
package/src/sdk/broker/broker.ts
CHANGED
|
@@ -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.#
|
|
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 =>
|
|
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
|
-
|
|
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",
|
package/src/sdk/session.ts
CHANGED
|
@@ -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;
|