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.
- package/CHANGELOG.md +59 -0
- package/README.es.md +45 -0
- package/README.ja.md +17 -0
- package/README.ko.md +31 -0
- package/README.md +39 -0
- package/README.pt-BR.md +45 -0
- package/README.zh-CN.md +17 -0
- package/dist/app/assets/icon.icns +0 -0
- package/dist/app/assets/icon.ico +0 -0
- package/dist/app/assets/icon.png +0 -0
- package/dist/app/assets/trayDirectTemplate.png +0 -0
- package/dist/app/assets/trayDirectTemplate@2x.png +0 -0
- package/dist/app/assets/trayDownTemplate.png +0 -0
- package/dist/app/assets/trayDownTemplate@2x.png +0 -0
- package/dist/app/assets/trayTemplate.png +0 -0
- package/dist/app/assets/trayTemplate@2x.png +0 -0
- package/dist/app/assets/trayWarnTemplate.png +0 -0
- package/dist/app/assets/trayWarnTemplate@2x.png +0 -0
- package/dist/app/assets/trayWin.png +0 -0
- package/dist/app/assets/trayWin@2x.png +0 -0
- package/dist/app/assets/trayWinDirect.png +0 -0
- package/dist/app/assets/trayWinDirect@2x.png +0 -0
- package/dist/app/assets/trayWinDown.png +0 -0
- package/dist/app/assets/trayWinDown@2x.png +0 -0
- package/dist/app/assets/trayWinWarn.png +0 -0
- package/dist/app/assets/trayWinWarn@2x.png +0 -0
- package/dist/app/dist/claude-desktop.js +67 -0
- package/dist/app/dist/main.js +423 -110
- package/dist/app/dist/preload.js +21 -0
- package/dist/app/dist/protocol.js +121 -0
- package/dist/app/dist/status-model.js +87 -0
- package/dist/app/dist/window.js +140 -0
- package/dist/app/package.json +1 -1
- package/dist/cli/src/awake-setup.js +314 -0
- package/dist/cli/src/bundle.js +9 -5
- package/dist/cli/src/direct.js +159 -0
- package/dist/cli/src/doctor.js +205 -0
- package/dist/cli/src/index.js +184 -31
- package/dist/cli/src/launchd.js +29 -20
- package/dist/cli/src/launcher.js +215 -0
- package/dist/cli/src/power.js +182 -0
- package/dist/cli/src/registration.js +157 -0
- package/dist/cli/src/schtasks.js +2 -0
- package/dist/cli/src/settings.js +24 -0
- package/dist/cli/src/tray.js +5 -2
- package/dist/mod/hooks/i18n.ts +237 -237
- package/dist/mod/hooks/register.tsx +4 -5
- package/dist/mod/hooks/session.ts +6 -2
- package/dist/mod/hooks/ui/agents.tsx +81 -36
- package/dist/mod/hooks/ui/view.ts +8 -5
- package/dist/mod/tests/ui.test.tsx +46 -13
- package/dist/mod/types/index.d.ts +0 -2
- package/dist/remote/src/bridge-client.js +45 -4
- package/dist/router/src/admin.js +105 -2
- package/dist/router/src/awake-win.js +590 -0
- package/dist/router/src/awake.js +432 -0
- package/dist/router/src/config.js +64 -0
- package/dist/router/src/health.js +86 -3
- package/dist/router/src/hold.js +107 -0
- package/dist/router/src/index.js +69 -6
- package/dist/router/src/ingress/server.js +26 -4
- package/dist/router/src/power.js +107 -0
- package/dist/router/src/providers/google/antigravity.js +73 -10
- package/dist/router/src/providers/google/index.js +168 -17
- package/dist/router/src/providers/google/thoughtSignatures.js +13 -0
- package/dist/router/src/providers/google/transport.js +1 -1
- package/dist/router/src/providers/google/usage.js +71 -26
- package/dist/router/src/providers/retry.js +4 -1
- package/dist/router/src/proxy.js +50 -21
- package/dist/router/src/tether-win.js +211 -0
- package/dist/router/src/tether.js +712 -0
- package/dist/router/src/version.js +1 -1
- package/dist/ui/app.js +649 -2
- package/dist/ui/i18n.js +494 -77
- package/dist/ui/index.html +9 -1
- package/dist/ui/style.css +138 -0
- package/docs/ARCHITECTURE.md +294 -6
- 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
|
-
|
|
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($,
|
|
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),
|
|
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
|
|
72
|
-
|
|
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
|
-
/**
|
|
47
|
-
|
|
48
|
-
|
|
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;
|
|
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
|
|
169
|
-
*
|
|
170
|
-
*
|
|
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[],
|
|
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
|
-
|
|
179
|
-
|
|
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)) + (
|
|
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
|
|
249
|
-
*
|
|
250
|
-
*
|
|
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'
|
|
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 =
|
|
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(
|
|
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 +
|
|
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}
|
|
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">{
|
|
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,
|
|
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,
|
|
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.
|
|
680
|
+
if (page.dropped > 0) {
|
|
648
681
|
items.push(
|
|
649
|
-
<Box key="
|
|
650
|
-
<
|
|
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,
|
|
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.
|
|
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
|
|
226
|
-
open?: { id: string; talk: AgentTalk;
|
|
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,
|
|
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')
|
|
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 }
|
|
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.' }] }
|
|
668
|
-
drawPane(on, 'talk-long', (el, surface) => renderAgents(el, frameOf(surface, 64), { agents: AGENTS, now: NOW, showDone: false, open: { id: 'a1', talk: { entries: long },
|
|
669
|
-
|
|
670
|
-
drawPane(on, 'talk-
|
|
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
|
-
//
|
|
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
|
|
691
|
-
await long_.
|
|
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', '
|
|
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 }
|
|
1193
|
-
drawPane(on, 'runs-open', (el, surface) => renderAgents(el, frameOf(surface, 64), { agents: [done], now: NOW, showDone: false, open: { id: 'a9', talk: { entries: talk },
|
|
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
|
|
55
|
+
if (typeof account?.organizationUuid !== "string")
|
|
33
56
|
throw new Error();
|
|
34
|
-
|
|
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) {
|