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.
- package/README.md +1 -1
- package/cli.js +18 -47
- package/hub-app.json +2 -2
- package/install.js +11 -9
- package/lib/live-sessions.js +1 -1
- package/lib/terminal-client.js +4 -0
- package/lib/terminal-host.js +7 -1
- package/lib/terminal.js +61 -9
- package/lib/user-groups.js +86 -66
- package/package.json +2 -1
- package/public/app.js +486 -204
- package/public/group-tree.js +21 -0
- package/public/index.html +34 -13
- package/public/style.css +118 -6
- package/public/sw.js +1 -1
- package/public/vendor/claude-hub-sdk.js +1 -1
- package/server.js +72 -43
- package/skill-guides/dispatch.md +1 -1
- package/lib/dispatch-groups.js +0 -88
|
@@ -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
|
|
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="
|
|
756
|
-
<button class="storage-tab" data-tab="
|
|
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
|
-
<
|
|
832
|
+
<span class="ns-label"></span>
|
|
828
833
|
<div class="ns-field">
|
|
829
|
-
<
|
|
830
|
-
<
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
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:
|
|
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
|
|
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.
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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 && !
|
|
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
|
-
//
|
|
3496
|
-
|
|
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({
|
|
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', (
|
|
4054
|
+
app.get('/api/groups', (_req, res) => {
|
|
4054
4055
|
res.setHeader('Cache-Control', 'no-store');
|
|
4055
4056
|
userGroups.reload();
|
|
4056
|
-
|
|
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) =>
|
|
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
|
|
4075
|
-
|
|
4076
|
-
|
|
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
|
-
|
|
4754
|
-
|
|
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);
|
package/skill-guides/dispatch.md
CHANGED
|
@@ -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
|
|
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.
|
package/lib/dispatch-groups.js
DELETED
|
@@ -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 };
|