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.
- package/README.md +24 -0
- package/bin/README.md +10 -0
- package/bin/agent-coord.mjs +3 -0
- package/bin/agent-hook.mjs +3 -0
- package/bin/harn.mjs +3 -0
- package/bin/run.mjs +43 -0
- package/dist/commander.d.ts +18 -6
- package/dist/commander.d.ts.map +1 -1
- package/dist/commander.js +74 -2
- package/dist/commands/agents.d.ts +25 -0
- package/dist/commands/agents.d.ts.map +1 -1
- package/dist/commands/agents.js +70 -27
- package/dist/commands/autoupdate.d.ts +12 -0
- package/dist/commands/autoupdate.d.ts.map +1 -0
- package/dist/commands/autoupdate.js +190 -0
- package/dist/commands/computer.d.ts +45 -0
- package/dist/commands/computer.d.ts.map +1 -0
- package/dist/commands/computer.js +509 -0
- package/dist/commands/docs.js +3 -1
- package/dist/commands/fetch.js +11 -1
- package/dist/commands/grep.d.ts +72 -0
- package/dist/commands/grep.d.ts.map +1 -1
- package/dist/commands/grep.js +200 -47
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +15 -9
- package/dist/commands/rm.js +2 -2
- package/dist/commands/web.d.ts.map +1 -1
- package/dist/commands/web.js +2 -0
- package/dist/core/agents/canonical-emit.d.ts.map +1 -1
- package/dist/core/agents/canonical-emit.js +2 -3
- package/dist/core/agents/coord-bin.d.ts +3 -0
- package/dist/core/agents/coord-bin.d.ts.map +1 -1
- package/dist/core/agents/coord-bin.js +6 -0
- package/dist/core/agents/coord-client.d.ts.map +1 -1
- package/dist/core/agents/coord-client.js +6 -2
- package/dist/core/agents/render/prompt-context.d.ts +55 -7
- package/dist/core/agents/render/prompt-context.d.ts.map +1 -1
- package/dist/core/agents/render/prompt-context.js +122 -48
- package/dist/core/agents/state/proc-start.d.ts +3 -1
- package/dist/core/agents/state/proc-start.d.ts.map +1 -1
- package/dist/core/agents/state/proc-start.js +26 -4
- package/dist/core/autoupdate/engine.d.ts +97 -0
- package/dist/core/autoupdate/engine.d.ts.map +1 -0
- package/dist/core/autoupdate/engine.js +548 -0
- package/dist/core/autoupdate/fingerprint.d.ts +16 -0
- package/dist/core/autoupdate/fingerprint.d.ts.map +1 -0
- package/dist/core/autoupdate/fingerprint.js +73 -0
- package/dist/core/autoupdate/git.d.ts +30 -0
- package/dist/core/autoupdate/git.d.ts.map +1 -0
- package/dist/core/autoupdate/git.js +82 -0
- package/dist/core/autoupdate/index.d.ts +6 -0
- package/dist/core/autoupdate/index.d.ts.map +1 -0
- package/dist/core/autoupdate/index.js +4 -0
- package/dist/core/autoupdate/state.d.ts +59 -0
- package/dist/core/autoupdate/state.d.ts.map +1 -0
- package/dist/core/autoupdate/state.js +214 -0
- package/dist/core/autoupdate/types.d.ts +113 -0
- package/dist/core/autoupdate/types.d.ts.map +1 -0
- package/dist/core/autoupdate/types.js +35 -0
- package/dist/core/config.d.ts +53 -1
- package/dist/core/config.d.ts.map +1 -1
- package/dist/core/config.js +114 -1
- package/dist/core/events/v3/bootstrap.js +1 -0
- package/dist/core/events/v3/runtime-build.d.ts.map +1 -1
- package/dist/core/events/v3/runtime-build.js +1 -0
- package/dist/core/hooks/adapter/wiring.d.ts +12 -2
- package/dist/core/hooks/adapter/wiring.d.ts.map +1 -1
- package/dist/core/hooks/adapter/wiring.js +38 -8
- package/dist/core/hooks/autoupdate-cue.d.ts +29 -0
- package/dist/core/hooks/autoupdate-cue.d.ts.map +1 -0
- package/dist/core/hooks/autoupdate-cue.js +53 -0
- package/dist/core/hooks/cli.js +28 -3
- package/dist/core/hooks/effects/index.js +4 -4
- package/dist/core/hooks/resolve/coord-root.js +1 -0
- package/dist/core/semantic/contract.d.ts +3 -3
- package/dist/core/semantic/service.d.ts.map +1 -1
- package/dist/core/semantic/service.js +4 -2
- package/dist/core/storage/modes.d.ts +1 -1
- package/dist/core/storage/modes.d.ts.map +1 -1
- package/dist/core/storage/modes.js +5 -1
- package/dist/core/workflow/durable-record.d.ts +2 -0
- package/dist/core/workflow/durable-record.d.ts.map +1 -1
- package/dist/core/workflow/durable-record.js +13 -1
- package/dist/core/workflow/engine.js +4 -0
- package/dist/core/workflow/spawn-claude.d.ts +7 -0
- package/dist/core/workflow/spawn-claude.d.ts.map +1 -1
- package/dist/core/workflow/spawn-claude.js +16 -4
- package/dist/core/workflow/specialists.d.ts.map +1 -1
- package/dist/core/workflow/specialists.js +1 -0
- package/dist/core/workflow/types.d.ts +10 -0
- package/dist/core/workflow/types.d.ts.map +1 -1
- package/dist/core/workflow/workspaces/leases.d.ts.map +1 -1
- package/dist/core/workflow/workspaces/leases.js +1 -9
- package/dist/lib/computer/cua.d.ts +50 -0
- package/dist/lib/computer/cua.d.ts.map +1 -0
- package/dist/lib/computer/cua.js +474 -0
- package/dist/lib/computer/host.d.ts +19 -0
- package/dist/lib/computer/host.d.ts.map +1 -0
- package/dist/lib/computer/host.js +72 -0
- package/dist/lib/computer/lease.d.ts +13 -0
- package/dist/lib/computer/lease.d.ts.map +1 -0
- package/dist/lib/computer/lease.js +269 -0
- package/dist/lib/computer/types.d.ts +163 -0
- package/dist/lib/computer/types.d.ts.map +1 -0
- package/dist/lib/computer/types.js +10 -0
- package/dist/lib/docs-metadata-v2.d.ts.map +1 -1
- package/dist/lib/docs-metadata-v2.js +6 -5
- package/dist/lib/exec.d.ts.map +1 -1
- package/dist/lib/exec.js +1 -0
- package/dist/lib/guarded-remove.d.ts.map +1 -1
- package/dist/lib/guarded-remove.js +5 -4
- package/dist/lib/headless/index.d.ts +33 -0
- package/dist/lib/headless/index.d.ts.map +1 -1
- package/dist/lib/headless/index.js +38 -2
- package/dist/lib/http/client.d.ts +2 -9
- package/dist/lib/http/client.d.ts.map +1 -1
- package/dist/lib/http/client.js +30 -9
- package/dist/lib/instructions/apply.d.ts +9 -0
- package/dist/lib/instructions/apply.d.ts.map +1 -1
- package/dist/lib/instructions/apply.js +92 -14
- package/dist/lib/instructions/git-hooks.d.ts.map +1 -1
- package/dist/lib/instructions/git-hooks.js +8 -4
- package/dist/lib/instructions/templates.d.ts +11 -0
- package/dist/lib/instructions/templates.d.ts.map +1 -1
- package/dist/lib/instructions/templates.js +81 -1
- package/package.json +5 -5
- package/schemas/config.schema.json +89 -1
- package/src/commander.ts +98 -8
- package/src/commands/agents.ts +100 -32
- package/src/commands/autoupdate.ts +250 -0
- package/src/commands/computer.ts +682 -0
- package/src/commands/docs.ts +3 -1
- package/src/commands/fetch.ts +11 -1
- package/src/commands/grep.ts +274 -59
- package/src/commands/init.ts +17 -7
- package/src/commands/rm.ts +2 -2
- package/src/commands/web.ts +2 -0
- package/src/core/agents/canonical-emit.ts +2 -3
- package/src/core/agents/coord-bin.ts +30 -0
- package/src/core/agents/coord-client.ts +6 -2
- package/src/core/agents/render/prompt-context.ts +152 -53
- package/src/core/agents/state/proc-start.ts +29 -5
- package/src/core/autoupdate/engine.ts +691 -0
- package/src/core/autoupdate/fingerprint.ts +89 -0
- package/src/core/autoupdate/git.ts +120 -0
- package/src/core/autoupdate/index.ts +29 -0
- package/src/core/autoupdate/state.ts +269 -0
- package/src/core/autoupdate/types.ts +135 -0
- package/src/core/config.ts +142 -1
- package/src/core/events/v3/bootstrap.ts +1 -0
- package/src/core/events/v3/runtime-build.ts +1 -0
- package/src/core/hooks/adapter/wiring.ts +51 -6
- package/src/core/hooks/autoupdate-cue.ts +72 -0
- package/src/core/hooks/cli.ts +27 -1
- package/src/core/hooks/effects/index.ts +4 -4
- package/src/core/hooks/resolve/coord-root.ts +1 -0
- package/src/core/semantic/service.ts +6 -2
- package/src/core/storage/modes.ts +7 -1
- package/src/core/workflow/durable-record.ts +15 -1
- package/src/core/workflow/engine.ts +3 -0
- package/src/core/workflow/spawn-claude.ts +16 -4
- package/src/core/workflow/specialists.ts +1 -0
- package/src/core/workflow/types.ts +10 -0
- package/src/core/workflow/workspaces/leases.ts +1 -9
- package/src/lib/computer/cua.ts +572 -0
- package/src/lib/computer/host.ts +87 -0
- package/src/lib/computer/lease.ts +299 -0
- package/src/lib/computer/types.ts +177 -0
- package/src/lib/docs-metadata-v2.ts +24 -5
- package/src/lib/exec.ts +1 -0
- package/src/lib/guarded-remove.ts +5 -3
- package/src/lib/headless/index.ts +66 -2
- package/src/lib/http/client.ts +33 -19
- package/src/lib/instructions/apply.ts +100 -12
- package/src/lib/instructions/git-hooks.ts +8 -4
- 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)], {
|
|
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:
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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 {
|
|
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
|
|
102
|
-
*
|
|
103
|
-
*
|
|
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
|
|
186
|
-
//
|
|
187
|
-
//
|
|
188
|
-
//
|
|
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
|
-
|
|
408
|
-
|
|
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
|
|
423
|
-
//
|
|
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
|
|
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
|
-
|
|
443
|
-
const local = formatPeerTable(
|
|
444
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
-
//
|
|
661
|
-
|
|
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 =
|
|
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.
|
|
160
|
-
|
|
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";
|