@gajae-code/coding-agent 0.18.5 → 0.18.7

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 +140 -0
  2. package/dist/types/capability/types.d.ts +4 -0
  3. package/dist/types/cli-commands.d.ts +10 -0
  4. package/dist/types/cli-ordinary.d.ts +13 -0
  5. package/dist/types/cli.d.ts +12 -3
  6. package/dist/types/commands/contribution-prep.d.ts +0 -3
  7. package/dist/types/commands/sdk.d.ts +3 -0
  8. package/dist/types/commands/update.d.ts +1 -0
  9. package/dist/types/config/model-profile-activation.d.ts +19 -0
  10. package/dist/types/config/settings.d.ts +4 -0
  11. package/dist/types/coordinator-mcp/session-reaper.d.ts +5 -0
  12. package/dist/types/discovery/helpers.d.ts +2 -2
  13. package/dist/types/edit/modes/replace.d.ts +3 -3
  14. package/dist/types/edit/streaming.d.ts +2 -2
  15. package/dist/types/exec/bash-shell-supervisor.d.ts +12 -0
  16. package/dist/types/runtime-mcp/elicitation.d.ts +12 -0
  17. package/dist/types/sdk/broker/discovery.d.ts +8 -0
  18. package/dist/types/sdk/broker/ensure.d.ts +1 -0
  19. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +4 -0
  20. package/dist/types/sdk/broker/lifecycle.d.ts +9 -0
  21. package/dist/types/sdk/broker/runtime.d.ts +1 -1
  22. package/dist/types/sdk/broker/task-artifact-owner-validation.d.ts +55 -0
  23. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +1 -1
  24. package/dist/types/sdk/bus/telegram-daemon.d.ts +4 -2
  25. package/dist/types/sdk/cli/diagnostics-cli.d.ts +18 -0
  26. package/dist/types/sdk/diagnostics/observe-broker.d.ts +185 -0
  27. package/dist/types/sdk/host/control/operations.d.ts +1 -1
  28. package/dist/types/sdk/host/host.d.ts +6 -0
  29. package/dist/types/sdk/index.d.ts +1 -0
  30. package/dist/types/sdk/router/session-router.d.ts +9 -0
  31. package/dist/types/sdk/session.d.ts +13 -0
  32. package/dist/types/session/agent-session.d.ts +5 -1
  33. package/dist/types/session/internal/managed-session-storage.d.ts +11 -1
  34. package/dist/types/session/internal/task-artifact-owner-access.d.ts +17 -0
  35. package/dist/types/session/internal/task-artifact-owner-transcript.d.ts +3 -0
  36. package/dist/types/session/session-retirement.d.ts +47 -0
  37. package/dist/types/session/session-storage.d.ts +0 -46
  38. package/dist/types/session/session-transcript-header.d.ts +2 -0
  39. package/dist/types/session/task-artifact-owner-codec.d.ts +107 -0
  40. package/dist/types/session/task-artifact-owner-retirement.d.ts +7 -0
  41. package/dist/types/session/task-artifact-owner.d.ts +10 -0
  42. package/dist/types/tools/ask-contract.d.ts +75 -254
  43. package/dist/types/tools/bash.d.ts +1 -1
  44. package/package.json +7 -7
  45. package/scripts/build-sdk-package-smoke.ts +5 -0
  46. package/scripts/resident-memory-bench.ts +174 -20
  47. package/src/capability/index.ts +1 -0
  48. package/src/capability/types.ts +4 -0
  49. package/src/cli/config-cli.ts +8 -18
  50. package/src/cli/public-command-entry.ts +5 -0
  51. package/src/cli/ssh-cli.ts +6 -3
  52. package/src/cli/web-search-cli.ts +9 -3
  53. package/src/cli-commands.ts +14 -0
  54. package/src/cli-main.ts +7 -8
  55. package/src/cli-ordinary.ts +197 -0
  56. package/src/cli.ts +25 -182
  57. package/src/commands/contribution-prep.ts +1 -3
  58. package/src/commands/sdk.ts +171 -21
  59. package/src/commands/update.ts +15 -1
  60. package/src/config/file-lock.ts +91 -2
  61. package/src/config/model-profile-activation.ts +70 -4
  62. package/src/config/model-profile-contract.ts +4 -1
  63. package/src/config/model-profiles.ts +23 -31
  64. package/src/config/model-resolver.ts +9 -1
  65. package/src/config/settings.ts +44 -4
  66. package/src/coordinator-mcp/event-webhook.ts +19 -14
  67. package/src/coordinator-mcp/policy.ts +14 -4
  68. package/src/coordinator-mcp/server.ts +142 -7
  69. package/src/coordinator-mcp/session-reaper.ts +9 -1
  70. package/src/defaults/gjc/skills/deep-interview/SKILL.md +1 -1
  71. package/src/discovery/helpers.ts +9 -3
  72. package/src/discovery/ssh.ts +8 -3
  73. package/src/edit/modes/replace.ts +6 -6
  74. package/src/edit/streaming.ts +15 -15
  75. package/src/exec/bash-shell-supervisor.ts +60 -3
  76. package/src/gjc-runtime/gc-runtime.ts +2 -1
  77. package/src/hindsight/config.ts +9 -3
  78. package/src/internal-urls/docs-index.generated.ts +11 -10
  79. package/src/memories/index.ts +8 -2
  80. package/src/modes/acp/acp-agent.ts +724 -370
  81. package/src/modes/components/tool-execution.ts +1 -1
  82. package/src/prompts/tools/ask.md +2 -0
  83. package/src/runtime-mcp/client.ts +13 -4
  84. package/src/runtime-mcp/elicitation.ts +142 -0
  85. package/src/sdk/broker/broker.ts +54 -1
  86. package/src/sdk/broker/daemon-entry.ts +67 -6
  87. package/src/sdk/broker/discovery.ts +8 -0
  88. package/src/sdk/broker/ensure.ts +156 -20
  89. package/src/sdk/broker/lifecycle-ledger.ts +110 -12
  90. package/src/sdk/broker/lifecycle.ts +334 -348
  91. package/src/sdk/broker/runtime.ts +5 -1
  92. package/src/sdk/broker/task-artifact-owner-validation.ts +259 -0
  93. package/src/sdk/broker/transport.ts +5 -1
  94. package/src/sdk/bus/chat-daemon-control.ts +2 -2
  95. package/src/sdk/bus/chat-daemon-runtime.ts +3 -0
  96. package/src/sdk/bus/index.ts +3 -3
  97. package/src/sdk/bus/telegram-daemon-contract.ts +1 -1
  98. package/src/sdk/bus/telegram-daemon-control.ts +15 -12
  99. package/src/sdk/bus/telegram-daemon.ts +38 -3
  100. package/src/sdk/cli/diagnostics-cli.ts +247 -0
  101. package/src/sdk/diagnostics/observe-broker.ts +654 -0
  102. package/src/sdk/host/control/dispatch.ts +52 -4
  103. package/src/sdk/host/control/operations.ts +1 -1
  104. package/src/sdk/host/host.ts +6 -0
  105. package/src/sdk/host/session-runtime.test.ts +83 -4
  106. package/src/sdk/host/session-runtime.ts +15 -7
  107. package/src/sdk/index.ts +1 -0
  108. package/src/sdk/lifecycle-session.ts +1 -0
  109. package/src/sdk/mcp/server.ts +15 -3
  110. package/src/sdk/protocol/operation-registry.ts +1 -1
  111. package/src/sdk/router/session-router.ts +26 -3
  112. package/src/sdk/session.ts +51 -8
  113. package/src/session/agent-session.ts +169 -43
  114. package/src/session/blob-store.ts +78 -33
  115. package/src/session/internal/managed-session-storage.ts +203 -4
  116. package/src/session/internal/task-artifact-owner-access.ts +229 -0
  117. package/src/session/internal/task-artifact-owner-transcript.ts +68 -0
  118. package/src/session/session-retirement.ts +155 -0
  119. package/src/session/session-storage.ts +1 -160
  120. package/src/session/session-transcript-header.ts +17 -0
  121. package/src/session/task-artifact-owner-codec.ts +578 -0
  122. package/src/session/task-artifact-owner-retirement.ts +475 -0
  123. package/src/session/task-artifact-owner.ts +289 -0
  124. package/src/setup/hermes/templates/operator-instructions.v1.md +19 -1
  125. package/src/setup/hermes-setup.ts +4 -1
  126. package/src/slash-commands/builtin-registry.ts +8 -5
  127. package/src/tools/ask-contract.test.ts +76 -1
  128. package/src/tools/ask-contract.ts +57 -6
  129. package/src/tools/ask.ts +16 -7
  130. package/src/tools/bash.ts +38 -29
  131. package/src/tools/descriptors.test.ts +29 -0
  132. package/src/tools/tool-catalog.generated.ts +7 -4
  133. package/src/web/search/index.ts +2 -2
@@ -1,9 +1,15 @@
1
- import { createHash } from "node:crypto";
1
+ import { type ChildProcess, spawn } from "node:child_process";
2
+ import { createHash, randomUUID } from "node:crypto";
2
3
  import * as nodeFs from "node:fs";
3
4
  import * as fs from "node:fs/promises";
4
5
  import * as os from "node:os";
5
6
  import * as path from "node:path";
6
- import { exactRemoveDirectoryTree, type NativeDirectoryTreeSnapshot, snapshotDirectoryTree } from "@gajae-code/natives";
7
+ import {
8
+ exactRemoveDirectoryTree,
9
+ exactUnlinkDirect,
10
+ type NativeDirectoryTreeSnapshot,
11
+ snapshotDirectoryTree,
12
+ } from "@gajae-code/natives";
7
13
  import { logger, postmortem } from "@gajae-code/utils";
8
14
  import { CliParseError, Command } from "@gajae-code/utils/cli";
9
15
  import type { Args as ParsedArgs } from "../cli/args";
@@ -23,6 +29,7 @@ import {
23
29
  writeBrokerStartupExitRecordBounded,
24
30
  } from "../sdk/broker/broker-exit";
25
31
  import { readBrokerDiscovery } from "../sdk/broker/discovery";
32
+ import { type EndpointFileRead, readEndpointFile } from "../sdk/broker/endpoint-authority";
26
33
  import {
27
34
  emitBrokerStartupTestSignal,
28
35
  reconcileBrokerGenerationForStartup,
@@ -42,6 +49,7 @@ import {
42
49
  writeSessionLifecycleReady,
43
50
  } from "../sdk/broker/lifecycle";
44
51
  import { processIncarnation } from "../sdk/broker/process-incarnation";
52
+ import { resolveSdkInternalSpawnCommand } from "../sdk/broker/runtime";
45
53
  import { writeBrokerStartupFailureMarker } from "../sdk/broker/startup-failure";
46
54
  import { runSdkStderrDrainer } from "../sdk/broker/stderr-drainer";
47
55
  import { renderSdkSearchTable, runSdkSearch, runSdkSessionCli } from "../sdk/cli";
@@ -651,6 +659,7 @@ export async function runSessionHost(
651
659
  let startupComplete = false;
652
660
  let readinessPublicationCleanupComplete = true;
653
661
  let revokePendingReadinessMarker: (() => Promise<boolean>) | undefined;
662
+ let revokePublishedReadinessMarker: (() => Promise<boolean>) | undefined;
654
663
  let readinessRevocation: Promise<boolean> | undefined;
655
664
  let startupInterruption: SdkStartupFailure | undefined;
656
665
  const interrupted = Promise.withResolvers<SdkStartupFailure>();
@@ -1265,20 +1274,76 @@ export async function runSessionHost(
1265
1274
  return failureRollback;
1266
1275
  };
1267
1276
  const sessionEndpointPath = path.join(request.stateRoot, "sdk", `${request.sessionId}.json`);
1268
- const exitAfterSessionDisposal = async (reason?: "detached_idle"): Promise<void> => {
1269
- await disposeSession(reason);
1277
+ const removeOwnedSessionEndpoint = async (): Promise<boolean> => {
1278
+ try {
1279
+ await fs.stat(sessionEndpointPath);
1280
+ } catch (error) {
1281
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return true;
1282
+ return false;
1283
+ }
1284
+ const endpoint: EndpointFileRead | undefined = await readEndpointFile(sessionEndpointPath);
1285
+ if (!endpoint) return false;
1286
+ let parsed: { pid?: unknown; sessionId?: unknown };
1287
+ try {
1288
+ parsed = JSON.parse(endpoint.source) as { pid?: unknown; sessionId?: unknown };
1289
+ } catch {
1290
+ return false;
1291
+ }
1292
+ if (parsed.pid !== process.pid || parsed.sessionId !== request.sessionId) {
1293
+ return false;
1294
+ }
1295
+ const removal = (() => {
1296
+ try {
1297
+ return exactUnlinkDirect(sessionEndpointPath, {
1298
+ dev: endpoint.dev,
1299
+ ino: endpoint.ino,
1300
+ nlink: endpoint.nlink,
1301
+ size: endpoint.size,
1302
+ mtimeNs: endpoint.mtimeNs,
1303
+ quarantineName: `.gjc-delete-endpoint-${request.sessionId}-${randomUUID()}`,
1304
+ sha256: createHash("sha256").update(endpoint.source).digest("hex"),
1305
+ allowHardLink: true,
1306
+ });
1307
+ } catch (error) {
1308
+ logger.warn("SDK host endpoint cleanup failed", { error: String(error) });
1309
+ return undefined;
1310
+ }
1311
+ })();
1312
+ if (!removal) return false;
1313
+ return (
1314
+ removal.ok ||
1315
+ removal.code === "not_found" ||
1316
+ (removal.code === "cleanup_pending" &&
1317
+ removal.payloadDurable === true &&
1318
+ !removal.retainedSuccessorPath &&
1319
+ !removal.retainedUnknownPath)
1320
+ );
1321
+ };
1322
+ let unregisterHostPostmortem: (() => void) | undefined;
1323
+ let sessionDisposalExit: Promise<void> | undefined;
1324
+ const exitAfterSessionDisposal = async (reason?: "detached_idle", exitProcess = true): Promise<void> => {
1270
1325
  let failure: SdkStartupFailure | undefined;
1326
+ let ownsEndpoint = false;
1327
+ const endpointCleanup = (async (): Promise<boolean> => {
1328
+ let complete = await removeOwnedSessionEndpoint();
1329
+ for (let attempt = 0; !complete && attempt < 20; attempt += 1) {
1330
+ await Bun.sleep(25);
1331
+ complete = await removeOwnedSessionEndpoint();
1332
+ }
1333
+ return complete;
1334
+ })();
1335
+ let [endpointCleanupComplete, readinessRevoked] = await Promise.all([
1336
+ endpointCleanup,
1337
+ revokePublishedReadinessMarker?.() ?? Promise.resolve(true),
1338
+ ]);
1339
+ await disposeSession(reason);
1340
+ endpointCleanupComplete = await removeOwnedSessionEndpoint();
1271
1341
  try {
1272
1342
  const endpoint = JSON.parse(await fs.readFile(sessionEndpointPath, "utf8")) as {
1273
1343
  pid?: unknown;
1274
1344
  sessionId?: unknown;
1275
1345
  };
1276
- if (endpoint.pid === process.pid && endpoint.sessionId === request.sessionId)
1277
- failure = {
1278
- phase: "startup",
1279
- reason: "failed",
1280
- message: `SDK host endpoint remained after graceful shutdown: ${request.sessionId}`,
1281
- };
1346
+ ownsEndpoint = endpoint.pid === process.pid && endpoint.sessionId === request.sessionId;
1282
1347
  } catch (error) {
1283
1348
  if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
1284
1349
  failure = {
@@ -1286,27 +1351,57 @@ export async function runSessionHost(
1286
1351
  reason: "failed",
1287
1352
  message: `SDK host endpoint cleanup could not be verified: ${request.sessionId}`,
1288
1353
  };
1289
- }
1354
+ } else endpointCleanupComplete = true;
1290
1355
  }
1356
+ if (!ownsEndpoint && !failure && !readinessRevoked && revokePublishedReadinessMarker) {
1357
+ failure = {
1358
+ phase: "startup",
1359
+ reason: "failed",
1360
+ message: `SDK host readiness marker cleanup could not be verified: ${request.sessionId}`,
1361
+ };
1362
+ }
1363
+ if (ownsEndpoint && !endpointCleanupComplete && !failure)
1364
+ failure = {
1365
+ phase: "startup",
1366
+ reason: "failed",
1367
+ message: `SDK host endpoint remained after graceful shutdown: ${request.sessionId}`,
1368
+ };
1291
1369
  if (failure) {
1292
1370
  process.exitCode = 1;
1293
1371
  process.stderr.write(`${failure.message}\n`);
1294
1372
  await writeFailure(failure, rollback.result).catch(() => {});
1295
1373
  }
1296
- process.exit(process.exitCode ?? 0);
1374
+ unregisterHostPostmortem?.();
1375
+ unregisterHostPostmortem = undefined;
1376
+ if (exitProcess) process.exit(process.exitCode ?? 0);
1297
1377
  };
1298
1378
  let stopping = false;
1379
+ const requestSessionStop = (reason?: "detached_idle", exitProcess = true): Promise<void> => {
1380
+ if (stopping) return sessionDisposalExit ?? Promise.resolve();
1381
+ stopping = true;
1382
+ sessionDisposalExit = exitAfterSessionDisposal(reason, exitProcess);
1383
+ return sessionDisposalExit;
1384
+ };
1299
1385
  const stop = (reason?: "detached_idle") => {
1300
1386
  if (startupComplete) {
1301
- if (stopping) return;
1302
- stopping = true;
1303
- void exitAfterSessionDisposal(reason);
1387
+ void requestSessionStop(reason);
1304
1388
  return;
1305
1389
  }
1306
1390
  const failure = capability.normalizeFailure("startup", "failed", "SDK lifecycle host terminated.");
1307
1391
  interruptStartup(failure);
1308
1392
  };
1309
- const onReadySignal = (): void => stop();
1393
+ const registerHostPostmortem = (): void => {
1394
+ if (unregisterHostPostmortem) return;
1395
+ unregisterHostPostmortem = postmortem.register("sdk-session-host:exit", async reason => {
1396
+ if (reason !== postmortem.Reason.SIGTERM && reason !== postmortem.Reason.SIGINT) return;
1397
+ // The postmortem signal authority defaults to 143/130; a session host
1398
+ // signal is graceful and must preserve its cleanup result instead.
1399
+ await requestSessionStop(undefined, false);
1400
+ process.exitCode = 0;
1401
+ process.exit(0);
1402
+ });
1403
+ };
1404
+ registerHostPostmortem();
1310
1405
 
1311
1406
  try {
1312
1407
  const startupThinkingLevel = request.modelId ? parseModelString(request.modelId)?.thinkingLevel : undefined;
@@ -1354,6 +1449,7 @@ export async function runSessionHost(
1354
1449
  effectMarker,
1355
1450
  () => startupInterruption === undefined && now() < request.semanticReadyDeadlineAt,
1356
1451
  revoke => {
1452
+ revokePublishedReadinessMarker = revoke;
1357
1453
  revokePendingReadinessMarker = revoke;
1358
1454
  if (startupInterruption !== undefined) startReadinessRevocation(revoke);
1359
1455
  else if (now() >= request.semanticReadyDeadlineAt) {
@@ -1388,8 +1484,6 @@ export async function runSessionHost(
1388
1484
  revokePendingReadinessMarker = undefined;
1389
1485
  readinessRevocation = undefined;
1390
1486
  removeStartupSignalHandlers();
1391
- process.once("SIGTERM", onReadySignal);
1392
- process.once("SIGINT", onReadySignal);
1393
1487
  } catch (error) {
1394
1488
  if (error instanceof LifecycleReadinessCleanupError) constructionCleanupComplete = false;
1395
1489
  const failure =
@@ -1402,8 +1496,6 @@ export async function runSessionHost(
1402
1496
  await failAfterRollback(durableFailure);
1403
1497
  } finally {
1404
1498
  removeStartupSignalHandlers();
1405
- process.removeListener("SIGTERM", onReadySignal);
1406
- process.removeListener("SIGINT", onReadySignal);
1407
1499
  }
1408
1500
  throw error;
1409
1501
  }
@@ -1441,6 +1533,7 @@ export async function runSessionHost(
1441
1533
 
1442
1534
  export type SdkInternalArgv =
1443
1535
  | { action: "broker-internal"; agentDir: string }
1536
+ | { action: "broker-trampoline-internal"; agentDir: string }
1444
1537
  | { action: "session-host-internal" }
1445
1538
  | { action: "stderr-drain-internal"; logPath: string; maxBytes: number };
1446
1539
 
@@ -1455,6 +1548,14 @@ export function parseSdkInternalArgv(argv: readonly string[]): SdkInternalArgv {
1455
1548
  isSafeSdkInternalAgentDir(argv[2])
1456
1549
  )
1457
1550
  return { action: "broker-internal", agentDir: argv[2] };
1551
+ if (
1552
+ argv[0] === "broker-trampoline-internal" &&
1553
+ argv.length === 3 &&
1554
+ argv[1] === "--agent-dir" &&
1555
+ typeof argv[2] === "string" &&
1556
+ isSafeSdkInternalAgentDir(argv[2])
1557
+ )
1558
+ return { action: "broker-trampoline-internal", agentDir: argv[2] };
1458
1559
  if (
1459
1560
  argv[0] === "stderr-drain-internal" &&
1460
1561
  argv.length === 5 &&
@@ -1494,6 +1595,7 @@ export default class Sdk extends Command {
1494
1595
  async run(): Promise<void> {
1495
1596
  if (
1496
1597
  this.argv[0] !== "broker-internal" &&
1598
+ this.argv[0] !== "broker-trampoline-internal" &&
1497
1599
  this.argv[0] !== "session-host-internal" &&
1498
1600
  this.argv[0] !== "stderr-drain-internal"
1499
1601
  ) {
@@ -1596,7 +1698,55 @@ export default class Sdk extends Command {
1596
1698
  }
1597
1699
 
1598
1700
  const internal = parseSdkInternalArgv(this.argv);
1599
- if (internal.action === "broker-internal") usePostmortemSignalExitAuthority();
1701
+ if (internal.action === "broker-trampoline-internal") {
1702
+ if (process.platform === "win32") throw new CliParseError("Broker trampoline is unavailable on Windows.");
1703
+ const command = resolveSdkInternalSpawnCommand("broker-internal");
1704
+ let child: ChildProcess;
1705
+ try {
1706
+ child = spawn(command.file, [...command.args, "--agent-dir", internal.agentDir], {
1707
+ detached: true,
1708
+ stdio: ["ignore", "ignore", "inherit"],
1709
+ env: command.env,
1710
+ ...(command.kind === "bun-source" ? { cwd: command.cwd } : {}),
1711
+ });
1712
+ } catch (error) {
1713
+ throw new Error("Broker trampoline could not start broker.", { cause: error });
1714
+ }
1715
+ let spawnError: Error | undefined;
1716
+ child.once("error", error => {
1717
+ spawnError = error;
1718
+ });
1719
+ if (child.pid === undefined) throw new Error("Broker trampoline could not start broker.");
1720
+ const incarnation = processIncarnation(child.pid);
1721
+ if (!incarnation) {
1722
+ try {
1723
+ child.kill("SIGKILL");
1724
+ } catch {
1725
+ // The child may already have exited; either way identity was not established.
1726
+ }
1727
+ throw new Error("Broker trampoline could not observe broker identity.");
1728
+ }
1729
+ if (spawnError) throw new Error("Broker trampoline could not start broker.", { cause: spawnError });
1730
+ const stdoutWrite = Promise.withResolvers<void>();
1731
+ process.stdout.write(`${child.pid}\t${incarnation}\n`, error => {
1732
+ if (error) stdoutWrite.reject(error);
1733
+ else stdoutWrite.resolve();
1734
+ });
1735
+ try {
1736
+ await stdoutWrite.promise;
1737
+ } catch (error) {
1738
+ try {
1739
+ child.kill("SIGKILL");
1740
+ } catch {
1741
+ // The child may already have exited; reporting failure remains authoritative.
1742
+ }
1743
+ throw error;
1744
+ }
1745
+ child.unref();
1746
+ return;
1747
+ }
1748
+ if (internal.action === "broker-internal" || internal.action === "session-host-internal")
1749
+ usePostmortemSignalExitAuthority();
1600
1750
  if (internal.action === "session-host-internal") {
1601
1751
  await runSessionHost();
1602
1752
  return;
@@ -13,6 +13,20 @@ import {
13
13
  } from "../config/update-channel";
14
14
  import { initTheme } from "../modes/theme/theme";
15
15
 
16
+ export async function runUpdateRecoveryCommand(
17
+ recover: () => Promise<void> = () => runManagedNotifyRecovery({}),
18
+ ): Promise<void> {
19
+ try {
20
+ await recover();
21
+ } catch (error) {
22
+ const reason = error instanceof Error ? error.message : String(error);
23
+ process.stderr.write(
24
+ `Post-update recovery failed: ${reason}. For each configured notification daemon (telegram, discord, or slack), manually run 'gjc daemon stop <kind> --force' and then 'gjc daemon reload <kind>', then run 'gjc notify recovery'.\n`,
25
+ );
26
+ process.exitCode = 1;
27
+ }
28
+ }
29
+
16
30
  export default class Update extends Command {
17
31
  static description = "Check for and install updates";
18
32
 
@@ -35,7 +49,7 @@ export default class Update extends Command {
35
49
  async run(): Promise<void> {
36
50
  const { args, flags } = await this.parse(Update);
37
51
  if (args.action === "update-recovery") {
38
- await runManagedNotifyRecovery({});
52
+ await runUpdateRecoveryCommand();
39
53
  return;
40
54
  }
41
55
  let channel: UpdateChannel | undefined;
@@ -610,7 +610,7 @@ async function classifyFileLockRemovalTransition(
610
610
  if (!observation) return "active";
611
611
  const info = parseLockInfoBytes(observation.bytes);
612
612
  if (info) {
613
- const stale = await staleLockSnapshot(transitionPath, 0, ownerHostId, previousOwnerHostIds);
613
+ const stale = await staleLockSnapshot(transitionPath, 0, ownerHostId, previousOwnerHostIds, undefined, true);
614
614
  return stale.stale ? "abandoned" : "active";
615
615
  }
616
616
 
@@ -698,6 +698,74 @@ async function adoptOrphanedFileLockRemovalTransition(lockPath: string, orphanAg
698
698
  return false;
699
699
  }
700
700
 
701
+ async function adoptAbandonedFileLockRemovalTransition(
702
+ lockPath: string,
703
+ ownerHostId?: string,
704
+ previousOwnerHostIds: readonly string[] = [],
705
+ ): Promise<boolean> {
706
+ const transitionPath = fileLockRemovalTransitionPath(lockPath);
707
+ const stale = await staleLockSnapshot(transitionPath, 0, ownerHostId, previousOwnerHostIds, undefined, true);
708
+ if (!stale.stale || !stale.identity) return false;
709
+ if (!(await isNativeExactRemovalUsable())) return false;
710
+
711
+ let captured: NativeDirectoryTreeResult;
712
+ try {
713
+ captured = nativeFileLockBindings().snapshotDirectoryTree(transitionPath);
714
+ } catch (error) {
715
+ if (isTransientReleaseError(error)) return false;
716
+ throw error;
717
+ }
718
+ if (!captured.ok || !captured.snapshot) return false;
719
+ const snapshot = captured.snapshot;
720
+ const root = snapshot.entries.find(entry => entry.relativePath === "");
721
+ const infoEntry = snapshot.entries.find(entry => entry.relativePath === "info");
722
+ if (
723
+ root?.kind !== "directory" ||
724
+ infoEntry?.kind !== "file" ||
725
+ snapshot.entries.some(
726
+ entry =>
727
+ entry.relativePath !== "" &&
728
+ entry.relativePath !== "info" &&
729
+ entry.kind !== "directory" &&
730
+ (entry.kind !== "file" || entry.size !== "0"),
731
+ )
732
+ )
733
+ return false;
734
+ const currentIdentity = await captureFileLockDirIdentity(transitionPath);
735
+ if (!currentIdentity || !sameStableFileLockIdentity(currentIdentity, stale.identity)) return false;
736
+ if (
737
+ !nativeFileLockInfoMatchesStableIdentity(snapshot.rootDev, snapshot.rootIno, infoEntry, stale.identity) ||
738
+ !nativeFileLockInfoMatchesContentEvidence(infoEntry, stale.identity)
739
+ )
740
+ return false;
741
+
742
+ let removal: NativeExactUnlinkResult;
743
+ try {
744
+ removal = nativeFileLockBindings().exactRemoveDirectoryTree(transitionPath, snapshot);
745
+ } catch (error) {
746
+ if (isTransientReleaseError(error)) return false;
747
+ throw error;
748
+ }
749
+ if (removal.ok === true) return removal.code === undefined && Object.keys(removal).every(key => key === "ok");
750
+ if (
751
+ removal.ok === false &&
752
+ removal.code === "cleanup_pending" &&
753
+ removal.payloadDurable === true &&
754
+ removal.detachedPath !== undefined &&
755
+ path.resolve(removal.detachedPath) === path.resolve(transitionPath) &&
756
+ removal.retainedSuccessorPath === undefined &&
757
+ removal.retainedPlaceholderPath === undefined &&
758
+ removal.retainedUnknownPath === undefined &&
759
+ Object.keys(removal).every(key => ["ok", "code", "payloadDurable", "detachedPath"].includes(key))
760
+ )
761
+ return await removeDetachedLockQuarantineOnDisk(transitionPath, snapshot.rootDev, snapshot.rootIno);
762
+ return (
763
+ removal.ok === false &&
764
+ removal.code === "not_found" &&
765
+ Object.keys(removal).every(key => key === "ok" || key === "code")
766
+ );
767
+ }
768
+
701
769
  function sameFileLockTreeAfterPublication(
702
770
  staged: NativeDirectoryTreeSnapshot,
703
771
  published: NativeDirectoryTreeSnapshot,
@@ -1488,6 +1556,14 @@ async function staleLockSnapshot(
1488
1556
  ownerHostId?: string,
1489
1557
  previousOwnerHostIds: readonly string[] = [],
1490
1558
  startTimeCache?: Map<string, string | null>,
1559
+ // `.removing` transitions are internal cleanup state this codebase's own removal
1560
+ // machinery produced, never a live holder's public identity: a hostless transition
1561
+ // record predates host-id stamping and is reclaimable by local liveness proof alone,
1562
+ // regardless of the acquirer's own host id. Active `<file>.lock` holders keep the
1563
+ // stricter fail-closed rule (see `lockRecordIsForeignHost`) because that record can
1564
+ // belong to a genuinely foreign process; only the two transition-specific callers
1565
+ // below set this.
1566
+ hostlessTransitionIsLocal = false,
1491
1567
  ): Promise<LockStaleSnapshot> {
1492
1568
  // Capture the root and info inode BEFORE asking whether the owner is stale. A later
1493
1569
  // snapshot alone would let a copied successor inherit the stale verdict's authority.
@@ -1521,7 +1597,8 @@ async function staleLockSnapshot(
1521
1597
  // A host-qualified lock may only be reclaimed after proving that its owner is
1522
1598
  // local. Foreign and malformed host-qualified records fail closed: PID values
1523
1599
  // and clocks are not meaningful across hosts.
1524
- if (lockRecordIsForeignHost(info, ownerHostId, previousOwnerHostIds)) return { stale: false };
1600
+ const foreignCheckHostId = hostlessTransitionIsLocal && info.owner_host_id === undefined ? undefined : ownerHostId;
1601
+ if (lockRecordIsForeignHost(info, foreignCheckHostId, previousOwnerHostIds)) return { stale: false };
1525
1602
  if (ownerIncarnationChanged(info, startTimeCache)) {
1526
1603
  if (!judgedIdentity) return { stale: false };
1527
1604
  let currentIdentity: GenericFileLockDirIdentity | null;
@@ -1752,6 +1829,18 @@ async function tryAcquireLock(
1752
1829
  }
1753
1830
  if (transitionState === "orphan_transition")
1754
1831
  return { kind: "orphan_transition", path: fileLockRemovalTransitionPath(destinationPath) };
1832
+ if (transitionState === "abandoned") {
1833
+ if (await adoptAbandonedFileLockRemovalTransition(destinationPath, ownerHostId, previousOwnerHostIds)) {
1834
+ transitionState = await classifyFileLockRemovalTransition(
1835
+ destinationPath,
1836
+ orphanTransitionAgeMs,
1837
+ ownerHostId,
1838
+ previousOwnerHostIds,
1839
+ );
1840
+ } else {
1841
+ return null;
1842
+ }
1843
+ }
1755
1844
  if (transitionState !== null) return null;
1756
1845
  const staged = snapshotDirectoryTree(canonicalPendingPath);
1757
1846
  if (!staged.ok || !staged.snapshot) {
@@ -115,6 +115,7 @@ export interface PrepareModelProfileActivationOptions {
115
115
  >
116
116
  > & {
117
117
  getError?: ModelRegistry["getError"];
118
+ authStorage?: ModelRegistry["authStorage"];
118
119
  };
119
120
  settings: Pick<Settings, "get" | "getGlobal" | "getOverride">;
120
121
  profileName: string;
@@ -475,6 +476,53 @@ export class ModelProfileCredentialError extends Error {
475
476
  }
476
477
  }
477
478
 
479
+ interface SessionPinRegistry {
480
+ authStorage?: {
481
+ hasRuntimeApiKey(provider: string): boolean;
482
+ hasLiteralConfigApiKey(provider: string, owner?: object): boolean;
483
+ hasSessionCredentialUnavailable(provider: string, scopeId?: string): boolean;
484
+ };
485
+ getAuthStorageOwner?(): object;
486
+ }
487
+
488
+ /**
489
+ * Whether an unavailable session credential pin blocks `provider`.
490
+ *
491
+ * Mirrors `AuthStorage.getApiKey` precedence: a runtime `--api-key` override or a literal
492
+ * `models.yml` `apiKey` registered for this registry's owner is resolved before the
493
+ * unavailable-pin marker, so either one keeps the provider usable. An `apiKeyEnv` key does
494
+ * not: `getApiKey` prefers another stored api_key account over it, which would silently
495
+ * retarget the unavailable pin.
496
+ */
497
+ export function isSessionCredentialPinBlocking(
498
+ registry: SessionPinRegistry,
499
+ provider: string,
500
+ sessionId: string | undefined,
501
+ ): boolean {
502
+ const authStorage = registry.authStorage;
503
+ if (!authStorage) return false;
504
+ if (authStorage.hasRuntimeApiKey(provider)) return false;
505
+ if (authStorage.hasLiteralConfigApiKey(provider, registry.getAuthStorageOwner?.())) return false;
506
+ return authStorage.hasSessionCredentialUnavailable(provider, sessionId);
507
+ }
508
+
509
+ async function getProfileProviderApiKey(
510
+ registry: PrepareModelProfileActivationOptions["modelRegistry"],
511
+ provider: string,
512
+ sessionId: string,
513
+ profileLabel: string,
514
+ ): Promise<string | undefined> {
515
+ const pinBlocking = () => isSessionCredentialPinBlocking(registry, provider, sessionId);
516
+ if (pinBlocking()) throw new ModelProfileCredentialError(profileLabel, [provider]);
517
+ try {
518
+ return await registry.getApiKeyForProvider(provider, sessionId);
519
+ } catch (error) {
520
+ // OAuth selection can invalidate a pin while the credential probe is running.
521
+ if (pinBlocking()) throw new ModelProfileCredentialError(profileLabel, [provider]);
522
+ throw error;
523
+ }
524
+ }
525
+
478
526
  export function formatModelProfileCredentialError(profileLabel: string, providers: readonly string[]): string {
479
527
  return `Model profile "${profileLabel}" requires credentials for: ${providers.join(", ")}. Run /login and configure the missing provider(s), then retry.`;
480
528
  }
@@ -762,7 +810,12 @@ export async function resolveModelProfileDefaultChain(options: {
762
810
  ])) {
763
811
  let apiKey: string | undefined;
764
812
  try {
765
- apiKey = await options.modelRegistry.getApiKeyForProvider(provider, options.credentialSessionId);
813
+ apiKey = await getProfileProviderApiKey(
814
+ options.modelRegistry,
815
+ provider,
816
+ options.credentialSessionId,
817
+ profileLabel,
818
+ );
766
819
  } catch (error) {
767
820
  if (requiredProviderSet.has(provider) && !alternativeSet.has(provider)) throw error;
768
821
  continue;
@@ -787,7 +840,12 @@ export async function resolveModelProfileDefaultChain(options: {
787
840
  const proxyApiKey =
788
841
  proxyProvider === undefined
789
842
  ? undefined
790
- : await options.modelRegistry.getApiKeyForProvider(proxyProvider, options.credentialSessionId);
843
+ : await getProfileProviderApiKey(
844
+ options.modelRegistry,
845
+ proxyProvider,
846
+ options.credentialSessionId,
847
+ profileLabel,
848
+ );
791
849
  if (
792
850
  proxyProvider !== undefined &&
793
851
  !isModelProfileProxyConfigured(proxyProvider, configuredProviderIds, proxyApiKey === kNoAuth)
@@ -886,6 +944,8 @@ export async function resolveModelProfileDefaultChain(options: {
886
944
  aliasIntent: "preset-equivalent",
887
945
  canonicalSessionId: null,
888
946
  credentialSessionId: options.credentialSessionId,
947
+ isCredentialUnavailable: provider =>
948
+ isSessionCredentialPinBlocking(options.modelRegistry, provider, options.credentialSessionId),
889
949
  },
890
950
  );
891
951
  return { profileName, entries: defaultChain, ...resolution };
@@ -1083,6 +1143,8 @@ async function resolveAndClampSelectorValue(
1083
1143
  aliasIntent: options.aliasIntent,
1084
1144
  canonicalSessionId: options.sessionId,
1085
1145
  credentialSessionId: options.credentialSessionId,
1146
+ isCredentialUnavailable: provider =>
1147
+ isSessionCredentialPinBlocking(options.modelRegistry, provider, options.credentialSessionId),
1086
1148
  },
1087
1149
  );
1088
1150
  resolved = {
@@ -1213,6 +1275,8 @@ async function concretizeProfileSelectorValue(
1213
1275
  aliasIntent: "preset-equivalent",
1214
1276
  canonicalSessionId: prepared.session.sessionId,
1215
1277
  credentialSessionId,
1278
+ isCredentialUnavailable: provider =>
1279
+ isSessionCredentialPinBlocking(prepared.modelRegistry, provider, credentialSessionId),
1216
1280
  },
1217
1281
  )
1218
1282
  : resolveModelRoleValue(selector, candidates, {
@@ -1322,7 +1386,7 @@ export async function prepareModelProfileActivation(
1322
1386
  for (const provider of authenticationProbeProviders) {
1323
1387
  let apiKey: string | undefined;
1324
1388
  try {
1325
- apiKey = await options.modelRegistry.getApiKeyForProvider(provider, credentialSessionId);
1389
+ apiKey = await getProfileProviderApiKey(options.modelRegistry, provider, credentialSessionId, profileLabel);
1326
1390
  } catch (error) {
1327
1391
  if (requiredProviderSet.has(provider) && !alternativeSet.has(provider)) throw error;
1328
1392
  continue;
@@ -1357,7 +1421,7 @@ export async function prepareModelProfileActivation(
1357
1421
  const proxyApiKey =
1358
1422
  proxyProvider === undefined
1359
1423
  ? undefined
1360
- : await options.modelRegistry.getApiKeyForProvider(proxyProvider, credentialSessionId);
1424
+ : await getProfileProviderApiKey(options.modelRegistry, proxyProvider, credentialSessionId, profileLabel);
1361
1425
  if (proxyProvider !== undefined) {
1362
1426
  const configuredProxyProviders = options.modelRegistry.getConfiguredProviderIds?.();
1363
1427
  if (!isModelProfileProxyConfigured(proxyProvider, configuredProxyProviders, proxyApiKey === kNoAuth)) {
@@ -1462,6 +1526,8 @@ export async function prepareModelProfileActivation(
1462
1526
  aliasIntent: "preset-equivalent",
1463
1527
  canonicalSessionId: options.session.sessionId,
1464
1528
  credentialSessionId,
1529
+ isCredentialUnavailable: provider =>
1530
+ isSessionCredentialPinBlocking(options.modelRegistry, provider, credentialSessionId),
1465
1531
  },
1466
1532
  );
1467
1533
  const defaultModel = defaultResolution.model;
@@ -4,7 +4,10 @@ export const MODEL_PROFILE_DISCOVERY_QUERY = "models.profiles.list";
4
4
  export const MODEL_PROFILE_ERROR_DETAIL_MAX_BYTES = 2048;
5
5
  const REQUESTED_PROFILE_MAX_BYTES = 256;
6
6
 
7
- const LEGACY_MODEL_PROFILE_ALIASES: ReadonlyMap<string, string> = new Map([["codex-standard", "codex-medium"]]);
7
+ const LEGACY_MODEL_PROFILE_ALIASES: ReadonlyMap<string, string> = new Map([
8
+ ["codex-standard", "codex-medium"],
9
+ ["codex-sol61", "codex-pro"],
10
+ ]);
8
11
 
9
12
  export interface ModelProfileCatalogItem {
10
13
  id: string;