harnery 0.9.0 → 0.11.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 +1 -0
- package/dist/commander.d.ts.map +1 -1
- package/dist/commander.js +6 -0
- package/dist/commands/agents.d.ts +24 -0
- package/dist/commands/agents.d.ts.map +1 -1
- package/dist/commands/agents.js +244 -13
- package/dist/commands/backup.d.ts +5 -4
- package/dist/commands/backup.d.ts.map +1 -1
- package/dist/commands/backup.js +15 -14
- package/dist/commands/browse.d.ts.map +1 -1
- package/dist/commands/browse.js +27 -1
- package/dist/commands/claude-desktop.d.ts +19 -0
- package/dist/commands/claude-desktop.d.ts.map +1 -0
- package/dist/commands/claude-desktop.js +168 -0
- package/dist/commands/context.d.ts.map +1 -1
- package/dist/commands/context.js +149 -1
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +2 -3
- package/dist/commands/eml.d.ts +32 -0
- package/dist/commands/eml.d.ts.map +1 -1
- package/dist/commands/eml.js +16 -3
- package/dist/commands/harness.d.ts +11 -0
- package/dist/commands/harness.d.ts.map +1 -0
- package/dist/commands/harness.js +118 -0
- package/dist/commands/presence.d.ts +9 -4
- package/dist/commands/presence.d.ts.map +1 -1
- package/dist/commands/presence.js +88 -5
- package/dist/commands/relay.d.ts +9 -0
- package/dist/commands/relay.d.ts.map +1 -0
- package/dist/commands/relay.js +143 -0
- package/dist/commands/sync.d.ts.map +1 -1
- package/dist/commands/sync.js +5 -0
- package/dist/commands/workflow.d.ts.map +1 -1
- package/dist/commands/workflow.js +53 -17
- package/dist/core/agents/canonical-emit.d.ts +15 -0
- package/dist/core/agents/canonical-emit.d.ts.map +1 -1
- package/dist/core/agents/canonical-emit.js +41 -3
- package/dist/core/agents/cli.js +4 -2
- package/dist/core/agents/render/prompt-context.d.ts.map +1 -1
- package/dist/core/agents/render/prompt-context.js +44 -3
- package/dist/core/agents/render/session-context.d.ts.map +1 -1
- package/dist/core/agents/render/session-context.js +26 -1
- package/dist/core/agents/rules/commit-conflict.d.ts.map +1 -1
- package/dist/core/agents/rules/commit-conflict.js +4 -37
- package/dist/core/agents/session-events.d.ts.map +1 -1
- package/dist/core/agents/session-events.js +16 -43
- package/dist/core/agents/state/heartbeat-projector.d.ts.map +1 -1
- package/dist/core/agents/state/heartbeat-projector.js +11 -2
- package/dist/core/agents/state/heartbeat-writer.d.ts.map +1 -1
- package/dist/core/agents/state/heartbeat-writer.js +3 -3
- package/dist/core/agents/state/names.d.ts +38 -3
- package/dist/core/agents/state/names.d.ts.map +1 -1
- package/dist/core/agents/state/names.js +46 -7
- package/dist/core/agents/state/pidmap.d.ts +5 -1
- package/dist/core/agents/state/pidmap.d.ts.map +1 -1
- package/dist/core/agents/state/pidmap.js +58 -2
- package/dist/core/agents/state/stale-sweep.d.ts +3 -2
- package/dist/core/agents/state/stale-sweep.d.ts.map +1 -1
- package/dist/core/agents/state/stale-sweep.js +6 -6
- package/dist/core/config.d.ts +68 -11
- package/dist/core/config.d.ts.map +1 -1
- package/dist/core/config.js +191 -25
- package/dist/core/context/index.d.ts +144 -0
- package/dist/core/context/index.d.ts.map +1 -0
- package/dist/core/context/index.js +380 -0
- package/dist/core/harnesses/bench.d.ts +29 -0
- package/dist/core/harnesses/bench.d.ts.map +1 -0
- package/dist/core/harnesses/bench.js +151 -0
- package/dist/core/harnesses/index.d.ts +9 -0
- package/dist/core/harnesses/index.d.ts.map +1 -0
- package/dist/core/harnesses/index.js +4 -0
- package/dist/core/harnesses/profiles.d.ts +62 -0
- package/dist/core/harnesses/profiles.d.ts.map +1 -0
- package/dist/core/harnesses/profiles.js +115 -0
- package/dist/core/harnesses/registry.d.ts +15 -0
- package/dist/core/harnesses/registry.d.ts.map +1 -0
- package/dist/core/harnesses/registry.js +113 -0
- package/dist/core/harnesses/types.d.ts +64 -0
- package/dist/core/harnesses/types.d.ts.map +1 -0
- package/dist/core/harnesses/types.js +20 -0
- package/dist/core/hooks/cli.js +280 -63
- package/dist/core/hooks/events/schema.d.ts +38 -1
- package/dist/core/hooks/events/schema.d.ts.map +1 -1
- package/dist/core/hooks/harness/events.d.ts.map +1 -1
- package/dist/core/hooks/harness/events.js +3 -0
- package/dist/core/hooks/harness/parse.d.ts +1 -1
- package/dist/core/hooks/harness/parse.d.ts.map +1 -1
- package/dist/core/hooks/harness/parse.js +4 -0
- package/dist/core/presence/blob.d.ts +40 -0
- package/dist/core/presence/blob.d.ts.map +1 -0
- package/dist/core/presence/blob.js +91 -0
- package/dist/core/presence/git.d.ts +68 -0
- package/dist/core/presence/git.d.ts.map +1 -0
- package/dist/core/presence/git.js +173 -0
- package/dist/core/presence/index.d.ts +74 -0
- package/dist/core/presence/index.d.ts.map +1 -0
- package/dist/core/presence/index.js +224 -0
- package/dist/core/presence/relay-client.d.ts +39 -0
- package/dist/core/presence/relay-client.d.ts.map +1 -0
- package/dist/core/presence/relay-client.js +295 -0
- package/dist/core/presence/relay-protocol.d.ts +94 -0
- package/dist/core/presence/relay-protocol.d.ts.map +1 -0
- package/dist/core/presence/relay-protocol.js +169 -0
- package/dist/core/workflow/billing.d.ts +1 -1
- package/dist/core/workflow/billing.d.ts.map +1 -1
- package/dist/core/workflow/billing.js +7 -1
- package/dist/core/workflow/engine.d.ts +5 -0
- package/dist/core/workflow/engine.d.ts.map +1 -1
- package/dist/core/workflow/engine.js +164 -16
- package/dist/core/workflow/harnesses.d.ts +3 -3
- package/dist/core/workflow/harnesses.d.ts.map +1 -1
- package/dist/core/workflow/harnesses.js +8 -17
- package/dist/core/workflow/index.d.ts +5 -0
- package/dist/core/workflow/index.d.ts.map +1 -0
- package/dist/core/workflow/index.js +3 -0
- package/dist/core/workflow/proof.d.ts +42 -0
- package/dist/core/workflow/proof.d.ts.map +1 -0
- package/dist/core/workflow/proof.js +342 -0
- package/dist/core/workflow/spawn-claude.d.ts +4 -1
- package/dist/core/workflow/spawn-claude.d.ts.map +1 -1
- package/dist/core/workflow/spawn-claude.js +39 -18
- package/dist/core/workflow/spawn-codex.d.ts +4 -1
- package/dist/core/workflow/spawn-codex.d.ts.map +1 -1
- package/dist/core/workflow/spawn-codex.js +44 -22
- package/dist/core/workflow/spawn-cursor.d.ts +4 -1
- package/dist/core/workflow/spawn-cursor.d.ts.map +1 -1
- package/dist/core/workflow/spawn-cursor.js +35 -16
- package/dist/core/workflow/types.d.ts +156 -2
- package/dist/core/workflow/types.d.ts.map +1 -1
- package/dist/core/workflow/types.js +1 -1
- package/dist/lib/browser/client.d.ts +16 -0
- package/dist/lib/browser/client.d.ts.map +1 -1
- package/dist/lib/browser/client.js +22 -0
- package/dist/lib/browser/index.d.ts +1 -0
- package/dist/lib/browser/index.d.ts.map +1 -1
- package/dist/lib/browser/index.js +1 -0
- package/dist/lib/browser/launch-args.d.ts +21 -0
- package/dist/lib/browser/launch-args.d.ts.map +1 -0
- package/dist/lib/browser/launch-args.js +32 -0
- package/dist/lib/claude-desktop.d.ts +105 -0
- package/dist/lib/claude-desktop.d.ts.map +1 -0
- package/dist/lib/claude-desktop.js +217 -0
- package/dist/lib/docs-lint.d.ts.map +1 -1
- package/dist/lib/docs-lint.js +5 -2
- package/dist/lib/identities/assume.d.ts +51 -0
- package/dist/lib/identities/assume.d.ts.map +1 -0
- package/dist/lib/identities/assume.js +274 -0
- package/dist/lib/identities/index.d.ts +7 -7
- package/dist/lib/identities/index.d.ts.map +1 -1
- package/dist/lib/identities/index.js +24 -24
- package/dist/lib/instructions/templates.d.ts.map +1 -1
- package/dist/lib/instructions/templates.js +15 -2
- package/package.json +22 -6
- package/schemas/config.schema.json +88 -19
- package/src/commander.ts +6 -0
- package/src/commands/agents.ts +280 -14
- package/src/commands/backup.ts +17 -16
- package/src/commands/browse.ts +32 -0
- package/src/commands/claude-desktop.ts +215 -0
- package/src/commands/context.ts +174 -1
- package/src/commands/doctor.ts +2 -3
- package/src/commands/eml.ts +22 -4
- package/src/commands/harness.ts +147 -0
- package/src/commands/presence.ts +111 -5
- package/src/commands/relay.ts +160 -0
- package/src/commands/sync.ts +4 -0
- package/src/commands/workflow.ts +62 -19
- package/src/core/agents/canonical-emit.ts +41 -3
- package/src/core/agents/cli.ts +4 -2
- package/src/core/agents/render/prompt-context.ts +48 -3
- package/src/core/agents/render/session-context.ts +27 -1
- package/src/core/agents/rules/commit-conflict.ts +4 -33
- package/src/core/agents/session-events.ts +15 -39
- package/src/core/agents/state/heartbeat-projector.ts +10 -1
- package/src/core/agents/state/heartbeat-writer.ts +3 -2
- package/src/core/agents/state/names.ts +68 -9
- package/src/core/agents/state/pidmap.ts +59 -2
- package/src/core/agents/state/stale-sweep.ts +6 -11
- package/src/core/config.ts +248 -24
- package/src/core/context/index.ts +575 -0
- package/src/core/harnesses/bench.ts +214 -0
- package/src/core/harnesses/index.ts +31 -0
- package/src/core/harnesses/profiles.ts +141 -0
- package/src/core/harnesses/registry.ts +140 -0
- package/src/core/harnesses/types.ts +90 -0
- package/src/core/hooks/cli.ts +331 -62
- package/src/core/hooks/events/schema.ts +68 -0
- package/src/core/hooks/harness/events.ts +3 -0
- package/src/core/hooks/harness/parse.ts +7 -1
- package/src/core/presence/blob.ts +125 -0
- package/src/core/presence/git.ts +191 -0
- package/src/core/presence/index.ts +274 -0
- package/src/core/presence/relay-client.ts +322 -0
- package/src/core/presence/relay-protocol.ts +246 -0
- package/src/core/workflow/billing.ts +8 -2
- package/src/core/workflow/engine.ts +182 -16
- package/src/core/workflow/harnesses.ts +14 -19
- package/src/core/workflow/index.ts +43 -0
- package/src/core/workflow/proof.ts +434 -0
- package/src/core/workflow/spawn-claude.ts +39 -20
- package/src/core/workflow/spawn-codex.ts +47 -28
- package/src/core/workflow/spawn-cursor.ts +37 -18
- package/src/core/workflow/types.ts +176 -2
- package/src/lib/browser/client.ts +37 -0
- package/src/lib/browser/index.ts +1 -0
- package/src/lib/browser/launch-args.ts +33 -0
- package/src/lib/claude-desktop.ts +293 -0
- package/src/lib/docs-lint.ts +5 -2
- package/src/lib/identities/assume.ts +363 -0
- package/src/lib/identities/index.ts +28 -24
- package/src/lib/instructions/templates.ts +16 -2
package/src/commands/presence.ts
CHANGED
|
@@ -1,11 +1,19 @@
|
|
|
1
1
|
import type { Command } from "commander";
|
|
2
2
|
import type { EmitContext } from "../commander.ts";
|
|
3
|
+
import { monorepoRoot } from "../core/agents/coord-client.ts";
|
|
3
4
|
import {
|
|
4
5
|
emitCanonical,
|
|
5
6
|
normalizeHarness,
|
|
6
7
|
readHeartbeat,
|
|
7
8
|
resolveOwner,
|
|
8
9
|
} from "../core/agents/index.ts";
|
|
10
|
+
import {
|
|
11
|
+
fetchPresence,
|
|
12
|
+
publishPresence,
|
|
13
|
+
readRemoteMachines,
|
|
14
|
+
relayDaemonStatus,
|
|
15
|
+
runRelayDaemon,
|
|
16
|
+
} from "../core/presence/index.ts";
|
|
9
17
|
import {
|
|
10
18
|
ageSeconds,
|
|
11
19
|
applyDetection,
|
|
@@ -18,17 +26,24 @@ import {
|
|
|
18
26
|
} from "../lib/presence.ts";
|
|
19
27
|
|
|
20
28
|
/**
|
|
21
|
-
* `presence`:
|
|
29
|
+
* `presence`: two related surfaces under one name.
|
|
30
|
+
*
|
|
31
|
+
* 1. Mobile/office state for agents (~/.claude/presence): get / set /
|
|
32
|
+
* clear / detect. The state file path is Claude-Code-specific but the
|
|
33
|
+
* logic is generic.
|
|
34
|
+
* 2. Cross-machine session presence (ADR 0016): publish / fetch / peers —
|
|
35
|
+
* the git-refs transport that lets sessions on other machines show up
|
|
36
|
+
* in the peer table via `refs/harnery/presence/<machine>` on origin.
|
|
22
37
|
*
|
|
23
38
|
* Uses the injected EmitContext so composed and standalone consumers share
|
|
24
|
-
* one code path.
|
|
25
|
-
* ~/.claude/) but the detect/set/clear logic is generic: any agent harness
|
|
26
|
-
* can adopt the same convention.
|
|
39
|
+
* one code path.
|
|
27
40
|
*/
|
|
28
41
|
export function registerPresenceCommand(program: Command, emit: EmitContext): void {
|
|
29
42
|
const cmd = program
|
|
30
43
|
.command("presence")
|
|
31
|
-
.description(
|
|
44
|
+
.description(
|
|
45
|
+
"Presence: mobile/office state (get/set/clear/detect) + cross-machine session presence (publish/fetch/peers)",
|
|
46
|
+
)
|
|
32
47
|
.action(() => {
|
|
33
48
|
printStatus(emit);
|
|
34
49
|
});
|
|
@@ -83,6 +98,88 @@ export function registerPresenceCommand(program: Command, emit: EmitContext): vo
|
|
|
83
98
|
});
|
|
84
99
|
});
|
|
85
100
|
|
|
101
|
+
// ---- cross-machine session presence (ADR 0016) ----
|
|
102
|
+
|
|
103
|
+
cmd
|
|
104
|
+
.command("publish")
|
|
105
|
+
.description(
|
|
106
|
+
"Publish this machine's live sessions to refs/harnery/presence/<machine> on origin. " +
|
|
107
|
+
"Change-batched with a keepalive; --force pushes regardless. Synchronous (errors report).",
|
|
108
|
+
)
|
|
109
|
+
.option("--force", "Push even if unchanged / within throttle windows")
|
|
110
|
+
.action((opts: { force?: boolean }) => {
|
|
111
|
+
const root = requireRoot(emit);
|
|
112
|
+
const r = publishPresence(root, { force: opts.force, sync: true });
|
|
113
|
+
if (r.status === "error") {
|
|
114
|
+
emit.error({ code: "publish_failed", message: r.error });
|
|
115
|
+
process.exit(1);
|
|
116
|
+
}
|
|
117
|
+
emit.data(r);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
cmd
|
|
121
|
+
.command("fetch")
|
|
122
|
+
.description(
|
|
123
|
+
"Fetch peer machines' presence refs from origin (throttled; --force bypasses). Synchronous.",
|
|
124
|
+
)
|
|
125
|
+
.option("--force", "Fetch even within the throttle interval")
|
|
126
|
+
.action((opts: { force?: boolean }) => {
|
|
127
|
+
const root = requireRoot(emit);
|
|
128
|
+
const r = fetchPresence(root, { force: opts.force, sync: true });
|
|
129
|
+
if (r.status === "error") {
|
|
130
|
+
emit.error({ code: "fetch_failed", message: r.error });
|
|
131
|
+
process.exit(1);
|
|
132
|
+
}
|
|
133
|
+
emit.data(r);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
cmd
|
|
137
|
+
.command("relay-daemon")
|
|
138
|
+
.description(
|
|
139
|
+
"Run the relay client daemon in the foreground (normally spawned detached by the hooks " +
|
|
140
|
+
"when presence.relay is configured; holds the WebSocket, publishes on heartbeat change, " +
|
|
141
|
+
"caches peers' blobs for the renderers, exits when the machine goes idle).",
|
|
142
|
+
)
|
|
143
|
+
.action(async () => {
|
|
144
|
+
const root = requireRoot(emit);
|
|
145
|
+
process.exit(await runRelayDaemon(root));
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
cmd
|
|
149
|
+
.command("peers")
|
|
150
|
+
.description(
|
|
151
|
+
"Show sessions on other machines (merged from presence refs + the relay daemon's cache)",
|
|
152
|
+
)
|
|
153
|
+
.option("--json", "JSON output")
|
|
154
|
+
.option("--stale", "Include machines past the stale window")
|
|
155
|
+
.action((opts: { json?: boolean; stale?: boolean }) => {
|
|
156
|
+
const root = requireRoot(emit);
|
|
157
|
+
const machines = readRemoteMachines(root, { includeStale: opts.stale });
|
|
158
|
+
const daemon = relayDaemonStatus(root);
|
|
159
|
+
if (opts.json) emit.config({ format: "json" });
|
|
160
|
+
if (opts.json || !process.stdout.isTTY) {
|
|
161
|
+
emit.data({ machines, relay_daemon: daemon });
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
emit.text(
|
|
165
|
+
daemon.running
|
|
166
|
+
? `relay daemon: running (pid ${daemon.pid}, ${daemon.url})\n`
|
|
167
|
+
: "relay daemon: not running (git-refs transport only)\n",
|
|
168
|
+
);
|
|
169
|
+
if (machines.length === 0) {
|
|
170
|
+
emit.text("no remote sessions known (try `presence fetch --force`)\n");
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
for (const m of machines) {
|
|
174
|
+
emit.text(`${m.machine} (published ${formatAge(m.age_secs)} ago)\n`);
|
|
175
|
+
for (const a of m.agents) {
|
|
176
|
+
const task = a.task ? ` "${a.task.slice(0, 70)}"` : "";
|
|
177
|
+
const files = a.files_touched?.length ? ` [${a.files_touched.length} files]` : "";
|
|
178
|
+
emit.text(` agent-${a.name ?? a.instance_id.slice(0, 8)}${task}${files}\n`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
|
|
86
183
|
cmd
|
|
87
184
|
.command("detect")
|
|
88
185
|
.description(
|
|
@@ -111,6 +208,15 @@ export function registerPresenceCommand(program: Command, emit: EmitContext): vo
|
|
|
111
208
|
});
|
|
112
209
|
}
|
|
113
210
|
|
|
211
|
+
function requireRoot(emit: EmitContext): string {
|
|
212
|
+
const root = monorepoRoot();
|
|
213
|
+
if (!root) {
|
|
214
|
+
emit.error({ code: "not_in_repo", message: "coord root not found (no .harnery/ upward)" });
|
|
215
|
+
process.exit(1);
|
|
216
|
+
}
|
|
217
|
+
return root;
|
|
218
|
+
}
|
|
219
|
+
|
|
114
220
|
function printStatus(emit: EmitContext): void {
|
|
115
221
|
const r = readPresence();
|
|
116
222
|
emit.data({
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import type { Command } from "commander";
|
|
2
|
+
import type { EmitContext } from "../commander.ts";
|
|
3
|
+
import { MAX_FRAME_BYTES } from "../core/presence/relay-protocol.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `relay serve`: the Bun self-host of the presence relay (ADR 0016 phase 2).
|
|
7
|
+
* Same wire protocol as the Cloudflare Durable Objects host in relay/worker/ —
|
|
8
|
+
* one in-memory room per opaque room id, warm-join cache, fan-out, rate
|
|
9
|
+
* limits. For teams who'd rather run their own box than use the public
|
|
10
|
+
* relay.harnery.com; point clients at it via `.harnery/config.jsonc` →
|
|
11
|
+
* `{ "presence": { "relay": "ws://host:port" } }`.
|
|
12
|
+
*
|
|
13
|
+
* Bun-only (Bun.serve WebSockets), like `harn tunnel` — fails fast with a
|
|
14
|
+
* clear message on a Bun-free host.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const MAX_SOCKETS_PER_ROOM = 64;
|
|
18
|
+
const RATE_BURST = 10;
|
|
19
|
+
const RATE_REFILL_MS = 2000;
|
|
20
|
+
const CACHE_TTL_MS = 15 * 60 * 1000;
|
|
21
|
+
const ROOM_ID_RE = /^[0-9a-f]{32}$/;
|
|
22
|
+
|
|
23
|
+
interface RoomState {
|
|
24
|
+
sockets: Set<unknown>;
|
|
25
|
+
cache: Map<string, { raw: string; at: number }>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface SocketData {
|
|
29
|
+
roomId: string;
|
|
30
|
+
bucketTokens: number;
|
|
31
|
+
bucketAt: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Start the relay server. Exported for in-process integration tests. */
|
|
35
|
+
export function startRelayServer(port: number): { port: number; stop: () => void } {
|
|
36
|
+
if (typeof Bun === "undefined" || typeof Bun.serve !== "function") {
|
|
37
|
+
throw new Error("relay serve requires Bun (Bun.serve WebSockets)");
|
|
38
|
+
}
|
|
39
|
+
const rooms = new Map<string, RoomState>();
|
|
40
|
+
|
|
41
|
+
const roomOf = (roomId: string): RoomState => {
|
|
42
|
+
let r = rooms.get(roomId);
|
|
43
|
+
if (!r) {
|
|
44
|
+
r = { sockets: new Set(), cache: new Map() };
|
|
45
|
+
rooms.set(roomId, r);
|
|
46
|
+
}
|
|
47
|
+
return r;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const server = Bun.serve<SocketData>({
|
|
51
|
+
port,
|
|
52
|
+
fetch(req, srv) {
|
|
53
|
+
const url = new URL(req.url);
|
|
54
|
+
if (url.pathname === "/" || url.pathname === "/healthz") {
|
|
55
|
+
return Response.json({ ok: true, service: "harnery-presence-relay", v: 1 });
|
|
56
|
+
}
|
|
57
|
+
const m = /^\/v1\/room\/([0-9a-f]{32})$/.exec(url.pathname);
|
|
58
|
+
if (!m || !ROOM_ID_RE.test(m[1]!)) return new Response("not found", { status: 404 });
|
|
59
|
+
const room = roomOf(m[1]!);
|
|
60
|
+
if (room.sockets.size >= MAX_SOCKETS_PER_ROOM) {
|
|
61
|
+
return new Response("room full", { status: 429 });
|
|
62
|
+
}
|
|
63
|
+
const ok = srv.upgrade(req, {
|
|
64
|
+
data: { roomId: m[1]!, bucketTokens: RATE_BURST, bucketAt: Date.now() },
|
|
65
|
+
});
|
|
66
|
+
return ok ? undefined : new Response("expected websocket", { status: 426 });
|
|
67
|
+
},
|
|
68
|
+
websocket: {
|
|
69
|
+
open(ws) {
|
|
70
|
+
const room = roomOf(ws.data.roomId);
|
|
71
|
+
room.sockets.add(ws);
|
|
72
|
+
const now = Date.now();
|
|
73
|
+
for (const [sender, entry] of room.cache) {
|
|
74
|
+
if (now - entry.at > CACHE_TTL_MS) {
|
|
75
|
+
room.cache.delete(sender);
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
ws.send(entry.raw);
|
|
79
|
+
}
|
|
80
|
+
ws.send(JSON.stringify({ t: "hello", peers: room.sockets.size }));
|
|
81
|
+
},
|
|
82
|
+
message(ws, message) {
|
|
83
|
+
if (typeof message !== "string" || message.length > MAX_FRAME_BYTES) return;
|
|
84
|
+
// Rate limit (token bucket per socket).
|
|
85
|
+
const d = ws.data;
|
|
86
|
+
const now = Date.now();
|
|
87
|
+
const refill = Math.floor((now - d.bucketAt) / RATE_REFILL_MS);
|
|
88
|
+
if (refill > 0) {
|
|
89
|
+
d.bucketTokens = Math.min(RATE_BURST, d.bucketTokens + refill);
|
|
90
|
+
d.bucketAt = now;
|
|
91
|
+
}
|
|
92
|
+
if (d.bucketTokens <= 0) return;
|
|
93
|
+
d.bucketTokens -= 1;
|
|
94
|
+
|
|
95
|
+
let frame: { t?: unknown; sender?: unknown };
|
|
96
|
+
try {
|
|
97
|
+
frame = JSON.parse(message) as typeof frame;
|
|
98
|
+
} catch {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (
|
|
102
|
+
frame.t !== "pub" ||
|
|
103
|
+
typeof frame.sender !== "string" ||
|
|
104
|
+
!/^[0-9a-f]+$/.test(frame.sender) ||
|
|
105
|
+
frame.sender.length > 64
|
|
106
|
+
) {
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
const room = roomOf(d.roomId);
|
|
110
|
+
room.cache.set(frame.sender, { raw: message, at: now });
|
|
111
|
+
for (const peer of room.sockets) {
|
|
112
|
+
if (peer === ws) continue;
|
|
113
|
+
try {
|
|
114
|
+
(peer as { send(m: string): void }).send(message);
|
|
115
|
+
} catch {
|
|
116
|
+
/* dead socket */
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
close(ws) {
|
|
121
|
+
const room = rooms.get(ws.data.roomId);
|
|
122
|
+
if (!room) return;
|
|
123
|
+
room.sockets.delete(ws);
|
|
124
|
+
if (room.sockets.size === 0 && room.cache.size === 0) rooms.delete(ws.data.roomId);
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
port: server.port ?? port,
|
|
131
|
+
stop: () => server.stop(true),
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function registerRelayCommand(program: Command, emit: EmitContext): void {
|
|
136
|
+
const cmd = program
|
|
137
|
+
.command("relay")
|
|
138
|
+
.description("Self-host the presence relay (see also relay/worker/ for the Cloudflare host)");
|
|
139
|
+
|
|
140
|
+
cmd
|
|
141
|
+
.command("serve")
|
|
142
|
+
.description(
|
|
143
|
+
"Run the presence relay on this machine (Bun-only; same protocol as relay/worker/)",
|
|
144
|
+
)
|
|
145
|
+
.option("--port <n>", "Port to listen on", "8787")
|
|
146
|
+
.action((opts: { port: string }) => {
|
|
147
|
+
let server: { port: number; stop: () => void };
|
|
148
|
+
try {
|
|
149
|
+
server = startRelayServer(Number.parseInt(opts.port, 10));
|
|
150
|
+
} catch (e) {
|
|
151
|
+
emit.error({
|
|
152
|
+
code: "relay_serve_failed",
|
|
153
|
+
message: e instanceof Error ? e.message : String(e),
|
|
154
|
+
});
|
|
155
|
+
process.exit(1);
|
|
156
|
+
}
|
|
157
|
+
emit.log(`presence relay listening on :${server.port} (ws path: /v1/room/<room-id>)`, "info");
|
|
158
|
+
// Foreground service: run until interrupted.
|
|
159
|
+
});
|
|
160
|
+
}
|
package/src/commands/sync.ts
CHANGED
|
@@ -25,6 +25,7 @@ import os from "node:os";
|
|
|
25
25
|
import path from "node:path";
|
|
26
26
|
import type { Command } from "commander";
|
|
27
27
|
import type { EmitContext } from "../commander.ts";
|
|
28
|
+
import { syncJsoncConfig } from "../core/config.ts";
|
|
28
29
|
|
|
29
30
|
// What we sync. Anything matching one of these (relative to .harnery/) is
|
|
30
31
|
// considered part of the sync set. Everything else stays local-only.
|
|
@@ -40,9 +41,12 @@ function configPath(): string {
|
|
|
40
41
|
}
|
|
41
42
|
|
|
42
43
|
function loadConfig(): SyncConfig | null {
|
|
44
|
+
// Precedence: env → .harnery/config.jsonc `sync` → ~/.config/harnery/sync.json.
|
|
43
45
|
const envRemote = process.env.HARNERY_SYNC_REMOTE;
|
|
44
46
|
const envPrefix = process.env.HARNERY_SYNC_PREFIX ?? "harnery";
|
|
45
47
|
if (envRemote) return { remote: envRemote, prefix: envPrefix };
|
|
48
|
+
const jsonc = syncJsoncConfig();
|
|
49
|
+
if (jsonc) return jsonc;
|
|
46
50
|
const p = configPath();
|
|
47
51
|
if (!existsSync(p)) return null;
|
|
48
52
|
try {
|
package/src/commands/workflow.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Command } from "commander";
|
|
2
2
|
import type { EmitContext } from "../commander.ts";
|
|
3
3
|
import { workflowSubscriptionOnly } from "../core/config.ts";
|
|
4
|
+
import { createBuiltinHarnessRegistry } from "../core/harnesses/index.ts";
|
|
4
5
|
import { findCoordRoot } from "../core/hooks/resolve/coord-root.ts";
|
|
5
6
|
|
|
6
7
|
/**
|
|
@@ -24,9 +25,13 @@ interface WorkflowRunOpts {
|
|
|
24
25
|
json?: boolean;
|
|
25
26
|
}
|
|
26
27
|
|
|
27
|
-
|
|
28
|
+
interface WorkflowProofOpts {
|
|
29
|
+
json?: boolean;
|
|
30
|
+
}
|
|
28
31
|
|
|
29
32
|
export function registerWorkflowCommand(program: Command, emit: EmitContext): void {
|
|
33
|
+
const registry = createBuiltinHarnessRegistry();
|
|
34
|
+
const harnesses = registry.ids();
|
|
30
35
|
const workflow = program
|
|
31
36
|
.command("workflow")
|
|
32
37
|
.description("Run bounded, schema-gated multi-subagent workflow scripts.");
|
|
@@ -34,7 +39,7 @@ export function registerWorkflowCommand(program: Command, emit: EmitContext): vo
|
|
|
34
39
|
workflow
|
|
35
40
|
.command("run <script>")
|
|
36
41
|
.description(
|
|
37
|
-
"Execute a workflow script (plain JS: `export default async ({agent, parallel, stage, log}) => …`). " +
|
|
42
|
+
"Execute a workflow script (plain JS: `export default async ({agent, parallel, stage, log, evidence}) => …`). " +
|
|
38
43
|
"Subagents spawn as headless harness-CLI subprocesses, coordination-registered.",
|
|
39
44
|
)
|
|
40
45
|
.option("--max-agents <n>", "Total-agent ceiling for the run (default 50)")
|
|
@@ -42,7 +47,7 @@ export function registerWorkflowCommand(program: Command, emit: EmitContext): vo
|
|
|
42
47
|
.option("--cwd <dir>", "Working directory children spawn in (default: coord root)")
|
|
43
48
|
.option(
|
|
44
49
|
"--harness <name>",
|
|
45
|
-
`Default harness for agent() calls: ${
|
|
50
|
+
`Default harness for agent() calls: ${harnesses.join(" | ")} (default claude-code); scripts can override per agent via opts.harness`,
|
|
46
51
|
)
|
|
47
52
|
.option(
|
|
48
53
|
"--resume-from <run-id>",
|
|
@@ -68,10 +73,10 @@ export function registerWorkflowCommand(program: Command, emit: EmitContext): vo
|
|
|
68
73
|
});
|
|
69
74
|
process.exit(1);
|
|
70
75
|
}
|
|
71
|
-
if (opts.harness && !
|
|
76
|
+
if (opts.harness && !registry.get(opts.harness)) {
|
|
72
77
|
emit.error({
|
|
73
78
|
code: "bad_harness",
|
|
74
|
-
message: `unknown harness "${opts.harness}" (expected: ${
|
|
79
|
+
message: `unknown harness "${opts.harness}" (expected: ${harnesses.join(" | ")})`,
|
|
75
80
|
});
|
|
76
81
|
process.exit(1);
|
|
77
82
|
}
|
|
@@ -87,27 +92,25 @@ export function registerWorkflowCommand(program: Command, emit: EmitContext): vo
|
|
|
87
92
|
process.exit(1);
|
|
88
93
|
}
|
|
89
94
|
try {
|
|
90
|
-
const
|
|
91
|
-
await Promise.all([
|
|
92
|
-
import("../core/workflow/engine.ts"),
|
|
93
|
-
import("../core/workflow/spawn-claude.ts"),
|
|
94
|
-
import("../core/workflow/spawn-codex.ts"),
|
|
95
|
-
import("../core/workflow/spawn-cursor.ts"),
|
|
96
|
-
]);
|
|
95
|
+
const { runWorkflow } = await import("../core/workflow/engine.ts");
|
|
97
96
|
const report = await runWorkflow(script, {
|
|
98
97
|
coordRoot,
|
|
99
|
-
spawners:
|
|
100
|
-
|
|
101
|
-
codex: codexSpawner,
|
|
102
|
-
cursor: cursorSpawner,
|
|
103
|
-
},
|
|
104
|
-
defaultHarness: opts.harness as "claude-code" | "codex" | "cursor" | undefined,
|
|
98
|
+
spawners: registry.spawners(),
|
|
99
|
+
defaultHarness: opts.harness,
|
|
105
100
|
resumeFrom: opts.resumeFrom,
|
|
106
101
|
subscriptionOnly,
|
|
107
102
|
allowApiBilling: opts.allowApiBilling,
|
|
108
103
|
maxAgents: opts.maxAgents ? Number.parseInt(opts.maxAgents, 10) : undefined,
|
|
109
104
|
concurrency: opts.concurrency ? Number.parseInt(opts.concurrency, 10) : undefined,
|
|
110
105
|
cwd: opts.cwd,
|
|
106
|
+
harnessEvidence: Object.fromEntries(
|
|
107
|
+
registry
|
|
108
|
+
.list()
|
|
109
|
+
.map((adapter) => [
|
|
110
|
+
adapter.profile.id,
|
|
111
|
+
{ toolEvidence: adapter.profile.capabilities.toolEvidence },
|
|
112
|
+
]),
|
|
113
|
+
),
|
|
111
114
|
});
|
|
112
115
|
if (opts.json) {
|
|
113
116
|
emit.config({ format: "json" });
|
|
@@ -121,11 +124,51 @@ export function registerWorkflowCommand(program: Command, emit: EmitContext): vo
|
|
|
121
124
|
emit.text(
|
|
122
125
|
`run ${report.runId} (${report.name}) finished: ${report.agentsSpawned} agent(s)${cachedPart}, ` +
|
|
123
126
|
`$${report.costUsd.toFixed(4)}, ${Math.round(report.durationMs / 1000)}s\n${billingPart}` +
|
|
127
|
+
`acceptance: ${report.acceptance.satisfied} satisfied, ${report.acceptance.unsatisfied} unsatisfied, ` +
|
|
128
|
+
`${report.acceptance.unknown} unknown\n` +
|
|
124
129
|
`journal: ${report.journalPath}\n` +
|
|
130
|
+
`proof: ${report.proofPath}\n` +
|
|
125
131
|
`result: ${typeof report.result === "string" ? report.result : JSON.stringify(report.result, null, 2)}\n`,
|
|
126
132
|
);
|
|
127
133
|
} catch (err) {
|
|
128
|
-
|
|
134
|
+
const proofPath =
|
|
135
|
+
typeof err === "object" && err !== null && "proofPath" in err
|
|
136
|
+
? String(err.proofPath)
|
|
137
|
+
: undefined;
|
|
138
|
+
emit.error({
|
|
139
|
+
code: "workflow_failed",
|
|
140
|
+
message: `${(err as Error).message}${proofPath ? `\nproof: ${proofPath}` : ""}`,
|
|
141
|
+
});
|
|
142
|
+
process.exit(1);
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
workflow
|
|
147
|
+
.command("proof <run-id>")
|
|
148
|
+
.description("Show the bounded proof packet for a completed workflow run.")
|
|
149
|
+
.option("--json", "Emit the stored proof packet as JSON")
|
|
150
|
+
.action(async (runId: string, opts: WorkflowProofOpts) => {
|
|
151
|
+
const coordRoot = findCoordRoot();
|
|
152
|
+
if (!coordRoot) {
|
|
153
|
+
emit.error({
|
|
154
|
+
code: "no_coord_root",
|
|
155
|
+
message: "no .harnery/ coordination root found; run `init` first",
|
|
156
|
+
});
|
|
157
|
+
process.exit(1);
|
|
158
|
+
}
|
|
159
|
+
try {
|
|
160
|
+
const { readWorkflowProof, renderWorkflowProof } = await import(
|
|
161
|
+
"../core/workflow/proof.ts"
|
|
162
|
+
);
|
|
163
|
+
const proof = readWorkflowProof(coordRoot, runId);
|
|
164
|
+
if (opts.json) {
|
|
165
|
+
emit.config({ format: "json" });
|
|
166
|
+
emit.data(proof);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
emit.text(renderWorkflowProof(proof));
|
|
170
|
+
} catch (err) {
|
|
171
|
+
emit.error({ code: "workflow_proof_failed", message: (err as Error).message });
|
|
129
172
|
process.exit(1);
|
|
130
173
|
}
|
|
131
174
|
});
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
import { spawnSync } from "node:child_process";
|
|
13
13
|
import { existsSync } from "node:fs";
|
|
14
14
|
import { dirname, join, resolve } from "node:path";
|
|
15
|
+
import { monorepoRoot } from "./coord-client.ts";
|
|
15
16
|
|
|
16
17
|
export interface CanonicalEmitInput {
|
|
17
18
|
type: string;
|
|
@@ -24,8 +25,37 @@ export interface CanonicalEmitInput {
|
|
|
24
25
|
parentTurnId?: string;
|
|
25
26
|
}
|
|
26
27
|
|
|
28
|
+
/**
|
|
29
|
+
* Resolve the coord root for a canonical emit.
|
|
30
|
+
*
|
|
31
|
+
* Order: explicit `HARNERY_COORD_ROOT_OVERRIDE` → git-superproject-aware
|
|
32
|
+
* `monorepoRoot()` (must actually carry `.harnery/`) → cwd walk (non-git
|
|
33
|
+
* hosts). The git-aware step is load-bearing: a shell cd'd into a nested
|
|
34
|
+
* directory that carries its own `.harnery/` (e.g. an embedded harnery
|
|
35
|
+
* checkout) made the old cwd-walk resolve the NESTED root, from which
|
|
36
|
+
* `<root>/harnery/bin/agent-coord` doesn't exist — so `agents status` /
|
|
37
|
+
* `set-task` emits silently vanished and the Stop-hook's rule 1/3
|
|
38
|
+
* (`state.status_checked` in-turn) blocked turns that had done the ritual.
|
|
39
|
+
* Same bug class as the coordHelperOpts root-pin fix; this closes the
|
|
40
|
+
* emitCanonical instance of it.
|
|
41
|
+
*/
|
|
42
|
+
export function resolveEmitRoot(start: string = process.cwd()): string | null {
|
|
43
|
+
const override = process.env.HARNERY_COORD_ROOT_OVERRIDE;
|
|
44
|
+
if (override) return override;
|
|
45
|
+
// Memoized per start-dir: the session-tee middleware resolves once per
|
|
46
|
+
// emitted event, and monorepoRoot() spawns git — cache so a streaming
|
|
47
|
+
// command doesn't pay 1-3 subprocess spawns per output line.
|
|
48
|
+
if (cachedRoot && cachedRoot.start === start) return cachedRoot.root;
|
|
49
|
+
const gitRoot = monorepoRoot();
|
|
50
|
+
const root = gitRoot && existsSync(join(gitRoot, ".harnery")) ? gitRoot : findRepoRoot(start);
|
|
51
|
+
cachedRoot = { start, root };
|
|
52
|
+
return root;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
let cachedRoot: { start: string; root: string | null } | undefined;
|
|
56
|
+
|
|
27
57
|
export function emitCanonical(input: CanonicalEmitInput): void {
|
|
28
|
-
const root =
|
|
58
|
+
const root = resolveEmitRoot();
|
|
29
59
|
if (!root) return;
|
|
30
60
|
const binary = resolve(root, "harnery", "bin", "agent-coord");
|
|
31
61
|
if (!existsSync(binary)) return;
|
|
@@ -46,11 +76,19 @@ export function emitCanonical(input: CanonicalEmitInput): void {
|
|
|
46
76
|
if (input.turnId) args.push("--turn-id", input.turnId);
|
|
47
77
|
if (input.parentSessionId) args.push("--parent-session-id", input.parentSessionId);
|
|
48
78
|
if (input.parentTurnId) args.push("--parent-turn-id", input.parentTurnId);
|
|
49
|
-
|
|
79
|
+
// Pin cwd + HARNERY_COORD_ROOT_OVERRIDE so the child writes to the SAME
|
|
80
|
+
// stream we resolved, regardless of where the caller's shell is cd'd.
|
|
81
|
+
const result = spawnSync(binary, args, {
|
|
50
82
|
encoding: "utf8",
|
|
51
83
|
stdio: "ignore",
|
|
52
84
|
timeout: 3000,
|
|
85
|
+
cwd: root,
|
|
86
|
+
env: { ...process.env, HARNERY_COORD_ROOT_OVERRIDE: root },
|
|
53
87
|
});
|
|
88
|
+
if (result.error || result.status !== 0) {
|
|
89
|
+
const why = result.error ? result.error.message : `exit ${result.status}`;
|
|
90
|
+
process.stderr.write(`emitCanonical: ${input.type} emit failed (${why})\n`); // lint-ok-emission: soft-fail diagnostic promised by the module doc; silent drops cost blocked turns
|
|
91
|
+
}
|
|
54
92
|
} catch {
|
|
55
93
|
/* never break the caller */
|
|
56
94
|
}
|
|
@@ -66,7 +104,7 @@ export function normalizeHarness(platform: string | undefined): "claude-code" |
|
|
|
66
104
|
return "claude-code";
|
|
67
105
|
}
|
|
68
106
|
|
|
69
|
-
function findRepoRoot(start: string
|
|
107
|
+
function findRepoRoot(start: string): string | null {
|
|
70
108
|
let dir = resolve(start);
|
|
71
109
|
while (true) {
|
|
72
110
|
if (existsSync(join(dir, ".harnery"))) return dir;
|
package/src/core/agents/cli.ts
CHANGED
|
@@ -210,8 +210,10 @@ async function handleStateAction(root: string, action: string, rest: string[]):
|
|
|
210
210
|
const task = args.join(" ");
|
|
211
211
|
const hb = writer.setTask(root, owner, task);
|
|
212
212
|
if (!hb) {
|
|
213
|
+
// Name the RESOLVED root: when a nested .harnery/ shadows the real
|
|
214
|
+
// coordination home, the full path is what makes that diagnosable.
|
|
213
215
|
process.stderr.write(
|
|
214
|
-
`agent-coord set-task: no heartbeat at
|
|
216
|
+
`agent-coord set-task: no heartbeat at ${root}/.harnery/active/${owner}.json\n`,
|
|
215
217
|
);
|
|
216
218
|
return 1;
|
|
217
219
|
}
|
|
@@ -716,7 +718,7 @@ async function handleResolveName(root: string, rest: string[]): Promise<number>
|
|
|
716
718
|
return 0;
|
|
717
719
|
}
|
|
718
720
|
process.stdout.write(
|
|
719
|
-
`${JSON.stringify({ instance_id: owner, name: resolved.name, kind: resolved.kind })}\n`,
|
|
721
|
+
`${JSON.stringify({ instance_id: owner, name: resolved.name, kind: resolved.kind, agent_id: resolved.agent_id ?? null })}\n`,
|
|
720
722
|
);
|
|
721
723
|
return 0;
|
|
722
724
|
}
|
|
@@ -36,6 +36,7 @@ import {
|
|
|
36
36
|
import { join } from "node:path";
|
|
37
37
|
|
|
38
38
|
import { coordEnv } from "../../../lib/env.ts";
|
|
39
|
+
import { type RemoteMachine, readRemoteMachines } from "../../presence/index.ts";
|
|
39
40
|
import { formatPendingCouncils } from "./session-context.ts";
|
|
40
41
|
|
|
41
42
|
interface HeartbeatRow {
|
|
@@ -170,7 +171,10 @@ function computePeerTableIfChanged(
|
|
|
170
171
|
if (!hb?.instance_id || hb.instance_id === selfInstanceId) continue;
|
|
171
172
|
peers.push(hb);
|
|
172
173
|
}
|
|
173
|
-
|
|
174
|
+
// Cross-machine presence (ADR 0016): sessions on other machines, from the
|
|
175
|
+
// locally-fetched presence refs. Advisory (no claim blocking in v1).
|
|
176
|
+
const remote = readRemoteMachinesSafe(coordRoot);
|
|
177
|
+
if (peers.length === 0 && remote.length === 0) return "";
|
|
174
178
|
|
|
175
179
|
// Build hash basis: sorted-by-instance_id projection of semantically-relevant fields.
|
|
176
180
|
const basis = peers
|
|
@@ -184,14 +188,27 @@ function computePeerTableIfChanged(
|
|
|
184
188
|
platform: p.platform ?? null,
|
|
185
189
|
}))
|
|
186
190
|
.sort((a, b) => (a.instance_id ?? "").localeCompare(b.instance_id ?? ""));
|
|
187
|
-
|
|
191
|
+
// Remote basis: machine + per-agent identity/task/files (published_at is
|
|
192
|
+
// excluded — a keepalive re-publish alone shouldn't re-emit the table).
|
|
193
|
+
const remoteBasis = remote.map((m) => ({
|
|
194
|
+
machine: m.machine,
|
|
195
|
+
agents: m.agents.map((a) => ({
|
|
196
|
+
instance_id: a.instance_id,
|
|
197
|
+
name: a.name ?? null,
|
|
198
|
+
task: a.task ?? null,
|
|
199
|
+
files_touched: Array.from(a.files_touched ?? []).sort(),
|
|
200
|
+
})),
|
|
201
|
+
}));
|
|
202
|
+
const newHash = sha256Hex16(JSON.stringify({ local: basis, remote: remoteBasis }));
|
|
188
203
|
|
|
189
204
|
const hashFile = join(coordRoot, ".harnery", `.last-peer-hash.${selfInstanceId}`);
|
|
190
205
|
const oldHash = safeRead(hashFile);
|
|
191
206
|
if (oldHash && oldHash === newHash) return "";
|
|
192
207
|
|
|
193
208
|
// Render peer table via the same formatter used at SessionStart.
|
|
194
|
-
const
|
|
209
|
+
const local = formatPeerTable(peers, mySessionId);
|
|
210
|
+
const remoteTable = formatRemoteTable(remote);
|
|
211
|
+
const table = [local, remoteTable].filter(Boolean).join("\n\n");
|
|
195
212
|
if (!table) return "";
|
|
196
213
|
|
|
197
214
|
// Persist hash atomically (temp + rename, same convention as other coord writes).
|
|
@@ -199,6 +216,34 @@ function computePeerTableIfChanged(
|
|
|
199
216
|
return table;
|
|
200
217
|
}
|
|
201
218
|
|
|
219
|
+
/** readRemoteMachines with a local failure guard (rendering must never throw). */
|
|
220
|
+
function readRemoteMachinesSafe(coordRoot: string): RemoteMachine[] {
|
|
221
|
+
try {
|
|
222
|
+
return readRemoteMachines(coordRoot);
|
|
223
|
+
} catch {
|
|
224
|
+
return [];
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Render sessions on other machines (presence transport) as a subtable. */
|
|
229
|
+
function formatRemoteTable(remote: RemoteMachine[]): string {
|
|
230
|
+
if (remote.length === 0) return "";
|
|
231
|
+
const nowSec = Math.floor(Date.now() / 1000);
|
|
232
|
+
const lines: string[] = [];
|
|
233
|
+
for (const m of remote) {
|
|
234
|
+
for (const a of m.agents.slice(0, 10)) {
|
|
235
|
+
lines.push(
|
|
236
|
+
formatRow(
|
|
237
|
+
{ ...a, display_files: Array.from(a.files_touched ?? []).sort() },
|
|
238
|
+
nowSec,
|
|
239
|
+
).replace(/^ {2}- (agent-[^ ]+)/, ` - $1 @${m.machine}`),
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
if (lines.length === 0) return "";
|
|
244
|
+
return `Sessions on other machines (advisory, via presence refs):\n${lines.join("\n")}`;
|
|
245
|
+
}
|
|
246
|
+
|
|
202
247
|
function computeCouncilPendingIfChanged(
|
|
203
248
|
coordRoot: string,
|
|
204
249
|
selfInstanceId: string,
|