claude-code-kanban 4.0.0 → 4.2.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/lib/parsers.js +319 -86
- package/package.json +1 -1
- package/plugin/plugins/claude-code-kanban/.claude-plugin/plugin.json +1 -1
- package/plugin/plugins/claude-code-kanban/hooks/hooks.json +10 -0
- package/plugin/plugins/claude-code-kanban/scripts/agent-spy.sh +4 -4
- package/public/app.js +381 -47
- package/public/index.html +25 -0
- package/public/style.css +308 -17
- package/server.js +323 -39
package/lib/parsers.js
CHANGED
|
@@ -118,16 +118,23 @@ const TOOL_RESULT_MAX = 1500;
|
|
|
118
118
|
const USER_TEXT_MAX = 500;
|
|
119
119
|
const INTERRUPT_MARKER = '[Request interrupted by user]';
|
|
120
120
|
|
|
121
|
-
function pushUserMessage(messages, text, timestamp, sysLabel) {
|
|
121
|
+
function pushUserMessage(messages, text, timestamp, sysLabel, extras) {
|
|
122
122
|
if (sysLabel === '__skip__') return;
|
|
123
|
-
const
|
|
124
|
-
|
|
123
|
+
const safeText = text || '';
|
|
124
|
+
const truncated = safeText.length > USER_TEXT_MAX;
|
|
125
|
+
const msg = {
|
|
125
126
|
type: 'user',
|
|
126
|
-
text: truncated ?
|
|
127
|
-
fullText: truncated ?
|
|
127
|
+
text: truncated ? safeText.slice(0, USER_TEXT_MAX) + '...' : safeText,
|
|
128
|
+
fullText: truncated ? safeText : null,
|
|
128
129
|
timestamp,
|
|
129
130
|
...(sysLabel && { systemLabel: sysLabel })
|
|
130
|
-
}
|
|
131
|
+
};
|
|
132
|
+
if (extras) {
|
|
133
|
+
if (extras.uuid) msg.uuid = extras.uuid;
|
|
134
|
+
if (extras.images && extras.images.length) msg.images = extras.images;
|
|
135
|
+
if (extras.toolResultRefs && extras.toolResultRefs.length) msg.toolResultRefs = extras.toolResultRefs;
|
|
136
|
+
}
|
|
137
|
+
messages.push(msg);
|
|
131
138
|
}
|
|
132
139
|
|
|
133
140
|
// Cache: jsonlPath -> { scannedUpTo, customTitle }
|
|
@@ -135,19 +142,35 @@ function pushUserMessage(messages, text, timestamp, sysLabel) {
|
|
|
135
142
|
const customTitleCache = new Map();
|
|
136
143
|
const CUSTOM_TITLE_SCAN_SIZE = 1048576; // 1MB max scan on first read
|
|
137
144
|
|
|
145
|
+
// Returns the best title found, in priority order:
|
|
146
|
+
// custom-title (user override) > ai-title > agent-name
|
|
147
|
+
// Background-session JSONLs created by the "claude agents" feature only emit
|
|
148
|
+
// ai-title/agent-name records; older sessions emit custom-title.
|
|
138
149
|
function extractCustomTitleFromText(text) {
|
|
139
|
-
|
|
150
|
+
const hasCustom = text.includes('"custom-title"');
|
|
151
|
+
const hasAi = text.includes('"ai-title"');
|
|
152
|
+
const hasAgent = text.includes('"agent-name"');
|
|
153
|
+
if (!hasCustom && !hasAi && !hasAgent) return null;
|
|
140
154
|
const lines = text.split('\n');
|
|
155
|
+
let customTitle = null;
|
|
156
|
+
let aiTitle = null;
|
|
157
|
+
let agentName = null;
|
|
141
158
|
for (let i = lines.length - 1; i >= 0; i--) {
|
|
142
|
-
|
|
159
|
+
const line = lines[i];
|
|
160
|
+
if (!line.includes('"custom-title"') && !line.includes('"ai-title"') && !line.includes('"agent-name"')) continue;
|
|
143
161
|
try {
|
|
144
|
-
const data = JSON.parse(
|
|
145
|
-
if (data.type === 'custom-title' && data.customTitle && !data.customTitle.startsWith('<')) {
|
|
146
|
-
|
|
162
|
+
const data = JSON.parse(line);
|
|
163
|
+
if (!customTitle && data.type === 'custom-title' && data.customTitle && !data.customTitle.startsWith('<')) {
|
|
164
|
+
customTitle = data.customTitle;
|
|
165
|
+
} else if (!aiTitle && data.type === 'ai-title' && data.aiTitle && !data.aiTitle.startsWith('<')) {
|
|
166
|
+
aiTitle = data.aiTitle;
|
|
167
|
+
} else if (!agentName && data.type === 'agent-name' && data.agentName && !data.agentName.startsWith('<')) {
|
|
168
|
+
agentName = data.agentName;
|
|
147
169
|
}
|
|
170
|
+
if (customTitle) break;
|
|
148
171
|
} catch (e) {}
|
|
149
172
|
}
|
|
150
|
-
return null;
|
|
173
|
+
return customTitle || aiTitle || agentName || null;
|
|
151
174
|
}
|
|
152
175
|
|
|
153
176
|
function readCustomTitle(jsonlPath, existingStat) {
|
|
@@ -199,99 +222,147 @@ function scrapeScalarFromBlob(blob, re) {
|
|
|
199
222
|
const sessionInfoCache = new Map();
|
|
200
223
|
const SESSION_INFO_CACHE_MAX = 2000;
|
|
201
224
|
|
|
225
|
+
// gitBranch in the JSONL is pinned to the launch-time repo by Claude Code
|
|
226
|
+
// and goes stale once cwd shifts (Bash `cd`, submodule). Callers needing the
|
|
227
|
+
// live branch must resolve it from cwd separately. Cache is reset on inode
|
|
228
|
+
// change or truncation (size < scannedUpTo).
|
|
202
229
|
function readSessionInfoFromJsonl(jsonlPath) {
|
|
203
230
|
const result = { slug: null, projectPath: null, cwd: null, gitBranch: null, customTitle: null };
|
|
204
231
|
let stat;
|
|
205
232
|
let fd;
|
|
206
233
|
try {
|
|
207
234
|
stat = statSync(jsonlPath);
|
|
208
|
-
const cached = sessionInfoCache.get(jsonlPath);
|
|
209
|
-
if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) {
|
|
210
|
-
return { ...cached.result, customTitle: readCustomTitle(jsonlPath, stat) };
|
|
211
|
-
}
|
|
212
235
|
} catch (_) {
|
|
213
236
|
return result;
|
|
214
237
|
}
|
|
215
|
-
|
|
216
|
-
|
|
238
|
+
|
|
239
|
+
const cached = sessionInfoCache.get(jsonlPath);
|
|
240
|
+
const inodeMatch = cached && cached.ino === stat.ino;
|
|
241
|
+
const sizeOK = cached && stat.size >= cached.scannedUpTo;
|
|
242
|
+
const canIncrement = inodeMatch && sizeOK;
|
|
243
|
+
|
|
244
|
+
if (canIncrement && stat.size === cached.scannedUpTo) {
|
|
245
|
+
return {
|
|
246
|
+
slug: cached.slug,
|
|
247
|
+
projectPath: cached.projectPath,
|
|
248
|
+
cwd: cached.cwd,
|
|
249
|
+
gitBranch: cached.gitBranch,
|
|
250
|
+
customTitle: readCustomTitle(jsonlPath, stat)
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (canIncrement) {
|
|
255
|
+
result.slug = cached.slug;
|
|
256
|
+
result.projectPath = cached.projectPath;
|
|
257
|
+
result.cwd = cached.cwd;
|
|
258
|
+
result.gitBranch = cached.gitBranch;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
let lastCwdSeen = result.cwd;
|
|
217
262
|
const applyLine = (line) => {
|
|
218
263
|
try {
|
|
219
264
|
const data = JSON.parse(line);
|
|
220
|
-
if (data.slug) result.slug = data.slug;
|
|
265
|
+
if (data.slug && !result.slug) result.slug = data.slug;
|
|
221
266
|
if (data.cwd) {
|
|
222
267
|
if (!result.projectPath) result.projectPath = data.cwd;
|
|
223
|
-
|
|
268
|
+
lastCwdSeen = data.cwd;
|
|
224
269
|
}
|
|
225
270
|
if (data.gitBranch) result.gitBranch = data.gitBranch;
|
|
226
271
|
} catch (e) {}
|
|
227
272
|
};
|
|
273
|
+
|
|
274
|
+
const CHUNK_SIZE = 16384;
|
|
275
|
+
const TAIL_SIZE = 16384;
|
|
276
|
+
const HEAD_MAX = 1048576;
|
|
277
|
+
let scannedUpTo = canIncrement ? cached.scannedUpTo : 0;
|
|
278
|
+
|
|
228
279
|
try {
|
|
229
280
|
fd = fs.openSync(jsonlPath, 'r');
|
|
230
|
-
const CHUNK_SIZE = 16384;
|
|
231
|
-
const TAIL_SIZE = 16384;
|
|
232
|
-
// Stream head in chunks with StringDecoder to preserve multi-byte UTF-8
|
|
233
|
-
// codepoints across chunk boundaries. Scan up to HEAD_MAX so the last cwd
|
|
234
|
-
// in the window wins — sessions that change cwd mid-run must surface it.
|
|
235
|
-
const HEAD_MAX = 1048576;
|
|
236
|
-
const decoder = new StringDecoder('utf8');
|
|
237
|
-
const buf = Buffer.alloc(CHUNK_SIZE);
|
|
238
|
-
let leftover = '';
|
|
239
|
-
let offset = 0;
|
|
240
|
-
while (offset < stat.size && offset < HEAD_MAX) {
|
|
241
|
-
const len = Math.min(CHUNK_SIZE, stat.size - offset);
|
|
242
|
-
const n = fs.readSync(fd, buf, 0, len, offset);
|
|
243
|
-
if (n === 0) break;
|
|
244
|
-
offset += n;
|
|
245
|
-
const text = leftover + decoder.write(n === buf.length ? buf : buf.slice(0, n));
|
|
246
|
-
const lines = text.split('\n');
|
|
247
|
-
leftover = lines.pop();
|
|
248
|
-
for (const line of lines) applyLine(line);
|
|
249
|
-
}
|
|
250
|
-
leftover += decoder.end();
|
|
251
|
-
if (leftover) applyLine(leftover);
|
|
252
|
-
// Oversized first line (e.g. multi-MB inline image) left leftover unparsed
|
|
253
|
-
// above; scrape scalars out of it so we don't fall through to the tail and
|
|
254
|
-
// pick a mid-session cwd as projectPath.
|
|
255
|
-
if (!result.projectPath && leftover && leftover.length > CHUNK_SIZE) {
|
|
256
|
-
const scrapedCwd = scrapeScalarFromBlob(leftover, SCRAPE_CWD_RE);
|
|
257
|
-
if (scrapedCwd) { result.projectPath = scrapedCwd; lastCwdFromHead = scrapedCwd; }
|
|
258
|
-
if (!result.slug) result.slug = scrapeScalarFromBlob(leftover, SCRAPE_SLUG_RE);
|
|
259
|
-
if (!result.gitBranch) result.gitBranch = scrapeScalarFromBlob(leftover, SCRAPE_GITBRANCH_RE);
|
|
260
|
-
}
|
|
261
281
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
const
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
282
|
+
if (canIncrement) {
|
|
283
|
+
// Each conversational JSONL line carries `cwd`, so a mid-session `cd`
|
|
284
|
+
// surfaces in any tail window — we don't need the whole delta.
|
|
285
|
+
const DELTA_MAX = 1048576;
|
|
286
|
+
const deltaLen = stat.size - cached.scannedUpTo;
|
|
287
|
+
if (deltaLen > 0) {
|
|
288
|
+
const readLen = Math.min(deltaLen, DELTA_MAX);
|
|
289
|
+
const readStart = stat.size - readLen;
|
|
290
|
+
const buf = Buffer.alloc(readLen);
|
|
291
|
+
const n = fs.readSync(fd, buf, 0, readLen, readStart);
|
|
292
|
+
if (n > 0) {
|
|
293
|
+
const text = buf.toString('utf8', 0, n);
|
|
294
|
+
const lastNl = text.lastIndexOf('\n');
|
|
295
|
+
const complete = lastNl >= 0 ? text.slice(0, lastNl) : '';
|
|
296
|
+
const lines = complete.split('\n');
|
|
297
|
+
for (const line of lines) if (line) applyLine(line);
|
|
298
|
+
}
|
|
299
|
+
scannedUpTo = stat.size;
|
|
300
|
+
}
|
|
301
|
+
} else {
|
|
302
|
+
const decoder = new StringDecoder('utf8');
|
|
303
|
+
const buf = Buffer.alloc(CHUNK_SIZE);
|
|
304
|
+
let leftover = '';
|
|
305
|
+
let offset = 0;
|
|
306
|
+
while (offset < stat.size && offset < HEAD_MAX) {
|
|
307
|
+
const len = Math.min(CHUNK_SIZE, stat.size - offset);
|
|
308
|
+
const n = fs.readSync(fd, buf, 0, len, offset);
|
|
309
|
+
if (n === 0) break;
|
|
310
|
+
offset += n;
|
|
311
|
+
const text = leftover + decoder.write(n === buf.length ? buf : buf.slice(0, n));
|
|
312
|
+
const lines = text.split('\n');
|
|
313
|
+
leftover = lines.pop();
|
|
314
|
+
for (const line of lines) applyLine(line);
|
|
315
|
+
}
|
|
316
|
+
leftover += decoder.end();
|
|
317
|
+
if (leftover) applyLine(leftover);
|
|
318
|
+
// Oversized first line (e.g. multi-MB inline image) — scrape scalars so
|
|
319
|
+
// we don't fall through and pick a mid-session cwd as projectPath.
|
|
320
|
+
if (!result.projectPath && leftover && leftover.length > CHUNK_SIZE) {
|
|
321
|
+
const scrapedCwd = scrapeScalarFromBlob(leftover, SCRAPE_CWD_RE);
|
|
322
|
+
if (scrapedCwd) { result.projectPath = scrapedCwd; lastCwdSeen = scrapedCwd; }
|
|
323
|
+
if (!result.slug) result.slug = scrapeScalarFromBlob(leftover, SCRAPE_SLUG_RE);
|
|
324
|
+
if (!result.gitBranch) result.gitBranch = scrapeScalarFromBlob(leftover, SCRAPE_GITBRANCH_RE);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Tail scan catches late cwd switches past HEAD_MAX. projectPath stays
|
|
328
|
+
// anchored to the earliest cwd — it is never overwritten here.
|
|
329
|
+
if (stat.size > offset) {
|
|
330
|
+
const tailStart = Math.max(offset, stat.size - TAIL_SIZE);
|
|
331
|
+
const tailBuf = Buffer.alloc(TAIL_SIZE);
|
|
332
|
+
const tn = fs.readSync(fd, tailBuf, 0, TAIL_SIZE, tailStart);
|
|
333
|
+
const lines = tailBuf.toString('utf8', 0, tn).split('\n');
|
|
334
|
+
let latestTailCwd = null;
|
|
335
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
336
|
+
try {
|
|
337
|
+
const data = JSON.parse(lines[i]);
|
|
338
|
+
if (!result.slug && data.slug) result.slug = data.slug;
|
|
339
|
+
if (!result.projectPath && data.cwd) result.projectPath = data.cwd;
|
|
340
|
+
if (!result.gitBranch && data.gitBranch) result.gitBranch = data.gitBranch;
|
|
341
|
+
if (!latestTailCwd && data.cwd) latestTailCwd = data.cwd;
|
|
342
|
+
if (latestTailCwd && result.slug && result.projectPath && result.gitBranch) break;
|
|
343
|
+
} catch (e) {}
|
|
344
|
+
}
|
|
345
|
+
if (latestTailCwd) lastCwdSeen = latestTailCwd;
|
|
284
346
|
}
|
|
285
|
-
|
|
347
|
+
scannedUpTo = stat.size;
|
|
286
348
|
}
|
|
287
349
|
} catch (e) {
|
|
288
350
|
} finally {
|
|
289
351
|
if (fd !== undefined) { try { fs.closeSync(fd); } catch (e) {} }
|
|
290
352
|
}
|
|
291
353
|
|
|
354
|
+
result.cwd = lastCwdSeen;
|
|
355
|
+
|
|
292
356
|
if (stat) {
|
|
293
|
-
|
|
294
|
-
|
|
357
|
+
sessionInfoCache.set(jsonlPath, {
|
|
358
|
+
ino: stat.ino,
|
|
359
|
+
size: stat.size,
|
|
360
|
+
scannedUpTo,
|
|
361
|
+
slug: result.slug,
|
|
362
|
+
projectPath: result.projectPath,
|
|
363
|
+
gitBranch: result.gitBranch,
|
|
364
|
+
cwd: result.cwd
|
|
365
|
+
});
|
|
295
366
|
if (sessionInfoCache.size > SESSION_INFO_CACHE_MAX) {
|
|
296
367
|
const firstKey = sessionInfoCache.keys().next().value;
|
|
297
368
|
sessionInfoCache.delete(firstKey);
|
|
@@ -532,16 +603,19 @@ function readRecentMessages(jsonlPath, limit = 10) {
|
|
|
532
603
|
}
|
|
533
604
|
pushUserMessage(messages, t, obj.timestamp, getSystemMessageLabel(t));
|
|
534
605
|
} else if (Array.isArray(obj.message.content)) {
|
|
535
|
-
const
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
.
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
606
|
+
const texts = [];
|
|
607
|
+
const images = [];
|
|
608
|
+
const toolResultRefs = [];
|
|
609
|
+
obj.message.content.forEach((block, idx) => {
|
|
610
|
+
if (block.type === 'text' && typeof block.text === 'string' && block.text) {
|
|
611
|
+
texts.push(block.text);
|
|
612
|
+
} else if (block.type === 'image' && block.source && block.source.type === 'base64') {
|
|
613
|
+
images.push({
|
|
614
|
+
blockIndex: idx,
|
|
615
|
+
mediaType: block.source.media_type || 'image/png',
|
|
616
|
+
dataLen: typeof block.source.data === 'string' ? block.source.data.length : 0
|
|
617
|
+
});
|
|
618
|
+
} else if (block.type === 'tool_result' && block.tool_use_id) {
|
|
545
619
|
let resultText = '';
|
|
546
620
|
if (typeof block.content === 'string') {
|
|
547
621
|
resultText = block.content;
|
|
@@ -554,7 +628,23 @@ function readRecentMessages(jsonlPath, limit = 10) {
|
|
|
554
628
|
if (resultText) {
|
|
555
629
|
toolResults.set(block.tool_use_id, resultText);
|
|
556
630
|
}
|
|
631
|
+
toolResultRefs.push({
|
|
632
|
+
toolUseId: block.tool_use_id,
|
|
633
|
+
preview: resultText ? resultText.slice(0, 200) : ''
|
|
634
|
+
});
|
|
557
635
|
}
|
|
636
|
+
});
|
|
637
|
+
const joined = texts.join('\n').trim();
|
|
638
|
+
const hasText = joined && joined !== INTERRUPT_MARKER;
|
|
639
|
+
const hasImages = images.length > 0;
|
|
640
|
+
if (hasText || hasImages) {
|
|
641
|
+
pushUserMessage(
|
|
642
|
+
messages,
|
|
643
|
+
joined,
|
|
644
|
+
obj.timestamp,
|
|
645
|
+
getSystemMessageLabel(joined),
|
|
646
|
+
{ uuid: obj.uuid, images, toolResultRefs: hasText ? toolResultRefs : [] }
|
|
647
|
+
);
|
|
558
648
|
}
|
|
559
649
|
}
|
|
560
650
|
}
|
|
@@ -620,6 +710,31 @@ function readFullToolResult(jsonlPath, toolUseId) {
|
|
|
620
710
|
return null;
|
|
621
711
|
}
|
|
622
712
|
|
|
713
|
+
function readUserImage(jsonlPath, msgUuid, blockIndex) {
|
|
714
|
+
if (!msgUuid || !jsonlPath) return null;
|
|
715
|
+
const idx = Number(blockIndex);
|
|
716
|
+
if (!Number.isInteger(idx) || idx < 0) return null;
|
|
717
|
+
try {
|
|
718
|
+
const content = readFileSync(jsonlPath, 'utf8');
|
|
719
|
+
const lines = content.split('\n');
|
|
720
|
+
for (const line of lines) {
|
|
721
|
+
if (!line || line.indexOf(msgUuid) === -1) continue;
|
|
722
|
+
try {
|
|
723
|
+
const obj = JSON.parse(line);
|
|
724
|
+
if (obj?.uuid !== msgUuid) continue;
|
|
725
|
+
if (!Array.isArray(obj?.message?.content)) continue;
|
|
726
|
+
const block = obj.message.content[idx];
|
|
727
|
+
if (!block || block.type !== 'image' || !block.source || block.source.type !== 'base64') return null;
|
|
728
|
+
return {
|
|
729
|
+
mediaType: block.source.media_type || 'image/png',
|
|
730
|
+
data: block.source.data
|
|
731
|
+
};
|
|
732
|
+
} catch (_) {}
|
|
733
|
+
}
|
|
734
|
+
} catch (_) {}
|
|
735
|
+
return null;
|
|
736
|
+
}
|
|
737
|
+
|
|
623
738
|
function readMessagesPage(jsonlPath, limit = 10, beforeTimestamp = null) {
|
|
624
739
|
const fetchLimit = limit + 1;
|
|
625
740
|
const applyFilter = beforeTimestamp
|
|
@@ -902,6 +1017,121 @@ function extractModelFromTranscript(jsonlPath) {
|
|
|
902
1017
|
return null;
|
|
903
1018
|
}
|
|
904
1019
|
|
|
1020
|
+
// Incremental loop-tool scanner. JSONL is append-only, so we keep per-path
|
|
1021
|
+
// state and on each call read ONLY the bytes appended since scannedOffset.
|
|
1022
|
+
// Avoids the only full-file read that ran inside the /api/sessions hot path.
|
|
1023
|
+
//
|
|
1024
|
+
// State shape: { mtimeMs, size, scannedOffset, wakeups[], crons[],
|
|
1025
|
+
// taskIdByToolUseId<Map>, deletedTaskIds<Set> }
|
|
1026
|
+
const EMPTY_LOOP_STATE = () => ({
|
|
1027
|
+
mtimeMs: 0, size: 0, scannedOffset: 0,
|
|
1028
|
+
wakeups: [], crons: [],
|
|
1029
|
+
taskIdByToolUseId: new Map(), deletedTaskIds: new Set()
|
|
1030
|
+
});
|
|
1031
|
+
|
|
1032
|
+
function processLoopLine(line, state) {
|
|
1033
|
+
if (!line) return;
|
|
1034
|
+
const hasToolResult = line.includes('"tool_use_id"');
|
|
1035
|
+
const hasLoopTool = line.includes('"ScheduleWakeup"')
|
|
1036
|
+
|| line.includes('"CronCreate"')
|
|
1037
|
+
|| line.includes('"CronDelete"');
|
|
1038
|
+
if (!hasToolResult && !hasLoopTool) return;
|
|
1039
|
+
let obj;
|
|
1040
|
+
try { obj = JSON.parse(line); } catch (_) { return; }
|
|
1041
|
+
const content = obj?.message?.content;
|
|
1042
|
+
if (!Array.isArray(content)) return;
|
|
1043
|
+
for (const b of content) {
|
|
1044
|
+
if (!b) continue;
|
|
1045
|
+
if (b.type === 'tool_result' && b.tool_use_id) {
|
|
1046
|
+
const tid = obj.toolUseResult?.id;
|
|
1047
|
+
if (tid) state.taskIdByToolUseId.set(b.tool_use_id, tid);
|
|
1048
|
+
} else if (b.type === 'tool_use') {
|
|
1049
|
+
const inp = b.input || {};
|
|
1050
|
+
if (b.name === 'ScheduleWakeup') {
|
|
1051
|
+
state.wakeups.push({
|
|
1052
|
+
id: b.id || null,
|
|
1053
|
+
timestamp: obj.timestamp || null,
|
|
1054
|
+
delaySeconds: typeof inp.delaySeconds === 'number' ? inp.delaySeconds : null,
|
|
1055
|
+
reason: inp.reason || null,
|
|
1056
|
+
prompt: inp.prompt || null
|
|
1057
|
+
});
|
|
1058
|
+
} else if (b.name === 'CronCreate') {
|
|
1059
|
+
state.crons.push({
|
|
1060
|
+
id: b.id || null,
|
|
1061
|
+
taskId: null,
|
|
1062
|
+
timestamp: obj.timestamp || null,
|
|
1063
|
+
cron: inp.cron || inp.cronExpression || null,
|
|
1064
|
+
prompt: inp.prompt || null,
|
|
1065
|
+
description: inp.description || inp.reason || null
|
|
1066
|
+
});
|
|
1067
|
+
} else if (b.name === 'CronDelete') {
|
|
1068
|
+
const ids = inp.id ? [inp.id] : (Array.isArray(inp.ids) ? inp.ids : []);
|
|
1069
|
+
for (const i of ids) state.deletedTaskIds.add(i);
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
// Reads bytes appended to jsonlPath since prev.scannedOffset and merges into
|
|
1076
|
+
// state. Pass `null` (or undefined) for prev to do a one-time full scan.
|
|
1077
|
+
function updateLoopInfo(jsonlPath, prev) {
|
|
1078
|
+
let stat;
|
|
1079
|
+
try { stat = statSync(jsonlPath); } catch (_) { return prev || EMPTY_LOOP_STATE(); }
|
|
1080
|
+
|
|
1081
|
+
let state = prev;
|
|
1082
|
+
// Cold start OR file shrank (truncate/replace) → rescan from beginning.
|
|
1083
|
+
if (!state || stat.size < state.size) {
|
|
1084
|
+
state = EMPTY_LOOP_STATE();
|
|
1085
|
+
} else if (state.mtimeMs === stat.mtimeMs && state.size === stat.size) {
|
|
1086
|
+
return state; // unchanged
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
if (stat.size <= state.scannedOffset) {
|
|
1090
|
+
state.mtimeMs = stat.mtimeMs;
|
|
1091
|
+
state.size = stat.size;
|
|
1092
|
+
return state;
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
let fd;
|
|
1096
|
+
try {
|
|
1097
|
+
fd = fs.openSync(jsonlPath, 'r');
|
|
1098
|
+
const len = stat.size - state.scannedOffset;
|
|
1099
|
+
const buf = Buffer.alloc(len);
|
|
1100
|
+
const n = fs.readSync(fd, buf, 0, len, state.scannedOffset);
|
|
1101
|
+
const text = buf.toString('utf8', 0, n);
|
|
1102
|
+
const lastNl = text.lastIndexOf('\n');
|
|
1103
|
+
if (lastNl < 0) {
|
|
1104
|
+
// No complete line yet — leave scannedOffset alone, update mtime only.
|
|
1105
|
+
state.mtimeMs = stat.mtimeMs;
|
|
1106
|
+
return state;
|
|
1107
|
+
}
|
|
1108
|
+
const complete = text.slice(0, lastNl);
|
|
1109
|
+
for (const line of complete.split('\n')) processLoopLine(line, state);
|
|
1110
|
+
state.scannedOffset += Buffer.byteLength(complete, 'utf8') + 1;
|
|
1111
|
+
state.mtimeMs = stat.mtimeMs;
|
|
1112
|
+
state.size = stat.size;
|
|
1113
|
+
} catch (_) {
|
|
1114
|
+
// leave state as-is
|
|
1115
|
+
} finally {
|
|
1116
|
+
if (fd != null) { try { fs.closeSync(fd); } catch (_) {} }
|
|
1117
|
+
}
|
|
1118
|
+
return state;
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
function buildLoopInfoFromState(state) {
|
|
1122
|
+
if (!state) return { wakeups: [], crons: [] };
|
|
1123
|
+
// Resolve pending taskIds in place — taskIdByToolUseId is monotonic, so
|
|
1124
|
+
// once resolved an entry stays resolved and we skip the spread-copy on
|
|
1125
|
+
// every subsequent call.
|
|
1126
|
+
for (const c of state.crons) {
|
|
1127
|
+
if (!c.taskId) c.taskId = state.taskIdByToolUseId.get(c.id) || null;
|
|
1128
|
+
}
|
|
1129
|
+
const crons = state.deletedTaskIds.size
|
|
1130
|
+
? state.crons.filter(c => !c.taskId || !state.deletedTaskIds.has(c.taskId))
|
|
1131
|
+
: state.crons;
|
|
1132
|
+
return { wakeups: state.wakeups, crons };
|
|
1133
|
+
}
|
|
1134
|
+
|
|
905
1135
|
module.exports = {
|
|
906
1136
|
parseTask,
|
|
907
1137
|
parseAgent,
|
|
@@ -913,6 +1143,9 @@ module.exports = {
|
|
|
913
1143
|
readRecentMessages,
|
|
914
1144
|
readMessagesPage,
|
|
915
1145
|
readFullToolResult,
|
|
1146
|
+
readUserImage,
|
|
1147
|
+
updateLoopInfo,
|
|
1148
|
+
buildLoopInfoFromState,
|
|
916
1149
|
buildAgentProgressMap,
|
|
917
1150
|
buildSessionDigest,
|
|
918
1151
|
readCompactSummaries,
|
package/package.json
CHANGED
|
@@ -38,17 +38,17 @@ if [ "$EVENT" = "SessionStart" ]; then
|
|
|
38
38
|
fi
|
|
39
39
|
|
|
40
40
|
# PostToolUse / non-waiting PreToolUse: clear waiting state
|
|
41
|
-
if [ "$EVENT" = "PostToolUse" ] || { [ "$EVENT" = "PreToolUse" ] && [ "$TOOL_NAME" != "AskUserQuestion" ]; }; then
|
|
41
|
+
if [ "$EVENT" = "PostToolUse" ] || { [ "$EVENT" = "PreToolUse" ] && [ "$TOOL_NAME" != "AskUserQuestion" ] && [ "$TOOL_NAME" != "ExitPlanMode" ]; }; then
|
|
42
42
|
WFILE="$CCK_ACTIVITY/$SESSION_ID/_waiting.json"
|
|
43
43
|
rm -f "$WFILE"
|
|
44
44
|
[ "$EVENT" = "PostToolUse" ] && exit 0
|
|
45
45
|
fi
|
|
46
46
|
|
|
47
|
-
#
|
|
48
|
-
[ "$TOOL_NAME" = "EnterPlanMode" ]
|
|
47
|
+
# EnterPlanMode has no waiting semantics — skip
|
|
48
|
+
[ "$TOOL_NAME" = "EnterPlanMode" ] && exit 0
|
|
49
49
|
|
|
50
50
|
# Waiting-for-user events → write _waiting.json marker
|
|
51
|
-
if [ "$EVENT" = "PermissionRequest" ] || { [ "$EVENT" = "PreToolUse" ] && [ "$TOOL_NAME" = "AskUserQuestion" ]; }; then
|
|
51
|
+
if [ "$EVENT" = "PermissionRequest" ] || { [ "$EVENT" = "PreToolUse" ] && { [ "$TOOL_NAME" = "AskUserQuestion" ] || [ "$TOOL_NAME" = "ExitPlanMode" ]; }; }; then
|
|
52
52
|
DIR="$CCK_ACTIVITY/$SESSION_ID"
|
|
53
53
|
mkdir -p "$DIR"
|
|
54
54
|
KIND="permission"
|