@volter/supercode-terminal 0.2.30
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/CAPABILITIES.md +57 -0
- package/LICENSE-SUBSTRATE +201 -0
- package/NOTICE-SUBSTRATE +14 -0
- package/README.md +190 -0
- package/client.d.ts +17 -0
- package/client.mjs +32 -0
- package/index.d.ts +195 -0
- package/index.mjs +981 -0
- package/package.json +156 -0
- package/react/stream.d.ts +3 -0
- package/react/stream.mjs +446 -0
- package/react/ui.d.ts +136 -0
- package/react/ui.mjs +892 -0
- package/stream.d.ts +3 -0
- package/stream.mjs +446 -0
- package/styles.css +160 -0
- package/substrate/attach.d.ts +29 -0
- package/substrate/attach.js +147 -0
- package/substrate/client.d.ts +62 -0
- package/substrate/client.js +138 -0
- package/substrate/docker-exec.d.ts +41 -0
- package/substrate/docker-exec.js +101 -0
- package/substrate/internal/exec.d.ts +15 -0
- package/substrate/internal/exec.js +192 -0
- package/substrate/internal/process-tree.d.ts +1 -0
- package/substrate/internal/process-tree.js +41 -0
- package/substrate/local-terminal.d.ts +13 -0
- package/substrate/local-terminal.js +41 -0
- package/substrate/native-host.d.ts +57 -0
- package/substrate/native-host.js +529 -0
- package/substrate/session-lifecycle.d.ts +162 -0
- package/substrate/session-lifecycle.js +1344 -0
- package/substrate/session-roll.d.ts +14 -0
- package/substrate/session-roll.js +407 -0
- package/substrate/tmux-stream.d.ts +32 -0
- package/substrate/tmux-stream.js +212 -0
- package/substrate/tmux.d.ts +144 -0
- package/substrate/tmux.js +899 -0
- package/ui.d.ts +136 -0
- package/ui.mjs +892 -0
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export type HeadScan = {
|
|
2
|
+
firstTimestampMs?: number;
|
|
3
|
+
isClearRoll: boolean;
|
|
4
|
+
};
|
|
5
|
+
export type SessionRollTracker = {
|
|
6
|
+
gather(currentSessionIds: string[]): Promise<Map<string, string>>;
|
|
7
|
+
};
|
|
8
|
+
export declare function createSessionRollTracker(options?: {
|
|
9
|
+
home?: string;
|
|
10
|
+
claudeConfigDir?: string;
|
|
11
|
+
scanIntervalMs?: number;
|
|
12
|
+
}): SessionRollTracker;
|
|
13
|
+
export declare function scanTranscriptHead(head: string): HeadScan;
|
|
14
|
+
export declare function clearRollHeadVerdict(head: string): boolean;
|
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
import { open, readdir, stat } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { basename, dirname, join } from "node:path";
|
|
4
|
+
// Detects a claude session ROLL. `/clear` inside a live claude process ends the
|
|
5
|
+
// current conversation and starts a NEW session id — a new transcript file in
|
|
6
|
+
// the same project dir — while the process (and the `--session-id`/`--resume`
|
|
7
|
+
// pin in its argv) stays the same. The lifecycle derives a pane's session from
|
|
8
|
+
// that argv pin, so without correction it attributes the pane to the launch-time
|
|
9
|
+
// id forever: the live board claims the OLD id is running while every durable
|
|
10
|
+
// surface (the conversation index behind `sessions search` and the Chats panel)
|
|
11
|
+
// correctly reports that id's transcript as frozen — a "live but 26h idle,
|
|
12
|
+
// stuck" contradiction (see test/session-roll.test.mjs for the shape).
|
|
13
|
+
//
|
|
14
|
+
// The tracker gathers roll evidence OFF the pure observe pass (async fs only —
|
|
15
|
+
// it runs on the provider event loop, so nothing here may block) and hands
|
|
16
|
+
// `observeLifecycle` a map of prefixed agentSessionId → successor to apply.
|
|
17
|
+
// All ordering gates run on the transcripts' CONTENT timestamps (the entries'
|
|
18
|
+
// `timestamp` fields), never on file mtimes: mtimes move without conversation
|
|
19
|
+
// activity (observed live — indexing/backup touches, in-place snapshot
|
|
20
|
+
// rewrites), and an mtime-based gate both missed the real roll and would have
|
|
21
|
+
// flapped revocation. A successor must clear four gates, deliberately
|
|
22
|
+
// conservative because adopting a wrong id would misroute session-keyed input:
|
|
23
|
+
// 1. same project dir — claude writes a roll's transcript beside its
|
|
24
|
+
// predecessor's;
|
|
25
|
+
// 2. its first entry timestamp is at/after the predecessor's LAST entry
|
|
26
|
+
// timestamp — a conversation that was already running in parallel is never
|
|
27
|
+
// adopted;
|
|
28
|
+
// 3. its head BEGINS with the `/clear` local-command turn — the roll marker.
|
|
29
|
+
// The `/clear` turn is recorded in the file it CREATES (never in the file
|
|
30
|
+
// it ends), so a head-of-file `/clear` identifies a rolled-from-something
|
|
31
|
+
// transcript, while a fresh session or a `--resume` fork (copied history)
|
|
32
|
+
// starts with a real first message instead;
|
|
33
|
+
// 4. not claimed by another pane (enforced store-side in observeLifecycle,
|
|
34
|
+
// which sees all panes).
|
|
35
|
+
// A successor eligible for MORE THAN ONE tracked predecessor in the dir is
|
|
36
|
+
// adopted by none — two co-located panes' rolls are indistinguishable by
|
|
37
|
+
// transcript evidence alone, and the ambiguity clears once the other candidate
|
|
38
|
+
// writes again. A mapping self-heals: if the predecessor transcript gains a
|
|
39
|
+
// LATER entry the roll was wrong (that session is demonstrably still live) and
|
|
40
|
+
// the mapping is revoked; a vanished successor transcript revokes it too. All
|
|
41
|
+
// state is derived and in-memory — a provider restart re-derives it.
|
|
42
|
+
// Ordering slack: a predecessor flush that lands moments after the successor's
|
|
43
|
+
// first entry must not hide a genuine roll behind the ordering gate.
|
|
44
|
+
const ORDER_SLACK_MS = 15_000;
|
|
45
|
+
// A session id whose transcript is not on disk yet (just launched) is re-searched
|
|
46
|
+
// at most this often, so a missing file never costs a projects walk per tick.
|
|
47
|
+
const MISSING_PATH_TTL_MS = 15_000;
|
|
48
|
+
// Successor scans re-walk a project dir at most this often: eligibility can
|
|
49
|
+
// change without the dir changing (a co-located session writing again un-blurs
|
|
50
|
+
// an ambiguous successor), so the cadence is time-based, not dir-mtime-based.
|
|
51
|
+
const SCAN_INTERVAL_MS = 15_000;
|
|
52
|
+
const EDGE_BYTES = 16 * 1024;
|
|
53
|
+
const HEAD_SCAN_MAX_ENTRIES = 50;
|
|
54
|
+
const EDGE_CACHE_MAX = 500;
|
|
55
|
+
export function createSessionRollTracker(options = {}) {
|
|
56
|
+
const scanIntervalMs = options.scanIntervalMs ?? SCAN_INTERVAL_MS;
|
|
57
|
+
const configDir = options.claudeConfigDir ?? (options.home === undefined ? process.env.CLAUDE_CONFIG_DIR : undefined) ?? join(options.home ?? homedir(), ".claude");
|
|
58
|
+
let pending = Promise.resolve();
|
|
59
|
+
const mappings = new Map();
|
|
60
|
+
const revokedTargetByFrom = new Map();
|
|
61
|
+
const pathBySessionId = new Map();
|
|
62
|
+
const missingPathCheckedAtMs = new Map();
|
|
63
|
+
const scannedAtMsByDir = new Map();
|
|
64
|
+
// Bounded-edge read caches, keyed by size (appends always grow a transcript;
|
|
65
|
+
// a positive head verdict is immutable since the head of an append-only file
|
|
66
|
+
// never changes).
|
|
67
|
+
const headScanByPath = new Map();
|
|
68
|
+
const lastActivityByPath = new Map();
|
|
69
|
+
async function resolveTranscriptPath(sessionId) {
|
|
70
|
+
const cached = pathBySessionId.get(sessionId);
|
|
71
|
+
if (cached && await statOrUndefined(cached))
|
|
72
|
+
return cached;
|
|
73
|
+
const checkedAtMs = missingPathCheckedAtMs.get(sessionId);
|
|
74
|
+
if (checkedAtMs !== undefined && Date.now() - checkedAtMs < MISSING_PATH_TTL_MS)
|
|
75
|
+
return undefined;
|
|
76
|
+
const fileName = `${sessionId.slice("claude:".length)}.jsonl`;
|
|
77
|
+
const projectsRoot = join(configDir, "projects");
|
|
78
|
+
let projectDirs;
|
|
79
|
+
try {
|
|
80
|
+
projectDirs = await readdir(projectsRoot);
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
projectDirs = [];
|
|
84
|
+
}
|
|
85
|
+
const hits = [];
|
|
86
|
+
for (const projectDir of projectDirs) {
|
|
87
|
+
const candidate = join(projectsRoot, projectDir, fileName);
|
|
88
|
+
if (await statOrUndefined(candidate))
|
|
89
|
+
hits.push(candidate);
|
|
90
|
+
if (hits.length > 1)
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
const found = hits.length === 1 ? hits[0] : undefined;
|
|
94
|
+
if (!found) {
|
|
95
|
+
missingPathCheckedAtMs.set(sessionId, Date.now());
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
missingPathCheckedAtMs.delete(sessionId);
|
|
99
|
+
pathBySessionId.set(sessionId, found);
|
|
100
|
+
return found;
|
|
101
|
+
}
|
|
102
|
+
async function headScanFor(transcriptPath, size) {
|
|
103
|
+
const cached = headScanByPath.get(transcriptPath);
|
|
104
|
+
if (cached && (cached.scan.isClearRoll || cached.size === size))
|
|
105
|
+
return cached.scan;
|
|
106
|
+
const head = await readSlice(transcriptPath, 0, EDGE_BYTES);
|
|
107
|
+
if (head === undefined)
|
|
108
|
+
return { isClearRoll: false };
|
|
109
|
+
const scan = scanTranscriptHead(head);
|
|
110
|
+
if (headScanByPath.size >= EDGE_CACHE_MAX)
|
|
111
|
+
headScanByPath.clear();
|
|
112
|
+
headScanByPath.set(transcriptPath, { scan, size });
|
|
113
|
+
return scan;
|
|
114
|
+
}
|
|
115
|
+
// The predecessor's conversation clock: the max entry timestamp in the tail.
|
|
116
|
+
// Content-derived on purpose — file mtimes move without conversation activity.
|
|
117
|
+
async function lastActivityFor(transcriptPath, size) {
|
|
118
|
+
const cached = lastActivityByPath.get(transcriptPath);
|
|
119
|
+
if (cached && cached.size === size)
|
|
120
|
+
return cached.lastTimestampMs;
|
|
121
|
+
const tail = await readSlice(transcriptPath, Math.max(0, size - EDGE_BYTES), EDGE_BYTES);
|
|
122
|
+
if (tail === undefined)
|
|
123
|
+
return undefined;
|
|
124
|
+
let lastTimestampMs;
|
|
125
|
+
for (const line of tail.split("\n")) {
|
|
126
|
+
const entry = parseLine(line);
|
|
127
|
+
if (!entry)
|
|
128
|
+
continue;
|
|
129
|
+
const timestampMs = entryTimestampMs(entry);
|
|
130
|
+
if (timestampMs !== undefined && (lastTimestampMs === undefined || timestampMs > lastTimestampMs)) {
|
|
131
|
+
lastTimestampMs = timestampMs;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
if (lastActivityByPath.size >= EDGE_CACHE_MAX)
|
|
135
|
+
lastActivityByPath.clear();
|
|
136
|
+
lastActivityByPath.set(transcriptPath, { lastTimestampMs, size });
|
|
137
|
+
return lastTimestampMs;
|
|
138
|
+
}
|
|
139
|
+
// One scan covers every tracked session in a project dir, so a successor is
|
|
140
|
+
// matched against ALL of its potential predecessors at once (the ambiguity
|
|
141
|
+
// gate — see the module comment).
|
|
142
|
+
async function detectRollsInDir(projectDir, tracked) {
|
|
143
|
+
let entries;
|
|
144
|
+
try {
|
|
145
|
+
entries = await readdir(projectDir);
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
return [];
|
|
149
|
+
}
|
|
150
|
+
const trackedPaths = new Set(tracked.map((session) => session.transcriptPath));
|
|
151
|
+
const claimedPaths = new Set([...mappings.values()].map((mapping) => mapping.toPath));
|
|
152
|
+
const oldestActivityMs = Math.min(...tracked.map((session) => session.lastActivityMs));
|
|
153
|
+
const siblings = entries.filter((entry) => {
|
|
154
|
+
const candidatePath = join(projectDir, entry);
|
|
155
|
+
return /^[a-zA-Z0-9_-]+\.jsonl$/.test(entry) && !trackedPaths.has(candidatePath) && !claimedPaths.has(candidatePath);
|
|
156
|
+
});
|
|
157
|
+
// Cheap fs-level pre-filter before any content read: a file CREATED before
|
|
158
|
+
// every tracked session's last activity cannot be anyone's successor. Only
|
|
159
|
+
// applied when the filesystem reports a real birthtime.
|
|
160
|
+
const candidates = [];
|
|
161
|
+
for (const entry of siblings) {
|
|
162
|
+
const candidatePath = join(projectDir, entry);
|
|
163
|
+
const stats = await statOrUndefined(candidatePath);
|
|
164
|
+
if (!stats)
|
|
165
|
+
continue;
|
|
166
|
+
if (stats.birthtimeMs > 0 && stats.birthtimeMs < oldestActivityMs - ORDER_SLACK_MS)
|
|
167
|
+
continue;
|
|
168
|
+
const scan = await headScanFor(candidatePath, stats.size);
|
|
169
|
+
if (!scan.isClearRoll || scan.firstTimestampMs === undefined)
|
|
170
|
+
continue;
|
|
171
|
+
candidates.push({ firstTimestampMs: scan.firstTimestampMs, path: candidatePath });
|
|
172
|
+
if (candidates.length > EDGE_CACHE_MAX)
|
|
173
|
+
return []; // Ambiguous overload: adopt none.
|
|
174
|
+
}
|
|
175
|
+
// Earliest successor first: the immediate next conversation is the roll; a
|
|
176
|
+
// later one (a chained /clear) is reached tick by tick.
|
|
177
|
+
candidates.sort((a, b) => a.firstTimestampMs - b.firstTimestampMs);
|
|
178
|
+
const rolls = [];
|
|
179
|
+
const consumed = new Set();
|
|
180
|
+
for (const candidate of candidates) {
|
|
181
|
+
const predecessors = tracked.filter((session) => !consumed.has(session.sessionId) && candidate.firstTimestampMs >= session.lastActivityMs - ORDER_SLACK_MS);
|
|
182
|
+
if (predecessors.length !== 1)
|
|
183
|
+
continue;
|
|
184
|
+
const predecessor = predecessors[0];
|
|
185
|
+
if (!predecessor)
|
|
186
|
+
continue;
|
|
187
|
+
if (revokedTargetByFrom.get(predecessor.sessionId) === candidate.path)
|
|
188
|
+
continue;
|
|
189
|
+
const predecessorStats = await statOrUndefined(predecessor.transcriptPath);
|
|
190
|
+
if (!predecessorStats)
|
|
191
|
+
continue;
|
|
192
|
+
consumed.add(predecessor.sessionId);
|
|
193
|
+
rolls.push({
|
|
194
|
+
from: predecessor.sessionId,
|
|
195
|
+
fromLastActivityMs: predecessor.lastActivityMs,
|
|
196
|
+
fromPath: predecessor.transcriptPath,
|
|
197
|
+
fromSize: predecessorStats.size,
|
|
198
|
+
to: `claude:${basename(candidate.path).replace(/\.jsonl$/, "")}`,
|
|
199
|
+
toPath: candidate.path
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
return rolls;
|
|
203
|
+
}
|
|
204
|
+
async function gather(currentSessionIds) {
|
|
205
|
+
// Retain only live chains, never an unbounded history of closed panes.
|
|
206
|
+
const reachable = new Set(currentSessionIds);
|
|
207
|
+
for (const id of reachable) {
|
|
208
|
+
const next = mappings.get(id)?.to;
|
|
209
|
+
if (next && reachable.size <= EDGE_CACHE_MAX)
|
|
210
|
+
reachable.add(next);
|
|
211
|
+
}
|
|
212
|
+
for (const id of mappings.keys())
|
|
213
|
+
if (!reachable.has(id))
|
|
214
|
+
mappings.delete(id);
|
|
215
|
+
for (const id of revokedTargetByFrom.keys())
|
|
216
|
+
if (!reachable.has(id))
|
|
217
|
+
revokedTargetByFrom.delete(id);
|
|
218
|
+
for (const id of pathBySessionId.keys())
|
|
219
|
+
if (!reachable.has(id))
|
|
220
|
+
pathBySessionId.delete(id);
|
|
221
|
+
for (const id of missingPathCheckedAtMs.keys())
|
|
222
|
+
if (!reachable.has(id))
|
|
223
|
+
missingPathCheckedAtMs.delete(id);
|
|
224
|
+
if (reachable.size > EDGE_CACHE_MAX) {
|
|
225
|
+
mappings.clear();
|
|
226
|
+
revokedTargetByFrom.clear();
|
|
227
|
+
pathBySessionId.clear();
|
|
228
|
+
missingPathCheckedAtMs.clear();
|
|
229
|
+
return new Map(); // Evidence capacity exhaustion is fail-closed.
|
|
230
|
+
}
|
|
231
|
+
if (scannedAtMsByDir.size >= EDGE_CACHE_MAX)
|
|
232
|
+
scannedAtMsByDir.clear();
|
|
233
|
+
for (const [from, mapping] of mappings) {
|
|
234
|
+
if (!await statOrUndefined(mapping.toPath)) {
|
|
235
|
+
mappings.delete(from);
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
const fromStats = await statOrUndefined(mapping.fromPath);
|
|
239
|
+
// A vanished predecessor doesn't invalidate the roll (the transcript may
|
|
240
|
+
// have been reaped); an unchanged size can't have new entries.
|
|
241
|
+
if (!fromStats || fromStats.size === mapping.fromSize)
|
|
242
|
+
continue;
|
|
243
|
+
const lastActivityMs = await lastActivityFor(mapping.fromPath, fromStats.size);
|
|
244
|
+
if (lastActivityMs !== undefined && lastActivityMs > mapping.fromLastActivityMs) {
|
|
245
|
+
// The predecessor gained a LATER entry after the roll was recorded —
|
|
246
|
+
// that session is still live, so the roll was wrong. Revoke; the
|
|
247
|
+
// ordering gate keeps the same successor from being re-adopted.
|
|
248
|
+
mappings.delete(from);
|
|
249
|
+
revokedTargetByFrom.set(from, mapping.toPath);
|
|
250
|
+
}
|
|
251
|
+
else {
|
|
252
|
+
// Content changed without a later timestamp (e.g. a snapshot line):
|
|
253
|
+
// remember the new size so the tail isn't re-read every tick.
|
|
254
|
+
mappings.set(from, { ...mapping, fromSize: fromStats.size });
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
// Group the tracked claude sessions by project dir and scan each dir once,
|
|
258
|
+
// on a time cadence.
|
|
259
|
+
const trackedByDir = new Map();
|
|
260
|
+
for (const sessionId of reachable) {
|
|
261
|
+
if (mappings.has(sessionId))
|
|
262
|
+
continue;
|
|
263
|
+
if (!/^claude:[a-zA-Z0-9_-]+$/.test(sessionId))
|
|
264
|
+
continue;
|
|
265
|
+
try {
|
|
266
|
+
const transcriptPath = await resolveTranscriptPath(sessionId);
|
|
267
|
+
if (!transcriptPath)
|
|
268
|
+
continue;
|
|
269
|
+
const stats = await statOrUndefined(transcriptPath);
|
|
270
|
+
if (!stats)
|
|
271
|
+
continue;
|
|
272
|
+
const lastActivityMs = await lastActivityFor(transcriptPath, stats.size);
|
|
273
|
+
if (lastActivityMs === undefined)
|
|
274
|
+
continue;
|
|
275
|
+
const projectDir = dirname(transcriptPath);
|
|
276
|
+
const tracked = trackedByDir.get(projectDir) ?? [];
|
|
277
|
+
tracked.push({ lastActivityMs, sessionId, transcriptPath });
|
|
278
|
+
trackedByDir.set(projectDir, tracked);
|
|
279
|
+
}
|
|
280
|
+
catch {
|
|
281
|
+
// Evidence gathering is best-effort; a failed resolve just retries later.
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
for (const [projectDir, tracked] of trackedByDir) {
|
|
285
|
+
const scannedAtMs = scannedAtMsByDir.get(projectDir);
|
|
286
|
+
if (scannedAtMs !== undefined && Date.now() - scannedAtMs < scanIntervalMs)
|
|
287
|
+
continue;
|
|
288
|
+
scannedAtMsByDir.set(projectDir, Date.now());
|
|
289
|
+
try {
|
|
290
|
+
for (const roll of await detectRollsInDir(projectDir, tracked)) {
|
|
291
|
+
mappings.set(roll.from, roll);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
catch {
|
|
295
|
+
// Best-effort; the next cadence retries.
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
const rolls = new Map();
|
|
299
|
+
for (const [from, mapping] of mappings)
|
|
300
|
+
rolls.set(from, mapping.to);
|
|
301
|
+
return rolls;
|
|
302
|
+
}
|
|
303
|
+
return {
|
|
304
|
+
gather(currentSessionIds) {
|
|
305
|
+
// Serialize reads of mutable evidence, including concurrent host refreshes.
|
|
306
|
+
const ids = [...currentSessionIds];
|
|
307
|
+
const result = pending.then(() => gather(ids));
|
|
308
|
+
pending = result.catch(() => undefined);
|
|
309
|
+
return result;
|
|
310
|
+
},
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
// Pure scan of a transcript head: does this file BEGIN with the `/clear`
|
|
314
|
+
// local-command turn (the roll marker), and what is its first entry timestamp
|
|
315
|
+
// (the successor's conversation birth)? Claude records the `/clear` turn in the
|
|
316
|
+
// transcript it creates, so it can only ever sit at the head of a rolled-from
|
|
317
|
+
// file; any real first message means a fresh session (or a --resume fork's
|
|
318
|
+
// copied history) and vetoes the verdict.
|
|
319
|
+
export function scanTranscriptHead(head) {
|
|
320
|
+
let firstTimestampMs;
|
|
321
|
+
let isClearRoll = false;
|
|
322
|
+
let decided = false;
|
|
323
|
+
let inspected = 0;
|
|
324
|
+
for (const line of head.split("\n")) {
|
|
325
|
+
if (inspected >= HEAD_SCAN_MAX_ENTRIES)
|
|
326
|
+
break;
|
|
327
|
+
const entry = parseLine(line);
|
|
328
|
+
if (!entry)
|
|
329
|
+
continue;
|
|
330
|
+
inspected += 1;
|
|
331
|
+
const timestampMs = entryTimestampMs(entry);
|
|
332
|
+
if (timestampMs !== undefined && (firstTimestampMs === undefined || timestampMs < firstTimestampMs)) {
|
|
333
|
+
firstTimestampMs = timestampMs;
|
|
334
|
+
}
|
|
335
|
+
if (decided || entry.type !== "user")
|
|
336
|
+
continue;
|
|
337
|
+
const message = entry.message;
|
|
338
|
+
const content = message && typeof message === "object" && !Array.isArray(message)
|
|
339
|
+
? message.content
|
|
340
|
+
: undefined;
|
|
341
|
+
// A non-text user turn (tool_result blocks) is mid-conversation shape, never
|
|
342
|
+
// the head of a fresh roll.
|
|
343
|
+
if (typeof content !== "string") {
|
|
344
|
+
decided = true;
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
const text = content.trimStart();
|
|
348
|
+
if (text.startsWith("<command-name>")) {
|
|
349
|
+
isClearRoll = /^<command-name>\s*\/clear\s*<\/command-name>/.test(text);
|
|
350
|
+
decided = true;
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
// The caveat/stdout wrappers around a local command precede the command turn
|
|
354
|
+
// itself in file order — skip them, they don't decide either way.
|
|
355
|
+
if (text.startsWith("<local-command-"))
|
|
356
|
+
continue;
|
|
357
|
+
decided = true;
|
|
358
|
+
}
|
|
359
|
+
return { ...(firstTimestampMs === undefined ? {} : { firstTimestampMs }), isClearRoll };
|
|
360
|
+
}
|
|
361
|
+
export function clearRollHeadVerdict(head) {
|
|
362
|
+
return scanTranscriptHead(head).isClearRoll;
|
|
363
|
+
}
|
|
364
|
+
function parseLine(line) {
|
|
365
|
+
const trimmed = line.trim();
|
|
366
|
+
if (!trimmed)
|
|
367
|
+
return undefined;
|
|
368
|
+
try {
|
|
369
|
+
const value = JSON.parse(trimmed);
|
|
370
|
+
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
371
|
+
? value : undefined;
|
|
372
|
+
}
|
|
373
|
+
catch {
|
|
374
|
+
return undefined;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
function entryTimestampMs(entry) {
|
|
378
|
+
if (typeof entry.timestamp !== "string")
|
|
379
|
+
return undefined;
|
|
380
|
+
const parsed = Date.parse(entry.timestamp);
|
|
381
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
382
|
+
}
|
|
383
|
+
async function statOrUndefined(path) {
|
|
384
|
+
try {
|
|
385
|
+
return await stat(path);
|
|
386
|
+
}
|
|
387
|
+
catch {
|
|
388
|
+
return undefined;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
async function readSlice(path, position, bytes) {
|
|
392
|
+
let handle;
|
|
393
|
+
try {
|
|
394
|
+
handle = await open(path, "r");
|
|
395
|
+
}
|
|
396
|
+
catch {
|
|
397
|
+
return undefined;
|
|
398
|
+
}
|
|
399
|
+
try {
|
|
400
|
+
const buffer = Buffer.alloc(bytes);
|
|
401
|
+
const { bytesRead } = await handle.read(buffer, 0, bytes, position);
|
|
402
|
+
return buffer.toString("utf8", 0, bytesRead);
|
|
403
|
+
}
|
|
404
|
+
finally {
|
|
405
|
+
await handle.close();
|
|
406
|
+
}
|
|
407
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { WebSocket } from "ws";
|
|
2
|
+
import pty from "@homebridge/node-pty-prebuilt-multiarch";
|
|
3
|
+
export type TerminalAttachment = {
|
|
4
|
+
dispose(): void;
|
|
5
|
+
};
|
|
6
|
+
export declare class TmuxTerminalAttachError extends Error {
|
|
7
|
+
readonly phase: "resolve" | "spawn";
|
|
8
|
+
constructor(phase: "resolve" | "spawn", cause: unknown);
|
|
9
|
+
}
|
|
10
|
+
export type TmuxTerminalSocketOptions<TAuthorization = void> = {
|
|
11
|
+
authorizeInput?: () => Promise<TAuthorization> | TAuthorization;
|
|
12
|
+
cols?: number;
|
|
13
|
+
cwd?: string;
|
|
14
|
+
errorLabel?: string;
|
|
15
|
+
onInput?: (data: string, authorization: TAuthorization | undefined) => Promise<void> | void;
|
|
16
|
+
readOnly?: boolean;
|
|
17
|
+
rows?: number;
|
|
18
|
+
socket: WebSocket;
|
|
19
|
+
terminalId: string;
|
|
20
|
+
tmuxSocket?: string;
|
|
21
|
+
window?: number;
|
|
22
|
+
};
|
|
23
|
+
export declare function attachTmuxTerminalSocket<TAuthorization = void>(options: TmuxTerminalSocketOptions<TAuthorization>): TerminalAttachment;
|
|
24
|
+
type ReconciledPty = {
|
|
25
|
+
child: ReturnType<typeof pty.spawn>;
|
|
26
|
+
orphanPtmxFds: number[];
|
|
27
|
+
};
|
|
28
|
+
export declare function spawnReconciledPty(file: string, args: string[], options: Parameters<typeof pty.spawn>[2]): ReconciledPty;
|
|
29
|
+
export declare function releasePtyMaster(child: {
|
|
30
|
+
destroy(): void;
|
|
31
|
+
}, orphanPtmxFds?: number[]): void;
|
|
32
|
+
export {};
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import pty from "@homebridge/node-pty-prebuilt-multiarch";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import { assertSession, getStatusLineCount, getWindowSize } from "./tmux.js";
|
|
4
|
+
export class TmuxTerminalAttachError extends Error {
|
|
5
|
+
phase;
|
|
6
|
+
constructor(phase, cause) {
|
|
7
|
+
super(errorMessage(cause), { cause });
|
|
8
|
+
this.name = "TmuxTerminalAttachError";
|
|
9
|
+
this.phase = phase;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
// Attach one ephemeral PTY client to a durable tmux session. The returned
|
|
13
|
+
// attachment owns every listener and descriptor it creates; dispose is
|
|
14
|
+
// idempotent and never terminates the tmux session itself.
|
|
15
|
+
export function attachTmuxTerminalSocket(options) {
|
|
16
|
+
const window = options.window ?? 0;
|
|
17
|
+
if (!Number.isInteger(window) || window < 0) {
|
|
18
|
+
throw new Error("window must be a non-negative integer");
|
|
19
|
+
}
|
|
20
|
+
let tmuxSize;
|
|
21
|
+
try {
|
|
22
|
+
assertSession(options.terminalId, options.tmuxSocket);
|
|
23
|
+
tmuxSize = getWindowSize({ name: options.terminalId, socket: options.tmuxSocket, window });
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
throw new TmuxTerminalAttachError("resolve", error);
|
|
27
|
+
}
|
|
28
|
+
// Observers inherit the durable pane dimensions exactly. Even an ignore-size tmux client can
|
|
29
|
+
// become the size source when it is the only attached client, so accepting the browser viewport
|
|
30
|
+
// here would silently reflow someone else's terminal merely by opening a read-only mirror.
|
|
31
|
+
const cols = options.readOnly ? tmuxSize.width : options.cols ?? tmuxSize.width;
|
|
32
|
+
const rows = options.readOnly
|
|
33
|
+
? tmuxSize.height + getStatusLineCount(options.terminalId, options.tmuxSocket)
|
|
34
|
+
: options.rows ?? tmuxSize.height;
|
|
35
|
+
let reconciledPty;
|
|
36
|
+
try {
|
|
37
|
+
reconciledPty = spawnReconciledPty("tmux", [
|
|
38
|
+
...(options.tmuxSocket ? ["-L", options.tmuxSocket] : []),
|
|
39
|
+
"-T",
|
|
40
|
+
"RGB",
|
|
41
|
+
"attach-session",
|
|
42
|
+
...(options.readOnly ? ["-r"] : []),
|
|
43
|
+
"-t",
|
|
44
|
+
options.terminalId
|
|
45
|
+
], {
|
|
46
|
+
cols,
|
|
47
|
+
cwd: options.cwd ?? process.cwd(),
|
|
48
|
+
env: {
|
|
49
|
+
...process.env,
|
|
50
|
+
TERM: "screen-256color"
|
|
51
|
+
},
|
|
52
|
+
rows
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
throw new TmuxTerminalAttachError("spawn", error);
|
|
57
|
+
}
|
|
58
|
+
const { child, orphanPtmxFds } = reconciledPty;
|
|
59
|
+
let disposed = false;
|
|
60
|
+
let outputSubscription;
|
|
61
|
+
let exitSubscription;
|
|
62
|
+
const onClose = () => dispose();
|
|
63
|
+
let controlQueue = Promise.resolve();
|
|
64
|
+
const runControl = (operation) => {
|
|
65
|
+
controlQueue = controlQueue
|
|
66
|
+
.then(async () => {
|
|
67
|
+
const authorization = await options.authorizeInput?.();
|
|
68
|
+
if (!disposed)
|
|
69
|
+
await operation(authorization);
|
|
70
|
+
})
|
|
71
|
+
.catch((error) => {
|
|
72
|
+
sendTerminalSocketError(options.socket, error, options.errorLabel);
|
|
73
|
+
options.socket.close(1008, "terminal access ended");
|
|
74
|
+
});
|
|
75
|
+
};
|
|
76
|
+
const onMessage = (raw) => {
|
|
77
|
+
try {
|
|
78
|
+
const message = JSON.parse(raw.toString());
|
|
79
|
+
if (message.type === "resize") {
|
|
80
|
+
if (options.readOnly)
|
|
81
|
+
throw new Error("Read-only terminal attachments cannot resize the pane.");
|
|
82
|
+
assertTerminalDimension(message.cols, "cols");
|
|
83
|
+
assertTerminalDimension(message.rows, "rows");
|
|
84
|
+
runControl(() => child.resize(message.cols, message.rows));
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (message.type !== "input") {
|
|
88
|
+
throw new Error(`Unsupported websocket message type: ${message.type}`);
|
|
89
|
+
}
|
|
90
|
+
if (typeof message.data !== "string") {
|
|
91
|
+
throw new Error("Input message did not include string data.");
|
|
92
|
+
}
|
|
93
|
+
runControl(async (authorization) => {
|
|
94
|
+
await options.onInput?.(message.data, authorization);
|
|
95
|
+
if (!disposed)
|
|
96
|
+
child.write(message.data);
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
sendTerminalSocketError(options.socket, error, options.errorLabel);
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
const dispose = () => {
|
|
104
|
+
if (disposed)
|
|
105
|
+
return;
|
|
106
|
+
disposed = true;
|
|
107
|
+
options.socket.off("close", onClose);
|
|
108
|
+
options.socket.off("message", onMessage);
|
|
109
|
+
outputSubscription?.dispose();
|
|
110
|
+
exitSubscription?.dispose();
|
|
111
|
+
releasePtyMaster(child, orphanPtmxFds);
|
|
112
|
+
};
|
|
113
|
+
options.socket.send(JSON.stringify({ cols, rows, type: "hello" }));
|
|
114
|
+
outputSubscription = child.onData((data) => {
|
|
115
|
+
if (!disposed && options.socket.readyState === options.socket.OPEN) {
|
|
116
|
+
options.socket.send(JSON.stringify({ data: wellFormedTerminalText(data), type: "output" }));
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
exitSubscription = child.onExit(({ exitCode, signal }) => {
|
|
120
|
+
if (!disposed && options.socket.readyState === options.socket.OPEN) {
|
|
121
|
+
options.socket.send(JSON.stringify({ exitCode, signal, type: "exit" }));
|
|
122
|
+
options.socket.close(1000, "tmux client exited");
|
|
123
|
+
}
|
|
124
|
+
dispose();
|
|
125
|
+
});
|
|
126
|
+
options.socket.on("close", onClose);
|
|
127
|
+
options.socket.on("message", onMessage);
|
|
128
|
+
return { dispose };
|
|
129
|
+
}
|
|
130
|
+
export function spawnReconciledPty(file, args, options) {
|
|
131
|
+
if (process.platform !== "darwin") {
|
|
132
|
+
return { child: pty.spawn(file, args, options), orphanPtmxFds: [] };
|
|
133
|
+
}
|
|
134
|
+
const before = listCharacterDeviceFds();
|
|
135
|
+
const child = pty.spawn(file, args, options);
|
|
136
|
+
const masterFd = child._fd;
|
|
137
|
+
const masterMajor = characterDeviceMajor(masterFd);
|
|
138
|
+
const orphanPtmxFds = [];
|
|
139
|
+
if (typeof masterFd === "number" && masterMajor !== null) {
|
|
140
|
+
for (const fd of listCharacterDeviceFds()) {
|
|
141
|
+
if (!before.has(fd) && fd !== masterFd && characterDeviceMajor(fd) === masterMajor) {
|
|
142
|
+
orphanPtmxFds.push(fd);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return { child, orphanPtmxFds };
|
|
147
|
+
}
|
|
148
|
+
export function releasePtyMaster(child, orphanPtmxFds = []) {
|
|
149
|
+
try {
|
|
150
|
+
child.destroy();
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
// Child already gone; destroy is best-effort.
|
|
154
|
+
}
|
|
155
|
+
for (const fd of orphanPtmxFds) {
|
|
156
|
+
if (characterDeviceMajor(fd) === null)
|
|
157
|
+
continue;
|
|
158
|
+
try {
|
|
159
|
+
fs.closeSync(fd);
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
// Already closed.
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
function characterDeviceMajor(fd) {
|
|
167
|
+
if (typeof fd !== "number")
|
|
168
|
+
return null;
|
|
169
|
+
try {
|
|
170
|
+
const stat = fs.fstatSync(fd);
|
|
171
|
+
return stat.isCharacterDevice() ? (stat.rdev >> 24) & 0xff : null;
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
function listCharacterDeviceFds() {
|
|
178
|
+
const fds = new Set();
|
|
179
|
+
try {
|
|
180
|
+
for (const entry of fs.readdirSync("/dev/fd")) {
|
|
181
|
+
const fd = Number(entry);
|
|
182
|
+
if (Number.isInteger(fd) && characterDeviceMajor(fd) !== null)
|
|
183
|
+
fds.add(fd);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
// No /dev/fd (for example Windows conpty).
|
|
188
|
+
}
|
|
189
|
+
return fds;
|
|
190
|
+
}
|
|
191
|
+
function assertTerminalDimension(value, label) {
|
|
192
|
+
if (!Number.isInteger(value) || value < 1 || value > 1_000) {
|
|
193
|
+
throw new Error(`Terminal ${label} must be an integer between 1 and 1000.`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
function sendTerminalSocketError(socket, error, label = "terminal") {
|
|
197
|
+
if (socket.readyState !== socket.OPEN)
|
|
198
|
+
return;
|
|
199
|
+
socket.send(JSON.stringify({
|
|
200
|
+
message: `\r\n[${label}] ${errorMessage(error)}\r\n`,
|
|
201
|
+
type: "error"
|
|
202
|
+
}));
|
|
203
|
+
}
|
|
204
|
+
function errorMessage(error) {
|
|
205
|
+
return error instanceof Error ? error.message : String(error);
|
|
206
|
+
}
|
|
207
|
+
function wellFormedTerminalText(value) {
|
|
208
|
+
const maybeWellFormed = value;
|
|
209
|
+
if (typeof maybeWellFormed.toWellFormed === "function")
|
|
210
|
+
return maybeWellFormed.toWellFormed();
|
|
211
|
+
return value.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, "\uFFFD");
|
|
212
|
+
}
|