@gajae-code/coding-agent 0.18.10 → 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 +295 -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
@@ -0,0 +1,40 @@
1
+ import { logger } from "@gajae-code/utils";
2
+ import type { SessionManager } from "../session/session-manager";
3
+ import { DEFAULT_ARTIFACT_MAX_BYTES, truncateHeadBytes } from "../session/streaming-output";
4
+
5
+ const ASYNC_INLINE_RESULT_MAX_CHARS = 12_000;
6
+ const ASYNC_PREVIEW_MAX_CHARS = 4_000;
7
+
8
+ export async function formatAsyncResultForFollowUp(
9
+ sessionManager: SessionManager,
10
+ result: string,
11
+ allowArtifact = true,
12
+ ): Promise<string> {
13
+ if (result.length <= ASYNC_INLINE_RESULT_MAX_CHARS) return result;
14
+ const preview = `${result.slice(0, ASYNC_PREVIEW_MAX_CHARS)}\n\n[Output truncated. Showing first ${ASYNC_PREVIEW_MAX_CHARS.toLocaleString()} characters.]`;
15
+ // Denied owned deliveries must not leave unreferenced output artifacts.
16
+ if (!allowArtifact) return preview;
17
+ try {
18
+ const artifactId = await sessionManager.saveArtifact(result, "async");
19
+ if (artifactId) {
20
+ const bytes = Buffer.byteLength(result, "utf8");
21
+ const omittedBytes =
22
+ bytes > DEFAULT_ARTIFACT_MAX_BYTES
23
+ ? bytes - truncateHeadBytes(result, DEFAULT_ARTIFACT_MAX_BYTES).bytes
24
+ : 0;
25
+ const notice = omittedBytes
26
+ ? `Saved completion output is incomplete; omitted ${omittedBytes} UTF-8 bytes`
27
+ : "Saved completion output";
28
+ return `${preview}\n${notice}: artifact://${artifactId}`;
29
+ }
30
+ } catch (error) {
31
+ try {
32
+ logger.warn("Failed to persist async follow-up artifact", {
33
+ error: error instanceof Error ? error.message : String(error),
34
+ });
35
+ } catch {
36
+ // Diagnostics must not discard a completed job's preview.
37
+ }
38
+ }
39
+ return `${preview}\n[Completion output storage unavailable; only the preview is available here.]`;
40
+ }
@@ -13,6 +13,7 @@
13
13
  */
14
14
  import * as fs from "node:fs";
15
15
  import * as path from "node:path";
16
+ import { isEnoent } from "@gajae-code/utils";
16
17
  import * as git from "../utils/git";
17
18
  import { HARNESS_FILENAME, normalizePathSpec } from "./harness";
18
19
 
@@ -49,10 +50,16 @@ export function getCurrentAutoresearchBranch(workDir: string): Promise<string |
49
50
  * Mission state under `.gjc/**` is intentionally NOT listed here: it is
50
51
  * runtime-owned and only the sanctioned `gjc autoresearch` CLI writes it.
51
52
  */
52
- export function isAutoresearchAuthorizedResearchPath(_cwd: string, rawPath: string): boolean {
53
+ export function isAutoresearchAuthorizedResearchPath(cwd: string, rawPath: string): boolean {
53
54
  const normalized = normalizePathSpec(rawPath);
54
55
  if (normalized === ".") return false;
55
- return normalized === HARNESS_FILENAME;
56
+ if (normalized !== HARNESS_FILENAME) return false;
57
+ try {
58
+ // Writes follow an existing harness symlink into its referent.
59
+ return !fs.lstatSync(path.join(cwd, HARNESS_FILENAME)).isSymbolicLink();
60
+ } catch (error) {
61
+ return isEnoent(error);
62
+ }
56
63
  }
57
64
 
58
65
  /**
@@ -32,7 +32,7 @@ Do not edit product source, manifests, or dependencies. The harness must use exi
32
32
  ### Steps
33
33
 
34
34
  1. Inspect the target. Read source, identify what to measure, decide on the workload.
35
- 2. Write `autoresearch.sh` only when it is an explicitly approved research artifact; otherwise use an existing benchmark command.
35
+ 2. Write or edit `autoresearch.sh` with a direct file tool only when it is an explicitly approved research artifact; do not use Bash, Python, or eval to change the harness. Otherwise use an existing benchmark command.
36
36
  3. Validate it: invoke `bash autoresearch.sh` through the regular `bash` tool. Confirm it exits 0 and emits at least one `METRIC` line. Iterate on the harness until it does.
37
37
  4. Start the mission with `gjc autoresearch` (cold intake: goal, constraints, deliverables, and an explicit mode) or `gjc autoresearch --spec <spec>` (handoff intake). Record the primary metric name and its direction (lower/higher is better) with the mission. The harness baseline is snapshotted at mission start and Phase 2 (the iteration loop) begins.
38
38
 
@@ -9,8 +9,8 @@ const dirCache = new Map<string, fs.Dirent[]>();
9
9
  export type ReadScope = "project" | "user" | "native";
10
10
 
11
11
  export interface FileIdentity {
12
- readonly dev: number;
13
- readonly ino: number;
12
+ readonly dev: bigint;
13
+ readonly ino: bigint;
14
14
  }
15
15
 
16
16
  export interface ReadFileOptions {
@@ -45,7 +45,7 @@ function isWithinOrEqual(root: string, candidate: string): boolean {
45
45
  async function isHomeIdentityStable(options?: ReadFileOptions): Promise<boolean> {
46
46
  if (!options?.isolatedHome || !options.home || !options.homeIdentity) return true;
47
47
  try {
48
- const current = await fs.promises.stat(options.home);
48
+ const current = await fs.promises.stat(options.home, { bigint: true });
49
49
  return current.dev === options.homeIdentity.dev && current.ino === options.homeIdentity.ino;
50
50
  } catch {
51
51
  return false;
@@ -69,7 +69,7 @@ async function isUserAgentIdentityStable(options?: ReadFileOptions): Promise<boo
69
69
  }
70
70
  }
71
71
  try {
72
- const current = await fs.promises.stat(options.userAgentDir);
72
+ const current = await fs.promises.stat(options.userAgentDir, { bigint: true });
73
73
  return current.dev === options.userAgentIdentity.dev && current.ino === options.userAgentIdentity.ino;
74
74
  } catch {
75
75
  return false;
@@ -79,7 +79,7 @@ async function isUserAgentIdentityStable(options?: ReadFileOptions): Promise<boo
79
79
  async function isContainmentRootIdentityStable(options?: ReadFileOptions): Promise<boolean> {
80
80
  if (!options?.isolatedHome || !options.containmentRoot || !options.containmentRootIdentity) return true;
81
81
  try {
82
- const current = await fs.promises.stat(options.containmentRoot);
82
+ const current = await fs.promises.stat(options.containmentRoot, { bigint: true });
83
83
  return sameFileIdentity(current, options.containmentRootIdentity);
84
84
  } catch {
85
85
  return false;
@@ -152,8 +152,8 @@ async function isCurrentIsolatedPath(abs: string, options?: ReadFileOptions): Pr
152
152
  }
153
153
  }
154
154
 
155
- function isSingleLinkRegularFile(stat: fs.Stats): boolean {
156
- return stat.isFile() && !stat.isSymbolicLink() && stat.nlink === 1;
155
+ function isSingleLinkRegularFile(stat: fs.Stats | fs.BigIntStats): boolean {
156
+ return stat.isFile() && !stat.isSymbolicLink() && (stat.nlink === 1n || stat.nlink === 1);
157
157
  }
158
158
 
159
159
  /** Reject hard-linked executable or configuration files in isolated discovery. */
@@ -165,8 +165,16 @@ export async function isSingleLinkRegularFileAt(filePath: string): Promise<boole
165
165
  }
166
166
  }
167
167
 
168
- function sameFileIdentity(left: Pick<fs.Stats, "dev" | "ino">, right: Pick<fs.Stats, "dev" | "ino">): boolean {
169
- return left.dev === right.dev && left.ino === right.ino;
168
+ function sameFileIdentity(
169
+ left: Pick<fs.Stats, "dev" | "ino"> | Pick<fs.BigIntStats, "dev" | "ino">,
170
+ right: Pick<fs.Stats, "dev" | "ino"> | Pick<fs.BigIntStats, "dev" | "ino"> | FileIdentity,
171
+ ): boolean {
172
+ // Convert to bigint if needed for comparison
173
+ const leftDev = typeof left.dev === "bigint" ? left.dev : BigInt(left.dev);
174
+ const leftIno = typeof left.ino === "bigint" ? left.ino : BigInt(left.ino);
175
+ const rightDev = typeof right.dev === "bigint" ? right.dev : BigInt(right.dev);
176
+ const rightIno = typeof right.ino === "bigint" ? right.ino : BigInt(right.ino);
177
+ return leftDev === rightDev && leftIno === rightIno;
170
178
  }
171
179
 
172
180
  /**
@@ -174,13 +182,13 @@ function sameFileIdentity(left: Pick<fs.Stats, "dev" | "ino">, right: Pick<fs.St
174
182
  * Stat first and canonicalize second so a parent replacement in either seam
175
183
  * is detected by the descriptor identity check below.
176
184
  */
177
- async function statAuthorizedPath(abs: string, options?: ReadFileOptions): Promise<fs.Stats | null> {
185
+ async function statAuthorizedPath(abs: string, options?: ReadFileOptions): Promise<fs.Stats | fs.BigIntStats | null> {
178
186
  if (!options?.isolatedHome) return null;
179
187
  if (!(await isHomeIdentityStable(options))) return null;
180
188
  if (!(await isUserAgentIdentityStable(options))) return null;
181
- let authorized: fs.Stats;
189
+ let authorized: fs.Stats | fs.BigIntStats;
182
190
  try {
183
- authorized = await fs.promises.stat(abs);
191
+ authorized = await fs.promises.stat(abs, { bigint: true });
184
192
  } catch {
185
193
  return null;
186
194
  }
@@ -191,18 +199,18 @@ async function validateOpenedPath(
191
199
  handle: fsPromises.FileHandle,
192
200
  abs: string,
193
201
  options: ReadFileOptions | undefined,
194
- authorized: fs.Stats | null,
202
+ authorized: fs.Stats | fs.BigIntStats | null,
195
203
  kind: "file" | "directory",
196
204
  ): Promise<boolean> {
197
205
  if (!options?.isolatedHome) return true;
198
206
  if (!(await isHomeIdentityStable(options))) return false;
199
207
  if (!(await isUserAgentIdentityStable(options))) return false;
200
208
  try {
201
- const opened = await handle.stat();
209
+ const opened = await handle.stat({ bigint: true });
202
210
  const validKind =
203
211
  kind === "file" ? isSingleLinkRegularFile(opened) : opened.isDirectory() && !opened.isSymbolicLink();
204
212
  if (!validKind || authorized === null || !sameFileIdentity(opened, authorized)) return false;
205
- const current = await fs.promises.stat(abs);
213
+ const current = await fs.promises.stat(abs, { bigint: true });
206
214
  if (!sameFileIdentity(opened, current)) return false;
207
215
  return await isCurrentIsolatedPath(abs, options);
208
216
  } catch {
@@ -212,7 +220,7 @@ async function validateOpenedPath(
212
220
 
213
221
  interface OpenedPath {
214
222
  handle: fsPromises.FileHandle;
215
- authorized: fs.Stats | null;
223
+ authorized: fs.Stats | fs.BigIntStats | null;
216
224
  }
217
225
 
218
226
  async function openIsolatedFile(abs: string, options?: ReadFileOptions): Promise<OpenedPath | null> {
@@ -279,7 +287,7 @@ export async function readFile(filePath: string, options?: ReadFileOptions): Pro
279
287
  /** Capture a stable physical identity for an authorized containment root. */
280
288
  export async function capturePathIdentity(filePath: string): Promise<FileIdentity | null> {
281
289
  try {
282
- const stat = await fs.promises.stat(path.resolve(filePath));
290
+ const stat = await fs.promises.stat(path.resolve(filePath), { bigint: true });
283
291
  return { dev: stat.dev, ino: stat.ino };
284
292
  } catch {
285
293
  return null;
@@ -342,14 +350,14 @@ export async function readFileSize(filePath: string, options?: ReadFileOptions):
342
350
  async function validateDirectoryPath(
343
351
  abs: string,
344
352
  options: ReadFileOptions | undefined,
345
- authorized: fs.Stats | null,
353
+ authorized: fs.Stats | fs.BigIntStats | null,
346
354
  ): Promise<boolean> {
347
355
  if (!options?.isolatedHome) return true;
348
356
  if (authorized === null) return false;
349
357
  if (!(await isHomeIdentityStable(options))) return false;
350
358
  if (!(await isUserAgentIdentityStable(options))) return false;
351
359
  try {
352
- const current = await fs.promises.stat(abs);
360
+ const current = await fs.promises.stat(abs, { bigint: true });
353
361
  return (
354
362
  current.isDirectory() && sameFileIdentity(current, authorized) && (await isCurrentIsolatedPath(abs, options))
355
363
  );
@@ -482,7 +482,7 @@ export async function loadCapabilityForHome<T>(
482
482
  // read another profile's SYSTEM/RULES/AGENTS, skills, commands, hooks,
483
483
  // settings, or executable descriptors.
484
484
  const canonicalHome = await canonicalizeThroughExistingAncestor(resolvedHome);
485
- const homeStats = await fs.stat(canonicalHome);
485
+ const homeStats = await fs.stat(canonicalHome, { bigint: true });
486
486
  const homeIdentity = { dev: homeStats.dev, ino: homeStats.ino };
487
487
  const trustedHome = await canonicalizeThroughExistingAncestor(getTrustedHomeDir());
488
488
  const isolatedHome = canonicalHome !== trustedHome;
@@ -510,7 +510,7 @@ export async function loadCapabilityForHome<T>(
510
510
  "user agent directory",
511
511
  )
512
512
  : path.join(canonicalHome, getConfigDirName(), "agent");
513
- const userAgentStats = await fs.stat(userAgentDir).catch(() => null);
513
+ const userAgentStats = await fs.stat(userAgentDir, { bigint: true }).catch(() => null);
514
514
  const userAgentIdentity = userAgentStats ? { dev: userAgentStats.dev, ino: userAgentStats.ino } : null;
515
515
  await assertExplicitHomeRoots(canonicalHome, cwd, Boolean(options.agentDir), effectiveProviderIds, isolatedHome);
516
516
  const repoRootCandidate = await findRepoRoot(cwd, canonicalHome, {
@@ -14,6 +14,7 @@ import {
14
14
  OAuthCredentialSelectorError,
15
15
  type OAuthPinTarget,
16
16
  resolveOAuthStorageProvider,
17
+ selectOrganizationMatches,
17
18
  } from "@gajae-code/ai/core";
18
19
  import { getAgentDir } from "@gajae-code/utils";
19
20
  import { ModelRegistry } from "../config/model-registry";
@@ -25,15 +26,21 @@ import {
25
26
  clearPersistentPinForRemovedRows,
26
27
  } from "../session/account-inventory";
27
28
  import { CREDENTIAL_STORE_UNREADABLE_MESSAGE } from "../session/credential-store-errors";
28
- import { resolveStartupAuthConfig, type StartupAuthConfigSnapshot } from "../session/startup-auth-config";
29
+ import {
30
+ type CredentialPreferenceEntryValue,
31
+ isValidCredentialPreferenceCap,
32
+ parsePreferenceAccount,
33
+ resolveStartupAuthConfig,
34
+ type StartupAuthConfigSnapshot,
35
+ } from "../session/startup-auth-config";
29
36
 
30
- export const ACCOUNTS_ACTIONS = ["list", "check", "pin", "logout"] as const;
37
+ export const ACCOUNTS_ACTIONS = ["list", "check", "pin", "prefer", "logout"] as const;
31
38
  export type AccountsAction = (typeof ACCOUNTS_ACTIONS)[number];
32
39
 
33
40
  export interface AccountsCommandArgs {
34
41
  action: AccountsAction;
35
42
  provider?: string;
36
- selector?: string;
43
+ selectors?: readonly string[];
37
44
  flags: {
38
45
  json?: boolean;
39
46
  persistent?: boolean;
@@ -92,9 +99,10 @@ function selectorFromInput(raw: string | undefined): AuthCredentialSelector {
92
99
  if (/^id:[1-9]\d*$/.test(value)) return { kind: "id", value: value.slice(3) };
93
100
  if (/^email:[^@\s]+@[^@\s]+$/.test(value)) return { kind: "email", value: value.slice(6) };
94
101
  if (/^account:\S+$/.test(value)) return { kind: "account", value: value.slice(8) };
102
+ if (/^org:\S/.test(value)) return { kind: "org", value: value.slice(4).trim() };
95
103
  if (/^[^@\s]+@[^@\s]+$/.test(value)) return { kind: "email", value };
96
104
  throw new AccountsCommandError(
97
- "Pin target must be a bare email or an id:<row-id>, email:<email>, or account:<account-id> selector.",
105
+ "Pin target must be a bare email or an id:<row-id>, email:<email>, account:<account-id>, or org:<organization id or name> selector.",
98
106
  );
99
107
  }
100
108
 
@@ -286,15 +294,171 @@ async function runPin(
286
294
  });
287
295
  }
288
296
 
297
+ /**
298
+ * Split `<account>=<percent>` into the account and its usage cap. The cap follows the whole account, so an `=` before
299
+ * the last `@` belongs to an email local part (`user=2@example.com`), and a suffix that does not start with a digit is
300
+ * part of the account (account ids may end in `=`); a digit-led suffix must be a valid cap.
301
+ */
302
+ function parsePreferArgument(raw: string): { account: string; cap: number | undefined } {
303
+ const separator = raw.lastIndexOf("=");
304
+ const rawCap = separator < 0 || separator < raw.lastIndexOf("@") ? "" : raw.slice(separator + 1).trim();
305
+ if (!/^\d/.test(rawCap)) return { account: raw.trim(), cap: undefined };
306
+ const cap = /^\d+(\.\d+)?$/.test(rawCap) ? Number(rawCap) : Number.NaN;
307
+ if (!isValidCredentialPreferenceCap(cap)) {
308
+ throw new AccountsCommandError(
309
+ `Usage cap in ${JSON.stringify(raw)} must be a percent above 0 and at most 100, e.g. me@example.com=75; no preference was written.`,
310
+ );
311
+ }
312
+ return { account: raw.slice(0, separator).trim(), cap };
313
+ }
314
+
315
+ /**
316
+ * Portable config text for a resolved OAuth row: its email, else its account id, whichever no other OAuth row of the
317
+ * provider shares. Numeric row ids are never persisted as preferences; a row with neither unique value is refused.
318
+ * Each candidate text is written only when {@link parsePreferenceAccount} reads it back as the same selector, so a
319
+ * successful write neither fails the next startup nor retargets the row: the email is written bare when that
320
+ * round-trips, else as `email:<address>` (an address such as `account:team@example.com` would otherwise parse as an
321
+ * account id, and `email:team@example.com` would lose its prefix).
322
+ */
323
+ function portablePreferenceAccount(
324
+ row: CredentialInventoryRecord,
325
+ providerRows: readonly CredentialInventoryRecord[],
326
+ ): string | undefined {
327
+ const oauthRows = providerRows.filter(other => other.credentialKind === "oauth");
328
+ const email = row.email?.trim().toLowerCase();
329
+ if (email && oauthRows.filter(other => other.email?.trim().toLowerCase() === email).length === 1) {
330
+ const selector: AuthCredentialSelector = { kind: "email", value: row.email!.trim() };
331
+ const written = [selector.value, `email:${selector.value}`].find(text => readsBackAs(text, selector));
332
+ if (written) return written;
333
+ }
334
+ const accountId = row.accountId?.trim();
335
+ if (accountId && oauthRows.filter(other => other.accountId?.trim() === accountId).length === 1) {
336
+ const written = `account:${accountId}`;
337
+ if (readsBackAs(written, { kind: "account", value: accountId })) return written;
338
+ }
339
+ return undefined;
340
+ }
341
+
342
+ /** Whether startup parses `text` as exactly `selector`, kind and value. */
343
+ function readsBackAs(text: string, selector: AuthCredentialSelector): boolean {
344
+ const parsed = parsePreferenceAccount(text);
345
+ return parsed?.kind === selector.kind && parsed.value === selector.value;
346
+ }
347
+
348
+ async function writePersistentPreferences(
349
+ provider: string,
350
+ entries: readonly CredentialPreferenceEntryValue[] | undefined,
351
+ ): Promise<void> {
352
+ const settings = await Settings.init();
353
+ await settings.commitAtomicBatchWithCurrent(current => {
354
+ const currentAuth = current.auth === undefined ? {} : asRecord(current.auth);
355
+ const rawPreferences = currentAuth?.credentialPreferences;
356
+ const preferences = rawPreferences === undefined ? {} : asRecord(rawPreferences);
357
+ if (!currentAuth || !preferences) {
358
+ throw new AccountsCommandError("Global auth.credentialPreferences is malformed; no preference was written.");
359
+ }
360
+ const next: Record<string, unknown> = { ...preferences };
361
+ if (entries) next[provider] = entries;
362
+ else delete next[provider];
363
+ return [{ path: "auth.credentialPreferences", op: "set", value: next }];
364
+ });
365
+ }
366
+
367
+ async function runPrefer(
368
+ providerArg: string | undefined,
369
+ accountArgs: readonly string[] | undefined,
370
+ flags: AccountsCommandArgs["flags"],
371
+ ): Promise<void> {
372
+ const provider = canonicalProvider(providerArg);
373
+ const accounts = accountArgs ?? [];
374
+ if (flags.clear && accounts.length > 0) {
375
+ throw new AccountsCommandError("Use either preferred accounts or --clear, not both.");
376
+ }
377
+ if (!flags.clear && accounts.length === 0) {
378
+ throw new AccountsCommandError(
379
+ "Prefer requires one or more accounts in order (e.g. me@example.com shared@example.com=75), or --clear.",
380
+ );
381
+ }
382
+ if (flags.clear) {
383
+ await writePersistentPreferences(provider, undefined);
384
+ if (flags.json) writeJson({ ok: true, provider, cleared: true });
385
+ else writeText([`Cleared preferred accounts for ${provider}; selection uses normal ranking.`]);
386
+ return;
387
+ }
388
+
389
+ const requested = accounts.map(parsePreferArgument);
390
+ await withAuthStorageAndModels(async (storage, modelRegistry, startupAuth) => {
391
+ if (
392
+ !modelRegistry.getConfiguredProviderIds().includes(provider) &&
393
+ modelRegistry.getAll().every(model => model.provider !== provider)
394
+ ) {
395
+ throw new AccountsCommandError(`Provider ${provider} is not configured; no preference was written.`);
396
+ }
397
+ const providerRows = storage.listCredentialInventory(provider);
398
+ const seenIds = new Set<number>();
399
+ const resolved = requested.map(({ account, cap }) => {
400
+ let target: OAuthPinTarget;
401
+ try {
402
+ target = storage.resolveOAuthPinTarget(provider, selectorFromInput(account));
403
+ } catch (error) {
404
+ throw toAccountsCommandError(error) ?? error;
405
+ }
406
+ if (seenIds.has(target.credentialId)) {
407
+ throw new AccountsCommandError(`${account} is listed more than once; no preference was written.`);
408
+ }
409
+ seenIds.add(target.credentialId);
410
+ const row = providerRows.find(candidate => candidate.id === target.credentialId);
411
+ const portable = row ? portablePreferenceAccount(row, providerRows) : undefined;
412
+ if (!portable) {
413
+ throw new AccountsCommandError(
414
+ `${account} has no unique email or account id to store as a preference; no preference was written.`,
415
+ );
416
+ }
417
+ return { account: portable, cap: cap === 100 ? undefined : cap };
418
+ });
419
+ const entries: CredentialPreferenceEntryValue[] = resolved.map(({ account, cap }) =>
420
+ cap === undefined ? account : { [account]: cap },
421
+ );
422
+ await writePersistentPreferences(provider, entries);
423
+ if (flags.json) {
424
+ writeJson({ ok: true, provider, preferences: entries });
425
+ return;
426
+ }
427
+ writeText([
428
+ `Preferred accounts for ${provider}, tried in order:`,
429
+ ...resolved.map(
430
+ ({ account, cap }, index) =>
431
+ ` ${index + 1}. ${account}${cap === undefined ? " (until rate-limited)" : ` (until ${cap}% used)`}`,
432
+ ),
433
+ `Then: remaining accounts by ${startupAuth.credentialRankingMode} ranking. Takes effect for new sessions.`,
434
+ ]);
435
+ });
436
+ }
437
+
289
438
  function accountMatches(row: CredentialInventoryRecord, rawAccount: string): boolean {
290
439
  const value = rawAccount.trim();
291
440
  if (value.length === 0) return false;
292
441
  if (/^[1-9]\d*$/.test(value) && row.id === Number(value)) return true;
293
442
  if (/^id:[1-9]\d*$/i.test(value) && row.id === Number(value.slice(3))) return true;
443
+ // Labels carry the organization name, so a bare email matches every organization of that account.
444
+ if (row.email?.toLowerCase() === value.toLowerCase()) return true;
294
445
  const identity = inventoryIdentity(row);
295
446
  return identity?.toLowerCase() === value.toLowerCase();
296
447
  }
297
448
 
449
+ /**
450
+ * Rows a `--account` value designates. `org:` prefers an exact organization-id match over display-name
451
+ * matches; more than one result is reported as ambiguous by the caller, never resolved by guessing.
452
+ */
453
+ function selectLogoutTargets<T extends { row: CredentialInventoryRecord }>(
454
+ targets: readonly T[],
455
+ rawAccount: string,
456
+ ): T[] {
457
+ const value = rawAccount.trim();
458
+ if (/^org:/i.test(value)) return selectOrganizationMatches(targets, value.slice(4), target => target.row);
459
+ return targets.filter(target => accountMatches(target.row, value));
460
+ }
461
+
298
462
  function removalCandidates(
299
463
  inventory: readonly CredentialInventoryRecord[],
300
464
  targets: readonly CredentialRemovalTarget[],
@@ -335,9 +499,7 @@ async function runLogout(providerArg: string | undefined, flags: AccountsCommand
335
499
  try {
336
500
  const inventory = storage.listCredentialInventory(provider);
337
501
  const targets = removalCandidates(inventory, storage.listCredentialRemovalTargets(provider));
338
- const selected = flags.all
339
- ? targets
340
- : targets.filter(candidate => accountMatches(candidate.row, flags.account ?? ""));
502
+ const selected = flags.all ? targets : selectLogoutTargets(targets, flags.account ?? "");
341
503
  if (selected.length !== 1 && !flags.all) throwLogoutResolutionError(provider, flags.account, targets);
342
504
  if (selected.length === 0) throwLogoutResolutionError(provider, flags.account, targets);
343
505
  const result = storage.removeAuthCredentialsHard(
@@ -383,7 +545,13 @@ export async function runAccountsCommand(cmd: AccountsCommandArgs): Promise<void
383
545
  await runCheck(cmd.provider, cmd.flags);
384
546
  return;
385
547
  case "pin":
386
- await runPin(cmd.provider, cmd.selector, cmd.flags);
548
+ if ((cmd.selectors?.length ?? 0) > 1) {
549
+ throw new AccountsCommandError("Pin takes one selector; use `gjc accounts prefer` for an ordered list.");
550
+ }
551
+ await runPin(cmd.provider, cmd.selectors?.[0], cmd.flags);
552
+ return;
553
+ case "prefer":
554
+ await runPrefer(cmd.provider, cmd.selectors, cmd.flags);
387
555
  return;
388
556
  case "logout":
389
557
  await runLogout(cmd.provider, cmd.flags);
@@ -25,6 +25,7 @@ import {
25
25
  AuthStorage,
26
26
  type CredentialDisabledEvent,
27
27
  DEFAULT_AUTH_BROKER_BIND,
28
+ formatOAuthIdentityLabel,
28
29
  getEnvApiKey,
29
30
  getOAuthProviders,
30
31
  listProvidersWithEnvKey,
@@ -568,19 +569,21 @@ interface MigrateSkip {
568
569
 
569
570
  function credentialIdentity(provider: string, credential: AuthCredential): string {
570
571
  if (credential.type === "api_key") return "(api key)";
571
- return credential.email ?? credential.accountId ?? credential.projectId ?? `<${provider} oauth>`;
572
+ return formatOAuthIdentityLabel(credential) ?? `<${provider} oauth>`;
572
573
  }
573
574
 
574
575
  /**
575
576
  * Build the set of "identities already on the broker" so re-runs are idempotent.
576
- * For OAuth, identity = email|accountId|projectId. For api_key, we collapse
577
- * to a single marker per provider (broker has no concept of "multiple api keys
578
- * per provider with different identities"; upsert would coalesce them).
577
+ * For OAuth, identity = email|accountId|projectId, scoped to the organization
578
+ * when the credential carries one (one account can sign in to several
579
+ * organizations). For api_key, we collapse to a single marker per provider
580
+ * (broker has no concept of "multiple api keys per provider with different
581
+ * identities"; upsert would coalesce them).
579
582
  */
580
583
  function indexBrokerSnapshot(snapshot: {
581
584
  credentials: Array<{
582
585
  provider: string;
583
- credential: { type: string; email?: string; accountId?: string; projectId?: string };
586
+ credential: { type: string; email?: string; accountId?: string; projectId?: string; organizationId?: string };
584
587
  }>;
585
588
  }): Map<string, Set<string>> {
586
589
  const out = new Map<string, Set<string>>();
@@ -589,22 +592,29 @@ function indexBrokerSnapshot(snapshot: {
589
592
  if (entry.credential.type === "api_key") {
590
593
  ids.add("@api_key");
591
594
  } else {
592
- if (entry.credential.email) ids.add(`email:${entry.credential.email}`);
593
- if (entry.credential.accountId) ids.add(`accountId:${entry.credential.accountId}`);
594
- if (entry.credential.projectId) ids.add(`projectId:${entry.credential.projectId}`);
595
+ const org = organizationSuffix(entry.credential.organizationId);
596
+ if (entry.credential.email) ids.add(`email:${entry.credential.email}${org}`);
597
+ if (entry.credential.accountId) ids.add(`accountId:${entry.credential.accountId}${org}`);
598
+ if (entry.credential.projectId) ids.add(`projectId:${entry.credential.projectId}${org}`);
595
599
  }
596
600
  out.set(entry.provider, ids);
597
601
  }
598
602
  return out;
599
603
  }
600
604
 
605
+ function organizationSuffix(organizationId: string | undefined): string {
606
+ const trimmed = organizationId?.trim();
607
+ return trimmed ? `|org:${trimmed}` : "";
608
+ }
609
+
601
610
  function brokerAlreadyHas(existing: Map<string, Set<string>>, provider: string, credential: AuthCredential): boolean {
602
611
  const ids = existing.get(provider);
603
612
  if (!ids) return false;
604
613
  if (credential.type === "api_key") return ids.has("@api_key");
605
- if (credential.email && ids.has(`email:${credential.email}`)) return true;
606
- if (credential.accountId && ids.has(`accountId:${credential.accountId}`)) return true;
607
- if (credential.projectId && ids.has(`projectId:${credential.projectId}`)) return true;
614
+ const org = organizationSuffix(credential.organizationId);
615
+ if (credential.email && ids.has(`email:${credential.email}${org}`)) return true;
616
+ if (credential.accountId && ids.has(`accountId:${credential.accountId}${org}`)) return true;
617
+ if (credential.projectId && ids.has(`projectId:${credential.projectId}${org}`)) return true;
608
618
  return false;
609
619
  }
610
620
 
@@ -14,7 +14,7 @@ import { randomUUID } from "node:crypto";
14
14
  import { realpathSync } from "node:fs";
15
15
  import * as fs from "node:fs/promises";
16
16
  import * as path from "node:path";
17
- import { isCompiledBinary, VERSION } from "@gajae-code/utils";
17
+ import { isCompiledBinary, logger, VERSION } from "@gajae-code/utils";
18
18
  import { isUpdateChannel } from "../../config/update-channel";
19
19
  import {
20
20
  activationRecordPath,
@@ -27,8 +27,10 @@ import {
27
27
  snapshotRegularFile,
28
28
  } from "../install-activation";
29
29
  import {
30
+ ActivationRetryCandidateRepointError,
30
31
  acquireBinaryUpdateLock,
31
32
  fetchAndVerifyOfficialPinnedCandidate,
33
+ InstallationAccessDeniedError,
32
34
  isProtectedSourcePathForInstall,
33
35
  replaceBinaryForUpdate,
34
36
  smokeTestPinnedCandidate,
@@ -111,8 +113,10 @@ export interface InstallRepairResult {
111
113
  readonly reason?: string;
112
114
  readonly recordPath?: string;
113
115
  readonly sideEffectStarted: boolean;
114
- /** Present only for a pre-mutation Windows sharing violation (nothing was renamed). */
116
+ /** Native Windows status returned by exact replacement. */
115
117
  readonly windowsErrorCode?: string;
118
+ readonly stagingCleanupWarning?: string;
119
+ readonly retainedPaths?: InstallationAccessDeniedError["retainedPaths"];
116
120
  readonly before?: { readonly identity?: FileIdentity; readonly digest?: string };
117
121
  readonly after?: { readonly identity?: FileIdentity; readonly digest?: string; readonly version?: string };
118
122
  }
@@ -317,17 +321,43 @@ export async function repairStandaloneBinary(
317
321
  };
318
322
  } catch (error) {
319
323
  const pendingActivation = error instanceof Error && error.message === "installation_pending_activation";
324
+ const accessDeniedError = error instanceof InstallationAccessDeniedError ? error : undefined;
325
+ const repointError = error instanceof ActivationRetryCandidateRepointError ? error : undefined;
320
326
  const reason = pendingActivation
321
327
  ? "sharing_violation"
322
- : error instanceof Error && /^[a-z][a-z0-9_]{0,63}$/.test(error.message)
323
- ? error.message
324
- : "installation_execution_unverified";
328
+ : accessDeniedError
329
+ ? accessDeniedError.code
330
+ : repointError
331
+ ? repointError.reasonCode
332
+ : error instanceof Error && /^[a-z][a-z0-9_]{0,63}$/.test(error.message)
333
+ ? error.message
334
+ : "installation_execution_unverified";
335
+ if (accessDeniedError)
336
+ logger.warn("Doctor standalone binary restore failed with access denied", {
337
+ targetPath,
338
+ code: accessDeniedError.code,
339
+ ...(accessDeniedError.windowsErrorCode ? { windowsErrorCode: accessDeniedError.windowsErrorCode } : {}),
340
+ ...(accessDeniedError.stagingCleanupWarning
341
+ ? { stagingCleanupWarning: accessDeniedError.stagingCleanupWarning }
342
+ : {}),
343
+ ...(accessDeniedError.retainedPaths ? { retainedPaths: accessDeniedError.retainedPaths } : {}),
344
+ });
345
+ if (repointError)
346
+ logger.warn("Doctor standalone binary restore retained a retry candidate after record repoint failed", {
347
+ path: repointError.candidatePath,
348
+ reason: repointError.reasonCode,
349
+ });
325
350
  return {
326
351
  state: pendingActivation ? "pending_activation" : started ? "failed" : "blocked",
327
352
  reason,
328
353
  sideEffectStarted: started,
329
354
  before,
330
355
  recordPath,
356
+ ...(accessDeniedError?.windowsErrorCode ? { windowsErrorCode: accessDeniedError.windowsErrorCode } : {}),
357
+ ...(accessDeniedError?.stagingCleanupWarning
358
+ ? { stagingCleanupWarning: accessDeniedError.stagingCleanupWarning }
359
+ : {}),
360
+ ...(accessDeniedError?.retainedPaths ? { retainedPaths: accessDeniedError.retainedPaths } : {}),
331
361
  };
332
362
  } finally {
333
363
  await release();
@@ -8,7 +8,7 @@ export function getExtraHelpText(): string {
8
8
  ${APP_NAME} session - List, inspect, create, remove, or attach sessions
9
9
  ${APP_NAME} state - Inspect or manage persisted GJC state
10
10
  ${APP_NAME} auth-broker - Manage the auth-broker (credential vault)
11
- ${APP_NAME} accounts - List, check, pin, and remove stored OAuth accounts
11
+ ${APP_NAME} accounts - List, check, pin, prefer, and remove stored OAuth accounts
12
12
  ${APP_NAME} models presets - Inspect, refresh, pin, rollback, or disable the signed model preset registry
13
13
  ${APP_NAME} auth-gateway - Run an auth-gateway forward proxy
14
14
  ${APP_NAME} harness - Run harness control-plane commands