@gajae-code/coding-agent 0.16.3 → 0.16.4

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 (133) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/dist/types/cli/args.d.ts +2 -2
  3. package/dist/types/cli/root-flags.d.ts +4 -2
  4. package/dist/types/cli.d.ts +1 -1
  5. package/dist/types/commands/launch.d.ts +1 -1
  6. package/dist/types/config/autorouting-tier-map.d.ts +3 -0
  7. package/dist/types/config/model-registry.d.ts +4 -0
  8. package/dist/types/config/model-resolver.d.ts +9 -0
  9. package/dist/types/config/settings-schema.d.ts +29 -1
  10. package/dist/types/discovery/claude-plugins.d.ts +3 -1
  11. package/dist/types/discovery/helpers.d.ts +2 -0
  12. package/dist/types/extensibility/gjc-plugins/mcp-policy.d.ts +16 -0
  13. package/dist/types/extensibility/gjc-plugins/paths.d.ts +2 -0
  14. package/dist/types/extensibility/runtime-skill-discovery.d.ts +1 -1
  15. package/dist/types/extensibility/shared-events.d.ts +1 -1
  16. package/dist/types/gjc-runtime/session-state-lock.d.ts +2 -0
  17. package/dist/types/modes/components/assistant-message.d.ts +1 -2
  18. package/dist/types/modes/components/settings-selector.d.ts +6 -1
  19. package/dist/types/modes/components/status-line/command.d.ts +35 -0
  20. package/dist/types/modes/components/status-line/index.d.ts +1 -0
  21. package/dist/types/modes/components/status-line/types.d.ts +5 -0
  22. package/dist/types/modes/components/tool-status-header.d.ts +14 -0
  23. package/dist/types/modes/controllers/event-controller.d.ts +6 -0
  24. package/dist/types/modes/interactive-mode.d.ts +1 -0
  25. package/dist/types/modes/types.d.ts +1 -0
  26. package/dist/types/runtime-mcp/client.d.ts +9 -1
  27. package/dist/types/runtime-mcp/manager.d.ts +7 -1
  28. package/dist/types/runtime-mcp/pool.d.ts +22 -3
  29. package/dist/types/runtime-mcp/transports/stdio.d.ts +0 -4
  30. package/dist/types/runtime-mcp/types.d.ts +22 -0
  31. package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +12 -0
  32. package/dist/types/sdk/bus/slack-live-provider.d.ts +6 -0
  33. package/dist/types/sdk/bus/slack-provider.d.ts +12 -0
  34. package/dist/types/sdk/host/session-runtime.d.ts +23 -0
  35. package/dist/types/sdk/session-api-key.d.ts +26 -0
  36. package/dist/types/sdk/transport/serve-cli.d.ts +1 -1
  37. package/dist/types/session/agent-session.d.ts +9 -0
  38. package/dist/types/setup/paseo/announce.d.ts +111 -0
  39. package/dist/types/setup/paseo/setup-deps.d.ts +2 -1
  40. package/dist/types/system-prompt.d.ts +2 -0
  41. package/dist/types/task/executor.d.ts +2 -1
  42. package/dist/types/tools/fetch.d.ts +7 -0
  43. package/dist/types/tools/index.d.ts +8 -0
  44. package/dist/types/tools/path-utils.d.ts +2 -0
  45. package/dist/types/tools/search.d.ts +6 -0
  46. package/package.json +7 -7
  47. package/scripts/capture-status-line-custom-editor-showcase.ts +151 -0
  48. package/scripts/generate-sdk-operation-inventory.ts +9 -0
  49. package/scripts/verify-status-line-custom-editor-showcase.ts +309 -0
  50. package/src/cli/args.ts +20 -9
  51. package/src/cli/root-flags.ts +5 -3
  52. package/src/config/autorouting-tier-map.ts +1 -0
  53. package/src/config/file-lock.ts +181 -17
  54. package/src/config/model-preset-registry.ts +5 -3
  55. package/src/config/model-profiles.ts +40 -0
  56. package/src/config/model-registry.ts +57 -11
  57. package/src/config/model-resolver.ts +48 -0
  58. package/src/config/settings-schema.ts +33 -1
  59. package/src/config/settings.ts +1 -0
  60. package/src/defaults/gjc/extensions/grok-cli-vendor/src/models/catalog.ts +11 -11
  61. package/src/defaults/gjc/extensions/grok-cli-vendor/src/provider/register.ts +13 -2
  62. package/src/discovery/builtin.ts +13 -4
  63. package/src/discovery/claude-plugins.ts +12 -4
  64. package/src/discovery/helpers.ts +10 -2
  65. package/src/extensibility/gjc-plugins/compiler.ts +208 -58
  66. package/src/extensibility/gjc-plugins/installer.ts +13 -12
  67. package/src/extensibility/gjc-plugins/mcp-policy.ts +81 -52
  68. package/src/extensibility/gjc-plugins/paths.ts +13 -0
  69. package/src/extensibility/gjc-plugins/runtime-adapters.ts +758 -16
  70. package/src/extensibility/runtime-skill-discovery.ts +77 -151
  71. package/src/extensibility/shared-events.ts +1 -1
  72. package/src/extensibility/skills.ts +44 -16
  73. package/src/gjc-runtime/repository-binding.ts +1 -1
  74. package/src/gjc-runtime/session-state-lock.ts +92 -2
  75. package/src/internal-urls/docs-index.generated.ts +7 -7
  76. package/src/main.ts +72 -34
  77. package/src/modes/DESIGN.md +54 -0
  78. package/src/modes/components/assistant-message.ts +22 -9
  79. package/src/modes/components/model-selector.ts +166 -33
  80. package/src/modes/components/settings-selector.ts +1075 -235
  81. package/src/modes/components/status-line/command.ts +190 -0
  82. package/src/modes/components/status-line/index.ts +1 -0
  83. package/src/modes/components/status-line/segments.ts +32 -5
  84. package/src/modes/components/status-line/types.ts +5 -0
  85. package/src/modes/components/tool-status-header.ts +393 -21
  86. package/src/modes/controllers/command-controller.ts +2 -0
  87. package/src/modes/controllers/event-controller.ts +148 -5
  88. package/src/modes/controllers/selector-controller.ts +21 -0
  89. package/src/modes/interactive-mode.ts +47 -1
  90. package/src/modes/types.ts +1 -0
  91. package/src/modes/utils/ui-helpers.ts +1 -0
  92. package/src/prompts/system/system-prompt.md +8 -0
  93. package/src/prompts/tools/search.md +2 -0
  94. package/src/runtime-mcp/client.ts +60 -17
  95. package/src/runtime-mcp/manager.ts +171 -14
  96. package/src/runtime-mcp/pool.test.ts +209 -0
  97. package/src/runtime-mcp/pool.ts +224 -23
  98. package/src/runtime-mcp/transports/stdio.ts +158 -24
  99. package/src/runtime-mcp/types.ts +24 -0
  100. package/src/sdk/broker/broker.ts +5 -4
  101. package/src/sdk/bus/chat-daemon-control.ts +1 -1
  102. package/src/sdk/bus/chat-daemon-runtime.ts +37 -1
  103. package/src/sdk/bus/slack-daemon.ts +92 -2
  104. package/src/sdk/bus/slack-live-provider.ts +12 -0
  105. package/src/sdk/bus/slack-provider.ts +5 -0
  106. package/src/sdk/cli/session-cli.ts +9 -6
  107. package/src/sdk/host/session-runtime.test.ts +2 -2
  108. package/src/sdk/host/session-runtime.ts +275 -43
  109. package/src/sdk/protocol/operation-inventory.generated.json +66 -0
  110. package/src/sdk/session-api-key.ts +94 -0
  111. package/src/sdk/session.ts +73 -29
  112. package/src/sdk/transport/serve-cli.ts +3 -3
  113. package/src/session/agent-session.ts +123 -38
  114. package/src/session/contribution-prep.ts +214 -21
  115. package/src/setup/paseo/announce.ts +450 -0
  116. package/src/setup/paseo/setup-deps.ts +15 -3
  117. package/src/slash-commands/builtin-registry.ts +1 -0
  118. package/src/system-prompt.ts +4 -0
  119. package/src/task/executor.ts +2 -1
  120. package/src/task/index.ts +17 -4
  121. package/src/tools/bash.ts +35 -8
  122. package/src/tools/cron.ts +1 -1
  123. package/src/tools/fetch.ts +29 -1
  124. package/src/tools/index.ts +8 -0
  125. package/src/tools/job.ts +8 -2
  126. package/src/tools/monitor.ts +8 -2
  127. package/src/tools/path-utils.ts +7 -0
  128. package/src/tools/search.ts +116 -58
  129. package/src/tools/skill.ts +22 -2
  130. package/src/tools/subagent.ts +3 -1
  131. package/src/tools/tool-catalog.generated.ts +8 -1
  132. package/src/web/insane/bridge.ts +113 -23
  133. package/src/web/search/providers/perplexity.ts +102 -41
@@ -2,8 +2,19 @@ import * as crypto from "node:crypto";
2
2
  import type { BigIntStats } from "node:fs";
3
3
  import * as fs from "node:fs/promises";
4
4
  import * as path from "node:path";
5
- import type { NativeDirectoryTreeResult, NativeExactUnlinkResult, NativeNoReplaceResult } from "@gajae-code/natives";
6
- import { renameDirectoryNoReplacePathAsync, renameNoReplacePathAsync } from "@gajae-code/natives";
5
+ import type {
6
+ NativeDirectoryTreeResult,
7
+ NativeDirectoryTreeSnapshot,
8
+ NativeExactUnlinkResult,
9
+ NativeNoReplaceResult,
10
+ RecoveryFsPublishResult,
11
+ } from "@gajae-code/natives";
12
+ import {
13
+ openRecoveryFsRoot,
14
+ renameDirectoryNoReplacePathAsync,
15
+ renameNoReplacePathAsync,
16
+ snapshotDirectoryTree,
17
+ } from "@gajae-code/natives";
7
18
  import { isEnoent } from "@gajae-code/utils/fs-error";
8
19
  import { nativeProcessBindings } from "@gajae-code/utils/native-process";
9
20
 
@@ -397,11 +408,114 @@ export interface FileLockOwnerToken {
397
408
  * another process may copy into a new lock generation.
398
409
  */
399
410
  const fileLockDirIdentities = new WeakMap<object, GenericFileLockDirIdentity>();
411
+ const pendingDetachedLockCleanups = new WeakMap<object, { path: string; rootDev: string; rootIno: string }>();
412
+
413
+ async function finishDetachedLockCleanup(owner: FileLockOwnerToken): Promise<boolean> {
414
+ const pending = pendingDetachedLockCleanups.get(owner);
415
+ if (!pending) return false;
416
+ try {
417
+ const current = await fs.lstat(pending.path, { bigint: true });
418
+ if (
419
+ !current.isDirectory() ||
420
+ current.isSymbolicLink() ||
421
+ current.dev.toString() !== pending.rootDev ||
422
+ current.ino.toString() !== pending.rootIno
423
+ ) {
424
+ throw new Error("Detached file lock cleanup identity changed; refusing removal");
425
+ }
426
+ await fs.rm(pending.path, { recursive: true, force: true });
427
+ } catch (error) {
428
+ if (!isEnoent(error)) throw error;
429
+ }
430
+ pendingDetachedLockCleanups.delete(owner);
431
+ return true;
432
+ }
400
433
 
401
434
  function getLockPath(filePath: string): string {
402
435
  return `${filePath}.lock`;
403
436
  }
404
437
 
438
+ function fileLockRemovalTransitionPath(lockPath: string): string {
439
+ return `${lockPath}.removing`;
440
+ }
441
+
442
+ async function fileLockRemovalTransitionExists(lockPath: string): Promise<boolean> {
443
+ try {
444
+ await fs.lstat(fileLockRemovalTransitionPath(lockPath));
445
+ return true;
446
+ } catch (error) {
447
+ if (isEnoent(error)) return false;
448
+ throw error;
449
+ }
450
+ }
451
+
452
+ function sameFileLockTreeAfterPublication(
453
+ staged: NativeDirectoryTreeSnapshot,
454
+ published: NativeDirectoryTreeSnapshot,
455
+ ): boolean {
456
+ return (
457
+ staged.rootDev === published.rootDev &&
458
+ staged.rootIno === published.rootIno &&
459
+ staged.entries.length === published.entries.length &&
460
+ staged.entries.every((entry, index) => {
461
+ const current = published.entries[index];
462
+ return (
463
+ current !== undefined &&
464
+ entry.relativePath === current.relativePath &&
465
+ entry.kind === current.kind &&
466
+ entry.dev === current.dev &&
467
+ entry.ino === current.ino &&
468
+ entry.nlink === current.nlink &&
469
+ entry.size === current.size &&
470
+ entry.mtimeNs === current.mtimeNs &&
471
+ (entry.relativePath === "" || entry.ctimeNs === current.ctimeNs) &&
472
+ entry.sha256 === current.sha256
473
+ );
474
+ })
475
+ );
476
+ }
477
+
478
+ function rollbackPublishedFileLock(
479
+ canonicalParent: string,
480
+ lockPath: string,
481
+ pendingPath: string,
482
+ stagedSnapshot: NativeDirectoryTreeSnapshot,
483
+ ): void {
484
+ const authority = openRecoveryFsRoot(canonicalParent);
485
+ let rollback: RecoveryFsPublishResult | undefined;
486
+ const errors: unknown[] = [];
487
+ try {
488
+ rollback = authority.renameManagedTreeNoReplace(
489
+ path.basename(lockPath),
490
+ path.basename(pendingPath),
491
+ stagedSnapshot,
492
+ );
493
+ } catch (error) {
494
+ errors.push(error);
495
+ }
496
+ if (rollback && !rollback.ok) {
497
+ const failure = new Error(
498
+ `Failed to roll back file lock published during removal transition: ${rollback.code ?? "unknown"}.`,
499
+ ) as NodeJS.ErrnoException;
500
+ if (rollback.code) failure.code = rollback.code;
501
+ errors.push(failure);
502
+ }
503
+ try {
504
+ const closed = authority.close();
505
+ if (!closed.ok && closed.code !== "closed") {
506
+ const failure = new Error(
507
+ `Failed to close file lock rollback authority: ${closed.code ?? "unknown"}.`,
508
+ ) as NodeJS.ErrnoException;
509
+ if (closed.code) failure.code = closed.code;
510
+ errors.push(failure);
511
+ }
512
+ } catch (error) {
513
+ errors.push(error);
514
+ }
515
+ if (errors.length > 0) throw new AggregateError(errors, "File lock rollback authority failed");
516
+ if (!rollback) throw new Error("File lock rollback returned no result");
517
+ }
518
+
405
519
  async function ensureLockParent(directory: string): Promise<void> {
406
520
  const missing: string[] = [];
407
521
  let current = path.resolve(directory);
@@ -704,17 +818,12 @@ export async function removeFileLockDirForGc(
704
818
  removed.retainedPlaceholderPath === undefined &&
705
819
  removed.retainedUnknownPath === undefined;
706
820
  if (verifiedDetach && detachedPath !== undefined) {
707
- // The security-critical phase is done: the verified tree was detached from
708
- // the canonical name and durably parked under the no-replace quarantine
709
- // name with no successor retained. Finish that replay deterministically by
710
- // deleting the retained quarantine — the same completion contract gc-runtime
711
- // applies to its own exact removals — then report the release as done.
712
- try {
713
- await fs.rm(detachedPath, { recursive: true, force: true });
714
- } catch {
715
- // The canonical pathname is free either way; a retained quarantine is
716
- // recoverable debris, not a live lock.
717
- }
821
+ pendingDetachedLockCleanups.set(expected, {
822
+ path: detachedPath,
823
+ rootDev: captured.snapshot.rootDev,
824
+ rootIno: captured.snapshot.rootIno,
825
+ });
826
+ await finishDetachedLockCleanup(expected);
718
827
  return "removed";
719
828
  }
720
829
  if (removed.code === "not_found") return "removed";
@@ -880,8 +989,10 @@ async function tryAcquireLock(
880
989
  if (afterParentMkdir) await afterParentMkdir(lockPath);
881
990
  const pendingPath = `${lockPath}.pending.${process.pid}.${crypto.randomUUID()}`;
882
991
  const owner = lockInfo(ownerHostId, ownerToken);
992
+ let removePending = false;
883
993
  try {
884
994
  await fs.mkdir(pendingPath, { mode: 0o700 });
995
+ removePending = true;
885
996
  await fs.chmod(pendingPath, 0o700);
886
997
  await writeLockInfo(pendingPath, owner);
887
998
  // A plain POSIX rename replaces an existing empty directory. The legacy
@@ -896,6 +1007,22 @@ async function tryAcquireLock(
896
1007
  // component would follow an attacker-replaced symlink before native no-follow
897
1008
  // validation gets a chance to reject it.
898
1009
  const canonicalPendingPath = path.join(canonicalParent, path.basename(pendingPath));
1010
+ let stagedSnapshot: NativeDirectoryTreeSnapshot | undefined;
1011
+ if (process.platform === "linux") {
1012
+ // Linux exact tree removal owns this deterministic sibling from detach
1013
+ // until cleanup. The outer acquisition loop supplies the existing bounded
1014
+ // contention wait while the predecessor retains that namespace.
1015
+ if (await fileLockRemovalTransitionExists(destinationPath)) return null;
1016
+ const staged = snapshotDirectoryTree(canonicalPendingPath);
1017
+ if (!staged.ok || !staged.snapshot) {
1018
+ const failure = new Error(
1019
+ `Failed to snapshot staged file lock: ${staged.code ?? "unknown"}.`,
1020
+ ) as NodeJS.ErrnoException;
1021
+ if (staged.code) failure.code = staged.code;
1022
+ throw failure;
1023
+ }
1024
+ stagedSnapshot = staged.snapshot;
1025
+ }
899
1026
  const publication = FileLockTestHooks.nativePublicationBindings?.() ?? {
900
1027
  renameNoReplacePathAsync,
901
1028
  renameDirectoryNoReplacePathAsync,
@@ -924,12 +1051,34 @@ async function tryAcquireLock(
924
1051
  if (published.code) failure.code = published.code;
925
1052
  throw failure;
926
1053
  }
927
- // Published above, so an onAcquired failure must propagate instead of
928
- // retrying an acquisition that already owns the lock.
1054
+ removePending = false;
1055
+ // The transition can appear after the pre-publication check only when the
1056
+ // predecessor atomically detaches its lock before this no-replace publish.
1057
+ // Roll this exact staged tree back to its UUID path before reporting
1058
+ // contention; never delete or rename the predecessor's `.removing` tree.
1059
+ if (stagedSnapshot && (await fileLockRemovalTransitionExists(destinationPath))) {
1060
+ const publishedSnapshot = snapshotDirectoryTree(destinationPath);
1061
+ if (
1062
+ !publishedSnapshot.ok ||
1063
+ !publishedSnapshot.snapshot ||
1064
+ !sameFileLockTreeAfterPublication(stagedSnapshot, publishedSnapshot.snapshot)
1065
+ ) {
1066
+ const failure = new Error(
1067
+ `Failed to verify published file lock before transition rollback: ${publishedSnapshot.code ?? "identity_mismatch"}.`,
1068
+ ) as NodeJS.ErrnoException;
1069
+ if (publishedSnapshot.code) failure.code = publishedSnapshot.code;
1070
+ throw failure;
1071
+ }
1072
+ rollbackPublishedFileLock(canonicalParent, destinationPath, canonicalPendingPath, publishedSnapshot.snapshot);
1073
+ removePending = true;
1074
+ return null;
1075
+ }
1076
+ // Published and transition-free above, so an onAcquired failure must
1077
+ // propagate instead of retrying an acquisition that already owns the lock.
929
1078
  onAcquired?.();
930
1079
  return owner;
931
1080
  } finally {
932
- await fs.rm(pendingPath, { recursive: true, force: true }).catch(() => undefined);
1081
+ if (removePending) await fs.rm(pendingPath, { recursive: true, force: true }).catch(() => undefined);
933
1082
  }
934
1083
  }
935
1084
 
@@ -1025,7 +1174,12 @@ async function quarantineReleasedLock(
1025
1174
  return false;
1026
1175
  } catch (error) {
1027
1176
  if (isEnoent(error)) {
1028
- nativeFileLockBindings().exactRemoveDirectoryTree(removed.detachedPath, captured.snapshot);
1177
+ pendingDetachedLockCleanups.set(owner, {
1178
+ path: removed.detachedPath,
1179
+ rootDev: captured.snapshot.rootDev,
1180
+ rootIno: captured.snapshot.rootIno,
1181
+ });
1182
+ await finishDetachedLockCleanup(owner);
1029
1183
  return true;
1030
1184
  }
1031
1185
  throw error;
@@ -1035,6 +1189,7 @@ async function quarantineReleasedLock(
1035
1189
  }
1036
1190
 
1037
1191
  async function releaseOwnedLock(lockPath: string, owner: FileLockOwnerToken): Promise<void> {
1192
+ if (await finishDetachedLockCleanup(owner)) return;
1038
1193
  let preVerdictIdentity: GenericFileLockDirIdentity | undefined;
1039
1194
  try {
1040
1195
  preVerdictIdentity = (await captureFileLockDirIdentity(lockPath)) ?? undefined;
@@ -1045,6 +1200,7 @@ async function releaseOwnedLock(lockPath: string, owner: FileLockOwnerToken): Pr
1045
1200
  let lastTransientError: unknown;
1046
1201
  for (let attempt = 0; attempt < FILE_LOCK_RELEASE_RETRY_ATTEMPTS; attempt++) {
1047
1202
  try {
1203
+ if (await finishDetachedLockCleanup(owner)) return;
1048
1204
  const outcome = await removeFileLockDirForGc(lockPath, owner, preVerdictIdentity);
1049
1205
  if (outcome === "removed" || outcome === "missing") {
1050
1206
  if (outcome === "missing") throw new Error("Failed to release file lock: missing.");
@@ -1134,6 +1290,9 @@ async function releaseLock(lockPath: string, owner: FileLockOwnerToken, knownKey
1134
1290
  */
1135
1291
  async function lockHolderDescription(lockPath: string): Promise<string> {
1136
1292
  try {
1293
+ if (process.platform === "linux" && (await fileLockRemovalTransitionExists(lockPath))) {
1294
+ return "blocked by retained removal transition; retry the owning process cleanup or inspect the exact orphan manually; unproven transition ownership is never removed";
1295
+ }
1137
1296
  const info = await readLockInfo(lockPath);
1138
1297
  if (info) {
1139
1298
  // A lock record carrying a foreign owner_host_id belongs to another
@@ -1190,6 +1349,11 @@ async function acquireLock(filePath: string, options: FileLockOptions = {}): Pro
1190
1349
  for (let attempt = 0; attempt < opts.retries; attempt++) {
1191
1350
  if (opts.signal?.aborted) throw opts.signal.reason ?? new Error("File lock acquisition aborted");
1192
1351
  const localKey = await localLockKey(lockPath);
1352
+ const priorRelease = localLockStates.get(localKey);
1353
+ if (priorRelease && priorRelease.status !== "held" && pendingDetachedLockCleanups.has(priorRelease.owner)) {
1354
+ await retryPendingLocalRelease(lockPath, localKey);
1355
+ continue;
1356
+ }
1193
1357
  const owner = await tryAcquireLock(lockPath, opts.ownerHostId, ownerToken, opts.onAcquired);
1194
1358
  if (owner) {
1195
1359
  localLockStates.set(localKey, { owner, status: "held" });
@@ -4,7 +4,7 @@ import * as fs from "node:fs/promises";
4
4
  import * as path from "node:path";
5
5
  import type { Api, Model } from "@gajae-code/ai/core";
6
6
  import type { NativeExactFileIdentity, NativeExactUnlinkResult } from "@gajae-code/natives";
7
- import { getAgentDir, isEnoent } from "@gajae-code/utils";
7
+ import { $credentialEnv, getAgentDir, isEnoent } from "@gajae-code/utils";
8
8
  import * as z from "zod/v4";
9
9
  import { splitSelectorThinkingSuffix } from "../thinking";
10
10
  import { withFileLock } from "./file-lock";
@@ -1444,10 +1444,12 @@ function effectiveTrustedKeys(
1444
1444
  return getModelPresetRegistryTestTrustedKeys(agentDir) ?? MODEL_PRESET_REGISTRY_TRUSTED_KEYS;
1445
1445
  }
1446
1446
  function effectiveManifestUrl(dependencies: ModelPresetRegistryDependencies): string {
1447
- return dependencies.manifestUrl ?? process.env.GJC_MODEL_PRESET_REGISTRY_URL ?? DEFAULT_MODEL_PRESET_REGISTRY_URL;
1447
+ return (
1448
+ dependencies.manifestUrl ?? $credentialEnv("GJC_MODEL_PRESET_REGISTRY_URL") ?? DEFAULT_MODEL_PRESET_REGISTRY_URL
1449
+ );
1448
1450
  }
1449
1451
  function environmentDisabled(): boolean {
1450
- return /^(?:1|true|yes|on)$/i.test(process.env.GJC_MODEL_PRESET_REGISTRY_DISABLED ?? "");
1452
+ return /^(?:1|true|yes|on)$/i.test($credentialEnv("GJC_MODEL_PRESET_REGISTRY_DISABLED") ?? "");
1451
1453
  }
1452
1454
  function assertHttpsUrl(raw: string, description: string): URL {
1453
1455
  let url: URL;
@@ -100,6 +100,27 @@ export const BUILTIN_MODEL_PROFILES: readonly ModelProfileDefinition[] = [
100
100
  critic: "openai-codex/gpt-5.6-luna:max",
101
101
  architect: "openai-codex/gpt-5.6-luna:max",
102
102
  }),
103
+ profile("astra-lite", ["openai-codex"], {
104
+ default: "openai-codex/gpt-6-astra:medium",
105
+ executor: "openai-codex/gpt-5.6-luna:xhigh",
106
+ planner: "openai-codex/gpt-6-astra:xhigh",
107
+ critic: "openai-codex/gpt-5.6-sol:high",
108
+ architect: "openai-codex/gpt-5.6-terra:xhigh",
109
+ }),
110
+ profile("astra-default", ["openai-codex"], {
111
+ default: "openai-codex/gpt-6-astra:medium",
112
+ executor: "openai-codex/gpt-5.6-luna:max",
113
+ planner: "openai-codex/gpt-6-astra:xhigh",
114
+ critic: "openai-codex/gpt-5.6-sol:xhigh",
115
+ architect: "openai-codex/gpt-5.6-terra:xhigh",
116
+ }),
117
+ profile("astra-heavy", ["openai-codex"], {
118
+ default: "openai-codex/gpt-6-astra:medium",
119
+ executor: "openai-codex/gpt-5.6-sol:max",
120
+ planner: "openai-codex/gpt-5.6-sol:max",
121
+ critic: "openai-codex/gpt-6-astra:max",
122
+ architect: "openai-codex/gpt-6-astra:max",
123
+ }),
103
124
  profile("macos-omlx-fast", ["omlx"], {
104
125
  default: "omlx/Qwen3.6-35B-A3B-4bit:low",
105
126
  executor: "omlx/Qwen3.6-35B-A3B-4bit:low",
@@ -488,6 +509,20 @@ export const BUILTIN_MODEL_PROFILES: readonly ModelProfileDefinition[] = [
488
509
  critic: "anthropic/claude-opus-5:high",
489
510
  architect: "openai-codex/gpt-5.6-sol:xhigh",
490
511
  }),
512
+ profile("astra-fable", ["openai-codex", "anthropic"], {
513
+ default: "openai-codex/gpt-6-astra:medium",
514
+ executor: "openai-codex/gpt-5.6-luna:max",
515
+ planner: "openai-codex/gpt-6-astra:xhigh",
516
+ critic: "anthropic/claude-fable-5-1:xhigh",
517
+ architect: "anthropic/claude-fable-5-1:xhigh",
518
+ }),
519
+ profile("astra-fable-opus", ["openai-codex", "anthropic"], {
520
+ default: "openai-codex/gpt-6-astra:medium",
521
+ executor: "openai-codex/gpt-5.6-luna:max",
522
+ planner: "anthropic/claude-opus-5:medium",
523
+ critic: "anthropic/claude-fable-5-1:xhigh",
524
+ architect: "anthropic/claude-fable-5-1:xhigh",
525
+ }),
491
526
  ];
492
527
 
493
528
  /**
@@ -521,6 +556,9 @@ const PROFILE_PRESENTATION: Record<string, ModelProfilePresentation> = {
521
556
  "codex-medium": { displayName: "Codex Medium", providerGroup: "CODEX" },
522
557
  "codex-pro": { displayName: "Codex Pro", providerGroup: "CODEX" },
523
558
  lunamaxxing: { displayName: "LunaMaxxing", providerGroup: "CODEX" },
559
+ "astra-lite": { displayName: "ASTRA-Lite", providerGroup: "CODEX" },
560
+ "astra-default": { displayName: "ASTRA-Default", providerGroup: "CODEX" },
561
+ "astra-heavy": { displayName: "ASTRA-Heavy", providerGroup: "CODEX" },
524
562
  opencodego: { displayName: "OpenCodeGo", providerGroup: "OPENCODEGO" },
525
563
  "commandcode-goat": { displayName: "Command Code GOAT", providerGroup: "COMMAND CODE GOAT" },
526
564
  "open-weights-glm": { displayName: "GLM", providerGroup: "OPEN WEIGHT MODELS (PROVIDER AGNOSTIC)" },
@@ -594,6 +632,8 @@ const PROFILE_PRESENTATION: Record<string, ModelProfilePresentation> = {
594
632
  "opus-codex": { displayName: "Opus + Codex", providerGroup: "COMBOS" },
595
633
  "codex-opencodego": { displayName: "Codex + OpenCodeGo", providerGroup: "COMBOS" },
596
634
  "fable-opus-codex": { displayName: "Fable + Opus + Codex", providerGroup: "COMBOS" },
635
+ "astra-fable": { displayName: "ASTRA + Fable (5.1)", providerGroup: "COMBOS" },
636
+ "astra-fable-opus": { displayName: "ASTRA + Fable (5.1) + Opus", providerGroup: "COMBOS" },
597
637
  "macos-omlx-fast": { displayName: "4-bit Fast (MoE measured 93.5 tok/s)", providerGroup: "macOS Local (oMLX)" },
598
638
  "macos-omlx-balanced": {
599
639
  displayName: "8-bit Balanced (MoE measured 71.1 tok/s)",
@@ -40,6 +40,7 @@ import {
40
40
  unregisterCustomApis,
41
41
  } from "@gajae-code/ai/core";
42
42
  import { fetchModelsDevPayload } from "@gajae-code/ai/provider-models/openai-compat";
43
+ import { isDirectXaiReasoningEffortModel } from "@gajae-code/ai/providers/openai-completions-compat";
43
44
  import {
44
45
  detectDiscoveredApiFamily,
45
46
  resolveLoopbackOpenAIBaseUrl,
@@ -279,11 +280,7 @@ export const GJC_MODEL_ASSIGNMENT_TARGETS: Record<GjcModelAssignmentTargetId, Gj
279
280
 
280
281
  export function requiresExplicitThinkingChoice(model: Model, role: GjcModelAssignmentTargetId | null): boolean {
281
282
  if (!modelSupportsReasoningControl(model)) return false;
282
- if (
283
- model.provider === "openai" ||
284
- model.provider === "openai-codex" ||
285
- (model.provider === "xai" && (model.id === "grok-4.5" || model.id === "grok-4.6"))
286
- )
283
+ if (model.provider === "openai" || model.provider === "openai-codex" || isDirectXaiReasoningEffortModel(model))
287
284
  return true;
288
285
  if (role === null) return false;
289
286
  if (role === "default") return true;
@@ -1835,6 +1832,25 @@ export class ModelRegistry {
1835
1832
  /**
1836
1833
  * Reload models from disk (embedded + accepted registry + custom from models.yml).
1837
1834
  */
1835
+ async refreshStatic(): Promise<void> {
1836
+ if (this.#disposed) return;
1837
+ await this.#enqueueCatalogMutation(async () => {
1838
+ if (this.#disposed) return;
1839
+ this.#catalogRefreshGeneration++;
1840
+ this.#suspendRebuild();
1841
+ try {
1842
+ this.#reloadStaticModels();
1843
+ this.#suppressedSelectors.clear();
1844
+ this.#modelBindingsApplier.apply();
1845
+ } finally {
1846
+ this.#resumeRebuild();
1847
+ }
1848
+ });
1849
+ }
1850
+
1851
+ /**
1852
+ * Reload models from disk and refresh runtime provider discovery state.
1853
+ */
1838
1854
  async refresh(strategy: ModelRefreshStrategy = "online-if-uncached"): Promise<void> {
1839
1855
  if (this.#disposed) return;
1840
1856
  await this.#enqueueCatalogMutation(async () => {
@@ -1882,7 +1898,8 @@ export class ModelRegistry {
1882
1898
  const refreshGeneration = this.#catalogRefreshGeneration;
1883
1899
  this.#suspendRebuild();
1884
1900
  try {
1885
- this.#reloadStaticModels();
1901
+ // A provider refresh only updates discovery for this provider.
1902
+ // Static catalog changes are serialized through refresh().
1886
1903
  for (const selector of this.#suppressedSelectors.keys()) {
1887
1904
  if (selector.startsWith(`${providerId}/`)) {
1888
1905
  this.#suppressedSelectors.delete(selector);
@@ -3026,6 +3043,7 @@ export class ModelRegistry {
3026
3043
  refreshGeneration = this.#catalogRefreshGeneration,
3027
3044
  providerRefresh?: ProviderRefreshFence,
3028
3045
  ): Promise<void> {
3046
+ const profileAvailabilityEvidenceBefore = this.#profileAvailabilityEvidenceFingerprint();
3029
3047
  const disabledProviders = getDisabledProviderIdsFromSettings(this.#settings);
3030
3048
  const selectedDiscoverableProviders = (
3031
3049
  providerFilter
@@ -3232,7 +3250,10 @@ export class ModelRegistry {
3232
3250
  });
3233
3251
  }
3234
3252
  if (discovered.length === 0) {
3235
- if (clearPublishedModelIds.size > 0) {
3253
+ if (
3254
+ clearPublishedModelIds.size > 0 ||
3255
+ profileAvailabilityEvidenceBefore !== this.#profileAvailabilityEvidenceFingerprint()
3256
+ ) {
3236
3257
  this.#rebuildCanonicalIndex();
3237
3258
  }
3238
3259
  return;
@@ -3240,6 +3261,23 @@ export class ModelRegistry {
3240
3261
  this.#mergeDiscoveredModels(discovered);
3241
3262
  }
3242
3263
 
3264
+ #profileAvailabilityEvidenceFingerprint(): string {
3265
+ return JSON.stringify(
3266
+ [...this.#descriptorDiscoveryEvidence.entries()]
3267
+ .sort(([left], [right]) => left.localeCompare(right))
3268
+ .map(([provider, evidence]) => [
3269
+ provider,
3270
+ evidence.fresh,
3271
+ evidence.profileFresh,
3272
+ evidence.authGeneration,
3273
+ evidence.endpoint,
3274
+ evidence.profileEndpoint,
3275
+ [...evidence.modelIds].sort(),
3276
+ evidence.profileModelIds === undefined ? undefined : [...evidence.profileModelIds].sort(),
3277
+ ]),
3278
+ );
3279
+ }
3280
+
3243
3281
  #mergeDiscoveredModels(discovered: readonly Model<Api>[]): void {
3244
3282
  const discoveredModels = this.#applyHardcodedModelPolicies(
3245
3283
  discovered.map(model =>
@@ -3537,8 +3575,9 @@ export class ModelRegistry {
3537
3575
  // through discovery.type.
3538
3576
  if (this.#configuredProviderIds.has("vllm")) configuredDiscoveryProviders.add("vllm");
3539
3577
  if (this.#configuredProviderIds.has("sglang")) configuredDiscoveryProviders.add("sglang");
3540
- const managerOptions = (await this.#collectBuiltInModelManagerOptions(configuredDiscoveryProviders)).filter(
3541
- entry => (providerFilter ? providerFilter.has(entry.options.providerId) : true),
3578
+ const managerOptions = await this.#collectBuiltInModelManagerOptions(
3579
+ configuredDiscoveryProviders,
3580
+ providerFilter,
3542
3581
  );
3543
3582
  if (managerOptions.length === 0) {
3544
3583
  return [];
@@ -3551,6 +3590,7 @@ export class ModelRegistry {
3551
3590
 
3552
3591
  async #collectBuiltInModelManagerOptions(
3553
3592
  excludedProviderIds: ReadonlySet<string> = new Set(),
3593
+ providerFilter?: ReadonlySet<string>,
3554
3594
  ): Promise<ModelManagerDiscoveryOptions[]> {
3555
3595
  const specialProviderDescriptors: Array<{
3556
3596
  providerId: string;
@@ -3594,10 +3634,16 @@ export class ModelRegistry {
3594
3634
  ];
3595
3635
  const disabledProviders = getDisabledProviderIdsFromSettings(this.#settings);
3596
3636
  const standardProviderDescriptors = PROVIDER_DESCRIPTORS.filter(
3597
- descriptor => !disabledProviders.has(descriptor.providerId) && !excludedProviderIds.has(descriptor.providerId),
3637
+ descriptor =>
3638
+ !disabledProviders.has(descriptor.providerId) &&
3639
+ !excludedProviderIds.has(descriptor.providerId) &&
3640
+ (providerFilter === undefined || providerFilter.has(descriptor.providerId)),
3598
3641
  );
3599
3642
  const enabledSpecialProviderDescriptors = specialProviderDescriptors.filter(
3600
- descriptor => !disabledProviders.has(descriptor.providerId) && !excludedProviderIds.has(descriptor.providerId),
3643
+ descriptor =>
3644
+ !disabledProviders.has(descriptor.providerId) &&
3645
+ !excludedProviderIds.has(descriptor.providerId) &&
3646
+ (providerFilter === undefined || providerFilter.has(descriptor.providerId)),
3601
3647
  );
3602
3648
  for (const descriptor of standardProviderDescriptors) {
3603
3649
  if (!descriptor.allowUnauthenticated) continue;
@@ -62,6 +62,54 @@ export function parseModelString(
62
62
  : { provider, id: modelStr.slice(slashIdx + 1) };
63
63
  }
64
64
 
65
+ export function resolveStartupModelRefreshSelectors(
66
+ options: {
67
+ model?: string;
68
+ provider?: string;
69
+ credential?: string;
70
+ continue?: boolean;
71
+ resume?: string | boolean;
72
+ hasStartupProfile: boolean;
73
+ },
74
+ settings: ModelRoleSettings,
75
+ ): ModelSelectorValue | undefined {
76
+ if (options.credential) return undefined;
77
+ if (options.model) {
78
+ return options.provider && !options.model.toLowerCase().startsWith(`${options.provider.toLowerCase()}/`)
79
+ ? `${options.provider}/${options.model}`
80
+ : options.model;
81
+ }
82
+ if (options.hasStartupProfile) return undefined;
83
+ if (options.continue || options.resume) return undefined;
84
+ return resolveConfiguredModelPatterns(settings.getModelRole("default"), settings);
85
+ }
86
+
87
+ export async function refreshMissingQualifiedModelProviders(
88
+ selectors: ModelSelectorValue | undefined,
89
+ modelRegistry: Pick<ModelRegistry, "getAvailable" | "getDiscoverableProviders" | "refreshProvider">,
90
+ ): Promise<boolean> {
91
+ const refreshedProviders = new Set<string>();
92
+ for (const selector of normalizeModelSelectorValue(selectors)) {
93
+ const parsedSelector = parseModelString(selector);
94
+ if (!parsedSelector) continue;
95
+ const provider = modelRegistry
96
+ .getDiscoverableProviders()
97
+ .find(candidate => candidate.toLowerCase() === parsedSelector.provider.toLowerCase());
98
+ if (!provider || refreshedProviders.has(provider)) continue;
99
+ const availableModels = modelRegistry.getAvailable();
100
+ const rawModelId = selector.slice(selector.indexOf("/") + 1);
101
+ if (
102
+ resolveProviderModelReference(provider, rawModelId, availableModels) ||
103
+ resolveProviderModelReference(provider, parsedSelector.id, availableModels)
104
+ )
105
+ continue;
106
+
107
+ await modelRegistry.refreshProvider(provider, "online-if-uncached");
108
+ refreshedProviders.add(provider);
109
+ }
110
+ return refreshedProviders.size > 0;
111
+ }
112
+
65
113
  /**
66
114
  * Format a model as "provider/modelId" string.
67
115
  */
@@ -111,7 +111,8 @@ export type StatusLineSegmentId =
111
111
  | "cache_read"
112
112
  | "cache_write"
113
113
  | "session_name"
114
- | "usage";
114
+ | "usage"
115
+ | "command";
115
116
 
116
117
  /** Submenu choice metadata. */
117
118
  export type SubmenuOption<V extends string = string> = {
@@ -1242,6 +1243,26 @@ export const SETTINGS_SCHEMA = {
1242
1243
  },
1243
1244
  },
1244
1245
 
1246
+ reasoningLanguage: {
1247
+ type: "enum",
1248
+ values: ["off", "english"] as const,
1249
+ default: "off",
1250
+ ui: {
1251
+ tab: "model",
1252
+ label: "Reasoning Language",
1253
+ description:
1254
+ "Optionally reason through development work in English while preserving the user's response language",
1255
+ options: [
1256
+ { value: "off", label: "Off", description: "Do not add reasoning-language guidance" },
1257
+ {
1258
+ value: "english",
1259
+ label: "English",
1260
+ description: "Reason through technical work in English and answer in the user's language",
1261
+ },
1262
+ ],
1263
+ },
1264
+ },
1265
+
1245
1266
  hideThinkingBlock: {
1246
1267
  type: "boolean",
1247
1268
  default: false,
@@ -1749,6 +1770,17 @@ export const SETTINGS_SCHEMA = {
1749
1770
  },
1750
1771
  },
1751
1772
 
1773
+ "paseo.autoImport": {
1774
+ type: "boolean",
1775
+ default: false,
1776
+ ui: {
1777
+ tab: "interaction",
1778
+ label: "Announce Sessions to Paseo",
1779
+ description:
1780
+ "Global-only operator opt-in. When enabled, and only when a GJC ACP provider is already registered in Paseo and its daemon is already running, import each interactive session into Paseo so it can be driven from there. Project settings cannot enable it. Never starts the Paseo daemon or removes imported entries; stale entries must be pruned in Paseo. Never runs when Paseo is absent.",
1781
+ },
1782
+ },
1783
+
1752
1784
  "crashReport.nudge": {
1753
1785
  type: "boolean",
1754
1786
  default: true,
@@ -333,6 +333,7 @@ const PATH_SCOPED_ARRAY_SETTINGS = new Set<SettingPath>(["enabledModels", "disab
333
333
  const GLOBAL_ONLY_SETTINGS = new Set<SettingPath>([
334
334
  "crashReport.upstream",
335
335
  "crashReport.upstreamDsn",
336
+ "paseo.autoImport",
336
337
  "ui.language",
337
338
  "telemetry.enabled",
338
339
  ]);
@@ -73,6 +73,7 @@ const FALLBACK_MODELS: GrokCliModelConfig[] = [
73
73
  cost: COST_43,
74
74
  contextWindow: 1_000_000,
75
75
  maxTokens: 30_000,
76
+ maxReasoningEffort: Effort.High,
76
77
  },
77
78
  {
78
79
  // Official metadata and pricing: https://docs.x.ai/developers/models/grok-4.6
@@ -158,23 +159,22 @@ function matchesModelFamily(name: string, modelId: string): boolean {
158
159
  return name.startsWith(`${modelId}-`) && /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(suffix);
159
160
  }
160
161
 
162
+ const REASONING_EFFORT_CAPS = [
163
+ { prefix: 'grok-3-mini', max: Effort.High },
164
+ { prefix: 'grok-4.3', max: Effort.High },
165
+ { prefix: 'grok-4.5', max: Effort.High },
166
+ { prefix: 'grok-4.6', max: Effort.XHigh },
167
+ { prefix: 'grok-4.20-multi-agent', max: Effort.XHigh },
168
+ ] as const;
169
+
161
170
  export function getMaxReasoningEffort(modelId: string): Effort | undefined {
162
171
  const name = getCanonicalModelName(modelId);
163
- return FALLBACK_MODELS.find((model) => matchesModelFamily(name, model.id.toLowerCase()))
164
- ?.maxReasoningEffort;
172
+ return REASONING_EFFORT_CAPS.find(({ prefix }) => matchesModelFamily(name, prefix))?.max;
165
173
  }
166
174
 
167
- const EFFORT_CAPABLE_PREFIXES = [
168
- 'grok-3-mini',
169
- 'grok-4.20-multi-agent',
170
- 'grok-4.3',
171
- 'grok-4.5',
172
- 'grok-4.6',
173
- ];
174
-
175
175
  export function supportsReasoningEffort(modelId: string): boolean {
176
176
  const name = getCanonicalModelName(modelId);
177
- if (!EFFORT_CAPABLE_PREFIXES.some((prefix) => matchesModelFamily(name, prefix))) {
177
+ if (!REASONING_EFFORT_CAPS.some(({ prefix }) => matchesModelFamily(name, prefix))) {
178
178
  return false;
179
179
  }
180
180
  const model = resolveModels().find((entry) => getCanonicalModelName(entry.id) === name);