claude-code-kanban 3.10.0 → 4.1.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/public/style.css CHANGED
@@ -724,6 +724,12 @@ body::before {
724
724
  font-weight: 600;
725
725
  }
726
726
 
727
+ .sidebar-footer .footer-limits .footer-limit-reset {
728
+ color: var(--text-tertiary);
729
+ font-weight: 400;
730
+ opacity: 0.75;
731
+ }
732
+
727
733
  .sidebar-footer a:hover {
728
734
  color: var(--text-secondary);
729
735
  }
@@ -1743,6 +1749,55 @@ body::before {
1743
1749
  border-top: 1px solid var(--border);
1744
1750
  }
1745
1751
 
1752
+ .info-grid {
1753
+ display: grid;
1754
+ grid-template-columns: auto 1fr auto;
1755
+ gap: 6px 12px;
1756
+ align-items: center;
1757
+ margin-bottom: 16px;
1758
+ }
1759
+ .info-row-actions {
1760
+ display: flex;
1761
+ gap: 4px;
1762
+ opacity: 0.3;
1763
+ transition: opacity 0.15s ease;
1764
+ }
1765
+ .info-grid:hover .info-row-actions {
1766
+ opacity: 1;
1767
+ }
1768
+ .info-row-actions button {
1769
+ display: inline-flex;
1770
+ align-items: center;
1771
+ justify-content: center;
1772
+ width: 22px;
1773
+ height: 22px;
1774
+ padding: 0;
1775
+ background: var(--bg-elevated);
1776
+ border: 1px solid var(--border);
1777
+ border-radius: 4px;
1778
+ color: var(--text-secondary);
1779
+ cursor: pointer;
1780
+ transition:
1781
+ background 0.1s ease,
1782
+ border-color 0.1s ease,
1783
+ color 0.1s ease;
1784
+ }
1785
+ .info-row-actions button:hover {
1786
+ background: var(--bg-hover);
1787
+ border-color: var(--accent);
1788
+ color: var(--accent-text);
1789
+ }
1790
+ .info-row-actions button svg {
1791
+ width: 12px;
1792
+ height: 12px;
1793
+ display: block;
1794
+ }
1795
+ @media (hover: none) {
1796
+ .info-row-actions {
1797
+ opacity: 1;
1798
+ }
1799
+ }
1800
+
1746
1801
  /* #endregion */
1747
1802
 
1748
1803
  /* #region OWNER_FILTER */
@@ -2107,6 +2162,11 @@ body::before {
2107
2162
  opacity: 1;
2108
2163
  color: var(--text-primary);
2109
2164
  }
2165
+ .agent-tab-copy svg {
2166
+ width: 14px;
2167
+ height: 14px;
2168
+ display: block;
2169
+ }
2110
2170
  .toast {
2111
2171
  position: fixed;
2112
2172
  bottom: 16px;
@@ -2222,6 +2282,59 @@ body::before {
2222
2282
  border-radius: 3px;
2223
2283
  font-size: 11px;
2224
2284
  }
2285
+ .user-attach-chips {
2286
+ display: flex;
2287
+ gap: 4px;
2288
+ flex-wrap: wrap;
2289
+ margin-top: 4px;
2290
+ }
2291
+ .user-attach-chip {
2292
+ font-size: 10px;
2293
+ padding: 1px 6px;
2294
+ border-radius: 10px;
2295
+ background: var(--bg-hover);
2296
+ color: var(--text-secondary);
2297
+ }
2298
+ .msg-text-muted {
2299
+ color: var(--text-secondary);
2300
+ font-size: 12px;
2301
+ }
2302
+ .user-attach-section {
2303
+ margin-top: 12px;
2304
+ padding-top: 10px;
2305
+ border-top: 1px solid var(--border);
2306
+ }
2307
+ .user-attach-label {
2308
+ font-size: 11px;
2309
+ color: var(--text-secondary);
2310
+ margin-bottom: 6px;
2311
+ text-transform: uppercase;
2312
+ letter-spacing: 0.5px;
2313
+ }
2314
+ .user-attach-images {
2315
+ display: flex;
2316
+ flex-wrap: wrap;
2317
+ gap: 8px;
2318
+ }
2319
+ .user-attach-image {
2320
+ max-width: 100%;
2321
+ max-height: 480px;
2322
+ border-radius: 4px;
2323
+ border: 1px solid var(--border);
2324
+ background: var(--bg-hover);
2325
+ }
2326
+ .user-attach-toolresult {
2327
+ margin-top: 6px;
2328
+ }
2329
+ .user-attach-toolresult > summary {
2330
+ cursor: pointer;
2331
+ font-size: 12px;
2332
+ color: var(--text-secondary);
2333
+ padding: 4px 0;
2334
+ }
2335
+ .user-attach-toolresult > summary code {
2336
+ font-size: 10px;
2337
+ }
2225
2338
  .msg-item.msg-system {
2226
2339
  border-left: 3px solid var(--border);
2227
2340
  }
@@ -2241,6 +2354,30 @@ body::before {
2241
2354
  border-left: 3px solid var(--border);
2242
2355
  opacity: 0.75;
2243
2356
  }
2357
+ .msg-item.msg-waiting {
2358
+ border-left: 3px solid var(--warning, #f5a623);
2359
+ background: color-mix(in srgb, var(--warning, #f5a623) 8%, transparent);
2360
+ animation: msg-waiting-pulse 2s ease-in-out infinite;
2361
+ }
2362
+ .msg-waiting .msg-text {
2363
+ font-weight: 600;
2364
+ }
2365
+ .msg-waiting-preview {
2366
+ font-size: 11px;
2367
+ opacity: 0.85;
2368
+ margin-top: 2px;
2369
+ white-space: pre-wrap;
2370
+ word-break: break-word;
2371
+ }
2372
+ @keyframes msg-waiting-pulse {
2373
+ 0%,
2374
+ 100% {
2375
+ background: color-mix(in srgb, var(--warning, #f5a623) 8%, transparent);
2376
+ }
2377
+ 50% {
2378
+ background: color-mix(in srgb, var(--warning, #f5a623) 16%, transparent);
2379
+ }
2380
+ }
2244
2381
  .msg-item.msg-idle .msg-icon {
2245
2382
  width: 12px;
2246
2383
  height: 12px;
@@ -2531,12 +2668,31 @@ body::before {
2531
2668
  border: 1px solid var(--border);
2532
2669
  }
2533
2670
  .agent-badge {
2534
- font-size: 12px;
2671
+ display: inline-flex;
2672
+ align-items: center;
2673
+ justify-content: center;
2535
2674
  cursor: default;
2675
+ line-height: 1;
2676
+ flex-shrink: 0;
2677
+ color: var(--text-secondary);
2678
+ }
2679
+ .agent-badge-waiting {
2680
+ color: var(--warning);
2681
+ animation: agent-badge-pulse 1.6s ease-in-out infinite;
2682
+ }
2683
+ @keyframes agent-badge-pulse {
2684
+ 0%,
2685
+ 100% {
2686
+ opacity: 0.65;
2687
+ }
2688
+ 50% {
2689
+ opacity: 1;
2690
+ }
2536
2691
  }
2537
2692
 
2538
2693
  .linked-docs-badge,
2539
- .bookmarks-badge {
2694
+ .bookmarks-badge,
2695
+ .scratchpad-badge {
2540
2696
  display: inline-flex;
2541
2697
  align-items: center;
2542
2698
  gap: 2px;
@@ -2552,7 +2708,8 @@ body::before {
2552
2708
  }
2553
2709
 
2554
2710
  .linked-docs-badge:hover,
2555
- .bookmarks-badge:hover {
2711
+ .bookmarks-badge:hover,
2712
+ .scratchpad-badge:hover {
2556
2713
  border-color: var(--accent);
2557
2714
  color: var(--text-primary);
2558
2715
  }
@@ -3500,22 +3657,6 @@ pre.mermaid svg {
3500
3657
  color: var(--accent);
3501
3658
  }
3502
3659
 
3503
- .marketplace-btn {
3504
- color: #888;
3505
- cursor: pointer;
3506
- display: inline-flex;
3507
- align-items: center;
3508
- transition:
3509
- color 0.15s,
3510
- filter 0.15s;
3511
- border-radius: 3px;
3512
- }
3513
-
3514
- .marketplace-btn:hover {
3515
- color: var(--accent);
3516
- filter: drop-shadow(0 0 3px var(--accent));
3517
- }
3518
-
3519
3660
  .project-group-header .group-count {
3520
3661
  font-weight: 400;
3521
3662
  color: var(--text-muted);
package/server.js CHANGED
@@ -20,7 +20,8 @@ const {
20
20
  findTerminatedTeammates,
21
21
  extractPromptFromTranscript,
22
22
  extractModelFromTranscript,
23
- readFullToolResult
23
+ readFullToolResult,
24
+ readUserImage
24
25
  } = require('./lib/parsers');
25
26
 
26
27
  if (process.argv.includes("--install") || process.argv.includes("--uninstall")) {
@@ -95,19 +96,30 @@ function writePins(pins) {
95
96
  }
96
97
  }
97
98
 
98
- const PERMISSION_TTL_MS = 1800000;
99
- const AGENT_TTL_MS = 3600000;
100
- const AGENT_STALE_MS = 900000;
101
- const SESSION_STALE_MS = 300000;
102
-
103
- const WAITING_RESOLVE_GRACE_MS = 15000;
99
+ // #region TIMINGS
100
+ const PERMISSION_TTL_MS = 30 * 60 * 1000;
101
+ const AGENT_TTL_MS = 60 * 60 * 1000;
102
+ const AGENT_STALE_MS = 30 * 60 * 1000; // safety net for crashed sessions
103
+ const SESSION_STALE_MS = 5 * 60 * 1000;
104
+ const WAITING_RESOLVE_GRACE_MS = 15 * 1000;
105
+ const CTX_CLEANUP_MAX_AGE_MS = 2 * 60 * 60 * 1000;
106
+ const CLEANUP_MAX_AGE_MS = 2 * 24 * 60 * 60 * 1000;
107
+ const CLEANUP_INTERVAL_MS = 60 * 60 * 1000;
108
+ // #endregion
109
+
110
+ function readAgentJsonl(filePath) {
111
+ const raw = readFileSync(filePath, 'utf8');
112
+ const merged = {};
113
+ for (const line of raw.split(/\r?\n/)) {
114
+ if (!line.trim()) continue;
115
+ try { Object.assign(merged, JSON.parse(line)); } catch (_) { /* skip malformed */ }
116
+ }
117
+ return merged;
118
+ }
104
119
 
105
120
  function persistAgent(dir, agent) {
106
- const file = path.join(dir, agent.agentId + '.json');
107
- const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
108
- fs.writeFile(tmp, JSON.stringify(agent), 'utf8')
109
- .then(() => fs.rename(tmp, file))
110
- .catch(() => { fs.unlink(tmp).catch(() => {}); });
121
+ const file = path.join(dir, agent.agentId + '.jsonl');
122
+ fs.appendFile(file, JSON.stringify({ ...agent, event: 'server-update' }) + '\n', 'utf8').catch(() => {});
111
123
  }
112
124
 
113
125
  function checkWaitingForUser(agentDir, logMtime) {
@@ -125,6 +137,10 @@ function checkWaitingForUser(agentDir, logMtime) {
125
137
  return null;
126
138
  }
127
139
 
140
+ function agentDisplayName(agent) {
141
+ return agent.type || agent.name;
142
+ }
143
+
128
144
  function isGhostAgent(agent) {
129
145
  if (agent.startedAt !== agent.updatedAt || agent.lastMessage) return false;
130
146
  return (Date.now() - new Date(agent.startedAt).getTime()) >= AGENT_STALE_MS;
@@ -156,9 +172,9 @@ function checkAgentStatus(agentDir, stale, logMtime, isTeam) {
156
172
  if (result.waitingForUser) result.hasActive = true;
157
173
  if (stale && !isTeam) return result;
158
174
  try {
159
- for (const file of readdirSync(agentDir).filter(f => f.endsWith('.json') && !f.startsWith('_'))) {
175
+ for (const file of readdirSync(agentDir).filter(f => f.endsWith('.jsonl') && !f.startsWith('_'))) {
160
176
  try {
161
- const agent = JSON.parse(readFileSync(path.join(agentDir, file), 'utf8'));
177
+ const agent = readAgentJsonl(path.join(agentDir, file));
162
178
  if (isTeam && (agent.status === 'active' || agent.status === 'idle')) {
163
179
  result.hasActive = true;
164
180
  if (agent.status === 'active') result.hasRunning = true;
@@ -1050,17 +1066,17 @@ app.get('/api/sessions/:sessionId/agents', (req, res) => {
1050
1066
  const isTeam = !!teamConfig;
1051
1067
  const teamMemberNames = isTeam ? new Set(teamConfig.members.map(m => m.name)) : null;
1052
1068
 
1053
- const files = readdirSync(agentDir).filter(f => f.endsWith('.json') && !f.startsWith('_'));
1069
+ const files = readdirSync(agentDir).filter(f => f.endsWith('.jsonl') && !f.startsWith('_'));
1054
1070
  const agents = [];
1055
1071
  for (const file of files) {
1056
1072
  try {
1057
- const agent = JSON.parse(readFileSync(path.join(agentDir, file), 'utf8'));
1073
+ const agent = readAgentJsonl(path.join(agentDir, file));
1058
1074
  if (isGhostAgent(agent)) continue;
1059
1075
  const agentTs = agent.updatedAt || agent.startedAt;
1060
1076
  const agentStale = !sessionStale && agentTs && (Date.now() - new Date(agentTs).getTime()) > AGENT_STALE_MS;
1061
1077
  if (!isAgentFresh(agent) || sessionStale || agentStale) {
1062
1078
  if (agent.status === 'active' || agent.status === 'idle') {
1063
- const agentName = agent.type || agent.name;
1079
+ const agentName = agentDisplayName(agent);
1064
1080
  const isTeamMember = isTeam && agentName && teamMemberNames.has(agentName);
1065
1081
  if (!isTeamMember) {
1066
1082
  agent.status = 'stopped';
@@ -1077,7 +1093,7 @@ app.get('/api/sessions/:sessionId/agents', (req, res) => {
1077
1093
  const terminated = getTerminatedTeammates(meta.jsonlPath);
1078
1094
  if (terminated.size) {
1079
1095
  for (const agent of liveAgents) {
1080
- const agentName = agent.type || agent.name;
1096
+ const agentName = agentDisplayName(agent);
1081
1097
  if (agentName && terminated.has(agentName)) {
1082
1098
  const terminatedAt = terminated.get(agentName);
1083
1099
  if (terminatedAt && agent.startedAt && terminatedAt < agent.startedAt) continue;
@@ -1168,14 +1184,40 @@ app.get('/api/sessions/:sessionId/agents', (req, res) => {
1168
1184
  }
1169
1185
  if (Object.keys(teamColors).length) {
1170
1186
  for (const agent of agents) {
1171
- const name = agent.type || agent.name;
1187
+ const name = agentDisplayName(agent);
1172
1188
  if (name && teamColors[name]) agent.color = teamColors[name];
1173
1189
  }
1174
1190
  }
1175
1191
  }
1176
1192
 
1193
+ // Collapse teammate re-spawns: when a teammate goes idle and is later re-engaged,
1194
+ // a fresh agentId is spawned. Hide older idle/stopped entries when a newer same-name
1195
+ // teammate exists; never hide an `active` agent (parallel teammate work would vanish).
1196
+ // Subagents (Explore, general-purpose, etc.) are not in teamMemberNames and bypass
1197
+ // dedup entirely, so parallel siblings of the same subagent type remain visible.
1198
+ let visibleAgents = agents;
1199
+ if (teamMemberNames && teamMemberNames.size) {
1200
+ const groups = new Map();
1201
+ for (const a of agents) {
1202
+ const t = agentDisplayName(a);
1203
+ if (!t || !teamMemberNames.has(t)) continue;
1204
+ const list = groups.get(t) || [];
1205
+ list.push(a);
1206
+ groups.set(t, list);
1207
+ }
1208
+ const hidden = new Set();
1209
+ for (const list of groups.values()) {
1210
+ if (list.length < 2) continue;
1211
+ list.sort((a, b) => new Date(b.startedAt || 0) - new Date(a.startedAt || 0));
1212
+ for (const older of list.slice(1)) {
1213
+ if (older.status === 'idle' || older.status === 'stopped') hidden.add(older.agentId);
1214
+ }
1215
+ }
1216
+ if (hidden.size) visibleAgents = agents.filter(a => !hidden.has(a.agentId));
1217
+ }
1218
+
1177
1219
  const waitingForUser = checkWaitingForUser(agentDir, logMtime);
1178
- res.json({ agents, waitingForUser, teamColors });
1220
+ res.json({ agents: visibleAgents, waitingForUser, teamColors });
1179
1221
  } catch (e) {
1180
1222
  res.json({ agents: [], waitingForUser: null });
1181
1223
  }
@@ -1184,13 +1226,14 @@ app.get('/api/sessions/:sessionId/agents', (req, res) => {
1184
1226
  app.post('/api/sessions/:sessionId/agents/:agentId/stop', (req, res) => {
1185
1227
  const sessionId = resolveSessionId(req.params.sessionId);
1186
1228
  const agentId = sanitizeAgentId(req.params.agentId);
1187
- const agentFile = path.join(AGENT_ACTIVITY_DIR, sessionId, agentId + '.json');
1229
+ const agentFile = path.join(AGENT_ACTIVITY_DIR, sessionId, agentId + '.jsonl');
1188
1230
  if (!existsSync(agentFile)) return res.status(404).json({ error: 'Agent not found' });
1189
1231
  try {
1190
- const agent = JSON.parse(readFileSync(agentFile, 'utf8'));
1232
+ const agent = readAgentJsonl(agentFile);
1191
1233
  agent.status = 'stopped';
1192
1234
  agent.stoppedAt = new Date().toISOString();
1193
- writeFileSync(agentFile, JSON.stringify(agent), 'utf8'); // sync response depends on write
1235
+ const stopEvt = { agentId, type: agent.type, event: 'user-stop', status: 'stopped', stoppedAt: agent.stoppedAt, updatedAt: agent.stoppedAt };
1236
+ writeFileSync(agentFile, readFileSync(agentFile, 'utf8') + JSON.stringify(stopEvt) + '\n', 'utf8'); // sync — response depends on write
1194
1237
  // Also remove waiting state if present
1195
1238
  const waitingFile = path.join(AGENT_ACTIVITY_DIR, sessionId, '_waiting.json');
1196
1239
  if (existsSync(waitingFile)) unlinkSync(waitingFile);
@@ -1214,30 +1257,122 @@ function subagentJsonlPath(meta, agentId) {
1214
1257
  }
1215
1258
 
1216
1259
  // Claude Code can scatter a session's records across multiple project dirs
1217
- // (e.g. main repo + worktree), so the subagent JSONL may live under a
1218
- // different project dir than meta.jsonlPath. Fall back to scanning when the
1219
- // derived path is missing.
1260
+ // (e.g. main repo + worktree) and across sibling sessionId dirs when a
1261
+ // session is forked/resumed the subagent JSONL stays under the original
1262
+ // parent sessionId. Fall back to scanning when the derived path is missing.
1220
1263
  const subagentPathCache = new Map();
1264
+ function findSubagentJsonlInProject(projPath, sessionId, agentId) {
1265
+ const sameSid = path.join(projPath, sessionId, 'subagents', 'agent-' + agentId + '.jsonl');
1266
+ if (existsSync(sameSid)) return sameSid;
1267
+ let sessions;
1268
+ try { sessions = readdirSync(projPath, { withFileTypes: true }); } catch { return null; }
1269
+ for (const sess of sessions) {
1270
+ if (!sess.isDirectory() || sess.name === sessionId) continue;
1271
+ const candidate = path.join(projPath, sess.name, 'subagents', 'agent-' + agentId + '.jsonl');
1272
+ if (existsSync(candidate)) return candidate;
1273
+ }
1274
+ return null;
1275
+ }
1221
1276
  function resolveSubagentJsonl(meta, sessionId, agentId) {
1222
1277
  const primary = subagentJsonlPath(meta, agentId);
1223
1278
  if (existsSync(primary)) return primary;
1224
1279
  const key = sessionId + '/' + agentId;
1225
- if (subagentPathCache.has(key)) return subagentPathCache.get(key) || primary;
1280
+ const cached = subagentPathCache.get(key);
1281
+ if (cached) return cached;
1226
1282
  let found = null;
1227
- try {
1228
- for (const entry of readdirSync(PROJECTS_DIR, { withFileTypes: true })) {
1229
- if (!entry.isDirectory()) continue;
1230
- const candidate = path.join(
1231
- PROJECTS_DIR, entry.name, sessionId,
1232
- 'subagents', 'agent-' + agentId + '.jsonl'
1233
- );
1234
- if (existsSync(candidate)) { found = candidate; break; }
1235
- }
1236
- } catch (_) { /* projects dir missing */ }
1237
- subagentPathCache.set(key, found);
1283
+ const parent = lookupParentSession(sessionId);
1284
+ if (parent.parentSessionId && parent.parentJsonlPath) {
1285
+ const projDir = path.dirname(parent.parentJsonlPath);
1286
+ const candidate = path.join(projDir, parent.parentSessionId, 'subagents', 'agent-' + agentId + '.jsonl');
1287
+ if (existsSync(candidate)) found = candidate;
1288
+ }
1289
+ if (!found) {
1290
+ try {
1291
+ for (const proj of readdirSync(PROJECTS_DIR, { withFileTypes: true })) {
1292
+ if (!proj.isDirectory()) continue;
1293
+ found = findSubagentJsonlInProject(path.join(PROJECTS_DIR, proj.name), sessionId, agentId);
1294
+ if (found) break;
1295
+ }
1296
+ } catch (_) { /* projects dir missing */ }
1297
+ }
1298
+ if (found) subagentPathCache.set(key, found);
1238
1299
  return found || primary;
1239
1300
  }
1240
1301
 
1302
+ // Claude Code marks fork lineage in two ways:
1303
+ // 1. `logicalParentUuid` on a system record (when present) points to a uuid
1304
+ // in the parent session's JSONL.
1305
+ // 2. When absent, the fork copies the parent's early records verbatim, so
1306
+ // the earliest `uuid` in this session also exists (same uuid+timestamp)
1307
+ // in the parent's JSONL.
1308
+ // We try (1) first, then fall back to (2).
1309
+ const parentSessionCache = new Map();
1310
+ // Both anchor signals live in the first few records (system marker on top,
1311
+ // fork-copy starts at line 0), so cap the scan instead of reading the whole file.
1312
+ const FORK_ANCHOR_SCAN_LINES = 10;
1313
+ function findForkAnchorUuid(jsonlPath) {
1314
+ let text;
1315
+ try { text = readFileSync(jsonlPath, 'utf8'); } catch { return null; }
1316
+ let firstUuid = null;
1317
+ let scanned = 0;
1318
+ for (const l of text.split('\n')) {
1319
+ if (!l) continue;
1320
+ if (scanned++ >= FORK_ANCHOR_SCAN_LINES) break;
1321
+ try {
1322
+ const d = JSON.parse(l);
1323
+ if (d.logicalParentUuid) return d.logicalParentUuid;
1324
+ if (!firstUuid && d.uuid) firstUuid = d.uuid;
1325
+ } catch { /* skip malformed */ }
1326
+ }
1327
+ return firstUuid;
1328
+ }
1329
+ function findSessionContainingUuid(projectDir, targetUuid, excludeJsonlPath) {
1330
+ let files;
1331
+ try { files = readdirSync(projectDir); } catch { return null; }
1332
+ const candidates = [];
1333
+ for (const f of files) {
1334
+ if (!f.endsWith('.jsonl')) continue;
1335
+ const fp = path.join(projectDir, f);
1336
+ if (fp === excludeJsonlPath) continue;
1337
+ let text;
1338
+ try { text = readFileSync(fp, 'utf8'); } catch { continue; }
1339
+ if (!text.includes(targetUuid)) continue;
1340
+ for (const l of text.split('\n')) {
1341
+ if (!l || !l.includes(targetUuid)) continue;
1342
+ try {
1343
+ const d = JSON.parse(l);
1344
+ if (d.uuid === targetUuid && d.sessionId) {
1345
+ let mtime = 0;
1346
+ try { mtime = statSync(fp).mtimeMs; } catch { /* ignore */ }
1347
+ candidates.push({ parentSessionId: d.sessionId, parentJsonlPath: fp, mtime });
1348
+ break;
1349
+ }
1350
+ } catch { /* skip */ }
1351
+ }
1352
+ }
1353
+ if (!candidates.length) return null;
1354
+ candidates.sort((a, b) => a.mtime - b.mtime);
1355
+ const { parentSessionId, parentJsonlPath } = candidates[0];
1356
+ return { parentSessionId, parentJsonlPath };
1357
+ }
1358
+ function lookupParentSession(sessionId) {
1359
+ if (parentSessionCache.has(sessionId)) return parentSessionCache.get(sessionId);
1360
+ const meta = loadSessionMetadata()[sessionId];
1361
+ const result = { parentSessionId: null, parentJsonlPath: null };
1362
+ if (meta?.jsonlPath) {
1363
+ const anchorUuid = findForkAnchorUuid(meta.jsonlPath);
1364
+ if (anchorUuid) {
1365
+ const hit = findSessionContainingUuid(path.dirname(meta.jsonlPath), anchorUuid, meta.jsonlPath);
1366
+ if (hit) Object.assign(result, hit);
1367
+ }
1368
+ }
1369
+ if (result.parentSessionId) parentSessionCache.set(sessionId, result);
1370
+ return result;
1371
+ }
1372
+ app.get('/api/sessions/:sessionId/parent', (req, res) => {
1373
+ res.json(lookupParentSession(resolveSessionId(req.params.sessionId)));
1374
+ });
1375
+
1241
1376
  app.get('/api/sessions/:sessionId/agents/:agentId/messages', (req, res) => {
1242
1377
  const sessionId = resolveSessionId(req.params.sessionId);
1243
1378
  const agentId = sanitizeAgentId(req.params.agentId);
@@ -1331,8 +1466,8 @@ app.get('/api/sessions/:sessionId/messages', (req, res) => {
1331
1466
  if (entry.description) msg.agentDescription = entry.description;
1332
1467
  if (entry.prompt && !msg.agentPrompt) msg.agentPrompt = entry.prompt;
1333
1468
  try {
1334
- const agentFile = path.join(agentDir, entry.agentId + '.json');
1335
- const agent = JSON.parse(readFileSync(agentFile, 'utf8'));
1469
+ const agentFile = path.join(agentDir, entry.agentId + '.jsonl');
1470
+ const agent = readAgentJsonl(agentFile);
1336
1471
  if (agent.lastMessage) msg.agentLastMessage = agent.lastMessage;
1337
1472
  if (agent.prompt && !msg.agentPrompt) msg.agentPrompt = agent.prompt;
1338
1473
  const prompt = msg.agentPrompt || entry.prompt;
@@ -1380,6 +1515,19 @@ app.get('/api/sessions/:sessionId/tool-result/:toolUseId', (req, res) => {
1380
1515
  res.json({ toolUseId: req.params.toolUseId, content });
1381
1516
  });
1382
1517
 
1518
+ app.get('/api/sessions/:sessionId/user-image/:msgUuid/:blockIndex', (req, res) => {
1519
+ const metadata = loadSessionMetadata();
1520
+ const meta = metadata[req.params.sessionId];
1521
+ const jsonlPath = meta?.jsonlPath;
1522
+ if (!jsonlPath) return res.status(404).end();
1523
+ const img = readUserImage(jsonlPath, req.params.msgUuid, req.params.blockIndex);
1524
+ if (!img) return res.status(404).end();
1525
+ const buf = Buffer.from(img.data, 'base64');
1526
+ res.setHeader('Content-Type', img.mediaType);
1527
+ res.setHeader('Cache-Control', 'no-store');
1528
+ res.end(buf);
1529
+ });
1530
+
1383
1531
  app.get('/api/version', (req, res) => {
1384
1532
  const pkg = require('./package.json');
1385
1533
  res.json({ version: pkg.version });
@@ -1819,14 +1967,16 @@ const agentActivityWatcher = chokidar.watch(AGENT_ACTIVITY_DIR, {
1819
1967
  const AGENT_FILE_CAP = 20;
1820
1968
 
1821
1969
  agentActivityWatcher.on('all', (event, filePath) => {
1822
- if ((event === 'add' || event === 'change' || event === 'unlink') && filePath.endsWith('.json')) {
1970
+ const base = path.basename(filePath);
1971
+ const isAgentEvent = filePath.endsWith('.jsonl') || base === '_waiting.json';
1972
+ if ((event === 'add' || event === 'change' || event === 'unlink') && isAgentEvent) {
1823
1973
  const relativePath = path.relative(AGENT_ACTIVITY_DIR, filePath);
1824
1974
  const sessionId = relativePath.split(path.sep)[0];
1825
1975
  // Cleanup: if session dir exceeds cap, delete oldest files by mtime
1826
- if (event === 'add') {
1976
+ if (event === 'add' && filePath.endsWith('.jsonl')) {
1827
1977
  try {
1828
1978
  const sessionDir = path.join(AGENT_ACTIVITY_DIR, sessionId);
1829
- const files = readdirSync(sessionDir).filter(f => f.endsWith('.json') && !f.startsWith('_'));
1979
+ const files = readdirSync(sessionDir).filter(f => f.endsWith('.jsonl') && !f.startsWith('_'));
1830
1980
  if (files.length > AGENT_FILE_CAP) {
1831
1981
  const withStats = files.map(f => {
1832
1982
  const fp = path.join(sessionDir, f);
@@ -1880,7 +2030,6 @@ contextStatusWatcher.on('all', (event, filePath) => {
1880
2030
  }
1881
2031
  });
1882
2032
 
1883
- const CTX_CLEANUP_MAX_AGE_MS = 2 * 60 * 60 * 1000;
1884
2033
  async function cleanupContextStatus() {
1885
2034
  try {
1886
2035
  const entries = await fs.readdir(CONTEXT_STATUS_DIR);
@@ -1899,10 +2048,6 @@ async function cleanupContextStatus() {
1899
2048
  } catch (e) { /* dir may not exist */ }
1900
2049
  }
1901
2050
 
1902
- // Cleanup agent-activity folders older than 2 days
1903
- const CLEANUP_MAX_AGE_MS = 2 * 24 * 60 * 60 * 1000;
1904
- const CLEANUP_INTERVAL_MS = 60 * 60 * 1000;
1905
-
1906
2051
  async function cleanupAgentActivity() {
1907
2052
  try {
1908
2053
  const entries = await fs.readdir(AGENT_ACTIVITY_DIR, { withFileTypes: true });