claude-code-kanban 4.1.0 → 4.3.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 +201 -38
- package/public/index.html +19 -0
- package/public/style.css +158 -17
- 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');
|
|
@@ -2490,16 +2509,25 @@ function renderSessions() {
|
|
|
2490
2509
|
filteredSessions = filteredSessions.filter(matchesSearch);
|
|
2491
2510
|
|
|
2492
2511
|
// Re-add pinned/sticky sessions that match the query but were excluded by active filter
|
|
2493
|
-
if (
|
|
2512
|
+
if (
|
|
2513
|
+
sessionFilter === 'active' &&
|
|
2514
|
+
activityFilter.size === 0 &&
|
|
2515
|
+
(pinnedSessionIds.size > 0 || stickySessionIds.size > 0)
|
|
2516
|
+
) {
|
|
2494
2517
|
const filteredIds = new Set(filteredSessions.map((s) => s.id));
|
|
2495
2518
|
const missingPinned = sessions.filter((s) => isAnyPinned(s.id) && !filteredIds.has(s.id) && matchesSearch(s));
|
|
2496
2519
|
if (missingPinned.length) filteredSessions = [...missingPinned, ...filteredSessions];
|
|
2497
2520
|
}
|
|
2498
2521
|
}
|
|
2499
2522
|
|
|
2500
|
-
// Include pinned/sticky sessions even if they don't match active
|
|
2501
|
-
//
|
|
2502
|
-
if (
|
|
2523
|
+
// Include pinned/sticky sessions even if they don't match the active filter.
|
|
2524
|
+
// Only in the "active" view — the "all" view is a plain list with no pin prioritization.
|
|
2525
|
+
if (
|
|
2526
|
+
sessionFilter === 'active' &&
|
|
2527
|
+
activityFilter.size === 0 &&
|
|
2528
|
+
!searchQuery &&
|
|
2529
|
+
(pinnedSessionIds.size > 0 || stickySessionIds.size > 0)
|
|
2530
|
+
) {
|
|
2503
2531
|
const filteredIds = new Set(filteredSessions.map((s) => s.id));
|
|
2504
2532
|
const missingPinned = sessions.filter((s) => isAnyPinned(s.id) && !filteredIds.has(s.id));
|
|
2505
2533
|
if (missingPinned.length) filteredSessions = [...missingPinned, ...filteredSessions];
|
|
@@ -2580,6 +2608,7 @@ function renderSessions() {
|
|
|
2580
2608
|
${session.sharedTaskList ? `<span class="shared-tasklist-badge" title="Shared task list: ${escapeHtml(session.sharedTaskList)}">${linkSvg(12)}</span>` : ''}
|
|
2581
2609
|
${isTeam || session.project || showCtx ? `<span class="team-info-btn" onclick="event.stopPropagation(); showSessionInfoModal('${session.id}')" title="View session info">ℹ</span>` : ''}
|
|
2582
2610
|
${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>` : ''}
|
|
2611
|
+
${renderLoopBadge(session)}
|
|
2583
2612
|
${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
2613
|
${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
2614
|
${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>` : ''}
|
|
@@ -2717,31 +2746,7 @@ function renderSessions() {
|
|
|
2717
2746
|
|
|
2718
2747
|
sessionsList.innerHTML = html;
|
|
2719
2748
|
} else {
|
|
2720
|
-
|
|
2721
|
-
const idlePinned = filteredSessions.filter((s) => isPlacedPinned(s.id) && !isSessionActive(s));
|
|
2722
|
-
const rest = filteredSessions.filter(
|
|
2723
|
-
(s) => (!isPlacedPinned(s.id) && !isPlacedSticky(s.id)) || (isPlacedPinned(s.id) && isSessionActive(s)),
|
|
2724
|
-
);
|
|
2725
|
-
let html = '';
|
|
2726
|
-
if (sticky.length > 0) {
|
|
2727
|
-
html += sticky.map(renderSessionCard).join('');
|
|
2728
|
-
}
|
|
2729
|
-
const isCollapsed = collapsedProjectGroups.has('__pinned__');
|
|
2730
|
-
const hasPinned = pinnedSessionIds.size > 0 && filteredSessions.some((s) => pinnedSessionIds.has(s.id));
|
|
2731
|
-
if (idlePinned.length > 0 || (hasPinned && isCollapsed)) {
|
|
2732
|
-
html += `
|
|
2733
|
-
<div class="project-group-header${isCollapsed ? ' collapsed' : ''}" data-group-path="__pinned__">
|
|
2734
|
-
<svg class="group-chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>
|
|
2735
|
-
<span class="group-name">Pinned</span>
|
|
2736
|
-
<span class="group-count">${idlePinned.length}</span>
|
|
2737
|
-
</div>
|
|
2738
|
-
<div class="project-group-sessions${isCollapsed ? ' collapsed' : ''}">
|
|
2739
|
-
${idlePinned.map(renderSessionCard).join('')}
|
|
2740
|
-
</div>
|
|
2741
|
-
`;
|
|
2742
|
-
}
|
|
2743
|
-
html += rest.map(renderSessionCard).join('');
|
|
2744
|
-
sessionsList.innerHTML = html;
|
|
2749
|
+
sessionsList.innerHTML = filteredSessions.map(renderSessionCard).join('');
|
|
2745
2750
|
}
|
|
2746
2751
|
|
|
2747
2752
|
const navItems = getNavigableItems();
|
|
@@ -4251,7 +4256,7 @@ function matchKey(e, ...keys) {
|
|
|
4251
4256
|
return keys.some((k) => e.key === k || e.code === k);
|
|
4252
4257
|
}
|
|
4253
4258
|
|
|
4254
|
-
const MODAL_ESC_PRIORITY = ['preview-modal', 'msg-detail-modal', 'plan-modal'];
|
|
4259
|
+
const MODAL_ESC_PRIORITY = ['preview-modal', 'msg-detail-modal', 'plan-modal', 'loop-modal'];
|
|
4255
4260
|
const MODAL_CLOSERS = {
|
|
4256
4261
|
'preview-modal': () => closePreviewModal(),
|
|
4257
4262
|
'msg-detail-modal': () => {
|
|
@@ -4259,6 +4264,7 @@ const MODAL_CLOSERS = {
|
|
|
4259
4264
|
msgDetailFollowLatest = false;
|
|
4260
4265
|
},
|
|
4261
4266
|
'plan-modal': () => closePlanModal(),
|
|
4267
|
+
'loop-modal': () => closeLoopModal(),
|
|
4262
4268
|
'team-modal': () => closeTeamModal(),
|
|
4263
4269
|
'agent-modal': () => closeAgentModal(),
|
|
4264
4270
|
'help-modal': () => closeHelpModal(),
|
|
@@ -4293,7 +4299,13 @@ document.addEventListener('keydown', (e) => {
|
|
|
4293
4299
|
} else if (document.getElementById('msg-detail-modal').classList.contains('visible')) {
|
|
4294
4300
|
if (matchKey(e, 'ArrowDown', 'KeyJ')) {
|
|
4295
4301
|
e.preventDefault();
|
|
4296
|
-
if (currentMsgDetailIdx
|
|
4302
|
+
if (currentMsgDetailIdx === MSG_DETAIL_WAITING_IDX) {
|
|
4303
|
+
msgDetailFollowLatest = true;
|
|
4304
|
+
showWaitingDetail();
|
|
4305
|
+
} else if (currentMsgDetailIdx === currentMessages.length - 1 && isWaitingFresh()) {
|
|
4306
|
+
msgDetailFollowLatest = false;
|
|
4307
|
+
showWaitingDetail();
|
|
4308
|
+
} else if (currentMsgDetailIdx < currentMessages.length - 1) {
|
|
4297
4309
|
msgDetailFollowLatest = false;
|
|
4298
4310
|
showMsgDetail(currentMsgDetailIdx + 1);
|
|
4299
4311
|
} else if (currentMsgDetailIdx === currentMessages.length - 1) {
|
|
@@ -4302,7 +4314,12 @@ document.addEventListener('keydown', (e) => {
|
|
|
4302
4314
|
}
|
|
4303
4315
|
} else if (matchKey(e, 'ArrowUp', 'KeyK')) {
|
|
4304
4316
|
e.preventDefault();
|
|
4305
|
-
if (currentMsgDetailIdx
|
|
4317
|
+
if (currentMsgDetailIdx === MSG_DETAIL_WAITING_IDX) {
|
|
4318
|
+
if (currentMessages.length) {
|
|
4319
|
+
msgDetailFollowLatest = false;
|
|
4320
|
+
showMsgDetail(currentMessages.length - 1);
|
|
4321
|
+
}
|
|
4322
|
+
} else if (currentMsgDetailIdx > 0) {
|
|
4306
4323
|
msgDetailFollowLatest = false;
|
|
4307
4324
|
showMsgDetail(currentMsgDetailIdx - 1);
|
|
4308
4325
|
}
|
|
@@ -4327,6 +4344,9 @@ document.addEventListener('keydown', (e) => {
|
|
|
4327
4344
|
const msgDetailModal = document.getElementById('msg-detail-modal');
|
|
4328
4345
|
if (msgDetailModal.classList.contains('visible')) {
|
|
4329
4346
|
closeMsgDetailModal();
|
|
4347
|
+
} else if (isWaitingFresh()) {
|
|
4348
|
+
msgDetailFollowLatest = true;
|
|
4349
|
+
showWaitingDetail();
|
|
4330
4350
|
} else if (currentMessages.length) {
|
|
4331
4351
|
msgDetailFollowLatest = true;
|
|
4332
4352
|
showMsgDetail(currentMessages.length - 1);
|
|
@@ -5091,11 +5111,50 @@ function renderContextDetail(raw) {
|
|
|
5091
5111
|
|
|
5092
5112
|
//#region UTILS
|
|
5093
5113
|
function maybeFollowLatest() {
|
|
5094
|
-
if (msgDetailFollowLatest
|
|
5114
|
+
if (!msgDetailFollowLatest) return;
|
|
5115
|
+
if (isWaitingFresh()) {
|
|
5116
|
+
showWaitingDetail();
|
|
5117
|
+
} else if (currentMessages.length) {
|
|
5095
5118
|
showMsgDetail(currentMessages.length - 1);
|
|
5096
5119
|
}
|
|
5097
5120
|
}
|
|
5098
5121
|
|
|
5122
|
+
function isWaitingFresh() {
|
|
5123
|
+
if (!currentWaiting?.timestamp) return false;
|
|
5124
|
+
return Date.now() - new Date(currentWaiting.timestamp).getTime() < WAITING_TTL_MS;
|
|
5125
|
+
}
|
|
5126
|
+
|
|
5127
|
+
function showWaitingDetail() {
|
|
5128
|
+
if (!isWaitingFresh()) return;
|
|
5129
|
+
currentMsgDetailIdx = MSG_DETAIL_WAITING_IDX;
|
|
5130
|
+
const tool = currentWaiting.toolName || 'unknown';
|
|
5131
|
+
const label = getWaitingLabel(currentWaiting.kind, tool);
|
|
5132
|
+
const body = document.getElementById('msg-detail-body');
|
|
5133
|
+
let inputHtml = '';
|
|
5134
|
+
if (currentWaiting.toolInput) {
|
|
5135
|
+
let pretty = currentWaiting.toolInput;
|
|
5136
|
+
try {
|
|
5137
|
+
pretty = JSON.stringify(JSON.parse(currentWaiting.toolInput), null, 2);
|
|
5138
|
+
} catch (_) {
|
|
5139
|
+
/* keep raw */
|
|
5140
|
+
}
|
|
5141
|
+
inputHtml = `<pre class="${TINTED_PRE_CLASS}">${escapeHtml(pretty)}</pre>`;
|
|
5142
|
+
}
|
|
5143
|
+
body.innerHTML = inputHtml;
|
|
5144
|
+
document.getElementById('msg-detail-title').textContent = label;
|
|
5145
|
+
document.getElementById('msg-detail-agent-btn').style.display = 'none';
|
|
5146
|
+
const modal = document.getElementById('msg-detail-modal').querySelector('.modal');
|
|
5147
|
+
autoSizeModal(modal, body);
|
|
5148
|
+
modal.classList.toggle('live', msgDetailFollowLatest);
|
|
5149
|
+
const overlay = document.getElementById('msg-detail-modal');
|
|
5150
|
+
overlay.classList.toggle('live-overlay', msgDetailFollowLatest);
|
|
5151
|
+
const meta = [formatDate(currentWaiting.timestamp), 'waiting'];
|
|
5152
|
+
document.getElementById('msg-detail-meta').textContent = meta.join(' · ');
|
|
5153
|
+
currentPinDetailId = null;
|
|
5154
|
+
updateMsgDetailPinState();
|
|
5155
|
+
overlay.classList.add('visible');
|
|
5156
|
+
}
|
|
5157
|
+
|
|
5099
5158
|
function isSessionActive(s) {
|
|
5100
5159
|
return s.hasRecentLog || s.inProgress > 0 || s.hasActiveAgents || s.hasWaitingForUser;
|
|
5101
5160
|
}
|
|
@@ -5884,6 +5943,110 @@ function refreshOpenPlan() {
|
|
|
5884
5943
|
.catch(() => {});
|
|
5885
5944
|
}
|
|
5886
5945
|
|
|
5946
|
+
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
5947
|
+
function showLoopModal(sessionId) {
|
|
5948
|
+
const body = document.getElementById('loop-modal-body');
|
|
5949
|
+
body.innerHTML = '<div style="padding:16px;color:var(--text-secondary);">Loading…</div>';
|
|
5950
|
+
document.getElementById('loop-modal').classList.add('visible');
|
|
5951
|
+
fetch(`/api/sessions/${sessionId}/loop`)
|
|
5952
|
+
.then((r) => (r.ok ? r.json() : { wakeups: [], crons: [] }))
|
|
5953
|
+
.catch(() => ({ wakeups: [], crons: [] }))
|
|
5954
|
+
.then((data) => {
|
|
5955
|
+
renderLoopModalBody(data);
|
|
5956
|
+
});
|
|
5957
|
+
}
|
|
5958
|
+
|
|
5959
|
+
function fmtLoopDelay(s) {
|
|
5960
|
+
if (s == null) return '';
|
|
5961
|
+
if (s < 60) return `${s}s`;
|
|
5962
|
+
if (s < 3600) return `${Math.round(s / 60)}m`;
|
|
5963
|
+
return `${(s / 3600).toFixed(1)}h`;
|
|
5964
|
+
}
|
|
5965
|
+
|
|
5966
|
+
function fmtLoopFireTime(timestamp, delaySeconds) {
|
|
5967
|
+
if (!timestamp || delaySeconds == null) return { abs: '', rel: '', status: '' };
|
|
5968
|
+
const fireMs = new Date(timestamp).getTime() + delaySeconds * 1000;
|
|
5969
|
+
const fireDate = new Date(fireMs);
|
|
5970
|
+
const diff = fireMs - Date.now();
|
|
5971
|
+
const abs = fireDate.toLocaleString(undefined, {
|
|
5972
|
+
month: 'short',
|
|
5973
|
+
day: 'numeric',
|
|
5974
|
+
hour: '2-digit',
|
|
5975
|
+
minute: '2-digit',
|
|
5976
|
+
});
|
|
5977
|
+
const absSec = Math.abs(Math.round(diff / 1000));
|
|
5978
|
+
const rel =
|
|
5979
|
+
absSec < 60 ? `${absSec}s` : absSec < 3600 ? `${Math.round(absSec / 60)}m` : `${(absSec / 3600).toFixed(1)}h`;
|
|
5980
|
+
if (diff > 0) return { abs, rel: `in ${rel}`, status: 'pending' };
|
|
5981
|
+
return { abs, rel: `${rel} ago`, status: 'fired' };
|
|
5982
|
+
}
|
|
5983
|
+
|
|
5984
|
+
const LOOP_CLOCK_SVG =
|
|
5985
|
+
'<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>';
|
|
5986
|
+
const LOOP_CRON_SVG =
|
|
5987
|
+
'<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>';
|
|
5988
|
+
|
|
5989
|
+
function loopField(label, value, mono = false) {
|
|
5990
|
+
if (!value) return '';
|
|
5991
|
+
const inner = mono ? `<code>${escapeHtml(value)}</code>` : escapeHtml(value);
|
|
5992
|
+
return `<div class="loop-field"><div class="loop-field-label">${label}</div><div class="loop-field-val">${inner}</div></div>`;
|
|
5993
|
+
}
|
|
5994
|
+
|
|
5995
|
+
function renderLoopRow(item, kind) {
|
|
5996
|
+
const when = item.timestamp ? formatDate(item.timestamp) : '';
|
|
5997
|
+
let headline = '';
|
|
5998
|
+
let footer = '';
|
|
5999
|
+
let fields = '';
|
|
6000
|
+
if (kind === 'wakeup') {
|
|
6001
|
+
const fire = fmtLoopFireTime(item.timestamp, item.delaySeconds);
|
|
6002
|
+
const delayLbl = item.delaySeconds != null ? `delay ${fmtLoopDelay(item.delaySeconds)}` : '';
|
|
6003
|
+
if (fire.abs) {
|
|
6004
|
+
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>`;
|
|
6005
|
+
}
|
|
6006
|
+
fields = loopField('Reason', item.reason) + loopField('Prompt', item.prompt, true);
|
|
6007
|
+
footer = `<div class="loop-foot">scheduled ${escapeHtml(when)}${delayLbl ? ` · ${delayLbl}` : ''}</div>`;
|
|
6008
|
+
} else {
|
|
6009
|
+
if (item.cron) {
|
|
6010
|
+
headline = `<div class="loop-headline">${LOOP_CRON_SVG}<span class="loop-headline-rel"><code>${escapeHtml(item.cron)}</code></span></div>`;
|
|
6011
|
+
}
|
|
6012
|
+
fields = loopField('Description', item.description) + loopField('Prompt', item.prompt, true);
|
|
6013
|
+
footer = `<div class="loop-foot">created ${escapeHtml(when)}</div>`;
|
|
6014
|
+
}
|
|
6015
|
+
return `<div class="loop-row">${headline}${fields}${footer}</div>`;
|
|
6016
|
+
}
|
|
6017
|
+
|
|
6018
|
+
function renderLoopModalBody(data) {
|
|
6019
|
+
const body = document.getElementById('loop-modal-body');
|
|
6020
|
+
const wakeups = data.wakeups || [];
|
|
6021
|
+
const crons = data.crons || [];
|
|
6022
|
+
if (!wakeups.length && !crons.length) {
|
|
6023
|
+
body.innerHTML =
|
|
6024
|
+
'<div style="padding:24px;text-align:center;color:var(--text-secondary);">No scheduled wakeups or cron jobs.</div>';
|
|
6025
|
+
return;
|
|
6026
|
+
}
|
|
6027
|
+
const section = (title, items, kind) =>
|
|
6028
|
+
items.length
|
|
6029
|
+
? `<h4 class="loop-section-title">${title} <span class="loop-count">${items.length}</span></h4>${items.map((i) => renderLoopRow(i, kind)).join('')}`
|
|
6030
|
+
: '';
|
|
6031
|
+
body.innerHTML = section('Wakeups', wakeups, 'wakeup') + section('Cron jobs', crons, 'cron');
|
|
6032
|
+
}
|
|
6033
|
+
|
|
6034
|
+
function renderLoopBadge(session) {
|
|
6035
|
+
const li = session.loopInfo;
|
|
6036
|
+
const total = (li?.wakeupCount || 0) + (li?.cronCount || 0);
|
|
6037
|
+
if (total === 0) return '';
|
|
6038
|
+
let tip = `${li.wakeupCount} wakeup${li.wakeupCount === 1 ? '' : 's'}, ${li.cronCount} cron${li.cronCount === 1 ? '' : 's'}`;
|
|
6039
|
+
if (li.latest?.timestamp && li.latest.delaySeconds != null) {
|
|
6040
|
+
const f = fmtLoopFireTime(li.latest.timestamp, li.latest.delaySeconds);
|
|
6041
|
+
if (f.abs) tip += ` — latest ${f.status === 'pending' ? 'fires' : 'fired'} ${f.rel} (${f.abs})`;
|
|
6042
|
+
}
|
|
6043
|
+
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>`;
|
|
6044
|
+
}
|
|
6045
|
+
|
|
6046
|
+
function closeLoopModal() {
|
|
6047
|
+
document.getElementById('loop-modal').classList.remove('visible');
|
|
6048
|
+
}
|
|
6049
|
+
|
|
5887
6050
|
function openPlanForSession(sid) {
|
|
5888
6051
|
fetch(`/api/sessions/${sid}/plan`)
|
|
5889
6052
|
.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
|
@@ -153,14 +153,13 @@ body::before {
|
|
|
153
153
|
}
|
|
154
154
|
|
|
155
155
|
.activity-chip.activity-filter-on {
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
156
|
+
--chip-color: var(--accent);
|
|
157
|
+
background: color-mix(in srgb, var(--chip-color) 9%, var(--bg-deep));
|
|
158
|
+
border-color: color-mix(in srgb, var(--chip-color) 50%, var(--border));
|
|
159
|
+
color: color-mix(in srgb, var(--chip-color) 80%, var(--text-secondary));
|
|
159
160
|
}
|
|
160
161
|
.activity-chip.activity-waiting.activity-filter-on {
|
|
161
|
-
|
|
162
|
-
box-shadow: inset 0 0 0 1px var(--warning);
|
|
163
|
-
background: color-mix(in srgb, var(--warning) 14%, var(--bg-deep));
|
|
162
|
+
--chip-color: var(--warning);
|
|
164
163
|
}
|
|
165
164
|
|
|
166
165
|
.activity-dot {
|
|
@@ -188,22 +187,12 @@ body::before {
|
|
|
188
187
|
opacity: 1;
|
|
189
188
|
}
|
|
190
189
|
|
|
191
|
-
.activity-chip.activity-waiting {
|
|
192
|
-
color: var(--warning);
|
|
193
|
-
border-color: color-mix(in srgb, var(--warning) 40%, var(--border));
|
|
194
|
-
}
|
|
195
190
|
.activity-chip.activity-waiting .activity-dot {
|
|
196
191
|
background: var(--warning);
|
|
197
|
-
box-shadow: 0 0 8px color-mix(in srgb, var(--warning) 60%, transparent);
|
|
198
192
|
}
|
|
199
193
|
|
|
200
|
-
.activity-chip.activity-active {
|
|
201
|
-
color: color-mix(in srgb, var(--accent) 70%, var(--text-secondary));
|
|
202
|
-
border-color: color-mix(in srgb, var(--accent) 18%, var(--border));
|
|
203
|
-
}
|
|
204
194
|
.activity-chip.activity-active .activity-dot {
|
|
205
|
-
background: color-mix(in srgb, var(--accent)
|
|
206
|
-
box-shadow: 0 0 4px color-mix(in srgb, var(--accent) 30%, transparent);
|
|
195
|
+
background: color-mix(in srgb, var(--accent) 60%, var(--text-muted));
|
|
207
196
|
animation: pulse 2.5s ease-in-out infinite;
|
|
208
197
|
}
|
|
209
198
|
|
|
@@ -1615,6 +1604,136 @@ body::before {
|
|
|
1615
1604
|
border-color: var(--plan);
|
|
1616
1605
|
}
|
|
1617
1606
|
|
|
1607
|
+
.loop-badge {
|
|
1608
|
+
width: 24px;
|
|
1609
|
+
height: 24px;
|
|
1610
|
+
display: inline-flex;
|
|
1611
|
+
align-items: center;
|
|
1612
|
+
justify-content: center;
|
|
1613
|
+
background: var(--bg-deep);
|
|
1614
|
+
border: 1px solid transparent;
|
|
1615
|
+
border-radius: 4px;
|
|
1616
|
+
color: var(--text-secondary);
|
|
1617
|
+
cursor: pointer;
|
|
1618
|
+
flex-shrink: 0;
|
|
1619
|
+
transition: all 0.15s ease;
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
.loop-badge:hover {
|
|
1623
|
+
border-color: var(--accent, var(--plan));
|
|
1624
|
+
color: var(--accent, var(--plan));
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
.loop-section-title {
|
|
1628
|
+
margin: 14px 0 8px;
|
|
1629
|
+
font-size: 12px;
|
|
1630
|
+
font-weight: 600;
|
|
1631
|
+
text-transform: uppercase;
|
|
1632
|
+
letter-spacing: 0.04em;
|
|
1633
|
+
color: var(--text-secondary);
|
|
1634
|
+
display: flex;
|
|
1635
|
+
align-items: center;
|
|
1636
|
+
gap: 8px;
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1639
|
+
.loop-section-title:first-child {
|
|
1640
|
+
margin-top: 4px;
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1643
|
+
.loop-count {
|
|
1644
|
+
font-size: 11px;
|
|
1645
|
+
font-weight: 500;
|
|
1646
|
+
padding: 1px 6px;
|
|
1647
|
+
border-radius: 10px;
|
|
1648
|
+
background: var(--bg-elevated);
|
|
1649
|
+
color: var(--text-secondary);
|
|
1650
|
+
text-transform: none;
|
|
1651
|
+
letter-spacing: 0;
|
|
1652
|
+
}
|
|
1653
|
+
|
|
1654
|
+
.loop-row {
|
|
1655
|
+
padding: 12px 14px;
|
|
1656
|
+
border: 1px solid var(--border);
|
|
1657
|
+
border-left: 3px solid var(--border);
|
|
1658
|
+
border-radius: 6px;
|
|
1659
|
+
background: var(--bg-elevated);
|
|
1660
|
+
margin-bottom: 10px;
|
|
1661
|
+
display: flex;
|
|
1662
|
+
flex-direction: column;
|
|
1663
|
+
gap: 10px;
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
.loop-row:has(.loop-fire-pending) {
|
|
1667
|
+
border-left-color: var(--accent, var(--plan));
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1670
|
+
.loop-headline {
|
|
1671
|
+
display: flex;
|
|
1672
|
+
align-items: center;
|
|
1673
|
+
gap: 10px;
|
|
1674
|
+
flex-wrap: wrap;
|
|
1675
|
+
font-variant-numeric: tabular-nums;
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
.loop-headline-rel {
|
|
1679
|
+
font-size: 15px;
|
|
1680
|
+
font-weight: 600;
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
.loop-headline-abs {
|
|
1684
|
+
font-size: 12px;
|
|
1685
|
+
color: var(--text-secondary);
|
|
1686
|
+
margin-left: auto;
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
.loop-fire-pending {
|
|
1690
|
+
color: var(--accent, var(--plan));
|
|
1691
|
+
}
|
|
1692
|
+
.loop-fire-fired {
|
|
1693
|
+
color: var(--text-secondary);
|
|
1694
|
+
}
|
|
1695
|
+
.loop-fire-fired .loop-headline-rel {
|
|
1696
|
+
font-weight: 500;
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1699
|
+
.loop-field {
|
|
1700
|
+
display: grid;
|
|
1701
|
+
grid-template-columns: 80px 1fr;
|
|
1702
|
+
gap: 10px;
|
|
1703
|
+
align-items: start;
|
|
1704
|
+
font-size: 13px;
|
|
1705
|
+
line-height: 1.45;
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1708
|
+
.loop-field-label {
|
|
1709
|
+
font-size: 10px;
|
|
1710
|
+
text-transform: uppercase;
|
|
1711
|
+
letter-spacing: 0.06em;
|
|
1712
|
+
color: var(--text-secondary);
|
|
1713
|
+
padding-top: 3px;
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1716
|
+
.loop-field-val {
|
|
1717
|
+
color: var(--text-primary);
|
|
1718
|
+
word-break: break-word;
|
|
1719
|
+
}
|
|
1720
|
+
|
|
1721
|
+
.loop-field-val code {
|
|
1722
|
+
background: var(--bg);
|
|
1723
|
+
padding: 2px 6px;
|
|
1724
|
+
border-radius: 4px;
|
|
1725
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
1726
|
+
font-size: 12px;
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1729
|
+
.loop-foot {
|
|
1730
|
+
font-size: 11px;
|
|
1731
|
+
color: var(--text-secondary);
|
|
1732
|
+
padding-top: 6px;
|
|
1733
|
+
border-top: 1px dashed var(--border);
|
|
1734
|
+
font-variant-numeric: tabular-nums;
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1618
1737
|
/* #endregion */
|
|
1619
1738
|
|
|
1620
1739
|
/* #region OWNER_BADGE */
|
|
@@ -2358,6 +2477,28 @@ body::before {
|
|
|
2358
2477
|
border-left: 3px solid var(--warning, #f5a623);
|
|
2359
2478
|
background: color-mix(in srgb, var(--warning, #f5a623) 8%, transparent);
|
|
2360
2479
|
animation: msg-waiting-pulse 2s ease-in-out infinite;
|
|
2480
|
+
cursor: pointer;
|
|
2481
|
+
}
|
|
2482
|
+
.msg-waiting-discard {
|
|
2483
|
+
flex-shrink: 0;
|
|
2484
|
+
background: none;
|
|
2485
|
+
border: none;
|
|
2486
|
+
color: var(--text-muted);
|
|
2487
|
+
cursor: pointer;
|
|
2488
|
+
padding: 2px;
|
|
2489
|
+
opacity: 0;
|
|
2490
|
+
transition: opacity 0.15s;
|
|
2491
|
+
margin-left: auto;
|
|
2492
|
+
align-self: flex-start;
|
|
2493
|
+
line-height: 0;
|
|
2494
|
+
}
|
|
2495
|
+
.msg-item.msg-waiting:hover .msg-waiting-discard {
|
|
2496
|
+
opacity: 0.6;
|
|
2497
|
+
}
|
|
2498
|
+
.msg-waiting-discard:hover {
|
|
2499
|
+
/* biome-ignore lint/complexity/noImportantStyles: override parent hover opacity */
|
|
2500
|
+
opacity: 1 !important;
|
|
2501
|
+
color: var(--danger, #e54d4d);
|
|
2361
2502
|
}
|
|
2362
2503
|
.msg-waiting .msg-text {
|
|
2363
2504
|
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');
|