harnery 0.42.0 → 0.43.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/dist/commander.d.ts +16 -0
- package/dist/commander.d.ts.map +1 -1
- package/dist/commander.js +18 -0
- package/dist/commands/agents.d.ts.map +1 -1
- package/dist/commands/agents.js +54 -4
- package/dist/commands/tunnel.d.ts +65 -0
- package/dist/commands/tunnel.d.ts.map +1 -1
- package/dist/commands/tunnel.js +290 -101
- package/dist/commands/web-fetch.d.ts +3 -2
- package/dist/commands/web-fetch.d.ts.map +1 -1
- package/dist/commands/web-fetch.js +25 -19
- package/dist/commands/web.d.ts +7 -1
- package/dist/commands/web.d.ts.map +1 -1
- package/dist/commands/web.js +9 -3
- package/dist/core/agents/render/prompt-context.d.ts.map +1 -1
- package/dist/core/agents/render/prompt-context.js +11 -5
- package/dist/core/agents/render/session-context.d.ts.map +1 -1
- package/dist/core/agents/render/session-context.js +57 -13
- package/dist/core/agents/state/names.d.ts +43 -3
- package/dist/core/agents/state/names.d.ts.map +1 -1
- package/dist/core/agents/state/names.js +87 -3
- package/dist/core/events/v3/support-storage/pack-reader.d.ts.map +1 -1
- package/dist/core/events/v3/support-storage/pack-reader.js +42 -16
- package/dist/core/hooks/cli.js +87 -18
- package/dist/core/hooks/fork-lineage.d.ts +40 -0
- package/dist/core/hooks/fork-lineage.d.ts.map +1 -0
- package/dist/core/hooks/fork-lineage.js +62 -0
- package/dist/core/hooks/resolve/transcript.d.ts +16 -0
- package/dist/core/hooks/resolve/transcript.d.ts.map +1 -1
- package/dist/core/hooks/resolve/transcript.js +84 -0
- package/dist/core/hooks/shell-rm.d.ts +2 -0
- package/dist/core/hooks/shell-rm.d.ts.map +1 -0
- package/dist/core/hooks/shell-rm.js +105 -0
- package/dist/core/hooks/shell-tokens.d.ts +12 -0
- package/dist/core/hooks/shell-tokens.d.ts.map +1 -0
- package/dist/core/hooks/shell-tokens.js +97 -0
- package/dist/core/hooks/shell-waiter.d.ts.map +1 -1
- package/dist/core/hooks/shell-waiter.js +3 -93
- package/dist/core/semantic/contract.d.ts +5 -5
- package/dist/core/storage/process-log.d.ts +12 -0
- package/dist/core/storage/process-log.d.ts.map +1 -1
- package/dist/core/storage/process-log.js +10 -2
- package/dist/lib/tunnel/allowlist.d.ts +44 -0
- package/dist/lib/tunnel/allowlist.d.ts.map +1 -0
- package/dist/lib/tunnel/allowlist.js +204 -0
- package/dist/lib/tunnel/current-address.d.ts +41 -0
- package/dist/lib/tunnel/current-address.d.ts.map +1 -0
- package/dist/lib/tunnel/current-address.js +123 -0
- package/dist/lib/tunnel/gate.js +13 -6
- package/dist/lib/tunnel/processes.d.ts +39 -0
- package/dist/lib/tunnel/processes.d.ts.map +1 -0
- package/dist/lib/tunnel/processes.js +121 -0
- package/dist/lib/tunnel/state.d.ts +28 -3
- package/dist/lib/tunnel/state.d.ts.map +1 -1
- package/dist/lib/tunnel/state.js +98 -27
- package/package.json +2 -1
- package/src/commander.ts +32 -0
- package/src/commands/agents.ts +97 -11
- package/src/commands/tunnel.ts +394 -116
- package/src/commands/web-fetch.ts +30 -19
- package/src/commands/web.ts +9 -3
- package/src/core/agents/render/prompt-context.ts +25 -8
- package/src/core/agents/render/session-context.ts +83 -16
- package/src/core/agents/state/names.ts +117 -6
- package/src/core/events/v3/support-storage/pack-reader.ts +45 -19
- package/src/core/hooks/cli.ts +106 -17
- package/src/core/hooks/fork-lineage.ts +82 -0
- package/src/core/hooks/resolve/transcript.ts +88 -0
- package/src/core/hooks/shell-rm.ts +103 -0
- package/src/core/hooks/shell-tokens.ts +96 -0
- package/src/core/hooks/shell-waiter.ts +4 -91
- package/src/core/storage/process-log.ts +17 -2
- package/src/lib/tunnel/allowlist.ts +208 -0
- package/src/lib/tunnel/current-address.ts +158 -0
- package/src/lib/tunnel/gate.ts +16 -8
- package/src/lib/tunnel/processes.ts +149 -0
- package/src/lib/tunnel/state.ts +123 -27
|
@@ -16,9 +16,11 @@ import type { EmitContext } from "../commander.ts";
|
|
|
16
16
|
* harnery repo at the *matching version tag* into a cache dir and install the
|
|
17
17
|
* web app's deps. Subsequent runs reuse the cache.
|
|
18
18
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
19
|
+
* `web/` imports readers from `src/`, and `web/next.config.ts` resolves the
|
|
20
|
+
* root `playwright` package, so the install covers the repository root and
|
|
21
|
+
* then `web/`. Installing the `playwright` package downloads no browsers.
|
|
22
|
+
* Installing `web/` alone left `harn web up` failing with
|
|
23
|
+
* `Cannot find module 'playwright/package.json'`.
|
|
22
24
|
*/
|
|
23
25
|
|
|
24
26
|
const DEFAULT_REPO_URL = "https://github.com/ryanjkelly/harnery.git";
|
|
@@ -53,8 +55,8 @@ function webCacheRoot(ref: string): string {
|
|
|
53
55
|
}
|
|
54
56
|
|
|
55
57
|
/** bun if available (preferred), else npm. Both support `<runner> install` and `<runner> run <script>`. */
|
|
56
|
-
export function webRunner(): "bun" | "npm" {
|
|
57
|
-
const r =
|
|
58
|
+
export function webRunner(spawn: typeof spawnSync = spawnSync): "bun" | "npm" {
|
|
59
|
+
const r = spawn("bun", ["--version"], { stdio: "ignore" });
|
|
58
60
|
return r.status === 0 ? "bun" : "npm";
|
|
59
61
|
}
|
|
60
62
|
|
|
@@ -63,14 +65,20 @@ export function webRunner(): "bun" | "npm" {
|
|
|
63
65
|
* its path, fetching (clone + install) on first use. Returns null on failure
|
|
64
66
|
* (after emitting an actionable error).
|
|
65
67
|
*/
|
|
66
|
-
export function lazyFetchWebRoot(
|
|
68
|
+
export function lazyFetchWebRoot(
|
|
69
|
+
emit: EmitContext,
|
|
70
|
+
spawn: typeof spawnSync = spawnSync,
|
|
71
|
+
): string | null {
|
|
67
72
|
const { version, repoUrl } = readPkg();
|
|
68
73
|
const ref = resolveRef(version);
|
|
69
74
|
const cacheRoot = webCacheRoot(ref);
|
|
70
75
|
const webDir = path.join(cacheRoot, "web");
|
|
71
76
|
|
|
72
|
-
// node_modules in the cached web/ is the "fetched + installed" marker.
|
|
73
|
-
if (
|
|
77
|
+
// node_modules in both the cached root and web/ is the "fetched + installed" marker.
|
|
78
|
+
if (
|
|
79
|
+
existsSync(path.join(cacheRoot, "node_modules")) &&
|
|
80
|
+
existsSync(path.join(webDir, "node_modules"))
|
|
81
|
+
) {
|
|
74
82
|
emit.log(`harn web · using cached dashboard (${ref})`, "info");
|
|
75
83
|
return webDir;
|
|
76
84
|
}
|
|
@@ -85,7 +93,7 @@ export function lazyFetchWebRoot(emit: EmitContext): string | null {
|
|
|
85
93
|
if (existsSync(cacheRoot)) rmSync(cacheRoot, { recursive: true, force: true });
|
|
86
94
|
mkdirSync(path.dirname(cacheRoot), { recursive: true });
|
|
87
95
|
|
|
88
|
-
const clone =
|
|
96
|
+
const clone = spawn("git", ["clone", "--depth", "1", "--branch", ref, repoUrl, cacheRoot], {
|
|
89
97
|
stdio: "inherit",
|
|
90
98
|
});
|
|
91
99
|
if (clone.status !== 0) {
|
|
@@ -100,16 +108,19 @@ export function lazyFetchWebRoot(emit: EmitContext): string | null {
|
|
|
100
108
|
return null;
|
|
101
109
|
}
|
|
102
110
|
|
|
103
|
-
const runner = webRunner();
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
111
|
+
const runner = webRunner(spawn);
|
|
112
|
+
for (const dir of [cacheRoot, webDir]) {
|
|
113
|
+
const where = dir === webDir ? "web/" : "the harnery root";
|
|
114
|
+
emit.log(`harn web · installing dashboard deps (${runner} install in ${where})`, "info");
|
|
115
|
+
const install = spawn(runner, ["install"], { cwd: dir, stdio: "inherit" });
|
|
116
|
+
if (install.status !== 0) {
|
|
117
|
+
emit.error({
|
|
118
|
+
code: "web_fetch_failed",
|
|
119
|
+
message: `${runner} install in ${dir} failed (status ${install.status})`,
|
|
120
|
+
hint: `Try manually: cd ${dir} && ${runner} install`,
|
|
121
|
+
});
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
113
124
|
}
|
|
114
125
|
|
|
115
126
|
emit.log(`harn web · dashboard ready (cached at ${webDir})`, "info");
|
package/src/commands/web.ts
CHANGED
|
@@ -82,11 +82,17 @@ export function nodeOptionsWithHeapCap(mb: number): string | undefined {
|
|
|
82
82
|
return `${inherited} --max-old-space-size=${mb}`.trim();
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
-
/**
|
|
85
|
+
/**
|
|
86
|
+
* Preload the dashboard's server hooks (keep-alive and request diagnostics)
|
|
87
|
+
* without replacing Next's HTTP server. NODE_OPTIONS reaches the child that
|
|
88
|
+
* `next dev` forks, but Next re-parses every `--import` into one value, so
|
|
89
|
+
* this names the single combined preload; a second, different `--import`
|
|
90
|
+
* would collapse into an unloadable path (web/server-preload.mjs).
|
|
91
|
+
*/
|
|
86
92
|
export function nodeOptionsWithWebDiagnostics(webDir: string, mb: number): string {
|
|
87
93
|
const inherited = nodeOptionsWithHeapCap(mb) ?? "";
|
|
88
|
-
if (inherited.includes("server-
|
|
89
|
-
const preload = pathToFileURL(path.join(webDir, "server-
|
|
94
|
+
if (inherited.includes("server-preload.mjs")) return inherited;
|
|
95
|
+
const preload = pathToFileURL(path.join(webDir, "server-preload.mjs")).href;
|
|
90
96
|
return `${inherited} --import=${preload}`.trim();
|
|
91
97
|
}
|
|
92
98
|
|
|
@@ -57,6 +57,7 @@ import {
|
|
|
57
57
|
readLiveCoordinationRow,
|
|
58
58
|
readLiveCoordinationRows,
|
|
59
59
|
} from "../state/live-coordination-view.ts";
|
|
60
|
+
import { forkRelationLabels } from "../state/names.ts";
|
|
60
61
|
import type { AgentActivity, TaskState } from "../state/session-state.ts";
|
|
61
62
|
import { formatPendingCouncils, recordDeliveredMessages } from "./session-context.ts";
|
|
62
63
|
|
|
@@ -441,7 +442,11 @@ function computePeerTableIfChanged(
|
|
|
441
442
|
if (oldHash && oldHash === newHash) return "";
|
|
442
443
|
|
|
443
444
|
// Render peer table via the same formatter used at SessionStart.
|
|
444
|
-
const local = formatPeerTable(
|
|
445
|
+
const local = formatPeerTable(
|
|
446
|
+
peers,
|
|
447
|
+
mySessionId,
|
|
448
|
+
forkRelationLabels(coordRoot, selfInstanceId, new Set(peers.map((p) => p.instance_id ?? ""))),
|
|
449
|
+
);
|
|
445
450
|
const remoteTable = formatRemoteTable(remote);
|
|
446
451
|
const table = [local, remoteTable].filter(Boolean).join("\n\n");
|
|
447
452
|
if (!table) return "";
|
|
@@ -580,7 +585,11 @@ function writeHashFile(path: string, value: string): void {
|
|
|
580
585
|
* (no cross-file imports of formatting internals). If both renderers diverge,
|
|
581
586
|
* pull the shared bits into a small util module. */
|
|
582
587
|
|
|
583
|
-
function formatPeerTable(
|
|
588
|
+
function formatPeerTable(
|
|
589
|
+
peers: HeartbeatRow[],
|
|
590
|
+
mySessionId: string,
|
|
591
|
+
relations: Map<string, string> = new Map(),
|
|
592
|
+
): string {
|
|
584
593
|
if (peers.length === 0) return "";
|
|
585
594
|
const nowSec = Math.floor(Date.now() / 1000);
|
|
586
595
|
|
|
@@ -592,16 +601,20 @@ function formatPeerTable(peers: HeartbeatRow[], mySessionId: string): string {
|
|
|
592
601
|
}
|
|
593
602
|
}
|
|
594
603
|
|
|
595
|
-
type RowExt = HeartbeatRow & { display_files: string[] };
|
|
604
|
+
type RowExt = HeartbeatRow & { display_files: string[]; relation?: string };
|
|
596
605
|
const rows: RowExt[] = peers
|
|
597
606
|
.filter((p) => (p.kind ?? "unknown") !== "transient")
|
|
598
607
|
.map((p) => {
|
|
599
608
|
const folded = fold[p.instance_id ?? ""] ?? [];
|
|
600
609
|
const display = Array.from(new Set([...(p.files_touched ?? []), ...folded])).sort();
|
|
601
|
-
|
|
610
|
+
const relation = relations.get(p.instance_id ?? "");
|
|
611
|
+
return { ...p, display_files: display, ...(relation ? { relation } : {}) };
|
|
602
612
|
});
|
|
603
613
|
|
|
604
|
-
|
|
614
|
+
// Related sessions sort first so a fork parent never hides in "+N more".
|
|
615
|
+
const blocking = rows
|
|
616
|
+
.filter((p) => p.session_id !== mySessionId)
|
|
617
|
+
.sort((a, b) => Number(!!b.relation) - Number(!!a.relation) || byStartedAt(a, b));
|
|
605
618
|
const group = rows.filter((p) => p.session_id === mySessionId).sort(byStartedAt);
|
|
606
619
|
|
|
607
620
|
const out: string[] = [];
|
|
@@ -625,7 +638,7 @@ function byStartedAt(a: HeartbeatRow, b: HeartbeatRow): number {
|
|
|
625
638
|
}
|
|
626
639
|
|
|
627
640
|
function renderSubtable(
|
|
628
|
-
rows: Array<HeartbeatRow & { display_files: string[] }>,
|
|
641
|
+
rows: Array<HeartbeatRow & { display_files: string[]; relation?: string }>,
|
|
629
642
|
header: string,
|
|
630
643
|
nowSec: number,
|
|
631
644
|
): string {
|
|
@@ -635,7 +648,10 @@ function renderSubtable(
|
|
|
635
648
|
return `${header}\n${first.join("\n")}${overflow}`;
|
|
636
649
|
}
|
|
637
650
|
|
|
638
|
-
function formatRow(
|
|
651
|
+
function formatRow(
|
|
652
|
+
r: HeartbeatRow & { display_files: string[]; relation?: string },
|
|
653
|
+
nowSec: number,
|
|
654
|
+
): string {
|
|
639
655
|
const taskPart = r.task ? ` "${r.task.slice(0, 60)}"` : "";
|
|
640
656
|
const statePart = formatState(r);
|
|
641
657
|
// Fall back started_at → last_heartbeat; if neither is a valid timestamp,
|
|
@@ -646,7 +662,8 @@ function formatRow(r: HeartbeatRow & { display_files: string[] }, nowSec: number
|
|
|
646
662
|
// Prefer a short instance_id over a bare "unknown" so an incomplete row is
|
|
647
663
|
// still identifiable.
|
|
648
664
|
const label = r.name ?? (r.instance_id ? r.instance_id.slice(0, 8) : "unknown");
|
|
649
|
-
|
|
665
|
+
const relation = r.relation ? ` [${r.relation}]` : "";
|
|
666
|
+
return ` - agent-${label}${relation}${taskPart} (${statePart}, ${ageFrom}, ${filesPart})`;
|
|
650
667
|
}
|
|
651
668
|
|
|
652
669
|
function formatState(r: HeartbeatRow): string {
|
|
@@ -16,7 +16,7 @@ import { appendEntry } from "../../journal/index.ts";
|
|
|
16
16
|
import { readRemoteMachines } from "../../presence/index.ts";
|
|
17
17
|
import { drainMailbox, formatMailboxDelivery, type MailboxMessageV1 } from "../mailbox.ts";
|
|
18
18
|
import { readLiveCoordinationRows } from "../state/live-coordination-view.ts";
|
|
19
|
-
import {
|
|
19
|
+
import { forkRelationLabels, readForkState } from "../state/names.ts";
|
|
20
20
|
import type { AgentActivity, TaskState } from "../state/session-state.ts";
|
|
21
21
|
|
|
22
22
|
interface HeartbeatRow {
|
|
@@ -54,7 +54,17 @@ export function renderSessionContext(opts: RenderOpts): string {
|
|
|
54
54
|
|
|
55
55
|
// 1. Self-name line + peer table (folded if peers present)
|
|
56
56
|
const peers = readActivePeers(coordRoot, instanceId);
|
|
57
|
-
const
|
|
57
|
+
const forkState = readForkState(coordRoot, instanceId);
|
|
58
|
+
const forkParent = forkState?.parent ?? null;
|
|
59
|
+
const parentRow = forkParent
|
|
60
|
+
? peers.find((peer) => peer.instance_id === forkParent.instance_id)
|
|
61
|
+
: undefined;
|
|
62
|
+
const relations = forkRelationLabels(
|
|
63
|
+
coordRoot,
|
|
64
|
+
instanceId,
|
|
65
|
+
new Set(peers.map((peer) => peer.instance_id ?? "")),
|
|
66
|
+
);
|
|
67
|
+
const localTable = formatPeerTable(peers, sessionId, relations);
|
|
58
68
|
// Cross-machine presence (ADR 0016): sessions on other machines (advisory).
|
|
59
69
|
const peerTable = [localTable, formatRemoteMachines(coordRoot)].filter(Boolean).join("\n\n");
|
|
60
70
|
if (agentName) {
|
|
@@ -64,13 +74,11 @@ export function renderSessionContext(opts: RenderOpts): string {
|
|
|
64
74
|
// asserts the parent's name, claims, and task. When recorded fork lineage
|
|
65
75
|
// (.name-history forked_from) names the parent, say so specifically — the
|
|
66
76
|
// fork is about to read a transcript full of that exact name. Otherwise
|
|
67
|
-
// fall back to the generic clause, which needs no fork detection.
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
? `This conversation was branched from another session (${forkParent.instance_id}); any agent name in earlier context belongs to that session, not to you.`
|
|
73
|
-
: "Any different agent name in earlier context was inherited from another session; this one is authoritative.";
|
|
77
|
+
// fall back to the generic clause, which needs no fork detection. A live
|
|
78
|
+
// parent is the dangerous case (2026-10-07: a fork read its running
|
|
79
|
+
// parent as its own earlier self and offered to close it), so the notice
|
|
80
|
+
// names what the parent is doing and forbids ending it.
|
|
81
|
+
const authority = forkAuthorityClause(forkParent, forkState?.fork === true, parentRow);
|
|
74
82
|
const selfLine = `You are agent-${agentName}${suffix}. ${authority}`;
|
|
75
83
|
messages.push(peerTable ? `${selfLine}\n\n${peerTable}` : selfLine);
|
|
76
84
|
} else if (peerTable) {
|
|
@@ -194,13 +202,64 @@ function readActivePeers(coordRoot: string, selfInstanceId: string): HeartbeatRo
|
|
|
194
202
|
);
|
|
195
203
|
}
|
|
196
204
|
|
|
205
|
+
const DO_NOT_END =
|
|
206
|
+
"Do not end, release, or finalize that session unless the operator explicitly tells you to.";
|
|
207
|
+
|
|
208
|
+
function forkAuthorityClause(
|
|
209
|
+
parent: { instance_id: string; name: string | null } | null,
|
|
210
|
+
fork: boolean,
|
|
211
|
+
parentRow: HeartbeatRow | undefined,
|
|
212
|
+
): string {
|
|
213
|
+
if (parent?.name && parentRow) {
|
|
214
|
+
const files = parentRow.files_touched ?? [];
|
|
215
|
+
const task = parentRow.task ? ` on "${parentRow.task.slice(0, 80)}"` : "";
|
|
216
|
+
const holds =
|
|
217
|
+
files.length > 0
|
|
218
|
+
? ` and holds ${files.length} file${files.length === 1 ? "" : "s"} (${fmtFileList(files)})`
|
|
219
|
+
: "";
|
|
220
|
+
const owned =
|
|
221
|
+
files.length > 0
|
|
222
|
+
? parentRow.task
|
|
223
|
+
? "That name, task, and those files"
|
|
224
|
+
: "That name and those files"
|
|
225
|
+
: parentRow.task
|
|
226
|
+
? "That name and task"
|
|
227
|
+
: "That name";
|
|
228
|
+
return (
|
|
229
|
+
`This conversation was forked from agent-${parent.name}, which is still running as a separate session${task}${holds}. ` +
|
|
230
|
+
`${owned} belong${owned === "That name" ? "s" : ""} to agent-${parent.name}, not to you. ${DO_NOT_END}`
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
if (parent?.name) {
|
|
234
|
+
return `This conversation was branched from agent-${parent.name}'s session: earlier context showing agent-${parent.name}'s name, task, or file claims belongs to the pre-fork session, not to you.`;
|
|
235
|
+
}
|
|
236
|
+
if (parent) {
|
|
237
|
+
return `This conversation was branched from another session (${parent.instance_id}); any agent name in earlier context belongs to that session, not to you.`;
|
|
238
|
+
}
|
|
239
|
+
if (fork) {
|
|
240
|
+
return `This conversation was forked from another session, which may still be running. Any agent name, task, or file claim in earlier context belongs to that session, not to you. ${DO_NOT_END}`;
|
|
241
|
+
}
|
|
242
|
+
return "Any different agent name in earlier context was inherited from another session; this one is authoritative.";
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function fmtFileList(files: string[]): string {
|
|
246
|
+
const sorted = [...files].sort();
|
|
247
|
+
return sorted.length <= 3
|
|
248
|
+
? sorted.join(", ")
|
|
249
|
+
: `${sorted.slice(0, 3).join(", ")}, +${sorted.length - 3} more`;
|
|
250
|
+
}
|
|
251
|
+
|
|
197
252
|
/**
|
|
198
253
|
* Renders the peer table as
|
|
199
254
|
* two subsections: "Other agent groups active" (blocking) and "Your group"
|
|
200
255
|
* (subagents/siblings, no mutual block). Folds transient subagents' files
|
|
201
256
|
* into their parent session.
|
|
202
257
|
*/
|
|
203
|
-
function formatPeerTable(
|
|
258
|
+
function formatPeerTable(
|
|
259
|
+
peers: HeartbeatRow[],
|
|
260
|
+
mySessionId: string,
|
|
261
|
+
relations: Map<string, string> = new Map(),
|
|
262
|
+
): string {
|
|
204
263
|
if (peers.length === 0) return "";
|
|
205
264
|
const nowSec = Math.floor(Date.now() / 1000);
|
|
206
265
|
|
|
@@ -214,16 +273,20 @@ function formatPeerTable(peers: HeartbeatRow[], mySessionId: string): string {
|
|
|
214
273
|
}
|
|
215
274
|
|
|
216
275
|
// Build rows with display_files (own files + folded transient files).
|
|
217
|
-
type RowExt = HeartbeatRow & { display_files: string[] };
|
|
276
|
+
type RowExt = HeartbeatRow & { display_files: string[]; relation?: string };
|
|
218
277
|
const rows: RowExt[] = peers
|
|
219
278
|
.filter((p) => (p.kind ?? "unknown") !== "transient")
|
|
220
279
|
.map((p) => {
|
|
221
280
|
const folded = fold[p.instance_id ?? ""] ?? [];
|
|
222
281
|
const display = Array.from(new Set([...(p.files_touched ?? []), ...folded])).sort();
|
|
223
|
-
|
|
282
|
+
const relation = relations.get(p.instance_id ?? "");
|
|
283
|
+
return { ...p, display_files: display, ...(relation ? { relation } : {}) };
|
|
224
284
|
});
|
|
225
285
|
|
|
226
|
-
|
|
286
|
+
// Related sessions sort first so the parent never hides in "+N more".
|
|
287
|
+
const blocking = rows
|
|
288
|
+
.filter((p) => p.session_id !== mySessionId)
|
|
289
|
+
.sort((a, b) => Number(!!b.relation) - Number(!!a.relation) || byStartedAt(a, b));
|
|
227
290
|
const group = rows.filter((p) => p.session_id === mySessionId).sort(byStartedAt);
|
|
228
291
|
|
|
229
292
|
const sections: string[] = [];
|
|
@@ -248,7 +311,7 @@ function byStartedAt(a: HeartbeatRow, b: HeartbeatRow): number {
|
|
|
248
311
|
}
|
|
249
312
|
|
|
250
313
|
function renderSubtable(
|
|
251
|
-
rows: Array<HeartbeatRow & { display_files: string[] }>,
|
|
314
|
+
rows: Array<HeartbeatRow & { display_files: string[]; relation?: string }>,
|
|
252
315
|
header: string,
|
|
253
316
|
nowSec: number,
|
|
254
317
|
): string {
|
|
@@ -258,7 +321,10 @@ function renderSubtable(
|
|
|
258
321
|
return `${header}\n${first.join("\n")}${overflow}`;
|
|
259
322
|
}
|
|
260
323
|
|
|
261
|
-
function formatRow(
|
|
324
|
+
function formatRow(
|
|
325
|
+
r: HeartbeatRow & { display_files: string[]; relation?: string },
|
|
326
|
+
nowSec: number,
|
|
327
|
+
): string {
|
|
262
328
|
const taskPart = r.task ? ` "${r.task.slice(0, 60)}"` : "";
|
|
263
329
|
const activity = r.activity ?? "unknown";
|
|
264
330
|
const lifecycle = r.task_state ?? "active";
|
|
@@ -266,7 +332,8 @@ function formatRow(r: HeartbeatRow & { display_files: string[] }, nowSec: number
|
|
|
266
332
|
lifecycle === "blocked" && r.task_state_reason ? `: ${r.task_state_reason.slice(0, 80)}` : "";
|
|
267
333
|
const ageFrom = fmtAge(nowSec - parseIsoSec(r.started_at));
|
|
268
334
|
const filesPart = fmtFiles(r.display_files);
|
|
269
|
-
|
|
335
|
+
const relation = r.relation ? ` [${r.relation}]` : "";
|
|
336
|
+
return ` - agent-${r.name ?? "unknown"}${relation}${taskPart} (activity=${activity}, lifecycle=${lifecycle}${reason}, ${ageFrom}, ${filesPart})`;
|
|
270
337
|
}
|
|
271
338
|
|
|
272
339
|
function fmtFiles(files: string[]): string {
|
|
@@ -331,13 +331,20 @@ export interface NameHistoryRow {
|
|
|
331
331
|
ts: string;
|
|
332
332
|
/** Durable persona UUID. Present after `agents identity assume`. */
|
|
333
333
|
agent_id?: string;
|
|
334
|
-
/** Audit marker distinguishing an explicit role adoption from pool assignment
|
|
335
|
-
|
|
334
|
+
/** Audit marker distinguishing an explicit role adoption from pool assignment,
|
|
335
|
+
* and a lineage row appended after the name was already assigned. */
|
|
336
|
+
source?: "pool" | "identity.assume" | "fork.detected";
|
|
336
337
|
previous_name?: string;
|
|
337
338
|
/** Instance this session was forked/branched from (recorded fork lineage).
|
|
338
339
|
* Stamped only on the row that first assigns this instance, when the adapter
|
|
339
340
|
* layer detected or supplied a parent conversation. */
|
|
340
341
|
forked_from?: string;
|
|
342
|
+
/** The adapter declared or inherited-history proved this instance is a fork,
|
|
343
|
+
* even when its parent could not be resolved yet. */
|
|
344
|
+
fork?: true;
|
|
345
|
+
/** Fork detection has run against this instance's transcript; the tool-path
|
|
346
|
+
* heal skips instances carrying it so the scan runs once per instance. */
|
|
347
|
+
fork_checked?: true;
|
|
341
348
|
}
|
|
342
349
|
|
|
343
350
|
function atomicWrite(path: string, content: string): void {
|
|
@@ -516,7 +523,13 @@ export function assignName(
|
|
|
516
523
|
coordRoot: string,
|
|
517
524
|
instanceId: string,
|
|
518
525
|
kind: NameKind,
|
|
519
|
-
opts: {
|
|
526
|
+
opts: {
|
|
527
|
+
freshnessSecs: number;
|
|
528
|
+
forkedFrom?: string;
|
|
529
|
+
fork?: boolean;
|
|
530
|
+
forkChecked?: boolean;
|
|
531
|
+
nowMs?: number;
|
|
532
|
+
},
|
|
520
533
|
): string {
|
|
521
534
|
// Check 1: existing history row → original name. A resume re-enters here,
|
|
522
535
|
// which also makes fork stamping naturally idempotent: lineage lands only on
|
|
@@ -558,13 +571,111 @@ export function assignName(
|
|
|
558
571
|
kind,
|
|
559
572
|
source: "pool",
|
|
560
573
|
...(forkedFrom && forkedFrom !== instanceId ? { forked_from: forkedFrom } : {}),
|
|
574
|
+
...(opts.fork || (forkedFrom && forkedFrom !== instanceId) ? { fork: true as const } : {}),
|
|
575
|
+
...(opts.forkChecked ? { fork_checked: true as const } : {}),
|
|
561
576
|
ts: new Date().toISOString().replace(/\.\d{3}Z$/, "Z"),
|
|
562
577
|
});
|
|
563
578
|
return name;
|
|
564
579
|
}
|
|
565
580
|
|
|
581
|
+
export interface ForkState {
|
|
582
|
+
/** True when the instance is a recorded fork, parent known or not. */
|
|
583
|
+
fork: boolean;
|
|
584
|
+
/** Detection already ran for this instance. */
|
|
585
|
+
checked: boolean;
|
|
586
|
+
parent: { instance_id: string; name: string | null } | null;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/** Recorded fork state for <instanceId>, or null when it has no history row. */
|
|
590
|
+
export function readForkState(coordRoot: string, instanceId: string): ForkState | null {
|
|
591
|
+
const history = readHistory(coordRoot);
|
|
592
|
+
let seen = false;
|
|
593
|
+
let fork = false;
|
|
594
|
+
let checked = false;
|
|
595
|
+
let parentId: string | undefined;
|
|
596
|
+
for (const row of history) {
|
|
597
|
+
if (row.instance_id !== instanceId) continue;
|
|
598
|
+
seen = true;
|
|
599
|
+
if (row.fork || row.forked_from) fork = true;
|
|
600
|
+
if (row.fork_checked) checked = true;
|
|
601
|
+
if (row.forked_from && row.forked_from !== instanceId) parentId = row.forked_from;
|
|
602
|
+
}
|
|
603
|
+
if (!seen) return null;
|
|
604
|
+
const parent = parentId
|
|
605
|
+
? { instance_id: parentId, name: resolveName(coordRoot, parentId)?.name ?? null }
|
|
606
|
+
: null;
|
|
607
|
+
return { fork, checked, parent };
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* Record fork detection that ran after the instance was already named (the
|
|
612
|
+
* tool-path heal). Appends a copy of the latest identity row, so the name,
|
|
613
|
+
* kind, and persona are unchanged, plus `fork_checked` and any lineage found.
|
|
614
|
+
* A no-op for an instance with no history row or one already checked.
|
|
615
|
+
*/
|
|
616
|
+
export function recordForkDetection(
|
|
617
|
+
coordRoot: string,
|
|
618
|
+
instanceId: string,
|
|
619
|
+
detection: { fork: boolean; forkedFrom?: string },
|
|
620
|
+
): boolean {
|
|
621
|
+
const history = readHistory(coordRoot);
|
|
622
|
+
let latest: NameHistoryRow | undefined;
|
|
623
|
+
for (const row of history) {
|
|
624
|
+
if (row.instance_id !== instanceId) continue;
|
|
625
|
+
if (row.fork_checked) return false;
|
|
626
|
+
latest = row;
|
|
627
|
+
}
|
|
628
|
+
if (!latest) return false;
|
|
629
|
+
const forkedFrom =
|
|
630
|
+
detection.forkedFrom && detection.forkedFrom !== instanceId ? detection.forkedFrom : undefined;
|
|
631
|
+
appendHistory(coordRoot, {
|
|
632
|
+
instance_id: instanceId,
|
|
633
|
+
name: latest.name,
|
|
634
|
+
kind: latest.kind,
|
|
635
|
+
...(latest.agent_id ? { agent_id: latest.agent_id } : {}),
|
|
636
|
+
source: "fork.detected",
|
|
637
|
+
...(forkedFrom ? { forked_from: forkedFrom } : {}),
|
|
638
|
+
...(detection.fork || forkedFrom || latest.fork ? { fork: true as const } : {}),
|
|
639
|
+
fork_checked: true,
|
|
640
|
+
ts: new Date().toISOString().replace(/\.\d{3}Z$/, "Z"),
|
|
641
|
+
});
|
|
642
|
+
return true;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/**
|
|
646
|
+
* Peer labels for recorded fork relations, keyed by instance id: this
|
|
647
|
+
* session's fork parent and its direct forks. `liveIds` marks which of them
|
|
648
|
+
* are running, so a fork can tell its live parent from its own earlier self.
|
|
649
|
+
*/
|
|
650
|
+
export function forkRelationLabels(
|
|
651
|
+
coordRoot: string,
|
|
652
|
+
instanceId: string,
|
|
653
|
+
liveIds: Set<string>,
|
|
654
|
+
): Map<string, string> {
|
|
655
|
+
const out = new Map<string, string>();
|
|
656
|
+
const parent = readForkParent(coordRoot, instanceId);
|
|
657
|
+
if (parent) {
|
|
658
|
+
out.set(
|
|
659
|
+
parent.instance_id,
|
|
660
|
+
liveIds.has(parent.instance_id) ? "your fork parent, live" : "your fork parent",
|
|
661
|
+
);
|
|
662
|
+
}
|
|
663
|
+
for (const child of readForkChildren(coordRoot, instanceId)) out.set(child, "your fork");
|
|
664
|
+
return out;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/** Instance ids recorded as direct forks of <instanceId>. */
|
|
668
|
+
export function readForkChildren(coordRoot: string, instanceId: string): Set<string> {
|
|
669
|
+
const out = new Set<string>();
|
|
670
|
+
for (const row of readHistory(coordRoot)) {
|
|
671
|
+
if (row.forked_from === instanceId && row.instance_id !== instanceId) out.add(row.instance_id);
|
|
672
|
+
}
|
|
673
|
+
return out;
|
|
674
|
+
}
|
|
675
|
+
|
|
566
676
|
/** One step of recorded fork lineage: the latest row for <instanceId> that
|
|
567
|
-
* carries `forked_from
|
|
677
|
+
* carries `forked_from`. Later rows without lineage (an identity assumption)
|
|
678
|
+
* do not erase it: a session never stops being a fork. */
|
|
568
679
|
export function readForkParent(
|
|
569
680
|
coordRoot: string,
|
|
570
681
|
instanceId: string,
|
|
@@ -572,8 +683,8 @@ export function readForkParent(
|
|
|
572
683
|
const history = readHistory(coordRoot);
|
|
573
684
|
for (let i = history.length - 1; i >= 0; i--) {
|
|
574
685
|
const row = history[i]!;
|
|
575
|
-
if (row.instance_id !== instanceId) continue;
|
|
576
|
-
if (
|
|
686
|
+
if (row.instance_id !== instanceId || !row.forked_from) continue;
|
|
687
|
+
if (row.forked_from === instanceId) return null;
|
|
577
688
|
const parent = resolveName(coordRoot, row.forked_from);
|
|
578
689
|
return { instance_id: row.forked_from, name: parent?.name ?? null };
|
|
579
690
|
}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import { constants
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
3
|
import { lstat, mkdir, open, realpath, rm, writeFile } from "node:fs/promises";
|
|
4
4
|
import { dirname, isAbsolute, relative, resolve } from "node:path";
|
|
5
|
+
import { Readable } from "node:stream";
|
|
6
|
+
import { finished } from "node:stream/promises";
|
|
5
7
|
import { StringDecoder } from "node:string_decoder";
|
|
6
8
|
import { createGunzip, createInflateRaw } from "node:zlib";
|
|
7
9
|
import { stateDirMode, stateFileMode } from "../../../storage/modes.ts";
|
|
@@ -89,24 +91,17 @@ async function* streamEventV3SupportPackRecordsForManifest(
|
|
|
89
91
|
throw new Error("event_v3_support_payload_length_mismatch");
|
|
90
92
|
if (payloadStat.size > bounded.max_payload_bytes)
|
|
91
93
|
throw new Error("event_v3_support_payload_too_large");
|
|
92
|
-
if ((await hashHandle(
|
|
94
|
+
if ((await hashHandle(payloadHandle)) !== manifest.payload.digest) {
|
|
93
95
|
throw new Error("event_v3_support_payload_digest_mismatch");
|
|
94
96
|
}
|
|
95
97
|
limits.fault?.("after_payload_digest", payloadPath);
|
|
96
98
|
await assertCanonicalSingleGzipMember(
|
|
97
|
-
payloadPath,
|
|
98
99
|
payloadHandle,
|
|
99
100
|
payloadStat.size,
|
|
100
101
|
bounded.max_decompressed_bytes,
|
|
101
102
|
);
|
|
102
103
|
|
|
103
104
|
const gunzip = createGunzip();
|
|
104
|
-
const source = createReadStream(payloadPath, {
|
|
105
|
-
fd: payloadHandle.fd,
|
|
106
|
-
autoClose: false,
|
|
107
|
-
start: 0,
|
|
108
|
-
});
|
|
109
|
-
source.pipe(gunzip);
|
|
110
105
|
const decoder = new StringDecoder("utf8");
|
|
111
106
|
let pending = "";
|
|
112
107
|
let decompressedBytes = 0;
|
|
@@ -116,7 +111,7 @@ async function* streamEventV3SupportPackRecordsForManifest(
|
|
|
116
111
|
const entries: EventV3LogicalAuthorityEntry[] = [];
|
|
117
112
|
const seen = new Set<string>();
|
|
118
113
|
|
|
119
|
-
for await (const current of gunzip) {
|
|
114
|
+
for await (const current of decompressHandle(payloadHandle, 0, gunzip)) {
|
|
120
115
|
const chunk = current as Buffer;
|
|
121
116
|
decompressedBytes += chunk.length;
|
|
122
117
|
if (decompressedBytes > bounded.max_decompressed_bytes) {
|
|
@@ -247,16 +242,49 @@ export async function unpackEventV3SupportPack(
|
|
|
247
242
|
}
|
|
248
243
|
}
|
|
249
244
|
|
|
250
|
-
async function hashHandle(
|
|
251
|
-
path: string,
|
|
252
|
-
handle: Awaited<ReturnType<typeof open>>,
|
|
253
|
-
): Promise<`sha256:${string}`> {
|
|
245
|
+
async function hashHandle(handle: Awaited<ReturnType<typeof open>>): Promise<`sha256:${string}`> {
|
|
254
246
|
const hash = createHash("sha256");
|
|
255
|
-
|
|
256
|
-
for await (const current of stream) hash.update(current as Buffer);
|
|
247
|
+
for await (const current of handleChunks(handle, 0)) hash.update(current);
|
|
257
248
|
return `sha256:${hash.digest("hex")}`;
|
|
258
249
|
}
|
|
259
250
|
|
|
251
|
+
async function* handleChunks(handle: Awaited<ReturnType<typeof open>>, position: number) {
|
|
252
|
+
while (true) {
|
|
253
|
+
const buffer = Buffer.alloc(64 * 1024);
|
|
254
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, position);
|
|
255
|
+
if (bytesRead === 0) return;
|
|
256
|
+
position += bytesRead;
|
|
257
|
+
yield buffer.subarray(0, bytesRead);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async function* decompressHandle(
|
|
262
|
+
handle: Awaited<ReturnType<typeof open>>,
|
|
263
|
+
start: number,
|
|
264
|
+
decoder: ReturnType<typeof createGunzip>,
|
|
265
|
+
): AsyncGenerator<Buffer> {
|
|
266
|
+
const source = Readable.from(handleChunks(handle, start));
|
|
267
|
+
// Observe failures immediately, then settle source reads before the caller
|
|
268
|
+
// can close and reuse the verified handle, including on early rejection.
|
|
269
|
+
const completed = finished(source, { cleanup: true }).then(
|
|
270
|
+
() => undefined,
|
|
271
|
+
(error: unknown) => error,
|
|
272
|
+
);
|
|
273
|
+
source.once("error", (error) => decoder.destroy(error));
|
|
274
|
+
source.pipe(decoder);
|
|
275
|
+
try {
|
|
276
|
+
for await (const chunk of decoder) yield chunk as Buffer;
|
|
277
|
+
source.unpipe(decoder);
|
|
278
|
+
source.resume();
|
|
279
|
+
const error = await completed;
|
|
280
|
+
if (error) throw error;
|
|
281
|
+
} finally {
|
|
282
|
+
source.destroy();
|
|
283
|
+
decoder.destroy();
|
|
284
|
+
await completed;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
260
288
|
async function realDirectory(path: string): Promise<string> {
|
|
261
289
|
const stat = await lstat(path);
|
|
262
290
|
if (!stat.isDirectory() || stat.isSymbolicLink())
|
|
@@ -265,7 +293,6 @@ async function realDirectory(path: string): Promise<string> {
|
|
|
265
293
|
}
|
|
266
294
|
|
|
267
295
|
async function assertCanonicalSingleGzipMember(
|
|
268
|
-
path: string,
|
|
269
296
|
handle: Awaited<ReturnType<typeof open>>,
|
|
270
297
|
payloadBytes: number,
|
|
271
298
|
maximumDecompressedBytes: number,
|
|
@@ -284,9 +311,8 @@ async function assertCanonicalSingleGzipMember(
|
|
|
284
311
|
throw new Error("event_v3_support_gzip_header_noncanonical");
|
|
285
312
|
}
|
|
286
313
|
const inflate = createInflateRaw();
|
|
287
|
-
createReadStream(path, { fd: handle.fd, autoClose: false, start: 10 }).pipe(inflate);
|
|
288
314
|
let decompressed = 0;
|
|
289
|
-
for await (const current of inflate) {
|
|
315
|
+
for await (const current of decompressHandle(handle, 10, inflate)) {
|
|
290
316
|
decompressed += (current as Buffer).length;
|
|
291
317
|
if (decompressed > maximumDecompressedBytes) {
|
|
292
318
|
inflate.destroy();
|