claude-code-kanban 5.3.0 → 6.0.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 +4 -4
- package/cli.js +30 -108
- package/install.js +43 -123
- package/lib/approvals.js +16 -17
- package/lib/dispatch-groups.js +9 -31
- package/lib/dispatch.js +19 -166
- package/lib/git-branch.js +62 -0
- package/lib/parsers.js +96 -59
- package/lib/plugin-status.js +1 -1
- package/lib/retention.js +5 -17
- package/lib/session-cache.js +1 -1
- package/lib/session-events.js +3 -2
- package/lib/terminal.js +30 -5
- package/package.json +1 -2
- package/plugin/.claude-plugin/marketplace.json +1 -1
- package/plugin/plugins/claude-code-kanban/.claude-plugin/plugin.json +2 -2
- package/plugin/plugins/claude-code-kanban/hooks/activity.ts +266 -0
- package/plugin/plugins/claude-code-kanban/hooks/context.ts +77 -0
- package/plugin/plugins/claude-code-kanban/hooks/hooks.json +1 -93
- package/plugin/plugins/claude-code-kanban/hooks/index.ts +8 -0
- package/plugin/plugins/claude-code-kanban/monitors.json +0 -6
- package/plugin/plugins/claude-code-kanban/scripts/postman.js +6 -11
- 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/activity.test.ts +300 -0
- package/plugin/plugins/claude-code-kanban/tests/context.test.ts +72 -0
- package/plugin/plugins/claude-code-kanban/tsconfig.json +3 -0
- package/public/app.js +257 -74
- package/public/index.html +12 -2
- package/public/project-match.js +6 -1
- package/public/style.css +99 -0
- package/server.js +146 -147
- package/skill-guides/dispatch.md +16 -54
- package/plugin/plugins/claude-code-kanban/scripts/agent-spy.sh +0 -135
- package/plugin/plugins/claude-code-kanban/scripts/approval-gate.sh +0 -208
- package/plugin/plugins/claude-code-kanban/scripts/context-status.sh +0 -18
package/lib/dispatch.js
CHANGED
|
@@ -1,179 +1,32 @@
|
|
|
1
|
-
// Dispatch:
|
|
2
|
-
//
|
|
3
|
-
// restart drops could still settle.
|
|
4
|
-
//
|
|
5
|
-
// The child holds a per-dispatch capability, never the terminal token, so it can
|
|
6
|
-
// report its own outcome and nothing else. A stale or duplicate child is refused
|
|
7
|
-
// because a record settles once.
|
|
1
|
+
// Dispatch: a Claude Code session one session started in cck's terminal. In memory on
|
|
2
|
+
// purpose: an entry lives as long as its PTY, which dies with the server.
|
|
8
3
|
|
|
9
|
-
|
|
10
|
-
const
|
|
11
|
-
const { clampWait, EVENT_PREFIX } = require('./session-events');
|
|
4
|
+
function createDispatchRegistry({ now = Date.now } = {}) {
|
|
5
|
+
const entries = new Map();
|
|
12
6
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
const KEEP_SETTLED_MS = 24 * 60 * 60 * 1000;
|
|
17
|
-
const ID_RE = /^d_[0-9a-f]{12}$/;
|
|
18
|
-
// The peer name lands verbatim in the started session's prompt.
|
|
19
|
-
const PEER_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
20
|
-
|
|
21
|
-
const isPeerName = (v) => typeof v === 'string' && PEER_RE.test(v);
|
|
22
|
-
|
|
23
|
-
// With neither --report nor --peer the started session gets the task alone, like one the user typed.
|
|
24
|
-
function formatPreamble(record, cli = 'claude-code-kanban') {
|
|
25
|
-
if (!record.report && !record.peer) return record.spec;
|
|
26
|
-
const lines = [`[cck dispatch ${record.id}] Another Claude Code session started you through claude-code-kanban to do the task below.`];
|
|
27
|
-
if (record.peer) {
|
|
28
|
-
lines.push(
|
|
29
|
-
`That session is the peer "${record.peer}". Ask it, not the user: when you need a decision, or find something that changes the task, send a short message with the SendMessage tool to "${record.peer}".`,
|
|
30
|
-
'Keep working on what does not depend on the answer. The reply arrives as a new message.',
|
|
31
|
-
);
|
|
32
|
-
}
|
|
33
|
-
if (record.report) {
|
|
34
|
-
lines.push(
|
|
35
|
-
'When the task is done, or you cannot finish it, report exactly once with this command, then stop:',
|
|
36
|
-
`${cli} dispatch done ${record.id} --cap ${record.cap} --outcome succeeded --summary "<what changed, what you found, what remains>"`,
|
|
37
|
-
record.peer
|
|
38
|
-
? 'Use --outcome failed when the task cannot be finished. If the command refuses because the dispatch already ended, send the summary to the peer instead.'
|
|
39
|
-
: 'Use --outcome failed when the task is not done. Do not ask the user questions the other session must answer; report failed with the question instead.',
|
|
40
|
-
);
|
|
41
|
-
}
|
|
42
|
-
lines.push('', 'Task:', record.spec);
|
|
43
|
-
return lines.join('\n');
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
// The line reaches the parent through the postman at hook trust level; the summary is
|
|
47
|
-
// last so no summary text can pose as a further field.
|
|
48
|
-
const DISPATCH_OUTCOME = {
|
|
49
|
-
succeeded: 'reported success',
|
|
50
|
-
failed: 'reported failure',
|
|
51
|
-
exited: 'ended without a report',
|
|
52
|
-
};
|
|
53
|
-
|
|
54
|
-
function formatDispatchLine(r) {
|
|
55
|
-
const outcome = DISPATCH_OUTCOME[r.status] || r.status;
|
|
56
|
-
const head = `${EVENT_PREFIX} Dispatch ${r.id} (session ${r.session}) ${outcome}.`;
|
|
57
|
-
return r.summary ? `${head} Summary: ${r.summary}` : head;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
function publicView(r) {
|
|
61
|
-
const { cap: _cap, ...rest } = r;
|
|
62
|
-
return rest;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
function createDispatchRegistry({ onSettle, now = Date.now } = {}) {
|
|
66
|
-
const records = new Map();
|
|
67
|
-
const waiters = new Set();
|
|
68
|
-
|
|
69
|
-
function prune() {
|
|
70
|
-
const cutoff = now() - KEEP_SETTLED_MS;
|
|
71
|
-
for (const [id, r] of records) if (r.settledAt && r.settledAt < cutoff) records.delete(id);
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
function create({ parent, spec, name, report, peer, group, worktree }) {
|
|
75
|
-
prune();
|
|
76
|
-
const r = {
|
|
77
|
-
id: `d_${crypto.randomBytes(6).toString('hex')}`,
|
|
78
|
-
cap: crypto.randomBytes(16).toString('hex'),
|
|
7
|
+
function add({ session, parent, cwd, name, group, worktree }) {
|
|
8
|
+
entries.set(session, {
|
|
9
|
+
session,
|
|
79
10
|
parent: parent || null,
|
|
80
|
-
|
|
81
|
-
cwd: null,
|
|
11
|
+
cwd,
|
|
82
12
|
name: name || null,
|
|
83
|
-
report: !!report,
|
|
84
|
-
peer: peer || null,
|
|
85
13
|
group: group || null,
|
|
86
14
|
worktree: worktree || null,
|
|
87
|
-
spec,
|
|
88
|
-
status: 'running',
|
|
89
|
-
summary: null,
|
|
90
15
|
startedAt: now(),
|
|
91
|
-
|
|
92
|
-
};
|
|
93
|
-
records.set(r.id, r);
|
|
94
|
-
return r;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
// The preamble carries the id, so the record exists before its session does.
|
|
98
|
-
function attach(id, { session, cwd }) {
|
|
99
|
-
const r = records.get(id);
|
|
100
|
-
if (r) Object.assign(r, { session, cwd });
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
function discard(id) {
|
|
104
|
-
records.delete(id);
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
function finish(r, status, summary) {
|
|
108
|
-
r.status = status;
|
|
109
|
-
r.summary = summary;
|
|
110
|
-
r.settledAt = now();
|
|
111
|
-
onSettle?.(r);
|
|
112
|
-
for (const wake of [...waiters]) wake();
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
// Returns null when settled, or {status, error} naming the refusal.
|
|
116
|
-
function settle(id, cap, outcome, summary) {
|
|
117
|
-
const r = typeof id === 'string' && ID_RE.test(id) ? records.get(id) : null;
|
|
118
|
-
if (!r) return { status: 404, error: 'no such dispatch' };
|
|
119
|
-
if (!tokenMatches(r.cap, cap)) return { status: 403, error: 'wrong capability' };
|
|
120
|
-
if (r.status !== 'running') return { status: 409, error: `already ${r.status}` };
|
|
121
|
-
if (!OUTCOMES.has(outcome)) return { status: 400, error: 'outcome must be succeeded or failed' };
|
|
122
|
-
// biome-ignore lint/suspicious/noControlCharactersInRegex: strips control characters on purpose
|
|
123
|
-
const text = typeof summary === 'string' ? summary.replace(/[\x00-\x1f\x7f]/g, ' ').trim().slice(0, MAX_SUMMARY) : '';
|
|
124
|
-
finish(r, outcome, text || null);
|
|
125
|
-
return null;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
// The PTY ended with no report: the parent must not wait on it forever.
|
|
129
|
-
function sessionExited(sessionId) {
|
|
130
|
-
for (const r of records.values()) if (r.session === sessionId && r.status === 'running') finish(r, 'exited', null);
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
function select({ ids, parent } = {}) {
|
|
134
|
-
prune();
|
|
135
|
-
let out = [...records.values()];
|
|
136
|
-
if (ids?.length) out = out.filter((r) => ids.includes(r.id));
|
|
137
|
-
else if (parent) out = out.filter((r) => r.parent === parent);
|
|
138
|
-
return out;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
function list(filter) {
|
|
142
|
-
return select(filter).map(publicView);
|
|
16
|
+
});
|
|
143
17
|
}
|
|
144
18
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
function wait(filter, waitSec, onClose) {
|
|
149
|
-
const sec = clampWait(waitSec);
|
|
150
|
-
const snapshot = () => {
|
|
151
|
-
const rows = select(filter);
|
|
152
|
-
return {
|
|
153
|
-
settled: rows.filter((r) => r.status !== 'running').map(publicView),
|
|
154
|
-
running: rows.filter((r) => r.status === 'running').map(publicView),
|
|
155
|
-
};
|
|
156
|
-
};
|
|
157
|
-
const first = snapshot();
|
|
158
|
-
if (first.settled.length || !first.running.length || !sec) return Promise.resolve({ ...first, timeout: false });
|
|
159
|
-
return new Promise((resolve) => {
|
|
160
|
-
let unsubscribe;
|
|
161
|
-
const done = (timeout) => {
|
|
162
|
-
if (!waiters.delete(wake)) return;
|
|
163
|
-
clearTimeout(timer);
|
|
164
|
-
unsubscribe?.();
|
|
165
|
-
resolve({ ...snapshot(), timeout });
|
|
166
|
-
};
|
|
167
|
-
const wake = () => {
|
|
168
|
-
if (select(filter).some((r) => r.status !== 'running')) done(false);
|
|
169
|
-
};
|
|
170
|
-
const timer = setTimeout(() => done(true), sec * 1000);
|
|
171
|
-
waiters.add(wake);
|
|
172
|
-
unsubscribe = onClose?.(() => done(true));
|
|
173
|
-
});
|
|
19
|
+
function list({ parent } = {}) {
|
|
20
|
+
const out = [...entries.values()];
|
|
21
|
+
return parent ? out.filter((e) => e.parent === parent) : out;
|
|
174
22
|
}
|
|
175
23
|
|
|
176
|
-
return {
|
|
24
|
+
return {
|
|
25
|
+
add,
|
|
26
|
+
list,
|
|
27
|
+
has: (session) => entries.has(session),
|
|
28
|
+
remove: (session) => entries.delete(session),
|
|
29
|
+
};
|
|
177
30
|
}
|
|
178
31
|
|
|
179
|
-
module.exports = { createDispatchRegistry
|
|
32
|
+
module.exports = { createDispatchRegistry };
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Read from HEAD rather than by spawning git: a sync spawn on the session-list path blocks the
|
|
2
|
+
// event loop for up to half a second per cwd on Windows, and terminal input waits behind it.
|
|
3
|
+
|
|
4
|
+
const fs = require('node:fs');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
|
|
7
|
+
const REF_RE = /^ref:\s*refs\/heads\/(.+)$/;
|
|
8
|
+
const GITDIR_RE = /^gitdir:\s*(.+)$/;
|
|
9
|
+
|
|
10
|
+
// `.git` is a directory in an ordinary checkout and a `gitdir:` pointer file in a linked
|
|
11
|
+
// worktree or a submodule.
|
|
12
|
+
function headFile(dir, readFile) {
|
|
13
|
+
const dotGit = path.join(dir, '.git');
|
|
14
|
+
let text;
|
|
15
|
+
try {
|
|
16
|
+
text = readFile(dotGit);
|
|
17
|
+
} catch (e) {
|
|
18
|
+
if (e.code === 'EISDIR') return path.join(dotGit, 'HEAD');
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
const m = GITDIR_RE.exec(text.trim());
|
|
22
|
+
return m ? path.join(path.resolve(dir, m[1]), 'HEAD') : null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @param {string} cwd
|
|
27
|
+
* @param {(file: string) => string} [readFile]
|
|
28
|
+
* @returns {string|null} null outside a repo and on a detached HEAD
|
|
29
|
+
*/
|
|
30
|
+
function readGitBranch(cwd, readFile = (f) => fs.readFileSync(f, 'utf8')) {
|
|
31
|
+
for (let dir = path.resolve(cwd); ; ) {
|
|
32
|
+
const head = headFile(dir, readFile);
|
|
33
|
+
if (head) {
|
|
34
|
+
try {
|
|
35
|
+
const m = REF_RE.exec(readFile(head).trim());
|
|
36
|
+
return m ? m[1] : null;
|
|
37
|
+
} catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const up = path.dirname(dir);
|
|
42
|
+
if (up === dir) return null;
|
|
43
|
+
dir = up;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Reads HEAD only where the transcript's `gitBranch` is wrong: cwd moved away from the launch
|
|
49
|
+
* project, or the project is a worktree (a `claude -w` session records the main checkout's
|
|
50
|
+
* branch on every line).
|
|
51
|
+
* @param {{cwd?: string, project?: string, gitBranch?: string}} meta
|
|
52
|
+
* @param {boolean} isWorktree whether `meta.project` is a linked worktree
|
|
53
|
+
* @param {(cwd: string) => string|null} branchAt
|
|
54
|
+
*/
|
|
55
|
+
function sessionGitBranch(meta, isWorktree, branchAt) {
|
|
56
|
+
if (meta.cwd && meta.project && (meta.cwd !== meta.project || isWorktree)) {
|
|
57
|
+
return branchAt(meta.cwd) || meta.gitBranch || null;
|
|
58
|
+
}
|
|
59
|
+
return meta.gitBranch || null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
module.exports = { readGitBranch, sessionGitBranch };
|
package/lib/parsers.js
CHANGED
|
@@ -4,6 +4,21 @@ const path = require('node:path');
|
|
|
4
4
|
const os = require('node:os');
|
|
5
5
|
const { StringDecoder } = require('node:string_decoder');
|
|
6
6
|
|
|
7
|
+
// Full-transcript scans stream the file instead of reading it whole: a transcript can
|
|
8
|
+
// reach hundreds of megabytes, and the event loop it would block also carries the
|
|
9
|
+
// embedded terminal's keystrokes. Yields the same lines as content.split('\n').
|
|
10
|
+
async function* readLines(file) {
|
|
11
|
+
const decoder = new StringDecoder('utf8');
|
|
12
|
+
let rest = '';
|
|
13
|
+
for await (const chunk of fs.createReadStream(file, { highWaterMark: 1 << 20 })) {
|
|
14
|
+
const parts = decoder.write(chunk).split('\n');
|
|
15
|
+
parts[0] = rest + parts[0];
|
|
16
|
+
rest = parts.pop();
|
|
17
|
+
yield* parts;
|
|
18
|
+
}
|
|
19
|
+
yield rest + decoder.end();
|
|
20
|
+
}
|
|
21
|
+
|
|
7
22
|
function parseTask(raw) {
|
|
8
23
|
const task = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
|
9
24
|
return {
|
|
@@ -347,12 +362,24 @@ function scrapeScalarFromBlob(blob, re) {
|
|
|
347
362
|
const sessionInfoCache = new Map();
|
|
348
363
|
const SESSION_INFO_CACHE_MAX = 2000;
|
|
349
364
|
|
|
365
|
+
// A prompt line carries the mode it was sent in. After a Shift+Tab, Claude Code writes an
|
|
366
|
+
// auto_mode / auto_mode_exit attachment with the next tool result, and later rewrites a
|
|
367
|
+
// permission-mode line with the live mode, often tens of seconds after.
|
|
368
|
+
const modeOf = (data) => {
|
|
369
|
+
if (data.type === 'attachment') {
|
|
370
|
+
const t = data.attachment?.type;
|
|
371
|
+
return t === 'auto_mode' ? 'auto' : t === 'auto_mode_exit' ? 'default' : null;
|
|
372
|
+
}
|
|
373
|
+
return (data.type === 'user' || data.type === 'permission-mode') && data.permissionMode || null;
|
|
374
|
+
};
|
|
375
|
+
|
|
350
376
|
// gitBranch in the JSONL is pinned to the launch-time repo by Claude Code
|
|
351
377
|
// and goes stale once cwd shifts (Bash `cd`, submodule). Callers needing the
|
|
352
378
|
// live branch must resolve it from cwd separately. Cache is reset on inode
|
|
353
379
|
// change or truncation (size < scannedUpTo).
|
|
380
|
+
|
|
354
381
|
function readSessionInfoFromJsonl(jsonlPath) {
|
|
355
|
-
const result = { slug: null, projectPath: null, cwd: null, gitBranch: null, customTitle: null, logicalParentUuid: null, compactBoundaryUuid: null };
|
|
382
|
+
const result = { slug: null, projectPath: null, cwd: null, gitBranch: null, customTitle: null, logicalParentUuid: null, compactBoundaryUuid: null, permissionMode: null };
|
|
356
383
|
let stat;
|
|
357
384
|
let fd;
|
|
358
385
|
try {
|
|
@@ -372,6 +399,7 @@ function readSessionInfoFromJsonl(jsonlPath) {
|
|
|
372
399
|
gitBranch: cached.gitBranch,
|
|
373
400
|
logicalParentUuid: cached.logicalParentUuid || null,
|
|
374
401
|
compactBoundaryUuid: cached.compactBoundaryUuid || null,
|
|
402
|
+
permissionMode: cached.permissionMode || null,
|
|
375
403
|
customTitle: readCustomTitle(jsonlPath, stat)
|
|
376
404
|
};
|
|
377
405
|
}
|
|
@@ -383,6 +411,7 @@ function readSessionInfoFromJsonl(jsonlPath) {
|
|
|
383
411
|
result.gitBranch = cached.gitBranch;
|
|
384
412
|
result.logicalParentUuid = cached.logicalParentUuid || null;
|
|
385
413
|
result.compactBoundaryUuid = cached.compactBoundaryUuid || null;
|
|
414
|
+
result.permissionMode = cached.permissionMode || null;
|
|
386
415
|
}
|
|
387
416
|
|
|
388
417
|
let lastCwdSeen = result.cwd;
|
|
@@ -395,6 +424,8 @@ function readSessionInfoFromJsonl(jsonlPath) {
|
|
|
395
424
|
lastCwdSeen = data.cwd;
|
|
396
425
|
}
|
|
397
426
|
if (data.gitBranch) result.gitBranch = data.gitBranch;
|
|
427
|
+
const mode = modeOf(data);
|
|
428
|
+
if (mode) result.permissionMode = mode;
|
|
398
429
|
if (data.subtype === 'compact_boundary' && data.logicalParentUuid && !result.logicalParentUuid) {
|
|
399
430
|
result.logicalParentUuid = data.logicalParentUuid;
|
|
400
431
|
// The boundary record's own uuid: a fork copies it verbatim from the
|
|
@@ -466,6 +497,7 @@ function readSessionInfoFromJsonl(jsonlPath) {
|
|
|
466
497
|
const tn = fs.readSync(fd, tailBuf, 0, TAIL_SIZE, tailStart);
|
|
467
498
|
const lines = tailBuf.toString('utf8', 0, tn).split('\n');
|
|
468
499
|
let latestTailCwd = null;
|
|
500
|
+
let latestTailMode = null;
|
|
469
501
|
for (let i = lines.length - 1; i >= 0; i--) {
|
|
470
502
|
try {
|
|
471
503
|
const data = JSON.parse(lines[i]);
|
|
@@ -473,10 +505,12 @@ function readSessionInfoFromJsonl(jsonlPath) {
|
|
|
473
505
|
if (!result.projectPath && data.cwd) result.projectPath = data.cwd;
|
|
474
506
|
if (!result.gitBranch && data.gitBranch) result.gitBranch = data.gitBranch;
|
|
475
507
|
if (!latestTailCwd && data.cwd) latestTailCwd = data.cwd;
|
|
476
|
-
if (
|
|
508
|
+
if (!latestTailMode) latestTailMode = modeOf(data);
|
|
509
|
+
if (latestTailCwd && latestTailMode && result.slug && result.projectPath && result.gitBranch) break;
|
|
477
510
|
} catch {}
|
|
478
511
|
}
|
|
479
512
|
if (latestTailCwd) lastCwdSeen = latestTailCwd;
|
|
513
|
+
if (latestTailMode) result.permissionMode = latestTailMode;
|
|
480
514
|
}
|
|
481
515
|
scannedUpTo = stat.size;
|
|
482
516
|
}
|
|
@@ -498,7 +532,8 @@ function readSessionInfoFromJsonl(jsonlPath) {
|
|
|
498
532
|
gitBranch: result.gitBranch,
|
|
499
533
|
cwd: result.cwd,
|
|
500
534
|
logicalParentUuid: result.logicalParentUuid,
|
|
501
|
-
compactBoundaryUuid: result.compactBoundaryUuid
|
|
535
|
+
compactBoundaryUuid: result.compactBoundaryUuid,
|
|
536
|
+
permissionMode: result.permissionMode
|
|
502
537
|
});
|
|
503
538
|
if (sessionInfoCache.size > SESSION_INFO_CACHE_MAX) {
|
|
504
539
|
const firstKey = sessionInfoCache.keys().next().value;
|
|
@@ -1064,12 +1099,10 @@ function readRecentMessages(jsonlPath, limit = 10) {
|
|
|
1064
1099
|
}
|
|
1065
1100
|
}
|
|
1066
1101
|
|
|
1067
|
-
function readFullToolResult(jsonlPath, toolUseId) {
|
|
1102
|
+
async function readFullToolResult(jsonlPath, toolUseId) {
|
|
1068
1103
|
if (!toolUseId || !jsonlPath || !existsSync(jsonlPath)) return null;
|
|
1069
1104
|
try {
|
|
1070
|
-
const
|
|
1071
|
-
const lines = content.split('\n');
|
|
1072
|
-
for (const line of lines) {
|
|
1105
|
+
for await (const line of readLines(jsonlPath)) {
|
|
1073
1106
|
if (!line || line.indexOf(toolUseId) === -1) continue;
|
|
1074
1107
|
try {
|
|
1075
1108
|
const obj = JSON.parse(line);
|
|
@@ -1092,14 +1125,12 @@ function readFullToolResult(jsonlPath, toolUseId) {
|
|
|
1092
1125
|
return null;
|
|
1093
1126
|
}
|
|
1094
1127
|
|
|
1095
|
-
function readUserImage(jsonlPath, msgUuid, blockIndex) {
|
|
1128
|
+
async function readUserImage(jsonlPath, msgUuid, blockIndex) {
|
|
1096
1129
|
if (!msgUuid || !jsonlPath) return null;
|
|
1097
1130
|
const idx = Number(blockIndex);
|
|
1098
1131
|
if (!Number.isInteger(idx) || idx < 0) return null;
|
|
1099
1132
|
try {
|
|
1100
|
-
const
|
|
1101
|
-
const lines = content.split('\n');
|
|
1102
|
-
for (const line of lines) {
|
|
1133
|
+
for await (const line of readLines(jsonlPath)) {
|
|
1103
1134
|
if (!line || line.indexOf(msgUuid) === -1) continue;
|
|
1104
1135
|
try {
|
|
1105
1136
|
const obj = JSON.parse(line);
|
|
@@ -1119,14 +1150,12 @@ function readUserImage(jsonlPath, msgUuid, blockIndex) {
|
|
|
1119
1150
|
|
|
1120
1151
|
// Read the Nth base64 image embedded in a tool_result block (e.g. the output of
|
|
1121
1152
|
// a Read on an image file). Scans for the tool_result matching toolUseId.
|
|
1122
|
-
function readToolResultImage(jsonlPath, toolUseId, n) {
|
|
1153
|
+
async function readToolResultImage(jsonlPath, toolUseId, n) {
|
|
1123
1154
|
if (!toolUseId || !jsonlPath) return null;
|
|
1124
1155
|
const idx = Number(n);
|
|
1125
1156
|
if (!Number.isInteger(idx) || idx < 0) return null;
|
|
1126
1157
|
try {
|
|
1127
|
-
const
|
|
1128
|
-
const lines = content.split('\n');
|
|
1129
|
-
for (const line of lines) {
|
|
1158
|
+
for await (const line of readLines(jsonlPath)) {
|
|
1130
1159
|
if (!line || line.indexOf(toolUseId) === -1) continue;
|
|
1131
1160
|
let obj;
|
|
1132
1161
|
try { obj = JSON.parse(line); } catch (_) { continue; }
|
|
@@ -1196,14 +1225,13 @@ function readMessagesPage(jsonlPath, limit = 10, beforeTimestamp = null) {
|
|
|
1196
1225
|
};
|
|
1197
1226
|
}
|
|
1198
1227
|
|
|
1199
|
-
function buildSessionDigest(jsonlPath) {
|
|
1228
|
+
async function buildSessionDigest(jsonlPath) {
|
|
1200
1229
|
const map = {};
|
|
1201
1230
|
const terminated = new Map();
|
|
1202
1231
|
const rejectedToolUseIds = new Set();
|
|
1203
1232
|
const promptByToolUseId = {};
|
|
1204
1233
|
const killedAgentIds = new Set();
|
|
1205
1234
|
try {
|
|
1206
|
-
const content = readFileSync(jsonlPath, 'utf8');
|
|
1207
1235
|
const re = /"type":"agent_progress"[^}]*"agentId":"([^"]+)"/;
|
|
1208
1236
|
const parentRe = /"parentToolUseID":"([^"]+)"/;
|
|
1209
1237
|
const promptRe = /"prompt":"((?:[^"\\]|\\.)*)"/;
|
|
@@ -1215,7 +1243,7 @@ function buildSessionDigest(jsonlPath) {
|
|
|
1215
1243
|
const nameByToolUseId = {};
|
|
1216
1244
|
const descByToolUseId = {};
|
|
1217
1245
|
const usageByToolUseId = {};
|
|
1218
|
-
for (const line of
|
|
1246
|
+
for await (const line of readLines(jsonlPath)) {
|
|
1219
1247
|
// Terminated-teammate detection: check first since cheap substring guards
|
|
1220
1248
|
if (line.includes('teammate-message') &&
|
|
1221
1249
|
(line.includes('teammate_terminated') || line.includes('shutdown_response'))) {
|
|
@@ -1346,8 +1374,8 @@ function buildSessionDigest(jsonlPath) {
|
|
|
1346
1374
|
return { progressMap: map, terminated, rejectedAgentIds, rejectedPrompts, killedAgentIds };
|
|
1347
1375
|
}
|
|
1348
1376
|
|
|
1349
|
-
function buildAgentProgressMap(jsonlPath) {
|
|
1350
|
-
return buildSessionDigest(jsonlPath).progressMap;
|
|
1377
|
+
async function buildAgentProgressMap(jsonlPath) {
|
|
1378
|
+
return (await buildSessionDigest(jsonlPath)).progressMap;
|
|
1351
1379
|
}
|
|
1352
1380
|
|
|
1353
1381
|
const ARTIFACT_URL_RE = /https:\/\/claude\.ai\/\S*artifact\/[A-Za-z0-9_-]+/;
|
|
@@ -1365,7 +1393,7 @@ function toolResultText(content) {
|
|
|
1365
1393
|
// only when its tool_use_id belongs to a publish call — an artifact URL quoted
|
|
1366
1394
|
// back by a grep or a file read would otherwise register another session's page as
|
|
1367
1395
|
// this session's own, and a `read` of someone else's artifact is not a publish.
|
|
1368
|
-
function readArtifactLinks(jsonlPath) {
|
|
1396
|
+
async function readArtifactLinks(jsonlPath) {
|
|
1369
1397
|
const byUrl = new Map();
|
|
1370
1398
|
const publishedFileByToolUseId = new Map();
|
|
1371
1399
|
const upsert = (url, ts) => {
|
|
@@ -1378,8 +1406,7 @@ function readArtifactLinks(jsonlPath) {
|
|
|
1378
1406
|
return a;
|
|
1379
1407
|
};
|
|
1380
1408
|
try {
|
|
1381
|
-
const
|
|
1382
|
-
for (const line of content.split('\n')) {
|
|
1409
|
+
for await (const line of readLines(jsonlPath)) {
|
|
1383
1410
|
if (!line.includes('"frame-link"') && !line.includes('"name":"Artifact"') && !line.includes('claude.ai'))
|
|
1384
1411
|
continue;
|
|
1385
1412
|
let obj;
|
|
@@ -1426,11 +1453,10 @@ const SCRATCH_NAME_RE = /\bscratch\s+new\s+(?:"([^"]+)"|'([^']+)'|(?!-)(\S+))/;
|
|
|
1426
1453
|
// the manifest is the 4th of ~13 printed lines, so any `| tail -N` cuts it off. `name`
|
|
1427
1454
|
// stands in because it is what the manifest stores as its own `name`; the path is not
|
|
1428
1455
|
// recovered from the command, which would mean tracking the `cd` and a relative `--dir`.
|
|
1429
|
-
function readScratchpadCreations(jsonlPath) {
|
|
1456
|
+
async function readScratchpadCreations(jsonlPath) {
|
|
1430
1457
|
const calls = new Map();
|
|
1431
1458
|
try {
|
|
1432
|
-
const
|
|
1433
|
-
for (const line of content.split('\n')) {
|
|
1459
|
+
for await (const line of readLines(jsonlPath)) {
|
|
1434
1460
|
// Substring reject before JSON.parse: transcripts reach tens of megabytes and
|
|
1435
1461
|
// only a handful of lines can ever match.
|
|
1436
1462
|
const isCall = line.includes('scratch new');
|
|
@@ -1456,13 +1482,12 @@ function readScratchpadCreations(jsonlPath) {
|
|
|
1456
1482
|
return [...calls.values()];
|
|
1457
1483
|
}
|
|
1458
1484
|
|
|
1459
|
-
function readCompactSummaries(jsonlPath) {
|
|
1485
|
+
async function readCompactSummaries(jsonlPath) {
|
|
1460
1486
|
const results = [];
|
|
1461
1487
|
// Inline format: newer Claude Code stores the summary directly in the parent
|
|
1462
1488
|
// session JSONL as a user message with isCompactSummary: true (no subagent file).
|
|
1463
1489
|
try {
|
|
1464
|
-
const
|
|
1465
|
-
for (const line of content.split('\n')) {
|
|
1490
|
+
for await (const line of readLines(jsonlPath)) {
|
|
1466
1491
|
if (!line.trim() || !line.includes('isCompactSummary')) continue;
|
|
1467
1492
|
try {
|
|
1468
1493
|
const obj = JSON.parse(line);
|
|
@@ -1515,8 +1540,8 @@ function readCompactSummaries(jsonlPath) {
|
|
|
1515
1540
|
return results.sort((a, b) => (a.timestamp || '').localeCompare(b.timestamp || ''));
|
|
1516
1541
|
}
|
|
1517
1542
|
|
|
1518
|
-
function findTerminatedTeammates(jsonlPath) {
|
|
1519
|
-
return buildSessionDigest(jsonlPath).terminated;
|
|
1543
|
+
async function findTerminatedTeammates(jsonlPath) {
|
|
1544
|
+
return (await buildSessionDigest(jsonlPath)).terminated;
|
|
1520
1545
|
}
|
|
1521
1546
|
|
|
1522
1547
|
function extractPromptFromTranscript(jsonlPath) {
|
|
@@ -1585,28 +1610,28 @@ function extractModelFromTranscript(jsonlPath) {
|
|
|
1585
1610
|
// output tokens, and first/last timestamps (for duration). COLD PATH ONLY —
|
|
1586
1611
|
// used by the workflow run view on demand, never from the session-scan hot
|
|
1587
1612
|
// path (a full read per agent would blow the /api/sessions budget).
|
|
1588
|
-
function extractTranscriptStats(jsonlPath) {
|
|
1589
|
-
let content;
|
|
1590
|
-
try { content = readFileSync(jsonlPath, 'utf8'); } catch (_) { return null; }
|
|
1613
|
+
async function extractTranscriptStats(jsonlPath) {
|
|
1591
1614
|
let model = null;
|
|
1592
1615
|
let outputTokens = 0;
|
|
1593
1616
|
let firstTs = null;
|
|
1594
1617
|
let lastTs = null;
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
if (
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
if (
|
|
1606
|
-
|
|
1607
|
-
|
|
1618
|
+
try {
|
|
1619
|
+
for await (const line of readLines(jsonlPath)) {
|
|
1620
|
+
if (!line.trim()) continue;
|
|
1621
|
+
let obj;
|
|
1622
|
+
try { obj = JSON.parse(line); } catch (_) { continue; }
|
|
1623
|
+
if (obj.timestamp) {
|
|
1624
|
+
if (!firstTs) firstTs = obj.timestamp;
|
|
1625
|
+
lastTs = obj.timestamp;
|
|
1626
|
+
}
|
|
1627
|
+
const msg = obj.message;
|
|
1628
|
+
if (msg && typeof msg === 'object') {
|
|
1629
|
+
if (!model && msg.model) model = msg.model;
|
|
1630
|
+
const out = msg.usage?.output_tokens;
|
|
1631
|
+
if (typeof out === 'number') outputTokens += out;
|
|
1632
|
+
}
|
|
1608
1633
|
}
|
|
1609
|
-
}
|
|
1634
|
+
} catch (_) { return null; }
|
|
1610
1635
|
return { model, outputTokens, firstTs, lastTs };
|
|
1611
1636
|
}
|
|
1612
1637
|
|
|
@@ -1625,7 +1650,9 @@ function formatStructuredResult(input) {
|
|
|
1625
1650
|
// Some subagents end their run on a tool call and never emit a text response, so
|
|
1626
1651
|
// their lastMessage is empty: workflow subagents given a schema call StructuredOutput,
|
|
1627
1652
|
// background subagents report through SubagentHandback. Tail-read the transcript and
|
|
1628
|
-
// take the LAST such tool_use as the agent's result.
|
|
1653
|
+
// take the LAST such tool_use as the agent's result. Without one, take the last
|
|
1654
|
+
// assistant text: the plugin's mod records no lastMessage, because Claude Code's
|
|
1655
|
+
// turn.complete carries an empty answer for a subagent. Returns null when neither exists.
|
|
1629
1656
|
const RESULT_TOOLS = {
|
|
1630
1657
|
StructuredOutput: formatStructuredResult,
|
|
1631
1658
|
SubagentHandback: (input) => (typeof input.message === 'string' && input.message) || null,
|
|
@@ -1649,16 +1676,25 @@ function extractAgentResultFromTranscript(jsonlPath) {
|
|
|
1649
1676
|
const text = buf.toString('utf8');
|
|
1650
1677
|
// Drop the leading partial line unless we read from the very start.
|
|
1651
1678
|
const clean = start > 0 ? text.slice(text.indexOf('\n') + 1) : text;
|
|
1652
|
-
if (!RESULT_TOOL_TEXT.some((t) => clean.includes(t))) return null;
|
|
1653
1679
|
const lines = clean.split('\n');
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1680
|
+
const lastAssistant = (needle, pick) => {
|
|
1681
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
1682
|
+
if (!needle(lines[i])) continue;
|
|
1683
|
+
let obj;
|
|
1684
|
+
try { obj = JSON.parse(lines[i]); } catch (_) { continue; }
|
|
1685
|
+
const content = obj?.message?.content;
|
|
1686
|
+
const found = obj?.type === 'assistant' && Array.isArray(content) && pick(content);
|
|
1687
|
+
if (found) return found;
|
|
1688
|
+
}
|
|
1689
|
+
return null;
|
|
1690
|
+
};
|
|
1691
|
+
return lastAssistant(
|
|
1692
|
+
(line) => RESULT_TOOL_TEXT.some((t) => line.includes(t)),
|
|
1693
|
+
(content) => content.map(resultFromBlock).findLast(Boolean),
|
|
1694
|
+
) ?? lastAssistant(
|
|
1695
|
+
(line) => line.includes('"type":"assistant"'),
|
|
1696
|
+
(content) => toolResultText(content).trim(),
|
|
1697
|
+
);
|
|
1662
1698
|
} catch (_) {
|
|
1663
1699
|
} finally {
|
|
1664
1700
|
closeSync(fd);
|
|
@@ -1831,5 +1867,6 @@ module.exports = {
|
|
|
1831
1867
|
extractPromptFromTranscript,
|
|
1832
1868
|
extractModelFromTranscript,
|
|
1833
1869
|
extractAgentResultFromTranscript,
|
|
1834
|
-
extractTranscriptStats
|
|
1870
|
+
extractTranscriptStats,
|
|
1871
|
+
readLines
|
|
1835
1872
|
};
|
package/lib/plugin-status.js
CHANGED
|
@@ -31,7 +31,7 @@ function pluginStatus(claudeDir, bundledManifest = BUNDLED_MANIFEST) {
|
|
|
31
31
|
|
|
32
32
|
// --dir rather than CLAUDE_CONFIG_DIR=…, so the same line runs in bash, PowerShell and cmd.
|
|
33
33
|
function installCommand(claudeDir) {
|
|
34
|
-
const base = 'claude-code-kanban --install --
|
|
34
|
+
const base = 'claude-code-kanban --install --yes';
|
|
35
35
|
return isDefaultClaudeDir(claudeDir) ? base : `${base} --dir "${claudeDir}"`;
|
|
36
36
|
}
|
|
37
37
|
|