yzcode-cli 1.0.2 → 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 +19 -2
- 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,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
|
+
}
|