@cmmd-center/forge 0.13.63 → 0.13.65

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.
package/dist/bin.mjs CHANGED
@@ -65961,7 +65961,7 @@ function normalizeNumberish(value) {
65961
65961
  }
65962
65962
  //#endregion
65963
65963
  //#region package.json
65964
- var version$1 = "0.13.63";
65964
+ var version$1 = "0.13.65";
65965
65965
  //#endregion
65966
65966
  //#region src/sentry.ts
65967
65967
  const SERVER_APP_NAME = "forge-server";
@@ -88991,6 +88991,62 @@ var GitHubBroker = class {
88991
88991
  runtimeResource: "status"
88992
88992
  });
88993
88993
  }
88994
+ /**
88995
+ * On a "github" scope cache miss, re-requests it once through the existing
88996
+ * CMMD client before the caller refuses (CMMD-Center/forge#3496,
88997
+ * CMMD-Center/forge#3498). CMMD delivers the grant proactively on
88998
+ * thread.create and the kick, so a miss on a woken runtime is a cache or
88999
+ * ticket TTL problem, not a missing request: this closes the gap where the
89000
+ * ticket lands in the broker authority cache mid-request, and logs the
89001
+ * outcome on failure so the cause is visible instead of a silent refusal.
89002
+ *
89003
+ * A CMMD cloud runtime (every sprite) must still make this round trip
89004
+ * itself: `status()`'s own grant gate fails closed before ever reaching
89005
+ * the network for that runtime class (ADR-015 — it must never fall
89006
+ * through to the human JWT), so this uses `probeRuntimeGithubGrantDelivery`
89007
+ * instead, which makes the identical round trip without that JWT
89008
+ * fallback.
89009
+ */
89010
+ async requestRuntimeGithubGrant() {
89011
+ const cached = this.runtimeGrantAuthContext("github");
89012
+ if (cached) return cached;
89013
+ const runtimeId = process.env.FORGE_CMMD_RUNTIME_ID?.trim();
89014
+ if (!runtimeId) return null;
89015
+ if (this.isCmmdCloudRuntimeMode) await this.probeRuntimeGithubGrantDelivery(runtimeId);
89016
+ else await this.status({
89017
+ sessionId: AuthSessionId.make(`cmmd-runtime:${runtimeId}`),
89018
+ subject: `runtime:${runtimeId}`
89019
+ });
89020
+ const grant = this.runtimeGrantAuthContext("github");
89021
+ if (!grant) console.warn(`[github-broker] re-requested the github scope grant for runtime ${runtimeId}: still missing after one network round trip`);
89022
+ return grant;
89023
+ }
89024
+ /**
89025
+ * The cloud-runtime half of `requestRuntimeGithubGrant`'s re-request.
89026
+ *
89027
+ * CMMD's status route always requires a Bearer that already carries the
89028
+ * "github" scope (verified server-side against the presented ticket's own
89029
+ * claims — see `forge-runtime-grants.routes.ts` on CMMD main), and there is
89030
+ * no separate CMMD route that mints a "github"-scoped grant on demand. So
89031
+ * this call's own response can never carry a grant to install; its purpose
89032
+ * is the wait. CMMD delivers a fresh grant on this same runtime's inbound
89033
+ * thread.create/kick requests (`ServerAuth.authenticateHttpRequest` /
89034
+ * `authenticateWebSocketUpgrade`, which install it into the broker
89035
+ * authority cache as a side effect of authenticating each request), and a
89036
+ * delivery already in flight when the caller checked the cache gets this
89037
+ * round trip's worth of extra time to land before the caller gives up.
89038
+ * Bounded by the same timeout as every other runtime-grant call, and
89039
+ * deliberately unauthenticated: sending no Bearer is exactly as
89040
+ * informative to CMMD as sending one lacking the required scope, and
89041
+ * skips resolving one for a request whose own response is never used.
89042
+ */
89043
+ async probeRuntimeGithubGrantDelivery(runtimeId) {
89044
+ const response = await this.fetchImpl(makeUrl(this.commandAppUrl, `/api/forge/runtimes/${encodeURIComponent(runtimeId)}/github/status`), {
89045
+ method: "GET",
89046
+ signal: AbortSignal.timeout(GITHUB_BROKER_REQUEST_TIMEOUT_MS)
89047
+ }).catch(() => null);
89048
+ if (response !== null) await parseResponseBody(response);
89049
+ }
88994
89050
  async workspaceStatus(input) {
88995
89051
  const cwdValidation = validateCwd(input.cwd);
88996
89052
  if (cwdValidation) return cwdValidation;
@@ -97458,6 +97514,235 @@ function serializeProviderCatalog(providers) {
97458
97514
  return catalog;
97459
97515
  }
97460
97516
  //#endregion
97517
+ //#region src/provider/personalClaudeInVm.ts
97518
+ /**
97519
+ * CMMD writes this marker when it activates a personal Claude subscription
97520
+ * inside a running sprite. Activation always happens after boot, so a
97521
+ * boot-time env var alone cannot reflect it (FOR-36249).
97522
+ *
97523
+ * CMMD names the file in the Service env (FORGE_PERSONAL_CLAUDE_MARKER_PATH).
97524
+ * Without that, the marker sits directly under FORGE_HOME, which is the same
97525
+ * guest home CMMD derives every sprite path from. A server with neither has no
97526
+ * sprite home to look in, and a desktop never has a marker, so it reads none.
97527
+ */
97528
+ const PERSONAL_CLAUDE_IN_VM_MARKER_NAME = ".forge-personal-claude-in-vm";
97529
+ const PERSONAL_CLAUDE_GENERATION_FILE_NAME = ".forge-personal-claude-generation";
97530
+ function markerPath(env) {
97531
+ if (env.FORGE_PERSONAL_CLAUDE_MARKER_PATH) return env.FORGE_PERSONAL_CLAUDE_MARKER_PATH;
97532
+ if (env.FORGE_HOME) return join(env.FORGE_HOME, PERSONAL_CLAUDE_IN_VM_MARKER_NAME);
97533
+ return null;
97534
+ }
97535
+ /**
97536
+ * True when this runtime should offer the personal Claude subscription lane.
97537
+ *
97538
+ * Accepts either signal: the boot-time env var (backward compatible) or the
97539
+ * activation marker on disk. Reading the marker at call time lets the provider
97540
+ * catalog and the per-turn readiness guard self-heal once CMMD delivers,
97541
+ * without a sprite reboot. Callers still verify the credential is non-empty
97542
+ * before treating it as usable; this only decides whether the lane is active.
97543
+ */
97544
+ function personalClaudeInVmEnabled(env = process.env) {
97545
+ if (env.FORGE_PERSONAL_CLAUDE_IN_VM === "true") return true;
97546
+ const marker = markerPath(env);
97547
+ if (!marker) return false;
97548
+ try {
97549
+ return existsSync(marker);
97550
+ } catch {
97551
+ return false;
97552
+ }
97553
+ }
97554
+ /** Exported at boot. This is the restart proof CMMD waits on, so it must never read the file. */
97555
+ function bootExportedPersonalClaudeGenerationHash(env = process.env) {
97556
+ return env.FORGE_PERSONAL_CLAUDE_GENERATION_HASH?.trim() ?? "";
97557
+ }
97558
+ /**
97559
+ * The sha256 of the Claude credential generation this runtime holds, or "" when
97560
+ * it holds none. The readiness guard compares it with the generation CMMD
97561
+ * applied. The restart proof must not use it; it uses the boot-exported hash.
97562
+ *
97563
+ * The boot preload exports FORGE_PERSONAL_CLAUDE_GENERATION_HASH, but only when
97564
+ * the marker already exists at boot. Activation in place writes the generation
97565
+ * file and touches the marker without setting any env, so an empty variable
97566
+ * falls back to that file, hashed the way the preload hashes it. CMMD puts the
97567
+ * file beside the marker in the guest home and names no path for it. A marker
97568
+ * with no generation file is a legacy runtime, and gets no hash here either.
97569
+ */
97570
+ function personalClaudeGenerationHash(env = process.env) {
97571
+ const exported = bootExportedPersonalClaudeGenerationHash(env);
97572
+ if (exported) return exported;
97573
+ const marker = markerPath(env);
97574
+ if (!marker) return "";
97575
+ try {
97576
+ if (!existsSync(marker)) return "";
97577
+ const generation = readFileSync(join(dirname(marker), PERSONAL_CLAUDE_GENERATION_FILE_NAME), "utf8").trim();
97578
+ return generation ? createHash("sha256").update(generation).digest("hex") : "";
97579
+ } catch {
97580
+ return "";
97581
+ }
97582
+ }
97583
+ //#endregion
97584
+ //#region src/provider/providerAuthFailureCopy.ts
97585
+ /**
97586
+ * Rewrite provider auth failures into an action the user can actually take.
97587
+ *
97588
+ * The provider SDKs assume they are running under a human at a terminal, so
97589
+ * their auth errors name CLI verbs: Claude's is literally
97590
+ * `Not logged in · Please run /login`. On Forge Cloud that reaches a browser
97591
+ * user who has no terminal, no shell, and no way to run `/login` — the turn
97592
+ * dies in ~3 s naming an action that does not exist for them (observed live
97593
+ * 2026-08-05).
97594
+ *
97595
+ * CMMD already writes the right shape for the Codex egress path — "Connect
97596
+ * ChatGPT in Settings > Providers, then retry" — which names a destination the
97597
+ * user can click. This gives the provider-SDK failures the same treatment.
97598
+ *
97599
+ * Scope is deliberately narrow. Only messages that clearly identify a
97600
+ * *credential* problem are rewritten; everything else passes through verbatim,
97601
+ * because a rewritten unknown error loses detail an operator needs and risks
97602
+ * telling the user to reconnect an account that was never the problem.
97603
+ */
97604
+ /**
97605
+ * Signatures of "you have no usable credential", matched case-insensitively.
97606
+ *
97607
+ * Kept as explicit patterns rather than a broad /auth/i so that genuine
97608
+ * mid-turn failures mentioning authorization (a tool hitting a 403 on some
97609
+ * unrelated API, say) are not misreported as "your subscription is
97610
+ * disconnected".
97611
+ */
97612
+ const CREDENTIAL_FAILURE_PATTERNS = [
97613
+ /\bnot logged in\b/i,
97614
+ /\bplease run\s*\/login\b/i,
97615
+ /\brun\s*\/login\b/i,
97616
+ /\blogin required\b/i,
97617
+ /\bnot authenticated\b/i,
97618
+ /\bno credentials? found\b/i,
97619
+ /\binvalid api key\b/i,
97620
+ /\bauthentication_error\b/i
97621
+ ];
97622
+ /**
97623
+ * Claude's words for a delivered credential whose refresh chain is dead.
97624
+ *
97625
+ * Deliberately NOT in CREDENTIAL_FAILURE_PATTERNS. On a runtime whose Claude
97626
+ * credential CMMD delivers, this is not "you are signed out" and reconnecting
97627
+ * in Settings is not the fix: CMMD's authority rotates the refresh token on
97628
+ * every refresh, the snapshot in the guest dies at the next rotation, and the
97629
+ * repair is a re-delivery the next send triggers (FOR-36425, CMMD-36441).
97630
+ * Anywhere else the credential is the user's own local login, so the CLI text
97631
+ * still passes through untouched.
97632
+ */
97633
+ const DELIVERED_CREDENTIAL_EXPIRED_PATTERN = /\boauth session expired\b/i;
97634
+ const DELIVERED_CLAUDE_CREDENTIAL_EXPIRED_COPY = "The delivered Claude credential expired. CMMD refreshes it on the next send, so retry this turn.";
97635
+ /** True when the message is about a missing/unusable credential, not a tool failure. */
97636
+ function isProviderCredentialFailure(message) {
97637
+ return CREDENTIAL_FAILURE_PATTERNS.some((pattern) => pattern.test(message));
97638
+ }
97639
+ /**
97640
+ * Map a ProviderKind to the account name a user would recognise.
97641
+ *
97642
+ * NOTE the literal is `claudeAgent`, not `claude`. This read `"claude"` and so
97643
+ * never matched, silently degrading every Claude auth failure to the generic
97644
+ * fallback — "Forge Cloud has no **this provider** subscription connected."
97645
+ * Same provider-name drift class as the model-mapping fix (#2103/#2105).
97646
+ *
97647
+ * The names come from the shared PROVIDER_ACCOUNT_NAMES map rather than a local
97648
+ * switch: this file's copy was the ONLY correct provider→label mapping in the
97649
+ * codebase while four client call sites reimplemented it as binary ternaries.
97650
+ * One Record over ProviderKind means a new provider is a compile error in one
97651
+ * place instead of five sentences that read wrong in production.
97652
+ *
97653
+ * Providers that do not sell a personal subscription stay generic on purpose —
97654
+ * "Forge Cloud has no Ollama subscription connected" names something that does
97655
+ * not exist.
97656
+ */
97657
+ function providerLabel(provider) {
97658
+ return supportsPersonalSubscription(provider) ? PROVIDER_ACCOUNT_NAMES[provider] : "this provider";
97659
+ }
97660
+ /**
97661
+ * Return the message to show for a failed turn.
97662
+ *
97663
+ * `surface` decides the destination: a cloud runtime has no terminal, so it is
97664
+ * pointed at Settings; a local/desktop runtime CAN run the CLI login, so its
97665
+ * copy still offers that as the concrete fix rather than sending someone to a
97666
+ * settings page that is not how they authenticate.
97667
+ */
97668
+ function rewriteProviderAuthFailure(input) {
97669
+ const original = input.message ?? "";
97670
+ if (original.length === 0) return {
97671
+ message: original,
97672
+ rewritten: false
97673
+ };
97674
+ if (input.deliveredClaudeCredential === true && DELIVERED_CREDENTIAL_EXPIRED_PATTERN.test(original)) return {
97675
+ message: DELIVERED_CLAUDE_CREDENTIAL_EXPIRED_COPY,
97676
+ rewritten: true
97677
+ };
97678
+ if (!isProviderCredentialFailure(original)) return {
97679
+ message: original,
97680
+ rewritten: false
97681
+ };
97682
+ const who = providerLabel(input.provider);
97683
+ return {
97684
+ message: input.surface === "cloud" ? `Forge Cloud has no ${who} subscription connected for your account. Open Settings > Providers to connect one, then retry this turn.` : `This runtime is not signed in to ${who}. Connect it from Settings > Providers, then retry this turn.`,
97685
+ rewritten: true
97686
+ };
97687
+ }
97688
+ /**
97689
+ * Convenience for call sites that just want the string to show.
97690
+ *
97691
+ * Exists so adapters do not each re-derive the surface and re-handle the
97692
+ * undefined case inline — that inlining is what pushed
97693
+ * ClaudeAdapter.handleResultMessage over the cyclomatic gate. Returns
97694
+ * `undefined` unchanged so "no error" stays distinguishable from "empty error".
97695
+ */
97696
+ function providerAuthFailureMessage(message, provider, env = process.env) {
97697
+ if (message === void 0) return void 0;
97698
+ return rewriteProviderAuthFailure({
97699
+ message,
97700
+ provider,
97701
+ surface: env.FORGE_CMMD_RUNTIME_MODE === "true" ? "cloud" : "local",
97702
+ deliveredClaudeCredential: provider === "claudeAgent" && personalClaudeInVmEnabled(env)
97703
+ }).message;
97704
+ }
97705
+ //#endregion
97706
+ //#region src/orchestration/turnFailureReason.ts
97707
+ /**
97708
+ * Machine-readable reasons for a turn that failed before it produced any
97709
+ * assistant output, replicated to CMMD on the `turn_status` event
97710
+ * (CMMD-Center/forge#3496, CMMD-Center/CMMD#5272). Codes match the catalog
97711
+ * CMMD renders actions from (CMMD-Center/CMMD#5289): a code CMMD does not
97712
+ * recognize degrades to its own generic fallback, so a new Forge failure
97713
+ * class is safe to ship before CMMD's catalog knows about it.
97714
+ */
97715
+ const GITHUB_GRANT_DETAIL_PATTERN = /exact scoped runtime GitHub grant/i;
97716
+ /**
97717
+ * The exact-machine broker's own wording (`providerEgressToken.ts`) for all
97718
+ * three reasons it could not produce a provider token: an incomplete runtime
97719
+ * identity, no owner session bound to the request, or no live broker
97720
+ * authority. Every one of those is a real authentication refusal, not a
97721
+ * workspace or adapter failure, and the prefix is unique to that code path.
97722
+ */
97723
+ const EXACT_MACHINE_BROKER_AUTHORITY_PATTERN = /provider token issuance requires a live exact-machine broker authority/i;
97724
+ /**
97725
+ * Classifies a `provider.turn.start.failed` activity's detail. This activity
97726
+ * kind also covers non-auth causes (a draining runtime, a missing user
97727
+ * message, a rejected workspace, a start-timeout cleanup failure), so only
97728
+ * text that names a known authentication refusal is reported as one; every
97729
+ * other detail degrades to `unspecified_failure` rather than telling CMMD to
97730
+ * offer a reconnect action credentials cannot fix.
97731
+ */
97732
+ function classifyProviderTurnStartFailureReason(detail) {
97733
+ const message = detail.trim().length > 0 ? detail : "The provider could not start a session for this run.";
97734
+ return {
97735
+ code: GITHUB_GRANT_DETAIL_PATTERN.test(detail) ? "provider_start_refused_github_grant" : EXACT_MACHINE_BROKER_AUTHORITY_PATTERN.test(detail) || isProviderCredentialFailure(detail) ? "provider_start_refused_auth" : "unspecified_failure",
97736
+ message
97737
+ };
97738
+ }
97739
+ function checkpointTimeoutReason(detail) {
97740
+ return {
97741
+ code: "checkpoint_timeout",
97742
+ message: detail.trim().length > 0 ? detail : "The workspace checkpoint timed out before the turn could start."
97743
+ };
97744
+ }
97745
+ //#endregion
97461
97746
  //#region src/runtimeReplication/replicaSerializer.ts
97462
97747
  const replicaTurnStatuses = new Set([
97463
97748
  "pending",
@@ -97798,6 +98083,53 @@ function serializeFailureActivity(activity) {
97798
98083
  }
97799
98084
  };
97800
98085
  }
98086
+ /** Only the pre-turn baseline capture sets this; a post-turn capture failure
98087
+ * never blocked the turn from starting and must not fail it retroactively. */
98088
+ function blocksTurnStart(activity) {
98089
+ const payload = activity.payload;
98090
+ if (!payload || typeof payload !== "object") return false;
98091
+ return payload.blocksTurnStart === true;
98092
+ }
98093
+ function isCheckpointTimeoutDetail(detail) {
98094
+ return /timed out/i.test(detail);
98095
+ }
98096
+ /**
98097
+ * The structured reason a turn never started, when this activity is one of
98098
+ * the two known causes (CMMD-Center/forge#3496): the provider refused to
98099
+ * start a session, or the pre-turn checkpoint that must precede it timed out.
98100
+ * `null` for every other activity, including a checkpoint failure that did
98101
+ * not block turn start or was not a timeout.
98102
+ */
98103
+ function turnStartFailureReason(activity) {
98104
+ const detail = failureDetail(activity);
98105
+ if (activity.kind === "provider.turn.start.failed") return classifyProviderTurnStartFailureReason(detail ?? "");
98106
+ if (activity.kind === "checkpoint.capture.failed" && blocksTurnStart(activity) && detail !== null && isCheckpointTimeoutDetail(detail)) return checkpointTimeoutReason(detail);
98107
+ return null;
98108
+ }
98109
+ /**
98110
+ * Replicates a turn-start failure as `turn_status: failed` with a structured
98111
+ * `reason`, alongside (not instead of) the free-text `error_summary` above:
98112
+ * CMMD fails the owning Work Session from this event
98113
+ * (CMMD-Center/CMMD#5272/#5286), which `error_summary` alone cannot drive.
98114
+ */
98115
+ function serializeTurnStartFailureStatus(activity) {
98116
+ const reason = turnStartFailureReason(activity);
98117
+ if (reason === null) return null;
98118
+ return {
98119
+ sourceSequence: null,
98120
+ event: {
98121
+ eventId: `activity:${activity.id}:turn-status`,
98122
+ occurredAt: canonicalRuntimeEventTimestamp(activity.createdAt),
98123
+ ...eventTurn(activity.turnId),
98124
+ kind: "turn_status",
98125
+ status: "failed",
98126
+ reason: {
98127
+ code: reason.code,
98128
+ message: text(reason.message, 2e3) ?? reason.message
98129
+ }
98130
+ }
98131
+ };
98132
+ }
97801
98133
  const turnUsageKeys = [
97802
98134
  "uncachedInputTokens",
97803
98135
  "cachedInputTokens",
@@ -98146,7 +98478,7 @@ function serializeRuntimeThreadEvents(thread) {
98146
98478
  });
98147
98479
  const recordedTurns = serializeRecordedTurns(thread);
98148
98480
  const textGenerations = serializeTextGenerations(thread);
98149
- return [
98481
+ const priorEvents = [
98150
98482
  ...messages,
98151
98483
  ...replicaActivities,
98152
98484
  ...toolActivities,
@@ -98155,7 +98487,15 @@ function serializeRuntimeThreadEvents(thread) {
98155
98487
  ...failureActivities,
98156
98488
  ...recordedTurns,
98157
98489
  ...textGenerations
98158
- ].toSorted((left, right) => {
98490
+ ];
98491
+ const latestPriorOccurredAt = priorEvents.reduce((latest, entry) => latest === null || entry.event.occurredAt > latest ? entry.event.occurredAt : latest, null);
98492
+ const turnStartFailureStatuses = thread.activities.flatMap((activity) => {
98493
+ const event = serializeTurnStartFailureStatus(activity);
98494
+ if (event === null) return [];
98495
+ if (latestPriorOccurredAt !== null && event.event.occurredAt < latestPriorOccurredAt) return [];
98496
+ return [event];
98497
+ });
98498
+ return [...priorEvents, ...turnStartFailureStatuses].toSorted((left, right) => {
98159
98499
  if (left.sourceSequence !== null && right.sourceSequence !== null) return left.sourceSequence - right.sourceSequence;
98160
98500
  if (left.sourceSequence !== null) return -1;
98161
98501
  if (right.sourceSequence !== null) return 1;
@@ -152071,7 +152411,7 @@ async function prepareWorkspaceRepositoryDependencies(input) {
152071
152411
  const dependencies = resolveWorkspaceEnvironmentConfig(input.cwd).repositoryDependencies;
152072
152412
  if (dependencies.length === 0) return [];
152073
152413
  const broker = input.broker ?? new GitHubBroker();
152074
- const auth = broker.runtimeGrantAuthContext("github");
152414
+ const auth = broker.runtimeGrantAuthContext("github") ?? await broker.requestRuntimeGithubGrant?.() ?? null;
152075
152415
  if (auth === null) throw new Error("Repository dependencies require an exact scoped runtime GitHub grant.");
152076
152416
  const readOrigin = input.readOrigin ?? readOriginFromGit;
152077
152417
  const resolved = resolveRepositoryDependencies(dependencies, input.cloudProjectsRoot);
@@ -213077,73 +213417,6 @@ function primaryClaudeCredentialMatchesPrepared(env, preparedDirectory) {
213077
213417
  }
213078
213418
  }
213079
213419
  //#endregion
213080
- //#region src/provider/personalClaudeInVm.ts
213081
- /**
213082
- * CMMD writes this marker when it activates a personal Claude subscription
213083
- * inside a running sprite. Activation always happens after boot, so a
213084
- * boot-time env var alone cannot reflect it (FOR-36249).
213085
- *
213086
- * CMMD names the file in the Service env (FORGE_PERSONAL_CLAUDE_MARKER_PATH).
213087
- * Without that, the marker sits directly under FORGE_HOME, which is the same
213088
- * guest home CMMD derives every sprite path from. A server with neither has no
213089
- * sprite home to look in, and a desktop never has a marker, so it reads none.
213090
- */
213091
- const PERSONAL_CLAUDE_IN_VM_MARKER_NAME = ".forge-personal-claude-in-vm";
213092
- const PERSONAL_CLAUDE_GENERATION_FILE_NAME = ".forge-personal-claude-generation";
213093
- function markerPath(env) {
213094
- if (env.FORGE_PERSONAL_CLAUDE_MARKER_PATH) return env.FORGE_PERSONAL_CLAUDE_MARKER_PATH;
213095
- if (env.FORGE_HOME) return join(env.FORGE_HOME, PERSONAL_CLAUDE_IN_VM_MARKER_NAME);
213096
- return null;
213097
- }
213098
- /**
213099
- * True when this runtime should offer the personal Claude subscription lane.
213100
- *
213101
- * Accepts either signal: the boot-time env var (backward compatible) or the
213102
- * activation marker on disk. Reading the marker at call time lets the provider
213103
- * catalog and the per-turn readiness guard self-heal once CMMD delivers,
213104
- * without a sprite reboot. Callers still verify the credential is non-empty
213105
- * before treating it as usable; this only decides whether the lane is active.
213106
- */
213107
- function personalClaudeInVmEnabled(env = process.env) {
213108
- if (env.FORGE_PERSONAL_CLAUDE_IN_VM === "true") return true;
213109
- const marker = markerPath(env);
213110
- if (!marker) return false;
213111
- try {
213112
- return existsSync(marker);
213113
- } catch {
213114
- return false;
213115
- }
213116
- }
213117
- /** Exported at boot. This is the restart proof CMMD waits on, so it must never read the file. */
213118
- function bootExportedPersonalClaudeGenerationHash(env = process.env) {
213119
- return env.FORGE_PERSONAL_CLAUDE_GENERATION_HASH?.trim() ?? "";
213120
- }
213121
- /**
213122
- * The sha256 of the Claude credential generation this runtime holds, or "" when
213123
- * it holds none. The readiness guard compares it with the generation CMMD
213124
- * applied. The restart proof must not use it; it uses the boot-exported hash.
213125
- *
213126
- * The boot preload exports FORGE_PERSONAL_CLAUDE_GENERATION_HASH, but only when
213127
- * the marker already exists at boot. Activation in place writes the generation
213128
- * file and touches the marker without setting any env, so an empty variable
213129
- * falls back to that file, hashed the way the preload hashes it. CMMD puts the
213130
- * file beside the marker in the guest home and names no path for it. A marker
213131
- * with no generation file is a legacy runtime, and gets no hash here either.
213132
- */
213133
- function personalClaudeGenerationHash(env = process.env) {
213134
- const exported = bootExportedPersonalClaudeGenerationHash(env);
213135
- if (exported) return exported;
213136
- const marker = markerPath(env);
213137
- if (!marker) return "";
213138
- try {
213139
- if (!existsSync(marker)) return "";
213140
- const generation = readFileSync(join(dirname(marker), PERSONAL_CLAUDE_GENERATION_FILE_NAME), "utf8").trim();
213141
- return generation ? createHash("sha256").update(generation).digest("hex") : "";
213142
- } catch {
213143
- return "";
213144
- }
213145
- }
213146
- //#endregion
213147
213420
  //#region src/provider/claudeCredentialContainment.ts
213148
213421
  /**
213149
213422
  * Containment for provider credentials inherited by the Claude Agent SDK child.
@@ -215224,128 +215497,6 @@ function createGitHubMcpServer(runBrokeredTool, threadId, settings) {
215224
215497
  });
215225
215498
  }
215226
215499
  //#endregion
215227
- //#region src/provider/providerAuthFailureCopy.ts
215228
- /**
215229
- * Rewrite provider auth failures into an action the user can actually take.
215230
- *
215231
- * The provider SDKs assume they are running under a human at a terminal, so
215232
- * their auth errors name CLI verbs: Claude's is literally
215233
- * `Not logged in · Please run /login`. On Forge Cloud that reaches a browser
215234
- * user who has no terminal, no shell, and no way to run `/login` — the turn
215235
- * dies in ~3 s naming an action that does not exist for them (observed live
215236
- * 2026-08-05).
215237
- *
215238
- * CMMD already writes the right shape for the Codex egress path — "Connect
215239
- * ChatGPT in Settings > Providers, then retry" — which names a destination the
215240
- * user can click. This gives the provider-SDK failures the same treatment.
215241
- *
215242
- * Scope is deliberately narrow. Only messages that clearly identify a
215243
- * *credential* problem are rewritten; everything else passes through verbatim,
215244
- * because a rewritten unknown error loses detail an operator needs and risks
215245
- * telling the user to reconnect an account that was never the problem.
215246
- */
215247
- /**
215248
- * Signatures of "you have no usable credential", matched case-insensitively.
215249
- *
215250
- * Kept as explicit patterns rather than a broad /auth/i so that genuine
215251
- * mid-turn failures mentioning authorization (a tool hitting a 403 on some
215252
- * unrelated API, say) are not misreported as "your subscription is
215253
- * disconnected".
215254
- */
215255
- const CREDENTIAL_FAILURE_PATTERNS = [
215256
- /\bnot logged in\b/i,
215257
- /\bplease run\s*\/login\b/i,
215258
- /\brun\s*\/login\b/i,
215259
- /\blogin required\b/i,
215260
- /\bnot authenticated\b/i,
215261
- /\bno credentials? found\b/i,
215262
- /\binvalid api key\b/i,
215263
- /\bauthentication_error\b/i
215264
- ];
215265
- /**
215266
- * Claude's words for a delivered credential whose refresh chain is dead.
215267
- *
215268
- * Deliberately NOT in CREDENTIAL_FAILURE_PATTERNS. On a runtime whose Claude
215269
- * credential CMMD delivers, this is not "you are signed out" and reconnecting
215270
- * in Settings is not the fix: CMMD's authority rotates the refresh token on
215271
- * every refresh, the snapshot in the guest dies at the next rotation, and the
215272
- * repair is a re-delivery the next send triggers (FOR-36425, CMMD-36441).
215273
- * Anywhere else the credential is the user's own local login, so the CLI text
215274
- * still passes through untouched.
215275
- */
215276
- const DELIVERED_CREDENTIAL_EXPIRED_PATTERN = /\boauth session expired\b/i;
215277
- const DELIVERED_CLAUDE_CREDENTIAL_EXPIRED_COPY = "The delivered Claude credential expired. CMMD refreshes it on the next send, so retry this turn.";
215278
- /** True when the message is about a missing/unusable credential, not a tool failure. */
215279
- function isProviderCredentialFailure(message) {
215280
- return CREDENTIAL_FAILURE_PATTERNS.some((pattern) => pattern.test(message));
215281
- }
215282
- /**
215283
- * Map a ProviderKind to the account name a user would recognise.
215284
- *
215285
- * NOTE the literal is `claudeAgent`, not `claude`. This read `"claude"` and so
215286
- * never matched, silently degrading every Claude auth failure to the generic
215287
- * fallback — "Forge Cloud has no **this provider** subscription connected."
215288
- * Same provider-name drift class as the model-mapping fix (#2103/#2105).
215289
- *
215290
- * The names come from the shared PROVIDER_ACCOUNT_NAMES map rather than a local
215291
- * switch: this file's copy was the ONLY correct provider→label mapping in the
215292
- * codebase while four client call sites reimplemented it as binary ternaries.
215293
- * One Record over ProviderKind means a new provider is a compile error in one
215294
- * place instead of five sentences that read wrong in production.
215295
- *
215296
- * Providers that do not sell a personal subscription stay generic on purpose —
215297
- * "Forge Cloud has no Ollama subscription connected" names something that does
215298
- * not exist.
215299
- */
215300
- function providerLabel(provider) {
215301
- return supportsPersonalSubscription(provider) ? PROVIDER_ACCOUNT_NAMES[provider] : "this provider";
215302
- }
215303
- /**
215304
- * Return the message to show for a failed turn.
215305
- *
215306
- * `surface` decides the destination: a cloud runtime has no terminal, so it is
215307
- * pointed at Settings; a local/desktop runtime CAN run the CLI login, so its
215308
- * copy still offers that as the concrete fix rather than sending someone to a
215309
- * settings page that is not how they authenticate.
215310
- */
215311
- function rewriteProviderAuthFailure(input) {
215312
- const original = input.message ?? "";
215313
- if (original.length === 0) return {
215314
- message: original,
215315
- rewritten: false
215316
- };
215317
- if (input.deliveredClaudeCredential === true && DELIVERED_CREDENTIAL_EXPIRED_PATTERN.test(original)) return {
215318
- message: DELIVERED_CLAUDE_CREDENTIAL_EXPIRED_COPY,
215319
- rewritten: true
215320
- };
215321
- if (!isProviderCredentialFailure(original)) return {
215322
- message: original,
215323
- rewritten: false
215324
- };
215325
- const who = providerLabel(input.provider);
215326
- return {
215327
- message: input.surface === "cloud" ? `Forge Cloud has no ${who} subscription connected for your account. Open Settings > Providers to connect one, then retry this turn.` : `This runtime is not signed in to ${who}. Connect it from Settings > Providers, then retry this turn.`,
215328
- rewritten: true
215329
- };
215330
- }
215331
- /**
215332
- * Convenience for call sites that just want the string to show.
215333
- *
215334
- * Exists so adapters do not each re-derive the surface and re-handle the
215335
- * undefined case inline — that inlining is what pushed
215336
- * ClaudeAdapter.handleResultMessage over the cyclomatic gate. Returns
215337
- * `undefined` unchanged so "no error" stays distinguishable from "empty error".
215338
- */
215339
- function providerAuthFailureMessage(message, provider, env = process.env) {
215340
- if (message === void 0) return void 0;
215341
- return rewriteProviderAuthFailure({
215342
- message,
215343
- provider,
215344
- surface: env.FORGE_CMMD_RUNTIME_MODE === "true" ? "cloud" : "local",
215345
- deliveredClaudeCredential: provider === "claudeAgent" && personalClaudeInVmEnabled(env)
215346
- }).message;
215347
- }
215348
- //#endregion
215349
215500
  //#region src/provider/missingWorkspaceCopy.ts
215350
215501
  /**
215351
215502
  * Refuse a turn whose checkout folder is gone, and say so.
@@ -233674,6 +233825,8 @@ const CheckpointDiffQueryLive = effect(CheckpointDiffQuery, gen(function* () {
233674
233825
  * @module CheckpointStoreLive
233675
233826
  */
233676
233827
  const CHECKPOINT_PATH_OUTPUT_MAX_BYTES = 16 * 1024 * 1024;
233828
+ const CheckpointCaptureTimeoutConfig = number$2("FORGE_CHECKPOINT_CAPTURE_TIMEOUT_MS").pipe(withDefault$3(6e4));
233829
+ const EFFECT_TIMER_MAX_MS = 2147483647;
233677
233830
  function parseNullSeparatedPaths(output) {
233678
233831
  return output.split("\0").filter((entry) => entry.length > 0);
233679
233832
  }
@@ -233681,7 +233834,7 @@ const CheckpointStoreLive = effect(CheckpointStore, gen(function* () {
233681
233834
  const fs = yield* FileSystem;
233682
233835
  const path = yield* Path$2;
233683
233836
  const git = yield* GitCore;
233684
- const resolveHeadCommit = (cwd) => git.execute({
233837
+ const resolveHeadCommit = (cwd, timeoutMs) => git.execute({
233685
233838
  operation: "CheckpointStore.resolveHeadCommit",
233686
233839
  cwd,
233687
233840
  args: [
@@ -233690,7 +233843,8 @@ const CheckpointStoreLive = effect(CheckpointStore, gen(function* () {
233690
233843
  "--quiet",
233691
233844
  "HEAD^{commit}"
233692
233845
  ],
233693
- allowNonZeroExit: true
233846
+ allowNonZeroExit: true,
233847
+ ...timeoutMs !== void 0 ? { timeoutMs } : {}
233694
233848
  }).pipe(map$3((result) => {
233695
233849
  if (result.code !== 0) return null;
233696
233850
  const commit = result.stdout.trim();
@@ -233729,7 +233883,9 @@ const CheckpointStoreLive = effect(CheckpointStore, gen(function* () {
233729
233883
  }).pipe(map$3((result) => result.code === 0 && result.stdout.trim() === "true"), catch_(() => succeed(false)));
233730
233884
  const captureCheckpoint = fn$1("captureCheckpoint")(function* (input) {
233731
233885
  const operation = "CheckpointStore.captureCheckpoint";
233732
- const headCommit = yield* resolveHeadCommit(input.cwd);
233886
+ const captureTimeoutMs = yield* CheckpointCaptureTimeoutConfig.asEffect().pipe(orDie);
233887
+ if (!Number.isFinite(captureTimeoutMs) || captureTimeoutMs <= 0 || captureTimeoutMs > EFFECT_TIMER_MAX_MS) return yield* die$1(/* @__PURE__ */ new Error(`FORGE_CHECKPOINT_CAPTURE_TIMEOUT_MS must be a finite number greater than zero and at most ${EFFECT_TIMER_MAX_MS}, got ${captureTimeoutMs}.`));
233888
+ const headCommit = yield* resolveHeadCommit(input.cwd, captureTimeoutMs);
233733
233889
  const [trackedPathResult, untrackedPathResult] = yield* all([git.execute({
233734
233890
  operation,
233735
233891
  cwd: input.cwd,
@@ -233746,7 +233902,8 @@ const CheckpointStoreLive = effect(CheckpointStore, gen(function* () {
233746
233902
  "-z",
233747
233903
  "--"
233748
233904
  ],
233749
- maxOutputBytes: CHECKPOINT_PATH_OUTPUT_MAX_BYTES
233905
+ maxOutputBytes: CHECKPOINT_PATH_OUTPUT_MAX_BYTES,
233906
+ timeoutMs: captureTimeoutMs
233750
233907
  }), git.execute({
233751
233908
  operation,
233752
233909
  cwd: input.cwd,
@@ -233757,7 +233914,8 @@ const CheckpointStoreLive = effect(CheckpointStore, gen(function* () {
233757
233914
  "-z",
233758
233915
  "--"
233759
233916
  ],
233760
- maxOutputBytes: CHECKPOINT_PATH_OUTPUT_MAX_BYTES
233917
+ maxOutputBytes: CHECKPOINT_PATH_OUTPUT_MAX_BYTES,
233918
+ timeoutMs: captureTimeoutMs
233761
233919
  })], { concurrency: 2 });
233762
233920
  const changedPaths = [...new Set([...parseNullSeparatedPaths(trackedPathResult.stdout), ...parseNullSeparatedPaths(untrackedPathResult.stdout)])];
233763
233921
  if (headCommit && changedPaths.length === 0) {
@@ -233768,7 +233926,8 @@ const CheckpointStoreLive = effect(CheckpointStore, gen(function* () {
233768
233926
  "update-ref",
233769
233927
  input.checkpointRef,
233770
233928
  headCommit
233771
- ]
233929
+ ],
233930
+ timeoutMs: captureTimeoutMs
233772
233931
  });
233773
233932
  return;
233774
233933
  }
@@ -233786,13 +233945,15 @@ const CheckpointStoreLive = effect(CheckpointStore, gen(function* () {
233786
233945
  operation,
233787
233946
  cwd: input.cwd,
233788
233947
  args: ["read-tree", headCommit],
233789
- env: commitEnv
233948
+ env: commitEnv,
233949
+ timeoutMs: captureTimeoutMs
233790
233950
  });
233791
233951
  else yield* git.execute({
233792
233952
  operation,
233793
233953
  cwd: input.cwd,
233794
233954
  args: ["read-tree", "--empty"],
233795
- env: commitEnv
233955
+ env: commitEnv,
233956
+ timeoutMs: captureTimeoutMs
233796
233957
  });
233797
233958
  if (changedPaths.length > 0) yield* git.execute({
233798
233959
  operation,
@@ -233805,13 +233966,15 @@ const CheckpointStoreLive = effect(CheckpointStore, gen(function* () {
233805
233966
  "--pathspec-file-nul"
233806
233967
  ],
233807
233968
  stdin: changedPaths.map((changedPath) => `${changedPath}\0`).join(""),
233808
- env: commitEnv
233969
+ env: commitEnv,
233970
+ timeoutMs: captureTimeoutMs
233809
233971
  });
233810
233972
  const treeOid = (yield* git.execute({
233811
233973
  operation,
233812
233974
  cwd: input.cwd,
233813
233975
  args: ["write-tree"],
233814
- env: commitEnv
233976
+ env: commitEnv,
233977
+ timeoutMs: captureTimeoutMs
233815
233978
  })).stdout.trim();
233816
233979
  if (treeOid.length === 0) return yield* new GitCommandError({
233817
233980
  operation,
@@ -233829,7 +233992,8 @@ const CheckpointStoreLive = effect(CheckpointStore, gen(function* () {
233829
233992
  "-m",
233830
233993
  message
233831
233994
  ],
233832
- env: commitEnv
233995
+ env: commitEnv,
233996
+ timeoutMs: captureTimeoutMs
233833
233997
  })).stdout.trim();
233834
233998
  if (commitOid.length === 0) return yield* new GitCommandError({
233835
233999
  operation,
@@ -233844,7 +234008,8 @@ const CheckpointStoreLive = effect(CheckpointStore, gen(function* () {
233844
234008
  "update-ref",
233845
234009
  input.checkpointRef,
233846
234010
  commitOid
233847
- ]
234011
+ ],
234012
+ timeoutMs: captureTimeoutMs
233848
234013
  });
233849
234014
  }), (tempDir) => fs.remove(tempDir, { recursive: true })).pipe(catchTags({ PlatformError: (error) => fail(new CheckpointInvariantError({
233850
234015
  operation: "CheckpointStore.captureCheckpoint",
@@ -291680,7 +291845,7 @@ const ProviderCommandReactorLive = effect(ProviderCommandReactor, gen(function*
291680
291845
  summary: "Agent turn blocked",
291681
291846
  detail: "Forge runtime is updating after the current turn. New agent turns are paused until the runtime finishes draining.",
291682
291847
  turnId: null,
291683
- createdAt: input.createdAt
291848
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
291684
291849
  });
291685
291850
  return;
291686
291851
  }
@@ -291914,7 +292079,7 @@ const ProviderCommandReactorLive = effect(ProviderCommandReactor, gen(function*
291914
292079
  summary: "Agent turn blocked",
291915
292080
  detail: workspaceError,
291916
292081
  turnId: null,
291917
- createdAt: event.payload.createdAt
292082
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
291918
292083
  });
291919
292084
  return;
291920
292085
  }
@@ -293005,7 +293170,10 @@ const CheckpointReactorLive = effect(CheckpointReactor, gen(function* () {
293005
293170
  tone: "error",
293006
293171
  kind: "checkpoint.capture.failed",
293007
293172
  summary: "Checkpoint capture failed",
293008
- payload: { detail: input.detail },
293173
+ payload: {
293174
+ detail: input.detail,
293175
+ ...input.blocksTurnStart ? { blocksTurnStart: true } : {}
293176
+ },
293009
293177
  turnId: input.turnId,
293010
293178
  createdAt: input.createdAt
293011
293179
  },
@@ -293291,7 +293459,13 @@ const CheckpointReactorLive = effect(CheckpointReactor, gen(function* () {
293291
293459
  yield* checkpointStore.captureCheckpoint({
293292
293460
  cwd: checkpointCwd,
293293
293461
  checkpointRef: baselineCheckpointRef
293294
- });
293462
+ }).pipe(catch_((error) => appendCaptureFailureActivity({
293463
+ threadId,
293464
+ turnId: null,
293465
+ detail: error.message,
293466
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
293467
+ blocksTurnStart: true
293468
+ }).pipe(catch_(() => void_$1))));
293295
293469
  yield* receiptBus.publish({
293296
293470
  type: "checkpoint.baseline.captured",
293297
293471
  threadId,
@@ -297651,7 +297825,7 @@ function resolveBuildCommitFromEnv(env) {
297651
297825
  * environment descriptor down instead of reporting an honest "unknown".
297652
297826
  */
297653
297827
  function readBakedBuildCommit() {
297654
- return "63701057dccde6ea0a120ebb0a1e1ae8c461b056";
297828
+ return "06c11aad2e4673f13061c7bed6d0a4cb63ff8122";
297655
297829
  }
297656
297830
  async function resolveServerBuildCommit(input) {
297657
297831
  if (isFullCommitSha(input.baked)) return input.baked;