claude-code-kanban 7.7.0 → 7.8.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.
@@ -0,0 +1,21 @@
1
+ // Loaded by the page as a plain script and by lib/user-groups.js through require, so the board's
2
+ // transient sweep and the server's prune and expiry delete nested groups by one rule.
3
+ const groupTree = (() => {
4
+ // The groups `goes` names whose child groups all go too.
5
+ function cascade(groups, goes) {
6
+ const children = new Map();
7
+ for (const g of groups) {
8
+ if (!children.has(g.parent)) children.set(g.parent, []);
9
+ children.get(g.parent).push(g);
10
+ }
11
+ const memo = new Map();
12
+ const all = (g) => {
13
+ if (!memo.has(g)) memo.set(g, goes(g) && (children.get(g.id) || []).every(all));
14
+ return memo.get(g);
15
+ };
16
+ return new Set(groups.filter(all));
17
+ }
18
+ return { cascade };
19
+ })();
20
+
21
+ if (typeof module === 'object' && module.exports) module.exports = groupTree;
package/public/index.html CHANGED
@@ -28,6 +28,7 @@
28
28
  <script defer src="/project-match.js"></script>
29
29
  <script defer src="/tree-highlight.js"></script>
30
30
  <script defer src="/link-url.js"></script>
31
+ <script defer src="/group-tree.js"></script>
31
32
  <script defer src="/app.js"></script>
32
33
  </head>
33
34
  <body class="booting">
@@ -391,6 +392,7 @@
391
392
  <path d="M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/>
392
393
  </svg>
393
394
  </button>
395
+ <button id="expand-detail" class="detail-close detail-expand" type="button"></button>
394
396
  <button id="close-detail" class="detail-close" aria-label="Close detail panel">
395
397
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
396
398
  <path d="M6 18L18 6M6 6l12 12"/>
@@ -552,7 +554,7 @@
552
554
  <div class="modal-header">
553
555
  <h3 id="team-modal-title" class="modal-title">Team</h3>
554
556
  <div style="display:flex;align-items:center;gap:4px;">
555
- <button id="session-info-sticky-btn" class="icon-btn" style="display:none" title="Sticky pin — always show at top" onclick="toggleSessionSticky(_infoModalSessionId); updateStickyBtnState()">
557
+ <button id="session-info-sticky-btn" class="icon-btn" style="display:none" title="Sticky pin — always show in the list" onclick="toggleSessionSticky(_infoModalSessionId); updateStickyBtnState()">
556
558
  <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2 L15 9 L22 9 L17 14 L19 22 L12 18 L5 22 L7 14 L2 9 L9 9 Z"/></svg>
557
559
  </button>
558
560
  <button id="session-info-cost-btn" class="icon-btn" style="display:none" title="Open in Cost" onclick="openCost(_infoModalSessionId)">
@@ -750,13 +752,16 @@
750
752
  </button>
751
753
  </div>
752
754
  </div>
753
- <div class="storage-tabs">
754
- <button class="storage-tab active" data-tab="sessions" onclick="switchStorageTab('sessions')">Sessions</button>
755
- <button class="storage-tab" data-tab="scratchpads" onclick="switchStorageTab('scratchpads')">Scratchpads</button>
756
- <button class="storage-tab" data-tab="linked-docs" onclick="switchStorageTab('linked-docs')">Linked Docs</button>
755
+ <div class="storage-tabs" role="tablist" aria-label="Storage" title="← → switch tab">
756
+ <button class="storage-tab active" role="tab" aria-selected="true" data-tab="sessions" onclick="switchStorageTab('sessions')">Sessions</button>
757
+ <button class="storage-tab" role="tab" aria-selected="false" data-tab="dismissed" onclick="switchStorageTab('dismissed')">Dismissed</button>
758
+ <button class="storage-tab" role="tab" aria-selected="false" data-tab="scratchpads" onclick="switchStorageTab('scratchpads')">Scratchpads</button>
759
+ <button class="storage-tab" role="tab" aria-selected="false" data-tab="linked-docs" onclick="switchStorageTab('linked-docs')">Linked Docs</button>
760
+ <button class="storage-tab" role="tab" aria-selected="false" data-tab="groups" onclick="switchStorageTab('groups')">Groups</button>
757
761
  </div>
758
762
  <div class="modal-body modal-zoomable" id="storage-modal-body"></div>
759
763
  <div class="modal-footer">
764
+ <button id="storage-groups-clean-btn" class="btn btn-secondary" style="display:none" disabled title="Take the sessions the sidebar hides (not active or dismissed, and not pinned) out of every group" onclick="_storageCleanGroup(null)">Clean all</button>
760
765
  <button id="storage-cleanup-btn" class="btn btn-secondary" onclick="cleanupOrphanedStorage()">Clean Orphaned</button>
761
766
  <button class="btn btn-secondary" onclick="closeStorageManager()">Close</button>
762
767
  </div>
@@ -824,15 +829,31 @@
824
829
  </div>
825
830
  </div>
826
831
  <div class="ns-row ns-extra">
827
- <label class="ns-label" for="ns-model">Model</label>
832
+ <span class="ns-label"></span>
828
833
  <div class="ns-field">
829
- <select id="ns-model" class="form-input">
830
- <option value="">Default</option>
831
- <option value="fable">Fable</option>
832
- <option value="opus">Opus</option>
833
- <option value="sonnet">Sonnet</option>
834
- <option value="haiku">Haiku</option>
835
- </select>
834
+ <div class="ns-inline">
835
+ <fieldset class="ns-notched">
836
+ <legend><label for="ns-model">Model</label></legend>
837
+ <select id="ns-model" class="form-input">
838
+ <option value="">Default</option>
839
+ <option value="fable">Fable</option>
840
+ <option value="opus">Opus</option>
841
+ <option value="sonnet">Sonnet</option>
842
+ <option value="haiku">Haiku</option>
843
+ </select>
844
+ </fieldset>
845
+ <fieldset class="ns-notched">
846
+ <legend><label for="ns-effort">Effort</label></legend>
847
+ <select id="ns-effort" class="form-input">
848
+ <option value="">Default</option>
849
+ <option value="low">Low</option>
850
+ <option value="medium">Medium</option>
851
+ <option value="high">High</option>
852
+ <option value="xhigh">XHigh</option>
853
+ <option value="max">Max</option>
854
+ </select>
855
+ </fieldset>
856
+ </div>
836
857
  </div>
837
858
  </div>
838
859
  <div id="ns-error" class="ns-error" hidden></div>
package/public/style.css CHANGED
@@ -2134,6 +2134,12 @@ body.booting .boot-splash {
2134
2134
  border-left-color: var(--success);
2135
2135
  }
2136
2136
 
2137
+ .task-card.dim,
2138
+ .task-card.dim:hover {
2139
+ opacity: 0.55;
2140
+ border-left-color: var(--border);
2141
+ }
2142
+
2137
2143
  .task-card.blocked {
2138
2144
  border-left-style: dashed;
2139
2145
  }
@@ -2209,6 +2215,21 @@ body.booting .boot-splash {
2209
2215
  white-space: nowrap;
2210
2216
  }
2211
2217
 
2218
+ .task-chip {
2219
+ color: var(--text-muted);
2220
+ text-transform: uppercase;
2221
+ letter-spacing: 0.04em;
2222
+ white-space: nowrap;
2223
+ }
2224
+
2225
+ .task-chip-warn {
2226
+ color: var(--warning);
2227
+ }
2228
+
2229
+ .task-chip-accent {
2230
+ color: var(--accent);
2231
+ }
2232
+
2212
2233
  .task-desc {
2213
2234
  font-size: 11.5px;
2214
2235
  font-weight: 400;
@@ -2242,6 +2263,29 @@ body.booting .boot-splash {
2242
2263
  .detail-panel.visible {
2243
2264
  display: flex;
2244
2265
  }
2266
+
2267
+ .detail-panel.expanded {
2268
+ top: 50%;
2269
+ left: 50%;
2270
+ right: auto;
2271
+ width: min(1200px, calc(100vw - 48px));
2272
+ height: auto;
2273
+ max-height: calc(100vh - 96px);
2274
+ transform: translate(-50%, -50%);
2275
+ border: 1px solid var(--border);
2276
+ border-radius: 10px;
2277
+ box-shadow: 0 12px 40px rgb(0 0 0 / 35%);
2278
+ z-index: 900;
2279
+ }
2280
+
2281
+ .detail-panel.expanded .panel-resize-handle {
2282
+ display: none;
2283
+ }
2284
+
2285
+ .detail-expand svg {
2286
+ width: 15px;
2287
+ height: 15px;
2288
+ }
2245
2289
  .detail-panel.resizing,
2246
2290
  .message-panel.resizing {
2247
2291
  transition: none;
@@ -4856,6 +4900,10 @@ body.show-resizing iframe {
4856
4900
  word-break: break-word;
4857
4901
  }
4858
4902
 
4903
+ .pane-doc-body:focus {
4904
+ outline: none;
4905
+ }
4906
+
4859
4907
  .pane-msg-meta {
4860
4908
  margin-bottom: 12px;
4861
4909
  font-size: 11px;
@@ -5518,6 +5566,9 @@ body.show-resizing iframe {
5518
5566
  .rendered-md li {
5519
5567
  margin: 0.2em 0;
5520
5568
  }
5569
+ .rendered-md ul > li:has(> input[type='checkbox']) {
5570
+ list-style: none;
5571
+ }
5521
5572
  .rendered-md a {
5522
5573
  color: var(--accent);
5523
5574
  }
@@ -7851,7 +7902,7 @@ body.light .msg-assistant .msg-text {
7851
7902
 
7852
7903
  .confirm-dialog {
7853
7904
  padding: 0;
7854
- max-width: 480px;
7905
+ max-width: 560px;
7855
7906
  overflow: hidden;
7856
7907
  }
7857
7908
 
@@ -8857,6 +8908,57 @@ select.form-input option:checked {
8857
8908
  min-width: 0;
8858
8909
  }
8859
8910
 
8911
+ .ns-body select.form-input {
8912
+ appearance: none;
8913
+ padding-right: 30px;
8914
+ cursor: pointer;
8915
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%238b8d95' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
8916
+ background-repeat: no-repeat;
8917
+ background-position: right 10px center;
8918
+ }
8919
+
8920
+ .ns-notched {
8921
+ flex: 1;
8922
+ min-width: 0;
8923
+ margin: 0;
8924
+ padding: 0;
8925
+ border: 1px solid var(--border);
8926
+ border-radius: 6px;
8927
+ background: var(--bg-elevated);
8928
+ transition:
8929
+ border-color 0.15s ease,
8930
+ box-shadow 0.15s ease;
8931
+ }
8932
+
8933
+ .ns-notched:focus-within {
8934
+ border-color: var(--accent);
8935
+ box-shadow: 0 0 0 3px var(--accent-dim);
8936
+ }
8937
+
8938
+ .ns-notched legend {
8939
+ margin: 0 8px 0 auto;
8940
+ padding: 0 4px;
8941
+ font-size: 11px;
8942
+ font-weight: 500;
8943
+ line-height: 13px;
8944
+ color: var(--text-secondary);
8945
+ }
8946
+
8947
+ .ns-notched:focus-within legend {
8948
+ color: var(--accent-text);
8949
+ }
8950
+
8951
+ /* The legend's lower half sits inside the box, so less top padding keeps the box as tall as a plain input. */
8952
+ .ns-body .ns-notched select.form-input,
8953
+ .ns-body .ns-notched select.form-input:focus {
8954
+ padding-top: 2px;
8955
+ font-size: 12px;
8956
+ background-position: right 10px top calc(50% - 3px);
8957
+ border: none;
8958
+ background-color: transparent;
8959
+ box-shadow: none;
8960
+ }
8961
+
8860
8962
  .ns-body .btn.ns-browse {
8861
8963
  flex: none;
8862
8964
  padding: 8px 12px;
@@ -9551,6 +9653,15 @@ select.form-input option:checked {
9551
9653
  padding-top: 0;
9552
9654
  }
9553
9655
 
9656
+ .storage-group-header.storage-group-row {
9657
+ padding: 6px 0;
9658
+ }
9659
+
9660
+ .storage-group-row .storage-item-actions {
9661
+ align-items: center;
9662
+ gap: 8px;
9663
+ }
9664
+
9554
9665
  .storage-empty {
9555
9666
  text-align: center;
9556
9667
  padding: 32px 0;
@@ -9692,19 +9803,16 @@ pre.mermaid[data-mermaid-error]::before {
9692
9803
  /* #endregion */
9693
9804
 
9694
9805
  /* #region ANIMATIONS */
9806
+ /* No `to` and fill backwards: the card ends on its own opacity (.completed, .dim), not on 1. */
9695
9807
  @keyframes fadeSlideIn {
9696
9808
  from {
9697
9809
  opacity: 0;
9698
9810
  transform: translateY(8px);
9699
9811
  }
9700
- to {
9701
- opacity: 1;
9702
- transform: translateY(0);
9703
- }
9704
9812
  }
9705
9813
 
9706
9814
  .column-tasks .task-card {
9707
- animation: fadeSlideIn 150ms ease-out both;
9815
+ animation: fadeSlideIn 150ms ease-out backwards;
9708
9816
  }
9709
9817
 
9710
9818
  .column-tasks .task-card:nth-child(1) {
@@ -10076,6 +10184,10 @@ pre.mermaid[data-mermaid-error]::before {
10076
10184
  outline-offset: -1px;
10077
10185
  }
10078
10186
 
10187
+ .session-group-header.sg-zen {
10188
+ color: var(--accent);
10189
+ }
10190
+
10079
10191
  .sg-sub-label {
10080
10192
  padding: 8px 10px 2px 8px;
10081
10193
  font-size: 10px;
package/public/sw.js CHANGED
@@ -1,5 +1,5 @@
1
1
  const CACHE_NAME = 'cc-kanban-v2';
2
- const PRECACHE_URLS = ['/', '/style.css', '/project-match.js', '/link-url.js', '/app.js'];
2
+ const PRECACHE_URLS = ['/', '/style.css', '/project-match.js', '/link-url.js', '/group-tree.js', '/app.js'];
3
3
 
4
4
  let cachePromise = null;
5
5
  function getCache() {
@@ -1,4 +1,4 @@
1
- // claude-hub-sdk 1.4.0 (sha256 7d559a8888ce). Copied by npm run sdk:sync in claude-code-hub. Do not edit.
1
+ // claude-hub-sdk 1.5.0 (sha256 1505d3d5f8ba). Copied by npm run sdk:sync in claude-code-hub. Do not edit.
2
2
  // Claude Code Hub SDK: the one rule that names a key press, shared by the hub page, client.js and
3
3
  // stub.js. The hub serves it to its page as /sdk/keys.js; mount() and sdk:sync put it in front of
4
4
  // client.js and stub.js, so an app still loads one file.
package/server.js CHANGED
@@ -50,9 +50,8 @@ const { readTerminalConfig } = require('./lib/terminal');
50
50
  const { createTerminalClient } = require('./lib/terminal-client');
51
51
  const { kanbotOn, kanbotModel, createKanbot } = require('./lib/kanbot');
52
52
  const { createShowStore, mountShowRoutes, showBodyParser, SHOW_PATH } = require('./lib/show');
53
- const { readLiveSessions, isPidAlive, isSessionLive } = require('./lib/live-sessions');
53
+ const { readLiveSessions, isPidAlive } = require('./lib/live-sessions');
54
54
  const { createProcStats } = require('./lib/proc-stats');
55
- const { createGroupStore, isGroupName, suggestGroupName } = require('./lib/dispatch-groups');
56
55
  const { createUserGroupStore } = require('./lib/user-groups');
57
56
  const { createSessionPinStore } = require('./lib/session-pins');
58
57
  const { stampedJsonFile } = require('./lib/stamped-json-file');
@@ -115,7 +114,6 @@ const CCK_DIR = path.join(CLAUDE_DIR, '.cck');
115
114
  const AGENT_ACTIVITY_DIR = path.join(CCK_DIR, 'agent-activity');
116
115
  const CONTEXT_STATUS_DIR = path.join(CCK_DIR, 'context-status');
117
116
  const PINS_FILE = path.join(CCK_DIR, 'pins.json');
118
- const DISPATCH_GROUPS_FILE = path.join(CCK_DIR, 'dispatch-groups.json');
119
117
  const USER_GROUPS_FILE = path.join(CCK_DIR, 'groups.json');
120
118
  const DISPATCHED_FILE = path.join(CCK_DIR, 'dispatched.json');
121
119
  const WORKTREES_FILE = path.join(CCK_DIR, 'worktrees.json');
@@ -1659,7 +1657,7 @@ app.get('/api/sessions', async (req, res) => {
1659
1657
  s.autoCompact = autoCompactByProject.get(s.project);
1660
1658
  }
1661
1659
 
1662
- res.json(withDispatchPlacement(sessions));
1660
+ res.json(withDispatchMarker(sessions));
1663
1661
  startPrewarm();
1664
1662
  } catch (error) {
1665
1663
  console.error('Error listing sessions:', error);
@@ -3346,7 +3344,7 @@ const terminal = createTerminalClient({
3346
3344
  // project dir it started in, and cwd drifts into subdirectories.
3347
3345
  resolveCwd: resolveSessionFolder,
3348
3346
  isAllowedFolder,
3349
- onChange: () => broadcast({ type: 'terminals-update', ids: terminal.ids() }),
3347
+ onChange: () => broadcast({ type: 'terminals-update', ids: terminal.ids(), awaitingTrust: terminal.awaitingTrust() }),
3350
3348
  onExit: (id) => {
3351
3349
  if (dispatched.get(id)) broadcast({ type: 'dispatch-update' });
3352
3350
  },
@@ -3379,8 +3377,8 @@ app.post('/api/terminal/pick-folder', async (req, res) => {
3379
3377
  // The New session dialog's "Stay here": the PTY starts headless and the board keeps its view.
3380
3378
  app.post('/api/terminal/start', terminalRoute(async (req, res) => {
3381
3379
  if (!terminal.authorized(req.get('x-terminal-token'))) return res.status(401).json({ error: 'invalid terminal token' });
3382
- const { id, cwd, name, model, worktree, prompt } = req.body || {};
3383
- const started = await terminal.startNew({ id, cwd, name, model, worktree, prompt });
3380
+ const { id, cwd, name, model, effort, worktree, prompt } = req.body || {};
3381
+ const started = await terminal.startNew({ id, cwd, name, model, effort, worktree, prompt });
3384
3382
  if (started.error) return res.status(started.status).json({ error: started.error });
3385
3383
  res.status(201).json({ session: started.id, cwd: started.cwd });
3386
3384
  }));
@@ -3456,26 +3454,12 @@ app.get('/vendor/xterm/:file', (req, res) => {
3456
3454
  // #region DISPATCH
3457
3455
  const dispatched = createDispatchedStore(jsonFile(DISPATCHED_FILE));
3458
3456
 
3459
- const dispatchGroups = createGroupStore({
3460
- ...jsonFile(DISPATCH_GROUPS_FILE),
3461
- isAlive: (id) => terminal.isRunning(id) || isSessionLive(loadLiveSessions(), id),
3462
- pinnedIds: () => new Set(Object.keys(sessionPins.state().pins)),
3463
- });
3464
-
3465
3457
  const linkedDocs = createLinkedDocStore(jsonFile(LINKED_DOCS_FILE));
3466
3458
 
3467
- // The board places a session from these alone, so it never has to move it later.
3468
- function withDispatchPlacement(sessions) {
3469
- const groups = dispatchGroups.snapshot();
3459
+ function withDispatchMarker(sessions) {
3470
3460
  return sessions.map((s) => {
3471
- const dispatchGroup = groups.get(s.id);
3472
3461
  const marker = dispatched.get(s.id);
3473
- if (!dispatchGroup && !marker) return s;
3474
- return {
3475
- ...s,
3476
- dispatchGroup,
3477
- dispatched: marker ? { parent: marker.parent } : undefined,
3478
- };
3462
+ return marker ? { ...s, dispatched: { parent: marker.parent } } : s;
3479
3463
  });
3480
3464
  }
3481
3465
 
@@ -3486,16 +3470,31 @@ app.post('/api/dispatch', terminalRoute(async (req, res) => {
3486
3470
  const { cwd, spec, name, model, worktree, taskList, parent, group, claudeArgs } = req.body || {};
3487
3471
  if (typeof spec !== 'string' || !spec.trim()) return res.status(400).json({ error: 'spec is required' });
3488
3472
  if (parent != null && !(typeof parent === 'string' && isUUID(parent))) return res.status(400).json({ error: 'invalid parent' });
3489
- if (group != null && !isGroupName(group)) {
3490
- return res.status(400).json({ error: `group must be kebab-case, e.g. ${suggestGroupName(group) || 'my-group'}` });
3491
- }
3473
+ if (group != null && !(typeof group === 'string' && group.trim())) return res.status(400).json({ error: 'group must be a group name, path or id' });
3492
3474
  const started = await terminal.startNew({ cwd, name, model, worktree, taskList, prompt: spec.trim(), extraArgs: claudeArgs });
3493
3475
  if (started.error) return res.status(started.status).json({ error: started.error });
3494
3476
  dispatched.record(started.id, { parent, cwd: started.cwd, name, group, worktree });
3495
- // The starter stays where it is: moving it would jump it under the user.
3496
- if (group) dispatchGroups.join(group, [started.id]);
3477
+ // Joined before dispatch-update, so the board places the session from its first frame. The
3478
+ // starter stays where it is: moving it would jump it under the user.
3479
+ let joined = null;
3480
+ let groupError;
3481
+ if (group) {
3482
+ try {
3483
+ userGroups.reload();
3484
+ joined = userGroups.joinDispatch(group, started.id);
3485
+ broadcast({ type: 'group:changed', rev: joined.rev });
3486
+ } catch (e) {
3487
+ groupError = e.message;
3488
+ }
3489
+ }
3497
3490
  broadcast({ type: 'dispatch-update' });
3498
- res.status(201).json({ session: started.id, cwd: started.cwd, group: group || null });
3491
+ res.status(201).json({
3492
+ session: started.id,
3493
+ cwd: started.cwd,
3494
+ group: joined?.path ?? null,
3495
+ ...(joined ? { groupId: joined.group, transient: joined.transient } : {}),
3496
+ ...(groupError ? { groupError } : {}),
3497
+ });
3499
3498
  }));
3500
3499
 
3501
3500
  app.get('/api/dispatch', (req, res) => {
@@ -3627,7 +3626,6 @@ app.post('/api/tasks/:sessionId', async (req, res) => {
3627
3626
  id,
3628
3627
  subject,
3629
3628
  description: (req.body.description || '').trim(),
3630
- activeForm: subject,
3631
3629
  status: 'pending',
3632
3630
  blocks: [],
3633
3631
  blockedBy: [],
@@ -3959,6 +3957,9 @@ app.get('/api/session/resolve', (req, res) => {
3959
3957
  const m = metadata[idArg];
3960
3958
  return res.json({ id: idArg, customTitle: m?.customTitle || null });
3961
3959
  }
3960
+ // A session cck just dispatched has no transcript yet, and its starter groups it at once.
3961
+ const started = dispatched.get(idArg);
3962
+ if (started) return res.json({ id: idArg, customTitle: started.name || null });
3962
3963
  const matches = ids.filter(id => id.startsWith(idArg));
3963
3964
  if (matches.length === 0) return res.status(404).json({ matches: [] });
3964
3965
  if (matches.length > 1) {
@@ -4050,30 +4051,58 @@ function groupRoute(fn) {
4050
4051
  };
4051
4052
  }
4052
4053
 
4053
- app.get('/api/groups', (req, res) => {
4054
+ app.get('/api/groups', (_req, res) => {
4054
4055
  res.setHeader('Cache-Control', 'no-store');
4055
4056
  userGroups.reload();
4056
- const state = userGroups.state();
4057
- res.json(req.query.dispatch === '1' ? { ...state, dispatch: Object.fromEntries(dispatchGroups.snapshot()) } : state);
4057
+ res.json(userGroups.state());
4058
4058
  });
4059
4059
  app.post('/api/groups', groupRoute((b) => userGroups.create(b)));
4060
4060
  app.put('/api/groups', groupRoute((b) => userGroups.replace(b)));
4061
4061
  app.post('/api/groups/import', groupRoute((b) => userGroups.importLocal(b)));
4062
4062
  app.post('/api/groups/ungroup', groupRoute((b) => userGroups.ungroup(b)));
4063
- app.post('/api/groups/release', groupRoute((b) => userGroups.release(b.ids)));
4064
4063
  app.patch('/api/groups/:id', groupRoute((b, p) => userGroups.update(p.id, b)));
4065
4064
  app.delete('/api/groups/:id', groupRoute((_b, p) => userGroups.remove(p.id)));
4066
4065
  app.put('/api/groups/:id/members', groupRoute((b, p) => userGroups.place(p.id, b)));
4067
- app.post('/api/groups/path', groupRoute((b) => userGroups.createPath(b.path)));
4066
+ app.post('/api/groups/path', groupRoute((b) => userGroups.createPath(b.path, b.transient === true)));
4068
4067
  app.post('/api/groups/rename', groupRoute((b) => userGroups.rename(b.group, b.name)));
4069
4068
  app.post('/api/groups/remove', groupRoute((b) => userGroups.removeByKey(b.group)));
4070
- app.post('/api/groups/prune', groupRoute((b) => userGroups.prune({ dryRun: b.dryRun === true, isLive: liveGroupMember() })));
4069
+ app.post('/api/groups/prune', groupRoute((b) => {
4070
+ const known = loadSessionMetadata();
4071
+ return userGroups.prune({ dryRun: b.dryRun === true, isLive: liveGroupMember((id) => !!known[id]) });
4072
+ }));
4073
+ // `keep` is the ids of `/api/sessions?filter=active&limit=all`: the Active rule lives in that route.
4074
+ // `hide` is the ids dismissed on the board: hidden there even while their terminal runs, unless pinned.
4075
+ app.post('/api/groups/clean', groupRoute((b) => {
4076
+ if (!Array.isArray(b.keep)) throw previewError(400, 'keep must be an array of session ids');
4077
+ const keep = new Set(b.keep);
4078
+ const hide = new Set(Array.isArray(b.hide) ? b.hide : []);
4079
+ const pins = sessionPins.state().pins;
4080
+ const live = liveGroupMember((id) => keep.has(id));
4081
+ const isVisible = (m) => (Object.hasOwn(pins, m.ref) || !hide.has(m.ref)) && live(m);
4082
+ return userGroups.clean({ group: b.group ?? null, dryRun: b.dryRun === true, isVisible });
4083
+ }));
4071
4084
 
4072
4085
  // A session that runs in the terminal or is still starting has no transcript yet.
4073
- function liveGroupMember() {
4074
- const known = loadSessionMetadata();
4075
- const running = new Set([...terminal.ids(), ...dispatchGroups.snapshot().keys()]);
4076
- return (m) => m.type !== 'session' || !!known[m.ref] || running.has(m.ref);
4086
+ function liveGroupMember(isKnown) {
4087
+ const live = new Set([...terminal.ids(), ...Object.keys(sessionPins.state().pins)]);
4088
+ return (m) => m.type !== 'session' || isKnown(m.ref) || live.has(m.ref);
4089
+ }
4090
+
4091
+ // Members dead in the previous sweep too, which is the grace period: a member has no timestamp.
4092
+ let groupMembersDead = new Set();
4093
+ function expireGroupMembers(known) {
4094
+ if (!known) return { members: 0, groups: 0 };
4095
+ const isLive = liveGroupMember((id) => known.has(id));
4096
+ const dead = new Set();
4097
+ userGroups.reload();
4098
+ const out = userGroups.expire((m) => {
4099
+ if (isLive(m)) return false;
4100
+ dead.add(m.ref);
4101
+ return groupMembersDead.has(m.ref);
4102
+ });
4103
+ groupMembersDead = dead;
4104
+ if (out.members) broadcast({ type: 'group:changed', rev: out.rev });
4105
+ return out;
4077
4106
  }
4078
4107
  // `with` names another session; its project is keyed as the board keys it, by the worktree's repo.
4079
4108
  function groupPeer(id) {
@@ -4082,7 +4111,6 @@ function groupPeer(id) {
4082
4111
  return {
4083
4112
  ref: id,
4084
4113
  project: sessionProjectKey({ project: meta.project, worktree: worktrees.resolve(meta.project) }),
4085
- dispatchGroup: dispatchGroups.snapshot().get(id) || null,
4086
4114
  name: getSessionDisplayName(id, meta) || `Session ${id.slice(0, 8)}`,
4087
4115
  };
4088
4116
  }
@@ -4750,8 +4778,9 @@ async function runRetention() {
4750
4778
  const contexts = await pruneContextStatus(CONTEXT_STATUS_DIR, opts);
4751
4779
  const wts = worktrees.prune(scan?.dirs);
4752
4780
  const taskMaps = await pruneTaskMaps(TASK_MAPS_DIR, opts);
4753
- if (markers || layouts || reviews || contexts || wts || taskMaps) {
4754
- console.log(`[retention] removed ${markers} dispatch markers, ${layouts} pane layouts, ${reviews} reviews, ${contexts} context status files, ${wts} worktrees, ${taskMaps} task-list mappings`);
4781
+ const grouped = expireGroupMembers(scan?.ids);
4782
+ if (markers || layouts || reviews || contexts || wts || taskMaps || grouped.members) {
4783
+ console.log(`[retention] removed ${markers} dispatch markers, ${layouts} pane layouts, ${reviews} reviews, ${contexts} context status files, ${wts} worktrees, ${taskMaps} task-list mappings, ${grouped.members} group members, ${grouped.groups} groups`);
4755
4784
  }
4756
4785
  } catch (e) {
4757
4786
  console.warn('[retention] failed:', e.message);
@@ -15,7 +15,7 @@ claude-code-kanban dispatch start --cwd <dir> --spec-file <spec.md> --name <name
15
15
  ```
16
16
 
17
17
  - `--name` is the sidebar name and the session's peer name. Kebab-case and unique: `fix-login-redirect`.
18
- - `--group` names the effort in kebab-case (`auth-refactor`) and shows the session under that sidebar group. Pass it on every dispatch that belongs to the effort; a dispatch without it goes to its project.
18
+ - `--group` names the effort (`auth-refactor`, or a path like `auth-refactor/api`) and shows the session under that sidebar group. Pass it on every dispatch that belongs to the effort; a dispatch without it goes to its project. A group it makes goes when none of its sessions shows as active; an existing group stays.
19
19
  - `--spec-file` keeps a long spec out of shell quoting.
20
20
  - Everything after `--` goes to `claude` as it is: any flag in `claude --help`, e.g. `-- --permission-mode auto --add-dir ../shared`. Keep each value one shell word of plain characters (no quotes, `%` or control characters); long text belongs in the spec. cck owns the session id, name, model and worktree, so pass those with its own flags.
21
21
  - After a cck restart the terminal comes back with `claude --resume <id>` alone, so the args after `--` apply to the first run only.
@@ -1,88 +0,0 @@
1
- // Transient session groups: `dispatch start --group` puts the started session in a group,
2
- // and only an explicit `--group` does. A group lives while any member's claude
3
- // runs, and for a grace period after; once none runs, only pinned members stay in it.
4
- // The map is on disk so a pinned group outlives a server restart.
5
-
6
- const GROUP_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
7
- const MAX_GROUP = 64;
8
- // Covers a claude that has started but not yet registered, a resume, and registry lag.
9
- const GRACE_MS = 60 * 1000;
10
-
11
- function isGroupName(s) {
12
- return typeof s === 'string' && s.length <= MAX_GROUP && GROUP_RE.test(s);
13
- }
14
-
15
- function suggestGroupName(s) {
16
- return String(s ?? '')
17
- .replace(/([a-z0-9])([A-Z])/g, '$1-$2')
18
- .toLowerCase()
19
- .replace(/[^a-z0-9]+/g, '-')
20
- .replace(/^-+|-+$/g, '')
21
- .slice(0, MAX_GROUP)
22
- .replace(/-+$/, '');
23
- }
24
-
25
- /**
26
- * @param {object} o
27
- * @param {() => object|null} o.load returns `{sessions: {[id]: group}}` or null
28
- * @param {(data: object) => void} o.save
29
- * @param {(id: string) => boolean} o.isAlive
30
- * @param {() => Set<string>} o.pinnedIds
31
- */
32
- function createGroupStore({ load, save, isAlive, pinnedIds, now = Date.now }) {
33
- const members = new Map();
34
- const liveAt = new Map();
35
- const saved = load();
36
- if (saved?.sessions && typeof saved.sessions === 'object') {
37
- for (const [id, g] of Object.entries(saved.sessions)) if (isGroupName(g)) members.set(id, g);
38
- }
39
- for (const g of members.values()) liveAt.set(g, now());
40
-
41
- const persist = () => save({ version: 1, sessions: Object.fromEntries(members) });
42
-
43
- function join(group, ids) {
44
- let changed = false;
45
- for (const id of ids) {
46
- if (!id || members.get(id) === group) continue;
47
- members.set(id, group);
48
- changed = true;
49
- }
50
- liveAt.set(group, now());
51
- if (changed) persist();
52
- }
53
-
54
- function refresh() {
55
- const t = now();
56
- const byGroup = new Map();
57
- for (const [id, g] of members) {
58
- if (!byGroup.has(g)) byGroup.set(g, []);
59
- byGroup.get(g).push(id);
60
- }
61
- let pinned = null;
62
- let changed = false;
63
- for (const [g, ids] of byGroup) {
64
- if (ids.some(isAlive)) {
65
- liveAt.set(g, t);
66
- continue;
67
- }
68
- if (t - (liveAt.get(g) ?? 0) <= GRACE_MS) continue;
69
- pinned ??= pinnedIds();
70
- for (const id of ids) {
71
- if (pinned.has(id)) continue;
72
- members.delete(id);
73
- changed = true;
74
- }
75
- if (!ids.some((id) => pinned.has(id))) liveAt.delete(g);
76
- }
77
- if (changed) persist();
78
- }
79
-
80
- function snapshot() {
81
- refresh();
82
- return members;
83
- }
84
-
85
- return { join, snapshot };
86
- }
87
-
88
- module.exports = { createGroupStore, isGroupName, suggestGroupName, GRACE_MS };