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.
- package/README.md +252 -0
- package/dist/attach-manager.js +259 -0
- package/dist/bridge.js +931 -0
- package/dist/cli-args.js +14 -0
- package/dist/cli.js +742 -0
- package/dist/codex-prompts.js +148 -0
- package/dist/codex-thread-source.js +495 -0
- package/dist/codex-transcript.js +415 -0
- package/dist/dev-server.js +126 -0
- package/dist/discovery.js +111 -0
- package/dist/e2e/codec.js +119 -0
- package/dist/e2e/crypto.js +127 -0
- package/dist/e2e/key-store.js +48 -0
- package/dist/e2e/keychain-identity.js +29 -0
- package/dist/engine/adapter.js +5 -0
- package/dist/engine/claude-adapter.js +77 -0
- package/dist/engine/codex-adapter.js +593 -0
- package/dist/file-preview.js +292 -0
- package/dist/hub-protocol.js +28 -0
- package/dist/hub-server.js +106 -0
- package/dist/hub.js +84 -0
- package/dist/install-util.js +33 -0
- package/dist/local-shell.js +32 -0
- package/dist/mcp-config.js +230 -0
- package/dist/mcp-device-proxy.js +501 -0
- package/dist/media-hydrator.js +222 -0
- package/dist/message-counter.js +79 -0
- package/dist/phone-probe.js +55 -0
- package/dist/prompt-detector.js +213 -0
- package/dist/protocol.js +3 -0
- package/dist/pty-mirror.js +80 -0
- package/dist/pty-spawn.js +53 -0
- package/dist/scanner.js +422 -0
- package/dist/self-update.js +122 -0
- package/dist/session-map.js +15 -0
- package/dist/session-runner.js +131 -0
- package/dist/shell.js +104 -0
- package/dist/skills-scanner.js +167 -0
- package/dist/stdin-encode.js +32 -0
- package/dist/stream-translate.js +122 -0
- package/dist/terminal-render.js +29 -0
- package/dist/transcript-watcher.js +138 -0
- package/dist/transcript.js +346 -0
- package/dist/turn-probe.js +152 -0
- package/dist/types.js +2 -0
- package/dist/watch-manager.js +77 -0
- package/install-cc.sh +90 -0
- package/install-codex.sh +97 -0
- package/package.json +39 -0
- 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