heyspex 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/README.md +157 -0
- package/bin/hs.mjs +1028 -0
- package/bin/postinstall.mjs +88 -0
- package/dev-factory/lib/fleet-readiness.mjs +274 -0
- package/dev-factory/lib/fleet-state.mjs +250 -0
- package/dev-factory/lib/sufficiency-score.mjs +441 -0
- package/dist/cli/hs-check.mjs +747 -0
- package/dist/cli/hs-claim.mjs +541 -0
- package/dist/cli/hs-connect.mjs +3436 -0
- package/dist/cli/hs-defer.mjs +8455 -0
- package/dist/cli/hs-env.mjs +36845 -0
- package/dist/cli/hs-init.mjs +1145 -0
- package/dist/cli/hs-install.mjs +1390 -0
- package/dist/cli/hs-join.mjs +38553 -0
- package/dist/cli/hs-vault-pull.mjs +7782 -0
- package/dist/cli/hs-vault.mjs +4166 -0
- package/dist/cli/hs-workspace.mjs +2898 -0
- package/dist/cli-templates/customer-vault/specs/entity-model.md +1735 -0
- package/dist/cli-templates/customer-vault/specs/protocols/canonical-frontmatter-templates.md +531 -0
- package/dist/cli-templates/customer-vault/specs/protocols/memory-protocol.md +48 -0
- package/dist/cli-templates/customer-vault/specs/protocols/spec-hierarchy.md +548 -0
- package/docs/mcp-client-config.md +200 -0
- package/package.json +124 -0
- package/scripts/check-mcp-freshness.mjs +379 -0
- package/scripts/dev-server-status.mjs +722 -0
- package/scripts/gateway-skills-lib.mjs +421 -0
- package/scripts/lib/console-url-resolver.mjs +152 -0
- package/scripts/lib/dev-mode.mjs +369 -0
- package/scripts/lib/hs-link.mjs +154 -0
- package/scripts/lib/hs-source-commit.mjs +207 -0
- package/scripts/lib/hs-source-log.mjs +366 -0
- package/scripts/lib/hs-source-preflight.mjs +402 -0
- package/scripts/lib/hs-source-push.mjs +307 -0
- package/scripts/lib/hs-source-secret-scan.mjs +263 -0
- package/scripts/lib/standalone-doctor.mjs +89 -0
- package/scripts/vault-id-lib.mjs +173 -0
- package/vendor/heyspex-mcp/alignment-gate.mjs +1347 -0
- package/vendor/heyspex-mcp/blocker-resolver.mjs +891 -0
- package/vendor/heyspex-mcp/breakdown-ladder.mjs +309 -0
- package/vendor/heyspex-mcp/breakdown-source.mjs +236 -0
- package/vendor/heyspex-mcp/breakdown-writer.mjs +524 -0
- package/vendor/heyspex-mcp/brief-observability.mjs +67 -0
- package/vendor/heyspex-mcp/canvas-inbox.mjs +130 -0
- package/vendor/heyspex-mcp/capture-active-rehydrate.mjs +76 -0
- package/vendor/heyspex-mcp/capture-buffer-patches.mjs +147 -0
- package/vendor/heyspex-mcp/capture-channel.mjs +577 -0
- package/vendor/heyspex-mcp/capture-console-client.mjs +290 -0
- package/vendor/heyspex-mcp/capture-focus.mjs +1366 -0
- package/vendor/heyspex-mcp/capture-kind-suggest.mjs +121 -0
- package/vendor/heyspex-mcp/capture-outbox.mjs +239 -0
- package/vendor/heyspex-mcp/capture-stage-gate.mjs +160 -0
- package/vendor/heyspex-mcp/cli.mjs +15754 -0
- package/vendor/heyspex-mcp/config-settings.mjs +419 -0
- package/vendor/heyspex-mcp/console-fetch.mjs +342 -0
- package/vendor/heyspex-mcp/defer-spec.mjs +527 -0
- package/vendor/heyspex-mcp/deferred-reclaim.mjs +284 -0
- package/vendor/heyspex-mcp/design-tools.mjs +449 -0
- package/vendor/heyspex-mcp/dev-factory-bridge.mjs +66 -0
- package/vendor/heyspex-mcp/diagnose-skeleton.mjs +114 -0
- package/vendor/heyspex-mcp/fleet-queue-tools.mjs +241 -0
- package/vendor/heyspex-mcp/hooks-engine.mjs +399 -0
- package/vendor/heyspex-mcp/login-handoff.mjs +438 -0
- package/vendor/heyspex-mcp/mcp-subprocess.mjs +136 -0
- package/vendor/heyspex-mcp/mvp-mode.mjs +101 -0
- package/vendor/heyspex-mcp/next-resolution.mjs +240 -0
- package/vendor/heyspex-mcp/next-review.mjs +548 -0
- package/vendor/heyspex-mcp/peer-presence.mjs +430 -0
- package/vendor/heyspex-mcp/plan-ground-conflicts.mjs +321 -0
- package/vendor/heyspex-mcp/plan-ground.mjs +2001 -0
- package/vendor/heyspex-mcp/plan-ingest.mjs +476 -0
- package/vendor/heyspex-mcp/plan-push.mjs +1826 -0
- package/vendor/heyspex-mcp/plan-review.mjs +1419 -0
- package/vendor/heyspex-mcp/preflight-gate.mjs +0 -0
- package/vendor/heyspex-mcp/procedure-tools.mjs +970 -0
- package/vendor/heyspex-mcp/promote-capture.mjs +1280 -0
- package/vendor/heyspex-mcp/promote-diagnose.mjs +187 -0
- package/vendor/heyspex-mcp/promote-research-to-diagnose.mjs +338 -0
- package/vendor/heyspex-mcp/promote-research.mjs +1553 -0
- package/vendor/heyspex-mcp/promotion-helpers.mjs +280 -0
- package/vendor/heyspex-mcp/read-path.mjs +138 -0
- package/vendor/heyspex-mcp/recipe-tools.mjs +258 -0
- package/vendor/heyspex-mcp/regen-tasks.mjs +326 -0
- package/vendor/heyspex-mcp/register-spec.mjs +417 -0
- package/vendor/heyspex-mcp/research-focus.mjs +476 -0
- package/vendor/heyspex-mcp/research-tools.mjs +109 -0
- package/vendor/heyspex-mcp/research-type-suggest.mjs +112 -0
- package/vendor/heyspex-mcp/resolution-focus.mjs +45 -0
- package/vendor/heyspex-mcp/review-focus.mjs +55 -0
- package/vendor/heyspex-mcp/review-rollup.mjs +47 -0
- package/vendor/heyspex-mcp/run-front.mjs +60 -0
- package/vendor/heyspex-mcp/scheduler.mjs +813 -0
- package/vendor/heyspex-mcp/seat-identity.mjs +281 -0
- package/vendor/heyspex-mcp/settings-tools.mjs +119 -0
- package/vendor/heyspex-mcp/spec-id-lib.mjs +790 -0
- package/vendor/heyspex-mcp/split-research.mjs +832 -0
- package/vendor/heyspex-mcp/submit-for-review.mjs +190 -0
- package/vendor/heyspex-mcp/summary-contract.mjs +162 -0
- package/vendor/heyspex-mcp/test-helpers.mjs +43 -0
- package/vendor/heyspex-mcp/validate-linkage.mjs +574 -0
- package/vendor/heyspex-mcp/vault-resolve.mjs +297 -0
- package/vendor/heyspex-mcp/vault-settings.mjs +87 -0
- package/vendor/heyspex-mcp/verify-reproduction.mjs +314 -0
- package/vendor/heyspex-mcp/why-now.mjs +64 -0
- package/vendor/heyspex-mcp/yaml-scalar.mjs +34 -0
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Peer presence — every tool response tells the calling agent which OTHER agent
|
|
3
|
+
* sessions are live and what they hold ([[HS-89JR-ARLV-EPIC-peer-presence-on-every-heyspex-call]],
|
|
4
|
+
* owner directive 2026-08-14; capture [[HS-3XIE-GVNH-CAP-parallel-agents-are-still-stepping-on-ea]] —
|
|
5
|
+
* six cross-session collisions in one day because coordination data existed but
|
|
6
|
+
* was never pushed to the agents).
|
|
7
|
+
*
|
|
8
|
+
* Claim SSOT is the console (`GET /api/claims`) — NOT `.heyspex/active-claims.json`,
|
|
9
|
+
* which is stale on disk (HS-AG04). The fetch is fail-open with a short timeout,
|
|
10
|
+
* a positive cache (a burst of tool calls does not multiply requests) and a
|
|
11
|
+
* negative cache (a down console must not add timeout latency to every call).
|
|
12
|
+
* Presence INFORMS; leases enforce (HS-O4J0) — nothing here ever blocks a write.
|
|
13
|
+
*
|
|
14
|
+
* Pure core + thin shell (unit-test-authoring-protocol): buildPeerAgentsBlock is
|
|
15
|
+
* pure and fully tested against fixtures; fetchPeerAgents/appendPeerPresence are
|
|
16
|
+
* the fail-open shells the cli.mjs dispatch calls.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { readFileSync } from 'node:fs';
|
|
20
|
+
import { join } from 'node:path';
|
|
21
|
+
|
|
22
|
+
export const PEER_AGENTS_DEFAULT_LIMIT = 8;
|
|
23
|
+
export const PEER_DIRS_CAP = 4;
|
|
24
|
+
// [[HS-QQJP]] — "someone is working under spex-console/src" is nearly every file
|
|
25
|
+
// in this repo, so the round-2 two-segment cap does not answer "will my next
|
|
26
|
+
// write collide". Depth is the one direction of the four in the task that was
|
|
27
|
+
// not already shipped: stage (row.stage) and recency (row.last_active) already
|
|
28
|
+
// exist per session, and "count shape" already exists as files_touched. Depth
|
|
29
|
+
// alone was the gap, verified against .heyspex/wip-state.json's real shape
|
|
30
|
+
// (2026-08-15): most files cluster several segments deep under one project
|
|
31
|
+
// (e.g. companion/src/HeySpex.Companion.Core/Gather/*.cs), so a fixed two-segment
|
|
32
|
+
// cap throws away exactly the information a peer needs. Deepen ONLY when at
|
|
33
|
+
// least two files agree past the two-segment root — a lone file going deeper
|
|
34
|
+
// would just be a disguised file path, which the contract forbids.
|
|
35
|
+
export const PEER_DIR_HINT_MAX_DEPTH = 4;
|
|
36
|
+
// [[HS-O8YN]] — 1200ms lost the race against a real dev-server /api/claims read
|
|
37
|
+
// in 4 of 8 live-measured samples (2026-08-15): 2325, 2767, 1559, 845, 901,
|
|
38
|
+
// 1341, 920, 889 ms — 4/8 over 1200ms, worst case 2767ms. 3000ms covers every
|
|
39
|
+
// sample above with headroom. Do not lower this without a fresh measurement.
|
|
40
|
+
const DEFAULT_TIMEOUT_MS = 3000;
|
|
41
|
+
export const PEER_PRESENCE_DEFAULT_TIMEOUT_MS = DEFAULT_TIMEOUT_MS;
|
|
42
|
+
const CACHE_TTL_MS = 5_000;
|
|
43
|
+
const NEGATIVE_CACHE_MS = 30_000;
|
|
44
|
+
|
|
45
|
+
// [[HS-ZYPM]] — the 1500-char budget was stated in round 2 and certified at 1442
|
|
46
|
+
// against a synthetic fixture, but nothing in code ever checked it. Measured
|
|
47
|
+
// against the REAL .heyspex/wip-state.json ledger the same block lands at 1500 at
|
|
48
|
+
// six peers and 1965 at eight — and it lands on EVERY tool response, which is the
|
|
49
|
+
// whole reason the budget exists. A stated limit nothing checks is not a limit,
|
|
50
|
+
// so the render path now trims to fit and reports what it dropped.
|
|
51
|
+
export const PEER_BLOCK_CHAR_BUDGET = 1500;
|
|
52
|
+
const PEER_BLOCK_PREFIX = 'PEER AGENTS — other live sessions; avoid claiming their specs or editing their scope: ';
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Directory segments for one WIP path (filename dropped), normalized to
|
|
56
|
+
* forward slashes. Returns null for non-strings/empty paths; [] for a
|
|
57
|
+
* root-level file (no directory to report — keeping its bare name would put
|
|
58
|
+
* a full file path in the block, which the scope-hint contract forbids,
|
|
59
|
+
* HS-NKMN AC2). Callers that only want the round-2 two-segment cap use
|
|
60
|
+
* `baseDirKey`; buildPeerAgentsBlock uses the full segment list to check
|
|
61
|
+
* whether a group of files agrees on a deeper common root (HS-QQJP).
|
|
62
|
+
*/
|
|
63
|
+
function dirSegments(path) {
|
|
64
|
+
if (typeof path !== 'string') return null;
|
|
65
|
+
const segments = path.replace(/\\/g, '/').split('/').filter(Boolean);
|
|
66
|
+
if (segments.length === 0) return null;
|
|
67
|
+
return segments.slice(0, -1);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The round-2 grouping key: "." for root-level files, else the first two directory segments. */
|
|
71
|
+
function baseDirKey(segments) {
|
|
72
|
+
return segments.length === 0 ? '.' : segments.slice(0, 2).join('/');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Given every file that reduced to the same `baseDirKey`, decide whether they
|
|
77
|
+
* agree on a directory deeper than the two-segment default and, if so, report
|
|
78
|
+
* that deeper shared root instead — up to PEER_DIR_HINT_MAX_DEPTH segments.
|
|
79
|
+
* Requires at least two files: a lone file going deeper would just be a
|
|
80
|
+
* disguised file path (HS-NKMN's "never a full path" contract), and it isn't
|
|
81
|
+
* "agreement" if there is only one data point.
|
|
82
|
+
*/
|
|
83
|
+
function deepestCommonHint(key, segsList) {
|
|
84
|
+
if (key === '.' || segsList.length < 2) return key;
|
|
85
|
+
const baseDepth = key.split('/').length;
|
|
86
|
+
const minLen = Math.min(...segsList.map((s) => s.length));
|
|
87
|
+
const cap = Math.min(PEER_DIR_HINT_MAX_DEPTH, minLen);
|
|
88
|
+
let depth = baseDepth;
|
|
89
|
+
while (depth < cap && segsList.every((s) => s[depth] === segsList[0][depth])) depth++;
|
|
90
|
+
return segsList[0].slice(0, depth).join('/');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Pure core: turn the console's claims list (+ optional wip-state) into a compact
|
|
95
|
+
* peer_agents block, or null when no OTHER live session exists.
|
|
96
|
+
*
|
|
97
|
+
* @param {object} opts
|
|
98
|
+
* @param {Array} opts.claims rows from GET /api/claims (specId, agent, sessionId, effectiveStatus, staleAt, expiresAt, lastActivityAt, currentStage, …)
|
|
99
|
+
* @param {object} [opts.wip] parsed .heyspex/wip-state.json ({ [specId]: { sessionId, filesTouched: [...] } })
|
|
100
|
+
* @param {number} [opts.nowMs] clock injection for staleness tests
|
|
101
|
+
* @param {string} [opts.selfSessionId] the CALLER's session — always excluded
|
|
102
|
+
* @param {number} [opts.limit] max rows before truncation (more_count carries the rest)
|
|
103
|
+
* @returns {{peer_agents: Array, more_count?: number} | null}
|
|
104
|
+
*/
|
|
105
|
+
export function buildPeerAgentsBlock({ claims, wip = null, nowMs = Date.now(), selfSessionId = null, limit = PEER_AGENTS_DEFAULT_LIMIT }) {
|
|
106
|
+
if (!Array.isArray(claims) || claims.length === 0) return null;
|
|
107
|
+
const bySession = new Map();
|
|
108
|
+
for (const c of claims) {
|
|
109
|
+
if (!c || typeof c !== 'object') continue;
|
|
110
|
+
const sid = c.sessionId ?? null;
|
|
111
|
+
if (!sid || sid === selfSessionId) continue;
|
|
112
|
+
const staleAtMs = c.staleAt ? Date.parse(c.staleAt) : null;
|
|
113
|
+
const expiresAtMs = c.expiresAt ? Date.parse(c.expiresAt) : null;
|
|
114
|
+
if ((staleAtMs != null && staleAtMs < nowMs) || (expiresAtMs != null && expiresAtMs < nowMs)) continue;
|
|
115
|
+
if (c.effectiveStatus != null && c.effectiveStatus !== 'working') continue;
|
|
116
|
+
const row = bySession.get(sid) ?? {
|
|
117
|
+
_sid: sid,
|
|
118
|
+
_activityMs: 0,
|
|
119
|
+
agent: c.agent ?? 'unknown',
|
|
120
|
+
session: String(sid).slice(0, 8),
|
|
121
|
+
spec_ids: [],
|
|
122
|
+
stage: null,
|
|
123
|
+
last_active: null,
|
|
124
|
+
};
|
|
125
|
+
if (c.specId && !row.spec_ids.includes(c.specId)) row.spec_ids.push(c.specId);
|
|
126
|
+
if (c.currentStage) row.stage = c.currentStage;
|
|
127
|
+
const activityMs = c.lastActivityAt ? Date.parse(c.lastActivityAt) : null;
|
|
128
|
+
if (activityMs != null && activityMs > row._activityMs) {
|
|
129
|
+
row._activityMs = activityMs;
|
|
130
|
+
row.last_active = c.lastActivityAt;
|
|
131
|
+
}
|
|
132
|
+
bySession.set(sid, row);
|
|
133
|
+
}
|
|
134
|
+
if (bySession.size === 0) return null;
|
|
135
|
+
|
|
136
|
+
// Scope hints from the WIP ledger: how many files that session's work touches
|
|
137
|
+
// (the COUNT only, never the list — mcp-tool-response-size-budget) plus WHICH
|
|
138
|
+
// directories, as de-duped two-segment hints capped at PEER_DIRS_CAP (first
|
|
139
|
+
// seen, round-2 order preserved) with a dirs_more overflow count. Only the
|
|
140
|
+
// FIRST shown bucket is widened to the deepest root ALL of its files agree
|
|
141
|
+
// on, up to PEER_DIR_HINT_MAX_DEPTH ([[HS-QQJP]]) — deepening every shown
|
|
142
|
+
// bucket, plus maxing every other field at once (long agent names, the
|
|
143
|
+
// longest stage verb, 4-digit files_touched), measured 2231-2583 chars on
|
|
144
|
+
// an 8-row block, well over the 1500 budget; depth on one root per row
|
|
145
|
+
// against round 2's own already-certified 1442-char fixture measures 1479
|
|
146
|
+
// (see peer-presence.test.mjs "measured" case) — 21 chars of headroom, so
|
|
147
|
+
// this is deliberately spent on the single most collision-relevant root per
|
|
148
|
+
// row, not spread across all four. Advisory visibility only — derived from
|
|
149
|
+
// this same wip read (zero new round trips), never a file list, never a
|
|
150
|
+
// claim or lock ([[HS-45FR-A9SI-RES-parallel-agent-collision-enforcement]]
|
|
151
|
+
// §"Design constraint — derive scope, don't claim it").
|
|
152
|
+
if (wip && typeof wip === 'object') {
|
|
153
|
+
for (const row of bySession.values()) {
|
|
154
|
+
let filesTouched = 0;
|
|
155
|
+
const buckets = new Map(); // baseDirKey -> segments[] for every file in that bucket, in first-seen order
|
|
156
|
+
for (const [specId, entry] of Object.entries(wip)) {
|
|
157
|
+
if (!entry || typeof entry !== 'object') continue;
|
|
158
|
+
const matches = entry.sessionId === row._sid || row.spec_ids.includes(specId);
|
|
159
|
+
if (!matches || !Array.isArray(entry.filesTouched)) continue;
|
|
160
|
+
filesTouched = Math.max(filesTouched, entry.filesTouched.length);
|
|
161
|
+
for (const file of entry.filesTouched) {
|
|
162
|
+
const segs = dirSegments(file);
|
|
163
|
+
if (segs == null) continue;
|
|
164
|
+
const key = baseDirKey(segs);
|
|
165
|
+
let bucket = buckets.get(key);
|
|
166
|
+
if (!bucket) buckets.set(key, (bucket = []));
|
|
167
|
+
bucket.push(segs);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (filesTouched > 0) row.files_touched = filesTouched;
|
|
171
|
+
if (buckets.size > 0) {
|
|
172
|
+
const dirs = [];
|
|
173
|
+
let i = 0;
|
|
174
|
+
for (const [key, segsList] of buckets) {
|
|
175
|
+
if (i >= PEER_DIRS_CAP) break;
|
|
176
|
+
dirs.push(i === 0 ? deepestCommonHint(key, segsList) : key);
|
|
177
|
+
i++;
|
|
178
|
+
}
|
|
179
|
+
row.dirs = dirs;
|
|
180
|
+
const overflow = buckets.size - dirs.length;
|
|
181
|
+
if (overflow > 0) row.dirs_more = overflow;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const rows = [...bySession.values()]
|
|
187
|
+
.sort((a, b) => b._activityMs - a._activityMs)
|
|
188
|
+
.map(({ _sid, _activityMs, ...publicRow }) => publicRow);
|
|
189
|
+
const capped = rows.slice(0, limit);
|
|
190
|
+
const moreCount = rows.length - capped.length;
|
|
191
|
+
return { peer_agents: capped, ...(moreCount > 0 ? { more_count: moreCount } : {}) };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Best-effort local read of the WIP ledger — null on any problem, never throws. */
|
|
195
|
+
export function readWipState(vaultRoot) {
|
|
196
|
+
try {
|
|
197
|
+
if (!vaultRoot) return null;
|
|
198
|
+
const parsed = JSON.parse(readFileSync(join(vaultRoot, '.heyspex', 'wip-state.json'), 'utf8'));
|
|
199
|
+
return parsed && typeof parsed === 'object' ? parsed : null;
|
|
200
|
+
} catch {
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Positive + negative caches — module-level on purpose (one MCP server process
|
|
206
|
+
// serves one session; a burst of tool calls shares one claims snapshot).
|
|
207
|
+
let claimsCache = { atMs: 0, url: null, claims: null };
|
|
208
|
+
let failedUntilMs = 0;
|
|
209
|
+
// [[HS-O8YN]] direction 3: last successfully built block + when, so a failed
|
|
210
|
+
// fetch can serve slightly-stale data instead of nothing (the negative cache is
|
|
211
|
+
// already 30s, so a stale block is strictly better than silence). Null block
|
|
212
|
+
// still counts as "known" — it means the last real check found no peers.
|
|
213
|
+
let lastGoodBlock = null;
|
|
214
|
+
let lastGoodAtMs = 0;
|
|
215
|
+
let lastGoodChecked = false;
|
|
216
|
+
|
|
217
|
+
/** Test hook: clear all caches between cases. */
|
|
218
|
+
export function resetPeerPresenceCache() {
|
|
219
|
+
claimsCache = { atMs: 0, url: null, claims: null };
|
|
220
|
+
failedUntilMs = 0;
|
|
221
|
+
lastGoodBlock = null;
|
|
222
|
+
lastGoodAtMs = 0;
|
|
223
|
+
lastGoodChecked = false;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async function readClaimsFromConsole(consoleUrl, fetchFn, timeoutMs, headers) {
|
|
227
|
+
const url = `${String(consoleUrl).replace(/\/+$/, '')}/api/claims`;
|
|
228
|
+
const init = { signal: AbortSignal.timeout?.(timeoutMs) };
|
|
229
|
+
// Cloud console gates /api/claims behind the same bearer every other console
|
|
230
|
+
// call sends (guardMcpAuth) — an unauthenticated probe 401s on EVERY tool
|
|
231
|
+
// call there, rendering the "PEER AGENTS UNAVAILABLE" line while the calls
|
|
232
|
+
// themselves succeed (HS-0C12-3UIK sibling finding). No headers → exactly
|
|
233
|
+
// the old request, so the local dev console path is byte-identical.
|
|
234
|
+
if (headers && Object.keys(headers).length > 0) init.headers = headers;
|
|
235
|
+
const res = await fetchFn(url, init);
|
|
236
|
+
if (!res?.ok) return null;
|
|
237
|
+
const body = await res.json();
|
|
238
|
+
return Array.isArray(body?.claims) ? body.claims : null;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Build the failure-path result: stale-but-known data beats nothing (direction 3). */
|
|
242
|
+
function staleResult(nowMs) {
|
|
243
|
+
if (lastGoodChecked) {
|
|
244
|
+
return { block: lastGoodBlock, ok: false, stale: true, ageMs: nowMs - lastGoodAtMs };
|
|
245
|
+
}
|
|
246
|
+
return { block: null, ok: false, stale: false, ageMs: null };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Internal shell: fetch live claims (cached) and build the block, reporting HOW
|
|
251
|
+
* the result was obtained — `ok: true` for a fresh/cached-fresh read, `ok: false`
|
|
252
|
+
* for a failed/timed-out read (optionally with a stale `block` served from the
|
|
253
|
+
* last known-good check). This is the piece that lets a caller tell "checked,
|
|
254
|
+
* no peers" apart from "could not check" ([[HS-O8YN]]) — fetchPeerAgents below
|
|
255
|
+
* collapses both failure shapes back to null for callers that only want the block.
|
|
256
|
+
* Fail-open — any error, non-OK response, or unreachable console never throws.
|
|
257
|
+
*/
|
|
258
|
+
async function resolvePeerPresence({
|
|
259
|
+
consoleUrl,
|
|
260
|
+
selfSessionId = null,
|
|
261
|
+
vaultRoot = null,
|
|
262
|
+
fetchFn = fetch,
|
|
263
|
+
nowMs = Date.now(),
|
|
264
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
265
|
+
limit = PEER_AGENTS_DEFAULT_LIMIT,
|
|
266
|
+
cacheTtlMs = CACHE_TTL_MS,
|
|
267
|
+
negativeCacheMs = NEGATIVE_CACHE_MS,
|
|
268
|
+
/** Outbound auth header(s) for the console read (cloud bearer). Optional. */
|
|
269
|
+
headers = null,
|
|
270
|
+
} = {}) {
|
|
271
|
+
try {
|
|
272
|
+
if (!consoleUrl) return { block: null, ok: false, stale: false, ageMs: null };
|
|
273
|
+
let claims = null;
|
|
274
|
+
if (claimsCache.claims && claimsCache.url === consoleUrl && nowMs - claimsCache.atMs < cacheTtlMs) {
|
|
275
|
+
claims = claimsCache.claims;
|
|
276
|
+
} else if (nowMs < failedUntilMs) {
|
|
277
|
+
return staleResult(nowMs);
|
|
278
|
+
} else {
|
|
279
|
+
claims = await readClaimsFromConsole(consoleUrl, fetchFn, timeoutMs, headers);
|
|
280
|
+
if (claims == null) {
|
|
281
|
+
failedUntilMs = nowMs + negativeCacheMs;
|
|
282
|
+
return staleResult(nowMs);
|
|
283
|
+
}
|
|
284
|
+
claimsCache = { atMs: nowMs, url: consoleUrl, claims };
|
|
285
|
+
failedUntilMs = 0;
|
|
286
|
+
}
|
|
287
|
+
const block = buildPeerAgentsBlock({ claims, wip: readWipState(vaultRoot), nowMs, selfSessionId, limit });
|
|
288
|
+
lastGoodBlock = block;
|
|
289
|
+
lastGoodAtMs = nowMs;
|
|
290
|
+
lastGoodChecked = true;
|
|
291
|
+
return { block, ok: true, stale: false, ageMs: null };
|
|
292
|
+
} catch {
|
|
293
|
+
failedUntilMs = nowMs + negativeCacheMs;
|
|
294
|
+
return staleResult(nowMs);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Thin shell: fetch live claims (cached) and build the block. Fail-open — any
|
|
300
|
+
* error, non-OK response, or unreachable console returns null and arms the
|
|
301
|
+
* negative cache so subsequent tool calls skip the attempt entirely. Kept as a
|
|
302
|
+
* plain block-or-null contract for callers that don't need failure visibility;
|
|
303
|
+
* appendPeerPresence uses resolvePeerPresence directly instead.
|
|
304
|
+
*/
|
|
305
|
+
export async function fetchPeerAgents(opts = {}) {
|
|
306
|
+
const { block } = await resolvePeerPresence(opts);
|
|
307
|
+
return block;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Trim a block until the line it renders to fits PEER_BLOCK_CHAR_BUDGET ([[HS-ZYPM]]).
|
|
312
|
+
* Pure — the caller's block is never mutated.
|
|
313
|
+
*
|
|
314
|
+
* What goes first is what costs most and is worth least. The directory hints are
|
|
315
|
+
* the entire overage: the same 8-row block measures 1965 chars with hints and
|
|
316
|
+
* 1141 without, so hints are shed before anything else, and always from whichever
|
|
317
|
+
* row is currently carrying the most. That way every row keeps its FIRST hint —
|
|
318
|
+
* the deepest, most collision-relevant one — for as long as possible, which is
|
|
319
|
+
* the only hint a peer usually acts on.
|
|
320
|
+
*
|
|
321
|
+
* Nothing is dropped quietly. A shed hint is added to that row's `dirs_more`, a
|
|
322
|
+
* dropped row is added to `more_count`, and `budget_trimmed: true` marks that the
|
|
323
|
+
* trim came from the budget rather than from PEER_AGENTS_DEFAULT_LIMIT.
|
|
324
|
+
*
|
|
325
|
+
* The last row is never dropped, even if it alone exceeds the budget. Rendering
|
|
326
|
+
* an empty peer list to save characters would tell a live agent it is alone when
|
|
327
|
+
* it is not, which is the exact failure [[HS-O8YN]] exists to prevent.
|
|
328
|
+
*/
|
|
329
|
+
export function fitPeerAgentsBlock(block, opts = {}) {
|
|
330
|
+
const budget = opts.budget ?? PEER_BLOCK_CHAR_BUDGET;
|
|
331
|
+
const prefixLength = opts.prefixLength ?? PEER_BLOCK_PREFIX.length;
|
|
332
|
+
if (!block || !Array.isArray(block.peer_agents) || block.peer_agents.length === 0) return block;
|
|
333
|
+
if (prefixLength + JSON.stringify(block).length <= budget) return block;
|
|
334
|
+
|
|
335
|
+
const rows = block.peer_agents.map((r) => (Array.isArray(r.dirs) ? { ...r, dirs: [...r.dirs] } : { ...r }));
|
|
336
|
+
const current = () => {
|
|
337
|
+
const dropped = block.peer_agents.length - rows.length;
|
|
338
|
+
const out = { ...block, peer_agents: rows, budget_trimmed: true };
|
|
339
|
+
if (dropped > 0) out.more_count = (block.more_count ?? 0) + dropped;
|
|
340
|
+
return out;
|
|
341
|
+
};
|
|
342
|
+
const over = () => prefixLength + JSON.stringify(current()).length > budget;
|
|
343
|
+
|
|
344
|
+
// Ties break toward the BOTTOM of the list in both passes below. Rows arrive
|
|
345
|
+
// sorted most-recently-active first, and the peer most likely to collide with
|
|
346
|
+
// your next write is the one that just wrote something — so the quiet sessions
|
|
347
|
+
// give up their hints before the busy ones do.
|
|
348
|
+
const widestFromTail = () => {
|
|
349
|
+
let widest = null;
|
|
350
|
+
for (const r of rows) {
|
|
351
|
+
if (Array.isArray(r.dirs) && r.dirs.length > 1 && (widest === null || r.dirs.length >= widest.dirs.length)) widest = r;
|
|
352
|
+
}
|
|
353
|
+
return widest;
|
|
354
|
+
};
|
|
355
|
+
const lastHolder = () => {
|
|
356
|
+
for (let i = rows.length - 1; i >= 0; i--) {
|
|
357
|
+
if (Array.isArray(rows[i].dirs) && rows[i].dirs.length > 0) return rows[i];
|
|
358
|
+
}
|
|
359
|
+
return null;
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
// 1 — shed hints from the widest row, keeping at least one hint on every row.
|
|
363
|
+
while (over()) {
|
|
364
|
+
const widest = widestFromTail();
|
|
365
|
+
if (widest === null) break;
|
|
366
|
+
widest.dirs.pop();
|
|
367
|
+
widest.dirs_more = (widest.dirs_more ?? 0) + 1;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// 2 — still over: give up the last hint rows are holding, quietest first.
|
|
371
|
+
while (over()) {
|
|
372
|
+
const holder = lastHolder();
|
|
373
|
+
if (holder === null) break;
|
|
374
|
+
holder.dirs.pop();
|
|
375
|
+
holder.dirs_more = (holder.dirs_more ?? 0) + 1;
|
|
376
|
+
if (holder.dirs.length === 0) delete holder.dirs;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// 3 — still over: drop rows from the tail. buildPeerAgentsBlock sorts most
|
|
380
|
+
// recently active first, so the quietest sessions go, and more_count says how
|
|
381
|
+
// many are missing.
|
|
382
|
+
while (over() && rows.length > 1) rows.pop();
|
|
383
|
+
|
|
384
|
+
return current();
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** Render the block as the single text line appended to tool responses. */
|
|
388
|
+
export function formatPeerAgentsText(block) {
|
|
389
|
+
if (!block || !Array.isArray(block.peer_agents) || block.peer_agents.length === 0) return null;
|
|
390
|
+
return `${PEER_BLOCK_PREFIX}${JSON.stringify(fitPeerAgentsBlock(block))}`;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Render an explicit failure/staleness line — an agent must never read silence
|
|
395
|
+
* as "no peers" ([[HS-O8YN]]). "Confirmed empty" and "could not check" are
|
|
396
|
+
* opposite states and must never render identically. When a last-known-good
|
|
397
|
+
* block exists it is served WITH its age so staleness is visible, not implied.
|
|
398
|
+
*/
|
|
399
|
+
function formatUnavailableText(state) {
|
|
400
|
+
if (state.stale && state.block && Array.isArray(state.block.peer_agents) && state.block.peer_agents.length > 0) {
|
|
401
|
+
const ageSec = Math.max(0, Math.round(state.ageMs / 1000));
|
|
402
|
+
// Same budget applies here ([[HS-ZYPM]]) — this line has a LONGER prefix than
|
|
403
|
+
// the normal one, so it gets less room for rows, not more.
|
|
404
|
+
const prefix = `PEER AGENTS UNAVAILABLE (live check failed) — showing last known state from ${ageSec}s ago, may be stale: `;
|
|
405
|
+
return `${prefix}${JSON.stringify(fitPeerAgentsBlock(state.block, { prefixLength: prefix.length }))}`;
|
|
406
|
+
}
|
|
407
|
+
return 'PEER AGENTS UNAVAILABLE — could not reach the console to check for other live sessions. Peer presence is unknown, not confirmed empty.';
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Dispatch wrapper ([[HS-GMNO]]): append the peer_agents line to an MCP tool
|
|
412
|
+
* result. Applied on EVERY tools/call response (unlike the once-per-session seat
|
|
413
|
+
* banner). Absolutely fail-open — a presence failure must never break a response,
|
|
414
|
+
* but per [[HS-O8YN]] it must never be SILENT either: a failed/timed-out check
|
|
415
|
+
* always appends an explicit "unavailable" line instead of returning `result`
|
|
416
|
+
* unchanged, so an agent can never mistake "could not check" for "no peers".
|
|
417
|
+
*/
|
|
418
|
+
export async function appendPeerPresence(result, opts = {}) {
|
|
419
|
+
try {
|
|
420
|
+
const state = await resolvePeerPresence(opts);
|
|
421
|
+
const line = state.ok ? formatPeerAgentsText(state.block) : formatUnavailableText(state);
|
|
422
|
+
if (!line) return result;
|
|
423
|
+
if (result && Array.isArray(result.content)) {
|
|
424
|
+
return { ...result, content: [...result.content, { type: 'text', text: `\n${line}` }] };
|
|
425
|
+
}
|
|
426
|
+
return result;
|
|
427
|
+
} catch {
|
|
428
|
+
return result;
|
|
429
|
+
}
|
|
430
|
+
}
|