great-cto 3.28.9 → 3.29.1

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "great-cto",
3
3
  "description": "You already have the agent. This is everything around it. great_cto runs Claude Code as a pipeline of 70 specialist agents \u2014 an independent model checks each stage before the next builds on it, spending caps refuse rather than warn, and three decisions stay yours: what gets built, how, and whether it ships.",
4
- "version": "3.28.9",
4
+ "version": "3.29.1",
5
5
  "author": {
6
6
  "name": "Alexander Velikiy",
7
7
  "url": "https://hashnode.com/@Greatcto"
@@ -76,6 +76,12 @@
76
76
  "command": "PLUGIN_DIR=${CLAUDE_PLUGIN_ROOT:-$(ls -d ~/.claude/plugins/cache/*/great_cto/*/ 2>/dev/null | sort -V | tail -1 | sed 's|/$||')}; [ -n \"$PLUGIN_DIR\" ] && node \"${PLUGIN_DIR}/scripts/hooks/session-pipeline-resume.mjs\" 2>/dev/null || true",
77
77
  "timeout": 10,
78
78
  "statusMessage": "Checking for pipeline work in flight..."
79
+ },
80
+ {
81
+ "type": "command",
82
+ "command": "PLUGIN_DIR=${CLAUDE_PLUGIN_ROOT:-$(ls -d ~/.claude/plugins/cache/*/great_cto/*/ 2>/dev/null | sort -V | tail -1 | sed \"s|/$||\")}; [ -n \"$PLUGIN_DIR\" ] && node \"${PLUGIN_DIR}/scripts/hooks/register-project.mjs\" </dev/null 2>/dev/null || true",
83
+ "timeout": 10,
84
+ "statusMessage": "Registering project on the board..."
79
85
  }
80
86
  ]
81
87
  }
@@ -121,6 +127,11 @@
121
127
  "command": "PLUGIN_DIR=${CLAUDE_PLUGIN_ROOT:-$(ls -d ~/.claude/plugins/cache/*/great_cto/*/ 2>/dev/null | sort -V | tail -1 | sed 's|/$||')}; node \"${PLUGIN_DIR}/scripts/hooks/subagent-stop-completion.mjs\" 2>&1; EXIT=$?; if [ $EXIT -eq 2 ]; then exit 2; fi; exit 0",
122
128
  "timeout": 5,
123
129
  "statusMessage": "Three-state completion check..."
130
+ },
131
+ {
132
+ "type": "command",
133
+ "async": true,
134
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hooks/turn-snapshot.mjs\" 2>/dev/null || true"
124
135
  }
125
136
  ]
126
137
  }
@@ -272,7 +283,7 @@
272
283
  "hooks": [
273
284
  {
274
285
  "type": "command",
275
- "command": "INPUT=$(cat 2>/dev/null); TOOL=$(echo \"$INPUT\" | python3 -c \"import sys,json; d=json.load(sys.stdin); print(d.get('tool_name','unknown'))\" 2>/dev/null); mkdir -p .great_cto 2>/dev/null; printf '%s PERMISSION_DENIED tool=%s\\n' \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\" \"$TOOL\" >> .great_cto/permission-denied.log 2>/dev/null; if [ \"$TOOL\" = \"Bash\" ] || [ \"$TOOL\" = \"Write\" ]; then printf '%s PERMISSION_DENIED %s \u2014 agent may be stuck. Run /inbox.\\n' \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\" \"$TOOL\" >> .great_cto/permission-denied.log 2>/dev/null; fi; true",
286
+ "command": "INPUT=$(cat 2>/dev/null); TOOL=$(echo \"$INPUT\" | python3 -c \"import sys,json; d=json.load(sys.stdin); print(d.get('tool_name','unknown'))\" 2>/dev/null); mkdir -p .great_cto 2>/dev/null; printf '%s PERMISSION_DENIED tool=%s\\n' \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\" \"$TOOL\" >> .great_cto/permission-denied.log 2>/dev/null; if [ \"$TOOL\" = \"Bash\" ] || [ \"$TOOL\" = \"Write\" ]; then printf '%s PERMISSION_DENIED %s \u2014 agent may be stuck. Run /inbox.\\n' \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\" \"$TOOL\" >> .great_cto/permission-denied.log 2>/dev/null; fi; true; node \"${CLAUDE_PLUGIN_ROOT}/scripts/lib/agent-events.mjs\" --emit denied --tool \"$TOOL\" 2>/dev/null || true",
276
287
  "timeout": 3
277
288
  }
278
289
  ]
@@ -288,6 +299,16 @@
288
299
  {
289
300
  "type": "command",
290
301
  "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hooks/cross-review-gate.mjs\" 2>/dev/null || true"
302
+ },
303
+ {
304
+ "type": "command",
305
+ "async": true,
306
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hooks/turn-snapshot.mjs\" 2>/dev/null || true"
307
+ },
308
+ {
309
+ "type": "command",
310
+ "async": true,
311
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hooks/register-project.mjs\" 2>/dev/null || true"
291
312
  }
292
313
  ]
293
314
  }
@@ -0,0 +1,53 @@
1
+ // Agent activity for the board (ADR-021, phase 1).
2
+ //
3
+ // The hooks append agent events to <project>/.great_cto/events.jsonl through
4
+ // scripts/lib/agent-events.mjs; this reads them for one project, and gives the
5
+ // watcher a cheap stamp to notice that something new arrived.
6
+ //
7
+ // Three states come straight from the reader and are passed through unchanged:
8
+ // `none` (no file — no events recorded, which is not an idle agent), `live`, and
9
+ // `unreadable` (a file the board could not read — not zero events). `attention`
10
+ // is the pipeline events whose outcome means the chain did not move: on
11
+ // 2026-09-14 a run stopped on `no-rule` and nothing on the board said so.
12
+
13
+ import fs from 'fs';
14
+ import path from 'path';
15
+ import { readEvents, readEventsSince, EVENTS_FILE } from '../../../scripts/lib/agent-events.mjs';
16
+
17
+ /** Journal outcomes after which the pipeline did not move on. */
18
+ export const STALLED_OUTCOMES = Object.freeze(['no-rule', 'no-verdict', 'unknown-verdict', 'no-map', 'breaker', 'blocked-budget']);
19
+
20
+ export function agentActivity(cwd, { limit = 20 } = {}) {
21
+ const r = readEvents(path.join(cwd, '.great_cto'), { limit });
22
+ const attention = r.events.filter((e) => e.kind === 'pipeline' && STALLED_OUTCOMES.includes(e.outcome)).slice(-3);
23
+ return { state: r.state, why: r.why ?? null, events: r.events, attention, bad: r.bad ?? 0 };
24
+ }
25
+
26
+ /**
27
+ * The same, resumable (ADR-021 phase 2): events after `cursor` as a `delta`, or a
28
+ * `snapshot` when the cursor no longer points into this project's file. `cursor` is
29
+ * what the next call should pass; `gap` says a snapshot replaced more events than a
30
+ * replay sends. `attention` covers only the events returned — the page merges.
31
+ */
32
+ export function agentActivitySince(cwd, cursor, { limit = 20 } = {}) {
33
+ const r = readEventsSince(path.join(cwd, '.great_cto'), cursor, { limit });
34
+ const attention = r.events.filter((e) => e.kind === 'pipeline' && STALLED_OUTCOMES.includes(e.outcome)).slice(-3);
35
+ return {
36
+ state: r.state, mode: r.mode ?? 'snapshot', why: r.why ?? null, events: r.events, attention,
37
+ bad: r.bad ?? 0, cursor: r.cursor, gap: r.gap === true,
38
+ };
39
+ }
40
+
41
+ /**
42
+ * What the watcher compares between polls. `none` when there is no file; the
43
+ * error code when it cannot be stat'ed, so a file that turns unreadable is a
44
+ * change too.
45
+ */
46
+ export function activityStamp(cwd) {
47
+ try {
48
+ const s = fs.statSync(path.join(cwd, '.great_cto', EVENTS_FILE));
49
+ return `${s.size}:${s.mtimeMs}`;
50
+ } catch (err) {
51
+ return err?.code === 'ENOENT' ? 'none' : `error:${err?.code || 'unknown'}`;
52
+ }
53
+ }
@@ -0,0 +1,151 @@
1
+ // Gate approval tokens (ADR-024 §1).
2
+ //
3
+ // POST /api/gates/:id closed a gate, logged it, woke the pipeline and, with sharing
4
+ // on, republished the public report — guarded by the Host allowlist and the Origin
5
+ // check alone. The controlled Codex host already refused an approval without the
6
+ // token issued for that pending gate, or after the tree changed; the board did not.
7
+ //
8
+ // A token here is minted when the board lists a pending gate, is single-use,
9
+ // expires, and is bound to the project tree OUTSIDE .great_cto/ as it was when the
10
+ // board first showed the gate. .great_cto/ is excluded because an approval writes
11
+ // the pipeline's own files there (gate row, decision log, wake record, this store),
12
+ // and `init` does not gitignore it: bound to the whole tree, the first approval
13
+ // would invalidate every other open gate though no reviewed code moved.
14
+ //
15
+ // Every check fails closed and says why. A store that cannot be read issues no
16
+ // tokens and accepts none — it is never silently overwritten.
17
+
18
+ import fs from 'fs';
19
+ import path from 'path';
20
+ import { randomUUID } from 'crypto';
21
+ import { treeReceipt, receiptHash, compareReceipts } from '../../../scripts/lib/receipt.mjs';
22
+
23
+ export const TOKENS_FILE = 'gate-tokens.json';
24
+ export const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000;
25
+ // Why these are outside the binding: both are the pipeline's own state, and a
26
+ // decision itself writes to them — .great_cto/ gets the gate row, decision log, wake
27
+ // and this store; .beads/ gets `bd update`'s interactions.jsonl. Counting either would
28
+ // let one approval refuse every other open gate as "changed". .beads/ was found by
29
+ // tests/pipeline-e2e.test.mjs: approving gate:plan made gate:ship read stale.
30
+ export const BINDING_EXCLUDE = Object.freeze(['.great_cto', '.beads']);
31
+
32
+ /** Test seam: GREAT_CTO_GATE_TOKEN_TTL_MS; production is 24 h. */
33
+ export function tokenTtlMs(env = process.env) {
34
+ const n = Number(env.GREAT_CTO_GATE_TOKEN_TTL_MS);
35
+ return Number.isFinite(n) && n > 0 ? n : DEFAULT_TTL_MS;
36
+ }
37
+
38
+ const storePath = (cwd) => path.join(cwd, '.great_cto', TOKENS_FILE);
39
+
40
+ /** @returns {{state:'none'|'ok'|'unreadable', tokens:object, why?:string}} */
41
+ export function readTokens(cwd) {
42
+ let raw;
43
+ try { raw = fs.readFileSync(storePath(cwd), 'utf8'); }
44
+ catch (err) {
45
+ if (err?.code === 'ENOENT') return { state: 'none', tokens: {} };
46
+ return { state: 'unreadable', tokens: {}, why: String(err?.code || err?.message || err) };
47
+ }
48
+ try {
49
+ const parsed = JSON.parse(raw);
50
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw Error('not an object');
51
+ return { state: 'ok', tokens: parsed };
52
+ } catch (err) {
53
+ return { state: 'unreadable', tokens: {}, why: `the token store could not be parsed: ${err.message}` };
54
+ }
55
+ }
56
+
57
+ function writeTokens(cwd, tokens) {
58
+ fs.mkdirSync(path.join(cwd, '.great_cto'), { recursive: true });
59
+ const file = storePath(cwd);
60
+ const tmp = `${file}.${process.pid}.tmp`;
61
+ fs.writeFileSync(tmp, JSON.stringify(tokens, null, 2) + '\n', { mode: 0o600 });
62
+ fs.renameSync(tmp, file);
63
+ }
64
+
65
+ /** The tree a token is bound to: the project outside .great_cto/. Null outside git. */
66
+ export function bindingReceipt(cwd) {
67
+ return treeReceipt(cwd, { exclude: BINDING_EXCLUDE });
68
+ }
69
+
70
+ /**
71
+ * Tokens for the pending gates, minting what is missing or expired.
72
+ *
73
+ * `gates` is the FULL pending list — entries for gates no longer pending are
74
+ * dropped. At most one receipt is computed per call (about 300 ms on a real tree),
75
+ * and only when something has to be minted.
76
+ *
77
+ * @param {{id:string, gate?:string, guarded?:boolean}[]} gates
78
+ * @returns {{state:'ok'|'unreadable', tokens:Record<string,string>, why?:string}}
79
+ */
80
+ export function issueTokens(cwd, gates, { now = Date.now(), env = process.env } = {}) {
81
+ const store = readTokens(cwd);
82
+ if (store.state === 'unreadable') return { state: 'unreadable', tokens: {}, why: store.why };
83
+ const ttl = tokenTtlMs(env);
84
+ const pending = new Map((gates || []).filter((g) => g && typeof g.id === 'string' && g.id).map((g) => [g.id, g]));
85
+ const next = {};
86
+ const toMint = [];
87
+ for (const [id, g] of pending) {
88
+ const e = store.tokens[id];
89
+ if (e && typeof e.token === 'string' && Number.isFinite(e.firstSeenAt) && now - e.firstSeenAt <= ttl) next[id] = e;
90
+ else toMint.push(g);
91
+ }
92
+ if (toMint.length) {
93
+ const receipt = bindingReceipt(cwd);
94
+ const hash = receiptHash(receipt);
95
+ for (const g of toMint) {
96
+ next[g.id] = {
97
+ token: randomUUID(), firstSeenAt: now, receiptHash: hash, receipt,
98
+ gate: typeof g.gate === 'string' ? g.gate : '', guarded: g.guarded === true,
99
+ };
100
+ }
101
+ }
102
+ const changed = toMint.length || Object.keys(store.tokens).some((id) => !pending.has(id));
103
+ if (changed) writeTokens(cwd, next);
104
+ return { state: 'ok', tokens: Object.fromEntries(Object.entries(next).map(([id, e]) => [id, e.token])) };
105
+ }
106
+
107
+ /**
108
+ * Is `token` the live token for gate `id`?
109
+ * @returns {{ok:true, entry:object} | {ok:false, status:number, outcome:string, why:string}}
110
+ */
111
+ export function checkToken(cwd, id, token, { now = Date.now(), env = process.env } = {}) {
112
+ const store = readTokens(cwd);
113
+ if (store.state === 'unreadable') {
114
+ return { ok: false, status: 403, outcome: 'refused-unreadable', why: `approval tokens could not be read: ${store.why}` };
115
+ }
116
+ const entry = store.tokens[id];
117
+ if (!entry || typeof token !== 'string' || !token || token !== entry.token) {
118
+ return { ok: false, status: 403, outcome: 'refused-token', why: 'no valid approval token for this gate — reload the inbox and try again' };
119
+ }
120
+ if (!Number.isFinite(entry.firstSeenAt) || now - entry.firstSeenAt > tokenTtlMs(env)) {
121
+ return { ok: false, status: 403, outcome: 'refused-expired', why: 'this approval token has expired — reload the inbox to see the gate as it is now' };
122
+ }
123
+ return { ok: true, entry };
124
+ }
125
+
126
+ /**
127
+ * Is the tree outside .great_cto/ still the one the gate was shown with?
128
+ * @returns {{ok:true, bound:boolean} | {ok:false, status:409, outcome:'refused-stale', why:string, changed:string[]}}
129
+ */
130
+ export function checkBinding(cwd, entry) {
131
+ const current = bindingReceipt(cwd);
132
+ if (!entry.receipt && !current) return { ok: true, bound: false }; // not a git repository: nothing to bind to, and said so
133
+ if (receiptHash(current) === entry.receiptHash) return { ok: true, bound: true };
134
+ const cmp = compareReceipts(entry.receipt, current, { cwd });
135
+ const changed = [...(cmp.changed || []), ...(cmp.removed || []), ...(cmp.added || [])];
136
+ return {
137
+ ok: false, status: 409, outcome: 'refused-stale', changed,
138
+ why: changed.length
139
+ ? `the project changed since this gate was shown: ${changed.slice(0, 10).join(', ')}${changed.length > 10 ? ` and ${changed.length - 10} more` : ''}`
140
+ : 'the project changed since this gate was shown (a commit or an unreadable tree)',
141
+ };
142
+ }
143
+
144
+ /** Single use: drop the gate's token once it has been decided. */
145
+ export function consumeToken(cwd, id) {
146
+ const store = readTokens(cwd);
147
+ if (store.state !== 'ok' || !(id in store.tokens)) return;
148
+ const next = { ...store.tokens };
149
+ delete next[id];
150
+ writeTokens(cwd, next);
151
+ }
@@ -13,17 +13,31 @@ import { sseClients, notifHistory } from './state.mjs';
13
13
  import { autoRegisterProject, listProjects, resolveProjectCwd, resolveProjectInfo, getChangeTier, readProjectsRegistry, getRegistryDegradation } from './projects.mjs';
14
14
  import { readVerdictsWithHealth } from './verdicts.mjs';
15
15
  import { readScores, summarizeScores } from '../../../scripts/lib/scores.mjs';
16
- import { status as routerKeyStatus, writeKey as writeRouterKey } from '../../../scripts/lib/router-key.mjs';
16
+ import { status as routerKeyStatus, writeKey as writeRouterKey, verifyKey as verifyRouterKey } from '../../../scripts/lib/router-key.mjs';
17
+
18
+ // The last live check of the judge key, by fingerprint. In memory: a restart
19
+ // re-checks, which costs one free request.
20
+ let routerKeyCheck = null;
17
21
  import { broadcastTasks } from './sse.mjs';
18
22
  import { saveNotifHistory } from './notifications.mjs';
19
23
  import { getMemory, getPipeline, getCostHistory, getInbox, inboxElsewhere } from './data-readers.mjs';
20
24
  import { log } from './log.mjs';
21
25
  import { bdCacheInvalidate, checkBeadsAvailable, bdWriteSerialised, bd, bdErr, getTasks, setTaskStatusInTasksMd, getReadDegradation } from './beads.mjs';
22
26
  import { getMetrics } from './metrics.mjs';
27
+ import { agentActivity, agentActivitySince } from './agent-activity.mjs';
28
+ import { issueTokens, checkToken, checkBinding, consumeToken } from './gate-tokens.mjs';
29
+ import { readTurns, turnPatch } from '../../../scripts/lib/turn-snapshot.mjs';
30
+ import { appendEvent } from '../../../scripts/lib/agent-events.mjs';
31
+
32
+ /** A gate decision — made or refused — as an agent event (ADR-024 §1). */
33
+ function recordGateEvent(cwd, id, outcome) {
34
+ return appendEvent(path.join(cwd, '.great_cto'), { kind: 'pipeline', agent: id, outcome });
35
+ }
23
36
  import { readVerdicts } from './verdicts.mjs';
24
37
  import { parseAgentBudgets, upsertAgentBudget, removeAgentBudget } from '../../../scripts/lib/agent-budget.mjs';
25
38
  import { resolveSecondOpinion, SECOND_OPINION_PROVIDERS } from '../../../scripts/lib/second-opinion.mjs';
26
39
  import { detectCodex } from '../../../scripts/lib/codex-exec.mjs';
40
+ import { listCodexRuns } from '../../../scripts/lib/codex-host-state.mjs';
27
41
  import { upsertCapability, capabilitiesFromProjectMd } from '../../../scripts/lib/stack-capabilities.mjs';
28
42
  // Moved to scripts/lib so the cross-review Stop hook can ask the same question
29
43
  // without importing a server. Re-exported here: this module's callers are unchanged.
@@ -92,6 +106,19 @@ async function dispatch(req, res, url, cwd) {
92
106
  res._gctoCwd = cwd; // remember which project this client wants
93
107
  sseClients.add(res);
94
108
  res.write(`event: tasks\ndata: ${JSON.stringify(getTasks(cwd))}\n\n`);
109
+ // ADR-021: the agent activity strip starts from a snapshot, then follows pushes.
110
+ // Phase 2: a client that hands back the cursor of the last frame it saw — the
111
+ // Last-Event-ID header, or ?since= from a page that had to recreate its
112
+ // EventSource — gets only what came after it. Each frame names its cursor.
113
+ let a;
114
+ try {
115
+ a = agentActivitySince(cwd, req.headers['last-event-id'] || url.searchParams.get('since') || null);
116
+ } catch (err) {
117
+ // Not "no events": the strip must say it could not measure them.
118
+ a = { state: 'unreadable', mode: 'snapshot', why: String(err?.message || err), events: [], attention: [], bad: 0, cursor: null, gap: false };
119
+ }
120
+ res._gctoAgentCursor = a.cursor;
121
+ res.write(`${a.cursor ? `id: ${a.cursor}\n` : ''}event: agent\ndata: ${JSON.stringify(a)}\n\n`);
95
122
  req.on('close', () => sseClients.delete(res));
96
123
  return true;
97
124
  }
@@ -520,6 +547,26 @@ async function dispatch(req, res, url, cwd) {
520
547
  res.end(JSON.stringify({ error: 'invalid action' }));
521
548
  return;
522
549
  }
550
+ // ADR-024 §1 — an approval is bound to one request and one state. Checked before
551
+ // anything is written: a refused request changes no gate, logs no decision,
552
+ // wakes nothing and publishes nothing. A rejection needs the token too (a forged
553
+ // reject is still a forged request) but is never refused for a changed tree.
554
+ const refuse = (r) => {
555
+ recordGateEvent(gateCwd, id, r.outcome);
556
+ res.writeHead(r.status, { 'Content-Type': 'application/json' });
557
+ res.end(JSON.stringify({ error: r.why, outcome: r.outcome, ...(r.changed ? { changed: r.changed } : {}) }));
558
+ };
559
+ const tok = checkToken(gateCwd, id, parsed.token);
560
+ if (!tok.ok) { refuse(tok); return; }
561
+ if (action === 'approve') {
562
+ // The typed-name ritual used to live only in the page; the server now agrees.
563
+ if (tok.entry.guarded && String(parsed.confirm ?? '').trim() !== tok.entry.gate) {
564
+ refuse({ status: 403, outcome: 'refused-confirm', why: `this gate is expensive to undo — type ${tok.entry.gate} to approve it` });
565
+ return;
566
+ }
567
+ const bound = checkBinding(gateCwd, tok.entry);
568
+ if (!bound.ok) { refuse(bound); return; }
569
+ }
523
570
  // A project can be tasks.md-backed (no working beads — e.g. its path
524
571
  // contains a space, which embedded-dolt can't open). Only 409 when there
525
572
  // is neither a beads store NOR a tasks.md to record the decision in.
@@ -610,6 +657,9 @@ async function dispatch(req, res, url, cwd) {
610
657
  res.end(JSON.stringify({ error: (result && result.error) || 'bd update failed' }));
611
658
  return;
612
659
  }
660
+ // Single use: the decision landed, so its token is spent.
661
+ consumeToken(gateCwd, id);
662
+ recordGateEvent(gateCwd, id, action === 'approve' ? 'approved' : 'rejected');
613
663
  // An approval is evidence that the pipeline is waiting — record it.
614
664
  //
615
665
  // `session-pipeline-resume` opens with a freshness shortcut: a pipeline
@@ -677,8 +727,19 @@ async function dispatch(req, res, url, cwd) {
677
727
  let elsewhere;
678
728
  try { elsewhere = inboxElsewhere(listProjects(), cwd, { readInbox: getInbox }); }
679
729
  catch (e) { elsewhere = { state: 'unreadable', why: String(e?.message || e) }; }
730
+ // ADR-024 §1: each pending gate carries the token an approval must present,
731
+ // bound to the project as it is now. The typed name stored with it is the one
732
+ // the page asks for; a store that cannot be read issues nothing and says so.
733
+ const gates = inbox.pending_gates || [];
734
+ const issued = issueTokens(cwd, gates.map((g) => ({
735
+ id: g.id,
736
+ gate: g.reversibility?.gate ? `gate:${g.reversibility.gate}` : g.id,
737
+ guarded: ['expensive', 'unclassified'].includes(g.reversibility?.state),
738
+ })));
739
+ const pending_gates = gates.map((g) => ({ ...g, token: issued.tokens[g.id] || null }));
740
+ const approval_tokens = { state: issued.state, ...(issued.why ? { why: issued.why } : {}) };
680
741
  res.writeHead(200, verdictHeaders(cwd, { 'Content-Type': 'application/json' }));
681
- res.end(JSON.stringify({ ...inbox, elsewhere, second_opinion }));
742
+ res.end(JSON.stringify({ ...inbox, pending_gates, approval_tokens, elsewhere, second_opinion }));
682
743
  return true;
683
744
  }
684
745
 
@@ -822,8 +883,31 @@ async function dispatch(req, res, url, cwd) {
822
883
  // single-page app a secret disclosure, and buys nothing: nobody needs to read
823
884
  // back a key they already hold.
824
885
  if (pathname === '/api/router-key' && req.method === 'GET') {
886
+ // The last live check rides along, but only for the key it checked: a replaced
887
+ // key must not inherit its predecessor's "verified". GET makes no network call.
888
+ const st = routerKeyStatus({ cwd });
889
+ const verification = routerKeyCheck && routerKeyCheck.fingerprint === st.fingerprint ? routerKeyCheck : null;
825
890
  res.writeHead(200, verdictHeaders(cwd, { 'Content-Type': 'application/json' }));
826
- res.end(JSON.stringify(routerKeyStatus({ cwd })));
891
+ res.end(JSON.stringify({ ...st, verification }));
892
+ return true;
893
+ }
894
+
895
+ // A live check against OpenRouter. POST and origin-checked like the write: it
896
+ // spends the key on an outbound request, so another page must not trigger it.
897
+ if (pathname === '/api/router-key/verify' && req.method === 'POST') {
898
+ if (!originAllowed(req)) {
899
+ res.writeHead(403, { 'Content-Type': 'application/json' });
900
+ res.end(JSON.stringify({ error: 'origin not allowed' }));
901
+ return true;
902
+ }
903
+ verifyRouterKey({ cwd }).then((r) => {
904
+ routerKeyCheck = r;
905
+ res.writeHead(200, { 'Content-Type': 'application/json' });
906
+ res.end(JSON.stringify(r));
907
+ }, (e) => {
908
+ res.writeHead(500, { 'Content-Type': 'application/json' });
909
+ res.end(JSON.stringify({ state: 'unreachable', reason: String(e.message || e) }));
910
+ });
827
911
  return true;
828
912
  }
829
913
 
@@ -902,12 +986,56 @@ async function dispatch(req, res, url, cwd) {
902
986
  }
903
987
 
904
988
  // Pipeline — current stage states (idle / active / done / failed)
989
+ // ADR-021: agent events for this project — three states, never an empty list
990
+ // that could mean either "nothing happened" or "could not read".
991
+ if (pathname === '/api/agent-events') {
992
+ let limit = parseInt(url.searchParams.get('limit') || '20', 10);
993
+ if (!Number.isFinite(limit) || limit < 1) limit = 20;
994
+ if (limit > 200) limit = 200;
995
+ res.writeHead(200, { 'Content-Type': 'application/json' });
996
+ res.end(JSON.stringify(agentActivity(cwd, { limit })));
997
+ return true;
998
+ }
999
+
1000
+ // ADR-023: what each agent turn changed. Turns are git refs in the project's own
1001
+ // repository; these read them and nothing else. A diff is file content, served only
1002
+ // on a Host the board knows (xq9h) and escaped by the page before it is drawn.
1003
+ if (pathname === '/api/turns') {
1004
+ let limit = parseInt(url.searchParams.get('limit') || '10', 10);
1005
+ if (!Number.isFinite(limit) || limit < 1) limit = 10;
1006
+ if (limit > 50) limit = 50;
1007
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1008
+ res.end(JSON.stringify(readTurns(cwd, { limit })));
1009
+ return true;
1010
+ }
1011
+ if (pathname === '/api/turns/diff') {
1012
+ const turnParam = url.searchParams.get('turn');
1013
+ const turn = /^\d{1,9}$/.test(turnParam || '') ? Number(turnParam) : NaN;
1014
+ const p = turnPatch(cwd, { session: url.searchParams.get('session') || '', turn });
1015
+ const status = p.state === 'ok' ? 200 : p.state === 'invalid' ? 400 : 404;
1016
+ res.writeHead(status, { 'Content-Type': 'application/json' });
1017
+ res.end(JSON.stringify(p));
1018
+ return true;
1019
+ }
1020
+
905
1021
  if (pathname === '/api/pipeline') {
906
1022
  res.writeHead(200, verdictHeaders(cwd, { 'Content-Type': 'application/json' }));
907
1023
  res.end(JSON.stringify(getPipeline(cwd)));
908
1024
  return true;
909
1025
  }
910
1026
 
1027
+ // Controlled Codex host runs are stored outside worker projects. Return the
1028
+ // shared sanitized projection only: prompts, approval tokens and artifact
1029
+ // bytes never cross into the browser payload.
1030
+ if (pathname === '/api/codex-runs') {
1031
+ const result = listCodexRuns({ root: cwd });
1032
+ const headers = { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' };
1033
+ if (result.state === 'degraded') headers['X-Board-Degraded'] = encodeURIComponent(`${result.unreadable} unreadable Codex run state file(s)`);
1034
+ res.writeHead(200, headers);
1035
+ res.end(JSON.stringify(result));
1036
+ return true;
1037
+ }
1038
+
911
1039
  // change_tier for the current working-tree diff — the gate + judge plan (ADR-003/004).
912
1040
  if (pathname === '/api/change-tier') {
913
1041
  res.writeHead(200, verdictHeaders(cwd, { 'Content-Type': 'application/json' }));
@@ -1,6 +1,6 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
- import { PORT } from './config.mjs';
3
+ import { PORT, HOST } from './config.mjs';
4
4
 
5
5
  /**
6
6
  * Escape a single value for inclusion in a CSV cell.
@@ -14,13 +14,41 @@ function csvCell(v) {
14
14
  }
15
15
 
16
16
  // Only same-origin (the board's own page) may make a state-changing request — a malicious page must not.
17
+ //
18
+ // "Same origin" used to mean: the Origin matches the Host this request arrived with.
19
+ // A DNS-rebinding page points its own domain at 127.0.0.1, so the browser sends
20
+ // Host: evil.test:PORT and Origin: http://evil.test:PORT — a perfect match. It could
21
+ // read every GET and pass this guard for POST, gate approvals included (great_cto-xq9h).
22
+ // So the Host itself must be a name the board was told about: loopback on its port,
23
+ // the concrete bind host, or an entry in GREAT_CTO_ALLOWED_HOSTS (a tunnel or hosted
24
+ // console). server.mjs checks it for every request; the Origin must name one too.
25
+ const WILDCARD_BINDS = new Set(['0.0.0.0', '::', '[::]', '']);
26
+
27
+ function parseAllowedHosts(raw = process.env.GREAT_CTO_ALLOWED_HOSTS) {
28
+ return String(raw || '').split(',').map((h) => h.trim().toLowerCase()).filter(Boolean);
29
+ }
30
+
31
+ function allowedHostList({ port = PORT, bindHost = HOST, extra = parseAllowedHosts() } = {}) {
32
+ const list = [`localhost:${port}`, `127.0.0.1:${port}`, `[::1]:${port}`];
33
+ const b = String(bindHost || '').toLowerCase();
34
+ if (!WILDCARD_BINDS.has(b)) list.push(`${b.includes(':') && !b.startsWith('[') ? `[${b}]` : b}:${port}`);
35
+ return [...list, ...extra];
36
+ }
37
+
38
+ function hostAllowed(hostHeader, opts = {}) {
39
+ if (typeof hostHeader !== 'string' || !hostHeader.trim()) return false;
40
+ return allowedHostList(opts).includes(hostHeader.trim().toLowerCase());
41
+ }
42
+
17
43
  function originAllowed(req) {
18
44
  const o = req.headers.origin || req.headers.referer || '';
19
45
  if (!o) return true; // same-origin fetch / curl with no Origin
20
- // True same-origin: the browser's Origin matches the host this request arrived on
21
- // (covers a tunnelled/hosted console at console.client.com, http or https).
22
- const self = req.headers.host ? [`http://${req.headers.host}`, `https://${req.headers.host}`] : [];
23
- return [`http://localhost:${PORT}`, `http://127.0.0.1:${PORT}`, ...self].some((e) => o === e || o.startsWith(e + '/'));
46
+ let u;
47
+ try { u = new URL(o); } catch { return false; }
48
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') return false;
49
+ // u.host keeps an explicit port and drops a default one (https://x → "x"), which is
50
+ // how a tunnel host is listed. Never "whatever Host this request came with".
51
+ return allowedHostList().includes(u.host.toLowerCase());
24
52
  }
25
53
 
26
54
  // Which surface an alert belongs to. Operate-side events (autopilot runtime: dead-letters,
@@ -93,4 +121,4 @@ function isInsideDir(base, target) {
93
121
  return resolvedTarget === resolvedBase || resolvedTarget.startsWith(resolvedBase + path.sep);
94
122
  }
95
123
 
96
- export { csvCell, originAllowed, eventSurface, readFileSafe, readSafe, parseSafe, isInsideDir };
124
+ export { csvCell, originAllowed, hostAllowed, allowedHostList, parseAllowedHosts, eventSurface, readFileSafe, readSafe, parseSafe, isInsideDir };
@@ -5,6 +5,7 @@ import { sseClients } from './state.mjs';
5
5
  import { listProjects } from './projects.mjs';
6
6
  import { bdCacheStale, getTasks, isSelfInflictedTouch } from './beads.mjs';
7
7
  import { getPipeline, getInbox } from './data-readers.mjs';
8
+ import { agentActivitySince, activityStamp } from './agent-activity.mjs';
8
9
 
9
10
  // ── File watcher ───────────────────────────────────────────────────────────────
10
11
  function watchBeads() {
@@ -147,4 +148,36 @@ function watchVerdicts() {
147
148
  } catch {}
148
149
  }
149
150
 
150
- export { watchBeads, watchVerdicts };
151
+ // ADR-021: push agent events to the clients watching the project that wrote them.
152
+ //
153
+ // Polled, not fs.watch'd: events.jsonl is created on the first event and rotated
154
+ // at its cap, and a watch on a file that does not exist yet — or was just renamed
155
+ // away — silently watches nothing. One stat per watched project per second is
156
+ // cheap, and it meets the ADR's "on the board within two seconds".
157
+ function watchAgentEvents({ intervalMs = 1000 } = {}) {
158
+ const stamps = new Map();
159
+ const timer = setInterval(() => {
160
+ const cwds = new Set();
161
+ for (const res of sseClients) cwds.add(res._gctoCwd || process.cwd());
162
+ for (const cwd of cwds) {
163
+ const stamp = activityStamp(cwd);
164
+ if (stamps.get(cwd) === stamp) continue;
165
+ stamps.set(cwd, stamp);
166
+ // Phase 2: each client gets what came after ITS cursor — a delta, or a snapshot
167
+ // when its cursor stopped pointing into this file — and the frame names the new
168
+ // cursor. Nothing new for a client is nothing sent.
169
+ for (const res of sseClients) {
170
+ if ((res._gctoCwd || process.cwd()) !== cwd) continue;
171
+ let a;
172
+ try { a = agentActivitySince(cwd, res._gctoAgentCursor ?? null); } catch { continue; }
173
+ res._gctoAgentCursor = a.cursor;
174
+ if (a.mode === 'delta' && a.events.length === 0) continue;
175
+ try { res.write(`${a.cursor ? `id: ${a.cursor}\n` : ''}event: agent\ndata: ${JSON.stringify(a)}\n\n`); } catch { sseClients.delete(res); }
176
+ }
177
+ }
178
+ }, intervalMs);
179
+ if (typeof timer.unref === 'function') timer.unref();
180
+ return timer;
181
+ }
182
+
183
+ export { watchBeads, watchVerdicts, watchAgentEvents };