claude-code-kanban 4.25.0 → 4.26.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/README.md +1 -1
- package/install.js +16 -1
- package/lib/approvals.js +34 -3
- package/lib/parsers.js +64 -0
- package/package.json +1 -1
- package/plugin/plugins/claude-code-kanban/.claude-plugin/plugin.json +1 -1
- package/plugin/plugins/claude-code-kanban/scripts/approval-gate.sh +53 -18
- package/public/app.js +197 -22
- package/public/index.html +14 -0
- package/public/style.css +154 -14
- package/server.js +127 -23
package/README.md
CHANGED
|
@@ -51,7 +51,7 @@ Tasks, agents, and messages appear on the board automatically — Claude Code wr
|
|
|
51
51
|
- **Follow & pin** — Follow the latest message live (`Shift+M`), pin the messages that matter
|
|
52
52
|
- **Tool stats & impact** — Per-session tool usage breakdown and file impact
|
|
53
53
|
- **Waiting-for-user indicators** — Amber highlight on sessions needing permission or input
|
|
54
|
-
- **UI approvals
|
|
54
|
+
- **UI approvals** — Allow/deny permission asks and answer questions from the board; on by default, opt out per config dir — [docs](docs/ui-approvals.md)
|
|
55
55
|
- **Agent teams** — Color-coded team members, owner filtering, member count badges
|
|
56
56
|
- **17 color themes** — Dracula, Nord, Catppuccin, Gruvbox, Tokyo Night, and more — each in light and dark
|
|
57
57
|
- **Storage manager** — Inspect disk usage and clean up stale sessions and tasks
|
package/install.js
CHANGED
|
@@ -130,12 +130,27 @@ async function runInstall({ pluginOnly = false } = {}) {
|
|
|
130
130
|
if (!upd.ok) console.log(` ${yellow('⚠')} Marketplace refresh failed: ${upd.error}`);
|
|
131
131
|
|
|
132
132
|
const inst = runCLI('claude plugin install claude-code-kanban@claude-code-kanban', ['already installed', 'already exists']);
|
|
133
|
+
const alreadyInstalled = inst.idempotent || /already installed/i.test(inst.output || '');
|
|
133
134
|
if (inst.ok) {
|
|
134
|
-
console.log(` ${green('✓')} ${
|
|
135
|
+
console.log(` ${green('✓')} ${alreadyInstalled ? 'Already installed' : 'Plugin installed'}`);
|
|
135
136
|
} else {
|
|
136
137
|
console.log(` ${red('✗')} Plugin install failed: ${inst.error}`);
|
|
137
138
|
failed = true;
|
|
138
139
|
}
|
|
140
|
+
|
|
141
|
+
// `install` is a no-op once the plugin is present — it exits 0 and only says so
|
|
142
|
+
// on stdout — so a re-copied plugin keeps running from the old cached version
|
|
143
|
+
// until `update` pulls the new one in. That is how a config dir ends up serving
|
|
144
|
+
// a plugin older than the board it talks to.
|
|
145
|
+
if (alreadyInstalled) {
|
|
146
|
+
const upgrade = runCLI('claude plugin update claude-code-kanban', ['already at the latest']);
|
|
147
|
+
if (upgrade.ok) {
|
|
148
|
+
const line = (upgrade.output || '').split('\n').find(l => l.includes('updated from'));
|
|
149
|
+
console.log(` ${green('✓')} ${line ? line.replace(/^[^A-Za-z]*/, '') : 'Already at the latest version'}`);
|
|
150
|
+
} else {
|
|
151
|
+
console.log(` ${yellow('⚠')} Plugin update failed: ${upgrade.error}`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
139
154
|
} else {
|
|
140
155
|
console.log(` ${dim('Skipped')}`);
|
|
141
156
|
}
|
package/lib/approvals.js
CHANGED
|
@@ -63,20 +63,51 @@ function isDecisionFile(name) {
|
|
|
63
63
|
// (PERMISSION_TTL_MS hides the card at 30 min, so waiting longer than the UI
|
|
64
64
|
// can show the ask is strictly worse — D11); keep them in sync with
|
|
65
65
|
// approval-gate.sh.
|
|
66
|
-
const WAIT_SECONDS_DEFAULT =
|
|
66
|
+
const WAIT_SECONDS_DEFAULT = 1800;
|
|
67
67
|
const WAIT_SECONDS_MAX = 1800;
|
|
68
68
|
// Small grace over the gate's own deadline so a race never 410s a live hook
|
|
69
69
|
const LAPSE_GRACE_MS = 5000;
|
|
70
|
-
|
|
71
70
|
function waitSecondsFrom(cfg) {
|
|
72
71
|
const raw = cfg && cfg.waitSeconds;
|
|
73
72
|
if (Number.isInteger(raw) && raw >= 0) return Math.min(raw, WAIT_SECONDS_MAX);
|
|
74
73
|
return WAIT_SECONDS_DEFAULT;
|
|
75
74
|
}
|
|
76
75
|
|
|
76
|
+
// Normalizes the `approvals` section of <CCK_DIR>/config.json. The gate is on
|
|
77
|
+
// by default: only an explicit `enabled: false` turns it off, and anything
|
|
78
|
+
// unparseable falls back to defaults — the same parse approval-gate.sh does.
|
|
79
|
+
function approvalsFrom(cfg) {
|
|
80
|
+
const a = cfg && typeof cfg.approvals === 'object' && cfg.approvals ? cfg.approvals : {};
|
|
81
|
+
return {
|
|
82
|
+
enabled: a.enabled !== false,
|
|
83
|
+
mode: a.mode === 'permission' ? 'permission' : 'permission+question',
|
|
84
|
+
waitSeconds: waitSecondsFrom(a),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Whether the hook waits for a board decision on a marker of this kind
|
|
89
|
+
function isKindGated(kind, approvals) {
|
|
90
|
+
if (!approvals.enabled) return false;
|
|
91
|
+
if (kind === 'question') return approvals.mode === 'permission+question';
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Why the board may not answer this marker, or null when it may. Shared by the
|
|
96
|
+
// session list (which hides the buttons) and the respond route (which refuses),
|
|
97
|
+
// so the two can never disagree.
|
|
98
|
+
function boardRefusal(marker, approvals, now = Date.now()) {
|
|
99
|
+
if (!isKindGated(marker.kind, approvals)) {
|
|
100
|
+
return { status: 410, error: 'UI approvals are off for this ask — answer it in the terminal' };
|
|
101
|
+
}
|
|
102
|
+
if (isLapsed(marker.timestamp, approvals.waitSeconds * 1000, now)) {
|
|
103
|
+
return { status: 410, error: 'Ask lapsed — answer it in the terminal' };
|
|
104
|
+
}
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
77
108
|
function isLapsed(timestamp, waitMs, now = Date.now()) {
|
|
78
109
|
if (!timestamp) return true;
|
|
79
110
|
return now - new Date(timestamp).getTime() > waitMs + LAPSE_GRACE_MS;
|
|
80
111
|
}
|
|
81
112
|
|
|
82
|
-
module.exports = { sanitizeRequestId, buildDecision, decisionFileName, isDecisionFile, waitSecondsFrom, isLapsed };
|
|
113
|
+
module.exports = { sanitizeRequestId, buildDecision, decisionFileName, isDecisionFile, waitSecondsFrom, approvalsFrom, isKindGated, boardRefusal, isLapsed };
|
package/lib/parsers.js
CHANGED
|
@@ -1296,6 +1296,69 @@ function buildAgentProgressMap(jsonlPath) {
|
|
|
1296
1296
|
return buildSessionDigest(jsonlPath).progressMap;
|
|
1297
1297
|
}
|
|
1298
1298
|
|
|
1299
|
+
const ARTIFACT_URL_RE = /https:\/\/claude\.ai\/\S*artifact\/[A-Za-z0-9_-]+/;
|
|
1300
|
+
|
|
1301
|
+
function toolResultText(content) {
|
|
1302
|
+
if (typeof content === 'string') return content;
|
|
1303
|
+
if (!Array.isArray(content)) return '';
|
|
1304
|
+
return content.filter(b => b?.type === 'text' && b.text).map(b => b.text).join('\n');
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
// Two kinds of line record a published artifact: the `frame-link` entry the
|
|
1308
|
+
// viewer writes, which carries the title, and the result of an Artifact publish,
|
|
1309
|
+
// which is there even when the artifact was never opened in a frame. Keyed by URL,
|
|
1310
|
+
// so the republishes of one page collapse into a single entry. A result counts
|
|
1311
|
+
// only when its tool_use_id belongs to a publish call — an artifact URL quoted
|
|
1312
|
+
// back by a grep or a file read would otherwise register another session's page as
|
|
1313
|
+
// this session's own, and a `read` of someone else's artifact is not a publish.
|
|
1314
|
+
function readArtifactLinks(jsonlPath) {
|
|
1315
|
+
const byUrl = new Map();
|
|
1316
|
+
const publishedFileByToolUseId = new Map();
|
|
1317
|
+
const upsert = (url, ts) => {
|
|
1318
|
+
let a = byUrl.get(url);
|
|
1319
|
+
if (!a) {
|
|
1320
|
+
a = { url, title: null, path: null, firstSeen: ts || null };
|
|
1321
|
+
byUrl.set(url, a);
|
|
1322
|
+
}
|
|
1323
|
+
if (ts && (!a.firstSeen || ts < a.firstSeen)) a.firstSeen = ts;
|
|
1324
|
+
return a;
|
|
1325
|
+
};
|
|
1326
|
+
try {
|
|
1327
|
+
const content = readFileSync(jsonlPath, 'utf8');
|
|
1328
|
+
for (const line of content.split('\n')) {
|
|
1329
|
+
if (!line.includes('"frame-link"') && !line.includes('"name":"Artifact"') && !line.includes('claude.ai'))
|
|
1330
|
+
continue;
|
|
1331
|
+
let obj;
|
|
1332
|
+
try { obj = JSON.parse(line); } catch (_) { continue; }
|
|
1333
|
+
if (obj.type === 'frame-link' && obj.frameUrl) {
|
|
1334
|
+
const a = upsert(obj.frameUrl, obj.timestamp);
|
|
1335
|
+
if (obj.title) a.title = obj.title;
|
|
1336
|
+
if (obj.path) a.path = obj.path;
|
|
1337
|
+
continue;
|
|
1338
|
+
}
|
|
1339
|
+
const blocks = obj?.message?.content;
|
|
1340
|
+
if (!Array.isArray(blocks)) continue;
|
|
1341
|
+
for (const b of blocks) {
|
|
1342
|
+
if (b?.type === 'tool_use') {
|
|
1343
|
+
const input = b.name === 'Artifact' ? b.input || {} : null;
|
|
1344
|
+
// The publish action is the default one, so it is spelled by the file it
|
|
1345
|
+
// takes rather than by a name.
|
|
1346
|
+
if (b.id && input?.file_path && (!input.action || input.action === 'publish')) {
|
|
1347
|
+
publishedFileByToolUseId.set(b.id, input.file_path);
|
|
1348
|
+
}
|
|
1349
|
+
continue;
|
|
1350
|
+
}
|
|
1351
|
+
if (b?.type !== 'tool_result' || !publishedFileByToolUseId.has(b.tool_use_id)) continue;
|
|
1352
|
+
const m = toolResultText(b.content).match(ARTIFACT_URL_RE);
|
|
1353
|
+
if (!m) continue;
|
|
1354
|
+
const a = upsert(m[0], obj.timestamp);
|
|
1355
|
+
if (!a.path) a.path = publishedFileByToolUseId.get(b.tool_use_id);
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
} catch (_) {}
|
|
1359
|
+
return [...byUrl.values()].sort((x, y) => (x.firstSeen < y.firstSeen ? -1 : x.firstSeen > y.firstSeen ? 1 : 0));
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1299
1362
|
function readCompactSummaries(jsonlPath) {
|
|
1300
1363
|
const results = [];
|
|
1301
1364
|
// Inline format: newer Claude Code stores the summary directly in the parent
|
|
@@ -1634,6 +1697,7 @@ module.exports = {
|
|
|
1634
1697
|
buildAgentProgressMap,
|
|
1635
1698
|
buildSessionDigest,
|
|
1636
1699
|
readCompactSummaries,
|
|
1700
|
+
readArtifactLinks,
|
|
1637
1701
|
findTerminatedTeammates,
|
|
1638
1702
|
extractPromptFromTranscript,
|
|
1639
1703
|
extractModelFromTranscript,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-code-kanban",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.12.0",
|
|
4
4
|
"description": "claude-code-kanban dashboard integration: agent activity tracking, context statusline, skills to drive the board from a session and to follow it",
|
|
5
5
|
"experimental": {
|
|
6
6
|
"monitors": "./monitors.json"
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
2
|
# Blocking approval gate: lets the cck board answer a permission ask or an
|
|
3
3
|
# AskUserQuestion. Always writes the _waiting.json marker first (badge behavior
|
|
4
|
-
# is unchanged when the feature is off), then —
|
|
5
|
-
# the board's server is alive — waits for a decision file written by the server.
|
|
4
|
+
# is unchanged when the feature is off), then — unless config.json opts out, and
|
|
5
|
+
# only while the board's server is alive — waits for a decision file written by the server.
|
|
6
6
|
#
|
|
7
7
|
# Contract (_plans/cck-ui-approvals/decisions.md), rooted at <CLAUDE_CONFIG_DIR or ~/.claude>/.cck:
|
|
8
8
|
# marker agent-activity/<sid>/_waiting.json (D8: + id, cwd, permissionSuggestions)
|
|
9
9
|
# decision agent-activity/<sid>/_decision-<id>.json (server writes it, Phase 3)
|
|
10
|
-
# config
|
|
10
|
+
# config config.json {approvals: {enabled, mode, waitSeconds}} (on by default; absent = defaults)
|
|
11
11
|
# liveness server.json {port, pid} (D1: a dead board costs nothing)
|
|
12
12
|
#
|
|
13
13
|
# First writer wins (D5): a terminal answer deletes the marker via PostToolUse
|
|
@@ -61,37 +61,58 @@ echo "$INPUT" | jq -c --arg kind "$KIND" --arg ts "$TS" --arg id "$REQ_ID" '{
|
|
|
61
61
|
# Every exit below leaves the marker in place for the badge; agent-spy.sh's
|
|
62
62
|
# PostToolUse (or the server's TTL) retires it, exactly as before this feature.
|
|
63
63
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
ENABLED=""
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
64
|
+
# Defaults mirror lib/approvals.js — keep in sync. A missing or unparseable
|
|
65
|
+
# config.json means defaults, i.e. the gate is on.
|
|
66
|
+
ENABLED="true"
|
|
67
|
+
MODE="permission+question"
|
|
68
|
+
WAIT_SECONDS=""
|
|
69
|
+
CONFIG="$CCK_DIR/config.json"
|
|
70
|
+
if [ -f "$CONFIG" ]; then
|
|
71
|
+
eval "$(jq -r '
|
|
72
|
+
@sh "ENABLED=\(if .approvals.enabled == false then "false" else "true" end)",
|
|
73
|
+
@sh "MODE=\(.approvals.mode // "permission+question")",
|
|
74
|
+
@sh "WAIT_SECONDS=\(.approvals.waitSeconds // "")"
|
|
75
|
+
' < "$CONFIG" 2>/dev/null)"
|
|
76
|
+
fi
|
|
77
|
+
[ "$ENABLED" = "false" ] && exit 0
|
|
73
78
|
|
|
74
79
|
if [ "$KIND" = "question" ] && [ "$MODE" != "permission+question" ]; then
|
|
75
80
|
exit 0
|
|
76
81
|
fi
|
|
77
82
|
|
|
78
|
-
case "$WAIT_SECONDS" in *[!0-9]* | "") WAIT_SECONDS=
|
|
83
|
+
case "$WAIT_SECONDS" in *[!0-9]* | "") WAIT_SECONDS=1800 ;; esac
|
|
79
84
|
# PERMISSION_TTL_MS hides the card at 30 min — waiting longer than the UI can
|
|
80
85
|
# show the ask is strictly worse than giving up (D11)
|
|
81
86
|
[ "$WAIT_SECONDS" -gt 1800 ] && WAIT_SECONDS=1800
|
|
82
87
|
|
|
83
88
|
SERVER_INFO="$CCK_DIR/server.json"
|
|
84
|
-
|
|
85
|
-
SERVER_PORT=$(jq -r '.port // empty' < "$SERVER_INFO" 2>/dev/null)
|
|
86
|
-
[ -n "$SERVER_PORT" ] || exit 0
|
|
89
|
+
|
|
87
90
|
# A TCP connect beats a pid probe: it proves the board is actually serving, and
|
|
88
91
|
# it works in the stripped environment Claude Code spawns hooks into, where
|
|
89
|
-
# kill -0 cannot see native Windows pids and ps may be missing from PATH
|
|
90
|
-
|
|
92
|
+
# kill -0 cannot see native Windows pids and ps may be missing from PATH. The
|
|
93
|
+
# port is re-read on every probe, not cached: under the hub every sub-app binds
|
|
94
|
+
# an ephemeral port, so a board that restarts mid-wait comes back on a new one.
|
|
95
|
+
# $(<file) and =~ keep the probe free of jq and cat spawns (~280 ms each here).
|
|
96
|
+
board_alive() {
|
|
97
|
+
local raw port
|
|
98
|
+
raw=$(<"$SERVER_INFO") 2>/dev/null || return 1
|
|
99
|
+
[[ $raw =~ \"port\"[[:space:]]*:[[:space:]]*([0-9]+) ]] || return 1
|
|
100
|
+
port=${BASH_REMATCH[1]}
|
|
101
|
+
(: < "/dev/tcp/127.0.0.1/$port") 2>/dev/null
|
|
102
|
+
}
|
|
91
103
|
|
|
92
104
|
DECISION="$DIR/_decision-$REQ_ID.json"
|
|
93
105
|
# EPOCHSECONDS (bash 5) keeps the poll loop free of `date` spawns
|
|
94
106
|
DEADLINE=$((EPOCHSECONDS + WAIT_SECONDS))
|
|
107
|
+
# Probing once up front made one unreachable moment disarm the whole ask, so a
|
|
108
|
+
# board restarting on a new port (every hub-spawned sub-app binds an ephemeral
|
|
109
|
+
# one) took the ask with it. Probe on a cadence instead and only give up once
|
|
110
|
+
# the board has been gone for the whole grace. The terminal prompt stays live
|
|
111
|
+
# throughout either way.
|
|
112
|
+
BOARD_PROBE_SECONDS=5
|
|
113
|
+
BOARD_GRACE_SECONDS=15
|
|
114
|
+
NEXT_PROBE=0
|
|
115
|
+
UNREACHABLE_SINCE=0
|
|
95
116
|
|
|
96
117
|
while :; do
|
|
97
118
|
if [ -f "$DECISION" ]; then
|
|
@@ -136,6 +157,20 @@ while :; do
|
|
|
136
157
|
IFS= read -r CUR_MARKER < "$MARKER" 2>/dev/null || exit 0
|
|
137
158
|
case "$CUR_MARKER" in *"\"id\":\"$REQ_ID\""*) ;; *) exit 0 ;; esac
|
|
138
159
|
|
|
160
|
+
if [ "$EPOCHSECONDS" -ge "$NEXT_PROBE" ]; then
|
|
161
|
+
NEXT_PROBE=$((EPOCHSECONDS + BOARD_PROBE_SECONDS))
|
|
162
|
+
# No beacon at all = no board on this config dir, now or a moment ago: give
|
|
163
|
+
# up at once, exactly as before. A beacon whose port is closed is the
|
|
164
|
+
# restart window instead, so that one gets the grace.
|
|
165
|
+
[ -f "$SERVER_INFO" ] || exit 0
|
|
166
|
+
if board_alive; then
|
|
167
|
+
UNREACHABLE_SINCE=0
|
|
168
|
+
else
|
|
169
|
+
[ "$UNREACHABLE_SINCE" -eq 0 ] && UNREACHABLE_SINCE=$EPOCHSECONDS
|
|
170
|
+
[ $((EPOCHSECONDS - UNREACHABLE_SINCE)) -ge "$BOARD_GRACE_SECONDS" ] && exit 0
|
|
171
|
+
fi
|
|
172
|
+
fi
|
|
173
|
+
|
|
139
174
|
[ "$EPOCHSECONDS" -ge "$DEADLINE" ] && exit 0
|
|
140
175
|
sleep 0.5
|
|
141
176
|
done
|
package/public/app.js
CHANGED
|
@@ -30,6 +30,12 @@ let sessionFilter = FILTER_DEFAULTS.session;
|
|
|
30
30
|
// Only meaningful while sessionFilter === 'active' (filterBySessions clears it otherwise)
|
|
31
31
|
const activityFilter = new Set(); // kinds: 'waiting' | 'active'
|
|
32
32
|
let sessionLimit = FILTER_DEFAULTS.limit;
|
|
33
|
+
// Narrows the sidebar to the open session only. Kept out of FILTER_DEFAULTS/updateUrl because it
|
|
34
|
+
// is not a filter — it has no select, no summary token, and never reaches the session picker.
|
|
35
|
+
// Persisted in localStorage rather than the URL: the hub recreates each iframe at the app's base
|
|
36
|
+
// URL, so a query string would not survive a hub reload.
|
|
37
|
+
const ZEN_KEY = 'zenMode';
|
|
38
|
+
let zenMode = store.getItem(ZEN_KEY) === 'true';
|
|
33
39
|
let filterProject = FILTER_DEFAULTS.project; // null = all, '__recent__' = last 24h, or project path
|
|
34
40
|
let recentProjects = new Set();
|
|
35
41
|
let projectsCacheDirty = true;
|
|
@@ -144,6 +150,9 @@ function resetState() {
|
|
|
144
150
|
sessionFilter = FILTER_DEFAULTS.session;
|
|
145
151
|
sessionLimit = FILTER_DEFAULTS.limit;
|
|
146
152
|
filterProject = FILTER_DEFAULTS.project;
|
|
153
|
+
zenMode = false;
|
|
154
|
+
store.removeItem(ZEN_KEY);
|
|
155
|
+
renderZenState();
|
|
147
156
|
ownerFilter = '';
|
|
148
157
|
searchQuery = '';
|
|
149
158
|
viewMode = 'all';
|
|
@@ -193,7 +202,7 @@ async function fetchSessions(includeTasks = true) {
|
|
|
193
202
|
if (revealedStorageSessionId) allPinnedIds.add(revealedStorageSessionId);
|
|
194
203
|
// When server filters by activity, the focused session may not be active —
|
|
195
204
|
// include it in pinned so the server still returns it.
|
|
196
|
-
if (sessionFilter === 'active' && currentSessionId) allPinnedIds.add(currentSessionId);
|
|
205
|
+
if ((sessionFilter === 'active' || zenMode) && currentSessionId) allPinnedIds.add(currentSessionId);
|
|
197
206
|
const pinnedParam = allPinnedIds.size > 0 ? `&pinned=${[...allPinnedIds].join(',')}` : '';
|
|
198
207
|
const projectParam =
|
|
199
208
|
filterProject && filterProject !== '__recent__' ? `&project=${encodeURIComponent(filterProject)}` : '';
|
|
@@ -493,6 +502,7 @@ async function fetchAgents(sessionId) {
|
|
|
493
502
|
lastWaitingHash = waitHash;
|
|
494
503
|
if (messagePanelOpen && currentMessages.length) renderMessages(currentMessages);
|
|
495
504
|
maybeFollowLatest();
|
|
505
|
+
maybeAutoOpenWaiting();
|
|
496
506
|
}
|
|
497
507
|
} catch (e) {
|
|
498
508
|
console.error('[fetchAgents]', e);
|
|
@@ -2181,6 +2191,10 @@ function isZoomableModalOpen() {
|
|
|
2181
2191
|
return document.querySelector('.modal-overlay.visible .modal-zoomable') !== null;
|
|
2182
2192
|
}
|
|
2183
2193
|
|
|
2194
|
+
function isAnyModalOpen() {
|
|
2195
|
+
return document.querySelector('.modal-overlay.visible') !== null;
|
|
2196
|
+
}
|
|
2197
|
+
|
|
2184
2198
|
const ZOOM_KEYS = { '+': 0.1, '=': 0.1, NumpadAdd: 0.1, '-': -0.1, _: -0.1, NumpadSubtract: -0.1, 0: 0, Numpad0: 0 };
|
|
2185
2199
|
|
|
2186
2200
|
let _toastTimer = null;
|
|
@@ -3167,20 +3181,32 @@ function getFilteredSessions() {
|
|
|
3167
3181
|
return filteredSessions;
|
|
3168
3182
|
}
|
|
3169
3183
|
|
|
3184
|
+
// A live refresh would replace the zen panel's link-a-file input mid-type, the same way it
|
|
3185
|
+
// would swallow a half-typed group name. Off zen there is no panel, so skip the DOM probe.
|
|
3186
|
+
function zenPanelIsEditing() {
|
|
3187
|
+
return zenMode && !!sessionsList.querySelector('.linked-doc-input');
|
|
3188
|
+
}
|
|
3189
|
+
|
|
3170
3190
|
function renderSessions() {
|
|
3171
3191
|
// Rebuilding the list under the pointer cancels an in-flight drop and would swallow a
|
|
3172
3192
|
// half-typed group name, and the SSE path can fire at any moment — defer instead.
|
|
3173
|
-
if (sgDrag || sgIsEditing()) return;
|
|
3193
|
+
if (sgDrag || sgIsEditing() || zenPanelIsEditing()) return;
|
|
3174
3194
|
// Update project dropdown
|
|
3175
3195
|
updateProjectDropdown();
|
|
3176
3196
|
|
|
3177
|
-
|
|
3197
|
+
// Zen narrows the rendered list only — the session picker keeps calling getFilteredSessions()
|
|
3198
|
+
// and still offers everything, or there would be no way to switch sessions without leaving zen.
|
|
3199
|
+
const zenSession = zenMode && currentSessionId ? sessions.find((s) => s.id === currentSessionId) : null;
|
|
3200
|
+
const filteredSessions = zenMode ? (zenSession ? [zenSession] : []) : getFilteredSessions();
|
|
3178
3201
|
|
|
3179
3202
|
if (filteredSessions.length === 0) {
|
|
3180
3203
|
let emptyMsg = 'No sessions found';
|
|
3181
3204
|
let emptyHint = 'Tasks appear when you use Claude Code';
|
|
3182
3205
|
|
|
3183
|
-
if (
|
|
3206
|
+
if (zenMode) {
|
|
3207
|
+
emptyMsg = 'Zen mode: no session open';
|
|
3208
|
+
emptyHint = 'Press Shift+Z to leave zen mode, then open a session';
|
|
3209
|
+
} else if (searchQuery) {
|
|
3184
3210
|
emptyMsg = `No results for "${searchQuery}"`;
|
|
3185
3211
|
emptyHint = 'Try a different search term or clear the search';
|
|
3186
3212
|
} else if (filterProject && sessionFilter === 'active') {
|
|
@@ -3209,9 +3235,7 @@ function renderSessions() {
|
|
|
3209
3235
|
const isActive = session.id === currentSessionId && viewMode === 'session';
|
|
3210
3236
|
const isLive = isSessionLive(session);
|
|
3211
3237
|
const sessionName = sessionDisplayName(session);
|
|
3212
|
-
const
|
|
3213
|
-
const primaryName = useGrouped ? sessionName : session.project ? session.project.split('/').pop() : sessionName;
|
|
3214
|
-
const secondaryName = useGrouped ? null : session.project ? sessionName : null;
|
|
3238
|
+
const projectHtml = renderProjectIdentity(session);
|
|
3215
3239
|
|
|
3216
3240
|
const gitBranch = session.gitBranch ? escapeHtml(session.gitBranch) : null;
|
|
3217
3241
|
const createdDisplay = session.createdAt ? formatDate(session.createdAt) : '';
|
|
@@ -3220,7 +3244,9 @@ function renderSessions() {
|
|
|
3220
3244
|
session.createdAt && createdDisplay !== modifiedDisplay
|
|
3221
3245
|
? `Created ${createdDisplay} · Modified ${modifiedDisplay}`
|
|
3222
3246
|
: modifiedDisplay;
|
|
3223
|
-
const tooltip = [session.id, timeDisplay, gitBranch ? `Branch: ${gitBranch}` : '']
|
|
3247
|
+
const tooltip = [session.id, session.project, timeDisplay, gitBranch ? `Branch: ${gitBranch}` : '']
|
|
3248
|
+
.filter(Boolean)
|
|
3249
|
+
.join(' | ');
|
|
3224
3250
|
const isTeam = session.isTeam;
|
|
3225
3251
|
const memberCount = session.memberCount || 0;
|
|
3226
3252
|
|
|
@@ -3228,7 +3254,8 @@ function renderSessions() {
|
|
|
3228
3254
|
const pinClass = pinState === 'sticky' ? ' sticky' : pinState === 'pinned' ? ' pinned' : '';
|
|
3229
3255
|
const pinTitle =
|
|
3230
3256
|
pinState === 'pinned' || pinState === 'sticky' ? 'Unpin session (.)' : 'Pin session (. · > sticky)';
|
|
3231
|
-
|
|
3257
|
+
// Zen renders the full context detail below the card, so the compact bar would just repeat it.
|
|
3258
|
+
const showCtx = !!session.contextStatus && !zenMode;
|
|
3232
3259
|
const linkedDocsCount = getSessionPreviewPaths(session.id).length;
|
|
3233
3260
|
const bookmarksCount = loadPins(session.id).length;
|
|
3234
3261
|
const hasScratchpad = !!(store.getItem(_sessionScratchpadKey(session.id)) || '').trim();
|
|
@@ -3237,8 +3264,8 @@ function renderSessions() {
|
|
|
3237
3264
|
return `
|
|
3238
3265
|
<button onclick="fetchTasks('${sid}')" draggable="true" data-session-id="${escapeHtml(session.id)}" class="session-item ${isActive ? 'active' : ''} ${session.hasWaitingForUser ? 'permission-pending' : ''} ${tempClass} ${showCtx ? 'has-context' : ''}" title="${escapeHtml(tooltip)}">
|
|
3239
3266
|
<span class="session-pin-btn${pinClass}" onclick="event.stopPropagation();toggleSessionPin('${sid}')" title="${pinTitle} session">${pinState === 'sticky' ? SESSION_STAR_SVG : SESSION_PIN_SVG}</span>
|
|
3240
|
-
<div class="session-name">${escapeHtml(
|
|
3241
|
-
${
|
|
3267
|
+
<div class="session-name">${escapeHtml(sessionName)}</div>
|
|
3268
|
+
${projectHtml ? `<div class="session-secondary">${projectHtml}</div>` : ''}
|
|
3242
3269
|
${gitBranch ? `<div class="session-branch">${gitBranch}</div>` : ''}
|
|
3243
3270
|
${session.planTitle ? `<div class="session-plan">${escapeHtml(session.planTitle)}</div>` : ''}
|
|
3244
3271
|
${renderGoalSubtitle(session)}
|
|
@@ -3267,6 +3294,21 @@ function renderSessions() {
|
|
|
3267
3294
|
`;
|
|
3268
3295
|
};
|
|
3269
3296
|
|
|
3297
|
+
// Zen draws one card and its panel, so it returns before the grouping below — leaving it in
|
|
3298
|
+
// that chain would build the whole list only to discard it.
|
|
3299
|
+
if (zenMode) {
|
|
3300
|
+
sessionsList.innerHTML = `${renderSessionCard(zenSession)}
|
|
3301
|
+
<div class="zen-panel">
|
|
3302
|
+
${renderContextDetail(zenSession.contextStatus) || '<div class="zen-panel-empty">No context data for this session</div>'}
|
|
3303
|
+
${renderScratchpadRow(zenSession)}
|
|
3304
|
+
${renderLinkedDocsHtml(zenSession.id)}
|
|
3305
|
+
${renderArtifactsHtml(zenSession.id)}
|
|
3306
|
+
</div>`;
|
|
3307
|
+
bindLinkedDocsHandlers(sessionsList.querySelector('.linked-docs-section'), zenSession.id);
|
|
3308
|
+
ensureSessionArtifacts(zenSession.id);
|
|
3309
|
+
return;
|
|
3310
|
+
}
|
|
3311
|
+
|
|
3270
3312
|
const groupPinned = store.getItem('groupPinnedSessions') !== 'false';
|
|
3271
3313
|
const pinWeight = (s) => (isPlacedSticky(s.id) ? 2 : isPlacedPinned(s.id) && !isSessionActive(s) ? 1 : 0);
|
|
3272
3314
|
const pinSort = (a, b) => pinWeight(b) - pinWeight(a);
|
|
@@ -3545,7 +3587,7 @@ function renderSession() {
|
|
|
3545
3587
|
sessionTitle.textContent = displayName;
|
|
3546
3588
|
|
|
3547
3589
|
// Build meta text with project path and description
|
|
3548
|
-
const projectName = session.project ? session.project
|
|
3590
|
+
const projectName = session.project ? pathBasename(session.project) : null;
|
|
3549
3591
|
const metaParts = [`${currentTasks.length} tasks`];
|
|
3550
3592
|
if (projectName) {
|
|
3551
3593
|
metaParts.push(projectName);
|
|
@@ -4649,16 +4691,18 @@ function expandActiveGroups({ onlyNew = false } = {}) {
|
|
|
4649
4691
|
}
|
|
4650
4692
|
|
|
4651
4693
|
// Drops every collapse key that could hide this session: its project, a named group wrapping
|
|
4652
|
-
// the project or the session itself, the pinned sub-
|
|
4653
|
-
//
|
|
4694
|
+
// the project or the session itself, the pinned sub-section only when the card renders inside
|
|
4695
|
+
// it (pinned, not sticky, idle — see renderGroupSessions), and — since which section holds it
|
|
4696
|
+
// depends on the view — every section header. Returns whether anything changed.
|
|
4654
4697
|
function uncollapseFor(session) {
|
|
4655
4698
|
const project = session.project || '__ungrouped__';
|
|
4656
4699
|
const group = sgGroupForSession(session);
|
|
4700
|
+
const inPinned = isPlacedPinned(session.id) && !isPlacedSticky(session.id) && !isSessionActive(session);
|
|
4657
4701
|
const keys = [
|
|
4658
4702
|
project,
|
|
4659
|
-
pinKey(project),
|
|
4703
|
+
inPinned && pinKey(project),
|
|
4660
4704
|
group && sgKey(group.id),
|
|
4661
|
-
group && `__pinned_group_${group.id}__`,
|
|
4705
|
+
group && inPinned && `__pinned_group_${group.id}__`,
|
|
4662
4706
|
SECTION_GROUPS,
|
|
4663
4707
|
SECTION_PROJECTS,
|
|
4664
4708
|
SECTION_SESSIONS,
|
|
@@ -5194,6 +5238,7 @@ const SHORTCUT_PAIRS = [
|
|
|
5194
5238
|
{ keys: ['['], label: 'Toggle sidebar' },
|
|
5195
5239
|
{ keys: ['T'], label: 'Toggle theme' },
|
|
5196
5240
|
{ keys: ['Shift', 'S'], combo: true, label: 'Storage manager' },
|
|
5241
|
+
{ keys: ['Shift', 'Z'], combo: true, label: 'Zen mode (current session only)' },
|
|
5197
5242
|
{ keys: ['Ctrl', '+'], combo: true, label: 'Larger modal text' },
|
|
5198
5243
|
{ keys: ['Ctrl', '−'], combo: true, label: 'Smaller modal text' },
|
|
5199
5244
|
{ keys: ['Ctrl', '0'], combo: true, label: 'Reset modal text size' },
|
|
@@ -5407,7 +5452,7 @@ function _groupByProject(sessionIds) {
|
|
|
5407
5452
|
|
|
5408
5453
|
function _projectLabel(project) {
|
|
5409
5454
|
if (project === '(no project)') return '(no project)';
|
|
5410
|
-
return project
|
|
5455
|
+
return pathBasename(project);
|
|
5411
5456
|
}
|
|
5412
5457
|
|
|
5413
5458
|
function _renderProjectGroup(label, meta, innerHtml) {
|
|
@@ -5844,7 +5889,7 @@ document.addEventListener('keydown', (e) => {
|
|
|
5844
5889
|
}
|
|
5845
5890
|
|
|
5846
5891
|
// Modal guard — only Escape, Shift+M, and msg-detail J/K navigation pass through
|
|
5847
|
-
if (
|
|
5892
|
+
if (isAnyModalOpen()) {
|
|
5848
5893
|
if (e.key === 'Escape') {
|
|
5849
5894
|
if (_scratchpadModal.classList.contains('visible')) {
|
|
5850
5895
|
closeScratchpad();
|
|
@@ -5929,6 +5974,11 @@ document.addEventListener('keydown', (e) => {
|
|
|
5929
5974
|
showStorageManager();
|
|
5930
5975
|
return;
|
|
5931
5976
|
}
|
|
5977
|
+
if (e.code === 'KeyZ' && e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey) {
|
|
5978
|
+
e.preventDefault();
|
|
5979
|
+
toggleZenMode();
|
|
5980
|
+
return;
|
|
5981
|
+
}
|
|
5932
5982
|
// Ctrl+Shift+P is the browser's own; only the bare chord opens the picker.
|
|
5933
5983
|
if (e.code === 'KeyP' && e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey) {
|
|
5934
5984
|
e.preventDefault();
|
|
@@ -6476,11 +6526,11 @@ function renderLinkedDocsHtml(sessionId) {
|
|
|
6476
6526
|
const body = paths.length
|
|
6477
6527
|
? `<ul class="linked-doc-list">${items}</ul>`
|
|
6478
6528
|
: '<div class="linked-docs-empty">No linked files yet</div>';
|
|
6479
|
-
return `<div class="linked-docs-section
|
|
6480
|
-
<div
|
|
6529
|
+
return `<div class="linked-docs-section panel-section">
|
|
6530
|
+
<div class="panel-section-header">
|
|
6481
6531
|
${linkSvg(12)}
|
|
6482
6532
|
<span>Linked documents</span>
|
|
6483
|
-
<span
|
|
6533
|
+
<span class="panel-section-count">${paths.length}</span>
|
|
6484
6534
|
<button type="button" class="linked-docs-add-btn" title="Link a file" aria-label="Link a file">+</button>
|
|
6485
6535
|
</div>
|
|
6486
6536
|
<div class="linked-doc-editor-slot"></div>
|
|
@@ -6488,6 +6538,63 @@ function renderLinkedDocsHtml(sessionId) {
|
|
|
6488
6538
|
</div>`;
|
|
6489
6539
|
}
|
|
6490
6540
|
|
|
6541
|
+
// Artifacts are read out of the transcript rather than stored with the session, so
|
|
6542
|
+
// the list arrives after the panel is already on screen and the slot — always
|
|
6543
|
+
// rendered, empty or not — is patched in place once it does.
|
|
6544
|
+
const artifactsBySession = new Map();
|
|
6545
|
+
const artifactsInFlight = new Set();
|
|
6546
|
+
|
|
6547
|
+
function artifactLabel(a) {
|
|
6548
|
+
if (a.title) return a.title;
|
|
6549
|
+
return a.path ? pathBasename(a.path).replace(/\.html?$/i, '') : 'Artifact';
|
|
6550
|
+
}
|
|
6551
|
+
|
|
6552
|
+
function artifactsInnerHtml(sessionId) {
|
|
6553
|
+
const list = artifactsBySession.get(sessionId) || [];
|
|
6554
|
+
if (!list.length) return '';
|
|
6555
|
+
const items = list
|
|
6556
|
+
.map(
|
|
6557
|
+
(a) => `<li class="artifact-item">
|
|
6558
|
+
<a class="artifact-link" href="${escapeHtml(a.url)}" target="_blank" rel="noopener" title="${escapeHtml(a.url)}">${escapeHtml(artifactLabel(a))}</a>
|
|
6559
|
+
<span class="row-actions artifact-actions">
|
|
6560
|
+
<button type="button" onclick="copyWithFeedback('${escAttrJs(a.url)}', this)" title="Copy link" aria-label="Copy artifact link">${ICON_COPY}</button>
|
|
6561
|
+
</span>
|
|
6562
|
+
</li>`,
|
|
6563
|
+
)
|
|
6564
|
+
.join('');
|
|
6565
|
+
return `<div class="panel-section-header">
|
|
6566
|
+
${ICON_OPEN_EXTERNAL}
|
|
6567
|
+
<span>Artifacts</span>
|
|
6568
|
+
<span class="panel-section-count">${list.length}</span>
|
|
6569
|
+
</div>
|
|
6570
|
+
<ul class="artifact-list">${items}</ul>`;
|
|
6571
|
+
}
|
|
6572
|
+
|
|
6573
|
+
function renderArtifactsHtml(sessionId) {
|
|
6574
|
+
return `<div class="artifacts-section panel-section" data-artifacts-for="${escapeHtml(sessionId)}">${artifactsInnerHtml(sessionId)}</div>`;
|
|
6575
|
+
}
|
|
6576
|
+
|
|
6577
|
+
// Always asks: the server holds the scan until the transcript grows, so a repeat
|
|
6578
|
+
// costs one stat, and an artifact published while the panel is open still appears.
|
|
6579
|
+
async function ensureSessionArtifacts(sessionId) {
|
|
6580
|
+
if (!sessionId || artifactsInFlight.has(sessionId)) return;
|
|
6581
|
+
artifactsInFlight.add(sessionId);
|
|
6582
|
+
try {
|
|
6583
|
+
const res = await fetch(`/api/sessions/${sessionId}/artifacts`);
|
|
6584
|
+
if (!res.ok) return;
|
|
6585
|
+
const data = await res.json();
|
|
6586
|
+
artifactsBySession.set(sessionId, data.artifacts || []);
|
|
6587
|
+
} catch (_) {
|
|
6588
|
+
return;
|
|
6589
|
+
} finally {
|
|
6590
|
+
artifactsInFlight.delete(sessionId);
|
|
6591
|
+
}
|
|
6592
|
+
const html = artifactsInnerHtml(sessionId);
|
|
6593
|
+
for (const slot of document.querySelectorAll(`[data-artifacts-for="${CSS.escape(sessionId)}"]`)) {
|
|
6594
|
+
slot.innerHTML = html;
|
|
6595
|
+
}
|
|
6596
|
+
}
|
|
6597
|
+
|
|
6491
6598
|
// One delegated listener per section: the list is re-rendered on every change, so
|
|
6492
6599
|
// per-row handlers would only be rebound each time anyway.
|
|
6493
6600
|
function bindLinkedDocsHandlers(container, sessionId) {
|
|
@@ -6561,6 +6668,9 @@ async function linkFileByPath(sessionId, raw, slot) {
|
|
|
6561
6668
|
fail(data.error || 'File not found');
|
|
6562
6669
|
return;
|
|
6563
6670
|
}
|
|
6671
|
+
// Close the editor before the refresh: a sidebar panel defers its re-render while an
|
|
6672
|
+
// input is open, so leaving it up would hide the file that was just linked.
|
|
6673
|
+
if (slot) slot.innerHTML = '';
|
|
6564
6674
|
setSessionDocLink(sessionId, data.path, false);
|
|
6565
6675
|
showToast('Linked to session', 'success');
|
|
6566
6676
|
} catch {
|
|
@@ -6884,6 +6994,19 @@ function maybeFollowLatest() {
|
|
|
6884
6994
|
}
|
|
6885
6995
|
}
|
|
6886
6996
|
|
|
6997
|
+
// A new answerable ask opens its modal on its own, but only onto an empty
|
|
6998
|
+
// screen: an open modal means the user is reading something, so the ask stays
|
|
6999
|
+
// on the card and the badge until they get to it. Each ask id is offered once,
|
|
7000
|
+
// so closing the modal does not bring it back on the next poll.
|
|
7001
|
+
let lastAutoOpenedWaitingId = null;
|
|
7002
|
+
function maybeAutoOpenWaiting() {
|
|
7003
|
+
if (!isWaitingAnswerable() || !isWaitingFresh()) return;
|
|
7004
|
+
if (currentWaiting.id === lastAutoOpenedWaitingId || isAnyModalOpen()) return;
|
|
7005
|
+
lastAutoOpenedWaitingId = currentWaiting.id;
|
|
7006
|
+
msgDetailFollowLatest = true;
|
|
7007
|
+
maybeFollowLatest();
|
|
7008
|
+
}
|
|
7009
|
+
|
|
6887
7010
|
function isWaitingFresh() {
|
|
6888
7011
|
if (!currentWaiting?.timestamp) return false;
|
|
6889
7012
|
return Date.now() - new Date(currentWaiting.timestamp).getTime() < WAITING_TTL_MS;
|
|
@@ -7087,6 +7210,24 @@ function sessionDisplayName(session) {
|
|
|
7087
7210
|
return SESSION_UUID_RE.test(raw) ? raw.slice(0, 8) : raw;
|
|
7088
7211
|
}
|
|
7089
7212
|
|
|
7213
|
+
// Project paths arrive in the OS spelling, so a Windows path is backslash-separated.
|
|
7214
|
+
function pathBasename(p) {
|
|
7215
|
+
return p.split(/[/\\]/).filter(Boolean).pop() || p;
|
|
7216
|
+
}
|
|
7217
|
+
|
|
7218
|
+
// `session.worktree` is resolved server-side from the `.git` pointer file, so it holds for a
|
|
7219
|
+
// worktree parked beside its repo as well as one under <repo>/.claude/worktrees/.
|
|
7220
|
+
function renderProjectIdentity(session) {
|
|
7221
|
+
if (!session.project) return null;
|
|
7222
|
+
const wt = session.worktree;
|
|
7223
|
+
if (!wt) return escapeHtml(pathBasename(session.project));
|
|
7224
|
+
const repo = pathBasename(wt.repo);
|
|
7225
|
+
// Worktrees beside the repo are conventionally named `<repo>-<branch>`, and repeating the
|
|
7226
|
+
// repo inside a chip that already sits next to it wastes the whole width of the card.
|
|
7227
|
+
const name = wt.name.startsWith(repo) ? wt.name.slice(repo.length).replace(/^[-._]/, '') || wt.name : wt.name;
|
|
7228
|
+
return `${escapeHtml(repo)}<span class="wt-chip" title="Worktree of ${escapeHtml(wt.repo)}">⑂ ${escapeHtml(name)}</span>`;
|
|
7229
|
+
}
|
|
7230
|
+
|
|
7090
7231
|
function formatDate(dateStr) {
|
|
7091
7232
|
const date = new Date(dateStr);
|
|
7092
7233
|
const now = new Date();
|
|
@@ -7524,6 +7665,24 @@ function renderFilterState() {
|
|
|
7524
7665
|
document.getElementById('filter-menu-btn')?.classList.toggle('has-filters', short.length > 0);
|
|
7525
7666
|
}
|
|
7526
7667
|
|
|
7668
|
+
function toggleZenMode() {
|
|
7669
|
+
zenMode = !zenMode;
|
|
7670
|
+
store.setItem(ZEN_KEY, String(zenMode));
|
|
7671
|
+
renderZenState();
|
|
7672
|
+
if (zenMode) closeFilterMenu();
|
|
7673
|
+
renderSessions();
|
|
7674
|
+
// The open session may be outside the current server page/filter; refetch pins it in.
|
|
7675
|
+
if (zenMode) fetchSessions(false);
|
|
7676
|
+
}
|
|
7677
|
+
|
|
7678
|
+
function renderZenState() {
|
|
7679
|
+
const btn = document.getElementById('zen-mode-btn');
|
|
7680
|
+
if (!btn) return;
|
|
7681
|
+
btn.classList.toggle('active', zenMode);
|
|
7682
|
+
btn.setAttribute('aria-pressed', String(zenMode));
|
|
7683
|
+
btn.title = zenMode ? 'Zen mode on — show all sessions (Shift+Z)' : 'Zen mode: current session only (Shift+Z)';
|
|
7684
|
+
}
|
|
7685
|
+
|
|
7527
7686
|
// biome-ignore lint/correctness/noUnusedVariables: used in HTML
|
|
7528
7687
|
function filterBySessions(value) {
|
|
7529
7688
|
sessionFilter = value;
|
|
@@ -7965,6 +8124,7 @@ async function showSessionInfoModal(sessionId) {
|
|
|
7965
8124
|
_planSessionId = sessionId;
|
|
7966
8125
|
const cachedTasks = currentSessionId === sessionId ? currentTasks : [];
|
|
7967
8126
|
showInfoModal(session, null, cachedTasks, null, null);
|
|
8127
|
+
ensureSessionArtifacts(sessionId);
|
|
7968
8128
|
|
|
7969
8129
|
const rerender = (teamConfig, tasks, planContent, parentInfo) => {
|
|
7970
8130
|
if (_planSessionId !== sessionId) return; // user opened a different modal
|
|
@@ -8023,7 +8183,20 @@ function updateStickyBtnState() {
|
|
|
8023
8183
|
// carry information. The full path stays in the tooltip and the copy button.
|
|
8024
8184
|
function abbreviateScratchpadDir(dir) {
|
|
8025
8185
|
const sep = dir.includes('\\') ? '\\' : '/';
|
|
8026
|
-
return
|
|
8186
|
+
return dir.split(/[/\\]/).slice(-2).join(sep);
|
|
8187
|
+
}
|
|
8188
|
+
|
|
8189
|
+
function renderScratchpadRow(session) {
|
|
8190
|
+
const dir = session?.scratchpadDir;
|
|
8191
|
+
if (!dir) return '';
|
|
8192
|
+
return `<div class="zen-scratchpad">
|
|
8193
|
+
<span class="zen-scratchpad-label">Scratchpad</span>
|
|
8194
|
+
<span class="zen-scratchpad-path" title="${escapeHtml(dir)}">${escapeHtml(abbreviateScratchpadDir(dir))}</span>
|
|
8195
|
+
<span class="row-actions">
|
|
8196
|
+
<button onclick="copyWithFeedback('${escAttrJs(dir)}', this)" title="Copy">${ICON_COPY}</button>
|
|
8197
|
+
<button data-folder="${escapeHtml(dir)}" onclick="openFolderInEditor(this.dataset.folder)" title="Open in editor">${ICON_OPEN_EXTERNAL}</button>
|
|
8198
|
+
</span>
|
|
8199
|
+
</div>`;
|
|
8027
8200
|
}
|
|
8028
8201
|
|
|
8029
8202
|
function showInfoModal(session, teamConfig, tasks, planContent, parentInfo) {
|
|
@@ -8138,6 +8311,7 @@ function showInfoModal(session, teamConfig, tasks, planContent, parentInfo) {
|
|
|
8138
8311
|
}
|
|
8139
8312
|
|
|
8140
8313
|
html += renderLinkedDocsHtml(session.id);
|
|
8314
|
+
html += renderArtifactsHtml(session.id);
|
|
8141
8315
|
|
|
8142
8316
|
// Team info section
|
|
8143
8317
|
if (teamConfig) {
|
|
@@ -9207,6 +9381,7 @@ ownerFilter = urlState.owner || '';
|
|
|
9207
9381
|
searchQuery = urlState.search || '';
|
|
9208
9382
|
|
|
9209
9383
|
renderFilterState();
|
|
9384
|
+
renderZenState();
|
|
9210
9385
|
pinnedSessionIds = loadPinnedSessions();
|
|
9211
9386
|
stickySessionIds = loadStickySessions();
|
|
9212
9387
|
setupEventSource();
|
package/public/index.html
CHANGED
|
@@ -60,6 +60,20 @@
|
|
|
60
60
|
<span class="section-header-label">Sessions</span>
|
|
61
61
|
<span id="filter-summary" class="filter-summary" title=""></span>
|
|
62
62
|
<span class="section-header-actions">
|
|
63
|
+
<button
|
|
64
|
+
id="zen-mode-btn"
|
|
65
|
+
class="icon-btn section-btn"
|
|
66
|
+
onclick="event.stopPropagation(); toggleZenMode()"
|
|
67
|
+
title="Zen mode: current session only (Shift+Z)"
|
|
68
|
+
aria-label="Zen mode: current session only"
|
|
69
|
+
aria-pressed="false"
|
|
70
|
+
>
|
|
71
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
72
|
+
<circle cx="12" cy="12" r="7"/>
|
|
73
|
+
<circle cx="12" cy="12" r="2"/>
|
|
74
|
+
<path d="M12 1v3M12 20v3M1 12h3M20 12h3"/>
|
|
75
|
+
</svg>
|
|
76
|
+
</button>
|
|
63
77
|
<button
|
|
64
78
|
id="filter-menu-btn"
|
|
65
79
|
class="icon-btn section-btn"
|
package/public/style.css
CHANGED
|
@@ -9,6 +9,9 @@
|
|
|
9
9
|
--bg-surface: #16181c;
|
|
10
10
|
--bg-elevated: #1e2025;
|
|
11
11
|
--bg-hover: #282a30;
|
|
12
|
+
/* Selection reads as a recess against the darker surface here, but light theme's deep tone
|
|
13
|
+
sits within a shade of the sidebar, so there it has to rise instead. */
|
|
14
|
+
--session-active-bg: var(--bg-deep);
|
|
12
15
|
--border: #363840;
|
|
13
16
|
--text-primary: #f0f1f3;
|
|
14
17
|
--text-secondary: #c2c4c9;
|
|
@@ -440,7 +443,8 @@ body::before {
|
|
|
440
443
|
}
|
|
441
444
|
|
|
442
445
|
/* Ember only while something is actually filtered — at rest the funnel is just chrome. */
|
|
443
|
-
.icon-btn.section-btn.has-filters
|
|
446
|
+
.icon-btn.section-btn.has-filters,
|
|
447
|
+
.icon-btn.section-btn.active {
|
|
444
448
|
color: var(--accent-text);
|
|
445
449
|
}
|
|
446
450
|
|
|
@@ -575,17 +579,15 @@ body::before {
|
|
|
575
579
|
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
|
576
580
|
}
|
|
577
581
|
|
|
578
|
-
/* `.active` is the open session, `.kb-selected` the keyboard cursor.
|
|
579
|
-
|
|
582
|
+
/* `.active` is the open session, `.kb-selected` the keyboard cursor. They own different
|
|
583
|
+
properties — fill and border — so the cursor landing on the open session adds to it
|
|
584
|
+
instead of replacing how it looks. */
|
|
580
585
|
.session-item.active {
|
|
581
|
-
background: var(--bg
|
|
582
|
-
border-color: var(--accent);
|
|
586
|
+
background: var(--session-active-bg);
|
|
583
587
|
}
|
|
584
588
|
|
|
585
589
|
.session-item.kb-selected {
|
|
586
|
-
background: var(--bg-elevated);
|
|
587
590
|
border-color: var(--accent);
|
|
588
|
-
box-shadow: 0 0 0 1px var(--accent-dim);
|
|
589
591
|
}
|
|
590
592
|
|
|
591
593
|
.session-item.stale {
|
|
@@ -633,7 +635,8 @@ body::before {
|
|
|
633
635
|
}
|
|
634
636
|
|
|
635
637
|
.session-name {
|
|
636
|
-
font-size:
|
|
638
|
+
font-size: 14px;
|
|
639
|
+
font-weight: 500;
|
|
637
640
|
color: var(--text-primary);
|
|
638
641
|
white-space: nowrap;
|
|
639
642
|
overflow: hidden;
|
|
@@ -671,6 +674,26 @@ body::before {
|
|
|
671
674
|
overflow: hidden;
|
|
672
675
|
}
|
|
673
676
|
|
|
677
|
+
/* inline-block, not inline-flex: a flex box ignores text-overflow, so a long worktree name
|
|
678
|
+
ran past the card edge instead of truncating. */
|
|
679
|
+
.wt-chip {
|
|
680
|
+
display: inline-block;
|
|
681
|
+
max-width: 60%;
|
|
682
|
+
margin-left: 5px;
|
|
683
|
+
padding: 0 5px;
|
|
684
|
+
border: 1px solid var(--accent-dim);
|
|
685
|
+
border-radius: 999px;
|
|
686
|
+
background: var(--accent-dim);
|
|
687
|
+
color: var(--accent-text);
|
|
688
|
+
font-family: var(--mono);
|
|
689
|
+
font-size: 9px;
|
|
690
|
+
line-height: 14px;
|
|
691
|
+
vertical-align: middle;
|
|
692
|
+
white-space: nowrap;
|
|
693
|
+
overflow: hidden;
|
|
694
|
+
text-overflow: ellipsis;
|
|
695
|
+
}
|
|
696
|
+
|
|
674
697
|
.session-branch {
|
|
675
698
|
font-size: 10px;
|
|
676
699
|
color: var(--text-secondary);
|
|
@@ -858,6 +881,68 @@ body::before {
|
|
|
858
881
|
color: var(--text-secondary);
|
|
859
882
|
}
|
|
860
883
|
|
|
884
|
+
/* Zen view: the sidebar holds one card, so the usage detail that normally lives behind the
|
|
885
|
+
info modal gets rendered underneath it. */
|
|
886
|
+
.zen-panel {
|
|
887
|
+
margin-top: 10px;
|
|
888
|
+
/* No bottom padding: the linked-docs section that always closes the panel carries its own. */
|
|
889
|
+
padding: 10px 10px 0;
|
|
890
|
+
background: var(--bg-elevated);
|
|
891
|
+
border: 1px solid var(--border);
|
|
892
|
+
border-radius: 8px;
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
.zen-panel .detail-context {
|
|
896
|
+
margin-bottom: 0;
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
.zen-panel .detail-context-stats {
|
|
900
|
+
gap: 3px 10px;
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
.zen-panel .linked-docs-section {
|
|
904
|
+
margin-top: 12px;
|
|
905
|
+
padding-top: 10px;
|
|
906
|
+
border-top: 1px solid var(--border);
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
.zen-panel .artifacts-section {
|
|
910
|
+
padding-top: 10px;
|
|
911
|
+
border-top: 1px solid var(--border);
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
.zen-scratchpad {
|
|
915
|
+
display: flex;
|
|
916
|
+
align-items: center;
|
|
917
|
+
gap: 8px;
|
|
918
|
+
margin-top: 12px;
|
|
919
|
+
padding-top: 10px;
|
|
920
|
+
border-top: 1px solid var(--border);
|
|
921
|
+
font-size: 11px;
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
.zen-scratchpad-label {
|
|
925
|
+
font-weight: 500;
|
|
926
|
+
color: var(--text-muted);
|
|
927
|
+
text-transform: uppercase;
|
|
928
|
+
letter-spacing: 0.5px;
|
|
929
|
+
white-space: nowrap;
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
.zen-scratchpad-path {
|
|
933
|
+
flex: 1;
|
|
934
|
+
min-width: 0;
|
|
935
|
+
overflow: hidden;
|
|
936
|
+
text-overflow: ellipsis;
|
|
937
|
+
white-space: nowrap;
|
|
938
|
+
font-family: var(--font-mono);
|
|
939
|
+
color: var(--text-secondary);
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
.zen-panel:hover .row-actions {
|
|
943
|
+
opacity: 1;
|
|
944
|
+
}
|
|
945
|
+
|
|
861
946
|
.session-time {
|
|
862
947
|
font-size: 11px;
|
|
863
948
|
font-weight: 450;
|
|
@@ -3818,7 +3903,8 @@ body::before {
|
|
|
3818
3903
|
color: var(--text-primary);
|
|
3819
3904
|
}
|
|
3820
3905
|
|
|
3821
|
-
.linked-doc-list
|
|
3906
|
+
.linked-doc-list,
|
|
3907
|
+
.artifact-list {
|
|
3822
3908
|
list-style: none;
|
|
3823
3909
|
padding: 0;
|
|
3824
3910
|
margin: 0;
|
|
@@ -3827,14 +3913,16 @@ body::before {
|
|
|
3827
3913
|
gap: 4px;
|
|
3828
3914
|
}
|
|
3829
3915
|
|
|
3830
|
-
.linked-doc-item
|
|
3916
|
+
.linked-doc-item,
|
|
3917
|
+
.artifact-item {
|
|
3831
3918
|
display: flex;
|
|
3832
3919
|
align-items: baseline;
|
|
3833
3920
|
gap: 8px;
|
|
3834
3921
|
min-width: 0;
|
|
3835
3922
|
}
|
|
3836
3923
|
|
|
3837
|
-
.linked-doc-link
|
|
3924
|
+
.linked-doc-link,
|
|
3925
|
+
.artifact-link {
|
|
3838
3926
|
color: var(--accent-text);
|
|
3839
3927
|
text-decoration: underline;
|
|
3840
3928
|
text-decoration-style: dotted;
|
|
@@ -3855,13 +3943,15 @@ body::before {
|
|
|
3855
3943
|
/* Rides on the shared .row-actions group; only the reveal trigger differs — per row
|
|
3856
3944
|
here, per grid in the session info. margin-left:auto pins the group to the same
|
|
3857
3945
|
right edge however long the names in between are. */
|
|
3858
|
-
.linked-doc-actions
|
|
3946
|
+
.linked-doc-actions,
|
|
3947
|
+
.artifact-actions {
|
|
3859
3948
|
margin-left: auto;
|
|
3860
3949
|
align-self: center;
|
|
3861
3950
|
flex-shrink: 0;
|
|
3862
3951
|
}
|
|
3863
3952
|
|
|
3864
|
-
.linked-doc-item:hover .linked-doc-actions
|
|
3953
|
+
.linked-doc-item:hover .linked-doc-actions,
|
|
3954
|
+
.artifact-item:hover .artifact-actions {
|
|
3865
3955
|
opacity: 1;
|
|
3866
3956
|
}
|
|
3867
3957
|
|
|
@@ -3885,7 +3975,55 @@ body::before {
|
|
|
3885
3975
|
text-decoration-style: dashed;
|
|
3886
3976
|
}
|
|
3887
3977
|
|
|
3888
|
-
|
|
3978
|
+
/* A section of the session panel: linked documents, artifacts. */
|
|
3979
|
+
.panel-section {
|
|
3980
|
+
margin-bottom: 16px;
|
|
3981
|
+
font-size: 12px;
|
|
3982
|
+
}
|
|
3983
|
+
|
|
3984
|
+
/* Artifacts are read out of the transcript, so the section renders before the
|
|
3985
|
+
list arrives and stays in the DOM as the patch target when there is none. */
|
|
3986
|
+
.artifacts-section:empty {
|
|
3987
|
+
display: none;
|
|
3988
|
+
}
|
|
3989
|
+
|
|
3990
|
+
.panel-section-header {
|
|
3991
|
+
display: flex;
|
|
3992
|
+
align-items: center;
|
|
3993
|
+
gap: 6px;
|
|
3994
|
+
margin-bottom: 6px;
|
|
3995
|
+
font-size: 11px;
|
|
3996
|
+
font-weight: 500;
|
|
3997
|
+
color: var(--text-muted);
|
|
3998
|
+
text-transform: uppercase;
|
|
3999
|
+
letter-spacing: 0.5px;
|
|
4000
|
+
}
|
|
4001
|
+
|
|
4002
|
+
.panel-section-header svg {
|
|
4003
|
+
width: 12px;
|
|
4004
|
+
height: 12px;
|
|
4005
|
+
}
|
|
4006
|
+
|
|
4007
|
+
.panel-section-count {
|
|
4008
|
+
background: var(--bg-elevated);
|
|
4009
|
+
border: 1px solid var(--border);
|
|
4010
|
+
border-radius: 10px;
|
|
4011
|
+
padding: 0 6px;
|
|
4012
|
+
font-size: 10px;
|
|
4013
|
+
color: var(--text-secondary);
|
|
4014
|
+
}
|
|
4015
|
+
|
|
4016
|
+
/* The artifact title is a URL away from the reader, so it truncates where a file
|
|
4017
|
+
name, which the linked-doc row shows in full, does not. */
|
|
4018
|
+
.artifact-link {
|
|
4019
|
+
overflow: hidden;
|
|
4020
|
+
text-overflow: ellipsis;
|
|
4021
|
+
white-space: nowrap;
|
|
4022
|
+
min-width: 0;
|
|
4023
|
+
}
|
|
4024
|
+
|
|
4025
|
+
.linked-docs-empty,
|
|
4026
|
+
.zen-panel-empty {
|
|
3889
4027
|
color: var(--text-muted);
|
|
3890
4028
|
font-size: 11px;
|
|
3891
4029
|
font-style: italic;
|
|
@@ -4122,6 +4260,7 @@ body.light {
|
|
|
4122
4260
|
--bg-surface: #efede9;
|
|
4123
4261
|
--bg-elevated: #fbfaf9;
|
|
4124
4262
|
--bg-hover: #d2d0cc;
|
|
4263
|
+
--session-active-bg: var(--bg-elevated);
|
|
4125
4264
|
--border: #cfcbc4;
|
|
4126
4265
|
--text-primary: #0a0a0a;
|
|
4127
4266
|
--text-secondary: #444444;
|
|
@@ -5450,6 +5589,7 @@ select.form-input option:checked {
|
|
|
5450
5589
|
--bg-surface: #efede9;
|
|
5451
5590
|
--bg-elevated: #fbfaf9;
|
|
5452
5591
|
--bg-hover: #d2d0cc;
|
|
5592
|
+
--session-active-bg: var(--bg-elevated);
|
|
5453
5593
|
--border: #cfcbc4;
|
|
5454
5594
|
--text-primary: #0a0a0a;
|
|
5455
5595
|
--text-secondary: #444444;
|
package/server.js
CHANGED
|
@@ -21,6 +21,7 @@ const {
|
|
|
21
21
|
readSessionInfoFromJsonl,
|
|
22
22
|
buildSessionDigest,
|
|
23
23
|
readCompactSummaries,
|
|
24
|
+
readArtifactLinks,
|
|
24
25
|
extractPromptFromTranscript,
|
|
25
26
|
extractModelFromTranscript,
|
|
26
27
|
extractStructuredResultFromTranscript,
|
|
@@ -33,7 +34,7 @@ const {
|
|
|
33
34
|
buildLoopInfoFromState
|
|
34
35
|
} = require('./lib/parsers');
|
|
35
36
|
const { inlineHtmlAssets } = require('./lib/inline-assets');
|
|
36
|
-
const { buildDecision, decisionFileName, isDecisionFile,
|
|
37
|
+
const { buildDecision, decisionFileName, isDecisionFile, approvalsFrom, boardRefusal } = require('./lib/approvals');
|
|
37
38
|
const { getClaudeDir, getArgValue, storageNamespace } = require('./lib/claude-dir');
|
|
38
39
|
|
|
39
40
|
if (process.argv.includes("--install") || process.argv.includes("--uninstall")) {
|
|
@@ -105,12 +106,33 @@ function writePins(pins) {
|
|
|
105
106
|
writeJsonAtomic(PINS_FILE, pins);
|
|
106
107
|
}
|
|
107
108
|
|
|
108
|
-
// Port discovery for out-of-process helpers (the postman monitor).
|
|
109
|
-
// so a reader can tell a live server from a file left behind by
|
|
109
|
+
// Port discovery for out-of-process helpers (the postman monitor, approval-gate.sh).
|
|
110
|
+
// The pid rides along so a reader can tell a live server from a file left behind by
|
|
111
|
+
// a crashed one.
|
|
110
112
|
function writeServerInfo(port) {
|
|
111
113
|
writeJsonAtomic(SERVER_INFO_FILE, { port, pid: process.pid });
|
|
112
114
|
}
|
|
113
115
|
|
|
116
|
+
// A beacon that outlives its server disarms UI approvals silently: approval-gate.sh
|
|
117
|
+
// probes the port, finds it closed and never waits. The hub spawns every sub-app on
|
|
118
|
+
// an ephemeral port, so a stale beacon never comes back on its own — drop ours on
|
|
119
|
+
// the way out. Only when the file is still ours: a newer server on the same config
|
|
120
|
+
// dir has already claimed it.
|
|
121
|
+
function removeServerInfo() {
|
|
122
|
+
try {
|
|
123
|
+
const info = JSON.parse(readFileSync(SERVER_INFO_FILE, 'utf8'));
|
|
124
|
+
if (info.pid === process.pid) unlinkSync(SERVER_INFO_FILE);
|
|
125
|
+
} catch (_) { /* gone, unreadable, or not ours */ }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
process.on('exit', removeServerInfo);
|
|
129
|
+
for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
|
|
130
|
+
process.on(sig, () => {
|
|
131
|
+
removeServerInfo();
|
|
132
|
+
process.exit(0);
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
114
136
|
// #endregion
|
|
115
137
|
|
|
116
138
|
// #region TIMINGS
|
|
@@ -155,23 +177,30 @@ function persistAgent(dir, agent) {
|
|
|
155
177
|
fs.appendFile(file, `${JSON.stringify({ ...agent, event: 'server-update' })}\n`, 'utf8').catch(() => {});
|
|
156
178
|
}
|
|
157
179
|
|
|
158
|
-
//
|
|
159
|
-
//
|
|
160
|
-
|
|
161
|
-
const
|
|
162
|
-
|
|
163
|
-
function
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
try {
|
|
167
|
-
cfg = JSON.parse(readFileSync(APPROVALS_CONFIG_FILE, 'utf8'));
|
|
168
|
-
} catch { /* missing config — gate default */ }
|
|
169
|
-
approvalsWaitCache = { ts: Date.now(), ms: waitSecondsFrom(cfg) * 1000 };
|
|
170
|
-
return approvalsWaitCache.ms;
|
|
180
|
+
// <CCK_DIR>/config.json — cck's own settings, per Claude config dir.
|
|
181
|
+
// Normalization lives in lib/approvals (kept in sync with the gate's own parse).
|
|
182
|
+
const CCK_CONFIG_FILE = path.join(CCK_DIR, 'config.json');
|
|
183
|
+
const LEGACY_APPROVALS_FILE = path.join(CCK_DIR, 'approvals.json');
|
|
184
|
+
const cckConfigCache = new Map();
|
|
185
|
+
function approvalsConfig() {
|
|
186
|
+
return cachedByMtime(cckConfigCache, 'approvals', CCK_CONFIG_FILE,
|
|
187
|
+
() => approvalsFrom(JSON.parse(readFileSync(CCK_CONFIG_FILE, 'utf8'))), approvalsFrom(null));
|
|
171
188
|
}
|
|
172
189
|
|
|
173
|
-
|
|
174
|
-
|
|
190
|
+
// approvals.json predates config.json (and was opt-in). Fold it into
|
|
191
|
+
// config.json once so an existing opt-in keeps its tuning, then drop it.
|
|
192
|
+
function migrateLegacyApprovalsConfig() {
|
|
193
|
+
if (!existsSync(LEGACY_APPROVALS_FILE)) return;
|
|
194
|
+
try {
|
|
195
|
+
const legacy = JSON.parse(readFileSync(LEGACY_APPROVALS_FILE, 'utf8'));
|
|
196
|
+
let cfg = {};
|
|
197
|
+
try { cfg = JSON.parse(readFileSync(CCK_CONFIG_FILE, 'utf8')) || {}; } catch { /* absent */ }
|
|
198
|
+
if (!cfg.approvals) writeJsonAtomic(CCK_CONFIG_FILE, { ...cfg, approvals: legacy });
|
|
199
|
+
unlinkSync(LEGACY_APPROVALS_FILE);
|
|
200
|
+
cckConfigCache.clear();
|
|
201
|
+
} catch (e) {
|
|
202
|
+
console.error(`[cck] could not migrate ${LEGACY_APPROVALS_FILE}: ${e.message}`);
|
|
203
|
+
}
|
|
175
204
|
}
|
|
176
205
|
|
|
177
206
|
function checkWaitingForUser(agentDir, logMtime) {
|
|
@@ -183,7 +212,9 @@ function checkWaitingForUser(agentDir, logMtime) {
|
|
|
183
212
|
if (age >= PERMISSION_TTL_MS) return null;
|
|
184
213
|
// After grace period, check if session resumed activity (user already responded)
|
|
185
214
|
if (logMtime && age >= WAITING_RESOLVE_GRACE_MS && logMtime > waitTime + WAITING_RESOLVE_GRACE_MS) return null;
|
|
186
|
-
|
|
215
|
+
// Opted out reads the same as lapsed to the board: the ask is only
|
|
216
|
+
// answerable in the terminal, so no buttons.
|
|
217
|
+
if (boardRefusal(data, approvalsConfig())) return { ...data, lapsed: true };
|
|
187
218
|
return data;
|
|
188
219
|
}
|
|
189
220
|
} catch { /* skip — missing or invalid */ }
|
|
@@ -246,6 +277,34 @@ function getGitBranch(cwd) {
|
|
|
246
277
|
return branch;
|
|
247
278
|
}
|
|
248
279
|
|
|
280
|
+
// A linked worktree's `.git` is a file holding `gitdir: <main>/.git/worktrees/<name>`, so the
|
|
281
|
+
// main checkout is readable without spawning git. Path shape alone would not do: only some
|
|
282
|
+
// worktrees live under `<repo>/.claude/worktrees/`, the rest sit beside the repo.
|
|
283
|
+
// Cached without a TTL, misses included: a directory cannot turn from an ordinary checkout into
|
|
284
|
+
// a linked worktree without being recreated, and a recreated path is a new cache key.
|
|
285
|
+
const worktreeCache = new Map();
|
|
286
|
+
const WORKTREE_CACHE_MAX = 500;
|
|
287
|
+
const GITDIR_WORKTREE_RE = /^gitdir:\s*(.*)[/\\]\.git[/\\]worktrees[/\\]([^/\\]+)[/\\]?$/;
|
|
288
|
+
|
|
289
|
+
function resolveWorktree(dir) {
|
|
290
|
+
if (!dir) return null;
|
|
291
|
+
if (worktreeCache.has(dir)) return worktreeCache.get(dir);
|
|
292
|
+
|
|
293
|
+
let worktree = null;
|
|
294
|
+
try {
|
|
295
|
+
// An ordinary checkout's `.git` is a directory, so the read throws EISDIR — that is the
|
|
296
|
+
// answer, and it costs one syscall instead of a stat followed by a read.
|
|
297
|
+
const m = GITDIR_WORKTREE_RE.exec(readFileSync(path.join(dir, '.git'), 'utf8').trim());
|
|
298
|
+
if (m) worktree = { repo: m[1], name: m[2] };
|
|
299
|
+
} catch (_) {}
|
|
300
|
+
|
|
301
|
+
worktreeCache.set(dir, worktree);
|
|
302
|
+
if (worktreeCache.size > WORKTREE_CACHE_MAX) {
|
|
303
|
+
worktreeCache.delete(worktreeCache.keys().next().value);
|
|
304
|
+
}
|
|
305
|
+
return worktree;
|
|
306
|
+
}
|
|
307
|
+
|
|
249
308
|
// Only spawn git when cwd has diverged from the launch project — that's the
|
|
250
309
|
// only case the JSONL value is wrong. Saves N spawns on a typical list build.
|
|
251
310
|
function resolveSessionGitBranch(meta) {
|
|
@@ -1015,12 +1074,30 @@ function getSessionDisplayName(_sessionId, meta) {
|
|
|
1015
1074
|
return null;
|
|
1016
1075
|
}
|
|
1017
1076
|
|
|
1077
|
+
// The harness spells a project path into a temp/log dir name by replacing every
|
|
1078
|
+
// non-alphanumeric character with a dash, drive colon and separators included.
|
|
1079
|
+
function encodeProjectDirName(p) {
|
|
1080
|
+
return p.replace(/[^a-zA-Z0-9]/g, '-');
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1018
1083
|
// Derived by convention, not looked up: the harness creates the dir lazily, so a
|
|
1019
1084
|
// stat here would report "missing" for every session that has not written a temp
|
|
1020
1085
|
// file yet — and it would put IO on the session-list hot path. Pure string join.
|
|
1021
1086
|
function getScratchpadDir(id, meta) {
|
|
1022
1087
|
if (!meta.jsonlPath) return null;
|
|
1023
|
-
|
|
1088
|
+
const byProject = path.join(SCRATCHPAD_ROOT, path.basename(path.dirname(meta.jsonlPath)), id, 'scratchpad');
|
|
1089
|
+
|
|
1090
|
+
// A session launched with `claude -w` starts in the main checkout and enters the
|
|
1091
|
+
// worktree afterwards, so the harness keys its scratchpad on the repo while the
|
|
1092
|
+
// transcript ends up filed under the worktree. Neither the project nor any `cwd`
|
|
1093
|
+
// in the log records that, so the two candidates are told apart by which one the
|
|
1094
|
+
// harness actually created. Probed only for worktree sessions, and only until one
|
|
1095
|
+
// of them exists — before that there is nothing to disambiguate and `byProject`,
|
|
1096
|
+
// right for a session started inside the worktree, stands.
|
|
1097
|
+
const wt = resolveWorktree(meta.project);
|
|
1098
|
+
if (!wt || existsSync(byProject)) return byProject;
|
|
1099
|
+
const byRepo = path.join(SCRATCHPAD_ROOT, encodeProjectDirName(wt.repo), id, 'scratchpad');
|
|
1100
|
+
return existsSync(byRepo) ? byRepo : byProject;
|
|
1024
1101
|
}
|
|
1025
1102
|
|
|
1026
1103
|
function buildSessionObject(id, meta, overrides = {}) {
|
|
@@ -1035,6 +1112,7 @@ function buildSessionObject(id, meta, overrides = {}) {
|
|
|
1035
1112
|
cwd: meta.cwd || null,
|
|
1036
1113
|
description: meta.description || null,
|
|
1037
1114
|
gitBranch: resolveSessionGitBranch(meta),
|
|
1115
|
+
worktree: resolveWorktree(meta.project),
|
|
1038
1116
|
customTitle: meta.customTitle || null,
|
|
1039
1117
|
goal: meta.goal || null,
|
|
1040
1118
|
taskCount: 0,
|
|
@@ -1574,6 +1652,32 @@ app.get('/api/sessions/:sessionId/loop', (req, res) => {
|
|
|
1574
1652
|
}
|
|
1575
1653
|
});
|
|
1576
1654
|
|
|
1655
|
+
// Cold path — a full transcript scan, so the result is held until the file grows.
|
|
1656
|
+
// The zen panel asks again on every re-render, which then costs one stat.
|
|
1657
|
+
const artifactsByPath = new Map();
|
|
1658
|
+
|
|
1659
|
+
function getArtifactLinks(jsonlPath) {
|
|
1660
|
+
let stat;
|
|
1661
|
+
try { stat = statSync(jsonlPath); } catch (_) { return []; }
|
|
1662
|
+
const hit = artifactsByPath.get(jsonlPath);
|
|
1663
|
+
if (hit && hit.mtimeMs === stat.mtimeMs && hit.size === stat.size) return hit.artifacts;
|
|
1664
|
+
const artifacts = readArtifactLinks(jsonlPath);
|
|
1665
|
+
artifactsByPath.set(jsonlPath, { mtimeMs: stat.mtimeMs, size: stat.size, artifacts });
|
|
1666
|
+
return artifacts;
|
|
1667
|
+
}
|
|
1668
|
+
|
|
1669
|
+
app.get('/api/sessions/:sessionId/artifacts', (req, res) => {
|
|
1670
|
+
try {
|
|
1671
|
+
const metadata = loadSessionMetadata();
|
|
1672
|
+
const meta = metadata[req.params.sessionId] || metadata[resolveSessionId(req.params.sessionId)];
|
|
1673
|
+
if (!meta?.jsonlPath) return res.json({ artifacts: [] });
|
|
1674
|
+
res.json({ artifacts: getArtifactLinks(meta.jsonlPath) });
|
|
1675
|
+
} catch (error) {
|
|
1676
|
+
console.error('Error reading artifacts:', error);
|
|
1677
|
+
res.status(500).json({ error: 'Failed to read artifacts' });
|
|
1678
|
+
}
|
|
1679
|
+
});
|
|
1680
|
+
|
|
1577
1681
|
// API: List workflow scripts for a session. Parses each script's meta for the
|
|
1578
1682
|
// canonical name + description (cold path — only when the workflow modal opens).
|
|
1579
1683
|
app.get('/api/sessions/:sessionId/workflows', async (req, res) => {
|
|
@@ -2161,9 +2265,8 @@ app.post('/api/sessions/:sessionId/waiting/respond', (req, res) => {
|
|
|
2161
2265
|
catch { /* missing or invalid → buildDecision reports 410 */ }
|
|
2162
2266
|
// The gate stopped polling after waitSeconds — an orphaned decision file would
|
|
2163
2267
|
// sit unconsumed while the card pretends the click worked.
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
}
|
|
2268
|
+
const refusal = marker && boardRefusal(marker, approvalsConfig());
|
|
2269
|
+
if (refusal) return res.status(refusal.status).json({ error: refusal.error });
|
|
2167
2270
|
const result = buildDecision(marker, req.body || {});
|
|
2168
2271
|
if (result.error) return res.status(result.status).json({ error: result.error });
|
|
2169
2272
|
try {
|
|
@@ -3454,6 +3557,7 @@ async function prewarmCaches() {
|
|
|
3454
3557
|
// The port is configurable and falls back to a random one when taken, so the postman
|
|
3455
3558
|
// monitor cannot assume it -- publish the live one where it can read it.
|
|
3456
3559
|
writeServerInfo(actualPort);
|
|
3560
|
+
migrateLegacyApprovalsConfig();
|
|
3457
3561
|
const warning = net.exposureWarning();
|
|
3458
3562
|
if (warning) console.log(warning);
|
|
3459
3563
|
|