claude-code-kanban 1.18.0 → 1.19.0-rc.1
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/hooks/agent-spy.sh +46 -17
- package/install.js +17 -8
- package/package.json +3 -2
- package/public/index.html +412 -36
- package/server.js +246 -53
package/hooks/agent-spy.sh
CHANGED
|
@@ -4,13 +4,43 @@
|
|
|
4
4
|
|
|
5
5
|
INPUT=$(cat)
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
# Single jq call to extract all routing fields (was 3-4 separate calls)
|
|
8
|
+
eval "$(echo "$INPUT" | jq -r '
|
|
9
|
+
@sh "SESSION_ID=\(.session_id // "")",
|
|
10
|
+
@sh "AGENT_ID=\(.agent_id // "")",
|
|
11
|
+
@sh "EVENT=\(.hook_event_name // "")",
|
|
12
|
+
@sh "TOOL_NAME=\(.tool_name // "")",
|
|
13
|
+
@sh "AGENT_TYPE_RAW=\(.agent_type // "")"
|
|
14
|
+
')"
|
|
10
15
|
|
|
11
|
-
[ -z "$SESSION_ID" ]
|
|
16
|
+
[ -z "$SESSION_ID" ] && exit 0
|
|
17
|
+
|
|
18
|
+
# PostToolUse: clear waiting state (permission granted or question answered)
|
|
19
|
+
if [ "$EVENT" = "PostToolUse" ]; then
|
|
20
|
+
WFILE="$HOME/.claude/agent-activity/$SESSION_ID/_waiting.json"
|
|
21
|
+
[ -f "$WFILE" ] && rm -f "$WFILE"
|
|
22
|
+
exit 0
|
|
23
|
+
fi
|
|
24
|
+
|
|
25
|
+
# Waiting-for-user events → write _waiting.json marker
|
|
26
|
+
if [ "$EVENT" = "PermissionRequest" ] || { [ "$EVENT" = "PreToolUse" ] && [ "$TOOL_NAME" = "AskUserQuestion" ]; }; then
|
|
27
|
+
DIR="$HOME/.claude/agent-activity/$SESSION_ID"
|
|
28
|
+
mkdir -p "$DIR"
|
|
29
|
+
KIND="permission"
|
|
30
|
+
[ "$EVENT" = "PreToolUse" ] && KIND="question"
|
|
31
|
+
TS=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
|
32
|
+
echo "$INPUT" | jq -c --arg kind "$KIND" --arg ts "$TS" '{
|
|
33
|
+
status: "waiting",
|
|
34
|
+
kind: $kind,
|
|
35
|
+
toolName: (.tool_name // "unknown"),
|
|
36
|
+
toolInput: ((.tool_input | tostring)[0:200] // ""),
|
|
37
|
+
timestamp: $ts
|
|
38
|
+
}' > "$DIR/_waiting.json"
|
|
39
|
+
exit 0
|
|
40
|
+
fi
|
|
41
|
+
|
|
42
|
+
[ -z "$AGENT_ID" ] && exit 0
|
|
12
43
|
|
|
13
|
-
AGENT_TYPE_RAW=$(echo "$INPUT" | jq -r '.agent_type // empty')
|
|
14
44
|
DIR="$HOME/.claude/agent-activity/$SESSION_ID"
|
|
15
45
|
FILE="$DIR/$AGENT_ID.json"
|
|
16
46
|
|
|
@@ -33,27 +63,26 @@ if [ "$EVENT" = "SubagentStart" ]; then
|
|
|
33
63
|
EOF
|
|
34
64
|
|
|
35
65
|
elif [ "$EVENT" = "SubagentStop" ]; then
|
|
36
|
-
# Read type and startedAt from existing file if available
|
|
37
66
|
AGENT_TYPE="$AGENT_TYPE_RAW"
|
|
38
67
|
STARTED_AT="$TS"
|
|
39
68
|
if [ -f "$FILE" ]; then
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
[ -
|
|
69
|
+
eval "$(jq -r '@sh "PREV_TYPE=\(.type // "unknown")", @sh "PREV_START=\(.startedAt // "")"' "$FILE")"
|
|
70
|
+
[ -z "$AGENT_TYPE" ] && AGENT_TYPE="$PREV_TYPE"
|
|
71
|
+
[ -n "$PREV_START" ] && STARTED_AT="$PREV_START"
|
|
43
72
|
fi
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
73
|
+
echo "$INPUT" | jq -c \
|
|
74
|
+
--arg id "$AGENT_ID" --arg type "$AGENT_TYPE" --arg started "$STARTED_AT" --arg ts "$TS" \
|
|
75
|
+
'{agentId: $id, type: $type, status: "stopped", startedAt: $started,
|
|
76
|
+
lastMessage: (.last_assistant_message // ""), stoppedAt: $ts, updatedAt: $ts}' \
|
|
77
|
+
> "$FILE"
|
|
49
78
|
|
|
50
79
|
elif [ "$EVENT" = "TeammateIdle" ]; then
|
|
51
80
|
AGENT_TYPE="$AGENT_TYPE_RAW"
|
|
52
81
|
STARTED_AT="$TS"
|
|
53
82
|
if [ -f "$FILE" ]; then
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
[ -
|
|
83
|
+
eval "$(jq -r '@sh "PREV_TYPE=\(.type // "unknown")", @sh "PREV_START=\(.startedAt // "")"' "$FILE")"
|
|
84
|
+
[ -z "$AGENT_TYPE" ] && AGENT_TYPE="$PREV_TYPE"
|
|
85
|
+
[ -n "$PREV_START" ] && STARTED_AT="$PREV_START"
|
|
57
86
|
fi
|
|
58
87
|
cat > "$FILE" <<EOF
|
|
59
88
|
{"agentId":"$AGENT_ID","type":"$AGENT_TYPE","status":"idle","startedAt":"$STARTED_AT","updatedAt":"$TS"}
|
package/install.js
CHANGED
|
@@ -14,7 +14,14 @@ const HOOK_SCRIPT_SRC = path.join(__dirname, 'hooks', 'agent-spy.sh');
|
|
|
14
14
|
const AGENT_ACTIVITY_DIR = path.join(CLAUDE_DIR, 'agent-activity');
|
|
15
15
|
|
|
16
16
|
const HOOK_COMMAND = '~/.claude/hooks/agent-spy.sh';
|
|
17
|
-
const HOOK_EVENTS = [
|
|
17
|
+
const HOOK_EVENTS = [
|
|
18
|
+
{ event: 'SubagentStart' },
|
|
19
|
+
{ event: 'SubagentStop' },
|
|
20
|
+
{ event: 'TeammateIdle' },
|
|
21
|
+
{ event: 'PermissionRequest' },
|
|
22
|
+
{ event: 'PreToolUse', matcher: 'AskUserQuestion' },
|
|
23
|
+
{ event: 'PostToolUse' },
|
|
24
|
+
];
|
|
18
25
|
|
|
19
26
|
// ANSI helpers
|
|
20
27
|
const green = s => `\x1b[32m${s}\x1b[0m`;
|
|
@@ -95,12 +102,13 @@ async function runInstall() {
|
|
|
95
102
|
if (!settings.hooks) settings.hooks = {};
|
|
96
103
|
|
|
97
104
|
const needed = [];
|
|
98
|
-
for (const event of HOOK_EVENTS) {
|
|
105
|
+
for (const { event, matcher } of HOOK_EVENTS) {
|
|
99
106
|
if (!settings.hooks[event]) settings.hooks[event] = [];
|
|
107
|
+
const matcherStr = matcher || '';
|
|
100
108
|
const exists = settings.hooks[event].some(g =>
|
|
101
|
-
g.hooks?.some(h => h.command === HOOK_COMMAND)
|
|
109
|
+
g.matcher === matcherStr && g.hooks?.some(h => h.command === HOOK_COMMAND)
|
|
102
110
|
);
|
|
103
|
-
if (!exists) needed.push(event);
|
|
111
|
+
if (!exists) needed.push({ event, matcher: matcherStr });
|
|
104
112
|
}
|
|
105
113
|
|
|
106
114
|
let settingsUpdated = false;
|
|
@@ -108,11 +116,11 @@ async function runInstall() {
|
|
|
108
116
|
console.log(` ${green('✓')} Already configured`);
|
|
109
117
|
settingsUpdated = true;
|
|
110
118
|
} else {
|
|
111
|
-
console.log(` Adding hooks for: ${needed.join(', ')}`);
|
|
119
|
+
console.log(` Adding hooks for: ${needed.map(n => n.matcher ? `${n.event}:${n.matcher}` : n.event).join(', ')}`);
|
|
112
120
|
if (await prompt(` Update settings? [Y/n] `)) {
|
|
113
|
-
for (const event of needed) {
|
|
121
|
+
for (const { event, matcher } of needed) {
|
|
114
122
|
settings.hooks[event].push({
|
|
115
|
-
matcher
|
|
123
|
+
matcher,
|
|
116
124
|
hooks: [{ type: 'command', command: HOOK_COMMAND, timeout: 5 }]
|
|
117
125
|
});
|
|
118
126
|
}
|
|
@@ -147,7 +155,8 @@ async function runUninstall() {
|
|
|
147
155
|
const settings = JSON.parse(fs.readFileSync(SETTINGS_PATH, 'utf8'));
|
|
148
156
|
if (settings.hooks) {
|
|
149
157
|
let removed = 0;
|
|
150
|
-
|
|
158
|
+
const eventNames = [...new Set(HOOK_EVENTS.map(e => e.event))];
|
|
159
|
+
for (const event of eventNames) {
|
|
151
160
|
if (!Array.isArray(settings.hooks[event])) continue;
|
|
152
161
|
const before = settings.hooks[event].length;
|
|
153
162
|
settings.hooks[event] = settings.hooks[event].filter(g =>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-code-kanban",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.19.0-rc.1",
|
|
4
4
|
"description": "A web-based Kanban board for viewing Claude Code tasks with agent teams support",
|
|
5
5
|
"main": "server.js",
|
|
6
6
|
"bin": {
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
},
|
|
9
9
|
"scripts": {
|
|
10
10
|
"start": "node server.js",
|
|
11
|
-
"dev": "node server.js --open"
|
|
11
|
+
"dev": "node server.js --open",
|
|
12
|
+
"test": "bash tests/test-agent-spy.sh"
|
|
12
13
|
},
|
|
13
14
|
"repository": {
|
|
14
15
|
"type": "git",
|
package/public/index.html
CHANGED
|
@@ -472,6 +472,20 @@
|
|
|
472
472
|
margin-top: 6px;
|
|
473
473
|
}
|
|
474
474
|
|
|
475
|
+
.session-latest-msg {
|
|
476
|
+
font-size: 11px;
|
|
477
|
+
color: var(--text-muted);
|
|
478
|
+
margin-top: 2px;
|
|
479
|
+
white-space: nowrap;
|
|
480
|
+
overflow: hidden;
|
|
481
|
+
text-overflow: ellipsis;
|
|
482
|
+
}
|
|
483
|
+
.session-latest-msg .lm-label {
|
|
484
|
+
color: var(--text-tertiary);
|
|
485
|
+
font-weight: 500;
|
|
486
|
+
font-size: 10px;
|
|
487
|
+
}
|
|
488
|
+
|
|
475
489
|
/* Footer */
|
|
476
490
|
.sidebar-footer {
|
|
477
491
|
flex-shrink: 0;
|
|
@@ -563,6 +577,24 @@
|
|
|
563
577
|
margin-top: 4px;
|
|
564
578
|
}
|
|
565
579
|
|
|
580
|
+
.latest-message {
|
|
581
|
+
flex: 1;
|
|
582
|
+
font-size: 11px;
|
|
583
|
+
color: var(--text-secondary);
|
|
584
|
+
white-space: nowrap;
|
|
585
|
+
overflow: hidden;
|
|
586
|
+
text-overflow: ellipsis;
|
|
587
|
+
text-align: right;
|
|
588
|
+
padding: 0 16px;
|
|
589
|
+
min-width: 0;
|
|
590
|
+
display: none;
|
|
591
|
+
}
|
|
592
|
+
.latest-message.visible { display: block; }
|
|
593
|
+
.latest-message .lm-label {
|
|
594
|
+
color: var(--accent);
|
|
595
|
+
font-weight: 500;
|
|
596
|
+
}
|
|
597
|
+
|
|
566
598
|
.view-actions {
|
|
567
599
|
display: flex;
|
|
568
600
|
align-items: center;
|
|
@@ -856,7 +888,7 @@
|
|
|
856
888
|
position: fixed;
|
|
857
889
|
top: 0;
|
|
858
890
|
right: 0;
|
|
859
|
-
width:
|
|
891
|
+
width: 540px;
|
|
860
892
|
height: 100vh;
|
|
861
893
|
background: var(--bg-surface);
|
|
862
894
|
border-left: 1px solid var(--border);
|
|
@@ -1389,6 +1421,110 @@
|
|
|
1389
1421
|
padding: 6px 10px;
|
|
1390
1422
|
}
|
|
1391
1423
|
|
|
1424
|
+
/* Message log panel */
|
|
1425
|
+
.message-panel {
|
|
1426
|
+
position: fixed;
|
|
1427
|
+
top: 0;
|
|
1428
|
+
right: 0;
|
|
1429
|
+
width: 540px;
|
|
1430
|
+
height: 100vh;
|
|
1431
|
+
background: var(--bg-surface);
|
|
1432
|
+
border-left: 1px solid var(--border);
|
|
1433
|
+
box-shadow: -8px 0 24px rgba(0, 0, 0, 0.15);
|
|
1434
|
+
display: none;
|
|
1435
|
+
flex-direction: column;
|
|
1436
|
+
z-index: 99;
|
|
1437
|
+
overflow: hidden;
|
|
1438
|
+
}
|
|
1439
|
+
.message-panel.visible { display: flex; }
|
|
1440
|
+
.message-panel-header {
|
|
1441
|
+
padding: 16px 20px;
|
|
1442
|
+
display: flex;
|
|
1443
|
+
align-items: center;
|
|
1444
|
+
justify-content: space-between;
|
|
1445
|
+
background-image: linear-gradient(to right, transparent, var(--border), transparent);
|
|
1446
|
+
background-size: 100% 1px;
|
|
1447
|
+
background-repeat: no-repeat;
|
|
1448
|
+
background-position: bottom;
|
|
1449
|
+
}
|
|
1450
|
+
.message-panel-header h3 {
|
|
1451
|
+
font-family: var(--serif);
|
|
1452
|
+
font-size: 14px;
|
|
1453
|
+
font-weight: 500;
|
|
1454
|
+
}
|
|
1455
|
+
.message-panel-content {
|
|
1456
|
+
flex: 1;
|
|
1457
|
+
overflow-y: auto;
|
|
1458
|
+
padding: 12px 16px;
|
|
1459
|
+
display: flex;
|
|
1460
|
+
flex-direction: column;
|
|
1461
|
+
gap: 8px;
|
|
1462
|
+
}
|
|
1463
|
+
.msg-item {
|
|
1464
|
+
display: flex;
|
|
1465
|
+
gap: 8px;
|
|
1466
|
+
padding: 8px 10px;
|
|
1467
|
+
border-radius: 6px;
|
|
1468
|
+
font-size: 12px;
|
|
1469
|
+
line-height: 1.5;
|
|
1470
|
+
background: var(--bg-elevated);
|
|
1471
|
+
border: 1px solid var(--border);
|
|
1472
|
+
}
|
|
1473
|
+
.msg-item.msg-user {
|
|
1474
|
+
border-left: 3px solid var(--text-muted);
|
|
1475
|
+
}
|
|
1476
|
+
.msg-item.msg-assistant {
|
|
1477
|
+
border-left: 3px solid var(--accent);
|
|
1478
|
+
}
|
|
1479
|
+
.msg-item.msg-tool {
|
|
1480
|
+
border-left: 3px solid var(--team);
|
|
1481
|
+
font-size: 11px;
|
|
1482
|
+
padding: 5px 10px;
|
|
1483
|
+
color: var(--text-secondary);
|
|
1484
|
+
}
|
|
1485
|
+
.msg-icon {
|
|
1486
|
+
flex-shrink: 0;
|
|
1487
|
+
width: 16px;
|
|
1488
|
+
height: 16px;
|
|
1489
|
+
margin-top: 2px;
|
|
1490
|
+
opacity: 0.6;
|
|
1491
|
+
}
|
|
1492
|
+
.msg-body { flex: 1; min-width: 0; }
|
|
1493
|
+
.msg-text {
|
|
1494
|
+
white-space: pre-wrap;
|
|
1495
|
+
word-break: break-word;
|
|
1496
|
+
color: var(--text-primary);
|
|
1497
|
+
display: -webkit-box;
|
|
1498
|
+
-webkit-line-clamp: 3;
|
|
1499
|
+
-webkit-box-orient: vertical;
|
|
1500
|
+
overflow: hidden;
|
|
1501
|
+
}
|
|
1502
|
+
.msg-tool .msg-text { color: var(--text-secondary); }
|
|
1503
|
+
.msg-detail-pre {
|
|
1504
|
+
white-space: pre-wrap;
|
|
1505
|
+
word-break: break-all;
|
|
1506
|
+
margin: 0;
|
|
1507
|
+
font-family: var(--font-mono);
|
|
1508
|
+
font-size: 0.85rem;
|
|
1509
|
+
}
|
|
1510
|
+
.msg-cmd .msg-text code {
|
|
1511
|
+
background: var(--bg-hover);
|
|
1512
|
+
padding: 2px 6px;
|
|
1513
|
+
border-radius: 3px;
|
|
1514
|
+
font-size: 11px;
|
|
1515
|
+
}
|
|
1516
|
+
.msg-time {
|
|
1517
|
+
font-size: 10px;
|
|
1518
|
+
color: var(--text-muted);
|
|
1519
|
+
margin-top: 4px;
|
|
1520
|
+
}
|
|
1521
|
+
.msg-empty {
|
|
1522
|
+
text-align: center;
|
|
1523
|
+
color: var(--text-muted);
|
|
1524
|
+
padding: 40px 20px;
|
|
1525
|
+
font-size: 12px;
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1392
1528
|
/* Agent footer panel */
|
|
1393
1529
|
.agent-footer {
|
|
1394
1530
|
display: none;
|
|
@@ -1455,6 +1591,27 @@
|
|
|
1455
1591
|
max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
1456
1592
|
}
|
|
1457
1593
|
|
|
1594
|
+
.agent-badge { font-size: 12px; cursor: default; }
|
|
1595
|
+
|
|
1596
|
+
/* Permission pending — indicated by ❓ badge only */
|
|
1597
|
+
.permission-badge {
|
|
1598
|
+
display: inline-flex;
|
|
1599
|
+
align-items: center;
|
|
1600
|
+
gap: 6px;
|
|
1601
|
+
padding: 6px 12px;
|
|
1602
|
+
background: var(--warning-dim);
|
|
1603
|
+
border: 1px solid var(--warning);
|
|
1604
|
+
border-radius: 8px;
|
|
1605
|
+
font-size: 12px;
|
|
1606
|
+
color: var(--warning);
|
|
1607
|
+
white-space: nowrap;
|
|
1608
|
+
animation: permission-pulse 1.5s ease-in-out infinite;
|
|
1609
|
+
}
|
|
1610
|
+
@keyframes permission-pulse {
|
|
1611
|
+
0%, 100% { opacity: 1; }
|
|
1612
|
+
50% { opacity: 0.5; }
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1458
1615
|
/* Light mode */
|
|
1459
1616
|
body.light {
|
|
1460
1617
|
--bg-deep: #e8e6e3;
|
|
@@ -2006,6 +2163,7 @@
|
|
|
2006
2163
|
<h1 id="session-title" class="view-title">Session</h1>
|
|
2007
2164
|
<p id="session-meta" class="view-meta"></p>
|
|
2008
2165
|
</div>
|
|
2166
|
+
<div id="latest-message" class="latest-message"></div>
|
|
2009
2167
|
<div class="view-actions">
|
|
2010
2168
|
<div class="view-progress">
|
|
2011
2169
|
<div class="progress-bar">
|
|
@@ -2013,6 +2171,11 @@
|
|
|
2013
2171
|
</div>
|
|
2014
2172
|
<span id="progress-percent" class="progress-text">0%</span>
|
|
2015
2173
|
</div>
|
|
2174
|
+
<button id="message-toggle" class="icon-btn" onclick="toggleMessagePanel()" title="Session log (L)" aria-label="Toggle session log">
|
|
2175
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
2176
|
+
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
|
2177
|
+
</svg>
|
|
2178
|
+
</button>
|
|
2016
2179
|
<button id="theme-toggle" class="icon-btn" onclick="toggleTheme()" title="Toggle theme" aria-label="Toggle theme">
|
|
2017
2180
|
<svg id="theme-icon-dark" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
2018
2181
|
<path d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"/>
|
|
@@ -2099,6 +2262,18 @@
|
|
|
2099
2262
|
</header>
|
|
2100
2263
|
<div id="detail-content" class="detail-content"></div>
|
|
2101
2264
|
</aside>
|
|
2265
|
+
|
|
2266
|
+
<aside id="message-panel" class="message-panel">
|
|
2267
|
+
<header class="message-panel-header">
|
|
2268
|
+
<h3>Session Log</h3>
|
|
2269
|
+
<button class="detail-close" onclick="toggleMessagePanel()" aria-label="Close message panel">
|
|
2270
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
2271
|
+
<path d="M6 18L18 6M6 6l12 12"/>
|
|
2272
|
+
</svg>
|
|
2273
|
+
</button>
|
|
2274
|
+
</header>
|
|
2275
|
+
<div id="message-panel-content" class="message-panel-content"></div>
|
|
2276
|
+
</aside>
|
|
2102
2277
|
</div>
|
|
2103
2278
|
|
|
2104
2279
|
<script>
|
|
@@ -2116,7 +2291,12 @@
|
|
|
2116
2291
|
let bulkDeleteSessionId = null; // Track session for bulk delete
|
|
2117
2292
|
let ownerFilter = '';
|
|
2118
2293
|
let currentAgents = [];
|
|
2294
|
+
let currentWaiting = null;
|
|
2119
2295
|
let lastAgentsHash = '';
|
|
2296
|
+
let messagePanelOpen = false;
|
|
2297
|
+
let lastMessagesHash = '';
|
|
2298
|
+
let lastInlineMessage = '';
|
|
2299
|
+
let currentMessages = [];
|
|
2120
2300
|
let agentDurationInterval = null;
|
|
2121
2301
|
let selectedTaskId = null;
|
|
2122
2302
|
let selectedSessionId = null;
|
|
@@ -2497,9 +2677,13 @@
|
|
|
2497
2677
|
currentTasks = newTasks;
|
|
2498
2678
|
currentSessionId = sessionId;
|
|
2499
2679
|
ownerFilter = '';
|
|
2680
|
+
lastMessagesHash = '';
|
|
2681
|
+
lastInlineMessage = '';
|
|
2682
|
+
document.getElementById('latest-message').classList.remove('visible');
|
|
2500
2683
|
updateUrl();
|
|
2501
2684
|
renderSession();
|
|
2502
2685
|
fetchAgents(sessionId);
|
|
2686
|
+
fetchMessages(sessionId);
|
|
2503
2687
|
} catch (error) {
|
|
2504
2688
|
console.error('Failed to fetch tasks:', error);
|
|
2505
2689
|
currentTasks = [];
|
|
@@ -2511,15 +2695,17 @@
|
|
|
2511
2695
|
}
|
|
2512
2696
|
|
|
2513
2697
|
const AGENT_COOLDOWN_MS = 3 * 60 * 1000;
|
|
2514
|
-
const AGENT_STALE_MS = 5 * 60 * 1000;
|
|
2698
|
+
const AGENT_STALE_MS = 5 * 60 * 1000; // kept for reference; no longer used for force-stopping
|
|
2515
2699
|
const AGENT_LOG_MAX = 8;
|
|
2516
2700
|
|
|
2517
2701
|
async function fetchAgents(sessionId) {
|
|
2518
2702
|
try {
|
|
2519
2703
|
const res = await fetch(`/api/sessions/${sessionId}/agents`);
|
|
2520
|
-
if (!res.ok) { currentAgents = []; renderAgentFooter(); return; }
|
|
2521
|
-
const
|
|
2522
|
-
const
|
|
2704
|
+
if (!res.ok) { currentAgents = []; currentWaiting = null; renderAgentFooter(); return; }
|
|
2705
|
+
const data = await res.json();
|
|
2706
|
+
const agents = Array.isArray(data) ? data : (data.agents || []);
|
|
2707
|
+
currentWaiting = data.waitingForUser || null;
|
|
2708
|
+
const hash = JSON.stringify(data);
|
|
2523
2709
|
if (hash === lastAgentsHash) return;
|
|
2524
2710
|
lastAgentsHash = hash;
|
|
2525
2711
|
currentAgents = agents;
|
|
@@ -2529,6 +2715,133 @@
|
|
|
2529
2715
|
}
|
|
2530
2716
|
}
|
|
2531
2717
|
|
|
2718
|
+
function toggleMessagePanel() {
|
|
2719
|
+
const panel = document.getElementById('message-panel');
|
|
2720
|
+
messagePanelOpen = !messagePanelOpen;
|
|
2721
|
+
panel.classList.toggle('visible', messagePanelOpen);
|
|
2722
|
+
document.getElementById('message-toggle')?.classList.toggle('active', messagePanelOpen);
|
|
2723
|
+
if (messagePanelOpen && currentSessionId) {
|
|
2724
|
+
if (currentMessages.length) renderMessages(currentMessages);
|
|
2725
|
+
fetchMessages(currentSessionId);
|
|
2726
|
+
}
|
|
2727
|
+
}
|
|
2728
|
+
|
|
2729
|
+
async function fetchMessages(sessionId) {
|
|
2730
|
+
try {
|
|
2731
|
+
const res = await fetch(`/api/sessions/${sessionId}/messages?limit=15`);
|
|
2732
|
+
if (!res.ok) return;
|
|
2733
|
+
const data = await res.json();
|
|
2734
|
+
const hash = JSON.stringify(data.messages);
|
|
2735
|
+
if (hash === lastMessagesHash) return;
|
|
2736
|
+
lastMessagesHash = hash;
|
|
2737
|
+
currentMessages = data.messages;
|
|
2738
|
+
updateLatestMessage(data.messages);
|
|
2739
|
+
if (messagePanelOpen) renderMessages(data.messages);
|
|
2740
|
+
} catch (e) {
|
|
2741
|
+
console.error('[fetchMessages]', e);
|
|
2742
|
+
}
|
|
2743
|
+
}
|
|
2744
|
+
|
|
2745
|
+
function parseCommandMessage(text) {
|
|
2746
|
+
const nameMatch = text.match(/<command-name>([^<]+)<\/command-name>/);
|
|
2747
|
+
if (nameMatch) return nameMatch[1].trim();
|
|
2748
|
+
const msgMatch = text.match(/<command-message>([^<]+)<\/command-message>/);
|
|
2749
|
+
if (msgMatch) return '/' + msgMatch[1].trim();
|
|
2750
|
+
return null;
|
|
2751
|
+
}
|
|
2752
|
+
|
|
2753
|
+
function cleanMessageText(text) {
|
|
2754
|
+
const cmd = parseCommandMessage(text);
|
|
2755
|
+
if (cmd) return cmd;
|
|
2756
|
+
return text
|
|
2757
|
+
.replace(/<[^>]+>/g, '')
|
|
2758
|
+
.replace(/\*\*/g, '')
|
|
2759
|
+
.replace(/^#+\s*/gm, '')
|
|
2760
|
+
.replace(/\n/g, ' ')
|
|
2761
|
+
.replace(/\s+/g, ' ')
|
|
2762
|
+
.trim();
|
|
2763
|
+
}
|
|
2764
|
+
|
|
2765
|
+
function updateLatestMessage(messages) {
|
|
2766
|
+
const el = document.getElementById('latest-message');
|
|
2767
|
+
let last = null;
|
|
2768
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
2769
|
+
if (messages[i].type === 'assistant' || messages[i].type === 'user') { last = messages[i]; break; }
|
|
2770
|
+
}
|
|
2771
|
+
if (!last) { el.classList.remove('visible'); lastInlineMessage = ''; return; }
|
|
2772
|
+
const label = last.type === 'assistant' ? 'Claude' : 'User';
|
|
2773
|
+
const cleaned = cleanMessageText(last.text);
|
|
2774
|
+
const text = cleaned.length > 120 ? cleaned.slice(0, 120) + '...' : cleaned;
|
|
2775
|
+
const html = `<span class="lm-label">${escapeHtml(label)}:</span> ${escapeHtml(text)}`;
|
|
2776
|
+
el.innerHTML = html;
|
|
2777
|
+
el.classList.add('visible');
|
|
2778
|
+
if (lastInlineMessage !== html) {
|
|
2779
|
+
lastInlineMessage = html;
|
|
2780
|
+
renderSessions();
|
|
2781
|
+
}
|
|
2782
|
+
}
|
|
2783
|
+
|
|
2784
|
+
function renderMessages(messages) {
|
|
2785
|
+
const container = document.getElementById('message-panel-content');
|
|
2786
|
+
if (!messages.length) {
|
|
2787
|
+
container.innerHTML = '<div class="msg-empty">No messages found for this session</div>';
|
|
2788
|
+
return;
|
|
2789
|
+
}
|
|
2790
|
+
container.innerHTML = messages.map((m, i) => {
|
|
2791
|
+
const clickable = `onclick="showMsgDetail(${i})" style="cursor:pointer"`;
|
|
2792
|
+
if (m.type === 'user') {
|
|
2793
|
+
const cmd = parseCommandMessage(m.text);
|
|
2794
|
+
const displayText = cmd ? cmd : escapeHtml(cleanMessageText(m.text));
|
|
2795
|
+
const isCmd = !!cmd;
|
|
2796
|
+
return `<div class="msg-item msg-user${isCmd ? ' msg-cmd' : ''}" ${clickable}>
|
|
2797
|
+
<svg class="msg-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
|
|
2798
|
+
<div class="msg-body"><div class="msg-text">${isCmd ? '<code>' + escapeHtml(displayText) + '</code>' : displayText}</div><div class="msg-time">${formatDate(m.timestamp)}</div></div>
|
|
2799
|
+
</div>`;
|
|
2800
|
+
} else if (m.type === 'assistant') {
|
|
2801
|
+
return `<div class="msg-item msg-assistant" ${clickable}>
|
|
2802
|
+
<svg class="msg-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="10" rx="2"/><circle cx="9" cy="16" r="1.5"/><circle cx="15" cy="16" r="1.5"/><path d="M12 2v4M8 7h8"/></svg>
|
|
2803
|
+
<div class="msg-body"><div class="msg-text">${escapeHtml(cleanMessageText(m.text))}</div><div class="msg-time">${m.model ? escapeHtml(m.model) + ' · ' : ''}${formatDate(m.timestamp)}</div></div>
|
|
2804
|
+
</div>`;
|
|
2805
|
+
} else if (m.type === 'tool_use') {
|
|
2806
|
+
const toolDetail = m.detail ? ` <span style="color:var(--text-muted)">${escapeHtml(m.detail)}</span>` : '';
|
|
2807
|
+
return `<div class="msg-item msg-tool" ${clickable}>
|
|
2808
|
+
<svg class="msg-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>
|
|
2809
|
+
<div class="msg-body"><div class="msg-text">${escapeHtml(m.tool)}${toolDetail}</div><div class="msg-time">${formatDate(m.timestamp)}</div></div>
|
|
2810
|
+
</div>`;
|
|
2811
|
+
}
|
|
2812
|
+
return '';
|
|
2813
|
+
}).join('');
|
|
2814
|
+
container.scrollTop = container.scrollHeight;
|
|
2815
|
+
}
|
|
2816
|
+
|
|
2817
|
+
function showMsgDetail(idx) {
|
|
2818
|
+
const m = currentMessages[idx];
|
|
2819
|
+
if (!m) return;
|
|
2820
|
+
const body = document.getElementById('msg-detail-body');
|
|
2821
|
+
if (m.type === 'tool_use') {
|
|
2822
|
+
document.getElementById('msg-detail-title').textContent = m.tool;
|
|
2823
|
+
const fullText = m.fullDetail || m.detail || '';
|
|
2824
|
+
const descHtml = m.description && m.description !== fullText ? `<div style="margin-bottom:8px;color:var(--text-secondary);font-size:0.85rem">${escapeHtml(m.description)}</div>` : '';
|
|
2825
|
+
body.innerHTML = fullText ? descHtml + `<pre class="msg-detail-pre">${escapeHtml(fullText)}</pre>` : '<em>No details</em>';
|
|
2826
|
+
} else {
|
|
2827
|
+
const text = m.fullText || m.text;
|
|
2828
|
+
document.getElementById('msg-detail-title').textContent = m.type === 'assistant' ? 'Claude' : 'User';
|
|
2829
|
+
if (typeof marked !== 'undefined' && typeof DOMPurify !== 'undefined') {
|
|
2830
|
+
body.innerHTML = DOMPurify.sanitize(marked.parse(text));
|
|
2831
|
+
} else {
|
|
2832
|
+
body.textContent = text;
|
|
2833
|
+
}
|
|
2834
|
+
}
|
|
2835
|
+
const meta = [formatDate(m.timestamp)];
|
|
2836
|
+
if (m.model) meta.unshift(m.model);
|
|
2837
|
+
document.getElementById('msg-detail-meta').textContent = meta.join(' · ');
|
|
2838
|
+
document.getElementById('msg-detail-modal').classList.add('visible');
|
|
2839
|
+
}
|
|
2840
|
+
|
|
2841
|
+
function closeMsgDetailModal() {
|
|
2842
|
+
document.getElementById('msg-detail-modal').classList.remove('visible');
|
|
2843
|
+
}
|
|
2844
|
+
|
|
2532
2845
|
function formatDuration(ms) {
|
|
2533
2846
|
const s = Math.floor(ms / 1000);
|
|
2534
2847
|
if (s < 60) return `${s}s`;
|
|
@@ -2543,13 +2856,7 @@
|
|
|
2543
2856
|
const label = document.getElementById('agent-footer-label');
|
|
2544
2857
|
const now = Date.now();
|
|
2545
2858
|
|
|
2546
|
-
|
|
2547
|
-
const agents = currentAgents.map(a => {
|
|
2548
|
-
if (a.status === 'active' && a.startedAt && (now - new Date(a.startedAt).getTime()) > AGENT_STALE_MS) {
|
|
2549
|
-
return { ...a, status: 'stopped', stoppedAt: a.updatedAt };
|
|
2550
|
-
}
|
|
2551
|
-
return a;
|
|
2552
|
-
});
|
|
2859
|
+
const agents = currentAgents;
|
|
2553
2860
|
// Filter shutdown ghosts: for same-type agents, keep if they overlapped (parallel)
|
|
2554
2861
|
// or started >30s after previous stopped (legitimate re-spawn). Filter the rest.
|
|
2555
2862
|
const byType = {};
|
|
@@ -2572,7 +2879,10 @@
|
|
|
2572
2879
|
.sort((a, b) => new Date(b.updatedAt || 0) - new Date(a.updatedAt || 0))
|
|
2573
2880
|
.slice(0, AGENT_LOG_MAX);
|
|
2574
2881
|
|
|
2575
|
-
|
|
2882
|
+
const permFresh = currentWaiting && currentWaiting.timestamp &&
|
|
2883
|
+
(now - new Date(currentWaiting.timestamp).getTime()) < 120000;
|
|
2884
|
+
|
|
2885
|
+
if (visible.length === 0 && !permFresh) {
|
|
2576
2886
|
footer.classList.remove('visible');
|
|
2577
2887
|
clearInterval(agentDurationInterval);
|
|
2578
2888
|
agentDurationInterval = null;
|
|
@@ -2586,7 +2896,11 @@
|
|
|
2586
2896
|
footer.classList.toggle('collapsed', collapsed);
|
|
2587
2897
|
document.getElementById('agent-footer-toggle').innerHTML = collapsed ? '▴' : '▾';
|
|
2588
2898
|
|
|
2589
|
-
|
|
2899
|
+
const permHtml = permFresh
|
|
2900
|
+
? `<div class="permission-badge">${currentWaiting.kind === 'question' ? '❓ Question pending' : '⏳ Awaiting: ' + escapeHtml(currentWaiting.toolName || 'unknown')}</div>`
|
|
2901
|
+
: '';
|
|
2902
|
+
|
|
2903
|
+
content.innerHTML = permHtml + visible.map(a => {
|
|
2590
2904
|
const elapsed = a.status === 'stopped' && a.stoppedAt
|
|
2591
2905
|
? new Date(a.stoppedAt).getTime() - new Date(a.startedAt || a.stoppedAt).getTime()
|
|
2592
2906
|
: now - new Date(a.startedAt || a.updatedAt).getTime();
|
|
@@ -2685,7 +2999,7 @@
|
|
|
2685
2999
|
viewMode = 'all';
|
|
2686
3000
|
currentSessionId = null;
|
|
2687
3001
|
ownerFilter = '';
|
|
2688
|
-
currentAgents = []; lastAgentsHash = ''; renderAgentFooter();
|
|
3002
|
+
currentAgents = []; currentWaiting = null; lastAgentsHash = ''; renderAgentFooter();
|
|
2689
3003
|
const res = await fetch('/api/tasks/all');
|
|
2690
3004
|
let tasks = await res.json();
|
|
2691
3005
|
if (filterProject) {
|
|
@@ -2728,7 +3042,12 @@
|
|
|
2728
3042
|
|
|
2729
3043
|
let filteredSessions = sessions;
|
|
2730
3044
|
if (sessionFilter === 'active') {
|
|
2731
|
-
|
|
3045
|
+
const ACTIVE_PLAN_MS = 15 * 60 * 1000;
|
|
3046
|
+
const RECENTLY_MODIFIED_MS = 5 * 60 * 1000;
|
|
3047
|
+
const now = Date.now();
|
|
3048
|
+
filteredSessions = filteredSessions.filter(s => s.pending > 0 || s.inProgress > 0 || s.hasActiveAgents || s.hasWaitingForUser ||
|
|
3049
|
+
(s.hasPlan && (now - new Date(s.modifiedAt).getTime()) <= ACTIVE_PLAN_MS) ||
|
|
3050
|
+
(now - new Date(s.modifiedAt).getTime()) <= RECENTLY_MODIFIED_MS);
|
|
2732
3051
|
}
|
|
2733
3052
|
if (filterProject) {
|
|
2734
3053
|
filteredSessions = filteredSessions.filter(s => matchesProjectFilter(s.project));
|
|
@@ -2804,15 +3123,15 @@
|
|
|
2804
3123
|
: modifiedDisplay;
|
|
2805
3124
|
|
|
2806
3125
|
// Build tooltip
|
|
2807
|
-
const tooltip = [timeDisplay, gitBranch ? `Branch: ${gitBranch}` : ''].filter(Boolean).join(' | ');
|
|
3126
|
+
const tooltip = [session.id, timeDisplay, gitBranch ? `Branch: ${gitBranch}` : ''].filter(Boolean).join(' | ');
|
|
2808
3127
|
|
|
2809
3128
|
const isTeam = session.isTeam;
|
|
2810
3129
|
const memberCount = session.memberCount || 0;
|
|
2811
3130
|
|
|
2812
3131
|
return `
|
|
2813
|
-
<button onclick="fetchTasks('${session.id}')" data-session-id="${session.id}" class="session-item ${isActive ? 'active' : ''}" title="${tooltip}">
|
|
3132
|
+
<button onclick="fetchTasks('${session.id}')" data-session-id="${session.id}" class="session-item ${isActive ? 'active' : ''} ${session.hasWaitingForUser ? 'permission-pending' : ''}" title="${tooltip}">
|
|
2814
3133
|
<div class="session-name">${escapeHtml(primaryName)}</div>
|
|
2815
|
-
${secondaryName ? `<div class="session-secondary">${escapeHtml(secondaryName)}</div>` : ''}
|
|
3134
|
+
${isActive && lastInlineMessage ? `<div class="session-latest-msg">${lastInlineMessage}</div>` : secondaryName ? `<div class="session-secondary">${escapeHtml(secondaryName)}</div>` : ''}
|
|
2816
3135
|
${gitBranch ? `<div class="session-branch">${gitBranch}</div>` : ''}
|
|
2817
3136
|
${session.planTitle ? `<div class="session-plan">${escapeHtml(session.planTitle)}</div>` : ''}
|
|
2818
3137
|
<div class="session-progress">
|
|
@@ -2820,6 +3139,8 @@
|
|
|
2820
3139
|
${isTeam ? `<span class="team-badge" title="${memberCount} team members"><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="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>${memberCount}</span>` : ''}
|
|
2821
3140
|
${(isTeam || session.project) ? `<span class="team-info-btn" onclick="event.stopPropagation(); showSessionInfoModal('${session.id}')" title="View session info">ℹ</span>` : ''}
|
|
2822
3141
|
${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>` : ''}
|
|
3142
|
+
${session.hasRunningAgents ? '<span class="agent-badge" title="Active agents">🤖</span>' : ''}
|
|
3143
|
+
${session.hasWaitingForUser ? '<span class="agent-badge" title="Waiting for user">❓</span>' : ''}
|
|
2823
3144
|
${hasInProgress ? '<span class="pulse"></span>' : ''}
|
|
2824
3145
|
</span>
|
|
2825
3146
|
<div class="progress-bar"><div class="progress-fill" style="width: ${percent}%"></div></div>
|
|
@@ -3517,7 +3838,12 @@
|
|
|
3517
3838
|
return;
|
|
3518
3839
|
}
|
|
3519
3840
|
|
|
3520
|
-
if (document.querySelector('.modal-overlay.visible'))
|
|
3841
|
+
if (document.querySelector('.modal-overlay.visible')) {
|
|
3842
|
+
if (e.key === 'Escape') {
|
|
3843
|
+
document.querySelectorAll('.modal-overlay.visible').forEach(m => m.classList.remove('visible'));
|
|
3844
|
+
}
|
|
3845
|
+
return;
|
|
3846
|
+
}
|
|
3521
3847
|
|
|
3522
3848
|
if (e.key === '[') {
|
|
3523
3849
|
e.preventDefault();
|
|
@@ -3525,6 +3851,12 @@
|
|
|
3525
3851
|
return;
|
|
3526
3852
|
}
|
|
3527
3853
|
|
|
3854
|
+
if ((e.key === 'L' || e.key === 'l') && e.shiftKey) {
|
|
3855
|
+
e.preventDefault();
|
|
3856
|
+
toggleMessagePanel();
|
|
3857
|
+
return;
|
|
3858
|
+
}
|
|
3859
|
+
|
|
3528
3860
|
// Tab toggles focus zone
|
|
3529
3861
|
if (e.key === 'Tab') {
|
|
3530
3862
|
e.preventDefault();
|
|
@@ -3605,8 +3937,9 @@
|
|
|
3605
3937
|
return;
|
|
3606
3938
|
}
|
|
3607
3939
|
|
|
3608
|
-
if (e.key === 'Escape'
|
|
3609
|
-
closeDetailPanel();
|
|
3940
|
+
if (e.key === 'Escape') {
|
|
3941
|
+
if (detailPanel.classList.contains('visible')) closeDetailPanel();
|
|
3942
|
+
else if (messagePanelOpen) toggleMessagePanel();
|
|
3610
3943
|
}
|
|
3611
3944
|
|
|
3612
3945
|
if (e.key === 'p' || e.key === 'P') {
|
|
@@ -3637,12 +3970,19 @@
|
|
|
3637
3970
|
function setupEventSource() {
|
|
3638
3971
|
let retryDelay = 1000;
|
|
3639
3972
|
let eventSource;
|
|
3973
|
+
let wasConnected = false;
|
|
3640
3974
|
|
|
3641
3975
|
function connect() {
|
|
3642
3976
|
eventSource = new EventSource('/api/events');
|
|
3643
3977
|
|
|
3644
3978
|
eventSource.onopen = () => {
|
|
3645
|
-
|
|
3979
|
+
if (wasConnected) {
|
|
3980
|
+
console.warn('[SSE] Reconnected after drop — forcing full refresh');
|
|
3981
|
+
fetchSessions().catch(() => {});
|
|
3982
|
+
if (currentSessionId) fetchTasks(currentSessionId);
|
|
3983
|
+
}
|
|
3984
|
+
wasConnected = true;
|
|
3985
|
+
retryDelay = 1000;
|
|
3646
3986
|
connectionStatus.innerHTML = `
|
|
3647
3987
|
<span class="connection-dot live"></span>
|
|
3648
3988
|
<span>Connected</span>
|
|
@@ -3651,24 +3991,37 @@
|
|
|
3651
3991
|
|
|
3652
3992
|
eventSource.onerror = () => {
|
|
3653
3993
|
eventSource.close();
|
|
3994
|
+
console.warn('[SSE] Connection lost, retrying in', retryDelay, 'ms');
|
|
3654
3995
|
connectionStatus.innerHTML = `
|
|
3655
3996
|
<span class="connection-dot error"></span>
|
|
3656
3997
|
<span>Reconnecting...</span>
|
|
3657
3998
|
`;
|
|
3658
3999
|
setTimeout(connect, retryDelay);
|
|
3659
|
-
retryDelay = Math.min(retryDelay * 2, 30000);
|
|
4000
|
+
retryDelay = Math.min(retryDelay * 2, 30000);
|
|
3660
4001
|
};
|
|
3661
4002
|
|
|
3662
|
-
let
|
|
4003
|
+
let taskRefreshTimer = null;
|
|
4004
|
+
let metadataRefreshTimer = null;
|
|
4005
|
+
let pendingTaskSessionIds = new Set();
|
|
4006
|
+
|
|
3663
4007
|
function debouncedRefresh(sessionId, isMetadata) {
|
|
3664
|
-
|
|
3665
|
-
|
|
3666
|
-
|
|
3667
|
-
|
|
3668
|
-
|
|
3669
|
-
|
|
3670
|
-
|
|
3671
|
-
|
|
4008
|
+
if (isMetadata) {
|
|
4009
|
+
clearTimeout(metadataRefreshTimer);
|
|
4010
|
+
metadataRefreshTimer = setTimeout(() => {
|
|
4011
|
+
fetchSessions().catch(err => console.error('[SSE] fetchSessions failed:', err));
|
|
4012
|
+
if (currentSessionId) fetchMessages(currentSessionId);
|
|
4013
|
+
}, 2000);
|
|
4014
|
+
} else {
|
|
4015
|
+
pendingTaskSessionIds.add(sessionId);
|
|
4016
|
+
clearTimeout(taskRefreshTimer);
|
|
4017
|
+
taskRefreshTimer = setTimeout(() => {
|
|
4018
|
+
fetchSessions().catch(err => console.error('[SSE] fetchSessions failed:', err));
|
|
4019
|
+
if (currentSessionId && pendingTaskSessionIds.has(currentSessionId)) {
|
|
4020
|
+
fetchTasks(currentSessionId);
|
|
4021
|
+
}
|
|
4022
|
+
pendingTaskSessionIds.clear();
|
|
4023
|
+
}, 500);
|
|
4024
|
+
}
|
|
3672
4025
|
}
|
|
3673
4026
|
|
|
3674
4027
|
eventSource.onmessage = (event) => {
|
|
@@ -3683,6 +4036,7 @@
|
|
|
3683
4036
|
}
|
|
3684
4037
|
|
|
3685
4038
|
if (data.type === 'agent-update') {
|
|
4039
|
+
fetchSessions().catch(err => console.error('[SSE] fetchSessions failed:', err));
|
|
3686
4040
|
if (currentSessionId && data.sessionId === currentSessionId) {
|
|
3687
4041
|
fetchAgents(currentSessionId);
|
|
3688
4042
|
}
|
|
@@ -3691,13 +4045,15 @@
|
|
|
3691
4045
|
if (data.type === 'team-update') {
|
|
3692
4046
|
console.log('[SSE] Team update:', data.teamName);
|
|
3693
4047
|
debouncedRefresh(data.teamName, false);
|
|
3694
|
-
if (currentSessionId && data.teamName === currentSessionId) {
|
|
3695
|
-
fetchTasks(currentSessionId);
|
|
3696
|
-
}
|
|
3697
4048
|
}
|
|
3698
4049
|
};
|
|
3699
4050
|
}
|
|
3700
4051
|
|
|
4052
|
+
// Fallback poll every 30s in case SSE silently drops
|
|
4053
|
+
setInterval(() => {
|
|
4054
|
+
fetchSessions().catch(() => {});
|
|
4055
|
+
}, 30000);
|
|
4056
|
+
|
|
3701
4057
|
connect();
|
|
3702
4058
|
}
|
|
3703
4059
|
|
|
@@ -4211,6 +4567,22 @@
|
|
|
4211
4567
|
});
|
|
4212
4568
|
</script>
|
|
4213
4569
|
|
|
4570
|
+
<!-- Message Detail Modal -->
|
|
4571
|
+
<div id="msg-detail-modal" class="modal-overlay" onclick="closeMsgDetailModal()">
|
|
4572
|
+
<div class="modal" onclick="event.stopPropagation()" style="max-width: 640px;">
|
|
4573
|
+
<div class="modal-header">
|
|
4574
|
+
<h3 class="modal-title" id="msg-detail-title">Message</h3>
|
|
4575
|
+
<button class="modal-close" aria-label="Close" onclick="closeMsgDetailModal()">
|
|
4576
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12"/></svg>
|
|
4577
|
+
</button>
|
|
4578
|
+
</div>
|
|
4579
|
+
<div class="modal-body">
|
|
4580
|
+
<div id="msg-detail-body" style="white-space: pre-wrap; word-break: break-word; font-size: 13px; line-height: 1.6; max-height: 60vh; overflow-y: auto;"></div>
|
|
4581
|
+
<div id="msg-detail-meta" style="margin-top: 12px; font-size: 11px; color: var(--text-muted);"></div>
|
|
4582
|
+
</div>
|
|
4583
|
+
</div>
|
|
4584
|
+
</div>
|
|
4585
|
+
|
|
4214
4586
|
<!-- Help Modal -->
|
|
4215
4587
|
<div id="help-modal" class="modal-overlay" onclick="closeHelpModal()">
|
|
4216
4588
|
<div class="modal" onclick="event.stopPropagation()">
|
|
@@ -4285,6 +4657,10 @@
|
|
|
4285
4657
|
<td style="padding: 4px 0; color: var(--text-secondary);"><kbd style="background: var(--bg-hover); padding: 2px 6px; border-radius: 4px; font-family: monospace;">D</kbd></td>
|
|
4286
4658
|
<td style="padding: 4px 0; color: var(--text-primary);">Delete selected task</td>
|
|
4287
4659
|
</tr>
|
|
4660
|
+
<tr>
|
|
4661
|
+
<td style="padding: 4px 0; color: var(--text-secondary);"><kbd style="background: var(--bg-hover); padding: 2px 6px; border-radius: 4px; font-family: monospace;">Shift+L</kbd></td>
|
|
4662
|
+
<td style="padding: 4px 0; color: var(--text-primary);">Toggle session log</td>
|
|
4663
|
+
</tr>
|
|
4288
4664
|
</table>
|
|
4289
4665
|
</div>
|
|
4290
4666
|
</div>
|
package/server.js
CHANGED
|
@@ -43,6 +43,70 @@ const TEAMS_DIR = path.join(CLAUDE_DIR, 'teams');
|
|
|
43
43
|
const PLANS_DIR = path.join(CLAUDE_DIR, 'plans');
|
|
44
44
|
const AGENT_ACTIVITY_DIR = path.join(CLAUDE_DIR, 'agent-activity');
|
|
45
45
|
|
|
46
|
+
const PERMISSION_TTL_MS = 1800000;
|
|
47
|
+
const AGENT_TTL_MS = 3600000;
|
|
48
|
+
const AGENT_STALE_MS = 120000;
|
|
49
|
+
|
|
50
|
+
function checkWaitingForUser(agentDir) {
|
|
51
|
+
try {
|
|
52
|
+
const waitFile = path.join(agentDir, '_waiting.json');
|
|
53
|
+
if (!existsSync(waitFile)) return null;
|
|
54
|
+
const data = JSON.parse(readFileSync(waitFile, 'utf8'));
|
|
55
|
+
if (data.status === 'waiting' && data.timestamp) {
|
|
56
|
+
const age = Date.now() - new Date(data.timestamp).getTime();
|
|
57
|
+
if (age < PERMISSION_TTL_MS) return data;
|
|
58
|
+
}
|
|
59
|
+
} catch (e) { /* skip invalid */ }
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function isAgentFresh(agent) {
|
|
64
|
+
if (!agent.updatedAt) return true;
|
|
65
|
+
return (Date.now() - new Date(agent.updatedAt).getTime()) < AGENT_TTL_MS;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function isSessionStale(meta) {
|
|
69
|
+
if (!meta.jsonlPath) return false;
|
|
70
|
+
try {
|
|
71
|
+
const mtime = statSync(meta.jsonlPath).mtime;
|
|
72
|
+
return (Date.now() - mtime.getTime()) > AGENT_STALE_MS;
|
|
73
|
+
} catch (e) { return false; }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function checkActiveAgents(sessionId, meta) {
|
|
77
|
+
const teamConfig = loadTeamConfig(sessionId);
|
|
78
|
+
const resolvedId = (teamConfig && teamConfig.leadSessionId) ? teamConfig.leadSessionId : sessionId;
|
|
79
|
+
const agentDir = path.join(AGENT_ACTIVITY_DIR, resolvedId);
|
|
80
|
+
if (!existsSync(agentDir)) return false;
|
|
81
|
+
try {
|
|
82
|
+
if (meta && isSessionStale(meta)) return false;
|
|
83
|
+
if (checkWaitingForUser(agentDir)) return true;
|
|
84
|
+
for (const file of readdirSync(agentDir).filter(f => f.endsWith('.json') && !f.startsWith('_'))) {
|
|
85
|
+
try {
|
|
86
|
+
const agent = JSON.parse(readFileSync(path.join(agentDir, file), 'utf8'));
|
|
87
|
+
if ((agent.status === 'active' || agent.status === 'idle') && isAgentFresh(agent)) {
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
} catch (e) { /* skip invalid */ }
|
|
91
|
+
}
|
|
92
|
+
} catch (e) { /* ignore */ }
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function checkRunningAgents(agentDir, meta) {
|
|
97
|
+
if (!existsSync(agentDir)) return false;
|
|
98
|
+
if (meta && isSessionStale(meta)) return false;
|
|
99
|
+
try {
|
|
100
|
+
for (const file of readdirSync(agentDir).filter(f => f.endsWith('.json') && !f.startsWith('_'))) {
|
|
101
|
+
try {
|
|
102
|
+
const agent = JSON.parse(readFileSync(path.join(agentDir, file), 'utf8'));
|
|
103
|
+
if (agent.status === 'active' && isAgentFresh(agent)) return true;
|
|
104
|
+
} catch (e) { /* skip invalid */ }
|
|
105
|
+
}
|
|
106
|
+
} catch (e) { /* ignore */ }
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
|
|
46
110
|
function isTeamSession(sessionId) {
|
|
47
111
|
return existsSync(path.join(TEAMS_DIR, sessionId, 'config.json'));
|
|
48
112
|
}
|
|
@@ -78,55 +142,121 @@ app.use(express.json());
|
|
|
78
142
|
// Serve static files
|
|
79
143
|
app.use(express.static(path.join(__dirname, 'public')));
|
|
80
144
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
* Returns { customTitle, slug } - customTitle from /rename, slug from session
|
|
84
|
-
*/
|
|
85
|
-
function readSessionInfoFromJsonl(jsonlPath) {
|
|
86
|
-
const result = { customTitle: null, slug: null, projectPath: null };
|
|
145
|
+
const messageCache = new Map();
|
|
146
|
+
const MESSAGE_CACHE_TTL = 5000;
|
|
87
147
|
|
|
148
|
+
function readRecentMessages(jsonlPath, limit = 10) {
|
|
149
|
+
let fd;
|
|
88
150
|
try {
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
const bytesRead = require('fs').readSync(fd, buffer, 0, 65536, 0);
|
|
95
|
-
require('fs').closeSync(fd);
|
|
96
|
-
|
|
97
|
-
const content = buffer.toString('utf8', 0, bytesRead);
|
|
98
|
-
const lines = content.split('\n');
|
|
99
|
-
|
|
100
|
-
for (const line of lines) {
|
|
101
|
-
if (!line.trim()) continue;
|
|
102
|
-
try {
|
|
103
|
-
const data = JSON.parse(line);
|
|
151
|
+
const stat = statSync(jsonlPath);
|
|
152
|
+
const cached = messageCache.get(jsonlPath);
|
|
153
|
+
if (cached && cached.mtime === stat.mtimeMs && Date.now() - cached.ts < MESSAGE_CACHE_TTL) {
|
|
154
|
+
return cached.messages;
|
|
155
|
+
}
|
|
104
156
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
}
|
|
157
|
+
fd = require('fs').openSync(jsonlPath, 'r');
|
|
158
|
+
const messages = [];
|
|
159
|
+
let readSize = 65536;
|
|
109
160
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
161
|
+
while (messages.length < limit && readSize <= stat.size) {
|
|
162
|
+
const start = Math.max(0, stat.size - readSize);
|
|
163
|
+
const bufSize = Math.min(readSize, stat.size);
|
|
164
|
+
const buf = Buffer.alloc(bufSize);
|
|
165
|
+
require('fs').readSync(fd, buf, 0, bufSize, start);
|
|
114
166
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
}
|
|
167
|
+
const text = buf.toString('utf8');
|
|
168
|
+
const firstNewline = text.indexOf('\n');
|
|
169
|
+
const clean = firstNewline >= 0 ? text.substring(firstNewline + 1) : text;
|
|
119
170
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
171
|
+
messages.length = 0;
|
|
172
|
+
for (const line of clean.split('\n')) {
|
|
173
|
+
if (!line.trim()) continue;
|
|
174
|
+
try {
|
|
175
|
+
const obj = JSON.parse(line);
|
|
176
|
+
if (obj.type === 'assistant' && obj.message?.content && Array.isArray(obj.message.content)) {
|
|
177
|
+
for (const block of obj.message.content) {
|
|
178
|
+
if (block.type === 'text' && block.text) {
|
|
179
|
+
const truncated = block.text.length > 500;
|
|
180
|
+
messages.push({
|
|
181
|
+
type: 'assistant',
|
|
182
|
+
text: truncated ? block.text.slice(0, 500) + '...' : block.text,
|
|
183
|
+
fullText: truncated ? block.text : null,
|
|
184
|
+
timestamp: obj.timestamp,
|
|
185
|
+
model: obj.message.model || null
|
|
186
|
+
});
|
|
187
|
+
} else if (block.type === 'tool_use') {
|
|
188
|
+
let detail = null;
|
|
189
|
+
let fullDetail = null;
|
|
190
|
+
let inp = null;
|
|
191
|
+
if (block.input) {
|
|
192
|
+
inp = typeof block.input === 'string' ? (() => { try { return JSON.parse(block.input); } catch(_) { return {}; } })() : block.input;
|
|
193
|
+
if (inp.file_path) { detail = inp.file_path.replace(/^.*[/\\]/, ''); fullDetail = inp.file_path; }
|
|
194
|
+
else if (inp.command) { detail = inp.command.length > 80 ? inp.command.slice(0, 80) + '...' : inp.command; fullDetail = inp.command; }
|
|
195
|
+
else if (inp.pattern) { detail = inp.pattern; fullDetail = inp.pattern; }
|
|
196
|
+
else if (inp.query) { detail = inp.query; fullDetail = inp.query; }
|
|
197
|
+
else if (inp.description) { detail = inp.description; fullDetail = inp.description; }
|
|
198
|
+
}
|
|
199
|
+
messages.push({
|
|
200
|
+
type: 'tool_use',
|
|
201
|
+
tool: block.name,
|
|
202
|
+
detail,
|
|
203
|
+
fullDetail: fullDetail !== detail ? fullDetail : null,
|
|
204
|
+
description: inp?.description || null,
|
|
205
|
+
timestamp: obj.timestamp
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
} else if (obj.type === 'user' && obj.message?.role === 'user' && !obj.isMeta) {
|
|
210
|
+
if (typeof obj.message.content === 'string') {
|
|
211
|
+
const t = obj.message.content;
|
|
212
|
+
const uTruncated = t.length > 500;
|
|
213
|
+
messages.push({
|
|
214
|
+
type: 'user',
|
|
215
|
+
text: uTruncated ? t.slice(0, 500) + '...' : t,
|
|
216
|
+
fullText: uTruncated ? t : null,
|
|
217
|
+
timestamp: obj.timestamp
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
} catch (e) { /* partial line */ }
|
|
124
222
|
}
|
|
223
|
+
|
|
224
|
+
if (readSize >= stat.size) break;
|
|
225
|
+
readSize *= 4;
|
|
125
226
|
}
|
|
227
|
+
|
|
228
|
+
require('fs').closeSync(fd);
|
|
229
|
+
fd = null;
|
|
230
|
+
const result = messages.slice(-limit);
|
|
231
|
+
messageCache.set(jsonlPath, { messages: result, mtime: stat.mtimeMs, ts: Date.now() });
|
|
232
|
+
return result;
|
|
126
233
|
} catch (e) {
|
|
127
|
-
|
|
234
|
+
if (fd) try { require('fs').closeSync(fd); } catch (_) {}
|
|
235
|
+
return [];
|
|
128
236
|
}
|
|
237
|
+
}
|
|
129
238
|
|
|
239
|
+
/**
|
|
240
|
+
* Extract slug and projectPath from first few lines of a session JSONL.
|
|
241
|
+
* Slug and cwd appear in the first few entries, so 4KB is plenty.
|
|
242
|
+
*/
|
|
243
|
+
function readSessionInfoFromJsonl(jsonlPath) {
|
|
244
|
+
const result = { slug: null, projectPath: null };
|
|
245
|
+
try {
|
|
246
|
+
if (!existsSync(jsonlPath)) return result;
|
|
247
|
+
const fd = require('fs').openSync(jsonlPath, 'r');
|
|
248
|
+
const buf = Buffer.alloc(4096);
|
|
249
|
+
const n = require('fs').readSync(fd, buf, 0, 4096, 0);
|
|
250
|
+
require('fs').closeSync(fd);
|
|
251
|
+
for (const line of buf.toString('utf8', 0, n).split('\n')) {
|
|
252
|
+
try {
|
|
253
|
+
const data = JSON.parse(line);
|
|
254
|
+
if (data.slug) result.slug = data.slug;
|
|
255
|
+
if (data.cwd) result.projectPath = data.cwd;
|
|
256
|
+
if (result.slug && result.projectPath) break;
|
|
257
|
+
} catch (e) {}
|
|
258
|
+
}
|
|
259
|
+
} catch (e) {}
|
|
130
260
|
return result;
|
|
131
261
|
}
|
|
132
262
|
|
|
@@ -168,7 +298,6 @@ function loadSessionMetadata() {
|
|
|
168
298
|
}
|
|
169
299
|
|
|
170
300
|
metadata[sessionId] = {
|
|
171
|
-
customTitle: sessionInfo.customTitle,
|
|
172
301
|
slug: sessionInfo.slug,
|
|
173
302
|
project: sessionInfo.projectPath || null,
|
|
174
303
|
jsonlPath: jsonlPath
|
|
@@ -196,7 +325,6 @@ function loadSessionMetadata() {
|
|
|
196
325
|
if (entry.sessionId) {
|
|
197
326
|
if (!metadata[entry.sessionId]) {
|
|
198
327
|
metadata[entry.sessionId] = {
|
|
199
|
-
customTitle: null,
|
|
200
328
|
slug: null,
|
|
201
329
|
project: entry.projectPath || null,
|
|
202
330
|
jsonlPath: null
|
|
@@ -229,8 +357,7 @@ function loadSessionMetadata() {
|
|
|
229
357
|
const project = parentMeta?.project || leadMember?.cwd || teamConfig.working_dir || null;
|
|
230
358
|
|
|
231
359
|
metadata[dir.name] = {
|
|
232
|
-
|
|
233
|
-
slug: null,
|
|
360
|
+
slug: teamConfig.description || dir.name,
|
|
234
361
|
project,
|
|
235
362
|
jsonlPath: parentMeta?.jsonlPath || null,
|
|
236
363
|
description: teamConfig.description || parentMeta?.description || null,
|
|
@@ -249,9 +376,6 @@ function loadSessionMetadata() {
|
|
|
249
376
|
return metadata;
|
|
250
377
|
}
|
|
251
378
|
|
|
252
|
-
/**
|
|
253
|
-
* Get display name for a session: customTitle > slug > null (frontend shows UUID)
|
|
254
|
-
*/
|
|
255
379
|
function getPlanInfo(slug) {
|
|
256
380
|
if (!slug) return { hasPlan: false, planTitle: null };
|
|
257
381
|
const planPath = path.join(PLANS_DIR, `${slug}.md`);
|
|
@@ -266,7 +390,6 @@ function getPlanInfo(slug) {
|
|
|
266
390
|
}
|
|
267
391
|
|
|
268
392
|
function getSessionDisplayName(sessionId, meta) {
|
|
269
|
-
if (meta?.customTitle) return meta.customTitle;
|
|
270
393
|
if (meta?.slug) return meta.slug;
|
|
271
394
|
return null; // Frontend will show UUID as fallback
|
|
272
395
|
}
|
|
@@ -331,6 +454,12 @@ app.get('/api/sessions', async (req, res) => {
|
|
|
331
454
|
const memberCount = isTeam ? (loadTeamConfig(entry.name)?.members?.length || 0) : 0;
|
|
332
455
|
const planInfo = getPlanInfo(meta.slug);
|
|
333
456
|
|
|
457
|
+
const resolvedAgentDir = (() => {
|
|
458
|
+
const tc = loadTeamConfig(entry.name);
|
|
459
|
+
const rid = (tc && tc.leadSessionId) ? tc.leadSessionId : entry.name;
|
|
460
|
+
return path.join(AGENT_ACTIVITY_DIR, rid);
|
|
461
|
+
})();
|
|
462
|
+
|
|
334
463
|
sessionsMap.set(entry.name, {
|
|
335
464
|
id: entry.name,
|
|
336
465
|
name: getSessionDisplayName(entry.name, meta),
|
|
@@ -346,6 +475,9 @@ app.get('/api/sessions', async (req, res) => {
|
|
|
346
475
|
modifiedAt: modifiedAt,
|
|
347
476
|
isTeam,
|
|
348
477
|
memberCount,
|
|
478
|
+
hasActiveAgents: checkActiveAgents(entry.name, meta),
|
|
479
|
+
hasRunningAgents: checkRunningAgents(resolvedAgentDir, meta),
|
|
480
|
+
hasWaitingForUser: !!checkWaitingForUser(resolvedAgentDir),
|
|
349
481
|
...planInfo
|
|
350
482
|
});
|
|
351
483
|
}
|
|
@@ -360,6 +492,7 @@ app.get('/api/sessions', async (req, res) => {
|
|
|
360
492
|
try { modifiedAt = statSync(meta.jsonlPath).mtime.toISOString(); } catch (e) {}
|
|
361
493
|
}
|
|
362
494
|
const planInfo = getPlanInfo(meta.slug);
|
|
495
|
+
const metaAgentDir = path.join(AGENT_ACTIVITY_DIR, sessionId);
|
|
363
496
|
sessionsMap.set(sessionId, {
|
|
364
497
|
id: sessionId,
|
|
365
498
|
name: getSessionDisplayName(sessionId, meta),
|
|
@@ -375,11 +508,45 @@ app.get('/api/sessions', async (req, res) => {
|
|
|
375
508
|
modifiedAt: modifiedAt || new Date(0).toISOString(),
|
|
376
509
|
isTeam: false,
|
|
377
510
|
memberCount: 0,
|
|
511
|
+
hasActiveAgents: checkActiveAgents(sessionId, meta),
|
|
512
|
+
hasRunningAgents: checkRunningAgents(metaAgentDir, meta),
|
|
513
|
+
hasWaitingForUser: !!checkWaitingForUser(metaAgentDir),
|
|
378
514
|
...planInfo
|
|
379
515
|
});
|
|
380
516
|
}
|
|
381
517
|
}
|
|
382
518
|
|
|
519
|
+
// Add sessions from agent-activity that have _waiting.json but no tasks/metadata
|
|
520
|
+
if (existsSync(AGENT_ACTIVITY_DIR)) {
|
|
521
|
+
try {
|
|
522
|
+
for (const dir of readdirSync(AGENT_ACTIVITY_DIR, { withFileTypes: true })) {
|
|
523
|
+
if (!dir.isDirectory() || sessionsMap.has(dir.name)) continue;
|
|
524
|
+
const agentDir = path.join(AGENT_ACTIVITY_DIR, dir.name);
|
|
525
|
+
const waiting = checkWaitingForUser(agentDir);
|
|
526
|
+
if (!waiting) continue;
|
|
527
|
+
const meta = metadata[dir.name] || {};
|
|
528
|
+
sessionsMap.set(dir.name, {
|
|
529
|
+
id: dir.name,
|
|
530
|
+
name: getSessionDisplayName(dir.name, meta),
|
|
531
|
+
slug: meta.slug || null,
|
|
532
|
+
project: meta.project || null,
|
|
533
|
+
description: meta.description || null,
|
|
534
|
+
gitBranch: meta.gitBranch || null,
|
|
535
|
+
taskCount: 0,
|
|
536
|
+
completed: 0,
|
|
537
|
+
inProgress: 0,
|
|
538
|
+
pending: 0,
|
|
539
|
+
createdAt: meta.created || null,
|
|
540
|
+
modifiedAt: waiting.timestamp || new Date().toISOString(),
|
|
541
|
+
isTeam: false,
|
|
542
|
+
memberCount: 0,
|
|
543
|
+
hasActiveAgents: true,
|
|
544
|
+
hasWaitingForUser: true,
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
} catch (e) { /* ignore */ }
|
|
548
|
+
}
|
|
549
|
+
|
|
383
550
|
// Hide leader UUID sessions that are represented by a team session
|
|
384
551
|
const teamLeaderIds = new Set();
|
|
385
552
|
for (const [sid, session] of sessionsMap) {
|
|
@@ -515,21 +682,42 @@ app.get('/api/sessions/:sessionId/agents', (req, res) => {
|
|
|
515
682
|
sessionId = teamConfig.leadSessionId;
|
|
516
683
|
}
|
|
517
684
|
const agentDir = path.join(AGENT_ACTIVITY_DIR, sessionId);
|
|
518
|
-
if (!existsSync(agentDir)) return res.json([]);
|
|
685
|
+
if (!existsSync(agentDir)) return res.json({ agents: [], waitingForUser: null });
|
|
519
686
|
try {
|
|
520
|
-
const
|
|
687
|
+
const metadata = loadSessionMetadata();
|
|
688
|
+
const meta = metadata[sessionId] || {};
|
|
689
|
+
const sessionStale = isSessionStale(meta);
|
|
690
|
+
|
|
691
|
+
const files = readdirSync(agentDir).filter(f => f.endsWith('.json') && !f.startsWith('_'));
|
|
521
692
|
const agents = [];
|
|
522
693
|
for (const file of files) {
|
|
523
694
|
try {
|
|
524
|
-
|
|
695
|
+
const agent = JSON.parse(readFileSync(path.join(agentDir, file), 'utf8'));
|
|
696
|
+
if (!isAgentFresh(agent)) continue;
|
|
697
|
+
if (sessionStale && (agent.status === 'active' || agent.status === 'idle')) {
|
|
698
|
+
agent.status = 'stopped';
|
|
699
|
+
if (!agent.stoppedAt) agent.stoppedAt = agent.updatedAt || agent.startedAt;
|
|
700
|
+
}
|
|
701
|
+
agents.push(agent);
|
|
525
702
|
} catch (e) { /* skip invalid */ }
|
|
526
703
|
}
|
|
527
|
-
|
|
704
|
+
const waitingForUser = checkWaitingForUser(agentDir);
|
|
705
|
+
res.json({ agents, waitingForUser });
|
|
528
706
|
} catch (e) {
|
|
529
|
-
res.json([]);
|
|
707
|
+
res.json({ agents: [], waitingForUser: null });
|
|
530
708
|
}
|
|
531
709
|
});
|
|
532
710
|
|
|
711
|
+
app.get('/api/sessions/:sessionId/messages', (req, res) => {
|
|
712
|
+
const limit = Math.min(parseInt(req.query.limit, 10) || 10, 50);
|
|
713
|
+
const metadata = loadSessionMetadata();
|
|
714
|
+
const meta = metadata[req.params.sessionId];
|
|
715
|
+
const jsonlPath = meta?.jsonlPath;
|
|
716
|
+
if (!jsonlPath) return res.json({ messages: [], sessionId: req.params.sessionId });
|
|
717
|
+
const messages = readRecentMessages(jsonlPath, limit);
|
|
718
|
+
res.json({ messages, sessionId: req.params.sessionId });
|
|
719
|
+
});
|
|
720
|
+
|
|
533
721
|
app.get('/api/version', (req, res) => {
|
|
534
722
|
const pkg = require('./package.json');
|
|
535
723
|
res.json({ version: pkg.version });
|
|
@@ -675,9 +863,14 @@ app.get('/api/events', (req, res) => {
|
|
|
675
863
|
res.setHeader('Connection', 'keep-alive');
|
|
676
864
|
|
|
677
865
|
clients.add(res);
|
|
866
|
+
console.log(`[SSE] Client connected (total: ${clients.size})`);
|
|
867
|
+
|
|
868
|
+
const heartbeat = setInterval(() => res.write(':heartbeat\n\n'), 30000);
|
|
678
869
|
|
|
679
870
|
req.on('close', () => {
|
|
871
|
+
clearInterval(heartbeat);
|
|
680
872
|
clients.delete(res);
|
|
873
|
+
console.log(`[SSE] Client disconnected (total: ${clients.size})`);
|
|
681
874
|
});
|
|
682
875
|
|
|
683
876
|
// Send initial ping
|
|
@@ -782,14 +975,14 @@ const agentActivityWatcher = chokidar.watch(AGENT_ACTIVITY_DIR, {
|
|
|
782
975
|
const AGENT_FILE_CAP = 20;
|
|
783
976
|
|
|
784
977
|
agentActivityWatcher.on('all', (event, filePath) => {
|
|
785
|
-
if ((event === 'add' || event === 'change') && filePath.endsWith('.json')) {
|
|
978
|
+
if ((event === 'add' || event === 'change' || event === 'unlink') && filePath.endsWith('.json')) {
|
|
786
979
|
const relativePath = path.relative(AGENT_ACTIVITY_DIR, filePath);
|
|
787
980
|
const sessionId = relativePath.split(path.sep)[0];
|
|
788
981
|
// Cleanup: if session dir exceeds cap, delete oldest files by mtime
|
|
789
982
|
if (event === 'add') {
|
|
790
983
|
try {
|
|
791
984
|
const sessionDir = path.join(AGENT_ACTIVITY_DIR, sessionId);
|
|
792
|
-
const files = readdirSync(sessionDir).filter(f => f.endsWith('.json'));
|
|
985
|
+
const files = readdirSync(sessionDir).filter(f => f.endsWith('.json') && !f.startsWith('_'));
|
|
793
986
|
if (files.length > AGENT_FILE_CAP) {
|
|
794
987
|
const withStats = files.map(f => {
|
|
795
988
|
const fp = path.join(sessionDir, f);
|