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.
- package/README.md +1 -1
- package/cli.js +23 -49
- package/hub-app.json +2 -2
- package/install.js +11 -9
- package/lib/live-sessions.js +17 -2
- package/lib/retention.js +10 -1
- package/lib/terminal-client.js +6 -0
- package/lib/terminal-host.js +8 -1
- package/lib/terminal.js +98 -22
- package/lib/user-groups.js +86 -66
- package/package.json +2 -1
- package/public/app.js +716 -255
- package/public/group-tree.js +21 -0
- package/public/index.html +35 -14
- package/public/style.css +147 -10
- package/public/sw.js +1 -1
- package/public/vendor/claude-hub-sdk.js +1 -1
- package/server.js +80 -44
- 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"/>
|
|
@@ -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
|
|
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
|
|
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
|
|
|
@@ -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
|
|
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
|
|
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.
|
|
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, 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(
|
|
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
|
-
|
|
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
|
-
|
|
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 && !
|
|
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
|
-
//
|
|
3496
|
-
|
|
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({
|
|
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', (
|
|
4061
|
+
app.get('/api/groups', (_req, res) => {
|
|
4054
4062
|
res.setHeader('Cache-Control', 'no-store');
|
|
4055
4063
|
userGroups.reload();
|
|
4056
|
-
|
|
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) =>
|
|
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
|
|
4075
|
-
|
|
4076
|
-
|
|
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
|
-
|
|
4754
|
-
|
|
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);
|
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.
|