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.
- package/assistant/sessionHistory.ts +87 -0
- package/bootstrap/state.ts +1769 -0
- package/bridge/bridgeApi.ts +539 -0
- package/bridge/bridgeConfig.ts +48 -0
- package/bridge/bridgeDebug.ts +135 -0
- package/bridge/bridgeEnabled.ts +202 -0
- package/bridge/bridgeMain.ts +2999 -0
- package/bridge/bridgeMessaging.ts +461 -0
- package/bridge/bridgePermissionCallbacks.ts +43 -0
- package/bridge/bridgePointer.ts +210 -0
- package/bridge/bridgeStatusUtil.ts +163 -0
- package/bridge/bridgeUI.ts +530 -0
- package/bridge/capacityWake.ts +56 -0
- package/bridge/codeSessionApi.ts +168 -0
- package/bridge/createSession.ts +384 -0
- package/bridge/debugUtils.ts +141 -0
- package/bridge/envLessBridgeConfig.ts +165 -0
- package/bridge/flushGate.ts +71 -0
- package/bridge/inboundAttachments.ts +175 -0
- package/bridge/inboundMessages.ts +80 -0
- package/bridge/initReplBridge.ts +569 -0
- package/bridge/jwtUtils.ts +256 -0
- package/bridge/pollConfig.ts +110 -0
- package/bridge/pollConfigDefaults.ts +82 -0
- package/bridge/remoteBridgeCore.ts +1008 -0
- package/bridge/replBridge.ts +2406 -0
- package/bridge/replBridgeHandle.ts +36 -0
- package/bridge/replBridgeTransport.ts +370 -0
- package/bridge/sessionIdCompat.ts +57 -0
- package/bridge/sessionRunner.ts +550 -0
- package/bridge/trustedDevice.ts +210 -0
- package/bridge/types.ts +262 -0
- package/bridge/workSecret.ts +127 -0
- package/buddy/CompanionSprite.tsx +371 -0
- package/buddy/companion.ts +133 -0
- package/buddy/prompt.ts +36 -0
- package/buddy/sprites.ts +514 -0
- package/buddy/types.ts +148 -0
- package/buddy/useBuddyNotification.tsx +98 -0
- package/coordinator/coordinatorMode.ts +369 -0
- package/memdir/findRelevantMemories.ts +141 -0
- package/memdir/memdir.ts +507 -0
- package/memdir/memoryAge.ts +53 -0
- package/memdir/memoryScan.ts +94 -0
- package/memdir/memoryTypes.ts +271 -0
- package/memdir/paths.ts +278 -0
- package/memdir/teamMemPaths.ts +292 -0
- package/memdir/teamMemPrompts.ts +100 -0
- package/migrations/migrateAutoUpdatesToSettings.ts +61 -0
- package/migrations/migrateBypassPermissionsAcceptedToSettings.ts +40 -0
- package/migrations/migrateEnableAllProjectMcpServersToSettings.ts +118 -0
- package/migrations/migrateFennecToOpus.ts +45 -0
- package/migrations/migrateLegacyOpusToCurrent.ts +57 -0
- package/migrations/migrateOpusToOpus1m.ts +43 -0
- package/migrations/migrateReplBridgeEnabledToRemoteControlAtStartup.ts +22 -0
- package/migrations/migrateSonnet1mToSonnet45.ts +48 -0
- package/migrations/migrateSonnet45ToSonnet46.ts +67 -0
- package/migrations/resetAutoModeOptInForDefaultOffer.ts +51 -0
- package/migrations/resetProToOpusDefault.ts +51 -0
- package/native-ts/color-diff/index.ts +999 -0
- package/native-ts/file-index/index.ts +370 -0
- package/native-ts/yoga-layout/enums.ts +134 -0
- package/native-ts/yoga-layout/index.ts +2578 -0
- package/outputStyles/loadOutputStylesDir.ts +98 -0
- package/package.json +22 -5
- package/plugins/builtinPlugins.ts +159 -0
- package/plugins/bundled/index.ts +23 -0
- package/schemas/hooks.ts +222 -0
- package/screens/Doctor.tsx +575 -0
- package/screens/REPL.tsx +5006 -0
- package/screens/ResumeConversation.tsx +399 -0
- package/server/createDirectConnectSession.ts +88 -0
- package/server/directConnectManager.ts +213 -0
- package/server/types.ts +57 -0
- package/skills/bundled/batch.ts +124 -0
- package/skills/bundled/claudeApi.ts +196 -0
- package/skills/bundled/claudeApiContent.ts +75 -0
- package/skills/bundled/claudeInChrome.ts +34 -0
- package/skills/bundled/debug.ts +103 -0
- package/skills/bundled/index.ts +79 -0
- package/skills/bundled/keybindings.ts +339 -0
- package/skills/bundled/loop.ts +92 -0
- package/skills/bundled/loremIpsum.ts +282 -0
- package/skills/bundled/remember.ts +82 -0
- package/skills/bundled/scheduleRemoteAgents.ts +447 -0
- package/skills/bundled/simplify.ts +69 -0
- package/skills/bundled/skillify.ts +197 -0
- package/skills/bundled/stuck.ts +79 -0
- package/skills/bundled/updateConfig.ts +475 -0
- package/skills/bundled/verify/SKILL.md +3 -0
- package/skills/bundled/verify/examples/cli.md +3 -0
- package/skills/bundled/verify/examples/server.md +3 -0
- package/skills/bundled/verify.ts +30 -0
- package/skills/bundled/verifyContent.ts +13 -0
- package/skills/bundledSkills.ts +220 -0
- package/skills/loadSkillsDir.ts +1086 -0
- package/skills/mcpSkillBuilders.ts +44 -0
- package/tasks/DreamTask/DreamTask.ts +157 -0
- package/tasks/InProcessTeammateTask/InProcessTeammateTask.tsx +126 -0
- package/tasks/InProcessTeammateTask/types.ts +121 -0
- package/tasks/LocalAgentTask/LocalAgentTask.tsx +683 -0
- package/tasks/LocalMainSessionTask.ts +479 -0
- package/tasks/LocalShellTask/LocalShellTask.tsx +523 -0
- package/tasks/LocalShellTask/guards.ts +41 -0
- package/tasks/LocalShellTask/killShellTasks.ts +76 -0
- package/tasks/RemoteAgentTask/RemoteAgentTask.tsx +856 -0
- package/tasks/pillLabel.ts +82 -0
- package/tasks/stopTask.ts +100 -0
- package/tasks/types.ts +46 -0
- package/upstreamproxy/relay.ts +455 -0
- package/upstreamproxy/upstreamproxy.ts +285 -0
- package/vim/motions.ts +82 -0
- package/vim/operators.ts +556 -0
- package/vim/textObjects.ts +186 -0
- package/vim/transitions.ts +490 -0
- package/vim/types.ts +199 -0
- 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
|
+
}
|
package/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
|
+
}
|
|
@@ -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
|
+
}
|