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
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Presence blob: the per-machine payload published to
|
|
3
|
+
* `refs/harnery/presence/<machine>` (ADR 0016). One blob describes every live
|
|
4
|
+
* session on this machine; peers render it in their agents list / status box /
|
|
5
|
+
* peer table, labeled by machine.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { createHash } from "node:crypto";
|
|
9
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { resolveMachineLabel } from "../../lib/machine.ts";
|
|
12
|
+
|
|
13
|
+
/** Mirror of the local heartbeat freshness window (commands/agents.ts). */
|
|
14
|
+
const FRESHNESS_SECS = 600;
|
|
15
|
+
|
|
16
|
+
/** Caps that bound the blob to ~1-2KB regardless of local state. */
|
|
17
|
+
const MAX_AGENTS = 20;
|
|
18
|
+
const MAX_FILES_PER_AGENT = 10;
|
|
19
|
+
|
|
20
|
+
export interface PresenceAgent {
|
|
21
|
+
instance_id: string;
|
|
22
|
+
name?: string;
|
|
23
|
+
kind?: string;
|
|
24
|
+
session_id?: string;
|
|
25
|
+
platform?: string;
|
|
26
|
+
task?: string;
|
|
27
|
+
turn_summary?: string;
|
|
28
|
+
files_touched?: string[];
|
|
29
|
+
last_tool?: string;
|
|
30
|
+
started_at?: string;
|
|
31
|
+
last_heartbeat?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface PresenceBlob {
|
|
35
|
+
v: 1;
|
|
36
|
+
machine: string;
|
|
37
|
+
published_at: string;
|
|
38
|
+
agents: PresenceAgent[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface BuiltBlob {
|
|
42
|
+
blob: PresenceBlob;
|
|
43
|
+
/** Stable hash of the semantically-relevant projection — the change
|
|
44
|
+
* detector that decides whether a publish is worth a push. */
|
|
45
|
+
basisHash: string;
|
|
46
|
+
json: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Build this machine's presence blob from `.harnery/active/` heartbeats.
|
|
51
|
+
* Includes live sessions + subagents; excludes kind=transient stubs (they are
|
|
52
|
+
* fold-artifacts of the local claim model, not sessions) and anything past the
|
|
53
|
+
* freshness window.
|
|
54
|
+
*/
|
|
55
|
+
export function buildPresenceBlob(coordRoot: string, now: Date = new Date()): BuiltBlob {
|
|
56
|
+
const machine = resolveMachineLabel();
|
|
57
|
+
const agents: PresenceAgent[] = [];
|
|
58
|
+
const activeDir = join(coordRoot, ".harnery", "active");
|
|
59
|
+
const cutoffMs = now.getTime() - FRESHNESS_SECS * 1000;
|
|
60
|
+
|
|
61
|
+
if (existsSync(activeDir)) {
|
|
62
|
+
for (const f of readdirSync(activeDir)) {
|
|
63
|
+
if (!f.endsWith(".json")) continue;
|
|
64
|
+
let hb: Record<string, unknown>;
|
|
65
|
+
try {
|
|
66
|
+
hb = JSON.parse(readFileSync(join(activeDir, f), "utf8")) as Record<string, unknown>;
|
|
67
|
+
} catch {
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (typeof hb.instance_id !== "string") continue;
|
|
71
|
+
if ((hb.kind as string | undefined) === "transient") continue;
|
|
72
|
+
const ts = Date.parse((hb.last_heartbeat as string | undefined) ?? "");
|
|
73
|
+
if (!Number.isFinite(ts) || ts < cutoffMs) continue;
|
|
74
|
+
agents.push({
|
|
75
|
+
instance_id: hb.instance_id,
|
|
76
|
+
name: strOr(hb.name),
|
|
77
|
+
kind: strOr(hb.kind),
|
|
78
|
+
session_id: strOr(hb.session_id),
|
|
79
|
+
platform: strOr(hb.platform),
|
|
80
|
+
task: strOr(hb.task),
|
|
81
|
+
turn_summary: clamp(strOr(hb.turn_summary), 160),
|
|
82
|
+
files_touched: Array.isArray(hb.files_touched)
|
|
83
|
+
? (hb.files_touched as string[]).slice(0, MAX_FILES_PER_AGENT)
|
|
84
|
+
: undefined,
|
|
85
|
+
last_tool: strOr(hb.last_tool),
|
|
86
|
+
started_at: strOr(hb.started_at),
|
|
87
|
+
last_heartbeat: strOr(hb.last_heartbeat),
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
agents.sort((a, b) => a.instance_id.localeCompare(b.instance_id));
|
|
93
|
+
const capped = agents.slice(0, MAX_AGENTS);
|
|
94
|
+
|
|
95
|
+
const blob: PresenceBlob = {
|
|
96
|
+
v: 1,
|
|
97
|
+
machine,
|
|
98
|
+
published_at: now.toISOString().replace(/\.\d{3}Z$/, "Z"),
|
|
99
|
+
agents: capped,
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
// Basis: the fields whose change should trigger a re-publish. Timestamps
|
|
103
|
+
// and last_tool are excluded (pure churn); task/turn_summary/files are the
|
|
104
|
+
// signal peers actually read.
|
|
105
|
+
const basis = capped.map((a) => ({
|
|
106
|
+
i: a.instance_id,
|
|
107
|
+
n: a.name ?? null,
|
|
108
|
+
k: a.kind ?? null,
|
|
109
|
+
t: a.task ?? null,
|
|
110
|
+
s: a.turn_summary ?? null,
|
|
111
|
+
f: [...(a.files_touched ?? [])].sort(),
|
|
112
|
+
}));
|
|
113
|
+
const basisHash = createHash("sha256").update(JSON.stringify(basis)).digest("hex").slice(0, 16);
|
|
114
|
+
|
|
115
|
+
return { blob, basisHash, json: JSON.stringify(blob) };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function strOr(v: unknown): string | undefined {
|
|
119
|
+
return typeof v === "string" && v.length > 0 ? v : undefined;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function clamp(v: string | undefined, max: number): string | undefined {
|
|
123
|
+
if (!v) return undefined;
|
|
124
|
+
return v.length > max ? v.slice(0, max) : v;
|
|
125
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git plumbing for the cross-machine presence transport (ADR 0016).
|
|
3
|
+
*
|
|
4
|
+
* Presence blobs travel as parentless commits (empty tree, JSON message)
|
|
5
|
+
* force-pushed to `refs/harnery/presence/<machine>` on the host repo's own
|
|
6
|
+
* `origin`. Non-branch refs: invisible to branch tooling, safe to force-push,
|
|
7
|
+
* no history accumulation. The remote is the rendezvous every install already
|
|
8
|
+
* shares, and repo access is the ACL.
|
|
9
|
+
*
|
|
10
|
+
* Everything here is best-effort and fail-silent by design — presence must
|
|
11
|
+
* never break a hook or a render. Network operations (push/fetch) run either
|
|
12
|
+
* detached (hook path: never block a turn) or synchronously (CLI path: the
|
|
13
|
+
* user wants to see errors).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
17
|
+
|
|
18
|
+
/** The ref namespace. Hardcoded — never interpolated from config — so the
|
|
19
|
+
* publisher is structurally unable to touch refs/heads/* (ADR 0016). */
|
|
20
|
+
export const PRESENCE_REF_PREFIX = "refs/harnery/presence/";
|
|
21
|
+
|
|
22
|
+
/** Well-known SHA of git's empty tree; `git mktree` below guarantees the
|
|
23
|
+
* object exists in the local odb before we build a commit on it. */
|
|
24
|
+
const EMPTY_TREE_SHA = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
|
|
25
|
+
|
|
26
|
+
const GIT_TIMEOUT_MS = 8000;
|
|
27
|
+
|
|
28
|
+
/** Non-interactive env for every presence git call: never prompt for creds
|
|
29
|
+
* (a hook must not hang on a password), and a fixed identity so commit-tree
|
|
30
|
+
* works on machines with no user.name/user.email configured. */
|
|
31
|
+
function gitEnv(): NodeJS.ProcessEnv {
|
|
32
|
+
return {
|
|
33
|
+
...process.env,
|
|
34
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
35
|
+
GIT_AUTHOR_NAME: "harnery-presence",
|
|
36
|
+
GIT_AUTHOR_EMAIL: "presence@harnery.invalid",
|
|
37
|
+
GIT_COMMITTER_NAME: "harnery-presence",
|
|
38
|
+
GIT_COMMITTER_EMAIL: "presence@harnery.invalid",
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function runGit(
|
|
43
|
+
root: string,
|
|
44
|
+
args: string[],
|
|
45
|
+
input?: string,
|
|
46
|
+
): { ok: boolean; stdout: string; stderr: string } {
|
|
47
|
+
try {
|
|
48
|
+
const r = spawnSync("git", ["-C", root, ...args], {
|
|
49
|
+
encoding: "utf8",
|
|
50
|
+
timeout: GIT_TIMEOUT_MS,
|
|
51
|
+
env: gitEnv(),
|
|
52
|
+
...(input !== undefined ? { input } : {}),
|
|
53
|
+
});
|
|
54
|
+
return { ok: r.status === 0, stdout: r.stdout ?? "", stderr: r.stderr ?? "" };
|
|
55
|
+
} catch {
|
|
56
|
+
return { ok: false, stdout: "", stderr: "spawn failed" };
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Does the repo have an `origin` remote to rendezvous on? */
|
|
61
|
+
export function hasOrigin(root: string): boolean {
|
|
62
|
+
return runGit(root, ["remote", "get-url", "origin"]).ok;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The origin remote URL, or null. Input to relay room derivation. */
|
|
66
|
+
export function originUrl(root: string): string | null {
|
|
67
|
+
const r = runGit(root, ["remote", "get-url", "origin"]);
|
|
68
|
+
const url = r.stdout.trim();
|
|
69
|
+
return r.ok && url ? url : null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The repo's root-commit SHA — the stable repo-identity input to relay room
|
|
74
|
+
* derivation. Repos can have multiple roots (merged histories); the list is
|
|
75
|
+
* sorted so every clone picks the same one. Null when the repo has no commits.
|
|
76
|
+
*/
|
|
77
|
+
export function rootCommitSha(root: string): string | null {
|
|
78
|
+
const r = runGit(root, ["rev-list", "--max-parents=0", "HEAD"]);
|
|
79
|
+
if (!r.ok) return null;
|
|
80
|
+
const shas = r.stdout
|
|
81
|
+
.split("\n")
|
|
82
|
+
.map((l) => l.trim())
|
|
83
|
+
.filter(Boolean)
|
|
84
|
+
.sort();
|
|
85
|
+
return shas[0] ?? null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Sanitize a machine label into a valid single ref component: lowercase,
|
|
90
|
+
* [a-z0-9._-] only, no leading dot, no `.lock` suffix, never empty.
|
|
91
|
+
*/
|
|
92
|
+
export function sanitizeRefComponent(label: string): string {
|
|
93
|
+
let s = label
|
|
94
|
+
.toLowerCase()
|
|
95
|
+
.replace(/[^a-z0-9._-]+/g, "-")
|
|
96
|
+
.replace(/\.{2,}/g, ".")
|
|
97
|
+
.replace(/^[.-]+/, "")
|
|
98
|
+
.replace(/\.lock$/, "");
|
|
99
|
+
if (!s) s = "unknown";
|
|
100
|
+
return s;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Build the parentless presence commit carrying `message` (the JSON blob).
|
|
104
|
+
* Returns the commit sha, or null on failure. Local-only, fast (~10ms). */
|
|
105
|
+
export function writePresenceCommit(root: string, message: string): string | null {
|
|
106
|
+
// Ensure the empty-tree object exists in this odb (mktree with empty stdin
|
|
107
|
+
// writes it idempotently).
|
|
108
|
+
const mk = runGit(root, ["mktree"], "");
|
|
109
|
+
const tree = mk.ok && mk.stdout.trim() ? mk.stdout.trim() : EMPTY_TREE_SHA;
|
|
110
|
+
const r = runGit(root, ["commit-tree", tree, "-m", message]);
|
|
111
|
+
return r.ok && r.stdout.trim() ? r.stdout.trim() : null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Force-push a presence commit to `refs/harnery/presence/<machine>`.
|
|
116
|
+
* `sync: true` (CLI/tests) waits and reports; default is detached fire-and-
|
|
117
|
+
* forget (hook path — a slow or credential-less push must not block a turn).
|
|
118
|
+
*/
|
|
119
|
+
export function pushPresenceRef(
|
|
120
|
+
root: string,
|
|
121
|
+
machine: string,
|
|
122
|
+
sha: string,
|
|
123
|
+
opts: { sync?: boolean } = {},
|
|
124
|
+
): { ok: boolean; error?: string } {
|
|
125
|
+
const refspec = `+${sha}:${PRESENCE_REF_PREFIX}${sanitizeRefComponent(machine)}`;
|
|
126
|
+
const args = ["-C", root, "push", "--quiet", "--no-verify", "origin", refspec];
|
|
127
|
+
if (opts.sync) {
|
|
128
|
+
const r = runGit(root, args.slice(2));
|
|
129
|
+
return r.ok ? { ok: true } : { ok: false, error: r.stderr.trim().slice(0, 300) };
|
|
130
|
+
}
|
|
131
|
+
try {
|
|
132
|
+
const child = spawn("git", args, { detached: true, stdio: "ignore", env: gitEnv() });
|
|
133
|
+
child.unref();
|
|
134
|
+
return { ok: true };
|
|
135
|
+
} catch {
|
|
136
|
+
return { ok: false, error: "spawn failed" };
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Fetch every peer presence ref from origin into the local ref namespace. */
|
|
141
|
+
export function fetchPresenceRefs(
|
|
142
|
+
root: string,
|
|
143
|
+
opts: { sync?: boolean } = {},
|
|
144
|
+
): { ok: boolean; error?: string } {
|
|
145
|
+
const refspec = `+${PRESENCE_REF_PREFIX}*:${PRESENCE_REF_PREFIX}*`;
|
|
146
|
+
const args = ["-C", root, "fetch", "--quiet", "--no-tags", "origin", refspec];
|
|
147
|
+
if (opts.sync) {
|
|
148
|
+
const r = runGit(root, args.slice(2));
|
|
149
|
+
return r.ok ? { ok: true } : { ok: false, error: r.stderr.trim().slice(0, 300) };
|
|
150
|
+
}
|
|
151
|
+
try {
|
|
152
|
+
const child = spawn("git", args, { detached: true, stdio: "ignore", env: gitEnv() });
|
|
153
|
+
child.unref();
|
|
154
|
+
return { ok: true };
|
|
155
|
+
} catch {
|
|
156
|
+
return { ok: false, error: "spawn failed" };
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Read every locally-known presence ref → [{ref, machine, message}]. */
|
|
161
|
+
export function readPresenceRefs(root: string): Array<{ machine: string; message: string }> {
|
|
162
|
+
const r = runGit(root, [
|
|
163
|
+
"for-each-ref",
|
|
164
|
+
"--format=%(refname)%00%(contents)%01",
|
|
165
|
+
`${PRESENCE_REF_PREFIX}*`,
|
|
166
|
+
]);
|
|
167
|
+
if (!r.ok) return [];
|
|
168
|
+
return parseForEachRefOutput(r.stdout);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Parse `for-each-ref --format='%(refname)%00%(contents)%01'` output.
|
|
173
|
+
* Records are %01-terminated (contents may contain newlines); refname and
|
|
174
|
+
* contents are %00-separated. Exported for unit tests.
|
|
175
|
+
*/
|
|
176
|
+
export function parseForEachRefOutput(out: string): Array<{ machine: string; message: string }> {
|
|
177
|
+
const rows: Array<{ machine: string; message: string }> = [];
|
|
178
|
+
for (const rec of out.split("\u0001")) {
|
|
179
|
+
const trimmed = rec.replace(/^\n+/, "");
|
|
180
|
+
if (!trimmed) continue;
|
|
181
|
+
const sep = trimmed.indexOf("\u0000");
|
|
182
|
+
if (sep === -1) continue;
|
|
183
|
+
const refname = trimmed.slice(0, sep).trim();
|
|
184
|
+
if (!refname.startsWith(PRESENCE_REF_PREFIX)) continue;
|
|
185
|
+
rows.push({
|
|
186
|
+
machine: refname.slice(PRESENCE_REF_PREFIX.length),
|
|
187
|
+
message: trimmed.slice(sep + 1).trim(),
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
return rows;
|
|
191
|
+
}
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-machine presence (ADR 0016), phase 1: the git-refs transport.
|
|
3
|
+
*
|
|
4
|
+
* publishPresence(): serialize this machine's live sessions into a presence
|
|
5
|
+
* blob and force-push it to `refs/harnery/presence/<machine>` on origin —
|
|
6
|
+
* change-batched (only when the blob materially changed) with a keepalive
|
|
7
|
+
* (~5 min) so peers can distinguish "no change" from "machine gone".
|
|
8
|
+
*
|
|
9
|
+
* fetchPresence(): throttled fetch of every peer machine's presence ref.
|
|
10
|
+
*
|
|
11
|
+
* readRemoteMachines(): parse the locally-known refs into render-ready rows
|
|
12
|
+
* (self machine excluded, stale blobs dropped).
|
|
13
|
+
*
|
|
14
|
+
* All entry points are fail-silent and gated on `presenceEnabled()` — presence
|
|
15
|
+
* must never break a hook, a render, or a repo with no origin.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
existsSync,
|
|
20
|
+
mkdirSync,
|
|
21
|
+
readdirSync,
|
|
22
|
+
readFileSync,
|
|
23
|
+
renameSync,
|
|
24
|
+
statSync,
|
|
25
|
+
utimesSync,
|
|
26
|
+
writeFileSync,
|
|
27
|
+
} from "node:fs";
|
|
28
|
+
import { join } from "node:path";
|
|
29
|
+
import { coordEnv } from "../../lib/env.ts";
|
|
30
|
+
import { resolveMachineLabel } from "../../lib/machine.ts";
|
|
31
|
+
import { presenceEnabled } from "../config.ts";
|
|
32
|
+
import { buildPresenceBlob, type PresenceAgent, type PresenceBlob } from "./blob.ts";
|
|
33
|
+
import {
|
|
34
|
+
fetchPresenceRefs,
|
|
35
|
+
hasOrigin,
|
|
36
|
+
pushPresenceRef,
|
|
37
|
+
readPresenceRefs,
|
|
38
|
+
sanitizeRefComponent,
|
|
39
|
+
writePresenceCommit,
|
|
40
|
+
} from "./git.ts";
|
|
41
|
+
|
|
42
|
+
export type { PresenceAgent, PresenceBlob } from "./blob.ts";
|
|
43
|
+
export { buildPresenceBlob } from "./blob.ts";
|
|
44
|
+
export { PRESENCE_REF_PREFIX, parseForEachRefOutput, sanitizeRefComponent } from "./git.ts";
|
|
45
|
+
export { ensureRelayDaemon, relayDaemonStatus, runRelayDaemon } from "./relay-client.ts";
|
|
46
|
+
|
|
47
|
+
/** Re-push at least this often while sessions are live, so peers can treat a
|
|
48
|
+
* blob older than ~3x this as "machine offline" rather than "no change". */
|
|
49
|
+
const KEEPALIVE_SECS = 300;
|
|
50
|
+
/** Never push more often than this, however chatty the triggers. */
|
|
51
|
+
const MIN_PUSH_INTERVAL_SECS = 15;
|
|
52
|
+
/** Default fetch throttle (hook cadence fires far more often than this). */
|
|
53
|
+
const FETCH_INTERVAL_SECS = 60;
|
|
54
|
+
/** A blob whose published_at is older than this is rendered as gone. */
|
|
55
|
+
const REMOTE_STALE_SECS = 900;
|
|
56
|
+
|
|
57
|
+
export type PublishResult =
|
|
58
|
+
| { status: "published"; machine: string; agents: number; sha: string }
|
|
59
|
+
| { status: "skipped"; reason: string }
|
|
60
|
+
| { status: "error"; error: string };
|
|
61
|
+
|
|
62
|
+
export type FetchResult =
|
|
63
|
+
| { status: "fetched" }
|
|
64
|
+
| { status: "skipped"; reason: string }
|
|
65
|
+
| { status: "error"; error: string };
|
|
66
|
+
|
|
67
|
+
interface PublishState {
|
|
68
|
+
basis_hash?: string;
|
|
69
|
+
pushed_at?: string;
|
|
70
|
+
agents?: number;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function presenceDir(coordRoot: string): string {
|
|
74
|
+
return join(coordRoot, ".harnery", "presence");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function statePath(coordRoot: string): string {
|
|
78
|
+
return join(presenceDir(coordRoot), "publish-state.json");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function fetchStampPath(coordRoot: string): string {
|
|
82
|
+
return join(presenceDir(coordRoot), "fetch-stamp");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function readState(coordRoot: string): PublishState {
|
|
86
|
+
try {
|
|
87
|
+
return JSON.parse(readFileSync(statePath(coordRoot), "utf8")) as PublishState;
|
|
88
|
+
} catch {
|
|
89
|
+
return {};
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function writeState(coordRoot: string, state: PublishState): void {
|
|
94
|
+
try {
|
|
95
|
+
const p = statePath(coordRoot);
|
|
96
|
+
mkdirSync(presenceDir(coordRoot), { recursive: true });
|
|
97
|
+
const tmp = `${p}.tmp.${process.pid}`;
|
|
98
|
+
writeFileSync(tmp, JSON.stringify(state), "utf8");
|
|
99
|
+
renameSync(tmp, p);
|
|
100
|
+
} catch {
|
|
101
|
+
/* best-effort */
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Publish this machine's presence blob to origin if it changed (or the
|
|
107
|
+
* keepalive elapsed). `sync: true` pushes in-process and reports errors (CLI
|
|
108
|
+
* path); default spawns the push detached (hook path).
|
|
109
|
+
*/
|
|
110
|
+
export function publishPresence(
|
|
111
|
+
coordRoot: string,
|
|
112
|
+
opts: { force?: boolean; sync?: boolean } = {},
|
|
113
|
+
): PublishResult {
|
|
114
|
+
try {
|
|
115
|
+
if (!presenceEnabled(coordRoot)) return { status: "skipped", reason: "disabled" };
|
|
116
|
+
if (!hasOrigin(coordRoot)) return { status: "skipped", reason: "no origin remote" };
|
|
117
|
+
|
|
118
|
+
const { blob, basisHash, json } = buildPresenceBlob(coordRoot);
|
|
119
|
+
const state = readState(coordRoot);
|
|
120
|
+
const pushedAtMs = Date.parse(state.pushed_at ?? "");
|
|
121
|
+
const sincePush = Number.isFinite(pushedAtMs)
|
|
122
|
+
? (Date.now() - pushedAtMs) / 1000
|
|
123
|
+
: Number.POSITIVE_INFINITY;
|
|
124
|
+
|
|
125
|
+
if (!opts.force) {
|
|
126
|
+
// Nothing live and the last publish already said so → stay quiet.
|
|
127
|
+
if (blob.agents.length === 0 && (state.agents ?? 0) === 0) {
|
|
128
|
+
return { status: "skipped", reason: "no live sessions" };
|
|
129
|
+
}
|
|
130
|
+
const unchanged = state.basis_hash === basisHash;
|
|
131
|
+
if (unchanged && sincePush < KEEPALIVE_SECS) {
|
|
132
|
+
return { status: "skipped", reason: "unchanged (within keepalive)" };
|
|
133
|
+
}
|
|
134
|
+
if (!unchanged && sincePush < MIN_PUSH_INTERVAL_SECS) {
|
|
135
|
+
return { status: "skipped", reason: "min push interval" };
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const sha = writePresenceCommit(coordRoot, json);
|
|
140
|
+
if (!sha) return { status: "error", error: "commit-tree failed" };
|
|
141
|
+
|
|
142
|
+
const push = pushPresenceRef(coordRoot, blob.machine, sha, { sync: opts.sync });
|
|
143
|
+
if (!push.ok) return { status: "error", error: push.error ?? "push failed" };
|
|
144
|
+
|
|
145
|
+
// Optimistic on the detached path: a failed background push self-heals at
|
|
146
|
+
// the next keepalive re-push.
|
|
147
|
+
writeState(coordRoot, {
|
|
148
|
+
basis_hash: basisHash,
|
|
149
|
+
pushed_at: new Date().toISOString(),
|
|
150
|
+
agents: blob.agents.length,
|
|
151
|
+
});
|
|
152
|
+
return { status: "published", machine: blob.machine, agents: blob.agents.length, sha };
|
|
153
|
+
} catch (e) {
|
|
154
|
+
return { status: "error", error: e instanceof Error ? e.message : String(e) };
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Throttled fetch of peer presence refs from origin. */
|
|
159
|
+
export function fetchPresence(
|
|
160
|
+
coordRoot: string,
|
|
161
|
+
opts: { force?: boolean; sync?: boolean } = {},
|
|
162
|
+
): FetchResult {
|
|
163
|
+
try {
|
|
164
|
+
if (!presenceEnabled(coordRoot)) return { status: "skipped", reason: "disabled" };
|
|
165
|
+
if (!hasOrigin(coordRoot)) return { status: "skipped", reason: "no origin remote" };
|
|
166
|
+
|
|
167
|
+
const stamp = fetchStampPath(coordRoot);
|
|
168
|
+
if (!opts.force) {
|
|
169
|
+
const interval = Number.parseInt(
|
|
170
|
+
coordEnv("PRESENCE_FETCH_INTERVAL") ?? String(FETCH_INTERVAL_SECS),
|
|
171
|
+
10,
|
|
172
|
+
);
|
|
173
|
+
try {
|
|
174
|
+
const age = (Date.now() - statSync(stamp).mtimeMs) / 1000;
|
|
175
|
+
if (age < interval) return { status: "skipped", reason: "within fetch interval" };
|
|
176
|
+
} catch {
|
|
177
|
+
/* no stamp yet → fetch */
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Touch the stamp BEFORE spawning so concurrent hooks don't stampede.
|
|
182
|
+
try {
|
|
183
|
+
mkdirSync(presenceDir(coordRoot), { recursive: true });
|
|
184
|
+
if (existsSync(stamp)) {
|
|
185
|
+
const now = new Date();
|
|
186
|
+
utimesSync(stamp, now, now);
|
|
187
|
+
} else {
|
|
188
|
+
writeFileSync(stamp, "", "utf8");
|
|
189
|
+
}
|
|
190
|
+
} catch {
|
|
191
|
+
/* best-effort */
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const r = fetchPresenceRefs(coordRoot, { sync: opts.sync });
|
|
195
|
+
if (!r.ok) return { status: "error", error: r.error ?? "fetch failed" };
|
|
196
|
+
return { status: "fetched" };
|
|
197
|
+
} catch (e) {
|
|
198
|
+
return { status: "error", error: e instanceof Error ? e.message : String(e) };
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export interface RemoteMachine {
|
|
203
|
+
machine: string;
|
|
204
|
+
published_at: string;
|
|
205
|
+
age_secs: number;
|
|
206
|
+
agents: PresenceAgent[];
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Render-ready view of every OTHER machine's presence, merged from BOTH
|
|
211
|
+
* transports: the locally-fetched git refs (the floor) and the relay
|
|
212
|
+
* daemon's cache files at `.harnery/presence/remote/` (the live upgrade).
|
|
213
|
+
* Freshest source per machine wins. Self machine excluded; blobs older than
|
|
214
|
+
* the stale window or with zero agents dropped.
|
|
215
|
+
*/
|
|
216
|
+
export function readRemoteMachines(
|
|
217
|
+
coordRoot: string,
|
|
218
|
+
opts: { includeSelf?: boolean; includeStale?: boolean } = {},
|
|
219
|
+
): RemoteMachine[] {
|
|
220
|
+
try {
|
|
221
|
+
if (!presenceEnabled(coordRoot)) return [];
|
|
222
|
+
const selfMachine = sanitizeRefComponent(resolveMachineLabel());
|
|
223
|
+
|
|
224
|
+
// Collect candidate blobs from both transports, freshest per machine.
|
|
225
|
+
const byMachine = new Map<string, PresenceBlob>();
|
|
226
|
+
const consider = (machine: string, blob: PresenceBlob | null): void => {
|
|
227
|
+
if (blob?.v !== 1 || !Array.isArray(blob.agents)) return;
|
|
228
|
+
const publishedMs = Date.parse(blob.published_at ?? "");
|
|
229
|
+
if (!Number.isFinite(publishedMs)) return;
|
|
230
|
+
const prev = byMachine.get(machine);
|
|
231
|
+
if (prev && Date.parse(prev.published_at) >= publishedMs) return;
|
|
232
|
+
byMachine.set(machine, blob);
|
|
233
|
+
};
|
|
234
|
+
for (const { machine, message } of readPresenceRefs(coordRoot)) {
|
|
235
|
+
try {
|
|
236
|
+
consider(machine, JSON.parse(message) as PresenceBlob);
|
|
237
|
+
} catch {
|
|
238
|
+
/* skip malformed */
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
const relayDir = join(coordRoot, ".harnery", "presence", "remote");
|
|
242
|
+
if (existsSync(relayDir)) {
|
|
243
|
+
for (const f of readdirSync(relayDir)) {
|
|
244
|
+
if (!f.endsWith(".json")) continue;
|
|
245
|
+
try {
|
|
246
|
+
consider(
|
|
247
|
+
f.replace(/\.json$/, ""),
|
|
248
|
+
JSON.parse(readFileSync(join(relayDir, f), "utf8")) as PresenceBlob,
|
|
249
|
+
);
|
|
250
|
+
} catch {
|
|
251
|
+
/* skip malformed */
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const out: RemoteMachine[] = [];
|
|
257
|
+
for (const [machine, blob] of byMachine) {
|
|
258
|
+
if (!opts.includeSelf && machine === selfMachine) continue;
|
|
259
|
+
const ageSecs = Math.max(0, Math.floor((Date.now() - Date.parse(blob.published_at)) / 1000));
|
|
260
|
+
if (!opts.includeStale && ageSecs > REMOTE_STALE_SECS) continue;
|
|
261
|
+
if (blob.agents.length === 0) continue;
|
|
262
|
+
out.push({
|
|
263
|
+
machine,
|
|
264
|
+
published_at: blob.published_at,
|
|
265
|
+
age_secs: ageSecs,
|
|
266
|
+
agents: blob.agents,
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
out.sort((a, b) => a.machine.localeCompare(b.machine));
|
|
270
|
+
return out;
|
|
271
|
+
} catch {
|
|
272
|
+
return [];
|
|
273
|
+
}
|
|
274
|
+
}
|