yzcode-cli 1.0.1 → 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/assistant/sessionHistory.ts +87 -0
  2. package/bootstrap/state.ts +1769 -0
  3. package/bridge/bridgeApi.ts +539 -0
  4. package/bridge/bridgeConfig.ts +48 -0
  5. package/bridge/bridgeDebug.ts +135 -0
  6. package/bridge/bridgeEnabled.ts +202 -0
  7. package/bridge/bridgeMain.ts +2999 -0
  8. package/bridge/bridgeMessaging.ts +461 -0
  9. package/bridge/bridgePermissionCallbacks.ts +43 -0
  10. package/bridge/bridgePointer.ts +210 -0
  11. package/bridge/bridgeStatusUtil.ts +163 -0
  12. package/bridge/bridgeUI.ts +530 -0
  13. package/bridge/capacityWake.ts +56 -0
  14. package/bridge/codeSessionApi.ts +168 -0
  15. package/bridge/createSession.ts +384 -0
  16. package/bridge/debugUtils.ts +141 -0
  17. package/bridge/envLessBridgeConfig.ts +165 -0
  18. package/bridge/flushGate.ts +71 -0
  19. package/bridge/inboundAttachments.ts +175 -0
  20. package/bridge/inboundMessages.ts +80 -0
  21. package/bridge/initReplBridge.ts +569 -0
  22. package/bridge/jwtUtils.ts +256 -0
  23. package/bridge/pollConfig.ts +110 -0
  24. package/bridge/pollConfigDefaults.ts +82 -0
  25. package/bridge/remoteBridgeCore.ts +1008 -0
  26. package/bridge/replBridge.ts +2406 -0
  27. package/bridge/replBridgeHandle.ts +36 -0
  28. package/bridge/replBridgeTransport.ts +370 -0
  29. package/bridge/sessionIdCompat.ts +57 -0
  30. package/bridge/sessionRunner.ts +550 -0
  31. package/bridge/trustedDevice.ts +210 -0
  32. package/bridge/types.ts +262 -0
  33. package/bridge/workSecret.ts +127 -0
  34. package/buddy/CompanionSprite.tsx +371 -0
  35. package/buddy/companion.ts +133 -0
  36. package/buddy/prompt.ts +36 -0
  37. package/buddy/sprites.ts +514 -0
  38. package/buddy/types.ts +148 -0
  39. package/buddy/useBuddyNotification.tsx +98 -0
  40. package/coordinator/coordinatorMode.ts +369 -0
  41. package/memdir/findRelevantMemories.ts +141 -0
  42. package/memdir/memdir.ts +507 -0
  43. package/memdir/memoryAge.ts +53 -0
  44. package/memdir/memoryScan.ts +94 -0
  45. package/memdir/memoryTypes.ts +271 -0
  46. package/memdir/paths.ts +278 -0
  47. package/memdir/teamMemPaths.ts +292 -0
  48. package/memdir/teamMemPrompts.ts +100 -0
  49. package/migrations/migrateAutoUpdatesToSettings.ts +61 -0
  50. package/migrations/migrateBypassPermissionsAcceptedToSettings.ts +40 -0
  51. package/migrations/migrateEnableAllProjectMcpServersToSettings.ts +118 -0
  52. package/migrations/migrateFennecToOpus.ts +45 -0
  53. package/migrations/migrateLegacyOpusToCurrent.ts +57 -0
  54. package/migrations/migrateOpusToOpus1m.ts +43 -0
  55. package/migrations/migrateReplBridgeEnabledToRemoteControlAtStartup.ts +22 -0
  56. package/migrations/migrateSonnet1mToSonnet45.ts +48 -0
  57. package/migrations/migrateSonnet45ToSonnet46.ts +67 -0
  58. package/migrations/resetAutoModeOptInForDefaultOffer.ts +51 -0
  59. package/migrations/resetProToOpusDefault.ts +51 -0
  60. package/native-ts/color-diff/index.ts +999 -0
  61. package/native-ts/file-index/index.ts +370 -0
  62. package/native-ts/yoga-layout/enums.ts +134 -0
  63. package/native-ts/yoga-layout/index.ts +2578 -0
  64. package/outputStyles/loadOutputStylesDir.ts +98 -0
  65. package/package.json +22 -5
  66. package/plugins/builtinPlugins.ts +159 -0
  67. package/plugins/bundled/index.ts +23 -0
  68. package/schemas/hooks.ts +222 -0
  69. package/screens/Doctor.tsx +575 -0
  70. package/screens/REPL.tsx +5006 -0
  71. package/screens/ResumeConversation.tsx +399 -0
  72. package/server/createDirectConnectSession.ts +88 -0
  73. package/server/directConnectManager.ts +213 -0
  74. package/server/types.ts +57 -0
  75. package/skills/bundled/batch.ts +124 -0
  76. package/skills/bundled/claudeApi.ts +196 -0
  77. package/skills/bundled/claudeApiContent.ts +75 -0
  78. package/skills/bundled/claudeInChrome.ts +34 -0
  79. package/skills/bundled/debug.ts +103 -0
  80. package/skills/bundled/index.ts +79 -0
  81. package/skills/bundled/keybindings.ts +339 -0
  82. package/skills/bundled/loop.ts +92 -0
  83. package/skills/bundled/loremIpsum.ts +282 -0
  84. package/skills/bundled/remember.ts +82 -0
  85. package/skills/bundled/scheduleRemoteAgents.ts +447 -0
  86. package/skills/bundled/simplify.ts +69 -0
  87. package/skills/bundled/skillify.ts +197 -0
  88. package/skills/bundled/stuck.ts +79 -0
  89. package/skills/bundled/updateConfig.ts +475 -0
  90. package/skills/bundled/verify/SKILL.md +3 -0
  91. package/skills/bundled/verify/examples/cli.md +3 -0
  92. package/skills/bundled/verify/examples/server.md +3 -0
  93. package/skills/bundled/verify.ts +30 -0
  94. package/skills/bundled/verifyContent.ts +13 -0
  95. package/skills/bundledSkills.ts +220 -0
  96. package/skills/loadSkillsDir.ts +1086 -0
  97. package/skills/mcpSkillBuilders.ts +44 -0
  98. package/tasks/DreamTask/DreamTask.ts +157 -0
  99. package/tasks/InProcessTeammateTask/InProcessTeammateTask.tsx +126 -0
  100. package/tasks/InProcessTeammateTask/types.ts +121 -0
  101. package/tasks/LocalAgentTask/LocalAgentTask.tsx +683 -0
  102. package/tasks/LocalMainSessionTask.ts +479 -0
  103. package/tasks/LocalShellTask/LocalShellTask.tsx +523 -0
  104. package/tasks/LocalShellTask/guards.ts +41 -0
  105. package/tasks/LocalShellTask/killShellTasks.ts +76 -0
  106. package/tasks/RemoteAgentTask/RemoteAgentTask.tsx +856 -0
  107. package/tasks/pillLabel.ts +82 -0
  108. package/tasks/stopTask.ts +100 -0
  109. package/tasks/types.ts +46 -0
  110. package/upstreamproxy/relay.ts +455 -0
  111. package/upstreamproxy/upstreamproxy.ts +285 -0
  112. package/vim/motions.ts +82 -0
  113. package/vim/operators.ts +556 -0
  114. package/vim/textObjects.ts +186 -0
  115. package/vim/transitions.ts +490 -0
  116. package/vim/types.ts +199 -0
  117. package/voice/voiceModeEnabled.ts +54 -0
@@ -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
+ }
@@ -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
+ }
@@ -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
+ }