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/lib/parsers.js +68 -16
- package/package.json +1 -1
- package/plugin/plugins/claude-code-kanban/.claude-plugin/plugin.json +1 -1
- package/plugin/plugins/claude-code-kanban/hooks/hooks.json +10 -0
- package/plugin/plugins/claude-code-kanban/scripts/agent-spy.sh +17 -52
- package/public/app.js +221 -45
- package/public/index.html +6 -0
- package/public/style.css +160 -19
- package/server.js +193 -48
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
|
-
|
|
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
|
-
|
|
99
|
-
const
|
|
100
|
-
const
|
|
101
|
-
const
|
|
102
|
-
|
|
103
|
-
const WAITING_RESOLVE_GRACE_MS =
|
|
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 + '.
|
|
107
|
-
|
|
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('.
|
|
175
|
+
for (const file of readdirSync(agentDir).filter(f => f.endsWith('.jsonl') && !f.startsWith('_'))) {
|
|
160
176
|
try {
|
|
161
|
-
const agent =
|
|
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('.
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
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 + '.
|
|
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 =
|
|
1232
|
+
const agent = readAgentJsonl(agentFile);
|
|
1191
1233
|
agent.status = 'stopped';
|
|
1192
1234
|
agent.stoppedAt = new Date().toISOString();
|
|
1193
|
-
|
|
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)
|
|
1218
|
-
//
|
|
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
|
-
|
|
1280
|
+
const cached = subagentPathCache.get(key);
|
|
1281
|
+
if (cached) return cached;
|
|
1226
1282
|
let found = null;
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
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 + '.
|
|
1335
|
-
const agent =
|
|
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
|
-
|
|
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('.
|
|
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 });
|