@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # Halofy agent lifecycle installer
2
2
 
3
- Status: source for `@halofy/agent-connect@0.13.1`; publication and deployment
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
@@ -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 = hookEvent === "PreToolUse" ? 0 : 1;
141
+ process.exitCode = ["PreToolUse", "UserPromptSubmit"].includes(hookEvent) ? 0 : 1;
140
142
  }
141
143
  }
142
144
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@halofy/agent-connect",
3
- "version": "0.13.1",
3
+ "version": "0.14.0",
4
4
  "type": "module",
5
5
  "description": "Halofy lifecycle installer and runtime for supported agents; runtime requests are signed with a per-installation Ed25519 key",
6
6
  "bin": {
@@ -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
+ }
@@ -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`);
@@ -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) continue;
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 > 120_000) throw fail("context_sync_busy");
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 > 120_000) throw fail("context_sync_busy");
93
+ if (Date.now() - started >= timeoutMs) throw fail("context_sync_busy");
87
94
  await new Promise((done) => setTimeout(done, 25));
88
95
  }
89
96
  }
@@ -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,
@@ -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],
@@ -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
- (previous !== null && !(doc.namespace !== previous && (previous === "" || doc.namespace.startsWith(`${previous}/`)))) ||
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(), this.timeoutMs);
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.13.1";
3
- export const RUNTIME_VERSION = "0.13.1";
4
- export const DISCLOSURE_VERSION = "halofy-agent-lifecycle-2026-09-17.1";
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";