@sayknow-cli/coding-agent 0.5.10 → 0.5.11

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.
@@ -148,7 +148,7 @@ export async function runTelegramCommand(cmd: TelegramCommandArgs): Promise<void
148
148
  case "__gateway": {
149
149
  // Hidden entrypoint: run the Telegram Remote gateway in-process. Reached
150
150
  // only via the self-spawn from runStart()/autostart, never by users.
151
- const { loadConfigFromEnv, runService } = await import("../../../telegram-remote/src/index");
151
+ const { loadConfigFromEnv, runService } = await import("@sayknow-cli/telegram-remote");
152
152
  await runService(loadConfigFromEnv(process.env));
153
153
  return;
154
154
  }
@@ -2,11 +2,8 @@ import { ThinkingLevel } from "@sayknow-cli/agent-core";
2
2
  import type { Api, Model } from "@sayknow-cli/ai";
3
3
  import type { AgentSession } from "../session/agent-session";
4
4
  import { formatClampedModelSelector } from "../thinking";
5
- import {
6
- aggregateModelProfileRequiredProviders,
7
- formatAvailableProfileNames,
8
- resolveProfileBindings,
9
- } from "./model-profiles";
5
+ import { UnknownModelProfileError, validateModelProfileName } from "./model-profile-contract";
6
+ import { aggregateModelProfileRequiredProviders, resolveProfileBindings } from "./model-profiles";
10
7
  import {
11
8
  isAuthenticated,
12
9
  kNoAuth,
@@ -49,7 +46,7 @@ export interface PrepareModelProfileActivationOptions {
49
46
  | "resolveCanonicalModel"
50
47
  | "getCanonicalVariants"
51
48
  | "getCanonicalId"
52
- >;
49
+ > & { getError?: ModelRegistry["getError"] };
53
50
  settings: Pick<Settings, "get">;
54
51
  profileName: string;
55
52
  }
@@ -347,12 +344,11 @@ export async function prepareModelProfileActivation(
347
344
  options: PrepareModelProfileActivationOptions,
348
345
  ): Promise<PreparedModelProfileActivation> {
349
346
  const profiles = options.modelRegistry.getModelProfiles();
350
- const profileName = resolveModelProfileName(options.profileName, profiles);
347
+ // Typed contract errors (`unknown_model_profile` / `model_profile_registry_error`)
348
+ // so SDK lifecycle readiness and BrokerResponse preserve the code and details.
349
+ const profileName = validateModelProfileName(options.profileName, profiles, options.modelRegistry.getError?.());
351
350
  const profile = profiles.get(profileName) ?? options.modelRegistry.getModelProfile(profileName);
352
- if (!profile) {
353
- const available = formatAvailableProfileNames(profiles);
354
- throw new Error(`Unknown model profile "${options.profileName}". Available profiles: ${available}`);
355
- }
351
+ if (!profile) throw new UnknownModelProfileError(options.profileName, profiles);
356
352
  const profileLabel = options.profileName;
357
353
 
358
354
  const requiredProviders = aggregateModelProfileRequiredProviders(profile.requiredProviders, profile);
@@ -69,6 +69,36 @@ import { type Settings, settings } from "./settings";
69
69
 
70
70
  export type { CanonicalModelIndex, CanonicalModelRecord, CanonicalModelVariant, ModelEquivalenceConfig };
71
71
 
72
+ /**
73
+ * Strip userinfo and query strings from a discovery URL before it is surfaced
74
+ * in an error message, so credentials embedded in the URL never reach logs or UI.
75
+ */
76
+ function redactDiscoveryUrl(value: string | URL): string {
77
+ try {
78
+ const url = typeof value === "string" ? new URL(value) : value;
79
+ return `${url.origin}${url.pathname}`;
80
+ } catch {
81
+ return "(invalid URL)";
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Fingerprint of the environment variables that can change provider
87
+ * availability without touching AuthStorage. Keys the `getAvailable()` cache
88
+ * so a process.env mutation invalidates it.
89
+ */
90
+ function envAvailabilityFingerprint(): string {
91
+ return Object.entries(process.env)
92
+ .filter(
93
+ ([name]) =>
94
+ /(?:_API_KEY|_OAUTH_TOKEN|_ACCESS_TOKEN)$/.test(name) ||
95
+ /^(?:GH_TOKEN|GITHUB_TOKEN|HF_TOKEN|COPILOT_GITHUB_TOKEN)$/.test(name),
96
+ )
97
+ .sort(([left], [right]) => left.localeCompare(right))
98
+ .map(([name, value]) => `${name}=${value ?? ""}`)
99
+ .join("\u0000");
100
+ }
101
+
72
102
  export const kNoAuth = "N/A";
73
103
 
74
104
  export function isAuthenticated(apiKey: string | undefined | null): apiKey is string {
@@ -544,6 +574,8 @@ export interface ProviderDiscoveryState {
544
574
  export interface CanonicalModelQueryOptions {
545
575
  availableOnly?: boolean;
546
576
  candidates?: readonly Model<Api>[];
577
+ /** Session whose canonical stickiness should scope the lookup, when the caller has one. */
578
+ sessionId?: string;
547
579
  }
548
580
 
549
581
  /** Result of loading custom models from models.json */
@@ -1021,6 +1053,9 @@ export class ModelRegistry {
1021
1053
  #providerWebSearchModes: Map<string, WebSearchMode> = new Map();
1022
1054
  #keylessProviders: Set<string> = new Set();
1023
1055
  #discoverableProviders: DiscoveryProviderConfig[] = [];
1056
+ #availableModelsCache: Model<Api>[] | undefined;
1057
+ #availableModelsDisabledProviders: string | undefined;
1058
+ #availableModelsEnvFingerprint: string | undefined;
1024
1059
  #customModelOverlays: CustomModelOverlay[] = [];
1025
1060
  #providerOverrides: Map<string, ProviderOverride> = new Map();
1026
1061
  #modelOverrides: Map<string, Map<string, ModelOverride>> = new Map();
@@ -1074,6 +1109,8 @@ export class ModelRegistry {
1074
1109
  const keyConfig = this.#customProviderApiKeys.get(provider);
1075
1110
  return keyConfig;
1076
1111
  });
1112
+ // Any credential mutation (runtime/config keys, OAuth refresh) changes availability.
1113
+ this.authStorage.onGenerationChanged(() => this.#invalidateAvailableModels());
1077
1114
  // Load models synchronously in constructor
1078
1115
  this.#loadModels();
1079
1116
  }
@@ -2232,7 +2269,17 @@ export class ModelRegistry {
2232
2269
  headers,
2233
2270
  signal: AbortSignal.timeout(providerConfig.provider === "sglang" ? 500 : 250),
2234
2271
  fetch: (input, init) => fetch(input, { ...init, redirect: "error" }),
2235
- throwOnStatus: response => new Error(`HTTP ${response.status} from ${baseUrl}/models`),
2272
+ throwOnStatus: response => {
2273
+ const modelsUrl = redactDiscoveryUrl(`${baseUrl}/models`);
2274
+ if (response.status === 401 || response.status === 403) {
2275
+ // Redacted by construction: name the provider, endpoint, and the
2276
+ // config surface to fix, never the resolved key.
2277
+ return new Error(
2278
+ `HTTP ${response.status} from ${modelsUrl}: provider "${providerConfig.provider}" credential was rejected for OpenAI models-list discovery; check providers.${providerConfig.provider}.apiKey/apiKeyEnv.`,
2279
+ );
2280
+ }
2281
+ return new Error(`HTTP ${response.status} from ${modelsUrl}`);
2282
+ },
2236
2283
  mapModel: (item, defaults) => ({
2237
2284
  ...defaults,
2238
2285
  reasoning: isOmlx,
@@ -2434,6 +2481,9 @@ export class ModelRegistry {
2434
2481
  }
2435
2482
 
2436
2483
  #rebuildCanonicalIndex(): void {
2484
+ // #models has already changed by the time a rebuild is requested; drop the
2485
+ // availability cache even when the index rebuild itself is deferred.
2486
+ this.#invalidateAvailableModels();
2437
2487
  if (this.#rebuildSuspended > 0) {
2438
2488
  this.#rebuildPending = true;
2439
2489
  return;
@@ -2442,6 +2492,12 @@ export class ModelRegistry {
2442
2492
  this.#rebuildPending = false;
2443
2493
  }
2444
2494
 
2495
+ #invalidateAvailableModels(): void {
2496
+ this.#availableModelsCache = undefined;
2497
+ this.#availableModelsDisabledProviders = undefined;
2498
+ this.#availableModelsEnvFingerprint = undefined;
2499
+ }
2500
+
2445
2501
  #suspendRebuild(): void {
2446
2502
  this.#rebuildSuspended += 1;
2447
2503
  }
@@ -2453,6 +2509,7 @@ export class ModelRegistry {
2453
2509
  if (this.#rebuildSuspended === 0 && this.#rebuildPending) {
2454
2510
  this.#rebuildPending = false;
2455
2511
  this.#canonicalIndex = buildCanonicalModelIndex(this.#models, this.#equivalenceConfig);
2512
+ this.#invalidateAvailableModels();
2456
2513
  }
2457
2514
  }
2458
2515
 
@@ -2503,8 +2560,10 @@ export class ModelRegistry {
2503
2560
  return this.#models;
2504
2561
  }
2505
2562
 
2506
- #isModelAvailable(model: Model<Api>): boolean {
2507
- const disabledProviders = getDisabledProviderIdsFromSettings();
2563
+ #isModelAvailable(
2564
+ model: Model<Api>,
2565
+ disabledProviders: ReadonlySet<string> = getDisabledProviderIdsFromSettings(),
2566
+ ): boolean {
2508
2567
  return (
2509
2568
  !disabledProviders.has(model.provider) &&
2510
2569
  (this.#keylessProviders.has(model.provider) || this.authStorage.hasAuth(model.provider))
@@ -2643,7 +2702,20 @@ export class ModelRegistry {
2643
2702
  * This is a fast check that doesn't refresh OAuth tokens.
2644
2703
  */
2645
2704
  getAvailable(): Model<Api>[] {
2646
- return this.#models.filter(model => this.#isModelAvailable(model));
2705
+ const disabledProviders = getDisabledProviderIdsFromSettings();
2706
+ const disabledProviderKey = [...disabledProviders].sort().join("\u0000");
2707
+ const envFingerprint = envAvailabilityFingerprint();
2708
+ if (
2709
+ this.#availableModelsCache &&
2710
+ this.#availableModelsDisabledProviders === disabledProviderKey &&
2711
+ this.#availableModelsEnvFingerprint === envFingerprint
2712
+ ) {
2713
+ return this.#availableModelsCache;
2714
+ }
2715
+ this.#availableModelsCache = this.#models.filter(model => this.#isModelAvailable(model, disabledProviders));
2716
+ this.#availableModelsDisabledProviders = disabledProviderKey;
2717
+ this.#availableModelsEnvFingerprint = envFingerprint;
2718
+ return this.#availableModelsCache;
2647
2719
  }
2648
2720
 
2649
2721
  /**
@@ -355,7 +355,7 @@ function findExactCanonicalModelMatch(
355
355
  modelReference: string,
356
356
  availableModels: Model<Api>[],
357
357
  modelRegistry: CanonicalModelRegistry | undefined,
358
- _sessionId?: string,
358
+ sessionId?: string,
359
359
  ): Model<Api> | undefined {
360
360
  if (!modelRegistry) {
361
361
  return undefined;
@@ -367,6 +367,7 @@ function findExactCanonicalModelMatch(
367
367
  return modelRegistry.resolveCanonicalModel?.(trimmedReference, {
368
368
  availableOnly: false,
369
369
  candidates: availableModels,
370
+ sessionId,
370
371
  });
371
372
  }
372
373
 
@@ -378,7 +379,7 @@ function findExactEquivalentModelMatch(
378
379
  modelReference: string,
379
380
  availableModels: Model<Api>[],
380
381
  modelRegistry: CanonicalModelRegistry | undefined,
381
- _sessionId?: string,
382
+ sessionId?: string,
382
383
  ): Model<Api> | undefined {
383
384
  if (!modelRegistry?.getCanonicalId || !modelRegistry.resolveCanonicalModel) return undefined;
384
385
  const trimmedReference = modelReference.trim();
@@ -394,6 +395,7 @@ function findExactEquivalentModelMatch(
394
395
  return modelRegistry.resolveCanonicalModel([...canonicalIds][0]!, {
395
396
  availableOnly: false,
396
397
  candidates: availableModels,
398
+ sessionId,
397
399
  });
398
400
  }
399
401
 
@@ -635,6 +635,18 @@ A transition occurs whenever the band changes versus the prior scored round —
635
635
 
636
636
  **Bookkeeping:** record each convened panel in `state.lateral_reviews` (round, milestone transition or pre-answer trigger, personas dispatched, findings folded). On panel spawn or validation failure, fall back silently to the normal generated question and increment `lateral_panel_failures`; do not expose tool noise unless it changes the next user-facing question. The panel is a prompt-budgeted assist layer — summarize oversized context before dispatch.
637
637
 
638
+ ### Per-question advisory fanout lanes (distinct from the milestone panel)
639
+
640
+ Separate from the milestone-triggered lateral panel above, a lightweight **advisory fanout** may assist any single question the main session is about to synthesize or route — especially when the user is terse, uncertain, or would benefit from selectable options instead of another open-ended prompt. Adopted from ouroboros's ooo interview, the standard lanes are:
641
+
642
+ - `code_context` — inspect repo-local facts and reuse existing exploration before asking the user.
643
+ - `web_context` — browse/search only when current external facts genuinely affect the answer.
644
+ - `ambiguity_contrarian` — find hidden assumptions, vague terms, missing decisions, and risky defaults.
645
+ - `answer_simplifier` — turn the question into 2-3 easy choices or one concise draft answer.
646
+ - `architecture_implications` — check whether the answer changes ownership, interfaces, rollout, or system shape.
647
+
648
+ Advisory fanout is an assist layer, not a decision maker: it never replaces or delays the single user-facing question, never adds a second question, and never forwards a synthesized answer without the user's approval, edit, or explicit auto-confirm request. It differs from the milestone panel in trigger (per-question, not band-transition) and intent (help the human answer this one question). When both would fire on the same round, run the milestone panel and fold advisory lanes into the same single question. Runtimes without a parallel subagent primitive process lanes sequentially; on lane failure, fall back silently to the normal generated question.
649
+
638
650
  ## Phase 4: Crystallize Spec
639
651
 
640
652
  When ambiguity ≤ threshold (or hard cap / early exit):
@@ -204,6 +204,10 @@ const TIME_FORMAT_OPTIONS: SelectItem[] = [
204
204
  { value: "24h", label: "24h" },
205
205
  { value: "12h", label: "12h" },
206
206
  ];
207
+ const USAGE_MODE_OPTIONS: SelectItem[] = [
208
+ { value: "used", label: "Used" },
209
+ { value: "remaining", label: "Remaining" },
210
+ ];
207
211
  const USAGE_MODE_VALUES = ["used", "remaining"] as const;
208
212
  type UsageMode = (typeof USAGE_MODE_VALUES)[number];
209
213
 
@@ -220,6 +224,7 @@ function mergeSegmentOptions(
220
224
  path: base?.path || overrides?.path ? { ...(base?.path ?? {}), ...(overrides?.path ?? {}) } : undefined,
221
225
  git: base?.git || overrides?.git ? { ...(base?.git ?? {}), ...(overrides?.git ?? {}) } : undefined,
222
226
  time: base?.time || overrides?.time ? { ...(base?.time ?? {}), ...(overrides?.time ?? {}) } : undefined,
227
+ usage: base?.usage || overrides?.usage ? { ...(base?.usage ?? {}), ...(overrides?.usage ?? {}) } : undefined,
223
228
  };
224
229
  }
225
230
 
@@ -279,8 +284,6 @@ function segmentPlacement(
279
284
  class StatusLineCustomEditor extends Container {
280
285
  #list!: SettingsList;
281
286
  #draft: StatusLineDraft;
282
- #currentWidthPreviewText!: Text;
283
- #narrowWidthPreviewText!: Text;
284
287
  #previewHighlightSegment: StatusLineSegmentId | undefined;
285
288
 
286
289
  constructor(
@@ -312,13 +315,6 @@ class StatusLineCustomEditor extends Container {
312
315
  this.clear();
313
316
  this.addChild(new Text(theme.bold(theme.fg("accent", "Status Line Custom Editor")), 0, 0));
314
317
  this.addChild(new Spacer(1));
315
- this.addChild(new Text(theme.fg("muted", "Current width preview:"), 0, 0));
316
- this.#currentWidthPreviewText = new Text(this.#statusLinePreview(), 0, 0);
317
- this.addChild(this.#currentWidthPreviewText);
318
- this.addChild(new Text(theme.fg("muted", "Narrow width preview:"), 0, 0));
319
- this.#narrowWidthPreviewText = new Text(this.#statusLinePreview(40), 0, 0);
320
- this.addChild(this.#narrowWidthPreviewText);
321
- this.addChild(new Spacer(1));
322
318
  this.#list = new SettingsList(
323
319
  this.#items(),
324
320
  14,
@@ -326,27 +322,17 @@ class StatusLineCustomEditor extends Container {
326
322
  (id, value) => this.#handleChange(id, value),
327
323
  () => this.#cancel(),
328
324
  item => this.#setSelectedItem(item),
325
+ 2,
329
326
  );
330
327
  this.addChild(this.#list);
331
328
  }
332
329
 
333
330
  #refresh(): void {
334
- this.#refreshPreview();
335
331
  this.#list.setItems(this.#items());
336
332
  }
337
-
338
- #refreshPreview(): void {
339
- this.#currentWidthPreviewText.setText(this.#statusLinePreview());
340
- this.#narrowWidthPreviewText.setText(this.#statusLinePreview(40));
341
- }
342
-
343
- #statusLinePreview(width?: number): string {
344
- return this.callbacks.getStatusLinePreview?.(width) ?? theme.fg("dim", "(preview not available)");
345
- }
346
333
  #setSelectedItem(item: SettingItem | undefined): void {
347
334
  this.#previewHighlightSegment = this.#highlightSegmentForItem(item);
348
335
  this.#preview();
349
- this.#refreshPreview();
350
336
  }
351
337
 
352
338
  #highlightSegmentForItem(item: SettingItem | undefined): StatusLineSegmentId | undefined {
@@ -432,6 +418,22 @@ class StatusLineCustomEditor extends Container {
432
418
  },
433
419
  );
434
420
  }
421
+ if (id === "usage") {
422
+ items.push({
423
+ id: "option.usage.mode",
424
+ label: "Usage: mode",
425
+ currentValue: this.#draft.segmentOptions.usage?.mode ?? "used",
426
+ submenu: (currentValue, done) =>
427
+ new SelectSubmenu(
428
+ "Usage mode",
429
+ "Show used quota or remaining quota in the usage segment.",
430
+ USAGE_MODE_OPTIONS,
431
+ currentValue,
432
+ done,
433
+ () => done(),
434
+ ),
435
+ });
436
+ }
435
437
  }
436
438
 
437
439
  items.push(
@@ -596,6 +598,12 @@ class StatusLineCustomEditor extends Container {
596
598
  case "time.showSeconds":
597
599
  this.#draft.segmentOptions.time = { ...(this.#draft.segmentOptions.time ?? {}), showSeconds: bool };
598
600
  break;
601
+ case "usage.mode":
602
+ this.#draft.segmentOptions.usage = {
603
+ ...(this.#draft.segmentOptions.usage ?? {}),
604
+ mode: value === "remaining" ? "remaining" : "used",
605
+ };
606
+ break;
599
607
  }
600
608
  }
601
609
 
@@ -606,6 +614,8 @@ class StatusLineCustomEditor extends Container {
606
614
  rightSegments: [...this.#draft.rightSegments],
607
615
  separator: this.#draft.separator,
608
616
  segmentOptions: cloneSegmentOptions(this.#draft.segmentOptions),
617
+ sessionAccent: settings.get("statusLine.sessionAccent"),
618
+ maxRows: settings.get("statusLine.maxRows"),
609
619
  previewHighlightSegment: this.#previewHighlightSegment,
610
620
  });
611
621
  }
@@ -618,6 +628,7 @@ class StatusLineCustomEditor extends Container {
618
628
  separator: settings.get("statusLine.separator"),
619
629
  segmentOptions: cloneSegmentOptions(settings.get("statusLine.segmentOptions") as StatusLineSegmentOptions),
620
630
  sessionAccent: settings.get("statusLine.sessionAccent"),
631
+ maxRows: settings.get("statusLine.maxRows"),
621
632
  previewHighlightSegment: undefined,
622
633
  });
623
634
  }
@@ -1197,13 +1208,20 @@ export class SettingsSelectorComponent extends Container {
1197
1208
  #buildItemsForTab(defs: SettingDef[], tabId: SettingTab): SettingItem[] {
1198
1209
  const items = this.#buildItemsForDefs(defs);
1199
1210
  if (tabId === "appearance") {
1211
+ const customEditorCallbacks: SettingsCallbacks = {
1212
+ ...this.callbacks,
1213
+ onStatusLinePreview: previewSettings => {
1214
+ this.callbacks.onStatusLinePreview?.(previewSettings);
1215
+ this.#updateStatusPreview();
1216
+ },
1217
+ };
1200
1218
  const customEditorItem: SettingItem = {
1201
1219
  id: STATUS_LINE_CUSTOM_EDITOR_ID,
1202
1220
  label: "Status Line Custom Editor",
1203
1221
  description:
1204
1222
  "Edit custom status line segments, placement, separator, and typed segment options with live previews.",
1205
1223
  currentValue: "open",
1206
- submenu: (_currentValue, done) => new StatusLineCustomEditor(this.callbacks, done),
1224
+ submenu: (_currentValue, done) => new StatusLineCustomEditor(customEditorCallbacks, done),
1207
1225
  };
1208
1226
  const presetIndex = items.findIndex(item => item.id === "statusLine.preset");
1209
1227
  if (presetIndex >= 0) {
@@ -5,7 +5,7 @@ import type { OAuthProvider } from "@sayknow-cli/ai/utils/oauth/types";
5
5
  import type { Component, OverlayHandle, SlashCommand } from "@sayknow-cli/tui";
6
6
  import { Input, isPetMode, Loader, Spacer, Text } from "@sayknow-cli/tui";
7
7
  import { getAgentDbPath, getProjectDir, logger, VERSION } from "@sayknow-cli/utils";
8
- import { type AppKeybinding, formatKeyHints } from "../../config/keybindings";
8
+ import type { AppKeybinding } from "../../config/keybindings";
9
9
  import {
10
10
  activateModelProfile,
11
11
  materializeActiveModelProfileAssignment,
@@ -27,6 +27,7 @@ import {
27
27
  getPluginsCacheDir,
28
28
  MarketplaceManager,
29
29
  } from "../../extensibility/plugins/marketplace";
30
+ import { INTERACTIVE_SELECTOR_RESUME_ORIGIN } from "../../extensibility/shared-events";
30
31
  import {
31
32
  getAvailableThemes,
32
33
  getCurrentThemeName,
@@ -832,7 +833,7 @@ export class SelectorController {
832
833
  id: `action:${action.id}`,
833
834
  label: action.label,
834
835
  description: action.id,
835
- keybinding: formatKeyHints(this.ctx.keybindings.getKeys(action.id as AppKeybinding)) || undefined,
836
+ keybinding: this.ctx.keybindings.getDisplayString(action.id as AppKeybinding) || undefined,
836
837
  searchText: action.id,
837
838
  handler: action.handler,
838
839
  })),
@@ -1042,13 +1043,18 @@ export class SelectorController {
1042
1043
  const selector = new ThinkingSelectorComponent(
1043
1044
  this.ctx.session.thinkingLevel,
1044
1045
  availableLevels,
1045
- selection => {
1046
- done();
1047
-
1046
+ async selection => {
1048
1047
  const { level, persistDefault } = selection;
1049
1048
  const configuredDefault = this.ctx.settings.get("defaultThinkingLevel");
1050
1049
  const levelToApply = level === ThinkingLevel.Inherit ? configuredDefault : level;
1051
- this.ctx.session.setThinkingLevel(levelToApply, persistDefault);
1050
+ try {
1051
+ await this.ctx.session.setThinkingLevelForControl(level, persistDefault);
1052
+ } catch (error) {
1053
+ this.ctx.showError(error instanceof Error ? error.message : String(error));
1054
+ return;
1055
+ }
1056
+ done();
1057
+
1052
1058
  const effectiveLevel = this.ctx.session.thinkingLevel ?? ThinkingLevel.Off;
1053
1059
  const requestedLabel =
1054
1060
  level === ThinkingLevel.Inherit ? `${level} (configured default: ${configuredDefault})` : level;
@@ -1959,10 +1965,7 @@ export class SelectorController {
1959
1965
  }
1960
1966
 
1961
1967
  async showSessionSelector(): Promise<void> {
1962
- const sessions = await SessionManager.listForResumePickerReadOnly(
1963
- this.ctx.sessionManager.getCwd(),
1964
- this.ctx.sessionManager.getSessionDir(),
1965
- );
1968
+ const sessions = await this.ctx.sessionManager.listForResumePickerReadOnly();
1966
1969
  this.showSelector(done => {
1967
1970
  const selector = new SessionSelectorComponent(
1968
1971
  sessions,
@@ -2064,13 +2067,30 @@ export class SelectorController {
2064
2067
  const previousSessionId = this.ctx.sessionManager.getSessionId();
2065
2068
  const migrationPolicy =
2066
2069
  this.ctx.settings?.get("session.directoryMigration") === "disabled" ? "disabled" : "copy-retain";
2067
- const writableSessionPath = await this.ctx.sessionManager.prepareManagedCandidateForWrite(
2068
- sessionPath,
2069
- migrationPolicy,
2070
- );
2070
+ let writableSessionPath = sessionPath;
2071
+ if (this.ctx.sessionManager.isManagedDestination()) {
2072
+ // Managed candidates are prepared against their inspected identity so a
2073
+ // replacement race fails closed before adoption. Explicit `--session-dir`
2074
+ // selections never enter the managed migration fence (preparation would
2075
+ // reject a non-managed destination authority outright).
2076
+ const inspection = await SessionManager.inspectSessionTailReadOnly(sessionPath);
2077
+ if (inspection.kind === "error")
2078
+ throw new Error(`Could not inspect selected session: ${inspection.reason}`);
2079
+ writableSessionPath = await this.ctx.sessionManager.prepareManagedCandidateForStrictAdoption(
2080
+ sessionPath,
2081
+ migrationPolicy,
2082
+ inspection.identity,
2083
+ );
2084
+ }
2071
2085
 
2072
2086
  // Switch session via AgentSession (emits hook and tool session events)
2073
- if (!(await this.ctx.session.switchSession(writableSessionPath, options))) return false;
2087
+ if (
2088
+ !(await this.ctx.session.switchSession(writableSessionPath, {
2089
+ ...options,
2090
+ transition: { origin: INTERACTIVE_SELECTOR_RESUME_ORIGIN },
2091
+ }))
2092
+ )
2093
+ return false;
2074
2094
  onSwitched?.();
2075
2095
  this.#clearTransientSessionUi();
2076
2096
  const switchingToDifferentSession = previousSessionId !== this.ctx.sessionManager.getSessionId();
@@ -288,11 +288,27 @@ class StdioRpcTransport implements RpcTransport {
288
288
  } else {
289
289
  onClose(new Error("RPC stdio transport closed"));
290
290
  }
291
- })().catch((err: Error) => {
292
- if (!readySettled) {
293
- readySettled = true;
291
+ })().catch(async (err: Error) => {
292
+ if (readySettled) {
293
+ onClose(err);
294
+ return;
295
+ }
296
+ // Non-JSONL stdout before `ready` usually means the CLI refused to start
297
+ // (for example a usage error such as the removed `--mode rpc`). When the
298
+ // child is already gone, report its exit code and stderr instead of the
299
+ // parse noise so callers see the actual refusal.
300
+ const proc = this.#process;
301
+ const exitCode = proc
302
+ ? await Promise.race([proc.exited.catch(() => proc.exitCode ?? -1), Bun.sleep(1_000).then(() => undefined)])
303
+ : undefined;
304
+ if (readySettled) return;
305
+ readySettled = true;
306
+ if (exitCode === undefined) {
294
307
  readyReject(err);
295
- } else onClose(err);
308
+ return;
309
+ }
310
+ const stderr = await getStartupStderr();
311
+ readyReject(new Error(`Agent process exited with code ${exitCode} before ready. Stderr: ${stderr}`));
296
312
  });
297
313
  void this.#process.exited.then(async (exitCode: number) => {
298
314
  if (!readySettled) {
@@ -3217,9 +3217,7 @@ export function createNotificationsExtension(
3217
3217
  let serverStopped = rt.serverStopped;
3218
3218
  if (!serverStopped) {
3219
3219
  try {
3220
- // fork's NotificationServer.stop() is synchronous; stopAndWait is upstream v0.11.x's
3221
- // async variant. TODO(port): add stop_and_wait to pi-natives/src/notifications.rs.
3222
- rt.server.stop();
3220
+ await rt.server.stopAndWait();
3223
3221
  serverStopped = true;
3224
3222
  rt.serverStopped = true;
3225
3223
  } catch (e) {