harnery 0.46.1 → 0.47.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.
Files changed (176) hide show
  1. package/README.md +24 -0
  2. package/bin/README.md +10 -0
  3. package/bin/agent-coord.mjs +3 -0
  4. package/bin/agent-hook.mjs +3 -0
  5. package/bin/harn.mjs +3 -0
  6. package/bin/run.mjs +43 -0
  7. package/dist/commander.d.ts +18 -6
  8. package/dist/commander.d.ts.map +1 -1
  9. package/dist/commander.js +74 -2
  10. package/dist/commands/agents.d.ts +25 -0
  11. package/dist/commands/agents.d.ts.map +1 -1
  12. package/dist/commands/agents.js +70 -27
  13. package/dist/commands/autoupdate.d.ts +12 -0
  14. package/dist/commands/autoupdate.d.ts.map +1 -0
  15. package/dist/commands/autoupdate.js +190 -0
  16. package/dist/commands/computer.d.ts +45 -0
  17. package/dist/commands/computer.d.ts.map +1 -0
  18. package/dist/commands/computer.js +509 -0
  19. package/dist/commands/docs.js +3 -1
  20. package/dist/commands/fetch.js +11 -1
  21. package/dist/commands/grep.d.ts +72 -0
  22. package/dist/commands/grep.d.ts.map +1 -1
  23. package/dist/commands/grep.js +200 -47
  24. package/dist/commands/init.d.ts.map +1 -1
  25. package/dist/commands/init.js +15 -9
  26. package/dist/commands/rm.js +2 -2
  27. package/dist/commands/web.d.ts.map +1 -1
  28. package/dist/commands/web.js +2 -0
  29. package/dist/core/agents/canonical-emit.d.ts.map +1 -1
  30. package/dist/core/agents/canonical-emit.js +2 -3
  31. package/dist/core/agents/coord-bin.d.ts +3 -0
  32. package/dist/core/agents/coord-bin.d.ts.map +1 -1
  33. package/dist/core/agents/coord-bin.js +6 -0
  34. package/dist/core/agents/coord-client.d.ts.map +1 -1
  35. package/dist/core/agents/coord-client.js +6 -2
  36. package/dist/core/agents/render/prompt-context.d.ts +55 -7
  37. package/dist/core/agents/render/prompt-context.d.ts.map +1 -1
  38. package/dist/core/agents/render/prompt-context.js +122 -48
  39. package/dist/core/agents/state/proc-start.d.ts +3 -1
  40. package/dist/core/agents/state/proc-start.d.ts.map +1 -1
  41. package/dist/core/agents/state/proc-start.js +26 -4
  42. package/dist/core/autoupdate/engine.d.ts +97 -0
  43. package/dist/core/autoupdate/engine.d.ts.map +1 -0
  44. package/dist/core/autoupdate/engine.js +548 -0
  45. package/dist/core/autoupdate/fingerprint.d.ts +16 -0
  46. package/dist/core/autoupdate/fingerprint.d.ts.map +1 -0
  47. package/dist/core/autoupdate/fingerprint.js +73 -0
  48. package/dist/core/autoupdate/git.d.ts +30 -0
  49. package/dist/core/autoupdate/git.d.ts.map +1 -0
  50. package/dist/core/autoupdate/git.js +82 -0
  51. package/dist/core/autoupdate/index.d.ts +6 -0
  52. package/dist/core/autoupdate/index.d.ts.map +1 -0
  53. package/dist/core/autoupdate/index.js +4 -0
  54. package/dist/core/autoupdate/state.d.ts +59 -0
  55. package/dist/core/autoupdate/state.d.ts.map +1 -0
  56. package/dist/core/autoupdate/state.js +214 -0
  57. package/dist/core/autoupdate/types.d.ts +113 -0
  58. package/dist/core/autoupdate/types.d.ts.map +1 -0
  59. package/dist/core/autoupdate/types.js +35 -0
  60. package/dist/core/config.d.ts +53 -1
  61. package/dist/core/config.d.ts.map +1 -1
  62. package/dist/core/config.js +114 -1
  63. package/dist/core/events/v3/bootstrap.js +1 -0
  64. package/dist/core/events/v3/runtime-build.d.ts.map +1 -1
  65. package/dist/core/events/v3/runtime-build.js +1 -0
  66. package/dist/core/hooks/adapter/wiring.d.ts +12 -2
  67. package/dist/core/hooks/adapter/wiring.d.ts.map +1 -1
  68. package/dist/core/hooks/adapter/wiring.js +38 -8
  69. package/dist/core/hooks/autoupdate-cue.d.ts +29 -0
  70. package/dist/core/hooks/autoupdate-cue.d.ts.map +1 -0
  71. package/dist/core/hooks/autoupdate-cue.js +53 -0
  72. package/dist/core/hooks/cli.js +28 -3
  73. package/dist/core/hooks/effects/index.js +4 -4
  74. package/dist/core/hooks/resolve/coord-root.js +1 -0
  75. package/dist/core/semantic/contract.d.ts +3 -3
  76. package/dist/core/semantic/service.d.ts.map +1 -1
  77. package/dist/core/semantic/service.js +4 -2
  78. package/dist/core/storage/modes.d.ts +1 -1
  79. package/dist/core/storage/modes.d.ts.map +1 -1
  80. package/dist/core/storage/modes.js +5 -1
  81. package/dist/core/workflow/durable-record.d.ts +2 -0
  82. package/dist/core/workflow/durable-record.d.ts.map +1 -1
  83. package/dist/core/workflow/durable-record.js +13 -1
  84. package/dist/core/workflow/engine.js +4 -0
  85. package/dist/core/workflow/spawn-claude.d.ts +7 -0
  86. package/dist/core/workflow/spawn-claude.d.ts.map +1 -1
  87. package/dist/core/workflow/spawn-claude.js +16 -4
  88. package/dist/core/workflow/specialists.d.ts.map +1 -1
  89. package/dist/core/workflow/specialists.js +1 -0
  90. package/dist/core/workflow/types.d.ts +10 -0
  91. package/dist/core/workflow/types.d.ts.map +1 -1
  92. package/dist/core/workflow/workspaces/leases.d.ts.map +1 -1
  93. package/dist/core/workflow/workspaces/leases.js +1 -9
  94. package/dist/lib/computer/cua.d.ts +50 -0
  95. package/dist/lib/computer/cua.d.ts.map +1 -0
  96. package/dist/lib/computer/cua.js +474 -0
  97. package/dist/lib/computer/host.d.ts +19 -0
  98. package/dist/lib/computer/host.d.ts.map +1 -0
  99. package/dist/lib/computer/host.js +72 -0
  100. package/dist/lib/computer/lease.d.ts +13 -0
  101. package/dist/lib/computer/lease.d.ts.map +1 -0
  102. package/dist/lib/computer/lease.js +269 -0
  103. package/dist/lib/computer/types.d.ts +163 -0
  104. package/dist/lib/computer/types.d.ts.map +1 -0
  105. package/dist/lib/computer/types.js +10 -0
  106. package/dist/lib/docs-metadata-v2.d.ts.map +1 -1
  107. package/dist/lib/docs-metadata-v2.js +6 -5
  108. package/dist/lib/exec.d.ts.map +1 -1
  109. package/dist/lib/exec.js +1 -0
  110. package/dist/lib/guarded-remove.d.ts.map +1 -1
  111. package/dist/lib/guarded-remove.js +5 -4
  112. package/dist/lib/headless/index.d.ts +33 -0
  113. package/dist/lib/headless/index.d.ts.map +1 -1
  114. package/dist/lib/headless/index.js +38 -2
  115. package/dist/lib/http/client.d.ts +2 -9
  116. package/dist/lib/http/client.d.ts.map +1 -1
  117. package/dist/lib/http/client.js +30 -9
  118. package/dist/lib/instructions/apply.d.ts +9 -0
  119. package/dist/lib/instructions/apply.d.ts.map +1 -1
  120. package/dist/lib/instructions/apply.js +92 -14
  121. package/dist/lib/instructions/git-hooks.d.ts.map +1 -1
  122. package/dist/lib/instructions/git-hooks.js +8 -4
  123. package/dist/lib/instructions/templates.d.ts +11 -0
  124. package/dist/lib/instructions/templates.d.ts.map +1 -1
  125. package/dist/lib/instructions/templates.js +81 -1
  126. package/package.json +5 -5
  127. package/schemas/config.schema.json +89 -1
  128. package/src/commander.ts +98 -8
  129. package/src/commands/agents.ts +100 -32
  130. package/src/commands/autoupdate.ts +250 -0
  131. package/src/commands/computer.ts +682 -0
  132. package/src/commands/docs.ts +3 -1
  133. package/src/commands/fetch.ts +11 -1
  134. package/src/commands/grep.ts +274 -59
  135. package/src/commands/init.ts +17 -7
  136. package/src/commands/rm.ts +2 -2
  137. package/src/commands/web.ts +2 -0
  138. package/src/core/agents/canonical-emit.ts +2 -3
  139. package/src/core/agents/coord-bin.ts +30 -0
  140. package/src/core/agents/coord-client.ts +6 -2
  141. package/src/core/agents/render/prompt-context.ts +152 -53
  142. package/src/core/agents/state/proc-start.ts +29 -5
  143. package/src/core/autoupdate/engine.ts +691 -0
  144. package/src/core/autoupdate/fingerprint.ts +89 -0
  145. package/src/core/autoupdate/git.ts +120 -0
  146. package/src/core/autoupdate/index.ts +29 -0
  147. package/src/core/autoupdate/state.ts +269 -0
  148. package/src/core/autoupdate/types.ts +135 -0
  149. package/src/core/config.ts +142 -1
  150. package/src/core/events/v3/bootstrap.ts +1 -0
  151. package/src/core/events/v3/runtime-build.ts +1 -0
  152. package/src/core/hooks/adapter/wiring.ts +51 -6
  153. package/src/core/hooks/autoupdate-cue.ts +72 -0
  154. package/src/core/hooks/cli.ts +27 -1
  155. package/src/core/hooks/effects/index.ts +4 -4
  156. package/src/core/hooks/resolve/coord-root.ts +1 -0
  157. package/src/core/semantic/service.ts +6 -2
  158. package/src/core/storage/modes.ts +7 -1
  159. package/src/core/workflow/durable-record.ts +15 -1
  160. package/src/core/workflow/engine.ts +3 -0
  161. package/src/core/workflow/spawn-claude.ts +16 -4
  162. package/src/core/workflow/specialists.ts +1 -0
  163. package/src/core/workflow/types.ts +10 -0
  164. package/src/core/workflow/workspaces/leases.ts +1 -9
  165. package/src/lib/computer/cua.ts +572 -0
  166. package/src/lib/computer/host.ts +87 -0
  167. package/src/lib/computer/lease.ts +299 -0
  168. package/src/lib/computer/types.ts +177 -0
  169. package/src/lib/docs-metadata-v2.ts +24 -5
  170. package/src/lib/exec.ts +1 -0
  171. package/src/lib/guarded-remove.ts +5 -3
  172. package/src/lib/headless/index.ts +66 -2
  173. package/src/lib/http/client.ts +33 -19
  174. package/src/lib/instructions/apply.ts +100 -12
  175. package/src/lib/instructions/git-hooks.ts +8 -4
  176. package/src/lib/instructions/templates.ts +95 -1
@@ -214,7 +214,7 @@ function gitRevParse(cwd: string, flag: string): string | null {
214
214
  // `cwd` must exist or the spawn itself fails; callers pass paths that may
215
215
  // not (a project dir from a stale env var), so treat any failure as "no
216
216
  // answer" rather than letting it throw.
217
- const r = spawnSync("git", ["rev-parse", flag], { encoding: "utf8", cwd });
217
+ const r = spawnSync("git", ["rev-parse", flag], { encoding: "utf8", cwd, windowsHide: true });
218
218
  if (r.status === 0) value = r.stdout.trim() || null;
219
219
  } catch {
220
220
  value = null;
@@ -297,6 +297,7 @@ export function pidStartToken(pid: number): string | null {
297
297
  // two callers with different environments must not disagree about one
298
298
  // process.
299
299
  const out = spawnSync("ps", ["-o", "lstart=", "-p", String(pid)], {
300
+ windowsHide: true,
300
301
  encoding: "utf8",
301
302
  timeout: 2000,
302
303
  env: { ...process.env, TZ: "UTC", LC_ALL: "C" },
@@ -686,7 +687,10 @@ function readPpid(pid: number): number | null {
686
687
  }
687
688
  // Portable fallback: `ps -o ppid= -p <pid>` works on macOS/BSD/Linux.
688
689
  try {
689
- const out = spawnSync("ps", ["-o", "ppid=", "-p", String(pid)], { encoding: "utf8" });
690
+ const out = spawnSync("ps", ["-o", "ppid=", "-p", String(pid)], {
691
+ encoding: "utf8",
692
+ windowsHide: true,
693
+ });
690
694
  if (out.status === 0) {
691
695
  const parsed = Number.parseInt(out.stdout.trim(), 10);
692
696
  if (Number.isFinite(parsed) && parsed > 0) return parsed;
@@ -7,9 +7,10 @@
7
7
  * and forwards the result as the adapter-shaped additionalContext payload.
8
8
  *
9
9
  * Four subsections combined into one additionalContext payload:
10
- * 1. Peer table: semantically-relevant peer fields hashed; only re-emits
11
- * when peers change (name + instance_id + session_id + kind + started_at
12
- * + sorted(files_touched) + platform, sorted by instance_id).
10
+ * 1. Peer table: re-emits only when a live peer arrives or leaves, changes
11
+ * lifecycle, or changes its claimed files. Activity and task text never
12
+ * trigger a reprint. Peers past the heartbeat-freshness window are left
13
+ * out and counted in "+N more".
13
14
  * 2. Council pending: pending open-council IDs hashed; re-emits when the
14
15
  * ID set changes.
15
16
  * 3. Focus nudge. Until the session has displayed its current suggested
@@ -24,8 +25,11 @@
24
25
  * 5. Codex status footer: a non-deduplicated reminder to put the live status
25
26
  * box after the substantive answer. Stop stays observe-only, so missing the
26
27
  * footer can never trigger a replacement response.
28
+ * 6. Turn ritual: for adapters whose Stop hook enforces the ritual, shown on
29
+ * the first prompt, after a compaction, and after a bounced turn.
27
30
  *
28
31
  * Hash files live at:
32
+ * .harnery/.last-turn-ritual.<instance_id>
29
33
  * .harnery/.last-peer-hash.<instance_id>
30
34
  * .harnery/.last-council-hash.<instance_id>
31
35
  * .harnery/.last-task-nudge-hash.<instance_id>
@@ -46,7 +50,13 @@ import {
46
50
  import { join } from "node:path";
47
51
 
48
52
  import { coordEnv } from "../../../lib/env.ts";
49
- import { endOfTurnStatusCommand, hostPromptReminder, resolveBinName } from "../../config.ts";
53
+ import {
54
+ coordFreshnessSeconds,
55
+ endOfTurnStatusCommand,
56
+ hostPromptReminder,
57
+ resolveBinName,
58
+ } from "../../config.ts";
59
+ import { readContextState } from "../../context/index.ts";
50
60
  import type { PressureWorkloadClass } from "../../diagnostics/contract.ts";
51
61
  import { formatPeerInbox, peerInbox } from "../../inbox/peer-messages.ts";
52
62
  import { type RemoteMachine, readRemoteMachines } from "../../presence/index.ts";
@@ -98,10 +108,9 @@ export interface PromptContextOpts {
98
108
  * The caller enables this only for human-facing Codex sessions. */
99
109
  statusFooterNudge?: boolean;
100
110
  /** Adapter id whose Stop hook ENFORCES the end-of-turn ritual (task_set +
101
- * status check). When set, append a fresh per-prompt reminder of that
102
- * contract so the model satisfies it before ending the turn instead of
103
- * being bounced into a retry. Not hash-deduped, same rationale as the
104
- * status footer. */
111
+ * status check). When set, append a reminder of that contract on the
112
+ * session's first prompt, after a context compaction, and after a turn the
113
+ * Stop hook bounced (see `markTurnRitualBounced`). */
105
114
  turnRitualNudge?: string;
106
115
  /** The emitter acknowledges exactly these IDs only after output succeeds. */
107
116
  onInboxPrepared?: (messageIds: readonly string[]) => void;
@@ -182,13 +191,12 @@ export function renderPromptContext(opts: PromptContextOpts): string {
182
191
  if (statusMsg) sections.push(statusMsg);
183
192
  }
184
193
 
185
- // 7. Adapters whose Stop hook ENFORCES the turn ritual get a fresh reminder
186
- // on every prompt. Reminding before the reply is drafted is far cheaper than
187
- // the bounce-and-retry the Stop hook otherwise forces: enforcement-only
188
- // compliance was measured missing on a double-digit share of turns. Not
189
- // hash-deduped for the same reason as the Codex footer.
194
+ // 7. Adapters whose Stop hook ENFORCES the turn ritual get the reminder when
195
+ // it is news: on the session's first prompt, after a context compaction,
196
+ // and after a turn the Stop hook bounced. The Stop hook keeps enforcing on
197
+ // every turn, so a missed ritual still costs one retry and re-arms this.
190
198
  if (turnRitualNudge) {
191
- const ritualMsg = renderTurnRitualReminder(coordRoot, instanceId, turnRitualNudge);
199
+ const ritualMsg = renderTurnRitualReminder(coordRoot, instanceId, sessionId, turnRitualNudge);
192
200
  if (ritualMsg) sections.push(ritualMsg);
193
201
  }
194
202
 
@@ -262,14 +270,46 @@ function renderHostPromptReminder(coordRoot: string, selfInstanceId: string): st
262
270
  return hostPromptReminder(coordRoot) ?? "";
263
271
  }
264
272
 
273
+ function turnRitualStatePath(coordRoot: string, instanceId: string): string {
274
+ return join(coordRoot, ".harnery", `.last-turn-ritual.${instanceId}`);
275
+ }
276
+
277
+ const TURN_RITUAL_BOUNCED = "bounced";
278
+
279
+ /**
280
+ * Called by the Stop hook when it blocks a turn for a missing ritual step, so
281
+ * the next prompt carries the reminder again.
282
+ */
283
+ export function markTurnRitualBounced(coordRoot: string, instanceId: string): void {
284
+ writeHashFile(turnRitualStatePath(coordRoot, instanceId), TURN_RITUAL_BOUNCED);
285
+ }
286
+
265
287
  function renderTurnRitualReminder(
266
288
  coordRoot: string,
267
289
  selfInstanceId: string,
290
+ sessionId: string,
268
291
  adapter: string,
269
292
  ): string {
270
293
  const hb = readLiveCoordinationRow(coordRoot, selfInstanceId);
271
294
  if (!hb || hb.kind === "subagent" || hb.kind === "transient" || hb.workflow_run_id) return "";
272
295
 
296
+ // The key changes when native context compaction drops the earlier reminder.
297
+ let generation = 0;
298
+ try {
299
+ generation = readContextState(coordRoot, sessionId)?.generation ?? 0;
300
+ } catch {
301
+ /* no continuity state: treat as the first context generation */
302
+ }
303
+ const key = `context-generation=${generation}`;
304
+ const statePath = turnRitualStatePath(coordRoot, selfInstanceId);
305
+ const previous = safeRead(statePath);
306
+ if (previous === key) return "";
307
+ writeHashFile(statePath, key);
308
+ const bounced =
309
+ previous === TURN_RITUAL_BOUNCED
310
+ ? "Your previous turn was bounced by the Stop hook because the ritual was incomplete. "
311
+ : "";
312
+
273
313
  const bin = resolveBinName(coordRoot);
274
314
  const statusCommand = endOfTurnStatusCommand(coordRoot);
275
315
  // Claude Code and Cursor can both enforce completed assistant reply text.
@@ -278,6 +318,7 @@ function renderTurnRitualReminder(
278
318
  ? " and paste its output verbatim in a fenced code block at the end of your reply"
279
319
  : "";
280
320
  return (
321
+ bounced +
281
322
  `Turn ritual (Stop-enforced): on any turn that uses tools, run \`${bin} agents set-task "<short focus>"\` at some point during the turn (required every turn, even when the focus is unchanged; pass an empty string when there is no meaningful focus), then make \`${statusCommand}\` your final tool call${paste}. ` +
282
323
  "A turn that ends without these is bounced by the Stop hook and costs a retry."
283
324
  );
@@ -404,8 +445,57 @@ function computePeerTableIfChanged(
404
445
  const remote = readRemoteMachinesSafe(coordRoot);
405
446
  if (peers.length === 0 && remote.length === 0) return "";
406
447
 
407
- // Build hash basis: sorted-by-instance_id projection of semantically-relevant fields.
408
- const basis = peers
448
+ const rendered = renderPeerTable({
449
+ peers,
450
+ remote,
451
+ mySessionId,
452
+ nowMs: Date.now(),
453
+ freshnessSeconds: coordFreshnessSeconds(coordRoot),
454
+ relations: forkRelationLabels(
455
+ coordRoot,
456
+ selfInstanceId,
457
+ new Set(peers.map((p) => p.instance_id ?? "")),
458
+ ),
459
+ });
460
+ if (!rendered.table) return "";
461
+
462
+ const hashFile = join(coordRoot, ".harnery", `.last-peer-hash.${selfInstanceId}`);
463
+ const oldHash = safeRead(hashFile);
464
+ if (oldHash && oldHash === rendered.hash) return "";
465
+
466
+ // Persist hash atomically (temp + rename, same convention as other coord writes).
467
+ writeHashFile(hashFile, rendered.hash);
468
+ return rendered.table;
469
+ }
470
+
471
+ export interface PeerTableInput {
472
+ peers: HeartbeatRow[];
473
+ remote: RemoteMachine[];
474
+ mySessionId: string;
475
+ nowMs: number;
476
+ /** Sessions whose last heartbeat is older than this are folded into "+N more". */
477
+ freshnessSeconds: number;
478
+ relations?: Map<string, string>;
479
+ }
480
+
481
+ /**
482
+ * Pure peer-table render plus its change key. The key covers only what makes
483
+ * the table news: which live sessions exist, their lifecycle, and the files
484
+ * they claim. Activity and task text change on nearly every peer tool call, so
485
+ * they render in the table but never trigger a reprint by themselves.
486
+ */
487
+ export function renderPeerTable(input: PeerTableInput): { hash: string; table: string } {
488
+ const { peers, remote, mySessionId, nowMs, freshnessSeconds } = input;
489
+ const cutoffMs = nowMs - freshnessSeconds * 1000;
490
+ const live: HeartbeatRow[] = [];
491
+ const stale: HeartbeatRow[] = [];
492
+ for (const peer of peers) {
493
+ const seenMs = Date.parse(peer.last_heartbeat ?? "");
494
+ if (Number.isFinite(seenMs) && seenMs >= cutoffMs) live.push(peer);
495
+ else stale.push(peer);
496
+ }
497
+
498
+ const basis = live
409
499
  .map((p) => ({
410
500
  name: p.name ?? null,
411
501
  instance_id: p.instance_id ?? null,
@@ -414,44 +504,29 @@ function computePeerTableIfChanged(
414
504
  started_at: p.started_at ?? null,
415
505
  files_touched: Array.from(p.files_touched ?? []).sort(),
416
506
  platform: p.platform ?? null,
417
- activity: p.activity ?? "unknown",
418
507
  task_state: p.task_state ?? "active",
419
508
  task_state_reason: p.task_state_reason ?? null,
420
509
  }))
421
510
  .sort((a, b) => (a.instance_id ?? "").localeCompare(b.instance_id ?? ""));
422
- // Remote basis: machine + per-agent identity/task/files (published_at is
423
- // excluded — a keepalive re-publish alone shouldn't re-emit the table).
511
+ // Remote basis: machine + per-agent identity, lifecycle, and files
512
+ // (published_at, activity, and task are excluded for the same reason).
424
513
  const remoteBasis = remote.map((m) => ({
425
514
  machine: m.machine,
426
515
  agents: m.agents.map((a) => ({
427
516
  instance_id: a.instance_id,
428
517
  name: a.name ?? null,
429
- task: a.task ?? null,
430
- activity: a.activity,
431
518
  task_state: a.task_state,
432
519
  task_state_reason: a.task_state_reason ?? null,
433
520
  files_touched: Array.from(a.files_touched ?? []).sort(),
434
521
  })),
435
522
  }));
436
- const newHash = sha256Hex16(JSON.stringify({ local: basis, remote: remoteBasis }));
437
-
438
- const hashFile = join(coordRoot, ".harnery", `.last-peer-hash.${selfInstanceId}`);
439
- const oldHash = safeRead(hashFile);
440
- if (oldHash && oldHash === newHash) return "";
523
+ const hash = sha256Hex16(JSON.stringify({ local: basis, remote: remoteBasis }));
441
524
 
442
- // Render peer table via the same formatter used at SessionStart.
443
- const local = formatPeerTable(
444
- peers,
445
- mySessionId,
446
- forkRelationLabels(coordRoot, selfInstanceId, new Set(peers.map((p) => p.instance_id ?? ""))),
447
- );
448
- const remoteTable = formatRemoteTable(remote);
525
+ const nowSec = Math.floor(nowMs / 1000);
526
+ const local = formatPeerTable(live, stale, mySessionId, nowSec, input.relations);
527
+ const remoteTable = formatRemoteTable(remote, nowSec);
449
528
  const table = [local, remoteTable].filter(Boolean).join("\n\n");
450
- if (!table) return "";
451
-
452
- // Persist hash atomically (temp + rename, same convention as other coord writes).
453
- writeHashFile(hashFile, newHash);
454
- return table;
529
+ return { hash, table };
455
530
  }
456
531
 
457
532
  /** readRemoteMachines with a local failure guard (rendering must never throw). */
@@ -464,9 +539,8 @@ function readRemoteMachinesSafe(coordRoot: string): RemoteMachine[] {
464
539
  }
465
540
 
466
541
  /** Render sessions on other machines (presence transport) as a subtable. */
467
- function formatRemoteTable(remote: RemoteMachine[]): string {
542
+ function formatRemoteTable(remote: RemoteMachine[], nowSec: number): string {
468
543
  if (remote.length === 0) return "";
469
- const nowSec = Math.floor(Date.now() / 1000);
470
544
  const lines: string[] = [];
471
545
  for (const m of remote) {
472
546
  for (const a of m.agents.slice(0, 10)) {
@@ -583,24 +657,26 @@ function writeHashFile(path: string, value: string): void {
583
657
  * (no cross-file imports of formatting internals). If both renderers diverge,
584
658
  * pull the shared bits into a small util module. */
585
659
 
660
+ type PeerRow = HeartbeatRow & { display_files: string[]; relation?: string; label?: string };
661
+
586
662
  function formatPeerTable(
587
663
  peers: HeartbeatRow[],
664
+ stalePeers: HeartbeatRow[],
588
665
  mySessionId: string,
666
+ nowSec: number,
589
667
  relations: Map<string, string> = new Map(),
590
668
  ): string {
591
669
  if (peers.length === 0) return "";
592
- const nowSec = Math.floor(Date.now() / 1000);
593
670
 
594
671
  const fold: Record<string, string[]> = {};
595
- for (const p of peers) {
672
+ for (const p of [...peers, ...stalePeers]) {
596
673
  const kind = p.kind ?? "unknown";
597
674
  if (kind === "transient" && p.session_id) {
598
675
  fold[p.session_id] = (fold[p.session_id] ?? []).concat(p.files_touched ?? []);
599
676
  }
600
677
  }
601
678
 
602
- type RowExt = HeartbeatRow & { display_files: string[]; relation?: string };
603
- const rows: RowExt[] = peers
679
+ const rows: PeerRow[] = peers
604
680
  .filter((p) => (p.kind ?? "unknown") !== "transient")
605
681
  .map((p) => {
606
682
  const folded = fold[p.instance_id ?? ""] ?? [];
@@ -608,22 +684,28 @@ function formatPeerTable(
608
684
  const relation = relations.get(p.instance_id ?? "");
609
685
  return { ...p, display_files: display, ...(relation ? { relation } : {}) };
610
686
  });
687
+ assignUniqueLabels(rows);
688
+ const staleRows = stalePeers.filter((p) => (p.kind ?? "unknown") !== "transient");
611
689
 
612
690
  // Related sessions sort first so a fork parent never hides in "+N more".
613
691
  const blocking = rows
614
692
  .filter((p) => p.session_id !== mySessionId)
615
693
  .sort((a, b) => Number(!!b.relation) - Number(!!a.relation) || byStartedAt(a, b));
616
694
  const group = rows.filter((p) => p.session_id === mySessionId).sort(byStartedAt);
695
+ const staleBlocking = staleRows.filter((p) => p.session_id !== mySessionId).length;
696
+ const staleGroup = staleRows.length - staleBlocking;
617
697
 
618
698
  const out: string[] = [];
619
699
  const blk = renderSubtable(
620
700
  blocking,
701
+ staleBlocking,
621
702
  "Other agent groups active (their files block you):",
622
703
  nowSec,
623
704
  );
624
705
  if (blk) out.push(blk);
625
706
  const grp = renderSubtable(
626
707
  group,
708
+ staleGroup,
627
709
  "Your group (subagents / parent / siblings; no mutual block):",
628
710
  nowSec,
629
711
  );
@@ -631,25 +713,43 @@ function formatPeerTable(
631
713
  return out.join("\n\n");
632
714
  }
633
715
 
716
+ /**
717
+ * One full, distinct label per row. An unnamed session shows its whole
718
+ * instance id (an 8-character prefix collides for time-ordered ids), and a
719
+ * name two live rows share carries its instance id so they read as two.
720
+ */
721
+ function assignUniqueLabels(rows: PeerRow[]): void {
722
+ const base = (r: PeerRow) => r.name ?? r.instance_id ?? "unknown";
723
+ const counts = new Map<string, number>();
724
+ for (const r of rows) counts.set(base(r), (counts.get(base(r)) ?? 0) + 1);
725
+ for (const r of rows) {
726
+ const label = base(r);
727
+ r.label =
728
+ (counts.get(label) ?? 0) > 1 && r.name && r.instance_id
729
+ ? `${label} (${r.instance_id})`
730
+ : label;
731
+ }
732
+ }
733
+
634
734
  function byStartedAt(a: HeartbeatRow, b: HeartbeatRow): number {
635
735
  return (a.started_at ?? "").localeCompare(b.started_at ?? "");
636
736
  }
637
737
 
638
738
  function renderSubtable(
639
- rows: Array<HeartbeatRow & { display_files: string[]; relation?: string }>,
739
+ rows: PeerRow[],
740
+ staleCount: number,
640
741
  header: string,
641
742
  nowSec: number,
642
743
  ): string {
643
744
  if (rows.length === 0) return "";
644
745
  const first = rows.slice(0, 10).map((r) => formatRow(r, nowSec));
645
- const overflow = rows.length > 10 ? `\n +${rows.length - 10} more` : "";
746
+ const more = Math.max(0, rows.length - 10) + staleCount;
747
+ const staleNote = staleCount > 0 ? ` (${staleCount} with no recent heartbeat)` : "";
748
+ const overflow = more > 0 ? `\n +${more} more${staleNote}` : "";
646
749
  return `${header}\n${first.join("\n")}${overflow}`;
647
750
  }
648
751
 
649
- function formatRow(
650
- r: HeartbeatRow & { display_files: string[]; relation?: string },
651
- nowSec: number,
652
- ): string {
752
+ function formatRow(r: PeerRow, nowSec: number): string {
653
753
  const taskPart = r.task ? ` "${r.task.slice(0, 60)}"` : "";
654
754
  const statePart = formatState(r);
655
755
  // Fall back started_at → last_heartbeat; if neither is a valid timestamp,
@@ -657,9 +757,8 @@ function formatRow(
657
757
  const startedSec = parseIsoSec(r.started_at) ?? parseIsoSec(r.last_heartbeat);
658
758
  const ageFrom = startedSec === null ? "age unknown" : fmtAge(nowSec - startedSec);
659
759
  const filesPart = fmtFiles(r.display_files);
660
- // Prefer a short instance_id over a bare "unknown" so an incomplete row is
661
- // still identifiable.
662
- const label = r.name ?? (r.instance_id ? r.instance_id.slice(0, 8) : "unknown");
760
+ // An unnamed row shows its full instance id; a prefix can collide.
761
+ const label = r.label ?? r.name ?? r.instance_id ?? "unknown";
663
762
  const relation = r.relation ? ` [${r.relation}]` : "";
664
763
  return ` - agent-${label}${relation}${taskPart} (${statePart}, ${ageFrom}, ${filesPart})`;
665
764
  }
@@ -36,7 +36,7 @@ import { existsSync, readFileSync } from "node:fs";
36
36
  import { coordEnv } from "../../../lib/env.ts";
37
37
 
38
38
  /** Which probe answers "when did this process start?" on this machine. */
39
- export type StartTokenProbe = "procfs" | "ps";
39
+ export type StartTokenProbe = "procfs" | "ps" | "windows";
40
40
 
41
41
  /**
42
42
  * The `ps` probe is text formatted by `strftime("%c", localtime(...))`, so its
@@ -59,9 +59,10 @@ let cachedBootId: string | null | undefined;
59
59
  */
60
60
  export function startTokenProbe(): StartTokenProbe {
61
61
  const override = coordEnv("PID_PROBE");
62
- if (override === "procfs" || override === "ps") return override;
62
+ if (override === "procfs" || override === "ps" || override === "windows") return override;
63
63
  if (cachedProbe === undefined) {
64
- cachedProbe = existsSync("/proc/self/stat") ? "procfs" : "ps";
64
+ cachedProbe =
65
+ process.platform === "win32" ? "windows" : existsSync("/proc/self/stat") ? "procfs" : "ps";
65
66
  }
66
67
  return cachedProbe;
67
68
  }
@@ -156,8 +157,31 @@ function defaultPsRun(pid: number): { status: number | null; stdout: string } {
156
157
  * will not say.
157
158
  */
158
159
  export function processStartToken(pid: number): string | null {
159
- if (!Number.isFinite(pid) || pid <= 0) return null;
160
- return startTokenProbe() === "procfs" ? procfsStartToken(pid) : psStartToken(pid);
160
+ if (!Number.isSafeInteger(pid) || pid <= 0) return null;
161
+ const probe = startTokenProbe();
162
+ if (probe === "windows") return windowsStartToken(pid);
163
+ return probe === "procfs" ? procfsStartToken(pid) : psStartToken(pid);
164
+ }
165
+
166
+ /** Windows process creation time in invariant UTC ticks, independent of locale. */
167
+ export function windowsStartToken(pid: number): string | null {
168
+ if (!Number.isSafeInteger(pid) || pid <= 0) return null;
169
+ try {
170
+ const out = spawnSync(
171
+ "powershell.exe",
172
+ [
173
+ "-NoProfile",
174
+ "-NonInteractive",
175
+ "-Command",
176
+ `try { (Get-Process -Id ${pid} -ErrorAction Stop).StartTime.ToUniversalTime().Ticks } catch { exit 1 }`,
177
+ ],
178
+ { encoding: "utf8", timeout: 4_000, windowsHide: true },
179
+ );
180
+ const ticks = out.stdout?.trim();
181
+ return out.status === 0 && ticks && /^\d+$/.test(ticks) ? `w${ticks}` : null;
182
+ } catch {
183
+ return null;
184
+ }
161
185
  }
162
186
 
163
187
  export type ProcessGenerationVerdict = "same" | "different" | "unknown";