clauderipple 0.10.0 → 0.11.1

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 (78) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/README.es.md +45 -0
  3. package/README.ja.md +17 -0
  4. package/README.ko.md +31 -0
  5. package/README.md +39 -0
  6. package/README.pt-BR.md +45 -0
  7. package/README.zh-CN.md +17 -0
  8. package/dist/app/assets/icon.icns +0 -0
  9. package/dist/app/assets/icon.ico +0 -0
  10. package/dist/app/assets/icon.png +0 -0
  11. package/dist/app/assets/trayDirectTemplate.png +0 -0
  12. package/dist/app/assets/trayDirectTemplate@2x.png +0 -0
  13. package/dist/app/assets/trayDownTemplate.png +0 -0
  14. package/dist/app/assets/trayDownTemplate@2x.png +0 -0
  15. package/dist/app/assets/trayTemplate.png +0 -0
  16. package/dist/app/assets/trayTemplate@2x.png +0 -0
  17. package/dist/app/assets/trayWarnTemplate.png +0 -0
  18. package/dist/app/assets/trayWarnTemplate@2x.png +0 -0
  19. package/dist/app/assets/trayWin.png +0 -0
  20. package/dist/app/assets/trayWin@2x.png +0 -0
  21. package/dist/app/assets/trayWinDirect.png +0 -0
  22. package/dist/app/assets/trayWinDirect@2x.png +0 -0
  23. package/dist/app/assets/trayWinDown.png +0 -0
  24. package/dist/app/assets/trayWinDown@2x.png +0 -0
  25. package/dist/app/assets/trayWinWarn.png +0 -0
  26. package/dist/app/assets/trayWinWarn@2x.png +0 -0
  27. package/dist/app/dist/claude-desktop.js +67 -0
  28. package/dist/app/dist/main.js +423 -110
  29. package/dist/app/dist/preload.js +21 -0
  30. package/dist/app/dist/protocol.js +121 -0
  31. package/dist/app/dist/status-model.js +87 -0
  32. package/dist/app/dist/window.js +140 -0
  33. package/dist/app/package.json +1 -1
  34. package/dist/cli/src/awake-setup.js +314 -0
  35. package/dist/cli/src/bundle.js +9 -5
  36. package/dist/cli/src/direct.js +159 -0
  37. package/dist/cli/src/doctor.js +205 -0
  38. package/dist/cli/src/index.js +184 -31
  39. package/dist/cli/src/launchd.js +29 -20
  40. package/dist/cli/src/launcher.js +215 -0
  41. package/dist/cli/src/power.js +182 -0
  42. package/dist/cli/src/registration.js +157 -0
  43. package/dist/cli/src/schtasks.js +2 -0
  44. package/dist/cli/src/settings.js +24 -0
  45. package/dist/cli/src/tray.js +5 -2
  46. package/dist/mod/hooks/i18n.ts +237 -237
  47. package/dist/mod/hooks/register.tsx +4 -5
  48. package/dist/mod/hooks/session.ts +6 -2
  49. package/dist/mod/hooks/ui/agents.tsx +81 -36
  50. package/dist/mod/hooks/ui/view.ts +8 -5
  51. package/dist/mod/tests/ui.test.tsx +46 -13
  52. package/dist/mod/types/index.d.ts +0 -2
  53. package/dist/remote/src/bridge-client.js +45 -4
  54. package/dist/router/src/admin.js +105 -2
  55. package/dist/router/src/awake-win.js +590 -0
  56. package/dist/router/src/awake.js +432 -0
  57. package/dist/router/src/config.js +64 -0
  58. package/dist/router/src/health.js +86 -3
  59. package/dist/router/src/hold.js +107 -0
  60. package/dist/router/src/index.js +69 -6
  61. package/dist/router/src/ingress/server.js +26 -4
  62. package/dist/router/src/power.js +107 -0
  63. package/dist/router/src/providers/google/antigravity.js +73 -10
  64. package/dist/router/src/providers/google/index.js +168 -17
  65. package/dist/router/src/providers/google/thoughtSignatures.js +13 -0
  66. package/dist/router/src/providers/google/transport.js +1 -1
  67. package/dist/router/src/providers/google/usage.js +71 -26
  68. package/dist/router/src/providers/retry.js +4 -1
  69. package/dist/router/src/proxy.js +50 -21
  70. package/dist/router/src/tether-win.js +211 -0
  71. package/dist/router/src/tether.js +712 -0
  72. package/dist/router/src/version.js +1 -1
  73. package/dist/ui/app.js +649 -2
  74. package/dist/ui/i18n.js +494 -77
  75. package/dist/ui/index.html +9 -1
  76. package/dist/ui/style.css +138 -0
  77. package/docs/ARCHITECTURE.md +294 -6
  78. package/package.json +2 -2
@@ -88,7 +88,6 @@ const effortMenuOpen = atom({ plugin: 'ripple', key: 'effortMenuOpen' } as const
88
88
  const agentsOpen = atom({ plugin: 'ripple', key: 'agentsOpen' } as const, false)
89
89
  const agentsShowDone = atom({ plugin: 'ripple', key: 'agentsShowDone' } as const, false)
90
90
  const agentOpen = atom({ plugin: 'ripple', key: 'agentOpen' } as const, null)
91
- const agentEarlier = atom({ plugin: 'ripple', key: 'agentEarlier' } as const, 0)
92
91
  const agentRunsOpen = atom({ plugin: 'ripple', key: 'agentRunsOpen' } as const, [])
93
92
  const agentsTick = atom({ plugin: 'ripple', key: 'agentsTick' } as const, 0)
94
93
  const jev = atom({ plugin: 'ripple', key: 'jev' } as const, { source: null, test: { state: 'idle' } })
@@ -1232,7 +1231,8 @@ async function frameOf($: EngineInterface, e: RenderInput): Promise<Frame> {
1232
1231
  const s = await currentSettings($)
1233
1232
  const columns = 'bodyColumns' in e.props ? e.props.bodyColumns : e.viewport?.columns ?? 100
1234
1233
  const maxRows = 'maxRows' in e.props ? e.props.maxRows : 'scroll' in e.props ? e.props.scroll.bodyRows : 1
1235
- return { surface: e.surface, columns, maxRows, now: await $.clock.now(), appearance: s.appearance, skin: s.skin, lang: await read($, lang) }
1234
+ const offset = 'scroll' in e.props && !('maxRows' in e.props) ? e.props.scroll.offset : undefined
1235
+ return { surface: e.surface, columns, maxRows, ...(offset ? { offset } : {}), now: await $.clock.now(), appearance: s.appearance, skin: s.skin, lang: await read($, lang) }
1236
1236
  }
1237
1237
  async function alertView($: EngineInterface, now: number): Promise<AlertView | null> {
1238
1238
  const language = await read($, lang)
@@ -1317,10 +1317,9 @@ function alertActions($: EngineInterface, view: AlertView): AlertActions {
1317
1317
  function agentsActions($: EngineInterface): AgentsActions {
1318
1318
  return {
1319
1319
  toggleDone: () => update($, agentsShowDone, value => !value),
1320
- open: async id => { await update($, agentEarlier, () => 0); await update($, agentRunsOpen, () => []); await update($, agentOpen, () => id) },
1320
+ open: async id => { await update($, agentRunsOpen, () => []); await update($, agentOpen, () => id) },
1321
1321
  // Back on the list, the runs unfolded in that conversation fold again: it opens folded next time.
1322
1322
  back: async () => { await update($, agentRunsOpen, () => []); await update($, agentOpen, () => null) },
1323
- earlier: () => update($, agentEarlier, n => n + 1),
1324
1323
  toggleRun: id => update($, agentRunsOpen, ids => ids.includes(id) ? ids.filter(i => i !== id) : [...ids, id]),
1325
1324
  }
1326
1325
  }
@@ -1759,7 +1758,7 @@ export const register: Register = on => {
1759
1758
  // Read to be drawn again each second an agent runs (agentsClock): its elapsed time ticks.
1760
1759
  await read($, agentsTick)
1761
1760
  const id = await read($, agentOpen)
1762
- const open = id === null ? undefined : { id, talk: await agentTalk($, id), earlier: await read($, agentEarlier), runsOpen: await read($, agentRunsOpen) }
1761
+ const open = id === null ? undefined : { id, talk: await agentTalk($, id), runsOpen: await read($, agentRunsOpen) }
1763
1762
  return renderAgents($.ui.resolve(e), frame, { agents: await read($, agents), now: frame.now, showDone: await read($, agentsShowDone), ...(open ? { open } : {}) }, agentsActions($)) as RenderElement
1764
1763
  } catch (error) { await report($, 'agents render', error); return next(e) }
1765
1764
  })
@@ -68,8 +68,12 @@ function limitItemFromWindow(w: unknown, provider: string, type: string | undefi
68
68
  if (typeof used !== 'number' || !Number.isFinite(used)) return null
69
69
  const minutes = typeof o.window_minutes === 'number' && o.window_minutes > 0 ? o.window_minutes : undefined
70
70
  const reset = typeof o.reset_at === 'number' && Number.isFinite(o.reset_at) ? o.reset_at * (o.reset_at < 1e12 ? 1000 : 1) : NaN
71
- // A window whose reset has passed no longer stands (as the ChatGPT reader has always said).
72
- if (Number.isFinite(reset) && reset <= now) return null
71
+ // A window whose reset has passed starts again from nothing: it reads 0% until the provider next
72
+ // reports, not gone. Dropped, Claude's five-hour window vanished from the row the minute it reset,
73
+ // and the row read as one of Claude's figures cut off (2026-10-20).
74
+ if (Number.isFinite(reset) && reset <= now) {
75
+ return { provider, ...(type ? { type } : {}), ...(title ? { title } : {}), account, kind: kind as LimitKind, label: typeof o.label === 'string' ? o.label : '', ...(minutes !== undefined ? { windowMinutes: minutes } : {}), percent: 0 }
76
+ }
73
77
  return {
74
78
  provider,
75
79
  ...(type ? { type } : {}),
@@ -43,9 +43,13 @@ const BY: Record<NonNullable<AgentRec['pick']>['by'] | 'tier', string> = { haiku
43
43
 
44
44
  /** Finished agents the list shows before "more". */
45
45
  export const DONE_SHOWN = 4
46
- /** Conversation entries a page shows; "earlier" adds a page. */
47
- export const TALK_PAGE = 30
48
- /** Characters of a text entry drawn: a tree draws 100,000 characters in all (the engine's cut). */
46
+ /**
47
+ * Characters of the conversation drawn in all, about: a tree draws 100,000 characters (the engine's
48
+ * cut), and the card and the page's chrome take some. Only a conversation past it loses its oldest
49
+ * entries; any other shows whole, as the Claude app's chat does.
50
+ */
51
+ export const TALK_CHARS = 80_000
52
+ /** Characters of a text entry drawn. */
49
53
  const TEXT_MAX = 3000
50
54
  const PROMPT_MAX = 2400
51
55
  const ANSWER_MAX = 12000
@@ -162,21 +166,30 @@ function cut(text: string, max: number): string {
162
166
  return text.length > max ? text.slice(0, max).trimEnd() + ' …' : text
163
167
  }
164
168
 
165
- type Page = { first?: AgentEntry; hidden: number; shown: AgentEntry[]; answer?: string }
169
+ type Page = { first?: AgentEntry; dropped: number; shown: AgentEntry[]; answer?: string }
170
+
171
+ /** Characters an entry draws, about: its text as cut, or a tool call's line. */
172
+ function weight(entry: AgentEntry): number {
173
+ if (entry.kind === 'tool') return (entry.detail?.length ?? 0) + 40
174
+ return Math.min(entry.text.length, entry.kind === 'prompt' ? PROMPT_MAX : TEXT_MAX) + 40
175
+ }
166
176
 
167
177
  /**
168
- * What a page of the conversation shows: the first prompt always (what it was asked), the latest
169
- * entries, how many earlier ones wait behind "earlier", and a finished agent's last words as its
170
- * answer.
178
+ * What the page shows: the first prompt always (what it was asked), every entry after it, and a
179
+ * finished agent's last words as its answer. Every entry, with no "earlier" to press: the owner
180
+ * read the agent's words behind it as hidden (2026-10-20). Only past TALK_CHARS do the oldest go,
181
+ * counted in `dropped`.
171
182
  */
172
- function pageOf(entries: readonly AgentEntry[], earlier: number, finished: boolean): Page {
183
+ function pageOf(entries: readonly AgentEntry[], finished: boolean): Page {
173
184
  const first = entries[0]?.kind === 'prompt' ? entries[0] : undefined
174
185
  let rest = first ? entries.slice(1) : [...entries]
175
186
  const last = rest.at(-1)
176
187
  const answer = finished && last?.kind === 'text' ? last.text : undefined
177
188
  if (answer !== undefined) rest = rest.slice(0, -1)
178
- const shown = rest.slice(-TALK_PAGE * (earlier + 1))
179
- return { ...(first ? { first } : {}), hidden: rest.length - shown.length, shown, ...(answer !== undefined ? { answer } : {}) }
189
+ let room = TALK_CHARS - (first ? weight(first) : 0) - Math.min(answer?.length ?? 0, ANSWER_MAX)
190
+ let from = rest.length
191
+ while (from > 0 && room - weight(rest[from - 1]!) >= 0) room -= weight(rest[--from]!)
192
+ return { ...(first ? { first } : {}), dropped: from, shown: rest.slice(from), ...(answer !== undefined ? { answer } : {}) }
180
193
  }
181
194
 
182
195
  // ---- Desktop: the cards ------------------------------------------------------------------------
@@ -208,9 +221,23 @@ function timeCh(lang: Frame['lang']): number {
208
221
  return ch
209
222
  }
210
223
 
224
+ /** Cells "failed" takes before the time when it failed. */
225
+ function failedCells(frame: Frame, agent: AgentRec): number {
226
+ return agent.state === 'failed' ? Math.ceil(chOf(t(frame.lang, 'agents.failed'))) + 1 : 0
227
+ }
228
+
211
229
  /** Cells of the time's column: the elapsed time's widest, and "failed" before it when it failed. */
212
230
  export function timeCells(frame: Frame, agent: AgentRec): number {
213
- return Math.ceil(timeCh(frame.lang)) + (agent.state === 'failed' ? Math.ceil(chOf(t(frame.lang, 'agents.failed'))) + 1 : 0)
231
+ return Math.ceil(timeCh(frame.lang)) + failedCells(frame, agent)
232
+ }
233
+
234
+ /**
235
+ * Cells the time needs at the end of the numbers' last row: an hour's worth ("59분 59초"). It ends the
236
+ * row right-aligned, so a longer one only grows leftward into the row's slack; reserving 99 hours there
237
+ * left the time a row of its own on a narrow pane (2026-10-20).
238
+ */
239
+ function tailCells(frame: Frame, agent: AgentRec): number {
240
+ return Math.ceil(chOf(elapsed(3_599_000, frame.lang))) + failedCells(frame, agent)
214
241
  }
215
242
 
216
243
  /** The widest each number shows, for its slot. */
@@ -245,17 +272,18 @@ const NARROW_CELLS = 44
245
272
  /**
246
273
  * How a card lays out, from the pane's width and its language alone, never a value that ticks.
247
274
  * Wide: the time and the chevron in a fixed column right of the title, the four numbers in a row.
248
- * Narrow (or when the four numbers would not fit a row): the title has the row to itself but for the
249
- * chevron, the numbers go in two fixed rows (in · cache / out · tools), and the time ends the second
250
- * of them, or takes a row of its own when it does not fit there.
275
+ * Narrow (or when the four numbers would not fit a row): the numbers go in two fixed rows (in · cache /
276
+ * out · tools) and the time ends the second of them; the title has its row to itself but for the
277
+ * chevron, or, when the time does not fit by the numbers, beside the time in the head. The time never
278
+ * takes a row of its own (2026-10-20: a whole row for one value was the owner's complaint).
251
279
  */
252
- type CardLayout = { width: number; narrow: boolean; time: number; timeOn: 'head' | 'nums' | 'own'; side: number; title: number }
280
+ type CardLayout = { width: number; narrow: boolean; time: number; timeOn: 'head' | 'nums'; side: number; title: number }
253
281
  function layoutOf(frame: Frame, agent: AgentRec, pressable: boolean): CardLayout {
254
282
  const width = innerCells(frame)
255
283
  const figs = cardFigures(frame, agent)
256
284
  const narrow = width < NARROW_CELLS || figureCells(figs) > width - 2
257
285
  const time = timeCells(frame, agent)
258
- const timeOn = !narrow ? 'head' : figs.length && figureCells(figs.slice(2)) + 2 + time <= width - 2 ? 'nums' : 'own'
286
+ const timeOn = narrow && figs.length && figureCells(figs.slice(2)) + 2 + tailCells(frame, agent) <= width - 2 ? 'nums' : 'head'
259
287
  const side = timeOn === 'head' ? time + (pressable ? 2 : 0) : pressable ? 1 : 0
260
288
  return { width, narrow, time, timeOn, side, title: width - 2 - (side ? side + 1 : 0) }
261
289
  }
@@ -285,14 +313,24 @@ function modelRow(frame: Frame, agent: AgentRec, model: string): { type: string;
285
313
  return { type, word, room: modelRoom(frame, agent, type, word) }
286
314
  }
287
315
 
316
+ /** Lines a card's title takes at most; the rest is cut with an ellipsis. */
317
+ const TITLE_LINES = 2
318
+
319
+ /**
320
+ * The title cut to two lines of `cells`: a few cells short of two full ones, as wrapping at words
321
+ * leaves the end of a line empty.
322
+ */
323
+ function cardTitle(agent: AgentRec, cells: number): string {
324
+ return clipCh(titleOf(agent), TITLE_LINES * Math.max(10, cells) - 4)
325
+ }
326
+
288
327
  /** Rows a card draws, about: for the presses laid over it and the capsule's water. */
289
328
  export function cardRows(frame: Frame, agent: AgentRec, pressable = true): number {
290
329
  const layout = layoutOf(frame, agent, pressable)
291
- const head = linesOf(titleOf(agent), layout.title)
330
+ const head = Math.min(TITLE_LINES, linesOf(cardTitle(agent, layout.title), layout.title))
292
331
  const nums = figureRows(cardFigures(frame, agent), layout.narrow).length
293
- const own = layout.timeOn === 'own' ? 1 : 0
294
332
  const fall = agent.pick?.fallbackReason ? linesOf(t(frame.lang, 'agents.fallback', { reason: agent.pick.fallbackReason }), layout.width - 2) : 0
295
- return head + 1.2 + nums + own + fall + 1.3
333
+ return head + 1.2 + nums + fall + 1.3
296
334
  }
297
335
 
298
336
  /** A state's mark and its colour: running in the accent, waiting amber, done green, failed red. */
@@ -338,8 +376,10 @@ function cardBody(el: Els, frame: Frame, agent: AgentRec, now: number, pressable
338
376
  const { type, word, room } = modelRow(frame, agent, model)
339
377
  const nums = figureRows(cardFigures(frame, agent), layout.narrow)
340
378
  const auto = agent.pick && ['haiku', 'jev', 'model'].includes(agent.pick.by)
379
+ // In the head a column as wide as its widest value; at the end of the numbers, right-aligned in
380
+ // the row's slack (layoutOf), as wide as it is.
341
381
  const time = (
342
- <Box key="time" flexDirection="row" justifyContent="flex-end" columnGap={1} flexShrink={0} width={layout.time}>
382
+ <Box key="time" flexDirection="row" justifyContent="flex-end" columnGap={1} flexShrink={0} {...(layout.timeOn === 'head' ? { width: layout.time } : {})}>
343
383
  {agent.state === 'failed' ? <Text key="failed" color={k.tone('red')}>{t(L, 'agents.failed')}</Text> : null}
344
384
  <Text key="elapsed" {...(live(agent) ? k.ink : k.sub)}>{ranFor(agent, now, L)}</Text>
345
385
  </Box>
@@ -350,7 +390,7 @@ function cardBody(el: Els, frame: Frame, agent: AgentRec, now: number, pressable
350
390
  <Text color={m.color} bold>{m.glyph}</Text>
351
391
  </Box>
352
392
  <Box key="title" flexGrow={1} flexShrink={1} minWidth={0}>
353
- <Text bold {...k.ink} wrap="wrap">{titleOf(agent)}</Text>
393
+ <Text bold {...k.ink} wrap="wrap">{cardTitle(agent, layout.title)}</Text>
354
394
  </Box>
355
395
  {layout.side ? (
356
396
  <Box key="side" flexDirection="row" justifyContent="flex-end" columnGap={1} flexShrink={0} width={layout.side}>
@@ -396,13 +436,6 @@ function cardBody(el: Els, frame: Frame, agent: AgentRec, now: number, pressable
396
436
  </Box>,
397
437
  )
398
438
  })
399
- if (layout.timeOn === 'own') {
400
- rows.push(
401
- <Box key="time-row" flexDirection="row" justifyContent="flex-end" paddingLeft={2} minWidth={0}>
402
- {time}
403
- </Box>,
404
- )
405
- }
406
439
  if (agent.pick?.fallbackReason) {
407
440
  rows.push(
408
441
  <Box key="fallback" paddingLeft={2} minWidth={0}>
@@ -631,7 +664,7 @@ function block(el: Els, frame: Frame, key: string, label: RenderNode, text: stri
631
664
 
632
665
  /** The conversation under the card: the task, the latest entries, the answer, what it does now. */
633
666
  function talkDrawn(el: Els, frame: Frame, view: AgentsView, agent: AgentRec, talk: AgentTalk, actions: AgentsActions): RenderNode {
634
- const { Box, Button, Markdown, Text } = el
667
+ const { Box, Markdown, Text } = el
635
668
  const k = pageLook(frame)
636
669
  const L = frame.lang
637
670
  if ('deny' in talk) {
@@ -641,13 +674,13 @@ function talkDrawn(el: Els, frame: Frame, view: AgentsView, agent: AgentRec, tal
641
674
  </Box>
642
675
  )
643
676
  }
644
- const page = pageOf(talk.entries, view.open?.earlier ?? 0, !live(agent))
677
+ const page = pageOf(talk.entries, !live(agent))
645
678
  const items: RenderNode[] = []
646
679
  if (page.first?.kind === 'prompt') items.push(block(el, frame, 'task', <Text key="task-label" bold {...k.sub}>{t(L, 'agents.task')}</Text>, cut(page.first.text, PROMPT_MAX)))
647
- if (page.hidden > 0) {
680
+ if (page.dropped > 0) {
648
681
  items.push(
649
- <Box key="earlier" flexDirection="row" justifyContent="center">
650
- <Button key="earlier" plain dimColor label={t(L, 'agents.earlier', { count: Math.min(page.hidden, TALK_PAGE) })} onPress={() => actions.earlier()} />
682
+ <Box key="dropped" flexDirection="row" justifyContent="center">
683
+ <Text {...k.mute}>{t(L, 'agents.dropped', { count: page.dropped })}</Text>
651
684
  </Box>,
652
685
  )
653
686
  }
@@ -728,14 +761,26 @@ function openDrawn(el: Els, frame: Frame, view: AgentsView, actions: AgentsActio
728
761
  card(el, frame, agent, view.now),
729
762
  ],
730
763
  })
764
+ // Scrolled past the head, the way back floats at the window's top: the engine asks again with the
765
+ // new offset on each scroll, so a box placed at that row stays put while the conversation moves
766
+ // under it. Scrolling back up to leave was the owner's complaint (2026-10-20).
767
+ const offset = frame.offset ?? 0
731
768
  return (
732
- <Box flexDirection="column" rowGap={1} width="100%" minWidth={0}>
769
+ <Box position="relative" flexDirection="column" rowGap={1} width="100%" minWidth={0}>
733
770
  {head}
734
771
  {talkDrawn(el, frame, view, agent, talk, actions)}
772
+ {offset >= FLOAT_AFTER ? (
773
+ <Box key="float-back" position="absolute" top={offset} left={0} flexDirection="row" paddingLeft={1}>
774
+ {pill(el, frame, 'back-float', t(L, 'agents.back'), 'primary', actions.back, 'violet', 'back')}
775
+ </Box>
776
+ ) : null}
735
777
  </Box>
736
778
  )
737
779
  }
738
780
 
781
+ /** Rows scrolled before the way back floats: about where the head's own leaves the window. */
782
+ const FLOAT_AFTER = 3
783
+
739
784
  // ---- Terminal ----------------------------------------------------------------------------------
740
785
 
741
786
  /** The model line as text: the model, its effort, its type when that says more. */
@@ -842,9 +887,9 @@ function openText(el: Els, frame: Frame, view: AgentsView, actions: AgentsAction
842
887
  const lines: RenderNode[] = []
843
888
  if ('deny' in talk) lines.push(<Text key="deny" dimColor wrap="wrap">{t(L, 'agents.deny', { reason: talk.deny })}</Text>)
844
889
  else {
845
- const page = pageOf(talk.entries, view.open?.earlier ?? 0, !live(agent))
890
+ const page = pageOf(talk.entries, !live(agent))
846
891
  if (page.first?.kind === 'prompt') lines.push(<Box key="task" flexDirection="column"><Text dimColor>{t(L, 'agents.task')}</Text><Text wrap="wrap">{cut(page.first.text, 600)}</Text></Box>)
847
- if (page.hidden > 0) lines.push(<Box key="earlier">{textButton(el, 'earlier', t(L, 'agents.earlier', { count: Math.min(page.hidden, TALK_PAGE) }), actions.earlier, 'e')}</Box>)
892
+ if (page.dropped > 0) lines.push(<Box key="dropped"><Text dimColor>{t(L, 'agents.dropped', { count: page.dropped })}</Text></Box>)
848
893
  // A run of calls folds into one line here too; a single call, and the one in flight, stay lines.
849
894
  const inFlight = live(agent) ? page.shown.findLast(e => e.kind === 'tool' && e.state === 'running') : undefined
850
895
  const opened = view.open?.runsOpen ?? []
@@ -16,6 +16,8 @@ export type Frame = {
16
16
  columns: number
17
17
  /** Rows the band may take (`maxRows`); a pane passes its `scroll.bodyRows`. */
18
18
  maxRows: number
19
+ /** A pane's first row in view (`scroll.offset`); the engine asks again when the person scrolls. */
20
+ offset?: number
19
21
  /** Epoch ms when drawn; countdowns are computed from it. */
20
22
  now: number
21
23
  appearance: Appearance
@@ -222,8 +224,8 @@ export type AgentsView = {
222
224
  now: number
223
225
  /** Every finished agent, not only the latest few. */
224
226
  showDone: boolean
225
- /** The agent whose conversation is open, what of it was read, and how many earlier pages show. */
226
- open?: { id: string; talk: AgentTalk; earlier: number; runsOpen?: readonly string[] }
227
+ /** The agent whose conversation is open, what of it was read, and the runs of calls unfolded. */
228
+ open?: { id: string; talk: AgentTalk; runsOpen?: readonly string[] }
227
229
  }
228
230
 
229
231
  export type AgentsActions = {
@@ -232,8 +234,6 @@ export type AgentsActions = {
232
234
  open: (id: string) => void
233
235
  /** Back to the list. */
234
236
  back: () => void
235
- /** One more page of the open conversation's earlier entries. */
236
- earlier: () => void
237
237
  /** Folds or unfolds a run of tool calls, named by its first call's id. */
238
238
  toggleRun: (id: string) => void
239
239
  }
@@ -312,8 +312,11 @@ export function cachePercent(usage: Usage | undefined): number | undefined {
312
312
  export function modelOf(record: StatusRequest): string {
313
313
  const at = record.source.lastIndexOf('@')
314
314
  const source = at < 0 ? record.source : record.source.slice(0, at)
315
+ // A hosted id's vendor path ("deepseek-ai/") goes, as on the agent cards: the slot is narrow and
316
+ // the provider is named beside it.
317
+ const bare = (id: string) => id.replace(/^.*\//, '')
315
318
  // A dated id of the same model (claude-haiku-5-5-20260815 for claude-haiku-5-5) says nothing new.
316
- return source.startsWith(record.target) || record.target.startsWith(source) ? record.target : record.target + ' (' + source + ')'
319
+ return source.startsWith(record.target) || record.target.startsWith(source) ? bare(record.target) : bare(record.target) + ' (' + bare(source) + ')'
317
320
  }
318
321
 
319
322
  export function routeOf(record: StatusRequest): RouteView {
@@ -131,6 +131,9 @@ test('view helpers', () => {
131
131
  expect(targetOf('gpt-6-1-sol')).toEqual({ agent: 'gpt-6-1-sol' })
132
132
  const record: StatusRequest = { at: '2026-10-10T13:19:10', source: 'gpt-6-1-sol@high', target: 'gpt-6.1-sol', provider: 'chatgpt', status: 200, ok: true, ms: 2140, usage: { input: 3000, cached: 41_000, output: 900 } }
133
133
  expect(routeOf(record)).toMatchObject({ model: 'gpt-6.1-sol (gpt-6-1-sol)', cachePct: 93, seconds: 2.14 })
134
+ // A hosted id's vendor path is not drawn.
135
+ expect(routeOf({ ...record, source: 'deepseek-ai-deepseek-v4-1-flash-1017@high', target: 'deepseek-ai/deepseek-v4.1-flash-1017' }).model).toBe('deepseek-v4.1-flash-1017 (deepseek-ai-deepseek-v4-1-flash-1017)')
136
+ expect(routeOf({ ...record, source: 'deepseek-ai/deepseek-v4.1-flash-1017', target: 'deepseek-ai/deepseek-v4.1-flash-1017' }).model).toBe('deepseek-v4.1-flash-1017')
134
137
  expect(minutesLeft(41 * 60_000 + 1, 'en')).toBe('42m')
135
138
  expect(minutesLeft(41 * 60_000, 'en')).toBe('41m')
136
139
  expect(minutesLeft(30_000, 'ko')).toBe('1분')
@@ -586,7 +589,7 @@ const AGENTS: AgentRec[] = [
586
589
  { id: 'a2', description: 'Summarize the docs', type: 'deepseek-v4-1-flash', state: 'done', startedAt: NOW - 900_000, endedAt: NOW - 800_000, cost: 9000 },
587
590
  ]
588
591
 
589
- const AGENT_NOOP: AgentsActions = { toggleDone: () => undefined, open: () => undefined, back: () => undefined, earlier: () => undefined, toggleRun: () => undefined }
592
+ const AGENT_NOOP: AgentsActions = { toggleDone: () => undefined, open: () => undefined, back: () => undefined, toggleRun: () => undefined }
590
593
 
591
594
  test('the Agents pane draws a worker the session\'s own model runs', async ($, on) => {
592
595
  const onSession: AgentRec = { ...AGENTS[0]!, id: 's1', description: 'Review on the session model', pick: { ...AGENTS[0]!.pick!, target: { agent: 'general-purpose' }, by: 'session' } }
@@ -653,7 +656,7 @@ test('the Agents pane shows the latest finished agents and the rest behind more;
653
656
 
654
657
  test('an agent\'s conversation reads in the pane: the task, its words, its tools, the answer, and a way back', async ($, on) => {
655
658
  const { calls, fn } = recorder()
656
- const actions = { ...AGENT_NOOP, back: fn('back'), earlier: fn('earlier') }
659
+ const actions = { ...AGENT_NOOP, back: fn('back') }
657
660
  const talk: AgentEntry[] = [
658
661
  { kind: 'prompt', text: 'Port the log renderer to the new kit.' },
659
662
  { kind: 'text', text: 'Reading the **current** renderer first.' },
@@ -663,11 +666,13 @@ test('an agent\'s conversation reads in the pane: the task, its words, its tools
663
666
  ]
664
667
  const done: AgentRec = { ...AGENTS[0]!, id: 'a9', state: 'done', endedAt: NOW - 1000, now: undefined, toolSince: undefined }
665
668
  const long: AgentEntry[] = [talk[0]!, ...Array.from({ length: 45 }, (_, i): AgentEntry => ({ kind: 'tool', id: 'x' + i, tool: 'Grep', detail: 'pattern ' + i, state: 'ok' }))]
666
- drawPane(on, 'talk-live', (el, surface) => renderAgents(el, frameOf(surface, 64), { agents: AGENTS, now: NOW, showDone: false, open: { id: 'a1', talk: { entries: talk }, earlier: 0 } }, actions))
667
- drawPane(on, 'talk-done', (el, surface) => renderAgents(el, frameOf(surface, 64), { agents: [done], now: NOW, showDone: false, open: { id: 'a9', talk: { entries: [...talk.slice(0, 3), { kind: 'text', text: 'Ported; tests pass.' }] }, earlier: 0 } }, actions))
668
- drawPane(on, 'talk-long', (el, surface) => renderAgents(el, frameOf(surface, 64), { agents: AGENTS, now: NOW, showDone: false, open: { id: 'a1', talk: { entries: long }, earlier: 0, runsOpen: ['x15'] } }, actions))
669
- drawPane(on, 'talk-deny', (el, surface) => renderAgents(el, frameOf(surface, 64), { agents: AGENTS, now: NOW, showDone: false, open: { id: 'a1', talk: { deny: 'no saved transcript for a1' }, earlier: 0 } }, actions))
670
- drawPane(on, 'talk-gone', (el, surface) => renderAgents(el, frameOf(surface, 64), { agents: AGENTS, now: NOW, showDone: false, open: { id: 'zz', talk: { entries: [] }, earlier: 0 } }, actions))
669
+ drawPane(on, 'talk-live', (el, surface) => renderAgents(el, frameOf(surface, 64), { agents: AGENTS, now: NOW, showDone: false, open: { id: 'a1', talk: { entries: talk } } }, actions))
670
+ drawPane(on, 'talk-done', (el, surface) => renderAgents(el, frameOf(surface, 64), { agents: [done], now: NOW, showDone: false, open: { id: 'a9', talk: { entries: [...talk.slice(0, 3), { kind: 'text', text: 'Ported; tests pass.' }] } } }, actions))
671
+ drawPane(on, 'talk-long', (el, surface) => renderAgents(el, frameOf(surface, 64), { agents: AGENTS, now: NOW, showDone: false, open: { id: 'a1', talk: { entries: long }, runsOpen: ['x0'] } }, actions))
672
+ const huge: AgentEntry[] = [talk[0]!, ...Array.from({ length: 60 }, (_, i): AgentEntry => ({ kind: 'text', text: 'words ' + i + ' ' + 'x'.repeat(2000) }))]
673
+ drawPane(on, 'talk-huge', (el, surface) => renderAgents(el, frameOf(surface, 64), { agents: AGENTS, now: NOW, showDone: false, open: { id: 'a1', talk: { entries: huge } } }, actions))
674
+ drawPane(on, 'talk-deny', (el, surface) => renderAgents(el, frameOf(surface, 64), { agents: AGENTS, now: NOW, showDone: false, open: { id: 'a1', talk: { deny: 'no saved transcript for a1' } } }, actions))
675
+ drawPane(on, 'talk-gone', (el, surface) => renderAgents(el, frameOf(surface, 64), { agents: AGENTS, now: NOW, showDone: false, open: { id: 'zz', talk: { entries: [] } } }, actions))
671
676
  for (const surface of SURFACES) {
672
677
  const live = await mount($, 'talk-live', surface, 64)
673
678
  expect(await live.find({ type: 'Text', text: 'Port the request log renderer' }), surface).toBeDefined()
@@ -685,17 +690,40 @@ test('an agent\'s conversation reads in the pane: the task, its words, its tools
685
690
  const finished = await mount($, 'talk-done', surface, 64)
686
691
  expect(await finished.find({ type: 'Text', text: 'Final answer' }), surface).toBeDefined()
687
692
  const long_ = await mount($, 'talk-long', surface, 64)
688
- // The task stays at the head; 30 of the 45 calls show, 15 wait behind "earlier".
693
+ // Every entry shows, as in the Claude app's chat: no "earlier" to press.
689
694
  expect(await long_.find({ type: 'Text', text: 'pattern 44' }), surface).toBeDefined()
690
- expect(await long_.find({ type: 'Text', text: 'pattern 14' }), surface).toBeUndefined()
691
- await long_.press({ key: 'earlier' })
695
+ expect(await long_.find({ type: 'Text', text: 'pattern 0' }), surface).toBeDefined()
696
+ expect(await long_.find({ key: 'earlier' }), surface).toBeUndefined()
697
+ expect(await long_.find({ key: 'dropped' }), surface).toBeUndefined()
698
+ // Only a conversation past what a tree can draw loses its oldest entries, and says so.
699
+ const huge = await mount($, 'talk-huge', surface, 64)
700
+ expect(await huge.find({ key: 'dropped' }), surface).toBeDefined()
701
+ const latest = surface === 'desktop'
702
+ ? (await huge.findAll({ type: 'Markdown' })).some(m => String(m.props.text).startsWith('words 59'))
703
+ : (await huge.find({ type: 'Text', text: /^words 59/ })) !== undefined
704
+ expect(latest, surface).toBe(true)
692
705
  const deny = await mount($, 'talk-deny', surface, 64)
693
706
  expect(await deny.find({ type: 'Text', text: /can’t be read: no saved transcript for a1/ }), surface).toBeDefined()
694
707
  const gone = await mount($, 'talk-gone', surface, 64)
695
708
  expect(await gone.find({ type: 'Text', text: 'This agent is no longer in the list.' }), surface).toBeDefined()
696
709
  await gone.press({ key: 'back' })
697
710
  }
698
- expect(calls).toEqual(['back', 'earlier', 'back', 'back', 'earlier', 'back'])
711
+ expect(calls).toEqual(['back', 'back', 'back', 'back'])
712
+ })
713
+
714
+ test('scrolled down an open conversation, the way back floats at the window\'s top', async ($, on) => {
715
+ const { calls, fn } = recorder()
716
+ const actions = { ...AGENT_NOOP, back: fn('back') }
717
+ const talk: AgentEntry[] = [{ kind: 'prompt', text: 'Do it.' }, ...Array.from({ length: 20 }, (_, i): AgentEntry => ({ kind: 'text', text: 'words ' + i }))]
718
+ const view = { agents: AGENTS, now: NOW, showDone: false, open: { id: 'a1', talk: { entries: talk } } }
719
+ drawPane(on, 'float-top', el => renderAgents(el, frameOf('desktop', 64), view, actions))
720
+ drawPane(on, 'float-down', el => renderAgents(el, { ...frameOf('desktop', 64), offset: 12 }, view, actions))
721
+ const top = await mount($, 'float-top', 'desktop', 64)
722
+ expect(await top.find({ key: 'float-back' })).toBeUndefined()
723
+ const down = await mount($, 'float-down', 'desktop', 64)
724
+ expect((await down.find({ key: 'float-back' }))?.props.top).toBe(12)
725
+ await down.press({ key: 'back-float' })
726
+ expect(calls).toEqual(['back'])
699
727
  })
700
728
 
701
729
  test('talkOf reads a conversation into prompts, words and tool calls that stand answered, failed or running', () => {
@@ -1189,8 +1217,8 @@ test('a run of calls folds into its head, unfolds call by call when pressed, and
1189
1217
  { kind: 'tool', id: 'solo', tool: 'Bash', detail: 'git status', state: 'ok' },
1190
1218
  ]
1191
1219
  const done: AgentRec = { ...AGENTS[0]!, id: 'a9', state: 'done', endedAt: NOW - 1000, now: undefined, toolSince: undefined }
1192
- drawPane(on, 'runs-folded', (el, surface) => renderAgents(el, frameOf(surface, 64), { agents: [done], now: NOW, showDone: false, open: { id: 'a9', talk: { entries: talk }, earlier: 0 } }, actions))
1193
- drawPane(on, 'runs-open', (el, surface) => renderAgents(el, frameOf(surface, 64), { agents: [done], now: NOW, showDone: false, open: { id: 'a9', talk: { entries: talk }, earlier: 0, runsOpen: ['b0'] } }, actions))
1220
+ drawPane(on, 'runs-folded', (el, surface) => renderAgents(el, frameOf(surface, 64), { agents: [done], now: NOW, showDone: false, open: { id: 'a9', talk: { entries: talk } } }, actions))
1221
+ drawPane(on, 'runs-open', (el, surface) => renderAgents(el, frameOf(surface, 64), { agents: [done], now: NOW, showDone: false, open: { id: 'a9', talk: { entries: talk }, runsOpen: ['b0'] } }, actions))
1194
1222
  for (const surface of SURFACES) {
1195
1223
  const folded = await mount($, 'runs-folded', surface, 64)
1196
1224
  expect(await folded.find({ type: surface === 'desktop' ? 'Text' : 'Button', text: /^Ran 8 commands, Edited 1 file/ }), surface).toBeDefined()
@@ -1292,4 +1320,9 @@ test('a narrow card keeps its model on one line without the vendor path, and its
1292
1320
  expect(await narrow.find({ key: 'nums-1' })).toBeDefined()
1293
1321
  expect(await wide.find({ key: 'nums-1' })).toBeUndefined()
1294
1322
  expect(await narrow.find({ type: 'Text', text: '3m 12s' }) ?? await narrow.find({ type: 'Text', text: '3분 12초' })).toBeDefined()
1323
+ // The time never takes a row of its own, and a long title is cut to two lines.
1324
+ for (const ui of [narrow, wide]) expect(await ui.find({ key: 'time-row' })).toBeUndefined()
1325
+ const title = (await narrow.find({ type: 'Text', text: /^Port the/ }))!.text
1326
+ expect(title.endsWith('…')).toBe(true)
1327
+ expect(chOf(title) <= 2 * 33).toBe(true)
1295
1328
  })
@@ -400,8 +400,6 @@ declare module 'claude-code' {
400
400
  agentsShowDone: boolean
401
401
  /** The agent whose conversation the Agents pane shows; null shows the list. */
402
402
  agentOpen: string | null
403
- /** Pages of earlier entries the open conversation shows beyond its latest. */
404
- agentEarlier: number
405
403
  /** The open conversation's tool runs shown call by call, by their first call's id. */
406
404
  agentRunsOpen: string[]
407
405
  /** Counts the Agents pane's seconds while an agent runs: a write redraws its clocks. */
@@ -22,20 +22,61 @@ export const AUTH_REREAD_MS = 30_000;
22
22
  export function object(value) {
23
23
  return value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
24
24
  }
25
+ /** A Claude app login the router saw this long ago still counts as current. */
26
+ export const OBSERVED_FRESH_MS = 6 * 3_600_000;
27
+ /** How long an observed login is trusted before it is read again (its own expiry is not visible). */
28
+ export const OBSERVED_TTL_MS = 10 * 60_000;
29
+ /**
30
+ * The login the Claude app itself is using right now, as the router last saw it on a request it
31
+ * carried (router observedClaudeCodeAuth, `claude-auth-observed.json`), or null when there is none
32
+ * recent. The keychain item belongs to the `claude` CLI and is refreshed only while the CLI runs:
33
+ * working in the desktop app alone left it expired, then emptied (accessToken "", expiresAt 0,
34
+ * 2026-10-20 21:28), and the phone was told the login had expired while the app worked fine.
35
+ */
36
+ export function loadObservedAuth(now = Date.now(), home = process.env.CLAUDERIPPLE_HOME ?? path.join(os.homedir(), ".clauderipple")) {
37
+ try {
38
+ const seen = object(JSON.parse(fs.readFileSync(path.join(home, "claude-auth-observed.json"), "utf8")));
39
+ const token = typeof seen?.authorization === "string" ? /^Bearer\s+(\S+)$/.exec(seen.authorization)?.[1] : undefined;
40
+ if (!token || typeof seen?.observedAt !== "number" || now - seen.observedAt > OBSERVED_FRESH_MS)
41
+ return null;
42
+ return { accessToken: token, expiresAt: now + OBSERVED_TTL_MS };
43
+ }
44
+ catch {
45
+ return null;
46
+ }
47
+ }
25
48
  export function loadBridgeAuth() {
26
49
  if (process.platform !== "darwin")
27
50
  throw new Error(DESKTOP_BLOCKED);
51
+ const now = Date.now();
52
+ let organizationUuid;
28
53
  try {
29
- const credentials = object(JSON.parse(execFileSync("security", ["find-generic-password", "-a", process.env.USER ?? os.userInfo().username, "-s", "Claude Code-credentials", "-w"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] })));
30
- const oauth = object(credentials?.claudeAiOauth);
31
54
  const account = object(object(JSON.parse(fs.readFileSync(path.join(os.homedir(), ".claude.json"), "utf8")))?.oauthAccount);
32
- if (typeof oauth?.accessToken !== "string" || typeof oauth.expiresAt !== "number" || typeof account?.organizationUuid !== "string")
55
+ if (typeof account?.organizationUuid !== "string")
33
56
  throw new Error();
34
- return { accessToken: oauth.accessToken, expiresAt: oauth.expiresAt, organizationUuid: account.organizationUuid };
57
+ organizationUuid = account.organizationUuid;
35
58
  }
36
59
  catch {
37
60
  throw new BridgeAuthError("이 컴퓨터의 Claude 로그인 정보를 읽을 수 없습니다.");
38
61
  }
62
+ let keychain = null;
63
+ try {
64
+ const credentials = object(JSON.parse(execFileSync("security", ["find-generic-password", "-a", process.env.USER ?? os.userInfo().username, "-s", "Claude Code-credentials", "-w"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] })));
65
+ const oauth = object(credentials?.claudeAiOauth);
66
+ if (typeof oauth?.accessToken === "string" && oauth.accessToken && typeof oauth.expiresAt === "number")
67
+ keychain = { accessToken: oauth.accessToken, expiresAt: oauth.expiresAt };
68
+ }
69
+ catch { /* the observed login may still serve */ }
70
+ // The CLI's login while it is good; else the one the Claude app is working with; else the
71
+ // CLI's expired one, so the phone is told it expired.
72
+ if (keychain && keychain.expiresAt - now > 5 * 60_000)
73
+ return { ...keychain, organizationUuid };
74
+ const observed = loadObservedAuth(now);
75
+ if (observed)
76
+ return { ...observed, organizationUuid };
77
+ if (keychain)
78
+ return { ...keychain, organizationUuid };
79
+ throw new BridgeAuthError(LOGIN_EXPIRED);
39
80
  }
40
81
  /** `slash_commands` minus `terminal_slash_commands`, which phone UIs should hide (SDK init docs). */
41
82
  export function slashCommands(init) {