ccdeck 3.22.3 → 3.22.5
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/README.md +11 -3
- package/dist/web/assets/index-D0ZefIva.css +1 -0
- package/dist/web/assets/{index-BDUK5zhD.js → index-Dr2467D6.js} +39 -35
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/release-notes.json +6 -0
- package/src/server/index.mjs +54 -0
- package/src/server/macmon.mjs +9 -1
- package/src/server/output-watch.mjs +222 -0
- package/src/server/uv-bootstrap.mjs +6 -1
- package/dist/web/assets/index-CJYsv0lr.css +0 -1
package/dist/web/index.html
CHANGED
|
@@ -40,8 +40,8 @@
|
|
|
40
40
|
document.documentElement.setAttribute("data-theme", stored === "light" ? "light" : "dark");
|
|
41
41
|
})();
|
|
42
42
|
</script>
|
|
43
|
-
<script type="module" crossorigin src="/assets/index-
|
|
44
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
43
|
+
<script type="module" crossorigin src="/assets/index-Dr2467D6.js"></script>
|
|
44
|
+
<link rel="stylesheet" crossorigin href="/assets/index-D0ZefIva.css">
|
|
45
45
|
</head>
|
|
46
46
|
<body>
|
|
47
47
|
<div id="root"></div>
|
package/package.json
CHANGED
package/release-notes.json
CHANGED
|
@@ -394,5 +394,11 @@
|
|
|
394
394
|
"title": "🖥️ A first run now opens This machine too",
|
|
395
395
|
"body": "Accounts and usage already opened themselves the first time you ran the deck; the machine panel did not, and now does. It is the panel that answers \"is this box coping\" — cores, memory, load, temperature — and somebody meeting the deck for the first time cannot ask for a panel they do not know is there.\n\nIt only does this when you have never said otherwise. Close it once and it stays closed, on that browser, for good — the same rule the other two panels have always followed. Nothing reopens it behind you.\n\nWhile it is open it polls this machine once every few seconds, and it stops the moment you close it or switch tabs."
|
|
396
396
|
}
|
|
397
|
+
],
|
|
398
|
+
"3.22.4": [
|
|
399
|
+
{
|
|
400
|
+
"title": "📈 A card stops looking idle while it is thinking",
|
|
401
|
+
"body": "The activity chart on each card counted tool calls and nothing else, so the time an agent spends reading, reasoning and writing drew as a flat line — measured on this machine, 16.5% of all working time, more than the time spent inside the tools themselves. A session that was busy looked exactly like one that had died, and the only way to tell them apart was to wait and see whether anything ever appeared.\n\nIt marks that work now. A chart with bars in it means the agent is doing something, whether or not it has called a tool in the last minute; a flat chart means it genuinely is not. Hover it for the split — how many tool calls and how many blocks of thinking and writing landed in the last 60 seconds.\n\nNothing was added to the card and nothing new is polled. The deck already reads each session's transcript for its token counts; this reads the same file for what the model has finished writing, and a session that is quiet costs one file check per second and a half."
|
|
402
|
+
}
|
|
397
403
|
]
|
|
398
404
|
}
|
package/src/server/index.mjs
CHANGED
|
@@ -27,6 +27,9 @@ import { CODEX_HOME, CODEX_SESSIONS_DIR, STOP, walkRolloutDays } from "./codex-d
|
|
|
27
27
|
import { PRODUCT } from "./brand.mjs";
|
|
28
28
|
import { createBlockNotifier } from "./block-notify.mjs";
|
|
29
29
|
import { createActivity } from "./activity.mjs";
|
|
30
|
+
// What a session is producing between its tool calls — the 16.5% of measured
|
|
31
|
+
// time the hooks cannot see. See output-watch.mjs.
|
|
32
|
+
import { createOutputWatch } from "./output-watch.mjs";
|
|
30
33
|
import { AWAY_BOOT_GRACE_MS, AWAY_RECHECK_MS, AWAY_TICK_MS, awayGate, awayUpdateStep } from "./auto-update.mjs";
|
|
31
34
|
import { createPresence } from "./presence.mjs";
|
|
32
35
|
import { DEFAULTS as PREF_DEFAULTS, cleanAlias, isAliasKey, notificationsOn, notificationsVetoed, publicPrefs, readPrefs, writePrefs } from "./deck-prefs.mjs";
|
|
@@ -2781,7 +2784,49 @@ export function eventsSince(seq) {
|
|
|
2781
2784
|
const sessionTouchedAt = new Map(); // sid -> ms of the last event seen
|
|
2782
2785
|
const MAX_TRACKED_SESSIONS = 256;
|
|
2783
2786
|
|
|
2787
|
+
/** The transcript watch, and how far back a session stays worth polling.
|
|
2788
|
+
*
|
|
2789
|
+
* A session is polled while its last HOOK event is recent — and the window has
|
|
2790
|
+
* to be generous for exactly the reason this watch exists: a session that is
|
|
2791
|
+
* thinking has, by definition, not fired a hook. Measured on this machine's
|
|
2792
|
+
* log, the gaps with no event at all run to p99 53s and 155s at the longest,
|
|
2793
|
+
* so five minutes clears the whole measured distribution and still keeps the
|
|
2794
|
+
* polled set to the sessions somebody is actually running. */
|
|
2795
|
+
const outputWatch = createOutputWatch();
|
|
2796
|
+
const OUTPUT_WATCH_WINDOW_MS = 5 * 60_000;
|
|
2797
|
+
const OUTPUT_WATCH_MS = 1_500;
|
|
2798
|
+
let outputWatchTimer = null;
|
|
2799
|
+
|
|
2800
|
+
/** One tick: stat the recent sessions' transcripts, read only what grew, and
|
|
2801
|
+
* say what landed. Everything expensive about this is guarded inside the watch
|
|
2802
|
+
* — a session that wrote nothing costs one `stat`. */
|
|
2803
|
+
async function outputWatchOnce() {
|
|
2804
|
+
const cutoff = Date.now() - OUTPUT_WATCH_WINDOW_MS;
|
|
2805
|
+
const live = [];
|
|
2806
|
+
for (const [sid, at] of sessionTouchedAt) if (at >= cutoff) live.push(sid);
|
|
2807
|
+
if (!live.length) return;
|
|
2808
|
+
const found = await outputWatch.poll(live);
|
|
2809
|
+
for (const f of found) {
|
|
2810
|
+
pushEvent({
|
|
2811
|
+
hook_event_name: "OutputObserved",
|
|
2812
|
+
session_id: f.sid,
|
|
2813
|
+
kind: f.kind,
|
|
2814
|
+
at: f.at,
|
|
2815
|
+
}, "internal");
|
|
2816
|
+
}
|
|
2817
|
+
}
|
|
2818
|
+
|
|
2819
|
+
function startOutputWatch() {
|
|
2820
|
+
if (outputWatchTimer) return outputWatchTimer;
|
|
2821
|
+
outputWatchTimer = setInterval(() => { outputWatchOnce().catch(() => {}); }, OUTPUT_WATCH_MS);
|
|
2822
|
+
// Unref'd like the Codex watcher beside it: a poll must never be the reason
|
|
2823
|
+
// the process stays up.
|
|
2824
|
+
if (outputWatchTimer.unref) outputWatchTimer.unref();
|
|
2825
|
+
return outputWatchTimer;
|
|
2826
|
+
}
|
|
2827
|
+
|
|
2784
2828
|
function forgetSession(sid) {
|
|
2829
|
+
outputWatch.forget(sid);
|
|
2785
2830
|
modelBySession.delete(sid);
|
|
2786
2831
|
// The two the session-naming work added (#520/#522) and did not list here.
|
|
2787
2832
|
// Both are keyed by session id and nothing else ever removed an entry, which
|
|
@@ -3792,6 +3837,10 @@ function pushEvent(raw, source, opts = {}) {
|
|
|
3792
3837
|
maybeResolveUsage(raw);
|
|
3793
3838
|
maybeResolveContext(raw);
|
|
3794
3839
|
maybeResolveSessionName(raw);
|
|
3840
|
+
// Where the transcript IS, learned from the one place it is free. The
|
|
3841
|
+
// four scanners above read it on this event; the watch reads it between
|
|
3842
|
+
// events, which is the whole of what it adds.
|
|
3843
|
+
if (raw?.transcript_path) outputWatch.note(raw.session_id, raw.transcript_path);
|
|
3795
3844
|
} else {
|
|
3796
3845
|
noteRefusedTranscript(raw.transcript_path);
|
|
3797
3846
|
}
|
|
@@ -5175,6 +5224,7 @@ async function handleClear(res) {
|
|
|
5175
5224
|
// The rule, for the next cache that gates an emit: anything answering
|
|
5176
5225
|
// "has this changed" has to appear in BOTH places that mean the client no
|
|
5177
5226
|
// longer has it — here, and in forgetSession.
|
|
5227
|
+
outputWatch.clear();
|
|
5178
5228
|
nameBySession.clear();
|
|
5179
5229
|
modelBySession.clear();
|
|
5180
5230
|
// The read stamps go with them. Clearing only the signatures would leave
|
|
@@ -5995,6 +6045,10 @@ export async function startServer({ port = 4317, host = "127.0.0.1", persist = n
|
|
|
5995
6045
|
await tryListen(server, candidate, host);
|
|
5996
6046
|
// Codex has no working hooks on Windows — tail its rollout files instead.
|
|
5997
6047
|
if (codex) startCodexWatcher(workspace);
|
|
6048
|
+
// What a session is producing between its tool calls. Claude only — it
|
|
6049
|
+
// reads `transcript_path`, which Codex hooks never send — and unref'd
|
|
6050
|
+
// like its neighbours.
|
|
6051
|
+
startOutputWatch();
|
|
5998
6052
|
// Both timers are unref'd, so this never holds the process open.
|
|
5999
6053
|
startSystemMetrics();
|
|
6000
6054
|
// LAN sync, from the prefs the import read — and only from here, so a
|
package/src/server/macmon.mjs
CHANGED
|
@@ -186,7 +186,15 @@ export function resetMacmonFetch() { _fetched = false; }
|
|
|
186
186
|
*/
|
|
187
187
|
export function macmonAsset(release) {
|
|
188
188
|
const tag = release?.tag_name;
|
|
189
|
-
|
|
189
|
+
// Named, not merely suffixed. Every macmon release back to v0.5.1 has
|
|
190
|
+
// published exactly ONE asset, so `.tar.gz` alone has always picked the right
|
|
191
|
+
// one — but it picks the FIRST match, and the day a release adds a second
|
|
192
|
+
// tarball beside the binary (a `source.tar.gz`, or per-arch builds) the wrong
|
|
193
|
+
// one is chosen silently: it downloads, it hashes against a digest that
|
|
194
|
+
// matches, and what lands is not an executable. The published name has been
|
|
195
|
+
// `macmon-v<X.Y.Z>.tar.gz` throughout, so requiring that shape costs nothing
|
|
196
|
+
// today and refuses to guess on the day it would otherwise guess wrong.
|
|
197
|
+
const a = (release?.assets ?? []).find(x => typeof x?.name === "string" && /^macmon-v[\d.]+\.tar\.gz$/.test(x.name));
|
|
190
198
|
if (!a?.browser_download_url) return null;
|
|
191
199
|
const m = /^sha256:([0-9a-f]{64})$/.exec(String(a.digest ?? ""));
|
|
192
200
|
if (!m) return null;
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
// What a session is producing between its tool calls.
|
|
2
|
+
//
|
|
3
|
+
// THE HOLE THIS FILLS. The hooks fire at boundaries — a prompt goes in, a tool
|
|
4
|
+
// starts, a tool ends, a turn ends — and nothing at all fires while the model
|
|
5
|
+
// is working. Measured on this machine's transcripts over six hours: 86 minutes
|
|
6
|
+
// in 648 thinking blocks, 16.5% of all measured time and MORE than the time
|
|
7
|
+
// spent inside tool calls, with nothing drawn for any of it. A card that is
|
|
8
|
+
// working looks exactly like a card that has died, and the only way to tell
|
|
9
|
+
// them apart was to wait and see whether anything ever appeared.
|
|
10
|
+
//
|
|
11
|
+
// WHAT THE TRANSCRIPT KNOWS THAT THE HOOKS DO NOT. Claude Code appends one JSON
|
|
12
|
+
// line per content block as that block completes, each with its own timestamp:
|
|
13
|
+
//
|
|
14
|
+
// assistant 19:04:40.038 thinking
|
|
15
|
+
// assistant 19:04:41.446 text
|
|
16
|
+
// assistant 19:04:44.174 tool_use
|
|
17
|
+
//
|
|
18
|
+
// So the file advances DURING a turn, at a measured median of 4.1s between
|
|
19
|
+
// blocks, and the deck already knows where that file is — every Claude hook
|
|
20
|
+
// payload carries `transcript_path` and four scanners already read it. What
|
|
21
|
+
// none of them do is look BETWEEN events, which is the only place this signal
|
|
22
|
+
// lives.
|
|
23
|
+
//
|
|
24
|
+
// WHY THIS IS A STAT AND NOT A READ. The transcripts here run to megabytes and
|
|
25
|
+
// the question being asked each tick is one bit: did it grow. So the poll stats
|
|
26
|
+
// and nothing else, and only a file that moved is opened — and then only from
|
|
27
|
+
// the byte where the last read stopped, never from the beginning. A session
|
|
28
|
+
// that is quiet costs one `stat` per tick and no bytes at all.
|
|
29
|
+
//
|
|
30
|
+
// WHAT IT DELIBERATELY DOES NOT DO. It does not claim to know what the model is
|
|
31
|
+
// doing NOW. A block is written when it FINISHES, so the honest statement is
|
|
32
|
+
// "this session completed a thinking block 2 seconds ago" — and 2.1s is the
|
|
33
|
+
// measured median for what follows one, with 99% landing inside 25.5s. The
|
|
34
|
+
// freshness judgement belongs to the reader, which is why this emits a
|
|
35
|
+
// timestamp and a kind and no verdict.
|
|
36
|
+
import { open, stat } from "node:fs/promises";
|
|
37
|
+
import { StringDecoder } from "node:string_decoder";
|
|
38
|
+
|
|
39
|
+
/** Read no more than this from one file in one tick.
|
|
40
|
+
*
|
|
41
|
+
* A tail is normally a few kilobytes — one block. This is the ceiling for the
|
|
42
|
+
* case that is not normal: a session that was quiet while the deck was not
|
|
43
|
+
* watching, or a file replaced under the same name. Past it the watch skips
|
|
44
|
+
* to the end rather than reading megabytes to answer a one-bit question; the
|
|
45
|
+
* next block to land is then seen normally. */
|
|
46
|
+
const MAX_TAIL_BYTES = 256 * 1024;
|
|
47
|
+
|
|
48
|
+
/** The block kinds worth telling apart, in the order a tie is broken.
|
|
49
|
+
*
|
|
50
|
+
* One assistant record can carry several blocks — a thinking block and the
|
|
51
|
+
* tool call it produced arrive together often enough to matter — and the
|
|
52
|
+
* interesting one is the one that says the model was REASONING rather than
|
|
53
|
+
* the mechanical result of it. */
|
|
54
|
+
const KIND_ORDER = ["thinking", "tool_use", "text"];
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* What one transcript line says was produced, or null for a line that says
|
|
58
|
+
* nothing about production.
|
|
59
|
+
*
|
|
60
|
+
* A `user` record carrying a tool_result is the tool answering, not the model
|
|
61
|
+
* producing, and counting it would make every tool call look like generation.
|
|
62
|
+
* Anything unparseable is null rather than a throw: this reads a file that is
|
|
63
|
+
* being appended to, and a torn final line is the ordinary case, not an error.
|
|
64
|
+
*/
|
|
65
|
+
export function blockKindOf(line) {
|
|
66
|
+
if (!line) return null;
|
|
67
|
+
let rec;
|
|
68
|
+
try { rec = JSON.parse(line); } catch { return null; }
|
|
69
|
+
if (!rec || rec.type !== "assistant") return null;
|
|
70
|
+
const content = rec.message?.content;
|
|
71
|
+
if (!Array.isArray(content)) return null;
|
|
72
|
+
const types = new Set(content.map(b => b?.type));
|
|
73
|
+
for (const kind of KIND_ORDER) if (types.has(kind)) return kind;
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** When that line says it happened, or null. Its own stamp rather than the
|
|
78
|
+
* clock here: the deck may be reading a block that landed while it was busy,
|
|
79
|
+
* and dating it `now` would report work as having just happened when it did
|
|
80
|
+
* not. */
|
|
81
|
+
export function blockTimeOf(line) {
|
|
82
|
+
if (!line) return null;
|
|
83
|
+
let rec;
|
|
84
|
+
try { rec = JSON.parse(line); } catch { return null; }
|
|
85
|
+
const t = rec?.timestamp ? Date.parse(rec.timestamp) : NaN;
|
|
86
|
+
return Number.isFinite(t) ? t : null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* EVERY block in a chunk of whole-ish lines, oldest first.
|
|
91
|
+
*
|
|
92
|
+
* Not the newest one. This took only the last block and lost the interesting
|
|
93
|
+
* half of nearly every turn: measured on this machine, consecutive blocks land
|
|
94
|
+
* 1.4s and 2.7s apart, so a `thinking` and the `tool_use` it produced arrive
|
|
95
|
+
* inside one 1500ms tick more often than not — and reporting only the newest
|
|
96
|
+
* meant the canvas saw `tool_use` forever and `thinking` never, which is the
|
|
97
|
+
* one thing this whole watch exists to show. Confirmed on the live deck: two
|
|
98
|
+
* ticks, two events, both `tool_use`, no thinking at all.
|
|
99
|
+
*
|
|
100
|
+
* It also undercounted. Each block is a unit of work the model finished and
|
|
101
|
+
* the activity chart marks one per block, so folding three into one drew a
|
|
102
|
+
* third of the movement that happened.
|
|
103
|
+
*/
|
|
104
|
+
export function blocksIn(chunk) {
|
|
105
|
+
const out = [];
|
|
106
|
+
for (const line of chunk.split("\n")) {
|
|
107
|
+
const kind = blockKindOf(line);
|
|
108
|
+
if (kind) out.push({ kind, at: blockTimeOf(line) });
|
|
109
|
+
}
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The watch.
|
|
115
|
+
*
|
|
116
|
+
* `io` is injectable so the whole of this can be tested without a filesystem,
|
|
117
|
+
* and so the suite can drive a file that grows, shrinks, vanishes and comes
|
|
118
|
+
* back without arranging any of that for real.
|
|
119
|
+
*/
|
|
120
|
+
export function createOutputWatch(io = {}) {
|
|
121
|
+
const statFile = io.stat ?? (p => stat(p));
|
|
122
|
+
const openFile = io.open ?? (p => open(p, "r"));
|
|
123
|
+
/** sid -> { path, offset, size } — where this session's file is and how far
|
|
124
|
+
* into it the watch has already looked. */
|
|
125
|
+
const seen = new Map();
|
|
126
|
+
|
|
127
|
+
/** Remember where a session's transcript is. Called for every hook payload
|
|
128
|
+
* that carries one, which is every Claude hook: the path is free there and
|
|
129
|
+
* there is nowhere else to learn it. A path that changes for a session
|
|
130
|
+
* replaces the entry and restarts at that file's end — see `note`. */
|
|
131
|
+
function note(sid, path) {
|
|
132
|
+
if (!sid || !path) return;
|
|
133
|
+
const prev = seen.get(sid);
|
|
134
|
+
if (prev && prev.path === path) return;
|
|
135
|
+
// Start at the END of a file the watch has not seen before. Everything
|
|
136
|
+
// already in it happened before the deck looked, and replaying it would
|
|
137
|
+
// report a morning's thinking as having just occurred.
|
|
138
|
+
seen.set(sid, { path, offset: null, size: null });
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function forget(sid) { seen.delete(sid); }
|
|
142
|
+
|
|
143
|
+
/** For the suite, and for the one place the server clears everything. */
|
|
144
|
+
function clear() { seen.clear(); }
|
|
145
|
+
function size() { return seen.size; }
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* One tick. Stats each named session's file, reads only what is new, and
|
|
149
|
+
* answers with EVERY block that landed in it, oldest first — see blocksIn
|
|
150
|
+
* for why the newest alone was the wrong answer.
|
|
151
|
+
*
|
|
152
|
+
* Sessions the caller believes are live, and no others: a finished session's
|
|
153
|
+
* file can still be appended to by a later turn, and the deck has its own
|
|
154
|
+
* rules about what counts as live. This asks no questions about that.
|
|
155
|
+
*/
|
|
156
|
+
async function poll(liveSids) {
|
|
157
|
+
const out = [];
|
|
158
|
+
for (const sid of liveSids) {
|
|
159
|
+
const entry = seen.get(sid);
|
|
160
|
+
if (!entry) continue;
|
|
161
|
+
let st;
|
|
162
|
+
try { st = await statFile(entry.path); } catch { continue; }
|
|
163
|
+
const size = Number(st?.size ?? 0);
|
|
164
|
+
// First sight: record where the end is and report nothing. The file's
|
|
165
|
+
// whole history is older than this deck's interest in it.
|
|
166
|
+
if (entry.offset == null) { entry.offset = size; entry.size = size; continue; }
|
|
167
|
+
// TRUNCATED OR REPLACED. A file that is now shorter than the byte this
|
|
168
|
+
// watch was going to read from is not the file it was reading; seeking to
|
|
169
|
+
// the old offset would return whatever now lives there. Start again at
|
|
170
|
+
// the new end rather than guessing which bytes are which.
|
|
171
|
+
if (size < entry.offset) { entry.offset = size; entry.size = size; continue; }
|
|
172
|
+
if (size === entry.offset) continue;
|
|
173
|
+
// Past the ceiling, skip to the end. See MAX_TAIL_BYTES.
|
|
174
|
+
if (size - entry.offset > MAX_TAIL_BYTES) { entry.offset = size; entry.size = size; continue; }
|
|
175
|
+
|
|
176
|
+
const from = entry.offset;
|
|
177
|
+
const want = size - from;
|
|
178
|
+
let fd;
|
|
179
|
+
let text = "";
|
|
180
|
+
try {
|
|
181
|
+
fd = await openFile(entry.path);
|
|
182
|
+
const buf = Buffer.allocUnsafe(want);
|
|
183
|
+
const { bytesRead } = await fd.read(buf, 0, want, from);
|
|
184
|
+
// A decoder rather than toString, because a tail can begin or end in
|
|
185
|
+
// the middle of a UTF-8 sequence and a replacement character in the
|
|
186
|
+
// middle of a JSON string breaks the parse for a whole block.
|
|
187
|
+
const dec = new StringDecoder("utf8");
|
|
188
|
+
text = dec.write(buf.subarray(0, Math.max(0, bytesRead))) + dec.end();
|
|
189
|
+
entry.offset = from + Math.max(0, bytesRead);
|
|
190
|
+
entry.size = size;
|
|
191
|
+
} catch {
|
|
192
|
+
continue;
|
|
193
|
+
} finally {
|
|
194
|
+
// Awaited, for the reason codex-usage.mjs gives: a close scheduled and
|
|
195
|
+
// abandoned leaves a handle open, and on Windows a file with a handle
|
|
196
|
+
// on it cannot be deleted.
|
|
197
|
+
await fd?.close().catch(() => {});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// A tail that ends mid-line is the ordinary case — the file is being
|
|
201
|
+
// appended to while this reads it. Rewind the offset to the start of the
|
|
202
|
+
// torn line so the next tick sees it whole rather than dropping it.
|
|
203
|
+
const lastNl = text.lastIndexOf("\n");
|
|
204
|
+
if (lastNl < 0) {
|
|
205
|
+
// No complete line at all yet. Give the bytes back and wait.
|
|
206
|
+
entry.offset = from;
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
if (lastNl < text.length - 1) {
|
|
210
|
+
entry.offset -= Buffer.byteLength(text.slice(lastNl + 1), "utf8");
|
|
211
|
+
text = text.slice(0, lastNl);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Oldest first, so a reader that refuses to move backwards accepts the
|
|
215
|
+
// whole run rather than only its last member.
|
|
216
|
+
for (const b of blocksIn(text)) out.push({ sid, kind: b.kind, at: b.at });
|
|
217
|
+
}
|
|
218
|
+
return out;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return { note, forget, clear, size, poll };
|
|
222
|
+
}
|
|
@@ -36,7 +36,12 @@ const DOWNLOAD_BASE = "https://github.com/astral-sh/uv/releases/download";
|
|
|
36
36
|
// Used when the releases API cannot be reached — rate limiting is per-IP and
|
|
37
37
|
// unauthenticated, so this is a normal outcome rather than an error. Any
|
|
38
38
|
// reasonably recent uv can install claude-swap; it self-updates later.
|
|
39
|
-
|
|
39
|
+
//
|
|
40
|
+
// Dated, because this constant rots silently: nothing fails when it falls
|
|
41
|
+
// behind, it simply installs an older uv on the machines that reach it, so no
|
|
42
|
+
// test and no user will ever report it. Checked against the releases API on the
|
|
43
|
+
// date below; worth re-reading whenever this file is opened for another reason.
|
|
44
|
+
const FALLBACK_VERSION = "0.12.13"; // latest as of 2026-09-13
|
|
40
45
|
|
|
41
46
|
const DOWNLOAD_TIMEOUT_MS = 120_000;
|
|
42
47
|
|