harnery 0.8.0 → 0.10.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 +2 -0
- package/dist/commander.d.ts.map +1 -1
- package/dist/commander.js +8 -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 +37 -0
- 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/grep.d.ts +35 -2
- package/dist/commands/grep.d.ts.map +1 -1
- package/dist/commands/grep.js +427 -139
- 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/init.d.ts +15 -5
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +125 -14
- 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 +4 -0
- package/dist/commands/workflow.d.ts.map +1 -0
- package/dist/commands/workflow.js +82 -0
- 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 +14 -5
- 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/claim-conflict.d.ts.map +1 -1
- package/dist/core/agents/rules/claim-conflict.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/rules/stop-hook.d.ts +8 -0
- package/dist/core/agents/rules/stop-hook.d.ts.map +1 -1
- package/dist/core/agents/rules/stop-hook.js +8 -0
- 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 +2 -0
- package/dist/core/agents/state/heartbeat-projector.d.ts.map +1 -1
- package/dist/core/agents/state/heartbeat-projector.js +24 -4
- package/dist/core/agents/state/heartbeat-writer.d.ts +16 -2
- package/dist/core/agents/state/heartbeat-writer.d.ts.map +1 -1
- package/dist/core/agents/state/heartbeat-writer.js +24 -7
- 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 +77 -11
- package/dist/core/config.d.ts.map +1 -1
- package/dist/core/config.js +210 -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 +309 -66
- package/dist/core/hooks/events/schema.d.ts +42 -1
- package/dist/core/hooks/events/schema.d.ts.map +1 -1
- package/dist/core/hooks/harness/events.d.ts +7 -0
- package/dist/core/hooks/harness/events.d.ts.map +1 -1
- package/dist/core/hooks/harness/events.js +4 -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/hooks/resolve/coord-root.d.ts +11 -0
- package/dist/core/hooks/resolve/coord-root.d.ts.map +1 -1
- package/dist/core/hooks/resolve/coord-root.js +28 -6
- 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 +48 -0
- package/dist/core/workflow/billing.d.ts.map +1 -0
- package/dist/core/workflow/billing.js +108 -0
- package/dist/core/workflow/child-env.d.ts +30 -0
- package/dist/core/workflow/child-env.d.ts.map +1 -0
- package/dist/core/workflow/child-env.js +43 -0
- package/dist/core/workflow/engine.d.ts +21 -0
- package/dist/core/workflow/engine.d.ts.map +1 -0
- package/dist/core/workflow/engine.js +349 -0
- package/dist/core/workflow/harnesses.d.ts +17 -0
- package/dist/core/workflow/harnesses.d.ts.map +1 -0
- package/dist/core/workflow/harnesses.js +21 -0
- package/dist/core/workflow/spawn-claude.d.ts +25 -0
- package/dist/core/workflow/spawn-claude.d.ts.map +1 -0
- package/dist/core/workflow/spawn-claude.js +103 -0
- package/dist/core/workflow/spawn-codex.d.ts +22 -0
- package/dist/core/workflow/spawn-codex.d.ts.map +1 -0
- package/dist/core/workflow/spawn-codex.js +88 -0
- package/dist/core/workflow/spawn-cursor.d.ts +29 -0
- package/dist/core/workflow/spawn-cursor.d.ts.map +1 -0
- package/dist/core/workflow/spawn-cursor.js +91 -0
- package/dist/core/workflow/types.d.ts +152 -0
- package/dist/core/workflow/types.d.ts.map +1 -0
- package/dist/core/workflow/types.js +9 -0
- package/dist/core/workflow/validate.d.ts +15 -0
- package/dist/core/workflow/validate.d.ts.map +1 -0
- package/dist/core/workflow/validate.js +70 -0
- 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 +12 -1
- package/schemas/config.schema.json +88 -19
- package/src/commander.ts +8 -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 +44 -0
- package/src/commands/eml.ts +22 -4
- package/src/commands/grep.ts +535 -142
- package/src/commands/harness.ts +147 -0
- package/src/commands/init.ts +138 -17
- 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 +123 -0
- package/src/core/agents/canonical-emit.ts +41 -3
- package/src/core/agents/cli.ts +15 -6
- 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/claim-conflict.ts +26 -1
- package/src/core/agents/rules/commit-conflict.ts +4 -33
- package/src/core/agents/rules/stop-hook.ts +17 -0
- package/src/core/agents/session-events.ts +15 -39
- package/src/core/agents/state/heartbeat-projector.ts +23 -2
- package/src/core/agents/state/heartbeat-writer.ts +33 -7
- 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 +271 -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 +361 -65
- package/src/core/hooks/events/schema.ts +72 -0
- package/src/core/hooks/harness/events.ts +11 -0
- package/src/core/hooks/harness/parse.ts +7 -1
- package/src/core/hooks/resolve/coord-root.ts +28 -6
- 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 +152 -0
- package/src/core/workflow/child-env.ts +47 -0
- package/src/core/workflow/engine.ts +403 -0
- package/src/core/workflow/harnesses.ts +33 -0
- package/src/core/workflow/spawn-claude.ts +118 -0
- package/src/core/workflow/spawn-codex.ts +93 -0
- package/src/core/workflow/spawn-cursor.ts +108 -0
- package/src/core/workflow/types.ts +160 -0
- package/src/core/workflow/validate.ts +75 -0
- 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,147 @@
|
|
|
1
|
+
import type { Command } from "commander";
|
|
2
|
+
import type { EmitContext } from "../commander.ts";
|
|
3
|
+
import {
|
|
4
|
+
type BenchResult,
|
|
5
|
+
createBuiltinHarnessRegistry,
|
|
6
|
+
type HarnessBenchReport,
|
|
7
|
+
runHarnessBench,
|
|
8
|
+
} from "../core/harnesses/index.ts";
|
|
9
|
+
import type { HarnessProfile } from "../core/harnesses/types.ts";
|
|
10
|
+
|
|
11
|
+
interface FormatOpts {
|
|
12
|
+
json?: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface BenchOpts extends FormatOpts {
|
|
16
|
+
requireInstalled?: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const registry = createBuiltinHarnessRegistry();
|
|
20
|
+
|
|
21
|
+
export function registerHarnessCommand(program: Command, emit: EmitContext): void {
|
|
22
|
+
const command = program
|
|
23
|
+
.command("harness")
|
|
24
|
+
.description("Inspect registered harness capabilities and run their conformance bench.");
|
|
25
|
+
|
|
26
|
+
command
|
|
27
|
+
.command("list")
|
|
28
|
+
.description("List registered harness adapters and their high-signal capability claims.")
|
|
29
|
+
.option("--json", "Machine-readable profile catalog")
|
|
30
|
+
.action((opts: FormatOpts) => {
|
|
31
|
+
const profiles = registry.list().map((adapter) => adapter.profile);
|
|
32
|
+
if (opts.json) {
|
|
33
|
+
emit.config({ format: "json" });
|
|
34
|
+
emit.data({ harnesses: profiles });
|
|
35
|
+
} else {
|
|
36
|
+
emit.text(renderProfileTable(profiles));
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
command
|
|
41
|
+
.command("show <id>")
|
|
42
|
+
.description("Show one harness's complete capability declaration.")
|
|
43
|
+
.option("--json", "Machine-readable profile")
|
|
44
|
+
.action((id: string, opts: FormatOpts) => {
|
|
45
|
+
const adapter = registry.get(id);
|
|
46
|
+
if (!adapter) {
|
|
47
|
+
emit.error({
|
|
48
|
+
code: "unknown_harness",
|
|
49
|
+
message: `unknown harness ${JSON.stringify(id)} (registered: ${registry.ids().join(", ")})`,
|
|
50
|
+
});
|
|
51
|
+
emit.setExitCode(1);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (opts.json) {
|
|
55
|
+
emit.config({ format: "json" });
|
|
56
|
+
emit.data(adapter.profile);
|
|
57
|
+
} else {
|
|
58
|
+
emit.text(renderProfile(adapter.profile));
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
command
|
|
63
|
+
.command("bench [harnesses...]")
|
|
64
|
+
.description("Run the offline adapter-contract bench (no model calls); drift exits non-zero.")
|
|
65
|
+
.option("--require-installed", "Also fail when a registered vendor CLI is missing")
|
|
66
|
+
.option("--json", "Machine-readable conformance report")
|
|
67
|
+
.action((harnesses: string[], opts: BenchOpts) => {
|
|
68
|
+
try {
|
|
69
|
+
const report = runHarnessBench(registry, { harnesses });
|
|
70
|
+
if (opts.json) {
|
|
71
|
+
emit.config({ format: "json" });
|
|
72
|
+
emit.data(report);
|
|
73
|
+
} else {
|
|
74
|
+
emit.text(renderBenchReport(report));
|
|
75
|
+
}
|
|
76
|
+
emit.setExitCode(report.drift || (opts.requireInstalled && report.skipped) ? 1 : 0);
|
|
77
|
+
} catch (error) {
|
|
78
|
+
emit.error({ code: "harness_bench_failed", message: (error as Error).message });
|
|
79
|
+
emit.setExitCode(1);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function renderProfileTable(profiles: readonly HarnessProfile[]): string {
|
|
85
|
+
const rows = profiles.map((profile) => [
|
|
86
|
+
profile.id,
|
|
87
|
+
profile.binary,
|
|
88
|
+
profile.capabilities.modelSelection.support,
|
|
89
|
+
profile.capabilities.effortSelection.support,
|
|
90
|
+
profile.capabilities.sessionId.support,
|
|
91
|
+
profile.capabilities.cost.support,
|
|
92
|
+
]);
|
|
93
|
+
return renderTable(["HARNESS", "BINARY", "MODEL", "EFFORT", "SESSION", "COST"], rows);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function renderProfile(profile: HarnessProfile): string {
|
|
97
|
+
const lines = [
|
|
98
|
+
`${profile.displayName} (${profile.id})`,
|
|
99
|
+
`binary: ${profile.binary}`,
|
|
100
|
+
`integration: ${profile.integrationMode}`,
|
|
101
|
+
`auth: ${profile.authModel}`,
|
|
102
|
+
`model family: ${profile.modelFamily}`,
|
|
103
|
+
`effort values: ${profile.effortValues.join(", ") || "none"}`,
|
|
104
|
+
profile.verified
|
|
105
|
+
? `verified: ${profile.verified.date} (${profile.verified.version})`
|
|
106
|
+
: "verified: not recorded",
|
|
107
|
+
"",
|
|
108
|
+
"capabilities:",
|
|
109
|
+
];
|
|
110
|
+
for (const [dimension, claim] of Object.entries(profile.capabilities)) {
|
|
111
|
+
lines.push(` ${dimension.padEnd(18)} ${claim.support}${claim.note ? ` — ${claim.note}` : ""}`);
|
|
112
|
+
}
|
|
113
|
+
return lines.join("\n");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function renderBenchReport(report: HarnessBenchReport): string {
|
|
117
|
+
const rows = report.results.map((result) => [
|
|
118
|
+
result.harness,
|
|
119
|
+
result.dimension,
|
|
120
|
+
result.declared,
|
|
121
|
+
result.observed,
|
|
122
|
+
result.verdict,
|
|
123
|
+
]);
|
|
124
|
+
const table = renderTable(["HARNESS", "DIMENSION", "DECLARED", "OBSERVED", "VERDICT"], rows);
|
|
125
|
+
const summary = Object.entries(report.summary)
|
|
126
|
+
.filter(([, count]) => count > 0)
|
|
127
|
+
.map(([verdict, count]) => `${verdict}=${count}`)
|
|
128
|
+
.join(", ");
|
|
129
|
+
return `${table}\n\nmode: ${report.mode} (no model calls)\n${summary}`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function renderTable(headers: string[], rows: string[][]): string {
|
|
133
|
+
const widths = headers.map((header, index) =>
|
|
134
|
+
Math.max(header.length, ...rows.map((row) => row[index]?.length ?? 0)),
|
|
135
|
+
);
|
|
136
|
+
const line = (cells: string[]) =>
|
|
137
|
+
cells
|
|
138
|
+
.map((cell, index) => cell.padEnd(widths[index]))
|
|
139
|
+
.join(" ")
|
|
140
|
+
.trimEnd();
|
|
141
|
+
return [line(headers), line(widths.map((width) => "-".repeat(width))), ...rows.map(line)].join(
|
|
142
|
+
"\n",
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Kept exported so command tests can assert row semantics without model calls. */
|
|
147
|
+
export type { BenchResult };
|
package/src/commands/init.ts
CHANGED
|
@@ -120,6 +120,17 @@ export function registerInitCommand(program: Command, emit: EmitContext, binName
|
|
|
120
120
|
if (stamp) actions.push(stamp);
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
+
// ── 1c. workflow billing default ───────────────────────────────────────
|
|
124
|
+
// Every init'd project gets `workflow.subscriptionOnly: true`: workflow
|
|
125
|
+
// children ride the logged-in (subscription) harness auth, and the pin
|
|
126
|
+
// makes per-token API billing structurally impossible unless the project
|
|
127
|
+
// deliberately flips it. A committed `workflow` key of any shape is a
|
|
128
|
+
// deliberate choice and is never touched.
|
|
129
|
+
{
|
|
130
|
+
const stamp = stampWorkflowDefaults(resolve(coordDir, "config.jsonc"), dryRun);
|
|
131
|
+
if (stamp) actions.push(stamp);
|
|
132
|
+
}
|
|
133
|
+
|
|
123
134
|
// ── 2. harness hooks ───────────────────────────────────────────────────
|
|
124
135
|
const settingsPath = resolve(projectRoot, spec.settingsFile);
|
|
125
136
|
const agentHook = relative(projectRoot, resolve(HARNERY_ROOT, "bin", "agent-hook"));
|
|
@@ -139,23 +150,23 @@ export function registerInitCommand(program: Command, emit: EmitContext, binName
|
|
|
139
150
|
} else {
|
|
140
151
|
settings = {};
|
|
141
152
|
}
|
|
142
|
-
const { wired, already, removed } = wireHooks(settings, spec, agentHook, harness);
|
|
153
|
+
const { wired, already, removed, upgraded } = wireHooks(settings, spec, agentHook, harness);
|
|
143
154
|
|
|
144
|
-
if (wired === 0 && removed === 0) {
|
|
155
|
+
if (wired === 0 && removed === 0 && upgraded === 0) {
|
|
145
156
|
actions.push(
|
|
146
157
|
`· all ${spec.events.length} ${harness} hooks already wired in ${rel(projectRoot, settingsPath)}`,
|
|
147
158
|
);
|
|
148
159
|
} else if (dryRun) {
|
|
149
160
|
actions.push(
|
|
150
|
-
`+ would wire ${wired} hook(s)
|
|
151
|
-
`${rel(projectRoot, settingsPath)} (${already} already present)`,
|
|
161
|
+
`+ would wire ${wired} hook(s), upgrade ${upgraded} stale command(s), and remove ` +
|
|
162
|
+
`${removed} legacy hook(s) in ${rel(projectRoot, settingsPath)} (${already} already present)`,
|
|
152
163
|
);
|
|
153
164
|
} else {
|
|
154
165
|
mkdirSync(dirname(settingsPath), { recursive: true });
|
|
155
166
|
writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`);
|
|
156
167
|
actions.push(
|
|
157
|
-
`+ wired ${wired} hook(s)
|
|
158
|
-
`${rel(projectRoot, settingsPath)} (${already} already present)`,
|
|
168
|
+
`+ wired ${wired} hook(s), upgraded ${upgraded} stale command(s), and removed ` +
|
|
169
|
+
`${removed} legacy hook(s) in ${rel(projectRoot, settingsPath)} (${already} already present)`,
|
|
159
170
|
);
|
|
160
171
|
}
|
|
161
172
|
|
|
@@ -169,11 +180,12 @@ export function registerInitCommand(program: Command, emit: EmitContext, binName
|
|
|
169
180
|
|
|
170
181
|
/**
|
|
171
182
|
* Merge agent-hook entries into a harness settings object in place, idempotently.
|
|
172
|
-
* Preserves every existing hook;
|
|
173
|
-
* `agent-hook <subcommand
|
|
174
|
-
*
|
|
175
|
-
*
|
|
176
|
-
*
|
|
183
|
+
* Preserves every existing hook; rewrites events already wired to
|
|
184
|
+
* `agent-hook <subcommand>` when their command string is stale (e.g. an older
|
|
185
|
+
* harnery wired a bare relative path). Honors the harness's entry shape
|
|
186
|
+
* (Claude/Codex nest under an inner `hooks` array; Cursor uses a flat
|
|
187
|
+
* `{ command }`) and ensures the root `version` key when the harness requires
|
|
188
|
+
* one. Pure (no fs/git) so it's unit-testable.
|
|
177
189
|
*
|
|
178
190
|
* The trailing space in the match (`agent-hook ${subcommand} `) is load-bearing:
|
|
179
191
|
* it keeps `stop` from matching `stop-failure`.
|
|
@@ -183,7 +195,7 @@ export function wireHooks(
|
|
|
183
195
|
spec: HarnessSpec,
|
|
184
196
|
agentHookPath: string,
|
|
185
197
|
harness: HarnessId,
|
|
186
|
-
): { wired: number; already: number; removed: number } {
|
|
198
|
+
): { wired: number; already: number; removed: number; upgraded: number } {
|
|
187
199
|
if (spec.rootVersion !== undefined && settings.version === undefined) {
|
|
188
200
|
settings.version = spec.rootVersion;
|
|
189
201
|
}
|
|
@@ -191,6 +203,7 @@ export function wireHooks(
|
|
|
191
203
|
let wired = 0;
|
|
192
204
|
let already = 0;
|
|
193
205
|
let removed = 0;
|
|
206
|
+
let upgraded = 0;
|
|
194
207
|
for (const { settingsKey, subcommand } of spec.legacyEvents ?? []) {
|
|
195
208
|
const groups = settings.hooks[settingsKey] ?? [];
|
|
196
209
|
const kept = groups.filter(
|
|
@@ -202,11 +215,14 @@ export function wireHooks(
|
|
|
202
215
|
else settings.hooks[settingsKey] = kept;
|
|
203
216
|
}
|
|
204
217
|
for (const { settingsKey, subcommand } of spec.events) {
|
|
205
|
-
const command =
|
|
218
|
+
const command = hookCommand(spec, agentHookPath, subcommand, harness);
|
|
206
219
|
const groups = settings.hooks[settingsKey] ?? [];
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
220
|
+
let present = false;
|
|
221
|
+
for (const group of groups) {
|
|
222
|
+
upgraded += rewriteStaleCommands(group, subcommand, command, () => {
|
|
223
|
+
present = true;
|
|
224
|
+
});
|
|
225
|
+
}
|
|
210
226
|
if (present) {
|
|
211
227
|
already++;
|
|
212
228
|
continue;
|
|
@@ -215,7 +231,62 @@ export function wireHooks(
|
|
|
215
231
|
settings.hooks[settingsKey] = groups;
|
|
216
232
|
wired++;
|
|
217
233
|
}
|
|
218
|
-
return { wired, already, removed };
|
|
234
|
+
return { wired, already, removed, upgraded };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* The canonical hook command for one event. When the harness exports a
|
|
239
|
+
* project-dir env var to hook processes (Claude Code's CLAUDE_PROJECT_DIR),
|
|
240
|
+
* anchor the agent-hook path on it: hook processes inherit the session
|
|
241
|
+
* shell's cwd, which follows `cd` away from the project root — a bare
|
|
242
|
+
* relative path silently fails to spawn from there (no events, no image
|
|
243
|
+
* capture, no guards). `:-.` keeps the command working on harness versions
|
|
244
|
+
* that don't set the var. Only the env expansion is quoted so the
|
|
245
|
+
* `agent-hook <subcommand> ` wiring match stays byte-identical.
|
|
246
|
+
*/
|
|
247
|
+
function hookCommand(
|
|
248
|
+
spec: HarnessSpec,
|
|
249
|
+
agentHookPath: string,
|
|
250
|
+
subcommand: string,
|
|
251
|
+
harness: HarnessId,
|
|
252
|
+
): string {
|
|
253
|
+
const anchor = spec.projectDirEnv ? `"\${${spec.projectDirEnv}:-.}"/` : "";
|
|
254
|
+
return `bash ${anchor}${agentHookPath} ${subcommand} --harness ${harness}`;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Rewrite any harnery-owned command for `subcommand` inside one hook group to
|
|
259
|
+
* the canonical form, in place. Returns the number of commands rewritten and
|
|
260
|
+
* calls `onPresent` when the subcommand is wired in this group at all.
|
|
261
|
+
*/
|
|
262
|
+
function rewriteStaleCommands(
|
|
263
|
+
group: HookGroup,
|
|
264
|
+
subcommand: string,
|
|
265
|
+
canonical: string,
|
|
266
|
+
onPresent: () => void,
|
|
267
|
+
): number {
|
|
268
|
+
let rewritten = 0;
|
|
269
|
+
if ("command" in group && typeof group.command === "string") {
|
|
270
|
+
if (commandWiresSubcommand(group.command, subcommand)) {
|
|
271
|
+
onPresent();
|
|
272
|
+
if (group.command !== canonical) {
|
|
273
|
+
group.command = canonical;
|
|
274
|
+
rewritten++;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
if ("hooks" in group && Array.isArray(group.hooks)) {
|
|
279
|
+
for (const hook of group.hooks) {
|
|
280
|
+
if (typeof hook.command !== "string") continue;
|
|
281
|
+
if (!commandWiresSubcommand(hook.command, subcommand)) continue;
|
|
282
|
+
onPresent();
|
|
283
|
+
if (hook.command !== canonical) {
|
|
284
|
+
hook.command = canonical;
|
|
285
|
+
rewritten++;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return rewritten;
|
|
219
290
|
}
|
|
220
291
|
|
|
221
292
|
/**
|
|
@@ -311,6 +382,56 @@ export function stampBinName(configPath: string, binName: string, dryRun: boolea
|
|
|
311
382
|
return `+ added binName "${binName}" to ${rel(configPath)}`;
|
|
312
383
|
}
|
|
313
384
|
|
|
385
|
+
/** The commented workflow-billing default block init stamps into config.jsonc. */
|
|
386
|
+
const WORKFLOW_DEFAULT_BLOCK =
|
|
387
|
+
` // Workflow children ride the logged-in (subscription) harness auth; this pin\n` +
|
|
388
|
+
` // scrubs API-key vars from child envs so runs can never bill per-token rates.\n` +
|
|
389
|
+
` // Key-only hosts (CI): set false, or HARNERY_WORKFLOW_SUBSCRIPTION_ONLY=0.\n` +
|
|
390
|
+
` "workflow": { "subscriptionOnly": true }`;
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Idempotently pin `workflow.subscriptionOnly: true` in `.harnery/config.jsonc`
|
|
394
|
+
* (see `workflowSubscriptionOnly()` in core/config.ts and the `workflow run`
|
|
395
|
+
* billing safeguards). Same comment-preserving discipline as `stampBinName`.
|
|
396
|
+
* A `workflow` key of ANY shape (including `subscriptionOnly: false`) is a
|
|
397
|
+
* deliberate project choice and is left alone.
|
|
398
|
+
*/
|
|
399
|
+
export function stampWorkflowDefaults(configPath: string, dryRun: boolean): string | null {
|
|
400
|
+
const rel = (p: string) => relative(dirname(dirname(configPath)), p) || p;
|
|
401
|
+
|
|
402
|
+
if (!existsSync(configPath)) {
|
|
403
|
+
if (dryRun) return `+ would pin workflow.subscriptionOnly in ${rel(configPath)}`;
|
|
404
|
+
writeFileSync(configPath, `{\n${WORKFLOW_DEFAULT_BLOCK}\n}\n`);
|
|
405
|
+
return `+ pinned workflow.subscriptionOnly: true in ${rel(configPath)}`;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
let raw: string;
|
|
409
|
+
try {
|
|
410
|
+
raw = readFileSync(configPath, "utf8");
|
|
411
|
+
} catch {
|
|
412
|
+
return null;
|
|
413
|
+
}
|
|
414
|
+
let parsed: { workflow?: unknown } & Record<string, unknown>;
|
|
415
|
+
try {
|
|
416
|
+
parsed = (JSON.parse(stripJsonComments(raw)) as typeof parsed) ?? {};
|
|
417
|
+
} catch {
|
|
418
|
+
return `· ${rel(configPath)} isn't valid JSONC; skipped workflow.subscriptionOnly pin`;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
if ("workflow" in parsed) return null; // deliberate config; never touch
|
|
422
|
+
|
|
423
|
+
const at = firstBraceIndex(raw);
|
|
424
|
+
if (at < 0) return `· ${rel(configPath)} has no object literal; skipped workflow pin`;
|
|
425
|
+
const keys = Object.keys(parsed);
|
|
426
|
+
const next =
|
|
427
|
+
keys.length === 0
|
|
428
|
+
? `{\n${WORKFLOW_DEFAULT_BLOCK}\n}\n`
|
|
429
|
+
: `${raw.slice(0, at + 1)}\n${WORKFLOW_DEFAULT_BLOCK},${raw.slice(at + 1)}`;
|
|
430
|
+
if (dryRun) return `+ would pin workflow.subscriptionOnly in ${rel(configPath)}`;
|
|
431
|
+
writeFileSync(configPath, next);
|
|
432
|
+
return `+ pinned workflow.subscriptionOnly: true in ${rel(configPath)}`;
|
|
433
|
+
}
|
|
434
|
+
|
|
314
435
|
/** Index of the first structural `{`, skipping leading whitespace + comments. */
|
|
315
436
|
function firstBraceIndex(raw: string): number {
|
|
316
437
|
let i = 0;
|
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
|
+
}
|