nexting-cc-bridge 0.8.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +252 -0
  2. package/dist/attach-manager.js +259 -0
  3. package/dist/bridge.js +931 -0
  4. package/dist/cli-args.js +14 -0
  5. package/dist/cli.js +742 -0
  6. package/dist/codex-prompts.js +148 -0
  7. package/dist/codex-thread-source.js +495 -0
  8. package/dist/codex-transcript.js +415 -0
  9. package/dist/dev-server.js +126 -0
  10. package/dist/discovery.js +111 -0
  11. package/dist/e2e/codec.js +119 -0
  12. package/dist/e2e/crypto.js +127 -0
  13. package/dist/e2e/key-store.js +48 -0
  14. package/dist/e2e/keychain-identity.js +29 -0
  15. package/dist/engine/adapter.js +5 -0
  16. package/dist/engine/claude-adapter.js +77 -0
  17. package/dist/engine/codex-adapter.js +593 -0
  18. package/dist/file-preview.js +292 -0
  19. package/dist/hub-protocol.js +28 -0
  20. package/dist/hub-server.js +106 -0
  21. package/dist/hub.js +84 -0
  22. package/dist/install-util.js +33 -0
  23. package/dist/local-shell.js +32 -0
  24. package/dist/mcp-config.js +230 -0
  25. package/dist/mcp-device-proxy.js +501 -0
  26. package/dist/media-hydrator.js +222 -0
  27. package/dist/message-counter.js +79 -0
  28. package/dist/phone-probe.js +55 -0
  29. package/dist/prompt-detector.js +213 -0
  30. package/dist/protocol.js +3 -0
  31. package/dist/pty-mirror.js +80 -0
  32. package/dist/pty-spawn.js +53 -0
  33. package/dist/scanner.js +422 -0
  34. package/dist/self-update.js +122 -0
  35. package/dist/session-map.js +15 -0
  36. package/dist/session-runner.js +131 -0
  37. package/dist/shell.js +104 -0
  38. package/dist/skills-scanner.js +167 -0
  39. package/dist/stdin-encode.js +32 -0
  40. package/dist/stream-translate.js +122 -0
  41. package/dist/terminal-render.js +29 -0
  42. package/dist/transcript-watcher.js +138 -0
  43. package/dist/transcript.js +346 -0
  44. package/dist/turn-probe.js +152 -0
  45. package/dist/types.js +2 -0
  46. package/dist/watch-manager.js +77 -0
  47. package/install-cc.sh +90 -0
  48. package/install-codex.sh +97 -0
  49. package/package.json +39 -0
  50. package/shim/claude +55 -0
@@ -0,0 +1,138 @@
1
+ // Tail-follows one session JSONL and emits parsed entries as
2
+ // cc_event{kind:"transcript_append"} — the push that replaces the phone's
3
+ // 1.5s full-transcript polling. Works for ALL session flavors (live pty TUI,
4
+ // attach children, local shell) because the jsonl is claude's own record.
5
+ //
6
+ // Pure tail state machine (consumeChunk) is unit-tested; chokidar + a cheap
7
+ // local stat() backstop are the IO glue.
8
+ import fsp from "node:fs/promises";
9
+ import chokidar from "chokidar";
10
+ import { parseTranscript } from "./transcript.js";
11
+ const BACKSTOP_MS = 3000; // fs events can be dropped; stat() locally is cheap
12
+ export function freshTailState() {
13
+ return { remainder: Buffer.alloc(0), entryIndex: 0 };
14
+ }
15
+ /** Fold appended bytes into the tail state; complete lines → entries.
16
+ * Byte-level remainder so a UTF-8 char split across reads never garbles. */
17
+ export function consumeChunk(state, chunk, parse = parseTranscript) {
18
+ const buf = Buffer.concat([state.remainder, chunk]);
19
+ const lastNl = buf.lastIndexOf(0x0a);
20
+ if (lastNl === -1) {
21
+ state.remainder = buf;
22
+ return { entries: [], startIndex: state.entryIndex };
23
+ }
24
+ const complete = buf.subarray(0, lastNl).toString("utf8");
25
+ state.remainder = Buffer.from(buf.subarray(lastNl + 1));
26
+ const entries = parse(complete.split("\n"));
27
+ const startIndex = state.entryIndex;
28
+ state.entryIndex += entries.length;
29
+ return { entries, startIndex };
30
+ }
31
+ export function createTranscriptFollower(opts) {
32
+ const parse = opts.parse ?? parseTranscript;
33
+ const transform = opts.transform ??
34
+ (async (entries, _sessionId) => entries);
35
+ let state = freshTailState();
36
+ let offset = 0;
37
+ let watcher = null;
38
+ let timer = null;
39
+ let busy = false;
40
+ async function baseline() {
41
+ state = freshTailState();
42
+ offset = 0;
43
+ try {
44
+ const st = await fsp.stat(opts.file);
45
+ const buf = await fsp.readFile(opts.file);
46
+ consumeChunk(state, buf, parse); // counts entries; baseline never emits
47
+ offset = st.size;
48
+ }
49
+ catch {
50
+ /* unreadable → counts stay 0; next poke retries */
51
+ }
52
+ }
53
+ async function poke() {
54
+ if (busy)
55
+ return; // a slow read must not interleave with the next tick
56
+ busy = true;
57
+ try {
58
+ const st = await fsp.stat(opts.file).catch(() => null);
59
+ if (!st)
60
+ return;
61
+ if (st.size < offset) {
62
+ // Truncated/rewritten (e.g. compaction) — phone refetches the anchor.
63
+ await baseline();
64
+ opts.emit({
65
+ type: "cc_event",
66
+ sessionId: opts.sessionId,
67
+ kind: "transcript_reset",
68
+ payload: {},
69
+ });
70
+ return;
71
+ }
72
+ if (st.size === offset)
73
+ return;
74
+ const fh = await fsp.open(opts.file, "r");
75
+ try {
76
+ const len = st.size - offset;
77
+ const buf = Buffer.alloc(len);
78
+ await fh.read(buf, 0, len, offset);
79
+ // Snapshot the tail state BEFORE consuming so a dropped emit (socket
80
+ // mid-reconnect, or closed during the transform await) can be rolled
81
+ // back in full — offset stays put and the next poke re-reads the same
82
+ // bytes and re-emits. Previously offset advanced before the emit, so a
83
+ // frame lost to a closed socket was gone for good and the phone's
84
+ // spinner spun until the ~4min re-watch reconciled (the "卡住,必须手动
85
+ // 刷新" bug).
86
+ const snapRemainder = state.remainder;
87
+ const snapEntryIndex = state.entryIndex;
88
+ const { entries, startIndex } = consumeChunk(state, buf, parse);
89
+ if (entries.length > 0) {
90
+ const transformed = await transform(entries, opts.sessionId);
91
+ const sent = opts.emit({
92
+ type: "cc_event",
93
+ sessionId: opts.sessionId,
94
+ kind: "transcript_append",
95
+ // `total` = authoritative jsonl entry count at emit time. The phone
96
+ // uses it as a per-frame cursor: local count < total → it anchors
97
+ // the gap immediately instead of waiting for the re-watch heartbeat.
98
+ payload: {
99
+ startIndex,
100
+ entries: transformed,
101
+ total: state.entryIndex,
102
+ },
103
+ });
104
+ if (sent === false) {
105
+ state.remainder = snapRemainder;
106
+ state.entryIndex = snapEntryIndex;
107
+ return; // leave offset unadvanced → next poke re-reads & re-emits
108
+ }
109
+ }
110
+ offset = st.size;
111
+ }
112
+ finally {
113
+ await fh.close();
114
+ }
115
+ }
116
+ finally {
117
+ busy = false;
118
+ }
119
+ }
120
+ return {
121
+ start: async () => {
122
+ await baseline();
123
+ watcher = chokidar.watch(opts.file, { ignoreInitial: true });
124
+ watcher.on("change", () => void poke());
125
+ timer = setInterval(() => void poke(), BACKSTOP_MS);
126
+ timer.unref?.();
127
+ },
128
+ poke,
129
+ stop: () => {
130
+ void watcher?.close();
131
+ watcher = null;
132
+ if (timer)
133
+ clearInterval(timer);
134
+ timer = null;
135
+ },
136
+ entryCount: () => state.entryIndex,
137
+ };
138
+ }
@@ -0,0 +1,346 @@
1
+ import { mediaFromImageBlock, mediaFromLocalPath } from "./media-hydrator.js";
2
+ const TEXT_CAP = 16 * 1024;
3
+ const RECENT_TEXT_CAP = 2000;
4
+ const RECENT_LIMIT = 20;
5
+ function parseLine(line) {
6
+ const s = line.trim();
7
+ if (!s)
8
+ return null;
9
+ try {
10
+ return JSON.parse(s);
11
+ }
12
+ catch {
13
+ return null;
14
+ }
15
+ }
16
+ function capText(text) {
17
+ if (text.length <= TEXT_CAP)
18
+ return { text };
19
+ return {
20
+ text: text.slice(0, TEXT_CAP),
21
+ truncatedChars: text.length - TEXT_CAP,
22
+ };
23
+ }
24
+ /** tool_result content can be a string or an array of text blocks. */
25
+ function toolResultText(content) {
26
+ if (typeof content === "string")
27
+ return content;
28
+ if (Array.isArray(content)) {
29
+ return content
30
+ .map((b) => (typeof b === "string" ? b : (b?.text ?? "")))
31
+ .join("");
32
+ }
33
+ return "";
34
+ }
35
+ /** Claude Code writes a user entry starting with this marker when Esc aborts a
36
+ * turn after output started ("[Request interrupted by user]" / "…for tool use]"). */
37
+ const INTERRUPT_MARKER = "[Request interrupted";
38
+ function isInterruptText(text) {
39
+ return typeof text === "string" && text.startsWith(INTERRUPT_MARKER);
40
+ }
41
+ /**
42
+ * True when a user message is purely Claude Code's Esc-interrupt marker:
43
+ * string content starting with the marker, or block content whose text block(s)
44
+ * all start with the marker — with NO tool_result blocks (a tool_result means
45
+ * real turn data the phone must still see). Per-line/stateless on purpose so the
46
+ * full-file parse (scanner.readFullTranscript) and the incremental watcher
47
+ * (transcript-watcher consumeChunk) map identical entry sequences and indexes.
48
+ */
49
+ function isAbortedUserContent(content) {
50
+ if (typeof content === "string")
51
+ return isInterruptText(content);
52
+ if (!Array.isArray(content))
53
+ return false;
54
+ let sawInterruptText = false;
55
+ for (const b of content) {
56
+ if (typeof b !== "object" || b === null)
57
+ continue;
58
+ if (b.type === "tool_result")
59
+ return false;
60
+ if (b.type === "text") {
61
+ if (!isInterruptText(b.text))
62
+ return false;
63
+ sawInterruptText = true;
64
+ }
65
+ }
66
+ return sawInterruptText;
67
+ }
68
+ /** Concatenate the human-readable text from a message's content blocks. */
69
+ function blocksToText(content) {
70
+ if (typeof content === "string")
71
+ return content;
72
+ if (!Array.isArray(content))
73
+ return "";
74
+ const parts = [];
75
+ for (const b of content) {
76
+ if (typeof b !== "object" || b === null)
77
+ continue;
78
+ switch (b.type) {
79
+ case "text":
80
+ parts.push(b.text ?? "");
81
+ break;
82
+ case "thinking":
83
+ parts.push(b.thinking ?? "");
84
+ break;
85
+ case "tool_use":
86
+ parts.push(`[tool_use: ${b.name}]`);
87
+ break;
88
+ case "tool_result":
89
+ parts.push(toolResultText(b.content));
90
+ break;
91
+ case "image":
92
+ parts.push("[image]");
93
+ break;
94
+ }
95
+ }
96
+ return parts.join(" ").trim();
97
+ }
98
+ /** Rebuild the full kind-tagged timeline from raw JSONL lines (for the read-only viewer). */
99
+ export function parseTranscript(lines) {
100
+ const out = [];
101
+ for (const line of lines) {
102
+ const o = parseLine(line);
103
+ if (!o)
104
+ continue;
105
+ const ts = o.timestamp ?? null;
106
+ if (o.type === "system") {
107
+ out.push({ kind: "system", subtype: o.subtype, timestamp: ts });
108
+ continue;
109
+ }
110
+ if (o.type !== "user" && o.type !== "assistant")
111
+ continue;
112
+ const role = o.type;
113
+ const content = o.message?.content;
114
+ // Esc-interrupt marker → a system/turn_aborted entry INSTEAD of user_text,
115
+ // so the phone's shape-derived spinner knows the turn is over (a trailing
116
+ // user_text would read as "user spoke, turn still running").
117
+ if (role === "user" && isAbortedUserContent(content)) {
118
+ out.push({ kind: "system", subtype: "turn_aborted", timestamp: ts });
119
+ continue;
120
+ }
121
+ if (typeof content === "string") {
122
+ const c = capText(content);
123
+ out.push({
124
+ kind: role === "user" ? "user_text" : "assistant_text",
125
+ timestamp: ts,
126
+ ...c,
127
+ });
128
+ continue;
129
+ }
130
+ if (!Array.isArray(content))
131
+ continue;
132
+ const outputTokens = role === "assistant" ? o.message?.usage?.output_tokens : undefined;
133
+ for (const b of content) {
134
+ if (typeof b !== "object" || b === null)
135
+ continue;
136
+ switch (b.type) {
137
+ case "text":
138
+ out.push({
139
+ kind: role === "user" ? "user_text" : "assistant_text",
140
+ timestamp: ts,
141
+ ...(typeof outputTokens === "number" ? { outputTokens } : {}),
142
+ ...capText(b.text ?? ""),
143
+ });
144
+ break;
145
+ case "thinking":
146
+ out.push({
147
+ kind: "thinking",
148
+ timestamp: ts,
149
+ ...capText(b.thinking ?? ""),
150
+ });
151
+ break;
152
+ case "tool_use":
153
+ out.push({
154
+ kind: "tool_use",
155
+ timestamp: ts,
156
+ name: b.name,
157
+ inputJson: JSON.stringify(b.input ?? {}),
158
+ });
159
+ break;
160
+ case "tool_result": {
161
+ const c = capText(toolResultText(b.content));
162
+ const toolUseId = typeof b.tool_use_id === "string" ? b.tool_use_id : undefined;
163
+ out.push({
164
+ kind: "tool_result",
165
+ timestamp: ts,
166
+ toolUseId,
167
+ isError: !!b.is_error,
168
+ ...c,
169
+ });
170
+ const filePath = o.tool_use_result?.file?.filePath;
171
+ if (typeof filePath === "string") {
172
+ const media = mediaFromLocalPath(filePath);
173
+ if (media) {
174
+ out.push({
175
+ kind: "image",
176
+ timestamp: ts,
177
+ toolUseId,
178
+ media,
179
+ });
180
+ }
181
+ }
182
+ break;
183
+ }
184
+ case "image": {
185
+ const media = mediaFromImageBlock(b);
186
+ out.push({
187
+ kind: "image",
188
+ timestamp: ts,
189
+ ...(media ? { media } : {}),
190
+ });
191
+ break;
192
+ }
193
+ }
194
+ }
195
+ }
196
+ return out;
197
+ }
198
+ /** True when a user row only carries tool_result blocks back to the model —
199
+ * turn internals, not a message the user typed. */
200
+ function isToolResultCarrier(content) {
201
+ return (Array.isArray(content) &&
202
+ content.some((b) => typeof b === "object" && b !== null && b.type === "tool_result"));
203
+ }
204
+ /** THE counting rule, shared by summarize() (windowed fallback) and the exact
205
+ * incremental message counter — one parsed jsonl entry advances the state.
206
+ * A message = a real user message (not tool_result carrier / Esc-interrupt /
207
+ * isMeta injection) or the start of an assistant turn (chunks merge; meta and
208
+ * tool_result rows do not break a turn). */
209
+ export function ccCountEntry(o, st) {
210
+ if (o?.type === "user") {
211
+ const c = o.message?.content;
212
+ if (!o.isMeta && !isToolResultCarrier(c) && !isAbortedUserContent(c)) {
213
+ st.messages++;
214
+ st.inAssistantTurn = false;
215
+ }
216
+ }
217
+ else if (o?.type === "assistant") {
218
+ if (!st.inAssistantTurn) {
219
+ st.messages++;
220
+ st.inAssistantTurn = true;
221
+ }
222
+ }
223
+ }
224
+ /** Raw-line reducer for createMessageCounter (claude engine). */
225
+ export function ccCountReducer(line, st) {
226
+ const o = parseLine(line);
227
+ if (o)
228
+ ccCountEntry(o, st);
229
+ }
230
+ /** Slice by Unicode code points — safer than UTF-16 .slice (no split surrogates).
231
+ * Grapheme clusters (e.g. ZWJ emoji sequences) may still split; acceptable for titles. */
232
+ function sliceCodePoints(text, max) {
233
+ return [...text].slice(0, max).join("");
234
+ }
235
+ /** Strip Claude Code's UI hint suffix from an away_summary recap. */
236
+ function cleanRecap(content) {
237
+ return content.replace(/\s*\(disable recaps in \/config\)\s*$/, "").trim();
238
+ }
239
+ /**
240
+ * Derive a list-row title from a user message, together with its source kind.
241
+ * Slash-command wrappers become "/name" with source "command";
242
+ * local-command caveats yield null so the next real message is tried;
243
+ * plain text returns the text with source "user-text".
244
+ */
245
+ export function titleFromUserText(text) {
246
+ const name = text.match(/<command-name>\s*(\/?[\w:-]+)\s*<\/command-name>/)?.[1] ??
247
+ text.match(/<command-message>\s*([\w:-]+)/)?.[1];
248
+ if (name)
249
+ return {
250
+ title: name.startsWith("/") ? name : "/" + name,
251
+ source: "command",
252
+ };
253
+ if (text.startsWith("<local-command-caveat>"))
254
+ return null;
255
+ return { title: text, source: "user-text" };
256
+ }
257
+ /** Derive the lightweight list-row fields from JSONL lines. */
258
+ export function summarize(lines) {
259
+ const entries = lines.map(parseLine).filter(Boolean);
260
+ let title = null;
261
+ let titleSource = null;
262
+ let cwd = null;
263
+ let firstMessageAt = null;
264
+ let lastAgentMessageAt = null;
265
+ const countState = { messages: 0, inAssistantTurn: false };
266
+ let recap = null;
267
+ const recent = [];
268
+ for (const o of entries) {
269
+ if (o.type === "ai-title" && o.aiTitle && !title) {
270
+ title = o.aiTitle;
271
+ titleSource = "ai-title";
272
+ }
273
+ if (cwd === null && typeof o.cwd === "string")
274
+ cwd = o.cwd;
275
+ if (o.type === "system" &&
276
+ o.subtype === "away_summary" &&
277
+ typeof o.content === "string") {
278
+ const cleaned = cleanRecap(o.content);
279
+ if (cleaned)
280
+ recap = cleaned; // latest one wins
281
+ }
282
+ if (o.type === "user" || o.type === "assistant") {
283
+ ccCountEntry(o, countState);
284
+ const ts = o.timestamp ?? null;
285
+ if (firstMessageAt === null)
286
+ firstMessageAt = ts;
287
+ if (o.type === "assistant" && ts)
288
+ lastAgentMessageAt = ts;
289
+ const text = blocksToText(o.message?.content);
290
+ if (title === null && o.type === "user" && text) {
291
+ const derived = titleFromUserText(text);
292
+ if (derived) {
293
+ title = sliceCodePoints(derived.title, 120);
294
+ titleSource = derived.source;
295
+ }
296
+ }
297
+ recent.push({
298
+ role: o.message?.role ?? o.type,
299
+ text: text.slice(0, RECENT_TEXT_CAP),
300
+ timestamp: ts,
301
+ });
302
+ }
303
+ }
304
+ const recentMessages = recent.slice(-RECENT_LIMIT);
305
+ const summary = recentMessages.length > 0
306
+ ? recentMessages[recentMessages.length - 1].text
307
+ .replace(/\s+/g, " ")
308
+ .slice(0, 200)
309
+ : null;
310
+ return {
311
+ title,
312
+ titleSource,
313
+ cwd,
314
+ firstMessageAt,
315
+ lastAgentMessageAt,
316
+ totalMessages: countState.messages,
317
+ summary,
318
+ recap,
319
+ recentMessages,
320
+ };
321
+ }
322
+ /**
323
+ * Merge head- and tail-window summaries of one session file.
324
+ * The freshest recap (Claude Code's live away_summary) wins the title slot —
325
+ * it tracks the whole conversation, unlike the first user message.
326
+ */
327
+ export function combineSummaries(headSum, tailSum) {
328
+ const recap = tailSum.recap ?? headSum.recap;
329
+ return {
330
+ title: recap ?? headSum.title ?? tailSum.title,
331
+ titleSource: recap
332
+ ? "recap"
333
+ : headSum.title
334
+ ? headSum.titleSource
335
+ : tailSum.titleSource,
336
+ cwd: headSum.cwd ?? tailSum.cwd,
337
+ firstMessageAt: headSum.firstMessageAt,
338
+ lastAgentMessageAt: tailSum.lastAgentMessageAt ?? headSum.lastAgentMessageAt,
339
+ totalMessages: Math.max(headSum.totalMessages, tailSum.totalMessages),
340
+ summary: tailSum.summary ?? headSum.summary,
341
+ recap,
342
+ recentMessages: tailSum.recentMessages.length
343
+ ? tailSum.recentMessages
344
+ : headSum.recentMessages,
345
+ };
346
+ }
@@ -0,0 +1,152 @@
1
+ // PTY turn-activity probe. Covers the Esc-before-output abort case the jsonl
2
+ // can NEVER show: pressing Esc before any assistant output writes nothing to the
3
+ // session file, so the phone's shape-derived spinner spins until its watchdog.
4
+ //
5
+ // Mechanism: the engine's TUI prints a "running marker" (e.g. "esc to interrupt")
6
+ // in its status line while a turn is in flight. For every hub term bound to a
7
+ // sessionId we substring-match each raw pty output chunk (ANSI-stripped,
8
+ // case-insensitive, with a small tail carry so a marker split across chunks
9
+ // still matches). Marker seen → armed + latched running (emit term_turn
10
+ // running:true on the transition).
11
+ //
12
+ // Idle detection is OUTPUT QUIESCENCE, not marker recency. Empirical (claude
13
+ // 2.1.170 in a real pty): the TUI uses incremental diff repaints — the marker
14
+ // string is painted ONCE when the spinner line first renders; subsequent
15
+ // animation ticks repaint only the changed chars (verb/seconds/tokens). So a
16
+ // marker-recency timeout would false-idle every turn longer than the window.
17
+ // But while a turn is genuinely running the pty is never quiet (spinner seconds
18
+ // repaint at least every ~1s; observed max gap 0.94s over 30s), and after Esc
19
+ // (or a normal turn end) output goes COMPLETELY silent. Hence: ANY output chunk
20
+ // on the term refreshes lastOutputAt; the sweep latches idle and emits
21
+ // term_turn running:false only after QUIET_MS of total pty silence (>8x margin
22
+ // vs the ~1s running repaint cadence).
23
+ //
24
+ // Note: a turn that ends NORMALLY also goes silent, so running:false is emitted
25
+ // ~QUIET_MS after completion too — correct and harmless: the phone gates on its
26
+ // own shape-derived working state and only uses this as an abort/death signal.
27
+ //
28
+ // Liveness heartbeat: while latched running, the sweep RE-EMITS running:true
29
+ // every HEARTBEAT_MS. This is the only true "still alive" signal the phone can
30
+ // get during a long single-turn generation that writes nothing to jsonl (e.g.
31
+ // building one big report): the TUI's token counter / spinner are local repaints
32
+ // that never reach the phone, so without this the phone's no-progress watchdog
33
+ // (180s) false-kills a live session as "stopped". The heartbeat is a TRUE
34
+ // signal — a dead/finished turn goes pty-silent within QUIET_MS and latches idle
35
+ // FIRST, so a heartbeat only ever fires while output is genuinely churning.
36
+ //
37
+ // Arm-on-sighting by design: if the CLI ever changes its TUI strings the probe
38
+ // simply never arms — silent no-op, zero false positives. running:false is
39
+ // NEVER emitted unless a marker was seen at least once on this term.
40
+ //
41
+ // Frame contract (rides the same cc_event path as the transcript watcher; the
42
+ // cloud relays unknown cc_event kinds transparently):
43
+ // { type:"cc_event", sessionId, kind:"term_turn", payload:{ running:boolean } }
44
+ const QUIET_MS = 8000; // total pty output silence → turn considered over
45
+ const SWEEP_MS = 2500; // idle-latch scan cadence
46
+ const HEARTBEAT_MS = 30000; // re-emit running:true this often while latched running
47
+ /** Strip ANSI CSI/OSC escape sequences — TUI redraws interleave them with text. */
48
+ export function stripAnsi(s) {
49
+ return s
50
+ .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)?/g, "") // OSC
51
+ .replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "") // CSI
52
+ .replace(/\x1b[@-Z\\-_]/g, ""); // 2-char escapes
53
+ }
54
+ export function createTurnProbe(opts) {
55
+ const markers = opts.markers.map((m) => m.toLowerCase()).filter(Boolean);
56
+ const maxMarkerLen = markers.reduce((a, m) => Math.max(a, m.length), 0);
57
+ const quietMs = opts.quietMs ?? QUIET_MS;
58
+ const heartbeatMs = opts.heartbeatMs ?? HEARTBEAT_MS;
59
+ const now = opts.now ?? Date.now;
60
+ const terms = new Map();
61
+ function emitTurn(sessionId, running) {
62
+ opts.emit({
63
+ type: "cc_event",
64
+ sessionId,
65
+ kind: "term_turn",
66
+ payload: { running },
67
+ });
68
+ }
69
+ const sweep = setInterval(() => {
70
+ const t = now();
71
+ for (const st of terms.values()) {
72
+ if (!st.armed || st.latched !== "running")
73
+ continue;
74
+ if (t - st.lastOutputAt > quietMs) {
75
+ st.latched = "idle";
76
+ emitTurn(st.sessionId, false);
77
+ }
78
+ else if (t - st.lastRunningEmitAt >= heartbeatMs) {
79
+ // pty still churning (not yet quiet) → session is demonstrably alive.
80
+ // Re-emit running:true so the phone's no-progress watchdog resets.
81
+ st.lastRunningEmitAt = t;
82
+ emitTurn(st.sessionId, true);
83
+ }
84
+ }
85
+ }, opts.sweepMs ?? SWEEP_MS);
86
+ sweep.unref?.();
87
+ return {
88
+ noteTermHello(termId, sessionId) {
89
+ if (!sessionId)
90
+ return; // unbound term (e.g. bare mirror) — nothing to attribute to
91
+ const existing = terms.get(termId);
92
+ if (existing) {
93
+ existing.sessionId = sessionId; // re-reg after a shell reconnect; keep probe state
94
+ return;
95
+ }
96
+ terms.set(termId, {
97
+ sessionId,
98
+ armed: false,
99
+ lastOutputAt: 0,
100
+ latched: null,
101
+ tail: "",
102
+ lastRunningEmitAt: 0,
103
+ });
104
+ },
105
+ noteOutput(termId, dataBase64) {
106
+ if (markers.length === 0)
107
+ return;
108
+ const st = terms.get(termId);
109
+ if (!st)
110
+ return; // not bound to a session
111
+ let text;
112
+ try {
113
+ text = Buffer.from(dataBase64, "base64").toString("utf8");
114
+ }
115
+ catch {
116
+ return;
117
+ }
118
+ // ANY output (marker or not) counts as turn activity — the TUI's diff
119
+ // repaints stop painting the marker after the first render, but never
120
+ // stop ticking the spinner while a turn runs.
121
+ st.lastOutputAt = now();
122
+ const hay = st.tail + stripAnsi(text).toLowerCase();
123
+ st.tail = hay.slice(-(maxMarkerLen - 1));
124
+ if (!markers.some((m) => hay.includes(m)))
125
+ return;
126
+ st.armed = true;
127
+ if (st.latched !== "running") {
128
+ st.latched = "running";
129
+ st.lastRunningEmitAt = now();
130
+ emitTurn(st.sessionId, true);
131
+ }
132
+ },
133
+ noteTermBye(termId) {
134
+ terms.delete(termId);
135
+ },
136
+ reemitForSession(sessionId) {
137
+ for (const st of terms.values()) {
138
+ if (st.sessionId !== sessionId)
139
+ continue;
140
+ if (st.armed && st.latched !== null) {
141
+ if (st.latched === "running")
142
+ st.lastRunningEmitAt = now();
143
+ emitTurn(st.sessionId, st.latched === "running");
144
+ }
145
+ }
146
+ },
147
+ stop() {
148
+ clearInterval(sweep);
149
+ terms.clear();
150
+ },
151
+ };
152
+ }
package/dist/types.js ADDED
@@ -0,0 +1,2 @@
1
+ // Shared data model — mirrors LoopWeave's SessionInfo, trimmed to read-only v1.
2
+ export {};