claude-code-kanban 7.7.0 → 7.9.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"/>
@@ -475,7 +477,7 @@
475
477
  <button id="preview-pane-btn" class="icon-btn" aria-label="Open in a pane" title="Open in a pane" onclick="openPreviewInPane()">
476
478
  <svg fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><use href="#icon-pane"/></svg>
477
479
  </button>
478
- <button id="preview-link-btn" class="icon-btn" aria-label="Link to current session" title="Link to current session" onclick="togglePreviewSessionLink()">
480
+ <button id="preview-link-btn" class="icon-btn" aria-label="Link to the session" title="Link to the session" onclick="togglePreviewSessionLink()">
479
481
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
480
482
  </button>
481
483
  <button class="icon-btn" aria-label="Open in editor" title="Open in editor" onclick="openPreviewInEditor()">
@@ -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
 
@@ -8395,7 +8446,8 @@ select.form-input option:checked {
8395
8446
  box-shadow: 0 6px 20px rgba(0, 0, 0, 0.3);
8396
8447
  }
8397
8448
 
8398
- #review-pop textarea {
8449
+ #review-pop textarea,
8450
+ .review-card-edit {
8399
8451
  width: 100%;
8400
8452
  box-sizing: border-box;
8401
8453
  margin: 0 0 6px;
@@ -8524,7 +8576,8 @@ select.form-input option:checked {
8524
8576
  }
8525
8577
 
8526
8578
  .review-panel button.review-discard,
8527
- .review-panel button.review-remove {
8579
+ .review-panel button.review-remove,
8580
+ .review-panel button.review-edit {
8528
8581
  padding: 3px 4px;
8529
8582
  color: var(--text-muted);
8530
8583
  background: none;
@@ -8539,7 +8592,7 @@ select.form-input option:checked {
8539
8592
 
8540
8593
  .review-card {
8541
8594
  position: relative;
8542
- padding: 8px 24px 10px 0;
8595
+ padding: 8px 44px 10px 0;
8543
8596
  cursor: pointer;
8544
8597
  }
8545
8598
 
@@ -8566,12 +8619,35 @@ select.form-input option:checked {
8566
8619
  word-break: break-word;
8567
8620
  }
8568
8621
 
8569
- .review-panel button.review-remove {
8622
+ .review-panel button.review-remove,
8623
+ .review-panel button.review-edit {
8570
8624
  position: absolute;
8571
8625
  top: 4px;
8572
8626
  right: 4px;
8573
8627
  padding: 0 6px;
8574
8628
  }
8629
+
8630
+ .review-panel button.review-edit {
8631
+ right: 24px;
8632
+ }
8633
+
8634
+ .review-edit svg {
8635
+ width: 11px;
8636
+ height: 11px;
8637
+ }
8638
+
8639
+ .review-card-edit {
8640
+ border-color: var(--accent);
8641
+ }
8642
+
8643
+ .review-orphan {
8644
+ margin-right: 6px;
8645
+ padding: 0 4px;
8646
+ font-size: 10px;
8647
+ color: var(--warning);
8648
+ border: 1px solid var(--warning-dim);
8649
+ border-radius: 3px;
8650
+ }
8575
8651
  /* #endregion */
8576
8652
 
8577
8653
  /* #region SESSION_PICKER */
@@ -8857,6 +8933,57 @@ select.form-input option:checked {
8857
8933
  min-width: 0;
8858
8934
  }
8859
8935
 
8936
+ .ns-body select.form-input {
8937
+ appearance: none;
8938
+ padding-right: 30px;
8939
+ cursor: pointer;
8940
+ 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");
8941
+ background-repeat: no-repeat;
8942
+ background-position: right 10px center;
8943
+ }
8944
+
8945
+ .ns-notched {
8946
+ flex: 1;
8947
+ min-width: 0;
8948
+ margin: 0;
8949
+ padding: 0;
8950
+ border: 1px solid var(--border);
8951
+ border-radius: 6px;
8952
+ background: var(--bg-elevated);
8953
+ transition:
8954
+ border-color 0.15s ease,
8955
+ box-shadow 0.15s ease;
8956
+ }
8957
+
8958
+ .ns-notched:focus-within {
8959
+ border-color: var(--accent);
8960
+ box-shadow: 0 0 0 3px var(--accent-dim);
8961
+ }
8962
+
8963
+ .ns-notched legend {
8964
+ margin: 0 8px 0 auto;
8965
+ padding: 0 4px;
8966
+ font-size: 11px;
8967
+ font-weight: 500;
8968
+ line-height: 13px;
8969
+ color: var(--text-secondary);
8970
+ }
8971
+
8972
+ .ns-notched:focus-within legend {
8973
+ color: var(--accent-text);
8974
+ }
8975
+
8976
+ /* The legend's lower half sits inside the box, so less top padding keeps the box as tall as a plain input. */
8977
+ .ns-body .ns-notched select.form-input,
8978
+ .ns-body .ns-notched select.form-input:focus {
8979
+ padding-top: 2px;
8980
+ font-size: 12px;
8981
+ background-position: right 10px top calc(50% - 3px);
8982
+ border: none;
8983
+ background-color: transparent;
8984
+ box-shadow: none;
8985
+ }
8986
+
8860
8987
  .ns-body .btn.ns-browse {
8861
8988
  flex: none;
8862
8989
  padding: 8px 12px;
@@ -9551,6 +9678,15 @@ select.form-input option:checked {
9551
9678
  padding-top: 0;
9552
9679
  }
9553
9680
 
9681
+ .storage-group-header.storage-group-row {
9682
+ padding: 6px 0;
9683
+ }
9684
+
9685
+ .storage-group-row .storage-item-actions {
9686
+ align-items: center;
9687
+ gap: 8px;
9688
+ }
9689
+
9554
9690
  .storage-empty {
9555
9691
  text-align: center;
9556
9692
  padding: 32px 0;
@@ -9692,19 +9828,16 @@ pre.mermaid[data-mermaid-error]::before {
9692
9828
  /* #endregion */
9693
9829
 
9694
9830
  /* #region ANIMATIONS */
9831
+ /* No `to` and fill backwards: the card ends on its own opacity (.completed, .dim), not on 1. */
9695
9832
  @keyframes fadeSlideIn {
9696
9833
  from {
9697
9834
  opacity: 0;
9698
9835
  transform: translateY(8px);
9699
9836
  }
9700
- to {
9701
- opacity: 1;
9702
- transform: translateY(0);
9703
- }
9704
9837
  }
9705
9838
 
9706
9839
  .column-tasks .task-card {
9707
- animation: fadeSlideIn 150ms ease-out both;
9840
+ animation: fadeSlideIn 150ms ease-out backwards;
9708
9841
  }
9709
9842
 
9710
9843
  .column-tasks .task-card:nth-child(1) {
@@ -10076,6 +10209,10 @@ pre.mermaid[data-mermaid-error]::before {
10076
10209
  outline-offset: -1px;
10077
10210
  }
10078
10211
 
10212
+ .session-group-header.sg-zen {
10213
+ color: var(--accent);
10214
+ }
10215
+
10079
10216
  .sg-sub-label {
10080
10217
  padding: 8px 10px 2px 8px;
10081
10218
  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, noteReplacedSessions } = 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');
@@ -548,12 +546,15 @@ const SELF_TEAM_BOOT_WINDOW_MS = 60 * 1000;
548
546
  let liveSessionsCache = null;
549
547
  let lastLiveSessionsScan = 0;
550
548
  const LIVE_SESSIONS_TTL = 5000;
549
+ const replacedSessions = new Map();
551
550
 
552
551
  function loadLiveSessions() {
553
552
  const now = Date.now();
554
553
  if (liveSessionsCache && now - lastLiveSessionsScan < LIVE_SESSIONS_TTL) return liveSessionsCache;
554
+ const prev = liveSessionsCache;
555
555
  liveSessionsCache = readLiveSessions(SESSIONS_DIR);
556
556
  lastLiveSessionsScan = now;
557
+ noteReplacedSessions(prev, liveSessionsCache, replacedSessions, now, SESSION_STALE_MS);
557
558
  return liveSessionsCache;
558
559
  }
559
560
 
@@ -574,13 +575,14 @@ function getPeerName(sessionId) {
574
575
  }
575
576
 
576
577
  function hasRecentLogActivity(sessionId, logAge) {
577
- return logAge <= SESSION_STALE_MS && !isRegistryIdle(sessionId);
578
+ return logAge <= SESSION_STALE_MS && !isRegistryIdle(sessionId) && !replacedSessions.has(sessionId);
578
579
  }
579
580
 
580
581
  // Visibility-only recency: hasRecentLogActivity widened by the post-turn grace
581
582
  // window (ungated by registry-idle). Drives whether a session appears in the
582
583
  // active list — never the "active" status badges, which stay on hasRecentLog.
583
584
  function hasVisibleLogActivity(sessionId, logAge) {
585
+ if (replacedSessions.has(sessionId)) return false;
584
586
  return logAge <= SESSION_GRACE_MS || hasRecentLogActivity(sessionId, logAge);
585
587
  }
586
588
 
@@ -1659,7 +1661,7 @@ app.get('/api/sessions', async (req, res) => {
1659
1661
  s.autoCompact = autoCompactByProject.get(s.project);
1660
1662
  }
1661
1663
 
1662
- res.json(withDispatchPlacement(sessions));
1664
+ res.json(withDispatchMarker(sessions));
1663
1665
  startPrewarm();
1664
1666
  } catch (error) {
1665
1667
  console.error('Error listing sessions:', error);
@@ -3346,10 +3348,13 @@ const terminal = createTerminalClient({
3346
3348
  // project dir it started in, and cwd drifts into subdirectories.
3347
3349
  resolveCwd: resolveSessionFolder,
3348
3350
  isAllowedFolder,
3349
- onChange: () => broadcast({ type: 'terminals-update', ids: terminal.ids() }),
3351
+ onChange: () => broadcast({ type: 'terminals-update', ids: terminal.ids(), awaitingTrust: terminal.awaitingTrust() }),
3350
3352
  onExit: (id) => {
3351
3353
  if (dispatched.get(id)) broadcast({ type: 'dispatch-update' });
3352
3354
  },
3355
+ onRekey: (from, to) => {
3356
+ if (dispatched.move(from, to)) broadcast({ type: 'dispatch-update' });
3357
+ },
3353
3358
  });
3354
3359
 
3355
3360
  // A call to the terminal host fails with 503 while the host restarts.
@@ -3379,8 +3384,8 @@ app.post('/api/terminal/pick-folder', async (req, res) => {
3379
3384
  // The New session dialog's "Stay here": the PTY starts headless and the board keeps its view.
3380
3385
  app.post('/api/terminal/start', terminalRoute(async (req, res) => {
3381
3386
  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 });
3387
+ const { id, cwd, name, model, effort, worktree, prompt } = req.body || {};
3388
+ const started = await terminal.startNew({ id, cwd, name, model, effort, worktree, prompt });
3384
3389
  if (started.error) return res.status(started.status).json({ error: started.error });
3385
3390
  res.status(201).json({ session: started.id, cwd: started.cwd });
3386
3391
  }));
@@ -3456,26 +3461,12 @@ app.get('/vendor/xterm/:file', (req, res) => {
3456
3461
  // #region DISPATCH
3457
3462
  const dispatched = createDispatchedStore(jsonFile(DISPATCHED_FILE));
3458
3463
 
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
3464
  const linkedDocs = createLinkedDocStore(jsonFile(LINKED_DOCS_FILE));
3466
3465
 
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();
3466
+ function withDispatchMarker(sessions) {
3470
3467
  return sessions.map((s) => {
3471
- const dispatchGroup = groups.get(s.id);
3472
3468
  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
- };
3469
+ return marker ? { ...s, dispatched: { parent: marker.parent } } : s;
3479
3470
  });
3480
3471
  }
3481
3472
 
@@ -3486,16 +3477,31 @@ app.post('/api/dispatch', terminalRoute(async (req, res) => {
3486
3477
  const { cwd, spec, name, model, worktree, taskList, parent, group, claudeArgs } = req.body || {};
3487
3478
  if (typeof spec !== 'string' || !spec.trim()) return res.status(400).json({ error: 'spec is required' });
3488
3479
  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
- }
3480
+ if (group != null && !(typeof group === 'string' && group.trim())) return res.status(400).json({ error: 'group must be a group name, path or id' });
3492
3481
  const started = await terminal.startNew({ cwd, name, model, worktree, taskList, prompt: spec.trim(), extraArgs: claudeArgs });
3493
3482
  if (started.error) return res.status(started.status).json({ error: started.error });
3494
3483
  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]);
3484
+ // Joined before dispatch-update, so the board places the session from its first frame. The
3485
+ // starter stays where it is: moving it would jump it under the user.
3486
+ let joined = null;
3487
+ let groupError;
3488
+ if (group) {
3489
+ try {
3490
+ userGroups.reload();
3491
+ joined = userGroups.joinDispatch(group, started.id);
3492
+ broadcast({ type: 'group:changed', rev: joined.rev });
3493
+ } catch (e) {
3494
+ groupError = e.message;
3495
+ }
3496
+ }
3497
3497
  broadcast({ type: 'dispatch-update' });
3498
- res.status(201).json({ session: started.id, cwd: started.cwd, group: group || null });
3498
+ res.status(201).json({
3499
+ session: started.id,
3500
+ cwd: started.cwd,
3501
+ group: joined?.path ?? null,
3502
+ ...(joined ? { groupId: joined.group, transient: joined.transient } : {}),
3503
+ ...(groupError ? { groupError } : {}),
3504
+ });
3499
3505
  }));
3500
3506
 
3501
3507
  app.get('/api/dispatch', (req, res) => {
@@ -3627,7 +3633,6 @@ app.post('/api/tasks/:sessionId', async (req, res) => {
3627
3633
  id,
3628
3634
  subject,
3629
3635
  description: (req.body.description || '').trim(),
3630
- activeForm: subject,
3631
3636
  status: 'pending',
3632
3637
  blocks: [],
3633
3638
  blockedBy: [],
@@ -3959,6 +3964,9 @@ app.get('/api/session/resolve', (req, res) => {
3959
3964
  const m = metadata[idArg];
3960
3965
  return res.json({ id: idArg, customTitle: m?.customTitle || null });
3961
3966
  }
3967
+ // A session cck just dispatched has no transcript yet, and its starter groups it at once.
3968
+ const started = dispatched.get(idArg);
3969
+ if (started) return res.json({ id: idArg, customTitle: started.name || null });
3962
3970
  const matches = ids.filter(id => id.startsWith(idArg));
3963
3971
  if (matches.length === 0) return res.status(404).json({ matches: [] });
3964
3972
  if (matches.length > 1) {
@@ -4050,30 +4058,58 @@ function groupRoute(fn) {
4050
4058
  };
4051
4059
  }
4052
4060
 
4053
- app.get('/api/groups', (req, res) => {
4061
+ app.get('/api/groups', (_req, res) => {
4054
4062
  res.setHeader('Cache-Control', 'no-store');
4055
4063
  userGroups.reload();
4056
- const state = userGroups.state();
4057
- res.json(req.query.dispatch === '1' ? { ...state, dispatch: Object.fromEntries(dispatchGroups.snapshot()) } : state);
4064
+ res.json(userGroups.state());
4058
4065
  });
4059
4066
  app.post('/api/groups', groupRoute((b) => userGroups.create(b)));
4060
4067
  app.put('/api/groups', groupRoute((b) => userGroups.replace(b)));
4061
4068
  app.post('/api/groups/import', groupRoute((b) => userGroups.importLocal(b)));
4062
4069
  app.post('/api/groups/ungroup', groupRoute((b) => userGroups.ungroup(b)));
4063
- app.post('/api/groups/release', groupRoute((b) => userGroups.release(b.ids)));
4064
4070
  app.patch('/api/groups/:id', groupRoute((b, p) => userGroups.update(p.id, b)));
4065
4071
  app.delete('/api/groups/:id', groupRoute((_b, p) => userGroups.remove(p.id)));
4066
4072
  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)));
4073
+ app.post('/api/groups/path', groupRoute((b) => userGroups.createPath(b.path, b.transient === true)));
4068
4074
  app.post('/api/groups/rename', groupRoute((b) => userGroups.rename(b.group, b.name)));
4069
4075
  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() })));
4076
+ app.post('/api/groups/prune', groupRoute((b) => {
4077
+ const known = loadSessionMetadata();
4078
+ return userGroups.prune({ dryRun: b.dryRun === true, isLive: liveGroupMember((id) => !!known[id]) });
4079
+ }));
4080
+ // `keep` is the ids of `/api/sessions?filter=active&limit=all`: the Active rule lives in that route.
4081
+ // `hide` is the ids dismissed on the board: hidden there even while their terminal runs, unless pinned.
4082
+ app.post('/api/groups/clean', groupRoute((b) => {
4083
+ if (!Array.isArray(b.keep)) throw previewError(400, 'keep must be an array of session ids');
4084
+ const keep = new Set(b.keep);
4085
+ const hide = new Set(Array.isArray(b.hide) ? b.hide : []);
4086
+ const pins = sessionPins.state().pins;
4087
+ const live = liveGroupMember((id) => keep.has(id));
4088
+ const isVisible = (m) => (Object.hasOwn(pins, m.ref) || !hide.has(m.ref)) && live(m);
4089
+ return userGroups.clean({ group: b.group ?? null, dryRun: b.dryRun === true, isVisible });
4090
+ }));
4071
4091
 
4072
4092
  // 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);
4093
+ function liveGroupMember(isKnown) {
4094
+ const live = new Set([...terminal.ids(), ...Object.keys(sessionPins.state().pins)]);
4095
+ return (m) => m.type !== 'session' || isKnown(m.ref) || live.has(m.ref);
4096
+ }
4097
+
4098
+ // Members dead in the previous sweep too, which is the grace period: a member has no timestamp.
4099
+ let groupMembersDead = new Set();
4100
+ function expireGroupMembers(known) {
4101
+ if (!known) return { members: 0, groups: 0 };
4102
+ const isLive = liveGroupMember((id) => known.has(id));
4103
+ const dead = new Set();
4104
+ userGroups.reload();
4105
+ const out = userGroups.expire((m) => {
4106
+ if (isLive(m)) return false;
4107
+ dead.add(m.ref);
4108
+ return groupMembersDead.has(m.ref);
4109
+ });
4110
+ groupMembersDead = dead;
4111
+ if (out.members) broadcast({ type: 'group:changed', rev: out.rev });
4112
+ return out;
4077
4113
  }
4078
4114
  // `with` names another session; its project is keyed as the board keys it, by the worktree's repo.
4079
4115
  function groupPeer(id) {
@@ -4082,7 +4118,6 @@ function groupPeer(id) {
4082
4118
  return {
4083
4119
  ref: id,
4084
4120
  project: sessionProjectKey({ project: meta.project, worktree: worktrees.resolve(meta.project) }),
4085
- dispatchGroup: dispatchGroups.snapshot().get(id) || null,
4086
4121
  name: getSessionDisplayName(id, meta) || `Session ${id.slice(0, 8)}`,
4087
4122
  };
4088
4123
  }
@@ -4750,8 +4785,9 @@ async function runRetention() {
4750
4785
  const contexts = await pruneContextStatus(CONTEXT_STATUS_DIR, opts);
4751
4786
  const wts = worktrees.prune(scan?.dirs);
4752
4787
  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`);
4788
+ const grouped = expireGroupMembers(scan?.ids);
4789
+ if (markers || layouts || reviews || contexts || wts || taskMaps || grouped.members) {
4790
+ 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
4791
  }
4756
4792
  } catch (e) {
4757
4793
  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.