@gajae-code/coding-agent 0.18.11 → 0.18.12

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 (270) hide show
  1. package/CHANGELOG.md +293 -0
  2. package/dist/types/async/job-manager.d.ts +7 -3
  3. package/dist/types/async/result-formatting.d.ts +2 -0
  4. package/dist/types/autoresearch/git.d.ts +1 -1
  5. package/dist/types/capability/fs.d.ts +2 -2
  6. package/dist/types/cli/accounts-cli.d.ts +2 -2
  7. package/dist/types/cli/doctor/install-repairs.d.ts +4 -1
  8. package/dist/types/cli/install-activation.d.ts +10 -4
  9. package/dist/types/cli/public-command-errors.d.ts +1 -0
  10. package/dist/types/cli/update-cli.d.ts +14 -1
  11. package/dist/types/cli-ordinary.d.ts +2 -2
  12. package/dist/types/commands/accounts.d.ts +2 -1
  13. package/dist/types/commands/migrate.d.ts +1 -1
  14. package/dist/types/commands/sdk.d.ts +5 -0
  15. package/dist/types/commit/changelog/index.d.ts +1 -0
  16. package/dist/types/config/autorouting-tier-map.d.ts +30 -0
  17. package/dist/types/config/config-hot-reload-trust.d.ts +12 -0
  18. package/dist/types/config/config-hot-reload.d.ts +39 -0
  19. package/dist/types/config/file-lock.d.ts +8 -1
  20. package/dist/types/config/model-discovery-manager.d.ts +2 -0
  21. package/dist/types/config/model-profile-activation.d.ts +62 -6
  22. package/dist/types/config/model-registry.d.ts +56 -5
  23. package/dist/types/config/model-resolver.d.ts +2 -0
  24. package/dist/types/config/models-config-schema.d.ts +10 -0
  25. package/dist/types/config/settings-schema.d.ts +46 -3
  26. package/dist/types/config/settings.d.ts +33 -1
  27. package/dist/types/dap/config.d.ts +8 -4
  28. package/dist/types/debug/system-info.d.ts +6 -1
  29. package/dist/types/discovery/mcp-compat.d.ts +3 -1
  30. package/dist/types/edit/modes/patch.d.ts +5 -0
  31. package/dist/types/edit/modes/replace.d.ts +3 -0
  32. package/dist/types/exec/bash-executor.d.ts +12 -0
  33. package/dist/types/gjc-runtime/managed-owner-admission.d.ts +3 -0
  34. package/dist/types/gjc-runtime/session-state-lock.d.ts +2 -0
  35. package/dist/types/gjc-runtime/state-runtime.d.ts +0 -2
  36. package/dist/types/migrate/adapters/codex.d.ts +2 -1
  37. package/dist/types/migrate/adapters/index.d.ts +10 -1
  38. package/dist/types/migrate/executor.d.ts +3 -1
  39. package/dist/types/migrate/types.d.ts +15 -3
  40. package/dist/types/modes/components/settings-selector.d.ts +6 -0
  41. package/dist/types/modes/controllers/selector-controller.d.ts +2 -0
  42. package/dist/types/modes/theme/theme.d.ts +1 -0
  43. package/dist/types/runtime-mcp/content-limits.d.ts +4 -0
  44. package/dist/types/runtime-mcp/manager.d.ts +7 -0
  45. package/dist/types/runtime-mcp/plugin-mcp-name-filter.d.ts +22 -0
  46. package/dist/types/runtime-mcp/tool-bridge.d.ts +4 -0
  47. package/dist/types/sdk/broker/broker-exit.d.ts +2 -2
  48. package/dist/types/sdk/broker/broker.d.ts +7 -1
  49. package/dist/types/sdk/broker/daemon-entry.d.ts +11 -1
  50. package/dist/types/sdk/broker/doctor-restart.d.ts +1 -1
  51. package/dist/types/sdk/broker/ensure.d.ts +136 -7
  52. package/dist/types/sdk/broker/hop.d.ts +36 -0
  53. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +18 -10
  54. package/dist/types/sdk/broker/lock-host-identity.d.ts +5 -0
  55. package/dist/types/sdk/broker/session-index.d.ts +4 -1
  56. package/dist/types/sdk/bus/chat-command-policy.d.ts +2 -1
  57. package/dist/types/sdk/bus/index.d.ts +23 -0
  58. package/dist/types/sdk/bus/reconciliation-store.d.ts +10 -0
  59. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +3 -1
  60. package/dist/types/sdk/cli/session-cli.d.ts +0 -1
  61. package/dist/types/sdk/host/session-runtime.d.ts +32 -0
  62. package/dist/types/sdk/prompt-deadline-flush.d.ts +42 -4
  63. package/dist/types/sdk/prompt-deadline-manager.d.ts +2 -0
  64. package/dist/types/sdk/protocol/adapter-validation.d.ts +10 -4
  65. package/dist/types/sdk/reconciliation-extensions.d.ts +1 -1
  66. package/dist/types/sdk/session-list.d.ts +1 -1
  67. package/dist/types/session/agent-session.d.ts +58 -2
  68. package/dist/types/session/internal/managed-session-scope.d.ts +2 -2
  69. package/dist/types/session/internal/managed-session-storage.d.ts +1 -1
  70. package/dist/types/session/internal/native-publish-outcome.d.ts +1 -1
  71. package/dist/types/session/messages.d.ts +5 -1
  72. package/dist/types/session/session-manager.d.ts +13 -3
  73. package/dist/types/session/startup-auth-config.d.ts +24 -2
  74. package/dist/types/session/streaming-output.d.ts +11 -0
  75. package/dist/types/session/terminal-abort.d.ts +3 -1
  76. package/dist/types/session-import/service.d.ts +1 -1
  77. package/dist/types/skill-state/workflow-mutation-guard.d.ts +7 -3
  78. package/dist/types/slash-commands/helpers/provider-auth-status.d.ts +8 -0
  79. package/dist/types/task/executor.d.ts +19 -2
  80. package/dist/types/tools/ast-edit-write-path.d.ts +10 -0
  81. package/dist/types/tools/atomic-file-write.d.ts +9 -7
  82. package/dist/types/tools/bash-allowed-prefixes.d.ts +11 -0
  83. package/dist/types/tools/descriptors.d.ts +2 -0
  84. package/dist/types/tools/fetch.d.ts +1 -1
  85. package/dist/types/tools/index.d.ts +2 -0
  86. package/dist/types/tools/python.d.ts +1 -1
  87. package/dist/types/tools/report-issue.d.ts +41 -0
  88. package/dist/types/tools/subagent.d.ts +23 -0
  89. package/dist/types/web/scrapers/types.d.ts +8 -1
  90. package/dist/types/web/search/providers/insane.d.ts +7 -1
  91. package/dist/types/web/search/providers/searxng.d.ts +5 -0
  92. package/package.json +8 -8
  93. package/scripts/build-binary.ts +2 -1
  94. package/scripts/capture-subagent-compact-panel-showcase.ts +105 -0
  95. package/scripts/generate-docs-index.ts +8 -1
  96. package/scripts/generate-sdk-operation-inventory.ts +22 -4
  97. package/src/async/job-manager.ts +356 -56
  98. package/src/async/result-formatting.ts +40 -0
  99. package/src/autoresearch/git.ts +9 -2
  100. package/src/autoresearch/prompts/prompt-setup.md +1 -1
  101. package/src/capability/fs.ts +27 -19
  102. package/src/capability/index.ts +2 -2
  103. package/src/cli/accounts-cli.ts +176 -8
  104. package/src/cli/auth-broker-cli.ts +21 -11
  105. package/src/cli/doctor/install-repairs.ts +35 -5
  106. package/src/cli/fast-help.ts +1 -1
  107. package/src/cli/install-activation.ts +47 -4
  108. package/src/cli/migrate-cli.ts +7 -5
  109. package/src/cli/public-command-errors.ts +4 -0
  110. package/src/cli/root-flags.ts +2 -2
  111. package/src/cli/stats-cli.ts +9 -5
  112. package/src/cli/update-cli.ts +224 -7
  113. package/src/cli-ordinary.ts +7 -2
  114. package/src/commands/accounts.ts +10 -5
  115. package/src/commands/migrate.ts +3 -3
  116. package/src/commands/sdk.ts +199 -42
  117. package/src/commands/ultragoal.ts +16 -8
  118. package/src/commit/changelog/index.ts +39 -1
  119. package/src/commit/message.ts +10 -3
  120. package/src/config/autorouting-tier-map.ts +30 -0
  121. package/src/config/config-hot-reload-trust.ts +148 -0
  122. package/src/config/config-hot-reload.ts +734 -0
  123. package/src/config/file-lock.ts +216 -56
  124. package/src/config/model-bindings-applier.ts +85 -47
  125. package/src/config/model-discovery-manager.ts +10 -0
  126. package/src/config/model-profile-activation.ts +716 -113
  127. package/src/config/model-registry.ts +1334 -158
  128. package/src/config/model-resolver.ts +3 -1
  129. package/src/config/models-config-schema.ts +6 -0
  130. package/src/config/settings-schema.ts +60 -3
  131. package/src/config/settings.ts +458 -24
  132. package/src/coordinator-mcp/policy.ts +26 -1
  133. package/src/crash/sanitize.ts +4 -0
  134. package/src/customization/import.ts +3 -1
  135. package/src/dap/config.ts +31 -11
  136. package/src/dap/defaults.json +1 -1
  137. package/src/debug/crash-diagnostics.ts +2 -1
  138. package/src/debug/report-bundle.ts +19 -0
  139. package/src/debug/system-info.ts +8 -3
  140. package/src/defaults/gjc/extensions/grok-cli-vendor/src/shared/base-url.ts +21 -7
  141. package/src/discovery/agents-md.ts +1 -1
  142. package/src/discovery/helpers.ts +1 -2
  143. package/src/discovery/mcp-compat.ts +120 -17
  144. package/src/edit/apply-patch/index.ts +12 -1
  145. package/src/edit/index.ts +13 -17
  146. package/src/edit/modes/patch.ts +187 -8
  147. package/src/edit/modes/replace.ts +136 -0
  148. package/src/eval/js/context-manager.ts +29 -3
  149. package/src/eval/js/worker-entry.ts +51 -18
  150. package/src/eval/py/executor.ts +14 -4
  151. package/src/eval/py/runner.py +144 -5
  152. package/src/exec/bash-executor.ts +81 -4
  153. package/src/exec/bash-shell-guardian.ts +4 -1
  154. package/src/exec/bash-shell-supervisor.ts +8 -4
  155. package/src/exec/isolated-shell.ts +5 -1
  156. package/src/export/html/index.ts +46 -6
  157. package/src/extensibility/custom-tools/wrapper.ts +3 -1
  158. package/src/extensibility/extensions/loader.ts +8 -1
  159. package/src/extensibility/plugins/marketplace/fetcher.ts +2 -2
  160. package/src/gjc-runtime/gc-runtime.ts +33 -6
  161. package/src/gjc-runtime/launch-tmux.ts +7 -2
  162. package/src/gjc-runtime/launch-worktree.test.ts +148 -0
  163. package/src/gjc-runtime/launch-worktree.ts +101 -14
  164. package/src/gjc-runtime/managed-owner-admission.ts +46 -2
  165. package/src/gjc-runtime/session-resolution.ts +38 -6
  166. package/src/gjc-runtime/session-state-lock.ts +59 -14
  167. package/src/gjc-runtime/state-runtime.ts +23 -19
  168. package/src/hooks/native-skill-hook.ts +14 -11
  169. package/src/internal-urls/docs-index.generated.ts +14 -13
  170. package/src/internal-urls/local-protocol.ts +142 -8
  171. package/src/lsp/index.ts +29 -0
  172. package/src/lsp/workspace-path.ts +23 -0
  173. package/src/migrate/action-planner.ts +57 -0
  174. package/src/migrate/adapters/claude-code.ts +23 -4
  175. package/src/migrate/adapters/codex.ts +22 -4
  176. package/src/migrate/adapters/index.ts +22 -4
  177. package/src/migrate/adapters/opencode.ts +6 -1
  178. package/src/migrate/executor.ts +84 -7
  179. package/src/migrate/report.ts +19 -2
  180. package/src/migrate/types.ts +14 -4
  181. package/src/modes/DESIGN.md +51 -0
  182. package/src/modes/acp/permission-mode.ts +18 -1
  183. package/src/modes/components/assistant-message.ts +55 -1
  184. package/src/modes/components/settings-selector.ts +103 -37
  185. package/src/modes/controllers/command-controller.ts +38 -2
  186. package/src/modes/controllers/plan-mode-controller.ts +17 -7
  187. package/src/modes/controllers/selector-controller.ts +62 -6
  188. package/src/modes/interactive-mode.ts +68 -0
  189. package/src/modes/theme/theme.ts +15 -0
  190. package/src/prompts/tools/debug.md +1 -1
  191. package/src/prompts/tools/read.md +1 -1
  192. package/src/prompts/tools/report-issue.md +13 -0
  193. package/src/prompts/tools/task.md +1 -1
  194. package/src/runtime-credential-selector.ts +2 -2
  195. package/src/runtime-mcp/content-limits.ts +7 -0
  196. package/src/runtime-mcp/manager-pool.test.ts +5 -4
  197. package/src/runtime-mcp/manager.ts +32 -0
  198. package/src/runtime-mcp/oauth-flow.ts +32 -6
  199. package/src/runtime-mcp/plugin-mcp-name-filter.ts +62 -0
  200. package/src/runtime-mcp/render.ts +16 -1
  201. package/src/runtime-mcp/tool-bridge.ts +126 -17
  202. package/src/sdk/broker/broker-exit.ts +8 -5
  203. package/src/sdk/broker/broker.ts +342 -82
  204. package/src/sdk/broker/daemon-entry.ts +338 -116
  205. package/src/sdk/broker/doctor-restart.ts +2 -1
  206. package/src/sdk/broker/ensure.ts +1143 -265
  207. package/src/sdk/broker/hop.ts +274 -0
  208. package/src/sdk/broker/lifecycle-ledger.ts +35 -11
  209. package/src/sdk/broker/lock-host-identity.ts +29 -0
  210. package/src/sdk/broker/session-index.ts +62 -12
  211. package/src/sdk/bus/chat-command-policy.ts +16 -3
  212. package/src/sdk/bus/index.ts +52 -21
  213. package/src/sdk/bus/reconciliation-store.ts +201 -8
  214. package/src/sdk/bus/telegram-daemon-contract.ts +3 -1
  215. package/src/sdk/cli/session-cli.ts +21 -6
  216. package/src/sdk/host/session-runtime.test.ts +118 -23
  217. package/src/sdk/host/session-runtime.ts +352 -47
  218. package/src/sdk/lifecycle/service.ts +1 -0
  219. package/src/sdk/prompt-deadline-flush.ts +78 -5
  220. package/src/sdk/prompt-deadline-manager.ts +68 -14
  221. package/src/sdk/protocol/adapter-validation.ts +53 -11
  222. package/src/sdk/protocol/operation-inventory.generated.json +101 -2
  223. package/src/sdk/reconciliation-extensions.ts +1 -0
  224. package/src/sdk/session-list.ts +1 -0
  225. package/src/sdk/session.ts +114 -61
  226. package/src/session/agent-session.ts +1043 -81
  227. package/src/session/internal/managed-session-scope.ts +126 -39
  228. package/src/session/internal/managed-session-storage.ts +14 -11
  229. package/src/session/internal/native-publish-outcome.ts +5 -1
  230. package/src/session/messages.ts +47 -6
  231. package/src/session/session-manager.ts +236 -14
  232. package/src/session/startup-auth-config.ts +116 -8
  233. package/src/session/streaming-output.ts +262 -4
  234. package/src/session/terminal-abort.ts +14 -2
  235. package/src/session-import/service.ts +1 -0
  236. package/src/skill-state/workflow-mutation-guard.ts +491 -32
  237. package/src/slash-commands/builtin-registry.ts +15 -3
  238. package/src/slash-commands/helpers/credential-switch.ts +10 -5
  239. package/src/slash-commands/helpers/provider-auth-status.ts +456 -0
  240. package/src/task/executor.ts +103 -12
  241. package/src/task/index.ts +389 -175
  242. package/src/tools/ast-edit-write-path.ts +64 -0
  243. package/src/tools/ast-edit.ts +18 -1
  244. package/src/tools/atomic-file-write.ts +46 -15
  245. package/src/tools/auto-generated-guard.ts +3 -0
  246. package/src/tools/bash-allowed-prefixes.ts +153 -5
  247. package/src/tools/bash-interactive.ts +14 -2
  248. package/src/tools/bash.ts +14 -0
  249. package/src/tools/debug.ts +7 -6
  250. package/src/tools/descriptors.test.ts +44 -0
  251. package/src/tools/descriptors.ts +34 -2
  252. package/src/tools/fetch.ts +3 -2
  253. package/src/tools/index.ts +2 -0
  254. package/src/tools/output-meta.ts +23 -2
  255. package/src/tools/python.ts +1 -1
  256. package/src/tools/read.ts +17 -35
  257. package/src/tools/report-issue.ts +184 -0
  258. package/src/tools/subagent-render.ts +114 -12
  259. package/src/tools/subagent.ts +108 -1
  260. package/src/tools/todo-write.ts +41 -3
  261. package/src/tools/tool-catalog.generated.ts +31 -3
  262. package/src/tools/write.ts +35 -3
  263. package/src/utils/commit-message-generator.ts +9 -2
  264. package/src/utils/file-mentions.ts +29 -1
  265. package/src/utils/title-generator.ts +7 -2
  266. package/src/web/scrapers/index.ts +3 -1
  267. package/src/web/scrapers/types.ts +8 -0
  268. package/src/web/scrapers/youtube.ts +3 -2
  269. package/src/web/search/providers/insane.ts +91 -28
  270. package/src/web/search/providers/searxng.ts +62 -10
@@ -3634,7 +3634,15 @@ function isLinkedWorktreePeer(a: string, b: string): boolean {
3634
3634
  * Read the terminal breadcrumb for the current terminal, scoped to a cwd.
3635
3635
  * Returns the session file path if it exists and matches the cwd, null otherwise.
3636
3636
  */
3637
- async function readTerminalBreadcrumb(cwd: string): Promise<string | null> {
3637
+ async function readTerminalBreadcrumb(
3638
+ cwd: string,
3639
+ options: {
3640
+ sessionsRoot?: string;
3641
+ allowExplicitOutsideRoot?: boolean;
3642
+ /** Managed continuation inventory, including project-local transcripts. */
3643
+ continuationInventory?: readonly SessionInfo[];
3644
+ } = {},
3645
+ ): Promise<string | null> {
3638
3646
  const terminalId = getTerminalId();
3639
3647
  if (!terminalId) return null;
3640
3648
 
@@ -3656,9 +3664,9 @@ async function readTerminalBreadcrumb(cwd: string): Promise<string | null> {
3656
3664
 
3657
3665
  if (isStagedSessionPath(sessionFile)) return null;
3658
3666
  const inspected = inspectResumeSessionFile(sessionFile, new FileSessionStorage());
3667
+ const sessionsRoot = options.sessionsRoot ?? getSessionsDir();
3659
3668
  if ("kind" in inspected) {
3660
3669
  if (inspected.reason !== "missing") return null;
3661
- const sessionsRoot = getSessionsDir();
3662
3670
  const resolved = resolveManagedScope({
3663
3671
  cwd: breadcrumbCwd,
3664
3672
  agentDir: path.resolve(sessionsRoot, ".."),
@@ -3675,7 +3683,6 @@ async function readTerminalBreadcrumb(cwd: string): Promise<string | null> {
3675
3683
  }
3676
3684
  const header = inspected.entries[0] as SessionHeader;
3677
3685
  if (resolveEquivalentPath(header.cwd) !== resolveEquivalentPath(breadcrumbCwd)) return null;
3678
- const sessionsRoot = getSessionsDir();
3679
3686
  const resolved = resolveManagedScope({
3680
3687
  cwd: header.cwd,
3681
3688
  agentDir: path.resolve(sessionsRoot, ".."),
@@ -3693,7 +3700,22 @@ async function readTerminalBreadcrumb(cwd: string): Promise<string | null> {
3693
3700
  candidate => !isStagedSessionPath(candidate.path) && candidate.sessionId === header.id,
3694
3701
  );
3695
3702
  if (byIdentity) return byIdentity.path;
3696
- return pathIsWithin(sessionsRoot, path.resolve(sessionFile)) ? null : path.resolve(sessionFile);
3703
+ // Not an owned managed candidate. A file inside the sessions root stays
3704
+ // rejected. A file outside that root is resumed only when this continuation
3705
+ // inventory already contains it. An explicit destination still receives an
3706
+ // unmatched outside path; the caller applies containment.
3707
+ const resolvedFile = path.resolve(sessionFile);
3708
+ const insideSessionsRoot = pathIsWithin(sessionsRoot, resolvedFile);
3709
+ if (
3710
+ !insideSessionsRoot &&
3711
+ options.continuationInventory?.some(
3712
+ session => session.id === header.id && path.resolve(session.path) === resolvedFile,
3713
+ )
3714
+ ) {
3715
+ return resolvedFile;
3716
+ }
3717
+ if (!options.allowExplicitOutsideRoot || insideSessionsRoot) return null;
3718
+ return resolvedFile;
3697
3719
  } catch (err) {
3698
3720
  if (!isEnoent(err)) logger.debug("Terminal breadcrumb read failed", { err });
3699
3721
  // Breadcrumb doesn't exist or is corrupt — fall through
@@ -7576,14 +7598,14 @@ type ManagedDestinationTransition = {
7576
7598
  /**
7577
7599
  * Freshness snapshot captured with every async whole-session persistence
7578
7600
  * preparation. Immediately before the synchronous persistence transaction, the
7579
- * live values are compared: a `sessionFile`/`lifecycleId` change aborts (lifecycle
7580
- * switch); a revision change discards the prepared bytes and re-prepares (bounded),
7601
+ * live values are compared: a logical lifecycle change aborts; a verified storage
7602
+ * relocation or revision change discards prepared bytes and re-prepares (bounded),
7581
7603
  * so a stale snapshot is never published.
7582
7604
  */
7583
7605
  export interface PersistenceInputToken {
7584
7606
  /** Canonical session file path the prepared bytes target. */
7585
7607
  sessionFile: string;
7586
- /** Per-manager lifecycle identity (`sessionId@sessionFile`); changes on switch/open/reset. */
7608
+ /** Logical lifecycle identity; storage-only relocation preserves its verified origin. */
7587
7609
  lifecycleId: string;
7588
7610
  /** Revision of #fileEntries affecting persisted bytes. */
7589
7611
  entryRevision: number;
@@ -7800,6 +7822,10 @@ export class SessionManager {
7800
7822
  #artifactManager: ArtifactManager | null = null;
7801
7823
  #artifactManagerSessionFile: string | null = null;
7802
7824
  #managedTranscriptStoreCache: { directory: string; store: ManagedSessionDescendantStore } | null = null;
7825
+ #managedTranscriptRelocationResolver: (() => ArtifactManager) | undefined;
7826
+ #managedTranscriptRelocationResolverToken: symbol | undefined;
7827
+ #managedTranscriptRelocationFileOrigin: string | undefined;
7828
+ #managedTranscriptRelocationParentArtifacts: ArtifactManager | undefined;
7803
7829
  #ownedManagedAuthority: ManagedSessionDescendantStore | undefined;
7804
7830
  #managedSidecarCacheStore: EphemeralBlobStore | undefined;
7805
7831
  readonly #managedSidecarCleanupStores = new Set<EphemeralBlobStore>();
@@ -10289,6 +10315,7 @@ export class SessionManager {
10289
10315
  }
10290
10316
  this.#artifactManager = null;
10291
10317
  this.#artifactManagerSessionFile = null;
10318
+ this.#clearManagedTranscriptRelocationResolver();
10292
10319
  this.#artifactLifecycle = Symbol("session-artifact-adoption");
10293
10320
  installedGeneration = this.#artifactLifecycle;
10294
10321
  installedSessionId = this.#sessionId;
@@ -10402,6 +10429,7 @@ export class SessionManager {
10402
10429
  throw error;
10403
10430
  }
10404
10431
  managedTransition?.adopt();
10432
+ this.#clearManagedTranscriptRelocationResolver();
10405
10433
  this.#sessionFile = resolvedSessionFile;
10406
10434
  this.#sessionId = sessionId;
10407
10435
  this.#sessionName = header?.title;
@@ -10489,6 +10517,7 @@ export class SessionManager {
10489
10517
  prepared.dispose();
10490
10518
  throw error;
10491
10519
  }
10520
+ this.#clearManagedTranscriptRelocationResolver();
10492
10521
  managedTransition?.settle();
10493
10522
  this.#pendingStrictAdoption = undefined;
10494
10523
  await this.#rewriteFile();
@@ -10958,6 +10987,7 @@ export class SessionManager {
10958
10987
  this.#adoptedArtifactManager = null;
10959
10988
  this.#managedPersistExpectedIdentity = expectedIdentity;
10960
10989
  this.#commitResidentTextStoreTransition(transition);
10990
+ this.#clearManagedTranscriptRelocationResolver();
10961
10991
  this.#retireEphemeralArtifacts();
10962
10992
  stage.committed = true;
10963
10993
  this.#preparedNewSessions.delete(stage);
@@ -12196,6 +12226,9 @@ export class SessionManager {
12196
12226
  path.basename(oldArtifactDir),
12197
12227
  managedPublishedArtifacts,
12198
12228
  );
12229
+ // The restored namespace has a new directory identity, even at the same path.
12230
+ this.#artifactManager = null;
12231
+ this.#artifactManagerSessionFile = null;
12199
12232
  }
12200
12233
  if (managedTranscript && !managedSourceStore.readExpected(path.basename(oldSessionFile)))
12201
12234
  await managedSourceStore.publishNoReplace(path.basename(oldSessionFile), managedTranscript.bytes);
@@ -12303,6 +12336,8 @@ export class SessionManager {
12303
12336
  path.basename(oldArtifactDir),
12304
12337
  managedPublishedArtifacts,
12305
12338
  );
12339
+ this.#artifactManager = null;
12340
+ this.#artifactManagerSessionFile = null;
12306
12341
  const restored = managedSourceStore.captureTree(path.basename(oldArtifactDir));
12307
12342
  if (!retainedTreeSnapshotEqualsAfterRename(restored, managedArtifacts))
12308
12343
  throw new Error("managed_move_source_restore_mismatch");
@@ -12653,7 +12688,7 @@ export class SessionManager {
12653
12688
  #capturePersistenceInputToken(): PersistenceInputToken {
12654
12689
  return {
12655
12690
  sessionFile: this.#sessionFile ?? "",
12656
- lifecycleId: `${this.#sessionId}@${this.#sessionFile ?? ""}`,
12691
+ lifecycleId: `${this.#sessionId}@${this.#managedTranscriptRelocationFileOrigin ?? this.#sessionFile ?? ""}`,
12657
12692
  entryRevision: this.#entryRevision,
12658
12693
  headerRevision: this.#headerExportRevision,
12659
12694
  residentBlobRevision: this.#residentBlobRevision,
@@ -12667,9 +12702,10 @@ export class SessionManager {
12667
12702
  */
12668
12703
  #persistenceInputTokenMatches(token: PersistenceInputToken): boolean {
12669
12704
  const live = this.#capturePersistenceInputToken();
12670
- if (live.sessionFile !== token.sessionFile || live.lifecycleId !== token.lifecycleId) {
12705
+ if (live.lifecycleId !== token.lifecycleId) {
12671
12706
  throw new Error("session_persistence_lifecycle_changed");
12672
12707
  }
12708
+ if (live.sessionFile !== token.sessionFile) return false;
12673
12709
  return (
12674
12710
  live.entryRevision === token.entryRevision &&
12675
12711
  live.headerRevision === token.headerRevision &&
@@ -12935,10 +12971,162 @@ export class SessionManager {
12935
12971
  this.#ownedManagedAuthority = undefined;
12936
12972
  }
12937
12973
 
12974
+ /**
12975
+ * Register the same-owner task capability used to follow a parent's managed
12976
+ * artifact root after its session move. The synchronous resolver must return
12977
+ * only the current ArtifactManager for the same parent ID and parent transcript
12978
+ * basename; it retains the last verified manager on a logical successor.
12979
+ * Registration captures the exact original manager identity. Unchanged identity
12980
+ * keeps using the child's retained store; a changed identity triggers strict
12981
+ * destination transcript verification before re-rooting.
12982
+ */
12983
+ registerManagedTranscriptRelocationResolver(resolver: () => ArtifactManager): () => void {
12984
+ if (this.destination.kind !== "managed")
12985
+ throw new Error("Managed transcript relocation requires a managed session");
12986
+ if (this.#managedTranscriptRelocationResolver)
12987
+ throw new Error("Managed transcript relocation resolver is already registered");
12988
+ const originalParentArtifacts = resolver();
12989
+ const registrationToken = Symbol("managed-transcript-relocation");
12990
+ this.#managedTranscriptRelocationResolver = resolver;
12991
+ this.#managedTranscriptRelocationResolverToken = registrationToken;
12992
+ this.#managedTranscriptRelocationParentArtifacts = originalParentArtifacts;
12993
+ return () => {
12994
+ if (this.#managedTranscriptRelocationResolverToken === registrationToken)
12995
+ this.#clearManagedTranscriptRelocationResolver();
12996
+ };
12997
+ }
12998
+
12999
+ #clearManagedTranscriptRelocationResolver(): void {
13000
+ this.#managedTranscriptRelocationResolver = undefined;
13001
+ this.#managedTranscriptRelocationResolverToken = undefined;
13002
+ this.#managedTranscriptRelocationFileOrigin = undefined;
13003
+ this.#managedTranscriptRelocationParentArtifacts = undefined;
13004
+ }
13005
+
13006
+ #refreshManagedTranscriptRelocation(sessionFile = this.#sessionFile): void {
13007
+ const currentSessionFile = this.#sessionFile;
13008
+ const resolver = this.#managedTranscriptRelocationResolver;
13009
+ if (this.destination.kind !== "managed" || !currentSessionFile || sessionFile !== currentSessionFile || !resolver)
13010
+ return;
13011
+
13012
+ const parentArtifacts = resolver();
13013
+ const nextDirectory = path.resolve(parentArtifacts.dir);
13014
+ const parentArtifactsChanged = this.#managedTranscriptRelocationParentArtifacts !== parentArtifacts;
13015
+ if (nextDirectory === path.resolve(this.destination.directory) && !parentArtifactsChanged) return;
13016
+ parentArtifacts.assertManagedBinding();
13017
+ const parentStore = parentArtifacts.getManagedStore();
13018
+ if (!parentStore || path.resolve(parentStore.dir) !== nextDirectory)
13019
+ throw new Error("managed_transcript_relocation_parent_authority_unavailable");
13020
+ parentStore.assertBound();
13021
+ if (path.resolve(path.dirname(currentSessionFile)) !== path.resolve(this.destination.directory))
13022
+ throw new Error("managed_transcript_relocation_source_path_unverified");
13023
+ const transcriptName = path.basename(currentSessionFile);
13024
+ const nextSessionFile = path.join(nextDirectory, transcriptName);
13025
+ if (path.basename(nextSessionFile) !== transcriptName)
13026
+ throw new Error("managed_transcript_relocation_transcript_name_mismatch");
13027
+
13028
+ if (this.#stagedPublication) throw new Error("managed_transcript_relocation_staged_publication_unavailable");
13029
+ if (this.#persistWriter || this.#persistWriterPath)
13030
+ throw new Error("managed_transcript_relocation_writer_unavailable");
13031
+ if (this.#boundedManagedSource) throw new Error("managed_transcript_relocation_bounded_source_unavailable");
13032
+ if (this.#coldSidecarActive() || (this.#sidecarRuntime?.coldEntries.size ?? 0) > 0)
13033
+ throw new Error("managed_transcript_relocation_cold_sidecar_unavailable");
13034
+ if (this.#recoveryHydrationContext || this.#recoveryPromotionTranscriptPath || this.#pendingStrictAdoption)
13035
+ throw new Error("managed_transcript_relocation_recovery_state_unavailable");
13036
+
13037
+ const expectedIdentity = this.#managedPersistExpectedIdentity;
13038
+ if (
13039
+ !expectedIdentity ||
13040
+ !Number.isSafeInteger(expectedIdentity.size) ||
13041
+ expectedIdentity.size < 0 ||
13042
+ typeof expectedIdentity.sha256 !== "string" ||
13043
+ !/^[0-9a-f]{64}$/.test(expectedIdentity.sha256)
13044
+ )
13045
+ throw new Error("managed_transcript_relocation_original_identity_unavailable");
13046
+
13047
+ let nextDestination: SessionDestination | undefined;
13048
+ let nextStore: ManagedSessionDescendantStore | undefined;
13049
+ let adopted = false;
13050
+ try {
13051
+ nextDestination = SessionManager.nestedManagedDestination(parentStore, nextDirectory);
13052
+ if (nextDestination.kind !== "managed")
13053
+ throw new Error("managed_transcript_relocation_destination_unavailable");
13054
+ nextStore = managedStoreFromContext(nextDestination.securityContext, nextDirectory);
13055
+ nextStore.verifyRootSecurity();
13056
+ nextStore.assertBound();
13057
+ const destinationTranscript = nextStore.readExpected(transcriptName);
13058
+ if (!destinationTranscript) throw new Error("managed_transcript_relocation_destination_missing");
13059
+ const destinationSha256 = crypto.createHash("sha256").update(destinationTranscript.bytes).digest("hex");
13060
+ if (
13061
+ destinationTranscript.identity.size !== expectedIdentity.size ||
13062
+ destinationTranscript.bytes.byteLength !== expectedIdentity.size ||
13063
+ destinationTranscript.identity.sha256 !== expectedIdentity.sha256 ||
13064
+ destinationSha256 !== expectedIdentity.sha256
13065
+ )
13066
+ throw new Error("managed_transcript_relocation_destination_identity_mismatch");
13067
+
13068
+ parentArtifacts.assertManagedBinding();
13069
+ parentStore.assertBound();
13070
+ nextStore.assertBound();
13071
+
13072
+ const previousDestination = this.destination;
13073
+ const previousCache = this.#managedTranscriptStoreCache;
13074
+ const previousOwnedAuthority = this.#ownedManagedAuthority;
13075
+ const previousArtifactStore = this.#artifactManager?.getManagedStore();
13076
+ // A hot/shadow sidecar remains a disposable cache over the same verified
13077
+ // transcript bytes. Keep its resident state and cache root; only retag the
13078
+ // cache key. Cold and bounded source state was rejected above because it
13079
+ // pins descriptor/path state that cannot be retargeted without rebuilding.
13080
+ if (this.#managedSidecarCacheStore) this.#managedSidecarCacheSessionFile = nextSessionFile;
13081
+ this.#boundedReadStorageProxy = undefined;
13082
+ this.#managedTranscriptRelocationFileOrigin ??= currentSessionFile;
13083
+ this.destination = nextDestination;
13084
+ this.sessionDir = nextDirectory;
13085
+ this.#sessionFile = nextSessionFile;
13086
+ this.#managedTranscriptStoreCache = { directory: nextDirectory, store: nextStore };
13087
+ this.#managedPersistExpectedIdentity = { ...destinationTranscript.identity };
13088
+ this.#managedRangeExpectedDescriptor = {
13089
+ dev: destinationTranscript.identity.dev,
13090
+ ino: destinationTranscript.identity.ino,
13091
+ nlink: destinationTranscript.identity.nlink,
13092
+ size: destinationTranscript.identity.size,
13093
+ mtimeNs: destinationTranscript.identity.mtimeNs,
13094
+ ctimeNs: destinationTranscript.identity.ctimeNs,
13095
+ };
13096
+ this.#adoptedArtifactManager = parentArtifacts;
13097
+ this.#managedTranscriptRelocationParentArtifacts = parentArtifacts;
13098
+ this.#artifactManager = null;
13099
+ this.#artifactManagerSessionFile = null;
13100
+ this.#ownedManagedAuthority = undefined;
13101
+ adopted = true;
13102
+
13103
+ // These stores were created by this manager. Their close methods release
13104
+ // only authorities they actually own; the borrowed parent manager remains
13105
+ // untouched.
13106
+ previousCache?.store.close();
13107
+ if (previousOwnedAuthority && previousOwnedAuthority !== previousCache?.store) previousOwnedAuthority.close();
13108
+ if (previousArtifactStore && previousArtifactStore !== parentStore && previousArtifactStore !== nextStore)
13109
+ previousArtifactStore.close();
13110
+ if (previousDestination.kind === "managed") previousDestination.securityContext.retainedAuthority?.close();
13111
+ } catch (error) {
13112
+ if (!adopted) {
13113
+ nextStore?.close();
13114
+ if (nextDestination?.kind === "managed") nextDestination.securityContext.retainedAuthority?.close();
13115
+ }
13116
+ throw error;
13117
+ }
13118
+ }
13119
+
12938
13120
  #managedTranscriptStore(sessionFile = this.#sessionFile): ManagedSessionDescendantStore {
12939
13121
  if (this.destination.kind !== "managed" || !sessionFile) {
12940
13122
  throw new Error("Managed transcript authority is unavailable");
12941
13123
  }
13124
+ const previousSessionFile = this.#sessionFile;
13125
+ if (sessionFile === previousSessionFile) {
13126
+ this.#refreshManagedTranscriptRelocation(sessionFile);
13127
+ if (this.#sessionFile !== previousSessionFile) sessionFile = this.#sessionFile;
13128
+ }
13129
+ if (!sessionFile) throw new Error("Managed transcript authority is unavailable");
12942
13130
  const sessionDir = path.resolve(path.dirname(sessionFile));
12943
13131
  if (sessionDir !== path.resolve(this.destination.directory)) {
12944
13132
  throw new Error("Managed transcript escaped its session directory");
@@ -16592,6 +16780,7 @@ export class SessionManager {
16592
16780
  // aborts. A stale snapshot is never published.
16593
16781
  let written = false;
16594
16782
  for (let attempt = 0; attempt <= 2 && !written; attempt++) {
16783
+ this.#refreshManagedTranscriptRelocation();
16595
16784
  this.#ensureFullHotView();
16596
16785
  const token = this.#capturePersistenceInputToken();
16597
16786
  await this.#closePersistWriterInternal();
@@ -16601,6 +16790,7 @@ export class SessionManager {
16601
16790
  ),
16602
16791
  );
16603
16792
  written = this.#withSessionPersistenceFenceSync(() => {
16793
+ this.#refreshManagedTranscriptRelocation();
16604
16794
  if (!this.#persistenceInputTokenMatches(token)) return false;
16605
16795
  this.#writeEntriesAtomicallySync(entries);
16606
16796
  return true;
@@ -16624,6 +16814,7 @@ export class SessionManager {
16624
16814
 
16625
16815
  #rewriteFileSync(): void {
16626
16816
  if (!this.persist || !this.#sessionFile) return;
16817
+ this.#refreshManagedTranscriptRelocation();
16627
16818
  this.#withSessionPersistenceFenceSync(() => {
16628
16819
  this.#ensureFullHotView();
16629
16820
  // Sync lane: capture and check the token immediately (always fresh absent
@@ -16958,6 +17149,7 @@ export class SessionManager {
16958
17149
  }
16959
17150
 
16960
17151
  #releaseClosedSessionState(): void {
17152
+ this.#clearManagedTranscriptRelocationResolver();
16961
17153
  const runtime = this.#sidecarRuntime;
16962
17154
  if (runtime) {
16963
17155
  runtime.enabled = false;
@@ -18552,6 +18744,7 @@ export class SessionManager {
18552
18744
  }
18553
18745
 
18554
18746
  #appendManagedRecordsSync(records: readonly (FileEntry | SessionPatchRecord)[]): void {
18747
+ this.#refreshManagedTranscriptRelocation();
18555
18748
  if (!this.#sessionFile) throw new Error("Managed transcript path is unavailable");
18556
18749
  this.#withSessionPersistenceFenceSync(() => {
18557
18750
  const sessionFile = this.#sessionFile!;
@@ -18580,12 +18773,23 @@ export class SessionManager {
18580
18773
 
18581
18774
  async #persistPatches(records: readonly SessionPatchRecord[]): Promise<void> {
18582
18775
  if (records.length === 0) return;
18583
- if (this.#coldSidecarActive()) this.#deactivateColdForBranchMutation();
18584
- if (!this.persist || !this.#sessionFile || !this.#storage.existsSync(this.#sessionFile)) return;
18585
- const sessionFile = this.#sessionFile;
18776
+ try {
18777
+ this.#refreshManagedTranscriptRelocation();
18778
+ } catch (error) {
18779
+ this.#recordPersistError(error);
18780
+ throw this.#persistError ?? toError(error);
18781
+ }
18782
+ if (!this.persist || !this.#sessionFile || !this.#storage.existsSync(this.#sessionFile)) {
18783
+ if (this.#coldSidecarActive()) this.#deactivateColdForBranchMutation();
18784
+ return;
18785
+ }
18786
+ let sessionFile = this.#sessionFile;
18586
18787
  const publishResumeBreadcrumb = this.#readOnlyResume;
18587
18788
  await this.#queuePersistTask(async () => {
18588
18789
  for (let attempt = 0; attempt <= 2; attempt++) {
18790
+ this.#refreshManagedTranscriptRelocation();
18791
+ if (this.#coldSidecarActive()) this.#deactivateColdForBranchMutation();
18792
+ sessionFile = this.#sessionFile!;
18589
18793
  const token = this.#capturePersistenceInputToken();
18590
18794
  const header = this.#fileEntries.find(entry => entry.type === "session") as SessionHeader | undefined;
18591
18795
  if (
@@ -18594,6 +18798,7 @@ export class SessionManager {
18594
18798
  (header?.version ?? 1) < CURRENT_SESSION_VERSION
18595
18799
  ) {
18596
18800
  await this.#rewriteFileContents();
18801
+ sessionFile = this.#sessionFile!;
18597
18802
  if (publishResumeBreadcrumb) writeTerminalBreadcrumb(this.cwd, sessionFile);
18598
18803
  this.#readOnlyResume = false;
18599
18804
  return;
@@ -18613,6 +18818,7 @@ export class SessionManager {
18613
18818
  SessionManagerTestHooks.beforePersistPatchFence?.(attempt);
18614
18819
  let persisted = false;
18615
18820
  const written = this.#withSessionPersistenceFenceSync(() => {
18821
+ this.#refreshManagedTranscriptRelocation();
18616
18822
  if (!this.#persistenceInputTokenMatches(token)) return false;
18617
18823
  if (this.destination.kind === "managed") {
18618
18824
  this.#appendManagedRecordsSync(persistedRecords);
@@ -18645,8 +18851,14 @@ export class SessionManager {
18645
18851
  _persist(entry: SessionEntry): void {
18646
18852
  if (!this.persist || !this.#sessionFile) return;
18647
18853
  if (this.#strictClosePending) throw new Error("Session manager is closing.");
18648
- const publishResumeBreadcrumb = this.#readOnlyResume;
18649
18854
  if (this.#persistError) throw this.#persistError;
18855
+ try {
18856
+ this.#refreshManagedTranscriptRelocation();
18857
+ } catch (error) {
18858
+ this.#recordPersistError(error);
18859
+ throw this.#persistError ?? toError(error);
18860
+ }
18861
+ const publishResumeBreadcrumb = this.#readOnlyResume;
18650
18862
 
18651
18863
  // Normally we wait for the first assistant message before persisting to avoid
18652
18864
  // creating files for sessions that never produce output. Once ensureOnDisk() has
@@ -20553,6 +20765,7 @@ export class SessionManager {
20553
20765
  }
20554
20766
  this.#sessionId = newSessionId;
20555
20767
  this.#sessionFile = newSessionFile;
20768
+ this.#clearManagedTranscriptRelocationResolver();
20556
20769
  this.#flushed = true;
20557
20770
  this.#commitResidentTextStoreTransition(transition);
20558
20771
  this.#adoptManagedPersistIdentity(newSessionFile);
@@ -20587,6 +20800,7 @@ export class SessionManager {
20587
20800
  "retain-and-throw",
20588
20801
  );
20589
20802
  this.#sessionId = newSessionId;
20803
+ this.#clearManagedTranscriptRelocationResolver();
20590
20804
  this.#commitResidentTextStoreTransition(transition);
20591
20805
  return undefined;
20592
20806
  }
@@ -23045,7 +23259,15 @@ export class SessionManager {
23045
23259
  storage,
23046
23260
  );
23047
23261
 
23048
- const terminalSession = await readTerminalBreadcrumb(cwd);
23262
+ const terminalSession = await readTerminalBreadcrumb(cwd, {
23263
+ ...(destination.kind === "managed"
23264
+ ? {
23265
+ sessionsRoot: destination.securityContext.sessionsRoot,
23266
+ continuationInventory: managedCandidates,
23267
+ }
23268
+ : {}),
23269
+ allowExplicitOutsideRoot: destination.kind === "explicit",
23270
+ });
23049
23271
  const terminalSessionIsInExplicitRoot = (() => {
23050
23272
  if (destination.kind === "managed" || !terminalSession) return destination.kind === "managed";
23051
23273
  const canonicalRoot = canonicalizeTrustedPath(destination.directory);
@@ -9,7 +9,11 @@
9
9
 
10
10
  import * as os from "node:os";
11
11
  import * as path from "node:path";
12
- import type { AuthCredentialSelector, CredentialRankingMode } from "@gajae-code/ai/core";
12
+ import type {
13
+ AuthCredentialSelector,
14
+ ConfiguredCredentialPreference,
15
+ CredentialRankingMode,
16
+ } from "@gajae-code/ai/core";
13
17
  import { $credentialEnv, getAgentDbPath, getAgentDir, getConfigRootDir, isEnoent, logger } from "@gajae-code/utils";
14
18
  import { YAML } from "bun";
15
19
  import { readSecureTokenFile } from "./secure-token-file";
@@ -27,6 +31,8 @@ export interface StartupAuthConfigSnapshot {
27
31
  credentialPinStoreIdentity?: string;
28
32
  credentialRankingMode: CredentialRankingMode;
29
33
  credentialPins: Readonly<Record<string, string>>;
34
+ /** Ordered soft per-provider preferences; only portable email/`account:` selectors are accepted. */
35
+ credentialPreferences: Readonly<Record<string, readonly ConfiguredCredentialPreference[]>>;
30
36
  }
31
37
 
32
38
  export type StartupAuthConfigErrorKind =
@@ -37,7 +43,8 @@ export type StartupAuthConfigErrorKind =
37
43
  | "invalid-broker"
38
44
  | "invalid-gateway"
39
45
  | "invalid-ranking-mode"
40
- | "invalid-credential-pins";
46
+ | "invalid-credential-pins"
47
+ | "invalid-credential-preferences";
41
48
 
42
49
  /** A malformed startup-auth config aborts resolution rather than downgrading to local authority. */
43
50
  export class StartupAuthConfigError extends Error {
@@ -64,6 +71,7 @@ const LEGACY_LITERAL_AUTH_KEYS = new Set([
64
71
  "auth.broker.token",
65
72
  "auth.credentialRankingMode",
66
73
  "auth.credentialPins",
74
+ "auth.credentialPreferences",
67
75
  ]);
68
76
 
69
77
  function configPathForAgentDir(agentDir: string): string {
@@ -78,7 +86,7 @@ function shortenConfigPath(configPath: string): string {
78
86
  return absolutePath.startsWith(homePrefix) ? `~${absolutePath.slice(homePath.length)}` : absolutePath;
79
87
  }
80
88
 
81
- function startupAuthConfigError(agentDir: string, kind: StartupAuthConfigErrorKind): StartupAuthConfigError {
89
+ export function startupAuthConfigError(agentDir: string, kind: StartupAuthConfigErrorKind): StartupAuthConfigError {
82
90
  return new StartupAuthConfigError(kind, configPathForAgentDir(agentDir));
83
91
  }
84
92
 
@@ -100,6 +108,69 @@ export function isValidPersistedCredentialSelector(value: string): boolean {
100
108
  return parsePersistedCredentialSelector(value) !== undefined;
101
109
  }
102
110
 
111
+ /** JSON-schema pattern for a preference account; shared with {@link parsePreferenceAccount}. */
112
+ export const PREFERENCE_ACCOUNT_PATTERN = "^(email:[^@\\s]+@[^@\\s]+|account:\\S+|[^@\\s:]+@[^@\\s]+)$";
113
+
114
+ /**
115
+ * Parse one `auth.credentialPreferences` account: a bare email, `email:<address>`, or `account:<id>`. Numeric row ids
116
+ * are excluded: they are bound to one credential store, and a preference has no store-identity binding to detect a
117
+ * retargeted row.
118
+ */
119
+ export function parsePreferenceAccount(value: string): AuthCredentialSelector | undefined {
120
+ const trimmed = value.trim();
121
+ const selector = parsePersistedCredentialSelector(trimmed);
122
+ if (selector) return selector.kind === "id" ? undefined : selector;
123
+ return /^[^@\s:]+@[^@\s]+$/.test(trimmed) ? { kind: "email", value: trimmed } : undefined;
124
+ }
125
+
126
+ /** A preference cap accepts (0, 100]; 100 moves past the account only once it is blocked. */
127
+ export function isValidCredentialPreferenceCap(value: unknown): value is number {
128
+ return typeof value === "number" && Number.isFinite(value) && value > 0 && value <= 100;
129
+ }
130
+
131
+ /** Canonical config text for one preference entry: the account, or a one-key `{ account: cap }` mapping. */
132
+ export type CredentialPreferenceEntryValue = string | Readonly<Record<string, number>>;
133
+
134
+ function parseCredentialPreferenceEntry(value: unknown): ConfiguredCredentialPreference | undefined {
135
+ if (typeof value === "string") {
136
+ const selector = parsePreferenceAccount(value);
137
+ return selector ? { selector } : undefined;
138
+ }
139
+ const entry = asRecord(value);
140
+ const fields = entry ? Object.entries(entry) : [];
141
+ if (fields.length !== 1) return undefined;
142
+ const [account, cap] = fields[0]!;
143
+ const selector = parsePreferenceAccount(account);
144
+ if (!selector || !isValidCredentialPreferenceCap(cap)) return undefined;
145
+ return cap === 100 ? { selector } : { selector, maxUsedFraction: cap / 100 };
146
+ }
147
+
148
+ function preferenceSelectorKey(selector: AuthCredentialSelector): string {
149
+ return `${selector.kind}:${selector.kind === "email" ? selector.value.toLowerCase() : selector.value}`;
150
+ }
151
+
152
+ /**
153
+ * Parse one provider's `auth.credentialPreferences` value: a single account, or a non-empty ordered list whose
154
+ * entries are an account (`me@example.com`) or an account with a usage cap in percent (`me@example.com: 75`).
155
+ * Returns undefined for any invalid value, including an account listed twice, so a typo fails closed instead of
156
+ * silently changing the order.
157
+ */
158
+ export function parseCredentialPreferenceList(value: unknown): ConfiguredCredentialPreference[] | undefined {
159
+ const rawEntries = Array.isArray(value) ? value : [value];
160
+ if (rawEntries.length === 0) return undefined;
161
+ const entries: ConfiguredCredentialPreference[] = [];
162
+ const seen = new Set<string>();
163
+ for (const rawEntry of rawEntries) {
164
+ const entry = parseCredentialPreferenceEntry(rawEntry);
165
+ if (!entry) return undefined;
166
+ const key = preferenceSelectorKey(entry.selector);
167
+ if (seen.has(key)) return undefined;
168
+ seen.add(key);
169
+ entries.push(entry);
170
+ }
171
+ return entries;
172
+ }
173
+
103
174
  function asRecord(value: unknown): Record<string, unknown> | undefined {
104
175
  return value !== null && typeof value === "object" && !Array.isArray(value)
105
176
  ? (value as Record<string, unknown>)
@@ -145,6 +216,10 @@ function throwLegacyLiteralKeyError(keys: string[]): never {
145
216
  " credentialRankingMode: balanced\n" +
146
217
  " credentialPins:\n" +
147
218
  " <provider>: id:<positive-id>\n" +
219
+ " credentialPreferences:\n" +
220
+ " <provider>:\n" +
221
+ " - <email>\n" +
222
+ " - <email>: <max used percent, above 0 and at most 100>\n" +
148
223
  "Do not copy secret values into command output; rewrite the file by hand.",
149
224
  );
150
225
  }
@@ -172,23 +247,53 @@ function readCredentialPins(
172
247
  return result;
173
248
  }
174
249
 
250
+ function readCredentialPreferences(
251
+ auth: Record<string, unknown> | undefined,
252
+ configPath: string,
253
+ ): Readonly<Record<string, readonly ConfiguredCredentialPreference[]>> {
254
+ const rawPreferences = auth?.credentialPreferences;
255
+ if (rawPreferences === undefined) return {};
256
+ const preferences = asRecord(rawPreferences);
257
+ if (!preferences) throw new StartupAuthConfigError("invalid-credential-preferences", configPath);
258
+
259
+ const result: Record<string, readonly ConfiguredCredentialPreference[]> = {};
260
+ for (const [provider, rawList] of Object.entries(preferences)) {
261
+ const normalizedProvider = provider.trim();
262
+ if (normalizedProvider.length === 0 || /[\u0000-\u001f\u007f]/u.test(normalizedProvider)) {
263
+ throw new StartupAuthConfigError("invalid-credential-preferences", configPath);
264
+ }
265
+ const entries = parseCredentialPreferenceList(rawList);
266
+ if (!entries) throw new StartupAuthConfigError("invalid-credential-preferences", configPath);
267
+ result[normalizedProvider] = entries;
268
+ }
269
+ return result;
270
+ }
271
+
175
272
  interface GlobalStartupAuthYaml {
176
273
  auth: Record<string, unknown> | undefined;
177
274
  credentialPinStoreIdentity: string | undefined;
178
275
  credentialPins: Readonly<Record<string, string>>;
276
+ credentialPreferences: Readonly<Record<string, readonly ConfiguredCredentialPreference[]>>;
179
277
  }
180
278
 
279
+ const EMPTY_GLOBAL_STARTUP_AUTH: GlobalStartupAuthYaml = {
280
+ auth: undefined,
281
+ credentialPinStoreIdentity: undefined,
282
+ credentialPins: {},
283
+ credentialPreferences: {},
284
+ };
285
+
181
286
  async function readGlobalStartupAuthYaml(agentDir: string): Promise<GlobalStartupAuthYaml> {
182
287
  const configPath = configPathForAgentDir(agentDir);
183
288
  let raw: string;
184
289
  try {
185
290
  raw = await Bun.file(configPath).text();
186
291
  } catch (error) {
187
- if (isEnoent(error)) return { auth: undefined, credentialPinStoreIdentity: undefined, credentialPins: {} };
292
+ if (isEnoent(error)) return EMPTY_GLOBAL_STARTUP_AUTH;
188
293
  logger.warn("startup auth config.yml unreadable", { path: shortenConfigPath(configPath), cause: "unreadable" });
189
294
  throw startupAuthConfigError(agentDir, "unreadable");
190
295
  }
191
- if (raw.trim() === "") return { auth: undefined, credentialPinStoreIdentity: undefined, credentialPins: {} };
296
+ if (raw.trim() === "") return EMPTY_GLOBAL_STARTUP_AUTH;
192
297
 
193
298
  let parsed: unknown;
194
299
  try {
@@ -211,7 +316,7 @@ async function readGlobalStartupAuthYaml(agentDir: string): Promise<GlobalStartu
211
316
  const legacyKeys = Object.keys(root).filter(key => LEGACY_LITERAL_AUTH_KEYS.has(key));
212
317
  if (legacyKeys.length > 0) throwLegacyLiteralKeyError(legacyKeys);
213
318
 
214
- if (root.auth === undefined) return { auth: undefined, credentialPinStoreIdentity: undefined, credentialPins: {} };
319
+ if (root.auth === undefined) return EMPTY_GLOBAL_STARTUP_AUTH;
215
320
  const auth = asRecord(root.auth);
216
321
  if (!auth) throw startupAuthConfigError(agentDir, "invalid-auth");
217
322
 
@@ -234,12 +339,13 @@ async function readGlobalStartupAuthYaml(agentDir: string): Promise<GlobalStartu
234
339
  throw startupAuthConfigError(agentDir, "invalid-ranking-mode");
235
340
  }
236
341
  const credentialPins = readCredentialPins(auth, configPath);
342
+ const credentialPreferences = readCredentialPreferences(auth, configPath);
237
343
  const rawCredentialPinStoreIdentity = auth.credentialPinStoreIdentity;
238
344
  const credentialPinStoreIdentity =
239
345
  typeof rawCredentialPinStoreIdentity === "string" && rawCredentialPinStoreIdentity.trim().length > 0
240
346
  ? rawCredentialPinStoreIdentity.trim()
241
347
  : undefined;
242
- return { auth, credentialPinStoreIdentity, credentialPins };
348
+ return { auth, credentialPinStoreIdentity, credentialPins, credentialPreferences };
243
349
  }
244
350
 
245
351
  async function readTokenFile(): Promise<string | undefined> {
@@ -253,7 +359,8 @@ async function readTokenFile(): Promise<string | undefined> {
253
359
  * cannot influence credential selection.
254
360
  */
255
361
  export async function resolveStartupAuthConfig(agentDir: string = getAgentDir()): Promise<StartupAuthConfigSnapshot> {
256
- const { auth, credentialPinStoreIdentity, credentialPins } = await readGlobalStartupAuthYaml(agentDir);
362
+ const { auth, credentialPinStoreIdentity, credentialPins, credentialPreferences } =
363
+ await readGlobalStartupAuthYaml(agentDir);
257
364
  const broker = asRecord(auth?.broker);
258
365
  const envUrl = $credentialEnv("GJC_AUTH_BROKER_URL")?.trim();
259
366
  const envToken = $credentialEnv("GJC_AUTH_BROKER_TOKEN")?.trim();
@@ -293,5 +400,6 @@ export async function resolveStartupAuthConfig(agentDir: string = getAgentDir())
293
400
  ...(credentialPinStoreIdentity ? { credentialPinStoreIdentity } : {}),
294
401
  credentialRankingMode: rankingMode,
295
402
  credentialPins,
403
+ credentialPreferences,
296
404
  };
297
405
  }