claude-code-kanban 4.29.0 → 4.31.0
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 +5 -4
- package/cli.js +406 -75
- package/install.js +9 -9
- package/lib/approvals.js +2 -2
- package/lib/contain.js +2 -2
- package/lib/dispatch-groups.js +28 -11
- package/lib/dispatch.js +1 -0
- package/lib/file-url.js +1 -1
- package/lib/inline-assets.js +2 -2
- package/lib/linked-docs.js +58 -0
- package/lib/net-guard.js +1 -1
- package/lib/open-editor.js +5 -3
- package/lib/parent-cache.js +58 -0
- package/lib/parsers.js +175 -168
- package/lib/plugin-status.js +38 -0
- package/lib/session-cache.js +34 -0
- package/lib/session-events.js +2 -1
- package/lib/task-counts.js +90 -0
- package/lib/terminal.js +9 -4
- package/package.json +2 -1
- package/plugin/plugins/claude-code-kanban/.claude-plugin/plugin.json +1 -1
- package/plugin/plugins/claude-code-kanban/scripts/postman.js +3 -0
- package/plugin/plugins/claude-code-kanban/skills/kanban/SKILL.md +16 -46
- package/public/app.js +817 -376
- package/public/index.html +51 -25
- package/public/link-url.js +15 -0
- package/public/project-match.js +27 -0
- package/public/style.css +385 -103
- package/public/sw.js +1 -1
- package/public/themes.css +49 -0
- package/server.js +185 -86
- package/skill-guides/dispatch.md +5 -4
package/lib/parsers.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
const fs = require('fs');
|
|
1
|
+
const fs = require('node:fs');
|
|
2
2
|
const { readFileSync, existsSync, readdirSync, statSync } = fs;
|
|
3
|
-
const path = require('path');
|
|
4
|
-
const
|
|
3
|
+
const path = require('node:path');
|
|
4
|
+
const os = require('node:os');
|
|
5
|
+
const { StringDecoder } = require('node:string_decoder');
|
|
5
6
|
|
|
6
7
|
function parseTask(raw) {
|
|
7
8
|
const task = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
|
@@ -12,7 +13,7 @@ function parseTask(raw) {
|
|
|
12
13
|
status: task.status,
|
|
13
14
|
blocks: task.blocks || [],
|
|
14
15
|
blockedBy: task.blockedBy || [],
|
|
15
|
-
isInternal: !!
|
|
16
|
+
isInternal: !!task.metadata?._internal,
|
|
16
17
|
raw: task
|
|
17
18
|
};
|
|
18
19
|
}
|
|
@@ -152,12 +153,12 @@ function formatTaskUsage(usage) {
|
|
|
152
153
|
const parts = [];
|
|
153
154
|
if (usage.subagentTokens != null) {
|
|
154
155
|
const t = usage.subagentTokens;
|
|
155
|
-
const tok = t >= 1000 ? (t / 1000).toFixed(1).replace(/\.0$/, '')
|
|
156
|
-
parts.push(tok
|
|
156
|
+
const tok = t >= 1000 ? `${(t / 1000).toFixed(1).replace(/\.0$/, '')}k` : String(t);
|
|
157
|
+
parts.push(`${tok} tok`);
|
|
157
158
|
}
|
|
158
159
|
if (usage.toolUses != null) parts.push(usage.toolUses + (usage.toolUses === 1 ? ' tool' : ' tools'));
|
|
159
|
-
if (usage.durationMs != null) parts.push(Math.round(usage.durationMs / 1000)
|
|
160
|
-
return parts.length ?
|
|
160
|
+
if (usage.durationMs != null) parts.push(`${Math.round(usage.durationMs / 1000)}s`);
|
|
161
|
+
return parts.length ? ` · ${parts.join(' · ')}` : '';
|
|
161
162
|
}
|
|
162
163
|
|
|
163
164
|
// Raw per-agent cost for the agent modal's chips plus the formatted one-liner for
|
|
@@ -196,7 +197,7 @@ function pushUserMessage(messages, text, timestamp, sysLabel, extras) {
|
|
|
196
197
|
const truncated = safeText.length > USER_TEXT_MAX;
|
|
197
198
|
const msg = {
|
|
198
199
|
type: 'user',
|
|
199
|
-
text: truncated ? safeText.slice(0, USER_TEXT_MAX)
|
|
200
|
+
text: truncated ? `${safeText.slice(0, USER_TEXT_MAX)}...` : safeText,
|
|
200
201
|
fullText: truncated ? safeText : null,
|
|
201
202
|
timestamp,
|
|
202
203
|
...(label && { systemLabel: label })
|
|
@@ -214,8 +215,8 @@ function pushUserMessage(messages, text, timestamp, sysLabel, extras) {
|
|
|
214
215
|
}
|
|
215
216
|
if (extras) {
|
|
216
217
|
if (extras.uuid) msg.uuid = extras.uuid;
|
|
217
|
-
if (extras.images
|
|
218
|
-
if (extras.toolResultRefs
|
|
218
|
+
if (extras.images?.length) msg.images = extras.images;
|
|
219
|
+
if (extras.toolResultRefs?.length) msg.toolResultRefs = extras.toolResultRefs;
|
|
219
220
|
if (extras.queued) msg.queued = true;
|
|
220
221
|
}
|
|
221
222
|
// One agent message reaches the log twice: the queued enqueue record and the
|
|
@@ -231,27 +232,37 @@ function pushUserMessage(messages, text, timestamp, sysLabel, extras) {
|
|
|
231
232
|
messages.push(msg);
|
|
232
233
|
}
|
|
233
234
|
|
|
234
|
-
// Cache: jsonlPath -> { scannedUpTo, customTitle }
|
|
235
|
+
// Cache: jsonlPath -> { ino, mtimeMs, scannedUpTo, customTitle }
|
|
235
236
|
// Only re-scan the new bytes appended since last scan
|
|
236
237
|
const customTitleCache = new Map();
|
|
238
|
+
let sessionCacheDirty = false;
|
|
239
|
+
|
|
240
|
+
// An entry scanned up to `scannedUpTo` still describes the file when the file is the same one
|
|
241
|
+
// and has only grown. A same-size file with a new mtime was rewritten in place. The entries
|
|
242
|
+
// outlive the process (exportSessionCaches), so this is the only check between two runs.
|
|
243
|
+
function sameFileGrown(cached, stat) {
|
|
244
|
+
if (!cached || cached.ino !== stat.ino || stat.size < cached.scannedUpTo) return false;
|
|
245
|
+
return stat.size > cached.scannedUpTo || cached.mtimeMs === stat.mtimeMs;
|
|
246
|
+
}
|
|
237
247
|
const CUSTOM_TITLE_SCAN_SIZE = 1048576; // 1MB max scan on first read
|
|
238
248
|
|
|
239
249
|
// Returns the best title found, in priority order:
|
|
240
250
|
// custom-title (user override) > ai-title > agent-name
|
|
241
251
|
// Background-session JSONLs created by the "claude agents" feature only emit
|
|
242
252
|
// ai-title/agent-name records; older sessions emit custom-title.
|
|
253
|
+
const TITLE_MARKER_TEXT = ['"custom-title"', '"ai-title"', '"agent-name"'];
|
|
254
|
+
const TITLE_MARKERS = TITLE_MARKER_TEXT.map((m) => Buffer.from(m));
|
|
255
|
+
const hasTitleMarker = (s) => TITLE_MARKER_TEXT.some((m) => s.includes(m));
|
|
256
|
+
|
|
243
257
|
function extractCustomTitleFromText(text) {
|
|
244
|
-
|
|
245
|
-
const hasAi = text.includes('"ai-title"');
|
|
246
|
-
const hasAgent = text.includes('"agent-name"');
|
|
247
|
-
if (!hasCustom && !hasAi && !hasAgent) return null;
|
|
258
|
+
if (!hasTitleMarker(text)) return null;
|
|
248
259
|
const lines = text.split('\n');
|
|
249
260
|
let customTitle = null;
|
|
250
261
|
let aiTitle = null;
|
|
251
262
|
let agentName = null;
|
|
252
263
|
for (let i = lines.length - 1; i >= 0; i--) {
|
|
253
264
|
const line = lines[i];
|
|
254
|
-
if (!
|
|
265
|
+
if (!hasTitleMarker(line)) continue;
|
|
255
266
|
try {
|
|
256
267
|
const data = JSON.parse(line);
|
|
257
268
|
if (!customTitle && data.type === 'custom-title' && data.customTitle && !data.customTitle.startsWith('<')) {
|
|
@@ -262,17 +273,37 @@ function extractCustomTitleFromText(text) {
|
|
|
262
273
|
agentName = data.agentName;
|
|
263
274
|
}
|
|
264
275
|
if (customTitle) break;
|
|
265
|
-
} catch
|
|
276
|
+
} catch {}
|
|
266
277
|
}
|
|
267
278
|
return customTitle || aiTitle || agentName || null;
|
|
268
279
|
}
|
|
269
280
|
|
|
281
|
+
// Same answer as extractCustomTitleFromText(buf.toString('utf8')): that function only looks at
|
|
282
|
+
// lines holding a marker, and a line cut at \n decodes the same alone as inside the chunk.
|
|
283
|
+
function extractCustomTitleFromBuffer(buf) {
|
|
284
|
+
const starts = new Set();
|
|
285
|
+
for (const m of TITLE_MARKERS) {
|
|
286
|
+
for (let i = buf.indexOf(m); i !== -1; i = buf.indexOf(m, i + m.length)) {
|
|
287
|
+
starts.add(buf.lastIndexOf(0x0a, i) + 1);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
if (!starts.size) return null;
|
|
291
|
+
const lines = [...starts]
|
|
292
|
+
.sort((a, b) => a - b)
|
|
293
|
+
.map((s) => {
|
|
294
|
+
const e = buf.indexOf(0x0a, s);
|
|
295
|
+
return buf.toString('utf8', s, e === -1 ? buf.length : e);
|
|
296
|
+
});
|
|
297
|
+
return extractCustomTitleFromText(lines.join('\n'));
|
|
298
|
+
}
|
|
299
|
+
|
|
270
300
|
function readCustomTitle(jsonlPath, existingStat) {
|
|
271
301
|
try {
|
|
272
302
|
const stat = existingStat || statSync(jsonlPath);
|
|
273
|
-
const
|
|
303
|
+
const prev = customTitleCache.get(jsonlPath);
|
|
304
|
+
const cached = sameFileGrown(prev, stat) ? prev : null;
|
|
274
305
|
|
|
275
|
-
if (cached && cached.scannedUpTo
|
|
306
|
+
if (cached && cached.scannedUpTo === stat.size) return cached.customTitle;
|
|
276
307
|
|
|
277
308
|
let customTitle = cached?.customTitle || null;
|
|
278
309
|
const fd = fs.openSync(jsonlPath, 'r');
|
|
@@ -282,23 +313,23 @@ function readCustomTitle(jsonlPath, existingStat) {
|
|
|
282
313
|
if (len > 0) {
|
|
283
314
|
const buf = Buffer.alloc(len);
|
|
284
315
|
fs.readSync(fd, buf, 0, len, cached.scannedUpTo);
|
|
285
|
-
customTitle =
|
|
316
|
+
customTitle = extractCustomTitleFromBuffer(buf) || customTitle;
|
|
286
317
|
}
|
|
287
318
|
} else {
|
|
319
|
+
// The last chunk holding a title wins, so scan from the end and stop at the first hit.
|
|
288
320
|
const CHUNK = CUSTOM_TITLE_SCAN_SIZE;
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
const
|
|
292
|
-
|
|
293
|
-
const found = extractCustomTitleFromText(buf.toString('utf8'));
|
|
294
|
-
if (found) customTitle = found;
|
|
321
|
+
const buf = Buffer.allocUnsafe(Math.min(CHUNK, stat.size));
|
|
322
|
+
for (let offset = Math.floor((stat.size - 1) / CHUNK) * CHUNK; offset >= 0 && !customTitle; offset -= CHUNK) {
|
|
323
|
+
const n = fs.readSync(fd, buf, 0, Math.min(CHUNK, stat.size - offset), offset);
|
|
324
|
+
customTitle = extractCustomTitleFromBuffer(buf.subarray(0, n));
|
|
295
325
|
}
|
|
296
326
|
}
|
|
297
327
|
|
|
298
328
|
fs.closeSync(fd);
|
|
299
|
-
customTitleCache.set(jsonlPath, { scannedUpTo: stat.size, customTitle });
|
|
329
|
+
customTitleCache.set(jsonlPath, { ino: stat.ino, mtimeMs: stat.mtimeMs, scannedUpTo: stat.size, customTitle });
|
|
330
|
+
sessionCacheDirty = true;
|
|
300
331
|
return customTitle;
|
|
301
|
-
} catch
|
|
332
|
+
} catch {
|
|
302
333
|
return null;
|
|
303
334
|
}
|
|
304
335
|
}
|
|
@@ -310,75 +341,18 @@ const SCRAPE_GITBRANCH_RE = /"gitBranch"\s*:\s*"((?:[^"\\]|\\.)*)"/;
|
|
|
310
341
|
function scrapeScalarFromBlob(blob, re) {
|
|
311
342
|
const m = blob.match(re);
|
|
312
343
|
if (!m) return null;
|
|
313
|
-
try { return JSON.parse(`"${m[1]}"`); } catch
|
|
344
|
+
try { return JSON.parse(`"${m[1]}"`); } catch { return null; }
|
|
314
345
|
}
|
|
315
346
|
|
|
316
347
|
const sessionInfoCache = new Map();
|
|
317
348
|
const SESSION_INFO_CACHE_MAX = 2000;
|
|
318
349
|
|
|
319
|
-
// Session-scoped /goal: a goal_status attachment carries the active condition
|
|
320
|
-
// (latest-wins as the Stop hook re-evaluates). Only an unmet goal is "active" —
|
|
321
|
-
// a met goal auto-clears in Claude Code, so treat met as removal, same as a
|
|
322
|
-
// `/goal clear` command line. Returns the goal value to apply, or `undefined`
|
|
323
|
-
// when the line is not a goal event (caller keeps its current value).
|
|
324
|
-
function extractGoalFromLine(data) {
|
|
325
|
-
if (data.type === 'attachment' && data.attachment?.type === 'goal_status') {
|
|
326
|
-
return data.attachment.met ? null : { condition: data.attachment.condition || '' };
|
|
327
|
-
}
|
|
328
|
-
// `/goal clear` is a type:system local_command line carrying the command
|
|
329
|
-
// markup in `content`; a user-typed command line carries it in message.content.
|
|
330
|
-
const content = data.content ?? data.message?.content;
|
|
331
|
-
if (typeof content === 'string' && content.includes(GOAL_COMMAND_TAG) && /<command-args>\s*clear/.test(content)) {
|
|
332
|
-
return null;
|
|
333
|
-
}
|
|
334
|
-
return undefined;
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
const GOAL_COMMAND_TAG = '<command-name>/goal</command-name>';
|
|
338
|
-
const GOAL_MARKERS = ['"goal_status"', GOAL_COMMAND_TAG].map((m) => Buffer.from(m));
|
|
339
|
-
const SCAN_CHUNK = 65536;
|
|
340
|
-
const scanBuf = Buffer.allocUnsafe(SCAN_CHUNK);
|
|
341
|
-
|
|
342
|
-
// Streams [from, to) and calls onLine for the complete lines of every chunk
|
|
343
|
-
// that contains a marker. Chunks without one are never decoded, which keeps a
|
|
344
|
-
// full pass close to raw read speed (measured ~4x cheaper than decode+split).
|
|
345
|
-
function scanMarkedLines(fd, from, to, markers, onLine) {
|
|
346
|
-
let carry = Buffer.alloc(0);
|
|
347
|
-
let offset = from;
|
|
348
|
-
while (offset < to) {
|
|
349
|
-
const n = fs.readSync(fd, scanBuf, 0, Math.min(SCAN_CHUNK, to - offset), offset);
|
|
350
|
-
if (n === 0) break;
|
|
351
|
-
offset += n;
|
|
352
|
-
const view = carry.length ? Buffer.concat([carry, scanBuf.subarray(0, n)]) : scanBuf.subarray(0, n);
|
|
353
|
-
const cut = view.lastIndexOf(0x0a) + 1;
|
|
354
|
-
const body = view.subarray(0, cut);
|
|
355
|
-
carry = Buffer.from(view.subarray(cut));
|
|
356
|
-
if (markers.some((m) => body.includes(m))) for (const line of body.toString('utf8').split('\n')) onLine(line);
|
|
357
|
-
}
|
|
358
|
-
if (carry.length && markers.some((m) => carry.includes(m))) onLine(carry.toString('utf8'));
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
// Goal events are rare and can sit anywhere in a long JSONL, outside both the
|
|
362
|
-
// head and tail windows. Returns the last goal update in [from, to), or
|
|
363
|
-
// undefined when there is none.
|
|
364
|
-
function scanGoalInRange(fd, from, to) {
|
|
365
|
-
let latest;
|
|
366
|
-
scanMarkedLines(fd, from, to, GOAL_MARKERS, (line) => {
|
|
367
|
-
if (!line.includes('"goal_status"') && !line.includes(GOAL_COMMAND_TAG)) return;
|
|
368
|
-
try {
|
|
369
|
-
const update = extractGoalFromLine(JSON.parse(line));
|
|
370
|
-
if (update !== undefined) latest = update;
|
|
371
|
-
} catch (_) {}
|
|
372
|
-
});
|
|
373
|
-
return latest;
|
|
374
|
-
}
|
|
375
|
-
|
|
376
350
|
// gitBranch in the JSONL is pinned to the launch-time repo by Claude Code
|
|
377
351
|
// and goes stale once cwd shifts (Bash `cd`, submodule). Callers needing the
|
|
378
352
|
// live branch must resolve it from cwd separately. Cache is reset on inode
|
|
379
353
|
// change or truncation (size < scannedUpTo).
|
|
380
354
|
function readSessionInfoFromJsonl(jsonlPath) {
|
|
381
|
-
const result = { slug: null, projectPath: null, cwd: null, gitBranch: null, customTitle: null, logicalParentUuid: null, compactBoundaryUuid: null
|
|
355
|
+
const result = { slug: null, projectPath: null, cwd: null, gitBranch: null, customTitle: null, logicalParentUuid: null, compactBoundaryUuid: null };
|
|
382
356
|
let stat;
|
|
383
357
|
let fd;
|
|
384
358
|
try {
|
|
@@ -388,9 +362,7 @@ function readSessionInfoFromJsonl(jsonlPath) {
|
|
|
388
362
|
}
|
|
389
363
|
|
|
390
364
|
const cached = sessionInfoCache.get(jsonlPath);
|
|
391
|
-
const
|
|
392
|
-
const sizeOK = cached && stat.size >= cached.scannedUpTo;
|
|
393
|
-
const canIncrement = inodeMatch && sizeOK;
|
|
365
|
+
const canIncrement = sameFileGrown(cached, stat);
|
|
394
366
|
|
|
395
367
|
if (canIncrement && stat.size === cached.scannedUpTo) {
|
|
396
368
|
return {
|
|
@@ -400,7 +372,6 @@ function readSessionInfoFromJsonl(jsonlPath) {
|
|
|
400
372
|
gitBranch: cached.gitBranch,
|
|
401
373
|
logicalParentUuid: cached.logicalParentUuid || null,
|
|
402
374
|
compactBoundaryUuid: cached.compactBoundaryUuid || null,
|
|
403
|
-
goal: cached.goal || null,
|
|
404
375
|
customTitle: readCustomTitle(jsonlPath, stat)
|
|
405
376
|
};
|
|
406
377
|
}
|
|
@@ -412,7 +383,6 @@ function readSessionInfoFromJsonl(jsonlPath) {
|
|
|
412
383
|
result.gitBranch = cached.gitBranch;
|
|
413
384
|
result.logicalParentUuid = cached.logicalParentUuid || null;
|
|
414
385
|
result.compactBoundaryUuid = cached.compactBoundaryUuid || null;
|
|
415
|
-
result.goal = cached.goal || null;
|
|
416
386
|
}
|
|
417
387
|
|
|
418
388
|
let lastCwdSeen = result.cwd;
|
|
@@ -432,10 +402,7 @@ function readSessionInfoFromJsonl(jsonlPath) {
|
|
|
432
402
|
// its presence in the parent JSONL to tell the two apart.
|
|
433
403
|
result.compactBoundaryUuid = data.uuid || null;
|
|
434
404
|
}
|
|
435
|
-
|
|
436
|
-
const goalUpdate = extractGoalFromLine(data);
|
|
437
|
-
if (goalUpdate !== undefined) result.goal = goalUpdate;
|
|
438
|
-
} catch (e) {}
|
|
405
|
+
} catch {}
|
|
439
406
|
};
|
|
440
407
|
|
|
441
408
|
const CHUNK_SIZE = 16384;
|
|
@@ -454,12 +421,6 @@ function readSessionInfoFromJsonl(jsonlPath) {
|
|
|
454
421
|
if (deltaLen > 0) {
|
|
455
422
|
const readLen = Math.min(deltaLen, DELTA_MAX);
|
|
456
423
|
const readStart = stat.size - readLen;
|
|
457
|
-
// The bytes an over-cap delta skips may hold a goal set, met or clear;
|
|
458
|
-
// scanned first so the delta's own lines win by order.
|
|
459
|
-
if (deltaLen > DELTA_MAX) {
|
|
460
|
-
const skipped = scanGoalInRange(fd, cached.scannedUpTo, readStart);
|
|
461
|
-
if (skipped !== undefined) result.goal = skipped;
|
|
462
|
-
}
|
|
463
424
|
const buf = Buffer.alloc(readLen);
|
|
464
425
|
const n = fs.readSync(fd, buf, 0, readLen, readStart);
|
|
465
426
|
if (n > 0) {
|
|
@@ -505,9 +466,6 @@ function readSessionInfoFromJsonl(jsonlPath) {
|
|
|
505
466
|
const tn = fs.readSync(fd, tailBuf, 0, TAIL_SIZE, tailStart);
|
|
506
467
|
const lines = tailBuf.toString('utf8', 0, tn).split('\n');
|
|
507
468
|
let latestTailCwd = null;
|
|
508
|
-
// undefined = no goal event seen in tail (keep head's); otherwise the
|
|
509
|
-
// most recent tail goal/clear supersedes head (tail bytes are later).
|
|
510
|
-
let tailGoal;
|
|
511
469
|
for (let i = lines.length - 1; i >= 0; i--) {
|
|
512
470
|
try {
|
|
513
471
|
const data = JSON.parse(lines[i]);
|
|
@@ -515,22 +473,16 @@ function readSessionInfoFromJsonl(jsonlPath) {
|
|
|
515
473
|
if (!result.projectPath && data.cwd) result.projectPath = data.cwd;
|
|
516
474
|
if (!result.gitBranch && data.gitBranch) result.gitBranch = data.gitBranch;
|
|
517
475
|
if (!latestTailCwd && data.cwd) latestTailCwd = data.cwd;
|
|
518
|
-
if (
|
|
519
|
-
|
|
520
|
-
} catch (e) {}
|
|
476
|
+
if (latestTailCwd && result.slug && result.projectPath && result.gitBranch) break;
|
|
477
|
+
} catch {}
|
|
521
478
|
}
|
|
522
479
|
if (latestTailCwd) lastCwdSeen = latestTailCwd;
|
|
523
|
-
// A goal can be set, met or cleared between the head and tail windows.
|
|
524
|
-
// The scan starts at the head's last partial line and runs to EOF, so a
|
|
525
|
-
// tail event is simply seen again in order.
|
|
526
|
-
const goal = tailGoal !== undefined ? tailGoal : scanGoalInRange(fd, offset - Buffer.byteLength(leftover), stat.size);
|
|
527
|
-
if (goal !== undefined) result.goal = goal;
|
|
528
480
|
}
|
|
529
481
|
scannedUpTo = stat.size;
|
|
530
482
|
}
|
|
531
|
-
} catch
|
|
483
|
+
} catch {
|
|
532
484
|
} finally {
|
|
533
|
-
if (fd !== undefined) { try { fs.closeSync(fd); } catch
|
|
485
|
+
if (fd !== undefined) { try { fs.closeSync(fd); } catch {} }
|
|
534
486
|
}
|
|
535
487
|
|
|
536
488
|
result.cwd = lastCwdSeen;
|
|
@@ -538,25 +490,59 @@ function readSessionInfoFromJsonl(jsonlPath) {
|
|
|
538
490
|
if (stat) {
|
|
539
491
|
sessionInfoCache.set(jsonlPath, {
|
|
540
492
|
ino: stat.ino,
|
|
541
|
-
|
|
493
|
+
mtimeMs: stat.mtimeMs,
|
|
494
|
+
birthtimeMs: stat.birthtimeMs,
|
|
542
495
|
scannedUpTo,
|
|
543
496
|
slug: result.slug,
|
|
544
497
|
projectPath: result.projectPath,
|
|
545
498
|
gitBranch: result.gitBranch,
|
|
546
499
|
cwd: result.cwd,
|
|
547
500
|
logicalParentUuid: result.logicalParentUuid,
|
|
548
|
-
compactBoundaryUuid: result.compactBoundaryUuid
|
|
549
|
-
goal: result.goal
|
|
501
|
+
compactBoundaryUuid: result.compactBoundaryUuid
|
|
550
502
|
});
|
|
551
503
|
if (sessionInfoCache.size > SESSION_INFO_CACHE_MAX) {
|
|
552
504
|
const firstKey = sessionInfoCache.keys().next().value;
|
|
553
505
|
sessionInfoCache.delete(firstKey);
|
|
554
506
|
}
|
|
507
|
+
sessionCacheDirty = true;
|
|
555
508
|
}
|
|
556
509
|
result.customTitle = readCustomTitle(jsonlPath, stat);
|
|
557
510
|
return result;
|
|
558
511
|
}
|
|
559
512
|
|
|
513
|
+
// Counts the transcripts of one project folder that were read since start (or loaded from the
|
|
514
|
+
// session cache). Entries of deleted files stay until the next save, so a deletion does not
|
|
515
|
+
// lower the count.
|
|
516
|
+
function countTranscriptsBornBefore(dir, birthtimeMs) {
|
|
517
|
+
let n = 0;
|
|
518
|
+
for (const [p, e] of sessionInfoCache) if (e.birthtimeMs < birthtimeMs && path.dirname(p) === dir) n++;
|
|
519
|
+
return n;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
const sessionCachesDirty = () => sessionCacheDirty;
|
|
523
|
+
|
|
524
|
+
// Snapshot of the per-transcript caches for lib/session-cache.js. Entries of deleted
|
|
525
|
+
// transcripts are left out, so the snapshot never holds more than the live set.
|
|
526
|
+
function exportSessionCaches() {
|
|
527
|
+
sessionCacheDirty = false;
|
|
528
|
+
const livePaths = new Set([...sessionInfoCache.keys(), ...customTitleCache.keys()].filter((p) => fs.existsSync(p)));
|
|
529
|
+
const live = (map) => [...map].filter(([p]) => livePaths.has(p));
|
|
530
|
+
return { info: live(sessionInfoCache), titles: live(customTitleCache) };
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function importSessionCaches(snapshot) {
|
|
534
|
+
const valid = (e) => e && typeof e === 'object' && typeof e.ino === 'number' && typeof e.scannedUpTo === 'number' && typeof e.mtimeMs === 'number';
|
|
535
|
+
const load = (map, entries) => {
|
|
536
|
+
if (!Array.isArray(entries)) return;
|
|
537
|
+
for (const pair of entries) {
|
|
538
|
+
if (!Array.isArray(pair) || typeof pair[0] !== 'string' || !valid(pair[1])) continue;
|
|
539
|
+
map.set(pair[0], pair[1]);
|
|
540
|
+
}
|
|
541
|
+
};
|
|
542
|
+
load(sessionInfoCache, snapshot?.info?.slice(-SESSION_INFO_CACHE_MAX));
|
|
543
|
+
load(customTitleCache, snapshot?.titles?.slice(-SESSION_INFO_CACHE_MAX));
|
|
544
|
+
}
|
|
545
|
+
|
|
560
546
|
// Background-task completion records arrive as a user message whose content is a
|
|
561
547
|
// <task-notification> envelope. Rendering the raw text leaks the tag values as a
|
|
562
548
|
// run-on line (task-id + tool-use-id + output-file path) plus the <usage> numbers
|
|
@@ -674,7 +660,7 @@ function readRecentMessages(jsonlPath, limit = 10) {
|
|
|
674
660
|
let fd;
|
|
675
661
|
try {
|
|
676
662
|
const stat = statSync(jsonlPath);
|
|
677
|
-
fd =
|
|
663
|
+
fd = fs.openSync(jsonlPath, 'r');
|
|
678
664
|
const messages = [];
|
|
679
665
|
const toolResults = new Map();
|
|
680
666
|
const toolResultExtras = new Map();
|
|
@@ -686,7 +672,7 @@ function readRecentMessages(jsonlPath, limit = 10) {
|
|
|
686
672
|
const start = Math.max(0, stat.size - readSize);
|
|
687
673
|
const bufSize = readSize;
|
|
688
674
|
const buf = Buffer.alloc(bufSize);
|
|
689
|
-
|
|
675
|
+
fs.readSync(fd, buf, 0, bufSize, start);
|
|
690
676
|
|
|
691
677
|
const text = buf.toString('utf8');
|
|
692
678
|
const firstNewline = text.indexOf('\n');
|
|
@@ -706,7 +692,7 @@ function readRecentMessages(jsonlPath, limit = 10) {
|
|
|
706
692
|
const truncated = block.text.length > 500;
|
|
707
693
|
messages.push({
|
|
708
694
|
type: 'assistant',
|
|
709
|
-
text: truncated ? block.text.slice(0, 500)
|
|
695
|
+
text: truncated ? `${block.text.slice(0, 500)}...` : block.text,
|
|
710
696
|
fullText: truncated ? block.text : null,
|
|
711
697
|
timestamp: obj.timestamp,
|
|
712
698
|
model: obj.message.model || null
|
|
@@ -718,33 +704,33 @@ function readRecentMessages(jsonlPath, limit = 10) {
|
|
|
718
704
|
if (block.input) {
|
|
719
705
|
inp = typeof block.input === 'string' ? (() => { try { return JSON.parse(block.input); } catch(_) { return {}; } })() : block.input;
|
|
720
706
|
if (inp.file_path) { detail = inp.file_path.replace(/^.*[/\\]/, ''); fullDetail = inp.file_path; }
|
|
721
|
-
else if (inp.command) { detail = inp.command.length > 80 ? inp.command.slice(0, 80)
|
|
707
|
+
else if (inp.command) { detail = inp.command.length > 80 ? `${inp.command.slice(0, 80)}...` : inp.command; fullDetail = inp.command; }
|
|
722
708
|
else if (inp.pattern) { detail = inp.pattern; fullDetail = inp.pattern; }
|
|
723
709
|
else if (inp.query) { detail = inp.query; fullDetail = inp.query; }
|
|
724
|
-
else if (inp.url) { detail = inp.url.length > 80 ? inp.url.slice(0, 80)
|
|
725
|
-
else if (inp.skill) { const s = inp.skill + (typeof inp.args === 'string' ?
|
|
710
|
+
else if (inp.url) { detail = inp.url.length > 80 ? `${inp.url.slice(0, 80)}...` : inp.url; fullDetail = inp.url; }
|
|
711
|
+
else if (inp.skill) { const s = inp.skill + (typeof inp.args === 'string' ? ` ${inp.args}` : ''); detail = s.length > 80 ? `${s.slice(0, 80)}...` : s; fullDetail = s; }
|
|
726
712
|
else if (inp.questions && Array.isArray(inp.questions)) {
|
|
727
|
-
const parts = inp.questions.map(q => (q.header ? q.header
|
|
713
|
+
const parts = inp.questions.map(q => (q.header ? `${q.header}: ` : '') + q.question);
|
|
728
714
|
const s = parts.join(' | ');
|
|
729
|
-
detail = s.length > 80 ? s.slice(0, 80)
|
|
715
|
+
detail = s.length > 80 ? `${s.slice(0, 80)}...` : s;
|
|
730
716
|
fullDetail = inp.questions.map(q => {
|
|
731
|
-
let text = (q.header ?
|
|
732
|
-
if (q.options) text +=
|
|
717
|
+
let text = (q.header ? `[${q.header}] ` : '') + q.question;
|
|
718
|
+
if (q.options) text += `\n\n${q.options.map((o, j) => ` ${j + 1}. ${o.label}${o.description ? ` — ${o.description}` : ''}`).join('\n')}`;
|
|
733
719
|
return text;
|
|
734
720
|
}).join('\n\n');
|
|
735
721
|
}
|
|
736
722
|
else if (inp.to) {
|
|
737
723
|
const proto = inp.message && typeof inp.message === 'object' ? inp.message : null;
|
|
738
724
|
if (proto?.type === 'shutdown_request') {
|
|
739
|
-
detail =
|
|
725
|
+
detail = `→ ${inp.to}: shutdown request${proto.reason ? ` (${proto.reason})` : ''}`;
|
|
740
726
|
} else if (proto?.type === 'shutdown_response') {
|
|
741
|
-
detail =
|
|
727
|
+
detail = `→ ${inp.to}: ${proto.approve ? 'shutdown approved' : 'shutdown rejected'}`;
|
|
742
728
|
} else if (proto?.type === 'plan_approval_response') {
|
|
743
|
-
detail =
|
|
729
|
+
detail = `→ ${inp.to}: ${proto.approve ? 'plan approved' : 'plan rejected'}`;
|
|
744
730
|
} else {
|
|
745
|
-
detail =
|
|
731
|
+
detail = `→ ${inp.to}${inp.summary ? `: ${inp.summary}` : ''}`;
|
|
746
732
|
}
|
|
747
|
-
if (detail.length > 80) detail = detail.slice(0, 80)
|
|
733
|
+
if (detail.length > 80) detail = `${detail.slice(0, 80)}...`;
|
|
748
734
|
fullDetail = typeof inp.message === 'string' ? inp.message : JSON.stringify(inp.message);
|
|
749
735
|
}
|
|
750
736
|
else if (inp.plan) {
|
|
@@ -765,7 +751,7 @@ function readRecentMessages(jsonlPath, limit = 10) {
|
|
|
765
751
|
if (inp.file_path) params.file_path = inp.file_path;
|
|
766
752
|
if (inp.content) {
|
|
767
753
|
if (inp.content.length > TOOL_RESULT_MAX) {
|
|
768
|
-
params.content = inp.content.slice(0, TOOL_RESULT_MAX)
|
|
754
|
+
params.content = `${inp.content.slice(0, TOOL_RESULT_MAX)}\n... (truncated)`;
|
|
769
755
|
params.contentFull = inp.content;
|
|
770
756
|
} else {
|
|
771
757
|
params.content = inp.content;
|
|
@@ -807,7 +793,7 @@ function readRecentMessages(jsonlPath, limit = 10) {
|
|
|
807
793
|
} else if (block.name === 'TaskCreate') {
|
|
808
794
|
if (inp.subject) params.subject = inp.subject;
|
|
809
795
|
} else if (block.name === 'TaskUpdate') {
|
|
810
|
-
if (inp.taskId) params.taskId =
|
|
796
|
+
if (inp.taskId) params.taskId = `#${inp.taskId}`;
|
|
811
797
|
if (inp.status) params.status = inp.status;
|
|
812
798
|
} else if (block.name === 'NotebookEdit') {
|
|
813
799
|
if (inp.command) params.command = inp.command;
|
|
@@ -833,8 +819,8 @@ function readRecentMessages(jsonlPath, limit = 10) {
|
|
|
833
819
|
for (const [k, v] of Object.entries(inp)) {
|
|
834
820
|
if (k === 'description' || v == null) continue;
|
|
835
821
|
if (typeof v === 'string' && v.length > TOOL_RESULT_MAX) {
|
|
836
|
-
params[k] = v.slice(0, TOOL_RESULT_MAX)
|
|
837
|
-
params[k
|
|
822
|
+
params[k] = `${v.slice(0, TOOL_RESULT_MAX)}\n... (truncated)`;
|
|
823
|
+
params[`${k}Full`] = v;
|
|
838
824
|
} else {
|
|
839
825
|
params[k] = v;
|
|
840
826
|
}
|
|
@@ -896,7 +882,7 @@ function readRecentMessages(jsonlPath, limit = 10) {
|
|
|
896
882
|
if (tmMatch) {
|
|
897
883
|
const attrs = tmMatch[1];
|
|
898
884
|
const body = tmMatch[2].trim();
|
|
899
|
-
const getAttr = (name) => (attrs.match(new RegExp(name
|
|
885
|
+
const getAttr = (name) => (attrs.match(new RegExp(`${name}="([^"]*)"`)) || [])[1] || null;
|
|
900
886
|
const tid = getAttr('teammate_id');
|
|
901
887
|
const color = getAttr('color');
|
|
902
888
|
const summary = getAttr('summary');
|
|
@@ -931,7 +917,7 @@ function readRecentMessages(jsonlPath, limit = 10) {
|
|
|
931
917
|
protocolType: protocol?.type || null,
|
|
932
918
|
protocolLabel,
|
|
933
919
|
protocolData: protocol || null,
|
|
934
|
-
text: isProtocol ? null : (truncated ? body.slice(0, 500)
|
|
920
|
+
text: isProtocol ? null : (truncated ? `${body.slice(0, 500)}...` : body),
|
|
935
921
|
fullText: isProtocol ? null : (truncated ? body : null),
|
|
936
922
|
timestamp: obj.timestamp
|
|
937
923
|
});
|
|
@@ -1023,7 +1009,7 @@ function readRecentMessages(jsonlPath, limit = 10) {
|
|
|
1023
1009
|
pushUserMessage(messages, text, obj.timestamp, null, { queued: true, images: refs });
|
|
1024
1010
|
}
|
|
1025
1011
|
}
|
|
1026
|
-
} catch
|
|
1012
|
+
} catch { /* partial line */ }
|
|
1027
1013
|
}
|
|
1028
1014
|
|
|
1029
1015
|
if (readSize >= stat.size) break;
|
|
@@ -1040,7 +1026,7 @@ function readRecentMessages(jsonlPath, limit = 10) {
|
|
|
1040
1026
|
const full = toolResults.get(msg.toolUseId);
|
|
1041
1027
|
const truncated = full.length > TOOL_RESULT_MAX;
|
|
1042
1028
|
if (truncated) {
|
|
1043
|
-
msg.toolResult = full.slice(0, TOOL_RESULT_MAX)
|
|
1029
|
+
msg.toolResult = `${full.slice(0, TOOL_RESULT_MAX)}\n... (truncated)`;
|
|
1044
1030
|
msg.toolResultFull = full;
|
|
1045
1031
|
} else {
|
|
1046
1032
|
msg.toolResult = full;
|
|
@@ -1057,7 +1043,7 @@ function readRecentMessages(jsonlPath, limit = 10) {
|
|
|
1057
1043
|
}
|
|
1058
1044
|
}
|
|
1059
1045
|
|
|
1060
|
-
|
|
1046
|
+
fs.closeSync(fd);
|
|
1061
1047
|
fd = null;
|
|
1062
1048
|
messages.sort((a, b) => (a.timestamp || '').localeCompare(b.timestamp || ''));
|
|
1063
1049
|
for (let i = messages.length - 1; i > 0; i--) {
|
|
@@ -1072,8 +1058,8 @@ function readRecentMessages(jsonlPath, limit = 10) {
|
|
|
1072
1058
|
}
|
|
1073
1059
|
}
|
|
1074
1060
|
return messages.slice(-limit);
|
|
1075
|
-
} catch
|
|
1076
|
-
if (fd) try {
|
|
1061
|
+
} catch {
|
|
1062
|
+
if (fd) try { fs.closeSync(fd); } catch (_) {}
|
|
1077
1063
|
return [];
|
|
1078
1064
|
}
|
|
1079
1065
|
}
|
|
@@ -1172,7 +1158,7 @@ function readCachedImage(sessionId, n, claudeDir) {
|
|
|
1172
1158
|
const idx = Number(n);
|
|
1173
1159
|
if (!sessionId || !/^[A-Za-z0-9._-]+$/.test(sessionId)) return null;
|
|
1174
1160
|
if (!Number.isInteger(idx) || idx < 0) return null;
|
|
1175
|
-
const baseDir = claudeDir || path.join(
|
|
1161
|
+
const baseDir = claudeDir || path.join(os.homedir(), '.claude');
|
|
1176
1162
|
const dir = path.join(baseDir, 'image-cache', sessionId);
|
|
1177
1163
|
try {
|
|
1178
1164
|
const match = readdirSync(dir).find((f) => {
|
|
@@ -1266,8 +1252,8 @@ function buildSessionDigest(jsonlPath) {
|
|
|
1266
1252
|
if (!map[key]) {
|
|
1267
1253
|
let prompt = null;
|
|
1268
1254
|
const promptMatch = promptRe.exec(line);
|
|
1269
|
-
if (promptMatch
|
|
1270
|
-
try { prompt = JSON.parse(
|
|
1255
|
+
if (promptMatch?.[1]) {
|
|
1256
|
+
try { prompt = JSON.parse(`"${promptMatch[1]}"`); } catch (_) { prompt = promptMatch[1]; }
|
|
1271
1257
|
}
|
|
1272
1258
|
map[key] = { agentId: agentMatch[1], prompt };
|
|
1273
1259
|
}
|
|
@@ -1578,14 +1564,13 @@ function extractModelFromTranscript(jsonlPath) {
|
|
|
1578
1564
|
const bytesRead = readSync(fd, buf, 0, CHUNK, null);
|
|
1579
1565
|
if (bytesRead === 0) break;
|
|
1580
1566
|
accumulated += buf.toString('utf8', 0, bytesRead);
|
|
1581
|
-
let nlIdx;
|
|
1582
|
-
while ((nlIdx = accumulated.indexOf('\n')) !== -1) {
|
|
1567
|
+
for (let nlIdx = accumulated.indexOf('\n'); nlIdx !== -1; nlIdx = accumulated.indexOf('\n')) {
|
|
1583
1568
|
const line = accumulated.slice(0, nlIdx);
|
|
1584
1569
|
accumulated = accumulated.slice(nlIdx + 1);
|
|
1585
1570
|
if (!line.trim()) continue;
|
|
1586
1571
|
try {
|
|
1587
1572
|
const obj = JSON.parse(line);
|
|
1588
|
-
const model = obj.model ||
|
|
1573
|
+
const model = obj.model || obj.message?.model;
|
|
1589
1574
|
if (model) return model;
|
|
1590
1575
|
} catch (_) {}
|
|
1591
1576
|
}
|
|
@@ -1631,8 +1616,8 @@ function formatStructuredResult(input) {
|
|
|
1631
1616
|
const parts = [];
|
|
1632
1617
|
for (const [key, value] of Object.entries(input)) {
|
|
1633
1618
|
if (value == null || value === '') continue;
|
|
1634
|
-
parts.push(
|
|
1635
|
-
parts.push(typeof value === 'string' ? value :
|
|
1619
|
+
parts.push(`### ${key}`);
|
|
1620
|
+
parts.push(typeof value === 'string' ? value : `\`\`\`json\n${JSON.stringify(value, null, 2)}\n\`\`\``);
|
|
1636
1621
|
}
|
|
1637
1622
|
return parts.length ? parts.join('\n\n') : null;
|
|
1638
1623
|
}
|
|
@@ -1684,12 +1669,21 @@ const EMPTY_LOOP_STATE = () => ({
|
|
|
1684
1669
|
taskIdByToolUseId: new Map(), deletedTaskIds: new Set()
|
|
1685
1670
|
});
|
|
1686
1671
|
|
|
1672
|
+
const LOOP_TOOL_TEXT = ['"ScheduleWakeup"', '"CronCreate"', '"CronDelete"'];
|
|
1673
|
+
const LOOP_TOOL_MARKERS = LOOP_TOOL_TEXT.map((m) => Buffer.from(m));
|
|
1674
|
+
|
|
1675
|
+
// Only a CronCreate result is ever read back from taskIdByToolUseId, and it is written after
|
|
1676
|
+
// its tool_use, so other tool results (most lines of a transcript) are not parsed.
|
|
1677
|
+
// `hay` is a line or a Buffer; both have includes.
|
|
1678
|
+
const hasPendingCronId = (hay, state) =>
|
|
1679
|
+
state.crons.some((c) => c.id && !state.taskIdByToolUseId.has(c.id) && hay.includes(c.id));
|
|
1680
|
+
|
|
1681
|
+
const wakeupFireMs = (w) => Date.parse(w.timestamp) + (w.delaySeconds ?? 0) * 1000;
|
|
1682
|
+
|
|
1687
1683
|
function processLoopLine(line, state) {
|
|
1688
1684
|
if (!line) return;
|
|
1689
|
-
const hasToolResult = line.includes('"tool_use_id"');
|
|
1690
|
-
const hasLoopTool = line.includes(
|
|
1691
|
-
|| line.includes('"CronCreate"')
|
|
1692
|
-
|| line.includes('"CronDelete"');
|
|
1685
|
+
const hasToolResult = line.includes('"tool_use_id"') && hasPendingCronId(line, state);
|
|
1686
|
+
const hasLoopTool = LOOP_TOOL_TEXT.some((m) => line.includes(m));
|
|
1693
1687
|
if (!hasToolResult && !hasLoopTool) return;
|
|
1694
1688
|
let obj;
|
|
1695
1689
|
try { obj = JSON.parse(line); } catch (_) { return; }
|
|
@@ -1703,6 +1697,11 @@ function processLoopLine(line, state) {
|
|
|
1703
1697
|
} else if (b.type === 'tool_use') {
|
|
1704
1698
|
const inp = b.input || {};
|
|
1705
1699
|
if (b.name === 'ScheduleWakeup') {
|
|
1700
|
+
// The harness keeps one pending wakeup per session: a newer call replaces it and
|
|
1701
|
+
// stop cancels it. Wakeups that already fired stay as history.
|
|
1702
|
+
const at = Date.parse(obj.timestamp);
|
|
1703
|
+
state.wakeups = state.wakeups.filter((w) => wakeupFireMs(w) <= at);
|
|
1704
|
+
if (inp.stop) continue;
|
|
1706
1705
|
state.wakeups.push({
|
|
1707
1706
|
id: b.id || null,
|
|
1708
1707
|
timestamp: obj.timestamp || null,
|
|
@@ -1727,6 +1726,12 @@ function processLoopLine(line, state) {
|
|
|
1727
1726
|
}
|
|
1728
1727
|
}
|
|
1729
1728
|
|
|
1729
|
+
// True when processLoopLine could act on some line in bytes: a loop tool call, or the result of a
|
|
1730
|
+
// CronCreate whose task id is still unknown. Most appended bytes hold neither and are never decoded.
|
|
1731
|
+
function hasLoopMarker(bytes, state) {
|
|
1732
|
+
return LOOP_TOOL_MARKERS.some((m) => bytes.includes(m)) || hasPendingCronId(bytes, state);
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1730
1735
|
// Reads bytes appended to jsonlPath since prev.scannedOffset and merges into
|
|
1731
1736
|
// state. Pass `null` (or undefined) for prev to do a one-time full scan.
|
|
1732
1737
|
function updateLoopInfo(jsonlPath, prev) {
|
|
@@ -1751,19 +1756,17 @@ function updateLoopInfo(jsonlPath, prev) {
|
|
|
1751
1756
|
try {
|
|
1752
1757
|
fd = fs.openSync(jsonlPath, 'r');
|
|
1753
1758
|
const len = stat.size - state.scannedOffset;
|
|
1754
|
-
const buf = Buffer.
|
|
1759
|
+
const buf = Buffer.allocUnsafe(len);
|
|
1755
1760
|
const n = fs.readSync(fd, buf, 0, len, state.scannedOffset);
|
|
1756
|
-
const text = buf.toString('utf8', 0, n);
|
|
1757
|
-
const lastNl = text.lastIndexOf('\n');
|
|
1758
|
-
if (lastNl < 0) {
|
|
1759
|
-
// No complete line yet — leave scannedOffset alone, update mtime only.
|
|
1760
|
-
state.mtimeMs = stat.mtimeMs;
|
|
1761
|
-
return state;
|
|
1762
|
-
}
|
|
1763
|
-
const complete = text.slice(0, lastNl);
|
|
1764
|
-
for (const line of complete.split('\n')) processLoopLine(line, state);
|
|
1765
|
-
state.scannedOffset += Buffer.byteLength(complete, 'utf8') + 1;
|
|
1766
1761
|
state.mtimeMs = stat.mtimeMs;
|
|
1762
|
+
// No complete line yet — leave scannedOffset alone.
|
|
1763
|
+
const lastNl = buf.subarray(0, n).lastIndexOf(0x0a);
|
|
1764
|
+
if (lastNl < 0) return state;
|
|
1765
|
+
const complete = buf.subarray(0, lastNl);
|
|
1766
|
+
if (hasLoopMarker(complete, state)) {
|
|
1767
|
+
for (const line of complete.toString('utf8').split('\n')) processLoopLine(line, state);
|
|
1768
|
+
}
|
|
1769
|
+
state.scannedOffset += lastNl + 1;
|
|
1767
1770
|
state.size = stat.size;
|
|
1768
1771
|
} catch (_) {
|
|
1769
1772
|
// leave state as-is
|
|
@@ -1798,6 +1801,10 @@ module.exports = {
|
|
|
1798
1801
|
parseAgentMessage,
|
|
1799
1802
|
getSystemMessageLabel,
|
|
1800
1803
|
readSessionInfoFromJsonl,
|
|
1804
|
+
exportSessionCaches,
|
|
1805
|
+
importSessionCaches,
|
|
1806
|
+
sessionCachesDirty,
|
|
1807
|
+
countTranscriptsBornBefore,
|
|
1801
1808
|
readRecentMessages,
|
|
1802
1809
|
readMessagesPage,
|
|
1803
1810
|
readFullToolResult,
|