claude-code-kanban 5.4.0 → 6.1.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 +23 -13
- package/cli.js +20 -103
- package/lib/dispatch-groups.js +9 -31
- package/lib/dispatch.js +19 -166
- package/lib/git-branch.js +16 -1
- package/lib/live-sessions.js +39 -0
- package/lib/parsers.js +18 -3
- package/lib/retention.js +5 -17
- package/lib/session-cache.js +1 -1
- package/lib/session-events.js +3 -2
- package/lib/terminal-client.js +221 -0
- package/lib/terminal-host.js +95 -0
- package/lib/terminal.js +101 -42
- package/package.json +1 -1
- package/plugin/plugins/claude-code-kanban/.claude-plugin/plugin.json +1 -1
- package/plugin/plugins/claude-code-kanban/hooks/context.ts +5 -2
- package/plugin/plugins/claude-code-kanban/monitors.json +0 -6
- package/plugin/plugins/claude-code-kanban/scripts/postman.js +2 -7
- package/plugin/plugins/claude-code-kanban/skills/dispatch/SKILL.md +17 -11
- package/plugin/plugins/claude-code-kanban/skills/dispatch/references/orchestration-patterns.md +50 -0
- package/plugin/plugins/claude-code-kanban/tests/context.test.ts +14 -1
- package/public/app.js +384 -93
- package/public/index.html +14 -3
- package/public/project-match.js +6 -1
- package/public/style.css +110 -0
- package/server.js +102 -119
- package/skill-guides/dispatch.md +16 -54
package/public/index.html
CHANGED
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
</div>
|
|
53
53
|
<h2>Server Not Running</h2>
|
|
54
54
|
<p>The dashboard needs the Claude Code Kanban server to stream live task data. Start it with:</p>
|
|
55
|
-
<code>
|
|
55
|
+
<code>claude-code-kanban</code>
|
|
56
56
|
<button class="btn btn-primary" onclick="location.reload()">Retry Connection</button>
|
|
57
57
|
<div class="offline-status" id="offline-status" aria-live="polite"></div>
|
|
58
58
|
</div>
|
|
@@ -691,8 +691,18 @@
|
|
|
691
691
|
</div>
|
|
692
692
|
<div id="scratchpad-modal" class="modal-overlay" onclick="closeScratchpad()">
|
|
693
693
|
<div class="modal scratchpad-modal" onclick="event.stopPropagation()">
|
|
694
|
-
<div class="modal-header">
|
|
695
|
-
<
|
|
694
|
+
<div class="modal-header scratchpad-header">
|
|
695
|
+
<div class="scratchpad-title-row">
|
|
696
|
+
<button id="scratchpad-emoji-btn" class="scratchpad-emoji-btn" type="button" onclick="toggleScratchpadEmojiPicker()"></button>
|
|
697
|
+
<h3 class="modal-title">Scratchpad</h3>
|
|
698
|
+
</div>
|
|
699
|
+
<div id="scratchpad-emoji-picker" class="scratchpad-emoji-picker" tabindex="-1">
|
|
700
|
+
<div id="scratchpad-emoji-grid" class="scratchpad-emoji-grid"></div>
|
|
701
|
+
<div class="scratchpad-emoji-row">
|
|
702
|
+
<input id="scratchpad-emoji-input" class="form-input" maxlength="16" placeholder="Paste" aria-label="Custom emoji">
|
|
703
|
+
<button type="button" class="btn btn-secondary" onclick="setScratchpadEmoji('')">Clear</button>
|
|
704
|
+
</div>
|
|
705
|
+
</div>
|
|
696
706
|
<button class="modal-close" aria-label="Close dialog" onclick="closeScratchpad()">
|
|
697
707
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
698
708
|
<path d="M18 6L6 18M6 6l12 12"/>
|
|
@@ -808,6 +818,7 @@
|
|
|
808
818
|
<div id="ns-error" class="ns-error" hidden></div>
|
|
809
819
|
</div>
|
|
810
820
|
<div class="modal-footer ns-footer">
|
|
821
|
+
<label class="ns-check ns-stay" title="Start in the background and keep the current view"><input type="checkbox" id="ns-stay"> Stay here</label>
|
|
811
822
|
<button type="button" class="btn btn-secondary" onclick="closeNewSession()">Cancel</button>
|
|
812
823
|
<button type="button" id="ns-start" class="btn btn-primary" onclick="startNewSession()" disabled>Start</button>
|
|
813
824
|
</div>
|
package/public/project-match.js
CHANGED
|
@@ -23,7 +23,12 @@ const projectMatch = (() => {
|
|
|
23
23
|
return p === q || (!!repo && normalizeProjectPath(repo) === q);
|
|
24
24
|
};
|
|
25
25
|
}
|
|
26
|
-
|
|
26
|
+
// The project a session is listed under. A worktree transcript is filed under the worktree
|
|
27
|
+
// path, so `session.project` alone would show each worktree as a project of its own.
|
|
28
|
+
function sessionProjectKey(session) {
|
|
29
|
+
return session?.worktree?.repo || session?.project || null;
|
|
30
|
+
}
|
|
31
|
+
return { normalizeProjectPath, isExactProjectPath, isExactProjectFilter, projectMatcher, sessionProjectKey };
|
|
27
32
|
})();
|
|
28
33
|
|
|
29
34
|
if (typeof module === 'object' && module.exports) module.exports = projectMatch;
|
package/public/style.css
CHANGED
|
@@ -6861,6 +6861,17 @@ select.form-input option:checked {
|
|
|
6861
6861
|
|
|
6862
6862
|
.ns-footer {
|
|
6863
6863
|
align-items: center;
|
|
6864
|
+
margin-top: 16px;
|
|
6865
|
+
border-top: 1px solid var(--border);
|
|
6866
|
+
}
|
|
6867
|
+
|
|
6868
|
+
.ns-stay {
|
|
6869
|
+
margin-right: auto;
|
|
6870
|
+
color: var(--text-tertiary);
|
|
6871
|
+
}
|
|
6872
|
+
|
|
6873
|
+
.new-session-modal.ns-resume .ns-stay {
|
|
6874
|
+
visibility: hidden;
|
|
6864
6875
|
}
|
|
6865
6876
|
|
|
6866
6877
|
.ns-footer .btn-primary:disabled {
|
|
@@ -7075,6 +7086,105 @@ select.form-input option:checked {
|
|
|
7075
7086
|
color: var(--text-muted);
|
|
7076
7087
|
align-self: center;
|
|
7077
7088
|
}
|
|
7089
|
+
|
|
7090
|
+
.scratchpad-header {
|
|
7091
|
+
position: relative;
|
|
7092
|
+
}
|
|
7093
|
+
|
|
7094
|
+
.scratchpad-title-row {
|
|
7095
|
+
display: flex;
|
|
7096
|
+
align-items: center;
|
|
7097
|
+
gap: 10px;
|
|
7098
|
+
}
|
|
7099
|
+
|
|
7100
|
+
.scratchpad-emoji-btn {
|
|
7101
|
+
width: 32px;
|
|
7102
|
+
height: 32px;
|
|
7103
|
+
display: flex;
|
|
7104
|
+
align-items: center;
|
|
7105
|
+
justify-content: center;
|
|
7106
|
+
font-size: 18px;
|
|
7107
|
+
line-height: 1;
|
|
7108
|
+
color: var(--text-muted);
|
|
7109
|
+
background: var(--bg-elevated);
|
|
7110
|
+
border: 1px dashed var(--border);
|
|
7111
|
+
border-radius: 8px;
|
|
7112
|
+
cursor: pointer;
|
|
7113
|
+
}
|
|
7114
|
+
|
|
7115
|
+
.scratchpad-emoji-btn.set {
|
|
7116
|
+
border-style: solid;
|
|
7117
|
+
border-color: var(--accent);
|
|
7118
|
+
}
|
|
7119
|
+
|
|
7120
|
+
.scratchpad-emoji-btn:hover {
|
|
7121
|
+
border-color: var(--accent);
|
|
7122
|
+
}
|
|
7123
|
+
|
|
7124
|
+
.scratchpad-emoji-btn[hidden] {
|
|
7125
|
+
display: none;
|
|
7126
|
+
}
|
|
7127
|
+
|
|
7128
|
+
.scratchpad-emoji-picker {
|
|
7129
|
+
display: none;
|
|
7130
|
+
position: absolute;
|
|
7131
|
+
top: 40px;
|
|
7132
|
+
left: 0;
|
|
7133
|
+
z-index: 5;
|
|
7134
|
+
width: 300px;
|
|
7135
|
+
padding: 10px;
|
|
7136
|
+
background: var(--bg-elevated);
|
|
7137
|
+
border: 1px solid var(--border);
|
|
7138
|
+
border-radius: 10px;
|
|
7139
|
+
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.35);
|
|
7140
|
+
outline: none;
|
|
7141
|
+
}
|
|
7142
|
+
|
|
7143
|
+
.scratchpad-emoji-picker.open {
|
|
7144
|
+
display: block;
|
|
7145
|
+
}
|
|
7146
|
+
|
|
7147
|
+
.scratchpad-emoji-grid {
|
|
7148
|
+
display: grid;
|
|
7149
|
+
gap: 2px;
|
|
7150
|
+
}
|
|
7151
|
+
|
|
7152
|
+
.scratchpad-emoji-grid button {
|
|
7153
|
+
padding: 4px 0;
|
|
7154
|
+
font-size: 18px;
|
|
7155
|
+
background: none;
|
|
7156
|
+
border: 0;
|
|
7157
|
+
border-radius: 6px;
|
|
7158
|
+
cursor: pointer;
|
|
7159
|
+
}
|
|
7160
|
+
|
|
7161
|
+
.scratchpad-emoji-grid button:hover,
|
|
7162
|
+
.scratchpad-emoji-grid button.kb {
|
|
7163
|
+
background: var(--bg-hover);
|
|
7164
|
+
}
|
|
7165
|
+
|
|
7166
|
+
.scratchpad-emoji-grid button.cur {
|
|
7167
|
+
background: var(--accent-dim);
|
|
7168
|
+
}
|
|
7169
|
+
|
|
7170
|
+
.scratchpad-emoji-row {
|
|
7171
|
+
display: flex;
|
|
7172
|
+
gap: 6px;
|
|
7173
|
+
margin-top: 8px;
|
|
7174
|
+
}
|
|
7175
|
+
|
|
7176
|
+
.scratchpad-emoji-row .form-input {
|
|
7177
|
+
flex: 1;
|
|
7178
|
+
min-width: 0;
|
|
7179
|
+
padding: 5px 8px;
|
|
7180
|
+
font-size: 13px;
|
|
7181
|
+
}
|
|
7182
|
+
|
|
7183
|
+
.scratchpad-badge.has-emoji {
|
|
7184
|
+
font-size: 13px;
|
|
7185
|
+
line-height: 18px;
|
|
7186
|
+
padding: 0 5px;
|
|
7187
|
+
}
|
|
7078
7188
|
/* #endregion */
|
|
7079
7189
|
|
|
7080
7190
|
/* #region STORAGE_MANAGER */
|
package/server.js
CHANGED
|
@@ -41,13 +41,15 @@ const {
|
|
|
41
41
|
const { inlineHtmlAssets, MIME_BY_EXT } = require('./lib/inline-assets');
|
|
42
42
|
const { buildDecision, decisionFileName, isDecisionFile, approvalsFrom, boardRefusal } = require('./lib/approvals');
|
|
43
43
|
const { getClaudeDir, getArgValue, storageNamespace, isDefaultClaudeDir, encodeProjectDirName } = require('./lib/claude-dir');
|
|
44
|
-
const {
|
|
44
|
+
const { readTerminalConfig } = require('./lib/terminal');
|
|
45
|
+
const { createTerminalClient } = require('./lib/terminal-client');
|
|
46
|
+
const { readLiveSessions, isPidAlive, isSessionLive } = require('./lib/live-sessions');
|
|
45
47
|
const { createProcStats } = require('./lib/proc-stats');
|
|
46
|
-
const { createDispatchRegistry
|
|
48
|
+
const { createDispatchRegistry } = require('./lib/dispatch');
|
|
47
49
|
const { createGroupStore, isGroupName, suggestGroupName } = require('./lib/dispatch-groups');
|
|
48
50
|
const { createDispatchedStore, scanTranscripts, pruneSessionDirs, retentionMs } = require('./lib/retention');
|
|
49
51
|
const { createWorktreeStore } = require('./lib/worktrees');
|
|
50
|
-
const { readGitBranch } = require('./lib/git-branch');
|
|
52
|
+
const { readGitBranch, sessionGitBranch } = require('./lib/git-branch');
|
|
51
53
|
const { createLinkedDocStore, linkUrl } = require('./lib/linked-docs');
|
|
52
54
|
const { pickFolder } = require('./lib/folder-dialog');
|
|
53
55
|
const { loadSessionCache, saveSessionCache } = require('./lib/session-cache');
|
|
@@ -301,7 +303,11 @@ function getContextStatus(sessionId, meta) {
|
|
|
301
303
|
|
|
302
304
|
function getContextFields(sessionId, meta) {
|
|
303
305
|
const contextStatus = getContextStatus(sessionId, meta);
|
|
304
|
-
return {
|
|
306
|
+
return {
|
|
307
|
+
contextStatus,
|
|
308
|
+
autoCompact: contextStatus ? getAutoCompact(CLAUDE_DIR, meta?.project) : null,
|
|
309
|
+
cacheTtl: meta?.cacheTtl || null,
|
|
310
|
+
};
|
|
305
311
|
}
|
|
306
312
|
|
|
307
313
|
function isAgentFresh(agent) {
|
|
@@ -343,15 +349,6 @@ const worktrees = createWorktreeStore({
|
|
|
343
349
|
save: (data) => writeJsonAtomic(WORKTREES_FILE, data),
|
|
344
350
|
});
|
|
345
351
|
|
|
346
|
-
// Only look up the branch when cwd has diverged from the launch project — that's
|
|
347
|
-
// the only case the JSONL value is wrong.
|
|
348
|
-
function resolveSessionGitBranch(meta) {
|
|
349
|
-
if (meta.cwd && meta.project && meta.cwd !== meta.project) {
|
|
350
|
-
return getGitBranch(meta.cwd) || meta.gitBranch || null;
|
|
351
|
-
}
|
|
352
|
-
return meta.gitBranch || null;
|
|
353
|
-
}
|
|
354
|
-
|
|
355
352
|
function getSessionLogStat(meta) {
|
|
356
353
|
if (!meta.jsonlPath) return { mtime: null, hasMessages: false };
|
|
357
354
|
try {
|
|
@@ -476,25 +473,12 @@ let liveSessionsCache = null;
|
|
|
476
473
|
let lastLiveSessionsScan = 0;
|
|
477
474
|
const LIVE_SESSIONS_TTL = 5000;
|
|
478
475
|
|
|
479
|
-
function loadLiveSessions(
|
|
476
|
+
function loadLiveSessions() {
|
|
480
477
|
const now = Date.now();
|
|
481
|
-
if (
|
|
482
|
-
|
|
483
|
-
if (existsSync(SESSIONS_DIR)) {
|
|
484
|
-
try {
|
|
485
|
-
for (const file of readdirSync(SESSIONS_DIR).filter(f => f.endsWith('.json'))) {
|
|
486
|
-
try {
|
|
487
|
-
const s = JSON.parse(readFileSync(path.join(SESSIONS_DIR, file), 'utf8'));
|
|
488
|
-
if (s?.sessionId && s.kind === 'interactive') {
|
|
489
|
-
sessions.push({ sessionId: s.sessionId, pid: s.pid || null, cwd: s.cwd || null, startedAt: s.startedAt || 0, status: s.status || null, name: s.name || null });
|
|
490
|
-
}
|
|
491
|
-
} catch (_) { /* skip invalid */ }
|
|
492
|
-
}
|
|
493
|
-
} catch (_) { /* ignore */ }
|
|
494
|
-
}
|
|
495
|
-
liveSessionsCache = sessions;
|
|
478
|
+
if (liveSessionsCache && now - lastLiveSessionsScan < LIVE_SESSIONS_TTL) return liveSessionsCache;
|
|
479
|
+
liveSessionsCache = readLiveSessions(SESSIONS_DIR);
|
|
496
480
|
lastLiveSessionsScan = now;
|
|
497
|
-
return
|
|
481
|
+
return liveSessionsCache;
|
|
498
482
|
}
|
|
499
483
|
|
|
500
484
|
// An open-but-idle interactive session keeps touching its JSONL (metadata-line
|
|
@@ -513,19 +497,6 @@ function getPeerName(sessionId) {
|
|
|
513
497
|
return live?.name ?? null;
|
|
514
498
|
}
|
|
515
499
|
|
|
516
|
-
// A registry file outlives a crashed claude, so the pid is probed rather than trusted.
|
|
517
|
-
function isSessionProcessAlive(sessionId, exceptPid = null) {
|
|
518
|
-
return loadLiveSessions().some((s) => {
|
|
519
|
-
if (s.sessionId !== sessionId || !s.pid || s.pid === exceptPid) return false;
|
|
520
|
-
return isPidAlive(s.pid);
|
|
521
|
-
});
|
|
522
|
-
}
|
|
523
|
-
|
|
524
|
-
// EPERM means the process exists but belongs to someone else.
|
|
525
|
-
function isPidAlive(pid) {
|
|
526
|
-
try { process.kill(pid, 0); return true; } catch (e) { return e.code === 'EPERM'; }
|
|
527
|
-
}
|
|
528
|
-
|
|
529
500
|
function hasRecentLogActivity(sessionId, logAge) {
|
|
530
501
|
return logAge <= SESSION_STALE_MS && !isRegistryIdle(sessionId);
|
|
531
502
|
}
|
|
@@ -856,6 +827,7 @@ function refreshSessionMetadataPath(jsonlPath) {
|
|
|
856
827
|
if (info.logicalParentUuid) existing.logicalParentUuid = info.logicalParentUuid;
|
|
857
828
|
if (info.compactBoundaryUuid) existing.compactBoundaryUuid = info.compactBoundaryUuid;
|
|
858
829
|
existing.permissionMode = info.permissionMode;
|
|
830
|
+
existing.cacheTtl = info.cacheTtl;
|
|
859
831
|
return true;
|
|
860
832
|
}
|
|
861
833
|
|
|
@@ -943,7 +915,8 @@ function loadSessionMetadata() {
|
|
|
943
915
|
jsonlPath: jsonlPath,
|
|
944
916
|
logicalParentUuid: sessionInfo.logicalParentUuid || null,
|
|
945
917
|
compactBoundaryUuid: sessionInfo.compactBoundaryUuid || null,
|
|
946
|
-
permissionMode: sessionInfo.permissionMode
|
|
918
|
+
permissionMode: sessionInfo.permissionMode,
|
|
919
|
+
cacheTtl: sessionInfo.cacheTtl || null
|
|
947
920
|
};
|
|
948
921
|
sessionIds.push(sessionId);
|
|
949
922
|
}
|
|
@@ -1168,6 +1141,7 @@ function buildSessionObject(id, meta, overrides = {}) {
|
|
|
1168
1141
|
const logStat = overrides._logStat || getSessionLogStat(meta);
|
|
1169
1142
|
const logMtime = logStat.mtime;
|
|
1170
1143
|
const logAge = logMtime ? Date.now() - logMtime : Infinity;
|
|
1144
|
+
const worktree = worktrees.resolve(meta.project);
|
|
1171
1145
|
return {
|
|
1172
1146
|
id,
|
|
1173
1147
|
name: getSessionDisplayName(id, meta),
|
|
@@ -1176,8 +1150,8 @@ function buildSessionObject(id, meta, overrides = {}) {
|
|
|
1176
1150
|
project: meta.project || null,
|
|
1177
1151
|
cwd: meta.cwd || null,
|
|
1178
1152
|
description: meta.description || null,
|
|
1179
|
-
gitBranch:
|
|
1180
|
-
worktree
|
|
1153
|
+
gitBranch: sessionGitBranch(meta, !!worktree, getGitBranch),
|
|
1154
|
+
worktree,
|
|
1181
1155
|
customTitle: meta.customTitle || null,
|
|
1182
1156
|
taskCount: 0,
|
|
1183
1157
|
completed: 0,
|
|
@@ -1227,7 +1201,7 @@ app.get('/api/sessions', async (req, res) => {
|
|
|
1227
1201
|
const pinnedIds = pinnedParam ? new Set(pinnedParam.split(',').filter(Boolean)) : new Set();
|
|
1228
1202
|
for (const id of includeIds) pinnedIds.add(id);
|
|
1229
1203
|
const activeFilter = req.query.filter === 'active';
|
|
1230
|
-
const terminalIds = activeFilter ? new Set(terminal.
|
|
1204
|
+
const terminalIds = activeFilter ? new Set(terminal.ids()) : new Set();
|
|
1231
1205
|
|
|
1232
1206
|
const metadata = loadSessionMetadata();
|
|
1233
1207
|
const sessionsMap = new Map();
|
|
@@ -1631,6 +1605,30 @@ app.get('/api/sessions/search', (req, res) => {
|
|
|
1631
1605
|
res.json(hits.slice(0, SESSION_SEARCH_MAX).map((h) => h.id));
|
|
1632
1606
|
});
|
|
1633
1607
|
|
|
1608
|
+
app.get('/api/sessions/known', (_req, res) => {
|
|
1609
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
1610
|
+
try {
|
|
1611
|
+
const known = new Map();
|
|
1612
|
+
for (const [id, meta] of Object.entries(loadSessionMetadata())) {
|
|
1613
|
+
known.set(id, { id, project: meta.project || null, name: getSessionDisplayName(id, meta) });
|
|
1614
|
+
}
|
|
1615
|
+
const add = (id, project = null) => {
|
|
1616
|
+
if (!known.has(id)) known.set(id, { id, project, name: null });
|
|
1617
|
+
};
|
|
1618
|
+
for (const dir of [TASKS_DIR, AGENT_ACTIVITY_DIR]) {
|
|
1619
|
+
if (!existsSync(dir)) continue;
|
|
1620
|
+
for (const d of readdirSync(dir, { withFileTypes: true })) if (d.isDirectory()) add(d.name);
|
|
1621
|
+
}
|
|
1622
|
+
for (const map of Object.values(loadAllTaskMaps().listToSessions)) {
|
|
1623
|
+
for (const [id, info] of Object.entries(map)) add(id, info.project || null);
|
|
1624
|
+
}
|
|
1625
|
+
res.json([...known.values()]);
|
|
1626
|
+
} catch (error) {
|
|
1627
|
+
console.error('Error listing known sessions:', error);
|
|
1628
|
+
res.status(500).json({ error: 'Failed to list known sessions' });
|
|
1629
|
+
}
|
|
1630
|
+
});
|
|
1631
|
+
|
|
1634
1632
|
function isTempPath(p) {
|
|
1635
1633
|
const rel = path.relative(TEMP_ROOT, p);
|
|
1636
1634
|
return !!rel && !rel.startsWith('..') && !path.isAbsolute(rel);
|
|
@@ -3256,29 +3254,34 @@ function resolveSessionFolder(id) {
|
|
|
3256
3254
|
return meta ? meta.project || meta.cwd || null : null;
|
|
3257
3255
|
}
|
|
3258
3256
|
|
|
3259
|
-
|
|
3260
|
-
const terminal = createTerminalService({
|
|
3257
|
+
const terminal = createTerminalClient({
|
|
3261
3258
|
config: readTerminalConfig({ getArgValue }),
|
|
3262
|
-
serverUrl: () => (listenPort ? `http://127.0.0.1:${listenPort}` : null),
|
|
3263
3259
|
net,
|
|
3264
3260
|
claudeDir: CLAUDE_DIR,
|
|
3265
3261
|
isDefaultDir: isDefaultClaudeDir(CLAUDE_DIR),
|
|
3262
|
+
sessionsDir: SESSIONS_DIR,
|
|
3266
3263
|
token: process.env.CCK_TERMINAL_TOKEN,
|
|
3267
3264
|
load: () => {
|
|
3268
3265
|
try { return JSON.parse(readFileSync(TERMINALS_FILE, 'utf8')); } catch { return null; }
|
|
3269
3266
|
},
|
|
3270
3267
|
save: (data) => writeJsonAtomic(TERMINALS_FILE, data),
|
|
3271
|
-
which: whichSync,
|
|
3272
|
-
isLiveElsewhere: isSessionProcessAlive,
|
|
3273
3268
|
// The project, not the last cwd: `claude --resume` finds a session under the
|
|
3274
3269
|
// project dir it started in, and cwd drifts into subdirectories.
|
|
3275
3270
|
resolveCwd: resolveSessionFolder,
|
|
3276
3271
|
isAllowedFolder,
|
|
3277
|
-
|
|
3278
|
-
|
|
3279
|
-
|
|
3272
|
+
onChange: () => broadcast({ type: 'terminals-update', ids: terminal.ids() }),
|
|
3273
|
+
onExit: (id) => {
|
|
3274
|
+
if (dispatches.remove(id)) broadcast({ type: 'dispatch-update' });
|
|
3275
|
+
},
|
|
3280
3276
|
});
|
|
3281
3277
|
|
|
3278
|
+
// A call to the terminal host fails with 503 while the host restarts.
|
|
3279
|
+
function terminalRoute(fn) {
|
|
3280
|
+
return (req, res) => fn(req, res).catch((e) => {
|
|
3281
|
+
if (!res.headersSent) res.status(e.status || 500).json({ error: e.message });
|
|
3282
|
+
});
|
|
3283
|
+
}
|
|
3284
|
+
|
|
3282
3285
|
let folderDialogOpen = false;
|
|
3283
3286
|
app.post('/api/terminal/pick-folder', async (req, res) => {
|
|
3284
3287
|
const reason = terminal.unavailableReason();
|
|
@@ -3297,28 +3300,37 @@ app.post('/api/terminal/pick-folder', async (req, res) => {
|
|
|
3297
3300
|
}
|
|
3298
3301
|
});
|
|
3299
3302
|
|
|
3303
|
+
// The New session dialog's "Stay here": the PTY starts headless and the board keeps its view.
|
|
3304
|
+
app.post('/api/terminal/start', terminalRoute(async (req, res) => {
|
|
3305
|
+
if (!terminal.authorized(req.get('x-terminal-token'))) return res.status(401).json({ error: 'invalid terminal token' });
|
|
3306
|
+
const { id, cwd, name, model, worktree, prompt } = req.body || {};
|
|
3307
|
+
const started = await terminal.startNew({ id, cwd, name, model, worktree, prompt });
|
|
3308
|
+
if (started.error) return res.status(started.status).json({ error: started.error });
|
|
3309
|
+
res.status(201).json({ session: started.id, cwd: started.cwd });
|
|
3310
|
+
}));
|
|
3311
|
+
|
|
3300
3312
|
// The hub's eviction check reads this to keep a pool with live PTYs alive.
|
|
3301
|
-
app.get('/api/terminals', (_req, res) => {
|
|
3313
|
+
app.get('/api/terminals', terminalRoute(async (_req, res) => {
|
|
3302
3314
|
res.setHeader('Cache-Control', 'no-store');
|
|
3303
|
-
res.json({ sessions: terminal.
|
|
3304
|
-
});
|
|
3315
|
+
res.json({ sessions: await terminal.sessions() });
|
|
3316
|
+
}));
|
|
3305
3317
|
|
|
3306
3318
|
const terminalProcStats = createProcStats();
|
|
3307
|
-
app.get('/api/terminals/stats', async (_req, res) => {
|
|
3319
|
+
app.get('/api/terminals/stats', terminalRoute(async (_req, res) => {
|
|
3308
3320
|
res.setHeader('Cache-Control', 'no-store');
|
|
3309
|
-
const
|
|
3310
|
-
const byPid = await terminalProcStats([process.pid, ...Object.values(
|
|
3321
|
+
const { hostPid, claudePids } = await terminal.stats();
|
|
3322
|
+
const byPid = await terminalProcStats([process.pid, hostPid, ...Object.values(claudePids)].filter(Boolean));
|
|
3311
3323
|
const terminals = {};
|
|
3312
|
-
for (const [id, pid] of Object.entries(
|
|
3313
|
-
res.json({ cck: byPid[process.pid] || null, terminals });
|
|
3314
|
-
});
|
|
3324
|
+
for (const [id, pid] of Object.entries(claudePids)) if (byPid[pid]) terminals[id] = byPid[pid];
|
|
3325
|
+
res.json({ cck: byPid[process.pid] || null, host: byPid[hostPid] || null, terminals });
|
|
3326
|
+
}));
|
|
3315
3327
|
|
|
3316
|
-
app.delete('/api/terminals/:id', (req, res) => {
|
|
3317
|
-
const err = terminal.end(req.params.id, req.get('x-terminal-token'));
|
|
3328
|
+
app.delete('/api/terminals/:id', terminalRoute(async (req, res) => {
|
|
3329
|
+
const err = await terminal.end(req.params.id, req.get('x-terminal-token'));
|
|
3318
3330
|
if (err === 'auth') return res.status(401).json({ error: 'invalid terminal token' });
|
|
3319
3331
|
if (err === 'not-found') return res.status(404).json({ error: 'no such terminal' });
|
|
3320
3332
|
res.status(204).end();
|
|
3321
|
-
});
|
|
3333
|
+
}));
|
|
3322
3334
|
|
|
3323
3335
|
// Served from node_modules, never a CDN: any script on this page can use the token.
|
|
3324
3336
|
const XTERM_FILES = {
|
|
@@ -3346,20 +3358,14 @@ const dispatched = createDispatchedStore({
|
|
|
3346
3358
|
save: (data) => writeJsonAtomic(DISPATCHED_FILE, data),
|
|
3347
3359
|
});
|
|
3348
3360
|
|
|
3349
|
-
const dispatches = createDispatchRegistry(
|
|
3350
|
-
onSettle: (r) => {
|
|
3351
|
-
if (r.parent && r.report) enqueueSessionEvent(topicKey('dispatch', r.parent), formatDispatchLine(r));
|
|
3352
|
-
if (r.session) dispatched.settle(r.session, r.status);
|
|
3353
|
-
broadcast({ type: 'dispatch-update' });
|
|
3354
|
-
},
|
|
3355
|
-
});
|
|
3361
|
+
const dispatches = createDispatchRegistry();
|
|
3356
3362
|
|
|
3357
3363
|
const dispatchGroups = createGroupStore({
|
|
3358
3364
|
load: () => {
|
|
3359
3365
|
try { return JSON.parse(readFileSync(DISPATCH_GROUPS_FILE, 'utf8')); } catch { return null; }
|
|
3360
3366
|
},
|
|
3361
3367
|
save: (data) => writeJsonAtomic(DISPATCH_GROUPS_FILE, data),
|
|
3362
|
-
isAlive: (id) => terminal.isRunning(id) ||
|
|
3368
|
+
isAlive: (id) => terminal.isRunning(id) || isSessionLive(loadLiveSessions(), id),
|
|
3363
3369
|
pinnedIds: () => new Set(Object.keys(readPins())),
|
|
3364
3370
|
});
|
|
3365
3371
|
|
|
@@ -3371,8 +3377,6 @@ const linkedDocs = createLinkedDocStore({
|
|
|
3371
3377
|
});
|
|
3372
3378
|
|
|
3373
3379
|
// The board places a session from these alone, so it never has to move it later.
|
|
3374
|
-
// `startedBy` lets it follow a starter the user put in a named group, which only the
|
|
3375
|
-
// browser knows; it goes once the dispatch settles. `dispatched` stays for the card's marker.
|
|
3376
3380
|
function withDispatchPlacement(sessions) {
|
|
3377
3381
|
const groups = dispatchGroups.snapshot();
|
|
3378
3382
|
return sessions.map((s) => {
|
|
@@ -3382,56 +3386,35 @@ function withDispatchPlacement(sessions) {
|
|
|
3382
3386
|
return {
|
|
3383
3387
|
...s,
|
|
3384
3388
|
dispatchGroup,
|
|
3385
|
-
|
|
3386
|
-
dispatched: marker ? { parent: marker.parent, outcome: DISPATCH_OUTCOME[marker.status] } : undefined,
|
|
3389
|
+
dispatched: marker ? { parent: marker.parent } : undefined,
|
|
3387
3390
|
};
|
|
3388
3391
|
});
|
|
3389
3392
|
}
|
|
3390
3393
|
|
|
3391
3394
|
// Starting a session needs the terminal token, as the browser does; the CLI reads it from
|
|
3392
|
-
// TERMINAL_TOKEN_FILE. The
|
|
3393
|
-
app.post('/api/dispatch', (req, res) => {
|
|
3395
|
+
// TERMINAL_TOKEN_FILE. The started session never holds it.
|
|
3396
|
+
app.post('/api/dispatch', terminalRoute(async (req, res) => {
|
|
3394
3397
|
if (!terminal.authorized(req.get('x-terminal-token'))) return res.status(401).json({ error: 'invalid terminal token' });
|
|
3395
|
-
const { cwd, spec, name, model, worktree, parent, group,
|
|
3398
|
+
const { cwd, spec, name, model, worktree, parent, group, claudeArgs } = req.body || {};
|
|
3396
3399
|
if (typeof spec !== 'string' || !spec.trim()) return res.status(400).json({ error: 'spec is required' });
|
|
3397
|
-
if (peer != null && !isPeerName(peer)) return res.status(400).json({ error: 'peer must be a peer name as ListAgents prints it' });
|
|
3398
3400
|
if (parent != null && !(typeof parent === 'string' && isUUID(parent))) return res.status(400).json({ error: 'invalid parent' });
|
|
3399
3401
|
if (group != null && !isGroupName(group)) {
|
|
3400
3402
|
return res.status(400).json({ error: `group must be kebab-case, e.g. ${suggestGroupName(group) || 'my-group'}` });
|
|
3401
3403
|
}
|
|
3402
|
-
const
|
|
3403
|
-
|
|
3404
|
-
|
|
3405
|
-
const started = terminal.startNew({ cwd, name, model, worktree, prompt: formatPreamble(r) }, env);
|
|
3406
|
-
if (started.error) {
|
|
3407
|
-
dispatches.discard(r.id);
|
|
3408
|
-
return res.status(started.status).json({ error: started.error });
|
|
3409
|
-
}
|
|
3410
|
-
dispatches.attach(r.id, { session: started.id, cwd: started.cwd });
|
|
3404
|
+
const started = await terminal.startNew({ cwd, name, model, worktree, prompt: spec.trim(), extraArgs: claudeArgs });
|
|
3405
|
+
if (started.error) return res.status(started.status).json({ error: started.error });
|
|
3406
|
+
dispatches.add({ session: started.id, parent, cwd: started.cwd, name, group: group || null, worktree });
|
|
3411
3407
|
dispatched.record(started.id, parent);
|
|
3412
3408
|
// The starter stays where it is: moving it would jump it under the user.
|
|
3413
|
-
if (
|
|
3409
|
+
if (group) dispatchGroups.join(group, [started.id]);
|
|
3414
3410
|
broadcast({ type: 'dispatch-update' });
|
|
3415
|
-
res.status(201).json({
|
|
3416
|
-
});
|
|
3417
|
-
|
|
3418
|
-
app.post('/api/dispatch/:id/done', (req, res) => {
|
|
3419
|
-
const { cap, outcome, summary } = req.body || {};
|
|
3420
|
-
const err = dispatches.settle(req.params.id, cap, outcome, summary);
|
|
3421
|
-
if (err) return res.status(err.status).json({ error: err.error });
|
|
3422
|
-
res.status(204).end();
|
|
3423
|
-
});
|
|
3411
|
+
res.status(201).json({ session: started.id, cwd: started.cwd, group: group || null });
|
|
3412
|
+
}));
|
|
3424
3413
|
|
|
3425
|
-
app.get('/api/dispatch',
|
|
3414
|
+
app.get('/api/dispatch', (req, res) => {
|
|
3426
3415
|
res.setHeader('Cache-Control', 'no-store');
|
|
3427
|
-
const ids = typeof req.query.ids === 'string' && req.query.ids ? req.query.ids.split(',') : null;
|
|
3428
3416
|
const parent = typeof req.query.parent === 'string' && req.query.parent ? req.query.parent : null;
|
|
3429
|
-
|
|
3430
|
-
const out = await dispatches.wait({ ids, parent }, req.query.wait, (stop) => {
|
|
3431
|
-
res.on('close', stop);
|
|
3432
|
-
return () => res.off('close', stop);
|
|
3433
|
-
});
|
|
3434
|
-
if (!res.writableEnded) res.json(out);
|
|
3417
|
+
res.json({ running: dispatches.list({ parent }) });
|
|
3435
3418
|
});
|
|
3436
3419
|
// #endregion
|
|
3437
3420
|
|
|
@@ -3496,7 +3479,6 @@ const {
|
|
|
3496
3479
|
formatTaskMoved,
|
|
3497
3480
|
handleSessionEvents,
|
|
3498
3481
|
hasSessionListener,
|
|
3499
|
-
topicKey,
|
|
3500
3482
|
} = require('./lib/session-events');
|
|
3501
3483
|
app.get('/api/sessions/:sessionId/events', handleSessionEvents);
|
|
3502
3484
|
|
|
@@ -4036,7 +4018,7 @@ app.post('/api/sessions/:sessionId/review', async (req, res) => {
|
|
|
4036
4018
|
terminal.isRunning(sessionId) &&
|
|
4037
4019
|
!isWaitingOnUser(sessionId)
|
|
4038
4020
|
) {
|
|
4039
|
-
if (terminal.paste(sessionId, `Address my review comments on ${src.label}: ${file}`)) delivered = 'terminal';
|
|
4021
|
+
if (await terminal.paste(sessionId, `Address my review comments on ${src.label}: ${file}`)) delivered = 'terminal';
|
|
4040
4022
|
}
|
|
4041
4023
|
res.json({ delivered, file, markdown });
|
|
4042
4024
|
} catch (error) {
|
|
@@ -4405,7 +4387,7 @@ async function prewarmCaches() {
|
|
|
4405
4387
|
}
|
|
4406
4388
|
|
|
4407
4389
|
const onReady = (actualPort) => {
|
|
4408
|
-
|
|
4390
|
+
terminal.setServerUrl(`http://127.0.0.1:${actualPort}`);
|
|
4409
4391
|
// The port is configurable and falls back to a random one when taken, so the postman
|
|
4410
4392
|
// monitor cannot assume it -- publish the live one where it can read it.
|
|
4411
4393
|
writeServerInfo(actualPort);
|
|
@@ -4415,12 +4397,13 @@ async function prewarmCaches() {
|
|
|
4415
4397
|
migrateLegacyApprovalsConfig();
|
|
4416
4398
|
const warning = net.exposureWarning();
|
|
4417
4399
|
if (warning) console.log(warning);
|
|
4418
|
-
|
|
4419
|
-
|
|
4420
|
-
|
|
4421
|
-
|
|
4422
|
-
|
|
4423
|
-
|
|
4400
|
+
terminal.started.then((terminalReason) => {
|
|
4401
|
+
if (terminalReason && terminalReason !== 'disabled') console.log(`Terminal unavailable: ${terminalReason}`);
|
|
4402
|
+
// Under the hub the hub owns the token and hands it to the iframe itself.
|
|
4403
|
+
if (!terminalReason && !process.env.CLAUDE_HUB) {
|
|
4404
|
+
console.log(`Terminal enabled - open http://localhost:${actualPort}/#t=${terminal.token}`);
|
|
4405
|
+
}
|
|
4406
|
+
});
|
|
4424
4407
|
|
|
4425
4408
|
if (process.argv.includes('--open')) {
|
|
4426
4409
|
import('open').then(open => open.default(`http://localhost:${actualPort}`));
|