yzcode-cli 1.0.1 → 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 +22 -5
  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,530 @@
1
+ import chalk from 'chalk'
2
+ import { toString as qrToString } from 'qrcode'
3
+ import {
4
+ BRIDGE_FAILED_INDICATOR,
5
+ BRIDGE_READY_INDICATOR,
6
+ BRIDGE_SPINNER_FRAMES,
7
+ } from '../constants/figures.js'
8
+ import { stringWidth } from '../ink/stringWidth.js'
9
+ import { logForDebugging } from '../utils/debug.js'
10
+ import {
11
+ buildActiveFooterText,
12
+ buildBridgeConnectUrl,
13
+ buildBridgeSessionUrl,
14
+ buildIdleFooterText,
15
+ FAILED_FOOTER_TEXT,
16
+ formatDuration,
17
+ type StatusState,
18
+ TOOL_DISPLAY_EXPIRY_MS,
19
+ timestamp,
20
+ truncatePrompt,
21
+ wrapWithOsc8Link,
22
+ } from './bridgeStatusUtil.js'
23
+ import type {
24
+ BridgeConfig,
25
+ BridgeLogger,
26
+ SessionActivity,
27
+ SpawnMode,
28
+ } from './types.js'
29
+
30
+ const QR_OPTIONS = {
31
+ type: 'utf8' as const,
32
+ errorCorrectionLevel: 'L' as const,
33
+ small: true,
34
+ }
35
+
36
+ /** Generate a QR code and return its lines. */
37
+ async function generateQr(url: string): Promise<string[]> {
38
+ const qr = await qrToString(url, QR_OPTIONS)
39
+ return qr.split('\n').filter((line: string) => line.length > 0)
40
+ }
41
+
42
+ export function createBridgeLogger(options: {
43
+ verbose: boolean
44
+ write?: (s: string) => void
45
+ }): BridgeLogger {
46
+ const write = options.write ?? ((s: string) => process.stdout.write(s))
47
+ const verbose = options.verbose
48
+
49
+ // Track how many status lines are currently displayed at the bottom
50
+ let statusLineCount = 0
51
+
52
+ // Status state machine
53
+ let currentState: StatusState = 'idle'
54
+ let currentStateText = 'Ready'
55
+ let repoName = ''
56
+ let branch = ''
57
+ let debugLogPath = ''
58
+
59
+ // Connect URL (built in printBanner with correct base for staging/prod)
60
+ let connectUrl = ''
61
+ let cachedIngressUrl = ''
62
+ let cachedEnvironmentId = ''
63
+ let activeSessionUrl: string | null = null
64
+
65
+ // QR code lines for the current URL
66
+ let qrLines: string[] = []
67
+ let qrVisible = false
68
+
69
+ // Tool activity for the second status line
70
+ let lastToolSummary: string | null = null
71
+ let lastToolTime = 0
72
+
73
+ // Session count indicator (shown when multi-session mode is enabled)
74
+ let sessionActive = 0
75
+ let sessionMax = 1
76
+ // Spawn mode shown in the session-count line + gates the `w` hint
77
+ let spawnModeDisplay: 'same-dir' | 'worktree' | null = null
78
+ let spawnMode: SpawnMode = 'single-session'
79
+
80
+ // Per-session display info for the multi-session bullet list (keyed by compat sessionId)
81
+ const sessionDisplayInfo = new Map<
82
+ string,
83
+ { title?: string; url: string; activity?: SessionActivity }
84
+ >()
85
+
86
+ // Connecting spinner state
87
+ let connectingTimer: ReturnType<typeof setInterval> | null = null
88
+ let connectingTick = 0
89
+
90
+ /**
91
+ * Count how many visual terminal rows a string occupies, accounting for
92
+ * line wrapping. Each `\n` is one row, and content wider than the terminal
93
+ * wraps to additional rows.
94
+ */
95
+ function countVisualLines(text: string): number {
96
+ // eslint-disable-next-line custom-rules/prefer-use-terminal-size
97
+ const cols = process.stdout.columns || 80 // non-React CLI context
98
+ let count = 0
99
+ // Split on newlines to get logical lines
100
+ for (const logical of text.split('\n')) {
101
+ if (logical.length === 0) {
102
+ // Empty segment between consecutive \n — counts as 1 row
103
+ count++
104
+ continue
105
+ }
106
+ const width = stringWidth(logical)
107
+ count += Math.max(1, Math.ceil(width / cols))
108
+ }
109
+ // The trailing \n in "line\n" produces an empty last element — don't count it
110
+ // because the cursor sits at the start of the next line, not a new visual row.
111
+ if (text.endsWith('\n')) {
112
+ count--
113
+ }
114
+ return count
115
+ }
116
+
117
+ /** Write a status line and track its visual line count. */
118
+ function writeStatus(text: string): void {
119
+ write(text)
120
+ statusLineCount += countVisualLines(text)
121
+ }
122
+
123
+ /** Clear any currently displayed status lines. */
124
+ function clearStatusLines(): void {
125
+ if (statusLineCount <= 0) return
126
+ logForDebugging(`[bridge:ui] clearStatusLines count=${statusLineCount}`)
127
+ // Move cursor up to the start of the status block, then erase everything below
128
+ write(`\x1b[${statusLineCount}A`) // cursor up N lines
129
+ write('\x1b[J') // erase from cursor to end of screen
130
+ statusLineCount = 0
131
+ }
132
+
133
+ /** Print a permanent log line, clearing status first and restoring after. */
134
+ function printLog(line: string): void {
135
+ clearStatusLines()
136
+ write(line)
137
+ }
138
+
139
+ /** Regenerate the QR code with the given URL. */
140
+ function regenerateQr(url: string): void {
141
+ generateQr(url)
142
+ .then(lines => {
143
+ qrLines = lines
144
+ renderStatusLine()
145
+ })
146
+ .catch(e => {
147
+ logForDebugging(`QR code generation failed: ${e}`, { level: 'error' })
148
+ })
149
+ }
150
+
151
+ /** Render the connecting spinner line (shown before first updateIdleStatus). */
152
+ function renderConnectingLine(): void {
153
+ clearStatusLines()
154
+
155
+ const frame =
156
+ BRIDGE_SPINNER_FRAMES[connectingTick % BRIDGE_SPINNER_FRAMES.length]!
157
+ let suffix = ''
158
+ if (repoName) {
159
+ suffix += chalk.dim(' \u00b7 ') + chalk.dim(repoName)
160
+ }
161
+ if (branch) {
162
+ suffix += chalk.dim(' \u00b7 ') + chalk.dim(branch)
163
+ }
164
+ writeStatus(
165
+ `${chalk.yellow(frame)} ${chalk.yellow('Connecting')}${suffix}\n`,
166
+ )
167
+ }
168
+
169
+ /** Start the connecting spinner. Stopped by first updateIdleStatus(). */
170
+ function startConnecting(): void {
171
+ stopConnecting()
172
+ renderConnectingLine()
173
+ connectingTimer = setInterval(() => {
174
+ connectingTick++
175
+ renderConnectingLine()
176
+ }, 150)
177
+ }
178
+
179
+ /** Stop the connecting spinner. */
180
+ function stopConnecting(): void {
181
+ if (connectingTimer) {
182
+ clearInterval(connectingTimer)
183
+ connectingTimer = null
184
+ }
185
+ }
186
+
187
+ /** Render and write the current status lines based on state. */
188
+ function renderStatusLine(): void {
189
+ if (currentState === 'reconnecting' || currentState === 'failed') {
190
+ // These states are handled separately (updateReconnectingStatus /
191
+ // updateFailedStatus). Return before clearing so callers like toggleQr
192
+ // and setSpawnModeDisplay don't blank the display during these states.
193
+ return
194
+ }
195
+
196
+ clearStatusLines()
197
+
198
+ const isIdle = currentState === 'idle'
199
+
200
+ // QR code above the status line
201
+ if (qrVisible) {
202
+ for (const line of qrLines) {
203
+ writeStatus(`${chalk.dim(line)}\n`)
204
+ }
205
+ }
206
+
207
+ // Determine indicator and colors based on state
208
+ const indicator = BRIDGE_READY_INDICATOR
209
+ const indicatorColor = isIdle ? chalk.green : chalk.cyan
210
+ const baseColor = isIdle ? chalk.green : chalk.cyan
211
+ const stateText = baseColor(currentStateText)
212
+
213
+ // Build the suffix with repo and branch
214
+ let suffix = ''
215
+ if (repoName) {
216
+ suffix += chalk.dim(' \u00b7 ') + chalk.dim(repoName)
217
+ }
218
+ // In worktree mode each session gets its own branch, so showing the
219
+ // bridge's branch would be misleading.
220
+ if (branch && spawnMode !== 'worktree') {
221
+ suffix += chalk.dim(' \u00b7 ') + chalk.dim(branch)
222
+ }
223
+
224
+ if (process.env.USER_TYPE === 'ant' && debugLogPath) {
225
+ writeStatus(
226
+ `${chalk.yellow('[ANT-ONLY] Logs:')} ${chalk.dim(debugLogPath)}\n`,
227
+ )
228
+ }
229
+ writeStatus(`${indicatorColor(indicator)} ${stateText}${suffix}\n`)
230
+
231
+ // Session count and per-session list (multi-session mode only)
232
+ if (sessionMax > 1) {
233
+ const modeHint =
234
+ spawnMode === 'worktree'
235
+ ? 'New sessions will be created in an isolated worktree'
236
+ : 'New sessions will be created in the current directory'
237
+ writeStatus(
238
+ ` ${chalk.dim(`Capacity: ${sessionActive}/${sessionMax} \u00b7 ${modeHint}`)}\n`,
239
+ )
240
+ for (const [, info] of sessionDisplayInfo) {
241
+ const titleText = info.title
242
+ ? truncatePrompt(info.title, 35)
243
+ : chalk.dim('Attached')
244
+ const titleLinked = wrapWithOsc8Link(titleText, info.url)
245
+ const act = info.activity
246
+ const showAct = act && act.type !== 'result' && act.type !== 'error'
247
+ const actText = showAct
248
+ ? chalk.dim(` ${truncatePrompt(act.summary, 40)}`)
249
+ : ''
250
+ writeStatus(` ${titleLinked}${actText}
251
+ `)
252
+ }
253
+ }
254
+
255
+ // Mode line for spawn modes with a single slot (or true single-session mode)
256
+ if (sessionMax === 1) {
257
+ const modeText =
258
+ spawnMode === 'single-session'
259
+ ? 'Single session \u00b7 exits when complete'
260
+ : spawnMode === 'worktree'
261
+ ? `Capacity: ${sessionActive}/1 \u00b7 New sessions will be created in an isolated worktree`
262
+ : `Capacity: ${sessionActive}/1 \u00b7 New sessions will be created in the current directory`
263
+ writeStatus(` ${chalk.dim(modeText)}\n`)
264
+ }
265
+
266
+ // Tool activity line for single-session mode
267
+ if (
268
+ sessionMax === 1 &&
269
+ !isIdle &&
270
+ lastToolSummary &&
271
+ Date.now() - lastToolTime < TOOL_DISPLAY_EXPIRY_MS
272
+ ) {
273
+ writeStatus(` ${chalk.dim(truncatePrompt(lastToolSummary, 60))}\n`)
274
+ }
275
+
276
+ // Blank line separator before footer
277
+ const url = activeSessionUrl ?? connectUrl
278
+ if (url) {
279
+ writeStatus('\n')
280
+ const footerText = isIdle
281
+ ? buildIdleFooterText(url)
282
+ : buildActiveFooterText(url)
283
+ const qrHint = qrVisible
284
+ ? chalk.dim.italic('space to hide QR code')
285
+ : chalk.dim.italic('space to show QR code')
286
+ const toggleHint = spawnModeDisplay
287
+ ? chalk.dim.italic(' \u00b7 w to toggle spawn mode')
288
+ : ''
289
+ writeStatus(`${chalk.dim(footerText)}\n`)
290
+ writeStatus(`${qrHint}${toggleHint}\n`)
291
+ }
292
+ }
293
+
294
+ return {
295
+ printBanner(config: BridgeConfig, environmentId: string): void {
296
+ cachedIngressUrl = config.sessionIngressUrl
297
+ cachedEnvironmentId = environmentId
298
+ connectUrl = buildBridgeConnectUrl(environmentId, cachedIngressUrl)
299
+ regenerateQr(connectUrl)
300
+
301
+ if (verbose) {
302
+ write(chalk.dim(`Remote Control`) + ` v${MACRO.VERSION}\n`)
303
+ }
304
+ if (verbose) {
305
+ if (config.spawnMode !== 'single-session') {
306
+ write(chalk.dim(`Spawn mode: `) + `${config.spawnMode}\n`)
307
+ write(
308
+ chalk.dim(`Max concurrent sessions: `) + `${config.maxSessions}\n`,
309
+ )
310
+ }
311
+ write(chalk.dim(`Environment ID: `) + `${environmentId}\n`)
312
+ }
313
+ if (config.sandbox) {
314
+ write(chalk.dim(`Sandbox: `) + `${chalk.green('Enabled')}\n`)
315
+ }
316
+ write('\n')
317
+
318
+ // Start connecting spinner — first updateIdleStatus() will stop it
319
+ startConnecting()
320
+ },
321
+
322
+ logSessionStart(sessionId: string, prompt: string): void {
323
+ if (verbose) {
324
+ const short = truncatePrompt(prompt, 80)
325
+ printLog(
326
+ chalk.dim(`[${timestamp()}]`) +
327
+ ` Session started: ${chalk.white(`"${short}"`)} (${chalk.dim(sessionId)})\n`,
328
+ )
329
+ }
330
+ },
331
+
332
+ logSessionComplete(sessionId: string, durationMs: number): void {
333
+ printLog(
334
+ chalk.dim(`[${timestamp()}]`) +
335
+ ` Session ${chalk.green('completed')} (${formatDuration(durationMs)}) ${chalk.dim(sessionId)}\n`,
336
+ )
337
+ },
338
+
339
+ logSessionFailed(sessionId: string, error: string): void {
340
+ printLog(
341
+ chalk.dim(`[${timestamp()}]`) +
342
+ ` Session ${chalk.red('failed')}: ${error} ${chalk.dim(sessionId)}\n`,
343
+ )
344
+ },
345
+
346
+ logStatus(message: string): void {
347
+ printLog(chalk.dim(`[${timestamp()}]`) + ` ${message}\n`)
348
+ },
349
+
350
+ logVerbose(message: string): void {
351
+ if (verbose) {
352
+ printLog(chalk.dim(`[${timestamp()}] ${message}`) + '\n')
353
+ }
354
+ },
355
+
356
+ logError(message: string): void {
357
+ printLog(chalk.red(`[${timestamp()}] Error: ${message}`) + '\n')
358
+ },
359
+
360
+ logReconnected(disconnectedMs: number): void {
361
+ printLog(
362
+ chalk.dim(`[${timestamp()}]`) +
363
+ ` ${chalk.green('Reconnected')} after ${formatDuration(disconnectedMs)}\n`,
364
+ )
365
+ },
366
+
367
+ setRepoInfo(repo: string, branchName: string): void {
368
+ repoName = repo
369
+ branch = branchName
370
+ },
371
+
372
+ setDebugLogPath(path: string): void {
373
+ debugLogPath = path
374
+ },
375
+
376
+ updateIdleStatus(): void {
377
+ stopConnecting()
378
+
379
+ currentState = 'idle'
380
+ currentStateText = 'Ready'
381
+ lastToolSummary = null
382
+ lastToolTime = 0
383
+ activeSessionUrl = null
384
+ regenerateQr(connectUrl)
385
+ renderStatusLine()
386
+ },
387
+
388
+ setAttached(sessionId: string): void {
389
+ stopConnecting()
390
+ currentState = 'attached'
391
+ currentStateText = 'Connected'
392
+ lastToolSummary = null
393
+ lastToolTime = 0
394
+ // Multi-session: keep footer/QR on the environment connect URL so users
395
+ // can spawn more sessions. Per-session links are in the bullet list.
396
+ if (sessionMax <= 1) {
397
+ activeSessionUrl = buildBridgeSessionUrl(
398
+ sessionId,
399
+ cachedEnvironmentId,
400
+ cachedIngressUrl,
401
+ )
402
+ regenerateQr(activeSessionUrl)
403
+ }
404
+ renderStatusLine()
405
+ },
406
+
407
+ updateReconnectingStatus(delayStr: string, elapsedStr: string): void {
408
+ stopConnecting()
409
+ clearStatusLines()
410
+ currentState = 'reconnecting'
411
+
412
+ // QR code above the status line
413
+ if (qrVisible) {
414
+ for (const line of qrLines) {
415
+ writeStatus(`${chalk.dim(line)}\n`)
416
+ }
417
+ }
418
+
419
+ const frame =
420
+ BRIDGE_SPINNER_FRAMES[connectingTick % BRIDGE_SPINNER_FRAMES.length]!
421
+ connectingTick++
422
+ writeStatus(
423
+ `${chalk.yellow(frame)} ${chalk.yellow('Reconnecting')} ${chalk.dim('\u00b7')} ${chalk.dim(`retrying in ${delayStr}`)} ${chalk.dim('\u00b7')} ${chalk.dim(`disconnected ${elapsedStr}`)}\n`,
424
+ )
425
+ },
426
+
427
+ updateFailedStatus(error: string): void {
428
+ stopConnecting()
429
+ clearStatusLines()
430
+ currentState = 'failed'
431
+
432
+ let suffix = ''
433
+ if (repoName) {
434
+ suffix += chalk.dim(' \u00b7 ') + chalk.dim(repoName)
435
+ }
436
+ if (branch) {
437
+ suffix += chalk.dim(' \u00b7 ') + chalk.dim(branch)
438
+ }
439
+
440
+ writeStatus(
441
+ `${chalk.red(BRIDGE_FAILED_INDICATOR)} ${chalk.red('Remote Control Failed')}${suffix}\n`,
442
+ )
443
+ writeStatus(`${chalk.dim(FAILED_FOOTER_TEXT)}\n`)
444
+
445
+ if (error) {
446
+ writeStatus(`${chalk.red(error)}\n`)
447
+ }
448
+ },
449
+
450
+ updateSessionStatus(
451
+ _sessionId: string,
452
+ _elapsed: string,
453
+ activity: SessionActivity,
454
+ _trail: string[],
455
+ ): void {
456
+ // Cache tool activity for the second status line
457
+ if (activity.type === 'tool_start') {
458
+ lastToolSummary = activity.summary
459
+ lastToolTime = Date.now()
460
+ }
461
+ renderStatusLine()
462
+ },
463
+
464
+ clearStatus(): void {
465
+ stopConnecting()
466
+ clearStatusLines()
467
+ },
468
+
469
+ toggleQr(): void {
470
+ qrVisible = !qrVisible
471
+ renderStatusLine()
472
+ },
473
+
474
+ updateSessionCount(active: number, max: number, mode: SpawnMode): void {
475
+ if (sessionActive === active && sessionMax === max && spawnMode === mode)
476
+ return
477
+ sessionActive = active
478
+ sessionMax = max
479
+ spawnMode = mode
480
+ // Don't re-render here — the status ticker calls renderStatusLine
481
+ // on its own cadence, and the next tick will pick up the new values.
482
+ },
483
+
484
+ setSpawnModeDisplay(mode: 'same-dir' | 'worktree' | null): void {
485
+ if (spawnModeDisplay === mode) return
486
+ spawnModeDisplay = mode
487
+ // Also sync the #21118-added spawnMode so the next render shows correct
488
+ // mode hint + branch visibility. Don't render here — matches
489
+ // updateSessionCount: called before printBanner (initial setup) and
490
+ // again from the `w` handler (which follows with refreshDisplay).
491
+ if (mode) spawnMode = mode
492
+ },
493
+
494
+ addSession(sessionId: string, url: string): void {
495
+ sessionDisplayInfo.set(sessionId, { url })
496
+ },
497
+
498
+ updateSessionActivity(sessionId: string, activity: SessionActivity): void {
499
+ const info = sessionDisplayInfo.get(sessionId)
500
+ if (!info) return
501
+ info.activity = activity
502
+ },
503
+
504
+ setSessionTitle(sessionId: string, title: string): void {
505
+ const info = sessionDisplayInfo.get(sessionId)
506
+ if (!info) return
507
+ info.title = title
508
+ // Guard against reconnecting/failed — renderStatusLine clears then returns
509
+ // early for those states, which would erase the spinner/error.
510
+ if (currentState === 'reconnecting' || currentState === 'failed') return
511
+ if (sessionMax === 1) {
512
+ // Single-session: show title in the main status line too.
513
+ currentState = 'titled'
514
+ currentStateText = truncatePrompt(title, 40)
515
+ }
516
+ renderStatusLine()
517
+ },
518
+
519
+ removeSession(sessionId: string): void {
520
+ sessionDisplayInfo.delete(sessionId)
521
+ },
522
+
523
+ refreshDisplay(): void {
524
+ // Skip during reconnecting/failed — renderStatusLine clears then returns
525
+ // early for those states, which would erase the spinner/error.
526
+ if (currentState === 'reconnecting' || currentState === 'failed') return
527
+ renderStatusLine()
528
+ },
529
+ }
530
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Shared capacity-wake primitive for bridge poll loops.
3
+ *
4
+ * Both replBridge.ts and bridgeMain.ts need to sleep while "at capacity"
5
+ * but wake early when either (a) the outer loop signal aborts (shutdown),
6
+ * or (b) capacity frees up (session done / transport lost). This module
7
+ * encapsulates the mutable wake-controller + two-signal merger that both
8
+ * poll loops previously duplicated byte-for-byte.
9
+ */
10
+
11
+ export type CapacitySignal = { signal: AbortSignal; cleanup: () => void }
12
+
13
+ export type CapacityWake = {
14
+ /**
15
+ * Create a signal that aborts when either the outer loop signal or the
16
+ * capacity-wake controller fires. Returns the merged signal and a cleanup
17
+ * function that removes listeners when the sleep resolves normally
18
+ * (without abort).
19
+ */
20
+ signal(): CapacitySignal
21
+ /**
22
+ * Abort the current at-capacity sleep and arm a fresh controller so the
23
+ * poll loop immediately re-checks for new work.
24
+ */
25
+ wake(): void
26
+ }
27
+
28
+ export function createCapacityWake(outerSignal: AbortSignal): CapacityWake {
29
+ let wakeController = new AbortController()
30
+
31
+ function wake(): void {
32
+ wakeController.abort()
33
+ wakeController = new AbortController()
34
+ }
35
+
36
+ function signal(): CapacitySignal {
37
+ const merged = new AbortController()
38
+ const abort = (): void => merged.abort()
39
+ if (outerSignal.aborted || wakeController.signal.aborted) {
40
+ merged.abort()
41
+ return { signal: merged.signal, cleanup: () => {} }
42
+ }
43
+ outerSignal.addEventListener('abort', abort, { once: true })
44
+ const capSig = wakeController.signal
45
+ capSig.addEventListener('abort', abort, { once: true })
46
+ return {
47
+ signal: merged.signal,
48
+ cleanup: () => {
49
+ outerSignal.removeEventListener('abort', abort)
50
+ capSig.removeEventListener('abort', abort)
51
+ },
52
+ }
53
+ }
54
+
55
+ return { signal, wake }
56
+ }