@kelceyp/caw-server 1.0.280 → 1.0.282

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.
@@ -0,0 +1,206 @@
1
+ // TranscriptTailer — polls a Claude Code session transcript JSONL file, forwards
2
+ // post-baseline entries to a consumer, and recovers transparently when the file
3
+ // shrinks, is replaced (dev/ino change), tears mid-write, or contains
4
+ // non-final unparseable lines. Sibling of `pty-wrapper.mjs`; not imported by it
5
+ // at this task's end-of-task (wired in at Task 6).
6
+ //
7
+ // Instance-owned mutable state — no module-level mutables. Two instances
8
+ // constructed against the same fixture path do not share state.
9
+
10
+ import * as fs from 'node:fs';
11
+
12
+ const TORN_PREFIX = 'tailTranscript: torn trailing line at cursor=';
13
+ const REOPENED_PREFIX = 'tailTranscript: reopened path=';
14
+ const UNPARSEABLE_PREFIX = 'tailTranscript: non-final unparseable line at offset=';
15
+ const UNUSABLE_PREFIX = 'tailTranscript: boundary unusable, cause=';
16
+ const READ_ERROR_PREFIX = 'tailTranscript: read error:';
17
+
18
+ // Regex UUID extraction — used by the pre-baseline walk and re-walks so a
19
+ // single unparseable line cannot poison the baseline the way per-line
20
+ // JSON.parse would.
21
+ const UUID_REGEX = /"uuid"\s*:\s*"([0-9a-fA-F-]+)"/g;
22
+
23
+ function extractUuidsInto(text, set) {
24
+ UUID_REGEX.lastIndex = 0;
25
+ let match;
26
+ while ((match = UUID_REGEX.exec(text)) !== null) {
27
+ set.add(match[1]);
28
+ }
29
+ }
30
+
31
+ export function createTranscriptTailer({ log } = {}) {
32
+ const emit = typeof log === 'function'
33
+ ? log
34
+ : (message) => { process.stderr.write(message + '\n'); };
35
+
36
+ const state = {
37
+ path: null,
38
+ dev: null,
39
+ ino: null,
40
+ cursor: 0,
41
+ preBaselineUUIDs: new Set(),
42
+ processedUUIDs: new Set(),
43
+ boundaryEstablished: false,
44
+ unusable: false,
45
+ closed: false,
46
+ };
47
+
48
+ function setUnusable(cause) {
49
+ if (state.unusable) return;
50
+ state.unusable = true;
51
+ emit(`${UNUSABLE_PREFIX}${cause} — subsequent ticks will return no entries`);
52
+ }
53
+
54
+ function initialiseBoundary(path, options = {}) {
55
+ if (state.boundaryEstablished) return;
56
+ state.boundaryEstablished = true;
57
+ state.path = path;
58
+ const { resume = false } = options;
59
+ if (!resume) {
60
+ state.cursor = 0;
61
+ state.preBaselineUUIDs = new Set();
62
+ return;
63
+ }
64
+ // Case B: --resume — synchronous pre-spawn baseline take.
65
+ try {
66
+ const stats = fs.statSync(path);
67
+ const content = fs.readFileSync(path, 'utf-8');
68
+ state.dev = stats.dev;
69
+ state.ino = stats.ino;
70
+ state.cursor = stats.size;
71
+ state.preBaselineUUIDs = new Set();
72
+ extractUuidsInto(content, state.preBaselineUUIDs);
73
+ } catch {
74
+ setUnusable('readError');
75
+ }
76
+ }
77
+
78
+ function reopenAndRewalkBoundary() {
79
+ if (!state.boundaryEstablished) return;
80
+ if (state.unusable) return;
81
+ try {
82
+ const prevDev = state.dev;
83
+ const prevIno = state.ino;
84
+ const stats = fs.statSync(state.path);
85
+ const content = fs.readFileSync(state.path, 'utf-8');
86
+ const rebuilt = new Set();
87
+ extractUuidsInto(content, rebuilt);
88
+ state.dev = stats.dev;
89
+ state.ino = stats.ino;
90
+ state.cursor = stats.size;
91
+ state.preBaselineUUIDs = rebuilt;
92
+ emit(
93
+ `${REOPENED_PREFIX}${state.path} dev=${state.dev} ino=${state.ino} `
94
+ + `(was dev=${prevDev} ino=${prevIno}), cursor=0, `
95
+ + `re-walking to rebuild preBaselineUUIDs (${rebuilt.size} entries suppressed from forwarding)`
96
+ );
97
+ } catch {
98
+ setUnusable('reopenFailed');
99
+ }
100
+ }
101
+
102
+ function tick() {
103
+ if (!state.boundaryEstablished) return [];
104
+ if (state.unusable) return [];
105
+ if (state.closed) return [];
106
+
107
+ let stats;
108
+ try {
109
+ stats = fs.statSync(state.path);
110
+ } catch (err) {
111
+ if (err && err.code === 'ENOENT') return [];
112
+ emit(`${READ_ERROR_PREFIX} ${err && err.message ? err.message : String(err)}`);
113
+ return [];
114
+ }
115
+
116
+ if (state.dev !== null && state.ino !== null) {
117
+ if (stats.dev !== state.dev || stats.ino !== state.ino || stats.size < state.cursor) {
118
+ reopenAndRewalkBoundary();
119
+ return [];
120
+ }
121
+ } else {
122
+ // First successful stat since boundary establishment (Case A): record
123
+ // (dev, ino) implicitly so subsequent ticks can detect replacement.
124
+ state.dev = stats.dev;
125
+ state.ino = stats.ino;
126
+ }
127
+
128
+ if (stats.size <= state.cursor) return [];
129
+
130
+ const toRead = stats.size - state.cursor;
131
+ let buf;
132
+ try {
133
+ const fd = fs.openSync(state.path, 'r');
134
+ try {
135
+ buf = Buffer.alloc(toRead);
136
+ let totalRead = 0;
137
+ while (totalRead < toRead) {
138
+ const n = fs.readSync(fd, buf, totalRead, toRead - totalRead, state.cursor + totalRead);
139
+ if (n === 0) break;
140
+ totalRead += n;
141
+ }
142
+ if (totalRead < toRead) buf = buf.slice(0, totalRead);
143
+ } finally {
144
+ fs.closeSync(fd);
145
+ }
146
+ } catch (err) {
147
+ emit(`${READ_ERROR_PREFIX} ${err && err.message ? err.message : String(err)}`);
148
+ return [];
149
+ }
150
+
151
+ const entries = [];
152
+ const startCursor = state.cursor;
153
+ let localOffset = 0;
154
+
155
+ while (localOffset < buf.length) {
156
+ const nlIdx = buf.indexOf(0x0A, localOffset);
157
+ if (nlIdx === -1) {
158
+ // Incomplete trailing line — leave cursor at its start; re-read next poll.
159
+ const tornPos = startCursor + localOffset;
160
+ state.cursor = tornPos;
161
+ emit(`${TORN_PREFIX}${tornPos}, deferring; will re-read next poll`);
162
+ return entries;
163
+ }
164
+ const line = buf.slice(localOffset, nlIdx).toString('utf-8');
165
+ const lineByteOffset = startCursor + localOffset;
166
+
167
+ if (line.length > 0) {
168
+ let entry;
169
+ let parsed = false;
170
+ try {
171
+ entry = JSON.parse(line);
172
+ parsed = true;
173
+ } catch {
174
+ emit(`${UNPARSEABLE_PREFIX}${lineByteOffset} — skipping (cursor still advances)`);
175
+ }
176
+ if (parsed) {
177
+ const uuid = entry && typeof entry === 'object' ? entry.uuid : undefined;
178
+ if (!uuid || (!state.preBaselineUUIDs.has(uuid) && !state.processedUUIDs.has(uuid))) {
179
+ if (uuid) state.processedUUIDs.add(uuid);
180
+ entries.push(entry);
181
+ }
182
+ }
183
+ }
184
+
185
+ localOffset = nlIdx + 1;
186
+ }
187
+
188
+ state.cursor = startCursor + buf.length;
189
+ return entries;
190
+ }
191
+
192
+ function close() {
193
+ state.closed = true;
194
+ }
195
+
196
+ return {
197
+ initialiseBoundary,
198
+ tick,
199
+ close,
200
+ reopenAndRewalkBoundary,
201
+ get path() { return state.path; },
202
+ get unusable() { return state.unusable; },
203
+ get preBaselineUUIDs() { return state.preBaselineUUIDs; },
204
+ get boundaryEstablished() { return state.boundaryEstablished; },
205
+ };
206
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kelceyp/caw-server",
3
- "version": "1.0.280",
3
+ "version": "1.0.282",
4
4
  "description": "CAW server - REST API, MCP server and core features",
5
5
  "type": "module",
6
6
  "main": "dist/main.js",
@@ -19,7 +19,7 @@
19
19
  "build": "bash scripts/bun-build.sh",
20
20
  "build:client": "cd ../client && bun run build",
21
21
  "build:cli": "cd ../cli && bun run build && cp dist/cli.js ../server/dist/",
22
- "build:all": "bun run clean && bun run generate:applyPojo && bun run build:client && bun run build && cp src/core/services/embedding-worker.js dist/embedding-worker.js && cp src/core/agents/pty-wrapper.mjs dist/pty-wrapper.mjs && cp src/core/agents/pty-deferred-teardown.mjs dist/pty-deferred-teardown.mjs && cp src/core/agents/pty-emit-result.mjs dist/pty-emit-result.mjs && cp src/core/agents/teardownReasons.mjs dist/teardownReasons.mjs && bun run build:cli",
22
+ "build:all": "bun run clean && bun run generate:applyPojo && bun run build:client && bun run build && cp src/core/services/embedding-worker.js dist/embedding-worker.js && cp src/core/agents/pty-wrapper.mjs dist/pty-wrapper.mjs && cp src/core/agents/pty-deferred-teardown.mjs dist/pty-deferred-teardown.mjs && cp src/core/agents/pty-emit-result.mjs dist/pty-emit-result.mjs && cp src/core/agents/teardownReasons.mjs dist/teardownReasons.mjs && cp src/core/agents/pty-non-interactive-setup.mjs dist/pty-non-interactive-setup.mjs && cp src/core/agents/transcript-tailer.mjs dist/transcript-tailer.mjs && cp src/core/agents/prompt-rejection-detector.mjs dist/prompt-rejection-detector.mjs && bun run build:cli",
23
23
  "dev": "STATIC_PATH=../client/src bun --define __BUILTIN_POSTHOG_KEY__=null run src/main.js",
24
24
  "start": "node dist/main.js",
25
25
  "test": "bun test --define __BUILTIN_POSTHOG_KEY__=null test/unit test/integration test/component test/cli test/investigation && (cd ../client && bun test)",