/ bridge / initReplBridge.ts
initReplBridge.ts
  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  }