great-cto 2.91.0 → 2.93.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/board/.claude-plugin/plugin.json +12 -2
- package/board/packages/board/lib/beads.mjs +8 -1
- package/board/packages/board/lib/fleet.mjs +15 -2
- package/board/packages/board/lib/routes.mjs +48 -0
- package/board/packages/board/lib/transcripts.mjs +198 -0
- package/board/packages/board/public/index.html +118 -1
- package/dist/main.js +40 -19
- package/dist/self-upgrade.js +54 -0
- package/dist/update-check.js +29 -3
- package/package.json +1 -1
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "great_cto",
|
|
3
3
|
"id": "great_cto",
|
|
4
4
|
"description": "Engineering process for solo founders and teams up to 50 engineers. Agents do architecture, code review, QA, and security. You make two decisions per feature.",
|
|
5
|
-
"version": "2.
|
|
5
|
+
"version": "2.93.0",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Great CTO",
|
|
8
8
|
"url": "https://github.com/avelikiy/great_cto"
|
|
@@ -56,7 +56,7 @@
|
|
|
56
56
|
"hooks": [
|
|
57
57
|
{
|
|
58
58
|
"type": "command",
|
|
59
|
-
"command": "PLUGIN_DIR=$(ls -d ~/.claude/plugins/cache/local/great_cto/*/ 2>/dev/null | sort -V | tail -1 | sed 's|/$||'); if [ -n \"$PLUGIN_DIR\" ] && [ -f \"${PLUGIN_DIR}/shared/orchestrator.toml\" ]; then mkdir -p shared; cp \"${PLUGIN_DIR}/shared/orchestrator.toml\" shared/orchestrator.toml 2>/dev/null || true; cp \"${PLUGIN_DIR}/shared/pipeline.toml\" shared/pipeline.toml 2>/dev/null || true; fi",
|
|
59
|
+
"command": "PLUGIN_DIR=$(ls -d ~/.claude/plugins/cache/local/great_cto/*/ 2>/dev/null | sort -V | tail -1 | sed 's|/$||'); if [ -f .claude-plugin/plugin.json ] && grep -q '\"name\": *\"great_cto\"' .claude-plugin/plugin.json 2>/dev/null; then : ; elif [ -n \"$PLUGIN_DIR\" ] && [ -f \"${PLUGIN_DIR}/shared/orchestrator.toml\" ]; then mkdir -p shared; cp \"${PLUGIN_DIR}/shared/orchestrator.toml\" shared/orchestrator.toml 2>/dev/null || true; cp \"${PLUGIN_DIR}/shared/pipeline.toml\" shared/pipeline.toml 2>/dev/null || true; fi",
|
|
60
60
|
"timeout": 5,
|
|
61
61
|
"statusMessage": "Refreshing orchestrator contract..."
|
|
62
62
|
},
|
|
@@ -262,6 +262,16 @@
|
|
|
262
262
|
}
|
|
263
263
|
]
|
|
264
264
|
}
|
|
265
|
+
],
|
|
266
|
+
"Stop": [
|
|
267
|
+
{
|
|
268
|
+
"hooks": [
|
|
269
|
+
{
|
|
270
|
+
"type": "command",
|
|
271
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hooks/pipeline-stall-guard.mjs\" 2>/dev/null || true"
|
|
272
|
+
}
|
|
273
|
+
]
|
|
274
|
+
}
|
|
265
275
|
]
|
|
266
276
|
}
|
|
267
277
|
}
|
|
@@ -132,7 +132,14 @@ function tasksMdStatus(rawStatus, id, title) {
|
|
|
132
132
|
else if (s === 'in_progress' || s === 'in-progress' || s === 'wip' || s === 'doing') status = 'in_progress';
|
|
133
133
|
else if (s === 'blocked') status = 'blocked';
|
|
134
134
|
else status = isGate ? 'gate' : 'backlog';
|
|
135
|
-
|
|
135
|
+
// raw_status is what the inbox filters on, because mapStatus() rewrites any
|
|
136
|
+
// gate-labelled task to 'gate' and filtering on the mapped value would leave
|
|
137
|
+
// closed gates in the inbox forever. That fix covered `done`; it did not cover
|
|
138
|
+
// `blocked`, so a gate marked blocked in tasks.md reported raw_status 'open'
|
|
139
|
+
// and never left the inbox — for exactly the bd-less projects this parser
|
|
140
|
+
// exists to serve.
|
|
141
|
+
const raw = status === 'done' ? 'closed' : status === 'blocked' ? 'blocked' : 'open';
|
|
142
|
+
return { status, raw_status: raw, isGate };
|
|
136
143
|
}
|
|
137
144
|
|
|
138
145
|
// Build the full task record both parsers emit — one shape so getTasks can
|
|
@@ -17,13 +17,26 @@ import { readVerdicts } from './verdicts.mjs';
|
|
|
17
17
|
// frontmatter, to avoid pipeline-wide migration). Founder may flip to
|
|
18
18
|
// frontmatter-driven later — encapsulated in this function only.
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
// The agents directory, overridable for tests.
|
|
21
|
+
//
|
|
22
|
+
// getAgentsFleet and getAgentProfile read the real ~/.claude/agents, which left
|
|
23
|
+
// them on the integration path and untested — and the untested part is where the
|
|
24
|
+
// classification bug lived that put 40 of 69 agents in one bucket. An env
|
|
25
|
+
// override is enough to exercise them against a fixture; mocking `fs` would test
|
|
26
|
+
// the mock.
|
|
27
|
+
const AGENTS_DIR = process.env.GREAT_CTO_AGENTS_DIR
|
|
28
|
+
|| path.join(os.homedir(), '.claude', 'agents');
|
|
21
29
|
|
|
22
30
|
function deriveDomain(slug) {
|
|
23
31
|
const s = slug.toLowerCase();
|
|
24
32
|
if (/architect|adr|design|prompt/.test(s)) return 'arch';
|
|
25
33
|
if (/security|sec-|threat|pci|gdpr|hipaa/.test(s)) return 'security';
|
|
26
|
-
|
|
34
|
+
// `review` used to be in this alternation, and it matched every `*-reviewer`
|
|
35
|
+
// slug — so 37 of the 40 agents in `qa` were domain reviewers, the `domain`
|
|
36
|
+
// bucket below was permanently empty, and the panel grouped 58% of the fleet
|
|
37
|
+
// under one heading. The rule that was supposed to separate them sat two lines
|
|
38
|
+
// later and could never be reached.
|
|
39
|
+
if (/\bqa\b|test|eval|^code-reviewer$/.test(s)) return 'qa';
|
|
27
40
|
if (/devops|deploy|infra|l3|support|oncall/.test(s)) return 'ops';
|
|
28
41
|
if (/reviewer$/.test(s)) return 'domain';
|
|
29
42
|
if (/pm|plan|product/.test(s)) return 'pm';
|
|
@@ -19,6 +19,7 @@ import { getMetrics } from './metrics.mjs';
|
|
|
19
19
|
import { readVerdicts } from './verdicts.mjs';
|
|
20
20
|
import { getAgentsFleet, getAgentProfile, retireAgent, restoreAgent, appendDecisionLog, readDecisionsLog } from './fleet.mjs';
|
|
21
21
|
import { getResume, getShareState, toggleShare } from './share.mjs';
|
|
22
|
+
import { listSessions, readSession, editedFiles, searchSessions } from './transcripts.mjs';
|
|
22
23
|
|
|
23
24
|
// ── HTTP router ────────────────────────────────────────────────────────────────
|
|
24
25
|
// dispatch(req, res, url, cwd, projInfo) handles every /api/* route plus /api/sse.
|
|
@@ -855,6 +856,53 @@ async function dispatch(req, res, url, cwd) {
|
|
|
855
856
|
return true;
|
|
856
857
|
}
|
|
857
858
|
|
|
859
|
+
// ── session transcripts ───────────────────────────────────────────────
|
|
860
|
+
//
|
|
861
|
+
// The board reports outcomes. These four report HOW an outcome was reached,
|
|
862
|
+
// which is the only thing that separates a weak agent from a broken harness —
|
|
863
|
+
// a distinction this repo got wrong three times in one week by reading a score
|
|
864
|
+
// without reading the run.
|
|
865
|
+
//
|
|
866
|
+
// Read-only by construction: no writes, no index, no cache. A transcript is
|
|
867
|
+
// evidence, and a tool that edits evidence is not one.
|
|
868
|
+
|
|
869
|
+
if (pathname === '/api/sessions') {
|
|
870
|
+
const sessions = listSessions(cwd);
|
|
871
|
+
res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
|
872
|
+
res.end(JSON.stringify({ project: cwd, count: sessions.length,
|
|
873
|
+
// The absolute path stays on the server. A browser has no use for it and
|
|
874
|
+
// it names the operator's home directory.
|
|
875
|
+
sessions: sessions.map(({ file, ...rest }) => rest) }));
|
|
876
|
+
return true;
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
if (pathname.startsWith('/api/sessions/') && pathname.endsWith('/edits')) {
|
|
880
|
+
const id = pathname.slice('/api/sessions/'.length, -'/edits'.length);
|
|
881
|
+
const s = listSessions(cwd).find((x) => x.id === id);
|
|
882
|
+
if (!s) { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end('{"error":"no such session"}'); return true; }
|
|
883
|
+
res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
|
884
|
+
res.end(JSON.stringify({ session: id, files: editedFiles(s.file) }));
|
|
885
|
+
return true;
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
if (pathname.startsWith('/api/sessions/')) {
|
|
889
|
+
// The id comes from the URL and is used to pick from a list the server
|
|
890
|
+
// built — never to build a path. A traversal in the id matches nothing.
|
|
891
|
+
const id = pathname.slice('/api/sessions/'.length);
|
|
892
|
+
const s = listSessions(cwd).find((x) => x.id === id);
|
|
893
|
+
if (!s) { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end('{"error":"no such session"}'); return true; }
|
|
894
|
+
res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
|
895
|
+
res.end(JSON.stringify({ session: id, title: s.title, modified: s.modified, turns: readSession(s.file) }));
|
|
896
|
+
return true;
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
if (pathname === '/api/session-search') {
|
|
900
|
+
const q = url.searchParams.get('q') || '';
|
|
901
|
+
res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
|
902
|
+
res.end(JSON.stringify(searchSessions(cwd, q)));
|
|
903
|
+
return true;
|
|
904
|
+
}
|
|
905
|
+
|
|
858
906
|
// Build version — so the board shows which great_cto version it's running.
|
|
859
907
|
if (pathname === '/api/version') {
|
|
860
908
|
res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
// Session transcripts — what the agent actually did, as opposed to what it
|
|
2
|
+
// concluded.
|
|
3
|
+
//
|
|
4
|
+
// The board reports outcomes: verdicts, gates, cost, tasks. It has never touched
|
|
5
|
+
// the 2 GB of session transcripts sitting in ~/.claude/projects, which are the
|
|
6
|
+
// only record of HOW an outcome was reached — the tool calls, the retries, the
|
|
7
|
+
// edits, the turn where it went wrong.
|
|
8
|
+
//
|
|
9
|
+
// The value of that record was demonstrated the hard way this week. Three
|
|
10
|
+
// separate times a low eval score was read as an agent gap and turned out to be
|
|
11
|
+
// the harness: a token cap that truncated every answer, a one-shot actor that
|
|
12
|
+
// could not look anything up, a fixture with no approved gate. Each was found by
|
|
13
|
+
// reading what the agent did, and each cost a day of the wrong repair first.
|
|
14
|
+
//
|
|
15
|
+
// Deliberately narrow, and deliberately read-only:
|
|
16
|
+
//
|
|
17
|
+
// - one format, ours. agentsview parses fifty; we have Claude Code's JSONL and
|
|
18
|
+
// no reason to guess at the others.
|
|
19
|
+
// - no index, no database. A session file is read when someone opens it. The
|
|
20
|
+
// board is zero-dependency and stays that way.
|
|
21
|
+
// - nothing is written back. A transcript is evidence; a tool that edits
|
|
22
|
+
// evidence is not one.
|
|
23
|
+
|
|
24
|
+
import fs from 'node:fs';
|
|
25
|
+
import path from 'node:path';
|
|
26
|
+
import os from 'node:os';
|
|
27
|
+
|
|
28
|
+
/** Claude Code's per-project session directory, keyed by a slugified cwd. */
|
|
29
|
+
export function projectsRoot() {
|
|
30
|
+
return path.join(os.homedir(), '.claude', 'projects');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* cwd → the directory name Claude Code derives from it.
|
|
35
|
+
*
|
|
36
|
+
* Every character outside [A-Za-z0-9-] becomes a hyphen, underscores included:
|
|
37
|
+
* `/Users/x/development/Personal/great_cto` is stored as
|
|
38
|
+
* `-Users-x-development-Personal-great-cto`. Deriving this from the observed
|
|
39
|
+
* directory names rather than guessing, because a slug that is nearly right
|
|
40
|
+
* silently lists zero sessions and looks like a project with no history.
|
|
41
|
+
*/
|
|
42
|
+
export function slugForCwd(cwd) {
|
|
43
|
+
return String(cwd || '').replace(/[^A-Za-z0-9-]/g, '-');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function readJsonl(file, { limit = Infinity } = {}) {
|
|
47
|
+
let text;
|
|
48
|
+
try { text = fs.readFileSync(file, 'utf8'); } catch { return []; }
|
|
49
|
+
const out = [];
|
|
50
|
+
for (const line of text.split('\n')) {
|
|
51
|
+
if (out.length >= limit) break;
|
|
52
|
+
const t = line.trim();
|
|
53
|
+
if (!t || t[0] !== '{') continue;
|
|
54
|
+
// A truncated append is the normal state of a file being written to right
|
|
55
|
+
// now. Skipping the line is right; failing the read is not.
|
|
56
|
+
try { out.push(JSON.parse(t)); } catch { /* partial write */ }
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Sessions for a project, newest first.
|
|
63
|
+
*
|
|
64
|
+
* Metadata only — each entry costs one stat and a bounded head-read, so listing
|
|
65
|
+
* a project with a hundred sessions does not read a hundred megabytes.
|
|
66
|
+
*/
|
|
67
|
+
export function listSessions(cwd, { root = projectsRoot() } = {}) {
|
|
68
|
+
const dir = path.join(root, slugForCwd(cwd));
|
|
69
|
+
let files;
|
|
70
|
+
try { files = fs.readdirSync(dir).filter((f) => f.endsWith('.jsonl')); } catch { return []; }
|
|
71
|
+
|
|
72
|
+
return files.map((f) => {
|
|
73
|
+
const full = path.join(dir, f);
|
|
74
|
+
let stat;
|
|
75
|
+
try { stat = fs.statSync(full); } catch { return null; }
|
|
76
|
+
|
|
77
|
+
// The title lives in a `custom-title` record that can appear anywhere, and
|
|
78
|
+
// rewriting it appends a new one — so the LAST is current. Read a bounded
|
|
79
|
+
// head and accept not finding it rather than scanning a 40 MB file for a
|
|
80
|
+
// label.
|
|
81
|
+
const head = readJsonl(full, { limit: 400 });
|
|
82
|
+
const titles = head.filter((r) => r.type === 'custom-title' && r.customTitle);
|
|
83
|
+
const firstUser = head.find((r) => r.type === 'user' && !r.isCompactSummary);
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
id: f.replace(/\.jsonl$/, ''),
|
|
87
|
+
file: full,
|
|
88
|
+
title: titles.length ? titles[titles.length - 1].customTitle : null,
|
|
89
|
+
// Falling back to the opening prompt: a session with no title is still
|
|
90
|
+
// recognisable by what it was asked to do.
|
|
91
|
+
opened_with: firstUser ? messageText(firstUser).slice(0, 120) : null,
|
|
92
|
+
size_bytes: stat.size,
|
|
93
|
+
modified: stat.mtime.toISOString(),
|
|
94
|
+
};
|
|
95
|
+
}).filter(Boolean).sort((a, b) => b.modified.localeCompare(a.modified));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** A message record → plain text, whatever content shape it carries. */
|
|
99
|
+
export function messageText(rec) {
|
|
100
|
+
const c = rec?.message?.content;
|
|
101
|
+
if (typeof c === 'string') return c;
|
|
102
|
+
if (!Array.isArray(c)) return '';
|
|
103
|
+
return c.filter((b) => b?.type === 'text').map((b) => b.text || '').join('\n').trim();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Tool calls in an assistant record, as {name, input}. */
|
|
107
|
+
export function toolCalls(rec) {
|
|
108
|
+
const c = rec?.message?.content;
|
|
109
|
+
if (!Array.isArray(c)) return [];
|
|
110
|
+
return c.filter((b) => b?.type === 'tool_use').map((b) => ({ name: b.name, input: b.input ?? {} }));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* One session as a readable turn list.
|
|
115
|
+
*
|
|
116
|
+
* Bookkeeping records — `mode`, `queue-operation`, `last-prompt`, `custom-title`
|
|
117
|
+
* — are dropped: they describe the client's state, not the work. `system` is
|
|
118
|
+
* kept, because a compaction or an error is part of how the session went.
|
|
119
|
+
*/
|
|
120
|
+
export function readSession(file, { limit = 2000 } = {}) {
|
|
121
|
+
const KEEP = new Set(['user', 'assistant', 'system']);
|
|
122
|
+
const turns = [];
|
|
123
|
+
for (const rec of readJsonl(file)) {
|
|
124
|
+
if (!KEEP.has(rec.type)) continue;
|
|
125
|
+
const tools = toolCalls(rec);
|
|
126
|
+
const text = messageText(rec);
|
|
127
|
+
if (!text && !tools.length && rec.type !== 'system') continue;
|
|
128
|
+
turns.push({
|
|
129
|
+
role: rec.type,
|
|
130
|
+
ts: rec.timestamp ?? null,
|
|
131
|
+
text,
|
|
132
|
+
tools: tools.map((t) => t.name),
|
|
133
|
+
// The whole input is not carried — a file write's content can be
|
|
134
|
+
// megabytes, and the panel wants to show what was called, not replay it.
|
|
135
|
+
tool_detail: tools.map((t) => ({ name: t.name, target: t.input?.file_path ?? t.input?.path ?? t.input?.command ?? null })),
|
|
136
|
+
subtype: rec.subtype ?? null,
|
|
137
|
+
});
|
|
138
|
+
if (turns.length >= limit) break;
|
|
139
|
+
}
|
|
140
|
+
return turns;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Files this session edited, newest first.
|
|
145
|
+
*
|
|
146
|
+
* Read from the Edit/Write tool calls rather than from git, so it shows what the
|
|
147
|
+
* agent touched even when nothing was committed — which is exactly the case
|
|
148
|
+
* where a reader is trying to work out what happened.
|
|
149
|
+
*/
|
|
150
|
+
export function editedFiles(file) {
|
|
151
|
+
const EDIT = new Set(['Edit', 'Write', 'NotebookEdit', 'MultiEdit']);
|
|
152
|
+
const seen = new Map();
|
|
153
|
+
for (const rec of readJsonl(file)) {
|
|
154
|
+
if (rec.type !== 'assistant') continue;
|
|
155
|
+
for (const t of toolCalls(rec)) {
|
|
156
|
+
if (!EDIT.has(t.name)) continue;
|
|
157
|
+
const p = t.input?.file_path ?? t.input?.path;
|
|
158
|
+
if (!p) continue;
|
|
159
|
+
const prev = seen.get(p);
|
|
160
|
+
seen.set(p, { path: p, tool: t.name, edits: (prev?.edits ?? 0) + 1, last: rec.timestamp ?? prev?.last ?? null });
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return [...seen.values()].sort((a, b) => String(b.last).localeCompare(String(a.last)));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Search a project's transcripts for a string.
|
|
168
|
+
*
|
|
169
|
+
* Plain substring, case-insensitive, capped. No index and no embedding: the
|
|
170
|
+
* question this answers is "where did I see that", and grep answers it at a
|
|
171
|
+
* scale of gigabytes without a database to keep in sync.
|
|
172
|
+
*/
|
|
173
|
+
export function searchSessions(cwd, query, { root = projectsRoot(), limit = 50 } = {}) {
|
|
174
|
+
const q = String(query || '').trim().toLowerCase();
|
|
175
|
+
if (q.length < 3) return { query: q, hits: [], note: 'a query under 3 characters matches everything' };
|
|
176
|
+
|
|
177
|
+
const hits = [];
|
|
178
|
+
for (const s of listSessions(cwd, { root })) {
|
|
179
|
+
for (const rec of readJsonl(s.file)) {
|
|
180
|
+
if (hits.length >= limit) break;
|
|
181
|
+
const text = messageText(rec);
|
|
182
|
+
if (!text) continue;
|
|
183
|
+
const at = text.toLowerCase().indexOf(q);
|
|
184
|
+
if (at === -1) continue;
|
|
185
|
+
hits.push({
|
|
186
|
+
session: s.id,
|
|
187
|
+
title: s.title,
|
|
188
|
+
role: rec.type,
|
|
189
|
+
ts: rec.timestamp ?? null,
|
|
190
|
+
// A window around the match, not the whole turn — a turn can be a
|
|
191
|
+
// thousand lines and the reader wants to know whether to open it.
|
|
192
|
+
excerpt: text.slice(Math.max(0, at - 60), at + q.length + 120).replace(/\s+/g, ' ').trim(),
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
if (hits.length >= limit) break;
|
|
196
|
+
}
|
|
197
|
+
return { query: q, hits, truncated: hits.length >= limit };
|
|
198
|
+
}
|
|
@@ -2194,6 +2194,11 @@ button { font-family: inherit; cursor: pointer; }
|
|
|
2194
2194
|
<span>Memory</span>
|
|
2195
2195
|
<span class="count" id="nav-memory-count">0</span>
|
|
2196
2196
|
</div>
|
|
2197
|
+
<div class="nav-item" data-tab="sessions" onclick="switchTab('sessions', this)">
|
|
2198
|
+
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"/></svg>
|
|
2199
|
+
<span>Sessions</span>
|
|
2200
|
+
<span class="count" id="nav-sessions-count">0</span>
|
|
2201
|
+
</div>
|
|
2197
2202
|
<div class="nav-item" data-tab="logs" onclick="switchTab('logs', this)">
|
|
2198
2203
|
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><line x1="10" y1="9" x2="8" y2="9"/></svg>
|
|
2199
2204
|
<span>Logs</span>
|
|
@@ -2464,6 +2469,19 @@ button { font-family: inherit; cursor: pointer; }
|
|
|
2464
2469
|
</div>
|
|
2465
2470
|
|
|
2466
2471
|
<!-- Memory -->
|
|
2472
|
+
<!-- Sessions: what the agent DID, as opposed to what it concluded.
|
|
2473
|
+
Every other panel reports an outcome. Three times this week a low eval
|
|
2474
|
+
score was read as an agent gap and turned out to be the harness, and
|
|
2475
|
+
each was found by reading the run rather than the number. -->
|
|
2476
|
+
<div class="panel" id="panel-sessions">
|
|
2477
|
+
<div class="panel-head">
|
|
2478
|
+
<h2>Sessions</h2>
|
|
2479
|
+
<input id="session-q" class="input" placeholder="search transcripts…" style="max-width:280px"
|
|
2480
|
+
onkeydown="if(event.key==='Enter')searchTranscripts()">
|
|
2481
|
+
</div>
|
|
2482
|
+
<div id="sessions-body" class="muted">Loading…</div>
|
|
2483
|
+
</div>
|
|
2484
|
+
|
|
2467
2485
|
<div class="panel" id="panel-memory">
|
|
2468
2486
|
<div class="memory-page">
|
|
2469
2487
|
<div class="memory-side">
|
|
@@ -3284,6 +3302,104 @@ function mdToHtml(md) {
|
|
|
3284
3302
|
});
|
|
3285
3303
|
}
|
|
3286
3304
|
|
|
3305
|
+
// ── Sessions ────────────────────────────────────────────────────────────────
|
|
3306
|
+
//
|
|
3307
|
+
// Every other panel answers "what happened". This one answers "how", which is
|
|
3308
|
+
// the only thing that separates a weak agent from a broken harness — a
|
|
3309
|
+
// distinction this repo got wrong three times in one week by reading a score
|
|
3310
|
+
// without reading the run.
|
|
3311
|
+
|
|
3312
|
+
const fmtMB = (b) => b >= 1e6 ? (b / 1e6).toFixed(1) + ' MB' : Math.round(b / 1e3) + ' KB';
|
|
3313
|
+
const ago = (iso) => {
|
|
3314
|
+
const h = (Date.now() - new Date(iso)) / 36e5;
|
|
3315
|
+
if (!Number.isFinite(h)) return '';
|
|
3316
|
+
return h < 1 ? `${Math.round(h * 60)}m ago` : h < 48 ? `${Math.round(h)}h ago` : `${Math.round(h / 24)}d ago`;
|
|
3317
|
+
};
|
|
3318
|
+
|
|
3319
|
+
async function loadSessions() {
|
|
3320
|
+
const body = document.getElementById('sessions-body');
|
|
3321
|
+
const d = await api(`/api/sessions${pqs()}`);
|
|
3322
|
+
// Never leave the panel on "Loading…" — an endpoint that failed must say so,
|
|
3323
|
+
// because a spinner and an empty project look identical to the reader.
|
|
3324
|
+
if (!d) { body.innerHTML = '<div class="muted">Could not read sessions.</div>'; return; }
|
|
3325
|
+
const c = document.getElementById('nav-sessions-count'); if (c) c.textContent = d.count ?? 0;
|
|
3326
|
+
if (!d.sessions?.length) {
|
|
3327
|
+
body.innerHTML = '<div class="muted">No transcripts for this project yet.</div>';
|
|
3328
|
+
return;
|
|
3329
|
+
}
|
|
3330
|
+
body.innerHTML = d.sessions.map(s => `
|
|
3331
|
+
<div class="card" style="margin-bottom:8px">
|
|
3332
|
+
<div style="display:flex;justify-content:space-between;gap:12px;align-items:baseline">
|
|
3333
|
+
<strong>${esc(s.title || s.opened_with || s.id)}</strong>
|
|
3334
|
+
<span class="muted" style="white-space:nowrap">${ago(s.modified)} · ${fmtMB(s.size_bytes)}</span>
|
|
3335
|
+
</div>
|
|
3336
|
+
<div style="margin-top:6px">
|
|
3337
|
+
<button class="btn-sm" onclick="openSession('${esc(s.id)}')">transcript</button>
|
|
3338
|
+
<button class="btn-sm" onclick="openEdits('${esc(s.id)}')">edits</button>
|
|
3339
|
+
</div>
|
|
3340
|
+
<div id="sess-${esc(s.id)}" style="margin-top:8px"></div>
|
|
3341
|
+
</div>`).join('');
|
|
3342
|
+
}
|
|
3343
|
+
|
|
3344
|
+
async function openEdits(id) {
|
|
3345
|
+
const slot = document.getElementById(`sess-${id}`);
|
|
3346
|
+
slot.innerHTML = '<span class="muted">Loading…</span>';
|
|
3347
|
+
const d = await api(`/api/sessions/${encodeURIComponent(id)}/edits${pqs()}`);
|
|
3348
|
+
if (!d?.files) { slot.innerHTML = '<span class="muted">Could not read edits.</span>'; return; }
|
|
3349
|
+
if (!d.files.length) { slot.innerHTML = '<span class="muted">No files edited in this session.</span>'; return; }
|
|
3350
|
+
slot.innerHTML = `<table class="tbl"><tbody>${d.files.map(f => `
|
|
3351
|
+
<tr><td style="width:3em;text-align:right" class="muted">${f.edits}×</td>
|
|
3352
|
+
<td style="width:5em" class="muted">${esc(f.tool)}</td>
|
|
3353
|
+
<td><code>${esc(f.path)}</code></td></tr>`).join('')}</tbody></table>`;
|
|
3354
|
+
}
|
|
3355
|
+
|
|
3356
|
+
async function openSession(id) {
|
|
3357
|
+
const slot = document.getElementById(`sess-${id}`);
|
|
3358
|
+
slot.innerHTML = '<span class="muted">Loading…</span>';
|
|
3359
|
+
const d = await api(`/api/sessions/${encodeURIComponent(id)}${pqs()}`);
|
|
3360
|
+
if (!d?.turns) { slot.innerHTML = '<span class="muted">Could not read transcript.</span>'; return; }
|
|
3361
|
+
// A session runs to thousands of turns. Showing the tail is what a reader
|
|
3362
|
+
// wants — the question is almost always "what happened at the end".
|
|
3363
|
+
const turns = d.turns.slice(-60);
|
|
3364
|
+
slot.innerHTML =
|
|
3365
|
+
`<div class="muted" style="margin-bottom:6px">last ${turns.length} of ${d.turns.length} turns</div>` +
|
|
3366
|
+
`<div style="max-height:420px;overflow:auto;border-left:2px solid var(--border);padding-left:10px">` +
|
|
3367
|
+
turns.map(t => {
|
|
3368
|
+
const tools = t.tool_detail?.length
|
|
3369
|
+
? `<div class="muted" style="font-size:.85em">${t.tool_detail.map(x =>
|
|
3370
|
+
`${esc(x.name)}${x.target ? ' → ' + esc(String(x.target).slice(0, 70)) : ''}`).join(' · ')}</div>`
|
|
3371
|
+
: '';
|
|
3372
|
+
const text = (t.text || '').slice(0, 400);
|
|
3373
|
+
return `<div style="margin-bottom:10px">
|
|
3374
|
+
<span class="muted" style="font-size:.8em">${esc(t.role)}${t.subtype ? '/' + esc(t.subtype) : ''} ${t.ts ? ago(t.ts) : ''}</span>
|
|
3375
|
+
${text ? `<div>${esc(text)}${(t.text || '').length > 400 ? '…' : ''}</div>` : ''}
|
|
3376
|
+
${tools}
|
|
3377
|
+
</div>`;
|
|
3378
|
+
}).join('') + '</div>';
|
|
3379
|
+
}
|
|
3380
|
+
|
|
3381
|
+
async function searchTranscripts() {
|
|
3382
|
+
const q = document.getElementById('session-q').value.trim();
|
|
3383
|
+
const body = document.getElementById('sessions-body');
|
|
3384
|
+
if (q.length < 3) { loadSessions(); return; }
|
|
3385
|
+
body.innerHTML = '<div class="muted">Searching…</div>';
|
|
3386
|
+
const d = await api(`/api/session-search${pqs()}&q=${encodeURIComponent(q)}`);
|
|
3387
|
+
if (!d) { body.innerHTML = '<div class="muted">Search failed.</div>'; return; }
|
|
3388
|
+
if (!d.hits?.length) {
|
|
3389
|
+
body.innerHTML = `<div class="muted">No match for “${esc(q)}”.${d.note ? ' ' + esc(d.note) : ''}
|
|
3390
|
+
<button class="btn-sm" onclick="loadSessions()">back</button></div>`;
|
|
3391
|
+
return;
|
|
3392
|
+
}
|
|
3393
|
+
body.innerHTML =
|
|
3394
|
+
`<div class="muted" style="margin-bottom:8px">${d.hits.length} match(es)${d.truncated ? ', capped' : ''}
|
|
3395
|
+
<button class="btn-sm" onclick="loadSessions()">back</button></div>` +
|
|
3396
|
+
d.hits.map(h => `
|
|
3397
|
+
<div class="card" style="margin-bottom:6px">
|
|
3398
|
+
<div class="muted" style="font-size:.8em">${esc(h.title || h.session)} · ${esc(h.role)} · ${h.ts ? ago(h.ts) : ''}</div>
|
|
3399
|
+
<div>${esc(h.excerpt)}</div>
|
|
3400
|
+
</div>`).join('');
|
|
3401
|
+
}
|
|
3402
|
+
|
|
3287
3403
|
async function loadMemory() {
|
|
3288
3404
|
const d = await api(`/api/memory${pqs()}`);
|
|
3289
3405
|
memoryData = d || { layers: [], patterns: [] };
|
|
@@ -4592,7 +4708,7 @@ function switchTab(id, el) {
|
|
|
4592
4708
|
// crumb — `labels` used to be referenced here but is only ever a function-local in
|
|
4593
4709
|
// this file, so this threw ReferenceError on every tab switch and skipped the per-tab
|
|
4594
4710
|
// loaders below (e.g. logs stuck on "Loading…"). Use a local label map.
|
|
4595
|
-
const TAB_LABELS = { dashboard: 'Board', inbox: 'Inbox', tasks: 'Tasks', agents: 'Agents', memory: 'Memory', logs: 'Logs', notifications: 'Notifications', metrics: 'Metrics' };
|
|
4711
|
+
const TAB_LABELS = { dashboard: 'Board', inbox: 'Inbox', tasks: 'Tasks', agents: 'Agents', memory: 'Memory', sessions: 'Sessions', logs: 'Logs', notifications: 'Notifications', metrics: 'Metrics' };
|
|
4596
4712
|
const crumb = document.getElementById('crumb-here'); if (crumb) crumb.textContent = TAB_LABELS[id] || 'Board';
|
|
4597
4713
|
if (id === 'dashboard' || id === 'agents') {
|
|
4598
4714
|
api(`/api/metrics${pqs()}`).then(m => { if (m) { metrics = m; renderDashboard(m); } });
|
|
@@ -4601,6 +4717,7 @@ function switchTab(id, el) {
|
|
|
4601
4717
|
}
|
|
4602
4718
|
if (id === 'inbox') refreshInbox();
|
|
4603
4719
|
if (id === 'memory') loadMemory();
|
|
4720
|
+
if (id === 'sessions') loadSessions();
|
|
4604
4721
|
if (id === 'logs') refreshLogs();
|
|
4605
4722
|
if (id === 'notifications') loadNotifications();
|
|
4606
4723
|
}
|
package/dist/main.js
CHANGED
|
@@ -1196,6 +1196,23 @@ async function runSelfUpgrade() {
|
|
|
1196
1196
|
return result.exitCode;
|
|
1197
1197
|
}
|
|
1198
1198
|
success(result.message);
|
|
1199
|
+
// The CLI is upgraded; the plugin Claude Code actually loads may not be.
|
|
1200
|
+
// `npm i -g` never touches the plugin cache, so without this the user is told
|
|
1201
|
+
// the upgrade succeeded and keeps running the previous release's hooks and
|
|
1202
|
+
// agent prompts with nothing anywhere saying so.
|
|
1203
|
+
try {
|
|
1204
|
+
const { pluginCacheLagWarning } = await import("./self-upgrade.js");
|
|
1205
|
+
const { readdirSync } = await import("node:fs");
|
|
1206
|
+
const { join } = await import("node:path");
|
|
1207
|
+
const cacheRoot = join(homedir(), ".claude", "plugins", "cache", "local", "great_cto");
|
|
1208
|
+
const dirs = readdirSync(cacheRoot);
|
|
1209
|
+
const warning = pluginCacheLagWarning(result.newVersion ?? "", dirs);
|
|
1210
|
+
if (warning) {
|
|
1211
|
+
log("");
|
|
1212
|
+
log(dim(warning));
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
catch { /* no plugin cache is not a lag — the plugin is simply not installed */ }
|
|
1199
1216
|
return 0;
|
|
1200
1217
|
}
|
|
1201
1218
|
async function runUpgrade(rawArgv, args) {
|
|
@@ -1242,6 +1259,10 @@ async function main() {
|
|
|
1242
1259
|
// is the single exit funnel: it fires one fire-and-forget event (command + node + os
|
|
1243
1260
|
// + exit_code + duration, no PII) only when the user has opted in, then exits.
|
|
1244
1261
|
const __tStart = Date.now();
|
|
1262
|
+
// Promise<never>, not Promise<void>: this always exits, and typing it as such
|
|
1263
|
+
// is what lets `await finish(2)` narrow a nullable the way `process.exit(2)`
|
|
1264
|
+
// did. Without it, routing an exit through the update check would silently
|
|
1265
|
+
// widen types at every call site.
|
|
1245
1266
|
const finish = async (code) => {
|
|
1246
1267
|
try {
|
|
1247
1268
|
await sendUsagePing({
|
|
@@ -1270,7 +1291,7 @@ async function main() {
|
|
|
1270
1291
|
if (args.command === "telemetry") {
|
|
1271
1292
|
const { exitCode, output } = telemetrySubcommand(args.positional[0]);
|
|
1272
1293
|
process.stdout.write(output);
|
|
1273
|
-
|
|
1294
|
+
await finish(exitCode);
|
|
1274
1295
|
}
|
|
1275
1296
|
if (args.command === "help") {
|
|
1276
1297
|
printHelp();
|
|
@@ -1281,7 +1302,7 @@ async function main() {
|
|
|
1281
1302
|
error(`great-cto: unknown command or flag '${tok}'`);
|
|
1282
1303
|
log("");
|
|
1283
1304
|
log(`Run ${cyan("great-cto --help")} for usage.`);
|
|
1284
|
-
|
|
1305
|
+
await finish(2);
|
|
1285
1306
|
}
|
|
1286
1307
|
if (args.command === "board") {
|
|
1287
1308
|
try {
|
|
@@ -1304,7 +1325,7 @@ async function main() {
|
|
|
1304
1325
|
}
|
|
1305
1326
|
catch (e) {
|
|
1306
1327
|
error(e.message);
|
|
1307
|
-
|
|
1328
|
+
await finish(1);
|
|
1308
1329
|
}
|
|
1309
1330
|
}
|
|
1310
1331
|
if (args.command === "console") {
|
|
@@ -1314,7 +1335,7 @@ async function main() {
|
|
|
1314
1335
|
}
|
|
1315
1336
|
catch (e) {
|
|
1316
1337
|
error(e.message);
|
|
1317
|
-
|
|
1338
|
+
await finish(1);
|
|
1318
1339
|
}
|
|
1319
1340
|
}
|
|
1320
1341
|
if (args.command === "register") {
|
|
@@ -1324,7 +1345,7 @@ async function main() {
|
|
|
1324
1345
|
}
|
|
1325
1346
|
catch (e) {
|
|
1326
1347
|
error(e.message);
|
|
1327
|
-
|
|
1348
|
+
await finish(1);
|
|
1328
1349
|
}
|
|
1329
1350
|
}
|
|
1330
1351
|
if (args.command === "ci") {
|
|
@@ -1335,7 +1356,7 @@ async function main() {
|
|
|
1335
1356
|
}
|
|
1336
1357
|
catch (e) {
|
|
1337
1358
|
error(e.message);
|
|
1338
|
-
|
|
1359
|
+
await finish(2);
|
|
1339
1360
|
}
|
|
1340
1361
|
}
|
|
1341
1362
|
if (args.command === "mcp") {
|
|
@@ -1349,7 +1370,7 @@ async function main() {
|
|
|
1349
1370
|
}
|
|
1350
1371
|
catch (e) {
|
|
1351
1372
|
error(e.message);
|
|
1352
|
-
|
|
1373
|
+
await finish(2);
|
|
1353
1374
|
}
|
|
1354
1375
|
}
|
|
1355
1376
|
if (args.command === "adapt") {
|
|
@@ -1364,16 +1385,16 @@ async function main() {
|
|
|
1364
1385
|
}
|
|
1365
1386
|
catch (e) {
|
|
1366
1387
|
error(e.message);
|
|
1367
|
-
|
|
1388
|
+
await finish(2);
|
|
1368
1389
|
}
|
|
1369
1390
|
}
|
|
1370
1391
|
if (args.command === "task") {
|
|
1371
1392
|
const { runTask } = await import("./worker.js");
|
|
1372
|
-
|
|
1393
|
+
await finish(await runTask(args.taskArgs ?? []));
|
|
1373
1394
|
}
|
|
1374
1395
|
if (args.command === "worker") {
|
|
1375
1396
|
const { runWorker } = await import("./worker.js");
|
|
1376
|
-
|
|
1397
|
+
await finish(await runWorker(args.taskArgs ?? []));
|
|
1377
1398
|
}
|
|
1378
1399
|
if (args.command === "serve") {
|
|
1379
1400
|
try {
|
|
@@ -1390,7 +1411,7 @@ async function main() {
|
|
|
1390
1411
|
}
|
|
1391
1412
|
catch (e) {
|
|
1392
1413
|
error(e.message);
|
|
1393
|
-
|
|
1414
|
+
await finish(2);
|
|
1394
1415
|
}
|
|
1395
1416
|
}
|
|
1396
1417
|
if (args.command === "webhook") {
|
|
@@ -1399,14 +1420,14 @@ async function main() {
|
|
|
1399
1420
|
const parsed = parseWebhookArgs(rawArgv);
|
|
1400
1421
|
if (!parsed) {
|
|
1401
1422
|
error("usage: great-cto webhook list | add-incoming <name> --secret <s> | add-outgoing <name> --url <u> --format <f> --triggers <t1,t2> | remove <name> | test <name>");
|
|
1402
|
-
|
|
1423
|
+
return finish(2);
|
|
1403
1424
|
}
|
|
1404
1425
|
const code = await runWebhookCli(parsed);
|
|
1405
1426
|
await finish(code);
|
|
1406
1427
|
}
|
|
1407
1428
|
catch (e) {
|
|
1408
1429
|
error(e.message);
|
|
1409
|
-
|
|
1430
|
+
await finish(2);
|
|
1410
1431
|
}
|
|
1411
1432
|
}
|
|
1412
1433
|
if (args.command === "upgrade") {
|
|
@@ -1416,7 +1437,7 @@ async function main() {
|
|
|
1416
1437
|
}
|
|
1417
1438
|
catch (e) {
|
|
1418
1439
|
error(e.message);
|
|
1419
|
-
|
|
1440
|
+
await finish(2);
|
|
1420
1441
|
}
|
|
1421
1442
|
}
|
|
1422
1443
|
if (args.command === "chat-only-hint") {
|
|
@@ -1433,21 +1454,21 @@ async function main() {
|
|
|
1433
1454
|
log(` ${cyan("serve")} · ${cyan("webhook")} · ${cyan("report")} · ${cyan("board")} · ${cyan("register")} · ${cyan("upgrade")}`);
|
|
1434
1455
|
log("");
|
|
1435
1456
|
log(`Run ${cyan("npx great-cto --help")} for the full CLI reference.`);
|
|
1436
|
-
|
|
1457
|
+
await finish(2);
|
|
1437
1458
|
}
|
|
1438
1459
|
if (args.command === "report") {
|
|
1439
1460
|
try {
|
|
1440
1461
|
const { runReport, parseReportArgs } = await import("./report.js");
|
|
1441
1462
|
const parsed = parseReportArgs(rawArgv, args.dir);
|
|
1442
1463
|
if (!parsed) {
|
|
1443
|
-
|
|
1464
|
+
return finish(2);
|
|
1444
1465
|
}
|
|
1445
1466
|
const code = await runReport(parsed);
|
|
1446
1467
|
await finish(code);
|
|
1447
1468
|
}
|
|
1448
1469
|
catch (e) {
|
|
1449
1470
|
error(e.message);
|
|
1450
|
-
|
|
1471
|
+
await finish(2);
|
|
1451
1472
|
}
|
|
1452
1473
|
}
|
|
1453
1474
|
if (args.command === "version") {
|
|
@@ -1464,7 +1485,7 @@ async function main() {
|
|
|
1464
1485
|
const pkg = JSON.parse(readFileSync(p, "utf-8"));
|
|
1465
1486
|
if (pkg.name === "great-cto" && pkg.version) {
|
|
1466
1487
|
log(pkg.version);
|
|
1467
|
-
|
|
1488
|
+
await finish(0);
|
|
1468
1489
|
}
|
|
1469
1490
|
}
|
|
1470
1491
|
catch { /* keep searching */ }
|
|
@@ -1474,7 +1495,7 @@ async function main() {
|
|
|
1474
1495
|
catch {
|
|
1475
1496
|
log("0.0.0");
|
|
1476
1497
|
}
|
|
1477
|
-
|
|
1498
|
+
await finish(0);
|
|
1478
1499
|
}
|
|
1479
1500
|
try {
|
|
1480
1501
|
const code = await runInit(args);
|
package/dist/self-upgrade.js
CHANGED
|
@@ -154,3 +154,57 @@ export function performSelfUpgrade(opts) {
|
|
|
154
154
|
}
|
|
155
155
|
// Re-export for callers that only need the prefix-derivation logic directly.
|
|
156
156
|
export { derivePrefixFromBinPath as _derivePrefixFromBinPath };
|
|
157
|
+
/**
|
|
158
|
+
* Newest version present in the Claude Code plugin cache, or null.
|
|
159
|
+
*
|
|
160
|
+
* Takes the directory names so the rule is testable without a filesystem.
|
|
161
|
+
*/
|
|
162
|
+
export function newestCachedPlugin(dirNames) {
|
|
163
|
+
const versions = dirNames.filter((d) => /^\d+\.\d+\.\d+$/.test(d));
|
|
164
|
+
if (!versions.length)
|
|
165
|
+
return null;
|
|
166
|
+
const key = (v) => v.split(".").map(Number);
|
|
167
|
+
return versions.sort((a, b) => {
|
|
168
|
+
const [A, B] = [key(a), key(b)];
|
|
169
|
+
for (let i = 0; i < 3; i++)
|
|
170
|
+
if (A[i] !== B[i])
|
|
171
|
+
return A[i] - B[i];
|
|
172
|
+
return 0;
|
|
173
|
+
})[versions.length - 1];
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Warn when the npm package has moved ahead of the plugin Claude Code loads.
|
|
177
|
+
*
|
|
178
|
+
* `npm i -g` replaces the CLI. It does not touch
|
|
179
|
+
* ~/.claude/plugins/cache/local/great_cto/<version>/, which is where the hooks
|
|
180
|
+
* and agent prompts are actually read from — so a user can take the update
|
|
181
|
+
* prompt, be told the upgrade succeeded, and keep running the previous release's
|
|
182
|
+
* agents with nothing anywhere saying so. That is the same defect this codebase
|
|
183
|
+
* keeps finding: a step reported as done while the thing it was meant to change
|
|
184
|
+
* stayed as it was.
|
|
185
|
+
*
|
|
186
|
+
* Returns null when they match, when nothing is cached (the plugin is simply not
|
|
187
|
+
* installed), or when the cache is somehow ahead — none of those are the failure
|
|
188
|
+
* this exists to catch.
|
|
189
|
+
*/
|
|
190
|
+
export function pluginCacheLagWarning(npmVersion, cachedDirs) {
|
|
191
|
+
const cached = newestCachedPlugin(cachedDirs);
|
|
192
|
+
if (!cached || !npmVersion)
|
|
193
|
+
return null;
|
|
194
|
+
if (cached === npmVersion)
|
|
195
|
+
return null;
|
|
196
|
+
const key = (v) => v.split(".").map(Number);
|
|
197
|
+
const [c, n] = [key(cached), key(npmVersion)];
|
|
198
|
+
for (let i = 0; i < 3; i++) {
|
|
199
|
+
if (c[i] > n[i])
|
|
200
|
+
return null; // cache ahead — not the case we guard
|
|
201
|
+
if (c[i] < n[i])
|
|
202
|
+
break;
|
|
203
|
+
}
|
|
204
|
+
return [
|
|
205
|
+
`note: the npm package is ${npmVersion}, but the plugin Claude Code loads is ${cached}.`,
|
|
206
|
+
" Hooks and agent prompts come from the plugin cache, not from npm — until it is",
|
|
207
|
+
" refreshed, this session keeps running the previous release's agents.",
|
|
208
|
+
" Refresh it with: bash scripts/install-local.sh --prune",
|
|
209
|
+
].join("\n");
|
|
210
|
+
}
|
package/dist/update-check.js
CHANGED
|
@@ -58,6 +58,29 @@ export function cachePath() {
|
|
|
58
58
|
}
|
|
59
59
|
/** Pure function: is a suppression condition active? Fail-open to "suppressed" on any ambiguity. */
|
|
60
60
|
export function isSuppressed(opts = {}) {
|
|
61
|
+
if (isRefreshSuppressed(opts))
|
|
62
|
+
return true;
|
|
63
|
+
// Nothing may be printed when stderr is not a terminal — the output is being
|
|
64
|
+
// read by something. This is about PRINTING only; see isRefreshSuppressed.
|
|
65
|
+
if (opts.stderrIsTTY === false)
|
|
66
|
+
return true;
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Should the background cache refresh be suppressed too?
|
|
71
|
+
*
|
|
72
|
+
* Deliberately narrower than isSuppressed. Not printing and not learning are
|
|
73
|
+
* different things, and conflating them had a consequence: the refresh sits
|
|
74
|
+
* behind the same non-TTY check as the hint, so a user whose runs are mostly
|
|
75
|
+
* non-interactive never warmed the cache — and their occasional interactive run
|
|
76
|
+
* therefore had no cache to read and showed nothing, every time. The cache file
|
|
77
|
+
* was simply absent on this machine, which is how this was found.
|
|
78
|
+
*
|
|
79
|
+
* A detached refresh with stdio ignored pollutes no output. What genuinely must
|
|
80
|
+
* not spawn: the protocol commands, whose children would outlive a parsed
|
|
81
|
+
* stream, and anyone who asked us not to check at all.
|
|
82
|
+
*/
|
|
83
|
+
export function isRefreshSuppressed(opts = {}) {
|
|
61
84
|
const env = opts.env ?? process.env;
|
|
62
85
|
if (env.CI != null && env.CI !== "" && env.CI !== "0" && env.CI !== "false")
|
|
63
86
|
return true;
|
|
@@ -65,8 +88,6 @@ export function isSuppressed(opts = {}) {
|
|
|
65
88
|
return true;
|
|
66
89
|
if (opts.command && PROTOCOL_SENSITIVE_COMMANDS.has(opts.command))
|
|
67
90
|
return true;
|
|
68
|
-
if (opts.stderrIsTTY === false)
|
|
69
|
-
return true;
|
|
70
91
|
return false;
|
|
71
92
|
}
|
|
72
93
|
/**
|
|
@@ -213,10 +234,15 @@ export async function checkForUpdate(opts) {
|
|
|
213
234
|
const env = opts.env ?? process.env;
|
|
214
235
|
const stderrIsTTY = opts.stderrIsTTY ?? Boolean(process.stderr.isTTY);
|
|
215
236
|
const stdinIsTTY = opts.stdinIsTTY ?? Boolean(process.stdin.isTTY);
|
|
216
|
-
|
|
237
|
+
// Warm the cache even when nothing may be printed. A run that cannot show a
|
|
238
|
+
// hint can still learn the version for the run that can.
|
|
239
|
+
if (isRefreshSuppressed({ env, command: opts.command }))
|
|
217
240
|
return;
|
|
241
|
+
const quiet = isSuppressed({ env, command: opts.command, stderrIsTTY });
|
|
218
242
|
const cache = readCache();
|
|
219
243
|
if (isCacheFresh(cache, opts.now)) {
|
|
244
|
+
if (quiet)
|
|
245
|
+
return; // fresh cache, but this run may not speak
|
|
220
246
|
if (!cache || !isNewerVersion(opts.currentVersion, cache.latest))
|
|
221
247
|
return;
|
|
222
248
|
if (shouldPrompt({ currentVersion: opts.currentVersion, cache, env, command: opts.command, stderrIsTTY, stdinIsTTY })) {
|
package/package.json
CHANGED