frayui 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +186 -0
- package/dist/claude-agent-broker.js +21641 -0
- package/dist/codex-app-server-daemon.js +354 -0
- package/dist/dev-child.js +40319 -0
- package/dist/frayui.js +8738 -0
- package/package.json +46 -0
- package/runtime/cc/scripts/fray/agent-bindings.mjs +287 -0
- package/runtime/cc/scripts/fray/agent-liveness.mjs +367 -0
- package/runtime/cc/scripts/fray/agent-status.mjs +178 -0
- package/runtime/cc/scripts/fray/config.mjs +982 -0
- package/runtime/cc/scripts/fray/decisions.mjs +97 -0
- package/runtime/cc/scripts/fray/index.mjs +699 -0
- package/runtime/cc/scripts/fray/notify-shared.mjs +90 -0
- package/runtime/cc/scripts/fray/notify.mjs +81 -0
- package/runtime/cc/scripts/fray/ownership.mjs +120 -0
- package/runtime/cc/scripts/fray/rest-detect.mjs +213 -0
- package/runtime/cc/scripts/fray/thread-excerpt.mjs +162 -0
- package/runtime/cc/scripts/fray/thread-update.mjs +285 -0
- package/runtime/cc-worker/.claude-plugin/plugin.json +10 -0
- package/runtime/cc-worker/DECISIONS.md +923 -0
- package/runtime/cc-worker/LICENSE +21 -0
- package/runtime/cc-worker/agents/fable-high.md +8 -0
- package/runtime/cc-worker/agents/fable-low.md +8 -0
- package/runtime/cc-worker/agents/fable-max.md +8 -0
- package/runtime/cc-worker/agents/fable-medium.md +8 -0
- package/runtime/cc-worker/agents/fable-xhigh.md +8 -0
- package/runtime/cc-worker/agents/haiku.md +7 -0
- package/runtime/cc-worker/agents/opus-high.md +8 -0
- package/runtime/cc-worker/agents/opus-low.md +8 -0
- package/runtime/cc-worker/agents/opus-max.md +8 -0
- package/runtime/cc-worker/agents/opus-medium.md +8 -0
- package/runtime/cc-worker/agents/opus-xhigh.md +8 -0
- package/runtime/cc-worker/agents/sonnet-high.md +8 -0
- package/runtime/cc-worker/agents/sonnet-low.md +8 -0
- package/runtime/cc-worker/agents/sonnet-max.md +8 -0
- package/runtime/cc-worker/agents/sonnet-medium.md +8 -0
- package/runtime/cc-worker/agents/sonnet-xhigh.md +8 -0
- package/runtime/cc-worker/bin/fray +17 -0
- package/runtime/cc-worker/bin/fray-mcp.mjs +248 -0
- package/runtime/cc-worker/bin/fray-update +18 -0
- package/runtime/cc-worker/hooks/agent-bind.mjs +40 -0
- package/runtime/cc-worker/hooks/agent-dispatch.mjs +74 -0
- package/runtime/cc-worker/hooks/bash-background.d.mts +6 -0
- package/runtime/cc-worker/hooks/bash-background.mjs +188 -0
- package/runtime/cc-worker/hooks/deny-ask.mjs +38 -0
- package/runtime/cc-worker/hooks/deny-plan.mjs +61 -0
- package/runtime/cc-worker/hooks/hooks.json +111 -0
- package/runtime/cc-worker/hooks/perm-policy.mjs +211 -0
- package/runtime/cc-worker/hooks/precompact-instructions.mjs +122 -0
- package/runtime/cc-worker/hooks/scratchpad-stop.mjs +125 -0
- package/runtime/cc-worker/hooks/scratchpad.mjs +419 -0
- package/runtime/cc-worker/hooks/session-seed.mjs +105 -0
- package/runtime/cc-worker/scripts/fray/agent-bindings.mjs +9 -0
- package/runtime/cc-worker/scripts/fray/config.mjs +12 -0
- package/runtime/cc-worker/skills/gh/SKILL.md +132 -0
- package/runtime/cc-worker/skills/gh/scripts/ci-watch.mjs +60 -0
- package/runtime/cc-worker/skills/gh/scripts/github-watch.mjs +130 -0
- package/runtime/cc-worker/skills/gh/scripts/review-watch.mjs +54 -0
- package/runtime/cc-worker/skills/handoff/SKILL.md +203 -0
- package/runtime/cc-worker/skills/waits/SKILL.md +83 -0
- package/web-dist/apple-touch-icon.png +0 -0
- package/web-dist/assets/TerminalPane-fMGrgB-a.js +7 -0
- package/web-dist/assets/abnfDiagram-VRR7QNED-BtlrDuYX.js +1 -0
- package/web-dist/assets/arc-BSyeo0Gb.js +1 -0
- package/web-dist/assets/architecture-TIHT7OUA-CuD8jpr-.js +1 -0
- package/web-dist/assets/architectureDiagram-ZJ3FMSHR-CdLQ71be.js +36 -0
- package/web-dist/assets/array-BifhSqXX.js +1 -0
- package/web-dist/assets/blockDiagram-677ZJIJ3-CBk2FHEj.js +132 -0
- package/web-dist/assets/c4Diagram-LMCZKHZV-mZ3HQ6WX.js +10 -0
- package/web-dist/assets/channel-C2eUWc74.js +1 -0
- package/web-dist/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
- package/web-dist/assets/chunk-32BRIVSS-Daxvi7f5.js +1 -0
- package/web-dist/assets/chunk-52WLFC77-BGJoZvry.js +10 -0
- package/web-dist/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
- package/web-dist/assets/chunk-7BUUIJ7U-Bb538aSH.js +1 -0
- package/web-dist/assets/chunk-C7G6YPKG-Ycd1yheh.js +1 -0
- package/web-dist/assets/chunk-EX3LRPZG-BPFhlsHp.js +231 -0
- package/web-dist/assets/chunk-FWX5IMBZ--xy8DTon.js +2 -0
- package/web-dist/assets/chunk-HOUHSVGY-Dq3zoygp.js +1 -0
- package/web-dist/assets/chunk-ICXQ74PX-FfKP-7yM.js +2 -0
- package/web-dist/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
- package/web-dist/assets/chunk-KEIR6QF5-BfrZ3jm6.js +161 -0
- package/web-dist/assets/chunk-MOJQB5TN-BeiHnsLF.js +88 -0
- package/web-dist/assets/chunk-OGEWGWER-CjDU_9fC.js +1 -0
- package/web-dist/assets/chunk-PUDLZKDR-BO7tm5QX.js +156 -0
- package/web-dist/assets/chunk-Q4XR5HBZ-BPefovOg.js +70 -0
- package/web-dist/assets/chunk-RYQCIY6F-Cu_KplZW.js +1 -0
- package/web-dist/assets/chunk-V7JOEXUC-DWnebFHh.js +206 -0
- package/web-dist/assets/chunk-VAUOI2AC-BL6qWFhW.js +1 -0
- package/web-dist/assets/chunk-VR4S4FIN-Mfs__7L9.js +1 -0
- package/web-dist/assets/chunk-WYO6CB5R-Bf2IYbEU.js +127 -0
- package/web-dist/assets/chunk-XXDRQBXY-DYlTP5J-.js +1 -0
- package/web-dist/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
- package/web-dist/assets/chunk-ZGVPDNZ5-CJ9ZHZgU.js +62 -0
- package/web-dist/assets/chunk-ZIRB5QZD-C6fEPe3t.js +32 -0
- package/web-dist/assets/classDiagram-OUVF2IWQ-GaOJNgfE.js +1 -0
- package/web-dist/assets/classDiagram-v2-EOCWNBFH-GaOJNgfE.js +1 -0
- package/web-dist/assets/cose-bilkent-JH36ORCC-BUIsLrGc.js +1 -0
- package/web-dist/assets/cynefin-VYW2F7L2-COSC0oNL.js +1 -0
- package/web-dist/assets/cynefinDiagram-TSTJHNR4-mInhJdg1.js +62 -0
- package/web-dist/assets/cytoscape.esm-B3I8pqwA.js +321 -0
- package/web-dist/assets/dagre-CXRCoUWR.js +1 -0
- package/web-dist/assets/dagre-VKFMJZFB-D-T5z05M.js +4 -0
- package/web-dist/assets/defaultLocale-C8Fc0cco.js +1 -0
- package/web-dist/assets/diagram-FQU43EPY-HbMkSb3j.js +3 -0
- package/web-dist/assets/diagram-G47NLZAW-DBwZLcZh.js +24 -0
- package/web-dist/assets/diagram-NH7WQ7WH-DG_Mhays.js +24 -0
- package/web-dist/assets/diagram-OA4YK3LP-hH39gqpu.js +30 -0
- package/web-dist/assets/diagram-WEI45ONY-DhKZuwxW.js +41 -0
- package/web-dist/assets/dist-DoH_9pyS.js +1 -0
- package/web-dist/assets/ebnfDiagram-CCIWWBDH-B7BiO-NR.js +1 -0
- package/web-dist/assets/erDiagram-Q63AITRT-DHjG5RDQ.js +85 -0
- package/web-dist/assets/eventmodeling-45OFAUF4-C0eV8RBx.js +1 -0
- package/web-dist/assets/flowDiagram-23GEKE2U-BbldRZUK.js +1 -0
- package/web-dist/assets/ganttDiagram-NO4QXBWP-BZ-98wTa.js +292 -0
- package/web-dist/assets/gitGraph-TEB2WS4Q-nF22R2jO.js +1 -0
- package/web-dist/assets/gitGraphDiagram-IHSO6WYX-CoYQQ22V.js +106 -0
- package/web-dist/assets/graphlib-B8gBHxth.js +1 -0
- package/web-dist/assets/index-BHzIN-tQ.js +357 -0
- package/web-dist/assets/index-Duiy4w7C.css +1 -0
- package/web-dist/assets/info-DKCQHKI2-Cy2BCbBW.js +1 -0
- package/web-dist/assets/infoDiagram-FWYZ7A6U-CmhR2R1x.js +2 -0
- package/web-dist/assets/init-D6jRqBbL.js +1 -0
- package/web-dist/assets/ishikawaDiagram-FXEZZL3T-CfG59afl.js +70 -0
- package/web-dist/assets/journeyDiagram-5HDEW3XC-BvFmG20q.js +139 -0
- package/web-dist/assets/kanban-definition-HUTT4EX6-BHGEecsY.js +89 -0
- package/web-dist/assets/katex-CddkPoXu.js +257 -0
- package/web-dist/assets/line-Ds4xvN3d.js +1 -0
- package/web-dist/assets/linear-z2V0wJk9.js +1 -0
- package/web-dist/assets/map-DsCK-0Cs.js +1 -0
- package/web-dist/assets/mermaid-parser.core-Gow4kgYJ.js +7 -0
- package/web-dist/assets/mermaid.core-B7Qc_kbm.js +11 -0
- package/web-dist/assets/mindmap-definition-LN4V7U3C-ayXKGcy_.js +96 -0
- package/web-dist/assets/ordinal-hYBb2elL.js +1 -0
- package/web-dist/assets/packet-7NZHBO7P-oLBdtkD8.js +1 -0
- package/web-dist/assets/path-BWPyau1x.js +1 -0
- package/web-dist/assets/pegDiagram-2B236MQR-DwJyc_1B.js +1 -0
- package/web-dist/assets/pie-RZYD4A2V-CXzad3fT.js +1 -0
- package/web-dist/assets/pieDiagram-ENE6RG2P-CZzgSGtM.js +39 -0
- package/web-dist/assets/quadrantDiagram-ABIIQ3AL--IPIp8yo.js +7 -0
- package/web-dist/assets/radar-I7S5WNFK-DldySvtZ.js +1 -0
- package/web-dist/assets/railroad-3IZDKUUU-CiWFZfXM.js +1 -0
- package/web-dist/assets/railroad-abnf-AHOZXSZD-bXMYUOCD.js +1 -0
- package/web-dist/assets/railroad-ebnf-EBAXGLYW-D11ZwZby.js +1 -0
- package/web-dist/assets/railroad-peg-LSFZ7HO6-DD1AC5X-.js +1 -0
- package/web-dist/assets/railroadDiagram-RFXS5EU6-DcCMNGGb.js +1 -0
- package/web-dist/assets/requirementDiagram-TGXJPOKE-C-6j8urk.js +84 -0
- package/web-dist/assets/rolldown-runtime-Bh1tDfsg.js +1 -0
- package/web-dist/assets/rough.esm-CSKSodPl.js +1 -0
- package/web-dist/assets/sankeyDiagram-HTMAVEWB-CPtiOpdz.js +40 -0
- package/web-dist/assets/sequenceDiagram-DBY2YBRQ-B8FnQTdn.js +162 -0
- package/web-dist/assets/sizeCapture-X5ZJPWSS-B0uUizjq.js +1 -0
- package/web-dist/assets/src-C4XfhTaE.js +1 -0
- package/web-dist/assets/stateDiagram-2N3HPSRC-CxG9nX2P.js +1 -0
- package/web-dist/assets/stateDiagram-v2-6OUMAXLB-SGarOFIy.js +1 -0
- package/web-dist/assets/swimlanes-5IMT3BWC-D49-t0hV.js +2 -0
- package/web-dist/assets/swimlanesDiagram-G3AALYLV-krF0bh4b.js +8 -0
- package/web-dist/assets/timeline-definition-FHXFAJF6-C9OEd9SE.js +120 -0
- package/web-dist/assets/treeView-QDETBFTQ-D1AKqVPa.js +1 -0
- package/web-dist/assets/treemap-6X3UGDF4-BRssB7hM.js +1 -0
- package/web-dist/assets/vennDiagram-L72KCM5P-CftkAbY0.js +34 -0
- package/web-dist/assets/wardley-OPB4EBWU-RLMgzRq4.js +1 -0
- package/web-dist/assets/wardleyDiagram-EHGQE667-BLytgXPS.js +78 -0
- package/web-dist/assets/xychartDiagram-FW5EYKEG-BYgK1Y3H.js +7 -0
- package/web-dist/favicon-16.png +0 -0
- package/web-dist/favicon-32.png +0 -0
- package/web-dist/favicon.svg +78 -0
- 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 +33 -0
- package/web-dist/manifest.webmanifest +16 -0
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* fray — 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 `.fray/.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
|
+
* FRAY_IDLE_MIN (default 10) — quiet this long → soft idle note.
|
|
41
|
+
* FRAY_DROPPED_MIN (default 45, FRAY_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/fray 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 frayDir = join(projectDir, '.fray');
|
|
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(frayDir).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(frayDir, 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(`fray: 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, '.fray', `${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 frayDir = join(projectDir, '.fray');
|
|
248
|
+
const newest = newestBindingByThread(projectDir);
|
|
249
|
+
const rested = restedAgentIds(projectDir);
|
|
250
|
+
let files;
|
|
251
|
+
try {
|
|
252
|
+
files = readdirSync(frayDir).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(frayDir, 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 frayDir = join(projectDir, '.fray');
|
|
320
|
+
const newest = newestBindingByThread(projectDir);
|
|
321
|
+
const downstream = downstreamThreads(projectDir);
|
|
322
|
+
let files;
|
|
323
|
+
try {
|
|
324
|
+
files = readdirSync(frayDir).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(frayDir, 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
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* fray — DERIVED agent state. The single shared derivation used by BOTH the Stop-hook
|
|
4
|
+
* liveness helper (`./agent-liveness.mjs`) and the board (`./index.mjs`), so an agent's
|
|
5
|
+
* reported state can never drift between the two.
|
|
6
|
+
*
|
|
7
|
+
* THE PRINCIPLE — compute, don't store (the same rule the board already follows for
|
|
8
|
+
* thread status). The thread↔agent binding (`.fray/.agent-bindings.jsonl`, written
|
|
9
|
+
* AUTOMATICALLY by the agent-bind hook) records ONLY immutable-at-dispatch facts
|
|
10
|
+
* (`agentId → thread`, plus a label); it carries NO per-agent `status`. (The old
|
|
11
|
+
* hand-maintained `agents:` frontmatter is gone; a lingering one is an ignored no-op.)
|
|
12
|
+
* Every liveness/doneness judgement is DERIVED here from ground truth:
|
|
13
|
+
*
|
|
14
|
+
* - output-file (`tasks/<id>.output`) mtime → how long since the agent last wrote,
|
|
15
|
+
* - the THREAD's own `status:` (done/dismissed = terminal) → whether the orchestrator
|
|
16
|
+
* has deliberately reconciled the thread.
|
|
17
|
+
*
|
|
18
|
+
* There is NO durable per-agent COMPLETION signal: a rest (`.rested-agents.jsonl`) records
|
|
19
|
+
* an `agent_id` but a rest is NOT "done" (an agent rests repeatedly). So "done" is INFERRED
|
|
20
|
+
* (terminal-or-stale output + thread status), never read from a stored per-agent flag. That
|
|
21
|
+
* is exactly why the old hand-maintained `status` field drifted and false-flagged a
|
|
22
|
+
* completed agent as idle; deriving it makes that drift class structurally impossible.
|
|
23
|
+
*
|
|
24
|
+
* Derived states (one per dispatched agent — keyed on the thread's NEWEST agent only):
|
|
25
|
+
* - 'terminal' — nothing to flag, for ANY of: the THREAD is terminal (done/dismissed);
|
|
26
|
+
* the thread is PARKED (non-terminal but not `active`); or the thread is
|
|
27
|
+
* DOWNSTREAM (a PR is landing via the merge cascade, so it is
|
|
28
|
+
* legitimately active while it merges, not because an agent is stuck).
|
|
29
|
+
* - 'dropped' — the conservative, high-confidence "no live agent" signal: an `active`
|
|
30
|
+
* thread whose newest agent's output is stale beyond `droppedMin` AND
|
|
31
|
+
* which has rested at least once (ended a turn) — so it is quiet at a
|
|
32
|
+
* stopping point, not mid-tool-call. Likely finished-but-unreconciled or
|
|
33
|
+
* genuinely dropped. THE one signal that matters.
|
|
34
|
+
* - 'idle' — quiet, but NOT confidently dropped: output between `idleMin` and
|
|
35
|
+
* `droppedMin`, OR stale-but-never-rested (still inside one long tool
|
|
36
|
+
* call — a build/test — so alive). Informational only; say it softly.
|
|
37
|
+
* - 'fresh' — output recent (<=idleMin): actively working, say nothing.
|
|
38
|
+
* - 'unknown' — no readable output file (placeholder id, never-started): can't
|
|
39
|
+
* judge; fail-open (say nothing).
|
|
40
|
+
*
|
|
41
|
+
* WHY a generous `droppedMin` (45m, vs the old 25m). Sub-agents now legitimately arm CI
|
|
42
|
+
* watchers and go quiet for long stretches while CI runs (30–40m), so "no output for 25m" is
|
|
43
|
+
* NORMAL, not stuck — the old threshold cried wolf. 45m gives a watcher room to fire and
|
|
44
|
+
* resume the agent (which refreshes its output) before we ever call it dropped; the bar is
|
|
45
|
+
* deliberately "better to miss a soft case than nag a benign one."
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
import { readdirSync, statSync } from 'node:fs';
|
|
49
|
+
import { join } from 'node:path';
|
|
50
|
+
|
|
51
|
+
export const DEFAULT_IDLE_MIN = 10;
|
|
52
|
+
export const DEFAULT_DROPPED_MIN = 45;
|
|
53
|
+
// Back-compat alias for the old name; repointed to the new, generous default.
|
|
54
|
+
export const DEFAULT_FROZEN_MIN = DEFAULT_DROPPED_MIN;
|
|
55
|
+
// The WATCHER/AGENT DROP-GUARD threshold (min): a dispatched agent that has RUN this long while
|
|
56
|
+
// still emitting output (looks alive) but has produced NO terminal thread result gets a LOUD
|
|
57
|
+
// "verify directly" flag — the #327 forcing function (a `ci-watch` hung on a nameless ghost check
|
|
58
|
+
// polls forever and looks alive while stranded; "watcher running = fine" then gets trusted). This
|
|
59
|
+
// is DISTINCT from DROPPED_MIN (stale + rested = went quiet): the drop-guard targets the
|
|
60
|
+
// looks-alive-but-maybe-hung band. Measured from the binding `ts` (dispatch time), not output age.
|
|
61
|
+
export const DEFAULT_LONG_RUNTIME_MIN = 35;
|
|
62
|
+
|
|
63
|
+
// Env-resolved thresholds, defined ONCE here so EVERY consumer (the Stop-hook liveness helper
|
|
64
|
+
// AND the board) reads the same knobs and can never disagree. FRAY_FROZEN_MIN is honored as the
|
|
65
|
+
// old alias for FRAY_DROPPED_MIN.
|
|
66
|
+
export const IDLE_MIN = parseInt(process.env.FRAY_IDLE_MIN || '', 10) || DEFAULT_IDLE_MIN;
|
|
67
|
+
export const DROPPED_MIN = parseInt(process.env.FRAY_DROPPED_MIN || process.env.FRAY_FROZEN_MIN || '', 10) || DEFAULT_DROPPED_MIN;
|
|
68
|
+
export const LONG_RUNTIME_MIN = parseInt(process.env.FRAY_LONG_RUNTIME_MIN || '', 10) || DEFAULT_LONG_RUNTIME_MIN;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* GROUND-TRUTH age of an agent's last activity, in minutes — globbed across ALL local
|
|
72
|
+
* Claude task dirs (`<tmp>/claude-<uid>/<project>/<session>/tasks/<id>.output`). Unlike
|
|
73
|
+
* the Stop hook's `deriveTasksDir` (which has the transcript_path and so knows the exact
|
|
74
|
+
* session), the BOARD runs standalone with no session id, so it must search every session
|
|
75
|
+
* for the agent's output symlink. `statSync` follows the symlink → the TARGET transcript's
|
|
76
|
+
* real last-write (the symlink's own mtime is stale). Returns the freshest match's age, or
|
|
77
|
+
* null when no output file exists (placeholder id / never-started / different machine).
|
|
78
|
+
* Fail-open: any error → null.
|
|
79
|
+
* @param {string} agentId
|
|
80
|
+
* @param {number} [now]
|
|
81
|
+
* @returns {number|null} minutes since last activity, or null
|
|
82
|
+
*/
|
|
83
|
+
export function findAgentOutputAge(agentId, now = Date.now()) {
|
|
84
|
+
if (!agentId) return null;
|
|
85
|
+
let best = null; // most-recent mtimeMs found
|
|
86
|
+
try {
|
|
87
|
+
for (const base of ['/tmp', '/private/tmp']) {
|
|
88
|
+
let claudeDirs;
|
|
89
|
+
try {
|
|
90
|
+
claudeDirs = readdirSync(base).filter((d) => d.startsWith('claude-'));
|
|
91
|
+
} catch {
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
for (const cd of claudeDirs) {
|
|
95
|
+
const root = join(base, cd);
|
|
96
|
+
let projects;
|
|
97
|
+
try {
|
|
98
|
+
projects = readdirSync(root);
|
|
99
|
+
} catch {
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
for (const proj of projects) {
|
|
103
|
+
let sessions;
|
|
104
|
+
try {
|
|
105
|
+
sessions = readdirSync(join(root, proj));
|
|
106
|
+
} catch {
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
for (const sess of sessions) {
|
|
110
|
+
try {
|
|
111
|
+
const st = statSync(join(root, proj, sess, 'tasks', `${agentId}.output`));
|
|
112
|
+
if (best == null || st.mtimeMs > best) best = st.mtimeMs;
|
|
113
|
+
} catch {
|
|
114
|
+
/* not in this session */
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
} catch {
|
|
121
|
+
/* fail-open */
|
|
122
|
+
}
|
|
123
|
+
return best == null ? null : (now - best) / 60000;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Derive one agent's state PURELY from ground truth. No per-agent stored status is
|
|
128
|
+
* consulted — `ageMin` comes from the output-file mtime and `threadTerminal` from the
|
|
129
|
+
* thread's own `status:` frontmatter.
|
|
130
|
+
*
|
|
131
|
+
* @param {object} a
|
|
132
|
+
* @param {number|null} a.ageMin minutes since the agent's output last changed, or
|
|
133
|
+
* null when there is no readable output file.
|
|
134
|
+
* @param {boolean} a.threadTerminal is the owning thread's status done/dismissed?
|
|
135
|
+
* @param {boolean} [a.threadActive] is the owning thread's status exactly `active`? Only
|
|
136
|
+
* `active` threads are "being worked" by an agent, so
|
|
137
|
+
* only they can have an UNRECONCILED/idle agent. Other
|
|
138
|
+
* non-terminal phases (planning/planned/blocked)
|
|
139
|
+
* with a done/absent agent are EXPECTED, not a drift
|
|
140
|
+
* signal — never flagged. NOTE `planning` is human-or-
|
|
141
|
+
* architect-driven design and may legitimately have NO
|
|
142
|
+
* live agent, so it is deliberately NOT `active` here and
|
|
143
|
+
* never triggers the no-live-agent warning. (Defaults
|
|
144
|
+
* true for backward-compat when a caller doesn't pass it.)
|
|
145
|
+
* @param {boolean} [a.threadDownstream] does the thread have a PR landing via the merge
|
|
146
|
+
* cascade (`.fray/merge-queue.jsonl`)? Such a thread is
|
|
147
|
+
* legitimately `active` while it merges — the producing
|
|
148
|
+
* agent has finished and reconciled — so it is never a
|
|
149
|
+
* drop. (Defaults false.)
|
|
150
|
+
* @param {boolean} [a.hasRested] has the agent recorded at least one rest (ended a
|
|
151
|
+
* turn)? Stale output + NO rest = still inside one long
|
|
152
|
+
* tool call (a build/test) → ALIVE, never 'dropped'.
|
|
153
|
+
* (Defaults true for back-compat with callers that don't
|
|
154
|
+
* supply it.)
|
|
155
|
+
* @param {number} [a.idleMin] idle threshold (min). Default {@link DEFAULT_IDLE_MIN}.
|
|
156
|
+
* @param {number} [a.droppedMin] dropped/stale threshold (min). Default {@link DEFAULT_DROPPED_MIN}.
|
|
157
|
+
* @param {number} [a.frozenMin] DEPRECATED alias for `droppedMin` (old callers).
|
|
158
|
+
* @returns {'terminal'|'dropped'|'idle'|'fresh'|'unknown'}
|
|
159
|
+
*/
|
|
160
|
+
export function deriveAgentState({ ageMin, threadTerminal, threadActive = true, threadDownstream = false, hasRested = true, idleMin = DEFAULT_IDLE_MIN, droppedMin = DEFAULT_DROPPED_MIN, frozenMin }) {
|
|
161
|
+
if (frozenMin != null) droppedMin = frozenMin; // back-compat: old callers passed frozenMin
|
|
162
|
+
// A reconciled thread is the orchestrator's deliberate "I folded this" signal — the
|
|
163
|
+
// only mutable bit in the whole loop, and it lives on the THREAD, not the agent.
|
|
164
|
+
if (threadTerminal) return 'terminal';
|
|
165
|
+
// A non-`active` non-terminal phase (planning/planned/blocked) is also a
|
|
166
|
+
// deliberate orchestrator state — a stale/done/absent agent on such a thread is EXPECTED
|
|
167
|
+
// (the work finished, the thread awaits a human/dep, or `planning` is human-driven with no
|
|
168
|
+
// agent at all), NOT a drop. Only an `active` thread is "being worked right now" by an
|
|
169
|
+
// agent, so only it can have a drift-signal agent.
|
|
170
|
+
if (!threadActive) return 'terminal';
|
|
171
|
+
// DOWNSTREAM: a PR is landing for this thread via the cascade — it is active for the
|
|
172
|
+
// merge, not a stuck agent. The producing agent has finished + been reconciled; suppress.
|
|
173
|
+
if (threadDownstream) return 'terminal';
|
|
174
|
+
if (ageMin == null) return 'unknown'; // no activity file → can't judge (fail-open)
|
|
175
|
+
if (ageMin > droppedMin) return hasRested ? 'dropped' : 'idle'; // stale + rested = quiet at a stopping point (the signal); stale + never-rested = still in one long tool call → alive
|
|
176
|
+
if (ageMin > idleMin) return 'idle';
|
|
177
|
+
return 'fresh';
|
|
178
|
+
}
|