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.
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 +19 -2
  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,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
+ }