linkshell-cli 0.10.53 → 0.10.54
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/dist/cli/tsconfig.tsbuildinfo +1 -1
- package/dist/gateway/src/poll.d.ts +41 -1
- package/dist/gateway/src/poll.js +116 -26
- package/dist/gateway/src/poll.js.map +1 -1
- package/dist/gateway/src/relay.d.ts +2 -0
- package/dist/gateway/src/relay.js +11 -0
- package/dist/gateway/src/relay.js.map +1 -1
- package/dist/gateway/src/serve.d.ts +4 -0
- package/dist/gateway/src/serve.js +2 -1
- package/dist/gateway/src/serve.js.map +1 -1
- package/dist/host/src/gateway.d.ts +8 -0
- package/dist/host/src/gateway.js +42 -1
- package/dist/host/src/gateway.js.map +1 -1
- package/dist/wire/src/watch.d.ts +46 -3
- package/dist/wire/src/watch.js +311 -90
- package/dist/wire/src/watch.js.map +1 -1
- package/package.json +4 -4
package/dist/wire/src/watch.js
CHANGED
|
@@ -46,8 +46,9 @@ export const watchTimelineItemSchema = z.object({
|
|
|
46
46
|
"error",
|
|
47
47
|
]),
|
|
48
48
|
title: z.string().max(120).optional(),
|
|
49
|
-
|
|
50
|
-
|
|
49
|
+
// The session the watch has open carries several KB per item (see watchSnapshotContent).
|
|
50
|
+
text: z.string().max(8000),
|
|
51
|
+
detail: z.string().max(4000).optional(),
|
|
51
52
|
status: z.enum(["running", "completed", "failed", "waiting"]).optional(),
|
|
52
53
|
filePath: z.string().max(200).optional(),
|
|
53
54
|
diffAdded: z.number().int().min(0).optional(),
|
|
@@ -119,6 +120,8 @@ export const watchSnapshotSchema = z.object({
|
|
|
119
120
|
computerKey: z.string().max(512),
|
|
120
121
|
computerName: z.string().max(80),
|
|
121
122
|
online: z.boolean(),
|
|
123
|
+
/** The session the watch last opened: it keeps the largest share of the byte budget. */
|
|
124
|
+
focusedSessionId: z.string().min(1).max(128).optional(),
|
|
122
125
|
statusLabel: z.string().max(80),
|
|
123
126
|
waitingCount: z.number().int().min(0),
|
|
124
127
|
runningCount: z.number().int().min(0),
|
|
@@ -130,6 +133,17 @@ export const watchSnapshotSchema = z.object({
|
|
|
130
133
|
sessions: z.array(watchSessionSchema).max(12),
|
|
131
134
|
terminals: z.array(watchTerminalSchema).max(8),
|
|
132
135
|
});
|
|
136
|
+
export const WATCH_PROMPT_MAX = 8000;
|
|
137
|
+
/**
|
|
138
|
+
* WCSession's applicationContext and sendMessage carry about 65 KB; the rest is
|
|
139
|
+
* headroom for the envelope and for Swift's re-serialization of the JSON.
|
|
140
|
+
*/
|
|
141
|
+
export const WATCH_SNAPSHOT_BYTE_LIMIT = 56_000;
|
|
142
|
+
/** Snapshot sessions and terminals; the phone attaches exactly the terminals it sends. */
|
|
143
|
+
export const WATCH_SESSION_LIMIT = 10;
|
|
144
|
+
export const WATCH_TERMINAL_LIMIT = 6;
|
|
145
|
+
export const WATCH_FOCUSED_ITEMS = 40;
|
|
146
|
+
export const WATCH_MAX_ITEMS = 60;
|
|
133
147
|
export const watchCommandSchema = z.discriminatedUnion("kind", [
|
|
134
148
|
z.object({
|
|
135
149
|
id: z.string().min(1).max(128),
|
|
@@ -141,7 +155,8 @@ export const watchCommandSchema = z.discriminatedUnion("kind", [
|
|
|
141
155
|
kind: z.literal("prompt"),
|
|
142
156
|
computerKey: z.string().min(1).max(512),
|
|
143
157
|
sessionId: z.string().min(1).max(128),
|
|
144
|
-
|
|
158
|
+
// Dictation runs long; the host's sessions.prompt has no limit of its own.
|
|
159
|
+
text: z.string().min(1).max(WATCH_PROMPT_MAX),
|
|
145
160
|
now: z.boolean().optional(),
|
|
146
161
|
}),
|
|
147
162
|
z.object({
|
|
@@ -188,7 +203,7 @@ export const watchCommandSchema = z.discriminatedUnion("kind", [
|
|
|
188
203
|
computerKey: z.string().min(1).max(512),
|
|
189
204
|
agent: z.string().min(1).max(48),
|
|
190
205
|
cwd: z.string().min(1).max(240),
|
|
191
|
-
prompt: z.string().max(
|
|
206
|
+
prompt: z.string().max(WATCH_PROMPT_MAX).optional(),
|
|
192
207
|
}),
|
|
193
208
|
z.object({
|
|
194
209
|
id: z.string().min(1).max(128),
|
|
@@ -208,7 +223,7 @@ export const watchCommandSchema = z.discriminatedUnion("kind", [
|
|
|
208
223
|
kind: z.literal("terminal-input"),
|
|
209
224
|
computerKey: z.string().min(1).max(512),
|
|
210
225
|
terminalId: z.string().min(1).max(128),
|
|
211
|
-
data: z.string().min(1).max(
|
|
226
|
+
data: z.string().min(1).max(WATCH_PROMPT_MAX),
|
|
212
227
|
}),
|
|
213
228
|
z.object({
|
|
214
229
|
id: z.string().min(1).max(128),
|
|
@@ -236,6 +251,106 @@ export const DEFAULT_WATCH_QUICK_PROMPTS = [
|
|
|
236
251
|
function cut(value, max) {
|
|
237
252
|
return [...(value ?? "")].slice(0, max).join("");
|
|
238
253
|
}
|
|
254
|
+
function utf8Length(value) {
|
|
255
|
+
let bytes = 0;
|
|
256
|
+
for (const ch of value) {
|
|
257
|
+
const code = ch.codePointAt(0);
|
|
258
|
+
bytes += code < 0x80 ? 1 : code < 0x800 ? 2 : code < 0x10000 ? 3 : 4;
|
|
259
|
+
}
|
|
260
|
+
return bytes;
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Cuts to a UTF-8 byte budget: the WatchConnectivity limit is in bytes, and CJK text
|
|
264
|
+
* takes three a character, so a character count would let it overflow threefold.
|
|
265
|
+
*/
|
|
266
|
+
export function cutWatchBytes(value, maxBytes) {
|
|
267
|
+
const text = value ?? "";
|
|
268
|
+
// Every character is at most 4 bytes, so short strings need no counting.
|
|
269
|
+
if (text.length * 4 <= maxBytes || utf8Length(text) <= maxBytes)
|
|
270
|
+
return text;
|
|
271
|
+
const budget = Math.max(0, maxBytes - 3);
|
|
272
|
+
let used = 0;
|
|
273
|
+
let end = 0;
|
|
274
|
+
for (const ch of text) {
|
|
275
|
+
const code = ch.codePointAt(0);
|
|
276
|
+
const size = code < 0x80 ? 1 : code < 0x800 ? 2 : code < 0x10000 ? 3 : 4;
|
|
277
|
+
if (used + size > budget)
|
|
278
|
+
break;
|
|
279
|
+
used += size;
|
|
280
|
+
end += ch.length;
|
|
281
|
+
}
|
|
282
|
+
return `${text.slice(0, end).trimEnd()}…`;
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Message text for the watch's conversation view. Unlike plainPreview (one line, for
|
|
286
|
+
* lists and Live Activities) it keeps line breaks and markdown: the watch renders
|
|
287
|
+
* answers as markdown, code blocks included. Only whitespace noise is tidied.
|
|
288
|
+
*/
|
|
289
|
+
export function watchTimelineText(text) {
|
|
290
|
+
return text
|
|
291
|
+
.replace(/\r\n?/g, "\n")
|
|
292
|
+
.replace(/\t/g, " ")
|
|
293
|
+
.replace(/[ \u00a0]+$/gm, "")
|
|
294
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
295
|
+
.trim();
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Line-kept text for rows the watch shows as plain Text (thoughts): inline markup
|
|
299
|
+
* outside code (emphasis, headings, links) is unwrapped; code lines stay as they are.
|
|
300
|
+
*/
|
|
301
|
+
export function watchPlainText(text) {
|
|
302
|
+
let inFence = false;
|
|
303
|
+
return watchTimelineText(text)
|
|
304
|
+
.split("\n")
|
|
305
|
+
.map((line) => {
|
|
306
|
+
if (/^\s*(```|~~~)/.test(line)) {
|
|
307
|
+
inFence = !inFence;
|
|
308
|
+
return line.trim();
|
|
309
|
+
}
|
|
310
|
+
if (inFence)
|
|
311
|
+
return line;
|
|
312
|
+
return line
|
|
313
|
+
.replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1")
|
|
314
|
+
.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
|
|
315
|
+
.replace(/^(\s{0,3})#{1,6}\s+/, "$1")
|
|
316
|
+
.replace(/^(\s*)[-*+]\s+/, "$1• ")
|
|
317
|
+
.replace(/(\*\*|__|~~)(\S(?:.*?\S)?)\1/g, "$2")
|
|
318
|
+
.replace(/`([^`\n]+)`/g, "$1");
|
|
319
|
+
})
|
|
320
|
+
.join("\n");
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* Like cutWatchBytes, but keeps the end too: a long answer's conclusion is at the bottom.
|
|
324
|
+
* Three quarters of the budget go to the beginning, the rest to the end.
|
|
325
|
+
*/
|
|
326
|
+
export function cutWatchBytesMiddle(value, maxBytes) {
|
|
327
|
+
const text = value ?? "";
|
|
328
|
+
if (text.length * 4 <= maxBytes || utf8Length(text) <= maxBytes)
|
|
329
|
+
return text;
|
|
330
|
+
const chars = [...text];
|
|
331
|
+
const take = (from, step, budget) => {
|
|
332
|
+
let used = 0;
|
|
333
|
+
let count = 0;
|
|
334
|
+
for (let i = from; i >= 0 && i < chars.length; i += step) {
|
|
335
|
+
const size = utf8Length(chars[i]);
|
|
336
|
+
if (used + size > budget)
|
|
337
|
+
break;
|
|
338
|
+
used += size;
|
|
339
|
+
count += 1;
|
|
340
|
+
}
|
|
341
|
+
return count;
|
|
342
|
+
};
|
|
343
|
+
const marker = (omitted) => `\n…(中间略去 ${omitted} 字)…\n`;
|
|
344
|
+
const room = maxBytes - utf8Length(marker(chars.length));
|
|
345
|
+
if (room < 200)
|
|
346
|
+
return cutWatchBytes(text, maxBytes);
|
|
347
|
+
const headCount = take(0, 1, Math.floor(room * 0.75));
|
|
348
|
+
const tailCount = take(chars.length - 1, -1, room - utf8Length(chars.slice(0, headCount).join("")));
|
|
349
|
+
return `${chars.slice(0, headCount).join("").trimEnd()}${marker(chars.length - headCount - tailCount)}${chars
|
|
350
|
+
.slice(chars.length - tailCount)
|
|
351
|
+
.join("")
|
|
352
|
+
.trimStart()}`;
|
|
353
|
+
}
|
|
239
354
|
function baseName(path) {
|
|
240
355
|
const parts = path.split(/[\\/]/).filter(Boolean);
|
|
241
356
|
return parts[parts.length - 1] ?? path;
|
|
@@ -347,7 +462,9 @@ export function buildWatchSession(session, online, computerKey = "", options = {
|
|
|
347
462
|
...(options.planProgress.currentStep ? { currentStep: cut(options.planProgress.currentStep, 100) } : {}),
|
|
348
463
|
}
|
|
349
464
|
: undefined;
|
|
350
|
-
const maxItems =
|
|
465
|
+
const maxItems = options.focused
|
|
466
|
+
? Math.max(WATCH_FOCUSED_ITEMS, Math.min(options.maxItems ?? 0, WATCH_MAX_ITEMS))
|
|
467
|
+
: Math.max(4, Math.min(options.maxItems ?? 14, 56));
|
|
351
468
|
const rawItems = options.items?.length ? options.items : fallbackTimelineItems(session, commandPreview, preview);
|
|
352
469
|
const items = maxItems > 14
|
|
353
470
|
? rawItems.slice(-maxItems)
|
|
@@ -402,6 +519,29 @@ export function buildWatchTerminal(terminal, online, computerKey = "", outputPre
|
|
|
402
519
|
deepLink: liveActivityDetailLink(computerKey, "terminal", terminal.id),
|
|
403
520
|
};
|
|
404
521
|
}
|
|
522
|
+
/** Failed, then busy, then most recently active: the order the watch lists terminals in. */
|
|
523
|
+
export function sortWatchTerminals(terminals) {
|
|
524
|
+
const rank = (s) => (s.failed ? 0 : s.busy ? 1 : 2);
|
|
525
|
+
return [...terminals].sort((a, b) => {
|
|
526
|
+
const ra = rank(terminalState(a));
|
|
527
|
+
const rb = rank(terminalState(b));
|
|
528
|
+
if (ra !== rb)
|
|
529
|
+
return ra - rb;
|
|
530
|
+
if (a.activeAt !== b.activeAt)
|
|
531
|
+
return b.activeAt - a.activeAt;
|
|
532
|
+
return a.id.localeCompare(b.id);
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
/** The first `max`, with the focused one swapped in for the last when it falls outside. */
|
|
536
|
+
function keepFocused(list, max, focusedId) {
|
|
537
|
+
const head = list.slice(0, max);
|
|
538
|
+
if (!focusedId || head.some((item) => item.id === focusedId))
|
|
539
|
+
return head;
|
|
540
|
+
const focused = list.find((item) => item.id === focusedId);
|
|
541
|
+
if (!focused)
|
|
542
|
+
return head;
|
|
543
|
+
return head.length < max ? [...head, focused] : [...head.slice(0, max - 1), focused];
|
|
544
|
+
}
|
|
405
545
|
/**
|
|
406
546
|
* Builds the structured Apple Watch companion snapshot for the selected computer.
|
|
407
547
|
* Prioritizes sessions needing approval/input, then errors, then running, then pinned/recent.
|
|
@@ -442,16 +582,10 @@ export function buildWatchSnapshot(computerName, sessions, terminals, online, op
|
|
|
442
582
|
return b.updatedAt - a.updatedAt;
|
|
443
583
|
return a.id.localeCompare(b.id);
|
|
444
584
|
});
|
|
445
|
-
const sortedTerminals =
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
if (rank(sa) !== rank(sb))
|
|
450
|
-
return rank(sa) - rank(sb);
|
|
451
|
-
if (a.activeAt !== b.activeAt)
|
|
452
|
-
return b.activeAt - a.activeAt;
|
|
453
|
-
return a.id.localeCompare(b.id);
|
|
454
|
-
});
|
|
585
|
+
const sortedTerminals = sortWatchTerminals(terminals);
|
|
586
|
+
const focusedId = options.focusedSessionId && liveSessions.some((s) => s.id === options.focusedSessionId)
|
|
587
|
+
? options.focusedSessionId
|
|
588
|
+
: undefined;
|
|
455
589
|
const statusLabel = !online
|
|
456
590
|
? "连接中断 · 正在重连"
|
|
457
591
|
: waitingCount > 0
|
|
@@ -492,9 +626,8 @@ export function buildWatchSnapshot(computerName, sessions, terminals, online, op
|
|
|
492
626
|
addWorkspace(s.worktree?.source ?? s.cwd);
|
|
493
627
|
for (const t of sortedTerminals)
|
|
494
628
|
addWorkspace(t.cwd);
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
: ["claude", "codex", "gemini", "cursor", "opencode"])
|
|
629
|
+
// Only what the computer reported: an invented agent would fail when the watch starts it.
|
|
630
|
+
const availableAgents = (options.availableAgents ?? [])
|
|
498
631
|
.map((id) => cut(id.trim(), 48))
|
|
499
632
|
.filter(Boolean)
|
|
500
633
|
.slice(0, 10);
|
|
@@ -504,6 +637,7 @@ export function buildWatchSnapshot(computerName, sessions, terminals, online, op
|
|
|
504
637
|
computerKey: cut(computerKey, 512),
|
|
505
638
|
computerName: cut(computerName || "电脑", 64),
|
|
506
639
|
online,
|
|
640
|
+
...(focusedId ? { focusedSessionId: focusedId } : {}),
|
|
507
641
|
statusLabel: cut(statusLabel, 80),
|
|
508
642
|
waitingCount,
|
|
509
643
|
runningCount,
|
|
@@ -512,7 +646,7 @@ export function buildWatchSnapshot(computerName, sessions, terminals, online, op
|
|
|
512
646
|
availableAgents,
|
|
513
647
|
...(workspaces.length ? { workspaces } : {}),
|
|
514
648
|
computers: watchComputers,
|
|
515
|
-
sessions: sortedSessions
|
|
649
|
+
sessions: keepFocused(sortedSessions, WATCH_SESSION_LIMIT, focusedId).map((session) => {
|
|
516
650
|
const goal = goals[session.id];
|
|
517
651
|
return buildWatchSession(session, online, computerKey, {
|
|
518
652
|
pinned: pinnedSet.has(session.id),
|
|
@@ -524,24 +658,122 @@ export function buildWatchSnapshot(computerName, sessions, terminals, online, op
|
|
|
524
658
|
maxItems: itemLimits[session.id],
|
|
525
659
|
mode: modes[session.id],
|
|
526
660
|
configOptions: configs[session.id],
|
|
661
|
+
focused: session.id === focusedId,
|
|
527
662
|
});
|
|
528
663
|
}),
|
|
529
|
-
terminals: sortedTerminals.slice(0,
|
|
664
|
+
terminals: sortedTerminals.slice(0, WATCH_TERMINAL_LIMIT).map((terminal) => buildWatchTerminal(terminal, online, computerKey, terminalOutputs[terminal.id])),
|
|
530
665
|
};
|
|
531
666
|
return watchSnapshotContent(snapshot);
|
|
532
667
|
}
|
|
668
|
+
const ITEM_TEXT_BYTES = 600;
|
|
669
|
+
const ITEM_DETAIL_BYTES = 360;
|
|
670
|
+
const FOCUSED_TEXT_BYTES = 6_000;
|
|
671
|
+
const FOCUSED_DETAIL_BYTES = 2_400;
|
|
672
|
+
const IMAGE_MAX = 12_000;
|
|
673
|
+
/** What the open conversation may take before the other sessions are squeezed. */
|
|
674
|
+
const FOCUSED_SHARE = 0.65;
|
|
675
|
+
function jsonBytes(value) {
|
|
676
|
+
return utf8Length(JSON.stringify(value));
|
|
677
|
+
}
|
|
678
|
+
function boundItem(item, focused) {
|
|
679
|
+
const { imageBase64, imageMimeType, ...rest } = item;
|
|
680
|
+
// A cut base64 string is a broken picture, so an oversized one is left out whole.
|
|
681
|
+
const image = imageBase64 && imageBase64.length <= IMAGE_MAX ? imageBase64 : undefined;
|
|
682
|
+
return {
|
|
683
|
+
...rest,
|
|
684
|
+
id: cut(item.id, 120),
|
|
685
|
+
...(item.title ? { title: cut(item.title, 80) } : {}),
|
|
686
|
+
text: focused ? cutWatchBytesMiddle(item.text, FOCUSED_TEXT_BYTES) : cutWatchBytes(item.text, ITEM_TEXT_BYTES),
|
|
687
|
+
...(item.detail ? { detail: cutWatchBytes(item.detail, focused ? FOCUSED_DETAIL_BYTES : ITEM_DETAIL_BYTES) } : {}),
|
|
688
|
+
...(item.filePath ? { filePath: cut(item.filePath, 160) } : {}),
|
|
689
|
+
...(item.subagentType ? { subagentType: cut(item.subagentType, 48) } : {}),
|
|
690
|
+
...(item.subagentName ? { subagentName: cut(item.subagentName, 48) } : {}),
|
|
691
|
+
...(item.subagentModel ? { subagentModel: cut(item.subagentModel, 64) } : {}),
|
|
692
|
+
...(image ? { imageBase64: image, ...(imageMimeType ? { imageMimeType: cut(imageMimeType, 48) } : {}) } : {}),
|
|
693
|
+
};
|
|
694
|
+
}
|
|
695
|
+
function withoutImage(item) {
|
|
696
|
+
if (!item.imageBase64)
|
|
697
|
+
return item;
|
|
698
|
+
const { imageBase64: _image, imageMimeType: _mime, ...rest } = item;
|
|
699
|
+
return rest;
|
|
700
|
+
}
|
|
533
701
|
/**
|
|
534
|
-
*
|
|
535
|
-
*
|
|
702
|
+
* The newest items that fit in `budget` bytes. The newest message is the one being
|
|
703
|
+
* read, so it is cut rather than dropped; only the newest picture is kept; and the
|
|
704
|
+
* latest question from the user stays when the tail has none, so the answer has context.
|
|
536
705
|
*/
|
|
537
|
-
|
|
706
|
+
function fitItems(items, budget) {
|
|
707
|
+
const kept = [];
|
|
708
|
+
let used = 2;
|
|
709
|
+
let imageKept = false;
|
|
710
|
+
let index = items.length - 1;
|
|
711
|
+
for (; index >= 0; index -= 1) {
|
|
712
|
+
let item = items[index];
|
|
713
|
+
if (imageKept)
|
|
714
|
+
item = withoutImage(item);
|
|
715
|
+
let size = jsonBytes(item) + 1;
|
|
716
|
+
if (used + size > budget && item.imageBase64) {
|
|
717
|
+
item = withoutImage(item);
|
|
718
|
+
size = jsonBytes(item) + 1;
|
|
719
|
+
}
|
|
720
|
+
if (used + size > budget) {
|
|
721
|
+
if (kept.length > 0)
|
|
722
|
+
break;
|
|
723
|
+
let room = budget - used - jsonBytes({ ...item, text: "" }) - 1;
|
|
724
|
+
while (room >= 120 && used + size > budget) {
|
|
725
|
+
item = { ...item, text: cutWatchBytes(item.text, room) };
|
|
726
|
+
size = jsonBytes(item) + 1;
|
|
727
|
+
room = Math.floor(room * 0.8);
|
|
728
|
+
}
|
|
729
|
+
if (used + size > budget)
|
|
730
|
+
break;
|
|
731
|
+
}
|
|
732
|
+
if (item.imageBase64)
|
|
733
|
+
imageKept = true;
|
|
734
|
+
kept.push(item);
|
|
735
|
+
used += size;
|
|
736
|
+
}
|
|
737
|
+
if (index >= 0 && !kept.some((item) => item.kind === "user")) {
|
|
738
|
+
const earlier = items.slice(0, index + 1).reverse().find((item) => item.kind === "user");
|
|
739
|
+
if (earlier) {
|
|
740
|
+
const asked = withoutImage({ ...earlier, text: cutWatchBytes(earlier.text, ITEM_TEXT_BYTES) });
|
|
741
|
+
const size = jsonBytes(asked) + 1;
|
|
742
|
+
if (used + size <= budget)
|
|
743
|
+
kept.push(asked);
|
|
744
|
+
else if (kept.length > 1 && jsonBytes(kept[kept.length - 1]) + 1 + budget - used >= size) {
|
|
745
|
+
kept[kept.length - 1] = asked;
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
return kept.reverse();
|
|
750
|
+
}
|
|
751
|
+
function squeezeSession(s, cap, textBytes) {
|
|
752
|
+
if (!s.items)
|
|
753
|
+
return s;
|
|
754
|
+
const items = sliceTimelinePreservingUsers(s.items, cap).map((item) => {
|
|
755
|
+
const { detail: _detail, ...rest } = withoutImage(item);
|
|
756
|
+
return { ...rest, text: cutWatchBytes(item.text, textBytes) };
|
|
757
|
+
});
|
|
758
|
+
return { ...s, items, hasEarlier: s.hasEarlier || items.length < s.items.length || undefined };
|
|
759
|
+
}
|
|
760
|
+
/**
|
|
761
|
+
* Bounds a WatchSnapshot to WATCH_SNAPSHOT_BYTE_LIMIT UTF-8 bytes, never truncating
|
|
762
|
+
* action IDs. The session the watch has open gets the largest share: pictures and the
|
|
763
|
+
* other sessions' items go first, and whatever their trimming frees goes back to it.
|
|
764
|
+
*/
|
|
765
|
+
export function watchSnapshotContent(snapshot, limit = WATCH_SNAPSHOT_BYTE_LIMIT) {
|
|
766
|
+
const focusedId = snapshot.focusedSessionId && snapshot.sessions.some((s) => s.id === snapshot.focusedSessionId)
|
|
767
|
+
? snapshot.focusedSessionId
|
|
768
|
+
: undefined;
|
|
769
|
+
const isFocused = (s) => s.id === focusedId;
|
|
770
|
+
const { focusedSessionId: _focused, ...base } = snapshot;
|
|
538
771
|
const next = {
|
|
539
|
-
...
|
|
772
|
+
...base,
|
|
773
|
+
...(focusedId ? { focusedSessionId: focusedId } : {}),
|
|
540
774
|
computerName: cut(snapshot.computerName, 64),
|
|
541
775
|
statusLabel: cut(snapshot.statusLabel, 80),
|
|
542
|
-
...(snapshot.availableAgents
|
|
543
|
-
? { availableAgents: snapshot.availableAgents.slice(0, 10).map((a) => cut(a, 48)) }
|
|
544
|
-
: {}),
|
|
776
|
+
...(snapshot.availableAgents ? { availableAgents: snapshot.availableAgents.slice(0, 10).map((a) => cut(a, 48)) } : {}),
|
|
545
777
|
...(snapshot.workspaces?.length
|
|
546
778
|
? {
|
|
547
779
|
workspaces: snapshot.workspaces.slice(0, 8).map((w) => ({
|
|
@@ -551,25 +783,16 @@ export function watchSnapshotContent(snapshot) {
|
|
|
551
783
|
}
|
|
552
784
|
: {}),
|
|
553
785
|
computers: snapshot.computers.slice(0, 12).map((c) => ({ ...c, name: cut(c.name, 64) })),
|
|
554
|
-
sessions: snapshot.sessions
|
|
786
|
+
sessions: keepFocused(snapshot.sessions, WATCH_SESSION_LIMIT, focusedId).map((s) => {
|
|
787
|
+
const focused = isFocused(s);
|
|
555
788
|
const validActions = (s.actions ?? []).filter((a) => watchActionSchema.safeParse(a).success).slice(0, 3);
|
|
556
789
|
const rawValidItems = (s.items ?? []).filter((item) => Boolean(item.id && item.text?.trim()));
|
|
557
|
-
const itemCap =
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
...(item.title ? { title: cut(item.title, 80) } : {}),
|
|
564
|
-
text: cut(item.text, 360),
|
|
565
|
-
...(item.detail ? { detail: cut(item.detail, 360) } : {}),
|
|
566
|
-
...(item.filePath ? { filePath: cut(item.filePath, 160) } : {}),
|
|
567
|
-
...(item.subagentType ? { subagentType: cut(item.subagentType, 48) } : {}),
|
|
568
|
-
...(item.subagentName ? { subagentName: cut(item.subagentName, 48) } : {}),
|
|
569
|
-
...(item.subagentModel ? { subagentModel: cut(item.subagentModel, 64) } : {}),
|
|
570
|
-
...(item.imageBase64 ? { imageBase64: item.imageBase64.slice(0, 12_000) } : {}),
|
|
571
|
-
...(item.imageMimeType ? { imageMimeType: cut(item.imageMimeType, 48) } : {}),
|
|
572
|
-
}));
|
|
790
|
+
const itemCap = focused
|
|
791
|
+
? WATCH_MAX_ITEMS
|
|
792
|
+
: rawValidItems.length > 14
|
|
793
|
+
? Math.min(rawValidItems.length, 48)
|
|
794
|
+
: 14;
|
|
795
|
+
const validItems = (itemCap > 14 ? rawValidItems.slice(-itemCap) : sliceTimelinePreservingUsers(rawValidItems, itemCap)).map((item) => boundItem(item, focused));
|
|
573
796
|
const validConfigs = (s.configOptions ?? []).slice(0, 4).map((opt) => ({
|
|
574
797
|
...opt,
|
|
575
798
|
id: cut(opt.id, 48),
|
|
@@ -609,10 +832,10 @@ export function watchSnapshotContent(snapshot) {
|
|
|
609
832
|
...(validActions.length ? { actions: validActions } : { actions: undefined }),
|
|
610
833
|
hasMoreActions: s.hasMoreActions || validActions.length < (s.actions?.length ?? 0) || undefined,
|
|
611
834
|
...(hasEarlier ? { hasEarlier: true } : { hasEarlier: undefined }),
|
|
612
|
-
...(validItems.length ? { items: validItems } : {}),
|
|
835
|
+
...(validItems.length ? { items: validItems } : { items: undefined }),
|
|
613
836
|
};
|
|
614
837
|
}),
|
|
615
|
-
terminals: snapshot.terminals.slice(0,
|
|
838
|
+
terminals: snapshot.terminals.slice(0, WATCH_TERMINAL_LIMIT).map((t) => ({
|
|
616
839
|
...t,
|
|
617
840
|
title: cut(t.title, 80),
|
|
618
841
|
subtitle: cut(t.subtitle, 120),
|
|
@@ -622,62 +845,60 @@ export function watchSnapshotContent(snapshot) {
|
|
|
622
845
|
...(t.outputPreview ? { outputPreview: cut(t.outputPreview, 1400) } : {}),
|
|
623
846
|
})),
|
|
624
847
|
};
|
|
625
|
-
const size = () =>
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
848
|
+
const size = () => jsonBytes(next);
|
|
849
|
+
const over = () => size() > limit;
|
|
850
|
+
const others = (change) => {
|
|
851
|
+
next.sessions = next.sessions.map((s) => (isFocused(s) ? s : change(s)));
|
|
852
|
+
};
|
|
853
|
+
const focusedItems = next.sessions.find(isFocused)?.items ?? [];
|
|
854
|
+
// Everything but the open conversation, counted with an empty one in its place.
|
|
855
|
+
const restBytes = () => jsonBytes({ ...next, sessions: next.sessions.map((s) => (isFocused(s) ? { ...s, items: [], hasEarlier: true } : s)) });
|
|
856
|
+
let focusedTrimmed = false;
|
|
857
|
+
const fitFocused = (budget) => {
|
|
858
|
+
if (!focusedItems.length)
|
|
859
|
+
return;
|
|
860
|
+
focusedTrimmed = true;
|
|
861
|
+
const items = fitItems(focusedItems, budget);
|
|
862
|
+
next.sessions = next.sessions.map((s) => isFocused(s)
|
|
863
|
+
? {
|
|
631
864
|
...s,
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
if (size() > 24_000) {
|
|
865
|
+
items: items.length ? items : undefined,
|
|
866
|
+
hasEarlier: s.hasEarlier || items.length < focusedItems.length || undefined,
|
|
867
|
+
}
|
|
868
|
+
: s);
|
|
869
|
+
};
|
|
870
|
+
if (over())
|
|
871
|
+
others((s) => (s.items ? { ...s, items: s.items.map(withoutImage) } : s));
|
|
872
|
+
if (over())
|
|
873
|
+
fitFocused(Math.max(Math.floor(limit * FOCUSED_SHARE), limit - restBytes()));
|
|
874
|
+
if (over())
|
|
875
|
+
others((s) => squeezeSession(s, 6, 300));
|
|
876
|
+
if (over()) {
|
|
645
877
|
next.terminals = next.terminals.map((t) => ({
|
|
646
878
|
...t,
|
|
647
879
|
outputPreview: t.outputPreview ? cut(t.outputPreview, 800) : undefined,
|
|
648
880
|
}));
|
|
649
881
|
}
|
|
650
|
-
if (
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
const cap = isExpanded ? 24 : idx < 3 ? 6 : 3;
|
|
654
|
-
return {
|
|
655
|
-
...s,
|
|
656
|
-
configOptions: idx < 2 || isExpanded ? s.configOptions : undefined,
|
|
657
|
-
hasEarlier: s.hasEarlier || (s.items?.length ?? 0) > cap || undefined,
|
|
658
|
-
items: s.items
|
|
659
|
-
? (isExpanded ? s.items.slice(-cap) : sliceTimelinePreservingUsers(s.items, cap)).map((i) => ({
|
|
660
|
-
...i,
|
|
661
|
-
text: cut(i.text, 160),
|
|
662
|
-
detail: undefined,
|
|
663
|
-
imageBase64: undefined,
|
|
664
|
-
}))
|
|
665
|
-
: undefined,
|
|
666
|
-
};
|
|
667
|
-
});
|
|
668
|
-
}
|
|
669
|
-
while (size() > 24_000 && next.terminals.length > 2)
|
|
882
|
+
if (over())
|
|
883
|
+
others((s) => ({ ...squeezeSession(s, 3, 160), configOptions: undefined }));
|
|
884
|
+
while (over() && next.terminals.length > 2)
|
|
670
885
|
next.terminals.pop();
|
|
671
|
-
while (
|
|
672
|
-
next.sessions.
|
|
673
|
-
|
|
674
|
-
|
|
886
|
+
while (over() && next.sessions.filter((s) => !isFocused(s)).length > 3) {
|
|
887
|
+
const lastOther = next.sessions.map(isFocused).lastIndexOf(false);
|
|
888
|
+
next.sessions.splice(lastOther, 1);
|
|
889
|
+
}
|
|
890
|
+
if (over()) {
|
|
891
|
+
others((s) => ({
|
|
675
892
|
...s,
|
|
676
893
|
detailPreview: s.detailPreview ? cut(s.detailPreview, 60) : undefined,
|
|
677
894
|
commandPreview: s.commandPreview ? cut(s.commandPreview, 60) : undefined,
|
|
678
895
|
items: undefined,
|
|
896
|
+
hasEarlier: s.items?.length ? true : s.hasEarlier,
|
|
679
897
|
}));
|
|
680
898
|
}
|
|
899
|
+
// Whatever squeezing the others freed goes back to the open conversation.
|
|
900
|
+
if (focusedTrimmed || over())
|
|
901
|
+
fitFocused(limit - restBytes());
|
|
681
902
|
return next;
|
|
682
903
|
}
|
|
683
904
|
/**
|