claude-code-kanban 4.1.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 +251 -70
- package/package.json +1 -1
- package/public/app.js +187 -9
- package/public/index.html +19 -0
- package/public/style.css +152 -0
- package/server.js +192 -13
package/lib/parsers.js
CHANGED
|
@@ -142,19 +142,35 @@ function pushUserMessage(messages, text, timestamp, sysLabel, extras) {
|
|
|
142
142
|
const customTitleCache = new Map();
|
|
143
143
|
const CUSTOM_TITLE_SCAN_SIZE = 1048576; // 1MB max scan on first read
|
|
144
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.
|
|
145
149
|
function extractCustomTitleFromText(text) {
|
|
146
|
-
|
|
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;
|
|
147
154
|
const lines = text.split('\n');
|
|
155
|
+
let customTitle = null;
|
|
156
|
+
let aiTitle = null;
|
|
157
|
+
let agentName = null;
|
|
148
158
|
for (let i = lines.length - 1; i >= 0; i--) {
|
|
149
|
-
|
|
159
|
+
const line = lines[i];
|
|
160
|
+
if (!line.includes('"custom-title"') && !line.includes('"ai-title"') && !line.includes('"agent-name"')) continue;
|
|
150
161
|
try {
|
|
151
|
-
const data = JSON.parse(
|
|
152
|
-
if (data.type === 'custom-title' && data.customTitle && !data.customTitle.startsWith('<')) {
|
|
153
|
-
|
|
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;
|
|
154
169
|
}
|
|
170
|
+
if (customTitle) break;
|
|
155
171
|
} catch (e) {}
|
|
156
172
|
}
|
|
157
|
-
return null;
|
|
173
|
+
return customTitle || aiTitle || agentName || null;
|
|
158
174
|
}
|
|
159
175
|
|
|
160
176
|
function readCustomTitle(jsonlPath, existingStat) {
|
|
@@ -206,99 +222,147 @@ function scrapeScalarFromBlob(blob, re) {
|
|
|
206
222
|
const sessionInfoCache = new Map();
|
|
207
223
|
const SESSION_INFO_CACHE_MAX = 2000;
|
|
208
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).
|
|
209
229
|
function readSessionInfoFromJsonl(jsonlPath) {
|
|
210
230
|
const result = { slug: null, projectPath: null, cwd: null, gitBranch: null, customTitle: null };
|
|
211
231
|
let stat;
|
|
212
232
|
let fd;
|
|
213
233
|
try {
|
|
214
234
|
stat = statSync(jsonlPath);
|
|
215
|
-
const cached = sessionInfoCache.get(jsonlPath);
|
|
216
|
-
if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) {
|
|
217
|
-
return { ...cached.result, customTitle: readCustomTitle(jsonlPath, stat) };
|
|
218
|
-
}
|
|
219
235
|
} catch (_) {
|
|
220
236
|
return result;
|
|
221
237
|
}
|
|
222
|
-
|
|
223
|
-
|
|
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;
|
|
224
262
|
const applyLine = (line) => {
|
|
225
263
|
try {
|
|
226
264
|
const data = JSON.parse(line);
|
|
227
|
-
if (data.slug) result.slug = data.slug;
|
|
265
|
+
if (data.slug && !result.slug) result.slug = data.slug;
|
|
228
266
|
if (data.cwd) {
|
|
229
267
|
if (!result.projectPath) result.projectPath = data.cwd;
|
|
230
|
-
|
|
268
|
+
lastCwdSeen = data.cwd;
|
|
231
269
|
}
|
|
232
270
|
if (data.gitBranch) result.gitBranch = data.gitBranch;
|
|
233
271
|
} catch (e) {}
|
|
234
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
|
+
|
|
235
279
|
try {
|
|
236
280
|
fd = fs.openSync(jsonlPath, 'r');
|
|
237
|
-
const CHUNK_SIZE = 16384;
|
|
238
|
-
const TAIL_SIZE = 16384;
|
|
239
|
-
// Stream head in chunks with StringDecoder to preserve multi-byte UTF-8
|
|
240
|
-
// codepoints across chunk boundaries. Scan up to HEAD_MAX so the last cwd
|
|
241
|
-
// in the window wins — sessions that change cwd mid-run must surface it.
|
|
242
|
-
const HEAD_MAX = 1048576;
|
|
243
|
-
const decoder = new StringDecoder('utf8');
|
|
244
|
-
const buf = Buffer.alloc(CHUNK_SIZE);
|
|
245
|
-
let leftover = '';
|
|
246
|
-
let offset = 0;
|
|
247
|
-
while (offset < stat.size && offset < HEAD_MAX) {
|
|
248
|
-
const len = Math.min(CHUNK_SIZE, stat.size - offset);
|
|
249
|
-
const n = fs.readSync(fd, buf, 0, len, offset);
|
|
250
|
-
if (n === 0) break;
|
|
251
|
-
offset += n;
|
|
252
|
-
const text = leftover + decoder.write(n === buf.length ? buf : buf.slice(0, n));
|
|
253
|
-
const lines = text.split('\n');
|
|
254
|
-
leftover = lines.pop();
|
|
255
|
-
for (const line of lines) applyLine(line);
|
|
256
|
-
}
|
|
257
|
-
leftover += decoder.end();
|
|
258
|
-
if (leftover) applyLine(leftover);
|
|
259
|
-
// Oversized first line (e.g. multi-MB inline image) left leftover unparsed
|
|
260
|
-
// above; scrape scalars out of it so we don't fall through to the tail and
|
|
261
|
-
// pick a mid-session cwd as projectPath.
|
|
262
|
-
if (!result.projectPath && leftover && leftover.length > CHUNK_SIZE) {
|
|
263
|
-
const scrapedCwd = scrapeScalarFromBlob(leftover, SCRAPE_CWD_RE);
|
|
264
|
-
if (scrapedCwd) { result.projectPath = scrapedCwd; lastCwdFromHead = scrapedCwd; }
|
|
265
|
-
if (!result.slug) result.slug = scrapeScalarFromBlob(leftover, SCRAPE_SLUG_RE);
|
|
266
|
-
if (!result.gitBranch) result.gitBranch = scrapeScalarFromBlob(leftover, SCRAPE_GITBRANCH_RE);
|
|
267
|
-
}
|
|
268
281
|
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
const
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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;
|
|
291
346
|
}
|
|
292
|
-
|
|
347
|
+
scannedUpTo = stat.size;
|
|
293
348
|
}
|
|
294
349
|
} catch (e) {
|
|
295
350
|
} finally {
|
|
296
351
|
if (fd !== undefined) { try { fs.closeSync(fd); } catch (e) {} }
|
|
297
352
|
}
|
|
298
353
|
|
|
354
|
+
result.cwd = lastCwdSeen;
|
|
355
|
+
|
|
299
356
|
if (stat) {
|
|
300
|
-
|
|
301
|
-
|
|
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
|
+
});
|
|
302
366
|
if (sessionInfoCache.size > SESSION_INFO_CACHE_MAX) {
|
|
303
367
|
const firstKey = sessionInfoCache.keys().next().value;
|
|
304
368
|
sessionInfoCache.delete(firstKey);
|
|
@@ -953,6 +1017,121 @@ function extractModelFromTranscript(jsonlPath) {
|
|
|
953
1017
|
return null;
|
|
954
1018
|
}
|
|
955
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
|
+
|
|
956
1135
|
module.exports = {
|
|
957
1136
|
parseTask,
|
|
958
1137
|
parseAgent,
|
|
@@ -965,6 +1144,8 @@ module.exports = {
|
|
|
965
1144
|
readMessagesPage,
|
|
966
1145
|
readFullToolResult,
|
|
967
1146
|
readUserImage,
|
|
1147
|
+
updateLoopInfo,
|
|
1148
|
+
buildLoopInfoFromState,
|
|
968
1149
|
buildAgentProgressMap,
|
|
969
1150
|
buildSessionDigest,
|
|
970
1151
|
readCompactSummaries,
|
package/package.json
CHANGED
package/public/app.js
CHANGED
|
@@ -604,6 +604,7 @@ async function fetchAgents(sessionId) {
|
|
|
604
604
|
if (waitHash !== lastWaitingHash) {
|
|
605
605
|
lastWaitingHash = waitHash;
|
|
606
606
|
if (messagePanelOpen && currentMessages.length) renderMessages(currentMessages);
|
|
607
|
+
maybeFollowLatest();
|
|
607
608
|
}
|
|
608
609
|
} catch (e) {
|
|
609
610
|
console.error('[fetchAgents]', e);
|
|
@@ -1159,16 +1160,33 @@ function getWaitingPreview(toolInput) {
|
|
|
1159
1160
|
}
|
|
1160
1161
|
|
|
1161
1162
|
function renderWaitingEntry() {
|
|
1162
|
-
if (!
|
|
1163
|
-
const age = Date.now() - new Date(currentWaiting.timestamp).getTime();
|
|
1164
|
-
if (age >= WAITING_TTL_MS) return '';
|
|
1163
|
+
if (!isWaitingFresh()) return '';
|
|
1165
1164
|
const tool = currentWaiting.toolName || 'unknown';
|
|
1166
1165
|
const label = getWaitingLabel(currentWaiting.kind, tool);
|
|
1167
1166
|
const preview = getWaitingPreview(currentWaiting.toolInput);
|
|
1168
1167
|
const previewHtml = preview
|
|
1169
1168
|
? `<div class="msg-waiting-preview">${escapeHtml(preview.slice(0, WAITING_PREVIEW_MAX_CHARS))}</div>`
|
|
1170
1169
|
: '';
|
|
1171
|
-
|
|
1170
|
+
const discardBtn = `<button class="msg-waiting-discard" title="Discard permission prompt" onclick="event.stopPropagation();discardWaiting()"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>`;
|
|
1171
|
+
return `<div class="msg-item msg-waiting" onclick="msgDetailFollowLatest=false;showWaitingDetail()">${ICON_CHAT}<div class="msg-body"><div class="msg-text">${escapeHtml(label)}</div>${previewHtml}<div class="msg-time">waiting…</div></div>${discardBtn}</div>`;
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
// biome-ignore lint/correctness/noUnusedVariables: used in HTML onclick
|
|
1175
|
+
async function discardWaiting() {
|
|
1176
|
+
if (!currentSessionId) return;
|
|
1177
|
+
try {
|
|
1178
|
+
const res = await fetch(`/api/sessions/${encodeURIComponent(currentSessionId)}/waiting/discard`, {
|
|
1179
|
+
method: 'POST',
|
|
1180
|
+
});
|
|
1181
|
+
if (res.ok) {
|
|
1182
|
+
currentWaiting = null;
|
|
1183
|
+
if (currentMsgDetailIdx === MSG_DETAIL_WAITING_IDX) closeMsgDetailModal();
|
|
1184
|
+
renderMessages(currentMessages);
|
|
1185
|
+
renderAgentFooter();
|
|
1186
|
+
}
|
|
1187
|
+
} catch (e) {
|
|
1188
|
+
console.error('[discardWaiting]', e);
|
|
1189
|
+
}
|
|
1172
1190
|
}
|
|
1173
1191
|
|
|
1174
1192
|
function renderMessages(messages) {
|
|
@@ -1199,6 +1217,7 @@ function renderMessages(messages) {
|
|
|
1199
1217
|
|
|
1200
1218
|
let currentMsgDetailIdx = null;
|
|
1201
1219
|
let msgDetailFollowLatest = false;
|
|
1220
|
+
const MSG_DETAIL_WAITING_IDX = -2;
|
|
1202
1221
|
let currentPins = [];
|
|
1203
1222
|
let pinnedCollapsed = false;
|
|
1204
1223
|
|
|
@@ -2137,7 +2156,7 @@ function renderAgentFooter() {
|
|
|
2137
2156
|
)
|
|
2138
2157
|
.slice(0, AGENT_LOG_MAX);
|
|
2139
2158
|
|
|
2140
|
-
const permFresh =
|
|
2159
|
+
const permFresh = isWaitingFresh();
|
|
2141
2160
|
|
|
2142
2161
|
if (visible.length === 0 && !permFresh) {
|
|
2143
2162
|
footer.classList.remove('visible');
|
|
@@ -2580,6 +2599,7 @@ function renderSessions() {
|
|
|
2580
2599
|
${session.sharedTaskList ? `<span class="shared-tasklist-badge" title="Shared task list: ${escapeHtml(session.sharedTaskList)}">${linkSvg(12)}</span>` : ''}
|
|
2581
2600
|
${isTeam || session.project || showCtx ? `<span class="team-info-btn" onclick="event.stopPropagation(); showSessionInfoModal('${session.id}')" title="View session info">ℹ</span>` : ''}
|
|
2582
2601
|
${session.hasPlan ? `<span class="plan-indicator" onclick="event.stopPropagation(); openPlanForSession('${session.id}')" title="View plan"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg></span>` : ''}
|
|
2602
|
+
${renderLoopBadge(session)}
|
|
2583
2603
|
${linkedDocsCount > 0 ? `<span class="linked-docs-badge" onclick="event.stopPropagation(); showSessionInfoModal('${session.id}')" title="${linkedDocsCount} linked document${linkedDocsCount > 1 ? 's' : ''}">${linkSvg(10)}${linkedDocsCount}</span>` : ''}
|
|
2584
2604
|
${bookmarksCount > 0 ? `<span class="bookmarks-badge" onclick="event.stopPropagation(); openSessionWithBookmarks('${session.id}')" title="${bookmarksCount} bookmarked message${bookmarksCount > 1 ? 's' : ''}"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>${bookmarksCount}</span>` : ''}
|
|
2585
2605
|
${hasScratchpad ? `<span class="scratchpad-badge" onclick="event.stopPropagation(); openSessionScratchpad('${session.id}')" title="Open scratchpad"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg></span>` : ''}
|
|
@@ -4251,7 +4271,7 @@ function matchKey(e, ...keys) {
|
|
|
4251
4271
|
return keys.some((k) => e.key === k || e.code === k);
|
|
4252
4272
|
}
|
|
4253
4273
|
|
|
4254
|
-
const MODAL_ESC_PRIORITY = ['preview-modal', 'msg-detail-modal', 'plan-modal'];
|
|
4274
|
+
const MODAL_ESC_PRIORITY = ['preview-modal', 'msg-detail-modal', 'plan-modal', 'loop-modal'];
|
|
4255
4275
|
const MODAL_CLOSERS = {
|
|
4256
4276
|
'preview-modal': () => closePreviewModal(),
|
|
4257
4277
|
'msg-detail-modal': () => {
|
|
@@ -4259,6 +4279,7 @@ const MODAL_CLOSERS = {
|
|
|
4259
4279
|
msgDetailFollowLatest = false;
|
|
4260
4280
|
},
|
|
4261
4281
|
'plan-modal': () => closePlanModal(),
|
|
4282
|
+
'loop-modal': () => closeLoopModal(),
|
|
4262
4283
|
'team-modal': () => closeTeamModal(),
|
|
4263
4284
|
'agent-modal': () => closeAgentModal(),
|
|
4264
4285
|
'help-modal': () => closeHelpModal(),
|
|
@@ -4293,7 +4314,13 @@ document.addEventListener('keydown', (e) => {
|
|
|
4293
4314
|
} else if (document.getElementById('msg-detail-modal').classList.contains('visible')) {
|
|
4294
4315
|
if (matchKey(e, 'ArrowDown', 'KeyJ')) {
|
|
4295
4316
|
e.preventDefault();
|
|
4296
|
-
if (currentMsgDetailIdx
|
|
4317
|
+
if (currentMsgDetailIdx === MSG_DETAIL_WAITING_IDX) {
|
|
4318
|
+
msgDetailFollowLatest = true;
|
|
4319
|
+
showWaitingDetail();
|
|
4320
|
+
} else if (currentMsgDetailIdx === currentMessages.length - 1 && isWaitingFresh()) {
|
|
4321
|
+
msgDetailFollowLatest = false;
|
|
4322
|
+
showWaitingDetail();
|
|
4323
|
+
} else if (currentMsgDetailIdx < currentMessages.length - 1) {
|
|
4297
4324
|
msgDetailFollowLatest = false;
|
|
4298
4325
|
showMsgDetail(currentMsgDetailIdx + 1);
|
|
4299
4326
|
} else if (currentMsgDetailIdx === currentMessages.length - 1) {
|
|
@@ -4302,7 +4329,12 @@ document.addEventListener('keydown', (e) => {
|
|
|
4302
4329
|
}
|
|
4303
4330
|
} else if (matchKey(e, 'ArrowUp', 'KeyK')) {
|
|
4304
4331
|
e.preventDefault();
|
|
4305
|
-
if (currentMsgDetailIdx
|
|
4332
|
+
if (currentMsgDetailIdx === MSG_DETAIL_WAITING_IDX) {
|
|
4333
|
+
if (currentMessages.length) {
|
|
4334
|
+
msgDetailFollowLatest = false;
|
|
4335
|
+
showMsgDetail(currentMessages.length - 1);
|
|
4336
|
+
}
|
|
4337
|
+
} else if (currentMsgDetailIdx > 0) {
|
|
4306
4338
|
msgDetailFollowLatest = false;
|
|
4307
4339
|
showMsgDetail(currentMsgDetailIdx - 1);
|
|
4308
4340
|
}
|
|
@@ -4327,6 +4359,9 @@ document.addEventListener('keydown', (e) => {
|
|
|
4327
4359
|
const msgDetailModal = document.getElementById('msg-detail-modal');
|
|
4328
4360
|
if (msgDetailModal.classList.contains('visible')) {
|
|
4329
4361
|
closeMsgDetailModal();
|
|
4362
|
+
} else if (isWaitingFresh()) {
|
|
4363
|
+
msgDetailFollowLatest = true;
|
|
4364
|
+
showWaitingDetail();
|
|
4330
4365
|
} else if (currentMessages.length) {
|
|
4331
4366
|
msgDetailFollowLatest = true;
|
|
4332
4367
|
showMsgDetail(currentMessages.length - 1);
|
|
@@ -5091,11 +5126,50 @@ function renderContextDetail(raw) {
|
|
|
5091
5126
|
|
|
5092
5127
|
//#region UTILS
|
|
5093
5128
|
function maybeFollowLatest() {
|
|
5094
|
-
if (msgDetailFollowLatest
|
|
5129
|
+
if (!msgDetailFollowLatest) return;
|
|
5130
|
+
if (isWaitingFresh()) {
|
|
5131
|
+
showWaitingDetail();
|
|
5132
|
+
} else if (currentMessages.length) {
|
|
5095
5133
|
showMsgDetail(currentMessages.length - 1);
|
|
5096
5134
|
}
|
|
5097
5135
|
}
|
|
5098
5136
|
|
|
5137
|
+
function isWaitingFresh() {
|
|
5138
|
+
if (!currentWaiting?.timestamp) return false;
|
|
5139
|
+
return Date.now() - new Date(currentWaiting.timestamp).getTime() < WAITING_TTL_MS;
|
|
5140
|
+
}
|
|
5141
|
+
|
|
5142
|
+
function showWaitingDetail() {
|
|
5143
|
+
if (!isWaitingFresh()) return;
|
|
5144
|
+
currentMsgDetailIdx = MSG_DETAIL_WAITING_IDX;
|
|
5145
|
+
const tool = currentWaiting.toolName || 'unknown';
|
|
5146
|
+
const label = getWaitingLabel(currentWaiting.kind, tool);
|
|
5147
|
+
const body = document.getElementById('msg-detail-body');
|
|
5148
|
+
let inputHtml = '';
|
|
5149
|
+
if (currentWaiting.toolInput) {
|
|
5150
|
+
let pretty = currentWaiting.toolInput;
|
|
5151
|
+
try {
|
|
5152
|
+
pretty = JSON.stringify(JSON.parse(currentWaiting.toolInput), null, 2);
|
|
5153
|
+
} catch (_) {
|
|
5154
|
+
/* keep raw */
|
|
5155
|
+
}
|
|
5156
|
+
inputHtml = `<pre class="${TINTED_PRE_CLASS}">${escapeHtml(pretty)}</pre>`;
|
|
5157
|
+
}
|
|
5158
|
+
body.innerHTML = inputHtml;
|
|
5159
|
+
document.getElementById('msg-detail-title').textContent = label;
|
|
5160
|
+
document.getElementById('msg-detail-agent-btn').style.display = 'none';
|
|
5161
|
+
const modal = document.getElementById('msg-detail-modal').querySelector('.modal');
|
|
5162
|
+
autoSizeModal(modal, body);
|
|
5163
|
+
modal.classList.toggle('live', msgDetailFollowLatest);
|
|
5164
|
+
const overlay = document.getElementById('msg-detail-modal');
|
|
5165
|
+
overlay.classList.toggle('live-overlay', msgDetailFollowLatest);
|
|
5166
|
+
const meta = [formatDate(currentWaiting.timestamp), 'waiting'];
|
|
5167
|
+
document.getElementById('msg-detail-meta').textContent = meta.join(' · ');
|
|
5168
|
+
currentPinDetailId = null;
|
|
5169
|
+
updateMsgDetailPinState();
|
|
5170
|
+
overlay.classList.add('visible');
|
|
5171
|
+
}
|
|
5172
|
+
|
|
5099
5173
|
function isSessionActive(s) {
|
|
5100
5174
|
return s.hasRecentLog || s.inProgress > 0 || s.hasActiveAgents || s.hasWaitingForUser;
|
|
5101
5175
|
}
|
|
@@ -5884,6 +5958,110 @@ function refreshOpenPlan() {
|
|
|
5884
5958
|
.catch(() => {});
|
|
5885
5959
|
}
|
|
5886
5960
|
|
|
5961
|
+
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
5962
|
+
function showLoopModal(sessionId) {
|
|
5963
|
+
const body = document.getElementById('loop-modal-body');
|
|
5964
|
+
body.innerHTML = '<div style="padding:16px;color:var(--text-secondary);">Loading…</div>';
|
|
5965
|
+
document.getElementById('loop-modal').classList.add('visible');
|
|
5966
|
+
fetch(`/api/sessions/${sessionId}/loop`)
|
|
5967
|
+
.then((r) => (r.ok ? r.json() : { wakeups: [], crons: [] }))
|
|
5968
|
+
.catch(() => ({ wakeups: [], crons: [] }))
|
|
5969
|
+
.then((data) => {
|
|
5970
|
+
renderLoopModalBody(data);
|
|
5971
|
+
});
|
|
5972
|
+
}
|
|
5973
|
+
|
|
5974
|
+
function fmtLoopDelay(s) {
|
|
5975
|
+
if (s == null) return '';
|
|
5976
|
+
if (s < 60) return `${s}s`;
|
|
5977
|
+
if (s < 3600) return `${Math.round(s / 60)}m`;
|
|
5978
|
+
return `${(s / 3600).toFixed(1)}h`;
|
|
5979
|
+
}
|
|
5980
|
+
|
|
5981
|
+
function fmtLoopFireTime(timestamp, delaySeconds) {
|
|
5982
|
+
if (!timestamp || delaySeconds == null) return { abs: '', rel: '', status: '' };
|
|
5983
|
+
const fireMs = new Date(timestamp).getTime() + delaySeconds * 1000;
|
|
5984
|
+
const fireDate = new Date(fireMs);
|
|
5985
|
+
const diff = fireMs - Date.now();
|
|
5986
|
+
const abs = fireDate.toLocaleString(undefined, {
|
|
5987
|
+
month: 'short',
|
|
5988
|
+
day: 'numeric',
|
|
5989
|
+
hour: '2-digit',
|
|
5990
|
+
minute: '2-digit',
|
|
5991
|
+
});
|
|
5992
|
+
const absSec = Math.abs(Math.round(diff / 1000));
|
|
5993
|
+
const rel =
|
|
5994
|
+
absSec < 60 ? `${absSec}s` : absSec < 3600 ? `${Math.round(absSec / 60)}m` : `${(absSec / 3600).toFixed(1)}h`;
|
|
5995
|
+
if (diff > 0) return { abs, rel: `in ${rel}`, status: 'pending' };
|
|
5996
|
+
return { abs, rel: `${rel} ago`, status: 'fired' };
|
|
5997
|
+
}
|
|
5998
|
+
|
|
5999
|
+
const LOOP_CLOCK_SVG =
|
|
6000
|
+
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>';
|
|
6001
|
+
const LOOP_CRON_SVG =
|
|
6002
|
+
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 8v4l3 3"/><circle cx="12" cy="12" r="10"/></svg>';
|
|
6003
|
+
|
|
6004
|
+
function loopField(label, value, mono = false) {
|
|
6005
|
+
if (!value) return '';
|
|
6006
|
+
const inner = mono ? `<code>${escapeHtml(value)}</code>` : escapeHtml(value);
|
|
6007
|
+
return `<div class="loop-field"><div class="loop-field-label">${label}</div><div class="loop-field-val">${inner}</div></div>`;
|
|
6008
|
+
}
|
|
6009
|
+
|
|
6010
|
+
function renderLoopRow(item, kind) {
|
|
6011
|
+
const when = item.timestamp ? formatDate(item.timestamp) : '';
|
|
6012
|
+
let headline = '';
|
|
6013
|
+
let footer = '';
|
|
6014
|
+
let fields = '';
|
|
6015
|
+
if (kind === 'wakeup') {
|
|
6016
|
+
const fire = fmtLoopFireTime(item.timestamp, item.delaySeconds);
|
|
6017
|
+
const delayLbl = item.delaySeconds != null ? `delay ${fmtLoopDelay(item.delaySeconds)}` : '';
|
|
6018
|
+
if (fire.abs) {
|
|
6019
|
+
headline = `<div class="loop-headline loop-fire-${fire.status}">${LOOP_CLOCK_SVG}<span class="loop-headline-rel">${fire.status === 'pending' ? 'Fires' : 'Fired'} ${escapeHtml(fire.rel)}</span><span class="loop-headline-abs">${escapeHtml(fire.abs)}</span></div>`;
|
|
6020
|
+
}
|
|
6021
|
+
fields = loopField('Reason', item.reason) + loopField('Prompt', item.prompt, true);
|
|
6022
|
+
footer = `<div class="loop-foot">scheduled ${escapeHtml(when)}${delayLbl ? ` · ${delayLbl}` : ''}</div>`;
|
|
6023
|
+
} else {
|
|
6024
|
+
if (item.cron) {
|
|
6025
|
+
headline = `<div class="loop-headline">${LOOP_CRON_SVG}<span class="loop-headline-rel"><code>${escapeHtml(item.cron)}</code></span></div>`;
|
|
6026
|
+
}
|
|
6027
|
+
fields = loopField('Description', item.description) + loopField('Prompt', item.prompt, true);
|
|
6028
|
+
footer = `<div class="loop-foot">created ${escapeHtml(when)}</div>`;
|
|
6029
|
+
}
|
|
6030
|
+
return `<div class="loop-row">${headline}${fields}${footer}</div>`;
|
|
6031
|
+
}
|
|
6032
|
+
|
|
6033
|
+
function renderLoopModalBody(data) {
|
|
6034
|
+
const body = document.getElementById('loop-modal-body');
|
|
6035
|
+
const wakeups = data.wakeups || [];
|
|
6036
|
+
const crons = data.crons || [];
|
|
6037
|
+
if (!wakeups.length && !crons.length) {
|
|
6038
|
+
body.innerHTML =
|
|
6039
|
+
'<div style="padding:24px;text-align:center;color:var(--text-secondary);">No scheduled wakeups or cron jobs.</div>';
|
|
6040
|
+
return;
|
|
6041
|
+
}
|
|
6042
|
+
const section = (title, items, kind) =>
|
|
6043
|
+
items.length
|
|
6044
|
+
? `<h4 class="loop-section-title">${title} <span class="loop-count">${items.length}</span></h4>${items.map((i) => renderLoopRow(i, kind)).join('')}`
|
|
6045
|
+
: '';
|
|
6046
|
+
body.innerHTML = section('Wakeups', wakeups, 'wakeup') + section('Cron jobs', crons, 'cron');
|
|
6047
|
+
}
|
|
6048
|
+
|
|
6049
|
+
function renderLoopBadge(session) {
|
|
6050
|
+
const li = session.loopInfo;
|
|
6051
|
+
const total = (li?.wakeupCount || 0) + (li?.cronCount || 0);
|
|
6052
|
+
if (total === 0) return '';
|
|
6053
|
+
let tip = `${li.wakeupCount} wakeup${li.wakeupCount === 1 ? '' : 's'}, ${li.cronCount} cron${li.cronCount === 1 ? '' : 's'}`;
|
|
6054
|
+
if (li.latest?.timestamp && li.latest.delaySeconds != null) {
|
|
6055
|
+
const f = fmtLoopFireTime(li.latest.timestamp, li.latest.delaySeconds);
|
|
6056
|
+
if (f.abs) tip += ` — latest ${f.status === 'pending' ? 'fires' : 'fired'} ${f.rel} (${f.abs})`;
|
|
6057
|
+
}
|
|
6058
|
+
return `<span class="loop-badge" onclick="event.stopPropagation(); showLoopModal('${session.id}')" title="${escapeHtml(tip)}"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg></span>`;
|
|
6059
|
+
}
|
|
6060
|
+
|
|
6061
|
+
function closeLoopModal() {
|
|
6062
|
+
document.getElementById('loop-modal').classList.remove('visible');
|
|
6063
|
+
}
|
|
6064
|
+
|
|
5887
6065
|
function openPlanForSession(sid) {
|
|
5888
6066
|
fetch(`/api/sessions/${sid}/plan`)
|
|
5889
6067
|
.then((r) => (r.ok ? r.json() : null))
|
package/public/index.html
CHANGED
|
@@ -604,6 +604,25 @@
|
|
|
604
604
|
</div>
|
|
605
605
|
</div>
|
|
606
606
|
|
|
607
|
+
<!-- Loop Wakeups Modal -->
|
|
608
|
+
<div id="loop-modal" class="modal-overlay" onclick="closeLoopModal()">
|
|
609
|
+
<div class="modal plan-modal" onclick="event.stopPropagation()">
|
|
610
|
+
<div class="modal-header">
|
|
611
|
+
<h3 id="loop-modal-title" class="modal-title" style="display:flex;align-items:center;gap:8px;">
|
|
612
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:18px;height:18px;"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
|
613
|
+
Loop activity
|
|
614
|
+
</h3>
|
|
615
|
+
<button class="modal-close" aria-label="Close dialog" onclick="closeLoopModal()">
|
|
616
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12"/></svg>
|
|
617
|
+
</button>
|
|
618
|
+
</div>
|
|
619
|
+
<div id="loop-modal-body" class="modal-body" style="overflow-y:auto;flex:0 1 auto;min-height:0;"></div>
|
|
620
|
+
<div class="modal-footer">
|
|
621
|
+
<button class="btn btn-primary" onclick="closeLoopModal()">Close</button>
|
|
622
|
+
</div>
|
|
623
|
+
</div>
|
|
624
|
+
</div>
|
|
625
|
+
|
|
607
626
|
<div id="agent-modal" class="modal-overlay plan-modal-overlay" onclick="closeAgentModal()">
|
|
608
627
|
<div class="modal plan-modal" onclick="event.stopPropagation()">
|
|
609
628
|
<div class="modal-header">
|
package/public/style.css
CHANGED
|
@@ -1615,6 +1615,136 @@ body::before {
|
|
|
1615
1615
|
border-color: var(--plan);
|
|
1616
1616
|
}
|
|
1617
1617
|
|
|
1618
|
+
.loop-badge {
|
|
1619
|
+
width: 24px;
|
|
1620
|
+
height: 24px;
|
|
1621
|
+
display: inline-flex;
|
|
1622
|
+
align-items: center;
|
|
1623
|
+
justify-content: center;
|
|
1624
|
+
background: var(--bg-deep);
|
|
1625
|
+
border: 1px solid transparent;
|
|
1626
|
+
border-radius: 4px;
|
|
1627
|
+
color: var(--text-secondary);
|
|
1628
|
+
cursor: pointer;
|
|
1629
|
+
flex-shrink: 0;
|
|
1630
|
+
transition: all 0.15s ease;
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
.loop-badge:hover {
|
|
1634
|
+
border-color: var(--accent, var(--plan));
|
|
1635
|
+
color: var(--accent, var(--plan));
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
.loop-section-title {
|
|
1639
|
+
margin: 14px 0 8px;
|
|
1640
|
+
font-size: 12px;
|
|
1641
|
+
font-weight: 600;
|
|
1642
|
+
text-transform: uppercase;
|
|
1643
|
+
letter-spacing: 0.04em;
|
|
1644
|
+
color: var(--text-secondary);
|
|
1645
|
+
display: flex;
|
|
1646
|
+
align-items: center;
|
|
1647
|
+
gap: 8px;
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1650
|
+
.loop-section-title:first-child {
|
|
1651
|
+
margin-top: 4px;
|
|
1652
|
+
}
|
|
1653
|
+
|
|
1654
|
+
.loop-count {
|
|
1655
|
+
font-size: 11px;
|
|
1656
|
+
font-weight: 500;
|
|
1657
|
+
padding: 1px 6px;
|
|
1658
|
+
border-radius: 10px;
|
|
1659
|
+
background: var(--bg-elevated);
|
|
1660
|
+
color: var(--text-secondary);
|
|
1661
|
+
text-transform: none;
|
|
1662
|
+
letter-spacing: 0;
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
.loop-row {
|
|
1666
|
+
padding: 12px 14px;
|
|
1667
|
+
border: 1px solid var(--border);
|
|
1668
|
+
border-left: 3px solid var(--border);
|
|
1669
|
+
border-radius: 6px;
|
|
1670
|
+
background: var(--bg-elevated);
|
|
1671
|
+
margin-bottom: 10px;
|
|
1672
|
+
display: flex;
|
|
1673
|
+
flex-direction: column;
|
|
1674
|
+
gap: 10px;
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1677
|
+
.loop-row:has(.loop-fire-pending) {
|
|
1678
|
+
border-left-color: var(--accent, var(--plan));
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1681
|
+
.loop-headline {
|
|
1682
|
+
display: flex;
|
|
1683
|
+
align-items: center;
|
|
1684
|
+
gap: 10px;
|
|
1685
|
+
flex-wrap: wrap;
|
|
1686
|
+
font-variant-numeric: tabular-nums;
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
.loop-headline-rel {
|
|
1690
|
+
font-size: 15px;
|
|
1691
|
+
font-weight: 600;
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1694
|
+
.loop-headline-abs {
|
|
1695
|
+
font-size: 12px;
|
|
1696
|
+
color: var(--text-secondary);
|
|
1697
|
+
margin-left: auto;
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
.loop-fire-pending {
|
|
1701
|
+
color: var(--accent, var(--plan));
|
|
1702
|
+
}
|
|
1703
|
+
.loop-fire-fired {
|
|
1704
|
+
color: var(--text-secondary);
|
|
1705
|
+
}
|
|
1706
|
+
.loop-fire-fired .loop-headline-rel {
|
|
1707
|
+
font-weight: 500;
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1710
|
+
.loop-field {
|
|
1711
|
+
display: grid;
|
|
1712
|
+
grid-template-columns: 80px 1fr;
|
|
1713
|
+
gap: 10px;
|
|
1714
|
+
align-items: start;
|
|
1715
|
+
font-size: 13px;
|
|
1716
|
+
line-height: 1.45;
|
|
1717
|
+
}
|
|
1718
|
+
|
|
1719
|
+
.loop-field-label {
|
|
1720
|
+
font-size: 10px;
|
|
1721
|
+
text-transform: uppercase;
|
|
1722
|
+
letter-spacing: 0.06em;
|
|
1723
|
+
color: var(--text-secondary);
|
|
1724
|
+
padding-top: 3px;
|
|
1725
|
+
}
|
|
1726
|
+
|
|
1727
|
+
.loop-field-val {
|
|
1728
|
+
color: var(--text-primary);
|
|
1729
|
+
word-break: break-word;
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1732
|
+
.loop-field-val code {
|
|
1733
|
+
background: var(--bg);
|
|
1734
|
+
padding: 2px 6px;
|
|
1735
|
+
border-radius: 4px;
|
|
1736
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
1737
|
+
font-size: 12px;
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1740
|
+
.loop-foot {
|
|
1741
|
+
font-size: 11px;
|
|
1742
|
+
color: var(--text-secondary);
|
|
1743
|
+
padding-top: 6px;
|
|
1744
|
+
border-top: 1px dashed var(--border);
|
|
1745
|
+
font-variant-numeric: tabular-nums;
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1618
1748
|
/* #endregion */
|
|
1619
1749
|
|
|
1620
1750
|
/* #region OWNER_BADGE */
|
|
@@ -2358,6 +2488,28 @@ body::before {
|
|
|
2358
2488
|
border-left: 3px solid var(--warning, #f5a623);
|
|
2359
2489
|
background: color-mix(in srgb, var(--warning, #f5a623) 8%, transparent);
|
|
2360
2490
|
animation: msg-waiting-pulse 2s ease-in-out infinite;
|
|
2491
|
+
cursor: pointer;
|
|
2492
|
+
}
|
|
2493
|
+
.msg-waiting-discard {
|
|
2494
|
+
flex-shrink: 0;
|
|
2495
|
+
background: none;
|
|
2496
|
+
border: none;
|
|
2497
|
+
color: var(--text-muted);
|
|
2498
|
+
cursor: pointer;
|
|
2499
|
+
padding: 2px;
|
|
2500
|
+
opacity: 0;
|
|
2501
|
+
transition: opacity 0.15s;
|
|
2502
|
+
margin-left: auto;
|
|
2503
|
+
align-self: flex-start;
|
|
2504
|
+
line-height: 0;
|
|
2505
|
+
}
|
|
2506
|
+
.msg-item.msg-waiting:hover .msg-waiting-discard {
|
|
2507
|
+
opacity: 0.6;
|
|
2508
|
+
}
|
|
2509
|
+
.msg-waiting-discard:hover {
|
|
2510
|
+
/* biome-ignore lint/complexity/noImportantStyles: override parent hover opacity */
|
|
2511
|
+
opacity: 1 !important;
|
|
2512
|
+
color: var(--danger, #e54d4d);
|
|
2361
2513
|
}
|
|
2362
2514
|
.msg-waiting .msg-text {
|
|
2363
2515
|
font-weight: 600;
|
package/server.js
CHANGED
|
@@ -8,7 +8,7 @@ const readline = require('readline');
|
|
|
8
8
|
const chokidar = require('chokidar');
|
|
9
9
|
const os = require('os');
|
|
10
10
|
const crypto = require('crypto');
|
|
11
|
-
const { spawn } = require('child_process');
|
|
11
|
+
const { spawn, spawnSync } = require('child_process');
|
|
12
12
|
|
|
13
13
|
const {
|
|
14
14
|
readRecentMessages: _readRecentMessagesUncached,
|
|
@@ -21,7 +21,9 @@ const {
|
|
|
21
21
|
extractPromptFromTranscript,
|
|
22
22
|
extractModelFromTranscript,
|
|
23
23
|
readFullToolResult,
|
|
24
|
-
readUserImage
|
|
24
|
+
readUserImage,
|
|
25
|
+
updateLoopInfo,
|
|
26
|
+
buildLoopInfoFromState
|
|
25
27
|
} = require('./lib/parsers');
|
|
26
28
|
|
|
27
29
|
if (process.argv.includes("--install") || process.argv.includes("--uninstall")) {
|
|
@@ -157,6 +159,45 @@ function isAgentFresh(agent) {
|
|
|
157
159
|
return (Date.now() - new Date(ts).getTime()) < AGENT_TTL_MS;
|
|
158
160
|
}
|
|
159
161
|
|
|
162
|
+
// Claude Code records gitBranch from the launch-time repo and never updates it
|
|
163
|
+
// when cwd shifts (Bash `cd`, submodule, sibling repo). Resolve on-demand from
|
|
164
|
+
// the live cwd instead. Cached per-cwd with a short TTL so a list refresh
|
|
165
|
+
// across N sessions sharing one cwd spawns git at most once per TTL window.
|
|
166
|
+
const gitBranchCache = new Map();
|
|
167
|
+
const GIT_BRANCH_TTL_MS = 30000;
|
|
168
|
+
const GIT_BRANCH_CACHE_MAX = 500;
|
|
169
|
+
function getGitBranch(cwd) {
|
|
170
|
+
if (!cwd) return null;
|
|
171
|
+
const now = Date.now();
|
|
172
|
+
const cached = gitBranchCache.get(cwd);
|
|
173
|
+
if (cached && now - cached.ts < GIT_BRANCH_TTL_MS) return cached.branch;
|
|
174
|
+
let branch = null;
|
|
175
|
+
try {
|
|
176
|
+
const r = spawnSync('git', ['-C', cwd, 'rev-parse', '--abbrev-ref', 'HEAD'], {
|
|
177
|
+
encoding: 'utf8', timeout: 500, windowsHide: true
|
|
178
|
+
});
|
|
179
|
+
if (r.status === 0) {
|
|
180
|
+
const out = (r.stdout || '').trim();
|
|
181
|
+
if (out && out !== 'HEAD') branch = out;
|
|
182
|
+
}
|
|
183
|
+
} catch (_) {}
|
|
184
|
+
gitBranchCache.set(cwd, { branch, ts: now });
|
|
185
|
+
if (gitBranchCache.size > GIT_BRANCH_CACHE_MAX) {
|
|
186
|
+
const firstKey = gitBranchCache.keys().next().value;
|
|
187
|
+
gitBranchCache.delete(firstKey);
|
|
188
|
+
}
|
|
189
|
+
return branch;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Only spawn git when cwd has diverged from the launch project — that's the
|
|
193
|
+
// only case the JSONL value is wrong. Saves N spawns on a typical list build.
|
|
194
|
+
function resolveSessionGitBranch(meta) {
|
|
195
|
+
if (meta.cwd && meta.project && meta.cwd !== meta.project) {
|
|
196
|
+
return getGitBranch(meta.cwd) || meta.gitBranch || null;
|
|
197
|
+
}
|
|
198
|
+
return meta.gitBranch || null;
|
|
199
|
+
}
|
|
200
|
+
|
|
160
201
|
function getSessionLogStat(meta) {
|
|
161
202
|
if (!meta.jsonlPath) return { mtime: null, hasMessages: false };
|
|
162
203
|
try {
|
|
@@ -221,6 +262,11 @@ const clients = new Set();
|
|
|
221
262
|
let sessionMetadataCache = {};
|
|
222
263
|
let lastMetadataRefresh = 0;
|
|
223
264
|
const METADATA_CACHE_TTL = 10000; // 10 seconds
|
|
265
|
+
// Watcher-driven invalidation. `change` events (append to existing jsonl) only
|
|
266
|
+
// dirty the one path so we can do a targeted refresh; `add` / `unlink` events
|
|
267
|
+
// are structural and force a full rescan.
|
|
268
|
+
const dirtyMetadataPaths = new Set();
|
|
269
|
+
let metadataNeedsFullScan = true;
|
|
224
270
|
|
|
225
271
|
const SAFE_ID_RE = /^[a-zA-Z0-9_-]+$/;
|
|
226
272
|
function isSafeId(id) {
|
|
@@ -386,10 +432,50 @@ function readRecentMessages(jsonlPath, limit = 10) {
|
|
|
386
432
|
/**
|
|
387
433
|
* Scan all project directories to find session JSONL files and extract slugs
|
|
388
434
|
*/
|
|
435
|
+
// Returns false when sessionId is unknown — caller must promote to full scan.
|
|
436
|
+
function refreshSessionMetadataPath(jsonlPath) {
|
|
437
|
+
const sessionId = path.basename(jsonlPath, '.jsonl');
|
|
438
|
+
if (!isSafeId(sessionId)) return false;
|
|
439
|
+
const existing = sessionMetadataCache[sessionId];
|
|
440
|
+
if (!existing) return false;
|
|
441
|
+
let info;
|
|
442
|
+
try {
|
|
443
|
+
info = readSessionInfoFromJsonl(jsonlPath);
|
|
444
|
+
} catch (_) {
|
|
445
|
+
return false;
|
|
446
|
+
}
|
|
447
|
+
// Shadow JSONLs (continued from a worktree) hold only custom-title / agent-
|
|
448
|
+
// name records — no projectPath. Don't let a shadow clobber the real entry.
|
|
449
|
+
const shadow = existing.project && !info.projectPath;
|
|
450
|
+
if (shadow) {
|
|
451
|
+
if (!existing.slug && info.slug) existing.slug = info.slug;
|
|
452
|
+
if (!existing.customTitle && info.customTitle) existing.customTitle = info.customTitle;
|
|
453
|
+
return true;
|
|
454
|
+
}
|
|
455
|
+
if (info.slug) existing.slug = info.slug;
|
|
456
|
+
if (info.cwd) existing.cwd = info.cwd;
|
|
457
|
+
if (info.gitBranch) existing.gitBranch = info.gitBranch;
|
|
458
|
+
if (info.customTitle) existing.customTitle = info.customTitle;
|
|
459
|
+
return true;
|
|
460
|
+
}
|
|
461
|
+
|
|
389
462
|
function loadSessionMetadata() {
|
|
390
463
|
const now = Date.now();
|
|
391
|
-
|
|
392
|
-
|
|
464
|
+
|
|
465
|
+
if (!metadataNeedsFullScan && now - lastMetadataRefresh < METADATA_CACHE_TTL) {
|
|
466
|
+
if (dirtyMetadataPaths.size > 0) {
|
|
467
|
+
for (const p of dirtyMetadataPaths) {
|
|
468
|
+
if (!refreshSessionMetadataPath(p)) {
|
|
469
|
+
// Unknown sessionId — structural change snuck in. Promote to full.
|
|
470
|
+
metadataNeedsFullScan = true;
|
|
471
|
+
break;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
dirtyMetadataPaths.clear();
|
|
475
|
+
if (!metadataNeedsFullScan) return sessionMetadataCache;
|
|
476
|
+
} else {
|
|
477
|
+
return sessionMetadataCache;
|
|
478
|
+
}
|
|
393
479
|
}
|
|
394
480
|
|
|
395
481
|
const metadata = {};
|
|
@@ -520,6 +606,8 @@ function loadSessionMetadata() {
|
|
|
520
606
|
|
|
521
607
|
sessionMetadataCache = metadata;
|
|
522
608
|
lastMetadataRefresh = now;
|
|
609
|
+
metadataNeedsFullScan = false;
|
|
610
|
+
dirtyMetadataPaths.clear();
|
|
523
611
|
return metadata;
|
|
524
612
|
}
|
|
525
613
|
|
|
@@ -536,6 +624,51 @@ function getPlanInfo(slug) {
|
|
|
536
624
|
}
|
|
537
625
|
}
|
|
538
626
|
|
|
627
|
+
// Hide wakeups whose fire time is more than this far in the past — long /loop
|
|
628
|
+
// sessions otherwise produce dozens of stale entries that drown the badge.
|
|
629
|
+
const WAKEUP_FIRED_GRACE_MS = 5 * 60 * 1000;
|
|
630
|
+
|
|
631
|
+
function isWakeupActive(w, now = Date.now()) {
|
|
632
|
+
if (!w || !w.timestamp || w.delaySeconds == null) return true;
|
|
633
|
+
const fireMs = new Date(w.timestamp).getTime() + w.delaySeconds * 1000;
|
|
634
|
+
return (now - fireMs) <= WAKEUP_FIRED_GRACE_MS;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function filterActiveLoopInfo(info) {
|
|
638
|
+
const now = Date.now();
|
|
639
|
+
return {
|
|
640
|
+
wakeups: info.wakeups.filter(w => isWakeupActive(w, now)),
|
|
641
|
+
crons: info.crons
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// Per-path incremental scan state. Populated lazily on first access and
|
|
646
|
+
// updated in place; the projectsWatcher event handler keeps entries warm so
|
|
647
|
+
// the request path does O(1) work in steady state.
|
|
648
|
+
const loopInfoStateByPath = new Map();
|
|
649
|
+
|
|
650
|
+
function refreshLoopInfoState(jsonlPath) {
|
|
651
|
+
if (!jsonlPath) return null;
|
|
652
|
+
const prev = loopInfoStateByPath.get(jsonlPath);
|
|
653
|
+
const next = updateLoopInfo(jsonlPath, prev);
|
|
654
|
+
if (next) loopInfoStateByPath.set(jsonlPath, next);
|
|
655
|
+
return next;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
function getLoopInfoSummary(meta) {
|
|
659
|
+
const empty = { wakeupCount: 0, cronCount: 0, latest: null };
|
|
660
|
+
if (!meta?.jsonlPath) return empty;
|
|
661
|
+
try {
|
|
662
|
+
const state = refreshLoopInfoState(meta.jsonlPath);
|
|
663
|
+
const filtered = filterActiveLoopInfo(buildLoopInfoFromState(state));
|
|
664
|
+
return {
|
|
665
|
+
wakeupCount: filtered.wakeups.length,
|
|
666
|
+
cronCount: filtered.crons.length,
|
|
667
|
+
latest: filtered.wakeups[filtered.wakeups.length - 1] || filtered.crons[filtered.crons.length - 1] || null
|
|
668
|
+
};
|
|
669
|
+
} catch (_) { return empty; }
|
|
670
|
+
}
|
|
671
|
+
|
|
539
672
|
function getSessionDisplayName(sessionId, meta) {
|
|
540
673
|
if (meta?.customTitle) return meta.customTitle;
|
|
541
674
|
if (meta?.slug) return meta.slug;
|
|
@@ -553,7 +686,7 @@ function buildSessionObject(id, meta, overrides = {}) {
|
|
|
553
686
|
project: meta.project || null,
|
|
554
687
|
cwd: meta.cwd || null,
|
|
555
688
|
description: meta.description || null,
|
|
556
|
-
gitBranch: meta
|
|
689
|
+
gitBranch: resolveSessionGitBranch(meta),
|
|
557
690
|
customTitle: meta.customTitle || null,
|
|
558
691
|
taskCount: 0,
|
|
559
692
|
completed: 0,
|
|
@@ -573,6 +706,7 @@ function buildSessionObject(id, meta, overrides = {}) {
|
|
|
573
706
|
projectDir: meta.jsonlPath ? path.dirname(meta.jsonlPath) : null,
|
|
574
707
|
contextStatus: getContextStatus(id, meta),
|
|
575
708
|
...getPlanInfo(meta.slug),
|
|
709
|
+
loopInfo: getLoopInfoSummary(meta),
|
|
576
710
|
...overrides,
|
|
577
711
|
// Remove internal-only field
|
|
578
712
|
_logStat: undefined,
|
|
@@ -974,6 +1108,23 @@ app.get('/api/sessions/:sessionId/plan', async (req, res) => {
|
|
|
974
1108
|
}
|
|
975
1109
|
});
|
|
976
1110
|
|
|
1111
|
+
app.get('/api/sessions/:sessionId/loop', (req, res) => {
|
|
1112
|
+
try {
|
|
1113
|
+
const metadata = loadSessionMetadata();
|
|
1114
|
+
const meta = metadata[req.params.sessionId];
|
|
1115
|
+
if (!meta?.jsonlPath) return res.json({ wakeups: [], crons: [] });
|
|
1116
|
+
const state = refreshLoopInfoState(meta.jsonlPath);
|
|
1117
|
+
const filtered = filterActiveLoopInfo(buildLoopInfoFromState(state));
|
|
1118
|
+
res.json({
|
|
1119
|
+
wakeups: [...filtered.wakeups].reverse(),
|
|
1120
|
+
crons: [...filtered.crons].reverse()
|
|
1121
|
+
});
|
|
1122
|
+
} catch (error) {
|
|
1123
|
+
console.error('Error reading loop info:', error);
|
|
1124
|
+
res.status(500).json({ error: 'Failed to read loop info' });
|
|
1125
|
+
}
|
|
1126
|
+
});
|
|
1127
|
+
|
|
977
1128
|
function openInEditor(...targets) {
|
|
978
1129
|
const editor = process.env.EDITOR || 'code';
|
|
979
1130
|
spawn(editor, ['-n', ...targets], { shell: true, stdio: 'ignore', detached: true }).unref();
|
|
@@ -1223,6 +1374,20 @@ app.get('/api/sessions/:sessionId/agents', (req, res) => {
|
|
|
1223
1374
|
}
|
|
1224
1375
|
});
|
|
1225
1376
|
|
|
1377
|
+
function clearWaitingFile(sessionId) {
|
|
1378
|
+
try { unlinkSync(path.join(AGENT_ACTIVITY_DIR, sessionId, '_waiting.json')); }
|
|
1379
|
+
catch (e) { if (e.code !== 'ENOENT') throw e; }
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
app.post('/api/sessions/:sessionId/waiting/discard', (req, res) => {
|
|
1383
|
+
try {
|
|
1384
|
+
clearWaitingFile(resolveSessionId(req.params.sessionId));
|
|
1385
|
+
res.json({ ok: true });
|
|
1386
|
+
} catch (e) {
|
|
1387
|
+
res.status(500).json({ error: 'Failed to discard waiting' });
|
|
1388
|
+
}
|
|
1389
|
+
});
|
|
1390
|
+
|
|
1226
1391
|
app.post('/api/sessions/:sessionId/agents/:agentId/stop', (req, res) => {
|
|
1227
1392
|
const sessionId = resolveSessionId(req.params.sessionId);
|
|
1228
1393
|
const agentId = sanitizeAgentId(req.params.agentId);
|
|
@@ -1234,9 +1399,7 @@ app.post('/api/sessions/:sessionId/agents/:agentId/stop', (req, res) => {
|
|
|
1234
1399
|
agent.stoppedAt = new Date().toISOString();
|
|
1235
1400
|
const stopEvt = { agentId, type: agent.type, event: 'user-stop', status: 'stopped', stoppedAt: agent.stoppedAt, updatedAt: agent.stoppedAt };
|
|
1236
1401
|
writeFileSync(agentFile, readFileSync(agentFile, 'utf8') + JSON.stringify(stopEvt) + '\n', 'utf8'); // sync — response depends on write
|
|
1237
|
-
|
|
1238
|
-
const waitingFile = path.join(AGENT_ACTIVITY_DIR, sessionId, '_waiting.json');
|
|
1239
|
-
if (existsSync(waitingFile)) unlinkSync(waitingFile);
|
|
1402
|
+
clearWaitingFile(sessionId);
|
|
1240
1403
|
res.json({ ok: true });
|
|
1241
1404
|
} catch (e) {
|
|
1242
1405
|
res.status(500).json({ error: 'Failed to stop agent' });
|
|
@@ -1928,13 +2091,27 @@ console.log(`Watching for team changes in: ${TEAMS_DIR}`);
|
|
|
1928
2091
|
const projectsWatcher = chokidar.watch(PROJECTS_DIR, {
|
|
1929
2092
|
persistent: true,
|
|
1930
2093
|
ignoreInitial: true,
|
|
1931
|
-
depth: 2
|
|
2094
|
+
depth: 2,
|
|
2095
|
+
awaitWriteFinish: { stabilityThreshold: 150, pollInterval: 50 }
|
|
1932
2096
|
});
|
|
1933
2097
|
|
|
1934
2098
|
projectsWatcher.on('all', (event, filePath) => {
|
|
1935
|
-
if (
|
|
1936
|
-
|
|
1937
|
-
|
|
2099
|
+
if (event !== 'add' && event !== 'change' && event !== 'unlink') return;
|
|
2100
|
+
if (filePath.endsWith('.jsonl')) {
|
|
2101
|
+
if (event === 'unlink') {
|
|
2102
|
+
loopInfoStateByPath.delete(filePath);
|
|
2103
|
+
} else {
|
|
2104
|
+
// Warm the incremental scan state so the next request does no IO.
|
|
2105
|
+
try { refreshLoopInfoState(filePath); } catch (_) {}
|
|
2106
|
+
}
|
|
2107
|
+
// add/unlink reshape the session set — promote to full rescan.
|
|
2108
|
+
if (event === 'change') dirtyMetadataPaths.add(filePath);
|
|
2109
|
+
else metadataNeedsFullScan = true;
|
|
2110
|
+
broadcast({ type: 'metadata-update' });
|
|
2111
|
+
} else if (path.basename(filePath) === 'sessions-index.json') {
|
|
2112
|
+
// Index holds description / created / customTitle that the targeted
|
|
2113
|
+
// refresh doesn't touch — promote to full rescan.
|
|
2114
|
+
metadataNeedsFullScan = true;
|
|
1938
2115
|
broadcast({ type: 'metadata-update' });
|
|
1939
2116
|
}
|
|
1940
2117
|
});
|
|
@@ -1947,7 +2124,9 @@ const plansWatcher = chokidar.watch(PLANS_DIR, {
|
|
|
1947
2124
|
|
|
1948
2125
|
plansWatcher.on('all', (event, filePath) => {
|
|
1949
2126
|
if ((event === 'add' || event === 'change' || event === 'unlink') && filePath.endsWith('.md')) {
|
|
1950
|
-
|
|
2127
|
+
// Plan files don't affect cached session metadata — getPlanInfo is called
|
|
2128
|
+
// fresh from buildSessionObject on every list build. The broadcast alone
|
|
2129
|
+
// is enough to trigger a client refetch.
|
|
1951
2130
|
broadcast({ type: 'metadata-update' });
|
|
1952
2131
|
if (event === 'change') {
|
|
1953
2132
|
const slug = path.basename(filePath, '.md');
|