agent-coord-mcp 0.26.14 → 0.26.16

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.
Files changed (42) hide show
  1. package/dist/capabilities.js +158 -0
  2. package/dist/capabilities.js.map +1 -0
  3. package/dist/server-identity.js +82 -0
  4. package/dist/server-identity.js.map +1 -0
  5. package/dist/server.js +61 -4
  6. package/dist/server.js.map +1 -1
  7. package/dist/tools/board-ref.js +164 -0
  8. package/dist/tools/board-ref.js.map +1 -0
  9. package/dist/tools/events.js +64 -7
  10. package/dist/tools/events.js.map +1 -1
  11. package/dist/tools/record-events.js +9 -2
  12. package/dist/tools/record-events.js.map +1 -1
  13. package/dist/tools/records.js +15 -1
  14. package/dist/tools/records.js.map +1 -1
  15. package/dist/tools/registry.js +3 -0
  16. package/dist/tools/registry.js.map +1 -1
  17. package/dist/tools/stall.js +25 -24
  18. package/dist/tools/stall.js.map +1 -1
  19. package/dist/tools/transport.js +26 -56
  20. package/dist/tools/transport.js.map +1 -1
  21. package/hooks/peek-coord.mjs +0 -0
  22. package/hooks/tmux-pusher.mjs +0 -0
  23. package/package.json +14 -11
  24. package/scripts/check-test-count.mjs +152 -91
  25. package/scripts/coord-attention-clock.mjs +0 -0
  26. package/scripts/coord-node.sh +0 -0
  27. package/scripts/coord-stall-clock.mjs +0 -0
  28. package/scripts/coord-token.mjs +0 -0
  29. package/scripts/probe-tmux-liveness.sh +0 -0
  30. package/scripts/spawn-agent.sh +0 -0
  31. package/scripts/stop-agent.sh +0 -0
  32. package/scripts/typed-record-stats.mjs +0 -0
  33. package/src/capabilities.ts +188 -0
  34. package/src/server-identity.ts +83 -0
  35. package/src/server.ts +70 -2
  36. package/src/tools/board-ref.ts +194 -0
  37. package/src/tools/events.ts +89 -9
  38. package/src/tools/record-events.ts +9 -2
  39. package/src/tools/records.ts +15 -1
  40. package/src/tools/registry.ts +17 -0
  41. package/src/tools/stall.ts +38 -24
  42. package/src/tools/transport.ts +26 -57
@@ -29,8 +29,26 @@ export type Subscription = {
29
29
  kind: SubKind;
30
30
  target: string;
31
31
  createdAt: number;
32
- /** null until this subscription has been EVALUATED at least once. */
32
+ /**
33
+ * null until an event OF THIS KIND was evaluated against this subscription.
34
+ *
35
+ * NOT "the machinery ran" — see lastScannedAt. Those were one field, and the
36
+ * collapse is the defect: `evaluate()` only touches subscriptions whose kind
37
+ * matches the event, so an `item` subscriber sat at `never evaluated` for a
38
+ * week while `pr` events flowed past it. Its health said the wiring was dead;
39
+ * the wiring was fine and nothing of its kind had happened.
40
+ */
33
41
  lastEvaluatedAt: number | null;
42
+ /**
43
+ * null until a scan RAN with this subscription registered — regardless of
44
+ * kind. A scan that ran is evidence the wiring works; it says nothing about
45
+ * whether your kind fired, which is the other field.
46
+ *
47
+ * Optional on disk: subscriptions written before this existed have no value,
48
+ * and absent must read as UNKNOWN rather than as "never scanned". Fabricating
49
+ * a scan nobody observed is the failure this whole phase is about.
50
+ */
51
+ lastScannedAt?: number | null;
34
52
  lastEventAt: number | null;
35
53
  /** Idempotency keys already delivered, for 6.4. */
36
54
  delivered: string[];
@@ -60,17 +78,53 @@ function writeSubs(subs: Subscription[]): void {
60
78
  * subscription with no last-evaluated mark has produced no evidence of
61
79
  * anything, and "no events" is the same output a broken subscription gives.
62
80
  */
63
- export function subscriptionHealth(s: Subscription): { level: "ok" | "error"; detail: string } {
64
- if (s.lastEvaluatedAt === null)
81
+ export function subscriptionHealth(s: Subscription): { level: "ok" | "error" | "unknown"; detail: string } {
82
+ const iso = (n: number) => new Date(n).toISOString();
83
+
84
+ // THREE STATES, BECAUSE THERE ARE THREE FACTS. They were two, and the
85
+ // collapse cost a week: an `item` subscriber read `error — never evaluated`
86
+ // the whole time, because `evaluate()` only touches subscriptions whose kind
87
+ // matches the event and no `item` event had fired. The wiring was fine. The
88
+ // health field said it was dead, and the agent holding it believed the field.
89
+ //
90
+ // Same shape as `alive` swallowing `heartbeatFresh`, and as
91
+ // `stall_clock_status` reporting green while covering nothing: one field
92
+ // answering two questions always answers the easier one.
93
+ if (s.lastScannedAt === undefined && s.lastEvaluatedAt === null) {
94
+ // Written before this field existed AND never evaluated. Absent is UNKNOWN,
95
+ // never "never scanned" — inventing a scan nobody observed is the failure
96
+ // this phase is about, and asserting one never happened is the same error
97
+ // pointed the other way.
98
+ return {
99
+ level: "unknown",
100
+ detail:
101
+ "predates scan tracking and has never been evaluated — whether the machinery has run for it CANNOT be determined from this record. " +
102
+ "It will resolve to ok or error on the next scan; until then this is an absence of evidence, not evidence of absence.",
103
+ };
104
+ }
105
+ if (!s.lastScannedAt) {
65
106
  return {
66
107
  level: "error",
67
- detail: `never evaluated — this subscription has produced no evidence it is wired to anything. "No events yet" and "never ran" are the same output, and only one of them is healthy.`,
108
+ detail:
109
+ "NEVER SCANNED — no scan has run with this subscription registered, so it has produced no evidence of being wired to anything. " +
110
+ "This is the state that means broken.",
111
+ };
112
+ }
113
+ if (s.lastEvaluatedAt === null) {
114
+ // THE STATE THAT USED TO READ AS BROKEN. The machinery ran and nothing of
115
+ // this kind happened, which is a healthy idle watch.
116
+ return {
117
+ level: "ok",
118
+ detail:
119
+ `scanned ${iso(s.lastScannedAt)}, never evaluated — the machinery RAN and no '${s.kind}' event has occurred yet. ` +
120
+ "Scanned-and-quiet is healthy; it is not the same as never run, and those shared a field until now.",
68
121
  };
122
+ }
69
123
  return {
70
124
  level: "ok",
71
125
  detail: s.lastEventAt
72
- ? `last evaluated ${new Date(s.lastEvaluatedAt).toISOString()}, last event ${new Date(s.lastEventAt).toISOString()}`
73
- : `last evaluated ${new Date(s.lastEvaluatedAt).toISOString()}, no events yet — evaluated and quiet, which is different from never run`,
126
+ ? `scanned ${iso(s.lastScannedAt)}, last evaluated ${iso(s.lastEvaluatedAt)}, last event ${iso(s.lastEventAt)}`
127
+ : `scanned ${iso(s.lastScannedAt)}, last evaluated ${iso(s.lastEvaluatedAt)}, no events yet`,
74
128
  };
75
129
  }
76
130
 
@@ -122,12 +176,26 @@ export type Delivery = { subscriptionId: string; agentId: string; key: string; s
122
176
  * A subscription only learns it is alive by being evaluated, so the mark is
123
177
  * written for every subscription of that kind, not only the ones that fired.
124
178
  */
179
+ /**
180
+ * Record that a scan RAN, for every live subscription regardless of kind.
181
+ *
182
+ * Called once per scan — including a scan that produced NO events, which is
183
+ * exactly the case that starved the old field: no events of your kind means
184
+ * `evaluate` never touches you, and a subscription that is never touched cannot
185
+ * be told from one that is not wired to anything.
186
+ */
187
+ export function markScanned(subs: Subscription[], now: number): Subscription[] {
188
+ return subs.map((s) => ({ ...s, lastScannedAt: now }));
189
+ }
190
+
125
191
  export function evaluate(subs: Subscription[], ev: RecordEvent, now: number): { subs: Subscription[]; deliveries: Delivery[] } {
126
192
  const key = eventKey(ev.kind, ev.target, ev.ref);
127
193
  const deliveries: Delivery[] = [];
128
194
  const next = subs.map((s) => {
129
- if (s.kind !== ev.kind) return s;
130
- const evaluated = { ...s, lastEvaluatedAt: now };
195
+ // Every subscription observed this scan, whatever its kind.
196
+ const scanned = { ...s, lastScannedAt: now };
197
+ if (s.kind !== ev.kind) return scanned;
198
+ const evaluated = { ...scanned, lastEvaluatedAt: now };
131
199
  if (s.target !== ev.target) return evaluated;
132
200
  if (s.delivered.includes(key)) {
133
201
  deliveries.push({ subscriptionId: s.id, agentId: s.agentId, key, status: "duplicate-suppressed" });
@@ -157,6 +225,12 @@ export async function subscribeTool(args: { agentId: string; kind: SubKind; targ
157
225
  kind: args.kind,
158
226
  target: args.target,
159
227
  createdAt: Date.now(),
228
+ // EXPLICIT null, never left absent. `null` means "we know no scan has run
229
+ // for this"; `undefined` means "this record predates the field and we
230
+ // cannot say". A subscription created now is the first case, and writing it
231
+ // explicitly is what keeps a brand-new registration from being reported as
232
+ // unknowable.
233
+ lastScannedAt: null,
160
234
  lastEvaluatedAt: null,
161
235
  lastEventAt: null,
162
236
  delivered: [],
@@ -185,6 +259,7 @@ export async function listSubscriptionsTool(args: { agentId?: string }) {
185
259
  const subs = args.agentId ? all.filter((s) => s.agentId === args.agentId) : all;
186
260
  const rows = subs.map((s) => ({ ...s, health: subscriptionHealth(s) }));
187
261
  const neverEvaluated = rows.filter((r) => r.health.level === "error");
262
+ const undetermined = rows.filter((r) => r.health.level === "unknown");
188
263
  return {
189
264
  ok: neverEvaluated.length === 0,
190
265
  // Population beside the verdict, always: "no subscriptions" and "none
@@ -192,7 +267,12 @@ export async function listSubscriptionsTool(args: { agentId?: string }) {
192
267
  population: { listed: rows.length, total: all.length },
193
268
  subscriptions: rows,
194
269
  ...(neverEvaluated.length
195
- ? { error: `${neverEvaluated.length} of ${rows.length} subscription(s) have NEVER been evaluated — they have produced no evidence of being wired to anything.` }
270
+ ? { error: `${neverEvaluated.length} of ${rows.length} subscription(s) have NEVER BEEN SCANNED — no scan has run with them registered, so they have produced no evidence of being wired to anything.` }
271
+ : {}),
272
+ ...(undetermined.length
273
+ ? {
274
+ undetermined: `${undetermined.length} of ${rows.length} subscription(s) predate scan tracking and cannot be judged yet — they resolve on the next scan. Reported rather than counted as either healthy or broken.`,
275
+ }
196
276
  : {}),
197
277
  };
198
278
  }
@@ -257,7 +257,7 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
257
257
  import path from "node:path";
258
258
  import { z } from "zod";
259
259
  import { ROOT } from "../store.js";
260
- import { readSubs, evaluate, commitEvaluation, eventIsDerived } from "./events.js";
260
+ import { readSubs, evaluate, commitEvaluation, eventIsDerived, markScanned } from "./events.js";
261
261
 
262
262
  /**
263
263
  * The watermark: the last commit whose record change has been turned into
@@ -349,8 +349,15 @@ export async function scanRecordEventsTool(args: { repo: string; since?: string;
349
349
 
350
350
  const deliveries: unknown[] = [];
351
351
  if (args.write) {
352
- let subs = readSubs();
352
+ // MARK THE SCAN BEFORE THE EVENTS, and unconditionally.
353
+ //
354
+ // A scan that produced NO events is exactly the case that starved the old
355
+ // health field: `evaluate` is per-event, so a quiet scan touched nothing
356
+ // and every subscription kept reading "never evaluated" — indistinguishable
357
+ // from unwired. The scan RAN; that is a fact about the machinery and it is
358
+ // recorded whether or not anything fired.
353
359
  const now = Date.now();
360
+ let subs = markScanned(readSubs(), now);
354
361
  for (const ev of emitted) {
355
362
  const r = evaluate(subs, ev, now);
356
363
  subs = r.subs;
@@ -27,6 +27,7 @@ import {
27
27
  sweepTagOf,
28
28
  } from "@davidbalzan/groundwork-seam";
29
29
  import { ensureWorktreeTool } from "./worktrees.js";
30
+ import { boardRefFor } from "./board-ref.js";
30
31
  import { haltState } from "./stall.js";
31
32
  import { readSubs, evaluate, commitEvaluation, eventIsDerived, type RecordEvent } from "./events.js";
32
33
 
@@ -340,7 +341,20 @@ export async function claimTool(args: { project: string; agentId: string; itemId
340
341
  };
341
342
  }
342
343
 
343
- const boardHunk = `| ${keyOf(item)} | ${args.agentId} | \`${wt.branch}\` · ${wt.path} | 🚧 In Progress | — | claimed |`;
344
+ // THE CELL NAMES THE REMOTE-TRACKING REF, not the bare local branch.
345
+ //
346
+ // A bare name resolves in the checkout that created it and nowhere else, so
347
+ // the same board read differently from two checkouts and whether a row was
348
+ // measurable depended on who last ran `git fetch` — a property of the reader
349
+ // rather than of the work. Writing `origin/<branch>` makes the cell mean the
350
+ // same thing everywhere.
351
+ //
352
+ // It does not resolve YET, because a freshly cut branch is unpushed. That is
353
+ // correct and `stall_check` says so in those words: an unpushed lane has no
354
+ // shared evidence of activity, which is an absence of evidence rather than a
355
+ // stall. Enforced at the WRITE as well as the read (board-ref.ts) — a rule the
356
+ // writer can defeat is a rule the writer defeats.
357
+ const boardHunk = `| ${keyOf(item)} | ${args.agentId} | \`${boardRefFor(wt.branch ?? "")}\` · ${wt.path} | 🚧 In Progress | — | claimed |`;
344
358
 
345
359
  // 2.4b — THE VERB WRITES THE ROW. Reported by default, applied with
346
360
  // write:true, matching `land`. A returned hunk that a human pastes is the
@@ -472,10 +472,25 @@ export type ClaimEvidence = {
472
472
  samePane: boolean;
473
473
  boundElsewhere: number;
474
474
  reasons: string[];
475
+ /**
476
+ * SESSION processes still holding this identity, structured rather than
477
+ * buried in `reasons` prose.
478
+ *
479
+ * A live SESSION binding is not the same fact as a live TRANSPORT marker, and
480
+ * conflating them is what let a refusal read as routine. The marker's pid is
481
+ * the pusher daemon — expected, and doing its job. A session binding for this
482
+ * id, alive, that is not this process, is a LEFTOVER PROCESS holding an
483
+ * identity: yesterday two sessions held worker-3's, and the refusal that
484
+ * reported it sent the reader to re-join.
485
+ *
486
+ * Named so the reader can look at the pid instead of routing around it.
487
+ */
488
+ leakedSessions: { pid: number; via: string }[];
475
489
  };
476
490
 
477
491
  export async function liveClaimEvidence(agentId: string, now: number): Promise<ClaimEvidence> {
478
492
  const reasons: string[] = [];
493
+ const leakedSessions: { pid: number; via: string }[] = [];
479
494
  let verifiable = true;
480
495
  let samePane = false;
481
496
  let heartbeatFresh = false;
@@ -529,6 +544,7 @@ export async function liveClaimEvidence(agentId: string, now: number): Promise<C
529
544
  if (!s || s.agentId !== agentId || s.pid === process.pid) continue;
530
545
  if (isPidAlive(s.pid)) {
531
546
  boundElsewhere++;
547
+ leakedSessions.push({ pid: s.pid, via: String(s.via ?? "unknown") });
532
548
  reasons.push(`another live session (pid ${s.pid}, via ${s.via}) is already bound to this id`);
533
549
  }
534
550
  }
@@ -539,6 +555,7 @@ export async function liveClaimEvidence(agentId: string, now: number): Promise<C
539
555
  samePane,
540
556
  boundElsewhere,
541
557
  reasons,
558
+ leakedSessions,
542
559
  };
543
560
  }
544
561
 
@@ -21,6 +21,7 @@ import path from "node:path";
21
21
  import { z } from "zod";
22
22
  import { parseWorkDoc, workstreamsV1RowsOf } from "@davidbalzan/groundwork-seam";
23
23
  import { ROOT, AGENTS_FILE, readJson } from "../store.js";
24
+ import { classifyBoardRef, refInCell } from "./board-ref.js";
24
25
  import { loadLiveTransports } from "./registry.js";
25
26
 
26
27
  const BOARD_DOC = "docs/WORKSTREAMS.md";
@@ -31,7 +32,19 @@ const haltFile = () => path.join(ROOT, "halt.json");
31
32
 
32
33
  export type StallHit =
33
34
  | { kind: "no-heartbeat"; agentId: string; stream: string; minutes: number }
34
- | { kind: "no-vcs-activity"; agentId: string; branch: string; minutes: number };
35
+ | { kind: "no-vcs-activity"; agentId: string; branch: string; minutes: number }
36
+ /**
37
+ * A 🚧 row whose branch HAS ALREADY LANDED. Not a stall — the opposite: the
38
+ * work finished and nobody closed the row.
39
+ *
40
+ * It exists because the VCS axis was catching this BY ACCIDENT and is about to
41
+ * stop. A merged ref is frozen, so it reported an ever-growing "no activity"
42
+ * age, and that false stall was the only thing surfacing a real routing
43
+ * failure: a lane sitting In Progress with nothing routed to it. Making the
44
+ * stall honest would have silently removed the signal, so the signal gets its
45
+ * own name instead of inheriting a wrong one.
46
+ */
47
+ | { kind: "stale-row"; agentId: string; branch: string; why: string };
35
48
 
36
49
  // ---------- halt ----------
37
50
 
@@ -343,47 +356,48 @@ export async function stallCheckTool(args: { repo?: string; stallMinutes?: numbe
343
356
  // heartbeats. An unresolvable value is REPORTED as unmeasurable rather than
344
357
  // skipped in silence: a silent `continue` and a healthy agent produce the
345
358
  // same output, which is the failure this whole verb exists to avoid.
346
- const raw = (row.branchWorktree.match(/`([^`]+)`/)?.[1] ?? "").trim();
347
- if (!raw) {
348
- unmeasurable.push({ agentId, value: "", why: "no value in the Branch · Worktree cell" });
359
+ // ONE CLASSIFIER, SHARED WITH `claim` (board-ref.ts). The old block asked
360
+ // only "does it resolve", and three of the four ways this cell has been
361
+ // wrong resolved cleanly — a shared ref, a local-only ref, and a merged
362
+ // ref. Resolution was never the property; the property is whether the ref's
363
+ // movement is THIS AGENT'S WORK.
364
+ const verdict = classifyBoardRef(repo, agentId, row.branchWorktree);
365
+ if (verdict.kind === "merged") {
366
+ // A VERDICT, NOT AN ABSENCE — and it counts as coverage, because the check
367
+ // did learn something about this lane: its work landed and its row is
368
+ // stale. Reported as its own kind rather than as a stall, which is what
369
+ // the frozen ref was reporting it as.
370
+ hits.push({ kind: "stale-row", agentId, branch: refInCell(row.branchWorktree), why: verdict.why });
371
+ measured.add(agentId);
349
372
  continue;
350
373
  }
351
- let sha = "";
352
- try {
353
- sha = execFileSync("git", ["rev-parse", "--verify", "--quiet", `${raw}^{commit}`], {
354
- cwd: repo, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"],
355
- }).trim();
356
- } catch {
357
- sha = "";
358
- }
359
- if (!sha) {
360
- unmeasurable.push({
361
- agentId,
362
- value: raw,
363
- why: `'${raw}' does not resolve as a git ref — it is a path or glob, and \`git log <path>\` would silently report that PATH's last commit as this agent's activity`,
364
- });
374
+ if (verdict.kind !== "measurable") {
375
+ unmeasurable.push({ agentId, value: refInCell(row.branchWorktree), why: verdict.why });
365
376
  continue;
366
377
  }
367
378
  try {
368
379
  // The resolved SHA, and `--`: a ref can then never be re-read as a
369
380
  // pathspec, which is the ambiguity that produced the wrong number.
381
+ const sha = execFileSync("git", ["rev-parse", "--verify", "--quiet", `${verdict.ref}^{commit}`], {
382
+ cwd: repo, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"],
383
+ }).trim();
370
384
  const iso = execFileSync("git", ["log", "-1", "--format=%cI", sha, "--"], {
371
385
  cwd: repo, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"],
372
386
  }).trim();
373
387
  const age = now - Date.parse(iso);
374
388
  measured.add(agentId);
375
389
  if (age > limit) {
376
- hits.push({ kind: "no-vcs-activity", agentId, branch: raw, minutes: Math.round(age / 60000) });
390
+ hits.push({ kind: "no-vcs-activity", agentId, branch: verdict.ref, minutes: Math.round(age / 60000) });
377
391
  }
378
392
  } catch {
379
- unmeasurable.push({ agentId, value: raw, why: "resolved as a ref but its log could not be read" });
393
+ unmeasurable.push({ agentId, value: verdict.ref, why: "resolved as a ref but its log could not be read" });
380
394
  }
381
395
  }
382
396
 
383
- // COVERAGE, not "did it run". A row is COVERED when at least one predicate
384
- // produced a verdict about it; a row where every predicate came back
385
- // unmeasurable was looked at and not measured, and counting it as checked is
386
- // how "the clock ran" gets mistaken for "the fleet is observed".
397
+ // COVERAGE, not "did it run". A row is COVERED when a predicate produced a
398
+ // verdict about it; a row where every predicate came back unmeasurable was
399
+ // looked at and not measured, and counting it as checked is how "the clock
400
+ // ran" gets mistaken for "the fleet is observed".
387
401
  const owners = inFlight.map((r) => r.owner.replace(/[`*]/g, "").trim()).filter(Boolean);
388
402
  const blind = [...new Set(owners)].filter((id) => !measured.has(id));
389
403
  const measurable = Math.max(0, inFlight.length - blind.length);
@@ -5,6 +5,15 @@ import { execFileSync } from "node:child_process";
5
5
  import { registerTool } from "./registry.js";
6
6
  import { roleInputSchema, type RoleArg } from "../roles.js";
7
7
  import { attributeWriter, isGitRepo, lastWriterOf, loadScopes, ownsDocument } from "./scopes.js";
8
+ // Moved to a LEAF so `status` can report capabilities without a cycle — see
9
+ // src/server-identity.ts. Re-exported so every existing importer is untouched.
10
+ export { resolveServerIdentity } from "../server-identity.js";
11
+ import { resolveServerIdentity } from "../server-identity.js";
12
+ // ONE-WAY EDGE, and the direction is the point: `status` CALLS the capabilities
13
+ // verb rather than re-deriving its probes. Two probes of the same thing is the
14
+ // four-matchers defect committed deliberately, which is why 17.4 was deferred
15
+ // behind 13.7 instead of built beside it.
16
+ import { capabilitiesTool } from "../capabilities.js";
8
17
  import { sendMessageTool, readMessagesTool } from "./messaging.js";
9
18
  import { randomUUID } from "node:crypto";
10
19
  import { existsSync, openSync, readFileSync, watch } from "node:fs";
@@ -835,63 +844,6 @@ function resolvePusherPath(): string {
835
844
  return path.resolve(here, "..", "..", "hooks", "tmux-pusher.mjs");
836
845
  }
837
846
 
838
- /** Running server package + optional git identity. `fromDir` is a test seam. */
839
- export function resolveServerIdentity(fromDir?: string): {
840
- path: string;
841
- version: string;
842
- branch?: string;
843
- sha?: string;
844
- } {
845
- const start = fromDir ?? path.dirname(fileURLToPath(import.meta.url));
846
- let dir = start;
847
- let pkgFile: string | undefined;
848
- for (let i = 0; i < 8; i++) {
849
- const candidate = path.join(dir, "package.json");
850
- if (existsSync(candidate)) {
851
- pkgFile = candidate;
852
- break;
853
- }
854
- const parent = path.dirname(dir);
855
- if (parent === dir) break;
856
- dir = parent;
857
- }
858
- const pkgPath = pkgFile ?? path.resolve(start, "..", "..", "package.json");
859
- const pkgDir = path.dirname(pkgPath);
860
- let version = "unknown";
861
- try {
862
- const raw = JSON.parse(readFileSync(pkgPath, "utf8")) as { version?: string };
863
- if (typeof raw.version === "string" && raw.version) version = raw.version;
864
- } catch {
865
- /* leave unknown — never invent a version */
866
- }
867
-
868
- const out: { path: string; version: string; branch?: string; sha?: string } = {
869
- path: pkgDir,
870
- version,
871
- };
872
-
873
- dir = pkgDir;
874
- let gitRoot: string | undefined;
875
- for (let i = 0; i < 10; i++) {
876
- if (existsSync(path.join(dir, ".git"))) {
877
- gitRoot = dir;
878
- break;
879
- }
880
- const parent = path.dirname(dir);
881
- if (parent === dir) break;
882
- dir = parent;
883
- }
884
- if (gitRoot && isGitRepo(gitRoot)) {
885
- const branch = spawnSync("git", ["-C", gitRoot, "rev-parse", "--abbrev-ref", "HEAD"], { encoding: "utf8" });
886
- const sha = spawnSync("git", ["-C", gitRoot, "rev-parse", "HEAD"], { encoding: "utf8" });
887
- const b = branch.status === 0 ? String(branch.stdout).trim() : "";
888
- const s = sha.status === 0 ? String(sha.stdout).trim() : "";
889
- if (b) out.branch = b;
890
- if (s) out.sha = s;
891
- }
892
- return out;
893
- }
894
-
895
847
  /** An epoch-ms stamp as ISO-8601 UTC, or an explicit absence — never a blank. */
896
848
  function stamp(ms: number | undefined): string {
897
849
  return ms === undefined ? "(no stamp)" : new Date(ms).toISOString();
@@ -925,6 +877,22 @@ export async function statusTool(args: { agentId: string }) {
925
877
  const cursor = await readJson<Cursor>(cursorFile(args.agentId), {});
926
878
  const inboxOffset = cursor.inboxOffset ?? 0;
927
879
  const unread = Math.max(0, inbox.length - inboxOffset);
880
+ // WHAT THIS PROCESS CAN ACTUALLY DO, embedded WHOLE.
881
+ //
882
+ // A new tool otherwise lands silently: an agent reading an old listing
883
+ // asserts the tool does not exist, and it is right about its own listing and
884
+ // wrong about the world. Three wrong conclusions in two days came from
885
+ // exactly that, each about which build a pane was running.
886
+ //
887
+ // The full report travels, not a summary. Flattening it to a boolean would
888
+ // hand the caller a tidier answer and a worse one: `versionLabel` stays
889
+ // CONTEXT rather than evidence, a THROWN probe stays an absent capability
890
+ // rather than a broken check, and `answeredBy` keeps naming which pid
891
+ // answered — two servers disagreeing mid-rollout is the normal case, and it
892
+ // is the case every other instrument here reports as if the fleet were one
893
+ // thing.
894
+ const capabilities = await capabilitiesTool();
895
+
928
896
  return {
929
897
  agentId: args.agentId,
930
898
  registered: !!entry,
@@ -935,6 +903,7 @@ export async function statusTool(args: { agentId: string }) {
935
903
  inboxUnread: unread,
936
904
  inTmux: !!process.env.TMUX_PANE,
937
905
  tmuxPane: process.env.TMUX_PANE,
906
+ capabilities,
938
907
  };
939
908
  }
940
909