@halofy/agent-connect 0.13.1 → 0.14.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 +39 -1
- package/bin/halofy-agent.mjs +3 -1
- package/package.json +1 -1
- package/src/budgets.mjs +112 -0
- package/src/claude-hook.mjs +16 -2
- package/src/context-sync.mjs +11 -4
- package/src/delivery-sync.mjs +4 -2
- package/src/host-hook.mjs +4 -0
- package/src/install.mjs +4 -0
- package/src/installer-cli.mjs +4 -0
- package/src/instructions.mjs +10 -4
- package/src/transport.mjs +5 -2
- package/src/version.mjs +3 -3
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Halofy agent lifecycle installer
|
|
2
2
|
|
|
3
|
-
Status: source for `@halofy/agent-connect@0.
|
|
3
|
+
Status: source for `@halofy/agent-connect@0.14.0`; publication and deployment
|
|
4
4
|
require separate verification. The production console supplies the command for
|
|
5
5
|
the verified published package pinned by the deployment workflow. Host loading
|
|
6
6
|
requires separate verification.
|
|
@@ -24,6 +24,31 @@ conversations and serves the agent-invoked MCP memory tools, but does not push
|
|
|
24
24
|
recalled memory into host sessions on session start or prompt submit. The
|
|
25
25
|
bounded recall block formats stay in place and tested for when it returns.
|
|
26
26
|
|
|
27
|
+
## AI budget delivery and controlled submissions (0.14.0 source)
|
|
28
|
+
|
|
29
|
+
Installation, session-start and explicit sync refresh the installation's own
|
|
30
|
+
structured budget state through signed `/v1/agent-runtime/budget/check`.
|
|
31
|
+
Claude `UserPromptSubmit` checks again before each submitted turn, even with
|
|
32
|
+
capture paused, and returns a mechanical blocking decision when a turn-limited
|
|
33
|
+
budget is exhausted or usage cannot be verified. Cached balances never grant
|
|
34
|
+
work. The check has a three-second network deadline; failures cannot silently
|
|
35
|
+
disable a previously enforced budget. An older kernel without this endpoint is
|
|
36
|
+
explicitly monitoring only until upgraded.
|
|
37
|
+
|
|
38
|
+
This requires trusted execution of the installed hook. It limits new submitted
|
|
39
|
+
turns based on observed API-equivalent estimates; it cannot stop every internal
|
|
40
|
+
model call or subagent, bound subscription invoices, or control a user who
|
|
41
|
+
removes the hook. Kimi, Codex, Cursor, Gemini, Cline, VS Code and Hermes remain
|
|
42
|
+
monitoring through this package until their blocking boundaries are separately
|
|
43
|
+
verified. The account/team budget applies across all captured models; no model
|
|
44
|
+
choice is inferred from an agent label. See the
|
|
45
|
+
[Claude hook decision contract](https://code.claude.com/docs/en/hooks#userpromptsubmit-decision-control).
|
|
46
|
+
|
|
47
|
+
The local `budget-<installation-id>.json` file is diagnostic configuration,
|
|
48
|
+
not financial authority. `sync` and installer results report budget status
|
|
49
|
+
separately from instruction/skill delivery. Publication, server deployment and
|
|
50
|
+
real-host enforcement still require release verification.
|
|
51
|
+
|
|
27
52
|
## Headless host limits and explicit refresh
|
|
28
53
|
|
|
29
54
|
Codex `exec` lifecycle hooks are not verified. A local Codex CLI 0.154.0
|
|
@@ -311,6 +336,19 @@ updated disposable installation. Existing local runtimes do not auto-upgrade.
|
|
|
311
336
|
|
|
312
337
|
## Organization instructions (0.10.0)
|
|
313
338
|
|
|
339
|
+
Version 0.13.2 includes an MCP policy validator extension.
|
|
340
|
+
It accepts one final generated `Required MCP access policy` document in the
|
|
341
|
+
connection's exact namespace, alongside ordinary authored instructions. That
|
|
342
|
+
document may exceed 16 KiB; the complete instruction bundle still cannot exceed
|
|
343
|
+
64 KiB. Ordinary documents keep their 16 KiB limit and unique, ordered ancestor
|
|
344
|
+
scopes. Content hashes, bundle digest, UTF-8 and managed-marker checks still apply.
|
|
345
|
+
Existing installed `0.13.1` runtimes without this extension reject same-scope
|
|
346
|
+
authored/generated pairs and generated policies above 16 KiB. They need an
|
|
347
|
+
upgraded runtime. The exact 0.13.2 publication and artifact verification are
|
|
348
|
+
recorded in [release evidence](../../../docs/operations/workspace-onboarding-rollout.md).
|
|
349
|
+
Publication does not upgrade existing installations. Invalid bundles leave
|
|
350
|
+
existing local files untouched.
|
|
351
|
+
|
|
314
352
|
This source adds Govern instruction delivery alongside the existing skill,
|
|
315
353
|
policy, knowledge and delivery-receipt paths. Activation requires the reviewed
|
|
316
354
|
0.10.0 npm publication, matching server deployment and a fresh confirmed
|
package/bin/halofy-agent.mjs
CHANGED
|
@@ -10,6 +10,7 @@ import { HERMES_HOOK_EVENTS, runHermesLifecycleHook } from "../src/hermes-hook.m
|
|
|
10
10
|
import { LifecycleRuntime } from "../src/runtime.mjs";
|
|
11
11
|
import { inspectCaptureHealth, setCapturePaused } from "../src/health.mjs";
|
|
12
12
|
import { refreshManagedConfiguration } from "../src/delivery-sync.mjs";
|
|
13
|
+
import { budgetBlockReason } from "../src/budgets.mjs";
|
|
13
14
|
import { RUNTIME_VERSION } from "../src/version.mjs";
|
|
14
15
|
import { join, resolve } from "node:path";
|
|
15
16
|
import { realpath } from "node:fs/promises";
|
|
@@ -134,9 +135,10 @@ if (!["mcp", "hermes-mcp", "diagnostics", "hook", "hermes-hook", "pause", "resum
|
|
|
134
135
|
}
|
|
135
136
|
} catch (error) {
|
|
136
137
|
if (hookEvent === "PreToolUse") process.stdout.write(`${JSON.stringify(skillInvocationDenial())}\n`);
|
|
138
|
+
if (hookEvent === "UserPromptSubmit") process.stdout.write(`${JSON.stringify({ decision: "block", reason: budgetBlockReason("usage_unavailable") })}\n`);
|
|
137
139
|
process.stderr.write(`${command === "diagnostics" ? "Halofy diagnostics unavailable" :
|
|
138
140
|
error?.message || "Halofy MCP proxy unavailable"}\n`);
|
|
139
|
-
process.exitCode =
|
|
141
|
+
process.exitCode = ["PreToolUse", "UserPromptSubmit"].includes(hookEvent) ? 0 : 1;
|
|
140
142
|
}
|
|
141
143
|
}
|
|
142
144
|
|
package/package.json
CHANGED
package/src/budgets.mjs
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { lstat } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { readPrivateJson, withContextLock } from "./context-sync.mjs";
|
|
4
|
+
import { safeDirectory, writePrivateFile } from "./storage.mjs";
|
|
5
|
+
|
|
6
|
+
const MODES = new Set(["monitoring", "turn_limited"]);
|
|
7
|
+
const REASONS = new Set(["ok", "budget_exhausted", "usage_unavailable"]);
|
|
8
|
+
const BASES = new Set(["catalog_estimate", "unavailable", "not_observed"]);
|
|
9
|
+
const amount = (value) => value === null || (typeof value === "number" && Number.isFinite(value) && value >= 0);
|
|
10
|
+
|
|
11
|
+
/** No cached balance grants work. The stored response is diagnostic state only. */
|
|
12
|
+
export function validateBudgetConfiguration(value) {
|
|
13
|
+
if (!value || value.schemaVersion !== 1 || typeof value.version !== "string" || value.version.length > 256
|
|
14
|
+
|| !MODES.has(value.mode) || !MODES.has(value.capability) || !REASONS.has(value.reason)
|
|
15
|
+
|| !BASES.has(value.usageBasis) || typeof value.configured !== "boolean" || typeof value.allowed !== "boolean"
|
|
16
|
+
|| typeof value.warning !== "boolean" || value.warningPercent !== 80
|
|
17
|
+
|| ![value.periodStart, value.periodEnd, value.asOf].every((date) => typeof date === "string" && Number.isFinite(Date.parse(date)))
|
|
18
|
+
|| Date.parse(value.periodStart) >= Date.parse(value.periodEnd)
|
|
19
|
+
|| !Array.isArray(value.policies) || value.policies.length > 2
|
|
20
|
+
|| !Array.isArray(value.balances) || value.balances.length > 4
|
|
21
|
+
|| value.balances.some((balance) => !balance || !["team", "account", "agent", "shared"].includes(balance.scope)
|
|
22
|
+
|| !amount(balance.limitUsd) || !amount(balance.spentUsd) || !amount(balance.reservedUsd)
|
|
23
|
+
|| balance.reservedUsd === null || !amount(balance.availableUsd))
|
|
24
|
+
|| (value.mode === "turn_limited" && value.allowed && value.reason !== "ok")) {
|
|
25
|
+
throw new Error("invalid_budget_configuration");
|
|
26
|
+
}
|
|
27
|
+
return value;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function budgetBlockReason(reason) {
|
|
31
|
+
return reason === "budget_exhausted"
|
|
32
|
+
? "Your Halofy AI budget has been reached. Ask your manager to increase the budget before starting another turn."
|
|
33
|
+
: "Halofy could not verify your current AI budget. Retry when the workspace is available; this turn has not started.";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function readBudgetState(path) {
|
|
37
|
+
const saved = await readPrivateJson(path);
|
|
38
|
+
if (saved === null) {
|
|
39
|
+
// readPrivateJson uses null for an absent file. A present JSON null is
|
|
40
|
+
// damaged evidence, not an installation that has never seen enforcement.
|
|
41
|
+
try { await lstat(path); }
|
|
42
|
+
catch (error) { if (error?.code === "ENOENT") return null; throw error; }
|
|
43
|
+
throw new Error("invalid_budget_state");
|
|
44
|
+
}
|
|
45
|
+
if (!saved || !MODES.has(saved.capability) || typeof saved.checkedAt !== "string"
|
|
46
|
+
|| !Number.isFinite(Date.parse(saved.checkedAt))) throw new Error("invalid_budget_state");
|
|
47
|
+
validateBudgetConfiguration(saved.configuration);
|
|
48
|
+
return saved;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function assertFreshBudget(configuration) {
|
|
52
|
+
const now = Date.now();
|
|
53
|
+
if (Date.parse(configuration.asOf) < now - 120_000 || Date.parse(configuration.asOf) > now + 30_000
|
|
54
|
+
|| Date.parse(configuration.periodStart) > now || Date.parse(configuration.periodEnd) <= now) {
|
|
55
|
+
throw new Error("stale_budget_configuration");
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function refreshBudgetConfiguration({ connection, transport, root, stderr = process.stderr }) {
|
|
60
|
+
const capable = connection.clientKind === "claude-code";
|
|
61
|
+
const unavailable = (legacy = false) => {
|
|
62
|
+
const denied = capable && !legacy;
|
|
63
|
+
stderr.write(`[halofy] AI budget ${legacy ? "monitoring: server upgrade required" : "unavailable"}; ${denied ? "new turn blocked" : "monitoring only"}\n`);
|
|
64
|
+
return { status: legacy ? "unsupported" : "unavailable", capability: capable && !legacy ? "turn_limited" : "monitoring",
|
|
65
|
+
denied, reason: "usage_unavailable" };
|
|
66
|
+
};
|
|
67
|
+
try {
|
|
68
|
+
if (typeof connection.installationId !== "string" || !/^[A-Za-z0-9_-]{1,160}$/.test(connection.installationId)) throw new Error("invalid_installation");
|
|
69
|
+
await safeDirectory(root, true);
|
|
70
|
+
const path = join(root, `budget-${connection.installationId}.json`);
|
|
71
|
+
const lockRoot = join(root, `budget-${connection.installationId}`);
|
|
72
|
+
await safeDirectory(lockRoot, true);
|
|
73
|
+
// Hooks may run in separate processes. Do not hold the state lock while
|
|
74
|
+
// waiting for HTTP, but decide against the latest state after it settles.
|
|
75
|
+
let response, failure;
|
|
76
|
+
try {
|
|
77
|
+
if (typeof transport?.budgetCheck !== "function") throw new Error("budget_transport_unavailable");
|
|
78
|
+
response = await transport.budgetCheck();
|
|
79
|
+
} catch (error) { failure = error; }
|
|
80
|
+
return await withContextLock(lockRoot, async () => {
|
|
81
|
+
const previous = await readBudgetState(path);
|
|
82
|
+
if (failure) {
|
|
83
|
+
// Legacy fallback is allowed only after checking the current record
|
|
84
|
+
// under the same lock used by all successful observation writers.
|
|
85
|
+
return unavailable(failure?.status === 404 && previous?.configuration.mode !== "turn_limited");
|
|
86
|
+
}
|
|
87
|
+
const configuration = validateBudgetConfiguration(response);
|
|
88
|
+
assertFreshBudget(configuration);
|
|
89
|
+
if (previous) {
|
|
90
|
+
const observedAt = Date.parse(configuration.asOf);
|
|
91
|
+
const previousAt = Date.parse(previous.configuration.asOf);
|
|
92
|
+
if (observedAt < previousAt || (observedAt === previousAt && previous.configuration.mode === "turn_limited"
|
|
93
|
+
&& (configuration.mode !== "turn_limited" || ((!previous.configuration.allowed
|
|
94
|
+
|| previous.configuration.capability !== "turn_limited") && configuration.allowed)))) {
|
|
95
|
+
throw new Error("outdated_budget_configuration");
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
const capability = capable && configuration.capability === "turn_limited" ? "turn_limited" : "monitoring";
|
|
99
|
+
// Persist a valid requirement even if this host cannot meet its advertised
|
|
100
|
+
// capability. A later 404 must not erase this first enforced observation.
|
|
101
|
+
await writePrivateFile(path, JSON.stringify({ configuration, capability, checkedAt: new Date().toISOString() }));
|
|
102
|
+
assertFreshBudget(configuration);
|
|
103
|
+
if (capable && configuration.mode === "turn_limited" && capability !== "turn_limited") return unavailable();
|
|
104
|
+
const denied = capability === "turn_limited" && configuration.mode === "turn_limited" && !configuration.allowed;
|
|
105
|
+
return { status: "ready", capability, denied, reason: configuration.reason, configuration };
|
|
106
|
+
}, { timeoutMs: 1_000 });
|
|
107
|
+
} catch {
|
|
108
|
+
// Unsafe, malformed, stale or unavailable evidence never enables Claude.
|
|
109
|
+
// Unsupported host hook protocols remain explicitly monitoring.
|
|
110
|
+
return unavailable();
|
|
111
|
+
}
|
|
112
|
+
}
|
package/src/claude-hook.mjs
CHANGED
|
@@ -6,6 +6,7 @@ import { LifecycleRuntime } from "./runtime.mjs";
|
|
|
6
6
|
import { normalizeClaudeHookEvent, RECALL_INJECTION_ENABLED, rankedRecallBlocks } from "./session.mjs";
|
|
7
7
|
import { defaultRuntimeDirectory } from "./storage.mjs";
|
|
8
8
|
import { syncManagedDelivery } from "./delivery-sync.mjs";
|
|
9
|
+
import { budgetBlockReason, refreshBudgetConfiguration } from "./budgets.mjs";
|
|
9
10
|
|
|
10
11
|
/** Shared delivery refresh remains independent of capture/heartbeat brownouts. */
|
|
11
12
|
export async function syncSkillsAtSessionStart(runtime, connection, root, stderr, syncOptions = {}) {
|
|
@@ -19,6 +20,7 @@ export async function syncConfigurationAtSessionStart(runtime, connection, root,
|
|
|
19
20
|
await Promise.allSettled([
|
|
20
21
|
Promise.resolve().then(() => runtime.syncInstructions?.()),
|
|
21
22
|
syncSkillsAtSessionStart(runtime, connection, root, stderr, syncOptions),
|
|
23
|
+
refreshBudgetConfiguration({ connection, transport: runtime.transport, root, stderr }),
|
|
22
24
|
]);
|
|
23
25
|
}
|
|
24
26
|
|
|
@@ -98,6 +100,7 @@ export async function runClaudeLifecycleHook(connection, eventName, {
|
|
|
98
100
|
env,
|
|
99
101
|
runtimeFactory = (activeConnection, options) => new LifecycleRuntime(activeConnection, options),
|
|
100
102
|
} = {}) {
|
|
103
|
+
let budgetVerified = false;
|
|
101
104
|
try {
|
|
102
105
|
const hookInput = input ?? await readHookInput();
|
|
103
106
|
if (eventName === "PreToolUse") {
|
|
@@ -105,10 +108,20 @@ export async function runClaudeLifecycleHook(connection, eventName, {
|
|
|
105
108
|
if (decision) stdout.write(JSON.stringify(decision));
|
|
106
109
|
return { handled: true, denied: Boolean(decision) };
|
|
107
110
|
}
|
|
108
|
-
const session = hostSession(hookInput);
|
|
109
|
-
if (!session) return { handled: true };
|
|
110
111
|
const capture = await captureStateSnapshot(root, connection.installationId);
|
|
111
112
|
const runtime = runtimeFactory(connection, { root, captureGeneration: capture.generation });
|
|
113
|
+
// This is a host decision, not an instruction asking the model to stop.
|
|
114
|
+
// Check before session/capture gates, including when capture is paused.
|
|
115
|
+
if (eventName === "UserPromptSubmit") {
|
|
116
|
+
const budget = await refreshBudgetConfiguration({ connection, transport: runtime.transport, root, stderr });
|
|
117
|
+
if (budget.denied) {
|
|
118
|
+
stdout.write(JSON.stringify({ decision: "block", reason: budgetBlockReason(budget.reason) }));
|
|
119
|
+
return { handled: true, denied: true, budget };
|
|
120
|
+
}
|
|
121
|
+
budgetVerified = true;
|
|
122
|
+
}
|
|
123
|
+
const session = hostSession(hookInput);
|
|
124
|
+
if (!session) return { handled: true };
|
|
112
125
|
if (capture.paused) {
|
|
113
126
|
if (eventName === "SessionStart") {
|
|
114
127
|
// Capture pause does not suspend instruction or reference delivery.
|
|
@@ -203,6 +216,7 @@ export async function runClaudeLifecycleHook(connection, eventName, {
|
|
|
203
216
|
return { handled: true };
|
|
204
217
|
} catch (error) {
|
|
205
218
|
if (eventName === "PreToolUse") stdout.write(JSON.stringify(skillInvocationDenial()));
|
|
219
|
+
if (eventName === "UserPromptSubmit" && !budgetVerified) stdout.write(JSON.stringify({ decision: "block", reason: budgetBlockReason("usage_unavailable") }));
|
|
206
220
|
const code = error && typeof error === "object" && typeof error.code === "string"
|
|
207
221
|
? error.code : "runtime_unavailable";
|
|
208
222
|
stderr.write(`[halofy] lifecycle hook degraded: ${code}\n`);
|
package/src/context-sync.mjs
CHANGED
|
@@ -44,14 +44,18 @@ async function saveManifest(stateRoot, value) {
|
|
|
44
44
|
await rename(temporary, join(stateRoot, "manifest.json"));
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
export async function withContextLock(stateRoot, action) {
|
|
47
|
+
export async function withContextLock(stateRoot, action, { timeoutMs = 120_000 } = {}) {
|
|
48
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs < 0) throw fail("invalid_lock_timeout");
|
|
48
49
|
const lockPath = join(stateRoot, "refresh.lock");
|
|
49
50
|
const candidate = join(stateRoot, `lock-${process.pid}-${randomBytes(12).toString("hex")}.tmp`);
|
|
50
51
|
await privateFile(candidate, JSON.stringify({ pid: process.pid }));
|
|
51
52
|
const started = Date.now();
|
|
52
53
|
let acquired = false;
|
|
54
|
+
let firstAttempt = true;
|
|
53
55
|
try {
|
|
54
56
|
while (!acquired) {
|
|
57
|
+
if (!firstAttempt && Date.now() - started >= timeoutMs) throw fail("context_sync_busy");
|
|
58
|
+
firstAttempt = false;
|
|
55
59
|
try { await link(candidate, lockPath); acquired = true; }
|
|
56
60
|
catch (error) {
|
|
57
61
|
if (error?.code !== "EEXIST") throw error;
|
|
@@ -61,7 +65,10 @@ export async function withContextLock(stateRoot, action) {
|
|
|
61
65
|
let owner;
|
|
62
66
|
try { owner = await readPrivateJson(lockPath); }
|
|
63
67
|
catch (readError) { if (readError?.code === "ENOENT") continue; throw readError; }
|
|
64
|
-
if (owner === null)
|
|
68
|
+
if (owner === null) {
|
|
69
|
+
if (await state(lockPath)) throw fail("unsafe_lock");
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
65
72
|
if (!Number.isSafeInteger(owner?.pid) || owner.pid <= 0) throw fail("unsafe_lock");
|
|
66
73
|
let dead = false;
|
|
67
74
|
try { process.kill(owner.pid, 0); } catch (probe) { if (probe?.code === "ESRCH") dead = true; }
|
|
@@ -75,7 +82,7 @@ export async function withContextLock(stateRoot, action) {
|
|
|
75
82
|
if (latest?.ino === entry.ino && latest?.dev === entry.dev) await rm(lockPath);
|
|
76
83
|
} catch (recoveryError) {
|
|
77
84
|
if (recoveryError?.code !== "EEXIST" && recoveryError?.code !== "ENOENT") throw recoveryError;
|
|
78
|
-
if (Date.now() - started
|
|
85
|
+
if (Date.now() - started >= timeoutMs) throw fail("context_sync_busy");
|
|
79
86
|
await new Promise((done) => setTimeout(done, 25));
|
|
80
87
|
} finally {
|
|
81
88
|
if (recovering) await rm(recovery, { recursive: true });
|
|
@@ -83,7 +90,7 @@ export async function withContextLock(stateRoot, action) {
|
|
|
83
90
|
continue;
|
|
84
91
|
}
|
|
85
92
|
// Never steal a live writer's lock because a network page was slow.
|
|
86
|
-
if (Date.now() - started
|
|
93
|
+
if (Date.now() - started >= timeoutMs) throw fail("context_sync_busy");
|
|
87
94
|
await new Promise((done) => setTimeout(done, 25));
|
|
88
95
|
}
|
|
89
96
|
}
|
package/src/delivery-sync.mjs
CHANGED
|
@@ -5,20 +5,22 @@ import { safeDirectory, readPrivateJson, withContextLock, readManagedContextTupl
|
|
|
5
5
|
import { syncManagedSkills, readManagedSkillTuples, describeSkillSync } from "./skills-sync.mjs";
|
|
6
6
|
import { retirePredecessorContent } from "./predecessor-sync.mjs";
|
|
7
7
|
import { writePrivateFile } from "./storage.mjs";
|
|
8
|
+
import { refreshBudgetConfiguration } from "./budgets.mjs";
|
|
8
9
|
|
|
9
10
|
export const DELIVERY_NOTICE = "Halofy managed policies, skills, or knowledge references changed during this conversation. Start a new session to load the current references; previously loaded content may be outdated. File delivery does not confirm policy compliance.";
|
|
10
11
|
|
|
11
12
|
/** Explicit file refresh for hosts/modes without observed lifecycle hooks.
|
|
12
13
|
* Never fabricate a host session or enqueue/capture conversation events. */
|
|
13
14
|
export async function refreshManagedConfiguration(runtime, connection, root, { stderr = process.stderr, ...options } = {}) {
|
|
14
|
-
const [instructions, delivery] = await Promise.allSettled([
|
|
15
|
+
const [instructions, delivery, budgets] = await Promise.allSettled([
|
|
15
16
|
Promise.resolve().then(() => runtime.syncInstructions()),
|
|
16
17
|
syncManagedDelivery({ ...options, connection, transport: runtime.transport, root, stderr, phase: "start" }),
|
|
18
|
+
refreshBudgetConfiguration({ connection, transport: runtime.transport, root, stderr }),
|
|
17
19
|
]);
|
|
18
20
|
const instructionStatus = instructions.status === "fulfilled" ? instructions.value?.status ?? "unavailable" : "unavailable";
|
|
19
21
|
const deliveryOutcome = delivery.status === "fulfilled" ? delivery.value.outcome : "failed";
|
|
20
22
|
const acknowledged = delivery.status === "fulfilled" && delivery.value.acknowledged === true;
|
|
21
|
-
return { instructionStatus, deliveryOutcome, acknowledged,
|
|
23
|
+
return { instructionStatus, deliveryOutcome, acknowledged, budgets: budgets.status === "fulfilled" ? budgets.value : { status: "unavailable" },
|
|
22
24
|
ready: ["installed", "pending_restart"].includes(instructionStatus) && deliveryOutcome === "synced" && acknowledged };
|
|
23
25
|
}
|
|
24
26
|
const SHA = /^[a-f0-9]{64}$/;
|
package/src/host-hook.mjs
CHANGED
|
@@ -11,6 +11,7 @@ import { defaultRuntimeDirectory } from "./storage.mjs";
|
|
|
11
11
|
import { readHookInput, syncConfigurationAtSessionStart } from "./claude-hook.mjs";
|
|
12
12
|
import { syncManagedDelivery } from "./delivery-sync.mjs";
|
|
13
13
|
import { transcriptDriverFor } from "./transcript-drivers/index.mjs";
|
|
14
|
+
import { refreshBudgetConfiguration } from "./budgets.mjs";
|
|
14
15
|
|
|
15
16
|
const USER_EVENTS = new Set(["UserPromptSubmit", "beforeSubmitPrompt", "BeforeAgent", "pre_llm_call"]);
|
|
16
17
|
const ASSISTANT_EVENTS = new Set(["afterAgentResponse", "AfterAgent", "post_llm_call", "transform_llm_output"]);
|
|
@@ -157,6 +158,9 @@ export async function runHostLifecycleHook(connection, eventName, {
|
|
|
157
158
|
if (!session) return { handled: true, unavailable: "missing_host_session" };
|
|
158
159
|
const capture = await captureStateSnapshot(root, connection.installationId);
|
|
159
160
|
const runtime = runtimeFactory(connection, { root, captureGeneration: capture.generation });
|
|
161
|
+
// Other host hook protocols remain monitoring until their blocking boundary
|
|
162
|
+
// has its own conformance test. Budget delivery never upgrades that claim.
|
|
163
|
+
if (USER_EVENTS.has(eventName)) await refreshBudgetConfiguration({ connection, transport: runtime.transport, root, stderr });
|
|
160
164
|
if (capture.paused) {
|
|
161
165
|
if (START_EVENTS.has(eventName)) {
|
|
162
166
|
// Capture pause does not suspend instruction or reference delivery.
|
package/src/install.mjs
CHANGED
|
@@ -14,6 +14,7 @@ import { syncManagedSkills } from "./skills-sync.mjs";
|
|
|
14
14
|
import { syncManagedDelivery } from "./delivery-sync.mjs";
|
|
15
15
|
import { retirePredecessorContent } from "./predecessor-sync.mjs";
|
|
16
16
|
import { syncManagedContext } from "./context-sync.mjs";
|
|
17
|
+
import { refreshBudgetConfiguration } from "./budgets.mjs";
|
|
17
18
|
|
|
18
19
|
export const CLAUDE_CAPABILITIES = CLIENT_REGISTRY["claude-code"].capabilities;
|
|
19
20
|
|
|
@@ -239,10 +240,13 @@ export async function installLocalConnection({
|
|
|
239
240
|
const instructions = instructionProfile
|
|
240
241
|
? await syncAgentInstructions(connection, { root, fetchImpl, timeoutMs: INSTALL_INSTRUCTION_SYNC_TIMEOUT_MS })
|
|
241
242
|
: undefined;
|
|
243
|
+
const budgets = await refreshBudgetConfiguration({ connection, root,
|
|
244
|
+
transport: new SignedRuntimeTransport(connection, { fetchImpl, timeoutMs: 3_000 }) });
|
|
242
245
|
return {
|
|
243
246
|
installationId,
|
|
244
247
|
previousInstallationId: connection.previousInstallationId ?? null,
|
|
245
248
|
...(instructions ? { instructions } : {}),
|
|
249
|
+
budgets,
|
|
246
250
|
heartbeat,
|
|
247
251
|
reused: false,
|
|
248
252
|
proofStorage: connection.proofStorage,
|
package/src/installer-cli.mjs
CHANGED
|
@@ -207,6 +207,7 @@ export function disclosureText({ serverUrl, projectRoot, clientKind, clientVersi
|
|
|
207
207
|
"Organization instructions: supported adapters manage an additive global rule file or marked section across projects in the selected local agent profile. Existing personal instructions are preserved. Updates sync at installation and supported session starts; restart the host to load changes.",
|
|
208
208
|
"Other OS accounts, containers, remote/cloud hosts and unsupported agents are not covered. Instruction precedence remains controlled by the host.",
|
|
209
209
|
"Disconnecting stops future capture but does not erase retained data.",
|
|
210
|
+
"AI budgets: fresh signed limits are delivered with configuration. Trusted Claude prompt-submit hooks can block new turns when an enforced budget is exhausted or unavailable, including while capture is paused. Other packaged hosts are monitoring only. Internal model calls, subagents and subscription billing are not a hard monetary ceiling.",
|
|
210
211
|
`Disclosure: ${DISCLOSURE_VERSION}`,
|
|
211
212
|
].join("\n");
|
|
212
213
|
}
|
|
@@ -264,6 +265,7 @@ export function allDisclosureText({ serverUrl, projectRoot, organization = null,
|
|
|
264
265
|
"Organization instructions: supported adapters manage an additive global rule file or marked section across projects in the selected local agent profile. Existing personal instructions are preserved. Updates sync at installation and supported session starts; restart the host to load changes.",
|
|
265
266
|
"Other OS accounts, containers, remote/cloud hosts and unsupported agents are not covered. Instruction precedence remains controlled by the host.",
|
|
266
267
|
"Disconnecting stops future capture but does not erase retained data.",
|
|
268
|
+
"AI budgets: trusted Claude prompt-submit hooks can block new turns on exhausted or unavailable enforced budgets independently of capture pause. Other packaged hosts are monitoring only; internal calls and subscription billing are outside a hard monetary ceiling.",
|
|
267
269
|
`Disclosure: ${DISCLOSURE_VERSION}`,
|
|
268
270
|
);
|
|
269
271
|
return lines.join("\n");
|
|
@@ -403,6 +405,7 @@ async function runAllInstaller(input, {
|
|
|
403
405
|
: null,
|
|
404
406
|
proofStorage: installed.proofStorage,
|
|
405
407
|
instructions: installed.instructions,
|
|
408
|
+
budgets: installed.budgets,
|
|
406
409
|
syncCommand: { command: process.execPath, args: [bundle.runtimePath, "sync", "--connection", installed.installationId], environment: { HALOFY_AGENT_HOME: root } },
|
|
407
410
|
skillGuard: configured.skillGuard,
|
|
408
411
|
configuredPaths: configured.configuredPaths || [configured.mcpPath, configured.settingsPath],
|
|
@@ -516,6 +519,7 @@ export async function runInstaller(argv, {
|
|
|
516
519
|
publishedPackage: true,
|
|
517
520
|
projectConfigured: true,
|
|
518
521
|
instructions: installed.instructions,
|
|
522
|
+
budgets: installed.budgets,
|
|
519
523
|
syncCommand: { command: process.execPath, args: [bundle.runtimePath, "sync", "--connection", installed.installationId], environment: { HALOFY_AGENT_HOME: root } },
|
|
520
524
|
skillGuard: configured.skillGuard,
|
|
521
525
|
configuredPaths: configured.configuredPaths || [configured.mcpPath, configured.settingsPath],
|
package/src/instructions.mjs
CHANGED
|
@@ -12,6 +12,7 @@ export const INSTALL_INSTRUCTION_SYNC_TIMEOUT_MS = 15_000;
|
|
|
12
12
|
|
|
13
13
|
const sha = (value) => createHash("sha256").update(value).digest("hex");
|
|
14
14
|
const HEX = /^[a-f0-9]{64}$/;
|
|
15
|
+
const MCP_POLICY_ID = /^mcp-policy-[a-f0-9]{64}$/;
|
|
15
16
|
const MARKER = "<!-- HALOFY-INSTRUCTIONS:";
|
|
16
17
|
const fail = (code) => { throw Object.assign(new Error(code), { instructionCode: code }); };
|
|
17
18
|
const plain = (value) => value && typeof value === "object" && !Array.isArray(value);
|
|
@@ -34,16 +35,21 @@ export function validateInstructionBundle(bundle, expectedNamespace) {
|
|
|
34
35
|
bundle.namespace !== expectedNamespace || !HEX.test(bundle.digest) || !Array.isArray(bundle.documents) ||
|
|
35
36
|
Buffer.byteLength(JSON.stringify(bundle)) > 64 * 1024) fail("invalid_bundle");
|
|
36
37
|
let previous = null;
|
|
37
|
-
const documents = bundle.documents.map((doc) => {
|
|
38
|
+
const documents = bundle.documents.map((doc, index) => {
|
|
39
|
+
// This reserved identifier is a wire discriminator, not an authenticity
|
|
40
|
+
// proof. Authority still comes from the authorized server response.
|
|
41
|
+
const generatedPolicy = plain(doc) && typeof doc.id === "string" && MCP_POLICY_ID.test(doc.id);
|
|
38
42
|
if (!plain(doc) || typeof doc.id !== "string" || !/^[A-Za-z0-9_-]{1,160}$/.test(doc.id) || !scope(doc.namespace) ||
|
|
39
43
|
!(doc.namespace === "" || doc.namespace === expectedNamespace || expectedNamespace.startsWith(`${doc.namespace}/`)) ||
|
|
40
|
-
(
|
|
44
|
+
(doc.id.startsWith("mcp-policy-") && !generatedPolicy) ||
|
|
45
|
+
(generatedPolicy && (index !== bundle.documents.length - 1 || doc.namespace !== expectedNamespace || doc.title !== "Required MCP access policy")) ||
|
|
46
|
+
(!generatedPolicy && previous !== null && !(doc.namespace !== previous && (previous === "" || doc.namespace.startsWith(`${previous}/`)))) ||
|
|
41
47
|
!Number.isSafeInteger(doc.version) || doc.version < 1 || typeof doc.title !== "string" || !doc.title.trim() || doc.title.length > 160 || doc.title.includes(MARKER) ||
|
|
42
48
|
/[\u0000-\u001f\u007f]/.test(doc.title) || typeof doc.content !== "string" || !doc.content.trim() ||
|
|
43
49
|
Buffer.from(doc.title, "utf8").toString("utf8") !== doc.title || Buffer.from(doc.content, "utf8").toString("utf8") !== doc.content ||
|
|
44
|
-
doc.content.includes("\0") || doc.content.includes(MARKER) || Buffer.byteLength(doc.content) > 16 * 1024 ||
|
|
50
|
+
doc.content.includes("\0") || doc.content.includes(MARKER) || Buffer.byteLength(doc.content) > (generatedPolicy ? 64 : 16) * 1024 ||
|
|
45
51
|
!HEX.test(doc.sha256) || sha(doc.content) !== doc.sha256) fail("invalid_bundle");
|
|
46
|
-
previous = doc.namespace;
|
|
52
|
+
if (!generatedPolicy) previous = doc.namespace;
|
|
47
53
|
return { id: doc.id, namespace: doc.namespace, version: doc.version, title: doc.title, content: doc.content, sha256: doc.sha256 };
|
|
48
54
|
});
|
|
49
55
|
if (sha(JSON.stringify(documents)) !== bundle.digest) fail("invalid_bundle");
|
package/src/transport.mjs
CHANGED
|
@@ -22,7 +22,7 @@ export class SignedRuntimeTransport {
|
|
|
22
22
|
this.signal = signal;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
async request(path, { method = "POST", body, headers = {}, raw = false, maxResponseBytes } = {}) {
|
|
25
|
+
async request(path, { method = "POST", body, headers = {}, raw = false, maxResponseBytes, timeoutMs = this.timeoutMs } = {}) {
|
|
26
26
|
const bodyBytes = body === undefined
|
|
27
27
|
? Buffer.alloc(0)
|
|
28
28
|
: Buffer.from(typeof body === "string" || Buffer.isBuffer(body) ? body : JSON.stringify(body));
|
|
@@ -34,7 +34,7 @@ export class SignedRuntimeTransport {
|
|
|
34
34
|
bodyBytes,
|
|
35
35
|
});
|
|
36
36
|
const controller = new AbortController();
|
|
37
|
-
const timer = setTimeout(() => controller.abort(),
|
|
37
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
38
38
|
try {
|
|
39
39
|
const response = await this.fetch(`${this.connection.serverUrl}${path}`, {
|
|
40
40
|
method,
|
|
@@ -97,6 +97,9 @@ export class SignedRuntimeTransport {
|
|
|
97
97
|
deliveryReceipt(receipt) {
|
|
98
98
|
return this.request("/v1/agent-runtime/delivery/receipt", { body: receipt, maxResponseBytes: 16 * 1024 });
|
|
99
99
|
}
|
|
100
|
+
budgetCheck() {
|
|
101
|
+
return this.request("/v1/agent-runtime/budget/check", { body: {}, maxResponseBytes: 32 * 1024, timeoutMs: Math.min(this.timeoutMs, 3_000) });
|
|
102
|
+
}
|
|
100
103
|
contextPage(cursor) {
|
|
101
104
|
return this.request("/v1/agent-runtime/context", {
|
|
102
105
|
body: cursor === undefined ? {} : { cursor }, maxResponseBytes: 8 * 1024 * 1024,
|
package/src/version.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
export const PACKAGE_NAME = "@halofy/agent-connect";
|
|
2
|
-
export const INSTALLER_VERSION = "0.
|
|
3
|
-
export const RUNTIME_VERSION = "0.
|
|
4
|
-
export const DISCLOSURE_VERSION = "halofy-agent-lifecycle-2026-09-
|
|
2
|
+
export const INSTALLER_VERSION = "0.14.0";
|
|
3
|
+
export const RUNTIME_VERSION = "0.14.0";
|
|
4
|
+
export const DISCLOSURE_VERSION = "halofy-agent-lifecycle-2026-09-25.1";
|