@cmmd-center/forge 0.13.12 → 0.13.14

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 (23) hide show
  1. package/dist/bin.cjs +543 -75
  2. package/dist/bin.mjs +543 -75
  3. package/dist/client/assets/{CanvasPanel-BjHxCGlb.js → CanvasPanel-RpD9Eh_S.js} +2 -2
  4. package/dist/client/assets/{CompositeItem-D4LER3__.js → CompositeItem-DEIBUHJR.js} +2 -2
  5. package/dist/client/assets/{DiffPanel-D3btZGjW.js → DiffPanel-P3t5Fq1q.js} +2 -2
  6. package/dist/client/assets/{DiffPanel.logic-DWcwE9Wj.js → DiffPanel.logic-BXQ1C0-e.js} +2 -2
  7. package/dist/client/assets/{DiffPanelShell-oXHh--ae.js → DiffPanelShell-3EpWEr7p.js} +103 -103
  8. package/dist/client/assets/{DiffWorkerPoolProvider-R9YPaaNy.js → DiffWorkerPoolProvider-3_hkcEDq.js} +2 -2
  9. package/dist/client/assets/{PullRequestCodePanel-DTCc2TBl.js → PullRequestCodePanel-B2T6MPcE.js} +2 -2
  10. package/dist/client/assets/{Virtualizer-arKGxq_z.js → Virtualizer-DCt36RGD.js} +2 -2
  11. package/dist/client/assets/{canvasStateStore-B5zd1u9r.js → canvasStateStore-NrAxDqvM.js} +2 -2
  12. package/dist/client/assets/{cmmdAuthRedirect-DeFAIQpj.js → cmmdAuthRedirect-BahlX0gF.js} +2 -2
  13. package/dist/client/assets/index-C3pC5t1K.css +1 -0
  14. package/dist/client/assets/index-DvwXD11B.js +154 -0
  15. package/dist/client/assets/{popover-DM7IA0zJ.js → popover-Z8Tvl-OD.js} +2 -2
  16. package/dist/client/assets/src-BU7yHzO9.js +3 -0
  17. package/dist/client/assets/{toggle-DVtS0BJJ.js → toggle-CWYdWYBn.js} +2 -2
  18. package/dist/client/index.html +11 -11
  19. package/dist/forge-build.json +1 -1
  20. package/package.json +1 -1
  21. package/dist/client/assets/index-Bn-ASQRG.css +0 -1
  22. package/dist/client/assets/index-C7YKDHIe.js +0 -154
  23. package/dist/client/assets/src-LDQtPKQt.js +0 -3
package/dist/bin.mjs CHANGED
@@ -17358,7 +17358,8 @@ const ORCHESTRATION_WS_METHODS = {
17358
17358
  getArchivedShellSnapshot: "orchestration.getArchivedShellSnapshot",
17359
17359
  subscribeShell: "orchestration.subscribeShell",
17360
17360
  subscribeThread: "orchestration.subscribeThread",
17361
- searchThreads: "orchestration.searchThreads"
17361
+ searchThreads: "orchestration.searchThreads",
17362
+ getThreadDetailPage: "orchestration.getThreadDetailPage"
17362
17363
  };
17363
17364
  const ProviderKind = Literals([
17364
17365
  "codex",
@@ -17878,6 +17879,21 @@ Struct({
17878
17879
  turnLimit: optionalKey(PositiveInt),
17879
17880
  beforeCursor: optionalKey(TrimmedNonEmptyString)
17880
17881
  });
17882
+ /**
17883
+ * Reads one windowed page of a thread's detail directly over the RPC socket.
17884
+ *
17885
+ * The HTTP snapshot route and `subscribeThread`'s fallback snapshot both take
17886
+ * the same `turnLimit`/`beforeCursor` window, but neither can serve "load
17887
+ * earlier" on a connection with no HTTP endpoint: the fallback snapshot only
17888
+ * fires once, on subscribe. This method exists for exactly that case (a
17889
+ * relay-only connection), gated on the server's `threadDetailPageRpc`
17890
+ * capability so older servers are never sent a method they do not know.
17891
+ */
17892
+ const OrchestrationGetThreadDetailPageInput = Struct({
17893
+ threadId: ThreadId,
17894
+ turnLimit: optionalKey(PositiveInt),
17895
+ beforeCursor: optionalKey(TrimmedNonEmptyString)
17896
+ });
17881
17897
  const OrchestrationThreadDetailSnapshot = Struct({
17882
17898
  snapshotSequence: NonNegativeInt,
17883
17899
  thread: OrchestrationThread,
@@ -18842,6 +18858,10 @@ const OrchestrationRpcSchemas = {
18842
18858
  searchThreads: {
18843
18859
  input: OrchestrationSearchThreadsInput,
18844
18860
  output: OrchestrationSearchThreadsResult
18861
+ },
18862
+ getThreadDetailPage: {
18863
+ input: OrchestrationGetThreadDetailPageInput,
18864
+ output: OrchestrationThreadDetailSnapshot
18845
18865
  }
18846
18866
  };
18847
18867
  var OrchestrationGetSnapshotError = class extends TaggedErrorClass()("OrchestrationGetSnapshotError", {
@@ -22812,6 +22832,7 @@ const ServerConfig$1 = Struct({
22812
22832
  shellRevealInFileManagerKind: optionalKey(FileManagerRevealKind),
22813
22833
  threadResumeCompletionMarker: optionalKey(Boolean$1),
22814
22834
  threadSnapshotPagination: optionalKey(Boolean$1),
22835
+ threadDetailPageRpc: optionalKey(Boolean$1),
22815
22836
  observability: ServerObservability,
22816
22837
  settings: ServerSettings
22817
22838
  });
@@ -26678,6 +26699,17 @@ const WsOrchestrationSearchThreadsRpc = make$30(ORCHESTRATION_WS_METHODS.searchT
26678
26699
  error: OrchestrationSearchThreadsError
26679
26700
  });
26680
26701
  /**
26702
+ * The socket-only path for "load earlier turns": a relay-only connection has
26703
+ * no HTTP endpoint, so this reads the same windowed page the HTTP snapshot
26704
+ * route and `subscribeThread`'s fallback snapshot serve, over the RPC socket
26705
+ * instead.
26706
+ */
26707
+ const WsOrchestrationGetThreadDetailPageRpc = make$30(ORCHESTRATION_WS_METHODS.getThreadDetailPage, {
26708
+ payload: OrchestrationRpcSchemas.getThreadDetailPage.input,
26709
+ success: OrchestrationRpcSchemas.getThreadDetailPage.output,
26710
+ error: OrchestrationGetSnapshotError
26711
+ });
26712
+ /**
26681
26713
  * Resolve `owner/repo` to the repository the caller is allowed to clone.
26682
26714
  *
26683
26715
  * Both of these run against the same CMMD-brokered GitHub credential the web
@@ -26790,7 +26822,7 @@ const WsRpcGroup = make$29(WsServerGetConfigRpc, WsServerRefreshProvidersRpc, Ws
26790
26822
  payload: ProviderUploadFeedbackInput,
26791
26823
  success: ProviderUploadFeedbackResult,
26792
26824
  error: ProviderUploadFeedbackError
26793
- }), WsOrchestrationSearchThreadsRpc, WsSourceControlLookupRepositoryRpc, WsSourceControlCloneRepositoryRpc);
26825
+ }), WsOrchestrationSearchThreadsRpc, WsSourceControlLookupRepositoryRpc, WsSourceControlCloneRepositoryRpc, WsOrchestrationGetThreadDetailPageRpc);
26794
26826
  const RelayWsRpcGroup = WsRpcGroup.middleware(RelayAuthorizationMiddleware).middleware(RelayDeliveryMiddleware);
26795
26827
  /**
26796
26828
  * The direct-WebSocket mount of the same group, with per-method scope
@@ -26863,6 +26895,32 @@ function encodeCmmdRuntimeToolActivityPayload(kind, payload) {
26863
26895
  }
26864
26896
  };
26865
26897
  }
26898
+ const WORKTREE_LAUNCH_ACTIVITY_KINDS = new Set([
26899
+ "worktree.requested",
26900
+ "worktree.stage",
26901
+ "worktree.output",
26902
+ "worktree.created",
26903
+ "worktree.failed",
26904
+ "setup-script.requested",
26905
+ "setup-script.started",
26906
+ "setup-script.skipped",
26907
+ "setup-script.failed",
26908
+ "launch.agent-starting",
26909
+ "launch.cancelled"
26910
+ ]);
26911
+ function encodeCmmdRuntimeWorktreeLaunchActivityPayload(kind, payload) {
26912
+ return {
26913
+ ...isRecord$23(payload) ? payload : { sourcePayload: payload },
26914
+ forgeWorktreeLaunchActivity: {
26915
+ schemaVersion: 1,
26916
+ source: "native",
26917
+ kind
26918
+ }
26919
+ };
26920
+ }
26921
+ function isCmmdRuntimeWorktreeLaunchActivityKind(kind) {
26922
+ return WORKTREE_LAUNCH_ACTIVITY_KINDS.has(kind);
26923
+ }
26866
26924
  //#endregion
26867
26925
  //#region ../../packages/contracts/src/cmmdProjectPatch.ts
26868
26926
  const CMMD_PROJECT_PATCH_FIELDS = {
@@ -31965,6 +32023,11 @@ const RELAY_METHOD_POLICY_BASES = [
31965
32023
  requiredScope: "thread.read",
31966
32024
  deliveryClass: "subscription"
31967
32025
  },
32026
+ {
32027
+ method: ORCHESTRATION_WS_METHODS.getThreadDetailPage,
32028
+ requiredScope: "thread.read",
32029
+ deliveryClass: "read"
32030
+ },
31968
32031
  {
31969
32032
  method: WS_METHODS.assetsCreateUrl,
31970
32033
  requiredScope: "thread.read",
@@ -66022,7 +66085,7 @@ function normalizeNumberish(value) {
66022
66085
  }
66023
66086
  //#endregion
66024
66087
  //#region package.json
66025
- var version$1 = "0.13.12";
66088
+ var version$1 = "0.13.14";
66026
66089
  //#endregion
66027
66090
  //#region src/sentry.ts
66028
66091
  const SERVER_APP_NAME = "forge-server";
@@ -66424,6 +66487,129 @@ const respondToAuthError = (error) => gen(function* () {
66424
66487
  }));
66425
66488
  return response;
66426
66489
  });
66490
+ //#endregion
66491
+ //#region src/http/rateLimit.ts
66492
+ /**
66493
+ * Per-client, per-route-class rate limiting for Forge's own HTTP surface.
66494
+ *
66495
+ * Scoped to the routes a broken or looping client can hammer before it ever
66496
+ * has an authenticated session — the CMMD device-code poll, the bootstrap and
66497
+ * pairing-token exchanges — plus the authenticated /api/cmmd proxy, which
66498
+ * every desktop UI interaction runs through. None of these had any limit
66499
+ * before FOR-36706: a stuck pairing loop or a poll that never saw `slow_down`
66500
+ * could retry as fast as the network allowed, with nothing to stop it and
66501
+ * nothing telling the client to back off.
66502
+ *
66503
+ * Defaults are generous on purpose. This exists to bound a runaway client,
66504
+ * not to throttle a working one — see `defaultRateLimitPolicies` for the
66505
+ * reasoning behind each number and how to override it.
66506
+ *
66507
+ * Deliberately plain module state, not an Effect service: every route this
66508
+ * guards is a synchronous decision (count, compare, maybe reset a window),
66509
+ * and threading a new Context requirement through the whole server's layer
66510
+ * graph for that would touch every test file that stands up any of these
66511
+ * routes. `cmmdJwtResolver.ts`'s in-flight-refresh map is the same call.
66512
+ */
66513
+ function envInt(name, fallback) {
66514
+ const raw = process.env[name];
66515
+ if (!raw) return fallback;
66516
+ const parsed = Number(raw);
66517
+ return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback;
66518
+ }
66519
+ /**
66520
+ * One reasoning line per class, so a future change to a default has to
66521
+ * confront why the old one was picked:
66522
+ *
66523
+ * - `auth-device-poll` (`/api/auth/cmmd-oauth/device` and `/token`): RFC 8628
66524
+ * polls every `interval` seconds, 5 by default, so a well-behaved client
66525
+ * sits under 12/min. 120/min leaves room for several concurrent sign-in
66526
+ * attempts and CMMD's own `slow_down` handling without ever tripping on
66527
+ * normal use, while still bounding a client that ignores `slow_down`.
66528
+ * - `auth-bootstrap` (`/api/auth/bootstrap*`, the bearer bootstrap routes):
66529
+ * a working client bootstraps once per sign-in. 30/min is generous headroom
66530
+ * for retries after a network blip, not a budget anyone should approach.
66531
+ * - `auth-token-exchange` (`/oauth/token`, the vendored pairing exchange):
66532
+ * same shape as bootstrap — one pairing attempt, occasionally retried.
66533
+ * - `cmmd-proxy` (every `/api/cmmd/*` route): this is the one real desktop
66534
+ * traffic actually leans on — task lists, project browsing, comments, all
66535
+ * day. 1200/min (20/sec) is set high enough that no normal session should
66536
+ * ever see it; it exists for a retry loop or a runaway resync, not for
66537
+ * ordinary use.
66538
+ */
66539
+ function buildPoliciesFromEnv() {
66540
+ return {
66541
+ "auth-device-poll": {
66542
+ maxRequests: envInt("FORGE_RATE_LIMIT_AUTH_DEVICE_POLL_MAX", 120),
66543
+ windowMs: envInt("FORGE_RATE_LIMIT_AUTH_DEVICE_POLL_WINDOW_SECONDS", 60) * 1e3
66544
+ },
66545
+ "auth-bootstrap": {
66546
+ maxRequests: envInt("FORGE_RATE_LIMIT_AUTH_BOOTSTRAP_MAX", 30),
66547
+ windowMs: envInt("FORGE_RATE_LIMIT_AUTH_BOOTSTRAP_WINDOW_SECONDS", 60) * 1e3
66548
+ },
66549
+ "auth-token-exchange": {
66550
+ maxRequests: envInt("FORGE_RATE_LIMIT_AUTH_TOKEN_EXCHANGE_MAX", 30),
66551
+ windowMs: envInt("FORGE_RATE_LIMIT_AUTH_TOKEN_EXCHANGE_WINDOW_SECONDS", 60) * 1e3
66552
+ },
66553
+ "cmmd-proxy": {
66554
+ maxRequests: envInt("FORGE_RATE_LIMIT_CMMD_PROXY_MAX", 1200),
66555
+ windowMs: envInt("FORGE_RATE_LIMIT_CMMD_PROXY_WINDOW_SECONDS", 60) * 1e3
66556
+ }
66557
+ };
66558
+ }
66559
+ let policies = buildPoliciesFromEnv();
66560
+ const windows = /* @__PURE__ */ new Map();
66561
+ function windowKey(routeClass, clientKey) {
66562
+ return `${routeClass}\0${clientKey}`;
66563
+ }
66564
+ /**
66565
+ * Fixed-window counting: cheap, and precise enough for a loop-guard rather
66566
+ * than a security boundary. The one imprecision this trades away — up to 2x
66567
+ * the configured rate right at a window boundary — matters for an attacker
66568
+ * budgeting requests against the limiter's clock, not for the runaway
66569
+ * clients this exists to catch.
66570
+ */
66571
+ function checkHttpRateLimit(input) {
66572
+ const policy = policies[input.routeClass];
66573
+ const nowMs = Date.now();
66574
+ const key = windowKey(input.routeClass, input.clientKey);
66575
+ const existing = windows.get(key);
66576
+ const windowIsLive = existing !== void 0 && nowMs - existing.windowStartMs < policy.windowMs;
66577
+ const windowStartMs = windowIsLive ? existing.windowStartMs : nowMs;
66578
+ const count = (windowIsLive ? existing.count : 0) + 1;
66579
+ windows.set(key, {
66580
+ windowStartMs,
66581
+ count
66582
+ });
66583
+ return {
66584
+ allowed: count <= policy.maxRequests,
66585
+ retryAfterSeconds: Math.max(0, Math.ceil((windowStartMs + policy.windowMs - nowMs) / 1e3))
66586
+ };
66587
+ }
66588
+ /**
66589
+ * Checks the limiter for one request and, when the caller is over budget,
66590
+ * returns the 429 to send instead of running the route — `null` means
66591
+ * proceed. Every route this guards is expected to call this first and return
66592
+ * early on a non-null result, before doing any other work.
66593
+ */
66594
+ function rateLimitOrRespond(input) {
66595
+ const decision = checkHttpRateLimit(input);
66596
+ if (decision.allowed) return null;
66597
+ return jsonUnsafe({ error: "Too many requests. Try again later." }, {
66598
+ status: 429,
66599
+ headers: { "retry-after": String(decision.retryAfterSeconds) }
66600
+ });
66601
+ }
66602
+ /**
66603
+ * The client identity for a route with no session yet — the device-code
66604
+ * poll, bootstrap and pairing-token exchange all run before Forge has issued
66605
+ * one. IP address is the only stable-enough signal available at that point;
66606
+ * the authenticated `/api/cmmd` proxy keys on the session instead (see its
66607
+ * call site), so one desktop and one phone sharing a NAT never share a
66608
+ * budget there.
66609
+ */
66610
+ function clientKeyFromRequest(request) {
66611
+ return normalizeIpAddress(getOrNull(request.remoteAddress)) ?? "unknown";
66612
+ }
66427
66613
  const OAUTH_ACCESS_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:access_token";
66428
66614
  /**
66429
66615
  * The one exchange this endpoint performs, or the reason it will not.
@@ -68271,6 +68457,11 @@ function commandOAuthProxyStatus(path, status, body) {
68271
68457
  }
68272
68458
  const authBootstrapRouteLayer = add$1("POST", "/api/auth/bootstrap", gen(function* () {
68273
68459
  const request = yield* HttpServerRequest;
68460
+ const limited = rateLimitOrRespond({
68461
+ routeClass: "auth-bootstrap",
68462
+ clientKey: clientKeyFromRequest(request)
68463
+ });
68464
+ if (limited) return limited;
68274
68465
  const serverAuth = yield* ServerAuth;
68275
68466
  const sessions = yield* SessionCredentialService;
68276
68467
  const payload = yield* schemaBodyJson(AuthBootstrapInput).pipe(mapError((cause) => new AuthError({
@@ -68288,6 +68479,11 @@ const authBootstrapRouteLayer = add$1("POST", "/api/auth/bootstrap", gen(functio
68288
68479
  }).pipe(catchTag("AuthError", (error) => respondToAuthError(error))));
68289
68480
  const authCommandBootstrapRouteLayer = add$1("POST", "/api/auth/bootstrap/command", gen(function* () {
68290
68481
  const request = yield* HttpServerRequest;
68482
+ const limited = rateLimitOrRespond({
68483
+ routeClass: "auth-bootstrap",
68484
+ clientKey: clientKeyFromRequest(request)
68485
+ });
68486
+ if (limited) return limited;
68291
68487
  const serverAuth = yield* ServerAuth;
68292
68488
  const sessions = yield* SessionCredentialService;
68293
68489
  if (serverAuth.isForgeCloudShell?.() === true) return yield* new AuthError({
@@ -68336,6 +68532,11 @@ const authCommandBootstrapRouteLayer = add$1("POST", "/api/auth/bootstrap/comman
68336
68532
  }).pipe(catchTag("AuthError", (error) => respondToAuthError(error))));
68337
68533
  const authCommandBearerBootstrapRouteLayer = add$1("POST", "/api/auth/bootstrap/command/bearer", gen(function* () {
68338
68534
  const request = yield* HttpServerRequest;
68535
+ const limited = rateLimitOrRespond({
68536
+ routeClass: "auth-bootstrap",
68537
+ clientKey: clientKeyFromRequest(request)
68538
+ });
68539
+ if (limited) return limited;
68339
68540
  const serverAuth = yield* ServerAuth;
68340
68541
  const sessions = yield* SessionCredentialService;
68341
68542
  if (serverAuth.isForgeCloudShell?.() === true) return yield* new AuthError({
@@ -68384,6 +68585,11 @@ const authCommandBearerBootstrapRouteLayer = add$1("POST", "/api/auth/bootstrap/
68384
68585
  }, { status: 200 });
68385
68586
  }).pipe(catchTag("AuthError", (error) => respondToAuthError(error))));
68386
68587
  const authCmmdOAuthDeviceRouteLayer = add$1("POST", "/api/auth/cmmd-oauth/device", gen(function* () {
68588
+ const limited = rateLimitOrRespond({
68589
+ routeClass: "auth-device-poll",
68590
+ clientKey: clientKeyFromRequest(yield* HttpServerRequest)
68591
+ });
68592
+ if (limited) return limited;
68387
68593
  const payload = yield* schemaBodyJson(CommandOAuthDeviceInput).pipe(mapError((cause) => new AuthError({
68388
68594
  message: "Invalid CMMD OAuth device payload.",
68389
68595
  status: 400,
@@ -68395,7 +68601,17 @@ const authCmmdOAuthDeviceRouteLayer = add$1("POST", "/api/auth/cmmd-oauth/device
68395
68601
  ...payload.scope ? { scope: payload.scope } : {}
68396
68602
  });
68397
68603
  }).pipe(catchTag("AuthError", (error) => respondToAuthError(error))));
68604
+ /**
68605
+ * The device-code poll. Called every `interval` seconds by design (RFC
68606
+ * 8628), which is exactly why FOR-36706 puts a limiter on it: a client that
68607
+ * ignores `slow_down` — a bug, not a person — has no other backstop here.
68608
+ */
68398
68609
  const authCmmdOAuthTokenRouteLayer = add$1("POST", "/api/auth/cmmd-oauth/token", gen(function* () {
68610
+ const limited = rateLimitOrRespond({
68611
+ routeClass: "auth-device-poll",
68612
+ clientKey: clientKeyFromRequest(yield* HttpServerRequest)
68613
+ });
68614
+ if (limited) return limited;
68399
68615
  const payload = yield* schemaBodyJson(CommandOAuthTokenInput).pipe(mapError((cause) => new AuthError({
68400
68616
  message: "Invalid CMMD OAuth token payload.",
68401
68617
  status: 400,
@@ -68690,6 +68906,11 @@ const authWebCallbackRouteLayer = add$1("GET", "/api/auth/callback", gen(functio
68690
68906
  }).pipe(catchTag("AuthError", (error) => respondToAuthError(error))));
68691
68907
  const authBearerBootstrapRouteLayer = add$1("POST", "/api/auth/bootstrap/bearer", gen(function* () {
68692
68908
  const request = yield* HttpServerRequest;
68909
+ const limited = rateLimitOrRespond({
68910
+ routeClass: "auth-bootstrap",
68911
+ clientKey: clientKeyFromRequest(request)
68912
+ });
68913
+ if (limited) return limited;
68693
68914
  const serverAuth = yield* ServerAuth;
68694
68915
  const payload = yield* schemaBodyJson(AuthBootstrapInput).pipe(mapError((cause) => new AuthError({
68695
68916
  message: "Invalid bootstrap payload.",
@@ -68721,6 +68942,11 @@ const authBearerBootstrapRouteLayer = add$1("POST", "/api/auth/bootstrap/bearer"
68721
68942
  */
68722
68943
  const authOAuthTokenExchangeRouteLayer = add$1("POST", "/oauth/token", gen(function* () {
68723
68944
  const request = yield* HttpServerRequest;
68945
+ const limited = rateLimitOrRespond({
68946
+ routeClass: "auth-token-exchange",
68947
+ clientKey: clientKeyFromRequest(request)
68948
+ });
68949
+ if (limited) return limited;
68724
68950
  const serverAuth = yield* ServerAuth;
68725
68951
  const payload = yield* schemaBodyUrlParams(OAuthTokenExchangeInput).pipe(mapError((cause) => new AuthError({
68726
68952
  message: "Invalid token exchange payload.",
@@ -70923,9 +71149,12 @@ function trySubjectFallback(sessionId, subject, logPrefix) {
70923
71149
  * Fail-closed: never returns a non-user / malformed Bearer. Callers must map
70924
71150
  * null to CMMD_AUTH_REQUIRED and must not call CMMD upstream with a bad token.
70925
71151
  */
70926
- function resolveCmmdJwtForSession({ sessionId, subject, logPrefix = "cmmd-auth", forceRefresh = false }) {
71152
+ function resolveCmmdJwtForSession({ sessionId, subject, logPrefix = "cmmd-auth", forceRefresh = false, staleAccessToken = null }) {
70927
71153
  const cached = usableCmmdUserJwt(readCommandJwtForSession(sessionId));
70928
- if (!forceRefresh && cached && !isJwtExpiredForSession(sessionId)) return succeed(cached);
71154
+ if (cached && !isJwtExpiredForSession(sessionId)) {
71155
+ if (!forceRefresh) return succeed(cached);
71156
+ if (staleAccessToken && cached !== staleAccessToken) return succeed(cached);
71157
+ }
70929
71158
  const normalizedSubject = subject?.trim() || null;
70930
71159
  if (!normalizedSubject && !forceRefresh) return succeed(null);
70931
71160
  return gen(function* () {
@@ -71762,6 +71991,11 @@ function buildProxyRouteLayer(route) {
71762
71991
  const handler = gen(function* () {
71763
71992
  const req = yield* HttpServerRequest;
71764
71993
  const session = yield* (yield* ServerAuth).authenticateHttpRequest(req);
71994
+ const limited = rateLimitOrRespond({
71995
+ routeClass: "cmmd-proxy",
71996
+ clientKey: session.sessionId
71997
+ });
71998
+ if (limited) return limited;
71765
71999
  const pathParams = yield* params;
71766
72000
  const cmmdPath = substitutePath(route.cmmdPath, pathParams);
71767
72001
  if (!cmmdPath) return jsonUnsafe({ error: "Missing required path parameter." }, { status: 400 });
@@ -71821,6 +72055,11 @@ function buildProxyHandlerEffect(route) {
71821
72055
  return gen(function* () {
71822
72056
  const req = yield* HttpServerRequest;
71823
72057
  const session = yield* (yield* ServerAuth).authenticateHttpRequest(req);
72058
+ const limited = rateLimitOrRespond({
72059
+ routeClass: "cmmd-proxy",
72060
+ clientKey: session.sessionId
72061
+ });
72062
+ if (limited) return limited;
71824
72063
  if (!readCommandAccessTokenForSession(session.sessionId)) return yield* respondCmmdConnectionRequired(session.sessionId);
71825
72064
  const parsed = yield* route.parse(req);
71826
72065
  if (!parsed.ok) return jsonUnsafe(parsed.body, { status: parsed.status });
@@ -97647,6 +97886,26 @@ function serializeNativeToolActivity(activity) {
97647
97886
  }
97648
97887
  };
97649
97888
  }
97889
+ function serializeWorktreeLaunchActivity(activity) {
97890
+ if (decodeWorktreeLaunchActivityKind(activity.kind) === null) return null;
97891
+ const summary = text(activity.summary, 4e3);
97892
+ if (summary === null) return null;
97893
+ return {
97894
+ sourceSequence: null,
97895
+ event: {
97896
+ eventId: `activity:${activity.id}`,
97897
+ occurredAt: canonicalRuntimeEventTimestamp(activity.createdAt),
97898
+ ...eventTurn(activity.turnId),
97899
+ kind: "tool_call",
97900
+ summary,
97901
+ status: "completed",
97902
+ payload: encodeCmmdRuntimeWorktreeLaunchActivityPayload(activity.kind, activity.payload)
97903
+ }
97904
+ };
97905
+ }
97906
+ function decodeWorktreeLaunchActivityKind(kind) {
97907
+ return isCmmdRuntimeWorktreeLaunchActivityKind(kind) ? kind : null;
97908
+ }
97650
97909
  function isReplicatedFailureActivity(activity) {
97651
97910
  return activity.kind === "runtime.error" || activity.kind.startsWith("provider.") && activity.kind.endsWith(".failed");
97652
97911
  }
@@ -98027,12 +98286,17 @@ function serializeRuntimeThreadEvents(thread) {
98027
98286
  const event = serializeNativeToolActivity(activity);
98028
98287
  return event === null ? [] : [event];
98029
98288
  });
98289
+ const worktreeLaunchActivities = thread.activities.flatMap((activity) => {
98290
+ const event = serializeWorktreeLaunchActivity(activity);
98291
+ return event === null ? [] : [event];
98292
+ });
98030
98293
  const recordedTurns = serializeRecordedTurns(thread);
98031
98294
  const textGenerations = serializeTextGenerations(thread);
98032
98295
  return [
98033
98296
  ...messages,
98034
98297
  ...replicaActivities,
98035
98298
  ...toolActivities,
98299
+ ...worktreeLaunchActivities,
98036
98300
  ...failureActivities,
98037
98301
  ...recordedTurns,
98038
98302
  ...textGenerations
@@ -106186,6 +106450,39 @@ function loadInitialShellSnapshot(input) {
106186
106450
  return yield* input.projectionSnapshotQuery.getShellSnapshot();
106187
106451
  });
106188
106452
  }
106453
+ /**
106454
+ * The thread snapshot a subscription falls back to, windowed when the
106455
+ * subscriber asked for a window. A missing thread is a failure rather than an
106456
+ * empty snapshot: the subscriber asked about a thread this server does not
106457
+ * have, and saying so is the only honest answer.
106458
+ *
106459
+ * Also the read behind `getThreadDetailPage`, the socket RPC a relay-only
106460
+ * connection uses for "load earlier turns" in place of the HTTP snapshot
106461
+ * route: same window, same pure `windowThreadDetail`, so the two reads cannot
106462
+ * disagree about what a page is.
106463
+ */
106464
+ function loadThreadDetailSnapshot(input) {
106465
+ return gen(function* () {
106466
+ const [threadDetail, snapshotSequence] = yield* all([input.projectionSnapshotQuery.getThreadDetailById(input.threadId).pipe(mapError((cause) => new OrchestrationGetSnapshotError({
106467
+ message: `Failed to load thread ${input.threadId}`,
106468
+ cause
106469
+ }))), input.orchestrationEngine.getReadModel().pipe(map$3((readModel) => readModel.snapshotSequence))]);
106470
+ if (isNone(threadDetail)) return yield* new OrchestrationGetSnapshotError({
106471
+ message: `Thread ${input.threadId} was not found`,
106472
+ cause: input.threadId
106473
+ });
106474
+ const windowed = windowThreadDetail({
106475
+ thread: threadDetail.value,
106476
+ window: input.window,
106477
+ snapshotSequence
106478
+ });
106479
+ return {
106480
+ snapshotSequence,
106481
+ thread: windowed.thread,
106482
+ ...windowed.page === void 0 ? {} : { page: windowed.page }
106483
+ };
106484
+ });
106485
+ }
106189
106486
  function makeSnapshotThenLiveStream(input) {
106190
106487
  return unwrap$1(gen(function* () {
106191
106488
  const liveEvents = yield* input.subscribeLiveEvents;
@@ -106411,32 +106708,6 @@ const makeWsRpcLayer = (currentSessionId, currentUserId) => WsRpcGroup.toLayer(g
106411
106708
  }, normalizedCommand) : (normalizedCommand.type === "thread.turn.interrupt" ? interruptBootstrapTurnStart(orchestrationEngine, normalizedCommand) : orchestrationEngine.dispatch(normalizedCommand)).pipe(mapError((cause) => toDispatchCommandError(cause, "Failed to dispatch orchestration command")));
106412
106709
  return startup.enqueueCommand(dispatchEffect).pipe(mapError((cause) => toDispatchCommandError(cause, "Failed to dispatch orchestration command")));
106413
106710
  };
106414
- /**
106415
- * The thread snapshot a subscription falls back to, windowed when the
106416
- * subscriber asked for a window. A missing thread is a failure rather
106417
- * than an empty snapshot: the subscriber asked about a thread this server
106418
- * does not have, and saying so is the only honest answer.
106419
- */
106420
- const loadThreadDetailSnapshot = (threadId, turnLimit) => gen(function* () {
106421
- const [threadDetail, snapshotSequence] = yield* all([projectionSnapshotQuery.getThreadDetailById(threadId).pipe(mapError((cause) => new OrchestrationGetSnapshotError({
106422
- message: `Failed to load thread ${threadId}`,
106423
- cause
106424
- }))), orchestrationEngine.getReadModel().pipe(map$3((readModel) => readModel.snapshotSequence))]);
106425
- if (isNone(threadDetail)) return yield* new OrchestrationGetSnapshotError({
106426
- message: `Thread ${threadId} was not found`,
106427
- cause: threadId
106428
- });
106429
- const windowed = windowThreadDetail({
106430
- thread: threadDetail.value,
106431
- window: turnLimit === void 0 ? {} : { turnLimit },
106432
- snapshotSequence
106433
- });
106434
- return {
106435
- snapshotSequence,
106436
- thread: windowed.thread,
106437
- ...windowed.page === void 0 ? {} : { page: windowed.page }
106438
- };
106439
- });
106440
106711
  const loadServerConfig = gen(function* () {
106441
106712
  const keybindingsConfig = yield* keybindings.loadConfigState;
106442
106713
  const providers = yield* providerRegistry.getProviders;
@@ -106460,6 +106731,7 @@ const makeWsRpcLayer = (currentSessionId, currentUserId) => WsRpcGroup.toLayer(g
106460
106731
  },
106461
106732
  threadResumeCompletionMarker: true,
106462
106733
  threadSnapshotPagination: true,
106734
+ threadDetailPageRpc: true,
106463
106735
  observability: {
106464
106736
  logsDirectoryPath: config.logsDir,
106465
106737
  localTracingEnabled: true,
@@ -106560,12 +106832,26 @@ const makeWsRpcLayer = (currentSessionId, currentUserId) => WsRpcGroup.toLayer(g
106560
106832
  [ORCHESTRATION_WS_METHODS.subscribeThread]: (input) => observeRpcStreamEffect(ORCHESTRATION_WS_METHODS.subscribeThread, succeed(makeThreadSubscriptionStream(input, {
106561
106833
  subscribeLiveEvents: orchestrationEngine.subscribeDomainEvents().pipe(map$3((events) => events.pipe(filter$2(isThreadDetailEventFor(input.threadId))))),
106562
106834
  currentSequence: orchestrationEngine.getReadModel().pipe(map$3((readModel) => readModel.snapshotSequence)),
106563
- loadSnapshot: loadThreadDetailSnapshot(input.threadId, input.turnLimit),
106835
+ loadSnapshot: loadThreadDetailSnapshot({
106836
+ threadId: input.threadId,
106837
+ window: input.turnLimit === void 0 ? {} : { turnLimit: input.turnLimit },
106838
+ projectionSnapshotQuery,
106839
+ orchestrationEngine
106840
+ }),
106564
106841
  replayEvents: (afterSequence) => orchestrationEngine.readEvents(afterSequence).pipe(filter$2(isThreadDetailEventFor(input.threadId)), mapError$1((cause) => new OrchestrationGetSnapshotError({
106565
106842
  message: `Failed to replay thread ${input.threadId}`,
106566
106843
  cause
106567
106844
  })))
106568
106845
  })), { "rpc.aggregate": "orchestration" }),
106846
+ [ORCHESTRATION_WS_METHODS.getThreadDetailPage]: (input) => observeRpcEffect(ORCHESTRATION_WS_METHODS.getThreadDetailPage, loadThreadDetailSnapshot({
106847
+ threadId: input.threadId,
106848
+ window: {
106849
+ ...input.turnLimit === void 0 ? {} : { turnLimit: input.turnLimit },
106850
+ ...input.beforeCursor === void 0 ? {} : { beforeCursor: input.beforeCursor }
106851
+ },
106852
+ projectionSnapshotQuery,
106853
+ orchestrationEngine
106854
+ }), { "rpc.aggregate": "orchestration" }),
106569
106855
  [WS_METHODS.serverGetConfig]: (_input) => observeRpcEffect(WS_METHODS.serverGetConfig, loadServerConfig, { "rpc.aggregate": "server" }),
106570
106856
  [WS_METHODS.serverRefreshProviders]: (_input) => observeRpcEffect(WS_METHODS.serverRefreshProviders, providerRegistry.refresh().pipe(map$3((providers) => ({ providers }))), { "rpc.aggregate": "server" }),
106571
106857
  [WS_METHODS.serverStartProviderAuth]: (input) => observeRpcEffect(WS_METHODS.serverStartProviderAuth, gen(function* () {
@@ -212612,6 +212898,8 @@ const PUBLIC_PROVIDER_LEAK_PATTERNS = [
212612
212898
  /\bCURSOR_API_KEY\b/,
212613
212899
  /\bOPENCODE_API_KEY\b/,
212614
212900
  /\bforge_proxy_[A-Za-z0-9._-]+\b/,
212901
+ /\bProviderValidationError\b/,
212902
+ /\/forge-server\/node_modules\//,
212615
212903
  /https?:\/\/[^\s'"<>)]*\/api\/provider-egress\/[^\s'"<>)]*/i,
212616
212904
  /https?:\/\/[^\s'"<>)]*(?:apex|deepseek|moonshot|kimi)[^\s'"<>)]*/i
212617
212905
  ];
@@ -212621,6 +212909,12 @@ function isProviderRateOrQuotaError(message) {
212621
212909
  const normalized = message.toLowerCase();
212622
212910
  return /\b(rate[\s-]?limit|too\s+many\s+requests|usage[ _-]limit|usage_limit_reached|quota|insufficient_quota)\b/.test(normalized) || /\b429\b/.test(normalized);
212623
212911
  }
212912
+ function providerLifecycleOrAccountCopy(message) {
212913
+ if (/\bbound to provider account\b/.test(message) && /\bcannot use provider account\b/.test(message)) return "Forge could not switch this conversation to the selected AI account. Retry the account switch.";
212914
+ if (/\bforge_runtime_repairing\b/.test(message) || /\bthis turn was interrupted because the environment is being repaired\b/.test(message)) return "This turn was interrupted because the environment is being repaired. Retry after the repair finishes.";
212915
+ if (/\bforge_runtime_stopped\b/.test(message) || /\bthis turn was interrupted because the environment was stopped\b/.test(message)) return "This turn was interrupted because the environment was stopped. Start the environment and retry.";
212916
+ return null;
212917
+ }
212624
212918
  /**
212625
212919
  * Infer a SAFE, clearer failure category from a (possibly leaky) provider error.
212626
212920
  *
@@ -212633,7 +212927,8 @@ function isProviderRateOrQuotaError(message) {
212633
212927
  function categorizePublicProviderError(message) {
212634
212928
  if (!message) return null;
212635
212929
  const m = message.toLowerCase();
212636
- if (/\bforge_runtime_stopped\b/.test(m) || /\bthis turn was interrupted because the environment was stopped\b/.test(m)) return "This turn was interrupted because the environment was stopped. Start the environment and retry.";
212930
+ const lifecycleOrAccountCopy = providerLifecycleOrAccountCopy(m);
212931
+ if (lifecycleOrAccountCopy !== null) return lifecycleOrAccountCopy;
212637
212932
  if (/\bforge_provider_model_not_allowed\b/.test(m) || /\bnot\s+scoped\s+to\s+the\s+requested\s+model\b/.test(m)) return "The workspace AI credential does not cover the model this turn asked for. Retry, or pick that model for the thread.";
212638
212933
  if (/\bmodel\b[\s\S]*\b(not\s+(found|available|supported)|does\s+not\s+exist|unsupported|invalid|unknown)\b/.test(m) || /\b(unsupported|unknown|invalid)\b[\s\S]*\bmodel\b/.test(m) || /\b(model_not_found|invalid_model|model_not_supported)\b/.test(m)) return "The selected model isn't available right now. Pick a different model and retry.";
212639
212934
  if (isProviderRateOrQuotaError(m)) return "The AI service is rate-limited or out of quota. Wait a moment and retry.";
@@ -212813,13 +213108,20 @@ function claudeConfigDirectory(env) {
212813
213108
  return null;
212814
213109
  }
212815
213110
  }
212816
- /** The fallback is allowed only for the exact credential bytes CMMD signed. */
212817
- function primaryClaudeCredentialMatchesRevision(env, revision) {
213111
+ /**
213112
+ * The fallback is allowed only when the primary on-disk credential is
213113
+ * byte-identical to what CMMD prepared for the selected account. A ticket's
213114
+ * `credentialRevisionHash` cannot express that: it hashes the encrypted row
213115
+ * CMMD stores, not the decrypted bytes this process ever sees. Comparing
213116
+ * files directly is the only check that means what the fallback needs.
213117
+ */
213118
+ function primaryClaudeCredentialMatchesPrepared(env, preparedDirectory) {
212818
213119
  const directory = claudeConfigDirectory(env);
212819
- if (!directory || !/^[a-f0-9]{64}$/.test(revision)) return false;
213120
+ if (!directory) return false;
212820
213121
  try {
212821
- const credential = readFileSync(join(directory, CLAUDE_CREDENTIAL_FILE_NAME), "utf8");
212822
- return createHash("sha256").update(credential).digest("hex") === revision && hasAccessToken(JSON.parse(credential));
213122
+ const primary = readFileSync(join(directory, CLAUDE_CREDENTIAL_FILE_NAME), "utf8");
213123
+ const prepared = readFileSync(join(preparedDirectory, CLAUDE_CREDENTIAL_FILE_NAME), "utf8");
213124
+ return hasAccessToken(JSON.parse(primary)) && createHash("sha256").update(primary).digest("hex") === createHash("sha256").update(prepared).digest("hex");
212823
213125
  } catch {
212824
213126
  return false;
212825
213127
  }
@@ -215206,6 +215508,7 @@ function buildClaudeSessionContext(input) {
215206
215508
  pendingUserInputs: input.pendingUserInputs,
215207
215509
  turns: [],
215208
215510
  inFlightTools: input.inFlightTools,
215511
+ taskPlan: makeClaudeTaskPlanState(),
215209
215512
  turnState: void 0,
215210
215513
  lastKnownContextWindow: void 0,
215211
215514
  lastKnownTokenUsage: void 0,
@@ -215715,6 +216018,7 @@ function classifyToolItemType(toolName) {
215715
216018
  if (normalized.includes("agent")) return "collab_agent_tool_call";
215716
216019
  if (normalized === "task" || normalized === "agent" || normalized.includes("subagent") || normalized.includes("sub-agent")) return "collab_agent_tool_call";
215717
216020
  if (normalized.includes("bash") || normalized.includes("command") || normalized.includes("shell") || normalized.includes("terminal")) return "command_execution";
216021
+ if (isTaskBookkeepingTool(normalized)) return "dynamic_tool_call";
215718
216022
  if (normalized.includes("edit") || normalized.includes("write") || normalized.includes("file") || normalized.includes("patch") || normalized.includes("replace") || normalized.includes("create") || normalized.includes("delete")) return "file_change";
215719
216023
  if (normalized.includes("browser")) return "browser_automation";
215720
216024
  if (normalized.includes("mcp")) return "mcp_tool_call";
@@ -215734,6 +216038,88 @@ function classifyRequestType(toolName) {
215734
216038
  function isTodoTool(toolName) {
215735
216039
  return toolName.toLowerCase().includes("todowrite");
215736
216040
  }
216041
+ function makeClaudeTaskPlanState() {
216042
+ return {
216043
+ entries: [],
216044
+ createsByToolUseId: /* @__PURE__ */ new Map()
216045
+ };
216046
+ }
216047
+ function taskPlanToolFor(toolName) {
216048
+ const normalized = toolName.toLowerCase();
216049
+ if (normalized === "taskcreate") return "TaskCreate";
216050
+ if (normalized === "taskupdate") return "TaskUpdate";
216051
+ }
216052
+ /** Task bookkeeping tools (create, update, list, get) are not file changes. */
216053
+ function isTaskBookkeepingTool(normalizedToolName) {
216054
+ return /^task(create|update|list|get)$/.test(normalizedToolName);
216055
+ }
216056
+ function taskPlanStatus(value) {
216057
+ return value === "completed" ? "completed" : value === "in_progress" ? "inProgress" : "pending";
216058
+ }
216059
+ function taskPlanSnapshot(state) {
216060
+ return state.entries.map((entry) => ({
216061
+ step: entry.step,
216062
+ status: entry.status
216063
+ }));
216064
+ }
216065
+ /**
216066
+ * Applies one TaskCreate or TaskUpdate input to the session's task list and
216067
+ * returns the plan to publish, or null when the input carries nothing yet.
216068
+ * Input JSON streams in pieces, so the same tool use can arrive more than once;
216069
+ * creates are keyed by tool use id so a repeat updates the entry in place.
216070
+ */
216071
+ function applyTaskPlanToolInput(state, tool, toolUseId, input) {
216072
+ if (tool === "TaskCreate") {
216073
+ const subject = typeof input.subject === "string" ? input.subject.trim() : "";
216074
+ const existing = state.createsByToolUseId.get(toolUseId);
216075
+ if (existing) {
216076
+ if (subject.length > 0) existing.step = subject;
216077
+ return taskPlanSnapshot(state);
216078
+ }
216079
+ const entry = {
216080
+ id: void 0,
216081
+ step: subject.length > 0 ? subject : "Task",
216082
+ status: taskPlanStatus(input.status)
216083
+ };
216084
+ state.entries.push(entry);
216085
+ state.createsByToolUseId.set(toolUseId, entry);
216086
+ return taskPlanSnapshot(state);
216087
+ }
216088
+ const taskId = typeof input.taskId === "string" ? input.taskId.trim() : typeof input.taskId === "number" ? String(input.taskId) : "";
216089
+ if (taskId.length === 0) return null;
216090
+ let entry = state.entries.find((candidate) => candidate.id === taskId);
216091
+ if (!entry) {
216092
+ const position = Number.parseInt(taskId, 10);
216093
+ const candidate = Number.isInteger(position) ? state.entries[position - 1] : void 0;
216094
+ if (candidate && candidate.id === void 0) {
216095
+ candidate.id = taskId;
216096
+ entry = candidate;
216097
+ }
216098
+ }
216099
+ if (input.status === "deleted") {
216100
+ if (entry) state.entries.splice(state.entries.indexOf(entry), 1);
216101
+ return taskPlanSnapshot(state);
216102
+ }
216103
+ if (!entry) {
216104
+ entry = {
216105
+ id: taskId,
216106
+ step: `Task #${taskId}`,
216107
+ status: "pending"
216108
+ };
216109
+ state.entries.push(entry);
216110
+ }
216111
+ if (typeof input.subject === "string" && input.subject.trim().length > 0) entry.step = input.subject.trim();
216112
+ if (input.status !== void 0) entry.status = taskPlanStatus(input.status);
216113
+ return taskPlanSnapshot(state);
216114
+ }
216115
+ const TASK_CREATE_RESULT_ID = /Task #(\S+) created/;
216116
+ /** Binds the id Claude Code assigned in the TaskCreate result to the entry. */
216117
+ function bindTaskPlanCreateResult(state, toolUseId, resultText) {
216118
+ const entry = state.createsByToolUseId.get(toolUseId);
216119
+ if (!entry || entry.id !== void 0) return;
216120
+ const match = TASK_CREATE_RESULT_ID.exec(resultText);
216121
+ if (match?.[1]) entry.id = match[1];
216122
+ }
215737
216123
  function extractPlanStepsFromTodoInput(input) {
215738
216124
  const todos = input.todos;
215739
216125
  if (!Array.isArray(todos) || todos.length === 0) return null;
@@ -216450,9 +216836,9 @@ const makeClaudeAdapter = fn$1("makeClaudeAdapter")(function* (options) {
216450
216836
  }
216451
216837
  });
216452
216838
  });
216453
- const emitPlanUpdateFromToolInput = fn$1("emitPlanUpdateFromToolInput")(function* (context, toolName, parsedInput) {
216454
- if (!isTodoTool(toolName)) return;
216455
- const planSteps = extractPlanStepsFromTodoInput(parsedInput);
216839
+ const emitPlanUpdateFromToolInput = fn$1("emitPlanUpdateFromToolInput")(function* (context, toolName, toolUseId, parsedInput) {
216840
+ const taskPlanTool = taskPlanToolFor(toolName);
216841
+ const planSteps = taskPlanTool ? applyTaskPlanToolInput(context.taskPlan, taskPlanTool, toolUseId, parsedInput) : isTodoTool(toolName) ? extractPlanStepsFromTodoInput(parsedInput) : null;
216456
216842
  if (!planSteps || planSteps.length === 0) return;
216457
216843
  const planStamp = yield* makeEventStamp();
216458
216844
  yield* offerRuntimeEvent({
@@ -216510,7 +216896,7 @@ const makeClaudeAdapter = fn$1("makeClaudeAdapter")(function* (options) {
216510
216896
  };
216511
216897
  context.inFlightTools.set(event.index, nextTool);
216512
216898
  yield* emitToolInputUpdated(context, message, nextTool);
216513
- if (state.parsedInput) yield* emitPlanUpdateFromToolInput(context, nextTool.toolName, state.parsedInput);
216899
+ if (state.parsedInput) yield* emitPlanUpdateFromToolInput(context, nextTool.toolName, nextTool.itemId, state.parsedInput);
216514
216900
  });
216515
216901
  const handleContentBlockDeltaEvent = fn$1("handleContentBlockDeltaEvent")(function* (context, message, event) {
216516
216902
  yield* handleTextOrThinkingDelta(context, message, event);
@@ -216563,6 +216949,7 @@ const makeClaudeAdapter = fn$1("makeClaudeAdapter")(function* (options) {
216563
216949
  if (!toolEntry) continue;
216564
216950
  const [index, tool] = toolEntry;
216565
216951
  const itemStatus = toolResult.isError ? "failed" : "completed";
216952
+ if (taskPlanToolFor(tool.toolName) === "TaskCreate" && !toolResult.isError) bindTaskPlanCreateResult(context.taskPlan, tool.itemId, toolResult.text);
216566
216953
  const toolData = {
216567
216954
  toolName: tool.toolName,
216568
216955
  input: tool.input,
@@ -217344,7 +217731,7 @@ const makeClaudeAdapter = fn$1("makeClaudeAdapter")(function* (options) {
217344
217731
  baseDir: serverConfig.baseDir,
217345
217732
  accountIdentity: selectedAccountIdentity,
217346
217733
  probeSupport: claudeCredentialDirectoryProbeFor(claudeBinaryPath).support,
217347
- primaryCredentialMatchesSelected: preparedCredential?.accountKind === "primary" && primaryClaudeCredentialMatchesRevision(process.env, preparedCredential.credentialRevisionHash)
217734
+ primaryCredentialMatchesSelected: preparedCredential?.accountKind === "primary" && primaryClaudeCredentialMatchesPrepared(process.env, preparedCredential.directory)
217348
217735
  });
217349
217736
  if (credentialDirectoryDecision.outcome === "refused") return yield* new ProviderAdapterValidationError({
217350
217737
  provider: PROVIDER$11,
@@ -232483,7 +232870,7 @@ async function acquireSelectedClaudeCredential(expected, transport) {
232483
232870
  const claims = token === null ? null : verifyCmmdClaudeSelectedTicket(token, expected);
232484
232871
  if (!token || !claims) throw new Error("invalid_ticket");
232485
232872
  const credentialJson = await transport.readCredential(token);
232486
- if (Buffer.byteLength(credentialJson, "utf8") > 512e3 || createHash("sha256").update(credentialJson).digest("hex") !== claims.credentialRevisionHash || !hasUsableClaudeCredentialBlob(credentialJson)) throw new Error("invalid_credential");
232873
+ if (Buffer.byteLength(credentialJson, "utf8") > 512e3 || !hasUsableClaudeCredentialBlob(credentialJson)) throw new Error("invalid_credential");
232487
232874
  return {
232488
232875
  claims,
232489
232876
  credentialJson
@@ -291649,6 +292036,10 @@ const ProviderCommandReactorLive = effect(ProviderCommandReactor, gen(function*
291649
292036
  shouldRestartForAccountChange,
291650
292037
  hasResumeCursor: resumeCursor !== void 0
291651
292038
  });
292039
+ if (shouldRestartForAccountChange) yield* providerService.stopSession({
292040
+ threadId,
292041
+ preserveResumeState: true
292042
+ });
291652
292043
  const restartedSession = yield* startProviderSession(resumeCursor !== void 0 ? { resumeCursor } : void 0);
291653
292044
  yield* logInfo("provider command reactor restarted provider session", {
291654
292045
  threadId,
@@ -297584,7 +297975,7 @@ function resolveBuildCommitFromEnv(env) {
297584
297975
  * environment descriptor down instead of reporting an honest "unknown".
297585
297976
  */
297586
297977
  function readBakedBuildCommit() {
297587
- return "e51896589531a57a458ca5c2430dfd54cdc16476";
297978
+ return "dea91e954d3d2f23154802ae304412fabe90dbbd";
297588
297979
  }
297589
297980
  async function resolveServerBuildCommit(input) {
297590
297981
  if (isFullCommitSha(input.baked)) return input.baked;
@@ -298997,20 +299388,20 @@ const generatePairingToken = () => {
298997
299388
  const randomBytes = crypto.getRandomValues(new Uint8Array(PAIRING_TOKEN_LENGTH));
298998
299389
  return Array.from(randomBytes, (value) => PAIRING_TOKEN_ALPHABET[value & 31]).join("");
298999
299390
  };
299391
+ const invalidBootstrapCredentialError = (message) => new BootstrapCredentialError({
299392
+ message,
299393
+ status: 401
299394
+ });
299395
+ const internalBootstrapCredentialError = (message, cause) => new BootstrapCredentialError({
299396
+ message,
299397
+ status: 500,
299398
+ cause
299399
+ });
299000
299400
  const BootstrapCredentialServiceLive = effect(BootstrapCredentialService, gen(function* () {
299001
299401
  const config = yield* ServerConfig;
299002
299402
  const pairingLinks = yield* AuthPairingLinkRepository;
299003
299403
  const seededGrantsRef = yield* make$50(/* @__PURE__ */ new Map());
299004
299404
  const changesPubSub = yield* unbounded$1();
299005
- const invalidBootstrapCredentialError = (message) => new BootstrapCredentialError({
299006
- message,
299007
- status: 401
299008
- });
299009
- const internalBootstrapCredentialError = (message, cause) => new BootstrapCredentialError({
299010
- message,
299011
- status: 500,
299012
- cause
299013
- });
299014
299405
  const seedGrant = (credential, grant) => update$4(seededGrantsRef, (current) => {
299015
299406
  const next = new Map(current);
299016
299407
  next.set(credential, grant);
@@ -299025,19 +299416,19 @@ const BootstrapCredentialServiceLive = effect(BootstrapCredentialService, gen(fu
299025
299416
  id
299026
299417
  }).pipe(asVoid);
299027
299418
  if (config.desktopBootstrapToken) {
299028
- const now$2 = yield* now;
299419
+ const now$3 = yield* now;
299029
299420
  yield* seedGrant(config.desktopBootstrapToken, {
299030
299421
  method: "desktop-bootstrap",
299031
299422
  role: "owner",
299032
299423
  subject: "desktop-bootstrap",
299033
- expiresAt: add$3(now$2, { milliseconds: toMillis(DESKTOP_BOOTSTRAP_TOKEN_TTL) }),
299424
+ expiresAt: add$3(now$3, { milliseconds: toMillis(DESKTOP_BOOTSTRAP_TOKEN_TTL) }),
299034
299425
  remainingUses: "unbounded"
299035
299426
  });
299036
299427
  }
299037
299428
  const toBootstrapCredentialError = (message) => (cause) => internalBootstrapCredentialError(message, cause);
299038
299429
  const listActive = () => gen(function* () {
299039
- const now$5 = yield* now;
299040
- return (yield* pairingLinks.listActive({ now: now$5 })).map((row) => row.label ? {
299430
+ const now$2 = yield* now;
299431
+ return (yield* pairingLinks.listActive({ now: now$2 })).map((row) => row.label ? {
299041
299432
  id: row.id,
299042
299433
  credential: row.credential,
299043
299434
  role: row.role,
@@ -299067,8 +299458,8 @@ const BootstrapCredentialServiceLive = effect(BootstrapCredentialService, gen(fu
299067
299458
  const id = crypto.randomUUID();
299068
299459
  const credential = generatePairingToken();
299069
299460
  const ttl = input?.ttl ?? DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES;
299070
- const now$4 = yield* now;
299071
- const expiresAt = add$3(now$4, { milliseconds: toMillis(ttl) });
299461
+ const now$5 = yield* now;
299462
+ const expiresAt = add$3(now$5, { milliseconds: toMillis(ttl) });
299072
299463
  const issued = {
299073
299464
  id,
299074
299465
  credential,
@@ -299082,7 +299473,7 @@ const BootstrapCredentialServiceLive = effect(BootstrapCredentialService, gen(fu
299082
299473
  role: input?.role ?? "client",
299083
299474
  subject: input?.subject ?? "one-time-token",
299084
299475
  label: input?.label ?? null,
299085
- createdAt: now$4,
299476
+ createdAt: now$5,
299086
299477
  expiresAt
299087
299478
  });
299088
299479
  yield* emitUpsert({
@@ -299091,13 +299482,13 @@ const BootstrapCredentialServiceLive = effect(BootstrapCredentialService, gen(fu
299091
299482
  role: input?.role ?? "client",
299092
299483
  subject: input?.subject ?? "one-time-token",
299093
299484
  ...input?.label ? { label: input.label } : {},
299094
- createdAt: now$4,
299485
+ createdAt: now$5,
299095
299486
  expiresAt
299096
299487
  });
299097
299488
  return issued;
299098
299489
  }).pipe(mapError(toBootstrapCredentialError("Failed to issue pairing credential.")));
299099
299490
  const consume = (credential) => gen(function* () {
299100
- const now$3 = yield* now;
299491
+ const now$4 = yield* now;
299101
299492
  const seededResult = yield* modify$3(seededGrantsRef, (current) => {
299102
299493
  const grant = current.get(credential);
299103
299494
  if (!grant) return [{
@@ -299106,7 +299497,7 @@ const BootstrapCredentialServiceLive = effect(BootstrapCredentialService, gen(fu
299106
299497
  error: invalidBootstrapCredentialError("Unknown bootstrap credential.")
299107
299498
  }, current];
299108
299499
  const next = new Map(current);
299109
- if (isGreaterThanOrEqualTo$1(now$3, grant.expiresAt)) {
299500
+ if (isGreaterThanOrEqualTo$1(now$4, grant.expiresAt)) {
299110
299501
  next.delete(credential);
299111
299502
  return [{
299112
299503
  _tag: "error",
@@ -299135,8 +299526,8 @@ const BootstrapCredentialServiceLive = effect(BootstrapCredentialService, gen(fu
299135
299526
  if (seededResult.reason !== "not-found") return yield* seededResult.error;
299136
299527
  const consumed = yield* pairingLinks.consumeAvailable({
299137
299528
  credential,
299138
- consumedAt: now$3,
299139
- now: now$3
299529
+ consumedAt: now$4,
299530
+ now: now$4
299140
299531
  });
299141
299532
  if (isSome(consumed)) {
299142
299533
  yield* emitRemoved(consumed.value.id);
@@ -299152,9 +299543,9 @@ const BootstrapCredentialServiceLive = effect(BootstrapCredentialService, gen(fu
299152
299543
  if (isNone(matching)) return yield* invalidBootstrapCredentialError("Unknown bootstrap credential.");
299153
299544
  if (matching.value.revokedAt !== null) return yield* invalidBootstrapCredentialError("Bootstrap credential is no longer available.");
299154
299545
  if (matching.value.consumedAt !== null) return yield* invalidBootstrapCredentialError("Unknown bootstrap credential.");
299155
- if (isGreaterThanOrEqualTo$1(now$3, matching.value.expiresAt)) return yield* invalidBootstrapCredentialError("Bootstrap credential expired.");
299546
+ if (isGreaterThanOrEqualTo$1(now$4, matching.value.expiresAt)) return yield* invalidBootstrapCredentialError("Bootstrap credential expired.");
299156
299547
  return yield* invalidBootstrapCredentialError("Bootstrap credential is no longer available.");
299157
- }).pipe(mapError((cause) => cause instanceof BootstrapCredentialError ? cause : internalBootstrapCredentialError("Failed to consume bootstrap credential.", cause)));
299548
+ }).pipe(mapError((cause) => cause instanceof BootstrapCredentialError ? cause : internalBootstrapCredentialError("Failed to consume bootstrap credential.", cause)), tapError((error) => error.status === 401 ? logWarning$1("Rejected bootstrap credential exchange.", { reason: error.message }) : void_$1));
299158
299549
  return {
299159
299550
  issueOneTimeToken,
299160
299551
  listActive,
@@ -299209,6 +299600,10 @@ const SetAuthSessionLastConnectedAtInput = Struct({
299209
299600
  sessionId: AuthSessionId,
299210
299601
  lastConnectedAt: DateTimeUtcFromString
299211
299602
  });
299603
+ const ExtendAuthSessionExpiryInput = Struct({
299604
+ sessionId: AuthSessionId,
299605
+ expiresAt: DateTimeUtcFromString
299606
+ });
299212
299607
  var AuthSessionRepository = class extends Service$1()("forge/persistence/Services/AuthSessions/AuthSessionRepository") {};
299213
299608
  //#endregion
299214
299609
  //#region src/persistence/Layers/AuthSessions.ts
@@ -299360,6 +299755,17 @@ const AuthSessionRepositoryLive = effect(AuthSessionRepository, gen(function* ()
299360
299755
  WHERE session_id = ${sessionId}
299361
299756
  AND revoked_at IS NULL
299362
299757
  RETURNING session_id AS "sessionId"
299758
+ `
299759
+ });
299760
+ const extendSessionExpiryRows = findAll({
299761
+ Request: ExtendAuthSessionExpiryInput,
299762
+ Result: Struct({ sessionId: AuthSessionId }),
299763
+ execute: ({ sessionId, expiresAt }) => sql`
299764
+ UPDATE auth_sessions
299765
+ SET expires_at = ${expiresAt}
299766
+ WHERE session_id = ${sessionId}
299767
+ AND revoked_at IS NULL
299768
+ RETURNING session_id AS "sessionId"
299363
299769
  `
299364
299770
  });
299365
299771
  const revokeOtherSessionRows = findAll({
@@ -299382,13 +299788,15 @@ const AuthSessionRepositoryLive = effect(AuthSessionRepository, gen(function* ()
299382
299788
  const revoke = (input) => revokeSessionRows(input).pipe(mapError(toPersistenceSqlOrDecodeError$3("AuthSessionRepository.revoke:query", "AuthSessionRepository.revoke:decodeRows")), map$3((rows) => rows.length > 0));
299383
299789
  const revokeAllExcept = (input) => revokeOtherSessionRows(input).pipe(mapError(toPersistenceSqlOrDecodeError$3("AuthSessionRepository.revokeAllExcept:query", "AuthSessionRepository.revokeAllExcept:decodeRows")), map$3((rows) => rows.map((row) => row.sessionId)));
299384
299790
  const setLastConnectedAt = (input) => setLastConnectedAtRow(input).pipe(mapError(toPersistenceSqlOrDecodeError$3("AuthSessionRepository.setLastConnectedAt:query", "AuthSessionRepository.setLastConnectedAt:encodeRequest")));
299791
+ const extendExpiry = (input) => extendSessionExpiryRows(input).pipe(mapError(toPersistenceSqlOrDecodeError$3("AuthSessionRepository.extendExpiry:query", "AuthSessionRepository.extendExpiry:decodeRows")), map$3((rows) => rows.length > 0));
299385
299792
  return {
299386
299793
  create,
299387
299794
  getById,
299388
299795
  listActive,
299389
299796
  revoke,
299390
299797
  revokeAllExcept,
299391
- setLastConnectedAt
299798
+ setLastConnectedAt,
299799
+ extendExpiry
299392
299800
  };
299393
299801
  }));
299394
299802
  //#endregion
@@ -299396,6 +299804,20 @@ const AuthSessionRepositoryLive = effect(AuthSessionRepository, gen(function* ()
299396
299804
  const SIGNING_SECRET_NAME = "server-signing-key";
299397
299805
  const DEFAULT_SESSION_TTL = days(30);
299398
299806
  const DEFAULT_WEBSOCKET_TOKEN_TTL = minutes(5);
299807
+ /**
299808
+ * Whether a session presented at `nowMs` has used up half its own lifetime.
299809
+ *
299810
+ * A phone that opens the app daily should never hit the 30-day wall, but a
299811
+ * lost phone's session should still die on schedule if it is never presented
299812
+ * again. Sliding the expiry forward only when the session is already more
299813
+ * than half spent gives an active phone many chances to renew before expiry
299814
+ * while keeping a dormant session's remaining lifetime bounded by the last
299815
+ * time it was actually used.
299816
+ */
299817
+ function isPastHalfLife(input) {
299818
+ const halfLifeMarkMs = input.issuedAtMs + (input.expiresAtMs - input.issuedAtMs) / 2;
299819
+ return input.nowMs >= halfLifeMarkMs;
299820
+ }
299399
299821
  const toSessionCredentialError = (message) => (cause) => new SessionCredentialError({
299400
299822
  message,
299401
299823
  cause
@@ -299556,6 +299978,42 @@ const SessionCredentialServiceLive = effect(SessionCredentialService, gen(functi
299556
299978
  role: claims.role
299557
299979
  };
299558
299980
  }).pipe(mapError(toSessionCredentialError("Failed to issue session credential.")));
299981
+ /**
299982
+ * Slide a session's expiry forward when the token it just verified is past
299983
+ * half its own lifetime, so a phone that keeps using the app never runs
299984
+ * into the 30-day wall.
299985
+ *
299986
+ * The session id, subject, role and method carry over unchanged — only the
299987
+ * signature-bearing `iat`/`exp` differ — which is what lets the DB row be
299988
+ * extended in place (`extendExpiry`) rather than replaced: everything keyed
299989
+ * by session id, most importantly the `command_access_tokens` row holding
299990
+ * this session's CMMD credential, needs no migration.
299991
+ */
299992
+ const renewIfPastHalfLife = (input) => gen(function* () {
299993
+ const nowMs = yield* currentTimeMillis;
299994
+ if (!isPastHalfLife({
299995
+ issuedAtMs: input.row.issuedAt.epochMilliseconds,
299996
+ expiresAtMs: input.row.expiresAt.epochMilliseconds,
299997
+ nowMs
299998
+ })) return void 0;
299999
+ const newIssuedAt = yield* now;
300000
+ const newExpiresAt = add$3(newIssuedAt, { milliseconds: toMillis(DEFAULT_SESSION_TTL) });
300001
+ const newClaims = {
300002
+ ...input.claims,
300003
+ iat: newIssuedAt.epochMilliseconds,
300004
+ exp: newExpiresAt.epochMilliseconds
300005
+ };
300006
+ const newEncodedPayload = base64UrlEncode(JSON.stringify(newClaims));
300007
+ const newSignature = signPayload(newEncodedPayload, signingSecret);
300008
+ if (!(yield* authSessions.extendExpiry({
300009
+ sessionId: input.claims.sid,
300010
+ expiresAt: newExpiresAt
300011
+ }).pipe(orElseSucceed(() => false)))) return void 0;
300012
+ return {
300013
+ token: `${newEncodedPayload}.${newSignature}`,
300014
+ expiresAt: newExpiresAt
300015
+ };
300016
+ });
299559
300017
  const verify = (token) => gen(function* () {
299560
300018
  const [encodedPayload, signature] = token.split(".");
299561
300019
  if (!encodedPayload || !signature) return yield* new SessionCredentialError({ message: "Malformed session token." });
@@ -299570,14 +300028,19 @@ const SessionCredentialServiceLive = effect(SessionCredentialService, gen(functi
299570
300028
  if (isSome(row)) {
299571
300029
  if (row.value.revokedAt !== null) return yield* new SessionCredentialError({ message: "Session token revoked." });
299572
300030
  } else if (!(statelessCookieSessions && claims.method === "browser-session-cookie")) return yield* new SessionCredentialError({ message: "Unknown session token." });
300031
+ const renewed = isSome(row) ? yield* renewIfPastHalfLife({
300032
+ claims,
300033
+ row: row.value
300034
+ }) : void 0;
299573
300035
  return {
299574
300036
  sessionId: claims.sid,
299575
300037
  token,
299576
300038
  method: claims.method,
299577
300039
  client: isSome(row) ? toClientMetadata(row.value.client) : createDefaultClientMetadata(),
299578
- expiresAt: makeUnsafe$5(claims.exp),
300040
+ expiresAt: renewed?.expiresAt ?? makeUnsafe$5(claims.exp),
299579
300041
  subject: claims.sub,
299580
- role: claims.role
300042
+ role: claims.role,
300043
+ ...renewed ? { renewed } : {}
299581
300044
  };
299582
300045
  }).pipe(mapError((cause) => cause instanceof SessionCredentialError ? cause : new SessionCredentialError({
299583
300046
  message: "Failed to verify session credential.",
@@ -300069,7 +300532,10 @@ const ServerAuthLive = effect(ServerAuth, gen(function* () {
300069
300532
  subject: session.subject,
300070
300533
  method: session.method,
300071
300534
  role: session.role,
300072
- ...session.expiresAt ? { expiresAt: session.expiresAt } : {}
300535
+ ...session.renewed ? {
300536
+ expiresAt: session.renewed.expiresAt,
300537
+ renewedSessionToken: session.renewed.token
300538
+ } : session.expiresAt ? { expiresAt: session.expiresAt } : {}
300073
300539
  })), mapError((cause) => new AuthError({
300074
300540
  message: "Unauthorized request.",
300075
300541
  status: 401,
@@ -300104,6 +300570,7 @@ const ServerAuthLive = effect(ServerAuth, gen(function* () {
300104
300570
  role: session.role,
300105
300571
  sessionMethod: session.method,
300106
300572
  ...session.expiresAt ? { expiresAt: toUtc(session.expiresAt) } : {},
300573
+ ...session.renewedSessionToken ? { renewedSessionToken: session.renewedSessionToken } : {},
300107
300574
  cmmdIdentity
300108
300575
  })))), tapError((e) => {
300109
300576
  if (e._tag === "AuthError" && (e.status ?? 500) < 500) return void_$1;
@@ -318374,7 +318841,7 @@ function makeProjectLeadControllerClientLive(options = {}) {
318374
318841
  const sql = yield* SqlClient;
318375
318842
  const fetchFn = options.fetchFn ?? fetch;
318376
318843
  const baseUrl = options.baseUrl ?? resolveCommandAppUrl();
318377
- const resolveStoredJwt = (forceRefresh) => gen(function* () {
318844
+ const resolveStoredJwt = (forceRefresh, staleJwt) => gen(function* () {
318378
318845
  const owner = yield* readMostRecentOwnerCommandAuthSession.pipe(provideService(SqlClient, sql), mapError((cause) => new ProjectLeadControllerClientError({
318379
318846
  kind: "unavailable",
318380
318847
  operation: "resolve-owner-session",
@@ -318388,7 +318855,8 @@ function makeProjectLeadControllerClientLive(options = {}) {
318388
318855
  sessionId: owner.sessionId,
318389
318856
  subject: owner.subject,
318390
318857
  logPrefix: "project-lead-scheduler",
318391
- forceRefresh
318858
+ forceRefresh,
318859
+ staleAccessToken: staleJwt
318392
318860
  }).pipe(provideService(SqlClient, sql));
318393
318861
  if (!jwt) return yield* new ProjectLeadControllerClientError({
318394
318862
  kind: "unauthenticated",
@@ -318431,7 +318899,7 @@ function makeProjectLeadControllerClientLive(options = {}) {
318431
318899
  }));
318432
318900
  if (first._tag === "Success") return first.success;
318433
318901
  if (first.failure.kind !== "unauthenticated") return yield* first.failure;
318434
- const refreshed = yield* resolveJwt(true);
318902
+ const refreshed = yield* resolveJwt(true, jwt);
318435
318903
  return yield* requestAttempt({
318436
318904
  ...input,
318437
318905
  jwt: refreshed