source dump of claude code
at main 569 lines 24 kB view raw
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 16import { feature } from 'bun:bundle' 17import { hostname } from 'os' 18import { getOriginalCwd, getSessionId } from '../bootstrap/state.js' 19import type { SDKMessage } from '../entrypoints/agentSdkTypes.js' 20import type { SDKControlResponse } from '../entrypoints/sdk/controlTypes.js' 21import { getFeatureValue_CACHED_WITH_REFRESH } from '../services/analytics/growthbook.js' 22import { getOrganizationUUID } from '../services/oauth/client.js' 23import { 24 isPolicyAllowed, 25 waitForPolicyLimitsToLoad, 26} from '../services/policyLimits/index.js' 27import type { Message } from '../types/message.js' 28import { 29 checkAndRefreshOAuthTokenIfNeeded, 30 getClaudeAIOAuthTokens, 31 handleOAuth401Error, 32} from '../utils/auth.js' 33import { getGlobalConfig, saveGlobalConfig } from '../utils/config.js' 34import { logForDebugging } from '../utils/debug.js' 35import { stripDisplayTagsAllowEmpty } from '../utils/displayTags.js' 36import { errorMessage } from '../utils/errors.js' 37import { getBranch, getRemoteUrl } from '../utils/git.js' 38import { toSDKMessages } from '../utils/messages/mappers.js' 39import { 40 getContentText, 41 getMessagesAfterCompactBoundary, 42 isSyntheticMessage, 43} from '../utils/messages.js' 44import type { PermissionMode } from '../utils/permissions/PermissionMode.js' 45import { getCurrentSessionTitle } from '../utils/sessionStorage.js' 46import { 47 extractConversationText, 48 generateSessionTitle, 49} from '../utils/sessionTitle.js' 50import { generateShortWordSlug } from '../utils/words.js' 51import { 52 getBridgeAccessToken, 53 getBridgeBaseUrl, 54 getBridgeTokenOverride, 55} from './bridgeConfig.js' 56import { 57 checkBridgeMinVersion, 58 isBridgeEnabledBlocking, 59 isCseShimEnabled, 60 isEnvLessBridgeEnabled, 61} from './bridgeEnabled.js' 62import { 63 archiveBridgeSession, 64 createBridgeSession, 65 updateBridgeSessionTitle, 66} from './createSession.js' 67import { logBridgeSkip } from './debugUtils.js' 68import { checkEnvLessBridgeMinVersion } from './envLessBridgeConfig.js' 69import { getPollIntervalConfig } from './pollConfig.js' 70import type { BridgeState, ReplBridgeHandle } from './replBridge.js' 71import { initBridgeCore } from './replBridge.js' 72import { setCseShimGate } from './sessionIdCompat.js' 73import type { BridgeWorkerType } from './types.js' 74 75export 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 110export 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 547const 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 */ 555function 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}