Commit ·
064bfd6
0
Parent(s):
init: verspercode v0.1.0
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitattributes +9 -0
- .gitignore +4 -0
- FEATURES.md +317 -0
- README.md +236 -0
- assets/screenshot.png +3 -0
- bun.lock +0 -0
- claude.sh +6 -0
- env.d.ts +14 -0
- install.sh +182 -0
- package.json +121 -0
- scripts/build.ts +205 -0
- src/QueryEngine.ts +1295 -0
- src/Task.ts +125 -0
- src/Tool.ts +792 -0
- src/assistant/AssistantSessionChooser.tsx +15 -0
- src/assistant/sessionHistory.ts +87 -0
- src/bootstrap/state.ts +1758 -0
- src/bridge/bridgeApi.ts +539 -0
- src/bridge/bridgeConfig.ts +48 -0
- src/bridge/bridgeDebug.ts +135 -0
- src/bridge/bridgeEnabled.ts +202 -0
- src/bridge/bridgeMain.ts +0 -0
- src/bridge/bridgeMessaging.ts +461 -0
- src/bridge/bridgePermissionCallbacks.ts +43 -0
- src/bridge/bridgePointer.ts +210 -0
- src/bridge/bridgeStatusUtil.ts +163 -0
- src/bridge/bridgeUI.ts +530 -0
- src/bridge/capacityWake.ts +56 -0
- src/bridge/codeSessionApi.ts +168 -0
- src/bridge/createSession.ts +384 -0
- src/bridge/debugUtils.ts +141 -0
- src/bridge/envLessBridgeConfig.ts +165 -0
- src/bridge/flushGate.ts +71 -0
- src/bridge/inboundAttachments.ts +175 -0
- src/bridge/inboundMessages.ts +80 -0
- src/bridge/initReplBridge.ts +569 -0
- src/bridge/jwtUtils.ts +256 -0
- src/bridge/pollConfig.ts +110 -0
- src/bridge/pollConfigDefaults.ts +82 -0
- src/bridge/remoteBridgeCore.ts +1008 -0
- src/bridge/replBridge.ts +0 -0
- src/bridge/replBridgeHandle.ts +36 -0
- src/bridge/replBridgeTransport.ts +370 -0
- src/bridge/sessionIdCompat.ts +57 -0
- src/bridge/sessionRunner.ts +550 -0
- src/bridge/trustedDevice.ts +210 -0
- src/bridge/types.ts +262 -0
- src/bridge/workSecret.ts +127 -0
- src/buddy/CompanionSprite.tsx +371 -0
- src/buddy/companion.ts +133 -0
.gitattributes
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
*.png filter=lfs diff=lfs merge=lfs -text
|
| 2 |
+
*.jpg filter=lfs diff=lfs merge=lfs -text
|
| 3 |
+
*.fbx filter=lfs diff=lfs merge=lfs -text
|
| 4 |
+
*.vrma filter=lfs diff=lfs merge=lfs -text
|
| 5 |
+
*.vmd filter=lfs diff=lfs merge=lfs -text
|
| 6 |
+
*.woff2 filter=lfs diff=lfs merge=lfs -text
|
| 7 |
+
*.icns filter=lfs diff=lfs merge=lfs -text
|
| 8 |
+
assets/subagent/* filter=lfs diff=lfs merge=lfs -text
|
| 9 |
+
*.pdf filter=lfs diff=lfs merge=lfs -text
|
.gitignore
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
node_modules/
|
| 2 |
+
dist/
|
| 3 |
+
cli
|
| 4 |
+
cli-dev
|
FEATURES.md
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Feature Flags Audit
|
| 2 |
+
|
| 3 |
+
Audit date: 2026-03-31
|
| 4 |
+
|
| 5 |
+
This repository currently references 88 `feature('FLAG')` compile-time flags.
|
| 6 |
+
I re-checked them by bundling the CLI once per flag on top of the current
|
| 7 |
+
external-build defines and externals. Result:
|
| 8 |
+
|
| 9 |
+
- 54 flags bundle cleanly in this snapshot
|
| 10 |
+
- 34 flags still fail to bundle
|
| 11 |
+
|
| 12 |
+
Important: "bundle cleanly" does not always mean "runtime-safe". Some flags
|
| 13 |
+
still depend on optional native modules, claude.ai OAuth, GrowthBook gates, or
|
| 14 |
+
externalized `@ant/*` packages.
|
| 15 |
+
|
| 16 |
+
## Build Variants
|
| 17 |
+
|
| 18 |
+
- `bun run build`
|
| 19 |
+
Builds the regular external binary at `./cli`.
|
| 20 |
+
- `bun run compile`
|
| 21 |
+
Builds the regular external binary at `./dist/cli`.
|
| 22 |
+
- `bun run build:dev`
|
| 23 |
+
Builds `./cli-dev` with a dev-stamped version and experimental GrowthBook key.
|
| 24 |
+
- `bun run build:dev:full`
|
| 25 |
+
Builds `./cli-dev` with the entire current "Working Experimental Features"
|
| 26 |
+
bundle from this document, minus `CHICAGO_MCP`. That flag still compiles,
|
| 27 |
+
but the external binary does not boot cleanly with it because startup
|
| 28 |
+
reaches the missing `@ant/computer-use-mcp` runtime package.
|
| 29 |
+
|
| 30 |
+
## Default Build Flags
|
| 31 |
+
|
| 32 |
+
- `VOICE_MODE`
|
| 33 |
+
This is now included in the default build pipeline, not just the dev build.
|
| 34 |
+
It enables `/voice`, push-to-talk UI, voice notices, and dictation plumbing.
|
| 35 |
+
Runtime still depends on claude.ai OAuth plus either the native audio module
|
| 36 |
+
or a fallback recorder such as SoX.
|
| 37 |
+
|
| 38 |
+
## Working Experimental Features
|
| 39 |
+
|
| 40 |
+
These are the user-facing or behavior-changing flags that currently bundle
|
| 41 |
+
cleanly and should still be treated as experimental in this snapshot unless
|
| 42 |
+
explicitly called out as default-on.
|
| 43 |
+
|
| 44 |
+
### Interaction and UI Experiments
|
| 45 |
+
|
| 46 |
+
- `AWAY_SUMMARY`
|
| 47 |
+
Adds away-from-keyboard summary behavior in the REPL.
|
| 48 |
+
- `HISTORY_PICKER`
|
| 49 |
+
Enables the interactive prompt history picker.
|
| 50 |
+
- `HOOK_PROMPTS`
|
| 51 |
+
Passes the prompt/request text into hook execution flows.
|
| 52 |
+
- `KAIROS_BRIEF`
|
| 53 |
+
Enables brief-only transcript layout and BriefTool-oriented UX without the
|
| 54 |
+
full assistant stack.
|
| 55 |
+
- `KAIROS_CHANNELS`
|
| 56 |
+
Enables channel notices and channel callback plumbing around MCP/channel
|
| 57 |
+
messaging.
|
| 58 |
+
- `LODESTONE`
|
| 59 |
+
Enables deep-link / protocol-registration related flows and settings wiring.
|
| 60 |
+
- `MESSAGE_ACTIONS`
|
| 61 |
+
Enables message action entrypoints in the interactive UI.
|
| 62 |
+
- `NEW_INIT`
|
| 63 |
+
Enables the newer `/init` decision path.
|
| 64 |
+
- `QUICK_SEARCH`
|
| 65 |
+
Enables prompt quick-search behavior.
|
| 66 |
+
- `SHOT_STATS`
|
| 67 |
+
Enables additional shot-distribution stats views.
|
| 68 |
+
- `TOKEN_BUDGET`
|
| 69 |
+
Enables token budget tracking, prompt triggers, and token warning UI.
|
| 70 |
+
- `ULTRAPLAN`
|
| 71 |
+
Enables `/ultraplan`, prompt triggers, and exit-plan affordances.
|
| 72 |
+
- `ULTRATHINK`
|
| 73 |
+
Enables the extra thinking-depth mode switch.
|
| 74 |
+
- `VOICE_MODE`
|
| 75 |
+
Enables voice toggling, dictation keybindings, voice notices, and voice UI.
|
| 76 |
+
|
| 77 |
+
### Agent, Memory, and Planning Experiments
|
| 78 |
+
|
| 79 |
+
- `AGENT_MEMORY_SNAPSHOT`
|
| 80 |
+
Stores extra custom-agent memory snapshot state in the app.
|
| 81 |
+
- `AGENT_TRIGGERS`
|
| 82 |
+
Enables local cron/trigger tools and bundled trigger-related skills.
|
| 83 |
+
- `AGENT_TRIGGERS_REMOTE`
|
| 84 |
+
Enables the remote trigger tool path.
|
| 85 |
+
- `BUILTIN_EXPLORE_PLAN_AGENTS`
|
| 86 |
+
Enables built-in explore/plan agent presets.
|
| 87 |
+
- `CACHED_MICROCOMPACT`
|
| 88 |
+
Enables cached microcompact state through query and API flows.
|
| 89 |
+
- `COMPACTION_REMINDERS`
|
| 90 |
+
Enables reminder copy around compaction and attachment flows.
|
| 91 |
+
- `EXTRACT_MEMORIES`
|
| 92 |
+
Enables post-query memory extraction hooks.
|
| 93 |
+
- `PROMPT_CACHE_BREAK_DETECTION`
|
| 94 |
+
Enables cache-break detection around compaction/query/API flow.
|
| 95 |
+
- `TEAMMEM`
|
| 96 |
+
Enables team-memory files, watcher hooks, and related UI messages.
|
| 97 |
+
- `VERIFICATION_AGENT`
|
| 98 |
+
Enables verification-agent guidance in prompts and task/todo tooling.
|
| 99 |
+
|
| 100 |
+
### Tools, Permissions, and Remote Experiments
|
| 101 |
+
|
| 102 |
+
- `BASH_CLASSIFIER`
|
| 103 |
+
Enables classifier-assisted bash permission decisions.
|
| 104 |
+
- `BRIDGE_MODE`
|
| 105 |
+
Enables Remote Control / REPL bridge command and entitlement paths.
|
| 106 |
+
- `CCR_AUTO_CONNECT`
|
| 107 |
+
Enables the CCR auto-connect default path.
|
| 108 |
+
- `CCR_MIRROR`
|
| 109 |
+
Enables outbound-only CCR mirror sessions.
|
| 110 |
+
- `CCR_REMOTE_SETUP`
|
| 111 |
+
Enables the remote setup command path.
|
| 112 |
+
- `CHICAGO_MCP`
|
| 113 |
+
Enables computer-use MCP integration paths and wrapper loading.
|
| 114 |
+
- `CONNECTOR_TEXT`
|
| 115 |
+
Enables connector-text block handling in API/logging/UI paths.
|
| 116 |
+
- `MCP_RICH_OUTPUT`
|
| 117 |
+
Enables richer MCP UI rendering.
|
| 118 |
+
- `NATIVE_CLIPBOARD_IMAGE`
|
| 119 |
+
Enables the native macOS clipboard image fast path.
|
| 120 |
+
- `POWERSHELL_AUTO_MODE`
|
| 121 |
+
Enables PowerShell-specific auto-mode permission handling.
|
| 122 |
+
- `TREE_SITTER_BASH`
|
| 123 |
+
Enables the tree-sitter bash parser backend.
|
| 124 |
+
- `TREE_SITTER_BASH_SHADOW`
|
| 125 |
+
Enables the tree-sitter bash shadow rollout path.
|
| 126 |
+
- `UNATTENDED_RETRY`
|
| 127 |
+
Enables unattended retry behavior in API retry flows.
|
| 128 |
+
|
| 129 |
+
## Bundle-Clean Support Flags
|
| 130 |
+
|
| 131 |
+
These also bundle cleanly, but they are mostly rollout, platform, telemetry,
|
| 132 |
+
or plumbing toggles rather than user-facing experimental features.
|
| 133 |
+
|
| 134 |
+
- `ABLATION_BASELINE`
|
| 135 |
+
CLI ablation/baseline entrypoint toggle.
|
| 136 |
+
- `ALLOW_TEST_VERSIONS`
|
| 137 |
+
Allows test versions in native installer flows.
|
| 138 |
+
- `ANTI_DISTILLATION_CC`
|
| 139 |
+
Adds anti-distillation request metadata.
|
| 140 |
+
- `BREAK_CACHE_COMMAND`
|
| 141 |
+
Injects the break-cache command path.
|
| 142 |
+
- `COWORKER_TYPE_TELEMETRY`
|
| 143 |
+
Adds coworker-type telemetry fields.
|
| 144 |
+
- `DOWNLOAD_USER_SETTINGS`
|
| 145 |
+
Enables settings-sync pull paths.
|
| 146 |
+
- `DUMP_SYSTEM_PROMPT`
|
| 147 |
+
Enables the system-prompt dump path.
|
| 148 |
+
- `FILE_PERSISTENCE`
|
| 149 |
+
Enables file persistence plumbing.
|
| 150 |
+
- `HARD_FAIL`
|
| 151 |
+
Enables stricter failure/logging behavior.
|
| 152 |
+
- `IS_LIBC_GLIBC`
|
| 153 |
+
Forces glibc environment detection.
|
| 154 |
+
- `IS_LIBC_MUSL`
|
| 155 |
+
Forces musl environment detection.
|
| 156 |
+
- `NATIVE_CLIENT_ATTESTATION`
|
| 157 |
+
Adds native attestation marker text in the system header.
|
| 158 |
+
- `PERFETTO_TRACING`
|
| 159 |
+
Enables perfetto tracing hooks.
|
| 160 |
+
- `SKILL_IMPROVEMENT`
|
| 161 |
+
Enables skill-improvement hooks.
|
| 162 |
+
- `SKIP_DETECTION_WHEN_AUTOUPDATES_DISABLED`
|
| 163 |
+
Skips updater detection when auto-updates are disabled.
|
| 164 |
+
- `SLOW_OPERATION_LOGGING`
|
| 165 |
+
Enables slow-operation logging.
|
| 166 |
+
- `UPLOAD_USER_SETTINGS`
|
| 167 |
+
Enables settings-sync push paths.
|
| 168 |
+
|
| 169 |
+
## Compile-Safe But Runtime-Caveated
|
| 170 |
+
|
| 171 |
+
These bundle today, but I would still treat them as experimental because they
|
| 172 |
+
have meaningful runtime caveats:
|
| 173 |
+
|
| 174 |
+
- `VOICE_MODE`
|
| 175 |
+
Bundles cleanly, but requires claude.ai OAuth and a local recording backend.
|
| 176 |
+
The native audio module is optional now; on this machine the fallback path
|
| 177 |
+
asks for `brew install sox`.
|
| 178 |
+
- `NATIVE_CLIPBOARD_IMAGE`
|
| 179 |
+
Bundles cleanly, but only accelerates macOS clipboard reads when
|
| 180 |
+
`image-processor-napi` is present.
|
| 181 |
+
- `BRIDGE_MODE`, `CCR_AUTO_CONNECT`, `CCR_MIRROR`, `CCR_REMOTE_SETUP`
|
| 182 |
+
Bundle cleanly, but are gated at runtime on claude.ai OAuth plus GrowthBook
|
| 183 |
+
entitlement checks.
|
| 184 |
+
- `KAIROS_BRIEF`, `KAIROS_CHANNELS`
|
| 185 |
+
Bundle cleanly, but they do not restore the full missing assistant stack.
|
| 186 |
+
They only expose the brief/channel-specific surfaces that still exist.
|
| 187 |
+
- `CHICAGO_MCP`
|
| 188 |
+
Bundles cleanly, but the runtime path still reaches externalized
|
| 189 |
+
`@ant/computer-use-*` packages. This is compile-safe, not fully
|
| 190 |
+
runtime-safe, in the external snapshot.
|
| 191 |
+
- `TEAMMEM`
|
| 192 |
+
Bundles cleanly, but only does useful work when team-memory config/files are
|
| 193 |
+
actually enabled in the environment.
|
| 194 |
+
|
| 195 |
+
## Broken Flags With Easy Reconstruction Paths
|
| 196 |
+
|
| 197 |
+
These are the failed flags where the current blocker looks small enough that a
|
| 198 |
+
focused reconstruction pass could probably restore them without rebuilding an
|
| 199 |
+
entire subsystem.
|
| 200 |
+
|
| 201 |
+
- `AUTO_THEME`
|
| 202 |
+
Fails on missing `src/utils/systemThemeWatcher.js`. `systemTheme.ts` and the
|
| 203 |
+
theme provider already contain the cache/parsing logic, so the missing piece
|
| 204 |
+
looks like the OSC 11 watcher only.
|
| 205 |
+
- `BG_SESSIONS`
|
| 206 |
+
Fails on missing `src/cli/bg.js`. The CLI fast-path dispatch in
|
| 207 |
+
`src/entrypoints/cli.tsx` is already wired.
|
| 208 |
+
- `BUDDY`
|
| 209 |
+
Fails on missing `src/commands/buddy/index.js`. The buddy UI components and
|
| 210 |
+
prompt-input hooks already exist.
|
| 211 |
+
- `BUILDING_CLAUDE_APPS`
|
| 212 |
+
Fails on missing `src/claude-api/csharp/claude-api.md`. This looks like an
|
| 213 |
+
asset/document gap, not a missing runtime subsystem.
|
| 214 |
+
- `COMMIT_ATTRIBUTION`
|
| 215 |
+
Fails on missing `src/utils/attributionHooks.js`. Setup and cache-clear code
|
| 216 |
+
already call into that hook module.
|
| 217 |
+
- `FORK_SUBAGENT`
|
| 218 |
+
Fails on missing `src/commands/fork/index.js`. Command slot and message
|
| 219 |
+
rendering support are already present.
|
| 220 |
+
- `HISTORY_SNIP`
|
| 221 |
+
Fails on missing `src/commands/force-snip.js`. The surrounding SnipTool and
|
| 222 |
+
query/message comments are already there.
|
| 223 |
+
- `KAIROS_GITHUB_WEBHOOKS`
|
| 224 |
+
Fails on missing `src/tools/SubscribePRTool/SubscribePRTool.js`. The command
|
| 225 |
+
slot and some message handling already exist.
|
| 226 |
+
- `KAIROS_PUSH_NOTIFICATION`
|
| 227 |
+
Fails on missing `src/tools/PushNotificationTool/PushNotificationTool.js`.
|
| 228 |
+
The tool slot already exists in `src/tools.ts`.
|
| 229 |
+
- `MCP_SKILLS`
|
| 230 |
+
Fails on missing `src/skills/mcpSkills.js`. `mcpSkillBuilders.ts` already
|
| 231 |
+
exists specifically to support that missing registry layer.
|
| 232 |
+
- `MEMORY_SHAPE_TELEMETRY`
|
| 233 |
+
Fails on missing `src/memdir/memoryShapeTelemetry.js`. The hook call sites
|
| 234 |
+
are already in place in `sessionFileAccessHooks.ts`.
|
| 235 |
+
- `OVERFLOW_TEST_TOOL`
|
| 236 |
+
Fails on missing `src/tools/OverflowTestTool/OverflowTestTool.js`. This
|
| 237 |
+
appears isolated and test-only.
|
| 238 |
+
- `RUN_SKILL_GENERATOR`
|
| 239 |
+
Fails on missing `src/runSkillGenerator.js`. The bundled skill registration
|
| 240 |
+
path already expects it.
|
| 241 |
+
- `TEMPLATES`
|
| 242 |
+
Fails on missing `src/cli/handlers/templateJobs.js`. The CLI fast-path is
|
| 243 |
+
already wired in `src/entrypoints/cli.tsx`.
|
| 244 |
+
- `TORCH`
|
| 245 |
+
Fails on missing `src/commands/torch.js`. This looks like a single command
|
| 246 |
+
entry gap.
|
| 247 |
+
- `TRANSCRIPT_CLASSIFIER`
|
| 248 |
+
The first hard failure is missing
|
| 249 |
+
`src/utils/permissions/yolo-classifier-prompts/auto_mode_system_prompt.txt`.
|
| 250 |
+
The classifier engine, parser, and settings plumbing already exist, so the
|
| 251 |
+
missing prompt/assets are likely the first reconstruction target.
|
| 252 |
+
|
| 253 |
+
## Broken Flags With Partial Wiring But Medium-Sized Gaps
|
| 254 |
+
|
| 255 |
+
These do have meaningful surrounding code, but the missing piece is larger
|
| 256 |
+
than a single wrapper or asset.
|
| 257 |
+
|
| 258 |
+
- `BYOC_ENVIRONMENT_RUNNER`
|
| 259 |
+
Missing `src/environment-runner/main.js`.
|
| 260 |
+
- `CONTEXT_COLLAPSE`
|
| 261 |
+
Missing `src/tools/CtxInspectTool/CtxInspectTool.js`.
|
| 262 |
+
- `COORDINATOR_MODE`
|
| 263 |
+
Missing `src/coordinator/workerAgent.js`.
|
| 264 |
+
- `DAEMON`
|
| 265 |
+
Missing `src/daemon/workerRegistry.js`.
|
| 266 |
+
- `DIRECT_CONNECT`
|
| 267 |
+
Missing `src/server/parseConnectUrl.js`.
|
| 268 |
+
- `EXPERIMENTAL_SKILL_SEARCH`
|
| 269 |
+
Missing `src/services/skillSearch/localSearch.js`.
|
| 270 |
+
- `MONITOR_TOOL`
|
| 271 |
+
Missing `src/tools/MonitorTool/MonitorTool.js`.
|
| 272 |
+
- `REACTIVE_COMPACT`
|
| 273 |
+
Missing `src/services/compact/reactiveCompact.js`.
|
| 274 |
+
- `REVIEW_ARTIFACT`
|
| 275 |
+
Missing `src/hunter.js`.
|
| 276 |
+
- `SELF_HOSTED_RUNNER`
|
| 277 |
+
Missing `src/self-hosted-runner/main.js`.
|
| 278 |
+
- `SSH_REMOTE`
|
| 279 |
+
Missing `src/ssh/createSSHSession.js`.
|
| 280 |
+
- `TERMINAL_PANEL`
|
| 281 |
+
Missing `src/tools/TerminalCaptureTool/TerminalCaptureTool.js`.
|
| 282 |
+
- `UDS_INBOX`
|
| 283 |
+
Missing `src/utils/udsMessaging.js`.
|
| 284 |
+
- `WEB_BROWSER_TOOL`
|
| 285 |
+
Missing `src/tools/WebBrowserTool/WebBrowserTool.js`.
|
| 286 |
+
- `WORKFLOW_SCRIPTS`
|
| 287 |
+
Fails first on `src/commands/workflows/index.js`, but there are more gaps:
|
| 288 |
+
`tasks.ts` already expects `LocalWorkflowTask`, and `tools.ts` expects a
|
| 289 |
+
real `WorkflowTool` implementation while only `WorkflowTool/constants.ts`
|
| 290 |
+
exists in this snapshot.
|
| 291 |
+
|
| 292 |
+
## Broken Flags With Large Missing Subsystems
|
| 293 |
+
|
| 294 |
+
These are the ones that still look expensive to restore because the first
|
| 295 |
+
missing import is only the visible edge of a broader absent subsystem.
|
| 296 |
+
|
| 297 |
+
- `KAIROS`
|
| 298 |
+
Missing `src/assistant/index.js` and much of the assistant stack with it.
|
| 299 |
+
- `KAIROS_DREAM`
|
| 300 |
+
Missing `src/dream.js` and related dream-task behavior.
|
| 301 |
+
- `PROACTIVE`
|
| 302 |
+
Missing `src/proactive/index.js` and the proactive task/tool stack.
|
| 303 |
+
|
| 304 |
+
## Useful Entry Points
|
| 305 |
+
|
| 306 |
+
- Feature-aware build logic:
|
| 307 |
+
[scripts/build.ts](/Users/paolo/Repos/claude-code/scripts/build.ts)
|
| 308 |
+
- Feature-gated command imports:
|
| 309 |
+
[src/commands.ts](/Users/paolo/Repos/claude-code/src/commands.ts)
|
| 310 |
+
- Feature-gated tool imports:
|
| 311 |
+
[src/tools.ts](/Users/paolo/Repos/claude-code/src/tools.ts)
|
| 312 |
+
- Feature-gated task imports:
|
| 313 |
+
[src/tasks.ts](/Users/paolo/Repos/claude-code/src/tasks.ts)
|
| 314 |
+
- Feature-gated query behavior:
|
| 315 |
+
[src/query.ts](/Users/paolo/Repos/claude-code/src/query.ts)
|
| 316 |
+
- Feature-gated CLI entry paths:
|
| 317 |
+
[src/entrypoints/cli.tsx](/Users/paolo/Repos/claude-code/src/entrypoints/cli.tsx)
|
README.md
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# free-code
|
| 2 |
+
|
| 3 |
+
**The free build of Claude Code.**
|
| 4 |
+
|
| 5 |
+
All telemetry stripped. All injected security-prompt guardrails removed. All experimental features unlocked. One binary, zero callbacks home.
|
| 6 |
+
|
| 7 |
+
```bash
|
| 8 |
+
curl -fsSL https://raw.githubusercontent.com/paoloanzn/free-code/main/install.sh | bash
|
| 9 |
+
```
|
| 10 |
+
|
| 11 |
+
> Checks your system, installs Bun if needed, clones, builds with all features enabled, and puts `free-code` on your PATH. Then just `export ANTHROPIC_API_KEY="sk-ant-..."` and run `free-code`.
|
| 12 |
+
|
| 13 |
+
<p align="center">
|
| 14 |
+
<img src="assets/screenshot.png" alt="free-code screenshot" width="800" />
|
| 15 |
+
</p>
|
| 16 |
+
|
| 17 |
+
---
|
| 18 |
+
|
| 19 |
+
## What is this
|
| 20 |
+
|
| 21 |
+
This is a clean, buildable fork of Anthropic's [Claude Code](https://docs.anthropic.com/en/docs/claude-code) CLI -- the terminal-native AI coding agent. The upstream source became publicly available on March 31, 2026 through a source map exposure in the npm distribution.
|
| 22 |
+
|
| 23 |
+
This fork applies three categories of changes on top of that snapshot:
|
| 24 |
+
|
| 25 |
+
### 1. Telemetry removed
|
| 26 |
+
|
| 27 |
+
The upstream binary phones home through OpenTelemetry/gRPC, GrowthBook analytics, Sentry error reporting, and custom event logging. In this build:
|
| 28 |
+
|
| 29 |
+
- All outbound telemetry endpoints are dead-code-eliminated or stubbed
|
| 30 |
+
- GrowthBook feature flag evaluation still works locally (needed for runtime feature gates) but does not report back
|
| 31 |
+
- No crash reports, no usage analytics, no session fingerprinting
|
| 32 |
+
|
| 33 |
+
### 2. Security-prompt guardrails removed
|
| 34 |
+
|
| 35 |
+
Anthropic injects system-level instructions into every conversation that constrain Claude's behavior beyond what the model itself enforces. These include:
|
| 36 |
+
|
| 37 |
+
- Hardcoded refusal patterns for certain categories of prompts
|
| 38 |
+
- Injected "cyber risk" instruction blocks
|
| 39 |
+
- Managed-settings security overlays pushed from Anthropic's servers
|
| 40 |
+
|
| 41 |
+
This build strips those injections. The model's own safety training still applies -- this just removes the extra layer of prompt-level restrictions that the CLI wraps around it.
|
| 42 |
+
|
| 43 |
+
### 3. Experimental features enabled
|
| 44 |
+
|
| 45 |
+
Claude Code ships with dozens of feature flags gated behind `bun:bundle` compile-time switches. Most are disabled in the public npm release. This build unlocks all 45+ flags that compile cleanly, including:
|
| 46 |
+
|
| 47 |
+
| Feature | What it does |
|
| 48 |
+
|---|---|
|
| 49 |
+
| `ULTRAPLAN` | Remote multi-agent planning on Claude Code web (Opus-class) |
|
| 50 |
+
| `ULTRATHINK` | Deep thinking mode -- type "ultrathink" to boost reasoning effort |
|
| 51 |
+
| `VOICE_MODE` | Push-to-talk voice input and dictation |
|
| 52 |
+
| `AGENT_TRIGGERS` | Local cron/trigger tools for background automation |
|
| 53 |
+
| `BRIDGE_MODE` | IDE remote-control bridge (VS Code, JetBrains) |
|
| 54 |
+
| `TOKEN_BUDGET` | Token budget tracking and usage warnings |
|
| 55 |
+
| `BUILTIN_EXPLORE_PLAN_AGENTS` | Built-in explore/plan agent presets |
|
| 56 |
+
| `VERIFICATION_AGENT` | Verification agent for task validation |
|
| 57 |
+
| `BASH_CLASSIFIER` | Classifier-assisted bash permission decisions |
|
| 58 |
+
| `EXTRACT_MEMORIES` | Post-query automatic memory extraction |
|
| 59 |
+
| `HISTORY_PICKER` | Interactive prompt history picker |
|
| 60 |
+
| `MESSAGE_ACTIONS` | Message action entrypoints in the UI |
|
| 61 |
+
| `QUICK_SEARCH` | Prompt quick-search |
|
| 62 |
+
| `SHOT_STATS` | Shot-distribution stats |
|
| 63 |
+
| `COMPACTION_REMINDERS` | Smart reminders around context compaction |
|
| 64 |
+
| `CACHED_MICROCOMPACT` | Cached microcompact state through query flows |
|
| 65 |
+
|
| 66 |
+
See [FEATURES.md](FEATURES.md) for the full audit of all 88 flags and their status.
|
| 67 |
+
|
| 68 |
+
---
|
| 69 |
+
|
| 70 |
+
## Quick install
|
| 71 |
+
|
| 72 |
+
```bash
|
| 73 |
+
curl -fsSL https://raw.githubusercontent.com/paoloanzn/free-code/main/install.sh | bash
|
| 74 |
+
```
|
| 75 |
+
|
| 76 |
+
This will check your system, install Bun if needed, clone the repo, build the binary with all experimental features enabled, and symlink it as `free-code` on your PATH.
|
| 77 |
+
|
| 78 |
+
After install, just run:
|
| 79 |
+
```bash
|
| 80 |
+
export ANTHROPIC_API_KEY="sk-ant-..."
|
| 81 |
+
free-code
|
| 82 |
+
```
|
| 83 |
+
|
| 84 |
+
---
|
| 85 |
+
|
| 86 |
+
## Requirements
|
| 87 |
+
|
| 88 |
+
- [Bun](https://bun.sh) >= 1.3.11
|
| 89 |
+
- macOS or Linux (Windows via WSL)
|
| 90 |
+
- An Anthropic API key (set `ANTHROPIC_API_KEY` in your environment)
|
| 91 |
+
|
| 92 |
+
```bash
|
| 93 |
+
# Install Bun if you don't have it
|
| 94 |
+
curl -fsSL https://bun.sh/install | bash
|
| 95 |
+
```
|
| 96 |
+
|
| 97 |
+
---
|
| 98 |
+
|
| 99 |
+
## Build
|
| 100 |
+
|
| 101 |
+
```bash
|
| 102 |
+
# Clone the repo
|
| 103 |
+
git clone https://github.com/paoloanzn/claude-code.git
|
| 104 |
+
cd claude-code
|
| 105 |
+
|
| 106 |
+
# Install dependencies
|
| 107 |
+
bun install
|
| 108 |
+
|
| 109 |
+
# Standard build -- produces ./cli
|
| 110 |
+
bun run build
|
| 111 |
+
|
| 112 |
+
# Dev build -- dev version stamp, experimental GrowthBook key
|
| 113 |
+
bun run build:dev
|
| 114 |
+
|
| 115 |
+
# Dev build with ALL experimental features enabled -- produces ./cli-dev
|
| 116 |
+
bun run build:dev:full
|
| 117 |
+
|
| 118 |
+
# Compiled build (alternative output path) -- produces ./dist/cli
|
| 119 |
+
bun run compile
|
| 120 |
+
```
|
| 121 |
+
|
| 122 |
+
### Build variants
|
| 123 |
+
|
| 124 |
+
| Command | Output | Features | Notes |
|
| 125 |
+
|---|---|---|---|
|
| 126 |
+
| `bun run build` | `./cli` | `VOICE_MODE` only | Production-like binary |
|
| 127 |
+
| `bun run build:dev` | `./cli-dev` | `VOICE_MODE` only | Dev version stamp |
|
| 128 |
+
| `bun run build:dev:full` | `./cli-dev` | All 45+ experimental flags | The full unlock build |
|
| 129 |
+
| `bun run compile` | `./dist/cli` | `VOICE_MODE` only | Alternative output directory |
|
| 130 |
+
|
| 131 |
+
### Individual feature flags
|
| 132 |
+
|
| 133 |
+
You can enable specific flags without the full bundle:
|
| 134 |
+
|
| 135 |
+
```bash
|
| 136 |
+
# Enable just ultraplan and ultrathink
|
| 137 |
+
bun run ./scripts/build.ts --feature=ULTRAPLAN --feature=ULTRATHINK
|
| 138 |
+
|
| 139 |
+
# Enable a specific flag on top of the dev build
|
| 140 |
+
bun run ./scripts/build.ts --dev --feature=BRIDGE_MODE
|
| 141 |
+
```
|
| 142 |
+
|
| 143 |
+
---
|
| 144 |
+
|
| 145 |
+
## Run
|
| 146 |
+
|
| 147 |
+
```bash
|
| 148 |
+
# Run the built binary directly
|
| 149 |
+
./cli
|
| 150 |
+
|
| 151 |
+
# Or the dev binary
|
| 152 |
+
./cli-dev
|
| 153 |
+
|
| 154 |
+
# Or run from source without compiling (slower startup)
|
| 155 |
+
bun run dev
|
| 156 |
+
|
| 157 |
+
# Set your API key
|
| 158 |
+
export ANTHROPIC_API_KEY="sk-ant-..."
|
| 159 |
+
|
| 160 |
+
# Or use Claude.ai OAuth
|
| 161 |
+
./cli /login
|
| 162 |
+
```
|
| 163 |
+
|
| 164 |
+
### Quick test
|
| 165 |
+
|
| 166 |
+
```bash
|
| 167 |
+
# One-shot mode
|
| 168 |
+
./cli -p "what files are in this directory?"
|
| 169 |
+
|
| 170 |
+
# Interactive REPL (default)
|
| 171 |
+
./cli
|
| 172 |
+
|
| 173 |
+
# With specific model
|
| 174 |
+
./cli --model claude-sonnet-4-6-20250514
|
| 175 |
+
```
|
| 176 |
+
|
| 177 |
+
---
|
| 178 |
+
|
| 179 |
+
## Project structure
|
| 180 |
+
|
| 181 |
+
```
|
| 182 |
+
scripts/
|
| 183 |
+
build.ts # Build script with feature flag system
|
| 184 |
+
|
| 185 |
+
src/
|
| 186 |
+
entrypoints/cli.tsx # CLI entrypoint
|
| 187 |
+
commands.ts # Command registry (slash commands)
|
| 188 |
+
tools.ts # Tool registry (agent tools)
|
| 189 |
+
QueryEngine.ts # LLM query engine
|
| 190 |
+
screens/REPL.tsx # Main interactive UI
|
| 191 |
+
|
| 192 |
+
commands/ # /slash command implementations
|
| 193 |
+
tools/ # Agent tool implementations (Bash, Read, Edit, etc.)
|
| 194 |
+
components/ # Ink/React terminal UI components
|
| 195 |
+
hooks/ # React hooks
|
| 196 |
+
services/ # API client, MCP, OAuth, analytics
|
| 197 |
+
state/ # App state store
|
| 198 |
+
utils/ # Utilities
|
| 199 |
+
skills/ # Skill system
|
| 200 |
+
plugins/ # Plugin system
|
| 201 |
+
bridge/ # IDE bridge
|
| 202 |
+
voice/ # Voice input
|
| 203 |
+
tasks/ # Background task management
|
| 204 |
+
```
|
| 205 |
+
|
| 206 |
+
---
|
| 207 |
+
|
| 208 |
+
## Tech stack
|
| 209 |
+
|
| 210 |
+
| | |
|
| 211 |
+
|---|---|
|
| 212 |
+
| Runtime | [Bun](https://bun.sh) |
|
| 213 |
+
| Language | TypeScript |
|
| 214 |
+
| Terminal UI | React + [Ink](https://github.com/vadimdemedes/ink) |
|
| 215 |
+
| CLI parsing | [Commander.js](https://github.com/tj/commander.js) |
|
| 216 |
+
| Schema validation | Zod v4 |
|
| 217 |
+
| Code search | ripgrep (bundled) |
|
| 218 |
+
| Protocols | MCP, LSP |
|
| 219 |
+
| API | Anthropic Messages API |
|
| 220 |
+
|
| 221 |
+
---
|
| 222 |
+
|
| 223 |
+
## IPFS Mirror
|
| 224 |
+
|
| 225 |
+
A full copy of this repository is permanently pinned on IPFS via Filecoin:
|
| 226 |
+
|
| 227 |
+
- **CID:** `bafybeiegvef3dt24n2znnnmzcud2vxat7y7rl5ikz7y7yoglxappim54bm`
|
| 228 |
+
- **Gateway:** https://w3s.link/ipfs/bafybeiegvef3dt24n2znnnmzcud2vxat7y7rl5ikz7y7yoglxappim54bm
|
| 229 |
+
|
| 230 |
+
If this repo gets taken down, the code lives on.
|
| 231 |
+
|
| 232 |
+
---
|
| 233 |
+
|
| 234 |
+
## License
|
| 235 |
+
|
| 236 |
+
The original Claude Code source is the property of Anthropic. This fork exists because the source was publicly exposed through their npm distribution. Use at your own discretion.
|
assets/screenshot.png
ADDED
|
Git LFS Details
|
bun.lock
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
claude.sh
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# cp ~/Code/Agent/free-code/cli-dev ~/.local/bin/cli-dev
|
| 2 |
+
# ln -sf ~/Code/Agent/free-code/cli-dev ~/.local/bin/cli-dev
|
| 3 |
+
|
| 4 |
+
cd ~/Code/Agent/free-code ANTHROPIC_BASE_URL="http://127.0.0.1:8001" ANTHROPIC_API_KEY="dummy" CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1 ./cli-dev --bare --model "Qwen3.5-4B.Q4_K_M.gguf"
|
| 5 |
+
|
| 6 |
+
cd ~/Code/Llm/evalbotv1 llama-server --model Jackrong/Qwen3.5-4B-Claude-4.6-Opus-Reasoning-Distilled-GGUF/Qwen3.5-4B.Q4_K_M.gguf --mmproj Jackrong/Qwen3.5-4B-Claude-4.6-Opus-Reasoning-Distilled-GGUF/mmproj-BF16.gguf --seed 3407 --temp 1.0 --top-p 0.95 --min-p 0.01 --top-k 40 -c 49152 --port 8001 --host 0.0.0.0
|
env.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
declare const MACRO: {
|
| 2 |
+
VERSION: string
|
| 3 |
+
BUILD_TIME: string
|
| 4 |
+
PACKAGE_URL?: string
|
| 5 |
+
NATIVE_PACKAGE_URL?: string
|
| 6 |
+
FEEDBACK_CHANNEL?: string
|
| 7 |
+
ISSUES_EXPLAINER?: string
|
| 8 |
+
VERSION_CHANGELOG?: string
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
declare module '*.node' {
|
| 12 |
+
const value: unknown
|
| 13 |
+
export default value
|
| 14 |
+
}
|
install.sh
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
set -euo pipefail
|
| 3 |
+
|
| 4 |
+
# free-code installer
|
| 5 |
+
# Usage: curl -fsSL https://raw.githubusercontent.com/paoloanzn/free-code/main/install.sh | bash
|
| 6 |
+
|
| 7 |
+
RED='\033[0;31m'
|
| 8 |
+
GREEN='\033[0;32m'
|
| 9 |
+
YELLOW='\033[1;33m'
|
| 10 |
+
CYAN='\033[0;36m'
|
| 11 |
+
BOLD='\033[1m'
|
| 12 |
+
DIM='\033[2m'
|
| 13 |
+
RESET='\033[0m'
|
| 14 |
+
|
| 15 |
+
REPO="https://github.com/paoloanzn/free-code.git"
|
| 16 |
+
INSTALL_DIR="$HOME/free-code"
|
| 17 |
+
BUN_MIN_VERSION="1.3.11"
|
| 18 |
+
|
| 19 |
+
info() { printf "${CYAN}[*]${RESET} %s\n" "$*"; }
|
| 20 |
+
ok() { printf "${GREEN}[+]${RESET} %s\n" "$*"; }
|
| 21 |
+
warn() { printf "${YELLOW}[!]${RESET} %s\n" "$*"; }
|
| 22 |
+
fail() {
|
| 23 |
+
printf "${RED}[x]${RESET} %s\n" "$*"
|
| 24 |
+
exit 1
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
header() {
|
| 28 |
+
echo ""
|
| 29 |
+
printf "${BOLD}${CYAN}"
|
| 30 |
+
cat <<'ART'
|
| 31 |
+
___ _
|
| 32 |
+
/ _|_ __ ___ ___ ___ __| | ___
|
| 33 |
+
| |_| '__/ _ \/ _ \_____ / __/ _` |/ _ \
|
| 34 |
+
| _| | | __/ __/_____| (_| (_| | __/
|
| 35 |
+
|_| |_| \___|\___| \___\__,_|\___|
|
| 36 |
+
|
| 37 |
+
ART
|
| 38 |
+
printf "${RESET}"
|
| 39 |
+
printf "${DIM} The free build of Claude Code${RESET}\n"
|
| 40 |
+
echo ""
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
# -------------------------------------------------------------------
|
| 44 |
+
# System checks
|
| 45 |
+
# -------------------------------------------------------------------
|
| 46 |
+
|
| 47 |
+
check_os() {
|
| 48 |
+
case "$(uname -s)" in
|
| 49 |
+
Darwin) OS="macos" ;;
|
| 50 |
+
Linux) OS="linux" ;;
|
| 51 |
+
*) fail "Unsupported OS: $(uname -s). macOS or Linux required." ;;
|
| 52 |
+
esac
|
| 53 |
+
ok "OS: $(uname -s) $(uname -m)"
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
check_git() {
|
| 57 |
+
if ! command -v git &>/dev/null; then
|
| 58 |
+
fail "git is not installed. Install it first:
|
| 59 |
+
macOS: xcode-select --install
|
| 60 |
+
Linux: sudo apt install git (or your distro's equivalent)"
|
| 61 |
+
fi
|
| 62 |
+
ok "git: $(git --version | head -1)"
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
# Compare semver: returns 0 if $1 >= $2
|
| 66 |
+
version_gte() {
|
| 67 |
+
[ "$(printf '%s\n' "$1" "$2" | sort -V | head -1)" = "$2" ]
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
check_bun() {
|
| 71 |
+
if command -v bun &>/dev/null; then
|
| 72 |
+
local ver
|
| 73 |
+
ver="$(bun --version 2>/dev/null || echo "0.0.0")"
|
| 74 |
+
if version_gte "$ver" "$BUN_MIN_VERSION"; then
|
| 75 |
+
ok "bun: v${ver}"
|
| 76 |
+
return
|
| 77 |
+
fi
|
| 78 |
+
warn "bun v${ver} found but v${BUN_MIN_VERSION}+ required. Upgrading..."
|
| 79 |
+
else
|
| 80 |
+
info "bun not found. Installing..."
|
| 81 |
+
fi
|
| 82 |
+
install_bun
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
install_bun() {
|
| 86 |
+
curl -fsSL https://bun.sh/install | bash
|
| 87 |
+
# Source the updated profile so bun is on PATH for this session
|
| 88 |
+
export BUN_INSTALL="${BUN_INSTALL:-$HOME/.bun}"
|
| 89 |
+
export PATH="$BUN_INSTALL/bin:$PATH"
|
| 90 |
+
if ! command -v bun &>/dev/null; then
|
| 91 |
+
fail "bun installation succeeded but binary not found on PATH.
|
| 92 |
+
Add this to your shell profile and restart:
|
| 93 |
+
export PATH=\"\$HOME/.bun/bin:\$PATH\""
|
| 94 |
+
fi
|
| 95 |
+
ok "bun: v$(bun --version) (just installed)"
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
# -------------------------------------------------------------------
|
| 99 |
+
# Clone & build
|
| 100 |
+
# -------------------------------------------------------------------
|
| 101 |
+
|
| 102 |
+
clone_repo() {
|
| 103 |
+
if [ -d "$INSTALL_DIR" ]; then
|
| 104 |
+
warn "$INSTALL_DIR already exists"
|
| 105 |
+
if [ -d "$INSTALL_DIR/.git" ]; then
|
| 106 |
+
info "Pulling latest changes..."
|
| 107 |
+
git -C "$INSTALL_DIR" pull --ff-only origin main 2>/dev/null || {
|
| 108 |
+
warn "Pull failed, continuing with existing copy"
|
| 109 |
+
}
|
| 110 |
+
fi
|
| 111 |
+
else
|
| 112 |
+
info "Cloning repository..."
|
| 113 |
+
git clone --depth 1 "$REPO" "$INSTALL_DIR"
|
| 114 |
+
fi
|
| 115 |
+
ok "Source: $INSTALL_DIR"
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
install_deps() {
|
| 119 |
+
info "Installing dependencies..."
|
| 120 |
+
cd "$INSTALL_DIR"
|
| 121 |
+
bun install --frozen-lockfile 2>/dev/null || bun install
|
| 122 |
+
ok "Dependencies installed"
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
build_binary() {
|
| 126 |
+
info "Building free-code (all experimental features enabled)..."
|
| 127 |
+
cd "$INSTALL_DIR"
|
| 128 |
+
bun run build:dev:full
|
| 129 |
+
ok "Binary built: $INSTALL_DIR/cli-dev"
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
link_binary() {
|
| 133 |
+
local link_dir="$HOME/.local/bin"
|
| 134 |
+
mkdir -p "$link_dir"
|
| 135 |
+
|
| 136 |
+
ln -sf "$INSTALL_DIR/cli-dev" "$link_dir/free-code"
|
| 137 |
+
ok "Symlinked: $link_dir/free-code"
|
| 138 |
+
|
| 139 |
+
if ! echo "$PATH" | tr ':' '\n' | grep -qx "$link_dir"; then
|
| 140 |
+
warn "$link_dir is not on your PATH"
|
| 141 |
+
echo ""
|
| 142 |
+
printf "${YELLOW} Add this to your shell profile (~/.bashrc, ~/.zshrc, etc.):${RESET}\n"
|
| 143 |
+
printf "${BOLD} export PATH=\"\$HOME/.local/bin:\$PATH\"${RESET}\n"
|
| 144 |
+
echo ""
|
| 145 |
+
fi
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
# -------------------------------------------------------------------
|
| 149 |
+
# Main
|
| 150 |
+
# -------------------------------------------------------------------
|
| 151 |
+
|
| 152 |
+
header
|
| 153 |
+
info "Starting installation..."
|
| 154 |
+
echo ""
|
| 155 |
+
|
| 156 |
+
check_os
|
| 157 |
+
check_git
|
| 158 |
+
check_bun
|
| 159 |
+
echo ""
|
| 160 |
+
|
| 161 |
+
clone_repo
|
| 162 |
+
install_deps
|
| 163 |
+
build_binary
|
| 164 |
+
link_binary
|
| 165 |
+
|
| 166 |
+
echo ""
|
| 167 |
+
printf "${GREEN}${BOLD} Installation complete!${RESET}\n"
|
| 168 |
+
echo ""
|
| 169 |
+
printf " ${BOLD}Run it:${RESET}\n"
|
| 170 |
+
printf " ${CYAN}free-code${RESET} # interactive REPL\n"
|
| 171 |
+
printf " ${CYAN}free-code -p \"your prompt\"${RESET} # one-shot mode\n"
|
| 172 |
+
echo ""
|
| 173 |
+
printf " ${BOLD}Set your API key:${RESET}\n"
|
| 174 |
+
printf " ${CYAN}export ANTHROPIC_API_KEY=\"sk-ant-...\"${RESET}\n"
|
| 175 |
+
echo ""
|
| 176 |
+
printf " ${BOLD}Or log in with Claude.ai:${RESET}\n"
|
| 177 |
+
printf " ${CYAN}free-code /login${RESET}\n"
|
| 178 |
+
echo ""
|
| 179 |
+
printf " ${DIM}Source: $INSTALL_DIR${RESET}\n"
|
| 180 |
+
printf " ${DIM}Binary: $INSTALL_DIR/cli-dev${RESET}\n"
|
| 181 |
+
printf " ${DIM}Link: ~/.local/bin/free-code${RESET}\n"
|
| 182 |
+
echo ""
|
package.json
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "claude-code-source-snapshot",
|
| 3 |
+
"version": "2.1.87",
|
| 4 |
+
"private": true,
|
| 5 |
+
"description": "Reconstructed Bun CLI workspace for the Claude Code source snapshot.",
|
| 6 |
+
"type": "module",
|
| 7 |
+
"packageManager": "bun@1.3.11",
|
| 8 |
+
"bin": {
|
| 9 |
+
"claude": "./cli",
|
| 10 |
+
"claude-source": "./cli"
|
| 11 |
+
},
|
| 12 |
+
"engines": {
|
| 13 |
+
"bun": ">=1.3.11"
|
| 14 |
+
},
|
| 15 |
+
"scripts": {
|
| 16 |
+
"build": "bun run ./scripts/build.ts",
|
| 17 |
+
"build:dev": "bun run ./scripts/build.ts --dev",
|
| 18 |
+
"build:dev:full": "bun run ./scripts/build.ts --dev --feature-set=dev-full",
|
| 19 |
+
"compile": "bun run ./scripts/build.ts --compile",
|
| 20 |
+
"dev": "bun run ./src/entrypoints/cli.tsx"
|
| 21 |
+
},
|
| 22 |
+
"dependencies": {
|
| 23 |
+
"@alcalzone/ansi-tokenize": "^0.3.0",
|
| 24 |
+
"@anthropic-ai/bedrock-sdk": "^0.26.4",
|
| 25 |
+
"@anthropic-ai/claude-agent-sdk": "^0.2.87",
|
| 26 |
+
"@anthropic-ai/foundry-sdk": "^0.2.3",
|
| 27 |
+
"@anthropic-ai/mcpb": "^2.1.2",
|
| 28 |
+
"@anthropic-ai/sandbox-runtime": "^0.0.44",
|
| 29 |
+
"@anthropic-ai/sdk": "^0.80.0",
|
| 30 |
+
"@anthropic-ai/vertex-sdk": "^0.14.4",
|
| 31 |
+
"@aws-sdk/client-bedrock": "^3.1020.0",
|
| 32 |
+
"@aws-sdk/client-bedrock-runtime": "^3.1020.0",
|
| 33 |
+
"@aws-sdk/client-sts": "^3.1020.0",
|
| 34 |
+
"@aws-sdk/credential-provider-node": "^3.972.28",
|
| 35 |
+
"@aws-sdk/credential-providers": "^3.1020.0",
|
| 36 |
+
"@azure/identity": "^4.13.1",
|
| 37 |
+
"@commander-js/extra-typings": "^14.0.0",
|
| 38 |
+
"@growthbook/growthbook": "^1.6.5",
|
| 39 |
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
| 40 |
+
"@opentelemetry/api": "^1.9.1",
|
| 41 |
+
"@opentelemetry/api-logs": "^0.214.0",
|
| 42 |
+
"@opentelemetry/core": "^2.6.1",
|
| 43 |
+
"@opentelemetry/exporter-logs-otlp-grpc": "^0.214.0",
|
| 44 |
+
"@opentelemetry/exporter-logs-otlp-http": "^0.214.0",
|
| 45 |
+
"@opentelemetry/exporter-logs-otlp-proto": "^0.214.0",
|
| 46 |
+
"@opentelemetry/exporter-metrics-otlp-grpc": "^0.214.0",
|
| 47 |
+
"@opentelemetry/exporter-metrics-otlp-http": "^0.214.0",
|
| 48 |
+
"@opentelemetry/exporter-metrics-otlp-proto": "^0.214.0",
|
| 49 |
+
"@opentelemetry/exporter-prometheus": "^0.214.0",
|
| 50 |
+
"@opentelemetry/exporter-trace-otlp-grpc": "^0.214.0",
|
| 51 |
+
"@opentelemetry/exporter-trace-otlp-http": "^0.214.0",
|
| 52 |
+
"@opentelemetry/exporter-trace-otlp-proto": "^0.214.0",
|
| 53 |
+
"@opentelemetry/resources": "^2.6.1",
|
| 54 |
+
"@opentelemetry/sdk-logs": "^0.214.0",
|
| 55 |
+
"@opentelemetry/sdk-metrics": "^2.6.1",
|
| 56 |
+
"@opentelemetry/sdk-trace-base": "^2.6.1",
|
| 57 |
+
"@opentelemetry/semantic-conventions": "^1.40.0",
|
| 58 |
+
"@smithy/core": "^3.23.13",
|
| 59 |
+
"@smithy/node-http-handler": "^4.5.1",
|
| 60 |
+
"ajv": "^8.18.0",
|
| 61 |
+
"asciichart": "^1.5.25",
|
| 62 |
+
"auto-bind": "^5.0.1",
|
| 63 |
+
"axios": "^1.14.0",
|
| 64 |
+
"bidi-js": "^1.0.3",
|
| 65 |
+
"cacache": "^20.0.4",
|
| 66 |
+
"chalk": "^5.6.2",
|
| 67 |
+
"chokidar": "^5.0.0",
|
| 68 |
+
"cli-boxes": "^4.0.1",
|
| 69 |
+
"cli-highlight": "^2.1.11",
|
| 70 |
+
"code-excerpt": "^4.0.0",
|
| 71 |
+
"diff": "^8.0.4",
|
| 72 |
+
"emoji-regex": "^10.6.0",
|
| 73 |
+
"env-paths": "^4.0.0",
|
| 74 |
+
"execa": "^9.6.1",
|
| 75 |
+
"fflate": "^0.8.2",
|
| 76 |
+
"figures": "^6.1.0",
|
| 77 |
+
"fuse.js": "^7.1.0",
|
| 78 |
+
"get-east-asian-width": "^1.5.0",
|
| 79 |
+
"google-auth-library": "^10.6.2",
|
| 80 |
+
"highlight.js": "^11.11.1",
|
| 81 |
+
"https-proxy-agent": "^8.0.0",
|
| 82 |
+
"ignore": "^7.0.5",
|
| 83 |
+
"indent-string": "^5.0.0",
|
| 84 |
+
"ink": "^6.8.0",
|
| 85 |
+
"jsonc-parser": "^3.3.1",
|
| 86 |
+
"lodash-es": "^4.17.23",
|
| 87 |
+
"lru-cache": "^11.2.7",
|
| 88 |
+
"marked": "^17.0.5",
|
| 89 |
+
"p-map": "^7.0.4",
|
| 90 |
+
"picomatch": "^4.0.4",
|
| 91 |
+
"plist": "^3.1.0",
|
| 92 |
+
"proper-lockfile": "^4.1.2",
|
| 93 |
+
"qrcode": "^1.5.4",
|
| 94 |
+
"react": "^19.2.4",
|
| 95 |
+
"react-reconciler": "^0.33.0",
|
| 96 |
+
"semver": "^7.7.4",
|
| 97 |
+
"sharp": "^0.34.5",
|
| 98 |
+
"shell-quote": "^1.8.3",
|
| 99 |
+
"signal-exit": "^4.1.0",
|
| 100 |
+
"stack-utils": "^2.0.6",
|
| 101 |
+
"strip-ansi": "^7.2.0",
|
| 102 |
+
"supports-hyperlinks": "^4.4.0",
|
| 103 |
+
"tree-kill": "^1.2.2",
|
| 104 |
+
"turndown": "^7.2.2",
|
| 105 |
+
"type-fest": "^5.5.0",
|
| 106 |
+
"undici": "^7.24.6",
|
| 107 |
+
"usehooks-ts": "^3.1.1",
|
| 108 |
+
"vscode-jsonrpc": "^8.2.1",
|
| 109 |
+
"vscode-languageserver-protocol": "^3.17.5",
|
| 110 |
+
"vscode-languageserver-types": "^3.17.5",
|
| 111 |
+
"wrap-ansi": "^10.0.0",
|
| 112 |
+
"ws": "^8.20.0",
|
| 113 |
+
"xss": "^1.0.15",
|
| 114 |
+
"yaml": "^2.8.3",
|
| 115 |
+
"zod": "^4.3.6"
|
| 116 |
+
},
|
| 117 |
+
"devDependencies": {
|
| 118 |
+
"@types/bun": "^1.3.11",
|
| 119 |
+
"typescript": "^6.0.2"
|
| 120 |
+
}
|
| 121 |
+
}
|
scripts/build.ts
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { chmodSync, existsSync, mkdirSync } from 'fs'
|
| 2 |
+
import { dirname } from 'path'
|
| 3 |
+
|
| 4 |
+
const pkg = await Bun.file(new URL('../package.json', import.meta.url)).json() as {
|
| 5 |
+
name: string
|
| 6 |
+
version: string
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
const args = process.argv.slice(2)
|
| 10 |
+
const compile = args.includes('--compile')
|
| 11 |
+
const dev = args.includes('--dev')
|
| 12 |
+
|
| 13 |
+
const fullExperimentalFeatures = [
|
| 14 |
+
'AGENT_MEMORY_SNAPSHOT',
|
| 15 |
+
'AGENT_TRIGGERS',
|
| 16 |
+
'AGENT_TRIGGERS_REMOTE',
|
| 17 |
+
'AWAY_SUMMARY',
|
| 18 |
+
'BASH_CLASSIFIER',
|
| 19 |
+
'BUDDY',
|
| 20 |
+
'BRIDGE_MODE',
|
| 21 |
+
'BUILTIN_EXPLORE_PLAN_AGENTS',
|
| 22 |
+
'CACHED_MICROCOMPACT',
|
| 23 |
+
'CCR_AUTO_CONNECT',
|
| 24 |
+
'CCR_MIRROR',
|
| 25 |
+
'CCR_REMOTE_SETUP',
|
| 26 |
+
'COMPACTION_REMINDERS',
|
| 27 |
+
'CONNECTOR_TEXT',
|
| 28 |
+
'EXTRACT_MEMORIES',
|
| 29 |
+
'HISTORY_PICKER',
|
| 30 |
+
'HOOK_PROMPTS',
|
| 31 |
+
'KAIROS_BRIEF',
|
| 32 |
+
'KAIROS_CHANNELS',
|
| 33 |
+
'LODESTONE',
|
| 34 |
+
'MCP_RICH_OUTPUT',
|
| 35 |
+
'MESSAGE_ACTIONS',
|
| 36 |
+
'NATIVE_CLIPBOARD_IMAGE',
|
| 37 |
+
'NEW_INIT',
|
| 38 |
+
'POWERSHELL_AUTO_MODE',
|
| 39 |
+
'PROMPT_CACHE_BREAK_DETECTION',
|
| 40 |
+
'QUICK_SEARCH',
|
| 41 |
+
'SHOT_STATS',
|
| 42 |
+
'TEAMMEM',
|
| 43 |
+
'TOKEN_BUDGET',
|
| 44 |
+
'TREE_SITTER_BASH',
|
| 45 |
+
'TREE_SITTER_BASH_SHADOW',
|
| 46 |
+
'ULTRAPLAN',
|
| 47 |
+
'ULTRATHINK',
|
| 48 |
+
'UNATTENDED_RETRY',
|
| 49 |
+
'VERIFICATION_AGENT',
|
| 50 |
+
'VOICE_MODE',
|
| 51 |
+
] as const
|
| 52 |
+
|
| 53 |
+
function runCommand(cmd: string[]): string | null {
|
| 54 |
+
const proc = Bun.spawnSync({
|
| 55 |
+
cmd,
|
| 56 |
+
cwd: process.cwd(),
|
| 57 |
+
stdout: 'pipe',
|
| 58 |
+
stderr: 'pipe',
|
| 59 |
+
})
|
| 60 |
+
|
| 61 |
+
if (proc.exitCode !== 0) {
|
| 62 |
+
return null
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
return new TextDecoder().decode(proc.stdout).trim() || null
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
function getDevVersion(baseVersion: string): string {
|
| 69 |
+
const timestamp = new Date().toISOString()
|
| 70 |
+
const date = timestamp.slice(0, 10).replaceAll('-', '')
|
| 71 |
+
const time = timestamp.slice(11, 19).replaceAll(':', '')
|
| 72 |
+
const sha = runCommand(['git', 'rev-parse', '--short=8', 'HEAD']) ?? 'unknown'
|
| 73 |
+
return `${baseVersion}-dev.${date}.t${time}.sha${sha}`
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
function getVersionChangelog(): string {
|
| 77 |
+
return (
|
| 78 |
+
runCommand(['git', 'log', '--format=%h %s', '-20']) ??
|
| 79 |
+
'Local development build'
|
| 80 |
+
)
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
const defaultFeatures = ['VOICE_MODE']
|
| 84 |
+
const featureSet = new Set(defaultFeatures)
|
| 85 |
+
for (let i = 0; i < args.length; i += 1) {
|
| 86 |
+
const arg = args[i]
|
| 87 |
+
if (arg === '--feature-set' && args[i + 1]) {
|
| 88 |
+
if (args[i + 1] === 'dev-full') {
|
| 89 |
+
for (const feature of fullExperimentalFeatures) {
|
| 90 |
+
featureSet.add(feature)
|
| 91 |
+
}
|
| 92 |
+
}
|
| 93 |
+
i += 1
|
| 94 |
+
continue
|
| 95 |
+
}
|
| 96 |
+
if (arg === '--feature-set=dev-full') {
|
| 97 |
+
for (const feature of fullExperimentalFeatures) {
|
| 98 |
+
featureSet.add(feature)
|
| 99 |
+
}
|
| 100 |
+
continue
|
| 101 |
+
}
|
| 102 |
+
if (arg === '--feature' && args[i + 1]) {
|
| 103 |
+
featureSet.add(args[i + 1]!)
|
| 104 |
+
i += 1
|
| 105 |
+
continue
|
| 106 |
+
}
|
| 107 |
+
if (arg.startsWith('--feature=')) {
|
| 108 |
+
featureSet.add(arg.slice('--feature='.length))
|
| 109 |
+
}
|
| 110 |
+
}
|
| 111 |
+
const features = [...featureSet]
|
| 112 |
+
|
| 113 |
+
const outfile = compile
|
| 114 |
+
? dev
|
| 115 |
+
? './dist/cli-dev'
|
| 116 |
+
: './dist/cli'
|
| 117 |
+
: dev
|
| 118 |
+
? './cli-dev'
|
| 119 |
+
: './cli'
|
| 120 |
+
const buildTime = new Date().toISOString()
|
| 121 |
+
const version = dev ? getDevVersion(pkg.version) : pkg.version
|
| 122 |
+
|
| 123 |
+
mkdirSync(dirname(outfile), { recursive: true })
|
| 124 |
+
|
| 125 |
+
const externals = [
|
| 126 |
+
'@ant/*',
|
| 127 |
+
'audio-capture-napi',
|
| 128 |
+
'image-processor-napi',
|
| 129 |
+
'modifiers-napi',
|
| 130 |
+
'url-handler-napi',
|
| 131 |
+
]
|
| 132 |
+
|
| 133 |
+
const defines = {
|
| 134 |
+
'process.env.USER_TYPE': JSON.stringify('external'),
|
| 135 |
+
'process.env.CLAUDE_CODE_FORCE_FULL_LOGO': JSON.stringify('true'),
|
| 136 |
+
...(dev
|
| 137 |
+
? { 'process.env.NODE_ENV': JSON.stringify('development') }
|
| 138 |
+
: {}),
|
| 139 |
+
...(dev
|
| 140 |
+
? {
|
| 141 |
+
'process.env.CLAUDE_CODE_EXPERIMENTAL_BUILD': JSON.stringify('true'),
|
| 142 |
+
}
|
| 143 |
+
: {}),
|
| 144 |
+
'process.env.CLAUDE_CODE_VERIFY_PLAN': JSON.stringify('false'),
|
| 145 |
+
'process.env.CCR_FORCE_BUNDLE': JSON.stringify('true'),
|
| 146 |
+
'MACRO.VERSION': JSON.stringify(version),
|
| 147 |
+
'MACRO.BUILD_TIME': JSON.stringify(buildTime),
|
| 148 |
+
'MACRO.PACKAGE_URL': JSON.stringify(pkg.name),
|
| 149 |
+
'MACRO.NATIVE_PACKAGE_URL': 'undefined',
|
| 150 |
+
'MACRO.FEEDBACK_CHANNEL': JSON.stringify('github'),
|
| 151 |
+
'MACRO.ISSUES_EXPLAINER': JSON.stringify(
|
| 152 |
+
'This reconstructed source snapshot does not include Anthropic internal issue routing.',
|
| 153 |
+
),
|
| 154 |
+
'MACRO.VERSION_CHANGELOG': JSON.stringify(
|
| 155 |
+
dev ? getVersionChangelog() : 'https://github.com/paoloanzn/claude-code',
|
| 156 |
+
),
|
| 157 |
+
} as const
|
| 158 |
+
|
| 159 |
+
const cmd = [
|
| 160 |
+
'bun',
|
| 161 |
+
'build',
|
| 162 |
+
'./src/entrypoints/cli.tsx',
|
| 163 |
+
'--compile',
|
| 164 |
+
'--target',
|
| 165 |
+
'bun',
|
| 166 |
+
'--format',
|
| 167 |
+
'esm',
|
| 168 |
+
'--outfile',
|
| 169 |
+
outfile,
|
| 170 |
+
'--minify',
|
| 171 |
+
'--bytecode',
|
| 172 |
+
'--packages',
|
| 173 |
+
'bundle',
|
| 174 |
+
'--conditions',
|
| 175 |
+
'bun',
|
| 176 |
+
]
|
| 177 |
+
|
| 178 |
+
for (const external of externals) {
|
| 179 |
+
cmd.push('--external', external)
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
for (const feature of features) {
|
| 183 |
+
cmd.push(`--feature=${feature}`)
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
for (const [key, value] of Object.entries(defines)) {
|
| 187 |
+
cmd.push('--define', `${key}=${value}`)
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
const proc = Bun.spawnSync({
|
| 191 |
+
cmd,
|
| 192 |
+
cwd: process.cwd(),
|
| 193 |
+
stdout: 'inherit',
|
| 194 |
+
stderr: 'inherit',
|
| 195 |
+
})
|
| 196 |
+
|
| 197 |
+
if (proc.exitCode !== 0) {
|
| 198 |
+
process.exit(proc.exitCode ?? 1)
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
if (existsSync(outfile)) {
|
| 202 |
+
chmodSync(outfile, 0o755)
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
console.log(`Built ${outfile}`)
|
src/QueryEngine.ts
ADDED
|
@@ -0,0 +1,1295 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { feature } from 'bun:bundle'
|
| 2 |
+
import type { ContentBlockParam } from '@anthropic-ai/sdk/resources/messages.mjs'
|
| 3 |
+
import { randomUUID } from 'crypto'
|
| 4 |
+
import last from 'lodash-es/last.js'
|
| 5 |
+
import {
|
| 6 |
+
getSessionId,
|
| 7 |
+
isSessionPersistenceDisabled,
|
| 8 |
+
} from 'src/bootstrap/state.js'
|
| 9 |
+
import type {
|
| 10 |
+
PermissionMode,
|
| 11 |
+
SDKCompactBoundaryMessage,
|
| 12 |
+
SDKMessage,
|
| 13 |
+
SDKPermissionDenial,
|
| 14 |
+
SDKStatus,
|
| 15 |
+
SDKUserMessageReplay,
|
| 16 |
+
} from 'src/entrypoints/agentSdkTypes.js'
|
| 17 |
+
import { accumulateUsage, updateUsage } from 'src/services/api/claude.js'
|
| 18 |
+
import type { NonNullableUsage } from 'src/services/api/logging.js'
|
| 19 |
+
import { EMPTY_USAGE } from 'src/services/api/logging.js'
|
| 20 |
+
import stripAnsi from 'strip-ansi'
|
| 21 |
+
import type { Command } from './commands.js'
|
| 22 |
+
import { getSlashCommandToolSkills } from './commands.js'
|
| 23 |
+
import {
|
| 24 |
+
LOCAL_COMMAND_STDERR_TAG,
|
| 25 |
+
LOCAL_COMMAND_STDOUT_TAG,
|
| 26 |
+
} from './constants/xml.js'
|
| 27 |
+
import {
|
| 28 |
+
getModelUsage,
|
| 29 |
+
getTotalAPIDuration,
|
| 30 |
+
getTotalCost,
|
| 31 |
+
} from './cost-tracker.js'
|
| 32 |
+
import type { CanUseToolFn } from './hooks/useCanUseTool.js'
|
| 33 |
+
import { loadMemoryPrompt } from './memdir/memdir.js'
|
| 34 |
+
import { hasAutoMemPathOverride } from './memdir/paths.js'
|
| 35 |
+
import { query } from './query.js'
|
| 36 |
+
import { categorizeRetryableAPIError } from './services/api/errors.js'
|
| 37 |
+
import type { MCPServerConnection } from './services/mcp/types.js'
|
| 38 |
+
import type { AppState } from './state/AppState.js'
|
| 39 |
+
import { type Tools, type ToolUseContext, toolMatchesName } from './Tool.js'
|
| 40 |
+
import type { AgentDefinition } from './tools/AgentTool/loadAgentsDir.js'
|
| 41 |
+
import { SYNTHETIC_OUTPUT_TOOL_NAME } from './tools/SyntheticOutputTool/SyntheticOutputTool.js'
|
| 42 |
+
import type { Message } from './types/message.js'
|
| 43 |
+
import type { OrphanedPermission } from './types/textInputTypes.js'
|
| 44 |
+
import { createAbortController } from './utils/abortController.js'
|
| 45 |
+
import type { AttributionState } from './utils/commitAttribution.js'
|
| 46 |
+
import { getGlobalConfig } from './utils/config.js'
|
| 47 |
+
import { getCwd } from './utils/cwd.js'
|
| 48 |
+
import { isBareMode, isEnvTruthy } from './utils/envUtils.js'
|
| 49 |
+
import { getFastModeState } from './utils/fastMode.js'
|
| 50 |
+
import {
|
| 51 |
+
type FileHistoryState,
|
| 52 |
+
fileHistoryEnabled,
|
| 53 |
+
fileHistoryMakeSnapshot,
|
| 54 |
+
} from './utils/fileHistory.js'
|
| 55 |
+
import {
|
| 56 |
+
cloneFileStateCache,
|
| 57 |
+
type FileStateCache,
|
| 58 |
+
} from './utils/fileStateCache.js'
|
| 59 |
+
import { headlessProfilerCheckpoint } from './utils/headlessProfiler.js'
|
| 60 |
+
import { registerStructuredOutputEnforcement } from './utils/hooks/hookHelpers.js'
|
| 61 |
+
import { getInMemoryErrors } from './utils/log.js'
|
| 62 |
+
import { countToolCalls, SYNTHETIC_MESSAGES } from './utils/messages.js'
|
| 63 |
+
import {
|
| 64 |
+
getMainLoopModel,
|
| 65 |
+
parseUserSpecifiedModel,
|
| 66 |
+
} from './utils/model/model.js'
|
| 67 |
+
import { loadAllPluginsCacheOnly } from './utils/plugins/pluginLoader.js'
|
| 68 |
+
import {
|
| 69 |
+
type ProcessUserInputContext,
|
| 70 |
+
processUserInput,
|
| 71 |
+
} from './utils/processUserInput/processUserInput.js'
|
| 72 |
+
import { fetchSystemPromptParts } from './utils/queryContext.js'
|
| 73 |
+
import { setCwd } from './utils/Shell.js'
|
| 74 |
+
import {
|
| 75 |
+
flushSessionStorage,
|
| 76 |
+
recordTranscript,
|
| 77 |
+
} from './utils/sessionStorage.js'
|
| 78 |
+
import { asSystemPrompt } from './utils/systemPromptType.js'
|
| 79 |
+
import { resolveThemeSetting } from './utils/systemTheme.js'
|
| 80 |
+
import {
|
| 81 |
+
shouldEnableThinkingByDefault,
|
| 82 |
+
type ThinkingConfig,
|
| 83 |
+
} from './utils/thinking.js'
|
| 84 |
+
|
| 85 |
+
// Lazy: MessageSelector.tsx pulls React/ink; only needed for message filtering at query time
|
| 86 |
+
/* eslint-disable @typescript-eslint/no-require-imports */
|
| 87 |
+
const messageSelector =
|
| 88 |
+
(): typeof import('src/components/MessageSelector.js') =>
|
| 89 |
+
require('src/components/MessageSelector.js')
|
| 90 |
+
|
| 91 |
+
import {
|
| 92 |
+
localCommandOutputToSDKAssistantMessage,
|
| 93 |
+
toSDKCompactMetadata,
|
| 94 |
+
} from './utils/messages/mappers.js'
|
| 95 |
+
import {
|
| 96 |
+
buildSystemInitMessage,
|
| 97 |
+
sdkCompatToolName,
|
| 98 |
+
} from './utils/messages/systemInit.js'
|
| 99 |
+
import {
|
| 100 |
+
getScratchpadDir,
|
| 101 |
+
isScratchpadEnabled,
|
| 102 |
+
} from './utils/permissions/filesystem.js'
|
| 103 |
+
/* eslint-enable @typescript-eslint/no-require-imports */
|
| 104 |
+
import {
|
| 105 |
+
handleOrphanedPermission,
|
| 106 |
+
isResultSuccessful,
|
| 107 |
+
normalizeMessage,
|
| 108 |
+
} from './utils/queryHelpers.js'
|
| 109 |
+
|
| 110 |
+
// Dead code elimination: conditional import for coordinator mode
|
| 111 |
+
/* eslint-disable @typescript-eslint/no-require-imports */
|
| 112 |
+
const getCoordinatorUserContext: (
|
| 113 |
+
mcpClients: ReadonlyArray<{ name: string }>,
|
| 114 |
+
scratchpadDir?: string,
|
| 115 |
+
) => { [k: string]: string } = feature('COORDINATOR_MODE')
|
| 116 |
+
? require('./coordinator/coordinatorMode.js').getCoordinatorUserContext
|
| 117 |
+
: () => ({})
|
| 118 |
+
/* eslint-enable @typescript-eslint/no-require-imports */
|
| 119 |
+
|
| 120 |
+
// Dead code elimination: conditional import for snip compaction
|
| 121 |
+
/* eslint-disable @typescript-eslint/no-require-imports */
|
| 122 |
+
const snipModule = feature('HISTORY_SNIP')
|
| 123 |
+
? (require('./services/compact/snipCompact.js') as typeof import('./services/compact/snipCompact.js'))
|
| 124 |
+
: null
|
| 125 |
+
const snipProjection = feature('HISTORY_SNIP')
|
| 126 |
+
? (require('./services/compact/snipProjection.js') as typeof import('./services/compact/snipProjection.js'))
|
| 127 |
+
: null
|
| 128 |
+
/* eslint-enable @typescript-eslint/no-require-imports */
|
| 129 |
+
|
| 130 |
+
export type QueryEngineConfig = {
|
| 131 |
+
cwd: string
|
| 132 |
+
tools: Tools
|
| 133 |
+
commands: Command[]
|
| 134 |
+
mcpClients: MCPServerConnection[]
|
| 135 |
+
agents: AgentDefinition[]
|
| 136 |
+
canUseTool: CanUseToolFn
|
| 137 |
+
getAppState: () => AppState
|
| 138 |
+
setAppState: (f: (prev: AppState) => AppState) => void
|
| 139 |
+
initialMessages?: Message[]
|
| 140 |
+
readFileCache: FileStateCache
|
| 141 |
+
customSystemPrompt?: string
|
| 142 |
+
appendSystemPrompt?: string
|
| 143 |
+
userSpecifiedModel?: string
|
| 144 |
+
fallbackModel?: string
|
| 145 |
+
thinkingConfig?: ThinkingConfig
|
| 146 |
+
maxTurns?: number
|
| 147 |
+
maxBudgetUsd?: number
|
| 148 |
+
taskBudget?: { total: number }
|
| 149 |
+
jsonSchema?: Record<string, unknown>
|
| 150 |
+
verbose?: boolean
|
| 151 |
+
replayUserMessages?: boolean
|
| 152 |
+
/** Handler for URL elicitations triggered by MCP tool -32042 errors. */
|
| 153 |
+
handleElicitation?: ToolUseContext['handleElicitation']
|
| 154 |
+
includePartialMessages?: boolean
|
| 155 |
+
setSDKStatus?: (status: SDKStatus) => void
|
| 156 |
+
abortController?: AbortController
|
| 157 |
+
orphanedPermission?: OrphanedPermission
|
| 158 |
+
/**
|
| 159 |
+
* Snip-boundary handler: receives each yielded system message plus the
|
| 160 |
+
* current mutableMessages store. Returns undefined if the message is not a
|
| 161 |
+
* snip boundary; otherwise returns the replayed snip result. Injected by
|
| 162 |
+
* ask() when HISTORY_SNIP is enabled so feature-gated strings stay inside
|
| 163 |
+
* the gated module (keeps QueryEngine free of excluded strings and testable
|
| 164 |
+
* despite feature() returning false under bun test). SDK-only: the REPL
|
| 165 |
+
* keeps full history for UI scrollback and projects on demand via
|
| 166 |
+
* projectSnippedView; QueryEngine truncates here to bound memory in long
|
| 167 |
+
* headless sessions (no UI to preserve).
|
| 168 |
+
*/
|
| 169 |
+
snipReplay?: (
|
| 170 |
+
yieldedSystemMsg: Message,
|
| 171 |
+
store: Message[],
|
| 172 |
+
) => { messages: Message[]; executed: boolean } | undefined
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
/**
|
| 176 |
+
* QueryEngine owns the query lifecycle and session state for a conversation.
|
| 177 |
+
* It extracts the core logic from ask() into a standalone class that can be
|
| 178 |
+
* used by both the headless/SDK path and (in a future phase) the REPL.
|
| 179 |
+
*
|
| 180 |
+
* One QueryEngine per conversation. Each submitMessage() call starts a new
|
| 181 |
+
* turn within the same conversation. State (messages, file cache, usage, etc.)
|
| 182 |
+
* persists across turns.
|
| 183 |
+
*/
|
| 184 |
+
export class QueryEngine {
|
| 185 |
+
private config: QueryEngineConfig
|
| 186 |
+
private mutableMessages: Message[]
|
| 187 |
+
private abortController: AbortController
|
| 188 |
+
private permissionDenials: SDKPermissionDenial[]
|
| 189 |
+
private totalUsage: NonNullableUsage
|
| 190 |
+
private hasHandledOrphanedPermission = false
|
| 191 |
+
private readFileState: FileStateCache
|
| 192 |
+
// Turn-scoped skill discovery tracking (feeds was_discovered on
|
| 193 |
+
// tengu_skill_tool_invocation). Must persist across the two
|
| 194 |
+
// processUserInputContext rebuilds inside submitMessage, but is cleared
|
| 195 |
+
// at the start of each submitMessage to avoid unbounded growth across
|
| 196 |
+
// many turns in SDK mode.
|
| 197 |
+
private discoveredSkillNames = new Set<string>()
|
| 198 |
+
private loadedNestedMemoryPaths = new Set<string>()
|
| 199 |
+
|
| 200 |
+
constructor(config: QueryEngineConfig) {
|
| 201 |
+
this.config = config
|
| 202 |
+
this.mutableMessages = config.initialMessages ?? []
|
| 203 |
+
this.abortController = config.abortController ?? createAbortController()
|
| 204 |
+
this.permissionDenials = []
|
| 205 |
+
this.readFileState = config.readFileCache
|
| 206 |
+
this.totalUsage = EMPTY_USAGE
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
async *submitMessage(
|
| 210 |
+
prompt: string | ContentBlockParam[],
|
| 211 |
+
options?: { uuid?: string; isMeta?: boolean },
|
| 212 |
+
): AsyncGenerator<SDKMessage, void, unknown> {
|
| 213 |
+
const {
|
| 214 |
+
cwd,
|
| 215 |
+
commands,
|
| 216 |
+
tools,
|
| 217 |
+
mcpClients,
|
| 218 |
+
verbose = false,
|
| 219 |
+
thinkingConfig,
|
| 220 |
+
maxTurns,
|
| 221 |
+
maxBudgetUsd,
|
| 222 |
+
taskBudget,
|
| 223 |
+
canUseTool,
|
| 224 |
+
customSystemPrompt,
|
| 225 |
+
appendSystemPrompt,
|
| 226 |
+
userSpecifiedModel,
|
| 227 |
+
fallbackModel,
|
| 228 |
+
jsonSchema,
|
| 229 |
+
getAppState,
|
| 230 |
+
setAppState,
|
| 231 |
+
replayUserMessages = false,
|
| 232 |
+
includePartialMessages = false,
|
| 233 |
+
agents = [],
|
| 234 |
+
setSDKStatus,
|
| 235 |
+
orphanedPermission,
|
| 236 |
+
} = this.config
|
| 237 |
+
|
| 238 |
+
this.discoveredSkillNames.clear()
|
| 239 |
+
setCwd(cwd)
|
| 240 |
+
const persistSession = !isSessionPersistenceDisabled()
|
| 241 |
+
const startTime = Date.now()
|
| 242 |
+
|
| 243 |
+
// Wrap canUseTool to track permission denials
|
| 244 |
+
const wrappedCanUseTool: CanUseToolFn = async (
|
| 245 |
+
tool,
|
| 246 |
+
input,
|
| 247 |
+
toolUseContext,
|
| 248 |
+
assistantMessage,
|
| 249 |
+
toolUseID,
|
| 250 |
+
forceDecision,
|
| 251 |
+
) => {
|
| 252 |
+
const result = await canUseTool(
|
| 253 |
+
tool,
|
| 254 |
+
input,
|
| 255 |
+
toolUseContext,
|
| 256 |
+
assistantMessage,
|
| 257 |
+
toolUseID,
|
| 258 |
+
forceDecision,
|
| 259 |
+
)
|
| 260 |
+
|
| 261 |
+
// Track denials for SDK reporting
|
| 262 |
+
if (result.behavior !== 'allow') {
|
| 263 |
+
this.permissionDenials.push({
|
| 264 |
+
tool_name: sdkCompatToolName(tool.name),
|
| 265 |
+
tool_use_id: toolUseID,
|
| 266 |
+
tool_input: input,
|
| 267 |
+
})
|
| 268 |
+
}
|
| 269 |
+
|
| 270 |
+
return result
|
| 271 |
+
}
|
| 272 |
+
|
| 273 |
+
const initialAppState = getAppState()
|
| 274 |
+
const initialMainLoopModel = userSpecifiedModel
|
| 275 |
+
? parseUserSpecifiedModel(userSpecifiedModel)
|
| 276 |
+
: getMainLoopModel()
|
| 277 |
+
|
| 278 |
+
const initialThinkingConfig: ThinkingConfig = thinkingConfig
|
| 279 |
+
? thinkingConfig
|
| 280 |
+
: shouldEnableThinkingByDefault() !== false
|
| 281 |
+
? { type: 'adaptive' }
|
| 282 |
+
: { type: 'disabled' }
|
| 283 |
+
|
| 284 |
+
headlessProfilerCheckpoint('before_getSystemPrompt')
|
| 285 |
+
// Narrow once so TS tracks the type through the conditionals below.
|
| 286 |
+
const customPrompt =
|
| 287 |
+
typeof customSystemPrompt === 'string' ? customSystemPrompt : undefined
|
| 288 |
+
const {
|
| 289 |
+
defaultSystemPrompt,
|
| 290 |
+
userContext: baseUserContext,
|
| 291 |
+
systemContext,
|
| 292 |
+
} = await fetchSystemPromptParts({
|
| 293 |
+
tools,
|
| 294 |
+
mainLoopModel: initialMainLoopModel,
|
| 295 |
+
additionalWorkingDirectories: Array.from(
|
| 296 |
+
initialAppState.toolPermissionContext.additionalWorkingDirectories.keys(),
|
| 297 |
+
),
|
| 298 |
+
mcpClients,
|
| 299 |
+
customSystemPrompt: customPrompt,
|
| 300 |
+
})
|
| 301 |
+
headlessProfilerCheckpoint('after_getSystemPrompt')
|
| 302 |
+
const userContext = {
|
| 303 |
+
...baseUserContext,
|
| 304 |
+
...getCoordinatorUserContext(
|
| 305 |
+
mcpClients,
|
| 306 |
+
isScratchpadEnabled() ? getScratchpadDir() : undefined,
|
| 307 |
+
),
|
| 308 |
+
}
|
| 309 |
+
|
| 310 |
+
// When an SDK caller provides a custom system prompt AND has set
|
| 311 |
+
// CLAUDE_COWORK_MEMORY_PATH_OVERRIDE, inject the memory-mechanics prompt.
|
| 312 |
+
// The env var is an explicit opt-in signal — the caller has wired up
|
| 313 |
+
// a memory directory and needs Claude to know how to use it (which
|
| 314 |
+
// Write/Edit tools to call, MEMORY.md filename, loading semantics).
|
| 315 |
+
// The caller can layer their own policy text via appendSystemPrompt.
|
| 316 |
+
const memoryMechanicsPrompt =
|
| 317 |
+
customPrompt !== undefined && hasAutoMemPathOverride()
|
| 318 |
+
? await loadMemoryPrompt()
|
| 319 |
+
: null
|
| 320 |
+
|
| 321 |
+
const systemPrompt = asSystemPrompt([
|
| 322 |
+
...(customPrompt !== undefined ? [customPrompt] : defaultSystemPrompt),
|
| 323 |
+
...(memoryMechanicsPrompt ? [memoryMechanicsPrompt] : []),
|
| 324 |
+
...(appendSystemPrompt ? [appendSystemPrompt] : []),
|
| 325 |
+
])
|
| 326 |
+
|
| 327 |
+
// Register function hook for structured output enforcement
|
| 328 |
+
const hasStructuredOutputTool = tools.some(t =>
|
| 329 |
+
toolMatchesName(t, SYNTHETIC_OUTPUT_TOOL_NAME),
|
| 330 |
+
)
|
| 331 |
+
if (jsonSchema && hasStructuredOutputTool) {
|
| 332 |
+
registerStructuredOutputEnforcement(setAppState, getSessionId())
|
| 333 |
+
}
|
| 334 |
+
|
| 335 |
+
let processUserInputContext: ProcessUserInputContext = {
|
| 336 |
+
messages: this.mutableMessages,
|
| 337 |
+
// Slash commands that mutate the message array (e.g. /force-snip)
|
| 338 |
+
// call setMessages(fn). In interactive mode this writes back to
|
| 339 |
+
// AppState; in print mode we write back to mutableMessages so the
|
| 340 |
+
// rest of the query loop (push at :389, snapshot at :392) sees
|
| 341 |
+
// the result. The second processUserInputContext below (after
|
| 342 |
+
// slash-command processing) keeps the no-op — nothing else calls
|
| 343 |
+
// setMessages past that point.
|
| 344 |
+
setMessages: fn => {
|
| 345 |
+
this.mutableMessages = fn(this.mutableMessages)
|
| 346 |
+
},
|
| 347 |
+
onChangeAPIKey: () => {},
|
| 348 |
+
handleElicitation: this.config.handleElicitation,
|
| 349 |
+
options: {
|
| 350 |
+
commands,
|
| 351 |
+
debug: false, // we use stdout, so don't want to clobber it
|
| 352 |
+
tools,
|
| 353 |
+
verbose,
|
| 354 |
+
mainLoopModel: initialMainLoopModel,
|
| 355 |
+
thinkingConfig: initialThinkingConfig,
|
| 356 |
+
mcpClients,
|
| 357 |
+
mcpResources: {},
|
| 358 |
+
ideInstallationStatus: null,
|
| 359 |
+
isNonInteractiveSession: true,
|
| 360 |
+
customSystemPrompt,
|
| 361 |
+
appendSystemPrompt,
|
| 362 |
+
agentDefinitions: { activeAgents: agents, allAgents: [] },
|
| 363 |
+
theme: resolveThemeSetting(getGlobalConfig().theme),
|
| 364 |
+
maxBudgetUsd,
|
| 365 |
+
},
|
| 366 |
+
getAppState,
|
| 367 |
+
setAppState,
|
| 368 |
+
abortController: this.abortController,
|
| 369 |
+
readFileState: this.readFileState,
|
| 370 |
+
nestedMemoryAttachmentTriggers: new Set<string>(),
|
| 371 |
+
loadedNestedMemoryPaths: this.loadedNestedMemoryPaths,
|
| 372 |
+
dynamicSkillDirTriggers: new Set<string>(),
|
| 373 |
+
discoveredSkillNames: this.discoveredSkillNames,
|
| 374 |
+
setInProgressToolUseIDs: () => {},
|
| 375 |
+
setResponseLength: () => {},
|
| 376 |
+
updateFileHistoryState: (
|
| 377 |
+
updater: (prev: FileHistoryState) => FileHistoryState,
|
| 378 |
+
) => {
|
| 379 |
+
setAppState(prev => {
|
| 380 |
+
const updated = updater(prev.fileHistory)
|
| 381 |
+
if (updated === prev.fileHistory) return prev
|
| 382 |
+
return { ...prev, fileHistory: updated }
|
| 383 |
+
})
|
| 384 |
+
},
|
| 385 |
+
updateAttributionState: (
|
| 386 |
+
updater: (prev: AttributionState) => AttributionState,
|
| 387 |
+
) => {
|
| 388 |
+
setAppState(prev => {
|
| 389 |
+
const updated = updater(prev.attribution)
|
| 390 |
+
if (updated === prev.attribution) return prev
|
| 391 |
+
return { ...prev, attribution: updated }
|
| 392 |
+
})
|
| 393 |
+
},
|
| 394 |
+
setSDKStatus,
|
| 395 |
+
}
|
| 396 |
+
|
| 397 |
+
// Handle orphaned permission (only once per engine lifetime)
|
| 398 |
+
if (orphanedPermission && !this.hasHandledOrphanedPermission) {
|
| 399 |
+
this.hasHandledOrphanedPermission = true
|
| 400 |
+
for await (const message of handleOrphanedPermission(
|
| 401 |
+
orphanedPermission,
|
| 402 |
+
tools,
|
| 403 |
+
this.mutableMessages,
|
| 404 |
+
processUserInputContext,
|
| 405 |
+
)) {
|
| 406 |
+
yield message
|
| 407 |
+
}
|
| 408 |
+
}
|
| 409 |
+
|
| 410 |
+
const {
|
| 411 |
+
messages: messagesFromUserInput,
|
| 412 |
+
shouldQuery,
|
| 413 |
+
allowedTools,
|
| 414 |
+
model: modelFromUserInput,
|
| 415 |
+
resultText,
|
| 416 |
+
} = await processUserInput({
|
| 417 |
+
input: prompt,
|
| 418 |
+
mode: 'prompt',
|
| 419 |
+
setToolJSX: () => {},
|
| 420 |
+
context: {
|
| 421 |
+
...processUserInputContext,
|
| 422 |
+
messages: this.mutableMessages,
|
| 423 |
+
},
|
| 424 |
+
messages: this.mutableMessages,
|
| 425 |
+
uuid: options?.uuid,
|
| 426 |
+
isMeta: options?.isMeta,
|
| 427 |
+
querySource: 'sdk',
|
| 428 |
+
})
|
| 429 |
+
|
| 430 |
+
// Push new messages, including user input and any attachments
|
| 431 |
+
this.mutableMessages.push(...messagesFromUserInput)
|
| 432 |
+
|
| 433 |
+
// Update params to reflect updates from processing /slash commands
|
| 434 |
+
const messages = [...this.mutableMessages]
|
| 435 |
+
|
| 436 |
+
// Persist the user's message(s) to transcript BEFORE entering the query
|
| 437 |
+
// loop. The for-await below only calls recordTranscript when ask() yields
|
| 438 |
+
// an assistant/user/compact_boundary message — which doesn't happen until
|
| 439 |
+
// the API responds. If the process is killed before that (e.g. user clicks
|
| 440 |
+
// Stop in cowork seconds after send), the transcript is left with only
|
| 441 |
+
// queue-operation entries; getLastSessionLog filters those out, returns
|
| 442 |
+
// null, and --resume fails with "No conversation found". Writing now makes
|
| 443 |
+
// the transcript resumable from the point the user message was accepted,
|
| 444 |
+
// even if no API response ever arrives.
|
| 445 |
+
//
|
| 446 |
+
// --bare / SIMPLE: fire-and-forget. Scripted calls don't --resume after
|
| 447 |
+
// kill-mid-request. The await is ~4ms on SSD, ~30ms under disk contention
|
| 448 |
+
// — the single largest controllable critical-path cost after module eval.
|
| 449 |
+
// Transcript is still written (for post-hoc debugging); just not blocking.
|
| 450 |
+
if (persistSession && messagesFromUserInput.length > 0) {
|
| 451 |
+
const transcriptPromise = recordTranscript(messages)
|
| 452 |
+
if (isBareMode()) {
|
| 453 |
+
void transcriptPromise
|
| 454 |
+
} else {
|
| 455 |
+
await transcriptPromise
|
| 456 |
+
if (
|
| 457 |
+
isEnvTruthy(process.env.CLAUDE_CODE_EAGER_FLUSH) ||
|
| 458 |
+
isEnvTruthy(process.env.CLAUDE_CODE_IS_COWORK)
|
| 459 |
+
) {
|
| 460 |
+
await flushSessionStorage()
|
| 461 |
+
}
|
| 462 |
+
}
|
| 463 |
+
}
|
| 464 |
+
|
| 465 |
+
// Filter messages that should be acknowledged after transcript
|
| 466 |
+
const replayableMessages = messagesFromUserInput.filter(
|
| 467 |
+
msg =>
|
| 468 |
+
(msg.type === 'user' &&
|
| 469 |
+
!msg.isMeta && // Skip synthetic caveat messages
|
| 470 |
+
!msg.toolUseResult && // Skip tool results (they'll be acked from query)
|
| 471 |
+
messageSelector().selectableUserMessagesFilter(msg)) || // Skip non-user-authored messages (task notifications, etc.)
|
| 472 |
+
(msg.type === 'system' && msg.subtype === 'compact_boundary'), // Always ack compact boundaries
|
| 473 |
+
)
|
| 474 |
+
const messagesToAck = replayUserMessages ? replayableMessages : []
|
| 475 |
+
|
| 476 |
+
// Update the ToolPermissionContext based on user input processing (as necessary)
|
| 477 |
+
setAppState(prev => ({
|
| 478 |
+
...prev,
|
| 479 |
+
toolPermissionContext: {
|
| 480 |
+
...prev.toolPermissionContext,
|
| 481 |
+
alwaysAllowRules: {
|
| 482 |
+
...prev.toolPermissionContext.alwaysAllowRules,
|
| 483 |
+
command: allowedTools,
|
| 484 |
+
},
|
| 485 |
+
},
|
| 486 |
+
}))
|
| 487 |
+
|
| 488 |
+
const mainLoopModel = modelFromUserInput ?? initialMainLoopModel
|
| 489 |
+
|
| 490 |
+
// Recreate after processing the prompt to pick up updated messages and
|
| 491 |
+
// model (from slash commands).
|
| 492 |
+
processUserInputContext = {
|
| 493 |
+
messages,
|
| 494 |
+
setMessages: () => {},
|
| 495 |
+
onChangeAPIKey: () => {},
|
| 496 |
+
handleElicitation: this.config.handleElicitation,
|
| 497 |
+
options: {
|
| 498 |
+
commands,
|
| 499 |
+
debug: false,
|
| 500 |
+
tools,
|
| 501 |
+
verbose,
|
| 502 |
+
mainLoopModel,
|
| 503 |
+
thinkingConfig: initialThinkingConfig,
|
| 504 |
+
mcpClients,
|
| 505 |
+
mcpResources: {},
|
| 506 |
+
ideInstallationStatus: null,
|
| 507 |
+
isNonInteractiveSession: true,
|
| 508 |
+
customSystemPrompt,
|
| 509 |
+
appendSystemPrompt,
|
| 510 |
+
theme: resolveThemeSetting(getGlobalConfig().theme),
|
| 511 |
+
agentDefinitions: { activeAgents: agents, allAgents: [] },
|
| 512 |
+
maxBudgetUsd,
|
| 513 |
+
},
|
| 514 |
+
getAppState,
|
| 515 |
+
setAppState,
|
| 516 |
+
abortController: this.abortController,
|
| 517 |
+
readFileState: this.readFileState,
|
| 518 |
+
nestedMemoryAttachmentTriggers: new Set<string>(),
|
| 519 |
+
loadedNestedMemoryPaths: this.loadedNestedMemoryPaths,
|
| 520 |
+
dynamicSkillDirTriggers: new Set<string>(),
|
| 521 |
+
discoveredSkillNames: this.discoveredSkillNames,
|
| 522 |
+
setInProgressToolUseIDs: () => {},
|
| 523 |
+
setResponseLength: () => {},
|
| 524 |
+
updateFileHistoryState: processUserInputContext.updateFileHistoryState,
|
| 525 |
+
updateAttributionState: processUserInputContext.updateAttributionState,
|
| 526 |
+
setSDKStatus,
|
| 527 |
+
}
|
| 528 |
+
|
| 529 |
+
headlessProfilerCheckpoint('before_skills_plugins')
|
| 530 |
+
// Cache-only: headless/SDK/CCR startup must not block on network for
|
| 531 |
+
// ref-tracked plugins. CCR populates the cache via CLAUDE_CODE_SYNC_PLUGIN_INSTALL
|
| 532 |
+
// (headlessPluginInstall) or CLAUDE_CODE_PLUGIN_SEED_DIR before this runs;
|
| 533 |
+
// SDK callers that need fresh source can call /reload-plugins.
|
| 534 |
+
const [skills, { enabled: enabledPlugins }] = await Promise.all([
|
| 535 |
+
getSlashCommandToolSkills(getCwd()),
|
| 536 |
+
loadAllPluginsCacheOnly(),
|
| 537 |
+
])
|
| 538 |
+
headlessProfilerCheckpoint('after_skills_plugins')
|
| 539 |
+
|
| 540 |
+
yield buildSystemInitMessage({
|
| 541 |
+
tools,
|
| 542 |
+
mcpClients,
|
| 543 |
+
model: mainLoopModel,
|
| 544 |
+
permissionMode: initialAppState.toolPermissionContext
|
| 545 |
+
.mode as PermissionMode, // TODO: avoid the cast
|
| 546 |
+
commands,
|
| 547 |
+
agents,
|
| 548 |
+
skills,
|
| 549 |
+
plugins: enabledPlugins,
|
| 550 |
+
fastMode: initialAppState.fastMode,
|
| 551 |
+
})
|
| 552 |
+
|
| 553 |
+
// Record when system message is yielded for headless latency tracking
|
| 554 |
+
headlessProfilerCheckpoint('system_message_yielded')
|
| 555 |
+
|
| 556 |
+
if (!shouldQuery) {
|
| 557 |
+
// Return the results of local slash commands.
|
| 558 |
+
// Use messagesFromUserInput (not replayableMessages) for command output
|
| 559 |
+
// because selectableUserMessagesFilter excludes local-command-stdout tags.
|
| 560 |
+
for (const msg of messagesFromUserInput) {
|
| 561 |
+
if (
|
| 562 |
+
msg.type === 'user' &&
|
| 563 |
+
typeof msg.message.content === 'string' &&
|
| 564 |
+
(msg.message.content.includes(`<${LOCAL_COMMAND_STDOUT_TAG}>`) ||
|
| 565 |
+
msg.message.content.includes(`<${LOCAL_COMMAND_STDERR_TAG}>`) ||
|
| 566 |
+
msg.isCompactSummary)
|
| 567 |
+
) {
|
| 568 |
+
yield {
|
| 569 |
+
type: 'user',
|
| 570 |
+
message: {
|
| 571 |
+
...msg.message,
|
| 572 |
+
content: stripAnsi(msg.message.content),
|
| 573 |
+
},
|
| 574 |
+
session_id: getSessionId(),
|
| 575 |
+
parent_tool_use_id: null,
|
| 576 |
+
uuid: msg.uuid,
|
| 577 |
+
timestamp: msg.timestamp,
|
| 578 |
+
isReplay: !msg.isCompactSummary,
|
| 579 |
+
isSynthetic: msg.isMeta || msg.isVisibleInTranscriptOnly,
|
| 580 |
+
} as SDKUserMessageReplay
|
| 581 |
+
}
|
| 582 |
+
|
| 583 |
+
// Local command output — yield as a synthetic assistant message so
|
| 584 |
+
// RC renders it as assistant-style text rather than a user bubble.
|
| 585 |
+
// Emitted as assistant (not the dedicated SDKLocalCommandOutputMessage
|
| 586 |
+
// system subtype) so mobile clients + session-ingress can parse it.
|
| 587 |
+
if (
|
| 588 |
+
msg.type === 'system' &&
|
| 589 |
+
msg.subtype === 'local_command' &&
|
| 590 |
+
typeof msg.content === 'string' &&
|
| 591 |
+
(msg.content.includes(`<${LOCAL_COMMAND_STDOUT_TAG}>`) ||
|
| 592 |
+
msg.content.includes(`<${LOCAL_COMMAND_STDERR_TAG}>`))
|
| 593 |
+
) {
|
| 594 |
+
yield localCommandOutputToSDKAssistantMessage(msg.content, msg.uuid)
|
| 595 |
+
}
|
| 596 |
+
|
| 597 |
+
if (msg.type === 'system' && msg.subtype === 'compact_boundary') {
|
| 598 |
+
yield {
|
| 599 |
+
type: 'system',
|
| 600 |
+
subtype: 'compact_boundary' as const,
|
| 601 |
+
session_id: getSessionId(),
|
| 602 |
+
uuid: msg.uuid,
|
| 603 |
+
compact_metadata: toSDKCompactMetadata(msg.compactMetadata),
|
| 604 |
+
} as SDKCompactBoundaryMessage
|
| 605 |
+
}
|
| 606 |
+
}
|
| 607 |
+
|
| 608 |
+
if (persistSession) {
|
| 609 |
+
await recordTranscript(messages)
|
| 610 |
+
if (
|
| 611 |
+
isEnvTruthy(process.env.CLAUDE_CODE_EAGER_FLUSH) ||
|
| 612 |
+
isEnvTruthy(process.env.CLAUDE_CODE_IS_COWORK)
|
| 613 |
+
) {
|
| 614 |
+
await flushSessionStorage()
|
| 615 |
+
}
|
| 616 |
+
}
|
| 617 |
+
|
| 618 |
+
yield {
|
| 619 |
+
type: 'result',
|
| 620 |
+
subtype: 'success',
|
| 621 |
+
is_error: false,
|
| 622 |
+
duration_ms: Date.now() - startTime,
|
| 623 |
+
duration_api_ms: getTotalAPIDuration(),
|
| 624 |
+
num_turns: messages.length - 1,
|
| 625 |
+
result: resultText ?? '',
|
| 626 |
+
stop_reason: null,
|
| 627 |
+
session_id: getSessionId(),
|
| 628 |
+
total_cost_usd: getTotalCost(),
|
| 629 |
+
usage: this.totalUsage,
|
| 630 |
+
modelUsage: getModelUsage(),
|
| 631 |
+
permission_denials: this.permissionDenials,
|
| 632 |
+
fast_mode_state: getFastModeState(
|
| 633 |
+
mainLoopModel,
|
| 634 |
+
initialAppState.fastMode,
|
| 635 |
+
),
|
| 636 |
+
uuid: randomUUID(),
|
| 637 |
+
}
|
| 638 |
+
return
|
| 639 |
+
}
|
| 640 |
+
|
| 641 |
+
if (fileHistoryEnabled() && persistSession) {
|
| 642 |
+
messagesFromUserInput
|
| 643 |
+
.filter(messageSelector().selectableUserMessagesFilter)
|
| 644 |
+
.forEach(message => {
|
| 645 |
+
void fileHistoryMakeSnapshot(
|
| 646 |
+
(updater: (prev: FileHistoryState) => FileHistoryState) => {
|
| 647 |
+
setAppState(prev => ({
|
| 648 |
+
...prev,
|
| 649 |
+
fileHistory: updater(prev.fileHistory),
|
| 650 |
+
}))
|
| 651 |
+
},
|
| 652 |
+
message.uuid,
|
| 653 |
+
)
|
| 654 |
+
})
|
| 655 |
+
}
|
| 656 |
+
|
| 657 |
+
// Track current message usage (reset on each message_start)
|
| 658 |
+
let currentMessageUsage: NonNullableUsage = EMPTY_USAGE
|
| 659 |
+
let turnCount = 1
|
| 660 |
+
let hasAcknowledgedInitialMessages = false
|
| 661 |
+
// Track structured output from StructuredOutput tool calls
|
| 662 |
+
let structuredOutputFromTool: unknown
|
| 663 |
+
// Track the last stop_reason from assistant messages
|
| 664 |
+
let lastStopReason: string | null = null
|
| 665 |
+
// Reference-based watermark so error_during_execution's errors[] is
|
| 666 |
+
// turn-scoped. A length-based index breaks when the 100-entry ring buffer
|
| 667 |
+
// shift()s during the turn — the index slides. If this entry is rotated
|
| 668 |
+
// out, lastIndexOf returns -1 and we include everything (safe fallback).
|
| 669 |
+
const errorLogWatermark = getInMemoryErrors().at(-1)
|
| 670 |
+
// Snapshot count before this query for delta-based retry limiting
|
| 671 |
+
const initialStructuredOutputCalls = jsonSchema
|
| 672 |
+
? countToolCalls(this.mutableMessages, SYNTHETIC_OUTPUT_TOOL_NAME)
|
| 673 |
+
: 0
|
| 674 |
+
|
| 675 |
+
for await (const message of query({
|
| 676 |
+
messages,
|
| 677 |
+
systemPrompt,
|
| 678 |
+
userContext,
|
| 679 |
+
systemContext,
|
| 680 |
+
canUseTool: wrappedCanUseTool,
|
| 681 |
+
toolUseContext: processUserInputContext,
|
| 682 |
+
fallbackModel,
|
| 683 |
+
querySource: 'sdk',
|
| 684 |
+
maxTurns,
|
| 685 |
+
taskBudget,
|
| 686 |
+
})) {
|
| 687 |
+
// Record assistant, user, and compact boundary messages
|
| 688 |
+
if (
|
| 689 |
+
message.type === 'assistant' ||
|
| 690 |
+
message.type === 'user' ||
|
| 691 |
+
(message.type === 'system' && message.subtype === 'compact_boundary')
|
| 692 |
+
) {
|
| 693 |
+
// Before writing a compact boundary, flush any in-memory-only
|
| 694 |
+
// messages up through the preservedSegment tail. Attachments and
|
| 695 |
+
// progress are now recorded inline (their switch cases below), but
|
| 696 |
+
// this flush still matters for the preservedSegment tail walk.
|
| 697 |
+
// If the SDK subprocess restarts before then (claude-desktop kills
|
| 698 |
+
// between turns), tailUuid points to a never-written message →
|
| 699 |
+
// applyPreservedSegmentRelinks fails its tail→head walk → returns
|
| 700 |
+
// without pruning → resume loads full pre-compact history.
|
| 701 |
+
if (
|
| 702 |
+
persistSession &&
|
| 703 |
+
message.type === 'system' &&
|
| 704 |
+
message.subtype === 'compact_boundary'
|
| 705 |
+
) {
|
| 706 |
+
const tailUuid = message.compactMetadata?.preservedSegment?.tailUuid
|
| 707 |
+
if (tailUuid) {
|
| 708 |
+
const tailIdx = this.mutableMessages.findLastIndex(
|
| 709 |
+
m => m.uuid === tailUuid,
|
| 710 |
+
)
|
| 711 |
+
if (tailIdx !== -1) {
|
| 712 |
+
await recordTranscript(this.mutableMessages.slice(0, tailIdx + 1))
|
| 713 |
+
}
|
| 714 |
+
}
|
| 715 |
+
}
|
| 716 |
+
messages.push(message)
|
| 717 |
+
if (persistSession) {
|
| 718 |
+
// Fire-and-forget for assistant messages. claude.ts yields one
|
| 719 |
+
// assistant message per content block, then mutates the last
|
| 720 |
+
// one's message.usage/stop_reason on message_delta — relying on
|
| 721 |
+
// the write queue's 100ms lazy jsonStringify. Awaiting here
|
| 722 |
+
// blocks ask()'s generator, so message_delta can't run until
|
| 723 |
+
// every block is consumed; the drain timer (started at block 1)
|
| 724 |
+
// elapses first. Interactive CC doesn't hit this because
|
| 725 |
+
// useLogMessages.ts fire-and-forgets. enqueueWrite is
|
| 726 |
+
// order-preserving so fire-and-forget here is safe.
|
| 727 |
+
if (message.type === 'assistant') {
|
| 728 |
+
void recordTranscript(messages)
|
| 729 |
+
} else {
|
| 730 |
+
await recordTranscript(messages)
|
| 731 |
+
}
|
| 732 |
+
}
|
| 733 |
+
|
| 734 |
+
// Acknowledge initial user messages after first transcript recording
|
| 735 |
+
if (!hasAcknowledgedInitialMessages && messagesToAck.length > 0) {
|
| 736 |
+
hasAcknowledgedInitialMessages = true
|
| 737 |
+
for (const msgToAck of messagesToAck) {
|
| 738 |
+
if (msgToAck.type === 'user') {
|
| 739 |
+
yield {
|
| 740 |
+
type: 'user',
|
| 741 |
+
message: msgToAck.message,
|
| 742 |
+
session_id: getSessionId(),
|
| 743 |
+
parent_tool_use_id: null,
|
| 744 |
+
uuid: msgToAck.uuid,
|
| 745 |
+
timestamp: msgToAck.timestamp,
|
| 746 |
+
isReplay: true,
|
| 747 |
+
} as SDKUserMessageReplay
|
| 748 |
+
}
|
| 749 |
+
}
|
| 750 |
+
}
|
| 751 |
+
}
|
| 752 |
+
|
| 753 |
+
if (message.type === 'user') {
|
| 754 |
+
turnCount++
|
| 755 |
+
}
|
| 756 |
+
|
| 757 |
+
switch (message.type) {
|
| 758 |
+
case 'tombstone':
|
| 759 |
+
// Tombstone messages are control signals for removing messages, skip them
|
| 760 |
+
break
|
| 761 |
+
case 'assistant':
|
| 762 |
+
// Capture stop_reason if already set (synthetic messages). For
|
| 763 |
+
// streamed responses, this is null at content_block_stop time;
|
| 764 |
+
// the real value arrives via message_delta (handled below).
|
| 765 |
+
if (message.message.stop_reason != null) {
|
| 766 |
+
lastStopReason = message.message.stop_reason
|
| 767 |
+
}
|
| 768 |
+
this.mutableMessages.push(message)
|
| 769 |
+
yield* normalizeMessage(message)
|
| 770 |
+
break
|
| 771 |
+
case 'progress':
|
| 772 |
+
this.mutableMessages.push(message)
|
| 773 |
+
// Record inline so the dedup loop in the next ask() call sees it
|
| 774 |
+
// as already-recorded. Without this, deferred progress interleaves
|
| 775 |
+
// with already-recorded tool_results in mutableMessages, and the
|
| 776 |
+
// dedup walk freezes startingParentUuid at the wrong message —
|
| 777 |
+
// forking the chain and orphaning the conversation on resume.
|
| 778 |
+
if (persistSession) {
|
| 779 |
+
messages.push(message)
|
| 780 |
+
void recordTranscript(messages)
|
| 781 |
+
}
|
| 782 |
+
yield* normalizeMessage(message)
|
| 783 |
+
break
|
| 784 |
+
case 'user':
|
| 785 |
+
this.mutableMessages.push(message)
|
| 786 |
+
yield* normalizeMessage(message)
|
| 787 |
+
break
|
| 788 |
+
case 'stream_event':
|
| 789 |
+
if (message.event.type === 'message_start') {
|
| 790 |
+
// Reset current message usage for new message
|
| 791 |
+
currentMessageUsage = EMPTY_USAGE
|
| 792 |
+
currentMessageUsage = updateUsage(
|
| 793 |
+
currentMessageUsage,
|
| 794 |
+
message.event.message.usage,
|
| 795 |
+
)
|
| 796 |
+
}
|
| 797 |
+
if (message.event.type === 'message_delta') {
|
| 798 |
+
currentMessageUsage = updateUsage(
|
| 799 |
+
currentMessageUsage,
|
| 800 |
+
message.event.usage,
|
| 801 |
+
)
|
| 802 |
+
// Capture stop_reason from message_delta. The assistant message
|
| 803 |
+
// is yielded at content_block_stop with stop_reason=null; the
|
| 804 |
+
// real value only arrives here (see claude.ts message_delta
|
| 805 |
+
// handler). Without this, result.stop_reason is always null.
|
| 806 |
+
if (message.event.delta.stop_reason != null) {
|
| 807 |
+
lastStopReason = message.event.delta.stop_reason
|
| 808 |
+
}
|
| 809 |
+
}
|
| 810 |
+
if (message.event.type === 'message_stop') {
|
| 811 |
+
// Accumulate current message usage into total
|
| 812 |
+
this.totalUsage = accumulateUsage(
|
| 813 |
+
this.totalUsage,
|
| 814 |
+
currentMessageUsage,
|
| 815 |
+
)
|
| 816 |
+
}
|
| 817 |
+
|
| 818 |
+
if (includePartialMessages) {
|
| 819 |
+
yield {
|
| 820 |
+
type: 'stream_event' as const,
|
| 821 |
+
event: message.event,
|
| 822 |
+
session_id: getSessionId(),
|
| 823 |
+
parent_tool_use_id: null,
|
| 824 |
+
uuid: randomUUID(),
|
| 825 |
+
}
|
| 826 |
+
}
|
| 827 |
+
|
| 828 |
+
break
|
| 829 |
+
case 'attachment':
|
| 830 |
+
this.mutableMessages.push(message)
|
| 831 |
+
// Record inline (same reason as progress above).
|
| 832 |
+
if (persistSession) {
|
| 833 |
+
messages.push(message)
|
| 834 |
+
void recordTranscript(messages)
|
| 835 |
+
}
|
| 836 |
+
|
| 837 |
+
// Extract structured output from StructuredOutput tool calls
|
| 838 |
+
if (message.attachment.type === 'structured_output') {
|
| 839 |
+
structuredOutputFromTool = message.attachment.data
|
| 840 |
+
}
|
| 841 |
+
// Handle max turns reached signal from query.ts
|
| 842 |
+
else if (message.attachment.type === 'max_turns_reached') {
|
| 843 |
+
if (persistSession) {
|
| 844 |
+
if (
|
| 845 |
+
isEnvTruthy(process.env.CLAUDE_CODE_EAGER_FLUSH) ||
|
| 846 |
+
isEnvTruthy(process.env.CLAUDE_CODE_IS_COWORK)
|
| 847 |
+
) {
|
| 848 |
+
await flushSessionStorage()
|
| 849 |
+
}
|
| 850 |
+
}
|
| 851 |
+
yield {
|
| 852 |
+
type: 'result',
|
| 853 |
+
subtype: 'error_max_turns',
|
| 854 |
+
duration_ms: Date.now() - startTime,
|
| 855 |
+
duration_api_ms: getTotalAPIDuration(),
|
| 856 |
+
is_error: true,
|
| 857 |
+
num_turns: message.attachment.turnCount,
|
| 858 |
+
stop_reason: lastStopReason,
|
| 859 |
+
session_id: getSessionId(),
|
| 860 |
+
total_cost_usd: getTotalCost(),
|
| 861 |
+
usage: this.totalUsage,
|
| 862 |
+
modelUsage: getModelUsage(),
|
| 863 |
+
permission_denials: this.permissionDenials,
|
| 864 |
+
fast_mode_state: getFastModeState(
|
| 865 |
+
mainLoopModel,
|
| 866 |
+
initialAppState.fastMode,
|
| 867 |
+
),
|
| 868 |
+
uuid: randomUUID(),
|
| 869 |
+
errors: [
|
| 870 |
+
`Reached maximum number of turns (${message.attachment.maxTurns})`,
|
| 871 |
+
],
|
| 872 |
+
}
|
| 873 |
+
return
|
| 874 |
+
}
|
| 875 |
+
// Yield queued_command attachments as SDK user message replays
|
| 876 |
+
else if (
|
| 877 |
+
replayUserMessages &&
|
| 878 |
+
message.attachment.type === 'queued_command'
|
| 879 |
+
) {
|
| 880 |
+
yield {
|
| 881 |
+
type: 'user',
|
| 882 |
+
message: {
|
| 883 |
+
role: 'user' as const,
|
| 884 |
+
content: message.attachment.prompt,
|
| 885 |
+
},
|
| 886 |
+
session_id: getSessionId(),
|
| 887 |
+
parent_tool_use_id: null,
|
| 888 |
+
uuid: message.attachment.source_uuid || message.uuid,
|
| 889 |
+
timestamp: message.timestamp,
|
| 890 |
+
isReplay: true,
|
| 891 |
+
} as SDKUserMessageReplay
|
| 892 |
+
}
|
| 893 |
+
break
|
| 894 |
+
case 'stream_request_start':
|
| 895 |
+
// Don't yield stream request start messages
|
| 896 |
+
break
|
| 897 |
+
case 'system': {
|
| 898 |
+
// Snip boundary: replay on our store to remove zombie messages and
|
| 899 |
+
// stale markers. The yielded boundary is a signal, not data to push —
|
| 900 |
+
// the replay produces its own equivalent boundary. Without this,
|
| 901 |
+
// markers persist and re-trigger on every turn, and mutableMessages
|
| 902 |
+
// never shrinks (memory leak in long SDK sessions). The subtype
|
| 903 |
+
// check lives inside the injected callback so feature-gated strings
|
| 904 |
+
// stay out of this file (excluded-strings check).
|
| 905 |
+
const snipResult = this.config.snipReplay?.(
|
| 906 |
+
message,
|
| 907 |
+
this.mutableMessages,
|
| 908 |
+
)
|
| 909 |
+
if (snipResult !== undefined) {
|
| 910 |
+
if (snipResult.executed) {
|
| 911 |
+
this.mutableMessages.length = 0
|
| 912 |
+
this.mutableMessages.push(...snipResult.messages)
|
| 913 |
+
}
|
| 914 |
+
break
|
| 915 |
+
}
|
| 916 |
+
this.mutableMessages.push(message)
|
| 917 |
+
// Yield compact boundary messages to SDK
|
| 918 |
+
if (
|
| 919 |
+
message.subtype === 'compact_boundary' &&
|
| 920 |
+
message.compactMetadata
|
| 921 |
+
) {
|
| 922 |
+
// Release pre-compaction messages for GC. The boundary was just
|
| 923 |
+
// pushed so it's the last element. query.ts already uses
|
| 924 |
+
// getMessagesAfterCompactBoundary() internally, so only
|
| 925 |
+
// post-boundary messages are needed going forward.
|
| 926 |
+
const mutableBoundaryIdx = this.mutableMessages.length - 1
|
| 927 |
+
if (mutableBoundaryIdx > 0) {
|
| 928 |
+
this.mutableMessages.splice(0, mutableBoundaryIdx)
|
| 929 |
+
}
|
| 930 |
+
const localBoundaryIdx = messages.length - 1
|
| 931 |
+
if (localBoundaryIdx > 0) {
|
| 932 |
+
messages.splice(0, localBoundaryIdx)
|
| 933 |
+
}
|
| 934 |
+
|
| 935 |
+
yield {
|
| 936 |
+
type: 'system',
|
| 937 |
+
subtype: 'compact_boundary' as const,
|
| 938 |
+
session_id: getSessionId(),
|
| 939 |
+
uuid: message.uuid,
|
| 940 |
+
compact_metadata: toSDKCompactMetadata(message.compactMetadata),
|
| 941 |
+
}
|
| 942 |
+
}
|
| 943 |
+
if (message.subtype === 'api_error') {
|
| 944 |
+
yield {
|
| 945 |
+
type: 'system',
|
| 946 |
+
subtype: 'api_retry' as const,
|
| 947 |
+
attempt: message.retryAttempt,
|
| 948 |
+
max_retries: message.maxRetries,
|
| 949 |
+
retry_delay_ms: message.retryInMs,
|
| 950 |
+
error_status: message.error.status ?? null,
|
| 951 |
+
error: categorizeRetryableAPIError(message.error),
|
| 952 |
+
session_id: getSessionId(),
|
| 953 |
+
uuid: message.uuid,
|
| 954 |
+
}
|
| 955 |
+
}
|
| 956 |
+
// Don't yield other system messages in headless mode
|
| 957 |
+
break
|
| 958 |
+
}
|
| 959 |
+
case 'tool_use_summary':
|
| 960 |
+
// Yield tool use summary messages to SDK
|
| 961 |
+
yield {
|
| 962 |
+
type: 'tool_use_summary' as const,
|
| 963 |
+
summary: message.summary,
|
| 964 |
+
preceding_tool_use_ids: message.precedingToolUseIds,
|
| 965 |
+
session_id: getSessionId(),
|
| 966 |
+
uuid: message.uuid,
|
| 967 |
+
}
|
| 968 |
+
break
|
| 969 |
+
}
|
| 970 |
+
|
| 971 |
+
// Check if USD budget has been exceeded
|
| 972 |
+
if (maxBudgetUsd !== undefined && getTotalCost() >= maxBudgetUsd) {
|
| 973 |
+
if (persistSession) {
|
| 974 |
+
if (
|
| 975 |
+
isEnvTruthy(process.env.CLAUDE_CODE_EAGER_FLUSH) ||
|
| 976 |
+
isEnvTruthy(process.env.CLAUDE_CODE_IS_COWORK)
|
| 977 |
+
) {
|
| 978 |
+
await flushSessionStorage()
|
| 979 |
+
}
|
| 980 |
+
}
|
| 981 |
+
yield {
|
| 982 |
+
type: 'result',
|
| 983 |
+
subtype: 'error_max_budget_usd',
|
| 984 |
+
duration_ms: Date.now() - startTime,
|
| 985 |
+
duration_api_ms: getTotalAPIDuration(),
|
| 986 |
+
is_error: true,
|
| 987 |
+
num_turns: turnCount,
|
| 988 |
+
stop_reason: lastStopReason,
|
| 989 |
+
session_id: getSessionId(),
|
| 990 |
+
total_cost_usd: getTotalCost(),
|
| 991 |
+
usage: this.totalUsage,
|
| 992 |
+
modelUsage: getModelUsage(),
|
| 993 |
+
permission_denials: this.permissionDenials,
|
| 994 |
+
fast_mode_state: getFastModeState(
|
| 995 |
+
mainLoopModel,
|
| 996 |
+
initialAppState.fastMode,
|
| 997 |
+
),
|
| 998 |
+
uuid: randomUUID(),
|
| 999 |
+
errors: [`Reached maximum budget ($${maxBudgetUsd})`],
|
| 1000 |
+
}
|
| 1001 |
+
return
|
| 1002 |
+
}
|
| 1003 |
+
|
| 1004 |
+
// Check if structured output retry limit exceeded (only on user messages)
|
| 1005 |
+
if (message.type === 'user' && jsonSchema) {
|
| 1006 |
+
const currentCalls = countToolCalls(
|
| 1007 |
+
this.mutableMessages,
|
| 1008 |
+
SYNTHETIC_OUTPUT_TOOL_NAME,
|
| 1009 |
+
)
|
| 1010 |
+
const callsThisQuery = currentCalls - initialStructuredOutputCalls
|
| 1011 |
+
const maxRetries = parseInt(
|
| 1012 |
+
process.env.MAX_STRUCTURED_OUTPUT_RETRIES || '5',
|
| 1013 |
+
10,
|
| 1014 |
+
)
|
| 1015 |
+
if (callsThisQuery >= maxRetries) {
|
| 1016 |
+
if (persistSession) {
|
| 1017 |
+
if (
|
| 1018 |
+
isEnvTruthy(process.env.CLAUDE_CODE_EAGER_FLUSH) ||
|
| 1019 |
+
isEnvTruthy(process.env.CLAUDE_CODE_IS_COWORK)
|
| 1020 |
+
) {
|
| 1021 |
+
await flushSessionStorage()
|
| 1022 |
+
}
|
| 1023 |
+
}
|
| 1024 |
+
yield {
|
| 1025 |
+
type: 'result',
|
| 1026 |
+
subtype: 'error_max_structured_output_retries',
|
| 1027 |
+
duration_ms: Date.now() - startTime,
|
| 1028 |
+
duration_api_ms: getTotalAPIDuration(),
|
| 1029 |
+
is_error: true,
|
| 1030 |
+
num_turns: turnCount,
|
| 1031 |
+
stop_reason: lastStopReason,
|
| 1032 |
+
session_id: getSessionId(),
|
| 1033 |
+
total_cost_usd: getTotalCost(),
|
| 1034 |
+
usage: this.totalUsage,
|
| 1035 |
+
modelUsage: getModelUsage(),
|
| 1036 |
+
permission_denials: this.permissionDenials,
|
| 1037 |
+
fast_mode_state: getFastModeState(
|
| 1038 |
+
mainLoopModel,
|
| 1039 |
+
initialAppState.fastMode,
|
| 1040 |
+
),
|
| 1041 |
+
uuid: randomUUID(),
|
| 1042 |
+
errors: [
|
| 1043 |
+
`Failed to provide valid structured output after ${maxRetries} attempts`,
|
| 1044 |
+
],
|
| 1045 |
+
}
|
| 1046 |
+
return
|
| 1047 |
+
}
|
| 1048 |
+
}
|
| 1049 |
+
}
|
| 1050 |
+
|
| 1051 |
+
// Stop hooks yield progress/attachment messages AFTER the assistant
|
| 1052 |
+
// response (via yield* handleStopHooks in query.ts). Since #23537 pushes
|
| 1053 |
+
// those to `messages` inline, last(messages) can be a progress/attachment
|
| 1054 |
+
// instead of the assistant — which makes textResult extraction below
|
| 1055 |
+
// return '' and -p mode emit a blank line. Allowlist to assistant|user:
|
| 1056 |
+
// isResultSuccessful handles both (user with all tool_result blocks is a
|
| 1057 |
+
// valid successful terminal state).
|
| 1058 |
+
const result = messages.findLast(
|
| 1059 |
+
m => m.type === 'assistant' || m.type === 'user',
|
| 1060 |
+
)
|
| 1061 |
+
// Capture for the error_during_execution diagnostic — isResultSuccessful
|
| 1062 |
+
// is a type predicate (message is Message), so inside the false branch
|
| 1063 |
+
// `result` narrows to never and these accesses don't typecheck.
|
| 1064 |
+
const edeResultType = result?.type ?? 'undefined'
|
| 1065 |
+
const edeLastContentType =
|
| 1066 |
+
result?.type === 'assistant'
|
| 1067 |
+
? (last(result.message.content)?.type ?? 'none')
|
| 1068 |
+
: 'n/a'
|
| 1069 |
+
|
| 1070 |
+
// Flush buffered transcript writes before yielding result.
|
| 1071 |
+
// The desktop app kills the CLI process immediately after receiving the
|
| 1072 |
+
// result message, so any unflushed writes would be lost.
|
| 1073 |
+
if (persistSession) {
|
| 1074 |
+
if (
|
| 1075 |
+
isEnvTruthy(process.env.CLAUDE_CODE_EAGER_FLUSH) ||
|
| 1076 |
+
isEnvTruthy(process.env.CLAUDE_CODE_IS_COWORK)
|
| 1077 |
+
) {
|
| 1078 |
+
await flushSessionStorage()
|
| 1079 |
+
}
|
| 1080 |
+
}
|
| 1081 |
+
|
| 1082 |
+
if (!isResultSuccessful(result, lastStopReason)) {
|
| 1083 |
+
yield {
|
| 1084 |
+
type: 'result',
|
| 1085 |
+
subtype: 'error_during_execution',
|
| 1086 |
+
duration_ms: Date.now() - startTime,
|
| 1087 |
+
duration_api_ms: getTotalAPIDuration(),
|
| 1088 |
+
is_error: true,
|
| 1089 |
+
num_turns: turnCount,
|
| 1090 |
+
stop_reason: lastStopReason,
|
| 1091 |
+
session_id: getSessionId(),
|
| 1092 |
+
total_cost_usd: getTotalCost(),
|
| 1093 |
+
usage: this.totalUsage,
|
| 1094 |
+
modelUsage: getModelUsage(),
|
| 1095 |
+
permission_denials: this.permissionDenials,
|
| 1096 |
+
fast_mode_state: getFastModeState(
|
| 1097 |
+
mainLoopModel,
|
| 1098 |
+
initialAppState.fastMode,
|
| 1099 |
+
),
|
| 1100 |
+
uuid: randomUUID(),
|
| 1101 |
+
// Diagnostic prefix: these are what isResultSuccessful() checks — if
|
| 1102 |
+
// the result type isn't assistant-with-text/thinking or user-with-
|
| 1103 |
+
// tool_result, and stop_reason isn't end_turn, that's why this fired.
|
| 1104 |
+
// errors[] is turn-scoped via the watermark; previously it dumped the
|
| 1105 |
+
// entire process's logError buffer (ripgrep timeouts, ENOENT, etc).
|
| 1106 |
+
errors: (() => {
|
| 1107 |
+
const all = getInMemoryErrors()
|
| 1108 |
+
const start = errorLogWatermark
|
| 1109 |
+
? all.lastIndexOf(errorLogWatermark) + 1
|
| 1110 |
+
: 0
|
| 1111 |
+
return [
|
| 1112 |
+
`[ede_diagnostic] result_type=${edeResultType} last_content_type=${edeLastContentType} stop_reason=${lastStopReason}`,
|
| 1113 |
+
...all.slice(start).map(_ => _.error),
|
| 1114 |
+
]
|
| 1115 |
+
})(),
|
| 1116 |
+
}
|
| 1117 |
+
return
|
| 1118 |
+
}
|
| 1119 |
+
|
| 1120 |
+
// Extract the text result based on message type
|
| 1121 |
+
let textResult = ''
|
| 1122 |
+
let isApiError = false
|
| 1123 |
+
|
| 1124 |
+
if (result.type === 'assistant') {
|
| 1125 |
+
const lastContent = last(result.message.content)
|
| 1126 |
+
if (
|
| 1127 |
+
lastContent?.type === 'text' &&
|
| 1128 |
+
!SYNTHETIC_MESSAGES.has(lastContent.text)
|
| 1129 |
+
) {
|
| 1130 |
+
textResult = lastContent.text
|
| 1131 |
+
}
|
| 1132 |
+
isApiError = Boolean(result.isApiErrorMessage)
|
| 1133 |
+
}
|
| 1134 |
+
|
| 1135 |
+
yield {
|
| 1136 |
+
type: 'result',
|
| 1137 |
+
subtype: 'success',
|
| 1138 |
+
is_error: isApiError,
|
| 1139 |
+
duration_ms: Date.now() - startTime,
|
| 1140 |
+
duration_api_ms: getTotalAPIDuration(),
|
| 1141 |
+
num_turns: turnCount,
|
| 1142 |
+
result: textResult,
|
| 1143 |
+
stop_reason: lastStopReason,
|
| 1144 |
+
session_id: getSessionId(),
|
| 1145 |
+
total_cost_usd: getTotalCost(),
|
| 1146 |
+
usage: this.totalUsage,
|
| 1147 |
+
modelUsage: getModelUsage(),
|
| 1148 |
+
permission_denials: this.permissionDenials,
|
| 1149 |
+
structured_output: structuredOutputFromTool,
|
| 1150 |
+
fast_mode_state: getFastModeState(
|
| 1151 |
+
mainLoopModel,
|
| 1152 |
+
initialAppState.fastMode,
|
| 1153 |
+
),
|
| 1154 |
+
uuid: randomUUID(),
|
| 1155 |
+
}
|
| 1156 |
+
}
|
| 1157 |
+
|
| 1158 |
+
interrupt(): void {
|
| 1159 |
+
this.abortController.abort()
|
| 1160 |
+
}
|
| 1161 |
+
|
| 1162 |
+
getMessages(): readonly Message[] {
|
| 1163 |
+
return this.mutableMessages
|
| 1164 |
+
}
|
| 1165 |
+
|
| 1166 |
+
getReadFileState(): FileStateCache {
|
| 1167 |
+
return this.readFileState
|
| 1168 |
+
}
|
| 1169 |
+
|
| 1170 |
+
getSessionId(): string {
|
| 1171 |
+
return getSessionId()
|
| 1172 |
+
}
|
| 1173 |
+
|
| 1174 |
+
setModel(model: string): void {
|
| 1175 |
+
this.config.userSpecifiedModel = model
|
| 1176 |
+
}
|
| 1177 |
+
}
|
| 1178 |
+
|
| 1179 |
+
/**
|
| 1180 |
+
* Sends a single prompt to the Claude API and returns the response.
|
| 1181 |
+
* Assumes that claude is being used non-interactively -- will not
|
| 1182 |
+
* ask the user for permissions or further input.
|
| 1183 |
+
*
|
| 1184 |
+
* Convenience wrapper around QueryEngine for one-shot usage.
|
| 1185 |
+
*/
|
| 1186 |
+
export async function* ask({
|
| 1187 |
+
commands,
|
| 1188 |
+
prompt,
|
| 1189 |
+
promptUuid,
|
| 1190 |
+
isMeta,
|
| 1191 |
+
cwd,
|
| 1192 |
+
tools,
|
| 1193 |
+
mcpClients,
|
| 1194 |
+
verbose = false,
|
| 1195 |
+
thinkingConfig,
|
| 1196 |
+
maxTurns,
|
| 1197 |
+
maxBudgetUsd,
|
| 1198 |
+
taskBudget,
|
| 1199 |
+
canUseTool,
|
| 1200 |
+
mutableMessages = [],
|
| 1201 |
+
getReadFileCache,
|
| 1202 |
+
setReadFileCache,
|
| 1203 |
+
customSystemPrompt,
|
| 1204 |
+
appendSystemPrompt,
|
| 1205 |
+
userSpecifiedModel,
|
| 1206 |
+
fallbackModel,
|
| 1207 |
+
jsonSchema,
|
| 1208 |
+
getAppState,
|
| 1209 |
+
setAppState,
|
| 1210 |
+
abortController,
|
| 1211 |
+
replayUserMessages = false,
|
| 1212 |
+
includePartialMessages = false,
|
| 1213 |
+
handleElicitation,
|
| 1214 |
+
agents = [],
|
| 1215 |
+
setSDKStatus,
|
| 1216 |
+
orphanedPermission,
|
| 1217 |
+
}: {
|
| 1218 |
+
commands: Command[]
|
| 1219 |
+
prompt: string | Array<ContentBlockParam>
|
| 1220 |
+
promptUuid?: string
|
| 1221 |
+
isMeta?: boolean
|
| 1222 |
+
cwd: string
|
| 1223 |
+
tools: Tools
|
| 1224 |
+
verbose?: boolean
|
| 1225 |
+
mcpClients: MCPServerConnection[]
|
| 1226 |
+
thinkingConfig?: ThinkingConfig
|
| 1227 |
+
maxTurns?: number
|
| 1228 |
+
maxBudgetUsd?: number
|
| 1229 |
+
taskBudget?: { total: number }
|
| 1230 |
+
canUseTool: CanUseToolFn
|
| 1231 |
+
mutableMessages?: Message[]
|
| 1232 |
+
customSystemPrompt?: string
|
| 1233 |
+
appendSystemPrompt?: string
|
| 1234 |
+
userSpecifiedModel?: string
|
| 1235 |
+
fallbackModel?: string
|
| 1236 |
+
jsonSchema?: Record<string, unknown>
|
| 1237 |
+
getAppState: () => AppState
|
| 1238 |
+
setAppState: (f: (prev: AppState) => AppState) => void
|
| 1239 |
+
getReadFileCache: () => FileStateCache
|
| 1240 |
+
setReadFileCache: (cache: FileStateCache) => void
|
| 1241 |
+
abortController?: AbortController
|
| 1242 |
+
replayUserMessages?: boolean
|
| 1243 |
+
includePartialMessages?: boolean
|
| 1244 |
+
handleElicitation?: ToolUseContext['handleElicitation']
|
| 1245 |
+
agents?: AgentDefinition[]
|
| 1246 |
+
setSDKStatus?: (status: SDKStatus) => void
|
| 1247 |
+
orphanedPermission?: OrphanedPermission
|
| 1248 |
+
}): AsyncGenerator<SDKMessage, void, unknown> {
|
| 1249 |
+
const engine = new QueryEngine({
|
| 1250 |
+
cwd,
|
| 1251 |
+
tools,
|
| 1252 |
+
commands,
|
| 1253 |
+
mcpClients,
|
| 1254 |
+
agents,
|
| 1255 |
+
canUseTool,
|
| 1256 |
+
getAppState,
|
| 1257 |
+
setAppState,
|
| 1258 |
+
initialMessages: mutableMessages,
|
| 1259 |
+
readFileCache: cloneFileStateCache(getReadFileCache()),
|
| 1260 |
+
customSystemPrompt,
|
| 1261 |
+
appendSystemPrompt,
|
| 1262 |
+
userSpecifiedModel,
|
| 1263 |
+
fallbackModel,
|
| 1264 |
+
thinkingConfig,
|
| 1265 |
+
maxTurns,
|
| 1266 |
+
maxBudgetUsd,
|
| 1267 |
+
taskBudget,
|
| 1268 |
+
jsonSchema,
|
| 1269 |
+
verbose,
|
| 1270 |
+
handleElicitation,
|
| 1271 |
+
replayUserMessages,
|
| 1272 |
+
includePartialMessages,
|
| 1273 |
+
setSDKStatus,
|
| 1274 |
+
abortController,
|
| 1275 |
+
orphanedPermission,
|
| 1276 |
+
...(feature('HISTORY_SNIP')
|
| 1277 |
+
? {
|
| 1278 |
+
snipReplay: (yielded: Message, store: Message[]) => {
|
| 1279 |
+
if (!snipProjection!.isSnipBoundaryMessage(yielded))
|
| 1280 |
+
return undefined
|
| 1281 |
+
return snipModule!.snipCompactIfNeeded(store, { force: true })
|
| 1282 |
+
},
|
| 1283 |
+
}
|
| 1284 |
+
: {}),
|
| 1285 |
+
})
|
| 1286 |
+
|
| 1287 |
+
try {
|
| 1288 |
+
yield* engine.submitMessage(prompt, {
|
| 1289 |
+
uuid: promptUuid,
|
| 1290 |
+
isMeta,
|
| 1291 |
+
})
|
| 1292 |
+
} finally {
|
| 1293 |
+
setReadFileCache(engine.getReadFileState())
|
| 1294 |
+
}
|
| 1295 |
+
}
|
src/Task.ts
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { randomBytes } from 'crypto'
|
| 2 |
+
import type { AppState } from './state/AppState.js'
|
| 3 |
+
import type { AgentId } from './types/ids.js'
|
| 4 |
+
import { getTaskOutputPath } from './utils/task/diskOutput.js'
|
| 5 |
+
|
| 6 |
+
export type TaskType =
|
| 7 |
+
| 'local_bash'
|
| 8 |
+
| 'local_agent'
|
| 9 |
+
| 'remote_agent'
|
| 10 |
+
| 'in_process_teammate'
|
| 11 |
+
| 'local_workflow'
|
| 12 |
+
| 'monitor_mcp'
|
| 13 |
+
| 'dream'
|
| 14 |
+
|
| 15 |
+
export type TaskStatus =
|
| 16 |
+
| 'pending'
|
| 17 |
+
| 'running'
|
| 18 |
+
| 'completed'
|
| 19 |
+
| 'failed'
|
| 20 |
+
| 'killed'
|
| 21 |
+
|
| 22 |
+
/**
|
| 23 |
+
* True when a task is in a terminal state and will not transition further.
|
| 24 |
+
* Used to guard against injecting messages into dead teammates, evicting
|
| 25 |
+
* finished tasks from AppState, and orphan-cleanup paths.
|
| 26 |
+
*/
|
| 27 |
+
export function isTerminalTaskStatus(status: TaskStatus): boolean {
|
| 28 |
+
return status === 'completed' || status === 'failed' || status === 'killed'
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
export type TaskHandle = {
|
| 32 |
+
taskId: string
|
| 33 |
+
cleanup?: () => void
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
export type SetAppState = (f: (prev: AppState) => AppState) => void
|
| 37 |
+
|
| 38 |
+
export type TaskContext = {
|
| 39 |
+
abortController: AbortController
|
| 40 |
+
getAppState: () => AppState
|
| 41 |
+
setAppState: SetAppState
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
// Base fields shared by all task states
|
| 45 |
+
export type TaskStateBase = {
|
| 46 |
+
id: string
|
| 47 |
+
type: TaskType
|
| 48 |
+
status: TaskStatus
|
| 49 |
+
description: string
|
| 50 |
+
toolUseId?: string
|
| 51 |
+
startTime: number
|
| 52 |
+
endTime?: number
|
| 53 |
+
totalPausedMs?: number
|
| 54 |
+
outputFile: string
|
| 55 |
+
outputOffset: number
|
| 56 |
+
notified: boolean
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
export type LocalShellSpawnInput = {
|
| 60 |
+
command: string
|
| 61 |
+
description: string
|
| 62 |
+
timeout?: number
|
| 63 |
+
toolUseId?: string
|
| 64 |
+
agentId?: AgentId
|
| 65 |
+
/** UI display variant: description-as-label, dialog title, status bar pill. */
|
| 66 |
+
kind?: 'bash' | 'monitor'
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
// What getTaskByType dispatches for: kill. spawn/render were never
|
| 70 |
+
// called polymorphically (removed in #22546). All six kill implementations
|
| 71 |
+
// use only setAppState — getAppState/abortController were dead weight.
|
| 72 |
+
export type Task = {
|
| 73 |
+
name: string
|
| 74 |
+
type: TaskType
|
| 75 |
+
kill(taskId: string, setAppState: SetAppState): Promise<void>
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
// Task ID prefixes
|
| 79 |
+
const TASK_ID_PREFIXES: Record<string, string> = {
|
| 80 |
+
local_bash: 'b', // Keep as 'b' for backward compatibility
|
| 81 |
+
local_agent: 'a',
|
| 82 |
+
remote_agent: 'r',
|
| 83 |
+
in_process_teammate: 't',
|
| 84 |
+
local_workflow: 'w',
|
| 85 |
+
monitor_mcp: 'm',
|
| 86 |
+
dream: 'd',
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
// Get task ID prefix
|
| 90 |
+
function getTaskIdPrefix(type: TaskType): string {
|
| 91 |
+
return TASK_ID_PREFIXES[type] ?? 'x'
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
// Case-insensitive-safe alphabet (digits + lowercase) for task IDs.
|
| 95 |
+
// 36^8 ≈ 2.8 trillion combinations, sufficient to resist brute-force symlink attacks.
|
| 96 |
+
const TASK_ID_ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyz'
|
| 97 |
+
|
| 98 |
+
export function generateTaskId(type: TaskType): string {
|
| 99 |
+
const prefix = getTaskIdPrefix(type)
|
| 100 |
+
const bytes = randomBytes(8)
|
| 101 |
+
let id = prefix
|
| 102 |
+
for (let i = 0; i < 8; i++) {
|
| 103 |
+
id += TASK_ID_ALPHABET[bytes[i]! % TASK_ID_ALPHABET.length]
|
| 104 |
+
}
|
| 105 |
+
return id
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
export function createTaskStateBase(
|
| 109 |
+
id: string,
|
| 110 |
+
type: TaskType,
|
| 111 |
+
description: string,
|
| 112 |
+
toolUseId?: string,
|
| 113 |
+
): TaskStateBase {
|
| 114 |
+
return {
|
| 115 |
+
id,
|
| 116 |
+
type,
|
| 117 |
+
status: 'pending',
|
| 118 |
+
description,
|
| 119 |
+
toolUseId,
|
| 120 |
+
startTime: Date.now(),
|
| 121 |
+
outputFile: getTaskOutputPath(id),
|
| 122 |
+
outputOffset: 0,
|
| 123 |
+
notified: false,
|
| 124 |
+
}
|
| 125 |
+
}
|
src/Tool.ts
ADDED
|
@@ -0,0 +1,792 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type {
|
| 2 |
+
ToolResultBlockParam,
|
| 3 |
+
ToolUseBlockParam,
|
| 4 |
+
} from '@anthropic-ai/sdk/resources/index.mjs'
|
| 5 |
+
import type {
|
| 6 |
+
ElicitRequestURLParams,
|
| 7 |
+
ElicitResult,
|
| 8 |
+
} from '@modelcontextprotocol/sdk/types.js'
|
| 9 |
+
import type { UUID } from 'crypto'
|
| 10 |
+
import type { z } from 'zod/v4'
|
| 11 |
+
import type { Command } from './commands.js'
|
| 12 |
+
import type { CanUseToolFn } from './hooks/useCanUseTool.js'
|
| 13 |
+
import type { ThinkingConfig } from './utils/thinking.js'
|
| 14 |
+
|
| 15 |
+
export type ToolInputJSONSchema = {
|
| 16 |
+
[x: string]: unknown
|
| 17 |
+
type: 'object'
|
| 18 |
+
properties?: {
|
| 19 |
+
[x: string]: unknown
|
| 20 |
+
}
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
import type { Notification } from './context/notifications.js'
|
| 24 |
+
import type {
|
| 25 |
+
MCPServerConnection,
|
| 26 |
+
ServerResource,
|
| 27 |
+
} from './services/mcp/types.js'
|
| 28 |
+
import type {
|
| 29 |
+
AgentDefinition,
|
| 30 |
+
AgentDefinitionsResult,
|
| 31 |
+
} from './tools/AgentTool/loadAgentsDir.js'
|
| 32 |
+
import type {
|
| 33 |
+
AssistantMessage,
|
| 34 |
+
AttachmentMessage,
|
| 35 |
+
Message,
|
| 36 |
+
ProgressMessage,
|
| 37 |
+
SystemLocalCommandMessage,
|
| 38 |
+
SystemMessage,
|
| 39 |
+
UserMessage,
|
| 40 |
+
} from './types/message.js'
|
| 41 |
+
// Import permission types from centralized location to break import cycles
|
| 42 |
+
// Import PermissionResult from centralized location to break import cycles
|
| 43 |
+
import type {
|
| 44 |
+
AdditionalWorkingDirectory,
|
| 45 |
+
PermissionMode,
|
| 46 |
+
PermissionResult,
|
| 47 |
+
} from './types/permissions.js'
|
| 48 |
+
// Import tool progress types from centralized location to break import cycles
|
| 49 |
+
import type {
|
| 50 |
+
AgentToolProgress,
|
| 51 |
+
BashProgress,
|
| 52 |
+
MCPProgress,
|
| 53 |
+
REPLToolProgress,
|
| 54 |
+
SkillToolProgress,
|
| 55 |
+
TaskOutputProgress,
|
| 56 |
+
ToolProgressData,
|
| 57 |
+
WebSearchProgress,
|
| 58 |
+
} from './types/tools.js'
|
| 59 |
+
import type { FileStateCache } from './utils/fileStateCache.js'
|
| 60 |
+
import type { DenialTrackingState } from './utils/permissions/denialTracking.js'
|
| 61 |
+
import type { SystemPrompt } from './utils/systemPromptType.js'
|
| 62 |
+
import type { ContentReplacementState } from './utils/toolResultStorage.js'
|
| 63 |
+
|
| 64 |
+
// Re-export progress types for backwards compatibility
|
| 65 |
+
export type {
|
| 66 |
+
AgentToolProgress,
|
| 67 |
+
BashProgress,
|
| 68 |
+
MCPProgress,
|
| 69 |
+
REPLToolProgress,
|
| 70 |
+
SkillToolProgress,
|
| 71 |
+
TaskOutputProgress,
|
| 72 |
+
WebSearchProgress,
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
import type { SpinnerMode } from './components/Spinner.js'
|
| 76 |
+
import type { QuerySource } from './constants/querySource.js'
|
| 77 |
+
import type { SDKStatus } from './entrypoints/agentSdkTypes.js'
|
| 78 |
+
import type { AppState } from './state/AppState.js'
|
| 79 |
+
import type {
|
| 80 |
+
HookProgress,
|
| 81 |
+
PromptRequest,
|
| 82 |
+
PromptResponse,
|
| 83 |
+
} from './types/hooks.js'
|
| 84 |
+
import type { AgentId } from './types/ids.js'
|
| 85 |
+
import type { DeepImmutable } from './types/utils.js'
|
| 86 |
+
import type { AttributionState } from './utils/commitAttribution.js'
|
| 87 |
+
import type { FileHistoryState } from './utils/fileHistory.js'
|
| 88 |
+
import type { Theme, ThemeName } from './utils/theme.js'
|
| 89 |
+
|
| 90 |
+
export type QueryChainTracking = {
|
| 91 |
+
chainId: string
|
| 92 |
+
depth: number
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
export type ValidationResult =
|
| 96 |
+
| { result: true }
|
| 97 |
+
| {
|
| 98 |
+
result: false
|
| 99 |
+
message: string
|
| 100 |
+
errorCode: number
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
export type SetToolJSXFn = (
|
| 104 |
+
args: {
|
| 105 |
+
jsx: React.ReactNode | null
|
| 106 |
+
shouldHidePromptInput: boolean
|
| 107 |
+
shouldContinueAnimation?: true
|
| 108 |
+
showSpinner?: boolean
|
| 109 |
+
isLocalJSXCommand?: boolean
|
| 110 |
+
isImmediate?: boolean
|
| 111 |
+
/** Set to true to clear a local JSX command (e.g., from its onDone callback) */
|
| 112 |
+
clearLocalJSX?: boolean
|
| 113 |
+
} | null,
|
| 114 |
+
) => void
|
| 115 |
+
|
| 116 |
+
// Import tool permission types from centralized location to break import cycles
|
| 117 |
+
import type { ToolPermissionRulesBySource } from './types/permissions.js'
|
| 118 |
+
|
| 119 |
+
// Re-export for backwards compatibility
|
| 120 |
+
export type { ToolPermissionRulesBySource }
|
| 121 |
+
|
| 122 |
+
// Apply DeepImmutable to the imported type
|
| 123 |
+
export type ToolPermissionContext = DeepImmutable<{
|
| 124 |
+
mode: PermissionMode
|
| 125 |
+
additionalWorkingDirectories: Map<string, AdditionalWorkingDirectory>
|
| 126 |
+
alwaysAllowRules: ToolPermissionRulesBySource
|
| 127 |
+
alwaysDenyRules: ToolPermissionRulesBySource
|
| 128 |
+
alwaysAskRules: ToolPermissionRulesBySource
|
| 129 |
+
isBypassPermissionsModeAvailable: boolean
|
| 130 |
+
isAutoModeAvailable?: boolean
|
| 131 |
+
strippedDangerousRules?: ToolPermissionRulesBySource
|
| 132 |
+
/** When true, permission prompts are auto-denied (e.g., background agents that can't show UI) */
|
| 133 |
+
shouldAvoidPermissionPrompts?: boolean
|
| 134 |
+
/** When true, automated checks (classifier, hooks) are awaited before showing the permission dialog (coordinator workers) */
|
| 135 |
+
awaitAutomatedChecksBeforeDialog?: boolean
|
| 136 |
+
/** Stores the permission mode before model-initiated plan mode entry, so it can be restored on exit */
|
| 137 |
+
prePlanMode?: PermissionMode
|
| 138 |
+
}>
|
| 139 |
+
|
| 140 |
+
export const getEmptyToolPermissionContext: () => ToolPermissionContext =
|
| 141 |
+
() => ({
|
| 142 |
+
mode: 'default',
|
| 143 |
+
additionalWorkingDirectories: new Map(),
|
| 144 |
+
alwaysAllowRules: {},
|
| 145 |
+
alwaysDenyRules: {},
|
| 146 |
+
alwaysAskRules: {},
|
| 147 |
+
isBypassPermissionsModeAvailable: false,
|
| 148 |
+
})
|
| 149 |
+
|
| 150 |
+
export type CompactProgressEvent =
|
| 151 |
+
| {
|
| 152 |
+
type: 'hooks_start'
|
| 153 |
+
hookType: 'pre_compact' | 'post_compact' | 'session_start'
|
| 154 |
+
}
|
| 155 |
+
| { type: 'compact_start' }
|
| 156 |
+
| { type: 'compact_end' }
|
| 157 |
+
|
| 158 |
+
export type ToolUseContext = {
|
| 159 |
+
options: {
|
| 160 |
+
commands: Command[]
|
| 161 |
+
debug: boolean
|
| 162 |
+
mainLoopModel: string
|
| 163 |
+
tools: Tools
|
| 164 |
+
verbose: boolean
|
| 165 |
+
thinkingConfig: ThinkingConfig
|
| 166 |
+
mcpClients: MCPServerConnection[]
|
| 167 |
+
mcpResources: Record<string, ServerResource[]>
|
| 168 |
+
isNonInteractiveSession: boolean
|
| 169 |
+
agentDefinitions: AgentDefinitionsResult
|
| 170 |
+
maxBudgetUsd?: number
|
| 171 |
+
/** Custom system prompt that replaces the default system prompt */
|
| 172 |
+
customSystemPrompt?: string
|
| 173 |
+
/** Additional system prompt appended after the main system prompt */
|
| 174 |
+
appendSystemPrompt?: string
|
| 175 |
+
/** Override querySource for analytics tracking */
|
| 176 |
+
querySource?: QuerySource
|
| 177 |
+
/** Optional callback to get the latest tools (e.g., after MCP servers connect mid-query) */
|
| 178 |
+
refreshTools?: () => Tools
|
| 179 |
+
}
|
| 180 |
+
abortController: AbortController
|
| 181 |
+
readFileState: FileStateCache
|
| 182 |
+
getAppState(): AppState
|
| 183 |
+
setAppState(f: (prev: AppState) => AppState): void
|
| 184 |
+
/**
|
| 185 |
+
* Always-shared setAppState for session-scoped infrastructure (background
|
| 186 |
+
* tasks, session hooks). Unlike setAppState, which is no-op for async agents
|
| 187 |
+
* (see createSubagentContext), this always reaches the root store so agents
|
| 188 |
+
* at any nesting depth can register/clean up infrastructure that outlives
|
| 189 |
+
* a single turn. Only set by createSubagentContext; main-thread contexts
|
| 190 |
+
* fall back to setAppState.
|
| 191 |
+
*/
|
| 192 |
+
setAppStateForTasks?: (f: (prev: AppState) => AppState) => void
|
| 193 |
+
/**
|
| 194 |
+
* Optional handler for URL elicitations triggered by tool call errors (-32042).
|
| 195 |
+
* In print/SDK mode, this delegates to structuredIO.handleElicitation.
|
| 196 |
+
* In REPL mode, this is undefined and the queue-based UI path is used.
|
| 197 |
+
*/
|
| 198 |
+
handleElicitation?: (
|
| 199 |
+
serverName: string,
|
| 200 |
+
params: ElicitRequestURLParams,
|
| 201 |
+
signal: AbortSignal,
|
| 202 |
+
) => Promise<ElicitResult>
|
| 203 |
+
setToolJSX?: SetToolJSXFn
|
| 204 |
+
addNotification?: (notif: Notification) => void
|
| 205 |
+
/** Append a UI-only system message to the REPL message list. Stripped at the
|
| 206 |
+
* normalizeMessagesForAPI boundary — the Exclude<> makes that type-enforced. */
|
| 207 |
+
appendSystemMessage?: (
|
| 208 |
+
msg: Exclude<SystemMessage, SystemLocalCommandMessage>,
|
| 209 |
+
) => void
|
| 210 |
+
/** Send an OS-level notification (iTerm2, Kitty, Ghostty, bell, etc.) */
|
| 211 |
+
sendOSNotification?: (opts: {
|
| 212 |
+
message: string
|
| 213 |
+
notificationType: string
|
| 214 |
+
}) => void
|
| 215 |
+
nestedMemoryAttachmentTriggers?: Set<string>
|
| 216 |
+
/**
|
| 217 |
+
* CLAUDE.md paths already injected as nested_memory attachments this
|
| 218 |
+
* session. Dedup for memoryFilesToAttachments — readFileState is an LRU
|
| 219 |
+
* that evicts entries in busy sessions, so its .has() check alone can
|
| 220 |
+
* re-inject the same CLAUDE.md dozens of times.
|
| 221 |
+
*/
|
| 222 |
+
loadedNestedMemoryPaths?: Set<string>
|
| 223 |
+
dynamicSkillDirTriggers?: Set<string>
|
| 224 |
+
/** Skill names surfaced via skill_discovery this session. Telemetry only (feeds was_discovered). */
|
| 225 |
+
discoveredSkillNames?: Set<string>
|
| 226 |
+
userModified?: boolean
|
| 227 |
+
setInProgressToolUseIDs: (f: (prev: Set<string>) => Set<string>) => void
|
| 228 |
+
/** Only wired in interactive (REPL) contexts; SDK/QueryEngine don't set this. */
|
| 229 |
+
setHasInterruptibleToolInProgress?: (v: boolean) => void
|
| 230 |
+
setResponseLength: (f: (prev: number) => number) => void
|
| 231 |
+
/** Ant-only: push a new API metrics entry for OTPS tracking.
|
| 232 |
+
* Called by subagent streaming when a new API request starts. */
|
| 233 |
+
pushApiMetricsEntry?: (ttftMs: number) => void
|
| 234 |
+
setStreamMode?: (mode: SpinnerMode) => void
|
| 235 |
+
onCompactProgress?: (event: CompactProgressEvent) => void
|
| 236 |
+
setSDKStatus?: (status: SDKStatus) => void
|
| 237 |
+
openMessageSelector?: () => void
|
| 238 |
+
updateFileHistoryState: (
|
| 239 |
+
updater: (prev: FileHistoryState) => FileHistoryState,
|
| 240 |
+
) => void
|
| 241 |
+
updateAttributionState: (
|
| 242 |
+
updater: (prev: AttributionState) => AttributionState,
|
| 243 |
+
) => void
|
| 244 |
+
setConversationId?: (id: UUID) => void
|
| 245 |
+
agentId?: AgentId // Only set for subagents; use getSessionId() for session ID. Hooks use this to distinguish subagent calls.
|
| 246 |
+
agentType?: string // Subagent type name. For the main thread's --agent type, hooks fall back to getMainThreadAgentType().
|
| 247 |
+
/** When true, canUseTool must always be called even when hooks auto-approve.
|
| 248 |
+
* Used by speculation for overlay file path rewriting. */
|
| 249 |
+
requireCanUseTool?: boolean
|
| 250 |
+
messages: Message[]
|
| 251 |
+
fileReadingLimits?: {
|
| 252 |
+
maxTokens?: number
|
| 253 |
+
maxSizeBytes?: number
|
| 254 |
+
}
|
| 255 |
+
globLimits?: {
|
| 256 |
+
maxResults?: number
|
| 257 |
+
}
|
| 258 |
+
toolDecisions?: Map<
|
| 259 |
+
string,
|
| 260 |
+
{
|
| 261 |
+
source: string
|
| 262 |
+
decision: 'accept' | 'reject'
|
| 263 |
+
timestamp: number
|
| 264 |
+
}
|
| 265 |
+
>
|
| 266 |
+
queryTracking?: QueryChainTracking
|
| 267 |
+
/** Callback factory for requesting interactive prompts from the user.
|
| 268 |
+
* Returns a prompt callback bound to the given source name.
|
| 269 |
+
* Only available in interactive (REPL) contexts. */
|
| 270 |
+
requestPrompt?: (
|
| 271 |
+
sourceName: string,
|
| 272 |
+
toolInputSummary?: string | null,
|
| 273 |
+
) => (request: PromptRequest) => Promise<PromptResponse>
|
| 274 |
+
toolUseId?: string
|
| 275 |
+
criticalSystemReminder_EXPERIMENTAL?: string
|
| 276 |
+
/** When true, preserve toolUseResult on messages even for subagents.
|
| 277 |
+
* Used by in-process teammates whose transcripts are viewable by the user. */
|
| 278 |
+
preserveToolUseResults?: boolean
|
| 279 |
+
/** Local denial tracking state for async subagents whose setAppState is a
|
| 280 |
+
* no-op. Without this, the denial counter never accumulates and the
|
| 281 |
+
* fallback-to-prompting threshold is never reached. Mutable — the
|
| 282 |
+
* permissions code updates it in place. */
|
| 283 |
+
localDenialTracking?: DenialTrackingState
|
| 284 |
+
/**
|
| 285 |
+
* Per-conversation-thread content replacement state for the tool result
|
| 286 |
+
* budget. When present, query.ts applies the aggregate tool result budget.
|
| 287 |
+
* Main thread: REPL provisions once (never resets — stale UUID keys
|
| 288 |
+
* are inert). Subagents: createSubagentContext clones the parent's state
|
| 289 |
+
* by default (cache-sharing forks need identical decisions), or
|
| 290 |
+
* resumeAgentBackground threads one reconstructed from sidechain records.
|
| 291 |
+
*/
|
| 292 |
+
contentReplacementState?: ContentReplacementState
|
| 293 |
+
/**
|
| 294 |
+
* Parent's rendered system prompt bytes, frozen at turn start.
|
| 295 |
+
* Used by fork subagents to share the parent's prompt cache — re-calling
|
| 296 |
+
* getSystemPrompt() at fork-spawn time can diverge (GrowthBook cold→warm)
|
| 297 |
+
* and bust the cache. See forkSubagent.ts.
|
| 298 |
+
*/
|
| 299 |
+
renderedSystemPrompt?: SystemPrompt
|
| 300 |
+
}
|
| 301 |
+
|
| 302 |
+
// Re-export ToolProgressData from centralized location
|
| 303 |
+
export type { ToolProgressData }
|
| 304 |
+
|
| 305 |
+
export type Progress = ToolProgressData | HookProgress
|
| 306 |
+
|
| 307 |
+
export type ToolProgress<P extends ToolProgressData> = {
|
| 308 |
+
toolUseID: string
|
| 309 |
+
data: P
|
| 310 |
+
}
|
| 311 |
+
|
| 312 |
+
export function filterToolProgressMessages(
|
| 313 |
+
progressMessagesForMessage: ProgressMessage[],
|
| 314 |
+
): ProgressMessage<ToolProgressData>[] {
|
| 315 |
+
return progressMessagesForMessage.filter(
|
| 316 |
+
(msg): msg is ProgressMessage<ToolProgressData> =>
|
| 317 |
+
msg.data?.type !== 'hook_progress',
|
| 318 |
+
)
|
| 319 |
+
}
|
| 320 |
+
|
| 321 |
+
export type ToolResult<T> = {
|
| 322 |
+
data: T
|
| 323 |
+
newMessages?: (
|
| 324 |
+
| UserMessage
|
| 325 |
+
| AssistantMessage
|
| 326 |
+
| AttachmentMessage
|
| 327 |
+
| SystemMessage
|
| 328 |
+
)[]
|
| 329 |
+
// contextModifier is only honored for tools that aren't concurrency safe.
|
| 330 |
+
contextModifier?: (context: ToolUseContext) => ToolUseContext
|
| 331 |
+
/** MCP protocol metadata (structuredContent, _meta) to pass through to SDK consumers */
|
| 332 |
+
mcpMeta?: {
|
| 333 |
+
_meta?: Record<string, unknown>
|
| 334 |
+
structuredContent?: Record<string, unknown>
|
| 335 |
+
}
|
| 336 |
+
}
|
| 337 |
+
|
| 338 |
+
export type ToolCallProgress<P extends ToolProgressData = ToolProgressData> = (
|
| 339 |
+
progress: ToolProgress<P>,
|
| 340 |
+
) => void
|
| 341 |
+
|
| 342 |
+
// Type for any schema that outputs an object with string keys
|
| 343 |
+
export type AnyObject = z.ZodType<{ [key: string]: unknown }>
|
| 344 |
+
|
| 345 |
+
/**
|
| 346 |
+
* Checks if a tool matches the given name (primary name or alias).
|
| 347 |
+
*/
|
| 348 |
+
export function toolMatchesName(
|
| 349 |
+
tool: { name: string; aliases?: string[] },
|
| 350 |
+
name: string,
|
| 351 |
+
): boolean {
|
| 352 |
+
return tool.name === name || (tool.aliases?.includes(name) ?? false)
|
| 353 |
+
}
|
| 354 |
+
|
| 355 |
+
/**
|
| 356 |
+
* Finds a tool by name or alias from a list of tools.
|
| 357 |
+
*/
|
| 358 |
+
export function findToolByName(tools: Tools, name: string): Tool | undefined {
|
| 359 |
+
return tools.find(t => toolMatchesName(t, name))
|
| 360 |
+
}
|
| 361 |
+
|
| 362 |
+
export type Tool<
|
| 363 |
+
Input extends AnyObject = AnyObject,
|
| 364 |
+
Output = unknown,
|
| 365 |
+
P extends ToolProgressData = ToolProgressData,
|
| 366 |
+
> = {
|
| 367 |
+
/**
|
| 368 |
+
* Optional aliases for backwards compatibility when a tool is renamed.
|
| 369 |
+
* The tool can be looked up by any of these names in addition to its primary name.
|
| 370 |
+
*/
|
| 371 |
+
aliases?: string[]
|
| 372 |
+
/**
|
| 373 |
+
* One-line capability phrase used by ToolSearch for keyword matching.
|
| 374 |
+
* Helps the model find this tool via keyword search when it's deferred.
|
| 375 |
+
* 3–10 words, no trailing period.
|
| 376 |
+
* Prefer terms not already in the tool name (e.g. 'jupyter' for NotebookEdit).
|
| 377 |
+
*/
|
| 378 |
+
searchHint?: string
|
| 379 |
+
call(
|
| 380 |
+
args: z.infer<Input>,
|
| 381 |
+
context: ToolUseContext,
|
| 382 |
+
canUseTool: CanUseToolFn,
|
| 383 |
+
parentMessage: AssistantMessage,
|
| 384 |
+
onProgress?: ToolCallProgress<P>,
|
| 385 |
+
): Promise<ToolResult<Output>>
|
| 386 |
+
description(
|
| 387 |
+
input: z.infer<Input>,
|
| 388 |
+
options: {
|
| 389 |
+
isNonInteractiveSession: boolean
|
| 390 |
+
toolPermissionContext: ToolPermissionContext
|
| 391 |
+
tools: Tools
|
| 392 |
+
},
|
| 393 |
+
): Promise<string>
|
| 394 |
+
readonly inputSchema: Input
|
| 395 |
+
// Type for MCP tools that can specify their input schema directly in JSON Schema format
|
| 396 |
+
// rather than converting from Zod schema
|
| 397 |
+
readonly inputJSONSchema?: ToolInputJSONSchema
|
| 398 |
+
// Optional because TungstenTool doesn't define this. TODO: Make it required.
|
| 399 |
+
// When we do that, we can also go through and make this a bit more type-safe.
|
| 400 |
+
outputSchema?: z.ZodType<unknown>
|
| 401 |
+
inputsEquivalent?(a: z.infer<Input>, b: z.infer<Input>): boolean
|
| 402 |
+
isConcurrencySafe(input: z.infer<Input>): boolean
|
| 403 |
+
isEnabled(): boolean
|
| 404 |
+
isReadOnly(input: z.infer<Input>): boolean
|
| 405 |
+
/** Defaults to false. Only set when the tool performs irreversible operations (delete, overwrite, send). */
|
| 406 |
+
isDestructive?(input: z.infer<Input>): boolean
|
| 407 |
+
/**
|
| 408 |
+
* What should happen when the user submits a new message while this tool
|
| 409 |
+
* is running.
|
| 410 |
+
*
|
| 411 |
+
* - `'cancel'` — stop the tool and discard its result
|
| 412 |
+
* - `'block'` — keep running; the new message waits
|
| 413 |
+
*
|
| 414 |
+
* Defaults to `'block'` when not implemented.
|
| 415 |
+
*/
|
| 416 |
+
interruptBehavior?(): 'cancel' | 'block'
|
| 417 |
+
/**
|
| 418 |
+
* Returns information about whether this tool use is a search or read operation
|
| 419 |
+
* that should be collapsed into a condensed display in the UI. Examples include
|
| 420 |
+
* file searching (Grep, Glob), file reading (Read), and bash commands like find,
|
| 421 |
+
* grep, wc, etc.
|
| 422 |
+
*
|
| 423 |
+
* Returns an object indicating whether the operation is a search or read operation:
|
| 424 |
+
* - `isSearch: true` for search operations (grep, find, glob patterns)
|
| 425 |
+
* - `isRead: true` for read operations (cat, head, tail, file read)
|
| 426 |
+
* - `isList: true` for directory-listing operations (ls, tree, du)
|
| 427 |
+
* - All can be false if the operation shouldn't be collapsed
|
| 428 |
+
*/
|
| 429 |
+
isSearchOrReadCommand?(input: z.infer<Input>): {
|
| 430 |
+
isSearch: boolean
|
| 431 |
+
isRead: boolean
|
| 432 |
+
isList?: boolean
|
| 433 |
+
}
|
| 434 |
+
isOpenWorld?(input: z.infer<Input>): boolean
|
| 435 |
+
requiresUserInteraction?(): boolean
|
| 436 |
+
isMcp?: boolean
|
| 437 |
+
isLsp?: boolean
|
| 438 |
+
/**
|
| 439 |
+
* When true, this tool is deferred (sent with defer_loading: true) and requires
|
| 440 |
+
* ToolSearch to be used before it can be called.
|
| 441 |
+
*/
|
| 442 |
+
readonly shouldDefer?: boolean
|
| 443 |
+
/**
|
| 444 |
+
* When true, this tool is never deferred — its full schema appears in the
|
| 445 |
+
* initial prompt even when ToolSearch is enabled. For MCP tools, set via
|
| 446 |
+
* `_meta['anthropic/alwaysLoad']`. Use for tools the model must see on
|
| 447 |
+
* turn 1 without a ToolSearch round-trip.
|
| 448 |
+
*/
|
| 449 |
+
readonly alwaysLoad?: boolean
|
| 450 |
+
/**
|
| 451 |
+
* For MCP tools: the server and tool names as received from the MCP server (unnormalized).
|
| 452 |
+
* Present on all MCP tools regardless of whether `name` is prefixed (mcp__server__tool)
|
| 453 |
+
* or unprefixed (CLAUDE_AGENT_SDK_MCP_NO_PREFIX mode).
|
| 454 |
+
*/
|
| 455 |
+
mcpInfo?: { serverName: string; toolName: string }
|
| 456 |
+
readonly name: string
|
| 457 |
+
/**
|
| 458 |
+
* Maximum size in characters for tool result before it gets persisted to disk.
|
| 459 |
+
* When exceeded, the result is saved to a file and Claude receives a preview
|
| 460 |
+
* with the file path instead of the full content.
|
| 461 |
+
*
|
| 462 |
+
* Set to Infinity for tools whose output must never be persisted (e.g. Read,
|
| 463 |
+
* where persisting creates a circular Read→file→Read loop and the tool
|
| 464 |
+
* already self-bounds via its own limits).
|
| 465 |
+
*/
|
| 466 |
+
maxResultSizeChars: number
|
| 467 |
+
/**
|
| 468 |
+
* When true, enables strict mode for this tool, which causes the API to
|
| 469 |
+
* more strictly adhere to tool instructions and parameter schemas.
|
| 470 |
+
* Only applied when the tengu_tool_pear is enabled.
|
| 471 |
+
*/
|
| 472 |
+
readonly strict?: boolean
|
| 473 |
+
|
| 474 |
+
/**
|
| 475 |
+
* Called on copies of tool_use input before observers see it (SDK stream,
|
| 476 |
+
* transcript, canUseTool, PreToolUse/PostToolUse hooks). Mutate in place
|
| 477 |
+
* to add legacy/derived fields. Must be idempotent. The original API-bound
|
| 478 |
+
* input is never mutated (preserves prompt cache). Not re-applied when a
|
| 479 |
+
* hook/permission returns a fresh updatedInput — those own their shape.
|
| 480 |
+
*/
|
| 481 |
+
backfillObservableInput?(input: Record<string, unknown>): void
|
| 482 |
+
|
| 483 |
+
/**
|
| 484 |
+
* Determines if this tool is allowed to run with this input in the current context.
|
| 485 |
+
* It informs the model of why the tool use failed, and does not directly display any UI.
|
| 486 |
+
* @param input
|
| 487 |
+
* @param context
|
| 488 |
+
*/
|
| 489 |
+
validateInput?(
|
| 490 |
+
input: z.infer<Input>,
|
| 491 |
+
context: ToolUseContext,
|
| 492 |
+
): Promise<ValidationResult>
|
| 493 |
+
|
| 494 |
+
/**
|
| 495 |
+
* Determines if the user is asked for permission. Only called after validateInput() passes.
|
| 496 |
+
* General permission logic is in permissions.ts. This method contains tool-specific logic.
|
| 497 |
+
* @param input
|
| 498 |
+
* @param context
|
| 499 |
+
*/
|
| 500 |
+
checkPermissions(
|
| 501 |
+
input: z.infer<Input>,
|
| 502 |
+
context: ToolUseContext,
|
| 503 |
+
): Promise<PermissionResult>
|
| 504 |
+
|
| 505 |
+
// Optional method for tools that operate on a file path
|
| 506 |
+
getPath?(input: z.infer<Input>): string
|
| 507 |
+
|
| 508 |
+
/**
|
| 509 |
+
* Prepare a matcher for hook `if` conditions (permission-rule patterns like
|
| 510 |
+
* "git *" from "Bash(git *)"). Called once per hook-input pair; any
|
| 511 |
+
* expensive parsing happens here. Returns a closure that is called per
|
| 512 |
+
* hook pattern. If not implemented, only tool-name-level matching works.
|
| 513 |
+
*/
|
| 514 |
+
preparePermissionMatcher?(
|
| 515 |
+
input: z.infer<Input>,
|
| 516 |
+
): Promise<(pattern: string) => boolean>
|
| 517 |
+
|
| 518 |
+
prompt(options: {
|
| 519 |
+
getToolPermissionContext: () => Promise<ToolPermissionContext>
|
| 520 |
+
tools: Tools
|
| 521 |
+
agents: AgentDefinition[]
|
| 522 |
+
allowedAgentTypes?: string[]
|
| 523 |
+
}): Promise<string>
|
| 524 |
+
userFacingName(input: Partial<z.infer<Input>> | undefined): string
|
| 525 |
+
userFacingNameBackgroundColor?(
|
| 526 |
+
input: Partial<z.infer<Input>> | undefined,
|
| 527 |
+
): keyof Theme | undefined
|
| 528 |
+
/**
|
| 529 |
+
* Transparent wrappers (e.g. REPL) delegate all rendering to their progress
|
| 530 |
+
* handler, which emits native-looking blocks for each inner tool call.
|
| 531 |
+
* The wrapper itself shows nothing.
|
| 532 |
+
*/
|
| 533 |
+
isTransparentWrapper?(): boolean
|
| 534 |
+
/**
|
| 535 |
+
* Returns a short string summary of this tool use for display in compact views.
|
| 536 |
+
* @param input The tool input
|
| 537 |
+
* @returns A short string summary, or null to not display
|
| 538 |
+
*/
|
| 539 |
+
getToolUseSummary?(input: Partial<z.infer<Input>> | undefined): string | null
|
| 540 |
+
/**
|
| 541 |
+
* Returns a human-readable present-tense activity description for spinner display.
|
| 542 |
+
* Example: "Reading src/foo.ts", "Running bun test", "Searching for pattern"
|
| 543 |
+
* @param input The tool input
|
| 544 |
+
* @returns Activity description string, or null to fall back to tool name
|
| 545 |
+
*/
|
| 546 |
+
getActivityDescription?(
|
| 547 |
+
input: Partial<z.infer<Input>> | undefined,
|
| 548 |
+
): string | null
|
| 549 |
+
/**
|
| 550 |
+
* Returns a compact representation of this tool use for the auto-mode
|
| 551 |
+
* security classifier. Examples: `ls -la` for Bash, `/tmp/x: new content`
|
| 552 |
+
* for Edit. Return '' to skip this tool in the classifier transcript
|
| 553 |
+
* (e.g. tools with no security relevance). May return an object to avoid
|
| 554 |
+
* double-encoding when the caller JSON-wraps the value.
|
| 555 |
+
*/
|
| 556 |
+
toAutoClassifierInput(input: z.infer<Input>): unknown
|
| 557 |
+
mapToolResultToToolResultBlockParam(
|
| 558 |
+
content: Output,
|
| 559 |
+
toolUseID: string,
|
| 560 |
+
): ToolResultBlockParam
|
| 561 |
+
/**
|
| 562 |
+
* Optional. When omitted, the tool result renders nothing (same as returning
|
| 563 |
+
* null). Omit for tools whose results are surfaced elsewhere (e.g., TodoWrite
|
| 564 |
+
* updates the todo panel, not the transcript).
|
| 565 |
+
*/
|
| 566 |
+
renderToolResultMessage?(
|
| 567 |
+
content: Output,
|
| 568 |
+
progressMessagesForMessage: ProgressMessage<P>[],
|
| 569 |
+
options: {
|
| 570 |
+
style?: 'condensed'
|
| 571 |
+
theme: ThemeName
|
| 572 |
+
tools: Tools
|
| 573 |
+
verbose: boolean
|
| 574 |
+
isTranscriptMode?: boolean
|
| 575 |
+
isBriefOnly?: boolean
|
| 576 |
+
/** Original tool_use input, when available. Useful for compact result
|
| 577 |
+
* summaries that reference what was requested (e.g. "Sent to #foo"). */
|
| 578 |
+
input?: unknown
|
| 579 |
+
},
|
| 580 |
+
): React.ReactNode
|
| 581 |
+
/**
|
| 582 |
+
* Flattened text of what renderToolResultMessage shows IN TRANSCRIPT
|
| 583 |
+
* MODE (verbose=true, isTranscriptMode=true). For transcript search
|
| 584 |
+
* indexing: the index counts occurrences in this string, the highlight
|
| 585 |
+
* overlay scans the actual screen buffer. For count ≡ highlight, this
|
| 586 |
+
* must return the text that ends up visible — not the model-facing
|
| 587 |
+
* serialization from mapToolResultToToolResultBlockParam (which adds
|
| 588 |
+
* system-reminders, persisted-output wrappers).
|
| 589 |
+
*
|
| 590 |
+
* Chrome can be skipped (under-count is fine). "Found 3 files in 12ms"
|
| 591 |
+
* isn't worth indexing. Phantoms are not fine — text that's claimed
|
| 592 |
+
* here but doesn't render is a count≠highlight bug.
|
| 593 |
+
*
|
| 594 |
+
* Optional: omitted → field-name heuristic in transcriptSearch.ts.
|
| 595 |
+
* Drift caught by test/utils/transcriptSearch.renderFidelity.test.tsx
|
| 596 |
+
* which renders sample outputs and flags text that's indexed-but-not-
|
| 597 |
+
* rendered (phantom) or rendered-but-not-indexed (under-count warning).
|
| 598 |
+
*/
|
| 599 |
+
extractSearchText?(out: Output): string
|
| 600 |
+
/**
|
| 601 |
+
* Render the tool use message. Note that `input` is partial because we render
|
| 602 |
+
* the message as soon as possible, possibly before tool parameters have fully
|
| 603 |
+
* streamed in.
|
| 604 |
+
*/
|
| 605 |
+
renderToolUseMessage(
|
| 606 |
+
input: Partial<z.infer<Input>>,
|
| 607 |
+
options: { theme: ThemeName; verbose: boolean; commands?: Command[] },
|
| 608 |
+
): React.ReactNode
|
| 609 |
+
/**
|
| 610 |
+
* Returns true when the non-verbose rendering of this output is truncated
|
| 611 |
+
* (i.e., clicking to expand would reveal more content). Gates
|
| 612 |
+
* click-to-expand in fullscreen — only messages where verbose actually
|
| 613 |
+
* shows more get a hover/click affordance. Unset means never truncated.
|
| 614 |
+
*/
|
| 615 |
+
isResultTruncated?(output: Output): boolean
|
| 616 |
+
/**
|
| 617 |
+
* Renders an optional tag to display after the tool use message.
|
| 618 |
+
* Used for additional metadata like timeout, model, resume ID, etc.
|
| 619 |
+
* Returns null to not display anything.
|
| 620 |
+
*/
|
| 621 |
+
renderToolUseTag?(input: Partial<z.infer<Input>>): React.ReactNode
|
| 622 |
+
/**
|
| 623 |
+
* Optional. When omitted, no progress UI is shown while the tool runs.
|
| 624 |
+
*/
|
| 625 |
+
renderToolUseProgressMessage?(
|
| 626 |
+
progressMessagesForMessage: ProgressMessage<P>[],
|
| 627 |
+
options: {
|
| 628 |
+
tools: Tools
|
| 629 |
+
verbose: boolean
|
| 630 |
+
terminalSize?: { columns: number; rows: number }
|
| 631 |
+
inProgressToolCallCount?: number
|
| 632 |
+
isTranscriptMode?: boolean
|
| 633 |
+
},
|
| 634 |
+
): React.ReactNode
|
| 635 |
+
renderToolUseQueuedMessage?(): React.ReactNode
|
| 636 |
+
/**
|
| 637 |
+
* Optional. When omitted, falls back to <FallbackToolUseRejectedMessage />.
|
| 638 |
+
* Only define this for tools that need custom rejection UI (e.g., file edits
|
| 639 |
+
* that show the rejected diff).
|
| 640 |
+
*/
|
| 641 |
+
renderToolUseRejectedMessage?(
|
| 642 |
+
input: z.infer<Input>,
|
| 643 |
+
options: {
|
| 644 |
+
columns: number
|
| 645 |
+
messages: Message[]
|
| 646 |
+
style?: 'condensed'
|
| 647 |
+
theme: ThemeName
|
| 648 |
+
tools: Tools
|
| 649 |
+
verbose: boolean
|
| 650 |
+
progressMessagesForMessage: ProgressMessage<P>[]
|
| 651 |
+
isTranscriptMode?: boolean
|
| 652 |
+
},
|
| 653 |
+
): React.ReactNode
|
| 654 |
+
/**
|
| 655 |
+
* Optional. When omitted, falls back to <FallbackToolUseErrorMessage />.
|
| 656 |
+
* Only define this for tools that need custom error UI (e.g., search tools
|
| 657 |
+
* that show "File not found" instead of the raw error).
|
| 658 |
+
*/
|
| 659 |
+
renderToolUseErrorMessage?(
|
| 660 |
+
result: ToolResultBlockParam['content'],
|
| 661 |
+
options: {
|
| 662 |
+
progressMessagesForMessage: ProgressMessage<P>[]
|
| 663 |
+
tools: Tools
|
| 664 |
+
verbose: boolean
|
| 665 |
+
isTranscriptMode?: boolean
|
| 666 |
+
},
|
| 667 |
+
): React.ReactNode
|
| 668 |
+
|
| 669 |
+
/**
|
| 670 |
+
* Renders multiple parallel instances of this tool as a group.
|
| 671 |
+
* @returns React node to render, or null to fall back to individual rendering
|
| 672 |
+
*/
|
| 673 |
+
/**
|
| 674 |
+
* Renders multiple tool uses as a group (non-verbose mode only).
|
| 675 |
+
* In verbose mode, individual tool uses render at their original positions.
|
| 676 |
+
* @returns React node to render, or null to fall back to individual rendering
|
| 677 |
+
*/
|
| 678 |
+
renderGroupedToolUse?(
|
| 679 |
+
toolUses: Array<{
|
| 680 |
+
param: ToolUseBlockParam
|
| 681 |
+
isResolved: boolean
|
| 682 |
+
isError: boolean
|
| 683 |
+
isInProgress: boolean
|
| 684 |
+
progressMessages: ProgressMessage<P>[]
|
| 685 |
+
result?: {
|
| 686 |
+
param: ToolResultBlockParam
|
| 687 |
+
output: unknown
|
| 688 |
+
}
|
| 689 |
+
}>,
|
| 690 |
+
options: {
|
| 691 |
+
shouldAnimate: boolean
|
| 692 |
+
tools: Tools
|
| 693 |
+
},
|
| 694 |
+
): React.ReactNode | null
|
| 695 |
+
}
|
| 696 |
+
|
| 697 |
+
/**
|
| 698 |
+
* A collection of tools. Use this type instead of `Tool[]` to make it easier
|
| 699 |
+
* to track where tool sets are assembled, passed, and filtered across the codebase.
|
| 700 |
+
*/
|
| 701 |
+
export type Tools = readonly Tool[]
|
| 702 |
+
|
| 703 |
+
/**
|
| 704 |
+
* Methods that `buildTool` supplies a default for. A `ToolDef` may omit these;
|
| 705 |
+
* the resulting `Tool` always has them.
|
| 706 |
+
*/
|
| 707 |
+
type DefaultableToolKeys =
|
| 708 |
+
| 'isEnabled'
|
| 709 |
+
| 'isConcurrencySafe'
|
| 710 |
+
| 'isReadOnly'
|
| 711 |
+
| 'isDestructive'
|
| 712 |
+
| 'checkPermissions'
|
| 713 |
+
| 'toAutoClassifierInput'
|
| 714 |
+
| 'userFacingName'
|
| 715 |
+
|
| 716 |
+
/**
|
| 717 |
+
* Tool definition accepted by `buildTool`. Same shape as `Tool` but with the
|
| 718 |
+
* defaultable methods optional — `buildTool` fills them in so callers always
|
| 719 |
+
* see a complete `Tool`.
|
| 720 |
+
*/
|
| 721 |
+
export type ToolDef<
|
| 722 |
+
Input extends AnyObject = AnyObject,
|
| 723 |
+
Output = unknown,
|
| 724 |
+
P extends ToolProgressData = ToolProgressData,
|
| 725 |
+
> = Omit<Tool<Input, Output, P>, DefaultableToolKeys> &
|
| 726 |
+
Partial<Pick<Tool<Input, Output, P>, DefaultableToolKeys>>
|
| 727 |
+
|
| 728 |
+
/**
|
| 729 |
+
* Type-level spread mirroring `{ ...TOOL_DEFAULTS, ...def }`. For each
|
| 730 |
+
* defaultable key: if D provides it (required), D's type wins; if D omits
|
| 731 |
+
* it or has it optional (inherited from Partial<> in the constraint), the
|
| 732 |
+
* default fills in. All other keys come from D verbatim — preserving arity,
|
| 733 |
+
* optional presence, and literal types exactly as `satisfies Tool` did.
|
| 734 |
+
*/
|
| 735 |
+
type BuiltTool<D> = Omit<D, DefaultableToolKeys> & {
|
| 736 |
+
[K in DefaultableToolKeys]-?: K extends keyof D
|
| 737 |
+
? undefined extends D[K]
|
| 738 |
+
? ToolDefaults[K]
|
| 739 |
+
: D[K]
|
| 740 |
+
: ToolDefaults[K]
|
| 741 |
+
}
|
| 742 |
+
|
| 743 |
+
/**
|
| 744 |
+
* Build a complete `Tool` from a partial definition, filling in safe defaults
|
| 745 |
+
* for the commonly-stubbed methods. All tool exports should go through this so
|
| 746 |
+
* that defaults live in one place and callers never need `?.() ?? default`.
|
| 747 |
+
*
|
| 748 |
+
* Defaults (fail-closed where it matters):
|
| 749 |
+
* - `isEnabled` → `true`
|
| 750 |
+
* - `isConcurrencySafe` → `false` (assume not safe)
|
| 751 |
+
* - `isReadOnly` → `false` (assume writes)
|
| 752 |
+
* - `isDestructive` → `false`
|
| 753 |
+
* - `checkPermissions` → `{ behavior: 'allow', updatedInput }` (defer to general permission system)
|
| 754 |
+
* - `toAutoClassifierInput` → `''` (skip classifier — security-relevant tools must override)
|
| 755 |
+
* - `userFacingName` → `name`
|
| 756 |
+
*/
|
| 757 |
+
const TOOL_DEFAULTS = {
|
| 758 |
+
isEnabled: () => true,
|
| 759 |
+
isConcurrencySafe: (_input?: unknown) => false,
|
| 760 |
+
isReadOnly: (_input?: unknown) => false,
|
| 761 |
+
isDestructive: (_input?: unknown) => false,
|
| 762 |
+
checkPermissions: (
|
| 763 |
+
input: { [key: string]: unknown },
|
| 764 |
+
_ctx?: ToolUseContext,
|
| 765 |
+
): Promise<PermissionResult> =>
|
| 766 |
+
Promise.resolve({ behavior: 'allow', updatedInput: input }),
|
| 767 |
+
toAutoClassifierInput: (_input?: unknown) => '',
|
| 768 |
+
userFacingName: (_input?: unknown) => '',
|
| 769 |
+
}
|
| 770 |
+
|
| 771 |
+
// The defaults type is the ACTUAL shape of TOOL_DEFAULTS (optional params so
|
| 772 |
+
// both 0-arg and full-arg call sites type-check — stubs varied in arity and
|
| 773 |
+
// tests relied on that), not the interface's strict signatures.
|
| 774 |
+
type ToolDefaults = typeof TOOL_DEFAULTS
|
| 775 |
+
|
| 776 |
+
// D infers the concrete object-literal type from the call site. The
|
| 777 |
+
// constraint provides contextual typing for method parameters; `any` in
|
| 778 |
+
// constraint position is structural and never leaks into the return type.
|
| 779 |
+
// BuiltTool<D> mirrors runtime `{...TOOL_DEFAULTS, ...def}` at the type level.
|
| 780 |
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
| 781 |
+
type AnyToolDef = ToolDef<any, any, any>
|
| 782 |
+
|
| 783 |
+
export function buildTool<D extends AnyToolDef>(def: D): BuiltTool<D> {
|
| 784 |
+
// The runtime spread is straightforward; the `as` bridges the gap between
|
| 785 |
+
// the structural-any constraint and the precise BuiltTool<D> return. The
|
| 786 |
+
// type semantics are proven by the 0-error typecheck across all 60+ tools.
|
| 787 |
+
return {
|
| 788 |
+
...TOOL_DEFAULTS,
|
| 789 |
+
userFacingName: () => def.name,
|
| 790 |
+
...def,
|
| 791 |
+
} as BuiltTool<D>
|
| 792 |
+
}
|
src/assistant/AssistantSessionChooser.tsx
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useEffect } from 'react'
|
| 2 |
+
|
| 3 |
+
type Props = {
|
| 4 |
+
sessions: unknown[]
|
| 5 |
+
onSelect: (sessionId: string) => void
|
| 6 |
+
onCancel: () => void
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
export function AssistantSessionChooser({ onCancel }: Props) {
|
| 10 |
+
useEffect(() => {
|
| 11 |
+
onCancel()
|
| 12 |
+
}, [onCancel])
|
| 13 |
+
|
| 14 |
+
return null
|
| 15 |
+
}
|
src/assistant/sessionHistory.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import axios from 'axios'
|
| 2 |
+
import { getOauthConfig } from '../constants/oauth.js'
|
| 3 |
+
import type { SDKMessage } from '../entrypoints/agentSdkTypes.js'
|
| 4 |
+
import { logForDebugging } from '../utils/debug.js'
|
| 5 |
+
import { getOAuthHeaders, prepareApiRequest } from '../utils/teleport/api.js'
|
| 6 |
+
|
| 7 |
+
export const HISTORY_PAGE_SIZE = 100
|
| 8 |
+
|
| 9 |
+
export type HistoryPage = {
|
| 10 |
+
/** Chronological order within the page. */
|
| 11 |
+
events: SDKMessage[]
|
| 12 |
+
/** Oldest event ID in this page → before_id cursor for next-older page. */
|
| 13 |
+
firstId: string | null
|
| 14 |
+
/** true = older events exist. */
|
| 15 |
+
hasMore: boolean
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
type SessionEventsResponse = {
|
| 19 |
+
data: SDKMessage[]
|
| 20 |
+
has_more: boolean
|
| 21 |
+
first_id: string | null
|
| 22 |
+
last_id: string | null
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
export type HistoryAuthCtx = {
|
| 26 |
+
baseUrl: string
|
| 27 |
+
headers: Record<string, string>
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
/** Prepare auth + headers + base URL once, reuse across pages. */
|
| 31 |
+
export async function createHistoryAuthCtx(
|
| 32 |
+
sessionId: string,
|
| 33 |
+
): Promise<HistoryAuthCtx> {
|
| 34 |
+
const { accessToken, orgUUID } = await prepareApiRequest()
|
| 35 |
+
return {
|
| 36 |
+
baseUrl: `${getOauthConfig().BASE_API_URL}/v1/sessions/${sessionId}/events`,
|
| 37 |
+
headers: {
|
| 38 |
+
...getOAuthHeaders(accessToken),
|
| 39 |
+
'anthropic-beta': 'ccr-byoc-2025-07-29',
|
| 40 |
+
'x-organization-uuid': orgUUID,
|
| 41 |
+
},
|
| 42 |
+
}
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
async function fetchPage(
|
| 46 |
+
ctx: HistoryAuthCtx,
|
| 47 |
+
params: Record<string, string | number | boolean>,
|
| 48 |
+
label: string,
|
| 49 |
+
): Promise<HistoryPage | null> {
|
| 50 |
+
const resp = await axios
|
| 51 |
+
.get<SessionEventsResponse>(ctx.baseUrl, {
|
| 52 |
+
headers: ctx.headers,
|
| 53 |
+
params,
|
| 54 |
+
timeout: 15000,
|
| 55 |
+
validateStatus: () => true,
|
| 56 |
+
})
|
| 57 |
+
.catch(() => null)
|
| 58 |
+
if (!resp || resp.status !== 200) {
|
| 59 |
+
logForDebugging(`[${label}] HTTP ${resp?.status ?? 'error'}`)
|
| 60 |
+
return null
|
| 61 |
+
}
|
| 62 |
+
return {
|
| 63 |
+
events: Array.isArray(resp.data.data) ? resp.data.data : [],
|
| 64 |
+
firstId: resp.data.first_id,
|
| 65 |
+
hasMore: resp.data.has_more,
|
| 66 |
+
}
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
/**
|
| 70 |
+
* Newest page: last `limit` events, chronological, via anchor_to_latest.
|
| 71 |
+
* has_more=true means older events exist.
|
| 72 |
+
*/
|
| 73 |
+
export async function fetchLatestEvents(
|
| 74 |
+
ctx: HistoryAuthCtx,
|
| 75 |
+
limit = HISTORY_PAGE_SIZE,
|
| 76 |
+
): Promise<HistoryPage | null> {
|
| 77 |
+
return fetchPage(ctx, { limit, anchor_to_latest: true }, 'fetchLatestEvents')
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
/** Older page: events immediately before `beforeId` cursor. */
|
| 81 |
+
export async function fetchOlderEvents(
|
| 82 |
+
ctx: HistoryAuthCtx,
|
| 83 |
+
beforeId: string,
|
| 84 |
+
limit = HISTORY_PAGE_SIZE,
|
| 85 |
+
): Promise<HistoryPage | null> {
|
| 86 |
+
return fetchPage(ctx, { limit, before_id: beforeId }, 'fetchOlderEvents')
|
| 87 |
+
}
|
src/bootstrap/state.ts
ADDED
|
@@ -0,0 +1,1758 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { BetaMessageStreamParams } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'
|
| 2 |
+
import type { Attributes, Meter, MetricOptions } from '@opentelemetry/api'
|
| 3 |
+
import type { logs } from '@opentelemetry/api-logs'
|
| 4 |
+
import type { LoggerProvider } from '@opentelemetry/sdk-logs'
|
| 5 |
+
import type { MeterProvider } from '@opentelemetry/sdk-metrics'
|
| 6 |
+
import type { BasicTracerProvider } from '@opentelemetry/sdk-trace-base'
|
| 7 |
+
import { realpathSync } from 'fs'
|
| 8 |
+
import sumBy from 'lodash-es/sumBy.js'
|
| 9 |
+
import { cwd } from 'process'
|
| 10 |
+
import type { HookEvent, ModelUsage } from 'src/entrypoints/agentSdkTypes.js'
|
| 11 |
+
import type { AgentColorName } from 'src/tools/AgentTool/agentColorManager.js'
|
| 12 |
+
import type { HookCallbackMatcher } from 'src/types/hooks.js'
|
| 13 |
+
// Indirection for browser-sdk build (package.json "browser" field swaps
|
| 14 |
+
// crypto.ts for crypto.browser.ts). Pure leaf re-export of node:crypto —
|
| 15 |
+
// zero circular-dep risk. Path-alias import bypasses bootstrap-isolation
|
| 16 |
+
// (rule only checks ./ and / prefixes); explicit disable documents intent.
|
| 17 |
+
// eslint-disable-next-line custom-rules/bootstrap-isolation
|
| 18 |
+
import { randomUUID } from 'src/utils/crypto.js'
|
| 19 |
+
import type { ModelSetting } from 'src/utils/model/model.js'
|
| 20 |
+
import type { ModelStrings } from 'src/utils/model/modelStrings.js'
|
| 21 |
+
import type { SettingSource } from 'src/utils/settings/constants.js'
|
| 22 |
+
import { resetSettingsCache } from 'src/utils/settings/settingsCache.js'
|
| 23 |
+
import type { PluginHookMatcher } from 'src/utils/settings/types.js'
|
| 24 |
+
import { createSignal } from 'src/utils/signal.js'
|
| 25 |
+
|
| 26 |
+
// Union type for registered hooks - can be SDK callbacks or native plugin hooks
|
| 27 |
+
type RegisteredHookMatcher = HookCallbackMatcher | PluginHookMatcher
|
| 28 |
+
|
| 29 |
+
import type { SessionId } from 'src/types/ids.js'
|
| 30 |
+
|
| 31 |
+
// DO NOT ADD MORE STATE HERE - BE JUDICIOUS WITH GLOBAL STATE
|
| 32 |
+
|
| 33 |
+
// dev: true on entries that came via --dangerously-load-development-channels.
|
| 34 |
+
// The allowlist gate checks this per-entry (not the session-wide
|
| 35 |
+
// hasDevChannels bit) so passing both flags doesn't let the dev dialog's
|
| 36 |
+
// acceptance leak allowlist-bypass to the --channels entries.
|
| 37 |
+
export type ChannelEntry =
|
| 38 |
+
| { kind: 'plugin'; name: string; marketplace: string; dev?: boolean }
|
| 39 |
+
| { kind: 'server'; name: string; dev?: boolean }
|
| 40 |
+
|
| 41 |
+
export type AttributedCounter = {
|
| 42 |
+
add(value: number, additionalAttributes?: Attributes): void
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
type State = {
|
| 46 |
+
originalCwd: string
|
| 47 |
+
// Stable project root - set once at startup (including by --worktree flag),
|
| 48 |
+
// never updated by mid-session EnterWorktreeTool.
|
| 49 |
+
// Use for project identity (history, skills, sessions) not file operations.
|
| 50 |
+
projectRoot: string
|
| 51 |
+
totalCostUSD: number
|
| 52 |
+
totalAPIDuration: number
|
| 53 |
+
totalAPIDurationWithoutRetries: number
|
| 54 |
+
totalToolDuration: number
|
| 55 |
+
turnHookDurationMs: number
|
| 56 |
+
turnToolDurationMs: number
|
| 57 |
+
turnClassifierDurationMs: number
|
| 58 |
+
turnToolCount: number
|
| 59 |
+
turnHookCount: number
|
| 60 |
+
turnClassifierCount: number
|
| 61 |
+
startTime: number
|
| 62 |
+
lastInteractionTime: number
|
| 63 |
+
totalLinesAdded: number
|
| 64 |
+
totalLinesRemoved: number
|
| 65 |
+
hasUnknownModelCost: boolean
|
| 66 |
+
cwd: string
|
| 67 |
+
modelUsage: { [modelName: string]: ModelUsage }
|
| 68 |
+
mainLoopModelOverride: ModelSetting | undefined
|
| 69 |
+
initialMainLoopModel: ModelSetting
|
| 70 |
+
modelStrings: ModelStrings | null
|
| 71 |
+
isInteractive: boolean
|
| 72 |
+
kairosActive: boolean
|
| 73 |
+
// When true, ensureToolResultPairing throws on mismatch instead of
|
| 74 |
+
// repairing with synthetic placeholders. HFI opts in at startup so
|
| 75 |
+
// trajectories fail fast rather than conditioning the model on fake
|
| 76 |
+
// tool_results.
|
| 77 |
+
strictToolResultPairing: boolean
|
| 78 |
+
sdkAgentProgressSummariesEnabled: boolean
|
| 79 |
+
userMsgOptIn: boolean
|
| 80 |
+
clientType: string
|
| 81 |
+
sessionSource: string | undefined
|
| 82 |
+
questionPreviewFormat: 'markdown' | 'html' | undefined
|
| 83 |
+
flagSettingsPath: string | undefined
|
| 84 |
+
flagSettingsInline: Record<string, unknown> | null
|
| 85 |
+
allowedSettingSources: SettingSource[]
|
| 86 |
+
sessionIngressToken: string | null | undefined
|
| 87 |
+
oauthTokenFromFd: string | null | undefined
|
| 88 |
+
apiKeyFromFd: string | null | undefined
|
| 89 |
+
// Telemetry state
|
| 90 |
+
meter: Meter | null
|
| 91 |
+
sessionCounter: AttributedCounter | null
|
| 92 |
+
locCounter: AttributedCounter | null
|
| 93 |
+
prCounter: AttributedCounter | null
|
| 94 |
+
commitCounter: AttributedCounter | null
|
| 95 |
+
costCounter: AttributedCounter | null
|
| 96 |
+
tokenCounter: AttributedCounter | null
|
| 97 |
+
codeEditToolDecisionCounter: AttributedCounter | null
|
| 98 |
+
activeTimeCounter: AttributedCounter | null
|
| 99 |
+
statsStore: { observe(name: string, value: number): void } | null
|
| 100 |
+
sessionId: SessionId
|
| 101 |
+
// Parent session ID for tracking session lineage (e.g., plan mode -> implementation)
|
| 102 |
+
parentSessionId: SessionId | undefined
|
| 103 |
+
// Logger state
|
| 104 |
+
loggerProvider: LoggerProvider | null
|
| 105 |
+
eventLogger: ReturnType<typeof logs.getLogger> | null
|
| 106 |
+
// Meter provider state
|
| 107 |
+
meterProvider: MeterProvider | null
|
| 108 |
+
// Tracer provider state
|
| 109 |
+
tracerProvider: BasicTracerProvider | null
|
| 110 |
+
// Agent color state
|
| 111 |
+
agentColorMap: Map<string, AgentColorName>
|
| 112 |
+
agentColorIndex: number
|
| 113 |
+
// Last API request for bug reports
|
| 114 |
+
lastAPIRequest: Omit<BetaMessageStreamParams, 'messages'> | null
|
| 115 |
+
// Messages from the last API request (ant-only; reference, not clone).
|
| 116 |
+
// Captures the exact post-compaction, CLAUDE.md-injected message set sent
|
| 117 |
+
// to the API so /share's serialized_conversation.json reflects reality.
|
| 118 |
+
lastAPIRequestMessages: BetaMessageStreamParams['messages'] | null
|
| 119 |
+
// Last auto-mode classifier request(s) for /share transcript
|
| 120 |
+
lastClassifierRequests: unknown[] | null
|
| 121 |
+
// CLAUDE.md content cached by context.ts for the auto-mode classifier.
|
| 122 |
+
// Breaks the yoloClassifier → claudemd → filesystem → permissions cycle.
|
| 123 |
+
cachedClaudeMdContent: string | null
|
| 124 |
+
// In-memory error log for recent errors
|
| 125 |
+
inMemoryErrorLog: Array<{ error: string; timestamp: string }>
|
| 126 |
+
// Session-only plugins from --plugin-dir flag
|
| 127 |
+
inlinePlugins: Array<string>
|
| 128 |
+
// Explicit --chrome / --no-chrome flag value (undefined = not set on CLI)
|
| 129 |
+
chromeFlagOverride: boolean | undefined
|
| 130 |
+
// Use cowork_plugins directory instead of plugins (--cowork flag or env var)
|
| 131 |
+
useCoworkPlugins: boolean
|
| 132 |
+
// Session-only bypass permissions mode flag (not persisted)
|
| 133 |
+
sessionBypassPermissionsMode: boolean
|
| 134 |
+
// Session-only flag gating the .claude/scheduled_tasks.json watcher
|
| 135 |
+
// (useScheduledTasks). Set by cronScheduler.start() when the JSON has
|
| 136 |
+
// entries, or by CronCreateTool. Not persisted.
|
| 137 |
+
scheduledTasksEnabled: boolean
|
| 138 |
+
// Session-only cron tasks created via CronCreate with durable: false.
|
| 139 |
+
// Fire on schedule like file-backed tasks but are never written to
|
| 140 |
+
// .claude/scheduled_tasks.json — they die with the process. Typed via
|
| 141 |
+
// SessionCronTask below (not importing from cronTasks.ts keeps
|
| 142 |
+
// bootstrap a leaf of the import DAG).
|
| 143 |
+
sessionCronTasks: SessionCronTask[]
|
| 144 |
+
// Teams created this session via TeamCreate. cleanupSessionTeams()
|
| 145 |
+
// removes these on gracefulShutdown so subagent-created teams don't
|
| 146 |
+
// persist on disk forever (gh-32730). TeamDelete removes entries to
|
| 147 |
+
// avoid double-cleanup. Lives here (not teamHelpers.ts) so
|
| 148 |
+
// resetStateForTests() clears it between tests.
|
| 149 |
+
sessionCreatedTeams: Set<string>
|
| 150 |
+
// Session-only trust flag for home directory (not persisted to disk)
|
| 151 |
+
// When running from home dir, trust dialog is shown but not saved to disk.
|
| 152 |
+
// This flag allows features requiring trust to work during the session.
|
| 153 |
+
sessionTrustAccepted: boolean
|
| 154 |
+
// Session-only flag to disable session persistence to disk
|
| 155 |
+
sessionPersistenceDisabled: boolean
|
| 156 |
+
// Track if user has exited plan mode in this session (for re-entry guidance)
|
| 157 |
+
hasExitedPlanMode: boolean
|
| 158 |
+
// Track if we need to show the plan mode exit attachment (one-time notification)
|
| 159 |
+
needsPlanModeExitAttachment: boolean
|
| 160 |
+
// Track if we need to show the auto mode exit attachment (one-time notification)
|
| 161 |
+
needsAutoModeExitAttachment: boolean
|
| 162 |
+
// Track if LSP plugin recommendation has been shown this session (only show once)
|
| 163 |
+
lspRecommendationShownThisSession: boolean
|
| 164 |
+
// SDK init event state - jsonSchema for structured output
|
| 165 |
+
initJsonSchema: Record<string, unknown> | null
|
| 166 |
+
// Registered hooks - SDK callbacks and plugin native hooks
|
| 167 |
+
registeredHooks: Partial<Record<HookEvent, RegisteredHookMatcher[]>> | null
|
| 168 |
+
// Cache for plan slugs: sessionId -> wordSlug
|
| 169 |
+
planSlugCache: Map<string, string>
|
| 170 |
+
// Track teleported session for reliability logging
|
| 171 |
+
teleportedSessionInfo: {
|
| 172 |
+
isTeleported: boolean
|
| 173 |
+
hasLoggedFirstMessage: boolean
|
| 174 |
+
sessionId: string | null
|
| 175 |
+
} | null
|
| 176 |
+
// Track invoked skills for preservation across compaction
|
| 177 |
+
// Keys are composite: `${agentId ?? ''}:${skillName}` to prevent cross-agent overwrites
|
| 178 |
+
invokedSkills: Map<
|
| 179 |
+
string,
|
| 180 |
+
{
|
| 181 |
+
skillName: string
|
| 182 |
+
skillPath: string
|
| 183 |
+
content: string
|
| 184 |
+
invokedAt: number
|
| 185 |
+
agentId: string | null
|
| 186 |
+
}
|
| 187 |
+
>
|
| 188 |
+
// Track slow operations for dev bar display (ant-only)
|
| 189 |
+
slowOperations: Array<{
|
| 190 |
+
operation: string
|
| 191 |
+
durationMs: number
|
| 192 |
+
timestamp: number
|
| 193 |
+
}>
|
| 194 |
+
// SDK-provided betas (e.g., context-1m-2025-08-07)
|
| 195 |
+
sdkBetas: string[] | undefined
|
| 196 |
+
// Main thread agent type (from --agent flag or settings)
|
| 197 |
+
mainThreadAgentType: string | undefined
|
| 198 |
+
// Remote mode (--remote flag)
|
| 199 |
+
isRemoteMode: boolean
|
| 200 |
+
// Direct connect server URL (for display in header)
|
| 201 |
+
directConnectServerUrl: string | undefined
|
| 202 |
+
// System prompt section cache state
|
| 203 |
+
systemPromptSectionCache: Map<string, string | null>
|
| 204 |
+
// Last date emitted to the model (for detecting midnight date changes)
|
| 205 |
+
lastEmittedDate: string | null
|
| 206 |
+
// Additional directories from --add-dir flag (for CLAUDE.md loading)
|
| 207 |
+
additionalDirectoriesForClaudeMd: string[]
|
| 208 |
+
// Channel server allowlist from --channels flag (servers whose channel
|
| 209 |
+
// notifications should register this session). Parsed once in main.tsx —
|
| 210 |
+
// the tag decides trust model: 'plugin' → marketplace verification +
|
| 211 |
+
// allowlist, 'server' → allowlist always fails (schema is plugin-only).
|
| 212 |
+
// Either kind needs entry.dev to bypass allowlist.
|
| 213 |
+
allowedChannels: ChannelEntry[]
|
| 214 |
+
// True if any entry in allowedChannels came from
|
| 215 |
+
// --dangerously-load-development-channels (so ChannelsNotice can name the
|
| 216 |
+
// right flag in policy-blocked messages)
|
| 217 |
+
hasDevChannels: boolean
|
| 218 |
+
// Dir containing the session's `.jsonl`; null = derive from originalCwd.
|
| 219 |
+
sessionProjectDir: string | null
|
| 220 |
+
// Cached prompt cache 1h TTL allowlist from GrowthBook (session-stable)
|
| 221 |
+
promptCache1hAllowlist: string[] | null
|
| 222 |
+
// Cached 1h TTL user eligibility (session-stable). Latched on first
|
| 223 |
+
// evaluation so mid-session overage flips don't change the cache_control
|
| 224 |
+
// TTL, which would bust the server-side prompt cache.
|
| 225 |
+
promptCache1hEligible: boolean | null
|
| 226 |
+
// Sticky-on latch for AFK_MODE_BETA_HEADER. Once auto mode is first
|
| 227 |
+
// activated, keep sending the header for the rest of the session so
|
| 228 |
+
// Shift+Tab toggles don't bust the ~50-70K token prompt cache.
|
| 229 |
+
afkModeHeaderLatched: boolean | null
|
| 230 |
+
// Sticky-on latch for FAST_MODE_BETA_HEADER. Once fast mode is first
|
| 231 |
+
// enabled, keep sending the header so cooldown enter/exit doesn't
|
| 232 |
+
// double-bust the prompt cache. The `speed` body param stays dynamic.
|
| 233 |
+
fastModeHeaderLatched: boolean | null
|
| 234 |
+
// Sticky-on latch for the cache-editing beta header. Once cached
|
| 235 |
+
// microcompact is first enabled, keep sending the header so mid-session
|
| 236 |
+
// GrowthBook/settings toggles don't bust the prompt cache.
|
| 237 |
+
cacheEditingHeaderLatched: boolean | null
|
| 238 |
+
// Sticky-on latch for clearing thinking from prior tool loops. Triggered
|
| 239 |
+
// when >1h since last API call (confirmed cache miss — no cache-hit
|
| 240 |
+
// benefit to keeping thinking). Once latched, stays on so the newly-warmed
|
| 241 |
+
// thinking-cleared cache isn't busted by flipping back to keep:'all'.
|
| 242 |
+
thinkingClearLatched: boolean | null
|
| 243 |
+
// Current prompt ID (UUID) correlating a user prompt with subsequent OTel events
|
| 244 |
+
promptId: string | null
|
| 245 |
+
// Last API requestId for the main conversation chain (not subagents).
|
| 246 |
+
// Updated after each successful API response for main-session queries.
|
| 247 |
+
// Read at shutdown to send cache eviction hints to inference.
|
| 248 |
+
lastMainRequestId: string | undefined
|
| 249 |
+
// Timestamp (Date.now()) of the last successful API call completion.
|
| 250 |
+
// Used to compute timeSinceLastApiCallMs in tengu_api_success for
|
| 251 |
+
// correlating cache misses with idle time (cache TTL is ~5min).
|
| 252 |
+
lastApiCompletionTimestamp: number | null
|
| 253 |
+
// Set to true after compaction (auto or manual /compact). Consumed by
|
| 254 |
+
// logAPISuccess to tag the first post-compaction API call so we can
|
| 255 |
+
// distinguish compaction-induced cache misses from TTL expiry.
|
| 256 |
+
pendingPostCompaction: boolean
|
| 257 |
+
}
|
| 258 |
+
|
| 259 |
+
// ALSO HERE - THINK THRICE BEFORE MODIFYING
|
| 260 |
+
function getInitialState(): State {
|
| 261 |
+
// Resolve symlinks in cwd to match behavior of shell.ts setCwd
|
| 262 |
+
// This ensures consistency with how paths are sanitized for session storage
|
| 263 |
+
let resolvedCwd = ''
|
| 264 |
+
if (
|
| 265 |
+
typeof process !== 'undefined' &&
|
| 266 |
+
typeof process.cwd === 'function' &&
|
| 267 |
+
typeof realpathSync === 'function'
|
| 268 |
+
) {
|
| 269 |
+
const rawCwd = cwd()
|
| 270 |
+
try {
|
| 271 |
+
resolvedCwd = realpathSync(rawCwd).normalize('NFC')
|
| 272 |
+
} catch {
|
| 273 |
+
// File Provider EPERM on CloudStorage mounts (lstat per path component).
|
| 274 |
+
resolvedCwd = rawCwd.normalize('NFC')
|
| 275 |
+
}
|
| 276 |
+
}
|
| 277 |
+
const state: State = {
|
| 278 |
+
originalCwd: resolvedCwd,
|
| 279 |
+
projectRoot: resolvedCwd,
|
| 280 |
+
totalCostUSD: 0,
|
| 281 |
+
totalAPIDuration: 0,
|
| 282 |
+
totalAPIDurationWithoutRetries: 0,
|
| 283 |
+
totalToolDuration: 0,
|
| 284 |
+
turnHookDurationMs: 0,
|
| 285 |
+
turnToolDurationMs: 0,
|
| 286 |
+
turnClassifierDurationMs: 0,
|
| 287 |
+
turnToolCount: 0,
|
| 288 |
+
turnHookCount: 0,
|
| 289 |
+
turnClassifierCount: 0,
|
| 290 |
+
startTime: Date.now(),
|
| 291 |
+
lastInteractionTime: Date.now(),
|
| 292 |
+
totalLinesAdded: 0,
|
| 293 |
+
totalLinesRemoved: 0,
|
| 294 |
+
hasUnknownModelCost: false,
|
| 295 |
+
cwd: resolvedCwd,
|
| 296 |
+
modelUsage: {},
|
| 297 |
+
mainLoopModelOverride: undefined,
|
| 298 |
+
initialMainLoopModel: null,
|
| 299 |
+
modelStrings: null,
|
| 300 |
+
isInteractive: false,
|
| 301 |
+
kairosActive: false,
|
| 302 |
+
strictToolResultPairing: false,
|
| 303 |
+
sdkAgentProgressSummariesEnabled: false,
|
| 304 |
+
userMsgOptIn: false,
|
| 305 |
+
clientType: 'cli',
|
| 306 |
+
sessionSource: undefined,
|
| 307 |
+
questionPreviewFormat: undefined,
|
| 308 |
+
sessionIngressToken: undefined,
|
| 309 |
+
oauthTokenFromFd: undefined,
|
| 310 |
+
apiKeyFromFd: undefined,
|
| 311 |
+
flagSettingsPath: undefined,
|
| 312 |
+
flagSettingsInline: null,
|
| 313 |
+
allowedSettingSources: [
|
| 314 |
+
'userSettings',
|
| 315 |
+
'projectSettings',
|
| 316 |
+
'localSettings',
|
| 317 |
+
'flagSettings',
|
| 318 |
+
'policySettings',
|
| 319 |
+
],
|
| 320 |
+
// Telemetry state
|
| 321 |
+
meter: null,
|
| 322 |
+
sessionCounter: null,
|
| 323 |
+
locCounter: null,
|
| 324 |
+
prCounter: null,
|
| 325 |
+
commitCounter: null,
|
| 326 |
+
costCounter: null,
|
| 327 |
+
tokenCounter: null,
|
| 328 |
+
codeEditToolDecisionCounter: null,
|
| 329 |
+
activeTimeCounter: null,
|
| 330 |
+
statsStore: null,
|
| 331 |
+
sessionId: randomUUID() as SessionId,
|
| 332 |
+
parentSessionId: undefined,
|
| 333 |
+
// Logger state
|
| 334 |
+
loggerProvider: null,
|
| 335 |
+
eventLogger: null,
|
| 336 |
+
// Meter provider state
|
| 337 |
+
meterProvider: null,
|
| 338 |
+
tracerProvider: null,
|
| 339 |
+
// Agent color state
|
| 340 |
+
agentColorMap: new Map(),
|
| 341 |
+
agentColorIndex: 0,
|
| 342 |
+
// Last API request for bug reports
|
| 343 |
+
lastAPIRequest: null,
|
| 344 |
+
lastAPIRequestMessages: null,
|
| 345 |
+
// Last auto-mode classifier request(s) for /share transcript
|
| 346 |
+
lastClassifierRequests: null,
|
| 347 |
+
cachedClaudeMdContent: null,
|
| 348 |
+
// In-memory error log for recent errors
|
| 349 |
+
inMemoryErrorLog: [],
|
| 350 |
+
// Session-only plugins from --plugin-dir flag
|
| 351 |
+
inlinePlugins: [],
|
| 352 |
+
// Explicit --chrome / --no-chrome flag value (undefined = not set on CLI)
|
| 353 |
+
chromeFlagOverride: undefined,
|
| 354 |
+
// Use cowork_plugins directory instead of plugins
|
| 355 |
+
useCoworkPlugins: false,
|
| 356 |
+
// Session-only bypass permissions mode flag (not persisted)
|
| 357 |
+
sessionBypassPermissionsMode: false,
|
| 358 |
+
// Scheduled tasks disabled until flag or dialog enables them
|
| 359 |
+
scheduledTasksEnabled: false,
|
| 360 |
+
sessionCronTasks: [],
|
| 361 |
+
sessionCreatedTeams: new Set(),
|
| 362 |
+
// Session-only trust flag (not persisted to disk)
|
| 363 |
+
sessionTrustAccepted: false,
|
| 364 |
+
// Session-only flag to disable session persistence to disk
|
| 365 |
+
sessionPersistenceDisabled: false,
|
| 366 |
+
// Track if user has exited plan mode in this session
|
| 367 |
+
hasExitedPlanMode: false,
|
| 368 |
+
// Track if we need to show the plan mode exit attachment
|
| 369 |
+
needsPlanModeExitAttachment: false,
|
| 370 |
+
// Track if we need to show the auto mode exit attachment
|
| 371 |
+
needsAutoModeExitAttachment: false,
|
| 372 |
+
// Track if LSP plugin recommendation has been shown this session
|
| 373 |
+
lspRecommendationShownThisSession: false,
|
| 374 |
+
// SDK init event state
|
| 375 |
+
initJsonSchema: null,
|
| 376 |
+
registeredHooks: null,
|
| 377 |
+
// Cache for plan slugs
|
| 378 |
+
planSlugCache: new Map(),
|
| 379 |
+
// Track teleported session for reliability logging
|
| 380 |
+
teleportedSessionInfo: null,
|
| 381 |
+
// Track invoked skills for preservation across compaction
|
| 382 |
+
invokedSkills: new Map(),
|
| 383 |
+
// Track slow operations for dev bar display
|
| 384 |
+
slowOperations: [],
|
| 385 |
+
// SDK-provided betas
|
| 386 |
+
sdkBetas: undefined,
|
| 387 |
+
// Main thread agent type
|
| 388 |
+
mainThreadAgentType: undefined,
|
| 389 |
+
// Remote mode
|
| 390 |
+
isRemoteMode: false,
|
| 391 |
+
...(process.env.USER_TYPE === 'ant'
|
| 392 |
+
? {
|
| 393 |
+
replBridgeActive: false,
|
| 394 |
+
}
|
| 395 |
+
: {}),
|
| 396 |
+
// Direct connect server URL
|
| 397 |
+
directConnectServerUrl: undefined,
|
| 398 |
+
// System prompt section cache state
|
| 399 |
+
systemPromptSectionCache: new Map(),
|
| 400 |
+
// Last date emitted to the model
|
| 401 |
+
lastEmittedDate: null,
|
| 402 |
+
// Additional directories from --add-dir flag (for CLAUDE.md loading)
|
| 403 |
+
additionalDirectoriesForClaudeMd: [],
|
| 404 |
+
// Channel server allowlist from --channels flag
|
| 405 |
+
allowedChannels: [],
|
| 406 |
+
hasDevChannels: false,
|
| 407 |
+
// Session project dir (null = derive from originalCwd)
|
| 408 |
+
sessionProjectDir: null,
|
| 409 |
+
// Prompt cache 1h allowlist (null = not yet fetched from GrowthBook)
|
| 410 |
+
promptCache1hAllowlist: null,
|
| 411 |
+
// Prompt cache 1h eligibility (null = not yet evaluated)
|
| 412 |
+
promptCache1hEligible: null,
|
| 413 |
+
// Beta header latches (null = not yet triggered)
|
| 414 |
+
afkModeHeaderLatched: null,
|
| 415 |
+
fastModeHeaderLatched: null,
|
| 416 |
+
cacheEditingHeaderLatched: null,
|
| 417 |
+
thinkingClearLatched: null,
|
| 418 |
+
// Current prompt ID
|
| 419 |
+
promptId: null,
|
| 420 |
+
lastMainRequestId: undefined,
|
| 421 |
+
lastApiCompletionTimestamp: null,
|
| 422 |
+
pendingPostCompaction: false,
|
| 423 |
+
}
|
| 424 |
+
|
| 425 |
+
return state
|
| 426 |
+
}
|
| 427 |
+
|
| 428 |
+
// AND ESPECIALLY HERE
|
| 429 |
+
const STATE: State = getInitialState()
|
| 430 |
+
|
| 431 |
+
export function getSessionId(): SessionId {
|
| 432 |
+
return STATE.sessionId
|
| 433 |
+
}
|
| 434 |
+
|
| 435 |
+
export function regenerateSessionId(
|
| 436 |
+
options: { setCurrentAsParent?: boolean } = {},
|
| 437 |
+
): SessionId {
|
| 438 |
+
if (options.setCurrentAsParent) {
|
| 439 |
+
STATE.parentSessionId = STATE.sessionId
|
| 440 |
+
}
|
| 441 |
+
// Drop the outgoing session's plan-slug entry so the Map doesn't
|
| 442 |
+
// accumulate stale keys. Callers that need to carry the slug across
|
| 443 |
+
// (REPL.tsx clearContext) read it before calling clearConversation.
|
| 444 |
+
STATE.planSlugCache.delete(STATE.sessionId)
|
| 445 |
+
// Regenerated sessions live in the current project: reset projectDir to
|
| 446 |
+
// null so getTranscriptPath() derives from originalCwd.
|
| 447 |
+
STATE.sessionId = randomUUID() as SessionId
|
| 448 |
+
STATE.sessionProjectDir = null
|
| 449 |
+
return STATE.sessionId
|
| 450 |
+
}
|
| 451 |
+
|
| 452 |
+
export function getParentSessionId(): SessionId | undefined {
|
| 453 |
+
return STATE.parentSessionId
|
| 454 |
+
}
|
| 455 |
+
|
| 456 |
+
/**
|
| 457 |
+
* Atomically switch the active session. `sessionId` and `sessionProjectDir`
|
| 458 |
+
* always change together — there is no separate setter for either, so they
|
| 459 |
+
* cannot drift out of sync (CC-34).
|
| 460 |
+
*
|
| 461 |
+
* @param projectDir — directory containing `<sessionId>.jsonl`. Omit (or
|
| 462 |
+
* pass `null`) for sessions in the current project — the path will derive
|
| 463 |
+
* from originalCwd at read time. Pass `dirname(transcriptPath)` when the
|
| 464 |
+
* session lives in a different project directory (git worktrees,
|
| 465 |
+
* cross-project resume). Every call resets the project dir; it never
|
| 466 |
+
* carries over from the previous session.
|
| 467 |
+
*/
|
| 468 |
+
export function switchSession(
|
| 469 |
+
sessionId: SessionId,
|
| 470 |
+
projectDir: string | null = null,
|
| 471 |
+
): void {
|
| 472 |
+
// Drop the outgoing session's plan-slug entry so the Map stays bounded
|
| 473 |
+
// across repeated /resume. Only the current session's slug is ever read
|
| 474 |
+
// (plans.ts getPlanSlug defaults to getSessionId()).
|
| 475 |
+
STATE.planSlugCache.delete(STATE.sessionId)
|
| 476 |
+
STATE.sessionId = sessionId
|
| 477 |
+
STATE.sessionProjectDir = projectDir
|
| 478 |
+
sessionSwitched.emit(sessionId)
|
| 479 |
+
}
|
| 480 |
+
|
| 481 |
+
const sessionSwitched = createSignal<[id: SessionId]>()
|
| 482 |
+
|
| 483 |
+
/**
|
| 484 |
+
* Register a callback that fires when switchSession changes the active
|
| 485 |
+
* sessionId. bootstrap can't import listeners directly (DAG leaf), so
|
| 486 |
+
* callers register themselves. concurrentSessions.ts uses this to keep the
|
| 487 |
+
* PID file's sessionId in sync with --resume.
|
| 488 |
+
*/
|
| 489 |
+
export const onSessionSwitch = sessionSwitched.subscribe
|
| 490 |
+
|
| 491 |
+
/**
|
| 492 |
+
* Project directory the current session's transcript lives in, or `null` if
|
| 493 |
+
* the session was created in the current project (common case — derive from
|
| 494 |
+
* originalCwd). See `switchSession()`.
|
| 495 |
+
*/
|
| 496 |
+
export function getSessionProjectDir(): string | null {
|
| 497 |
+
return STATE.sessionProjectDir
|
| 498 |
+
}
|
| 499 |
+
|
| 500 |
+
export function getOriginalCwd(): string {
|
| 501 |
+
return STATE.originalCwd
|
| 502 |
+
}
|
| 503 |
+
|
| 504 |
+
/**
|
| 505 |
+
* Get the stable project root directory.
|
| 506 |
+
* Unlike getOriginalCwd(), this is never updated by mid-session EnterWorktreeTool
|
| 507 |
+
* (so skills/history stay stable when entering a throwaway worktree).
|
| 508 |
+
* It IS set at startup by --worktree, since that worktree is the session's project.
|
| 509 |
+
* Use for project identity (history, skills, sessions) not file operations.
|
| 510 |
+
*/
|
| 511 |
+
export function getProjectRoot(): string {
|
| 512 |
+
return STATE.projectRoot
|
| 513 |
+
}
|
| 514 |
+
|
| 515 |
+
export function setOriginalCwd(cwd: string): void {
|
| 516 |
+
STATE.originalCwd = cwd.normalize('NFC')
|
| 517 |
+
}
|
| 518 |
+
|
| 519 |
+
/**
|
| 520 |
+
* Only for --worktree startup flag. Mid-session EnterWorktreeTool must NOT
|
| 521 |
+
* call this — skills/history should stay anchored to where the session started.
|
| 522 |
+
*/
|
| 523 |
+
export function setProjectRoot(cwd: string): void {
|
| 524 |
+
STATE.projectRoot = cwd.normalize('NFC')
|
| 525 |
+
}
|
| 526 |
+
|
| 527 |
+
export function getCwdState(): string {
|
| 528 |
+
return STATE.cwd
|
| 529 |
+
}
|
| 530 |
+
|
| 531 |
+
export function setCwdState(cwd: string): void {
|
| 532 |
+
STATE.cwd = cwd.normalize('NFC')
|
| 533 |
+
}
|
| 534 |
+
|
| 535 |
+
export function getDirectConnectServerUrl(): string | undefined {
|
| 536 |
+
return STATE.directConnectServerUrl
|
| 537 |
+
}
|
| 538 |
+
|
| 539 |
+
export function setDirectConnectServerUrl(url: string): void {
|
| 540 |
+
STATE.directConnectServerUrl = url
|
| 541 |
+
}
|
| 542 |
+
|
| 543 |
+
export function addToTotalDurationState(
|
| 544 |
+
duration: number,
|
| 545 |
+
durationWithoutRetries: number,
|
| 546 |
+
): void {
|
| 547 |
+
STATE.totalAPIDuration += duration
|
| 548 |
+
STATE.totalAPIDurationWithoutRetries += durationWithoutRetries
|
| 549 |
+
}
|
| 550 |
+
|
| 551 |
+
export function resetTotalDurationStateAndCost_FOR_TESTS_ONLY(): void {
|
| 552 |
+
STATE.totalAPIDuration = 0
|
| 553 |
+
STATE.totalAPIDurationWithoutRetries = 0
|
| 554 |
+
STATE.totalCostUSD = 0
|
| 555 |
+
}
|
| 556 |
+
|
| 557 |
+
export function addToTotalCostState(
|
| 558 |
+
cost: number,
|
| 559 |
+
modelUsage: ModelUsage,
|
| 560 |
+
model: string,
|
| 561 |
+
): void {
|
| 562 |
+
STATE.modelUsage[model] = modelUsage
|
| 563 |
+
STATE.totalCostUSD += cost
|
| 564 |
+
}
|
| 565 |
+
|
| 566 |
+
export function getTotalCostUSD(): number {
|
| 567 |
+
return STATE.totalCostUSD
|
| 568 |
+
}
|
| 569 |
+
|
| 570 |
+
export function getTotalAPIDuration(): number {
|
| 571 |
+
return STATE.totalAPIDuration
|
| 572 |
+
}
|
| 573 |
+
|
| 574 |
+
export function getTotalDuration(): number {
|
| 575 |
+
return Date.now() - STATE.startTime
|
| 576 |
+
}
|
| 577 |
+
|
| 578 |
+
export function getTotalAPIDurationWithoutRetries(): number {
|
| 579 |
+
return STATE.totalAPIDurationWithoutRetries
|
| 580 |
+
}
|
| 581 |
+
|
| 582 |
+
export function getTotalToolDuration(): number {
|
| 583 |
+
return STATE.totalToolDuration
|
| 584 |
+
}
|
| 585 |
+
|
| 586 |
+
export function addToToolDuration(duration: number): void {
|
| 587 |
+
STATE.totalToolDuration += duration
|
| 588 |
+
STATE.turnToolDurationMs += duration
|
| 589 |
+
STATE.turnToolCount++
|
| 590 |
+
}
|
| 591 |
+
|
| 592 |
+
export function getTurnHookDurationMs(): number {
|
| 593 |
+
return STATE.turnHookDurationMs
|
| 594 |
+
}
|
| 595 |
+
|
| 596 |
+
export function addToTurnHookDuration(duration: number): void {
|
| 597 |
+
STATE.turnHookDurationMs += duration
|
| 598 |
+
STATE.turnHookCount++
|
| 599 |
+
}
|
| 600 |
+
|
| 601 |
+
export function resetTurnHookDuration(): void {
|
| 602 |
+
STATE.turnHookDurationMs = 0
|
| 603 |
+
STATE.turnHookCount = 0
|
| 604 |
+
}
|
| 605 |
+
|
| 606 |
+
export function getTurnHookCount(): number {
|
| 607 |
+
return STATE.turnHookCount
|
| 608 |
+
}
|
| 609 |
+
|
| 610 |
+
export function getTurnToolDurationMs(): number {
|
| 611 |
+
return STATE.turnToolDurationMs
|
| 612 |
+
}
|
| 613 |
+
|
| 614 |
+
export function resetTurnToolDuration(): void {
|
| 615 |
+
STATE.turnToolDurationMs = 0
|
| 616 |
+
STATE.turnToolCount = 0
|
| 617 |
+
}
|
| 618 |
+
|
| 619 |
+
export function getTurnToolCount(): number {
|
| 620 |
+
return STATE.turnToolCount
|
| 621 |
+
}
|
| 622 |
+
|
| 623 |
+
export function getTurnClassifierDurationMs(): number {
|
| 624 |
+
return STATE.turnClassifierDurationMs
|
| 625 |
+
}
|
| 626 |
+
|
| 627 |
+
export function addToTurnClassifierDuration(duration: number): void {
|
| 628 |
+
STATE.turnClassifierDurationMs += duration
|
| 629 |
+
STATE.turnClassifierCount++
|
| 630 |
+
}
|
| 631 |
+
|
| 632 |
+
export function resetTurnClassifierDuration(): void {
|
| 633 |
+
STATE.turnClassifierDurationMs = 0
|
| 634 |
+
STATE.turnClassifierCount = 0
|
| 635 |
+
}
|
| 636 |
+
|
| 637 |
+
export function getTurnClassifierCount(): number {
|
| 638 |
+
return STATE.turnClassifierCount
|
| 639 |
+
}
|
| 640 |
+
|
| 641 |
+
export function getStatsStore(): {
|
| 642 |
+
observe(name: string, value: number): void
|
| 643 |
+
} | null {
|
| 644 |
+
return STATE.statsStore
|
| 645 |
+
}
|
| 646 |
+
|
| 647 |
+
export function setStatsStore(
|
| 648 |
+
store: { observe(name: string, value: number): void } | null,
|
| 649 |
+
): void {
|
| 650 |
+
STATE.statsStore = store
|
| 651 |
+
}
|
| 652 |
+
|
| 653 |
+
/**
|
| 654 |
+
* Marks that an interaction occurred.
|
| 655 |
+
*
|
| 656 |
+
* By default the actual Date.now() call is deferred until the next Ink render
|
| 657 |
+
* frame (via flushInteractionTime()) so we avoid calling Date.now() on every
|
| 658 |
+
* single keypress.
|
| 659 |
+
*
|
| 660 |
+
* Pass `immediate = true` when calling from React useEffect callbacks or
|
| 661 |
+
* other code that runs *after* the Ink render cycle has already flushed.
|
| 662 |
+
* Without it the timestamp stays stale until the next render, which may never
|
| 663 |
+
* come if the user is idle (e.g. permission dialog waiting for input).
|
| 664 |
+
*/
|
| 665 |
+
let interactionTimeDirty = false
|
| 666 |
+
|
| 667 |
+
export function updateLastInteractionTime(immediate?: boolean): void {
|
| 668 |
+
if (immediate) {
|
| 669 |
+
flushInteractionTime_inner()
|
| 670 |
+
} else {
|
| 671 |
+
interactionTimeDirty = true
|
| 672 |
+
}
|
| 673 |
+
}
|
| 674 |
+
|
| 675 |
+
/**
|
| 676 |
+
* If an interaction was recorded since the last flush, update the timestamp
|
| 677 |
+
* now. Called by Ink before each render cycle so we batch many keypresses into
|
| 678 |
+
* a single Date.now() call.
|
| 679 |
+
*/
|
| 680 |
+
export function flushInteractionTime(): void {
|
| 681 |
+
if (interactionTimeDirty) {
|
| 682 |
+
flushInteractionTime_inner()
|
| 683 |
+
}
|
| 684 |
+
}
|
| 685 |
+
|
| 686 |
+
function flushInteractionTime_inner(): void {
|
| 687 |
+
STATE.lastInteractionTime = Date.now()
|
| 688 |
+
interactionTimeDirty = false
|
| 689 |
+
}
|
| 690 |
+
|
| 691 |
+
export function addToTotalLinesChanged(added: number, removed: number): void {
|
| 692 |
+
STATE.totalLinesAdded += added
|
| 693 |
+
STATE.totalLinesRemoved += removed
|
| 694 |
+
}
|
| 695 |
+
|
| 696 |
+
export function getTotalLinesAdded(): number {
|
| 697 |
+
return STATE.totalLinesAdded
|
| 698 |
+
}
|
| 699 |
+
|
| 700 |
+
export function getTotalLinesRemoved(): number {
|
| 701 |
+
return STATE.totalLinesRemoved
|
| 702 |
+
}
|
| 703 |
+
|
| 704 |
+
export function getTotalInputTokens(): number {
|
| 705 |
+
return sumBy(Object.values(STATE.modelUsage), 'inputTokens')
|
| 706 |
+
}
|
| 707 |
+
|
| 708 |
+
export function getTotalOutputTokens(): number {
|
| 709 |
+
return sumBy(Object.values(STATE.modelUsage), 'outputTokens')
|
| 710 |
+
}
|
| 711 |
+
|
| 712 |
+
export function getTotalCacheReadInputTokens(): number {
|
| 713 |
+
return sumBy(Object.values(STATE.modelUsage), 'cacheReadInputTokens')
|
| 714 |
+
}
|
| 715 |
+
|
| 716 |
+
export function getTotalCacheCreationInputTokens(): number {
|
| 717 |
+
return sumBy(Object.values(STATE.modelUsage), 'cacheCreationInputTokens')
|
| 718 |
+
}
|
| 719 |
+
|
| 720 |
+
export function getTotalWebSearchRequests(): number {
|
| 721 |
+
return sumBy(Object.values(STATE.modelUsage), 'webSearchRequests')
|
| 722 |
+
}
|
| 723 |
+
|
| 724 |
+
let outputTokensAtTurnStart = 0
|
| 725 |
+
let currentTurnTokenBudget: number | null = null
|
| 726 |
+
export function getTurnOutputTokens(): number {
|
| 727 |
+
return getTotalOutputTokens() - outputTokensAtTurnStart
|
| 728 |
+
}
|
| 729 |
+
export function getCurrentTurnTokenBudget(): number | null {
|
| 730 |
+
return currentTurnTokenBudget
|
| 731 |
+
}
|
| 732 |
+
let budgetContinuationCount = 0
|
| 733 |
+
export function snapshotOutputTokensForTurn(budget: number | null): void {
|
| 734 |
+
outputTokensAtTurnStart = getTotalOutputTokens()
|
| 735 |
+
currentTurnTokenBudget = budget
|
| 736 |
+
budgetContinuationCount = 0
|
| 737 |
+
}
|
| 738 |
+
export function getBudgetContinuationCount(): number {
|
| 739 |
+
return budgetContinuationCount
|
| 740 |
+
}
|
| 741 |
+
export function incrementBudgetContinuationCount(): void {
|
| 742 |
+
budgetContinuationCount++
|
| 743 |
+
}
|
| 744 |
+
|
| 745 |
+
export function setHasUnknownModelCost(): void {
|
| 746 |
+
STATE.hasUnknownModelCost = true
|
| 747 |
+
}
|
| 748 |
+
|
| 749 |
+
export function hasUnknownModelCost(): boolean {
|
| 750 |
+
return STATE.hasUnknownModelCost
|
| 751 |
+
}
|
| 752 |
+
|
| 753 |
+
export function getLastMainRequestId(): string | undefined {
|
| 754 |
+
return STATE.lastMainRequestId
|
| 755 |
+
}
|
| 756 |
+
|
| 757 |
+
export function setLastMainRequestId(requestId: string): void {
|
| 758 |
+
STATE.lastMainRequestId = requestId
|
| 759 |
+
}
|
| 760 |
+
|
| 761 |
+
export function getLastApiCompletionTimestamp(): number | null {
|
| 762 |
+
return STATE.lastApiCompletionTimestamp
|
| 763 |
+
}
|
| 764 |
+
|
| 765 |
+
export function setLastApiCompletionTimestamp(timestamp: number): void {
|
| 766 |
+
STATE.lastApiCompletionTimestamp = timestamp
|
| 767 |
+
}
|
| 768 |
+
|
| 769 |
+
/** Mark that a compaction just occurred. The next API success event will
|
| 770 |
+
* include isPostCompaction=true, then the flag auto-resets. */
|
| 771 |
+
export function markPostCompaction(): void {
|
| 772 |
+
STATE.pendingPostCompaction = true
|
| 773 |
+
}
|
| 774 |
+
|
| 775 |
+
/** Consume the post-compaction flag. Returns true once after compaction,
|
| 776 |
+
* then returns false until the next compaction. */
|
| 777 |
+
export function consumePostCompaction(): boolean {
|
| 778 |
+
const was = STATE.pendingPostCompaction
|
| 779 |
+
STATE.pendingPostCompaction = false
|
| 780 |
+
return was
|
| 781 |
+
}
|
| 782 |
+
|
| 783 |
+
export function getLastInteractionTime(): number {
|
| 784 |
+
return STATE.lastInteractionTime
|
| 785 |
+
}
|
| 786 |
+
|
| 787 |
+
// Scroll drain suspension — background intervals check this before doing work
|
| 788 |
+
// so they don't compete with scroll frames for the event loop. Set by
|
| 789 |
+
// ScrollBox scrollBy/scrollTo, cleared SCROLL_DRAIN_IDLE_MS after the last
|
| 790 |
+
// scroll event. Module-scope (not in STATE) — ephemeral hot-path flag, no
|
| 791 |
+
// test-reset needed since the debounce timer self-clears.
|
| 792 |
+
let scrollDraining = false
|
| 793 |
+
let scrollDrainTimer: ReturnType<typeof setTimeout> | undefined
|
| 794 |
+
const SCROLL_DRAIN_IDLE_MS = 150
|
| 795 |
+
|
| 796 |
+
/** Mark that a scroll event just happened. Background intervals gate on
|
| 797 |
+
* getIsScrollDraining() and skip their work until the debounce clears. */
|
| 798 |
+
export function markScrollActivity(): void {
|
| 799 |
+
scrollDraining = true
|
| 800 |
+
if (scrollDrainTimer) clearTimeout(scrollDrainTimer)
|
| 801 |
+
scrollDrainTimer = setTimeout(() => {
|
| 802 |
+
scrollDraining = false
|
| 803 |
+
scrollDrainTimer = undefined
|
| 804 |
+
}, SCROLL_DRAIN_IDLE_MS)
|
| 805 |
+
scrollDrainTimer.unref?.()
|
| 806 |
+
}
|
| 807 |
+
|
| 808 |
+
/** True while scroll is actively draining (within 150ms of last event).
|
| 809 |
+
* Intervals should early-return when this is set — the work picks up next
|
| 810 |
+
* tick after scroll settles. */
|
| 811 |
+
export function getIsScrollDraining(): boolean {
|
| 812 |
+
return scrollDraining
|
| 813 |
+
}
|
| 814 |
+
|
| 815 |
+
/** Await this before expensive one-shot work (network, subprocess) that could
|
| 816 |
+
* coincide with scroll. Resolves immediately if not scrolling; otherwise
|
| 817 |
+
* polls at the idle interval until the flag clears. */
|
| 818 |
+
export async function waitForScrollIdle(): Promise<void> {
|
| 819 |
+
while (scrollDraining) {
|
| 820 |
+
// bootstrap-isolation forbids importing sleep() from src/utils/
|
| 821 |
+
// eslint-disable-next-line no-restricted-syntax
|
| 822 |
+
await new Promise(r => setTimeout(r, SCROLL_DRAIN_IDLE_MS).unref?.())
|
| 823 |
+
}
|
| 824 |
+
}
|
| 825 |
+
|
| 826 |
+
export function getModelUsage(): { [modelName: string]: ModelUsage } {
|
| 827 |
+
return STATE.modelUsage
|
| 828 |
+
}
|
| 829 |
+
|
| 830 |
+
export function getUsageForModel(model: string): ModelUsage | undefined {
|
| 831 |
+
return STATE.modelUsage[model]
|
| 832 |
+
}
|
| 833 |
+
|
| 834 |
+
/**
|
| 835 |
+
* Gets the model override set from the --model CLI flag or after the user
|
| 836 |
+
* updates their configured model.
|
| 837 |
+
*/
|
| 838 |
+
export function getMainLoopModelOverride(): ModelSetting | undefined {
|
| 839 |
+
return STATE.mainLoopModelOverride
|
| 840 |
+
}
|
| 841 |
+
|
| 842 |
+
export function getInitialMainLoopModel(): ModelSetting {
|
| 843 |
+
return STATE.initialMainLoopModel
|
| 844 |
+
}
|
| 845 |
+
|
| 846 |
+
export function setMainLoopModelOverride(
|
| 847 |
+
model: ModelSetting | undefined,
|
| 848 |
+
): void {
|
| 849 |
+
STATE.mainLoopModelOverride = model
|
| 850 |
+
}
|
| 851 |
+
|
| 852 |
+
export function setInitialMainLoopModel(model: ModelSetting): void {
|
| 853 |
+
STATE.initialMainLoopModel = model
|
| 854 |
+
}
|
| 855 |
+
|
| 856 |
+
export function getSdkBetas(): string[] | undefined {
|
| 857 |
+
return STATE.sdkBetas
|
| 858 |
+
}
|
| 859 |
+
|
| 860 |
+
export function setSdkBetas(betas: string[] | undefined): void {
|
| 861 |
+
STATE.sdkBetas = betas
|
| 862 |
+
}
|
| 863 |
+
|
| 864 |
+
export function resetCostState(): void {
|
| 865 |
+
STATE.totalCostUSD = 0
|
| 866 |
+
STATE.totalAPIDuration = 0
|
| 867 |
+
STATE.totalAPIDurationWithoutRetries = 0
|
| 868 |
+
STATE.totalToolDuration = 0
|
| 869 |
+
STATE.startTime = Date.now()
|
| 870 |
+
STATE.totalLinesAdded = 0
|
| 871 |
+
STATE.totalLinesRemoved = 0
|
| 872 |
+
STATE.hasUnknownModelCost = false
|
| 873 |
+
STATE.modelUsage = {}
|
| 874 |
+
STATE.promptId = null
|
| 875 |
+
}
|
| 876 |
+
|
| 877 |
+
/**
|
| 878 |
+
* Sets cost state values for session restore.
|
| 879 |
+
* Called by restoreCostStateForSession in cost-tracker.ts.
|
| 880 |
+
*/
|
| 881 |
+
export function setCostStateForRestore({
|
| 882 |
+
totalCostUSD,
|
| 883 |
+
totalAPIDuration,
|
| 884 |
+
totalAPIDurationWithoutRetries,
|
| 885 |
+
totalToolDuration,
|
| 886 |
+
totalLinesAdded,
|
| 887 |
+
totalLinesRemoved,
|
| 888 |
+
lastDuration,
|
| 889 |
+
modelUsage,
|
| 890 |
+
}: {
|
| 891 |
+
totalCostUSD: number
|
| 892 |
+
totalAPIDuration: number
|
| 893 |
+
totalAPIDurationWithoutRetries: number
|
| 894 |
+
totalToolDuration: number
|
| 895 |
+
totalLinesAdded: number
|
| 896 |
+
totalLinesRemoved: number
|
| 897 |
+
lastDuration: number | undefined
|
| 898 |
+
modelUsage: { [modelName: string]: ModelUsage } | undefined
|
| 899 |
+
}): void {
|
| 900 |
+
STATE.totalCostUSD = totalCostUSD
|
| 901 |
+
STATE.totalAPIDuration = totalAPIDuration
|
| 902 |
+
STATE.totalAPIDurationWithoutRetries = totalAPIDurationWithoutRetries
|
| 903 |
+
STATE.totalToolDuration = totalToolDuration
|
| 904 |
+
STATE.totalLinesAdded = totalLinesAdded
|
| 905 |
+
STATE.totalLinesRemoved = totalLinesRemoved
|
| 906 |
+
|
| 907 |
+
// Restore per-model usage breakdown
|
| 908 |
+
if (modelUsage) {
|
| 909 |
+
STATE.modelUsage = modelUsage
|
| 910 |
+
}
|
| 911 |
+
|
| 912 |
+
// Adjust startTime to make wall duration accumulate
|
| 913 |
+
if (lastDuration) {
|
| 914 |
+
STATE.startTime = Date.now() - lastDuration
|
| 915 |
+
}
|
| 916 |
+
}
|
| 917 |
+
|
| 918 |
+
// Only used in tests
|
| 919 |
+
export function resetStateForTests(): void {
|
| 920 |
+
if (process.env.NODE_ENV !== 'test') {
|
| 921 |
+
throw new Error('resetStateForTests can only be called in tests')
|
| 922 |
+
}
|
| 923 |
+
Object.entries(getInitialState()).forEach(([key, value]) => {
|
| 924 |
+
STATE[key as keyof State] = value as never
|
| 925 |
+
})
|
| 926 |
+
outputTokensAtTurnStart = 0
|
| 927 |
+
currentTurnTokenBudget = null
|
| 928 |
+
budgetContinuationCount = 0
|
| 929 |
+
sessionSwitched.clear()
|
| 930 |
+
}
|
| 931 |
+
|
| 932 |
+
// You shouldn't use this directly. See src/utils/model/modelStrings.ts::getModelStrings()
|
| 933 |
+
export function getModelStrings(): ModelStrings | null {
|
| 934 |
+
return STATE.modelStrings
|
| 935 |
+
}
|
| 936 |
+
|
| 937 |
+
// You shouldn't use this directly. See src/utils/model/modelStrings.ts
|
| 938 |
+
export function setModelStrings(modelStrings: ModelStrings): void {
|
| 939 |
+
STATE.modelStrings = modelStrings
|
| 940 |
+
}
|
| 941 |
+
|
| 942 |
+
// Test utility function to reset model strings for re-initialization.
|
| 943 |
+
// Separate from setModelStrings because we only want to accept 'null' in tests.
|
| 944 |
+
export function resetModelStringsForTestingOnly() {
|
| 945 |
+
STATE.modelStrings = null
|
| 946 |
+
}
|
| 947 |
+
|
| 948 |
+
export function setMeter(
|
| 949 |
+
meter: Meter,
|
| 950 |
+
createCounter: (name: string, options: MetricOptions) => AttributedCounter,
|
| 951 |
+
): void {
|
| 952 |
+
STATE.meter = meter
|
| 953 |
+
|
| 954 |
+
// Initialize all counters using the provided factory
|
| 955 |
+
STATE.sessionCounter = createCounter('claude_code.session.count', {
|
| 956 |
+
description: 'Count of CLI sessions started',
|
| 957 |
+
})
|
| 958 |
+
STATE.locCounter = createCounter('claude_code.lines_of_code.count', {
|
| 959 |
+
description:
|
| 960 |
+
"Count of lines of code modified, with the 'type' attribute indicating whether lines were added or removed",
|
| 961 |
+
})
|
| 962 |
+
STATE.prCounter = createCounter('claude_code.pull_request.count', {
|
| 963 |
+
description: 'Number of pull requests created',
|
| 964 |
+
})
|
| 965 |
+
STATE.commitCounter = createCounter('claude_code.commit.count', {
|
| 966 |
+
description: 'Number of git commits created',
|
| 967 |
+
})
|
| 968 |
+
STATE.costCounter = createCounter('claude_code.cost.usage', {
|
| 969 |
+
description: 'Cost of the Claude Code session',
|
| 970 |
+
unit: 'USD',
|
| 971 |
+
})
|
| 972 |
+
STATE.tokenCounter = createCounter('claude_code.token.usage', {
|
| 973 |
+
description: 'Number of tokens used',
|
| 974 |
+
unit: 'tokens',
|
| 975 |
+
})
|
| 976 |
+
STATE.codeEditToolDecisionCounter = createCounter(
|
| 977 |
+
'claude_code.code_edit_tool.decision',
|
| 978 |
+
{
|
| 979 |
+
description:
|
| 980 |
+
'Count of code editing tool permission decisions (accept/reject) for Edit, Write, and NotebookEdit tools',
|
| 981 |
+
},
|
| 982 |
+
)
|
| 983 |
+
STATE.activeTimeCounter = createCounter('claude_code.active_time.total', {
|
| 984 |
+
description: 'Total active time in seconds',
|
| 985 |
+
unit: 's',
|
| 986 |
+
})
|
| 987 |
+
}
|
| 988 |
+
|
| 989 |
+
export function getMeter(): Meter | null {
|
| 990 |
+
return STATE.meter
|
| 991 |
+
}
|
| 992 |
+
|
| 993 |
+
export function getSessionCounter(): AttributedCounter | null {
|
| 994 |
+
return STATE.sessionCounter
|
| 995 |
+
}
|
| 996 |
+
|
| 997 |
+
export function getLocCounter(): AttributedCounter | null {
|
| 998 |
+
return STATE.locCounter
|
| 999 |
+
}
|
| 1000 |
+
|
| 1001 |
+
export function getPrCounter(): AttributedCounter | null {
|
| 1002 |
+
return STATE.prCounter
|
| 1003 |
+
}
|
| 1004 |
+
|
| 1005 |
+
export function getCommitCounter(): AttributedCounter | null {
|
| 1006 |
+
return STATE.commitCounter
|
| 1007 |
+
}
|
| 1008 |
+
|
| 1009 |
+
export function getCostCounter(): AttributedCounter | null {
|
| 1010 |
+
return STATE.costCounter
|
| 1011 |
+
}
|
| 1012 |
+
|
| 1013 |
+
export function getTokenCounter(): AttributedCounter | null {
|
| 1014 |
+
return STATE.tokenCounter
|
| 1015 |
+
}
|
| 1016 |
+
|
| 1017 |
+
export function getCodeEditToolDecisionCounter(): AttributedCounter | null {
|
| 1018 |
+
return STATE.codeEditToolDecisionCounter
|
| 1019 |
+
}
|
| 1020 |
+
|
| 1021 |
+
export function getActiveTimeCounter(): AttributedCounter | null {
|
| 1022 |
+
return STATE.activeTimeCounter
|
| 1023 |
+
}
|
| 1024 |
+
|
| 1025 |
+
export function getLoggerProvider(): LoggerProvider | null {
|
| 1026 |
+
return STATE.loggerProvider
|
| 1027 |
+
}
|
| 1028 |
+
|
| 1029 |
+
export function setLoggerProvider(provider: LoggerProvider | null): void {
|
| 1030 |
+
STATE.loggerProvider = provider
|
| 1031 |
+
}
|
| 1032 |
+
|
| 1033 |
+
export function getEventLogger(): ReturnType<typeof logs.getLogger> | null {
|
| 1034 |
+
return STATE.eventLogger
|
| 1035 |
+
}
|
| 1036 |
+
|
| 1037 |
+
export function setEventLogger(
|
| 1038 |
+
logger: ReturnType<typeof logs.getLogger> | null,
|
| 1039 |
+
): void {
|
| 1040 |
+
STATE.eventLogger = logger
|
| 1041 |
+
}
|
| 1042 |
+
|
| 1043 |
+
export function getMeterProvider(): MeterProvider | null {
|
| 1044 |
+
return STATE.meterProvider
|
| 1045 |
+
}
|
| 1046 |
+
|
| 1047 |
+
export function setMeterProvider(provider: MeterProvider | null): void {
|
| 1048 |
+
STATE.meterProvider = provider
|
| 1049 |
+
}
|
| 1050 |
+
export function getTracerProvider(): BasicTracerProvider | null {
|
| 1051 |
+
return STATE.tracerProvider
|
| 1052 |
+
}
|
| 1053 |
+
export function setTracerProvider(provider: BasicTracerProvider | null): void {
|
| 1054 |
+
STATE.tracerProvider = provider
|
| 1055 |
+
}
|
| 1056 |
+
|
| 1057 |
+
export function getIsNonInteractiveSession(): boolean {
|
| 1058 |
+
return !STATE.isInteractive
|
| 1059 |
+
}
|
| 1060 |
+
|
| 1061 |
+
export function getIsInteractive(): boolean {
|
| 1062 |
+
return STATE.isInteractive
|
| 1063 |
+
}
|
| 1064 |
+
|
| 1065 |
+
export function setIsInteractive(value: boolean): void {
|
| 1066 |
+
STATE.isInteractive = value
|
| 1067 |
+
}
|
| 1068 |
+
|
| 1069 |
+
export function getClientType(): string {
|
| 1070 |
+
return STATE.clientType
|
| 1071 |
+
}
|
| 1072 |
+
|
| 1073 |
+
export function setClientType(type: string): void {
|
| 1074 |
+
STATE.clientType = type
|
| 1075 |
+
}
|
| 1076 |
+
|
| 1077 |
+
export function getSdkAgentProgressSummariesEnabled(): boolean {
|
| 1078 |
+
return STATE.sdkAgentProgressSummariesEnabled
|
| 1079 |
+
}
|
| 1080 |
+
|
| 1081 |
+
export function setSdkAgentProgressSummariesEnabled(value: boolean): void {
|
| 1082 |
+
STATE.sdkAgentProgressSummariesEnabled = value
|
| 1083 |
+
}
|
| 1084 |
+
|
| 1085 |
+
export function getKairosActive(): boolean {
|
| 1086 |
+
return STATE.kairosActive
|
| 1087 |
+
}
|
| 1088 |
+
|
| 1089 |
+
export function setKairosActive(value: boolean): void {
|
| 1090 |
+
STATE.kairosActive = value
|
| 1091 |
+
}
|
| 1092 |
+
|
| 1093 |
+
export function getStrictToolResultPairing(): boolean {
|
| 1094 |
+
return STATE.strictToolResultPairing
|
| 1095 |
+
}
|
| 1096 |
+
|
| 1097 |
+
export function setStrictToolResultPairing(value: boolean): void {
|
| 1098 |
+
STATE.strictToolResultPairing = value
|
| 1099 |
+
}
|
| 1100 |
+
|
| 1101 |
+
// Field name 'userMsgOptIn' avoids excluded-string substrings ('BriefTool',
|
| 1102 |
+
// 'SendUserMessage' — case-insensitive). All callers are inside feature()
|
| 1103 |
+
// guards so these accessors don't need their own (matches getKairosActive).
|
| 1104 |
+
export function getUserMsgOptIn(): boolean {
|
| 1105 |
+
return STATE.userMsgOptIn
|
| 1106 |
+
}
|
| 1107 |
+
|
| 1108 |
+
export function setUserMsgOptIn(value: boolean): void {
|
| 1109 |
+
STATE.userMsgOptIn = value
|
| 1110 |
+
}
|
| 1111 |
+
|
| 1112 |
+
export function getSessionSource(): string | undefined {
|
| 1113 |
+
return STATE.sessionSource
|
| 1114 |
+
}
|
| 1115 |
+
|
| 1116 |
+
export function setSessionSource(source: string): void {
|
| 1117 |
+
STATE.sessionSource = source
|
| 1118 |
+
}
|
| 1119 |
+
|
| 1120 |
+
export function getQuestionPreviewFormat(): 'markdown' | 'html' | undefined {
|
| 1121 |
+
return STATE.questionPreviewFormat
|
| 1122 |
+
}
|
| 1123 |
+
|
| 1124 |
+
export function setQuestionPreviewFormat(format: 'markdown' | 'html'): void {
|
| 1125 |
+
STATE.questionPreviewFormat = format
|
| 1126 |
+
}
|
| 1127 |
+
|
| 1128 |
+
export function getAgentColorMap(): Map<string, AgentColorName> {
|
| 1129 |
+
return STATE.agentColorMap
|
| 1130 |
+
}
|
| 1131 |
+
|
| 1132 |
+
export function getFlagSettingsPath(): string | undefined {
|
| 1133 |
+
return STATE.flagSettingsPath
|
| 1134 |
+
}
|
| 1135 |
+
|
| 1136 |
+
export function setFlagSettingsPath(path: string | undefined): void {
|
| 1137 |
+
STATE.flagSettingsPath = path
|
| 1138 |
+
}
|
| 1139 |
+
|
| 1140 |
+
export function getFlagSettingsInline(): Record<string, unknown> | null {
|
| 1141 |
+
return STATE.flagSettingsInline
|
| 1142 |
+
}
|
| 1143 |
+
|
| 1144 |
+
export function setFlagSettingsInline(
|
| 1145 |
+
settings: Record<string, unknown> | null,
|
| 1146 |
+
): void {
|
| 1147 |
+
STATE.flagSettingsInline = settings
|
| 1148 |
+
}
|
| 1149 |
+
|
| 1150 |
+
export function getSessionIngressToken(): string | null | undefined {
|
| 1151 |
+
return STATE.sessionIngressToken
|
| 1152 |
+
}
|
| 1153 |
+
|
| 1154 |
+
export function setSessionIngressToken(token: string | null): void {
|
| 1155 |
+
STATE.sessionIngressToken = token
|
| 1156 |
+
}
|
| 1157 |
+
|
| 1158 |
+
export function getOauthTokenFromFd(): string | null | undefined {
|
| 1159 |
+
return STATE.oauthTokenFromFd
|
| 1160 |
+
}
|
| 1161 |
+
|
| 1162 |
+
export function setOauthTokenFromFd(token: string | null): void {
|
| 1163 |
+
STATE.oauthTokenFromFd = token
|
| 1164 |
+
}
|
| 1165 |
+
|
| 1166 |
+
export function getApiKeyFromFd(): string | null | undefined {
|
| 1167 |
+
return STATE.apiKeyFromFd
|
| 1168 |
+
}
|
| 1169 |
+
|
| 1170 |
+
export function setApiKeyFromFd(key: string | null): void {
|
| 1171 |
+
STATE.apiKeyFromFd = key
|
| 1172 |
+
}
|
| 1173 |
+
|
| 1174 |
+
export function setLastAPIRequest(
|
| 1175 |
+
params: Omit<BetaMessageStreamParams, 'messages'> | null,
|
| 1176 |
+
): void {
|
| 1177 |
+
STATE.lastAPIRequest = params
|
| 1178 |
+
}
|
| 1179 |
+
|
| 1180 |
+
export function getLastAPIRequest(): Omit<
|
| 1181 |
+
BetaMessageStreamParams,
|
| 1182 |
+
'messages'
|
| 1183 |
+
> | null {
|
| 1184 |
+
return STATE.lastAPIRequest
|
| 1185 |
+
}
|
| 1186 |
+
|
| 1187 |
+
export function setLastAPIRequestMessages(
|
| 1188 |
+
messages: BetaMessageStreamParams['messages'] | null,
|
| 1189 |
+
): void {
|
| 1190 |
+
STATE.lastAPIRequestMessages = messages
|
| 1191 |
+
}
|
| 1192 |
+
|
| 1193 |
+
export function getLastAPIRequestMessages():
|
| 1194 |
+
| BetaMessageStreamParams['messages']
|
| 1195 |
+
| null {
|
| 1196 |
+
return STATE.lastAPIRequestMessages
|
| 1197 |
+
}
|
| 1198 |
+
|
| 1199 |
+
export function setLastClassifierRequests(requests: unknown[] | null): void {
|
| 1200 |
+
STATE.lastClassifierRequests = requests
|
| 1201 |
+
}
|
| 1202 |
+
|
| 1203 |
+
export function getLastClassifierRequests(): unknown[] | null {
|
| 1204 |
+
return STATE.lastClassifierRequests
|
| 1205 |
+
}
|
| 1206 |
+
|
| 1207 |
+
export function setCachedClaudeMdContent(content: string | null): void {
|
| 1208 |
+
STATE.cachedClaudeMdContent = content
|
| 1209 |
+
}
|
| 1210 |
+
|
| 1211 |
+
export function getCachedClaudeMdContent(): string | null {
|
| 1212 |
+
return STATE.cachedClaudeMdContent
|
| 1213 |
+
}
|
| 1214 |
+
|
| 1215 |
+
export function addToInMemoryErrorLog(errorInfo: {
|
| 1216 |
+
error: string
|
| 1217 |
+
timestamp: string
|
| 1218 |
+
}): void {
|
| 1219 |
+
const MAX_IN_MEMORY_ERRORS = 100
|
| 1220 |
+
if (STATE.inMemoryErrorLog.length >= MAX_IN_MEMORY_ERRORS) {
|
| 1221 |
+
STATE.inMemoryErrorLog.shift() // Remove oldest error
|
| 1222 |
+
}
|
| 1223 |
+
STATE.inMemoryErrorLog.push(errorInfo)
|
| 1224 |
+
}
|
| 1225 |
+
|
| 1226 |
+
export function getAllowedSettingSources(): SettingSource[] {
|
| 1227 |
+
return STATE.allowedSettingSources
|
| 1228 |
+
}
|
| 1229 |
+
|
| 1230 |
+
export function setAllowedSettingSources(sources: SettingSource[]): void {
|
| 1231 |
+
STATE.allowedSettingSources = sources
|
| 1232 |
+
}
|
| 1233 |
+
|
| 1234 |
+
export function preferThirdPartyAuthentication(): boolean {
|
| 1235 |
+
// IDE extension should behave as 1P for authentication reasons.
|
| 1236 |
+
return getIsNonInteractiveSession() && STATE.clientType !== 'claude-vscode'
|
| 1237 |
+
}
|
| 1238 |
+
|
| 1239 |
+
export function setInlinePlugins(plugins: Array<string>): void {
|
| 1240 |
+
STATE.inlinePlugins = plugins
|
| 1241 |
+
}
|
| 1242 |
+
|
| 1243 |
+
export function getInlinePlugins(): Array<string> {
|
| 1244 |
+
return STATE.inlinePlugins
|
| 1245 |
+
}
|
| 1246 |
+
|
| 1247 |
+
export function setChromeFlagOverride(value: boolean | undefined): void {
|
| 1248 |
+
STATE.chromeFlagOverride = value
|
| 1249 |
+
}
|
| 1250 |
+
|
| 1251 |
+
export function getChromeFlagOverride(): boolean | undefined {
|
| 1252 |
+
return STATE.chromeFlagOverride
|
| 1253 |
+
}
|
| 1254 |
+
|
| 1255 |
+
export function setUseCoworkPlugins(value: boolean): void {
|
| 1256 |
+
STATE.useCoworkPlugins = value
|
| 1257 |
+
resetSettingsCache()
|
| 1258 |
+
}
|
| 1259 |
+
|
| 1260 |
+
export function getUseCoworkPlugins(): boolean {
|
| 1261 |
+
return STATE.useCoworkPlugins
|
| 1262 |
+
}
|
| 1263 |
+
|
| 1264 |
+
export function setSessionBypassPermissionsMode(enabled: boolean): void {
|
| 1265 |
+
STATE.sessionBypassPermissionsMode = enabled
|
| 1266 |
+
}
|
| 1267 |
+
|
| 1268 |
+
export function getSessionBypassPermissionsMode(): boolean {
|
| 1269 |
+
return STATE.sessionBypassPermissionsMode
|
| 1270 |
+
}
|
| 1271 |
+
|
| 1272 |
+
export function setScheduledTasksEnabled(enabled: boolean): void {
|
| 1273 |
+
STATE.scheduledTasksEnabled = enabled
|
| 1274 |
+
}
|
| 1275 |
+
|
| 1276 |
+
export function getScheduledTasksEnabled(): boolean {
|
| 1277 |
+
return STATE.scheduledTasksEnabled
|
| 1278 |
+
}
|
| 1279 |
+
|
| 1280 |
+
export type SessionCronTask = {
|
| 1281 |
+
id: string
|
| 1282 |
+
cron: string
|
| 1283 |
+
prompt: string
|
| 1284 |
+
createdAt: number
|
| 1285 |
+
recurring?: boolean
|
| 1286 |
+
/**
|
| 1287 |
+
* When set, the task was created by an in-process teammate (not the team lead).
|
| 1288 |
+
* The scheduler routes fires to that teammate's pendingUserMessages queue
|
| 1289 |
+
* instead of the main REPL command queue. Session-only — never written to disk.
|
| 1290 |
+
*/
|
| 1291 |
+
agentId?: string
|
| 1292 |
+
}
|
| 1293 |
+
|
| 1294 |
+
export function getSessionCronTasks(): SessionCronTask[] {
|
| 1295 |
+
return STATE.sessionCronTasks
|
| 1296 |
+
}
|
| 1297 |
+
|
| 1298 |
+
export function addSessionCronTask(task: SessionCronTask): void {
|
| 1299 |
+
STATE.sessionCronTasks.push(task)
|
| 1300 |
+
}
|
| 1301 |
+
|
| 1302 |
+
/**
|
| 1303 |
+
* Returns the number of tasks actually removed. Callers use this to skip
|
| 1304 |
+
* downstream work (e.g. the disk read in removeCronTasks) when all ids
|
| 1305 |
+
* were accounted for here.
|
| 1306 |
+
*/
|
| 1307 |
+
export function removeSessionCronTasks(ids: readonly string[]): number {
|
| 1308 |
+
if (ids.length === 0) return 0
|
| 1309 |
+
const idSet = new Set(ids)
|
| 1310 |
+
const remaining = STATE.sessionCronTasks.filter(t => !idSet.has(t.id))
|
| 1311 |
+
const removed = STATE.sessionCronTasks.length - remaining.length
|
| 1312 |
+
if (removed === 0) return 0
|
| 1313 |
+
STATE.sessionCronTasks = remaining
|
| 1314 |
+
return removed
|
| 1315 |
+
}
|
| 1316 |
+
|
| 1317 |
+
export function setSessionTrustAccepted(accepted: boolean): void {
|
| 1318 |
+
STATE.sessionTrustAccepted = accepted
|
| 1319 |
+
}
|
| 1320 |
+
|
| 1321 |
+
export function getSessionTrustAccepted(): boolean {
|
| 1322 |
+
return STATE.sessionTrustAccepted
|
| 1323 |
+
}
|
| 1324 |
+
|
| 1325 |
+
export function setSessionPersistenceDisabled(disabled: boolean): void {
|
| 1326 |
+
STATE.sessionPersistenceDisabled = disabled
|
| 1327 |
+
}
|
| 1328 |
+
|
| 1329 |
+
export function isSessionPersistenceDisabled(): boolean {
|
| 1330 |
+
return STATE.sessionPersistenceDisabled
|
| 1331 |
+
}
|
| 1332 |
+
|
| 1333 |
+
export function hasExitedPlanModeInSession(): boolean {
|
| 1334 |
+
return STATE.hasExitedPlanMode
|
| 1335 |
+
}
|
| 1336 |
+
|
| 1337 |
+
export function setHasExitedPlanMode(value: boolean): void {
|
| 1338 |
+
STATE.hasExitedPlanMode = value
|
| 1339 |
+
}
|
| 1340 |
+
|
| 1341 |
+
export function needsPlanModeExitAttachment(): boolean {
|
| 1342 |
+
return STATE.needsPlanModeExitAttachment
|
| 1343 |
+
}
|
| 1344 |
+
|
| 1345 |
+
export function setNeedsPlanModeExitAttachment(value: boolean): void {
|
| 1346 |
+
STATE.needsPlanModeExitAttachment = value
|
| 1347 |
+
}
|
| 1348 |
+
|
| 1349 |
+
export function handlePlanModeTransition(
|
| 1350 |
+
fromMode: string,
|
| 1351 |
+
toMode: string,
|
| 1352 |
+
): void {
|
| 1353 |
+
// If switching TO plan mode, clear any pending exit attachment
|
| 1354 |
+
// This prevents sending both plan_mode and plan_mode_exit when user toggles quickly
|
| 1355 |
+
if (toMode === 'plan' && fromMode !== 'plan') {
|
| 1356 |
+
STATE.needsPlanModeExitAttachment = false
|
| 1357 |
+
}
|
| 1358 |
+
|
| 1359 |
+
// If switching out of plan mode, trigger the plan_mode_exit attachment
|
| 1360 |
+
if (fromMode === 'plan' && toMode !== 'plan') {
|
| 1361 |
+
STATE.needsPlanModeExitAttachment = true
|
| 1362 |
+
}
|
| 1363 |
+
}
|
| 1364 |
+
|
| 1365 |
+
export function needsAutoModeExitAttachment(): boolean {
|
| 1366 |
+
return STATE.needsAutoModeExitAttachment
|
| 1367 |
+
}
|
| 1368 |
+
|
| 1369 |
+
export function setNeedsAutoModeExitAttachment(value: boolean): void {
|
| 1370 |
+
STATE.needsAutoModeExitAttachment = value
|
| 1371 |
+
}
|
| 1372 |
+
|
| 1373 |
+
export function handleAutoModeTransition(
|
| 1374 |
+
fromMode: string,
|
| 1375 |
+
toMode: string,
|
| 1376 |
+
): void {
|
| 1377 |
+
// Auto↔plan transitions are handled by prepareContextForPlanMode (auto may
|
| 1378 |
+
// stay active through plan if opted in) and ExitPlanMode (restores mode).
|
| 1379 |
+
// Skip both directions so this function only handles direct auto transitions.
|
| 1380 |
+
if (
|
| 1381 |
+
(fromMode === 'auto' && toMode === 'plan') ||
|
| 1382 |
+
(fromMode === 'plan' && toMode === 'auto')
|
| 1383 |
+
) {
|
| 1384 |
+
return
|
| 1385 |
+
}
|
| 1386 |
+
const fromIsAuto = fromMode === 'auto'
|
| 1387 |
+
const toIsAuto = toMode === 'auto'
|
| 1388 |
+
|
| 1389 |
+
// If switching TO auto mode, clear any pending exit attachment
|
| 1390 |
+
// This prevents sending both auto_mode and auto_mode_exit when user toggles quickly
|
| 1391 |
+
if (toIsAuto && !fromIsAuto) {
|
| 1392 |
+
STATE.needsAutoModeExitAttachment = false
|
| 1393 |
+
}
|
| 1394 |
+
|
| 1395 |
+
// If switching out of auto mode, trigger the auto_mode_exit attachment
|
| 1396 |
+
if (fromIsAuto && !toIsAuto) {
|
| 1397 |
+
STATE.needsAutoModeExitAttachment = true
|
| 1398 |
+
}
|
| 1399 |
+
}
|
| 1400 |
+
|
| 1401 |
+
// LSP plugin recommendation session tracking
|
| 1402 |
+
export function hasShownLspRecommendationThisSession(): boolean {
|
| 1403 |
+
return STATE.lspRecommendationShownThisSession
|
| 1404 |
+
}
|
| 1405 |
+
|
| 1406 |
+
export function setLspRecommendationShownThisSession(value: boolean): void {
|
| 1407 |
+
STATE.lspRecommendationShownThisSession = value
|
| 1408 |
+
}
|
| 1409 |
+
|
| 1410 |
+
// SDK init event state
|
| 1411 |
+
export function setInitJsonSchema(schema: Record<string, unknown>): void {
|
| 1412 |
+
STATE.initJsonSchema = schema
|
| 1413 |
+
}
|
| 1414 |
+
|
| 1415 |
+
export function getInitJsonSchema(): Record<string, unknown> | null {
|
| 1416 |
+
return STATE.initJsonSchema
|
| 1417 |
+
}
|
| 1418 |
+
|
| 1419 |
+
export function registerHookCallbacks(
|
| 1420 |
+
hooks: Partial<Record<HookEvent, RegisteredHookMatcher[]>>,
|
| 1421 |
+
): void {
|
| 1422 |
+
if (!STATE.registeredHooks) {
|
| 1423 |
+
STATE.registeredHooks = {}
|
| 1424 |
+
}
|
| 1425 |
+
|
| 1426 |
+
// `registerHookCallbacks` may be called multiple times, so we need to merge (not overwrite)
|
| 1427 |
+
for (const [event, matchers] of Object.entries(hooks)) {
|
| 1428 |
+
const eventKey = event as HookEvent
|
| 1429 |
+
if (!STATE.registeredHooks[eventKey]) {
|
| 1430 |
+
STATE.registeredHooks[eventKey] = []
|
| 1431 |
+
}
|
| 1432 |
+
STATE.registeredHooks[eventKey]!.push(...matchers)
|
| 1433 |
+
}
|
| 1434 |
+
}
|
| 1435 |
+
|
| 1436 |
+
export function getRegisteredHooks(): Partial<
|
| 1437 |
+
Record<HookEvent, RegisteredHookMatcher[]>
|
| 1438 |
+
> | null {
|
| 1439 |
+
return STATE.registeredHooks
|
| 1440 |
+
}
|
| 1441 |
+
|
| 1442 |
+
export function clearRegisteredHooks(): void {
|
| 1443 |
+
STATE.registeredHooks = null
|
| 1444 |
+
}
|
| 1445 |
+
|
| 1446 |
+
export function clearRegisteredPluginHooks(): void {
|
| 1447 |
+
if (!STATE.registeredHooks) {
|
| 1448 |
+
return
|
| 1449 |
+
}
|
| 1450 |
+
|
| 1451 |
+
const filtered: Partial<Record<HookEvent, RegisteredHookMatcher[]>> = {}
|
| 1452 |
+
for (const [event, matchers] of Object.entries(STATE.registeredHooks)) {
|
| 1453 |
+
// Keep only callback hooks (those without pluginRoot)
|
| 1454 |
+
const callbackHooks = matchers.filter(m => !('pluginRoot' in m))
|
| 1455 |
+
if (callbackHooks.length > 0) {
|
| 1456 |
+
filtered[event as HookEvent] = callbackHooks
|
| 1457 |
+
}
|
| 1458 |
+
}
|
| 1459 |
+
|
| 1460 |
+
STATE.registeredHooks = Object.keys(filtered).length > 0 ? filtered : null
|
| 1461 |
+
}
|
| 1462 |
+
|
| 1463 |
+
export function resetSdkInitState(): void {
|
| 1464 |
+
STATE.initJsonSchema = null
|
| 1465 |
+
STATE.registeredHooks = null
|
| 1466 |
+
}
|
| 1467 |
+
|
| 1468 |
+
export function getPlanSlugCache(): Map<string, string> {
|
| 1469 |
+
return STATE.planSlugCache
|
| 1470 |
+
}
|
| 1471 |
+
|
| 1472 |
+
export function getSessionCreatedTeams(): Set<string> {
|
| 1473 |
+
return STATE.sessionCreatedTeams
|
| 1474 |
+
}
|
| 1475 |
+
|
| 1476 |
+
// Teleported session tracking for reliability logging
|
| 1477 |
+
export function setTeleportedSessionInfo(info: {
|
| 1478 |
+
sessionId: string | null
|
| 1479 |
+
}): void {
|
| 1480 |
+
STATE.teleportedSessionInfo = {
|
| 1481 |
+
isTeleported: true,
|
| 1482 |
+
hasLoggedFirstMessage: false,
|
| 1483 |
+
sessionId: info.sessionId,
|
| 1484 |
+
}
|
| 1485 |
+
}
|
| 1486 |
+
|
| 1487 |
+
export function getTeleportedSessionInfo(): {
|
| 1488 |
+
isTeleported: boolean
|
| 1489 |
+
hasLoggedFirstMessage: boolean
|
| 1490 |
+
sessionId: string | null
|
| 1491 |
+
} | null {
|
| 1492 |
+
return STATE.teleportedSessionInfo
|
| 1493 |
+
}
|
| 1494 |
+
|
| 1495 |
+
export function markFirstTeleportMessageLogged(): void {
|
| 1496 |
+
if (STATE.teleportedSessionInfo) {
|
| 1497 |
+
STATE.teleportedSessionInfo.hasLoggedFirstMessage = true
|
| 1498 |
+
}
|
| 1499 |
+
}
|
| 1500 |
+
|
| 1501 |
+
// Invoked skills tracking for preservation across compaction
|
| 1502 |
+
export type InvokedSkillInfo = {
|
| 1503 |
+
skillName: string
|
| 1504 |
+
skillPath: string
|
| 1505 |
+
content: string
|
| 1506 |
+
invokedAt: number
|
| 1507 |
+
agentId: string | null
|
| 1508 |
+
}
|
| 1509 |
+
|
| 1510 |
+
export function addInvokedSkill(
|
| 1511 |
+
skillName: string,
|
| 1512 |
+
skillPath: string,
|
| 1513 |
+
content: string,
|
| 1514 |
+
agentId: string | null = null,
|
| 1515 |
+
): void {
|
| 1516 |
+
const key = `${agentId ?? ''}:${skillName}`
|
| 1517 |
+
STATE.invokedSkills.set(key, {
|
| 1518 |
+
skillName,
|
| 1519 |
+
skillPath,
|
| 1520 |
+
content,
|
| 1521 |
+
invokedAt: Date.now(),
|
| 1522 |
+
agentId,
|
| 1523 |
+
})
|
| 1524 |
+
}
|
| 1525 |
+
|
| 1526 |
+
export function getInvokedSkills(): Map<string, InvokedSkillInfo> {
|
| 1527 |
+
return STATE.invokedSkills
|
| 1528 |
+
}
|
| 1529 |
+
|
| 1530 |
+
export function getInvokedSkillsForAgent(
|
| 1531 |
+
agentId: string | undefined | null,
|
| 1532 |
+
): Map<string, InvokedSkillInfo> {
|
| 1533 |
+
const normalizedId = agentId ?? null
|
| 1534 |
+
const filtered = new Map<string, InvokedSkillInfo>()
|
| 1535 |
+
for (const [key, skill] of STATE.invokedSkills) {
|
| 1536 |
+
if (skill.agentId === normalizedId) {
|
| 1537 |
+
filtered.set(key, skill)
|
| 1538 |
+
}
|
| 1539 |
+
}
|
| 1540 |
+
return filtered
|
| 1541 |
+
}
|
| 1542 |
+
|
| 1543 |
+
export function clearInvokedSkills(
|
| 1544 |
+
preservedAgentIds?: ReadonlySet<string>,
|
| 1545 |
+
): void {
|
| 1546 |
+
if (!preservedAgentIds || preservedAgentIds.size === 0) {
|
| 1547 |
+
STATE.invokedSkills.clear()
|
| 1548 |
+
return
|
| 1549 |
+
}
|
| 1550 |
+
for (const [key, skill] of STATE.invokedSkills) {
|
| 1551 |
+
if (skill.agentId === null || !preservedAgentIds.has(skill.agentId)) {
|
| 1552 |
+
STATE.invokedSkills.delete(key)
|
| 1553 |
+
}
|
| 1554 |
+
}
|
| 1555 |
+
}
|
| 1556 |
+
|
| 1557 |
+
export function clearInvokedSkillsForAgent(agentId: string): void {
|
| 1558 |
+
for (const [key, skill] of STATE.invokedSkills) {
|
| 1559 |
+
if (skill.agentId === agentId) {
|
| 1560 |
+
STATE.invokedSkills.delete(key)
|
| 1561 |
+
}
|
| 1562 |
+
}
|
| 1563 |
+
}
|
| 1564 |
+
|
| 1565 |
+
// Slow operations tracking for dev bar
|
| 1566 |
+
const MAX_SLOW_OPERATIONS = 10
|
| 1567 |
+
const SLOW_OPERATION_TTL_MS = 10000
|
| 1568 |
+
|
| 1569 |
+
export function addSlowOperation(operation: string, durationMs: number): void {
|
| 1570 |
+
if (process.env.USER_TYPE !== 'ant') return
|
| 1571 |
+
// Skip tracking for editor sessions (user editing a prompt file in $EDITOR)
|
| 1572 |
+
// These are intentionally slow since the user is drafting text
|
| 1573 |
+
if (operation.includes('exec') && operation.includes('claude-prompt-')) {
|
| 1574 |
+
return
|
| 1575 |
+
}
|
| 1576 |
+
const now = Date.now()
|
| 1577 |
+
// Remove stale operations
|
| 1578 |
+
STATE.slowOperations = STATE.slowOperations.filter(
|
| 1579 |
+
op => now - op.timestamp < SLOW_OPERATION_TTL_MS,
|
| 1580 |
+
)
|
| 1581 |
+
// Add new operation
|
| 1582 |
+
STATE.slowOperations.push({ operation, durationMs, timestamp: now })
|
| 1583 |
+
// Keep only the most recent operations
|
| 1584 |
+
if (STATE.slowOperations.length > MAX_SLOW_OPERATIONS) {
|
| 1585 |
+
STATE.slowOperations = STATE.slowOperations.slice(-MAX_SLOW_OPERATIONS)
|
| 1586 |
+
}
|
| 1587 |
+
}
|
| 1588 |
+
|
| 1589 |
+
const EMPTY_SLOW_OPERATIONS: ReadonlyArray<{
|
| 1590 |
+
operation: string
|
| 1591 |
+
durationMs: number
|
| 1592 |
+
timestamp: number
|
| 1593 |
+
}> = []
|
| 1594 |
+
|
| 1595 |
+
export function getSlowOperations(): ReadonlyArray<{
|
| 1596 |
+
operation: string
|
| 1597 |
+
durationMs: number
|
| 1598 |
+
timestamp: number
|
| 1599 |
+
}> {
|
| 1600 |
+
// Most common case: nothing tracked. Return a stable reference so the
|
| 1601 |
+
// caller's setState() can bail via Object.is instead of re-rendering at 2fps.
|
| 1602 |
+
if (STATE.slowOperations.length === 0) {
|
| 1603 |
+
return EMPTY_SLOW_OPERATIONS
|
| 1604 |
+
}
|
| 1605 |
+
const now = Date.now()
|
| 1606 |
+
// Only allocate a new array when something actually expired; otherwise keep
|
| 1607 |
+
// the reference stable across polls while ops are still fresh.
|
| 1608 |
+
if (
|
| 1609 |
+
STATE.slowOperations.some(op => now - op.timestamp >= SLOW_OPERATION_TTL_MS)
|
| 1610 |
+
) {
|
| 1611 |
+
STATE.slowOperations = STATE.slowOperations.filter(
|
| 1612 |
+
op => now - op.timestamp < SLOW_OPERATION_TTL_MS,
|
| 1613 |
+
)
|
| 1614 |
+
if (STATE.slowOperations.length === 0) {
|
| 1615 |
+
return EMPTY_SLOW_OPERATIONS
|
| 1616 |
+
}
|
| 1617 |
+
}
|
| 1618 |
+
// Safe to return directly: addSlowOperation() reassigns STATE.slowOperations
|
| 1619 |
+
// before pushing, so the array held in React state is never mutated.
|
| 1620 |
+
return STATE.slowOperations
|
| 1621 |
+
}
|
| 1622 |
+
|
| 1623 |
+
export function getMainThreadAgentType(): string | undefined {
|
| 1624 |
+
return STATE.mainThreadAgentType
|
| 1625 |
+
}
|
| 1626 |
+
|
| 1627 |
+
export function setMainThreadAgentType(agentType: string | undefined): void {
|
| 1628 |
+
STATE.mainThreadAgentType = agentType
|
| 1629 |
+
}
|
| 1630 |
+
|
| 1631 |
+
export function getIsRemoteMode(): boolean {
|
| 1632 |
+
return STATE.isRemoteMode
|
| 1633 |
+
}
|
| 1634 |
+
|
| 1635 |
+
export function setIsRemoteMode(value: boolean): void {
|
| 1636 |
+
STATE.isRemoteMode = value
|
| 1637 |
+
}
|
| 1638 |
+
|
| 1639 |
+
// System prompt section accessors
|
| 1640 |
+
|
| 1641 |
+
export function getSystemPromptSectionCache(): Map<string, string | null> {
|
| 1642 |
+
return STATE.systemPromptSectionCache
|
| 1643 |
+
}
|
| 1644 |
+
|
| 1645 |
+
export function setSystemPromptSectionCacheEntry(
|
| 1646 |
+
name: string,
|
| 1647 |
+
value: string | null,
|
| 1648 |
+
): void {
|
| 1649 |
+
STATE.systemPromptSectionCache.set(name, value)
|
| 1650 |
+
}
|
| 1651 |
+
|
| 1652 |
+
export function clearSystemPromptSectionState(): void {
|
| 1653 |
+
STATE.systemPromptSectionCache.clear()
|
| 1654 |
+
}
|
| 1655 |
+
|
| 1656 |
+
// Last emitted date accessors (for detecting midnight date changes)
|
| 1657 |
+
|
| 1658 |
+
export function getLastEmittedDate(): string | null {
|
| 1659 |
+
return STATE.lastEmittedDate
|
| 1660 |
+
}
|
| 1661 |
+
|
| 1662 |
+
export function setLastEmittedDate(date: string | null): void {
|
| 1663 |
+
STATE.lastEmittedDate = date
|
| 1664 |
+
}
|
| 1665 |
+
|
| 1666 |
+
export function getAdditionalDirectoriesForClaudeMd(): string[] {
|
| 1667 |
+
return STATE.additionalDirectoriesForClaudeMd
|
| 1668 |
+
}
|
| 1669 |
+
|
| 1670 |
+
export function setAdditionalDirectoriesForClaudeMd(
|
| 1671 |
+
directories: string[],
|
| 1672 |
+
): void {
|
| 1673 |
+
STATE.additionalDirectoriesForClaudeMd = directories
|
| 1674 |
+
}
|
| 1675 |
+
|
| 1676 |
+
export function getAllowedChannels(): ChannelEntry[] {
|
| 1677 |
+
return STATE.allowedChannels
|
| 1678 |
+
}
|
| 1679 |
+
|
| 1680 |
+
export function setAllowedChannels(entries: ChannelEntry[]): void {
|
| 1681 |
+
STATE.allowedChannels = entries
|
| 1682 |
+
}
|
| 1683 |
+
|
| 1684 |
+
export function getHasDevChannels(): boolean {
|
| 1685 |
+
return STATE.hasDevChannels
|
| 1686 |
+
}
|
| 1687 |
+
|
| 1688 |
+
export function setHasDevChannels(value: boolean): void {
|
| 1689 |
+
STATE.hasDevChannels = value
|
| 1690 |
+
}
|
| 1691 |
+
|
| 1692 |
+
export function getPromptCache1hAllowlist(): string[] | null {
|
| 1693 |
+
return STATE.promptCache1hAllowlist
|
| 1694 |
+
}
|
| 1695 |
+
|
| 1696 |
+
export function setPromptCache1hAllowlist(allowlist: string[] | null): void {
|
| 1697 |
+
STATE.promptCache1hAllowlist = allowlist
|
| 1698 |
+
}
|
| 1699 |
+
|
| 1700 |
+
export function getPromptCache1hEligible(): boolean | null {
|
| 1701 |
+
return STATE.promptCache1hEligible
|
| 1702 |
+
}
|
| 1703 |
+
|
| 1704 |
+
export function setPromptCache1hEligible(eligible: boolean | null): void {
|
| 1705 |
+
STATE.promptCache1hEligible = eligible
|
| 1706 |
+
}
|
| 1707 |
+
|
| 1708 |
+
export function getAfkModeHeaderLatched(): boolean | null {
|
| 1709 |
+
return STATE.afkModeHeaderLatched
|
| 1710 |
+
}
|
| 1711 |
+
|
| 1712 |
+
export function setAfkModeHeaderLatched(v: boolean): void {
|
| 1713 |
+
STATE.afkModeHeaderLatched = v
|
| 1714 |
+
}
|
| 1715 |
+
|
| 1716 |
+
export function getFastModeHeaderLatched(): boolean | null {
|
| 1717 |
+
return STATE.fastModeHeaderLatched
|
| 1718 |
+
}
|
| 1719 |
+
|
| 1720 |
+
export function setFastModeHeaderLatched(v: boolean): void {
|
| 1721 |
+
STATE.fastModeHeaderLatched = v
|
| 1722 |
+
}
|
| 1723 |
+
|
| 1724 |
+
export function getCacheEditingHeaderLatched(): boolean | null {
|
| 1725 |
+
return STATE.cacheEditingHeaderLatched
|
| 1726 |
+
}
|
| 1727 |
+
|
| 1728 |
+
export function setCacheEditingHeaderLatched(v: boolean): void {
|
| 1729 |
+
STATE.cacheEditingHeaderLatched = v
|
| 1730 |
+
}
|
| 1731 |
+
|
| 1732 |
+
export function getThinkingClearLatched(): boolean | null {
|
| 1733 |
+
return STATE.thinkingClearLatched
|
| 1734 |
+
}
|
| 1735 |
+
|
| 1736 |
+
export function setThinkingClearLatched(v: boolean): void {
|
| 1737 |
+
STATE.thinkingClearLatched = v
|
| 1738 |
+
}
|
| 1739 |
+
|
| 1740 |
+
/**
|
| 1741 |
+
* Reset beta header latches to null. Called on /clear and /compact so a
|
| 1742 |
+
* fresh conversation gets fresh header evaluation.
|
| 1743 |
+
*/
|
| 1744 |
+
export function clearBetaHeaderLatches(): void {
|
| 1745 |
+
STATE.afkModeHeaderLatched = null
|
| 1746 |
+
STATE.fastModeHeaderLatched = null
|
| 1747 |
+
STATE.cacheEditingHeaderLatched = null
|
| 1748 |
+
STATE.thinkingClearLatched = null
|
| 1749 |
+
}
|
| 1750 |
+
|
| 1751 |
+
export function getPromptId(): string | null {
|
| 1752 |
+
return STATE.promptId
|
| 1753 |
+
}
|
| 1754 |
+
|
| 1755 |
+
export function setPromptId(id: string | null): void {
|
| 1756 |
+
STATE.promptId = id
|
| 1757 |
+
}
|
| 1758 |
+
|
src/bridge/bridgeApi.ts
ADDED
|
@@ -0,0 +1,539 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import axios from 'axios'
|
| 2 |
+
|
| 3 |
+
import { debugBody, extractErrorDetail } from './debugUtils.js'
|
| 4 |
+
import {
|
| 5 |
+
BRIDGE_LOGIN_INSTRUCTION,
|
| 6 |
+
type BridgeApiClient,
|
| 7 |
+
type BridgeConfig,
|
| 8 |
+
type PermissionResponseEvent,
|
| 9 |
+
type WorkResponse,
|
| 10 |
+
} from './types.js'
|
| 11 |
+
|
| 12 |
+
type BridgeApiDeps = {
|
| 13 |
+
baseUrl: string
|
| 14 |
+
getAccessToken: () => string | undefined
|
| 15 |
+
runnerVersion: string
|
| 16 |
+
onDebug?: (msg: string) => void
|
| 17 |
+
/**
|
| 18 |
+
* Called on 401 to attempt OAuth token refresh. Returns true if refreshed,
|
| 19 |
+
* in which case the request is retried once. Injected because
|
| 20 |
+
* handleOAuth401Error from utils/auth.ts transitively pulls in config.ts →
|
| 21 |
+
* file.ts → permissions/filesystem.ts → sessionStorage.ts → commands.ts
|
| 22 |
+
* (~1300 modules). Daemon callers using env-var tokens omit this — their
|
| 23 |
+
* tokens don't refresh, so 401 goes straight to BridgeFatalError.
|
| 24 |
+
*/
|
| 25 |
+
onAuth401?: (staleAccessToken: string) => Promise<boolean>
|
| 26 |
+
/**
|
| 27 |
+
* Returns the trusted device token to send as X-Trusted-Device-Token on
|
| 28 |
+
* bridge API calls. Bridge sessions have SecurityTier=ELEVATED on the
|
| 29 |
+
* server (CCR v2); when the server's enforcement flag is on,
|
| 30 |
+
* ConnectBridgeWorker requires a trusted device at JWT-issuance.
|
| 31 |
+
* Optional — when absent or returning undefined, the header is omitted
|
| 32 |
+
* and the server falls through to its flag-off/no-op path. The CLI-side
|
| 33 |
+
* gate is tengu_sessions_elevated_auth_enforcement (see trustedDevice.ts).
|
| 34 |
+
*/
|
| 35 |
+
getTrustedDeviceToken?: () => string | undefined
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
const BETA_HEADER = 'environments-2025-11-01'
|
| 39 |
+
|
| 40 |
+
/** Allowlist pattern for server-provided IDs used in URL path segments. */
|
| 41 |
+
const SAFE_ID_PATTERN = /^[a-zA-Z0-9_-]+$/
|
| 42 |
+
|
| 43 |
+
/**
|
| 44 |
+
* Validate that a server-provided ID is safe to interpolate into a URL path.
|
| 45 |
+
* Prevents path traversal (e.g. `../../admin`) and injection via IDs that
|
| 46 |
+
* contain slashes, dots, or other special characters.
|
| 47 |
+
*/
|
| 48 |
+
export function validateBridgeId(id: string, label: string): string {
|
| 49 |
+
if (!id || !SAFE_ID_PATTERN.test(id)) {
|
| 50 |
+
throw new Error(`Invalid ${label}: contains unsafe characters`)
|
| 51 |
+
}
|
| 52 |
+
return id
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
/** Fatal bridge errors that should not be retried (e.g. auth failures). */
|
| 56 |
+
export class BridgeFatalError extends Error {
|
| 57 |
+
readonly status: number
|
| 58 |
+
/** Server-provided error type, e.g. "environment_expired". */
|
| 59 |
+
readonly errorType: string | undefined
|
| 60 |
+
constructor(message: string, status: number, errorType?: string) {
|
| 61 |
+
super(message)
|
| 62 |
+
this.name = 'BridgeFatalError'
|
| 63 |
+
this.status = status
|
| 64 |
+
this.errorType = errorType
|
| 65 |
+
}
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
export function createBridgeApiClient(deps: BridgeApiDeps): BridgeApiClient {
|
| 69 |
+
function debug(msg: string): void {
|
| 70 |
+
deps.onDebug?.(msg)
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
let consecutiveEmptyPolls = 0
|
| 74 |
+
const EMPTY_POLL_LOG_INTERVAL = 100
|
| 75 |
+
|
| 76 |
+
function getHeaders(accessToken: string): Record<string, string> {
|
| 77 |
+
const headers: Record<string, string> = {
|
| 78 |
+
Authorization: `Bearer ${accessToken}`,
|
| 79 |
+
'Content-Type': 'application/json',
|
| 80 |
+
'anthropic-version': '2023-06-01',
|
| 81 |
+
'anthropic-beta': BETA_HEADER,
|
| 82 |
+
'x-environment-runner-version': deps.runnerVersion,
|
| 83 |
+
}
|
| 84 |
+
const deviceToken = deps.getTrustedDeviceToken?.()
|
| 85 |
+
if (deviceToken) {
|
| 86 |
+
headers['X-Trusted-Device-Token'] = deviceToken
|
| 87 |
+
}
|
| 88 |
+
return headers
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
function resolveAuth(): string {
|
| 92 |
+
const accessToken = deps.getAccessToken()
|
| 93 |
+
if (!accessToken) {
|
| 94 |
+
throw new Error(BRIDGE_LOGIN_INSTRUCTION)
|
| 95 |
+
}
|
| 96 |
+
return accessToken
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
/**
|
| 100 |
+
* Execute an OAuth-authenticated request with a single retry on 401.
|
| 101 |
+
* On 401, attempts token refresh via handleOAuth401Error (same pattern as
|
| 102 |
+
* withRetry.ts for v1/messages). If refresh succeeds, retries the request
|
| 103 |
+
* once with the new token. If refresh fails or the retry also returns 401,
|
| 104 |
+
* the 401 response is returned for handleErrorStatus to throw BridgeFatalError.
|
| 105 |
+
*/
|
| 106 |
+
async function withOAuthRetry<T>(
|
| 107 |
+
fn: (accessToken: string) => Promise<{ status: number; data: T }>,
|
| 108 |
+
context: string,
|
| 109 |
+
): Promise<{ status: number; data: T }> {
|
| 110 |
+
const accessToken = resolveAuth()
|
| 111 |
+
const response = await fn(accessToken)
|
| 112 |
+
|
| 113 |
+
if (response.status !== 401) {
|
| 114 |
+
return response
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
if (!deps.onAuth401) {
|
| 118 |
+
debug(`[bridge:api] ${context}: 401 received, no refresh handler`)
|
| 119 |
+
return response
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
// Attempt token refresh — matches the pattern in withRetry.ts
|
| 123 |
+
debug(`[bridge:api] ${context}: 401 received, attempting token refresh`)
|
| 124 |
+
const refreshed = await deps.onAuth401(accessToken)
|
| 125 |
+
if (refreshed) {
|
| 126 |
+
debug(`[bridge:api] ${context}: Token refreshed, retrying request`)
|
| 127 |
+
const newToken = resolveAuth()
|
| 128 |
+
const retryResponse = await fn(newToken)
|
| 129 |
+
if (retryResponse.status !== 401) {
|
| 130 |
+
return retryResponse
|
| 131 |
+
}
|
| 132 |
+
debug(`[bridge:api] ${context}: Retry after refresh also got 401`)
|
| 133 |
+
} else {
|
| 134 |
+
debug(`[bridge:api] ${context}: Token refresh failed`)
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
// Refresh failed — return 401 for handleErrorStatus to throw
|
| 138 |
+
return response
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
return {
|
| 142 |
+
async registerBridgeEnvironment(
|
| 143 |
+
config: BridgeConfig,
|
| 144 |
+
): Promise<{ environment_id: string; environment_secret: string }> {
|
| 145 |
+
debug(
|
| 146 |
+
`[bridge:api] POST /v1/environments/bridge bridgeId=${config.bridgeId}`,
|
| 147 |
+
)
|
| 148 |
+
|
| 149 |
+
const response = await withOAuthRetry(
|
| 150 |
+
(token: string) =>
|
| 151 |
+
axios.post<{
|
| 152 |
+
environment_id: string
|
| 153 |
+
environment_secret: string
|
| 154 |
+
}>(
|
| 155 |
+
`${deps.baseUrl}/v1/environments/bridge`,
|
| 156 |
+
{
|
| 157 |
+
machine_name: config.machineName,
|
| 158 |
+
directory: config.dir,
|
| 159 |
+
branch: config.branch,
|
| 160 |
+
git_repo_url: config.gitRepoUrl,
|
| 161 |
+
// Advertise session capacity so claude.ai/code can show
|
| 162 |
+
// "2/4 sessions" badges and only block the picker when
|
| 163 |
+
// actually at capacity. Backends that don't yet accept
|
| 164 |
+
// this field will silently ignore it.
|
| 165 |
+
max_sessions: config.maxSessions,
|
| 166 |
+
// worker_type lets claude.ai filter environments by origin
|
| 167 |
+
// (e.g. assistant picker only shows assistant-mode workers).
|
| 168 |
+
// Desktop cowork app sends "cowork"; we send a distinct value.
|
| 169 |
+
metadata: { worker_type: config.workerType },
|
| 170 |
+
// Idempotent re-registration: if we have a backend-issued
|
| 171 |
+
// environment_id from a prior session (--session-id resume),
|
| 172 |
+
// send it back so the backend reattaches instead of creating
|
| 173 |
+
// a new env. The backend may still hand back a fresh ID if
|
| 174 |
+
// the old one expired — callers must compare the response.
|
| 175 |
+
...(config.reuseEnvironmentId && {
|
| 176 |
+
environment_id: config.reuseEnvironmentId,
|
| 177 |
+
}),
|
| 178 |
+
},
|
| 179 |
+
{
|
| 180 |
+
headers: getHeaders(token),
|
| 181 |
+
timeout: 15_000,
|
| 182 |
+
validateStatus: status => status < 500,
|
| 183 |
+
},
|
| 184 |
+
),
|
| 185 |
+
'Registration',
|
| 186 |
+
)
|
| 187 |
+
|
| 188 |
+
handleErrorStatus(response.status, response.data, 'Registration')
|
| 189 |
+
debug(
|
| 190 |
+
`[bridge:api] POST /v1/environments/bridge -> ${response.status} environment_id=${response.data.environment_id}`,
|
| 191 |
+
)
|
| 192 |
+
debug(
|
| 193 |
+
`[bridge:api] >>> ${debugBody({ machine_name: config.machineName, directory: config.dir, branch: config.branch, git_repo_url: config.gitRepoUrl, max_sessions: config.maxSessions, metadata: { worker_type: config.workerType } })}`,
|
| 194 |
+
)
|
| 195 |
+
debug(`[bridge:api] <<< ${debugBody(response.data)}`)
|
| 196 |
+
return response.data
|
| 197 |
+
},
|
| 198 |
+
|
| 199 |
+
async pollForWork(
|
| 200 |
+
environmentId: string,
|
| 201 |
+
environmentSecret: string,
|
| 202 |
+
signal?: AbortSignal,
|
| 203 |
+
reclaimOlderThanMs?: number,
|
| 204 |
+
): Promise<WorkResponse | null> {
|
| 205 |
+
validateBridgeId(environmentId, 'environmentId')
|
| 206 |
+
|
| 207 |
+
// Save and reset so errors break the "consecutive empty" streak.
|
| 208 |
+
// Restored below when the response is truly empty.
|
| 209 |
+
const prevEmptyPolls = consecutiveEmptyPolls
|
| 210 |
+
consecutiveEmptyPolls = 0
|
| 211 |
+
|
| 212 |
+
const response = await axios.get<WorkResponse | null>(
|
| 213 |
+
`${deps.baseUrl}/v1/environments/${environmentId}/work/poll`,
|
| 214 |
+
{
|
| 215 |
+
headers: getHeaders(environmentSecret),
|
| 216 |
+
params:
|
| 217 |
+
reclaimOlderThanMs !== undefined
|
| 218 |
+
? { reclaim_older_than_ms: reclaimOlderThanMs }
|
| 219 |
+
: undefined,
|
| 220 |
+
timeout: 10_000,
|
| 221 |
+
signal,
|
| 222 |
+
validateStatus: status => status < 500,
|
| 223 |
+
},
|
| 224 |
+
)
|
| 225 |
+
|
| 226 |
+
handleErrorStatus(response.status, response.data, 'Poll')
|
| 227 |
+
|
| 228 |
+
// Empty body or null = no work available
|
| 229 |
+
if (!response.data) {
|
| 230 |
+
consecutiveEmptyPolls = prevEmptyPolls + 1
|
| 231 |
+
if (
|
| 232 |
+
consecutiveEmptyPolls === 1 ||
|
| 233 |
+
consecutiveEmptyPolls % EMPTY_POLL_LOG_INTERVAL === 0
|
| 234 |
+
) {
|
| 235 |
+
debug(
|
| 236 |
+
`[bridge:api] GET .../work/poll -> ${response.status} (no work, ${consecutiveEmptyPolls} consecutive empty polls)`,
|
| 237 |
+
)
|
| 238 |
+
}
|
| 239 |
+
return null
|
| 240 |
+
}
|
| 241 |
+
|
| 242 |
+
debug(
|
| 243 |
+
`[bridge:api] GET .../work/poll -> ${response.status} workId=${response.data.id} type=${response.data.data?.type}${response.data.data?.id ? ` sessionId=${response.data.data.id}` : ''}`,
|
| 244 |
+
)
|
| 245 |
+
debug(`[bridge:api] <<< ${debugBody(response.data)}`)
|
| 246 |
+
return response.data
|
| 247 |
+
},
|
| 248 |
+
|
| 249 |
+
async acknowledgeWork(
|
| 250 |
+
environmentId: string,
|
| 251 |
+
workId: string,
|
| 252 |
+
sessionToken: string,
|
| 253 |
+
): Promise<void> {
|
| 254 |
+
validateBridgeId(environmentId, 'environmentId')
|
| 255 |
+
validateBridgeId(workId, 'workId')
|
| 256 |
+
|
| 257 |
+
debug(`[bridge:api] POST .../work/${workId}/ack`)
|
| 258 |
+
|
| 259 |
+
const response = await axios.post(
|
| 260 |
+
`${deps.baseUrl}/v1/environments/${environmentId}/work/${workId}/ack`,
|
| 261 |
+
{},
|
| 262 |
+
{
|
| 263 |
+
headers: getHeaders(sessionToken),
|
| 264 |
+
timeout: 10_000,
|
| 265 |
+
validateStatus: s => s < 500,
|
| 266 |
+
},
|
| 267 |
+
)
|
| 268 |
+
|
| 269 |
+
handleErrorStatus(response.status, response.data, 'Acknowledge')
|
| 270 |
+
debug(`[bridge:api] POST .../work/${workId}/ack -> ${response.status}`)
|
| 271 |
+
},
|
| 272 |
+
|
| 273 |
+
async stopWork(
|
| 274 |
+
environmentId: string,
|
| 275 |
+
workId: string,
|
| 276 |
+
force: boolean,
|
| 277 |
+
): Promise<void> {
|
| 278 |
+
validateBridgeId(environmentId, 'environmentId')
|
| 279 |
+
validateBridgeId(workId, 'workId')
|
| 280 |
+
|
| 281 |
+
debug(`[bridge:api] POST .../work/${workId}/stop force=${force}`)
|
| 282 |
+
|
| 283 |
+
const response = await withOAuthRetry(
|
| 284 |
+
(token: string) =>
|
| 285 |
+
axios.post(
|
| 286 |
+
`${deps.baseUrl}/v1/environments/${environmentId}/work/${workId}/stop`,
|
| 287 |
+
{ force },
|
| 288 |
+
{
|
| 289 |
+
headers: getHeaders(token),
|
| 290 |
+
timeout: 10_000,
|
| 291 |
+
validateStatus: s => s < 500,
|
| 292 |
+
},
|
| 293 |
+
),
|
| 294 |
+
'StopWork',
|
| 295 |
+
)
|
| 296 |
+
|
| 297 |
+
handleErrorStatus(response.status, response.data, 'StopWork')
|
| 298 |
+
debug(`[bridge:api] POST .../work/${workId}/stop -> ${response.status}`)
|
| 299 |
+
},
|
| 300 |
+
|
| 301 |
+
async deregisterEnvironment(environmentId: string): Promise<void> {
|
| 302 |
+
validateBridgeId(environmentId, 'environmentId')
|
| 303 |
+
|
| 304 |
+
debug(`[bridge:api] DELETE /v1/environments/bridge/${environmentId}`)
|
| 305 |
+
|
| 306 |
+
const response = await withOAuthRetry(
|
| 307 |
+
(token: string) =>
|
| 308 |
+
axios.delete(
|
| 309 |
+
`${deps.baseUrl}/v1/environments/bridge/${environmentId}`,
|
| 310 |
+
{
|
| 311 |
+
headers: getHeaders(token),
|
| 312 |
+
timeout: 10_000,
|
| 313 |
+
validateStatus: s => s < 500,
|
| 314 |
+
},
|
| 315 |
+
),
|
| 316 |
+
'Deregister',
|
| 317 |
+
)
|
| 318 |
+
|
| 319 |
+
handleErrorStatus(response.status, response.data, 'Deregister')
|
| 320 |
+
debug(
|
| 321 |
+
`[bridge:api] DELETE /v1/environments/bridge/${environmentId} -> ${response.status}`,
|
| 322 |
+
)
|
| 323 |
+
},
|
| 324 |
+
|
| 325 |
+
async archiveSession(sessionId: string): Promise<void> {
|
| 326 |
+
validateBridgeId(sessionId, 'sessionId')
|
| 327 |
+
|
| 328 |
+
debug(`[bridge:api] POST /v1/sessions/${sessionId}/archive`)
|
| 329 |
+
|
| 330 |
+
const response = await withOAuthRetry(
|
| 331 |
+
(token: string) =>
|
| 332 |
+
axios.post(
|
| 333 |
+
`${deps.baseUrl}/v1/sessions/${sessionId}/archive`,
|
| 334 |
+
{},
|
| 335 |
+
{
|
| 336 |
+
headers: getHeaders(token),
|
| 337 |
+
timeout: 10_000,
|
| 338 |
+
validateStatus: s => s < 500,
|
| 339 |
+
},
|
| 340 |
+
),
|
| 341 |
+
'ArchiveSession',
|
| 342 |
+
)
|
| 343 |
+
|
| 344 |
+
// 409 = already archived (idempotent, not an error)
|
| 345 |
+
if (response.status === 409) {
|
| 346 |
+
debug(
|
| 347 |
+
`[bridge:api] POST /v1/sessions/${sessionId}/archive -> 409 (already archived)`,
|
| 348 |
+
)
|
| 349 |
+
return
|
| 350 |
+
}
|
| 351 |
+
|
| 352 |
+
handleErrorStatus(response.status, response.data, 'ArchiveSession')
|
| 353 |
+
debug(
|
| 354 |
+
`[bridge:api] POST /v1/sessions/${sessionId}/archive -> ${response.status}`,
|
| 355 |
+
)
|
| 356 |
+
},
|
| 357 |
+
|
| 358 |
+
async reconnectSession(
|
| 359 |
+
environmentId: string,
|
| 360 |
+
sessionId: string,
|
| 361 |
+
): Promise<void> {
|
| 362 |
+
validateBridgeId(environmentId, 'environmentId')
|
| 363 |
+
validateBridgeId(sessionId, 'sessionId')
|
| 364 |
+
|
| 365 |
+
debug(
|
| 366 |
+
`[bridge:api] POST /v1/environments/${environmentId}/bridge/reconnect session_id=${sessionId}`,
|
| 367 |
+
)
|
| 368 |
+
|
| 369 |
+
const response = await withOAuthRetry(
|
| 370 |
+
(token: string) =>
|
| 371 |
+
axios.post(
|
| 372 |
+
`${deps.baseUrl}/v1/environments/${environmentId}/bridge/reconnect`,
|
| 373 |
+
{ session_id: sessionId },
|
| 374 |
+
{
|
| 375 |
+
headers: getHeaders(token),
|
| 376 |
+
timeout: 10_000,
|
| 377 |
+
validateStatus: s => s < 500,
|
| 378 |
+
},
|
| 379 |
+
),
|
| 380 |
+
'ReconnectSession',
|
| 381 |
+
)
|
| 382 |
+
|
| 383 |
+
handleErrorStatus(response.status, response.data, 'ReconnectSession')
|
| 384 |
+
debug(`[bridge:api] POST .../bridge/reconnect -> ${response.status}`)
|
| 385 |
+
},
|
| 386 |
+
|
| 387 |
+
async heartbeatWork(
|
| 388 |
+
environmentId: string,
|
| 389 |
+
workId: string,
|
| 390 |
+
sessionToken: string,
|
| 391 |
+
): Promise<{ lease_extended: boolean; state: string }> {
|
| 392 |
+
validateBridgeId(environmentId, 'environmentId')
|
| 393 |
+
validateBridgeId(workId, 'workId')
|
| 394 |
+
|
| 395 |
+
debug(`[bridge:api] POST .../work/${workId}/heartbeat`)
|
| 396 |
+
|
| 397 |
+
const response = await axios.post<{
|
| 398 |
+
lease_extended: boolean
|
| 399 |
+
state: string
|
| 400 |
+
last_heartbeat: string
|
| 401 |
+
ttl_seconds: number
|
| 402 |
+
}>(
|
| 403 |
+
`${deps.baseUrl}/v1/environments/${environmentId}/work/${workId}/heartbeat`,
|
| 404 |
+
{},
|
| 405 |
+
{
|
| 406 |
+
headers: getHeaders(sessionToken),
|
| 407 |
+
timeout: 10_000,
|
| 408 |
+
validateStatus: s => s < 500,
|
| 409 |
+
},
|
| 410 |
+
)
|
| 411 |
+
|
| 412 |
+
handleErrorStatus(response.status, response.data, 'Heartbeat')
|
| 413 |
+
debug(
|
| 414 |
+
`[bridge:api] POST .../work/${workId}/heartbeat -> ${response.status} lease_extended=${response.data.lease_extended} state=${response.data.state}`,
|
| 415 |
+
)
|
| 416 |
+
return response.data
|
| 417 |
+
},
|
| 418 |
+
|
| 419 |
+
async sendPermissionResponseEvent(
|
| 420 |
+
sessionId: string,
|
| 421 |
+
event: PermissionResponseEvent,
|
| 422 |
+
sessionToken: string,
|
| 423 |
+
): Promise<void> {
|
| 424 |
+
validateBridgeId(sessionId, 'sessionId')
|
| 425 |
+
|
| 426 |
+
debug(
|
| 427 |
+
`[bridge:api] POST /v1/sessions/${sessionId}/events type=${event.type}`,
|
| 428 |
+
)
|
| 429 |
+
|
| 430 |
+
const response = await axios.post(
|
| 431 |
+
`${deps.baseUrl}/v1/sessions/${sessionId}/events`,
|
| 432 |
+
{ events: [event] },
|
| 433 |
+
{
|
| 434 |
+
headers: getHeaders(sessionToken),
|
| 435 |
+
timeout: 10_000,
|
| 436 |
+
validateStatus: s => s < 500,
|
| 437 |
+
},
|
| 438 |
+
)
|
| 439 |
+
|
| 440 |
+
handleErrorStatus(
|
| 441 |
+
response.status,
|
| 442 |
+
response.data,
|
| 443 |
+
'SendPermissionResponseEvent',
|
| 444 |
+
)
|
| 445 |
+
debug(
|
| 446 |
+
`[bridge:api] POST /v1/sessions/${sessionId}/events -> ${response.status}`,
|
| 447 |
+
)
|
| 448 |
+
debug(`[bridge:api] >>> ${debugBody({ events: [event] })}`)
|
| 449 |
+
debug(`[bridge:api] <<< ${debugBody(response.data)}`)
|
| 450 |
+
},
|
| 451 |
+
}
|
| 452 |
+
}
|
| 453 |
+
|
| 454 |
+
function handleErrorStatus(
|
| 455 |
+
status: number,
|
| 456 |
+
data: unknown,
|
| 457 |
+
context: string,
|
| 458 |
+
): void {
|
| 459 |
+
if (status === 200 || status === 204) {
|
| 460 |
+
return
|
| 461 |
+
}
|
| 462 |
+
const detail = extractErrorDetail(data)
|
| 463 |
+
const errorType = extractErrorTypeFromData(data)
|
| 464 |
+
switch (status) {
|
| 465 |
+
case 401:
|
| 466 |
+
throw new BridgeFatalError(
|
| 467 |
+
`${context}: Authentication failed (401)${detail ? `: ${detail}` : ''}. ${BRIDGE_LOGIN_INSTRUCTION}`,
|
| 468 |
+
401,
|
| 469 |
+
errorType,
|
| 470 |
+
)
|
| 471 |
+
case 403:
|
| 472 |
+
throw new BridgeFatalError(
|
| 473 |
+
isExpiredErrorType(errorType)
|
| 474 |
+
? 'Remote Control session has expired. Please restart with `claude remote-control` or /remote-control.'
|
| 475 |
+
: `${context}: Access denied (403)${detail ? `: ${detail}` : ''}. Check your organization permissions.`,
|
| 476 |
+
403,
|
| 477 |
+
errorType,
|
| 478 |
+
)
|
| 479 |
+
case 404:
|
| 480 |
+
throw new BridgeFatalError(
|
| 481 |
+
detail ??
|
| 482 |
+
`${context}: Not found (404). Remote Control may not be available for this organization.`,
|
| 483 |
+
404,
|
| 484 |
+
errorType,
|
| 485 |
+
)
|
| 486 |
+
case 410:
|
| 487 |
+
throw new BridgeFatalError(
|
| 488 |
+
detail ??
|
| 489 |
+
'Remote Control session has expired. Please restart with `claude remote-control` or /remote-control.',
|
| 490 |
+
410,
|
| 491 |
+
errorType ?? 'environment_expired',
|
| 492 |
+
)
|
| 493 |
+
case 429:
|
| 494 |
+
throw new Error(`${context}: Rate limited (429). Polling too frequently.`)
|
| 495 |
+
default:
|
| 496 |
+
throw new Error(
|
| 497 |
+
`${context}: Failed with status ${status}${detail ? `: ${detail}` : ''}`,
|
| 498 |
+
)
|
| 499 |
+
}
|
| 500 |
+
}
|
| 501 |
+
|
| 502 |
+
/** Check whether an error type string indicates a session/environment expiry. */
|
| 503 |
+
export function isExpiredErrorType(errorType: string | undefined): boolean {
|
| 504 |
+
if (!errorType) {
|
| 505 |
+
return false
|
| 506 |
+
}
|
| 507 |
+
return errorType.includes('expired') || errorType.includes('lifetime')
|
| 508 |
+
}
|
| 509 |
+
|
| 510 |
+
/**
|
| 511 |
+
* Check whether a BridgeFatalError is a suppressible 403 permission error.
|
| 512 |
+
* These are 403 errors for scopes like 'external_poll_sessions' or operations
|
| 513 |
+
* like StopWork that fail because the user's role lacks 'environments:manage'.
|
| 514 |
+
* They don't affect core functionality and shouldn't be shown to users.
|
| 515 |
+
*/
|
| 516 |
+
export function isSuppressible403(err: BridgeFatalError): boolean {
|
| 517 |
+
if (err.status !== 403) {
|
| 518 |
+
return false
|
| 519 |
+
}
|
| 520 |
+
return (
|
| 521 |
+
err.message.includes('external_poll_sessions') ||
|
| 522 |
+
err.message.includes('environments:manage')
|
| 523 |
+
)
|
| 524 |
+
}
|
| 525 |
+
|
| 526 |
+
function extractErrorTypeFromData(data: unknown): string | undefined {
|
| 527 |
+
if (data && typeof data === 'object') {
|
| 528 |
+
if (
|
| 529 |
+
'error' in data &&
|
| 530 |
+
data.error &&
|
| 531 |
+
typeof data.error === 'object' &&
|
| 532 |
+
'type' in data.error &&
|
| 533 |
+
typeof data.error.type === 'string'
|
| 534 |
+
) {
|
| 535 |
+
return data.error.type
|
| 536 |
+
}
|
| 537 |
+
}
|
| 538 |
+
return undefined
|
| 539 |
+
}
|
src/bridge/bridgeConfig.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Shared bridge auth/URL resolution. Consolidates the ant-only
|
| 3 |
+
* CLAUDE_BRIDGE_* dev overrides that were previously copy-pasted across
|
| 4 |
+
* a dozen files — inboundAttachments, BriefTool/upload, bridgeMain,
|
| 5 |
+
* initReplBridge, remoteBridgeCore, daemon workers, /rename,
|
| 6 |
+
* /remote-control.
|
| 7 |
+
*
|
| 8 |
+
* Two layers: *Override() returns the ant-only env var (or undefined);
|
| 9 |
+
* the non-Override versions fall through to the real OAuth store/config.
|
| 10 |
+
* Callers that compose with a different auth source (e.g. daemon workers
|
| 11 |
+
* using IPC auth) use the Override getters directly.
|
| 12 |
+
*/
|
| 13 |
+
|
| 14 |
+
import { getOauthConfig } from '../constants/oauth.js'
|
| 15 |
+
import { getClaudeAIOAuthTokens } from '../utils/auth.js'
|
| 16 |
+
|
| 17 |
+
/** Ant-only dev override: CLAUDE_BRIDGE_OAUTH_TOKEN, else undefined. */
|
| 18 |
+
export function getBridgeTokenOverride(): string | undefined {
|
| 19 |
+
return (
|
| 20 |
+
(process.env.USER_TYPE === 'ant' &&
|
| 21 |
+
process.env.CLAUDE_BRIDGE_OAUTH_TOKEN) ||
|
| 22 |
+
undefined
|
| 23 |
+
)
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
/** Ant-only dev override: CLAUDE_BRIDGE_BASE_URL, else undefined. */
|
| 27 |
+
export function getBridgeBaseUrlOverride(): string | undefined {
|
| 28 |
+
return (
|
| 29 |
+
(process.env.USER_TYPE === 'ant' && process.env.CLAUDE_BRIDGE_BASE_URL) ||
|
| 30 |
+
undefined
|
| 31 |
+
)
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
/**
|
| 35 |
+
* Access token for bridge API calls: dev override first, then the OAuth
|
| 36 |
+
* keychain. Undefined means "not logged in".
|
| 37 |
+
*/
|
| 38 |
+
export function getBridgeAccessToken(): string | undefined {
|
| 39 |
+
return getBridgeTokenOverride() ?? getClaudeAIOAuthTokens()?.accessToken
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
/**
|
| 43 |
+
* Base URL for bridge API calls: dev override first, then the production
|
| 44 |
+
* OAuth config. Always returns a URL.
|
| 45 |
+
*/
|
| 46 |
+
export function getBridgeBaseUrl(): string {
|
| 47 |
+
return getBridgeBaseUrlOverride() ?? getOauthConfig().BASE_API_URL
|
| 48 |
+
}
|
src/bridge/bridgeDebug.ts
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { logForDebugging } from '../utils/debug.js'
|
| 2 |
+
import { BridgeFatalError } from './bridgeApi.js'
|
| 3 |
+
import type { BridgeApiClient } from './types.js'
|
| 4 |
+
|
| 5 |
+
/**
|
| 6 |
+
* Ant-only fault injection for manually testing bridge recovery paths.
|
| 7 |
+
*
|
| 8 |
+
* Real failure modes this targets (BQ 2026-03-12, 7-day window):
|
| 9 |
+
* poll 404 not_found_error — 147K sessions/week, dead onEnvironmentLost gate
|
| 10 |
+
* ws_closed 1002/1006 — 22K sessions/week, zombie poll after close
|
| 11 |
+
* register transient failure — residual: network blips during doReconnect
|
| 12 |
+
*
|
| 13 |
+
* Usage: /bridge-kick <subcommand> from the REPL while Remote Control is
|
| 14 |
+
* connected, then tail debug.log to watch the recovery machinery react.
|
| 15 |
+
*
|
| 16 |
+
* Module-level state is intentional here: one bridge per REPL process, the
|
| 17 |
+
* /bridge-kick slash command has no other way to reach into initBridgeCore's
|
| 18 |
+
* closures, and teardown clears the slot.
|
| 19 |
+
*/
|
| 20 |
+
|
| 21 |
+
/** One-shot fault to inject on the next matching api call. */
|
| 22 |
+
type BridgeFault = {
|
| 23 |
+
method:
|
| 24 |
+
| 'pollForWork'
|
| 25 |
+
| 'registerBridgeEnvironment'
|
| 26 |
+
| 'reconnectSession'
|
| 27 |
+
| 'heartbeatWork'
|
| 28 |
+
/** Fatal errors go through handleErrorStatus → BridgeFatalError. Transient
|
| 29 |
+
* errors surface as plain axios rejections (5xx / network). Recovery code
|
| 30 |
+
* distinguishes the two: fatal → teardown, transient → retry/backoff. */
|
| 31 |
+
kind: 'fatal' | 'transient'
|
| 32 |
+
status: number
|
| 33 |
+
errorType?: string
|
| 34 |
+
/** Remaining injections. Decremented on consume; removed at 0. */
|
| 35 |
+
count: number
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
export type BridgeDebugHandle = {
|
| 39 |
+
/** Invoke the transport's permanent-close handler directly. Tests the
|
| 40 |
+
* ws_closed → reconnectEnvironmentWithSession escalation (#22148). */
|
| 41 |
+
fireClose: (code: number) => void
|
| 42 |
+
/** Call reconnectEnvironmentWithSession() — same as SIGUSR2 but
|
| 43 |
+
* reachable from the slash command. */
|
| 44 |
+
forceReconnect: () => void
|
| 45 |
+
/** Queue a fault for the next N calls to the named api method. */
|
| 46 |
+
injectFault: (fault: BridgeFault) => void
|
| 47 |
+
/** Abort the at-capacity sleep so an injected poll fault lands
|
| 48 |
+
* immediately instead of up to 10min later. */
|
| 49 |
+
wakePollLoop: () => void
|
| 50 |
+
/** env/session IDs for the debug.log grep. */
|
| 51 |
+
describe: () => string
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
let debugHandle: BridgeDebugHandle | null = null
|
| 55 |
+
const faultQueue: BridgeFault[] = []
|
| 56 |
+
|
| 57 |
+
export function registerBridgeDebugHandle(h: BridgeDebugHandle): void {
|
| 58 |
+
debugHandle = h
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
export function clearBridgeDebugHandle(): void {
|
| 62 |
+
debugHandle = null
|
| 63 |
+
faultQueue.length = 0
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
export function getBridgeDebugHandle(): BridgeDebugHandle | null {
|
| 67 |
+
return debugHandle
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
export function injectBridgeFault(fault: BridgeFault): void {
|
| 71 |
+
faultQueue.push(fault)
|
| 72 |
+
logForDebugging(
|
| 73 |
+
`[bridge:debug] Queued fault: ${fault.method} ${fault.kind}/${fault.status}${fault.errorType ? `/${fault.errorType}` : ''} ×${fault.count}`,
|
| 74 |
+
)
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
/**
|
| 78 |
+
* Wrap a BridgeApiClient so each call first checks the fault queue. If a
|
| 79 |
+
* matching fault is queued, throw the specified error instead of calling
|
| 80 |
+
* through. Delegates everything else to the real client.
|
| 81 |
+
*
|
| 82 |
+
* Only called when USER_TYPE === 'ant' — zero overhead in external builds.
|
| 83 |
+
*/
|
| 84 |
+
export function wrapApiForFaultInjection(
|
| 85 |
+
api: BridgeApiClient,
|
| 86 |
+
): BridgeApiClient {
|
| 87 |
+
function consume(method: BridgeFault['method']): BridgeFault | null {
|
| 88 |
+
const idx = faultQueue.findIndex(f => f.method === method)
|
| 89 |
+
if (idx === -1) return null
|
| 90 |
+
const fault = faultQueue[idx]!
|
| 91 |
+
fault.count--
|
| 92 |
+
if (fault.count <= 0) faultQueue.splice(idx, 1)
|
| 93 |
+
return fault
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
function throwFault(fault: BridgeFault, context: string): never {
|
| 97 |
+
logForDebugging(
|
| 98 |
+
`[bridge:debug] Injecting ${fault.kind} fault into ${context}: status=${fault.status} errorType=${fault.errorType ?? 'none'}`,
|
| 99 |
+
)
|
| 100 |
+
if (fault.kind === 'fatal') {
|
| 101 |
+
throw new BridgeFatalError(
|
| 102 |
+
`[injected] ${context} ${fault.status}`,
|
| 103 |
+
fault.status,
|
| 104 |
+
fault.errorType,
|
| 105 |
+
)
|
| 106 |
+
}
|
| 107 |
+
// Transient: mimic an axios rejection (5xx / network). No .status on
|
| 108 |
+
// the error itself — that's how the catch blocks distinguish.
|
| 109 |
+
throw new Error(`[injected transient] ${context} ${fault.status}`)
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
return {
|
| 113 |
+
...api,
|
| 114 |
+
async pollForWork(envId, secret, signal, reclaimMs) {
|
| 115 |
+
const f = consume('pollForWork')
|
| 116 |
+
if (f) throwFault(f, 'Poll')
|
| 117 |
+
return api.pollForWork(envId, secret, signal, reclaimMs)
|
| 118 |
+
},
|
| 119 |
+
async registerBridgeEnvironment(config) {
|
| 120 |
+
const f = consume('registerBridgeEnvironment')
|
| 121 |
+
if (f) throwFault(f, 'Registration')
|
| 122 |
+
return api.registerBridgeEnvironment(config)
|
| 123 |
+
},
|
| 124 |
+
async reconnectSession(envId, sessionId) {
|
| 125 |
+
const f = consume('reconnectSession')
|
| 126 |
+
if (f) throwFault(f, 'ReconnectSession')
|
| 127 |
+
return api.reconnectSession(envId, sessionId)
|
| 128 |
+
},
|
| 129 |
+
async heartbeatWork(envId, workId, token) {
|
| 130 |
+
const f = consume('heartbeatWork')
|
| 131 |
+
if (f) throwFault(f, 'Heartbeat')
|
| 132 |
+
return api.heartbeatWork(envId, workId, token)
|
| 133 |
+
},
|
| 134 |
+
}
|
| 135 |
+
}
|
src/bridge/bridgeEnabled.ts
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { feature } from 'bun:bundle'
|
| 2 |
+
import {
|
| 3 |
+
checkGate_CACHED_OR_BLOCKING,
|
| 4 |
+
getDynamicConfig_CACHED_MAY_BE_STALE,
|
| 5 |
+
getFeatureValue_CACHED_MAY_BE_STALE,
|
| 6 |
+
} from '../services/analytics/growthbook.js'
|
| 7 |
+
// Namespace import breaks the bridgeEnabled → auth → config → bridgeEnabled
|
| 8 |
+
// cycle — authModule.foo is a live binding, so by the time the helpers below
|
| 9 |
+
// call it, auth.js is fully loaded. Previously used require() for the same
|
| 10 |
+
// deferral, but require() hits a CJS cache that diverges from the ESM
|
| 11 |
+
// namespace after mock.module() (daemon/auth.test.ts), breaking spyOn.
|
| 12 |
+
import * as authModule from '../utils/auth.js'
|
| 13 |
+
import { isEnvTruthy } from '../utils/envUtils.js'
|
| 14 |
+
import { lt } from '../utils/semver.js'
|
| 15 |
+
|
| 16 |
+
/**
|
| 17 |
+
* Runtime check for bridge mode entitlement.
|
| 18 |
+
*
|
| 19 |
+
* Remote Control requires a claude.ai subscription (the bridge auths to CCR
|
| 20 |
+
* with the claude.ai OAuth token). isClaudeAISubscriber() excludes
|
| 21 |
+
* Bedrock/Vertex/Foundry, apiKeyHelper/gateway deployments, env-var API keys,
|
| 22 |
+
* and Console API logins — none of which have the OAuth token CCR needs.
|
| 23 |
+
* See github.com/deshaw/anthropic-issues/issues/24.
|
| 24 |
+
*
|
| 25 |
+
* The `feature('BRIDGE_MODE')` guard ensures the GrowthBook string literal
|
| 26 |
+
* is only referenced when bridge mode is enabled at build time.
|
| 27 |
+
*/
|
| 28 |
+
export function isBridgeEnabled(): boolean {
|
| 29 |
+
// Positive ternary pattern — see docs/feature-gating.md.
|
| 30 |
+
// Negative pattern (if (!feature(...)) return) does not eliminate
|
| 31 |
+
// inline string literals from external builds.
|
| 32 |
+
return feature('BRIDGE_MODE')
|
| 33 |
+
? isClaudeAISubscriber() &&
|
| 34 |
+
getFeatureValue_CACHED_MAY_BE_STALE('tengu_ccr_bridge', false)
|
| 35 |
+
: false
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
/**
|
| 39 |
+
* Blocking entitlement check for Remote Control.
|
| 40 |
+
*
|
| 41 |
+
* Returns cached `true` immediately (fast path). If the disk cache says
|
| 42 |
+
* `false` or is missing, awaits GrowthBook init and fetches the fresh
|
| 43 |
+
* server value (slow path, max ~5s), then writes it to disk.
|
| 44 |
+
*
|
| 45 |
+
* Use at entitlement gates where a stale `false` would unfairly block access.
|
| 46 |
+
* For user-facing error paths, prefer `getBridgeDisabledReason()` which gives
|
| 47 |
+
* a specific diagnostic. For render-body UI visibility checks, use
|
| 48 |
+
* `isBridgeEnabled()` instead.
|
| 49 |
+
*/
|
| 50 |
+
export async function isBridgeEnabledBlocking(): Promise<boolean> {
|
| 51 |
+
return feature('BRIDGE_MODE')
|
| 52 |
+
? isClaudeAISubscriber() &&
|
| 53 |
+
(await checkGate_CACHED_OR_BLOCKING('tengu_ccr_bridge'))
|
| 54 |
+
: false
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
/**
|
| 58 |
+
* Diagnostic message for why Remote Control is unavailable, or null if
|
| 59 |
+
* it's enabled. Call this instead of a bare `isBridgeEnabledBlocking()`
|
| 60 |
+
* check when you need to show the user an actionable error.
|
| 61 |
+
*
|
| 62 |
+
* The GrowthBook gate targets on organizationUUID, which comes from
|
| 63 |
+
* config.oauthAccount — populated by /api/oauth/profile during login.
|
| 64 |
+
* That endpoint requires the user:profile scope. Tokens without it
|
| 65 |
+
* (setup-token, CLAUDE_CODE_OAUTH_TOKEN env var, or pre-scope-expansion
|
| 66 |
+
* logins) leave oauthAccount unpopulated, so the gate falls back to
|
| 67 |
+
* false and users see a dead-end "not enabled" message with no hint
|
| 68 |
+
* that re-login would fix it. See CC-1165 / gh-33105.
|
| 69 |
+
*/
|
| 70 |
+
export async function getBridgeDisabledReason(): Promise<string | null> {
|
| 71 |
+
if (feature('BRIDGE_MODE')) {
|
| 72 |
+
if (!isClaudeAISubscriber()) {
|
| 73 |
+
return 'Remote Control requires a claude.ai subscription. Run `claude auth login` to sign in with your claude.ai account.'
|
| 74 |
+
}
|
| 75 |
+
if (!hasProfileScope()) {
|
| 76 |
+
return 'Remote Control requires a full-scope login token. Long-lived tokens (from `claude setup-token` or CLAUDE_CODE_OAUTH_TOKEN) are limited to inference-only for security reasons. Run `claude auth login` to use Remote Control.'
|
| 77 |
+
}
|
| 78 |
+
if (!getOauthAccountInfo()?.organizationUuid) {
|
| 79 |
+
return 'Unable to determine your organization for Remote Control eligibility. Run `claude auth login` to refresh your account information.'
|
| 80 |
+
}
|
| 81 |
+
if (!(await checkGate_CACHED_OR_BLOCKING('tengu_ccr_bridge'))) {
|
| 82 |
+
return 'Remote Control is not yet enabled for your account.'
|
| 83 |
+
}
|
| 84 |
+
return null
|
| 85 |
+
}
|
| 86 |
+
return 'Remote Control is not available in this build.'
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
// try/catch: main.tsx:5698 calls isBridgeEnabled() while defining the Commander
|
| 90 |
+
// program, before enableConfigs() runs. isClaudeAISubscriber() → getGlobalConfig()
|
| 91 |
+
// throws "Config accessed before allowed" there. Pre-config, no OAuth token can
|
| 92 |
+
// exist anyway — false is correct. Same swallow getFeatureValue_CACHED_MAY_BE_STALE
|
| 93 |
+
// already does at growthbook.ts:775-780.
|
| 94 |
+
function isClaudeAISubscriber(): boolean {
|
| 95 |
+
try {
|
| 96 |
+
return authModule.isClaudeAISubscriber()
|
| 97 |
+
} catch {
|
| 98 |
+
return false
|
| 99 |
+
}
|
| 100 |
+
}
|
| 101 |
+
function hasProfileScope(): boolean {
|
| 102 |
+
try {
|
| 103 |
+
return authModule.hasProfileScope()
|
| 104 |
+
} catch {
|
| 105 |
+
return false
|
| 106 |
+
}
|
| 107 |
+
}
|
| 108 |
+
function getOauthAccountInfo(): ReturnType<
|
| 109 |
+
typeof authModule.getOauthAccountInfo
|
| 110 |
+
> {
|
| 111 |
+
try {
|
| 112 |
+
return authModule.getOauthAccountInfo()
|
| 113 |
+
} catch {
|
| 114 |
+
return undefined
|
| 115 |
+
}
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
/**
|
| 119 |
+
* Runtime check for the env-less (v2) REPL bridge path.
|
| 120 |
+
* Returns true when the GrowthBook flag `tengu_bridge_repl_v2` is enabled.
|
| 121 |
+
*
|
| 122 |
+
* This gates which implementation initReplBridge uses — NOT whether bridge
|
| 123 |
+
* is available at all (see isBridgeEnabled above). Daemon/print paths stay
|
| 124 |
+
* on the env-based implementation regardless of this gate.
|
| 125 |
+
*/
|
| 126 |
+
export function isEnvLessBridgeEnabled(): boolean {
|
| 127 |
+
return feature('BRIDGE_MODE')
|
| 128 |
+
? getFeatureValue_CACHED_MAY_BE_STALE('tengu_bridge_repl_v2', false)
|
| 129 |
+
: false
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
/**
|
| 133 |
+
* Kill-switch for the `cse_*` → `session_*` client-side retag shim.
|
| 134 |
+
*
|
| 135 |
+
* The shim exists because compat/convert.go:27 validates TagSession and the
|
| 136 |
+
* claude.ai frontend routes on `session_*`, while v2 worker endpoints hand out
|
| 137 |
+
* `cse_*`. Once the server tags by environment_kind and the frontend accepts
|
| 138 |
+
* `cse_*` directly, flip this to false to make toCompatSessionId a no-op.
|
| 139 |
+
* Defaults to true — the shim stays active until explicitly disabled.
|
| 140 |
+
*/
|
| 141 |
+
export function isCseShimEnabled(): boolean {
|
| 142 |
+
return feature('BRIDGE_MODE')
|
| 143 |
+
? getFeatureValue_CACHED_MAY_BE_STALE(
|
| 144 |
+
'tengu_bridge_repl_v2_cse_shim_enabled',
|
| 145 |
+
true,
|
| 146 |
+
)
|
| 147 |
+
: true
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
/**
|
| 151 |
+
* Returns an error message if the current CLI version is below the
|
| 152 |
+
* minimum required for the v1 (env-based) Remote Control path, or null if the
|
| 153 |
+
* version is fine. The v2 (env-less) path uses checkEnvLessBridgeMinVersion()
|
| 154 |
+
* in envLessBridgeConfig.ts instead — the two implementations have independent
|
| 155 |
+
* version floors.
|
| 156 |
+
*
|
| 157 |
+
* Uses cached (non-blocking) GrowthBook config. If GrowthBook hasn't
|
| 158 |
+
* loaded yet, the default '0.0.0' means the check passes — a safe fallback.
|
| 159 |
+
*/
|
| 160 |
+
export function checkBridgeMinVersion(): string | null {
|
| 161 |
+
// Positive pattern — see docs/feature-gating.md.
|
| 162 |
+
// Negative pattern (if (!feature(...)) return) does not eliminate
|
| 163 |
+
// inline string literals from external builds.
|
| 164 |
+
if (feature('BRIDGE_MODE')) {
|
| 165 |
+
const config = getDynamicConfig_CACHED_MAY_BE_STALE<{
|
| 166 |
+
minVersion: string
|
| 167 |
+
}>('tengu_bridge_min_version', { minVersion: '0.0.0' })
|
| 168 |
+
if (config.minVersion && lt(MACRO.VERSION, config.minVersion)) {
|
| 169 |
+
return `Your version of Claude Code (${MACRO.VERSION}) is too old for Remote Control.\nVersion ${config.minVersion} or higher is required. Run \`claude update\` to update.`
|
| 170 |
+
}
|
| 171 |
+
}
|
| 172 |
+
return null
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
/**
|
| 176 |
+
* Default for remoteControlAtStartup when the user hasn't explicitly set it.
|
| 177 |
+
* When the CCR_AUTO_CONNECT build flag is present (ant-only) and the
|
| 178 |
+
* tengu_cobalt_harbor GrowthBook gate is on, all sessions connect to CCR by
|
| 179 |
+
* default — the user can still opt out by setting remoteControlAtStartup=false
|
| 180 |
+
* in config (explicit settings always win over this default).
|
| 181 |
+
*
|
| 182 |
+
* Defined here rather than in config.ts to avoid a direct
|
| 183 |
+
* config.ts → growthbook.ts import cycle (growthbook.ts → user.ts → config.ts).
|
| 184 |
+
*/
|
| 185 |
+
export function getCcrAutoConnectDefault(): boolean {
|
| 186 |
+
return feature('CCR_AUTO_CONNECT')
|
| 187 |
+
? getFeatureValue_CACHED_MAY_BE_STALE('tengu_cobalt_harbor', false)
|
| 188 |
+
: false
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
/**
|
| 192 |
+
* Opt-in CCR mirror mode — every local session spawns an outbound-only
|
| 193 |
+
* Remote Control session that receives forwarded events. Separate from
|
| 194 |
+
* getCcrAutoConnectDefault (bidirectional Remote Control). Env var wins for
|
| 195 |
+
* local opt-in; GrowthBook controls rollout.
|
| 196 |
+
*/
|
| 197 |
+
export function isCcrMirrorEnabled(): boolean {
|
| 198 |
+
return feature('CCR_MIRROR')
|
| 199 |
+
? isEnvTruthy(process.env.CLAUDE_CODE_CCR_MIRROR) ||
|
| 200 |
+
getFeatureValue_CACHED_MAY_BE_STALE('tengu_ccr_mirror', false)
|
| 201 |
+
: false
|
| 202 |
+
}
|
src/bridge/bridgeMain.ts
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
src/bridge/bridgeMessaging.ts
ADDED
|
@@ -0,0 +1,461 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Shared transport-layer helpers for bridge message handling.
|
| 3 |
+
*
|
| 4 |
+
* Extracted from replBridge.ts so both the env-based core (initBridgeCore)
|
| 5 |
+
* and the env-less core (initEnvLessBridgeCore) can use the same ingress
|
| 6 |
+
* parsing, control-request handling, and echo-dedup machinery.
|
| 7 |
+
*
|
| 8 |
+
* Everything here is pure — no closure over bridge-specific state. All
|
| 9 |
+
* collaborators (transport, sessionId, UUID sets, callbacks) are passed
|
| 10 |
+
* as params.
|
| 11 |
+
*/
|
| 12 |
+
|
| 13 |
+
import { randomUUID } from 'crypto'
|
| 14 |
+
import type { SDKMessage } from '../entrypoints/agentSdkTypes.js'
|
| 15 |
+
import type {
|
| 16 |
+
SDKControlRequest,
|
| 17 |
+
SDKControlResponse,
|
| 18 |
+
} from '../entrypoints/sdk/controlTypes.js'
|
| 19 |
+
import type { SDKResultSuccess } from '../entrypoints/sdk/coreTypes.js'
|
| 20 |
+
import { logEvent } from '../services/analytics/index.js'
|
| 21 |
+
import { EMPTY_USAGE } from '../services/api/emptyUsage.js'
|
| 22 |
+
import type { Message } from '../types/message.js'
|
| 23 |
+
import { normalizeControlMessageKeys } from '../utils/controlMessageCompat.js'
|
| 24 |
+
import { logForDebugging } from '../utils/debug.js'
|
| 25 |
+
import { stripDisplayTagsAllowEmpty } from '../utils/displayTags.js'
|
| 26 |
+
import { errorMessage } from '../utils/errors.js'
|
| 27 |
+
import type { PermissionMode } from '../utils/permissions/PermissionMode.js'
|
| 28 |
+
import { jsonParse } from '../utils/slowOperations.js'
|
| 29 |
+
import type { ReplBridgeTransport } from './replBridgeTransport.js'
|
| 30 |
+
|
| 31 |
+
// ─── Type guards ─────────────────────────────────────────────────────────────
|
| 32 |
+
|
| 33 |
+
/** Type predicate for parsed WebSocket messages. SDKMessage is a
|
| 34 |
+
* discriminated union on `type` — validating the discriminant is
|
| 35 |
+
* sufficient for the predicate; callers narrow further via the union. */
|
| 36 |
+
export function isSDKMessage(value: unknown): value is SDKMessage {
|
| 37 |
+
return (
|
| 38 |
+
value !== null &&
|
| 39 |
+
typeof value === 'object' &&
|
| 40 |
+
'type' in value &&
|
| 41 |
+
typeof value.type === 'string'
|
| 42 |
+
)
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
/** Type predicate for control_response messages from the server. */
|
| 46 |
+
export function isSDKControlResponse(
|
| 47 |
+
value: unknown,
|
| 48 |
+
): value is SDKControlResponse {
|
| 49 |
+
return (
|
| 50 |
+
value !== null &&
|
| 51 |
+
typeof value === 'object' &&
|
| 52 |
+
'type' in value &&
|
| 53 |
+
value.type === 'control_response' &&
|
| 54 |
+
'response' in value
|
| 55 |
+
)
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
/** Type predicate for control_request messages from the server. */
|
| 59 |
+
export function isSDKControlRequest(
|
| 60 |
+
value: unknown,
|
| 61 |
+
): value is SDKControlRequest {
|
| 62 |
+
return (
|
| 63 |
+
value !== null &&
|
| 64 |
+
typeof value === 'object' &&
|
| 65 |
+
'type' in value &&
|
| 66 |
+
value.type === 'control_request' &&
|
| 67 |
+
'request_id' in value &&
|
| 68 |
+
'request' in value
|
| 69 |
+
)
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
/**
|
| 73 |
+
* True for message types that should be forwarded to the bridge transport.
|
| 74 |
+
* The server only wants user/assistant turns and slash-command system events;
|
| 75 |
+
* everything else (tool_result, progress, etc.) is internal REPL chatter.
|
| 76 |
+
*/
|
| 77 |
+
export function isEligibleBridgeMessage(m: Message): boolean {
|
| 78 |
+
// Virtual messages (REPL inner calls) are display-only — bridge/SDK
|
| 79 |
+
// consumers see the REPL tool_use/result which summarizes the work.
|
| 80 |
+
if ((m.type === 'user' || m.type === 'assistant') && m.isVirtual) {
|
| 81 |
+
return false
|
| 82 |
+
}
|
| 83 |
+
return (
|
| 84 |
+
m.type === 'user' ||
|
| 85 |
+
m.type === 'assistant' ||
|
| 86 |
+
(m.type === 'system' && m.subtype === 'local_command')
|
| 87 |
+
)
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
/**
|
| 91 |
+
* Extract title-worthy text from a Message for onUserMessage. Returns
|
| 92 |
+
* undefined for messages that shouldn't title the session: non-user, meta
|
| 93 |
+
* (nudges), tool results, compact summaries, non-human origins (task
|
| 94 |
+
* notifications, channel messages), or pure display-tag content
|
| 95 |
+
* (<ide_opened_file>, <session-start-hook>, etc.).
|
| 96 |
+
*
|
| 97 |
+
* Synthetic interrupts ([Request interrupted by user]) are NOT filtered here —
|
| 98 |
+
* isSyntheticMessage lives in messages.ts (heavy import, pulls command
|
| 99 |
+
* registry). The initialMessages path in initReplBridge checks it; the
|
| 100 |
+
* writeMessages path reaching an interrupt as the *first* message is
|
| 101 |
+
* implausible (an interrupt implies a prior prompt already flowed through).
|
| 102 |
+
*/
|
| 103 |
+
export function extractTitleText(m: Message): string | undefined {
|
| 104 |
+
if (m.type !== 'user' || m.isMeta || m.toolUseResult || m.isCompactSummary)
|
| 105 |
+
return undefined
|
| 106 |
+
if (m.origin && m.origin.kind !== 'human') return undefined
|
| 107 |
+
const content = m.message.content
|
| 108 |
+
let raw: string | undefined
|
| 109 |
+
if (typeof content === 'string') {
|
| 110 |
+
raw = content
|
| 111 |
+
} else {
|
| 112 |
+
for (const block of content) {
|
| 113 |
+
if (block.type === 'text') {
|
| 114 |
+
raw = block.text
|
| 115 |
+
break
|
| 116 |
+
}
|
| 117 |
+
}
|
| 118 |
+
}
|
| 119 |
+
if (!raw) return undefined
|
| 120 |
+
const clean = stripDisplayTagsAllowEmpty(raw)
|
| 121 |
+
return clean || undefined
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
// ─── Ingress routing ─────────────────────────────────────────────────────────
|
| 125 |
+
|
| 126 |
+
/**
|
| 127 |
+
* Parse an ingress WebSocket message and route it to the appropriate handler.
|
| 128 |
+
* Ignores messages whose UUID is in recentPostedUUIDs (echoes of what we sent)
|
| 129 |
+
* or in recentInboundUUIDs (re-deliveries we've already forwarded — e.g.
|
| 130 |
+
* server replayed history after a transport swap lost the seq-num cursor).
|
| 131 |
+
*/
|
| 132 |
+
export function handleIngressMessage(
|
| 133 |
+
data: string,
|
| 134 |
+
recentPostedUUIDs: BoundedUUIDSet,
|
| 135 |
+
recentInboundUUIDs: BoundedUUIDSet,
|
| 136 |
+
onInboundMessage: ((msg: SDKMessage) => void | Promise<void>) | undefined,
|
| 137 |
+
onPermissionResponse?: ((response: SDKControlResponse) => void) | undefined,
|
| 138 |
+
onControlRequest?: ((request: SDKControlRequest) => void) | undefined,
|
| 139 |
+
): void {
|
| 140 |
+
try {
|
| 141 |
+
const parsed: unknown = normalizeControlMessageKeys(jsonParse(data))
|
| 142 |
+
|
| 143 |
+
// control_response is not an SDKMessage — check before the type guard
|
| 144 |
+
if (isSDKControlResponse(parsed)) {
|
| 145 |
+
logForDebugging('[bridge:repl] Ingress message type=control_response')
|
| 146 |
+
onPermissionResponse?.(parsed)
|
| 147 |
+
return
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
// control_request from the server (initialize, set_model, can_use_tool).
|
| 151 |
+
// Must respond promptly or the server kills the WS (~10-14s timeout).
|
| 152 |
+
if (isSDKControlRequest(parsed)) {
|
| 153 |
+
logForDebugging(
|
| 154 |
+
`[bridge:repl] Inbound control_request subtype=${parsed.request.subtype}`,
|
| 155 |
+
)
|
| 156 |
+
onControlRequest?.(parsed)
|
| 157 |
+
return
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
if (!isSDKMessage(parsed)) return
|
| 161 |
+
|
| 162 |
+
// Check for UUID to detect echoes of our own messages
|
| 163 |
+
const uuid =
|
| 164 |
+
'uuid' in parsed && typeof parsed.uuid === 'string'
|
| 165 |
+
? parsed.uuid
|
| 166 |
+
: undefined
|
| 167 |
+
|
| 168 |
+
if (uuid && recentPostedUUIDs.has(uuid)) {
|
| 169 |
+
logForDebugging(
|
| 170 |
+
`[bridge:repl] Ignoring echo: type=${parsed.type} uuid=${uuid}`,
|
| 171 |
+
)
|
| 172 |
+
return
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
// Defensive dedup: drop inbound prompts we've already forwarded. The
|
| 176 |
+
// SSE seq-num carryover (lastTransportSequenceNum) is the primary fix
|
| 177 |
+
// for history-replay; this catches edge cases where that negotiation
|
| 178 |
+
// fails (server ignores from_sequence_num, transport died before
|
| 179 |
+
// receiving any frames, etc).
|
| 180 |
+
if (uuid && recentInboundUUIDs.has(uuid)) {
|
| 181 |
+
logForDebugging(
|
| 182 |
+
`[bridge:repl] Ignoring re-delivered inbound: type=${parsed.type} uuid=${uuid}`,
|
| 183 |
+
)
|
| 184 |
+
return
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
logForDebugging(
|
| 188 |
+
`[bridge:repl] Ingress message type=${parsed.type}${uuid ? ` uuid=${uuid}` : ''}`,
|
| 189 |
+
)
|
| 190 |
+
|
| 191 |
+
if (parsed.type === 'user') {
|
| 192 |
+
if (uuid) recentInboundUUIDs.add(uuid)
|
| 193 |
+
logEvent('tengu_bridge_message_received', {
|
| 194 |
+
is_repl: true,
|
| 195 |
+
})
|
| 196 |
+
// Fire-and-forget — handler may be async (attachment resolution).
|
| 197 |
+
void onInboundMessage?.(parsed)
|
| 198 |
+
} else {
|
| 199 |
+
logForDebugging(
|
| 200 |
+
`[bridge:repl] Ignoring non-user inbound message: type=${parsed.type}`,
|
| 201 |
+
)
|
| 202 |
+
}
|
| 203 |
+
} catch (err) {
|
| 204 |
+
logForDebugging(
|
| 205 |
+
`[bridge:repl] Failed to parse ingress message: ${errorMessage(err)}`,
|
| 206 |
+
)
|
| 207 |
+
}
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
// ─── Server-initiated control requests ───────────────────────────────────────
|
| 211 |
+
|
| 212 |
+
export type ServerControlRequestHandlers = {
|
| 213 |
+
transport: ReplBridgeTransport | null
|
| 214 |
+
sessionId: string
|
| 215 |
+
/**
|
| 216 |
+
* When true, all mutable requests (interrupt, set_model, set_permission_mode,
|
| 217 |
+
* set_max_thinking_tokens) reply with an error instead of false-success.
|
| 218 |
+
* initialize still replies success — the server kills the connection otherwise.
|
| 219 |
+
* Used by the outbound-only bridge mode and the SDK's /bridge subpath so claude.ai sees a
|
| 220 |
+
* proper error instead of "action succeeded but nothing happened locally".
|
| 221 |
+
*/
|
| 222 |
+
outboundOnly?: boolean
|
| 223 |
+
onInterrupt?: () => void
|
| 224 |
+
onSetModel?: (model: string | undefined) => void
|
| 225 |
+
onSetMaxThinkingTokens?: (maxTokens: number | null) => void
|
| 226 |
+
onSetPermissionMode?: (
|
| 227 |
+
mode: PermissionMode,
|
| 228 |
+
) => { ok: true } | { ok: false; error: string }
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
const OUTBOUND_ONLY_ERROR =
|
| 232 |
+
'This session is outbound-only. Enable Remote Control locally to allow inbound control.'
|
| 233 |
+
|
| 234 |
+
/**
|
| 235 |
+
* Respond to inbound control_request messages from the server. The server
|
| 236 |
+
* sends these for session lifecycle events (initialize, set_model) and
|
| 237 |
+
* for turn-level coordination (interrupt, set_max_thinking_tokens). If we
|
| 238 |
+
* don't respond, the server hangs and kills the WS after ~10-14s.
|
| 239 |
+
*
|
| 240 |
+
* Previously a closure inside initBridgeCore's onWorkReceived; now takes
|
| 241 |
+
* collaborators as params so both cores can use it.
|
| 242 |
+
*/
|
| 243 |
+
export function handleServerControlRequest(
|
| 244 |
+
request: SDKControlRequest,
|
| 245 |
+
handlers: ServerControlRequestHandlers,
|
| 246 |
+
): void {
|
| 247 |
+
const {
|
| 248 |
+
transport,
|
| 249 |
+
sessionId,
|
| 250 |
+
outboundOnly,
|
| 251 |
+
onInterrupt,
|
| 252 |
+
onSetModel,
|
| 253 |
+
onSetMaxThinkingTokens,
|
| 254 |
+
onSetPermissionMode,
|
| 255 |
+
} = handlers
|
| 256 |
+
if (!transport) {
|
| 257 |
+
logForDebugging(
|
| 258 |
+
'[bridge:repl] Cannot respond to control_request: transport not configured',
|
| 259 |
+
)
|
| 260 |
+
return
|
| 261 |
+
}
|
| 262 |
+
|
| 263 |
+
let response: SDKControlResponse
|
| 264 |
+
|
| 265 |
+
// Outbound-only: reply error for mutable requests so claude.ai doesn't show
|
| 266 |
+
// false success. initialize must still succeed (server kills the connection
|
| 267 |
+
// if it doesn't — see comment above).
|
| 268 |
+
if (outboundOnly && request.request.subtype !== 'initialize') {
|
| 269 |
+
response = {
|
| 270 |
+
type: 'control_response',
|
| 271 |
+
response: {
|
| 272 |
+
subtype: 'error',
|
| 273 |
+
request_id: request.request_id,
|
| 274 |
+
error: OUTBOUND_ONLY_ERROR,
|
| 275 |
+
},
|
| 276 |
+
}
|
| 277 |
+
const event = { ...response, session_id: sessionId }
|
| 278 |
+
void transport.write(event)
|
| 279 |
+
logForDebugging(
|
| 280 |
+
`[bridge:repl] Rejected ${request.request.subtype} (outbound-only) request_id=${request.request_id}`,
|
| 281 |
+
)
|
| 282 |
+
return
|
| 283 |
+
}
|
| 284 |
+
|
| 285 |
+
switch (request.request.subtype) {
|
| 286 |
+
case 'initialize':
|
| 287 |
+
// Respond with minimal capabilities — the REPL handles
|
| 288 |
+
// commands, models, and account info itself.
|
| 289 |
+
response = {
|
| 290 |
+
type: 'control_response',
|
| 291 |
+
response: {
|
| 292 |
+
subtype: 'success',
|
| 293 |
+
request_id: request.request_id,
|
| 294 |
+
response: {
|
| 295 |
+
commands: [],
|
| 296 |
+
output_style: 'normal',
|
| 297 |
+
available_output_styles: ['normal'],
|
| 298 |
+
models: [],
|
| 299 |
+
account: {},
|
| 300 |
+
pid: process.pid,
|
| 301 |
+
},
|
| 302 |
+
},
|
| 303 |
+
}
|
| 304 |
+
break
|
| 305 |
+
|
| 306 |
+
case 'set_model':
|
| 307 |
+
onSetModel?.(request.request.model)
|
| 308 |
+
response = {
|
| 309 |
+
type: 'control_response',
|
| 310 |
+
response: {
|
| 311 |
+
subtype: 'success',
|
| 312 |
+
request_id: request.request_id,
|
| 313 |
+
},
|
| 314 |
+
}
|
| 315 |
+
break
|
| 316 |
+
|
| 317 |
+
case 'set_max_thinking_tokens':
|
| 318 |
+
onSetMaxThinkingTokens?.(request.request.max_thinking_tokens)
|
| 319 |
+
response = {
|
| 320 |
+
type: 'control_response',
|
| 321 |
+
response: {
|
| 322 |
+
subtype: 'success',
|
| 323 |
+
request_id: request.request_id,
|
| 324 |
+
},
|
| 325 |
+
}
|
| 326 |
+
break
|
| 327 |
+
|
| 328 |
+
case 'set_permission_mode': {
|
| 329 |
+
// The callback returns a policy verdict so we can send an error
|
| 330 |
+
// control_response without importing isAutoModeGateEnabled /
|
| 331 |
+
// isBypassPermissionsModeDisabled here (bootstrap-isolation). If no
|
| 332 |
+
// callback is registered (daemon context, which doesn't wire this —
|
| 333 |
+
// see daemonBridge.ts), return an error verdict rather than a silent
|
| 334 |
+
// false-success: the mode is never actually applied in that context,
|
| 335 |
+
// so success would lie to the client.
|
| 336 |
+
const verdict = onSetPermissionMode?.(request.request.mode) ?? {
|
| 337 |
+
ok: false,
|
| 338 |
+
error:
|
| 339 |
+
'set_permission_mode is not supported in this context (onSetPermissionMode callback not registered)',
|
| 340 |
+
}
|
| 341 |
+
if (verdict.ok) {
|
| 342 |
+
response = {
|
| 343 |
+
type: 'control_response',
|
| 344 |
+
response: {
|
| 345 |
+
subtype: 'success',
|
| 346 |
+
request_id: request.request_id,
|
| 347 |
+
},
|
| 348 |
+
}
|
| 349 |
+
} else {
|
| 350 |
+
response = {
|
| 351 |
+
type: 'control_response',
|
| 352 |
+
response: {
|
| 353 |
+
subtype: 'error',
|
| 354 |
+
request_id: request.request_id,
|
| 355 |
+
error: verdict.error,
|
| 356 |
+
},
|
| 357 |
+
}
|
| 358 |
+
}
|
| 359 |
+
break
|
| 360 |
+
}
|
| 361 |
+
|
| 362 |
+
case 'interrupt':
|
| 363 |
+
onInterrupt?.()
|
| 364 |
+
response = {
|
| 365 |
+
type: 'control_response',
|
| 366 |
+
response: {
|
| 367 |
+
subtype: 'success',
|
| 368 |
+
request_id: request.request_id,
|
| 369 |
+
},
|
| 370 |
+
}
|
| 371 |
+
break
|
| 372 |
+
|
| 373 |
+
default:
|
| 374 |
+
// Unknown subtype — respond with error so the server doesn't
|
| 375 |
+
// hang waiting for a reply that never comes.
|
| 376 |
+
response = {
|
| 377 |
+
type: 'control_response',
|
| 378 |
+
response: {
|
| 379 |
+
subtype: 'error',
|
| 380 |
+
request_id: request.request_id,
|
| 381 |
+
error: `REPL bridge does not handle control_request subtype: ${request.request.subtype}`,
|
| 382 |
+
},
|
| 383 |
+
}
|
| 384 |
+
}
|
| 385 |
+
|
| 386 |
+
const event = { ...response, session_id: sessionId }
|
| 387 |
+
void transport.write(event)
|
| 388 |
+
logForDebugging(
|
| 389 |
+
`[bridge:repl] Sent control_response for ${request.request.subtype} request_id=${request.request_id} result=${response.response.subtype}`,
|
| 390 |
+
)
|
| 391 |
+
}
|
| 392 |
+
|
| 393 |
+
// ─── Result message (for session archival on teardown) ───────────────────────
|
| 394 |
+
|
| 395 |
+
/**
|
| 396 |
+
* Build a minimal `SDKResultSuccess` message for session archival.
|
| 397 |
+
* The server needs this event before a WS close to trigger archival.
|
| 398 |
+
*/
|
| 399 |
+
export function makeResultMessage(sessionId: string): SDKResultSuccess {
|
| 400 |
+
return {
|
| 401 |
+
type: 'result',
|
| 402 |
+
subtype: 'success',
|
| 403 |
+
duration_ms: 0,
|
| 404 |
+
duration_api_ms: 0,
|
| 405 |
+
is_error: false,
|
| 406 |
+
num_turns: 0,
|
| 407 |
+
result: '',
|
| 408 |
+
stop_reason: null,
|
| 409 |
+
total_cost_usd: 0,
|
| 410 |
+
usage: { ...EMPTY_USAGE },
|
| 411 |
+
modelUsage: {},
|
| 412 |
+
permission_denials: [],
|
| 413 |
+
session_id: sessionId,
|
| 414 |
+
uuid: randomUUID(),
|
| 415 |
+
}
|
| 416 |
+
}
|
| 417 |
+
|
| 418 |
+
// ─── BoundedUUIDSet (echo-dedup ring buffer) ─────────────────────────────────
|
| 419 |
+
|
| 420 |
+
/**
|
| 421 |
+
* FIFO-bounded set backed by a circular buffer. Evicts the oldest entry
|
| 422 |
+
* when capacity is reached, keeping memory usage constant at O(capacity).
|
| 423 |
+
*
|
| 424 |
+
* Messages are added in chronological order, so evicted entries are always
|
| 425 |
+
* the oldest. The caller relies on external ordering (the hook's
|
| 426 |
+
* lastWrittenIndexRef) as the primary dedup — this set is a secondary
|
| 427 |
+
* safety net for echo filtering and race-condition dedup.
|
| 428 |
+
*/
|
| 429 |
+
export class BoundedUUIDSet {
|
| 430 |
+
private readonly capacity: number
|
| 431 |
+
private readonly ring: (string | undefined)[]
|
| 432 |
+
private readonly set = new Set<string>()
|
| 433 |
+
private writeIdx = 0
|
| 434 |
+
|
| 435 |
+
constructor(capacity: number) {
|
| 436 |
+
this.capacity = capacity
|
| 437 |
+
this.ring = new Array<string | undefined>(capacity)
|
| 438 |
+
}
|
| 439 |
+
|
| 440 |
+
add(uuid: string): void {
|
| 441 |
+
if (this.set.has(uuid)) return
|
| 442 |
+
// Evict the entry at the current write position (if occupied)
|
| 443 |
+
const evicted = this.ring[this.writeIdx]
|
| 444 |
+
if (evicted !== undefined) {
|
| 445 |
+
this.set.delete(evicted)
|
| 446 |
+
}
|
| 447 |
+
this.ring[this.writeIdx] = uuid
|
| 448 |
+
this.set.add(uuid)
|
| 449 |
+
this.writeIdx = (this.writeIdx + 1) % this.capacity
|
| 450 |
+
}
|
| 451 |
+
|
| 452 |
+
has(uuid: string): boolean {
|
| 453 |
+
return this.set.has(uuid)
|
| 454 |
+
}
|
| 455 |
+
|
| 456 |
+
clear(): void {
|
| 457 |
+
this.set.clear()
|
| 458 |
+
this.ring.fill(undefined)
|
| 459 |
+
this.writeIdx = 0
|
| 460 |
+
}
|
| 461 |
+
}
|
src/bridge/bridgePermissionCallbacks.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { PermissionUpdate } from '../utils/permissions/PermissionUpdateSchema.js'
|
| 2 |
+
|
| 3 |
+
type BridgePermissionResponse = {
|
| 4 |
+
behavior: 'allow' | 'deny'
|
| 5 |
+
updatedInput?: Record<string, unknown>
|
| 6 |
+
updatedPermissions?: PermissionUpdate[]
|
| 7 |
+
message?: string
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
type BridgePermissionCallbacks = {
|
| 11 |
+
sendRequest(
|
| 12 |
+
requestId: string,
|
| 13 |
+
toolName: string,
|
| 14 |
+
input: Record<string, unknown>,
|
| 15 |
+
toolUseId: string,
|
| 16 |
+
description: string,
|
| 17 |
+
permissionSuggestions?: PermissionUpdate[],
|
| 18 |
+
blockedPath?: string,
|
| 19 |
+
): void
|
| 20 |
+
sendResponse(requestId: string, response: BridgePermissionResponse): void
|
| 21 |
+
/** Cancel a pending control_request so the web app can dismiss its prompt. */
|
| 22 |
+
cancelRequest(requestId: string): void
|
| 23 |
+
onResponse(
|
| 24 |
+
requestId: string,
|
| 25 |
+
handler: (response: BridgePermissionResponse) => void,
|
| 26 |
+
): () => void // returns unsubscribe
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
/** Type predicate for validating a parsed control_response payload
|
| 30 |
+
* as a BridgePermissionResponse. Checks the required `behavior`
|
| 31 |
+
* discriminant rather than using an unsafe `as` cast. */
|
| 32 |
+
function isBridgePermissionResponse(
|
| 33 |
+
value: unknown,
|
| 34 |
+
): value is BridgePermissionResponse {
|
| 35 |
+
if (!value || typeof value !== 'object') return false
|
| 36 |
+
return (
|
| 37 |
+
'behavior' in value &&
|
| 38 |
+
(value.behavior === 'allow' || value.behavior === 'deny')
|
| 39 |
+
)
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
export { isBridgePermissionResponse }
|
| 43 |
+
export type { BridgePermissionCallbacks, BridgePermissionResponse }
|
src/bridge/bridgePointer.ts
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { mkdir, readFile, stat, unlink, writeFile } from 'fs/promises'
|
| 2 |
+
import { dirname, join } from 'path'
|
| 3 |
+
import { z } from 'zod/v4'
|
| 4 |
+
import { logForDebugging } from '../utils/debug.js'
|
| 5 |
+
import { isENOENT } from '../utils/errors.js'
|
| 6 |
+
import { getWorktreePathsPortable } from '../utils/getWorktreePathsPortable.js'
|
| 7 |
+
import { lazySchema } from '../utils/lazySchema.js'
|
| 8 |
+
import {
|
| 9 |
+
getProjectsDir,
|
| 10 |
+
sanitizePath,
|
| 11 |
+
} from '../utils/sessionStoragePortable.js'
|
| 12 |
+
import { jsonParse, jsonStringify } from '../utils/slowOperations.js'
|
| 13 |
+
|
| 14 |
+
/**
|
| 15 |
+
* Upper bound on worktree fanout. git worktree list is naturally bounded
|
| 16 |
+
* (50 is a LOT), but this caps the parallel stat() burst and guards against
|
| 17 |
+
* pathological setups. Above this, --continue falls back to current-dir-only.
|
| 18 |
+
*/
|
| 19 |
+
const MAX_WORKTREE_FANOUT = 50
|
| 20 |
+
|
| 21 |
+
/**
|
| 22 |
+
* Crash-recovery pointer for Remote Control sessions.
|
| 23 |
+
*
|
| 24 |
+
* Written immediately after a bridge session is created, periodically
|
| 25 |
+
* refreshed during the session, and cleared on clean shutdown. If the
|
| 26 |
+
* process dies unclean (crash, kill -9, terminal closed), the pointer
|
| 27 |
+
* persists. On next startup, `claude remote-control` detects it and offers
|
| 28 |
+
* to resume via the --session-id flow from #20460.
|
| 29 |
+
*
|
| 30 |
+
* Staleness is checked against the file's mtime (not an embedded timestamp)
|
| 31 |
+
* so that a periodic re-write with the same content serves as a refresh —
|
| 32 |
+
* matches the backend's rolling BRIDGE_LAST_POLL_TTL (4h) semantics. A
|
| 33 |
+
* bridge that's been polling for 5+ hours and then crashes still has a
|
| 34 |
+
* fresh pointer as long as the refresh ran within the window.
|
| 35 |
+
*
|
| 36 |
+
* Scoped per working directory (alongside transcript JSONL files) so two
|
| 37 |
+
* concurrent bridges in different repos don't clobber each other.
|
| 38 |
+
*/
|
| 39 |
+
|
| 40 |
+
export const BRIDGE_POINTER_TTL_MS = 4 * 60 * 60 * 1000
|
| 41 |
+
|
| 42 |
+
const BridgePointerSchema = lazySchema(() =>
|
| 43 |
+
z.object({
|
| 44 |
+
sessionId: z.string(),
|
| 45 |
+
environmentId: z.string(),
|
| 46 |
+
source: z.enum(['standalone', 'repl']),
|
| 47 |
+
}),
|
| 48 |
+
)
|
| 49 |
+
|
| 50 |
+
export type BridgePointer = z.infer<ReturnType<typeof BridgePointerSchema>>
|
| 51 |
+
|
| 52 |
+
export function getBridgePointerPath(dir: string): string {
|
| 53 |
+
return join(getProjectsDir(), sanitizePath(dir), 'bridge-pointer.json')
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
/**
|
| 57 |
+
* Write the pointer. Also used to refresh mtime during long sessions —
|
| 58 |
+
* calling with the same IDs is a cheap no-content-change write that bumps
|
| 59 |
+
* the staleness clock. Best-effort — a crash-recovery file must never
|
| 60 |
+
* itself cause a crash. Logs and swallows on error.
|
| 61 |
+
*/
|
| 62 |
+
export async function writeBridgePointer(
|
| 63 |
+
dir: string,
|
| 64 |
+
pointer: BridgePointer,
|
| 65 |
+
): Promise<void> {
|
| 66 |
+
const path = getBridgePointerPath(dir)
|
| 67 |
+
try {
|
| 68 |
+
await mkdir(dirname(path), { recursive: true })
|
| 69 |
+
await writeFile(path, jsonStringify(pointer), 'utf8')
|
| 70 |
+
logForDebugging(`[bridge:pointer] wrote ${path}`)
|
| 71 |
+
} catch (err: unknown) {
|
| 72 |
+
logForDebugging(`[bridge:pointer] write failed: ${err}`, { level: 'warn' })
|
| 73 |
+
}
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
/**
|
| 77 |
+
* Read the pointer and its age (ms since last write). Operates directly
|
| 78 |
+
* and handles errors — no existence check (CLAUDE.md TOCTOU rule). Returns
|
| 79 |
+
* null on any failure: missing file, corrupted JSON, schema mismatch, or
|
| 80 |
+
* stale (mtime > 4h ago). Stale/invalid pointers are deleted so they don't
|
| 81 |
+
* keep re-prompting after the backend has already GC'd the env.
|
| 82 |
+
*/
|
| 83 |
+
export async function readBridgePointer(
|
| 84 |
+
dir: string,
|
| 85 |
+
): Promise<(BridgePointer & { ageMs: number }) | null> {
|
| 86 |
+
const path = getBridgePointerPath(dir)
|
| 87 |
+
let raw: string
|
| 88 |
+
let mtimeMs: number
|
| 89 |
+
try {
|
| 90 |
+
// stat for mtime (staleness anchor), then read. Two syscalls, but both
|
| 91 |
+
// are needed — mtime IS the data we return, not a TOCTOU guard.
|
| 92 |
+
mtimeMs = (await stat(path)).mtimeMs
|
| 93 |
+
raw = await readFile(path, 'utf8')
|
| 94 |
+
} catch {
|
| 95 |
+
return null
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
const parsed = BridgePointerSchema().safeParse(safeJsonParse(raw))
|
| 99 |
+
if (!parsed.success) {
|
| 100 |
+
logForDebugging(`[bridge:pointer] invalid schema, clearing: ${path}`)
|
| 101 |
+
await clearBridgePointer(dir)
|
| 102 |
+
return null
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
const ageMs = Math.max(0, Date.now() - mtimeMs)
|
| 106 |
+
if (ageMs > BRIDGE_POINTER_TTL_MS) {
|
| 107 |
+
logForDebugging(`[bridge:pointer] stale (>4h mtime), clearing: ${path}`)
|
| 108 |
+
await clearBridgePointer(dir)
|
| 109 |
+
return null
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
return { ...parsed.data, ageMs }
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
/**
|
| 116 |
+
* Worktree-aware read for `--continue`. The REPL bridge writes its pointer
|
| 117 |
+
* to `getOriginalCwd()` which EnterWorktreeTool/activeWorktreeSession can
|
| 118 |
+
* mutate to a worktree path — but `claude remote-control --continue` runs
|
| 119 |
+
* with `resolve('.')` = shell CWD. This fans out across git worktree
|
| 120 |
+
* siblings to find the freshest pointer, matching /resume's semantics.
|
| 121 |
+
*
|
| 122 |
+
* Fast path: checks `dir` first. Only shells out to `git worktree list` if
|
| 123 |
+
* that misses — the common case (pointer in launch dir) is one stat, zero
|
| 124 |
+
* exec. Fanout reads run in parallel; capped at MAX_WORKTREE_FANOUT.
|
| 125 |
+
*
|
| 126 |
+
* Returns the pointer AND the dir it was found in, so the caller can clear
|
| 127 |
+
* the right file on resume failure.
|
| 128 |
+
*/
|
| 129 |
+
export async function readBridgePointerAcrossWorktrees(
|
| 130 |
+
dir: string,
|
| 131 |
+
): Promise<{ pointer: BridgePointer & { ageMs: number }; dir: string } | null> {
|
| 132 |
+
// Fast path: current dir. Covers standalone bridge (always matches) and
|
| 133 |
+
// REPL bridge when no worktree mutation happened.
|
| 134 |
+
const here = await readBridgePointer(dir)
|
| 135 |
+
if (here) {
|
| 136 |
+
return { pointer: here, dir }
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
// Fanout: scan worktree siblings. getWorktreePathsPortable has a 5s
|
| 140 |
+
// timeout and returns [] on any error (not a git repo, git not installed).
|
| 141 |
+
const worktrees = await getWorktreePathsPortable(dir)
|
| 142 |
+
if (worktrees.length <= 1) return null
|
| 143 |
+
if (worktrees.length > MAX_WORKTREE_FANOUT) {
|
| 144 |
+
logForDebugging(
|
| 145 |
+
`[bridge:pointer] ${worktrees.length} worktrees exceeds fanout cap ${MAX_WORKTREE_FANOUT}, skipping`,
|
| 146 |
+
)
|
| 147 |
+
return null
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
// Dedupe against `dir` so we don't re-stat it. sanitizePath normalizes
|
| 151 |
+
// case/separators so worktree-list output matches our fast-path key even
|
| 152 |
+
// on Windows where git may emit C:/ vs stored c:/.
|
| 153 |
+
const dirKey = sanitizePath(dir)
|
| 154 |
+
const candidates = worktrees.filter(wt => sanitizePath(wt) !== dirKey)
|
| 155 |
+
|
| 156 |
+
// Parallel stat+read. Each readBridgePointer is a stat() that ENOENTs
|
| 157 |
+
// for worktrees with no pointer (cheap) plus a ~100-byte read for the
|
| 158 |
+
// rare ones that have one. Promise.all → latency ≈ slowest single stat.
|
| 159 |
+
const results = await Promise.all(
|
| 160 |
+
candidates.map(async wt => {
|
| 161 |
+
const p = await readBridgePointer(wt)
|
| 162 |
+
return p ? { pointer: p, dir: wt } : null
|
| 163 |
+
}),
|
| 164 |
+
)
|
| 165 |
+
|
| 166 |
+
// Pick freshest (lowest ageMs). The pointer stores environmentId so
|
| 167 |
+
// resume reconnects to the right env regardless of which worktree
|
| 168 |
+
// --continue was invoked from.
|
| 169 |
+
let freshest: {
|
| 170 |
+
pointer: BridgePointer & { ageMs: number }
|
| 171 |
+
dir: string
|
| 172 |
+
} | null = null
|
| 173 |
+
for (const r of results) {
|
| 174 |
+
if (r && (!freshest || r.pointer.ageMs < freshest.pointer.ageMs)) {
|
| 175 |
+
freshest = r
|
| 176 |
+
}
|
| 177 |
+
}
|
| 178 |
+
if (freshest) {
|
| 179 |
+
logForDebugging(
|
| 180 |
+
`[bridge:pointer] fanout found pointer in worktree ${freshest.dir} (ageMs=${freshest.pointer.ageMs})`,
|
| 181 |
+
)
|
| 182 |
+
}
|
| 183 |
+
return freshest
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
/**
|
| 187 |
+
* Delete the pointer. Idempotent — ENOENT is expected when the process
|
| 188 |
+
* shut down clean previously.
|
| 189 |
+
*/
|
| 190 |
+
export async function clearBridgePointer(dir: string): Promise<void> {
|
| 191 |
+
const path = getBridgePointerPath(dir)
|
| 192 |
+
try {
|
| 193 |
+
await unlink(path)
|
| 194 |
+
logForDebugging(`[bridge:pointer] cleared ${path}`)
|
| 195 |
+
} catch (err: unknown) {
|
| 196 |
+
if (!isENOENT(err)) {
|
| 197 |
+
logForDebugging(`[bridge:pointer] clear failed: ${err}`, {
|
| 198 |
+
level: 'warn',
|
| 199 |
+
})
|
| 200 |
+
}
|
| 201 |
+
}
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
function safeJsonParse(raw: string): unknown {
|
| 205 |
+
try {
|
| 206 |
+
return jsonParse(raw)
|
| 207 |
+
} catch {
|
| 208 |
+
return null
|
| 209 |
+
}
|
| 210 |
+
}
|
src/bridge/bridgeStatusUtil.ts
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import {
|
| 2 |
+
getClaudeAiBaseUrl,
|
| 3 |
+
getRemoteSessionUrl,
|
| 4 |
+
} from '../constants/product.js'
|
| 5 |
+
import { stringWidth } from '../ink/stringWidth.js'
|
| 6 |
+
import { formatDuration, truncateToWidth } from '../utils/format.js'
|
| 7 |
+
import { getGraphemeSegmenter } from '../utils/intl.js'
|
| 8 |
+
|
| 9 |
+
/** Bridge status state machine states. */
|
| 10 |
+
export type StatusState =
|
| 11 |
+
| 'idle'
|
| 12 |
+
| 'attached'
|
| 13 |
+
| 'titled'
|
| 14 |
+
| 'reconnecting'
|
| 15 |
+
| 'failed'
|
| 16 |
+
|
| 17 |
+
/** How long a tool activity line stays visible after last tool_start (ms). */
|
| 18 |
+
export const TOOL_DISPLAY_EXPIRY_MS = 30_000
|
| 19 |
+
|
| 20 |
+
/** Interval for the shimmer animation tick (ms). */
|
| 21 |
+
export const SHIMMER_INTERVAL_MS = 150
|
| 22 |
+
|
| 23 |
+
export function timestamp(): string {
|
| 24 |
+
const now = new Date()
|
| 25 |
+
const h = String(now.getHours()).padStart(2, '0')
|
| 26 |
+
const m = String(now.getMinutes()).padStart(2, '0')
|
| 27 |
+
const s = String(now.getSeconds()).padStart(2, '0')
|
| 28 |
+
return `${h}:${m}:${s}`
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
export { formatDuration, truncateToWidth as truncatePrompt }
|
| 32 |
+
|
| 33 |
+
/** Abbreviate a tool activity summary for the trail display. */
|
| 34 |
+
export function abbreviateActivity(summary: string): string {
|
| 35 |
+
return truncateToWidth(summary, 30)
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
/** Build the connect URL shown when the bridge is idle. */
|
| 39 |
+
export function buildBridgeConnectUrl(
|
| 40 |
+
environmentId: string,
|
| 41 |
+
ingressUrl?: string,
|
| 42 |
+
): string {
|
| 43 |
+
const baseUrl = getClaudeAiBaseUrl(undefined, ingressUrl)
|
| 44 |
+
return `${baseUrl}/code?bridge=${environmentId}`
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
/**
|
| 48 |
+
* Build the session URL shown when a session is attached. Delegates to
|
| 49 |
+
* getRemoteSessionUrl for the cse_→session_ prefix translation, then appends
|
| 50 |
+
* the v1-specific ?bridge={environmentId} query.
|
| 51 |
+
*/
|
| 52 |
+
export function buildBridgeSessionUrl(
|
| 53 |
+
sessionId: string,
|
| 54 |
+
environmentId: string,
|
| 55 |
+
ingressUrl?: string,
|
| 56 |
+
): string {
|
| 57 |
+
return `${getRemoteSessionUrl(sessionId, ingressUrl)}?bridge=${environmentId}`
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
/** Compute the glimmer index for a reverse-sweep shimmer animation. */
|
| 61 |
+
export function computeGlimmerIndex(
|
| 62 |
+
tick: number,
|
| 63 |
+
messageWidth: number,
|
| 64 |
+
): number {
|
| 65 |
+
const cycleLength = messageWidth + 20
|
| 66 |
+
return messageWidth + 10 - (tick % cycleLength)
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
/**
|
| 70 |
+
* Split text into three segments by visual column position for shimmer rendering.
|
| 71 |
+
*
|
| 72 |
+
* Uses grapheme segmentation and `stringWidth` so the split is correct for
|
| 73 |
+
* multi-byte characters, emoji, and CJK glyphs.
|
| 74 |
+
*
|
| 75 |
+
* Returns `{ before, shimmer, after }` strings. Both renderers (chalk in
|
| 76 |
+
* bridgeUI.ts and React/Ink in bridge.tsx) apply their own coloring to
|
| 77 |
+
* these segments.
|
| 78 |
+
*/
|
| 79 |
+
export function computeShimmerSegments(
|
| 80 |
+
text: string,
|
| 81 |
+
glimmerIndex: number,
|
| 82 |
+
): { before: string; shimmer: string; after: string } {
|
| 83 |
+
const messageWidth = stringWidth(text)
|
| 84 |
+
const shimmerStart = glimmerIndex - 1
|
| 85 |
+
const shimmerEnd = glimmerIndex + 1
|
| 86 |
+
|
| 87 |
+
// When shimmer is offscreen, return all text as "before"
|
| 88 |
+
if (shimmerStart >= messageWidth || shimmerEnd < 0) {
|
| 89 |
+
return { before: text, shimmer: '', after: '' }
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
// Split into at most 3 segments by visual column position
|
| 93 |
+
const clampedStart = Math.max(0, shimmerStart)
|
| 94 |
+
let colPos = 0
|
| 95 |
+
let before = ''
|
| 96 |
+
let shimmer = ''
|
| 97 |
+
let after = ''
|
| 98 |
+
for (const { segment } of getGraphemeSegmenter().segment(text)) {
|
| 99 |
+
const segWidth = stringWidth(segment)
|
| 100 |
+
if (colPos + segWidth <= clampedStart) {
|
| 101 |
+
before += segment
|
| 102 |
+
} else if (colPos > shimmerEnd) {
|
| 103 |
+
after += segment
|
| 104 |
+
} else {
|
| 105 |
+
shimmer += segment
|
| 106 |
+
}
|
| 107 |
+
colPos += segWidth
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
return { before, shimmer, after }
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
/** Computed bridge status label and color from connection state. */
|
| 114 |
+
export type BridgeStatusInfo = {
|
| 115 |
+
label:
|
| 116 |
+
| 'Remote Control failed'
|
| 117 |
+
| 'Remote Control reconnecting'
|
| 118 |
+
| 'Remote Control active'
|
| 119 |
+
| 'Remote Control connecting\u2026'
|
| 120 |
+
color: 'error' | 'warning' | 'success'
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
/** Derive a status label and color from the bridge connection state. */
|
| 124 |
+
export function getBridgeStatus({
|
| 125 |
+
error,
|
| 126 |
+
connected,
|
| 127 |
+
sessionActive,
|
| 128 |
+
reconnecting,
|
| 129 |
+
}: {
|
| 130 |
+
error: string | undefined
|
| 131 |
+
connected: boolean
|
| 132 |
+
sessionActive: boolean
|
| 133 |
+
reconnecting: boolean
|
| 134 |
+
}): BridgeStatusInfo {
|
| 135 |
+
if (error) return { label: 'Remote Control failed', color: 'error' }
|
| 136 |
+
if (reconnecting)
|
| 137 |
+
return { label: 'Remote Control reconnecting', color: 'warning' }
|
| 138 |
+
if (sessionActive || connected)
|
| 139 |
+
return { label: 'Remote Control active', color: 'success' }
|
| 140 |
+
return { label: 'Remote Control connecting\u2026', color: 'warning' }
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
/** Footer text shown when bridge is idle (Ready state). */
|
| 144 |
+
export function buildIdleFooterText(url: string): string {
|
| 145 |
+
return `Code everywhere with the Claude app or ${url}`
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
/** Footer text shown when a session is active (Connected state). */
|
| 149 |
+
export function buildActiveFooterText(url: string): string {
|
| 150 |
+
return `Continue coding in the Claude app or ${url}`
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
/** Footer text shown when the bridge has failed. */
|
| 154 |
+
export const FAILED_FOOTER_TEXT = 'Something went wrong, please try again'
|
| 155 |
+
|
| 156 |
+
/**
|
| 157 |
+
* Wrap text in an OSC 8 terminal hyperlink. Zero visual width for layout purposes.
|
| 158 |
+
* strip-ansi (used by stringWidth) correctly strips these sequences, so
|
| 159 |
+
* countVisualLines in bridgeUI.ts remains accurate.
|
| 160 |
+
*/
|
| 161 |
+
export function wrapWithOsc8Link(text: string, url: string): string {
|
| 162 |
+
return `\x1b]8;;${url}\x07${text}\x1b]8;;\x07`
|
| 163 |
+
}
|
src/bridge/bridgeUI.ts
ADDED
|
@@ -0,0 +1,530 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import chalk from 'chalk'
|
| 2 |
+
import { toString as qrToString } from 'qrcode'
|
| 3 |
+
import {
|
| 4 |
+
BRIDGE_FAILED_INDICATOR,
|
| 5 |
+
BRIDGE_READY_INDICATOR,
|
| 6 |
+
BRIDGE_SPINNER_FRAMES,
|
| 7 |
+
} from '../constants/figures.js'
|
| 8 |
+
import { stringWidth } from '../ink/stringWidth.js'
|
| 9 |
+
import { logForDebugging } from '../utils/debug.js'
|
| 10 |
+
import {
|
| 11 |
+
buildActiveFooterText,
|
| 12 |
+
buildBridgeConnectUrl,
|
| 13 |
+
buildBridgeSessionUrl,
|
| 14 |
+
buildIdleFooterText,
|
| 15 |
+
FAILED_FOOTER_TEXT,
|
| 16 |
+
formatDuration,
|
| 17 |
+
type StatusState,
|
| 18 |
+
TOOL_DISPLAY_EXPIRY_MS,
|
| 19 |
+
timestamp,
|
| 20 |
+
truncatePrompt,
|
| 21 |
+
wrapWithOsc8Link,
|
| 22 |
+
} from './bridgeStatusUtil.js'
|
| 23 |
+
import type {
|
| 24 |
+
BridgeConfig,
|
| 25 |
+
BridgeLogger,
|
| 26 |
+
SessionActivity,
|
| 27 |
+
SpawnMode,
|
| 28 |
+
} from './types.js'
|
| 29 |
+
|
| 30 |
+
const QR_OPTIONS = {
|
| 31 |
+
type: 'utf8' as const,
|
| 32 |
+
errorCorrectionLevel: 'L' as const,
|
| 33 |
+
small: true,
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
/** Generate a QR code and return its lines. */
|
| 37 |
+
async function generateQr(url: string): Promise<string[]> {
|
| 38 |
+
const qr = await qrToString(url, QR_OPTIONS)
|
| 39 |
+
return qr.split('\n').filter((line: string) => line.length > 0)
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
export function createBridgeLogger(options: {
|
| 43 |
+
verbose: boolean
|
| 44 |
+
write?: (s: string) => void
|
| 45 |
+
}): BridgeLogger {
|
| 46 |
+
const write = options.write ?? ((s: string) => process.stdout.write(s))
|
| 47 |
+
const verbose = options.verbose
|
| 48 |
+
|
| 49 |
+
// Track how many status lines are currently displayed at the bottom
|
| 50 |
+
let statusLineCount = 0
|
| 51 |
+
|
| 52 |
+
// Status state machine
|
| 53 |
+
let currentState: StatusState = 'idle'
|
| 54 |
+
let currentStateText = 'Ready'
|
| 55 |
+
let repoName = ''
|
| 56 |
+
let branch = ''
|
| 57 |
+
let debugLogPath = ''
|
| 58 |
+
|
| 59 |
+
// Connect URL (built in printBanner with correct base for staging/prod)
|
| 60 |
+
let connectUrl = ''
|
| 61 |
+
let cachedIngressUrl = ''
|
| 62 |
+
let cachedEnvironmentId = ''
|
| 63 |
+
let activeSessionUrl: string | null = null
|
| 64 |
+
|
| 65 |
+
// QR code lines for the current URL
|
| 66 |
+
let qrLines: string[] = []
|
| 67 |
+
let qrVisible = false
|
| 68 |
+
|
| 69 |
+
// Tool activity for the second status line
|
| 70 |
+
let lastToolSummary: string | null = null
|
| 71 |
+
let lastToolTime = 0
|
| 72 |
+
|
| 73 |
+
// Session count indicator (shown when multi-session mode is enabled)
|
| 74 |
+
let sessionActive = 0
|
| 75 |
+
let sessionMax = 1
|
| 76 |
+
// Spawn mode shown in the session-count line + gates the `w` hint
|
| 77 |
+
let spawnModeDisplay: 'same-dir' | 'worktree' | null = null
|
| 78 |
+
let spawnMode: SpawnMode = 'single-session'
|
| 79 |
+
|
| 80 |
+
// Per-session display info for the multi-session bullet list (keyed by compat sessionId)
|
| 81 |
+
const sessionDisplayInfo = new Map<
|
| 82 |
+
string,
|
| 83 |
+
{ title?: string; url: string; activity?: SessionActivity }
|
| 84 |
+
>()
|
| 85 |
+
|
| 86 |
+
// Connecting spinner state
|
| 87 |
+
let connectingTimer: ReturnType<typeof setInterval> | null = null
|
| 88 |
+
let connectingTick = 0
|
| 89 |
+
|
| 90 |
+
/**
|
| 91 |
+
* Count how many visual terminal rows a string occupies, accounting for
|
| 92 |
+
* line wrapping. Each `\n` is one row, and content wider than the terminal
|
| 93 |
+
* wraps to additional rows.
|
| 94 |
+
*/
|
| 95 |
+
function countVisualLines(text: string): number {
|
| 96 |
+
// eslint-disable-next-line custom-rules/prefer-use-terminal-size
|
| 97 |
+
const cols = process.stdout.columns || 80 // non-React CLI context
|
| 98 |
+
let count = 0
|
| 99 |
+
// Split on newlines to get logical lines
|
| 100 |
+
for (const logical of text.split('\n')) {
|
| 101 |
+
if (logical.length === 0) {
|
| 102 |
+
// Empty segment between consecutive \n — counts as 1 row
|
| 103 |
+
count++
|
| 104 |
+
continue
|
| 105 |
+
}
|
| 106 |
+
const width = stringWidth(logical)
|
| 107 |
+
count += Math.max(1, Math.ceil(width / cols))
|
| 108 |
+
}
|
| 109 |
+
// The trailing \n in "line\n" produces an empty last element — don't count it
|
| 110 |
+
// because the cursor sits at the start of the next line, not a new visual row.
|
| 111 |
+
if (text.endsWith('\n')) {
|
| 112 |
+
count--
|
| 113 |
+
}
|
| 114 |
+
return count
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
/** Write a status line and track its visual line count. */
|
| 118 |
+
function writeStatus(text: string): void {
|
| 119 |
+
write(text)
|
| 120 |
+
statusLineCount += countVisualLines(text)
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
/** Clear any currently displayed status lines. */
|
| 124 |
+
function clearStatusLines(): void {
|
| 125 |
+
if (statusLineCount <= 0) return
|
| 126 |
+
logForDebugging(`[bridge:ui] clearStatusLines count=${statusLineCount}`)
|
| 127 |
+
// Move cursor up to the start of the status block, then erase everything below
|
| 128 |
+
write(`\x1b[${statusLineCount}A`) // cursor up N lines
|
| 129 |
+
write('\x1b[J') // erase from cursor to end of screen
|
| 130 |
+
statusLineCount = 0
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
/** Print a permanent log line, clearing status first and restoring after. */
|
| 134 |
+
function printLog(line: string): void {
|
| 135 |
+
clearStatusLines()
|
| 136 |
+
write(line)
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
/** Regenerate the QR code with the given URL. */
|
| 140 |
+
function regenerateQr(url: string): void {
|
| 141 |
+
generateQr(url)
|
| 142 |
+
.then(lines => {
|
| 143 |
+
qrLines = lines
|
| 144 |
+
renderStatusLine()
|
| 145 |
+
})
|
| 146 |
+
.catch(e => {
|
| 147 |
+
logForDebugging(`QR code generation failed: ${e}`, { level: 'error' })
|
| 148 |
+
})
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
/** Render the connecting spinner line (shown before first updateIdleStatus). */
|
| 152 |
+
function renderConnectingLine(): void {
|
| 153 |
+
clearStatusLines()
|
| 154 |
+
|
| 155 |
+
const frame =
|
| 156 |
+
BRIDGE_SPINNER_FRAMES[connectingTick % BRIDGE_SPINNER_FRAMES.length]!
|
| 157 |
+
let suffix = ''
|
| 158 |
+
if (repoName) {
|
| 159 |
+
suffix += chalk.dim(' \u00b7 ') + chalk.dim(repoName)
|
| 160 |
+
}
|
| 161 |
+
if (branch) {
|
| 162 |
+
suffix += chalk.dim(' \u00b7 ') + chalk.dim(branch)
|
| 163 |
+
}
|
| 164 |
+
writeStatus(
|
| 165 |
+
`${chalk.yellow(frame)} ${chalk.yellow('Connecting')}${suffix}\n`,
|
| 166 |
+
)
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
/** Start the connecting spinner. Stopped by first updateIdleStatus(). */
|
| 170 |
+
function startConnecting(): void {
|
| 171 |
+
stopConnecting()
|
| 172 |
+
renderConnectingLine()
|
| 173 |
+
connectingTimer = setInterval(() => {
|
| 174 |
+
connectingTick++
|
| 175 |
+
renderConnectingLine()
|
| 176 |
+
}, 150)
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
/** Stop the connecting spinner. */
|
| 180 |
+
function stopConnecting(): void {
|
| 181 |
+
if (connectingTimer) {
|
| 182 |
+
clearInterval(connectingTimer)
|
| 183 |
+
connectingTimer = null
|
| 184 |
+
}
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
/** Render and write the current status lines based on state. */
|
| 188 |
+
function renderStatusLine(): void {
|
| 189 |
+
if (currentState === 'reconnecting' || currentState === 'failed') {
|
| 190 |
+
// These states are handled separately (updateReconnectingStatus /
|
| 191 |
+
// updateFailedStatus). Return before clearing so callers like toggleQr
|
| 192 |
+
// and setSpawnModeDisplay don't blank the display during these states.
|
| 193 |
+
return
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
clearStatusLines()
|
| 197 |
+
|
| 198 |
+
const isIdle = currentState === 'idle'
|
| 199 |
+
|
| 200 |
+
// QR code above the status line
|
| 201 |
+
if (qrVisible) {
|
| 202 |
+
for (const line of qrLines) {
|
| 203 |
+
writeStatus(`${chalk.dim(line)}\n`)
|
| 204 |
+
}
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
// Determine indicator and colors based on state
|
| 208 |
+
const indicator = BRIDGE_READY_INDICATOR
|
| 209 |
+
const indicatorColor = isIdle ? chalk.green : chalk.cyan
|
| 210 |
+
const baseColor = isIdle ? chalk.green : chalk.cyan
|
| 211 |
+
const stateText = baseColor(currentStateText)
|
| 212 |
+
|
| 213 |
+
// Build the suffix with repo and branch
|
| 214 |
+
let suffix = ''
|
| 215 |
+
if (repoName) {
|
| 216 |
+
suffix += chalk.dim(' \u00b7 ') + chalk.dim(repoName)
|
| 217 |
+
}
|
| 218 |
+
// In worktree mode each session gets its own branch, so showing the
|
| 219 |
+
// bridge's branch would be misleading.
|
| 220 |
+
if (branch && spawnMode !== 'worktree') {
|
| 221 |
+
suffix += chalk.dim(' \u00b7 ') + chalk.dim(branch)
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
if (process.env.USER_TYPE === 'ant' && debugLogPath) {
|
| 225 |
+
writeStatus(
|
| 226 |
+
`${chalk.yellow('[ANT-ONLY] Logs:')} ${chalk.dim(debugLogPath)}\n`,
|
| 227 |
+
)
|
| 228 |
+
}
|
| 229 |
+
writeStatus(`${indicatorColor(indicator)} ${stateText}${suffix}\n`)
|
| 230 |
+
|
| 231 |
+
// Session count and per-session list (multi-session mode only)
|
| 232 |
+
if (sessionMax > 1) {
|
| 233 |
+
const modeHint =
|
| 234 |
+
spawnMode === 'worktree'
|
| 235 |
+
? 'New sessions will be created in an isolated worktree'
|
| 236 |
+
: 'New sessions will be created in the current directory'
|
| 237 |
+
writeStatus(
|
| 238 |
+
` ${chalk.dim(`Capacity: ${sessionActive}/${sessionMax} \u00b7 ${modeHint}`)}\n`,
|
| 239 |
+
)
|
| 240 |
+
for (const [, info] of sessionDisplayInfo) {
|
| 241 |
+
const titleText = info.title
|
| 242 |
+
? truncatePrompt(info.title, 35)
|
| 243 |
+
: chalk.dim('Attached')
|
| 244 |
+
const titleLinked = wrapWithOsc8Link(titleText, info.url)
|
| 245 |
+
const act = info.activity
|
| 246 |
+
const showAct = act && act.type !== 'result' && act.type !== 'error'
|
| 247 |
+
const actText = showAct
|
| 248 |
+
? chalk.dim(` ${truncatePrompt(act.summary, 40)}`)
|
| 249 |
+
: ''
|
| 250 |
+
writeStatus(` ${titleLinked}${actText}
|
| 251 |
+
`)
|
| 252 |
+
}
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
// Mode line for spawn modes with a single slot (or true single-session mode)
|
| 256 |
+
if (sessionMax === 1) {
|
| 257 |
+
const modeText =
|
| 258 |
+
spawnMode === 'single-session'
|
| 259 |
+
? 'Single session \u00b7 exits when complete'
|
| 260 |
+
: spawnMode === 'worktree'
|
| 261 |
+
? `Capacity: ${sessionActive}/1 \u00b7 New sessions will be created in an isolated worktree`
|
| 262 |
+
: `Capacity: ${sessionActive}/1 \u00b7 New sessions will be created in the current directory`
|
| 263 |
+
writeStatus(` ${chalk.dim(modeText)}\n`)
|
| 264 |
+
}
|
| 265 |
+
|
| 266 |
+
// Tool activity line for single-session mode
|
| 267 |
+
if (
|
| 268 |
+
sessionMax === 1 &&
|
| 269 |
+
!isIdle &&
|
| 270 |
+
lastToolSummary &&
|
| 271 |
+
Date.now() - lastToolTime < TOOL_DISPLAY_EXPIRY_MS
|
| 272 |
+
) {
|
| 273 |
+
writeStatus(` ${chalk.dim(truncatePrompt(lastToolSummary, 60))}\n`)
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
+
// Blank line separator before footer
|
| 277 |
+
const url = activeSessionUrl ?? connectUrl
|
| 278 |
+
if (url) {
|
| 279 |
+
writeStatus('\n')
|
| 280 |
+
const footerText = isIdle
|
| 281 |
+
? buildIdleFooterText(url)
|
| 282 |
+
: buildActiveFooterText(url)
|
| 283 |
+
const qrHint = qrVisible
|
| 284 |
+
? chalk.dim.italic('space to hide QR code')
|
| 285 |
+
: chalk.dim.italic('space to show QR code')
|
| 286 |
+
const toggleHint = spawnModeDisplay
|
| 287 |
+
? chalk.dim.italic(' \u00b7 w to toggle spawn mode')
|
| 288 |
+
: ''
|
| 289 |
+
writeStatus(`${chalk.dim(footerText)}\n`)
|
| 290 |
+
writeStatus(`${qrHint}${toggleHint}\n`)
|
| 291 |
+
}
|
| 292 |
+
}
|
| 293 |
+
|
| 294 |
+
return {
|
| 295 |
+
printBanner(config: BridgeConfig, environmentId: string): void {
|
| 296 |
+
cachedIngressUrl = config.sessionIngressUrl
|
| 297 |
+
cachedEnvironmentId = environmentId
|
| 298 |
+
connectUrl = buildBridgeConnectUrl(environmentId, cachedIngressUrl)
|
| 299 |
+
regenerateQr(connectUrl)
|
| 300 |
+
|
| 301 |
+
if (verbose) {
|
| 302 |
+
write(chalk.dim(`Remote Control`) + ` v${MACRO.VERSION}\n`)
|
| 303 |
+
}
|
| 304 |
+
if (verbose) {
|
| 305 |
+
if (config.spawnMode !== 'single-session') {
|
| 306 |
+
write(chalk.dim(`Spawn mode: `) + `${config.spawnMode}\n`)
|
| 307 |
+
write(
|
| 308 |
+
chalk.dim(`Max concurrent sessions: `) + `${config.maxSessions}\n`,
|
| 309 |
+
)
|
| 310 |
+
}
|
| 311 |
+
write(chalk.dim(`Environment ID: `) + `${environmentId}\n`)
|
| 312 |
+
}
|
| 313 |
+
if (config.sandbox) {
|
| 314 |
+
write(chalk.dim(`Sandbox: `) + `${chalk.green('Enabled')}\n`)
|
| 315 |
+
}
|
| 316 |
+
write('\n')
|
| 317 |
+
|
| 318 |
+
// Start connecting spinner — first updateIdleStatus() will stop it
|
| 319 |
+
startConnecting()
|
| 320 |
+
},
|
| 321 |
+
|
| 322 |
+
logSessionStart(sessionId: string, prompt: string): void {
|
| 323 |
+
if (verbose) {
|
| 324 |
+
const short = truncatePrompt(prompt, 80)
|
| 325 |
+
printLog(
|
| 326 |
+
chalk.dim(`[${timestamp()}]`) +
|
| 327 |
+
` Session started: ${chalk.white(`"${short}"`)} (${chalk.dim(sessionId)})\n`,
|
| 328 |
+
)
|
| 329 |
+
}
|
| 330 |
+
},
|
| 331 |
+
|
| 332 |
+
logSessionComplete(sessionId: string, durationMs: number): void {
|
| 333 |
+
printLog(
|
| 334 |
+
chalk.dim(`[${timestamp()}]`) +
|
| 335 |
+
` Session ${chalk.green('completed')} (${formatDuration(durationMs)}) ${chalk.dim(sessionId)}\n`,
|
| 336 |
+
)
|
| 337 |
+
},
|
| 338 |
+
|
| 339 |
+
logSessionFailed(sessionId: string, error: string): void {
|
| 340 |
+
printLog(
|
| 341 |
+
chalk.dim(`[${timestamp()}]`) +
|
| 342 |
+
` Session ${chalk.red('failed')}: ${error} ${chalk.dim(sessionId)}\n`,
|
| 343 |
+
)
|
| 344 |
+
},
|
| 345 |
+
|
| 346 |
+
logStatus(message: string): void {
|
| 347 |
+
printLog(chalk.dim(`[${timestamp()}]`) + ` ${message}\n`)
|
| 348 |
+
},
|
| 349 |
+
|
| 350 |
+
logVerbose(message: string): void {
|
| 351 |
+
if (verbose) {
|
| 352 |
+
printLog(chalk.dim(`[${timestamp()}] ${message}`) + '\n')
|
| 353 |
+
}
|
| 354 |
+
},
|
| 355 |
+
|
| 356 |
+
logError(message: string): void {
|
| 357 |
+
printLog(chalk.red(`[${timestamp()}] Error: ${message}`) + '\n')
|
| 358 |
+
},
|
| 359 |
+
|
| 360 |
+
logReconnected(disconnectedMs: number): void {
|
| 361 |
+
printLog(
|
| 362 |
+
chalk.dim(`[${timestamp()}]`) +
|
| 363 |
+
` ${chalk.green('Reconnected')} after ${formatDuration(disconnectedMs)}\n`,
|
| 364 |
+
)
|
| 365 |
+
},
|
| 366 |
+
|
| 367 |
+
setRepoInfo(repo: string, branchName: string): void {
|
| 368 |
+
repoName = repo
|
| 369 |
+
branch = branchName
|
| 370 |
+
},
|
| 371 |
+
|
| 372 |
+
setDebugLogPath(path: string): void {
|
| 373 |
+
debugLogPath = path
|
| 374 |
+
},
|
| 375 |
+
|
| 376 |
+
updateIdleStatus(): void {
|
| 377 |
+
stopConnecting()
|
| 378 |
+
|
| 379 |
+
currentState = 'idle'
|
| 380 |
+
currentStateText = 'Ready'
|
| 381 |
+
lastToolSummary = null
|
| 382 |
+
lastToolTime = 0
|
| 383 |
+
activeSessionUrl = null
|
| 384 |
+
regenerateQr(connectUrl)
|
| 385 |
+
renderStatusLine()
|
| 386 |
+
},
|
| 387 |
+
|
| 388 |
+
setAttached(sessionId: string): void {
|
| 389 |
+
stopConnecting()
|
| 390 |
+
currentState = 'attached'
|
| 391 |
+
currentStateText = 'Connected'
|
| 392 |
+
lastToolSummary = null
|
| 393 |
+
lastToolTime = 0
|
| 394 |
+
// Multi-session: keep footer/QR on the environment connect URL so users
|
| 395 |
+
// can spawn more sessions. Per-session links are in the bullet list.
|
| 396 |
+
if (sessionMax <= 1) {
|
| 397 |
+
activeSessionUrl = buildBridgeSessionUrl(
|
| 398 |
+
sessionId,
|
| 399 |
+
cachedEnvironmentId,
|
| 400 |
+
cachedIngressUrl,
|
| 401 |
+
)
|
| 402 |
+
regenerateQr(activeSessionUrl)
|
| 403 |
+
}
|
| 404 |
+
renderStatusLine()
|
| 405 |
+
},
|
| 406 |
+
|
| 407 |
+
updateReconnectingStatus(delayStr: string, elapsedStr: string): void {
|
| 408 |
+
stopConnecting()
|
| 409 |
+
clearStatusLines()
|
| 410 |
+
currentState = 'reconnecting'
|
| 411 |
+
|
| 412 |
+
// QR code above the status line
|
| 413 |
+
if (qrVisible) {
|
| 414 |
+
for (const line of qrLines) {
|
| 415 |
+
writeStatus(`${chalk.dim(line)}\n`)
|
| 416 |
+
}
|
| 417 |
+
}
|
| 418 |
+
|
| 419 |
+
const frame =
|
| 420 |
+
BRIDGE_SPINNER_FRAMES[connectingTick % BRIDGE_SPINNER_FRAMES.length]!
|
| 421 |
+
connectingTick++
|
| 422 |
+
writeStatus(
|
| 423 |
+
`${chalk.yellow(frame)} ${chalk.yellow('Reconnecting')} ${chalk.dim('\u00b7')} ${chalk.dim(`retrying in ${delayStr}`)} ${chalk.dim('\u00b7')} ${chalk.dim(`disconnected ${elapsedStr}`)}\n`,
|
| 424 |
+
)
|
| 425 |
+
},
|
| 426 |
+
|
| 427 |
+
updateFailedStatus(error: string): void {
|
| 428 |
+
stopConnecting()
|
| 429 |
+
clearStatusLines()
|
| 430 |
+
currentState = 'failed'
|
| 431 |
+
|
| 432 |
+
let suffix = ''
|
| 433 |
+
if (repoName) {
|
| 434 |
+
suffix += chalk.dim(' \u00b7 ') + chalk.dim(repoName)
|
| 435 |
+
}
|
| 436 |
+
if (branch) {
|
| 437 |
+
suffix += chalk.dim(' \u00b7 ') + chalk.dim(branch)
|
| 438 |
+
}
|
| 439 |
+
|
| 440 |
+
writeStatus(
|
| 441 |
+
`${chalk.red(BRIDGE_FAILED_INDICATOR)} ${chalk.red('Remote Control Failed')}${suffix}\n`,
|
| 442 |
+
)
|
| 443 |
+
writeStatus(`${chalk.dim(FAILED_FOOTER_TEXT)}\n`)
|
| 444 |
+
|
| 445 |
+
if (error) {
|
| 446 |
+
writeStatus(`${chalk.red(error)}\n`)
|
| 447 |
+
}
|
| 448 |
+
},
|
| 449 |
+
|
| 450 |
+
updateSessionStatus(
|
| 451 |
+
_sessionId: string,
|
| 452 |
+
_elapsed: string,
|
| 453 |
+
activity: SessionActivity,
|
| 454 |
+
_trail: string[],
|
| 455 |
+
): void {
|
| 456 |
+
// Cache tool activity for the second status line
|
| 457 |
+
if (activity.type === 'tool_start') {
|
| 458 |
+
lastToolSummary = activity.summary
|
| 459 |
+
lastToolTime = Date.now()
|
| 460 |
+
}
|
| 461 |
+
renderStatusLine()
|
| 462 |
+
},
|
| 463 |
+
|
| 464 |
+
clearStatus(): void {
|
| 465 |
+
stopConnecting()
|
| 466 |
+
clearStatusLines()
|
| 467 |
+
},
|
| 468 |
+
|
| 469 |
+
toggleQr(): void {
|
| 470 |
+
qrVisible = !qrVisible
|
| 471 |
+
renderStatusLine()
|
| 472 |
+
},
|
| 473 |
+
|
| 474 |
+
updateSessionCount(active: number, max: number, mode: SpawnMode): void {
|
| 475 |
+
if (sessionActive === active && sessionMax === max && spawnMode === mode)
|
| 476 |
+
return
|
| 477 |
+
sessionActive = active
|
| 478 |
+
sessionMax = max
|
| 479 |
+
spawnMode = mode
|
| 480 |
+
// Don't re-render here — the status ticker calls renderStatusLine
|
| 481 |
+
// on its own cadence, and the next tick will pick up the new values.
|
| 482 |
+
},
|
| 483 |
+
|
| 484 |
+
setSpawnModeDisplay(mode: 'same-dir' | 'worktree' | null): void {
|
| 485 |
+
if (spawnModeDisplay === mode) return
|
| 486 |
+
spawnModeDisplay = mode
|
| 487 |
+
// Also sync the #21118-added spawnMode so the next render shows correct
|
| 488 |
+
// mode hint + branch visibility. Don't render here — matches
|
| 489 |
+
// updateSessionCount: called before printBanner (initial setup) and
|
| 490 |
+
// again from the `w` handler (which follows with refreshDisplay).
|
| 491 |
+
if (mode) spawnMode = mode
|
| 492 |
+
},
|
| 493 |
+
|
| 494 |
+
addSession(sessionId: string, url: string): void {
|
| 495 |
+
sessionDisplayInfo.set(sessionId, { url })
|
| 496 |
+
},
|
| 497 |
+
|
| 498 |
+
updateSessionActivity(sessionId: string, activity: SessionActivity): void {
|
| 499 |
+
const info = sessionDisplayInfo.get(sessionId)
|
| 500 |
+
if (!info) return
|
| 501 |
+
info.activity = activity
|
| 502 |
+
},
|
| 503 |
+
|
| 504 |
+
setSessionTitle(sessionId: string, title: string): void {
|
| 505 |
+
const info = sessionDisplayInfo.get(sessionId)
|
| 506 |
+
if (!info) return
|
| 507 |
+
info.title = title
|
| 508 |
+
// Guard against reconnecting/failed — renderStatusLine clears then returns
|
| 509 |
+
// early for those states, which would erase the spinner/error.
|
| 510 |
+
if (currentState === 'reconnecting' || currentState === 'failed') return
|
| 511 |
+
if (sessionMax === 1) {
|
| 512 |
+
// Single-session: show title in the main status line too.
|
| 513 |
+
currentState = 'titled'
|
| 514 |
+
currentStateText = truncatePrompt(title, 40)
|
| 515 |
+
}
|
| 516 |
+
renderStatusLine()
|
| 517 |
+
},
|
| 518 |
+
|
| 519 |
+
removeSession(sessionId: string): void {
|
| 520 |
+
sessionDisplayInfo.delete(sessionId)
|
| 521 |
+
},
|
| 522 |
+
|
| 523 |
+
refreshDisplay(): void {
|
| 524 |
+
// Skip during reconnecting/failed — renderStatusLine clears then returns
|
| 525 |
+
// early for those states, which would erase the spinner/error.
|
| 526 |
+
if (currentState === 'reconnecting' || currentState === 'failed') return
|
| 527 |
+
renderStatusLine()
|
| 528 |
+
},
|
| 529 |
+
}
|
| 530 |
+
}
|
src/bridge/capacityWake.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Shared capacity-wake primitive for bridge poll loops.
|
| 3 |
+
*
|
| 4 |
+
* Both replBridge.ts and bridgeMain.ts need to sleep while "at capacity"
|
| 5 |
+
* but wake early when either (a) the outer loop signal aborts (shutdown),
|
| 6 |
+
* or (b) capacity frees up (session done / transport lost). This module
|
| 7 |
+
* encapsulates the mutable wake-controller + two-signal merger that both
|
| 8 |
+
* poll loops previously duplicated byte-for-byte.
|
| 9 |
+
*/
|
| 10 |
+
|
| 11 |
+
export type CapacitySignal = { signal: AbortSignal; cleanup: () => void }
|
| 12 |
+
|
| 13 |
+
export type CapacityWake = {
|
| 14 |
+
/**
|
| 15 |
+
* Create a signal that aborts when either the outer loop signal or the
|
| 16 |
+
* capacity-wake controller fires. Returns the merged signal and a cleanup
|
| 17 |
+
* function that removes listeners when the sleep resolves normally
|
| 18 |
+
* (without abort).
|
| 19 |
+
*/
|
| 20 |
+
signal(): CapacitySignal
|
| 21 |
+
/**
|
| 22 |
+
* Abort the current at-capacity sleep and arm a fresh controller so the
|
| 23 |
+
* poll loop immediately re-checks for new work.
|
| 24 |
+
*/
|
| 25 |
+
wake(): void
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
export function createCapacityWake(outerSignal: AbortSignal): CapacityWake {
|
| 29 |
+
let wakeController = new AbortController()
|
| 30 |
+
|
| 31 |
+
function wake(): void {
|
| 32 |
+
wakeController.abort()
|
| 33 |
+
wakeController = new AbortController()
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
function signal(): CapacitySignal {
|
| 37 |
+
const merged = new AbortController()
|
| 38 |
+
const abort = (): void => merged.abort()
|
| 39 |
+
if (outerSignal.aborted || wakeController.signal.aborted) {
|
| 40 |
+
merged.abort()
|
| 41 |
+
return { signal: merged.signal, cleanup: () => {} }
|
| 42 |
+
}
|
| 43 |
+
outerSignal.addEventListener('abort', abort, { once: true })
|
| 44 |
+
const capSig = wakeController.signal
|
| 45 |
+
capSig.addEventListener('abort', abort, { once: true })
|
| 46 |
+
return {
|
| 47 |
+
signal: merged.signal,
|
| 48 |
+
cleanup: () => {
|
| 49 |
+
outerSignal.removeEventListener('abort', abort)
|
| 50 |
+
capSig.removeEventListener('abort', abort)
|
| 51 |
+
},
|
| 52 |
+
}
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
return { signal, wake }
|
| 56 |
+
}
|
src/bridge/codeSessionApi.ts
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Thin HTTP wrappers for the CCR v2 code-session API.
|
| 3 |
+
*
|
| 4 |
+
* Separate file from remoteBridgeCore.ts so the SDK /bridge subpath can
|
| 5 |
+
* export createCodeSession + fetchRemoteCredentials without bundling the
|
| 6 |
+
* heavy CLI tree (analytics, transport, etc.). Callers supply explicit
|
| 7 |
+
* accessToken + baseUrl — no implicit auth or config reads.
|
| 8 |
+
*/
|
| 9 |
+
|
| 10 |
+
import axios from 'axios'
|
| 11 |
+
import { logForDebugging } from '../utils/debug.js'
|
| 12 |
+
import { errorMessage } from '../utils/errors.js'
|
| 13 |
+
import { jsonStringify } from '../utils/slowOperations.js'
|
| 14 |
+
import { extractErrorDetail } from './debugUtils.js'
|
| 15 |
+
|
| 16 |
+
const ANTHROPIC_VERSION = '2023-06-01'
|
| 17 |
+
|
| 18 |
+
function oauthHeaders(accessToken: string): Record<string, string> {
|
| 19 |
+
return {
|
| 20 |
+
Authorization: `Bearer ${accessToken}`,
|
| 21 |
+
'Content-Type': 'application/json',
|
| 22 |
+
'anthropic-version': ANTHROPIC_VERSION,
|
| 23 |
+
}
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
export async function createCodeSession(
|
| 27 |
+
baseUrl: string,
|
| 28 |
+
accessToken: string,
|
| 29 |
+
title: string,
|
| 30 |
+
timeoutMs: number,
|
| 31 |
+
tags?: string[],
|
| 32 |
+
): Promise<string | null> {
|
| 33 |
+
const url = `${baseUrl}/v1/code/sessions`
|
| 34 |
+
let response
|
| 35 |
+
try {
|
| 36 |
+
response = await axios.post(
|
| 37 |
+
url,
|
| 38 |
+
// bridge: {} is the positive signal for the oneof runner — omitting it
|
| 39 |
+
// (or sending environment_id: "") now 400s. BridgeRunner is an empty
|
| 40 |
+
// message today; it's a placeholder for future bridge-specific options.
|
| 41 |
+
{ title, bridge: {}, ...(tags?.length ? { tags } : {}) },
|
| 42 |
+
{
|
| 43 |
+
headers: oauthHeaders(accessToken),
|
| 44 |
+
timeout: timeoutMs,
|
| 45 |
+
validateStatus: s => s < 500,
|
| 46 |
+
},
|
| 47 |
+
)
|
| 48 |
+
} catch (err: unknown) {
|
| 49 |
+
logForDebugging(
|
| 50 |
+
`[code-session] Session create request failed: ${errorMessage(err)}`,
|
| 51 |
+
)
|
| 52 |
+
return null
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
if (response.status !== 200 && response.status !== 201) {
|
| 56 |
+
const detail = extractErrorDetail(response.data)
|
| 57 |
+
logForDebugging(
|
| 58 |
+
`[code-session] Session create failed ${response.status}${detail ? `: ${detail}` : ''}`,
|
| 59 |
+
)
|
| 60 |
+
return null
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
const data: unknown = response.data
|
| 64 |
+
if (
|
| 65 |
+
!data ||
|
| 66 |
+
typeof data !== 'object' ||
|
| 67 |
+
!('session' in data) ||
|
| 68 |
+
!data.session ||
|
| 69 |
+
typeof data.session !== 'object' ||
|
| 70 |
+
!('id' in data.session) ||
|
| 71 |
+
typeof data.session.id !== 'string' ||
|
| 72 |
+
!data.session.id.startsWith('cse_')
|
| 73 |
+
) {
|
| 74 |
+
logForDebugging(
|
| 75 |
+
`[code-session] No session.id (cse_*) in response: ${jsonStringify(data).slice(0, 200)}`,
|
| 76 |
+
)
|
| 77 |
+
return null
|
| 78 |
+
}
|
| 79 |
+
return data.session.id
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
/**
|
| 83 |
+
* Credentials from POST /bridge. JWT is opaque — do not decode.
|
| 84 |
+
* Each /bridge call bumps worker_epoch server-side (it IS the register).
|
| 85 |
+
*/
|
| 86 |
+
export type RemoteCredentials = {
|
| 87 |
+
worker_jwt: string
|
| 88 |
+
api_base_url: string
|
| 89 |
+
expires_in: number
|
| 90 |
+
worker_epoch: number
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
export async function fetchRemoteCredentials(
|
| 94 |
+
sessionId: string,
|
| 95 |
+
baseUrl: string,
|
| 96 |
+
accessToken: string,
|
| 97 |
+
timeoutMs: number,
|
| 98 |
+
trustedDeviceToken?: string,
|
| 99 |
+
): Promise<RemoteCredentials | null> {
|
| 100 |
+
const url = `${baseUrl}/v1/code/sessions/${sessionId}/bridge`
|
| 101 |
+
const headers = oauthHeaders(accessToken)
|
| 102 |
+
if (trustedDeviceToken) {
|
| 103 |
+
headers['X-Trusted-Device-Token'] = trustedDeviceToken
|
| 104 |
+
}
|
| 105 |
+
let response
|
| 106 |
+
try {
|
| 107 |
+
response = await axios.post(
|
| 108 |
+
url,
|
| 109 |
+
{},
|
| 110 |
+
{
|
| 111 |
+
headers,
|
| 112 |
+
timeout: timeoutMs,
|
| 113 |
+
validateStatus: s => s < 500,
|
| 114 |
+
},
|
| 115 |
+
)
|
| 116 |
+
} catch (err: unknown) {
|
| 117 |
+
logForDebugging(
|
| 118 |
+
`[code-session] /bridge request failed: ${errorMessage(err)}`,
|
| 119 |
+
)
|
| 120 |
+
return null
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
if (response.status !== 200) {
|
| 124 |
+
const detail = extractErrorDetail(response.data)
|
| 125 |
+
logForDebugging(
|
| 126 |
+
`[code-session] /bridge failed ${response.status}${detail ? `: ${detail}` : ''}`,
|
| 127 |
+
)
|
| 128 |
+
return null
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
const data: unknown = response.data
|
| 132 |
+
if (
|
| 133 |
+
data === null ||
|
| 134 |
+
typeof data !== 'object' ||
|
| 135 |
+
!('worker_jwt' in data) ||
|
| 136 |
+
typeof data.worker_jwt !== 'string' ||
|
| 137 |
+
!('expires_in' in data) ||
|
| 138 |
+
typeof data.expires_in !== 'number' ||
|
| 139 |
+
!('api_base_url' in data) ||
|
| 140 |
+
typeof data.api_base_url !== 'string' ||
|
| 141 |
+
!('worker_epoch' in data)
|
| 142 |
+
) {
|
| 143 |
+
logForDebugging(
|
| 144 |
+
`[code-session] /bridge response malformed (need worker_jwt, expires_in, api_base_url, worker_epoch): ${jsonStringify(data).slice(0, 200)}`,
|
| 145 |
+
)
|
| 146 |
+
return null
|
| 147 |
+
}
|
| 148 |
+
// protojson serializes int64 as a string to avoid JS precision loss;
|
| 149 |
+
// Go may also return a number depending on encoder settings.
|
| 150 |
+
const rawEpoch = data.worker_epoch
|
| 151 |
+
const epoch = typeof rawEpoch === 'string' ? Number(rawEpoch) : rawEpoch
|
| 152 |
+
if (
|
| 153 |
+
typeof epoch !== 'number' ||
|
| 154 |
+
!Number.isFinite(epoch) ||
|
| 155 |
+
!Number.isSafeInteger(epoch)
|
| 156 |
+
) {
|
| 157 |
+
logForDebugging(
|
| 158 |
+
`[code-session] /bridge worker_epoch invalid: ${jsonStringify(rawEpoch)}`,
|
| 159 |
+
)
|
| 160 |
+
return null
|
| 161 |
+
}
|
| 162 |
+
return {
|
| 163 |
+
worker_jwt: data.worker_jwt,
|
| 164 |
+
api_base_url: data.api_base_url,
|
| 165 |
+
expires_in: data.expires_in,
|
| 166 |
+
worker_epoch: epoch,
|
| 167 |
+
}
|
| 168 |
+
}
|
src/bridge/createSession.ts
ADDED
|
@@ -0,0 +1,384 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { SDKMessage } from '../entrypoints/agentSdkTypes.js'
|
| 2 |
+
import { logForDebugging } from '../utils/debug.js'
|
| 3 |
+
import { errorMessage } from '../utils/errors.js'
|
| 4 |
+
import { extractErrorDetail } from './debugUtils.js'
|
| 5 |
+
import { toCompatSessionId } from './sessionIdCompat.js'
|
| 6 |
+
|
| 7 |
+
type GitSource = {
|
| 8 |
+
type: 'git_repository'
|
| 9 |
+
url: string
|
| 10 |
+
revision?: string
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
type GitOutcome = {
|
| 14 |
+
type: 'git_repository'
|
| 15 |
+
git_info: { type: 'github'; repo: string; branches: string[] }
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
// Events must be wrapped in { type: 'event', data: <sdk_message> } for the
|
| 19 |
+
// POST /v1/sessions endpoint (discriminated union format).
|
| 20 |
+
type SessionEvent = {
|
| 21 |
+
type: 'event'
|
| 22 |
+
data: SDKMessage
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
/**
|
| 26 |
+
* Create a session on a bridge environment via POST /v1/sessions.
|
| 27 |
+
*
|
| 28 |
+
* Used by both `claude remote-control` (empty session so the user has somewhere to
|
| 29 |
+
* type immediately) and `/remote-control` (session pre-populated with conversation
|
| 30 |
+
* history).
|
| 31 |
+
*
|
| 32 |
+
* Returns the session ID on success, or null if creation fails (non-fatal).
|
| 33 |
+
*/
|
| 34 |
+
export async function createBridgeSession({
|
| 35 |
+
environmentId,
|
| 36 |
+
title,
|
| 37 |
+
events,
|
| 38 |
+
gitRepoUrl,
|
| 39 |
+
branch,
|
| 40 |
+
signal,
|
| 41 |
+
baseUrl: baseUrlOverride,
|
| 42 |
+
getAccessToken,
|
| 43 |
+
permissionMode,
|
| 44 |
+
}: {
|
| 45 |
+
environmentId: string
|
| 46 |
+
title?: string
|
| 47 |
+
events: SessionEvent[]
|
| 48 |
+
gitRepoUrl: string | null
|
| 49 |
+
branch: string
|
| 50 |
+
signal: AbortSignal
|
| 51 |
+
baseUrl?: string
|
| 52 |
+
getAccessToken?: () => string | undefined
|
| 53 |
+
permissionMode?: string
|
| 54 |
+
}): Promise<string | null> {
|
| 55 |
+
const { getClaudeAIOAuthTokens } = await import('../utils/auth.js')
|
| 56 |
+
const { getOrganizationUUID } = await import('../services/oauth/client.js')
|
| 57 |
+
const { getOauthConfig } = await import('../constants/oauth.js')
|
| 58 |
+
const { getOAuthHeaders } = await import('../utils/teleport/api.js')
|
| 59 |
+
const { parseGitHubRepository } = await import('../utils/detectRepository.js')
|
| 60 |
+
const { getDefaultBranch } = await import('../utils/git.js')
|
| 61 |
+
const { getMainLoopModel } = await import('../utils/model/model.js')
|
| 62 |
+
const { default: axios } = await import('axios')
|
| 63 |
+
|
| 64 |
+
const accessToken =
|
| 65 |
+
getAccessToken?.() ?? getClaudeAIOAuthTokens()?.accessToken
|
| 66 |
+
if (!accessToken) {
|
| 67 |
+
logForDebugging('[bridge] No access token for session creation')
|
| 68 |
+
return null
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
const orgUUID = await getOrganizationUUID()
|
| 72 |
+
if (!orgUUID) {
|
| 73 |
+
logForDebugging('[bridge] No org UUID for session creation')
|
| 74 |
+
return null
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
// Build git source and outcome context
|
| 78 |
+
let gitSource: GitSource | null = null
|
| 79 |
+
let gitOutcome: GitOutcome | null = null
|
| 80 |
+
|
| 81 |
+
if (gitRepoUrl) {
|
| 82 |
+
const { parseGitRemote } = await import('../utils/detectRepository.js')
|
| 83 |
+
const parsed = parseGitRemote(gitRepoUrl)
|
| 84 |
+
if (parsed) {
|
| 85 |
+
const { host, owner, name } = parsed
|
| 86 |
+
const revision = branch || (await getDefaultBranch()) || undefined
|
| 87 |
+
gitSource = {
|
| 88 |
+
type: 'git_repository',
|
| 89 |
+
url: `https://${host}/${owner}/${name}`,
|
| 90 |
+
revision,
|
| 91 |
+
}
|
| 92 |
+
gitOutcome = {
|
| 93 |
+
type: 'git_repository',
|
| 94 |
+
git_info: {
|
| 95 |
+
type: 'github',
|
| 96 |
+
repo: `${owner}/${name}`,
|
| 97 |
+
branches: [`claude/${branch || 'task'}`],
|
| 98 |
+
},
|
| 99 |
+
}
|
| 100 |
+
} else {
|
| 101 |
+
// Fallback: try parseGitHubRepository for owner/repo format
|
| 102 |
+
const ownerRepo = parseGitHubRepository(gitRepoUrl)
|
| 103 |
+
if (ownerRepo) {
|
| 104 |
+
const [owner, name] = ownerRepo.split('/')
|
| 105 |
+
if (owner && name) {
|
| 106 |
+
const revision = branch || (await getDefaultBranch()) || undefined
|
| 107 |
+
gitSource = {
|
| 108 |
+
type: 'git_repository',
|
| 109 |
+
url: `https://github.com/${owner}/${name}`,
|
| 110 |
+
revision,
|
| 111 |
+
}
|
| 112 |
+
gitOutcome = {
|
| 113 |
+
type: 'git_repository',
|
| 114 |
+
git_info: {
|
| 115 |
+
type: 'github',
|
| 116 |
+
repo: `${owner}/${name}`,
|
| 117 |
+
branches: [`claude/${branch || 'task'}`],
|
| 118 |
+
},
|
| 119 |
+
}
|
| 120 |
+
}
|
| 121 |
+
}
|
| 122 |
+
}
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
const requestBody = {
|
| 126 |
+
...(title !== undefined && { title }),
|
| 127 |
+
events,
|
| 128 |
+
session_context: {
|
| 129 |
+
sources: gitSource ? [gitSource] : [],
|
| 130 |
+
outcomes: gitOutcome ? [gitOutcome] : [],
|
| 131 |
+
model: getMainLoopModel(),
|
| 132 |
+
},
|
| 133 |
+
environment_id: environmentId,
|
| 134 |
+
source: 'remote-control',
|
| 135 |
+
...(permissionMode && { permission_mode: permissionMode }),
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
const headers = {
|
| 139 |
+
...getOAuthHeaders(accessToken),
|
| 140 |
+
'anthropic-beta': 'ccr-byoc-2025-07-29',
|
| 141 |
+
'x-organization-uuid': orgUUID,
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
const url = `${baseUrlOverride ?? getOauthConfig().BASE_API_URL}/v1/sessions`
|
| 145 |
+
let response
|
| 146 |
+
try {
|
| 147 |
+
response = await axios.post(url, requestBody, {
|
| 148 |
+
headers,
|
| 149 |
+
signal,
|
| 150 |
+
validateStatus: s => s < 500,
|
| 151 |
+
})
|
| 152 |
+
} catch (err: unknown) {
|
| 153 |
+
logForDebugging(
|
| 154 |
+
`[bridge] Session creation request failed: ${errorMessage(err)}`,
|
| 155 |
+
)
|
| 156 |
+
return null
|
| 157 |
+
}
|
| 158 |
+
const isSuccess = response.status === 200 || response.status === 201
|
| 159 |
+
|
| 160 |
+
if (!isSuccess) {
|
| 161 |
+
const detail = extractErrorDetail(response.data)
|
| 162 |
+
logForDebugging(
|
| 163 |
+
`[bridge] Session creation failed with status ${response.status}${detail ? `: ${detail}` : ''}`,
|
| 164 |
+
)
|
| 165 |
+
return null
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
const sessionData: unknown = response.data
|
| 169 |
+
if (
|
| 170 |
+
!sessionData ||
|
| 171 |
+
typeof sessionData !== 'object' ||
|
| 172 |
+
!('id' in sessionData) ||
|
| 173 |
+
typeof sessionData.id !== 'string'
|
| 174 |
+
) {
|
| 175 |
+
logForDebugging('[bridge] No session ID in response')
|
| 176 |
+
return null
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
return sessionData.id
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
/**
|
| 183 |
+
* Fetch a bridge session via GET /v1/sessions/{id}.
|
| 184 |
+
*
|
| 185 |
+
* Returns the session's environment_id (for `--session-id` resume) and title.
|
| 186 |
+
* Uses the same org-scoped headers as create/archive — the environments-level
|
| 187 |
+
* client in bridgeApi.ts uses a different beta header and no org UUID, which
|
| 188 |
+
* makes the Sessions API return 404.
|
| 189 |
+
*/
|
| 190 |
+
export async function getBridgeSession(
|
| 191 |
+
sessionId: string,
|
| 192 |
+
opts?: { baseUrl?: string; getAccessToken?: () => string | undefined },
|
| 193 |
+
): Promise<{ environment_id?: string; title?: string } | null> {
|
| 194 |
+
const { getClaudeAIOAuthTokens } = await import('../utils/auth.js')
|
| 195 |
+
const { getOrganizationUUID } = await import('../services/oauth/client.js')
|
| 196 |
+
const { getOauthConfig } = await import('../constants/oauth.js')
|
| 197 |
+
const { getOAuthHeaders } = await import('../utils/teleport/api.js')
|
| 198 |
+
const { default: axios } = await import('axios')
|
| 199 |
+
|
| 200 |
+
const accessToken =
|
| 201 |
+
opts?.getAccessToken?.() ?? getClaudeAIOAuthTokens()?.accessToken
|
| 202 |
+
if (!accessToken) {
|
| 203 |
+
logForDebugging('[bridge] No access token for session fetch')
|
| 204 |
+
return null
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
const orgUUID = await getOrganizationUUID()
|
| 208 |
+
if (!orgUUID) {
|
| 209 |
+
logForDebugging('[bridge] No org UUID for session fetch')
|
| 210 |
+
return null
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
const headers = {
|
| 214 |
+
...getOAuthHeaders(accessToken),
|
| 215 |
+
'anthropic-beta': 'ccr-byoc-2025-07-29',
|
| 216 |
+
'x-organization-uuid': orgUUID,
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
const url = `${opts?.baseUrl ?? getOauthConfig().BASE_API_URL}/v1/sessions/${sessionId}`
|
| 220 |
+
logForDebugging(`[bridge] Fetching session ${sessionId}`)
|
| 221 |
+
|
| 222 |
+
let response
|
| 223 |
+
try {
|
| 224 |
+
response = await axios.get<{ environment_id?: string; title?: string }>(
|
| 225 |
+
url,
|
| 226 |
+
{ headers, timeout: 10_000, validateStatus: s => s < 500 },
|
| 227 |
+
)
|
| 228 |
+
} catch (err: unknown) {
|
| 229 |
+
logForDebugging(
|
| 230 |
+
`[bridge] Session fetch request failed: ${errorMessage(err)}`,
|
| 231 |
+
)
|
| 232 |
+
return null
|
| 233 |
+
}
|
| 234 |
+
|
| 235 |
+
if (response.status !== 200) {
|
| 236 |
+
const detail = extractErrorDetail(response.data)
|
| 237 |
+
logForDebugging(
|
| 238 |
+
`[bridge] Session fetch failed with status ${response.status}${detail ? `: ${detail}` : ''}`,
|
| 239 |
+
)
|
| 240 |
+
return null
|
| 241 |
+
}
|
| 242 |
+
|
| 243 |
+
return response.data
|
| 244 |
+
}
|
| 245 |
+
|
| 246 |
+
/**
|
| 247 |
+
* Archive a bridge session via POST /v1/sessions/{id}/archive.
|
| 248 |
+
*
|
| 249 |
+
* The CCR server never auto-archives sessions — archival is always an
|
| 250 |
+
* explicit client action. Both `claude remote-control` (standalone bridge) and the
|
| 251 |
+
* always-on `/remote-control` REPL bridge call this during shutdown to archive any
|
| 252 |
+
* sessions that are still alive.
|
| 253 |
+
*
|
| 254 |
+
* The archive endpoint accepts sessions in any status (running, idle,
|
| 255 |
+
* requires_action, pending) and returns 409 if already archived, making
|
| 256 |
+
* it safe to call even if the server-side runner already archived the
|
| 257 |
+
* session.
|
| 258 |
+
*
|
| 259 |
+
* Callers must handle errors — this function has no try/catch; 5xx,
|
| 260 |
+
* timeouts, and network errors throw. Archival is best-effort during
|
| 261 |
+
* cleanup; call sites wrap with .catch().
|
| 262 |
+
*/
|
| 263 |
+
export async function archiveBridgeSession(
|
| 264 |
+
sessionId: string,
|
| 265 |
+
opts?: {
|
| 266 |
+
baseUrl?: string
|
| 267 |
+
getAccessToken?: () => string | undefined
|
| 268 |
+
timeoutMs?: number
|
| 269 |
+
},
|
| 270 |
+
): Promise<void> {
|
| 271 |
+
const { getClaudeAIOAuthTokens } = await import('../utils/auth.js')
|
| 272 |
+
const { getOrganizationUUID } = await import('../services/oauth/client.js')
|
| 273 |
+
const { getOauthConfig } = await import('../constants/oauth.js')
|
| 274 |
+
const { getOAuthHeaders } = await import('../utils/teleport/api.js')
|
| 275 |
+
const { default: axios } = await import('axios')
|
| 276 |
+
|
| 277 |
+
const accessToken =
|
| 278 |
+
opts?.getAccessToken?.() ?? getClaudeAIOAuthTokens()?.accessToken
|
| 279 |
+
if (!accessToken) {
|
| 280 |
+
logForDebugging('[bridge] No access token for session archive')
|
| 281 |
+
return
|
| 282 |
+
}
|
| 283 |
+
|
| 284 |
+
const orgUUID = await getOrganizationUUID()
|
| 285 |
+
if (!orgUUID) {
|
| 286 |
+
logForDebugging('[bridge] No org UUID for session archive')
|
| 287 |
+
return
|
| 288 |
+
}
|
| 289 |
+
|
| 290 |
+
const headers = {
|
| 291 |
+
...getOAuthHeaders(accessToken),
|
| 292 |
+
'anthropic-beta': 'ccr-byoc-2025-07-29',
|
| 293 |
+
'x-organization-uuid': orgUUID,
|
| 294 |
+
}
|
| 295 |
+
|
| 296 |
+
const url = `${opts?.baseUrl ?? getOauthConfig().BASE_API_URL}/v1/sessions/${sessionId}/archive`
|
| 297 |
+
logForDebugging(`[bridge] Archiving session ${sessionId}`)
|
| 298 |
+
|
| 299 |
+
const response = await axios.post(
|
| 300 |
+
url,
|
| 301 |
+
{},
|
| 302 |
+
{
|
| 303 |
+
headers,
|
| 304 |
+
timeout: opts?.timeoutMs ?? 10_000,
|
| 305 |
+
validateStatus: s => s < 500,
|
| 306 |
+
},
|
| 307 |
+
)
|
| 308 |
+
|
| 309 |
+
if (response.status === 200) {
|
| 310 |
+
logForDebugging(`[bridge] Session ${sessionId} archived successfully`)
|
| 311 |
+
} else {
|
| 312 |
+
const detail = extractErrorDetail(response.data)
|
| 313 |
+
logForDebugging(
|
| 314 |
+
`[bridge] Session archive failed with status ${response.status}${detail ? `: ${detail}` : ''}`,
|
| 315 |
+
)
|
| 316 |
+
}
|
| 317 |
+
}
|
| 318 |
+
|
| 319 |
+
/**
|
| 320 |
+
* Update the title of a bridge session via PATCH /v1/sessions/{id}.
|
| 321 |
+
*
|
| 322 |
+
* Called when the user renames a session via /rename while a bridge
|
| 323 |
+
* connection is active, so the title stays in sync on claude.ai/code.
|
| 324 |
+
*
|
| 325 |
+
* Errors are swallowed — title sync is best-effort.
|
| 326 |
+
*/
|
| 327 |
+
export async function updateBridgeSessionTitle(
|
| 328 |
+
sessionId: string,
|
| 329 |
+
title: string,
|
| 330 |
+
opts?: { baseUrl?: string; getAccessToken?: () => string | undefined },
|
| 331 |
+
): Promise<void> {
|
| 332 |
+
const { getClaudeAIOAuthTokens } = await import('../utils/auth.js')
|
| 333 |
+
const { getOrganizationUUID } = await import('../services/oauth/client.js')
|
| 334 |
+
const { getOauthConfig } = await import('../constants/oauth.js')
|
| 335 |
+
const { getOAuthHeaders } = await import('../utils/teleport/api.js')
|
| 336 |
+
const { default: axios } = await import('axios')
|
| 337 |
+
|
| 338 |
+
const accessToken =
|
| 339 |
+
opts?.getAccessToken?.() ?? getClaudeAIOAuthTokens()?.accessToken
|
| 340 |
+
if (!accessToken) {
|
| 341 |
+
logForDebugging('[bridge] No access token for session title update')
|
| 342 |
+
return
|
| 343 |
+
}
|
| 344 |
+
|
| 345 |
+
const orgUUID = await getOrganizationUUID()
|
| 346 |
+
if (!orgUUID) {
|
| 347 |
+
logForDebugging('[bridge] No org UUID for session title update')
|
| 348 |
+
return
|
| 349 |
+
}
|
| 350 |
+
|
| 351 |
+
const headers = {
|
| 352 |
+
...getOAuthHeaders(accessToken),
|
| 353 |
+
'anthropic-beta': 'ccr-byoc-2025-07-29',
|
| 354 |
+
'x-organization-uuid': orgUUID,
|
| 355 |
+
}
|
| 356 |
+
|
| 357 |
+
// Compat gateway only accepts session_* (compat/convert.go:27). v2 callers
|
| 358 |
+
// pass raw cse_*; retag here so all callers can pass whatever they hold.
|
| 359 |
+
// Idempotent for v1's session_* and bridgeMain's pre-converted compatSessionId.
|
| 360 |
+
const compatId = toCompatSessionId(sessionId)
|
| 361 |
+
const url = `${opts?.baseUrl ?? getOauthConfig().BASE_API_URL}/v1/sessions/${compatId}`
|
| 362 |
+
logForDebugging(`[bridge] Updating session title: ${compatId} → ${title}`)
|
| 363 |
+
|
| 364 |
+
try {
|
| 365 |
+
const response = await axios.patch(
|
| 366 |
+
url,
|
| 367 |
+
{ title },
|
| 368 |
+
{ headers, timeout: 10_000, validateStatus: s => s < 500 },
|
| 369 |
+
)
|
| 370 |
+
|
| 371 |
+
if (response.status === 200) {
|
| 372 |
+
logForDebugging(`[bridge] Session title updated successfully`)
|
| 373 |
+
} else {
|
| 374 |
+
const detail = extractErrorDetail(response.data)
|
| 375 |
+
logForDebugging(
|
| 376 |
+
`[bridge] Session title update failed with status ${response.status}${detail ? `: ${detail}` : ''}`,
|
| 377 |
+
)
|
| 378 |
+
}
|
| 379 |
+
} catch (err: unknown) {
|
| 380 |
+
logForDebugging(
|
| 381 |
+
`[bridge] Session title update request failed: ${errorMessage(err)}`,
|
| 382 |
+
)
|
| 383 |
+
}
|
| 384 |
+
}
|
src/bridge/debugUtils.ts
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import {
|
| 2 |
+
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
| 3 |
+
logEvent,
|
| 4 |
+
} from '../services/analytics/index.js'
|
| 5 |
+
import { logForDebugging } from '../utils/debug.js'
|
| 6 |
+
import { errorMessage } from '../utils/errors.js'
|
| 7 |
+
import { jsonStringify } from '../utils/slowOperations.js'
|
| 8 |
+
|
| 9 |
+
const DEBUG_MSG_LIMIT = 2000
|
| 10 |
+
|
| 11 |
+
const SECRET_FIELD_NAMES = [
|
| 12 |
+
'session_ingress_token',
|
| 13 |
+
'environment_secret',
|
| 14 |
+
'access_token',
|
| 15 |
+
'secret',
|
| 16 |
+
'token',
|
| 17 |
+
]
|
| 18 |
+
|
| 19 |
+
const SECRET_PATTERN = new RegExp(
|
| 20 |
+
`"(${SECRET_FIELD_NAMES.join('|')})"\\s*:\\s*"([^"]*)"`,
|
| 21 |
+
'g',
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
const REDACT_MIN_LENGTH = 16
|
| 25 |
+
|
| 26 |
+
export function redactSecrets(s: string): string {
|
| 27 |
+
return s.replace(SECRET_PATTERN, (_match, field: string, value: string) => {
|
| 28 |
+
if (value.length < REDACT_MIN_LENGTH) {
|
| 29 |
+
return `"${field}":"[REDACTED]"`
|
| 30 |
+
}
|
| 31 |
+
const redacted = `${value.slice(0, 8)}...${value.slice(-4)}`
|
| 32 |
+
return `"${field}":"${redacted}"`
|
| 33 |
+
})
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
/** Truncate a string for debug logging, collapsing newlines. */
|
| 37 |
+
export function debugTruncate(s: string): string {
|
| 38 |
+
const flat = s.replace(/\n/g, '\\n')
|
| 39 |
+
if (flat.length <= DEBUG_MSG_LIMIT) {
|
| 40 |
+
return flat
|
| 41 |
+
}
|
| 42 |
+
return flat.slice(0, DEBUG_MSG_LIMIT) + `... (${flat.length} chars)`
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
/** Truncate a JSON-serializable value for debug logging. */
|
| 46 |
+
export function debugBody(data: unknown): string {
|
| 47 |
+
const raw = typeof data === 'string' ? data : jsonStringify(data)
|
| 48 |
+
const s = redactSecrets(raw)
|
| 49 |
+
if (s.length <= DEBUG_MSG_LIMIT) {
|
| 50 |
+
return s
|
| 51 |
+
}
|
| 52 |
+
return s.slice(0, DEBUG_MSG_LIMIT) + `... (${s.length} chars)`
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
/**
|
| 56 |
+
* Extract a descriptive error message from an axios error (or any error).
|
| 57 |
+
* For HTTP errors, appends the server's response body message if available,
|
| 58 |
+
* since axios's default message only includes the status code.
|
| 59 |
+
*/
|
| 60 |
+
export function describeAxiosError(err: unknown): string {
|
| 61 |
+
const msg = errorMessage(err)
|
| 62 |
+
if (err && typeof err === 'object' && 'response' in err) {
|
| 63 |
+
const response = (err as { response?: { data?: unknown } }).response
|
| 64 |
+
if (response?.data && typeof response.data === 'object') {
|
| 65 |
+
const data = response.data as Record<string, unknown>
|
| 66 |
+
const detail =
|
| 67 |
+
typeof data.message === 'string'
|
| 68 |
+
? data.message
|
| 69 |
+
: typeof data.error === 'object' &&
|
| 70 |
+
data.error &&
|
| 71 |
+
'message' in data.error &&
|
| 72 |
+
typeof (data.error as Record<string, unknown>).message ===
|
| 73 |
+
'string'
|
| 74 |
+
? (data.error as Record<string, unknown>).message
|
| 75 |
+
: undefined
|
| 76 |
+
if (detail) {
|
| 77 |
+
return `${msg}: ${detail}`
|
| 78 |
+
}
|
| 79 |
+
}
|
| 80 |
+
}
|
| 81 |
+
return msg
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
/**
|
| 85 |
+
* Extract the HTTP status code from an axios error, if present.
|
| 86 |
+
* Returns undefined for non-HTTP errors (e.g. network failures).
|
| 87 |
+
*/
|
| 88 |
+
export function extractHttpStatus(err: unknown): number | undefined {
|
| 89 |
+
if (
|
| 90 |
+
err &&
|
| 91 |
+
typeof err === 'object' &&
|
| 92 |
+
'response' in err &&
|
| 93 |
+
(err as { response?: { status?: unknown } }).response &&
|
| 94 |
+
typeof (err as { response: { status?: unknown } }).response.status ===
|
| 95 |
+
'number'
|
| 96 |
+
) {
|
| 97 |
+
return (err as { response: { status: number } }).response.status
|
| 98 |
+
}
|
| 99 |
+
return undefined
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
/**
|
| 103 |
+
* Pull a human-readable message out of an API error response body.
|
| 104 |
+
* Checks `data.message` first, then `data.error.message`.
|
| 105 |
+
*/
|
| 106 |
+
export function extractErrorDetail(data: unknown): string | undefined {
|
| 107 |
+
if (!data || typeof data !== 'object') return undefined
|
| 108 |
+
if ('message' in data && typeof data.message === 'string') {
|
| 109 |
+
return data.message
|
| 110 |
+
}
|
| 111 |
+
if (
|
| 112 |
+
'error' in data &&
|
| 113 |
+
data.error !== null &&
|
| 114 |
+
typeof data.error === 'object' &&
|
| 115 |
+
'message' in data.error &&
|
| 116 |
+
typeof data.error.message === 'string'
|
| 117 |
+
) {
|
| 118 |
+
return data.error.message
|
| 119 |
+
}
|
| 120 |
+
return undefined
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
/**
|
| 124 |
+
* Log a bridge init skip — debug message + `tengu_bridge_repl_skipped`
|
| 125 |
+
* analytics event. Centralizes the event name and the AnalyticsMetadata
|
| 126 |
+
* cast so call sites don't each repeat the 5-line boilerplate.
|
| 127 |
+
*/
|
| 128 |
+
export function logBridgeSkip(
|
| 129 |
+
reason: string,
|
| 130 |
+
debugMsg?: string,
|
| 131 |
+
v2?: boolean,
|
| 132 |
+
): void {
|
| 133 |
+
if (debugMsg) {
|
| 134 |
+
logForDebugging(debugMsg)
|
| 135 |
+
}
|
| 136 |
+
logEvent('tengu_bridge_repl_skipped', {
|
| 137 |
+
reason:
|
| 138 |
+
reason as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
| 139 |
+
...(v2 !== undefined && { v2 }),
|
| 140 |
+
})
|
| 141 |
+
}
|
src/bridge/envLessBridgeConfig.ts
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { z } from 'zod/v4'
|
| 2 |
+
import { getFeatureValue_DEPRECATED } from '../services/analytics/growthbook.js'
|
| 3 |
+
import { lazySchema } from '../utils/lazySchema.js'
|
| 4 |
+
import { lt } from '../utils/semver.js'
|
| 5 |
+
import { isEnvLessBridgeEnabled } from './bridgeEnabled.js'
|
| 6 |
+
|
| 7 |
+
export type EnvLessBridgeConfig = {
|
| 8 |
+
// withRetry — init-phase backoff (createSession, POST /bridge, recovery /bridge)
|
| 9 |
+
init_retry_max_attempts: number
|
| 10 |
+
init_retry_base_delay_ms: number
|
| 11 |
+
init_retry_jitter_fraction: number
|
| 12 |
+
init_retry_max_delay_ms: number
|
| 13 |
+
// axios timeout for POST /sessions, POST /bridge, POST /archive
|
| 14 |
+
http_timeout_ms: number
|
| 15 |
+
// BoundedUUIDSet ring size (echo + re-delivery dedup)
|
| 16 |
+
uuid_dedup_buffer_size: number
|
| 17 |
+
// CCRClient worker heartbeat cadence. Server TTL is 60s — 20s gives 3× margin.
|
| 18 |
+
heartbeat_interval_ms: number
|
| 19 |
+
// ±fraction of interval — per-beat jitter to spread fleet load.
|
| 20 |
+
heartbeat_jitter_fraction: number
|
| 21 |
+
// Fire proactive JWT refresh this long before expires_in. Larger buffer =
|
| 22 |
+
// more frequent refresh (refresh cadence ≈ expires_in - buffer).
|
| 23 |
+
token_refresh_buffer_ms: number
|
| 24 |
+
// Archive POST timeout in teardown(). Distinct from http_timeout_ms because
|
| 25 |
+
// gracefulShutdown races runCleanupFunctions() against a 2s cap — a 10s
|
| 26 |
+
// axios timeout on a slow/stalled archive burns the whole budget on a
|
| 27 |
+
// request that forceExit will kill anyway.
|
| 28 |
+
teardown_archive_timeout_ms: number
|
| 29 |
+
// Deadline for onConnect after transport.connect(). If neither onConnect
|
| 30 |
+
// nor onClose fires before this, emit tengu_bridge_repl_connect_timeout
|
| 31 |
+
// — the only telemetry for the ~1% of sessions that emit `started` then
|
| 32 |
+
// go silent (no error, no event, just nothing).
|
| 33 |
+
connect_timeout_ms: number
|
| 34 |
+
// Semver floor for the env-less bridge path. Separate from the v1
|
| 35 |
+
// tengu_bridge_min_version config so a v2-specific bug can force upgrades
|
| 36 |
+
// without blocking v1 (env-based) clients, and vice versa.
|
| 37 |
+
min_version: string
|
| 38 |
+
// When true, tell users their claude.ai app may be too old to see v2
|
| 39 |
+
// sessions — lets us roll the v2 bridge before the app ships the new
|
| 40 |
+
// session-list query.
|
| 41 |
+
should_show_app_upgrade_message: boolean
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
export const DEFAULT_ENV_LESS_BRIDGE_CONFIG: EnvLessBridgeConfig = {
|
| 45 |
+
init_retry_max_attempts: 3,
|
| 46 |
+
init_retry_base_delay_ms: 500,
|
| 47 |
+
init_retry_jitter_fraction: 0.25,
|
| 48 |
+
init_retry_max_delay_ms: 4000,
|
| 49 |
+
http_timeout_ms: 10_000,
|
| 50 |
+
uuid_dedup_buffer_size: 2000,
|
| 51 |
+
heartbeat_interval_ms: 20_000,
|
| 52 |
+
heartbeat_jitter_fraction: 0.1,
|
| 53 |
+
token_refresh_buffer_ms: 300_000,
|
| 54 |
+
teardown_archive_timeout_ms: 1500,
|
| 55 |
+
connect_timeout_ms: 15_000,
|
| 56 |
+
min_version: '0.0.0',
|
| 57 |
+
should_show_app_upgrade_message: false,
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
// Floors reject the whole object on violation (fall back to DEFAULT) rather
|
| 61 |
+
// than partially trusting — same defense-in-depth as pollConfig.ts.
|
| 62 |
+
const envLessBridgeConfigSchema = lazySchema(() =>
|
| 63 |
+
z.object({
|
| 64 |
+
init_retry_max_attempts: z.number().int().min(1).max(10).default(3),
|
| 65 |
+
init_retry_base_delay_ms: z.number().int().min(100).default(500),
|
| 66 |
+
init_retry_jitter_fraction: z.number().min(0).max(1).default(0.25),
|
| 67 |
+
init_retry_max_delay_ms: z.number().int().min(500).default(4000),
|
| 68 |
+
http_timeout_ms: z.number().int().min(2000).default(10_000),
|
| 69 |
+
uuid_dedup_buffer_size: z.number().int().min(100).max(50_000).default(2000),
|
| 70 |
+
// Server TTL is 60s. Floor 5s prevents thrash; cap 30s keeps ≥2× margin.
|
| 71 |
+
heartbeat_interval_ms: z
|
| 72 |
+
.number()
|
| 73 |
+
.int()
|
| 74 |
+
.min(5000)
|
| 75 |
+
.max(30_000)
|
| 76 |
+
.default(20_000),
|
| 77 |
+
// ±fraction per beat. Cap 0.5: at max interval (30s) × 1.5 = 45s worst case,
|
| 78 |
+
// still under the 60s TTL.
|
| 79 |
+
heartbeat_jitter_fraction: z.number().min(0).max(0.5).default(0.1),
|
| 80 |
+
// Floor 30s prevents tight-looping. Cap 30min rejects buffer-vs-delay
|
| 81 |
+
// semantic inversion: ops entering expires_in-5min (the *delay until
|
| 82 |
+
// refresh*) instead of 5min (the *buffer before expiry*) yields
|
| 83 |
+
// delayMs = expires_in - buffer ≈ 5min instead of ≈4h. Both are positive
|
| 84 |
+
// durations so .min() alone can't distinguish; .max() catches the
|
| 85 |
+
// inverted value since buffer ≥ 30min is nonsensical for a multi-hour JWT.
|
| 86 |
+
token_refresh_buffer_ms: z
|
| 87 |
+
.number()
|
| 88 |
+
.int()
|
| 89 |
+
.min(30_000)
|
| 90 |
+
.max(1_800_000)
|
| 91 |
+
.default(300_000),
|
| 92 |
+
// Cap 2000 keeps this under gracefulShutdown's 2s cleanup race — a higher
|
| 93 |
+
// timeout just lies to axios since forceExit kills the socket regardless.
|
| 94 |
+
teardown_archive_timeout_ms: z
|
| 95 |
+
.number()
|
| 96 |
+
.int()
|
| 97 |
+
.min(500)
|
| 98 |
+
.max(2000)
|
| 99 |
+
.default(1500),
|
| 100 |
+
// Observed p99 connect is ~2-3s; 15s is ~5× headroom. Floor 5s bounds
|
| 101 |
+
// false-positive rate under transient slowness; cap 60s bounds how long
|
| 102 |
+
// a truly-stalled session stays dark.
|
| 103 |
+
connect_timeout_ms: z.number().int().min(5_000).max(60_000).default(15_000),
|
| 104 |
+
min_version: z
|
| 105 |
+
.string()
|
| 106 |
+
.refine(v => {
|
| 107 |
+
try {
|
| 108 |
+
lt(v, '0.0.0')
|
| 109 |
+
return true
|
| 110 |
+
} catch {
|
| 111 |
+
return false
|
| 112 |
+
}
|
| 113 |
+
})
|
| 114 |
+
.default('0.0.0'),
|
| 115 |
+
should_show_app_upgrade_message: z.boolean().default(false),
|
| 116 |
+
}),
|
| 117 |
+
)
|
| 118 |
+
|
| 119 |
+
/**
|
| 120 |
+
* Fetch the env-less bridge timing config from GrowthBook. Read once per
|
| 121 |
+
* initEnvLessBridgeCore call — config is fixed for the lifetime of a bridge
|
| 122 |
+
* session.
|
| 123 |
+
*
|
| 124 |
+
* Uses the blocking getter (not _CACHED_MAY_BE_STALE) because /remote-control
|
| 125 |
+
* runs well after GrowthBook init — initializeGrowthBook() resolves instantly,
|
| 126 |
+
* so there's no startup penalty, and we get the fresh in-memory remoteEval
|
| 127 |
+
* value instead of the stale-on-first-read disk cache. The _DEPRECATED suffix
|
| 128 |
+
* warns against startup-path usage, which this isn't.
|
| 129 |
+
*/
|
| 130 |
+
export async function getEnvLessBridgeConfig(): Promise<EnvLessBridgeConfig> {
|
| 131 |
+
const raw = await getFeatureValue_DEPRECATED<unknown>(
|
| 132 |
+
'tengu_bridge_repl_v2_config',
|
| 133 |
+
DEFAULT_ENV_LESS_BRIDGE_CONFIG,
|
| 134 |
+
)
|
| 135 |
+
const parsed = envLessBridgeConfigSchema().safeParse(raw)
|
| 136 |
+
return parsed.success ? parsed.data : DEFAULT_ENV_LESS_BRIDGE_CONFIG
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
/**
|
| 140 |
+
* Returns an error message if the current CLI version is below the minimum
|
| 141 |
+
* required for the env-less (v2) bridge path, or null if the version is fine.
|
| 142 |
+
*
|
| 143 |
+
* v2 analogue of checkBridgeMinVersion() — reads from tengu_bridge_repl_v2_config
|
| 144 |
+
* instead of tengu_bridge_min_version so the two implementations can enforce
|
| 145 |
+
* independent floors.
|
| 146 |
+
*/
|
| 147 |
+
export async function checkEnvLessBridgeMinVersion(): Promise<string | null> {
|
| 148 |
+
const cfg = await getEnvLessBridgeConfig()
|
| 149 |
+
if (cfg.min_version && lt(MACRO.VERSION, cfg.min_version)) {
|
| 150 |
+
return `Your version of Claude Code (${MACRO.VERSION}) is too old for Remote Control.\nVersion ${cfg.min_version} or higher is required. Run \`claude update\` to update.`
|
| 151 |
+
}
|
| 152 |
+
return null
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
/**
|
| 156 |
+
* Whether to nudge users toward upgrading their claude.ai app when a
|
| 157 |
+
* Remote Control session starts. True only when the v2 bridge is active
|
| 158 |
+
* AND the should_show_app_upgrade_message config bit is set — lets us
|
| 159 |
+
* roll the v2 bridge before the app ships the new session-list query.
|
| 160 |
+
*/
|
| 161 |
+
export async function shouldShowAppUpgradeMessage(): Promise<boolean> {
|
| 162 |
+
if (!isEnvLessBridgeEnabled()) return false
|
| 163 |
+
const cfg = await getEnvLessBridgeConfig()
|
| 164 |
+
return cfg.should_show_app_upgrade_message
|
| 165 |
+
}
|
src/bridge/flushGate.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* State machine for gating message writes during an initial flush.
|
| 3 |
+
*
|
| 4 |
+
* When a bridge session starts, historical messages are flushed to the
|
| 5 |
+
* server via a single HTTP POST. During that flush, new messages must
|
| 6 |
+
* be queued to prevent them from arriving at the server interleaved
|
| 7 |
+
* with the historical messages.
|
| 8 |
+
*
|
| 9 |
+
* Lifecycle:
|
| 10 |
+
* start() → enqueue() returns true, items are queued
|
| 11 |
+
* end() → returns queued items for draining, enqueue() returns false
|
| 12 |
+
* drop() → discards queued items (permanent transport close)
|
| 13 |
+
* deactivate() → clears active flag without dropping items
|
| 14 |
+
* (transport replacement — new transport will drain)
|
| 15 |
+
*/
|
| 16 |
+
export class FlushGate<T> {
|
| 17 |
+
private _active = false
|
| 18 |
+
private _pending: T[] = []
|
| 19 |
+
|
| 20 |
+
get active(): boolean {
|
| 21 |
+
return this._active
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
get pendingCount(): number {
|
| 25 |
+
return this._pending.length
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
/** Mark flush as in-progress. enqueue() will start queuing items. */
|
| 29 |
+
start(): void {
|
| 30 |
+
this._active = true
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
/**
|
| 34 |
+
* End the flush and return any queued items for draining.
|
| 35 |
+
* Caller is responsible for sending the returned items.
|
| 36 |
+
*/
|
| 37 |
+
end(): T[] {
|
| 38 |
+
this._active = false
|
| 39 |
+
return this._pending.splice(0)
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
/**
|
| 43 |
+
* If flush is active, queue the items and return true.
|
| 44 |
+
* If flush is not active, return false (caller should send directly).
|
| 45 |
+
*/
|
| 46 |
+
enqueue(...items: T[]): boolean {
|
| 47 |
+
if (!this._active) return false
|
| 48 |
+
this._pending.push(...items)
|
| 49 |
+
return true
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
/**
|
| 53 |
+
* Discard all queued items (permanent transport close).
|
| 54 |
+
* Returns the number of items dropped.
|
| 55 |
+
*/
|
| 56 |
+
drop(): number {
|
| 57 |
+
this._active = false
|
| 58 |
+
const count = this._pending.length
|
| 59 |
+
this._pending.length = 0
|
| 60 |
+
return count
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
/**
|
| 64 |
+
* Clear the active flag without dropping queued items.
|
| 65 |
+
* Used when the transport is replaced (onWorkReceived) — the new
|
| 66 |
+
* transport's flush will drain the pending items.
|
| 67 |
+
*/
|
| 68 |
+
deactivate(): void {
|
| 69 |
+
this._active = false
|
| 70 |
+
}
|
| 71 |
+
}
|
src/bridge/inboundAttachments.ts
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Resolve file_uuid attachments on inbound bridge user messages.
|
| 3 |
+
*
|
| 4 |
+
* Web composer uploads via cookie-authed /api/{org}/upload, sends file_uuid
|
| 5 |
+
* alongside the message. Here we fetch each via GET /api/oauth/files/{uuid}/content
|
| 6 |
+
* (oauth-authed, same store), write to ~/.claude/uploads/{sessionId}/, and
|
| 7 |
+
* return @path refs to prepend. Claude's Read tool takes it from there.
|
| 8 |
+
*
|
| 9 |
+
* Best-effort: any failure (no token, network, non-2xx, disk) logs debug and
|
| 10 |
+
* skips that attachment. The message still reaches Claude, just without @path.
|
| 11 |
+
*/
|
| 12 |
+
|
| 13 |
+
import type { ContentBlockParam } from '@anthropic-ai/sdk/resources/messages.mjs'
|
| 14 |
+
import axios from 'axios'
|
| 15 |
+
import { randomUUID } from 'crypto'
|
| 16 |
+
import { mkdir, writeFile } from 'fs/promises'
|
| 17 |
+
import { basename, join } from 'path'
|
| 18 |
+
import { z } from 'zod/v4'
|
| 19 |
+
import { getSessionId } from '../bootstrap/state.js'
|
| 20 |
+
import { logForDebugging } from '../utils/debug.js'
|
| 21 |
+
import { getClaudeConfigHomeDir } from '../utils/envUtils.js'
|
| 22 |
+
import { lazySchema } from '../utils/lazySchema.js'
|
| 23 |
+
import { getBridgeAccessToken, getBridgeBaseUrl } from './bridgeConfig.js'
|
| 24 |
+
|
| 25 |
+
const DOWNLOAD_TIMEOUT_MS = 30_000
|
| 26 |
+
|
| 27 |
+
function debug(msg: string): void {
|
| 28 |
+
logForDebugging(`[bridge:inbound-attach] ${msg}`)
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
const attachmentSchema = lazySchema(() =>
|
| 32 |
+
z.object({
|
| 33 |
+
file_uuid: z.string(),
|
| 34 |
+
file_name: z.string(),
|
| 35 |
+
}),
|
| 36 |
+
)
|
| 37 |
+
const attachmentsArraySchema = lazySchema(() => z.array(attachmentSchema()))
|
| 38 |
+
|
| 39 |
+
export type InboundAttachment = z.infer<ReturnType<typeof attachmentSchema>>
|
| 40 |
+
|
| 41 |
+
/** Pull file_attachments off a loosely-typed inbound message. */
|
| 42 |
+
export function extractInboundAttachments(msg: unknown): InboundAttachment[] {
|
| 43 |
+
if (typeof msg !== 'object' || msg === null || !('file_attachments' in msg)) {
|
| 44 |
+
return []
|
| 45 |
+
}
|
| 46 |
+
const parsed = attachmentsArraySchema().safeParse(msg.file_attachments)
|
| 47 |
+
return parsed.success ? parsed.data : []
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
/**
|
| 51 |
+
* Strip path components and keep only filename-safe chars. file_name comes
|
| 52 |
+
* from the network (web composer), so treat it as untrusted even though the
|
| 53 |
+
* composer controls it.
|
| 54 |
+
*/
|
| 55 |
+
function sanitizeFileName(name: string): string {
|
| 56 |
+
const base = basename(name).replace(/[^a-zA-Z0-9._-]/g, '_')
|
| 57 |
+
return base || 'attachment'
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
function uploadsDir(): string {
|
| 61 |
+
return join(getClaudeConfigHomeDir(), 'uploads', getSessionId())
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
/**
|
| 65 |
+
* Fetch + write one attachment. Returns the absolute path on success,
|
| 66 |
+
* undefined on any failure.
|
| 67 |
+
*/
|
| 68 |
+
async function resolveOne(att: InboundAttachment): Promise<string | undefined> {
|
| 69 |
+
const token = getBridgeAccessToken()
|
| 70 |
+
if (!token) {
|
| 71 |
+
debug('skip: no oauth token')
|
| 72 |
+
return undefined
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
let data: Buffer
|
| 76 |
+
try {
|
| 77 |
+
// getOauthConfig() (via getBridgeBaseUrl) throws on a non-allowlisted
|
| 78 |
+
// CLAUDE_CODE_CUSTOM_OAUTH_URL — keep it inside the try so a bad
|
| 79 |
+
// FedStart URL degrades to "no @path" instead of crashing print.ts's
|
| 80 |
+
// reader loop (which has no catch around the await).
|
| 81 |
+
const url = `${getBridgeBaseUrl()}/api/oauth/files/${encodeURIComponent(att.file_uuid)}/content`
|
| 82 |
+
const response = await axios.get(url, {
|
| 83 |
+
headers: { Authorization: `Bearer ${token}` },
|
| 84 |
+
responseType: 'arraybuffer',
|
| 85 |
+
timeout: DOWNLOAD_TIMEOUT_MS,
|
| 86 |
+
validateStatus: () => true,
|
| 87 |
+
})
|
| 88 |
+
if (response.status !== 200) {
|
| 89 |
+
debug(`fetch ${att.file_uuid} failed: status=${response.status}`)
|
| 90 |
+
return undefined
|
| 91 |
+
}
|
| 92 |
+
data = Buffer.from(response.data)
|
| 93 |
+
} catch (e) {
|
| 94 |
+
debug(`fetch ${att.file_uuid} threw: ${e}`)
|
| 95 |
+
return undefined
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
// uuid-prefix makes collisions impossible across messages and within one
|
| 99 |
+
// (same filename, different files). 8 chars is enough — this isn't security.
|
| 100 |
+
const safeName = sanitizeFileName(att.file_name)
|
| 101 |
+
const prefix = (
|
| 102 |
+
att.file_uuid.slice(0, 8) || randomUUID().slice(0, 8)
|
| 103 |
+
).replace(/[^a-zA-Z0-9_-]/g, '_')
|
| 104 |
+
const dir = uploadsDir()
|
| 105 |
+
const outPath = join(dir, `${prefix}-${safeName}`)
|
| 106 |
+
|
| 107 |
+
try {
|
| 108 |
+
await mkdir(dir, { recursive: true })
|
| 109 |
+
await writeFile(outPath, data)
|
| 110 |
+
} catch (e) {
|
| 111 |
+
debug(`write ${outPath} failed: ${e}`)
|
| 112 |
+
return undefined
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
debug(`resolved ${att.file_uuid} → ${outPath} (${data.length} bytes)`)
|
| 116 |
+
return outPath
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
/**
|
| 120 |
+
* Resolve all attachments on an inbound message to a prefix string of
|
| 121 |
+
* @path refs. Empty string if none resolved.
|
| 122 |
+
*/
|
| 123 |
+
export async function resolveInboundAttachments(
|
| 124 |
+
attachments: InboundAttachment[],
|
| 125 |
+
): Promise<string> {
|
| 126 |
+
if (attachments.length === 0) return ''
|
| 127 |
+
debug(`resolving ${attachments.length} attachment(s)`)
|
| 128 |
+
const paths = await Promise.all(attachments.map(resolveOne))
|
| 129 |
+
const ok = paths.filter((p): p is string => p !== undefined)
|
| 130 |
+
if (ok.length === 0) return ''
|
| 131 |
+
// Quoted form — extractAtMentionedFiles truncates unquoted @refs at the
|
| 132 |
+
// first space, which breaks any home dir with spaces (/Users/John Smith/).
|
| 133 |
+
return ok.map(p => `@"${p}"`).join(' ') + ' '
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
/**
|
| 137 |
+
* Prepend @path refs to content, whichever form it's in.
|
| 138 |
+
* Targets the LAST text block — processUserInputBase reads inputString
|
| 139 |
+
* from processedBlocks[processedBlocks.length - 1], so putting refs in
|
| 140 |
+
* block[0] means they're silently ignored for [text, image] content.
|
| 141 |
+
*/
|
| 142 |
+
export function prependPathRefs(
|
| 143 |
+
content: string | Array<ContentBlockParam>,
|
| 144 |
+
prefix: string,
|
| 145 |
+
): string | Array<ContentBlockParam> {
|
| 146 |
+
if (!prefix) return content
|
| 147 |
+
if (typeof content === 'string') return prefix + content
|
| 148 |
+
const i = content.findLastIndex(b => b.type === 'text')
|
| 149 |
+
if (i !== -1) {
|
| 150 |
+
const b = content[i]!
|
| 151 |
+
if (b.type === 'text') {
|
| 152 |
+
return [
|
| 153 |
+
...content.slice(0, i),
|
| 154 |
+
{ ...b, text: prefix + b.text },
|
| 155 |
+
...content.slice(i + 1),
|
| 156 |
+
]
|
| 157 |
+
}
|
| 158 |
+
}
|
| 159 |
+
// No text block — append one at the end so it's last.
|
| 160 |
+
return [...content, { type: 'text', text: prefix.trimEnd() }]
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
/**
|
| 164 |
+
* Convenience: extract + resolve + prepend. No-op when the message has no
|
| 165 |
+
* file_attachments field (fast path — no network, returns same reference).
|
| 166 |
+
*/
|
| 167 |
+
export async function resolveAndPrepend(
|
| 168 |
+
msg: unknown,
|
| 169 |
+
content: string | Array<ContentBlockParam>,
|
| 170 |
+
): Promise<string | Array<ContentBlockParam>> {
|
| 171 |
+
const attachments = extractInboundAttachments(msg)
|
| 172 |
+
if (attachments.length === 0) return content
|
| 173 |
+
const prefix = await resolveInboundAttachments(attachments)
|
| 174 |
+
return prependPathRefs(content, prefix)
|
| 175 |
+
}
|
src/bridge/inboundMessages.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type {
|
| 2 |
+
Base64ImageSource,
|
| 3 |
+
ContentBlockParam,
|
| 4 |
+
ImageBlockParam,
|
| 5 |
+
} from '@anthropic-ai/sdk/resources/messages.mjs'
|
| 6 |
+
import type { UUID } from 'crypto'
|
| 7 |
+
import type { SDKMessage } from '../entrypoints/agentSdkTypes.js'
|
| 8 |
+
import { detectImageFormatFromBase64 } from '../utils/imageResizer.js'
|
| 9 |
+
|
| 10 |
+
/**
|
| 11 |
+
* Process an inbound user message from the bridge, extracting content
|
| 12 |
+
* and UUID for enqueueing. Supports both string content and
|
| 13 |
+
* ContentBlockParam[] (e.g. messages containing images).
|
| 14 |
+
*
|
| 15 |
+
* Normalizes image blocks from bridge clients that may use camelCase
|
| 16 |
+
* `mediaType` instead of snake_case `media_type` (mobile-apps#5825).
|
| 17 |
+
*
|
| 18 |
+
* Returns the extracted fields, or undefined if the message should be
|
| 19 |
+
* skipped (non-user type, missing/empty content).
|
| 20 |
+
*/
|
| 21 |
+
export function extractInboundMessageFields(
|
| 22 |
+
msg: SDKMessage,
|
| 23 |
+
):
|
| 24 |
+
| { content: string | Array<ContentBlockParam>; uuid: UUID | undefined }
|
| 25 |
+
| undefined {
|
| 26 |
+
if (msg.type !== 'user') return undefined
|
| 27 |
+
const content = msg.message?.content
|
| 28 |
+
if (!content) return undefined
|
| 29 |
+
if (Array.isArray(content) && content.length === 0) return undefined
|
| 30 |
+
|
| 31 |
+
const uuid =
|
| 32 |
+
'uuid' in msg && typeof msg.uuid === 'string'
|
| 33 |
+
? (msg.uuid as UUID)
|
| 34 |
+
: undefined
|
| 35 |
+
|
| 36 |
+
return {
|
| 37 |
+
content: Array.isArray(content) ? normalizeImageBlocks(content) : content,
|
| 38 |
+
uuid,
|
| 39 |
+
}
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
/**
|
| 43 |
+
* Normalize image content blocks from bridge clients. iOS/web clients may
|
| 44 |
+
* send `mediaType` (camelCase) instead of `media_type` (snake_case), or
|
| 45 |
+
* omit the field entirely. Without normalization, the bad block poisons
|
| 46 |
+
* the session — every subsequent API call fails with
|
| 47 |
+
* "media_type: Field required".
|
| 48 |
+
*
|
| 49 |
+
* Fast-path scan returns the original array reference when no
|
| 50 |
+
* normalization is needed (zero allocation on the happy path).
|
| 51 |
+
*/
|
| 52 |
+
export function normalizeImageBlocks(
|
| 53 |
+
blocks: Array<ContentBlockParam>,
|
| 54 |
+
): Array<ContentBlockParam> {
|
| 55 |
+
if (!blocks.some(isMalformedBase64Image)) return blocks
|
| 56 |
+
|
| 57 |
+
return blocks.map(block => {
|
| 58 |
+
if (!isMalformedBase64Image(block)) return block
|
| 59 |
+
const src = block.source as unknown as Record<string, unknown>
|
| 60 |
+
const mediaType =
|
| 61 |
+
typeof src.mediaType === 'string' && src.mediaType
|
| 62 |
+
? src.mediaType
|
| 63 |
+
: detectImageFormatFromBase64(block.source.data)
|
| 64 |
+
return {
|
| 65 |
+
...block,
|
| 66 |
+
source: {
|
| 67 |
+
type: 'base64' as const,
|
| 68 |
+
media_type: mediaType as Base64ImageSource['media_type'],
|
| 69 |
+
data: block.source.data,
|
| 70 |
+
},
|
| 71 |
+
}
|
| 72 |
+
})
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
function isMalformedBase64Image(
|
| 76 |
+
block: ContentBlockParam,
|
| 77 |
+
): block is ImageBlockParam & { source: Base64ImageSource } {
|
| 78 |
+
if (block.type !== 'image' || block.source?.type !== 'base64') return false
|
| 79 |
+
return !(block.source as unknown as Record<string, unknown>).media_type
|
| 80 |
+
}
|
src/bridge/initReplBridge.ts
ADDED
|
@@ -0,0 +1,569 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* REPL-specific wrapper around initBridgeCore. Owns the parts that read
|
| 3 |
+
* bootstrap state — gates, cwd, session ID, git context, OAuth, title
|
| 4 |
+
* derivation — then delegates to the bootstrap-free core.
|
| 5 |
+
*
|
| 6 |
+
* Split out of replBridge.ts because the sessionStorage import
|
| 7 |
+
* (getCurrentSessionTitle) transitively pulls in src/commands.ts → the
|
| 8 |
+
* entire slash command + React component tree (~1300 modules). Keeping
|
| 9 |
+
* initBridgeCore in a file that doesn't touch sessionStorage lets
|
| 10 |
+
* daemonBridge.ts import the core without bloating the Agent SDK bundle.
|
| 11 |
+
*
|
| 12 |
+
* Called via dynamic import by useReplBridge (auto-start) and print.ts
|
| 13 |
+
* (SDK -p mode via query.enableRemoteControl).
|
| 14 |
+
*/
|
| 15 |
+
|
| 16 |
+
import { feature } from 'bun:bundle'
|
| 17 |
+
import { hostname } from 'os'
|
| 18 |
+
import { getOriginalCwd, getSessionId } from '../bootstrap/state.js'
|
| 19 |
+
import type { SDKMessage } from '../entrypoints/agentSdkTypes.js'
|
| 20 |
+
import type { SDKControlResponse } from '../entrypoints/sdk/controlTypes.js'
|
| 21 |
+
import { getFeatureValue_CACHED_WITH_REFRESH } from '../services/analytics/growthbook.js'
|
| 22 |
+
import { getOrganizationUUID } from '../services/oauth/client.js'
|
| 23 |
+
import {
|
| 24 |
+
isPolicyAllowed,
|
| 25 |
+
waitForPolicyLimitsToLoad,
|
| 26 |
+
} from '../services/policyLimits/index.js'
|
| 27 |
+
import type { Message } from '../types/message.js'
|
| 28 |
+
import {
|
| 29 |
+
checkAndRefreshOAuthTokenIfNeeded,
|
| 30 |
+
getClaudeAIOAuthTokens,
|
| 31 |
+
handleOAuth401Error,
|
| 32 |
+
} from '../utils/auth.js'
|
| 33 |
+
import { getGlobalConfig, saveGlobalConfig } from '../utils/config.js'
|
| 34 |
+
import { logForDebugging } from '../utils/debug.js'
|
| 35 |
+
import { stripDisplayTagsAllowEmpty } from '../utils/displayTags.js'
|
| 36 |
+
import { errorMessage } from '../utils/errors.js'
|
| 37 |
+
import { getBranch, getRemoteUrl } from '../utils/git.js'
|
| 38 |
+
import { toSDKMessages } from '../utils/messages/mappers.js'
|
| 39 |
+
import {
|
| 40 |
+
getContentText,
|
| 41 |
+
getMessagesAfterCompactBoundary,
|
| 42 |
+
isSyntheticMessage,
|
| 43 |
+
} from '../utils/messages.js'
|
| 44 |
+
import type { PermissionMode } from '../utils/permissions/PermissionMode.js'
|
| 45 |
+
import { getCurrentSessionTitle } from '../utils/sessionStorage.js'
|
| 46 |
+
import {
|
| 47 |
+
extractConversationText,
|
| 48 |
+
generateSessionTitle,
|
| 49 |
+
} from '../utils/sessionTitle.js'
|
| 50 |
+
import { generateShortWordSlug } from '../utils/words.js'
|
| 51 |
+
import {
|
| 52 |
+
getBridgeAccessToken,
|
| 53 |
+
getBridgeBaseUrl,
|
| 54 |
+
getBridgeTokenOverride,
|
| 55 |
+
} from './bridgeConfig.js'
|
| 56 |
+
import {
|
| 57 |
+
checkBridgeMinVersion,
|
| 58 |
+
isBridgeEnabledBlocking,
|
| 59 |
+
isCseShimEnabled,
|
| 60 |
+
isEnvLessBridgeEnabled,
|
| 61 |
+
} from './bridgeEnabled.js'
|
| 62 |
+
import {
|
| 63 |
+
archiveBridgeSession,
|
| 64 |
+
createBridgeSession,
|
| 65 |
+
updateBridgeSessionTitle,
|
| 66 |
+
} from './createSession.js'
|
| 67 |
+
import { logBridgeSkip } from './debugUtils.js'
|
| 68 |
+
import { checkEnvLessBridgeMinVersion } from './envLessBridgeConfig.js'
|
| 69 |
+
import { getPollIntervalConfig } from './pollConfig.js'
|
| 70 |
+
import type { BridgeState, ReplBridgeHandle } from './replBridge.js'
|
| 71 |
+
import { initBridgeCore } from './replBridge.js'
|
| 72 |
+
import { setCseShimGate } from './sessionIdCompat.js'
|
| 73 |
+
import type { BridgeWorkerType } from './types.js'
|
| 74 |
+
|
| 75 |
+
export type InitBridgeOptions = {
|
| 76 |
+
onInboundMessage?: (msg: SDKMessage) => void | Promise<void>
|
| 77 |
+
onPermissionResponse?: (response: SDKControlResponse) => void
|
| 78 |
+
onInterrupt?: () => void
|
| 79 |
+
onSetModel?: (model: string | undefined) => void
|
| 80 |
+
onSetMaxThinkingTokens?: (maxTokens: number | null) => void
|
| 81 |
+
onSetPermissionMode?: (
|
| 82 |
+
mode: PermissionMode,
|
| 83 |
+
) => { ok: true } | { ok: false; error: string }
|
| 84 |
+
onStateChange?: (state: BridgeState, detail?: string) => void
|
| 85 |
+
initialMessages?: Message[]
|
| 86 |
+
// Explicit session name from `/remote-control <name>`. When set, overrides
|
| 87 |
+
// the title derived from the conversation or /rename.
|
| 88 |
+
initialName?: string
|
| 89 |
+
// Fresh view of the full conversation at call time. Used by onUserMessage's
|
| 90 |
+
// count-3 derivation to call generateSessionTitle over the full conversation.
|
| 91 |
+
// Optional — print.ts's SDK enableRemoteControl path has no REPL message
|
| 92 |
+
// array; count-3 falls back to the single message text when absent.
|
| 93 |
+
getMessages?: () => Message[]
|
| 94 |
+
// UUIDs already flushed in a prior bridge session. Messages with these
|
| 95 |
+
// UUIDs are excluded from the initial flush to avoid poisoning the
|
| 96 |
+
// server (duplicate UUIDs across sessions cause the WS to be killed).
|
| 97 |
+
// Mutated in place — newly flushed UUIDs are added after each flush.
|
| 98 |
+
previouslyFlushedUUIDs?: Set<string>
|
| 99 |
+
/** See BridgeCoreParams.perpetual. */
|
| 100 |
+
perpetual?: boolean
|
| 101 |
+
/**
|
| 102 |
+
* When true, the bridge only forwards events outbound (no SSE inbound
|
| 103 |
+
* stream). Used by CCR mirror mode — local sessions visible on claude.ai
|
| 104 |
+
* without enabling inbound control.
|
| 105 |
+
*/
|
| 106 |
+
outboundOnly?: boolean
|
| 107 |
+
tags?: string[]
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
export async function initReplBridge(
|
| 111 |
+
options?: InitBridgeOptions,
|
| 112 |
+
): Promise<ReplBridgeHandle | null> {
|
| 113 |
+
const {
|
| 114 |
+
onInboundMessage,
|
| 115 |
+
onPermissionResponse,
|
| 116 |
+
onInterrupt,
|
| 117 |
+
onSetModel,
|
| 118 |
+
onSetMaxThinkingTokens,
|
| 119 |
+
onSetPermissionMode,
|
| 120 |
+
onStateChange,
|
| 121 |
+
initialMessages,
|
| 122 |
+
getMessages,
|
| 123 |
+
previouslyFlushedUUIDs,
|
| 124 |
+
initialName,
|
| 125 |
+
perpetual,
|
| 126 |
+
outboundOnly,
|
| 127 |
+
tags,
|
| 128 |
+
} = options ?? {}
|
| 129 |
+
|
| 130 |
+
// Wire the cse_ shim kill switch so toCompatSessionId respects the
|
| 131 |
+
// GrowthBook gate. Daemon/SDK paths skip this — shim defaults to active.
|
| 132 |
+
setCseShimGate(isCseShimEnabled)
|
| 133 |
+
|
| 134 |
+
// 1. Runtime gate
|
| 135 |
+
if (!(await isBridgeEnabledBlocking())) {
|
| 136 |
+
logBridgeSkip('not_enabled', '[bridge:repl] Skipping: bridge not enabled')
|
| 137 |
+
return null
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
// 1b. Minimum version check — deferred to after the v1/v2 branch below,
|
| 141 |
+
// since each implementation has its own floor (tengu_bridge_min_version
|
| 142 |
+
// for v1, tengu_bridge_repl_v2_config.min_version for v2).
|
| 143 |
+
|
| 144 |
+
// 2. Check OAuth — must be signed in with claude.ai. Runs before the
|
| 145 |
+
// policy check so console-auth users get the actionable "/login" hint
|
| 146 |
+
// instead of a misleading policy error from a stale/wrong-org cache.
|
| 147 |
+
if (!getBridgeAccessToken()) {
|
| 148 |
+
logBridgeSkip('no_oauth', '[bridge:repl] Skipping: no OAuth tokens')
|
| 149 |
+
onStateChange?.('failed', '/login')
|
| 150 |
+
return null
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
// 3. Check organization policy — remote control may be disabled
|
| 154 |
+
await waitForPolicyLimitsToLoad()
|
| 155 |
+
if (!isPolicyAllowed('allow_remote_control')) {
|
| 156 |
+
logBridgeSkip(
|
| 157 |
+
'policy_denied',
|
| 158 |
+
'[bridge:repl] Skipping: allow_remote_control policy not allowed',
|
| 159 |
+
)
|
| 160 |
+
onStateChange?.('failed', "disabled by your organization's policy")
|
| 161 |
+
return null
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
// When CLAUDE_BRIDGE_OAUTH_TOKEN is set (ant-only local dev), the bridge
|
| 165 |
+
// uses that token directly via getBridgeAccessToken() — keychain state is
|
| 166 |
+
// irrelevant. Skip 2b/2c to preserve that decoupling: an expired keychain
|
| 167 |
+
// token shouldn't block a bridge connection that doesn't use it.
|
| 168 |
+
if (!getBridgeTokenOverride()) {
|
| 169 |
+
// 2a. Cross-process backoff. If N prior processes already saw this exact
|
| 170 |
+
// dead token (matched by expiresAt), skip silently — no event, no refresh
|
| 171 |
+
// attempt. The count threshold tolerates transient refresh failures (auth
|
| 172 |
+
// server 5xx, lockfile errors per auth.ts:1437/1444/1485): each process
|
| 173 |
+
// independently retries until 3 consecutive failures prove the token dead.
|
| 174 |
+
// Mirrors useReplBridge's MAX_CONSECUTIVE_INIT_FAILURES for in-process.
|
| 175 |
+
// The expiresAt key is content-addressed: /login → new token → new expiresAt
|
| 176 |
+
// → this stops matching without any explicit clear.
|
| 177 |
+
const cfg = getGlobalConfig()
|
| 178 |
+
if (
|
| 179 |
+
cfg.bridgeOauthDeadExpiresAt != null &&
|
| 180 |
+
(cfg.bridgeOauthDeadFailCount ?? 0) >= 3 &&
|
| 181 |
+
getClaudeAIOAuthTokens()?.expiresAt === cfg.bridgeOauthDeadExpiresAt
|
| 182 |
+
) {
|
| 183 |
+
logForDebugging(
|
| 184 |
+
`[bridge:repl] Skipping: cross-process backoff (dead token seen ${cfg.bridgeOauthDeadFailCount} times)`,
|
| 185 |
+
)
|
| 186 |
+
return null
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
// 2b. Proactively refresh if expired. Mirrors bridgeMain.ts:2096 — the REPL
|
| 190 |
+
// bridge fires at useEffect mount BEFORE any v1/messages call, making this
|
| 191 |
+
// usually the first OAuth request of the session. Without this, ~9% of
|
| 192 |
+
// registrations hit the server with a >8h-expired token → 401 → withOAuthRetry
|
| 193 |
+
// recovers, but the server logs a 401 we can avoid. VPN egress IPs observed
|
| 194 |
+
// at 30:1 401:200 when many unrelated users cluster at the 8h TTL boundary.
|
| 195 |
+
//
|
| 196 |
+
// Fresh-token cost: one memoized read + one Date.now() comparison (~µs).
|
| 197 |
+
// checkAndRefreshOAuthTokenIfNeeded clears its own cache in every path that
|
| 198 |
+
// touches the keychain (refresh success, lockfile race, throw), so no
|
| 199 |
+
// explicit clearOAuthTokenCache() here — that would force a blocking
|
| 200 |
+
// keychain spawn on the 91%+ fresh-token path.
|
| 201 |
+
await checkAndRefreshOAuthTokenIfNeeded()
|
| 202 |
+
|
| 203 |
+
// 2c. Skip if token is still expired post-refresh-attempt. Env-var / FD
|
| 204 |
+
// tokens (auth.ts:894-917) have expiresAt=null → never trip this. But a
|
| 205 |
+
// keychain token whose refresh token is dead (password change, org left,
|
| 206 |
+
// token GC'd) has expiresAt<now AND refresh just failed — the client would
|
| 207 |
+
// otherwise loop 401 forever: withOAuthRetry → handleOAuth401Error →
|
| 208 |
+
// refresh fails again → retry with same stale token → 401 again.
|
| 209 |
+
// Datadog 2026-03-08: single IPs generating 2,879 such 401s/day. Skip the
|
| 210 |
+
// guaranteed-fail API call; useReplBridge surfaces the failure.
|
| 211 |
+
//
|
| 212 |
+
// Intentionally NOT using isOAuthTokenExpired here — that has a 5-minute
|
| 213 |
+
// proactive-refresh buffer, which is the right heuristic for "should
|
| 214 |
+
// refresh soon" but wrong for "provably unusable". A token with 3min left
|
| 215 |
+
// + transient refresh endpoint blip (5xx/timeout/wifi-reconnect) would
|
| 216 |
+
// falsely trip a buffered check; the still-valid token would connect fine.
|
| 217 |
+
// Check actual expiry instead: past-expiry AND refresh-failed → truly dead.
|
| 218 |
+
const tokens = getClaudeAIOAuthTokens()
|
| 219 |
+
if (tokens && tokens.expiresAt !== null && tokens.expiresAt <= Date.now()) {
|
| 220 |
+
logBridgeSkip(
|
| 221 |
+
'oauth_expired_unrefreshable',
|
| 222 |
+
'[bridge:repl] Skipping: OAuth token expired and refresh failed (re-login required)',
|
| 223 |
+
)
|
| 224 |
+
onStateChange?.('failed', '/login')
|
| 225 |
+
// Persist for the next process. Increments failCount when re-discovering
|
| 226 |
+
// the same dead token (matched by expiresAt); resets to 1 for a different
|
| 227 |
+
// token. Once count reaches 3, step 2a's early-return fires and this path
|
| 228 |
+
// is never reached again — writes are capped at 3 per dead token.
|
| 229 |
+
// Local const captures the narrowed type (closure loses !==null narrowing).
|
| 230 |
+
const deadExpiresAt = tokens.expiresAt
|
| 231 |
+
saveGlobalConfig(c => ({
|
| 232 |
+
...c,
|
| 233 |
+
bridgeOauthDeadExpiresAt: deadExpiresAt,
|
| 234 |
+
bridgeOauthDeadFailCount:
|
| 235 |
+
c.bridgeOauthDeadExpiresAt === deadExpiresAt
|
| 236 |
+
? (c.bridgeOauthDeadFailCount ?? 0) + 1
|
| 237 |
+
: 1,
|
| 238 |
+
}))
|
| 239 |
+
return null
|
| 240 |
+
}
|
| 241 |
+
}
|
| 242 |
+
|
| 243 |
+
// 4. Compute baseUrl — needed by both v1 (env-based) and v2 (env-less)
|
| 244 |
+
// paths. Hoisted above the v2 gate so both can use it.
|
| 245 |
+
const baseUrl = getBridgeBaseUrl()
|
| 246 |
+
|
| 247 |
+
// 5. Derive session title. Precedence: explicit initialName → /rename
|
| 248 |
+
// (session storage) → last meaningful user message → generated slug.
|
| 249 |
+
// Cosmetic only (claude.ai session list); the model never sees it.
|
| 250 |
+
// Two flags: `hasExplicitTitle` (initialName or /rename — never auto-
|
| 251 |
+
// overwrite) vs. `hasTitle` (any title, including auto-derived — blocks
|
| 252 |
+
// the count-1 re-derivation but not count-3). The onUserMessage callback
|
| 253 |
+
// (wired to both v1 and v2 below) derives from the 1st prompt and again
|
| 254 |
+
// from the 3rd so mobile/web show a title that reflects more context.
|
| 255 |
+
// The slug fallback (e.g. "remote-control-graceful-unicorn") makes
|
| 256 |
+
// auto-started sessions distinguishable in the claude.ai list before the
|
| 257 |
+
// first prompt.
|
| 258 |
+
let title = `remote-control-${generateShortWordSlug()}`
|
| 259 |
+
let hasTitle = false
|
| 260 |
+
let hasExplicitTitle = false
|
| 261 |
+
if (initialName) {
|
| 262 |
+
title = initialName
|
| 263 |
+
hasTitle = true
|
| 264 |
+
hasExplicitTitle = true
|
| 265 |
+
} else {
|
| 266 |
+
const sessionId = getSessionId()
|
| 267 |
+
const customTitle = sessionId
|
| 268 |
+
? getCurrentSessionTitle(sessionId)
|
| 269 |
+
: undefined
|
| 270 |
+
if (customTitle) {
|
| 271 |
+
title = customTitle
|
| 272 |
+
hasTitle = true
|
| 273 |
+
hasExplicitTitle = true
|
| 274 |
+
} else if (initialMessages && initialMessages.length > 0) {
|
| 275 |
+
// Find the last user message that has meaningful content. Skip meta
|
| 276 |
+
// (nudges), tool results, compact summaries ("This session is being
|
| 277 |
+
// continued…"), non-human origins (task notifications, channel pushes),
|
| 278 |
+
// and synthetic interrupts ([Request interrupted by user]) — none are
|
| 279 |
+
// human-authored. Same filter as extractTitleText + isSyntheticMessage.
|
| 280 |
+
for (let i = initialMessages.length - 1; i >= 0; i--) {
|
| 281 |
+
const msg = initialMessages[i]!
|
| 282 |
+
if (
|
| 283 |
+
msg.type !== 'user' ||
|
| 284 |
+
msg.isMeta ||
|
| 285 |
+
msg.toolUseResult ||
|
| 286 |
+
msg.isCompactSummary ||
|
| 287 |
+
(msg.origin && msg.origin.kind !== 'human') ||
|
| 288 |
+
isSyntheticMessage(msg)
|
| 289 |
+
)
|
| 290 |
+
continue
|
| 291 |
+
const rawContent = getContentText(msg.message.content)
|
| 292 |
+
if (!rawContent) continue
|
| 293 |
+
const derived = deriveTitle(rawContent)
|
| 294 |
+
if (!derived) continue
|
| 295 |
+
title = derived
|
| 296 |
+
hasTitle = true
|
| 297 |
+
break
|
| 298 |
+
}
|
| 299 |
+
}
|
| 300 |
+
}
|
| 301 |
+
|
| 302 |
+
// Shared by both v1 and v2 — fires on every title-worthy user message until
|
| 303 |
+
// it returns true. At count 1: deriveTitle placeholder immediately, then
|
| 304 |
+
// generateSessionTitle (Haiku, sentence-case) fire-and-forget upgrade. At
|
| 305 |
+
// count 3: re-generate over the full conversation. Skips entirely if the
|
| 306 |
+
// title is explicit (/remote-control <name> or /rename) — re-checks
|
| 307 |
+
// sessionStorage at call time so /rename between messages isn't clobbered.
|
| 308 |
+
// Skips count 1 if initialMessages already derived (that title is fresh);
|
| 309 |
+
// still refreshes at count 3. v2 passes cse_*; updateBridgeSessionTitle
|
| 310 |
+
// retags internally.
|
| 311 |
+
let userMessageCount = 0
|
| 312 |
+
let lastBridgeSessionId: string | undefined
|
| 313 |
+
let genSeq = 0
|
| 314 |
+
const patch = (
|
| 315 |
+
derived: string,
|
| 316 |
+
bridgeSessionId: string,
|
| 317 |
+
atCount: number,
|
| 318 |
+
): void => {
|
| 319 |
+
hasTitle = true
|
| 320 |
+
title = derived
|
| 321 |
+
logForDebugging(
|
| 322 |
+
`[bridge:repl] derived title from message ${atCount}: ${derived}`,
|
| 323 |
+
)
|
| 324 |
+
void updateBridgeSessionTitle(bridgeSessionId, derived, {
|
| 325 |
+
baseUrl,
|
| 326 |
+
getAccessToken: getBridgeAccessToken,
|
| 327 |
+
}).catch(() => {})
|
| 328 |
+
}
|
| 329 |
+
// Fire-and-forget Haiku generation with post-await guards. Re-checks /rename
|
| 330 |
+
// (sessionStorage), v1 env-lost (lastBridgeSessionId), and same-session
|
| 331 |
+
// out-of-order resolution (genSeq — count-1's Haiku resolving after count-3
|
| 332 |
+
// would clobber the richer title). generateSessionTitle never rejects.
|
| 333 |
+
const generateAndPatch = (input: string, bridgeSessionId: string): void => {
|
| 334 |
+
const gen = ++genSeq
|
| 335 |
+
const atCount = userMessageCount
|
| 336 |
+
void generateSessionTitle(input, AbortSignal.timeout(15_000)).then(
|
| 337 |
+
generated => {
|
| 338 |
+
if (
|
| 339 |
+
generated &&
|
| 340 |
+
gen === genSeq &&
|
| 341 |
+
lastBridgeSessionId === bridgeSessionId &&
|
| 342 |
+
!getCurrentSessionTitle(getSessionId())
|
| 343 |
+
) {
|
| 344 |
+
patch(generated, bridgeSessionId, atCount)
|
| 345 |
+
}
|
| 346 |
+
},
|
| 347 |
+
)
|
| 348 |
+
}
|
| 349 |
+
const onUserMessage = (text: string, bridgeSessionId: string): boolean => {
|
| 350 |
+
if (hasExplicitTitle || getCurrentSessionTitle(getSessionId())) {
|
| 351 |
+
return true
|
| 352 |
+
}
|
| 353 |
+
// v1 env-lost re-creates the session with a new ID. Reset the count so
|
| 354 |
+
// the new session gets its own count-3 derivation; hasTitle stays true
|
| 355 |
+
// (new session was created via getCurrentTitle(), which reads the count-1
|
| 356 |
+
// title from this closure), so count-1 of the fresh cycle correctly skips.
|
| 357 |
+
if (
|
| 358 |
+
lastBridgeSessionId !== undefined &&
|
| 359 |
+
lastBridgeSessionId !== bridgeSessionId
|
| 360 |
+
) {
|
| 361 |
+
userMessageCount = 0
|
| 362 |
+
}
|
| 363 |
+
lastBridgeSessionId = bridgeSessionId
|
| 364 |
+
userMessageCount++
|
| 365 |
+
if (userMessageCount === 1 && !hasTitle) {
|
| 366 |
+
const placeholder = deriveTitle(text)
|
| 367 |
+
if (placeholder) patch(placeholder, bridgeSessionId, userMessageCount)
|
| 368 |
+
generateAndPatch(text, bridgeSessionId)
|
| 369 |
+
} else if (userMessageCount === 3) {
|
| 370 |
+
const msgs = getMessages?.()
|
| 371 |
+
const input = msgs
|
| 372 |
+
? extractConversationText(getMessagesAfterCompactBoundary(msgs))
|
| 373 |
+
: text
|
| 374 |
+
generateAndPatch(input, bridgeSessionId)
|
| 375 |
+
}
|
| 376 |
+
// Also re-latches if v1 env-lost resets the transport's done flag past 3.
|
| 377 |
+
return userMessageCount >= 3
|
| 378 |
+
}
|
| 379 |
+
|
| 380 |
+
const initialHistoryCap = getFeatureValue_CACHED_WITH_REFRESH(
|
| 381 |
+
'tengu_bridge_initial_history_cap',
|
| 382 |
+
200,
|
| 383 |
+
5 * 60 * 1000,
|
| 384 |
+
)
|
| 385 |
+
|
| 386 |
+
// Fetch orgUUID before the v1/v2 branch — both paths need it. v1 for
|
| 387 |
+
// environment registration; v2 for archive (which lives at the compat
|
| 388 |
+
// /v1/sessions/{id}/archive, not /v1/code/sessions). Without it, v2
|
| 389 |
+
// archive 404s and sessions stay alive in CCR after /exit.
|
| 390 |
+
const orgUUID = await getOrganizationUUID()
|
| 391 |
+
if (!orgUUID) {
|
| 392 |
+
logBridgeSkip('no_org_uuid', '[bridge:repl] Skipping: no org UUID')
|
| 393 |
+
onStateChange?.('failed', '/login')
|
| 394 |
+
return null
|
| 395 |
+
}
|
| 396 |
+
|
| 397 |
+
// ── GrowthBook gate: env-less bridge ──────────────────────────────────
|
| 398 |
+
// When enabled, skips the Environments API layer entirely (no register/
|
| 399 |
+
// poll/ack/heartbeat) and connects directly via POST /bridge → worker_jwt.
|
| 400 |
+
// See server PR #292605 (renamed in #293280). REPL-only — daemon/print stay
|
| 401 |
+
// on env-based.
|
| 402 |
+
//
|
| 403 |
+
// NAMING: "env-less" is distinct from "CCR v2" (the /worker/* transport).
|
| 404 |
+
// The env-based path below can ALSO use CCR v2 via CLAUDE_CODE_USE_CCR_V2.
|
| 405 |
+
// tengu_bridge_repl_v2 gates env-less (no poll loop), not transport version.
|
| 406 |
+
//
|
| 407 |
+
// perpetual (assistant-mode session continuity via bridge-pointer.json) is
|
| 408 |
+
// env-coupled and not yet implemented here — fall back to env-based when set
|
| 409 |
+
// so KAIROS users don't silently lose cross-restart continuity.
|
| 410 |
+
if (isEnvLessBridgeEnabled() && !perpetual) {
|
| 411 |
+
const versionError = await checkEnvLessBridgeMinVersion()
|
| 412 |
+
if (versionError) {
|
| 413 |
+
logBridgeSkip(
|
| 414 |
+
'version_too_old',
|
| 415 |
+
`[bridge:repl] Skipping: ${versionError}`,
|
| 416 |
+
true,
|
| 417 |
+
)
|
| 418 |
+
onStateChange?.('failed', 'run `claude update` to upgrade')
|
| 419 |
+
return null
|
| 420 |
+
}
|
| 421 |
+
logForDebugging(
|
| 422 |
+
'[bridge:repl] Using env-less bridge path (tengu_bridge_repl_v2)',
|
| 423 |
+
)
|
| 424 |
+
const { initEnvLessBridgeCore } = await import('./remoteBridgeCore.js')
|
| 425 |
+
return initEnvLessBridgeCore({
|
| 426 |
+
baseUrl,
|
| 427 |
+
orgUUID,
|
| 428 |
+
title,
|
| 429 |
+
getAccessToken: getBridgeAccessToken,
|
| 430 |
+
onAuth401: handleOAuth401Error,
|
| 431 |
+
toSDKMessages,
|
| 432 |
+
initialHistoryCap,
|
| 433 |
+
initialMessages,
|
| 434 |
+
// v2 always creates a fresh server session (new cse_* id), so
|
| 435 |
+
// previouslyFlushedUUIDs is not passed — there's no cross-session
|
| 436 |
+
// UUID collision risk, and the ref persists across enable→disable→
|
| 437 |
+
// re-enable cycles which would cause the new session to receive zero
|
| 438 |
+
// history (all UUIDs already in the set from the prior enable).
|
| 439 |
+
// v1 handles this by calling previouslyFlushedUUIDs.clear() on fresh
|
| 440 |
+
// session creation (replBridge.ts:768); v2 skips the param entirely.
|
| 441 |
+
onInboundMessage,
|
| 442 |
+
onUserMessage,
|
| 443 |
+
onPermissionResponse,
|
| 444 |
+
onInterrupt,
|
| 445 |
+
onSetModel,
|
| 446 |
+
onSetMaxThinkingTokens,
|
| 447 |
+
onSetPermissionMode,
|
| 448 |
+
onStateChange,
|
| 449 |
+
outboundOnly,
|
| 450 |
+
tags,
|
| 451 |
+
})
|
| 452 |
+
}
|
| 453 |
+
|
| 454 |
+
// ── v1 path: env-based (register/poll/ack/heartbeat) ──────────────────
|
| 455 |
+
|
| 456 |
+
const versionError = checkBridgeMinVersion()
|
| 457 |
+
if (versionError) {
|
| 458 |
+
logBridgeSkip('version_too_old', `[bridge:repl] Skipping: ${versionError}`)
|
| 459 |
+
onStateChange?.('failed', 'run `claude update` to upgrade')
|
| 460 |
+
return null
|
| 461 |
+
}
|
| 462 |
+
|
| 463 |
+
// Gather git context — this is the bootstrap-read boundary.
|
| 464 |
+
// Everything from here down is passed explicitly to bridgeCore.
|
| 465 |
+
const branch = await getBranch()
|
| 466 |
+
const gitRepoUrl = await getRemoteUrl()
|
| 467 |
+
const sessionIngressUrl =
|
| 468 |
+
process.env.USER_TYPE === 'ant' &&
|
| 469 |
+
process.env.CLAUDE_BRIDGE_SESSION_INGRESS_URL
|
| 470 |
+
? process.env.CLAUDE_BRIDGE_SESSION_INGRESS_URL
|
| 471 |
+
: baseUrl
|
| 472 |
+
|
| 473 |
+
// Assistant-mode sessions advertise a distinct worker_type so the web UI
|
| 474 |
+
// can filter them into a dedicated picker. KAIROS guard keeps the
|
| 475 |
+
// assistant module out of external builds entirely.
|
| 476 |
+
let workerType: BridgeWorkerType = 'claude_code'
|
| 477 |
+
if (feature('KAIROS')) {
|
| 478 |
+
/* eslint-disable @typescript-eslint/no-require-imports */
|
| 479 |
+
const { isAssistantMode } =
|
| 480 |
+
require('../assistant/index.js') as typeof import('../assistant/index.js')
|
| 481 |
+
/* eslint-enable @typescript-eslint/no-require-imports */
|
| 482 |
+
if (isAssistantMode()) {
|
| 483 |
+
workerType = 'claude_code_assistant'
|
| 484 |
+
}
|
| 485 |
+
}
|
| 486 |
+
|
| 487 |
+
// 6. Delegate. BridgeCoreHandle is a structural superset of
|
| 488 |
+
// ReplBridgeHandle (adds writeSdkMessages which REPL callers don't use),
|
| 489 |
+
// so no adapter needed — just the narrower type on the way out.
|
| 490 |
+
return initBridgeCore({
|
| 491 |
+
dir: getOriginalCwd(),
|
| 492 |
+
machineName: hostname(),
|
| 493 |
+
branch,
|
| 494 |
+
gitRepoUrl,
|
| 495 |
+
title,
|
| 496 |
+
baseUrl,
|
| 497 |
+
sessionIngressUrl,
|
| 498 |
+
workerType,
|
| 499 |
+
getAccessToken: getBridgeAccessToken,
|
| 500 |
+
createSession: opts =>
|
| 501 |
+
createBridgeSession({
|
| 502 |
+
...opts,
|
| 503 |
+
events: [],
|
| 504 |
+
baseUrl,
|
| 505 |
+
getAccessToken: getBridgeAccessToken,
|
| 506 |
+
}),
|
| 507 |
+
archiveSession: sessionId =>
|
| 508 |
+
archiveBridgeSession(sessionId, {
|
| 509 |
+
baseUrl,
|
| 510 |
+
getAccessToken: getBridgeAccessToken,
|
| 511 |
+
// gracefulShutdown.ts:407 races runCleanupFunctions against 2s.
|
| 512 |
+
// Teardown also does stopWork (parallel) + deregister (sequential),
|
| 513 |
+
// so archive can't have the full budget. 1.5s matches v2's
|
| 514 |
+
// teardown_archive_timeout_ms default.
|
| 515 |
+
timeoutMs: 1500,
|
| 516 |
+
}).catch((err: unknown) => {
|
| 517 |
+
// archiveBridgeSession has no try/catch — 5xx/timeout/network throw
|
| 518 |
+
// straight through. Previously swallowed silently, making archive
|
| 519 |
+
// failures BQ-invisible and undiagnosable from debug logs.
|
| 520 |
+
logForDebugging(
|
| 521 |
+
`[bridge:repl] archiveBridgeSession threw: ${errorMessage(err)}`,
|
| 522 |
+
{ level: 'error' },
|
| 523 |
+
)
|
| 524 |
+
}),
|
| 525 |
+
// getCurrentTitle is read on reconnect-after-env-lost to re-title the new
|
| 526 |
+
// session. /rename writes to session storage; onUserMessage mutates
|
| 527 |
+
// `title` directly — both paths are picked up here.
|
| 528 |
+
getCurrentTitle: () => getCurrentSessionTitle(getSessionId()) ?? title,
|
| 529 |
+
onUserMessage,
|
| 530 |
+
toSDKMessages,
|
| 531 |
+
onAuth401: handleOAuth401Error,
|
| 532 |
+
getPollIntervalConfig,
|
| 533 |
+
initialHistoryCap,
|
| 534 |
+
initialMessages,
|
| 535 |
+
previouslyFlushedUUIDs,
|
| 536 |
+
onInboundMessage,
|
| 537 |
+
onPermissionResponse,
|
| 538 |
+
onInterrupt,
|
| 539 |
+
onSetModel,
|
| 540 |
+
onSetMaxThinkingTokens,
|
| 541 |
+
onSetPermissionMode,
|
| 542 |
+
onStateChange,
|
| 543 |
+
perpetual,
|
| 544 |
+
})
|
| 545 |
+
}
|
| 546 |
+
|
| 547 |
+
const TITLE_MAX_LEN = 50
|
| 548 |
+
|
| 549 |
+
/**
|
| 550 |
+
* Quick placeholder title: strip display tags, take the first sentence,
|
| 551 |
+
* collapse whitespace, truncate to 50 chars. Returns undefined if the result
|
| 552 |
+
* is empty (e.g. message was only <local-command-stdout>). Replaced by
|
| 553 |
+
* generateSessionTitle once Haiku resolves (~1-15s).
|
| 554 |
+
*/
|
| 555 |
+
function deriveTitle(raw: string): string | undefined {
|
| 556 |
+
// Strip <ide_opened_file>, <session-start-hook>, etc. — these appear in
|
| 557 |
+
// user messages when IDE/hooks inject context. stripDisplayTagsAllowEmpty
|
| 558 |
+
// returns '' (not the original) so pure-tag messages are skipped.
|
| 559 |
+
const clean = stripDisplayTagsAllowEmpty(raw)
|
| 560 |
+
// First sentence is usually the intent; rest is often context/detail.
|
| 561 |
+
// Capture group instead of lookbehind — keeps YARR JIT happy.
|
| 562 |
+
const firstSentence = /^(.*?[.!?])\s/.exec(clean)?.[1] ?? clean
|
| 563 |
+
// Collapse newlines/tabs — titles are single-line in the claude.ai list.
|
| 564 |
+
const flat = firstSentence.replace(/\s+/g, ' ').trim()
|
| 565 |
+
if (!flat) return undefined
|
| 566 |
+
return flat.length > TITLE_MAX_LEN
|
| 567 |
+
? flat.slice(0, TITLE_MAX_LEN - 1) + '\u2026'
|
| 568 |
+
: flat
|
| 569 |
+
}
|
src/bridge/jwtUtils.ts
ADDED
|
@@ -0,0 +1,256 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { logEvent } from '../services/analytics/index.js'
|
| 2 |
+
import { logForDebugging } from '../utils/debug.js'
|
| 3 |
+
import { logForDiagnosticsNoPII } from '../utils/diagLogs.js'
|
| 4 |
+
import { errorMessage } from '../utils/errors.js'
|
| 5 |
+
import { jsonParse } from '../utils/slowOperations.js'
|
| 6 |
+
|
| 7 |
+
/** Format a millisecond duration as a human-readable string (e.g. "5m 30s"). */
|
| 8 |
+
function formatDuration(ms: number): string {
|
| 9 |
+
if (ms < 60_000) return `${Math.round(ms / 1000)}s`
|
| 10 |
+
const m = Math.floor(ms / 60_000)
|
| 11 |
+
const s = Math.round((ms % 60_000) / 1000)
|
| 12 |
+
return s > 0 ? `${m}m ${s}s` : `${m}m`
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
/**
|
| 16 |
+
* Decode a JWT's payload segment without verifying the signature.
|
| 17 |
+
* Strips the `sk-ant-si-` session-ingress prefix if present.
|
| 18 |
+
* Returns the parsed JSON payload as `unknown`, or `null` if the
|
| 19 |
+
* token is malformed or the payload is not valid JSON.
|
| 20 |
+
*/
|
| 21 |
+
export function decodeJwtPayload(token: string): unknown | null {
|
| 22 |
+
const jwt = token.startsWith('sk-ant-si-')
|
| 23 |
+
? token.slice('sk-ant-si-'.length)
|
| 24 |
+
: token
|
| 25 |
+
const parts = jwt.split('.')
|
| 26 |
+
if (parts.length !== 3 || !parts[1]) return null
|
| 27 |
+
try {
|
| 28 |
+
return jsonParse(Buffer.from(parts[1], 'base64url').toString('utf8'))
|
| 29 |
+
} catch {
|
| 30 |
+
return null
|
| 31 |
+
}
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
/**
|
| 35 |
+
* Decode the `exp` (expiry) claim from a JWT without verifying the signature.
|
| 36 |
+
* @returns The `exp` value in Unix seconds, or `null` if unparseable
|
| 37 |
+
*/
|
| 38 |
+
export function decodeJwtExpiry(token: string): number | null {
|
| 39 |
+
const payload = decodeJwtPayload(token)
|
| 40 |
+
if (
|
| 41 |
+
payload !== null &&
|
| 42 |
+
typeof payload === 'object' &&
|
| 43 |
+
'exp' in payload &&
|
| 44 |
+
typeof payload.exp === 'number'
|
| 45 |
+
) {
|
| 46 |
+
return payload.exp
|
| 47 |
+
}
|
| 48 |
+
return null
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
/** Refresh buffer: request a new token before expiry. */
|
| 52 |
+
const TOKEN_REFRESH_BUFFER_MS = 5 * 60 * 1000
|
| 53 |
+
|
| 54 |
+
/** Fallback refresh interval when the new token's expiry is unknown. */
|
| 55 |
+
const FALLBACK_REFRESH_INTERVAL_MS = 30 * 60 * 1000 // 30 minutes
|
| 56 |
+
|
| 57 |
+
/** Max consecutive failures before giving up on the refresh chain. */
|
| 58 |
+
const MAX_REFRESH_FAILURES = 3
|
| 59 |
+
|
| 60 |
+
/** Retry delay when getAccessToken returns undefined. */
|
| 61 |
+
const REFRESH_RETRY_DELAY_MS = 60_000
|
| 62 |
+
|
| 63 |
+
/**
|
| 64 |
+
* Creates a token refresh scheduler that proactively refreshes session tokens
|
| 65 |
+
* before they expire. Used by both the standalone bridge and the REPL bridge.
|
| 66 |
+
*
|
| 67 |
+
* When a token is about to expire, the scheduler calls `onRefresh` with the
|
| 68 |
+
* session ID and the bridge's OAuth access token. The caller is responsible
|
| 69 |
+
* for delivering the token to the appropriate transport (child process stdin
|
| 70 |
+
* for standalone bridge, WebSocket reconnect for REPL bridge).
|
| 71 |
+
*/
|
| 72 |
+
export function createTokenRefreshScheduler({
|
| 73 |
+
getAccessToken,
|
| 74 |
+
onRefresh,
|
| 75 |
+
label,
|
| 76 |
+
refreshBufferMs = TOKEN_REFRESH_BUFFER_MS,
|
| 77 |
+
}: {
|
| 78 |
+
getAccessToken: () => string | undefined | Promise<string | undefined>
|
| 79 |
+
onRefresh: (sessionId: string, oauthToken: string) => void
|
| 80 |
+
label: string
|
| 81 |
+
/** How long before expiry to fire refresh. Defaults to 5 min. */
|
| 82 |
+
refreshBufferMs?: number
|
| 83 |
+
}): {
|
| 84 |
+
schedule: (sessionId: string, token: string) => void
|
| 85 |
+
scheduleFromExpiresIn: (sessionId: string, expiresInSeconds: number) => void
|
| 86 |
+
cancel: (sessionId: string) => void
|
| 87 |
+
cancelAll: () => void
|
| 88 |
+
} {
|
| 89 |
+
const timers = new Map<string, ReturnType<typeof setTimeout>>()
|
| 90 |
+
const failureCounts = new Map<string, number>()
|
| 91 |
+
// Generation counter per session — incremented by schedule() and cancel()
|
| 92 |
+
// so that in-flight async doRefresh() calls can detect when they've been
|
| 93 |
+
// superseded and should skip setting follow-up timers.
|
| 94 |
+
const generations = new Map<string, number>()
|
| 95 |
+
|
| 96 |
+
function nextGeneration(sessionId: string): number {
|
| 97 |
+
const gen = (generations.get(sessionId) ?? 0) + 1
|
| 98 |
+
generations.set(sessionId, gen)
|
| 99 |
+
return gen
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
function schedule(sessionId: string, token: string): void {
|
| 103 |
+
const expiry = decodeJwtExpiry(token)
|
| 104 |
+
if (!expiry) {
|
| 105 |
+
// Token is not a decodable JWT (e.g. an OAuth token passed from the
|
| 106 |
+
// REPL bridge WebSocket open handler). Preserve any existing timer
|
| 107 |
+
// (such as the follow-up refresh set by doRefresh) so the refresh
|
| 108 |
+
// chain is not broken.
|
| 109 |
+
logForDebugging(
|
| 110 |
+
`[${label}:token] Could not decode JWT expiry for sessionId=${sessionId}, token prefix=${token.slice(0, 15)}…, keeping existing timer`,
|
| 111 |
+
)
|
| 112 |
+
return
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
// Clear any existing refresh timer — we have a concrete expiry to replace it.
|
| 116 |
+
const existing = timers.get(sessionId)
|
| 117 |
+
if (existing) {
|
| 118 |
+
clearTimeout(existing)
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
// Bump generation to invalidate any in-flight async doRefresh.
|
| 122 |
+
const gen = nextGeneration(sessionId)
|
| 123 |
+
|
| 124 |
+
const expiryDate = new Date(expiry * 1000).toISOString()
|
| 125 |
+
const delayMs = expiry * 1000 - Date.now() - refreshBufferMs
|
| 126 |
+
if (delayMs <= 0) {
|
| 127 |
+
logForDebugging(
|
| 128 |
+
`[${label}:token] Token for sessionId=${sessionId} expires=${expiryDate} (past or within buffer), refreshing immediately`,
|
| 129 |
+
)
|
| 130 |
+
void doRefresh(sessionId, gen)
|
| 131 |
+
return
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
logForDebugging(
|
| 135 |
+
`[${label}:token] Scheduled token refresh for sessionId=${sessionId} in ${formatDuration(delayMs)} (expires=${expiryDate}, buffer=${refreshBufferMs / 1000}s)`,
|
| 136 |
+
)
|
| 137 |
+
|
| 138 |
+
const timer = setTimeout(doRefresh, delayMs, sessionId, gen)
|
| 139 |
+
timers.set(sessionId, timer)
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
/**
|
| 143 |
+
* Schedule refresh using an explicit TTL (seconds until expiry) rather
|
| 144 |
+
* than decoding a JWT's exp claim. Used by callers whose JWT is opaque
|
| 145 |
+
* (e.g. POST /v1/code/sessions/{id}/bridge returns expires_in directly).
|
| 146 |
+
*/
|
| 147 |
+
function scheduleFromExpiresIn(
|
| 148 |
+
sessionId: string,
|
| 149 |
+
expiresInSeconds: number,
|
| 150 |
+
): void {
|
| 151 |
+
const existing = timers.get(sessionId)
|
| 152 |
+
if (existing) clearTimeout(existing)
|
| 153 |
+
const gen = nextGeneration(sessionId)
|
| 154 |
+
// Clamp to 30s floor — if refreshBufferMs exceeds the server's expires_in
|
| 155 |
+
// (e.g. very large buffer for frequent-refresh testing, or server shortens
|
| 156 |
+
// expires_in unexpectedly), unclamped delayMs ≤ 0 would tight-loop.
|
| 157 |
+
const delayMs = Math.max(expiresInSeconds * 1000 - refreshBufferMs, 30_000)
|
| 158 |
+
logForDebugging(
|
| 159 |
+
`[${label}:token] Scheduled token refresh for sessionId=${sessionId} in ${formatDuration(delayMs)} (expires_in=${expiresInSeconds}s, buffer=${refreshBufferMs / 1000}s)`,
|
| 160 |
+
)
|
| 161 |
+
const timer = setTimeout(doRefresh, delayMs, sessionId, gen)
|
| 162 |
+
timers.set(sessionId, timer)
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
async function doRefresh(sessionId: string, gen: number): Promise<void> {
|
| 166 |
+
let oauthToken: string | undefined
|
| 167 |
+
try {
|
| 168 |
+
oauthToken = await getAccessToken()
|
| 169 |
+
} catch (err) {
|
| 170 |
+
logForDebugging(
|
| 171 |
+
`[${label}:token] getAccessToken threw for sessionId=${sessionId}: ${errorMessage(err)}`,
|
| 172 |
+
{ level: 'error' },
|
| 173 |
+
)
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
// If the session was cancelled or rescheduled while we were awaiting,
|
| 177 |
+
// the generation will have changed — bail out to avoid orphaned timers.
|
| 178 |
+
if (generations.get(sessionId) !== gen) {
|
| 179 |
+
logForDebugging(
|
| 180 |
+
`[${label}:token] doRefresh for sessionId=${sessionId} stale (gen ${gen} vs ${generations.get(sessionId)}), skipping`,
|
| 181 |
+
)
|
| 182 |
+
return
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
if (!oauthToken) {
|
| 186 |
+
const failures = (failureCounts.get(sessionId) ?? 0) + 1
|
| 187 |
+
failureCounts.set(sessionId, failures)
|
| 188 |
+
logForDebugging(
|
| 189 |
+
`[${label}:token] No OAuth token available for refresh, sessionId=${sessionId} (failure ${failures}/${MAX_REFRESH_FAILURES})`,
|
| 190 |
+
{ level: 'error' },
|
| 191 |
+
)
|
| 192 |
+
logForDiagnosticsNoPII('error', 'bridge_token_refresh_no_oauth')
|
| 193 |
+
// Schedule a retry so the refresh chain can recover if the token
|
| 194 |
+
// becomes available again (e.g. transient cache clear during refresh).
|
| 195 |
+
// Cap retries to avoid spamming on genuine failures.
|
| 196 |
+
if (failures < MAX_REFRESH_FAILURES) {
|
| 197 |
+
const retryTimer = setTimeout(
|
| 198 |
+
doRefresh,
|
| 199 |
+
REFRESH_RETRY_DELAY_MS,
|
| 200 |
+
sessionId,
|
| 201 |
+
gen,
|
| 202 |
+
)
|
| 203 |
+
timers.set(sessionId, retryTimer)
|
| 204 |
+
}
|
| 205 |
+
return
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
// Reset failure counter on successful token retrieval
|
| 209 |
+
failureCounts.delete(sessionId)
|
| 210 |
+
|
| 211 |
+
logForDebugging(
|
| 212 |
+
`[${label}:token] Refreshing token for sessionId=${sessionId}: new token prefix=${oauthToken.slice(0, 15)}…`,
|
| 213 |
+
)
|
| 214 |
+
logEvent('tengu_bridge_token_refreshed', {})
|
| 215 |
+
onRefresh(sessionId, oauthToken)
|
| 216 |
+
|
| 217 |
+
// Schedule a follow-up refresh so long-running sessions stay authenticated.
|
| 218 |
+
// Without this, the initial one-shot timer leaves the session vulnerable
|
| 219 |
+
// to token expiry if it runs past the first refresh window.
|
| 220 |
+
const timer = setTimeout(
|
| 221 |
+
doRefresh,
|
| 222 |
+
FALLBACK_REFRESH_INTERVAL_MS,
|
| 223 |
+
sessionId,
|
| 224 |
+
gen,
|
| 225 |
+
)
|
| 226 |
+
timers.set(sessionId, timer)
|
| 227 |
+
logForDebugging(
|
| 228 |
+
`[${label}:token] Scheduled follow-up refresh for sessionId=${sessionId} in ${formatDuration(FALLBACK_REFRESH_INTERVAL_MS)}`,
|
| 229 |
+
)
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
function cancel(sessionId: string): void {
|
| 233 |
+
// Bump generation to invalidate any in-flight async doRefresh.
|
| 234 |
+
nextGeneration(sessionId)
|
| 235 |
+
const timer = timers.get(sessionId)
|
| 236 |
+
if (timer) {
|
| 237 |
+
clearTimeout(timer)
|
| 238 |
+
timers.delete(sessionId)
|
| 239 |
+
}
|
| 240 |
+
failureCounts.delete(sessionId)
|
| 241 |
+
}
|
| 242 |
+
|
| 243 |
+
function cancelAll(): void {
|
| 244 |
+
// Bump all generations so in-flight doRefresh calls are invalidated.
|
| 245 |
+
for (const sessionId of generations.keys()) {
|
| 246 |
+
nextGeneration(sessionId)
|
| 247 |
+
}
|
| 248 |
+
for (const timer of timers.values()) {
|
| 249 |
+
clearTimeout(timer)
|
| 250 |
+
}
|
| 251 |
+
timers.clear()
|
| 252 |
+
failureCounts.clear()
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
return { schedule, scheduleFromExpiresIn, cancel, cancelAll }
|
| 256 |
+
}
|
src/bridge/pollConfig.ts
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { z } from 'zod/v4'
|
| 2 |
+
import { getFeatureValue_CACHED_WITH_REFRESH } from '../services/analytics/growthbook.js'
|
| 3 |
+
import { lazySchema } from '../utils/lazySchema.js'
|
| 4 |
+
import {
|
| 5 |
+
DEFAULT_POLL_CONFIG,
|
| 6 |
+
type PollIntervalConfig,
|
| 7 |
+
} from './pollConfigDefaults.js'
|
| 8 |
+
|
| 9 |
+
// .min(100) on the seek-work intervals restores the old Math.max(..., 100)
|
| 10 |
+
// defense-in-depth floor against fat-fingered GrowthBook values. Unlike a
|
| 11 |
+
// clamp, Zod rejects the whole object on violation — a config with one bad
|
| 12 |
+
// field falls back to DEFAULT_POLL_CONFIG entirely rather than being
|
| 13 |
+
// partially trusted.
|
| 14 |
+
//
|
| 15 |
+
// The at_capacity intervals use a 0-or-≥100 refinement: 0 means "disabled"
|
| 16 |
+
// (heartbeat-only mode), ≥100 is the fat-finger floor. Values 1–99 are
|
| 17 |
+
// rejected so unit confusion (ops thinks seconds, enters 10) doesn't poll
|
| 18 |
+
// every 10ms against the VerifyEnvironmentSecretAuth DB path.
|
| 19 |
+
//
|
| 20 |
+
// The object-level refines require at least one at-capacity liveness
|
| 21 |
+
// mechanism enabled: heartbeat OR the relevant poll interval. Without this,
|
| 22 |
+
// the hb=0, atCapMs=0 drift config (ops disables heartbeat without
|
| 23 |
+
// restoring at_capacity) falls through every throttle site with no sleep —
|
| 24 |
+
// tight-looping /poll at HTTP-round-trip speed.
|
| 25 |
+
const zeroOrAtLeast100 = {
|
| 26 |
+
message: 'must be 0 (disabled) or ≥100ms',
|
| 27 |
+
}
|
| 28 |
+
const pollIntervalConfigSchema = lazySchema(() =>
|
| 29 |
+
z
|
| 30 |
+
.object({
|
| 31 |
+
poll_interval_ms_not_at_capacity: z.number().int().min(100),
|
| 32 |
+
// 0 = no at-capacity polling. Independent of heartbeat — both can be
|
| 33 |
+
// enabled (heartbeat runs, periodically breaks out to poll).
|
| 34 |
+
poll_interval_ms_at_capacity: z
|
| 35 |
+
.number()
|
| 36 |
+
.int()
|
| 37 |
+
.refine(v => v === 0 || v >= 100, zeroOrAtLeast100),
|
| 38 |
+
// 0 = disabled; positive value = heartbeat at this interval while at
|
| 39 |
+
// capacity. Runs alongside at-capacity polling, not instead of it.
|
| 40 |
+
// Named non_exclusive to distinguish from the old heartbeat_interval_ms
|
| 41 |
+
// (either-or semantics in pre-#22145 clients). .default(0) so existing
|
| 42 |
+
// GrowthBook configs without this field parse successfully.
|
| 43 |
+
non_exclusive_heartbeat_interval_ms: z.number().int().min(0).default(0),
|
| 44 |
+
// Multisession (bridgeMain.ts) intervals. Defaults match the
|
| 45 |
+
// single-session values so existing configs without these fields
|
| 46 |
+
// preserve current behavior.
|
| 47 |
+
multisession_poll_interval_ms_not_at_capacity: z
|
| 48 |
+
.number()
|
| 49 |
+
.int()
|
| 50 |
+
.min(100)
|
| 51 |
+
.default(
|
| 52 |
+
DEFAULT_POLL_CONFIG.multisession_poll_interval_ms_not_at_capacity,
|
| 53 |
+
),
|
| 54 |
+
multisession_poll_interval_ms_partial_capacity: z
|
| 55 |
+
.number()
|
| 56 |
+
.int()
|
| 57 |
+
.min(100)
|
| 58 |
+
.default(
|
| 59 |
+
DEFAULT_POLL_CONFIG.multisession_poll_interval_ms_partial_capacity,
|
| 60 |
+
),
|
| 61 |
+
multisession_poll_interval_ms_at_capacity: z
|
| 62 |
+
.number()
|
| 63 |
+
.int()
|
| 64 |
+
.refine(v => v === 0 || v >= 100, zeroOrAtLeast100)
|
| 65 |
+
.default(DEFAULT_POLL_CONFIG.multisession_poll_interval_ms_at_capacity),
|
| 66 |
+
// .min(1) matches the server's ge=1 constraint (work_v1.py:230).
|
| 67 |
+
reclaim_older_than_ms: z.number().int().min(1).default(5000),
|
| 68 |
+
session_keepalive_interval_v2_ms: z
|
| 69 |
+
.number()
|
| 70 |
+
.int()
|
| 71 |
+
.min(0)
|
| 72 |
+
.default(120_000),
|
| 73 |
+
})
|
| 74 |
+
.refine(
|
| 75 |
+
cfg =>
|
| 76 |
+
cfg.non_exclusive_heartbeat_interval_ms > 0 ||
|
| 77 |
+
cfg.poll_interval_ms_at_capacity > 0,
|
| 78 |
+
{
|
| 79 |
+
message:
|
| 80 |
+
'at-capacity liveness requires non_exclusive_heartbeat_interval_ms > 0 or poll_interval_ms_at_capacity > 0',
|
| 81 |
+
},
|
| 82 |
+
)
|
| 83 |
+
.refine(
|
| 84 |
+
cfg =>
|
| 85 |
+
cfg.non_exclusive_heartbeat_interval_ms > 0 ||
|
| 86 |
+
cfg.multisession_poll_interval_ms_at_capacity > 0,
|
| 87 |
+
{
|
| 88 |
+
message:
|
| 89 |
+
'at-capacity liveness requires non_exclusive_heartbeat_interval_ms > 0 or multisession_poll_interval_ms_at_capacity > 0',
|
| 90 |
+
},
|
| 91 |
+
),
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
+
/**
|
| 95 |
+
* Fetch the bridge poll interval config from GrowthBook with a 5-minute
|
| 96 |
+
* refresh window. Validates the served JSON against the schema; falls back
|
| 97 |
+
* to defaults if the flag is absent, malformed, or partially-specified.
|
| 98 |
+
*
|
| 99 |
+
* Shared by bridgeMain.ts (standalone) and replBridge.ts (REPL) so ops
|
| 100 |
+
* can tune both poll rates fleet-wide with a single config push.
|
| 101 |
+
*/
|
| 102 |
+
export function getPollIntervalConfig(): PollIntervalConfig {
|
| 103 |
+
const raw = getFeatureValue_CACHED_WITH_REFRESH<unknown>(
|
| 104 |
+
'tengu_bridge_poll_interval_config',
|
| 105 |
+
DEFAULT_POLL_CONFIG,
|
| 106 |
+
5 * 60 * 1000,
|
| 107 |
+
)
|
| 108 |
+
const parsed = pollIntervalConfigSchema().safeParse(raw)
|
| 109 |
+
return parsed.success ? parsed.data : DEFAULT_POLL_CONFIG
|
| 110 |
+
}
|
src/bridge/pollConfigDefaults.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Bridge poll interval defaults. Extracted from pollConfig.ts so callers
|
| 3 |
+
* that don't need live GrowthBook tuning (daemon via Agent SDK) can avoid
|
| 4 |
+
* the growthbook.ts → config.ts → file.ts → sessionStorage.ts → commands.ts
|
| 5 |
+
* transitive dependency chain.
|
| 6 |
+
*/
|
| 7 |
+
|
| 8 |
+
/**
|
| 9 |
+
* Poll interval when actively seeking work (no transport / below maxSessions).
|
| 10 |
+
* Governs user-visible "connecting…" latency on initial work pickup and
|
| 11 |
+
* recovery speed after the server re-dispatches a work item.
|
| 12 |
+
*/
|
| 13 |
+
const POLL_INTERVAL_MS_NOT_AT_CAPACITY = 2000
|
| 14 |
+
|
| 15 |
+
/**
|
| 16 |
+
* Poll interval when the transport is connected. Runs independently of
|
| 17 |
+
* heartbeat — when both are enabled, the heartbeat loop breaks out to poll
|
| 18 |
+
* at this interval. Set to 0 to disable at-capacity polling entirely.
|
| 19 |
+
*
|
| 20 |
+
* Server-side constraints that bound this value:
|
| 21 |
+
* - BRIDGE_LAST_POLL_TTL = 4h (Redis key expiry → environment auto-archived)
|
| 22 |
+
* - max_poll_stale_seconds = 24h (session-creation health gate, currently disabled)
|
| 23 |
+
*
|
| 24 |
+
* 10 minutes gives 24× headroom on the Redis TTL while still picking up
|
| 25 |
+
* server-initiated token-rotation redispatches within one poll cycle.
|
| 26 |
+
* The transport auto-reconnects internally for 10 minutes on transient WS
|
| 27 |
+
* failures, so poll is not the recovery path — it's strictly a liveness
|
| 28 |
+
* signal plus a backstop for permanent close.
|
| 29 |
+
*/
|
| 30 |
+
const POLL_INTERVAL_MS_AT_CAPACITY = 600_000
|
| 31 |
+
|
| 32 |
+
/**
|
| 33 |
+
* Multisession bridge (bridgeMain.ts) poll intervals. Defaults match the
|
| 34 |
+
* single-session values so existing GrowthBook configs without these fields
|
| 35 |
+
* preserve current behavior. Ops can tune these independently via the
|
| 36 |
+
* tengu_bridge_poll_interval_config GB flag.
|
| 37 |
+
*/
|
| 38 |
+
const MULTISESSION_POLL_INTERVAL_MS_NOT_AT_CAPACITY =
|
| 39 |
+
POLL_INTERVAL_MS_NOT_AT_CAPACITY
|
| 40 |
+
const MULTISESSION_POLL_INTERVAL_MS_PARTIAL_CAPACITY =
|
| 41 |
+
POLL_INTERVAL_MS_NOT_AT_CAPACITY
|
| 42 |
+
const MULTISESSION_POLL_INTERVAL_MS_AT_CAPACITY = POLL_INTERVAL_MS_AT_CAPACITY
|
| 43 |
+
|
| 44 |
+
export type PollIntervalConfig = {
|
| 45 |
+
poll_interval_ms_not_at_capacity: number
|
| 46 |
+
poll_interval_ms_at_capacity: number
|
| 47 |
+
non_exclusive_heartbeat_interval_ms: number
|
| 48 |
+
multisession_poll_interval_ms_not_at_capacity: number
|
| 49 |
+
multisession_poll_interval_ms_partial_capacity: number
|
| 50 |
+
multisession_poll_interval_ms_at_capacity: number
|
| 51 |
+
reclaim_older_than_ms: number
|
| 52 |
+
session_keepalive_interval_v2_ms: number
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
export const DEFAULT_POLL_CONFIG: PollIntervalConfig = {
|
| 56 |
+
poll_interval_ms_not_at_capacity: POLL_INTERVAL_MS_NOT_AT_CAPACITY,
|
| 57 |
+
poll_interval_ms_at_capacity: POLL_INTERVAL_MS_AT_CAPACITY,
|
| 58 |
+
// 0 = disabled. When > 0, at-capacity loops send per-work-item heartbeats
|
| 59 |
+
// at this interval. Independent of poll_interval_ms_at_capacity — both may
|
| 60 |
+
// run (heartbeat periodically yields to poll). 60s gives 5× headroom under
|
| 61 |
+
// the server's 300s heartbeat TTL. Named non_exclusive to distinguish from
|
| 62 |
+
// the old heartbeat_interval_ms field (either-or semantics in pre-#22145
|
| 63 |
+
// clients — heartbeat suppressed poll). Old clients ignore this key; ops
|
| 64 |
+
// can set both fields during rollout.
|
| 65 |
+
non_exclusive_heartbeat_interval_ms: 0,
|
| 66 |
+
multisession_poll_interval_ms_not_at_capacity:
|
| 67 |
+
MULTISESSION_POLL_INTERVAL_MS_NOT_AT_CAPACITY,
|
| 68 |
+
multisession_poll_interval_ms_partial_capacity:
|
| 69 |
+
MULTISESSION_POLL_INTERVAL_MS_PARTIAL_CAPACITY,
|
| 70 |
+
multisession_poll_interval_ms_at_capacity:
|
| 71 |
+
MULTISESSION_POLL_INTERVAL_MS_AT_CAPACITY,
|
| 72 |
+
// Poll query param: reclaim unacknowledged work items older than this.
|
| 73 |
+
// Matches the server's DEFAULT_RECLAIM_OLDER_THAN_MS (work_service.py:24).
|
| 74 |
+
// Enables picking up stale-pending work after JWT expiry, when the prior
|
| 75 |
+
// ack failed because the session_ingress_token was already stale.
|
| 76 |
+
reclaim_older_than_ms: 5000,
|
| 77 |
+
// 0 = disabled. When > 0, push a silent {type:'keep_alive'} frame to
|
| 78 |
+
// session-ingress at this interval so upstream proxies don't GC an idle
|
| 79 |
+
// remote-control session. 2 min is the default. _v2: bridge-only gate
|
| 80 |
+
// (pre-v2 clients read the old key, new clients ignore it).
|
| 81 |
+
session_keepalive_interval_v2_ms: 120_000,
|
| 82 |
+
}
|
src/bridge/remoteBridgeCore.ts
ADDED
|
@@ -0,0 +1,1008 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// biome-ignore-all assist/source/organizeImports: ANT-ONLY import markers must not be reordered
|
| 2 |
+
/**
|
| 3 |
+
* Env-less Remote Control bridge core.
|
| 4 |
+
*
|
| 5 |
+
* "Env-less" = no Environments API layer. Distinct from "CCR v2" (the
|
| 6 |
+
* /worker/* transport protocol) — the env-based path (replBridge.ts) can also
|
| 7 |
+
* use CCR v2 transport via CLAUDE_CODE_USE_CCR_V2. This file is about removing
|
| 8 |
+
* the poll/dispatch layer, not about which transport protocol is underneath.
|
| 9 |
+
*
|
| 10 |
+
* Unlike initBridgeCore (env-based, ~2400 lines), this connects directly
|
| 11 |
+
* to the session-ingress layer without the Environments API work-dispatch
|
| 12 |
+
* layer:
|
| 13 |
+
*
|
| 14 |
+
* 1. POST /v1/code/sessions (OAuth, no env_id) → session.id
|
| 15 |
+
* 2. POST /v1/code/sessions/{id}/bridge (OAuth) → {worker_jwt, expires_in, api_base_url, worker_epoch}
|
| 16 |
+
* Each /bridge call bumps epoch — it IS the register. No separate /worker/register.
|
| 17 |
+
* 3. createV2ReplTransport(worker_jwt, worker_epoch) → SSE + CCRClient
|
| 18 |
+
* 4. createTokenRefreshScheduler → proactive /bridge re-call (new JWT + new epoch)
|
| 19 |
+
* 5. 401 on SSE → rebuild transport with fresh /bridge credentials (same seq-num)
|
| 20 |
+
*
|
| 21 |
+
* No register/poll/ack/stop/heartbeat/deregister environment lifecycle.
|
| 22 |
+
* The Environments API historically existed because CCR's /worker/*
|
| 23 |
+
* endpoints required a session_id+role=worker JWT that only the work-dispatch
|
| 24 |
+
* layer could mint. Server PR #292605 (renamed in #293280) adds the /bridge endpoint as a direct
|
| 25 |
+
* OAuth→worker_jwt exchange, making the env layer optional for REPL sessions.
|
| 26 |
+
*
|
| 27 |
+
* Gated by `tengu_bridge_repl_v2` GrowthBook flag in initReplBridge.ts.
|
| 28 |
+
* REPL-only — daemon/print stay on env-based.
|
| 29 |
+
*/
|
| 30 |
+
|
| 31 |
+
import { feature } from 'bun:bundle'
|
| 32 |
+
import axios from 'axios'
|
| 33 |
+
import {
|
| 34 |
+
createV2ReplTransport,
|
| 35 |
+
type ReplBridgeTransport,
|
| 36 |
+
} from './replBridgeTransport.js'
|
| 37 |
+
import { buildCCRv2SdkUrl } from './workSecret.js'
|
| 38 |
+
import { toCompatSessionId } from './sessionIdCompat.js'
|
| 39 |
+
import { FlushGate } from './flushGate.js'
|
| 40 |
+
import { createTokenRefreshScheduler } from './jwtUtils.js'
|
| 41 |
+
import { getTrustedDeviceToken } from './trustedDevice.js'
|
| 42 |
+
import {
|
| 43 |
+
getEnvLessBridgeConfig,
|
| 44 |
+
type EnvLessBridgeConfig,
|
| 45 |
+
} from './envLessBridgeConfig.js'
|
| 46 |
+
import {
|
| 47 |
+
handleIngressMessage,
|
| 48 |
+
handleServerControlRequest,
|
| 49 |
+
makeResultMessage,
|
| 50 |
+
isEligibleBridgeMessage,
|
| 51 |
+
extractTitleText,
|
| 52 |
+
BoundedUUIDSet,
|
| 53 |
+
} from './bridgeMessaging.js'
|
| 54 |
+
import { logBridgeSkip } from './debugUtils.js'
|
| 55 |
+
import { logForDebugging } from '../utils/debug.js'
|
| 56 |
+
import { logForDiagnosticsNoPII } from '../utils/diagLogs.js'
|
| 57 |
+
import { isInProtectedNamespace } from '../utils/envUtils.js'
|
| 58 |
+
import { errorMessage } from '../utils/errors.js'
|
| 59 |
+
import { sleep } from '../utils/sleep.js'
|
| 60 |
+
import { registerCleanup } from '../utils/cleanupRegistry.js'
|
| 61 |
+
import {
|
| 62 |
+
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
| 63 |
+
logEvent,
|
| 64 |
+
} from '../services/analytics/index.js'
|
| 65 |
+
import type { ReplBridgeHandle, BridgeState } from './replBridge.js'
|
| 66 |
+
import type { Message } from '../types/message.js'
|
| 67 |
+
import type { SDKMessage } from '../entrypoints/agentSdkTypes.js'
|
| 68 |
+
import type {
|
| 69 |
+
SDKControlRequest,
|
| 70 |
+
SDKControlResponse,
|
| 71 |
+
} from '../entrypoints/sdk/controlTypes.js'
|
| 72 |
+
import type { PermissionMode } from '../utils/permissions/PermissionMode.js'
|
| 73 |
+
|
| 74 |
+
const ANTHROPIC_VERSION = '2023-06-01'
|
| 75 |
+
|
| 76 |
+
// Telemetry discriminator for ws_connected. 'initial' is the default and
|
| 77 |
+
// never passed to rebuildTransport (which can only be called post-init);
|
| 78 |
+
// Exclude<> makes that constraint explicit at both signatures.
|
| 79 |
+
type ConnectCause = 'initial' | 'proactive_refresh' | 'auth_401_recovery'
|
| 80 |
+
|
| 81 |
+
function oauthHeaders(accessToken: string): Record<string, string> {
|
| 82 |
+
return {
|
| 83 |
+
Authorization: `Bearer ${accessToken}`,
|
| 84 |
+
'Content-Type': 'application/json',
|
| 85 |
+
'anthropic-version': ANTHROPIC_VERSION,
|
| 86 |
+
}
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
export type EnvLessBridgeParams = {
|
| 90 |
+
baseUrl: string
|
| 91 |
+
orgUUID: string
|
| 92 |
+
title: string
|
| 93 |
+
getAccessToken: () => string | undefined
|
| 94 |
+
onAuth401?: (staleAccessToken: string) => Promise<boolean>
|
| 95 |
+
/**
|
| 96 |
+
* Converts internal Message[] → SDKMessage[] for writeMessages() and the
|
| 97 |
+
* initial-flush/drain paths. Injected rather than imported — mappers.ts
|
| 98 |
+
* transitively pulls in src/commands.ts (entire command registry + React
|
| 99 |
+
* tree) which would bloat bundles that don't already have it.
|
| 100 |
+
*/
|
| 101 |
+
toSDKMessages: (messages: Message[]) => SDKMessage[]
|
| 102 |
+
initialHistoryCap: number
|
| 103 |
+
initialMessages?: Message[]
|
| 104 |
+
onInboundMessage?: (msg: SDKMessage) => void | Promise<void>
|
| 105 |
+
/**
|
| 106 |
+
* Fired on each title-worthy user message seen in writeMessages() until
|
| 107 |
+
* the callback returns true (done). Mirrors replBridge.ts's onUserMessage —
|
| 108 |
+
* caller derives a title and PATCHes /v1/sessions/{id} so auto-started
|
| 109 |
+
* sessions don't stay at the generic fallback. The caller owns the
|
| 110 |
+
* derive-at-count-1-and-3 policy; the transport just keeps calling until
|
| 111 |
+
* told to stop. sessionId is the raw cse_* — updateBridgeSessionTitle
|
| 112 |
+
* retags internally.
|
| 113 |
+
*/
|
| 114 |
+
onUserMessage?: (text: string, sessionId: string) => boolean
|
| 115 |
+
onPermissionResponse?: (response: SDKControlResponse) => void
|
| 116 |
+
onInterrupt?: () => void
|
| 117 |
+
onSetModel?: (model: string | undefined) => void
|
| 118 |
+
onSetMaxThinkingTokens?: (maxTokens: number | null) => void
|
| 119 |
+
onSetPermissionMode?: (
|
| 120 |
+
mode: PermissionMode,
|
| 121 |
+
) => { ok: true } | { ok: false; error: string }
|
| 122 |
+
onStateChange?: (state: BridgeState, detail?: string) => void
|
| 123 |
+
/**
|
| 124 |
+
* When true, skip opening the SSE read stream — only the CCRClient write
|
| 125 |
+
* path is activated. Threaded to createV2ReplTransport and
|
| 126 |
+
* handleServerControlRequest.
|
| 127 |
+
*/
|
| 128 |
+
outboundOnly?: boolean
|
| 129 |
+
/** Free-form tags for session categorization (e.g. ['ccr-mirror']). */
|
| 130 |
+
tags?: string[]
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
/**
|
| 134 |
+
* Create a session, fetch a worker JWT, connect the v2 transport.
|
| 135 |
+
*
|
| 136 |
+
* Returns null on any pre-flight failure (session create failed, /bridge
|
| 137 |
+
* failed, transport setup failed). Caller (initReplBridge) surfaces this
|
| 138 |
+
* as a generic "initialization failed" state.
|
| 139 |
+
*/
|
| 140 |
+
export async function initEnvLessBridgeCore(
|
| 141 |
+
params: EnvLessBridgeParams,
|
| 142 |
+
): Promise<ReplBridgeHandle | null> {
|
| 143 |
+
const {
|
| 144 |
+
baseUrl,
|
| 145 |
+
orgUUID,
|
| 146 |
+
title,
|
| 147 |
+
getAccessToken,
|
| 148 |
+
onAuth401,
|
| 149 |
+
toSDKMessages,
|
| 150 |
+
initialHistoryCap,
|
| 151 |
+
initialMessages,
|
| 152 |
+
onInboundMessage,
|
| 153 |
+
onUserMessage,
|
| 154 |
+
onPermissionResponse,
|
| 155 |
+
onInterrupt,
|
| 156 |
+
onSetModel,
|
| 157 |
+
onSetMaxThinkingTokens,
|
| 158 |
+
onSetPermissionMode,
|
| 159 |
+
onStateChange,
|
| 160 |
+
outboundOnly,
|
| 161 |
+
tags,
|
| 162 |
+
} = params
|
| 163 |
+
|
| 164 |
+
const cfg = await getEnvLessBridgeConfig()
|
| 165 |
+
|
| 166 |
+
// ── 1. Create session (POST /v1/code/sessions, no env_id) ───────────────
|
| 167 |
+
const accessToken = getAccessToken()
|
| 168 |
+
if (!accessToken) {
|
| 169 |
+
logForDebugging('[remote-bridge] No OAuth token')
|
| 170 |
+
return null
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
const createdSessionId = await withRetry(
|
| 174 |
+
() =>
|
| 175 |
+
createCodeSession(baseUrl, accessToken, title, cfg.http_timeout_ms, tags),
|
| 176 |
+
'createCodeSession',
|
| 177 |
+
cfg,
|
| 178 |
+
)
|
| 179 |
+
if (!createdSessionId) {
|
| 180 |
+
onStateChange?.('failed', 'Session creation failed — see debug log')
|
| 181 |
+
logBridgeSkip('v2_session_create_failed', undefined, true)
|
| 182 |
+
return null
|
| 183 |
+
}
|
| 184 |
+
const sessionId: string = createdSessionId
|
| 185 |
+
logForDebugging(`[remote-bridge] Created session ${sessionId}`)
|
| 186 |
+
logForDiagnosticsNoPII('info', 'bridge_repl_v2_session_created')
|
| 187 |
+
|
| 188 |
+
// ── 2. Fetch bridge credentials (POST /bridge → worker_jwt, expires_in, api_base_url) ──
|
| 189 |
+
const credentials = await withRetry(
|
| 190 |
+
() =>
|
| 191 |
+
fetchRemoteCredentials(
|
| 192 |
+
sessionId,
|
| 193 |
+
baseUrl,
|
| 194 |
+
accessToken,
|
| 195 |
+
cfg.http_timeout_ms,
|
| 196 |
+
),
|
| 197 |
+
'fetchRemoteCredentials',
|
| 198 |
+
cfg,
|
| 199 |
+
)
|
| 200 |
+
if (!credentials) {
|
| 201 |
+
onStateChange?.('failed', 'Remote credentials fetch failed — see debug log')
|
| 202 |
+
logBridgeSkip('v2_remote_creds_failed', undefined, true)
|
| 203 |
+
void archiveSession(
|
| 204 |
+
sessionId,
|
| 205 |
+
baseUrl,
|
| 206 |
+
accessToken,
|
| 207 |
+
orgUUID,
|
| 208 |
+
cfg.http_timeout_ms,
|
| 209 |
+
)
|
| 210 |
+
return null
|
| 211 |
+
}
|
| 212 |
+
logForDebugging(
|
| 213 |
+
`[remote-bridge] Fetched bridge credentials (expires_in=${credentials.expires_in}s)`,
|
| 214 |
+
)
|
| 215 |
+
|
| 216 |
+
// ── 3. Build v2 transport (SSETransport + CCRClient) ────────────────────
|
| 217 |
+
const sessionUrl = buildCCRv2SdkUrl(credentials.api_base_url, sessionId)
|
| 218 |
+
logForDebugging(`[remote-bridge] v2 session URL: ${sessionUrl}`)
|
| 219 |
+
|
| 220 |
+
let transport: ReplBridgeTransport
|
| 221 |
+
try {
|
| 222 |
+
transport = await createV2ReplTransport({
|
| 223 |
+
sessionUrl,
|
| 224 |
+
ingressToken: credentials.worker_jwt,
|
| 225 |
+
sessionId,
|
| 226 |
+
epoch: credentials.worker_epoch,
|
| 227 |
+
heartbeatIntervalMs: cfg.heartbeat_interval_ms,
|
| 228 |
+
heartbeatJitterFraction: cfg.heartbeat_jitter_fraction,
|
| 229 |
+
// Per-instance closure — keeps the worker JWT out of
|
| 230 |
+
// process.env.CLAUDE_CODE_SESSION_ACCESS_TOKEN, which mcp/client.ts
|
| 231 |
+
// reads ungatedly and would otherwise send to user-configured ws/http
|
| 232 |
+
// MCP servers. Frozen-at-construction is correct: transport is fully
|
| 233 |
+
// rebuilt on refresh (rebuildTransport below).
|
| 234 |
+
getAuthToken: () => credentials.worker_jwt,
|
| 235 |
+
outboundOnly,
|
| 236 |
+
})
|
| 237 |
+
} catch (err) {
|
| 238 |
+
logForDebugging(
|
| 239 |
+
`[remote-bridge] v2 transport setup failed: ${errorMessage(err)}`,
|
| 240 |
+
{ level: 'error' },
|
| 241 |
+
)
|
| 242 |
+
onStateChange?.('failed', `Transport setup failed: ${errorMessage(err)}`)
|
| 243 |
+
logBridgeSkip('v2_transport_setup_failed', undefined, true)
|
| 244 |
+
void archiveSession(
|
| 245 |
+
sessionId,
|
| 246 |
+
baseUrl,
|
| 247 |
+
accessToken,
|
| 248 |
+
orgUUID,
|
| 249 |
+
cfg.http_timeout_ms,
|
| 250 |
+
)
|
| 251 |
+
return null
|
| 252 |
+
}
|
| 253 |
+
logForDebugging(
|
| 254 |
+
`[remote-bridge] v2 transport created (epoch=${credentials.worker_epoch})`,
|
| 255 |
+
)
|
| 256 |
+
onStateChange?.('ready')
|
| 257 |
+
|
| 258 |
+
// ── 4. State ────────────────────────────────────────────────────────────
|
| 259 |
+
|
| 260 |
+
// Echo dedup: messages we POST come back on the read stream. Seeded with
|
| 261 |
+
// initial message UUIDs so server echoes of flushed history are recognized.
|
| 262 |
+
// Both sets cover initial UUIDs — recentPostedUUIDs is a 2000-cap ring buffer
|
| 263 |
+
// and could evict them after enough live writes; initialMessageUUIDs is the
|
| 264 |
+
// unbounded fallback. Defense-in-depth; mirrors replBridge.ts.
|
| 265 |
+
const recentPostedUUIDs = new BoundedUUIDSet(cfg.uuid_dedup_buffer_size)
|
| 266 |
+
const initialMessageUUIDs = new Set<string>()
|
| 267 |
+
if (initialMessages) {
|
| 268 |
+
for (const msg of initialMessages) {
|
| 269 |
+
initialMessageUUIDs.add(msg.uuid)
|
| 270 |
+
recentPostedUUIDs.add(msg.uuid)
|
| 271 |
+
}
|
| 272 |
+
}
|
| 273 |
+
|
| 274 |
+
// Defensive dedup for re-delivered inbound prompts (seq-num negotiation
|
| 275 |
+
// edge cases, server history replay after transport swap).
|
| 276 |
+
const recentInboundUUIDs = new BoundedUUIDSet(cfg.uuid_dedup_buffer_size)
|
| 277 |
+
|
| 278 |
+
// FlushGate: queue live writes while the history flush POST is in flight,
|
| 279 |
+
// so the server receives [history..., live...] in order.
|
| 280 |
+
const flushGate = new FlushGate<Message>()
|
| 281 |
+
|
| 282 |
+
let initialFlushDone = false
|
| 283 |
+
let tornDown = false
|
| 284 |
+
let authRecoveryInFlight = false
|
| 285 |
+
// Latch for onUserMessage — flips true when the callback returns true
|
| 286 |
+
// (policy says "done deriving"). sessionId is const (no re-create path —
|
| 287 |
+
// rebuildTransport swaps JWT/epoch, same session), so no reset needed.
|
| 288 |
+
let userMessageCallbackDone = !onUserMessage
|
| 289 |
+
|
| 290 |
+
// Telemetry: why did onConnect fire? Set by rebuildTransport before
|
| 291 |
+
// wireTransportCallbacks; read asynchronously by onConnect. Race-safe
|
| 292 |
+
// because authRecoveryInFlight serializes rebuild callers, and a fresh
|
| 293 |
+
// initEnvLessBridgeCore() call gets a fresh closure defaulting to 'initial'.
|
| 294 |
+
let connectCause: ConnectCause = 'initial'
|
| 295 |
+
|
| 296 |
+
// Deadline for onConnect after transport.connect(). Cleared by onConnect
|
| 297 |
+
// (connected) and onClose (got a close — not silent). If neither fires
|
| 298 |
+
// before cfg.connect_timeout_ms, onConnectTimeout emits — the only
|
| 299 |
+
// signal for the `started → (silence)` gap.
|
| 300 |
+
let connectDeadline: ReturnType<typeof setTimeout> | undefined
|
| 301 |
+
function onConnectTimeout(cause: ConnectCause): void {
|
| 302 |
+
if (tornDown) return
|
| 303 |
+
logEvent('tengu_bridge_repl_connect_timeout', {
|
| 304 |
+
v2: true,
|
| 305 |
+
elapsed_ms: cfg.connect_timeout_ms,
|
| 306 |
+
cause:
|
| 307 |
+
cause as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
| 308 |
+
})
|
| 309 |
+
}
|
| 310 |
+
|
| 311 |
+
// ── 5. JWT refresh scheduler ────────────────────────────────────────────
|
| 312 |
+
// Schedule a callback 5min before expiry (per response.expires_in). On fire,
|
| 313 |
+
// re-fetch /bridge with OAuth → rebuild transport with fresh credentials.
|
| 314 |
+
// Each /bridge call bumps epoch server-side, so a JWT-only swap would leave
|
| 315 |
+
// the old CCRClient heartbeating with a stale epoch → 409 within 20s.
|
| 316 |
+
// JWT is opaque — do not decode.
|
| 317 |
+
const refresh = createTokenRefreshScheduler({
|
| 318 |
+
refreshBufferMs: cfg.token_refresh_buffer_ms,
|
| 319 |
+
getAccessToken: async () => {
|
| 320 |
+
// Unconditionally refresh OAuth before calling /bridge — getAccessToken()
|
| 321 |
+
// returns expired tokens as non-null strings (doesn't check expiresAt),
|
| 322 |
+
// so truthiness doesn't mean valid. Pass the stale token to onAuth401
|
| 323 |
+
// so handleOAuth401Error's keychain-comparison can detect parallel refresh.
|
| 324 |
+
const stale = getAccessToken()
|
| 325 |
+
if (onAuth401) await onAuth401(stale ?? '')
|
| 326 |
+
return getAccessToken() ?? stale
|
| 327 |
+
},
|
| 328 |
+
onRefresh: (sid, oauthToken) => {
|
| 329 |
+
void (async () => {
|
| 330 |
+
// Laptop wake: overdue proactive timer + SSE 401 fire ~simultaneously.
|
| 331 |
+
// Claim the flag BEFORE the /bridge fetch so the other path skips
|
| 332 |
+
// entirely — prevents double epoch bump (each /bridge call bumps; if
|
| 333 |
+
// both fetch, the first rebuild gets a stale epoch and 409s).
|
| 334 |
+
if (authRecoveryInFlight || tornDown) {
|
| 335 |
+
logForDebugging(
|
| 336 |
+
'[remote-bridge] Recovery already in flight, skipping proactive refresh',
|
| 337 |
+
)
|
| 338 |
+
return
|
| 339 |
+
}
|
| 340 |
+
authRecoveryInFlight = true
|
| 341 |
+
try {
|
| 342 |
+
const fresh = await withRetry(
|
| 343 |
+
() =>
|
| 344 |
+
fetchRemoteCredentials(
|
| 345 |
+
sid,
|
| 346 |
+
baseUrl,
|
| 347 |
+
oauthToken,
|
| 348 |
+
cfg.http_timeout_ms,
|
| 349 |
+
),
|
| 350 |
+
'fetchRemoteCredentials (proactive)',
|
| 351 |
+
cfg,
|
| 352 |
+
)
|
| 353 |
+
if (!fresh || tornDown) return
|
| 354 |
+
await rebuildTransport(fresh, 'proactive_refresh')
|
| 355 |
+
logForDebugging(
|
| 356 |
+
'[remote-bridge] Transport rebuilt (proactive refresh)',
|
| 357 |
+
)
|
| 358 |
+
} catch (err) {
|
| 359 |
+
logForDebugging(
|
| 360 |
+
`[remote-bridge] Proactive refresh rebuild failed: ${errorMessage(err)}`,
|
| 361 |
+
{ level: 'error' },
|
| 362 |
+
)
|
| 363 |
+
logForDiagnosticsNoPII(
|
| 364 |
+
'error',
|
| 365 |
+
'bridge_repl_v2_proactive_refresh_failed',
|
| 366 |
+
)
|
| 367 |
+
if (!tornDown) {
|
| 368 |
+
onStateChange?.('failed', `Refresh failed: ${errorMessage(err)}`)
|
| 369 |
+
}
|
| 370 |
+
} finally {
|
| 371 |
+
authRecoveryInFlight = false
|
| 372 |
+
}
|
| 373 |
+
})()
|
| 374 |
+
},
|
| 375 |
+
label: 'remote',
|
| 376 |
+
})
|
| 377 |
+
refresh.scheduleFromExpiresIn(sessionId, credentials.expires_in)
|
| 378 |
+
|
| 379 |
+
// ── 6. Wire callbacks (extracted so transport-rebuild can re-wire) ──────
|
| 380 |
+
function wireTransportCallbacks(): void {
|
| 381 |
+
transport.setOnConnect(() => {
|
| 382 |
+
clearTimeout(connectDeadline)
|
| 383 |
+
logForDebugging('[remote-bridge] v2 transport connected')
|
| 384 |
+
logForDiagnosticsNoPII('info', 'bridge_repl_v2_transport_connected')
|
| 385 |
+
logEvent('tengu_bridge_repl_ws_connected', {
|
| 386 |
+
v2: true,
|
| 387 |
+
cause:
|
| 388 |
+
connectCause as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
| 389 |
+
})
|
| 390 |
+
|
| 391 |
+
if (!initialFlushDone && initialMessages && initialMessages.length > 0) {
|
| 392 |
+
initialFlushDone = true
|
| 393 |
+
// Capture current transport — if 401/teardown happens mid-flush,
|
| 394 |
+
// the stale .finally() must not drain the gate or signal connected.
|
| 395 |
+
// (Same guard pattern as replBridge.ts:1119.)
|
| 396 |
+
const flushTransport = transport
|
| 397 |
+
void flushHistory(initialMessages)
|
| 398 |
+
.catch(e =>
|
| 399 |
+
logForDebugging(`[remote-bridge] flushHistory failed: ${e}`),
|
| 400 |
+
)
|
| 401 |
+
.finally(() => {
|
| 402 |
+
// authRecoveryInFlight catches the v1-vs-v2 asymmetry: v1 nulls
|
| 403 |
+
// transport synchronously in setOnClose (replBridge.ts:1175), so
|
| 404 |
+
// transport !== flushTransport trips immediately. v2 doesn't null —
|
| 405 |
+
// transport reassigned only at rebuildTransport:346, 3 awaits deep.
|
| 406 |
+
// authRecoveryInFlight is set synchronously at rebuildTransport entry.
|
| 407 |
+
if (
|
| 408 |
+
transport !== flushTransport ||
|
| 409 |
+
tornDown ||
|
| 410 |
+
authRecoveryInFlight
|
| 411 |
+
) {
|
| 412 |
+
return
|
| 413 |
+
}
|
| 414 |
+
drainFlushGate()
|
| 415 |
+
onStateChange?.('connected')
|
| 416 |
+
})
|
| 417 |
+
} else if (!flushGate.active) {
|
| 418 |
+
onStateChange?.('connected')
|
| 419 |
+
}
|
| 420 |
+
})
|
| 421 |
+
|
| 422 |
+
transport.setOnData((data: string) => {
|
| 423 |
+
handleIngressMessage(
|
| 424 |
+
data,
|
| 425 |
+
recentPostedUUIDs,
|
| 426 |
+
recentInboundUUIDs,
|
| 427 |
+
onInboundMessage,
|
| 428 |
+
// Remote client answered the permission prompt — the turn resumes.
|
| 429 |
+
// Without this the server stays on requires_action until the next
|
| 430 |
+
// user message or turn-end result.
|
| 431 |
+
onPermissionResponse
|
| 432 |
+
? res => {
|
| 433 |
+
transport.reportState('running')
|
| 434 |
+
onPermissionResponse(res)
|
| 435 |
+
}
|
| 436 |
+
: undefined,
|
| 437 |
+
req =>
|
| 438 |
+
handleServerControlRequest(req, {
|
| 439 |
+
transport,
|
| 440 |
+
sessionId,
|
| 441 |
+
onInterrupt,
|
| 442 |
+
onSetModel,
|
| 443 |
+
onSetMaxThinkingTokens,
|
| 444 |
+
onSetPermissionMode,
|
| 445 |
+
outboundOnly,
|
| 446 |
+
}),
|
| 447 |
+
)
|
| 448 |
+
})
|
| 449 |
+
|
| 450 |
+
transport.setOnClose((code?: number) => {
|
| 451 |
+
clearTimeout(connectDeadline)
|
| 452 |
+
if (tornDown) return
|
| 453 |
+
logForDebugging(`[remote-bridge] v2 transport closed (code=${code})`)
|
| 454 |
+
logEvent('tengu_bridge_repl_ws_closed', { code, v2: true })
|
| 455 |
+
// onClose fires only for TERMINAL failures: 401 (JWT invalid),
|
| 456 |
+
// 4090 (CCR epoch mismatch), 4091 (CCR init failed), or SSE 10-min
|
| 457 |
+
// reconnect budget exhausted. Transient disconnects are handled
|
| 458 |
+
// transparently inside SSETransport. 401 we can recover from (fetch
|
| 459 |
+
// fresh JWT, rebuild transport); all other codes are dead-ends.
|
| 460 |
+
if (code === 401 && !authRecoveryInFlight) {
|
| 461 |
+
void recoverFromAuthFailure()
|
| 462 |
+
return
|
| 463 |
+
}
|
| 464 |
+
onStateChange?.('failed', `Transport closed (code ${code})`)
|
| 465 |
+
})
|
| 466 |
+
}
|
| 467 |
+
|
| 468 |
+
// ── 7. Transport rebuild (shared by proactive refresh + 401 recovery) ──
|
| 469 |
+
// Every /bridge call bumps epoch server-side. Both refresh paths must
|
| 470 |
+
// rebuild the transport with the new epoch — a JWT-only swap leaves the
|
| 471 |
+
// old CCRClient heartbeating stale epoch → 409. SSE resumes from the old
|
| 472 |
+
// transport's high-water-mark seq-num so no server-side replay.
|
| 473 |
+
// Caller MUST set authRecoveryInFlight = true before calling (synchronously,
|
| 474 |
+
// before any await) and clear it in a finally. This function doesn't manage
|
| 475 |
+
// the flag — moving it here would be too late to prevent a double /bridge
|
| 476 |
+
// fetch, and each fetch bumps epoch.
|
| 477 |
+
async function rebuildTransport(
|
| 478 |
+
fresh: RemoteCredentials,
|
| 479 |
+
cause: Exclude<ConnectCause, 'initial'>,
|
| 480 |
+
): Promise<void> {
|
| 481 |
+
connectCause = cause
|
| 482 |
+
// Queue writes during rebuild — once /bridge returns, the old transport's
|
| 483 |
+
// epoch is stale and its next write/heartbeat 409s. Without this gate,
|
| 484 |
+
// writeMessages adds UUIDs to recentPostedUUIDs then writeBatch silently
|
| 485 |
+
// no-ops (closed uploader after 409) → permanent silent message loss.
|
| 486 |
+
flushGate.start()
|
| 487 |
+
try {
|
| 488 |
+
const seq = transport.getLastSequenceNum()
|
| 489 |
+
transport.close()
|
| 490 |
+
transport = await createV2ReplTransport({
|
| 491 |
+
sessionUrl: buildCCRv2SdkUrl(fresh.api_base_url, sessionId),
|
| 492 |
+
ingressToken: fresh.worker_jwt,
|
| 493 |
+
sessionId,
|
| 494 |
+
epoch: fresh.worker_epoch,
|
| 495 |
+
heartbeatIntervalMs: cfg.heartbeat_interval_ms,
|
| 496 |
+
heartbeatJitterFraction: cfg.heartbeat_jitter_fraction,
|
| 497 |
+
initialSequenceNum: seq,
|
| 498 |
+
getAuthToken: () => fresh.worker_jwt,
|
| 499 |
+
outboundOnly,
|
| 500 |
+
})
|
| 501 |
+
if (tornDown) {
|
| 502 |
+
// Teardown fired during the async createV2ReplTransport window.
|
| 503 |
+
// Don't wire/connect/schedule — we'd re-arm timers after cancelAll()
|
| 504 |
+
// and fire onInboundMessage into a torn-down bridge.
|
| 505 |
+
transport.close()
|
| 506 |
+
return
|
| 507 |
+
}
|
| 508 |
+
wireTransportCallbacks()
|
| 509 |
+
transport.connect()
|
| 510 |
+
connectDeadline = setTimeout(
|
| 511 |
+
onConnectTimeout,
|
| 512 |
+
cfg.connect_timeout_ms,
|
| 513 |
+
connectCause,
|
| 514 |
+
)
|
| 515 |
+
refresh.scheduleFromExpiresIn(sessionId, fresh.expires_in)
|
| 516 |
+
// Drain queued writes into the new uploader. Runs before
|
| 517 |
+
// ccr.initialize() resolves (transport.connect() is fire-and-forget),
|
| 518 |
+
// but the uploader serializes behind the initial PUT /worker. If
|
| 519 |
+
// init fails (4091), events drop — but only recentPostedUUIDs
|
| 520 |
+
// (per-instance) is populated, so re-enabling the bridge re-flushes.
|
| 521 |
+
drainFlushGate()
|
| 522 |
+
} finally {
|
| 523 |
+
// End the gate on failure paths too — drainFlushGate already ended
|
| 524 |
+
// it on success. Queued messages are dropped (transport still dead).
|
| 525 |
+
flushGate.drop()
|
| 526 |
+
}
|
| 527 |
+
}
|
| 528 |
+
|
| 529 |
+
// ── 8. 401 recovery (OAuth refresh + rebuild) ───────────────────────────
|
| 530 |
+
async function recoverFromAuthFailure(): Promise<void> {
|
| 531 |
+
// setOnClose already guards `!authRecoveryInFlight` but that check and
|
| 532 |
+
// this set must be atomic against onRefresh — claim synchronously before
|
| 533 |
+
// any await. Laptop wake fires both paths ~simultaneously.
|
| 534 |
+
if (authRecoveryInFlight) return
|
| 535 |
+
authRecoveryInFlight = true
|
| 536 |
+
onStateChange?.('reconnecting', 'JWT expired — refreshing')
|
| 537 |
+
logForDebugging('[remote-bridge] 401 on SSE — attempting JWT refresh')
|
| 538 |
+
try {
|
| 539 |
+
// Unconditionally try OAuth refresh — getAccessToken() returns expired
|
| 540 |
+
// tokens as non-null strings, so !oauthToken doesn't catch expiry.
|
| 541 |
+
// Pass the stale token so handleOAuth401Error's keychain-comparison
|
| 542 |
+
// can detect if another tab already refreshed.
|
| 543 |
+
const stale = getAccessToken()
|
| 544 |
+
if (onAuth401) await onAuth401(stale ?? '')
|
| 545 |
+
const oauthToken = getAccessToken() ?? stale
|
| 546 |
+
if (!oauthToken || tornDown) {
|
| 547 |
+
if (!tornDown) {
|
| 548 |
+
onStateChange?.('failed', 'JWT refresh failed: no OAuth token')
|
| 549 |
+
}
|
| 550 |
+
return
|
| 551 |
+
}
|
| 552 |
+
|
| 553 |
+
const fresh = await withRetry(
|
| 554 |
+
() =>
|
| 555 |
+
fetchRemoteCredentials(
|
| 556 |
+
sessionId,
|
| 557 |
+
baseUrl,
|
| 558 |
+
oauthToken,
|
| 559 |
+
cfg.http_timeout_ms,
|
| 560 |
+
),
|
| 561 |
+
'fetchRemoteCredentials (recovery)',
|
| 562 |
+
cfg,
|
| 563 |
+
)
|
| 564 |
+
if (!fresh || tornDown) {
|
| 565 |
+
if (!tornDown) {
|
| 566 |
+
onStateChange?.('failed', 'JWT refresh failed after 401')
|
| 567 |
+
}
|
| 568 |
+
return
|
| 569 |
+
}
|
| 570 |
+
// If 401 interrupted the initial flush, writeBatch may have silently
|
| 571 |
+
// no-op'd on the closed uploader (ccr.close() ran in the SSE wrapper
|
| 572 |
+
// before our setOnClose callback). Reset so the new onConnect re-flushes.
|
| 573 |
+
// (v1 scopes initialFlushDone inside the per-transport closure at
|
| 574 |
+
// replBridge.ts:1027 so it resets naturally; v2 has it at outer scope.)
|
| 575 |
+
initialFlushDone = false
|
| 576 |
+
await rebuildTransport(fresh, 'auth_401_recovery')
|
| 577 |
+
logForDebugging('[remote-bridge] Transport rebuilt after 401')
|
| 578 |
+
} catch (err) {
|
| 579 |
+
logForDebugging(
|
| 580 |
+
`[remote-bridge] 401 recovery failed: ${errorMessage(err)}`,
|
| 581 |
+
{ level: 'error' },
|
| 582 |
+
)
|
| 583 |
+
logForDiagnosticsNoPII('error', 'bridge_repl_v2_jwt_refresh_failed')
|
| 584 |
+
if (!tornDown) {
|
| 585 |
+
onStateChange?.('failed', `JWT refresh failed: ${errorMessage(err)}`)
|
| 586 |
+
}
|
| 587 |
+
} finally {
|
| 588 |
+
authRecoveryInFlight = false
|
| 589 |
+
}
|
| 590 |
+
}
|
| 591 |
+
|
| 592 |
+
wireTransportCallbacks()
|
| 593 |
+
|
| 594 |
+
// Start flushGate BEFORE connect so writeMessages() during handshake
|
| 595 |
+
// queues instead of racing the history POST.
|
| 596 |
+
if (initialMessages && initialMessages.length > 0) {
|
| 597 |
+
flushGate.start()
|
| 598 |
+
}
|
| 599 |
+
transport.connect()
|
| 600 |
+
connectDeadline = setTimeout(
|
| 601 |
+
onConnectTimeout,
|
| 602 |
+
cfg.connect_timeout_ms,
|
| 603 |
+
connectCause,
|
| 604 |
+
)
|
| 605 |
+
|
| 606 |
+
// ── 8. History flush + drain helpers ────────────────────────────────────
|
| 607 |
+
function drainFlushGate(): void {
|
| 608 |
+
const msgs = flushGate.end()
|
| 609 |
+
if (msgs.length === 0) return
|
| 610 |
+
for (const msg of msgs) recentPostedUUIDs.add(msg.uuid)
|
| 611 |
+
const events = toSDKMessages(msgs).map(m => ({
|
| 612 |
+
...m,
|
| 613 |
+
session_id: sessionId,
|
| 614 |
+
}))
|
| 615 |
+
if (msgs.some(m => m.type === 'user')) {
|
| 616 |
+
transport.reportState('running')
|
| 617 |
+
}
|
| 618 |
+
logForDebugging(
|
| 619 |
+
`[remote-bridge] Drained ${msgs.length} queued message(s) after flush`,
|
| 620 |
+
)
|
| 621 |
+
void transport.writeBatch(events)
|
| 622 |
+
}
|
| 623 |
+
|
| 624 |
+
async function flushHistory(msgs: Message[]): Promise<void> {
|
| 625 |
+
// v2 always creates a fresh server session (unconditional createCodeSession
|
| 626 |
+
// above) — no session reuse, no double-post risk. Unlike v1, we do NOT
|
| 627 |
+
// filter by previouslyFlushedUUIDs: that set persists across REPL enable/
|
| 628 |
+
// disable cycles (useRef), so it would wrongly suppress history on re-enable.
|
| 629 |
+
const eligible = msgs.filter(isEligibleBridgeMessage)
|
| 630 |
+
const capped =
|
| 631 |
+
initialHistoryCap > 0 && eligible.length > initialHistoryCap
|
| 632 |
+
? eligible.slice(-initialHistoryCap)
|
| 633 |
+
: eligible
|
| 634 |
+
if (capped.length < eligible.length) {
|
| 635 |
+
logForDebugging(
|
| 636 |
+
`[remote-bridge] Capped initial flush: ${eligible.length} -> ${capped.length} (cap=${initialHistoryCap})`,
|
| 637 |
+
)
|
| 638 |
+
}
|
| 639 |
+
const events = toSDKMessages(capped).map(m => ({
|
| 640 |
+
...m,
|
| 641 |
+
session_id: sessionId,
|
| 642 |
+
}))
|
| 643 |
+
if (events.length === 0) return
|
| 644 |
+
// Mid-turn init: if Remote Control is enabled while a query is running,
|
| 645 |
+
// the last eligible message is a user prompt or tool_result (both 'user'
|
| 646 |
+
// type). Without this the init PUT's 'idle' sticks until the next user-
|
| 647 |
+
// type message forwards via writeMessages — which for a pure-text turn
|
| 648 |
+
// is never (only assistant chunks stream post-init). Check eligible (pre-
|
| 649 |
+
// cap), not capped: the cap may truncate to a user message even when the
|
| 650 |
+
// actual trailing message is assistant.
|
| 651 |
+
if (eligible.at(-1)?.type === 'user') {
|
| 652 |
+
transport.reportState('running')
|
| 653 |
+
}
|
| 654 |
+
logForDebugging(`[remote-bridge] Flushing ${events.length} history events`)
|
| 655 |
+
await transport.writeBatch(events)
|
| 656 |
+
}
|
| 657 |
+
|
| 658 |
+
// ── 9. Teardown ───────────────────────────────────────────────────────────
|
| 659 |
+
// On SIGINT/SIGTERM//exit, gracefulShutdown races runCleanupFunctions()
|
| 660 |
+
// against a 2s cap before forceExit kills the process. Budget accordingly:
|
| 661 |
+
// - archive: teardown_archive_timeout_ms (default 1500, cap 2000)
|
| 662 |
+
// - result write: fire-and-forget, archive latency covers the drain
|
| 663 |
+
// - 401 retry: only if first archive 401s, shares the same budget
|
| 664 |
+
async function teardown(): Promise<void> {
|
| 665 |
+
if (tornDown) return
|
| 666 |
+
tornDown = true
|
| 667 |
+
refresh.cancelAll()
|
| 668 |
+
clearTimeout(connectDeadline)
|
| 669 |
+
flushGate.drop()
|
| 670 |
+
|
| 671 |
+
// Fire the result message before archive — transport.write() only awaits
|
| 672 |
+
// enqueue (SerialBatchEventUploader resolves once buffered, drain is
|
| 673 |
+
// async). Archiving before close() gives the uploader's drain loop a
|
| 674 |
+
// window (typical archive ≈ 100-500ms) to POST the result without an
|
| 675 |
+
// explicit sleep. close() sets closed=true which interrupts drain at the
|
| 676 |
+
// next while-check, so close-before-archive drops the result.
|
| 677 |
+
transport.reportState('idle')
|
| 678 |
+
void transport.write(makeResultMessage(sessionId))
|
| 679 |
+
|
| 680 |
+
let token = getAccessToken()
|
| 681 |
+
let status = await archiveSession(
|
| 682 |
+
sessionId,
|
| 683 |
+
baseUrl,
|
| 684 |
+
token,
|
| 685 |
+
orgUUID,
|
| 686 |
+
cfg.teardown_archive_timeout_ms,
|
| 687 |
+
)
|
| 688 |
+
|
| 689 |
+
// Token is usually fresh (refresh scheduler runs 5min before expiry) but
|
| 690 |
+
// laptop-wake past the refresh window leaves getAccessToken() returning a
|
| 691 |
+
// stale string. Retry once on 401 — onAuth401 (= handleOAuth401Error)
|
| 692 |
+
// clears keychain cache + force-refreshes. No proactive refresh on the
|
| 693 |
+
// happy path: handleOAuth401Error force-refreshes even valid tokens,
|
| 694 |
+
// which would waste budget 99% of the time. try/catch mirrors
|
| 695 |
+
// recoverFromAuthFailure: keychain reads can throw (macOS locked after
|
| 696 |
+
// wake); an uncaught throw here would skip transport.close + telemetry.
|
| 697 |
+
if (status === 401 && onAuth401) {
|
| 698 |
+
try {
|
| 699 |
+
await onAuth401(token ?? '')
|
| 700 |
+
token = getAccessToken()
|
| 701 |
+
status = await archiveSession(
|
| 702 |
+
sessionId,
|
| 703 |
+
baseUrl,
|
| 704 |
+
token,
|
| 705 |
+
orgUUID,
|
| 706 |
+
cfg.teardown_archive_timeout_ms,
|
| 707 |
+
)
|
| 708 |
+
} catch (err) {
|
| 709 |
+
logForDebugging(
|
| 710 |
+
`[remote-bridge] Teardown 401 retry threw: ${errorMessage(err)}`,
|
| 711 |
+
{ level: 'error' },
|
| 712 |
+
)
|
| 713 |
+
}
|
| 714 |
+
}
|
| 715 |
+
|
| 716 |
+
transport.close()
|
| 717 |
+
|
| 718 |
+
const archiveStatus: ArchiveTelemetryStatus =
|
| 719 |
+
status === 'no_token'
|
| 720 |
+
? 'skipped_no_token'
|
| 721 |
+
: status === 'timeout' || status === 'error'
|
| 722 |
+
? 'network_error'
|
| 723 |
+
: status >= 500
|
| 724 |
+
? 'server_5xx'
|
| 725 |
+
: status >= 400
|
| 726 |
+
? 'server_4xx'
|
| 727 |
+
: 'ok'
|
| 728 |
+
|
| 729 |
+
logForDebugging(`[remote-bridge] Torn down (archive=${status})`)
|
| 730 |
+
logForDiagnosticsNoPII('info', 'bridge_repl_v2_teardown')
|
| 731 |
+
logEvent(
|
| 732 |
+
feature('CCR_MIRROR') && outboundOnly
|
| 733 |
+
? 'tengu_ccr_mirror_teardown'
|
| 734 |
+
: 'tengu_bridge_repl_teardown',
|
| 735 |
+
{
|
| 736 |
+
v2: true,
|
| 737 |
+
archive_status:
|
| 738 |
+
archiveStatus as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
| 739 |
+
archive_ok: typeof status === 'number' && status < 400,
|
| 740 |
+
archive_http_status: typeof status === 'number' ? status : undefined,
|
| 741 |
+
archive_timeout: status === 'timeout',
|
| 742 |
+
archive_no_token: status === 'no_token',
|
| 743 |
+
},
|
| 744 |
+
)
|
| 745 |
+
}
|
| 746 |
+
const unregister = registerCleanup(teardown)
|
| 747 |
+
|
| 748 |
+
if (feature('CCR_MIRROR') && outboundOnly) {
|
| 749 |
+
logEvent('tengu_ccr_mirror_started', {
|
| 750 |
+
v2: true,
|
| 751 |
+
expires_in_s: credentials.expires_in,
|
| 752 |
+
})
|
| 753 |
+
} else {
|
| 754 |
+
logEvent('tengu_bridge_repl_started', {
|
| 755 |
+
has_initial_messages: !!(initialMessages && initialMessages.length > 0),
|
| 756 |
+
v2: true,
|
| 757 |
+
expires_in_s: credentials.expires_in,
|
| 758 |
+
inProtectedNamespace: isInProtectedNamespace(),
|
| 759 |
+
})
|
| 760 |
+
}
|
| 761 |
+
|
| 762 |
+
// ── 10. Handle ──────────────────────────────────────────────────────────
|
| 763 |
+
return {
|
| 764 |
+
bridgeSessionId: sessionId,
|
| 765 |
+
environmentId: '',
|
| 766 |
+
sessionIngressUrl: credentials.api_base_url,
|
| 767 |
+
writeMessages(messages) {
|
| 768 |
+
const filtered = messages.filter(
|
| 769 |
+
m =>
|
| 770 |
+
isEligibleBridgeMessage(m) &&
|
| 771 |
+
!initialMessageUUIDs.has(m.uuid) &&
|
| 772 |
+
!recentPostedUUIDs.has(m.uuid),
|
| 773 |
+
)
|
| 774 |
+
if (filtered.length === 0) return
|
| 775 |
+
|
| 776 |
+
// Fire onUserMessage for title derivation. Scan before the flushGate
|
| 777 |
+
// check — prompts are title-worthy even if they queue. Keeps calling
|
| 778 |
+
// on every title-worthy message until the callback returns true; the
|
| 779 |
+
// caller owns the policy (derive at 1st and 3rd, skip if explicit).
|
| 780 |
+
if (!userMessageCallbackDone) {
|
| 781 |
+
for (const m of filtered) {
|
| 782 |
+
const text = extractTitleText(m)
|
| 783 |
+
if (text !== undefined && onUserMessage?.(text, sessionId)) {
|
| 784 |
+
userMessageCallbackDone = true
|
| 785 |
+
break
|
| 786 |
+
}
|
| 787 |
+
}
|
| 788 |
+
}
|
| 789 |
+
|
| 790 |
+
if (flushGate.enqueue(...filtered)) {
|
| 791 |
+
logForDebugging(
|
| 792 |
+
`[remote-bridge] Queued ${filtered.length} message(s) during flush`,
|
| 793 |
+
)
|
| 794 |
+
return
|
| 795 |
+
}
|
| 796 |
+
|
| 797 |
+
for (const msg of filtered) recentPostedUUIDs.add(msg.uuid)
|
| 798 |
+
const events = toSDKMessages(filtered).map(m => ({
|
| 799 |
+
...m,
|
| 800 |
+
session_id: sessionId,
|
| 801 |
+
}))
|
| 802 |
+
// v2 does not derive worker_status from events server-side (unlike v1
|
| 803 |
+
// session-ingress session_status_updater.go). Push it from here so the
|
| 804 |
+
// CCR web session list shows Running instead of stuck on Idle. A user
|
| 805 |
+
// message in the batch marks turn start. CCRClient.reportState dedupes
|
| 806 |
+
// consecutive same-state pushes.
|
| 807 |
+
if (filtered.some(m => m.type === 'user')) {
|
| 808 |
+
transport.reportState('running')
|
| 809 |
+
}
|
| 810 |
+
logForDebugging(`[remote-bridge] Sending ${filtered.length} message(s)`)
|
| 811 |
+
void transport.writeBatch(events)
|
| 812 |
+
},
|
| 813 |
+
writeSdkMessages(messages: SDKMessage[]) {
|
| 814 |
+
const filtered = messages.filter(
|
| 815 |
+
m => !m.uuid || !recentPostedUUIDs.has(m.uuid),
|
| 816 |
+
)
|
| 817 |
+
if (filtered.length === 0) return
|
| 818 |
+
for (const msg of filtered) {
|
| 819 |
+
if (msg.uuid) recentPostedUUIDs.add(msg.uuid)
|
| 820 |
+
}
|
| 821 |
+
const events = filtered.map(m => ({ ...m, session_id: sessionId }))
|
| 822 |
+
void transport.writeBatch(events)
|
| 823 |
+
},
|
| 824 |
+
sendControlRequest(request: SDKControlRequest) {
|
| 825 |
+
if (authRecoveryInFlight) {
|
| 826 |
+
logForDebugging(
|
| 827 |
+
`[remote-bridge] Dropping control_request during 401 recovery: ${request.request_id}`,
|
| 828 |
+
)
|
| 829 |
+
return
|
| 830 |
+
}
|
| 831 |
+
const event = { ...request, session_id: sessionId }
|
| 832 |
+
if (request.request.subtype === 'can_use_tool') {
|
| 833 |
+
transport.reportState('requires_action')
|
| 834 |
+
}
|
| 835 |
+
void transport.write(event)
|
| 836 |
+
logForDebugging(
|
| 837 |
+
`[remote-bridge] Sent control_request request_id=${request.request_id}`,
|
| 838 |
+
)
|
| 839 |
+
},
|
| 840 |
+
sendControlResponse(response: SDKControlResponse) {
|
| 841 |
+
if (authRecoveryInFlight) {
|
| 842 |
+
logForDebugging(
|
| 843 |
+
'[remote-bridge] Dropping control_response during 401 recovery',
|
| 844 |
+
)
|
| 845 |
+
return
|
| 846 |
+
}
|
| 847 |
+
const event = { ...response, session_id: sessionId }
|
| 848 |
+
transport.reportState('running')
|
| 849 |
+
void transport.write(event)
|
| 850 |
+
logForDebugging('[remote-bridge] Sent control_response')
|
| 851 |
+
},
|
| 852 |
+
sendControlCancelRequest(requestId: string) {
|
| 853 |
+
if (authRecoveryInFlight) {
|
| 854 |
+
logForDebugging(
|
| 855 |
+
`[remote-bridge] Dropping control_cancel_request during 401 recovery: ${requestId}`,
|
| 856 |
+
)
|
| 857 |
+
return
|
| 858 |
+
}
|
| 859 |
+
const event = {
|
| 860 |
+
type: 'control_cancel_request' as const,
|
| 861 |
+
request_id: requestId,
|
| 862 |
+
session_id: sessionId,
|
| 863 |
+
}
|
| 864 |
+
// Hook/classifier/channel/recheck resolved the permission locally —
|
| 865 |
+
// interactiveHandler calls only cancelRequest (no sendResponse) on
|
| 866 |
+
// those paths, so without this the server stays on requires_action.
|
| 867 |
+
transport.reportState('running')
|
| 868 |
+
void transport.write(event)
|
| 869 |
+
logForDebugging(
|
| 870 |
+
`[remote-bridge] Sent control_cancel_request request_id=${requestId}`,
|
| 871 |
+
)
|
| 872 |
+
},
|
| 873 |
+
sendResult() {
|
| 874 |
+
if (authRecoveryInFlight) {
|
| 875 |
+
logForDebugging('[remote-bridge] Dropping result during 401 recovery')
|
| 876 |
+
return
|
| 877 |
+
}
|
| 878 |
+
transport.reportState('idle')
|
| 879 |
+
void transport.write(makeResultMessage(sessionId))
|
| 880 |
+
logForDebugging(`[remote-bridge] Sent result`)
|
| 881 |
+
},
|
| 882 |
+
async teardown() {
|
| 883 |
+
unregister()
|
| 884 |
+
await teardown()
|
| 885 |
+
},
|
| 886 |
+
}
|
| 887 |
+
}
|
| 888 |
+
|
| 889 |
+
// ─── Session API (v2 /code/sessions, no env) ─────────────────────────────────
|
| 890 |
+
|
| 891 |
+
/** Retry an async init call with exponential backoff + jitter. */
|
| 892 |
+
async function withRetry<T>(
|
| 893 |
+
fn: () => Promise<T | null>,
|
| 894 |
+
label: string,
|
| 895 |
+
cfg: EnvLessBridgeConfig,
|
| 896 |
+
): Promise<T | null> {
|
| 897 |
+
const max = cfg.init_retry_max_attempts
|
| 898 |
+
for (let attempt = 1; attempt <= max; attempt++) {
|
| 899 |
+
const result = await fn()
|
| 900 |
+
if (result !== null) return result
|
| 901 |
+
if (attempt < max) {
|
| 902 |
+
const base = cfg.init_retry_base_delay_ms * 2 ** (attempt - 1)
|
| 903 |
+
const jitter =
|
| 904 |
+
base * cfg.init_retry_jitter_fraction * (2 * Math.random() - 1)
|
| 905 |
+
const delay = Math.min(base + jitter, cfg.init_retry_max_delay_ms)
|
| 906 |
+
logForDebugging(
|
| 907 |
+
`[remote-bridge] ${label} failed (attempt ${attempt}/${max}), retrying in ${Math.round(delay)}ms`,
|
| 908 |
+
)
|
| 909 |
+
await sleep(delay)
|
| 910 |
+
}
|
| 911 |
+
}
|
| 912 |
+
return null
|
| 913 |
+
}
|
| 914 |
+
|
| 915 |
+
// Moved to codeSessionApi.ts so the SDK /bridge subpath can bundle them
|
| 916 |
+
// without pulling in this file's heavy CLI tree (analytics, transport).
|
| 917 |
+
export {
|
| 918 |
+
createCodeSession,
|
| 919 |
+
type RemoteCredentials,
|
| 920 |
+
} from './codeSessionApi.js'
|
| 921 |
+
import {
|
| 922 |
+
createCodeSession,
|
| 923 |
+
fetchRemoteCredentials as fetchRemoteCredentialsRaw,
|
| 924 |
+
type RemoteCredentials,
|
| 925 |
+
} from './codeSessionApi.js'
|
| 926 |
+
import { getBridgeBaseUrlOverride } from './bridgeConfig.js'
|
| 927 |
+
|
| 928 |
+
// CLI-side wrapper that applies the CLAUDE_BRIDGE_BASE_URL dev override and
|
| 929 |
+
// injects the trusted-device token (both are env/GrowthBook reads that the
|
| 930 |
+
// SDK-facing codeSessionApi.ts export must stay free of).
|
| 931 |
+
export async function fetchRemoteCredentials(
|
| 932 |
+
sessionId: string,
|
| 933 |
+
baseUrl: string,
|
| 934 |
+
accessToken: string,
|
| 935 |
+
timeoutMs: number,
|
| 936 |
+
): Promise<RemoteCredentials | null> {
|
| 937 |
+
const creds = await fetchRemoteCredentialsRaw(
|
| 938 |
+
sessionId,
|
| 939 |
+
baseUrl,
|
| 940 |
+
accessToken,
|
| 941 |
+
timeoutMs,
|
| 942 |
+
getTrustedDeviceToken(),
|
| 943 |
+
)
|
| 944 |
+
if (!creds) return null
|
| 945 |
+
return getBridgeBaseUrlOverride()
|
| 946 |
+
? { ...creds, api_base_url: baseUrl }
|
| 947 |
+
: creds
|
| 948 |
+
}
|
| 949 |
+
|
| 950 |
+
type ArchiveStatus = number | 'timeout' | 'error' | 'no_token'
|
| 951 |
+
|
| 952 |
+
// Single categorical for BQ `GROUP BY archive_status`. The booleans on
|
| 953 |
+
// _teardown predate this and are redundant with it (except archive_timeout,
|
| 954 |
+
// which distinguishes ECONNABORTED from other network errors — both map to
|
| 955 |
+
// 'network_error' here since the dominant cause in a 1.5s window is timeout).
|
| 956 |
+
type ArchiveTelemetryStatus =
|
| 957 |
+
| 'ok'
|
| 958 |
+
| 'skipped_no_token'
|
| 959 |
+
| 'network_error'
|
| 960 |
+
| 'server_4xx'
|
| 961 |
+
| 'server_5xx'
|
| 962 |
+
|
| 963 |
+
async function archiveSession(
|
| 964 |
+
sessionId: string,
|
| 965 |
+
baseUrl: string,
|
| 966 |
+
accessToken: string | undefined,
|
| 967 |
+
orgUUID: string,
|
| 968 |
+
timeoutMs: number,
|
| 969 |
+
): Promise<ArchiveStatus> {
|
| 970 |
+
if (!accessToken) return 'no_token'
|
| 971 |
+
// Archive lives at the compat layer (/v1/sessions/*, not /v1/code/sessions).
|
| 972 |
+
// compat.parseSessionID only accepts TagSession (session_*), so retag cse_*.
|
| 973 |
+
// anthropic-beta + x-organization-uuid are required — without them the
|
| 974 |
+
// compat gateway 404s before reaching the handler.
|
| 975 |
+
//
|
| 976 |
+
// Unlike bridgeMain.ts (which caches compatId in sessionCompatIds to keep
|
| 977 |
+
// in-memory titledSessions/logger keys consistent across a mid-session
|
| 978 |
+
// gate flip), this compatId is only a server URL path segment — no
|
| 979 |
+
// in-memory state. Fresh compute matches whatever the server currently
|
| 980 |
+
// validates: if the gate is OFF, the server has been updated to accept
|
| 981 |
+
// cse_* and we correctly send it.
|
| 982 |
+
const compatId = toCompatSessionId(sessionId)
|
| 983 |
+
try {
|
| 984 |
+
const response = await axios.post(
|
| 985 |
+
`${baseUrl}/v1/sessions/${compatId}/archive`,
|
| 986 |
+
{},
|
| 987 |
+
{
|
| 988 |
+
headers: {
|
| 989 |
+
...oauthHeaders(accessToken),
|
| 990 |
+
'anthropic-beta': 'ccr-byoc-2025-07-29',
|
| 991 |
+
'x-organization-uuid': orgUUID,
|
| 992 |
+
},
|
| 993 |
+
timeout: timeoutMs,
|
| 994 |
+
validateStatus: () => true,
|
| 995 |
+
},
|
| 996 |
+
)
|
| 997 |
+
logForDebugging(
|
| 998 |
+
`[remote-bridge] Archive ${compatId} status=${response.status}`,
|
| 999 |
+
)
|
| 1000 |
+
return response.status
|
| 1001 |
+
} catch (err) {
|
| 1002 |
+
const msg = errorMessage(err)
|
| 1003 |
+
logForDebugging(`[remote-bridge] Archive failed: ${msg}`)
|
| 1004 |
+
return axios.isAxiosError(err) && err.code === 'ECONNABORTED'
|
| 1005 |
+
? 'timeout'
|
| 1006 |
+
: 'error'
|
| 1007 |
+
}
|
| 1008 |
+
}
|
src/bridge/replBridge.ts
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
src/bridge/replBridgeHandle.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { updateSessionBridgeId } from '../utils/concurrentSessions.js'
|
| 2 |
+
import type { ReplBridgeHandle } from './replBridge.js'
|
| 3 |
+
import { toCompatSessionId } from './sessionIdCompat.js'
|
| 4 |
+
|
| 5 |
+
/**
|
| 6 |
+
* Global pointer to the active REPL bridge handle, so callers outside
|
| 7 |
+
* useReplBridge's React tree (tools, slash commands) can invoke handle methods
|
| 8 |
+
* like subscribePR. Same one-bridge-per-process justification as bridgeDebug.ts
|
| 9 |
+
* — the handle's closure captures the sessionId and getAccessToken that created
|
| 10 |
+
* the session, and re-deriving those independently (BriefTool/upload.ts pattern)
|
| 11 |
+
* risks staging/prod token divergence.
|
| 12 |
+
*
|
| 13 |
+
* Set from useReplBridge.tsx when init completes; cleared on teardown.
|
| 14 |
+
*/
|
| 15 |
+
|
| 16 |
+
let handle: ReplBridgeHandle | null = null
|
| 17 |
+
|
| 18 |
+
export function setReplBridgeHandle(h: ReplBridgeHandle | null): void {
|
| 19 |
+
handle = h
|
| 20 |
+
// Publish (or clear) our bridge session ID in the session record so other
|
| 21 |
+
// local peers can dedup us out of their bridge list — local is preferred.
|
| 22 |
+
void updateSessionBridgeId(getSelfBridgeCompatId() ?? null).catch(() => {})
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
export function getReplBridgeHandle(): ReplBridgeHandle | null {
|
| 26 |
+
return handle
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
/**
|
| 30 |
+
* Our own bridge session ID in the session_* compat format the API returns
|
| 31 |
+
* in /v1/sessions responses — or undefined if bridge isn't connected.
|
| 32 |
+
*/
|
| 33 |
+
export function getSelfBridgeCompatId(): string | undefined {
|
| 34 |
+
const h = getReplBridgeHandle()
|
| 35 |
+
return h ? toCompatSessionId(h.bridgeSessionId) : undefined
|
| 36 |
+
}
|
src/bridge/replBridgeTransport.ts
ADDED
|
@@ -0,0 +1,370 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { StdoutMessage } from 'src/entrypoints/sdk/controlTypes.js'
|
| 2 |
+
import { CCRClient } from '../cli/transports/ccrClient.js'
|
| 3 |
+
import type { HybridTransport } from '../cli/transports/HybridTransport.js'
|
| 4 |
+
import { SSETransport } from '../cli/transports/SSETransport.js'
|
| 5 |
+
import { logForDebugging } from '../utils/debug.js'
|
| 6 |
+
import { errorMessage } from '../utils/errors.js'
|
| 7 |
+
import { updateSessionIngressAuthToken } from '../utils/sessionIngressAuth.js'
|
| 8 |
+
import type { SessionState } from '../utils/sessionState.js'
|
| 9 |
+
import { registerWorker } from './workSecret.js'
|
| 10 |
+
|
| 11 |
+
/**
|
| 12 |
+
* Transport abstraction for replBridge. Covers exactly the surface that
|
| 13 |
+
* replBridge.ts uses against HybridTransport so the v1/v2 choice is
|
| 14 |
+
* confined to the construction site.
|
| 15 |
+
*
|
| 16 |
+
* - v1: HybridTransport (WS reads + POST writes to Session-Ingress)
|
| 17 |
+
* - v2: SSETransport (reads) + CCRClient (writes to CCR v2 /worker/*)
|
| 18 |
+
*
|
| 19 |
+
* The v2 write path goes through CCRClient.writeEvent → SerialBatchEventUploader,
|
| 20 |
+
* NOT through SSETransport.write() — SSETransport.write() targets the
|
| 21 |
+
* Session-Ingress POST URL shape, which is wrong for CCR v2.
|
| 22 |
+
*/
|
| 23 |
+
export type ReplBridgeTransport = {
|
| 24 |
+
write(message: StdoutMessage): Promise<void>
|
| 25 |
+
writeBatch(messages: StdoutMessage[]): Promise<void>
|
| 26 |
+
close(): void
|
| 27 |
+
isConnectedStatus(): boolean
|
| 28 |
+
getStateLabel(): string
|
| 29 |
+
setOnData(callback: (data: string) => void): void
|
| 30 |
+
setOnClose(callback: (closeCode?: number) => void): void
|
| 31 |
+
setOnConnect(callback: () => void): void
|
| 32 |
+
connect(): void
|
| 33 |
+
/**
|
| 34 |
+
* High-water mark of the underlying read stream's event sequence numbers.
|
| 35 |
+
* replBridge reads this before swapping transports so the new one can
|
| 36 |
+
* resume from where the old one left off (otherwise the server replays
|
| 37 |
+
* the entire session history from seq 0).
|
| 38 |
+
*
|
| 39 |
+
* v1 returns 0 — Session-Ingress WS doesn't use SSE sequence numbers;
|
| 40 |
+
* replay-on-reconnect is handled by the server-side message cursor.
|
| 41 |
+
*/
|
| 42 |
+
getLastSequenceNum(): number
|
| 43 |
+
/**
|
| 44 |
+
* Monotonic count of batches dropped via maxConsecutiveFailures.
|
| 45 |
+
* Snapshot before writeBatch() and compare after to detect silent drops
|
| 46 |
+
* (writeBatch() resolves normally even when batches were dropped).
|
| 47 |
+
* v2 returns 0 — the v2 write path doesn't set maxConsecutiveFailures.
|
| 48 |
+
*/
|
| 49 |
+
readonly droppedBatchCount: number
|
| 50 |
+
/**
|
| 51 |
+
* PUT /worker state (v2 only; v1 is a no-op). `requires_action` tells
|
| 52 |
+
* the backend a permission prompt is pending — claude.ai shows the
|
| 53 |
+
* "waiting for input" indicator. REPL/daemon callers don't need this
|
| 54 |
+
* (user watches the REPL locally); multi-session worker callers do.
|
| 55 |
+
*/
|
| 56 |
+
reportState(state: SessionState): void
|
| 57 |
+
/** PUT /worker external_metadata (v2 only; v1 is a no-op). */
|
| 58 |
+
reportMetadata(metadata: Record<string, unknown>): void
|
| 59 |
+
/**
|
| 60 |
+
* POST /worker/events/{id}/delivery (v2 only; v1 is a no-op). Populates
|
| 61 |
+
* CCR's processing_at/processed_at columns. `received` is auto-fired by
|
| 62 |
+
* CCRClient on every SSE frame and is not exposed here.
|
| 63 |
+
*/
|
| 64 |
+
reportDelivery(eventId: string, status: 'processing' | 'processed'): void
|
| 65 |
+
/**
|
| 66 |
+
* Drain the write queue before close() (v2 only; v1 resolves
|
| 67 |
+
* immediately — HybridTransport POSTs are already awaited per-write).
|
| 68 |
+
*/
|
| 69 |
+
flush(): Promise<void>
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
/**
|
| 73 |
+
* v1 adapter: HybridTransport already has the full surface (it extends
|
| 74 |
+
* WebSocketTransport which has setOnConnect + getStateLabel). This is a
|
| 75 |
+
* no-op wrapper that exists only so replBridge's `transport` variable
|
| 76 |
+
* has a single type.
|
| 77 |
+
*/
|
| 78 |
+
export function createV1ReplTransport(
|
| 79 |
+
hybrid: HybridTransport,
|
| 80 |
+
): ReplBridgeTransport {
|
| 81 |
+
return {
|
| 82 |
+
write: msg => hybrid.write(msg),
|
| 83 |
+
writeBatch: msgs => hybrid.writeBatch(msgs),
|
| 84 |
+
close: () => hybrid.close(),
|
| 85 |
+
isConnectedStatus: () => hybrid.isConnectedStatus(),
|
| 86 |
+
getStateLabel: () => hybrid.getStateLabel(),
|
| 87 |
+
setOnData: cb => hybrid.setOnData(cb),
|
| 88 |
+
setOnClose: cb => hybrid.setOnClose(cb),
|
| 89 |
+
setOnConnect: cb => hybrid.setOnConnect(cb),
|
| 90 |
+
connect: () => void hybrid.connect(),
|
| 91 |
+
// v1 Session-Ingress WS doesn't use SSE sequence numbers; replay
|
| 92 |
+
// semantics are different. Always return 0 so the seq-num carryover
|
| 93 |
+
// logic in replBridge is a no-op for v1.
|
| 94 |
+
getLastSequenceNum: () => 0,
|
| 95 |
+
get droppedBatchCount() {
|
| 96 |
+
return hybrid.droppedBatchCount
|
| 97 |
+
},
|
| 98 |
+
reportState: () => {},
|
| 99 |
+
reportMetadata: () => {},
|
| 100 |
+
reportDelivery: () => {},
|
| 101 |
+
flush: () => Promise.resolve(),
|
| 102 |
+
}
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
/**
|
| 106 |
+
* v2 adapter: wrap SSETransport (reads) + CCRClient (writes, heartbeat,
|
| 107 |
+
* state, delivery tracking).
|
| 108 |
+
*
|
| 109 |
+
* Auth: v2 endpoints validate the JWT's session_id claim (register_worker.go:32)
|
| 110 |
+
* and worker role (environment_auth.py:856). OAuth tokens have neither.
|
| 111 |
+
* This is the inverse of the v1 replBridge path, which deliberately uses OAuth.
|
| 112 |
+
* The JWT is refreshed when the poll loop re-dispatches work — the caller
|
| 113 |
+
* invokes createV2ReplTransport again with the fresh token.
|
| 114 |
+
*
|
| 115 |
+
* Registration happens here (not in the caller) so the entire v2 handshake
|
| 116 |
+
* is one async step. registerWorker failure propagates — replBridge will
|
| 117 |
+
* catch it and stay on the poll loop.
|
| 118 |
+
*/
|
| 119 |
+
export async function createV2ReplTransport(opts: {
|
| 120 |
+
sessionUrl: string
|
| 121 |
+
ingressToken: string
|
| 122 |
+
sessionId: string
|
| 123 |
+
/**
|
| 124 |
+
* SSE sequence-number high-water mark from the previous transport.
|
| 125 |
+
* Passed to the new SSETransport so its first connect() sends
|
| 126 |
+
* from_sequence_num / Last-Event-ID and the server resumes from where
|
| 127 |
+
* the old stream left off. Without this, every transport swap asks the
|
| 128 |
+
* server to replay the entire session history from seq 0.
|
| 129 |
+
*/
|
| 130 |
+
initialSequenceNum?: number
|
| 131 |
+
/**
|
| 132 |
+
* Worker epoch from POST /bridge response. When provided, the server
|
| 133 |
+
* already bumped epoch (the /bridge call IS the register — see server
|
| 134 |
+
* PR #293280). When omitted (v1 CCR-v2 path via replBridge.ts poll loop),
|
| 135 |
+
* call registerWorker as before.
|
| 136 |
+
*/
|
| 137 |
+
epoch?: number
|
| 138 |
+
/** CCRClient heartbeat interval. Defaults to 20s when omitted. */
|
| 139 |
+
heartbeatIntervalMs?: number
|
| 140 |
+
/** ±fraction per-beat jitter. Defaults to 0 (no jitter) when omitted. */
|
| 141 |
+
heartbeatJitterFraction?: number
|
| 142 |
+
/**
|
| 143 |
+
* When true, skip opening the SSE read stream — only the CCRClient write
|
| 144 |
+
* path is activated. Use for mirror-mode attachments that forward events
|
| 145 |
+
* but never receive inbound prompts or control requests.
|
| 146 |
+
*/
|
| 147 |
+
outboundOnly?: boolean
|
| 148 |
+
/**
|
| 149 |
+
* Per-instance auth header source. When provided, CCRClient + SSETransport
|
| 150 |
+
* read auth from this closure instead of the process-wide
|
| 151 |
+
* CLAUDE_CODE_SESSION_ACCESS_TOKEN env var. Required for callers managing
|
| 152 |
+
* multiple concurrent sessions — the env-var path stomps across sessions.
|
| 153 |
+
* When omitted, falls back to the env var (single-session callers).
|
| 154 |
+
*/
|
| 155 |
+
getAuthToken?: () => string | undefined
|
| 156 |
+
}): Promise<ReplBridgeTransport> {
|
| 157 |
+
const {
|
| 158 |
+
sessionUrl,
|
| 159 |
+
ingressToken,
|
| 160 |
+
sessionId,
|
| 161 |
+
initialSequenceNum,
|
| 162 |
+
getAuthToken,
|
| 163 |
+
} = opts
|
| 164 |
+
|
| 165 |
+
// Auth header builder. If getAuthToken is provided, read from it
|
| 166 |
+
// (per-instance, multi-session safe). Otherwise write ingressToken to
|
| 167 |
+
// the process-wide env var (legacy single-session path — CCRClient's
|
| 168 |
+
// default getAuthHeaders reads it via getSessionIngressAuthHeaders).
|
| 169 |
+
let getAuthHeaders: (() => Record<string, string>) | undefined
|
| 170 |
+
if (getAuthToken) {
|
| 171 |
+
getAuthHeaders = (): Record<string, string> => {
|
| 172 |
+
const token = getAuthToken()
|
| 173 |
+
if (!token) return {}
|
| 174 |
+
return { Authorization: `Bearer ${token}` }
|
| 175 |
+
}
|
| 176 |
+
} else {
|
| 177 |
+
// CCRClient.request() and SSETransport.connect() both read auth via
|
| 178 |
+
// getSessionIngressAuthHeaders() → this env var. Set it before either
|
| 179 |
+
// touches the network.
|
| 180 |
+
updateSessionIngressAuthToken(ingressToken)
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
const epoch = opts.epoch ?? (await registerWorker(sessionUrl, ingressToken))
|
| 184 |
+
logForDebugging(
|
| 185 |
+
`[bridge:repl] CCR v2: worker sessionId=${sessionId} epoch=${epoch}${opts.epoch !== undefined ? ' (from /bridge)' : ' (via registerWorker)'}`,
|
| 186 |
+
)
|
| 187 |
+
|
| 188 |
+
// Derive SSE stream URL. Same logic as transportUtils.ts:26-33 but
|
| 189 |
+
// starting from an http(s) base instead of a --sdk-url that might be ws://.
|
| 190 |
+
const sseUrl = new URL(sessionUrl)
|
| 191 |
+
sseUrl.pathname = sseUrl.pathname.replace(/\/$/, '') + '/worker/events/stream'
|
| 192 |
+
|
| 193 |
+
const sse = new SSETransport(
|
| 194 |
+
sseUrl,
|
| 195 |
+
{},
|
| 196 |
+
sessionId,
|
| 197 |
+
undefined,
|
| 198 |
+
initialSequenceNum,
|
| 199 |
+
getAuthHeaders,
|
| 200 |
+
)
|
| 201 |
+
let onCloseCb: ((closeCode?: number) => void) | undefined
|
| 202 |
+
const ccr = new CCRClient(sse, new URL(sessionUrl), {
|
| 203 |
+
getAuthHeaders,
|
| 204 |
+
heartbeatIntervalMs: opts.heartbeatIntervalMs,
|
| 205 |
+
heartbeatJitterFraction: opts.heartbeatJitterFraction,
|
| 206 |
+
// Default is process.exit(1) — correct for spawn-mode children. In-process,
|
| 207 |
+
// that kills the REPL. Close instead: replBridge's onClose wakes the poll
|
| 208 |
+
// loop, which picks up the server's re-dispatch (with fresh epoch).
|
| 209 |
+
onEpochMismatch: () => {
|
| 210 |
+
logForDebugging(
|
| 211 |
+
'[bridge:repl] CCR v2: epoch superseded (409) — closing for poll-loop recovery',
|
| 212 |
+
)
|
| 213 |
+
// Close resources in a try block so the throw always executes.
|
| 214 |
+
// If ccr.close() or sse.close() throw, we still need to unwind
|
| 215 |
+
// the caller (request()) — otherwise handleEpochMismatch's `never`
|
| 216 |
+
// return type is violated at runtime and control falls through.
|
| 217 |
+
try {
|
| 218 |
+
ccr.close()
|
| 219 |
+
sse.close()
|
| 220 |
+
onCloseCb?.(4090)
|
| 221 |
+
} catch (closeErr: unknown) {
|
| 222 |
+
logForDebugging(
|
| 223 |
+
`[bridge:repl] CCR v2: error during epoch-mismatch cleanup: ${errorMessage(closeErr)}`,
|
| 224 |
+
{ level: 'error' },
|
| 225 |
+
)
|
| 226 |
+
}
|
| 227 |
+
// Don't return — the calling request() code continues after the 409
|
| 228 |
+
// branch, so callers see the logged warning and a false return. We
|
| 229 |
+
// throw to unwind; the uploaders catch it as a send failure.
|
| 230 |
+
throw new Error('epoch superseded')
|
| 231 |
+
},
|
| 232 |
+
})
|
| 233 |
+
|
| 234 |
+
// CCRClient's constructor wired sse.setOnEvent → reportDelivery('received').
|
| 235 |
+
// remoteIO.ts additionally sends 'processing'/'processed' via
|
| 236 |
+
// setCommandLifecycleListener, which the in-process query loop fires. This
|
| 237 |
+
// transport's only caller (replBridge/daemonBridge) has no such wiring — the
|
| 238 |
+
// daemon's agent child is a separate process (ProcessTransport), and its
|
| 239 |
+
// notifyCommandLifecycle calls fire with listener=null in its own module
|
| 240 |
+
// scope. So events stay at 'received' forever, and reconnectSession re-queues
|
| 241 |
+
// them on every daemon restart (observed: 21→24→25 phantom prompts as
|
| 242 |
+
// "user sent a new message while you were working" system-reminders).
|
| 243 |
+
//
|
| 244 |
+
// Fix: ACK 'processed' immediately alongside 'received'. The window between
|
| 245 |
+
// SSE receipt and transcript-write is narrow (queue → SDK → child stdin →
|
| 246 |
+
// model); a crash there loses one prompt vs. the observed N-prompt flood on
|
| 247 |
+
// every restart. Overwrite the constructor's wiring to do both — setOnEvent
|
| 248 |
+
// replaces, not appends (SSETransport.ts:658).
|
| 249 |
+
sse.setOnEvent(event => {
|
| 250 |
+
ccr.reportDelivery(event.event_id, 'received')
|
| 251 |
+
ccr.reportDelivery(event.event_id, 'processed')
|
| 252 |
+
})
|
| 253 |
+
|
| 254 |
+
// Both sse.connect() and ccr.initialize() are deferred to connect() below.
|
| 255 |
+
// replBridge's calling order is newTransport → setOnConnect → setOnData →
|
| 256 |
+
// setOnClose → connect(), and both calls need those callbacks wired first:
|
| 257 |
+
// sse.connect() opens the stream (events flow to onData/onClose immediately),
|
| 258 |
+
// and ccr.initialize().then() fires onConnectCb.
|
| 259 |
+
//
|
| 260 |
+
// onConnect fires once ccr.initialize() resolves. Writes go via
|
| 261 |
+
// CCRClient HTTP POST (SerialBatchEventUploader), not SSE, so the
|
| 262 |
+
// write path is ready the moment workerEpoch is set. SSE.connect()
|
| 263 |
+
// awaits its read loop and never resolves — don't gate on it.
|
| 264 |
+
// The SSE stream opens in parallel (~30ms) and starts delivering
|
| 265 |
+
// inbound events via setOnData; outbound doesn't need to wait for it.
|
| 266 |
+
let onConnectCb: (() => void) | undefined
|
| 267 |
+
let ccrInitialized = false
|
| 268 |
+
let closed = false
|
| 269 |
+
|
| 270 |
+
return {
|
| 271 |
+
write(msg) {
|
| 272 |
+
return ccr.writeEvent(msg)
|
| 273 |
+
},
|
| 274 |
+
async writeBatch(msgs) {
|
| 275 |
+
// SerialBatchEventUploader already batches internally (maxBatchSize=100);
|
| 276 |
+
// sequential enqueue preserves order and the uploader coalesces.
|
| 277 |
+
// Check closed between writes to avoid sending partial batches after
|
| 278 |
+
// transport teardown (epoch mismatch, SSE drop).
|
| 279 |
+
for (const m of msgs) {
|
| 280 |
+
if (closed) break
|
| 281 |
+
await ccr.writeEvent(m)
|
| 282 |
+
}
|
| 283 |
+
},
|
| 284 |
+
close() {
|
| 285 |
+
closed = true
|
| 286 |
+
ccr.close()
|
| 287 |
+
sse.close()
|
| 288 |
+
},
|
| 289 |
+
isConnectedStatus() {
|
| 290 |
+
// Write-readiness, not read-readiness — replBridge checks this
|
| 291 |
+
// before calling writeBatch. SSE open state is orthogonal.
|
| 292 |
+
return ccrInitialized
|
| 293 |
+
},
|
| 294 |
+
getStateLabel() {
|
| 295 |
+
// SSETransport doesn't expose its state string; synthesize from
|
| 296 |
+
// what we can observe. replBridge only uses this for debug logging.
|
| 297 |
+
if (sse.isClosedStatus()) return 'closed'
|
| 298 |
+
if (sse.isConnectedStatus()) return ccrInitialized ? 'connected' : 'init'
|
| 299 |
+
return 'connecting'
|
| 300 |
+
},
|
| 301 |
+
setOnData(cb) {
|
| 302 |
+
sse.setOnData(cb)
|
| 303 |
+
},
|
| 304 |
+
setOnClose(cb) {
|
| 305 |
+
onCloseCb = cb
|
| 306 |
+
// SSE reconnect-budget exhaustion fires onClose(undefined) — map to
|
| 307 |
+
// 4092 so ws_closed telemetry can distinguish it from HTTP-status
|
| 308 |
+
// closes (SSETransport:280 passes response.status). Stop CCRClient's
|
| 309 |
+
// heartbeat timer before notifying replBridge. (sse.close() doesn't
|
| 310 |
+
// invoke this, so the epoch-mismatch path above isn't double-firing.)
|
| 311 |
+
sse.setOnClose(code => {
|
| 312 |
+
ccr.close()
|
| 313 |
+
cb(code ?? 4092)
|
| 314 |
+
})
|
| 315 |
+
},
|
| 316 |
+
setOnConnect(cb) {
|
| 317 |
+
onConnectCb = cb
|
| 318 |
+
},
|
| 319 |
+
getLastSequenceNum() {
|
| 320 |
+
return sse.getLastSequenceNum()
|
| 321 |
+
},
|
| 322 |
+
// v2 write path (CCRClient) doesn't set maxConsecutiveFailures — no drops.
|
| 323 |
+
droppedBatchCount: 0,
|
| 324 |
+
reportState(state) {
|
| 325 |
+
ccr.reportState(state)
|
| 326 |
+
},
|
| 327 |
+
reportMetadata(metadata) {
|
| 328 |
+
ccr.reportMetadata(metadata)
|
| 329 |
+
},
|
| 330 |
+
reportDelivery(eventId, status) {
|
| 331 |
+
ccr.reportDelivery(eventId, status)
|
| 332 |
+
},
|
| 333 |
+
flush() {
|
| 334 |
+
return ccr.flush()
|
| 335 |
+
},
|
| 336 |
+
connect() {
|
| 337 |
+
// Outbound-only: skip the SSE read stream entirely — no inbound
|
| 338 |
+
// events to receive, no delivery ACKs to send. Only the CCRClient
|
| 339 |
+
// write path (POST /worker/events) and heartbeat are needed.
|
| 340 |
+
if (!opts.outboundOnly) {
|
| 341 |
+
// Fire-and-forget — SSETransport.connect() awaits readStream()
|
| 342 |
+
// (the read loop) and only resolves on stream close/error. The
|
| 343 |
+
// spawn-mode path in remoteIO.ts does the same void discard.
|
| 344 |
+
void sse.connect()
|
| 345 |
+
}
|
| 346 |
+
void ccr.initialize(epoch).then(
|
| 347 |
+
() => {
|
| 348 |
+
ccrInitialized = true
|
| 349 |
+
logForDebugging(
|
| 350 |
+
`[bridge:repl] v2 transport ready for writes (epoch=${epoch}, sse=${sse.isConnectedStatus() ? 'open' : 'opening'})`,
|
| 351 |
+
)
|
| 352 |
+
onConnectCb?.()
|
| 353 |
+
},
|
| 354 |
+
(err: unknown) => {
|
| 355 |
+
logForDebugging(
|
| 356 |
+
`[bridge:repl] CCR v2 initialize failed: ${errorMessage(err)}`,
|
| 357 |
+
{ level: 'error' },
|
| 358 |
+
)
|
| 359 |
+
// Close transport resources and notify replBridge via onClose
|
| 360 |
+
// so the poll loop can retry on the next work dispatch.
|
| 361 |
+
// Without this callback, replBridge never learns the transport
|
| 362 |
+
// failed to initialize and sits with transport === null forever.
|
| 363 |
+
ccr.close()
|
| 364 |
+
sse.close()
|
| 365 |
+
onCloseCb?.(4091) // 4091 = init failure, distinguishable from 4090 epoch mismatch
|
| 366 |
+
},
|
| 367 |
+
)
|
| 368 |
+
},
|
| 369 |
+
}
|
| 370 |
+
}
|
src/bridge/sessionIdCompat.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Session ID tag translation helpers for the CCR v2 compat layer.
|
| 3 |
+
*
|
| 4 |
+
* Lives in its own file (rather than workSecret.ts) so that sessionHandle.ts
|
| 5 |
+
* and replBridgeTransport.ts (bridge.mjs entry points) can import from
|
| 6 |
+
* workSecret.ts without pulling in these retag functions.
|
| 7 |
+
*
|
| 8 |
+
* The isCseShimEnabled kill switch is injected via setCseShimGate() to avoid
|
| 9 |
+
* a static import of bridgeEnabled.ts → growthbook.ts → config.ts — all
|
| 10 |
+
* banned from the sdk.mjs bundle (scripts/build-agent-sdk.sh). Callers that
|
| 11 |
+
* already import bridgeEnabled.ts register the gate; the SDK path never does,
|
| 12 |
+
* so the shim defaults to active (matching isCseShimEnabled()'s own default).
|
| 13 |
+
*/
|
| 14 |
+
|
| 15 |
+
let _isCseShimEnabled: (() => boolean) | undefined
|
| 16 |
+
|
| 17 |
+
/**
|
| 18 |
+
* Register the GrowthBook gate for the cse_ shim. Called from bridge
|
| 19 |
+
* init code that already imports bridgeEnabled.ts.
|
| 20 |
+
*/
|
| 21 |
+
export function setCseShimGate(gate: () => boolean): void {
|
| 22 |
+
_isCseShimEnabled = gate
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
/**
|
| 26 |
+
* Re-tag a `cse_*` session ID to `session_*` for use with the v1 compat API.
|
| 27 |
+
*
|
| 28 |
+
* Worker endpoints (/v1/code/sessions/{id}/worker/*) want `cse_*`; that's
|
| 29 |
+
* what the work poll delivers. Client-facing compat endpoints
|
| 30 |
+
* (/v1/sessions/{id}, /v1/sessions/{id}/archive, /v1/sessions/{id}/events)
|
| 31 |
+
* want `session_*` — compat/convert.go:27 validates TagSession. Same UUID,
|
| 32 |
+
* different costume. No-op for IDs that aren't `cse_*`.
|
| 33 |
+
*
|
| 34 |
+
* bridgeMain holds one sessionId variable for both worker registration and
|
| 35 |
+
* session-management calls. It arrives as `cse_*` from the work poll under
|
| 36 |
+
* the compat gate, so archiveSession/fetchSessionTitle need this re-tag.
|
| 37 |
+
*/
|
| 38 |
+
export function toCompatSessionId(id: string): string {
|
| 39 |
+
if (!id.startsWith('cse_')) return id
|
| 40 |
+
if (_isCseShimEnabled && !_isCseShimEnabled()) return id
|
| 41 |
+
return 'session_' + id.slice('cse_'.length)
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
/**
|
| 45 |
+
* Re-tag a `session_*` session ID to `cse_*` for infrastructure-layer calls.
|
| 46 |
+
*
|
| 47 |
+
* Inverse of toCompatSessionId. POST /v1/environments/{id}/bridge/reconnect
|
| 48 |
+
* lives below the compat layer: once ccr_v2_compat_enabled is on server-side,
|
| 49 |
+
* it looks sessions up by their infra tag (`cse_*`). createBridgeSession still
|
| 50 |
+
* returns `session_*` (compat/convert.go:41) and that's what bridge-pointer
|
| 51 |
+
* stores — so perpetual reconnect passes the wrong costume and gets "Session
|
| 52 |
+
* not found" back. Same UUID, wrong tag. No-op for IDs that aren't `session_*`.
|
| 53 |
+
*/
|
| 54 |
+
export function toInfraSessionId(id: string): string {
|
| 55 |
+
if (!id.startsWith('session_')) return id
|
| 56 |
+
return 'cse_' + id.slice('session_'.length)
|
| 57 |
+
}
|
src/bridge/sessionRunner.ts
ADDED
|
@@ -0,0 +1,550 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { type ChildProcess, spawn } from 'child_process'
|
| 2 |
+
import { createWriteStream, type WriteStream } from 'fs'
|
| 3 |
+
import { tmpdir } from 'os'
|
| 4 |
+
import { dirname, join } from 'path'
|
| 5 |
+
import { createInterface } from 'readline'
|
| 6 |
+
import { jsonParse, jsonStringify } from '../utils/slowOperations.js'
|
| 7 |
+
import { debugTruncate } from './debugUtils.js'
|
| 8 |
+
import type {
|
| 9 |
+
SessionActivity,
|
| 10 |
+
SessionDoneStatus,
|
| 11 |
+
SessionHandle,
|
| 12 |
+
SessionSpawner,
|
| 13 |
+
SessionSpawnOpts,
|
| 14 |
+
} from './types.js'
|
| 15 |
+
|
| 16 |
+
const MAX_ACTIVITIES = 10
|
| 17 |
+
const MAX_STDERR_LINES = 10
|
| 18 |
+
|
| 19 |
+
/**
|
| 20 |
+
* Sanitize a session ID for use in file names.
|
| 21 |
+
* Strips any characters that could cause path traversal (e.g. `../`, `/`)
|
| 22 |
+
* or other filesystem issues, replacing them with underscores.
|
| 23 |
+
*/
|
| 24 |
+
export function safeFilenameId(id: string): string {
|
| 25 |
+
return id.replace(/[^a-zA-Z0-9_-]/g, '_')
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
/**
|
| 29 |
+
* A control_request emitted by the child CLI when it needs permission to
|
| 30 |
+
* execute a **specific** tool invocation (not a general capability check).
|
| 31 |
+
* The bridge forwards this to the server so the user can approve/deny.
|
| 32 |
+
*/
|
| 33 |
+
export type PermissionRequest = {
|
| 34 |
+
type: 'control_request'
|
| 35 |
+
request_id: string
|
| 36 |
+
request: {
|
| 37 |
+
/** Per-invocation permission check — "may I run this tool with these inputs?" */
|
| 38 |
+
subtype: 'can_use_tool'
|
| 39 |
+
tool_name: string
|
| 40 |
+
input: Record<string, unknown>
|
| 41 |
+
tool_use_id: string
|
| 42 |
+
}
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
type SessionSpawnerDeps = {
|
| 46 |
+
execPath: string
|
| 47 |
+
/**
|
| 48 |
+
* Arguments that must precede the CLI flags when spawning. Empty for
|
| 49 |
+
* compiled binaries (where execPath is the claude binary itself); contains
|
| 50 |
+
* the script path (process.argv[1]) for npm installs where execPath is the
|
| 51 |
+
* node runtime. Without this, node sees --sdk-url as a node option and
|
| 52 |
+
* exits with "bad option: --sdk-url" (see anthropics/claude-code#28334).
|
| 53 |
+
*/
|
| 54 |
+
scriptArgs: string[]
|
| 55 |
+
env: NodeJS.ProcessEnv
|
| 56 |
+
verbose: boolean
|
| 57 |
+
sandbox: boolean
|
| 58 |
+
debugFile?: string
|
| 59 |
+
permissionMode?: string
|
| 60 |
+
onDebug: (msg: string) => void
|
| 61 |
+
onActivity?: (sessionId: string, activity: SessionActivity) => void
|
| 62 |
+
onPermissionRequest?: (
|
| 63 |
+
sessionId: string,
|
| 64 |
+
request: PermissionRequest,
|
| 65 |
+
accessToken: string,
|
| 66 |
+
) => void
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
/** Map tool names to human-readable verbs for the status display. */
|
| 70 |
+
const TOOL_VERBS: Record<string, string> = {
|
| 71 |
+
Read: 'Reading',
|
| 72 |
+
Write: 'Writing',
|
| 73 |
+
Edit: 'Editing',
|
| 74 |
+
MultiEdit: 'Editing',
|
| 75 |
+
Bash: 'Running',
|
| 76 |
+
Glob: 'Searching',
|
| 77 |
+
Grep: 'Searching',
|
| 78 |
+
WebFetch: 'Fetching',
|
| 79 |
+
WebSearch: 'Searching',
|
| 80 |
+
Task: 'Running task',
|
| 81 |
+
FileReadTool: 'Reading',
|
| 82 |
+
FileWriteTool: 'Writing',
|
| 83 |
+
FileEditTool: 'Editing',
|
| 84 |
+
GlobTool: 'Searching',
|
| 85 |
+
GrepTool: 'Searching',
|
| 86 |
+
BashTool: 'Running',
|
| 87 |
+
NotebookEditTool: 'Editing notebook',
|
| 88 |
+
LSP: 'LSP',
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
function toolSummary(name: string, input: Record<string, unknown>): string {
|
| 92 |
+
const verb = TOOL_VERBS[name] ?? name
|
| 93 |
+
const target =
|
| 94 |
+
(input.file_path as string) ??
|
| 95 |
+
(input.filePath as string) ??
|
| 96 |
+
(input.pattern as string) ??
|
| 97 |
+
(input.command as string | undefined)?.slice(0, 60) ??
|
| 98 |
+
(input.url as string) ??
|
| 99 |
+
(input.query as string) ??
|
| 100 |
+
''
|
| 101 |
+
if (target) {
|
| 102 |
+
return `${verb} ${target}`
|
| 103 |
+
}
|
| 104 |
+
return verb
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
function extractActivities(
|
| 108 |
+
line: string,
|
| 109 |
+
sessionId: string,
|
| 110 |
+
onDebug: (msg: string) => void,
|
| 111 |
+
): SessionActivity[] {
|
| 112 |
+
let parsed: unknown
|
| 113 |
+
try {
|
| 114 |
+
parsed = jsonParse(line)
|
| 115 |
+
} catch {
|
| 116 |
+
return []
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
if (!parsed || typeof parsed !== 'object') {
|
| 120 |
+
return []
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
const msg = parsed as Record<string, unknown>
|
| 124 |
+
const activities: SessionActivity[] = []
|
| 125 |
+
const now = Date.now()
|
| 126 |
+
|
| 127 |
+
switch (msg.type) {
|
| 128 |
+
case 'assistant': {
|
| 129 |
+
const message = msg.message as Record<string, unknown> | undefined
|
| 130 |
+
if (!message) break
|
| 131 |
+
const content = message.content
|
| 132 |
+
if (!Array.isArray(content)) break
|
| 133 |
+
|
| 134 |
+
for (const block of content) {
|
| 135 |
+
if (!block || typeof block !== 'object') continue
|
| 136 |
+
const b = block as Record<string, unknown>
|
| 137 |
+
|
| 138 |
+
if (b.type === 'tool_use') {
|
| 139 |
+
const name = (b.name as string) ?? 'Tool'
|
| 140 |
+
const input = (b.input as Record<string, unknown>) ?? {}
|
| 141 |
+
const summary = toolSummary(name, input)
|
| 142 |
+
activities.push({
|
| 143 |
+
type: 'tool_start',
|
| 144 |
+
summary,
|
| 145 |
+
timestamp: now,
|
| 146 |
+
})
|
| 147 |
+
onDebug(
|
| 148 |
+
`[bridge:activity] sessionId=${sessionId} tool_use name=${name} ${inputPreview(input)}`,
|
| 149 |
+
)
|
| 150 |
+
} else if (b.type === 'text') {
|
| 151 |
+
const text = (b.text as string) ?? ''
|
| 152 |
+
if (text.length > 0) {
|
| 153 |
+
activities.push({
|
| 154 |
+
type: 'text',
|
| 155 |
+
summary: text.slice(0, 80),
|
| 156 |
+
timestamp: now,
|
| 157 |
+
})
|
| 158 |
+
onDebug(
|
| 159 |
+
`[bridge:activity] sessionId=${sessionId} text "${text.slice(0, 100)}"`,
|
| 160 |
+
)
|
| 161 |
+
}
|
| 162 |
+
}
|
| 163 |
+
}
|
| 164 |
+
break
|
| 165 |
+
}
|
| 166 |
+
case 'result': {
|
| 167 |
+
const subtype = msg.subtype as string | undefined
|
| 168 |
+
if (subtype === 'success') {
|
| 169 |
+
activities.push({
|
| 170 |
+
type: 'result',
|
| 171 |
+
summary: 'Session completed',
|
| 172 |
+
timestamp: now,
|
| 173 |
+
})
|
| 174 |
+
onDebug(
|
| 175 |
+
`[bridge:activity] sessionId=${sessionId} result subtype=success`,
|
| 176 |
+
)
|
| 177 |
+
} else if (subtype) {
|
| 178 |
+
const errors = msg.errors as string[] | undefined
|
| 179 |
+
const errorSummary = errors?.[0] ?? `Error: ${subtype}`
|
| 180 |
+
activities.push({
|
| 181 |
+
type: 'error',
|
| 182 |
+
summary: errorSummary,
|
| 183 |
+
timestamp: now,
|
| 184 |
+
})
|
| 185 |
+
onDebug(
|
| 186 |
+
`[bridge:activity] sessionId=${sessionId} result subtype=${subtype} error="${errorSummary}"`,
|
| 187 |
+
)
|
| 188 |
+
} else {
|
| 189 |
+
onDebug(
|
| 190 |
+
`[bridge:activity] sessionId=${sessionId} result subtype=undefined`,
|
| 191 |
+
)
|
| 192 |
+
}
|
| 193 |
+
break
|
| 194 |
+
}
|
| 195 |
+
default:
|
| 196 |
+
break
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
return activities
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
/**
|
| 203 |
+
* Extract plain text from a replayed SDKUserMessage NDJSON line. Returns the
|
| 204 |
+
* trimmed text if this looks like a real human-authored message, otherwise
|
| 205 |
+
* undefined so the caller keeps waiting for the first real message.
|
| 206 |
+
*/
|
| 207 |
+
function extractUserMessageText(
|
| 208 |
+
msg: Record<string, unknown>,
|
| 209 |
+
): string | undefined {
|
| 210 |
+
// Skip tool-result user messages (wrapped subagent results) and synthetic
|
| 211 |
+
// caveat messages — neither is human-authored.
|
| 212 |
+
if (msg.parent_tool_use_id != null || msg.isSynthetic || msg.isReplay)
|
| 213 |
+
return undefined
|
| 214 |
+
|
| 215 |
+
const message = msg.message as Record<string, unknown> | undefined
|
| 216 |
+
const content = message?.content
|
| 217 |
+
let text: string | undefined
|
| 218 |
+
if (typeof content === 'string') {
|
| 219 |
+
text = content
|
| 220 |
+
} else if (Array.isArray(content)) {
|
| 221 |
+
for (const block of content) {
|
| 222 |
+
if (
|
| 223 |
+
block &&
|
| 224 |
+
typeof block === 'object' &&
|
| 225 |
+
(block as Record<string, unknown>).type === 'text'
|
| 226 |
+
) {
|
| 227 |
+
text = (block as Record<string, unknown>).text as string | undefined
|
| 228 |
+
break
|
| 229 |
+
}
|
| 230 |
+
}
|
| 231 |
+
}
|
| 232 |
+
text = text?.trim()
|
| 233 |
+
return text ? text : undefined
|
| 234 |
+
}
|
| 235 |
+
|
| 236 |
+
/** Build a short preview of tool input for debug logging. */
|
| 237 |
+
function inputPreview(input: Record<string, unknown>): string {
|
| 238 |
+
const parts: string[] = []
|
| 239 |
+
for (const [key, val] of Object.entries(input)) {
|
| 240 |
+
if (typeof val === 'string') {
|
| 241 |
+
parts.push(`${key}="${val.slice(0, 100)}"`)
|
| 242 |
+
}
|
| 243 |
+
if (parts.length >= 3) break
|
| 244 |
+
}
|
| 245 |
+
return parts.join(' ')
|
| 246 |
+
}
|
| 247 |
+
|
| 248 |
+
export function createSessionSpawner(deps: SessionSpawnerDeps): SessionSpawner {
|
| 249 |
+
return {
|
| 250 |
+
spawn(opts: SessionSpawnOpts, dir: string): SessionHandle {
|
| 251 |
+
// Debug file resolution:
|
| 252 |
+
// 1. If deps.debugFile is provided, use it with session ID suffix for uniqueness
|
| 253 |
+
// 2. If verbose or ant build, auto-generate a temp file path
|
| 254 |
+
// 3. Otherwise, no debug file
|
| 255 |
+
const safeId = safeFilenameId(opts.sessionId)
|
| 256 |
+
let debugFile: string | undefined
|
| 257 |
+
if (deps.debugFile) {
|
| 258 |
+
const ext = deps.debugFile.lastIndexOf('.')
|
| 259 |
+
if (ext > 0) {
|
| 260 |
+
debugFile = `${deps.debugFile.slice(0, ext)}-${safeId}${deps.debugFile.slice(ext)}`
|
| 261 |
+
} else {
|
| 262 |
+
debugFile = `${deps.debugFile}-${safeId}`
|
| 263 |
+
}
|
| 264 |
+
} else if (deps.verbose || process.env.USER_TYPE === 'ant') {
|
| 265 |
+
debugFile = join(tmpdir(), 'claude', `bridge-session-${safeId}.log`)
|
| 266 |
+
}
|
| 267 |
+
|
| 268 |
+
// Transcript file: write raw NDJSON lines for post-hoc analysis.
|
| 269 |
+
// Placed alongside the debug file when one is configured.
|
| 270 |
+
let transcriptStream: WriteStream | null = null
|
| 271 |
+
let transcriptPath: string | undefined
|
| 272 |
+
if (deps.debugFile) {
|
| 273 |
+
transcriptPath = join(
|
| 274 |
+
dirname(deps.debugFile),
|
| 275 |
+
`bridge-transcript-${safeId}.jsonl`,
|
| 276 |
+
)
|
| 277 |
+
transcriptStream = createWriteStream(transcriptPath, { flags: 'a' })
|
| 278 |
+
transcriptStream.on('error', err => {
|
| 279 |
+
deps.onDebug(
|
| 280 |
+
`[bridge:session] Transcript write error: ${err.message}`,
|
| 281 |
+
)
|
| 282 |
+
transcriptStream = null
|
| 283 |
+
})
|
| 284 |
+
deps.onDebug(`[bridge:session] Transcript log: ${transcriptPath}`)
|
| 285 |
+
}
|
| 286 |
+
|
| 287 |
+
const args = [
|
| 288 |
+
...deps.scriptArgs,
|
| 289 |
+
'--print',
|
| 290 |
+
'--sdk-url',
|
| 291 |
+
opts.sdkUrl,
|
| 292 |
+
'--session-id',
|
| 293 |
+
opts.sessionId,
|
| 294 |
+
'--input-format',
|
| 295 |
+
'stream-json',
|
| 296 |
+
'--output-format',
|
| 297 |
+
'stream-json',
|
| 298 |
+
'--replay-user-messages',
|
| 299 |
+
...(deps.verbose ? ['--verbose'] : []),
|
| 300 |
+
...(debugFile ? ['--debug-file', debugFile] : []),
|
| 301 |
+
...(deps.permissionMode
|
| 302 |
+
? ['--permission-mode', deps.permissionMode]
|
| 303 |
+
: []),
|
| 304 |
+
]
|
| 305 |
+
|
| 306 |
+
const env: NodeJS.ProcessEnv = {
|
| 307 |
+
...deps.env,
|
| 308 |
+
// Strip the bridge's OAuth token so the child CC process uses
|
| 309 |
+
// the session access token for inference instead.
|
| 310 |
+
CLAUDE_CODE_OAUTH_TOKEN: undefined,
|
| 311 |
+
CLAUDE_CODE_ENVIRONMENT_KIND: 'bridge',
|
| 312 |
+
...(deps.sandbox && { CLAUDE_CODE_FORCE_SANDBOX: '1' }),
|
| 313 |
+
CLAUDE_CODE_SESSION_ACCESS_TOKEN: opts.accessToken,
|
| 314 |
+
// v1: HybridTransport (WS reads + POST writes) to Session-Ingress.
|
| 315 |
+
// Harmless in v2 mode — transportUtils checks CLAUDE_CODE_USE_CCR_V2 first.
|
| 316 |
+
CLAUDE_CODE_POST_FOR_SESSION_INGRESS_V2: '1',
|
| 317 |
+
// v2: SSETransport + CCRClient to CCR's /v1/code/sessions/* endpoints.
|
| 318 |
+
// Same env vars environment-manager sets in the container path.
|
| 319 |
+
...(opts.useCcrV2 && {
|
| 320 |
+
CLAUDE_CODE_USE_CCR_V2: '1',
|
| 321 |
+
CLAUDE_CODE_WORKER_EPOCH: String(opts.workerEpoch),
|
| 322 |
+
}),
|
| 323 |
+
}
|
| 324 |
+
|
| 325 |
+
deps.onDebug(
|
| 326 |
+
`[bridge:session] Spawning sessionId=${opts.sessionId} sdkUrl=${opts.sdkUrl} accessToken=${opts.accessToken ? 'present' : 'MISSING'}`,
|
| 327 |
+
)
|
| 328 |
+
deps.onDebug(`[bridge:session] Child args: ${args.join(' ')}`)
|
| 329 |
+
if (debugFile) {
|
| 330 |
+
deps.onDebug(`[bridge:session] Debug log: ${debugFile}`)
|
| 331 |
+
}
|
| 332 |
+
|
| 333 |
+
// Pipe all three streams: stdin for control, stdout for NDJSON parsing,
|
| 334 |
+
// stderr for error capture and diagnostics.
|
| 335 |
+
const child: ChildProcess = spawn(deps.execPath, args, {
|
| 336 |
+
cwd: dir,
|
| 337 |
+
stdio: ['pipe', 'pipe', 'pipe'],
|
| 338 |
+
env,
|
| 339 |
+
windowsHide: true,
|
| 340 |
+
})
|
| 341 |
+
|
| 342 |
+
deps.onDebug(
|
| 343 |
+
`[bridge:session] sessionId=${opts.sessionId} pid=${child.pid}`,
|
| 344 |
+
)
|
| 345 |
+
|
| 346 |
+
const activities: SessionActivity[] = []
|
| 347 |
+
let currentActivity: SessionActivity | null = null
|
| 348 |
+
const lastStderr: string[] = []
|
| 349 |
+
let sigkillSent = false
|
| 350 |
+
let firstUserMessageSeen = false
|
| 351 |
+
|
| 352 |
+
// Buffer stderr for error diagnostics
|
| 353 |
+
if (child.stderr) {
|
| 354 |
+
const stderrRl = createInterface({ input: child.stderr })
|
| 355 |
+
stderrRl.on('line', line => {
|
| 356 |
+
// Forward stderr to bridge's stderr in verbose mode
|
| 357 |
+
if (deps.verbose) {
|
| 358 |
+
process.stderr.write(line + '\n')
|
| 359 |
+
}
|
| 360 |
+
// Ring buffer of last N lines
|
| 361 |
+
if (lastStderr.length >= MAX_STDERR_LINES) {
|
| 362 |
+
lastStderr.shift()
|
| 363 |
+
}
|
| 364 |
+
lastStderr.push(line)
|
| 365 |
+
})
|
| 366 |
+
}
|
| 367 |
+
|
| 368 |
+
// Parse NDJSON from child stdout
|
| 369 |
+
if (child.stdout) {
|
| 370 |
+
const rl = createInterface({ input: child.stdout })
|
| 371 |
+
rl.on('line', line => {
|
| 372 |
+
// Write raw NDJSON to transcript file
|
| 373 |
+
if (transcriptStream) {
|
| 374 |
+
transcriptStream.write(line + '\n')
|
| 375 |
+
}
|
| 376 |
+
|
| 377 |
+
// Log all messages flowing from the child CLI to the bridge
|
| 378 |
+
deps.onDebug(
|
| 379 |
+
`[bridge:ws] sessionId=${opts.sessionId} <<< ${debugTruncate(line)}`,
|
| 380 |
+
)
|
| 381 |
+
|
| 382 |
+
// In verbose mode, forward raw output to stderr
|
| 383 |
+
if (deps.verbose) {
|
| 384 |
+
process.stderr.write(line + '\n')
|
| 385 |
+
}
|
| 386 |
+
|
| 387 |
+
const extracted = extractActivities(
|
| 388 |
+
line,
|
| 389 |
+
opts.sessionId,
|
| 390 |
+
deps.onDebug,
|
| 391 |
+
)
|
| 392 |
+
for (const activity of extracted) {
|
| 393 |
+
// Maintain ring buffer
|
| 394 |
+
if (activities.length >= MAX_ACTIVITIES) {
|
| 395 |
+
activities.shift()
|
| 396 |
+
}
|
| 397 |
+
activities.push(activity)
|
| 398 |
+
currentActivity = activity
|
| 399 |
+
|
| 400 |
+
deps.onActivity?.(opts.sessionId, activity)
|
| 401 |
+
}
|
| 402 |
+
|
| 403 |
+
// Detect control_request and replayed user messages.
|
| 404 |
+
// extractActivities parses the same line but swallows parse errors
|
| 405 |
+
// and skips 'user' type — re-parse here is cheap (NDJSON lines are
|
| 406 |
+
// small) and keeps each path self-contained.
|
| 407 |
+
{
|
| 408 |
+
let parsed: unknown
|
| 409 |
+
try {
|
| 410 |
+
parsed = jsonParse(line)
|
| 411 |
+
} catch {
|
| 412 |
+
// Non-JSON line, skip detection
|
| 413 |
+
}
|
| 414 |
+
if (parsed && typeof parsed === 'object') {
|
| 415 |
+
const msg = parsed as Record<string, unknown>
|
| 416 |
+
|
| 417 |
+
if (msg.type === 'control_request') {
|
| 418 |
+
const request = msg.request as
|
| 419 |
+
| Record<string, unknown>
|
| 420 |
+
| undefined
|
| 421 |
+
if (
|
| 422 |
+
request?.subtype === 'can_use_tool' &&
|
| 423 |
+
deps.onPermissionRequest
|
| 424 |
+
) {
|
| 425 |
+
deps.onPermissionRequest(
|
| 426 |
+
opts.sessionId,
|
| 427 |
+
parsed as PermissionRequest,
|
| 428 |
+
opts.accessToken,
|
| 429 |
+
)
|
| 430 |
+
}
|
| 431 |
+
// interrupt is turn-level; the child handles it internally (print.ts)
|
| 432 |
+
} else if (
|
| 433 |
+
msg.type === 'user' &&
|
| 434 |
+
!firstUserMessageSeen &&
|
| 435 |
+
opts.onFirstUserMessage
|
| 436 |
+
) {
|
| 437 |
+
const text = extractUserMessageText(msg)
|
| 438 |
+
if (text) {
|
| 439 |
+
firstUserMessageSeen = true
|
| 440 |
+
opts.onFirstUserMessage(text)
|
| 441 |
+
}
|
| 442 |
+
}
|
| 443 |
+
}
|
| 444 |
+
}
|
| 445 |
+
})
|
| 446 |
+
}
|
| 447 |
+
|
| 448 |
+
const done = new Promise<SessionDoneStatus>(resolve => {
|
| 449 |
+
child.on('close', (code, signal) => {
|
| 450 |
+
// Close transcript stream on exit
|
| 451 |
+
if (transcriptStream) {
|
| 452 |
+
transcriptStream.end()
|
| 453 |
+
transcriptStream = null
|
| 454 |
+
}
|
| 455 |
+
|
| 456 |
+
if (signal === 'SIGTERM' || signal === 'SIGINT') {
|
| 457 |
+
deps.onDebug(
|
| 458 |
+
`[bridge:session] sessionId=${opts.sessionId} interrupted signal=${signal} pid=${child.pid}`,
|
| 459 |
+
)
|
| 460 |
+
resolve('interrupted')
|
| 461 |
+
} else if (code === 0) {
|
| 462 |
+
deps.onDebug(
|
| 463 |
+
`[bridge:session] sessionId=${opts.sessionId} completed exit_code=0 pid=${child.pid}`,
|
| 464 |
+
)
|
| 465 |
+
resolve('completed')
|
| 466 |
+
} else {
|
| 467 |
+
deps.onDebug(
|
| 468 |
+
`[bridge:session] sessionId=${opts.sessionId} failed exit_code=${code} pid=${child.pid}`,
|
| 469 |
+
)
|
| 470 |
+
resolve('failed')
|
| 471 |
+
}
|
| 472 |
+
})
|
| 473 |
+
|
| 474 |
+
child.on('error', err => {
|
| 475 |
+
deps.onDebug(
|
| 476 |
+
`[bridge:session] sessionId=${opts.sessionId} spawn error: ${err.message}`,
|
| 477 |
+
)
|
| 478 |
+
resolve('failed')
|
| 479 |
+
})
|
| 480 |
+
})
|
| 481 |
+
|
| 482 |
+
const handle: SessionHandle = {
|
| 483 |
+
sessionId: opts.sessionId,
|
| 484 |
+
done,
|
| 485 |
+
activities,
|
| 486 |
+
accessToken: opts.accessToken,
|
| 487 |
+
lastStderr,
|
| 488 |
+
get currentActivity(): SessionActivity | null {
|
| 489 |
+
return currentActivity
|
| 490 |
+
},
|
| 491 |
+
kill(): void {
|
| 492 |
+
if (!child.killed) {
|
| 493 |
+
deps.onDebug(
|
| 494 |
+
`[bridge:session] Sending SIGTERM to sessionId=${opts.sessionId} pid=${child.pid}`,
|
| 495 |
+
)
|
| 496 |
+
// On Windows, child.kill('SIGTERM') throws; use default signal.
|
| 497 |
+
if (process.platform === 'win32') {
|
| 498 |
+
child.kill()
|
| 499 |
+
} else {
|
| 500 |
+
child.kill('SIGTERM')
|
| 501 |
+
}
|
| 502 |
+
}
|
| 503 |
+
},
|
| 504 |
+
forceKill(): void {
|
| 505 |
+
// Use separate flag because child.killed is set when kill() is called,
|
| 506 |
+
// not when the process exits. We need to send SIGKILL even after SIGTERM.
|
| 507 |
+
if (!sigkillSent && child.pid) {
|
| 508 |
+
sigkillSent = true
|
| 509 |
+
deps.onDebug(
|
| 510 |
+
`[bridge:session] Sending SIGKILL to sessionId=${opts.sessionId} pid=${child.pid}`,
|
| 511 |
+
)
|
| 512 |
+
if (process.platform === 'win32') {
|
| 513 |
+
child.kill()
|
| 514 |
+
} else {
|
| 515 |
+
child.kill('SIGKILL')
|
| 516 |
+
}
|
| 517 |
+
}
|
| 518 |
+
},
|
| 519 |
+
writeStdin(data: string): void {
|
| 520 |
+
if (child.stdin && !child.stdin.destroyed) {
|
| 521 |
+
deps.onDebug(
|
| 522 |
+
`[bridge:ws] sessionId=${opts.sessionId} >>> ${debugTruncate(data)}`,
|
| 523 |
+
)
|
| 524 |
+
child.stdin.write(data)
|
| 525 |
+
}
|
| 526 |
+
},
|
| 527 |
+
updateAccessToken(token: string): void {
|
| 528 |
+
handle.accessToken = token
|
| 529 |
+
// Send the fresh token to the child process via stdin. The child's
|
| 530 |
+
// StructuredIO handles update_environment_variables messages by
|
| 531 |
+
// setting process.env directly, so getSessionIngressAuthToken()
|
| 532 |
+
// picks up the new token on the next refreshHeaders call.
|
| 533 |
+
handle.writeStdin(
|
| 534 |
+
jsonStringify({
|
| 535 |
+
type: 'update_environment_variables',
|
| 536 |
+
variables: { CLAUDE_CODE_SESSION_ACCESS_TOKEN: token },
|
| 537 |
+
}) + '\n',
|
| 538 |
+
)
|
| 539 |
+
deps.onDebug(
|
| 540 |
+
`[bridge:session] Sent token refresh via stdin for sessionId=${opts.sessionId}`,
|
| 541 |
+
)
|
| 542 |
+
},
|
| 543 |
+
}
|
| 544 |
+
|
| 545 |
+
return handle
|
| 546 |
+
},
|
| 547 |
+
}
|
| 548 |
+
}
|
| 549 |
+
|
| 550 |
+
export { extractActivities as _extractActivitiesForTesting }
|
src/bridge/trustedDevice.ts
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import axios from 'axios'
|
| 2 |
+
import memoize from 'lodash-es/memoize.js'
|
| 3 |
+
import { hostname } from 'os'
|
| 4 |
+
import { getOauthConfig } from '../constants/oauth.js'
|
| 5 |
+
import {
|
| 6 |
+
checkGate_CACHED_OR_BLOCKING,
|
| 7 |
+
getFeatureValue_CACHED_MAY_BE_STALE,
|
| 8 |
+
} from '../services/analytics/growthbook.js'
|
| 9 |
+
import { logForDebugging } from '../utils/debug.js'
|
| 10 |
+
import { errorMessage } from '../utils/errors.js'
|
| 11 |
+
import { isEssentialTrafficOnly } from '../utils/privacyLevel.js'
|
| 12 |
+
import { getSecureStorage } from '../utils/secureStorage/index.js'
|
| 13 |
+
import { jsonStringify } from '../utils/slowOperations.js'
|
| 14 |
+
|
| 15 |
+
/**
|
| 16 |
+
* Trusted device token source for bridge (remote-control) sessions.
|
| 17 |
+
*
|
| 18 |
+
* Bridge sessions have SecurityTier=ELEVATED on the server (CCR v2).
|
| 19 |
+
* The server gates ConnectBridgeWorker on its own flag
|
| 20 |
+
* (sessions_elevated_auth_enforcement in Anthropic Main); this CLI-side
|
| 21 |
+
* flag controls whether the CLI sends X-Trusted-Device-Token at all.
|
| 22 |
+
* Two flags so rollout can be staged: flip CLI-side first (headers
|
| 23 |
+
* start flowing, server still no-ops), then flip server-side.
|
| 24 |
+
*
|
| 25 |
+
* Enrollment (POST /auth/trusted_devices) is gated server-side by
|
| 26 |
+
* account_session.created_at < 10min, so it must happen during /login.
|
| 27 |
+
* Token is persistent (90d rolling expiry) and stored in keychain.
|
| 28 |
+
*
|
| 29 |
+
* See anthropics/anthropic#274559 (spec), #310375 (B1b tenant RPCs),
|
| 30 |
+
* #295987 (B2 Python routes), #307150 (C1' CCR v2 gate).
|
| 31 |
+
*/
|
| 32 |
+
|
| 33 |
+
const TRUSTED_DEVICE_GATE = 'tengu_sessions_elevated_auth_enforcement'
|
| 34 |
+
|
| 35 |
+
function isGateEnabled(): boolean {
|
| 36 |
+
return getFeatureValue_CACHED_MAY_BE_STALE(TRUSTED_DEVICE_GATE, false)
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
// Memoized — secureStorage.read() spawns a macOS `security` subprocess (~40ms).
|
| 40 |
+
// bridgeApi.ts calls this from getHeaders() on every poll/heartbeat/ack.
|
| 41 |
+
// Cache cleared after enrollment (below) and on logout (clearAuthRelatedCaches).
|
| 42 |
+
//
|
| 43 |
+
// Only the storage read is memoized — the GrowthBook gate is checked live so
|
| 44 |
+
// that a gate flip after GrowthBook refresh takes effect without a restart.
|
| 45 |
+
const readStoredToken = memoize((): string | undefined => {
|
| 46 |
+
// Env var takes precedence for testing/canary.
|
| 47 |
+
const envToken = process.env.CLAUDE_TRUSTED_DEVICE_TOKEN
|
| 48 |
+
if (envToken) {
|
| 49 |
+
return envToken
|
| 50 |
+
}
|
| 51 |
+
return getSecureStorage().read()?.trustedDeviceToken
|
| 52 |
+
})
|
| 53 |
+
|
| 54 |
+
export function getTrustedDeviceToken(): string | undefined {
|
| 55 |
+
if (!isGateEnabled()) {
|
| 56 |
+
return undefined
|
| 57 |
+
}
|
| 58 |
+
return readStoredToken()
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
export function clearTrustedDeviceTokenCache(): void {
|
| 62 |
+
readStoredToken.cache?.clear?.()
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
/**
|
| 66 |
+
* Clear the stored trusted device token from secure storage and the memo cache.
|
| 67 |
+
* Called before enrollTrustedDevice() during /login so a stale token from the
|
| 68 |
+
* previous account isn't sent as X-Trusted-Device-Token while enrollment is
|
| 69 |
+
* in-flight (enrollTrustedDevice is async — bridge API calls between login and
|
| 70 |
+
* enrollment completion would otherwise still read the old cached token).
|
| 71 |
+
*/
|
| 72 |
+
export function clearTrustedDeviceToken(): void {
|
| 73 |
+
if (!isGateEnabled()) {
|
| 74 |
+
return
|
| 75 |
+
}
|
| 76 |
+
const secureStorage = getSecureStorage()
|
| 77 |
+
try {
|
| 78 |
+
const data = secureStorage.read()
|
| 79 |
+
if (data?.trustedDeviceToken) {
|
| 80 |
+
delete data.trustedDeviceToken
|
| 81 |
+
secureStorage.update(data)
|
| 82 |
+
}
|
| 83 |
+
} catch {
|
| 84 |
+
// Best-effort — don't block login if storage is inaccessible
|
| 85 |
+
}
|
| 86 |
+
readStoredToken.cache?.clear?.()
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
/**
|
| 90 |
+
* Enroll this device via POST /auth/trusted_devices and persist the token
|
| 91 |
+
* to keychain. Best-effort — logs and returns on failure so callers
|
| 92 |
+
* (post-login hooks) don't block the login flow.
|
| 93 |
+
*
|
| 94 |
+
* The server gates enrollment on account_session.created_at < 10min, so
|
| 95 |
+
* this must be called immediately after a fresh /login. Calling it later
|
| 96 |
+
* (e.g. lazy enrollment on /bridge 403) will fail with 403 stale_session.
|
| 97 |
+
*/
|
| 98 |
+
export async function enrollTrustedDevice(): Promise<void> {
|
| 99 |
+
try {
|
| 100 |
+
// checkGate_CACHED_OR_BLOCKING awaits any in-flight GrowthBook re-init
|
| 101 |
+
// (triggered by refreshGrowthBookAfterAuthChange in login.tsx) before
|
| 102 |
+
// reading the gate, so we get the post-refresh value.
|
| 103 |
+
if (!(await checkGate_CACHED_OR_BLOCKING(TRUSTED_DEVICE_GATE))) {
|
| 104 |
+
logForDebugging(
|
| 105 |
+
`[trusted-device] Gate ${TRUSTED_DEVICE_GATE} is off, skipping enrollment`,
|
| 106 |
+
)
|
| 107 |
+
return
|
| 108 |
+
}
|
| 109 |
+
// If CLAUDE_TRUSTED_DEVICE_TOKEN is set (e.g. by an enterprise wrapper),
|
| 110 |
+
// skip enrollment — the env var takes precedence in readStoredToken() so
|
| 111 |
+
// any enrolled token would be shadowed and never used.
|
| 112 |
+
if (process.env.CLAUDE_TRUSTED_DEVICE_TOKEN) {
|
| 113 |
+
logForDebugging(
|
| 114 |
+
'[trusted-device] CLAUDE_TRUSTED_DEVICE_TOKEN env var is set, skipping enrollment (env var takes precedence)',
|
| 115 |
+
)
|
| 116 |
+
return
|
| 117 |
+
}
|
| 118 |
+
// Lazy require — utils/auth.ts transitively pulls ~1300 modules
|
| 119 |
+
// (config → file → permissions → sessionStorage → commands). Daemon callers
|
| 120 |
+
// of getTrustedDeviceToken() don't need this; only /login does.
|
| 121 |
+
/* eslint-disable @typescript-eslint/no-require-imports */
|
| 122 |
+
const { getClaudeAIOAuthTokens } =
|
| 123 |
+
require('../utils/auth.js') as typeof import('../utils/auth.js')
|
| 124 |
+
/* eslint-enable @typescript-eslint/no-require-imports */
|
| 125 |
+
const accessToken = getClaudeAIOAuthTokens()?.accessToken
|
| 126 |
+
if (!accessToken) {
|
| 127 |
+
logForDebugging('[trusted-device] No OAuth token, skipping enrollment')
|
| 128 |
+
return
|
| 129 |
+
}
|
| 130 |
+
// Always re-enroll on /login — the existing token may belong to a
|
| 131 |
+
// different account (account-switch without /logout). Skipping enrollment
|
| 132 |
+
// would send the old account's token on the new account's bridge calls.
|
| 133 |
+
const secureStorage = getSecureStorage()
|
| 134 |
+
|
| 135 |
+
if (isEssentialTrafficOnly()) {
|
| 136 |
+
logForDebugging(
|
| 137 |
+
'[trusted-device] Essential traffic only, skipping enrollment',
|
| 138 |
+
)
|
| 139 |
+
return
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
const baseUrl = getOauthConfig().BASE_API_URL
|
| 143 |
+
let response
|
| 144 |
+
try {
|
| 145 |
+
response = await axios.post<{
|
| 146 |
+
device_token?: string
|
| 147 |
+
device_id?: string
|
| 148 |
+
}>(
|
| 149 |
+
`${baseUrl}/api/auth/trusted_devices`,
|
| 150 |
+
{ display_name: `Claude Code on ${hostname()} · ${process.platform}` },
|
| 151 |
+
{
|
| 152 |
+
headers: {
|
| 153 |
+
Authorization: `Bearer ${accessToken}`,
|
| 154 |
+
'Content-Type': 'application/json',
|
| 155 |
+
},
|
| 156 |
+
timeout: 10_000,
|
| 157 |
+
validateStatus: s => s < 500,
|
| 158 |
+
},
|
| 159 |
+
)
|
| 160 |
+
} catch (err: unknown) {
|
| 161 |
+
logForDebugging(
|
| 162 |
+
`[trusted-device] Enrollment request failed: ${errorMessage(err)}`,
|
| 163 |
+
)
|
| 164 |
+
return
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
if (response.status !== 200 && response.status !== 201) {
|
| 168 |
+
logForDebugging(
|
| 169 |
+
`[trusted-device] Enrollment failed ${response.status}: ${jsonStringify(response.data).slice(0, 200)}`,
|
| 170 |
+
)
|
| 171 |
+
return
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
const token = response.data?.device_token
|
| 175 |
+
if (!token || typeof token !== 'string') {
|
| 176 |
+
logForDebugging(
|
| 177 |
+
'[trusted-device] Enrollment response missing device_token field',
|
| 178 |
+
)
|
| 179 |
+
return
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
try {
|
| 183 |
+
const storageData = secureStorage.read()
|
| 184 |
+
if (!storageData) {
|
| 185 |
+
logForDebugging(
|
| 186 |
+
'[trusted-device] Cannot read storage, skipping token persist',
|
| 187 |
+
)
|
| 188 |
+
return
|
| 189 |
+
}
|
| 190 |
+
storageData.trustedDeviceToken = token
|
| 191 |
+
const result = secureStorage.update(storageData)
|
| 192 |
+
if (!result.success) {
|
| 193 |
+
logForDebugging(
|
| 194 |
+
`[trusted-device] Failed to persist token: ${result.warning ?? 'unknown'}`,
|
| 195 |
+
)
|
| 196 |
+
return
|
| 197 |
+
}
|
| 198 |
+
readStoredToken.cache?.clear?.()
|
| 199 |
+
logForDebugging(
|
| 200 |
+
`[trusted-device] Enrolled device_id=${response.data.device_id ?? 'unknown'}`,
|
| 201 |
+
)
|
| 202 |
+
} catch (err: unknown) {
|
| 203 |
+
logForDebugging(
|
| 204 |
+
`[trusted-device] Storage write failed: ${errorMessage(err)}`,
|
| 205 |
+
)
|
| 206 |
+
}
|
| 207 |
+
} catch (err: unknown) {
|
| 208 |
+
logForDebugging(`[trusted-device] Enrollment error: ${errorMessage(err)}`)
|
| 209 |
+
}
|
| 210 |
+
}
|
src/bridge/types.ts
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/** Default per-session timeout (24 hours). */
|
| 2 |
+
export const DEFAULT_SESSION_TIMEOUT_MS = 24 * 60 * 60 * 1000
|
| 3 |
+
|
| 4 |
+
/** Reusable login guidance appended to bridge auth errors. */
|
| 5 |
+
export const BRIDGE_LOGIN_INSTRUCTION =
|
| 6 |
+
'Remote Control is only available with claude.ai subscriptions. Please use `/login` to sign in with your claude.ai account.'
|
| 7 |
+
|
| 8 |
+
/** Full error printed when `claude remote-control` is run without auth. */
|
| 9 |
+
export const BRIDGE_LOGIN_ERROR =
|
| 10 |
+
'Error: You must be logged in to use Remote Control.\n\n' +
|
| 11 |
+
BRIDGE_LOGIN_INSTRUCTION
|
| 12 |
+
|
| 13 |
+
/** Shown when the user disconnects Remote Control (via /remote-control or ultraplan launch). */
|
| 14 |
+
export const REMOTE_CONTROL_DISCONNECTED_MSG = 'Remote Control disconnected.'
|
| 15 |
+
|
| 16 |
+
// --- Protocol types for the environments API ---
|
| 17 |
+
|
| 18 |
+
export type WorkData = {
|
| 19 |
+
type: 'session' | 'healthcheck'
|
| 20 |
+
id: string
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
export type WorkResponse = {
|
| 24 |
+
id: string
|
| 25 |
+
type: 'work'
|
| 26 |
+
environment_id: string
|
| 27 |
+
state: string
|
| 28 |
+
data: WorkData
|
| 29 |
+
secret: string // base64url-encoded JSON
|
| 30 |
+
created_at: string
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
export type WorkSecret = {
|
| 34 |
+
version: number
|
| 35 |
+
session_ingress_token: string
|
| 36 |
+
api_base_url: string
|
| 37 |
+
sources: Array<{
|
| 38 |
+
type: string
|
| 39 |
+
git_info?: { type: string; repo: string; ref?: string; token?: string }
|
| 40 |
+
}>
|
| 41 |
+
auth: Array<{ type: string; token: string }>
|
| 42 |
+
claude_code_args?: Record<string, string> | null
|
| 43 |
+
mcp_config?: unknown | null
|
| 44 |
+
environment_variables?: Record<string, string> | null
|
| 45 |
+
/**
|
| 46 |
+
* Server-driven CCR v2 selector. Set by prepare_work_secret() when the
|
| 47 |
+
* session was created via the v2 compat layer (ccr_v2_compat_enabled).
|
| 48 |
+
* Same field the BYOC runner reads at environment-runner/sessionExecutor.ts.
|
| 49 |
+
*/
|
| 50 |
+
use_code_sessions?: boolean
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
export type SessionDoneStatus = 'completed' | 'failed' | 'interrupted'
|
| 54 |
+
|
| 55 |
+
export type SessionActivityType = 'tool_start' | 'text' | 'result' | 'error'
|
| 56 |
+
|
| 57 |
+
export type SessionActivity = {
|
| 58 |
+
type: SessionActivityType
|
| 59 |
+
summary: string // e.g. "Editing src/foo.ts", "Reading package.json"
|
| 60 |
+
timestamp: number
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
/**
|
| 64 |
+
* How `claude remote-control` chooses session working directories.
|
| 65 |
+
* - `single-session`: one session in cwd, bridge tears down when it ends
|
| 66 |
+
* - `worktree`: persistent server, every session gets an isolated git worktree
|
| 67 |
+
* - `same-dir`: persistent server, every session shares cwd (can stomp each other)
|
| 68 |
+
*/
|
| 69 |
+
export type SpawnMode = 'single-session' | 'worktree' | 'same-dir'
|
| 70 |
+
|
| 71 |
+
/**
|
| 72 |
+
* Well-known worker_type values THIS codebase produces. Sent as
|
| 73 |
+
* `metadata.worker_type` at environment registration so claude.ai can filter
|
| 74 |
+
* the session picker by origin (e.g. assistant tab only shows assistant
|
| 75 |
+
* workers). The backend treats this as an opaque string — desktop cowork
|
| 76 |
+
* sends `"cowork"`, which isn't in this union. REPL code uses this narrow
|
| 77 |
+
* type for its own exhaustiveness; wire-level fields accept any string.
|
| 78 |
+
*/
|
| 79 |
+
export type BridgeWorkerType = 'claude_code' | 'claude_code_assistant'
|
| 80 |
+
|
| 81 |
+
export type BridgeConfig = {
|
| 82 |
+
dir: string
|
| 83 |
+
machineName: string
|
| 84 |
+
branch: string
|
| 85 |
+
gitRepoUrl: string | null
|
| 86 |
+
maxSessions: number
|
| 87 |
+
spawnMode: SpawnMode
|
| 88 |
+
verbose: boolean
|
| 89 |
+
sandbox: boolean
|
| 90 |
+
/** Client-generated UUID identifying this bridge instance. */
|
| 91 |
+
bridgeId: string
|
| 92 |
+
/**
|
| 93 |
+
* Sent as metadata.worker_type so web clients can filter by origin.
|
| 94 |
+
* Backend treats this as opaque — any string, not just BridgeWorkerType.
|
| 95 |
+
*/
|
| 96 |
+
workerType: string
|
| 97 |
+
/** Client-generated UUID for idempotent environment registration. */
|
| 98 |
+
environmentId: string
|
| 99 |
+
/**
|
| 100 |
+
* Backend-issued environment_id to reuse on re-register. When set, the
|
| 101 |
+
* backend treats registration as a reconnect to the existing environment
|
| 102 |
+
* instead of creating a new one. Used by `claude remote-control
|
| 103 |
+
* --session-id` resume. Must be a backend-format ID — client UUIDs are
|
| 104 |
+
* rejected with 400.
|
| 105 |
+
*/
|
| 106 |
+
reuseEnvironmentId?: string
|
| 107 |
+
/** API base URL the bridge is connected to (used for polling). */
|
| 108 |
+
apiBaseUrl: string
|
| 109 |
+
/** Session ingress base URL for WebSocket connections (may differ from apiBaseUrl locally). */
|
| 110 |
+
sessionIngressUrl: string
|
| 111 |
+
/** Debug file path passed via --debug-file. */
|
| 112 |
+
debugFile?: string
|
| 113 |
+
/** Per-session timeout in milliseconds. Sessions exceeding this are killed. */
|
| 114 |
+
sessionTimeoutMs?: number
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
// --- Dependency interfaces (for testability) ---
|
| 118 |
+
|
| 119 |
+
/**
|
| 120 |
+
* A control_response event sent back to a session (e.g. a permission decision).
|
| 121 |
+
* The `subtype` is `'success'` per the SDK protocol; the inner `response`
|
| 122 |
+
* carries the permission decision payload (e.g. `{ behavior: 'allow' }`).
|
| 123 |
+
*/
|
| 124 |
+
export type PermissionResponseEvent = {
|
| 125 |
+
type: 'control_response'
|
| 126 |
+
response: {
|
| 127 |
+
subtype: 'success'
|
| 128 |
+
request_id: string
|
| 129 |
+
response: Record<string, unknown>
|
| 130 |
+
}
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
export type BridgeApiClient = {
|
| 134 |
+
registerBridgeEnvironment(config: BridgeConfig): Promise<{
|
| 135 |
+
environment_id: string
|
| 136 |
+
environment_secret: string
|
| 137 |
+
}>
|
| 138 |
+
pollForWork(
|
| 139 |
+
environmentId: string,
|
| 140 |
+
environmentSecret: string,
|
| 141 |
+
signal?: AbortSignal,
|
| 142 |
+
reclaimOlderThanMs?: number,
|
| 143 |
+
): Promise<WorkResponse | null>
|
| 144 |
+
acknowledgeWork(
|
| 145 |
+
environmentId: string,
|
| 146 |
+
workId: string,
|
| 147 |
+
sessionToken: string,
|
| 148 |
+
): Promise<void>
|
| 149 |
+
/** Stop a work item via the environments API. */
|
| 150 |
+
stopWork(environmentId: string, workId: string, force: boolean): Promise<void>
|
| 151 |
+
/** Deregister/delete the bridge environment on graceful shutdown. */
|
| 152 |
+
deregisterEnvironment(environmentId: string): Promise<void>
|
| 153 |
+
/** Send a permission response (control_response) to a session via the session events API. */
|
| 154 |
+
sendPermissionResponseEvent(
|
| 155 |
+
sessionId: string,
|
| 156 |
+
event: PermissionResponseEvent,
|
| 157 |
+
sessionToken: string,
|
| 158 |
+
): Promise<void>
|
| 159 |
+
/** Archive a session so it no longer appears as active on the server. */
|
| 160 |
+
archiveSession(sessionId: string): Promise<void>
|
| 161 |
+
/**
|
| 162 |
+
* Force-stop stale worker instances and re-queue a session on an environment.
|
| 163 |
+
* Used by `--session-id` to resume a session after the original bridge died.
|
| 164 |
+
*/
|
| 165 |
+
reconnectSession(environmentId: string, sessionId: string): Promise<void>
|
| 166 |
+
/**
|
| 167 |
+
* Send a lightweight heartbeat for an active work item, extending its lease.
|
| 168 |
+
* Uses SessionIngressAuth (JWT, no DB hit) instead of EnvironmentSecretAuth.
|
| 169 |
+
* Returns the server's response with lease status.
|
| 170 |
+
*/
|
| 171 |
+
heartbeatWork(
|
| 172 |
+
environmentId: string,
|
| 173 |
+
workId: string,
|
| 174 |
+
sessionToken: string,
|
| 175 |
+
): Promise<{ lease_extended: boolean; state: string }>
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
export type SessionHandle = {
|
| 179 |
+
sessionId: string
|
| 180 |
+
done: Promise<SessionDoneStatus>
|
| 181 |
+
kill(): void
|
| 182 |
+
forceKill(): void
|
| 183 |
+
activities: SessionActivity[] // ring buffer of recent activities (last ~10)
|
| 184 |
+
currentActivity: SessionActivity | null // most recent
|
| 185 |
+
accessToken: string // session_ingress_token for API calls
|
| 186 |
+
lastStderr: string[] // ring buffer of last stderr lines
|
| 187 |
+
writeStdin(data: string): void // write directly to child stdin
|
| 188 |
+
/** Update the access token for a running session (e.g. after token refresh). */
|
| 189 |
+
updateAccessToken(token: string): void
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
export type SessionSpawnOpts = {
|
| 193 |
+
sessionId: string
|
| 194 |
+
sdkUrl: string
|
| 195 |
+
accessToken: string
|
| 196 |
+
/** When true, spawn the child with CCR v2 env vars (SSE transport + CCRClient). */
|
| 197 |
+
useCcrV2?: boolean
|
| 198 |
+
/** Required when useCcrV2 is true. Obtained from POST /worker/register. */
|
| 199 |
+
workerEpoch?: number
|
| 200 |
+
/**
|
| 201 |
+
* Fires once with the text of the first real user message seen on the
|
| 202 |
+
* child's stdout (via --replay-user-messages). Lets the caller derive a
|
| 203 |
+
* session title when none exists yet. Tool-result and synthetic user
|
| 204 |
+
* messages are skipped.
|
| 205 |
+
*/
|
| 206 |
+
onFirstUserMessage?: (text: string) => void
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
export type SessionSpawner = {
|
| 210 |
+
spawn(opts: SessionSpawnOpts, dir: string): SessionHandle
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
export type BridgeLogger = {
|
| 214 |
+
printBanner(config: BridgeConfig, environmentId: string): void
|
| 215 |
+
logSessionStart(sessionId: string, prompt: string): void
|
| 216 |
+
logSessionComplete(sessionId: string, durationMs: number): void
|
| 217 |
+
logSessionFailed(sessionId: string, error: string): void
|
| 218 |
+
logStatus(message: string): void
|
| 219 |
+
logVerbose(message: string): void
|
| 220 |
+
logError(message: string): void
|
| 221 |
+
/** Log a reconnection success event after recovering from connection errors. */
|
| 222 |
+
logReconnected(disconnectedMs: number): void
|
| 223 |
+
/** Show idle status with repo/branch info and shimmer animation. */
|
| 224 |
+
updateIdleStatus(): void
|
| 225 |
+
/** Show reconnecting status in the live display. */
|
| 226 |
+
updateReconnectingStatus(delayStr: string, elapsedStr: string): void
|
| 227 |
+
updateSessionStatus(
|
| 228 |
+
sessionId: string,
|
| 229 |
+
elapsed: string,
|
| 230 |
+
activity: SessionActivity,
|
| 231 |
+
trail: string[],
|
| 232 |
+
): void
|
| 233 |
+
clearStatus(): void
|
| 234 |
+
/** Set repository info for status line display. */
|
| 235 |
+
setRepoInfo(repoName: string, branch: string): void
|
| 236 |
+
/** Set debug log glob shown above the status line (ant users). */
|
| 237 |
+
setDebugLogPath(path: string): void
|
| 238 |
+
/** Transition to "Attached" state when a session starts. */
|
| 239 |
+
setAttached(sessionId: string): void
|
| 240 |
+
/** Show failed status in the live display. */
|
| 241 |
+
updateFailedStatus(error: string): void
|
| 242 |
+
/** Toggle QR code visibility. */
|
| 243 |
+
toggleQr(): void
|
| 244 |
+
/** Update the "<n> of <m> sessions" indicator and spawn mode hint. */
|
| 245 |
+
updateSessionCount(active: number, max: number, mode: SpawnMode): void
|
| 246 |
+
/** Update the spawn mode shown in the session-count line. Pass null to hide (single-session or toggle unavailable). */
|
| 247 |
+
setSpawnModeDisplay(mode: 'same-dir' | 'worktree' | null): void
|
| 248 |
+
/** Register a new session for multi-session display (called after spawn succeeds). */
|
| 249 |
+
addSession(sessionId: string, url: string): void
|
| 250 |
+
/** Update the per-session activity summary (tool being run) in the multi-session list. */
|
| 251 |
+
updateSessionActivity(sessionId: string, activity: SessionActivity): void
|
| 252 |
+
/**
|
| 253 |
+
* Set a session's display title. In multi-session mode, updates the bullet list
|
| 254 |
+
* entry. In single-session mode, also shows the title in the main status line.
|
| 255 |
+
* Triggers a render (guarded against reconnecting/failed states).
|
| 256 |
+
*/
|
| 257 |
+
setSessionTitle(sessionId: string, title: string): void
|
| 258 |
+
/** Remove a session from the multi-session display when it ends. */
|
| 259 |
+
removeSession(sessionId: string): void
|
| 260 |
+
/** Force a re-render of the status display (for multi-session activity refresh). */
|
| 261 |
+
refreshDisplay(): void
|
| 262 |
+
}
|
src/bridge/workSecret.ts
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import axios from 'axios'
|
| 2 |
+
import { jsonParse, jsonStringify } from '../utils/slowOperations.js'
|
| 3 |
+
import type { WorkSecret } from './types.js'
|
| 4 |
+
|
| 5 |
+
/** Decode a base64url-encoded work secret and validate its version. */
|
| 6 |
+
export function decodeWorkSecret(secret: string): WorkSecret {
|
| 7 |
+
const json = Buffer.from(secret, 'base64url').toString('utf-8')
|
| 8 |
+
const parsed: unknown = jsonParse(json)
|
| 9 |
+
if (
|
| 10 |
+
!parsed ||
|
| 11 |
+
typeof parsed !== 'object' ||
|
| 12 |
+
!('version' in parsed) ||
|
| 13 |
+
parsed.version !== 1
|
| 14 |
+
) {
|
| 15 |
+
throw new Error(
|
| 16 |
+
`Unsupported work secret version: ${parsed && typeof parsed === 'object' && 'version' in parsed ? parsed.version : 'unknown'}`,
|
| 17 |
+
)
|
| 18 |
+
}
|
| 19 |
+
const obj = parsed as Record<string, unknown>
|
| 20 |
+
if (
|
| 21 |
+
typeof obj.session_ingress_token !== 'string' ||
|
| 22 |
+
obj.session_ingress_token.length === 0
|
| 23 |
+
) {
|
| 24 |
+
throw new Error(
|
| 25 |
+
'Invalid work secret: missing or empty session_ingress_token',
|
| 26 |
+
)
|
| 27 |
+
}
|
| 28 |
+
if (typeof obj.api_base_url !== 'string') {
|
| 29 |
+
throw new Error('Invalid work secret: missing api_base_url')
|
| 30 |
+
}
|
| 31 |
+
return parsed as WorkSecret
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
/**
|
| 35 |
+
* Build a WebSocket SDK URL from the API base URL and session ID.
|
| 36 |
+
* Strips the HTTP(S) protocol and constructs a ws(s):// ingress URL.
|
| 37 |
+
*
|
| 38 |
+
* Uses /v2/ for localhost (direct to session-ingress, no Envoy rewrite)
|
| 39 |
+
* and /v1/ for production (Envoy rewrites /v1/ → /v2/).
|
| 40 |
+
*/
|
| 41 |
+
export function buildSdkUrl(apiBaseUrl: string, sessionId: string): string {
|
| 42 |
+
const isLocalhost =
|
| 43 |
+
apiBaseUrl.includes('localhost') || apiBaseUrl.includes('127.0.0.1')
|
| 44 |
+
const protocol = isLocalhost ? 'ws' : 'wss'
|
| 45 |
+
const version = isLocalhost ? 'v2' : 'v1'
|
| 46 |
+
const host = apiBaseUrl.replace(/^https?:\/\//, '').replace(/\/+$/, '')
|
| 47 |
+
return `${protocol}://${host}/${version}/session_ingress/ws/${sessionId}`
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
/**
|
| 51 |
+
* Compare two session IDs regardless of their tagged-ID prefix.
|
| 52 |
+
*
|
| 53 |
+
* Tagged IDs have the form {tag}_{body} or {tag}_staging_{body}, where the
|
| 54 |
+
* body encodes a UUID. CCR v2's compat layer returns `session_*` to v1 API
|
| 55 |
+
* clients (compat/convert.go:41) but the infrastructure layer (sandbox-gateway
|
| 56 |
+
* work queue, work poll response) uses `cse_*` (compat/CLAUDE.md:13). Both
|
| 57 |
+
* have the same underlying UUID.
|
| 58 |
+
*
|
| 59 |
+
* Without this, replBridge rejects its own session as "foreign" at the
|
| 60 |
+
* work-received check when the ccr_v2_compat_enabled gate is on.
|
| 61 |
+
*/
|
| 62 |
+
export function sameSessionId(a: string, b: string): boolean {
|
| 63 |
+
if (a === b) return true
|
| 64 |
+
// The body is everything after the last underscore — this handles both
|
| 65 |
+
// `{tag}_{body}` and `{tag}_staging_{body}`.
|
| 66 |
+
const aBody = a.slice(a.lastIndexOf('_') + 1)
|
| 67 |
+
const bBody = b.slice(b.lastIndexOf('_') + 1)
|
| 68 |
+
// Guard against IDs with no underscore (bare UUIDs): lastIndexOf returns -1,
|
| 69 |
+
// slice(0) returns the whole string, and we already checked a === b above.
|
| 70 |
+
// Require a minimum length to avoid accidental matches on short suffixes
|
| 71 |
+
// (e.g. single-char tag remnants from malformed IDs).
|
| 72 |
+
return aBody.length >= 4 && aBody === bBody
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
/**
|
| 76 |
+
* Build a CCR v2 session URL from the API base URL and session ID.
|
| 77 |
+
* Unlike buildSdkUrl, this returns an HTTP(S) URL (not ws://) and points at
|
| 78 |
+
* /v1/code/sessions/{id} — the child CC will derive the SSE stream path
|
| 79 |
+
* and worker endpoints from this base.
|
| 80 |
+
*/
|
| 81 |
+
export function buildCCRv2SdkUrl(
|
| 82 |
+
apiBaseUrl: string,
|
| 83 |
+
sessionId: string,
|
| 84 |
+
): string {
|
| 85 |
+
const base = apiBaseUrl.replace(/\/+$/, '')
|
| 86 |
+
return `${base}/v1/code/sessions/${sessionId}`
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
/**
|
| 90 |
+
* Register this bridge as the worker for a CCR v2 session.
|
| 91 |
+
* Returns the worker_epoch, which must be passed to the child CC process
|
| 92 |
+
* so its CCRClient can include it in every heartbeat/state/event request.
|
| 93 |
+
*
|
| 94 |
+
* Mirrors what environment-manager does in the container path
|
| 95 |
+
* (api-go/environment-manager/cmd/cmd_task_run.go RegisterWorker).
|
| 96 |
+
*/
|
| 97 |
+
export async function registerWorker(
|
| 98 |
+
sessionUrl: string,
|
| 99 |
+
accessToken: string,
|
| 100 |
+
): Promise<number> {
|
| 101 |
+
const response = await axios.post(
|
| 102 |
+
`${sessionUrl}/worker/register`,
|
| 103 |
+
{},
|
| 104 |
+
{
|
| 105 |
+
headers: {
|
| 106 |
+
Authorization: `Bearer ${accessToken}`,
|
| 107 |
+
'Content-Type': 'application/json',
|
| 108 |
+
'anthropic-version': '2023-06-01',
|
| 109 |
+
},
|
| 110 |
+
timeout: 10_000,
|
| 111 |
+
},
|
| 112 |
+
)
|
| 113 |
+
// protojson serializes int64 as a string to avoid JS number precision loss;
|
| 114 |
+
// the Go side may also return a number depending on encoder settings.
|
| 115 |
+
const raw = response.data?.worker_epoch
|
| 116 |
+
const epoch = typeof raw === 'string' ? Number(raw) : raw
|
| 117 |
+
if (
|
| 118 |
+
typeof epoch !== 'number' ||
|
| 119 |
+
!Number.isFinite(epoch) ||
|
| 120 |
+
!Number.isSafeInteger(epoch)
|
| 121 |
+
) {
|
| 122 |
+
throw new Error(
|
| 123 |
+
`registerWorker: invalid worker_epoch in response: ${jsonStringify(response.data)}`,
|
| 124 |
+
)
|
| 125 |
+
}
|
| 126 |
+
return epoch
|
| 127 |
+
}
|
src/buddy/CompanionSprite.tsx
ADDED
|
@@ -0,0 +1,371 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { c as _c } from "react/compiler-runtime";
|
| 2 |
+
import { feature } from 'bun:bundle';
|
| 3 |
+
import figures from 'figures';
|
| 4 |
+
import React, { useEffect, useRef, useState } from 'react';
|
| 5 |
+
import { useTerminalSize } from '../hooks/useTerminalSize.js';
|
| 6 |
+
import { stringWidth } from '../ink/stringWidth.js';
|
| 7 |
+
import { Box, Text } from '../ink.js';
|
| 8 |
+
import { useAppState, useSetAppState } from '../state/AppState.js';
|
| 9 |
+
import type { AppState } from '../state/AppStateStore.js';
|
| 10 |
+
import { getGlobalConfig } from '../utils/config.js';
|
| 11 |
+
import { isFullscreenActive } from '../utils/fullscreen.js';
|
| 12 |
+
import type { Theme } from '../utils/theme.js';
|
| 13 |
+
import { getCompanion } from './companion.js';
|
| 14 |
+
import { renderFace, renderSprite, spriteFrameCount } from './sprites.js';
|
| 15 |
+
import { RARITY_COLORS } from './types.js';
|
| 16 |
+
const TICK_MS = 500;
|
| 17 |
+
const BUBBLE_SHOW = 20; // ticks → ~10s at 500ms
|
| 18 |
+
const FADE_WINDOW = 6; // last ~3s the bubble dims so you know it's about to go
|
| 19 |
+
const PET_BURST_MS = 2500; // how long hearts float after /buddy pet
|
| 20 |
+
|
| 21 |
+
// Idle sequence: mostly rest (frame 0), occasional fidget (frames 1-2), rare blink.
|
| 22 |
+
// Sequence indices map to sprite frames; -1 means "blink on frame 0".
|
| 23 |
+
const IDLE_SEQUENCE = [0, 0, 0, 0, 1, 0, 0, 0, -1, 0, 0, 2, 0, 0, 0];
|
| 24 |
+
|
| 25 |
+
// Hearts float up-and-out over 5 ticks (~2.5s). Prepended above the sprite.
|
| 26 |
+
const H = figures.heart;
|
| 27 |
+
const PET_HEARTS = [` ${H} ${H} `, ` ${H} ${H} ${H} `, ` ${H} ${H} ${H} `, `${H} ${H} ${H} `, '· · · '];
|
| 28 |
+
function wrap(text: string, width: number): string[] {
|
| 29 |
+
const words = text.split(' ');
|
| 30 |
+
const lines: string[] = [];
|
| 31 |
+
let cur = '';
|
| 32 |
+
for (const w of words) {
|
| 33 |
+
if (cur.length + w.length + 1 > width && cur) {
|
| 34 |
+
lines.push(cur);
|
| 35 |
+
cur = w;
|
| 36 |
+
} else {
|
| 37 |
+
cur = cur ? `${cur} ${w}` : w;
|
| 38 |
+
}
|
| 39 |
+
}
|
| 40 |
+
if (cur) lines.push(cur);
|
| 41 |
+
return lines;
|
| 42 |
+
}
|
| 43 |
+
function SpeechBubble(t0) {
|
| 44 |
+
const $ = _c(31);
|
| 45 |
+
const {
|
| 46 |
+
text,
|
| 47 |
+
color,
|
| 48 |
+
fading,
|
| 49 |
+
tail
|
| 50 |
+
} = t0;
|
| 51 |
+
let T0;
|
| 52 |
+
let borderColor;
|
| 53 |
+
let t1;
|
| 54 |
+
let t2;
|
| 55 |
+
let t3;
|
| 56 |
+
let t4;
|
| 57 |
+
let t5;
|
| 58 |
+
let t6;
|
| 59 |
+
if ($[0] !== color || $[1] !== fading || $[2] !== text) {
|
| 60 |
+
const lines = wrap(text, 30);
|
| 61 |
+
borderColor = fading ? "inactive" : color;
|
| 62 |
+
T0 = Box;
|
| 63 |
+
t1 = "column";
|
| 64 |
+
t2 = "round";
|
| 65 |
+
t3 = borderColor;
|
| 66 |
+
t4 = 1;
|
| 67 |
+
t5 = 34;
|
| 68 |
+
let t7;
|
| 69 |
+
if ($[11] !== fading) {
|
| 70 |
+
t7 = (l, i) => <Text key={i} italic={true} dimColor={!fading} color={fading ? "inactive" : undefined}>{l}</Text>;
|
| 71 |
+
$[11] = fading;
|
| 72 |
+
$[12] = t7;
|
| 73 |
+
} else {
|
| 74 |
+
t7 = $[12];
|
| 75 |
+
}
|
| 76 |
+
t6 = lines.map(t7);
|
| 77 |
+
$[0] = color;
|
| 78 |
+
$[1] = fading;
|
| 79 |
+
$[2] = text;
|
| 80 |
+
$[3] = T0;
|
| 81 |
+
$[4] = borderColor;
|
| 82 |
+
$[5] = t1;
|
| 83 |
+
$[6] = t2;
|
| 84 |
+
$[7] = t3;
|
| 85 |
+
$[8] = t4;
|
| 86 |
+
$[9] = t5;
|
| 87 |
+
$[10] = t6;
|
| 88 |
+
} else {
|
| 89 |
+
T0 = $[3];
|
| 90 |
+
borderColor = $[4];
|
| 91 |
+
t1 = $[5];
|
| 92 |
+
t2 = $[6];
|
| 93 |
+
t3 = $[7];
|
| 94 |
+
t4 = $[8];
|
| 95 |
+
t5 = $[9];
|
| 96 |
+
t6 = $[10];
|
| 97 |
+
}
|
| 98 |
+
let t7;
|
| 99 |
+
if ($[13] !== T0 || $[14] !== t1 || $[15] !== t2 || $[16] !== t3 || $[17] !== t4 || $[18] !== t5 || $[19] !== t6) {
|
| 100 |
+
t7 = <T0 flexDirection={t1} borderStyle={t2} borderColor={t3} paddingX={t4} width={t5}>{t6}</T0>;
|
| 101 |
+
$[13] = T0;
|
| 102 |
+
$[14] = t1;
|
| 103 |
+
$[15] = t2;
|
| 104 |
+
$[16] = t3;
|
| 105 |
+
$[17] = t4;
|
| 106 |
+
$[18] = t5;
|
| 107 |
+
$[19] = t6;
|
| 108 |
+
$[20] = t7;
|
| 109 |
+
} else {
|
| 110 |
+
t7 = $[20];
|
| 111 |
+
}
|
| 112 |
+
const bubble = t7;
|
| 113 |
+
if (tail === "right") {
|
| 114 |
+
let t8;
|
| 115 |
+
if ($[21] !== borderColor) {
|
| 116 |
+
t8 = <Text color={borderColor}>─</Text>;
|
| 117 |
+
$[21] = borderColor;
|
| 118 |
+
$[22] = t8;
|
| 119 |
+
} else {
|
| 120 |
+
t8 = $[22];
|
| 121 |
+
}
|
| 122 |
+
let t9;
|
| 123 |
+
if ($[23] !== bubble || $[24] !== t8) {
|
| 124 |
+
t9 = <Box flexDirection="row" alignItems="center">{bubble}{t8}</Box>;
|
| 125 |
+
$[23] = bubble;
|
| 126 |
+
$[24] = t8;
|
| 127 |
+
$[25] = t9;
|
| 128 |
+
} else {
|
| 129 |
+
t9 = $[25];
|
| 130 |
+
}
|
| 131 |
+
return t9;
|
| 132 |
+
}
|
| 133 |
+
let t8;
|
| 134 |
+
if ($[26] !== borderColor) {
|
| 135 |
+
t8 = <Box flexDirection="column" alignItems="flex-end" paddingRight={6}><Text color={borderColor}>╲ </Text><Text color={borderColor}>╲</Text></Box>;
|
| 136 |
+
$[26] = borderColor;
|
| 137 |
+
$[27] = t8;
|
| 138 |
+
} else {
|
| 139 |
+
t8 = $[27];
|
| 140 |
+
}
|
| 141 |
+
let t9;
|
| 142 |
+
if ($[28] !== bubble || $[29] !== t8) {
|
| 143 |
+
t9 = <Box flexDirection="column" alignItems="flex-end" marginRight={1}>{bubble}{t8}</Box>;
|
| 144 |
+
$[28] = bubble;
|
| 145 |
+
$[29] = t8;
|
| 146 |
+
$[30] = t9;
|
| 147 |
+
} else {
|
| 148 |
+
t9 = $[30];
|
| 149 |
+
}
|
| 150 |
+
return t9;
|
| 151 |
+
}
|
| 152 |
+
export const MIN_COLS_FOR_FULL_SPRITE = 100;
|
| 153 |
+
const SPRITE_BODY_WIDTH = 12;
|
| 154 |
+
const NAME_ROW_PAD = 2; // focused state wraps name in spaces: ` name `
|
| 155 |
+
const SPRITE_PADDING_X = 2;
|
| 156 |
+
const BUBBLE_WIDTH = 36; // SpeechBubble box (34) + tail column
|
| 157 |
+
const NARROW_QUIP_CAP = 24;
|
| 158 |
+
function spriteColWidth(nameWidth: number): number {
|
| 159 |
+
return Math.max(SPRITE_BODY_WIDTH, nameWidth + NAME_ROW_PAD);
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
// Width the sprite area consumes. PromptInput subtracts this so text wraps
|
| 163 |
+
// correctly. In fullscreen the bubble floats over scrollback (no extra
|
| 164 |
+
// width); in non-fullscreen it sits inline and needs BUBBLE_WIDTH more.
|
| 165 |
+
// Narrow terminals: 0 — REPL.tsx stacks the one-liner on its own row
|
| 166 |
+
// (above input in fullscreen, below in scrollback), so no reservation.
|
| 167 |
+
export function companionReservedColumns(terminalColumns: number, speaking: boolean): number {
|
| 168 |
+
if (!feature('BUDDY')) return 0;
|
| 169 |
+
const companion = getCompanion();
|
| 170 |
+
if (!companion || getGlobalConfig().companionMuted) return 0;
|
| 171 |
+
if (terminalColumns < MIN_COLS_FOR_FULL_SPRITE) return 0;
|
| 172 |
+
const nameWidth = stringWidth(companion.name);
|
| 173 |
+
const bubble = speaking && !isFullscreenActive() ? BUBBLE_WIDTH : 0;
|
| 174 |
+
return spriteColWidth(nameWidth) + SPRITE_PADDING_X + bubble;
|
| 175 |
+
}
|
| 176 |
+
export function CompanionSprite(): React.ReactNode {
|
| 177 |
+
const reaction = useAppState(s => s.companionReaction);
|
| 178 |
+
const petAt = useAppState(s => s.companionPetAt);
|
| 179 |
+
const focused = useAppState(s => s.footerSelection === 'companion');
|
| 180 |
+
const setAppState = useSetAppState();
|
| 181 |
+
const {
|
| 182 |
+
columns
|
| 183 |
+
} = useTerminalSize();
|
| 184 |
+
const [tick, setTick] = useState(0);
|
| 185 |
+
const lastSpokeTick = useRef(0);
|
| 186 |
+
// Sync-during-render (not useEffect) so the first post-pet render already
|
| 187 |
+
// has petStartTick=tick and petAge=0 — otherwise frame 0 is skipped.
|
| 188 |
+
const [{
|
| 189 |
+
petStartTick,
|
| 190 |
+
forPetAt
|
| 191 |
+
}, setPetStart] = useState({
|
| 192 |
+
petStartTick: 0,
|
| 193 |
+
forPetAt: petAt
|
| 194 |
+
});
|
| 195 |
+
if (petAt !== forPetAt) {
|
| 196 |
+
setPetStart({
|
| 197 |
+
petStartTick: tick,
|
| 198 |
+
forPetAt: petAt
|
| 199 |
+
});
|
| 200 |
+
}
|
| 201 |
+
useEffect(() => {
|
| 202 |
+
const timer = setInterval(setT => setT((t: number) => t + 1), TICK_MS, setTick);
|
| 203 |
+
return () => clearInterval(timer);
|
| 204 |
+
}, []);
|
| 205 |
+
useEffect(() => {
|
| 206 |
+
if (!reaction) return;
|
| 207 |
+
lastSpokeTick.current = tick;
|
| 208 |
+
const timer = setTimeout(setA => setA((prev: AppState) => prev.companionReaction === undefined ? prev : {
|
| 209 |
+
...prev,
|
| 210 |
+
companionReaction: undefined
|
| 211 |
+
}), BUBBLE_SHOW * TICK_MS, setAppState);
|
| 212 |
+
return () => clearTimeout(timer);
|
| 213 |
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- tick intentionally captured at reaction-change, not tracked
|
| 214 |
+
}, [reaction, setAppState]);
|
| 215 |
+
if (!feature('BUDDY')) return null;
|
| 216 |
+
const companion = getCompanion();
|
| 217 |
+
if (!companion || getGlobalConfig().companionMuted) return null;
|
| 218 |
+
const color = RARITY_COLORS[companion.rarity];
|
| 219 |
+
const colWidth = spriteColWidth(stringWidth(companion.name));
|
| 220 |
+
const bubbleAge = reaction ? tick - lastSpokeTick.current : 0;
|
| 221 |
+
const fading = reaction !== undefined && bubbleAge >= BUBBLE_SHOW - FADE_WINDOW;
|
| 222 |
+
const petAge = petAt ? tick - petStartTick : Infinity;
|
| 223 |
+
const petting = petAge * TICK_MS < PET_BURST_MS;
|
| 224 |
+
|
| 225 |
+
// Narrow terminals: collapse to one-line face. When speaking, the quip
|
| 226 |
+
// replaces the name beside the face (no room for a bubble).
|
| 227 |
+
if (columns < MIN_COLS_FOR_FULL_SPRITE) {
|
| 228 |
+
const quip = reaction && reaction.length > NARROW_QUIP_CAP ? reaction.slice(0, NARROW_QUIP_CAP - 1) + '…' : reaction;
|
| 229 |
+
const label = quip ? `"${quip}"` : focused ? ` ${companion.name} ` : companion.name;
|
| 230 |
+
return <Box paddingX={1} alignSelf="flex-end">
|
| 231 |
+
<Text>
|
| 232 |
+
{petting && <Text color="autoAccept">{figures.heart} </Text>}
|
| 233 |
+
<Text bold color={color}>
|
| 234 |
+
{renderFace(companion)}
|
| 235 |
+
</Text>{' '}
|
| 236 |
+
<Text italic dimColor={!focused && !reaction} bold={focused} inverse={focused && !reaction} color={reaction ? fading ? 'inactive' : color : focused ? color : undefined}>
|
| 237 |
+
{label}
|
| 238 |
+
</Text>
|
| 239 |
+
</Text>
|
| 240 |
+
</Box>;
|
| 241 |
+
}
|
| 242 |
+
const frameCount = spriteFrameCount(companion.species);
|
| 243 |
+
const heartFrame = petting ? PET_HEARTS[petAge % PET_HEARTS.length] : null;
|
| 244 |
+
let spriteFrame: number;
|
| 245 |
+
let blink = false;
|
| 246 |
+
if (reaction || petting) {
|
| 247 |
+
// Excited: cycle all fidget frames fast
|
| 248 |
+
spriteFrame = tick % frameCount;
|
| 249 |
+
} else {
|
| 250 |
+
const step = IDLE_SEQUENCE[tick % IDLE_SEQUENCE.length]!;
|
| 251 |
+
if (step === -1) {
|
| 252 |
+
spriteFrame = 0;
|
| 253 |
+
blink = true;
|
| 254 |
+
} else {
|
| 255 |
+
spriteFrame = step % frameCount;
|
| 256 |
+
}
|
| 257 |
+
}
|
| 258 |
+
const body = renderSprite(companion, spriteFrame).map(line => blink ? line.replaceAll(companion.eye, '-') : line);
|
| 259 |
+
const sprite = heartFrame ? [heartFrame, ...body] : body;
|
| 260 |
+
|
| 261 |
+
// Name row doubles as hint row — unfocused shows dim name + ↓ discovery,
|
| 262 |
+
// focused shows inverse name. The enter-to-open hint lives in
|
| 263 |
+
// PromptInputFooter's right column so this row stays one line and the
|
| 264 |
+
// sprite doesn't jump up when selected. flexShrink=0 stops the
|
| 265 |
+
// inline-bubble row wrapper from squeezing the sprite to fit.
|
| 266 |
+
const spriteColumn = <Box flexDirection="column" flexShrink={0} alignItems="center" width={colWidth}>
|
| 267 |
+
{sprite.map((line, i) => <Text key={i} color={i === 0 && heartFrame ? 'autoAccept' : color}>
|
| 268 |
+
{line}
|
| 269 |
+
</Text>)}
|
| 270 |
+
<Text italic bold={focused} dimColor={!focused} color={focused ? color : undefined} inverse={focused}>
|
| 271 |
+
{focused ? ` ${companion.name} ` : companion.name}
|
| 272 |
+
</Text>
|
| 273 |
+
</Box>;
|
| 274 |
+
if (!reaction) {
|
| 275 |
+
return <Box paddingX={1}>{spriteColumn}</Box>;
|
| 276 |
+
}
|
| 277 |
+
|
| 278 |
+
// Fullscreen: bubble renders separately via CompanionFloatingBubble in
|
| 279 |
+
// FullscreenLayout's bottomFloat slot (the bottom slot's overflowY:hidden
|
| 280 |
+
// would clip a position:absolute overlay here). Sprite body only.
|
| 281 |
+
// Non-fullscreen: bubble sits inline beside the sprite (input shrinks)
|
| 282 |
+
// because floating into Static scrollback can't be cleared.
|
| 283 |
+
if (isFullscreenActive()) {
|
| 284 |
+
return <Box paddingX={1}>{spriteColumn}</Box>;
|
| 285 |
+
}
|
| 286 |
+
return <Box flexDirection="row" alignItems="flex-end" paddingX={1} flexShrink={0}>
|
| 287 |
+
<SpeechBubble text={reaction} color={color} fading={fading} tail="right" />
|
| 288 |
+
{spriteColumn}
|
| 289 |
+
</Box>;
|
| 290 |
+
}
|
| 291 |
+
|
| 292 |
+
// Floating bubble overlay for fullscreen mode. Mounted in FullscreenLayout's
|
| 293 |
+
// bottomFloat slot (outside the overflowY:hidden clip) so it can extend into
|
| 294 |
+
// the ScrollBox region. CompanionSprite owns the clear-after-10s timer; this
|
| 295 |
+
// just reads companionReaction and renders the fade.
|
| 296 |
+
export function CompanionFloatingBubble() {
|
| 297 |
+
const $ = _c(8);
|
| 298 |
+
const reaction = useAppState(_temp);
|
| 299 |
+
let t0;
|
| 300 |
+
if ($[0] !== reaction) {
|
| 301 |
+
t0 = {
|
| 302 |
+
tick: 0,
|
| 303 |
+
forReaction: reaction
|
| 304 |
+
};
|
| 305 |
+
$[0] = reaction;
|
| 306 |
+
$[1] = t0;
|
| 307 |
+
} else {
|
| 308 |
+
t0 = $[1];
|
| 309 |
+
}
|
| 310 |
+
const [t1, setTick] = useState(t0);
|
| 311 |
+
const {
|
| 312 |
+
tick,
|
| 313 |
+
forReaction
|
| 314 |
+
} = t1;
|
| 315 |
+
if (reaction !== forReaction) {
|
| 316 |
+
setTick({
|
| 317 |
+
tick: 0,
|
| 318 |
+
forReaction: reaction
|
| 319 |
+
});
|
| 320 |
+
}
|
| 321 |
+
let t2;
|
| 322 |
+
let t3;
|
| 323 |
+
if ($[2] !== reaction) {
|
| 324 |
+
t2 = () => {
|
| 325 |
+
if (!reaction) {
|
| 326 |
+
return;
|
| 327 |
+
}
|
| 328 |
+
const timer = setInterval(_temp3, TICK_MS, setTick);
|
| 329 |
+
return () => clearInterval(timer);
|
| 330 |
+
};
|
| 331 |
+
t3 = [reaction];
|
| 332 |
+
$[2] = reaction;
|
| 333 |
+
$[3] = t2;
|
| 334 |
+
$[4] = t3;
|
| 335 |
+
} else {
|
| 336 |
+
t2 = $[3];
|
| 337 |
+
t3 = $[4];
|
| 338 |
+
}
|
| 339 |
+
useEffect(t2, t3);
|
| 340 |
+
if (!feature("BUDDY") || !reaction) {
|
| 341 |
+
return null;
|
| 342 |
+
}
|
| 343 |
+
const companion = getCompanion();
|
| 344 |
+
if (!companion || getGlobalConfig().companionMuted) {
|
| 345 |
+
return null;
|
| 346 |
+
}
|
| 347 |
+
const t4 = tick >= BUBBLE_SHOW - FADE_WINDOW;
|
| 348 |
+
let t5;
|
| 349 |
+
if ($[5] !== reaction || $[6] !== t4) {
|
| 350 |
+
t5 = <SpeechBubble text={reaction} color={RARITY_COLORS[companion.rarity]} fading={t4} tail="down" />;
|
| 351 |
+
$[5] = reaction;
|
| 352 |
+
$[6] = t4;
|
| 353 |
+
$[7] = t5;
|
| 354 |
+
} else {
|
| 355 |
+
t5 = $[7];
|
| 356 |
+
}
|
| 357 |
+
return t5;
|
| 358 |
+
}
|
| 359 |
+
function _temp3(set) {
|
| 360 |
+
return set(_temp2);
|
| 361 |
+
}
|
| 362 |
+
function _temp2(s_0) {
|
| 363 |
+
return {
|
| 364 |
+
...s_0,
|
| 365 |
+
tick: s_0.tick + 1
|
| 366 |
+
};
|
| 367 |
+
}
|
| 368 |
+
function _temp(s) {
|
| 369 |
+
return s.companionReaction;
|
| 370 |
+
}
|
| 371 |
+
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJmZWF0dXJlIiwiZmlndXJlcyIsIlJlYWN0IiwidXNlRWZmZWN0IiwidXNlUmVmIiwidXNlU3RhdGUiLCJ1c2VUZXJtaW5hbFNpemUiLCJzdHJpbmdXaWR0aCIsIkJveCIsIlRleHQiLCJ1c2VBcHBTdGF0ZSIsInVzZVNldEFwcFN0YXRlIiwiQXBwU3RhdGUiLCJnZXRHbG9iYWxDb25maWciLCJpc0Z1bGxzY3JlZW5BY3RpdmUiLCJUaGVtZSIsImdldENvbXBhbmlvbiIsInJlbmRlckZhY2UiLCJyZW5kZXJTcHJpdGUiLCJzcHJpdGVGcmFtZUNvdW50IiwiUkFSSVRZX0NPTE9SUyIsIlRJQ0tfTVMiLCJCVUJCTEVfU0hPVyIsIkZBREVfV0lORE9XIiwiUEVUX0JVUlNUX01TIiwiSURMRV9TRVFVRU5DRSIsIkgiLCJoZWFydCIsIlBFVF9IRUFSVFMiLCJ3cmFwIiwidGV4dCIsIndpZHRoIiwid29yZHMiLCJzcGxpdCIsImxpbmVzIiwiY3VyIiwidyIsImxlbmd0aCIsInB1c2giLCJTcGVlY2hCdWJibGUiLCJ0MCIsIiQiLCJfYyIsImNvbG9yIiwiZmFkaW5nIiwidGFpbCIsIlQwIiwiYm9yZGVyQ29sb3IiLCJ0MSIsInQyIiwidDMiLCJ0NCIsInQ1IiwidDYiLCJ0NyIsImwiLCJpIiwidW5kZWZpbmVkIiwibWFwIiwiYnViYmxlIiwidDgiLCJ0OSIsIk1JTl9DT0xTX0ZPUl9GVUxMX1NQUklURSIsIlNQUklURV9CT0RZX1dJRFRIIiwiTkFNRV9ST1dfUEFEIiwiU1BSSVRFX1BBRERJTkdfWCIsIkJVQkJMRV9XSURUSCIsIk5BUlJPV19RVUlQX0NBUCIsInNwcml0ZUNvbFdpZHRoIiwibmFtZVdpZHRoIiwiTWF0aCIsIm1heCIsImNvbXBhbmlvblJlc2VydmVkQ29sdW1ucyIsInRlcm1pbmFsQ29sdW1ucyIsInNwZWFraW5nIiwiY29tcGFuaW9uIiwiY29tcGFuaW9uTXV0ZWQiLCJuYW1lIiwiQ29tcGFuaW9uU3ByaXRlIiwiUmVhY3ROb2RlIiwicmVhY3Rpb24iLCJzIiwiY29tcGFuaW9uUmVhY3Rpb24iLCJwZXRBdCIsImNvbXBhbmlvblBldEF0IiwiZm9jdXNlZCIsImZvb3RlclNlbGVjdGlvbiIsInNldEFwcFN0YXRlIiwiY29sdW1ucyIsInRpY2siLCJzZXRUaWNrIiwibGFzdFNwb2tlVGljayIsInBldFN0YXJ0VGljayIsImZvclBldEF0Iiwic2V0UGV0U3RhcnQiLCJ0aW1lciIsInNldEludGVydmFsIiwic2V0VCIsInQiLCJjbGVhckludGVydmFsIiwiY3VycmVudCIsInNldFRpbWVvdXQiLCJzZXRBIiwicHJldiIsImNsZWFyVGltZW91dCIsInJhcml0eSIsImNvbFdpZHRoIiwiYnViYmxlQWdlIiwicGV0QWdlIiwiSW5maW5pdHkiLCJwZXR0aW5nIiwicXVpcCIsInNsaWNlIiwibGFiZWwiLCJmcmFtZUNvdW50Iiwic3BlY2llcyIsImhlYXJ0RnJhbWUiLCJzcHJpdGVGcmFtZSIsImJsaW5rIiwic3RlcCIsImJvZHkiLCJsaW5lIiwicmVwbGFjZUFsbCIsImV5ZSIsInNwcml0ZSIsInNwcml0ZUNvbHVtbiIsIkNvbXBhbmlvbkZsb2F0aW5nQnViYmxlIiwiX3RlbXAiLCJmb3JSZWFjdGlvbiIsIl90ZW1wMyIsInNldCIsIl90ZW1wMiIsInNfMCJdLCJzb3VyY2VzIjpbIkNvbXBhbmlvblNwcml0ZS50c3giXSwic291cmNlc0NvbnRlbnQiOlsiaW1wb3J0IHsgZmVhdHVyZSB9IGZyb20gJ2J1bjpidW5kbGUnXG5pbXBvcnQgZmlndXJlcyBmcm9tICdmaWd1cmVzJ1xuaW1wb3J0IFJlYWN0LCB7IHVzZUVmZmVjdCwgdXNlUmVmLCB1c2VTdGF0ZSB9IGZyb20gJ3JlYWN0J1xuaW1wb3J0IHsgdXNlVGVybWluYWxTaXplIH0gZnJvbSAnLi4vaG9va3MvdXNlVGVybWluYWxTaXplLmpzJ1xuaW1wb3J0IHsgc3RyaW5nV2lkdGggfSBmcm9tICcuLi9pbmsvc3RyaW5nV2lkdGguanMnXG5pbXBvcnQgeyBCb3gsIFRleHQgfSBmcm9tICcuLi9pbmsuanMnXG5pbXBvcnQgeyB1c2VBcHBTdGF0ZSwgdXNlU2V0QXBwU3RhdGUgfSBmcm9tICcuLi9zdGF0ZS9BcHBTdGF0ZS5qcydcbmltcG9ydCB0eXBlIHsgQXBwU3RhdGUgfSBmcm9tICcuLi9zdGF0ZS9BcHBTdGF0ZVN0b3JlLmpzJ1xuaW1wb3J0IHsgZ2V0R2xvYmFsQ29uZmlnIH0gZnJvbSAnLi4vdXRpbHMvY29uZmlnLmpzJ1xuaW1wb3J0IHsgaXNGdWxsc2NyZWVuQWN0aXZlIH0gZnJvbSAnLi4vdXRpbHMvZnVsbHNjcmVlbi5qcydcbmltcG9ydCB0eXBlIHsgVGhlbWUgfSBmcm9tICcuLi91dGlscy90aGVtZS5qcydcbmltcG9ydCB7IGdldENvbXBhbmlvbiB9IGZyb20gJy4vY29tcGFuaW9uLmpzJ1xuaW1wb3J0IHsgcmVuZGVyRmFjZSwgcmVuZGVyU3ByaXRlLCBzcHJpdGVGcmFtZUNvdW50IH0gZnJvbSAnLi9zcHJpdGVzLmpzJ1xuaW1wb3J0IHsgUkFSSVRZX0NPTE9SUyB9IGZyb20gJy4vdHlwZXMuanMnXG5cbmNvbnN0IFRJQ0tfTVMgPSA1MDBcbmNvbnN0IEJVQkJMRV9TSE9XID0gMjAgLy8gdGlja3Mg4oaSIH4xMHMgYXQgNTAwbXNcbmNvbnN0IEZBREVfV0lORE9XID0gNiAvLyBsYXN0IH4zcyB0aGUgYnViYmxlIGRpbXMgc28geW91IGtub3cgaXQncyBhYm91dCB0byBnb1xuY29uc3QgUEVUX0JVUlNUX01TID0gMjUwMCAvLyBob3cgbG9uZyBoZWFydHMgZmxvYXQgYWZ0ZXIgL2J1ZGR5IHBldFxuXG4vLyBJZGxlIHNlcXVlbmNlOiBtb3N0bHkgcmVzdCAoZnJhbWUgMCksIG9jY2FzaW9uYWwgZmlkZ2V0IChmcmFtZXMgMS0yKSwgcmFyZSBibGluay5cbi8vIFNlcXVlbmNlIGluZGljZXMgbWFwIHRvIHNwcml0ZSBmcmFtZXM7IC0xIG1lYW5zIFwiYmxpbmsgb24gZnJhbWUgMFwiLlxuY29uc3QgSURMRV9TRVFVRU5DRSA9IFswLCAwLCAwLCAwLCAxLCAwLCAwLCAwLCAtMSwgMCwgMCwgMiwgMCwgMCwgMF1cblxuLy8gSGVhcnRzIGZsb2F0IHVwLWFuZC1vdXQgb3ZlciA1IHRpY2tzICh+Mi41cykuIFByZXBlbmRlZCBhYm92ZSB0aGUgc3ByaXRlLlxuY29uc3QgSCA9IGZpZ3VyZXMuaGVhcnRcbmNvbnN0IFBFVF9IRUFSVFMgPSBbXG4gIGAgICAke0h9ICAgICR7SH0gICBgLFxuICBgICAke0h9ICAke0h9ICAgJHtIfSAgYCxcbiAgYCAke0h9ICAgJHtIfSAgJHtIfSAgIGAsXG4gIGAke0h9ICAke0h9ICAgICAgJHtIfSBgLFxuICAnwrcgICAgwrcgICDCtyAgJyxcbl1cblxuZnVuY3Rpb24gd3JhcCh0ZXh0OiBzdHJpbmcsIHdpZHRoOiBudW1iZXIpOiBzdHJpbmdbXSB7XG4gIGNvbnN0IHdvcmRzID0gdGV4dC5zcGxpdCgnICcpXG4gIGNvbnN0IGxpbmVzOiBzdHJpbmdbXSA9IFtdXG4gIGxldCBjdXIgPSAnJ1xuICBmb3IgKGNvbnN0IHcgb2Ygd29yZHMpIHtcbiAgICBpZiAoY3VyLmxlbmd0aCArIHcubGVuZ3RoICsgMSA+IHdpZHRoICYmIGN1cikge1xuICAgICAgbGluZXMucHVzaChjdXIpXG4gICAgICBjdXIgPSB3XG4gICAgfSBlbHNlIHtcbiAgICAgIGN1ciA9IGN1ciA/IGAke2N1cn0gJHt3fWAgOiB3XG4gICAgfVxuICB9XG4gIGlmIChjdXIpIGxpbmVzLnB1c2goY3VyKVxuICByZXR1cm4gbGluZXNcbn1cblxuZnVuY3Rpb24gU3BlZWNoQnViYmxlKHtcbiAgdGV4dCxcbiAgY29sb3IsXG4gIGZhZGluZyxcbiAgdGFpbCxcbn06IHtcbiAgdGV4dDogc3RyaW5nXG4gIGNvbG9yOiBrZXlvZiBUaGVtZVxuICBmYWRpbmc6IGJvb2xlYW5cbiAgdGFpbDogJ2Rvd24nIHwgJ3JpZ2h0J1xufSk6IFJlYWN0LlJlYWN0Tm9kZSB7XG4gIGNvbnN0IGxpbmVzID0gd3JhcCh0ZXh0LCAzMClcbiAgY29uc3QgYm9yZGVyQ29sb3IgPSBmYWRpbmcgPyAnaW5hY3RpdmUnIDogY29sb3JcbiAgY29uc3QgYnViYmxlID0gKFxuICAgIDxCb3hcbiAgICAgIGZsZXhEaXJlY3Rpb249XCJjb2x1bW5cIlxuICAgICAgYm9yZGVyU3R5bGU9XCJyb3VuZFwiXG4gICAgICBib3JkZXJDb2xvcj17Ym9yZGVyQ29sb3J9XG4gICAgICBwYWRkaW5nWD17MX1cbiAgICAgIHdpZHRoPXszNH1cbiAgICA+XG4gICAgICB7bGluZXMubWFwKChsLCBpKSA9PiAoXG4gICAgICAgIDxUZXh0XG4gICAgICAgICAga2V5PXtpfVxuICAgICAgICAgIGl0YWxpY1xuICAgICAgICAgIGRpbUNvbG9yPXshZmFkaW5nfVxuICAgICAgICAgIGNvbG9yPXtmYWRpbmcgPyAnaW5hY3RpdmUnIDogdW5kZWZpbmVkfVxuICAgICAgICA+XG4gICAgICAgICAge2x9XG4gICAgICAgIDwvVGV4dD5cbiAgICAgICkpfVxuICAgIDwvQm94PlxuICApXG4gIGlmICh0YWlsID09PSAncmlnaHQnKSB7XG4gICAgcmV0dXJuIChcbiAgICAgIDxCb3ggZmxleERpcmVjdGlvbj1cInJvd1wiIGFsaWduSXRlbXM9XCJjZW50ZXJcIj5cbiAgICAgICAge2J1YmJsZX1cbiAgICAgICAgPFRleHQgY29sb3I9e2JvcmRlckNvbG9yfT7ilIA8L1RleHQ+XG4gICAgICA8L0JveD5cbiAgICApXG4gIH1cbiAgcmV0dXJuIChcbiAgICA8Qm94IGZsZXhEaXJlY3Rpb249XCJjb2x1bW5cIiBhbGlnbkl0ZW1zPVwiZmxleC1lbmRcIiBtYXJnaW5SaWdodD17MX0+XG4gICAgICB7YnViYmxlfVxuICAgICAgPEJveCBmbGV4RGlyZWN0aW9uPVwiY29sdW1uXCIgYWxpZ25JdGVtcz1cImZsZXgtZW5kXCIgcGFkZGluZ1JpZ2h0PXs2fT5cbiAgICAgICAgPFRleHQgY29sb3I9e2JvcmRlckNvbG9yfT7ilbIgPC9UZXh0PlxuICAgICAgICA8VGV4dCBjb2xvcj17Ym9yZGVyQ29sb3J9PuKVsjwvVGV4dD5cbiAgICAgIDwvQm94PlxuICAgIDwvQm94PlxuICApXG59XG5cbmV4cG9ydCBjb25zdCBNSU5fQ09MU19GT1JfRlVMTF9TUFJJVEUgPSAxMDBcbmNvbnN0IFNQUklURV9CT0RZX1dJRFRIID0gMTJcbmNvbnN0IE5BTUVfUk9XX1BBRCA9IDIgLy8gZm9jdXNlZCBzdGF0ZSB3cmFwcyBuYW1lIGluIHNwYWNlczogYCBuYW1lIGBcbmNvbnN0IFNQUklURV9QQURESU5HX1ggPSAyXG5jb25zdCBCVUJCTEVfV0lEVEggPSAzNiAvLyBTcGVlY2hCdWJibGUgYm94ICgzNCkgKyB0YWlsIGNvbHVtblxuY29uc3QgTkFSUk9XX1FVSVBfQ0FQID0gMjRcblxuZnVuY3Rpb24gc3ByaXRlQ29sV2lkdGgobmFtZVdpZHRoOiBudW1iZXIpOiBudW1iZXIge1xuICByZXR1cm4gTWF0aC5tYXgoU1BSSVRFX0JPRFlfV0lEVEgsIG5hbWVXaWR0aCArIE5BTUVfUk9XX1BBRClcbn1cblxuLy8gV2lkdGggdGhlIHNwcml0ZSBhcmVhIGNvbnN1bWVzLiBQcm9tcHRJbnB1dCBzdWJ0cmFjdHMgdGhpcyBzbyB0ZXh0IHdyYXBzXG4vLyBjb3JyZWN0bHkuIEluIGZ1bGxzY3JlZW4gdGhlIGJ1YmJsZSBmbG9hdHMgb3ZlciBzY3JvbGxiYWNrIChubyBleHRyYVxuLy8gd2lkdGgpOyBpbiBub24tZnVsbHNjcmVlbiBpdCBzaXRzIGlubGluZSBhbmQgbmVlZHMgQlVCQkxFX1dJRFRIIG1vcmUuXG4vLyBOYXJyb3cgdGVybWluYWxzOiAwIOKAlCBSRVBMLnRzeCBzdGFja3MgdGhlIG9uZS1saW5lciBvbiBpdHMgb3duIHJvd1xuLy8gKGFib3ZlIGlucHV0IGluIGZ1bGxzY3JlZW4sIGJlbG93IGluIHNjcm9sbGJhY2spLCBzbyBubyByZXNlcnZhdGlvbi5cbmV4cG9ydCBmdW5jdGlvbiBjb21wYW5pb25SZXNlcnZlZENvbHVtbnMoXG4gIHRlcm1pbmFsQ29sdW1uczogbnVtYmVyLFxuICBzcGVha2luZzogYm9vbGVhbixcbik6IG51bWJlciB7XG4gIGlmICghZmVhdHVyZSgnQlVERFknKSkgcmV0dXJuIDBcbiAgY29uc3QgY29tcGFuaW9uID0gZ2V0Q29tcGFuaW9uKClcbiAgaWYgKCFjb21wYW5pb24gfHwgZ2V0R2xvYmFsQ29uZmlnKCkuY29tcGFuaW9uTXV0ZWQpIHJldHVybiAwXG4gIGlmICh0ZXJtaW5hbENvbHVtbnMgPCBNSU5fQ09MU19GT1JfRlVMTF9TUFJJVEUpIHJldHVybiAwXG4gIGNvbnN0IG5hbWVXaWR0aCA9IHN0cmluZ1dpZHRoKGNvbXBhbmlvbi5uYW1lKVxuICBjb25zdCBidWJibGUgPSBzcGVha2luZyAmJiAhaXNGdWxsc2NyZWVuQWN0aXZlKCkgPyBCVUJCTEVfV0lEVEggOiAwXG4gIHJldHVybiBzcHJpdGVDb2xXaWR0aChuYW1lV2lkdGgpICsgU1BSSVRFX1BBRERJTkdfWCArIGJ1YmJsZVxufVxuXG5leHBvcnQgZnVuY3Rpb24gQ29tcGFuaW9uU3ByaXRlKCk6IFJlYWN0LlJlYWN0Tm9kZSB7XG4gIGNvbnN0IHJlYWN0aW9uID0gdXNlQXBwU3RhdGUocyA9PiBzLmNvbXBhbmlvblJlYWN0aW9uKVxuICBjb25zdCBwZXRBdCA9IHVzZUFwcFN0YXRlKHMgPT4gcy5jb21wYW5pb25QZXRBdClcbiAgY29uc3QgZm9jdXNlZCA9IHVzZUFwcFN0YXRlKHMgPT4gcy5mb290ZXJTZWxlY3Rpb24gPT09ICdjb21wYW5pb24nKVxuICBjb25zdCBzZXRBcHBTdGF0ZSA9IHVzZVNldEFwcFN0YXRlKClcbiAgY29uc3QgeyBjb2x1bW5zIH0gPSB1c2VUZXJtaW5hbFNpemUoKVxuICBjb25zdCBbdGljaywgc2V0VGlja10gPSB1c2VTdGF0ZSgwKVxuICBjb25zdCBsYXN0U3Bva2VUaWNrID0gdXNlUmVmKDApXG4gIC8vIFN5bmMtZHVyaW5nLXJlbmRlciAobm90IHVzZUVmZmVjdCkgc28gdGhlIGZpcnN0IHBvc3QtcGV0IHJlbmRlciBhbHJlYWR5XG4gIC8vIGhhcyBwZXRTdGFydFRpY2s9dGljayBhbmQgcGV0QWdlPTAg4oCUIG90aGVyd2lzZSBmcmFtZSAwIGlzIHNraXBwZWQuXG4gIGNvbnN0IFt7IHBldFN0YXJ0VGljaywgZm9yUGV0QXQgfSwgc2V0UGV0U3RhcnRdID0gdXNlU3RhdGUoe1xuICAgIHBldFN0YXJ0VGljazogMCxcbiAgICBmb3JQZXRBdDogcGV0QXQsXG4gIH0pXG4gIGlmIChwZXRBdCAhPT0gZm9yUGV0QXQpIHtcbiAgICBzZXRQZXRTdGFydCh7IHBldFN0YXJ0VGljazogdGljaywgZm9yUGV0QXQ6IHBldEF0IH0pXG4gIH1cblxuICB1c2VFZmZlY3QoKCkgPT4ge1xuICAgIGNvbnN0IHRpbWVyID0gc2V0SW50ZXJ2YWwoXG4gICAgICBzZXRUID0+IHNldFQoKHQ6IG51bWJlcikgPT4gdCArIDEpLFxuICAgICAgVElDS19NUyxcbiAgICAgIHNldFRpY2ssXG4gICAgKVxuICAgIHJldHVybiAoKSA9PiBjbGVhckludGVydmFsKHRpbWVyKVxuICB9LCBbXSlcblxuICB1c2VFZmZlY3QoKCkgPT4ge1xuICAgIGlmICghcmVhY3Rpb24pIHJldHVyblxuICAgIGxhc3RTcG9rZVRpY2suY3VycmVudCA9IHRpY2tcbiAgICBjb25zdCB0aW1lciA9IHNldFRpbWVvdXQoXG4gICAgICBzZXRBID0+XG4gICAgICAgIHNldEEoKHByZXY6IEFwcFN0YXRlKSA9PlxuICAgICAgICAgIHByZXYuY29tcGFuaW9uUmVhY3Rpb24gPT09IHVuZGVmaW5lZFxuICAgICAgICAgICAgPyBwcmV2XG4gICAgICAgICAgICA6IHsgLi4ucHJldiwgY29tcGFuaW9uUmVhY3Rpb246IHVuZGVmaW5lZCB9LFxuICAgICAgICApLFxuICAgICAgQlVCQkxFX1NIT1cgKiBUSUNLX01TLFxuICAgICAgc2V0QXBwU3RhdGUsXG4gICAgKVxuICAgIHJldHVybiAoKSA9PiBjbGVhclRpbWVvdXQodGltZXIpXG4gICAgLy8gZXNsaW50LWRpc2FibGUtbmV4dC1saW5lIHJlYWN0LWhvb2tzL2V4aGF1c3RpdmUtZGVwcyAtLSB0aWNrIGludGVudGlvbmFsbHkgY2FwdHVyZWQgYXQgcmVhY3Rpb24tY2hhbmdlLCBub3QgdHJhY2tlZFxuICB9LCBbcmVhY3Rpb24sIHNldEFwcFN0YXRlXSlcblxuICBpZiAoIWZlYXR1cmUoJ0JVRERZJykpIHJldHVybiBudWxsXG4gIGNvbnN0IGNvbXBhbmlvbiA9IGdldENvbXBhbmlvbigpXG4gIGlmICghY29tcGFuaW9uIHx8IGdldEdsb2JhbENvbmZpZygpLmNvbXBhbmlvbk11dGVkKSByZXR1cm4gbnVsbFxuXG4gIGNvbnN0IGNvbG9yID0gUkFSSVRZX0NPTE9SU1tjb21wYW5pb24ucmFyaXR5XVxuICBjb25zdCBjb2xXaWR0aCA9IHNwcml0ZUNvbFdpZHRoKHN0cmluZ1dpZHRoKGNvbXBhbmlvbi5uYW1lKSlcblxuICBjb25zdCBidWJibGVBZ2UgPSByZWFjdGlvbiA/IHRpY2sgLSBsYXN0U3Bva2VUaWNrLmN1cnJlbnQgOiAwXG4gIGNvbnN0IGZhZGluZyA9XG4gICAgcmVhY3Rpb24gIT09IHVuZGVmaW5lZCAmJiBidWJibGVBZ2UgPj0gQlVCQkxFX1NIT1cgLSBGQURFX1dJTkRPV1xuXG4gIGNvbnN0IHBldEFnZSA9IHBldEF0ID8gdGljayAtIHBldFN0YXJ0VGljayA6IEluZmluaXR5XG4gIGNvbnN0IHBldHRpbmcgPSBwZXRBZ2UgKiBUSUNLX01TIDwgUEVUX0JVUlNUX01TXG5cbiAgLy8gTmFycm93IHRlcm1pbmFsczogY29sbGFwc2UgdG8gb25lLWxpbmUgZmFjZS4gV2hlbiBzcGVha2luZywgdGhlIHF1aXBcbiAgLy8gcmVwbGFjZXMgdGhlIG5hbWUgYmVzaWRlIHRoZSBmYWNlIChubyByb29tIGZvciBhIGJ1YmJsZSkuXG4gIGlmIChjb2x1bW5zIDwgTUlOX0NPTFNfRk9SX0ZVTExfU1BSSVRFKSB7XG4gICAgY29uc3QgcXVpcCA9XG4gICAgICByZWFjdGlvbiAmJiByZWFjdGlvbi5sZW5ndGggPiBOQVJST1dfUVVJUF9DQVBcbiAgICAgICAgPyByZWFjdGlvbi5zbGljZSgwLCBOQVJST1dfUVVJUF9DQVAgLSAxKSArICfigKYnXG4gICAgICAgIDogcmVhY3Rpb25cbiAgICBjb25zdCBsYWJlbCA9IHF1aXBcbiAgICAgID8gYFwiJHtxdWlwfVwiYFxuICAgICAgOiBmb2N1c2VkXG4gICAgICAgID8gYCAke2NvbXBhbmlvbi5uYW1lfSBgXG4gICAgICAgIDogY29tcGFuaW9uLm5hbWVcbiAgICByZXR1cm4gKFxuICAgICAgPEJveCBwYWRkaW5nWD17MX0gYWxpZ25TZWxmPVwiZmxleC1lbmRcIj5cbiAgICAgICAgPFRleHQ+XG4gICAgICAgICAge3BldHRpbmcgJiYgPFRleHQgY29sb3I9XCJhdXRvQWNjZXB0XCI+e2ZpZ3VyZXMuaGVhcnR9IDwvVGV4dD59XG4gICAgICAgICAgPFRleHQgYm9sZCBjb2xvcj17Y29sb3J9PlxuICAgICAgICAgICAge3JlbmRlckZhY2UoY29tcGFuaW9uKX1cbiAgICAgICAgICA8L1RleHQ+eycgJ31cbiAgICAgICAgICA8VGV4dFxuICAgICAgICAgICAgaXRhbGljXG4gICAgICAgICAgICBkaW1Db2xvcj17IWZvY3VzZWQgJiYgIXJlYWN0aW9ufVxuICAgICAgICAgICAgYm9sZD17Zm9jdXNlZH1cbiAgICAgICAgICAgIGludmVyc2U9e2ZvY3VzZWQgJiYgIXJlYWN0aW9ufVxuICAgICAgICAgICAgY29sb3I9e1xuICAgICAgICAgICAgICByZWFjdGlvblxuICAgICAgICAgICAgICAgID8gZmFkaW5nXG4gICAgICAgICAgICAgICAgICA/ICdpbmFjdGl2ZSdcbiAgICAgICAgICAgICAgICAgIDogY29sb3JcbiAgICAgICAgICAgICAgICA6IGZvY3VzZWRcbiAgICAgICAgICAgICAgICAgID8gY29sb3JcbiAgICAgICAgICAgICAgICAgIDogdW5kZWZpbmVkXG4gICAgICAgICAgICB9XG4gICAgICAgICAgPlxuICAgICAgICAgICAge2xhYmVsfVxuICAgICAgICAgIDwvVGV4dD5cbiAgICAgICAgPC9UZXh0PlxuICAgICAgPC9Cb3g+XG4gICAgKVxuICB9XG4gIGNvbnN0IGZyYW1lQ291bnQgPSBzcHJpdGVGcmFtZUNvdW50KGNvbXBhbmlvbi5zcGVjaWVzKVxuICBjb25zdCBoZWFydEZyYW1lID0gcGV0dGluZyA/IFBFVF9IRUFSVFNbcGV0QWdlICUgUEVUX0hFQVJUUy5sZW5ndGhdIDogbnVsbFxuXG4gIGxldCBzcHJpdGVGcmFtZTogbnVtYmVyXG4gIGxldCBibGluayA9IGZhbHNlXG4gIGlmIChyZWFjdGlvbiB8fCBwZXR0aW5nKSB7XG4gICAgLy8gRXhjaXRlZDogY3ljbGUgYWxsIGZpZGdldCBmcmFtZXMgZmFzdFxuICAgIHNwcml0ZUZyYW1lID0gdGljayAlIGZyYW1lQ291bnRcbiAgfSBlbHNlIHtcbiAgICBjb25zdCBzdGVwID0gSURMRV9TRVFVRU5DRVt0aWNrICUgSURMRV9TRVFVRU5DRS5sZW5ndGhdIVxuICAgIGlmIChzdGVwID09PSAtMSkge1xuICAgICAgc3ByaXRlRnJhbWUgPSAwXG4gICAgICBibGluayA9IHRydWVcbiAgICB9IGVsc2Uge1xuICAgICAgc3ByaXRlRnJhbWUgPSBzdGVwICUgZnJhbWVDb3VudFxuICAgIH1cbiAgfVxuXG4gIGNvbnN0IGJvZHkgPSByZW5kZXJTcHJpdGUoY29tcGFuaW9uLCBzcHJpdGVGcmFtZSkubWFwKGxpbmUgPT5cbiAgICBibGluayA/IGxpbmUucmVwbGFjZUFsbChjb21wYW5pb24uZXllLCAnLScpIDogbGluZSxcbiAgKVxuICBjb25zdCBzcHJpdGUgPSBoZWFydEZyYW1lID8gW2hlYXJ0RnJhbWUsIC4uLmJvZHldIDogYm9keVxuXG4gIC8vIE5hbWUgcm93IGRvdWJsZXMgYXMgaGludCByb3cg4oCUIHVuZm9jdXNlZCBzaG93cyBkaW0gbmFtZSArIOKGkyBkaXNjb3ZlcnksXG4gIC8vIGZvY3VzZWQgc2hvd3MgaW52ZXJzZSBuYW1lLiBUaGUgZW50ZXItdG8tb3BlbiBoaW50IGxpdmVzIGluXG4gIC8vIFByb21wdElucHV0Rm9vdGVyJ3MgcmlnaHQgY29sdW1uIHNvIHRoaXMgcm93IHN0YXlzIG9uZSBsaW5lIGFuZCB0aGVcbiAgLy8gc3ByaXRlIGRvZXNuJ3QganVtcCB1cCB3aGVuIHNlbGVjdGVkLiBmbGV4U2hyaW5rPTAgc3RvcHMgdGhlXG4gIC8vIGlubGluZS1idWJibGUgcm93IHdyYXBwZXIgZnJvbSBzcXVlZXppbmcgdGhlIHNwcml0ZSB0byBmaXQuXG4gIGNvbnN0IHNwcml0ZUNvbHVtbiA9IChcbiAgICA8Qm94XG4gICAgICBmbGV4RGlyZWN0aW9uPVwiY29sdW1uXCJcbiAgICAgIGZsZXhTaHJpbms9ezB9XG4gICAgICBhbGlnbkl0ZW1zPVwiY2VudGVyXCJcbiAgICAgIHdpZHRoPXtjb2xXaWR0aH1cbiAgICA+XG4gICAgICB7c3ByaXRlLm1hcCgobGluZSwgaSkgPT4gKFxuICAgICAgICA8VGV4dCBrZXk9e2l9IGNvbG9yPXtpID09PSAwICYmIGhlYXJ0RnJhbWUgPyAnYXV0b0FjY2VwdCcgOiBjb2xvcn0+XG4gICAgICAgICAge2xpbmV9XG4gICAgICAgIDwvVGV4dD5cbiAgICAgICkpfVxuICAgICAgPFRleHRcbiAgICAgICAgaXRhbGljXG4gICAgICAgIGJvbGQ9e2ZvY3VzZWR9XG4gICAgICAgIGRpbUNvbG9yPXshZm9jdXNlZH1cbiAgICAgICAgY29sb3I9e2ZvY3VzZWQgPyBjb2xvciA6IHVuZGVmaW5lZH1cbiAgICAgICAgaW52ZXJzZT17Zm9jdXNlZH1cbiAgICAgID5cbiAgICAgICAge2ZvY3VzZWQgPyBgICR7Y29tcGFuaW9uLm5hbWV9IGAgOiBjb21wYW5pb24ubmFtZX1cbiAgICAgIDwvVGV4dD5cbiAgICA8L0JveD5cbiAgKVxuXG4gIGlmICghcmVhY3Rpb24pIHtcbiAgICByZXR1cm4gPEJveCBwYWRkaW5nWD17MX0+e3Nwcml0ZUNvbHVtbn08L0JveD5cbiAgfVxuXG4gIC8vIEZ1bGxzY3JlZW46IGJ1YmJsZSByZW5kZXJzIHNlcGFyYXRlbHkgdmlhIENvbXBhbmlvbkZsb2F0aW5nQnViYmxlIGluXG4gIC8vIEZ1bGxzY3JlZW5MYXlvdXQncyBib3R0b21GbG9hdCBzbG90ICh0aGUgYm90dG9tIHNsb3QncyBvdmVyZmxvd1k6aGlkZGVuXG4gIC8vIHdvdWxkIGNsaXAgYSBwb3NpdGlvbjphYnNvbHV0ZSBvdmVybGF5IGhlcmUpLiBTcHJpdGUgYm9keSBvbmx5LlxuICAvLyBOb24tZnVsbHNjcmVlbjogYnViYmxlIHNpdHMgaW5saW5lIGJlc2lkZSB0aGUgc3ByaXRlIChpbnB1dCBzaHJpbmtzKVxuICAvLyBiZWNhdXNlIGZsb2F0aW5nIGludG8gU3RhdGljIHNjcm9sbGJhY2sgY2FuJ3QgYmUgY2xlYXJlZC5cbiAgaWYgKGlzRnVsbHNjcmVlbkFjdGl2ZSgpKSB7XG4gICAgcmV0dXJuIDxCb3ggcGFkZGluZ1g9ezF9PntzcHJpdGVDb2x1bW59PC9Cb3g+XG4gIH1cbiAgcmV0dXJuIChcbiAgICA8Qm94IGZsZXhEaXJlY3Rpb249XCJyb3dcIiBhbGlnbkl0ZW1zPVwiZmxleC1lbmRcIiBwYWRkaW5nWD17MX0gZmxleFNocmluaz17MH0+XG4gICAgICA8U3BlZWNoQnViYmxlXG4gICAgICAgIHRleHQ9e3JlYWN0aW9ufVxuICAgICAgICBjb2xvcj17Y29sb3J9XG4gICAgICAgIGZhZGluZz17ZmFkaW5nfVxuICAgICAgICB0YWlsPVwicmlnaHRcIlxuICAgICAgLz5cbiAgICAgIHtzcHJpdGVDb2x1bW59XG4gICAgPC9Cb3g+XG4gIClcbn1cblxuLy8gRmxvYXRpbmcgYnViYmxlIG92ZXJsYXkgZm9yIGZ1bGxzY3JlZW4gbW9kZS4gTW91bnRlZCBpbiBGdWxsc2NyZWVuTGF5b3V0J3Ncbi8vIGJvdHRvbUZsb2F0IHNsb3QgKG91dHNpZGUgdGhlIG92ZXJmbG93WTpoaWRkZW4gY2xpcCkgc28gaXQgY2FuIGV4dGVuZCBpbnRvXG4vLyB0aGUgU2Nyb2xsQm94IHJlZ2lvbi4gQ29tcGFuaW9uU3ByaXRlIG93bnMgdGhlIGNsZWFyLWFmdGVyLTEwcyB0aW1lcjsgdGhpc1xuLy8ganVzdCByZWFkcyBjb21wYW5pb25SZWFjdGlvbiBhbmQgcmVuZGVycyB0aGUgZmFkZS5cbmV4cG9ydCBmdW5jdGlvbiBDb21wYW5pb25GbG9hdGluZ0J1YmJsZSgpOiBSZWFjdC5SZWFjdE5vZGUge1xuICBjb25zdCByZWFjdGlvbiA9IHVzZUFwcFN0YXRlKHMgPT4gcy5jb21wYW5pb25SZWFjdGlvbilcbiAgY29uc3QgW3sgdGljaywgZm9yUmVhY3Rpb24gfSwgc2V0VGlja10gPSB1c2VTdGF0ZSh7XG4gICAgdGljazogMCxcbiAgICBmb3JSZWFjdGlvbjogcmVhY3Rpb24sXG4gIH0pXG5cbiAgLy8gUmVzZXQgdGljayBzeW5jaHJvbm91c2x5IHdoZW4gcmVhY3Rpb24gY2hhbmdlcyAobm90IGluIHVzZUVmZmVjdCwgd2hpY2hcbiAgLy8gcnVucyBwb3N0LXJlbmRlciBhbmQgd291bGQgc2hvdyBvbmUgc3RhbGUtZmFkZWQgZnJhbWUpLiBTdG9yaW5nIHRoZVxuICAvLyByZWFjdGlvbiB0aGUgdGljayBpcyBjb3VudGluZyBGT1IgYWxvbmdzaWRlIHRoZSB0aWNrIGl0c2VsZiBtZWFucyB0aGVcbiAgLy8gZmFkZSBjb21wdXRhdGlvbiBuZXZlciBzZWVzIGEgdGljayBmcm9tIGEgcHJldmlvdXMgcmVhY3Rpb24uXG4gIGlmIChyZWFjdGlvbiAhPT0gZm9yUmVhY3Rpb24pIHtcbiAgICBzZXRUaWNrKHsgdGljazogMCwgZm9yUmVhY3Rpb246IHJlYWN0aW9uIH0pXG4gIH1cblxuICB1c2VFZmZlY3QoKCkgPT4ge1xuICAgIGlmICghcmVhY3Rpb24pIHJldHVyblxuICAgIGNvbnN0IHRpbWVyID0gc2V0SW50ZXJ2YWwoXG4gICAgICBzZXQgPT4gc2V0KHMgPT4gKHsgLi4ucywgdGljazogcy50aWNrICsgMSB9KSksXG4gICAgICBUSUNLX01TLFxuICAgICAgc2V0VGljayxcbiAgICApXG4gICAgcmV0dXJuICgpID0+IGNsZWFySW50ZXJ2YWwodGltZXIpXG4gIH0sIFtyZWFjdGlvbl0pXG5cbiAgaWYgKCFmZWF0dXJlKCdCVUREWScpIHx8ICFyZWFjdGlvbikgcmV0dXJuIG51bGxcbiAgY29uc3QgY29tcGFuaW9uID0gZ2V0Q29tcGFuaW9uKClcbiAgaWYgKCFjb21wYW5pb24gfHwgZ2V0R2xvYmFsQ29uZmlnKCkuY29tcGFuaW9uTXV0ZWQpIHJldHVybiBudWxsXG5cbiAgcmV0dXJuIChcbiAgICA8U3BlZWNoQnViYmxlXG4gICAgICB0ZXh0PXtyZWFjdGlvbn1cbiAgICAgIGNvbG9yPXtSQVJJVFlfQ09MT1JTW2NvbXBhbmlvbi5yYXJpdHldfVxuICAgICAgZmFkaW5nPXt0aWNrID49IEJVQkJMRV9TSE9XIC0gRkFERV9XSU5ET1d9XG4gICAgICB0YWlsPVwiZG93blwiXG4gICAgLz5cbiAgKVxufVxuIl0sIm1hcHBpbmdzIjoiO0FBQUEsU0FBU0EsT0FBTyxRQUFRLFlBQVk7QUFDcEMsT0FBT0MsT0FBTyxNQUFNLFNBQVM7QUFDN0IsT0FBT0MsS0FBSyxJQUFJQyxTQUFTLEVBQUVDLE1BQU0sRUFBRUMsUUFBUSxRQUFRLE9BQU87QUFDMUQsU0FBU0MsZUFBZSxRQUFRLDZCQUE2QjtBQUM3RCxTQUFTQyxXQUFXLFFBQVEsdUJBQXVCO0FBQ25ELFNBQVNDLEdBQUcsRUFBRUMsSUFBSSxRQUFRLFdBQVc7QUFDckMsU0FBU0MsV0FBVyxFQUFFQyxjQUFjLFFBQVEsc0JBQXNCO0FBQ2xFLGNBQWNDLFFBQVEsUUFBUSwyQkFBMkI7QUFDekQsU0FBU0MsZUFBZSxRQUFRLG9CQUFvQjtBQUNwRCxTQUFTQyxrQkFBa0IsUUFBUSx3QkFBd0I7QUFDM0QsY0FBY0MsS0FBSyxRQUFRLG1CQUFtQjtBQUM5QyxTQUFTQyxZQUFZLFFBQVEsZ0JBQWdCO0FBQzdDLFNBQVNDLFVBQVUsRUFBRUMsWUFBWSxFQUFFQyxnQkFBZ0IsUUFBUSxjQUFjO0FBQ3pFLFNBQVNDLGFBQWEsUUFBUSxZQUFZO0FBRTFDLE1BQU1DLE9BQU8sR0FBRyxHQUFHO0FBQ25CLE1BQU1DLFdBQVcsR0FBRyxFQUFFLEVBQUM7QUFDdkIsTUFBTUMsV0FBVyxHQUFHLENBQUMsRUFBQztBQUN0QixNQUFNQyxZQUFZLEdBQUcsSUFBSSxFQUFDOztBQUUxQjtBQUNBO0FBQ0EsTUFBTUMsYUFBYSxHQUFHLENBQUMsQ0FBQyxFQUFFLENBQUMsRUFBRSxDQUFDLEVBQUUsQ0FBQyxFQUFFLENBQUMsRUFBRSxDQUFDLEVBQUUsQ0FBQyxFQUFFLENBQUMsRUFBRSxDQUFDLENBQUMsRUFBRSxDQUFDLEVBQUUsQ0FBQyxFQUFFLENBQUMsRUFBRSxDQUFDLEVBQUUsQ0FBQyxFQUFFLENBQUMsQ0FBQzs7QUFFcEU7QUFDQSxNQUFNQyxDQUFDLEdBQUd6QixPQUFPLENBQUMwQixLQUFLO0FBQ3ZCLE1BQU1DLFVBQVUsR0FBRyxDQUNqQixNQUFNRixDQUFDLE9BQU9BLENBQUMsS0FBSyxFQUNwQixLQUFLQSxDQUFDLEtBQUtBLENBQUMsTUFBTUEsQ0FBQyxJQUFJLEVBQ3ZCLElBQUlBLENBQUMsTUFBTUEsQ0FBQyxLQUFLQSxDQUFDLEtBQUssRUFDdkIsR0FBR0EsQ0FBQyxLQUFLQSxDQUFDLFNBQVNBLENBQUMsR0FBRyxFQUN2QixjQUFjLENBQ2Y7QUFFRCxTQUFTRyxJQUFJQSxDQUFDQyxJQUFJLEVBQUUsTUFBTSxFQUFFQyxLQUFLLEVBQUUsTUFBTSxDQUFDLEVBQUUsTUFBTSxFQUFFLENBQUM7RUFDbkQsTUFBTUMsS0FBSyxHQUFHRixJQUFJLENBQUNHLEtBQUssQ0FBQyxHQUFHLENBQUM7RUFDN0IsTUFBTUMsS0FBSyxFQUFFLE1BQU0sRUFBRSxHQUFHLEVBQUU7RUFDMUIsSUFBSUMsR0FBRyxHQUFHLEVBQUU7RUFDWixLQUFLLE1BQU1DLENBQUMsSUFBSUosS0FBSyxFQUFFO0lBQ3JCLElBQUlHLEdBQUcsQ0FBQ0UsTUFBTSxHQUFHRCxDQUFDLENBQUNDLE1BQU0sR0FBRyxDQUFDLEdBQUdOLEtBQUssSUFBSUksR0FBRyxFQUFFO01BQzVDRCxLQUFLLENBQUNJLElBQUksQ0FBQ0gsR0FBRyxDQUFDO01BQ2ZBLEdBQUcsR0FBR0MsQ0FBQztJQUNULENBQUMsTUFBTTtNQUNMRCxHQUFHLEdBQUdBLEdBQUcsR0FBRyxHQUFHQSxHQUFHLElBQUlDLENBQUMsRUFBRSxHQUFHQSxDQUFDO0lBQy9CO0VBQ0Y7RUFDQSxJQUFJRCxHQUFHLEVBQUVELEtBQUssQ0FBQ0ksSUFBSSxDQUFDSCxHQUFHLENBQUM7RUFDeEIsT0FBT0QsS0FBSztBQUNkO0FBRUEsU0FBQUssYUFBQUMsRUFBQTtFQUFBLE1BQUFDLENBQUEsR0FBQUMsRUFBQTtFQUFzQjtJQUFBWixJQUFBO0lBQUFhLEtBQUE7SUFBQUMsTUFBQTtJQUFBQztFQUFBLElBQUFMLEVBVXJCO0VBQUEsSUFBQU0sRUFBQTtFQUFBLElBQUFDLFdBQUE7RUFBQSxJQUFBQyxFQUFBO0VBQUEsSUFBQUMsRUFBQTtFQUFBLElBQUFDLEVBQUE7RUFBQSxJQUFBQyxFQUFBO0VBQUEsSUFBQUMsRUFBQTtFQUFBLElBQUFDLEVBQUE7RUFBQSxJQUFBWixDQUFBLFFBQUFFLEtBQUEsSUFBQUYsQ0FBQSxRQUFBRyxNQUFBLElBQUFILENBQUEsUUFBQVgsSUFBQTtJQUNDLE1BQUFJLEtBQUEsR0FBY0wsSUFBSSxDQUFDQyxJQUFJLEVBQUUsRUFBRSxDQUFDO0lBQzVCaUIsV0FBQSxHQUFvQkgsTUFBTSxHQUFOLFVBQTJCLEdBQTNCRCxLQUEyQjtJQUU1Q0csRUFBQSxHQUFBdEMsR0FBRztJQUNZd0MsRUFBQSxXQUFRO0lBQ1ZDLEVBQUEsVUFBTztJQUNORixFQUFBLENBQUFBLENBQUEsQ0FBQUEsV0FBVztJQUNkSSxFQUFBLElBQUM7SUFDSkMsRUFBQSxLQUFFO0lBQUEsSUFBQUUsRUFBQTtJQUFBLElBQUFiLENBQUEsU0FBQUcsTUFBQTtNQUVFVSxFQUFBLEdBQUFBLENBQUFDLENBQUEsRUFBQUMsQ0FBQSxLQUNULENBQUMsSUFBSSxDQUNFQSxHQUFDLENBQURBLEVBQUEsQ0FBQyxDQUNOLE1BQU0sQ0FBTixLQUFLLENBQUMsQ0FDSSxRQUFPLENBQVAsRUFBQ1osTUFBSyxDQUFDLENBQ1YsS0FBK0IsQ0FBL0IsQ0FBQUEsTUFBTSxHQUFOLFVBQStCLEdBQS9CYSxTQUE4QixDQUFDLENBRXJDRixFQUFBLENBQ0gsRUFQQyxJQUFJLENBUU47TUFBQWQsQ0FBQSxPQUFBRyxNQUFBO01BQUFILENBQUEsT0FBQWEsRUFBQTtJQUFBO01BQUFBLEVBQUEsR0FBQWIsQ0FBQTtJQUFBO0lBVEFZLEVBQUEsR0FBQW5CLEtBQUssQ0FBQXdCLEdBQUksQ0FBQ0osRUFTVixDQUFDO0lBQUFiLENBQUEsTUFBQUUsS0FBQTtJQUFBRixDQUFBLE1BQUFHLE1BQUE7SUFBQUgsQ0FBQSxNQUFBWCxJQUFBO0lBQUFXLENBQUEsTUFBQUssRUFBQTtJQUFBTCxDQUFBLE1BQUFNLFdBQUE7SUFBQU4sQ0FBQSxNQUFBTyxFQUFBO0lBQUFQLENBQUEsTUFBQVEsRUFBQTtJQUFBUixDQUFBLE1BQUFTLEVBQUE7SUFBQVQsQ0FBQSxNQUFBVSxFQUFBO0lBQUFWLENBQUEsTUFBQVcsRUFBQTtJQUFBWCxDQUFBLE9BQUFZLEVBQUE7RUFBQTtJQUFBUCxFQUFBLEdBQUFMLENBQUE7SUFBQU0sV0FBQSxHQUFBTixDQUFBO0lBQUFPLEVBQUEsR0FBQVAsQ0FBQTtJQUFBUSxFQUFBLEdBQUFSLENBQUE7SUFBQVMsRUFBQSxHQUFBVCxDQUFBO0lBQUFVLEVBQUEsR0FBQVYsQ0FBQTtJQUFBVyxFQUFBLEdBQUFYLENBQUE7SUFBQVksRUFBQSxHQUFBWixDQUFBO0VBQUE7RUFBQSxJQUFBYSxFQUFBO0VBQUEsSUFBQWIsQ0FBQSxTQUFBSyxFQUFBLElBQUFMLENBQUEsU0FBQU8sRUFBQSxJQUFBUCxDQUFBLFNBQUFRLEVBQUEsSUFBQVIsQ0FBQSxTQUFBUyxFQUFBLElBQUFULENBQUEsU0FBQVUsRUFBQSxJQUFBVixDQUFBLFNBQUFXLEVBQUEsSUFBQVgsQ0FBQSxTQUFBWSxFQUFBO0lBaEJKQyxFQUFBLElBQUMsRUFBRyxDQUNZLGFBQVEsQ0FBUixDQUFBTixFQUFPLENBQUMsQ0FDVixXQUFPLENBQVAsQ0FBQUMsRUFBTSxDQUFDLENBQ05GLFdBQVcsQ0FBWEEsR0FBVSxDQUFDLENBQ2QsUUFBQyxDQUFELENBQUFJLEVBQUEsQ0FBQyxDQUNKLEtBQUUsQ0FBRixDQUFBQyxFQUFDLENBQUMsQ0FFUixDQUFBQyxFQVNBLENBQ0gsRUFqQkMsRUFBRyxDQWlCRTtJQUFBWixDQUFBLE9BQUFLLEVBQUE7SUFBQUwsQ0FBQSxPQUFBTyxFQUFBO0lBQUFQLENBQUEsT0FBQVEsRUFBQTtJQUFBUixDQUFBLE9BQUFTLEVBQUE7SUFBQVQsQ0FBQSxPQUFBVSxFQUFBO0lBQUFWLENBQUEsT0FBQVcsRUFBQTtJQUFBWCxDQUFBLE9BQUFZLEVBQUE7SUFBQVosQ0FBQSxPQUFBYSxFQUFBO0VBQUE7SUFBQUEsRUFBQSxHQUFBYixDQUFBO0VBQUE7RUFsQlIsTUFBQWtCLE1BQUEsR0FDRUwsRUFpQk07RUFFUixJQUFJVCxJQUFJLEtBQUssT0FBTztJQUFBLElBQUFlLEVBQUE7SUFBQSxJQUFBbkIsQ0FBQSxTQUFBTSxXQUFBO01BSWRhLEVBQUEsSUFBQyxJQUFJLENBQVFiLEtBQVcsQ0FBWEEsWUFBVSxDQUFDLENBQUUsQ0FBQyxFQUExQixJQUFJLENBQTZCO01BQUFOLENBQUEsT0FBQU0sV0FBQTtNQUFBTixDQUFBLE9BQUFtQixFQUFBO0lBQUE7TUFBQUEsRUFBQSxHQUFBbkIsQ0FBQTtJQUFBO0lBQUEsSUFBQW9CLEVBQUE7SUFBQSxJQUFBcEIsQ0FBQSxTQUFBa0IsTUFBQSxJQUFBbEIsQ0FBQSxTQUFBbUIsRUFBQTtNQUZwQ0MsRUFBQSxJQUFDLEdBQUcsQ0FBZSxhQUFLLENBQUwsS0FBSyxDQUFZLFVBQVEsQ0FBUixRQUFRLENBQ3pDRixPQUFLLENBQ04sQ0FBQUMsRUFBaUMsQ0FDbkMsRUFIQyxHQUFHLENBR0U7TUFBQW5CLENBQUEsT0FBQWtCLE1BQUE7TUFBQWxCLENBQUEsT0FBQW1CLEVBQUE7TUFBQW5CLENBQUEsT0FBQW9CLEVBQUE7SUFBQTtNQUFBQSxFQUFBLEdBQUFwQixDQUFBO0lBQUE7SUFBQSxPQUhOb0IsRUFHTTtFQUFBO0VBRVQsSUFBQUQsRUFBQTtFQUFBLElBQUFuQixDQUFBLFNBQUFNLFdBQUE7SUFJR2EsRUFBQSxJQUFDLEdBQUcsQ0FBZSxhQUFRLENBQVIsUUFBUSxDQUFZLFVBQVUsQ0FBVixVQUFVLENBQWUsWUFBQyxDQUFELEdBQUMsQ0FDL0QsQ0FBQyxJQUFJLENBQVFiLEtBQVcsQ0FBWEEsWUFBVSxDQUFDLENBQUUsRUFBRSxFQUEzQixJQUFJLENBQ0wsQ0FBQyxJQUFJLENBQVFBLEtBQVcsQ0FBWEEsWUFBVSxDQUFDLENBQUUsQ0FBQyxFQUExQixJQUFJLENBQ1AsRUFIQyxHQUFHLENBR0U7SUFBQU4sQ0FBQSxPQUFBTSxXQUFBO0lBQUFOLENBQUEsT0FBQW1CLEVBQUE7RUFBQTtJQUFBQSxFQUFBLEdBQUFuQixDQUFBO0VBQUE7RUFBQSxJQUFBb0IsRUFBQTtFQUFBLElBQUFwQixDQUFBLFNBQUFrQixNQUFBLElBQUFsQixDQUFBLFNBQUFtQixFQUFBO0lBTFJDLEVBQUEsSUFBQyxHQUFHLENBQWUsYUFBUSxDQUFSLFFBQVEsQ0FBWSxVQUFVLENBQVYsVUFBVSxDQUFjLFdBQUMsQ0FBRCxHQUFDLENBQzdERixPQUFLLENBQ04sQ0FBQUMsRUFHSyxDQUNQLEVBTkMsR0FBRyxDQU1FO0lBQUFuQixDQUFBLE9BQUFrQixNQUFBO0lBQUFsQixDQUFBLE9BQUFtQixFQUFBO0lBQUFuQixDQUFBLE9BQUFvQixFQUFBO0VBQUE7SUFBQUEsRUFBQSxHQUFBcEIsQ0FBQTtFQUFBO0VBQUEsT0FOTm9CLEVBTU07QUFBQTtBQUlWLE9BQU8sTUFBTUMsd0JBQXdCLEdBQUcsR0FBRztBQUMzQyxNQUFNQyxpQkFBaUIsR0FBRyxFQUFFO0FBQzVCLE1BQU1DLFlBQVksR0FBRyxDQUFDLEVBQUM7QUFDdkIsTUFBTUMsZ0JBQWdCLEdBQUcsQ0FBQztBQUMxQixNQUFNQyxZQUFZLEdBQUcsRUFBRSxFQUFDO0FBQ3hCLE1BQU1DLGVBQWUsR0FBRyxFQUFFO0FBRTFCLFNBQVNDLGNBQWNBLENBQUNDLFNBQVMsRUFBRSxNQUFNLENBQUMsRUFBRSxNQUFNLENBQUM7RUFDakQsT0FBT0MsSUFBSSxDQUFDQyxHQUFHLENBQUNSLGlCQUFpQixFQUFFTSxTQUFTLEdBQUdMLFlBQVksQ0FBQztBQUM5RDs7QUFFQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0EsT0FBTyxTQUFTUSx3QkFBd0JBLENBQ3RDQyxlQUFlLEVBQUUsTUFBTSxFQUN2QkMsUUFBUSxFQUFFLE9BQU8sQ0FDbEIsRUFBRSxNQUFNLENBQUM7RUFDUixJQUFJLENBQUMxRSxPQUFPLENBQUMsT0FBTyxDQUFDLEVBQUUsT0FBTyxDQUFDO0VBQy9CLE1BQU0yRSxTQUFTLEdBQUczRCxZQUFZLENBQUMsQ0FBQztFQUNoQyxJQUFJLENBQUMyRCxTQUFTLElBQUk5RCxlQUFlLENBQUMsQ0FBQyxDQUFDK0QsY0FBYyxFQUFFLE9BQU8sQ0FBQztFQUM1RCxJQUFJSCxlQUFlLEdBQUdYLHdCQUF3QixFQUFFLE9BQU8sQ0FBQztFQUN4RCxNQUFNTyxTQUFTLEdBQUc5RCxXQUFXLENBQUNvRSxTQUFTLENBQUNFLElBQUksQ0FBQztFQUM3QyxNQUFNbEIsTUFBTSxHQUFHZSxRQUFRLElBQUksQ0FBQzVELGtCQUFrQixDQUFDLENBQUMsR0FBR29ELFlBQVksR0FBRyxDQUFDO0VBQ25FLE9BQU9FLGNBQWMsQ0FBQ0MsU0FBUyxDQUFDLEdBQUdKLGdCQUFnQixHQUFHTixNQUFNO0FBQzlEO0FBRUEsT0FBTyxTQUFTbUIsZUFBZUEsQ0FBQSxDQUFFLEVBQUU1RSxLQUFLLENBQUM2RSxTQUFTLENBQUM7RUFDakQsTUFBTUMsUUFBUSxHQUFHdEUsV0FBVyxDQUFDdUUsQ0FBQyxJQUFJQSxDQUFDLENBQUNDLGlCQUFpQixDQUFDO0VBQ3RELE1BQU1DLEtBQUssR0FBR3pFLFdBQVcsQ0FBQ3VFLENBQUMsSUFBSUEsQ0FBQyxDQUFDRyxjQUFjLENBQUM7RUFDaEQsTUFBTUMsT0FBTyxHQUFHM0UsV0FBVyxDQUFDdUUsQ0FBQyxJQUFJQSxDQUFDLENBQUNLLGVBQWUsS0FBSyxXQUFXLENBQUM7RUFDbkUsTUFBTUMsV0FBVyxHQUFHNUUsY0FBYyxDQUFDLENBQUM7RUFDcEMsTUFBTTtJQUFFNkU7RUFBUSxDQUFDLEdBQUdsRixlQUFlLENBQUMsQ0FBQztFQUNyQyxNQUFNLENBQUNtRixJQUFJLEVBQUVDLE9BQU8sQ0FBQyxHQUFHckYsUUFBUSxDQUFDLENBQUMsQ0FBQztFQUNuQyxNQUFNc0YsYUFBYSxHQUFHdkYsTUFBTSxDQUFDLENBQUMsQ0FBQztFQUMvQjtFQUNBO0VBQ0EsTUFBTSxDQUFDO0lBQUV3RixZQUFZO0lBQUVDO0VBQVMsQ0FBQyxFQUFFQyxXQUFXLENBQUMsR0FBR3pGLFFBQVEsQ0FBQztJQUN6RHVGLFlBQVksRUFBRSxDQUFDO0lBQ2ZDLFFBQVEsRUFBRVY7RUFDWixDQUFDLENBQUM7RUFDRixJQUFJQSxLQUFLLEtBQUtVLFFBQVEsRUFBRTtJQUN0QkMsV0FBVyxDQUFDO01BQUVGLFlBQVksRUFBRUgsSUFBSTtNQUFFSSxRQUFRLEVBQUVWO0lBQU0sQ0FBQyxDQUFDO0VBQ3REO0VBRUFoRixTQUFTLENBQUMsTUFBTTtJQUNkLE1BQU00RixLQUFLLEdBQUdDLFdBQVcsQ0FDdkJDLElBQUksSUFBSUEsSUFBSSxDQUFDLENBQUNDLENBQUMsRUFBRSxNQUFNLEtBQUtBLENBQUMsR0FBRyxDQUFDLENBQUMsRUFDbEM3RSxPQUFPLEVBQ1BxRSxPQUNGLENBQUM7SUFDRCxPQUFPLE1BQU1TLGFBQWEsQ0FBQ0osS0FBSyxDQUFDO0VBQ25DLENBQUMsRUFBRSxFQUFFLENBQUM7RUFFTjVGLFNBQVMsQ0FBQyxNQUFNO0lBQ2QsSUFBSSxDQUFDNkUsUUFBUSxFQUFFO0lBQ2ZXLGFBQWEsQ0FBQ1MsT0FBTyxHQUFHWCxJQUFJO0lBQzVCLE1BQU1NLEtBQUssR0FBR00sVUFBVSxDQUN0QkMsSUFBSSxJQUNGQSxJQUFJLENBQUMsQ0FBQ0MsSUFBSSxFQUFFM0YsUUFBUSxLQUNsQjJGLElBQUksQ0FBQ3JCLGlCQUFpQixLQUFLekIsU0FBUyxHQUNoQzhDLElBQUksR0FDSjtNQUFFLEdBQUdBLElBQUk7TUFBRXJCLGlCQUFpQixFQUFFekI7SUFBVSxDQUM5QyxDQUFDLEVBQ0huQyxXQUFXLEdBQUdELE9BQU8sRUFDckJrRSxXQUNGLENBQUM7SUFDRCxPQUFPLE1BQU1pQixZQUFZLENBQUNULEtBQUssQ0FBQztJQUNoQztFQUNGLENBQUMsRUFBRSxDQUFDZixRQUFRLEVBQUVPLFdBQVcsQ0FBQyxDQUFDO0VBRTNCLElBQUksQ0FBQ3ZGLE9BQU8sQ0FBQyxPQUFPLENBQUMsRUFBRSxPQUFPLElBQUk7RUFDbEMsTUFBTTJFLFNBQVMsR0FBRzNELFlBQVksQ0FBQyxDQUFDO0VBQ2hDLElBQUksQ0FBQzJELFNBQVMsSUFBSTlELGVBQWUsQ0FBQyxDQUFDLENBQUMrRCxjQUFjLEVBQUUsT0FBTyxJQUFJO0VBRS9ELE1BQU1qQyxLQUFLLEdBQUd2QixhQUFhLENBQUN1RCxTQUFTLENBQUM4QixNQUFNLENBQUM7RUFDN0MsTUFBTUMsUUFBUSxHQUFHdEMsY0FBYyxDQUFDN0QsV0FBVyxDQUFDb0UsU0FBUyxDQUFDRSxJQUFJLENBQUMsQ0FBQztFQUU1RCxNQUFNOEIsU0FBUyxHQUFHM0IsUUFBUSxHQUFHUyxJQUFJLEdBQUdFLGFBQWEsQ0FBQ1MsT0FBTyxHQUFHLENBQUM7RUFDN0QsTUFBTXhELE1BQU0sR0FDVm9DLFFBQVEsS0FBS3ZCLFNBQVMsSUFBSWtELFNBQVMsSUFBSXJGLFdBQVcsR0FBR0MsV0FBVztFQUVsRSxNQUFNcUYsTUFBTSxHQUFHekIsS0FBSyxHQUFHTSxJQUFJLEdBQUdHLFlBQVksR0FBR2lCLFFBQVE7RUFDckQsTUFBTUMsT0FBTyxHQUFHRixNQUFNLEdBQUd2RixPQUFPLEdBQUdHLFlBQVk7O0VBRS9DO0VBQ0E7RUFDQSxJQUFJZ0UsT0FBTyxHQUFHMUIsd0JBQXdCLEVBQUU7SUFDdEMsTUFBTWlELElBQUksR0FDUi9CLFFBQVEsSUFBSUEsUUFBUSxDQUFDM0MsTUFBTSxHQUFHOEIsZUFBZSxHQUN6Q2EsUUFBUSxDQUFDZ0MsS0FBSyxDQUFDLENBQUMsRUFBRTdDLGVBQWUsR0FBRyxDQUFDLENBQUMsR0FBRyxHQUFHLEdBQzVDYSxRQUFRO0lBQ2QsTUFBTWlDLEtBQUssR0FBR0YsSUFBSSxHQUNkLElBQUlBLElBQUksR0FBRyxHQUNYMUIsT0FBTyxHQUNMLElBQUlWLFNBQVMsQ0FBQ0UsSUFBSSxHQUFHLEdBQ3JCRixTQUFTLENBQUNFLElBQUk7SUFDcEIsT0FDRSxDQUFDLEdBQUcsQ0FBQyxRQUFRLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxTQUFTLENBQUMsVUFBVTtBQUM1QyxRQUFRLENBQUMsSUFBSTtBQUNiLFVBQVUsQ0FBQ2lDLE9BQU8sSUFBSSxDQUFDLElBQUksQ0FBQyxLQUFLLENBQUMsWUFBWSxDQUFDLENBQUM3RyxPQUFPLENBQUMwQixLQUFLLENBQUMsQ0FBQyxFQUFFLElBQUksQ0FBQztBQUN0RSxVQUFVLENBQUMsSUFBSSxDQUFDLElBQUksQ0FBQyxLQUFLLENBQUMsQ0FBQ2dCLEtBQUssQ0FBQztBQUNsQyxZQUFZLENBQUMxQixVQUFVLENBQUMwRCxTQUFTLENBQUM7QUFDbEMsVUFBVSxFQUFFLElBQUksQ0FBQyxDQUFDLEdBQUc7QUFDckIsVUFBVSxDQUFDLElBQUksQ0FDSCxNQUFNLENBQ04sUUFBUSxDQUFDLENBQUMsQ0FBQ1UsT0FBTyxJQUFJLENBQUNMLFFBQVEsQ0FBQyxDQUNoQyxJQUFJLENBQUMsQ0FBQ0ssT0FBTyxDQUFDLENBQ2QsT0FBTyxDQUFDLENBQUNBLE9BQU8sSUFBSSxDQUFDTCxRQUFRLENBQUMsQ0FDOUIsS0FBSyxDQUFDLENBQ0pBLFFBQVEsR0FDSnBDLE1BQU0sR0FDSixVQUFVLEdBQ1ZELEtBQUssR0FDUDBDLE9BQU8sR0FDTDFDLEtBQUssR0FDTGMsU0FDUixDQUFDO0FBRWIsWUFBWSxDQUFDd0QsS0FBSztBQUNsQixVQUFVLEVBQUUsSUFBSTtBQUNoQixRQUFRLEVBQUUsSUFBSTtBQUNkLE1BQU0sRUFBRSxHQUFHLENBQUM7RUFFVjtFQUNBLE1BQU1DLFVBQVUsR0FBRy9GLGdCQUFnQixDQUFDd0QsU0FBUyxDQUFDd0MsT0FBTyxDQUFDO0VBQ3RELE1BQU1DLFVBQVUsR0FBR04sT0FBTyxHQUFHbEYsVUFBVSxDQUFDZ0YsTUFBTSxHQUFHaEYsVUFBVSxDQUFDUyxNQUFNLENBQUMsR0FBRyxJQUFJO0VBRTFFLElBQUlnRixXQUFXLEVBQUUsTUFBTTtFQUN2QixJQUFJQyxLQUFLLEdBQUcsS0FBSztFQUNqQixJQUFJdEMsUUFBUSxJQUFJOEIsT0FBTyxFQUFFO0lBQ3ZCO0lBQ0FPLFdBQVcsR0FBRzVCLElBQUksR0FBR3lCLFVBQVU7RUFDakMsQ0FBQyxNQUFNO0lBQ0wsTUFBTUssSUFBSSxHQUFHOUYsYUFBYSxDQUFDZ0UsSUFBSSxHQUFHaEUsYUFBYSxDQUFDWSxNQUFNLENBQUMsQ0FBQztJQUN4RCxJQUFJa0YsSUFBSSxLQUFLLENBQUMsQ0FBQyxFQUFFO01BQ2ZGLFdBQVcsR0FBRyxDQUFDO01BQ2ZDLEtBQUssR0FBRyxJQUFJO0lBQ2QsQ0FBQyxNQUFNO01BQ0xELFdBQVcsR0FBR0UsSUFBSSxHQUFHTCxVQUFVO0lBQ2pDO0VBQ0Y7RUFFQSxNQUFNTSxJQUFJLEdBQUd0RyxZQUFZLENBQUN5RCxTQUFTLEVBQUUwQyxXQUFXLENBQUMsQ0FBQzNELEdBQUcsQ0FBQytELElBQUksSUFDeERILEtBQUssR0FBR0csSUFBSSxDQUFDQyxVQUFVLENBQUMvQyxTQUFTLENBQUNnRCxHQUFHLEVBQUUsR0FBRyxDQUFDLEdBQUdGLElBQ2hELENBQUM7RUFDRCxNQUFNRyxNQUFNLEdBQUdSLFVBQVUsR0FBRyxDQUFDQSxVQUFVLEVBQUUsR0FBR0ksSUFBSSxDQUFDLEdBQUdBLElBQUk7O0VBRXhEO0VBQ0E7RUFDQTtFQUNBO0VBQ0E7RUFDQSxNQUFNSyxZQUFZLEdBQ2hCLENBQUMsR0FBRyxDQUNGLGFBQWEsQ0FBQyxRQUFRLENBQ3RCLFVBQVUsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUNkLFVBQVUsQ0FBQyxRQUFRLENBQ25CLEtBQUssQ0FBQyxDQUFDbkIsUUFBUSxDQUFDO0FBRXRCLE1BQU0sQ0FBQ2tCLE1BQU0sQ0FBQ2xFLEdBQUcsQ0FBQyxDQUFDK0QsSUFBSSxFQUFFakUsQ0FBQyxLQUNsQixDQUFDLElBQUksQ0FBQyxHQUFHLENBQUMsQ0FBQ0EsQ0FBQyxDQUFDLENBQUMsS0FBSyxDQUFDLENBQUNBLENBQUMsS0FBSyxDQUFDLElBQUk0RCxVQUFVLEdBQUcsWUFBWSxHQUFHekUsS0FBSyxDQUFDO0FBQzFFLFVBQVUsQ0FBQzhFLElBQUk7QUFDZixRQUFRLEVBQUUsSUFBSSxDQUNQLENBQUM7QUFDUixNQUFNLENBQUMsSUFBSSxDQUNILE1BQU0sQ0FDTixJQUFJLENBQUMsQ0FBQ3BDLE9BQU8sQ0FBQyxDQUNkLFFBQVEsQ0FBQyxDQUFDLENBQUNBLE9BQU8sQ0FBQyxDQUNuQixLQUFLLENBQUMsQ0FBQ0EsT0FBTyxHQUFHMUMsS0FBSyxHQUFHYyxTQUFTLENBQUMsQ0FDbkMsT0FBTyxDQUFDLENBQUM0QixPQUFPLENBQUM7QUFFekIsUUFBUSxDQUFDQSxPQUFPLEdBQUcsSUFBSVYsU0FBUyxDQUFDRSxJQUFJLEdBQUcsR0FBR0YsU0FBUyxDQUFDRSxJQUFJO0FBQ3pELE1BQU0sRUFBRSxJQUFJO0FBQ1osSUFBSSxFQUFFLEdBQUcsQ0FDTjtFQUVELElBQUksQ0FBQ0csUUFBUSxFQUFFO0lBQ2IsT0FBTyxDQUFDLEdBQUcsQ0FBQyxRQUFRLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDNkMsWUFBWSxDQUFDLEVBQUUsR0FBRyxDQUFDO0VBQy9DOztFQUVBO0VBQ0E7RUFDQTtFQUNBO0VBQ0E7RUFDQSxJQUFJL0csa0JBQWtCLENBQUMsQ0FBQyxFQUFFO0lBQ3hCLE9BQU8sQ0FBQyxHQUFHLENBQUMsUUFBUSxDQUFDLENBQUMsQ0FBQyxDQUFDLENBQUMsQ0FBQytHLFlBQVksQ0FBQyxFQUFFLEdBQUcsQ0FBQztFQUMvQztFQUNBLE9BQ0UsQ0FBQyxHQUFHLENBQUMsYUFBYSxDQUFDLEtBQUssQ0FBQyxVQUFVLENBQUMsVUFBVSxDQUFDLFFBQVEsQ0FBQyxDQUFDLENBQUMsQ0FBQyxDQUFDLFVBQVUsQ0FBQyxDQUFDLENBQUMsQ0FBQztBQUM5RSxNQUFNLENBQUMsWUFBWSxDQUNYLElBQUksQ0FBQyxDQUFDN0MsUUFBUSxDQUFDLENBQ2YsS0FBSyxDQUFDLENBQUNyQyxLQUFLLENBQUMsQ0FDYixNQUFNLENBQUMsQ0FBQ0MsTUFBTSxDQUFDLENBQ2YsSUFBSSxDQUFDLE9BQU87QUFFcEIsTUFBTSxDQUFDaUYsWUFBWTtBQUNuQixJQUFJLEVBQUUsR0FBRyxDQUFDO0FBRVY7O0FBRUE7QUFDQTtBQUNBO0FBQ0E7QUFDQSxPQUFPLFNBQUFDLHdCQUFBO0VBQUEsTUFBQXJGLENBQUEsR0FBQUMsRUFBQTtFQUNMLE1BQUFzQyxRQUFBLEdBQWlCdEUsV0FBVyxDQUFDcUgsS0FBd0IsQ0FBQztFQUFBLElBQUF2RixFQUFBO0VBQUEsSUFBQUMsQ0FBQSxRQUFBdUMsUUFBQTtJQUNKeEMsRUFBQTtNQUFBaUQsSUFBQSxFQUMxQyxDQUFDO01BQUF1QyxXQUFBLEVBQ01oRDtJQUNmLENBQUM7SUFBQXZDLENBQUEsTUFBQXVDLFFBQUE7SUFBQXZDLENBQUEsTUFBQUQsRUFBQTtFQUFBO0lBQUFBLEVBQUEsR0FBQUMsQ0FBQTtFQUFBO0VBSEQsT0FBQU8sRUFBQSxFQUFBMEMsT0FBQSxJQUF5Q3JGLFFBQVEsQ0FBQ21DLEVBR2pELENBQUM7RUFISztJQUFBaUQsSUFBQTtJQUFBdUM7RUFBQSxJQUFBaEYsRUFBcUI7RUFTNUIsSUFBSWdDLFFBQVEsS0FBS2dELFdBQVc7SUFDMUJ0QyxPQUFPLENBQUM7TUFBQUQsSUFBQSxFQUFRLENBQUM7TUFBQXVDLFdBQUEsRUFBZWhEO0lBQVMsQ0FBQyxDQUFDO0VBQUE7RUFDNUMsSUFBQS9CLEVBQUE7RUFBQSxJQUFBQyxFQUFBO0VBQUEsSUFBQVQsQ0FBQSxRQUFBdUMsUUFBQTtJQUVTL0IsRUFBQSxHQUFBQSxDQUFBO01BQ1IsSUFBSSxDQUFDK0IsUUFBUTtRQUFBO01BQUE7TUFDYixNQUFBZSxLQUFBLEdBQWNDLFdBQVcsQ0FDdkJpQyxNQUE2QyxFQUM3QzVHLE9BQU8sRUFDUHFFLE9BQ0YsQ0FBQztNQUFBLE9BQ00sTUFBTVMsYUFBYSxDQUFDSixLQUFLLENBQUM7SUFBQSxDQUNsQztJQUFFN0MsRUFBQSxJQUFDOEIsUUFBUSxDQUFDO0lBQUF2QyxDQUFBLE1BQUF1QyxRQUFBO0lBQUF2QyxDQUFBLE1BQUFRLEVBQUE7SUFBQVIsQ0FBQSxNQUFBUyxFQUFBO0VBQUE7SUFBQUQsRUFBQSxHQUFBUixDQUFBO0lBQUFTLEVBQUEsR0FBQVQsQ0FBQTtFQUFBO0VBUmJ0QyxTQUFTLENBQUM4QyxFQVFULEVBQUVDLEVBQVUsQ0FBQztFQUVkLElBQUksQ0FBQ2xELE9BQU8sQ0FBQyxPQUFPLENBQWMsSUFBOUIsQ0FBc0JnRixRQUFRO0lBQUEsT0FBUyxJQUFJO0VBQUE7RUFDL0MsTUFBQUwsU0FBQSxHQUFrQjNELFlBQVksQ0FBQyxDQUFDO0VBQ2hDLElBQUksQ0FBQzJELFNBQTZDLElBQWhDOUQsZUFBZSxDQUFDLENBQUMsQ0FBQStELGNBQWU7SUFBQSxPQUFTLElBQUk7RUFBQTtFQU1uRCxNQUFBekIsRUFBQSxHQUFBc0MsSUFBSSxJQUFJbkUsV0FBVyxHQUFHQyxXQUFXO0VBQUEsSUFBQTZCLEVBQUE7RUFBQSxJQUFBWCxDQUFBLFFBQUF1QyxRQUFBLElBQUF2QyxDQUFBLFFBQUFVLEVBQUE7SUFIM0NDLEVBQUEsSUFBQyxZQUFZLENBQ0w0QixJQUFRLENBQVJBLFNBQU8sQ0FBQyxDQUNQLEtBQStCLENBQS9CLENBQUE1RCxhQUFhLENBQUN1RCxTQUFTLENBQUE4QixNQUFPLEVBQUMsQ0FDOUIsTUFBaUMsQ0FBakMsQ0FBQXRELEVBQWdDLENBQUMsQ0FDcEMsSUFBTSxDQUFOLE1BQU0sR0FDWDtJQUFBVixDQUFBLE1BQUF1QyxRQUFBO0lBQUF2QyxDQUFBLE1BQUFVLEVBQUE7SUFBQVYsQ0FBQSxNQUFBVyxFQUFBO0VBQUE7SUFBQUEsRUFBQSxHQUFBWCxDQUFBO0VBQUE7RUFBQSxPQUxGVyxFQUtFO0FBQUE7QUFuQ0MsU0FBQTZFLE9BQUFDLEdBQUE7RUFBQSxPQWtCTUEsR0FBRyxDQUFDQyxNQUFpQyxDQUFDO0FBQUE7QUFsQjVDLFNBQUFBLE9BQUFDLEdBQUE7RUFBQSxPQWtCZ0I7SUFBQSxHQUFLbkQsR0FBQztJQUFBUSxJQUFBLEVBQVFSLEdBQUMsQ0FBQVEsSUFBSyxHQUFHO0VBQUUsQ0FBQztBQUFBO0FBbEIxQyxTQUFBc0MsTUFBQTlDLENBQUE7RUFBQSxPQUM2QkEsQ0FBQyxDQUFBQyxpQkFBa0I7QUFBQSIsImlnbm9yZUxpc3QiOltdfQ==
|
src/buddy/companion.ts
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { getGlobalConfig } from '../utils/config.js'
|
| 2 |
+
import {
|
| 3 |
+
type Companion,
|
| 4 |
+
type CompanionBones,
|
| 5 |
+
EYES,
|
| 6 |
+
HATS,
|
| 7 |
+
RARITIES,
|
| 8 |
+
RARITY_WEIGHTS,
|
| 9 |
+
type Rarity,
|
| 10 |
+
SPECIES,
|
| 11 |
+
STAT_NAMES,
|
| 12 |
+
type StatName,
|
| 13 |
+
} from './types.js'
|
| 14 |
+
|
| 15 |
+
// Mulberry32 — tiny seeded PRNG, good enough for picking ducks
|
| 16 |
+
function mulberry32(seed: number): () => number {
|
| 17 |
+
let a = seed >>> 0
|
| 18 |
+
return function () {
|
| 19 |
+
a |= 0
|
| 20 |
+
a = (a + 0x6d2b79f5) | 0
|
| 21 |
+
let t = Math.imul(a ^ (a >>> 15), 1 | a)
|
| 22 |
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
|
| 23 |
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
| 24 |
+
}
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
function hashString(s: string): number {
|
| 28 |
+
if (typeof Bun !== 'undefined') {
|
| 29 |
+
return Number(BigInt(Bun.hash(s)) & 0xffffffffn)
|
| 30 |
+
}
|
| 31 |
+
let h = 2166136261
|
| 32 |
+
for (let i = 0; i < s.length; i++) {
|
| 33 |
+
h ^= s.charCodeAt(i)
|
| 34 |
+
h = Math.imul(h, 16777619)
|
| 35 |
+
}
|
| 36 |
+
return h >>> 0
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
function pick<T>(rng: () => number, arr: readonly T[]): T {
|
| 40 |
+
return arr[Math.floor(rng() * arr.length)]!
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
function rollRarity(rng: () => number): Rarity {
|
| 44 |
+
const total = Object.values(RARITY_WEIGHTS).reduce((a, b) => a + b, 0)
|
| 45 |
+
let roll = rng() * total
|
| 46 |
+
for (const rarity of RARITIES) {
|
| 47 |
+
roll -= RARITY_WEIGHTS[rarity]
|
| 48 |
+
if (roll < 0) return rarity
|
| 49 |
+
}
|
| 50 |
+
return 'common'
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
const RARITY_FLOOR: Record<Rarity, number> = {
|
| 54 |
+
common: 5,
|
| 55 |
+
uncommon: 15,
|
| 56 |
+
rare: 25,
|
| 57 |
+
epic: 35,
|
| 58 |
+
legendary: 50,
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
// One peak stat, one dump stat, rest scattered. Rarity bumps the floor.
|
| 62 |
+
function rollStats(
|
| 63 |
+
rng: () => number,
|
| 64 |
+
rarity: Rarity,
|
| 65 |
+
): Record<StatName, number> {
|
| 66 |
+
const floor = RARITY_FLOOR[rarity]
|
| 67 |
+
const peak = pick(rng, STAT_NAMES)
|
| 68 |
+
let dump = pick(rng, STAT_NAMES)
|
| 69 |
+
while (dump === peak) dump = pick(rng, STAT_NAMES)
|
| 70 |
+
|
| 71 |
+
const stats = {} as Record<StatName, number>
|
| 72 |
+
for (const name of STAT_NAMES) {
|
| 73 |
+
if (name === peak) {
|
| 74 |
+
stats[name] = Math.min(100, floor + 50 + Math.floor(rng() * 30))
|
| 75 |
+
} else if (name === dump) {
|
| 76 |
+
stats[name] = Math.max(1, floor - 10 + Math.floor(rng() * 15))
|
| 77 |
+
} else {
|
| 78 |
+
stats[name] = floor + Math.floor(rng() * 40)
|
| 79 |
+
}
|
| 80 |
+
}
|
| 81 |
+
return stats
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
const SALT = 'friend-2026-401'
|
| 85 |
+
|
| 86 |
+
export type Roll = {
|
| 87 |
+
bones: CompanionBones
|
| 88 |
+
inspirationSeed: number
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
function rollFrom(rng: () => number): Roll {
|
| 92 |
+
const rarity = rollRarity(rng)
|
| 93 |
+
const bones: CompanionBones = {
|
| 94 |
+
rarity,
|
| 95 |
+
species: pick(rng, SPECIES),
|
| 96 |
+
eye: pick(rng, EYES),
|
| 97 |
+
hat: rarity === 'common' ? 'none' : pick(rng, HATS),
|
| 98 |
+
shiny: rng() < 0.01,
|
| 99 |
+
stats: rollStats(rng, rarity),
|
| 100 |
+
}
|
| 101 |
+
return { bones, inspirationSeed: Math.floor(rng() * 1e9) }
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
// Called from three hot paths (500ms sprite tick, per-keystroke PromptInput,
|
| 105 |
+
// per-turn observer) with the same userId → cache the deterministic result.
|
| 106 |
+
let rollCache: { key: string; value: Roll } | undefined
|
| 107 |
+
export function roll(userId: string): Roll {
|
| 108 |
+
const key = userId + SALT
|
| 109 |
+
if (rollCache?.key === key) return rollCache.value
|
| 110 |
+
const value = rollFrom(mulberry32(hashString(key)))
|
| 111 |
+
rollCache = { key, value }
|
| 112 |
+
return value
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
export function rollWithSeed(seed: string): Roll {
|
| 116 |
+
return rollFrom(mulberry32(hashString(seed)))
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
export function companionUserId(): string {
|
| 120 |
+
const config = getGlobalConfig()
|
| 121 |
+
return config.oauthAccount?.accountUuid ?? config.userID ?? 'anon'
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
// Regenerate bones from userId, merge with stored soul. Bones never persist
|
| 125 |
+
// so species renames and SPECIES-array edits can't break stored companions,
|
| 126 |
+
// and editing config.companion can't fake a rarity.
|
| 127 |
+
export function getCompanion(): Companion | undefined {
|
| 128 |
+
const stored = getGlobalConfig().companion
|
| 129 |
+
if (!stored) return undefined
|
| 130 |
+
const { bones } = roll(companionUserId())
|
| 131 |
+
// bones last so stale bones fields in old-format configs get overridden
|
| 132 |
+
return { ...stored, ...bones }
|
| 133 |
+
}
|