claude-code-kanban 5.3.0 → 6.0.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.
Files changed (36) hide show
  1. package/README.md +4 -4
  2. package/cli.js +30 -108
  3. package/install.js +43 -123
  4. package/lib/approvals.js +16 -17
  5. package/lib/dispatch-groups.js +9 -31
  6. package/lib/dispatch.js +19 -166
  7. package/lib/git-branch.js +62 -0
  8. package/lib/parsers.js +96 -59
  9. package/lib/plugin-status.js +1 -1
  10. package/lib/retention.js +5 -17
  11. package/lib/session-cache.js +1 -1
  12. package/lib/session-events.js +3 -2
  13. package/lib/terminal.js +30 -5
  14. package/package.json +1 -2
  15. package/plugin/.claude-plugin/marketplace.json +1 -1
  16. package/plugin/plugins/claude-code-kanban/.claude-plugin/plugin.json +2 -2
  17. package/plugin/plugins/claude-code-kanban/hooks/activity.ts +266 -0
  18. package/plugin/plugins/claude-code-kanban/hooks/context.ts +77 -0
  19. package/plugin/plugins/claude-code-kanban/hooks/hooks.json +1 -93
  20. package/plugin/plugins/claude-code-kanban/hooks/index.ts +8 -0
  21. package/plugin/plugins/claude-code-kanban/monitors.json +0 -6
  22. package/plugin/plugins/claude-code-kanban/scripts/postman.js +6 -11
  23. package/plugin/plugins/claude-code-kanban/skills/dispatch/SKILL.md +17 -11
  24. package/plugin/plugins/claude-code-kanban/skills/dispatch/references/orchestration-patterns.md +50 -0
  25. package/plugin/plugins/claude-code-kanban/tests/activity.test.ts +300 -0
  26. package/plugin/plugins/claude-code-kanban/tests/context.test.ts +72 -0
  27. package/plugin/plugins/claude-code-kanban/tsconfig.json +3 -0
  28. package/public/app.js +257 -74
  29. package/public/index.html +12 -2
  30. package/public/project-match.js +6 -1
  31. package/public/style.css +99 -0
  32. package/server.js +146 -147
  33. package/skill-guides/dispatch.md +16 -54
  34. package/plugin/plugins/claude-code-kanban/scripts/agent-spy.sh +0 -135
  35. package/plugin/plugins/claude-code-kanban/scripts/approval-gate.sh +0 -208
  36. package/plugin/plugins/claude-code-kanban/scripts/context-status.sh +0 -18
package/public/app.js CHANGED
@@ -4,7 +4,8 @@ const STORAGE_NS = window.__STORAGE_NS__ ? `${window.__STORAGE_NS__}:` : '';
4
4
  // Theme is hub-wide (echoed to every app via hub:theme), so it stays shared across config dirs.
5
5
  const GLOBAL_KEYS = new Set(['theme', 'color-theme']);
6
6
  const nsKey = (k) => (GLOBAL_KEYS.has(k) ? k : STORAGE_NS + k);
7
- const { normalizeProjectPath, isExactProjectPath, isExactProjectFilter, projectMatcher } = projectMatch;
7
+ const { normalizeProjectPath, isExactProjectPath, isExactProjectFilter, projectMatcher, sessionProjectKey } =
8
+ projectMatch;
8
9
  const store = {
9
10
  keys() {
10
11
  const out = [];
@@ -1541,7 +1542,7 @@ function renderWaitingBody(tool, params) {
1541
1542
  const PLAN_REJECT_MSG = 'Plan rejected from the kanban board.';
1542
1543
 
1543
1544
  // Answerable only when the marker carries a request id (written by
1544
- // approval-gate.sh, which is polling for a decision) and the gate hasn't
1545
+ // the plugin's mod, which is polling for a decision) and the gate hasn't
1545
1546
  // lapsed past its wait window — older or lapsed markers are only answerable
1546
1547
  // in the terminal.
1547
1548
  function isWaitingAnswerable() {
@@ -1600,9 +1601,9 @@ function rejectWaitingPlan(inputId) {
1600
1601
  respondWaiting({ behavior: 'deny', message: feedback || PLAN_REJECT_MSG });
1601
1602
  }
1602
1603
 
1603
- // UI-driven approvals: POST the decision the blocking approval-gate.sh hook is
1604
- // polling for. First writer wins (D5) — a 409/410 means the ask was answered in
1605
- // the terminal or superseded, so just drop the card.
1604
+ // UI-driven approvals: POST the decision the plugin's mod is polling for.
1605
+ // First writer wins (D5) — a 409/410 means the ask was answered in the
1606
+ // terminal or superseded, so just drop the card.
1606
1607
  async function respondWaiting(payload) {
1607
1608
  if (!currentSessionId || !currentWaiting?.id) return;
1608
1609
  try {
@@ -3600,7 +3601,8 @@ function renderSessions() {
3600
3601
  const showCtx = !!session.contextStatus && !zenMode;
3601
3602
  const linkedDocsCount = getSessionPreviewPaths(session.id).length;
3602
3603
  const bookmarksCount = loadPins(session.id).length;
3603
- const hasScratchpad = _hasScratchpad(_sessionScratchpadKey(session.id));
3604
+ const padEmoji = store.getItem(_padEmojiKey(session.id));
3605
+ const hasScratchpad = !!padEmoji || _hasScratchpad(_sessionScratchpadKey(session.id));
3604
3606
  const tempClass =
3605
3607
  session.hasRecentLog || session.hasRecentActivity || session.inProgress || session.hasWaitingForUser
3606
3608
  ? 'warm'
@@ -3631,7 +3633,7 @@ function renderSessions() {
3631
3633
  ${isTeam || session.project || showCtx ? `<span class="team-info-btn" onclick="event.stopPropagation(); showSessionInfoModal('${sid}')" title="View session info"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg></span>` : ''}
3632
3634
  ${renderWorkflowBadge(session)}
3633
3635
  ${renderLoopBadge(session)}
3634
- ${hasScratchpad ? `<span class="scratchpad-badge" onclick="event.stopPropagation(); openSessionScratchpad('${sid}')" title="Open scratchpad"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg></span>` : ''}
3636
+ ${hasScratchpad ? `<span class="scratchpad-badge${padEmoji ? ' has-emoji' : ''}" onclick="event.stopPropagation(); openSessionScratchpad('${sid}')" title="Open scratchpad">${padEmoji ? escapeHtml(padEmoji) : `<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>`}</span>` : ''}
3635
3637
  ${bookmarksCount > 0 ? `<span class="bookmarks-badge" onclick="event.stopPropagation(); openSessionWithBookmarks('${sid}')" title="${bookmarksCount} bookmarked message${bookmarksCount > 1 ? 's' : ''}"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg>${bookmarksCount}</span>` : ''}
3636
3638
  ${linkedDocsCount > 0 ? `<span class="linked-docs-badge" onclick="event.stopPropagation(); showSessionInfoModal('${sid}')" title="${linkedDocsCount} linked document${linkedDocsCount > 1 ? 's' : ''}">${linkSvg(10)}${linkedDocsCount}</span>` : ''}
3637
3639
  ${session.hasPlan && !session.planSourceSessionId ? `<span class="plan-indicator" onclick="event.stopPropagation(); openPlanForSession('${sid}')" title="View plan">${ICON_PLAN}</span>` : ''}
@@ -3781,7 +3783,7 @@ function renderSessions() {
3781
3783
  // Each session under its own project block, so a worktree session reads as its repo's.
3782
3784
  const byProject = new Map();
3783
3785
  for (const s of bucket) {
3784
- const key = s.project || '';
3786
+ const key = sessionProjectKey(s) || '';
3785
3787
  if (!byProject.has(key)) byProject.set(key, []);
3786
3788
  byProject.get(key).push(s);
3787
3789
  }
@@ -3844,9 +3846,10 @@ function renderSessions() {
3844
3846
  const groups = new Map();
3845
3847
  const ungrouped = [];
3846
3848
  for (const session of sgRest) {
3847
- if (session.project) {
3848
- if (!groups.has(session.project)) groups.set(session.project, []);
3849
- groups.get(session.project).push(session);
3849
+ const key = sessionProjectKey(session);
3850
+ if (key) {
3851
+ if (!groups.has(key)) groups.set(key, []);
3852
+ groups.get(key).push(session);
3850
3853
  } else {
3851
3854
  ungrouped.push(session);
3852
3855
  }
@@ -3861,12 +3864,13 @@ function renderSessions() {
3861
3864
  // back out restores its old slot instead of jumping to the top.
3862
3865
  const latestByPath = new Map();
3863
3866
  for (const s of filteredSessions) {
3864
- if (!s.project) continue;
3867
+ const key = sessionProjectKey(s);
3868
+ if (!key) continue;
3865
3869
  const t = new Date(s.modifiedAt).getTime();
3866
- latestByPath.set(s.project, Math.max(latestByPath.get(s.project) ?? -Infinity, t));
3870
+ latestByPath.set(key, Math.max(latestByPath.get(key) ?? -Infinity, t));
3867
3871
  }
3868
3872
  const byRecency = [...latestByPath.keys()].sort((a, b) => latestByPath.get(b) - latestByPath.get(a));
3869
- const loadedPaths = new Set(sessions.map((s) => s.project));
3873
+ const loadedPaths = new Set(sessions.map(sessionProjectKey));
3870
3874
  stableGroupOrder = mergeOrder(PROJECT_ORDER_KEY, stableGroupOrder, loadedPaths, byRecency);
3871
3875
  const sortedGroups = stableGroupOrder.filter((p) => groups.has(p)).map((p) => [p, groups.get(p)]);
3872
3876
 
@@ -4407,7 +4411,8 @@ function sgGroupForSession(session) {
4407
4411
  }
4408
4412
 
4409
4413
  function sgUserGroupFor(session) {
4410
- return sgGroupOf('session', session.id) || (session.project ? sgGroupOf('project', session.project) : null);
4414
+ const project = sessionProjectKey(session);
4415
+ return sgGroupOf('session', session.id) || (project ? sgGroupOf('project', project) : null);
4411
4416
  }
4412
4417
 
4413
4418
  // Transient groups come from the server (`dispatch start --group`) and go when their sessions
@@ -4424,20 +4429,18 @@ function sgTransientGroup(name) {
4424
4429
  return group;
4425
4430
  }
4426
4431
 
4427
- function dispatchedTitle({ parent, outcome }) {
4432
+ function dispatchedTitle({ parent }) {
4428
4433
  const starter = parent && sessions.find((s) => s.id === parent);
4429
4434
  const by = starter ? sessionDisplayName(starter) : 'another session';
4430
- return `Started by ${by}${outcome ? ` · ${outcome}` : ''}${parent ? ' — click to reveal it' : ''}`;
4435
+ return `Started by ${by}${parent ? ' — click to reveal it' : ''}`;
4431
4436
  }
4432
4437
 
4433
4438
  // A user group with the same name takes the session in, so Keep does not split a group in two.
4434
- // With no group of its own, a started session follows its starter into the starter's named group.
4435
4439
  function sgDispatchGroupFor(session) {
4436
4440
  if (sgReleased.has(session.id)) return null;
4437
4441
  const name = session.dispatchGroup;
4438
- if (name) return sessionGroups.find((g) => g.name.toLowerCase() === name) || sgTransientGroup(name);
4439
- const starter = session.startedBy && sessions.find((s) => s.id === session.startedBy);
4440
- return starter ? sgUserGroupFor(starter) : null;
4442
+ if (!name) return null;
4443
+ return sessionGroups.find((g) => g.name.toLowerCase() === name) || sgTransientGroup(name);
4441
4444
  }
4442
4445
 
4443
4446
  function sgKeepTransient(name) {
@@ -4467,7 +4470,8 @@ function sgHostOf(group, session) {
4467
4470
  const m = group.members.find((x) => x.type === 'session' && x.ref === session.id);
4468
4471
  const isMemberProject = (path) => !!path && group.members.some((x) => x.type === 'project' && x.ref === path);
4469
4472
  if (m?.under && isMemberProject(m.under)) return m.under;
4470
- if (!m?.loose && isMemberProject(session.project)) return session.project;
4473
+ const own = sessionProjectKey(session);
4474
+ if (!m?.loose && isMemberProject(own)) return own;
4471
4475
  return null;
4472
4476
  }
4473
4477
  // `opts.under` places the session under another project block of the group; `opts.loose` lifts it
@@ -4478,9 +4482,9 @@ function sgAssign(groupId, type, ref, opts = {}) {
4478
4482
  sgDetach(type, ref);
4479
4483
  // Pulling a whole project in supersedes the individual placements of its sessions.
4480
4484
  if (type === 'project') {
4481
- for (const s of sessions) if (s.project === ref) sgDetach('session', s.id);
4485
+ for (const s of sessions) if (sessionProjectKey(s) === ref) sgDetach('session', s.id);
4482
4486
  }
4483
- const own = type === 'session' ? sessions.find((s) => s.id === ref)?.project : null;
4487
+ const own = type === 'session' ? sessionProjectKey(sessions.find((s) => s.id === ref)) : null;
4484
4488
  // Under its own project block is simply the natural placement - no override needed.
4485
4489
  const under = opts.under && opts.under !== own ? opts.under : null;
4486
4490
  const loose = !under && !!opts.loose;
@@ -4758,7 +4762,7 @@ function sgGroupDropZone(target) {
4758
4762
  return fromHost === path && fromGroupId === pathGroup.id ? null : { zone, groupId: pathGroup.id, under: path };
4759
4763
  }
4760
4764
  // Stacking a session onto its own project block would only fold it back where it sits today.
4761
- if (dragSession?.project === path) return releasable ? { zone } : null;
4765
+ if (dragSession && sessionProjectKey(dragSession) === path) return releasable ? { zone } : null;
4762
4766
  return { zone, pairWith: path };
4763
4767
  }
4764
4768
 
@@ -4811,7 +4815,11 @@ function sgOnDrop(e) {
4811
4815
  // Stacked onto an ungrouped item: seed a group, then let the user name it in place.
4812
4816
  const target = hit.pairWith
4813
4817
  ? { type: 'project', ref: hit.pairWith, path: hit.pairWith }
4814
- : { type: 'session', ref: hit.pairSession, path: sessions.find((s) => s.id === hit.pairSession)?.project };
4818
+ : {
4819
+ type: 'session',
4820
+ ref: hit.pairSession,
4821
+ path: sessionProjectKey(sessions.find((s) => s.id === hit.pairSession)),
4822
+ };
4815
4823
  const group = sgCreateGroup(target.path ? target.path.split(/[/\\]/).pop() : '');
4816
4824
  sgAssign(group.id, target.type, target.ref);
4817
4825
  sgAssign(group.id, drag.kind, drag.ref);
@@ -5253,7 +5261,7 @@ function expandActiveGroups({ onlyNew = false } = {}) {
5253
5261
  function uncollapseFor(session) {
5254
5262
  const group = sgGroupForSession(session);
5255
5263
  const keys = [
5256
- session.project || '__ungrouped__',
5264
+ sessionProjectKey(session) || '__ungrouped__',
5257
5265
  ...pinnedCollapseKeys(session),
5258
5266
  group && sgKey(group.id),
5259
5267
  SECTION_GROUPS,
@@ -5271,7 +5279,11 @@ function pinnedCollapseKeys(session) {
5271
5279
  if (!isInPinnedGroup(session)) return [];
5272
5280
  const group = sgGroupForSession(session);
5273
5281
  const host = sgHostOf(group, session);
5274
- return [pinKey(session.project || '__ungrouped__'), host && pinKey(host), group && `__pinned_group_${group.id}__`];
5282
+ return [
5283
+ pinKey(sessionProjectKey(session) || '__ungrouped__'),
5284
+ host && pinKey(host),
5285
+ group && `__pinned_group_${group.id}__`,
5286
+ ];
5275
5287
  }
5276
5288
 
5277
5289
  // A card that moves into a collapsed Pinned sub-section would vanish from under the click.
@@ -5791,6 +5803,7 @@ const SHORTCUT_TABS = [
5791
5803
  { keys: ['Enter'], label: 'Toggle task detail panel' },
5792
5804
  { keys: ['D'], label: 'Delete selected task' },
5793
5805
  { keys: ['N'], label: 'Toggle scratchpad (in the sidebar: the item under the cursor)' },
5806
+ { keys: ['Ctrl', 'E'], combo: true, label: 'Set the session scratchpad emoji (in the scratchpad)' },
5794
5807
  { keys: ['R'], label: 'Refresh data' },
5795
5808
  { keys: ['Esc'], label: 'Close panel / clear selection' },
5796
5809
  ],
@@ -6037,6 +6050,140 @@ function _hasScratchpad(key) {
6037
6050
  return !!(store.getItem(key) || '').trim();
6038
6051
  }
6039
6052
 
6053
+ // Not under SESSION_PAD_PREFIX: _parsePadKey would read it as a session pad.
6054
+ const PAD_EMOJI_PREFIX = 'pad-emoji-';
6055
+ const PAD_EMOJIS = [
6056
+ '🔥',
6057
+ '🚧',
6058
+ '🐛',
6059
+ '✅',
6060
+ '⭐',
6061
+ '📌',
6062
+ '💡',
6063
+ '🧪',
6064
+ '🚀',
6065
+ '⚠️',
6066
+ '🔒',
6067
+ '🧹',
6068
+ '📝',
6069
+ '🎯',
6070
+ '🧭',
6071
+ '🛠️',
6072
+ '🔍',
6073
+ '📦',
6074
+ '🎨',
6075
+ '⏳',
6076
+ '❓',
6077
+ '💬',
6078
+ '🧠',
6079
+ '🏁',
6080
+ ];
6081
+ const PAD_EMOJI_COLS = 8;
6082
+ const _scratchpadEmojiBtn = document.getElementById('scratchpad-emoji-btn');
6083
+ const _scratchpadEmojiPicker = document.getElementById('scratchpad-emoji-picker');
6084
+ const _scratchpadEmojiGrid = document.getElementById('scratchpad-emoji-grid');
6085
+ const _scratchpadEmojiInput = document.getElementById('scratchpad-emoji-input');
6086
+ _scratchpadEmojiGrid.style.gridTemplateColumns = `repeat(${PAD_EMOJI_COLS}, 1fr)`;
6087
+ let _scratchpadEmojiCursor = 0;
6088
+
6089
+ function _padEmojiKey(sessionId) {
6090
+ return PAD_EMOJI_PREFIX + sessionId;
6091
+ }
6092
+
6093
+ function _scratchpadEmojiSessionId() {
6094
+ const pad = _parsePadKey(_scratchpadKey() || '');
6095
+ return pad?.kind === 'session' ? pad.id : null;
6096
+ }
6097
+
6098
+ function _firstGrapheme(s) {
6099
+ const seg = new Intl.Segmenter(undefined, { granularity: 'grapheme' }).segment(s.trim());
6100
+ return seg[Symbol.iterator]().next().value?.segment || '';
6101
+ }
6102
+
6103
+ function _syncScratchpadEmojiBtn() {
6104
+ const sid = _scratchpadEmojiSessionId();
6105
+ _scratchpadEmojiBtn.hidden = !sid;
6106
+ const emoji = sid ? store.getItem(_padEmojiKey(sid)) : null;
6107
+ _scratchpadEmojiBtn.textContent = emoji || '+';
6108
+ _scratchpadEmojiBtn.classList.toggle('set', !!emoji);
6109
+ _scratchpadEmojiBtn.title = `${emoji ? 'Change' : 'Set'} scratchpad emoji (Ctrl+E)`;
6110
+ }
6111
+
6112
+ function _markScratchpadEmojiCursor() {
6113
+ _scratchpadEmojiGrid.querySelectorAll('button').forEach((b, i) => {
6114
+ b.classList.toggle('kb', i === _scratchpadEmojiCursor);
6115
+ });
6116
+ }
6117
+
6118
+ // biome-ignore lint/correctness/noUnusedVariables: used in HTML onclick
6119
+ function toggleScratchpadEmojiPicker() {
6120
+ if (_scratchpadEmojiPicker.classList.contains('open')) _closeScratchpadEmojiPicker();
6121
+ else _openScratchpadEmojiPicker();
6122
+ }
6123
+
6124
+ function _openScratchpadEmojiPicker() {
6125
+ const sid = _scratchpadEmojiSessionId();
6126
+ if (!sid) return;
6127
+ const cur = store.getItem(_padEmojiKey(sid));
6128
+ _scratchpadEmojiCursor = Math.max(0, PAD_EMOJIS.indexOf(cur));
6129
+ _scratchpadEmojiGrid.innerHTML = PAD_EMOJIS.map(
6130
+ (e, i) => `<button type="button" data-i="${i}" class="${e === cur ? 'cur' : ''}">${e}</button>`,
6131
+ ).join('');
6132
+ _markScratchpadEmojiCursor();
6133
+ _scratchpadEmojiInput.value = '';
6134
+ _scratchpadEmojiPicker.classList.add('open');
6135
+ _scratchpadEmojiPicker.focus();
6136
+ }
6137
+
6138
+ function _closeScratchpadEmojiPicker(refocus = true) {
6139
+ if (!_scratchpadEmojiPicker.classList.contains('open')) return;
6140
+ _scratchpadEmojiPicker.classList.remove('open');
6141
+ if (refocus) _scratchpadTextarea.focus();
6142
+ }
6143
+
6144
+ function setScratchpadEmoji(value) {
6145
+ const sid = _scratchpadEmojiSessionId();
6146
+ if (!sid) return;
6147
+ const emoji = _firstGrapheme(value || '');
6148
+ if (emoji) store.setItem(_padEmojiKey(sid), emoji);
6149
+ else store.removeItem(_padEmojiKey(sid));
6150
+ _syncScratchpadEmojiBtn();
6151
+ _closeScratchpadEmojiPicker();
6152
+ renderSessions();
6153
+ }
6154
+
6155
+ _scratchpadEmojiGrid.addEventListener('click', (e) => {
6156
+ const b = e.target.closest('button[data-i]');
6157
+ if (b) setScratchpadEmoji(PAD_EMOJIS[+b.dataset.i]);
6158
+ });
6159
+
6160
+ // Stops propagation so the global handler does not read Escape as "close the scratchpad".
6161
+ _scratchpadEmojiPicker.addEventListener('keydown', (e) => {
6162
+ e.stopPropagation();
6163
+ if (matchKey(e, 'Escape')) {
6164
+ e.preventDefault();
6165
+ _closeScratchpadEmojiPicker();
6166
+ return;
6167
+ }
6168
+ if (e.target === _scratchpadEmojiInput) {
6169
+ if (matchKey(e, 'Enter') && _scratchpadEmojiInput.value.trim()) {
6170
+ e.preventDefault();
6171
+ setScratchpadEmoji(_scratchpadEmojiInput.value);
6172
+ }
6173
+ return;
6174
+ }
6175
+ const moves = { ArrowRight: 1, ArrowLeft: -1, ArrowDown: PAD_EMOJI_COLS, ArrowUp: -PAD_EMOJI_COLS };
6176
+ const move = matchKey(e, e.key) && moves[e.key];
6177
+ if (move) {
6178
+ e.preventDefault();
6179
+ _scratchpadEmojiCursor = Math.min(PAD_EMOJIS.length - 1, Math.max(0, _scratchpadEmojiCursor + move));
6180
+ _markScratchpadEmojiCursor();
6181
+ } else if (matchKey(e, 'Enter')) {
6182
+ e.preventDefault();
6183
+ setScratchpadEmoji(PAD_EMOJIS[_scratchpadEmojiCursor]);
6184
+ }
6185
+ });
6186
+
6040
6187
  function _scratchpadKey() {
6041
6188
  if (_scratchpadKeyOverride) return _scratchpadKeyOverride;
6042
6189
  if (currentSessionId) return _sessionScratchpadKey(currentSessionId);
@@ -6073,6 +6220,8 @@ function showScratchpad(keyOverride) {
6073
6220
  if (!key) return;
6074
6221
  _scratchpadTextarea.value = store.getItem(key) || '';
6075
6222
  _scratchpadCharcount.textContent = `${_scratchpadTextarea.value.length} chars`;
6223
+ _syncScratchpadEmojiBtn();
6224
+ _closeScratchpadEmojiPicker(false);
6076
6225
  _scratchpadModal.classList.add('visible');
6077
6226
  _scratchpadTextarea.focus();
6078
6227
  }
@@ -6083,6 +6232,7 @@ function closeScratchpad() {
6083
6232
  _scratchpadSaveTimer = null;
6084
6233
  }
6085
6234
  saveScratchpad();
6235
+ _closeScratchpadEmojiPicker(false);
6086
6236
  _scratchpadKeyOverride = null;
6087
6237
  _scratchpadModal.classList.remove('visible');
6088
6238
  }
@@ -6115,6 +6265,10 @@ _scratchpadTextarea.addEventListener('input', () => {
6115
6265
  // Bound on the textarea: the global handler returns early on TEXTAREA targets.
6116
6266
  _scratchpadTextarea.addEventListener('keydown', (e) => {
6117
6267
  if (e.key === 'Escape') closeScratchpad();
6268
+ else if (e.ctrlKey && !e.altKey && !e.shiftKey && !e.metaKey && e.code === 'KeyE' && _scratchpadEmojiSessionId()) {
6269
+ e.preventDefault();
6270
+ _openScratchpadEmojiPicker();
6271
+ }
6118
6272
  });
6119
6273
 
6120
6274
  // Vimium eats Escape inside a text field and only blurs it, so the page never sees the key.
@@ -6153,8 +6307,21 @@ function _updateStorageTotal() {
6153
6307
  if (el) el.textContent = `${(_getStorageTotalSize() / 1024).toFixed(1)} KB`;
6154
6308
  }
6155
6309
 
6156
- function _getKnownSessionIds() {
6157
- return new Set(sessions.map((s) => s.id));
6310
+ // From the server, because `sessions` is only the sidebar's loaded page (docs/session-scanning.md).
6311
+ // Loaded sessions are added for placeholders whose transcript does not exist yet.
6312
+ let _storageKnownSessions = null;
6313
+
6314
+ async function _fetchKnownSessions() {
6315
+ const res = await fetch('/api/sessions/known');
6316
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
6317
+ const known = new Map((await res.json()).map((s) => [s.id, s]));
6318
+ for (const s of sessions) known.set(s.id, s);
6319
+ _storageKnownSessions = known;
6320
+ return known;
6321
+ }
6322
+
6323
+ function _storageSessionMap() {
6324
+ return _storageKnownSessions ?? new Map(sessions.map((s) => [s.id, s]));
6158
6325
  }
6159
6326
 
6160
6327
  function _sessionLabel(session, id) {
@@ -6162,7 +6329,7 @@ function _sessionLabel(session, id) {
6162
6329
  }
6163
6330
 
6164
6331
  function _groupByProject(sessionIds) {
6165
- const sessionMap = new Map(sessions.map((s) => [s.id, s]));
6332
+ const sessionMap = _storageSessionMap();
6166
6333
  const groups = new Map();
6167
6334
  const orphans = [];
6168
6335
  for (const id of sessionIds) {
@@ -6197,14 +6364,18 @@ function _renderOrphanGroup(count, innerHtml) {
6197
6364
  return _renderProjectGroup('Orphaned', `<span class="storage-item-badge orphan">${count}</span>`, innerHtml);
6198
6365
  }
6199
6366
 
6200
- function showStorageManager() {
6367
+ async function showStorageManager() {
6368
+ _storageKnownSessions = null;
6201
6369
  _updateStorageTotal();
6202
- _updateOrphanedCount();
6203
6370
  document.querySelectorAll('.storage-tab').forEach((t) => {
6204
6371
  t.classList.toggle('active', t.dataset.tab === 'sessions');
6205
6372
  });
6206
- document.getElementById('storage-modal').classList.add('visible');
6373
+ const modal = document.getElementById('storage-modal');
6374
+ modal.classList.add('visible');
6375
+ const known = await _fetchKnownSessions().catch(() => null);
6376
+ if (!modal.classList.contains('visible')) return;
6207
6377
  _renderStorageTab();
6378
+ if (known) _updateOrphanedCount(known);
6208
6379
  }
6209
6380
 
6210
6381
  function closeStorageManager() {
@@ -6349,20 +6520,31 @@ function _storageUnpinMessage(sessionId, pinId) {
6349
6520
  }
6350
6521
 
6351
6522
  function _renderStorageScratchpads() {
6352
- const allItems = [];
6523
+ const groupItems = [];
6524
+ const projectItems = [];
6525
+ const scratchBySession = new Map();
6526
+ const sessionItem = (id) => {
6527
+ if (!scratchBySession.has(id))
6528
+ scratchBySession.set(id, { key: _sessionScratchpadKey(id), kind: 'session', id, chars: 0 });
6529
+ return scratchBySession.get(id);
6530
+ };
6353
6531
  for (const key of store.keys()) {
6532
+ if (key.startsWith(PAD_EMOJI_PREFIX)) {
6533
+ sessionItem(key.slice(PAD_EMOJI_PREFIX.length)).emoji = store.getItem(key);
6534
+ continue;
6535
+ }
6354
6536
  const parsed = _parsePadKey(key);
6355
6537
  if (!parsed) continue;
6356
- allItems.push({ key, ...parsed, chars: (store.getItem(key) || '').length });
6538
+ const chars = (store.getItem(key) || '').length;
6539
+ if (parsed.kind === 'session') sessionItem(parsed.id).chars = chars;
6540
+ else (parsed.kind === 'group' ? groupItems : projectItems).push({ key, ...parsed, chars });
6541
+ }
6542
+ if (!groupItems.length && !projectItems.length && !scratchBySession.size) {
6543
+ return '<div class="storage-empty">No scratchpads</div>';
6357
6544
  }
6358
- if (!allItems.length) return '<div class="storage-empty">No scratchpads</div>';
6359
6545
 
6360
- const groupItems = allItems.filter((i) => i.kind === 'group');
6361
- const projectItems = allItems.filter((i) => i.kind === 'project');
6362
- const sessionItems = allItems.filter((i) => i.kind === 'session');
6363
- const sessionIds = sessionItems.map((i) => i.id);
6364
- const { groups: projectGroups, orphans } = _groupByProject(sessionIds);
6365
- const scratchBySession = new Map(sessionItems.map((i) => [i.id, i]));
6546
+ const { groups: projectGroups, orphans } = _groupByProject(scratchBySession.keys());
6547
+ const sessionMap = _storageSessionMap();
6366
6548
 
6367
6549
  function renderScratchItem(item) {
6368
6550
  const jsKey = escAttrJs(item.key);
@@ -6375,13 +6557,10 @@ function _renderStorageScratchpads() {
6375
6557
  label = escapeHtml(_projectLabel(item.id));
6376
6558
  break;
6377
6559
  default:
6378
- label = _sessionLabel(
6379
- sessions.find((s) => s.id === item.id),
6380
- item.id,
6381
- );
6560
+ label = _sessionLabel(sessionMap.get(item.id), item.id);
6382
6561
  }
6383
6562
  return `<div class="storage-item">
6384
- <span class="storage-item-id" title="${escapeHtml(item.id)}">${label}</span>
6563
+ <span class="storage-item-id" title="${escapeHtml(item.id)}">${item.emoji ? `${escapeHtml(item.emoji)} ` : ''}${label}</span>
6385
6564
  <span class="storage-item-badge">${item.kind}</span>
6386
6565
  <span class="storage-item-meta">${item.chars} chars</span>
6387
6566
  <div class="storage-item-actions">
@@ -6452,6 +6631,11 @@ function _storagePreviewPin(sessionId, pinId) {
6452
6631
 
6453
6632
  function _storageDeleteScratchpad(key) {
6454
6633
  store.removeItem(key);
6634
+ const pad = _parsePadKey(key);
6635
+ if (pad?.kind === 'session' && store.getItem(_padEmojiKey(pad.id))) {
6636
+ store.removeItem(_padEmojiKey(pad.id));
6637
+ renderSessions();
6638
+ }
6455
6639
  _renderStorageTab();
6456
6640
  _updateStorageTotal();
6457
6641
  }
@@ -6530,43 +6714,42 @@ function _storageClearLinkedDocs(sessionId) {
6530
6714
  afterLinkedDocsChanged(sessionId);
6531
6715
  }
6532
6716
 
6533
- function _findOrphanedKeys() {
6534
- const known = _getKnownSessionIds();
6717
+ function _findOrphanedKeys(known) {
6535
6718
  if (!known.size) return [];
6536
6719
  const orphaned = [];
6537
6720
  for (const id of pinnedSessionIds) if (!known.has(id)) orphaned.push(`__pinned__${id}`);
6538
6721
  for (const id of stickySessionIds) if (!known.has(id)) orphaned.push(`__sticky__${id}`);
6722
+ const sessionKeyPrefixes = ['pinned-messages-', PAD_EMOJI_PREFIX, PREVIEW_STORAGE_PREFIX, PAD_LINKED_PREFIX];
6539
6723
  for (const key of store.keys()) {
6540
6724
  const pad = _parsePadKey(key);
6541
6725
  if (pad?.kind === 'group') {
6542
6726
  if (!sgGroupById(pad.id)) orphaned.push(key);
6543
- } else if (key.startsWith('pinned-messages-')) {
6544
- if (!known.has(key.slice('pinned-messages-'.length))) orphaned.push(key);
6545
- } else if (pad?.kind === 'session') {
6546
- if (!known.has(pad.id)) orphaned.push(key);
6547
- } else if (key.startsWith(PREVIEW_STORAGE_PREFIX)) {
6548
- if (!known.has(key.slice(PREVIEW_STORAGE_PREFIX.length))) orphaned.push(key);
6549
- } else if (key.startsWith(PAD_LINKED_PREFIX)) {
6550
- if (!known.has(key.slice(PAD_LINKED_PREFIX.length))) orphaned.push(key);
6727
+ continue;
6551
6728
  }
6729
+ const prefix = pad ? null : sessionKeyPrefixes.find((p) => key.startsWith(p));
6730
+ const id = pad?.kind === 'session' ? pad.id : prefix && key.slice(prefix.length);
6731
+ if (id && !known.has(id)) orphaned.push(key);
6552
6732
  }
6553
6733
  return orphaned;
6554
6734
  }
6555
6735
 
6556
- function _updateOrphanedCount() {
6736
+ function _updateOrphanedCount(known) {
6557
6737
  const btn = document.getElementById('storage-cleanup-btn');
6558
6738
  if (!btn) return;
6559
- const count = _findOrphanedKeys().length;
6739
+ const count = _findOrphanedKeys(known).length;
6560
6740
  btn.textContent = count ? `Clean Orphaned (${count})` : 'Clean Orphaned';
6561
6741
  }
6562
6742
 
6563
6743
  // biome-ignore lint/correctness/noUnusedVariables: used in HTML onclick
6564
- function cleanupOrphanedStorage() {
6565
- if (!sessions.length) {
6566
- showToast('Sessions not loaded yet — try again after they appear');
6744
+ async function cleanupOrphanedStorage() {
6745
+ let known;
6746
+ try {
6747
+ known = await _fetchKnownSessions();
6748
+ } catch {
6749
+ showToast('Could not list sessions — nothing was cleaned');
6567
6750
  return;
6568
6751
  }
6569
- const orphaned = _findOrphanedKeys();
6752
+ const orphaned = _findOrphanedKeys(known);
6570
6753
  let pinsChanged = false;
6571
6754
  for (const key of orphaned) {
6572
6755
  if (key.startsWith('__pinned__')) {
@@ -6586,7 +6769,7 @@ function cleanupOrphanedStorage() {
6586
6769
  renderSessions();
6587
6770
  _renderStorageTab();
6588
6771
  _updateStorageTotal();
6589
- _updateOrphanedCount();
6772
+ _updateOrphanedCount(known);
6590
6773
  }
6591
6774
  //#endregion
6592
6775
 
@@ -8632,8 +8815,9 @@ function setupEventSource() {
8632
8815
  const CONTEXT_COLORS = { green: '#5b9a6b', yellow: '#b8a63e', orange: '#c07840', red: '#b85555' };
8633
8816
  const COST_THRESHOLDS = { green: 0.5, yellow: 2, orange: 5 };
8634
8817
  const MODEL_THRESHOLDS = [
8635
- { match: /sonnet|haiku/i, yellow: 100000, orange: 130000, red: 150000 },
8636
- { match: /opus/i, yellow: 100000, orange: 200000, red: 700000 },
8818
+ { match: /sonnet/i, yellow: 120000, orange: 200000, red: 300000 },
8819
+ { match: /haiku/i, yellow: 100000, orange: 130000, red: 150000 },
8820
+ { match: /opus/i, yellow: 150000, orange: 250000, red: 400000 },
8637
8821
  ];
8638
8822
  const DEFAULT_THRESHOLDS = { yellow: 100000, orange: 130000, red: 150000 };
8639
8823
 
@@ -8764,7 +8948,7 @@ function renderContextDetail(session, { tokens = true } = {}) {
8764
8948
  <div class="stat-item"><span class="stat-label">Current output</span><span class="stat-value">${formatTokens((usage.output_tokens || 0) / 1000)}</span></div>
8765
8949
  <div class="stat-divider"></div>
8766
8950
  <div class="stat-item"><span class="stat-label">Total input</span><span class="stat-value">${formatTokens(ctx.inputTokens / 1000)}</span></div>
8767
- <div class="stat-item"><span class="stat-label">Total output</span><span class="stat-value">${formatTokens(ctx.outputTokens / 1000)}</span></div>
8951
+ ${ctx.outputTokens ? `<div class="stat-item"><span class="stat-label">Total output</span><span class="stat-value">${formatTokens(ctx.outputTokens / 1000)}</span></div>` : ''}
8768
8952
  <div class="stat-divider"></div>`
8769
8953
  : '';
8770
8954
 
@@ -8784,9 +8968,9 @@ function renderContextDetail(session, { tokens = true } = {}) {
8784
8968
  <div class="detail-context-stats">
8785
8969
  ${tokenRows}
8786
8970
  <div class="stat-item"><span class="stat-label">Cost</span><span class="stat-value" style="color:${getCostColor(cost.total_cost_usd)}">${formatCost(cost.total_cost_usd)}</span></div>
8787
- <div class="stat-item"><span class="stat-label">Duration</span><span class="stat-value">${formatDuration(cost.total_duration_ms)}</span></div>
8788
- <div class="stat-item"><span class="stat-label">API time</span><span class="stat-value">${formatDuration(cost.total_api_duration_ms)}</span></div>
8789
- <div class="stat-item"><span class="stat-label">Lines</span><span class="stat-value"><span style="color:${CONTEXT_COLORS.green}">+${(cost.total_lines_added || 0).toLocaleString()}</span> / <span style="color:${CONTEXT_COLORS.red}">-${(cost.total_lines_removed || 0).toLocaleString()}</span></span></div>
8971
+ ${cost.total_duration_ms != null ? `<div class="stat-item"><span class="stat-label">Duration</span><span class="stat-value">${formatDuration(cost.total_duration_ms)}</span></div>` : ''}
8972
+ ${cost.total_api_duration_ms != null ? `<div class="stat-item"><span class="stat-label">API time</span><span class="stat-value">${formatDuration(cost.total_api_duration_ms)}</span></div>` : ''}
8973
+ ${cost.total_lines_added != null ? `<div class="stat-item"><span class="stat-label">Lines</span><span class="stat-value"><span style="color:${CONTEXT_COLORS.green}">+${(cost.total_lines_added || 0).toLocaleString()}</span> / <span style="color:${CONTEXT_COLORS.red}">-${(cost.total_lines_removed || 0).toLocaleString()}</span></span></div>` : ''}
8790
8974
  </div>
8791
8975
  </div>`;
8792
8976
  }
@@ -12400,8 +12584,7 @@ function placeholderSession(id, spec) {
12400
12584
  mode: spec.mode,
12401
12585
  name: spec.name || PLACEHOLDER_NAMES[spec.mode] || PLACEHOLDER_NAMES.new,
12402
12586
  dispatchGroup: spec.group || undefined,
12403
- startedBy: spec.startedBy || undefined,
12404
- dispatched: spec.mode === 'dispatch' ? { parent: spec.startedBy || null } : undefined,
12587
+ dispatched: spec.mode === 'dispatch' ? { parent: spec.parent || null } : undefined,
12405
12588
  project: spec.cwd,
12406
12589
  worktree: spec.worktree ? { repo: spec.cwd, name: spec.worktree === true ? 'new' : spec.worktree } : null,
12407
12590
  modifiedAt: new Date(spec.startedAt).toISOString(),
@@ -12435,7 +12618,7 @@ async function loadDispatches() {
12435
12618
  name: r.name,
12436
12619
  worktree: r.worktree,
12437
12620
  group: r.group,
12438
- startedBy: r.parent,
12621
+ parent: r.parent,
12439
12622
  startedAt: r.startedAt,
12440
12623
  });
12441
12624
  }
package/public/index.html CHANGED
@@ -691,8 +691,18 @@
691
691
  </div>
692
692
  <div id="scratchpad-modal" class="modal-overlay" onclick="closeScratchpad()">
693
693
  <div class="modal scratchpad-modal" onclick="event.stopPropagation()">
694
- <div class="modal-header">
695
- <h3 class="modal-title">Scratchpad</h3>
694
+ <div class="modal-header scratchpad-header">
695
+ <div class="scratchpad-title-row">
696
+ <button id="scratchpad-emoji-btn" class="scratchpad-emoji-btn" type="button" onclick="toggleScratchpadEmojiPicker()"></button>
697
+ <h3 class="modal-title">Scratchpad</h3>
698
+ </div>
699
+ <div id="scratchpad-emoji-picker" class="scratchpad-emoji-picker" tabindex="-1">
700
+ <div id="scratchpad-emoji-grid" class="scratchpad-emoji-grid"></div>
701
+ <div class="scratchpad-emoji-row">
702
+ <input id="scratchpad-emoji-input" class="form-input" maxlength="16" placeholder="Paste" aria-label="Custom emoji">
703
+ <button type="button" class="btn btn-secondary" onclick="setScratchpadEmoji('')">Clear</button>
704
+ </div>
705
+ </div>
696
706
  <button class="modal-close" aria-label="Close dialog" onclick="closeScratchpad()">
697
707
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
698
708
  <path d="M18 6L6 18M6 6l12 12"/>
@@ -23,7 +23,12 @@ const projectMatch = (() => {
23
23
  return p === q || (!!repo && normalizeProjectPath(repo) === q);
24
24
  };
25
25
  }
26
- return { normalizeProjectPath, isExactProjectPath, isExactProjectFilter, projectMatcher };
26
+ // The project a session is listed under. A worktree transcript is filed under the worktree
27
+ // path, so `session.project` alone would show each worktree as a project of its own.
28
+ function sessionProjectKey(session) {
29
+ return session?.worktree?.repo || session?.project || null;
30
+ }
31
+ return { normalizeProjectPath, isExactProjectPath, isExactProjectFilter, projectMatcher, sessionProjectKey };
27
32
  })();
28
33
 
29
34
  if (typeof module === 'object' && module.exports) module.exports = projectMatch;