great-cto 3.28.9 → 3.29.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.
@@ -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.0",
5
5
  "author": {
6
6
  "name": "Alexander Velikiy",
7
7
  "url": "https://hashnode.com/@Greatcto"
@@ -121,6 +121,11 @@
121
121
  "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
122
  "timeout": 5,
123
123
  "statusMessage": "Three-state completion check..."
124
+ },
125
+ {
126
+ "type": "command",
127
+ "async": true,
128
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hooks/turn-snapshot.mjs\" 2>/dev/null || true"
124
129
  }
125
130
  ]
126
131
  }
@@ -272,7 +277,7 @@
272
277
  "hooks": [
273
278
  {
274
279
  "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",
280
+ "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
281
  "timeout": 3
277
282
  }
278
283
  ]
@@ -288,6 +293,11 @@
288
293
  {
289
294
  "type": "command",
290
295
  "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hooks/cross-review-gate.mjs\" 2>/dev/null || true"
296
+ },
297
+ {
298
+ "type": "command",
299
+ "async": true,
300
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hooks/turn-snapshot.mjs\" 2>/dev/null || true"
291
301
  }
292
302
  ]
293
303
  }
@@ -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
+ }
@@ -20,10 +20,20 @@ import { getMemory, getPipeline, getCostHistory, getInbox, inboxElsewhere } from
20
20
  import { log } from './log.mjs';
21
21
  import { bdCacheInvalidate, checkBeadsAvailable, bdWriteSerialised, bd, bdErr, getTasks, setTaskStatusInTasksMd, getReadDegradation } from './beads.mjs';
22
22
  import { getMetrics } from './metrics.mjs';
23
+ import { agentActivity, agentActivitySince } from './agent-activity.mjs';
24
+ import { issueTokens, checkToken, checkBinding, consumeToken } from './gate-tokens.mjs';
25
+ import { readTurns, turnPatch } from '../../../scripts/lib/turn-snapshot.mjs';
26
+ import { appendEvent } from '../../../scripts/lib/agent-events.mjs';
27
+
28
+ /** A gate decision — made or refused — as an agent event (ADR-024 §1). */
29
+ function recordGateEvent(cwd, id, outcome) {
30
+ return appendEvent(path.join(cwd, '.great_cto'), { kind: 'pipeline', agent: id, outcome });
31
+ }
23
32
  import { readVerdicts } from './verdicts.mjs';
24
33
  import { parseAgentBudgets, upsertAgentBudget, removeAgentBudget } from '../../../scripts/lib/agent-budget.mjs';
25
34
  import { resolveSecondOpinion, SECOND_OPINION_PROVIDERS } from '../../../scripts/lib/second-opinion.mjs';
26
35
  import { detectCodex } from '../../../scripts/lib/codex-exec.mjs';
36
+ import { listCodexRuns } from '../../../scripts/lib/codex-host-state.mjs';
27
37
  import { upsertCapability, capabilitiesFromProjectMd } from '../../../scripts/lib/stack-capabilities.mjs';
28
38
  // Moved to scripts/lib so the cross-review Stop hook can ask the same question
29
39
  // without importing a server. Re-exported here: this module's callers are unchanged.
@@ -92,6 +102,19 @@ async function dispatch(req, res, url, cwd) {
92
102
  res._gctoCwd = cwd; // remember which project this client wants
93
103
  sseClients.add(res);
94
104
  res.write(`event: tasks\ndata: ${JSON.stringify(getTasks(cwd))}\n\n`);
105
+ // ADR-021: the agent activity strip starts from a snapshot, then follows pushes.
106
+ // Phase 2: a client that hands back the cursor of the last frame it saw — the
107
+ // Last-Event-ID header, or ?since= from a page that had to recreate its
108
+ // EventSource — gets only what came after it. Each frame names its cursor.
109
+ let a;
110
+ try {
111
+ a = agentActivitySince(cwd, req.headers['last-event-id'] || url.searchParams.get('since') || null);
112
+ } catch (err) {
113
+ // Not "no events": the strip must say it could not measure them.
114
+ a = { state: 'unreadable', mode: 'snapshot', why: String(err?.message || err), events: [], attention: [], bad: 0, cursor: null, gap: false };
115
+ }
116
+ res._gctoAgentCursor = a.cursor;
117
+ res.write(`${a.cursor ? `id: ${a.cursor}\n` : ''}event: agent\ndata: ${JSON.stringify(a)}\n\n`);
95
118
  req.on('close', () => sseClients.delete(res));
96
119
  return true;
97
120
  }
@@ -520,6 +543,26 @@ async function dispatch(req, res, url, cwd) {
520
543
  res.end(JSON.stringify({ error: 'invalid action' }));
521
544
  return;
522
545
  }
546
+ // ADR-024 §1 — an approval is bound to one request and one state. Checked before
547
+ // anything is written: a refused request changes no gate, logs no decision,
548
+ // wakes nothing and publishes nothing. A rejection needs the token too (a forged
549
+ // reject is still a forged request) but is never refused for a changed tree.
550
+ const refuse = (r) => {
551
+ recordGateEvent(gateCwd, id, r.outcome);
552
+ res.writeHead(r.status, { 'Content-Type': 'application/json' });
553
+ res.end(JSON.stringify({ error: r.why, outcome: r.outcome, ...(r.changed ? { changed: r.changed } : {}) }));
554
+ };
555
+ const tok = checkToken(gateCwd, id, parsed.token);
556
+ if (!tok.ok) { refuse(tok); return; }
557
+ if (action === 'approve') {
558
+ // The typed-name ritual used to live only in the page; the server now agrees.
559
+ if (tok.entry.guarded && String(parsed.confirm ?? '').trim() !== tok.entry.gate) {
560
+ refuse({ status: 403, outcome: 'refused-confirm', why: `this gate is expensive to undo — type ${tok.entry.gate} to approve it` });
561
+ return;
562
+ }
563
+ const bound = checkBinding(gateCwd, tok.entry);
564
+ if (!bound.ok) { refuse(bound); return; }
565
+ }
523
566
  // A project can be tasks.md-backed (no working beads — e.g. its path
524
567
  // contains a space, which embedded-dolt can't open). Only 409 when there
525
568
  // is neither a beads store NOR a tasks.md to record the decision in.
@@ -610,6 +653,9 @@ async function dispatch(req, res, url, cwd) {
610
653
  res.end(JSON.stringify({ error: (result && result.error) || 'bd update failed' }));
611
654
  return;
612
655
  }
656
+ // Single use: the decision landed, so its token is spent.
657
+ consumeToken(gateCwd, id);
658
+ recordGateEvent(gateCwd, id, action === 'approve' ? 'approved' : 'rejected');
613
659
  // An approval is evidence that the pipeline is waiting — record it.
614
660
  //
615
661
  // `session-pipeline-resume` opens with a freshness shortcut: a pipeline
@@ -677,8 +723,19 @@ async function dispatch(req, res, url, cwd) {
677
723
  let elsewhere;
678
724
  try { elsewhere = inboxElsewhere(listProjects(), cwd, { readInbox: getInbox }); }
679
725
  catch (e) { elsewhere = { state: 'unreadable', why: String(e?.message || e) }; }
726
+ // ADR-024 §1: each pending gate carries the token an approval must present,
727
+ // bound to the project as it is now. The typed name stored with it is the one
728
+ // the page asks for; a store that cannot be read issues nothing and says so.
729
+ const gates = inbox.pending_gates || [];
730
+ const issued = issueTokens(cwd, gates.map((g) => ({
731
+ id: g.id,
732
+ gate: g.reversibility?.gate ? `gate:${g.reversibility.gate}` : g.id,
733
+ guarded: ['expensive', 'unclassified'].includes(g.reversibility?.state),
734
+ })));
735
+ const pending_gates = gates.map((g) => ({ ...g, token: issued.tokens[g.id] || null }));
736
+ const approval_tokens = { state: issued.state, ...(issued.why ? { why: issued.why } : {}) };
680
737
  res.writeHead(200, verdictHeaders(cwd, { 'Content-Type': 'application/json' }));
681
- res.end(JSON.stringify({ ...inbox, elsewhere, second_opinion }));
738
+ res.end(JSON.stringify({ ...inbox, pending_gates, approval_tokens, elsewhere, second_opinion }));
682
739
  return true;
683
740
  }
684
741
 
@@ -902,12 +959,56 @@ async function dispatch(req, res, url, cwd) {
902
959
  }
903
960
 
904
961
  // Pipeline — current stage states (idle / active / done / failed)
962
+ // ADR-021: agent events for this project — three states, never an empty list
963
+ // that could mean either "nothing happened" or "could not read".
964
+ if (pathname === '/api/agent-events') {
965
+ let limit = parseInt(url.searchParams.get('limit') || '20', 10);
966
+ if (!Number.isFinite(limit) || limit < 1) limit = 20;
967
+ if (limit > 200) limit = 200;
968
+ res.writeHead(200, { 'Content-Type': 'application/json' });
969
+ res.end(JSON.stringify(agentActivity(cwd, { limit })));
970
+ return true;
971
+ }
972
+
973
+ // ADR-023: what each agent turn changed. Turns are git refs in the project's own
974
+ // repository; these read them and nothing else. A diff is file content, served only
975
+ // on a Host the board knows (xq9h) and escaped by the page before it is drawn.
976
+ if (pathname === '/api/turns') {
977
+ let limit = parseInt(url.searchParams.get('limit') || '10', 10);
978
+ if (!Number.isFinite(limit) || limit < 1) limit = 10;
979
+ if (limit > 50) limit = 50;
980
+ res.writeHead(200, { 'Content-Type': 'application/json' });
981
+ res.end(JSON.stringify(readTurns(cwd, { limit })));
982
+ return true;
983
+ }
984
+ if (pathname === '/api/turns/diff') {
985
+ const turnParam = url.searchParams.get('turn');
986
+ const turn = /^\d{1,9}$/.test(turnParam || '') ? Number(turnParam) : NaN;
987
+ const p = turnPatch(cwd, { session: url.searchParams.get('session') || '', turn });
988
+ const status = p.state === 'ok' ? 200 : p.state === 'invalid' ? 400 : 404;
989
+ res.writeHead(status, { 'Content-Type': 'application/json' });
990
+ res.end(JSON.stringify(p));
991
+ return true;
992
+ }
993
+
905
994
  if (pathname === '/api/pipeline') {
906
995
  res.writeHead(200, verdictHeaders(cwd, { 'Content-Type': 'application/json' }));
907
996
  res.end(JSON.stringify(getPipeline(cwd)));
908
997
  return true;
909
998
  }
910
999
 
1000
+ // Controlled Codex host runs are stored outside worker projects. Return the
1001
+ // shared sanitized projection only: prompts, approval tokens and artifact
1002
+ // bytes never cross into the browser payload.
1003
+ if (pathname === '/api/codex-runs') {
1004
+ const result = listCodexRuns({ root: cwd });
1005
+ const headers = { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' };
1006
+ if (result.state === 'degraded') headers['X-Board-Degraded'] = encodeURIComponent(`${result.unreadable} unreadable Codex run state file(s)`);
1007
+ res.writeHead(200, headers);
1008
+ res.end(JSON.stringify(result));
1009
+ return true;
1010
+ }
1011
+
911
1012
  // change_tier for the current working-tree diff — the gate + judge plan (ADR-003/004).
912
1013
  if (pathname === '/api/change-tier') {
913
1014
  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 };