@oar-sh/oar 0.9.5 → 0.9.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/.github/extensions/web-relay/polling/polling-loop.mjs +12 -32
  2. package/.github/extensions/web-relay/skills/question-bridge.mjs +14 -39
  3. package/CHANGELOG.md +340 -0
  4. package/README.md +196 -26
  5. package/package.json +2 -1
  6. package/server/README.md +174 -7
  7. package/server/claude-cloud-worker/claude-cloud-event-normalizer.mjs +340 -0
  8. package/server/claude-cloud-worker/claude-cloud-session-process.mjs +1325 -0
  9. package/server/claude-cloud-worker/claude-cloud-session-worker.mjs +155 -0
  10. package/server/claude-worker/claude-remote-relay-tool.mjs +2 -0
  11. package/server/claude-worker/claude-sdk-adapter.mjs +18 -2
  12. package/server/claude-worker/claude-session-process.mjs +151 -1
  13. package/server/claude-worker/claude-session-worker.mjs +6 -0
  14. package/server/claude-worker/claude-turn-failure.mjs +92 -0
  15. package/server/claude-worker/claude-turn-publisher.mjs +52 -3
  16. package/server/claude-worker/sdk-message-normalizer.mjs +13 -1
  17. package/server/copilot-worker/copilot-model-switch.mjs +29 -3
  18. package/server/copilot-worker/copilot-question-bridge.mjs +2 -4
  19. package/server/copilot-worker/copilot-runtime-tree.mjs +91 -0
  20. package/server/copilot-worker/copilot-sdk-adapter.mjs +48 -8
  21. package/server/copilot-worker/copilot-sdk-session-process.mjs +897 -166
  22. package/server/cursor-worker/cursor-sdk-adapter.mjs +80 -13
  23. package/server/cursor-worker/cursor-turn-runner.mjs +5 -2
  24. package/server/db-schema.mjs +24 -0
  25. package/server/grok-worker/acp-client.mjs +68 -12
  26. package/server/grok-worker/grok-sdk-adapter.mjs +4 -0
  27. package/server/grok-worker/grok-turn-runner.mjs +6 -2
  28. package/server/migrations/0006-workspace-root-settings.mjs +67 -0
  29. package/server/migrations/0007-claude-cloud.mjs +82 -0
  30. package/server/public/app/agent-sessions-settings-ui.js +197 -0
  31. package/server/public/app/agent-sessions-settings.mjs +134 -0
  32. package/server/public/app/api-client.js +160 -6
  33. package/server/public/app/attachments-view.js +20 -1
  34. package/server/public/app/attribution-control.mjs +46 -0
  35. package/server/public/app/bootstrap.js +347 -31
  36. package/server/public/app/claude-cloud-conversation-ui.js +110 -0
  37. package/server/public/app/claude-cloud-settings-ui.js +374 -0
  38. package/server/public/app/claude-cloud-ui.mjs +611 -0
  39. package/server/public/app/composer-control-state.mjs +15 -3
  40. package/server/public/app/context-menu.mjs +140 -0
  41. package/server/public/app/context-tier-options.mjs +4 -0
  42. package/server/public/app/conversation-origin-ui.js +27 -0
  43. package/server/public/app/conversation-provider-indicator.mjs +22 -4
  44. package/server/public/app/conversation-view.js +178 -8
  45. package/server/public/app/cwd-picker.js +12 -0
  46. package/server/public/app/journal-view.js +704 -28
  47. package/server/public/app/markdown-strikethrough.mjs +31 -0
  48. package/server/public/app/math-dollars.mjs +89 -0
  49. package/server/public/app/model-selector-options.mjs +1 -1
  50. package/server/public/app/plan-usage-view.mjs +36 -8
  51. package/server/public/app/relay-board-inline.mjs +70 -0
  52. package/server/public/app/relay-board-view.js +157 -61
  53. package/server/public/app/relay-error-ctas.mjs +28 -5
  54. package/server/public/app/remote-relay-origin-view.mjs +120 -4
  55. package/server/public/app/router.js +49 -12
  56. package/server/public/app/settings-modal.js +33 -2
  57. package/server/public/app/settings-tabs.js +1 -1
  58. package/server/public/app/socket-handlers.js +60 -0
  59. package/server/public/app/store.js +4 -1
  60. package/server/public/app/subagent-fold.mjs +61 -0
  61. package/server/public/app/usage-limit-ui.js +225 -0
  62. package/server/public/index.html +471 -8
  63. package/server/relay-tools.md +13 -1
  64. package/server/repositories/message-repository.mjs +10 -1
  65. package/server/repositories/question-repository.mjs +1 -0
  66. package/server/repositories/remote-relay-repository.mjs +72 -1
  67. package/server/repositories/session-repository.mjs +47 -1
  68. package/server/routes/claude-cloud-routes.mjs +140 -0
  69. package/server/routes/git-routes.mjs +37 -0
  70. package/server/routes/messages-routes.mjs +202 -26
  71. package/server/routes/relay-board-routes.mjs +56 -2
  72. package/server/routes/remote-relay-routes.mjs +15 -1
  73. package/server/routes/sessions-routes.mjs +538 -72
  74. package/server/server-runtime.mjs +291 -27
  75. package/server/services/claude-account-usage-service.mjs +176 -0
  76. package/server/services/claude-cloud-repo-service.mjs +408 -0
  77. package/server/services/claude-cloud-session-service.mjs +326 -0
  78. package/server/services/claude-cloud-settings-service.mjs +269 -0
  79. package/server/services/git-remote-service.mjs +115 -0
  80. package/server/services/plan-usage-claude-cloud.mjs +211 -0
  81. package/server/services/plan-usage-claude.mjs +202 -40
  82. package/server/services/plan-usage-contract.mjs +39 -3
  83. package/server/services/plan-usage-service.mjs +82 -2
  84. package/server/services/preview-proxy-server.mjs +64 -36
  85. package/server/services/process-list-service.mjs +122 -0
  86. package/server/services/provider-reasoning-effort.mjs +3 -0
  87. package/server/services/relay-console-log.mjs +199 -0
  88. package/server/services/remote-relay-caller-context.mjs +78 -19
  89. package/server/services/remote-relay-client.mjs +9 -0
  90. package/server/services/remote-relay-dispatcher.mjs +451 -39
  91. package/server/services/remote-relay-inbound.mjs +83 -1
  92. package/server/services/remote-relay-loopback.mjs +245 -0
  93. package/server/services/remote-relay-registry-service.mjs +52 -0
  94. package/server/services/session-worker-launch-service.mjs +100 -15
  95. package/server/services/session-worker-process-service.mjs +410 -138
  96. package/server/services/session-worker-registry-service.mjs +1 -1
  97. package/server/services/session-worker-stop-service.mjs +24 -13
  98. package/server/services/session-worker-supervisor-service.mjs +89 -2
  99. package/server/services/usage-limit-pause-service.mjs +443 -0
  100. package/server/services/worker-launch-note-service.mjs +151 -0
  101. package/shared/ask-user-bridge.mjs +14 -39
  102. package/shared/claude-attribution.mjs +117 -0
  103. package/shared/claude-cloud/api-client.mjs +809 -0
  104. package/shared/claude-cloud/base-url.mjs +47 -0
  105. package/shared/claude-cloud/credentials.mjs +221 -0
  106. package/shared/claude-cloud/repo-url.mjs +111 -0
  107. package/shared/claude-usage-limit.mjs +257 -0
  108. package/shared/control-poller.mjs +69 -20
  109. package/shared/failure-note-text.mjs +38 -0
  110. package/shared/provider-routing.mjs +4 -2
  111. package/shared/question-wait.mjs +108 -0
  112. package/shared/remote-relay-contract.mjs +167 -22
  113. package/shared/remote-relay-tool-core.mjs +11 -8
  114. package/shared/steer-settle-failure.mjs +17 -0
  115. package/shared/steer-settle-markers.mjs +8 -0
  116. package/shared/worker-runtime/process-tree.mjs +483 -0
  117. package/shared/worker-runtime/relay-errors.mjs +31 -0
  118. package/shared/worker-runtime/send-and-wait-errors.mjs +4 -4
  119. package/shared/worker-runtime/turn-liveness.mjs +249 -0
package/server/README.md CHANGED
@@ -431,6 +431,135 @@ On Windows it opens a `Claude Worker <id>` terminal window.
431
431
  turns. Those replies carry no usage line rather than displaying Copilot premium-request numbers under
432
432
  a reply that never touched Copilot.
433
433
 
434
+ ## Claude Cloud provider
435
+
436
+ Provider type `claude-cloud`: a provider of its own, never an alias of `claude`. Its conversations
437
+ run Claude Code in a sandbox at Anthropic, on a clone of a GitHub repository. The worker,
438
+ `server/claude-cloud-worker/claude-cloud-session-worker.mjs`, is a plain Node process per
439
+ conversation that speaks the usual worker contracts (WebSocket link, heartbeat, control poller,
440
+ crash guard) but runs no agent: it sends the user's messages to the cloud session and follows the
441
+ session's event stream (`claude-cloud-session-process.mjs`, `claude-cloud-event-normalizer.mjs`).
442
+ The user-facing description, including what the provider does with the Claude CLI's login, is in
443
+ the [README](../README.md#claude-cloud); the tracker is
444
+ [docs/feature-tracker/claude-cloud.md](../docs/feature-tracker/claude-cloud.md).
445
+
446
+ ### The cloud client and the login
447
+
448
+ `shared/claude-cloud/api-client.mjs` (`createClaudeCloudClient`) is the only code that knows the
449
+ cloud API: paths, headers and bodies were measured from what the Claude CLI sends, not taken from
450
+ a documented API, so every expectation about them lives in that one file. It is SDK-free (Node's
451
+ `fetch`), turns every failure into a `ClaudeCloudError` with a `code` (`login_missing`,
452
+ `login_expired`, `github_not_connected`, `repo_access_denied`, `environment_missing`, `not_found`,
453
+ `rate_limited`, `bad_request`, `transient`), and never reconnects or retries on a timer by itself.
454
+
455
+ `shared/claude-cloud/credentials.mjs` (`createClaudeCloudCredentials`) is the only code that reads
456
+ the login: `CLAUDE_CODE_OAUTH_TOKEN` from the environment, else `claudeAiOauth.accessToken` in
457
+ `<CLAUDE_CONFIG_DIR | ~/.claude>/.credentials.json`. The token is cached until a minute before it
458
+ expires and then read again; the refresh token is never used. The relay process has one reader and
459
+ one client (`server-runtime.mjs`), whose `nudge` lets the CLI look at its own status
460
+ (`claudeAuthService.getStatus({ force: true })`) at most once in five minutes; each worker builds
461
+ its own reader, without a nudge. `applyClaudeCloudProviderEnvironment` adds no credential to the
462
+ launch environment (`COPILOT_WEB_RELAY_WORKER_KIND=claude-cloud` and `CLAUDE_CLOUD_RELAY_MODEL`,
463
+ nothing else). Only a relay that itself runs on `CLAUDE_CODE_OAUTH_TOKEN` passes a login on: the
464
+ variable is one of `WORKER_SECRET_ENV_VARS`, restricted to the `claude-cloud` worker kind
465
+ (`workerSecretEnvVarsFor`), so under tmux it reaches a cloud worker through the owner-only secret
466
+ env file, never as an `export` and never in the environment tmux is started with, and no other
467
+ worker kind gets it that way. `redact()` takes the token out of every error text.
468
+
469
+ The client's base URL is the Anthropic API. `shared/claude-cloud/base-url.mjs`
470
+ (`resolveClaudeCloudBaseUrl`) lets `OAR_CLAUDE_CLOUD_API_BASE_URL` replace it for tests, and only
471
+ with an `http(s)` URL of `127.0.0.1`, `[::1]` or `localhost` without credentials; any other value
472
+ is ignored, because the client sends the login to whatever its base URL is. The relay's client and
473
+ the worker's both resolve it there.
474
+
475
+ ### Settings
476
+
477
+ `services/claude-cloud-settings-service.mjs` keeps three `app_settings` rows:
478
+ `claude_cloud_enabled` (default off), `claude_cloud_default_model` (default `claude-sonnet-5-5`)
479
+ and `claude_cloud_environment_id`. Everything else the tab shows is read, not stored: the account
480
+ from `claude auth status`, the login's source and expiry from `credentials.describe()`, the
481
+ environments from the cloud (cached for a minute). **While the provider is off, nothing is asked of
482
+ Anthropic**: the environments are listed only when it is on and a login exists. With no
483
+ environment stored, `resolveEnvironmentId` takes the account's first active one and stores it.
484
+ `models` is the Claude provider's model list with the `[…]` tier suffix dropped, deduplicated, the
485
+ default first (`buildClaudeCloudModelList`). Every change is broadcast as the socket event
486
+ `claude_cloud_settings_updated` with the GET body.
487
+
488
+ Switching the provider off does not rebind anything: there is no reconciliation as for the other
489
+ providers, and an existing cloud conversation still launches its worker.
490
+
491
+ ### Creating a conversation
492
+
493
+ `POST /api/conversation/bootstrap` takes `providerType: 'claude-cloud'` with
494
+ `cloudSource: { repoUrl, branch? }`. The provider is only ever asked for by name (its model ids are
495
+ the Claude provider's own, so a model never selects it). Refusals are `400` with a `code`:
496
+ `claude_cloud_disabled`, `claude_cloud_repo_invalid`, `claude_cloud_branch_invalid`,
497
+ `claude_cloud_environment_missing`, and `CLAUDE_CLOUD_MODEL_UNAVAILABLE` for a model outside the
498
+ list. The repository is normalized to `https://github.com/<owner>/<repo>`
499
+ (`shared/claude-cloud/repo-url.mjs` → `normalizeGitHubRepoUrl`; https, ssh and scp forms of
500
+ github.com only), the branch is checked against git's ref rules (`isValidBranchName`; empty = the
501
+ default branch), and the result is stored in `conversations.cloud_source_json` in the same
502
+ transaction as the conversation. Nothing is created at Anthropic here. `POST /api/message` with
503
+ `providerType: 'claude-cloud'` on a conversation of another provider is `409
504
+ CLAUDE_CLOUD_REQUIRES_NEW_CONVERSATION`.
505
+
506
+ `POST /api/message` accepts another model of the list until the first message has created the
507
+ cloud session, and stores it on the runtime session; after that a different model is `409
508
+ CLAUDE_CLOUD_MODEL_REQUIRES_NEW_CONVERSATION`. The worker, however, creates the session with the
509
+ model it was launched with: `resolveModel` prefers `CLAUDE_CLOUD_RELAY_MODEL` over the delivered
510
+ `providerModel`, so a change made after the worker started only takes effect if the worker is
511
+ launched again before the first message. Reasoning effort is always `none` and the context tier
512
+ always `default`, whatever the request says.
513
+
514
+ ### Delivery, binding and resume
515
+
516
+ `buildDequeuedRelayMessage` adds a `claudeCloud` field to every message delivered to a cloud
517
+ worker (`buildClaudeCloudDelivery`): `{ sessionId, lastSequence, repoUrl, branch, environmentId,
518
+ title }`. A restarted worker therefore needs nothing but the delivery. The worker reports the
519
+ binding with `POST /api/claude-cloud-session` (`{ conversationId, cloudSessionId, sessionUrl?,
520
+ lastSequence, pushedBranch?, costUsd?, model? }`) after it created the session, after every
521
+ `result`, and on a push. `recordWorkerReport` (`services/claude-cloud-session-service.mjs`) stores
522
+ the session id, the sequence (never moved back) and the cost on the runtime session row, keeps the
523
+ session link and the list of pushed branches (the newest 20) in `cloud_source_json`, and emits the
524
+ socket event `claude_cloud_session` with `{ conversationId, cloud }`. A report that names another
525
+ cloud session than the stored one starts sequence, cost and pushed branches again.
526
+
527
+ The conversation list, the conversation payload and the bootstrap answer carry the same `cloud`
528
+ field: `{ repoUrl, slug, branch, sessionUrl, pushedBranches: [{ branch, at }], costUsd }`, or
529
+ `null` for a conversation that is not a cloud one. There is no session root for a cloud
530
+ conversation (`buildConversationSessionRootPayload` returns `null`).
531
+
532
+ ### Usage
533
+
534
+ The worker posts to the Claude worker's two usage routes. `POST /api/claude-context-usage` accepts
535
+ a `claude-cloud` conversation and stores the session's context occupancy (used and maximum tokens,
536
+ no categories) in `runtime_sessions.context_usage_json`, which `GET /api/context/:id` serves.
537
+ `POST /api/claude-plan-usage` keeps a cloud worker's report under its own snapshot key
538
+ (`claude-cloud`), so the Claude card stays what the Claude workers wrote.
539
+
540
+ `GET /api/usage` builds two things from the cloud client while the provider is on
541
+ (`services/claude-account-usage-service.mjs`: the usage body, the prepaid credits and the credit
542
+ offer, read without a model turn, cached for 60 s, a failed read for 15 s; the route waits at most
543
+ 3 s for them and falls back to the last turn's snapshot. Nothing is read and nothing cached is
544
+ handed out while the provider is off):
545
+
546
+ - the **Claude card's** live windows (`normalizeClaudeAccountUsage` in
547
+ `services/plan-usage-claude.mjs`): the 5-hour, weekly and model-scoped windows with the
548
+ account's own severity word, the week by product, and the organisation's prepaid credits;
549
+ - the **Claude Cloud card** (`services/plan-usage-claude-cloud.mjs` → `buildClaudeCloudPlanCard`):
550
+ one meter per top-level bucket of the usage body that carries a dollar limit (the cloud credit,
551
+ with its expiry), the relay's own cloud spend (`readClaudeCloudSpend`: the sum of
552
+ `runtime_sessions.claude_cloud_cost_usd` over the cloud conversations that still exist, plus the
553
+ cost of the conversation named by `?conversationId=`), the latest cloud snapshot's session
554
+ detail, and a note when a credit is offered but not claimed.
555
+
556
+ ### Archive
557
+
558
+ `DELETE /api/conversation/:id` and `POST /api/conversation/:id/archive` call
559
+ `archiveSessionInBackground` for a cloud conversation: the cloud session is archived at Anthropic,
560
+ best effort and not waited for, so neither action ever depends on Anthropic. Nothing is deleted
561
+ there.
562
+
434
563
  ## Cursor Agent SDK provider
435
564
 
436
565
  Cursor conversations run through `server/cursor-worker/cursor-session-worker.mjs`, a per-conversation
@@ -792,13 +921,14 @@ Queue metrics include `parkedCount` for turns deferred behind restart/rebind gat
792
921
  | GET | `/api/git/status` | Git status for the conversation's workspace root (`branch`, `upstream`, `ahead`, `behind`, `files[]` incl. untracked); a non-repo root returns `isRepo: false` rather than an error |
793
922
  | GET | `/api/git/diff` | Full-context unified diff for one changed file (`path`, optional `untracked=1`); the client renders both "changes only" and "full file" views from this single patch |
794
923
  | POST | `/api/git/pull` | Run `git pull` in the conversation's workspace root and return the combined output |
924
+ | GET | `/api/git/remote` | Where a folder lives on GitHub, for the New Chat modal of a Claude Cloud chat: `?root=<absolute path>` → `{ ok, root, hasGit, remoteUrl, repoUrl, slug, branch, upstream, ahead, behind, dirty }`. The one git route that takes a path from the client; it passes the same policy as a requested CWD (`403` outside `workspaceRootAllowList`, `400` otherwise). Read-only (`git status`, `git remote get-url`) |
795
925
  | GET | `/api/conversations` | List all conversations |
796
926
  | GET | `/api/sessions` | List runtime sessions bound 1:1 to conversations |
797
927
  | GET | `/api/conversation/:id` | Get conversation message windows (`before*`, `after*`, or `aroundMessageId`) plus session-root metadata |
798
928
  | GET | `/api/search/messages` | Search message text across all conversations (`q`, `limit`, `offset`) |
799
929
  | PATCH | `/api/conversation/:id` | Update a conversation title |
800
930
  | POST | `/api/conversation/:id/compact` | Compact a conversation into a new one with carry-over summary seed |
801
- | DELETE | `/api/conversation/:id` | Delete a conversation: stops its session worker, deletes its CLI session, removes its rows. A Copilot session the relay only imported and never ran is hidden and tombstoned instead: nothing is stopped and its CLI session is kept. `409 conversation-active` (`reason: turn-running` / `background-tasks`) while it is still working; `409 worker-stop-failed` when its worker survives the stop (nothing deleted) |
931
+ | DELETE | `/api/conversation/:id` | Delete a conversation: stops its session worker, deletes its CLI session, removes its rows. A Claude Cloud conversation's cloud session is archived at Anthropic in the background (never waited for). A Copilot session the relay only imported and never ran is hidden and tombstoned instead: nothing is stopped and its CLI session is kept. `409 conversation-active` (`reason: turn-running` / `background-tasks`) while it is still working; `409 worker-stop-failed` when its worker survives the stop (nothing deleted) |
802
932
  | GET | `/api/sdk-session-delete/pending` | (CLI relay) Fetch next pending SDK session delete request |
803
933
  | POST | `/api/sdk-session-delete/result` | (CLI relay) Report SDK session delete result |
804
934
  | POST | `/api/session-sync` | (CLI relay) Sync conversation↔SDK binding and optionally confirm orchestrator rebind completion |
@@ -822,20 +952,23 @@ Queue metrics include `parkedCount` for turns deferred behind restart/rebind gat
822
952
  | GET | `/api/models` | Live/cached model catalog used by the UI picker |
823
953
  | POST | `/api/models/snapshot` | (CLI/relay) Publish discovered model snapshot |
824
954
  | POST | `/api/conversation/:id/refresh-history` | Re-import an existing conversation's history through the local Copilot SDK |
825
- | GET | `/api/context/:conversationId` | Context metrics for a conversation or `sdk_session_id`. Copilot sessions are parsed from session-state events (falling back to a labeled lower-bound completion-token estimate when full legacy buckets are missing); Claude sessions are served from the breakdown their worker stored. Both return the same normalized `contextUsage` view alongside `providerType` and the runtime's own `text` dump |
955
+ | GET | `/api/context/:conversationId` | Context metrics for a conversation or `sdk_session_id`. Copilot sessions are parsed from session-state events (falling back to a labeled lower-bound completion-token estimate when full legacy buckets are missing); Claude sessions are served from the breakdown their worker stored, and so are Cursor, Grok and Claude Cloud sessions. All return the same normalized `contextUsage` view alongside `providerType` and the runtime's own `text` dump |
826
956
  | GET | `/api/context` | Same payload when a `conversationId` query is provided; otherwise returns a missing-selection response |
827
- | GET | `/api/usage` | Unified plan usage: one card per configured provider (`providers[]` with `meters`, `details`, `links`) built from the live Copilot quota, the Claude worker's latest stored snapshot, and the derived Cursor cycle totals. A provider that cannot be read yields an unavailable card rather than failing the response, so the modal always renders. Legacy top-level Copilot quota fields are spread alongside `providers` for cached clients; `?legacy=1` returns the Copilot-only snapshot on its own |
957
+ | GET | `/api/usage` | Unified plan usage: one card per configured provider (`providers[]` with `meters`, `details`, `links`) built from the live Copilot quota, the Claude worker's latest stored snapshot, and the derived Cursor cycle totals. A provider that cannot be read yields an unavailable card rather than failing the response, so the modal always renders. While Claude Cloud is on, the Claude card's windows are read live from the account, and a Claude Cloud card is added (credit meters, the relay's cloud spend, and the cost of the conversation named by `?conversationId=` when it is a cloud one); a card may carry `notes[]` (a line of text with an optional https link), and a meter `severity` and `resetKind: 'expiry'`. Legacy top-level Copilot quota fields are spread alongside `providers` for cached clients; `?legacy=1` returns the Copilot-only snapshot on its own |
828
958
  | GET | `/api/settings/cursor-allowance` | Read the manual Cursor plan allowances (`cursorModelsUsd`, `otherModelsUsd`, `resetDay`) |
829
959
  | POST | `/api/settings/cursor-allowance` | Set the Cursor pool allowances and billing reset day; `null` clears an allowance, and `resetAccounting: true` re-baselines the derived spend tracking |
830
960
  | GET | `/api/settings/claude` | Read Claude provider settings (`enabled`, `model`, `models`, `availableModels`) |
831
961
  | POST | `/api/settings/claude` | Enable/disable Claude, set the default model or the enabled model subset; triggers discovery and unstarted-conversation reconciliation |
962
+ | GET | `/api/settings/claude-cloud` | Read Claude Cloud settings: `{ enabled, defaultModel, environmentId, environments: [{ id, name }] \| null, environmentsError, account: { loggedIn, email, orgName, subscriptionType } \| null, token: { source, hasToken, expiresAt }, models }`. `token` says where the login is read from (`env`, `file`, `none`) and when it runs out, never the token. `environments` is `null` while the provider is off or the host has no login: nothing is asked of Anthropic then |
963
+ | POST | `/api/settings/claude-cloud` | `{ enabled?, defaultModel?, environmentId? }` → `{ ok: true, …the GET body }`. Enabling without a Claude login on the host is `400` with `code: 'claude_cloud_login_missing'`; an empty `environmentId` clears the stored one. Emits `claude_cloud_settings_updated` |
832
964
  | GET | `/api/settings/cursor` | Read Cursor provider settings — same shape as Claude's: `models` is the enabled subset, `availableModels` the full discovered list, plus per-model `efforts` |
833
965
  | POST | `/api/settings/cursor` | Set/remove the Cursor API key, default model, or enabled model subset; key changes reset discovered models and effort tiers |
834
966
  | GET | `/api/settings/turn-ceiling` | Read the max turn duration plus slider bounds (`minMinutes`, `maxMinutes`, `stepMinutes`, `defaultMinutes`) |
835
967
  | POST | `/api/settings/turn-ceiling` | Set the max turn duration in minutes (`0` = no limit) |
836
968
  | POST | `/api/claude-native-session` | (Claude worker) Persist the native Agent SDK session id for resume |
837
- | POST | `/api/claude-context-usage` | (Claude worker) Report the session's context-window breakdown after a turn |
838
- | POST | `/api/claude-plan-usage` | (Claude worker) Report the session's structured `/usage` data — plan rate-limit windows plus session cost totals — falling back to the stable `modelUsage`/`totalCostUsd` result fields. Stored as the latest Claude snapshot |
969
+ | POST | `/api/claude-context-usage` | (Claude and Claude Cloud workers) Report the session's context-window breakdown after a turn; `409` for a conversation of any other provider |
970
+ | POST | `/api/claude-plan-usage` | (Claude and Claude Cloud workers) Report the session's structured `/usage` data — plan rate-limit windows plus session cost totals — falling back to the stable `modelUsage`/`totalCostUsd` result fields. Stored as the latest Claude snapshot; a Claude Cloud conversation's report is stored under its own snapshot key, `claude-cloud` |
971
+ | POST | `/api/claude-cloud-session` | (Claude Cloud worker) Report the cloud session a conversation is bound to: `{ conversationId, cloudSessionId, sessionUrl?, lastSequence, pushedBranch?, costUsd?, model? }` → `{ ok, cloud }`. Sent after the session was created, after every `result` and on a push; emits `claude_cloud_session`. `404` without a runtime session, `409` for a conversation that is not a cloud one |
839
972
  | POST | `/api/cursor-plan-usage` | (Cursor worker) Report the agent's cumulative billed usage; the relay diffs it against a per-agent checkpoint and books the increase into the current billing cycle under the pool implied by the turn's model |
840
973
  | POST | `/api/subagent-run` | (Worker) Register/update a subagent run for the active turn |
841
974
  | POST | `/api/conversation/:conversationId/subagent/:subagentRunId/cancel` | Request cancellation of one subagent run (unsupported by the Claude runtime) |
@@ -853,10 +986,12 @@ Queue metrics include `parkedCount` for turns deferred behind restart/rebind gat
853
986
  | DELETE | `/api/remote-relays/:id` | Remove a paired relay here (the other side keeps its entry) |
854
987
  | POST | `/api/remote-relays/:id/check` | Probe a paired relay now |
855
988
  | POST | `/api/remote-relays/pair` | (Relay to relay) Another relay introduces itself after the user paired it there |
856
- | GET | `/api/remote-relays/summary` | (Worker) Number of paired relays; workers register `remote_relay` only when it is above zero |
857
- | POST | `/api/remote-relays/tool` | (Worker) One `remote_relay` tool call: `{ conversationId, action, args }`. The relay checks the mention unlock, the permission, the hop limit and (ask/plan mode) the user's approval, then forwards the call to the remote |
989
+ | GET | `/api/remote-relays/summary` | (Worker) `{ count, online, localEnabled }`: the number of paired relays, how many of them are online, and whether agent sessions are switched on. Workers register `remote_relay` when `count` is above zero or `localEnabled` is `true` |
990
+ | POST | `/api/remote-relays/tool` | (Worker) One `remote_relay` tool call: `{ conversationId, action, args }`. For a paired relay the relay checks the mention unlock, the permission, the hop limit and (ask/plan mode) the user's approval, then forwards the call to the remote. For this relay itself (named by its name or `this`; refused with `REMOTE_RELAY_LOCAL_DISABLED` while agent sessions are off) it checks instead that the call does not aim at the caller's own conversation (`REMOTE_RELAY_OWN_SESSION`) and, for `create_session`, that the caller was not created by an agent (`REMOTE_RELAY_NESTED_SESSION`), that fewer than 4 sessions it created have a turn queued or running (`REMOTE_RELAY_SESSION_LIMIT`) and that the user allowed it once for the conversation; the call then runs against this relay's own routes in-process. `wait_seconds` is cut to the `maxWaitSeconds` setting for every target |
858
991
  | GET | `/api/remote-relays/inflight` | (Worker) Number of `remote_relay` calls running for `conversationId` |
859
992
  | GET/POST | `/api/settings/remote-relays` | This relay's `publicUrl` and `inboundEnabled` (accept prompts from other relays' agents) |
993
+ | GET | `/api/settings/agent-sessions` | Agent sessions: `{ enabled, maxWaitSeconds, limits: { minWaitSeconds: 120, maxWaitSeconds: 3600, stepSeconds: 60 }, maxActiveSessions: 4 }`. `enabled` (default `false`) lets this relay's agents start and use sessions on it through `remote_relay`; `maxWaitSeconds` (default `600`) is the longest wait of one `remote_relay` call, for this relay and paired ones |
994
+ | POST | `/api/settings/agent-sessions` | `{ enabled?, maxWaitSeconds? }` → `{ ok: true, …the GET body }`, and the socket event `agent_sessions_settings_updated` with the GET body. `enabled` must be a boolean and `maxWaitSeconds` a whole number of seconds inside `limits` and on its step; anything else is `400` with `{ ok: false, error }` and nothing is stored |
860
995
 
861
996
  ### Manual relay shutdown / self-restart
862
997
 
@@ -1015,6 +1150,14 @@ workspace root, backed by `services/git-changes-service.mjs` and `routes/git-rou
1015
1150
  File paths passed to `/api/git/diff` go through the same `resolveWorkspaceFilePath` containment
1016
1151
  check as the file bridge, so traversal outside the workspace root is rejected.
1017
1152
 
1153
+ `GET /api/git/remote?root=…` belongs to the New Chat modal, not to this one: it reads the `origin`
1154
+ remote (else the remote of the upstream branch), the current branch, ahead/behind and whether the
1155
+ checkout is dirty, for the Repository and Branch fields of a Claude Cloud chat
1156
+ (`services/git-remote-service.mjs`). There is no conversation yet at that point, so it is the one
1157
+ git route that takes a path from the client, validated like a requested CWD
1158
+ (`validateRequestedWorkspaceRoot`). Credentials in a remote URL are dropped before it is returned,
1159
+ and a remote that is not a github.com repository comes back with `repoUrl: null`.
1160
+
1018
1161
  ### Changing the launch CWD
1019
1162
 
1020
1163
  Two endpoints set a conversation's launch directory. Both accept the same aliases —
@@ -1417,6 +1560,23 @@ and on `messages`:
1417
1560
  `0005-remote-relays.mjs` adds `origin_json` to `messages` and `conversations` (provenance of
1418
1561
  prompts and sessions another relay's agent created) and the table
1419
1562
  `conversation_remote_relay_unlocks` (the remote relays the user mentioned per conversation).
1563
+ Agent sessions reuse both without a migration of their own: a conversation an agent of this relay
1564
+ created stores an origin with `local: true` that names the conversation it was created from, and
1565
+ the user's **Allow** for a conversation's agent to start sessions here is a row of that table
1566
+ under the relay id `self` (`remote-relay-repository.mjs`).
1567
+
1568
+ `0007-claude-cloud.mjs` adds the storage of [Claude Cloud](#claude-cloud-provider) conversations.
1569
+ It is additive and idempotent, runs on every boot, and a failure is logged and retried on the next
1570
+ one:
1571
+
1572
+ - `runtime_sessions.claude_cloud_session_id` (TEXT) — the cloud session the conversation runs in,
1573
+ reported by its worker after the first message
1574
+ - `runtime_sessions.claude_cloud_last_sequence` (TEXT) — the event sequence number of the last
1575
+ finished turn's `result`: where a restarted worker reads the session's event log on from
1576
+ - `runtime_sessions.claude_cloud_cost_usd` (REAL) — what the session has cost so far, as the cloud
1577
+ reports it
1578
+ - `conversations.cloud_source_json` (TEXT/JSON) — `{ repoUrl, branch, environmentId, sessionUrl,
1579
+ pushedBranches: [{ branch, at }] }`
1420
1580
 
1421
1581
  New tables are likewise created at startup with `CREATE TABLE IF NOT EXISTS` in both the fresh-schema
1422
1582
  and migration blocks — most recently `workflow_runs` (final workflow digest per assistant message,
@@ -1449,6 +1609,13 @@ working with the corresponding feature inert rather than crashing at startup.
1449
1609
  | `data/copilot.db` | Persisted conversations, settings, and queue storage (gitignored) |
1450
1610
  | `relay-tools.md` | Markdown tool guidance loaded by the relay extension |
1451
1611
  | `claude-worker/` | Claude Agent SDK session worker (turn runner, ask-user bridge, attachments, SDK message normalizer) |
1612
+ | `claude-cloud-worker/` | Claude Cloud session worker (turn runner over the cloud session's event stream, event normalizer) |
1613
+ | `../shared/claude-cloud/` | Claude Cloud API client, the reader of the Claude CLI's login, repository/branch/model string rules |
1614
+ | `services/claude-cloud-settings-service.mjs` | Claude Cloud switch, default model and environment |
1615
+ | `services/claude-cloud-session-service.mjs` | What the relay keeps per cloud conversation: worker reports, the `cloud` payload field, archiving |
1616
+ | `services/claude-account-usage-service.mjs` | Live Claude account usage for Check Usage (only while Claude Cloud is on) |
1617
+ | `services/git-remote-service.mjs` | GitHub remote and branch of a folder, for the New Chat modal |
1618
+ | `services/remote-relay-loopback.mjs` | The `remote_relay` client for this relay itself (agent sessions): hands the dispatcher's requests to the relay's own HTTP handler in-process instead of opening a connection |
1452
1619
  | `services/claude-session-root-service.mjs` | Resolves the browsable session folder for Claude conversations |
1453
1620
  | `services/context-usage-view.mjs` | Normalizes Copilot and Claude context data into one payload |
1454
1621
  | `services/cloudflared-tunnel-service.mjs` | Supervises the `cloudflared` child process for the Cloudflare Tunnel mode |
@@ -0,0 +1,340 @@
1
+ import { capThought } from '../../shared/thought-cap.mjs';
2
+ import {
3
+ compactBoundaryActivityAction,
4
+ displayToolName,
5
+ formatApiRetryNotice,
6
+ formatToolActivityText,
7
+ isSubagentToolName,
8
+ } from '../claude-worker/sdk-message-normalizer.mjs';
9
+
10
+ const MAX_ACTIVITY_LENGTH = 140;
11
+ const REDACTED_THINKING_PLACEHOLDER = '[Reasoning redacted by the model provider]';
12
+ // The cloud sends most thinking blocks with the text left out (a signature
13
+ // only): the bubble says that the model thought rather than showing nothing.
14
+ const EMPTY_THINKING_PLACEHOLDER = '[Thinking — the cloud does not show the text]';
15
+ // Sandbox log lines worth a row: a step that begins, is skipped or fails.
16
+ // Each step also logs its completion, and lines without a step are the
17
+ // runner talking to itself (the path it launches the CLI from).
18
+ const QUIET_STEP_STATUSES = new Set(['completed']);
19
+ const LOUD_LOG_LEVELS = new Set(['warn', 'warning', 'error']);
20
+
21
+ function truncate(text, maxLength = MAX_ACTIVITY_LENGTH) {
22
+ const value = String(text || '').trim();
23
+ if (value.length <= maxLength) return value;
24
+ return `${value.slice(0, Math.max(0, maxLength - 1))}…`;
25
+ }
26
+
27
+ function finiteOrNull(value) {
28
+ if (value === null || value === undefined || value === '') return null;
29
+ const n = Number(value);
30
+ return Number.isFinite(n) ? n : null;
31
+ }
32
+
33
+ /** The sequence number of a cloud event (`sequence_num` is a string on the wire), or null. */
34
+ export function toCloudSequence(value) {
35
+ if (value === null || value === undefined || value === '') return null;
36
+ const n = Number(value);
37
+ return Number.isFinite(n) && n >= 0 ? n : null;
38
+ }
39
+
40
+ /**
41
+ * A turn the user stopped ends with an error result too
42
+ * (`error_during_execution`, `terminal_reason: "aborted_tools"`); what tells
43
+ * it from a failure is the terminal reason.
44
+ */
45
+ export function isInterruptedCloudResult(payload) {
46
+ return String(payload?.terminal_reason || '').trim().toLowerCase().startsWith('aborted');
47
+ }
48
+
49
+ /** The `result` payload of a turn in the shape the Claude worker's publisher reads. */
50
+ export function readCloudResult(payload) {
51
+ const subtype = String(payload?.subtype || '').trim();
52
+ const errors = (Array.isArray(payload?.errors) ? payload.errors : [])
53
+ .map((entry) => String(entry || '').trim())
54
+ .filter(Boolean);
55
+ const apiErrorStatus = Number(payload?.api_error_status ?? NaN);
56
+ return {
57
+ // An interrupted or failed turn carries no `result` string at all.
58
+ text: typeof payload?.result === 'string' ? payload.result.trim() : '',
59
+ isError: subtype !== 'success' || payload?.is_error === true,
60
+ subtype,
61
+ interrupted: isInterruptedCloudResult(payload),
62
+ terminalReason: String(payload?.terminal_reason || '').trim(),
63
+ assistantError: '',
64
+ apiErrorStatus: Number.isFinite(apiErrorStatus) ? apiErrorStatus : null,
65
+ errors,
66
+ modelUsage: payload?.modelUsage && typeof payload.modelUsage === 'object' ? payload.modelUsage : null,
67
+ totalCostUsd: finiteOrNull(payload?.total_cost_usd),
68
+ };
69
+ }
70
+
71
+ /**
72
+ * What `GET /v1/code/sessions/{id}` says after a turn, for the relay's usage
73
+ * routes: the context occupancy in the shape `/api/claude-context-usage`
74
+ * stores, the session's cost so far, and the session's page on claude.ai.
75
+ * Accepts the bare `response_shape` or the body that wraps it.
76
+ */
77
+ export function readCloudSessionUsage(sessionBody, { model = '' } = {}) {
78
+ const shape = sessionBody?.response_shape || sessionBody?.raw?.response_shape || sessionBody || {};
79
+ const metadata = shape?.external_metadata && typeof shape.external_metadata === 'object'
80
+ ? shape.external_metadata
81
+ : {};
82
+ const usage = metadata.usage && typeof metadata.usage === 'object' ? metadata.usage : {};
83
+ const usedTokens = finiteOrNull(metadata.context_usage?.used_tokens);
84
+ const maxTokens = finiteOrNull(metadata.context_usage?.max_tokens);
85
+ const contextUsage = usedTokens === null && maxTokens === null
86
+ ? null
87
+ : {
88
+ model: String(metadata.last_served_model || model || '').trim() || null,
89
+ totalTokens: usedTokens,
90
+ maxTokens,
91
+ percentage: usedTokens !== null && maxTokens ? Math.round((usedTokens / maxTokens) * 10000) / 100 : null,
92
+ categories: [],
93
+ apiUsage: {
94
+ input_tokens: finiteOrNull(usage.input_tokens),
95
+ output_tokens: finiteOrNull(usage.output_tokens),
96
+ cache_read_input_tokens: finiteOrNull(usage.cache_read_tokens),
97
+ cache_creation_input_tokens: finiteOrNull(usage.cache_write_tokens),
98
+ },
99
+ };
100
+ return {
101
+ contextUsage,
102
+ costUsd: finiteOrNull(usage.cost_usd),
103
+ sessionUrl: String(shape?.session_url || sessionBody?.sessionUrl || '').trim() || null,
104
+ workerStatus: String(shape?.worker_status || '').trim().toLowerCase() || null,
105
+ };
106
+ }
107
+
108
+ /**
109
+ * Stateful normalizer mapping the events of one cloud turn onto relay channel
110
+ * actions. One instance per turn. The event `payload` is the plain SDK
111
+ * message, delivered one complete content block per `assistant` event and
112
+ * without partial frames.
113
+ *
114
+ * `normalize(event, { sequence })` takes the event envelope
115
+ * (`{ event_type, source, sequence_num, payload }`) and returns
116
+ * `{ channel, payload }` actions:
117
+ * - `init` → `{ model }`
118
+ * - `stream` → `{ text, done:false }`: every text block of the turn so far,
119
+ * blank line between blocks
120
+ * - `thought` → `{ reasoningId, text, done:true }`
121
+ * - `activity` → `{ text, subagentRunId? }`
122
+ * - `subagent` → `{ subagentRunId, parentSubagentId, displayName, status }`
123
+ * - `push` → `{ branch }` (the agent pushed a branch)
124
+ * - `permission` → `{ requestId, toolName, displayName, input, toolUseId }`
125
+ * (the agent waits for the user: a question or a permission prompt)
126
+ * - `permission_settled` → `{ requestId }` (some client answered it)
127
+ *
128
+ * The turn's `result` is not an action: the runner pairs it with the turn by
129
+ * sequence number and reads it with `readCloudResult`.
130
+ */
131
+ export function createClaudeCloudEventNormalizer() {
132
+ const textBlocks = [];
133
+ const knownSubagentRuns = new Map(); // toolUseId -> { displayName, parentSubagentId }
134
+ let model = '';
135
+ let lastSandboxLine = '';
136
+ let lastRateLimitStatus = 'allowed';
137
+
138
+ function streamText() {
139
+ return textBlocks.join('\n\n');
140
+ }
141
+
142
+ function sandboxLogActions(payload) {
143
+ const data = payload?.data && typeof payload.data === 'object' ? payload.data : {};
144
+ const content = String(data.content || '').trim();
145
+ if (!content) return [];
146
+ const stepStatus = String(data.extra?.step_status || '').trim().toLowerCase();
147
+ const loud = LOUD_LOG_LEVELS.has(String(data.level || '').trim().toLowerCase());
148
+ if (!loud && (!stepStatus || QUIET_STEP_STATUSES.has(stepStatus))) return [];
149
+ const text = truncate(`Cloud: ${content}`);
150
+ if (text === lastSandboxLine) return [];
151
+ lastSandboxLine = text;
152
+ return [{ channel: 'activity', payload: { text, subagentRunId: null } }];
153
+ }
154
+
155
+ function assistantActions(payload, sequence) {
156
+ const actions = [];
157
+ const parentToolUseId = String(payload?.parent_tool_use_id || '').trim() || null;
158
+ const content = Array.isArray(payload?.message?.content) ? payload.message.content : [];
159
+ for (const [index, block] of content.entries()) {
160
+ const blockType = String(block?.type || '');
161
+ if (blockType === 'text') {
162
+ // Only the main thread's prose is the reply; a subagent's text is its
163
+ // own report to the agent that started it.
164
+ const text = String(block?.text || '').trim();
165
+ if (parentToolUseId || !text) continue;
166
+ textBlocks.push(text);
167
+ actions.push({ channel: 'stream', payload: { text: streamText(), done: false, subagentRunId: null } });
168
+ continue;
169
+ }
170
+ if (blockType === 'thinking' || blockType === 'redacted_thinking') {
171
+ const text = blockType === 'redacted_thinking'
172
+ ? (String(block?.data || '').trim() ? REDACTED_THINKING_PLACEHOLDER : '')
173
+ : (capThought(block?.thinking || '').trim() || (String(block?.signature || '').trim() ? EMPTY_THINKING_PLACEHOLDER : ''));
174
+ if (!text.trim()) continue;
175
+ actions.push({
176
+ channel: 'thought',
177
+ payload: {
178
+ // Keyed by the event's sequence number: a replayed event (stream
179
+ // reconnect, catch-up after a restart) updates its thought in
180
+ // place instead of adding a second one.
181
+ reasoningId: `claude-cloud-thought-${sequence ?? 'x'}-${index}`,
182
+ text,
183
+ done: true,
184
+ subagentRunId: parentToolUseId,
185
+ },
186
+ });
187
+ continue;
188
+ }
189
+ if (blockType === 'tool_use') {
190
+ const toolName = displayToolName(block?.name) || 'unknown';
191
+ const toolUseId = String(block?.id || '').trim();
192
+ const input = block?.input && typeof block.input === 'object' ? block.input : {};
193
+ if (isSubagentToolName(toolName) && toolUseId) {
194
+ const displayName = String(input.description || input.name || input.subagent_type || 'Subagent').trim()
195
+ || 'Subagent';
196
+ knownSubagentRuns.set(toolUseId, { displayName, parentSubagentId: parentToolUseId });
197
+ actions.push({
198
+ channel: 'subagent',
199
+ payload: { subagentRunId: toolUseId, parentSubagentId: parentToolUseId, displayName, status: 'running' },
200
+ });
201
+ }
202
+ actions.push({
203
+ channel: 'activity',
204
+ payload: { text: formatToolActivityText(toolName, input), subagentRunId: parentToolUseId },
205
+ });
206
+ }
207
+ }
208
+ return actions;
209
+ }
210
+
211
+ function toolResultActions(payload) {
212
+ const actions = [];
213
+ const parentToolUseId = String(payload?.parent_tool_use_id || '').trim() || null;
214
+ const content = Array.isArray(payload?.message?.content) ? payload.message.content : [];
215
+ for (const block of content) {
216
+ if (block?.type !== 'tool_result') continue;
217
+ const toolUseId = String(block?.tool_use_id || '').trim();
218
+ const isError = block?.is_error === true;
219
+ const run = toolUseId ? knownSubagentRuns.get(toolUseId) : null;
220
+ if (run) {
221
+ actions.push({
222
+ channel: 'subagent',
223
+ payload: {
224
+ subagentRunId: toolUseId,
225
+ parentSubagentId: run.parentSubagentId,
226
+ displayName: run.displayName,
227
+ status: isError ? 'failed' : 'completed',
228
+ },
229
+ });
230
+ }
231
+ if (isError) {
232
+ const errorText = typeof block?.content === 'string'
233
+ ? block.content
234
+ : (Array.isArray(block?.content) ? block.content.map((entry) => String(entry?.text || '')).join(' ') : '');
235
+ actions.push({
236
+ channel: 'activity',
237
+ payload: { text: truncate(`Tool failed: ${errorText || 'unknown error'}`), subagentRunId: parentToolUseId },
238
+ });
239
+ }
240
+ }
241
+ return actions;
242
+ }
243
+
244
+ function systemActions(payload) {
245
+ const subtype = String(payload?.subtype || '');
246
+ if (subtype === 'init') {
247
+ // Every turn of a cloud session opens with its own init.
248
+ model = String(payload?.model || '').trim() || model;
249
+ return [
250
+ { channel: 'init', payload: { model } },
251
+ {
252
+ channel: 'activity',
253
+ payload: { text: `Claude Code running in the cloud${model ? ` (model ${model})` : ''}`, subagentRunId: null },
254
+ },
255
+ ];
256
+ }
257
+ if (subtype === 'vcs_state_changed') {
258
+ const branch = String(payload?.branch || '').trim();
259
+ if (String(payload?.kind || '').trim().toLowerCase() !== 'push' || !branch) return [];
260
+ return [
261
+ { channel: 'activity', payload: { text: truncate(`Cloud: pushed branch ${branch}`), subagentRunId: null } },
262
+ { channel: 'push', payload: { branch } },
263
+ ];
264
+ }
265
+ if (subtype === 'task_notification') {
266
+ const taskId = String(payload?.task_id || '').trim() || 'unknown';
267
+ const status = String(payload?.status || '').trim() || 'unknown';
268
+ return [{
269
+ channel: 'activity',
270
+ payload: {
271
+ text: truncate(`Background task ${taskId} ${status}: ${String(payload?.summary || '').trim()}`),
272
+ subagentRunId: null,
273
+ },
274
+ }];
275
+ }
276
+ if (subtype === 'api_retry') {
277
+ return [{ channel: 'activity', payload: { text: formatApiRetryNotice(payload), subagentRunId: null } }];
278
+ }
279
+ if (subtype === 'compact_boundary') return [compactBoundaryActivityAction(payload)];
280
+ return [];
281
+ }
282
+
283
+ function rateLimitActions(payload) {
284
+ const info = payload?.rate_limit_info && typeof payload.rate_limit_info === 'object' ? payload.rate_limit_info : {};
285
+ const status = String(info.status || '').trim().toLowerCase() || 'allowed';
286
+ if (status === lastRateLimitStatus) return [];
287
+ lastRateLimitStatus = status;
288
+ if (status === 'allowed') return [];
289
+ const resetsAt = Number(info.resetsAt);
290
+ const reset = Number.isFinite(resetsAt) && resetsAt > 0
291
+ ? `, resets ${new Date(resetsAt * 1000).toISOString().replace(/\.\d{3}Z$/, 'Z')}`
292
+ : '';
293
+ const text = status === 'rejected'
294
+ ? `Claude usage limit reached${reset}`
295
+ : `Claude usage limit warning (${status.replace(/_/g, ' ')}${reset})`;
296
+ return [{ channel: 'activity', payload: { text: truncate(text), subagentRunId: null } }];
297
+ }
298
+
299
+ function controlActions(event, payload) {
300
+ if (payload.type === 'control_response') {
301
+ const requestId = String(payload?.response?.request_id || '').trim();
302
+ return requestId ? [{ channel: 'permission_settled', payload: { requestId } }] : [];
303
+ }
304
+ // The clients' own requests (set_permission_mode, interrupt) come back on
305
+ // the stream as well; only the agent's requests ask the user something.
306
+ if (String(event?.source || '').trim().toLowerCase() === 'client') return [];
307
+ const request = payload?.request && typeof payload.request === 'object' ? payload.request : {};
308
+ const requestId = String(payload?.request_id || '').trim();
309
+ if (request.subtype !== 'can_use_tool' || !requestId) return [];
310
+ return [{
311
+ channel: 'permission',
312
+ payload: {
313
+ requestId,
314
+ toolName: String(request.tool_name || '').trim(),
315
+ displayName: String(request.display_name || request.tool_name || '').trim(),
316
+ input: request.input && typeof request.input === 'object' ? request.input : {},
317
+ toolUseId: String(request.tool_use_id || '').trim() || null,
318
+ },
319
+ }];
320
+ }
321
+
322
+ function normalize(event, { sequence = toCloudSequence(event?.sequence_num) } = {}) {
323
+ const payload = event?.payload;
324
+ if (!payload || typeof payload !== 'object') return [];
325
+ const type = String(payload.type || event.event_type || '');
326
+ if (type === 'env_manager_log') return sandboxLogActions(payload);
327
+ if (type === 'system') return systemActions(payload);
328
+ if (type === 'assistant') return assistantActions(payload, sequence);
329
+ if (type === 'user') return toolResultActions(payload);
330
+ if (type === 'rate_limit_event') return rateLimitActions(payload);
331
+ if (type === 'control_request' || type === 'control_response') return controlActions(event, { ...payload, type });
332
+ return [];
333
+ }
334
+
335
+ return {
336
+ normalize,
337
+ streamText,
338
+ get model() { return model; },
339
+ };
340
+ }