@gajae-code/coding-agent 0.17.1 → 0.17.2

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 (53) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/dist/types/async/job-manager.d.ts +7 -6
  3. package/dist/types/config/autorouting-tier-map.d.ts +6 -0
  4. package/dist/types/config/file-lock.d.ts +1 -0
  5. package/dist/types/config/model-profile-activation.d.ts +18 -3
  6. package/dist/types/config/model-registry.d.ts +9 -0
  7. package/dist/types/coordinator-mcp/server.d.ts +3 -0
  8. package/dist/types/modes/acp/acp-agent.d.ts +40 -1
  9. package/dist/types/modes/components/thinking-selector.d.ts +2 -1
  10. package/dist/types/sdk/acp/adapter.d.ts +27 -0
  11. package/dist/types/sdk/acp/final-text.d.ts +8 -0
  12. package/dist/types/sdk/broker/ensure.d.ts +46 -1
  13. package/dist/types/sdk/prompt-deadline-manager.d.ts +1 -1
  14. package/dist/types/sdk/prompt-failure.d.ts +30 -1
  15. package/dist/types/sdk/prompt-status.d.ts +8 -0
  16. package/dist/types/sdk/router/replay-retention-gap-cache.d.ts +13 -0
  17. package/dist/types/sdk/transport/serve-cli.d.ts +27 -0
  18. package/dist/types/thinking.d.ts +3 -1
  19. package/dist/types/utils/mupdf-wasm-embedded.d.ts +2 -0
  20. package/package.json +7 -7
  21. package/src/async/job-manager.ts +38 -8
  22. package/src/commands/sdk.ts +78 -4
  23. package/src/config/autorouting-tier-map.ts +2 -0
  24. package/src/config/file-lock.ts +52 -17
  25. package/src/config/model-profile-activation.ts +72 -10
  26. package/src/config/model-registry.ts +21 -0
  27. package/src/coordinator-mcp/server.ts +21 -6
  28. package/src/debug/report-bundle.ts +114 -24
  29. package/src/gjc-runtime/managed-owner-admission.ts +67 -50
  30. package/src/internal-urls/docs-index.generated.ts +7 -6
  31. package/src/modes/acp/acp-agent.ts +187 -14
  32. package/src/modes/acp/acp-event-mapper.ts +9 -0
  33. package/src/modes/components/model-selector.ts +31 -15
  34. package/src/modes/components/status-line/segments.ts +6 -1
  35. package/src/modes/components/thinking-selector.ts +7 -3
  36. package/src/modes/controllers/selector-controller.ts +16 -3
  37. package/src/sdk/acp/adapter.ts +63 -1
  38. package/src/sdk/acp/final-text.ts +19 -2
  39. package/src/sdk/broker/ensure.ts +97 -19
  40. package/src/sdk/bus/index.ts +31 -4
  41. package/src/sdk/host/session-runtime.test.ts +498 -7
  42. package/src/sdk/host/session-runtime.ts +188 -21
  43. package/src/sdk/prompt-deadline-manager.ts +3 -3
  44. package/src/sdk/prompt-failure.ts +43 -1
  45. package/src/sdk/prompt-status.ts +8 -0
  46. package/src/sdk/router/replay-retention-gap-cache.ts +27 -0
  47. package/src/sdk/router/session-router.ts +22 -3
  48. package/src/sdk/session.ts +12 -9
  49. package/src/sdk/transport/serve-cli.ts +105 -15
  50. package/src/session-import/codex.ts +12 -3
  51. package/src/thinking.ts +21 -2
  52. package/src/utils/mupdf-wasm-embedded.ts +3 -0
  53. package/src/utils/mupdf-wasm.ts +24 -2
@@ -569,14 +569,22 @@ export class AsyncJobManager {
569
569
  * instead of the last-created process-global instance (review thread P1).
570
570
  * Returns TRUE when the mapping was installed (or already held by this
571
571
  * manager); returns FALSE when the endpoint id is already held by a
572
- * FOREIGN live manager — a second top-level session constructed or
573
- * resumed under that endpoint must then fail construction instead of
574
- * silently replacing the first manager, which would make the first
575
- * session's tools resolve the second manager and let same-id jobs be
576
- * queried, registered, or cancelled across sessions (review thread P1). */
572
+ * FOREIGN manager whose authority has not fully settled — a second
573
+ * top-level session constructed or resumed under that endpoint must then
574
+ * fail construction instead of silently replacing the first manager, which
575
+ * would make the first session's tools resolve the second manager and let
576
+ * same-id jobs be queried, registered, or cancelled across sessions
577
+ * (review thread P1). */
577
578
  static registerForEndpoint(endpointId: string, manager: AsyncJobManager): boolean {
578
579
  const holder = AsyncJobManager.#byEndpoint.get(endpointId);
579
- if (holder !== undefined && holder !== manager) return false;
580
+ if (manager.#disposed || manager.#registrationClosed) return false;
581
+ if (holder !== undefined && holder !== manager) {
582
+ // A manager remains the endpoint owner until all authority retained
583
+ // past its disposal deadline has physically settled. Logical shutdown
584
+ // alone must not admit a replacement while late callbacks can still run.
585
+ if (!holder.#disposed || !holder.#disposalSettled) return false;
586
+ AsyncJobManager.#byEndpoint.delete(endpointId);
587
+ }
580
588
  AsyncJobManager.#byEndpoint.set(endpointId, manager);
581
589
  return true;
582
590
  }
@@ -586,7 +594,8 @@ export class AsyncJobManager {
586
594
  return AsyncJobManager.#byEndpoint.get(endpointId);
587
595
  }
588
596
 
589
- static unregisterForEndpoint(endpointId: string): void {
597
+ static unregisterForEndpoint(endpointId: string, manager?: AsyncJobManager): void {
598
+ if (manager !== undefined && AsyncJobManager.#byEndpoint.get(endpointId) !== manager) return;
590
599
  AsyncJobManager.#byEndpoint.delete(endpointId);
591
600
  }
592
601
 
@@ -619,9 +628,13 @@ export class AsyncJobManager {
619
628
  manager: AsyncJobManager | undefined,
620
629
  ): boolean {
621
630
  if (!manager || predecessorEndpointId === successorEndpointId) return true;
631
+ if (manager.#disposed || manager.#registrationClosed) return false;
622
632
  if (AsyncJobManager.#byEndpoint.get(predecessorEndpointId) !== manager) return true;
623
633
  const successorOwner = AsyncJobManager.#byEndpoint.get(successorEndpointId);
624
- if (successorOwner !== undefined && successorOwner !== manager) return false;
634
+ if (successorOwner !== undefined && successorOwner !== manager) {
635
+ if (!successorOwner.#disposed || !successorOwner.#disposalSettled) return false;
636
+ AsyncJobManager.#byEndpoint.delete(successorEndpointId);
637
+ }
625
638
  AsyncJobManager.#byEndpoint.delete(predecessorEndpointId);
626
639
  AsyncJobManager.#byEndpoint.set(successorEndpointId, manager);
627
640
  return true;
@@ -651,6 +664,7 @@ export class AsyncJobManager {
651
664
 
652
665
  readonly #jobs = new Map<string, AsyncJob>();
653
666
  #retainedDisposalCompletion: Promise<void> = Promise.resolve();
667
+ #disposePromise: Promise<boolean> | undefined;
654
668
  readonly #deliveries: AsyncJobDelivery[] = [];
655
669
  readonly #inFlightDeliveries: AsyncJobDelivery[] = [];
656
670
  readonly #suppressedDeliveries = new Set<string>();
@@ -674,6 +688,8 @@ export class AsyncJobManager {
674
688
  readonly #maxDeadLetterOverflowOwners: number;
675
689
  #deliveryLoop: Promise<void> | undefined;
676
690
  #disposed = false;
691
+ /** Set only after all authority retained past a disposal deadline settles. */
692
+ #disposalSettled = false;
677
693
  #runningOwnerCleanups = false;
678
694
  readonly #subagentRecords = new Map<string, SubagentRecord>();
679
695
  readonly #terminalEvents = new Map<string, TerminalEvent>();
@@ -2773,6 +2789,13 @@ export class AsyncJobManager {
2773
2789
  }
2774
2790
 
2775
2791
  async dispose(options?: { timeoutMs?: number }): Promise<boolean> {
2792
+ if (this.#disposePromise) return this.#disposePromise;
2793
+ const disposal = this.#disposeInternal(options);
2794
+ this.#disposePromise = disposal;
2795
+ return disposal;
2796
+ }
2797
+
2798
+ async #disposeInternal(options?: { timeoutMs?: number }): Promise<boolean> {
2776
2799
  // Close registration FIRST, then run owner cleanups, and only then mark the
2777
2800
  // manager disposed. Setting #disposed before the cleanups made
2778
2801
  // #ensureDeliveryLoop a no-op, so a failure an owner cleanup settled through
@@ -2865,6 +2888,13 @@ export class AsyncJobManager {
2865
2888
  this.#notifyChange();
2866
2889
  this.#changeListeners.clear();
2867
2890
  this.#foldListeners.clear();
2891
+ const releaseEndpointOwnership = (): void => {
2892
+ this.#disposalSettled = true;
2893
+ AsyncJobManager.unregisterManager(this);
2894
+ if (AsyncJobManager.instance() === this) AsyncJobManager.setInstance(undefined);
2895
+ };
2896
+ if (disposalCompleted) releaseEndpointOwnership();
2897
+ else void this.#retainedDisposalCompletion.then(releaseEndpointOwnership);
2868
2898
  return disposalCompleted;
2869
2899
  }
2870
2900
 
@@ -1,4 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
+ import * as nodeFs from "node:fs";
2
3
  import * as fs from "node:fs/promises";
3
4
  import * as os from "node:os";
4
5
  import * as path from "node:path";
@@ -13,7 +14,11 @@ import { initTheme } from "../modes/theme/theme";
13
14
  import { ACP_MCP_REQUEST_TIMEOUT_MS, ACP_MCP_STARTUP_HEADROOM_MS } from "../sdk/acp/mcp";
14
15
  import { Broker } from "../sdk/broker/broker";
15
16
  import { readBrokerDiscovery } from "../sdk/broker/discovery";
16
- import { reconcileBrokerGenerationForStartup, withBrokerStartupLock } from "../sdk/broker/ensure";
17
+ import {
18
+ emitBrokerStartupTestSignal,
19
+ reconcileBrokerGenerationForStartup,
20
+ withBrokerStartupLock,
21
+ } from "../sdk/broker/ensure";
17
22
  import { completeBrokerProcess } from "../sdk/broker/internal";
18
23
  import {
19
24
  type LifecycleTranscriptEvidence,
@@ -39,7 +44,7 @@ import {
39
44
  type SdkStartupRollbackResult,
40
45
  SdkStartupRollbackTracker,
41
46
  } from "../sdk/startup-capability";
42
- import { runSdkServe } from "../sdk/transport/serve-cli";
47
+ import { runSdkServe, SdkServeError } from "../sdk/transport/serve-cli";
43
48
  import { isSessionDisposalIncompleteError } from "../session/agent-session";
44
49
  import {
45
50
  type CapturedSessionTranscriptSnapshot,
@@ -94,6 +99,43 @@ export async function lifecycleArgs(
94
99
  export const SESSION_HOST_BROKER_ABSENCE_GRACE_MS = 10 * 60_000;
95
100
  const SESSION_HOST_BROKER_POLL_MS = 15_000;
96
101
 
102
+ async function waitForBrokerStartupTestGate(gateFile: string): Promise<void> {
103
+ if (await Bun.file(gateFile).exists()) return;
104
+ const directory = path.dirname(gateFile);
105
+ const filename = path.basename(gateFile);
106
+ await new Promise<void>((resolve, reject) => {
107
+ let settled = false;
108
+ let watcher: nodeFs.FSWatcher | undefined;
109
+ const finish = (error?: unknown): void => {
110
+ if (settled) return;
111
+ settled = true;
112
+ watcher?.close();
113
+ if (error === undefined) resolve();
114
+ else reject(error);
115
+ };
116
+ try {
117
+ watcher = nodeFs.watch(directory, (_eventType, changed) => {
118
+ if (String(changed) !== filename) return;
119
+ void Bun.file(gateFile)
120
+ .exists()
121
+ .then(exists => {
122
+ if (exists) finish();
123
+ })
124
+ .catch(finish);
125
+ });
126
+ } catch (error) {
127
+ finish(error);
128
+ return;
129
+ }
130
+ void Bun.file(gateFile)
131
+ .exists()
132
+ .then(exists => {
133
+ if (exists) finish();
134
+ })
135
+ .catch(finish);
136
+ });
137
+ }
138
+
97
139
  /**
98
140
  * Identity of the broker incarnation an observation describes, or `null` when
99
141
  * it carries no usable identity.
@@ -1115,7 +1157,29 @@ export default class Sdk extends Command {
1115
1157
  return;
1116
1158
  }
1117
1159
  if (action === "serve") {
1118
- await runSdkServe(this.argv.slice(1));
1160
+ try {
1161
+ await runSdkServe(this.argv.slice(1));
1162
+ } catch (error) {
1163
+ if (!(error instanceof SdkServeError)) throw error;
1164
+ // stdout is the frame channel in --stdio mode, so the envelope goes to
1165
+ // stderr; returning instead of rethrowing is what keeps a session-selection
1166
+ // failure from reaching the embedder as an uncaught exception.
1167
+ process.stderr.write(
1168
+ `${JSON.stringify({
1169
+ ok: false,
1170
+ error: {
1171
+ code: error.code,
1172
+ message: error.message,
1173
+ ...(error.details === undefined ? {} : { details: error.details }),
1174
+ // A broker teardown that failed alongside the primary failure is
1175
+ // recorded on the error; dropping it here would hide from the
1176
+ // embedder that the session may not have been released cleanly.
1177
+ ...(error.cleanupError === undefined ? {} : { cleanupError: error.cleanupError }),
1178
+ },
1179
+ })}\n`,
1180
+ );
1181
+ process.exitCode = error.exitCode;
1182
+ }
1119
1183
  return;
1120
1184
  }
1121
1185
  if (action !== "broker-internal" && action !== "session-host-internal")
@@ -1128,7 +1192,7 @@ export default class Sdk extends Command {
1128
1192
  const agentDir = path.resolve(internal.agentDir);
1129
1193
  let broker: Broker | undefined;
1130
1194
  try {
1131
- broker = await withBrokerStartupLock(agentDir, async deadline => {
1195
+ const startupOperation = async (deadline: number): Promise<Broker | undefined> => {
1132
1196
  const remainingMs = Math.max(1, deadline - Date.now());
1133
1197
  const testWatchdogMs = Number(process.env.GJC_SDK_TEST_BROKER_STARTUP_WATCHDOG_MS ?? 0);
1134
1198
  const watchdogMs =
@@ -1145,6 +1209,12 @@ export default class Sdk extends Command {
1145
1209
  const stalled = Promise.withResolvers<void>();
1146
1210
  await stalled.promise;
1147
1211
  }
1212
+ const startupGateFile = process.env.GJC_SDK_TEST_BROKER_STARTUP_GATE_FILE;
1213
+ if (startupGateFile) {
1214
+ await emitBrokerStartupTestSignal("startup-gate-waiting");
1215
+ await waitForBrokerStartupTestGate(startupGateFile);
1216
+ await emitBrokerStartupTestSignal("startup-gate-released");
1217
+ }
1148
1218
  const startupDelayMs = Number(process.env.GJC_SDK_TEST_BROKER_STARTUP_DELAY_MS ?? 0);
1149
1219
  if (Number.isSafeInteger(startupDelayMs) && startupDelayMs > 0 && startupDelayMs <= 10_000)
1150
1220
  await Bun.sleep(startupDelayMs);
@@ -1179,6 +1249,10 @@ export default class Sdk extends Command {
1179
1249
  } finally {
1180
1250
  clearTimeout(startupWatchdog);
1181
1251
  }
1252
+ };
1253
+ broker = await withBrokerStartupLock(agentDir, startupOperation, {
1254
+ onAcquired: () => void emitBrokerStartupTestSignal("fence-acquired"),
1255
+ onContended: () => void emitBrokerStartupTestSignal("fence-contended"),
1182
1256
  });
1183
1257
  } catch (error) {
1184
1258
  if (broker) {
@@ -378,11 +378,13 @@ export const TIER_MAP_SKIP_LIST = {
378
378
  },
379
379
  "opencode-go/qwen3.8-flash": { rationale: "post-rebase catalog addition from dev; not yet curated" },
380
380
  "opencode-go/qwen3.8-max": { rationale: "post-rebase catalog addition from dev; not yet curated" },
381
+ "opencode-go/union-alpha": { rationale: "stealth model; capability tier not yet independently reviewed" },
381
382
  "opencode-zen/grok-4.6": { rationale: "post-rebase catalog addition from dev; not yet curated" },
382
383
  "opencode-zen/hy3-free": { rationale: "post-rebase catalog addition from dev; not yet curated" },
383
384
  "opencode-zen/kimi-k3": { rationale: "post-rebase catalog addition from dev; not yet curated" },
384
385
  "opencode-zen/ling-3.0-tiny-free": { rationale: "post-rebase catalog addition from dev; not yet curated" },
385
386
  "opencode-zen/nemotron-3.5-lightning-free": { rationale: "post-rebase catalog addition from dev; not yet curated" },
387
+ "opencode-zen/union-alpha": { rationale: "stealth model; capability tier not yet independently reviewed" },
386
388
  "openrouter/anthropic/claude-fable-5:batch": { rationale: "post-rebase catalog addition from dev; not yet curated" },
387
389
  "openrouter/anthropic/claude-haiku-4.5:batch": {
388
390
  rationale: "post-rebase catalog addition from dev; not yet curated",
@@ -27,6 +27,7 @@ export interface FileLockOptions {
27
27
  retryDelayMs?: number;
28
28
  signal?: AbortSignal;
29
29
  onAcquired?: () => void;
30
+ onContended?: () => void;
30
31
  /** Stable host identity required to safely reclaim locks on a shared volume. */
31
32
  ownerHostId?: string;
32
33
  /** Previous local host identities accepted only when deciding stale-owner reclamation. */
@@ -84,7 +85,7 @@ export function isFileLockAcquireTimeout(error: unknown): error is FileLockAcqui
84
85
  }
85
86
 
86
87
  const DEFAULT_OPTIONS: Required<
87
- Omit<FileLockOptions, "ownerHostId" | "previousOwnerHostIds" | "signal" | "onAcquired">
88
+ Omit<FileLockOptions, "ownerHostId" | "previousOwnerHostIds" | "signal" | "onAcquired" | "onContended">
88
89
  > = {
89
90
  staleMs: 10_000,
90
91
  retries: 50,
@@ -1363,6 +1364,28 @@ function ownerLiveness(pid: number): OwnerLiveness {
1363
1364
  }
1364
1365
  }
1365
1366
 
1367
+ /**
1368
+ * Does this owner record belong to a machine other than the acquirer's?
1369
+ *
1370
+ * A host-qualified record is foreign unless the acquirer can prove it is its own:
1371
+ * either the identity matches, or it matches an identity this installation used
1372
+ * before. An acquirer carrying no host identity can prove nothing, so every
1373
+ * host-qualified record stays foreign to it. Foreign records fail closed — their
1374
+ * PID values and clocks are not meaningful here, so they are neither reclaimable
1375
+ * nor locally probeable for liveness.
1376
+ *
1377
+ * Shared by the reclamation verdict and the exhaustion diagnostic so the two can
1378
+ * never disagree about whose pid a record names.
1379
+ */
1380
+ function lockRecordIsForeignHost(
1381
+ info: LockInfo,
1382
+ ownerHostId: string | undefined,
1383
+ previousOwnerHostIds: readonly string[],
1384
+ ): boolean {
1385
+ if (ownerHostId === undefined) return info.owner_host_id !== undefined;
1386
+ return info.owner_host_id !== ownerHostId && !previousOwnerHostIds.includes(info.owner_host_id ?? "");
1387
+ }
1388
+
1366
1389
  async function staleLockSnapshot(
1367
1390
  lockPath: string,
1368
1391
  _staleMs: number,
@@ -1402,13 +1425,7 @@ async function staleLockSnapshot(
1402
1425
  // A host-qualified lock may only be reclaimed after proving that its owner is
1403
1426
  // local. Foreign and malformed host-qualified records fail closed: PID values
1404
1427
  // and clocks are not meaningful across hosts.
1405
- if (
1406
- ownerHostId !== undefined &&
1407
- info.owner_host_id !== ownerHostId &&
1408
- !previousOwnerHostIds.includes(info.owner_host_id ?? "")
1409
- )
1410
- return { stale: false };
1411
- if (ownerHostId === undefined && info.owner_host_id !== undefined) return { stale: false };
1428
+ if (lockRecordIsForeignHost(info, ownerHostId, previousOwnerHostIds)) return { stale: false };
1412
1429
  if (ownerIncarnationChanged(info, startTimeCache)) {
1413
1430
  if (!judgedIdentity) return { stale: false };
1414
1431
  let currentIdentity: GenericFileLockDirIdentity | null;
@@ -1962,14 +1979,22 @@ async function lockHolderDescription(
1962
1979
  info = bytes === null ? null : parseLockInfoBytes(bytes);
1963
1980
  }
1964
1981
  if (info) {
1965
- // A lock record carrying a foreign owner_host_id belongs to another
1982
+ // A lock record carrying a FOREIGN owner_host_id belongs to another
1966
1983
  // machine (shared-volume topic registry): its pid is meaningful only
1967
1984
  // on that host, so probing the same numeric pid here could mislabel a
1968
1985
  // coincident local process as the holder. Report the owner host with
1969
- // unknown liveness instead.
1970
- if (info.owner_host_id !== undefined) {
1986
+ // unknown liveness instead. The same predicate that decides whether
1987
+ // the record is reclaimable decides whether its pid is probeable, so a
1988
+ // holder this acquirer *may* reclaim is never described as opaque.
1989
+ if (lockRecordIsForeignHost(info, ownerHostId, previousOwnerHostIds)) {
1990
+ // An unqualified record carries no host provenance at all. A host-aware
1991
+ // acquirer still fails closed on it — its pid is never probed and its
1992
+ // lock is never reclaimed — but the diagnostic must say the provenance
1993
+ // is missing instead of naming a host it does not have.
1994
+ const provenance =
1995
+ info.owner_host_id === undefined ? "on an unrecorded host" : `on host ${info.owner_host_id}`;
1971
1996
  return (
1972
- `held by pid ${info.pid} on host ${info.owner_host_id} (liveness unknown from this host)` +
1997
+ `held by pid ${info.pid} ${provenance} (liveness unknown from this host)` +
1973
1998
  ` since ${new Date(info.timestamp).toISOString()}`
1974
1999
  );
1975
2000
  }
@@ -1977,13 +2002,18 @@ async function lockHolderDescription(
1977
2002
  // record carries a start_time, the start-time identity match) so a dead
1978
2003
  // holder whose pid was already reused is not mislabeled "(live)".
1979
2004
  const alive = ownerIsAlive(info);
2005
+ const liveness = alive
2006
+ ? "live"
2007
+ : ownerLiveness(info.pid) === "dead"
2008
+ ? "dead but not reaped"
2009
+ : "liveness unknown";
1980
2010
  return (
1981
2011
  `held by pid ${info.pid}` +
1982
- (alive
1983
- ? " (live)"
1984
- : ownerLiveness(info.pid) === "dead"
1985
- ? " (dead but not reaped)"
1986
- : " (liveness unknown)") +
2012
+ // Keep the host on a host-qualified local record: on a shared volume the
2013
+ // same pathname is contended from several hosts, so naming the one whose
2014
+ // pid space this verdict came from is what makes it actionable.
2015
+ (info.owner_host_id === undefined ? "" : " on this host") +
2016
+ ` (${liveness})` +
1987
2017
  ` since ${new Date(info.timestamp).toISOString()}`
1988
2018
  );
1989
2019
  }
@@ -2022,6 +2052,7 @@ export async function acquireFileLock(filePath: string, options: FileLockOptions
2022
2052
  }
2023
2053
  const ownerToken = crypto.randomUUID();
2024
2054
  const contentionStartTimes = new Map<string, string | null>();
2055
+ let contentionObserved = false;
2025
2056
  let staleRemovalFailure: RecordedStaleRemovalFailure | undefined;
2026
2057
  for (let attempt = 0; attempt < opts.retries; attempt++) {
2027
2058
  if (opts.signal?.aborted) throw opts.signal.reason ?? new Error("File lock acquisition aborted");
@@ -2052,6 +2083,10 @@ export async function acquireFileLock(filePath: string, options: FileLockOptions
2052
2083
  localLockStates.set(localKey, { owner: result, status: "held" });
2053
2084
  return () => releaseLock(lockPath, result, localKey);
2054
2085
  }
2086
+ if (!contentionObserved) {
2087
+ contentionObserved = true;
2088
+ opts.onContended?.();
2089
+ }
2055
2090
  const pendingKey = await pendingLocalReleaseKey(lockPath, localKey);
2056
2091
  const localState = localLockStates.get(pendingKey ?? localKey);
2057
2092
  if (pendingKey !== undefined && localState?.status !== "held" && localState?.owner.owner_token !== undefined) {
@@ -1,5 +1,5 @@
1
1
  import { ThinkingLevel } from "@gajae-code/agent-core";
2
- import type { Api, Model } from "@gajae-code/ai/core";
2
+ import { type Api, isKnownProvider, type Model } from "@gajae-code/ai/core";
3
3
  import { logger } from "@gajae-code/utils";
4
4
  import type { AgentSession, DefaultFallbackRuntimeState } from "../session/agent-session";
5
5
  import { clampExplicitThinkingLevelForModel, formatClampedModelSelector } from "../thinking";
@@ -105,6 +105,7 @@ export interface PrepareModelProfileActivationOptions {
105
105
  | "getSessionCanonicalVariant"
106
106
  | "restoreSessionCanonicalVariant"
107
107
  | "getConfiguredProviderIds"
108
+ | "isKnownProvider"
108
109
  >
109
110
  > & {
110
111
  getError?: ModelRegistry["getError"];
@@ -449,7 +450,7 @@ export function materializeActiveModelProfileAssignments(options: MaterializeMod
449
450
  }
450
451
 
451
452
  export class ModelProfileCredentialError extends Error {
452
- readonly code = "authentication_failed";
453
+ readonly code: string = "authentication_failed";
453
454
  readonly profileLabel: string;
454
455
  readonly providers: readonly string[];
455
456
  readonly role: string | undefined;
@@ -466,6 +467,28 @@ export class ModelProfileCredentialError extends Error {
466
467
  export function formatModelProfileCredentialError(profileLabel: string, providers: readonly string[]): string {
467
468
  return `Model profile "${profileLabel}" requires credentials for: ${providers.join(", ")}. Run /login and configure the missing provider(s), then retry.`;
468
469
  }
470
+ export function formatModelProfileUnknownProviderError(profileLabel: string, providers: readonly string[]): string {
471
+ return `Model profile "${profileLabel}" requires provider(s) this build does not know: ${providers.join(", ")}. The profile likely targets a newer or custom build; declare the provider(s) in models.yml or use a build that ships them.`;
472
+ }
473
+
474
+ /**
475
+ * A required profile provider that this build cannot know: it is neither a
476
+ * built-in provider id nor declared in models.yml. That is a build/config
477
+ * mismatch (for example a profile authored on a newer or custom build), not a
478
+ * credential gap, so the diagnosis must not send the user to /login.
479
+ *
480
+ * Extends {@link ModelProfileCredentialError} so existing startup recovery
481
+ * (interactive toast-and-continue) keeps working with the sharper message.
482
+ */
483
+ export class ModelProfileUnknownProviderError extends ModelProfileCredentialError {
484
+ readonly code = "unknown_provider";
485
+
486
+ constructor(profileLabel: string, providers: readonly string[], role?: string) {
487
+ super(profileLabel, providers, role);
488
+ this.name = "ModelProfileUnknownProviderError";
489
+ this.message = formatModelProfileUnknownProviderError(profileLabel, providers);
490
+ }
491
+ }
469
492
 
470
493
  /**
471
494
  * Rewrite a selector only within the selector provider's own alternative group.
@@ -534,6 +557,14 @@ function rewriteBindingsProviders(
534
557
  ),
535
558
  };
536
559
  }
560
+ export function isModelProfileProxyConfigured(
561
+ provider: string,
562
+ configuredProviders: readonly string[] | undefined,
563
+ credentialless: boolean,
564
+ ): boolean {
565
+ return configuredProviders?.includes(provider) === true || (provider === "opencodex" && credentialless);
566
+ }
567
+
537
568
  /**
538
569
  * Resolve the explicitly configured OpenAI-compatible proxy provider id for a
539
570
  * preset. Returns undefined when unset or empty. Passwords/labels are never
@@ -620,9 +651,13 @@ export function rewriteSelectorForProxy(
620
651
  const baseSelector = suffix.selector;
621
652
  const slash = baseSelector.indexOf("/");
622
653
  const proxyModels = allModels.filter(model => model.provider === proxyProvider);
654
+ const matchingProxyModels = (id: string): Model<Api>[] => {
655
+ const publicMatches = proxyModels.filter(model => model.id === id);
656
+ return publicMatches.length > 0 ? publicMatches : proxyModels.filter(model => model.wireModelId === id);
657
+ };
623
658
  if (slash < 0) {
624
659
  if (proxyMode === "fallback") return selector;
625
- const exactMatches = proxyModels.filter(model => model.id === baseSelector);
660
+ const exactMatches = matchingProxyModels(baseSelector);
626
661
  const finalSegmentMatches = proxyModels.filter(model => model.id.split("/").at(-1) === baseSelector);
627
662
  const matches = exactMatches.length > 0 ? exactMatches : finalSegmentMatches;
628
663
  if (matches.length !== 1) {
@@ -640,8 +675,8 @@ export function rewriteSelectorForProxy(
640
675
  throw new Error(`Configured proxy "${proxyProvider}" cannot route its own direct selector "${baseSelector}"`);
641
676
  }
642
677
  const directModelId = baseSelector.substring(slash + 1);
643
- const exactMatches = proxyModels.filter(model => model.id === `${directProvider}/${directModelId}`);
644
- const flatMatches = proxyModels.filter(model => model.id === directModelId);
678
+ const exactMatches = matchingProxyModels(`${directProvider}/${directModelId}`);
679
+ const flatMatches = matchingProxyModels(directModelId);
645
680
  const matches = exactMatches.length > 0 ? exactMatches : flatMatches;
646
681
  if (matches.length === 0) {
647
682
  throw new Error(`Configured proxy "${proxyProvider}" does not expose a model for "${baseSelector}"`);
@@ -957,6 +992,33 @@ export async function prepareModelProfileActivation(
957
992
  ...alternativeSet,
958
993
  ...deriveModelProfileMappedProviders(profile),
959
994
  ]);
995
+ // A required provider this build cannot know is a build/config mismatch,
996
+ // not a credential gap: diagnose it before any auth probing so the user
997
+ // is never sent to /login for a provider this binary cannot serve (for
998
+ // example a models.yml profile authored on a newer or custom build). A
999
+ // registry without provider visibility keeps the legacy
1000
+ // credential diagnosis.
1001
+ const configuredProviderIds = options.modelRegistry.getConfiguredProviderIds?.();
1002
+ if (configuredProviderIds !== undefined || options.modelRegistry.isKnownProvider !== undefined) {
1003
+ const isProviderKnown = (provider: string): boolean =>
1004
+ (options.modelRegistry.isKnownProvider?.(provider) ?? false) ||
1005
+ isKnownProvider(provider) ||
1006
+ configuredProviderIds?.includes(provider) === true;
1007
+ const unknownRequiredProviders = new Set<string>();
1008
+ for (const provider of requiredProviders) {
1009
+ if (!alternativeSet.has(provider) && !isProviderKnown(provider)) unknownRequiredProviders.add(provider);
1010
+ }
1011
+ for (const group of alternativeGroups) {
1012
+ if (group.some(isProviderKnown)) continue;
1013
+ for (const provider of group) {
1014
+ if (!isProviderKnown(provider)) unknownRequiredProviders.add(provider);
1015
+ }
1016
+ }
1017
+ const unknownProviderIds = [...unknownRequiredProviders].sort();
1018
+ if (unknownProviderIds.length > 0) {
1019
+ throw new ModelProfileUnknownProviderError(profileLabel, unknownProviderIds);
1020
+ }
1021
+ }
960
1022
 
961
1023
  const missingProviders: string[] = [];
962
1024
  const authenticatedProviders: string[] = [];
@@ -995,18 +1057,18 @@ export async function prepareModelProfileActivation(
995
1057
  if (proxyMode === "always" && proxyProvider === undefined) {
996
1058
  throw new Error('modelProfile.proxyMode "always" requires modelProfile.proxyProvider');
997
1059
  }
1060
+ const proxyApiKey =
1061
+ proxyProvider === undefined
1062
+ ? undefined
1063
+ : await options.modelRegistry.getApiKeyForProvider(proxyProvider, credentialSessionId);
998
1064
  if (proxyProvider !== undefined) {
999
1065
  const configuredProxyProviders = options.modelRegistry.getConfiguredProviderIds?.();
1000
- if (!configuredProxyProviders?.includes(proxyProvider)) {
1066
+ if (!isModelProfileProxyConfigured(proxyProvider, configuredProxyProviders, proxyApiKey === kNoAuth)) {
1001
1067
  throw new Error(
1002
1068
  `modelProfile.proxyProvider "${proxyProvider}" is not configured. Configure it with \`gjc setup provider\` before activating a preset.`,
1003
1069
  );
1004
1070
  }
1005
1071
  }
1006
- const proxyApiKey =
1007
- proxyProvider === undefined
1008
- ? undefined
1009
- : await options.modelRegistry.getApiKeyForProvider(proxyProvider, credentialSessionId);
1010
1072
  const proxyAuthenticated =
1011
1073
  proxyProvider !== undefined &&
1012
1074
  proxyApiKey !== undefined &&
@@ -18,6 +18,7 @@ import {
18
18
  getBundledModels,
19
19
  getBundledProviders,
20
20
  getEnvApiKey,
21
+ getMiniMaxThinkingMode,
21
22
  googleAntigravityModelManagerOptions,
22
23
  googleGeminiCliModelManagerOptions,
23
24
  isCodexGpt56Tier,
@@ -280,6 +281,7 @@ export const GJC_MODEL_ASSIGNMENT_TARGETS: Record<GjcModelAssignmentTargetId, Gj
280
281
 
281
282
  export function requiresExplicitThinkingChoice(model: Model, role: GjcModelAssignmentTargetId | null): boolean {
282
283
  if (!modelSupportsReasoningControl(model)) return false;
284
+ if (getMiniMaxThinkingMode(model) === "toggle") return true;
283
285
  if (model.provider === "openai" || model.provider === "openai-codex" || isDirectXaiReasoningEffortModel(model))
284
286
  return true;
285
287
  if (role === null) return false;
@@ -4770,6 +4772,25 @@ export class ModelRegistry {
4770
4772
  return [...this.#configuredProviderIds];
4771
4773
  }
4772
4774
 
4775
+ /**
4776
+ * Whether this registry can serve a provider id from any active source.
4777
+ *
4778
+ * Built-in providers are backed by the bundled AI catalog, configured
4779
+ * providers come from models.yml, and extension registrations are retained
4780
+ * in the runtime provider stores below. Runtime model/override state is
4781
+ * included for direct registrations that do not have an extension source id.
4782
+ */
4783
+ isKnownProvider(provider: string): boolean {
4784
+ return (
4785
+ isKnownProvider(provider) ||
4786
+ this.#configuredProviderIds.has(provider) ||
4787
+ this.#runtimeProviderSourceByName.has(provider) ||
4788
+ this.#runtimeModelOverlays.some(model => model.provider === provider) ||
4789
+ this.#runtimeProviderApiKeys.has(provider) ||
4790
+ this.#runtimeProviderOverrides.has(provider)
4791
+ );
4792
+ }
4793
+
4773
4794
  #isModelAvailable(model: Model<Api>, disabledProviders?: ReadonlySet<string>): boolean {
4774
4795
  const disabled = disabledProviders ?? getDisabledProviderIdsFromSettings(this.#settings);
4775
4796
  return (
@@ -307,6 +307,9 @@ interface CoordinatorServices {
307
307
  canonicalizePath?: (value: string) => Promise<string>;
308
308
  codexTransportFactory?: CodexTransportFactory;
309
309
  eventWebhookDelivery?: WebhookDelivery;
310
+ /** Observable transitions for consumers coordinating with the filesystem-backed event wait. */
311
+ onCoordinatorEventWatchReady?: () => void;
312
+ onCoordinatorEventWake?: () => void;
310
313
  /** Test barrier invoked after an accepted prompt receipt is durable and before turn finalization. */
311
314
  afterPromptReceiptPersisted?: (sessionId: string) => void | Promise<void>;
312
315
  /** Test barrier invoked after an answer dispatch is claimed and before its final admission check. */
@@ -3188,18 +3191,24 @@ function waitForTurnStateChange(namespaceDir: string, turn: TurnRecord, timeoutM
3188
3191
  return deferred.promise;
3189
3192
  }
3190
3193
 
3191
- async function waitForCoordinatorEvents(namespaceDir: string, timeoutMs: number): Promise<void> {
3194
+ async function waitForCoordinatorEvents(
3195
+ namespaceDir: string,
3196
+ timeoutMs: number,
3197
+ onWatchReady?: () => void,
3198
+ onWake?: () => void,
3199
+ ): Promise<void> {
3192
3200
  const deferred = Promise.withResolvers<void>();
3193
3201
  const watchers: nodeFs.FSWatcher[] = [];
3194
3202
  let settled = false;
3195
- const finish = () => {
3203
+ const finish = (woke: boolean) => {
3196
3204
  if (settled) return;
3197
3205
  settled = true;
3198
3206
  for (const watcher of watchers) watcher.close();
3199
3207
  clearTimeout(timer);
3208
+ if (woke) onWake?.();
3200
3209
  deferred.resolve();
3201
3210
  };
3202
- const timer = setTimeout(finish, Math.max(timeoutMs, 0));
3211
+ const timer = setTimeout(() => finish(false), Math.max(timeoutMs, 0));
3203
3212
  timer.unref?.();
3204
3213
  const eventDir = eventsDir(namespaceDir);
3205
3214
  const watchedDirs = [
@@ -3213,16 +3222,17 @@ async function waitForCoordinatorEvents(namespaceDir: string, timeoutMs: number)
3213
3222
  try {
3214
3223
  const watcher = nodeFs.watch(dir, (_eventType, filename) => {
3215
3224
  if (dir === eventDir) {
3216
- if (filename === "event-journal.jsonl" || filename === "latest-seq.json") finish();
3225
+ if (filename === "event-journal.jsonl" || filename === "latest-seq.json") finish(true);
3217
3226
  return;
3218
3227
  }
3219
- if (typeof filename === "string" && filename.endsWith(".json")) finish();
3228
+ if (typeof filename === "string" && filename.endsWith(".json")) finish(true);
3220
3229
  });
3221
3230
  watchers.push(watcher);
3222
3231
  } catch {
3223
3232
  // Directory may not be watchable on this platform; the timeout remains a bounded fallback.
3224
3233
  }
3225
3234
  }
3235
+ onWatchReady?.();
3226
3236
  return deferred.promise;
3227
3237
  }
3228
3238
 
@@ -8645,7 +8655,12 @@ export function createCoordinatorMcpServer(options: CoordinatorMcpServerOptions
8645
8655
  if (matched.length === 0 && timeoutMs > 0) {
8646
8656
  const deadline = absoluteDeadline;
8647
8657
  while (matched.length === 0 && Date.now() < deadline && !watchController.signal.aborted) {
8648
- await waitForCoordinatorEvents(namespaceDir, Math.min(50, Math.max(1, deadline - Date.now())));
8658
+ await waitForCoordinatorEvents(
8659
+ namespaceDir,
8660
+ Math.min(50, Math.max(1, deadline - Date.now())),
8661
+ services.onCoordinatorEventWatchReady,
8662
+ services.onCoordinatorEventWake,
8663
+ );
8649
8664
  if (Date.now() >= deadline || watchController.signal.aborted) break;
8650
8665
  try {
8651
8666
  await exportRetainedDeliveries(32, watchController.signal);