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 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 (opt-in)** — Allow/deny permission asks and answer questions from the board — [docs](docs/ui-approvals.md)
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('✓')} ${inst.idempotent ? 'Already installed' : 'Plugin installed'}`);
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 = 30;
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": "4.25.0",
3
+ "version": "4.26.0",
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": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-kanban",
3
- "version": "2.11.0",
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 — only when explicitly enabled and
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 approvals.json {enabled, mode, waitSeconds} (D2: fail-open when absent)
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
- CONFIG="$CCK_DIR/approvals.json"
65
- [ -f "$CONFIG" ] || exit 0
66
- ENABLED=""
67
- eval "$(jq -r '
68
- @sh "ENABLED=\(.enabled // false)",
69
- @sh "MODE=\(.mode // "permission")",
70
- @sh "WAIT_SECONDS=\(.waitSeconds // 30)"
71
- ' < "$CONFIG" 2>/dev/null)"
72
- [ "$ENABLED" = "true" ] || exit 0
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=30 ;; esac
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
- [ -f "$SERVER_INFO" ] || exit 0
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
- (: < "/dev/tcp/127.0.0.1/$SERVER_PORT") 2>/dev/null || exit 0
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
- const filteredSessions = getFilteredSessions();
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 (searchQuery) {
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 useGrouped = sessionFilter === 'active' && session.project;
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}` : ''].filter(Boolean).join(' | ');
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
- const showCtx = !!session.contextStatus;
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(primaryName)}</div>
3241
- ${secondaryName ? `<div class="session-secondary">${escapeHtml(secondaryName)}</div>` : ''}
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.split('/').pop() : null;
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-sections of both, and — since which section
4653
- // holds it depends on the view — every section header. Returns whether anything changed.
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.split(/[/\\]/).pop() || 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 (document.querySelector('.modal-overlay.visible')) {
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" style="margin-bottom:16px;font-size:12px;">
6480
- <div style="font-size:11px;font-weight:500;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.5px;margin-bottom:6px;display:flex;align-items:center;gap:6px;">
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 style="background:var(--bg-elevated);border:1px solid var(--border);border-radius:10px;padding:0 6px;font-size:10px;color:var(--text-secondary);">${paths.length}</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 ['$tmp', '…', ...dir.split(/[/\\]/).slice(-2)].join(sep);
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. The cursor
579
- must win when both land on one card, which is why it is ordered second. */
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-deep);
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: 13px;
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
- .linked-docs-empty {
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, waitSecondsFrom, isLapsed } = require('./lib/approvals');
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). The pid rides along
109
- // so a reader can tell a live server from a file left behind by a crashed one.
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
- // Lapse math lives in lib/approvals (kept in sync with the gate's own parse);
159
- // this only reads the config, cached so the polled session list doesn't re-read
160
- // it per hit (marker stays visible for the badge until TTL).
161
- const APPROVALS_CONFIG_FILE = path.join(CCK_DIR, 'approvals.json');
162
- let approvalsWaitCache = { ts: 0, ms: 30 * 1000 };
163
- function approvalsWaitMs() {
164
- if (Date.now() - approvalsWaitCache.ts < 10 * 1000) return approvalsWaitCache.ms;
165
- let cfg = null;
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
- function isWaitingLapsed(data) {
174
- return isLapsed(data.timestamp, approvalsWaitMs());
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
- if (isWaitingLapsed(data)) return { ...data, lapsed: true };
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
- return path.join(SCRATCHPAD_ROOT, path.basename(path.dirname(meta.jsonlPath)), id, 'scratchpad');
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
- if (marker && isWaitingLapsed(marker)) {
2165
- return res.status(410).json({ error: 'Ask lapsed — answer it in the terminal' });
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