frizz 0.12.13 → 0.13.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.
- package/dist/frizz.js +872 -369
- package/package.json +13 -13
- package/dist/claude-agent-broker.js +0 -35911
- package/dist/codex-app-server-daemon.js +0 -423
- package/dist/dev-child.js +0 -56194
- package/runtime/board/agent-bindings.mjs +0 -287
- package/runtime/board/agent-liveness.mjs +0 -367
- package/runtime/board/agent-status.mjs +0 -178
- package/runtime/board/config.mjs +0 -993
- package/runtime/board/decisions.mjs +0 -97
- package/runtime/board/index.mjs +0 -704
- package/runtime/board/notify-shared.mjs +0 -90
- package/runtime/board/notify.mjs +0 -81
- package/runtime/board/ownership.mjs +0 -120
- package/runtime/board/rest-detect.mjs +0 -213
- package/runtime/board/thread-excerpt.mjs +0 -162
- package/runtime/board/thread-update.mjs +0 -289
- package/runtime/cc-worker/.claude-plugin/plugin.json +0 -10
- package/runtime/cc-worker/DECISIONS.md +0 -1172
- package/runtime/cc-worker/LICENSE +0 -21
- package/runtime/cc-worker/agents/high.md +0 -7
- package/runtime/cc-worker/agents/low.md +0 -7
- package/runtime/cc-worker/agents/max.md +0 -7
- package/runtime/cc-worker/agents/medium.md +0 -7
- package/runtime/cc-worker/agents/xhigh.md +0 -7
- package/runtime/cc-worker/bin/frizz +0 -17
- package/runtime/cc-worker/bin/frizz-mcp.mjs +0 -1612
- package/runtime/cc-worker/bin/frizz-update +0 -18
- package/runtime/cc-worker/hooks/agent-bind.mjs +0 -40
- package/runtime/cc-worker/hooks/agent-dispatch.mjs +0 -121
- package/runtime/cc-worker/hooks/bash-background.d.mts +0 -6
- package/runtime/cc-worker/hooks/bash-background.mjs +0 -247
- package/runtime/cc-worker/hooks/deny-ask.mjs +0 -39
- package/runtime/cc-worker/hooks/deny-plan.mjs +0 -62
- package/runtime/cc-worker/hooks/hooks.json +0 -102
- package/runtime/cc-worker/hooks/perm-policy.mjs +0 -211
- package/runtime/cc-worker/hooks/scratchpad.mjs +0 -417
- package/runtime/cc-worker/hooks/session-seed.mjs +0 -107
- package/runtime/cc-worker/scripts/frizz/agent-bindings.mjs +0 -9
- package/runtime/cc-worker/scripts/frizz/config.mjs +0 -12
- package/runtime/cc-worker/skills/gh/SKILL.md +0 -141
- package/runtime/cc-worker/skills/gh/scripts/ci-watch.mjs +0 -60
- package/runtime/cc-worker/skills/gh/scripts/github-watch.mjs +0 -130
- package/runtime/cc-worker/skills/gh/scripts/review-watch.mjs +0 -54
- package/web-dist/apple-touch-icon.png +0 -0
- package/web-dist/assets/TerminalPane-Cv0c_SNS.js +0 -7
- package/web-dist/assets/abnfDiagram-VRR7QNED-B-wED8ht.js +0 -1
- package/web-dist/assets/arc-BSyeo0Gb.js +0 -1
- package/web-dist/assets/architecture-TIHT7OUA-nsH-QfGr.js +0 -1
- package/web-dist/assets/architectureDiagram-ZJ3FMSHR-kHm_O2sR.js +0 -36
- package/web-dist/assets/array-BifhSqXX.js +0 -1
- package/web-dist/assets/blockDiagram-677ZJIJ3-CdazUaKU.js +0 -132
- package/web-dist/assets/c4Diagram-LMCZKHZV-rauLKZbJ.js +0 -10
- package/web-dist/assets/channel-fvwjeUt3.js +0 -1
- package/web-dist/assets/chunk-2Q5K7J3B-C1jixKkw.js +0 -1
- package/web-dist/assets/chunk-32BRIVSS-DjOqyEDX.js +0 -1
- package/web-dist/assets/chunk-52WLFC77-f1SThegZ.js +0 -10
- package/web-dist/assets/chunk-5VM5RSS4-ZNzvKenW.js +0 -15
- package/web-dist/assets/chunk-7BUUIJ7U-Bb538aSH.js +0 -1
- package/web-dist/assets/chunk-C7G6YPKG-BfQsesjz.js +0 -1
- package/web-dist/assets/chunk-EX3LRPZG-BIzZW7OQ.js +0 -231
- package/web-dist/assets/chunk-FWX5IMBZ-CATINZFC.js +0 -2
- package/web-dist/assets/chunk-HOUHSVGY-DbMBRCCE.js +0 -1
- package/web-dist/assets/chunk-ICXQ74PX-COcoSWx4.js +0 -2
- package/web-dist/assets/chunk-JWPE2WC7-DVXcaiue.js +0 -1
- package/web-dist/assets/chunk-KEIR6QF5-BfrZ3jm6.js +0 -161
- package/web-dist/assets/chunk-MOJQB5TN-CVnI-o_4.js +0 -88
- package/web-dist/assets/chunk-OGEWGWER-U0vdZqB6.js +0 -1
- package/web-dist/assets/chunk-PUDLZKDR-Bukb7rem.js +0 -156
- package/web-dist/assets/chunk-Q4XR5HBZ-Bl019PHd.js +0 -70
- package/web-dist/assets/chunk-RYQCIY6F-Cu_KplZW.js +0 -1
- package/web-dist/assets/chunk-V7JOEXUC-YJR0vzI6.js +0 -206
- package/web-dist/assets/chunk-VAUOI2AC-CK6NU9kj.js +0 -1
- package/web-dist/assets/chunk-VR4S4FIN-B3LBv3C3.js +0 -1
- package/web-dist/assets/chunk-WYO6CB5R-DjRPyRuS.js +0 -127
- package/web-dist/assets/chunk-XXDRQBXY-DYlTP5J-.js +0 -1
- package/web-dist/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +0 -1
- package/web-dist/assets/chunk-ZGVPDNZ5-BUJCayKP.js +0 -62
- package/web-dist/assets/chunk-ZIRB5QZD-C6fEPe3t.js +0 -32
- package/web-dist/assets/classDiagram-OUVF2IWQ-BRZQ_xnN.js +0 -1
- package/web-dist/assets/classDiagram-v2-EOCWNBFH-BRZQ_xnN.js +0 -1
- package/web-dist/assets/cose-bilkent-JH36ORCC-BUIsLrGc.js +0 -1
- package/web-dist/assets/cynefin-VYW2F7L2-CE3ce51v.js +0 -1
- package/web-dist/assets/cynefinDiagram-TSTJHNR4-DSXSPZQV.js +0 -62
- package/web-dist/assets/cytoscape.esm-B3I8pqwA.js +0 -321
- package/web-dist/assets/dagre-CXRCoUWR.js +0 -1
- package/web-dist/assets/dagre-VKFMJZFB-VwBQdjum.js +0 -4
- package/web-dist/assets/defaultLocale-C8Fc0cco.js +0 -1
- package/web-dist/assets/diagram-FQU43EPY-7Tf5_WDu.js +0 -3
- package/web-dist/assets/diagram-G47NLZAW-mu4aclWA.js +0 -24
- package/web-dist/assets/diagram-NH7WQ7WH-B3kAVNwv.js +0 -24
- package/web-dist/assets/diagram-OA4YK3LP-DAaV-2ou.js +0 -30
- package/web-dist/assets/diagram-WEI45ONY-DijPfgly.js +0 -41
- package/web-dist/assets/dist-DoH_9pyS.js +0 -1
- package/web-dist/assets/ebnfDiagram-CCIWWBDH-B1o42Wp-.js +0 -1
- package/web-dist/assets/erDiagram-Q63AITRT-Dqej9Wi3.js +0 -85
- package/web-dist/assets/eventmodeling-45OFAUF4-B8xeGcXU.js +0 -1
- package/web-dist/assets/flowDiagram-23GEKE2U-Cnyfcz6Z.js +0 -1
- package/web-dist/assets/ganttDiagram-NO4QXBWP-UoyrLPUT.js +0 -292
- package/web-dist/assets/gitGraph-TEB2WS4Q-CwjIwQ3T.js +0 -1
- package/web-dist/assets/gitGraphDiagram-IHSO6WYX-BxU758_g.js +0 -106
- package/web-dist/assets/graphlib-B8gBHxth.js +0 -1
- package/web-dist/assets/index-CT6k_A5y.css +0 -1
- package/web-dist/assets/index-k8HCkyvh.js +0 -319
- package/web-dist/assets/info-DKCQHKI2-CWtSI6aK.js +0 -1
- package/web-dist/assets/infoDiagram-FWYZ7A6U-n5xRfy_d.js +0 -2
- package/web-dist/assets/init-D6jRqBbL.js +0 -1
- package/web-dist/assets/ishikawaDiagram-FXEZZL3T-DU1LTQY-.js +0 -70
- package/web-dist/assets/journeyDiagram-5HDEW3XC-BzZWHoPv.js +0 -139
- package/web-dist/assets/kanban-definition-HUTT4EX6-CeneyR7c.js +0 -89
- package/web-dist/assets/katex-CddkPoXu.js +0 -257
- package/web-dist/assets/line-DTfkLWnw.js +0 -1
- package/web-dist/assets/linear-z2V0wJk9.js +0 -1
- package/web-dist/assets/map-DsCK-0Cs.js +0 -1
- package/web-dist/assets/mermaid-parser.core-CHzoJ5LT.js +0 -7
- package/web-dist/assets/mermaid.core-Cl84DLN2.js +0 -11
- package/web-dist/assets/mindmap-definition-LN4V7U3C-C39sBNqB.js +0 -96
- package/web-dist/assets/ordinal-hYBb2elL.js +0 -1
- package/web-dist/assets/packet-7NZHBO7P-Bj73rlQn.js +0 -1
- package/web-dist/assets/path-BWPyau1x.js +0 -1
- package/web-dist/assets/pegDiagram-2B236MQR-BEEM1vSv.js +0 -1
- package/web-dist/assets/pie-RZYD4A2V-CJ-fEfdy.js +0 -1
- package/web-dist/assets/pieDiagram-ENE6RG2P-Dcqj3oyh.js +0 -39
- package/web-dist/assets/quadrantDiagram-ABIIQ3AL-D5cljQ3D.js +0 -7
- package/web-dist/assets/radar-I7S5WNFK-G8JqqGPf.js +0 -1
- package/web-dist/assets/railroad-3IZDKUUU-CaGNL0iN.js +0 -1
- package/web-dist/assets/railroad-abnf-AHOZXSZD-DWZmdy-r.js +0 -1
- package/web-dist/assets/railroad-ebnf-EBAXGLYW-C-XoPuo_.js +0 -1
- package/web-dist/assets/railroad-peg-LSFZ7HO6-BGpxqg46.js +0 -1
- package/web-dist/assets/railroadDiagram-RFXS5EU6-BZGZXMY4.js +0 -1
- package/web-dist/assets/requirementDiagram-TGXJPOKE-Dj5yn_9e.js +0 -84
- package/web-dist/assets/rolldown-runtime-Bh1tDfsg.js +0 -1
- package/web-dist/assets/rough.esm-CSKSodPl.js +0 -1
- package/web-dist/assets/sankeyDiagram-HTMAVEWB-7iJ_NDNf.js +0 -40
- package/web-dist/assets/sequenceDiagram-DBY2YBRQ-Bl54CknA.js +0 -162
- package/web-dist/assets/sizeCapture-X5ZJPWSS-B0uUizjq.js +0 -1
- package/web-dist/assets/src-C4XfhTaE.js +0 -1
- package/web-dist/assets/stateDiagram-2N3HPSRC-DuvjeNgJ.js +0 -1
- package/web-dist/assets/stateDiagram-v2-6OUMAXLB-BR-vrMgH.js +0 -1
- package/web-dist/assets/swimlanes-5IMT3BWC-B7-Jj4Sk.js +0 -2
- package/web-dist/assets/swimlanesDiagram-G3AALYLV-C1HvMf2k.js +0 -8
- package/web-dist/assets/timeline-definition-FHXFAJF6-jsey2pEG.js +0 -120
- package/web-dist/assets/treeView-QDETBFTQ-DhPmOzHr.js +0 -1
- package/web-dist/assets/treemap-6X3UGDF4-Dw2IaXqK.js +0 -1
- package/web-dist/assets/vennDiagram-L72KCM5P-CXKZkBab.js +0 -34
- package/web-dist/assets/wardley-OPB4EBWU-CTCdDwyE.js +0 -1
- package/web-dist/assets/wardleyDiagram-EHGQE667-B1w0xIqB.js +0 -78
- package/web-dist/assets/xychartDiagram-FW5EYKEG-gULk-uIl.js +0 -7
- package/web-dist/favicon-16.png +0 -0
- package/web-dist/favicon-32.png +0 -0
- package/web-dist/favicon.svg +0 -34
- package/web-dist/icon-192.png +0 -0
- package/web-dist/icon-512.png +0 -0
- package/web-dist/icon-maskable-512.png +0 -0
- package/web-dist/index.html +0 -44
- package/web-dist/manifest.webmanifest +0 -16
|
@@ -1,287 +0,0 @@
|
|
|
1
|
-
// @ts-check
|
|
2
|
-
/**
|
|
3
|
-
* frizz — the AUTOMATIC, ephemeral thread↔agent binding.
|
|
4
|
-
*
|
|
5
|
-
* REPLACES the old hand-maintained `agents: [{id, label}]` thread frontmatter (a
|
|
6
|
-
* drift-prone ledger the orchestrator had to write by hand on every dispatch). The
|
|
7
|
-
* binding is now captured AUTOMATICALLY by the `agent-bind` hook (PostToolUse on the
|
|
8
|
-
* `Agent` tool): the Agent tool's RESULT carries the new `agentId`, the resolved
|
|
9
|
-
* `prompt` (with the `THREAD: <slug>` tag), and the `description` (a human label) — so
|
|
10
|
-
* the moment a background sub-agent is launched, the hook records `agentId → thread`
|
|
11
|
-
* here. The orchestrator records NOTHING by hand; there is no frontmatter to drift.
|
|
12
|
-
*
|
|
13
|
-
* EPHEMERAL ROUTING STATE, NOT DUPLICATED TRUTH. This file (`.frizz/.agent-bindings.jsonl`)
|
|
14
|
-
* is transient routing info under the already-gitignored `.frizz/` — the same class as
|
|
15
|
-
* `.dispatch-ledger.jsonl` / `.rested-agents.jsonl`. It maps an instance id to the thread
|
|
16
|
-
* it serves so consumers can RECONNECT a return/rest to its thread; it is never the source
|
|
17
|
-
* of a thread's truth (that's the thread's own `status:` + body). Append-only; a binding is
|
|
18
|
-
* immutable once written. Liveness/doneness is still DERIVED from ground truth (output-file
|
|
19
|
-
* mtime + thread status) — the binding only supplies the id↔thread mapping, never a state.
|
|
20
|
-
*
|
|
21
|
-
* FAIL-OPEN ABSOLUTELY: every reader/writer swallows errors. A missing/corrupt bindings
|
|
22
|
-
* file simply yields no bindings — the board and hooks degrade to "no agents to surface,"
|
|
23
|
-
* never an error.
|
|
24
|
-
*/
|
|
25
|
-
import { appendFileSync, readFileSync } from 'node:fs';
|
|
26
|
-
import { join } from 'node:path';
|
|
27
|
-
|
|
28
|
-
/** @param {string} projectDir */
|
|
29
|
-
export function bindingsPath(projectDir) {
|
|
30
|
-
return join(projectDir, '.frizz', '.agent-bindings.jsonl');
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* Extract the `THREAD: <slug>` tag from a dispatch prompt — the SAME shape the dispatch
|
|
35
|
-
* hook enforces (a `.frizz/<slug>.md`-backed tag at the top of the prompt). Returns the
|
|
36
|
-
* normalized slug (no `.frizz/` prefix, no `.md` suffix) or null for an untagged one-shot.
|
|
37
|
-
* @param {string} prompt
|
|
38
|
-
* @returns {string|null}
|
|
39
|
-
*/
|
|
40
|
-
export function threadFromPrompt(prompt) {
|
|
41
|
-
if (typeof prompt !== 'string') return null;
|
|
42
|
-
const m = prompt.match(/^THREAD:\s*([\w./-]+)/m);
|
|
43
|
-
return m ? m[1].replace(/^\.frizz\//, '').replace(/\.md$/, '') : null;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* Record one binding. Called by the `agent-bind` PostToolUse hook. No-ops (returns false)
|
|
48
|
-
* unless BOTH an agent id and a thread slug are present — an untagged one-shot has no thread
|
|
49
|
-
* to bind, so nothing is written. Fail-open: a write error never throws.
|
|
50
|
-
* @param {string} projectDir
|
|
51
|
-
* @param {{ agentId?: string|null, thread?: string|null, label?: string|null, session?: string|null }} b
|
|
52
|
-
* @returns {boolean} whether a line was written
|
|
53
|
-
*/
|
|
54
|
-
export function recordBinding(projectDir, { agentId, thread, label, session }) {
|
|
55
|
-
try {
|
|
56
|
-
if (!agentId || !thread) return false;
|
|
57
|
-
appendFileSync(
|
|
58
|
-
bindingsPath(projectDir),
|
|
59
|
-
JSON.stringify({
|
|
60
|
-
ts: new Date().toISOString(),
|
|
61
|
-
agent_id: String(agentId),
|
|
62
|
-
thread: String(thread),
|
|
63
|
-
label: label ? String(label) : null,
|
|
64
|
-
session: session ? String(session) : null,
|
|
65
|
-
}) + '\n',
|
|
66
|
-
);
|
|
67
|
-
return true;
|
|
68
|
-
} catch {
|
|
69
|
-
return false; // fail-open — a binding write must never block a dispatch
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
* Read every recorded binding (newest last). Fail-open: any error → [].
|
|
75
|
-
* @param {string} projectDir
|
|
76
|
-
* @returns {{ts?:string, agent_id:string, thread:string, label:string|null, session:string|null}[]}
|
|
77
|
-
*/
|
|
78
|
-
export function readBindings(projectDir) {
|
|
79
|
-
/** @type {{ts?:string, agent_id:string, thread:string, label:string|null, session:string|null}[]} */
|
|
80
|
-
const out = [];
|
|
81
|
-
try {
|
|
82
|
-
const raw = readFileSync(bindingsPath(projectDir), 'utf8');
|
|
83
|
-
for (const line of raw.split('\n')) {
|
|
84
|
-
if (!line.trim()) continue;
|
|
85
|
-
try {
|
|
86
|
-
const r = JSON.parse(line);
|
|
87
|
-
if (r && r.agent_id && r.thread) out.push(r);
|
|
88
|
-
} catch {
|
|
89
|
-
/* skip a malformed line */
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
} catch {
|
|
93
|
-
/* no file → no bindings */
|
|
94
|
-
}
|
|
95
|
-
return out;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
/**
|
|
99
|
-
* Group bindings by thread slug into the `{id, label}[]` shape the board + liveness hook
|
|
100
|
-
* consume — the AUTOMATIC replacement for parsing `agents:` frontmatter. Dedupes by
|
|
101
|
-
* agent_id (one entry per instance), keeping the LATEST label seen for that id.
|
|
102
|
-
* @param {string} projectDir
|
|
103
|
-
* @returns {Map<string, {id:string, label:string|null}[]>}
|
|
104
|
-
*/
|
|
105
|
-
export function bindingsByThread(projectDir) {
|
|
106
|
-
/** @type {Map<string, Map<string, string|null>>} thread → (agentId → label) */
|
|
107
|
-
const byThread = new Map();
|
|
108
|
-
for (const b of readBindings(projectDir)) {
|
|
109
|
-
let m = byThread.get(b.thread);
|
|
110
|
-
if (!m) byThread.set(b.thread, (m = new Map()));
|
|
111
|
-
m.set(b.agent_id, b.label ?? m.get(b.agent_id) ?? null); // latest non-null label wins
|
|
112
|
-
}
|
|
113
|
-
/** @type {Map<string, {id:string, label:string|null}[]>} */
|
|
114
|
-
const out = new Map();
|
|
115
|
-
for (const [thread, m] of byThread) {
|
|
116
|
-
out.set(thread, [...m].map(([id, label]) => ({ id, label })));
|
|
117
|
-
}
|
|
118
|
-
return out;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
/**
|
|
122
|
-
* The thread a given agent id serves (latest binding wins), or null. Used to name the
|
|
123
|
-
* thread when a rest/return is surfaced, so reconciliation points at the right file.
|
|
124
|
-
* @param {string} projectDir
|
|
125
|
-
* @param {string|null|undefined} agentId
|
|
126
|
-
* @returns {string|null}
|
|
127
|
-
*/
|
|
128
|
-
export function threadForAgent(projectDir, agentId) {
|
|
129
|
-
if (!agentId) return null;
|
|
130
|
-
let found = null;
|
|
131
|
-
for (const b of readBindings(projectDir)) if (b.agent_id === agentId) found = b.thread;
|
|
132
|
-
return found;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
/**
|
|
136
|
-
* Per thread, the SINGLE NEWEST binding — the agent CURRENTLY serving the thread.
|
|
137
|
-
*
|
|
138
|
-
* Liveness must key on this, NOT on every agent ever bound: a thread is frequently
|
|
139
|
-
* re-dispatched (an agent dies/rests and a FRESH agent picks the same thread up), and a
|
|
140
|
-
* SUPERSEDED older agent staying quiet is EXPECTED, never a drop. Flagging the old agent
|
|
141
|
-
* after a new one took over was the #1 false positive (e.g. dead `ac067e883` kept firing on
|
|
142
|
-
* `gvs-warning-silent` long after live `a56d5f1` replaced it). Keying on the newest binding
|
|
143
|
-
* makes that class structurally impossible.
|
|
144
|
-
*
|
|
145
|
-
* Newest = max binding `ts` (ISO), with append-order (file index) as the tiebreaker — so an
|
|
146
|
-
* unparseable/absent ts still resolves deterministically to the later-written line.
|
|
147
|
-
* @param {string} projectDir
|
|
148
|
-
* @returns {Map<string, {id:string, label:string|null, ts:string|null}>}
|
|
149
|
-
*/
|
|
150
|
-
export function newestBindingByThread(projectDir) {
|
|
151
|
-
/** @type {Map<string, {id:string, label:string|null, ts:string|null, _ms:number, _idx:number}>} */
|
|
152
|
-
const out = new Map();
|
|
153
|
-
const bindings = readBindings(projectDir);
|
|
154
|
-
for (let idx = 0; idx < bindings.length; idx++) {
|
|
155
|
-
const b = bindings[idx];
|
|
156
|
-
const ms = Date.parse(b.ts ?? '');
|
|
157
|
-
const key = Number.isFinite(ms) ? ms : -Infinity;
|
|
158
|
-
const cur = out.get(b.thread);
|
|
159
|
-
if (!cur || key > cur._ms || (key === cur._ms && idx > cur._idx)) {
|
|
160
|
-
out.set(b.thread, { id: b.agent_id, label: b.label ?? null, ts: b.ts ?? null, _ms: key, _idx: idx });
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
/** @type {Map<string, {id:string, label:string|null, ts:string|null}>} */
|
|
164
|
-
const clean = new Map();
|
|
165
|
-
for (const [thread, v] of out) clean.set(thread, { id: v.id, label: v.label, ts: v.ts });
|
|
166
|
-
return clean;
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
/**
|
|
170
|
-
* Threads with a PR landing in flight via the merge cascade (`.frizz/merge-queue.jsonl`).
|
|
171
|
-
*
|
|
172
|
-
* Such a thread is LEGITIMATELY `active` while its PR merges — the agent that produced the
|
|
173
|
-
* work has finished and its output is reconciled; the thread stays active purely for the
|
|
174
|
-
* DOWNSTREAM merge, not because an agent is stuck. Suppressing liveness for these kills the
|
|
175
|
-
* second false-positive class (a completed agent flagged because its thread is mid-merge).
|
|
176
|
-
* Fail-open: no file / unparseable → empty set (suppress nothing).
|
|
177
|
-
* @param {string} projectDir
|
|
178
|
-
* @returns {Set<string>}
|
|
179
|
-
*/
|
|
180
|
-
export function downstreamThreads(projectDir) {
|
|
181
|
-
/** @type {Set<string>} */
|
|
182
|
-
const out = new Set();
|
|
183
|
-
try {
|
|
184
|
-
const raw = readFileSync(join(projectDir, '.frizz', 'merge-queue.jsonl'), 'utf8');
|
|
185
|
-
for (const line of raw.split('\n')) {
|
|
186
|
-
if (!line.trim()) continue;
|
|
187
|
-
try {
|
|
188
|
-
const r = JSON.parse(line);
|
|
189
|
-
if (r && r.thread) out.add(String(r.thread));
|
|
190
|
-
} catch {
|
|
191
|
-
/* skip a malformed line */
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
} catch {
|
|
195
|
-
/* no merge-queue → nothing downstream */
|
|
196
|
-
}
|
|
197
|
-
return out;
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
/**
|
|
201
|
-
* agent_ids that have recorded at least one SubagentStop (rest) in `.frizz/.rested-agents.jsonl`
|
|
202
|
-
* — i.e. reached a clean stopping point at least once (ended a turn).
|
|
203
|
-
*
|
|
204
|
-
* This is the signal that separates a TERMINATED/parked agent from one still grinding inside a
|
|
205
|
-
* single long tool call. An agent mid-build (one 40-min `cargo build`, no rest yet) has stale
|
|
206
|
-
* output but is ALIVE — and must never be called "dropped". Combined with stale output, a rest
|
|
207
|
-
* record means the agent ended its turn and has been quiet since: the conservative "no live
|
|
208
|
-
* agent" condition. Fail-open: no file / unparseable → empty set.
|
|
209
|
-
* @param {string} projectDir
|
|
210
|
-
* @returns {Set<string>}
|
|
211
|
-
*/
|
|
212
|
-
export function restedAgentIds(projectDir) {
|
|
213
|
-
/** @type {Set<string>} */
|
|
214
|
-
const out = new Set();
|
|
215
|
-
try {
|
|
216
|
-
const raw = readFileSync(join(projectDir, '.frizz', '.rested-agents.jsonl'), 'utf8');
|
|
217
|
-
for (const line of raw.split('\n')) {
|
|
218
|
-
if (!line.trim()) continue;
|
|
219
|
-
try {
|
|
220
|
-
const r = JSON.parse(line);
|
|
221
|
-
if (r && r.agent_id) out.add(String(r.agent_id));
|
|
222
|
-
} catch {
|
|
223
|
-
/* skip a malformed line */
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
} catch {
|
|
227
|
-
/* no rest log → no rested agents */
|
|
228
|
-
}
|
|
229
|
-
return out;
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
/**
|
|
233
|
-
* Count sub-agent rests recorded strictly after `sinceMs` that the ORCHESTRATOR actually
|
|
234
|
-
* dispatched — i.e. THREAD-BOUND agents — and have NOT yet been surfaced. This is what the
|
|
235
|
-
* Stop-hook rest guard nags on.
|
|
236
|
-
*
|
|
237
|
-
* THE FILTER (the fix for the over-aggressive nag): a rest counts ONLY if its `agent_id`
|
|
238
|
-
* appears in the bindings ledger (`.agent-bindings.jsonl`), which the agent-bind hook writes
|
|
239
|
-
* ONLY for a `THREAD:`-tagged orchestrator dispatch. The real predicate is "has a THREAD
|
|
240
|
-
* binding"; everything without one is excluded — none of it is orchestrator-facing reconcile work:
|
|
241
|
-
* - NESTED / worker-spawned sub-agents (e.g. a landing agent's own fresh-context self-review
|
|
242
|
-
* sub-agents): no THREAD tag → no binding → unbound. Their results fold into the parent's
|
|
243
|
-
* report; they are the worker's internal business. This is the class that drove the nag.
|
|
244
|
-
* - UNTAGGED orchestrator one-shots: also unbound (no thread), and they have no `.frizz/<slug>.md`
|
|
245
|
-
* to fold into — the result returns as the tool result, with nothing to reconcile.
|
|
246
|
-
* - ANON rests (no `agent_id`): inherently unbindable, so never the orchestrator's to reconcile.
|
|
247
|
-
* The bound, THREAD-owned agent that genuinely rested and hasn't been folded STILL counts — the
|
|
248
|
-
* legitimate signal is preserved, just made precise.
|
|
249
|
-
*
|
|
250
|
-
* DEDUPE: an `agent_id` already in `surfacedAgents` is skipped, so a resuming bound agent's
|
|
251
|
-
* repeat rests don't re-nag; only genuinely-new bound ids count. Returns the count plus the
|
|
252
|
-
* DISTINCT new bound agent-ids and their thread slugs, so the caller can nag with a real number,
|
|
253
|
-
* grow the seen-set, and name the threads. Fail-open: any error → zero count.
|
|
254
|
-
* @param {string} projectDir
|
|
255
|
-
* @param {number} sinceMs
|
|
256
|
-
* @param {string[]} [surfacedAgents]
|
|
257
|
-
* @returns {{ count: number, agents: string[], threads: string[] }}
|
|
258
|
-
*/
|
|
259
|
-
export function newBoundRestsSince(projectDir, sinceMs, surfacedAgents = []) {
|
|
260
|
-
const seen = new Set(surfacedAgents);
|
|
261
|
-
const bound = new Set(readBindings(projectDir).map((b) => b.agent_id));
|
|
262
|
-
/** @type {Set<string>} */
|
|
263
|
-
const newAgents = new Set();
|
|
264
|
-
/** @type {Set<string>} */
|
|
265
|
-
const threads = new Set();
|
|
266
|
-
try {
|
|
267
|
-
const raw = readFileSync(join(projectDir, '.frizz', '.rested-agents.jsonl'), 'utf8');
|
|
268
|
-
for (const line of raw.split('\n')) {
|
|
269
|
-
if (!line.trim()) continue;
|
|
270
|
-
try {
|
|
271
|
-
const rec = JSON.parse(line);
|
|
272
|
-
const ts = Date.parse(rec.ts);
|
|
273
|
-
if (!Number.isFinite(ts) || ts <= sinceMs) continue;
|
|
274
|
-
const id = rec.agent_id;
|
|
275
|
-
// bound-only: skip anon (no id), already-surfaced, and unbound (nested/worker-spawned).
|
|
276
|
-
if (!id || seen.has(id) || !bound.has(id)) continue;
|
|
277
|
-
newAgents.add(id);
|
|
278
|
-
if (rec.thread) threads.add(String(rec.thread));
|
|
279
|
-
} catch {
|
|
280
|
-
/* skip a malformed line */
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
} catch {
|
|
284
|
-
/* no rest log → no rests */
|
|
285
|
-
}
|
|
286
|
-
return { count: newAgents.size, agents: [...newAgents], threads: [...threads] };
|
|
287
|
-
}
|
|
@@ -1,367 +0,0 @@
|
|
|
1
|
-
// @ts-check
|
|
2
|
-
/**
|
|
3
|
-
* frizz — agent-liveness helper for the Stop hook.
|
|
4
|
-
*
|
|
5
|
-
* Background sub-agents are bound to their thread AUTOMATICALLY: the `agent-bind`
|
|
6
|
-
* PostToolUse hook records `agentId → thread` into `.frizz/.agent-bindings.jsonl` at
|
|
7
|
-
* dispatch (see `./agent-bindings.mjs`). This module reads that ephemeral binding to learn
|
|
8
|
-
* which agents serve which thread, then DERIVES each one's real liveness from ground truth
|
|
9
|
-
* and returns reminder LINES for the Stop hook to surface. Hooks cannot call
|
|
10
|
-
* SendMessage/Agent, so this is detect-and-remind only — exactly the ask. (The old
|
|
11
|
-
* hand-maintained `agents: [{id, label}]` thread frontmatter is GONE — never read; a
|
|
12
|
-
* lingering one in an old thread file is an ignored no-op.)
|
|
13
|
-
*
|
|
14
|
-
* COMPUTE, DON'T STORE. The binding supplies only the id↔thread mapping; it carries no
|
|
15
|
-
* per-agent state. State comes from `deriveAgentState`
|
|
16
|
-
* (`./agent-status.mjs`) over two ground-truth signals:
|
|
17
|
-
* 1. The session tasks dir, derived from the Stop payload's `transcript_path`
|
|
18
|
-
* (`~/.claude/projects/<slug>/<session>.jsonl`). The per-agent activity files
|
|
19
|
-
* live at `<tmp>/claude-<uid>/<slug>/<session>/tasks/<agentId>.output`, where
|
|
20
|
-
* `<tmp>` is `/tmp` or `/private/tmp` and `<uid>` varies — so we GLOB the
|
|
21
|
-
* `claude-*` dirs under both bases rather than hard-code the uid. The `.output`
|
|
22
|
-
* entries are SYMLINKS to the subagent transcript jsonl; we `statSync` (follows
|
|
23
|
-
* the link) so the age reflects the TARGET's real last-write, not the stale
|
|
24
|
-
* symlink mtime. The mtime → idle/frozen age.
|
|
25
|
-
* 2. The owning THREAD's own `status:` frontmatter (done/dismissed = terminal) — the
|
|
26
|
-
* orchestrator's deliberate "I reconciled this" signal, and the ONLY mutable bit.
|
|
27
|
-
*
|
|
28
|
-
* THREAD-CENTRIC, LOW-NOISE. For each `active` thread we consider ONLY its NEWEST bound agent
|
|
29
|
-
* (a superseded older agent is never flagged) and SUPPRESS the thread entirely when a PR is
|
|
30
|
-
* landing for it via the merge cascade (it's active for the merge, not a stuck agent). The bar
|
|
31
|
-
* is "better to miss a soft case than nag a benign one":
|
|
32
|
-
* - ⚠ ACTIVE THREAD, NO LIVE AGENT (state 'dropped'): an active, non-downstream thread whose
|
|
33
|
-
* NEWEST agent is quiet beyond DROPPED_MIN AND has rested (ended a turn) — the
|
|
34
|
-
* high-confidence "this thread is stuck active with nobody on it" signal. Calm wording.
|
|
35
|
-
* - a soft idle note (state 'idle'): quiet but not confidently dropped — fine if it's
|
|
36
|
-
* watching CI or mid-build. Informational only.
|
|
37
|
-
* - (thread terminal / parked / downstream / fresh agent → say nothing.)
|
|
38
|
-
*
|
|
39
|
-
* Thresholds (minutes), tunable via env for experimentation:
|
|
40
|
-
* FRIZZ_IDLE_MIN (default 10) — quiet this long → soft idle note.
|
|
41
|
-
* FRIZZ_DROPPED_MIN (default 45, FRIZZ_FROZEN_MIN honored as the old alias) — quiet this long
|
|
42
|
-
* AND rested → call it dropped. Deliberately generous: a real agent legitimately arms a
|
|
43
|
-
* CI watcher and sits silent for 30–40 min, so 45 min lets the watcher fire and resume it
|
|
44
|
-
* before we ever flag. Tune via the env var for faster- or slower-paced repos.
|
|
45
|
-
*
|
|
46
|
-
* FAIL-OPEN ABSOLUTELY: any error (no tasks dir, no bindings file, unparseable frontmatter,
|
|
47
|
-
* unreadable file) → return [] (no lines). This must NEVER throw or block end-of-turn.
|
|
48
|
-
*/
|
|
49
|
-
import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs';
|
|
50
|
-
import { join } from 'node:path';
|
|
51
|
-
import { deriveAgentState, findAgentOutputAge, IDLE_MIN, DROPPED_MIN, LONG_RUNTIME_MIN } from './agent-status.mjs';
|
|
52
|
-
import { newestBindingByThread, downstreamThreads, restedAgentIds } from './agent-bindings.mjs';
|
|
53
|
-
|
|
54
|
-
// Thread-level terminal statuses (frontmatter `status:`), matching scripts/frizz TERMINAL.
|
|
55
|
-
const TERMINAL_THREAD = new Set(['done', 'dismissed']);
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* Derive the session tasks dir from a Stop payload's transcript_path.
|
|
59
|
-
* @param {string|undefined|null} transcriptPath
|
|
60
|
-
* @returns {string|null}
|
|
61
|
-
*/
|
|
62
|
-
export function deriveTasksDir(transcriptPath) {
|
|
63
|
-
try {
|
|
64
|
-
if (!transcriptPath || typeof transcriptPath !== 'string') return null;
|
|
65
|
-
const parts = transcriptPath.split('/');
|
|
66
|
-
const sessionFile = parts.pop(); // <session>.jsonl
|
|
67
|
-
const slug = parts.pop(); // <project-slug>
|
|
68
|
-
if (!sessionFile || !slug) return null;
|
|
69
|
-
const session = sessionFile.replace(/\.jsonl$/, '');
|
|
70
|
-
if (!session) return null;
|
|
71
|
-
for (const base of ['/tmp', '/private/tmp']) {
|
|
72
|
-
let dirs;
|
|
73
|
-
try {
|
|
74
|
-
dirs = readdirSync(base).filter((d) => d.startsWith('claude-'));
|
|
75
|
-
} catch {
|
|
76
|
-
continue;
|
|
77
|
-
}
|
|
78
|
-
for (const d of dirs) {
|
|
79
|
-
const cand = join(base, d, slug, session, 'tasks');
|
|
80
|
-
if (existsSync(cand)) return cand;
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
} catch {
|
|
84
|
-
/* fail-open */
|
|
85
|
-
}
|
|
86
|
-
return null;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/**
|
|
90
|
-
* GROUND-TRUTH age (minutes) of an agent's last activity. Prefers the exact session's tasks
|
|
91
|
-
* dir (fast, one statSync) when we have it; falls back to the session-independent glob whenever
|
|
92
|
-
* the agent's `.output` isn't in THIS session's tasks dir — either there's no transcript_path,
|
|
93
|
-
* OR the binding is from a PRIOR session (e.g. after a restart), where the cross-session glob is
|
|
94
|
-
* what keeps anti-drop working across restarts. Returns null when no output file exists.
|
|
95
|
-
* @param {string} agentId
|
|
96
|
-
* @param {string|null} tasksDir
|
|
97
|
-
* @param {number} now
|
|
98
|
-
* @returns {number|null}
|
|
99
|
-
*/
|
|
100
|
-
function agentAge(agentId, tasksDir, now) {
|
|
101
|
-
if (tasksDir) {
|
|
102
|
-
try {
|
|
103
|
-
const st = statSync(join(tasksDir, `${agentId}.output`)); // follows the symlink → target mtime
|
|
104
|
-
return (now - st.mtimeMs) / 60000;
|
|
105
|
-
} catch {
|
|
106
|
-
/* not in this session's tasks dir → fall through to the glob */
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
return findAgentOutputAge(agentId, now);
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/**
|
|
113
|
-
* Compute thread-centric liveness lines, DERIVED purely from ground truth (newest-binding age +
|
|
114
|
-
* rest log + thread status + merge-queue) — never a stored per-agent flag. At most ONE line per
|
|
115
|
-
* thread, keyed on the thread's NEWEST agent only, with downstream (mid-merge) threads
|
|
116
|
-
* suppressed. The loud "ACTIVE THREAD, NO LIVE AGENT" lines come first, then soft idle notes.
|
|
117
|
-
* @param {{transcriptPath?: string|null, projectDir: string, now?: number}} args
|
|
118
|
-
* @returns {string[]} reminder lines (possibly empty)
|
|
119
|
-
*/
|
|
120
|
-
export function agentLivenessLines({ transcriptPath, projectDir, now = Date.now() }) {
|
|
121
|
-
/** @type {string[]} */
|
|
122
|
-
const dropped = [];
|
|
123
|
-
/** @type {string[]} */
|
|
124
|
-
const idle = [];
|
|
125
|
-
try {
|
|
126
|
-
const tasksDir = deriveTasksDir(transcriptPath); // may be null → agentAge falls back to glob
|
|
127
|
-
const frizzDir = join(projectDir, '.frizz');
|
|
128
|
-
|
|
129
|
-
// AUTOMATIC binding: the NEWEST agent serving each thread (a superseded older agent is
|
|
130
|
-
// never considered). Plus the merge-cascade set (suppress mid-merge threads) and the rest
|
|
131
|
-
// log (separate a parked agent from one still inside a long tool call).
|
|
132
|
-
const newest = newestBindingByThread(projectDir);
|
|
133
|
-
const downstream = downstreamThreads(projectDir);
|
|
134
|
-
const rested = restedAgentIds(projectDir);
|
|
135
|
-
|
|
136
|
-
let files;
|
|
137
|
-
try {
|
|
138
|
-
files = readdirSync(frizzDir).filter((f) => f.endsWith('.md') && !f.startsWith('_') && !f.startsWith('.'));
|
|
139
|
-
} catch {
|
|
140
|
-
return dropped;
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
for (const f of files) {
|
|
144
|
-
const slug = f.replace(/\.md$/, '');
|
|
145
|
-
const binding = newest.get(slug);
|
|
146
|
-
if (!binding) continue; // no agent ever bound to this thread → nothing to judge
|
|
147
|
-
|
|
148
|
-
let src;
|
|
149
|
-
try {
|
|
150
|
-
src = readFileSync(join(frizzDir, f), 'utf8');
|
|
151
|
-
} catch {
|
|
152
|
-
continue;
|
|
153
|
-
}
|
|
154
|
-
const threadStatus = src.match(/^status:\s*(\S+)/m)?.[1] ?? '';
|
|
155
|
-
const threadTerminal = TERMINAL_THREAD.has(threadStatus);
|
|
156
|
-
const threadActive = threadStatus === 'active'; // ONLY active threads can strand an agent; parked phases holding a done agent are EXPECTED
|
|
157
|
-
if (threadTerminal || !threadActive) continue;
|
|
158
|
-
// PR landing via the cascade → legitimately active, suppress. Skip BEFORE agentAge so a
|
|
159
|
-
// mid-merge thread never pays the (potentially full-/tmp-glob) age lookup just to be
|
|
160
|
-
// dropped by deriveAgentState's downstream short-circuit anyway.
|
|
161
|
-
if (downstream.has(slug)) continue;
|
|
162
|
-
|
|
163
|
-
const ageMin = agentAge(binding.id, tasksDir, now);
|
|
164
|
-
const hasRested = rested.has(binding.id);
|
|
165
|
-
const state = deriveAgentState({ ageMin, threadTerminal, threadActive, hasRested, idleMin: IDLE_MIN, droppedMin: DROPPED_MIN }); // downstream already short-circuited above
|
|
166
|
-
const who = `${binding.label ? `${binding.label} ` : ''}[${binding.id.slice(0, 9)}]`;
|
|
167
|
-
|
|
168
|
-
if (state === 'dropped') {
|
|
169
|
-
dropped.push(`⚠ ACTIVE THREAD, NO LIVE AGENT: ${slug} — its newest agent ${who} has been quiet ${Math.round(ageMin ?? 0)}m with no PR/merge in flight, so it likely finished or dropped. Fold its report and flip the thread to done, or resume it (SendMessage ${binding.id.slice(0, 9)}) if it's still mid-task.`);
|
|
170
|
-
} else if (state === 'idle') {
|
|
171
|
-
idle.push(`frizz: thread ${slug} — agent ${who} quiet ${Math.round(ageMin ?? 0)}m. Fine if it's watching CI or mid-build; check in (SendMessage) if that's unexpected.`);
|
|
172
|
-
}
|
|
173
|
-
// 'terminal' (reconciled / parked / downstream), 'fresh' (working), 'unknown' (no file) → say nothing.
|
|
174
|
-
}
|
|
175
|
-
} catch {
|
|
176
|
-
/* fail-open: return whatever we have */
|
|
177
|
-
}
|
|
178
|
-
return [...dropped, ...idle];
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
/**
|
|
182
|
-
* The SHARED in-flight predicate: is a bound agent still live (not paused/done, not stale)?
|
|
183
|
-
* "Not rested" is the primary signal — a rest is the ONLY completion mark, so its absence
|
|
184
|
-
* means still-in-flight; age is the secondary guard — fresh output (age < DROPPED_MIN) OR no
|
|
185
|
-
* output file yet (age == null → just launched). A quiet-past-DROPPED_MIN + never-rested agent
|
|
186
|
-
* is the 'dropped' case agentLivenessLines flags, so it is NOT counted live here. Terminal-thread
|
|
187
|
-
* exclusion is the caller's job (this predicate only judges the agent). One code path shared by
|
|
188
|
-
* runningAgentCount and liveBoundAgentForThread so the two never drift.
|
|
189
|
-
* @param {string} agentId
|
|
190
|
-
* @param {string|null} tasksDir
|
|
191
|
-
* @param {Set<string>} rested
|
|
192
|
-
* @param {number} now
|
|
193
|
-
* @returns {boolean}
|
|
194
|
-
*/
|
|
195
|
-
function isAgentInFlight(agentId, tasksDir, rested, now) {
|
|
196
|
-
if (rested.has(agentId)) return false;
|
|
197
|
-
const ageMin = agentAge(agentId, tasksDir, now);
|
|
198
|
-
return ageMin == null || ageMin < DROPPED_MIN;
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
/**
|
|
202
|
-
* The single LIVE agent bound to `slug`, or null. "Live" = the thread's NEWEST binding, on a
|
|
203
|
-
* non-terminal thread, still in flight per {@link isAgentInFlight} (not rested, not stale). Same
|
|
204
|
-
* logic/thresholds as {@link runningAgentCount}, scoped to one thread — the thread-edit-steer
|
|
205
|
-
* hook uses it to decide whether an orchestrator edit needs a SendMessage steer.
|
|
206
|
-
* @param {{slug: string, transcriptPath?: string|null, projectDir: string, now?: number}} args
|
|
207
|
-
* @returns {{id: string, label: string|null} | null}
|
|
208
|
-
*/
|
|
209
|
-
export function liveBoundAgentForThread({ slug, transcriptPath, projectDir, now = Date.now() }) {
|
|
210
|
-
try {
|
|
211
|
-
const binding = newestBindingByThread(projectDir).get(slug);
|
|
212
|
-
if (!binding) return null;
|
|
213
|
-
let src;
|
|
214
|
-
try {
|
|
215
|
-
src = readFileSync(join(projectDir, '.frizz', `${slug}.md`), 'utf8');
|
|
216
|
-
} catch {
|
|
217
|
-
return null; // no thread file → nothing to steer
|
|
218
|
-
}
|
|
219
|
-
const threadStatus = src.match(/^status:\s*(\S+)/m)?.[1] ?? '';
|
|
220
|
-
if (TERMINAL_THREAD.has(threadStatus)) return null; // done/dismissed → no live agent
|
|
221
|
-
const tasksDir = deriveTasksDir(transcriptPath);
|
|
222
|
-
if (!isAgentInFlight(binding.id, tasksDir, restedAgentIds(projectDir), now)) return null;
|
|
223
|
-
return { id: binding.id, label: binding.label };
|
|
224
|
-
} catch {
|
|
225
|
-
return null; // fail-open
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
/**
|
|
230
|
-
* Count of dispatched agents currently RUNNING — the "Waiting for N background agents" case.
|
|
231
|
-
* An agent counts as running when: it's the NEWEST binding for its thread, it has NOT rested
|
|
232
|
-
* (no completion recorded → still in flight, not paused/done), its thread isn't terminal, and
|
|
233
|
-
* it isn't a long-dead stale binding. "Not rested" is the primary signal — a rest is the ONLY
|
|
234
|
-
* completion mark, so its absence means the agent is still in flight; age is a secondary guard:
|
|
235
|
-
* count when the output is fresh (age < DROPPED_MIN) OR there's no output file yet (age == null →
|
|
236
|
-
* JUST launched, definitely running), and drop only the clearly-stale (quiet past DROPPED_MIN and
|
|
237
|
-
* never rested → agentLivenessLines already flags that as 'dropped', so we don't double-count it
|
|
238
|
-
* as "working"). This is DISTINCT from agentLivenessLines (which flags STRANDED active threads);
|
|
239
|
-
* here we count live in-flight work so the Stop hook can surface "N still working" before the
|
|
240
|
-
* unhookable idle-wait. Fail-open → 0. Never throws.
|
|
241
|
-
* @param {{transcriptPath?: string|null, projectDir: string, now?: number}} args
|
|
242
|
-
* @returns {number}
|
|
243
|
-
*/
|
|
244
|
-
export function runningAgentCount({ transcriptPath, projectDir, now = Date.now() }) {
|
|
245
|
-
try {
|
|
246
|
-
const tasksDir = deriveTasksDir(transcriptPath);
|
|
247
|
-
const frizzDir = join(projectDir, '.frizz');
|
|
248
|
-
const newest = newestBindingByThread(projectDir);
|
|
249
|
-
const rested = restedAgentIds(projectDir);
|
|
250
|
-
let files;
|
|
251
|
-
try {
|
|
252
|
-
files = readdirSync(frizzDir).filter((f) => f.endsWith('.md') && !f.startsWith('_') && !f.startsWith('.'));
|
|
253
|
-
} catch {
|
|
254
|
-
return 0;
|
|
255
|
-
}
|
|
256
|
-
let count = 0;
|
|
257
|
-
const seen = new Set();
|
|
258
|
-
for (const f of files) {
|
|
259
|
-
const slug = f.replace(/\.md$/, '');
|
|
260
|
-
const binding = newest.get(slug);
|
|
261
|
-
if (!binding || seen.has(binding.id)) continue; // one count per agent even if it serves >1 thread
|
|
262
|
-
seen.add(binding.id);
|
|
263
|
-
let src;
|
|
264
|
-
try {
|
|
265
|
-
src = readFileSync(join(frizzDir, f), 'utf8');
|
|
266
|
-
} catch {
|
|
267
|
-
continue;
|
|
268
|
-
}
|
|
269
|
-
const threadStatus = src.match(/^status:\s*(\S+)/m)?.[1] ?? '';
|
|
270
|
-
if (TERMINAL_THREAD.has(threadStatus)) continue; // done/dismissed → not running
|
|
271
|
-
if (isAgentInFlight(binding.id, tasksDir, rested, now)) count++; // shared predicate (not rested + fresh/just-launched)
|
|
272
|
-
}
|
|
273
|
-
return count;
|
|
274
|
-
} catch {
|
|
275
|
-
return 0;
|
|
276
|
-
}
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
/**
|
|
280
|
-
* The high-confidence subset of {@link agentLivenessLines}: ONLY the "ACTIVE THREAD, NO LIVE
|
|
281
|
-
* AGENT" lines. Used by the per-turn reminder, where surfacing the soft idle notes every prompt
|
|
282
|
-
* would be noise — but a genuinely stranded active thread SHOULD nag every turn until reconciled
|
|
283
|
-
* (that is the anti-drop signal), and it's rare by construction, so it won't cry wolf.
|
|
284
|
-
* @param {{transcriptPath?: string|null, projectDir: string, now?: number}} args
|
|
285
|
-
* @returns {string[]}
|
|
286
|
-
*/
|
|
287
|
-
export function strandedThreadLines(args) {
|
|
288
|
-
return agentLivenessLines(args).filter((l) => l.startsWith('⚠ ACTIVE THREAD'));
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
/**
|
|
292
|
-
* THE WATCHER/AGENT DROP-GUARD (2026-07-06, the #327 forcing function). For each `active`,
|
|
293
|
-
* non-downstream thread whose NEWEST agent was dispatched > LONG_RUNTIME_MIN ago AND is STILL
|
|
294
|
-
* emitting output (age < DROPPED_MIN → looks alive) yet has produced NO terminal thread result,
|
|
295
|
-
* emit a LOUD "VERIFY DIRECTLY" line. This is the case the existing 'dropped' signal CANNOT catch:
|
|
296
|
-
* a `ci-watch` hung on a nameless ghost check keeps polling, so its output stays fresh and it never
|
|
297
|
-
* looks stranded — it just never terminates, and "watcher running = fine" gets trusted for hours
|
|
298
|
-
* (PR #327 sat effectively-green + stranded). The forcing function turns "a watcher can hang" from
|
|
299
|
-
* a RULE into a hook-surfaced nudge: go check the target rollup YOURSELF.
|
|
300
|
-
*
|
|
301
|
-
* Runtime is measured from the binding `ts` (dispatch time), NOT output age — a watcher looks
|
|
302
|
-
* "fresh" precisely because it is polling; the giveaway is the long total RUNTIME with no result.
|
|
303
|
-
* Distinct from 'dropped' (stale + rested): the two bands don't overlap (this requires fresh
|
|
304
|
-
* output, that requires stale). Structured return (slug/agentId/runtimeMin/tier/line) so a consumer
|
|
305
|
-
* can dedupe by agentId AND re-arm per `tier` — `tier = floor(runtimeMin / LONG_RUNTIME_MIN)`, the
|
|
306
|
-
* coarse 1×/2×/3× runtime multiple. Re-arming per tier is load-bearing for a PURE idle-wait (no
|
|
307
|
-
* user prompts → the per-turn reminder never fires): a permanent agent-id dedup would block the
|
|
308
|
-
* idle exactly ONCE at ~1× and let a genuinely-hung watcher be rationalized as "still fine" forever
|
|
309
|
-
* (the #327 trap); re-blocking at each new tier forces a multi-hour hung watcher back into view
|
|
310
|
-
* while a normal long-but-progressing agent still only crosses a tier occasionally. Fail-open → [].
|
|
311
|
-
* @param {{transcriptPath?: string|null, projectDir: string, now?: number}} args
|
|
312
|
-
* @returns {{slug:string, agentId:string, runtimeMin:number, tier:number, line:string}[]}
|
|
313
|
-
*/
|
|
314
|
-
export function longRunningAgentLines({ transcriptPath, projectDir, now = Date.now() }) {
|
|
315
|
-
/** @type {{slug:string, agentId:string, runtimeMin:number, line:string}[]} */
|
|
316
|
-
const out = [];
|
|
317
|
-
try {
|
|
318
|
-
const tasksDir = deriveTasksDir(transcriptPath);
|
|
319
|
-
const frizzDir = join(projectDir, '.frizz');
|
|
320
|
-
const newest = newestBindingByThread(projectDir);
|
|
321
|
-
const downstream = downstreamThreads(projectDir);
|
|
322
|
-
let files;
|
|
323
|
-
try {
|
|
324
|
-
files = readdirSync(frizzDir).filter((f) => f.endsWith('.md') && !f.startsWith('_') && !f.startsWith('.'));
|
|
325
|
-
} catch {
|
|
326
|
-
return out;
|
|
327
|
-
}
|
|
328
|
-
for (const f of files) {
|
|
329
|
-
const slug = f.replace(/\.md$/, '');
|
|
330
|
-
const binding = newest.get(slug);
|
|
331
|
-
if (!binding) continue;
|
|
332
|
-
let src;
|
|
333
|
-
try {
|
|
334
|
-
src = readFileSync(join(frizzDir, f), 'utf8');
|
|
335
|
-
} catch {
|
|
336
|
-
continue;
|
|
337
|
-
}
|
|
338
|
-
const threadStatus = src.match(/^status:\s*(\S+)/m)?.[1] ?? '';
|
|
339
|
-
if (threadStatus !== 'active') continue; // only an active thread can strand a running agent
|
|
340
|
-
if (downstream.has(slug)) continue; // PR landing via the cascade → legitimately long, suppress
|
|
341
|
-
const dispatchedMs = Date.parse(binding.ts ?? '');
|
|
342
|
-
if (!Number.isFinite(dispatchedMs)) continue; // no dispatch time → can't judge runtime
|
|
343
|
-
const runtimeMin = (now - dispatchedMs) / 60_000;
|
|
344
|
-
if (runtimeMin <= LONG_RUNTIME_MIN) continue; // not long-running yet
|
|
345
|
-
const ageMin = agentAge(binding.id, tasksDir, now);
|
|
346
|
-
// Must still LOOK alive (fresh output): a STALE agent is the 'dropped' case, not this one; a
|
|
347
|
-
// null age (no output file → likely a dead prior-session binding) can't confirm alive → skip.
|
|
348
|
-
if (ageMin == null || ageMin >= DROPPED_MIN) continue;
|
|
349
|
-
const who = `${binding.label ? `${binding.label} ` : ''}[${binding.id.slice(0, 9)}]`;
|
|
350
|
-
out.push({
|
|
351
|
-
slug,
|
|
352
|
-
agentId: binding.id,
|
|
353
|
-
runtimeMin: Math.round(runtimeMin),
|
|
354
|
-
tier: Math.floor(runtimeMin / LONG_RUNTIME_MIN), // coarse 1×/2×/3× runtime multiple → per-tier re-arm
|
|
355
|
-
line:
|
|
356
|
-
`⚠ VERIFY DIRECTLY — ${slug}: agent ${who} has run ${Math.round(runtimeMin)}m with NO terminal result ` +
|
|
357
|
-
`(last output ${Math.round(ageMin)}m ago, so it LOOKS alive). A watcher can HANG on a stuck/ghost CI check ` +
|
|
358
|
-
`(a nameless check-run that never reports) and look alive while stranded — check its PR/target rollup YOURSELF ` +
|
|
359
|
-
`(e.g. \`gh pr view <n> --json statusCheckRollup -q '.statusCheckRollup[]|select(.status!="COMPLETED")'\`) and act; ` +
|
|
360
|
-
`do NOT trust that it's still progressing.`,
|
|
361
|
-
});
|
|
362
|
-
}
|
|
363
|
-
} catch {
|
|
364
|
-
/* fail-open */
|
|
365
|
-
}
|
|
366
|
-
return out;
|
|
367
|
-
}
|