@halofy/agent-connect 0.9.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +85 -3
- package/bin/halofy-agent.mjs +8 -2
- package/package.json +1 -1
- package/src/claude-hook.mjs +18 -1
- package/src/health.mjs +156 -0
- package/src/host-hook.mjs +18 -1
- package/src/install.mjs +11 -0
- package/src/installer-cli.mjs +14 -5
- package/src/instructions.mjs +266 -0
- package/src/mcp-proxy.mjs +36 -22
- package/src/queue.mjs +2 -0
- package/src/runtime.mjs +37 -7
- package/src/session.mjs +6 -2
- package/src/transcript-drivers/claude.mjs +2 -2
- package/src/transcript-drivers/codex.mjs +2 -1
- package/src/transcript-drivers/kimi.mjs +2 -1
- package/src/transport.mjs +8 -5
- package/src/version.mjs +3 -3
package/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# Halofy agent lifecycle installer
|
|
2
2
|
|
|
3
|
-
Status:
|
|
4
|
-
|
|
3
|
+
Status: published installer (`@halofy/agent-connect@0.10.0`). The deployment
|
|
4
|
+
workflow pins the verified tarball; host loading requires separate verification.
|
|
5
5
|
|
|
6
6
|
This package contains the host-neutral client pieces for installation-bound
|
|
7
7
|
agent connections:
|
|
@@ -25,7 +25,7 @@ bounded recall block formats stay in place and tested for when it returns.
|
|
|
25
25
|
There is one setup path for every packaged client:
|
|
26
26
|
|
|
27
27
|
```bash
|
|
28
|
-
npx --yes @halofy/agent-connect@0.
|
|
28
|
+
npx --yes @halofy/agent-connect@0.10.0 install <client-kind> \
|
|
29
29
|
--server https://app.halofy.ai \
|
|
30
30
|
--claim '<one-time-claim>'
|
|
31
31
|
```
|
|
@@ -155,3 +155,85 @@ confirm delivery status. Existing offline skill limits remain; context refresh
|
|
|
155
155
|
failures withdraw the managed references. These are source capabilities; package
|
|
156
156
|
publication, installed-client upgrades and production deployment require separate
|
|
157
157
|
release verification.
|
|
158
|
+
|
|
159
|
+
## Connection health and local pause (0.11.0 source)
|
|
160
|
+
|
|
161
|
+
This source version adds a signed heartbeat immediately and every 60 seconds
|
|
162
|
+
while the existing MCP proxy runs. It inspects the installation's managed hook
|
|
163
|
+
and MCP configuration without repairing it. Health contains only a state; local
|
|
164
|
+
paths stay on disk. Legacy installations without inspection metadata report
|
|
165
|
+
`unknown`. Missing contact may mean an idle host, sleep, or network loss, and
|
|
166
|
+
cannot establish that an employee uninstalled the integration.
|
|
167
|
+
|
|
168
|
+
Use the installed runtime executable with `pause --connection <installation-id>`
|
|
169
|
+
or `resume --connection <installation-id>`. Pause persists for that installation,
|
|
170
|
+
attempts a bounded flush of already queued events, and skips subsequent capture
|
|
171
|
+
hooks. It does not block MCP. Resume reports inspected health and excludes older
|
|
172
|
+
transcript bodies from catch-up, so paused content is not backfilled. A failed
|
|
173
|
+
state report is retried by the next running-proxy heartbeat or session start.
|
|
174
|
+
|
|
175
|
+
JSON configuration checks require every managed hook registration and MCP entry.
|
|
176
|
+
Changed Kimi/Codex TOML is conservative: removed/disabled managed configuration
|
|
177
|
+
reports `hooks_missing`; other changes report `unknown` pending reinstallation.
|
|
178
|
+
This package does not add a TOML parser or a device-wide monitoring daemon.
|
|
179
|
+
|
|
180
|
+
The 0.11.0 source is not proof of a published or deployed artifact. Release must
|
|
181
|
+
publish the exact reviewed npm package, verify its integrity, update the server's
|
|
182
|
+
pinned installer artifact through its existing release process, and prove an
|
|
183
|
+
updated disposable installation. Existing local runtimes do not auto-upgrade.
|
|
184
|
+
|
|
185
|
+
## Organization instructions (0.10.0)
|
|
186
|
+
|
|
187
|
+
This source adds Govern instruction delivery alongside the existing skill,
|
|
188
|
+
policy, knowledge and delivery-receipt paths. Activation requires the reviewed
|
|
189
|
+
0.10.0 npm publication, matching server deployment and a fresh confirmed
|
|
190
|
+
installation. Source versioning alone is not publication evidence. Existing
|
|
191
|
+
connections without the new local instruction consent/profile snapshot do not
|
|
192
|
+
gain global file-writing authority.
|
|
193
|
+
|
|
194
|
+
The installer discloses global rule management alongside existing capture and
|
|
195
|
+
MCP behavior. It freezes the selected profile and server-provided namespace in
|
|
196
|
+
the local connection and fetches instructions with the installation proof.
|
|
197
|
+
Subsequent supported session-start hooks refresh them independently of disabled
|
|
198
|
+
memory recall. There is no resident instruction daemon; an offline device or a
|
|
199
|
+
host session without an installed startup hook does not fetch updates. Claude's
|
|
200
|
+
existing hook installation is project-scoped even though its rule file is global.
|
|
201
|
+
|
|
202
|
+
| Host | Local target | Boundaries |
|
|
203
|
+
|---|---|---|
|
|
204
|
+
| Codex | `CODEX_HOME` or `~/.codex`, active nonempty `AGENTS.override.md` else `AGENTS.md` | Marked section; changing the active target reports a conflict |
|
|
205
|
+
| Claude Code | `CLAUDE_CONFIG_DIR` or `~/.claude`, dedicated `rules/halofy-*.md` | Existing personal/project `CLAUDE.md` remains untouched |
|
|
206
|
+
| Gemini CLI | `GEMINI_CLI_HOME` or home, then `.gemini/GEMINI.md` | Marked section; custom discovery excluding `GEMINI.md` is unsupported |
|
|
207
|
+
| Cursor | `~/.cursor/rules/halofy-*.mdc` with `alwaysApply: true` | Agent Chat only; no claim for Tab, Inline Edit, or cloud hosts |
|
|
208
|
+
| Other packaged hosts | No instruction writes | Reports unsupported; existing capture/MCP continues |
|
|
209
|
+
|
|
210
|
+
Host mechanisms were checked against official documentation on 2026-09-10:
|
|
211
|
+
[Codex](https://learn.chatgpt.com/docs/agent-configuration/agents-md),
|
|
212
|
+
[Claude Code](https://code.claude.com/docs/en/memory),
|
|
213
|
+
[Gemini CLI](https://geminicli.com/docs/cli/gemini-md/), and
|
|
214
|
+
[Cursor](https://prod.cursor.com/help/customization/rules).
|
|
215
|
+
These are file-format adapters, not real-host loading certification. Host
|
|
216
|
+
precedence, context limits, project configuration and exclusion settings still
|
|
217
|
+
apply. Receipts say `pending_restart`, never loaded or obeyed. Other local OS
|
|
218
|
+
accounts, containers and remote/cloud profiles require their own installation.
|
|
219
|
+
|
|
220
|
+
Sync validates exact content and bundle digests, scope ancestry/order and size
|
|
221
|
+
before writing. Personal bytes outside a managed block are preserved exactly.
|
|
222
|
+
An isolated ownership manifest, exclusive profile lock, no-follow reads,
|
|
223
|
+
component symlink checks, private backups, concurrent-edit checks and atomic
|
|
224
|
+
replacement protect local content. Unexpected edits, broken/duplicate markers,
|
|
225
|
+
linked files or changed target selection fail without replacing host content.
|
|
226
|
+
Request failure leaves the current installation’s instructions unchanged. A
|
|
227
|
+
confirmed reconnect first retires only verified predecessor-owned instruction
|
|
228
|
+
bytes; old hooks cannot restore them. Conflicting predecessor edits prevent new
|
|
229
|
+
instruction activation while preserving all user content. Otherwise, only a
|
|
230
|
+
verified empty bundle removes managed content; an empty file and removal manifest remain so a
|
|
231
|
+
future authorized re-enable can be recognized safely. Backup files stay outside
|
|
232
|
+
host rules directories, under the runtime instructions directory.
|
|
233
|
+
|
|
234
|
+
A stale lock is deliberately not automatically deleted; after confirming no
|
|
235
|
+
sync is running, an operator may remove `.halofy-instructions.lock` in the
|
|
236
|
+
selected profile. Local filesystem errors and receipt failures do not interrupt
|
|
237
|
+
capture or MCP. Neither instruction text, filesystem paths nor backups are sent
|
|
238
|
+
in status receipts. Existing policy/knowledge/skill operations and runtime queue
|
|
239
|
+
files are not changed by instruction sync.
|
package/bin/halofy-agent.mjs
CHANGED
|
@@ -5,6 +5,8 @@ import { runStdioMcpProxy } from "../src/mcp-proxy.mjs";
|
|
|
5
5
|
import { BoundedEncryptedQueue } from "../src/queue.mjs";
|
|
6
6
|
import { runClaudeLifecycleHook } from "../src/claude-hook.mjs";
|
|
7
7
|
import { HOST_HOOK_EVENTS, runHostLifecycleHook } from "../src/host-hook.mjs";
|
|
8
|
+
import { LifecycleRuntime } from "../src/runtime.mjs";
|
|
9
|
+
import { setCapturePaused } from "../src/health.mjs";
|
|
8
10
|
import { join } from "node:path";
|
|
9
11
|
|
|
10
12
|
function option(name) {
|
|
@@ -18,9 +20,9 @@ const claudeHookEvents = new Set([
|
|
|
18
20
|
"SessionStart", "UserPromptSubmit", "Stop", "PreCompact", "SessionEnd",
|
|
19
21
|
"SubagentStart", "SubagentStop", "PostToolUse", "PostToolUseFailure", "PostToolBatch",
|
|
20
22
|
]);
|
|
21
|
-
if (!["mcp", "diagnostics", "hook"].includes(command) ||
|
|
23
|
+
if (!["mcp", "diagnostics", "hook", "pause", "resume"].includes(command) ||
|
|
22
24
|
(command === "hook" && !claudeHookEvents.has(hookEvent) && !HOST_HOOK_EVENTS.has(hookEvent))) {
|
|
23
|
-
process.stderr.write("Usage: halofy-agent <mcp|diagnostics|hook EVENT> [--connection <installation-id>]\n");
|
|
25
|
+
process.stderr.write("Usage: halofy-agent <mcp|diagnostics|hook EVENT|pause|resume> [--connection <installation-id>]\n");
|
|
24
26
|
process.exitCode = 2;
|
|
25
27
|
} else {
|
|
26
28
|
try {
|
|
@@ -31,6 +33,10 @@ if (!["mcp", "diagnostics", "hook"].includes(command) ||
|
|
|
31
33
|
if (!connection) throw new Error("no active installation-bound connection");
|
|
32
34
|
if (command === "mcp") {
|
|
33
35
|
await runStdioMcpProxy(connection);
|
|
36
|
+
} else if (command === "pause" || command === "resume") {
|
|
37
|
+
const root = defaultRuntimeDirectory();
|
|
38
|
+
const runtime = new LifecycleRuntime(connection, { root });
|
|
39
|
+
process.stdout.write(`${JSON.stringify(await setCapturePaused(runtime, root, command === "pause"))}\n`);
|
|
34
40
|
} else if (command === "hook") {
|
|
35
41
|
if (connection.clientKind === "claude-code") await runClaudeLifecycleHook(connection, hookEvent);
|
|
36
42
|
else await runHostLifecycleHook(connection, hookEvent);
|
package/package.json
CHANGED
package/src/claude-hook.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { captureStateSnapshot } from "./health.mjs";
|
|
1
2
|
import { createHash } from "node:crypto";
|
|
2
3
|
import { basename } from "node:path";
|
|
3
4
|
import { LifecycleRuntime } from "./runtime.mjs";
|
|
@@ -91,11 +92,27 @@ export async function runClaudeLifecycleHook(connection, eventName, {
|
|
|
91
92
|
const hookInput = input ?? await readHookInput();
|
|
92
93
|
const session = hostSession(hookInput);
|
|
93
94
|
if (!session) return { handled: true };
|
|
94
|
-
const
|
|
95
|
+
const capture = await captureStateSnapshot(root, connection.installationId);
|
|
96
|
+
const runtime = runtimeFactory(connection, { root, captureGeneration: capture.generation });
|
|
97
|
+
if (capture.paused) {
|
|
98
|
+
if (eventName === "SessionStart") {
|
|
99
|
+
await syncSkillsAtSessionStart(runtime, connection, root, stderr, { home, env });
|
|
100
|
+
// Capture pause does not suspend governed instruction delivery.
|
|
101
|
+
await runtime.syncInstructions?.().catch(() => {});
|
|
102
|
+
await runtime.heartbeat(connection.capabilities || {});
|
|
103
|
+
} else if (eventName === "UserPromptSubmit") {
|
|
104
|
+
const delivery = await syncManagedDelivery({ connection, transport: runtime.transport, root, home, env, stderr, phase: "turn" });
|
|
105
|
+
const output = recallText(null, "UserPromptSubmit", delivery.notice);
|
|
106
|
+
if (output) stdout.write(output);
|
|
107
|
+
}
|
|
108
|
+
return { handled: true, paused: true };
|
|
109
|
+
}
|
|
95
110
|
|
|
96
111
|
if (eventName === "SessionStart") {
|
|
97
112
|
// Refresh/withdraw local context even when replay or heartbeat fails.
|
|
98
113
|
await syncSkillsAtSessionStart(runtime, connection, root, stderr, { home, env });
|
|
114
|
+
// Instructions have their own brownout boundary, independent of recall.
|
|
115
|
+
await runtime.syncInstructions?.().catch(() => {});
|
|
99
116
|
await runtime.replay();
|
|
100
117
|
await runtime.heartbeat(connection.capabilities || {});
|
|
101
118
|
if (RECALL_INJECTION_ENABLED) {
|
package/src/health.mjs
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { readFile, stat } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { isDeepStrictEqual } from "node:util";
|
|
5
|
+
import { SignedRuntimeTransport } from "./transport.mjs";
|
|
6
|
+
import { readJson, writePrivateFile, withFileLock } from "./storage.mjs";
|
|
7
|
+
|
|
8
|
+
const statePath = (root, id) => join(root, id, "capture-state.json");
|
|
9
|
+
const targetsPath = (root, id) => join(root, id, "health-targets.json");
|
|
10
|
+
|
|
11
|
+
export async function capturePaused(root, installationId) {
|
|
12
|
+
// Corrupt local state cannot silently enable capture.
|
|
13
|
+
try { return (await readJson(statePath(root, installationId)))?.paused === true; }
|
|
14
|
+
catch { return true; }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function captureStateSnapshot(root, installationId) {
|
|
18
|
+
try {
|
|
19
|
+
const state = await readJson(statePath(root, installationId), {});
|
|
20
|
+
return { paused: state.paused === true, generation: state.generation ?? null, captureAfter: state.captureAfter ?? null };
|
|
21
|
+
} catch { return { paused: true, generation: null, captureAfter: null }; }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function captureAdmission(root, installationId, generation) {
|
|
25
|
+
const current = await captureStateSnapshot(root, installationId);
|
|
26
|
+
return !current.paused && current.generation === generation;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Local-only install metadata. Never send paths or commands as health evidence. */
|
|
30
|
+
export async function recordHealthTargets({ root, installationId, configured, runtimePath }) {
|
|
31
|
+
const paths = configured.configuredPaths || [configured.mcpPath, configured.settingsPath];
|
|
32
|
+
const targets = [];
|
|
33
|
+
const foundEvents = new Set();
|
|
34
|
+
for (const path of paths) {
|
|
35
|
+
const text = await readFile(path, "utf8");
|
|
36
|
+
if (path.endsWith(".json")) {
|
|
37
|
+
const value = JSON.parse(text);
|
|
38
|
+
const hooks = {};
|
|
39
|
+
for (const event of configured.hookEvents) {
|
|
40
|
+
const entries = value.hooks?.[event];
|
|
41
|
+
if (!Array.isArray(entries)) continue;
|
|
42
|
+
const managed = entries.filter((entry) => JSON.stringify(entry).includes(installationId) &&
|
|
43
|
+
JSON.stringify(entry).includes("--connection"));
|
|
44
|
+
if (managed.length) { hooks[event] = managed; foundEvents.add(event); }
|
|
45
|
+
}
|
|
46
|
+
const registry = value.mcpServers ? "mcpServers" : value.servers ? "servers" : null;
|
|
47
|
+
targets.push({ path, kind: "json", hooks, ...(registry ? { registry, mcp: value[registry].halofy } : {}) });
|
|
48
|
+
} else {
|
|
49
|
+
// TOML is deliberately conservative: without a bundled TOML parser a
|
|
50
|
+
// changed document cannot prove that hooks are still enabled.
|
|
51
|
+
const managedText = text.match(/# BEGIN HALOFY LIFECYCLE[\s\S]*?# END HALOFY LIFECYCLE/)?.[0];
|
|
52
|
+
targets.push({ path, kind: path.endsWith(".toml") ? "toml" : "script", text,
|
|
53
|
+
...(managedText ? { managedText } : {}) });
|
|
54
|
+
for (const event of configured.hookEvents) {
|
|
55
|
+
if (text.includes(`event = ${JSON.stringify(event)}`) || path.endsWith(`/${event}`)) foundEvents.add(event);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (configured.hookEvents.some((event) => !foundEvents.has(event))) throw new Error("managed hook inspection metadata incomplete");
|
|
60
|
+
await writePrivateFile(targetsPath(root, installationId), JSON.stringify({ version: 1, runtimePath, targets }));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function disabled(value) {
|
|
64
|
+
return value?.enabled === false || value?.disabled === true || value?.disableAllHooks === true;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function inspectCaptureHealth(connection, root) {
|
|
68
|
+
if (await capturePaused(root, connection.installationId)) return { captureState: "paused" };
|
|
69
|
+
let metadata;
|
|
70
|
+
try { metadata = await readJson(targetsPath(root, connection.installationId)); }
|
|
71
|
+
catch { return { captureState: "unknown" }; }
|
|
72
|
+
if (metadata?.version !== 1 || !metadata.targets?.length) return { captureState: "unknown" };
|
|
73
|
+
let unknown = false;
|
|
74
|
+
try {
|
|
75
|
+
if (!(await stat(metadata.runtimePath)).isFile()) return { captureState: "hooks_missing" };
|
|
76
|
+
for (const target of metadata.targets) {
|
|
77
|
+
const text = await readFile(target.path, "utf8");
|
|
78
|
+
if (target.kind === "json") {
|
|
79
|
+
const value = JSON.parse(text);
|
|
80
|
+
if (disabled(value) || disabled(value.hooks) || value.hooksConfig?.enabled === false) return { captureState: "hooks_missing" };
|
|
81
|
+
for (const [event, expected] of Object.entries(target.hooks)) {
|
|
82
|
+
const entries = value.hooks?.[event];
|
|
83
|
+
if (!Array.isArray(entries) || !expected.every((entry) => entries.some((actual) =>
|
|
84
|
+
!disabled(actual) && isDeepStrictEqual(actual, entry)))) return { captureState: "hooks_missing" };
|
|
85
|
+
}
|
|
86
|
+
if (target.registry && (!isDeepStrictEqual(value[target.registry]?.halofy, target.mcp) ||
|
|
87
|
+
disabled(value[target.registry]?.halofy))) return { captureState: "hooks_missing" };
|
|
88
|
+
} else if (target.kind === "script") {
|
|
89
|
+
if (text !== target.text || (process.platform !== "win32" && ((await stat(target.path)).mode & 0o111) === 0)) {
|
|
90
|
+
return { captureState: "hooks_missing" };
|
|
91
|
+
}
|
|
92
|
+
} else if (text !== target.text) {
|
|
93
|
+
if ((target.managedText && !text.includes(target.managedText)) || /^\s*(?:hooks|enabled)\s*=\s*false\b/m.test(text) ||
|
|
94
|
+
!text.includes(connection.installationId) || !text.includes("--connection")) return { captureState: "hooks_missing" };
|
|
95
|
+
unknown = true;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
} catch (error) {
|
|
99
|
+
return { captureState: error?.code === "ENOENT" || error instanceof SyntaxError ? "hooks_missing" : "unknown" };
|
|
100
|
+
}
|
|
101
|
+
return { captureState: unknown ? "unknown" : "active" };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Pause applies before flushing so concurrent future hooks cannot enqueue. */
|
|
105
|
+
export async function setCapturePaused(runtime, root, paused, { flushTimeoutMs = 5_000 } = {}) {
|
|
106
|
+
// Queue admission and pause persistence share a short lock. A capture read
|
|
107
|
+
// may still be running, but cannot enqueue across this state transition.
|
|
108
|
+
await withFileLock(runtime.queue.lockPath, async () => {
|
|
109
|
+
const previous = await readJson(statePath(root, runtime.connection.installationId), {});
|
|
110
|
+
const now = new Date().toISOString();
|
|
111
|
+
const resumedAt = !paused && previous.paused === true ? now : previous.captureAfter;
|
|
112
|
+
await writePrivateFile(statePath(root, runtime.connection.installationId), JSON.stringify({
|
|
113
|
+
paused, changedAt: now, generation: randomUUID(), ...(resumedAt ? { captureAfter: resumedAt } : {}),
|
|
114
|
+
}));
|
|
115
|
+
});
|
|
116
|
+
let flushed = null;
|
|
117
|
+
if (paused) {
|
|
118
|
+
// Dedicated flush transport has one deadline for the entire replay, not
|
|
119
|
+
// one fresh timeout for every batch. The existing proxy keeps running.
|
|
120
|
+
const controller = new AbortController();
|
|
121
|
+
const timer = setTimeout(() => controller.abort(), flushTimeoutMs);
|
|
122
|
+
try {
|
|
123
|
+
const flushRuntime = new runtime.constructor(runtime.connection, { root,
|
|
124
|
+
transport: new SignedRuntimeTransport(runtime.connection, {
|
|
125
|
+
fetchImpl: runtime.transport.fetch, signal: controller.signal, timeoutMs: flushTimeoutMs,
|
|
126
|
+
}),
|
|
127
|
+
});
|
|
128
|
+
await flushRuntime.replay();
|
|
129
|
+
flushed = true;
|
|
130
|
+
} catch { flushed = false; }
|
|
131
|
+
finally { clearTimeout(timer); }
|
|
132
|
+
}
|
|
133
|
+
let reported = false;
|
|
134
|
+
try { await runtime.heartbeat(runtime.connection.capabilities || {}); reported = true; } catch { /* next proxy tick retries */ }
|
|
135
|
+
return { paused, reported, ...(paused ? { flushed } : {}) };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** No overlapping checks and no timers survive proxy shutdown. */
|
|
139
|
+
export function startHealthHeartbeat(runtime, {
|
|
140
|
+
intervalMs = 60_000,
|
|
141
|
+
setIntervalImpl = setInterval,
|
|
142
|
+
clearIntervalImpl = clearInterval,
|
|
143
|
+
} = {}) {
|
|
144
|
+
let stopped = false;
|
|
145
|
+
let pending = null;
|
|
146
|
+
const tick = () => {
|
|
147
|
+
if (stopped || pending) return pending;
|
|
148
|
+
pending = Promise.resolve().then(() => runtime.heartbeat(runtime.connection.capabilities || {}))
|
|
149
|
+
.catch(() => {}).finally(() => { pending = null; });
|
|
150
|
+
return pending;
|
|
151
|
+
};
|
|
152
|
+
void tick();
|
|
153
|
+
const timer = setIntervalImpl(() => { void tick(); }, intervalMs);
|
|
154
|
+
timer?.unref?.();
|
|
155
|
+
return { tick, stop() { stopped = true; clearIntervalImpl(timer); } };
|
|
156
|
+
}
|
package/src/host-hook.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { captureStateSnapshot } from "./health.mjs";
|
|
1
2
|
import { createHash } from "node:crypto";
|
|
2
3
|
import { basename } from "node:path";
|
|
3
4
|
import { LifecycleRuntime } from "./runtime.mjs";
|
|
@@ -156,11 +157,27 @@ export async function runHostLifecycleHook(connection, eventName, {
|
|
|
156
157
|
const hookInput = input ?? await readHookInput();
|
|
157
158
|
const session = hostSession(hookInput);
|
|
158
159
|
if (!session) return { handled: true, unavailable: "missing_host_session" };
|
|
159
|
-
const
|
|
160
|
+
const capture = await captureStateSnapshot(root, connection.installationId);
|
|
161
|
+
const runtime = runtimeFactory(connection, { root, captureGeneration: capture.generation });
|
|
162
|
+
if (capture.paused) {
|
|
163
|
+
if (START_EVENTS.has(eventName)) {
|
|
164
|
+
await syncSkillsAtSessionStart(runtime, connection, root, stderr, { home, env });
|
|
165
|
+
// Capture pause does not suspend governed instruction delivery.
|
|
166
|
+
await runtime.syncInstructions?.().catch(() => {});
|
|
167
|
+
await runtime.heartbeat(connection.capabilities || {});
|
|
168
|
+
} else if (USER_EVENTS.has(eventName)) {
|
|
169
|
+
const delivery = await syncManagedDelivery({ connection, transport: runtime.transport, root, home, env, stderr, phase: "turn" });
|
|
170
|
+
const output = recallOutput(connection.clientKind, eventName, null, delivery.notice);
|
|
171
|
+
if (output) stdout.write(output);
|
|
172
|
+
}
|
|
173
|
+
return { handled: true, paused: true };
|
|
174
|
+
}
|
|
160
175
|
|
|
161
176
|
if (START_EVENTS.has(eventName)) {
|
|
162
177
|
// Refresh/withdraw local context even when replay or heartbeat fails.
|
|
163
178
|
await syncSkillsAtSessionStart(runtime, connection, root, stderr, { home, env });
|
|
179
|
+
// Instructions have their own brownout boundary, independent of recall.
|
|
180
|
+
await runtime.syncInstructions?.().catch(() => {});
|
|
164
181
|
await runtime.replay();
|
|
165
182
|
await runtime.heartbeat(connection.capabilities || {});
|
|
166
183
|
if (RECALL_INJECTION_ENABLED) {
|
package/src/install.mjs
CHANGED
|
@@ -4,7 +4,9 @@ import { chmod, cp, rename, rm, unlink } from "node:fs/promises";
|
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { generateInstallationKeyPair } from "./crypto.mjs";
|
|
6
6
|
import { ConnectionStore, defaultRuntimeDirectory, ensurePrivateDirectory, readJson, writePrivateFile } from "./storage.mjs";
|
|
7
|
+
import { syncAgentInstructions } from "./instructions.mjs";
|
|
7
8
|
import { SignedRuntimeTransport } from "./transport.mjs";
|
|
9
|
+
import { inspectCaptureHealth } from "./health.mjs";
|
|
8
10
|
import { INSTALLER_VERSION, RUNTIME_VERSION } from "./version.mjs";
|
|
9
11
|
import { CLIENT_KINDS, CLIENT_REGISTRY, lifecycleClient } from "./client-registry.mjs";
|
|
10
12
|
import { syncManagedSkills } from "./skills-sync.mjs";
|
|
@@ -151,6 +153,7 @@ export async function installLocalConnection({
|
|
|
151
153
|
root = defaultRuntimeDirectory(),
|
|
152
154
|
fetchImpl = globalThis.fetch,
|
|
153
155
|
sendHeartbeat = true,
|
|
156
|
+
instructionProfile,
|
|
154
157
|
}) {
|
|
155
158
|
if (!CLIENT_KINDS.includes(clientKind)) throw new Error("the selected lifecycle adapter is not packaged");
|
|
156
159
|
const client = lifecycleClient(clientKind);
|
|
@@ -201,6 +204,8 @@ export async function installLocalConnection({
|
|
|
201
204
|
pluginVersion: RUNTIME_VERSION,
|
|
202
205
|
proofStorage,
|
|
203
206
|
installedAt: new Date().toISOString(),
|
|
207
|
+
...(typeof consumed.namespace === "string" ? { namespace: consumed.namespace } : {}),
|
|
208
|
+
...(instructionProfile ? { instructionProfile } : {}),
|
|
204
209
|
};
|
|
205
210
|
await store.save(connection);
|
|
206
211
|
await writePrivateFile(join(root, `active-${clientKind}.json`), `${JSON.stringify({
|
|
@@ -217,6 +222,7 @@ export async function installLocalConnection({
|
|
|
217
222
|
try {
|
|
218
223
|
await new SignedRuntimeTransport(connection, { fetchImpl }).heartbeat(client.capabilities, {
|
|
219
224
|
pluginVersion: RUNTIME_VERSION,
|
|
225
|
+
health: await inspectCaptureHealth(connection, root),
|
|
220
226
|
proofStorage,
|
|
221
227
|
diagnostics: { queueDepth: 0, oldestPendingAt: null, expiredCount: 0 },
|
|
222
228
|
});
|
|
@@ -225,9 +231,13 @@ export async function installLocalConnection({
|
|
|
225
231
|
heartbeat = false;
|
|
226
232
|
}
|
|
227
233
|
}
|
|
234
|
+
const instructions = instructionProfile
|
|
235
|
+
? await syncAgentInstructions(connection, { root, transport: new SignedRuntimeTransport(connection, { fetchImpl, timeoutMs: 2_000 }) })
|
|
236
|
+
: undefined;
|
|
228
237
|
return {
|
|
229
238
|
installationId,
|
|
230
239
|
previousInstallationId: connection.previousInstallationId ?? null,
|
|
240
|
+
...(instructions ? { instructions } : {}),
|
|
231
241
|
heartbeat,
|
|
232
242
|
reused: false,
|
|
233
243
|
proofStorage: connection.proofStorage,
|
|
@@ -266,6 +276,7 @@ export async function heartbeatInstalledConnection({
|
|
|
266
276
|
const connection = await new ConnectionStore(root).load(installationId);
|
|
267
277
|
await new SignedRuntimeTransport(connection, { fetchImpl }).heartbeat(connection.capabilities, {
|
|
268
278
|
pluginVersion: connection.pluginVersion,
|
|
279
|
+
health: await inspectCaptureHealth(connection, root),
|
|
269
280
|
proofStorage: connection.proofStorage,
|
|
270
281
|
diagnostics: { queueDepth: 0, oldestPendingAt: null, expiredCount: 0 },
|
|
271
282
|
});
|
package/src/installer-cli.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { recordHealthTargets } from "./health.mjs";
|
|
1
2
|
import { spawnSync } from "node:child_process";
|
|
2
3
|
import { existsSync, readdirSync } from "node:fs";
|
|
3
4
|
import { createInterface } from "node:readline/promises";
|
|
@@ -16,6 +17,7 @@ import { configureClaudeProject } from "./claude-config.mjs";
|
|
|
16
17
|
import { configureCline, configureCodex, configureCursor, configureGemini, configureKimi, configureVscode } from "./host-config.mjs";
|
|
17
18
|
import { CLIENT_KINDS, lifecycleClient } from "./client-registry.mjs";
|
|
18
19
|
import { defaultRuntimeDirectory } from "./storage.mjs";
|
|
20
|
+
import { instructionProfile } from "./instructions.mjs";
|
|
19
21
|
import { DISCLOSURE_VERSION, INSTALLER_VERSION } from "./version.mjs";
|
|
20
22
|
import { managedSkillsDirectory } from "./skills-sync.mjs";
|
|
21
23
|
|
|
@@ -195,9 +197,9 @@ export function disclosureText({ serverUrl, projectRoot, clientKind, clientVersi
|
|
|
195
197
|
? "This reviewed host surface can report complete coverage when all declared evidence is observed."
|
|
196
198
|
: `This host has partial coverage (${client.reason}); missing categories remain explicit.`,
|
|
197
199
|
"Authorized organization managers may review retained conversations and summaries.",
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
200
|
+
"This reads and writes its managed skill folders and instruction sections; it does not scan historical files, other applications, clipboard, keystrokes, or other agents.",
|
|
201
|
+
"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.",
|
|
202
|
+
"Other OS accounts, containers, remote/cloud hosts and unsupported agents are not covered. Instruction precedence remains controlled by the host.",
|
|
201
203
|
"Disconnecting stops future capture but does not erase retained data.",
|
|
202
204
|
`Disclosure: ${DISCLOSURE_VERSION}`,
|
|
203
205
|
].join("\n");
|
|
@@ -250,8 +252,9 @@ export function allDisclosureText({ serverUrl, projectRoot, organization = null,
|
|
|
250
252
|
lines.push(
|
|
251
253
|
"",
|
|
252
254
|
"Authorized organization managers may review retained conversations and summaries.",
|
|
253
|
-
"This reads and writes
|
|
254
|
-
"
|
|
255
|
+
"This reads and writes its managed skill folders and instruction sections; it does not scan historical files, other applications, clipboard, keystrokes, or other agents.",
|
|
256
|
+
"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.",
|
|
257
|
+
"Other OS accounts, containers, remote/cloud hosts and unsupported agents are not covered. Instruction precedence remains controlled by the host.",
|
|
255
258
|
"Disconnecting stops future capture but does not erase retained data.",
|
|
256
259
|
`Disclosure: ${DISCLOSURE_VERSION}`,
|
|
257
260
|
);
|
|
@@ -354,6 +357,7 @@ async function runAllInstaller(input, {
|
|
|
354
357
|
root,
|
|
355
358
|
fetchImpl,
|
|
356
359
|
sendHeartbeat: false,
|
|
360
|
+
instructionProfile: instructionProfile(host.clientKind, skillsHome ? { home: skillsHome } : {}),
|
|
357
361
|
});
|
|
358
362
|
const configured = await configureHost(host.clientKind, {
|
|
359
363
|
projectRoot: input.projectRoot,
|
|
@@ -361,6 +365,7 @@ async function runAllInstaller(input, {
|
|
|
361
365
|
serverUrl: input.serverUrl,
|
|
362
366
|
runtimePath: bundle.runtimePath,
|
|
363
367
|
}, { claudeConfigPath });
|
|
368
|
+
await recordHealthTargets({ root, installationId: installed.installationId, configured, runtimePath: bundle.runtimePath });
|
|
364
369
|
let heartbeat = false;
|
|
365
370
|
try {
|
|
366
371
|
heartbeat = await heartbeatInstalledConnection({
|
|
@@ -383,6 +388,7 @@ async function runAllInstaller(input, {
|
|
|
383
388
|
conflicts: skills.conflicts.map((c) => c.skillKey), errors: skills.errors.length }
|
|
384
389
|
: null,
|
|
385
390
|
proofStorage: installed.proofStorage,
|
|
391
|
+
instructions: installed.instructions,
|
|
386
392
|
configuredPaths: configured.configuredPaths || [configured.mcpPath, configured.settingsPath],
|
|
387
393
|
replacedLegacyMcpEntries: configured.replacedLegacyMcpEntries || 0,
|
|
388
394
|
nextStep: `Restart ${lifecycleClient(host.clientKind).label}, then check the connection in Halofy.`,
|
|
@@ -448,6 +454,7 @@ export async function runInstaller(argv, {
|
|
|
448
454
|
root,
|
|
449
455
|
fetchImpl,
|
|
450
456
|
sendHeartbeat: false,
|
|
457
|
+
instructionProfile: instructionProfile(input.clientKind, skillsHome ? { home: skillsHome } : {}),
|
|
451
458
|
});
|
|
452
459
|
const bundle = await installRuntimeBundle({ root, ...(sourceRoot ? { sourceRoot } : {}) });
|
|
453
460
|
const common = {
|
|
@@ -457,6 +464,7 @@ export async function runInstaller(argv, {
|
|
|
457
464
|
runtimePath: bundle.runtimePath,
|
|
458
465
|
};
|
|
459
466
|
const configured = await configureHost(input.clientKind, common, { claudeConfigPath });
|
|
467
|
+
await recordHealthTargets({ root, installationId: installed.installationId, configured, runtimePath: bundle.runtimePath });
|
|
460
468
|
let heartbeat = false;
|
|
461
469
|
try {
|
|
462
470
|
heartbeat = await heartbeatInstalledConnection({
|
|
@@ -477,6 +485,7 @@ export async function runInstaller(argv, {
|
|
|
477
485
|
installerVersion: INSTALLER_VERSION,
|
|
478
486
|
publishedPackage: true,
|
|
479
487
|
projectConfigured: true,
|
|
488
|
+
instructions: installed.instructions,
|
|
480
489
|
configuredPaths: configured.configuredPaths || [configured.mcpPath, configured.settingsPath],
|
|
481
490
|
replacedLegacyMcpEntries: configured.replacedLegacyMcpEntries || 0,
|
|
482
491
|
mcpConfiguration: localMcpSnippet({
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { lstat, mkdir, open, rename, unlink } from "node:fs/promises";
|
|
3
|
+
import { dirname, isAbsolute, join, parse, resolve, sep } from "node:path";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
6
|
+
import { isActiveInstallation } from "./active.mjs";
|
|
7
|
+
import { ConnectionStore, readJson } from "./storage.mjs";
|
|
8
|
+
import { SignedRuntimeTransport } from "./transport.mjs";
|
|
9
|
+
|
|
10
|
+
const sha = (value) => createHash("sha256").update(value).digest("hex");
|
|
11
|
+
const HEX = /^[a-f0-9]{64}$/;
|
|
12
|
+
const MARKER = "<!-- HALOFY-INSTRUCTIONS:";
|
|
13
|
+
const fail = (code) => { throw Object.assign(new Error(code), { instructionCode: code }); };
|
|
14
|
+
const plain = (value) => value && typeof value === "object" && !Array.isArray(value);
|
|
15
|
+
const scope = (value) => typeof value === "string" && value.length <= 1024 && !/[\u0000-\u001f\u007f]/.test(value) &&
|
|
16
|
+
(value === "" || value.split("/").every((part) => part && part !== "." && part !== ".."));
|
|
17
|
+
|
|
18
|
+
/** Local consent/profile snapshot; never derive a writable path from a server response. */
|
|
19
|
+
export function instructionProfile(clientKind, { home = homedir(), env = process.env } = {}) {
|
|
20
|
+
const roots = {
|
|
21
|
+
codex: env.CODEX_HOME || join(home, ".codex"),
|
|
22
|
+
"claude-code": env.CLAUDE_CONFIG_DIR || join(home, ".claude"),
|
|
23
|
+
"gemini-cli": join(env.GEMINI_CLI_HOME || home, ".gemini"),
|
|
24
|
+
cursor: join(home, ".cursor"),
|
|
25
|
+
};
|
|
26
|
+
return { version: 1, consent: "organization-instructions-v1", clientKind, root: roots[clientKind] ? resolve(roots[clientKind]) : null };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function validateInstructionBundle(bundle, expectedNamespace) {
|
|
30
|
+
if (!plain(bundle) || bundle.schemaVersion !== 1 || !scope(expectedNamespace) ||
|
|
31
|
+
bundle.namespace !== expectedNamespace || !HEX.test(bundle.digest) || !Array.isArray(bundle.documents) ||
|
|
32
|
+
Buffer.byteLength(JSON.stringify(bundle)) > 64 * 1024) fail("invalid_bundle");
|
|
33
|
+
let previous = null;
|
|
34
|
+
const documents = bundle.documents.map((doc) => {
|
|
35
|
+
if (!plain(doc) || typeof doc.id !== "string" || !/^[A-Za-z0-9_-]{1,160}$/.test(doc.id) || !scope(doc.namespace) ||
|
|
36
|
+
!(doc.namespace === "" || doc.namespace === expectedNamespace || expectedNamespace.startsWith(`${doc.namespace}/`)) ||
|
|
37
|
+
(previous !== null && !(doc.namespace !== previous && (previous === "" || doc.namespace.startsWith(`${previous}/`)))) ||
|
|
38
|
+
!Number.isSafeInteger(doc.version) || doc.version < 1 || typeof doc.title !== "string" || !doc.title.trim() || doc.title.length > 160 || doc.title.includes(MARKER) ||
|
|
39
|
+
/[\u0000-\u001f\u007f]/.test(doc.title) || typeof doc.content !== "string" || !doc.content.trim() ||
|
|
40
|
+
Buffer.from(doc.title, "utf8").toString("utf8") !== doc.title || Buffer.from(doc.content, "utf8").toString("utf8") !== doc.content ||
|
|
41
|
+
doc.content.includes("\0") || doc.content.includes(MARKER) || Buffer.byteLength(doc.content) > 16 * 1024 ||
|
|
42
|
+
!HEX.test(doc.sha256) || sha(doc.content) !== doc.sha256) fail("invalid_bundle");
|
|
43
|
+
previous = doc.namespace;
|
|
44
|
+
return { id: doc.id, namespace: doc.namespace, version: doc.version, title: doc.title, content: doc.content, sha256: doc.sha256 };
|
|
45
|
+
});
|
|
46
|
+
if (sha(JSON.stringify(documents)) !== bundle.digest) fail("invalid_bundle");
|
|
47
|
+
return { ...bundle, documents };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Inspect every existing path component, including the final file. mkdir is
|
|
51
|
+
// deliberately incremental: recursive mkdir can follow a linked ancestor.
|
|
52
|
+
async function safePath(path, { createParents = false } = {}) {
|
|
53
|
+
if (typeof path !== "string" || !isAbsolute(path) || resolve(path) !== path || path.includes("\0")) fail("unsafe_path");
|
|
54
|
+
const parsed = parse(path);
|
|
55
|
+
const parts = path.slice(parsed.root.length).split(sep).filter(Boolean);
|
|
56
|
+
let current = parsed.root;
|
|
57
|
+
const chain = [];
|
|
58
|
+
for (let i = 0; i < parts.length; i++) {
|
|
59
|
+
current = join(current, parts[i]);
|
|
60
|
+
let info;
|
|
61
|
+
try { info = await lstat(current); } catch (error) {
|
|
62
|
+
if (error.code !== "ENOENT") throw error;
|
|
63
|
+
if (createParents && i < parts.length - 1) {
|
|
64
|
+
try { await mkdir(current, { mode: 0o700 }); } catch (made) { if (made.code !== "EEXIST") throw made; }
|
|
65
|
+
info = await lstat(current);
|
|
66
|
+
} else return chain;
|
|
67
|
+
}
|
|
68
|
+
if (info.isSymbolicLink() || (i < parts.length - 1 ? !info.isDirectory() : !info.isFile() && !info.isDirectory())) fail("unsafe_path");
|
|
69
|
+
chain.push(`${current}:${info.dev}:${info.ino}`);
|
|
70
|
+
}
|
|
71
|
+
return chain;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function snapshot(path) {
|
|
75
|
+
await safePath(path);
|
|
76
|
+
let handle;
|
|
77
|
+
try {
|
|
78
|
+
handle = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW || 0));
|
|
79
|
+
const info = await handle.stat();
|
|
80
|
+
if (!info.isFile() || info.nlink !== 1 || info.size > 1024 * 1024) fail("unsafe_file");
|
|
81
|
+
const bytes = await handle.readFile();
|
|
82
|
+
return { bytes, mode: info.mode & 0o777, identity: `${info.dev}:${info.ino}:${info.mtimeMs}:${info.ctimeMs}`, exists: true };
|
|
83
|
+
} catch (error) {
|
|
84
|
+
if (error.code === "ENOENT") return { bytes: Buffer.alloc(0), mode: 0o600, identity: null, exists: false };
|
|
85
|
+
throw error;
|
|
86
|
+
} finally { await handle?.close(); }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function atomic(path, bytes, prior) {
|
|
90
|
+
const ancestors = await safePath(path, { createParents: true });
|
|
91
|
+
const temp = `${path}.${process.pid}.${randomBytes(10).toString("hex")}.tmp`;
|
|
92
|
+
const handle = await open(temp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW || 0), prior.mode);
|
|
93
|
+
try {
|
|
94
|
+
await handle.writeFile(bytes);
|
|
95
|
+
await handle.sync();
|
|
96
|
+
await handle.close();
|
|
97
|
+
const now = await snapshot(path);
|
|
98
|
+
if (now.identity !== prior.identity || !now.bytes.equals(prior.bytes) ||
|
|
99
|
+
JSON.stringify(await safePath(path)) !== JSON.stringify(ancestors)) fail("concurrent_edit");
|
|
100
|
+
await rename(temp, path);
|
|
101
|
+
} finally {
|
|
102
|
+
await handle.close().catch(() => {});
|
|
103
|
+
await unlink(temp).catch((error) => { if (error.code !== "ENOENT") throw error; });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function targetFor(connection, owner) {
|
|
108
|
+
const profile = connection.instructionProfile;
|
|
109
|
+
if (!plain(profile) || profile.version !== 1 || profile.consent !== "organization-instructions-v1" ||
|
|
110
|
+
profile.clientKind !== connection.clientKind) fail("reinstall_required");
|
|
111
|
+
if (!["codex", "claude-code", "gemini-cli", "cursor"].includes(connection.clientKind)) return null;
|
|
112
|
+
const root = profile.root;
|
|
113
|
+
await safePath(root);
|
|
114
|
+
if (connection.clientKind === "codex") {
|
|
115
|
+
const override = join(root, "AGENTS.override.md");
|
|
116
|
+
const file = await snapshot(override);
|
|
117
|
+
return { path: file.bytes.toString("utf8").trim() ? override : join(root, "AGENTS.md"), dedicated: false };
|
|
118
|
+
}
|
|
119
|
+
if (connection.clientKind === "gemini-cli") {
|
|
120
|
+
const settings = await snapshot(join(root, "settings.json"));
|
|
121
|
+
let parsed;
|
|
122
|
+
try { parsed = settings.exists ? JSON.parse(settings.bytes.toString("utf8")) : {}; } catch { fail("host_settings_invalid"); }
|
|
123
|
+
const filename = parsed.context?.fileName;
|
|
124
|
+
// A non-default discovery configuration can be overridden again by project
|
|
125
|
+
// settings. Do not guess a writable global filename or change host settings.
|
|
126
|
+
if (filename !== undefined && filename !== "GEMINI.md" &&
|
|
127
|
+
!(Array.isArray(filename) && filename.includes("GEMINI.md"))) fail("custom_context_unsupported");
|
|
128
|
+
return { path: join(root, "GEMINI.md"), dedicated: false };
|
|
129
|
+
}
|
|
130
|
+
return { path: join(root, "rules", `halofy-${owner}.${connection.clientKind === "cursor" ? "mdc" : "md"}`), dedicated: true };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function render(bundle, owner, cursor) {
|
|
134
|
+
const begin = `${MARKER}${owner}:BEGIN -->`;
|
|
135
|
+
const end = `${MARKER}${owner}:END -->`;
|
|
136
|
+
const content = bundle.documents.map((doc) => `## ${doc.title}\n\n${doc.content}`).join("\n\n");
|
|
137
|
+
const text = `${cursor ? "---\nalwaysApply: true\n---\n" : ""}${begin}\n# Organization agent instructions\n\n${content}\n${end}\n`;
|
|
138
|
+
return { begin: Buffer.from(begin), end: Buffer.from(end), bytes: Buffer.from(text) };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Writes only owned bytes. Any ambiguity leaves the host file unchanged. */
|
|
142
|
+
export async function applyInstructionBundle(connection, bundle, { root, beforeWrite, canActivate = async () => true } = {}) {
|
|
143
|
+
bundle = validateInstructionBundle(bundle, connection.namespace);
|
|
144
|
+
const owner = sha(`${connection.serverUrl}\0${connection.namespace}`).slice(0, 24);
|
|
145
|
+
const target = await targetFor(connection, owner);
|
|
146
|
+
if (!target) return { digest: bundle.digest, status: "unsupported", reason: "host_unsupported" };
|
|
147
|
+
const stateKey = sha(`${connection.clientKind}\0${connection.instructionProfile.root}\0${owner}`);
|
|
148
|
+
const statePath = join(resolve(root), "instructions", `${stateKey}.json`);
|
|
149
|
+
await safePath(statePath, { createParents: true });
|
|
150
|
+
// Profile-level lock remains the same if Codex switches its override file.
|
|
151
|
+
const lockPath = join(connection.instructionProfile.root, ".halofy-instructions.lock");
|
|
152
|
+
await safePath(lockPath, { createParents: true });
|
|
153
|
+
let lock;
|
|
154
|
+
try {
|
|
155
|
+
try { lock = await open(lockPath, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW || 0), 0o600); }
|
|
156
|
+
catch (error) { if (error.code === "EEXIST") fail("sync_busy"); throw error; }
|
|
157
|
+
if (!(await canActivate())) fail("inactive_installation");
|
|
158
|
+
const initial = await snapshot(target.path);
|
|
159
|
+
const stateSnapshot = await snapshot(statePath);
|
|
160
|
+
let state;
|
|
161
|
+
try { state = stateSnapshot.exists ? JSON.parse(stateSnapshot.bytes.toString("utf8")) : null; } catch { fail("ownership_invalid"); }
|
|
162
|
+
if (state && (state.version !== 1 || state.path !== target.path || state.owner !== owner ||
|
|
163
|
+
typeof state.managedHash !== "string" || !HEX.test(state.managedHash))) fail("ownership_conflict");
|
|
164
|
+
if (state?.removed) {
|
|
165
|
+
if (target.dedicated && sha(initial.bytes) !== state.managedHash) fail("managed_content_changed");
|
|
166
|
+
if (!target.dedicated && initial.bytes.includes(Buffer.from(`${MARKER}${owner}:`))) fail("managed_content_changed");
|
|
167
|
+
if (bundle.documents.length === 0) return { digest: bundle.digest, status: "pending_restart", reason: "no_managed_content" };
|
|
168
|
+
state = null;
|
|
169
|
+
}
|
|
170
|
+
const rendered = render(bundle, owner, connection.clientKind === "cursor");
|
|
171
|
+
const bytes = initial.bytes;
|
|
172
|
+
const start = bytes.indexOf(rendered.begin);
|
|
173
|
+
const end = bytes.indexOf(rendered.end);
|
|
174
|
+
let prefix = bytes;
|
|
175
|
+
let suffix = Buffer.alloc(0);
|
|
176
|
+
let separator = "";
|
|
177
|
+
if (state) {
|
|
178
|
+
if (target.dedicated) {
|
|
179
|
+
if (!initial.exists || sha(bytes) !== state.managedHash) fail("managed_content_changed");
|
|
180
|
+
prefix = Buffer.alloc(0);
|
|
181
|
+
} else {
|
|
182
|
+
if (start < 0 || end < start || bytes.indexOf(rendered.begin, start + 1) !== -1 ||
|
|
183
|
+
bytes.indexOf(rendered.end, end + 1) !== -1) fail("markers_invalid");
|
|
184
|
+
const managedEnd = end + rendered.end.length + 1;
|
|
185
|
+
if (bytes[managedEnd - 1] !== 10 || sha(bytes.subarray(start, managedEnd)) !== state.managedHash) fail("managed_content_changed");
|
|
186
|
+
separator = state.separator === "\n\n" ? state.separator : "";
|
|
187
|
+
const prefixEnd = start - Buffer.byteLength(separator);
|
|
188
|
+
if (prefixEnd < 0 || bytes.subarray(prefixEnd, start).toString() !== separator) fail("managed_content_changed");
|
|
189
|
+
prefix = bytes.subarray(0, prefixEnd);
|
|
190
|
+
suffix = bytes.subarray(managedEnd);
|
|
191
|
+
}
|
|
192
|
+
} else if (target.dedicated && initial.exists && initial.bytes.length > 0 || start >= 0 || end >= 0 || bytes.includes(Buffer.from(MARKER))) fail("unowned_content");
|
|
193
|
+
// Refuse malformed owned-looking markers outside the verified region too.
|
|
194
|
+
if (prefix.includes(Buffer.from(MARKER)) || suffix.includes(Buffer.from(MARKER))) fail("markers_invalid");
|
|
195
|
+
const removing = bundle.documents.length === 0;
|
|
196
|
+
if (removing && !state) return { digest: bundle.digest, status: "pending_restart", reason: "no_managed_content" };
|
|
197
|
+
if (!state && !target.dedicated) separator = prefix.length ? "\n\n" : "";
|
|
198
|
+
const updated = removing ? Buffer.concat([prefix, suffix]) : Buffer.concat([prefix, Buffer.from(separator), rendered.bytes, suffix]);
|
|
199
|
+
const next = { version: 1, owner, path: target.path, managedHash: sha(rendered.bytes), separator, digest: bundle.digest };
|
|
200
|
+
if (!updated.equals(bytes)) {
|
|
201
|
+
// Backup lives outside the host's rules directory, so it cannot be loaded
|
|
202
|
+
// as a second rule. Never send backup or filesystem paths to the server.
|
|
203
|
+
const backup = `${statePath}.${Date.now()}.${randomBytes(6).toString("hex")}.bak`;
|
|
204
|
+
await atomic(backup, bytes, await snapshot(backup));
|
|
205
|
+
await beforeWrite?.();
|
|
206
|
+
if (!(await canActivate())) fail("inactive_installation");
|
|
207
|
+
if ((await targetFor(connection, owner))?.path !== target.path) fail("host_profile_changed");
|
|
208
|
+
await atomic(target.path, updated, initial);
|
|
209
|
+
}
|
|
210
|
+
if (removing) {
|
|
211
|
+
// Shared empty files remain to avoid a delete race with personal edits.
|
|
212
|
+
// Dedicated empty files are harmless and no longer include instructions.
|
|
213
|
+
await atomic(statePath, Buffer.from(`${JSON.stringify({ ...next, removed: true, managedHash: sha(updated) })}\n`), stateSnapshot);
|
|
214
|
+
} else {
|
|
215
|
+
await atomic(statePath, Buffer.from(`${JSON.stringify(next)}\n`), stateSnapshot);
|
|
216
|
+
}
|
|
217
|
+
return { digest: bundle.digest, status: "pending_restart", reason: removing ? "managed_content_removed" : "host_reload_required" };
|
|
218
|
+
} finally {
|
|
219
|
+
if (lock) { await lock.close(); await safePath(lockPath); await unlink(lockPath); }
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** A confirmed reconnect retires only verified instruction bytes owned by its
|
|
224
|
+
* predecessors. The complete chain is validated before the first removal.
|
|
225
|
+
* Same-owner retries share the manifest and keep their current instruction copy. */
|
|
226
|
+
async function retirePredecessorInstructions(connection, root) {
|
|
227
|
+
const sameOwner = (left, right) => left.serverUrl === right.serverUrl && left.namespace === right.namespace &&
|
|
228
|
+
left.clientKind === right.clientKind && left.instructionProfile?.root === right.instructionProfile?.root;
|
|
229
|
+
const store = new ConnectionStore(root);
|
|
230
|
+
const visited = new Set([connection.installationId]);
|
|
231
|
+
const predecessors = [];
|
|
232
|
+
let previous = connection.previousInstallationId;
|
|
233
|
+
while (previous) {
|
|
234
|
+
if (typeof previous !== "string" || !/^[A-Za-z0-9_-]{1,128}$/.test(previous) || visited.has(previous) || visited.size > 64) fail("predecessor_invalid");
|
|
235
|
+
visited.add(previous);
|
|
236
|
+
const prior = await readJson(store.path(previous));
|
|
237
|
+
if (!prior || prior.clientKind !== connection.clientKind) fail("predecessor_invalid");
|
|
238
|
+
predecessors.push(prior);
|
|
239
|
+
previous = prior.previousInstallationId;
|
|
240
|
+
}
|
|
241
|
+
const retired = [];
|
|
242
|
+
for (const prior of predecessors) {
|
|
243
|
+
if (!prior.instructionProfile || sameOwner(prior, connection) || retired.some((entry) => sameOwner(entry, prior))) continue;
|
|
244
|
+
const empty = { schemaVersion: 1, namespace: prior.namespace, digest: sha("[]"), documents: [] };
|
|
245
|
+
await applyInstructionBundle(prior, empty, { root, canActivate: () => isActiveInstallation(connection, root) });
|
|
246
|
+
retired.push(prior);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Separate best-effort operation: a failed GET never means remove instructions. */
|
|
251
|
+
export async function syncAgentInstructions(connection, { root, transport = new SignedRuntimeTransport(connection, { timeoutMs: 2_000 }) } = {}) {
|
|
252
|
+
let receipt = { digest: sha("[]"), status: "unavailable", reason: "fetch_unavailable" };
|
|
253
|
+
try {
|
|
254
|
+
if (!(await isActiveInstallation(connection, root))) return { ...receipt, status: "unsupported", reason: "inactive_installation" };
|
|
255
|
+
await retirePredecessorInstructions(connection, root);
|
|
256
|
+
const bundle = await transport.instructions();
|
|
257
|
+
receipt.digest = typeof bundle?.digest === "string" && HEX.test(bundle.digest) ? bundle.digest : receipt.digest;
|
|
258
|
+
receipt = await applyInstructionBundle(connection, bundle, { root, canActivate: () => isActiveInstallation(connection, root) });
|
|
259
|
+
} catch (error) {
|
|
260
|
+
const reason = error.instructionCode;
|
|
261
|
+
const unsupported = ["custom_context_unsupported", "reinstall_required", "inactive_installation"].includes(reason);
|
|
262
|
+
receipt = { ...receipt, status: unsupported ? "unsupported" : reason && reason !== "invalid_bundle" ? "conflict" : "unavailable", reason: reason || "fetch_unavailable" };
|
|
263
|
+
}
|
|
264
|
+
try { await transport.instructionStatus(receipt); } catch { /* A receipt failure cannot disable capture/MCP. */ }
|
|
265
|
+
return receipt;
|
|
266
|
+
}
|
package/src/mcp-proxy.mjs
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { LifecycleRuntime } from "./runtime.mjs";
|
|
2
|
+
import { defaultRuntimeDirectory } from "./storage.mjs";
|
|
3
|
+
import { startHealthHeartbeat } from "./health.mjs";
|
|
1
4
|
import { createInterface } from "node:readline";
|
|
2
5
|
import { SignedRuntimeTransport } from "./transport.mjs";
|
|
3
6
|
|
|
@@ -24,34 +27,45 @@ export async function runStdioMcpProxy(connection, {
|
|
|
24
27
|
input = process.stdin,
|
|
25
28
|
output = process.stdout,
|
|
26
29
|
fetchImpl = globalThis.fetch,
|
|
30
|
+
root = defaultRuntimeDirectory(),
|
|
31
|
+
heartbeatOptions,
|
|
27
32
|
} = {}) {
|
|
33
|
+
const healthController = new AbortController();
|
|
34
|
+
const healthRuntime = new LifecycleRuntime(connection, { root, transport: new SignedRuntimeTransport(connection, { fetchImpl, timeoutMs: 5_000, signal: healthController.signal }) });
|
|
35
|
+
const heartbeat = startHealthHeartbeat(healthRuntime, heartbeatOptions);
|
|
28
36
|
const transport = new SignedRuntimeTransport(connection, { fetchImpl, timeoutMs: 30_000 });
|
|
29
37
|
let mcpSessionId = null;
|
|
30
38
|
const lines = createInterface({ input, crlfDelay: Infinity });
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
39
|
+
try {
|
|
40
|
+
for await (const line of lines) {
|
|
41
|
+
if (!line.trim()) continue;
|
|
42
|
+
let parsed;
|
|
43
|
+
try { parsed = JSON.parse(line); } catch { continue; }
|
|
44
|
+
try {
|
|
45
|
+
const response = await transport.request("/mcp", {
|
|
46
|
+
body: JSON.stringify(parsed),
|
|
47
|
+
headers: {
|
|
48
|
+
Accept: "application/json, text/event-stream",
|
|
49
|
+
...(mcpSessionId ? { "Mcp-Session-Id": mcpSessionId } : {}),
|
|
50
|
+
},
|
|
51
|
+
raw: true,
|
|
52
|
+
});
|
|
53
|
+
mcpSessionId = response.headers.get("mcp-session-id") || mcpSessionId;
|
|
54
|
+
await relayResponse(response, parsed, output);
|
|
55
|
+
} catch {
|
|
56
|
+
if (parsed.id !== undefined) {
|
|
57
|
+
output.write(`${JSON.stringify({
|
|
58
|
+
jsonrpc: "2.0",
|
|
59
|
+
id: parsed.id,
|
|
60
|
+
error: { code: -32000, message: "Halofy MCP transport unavailable" },
|
|
61
|
+
})}\n`);
|
|
62
|
+
}
|
|
53
63
|
}
|
|
54
64
|
}
|
|
65
|
+
} finally {
|
|
66
|
+
heartbeat.stop();
|
|
67
|
+
healthController.abort();
|
|
68
|
+
lines.close();
|
|
55
69
|
}
|
|
56
70
|
}
|
|
57
71
|
|
package/src/queue.mjs
CHANGED
|
@@ -141,10 +141,12 @@ export class BoundedEncryptedQueue {
|
|
|
141
141
|
maxBatchEvents = 100,
|
|
142
142
|
maxBatchBytes = 1024 * 1024,
|
|
143
143
|
acknowledgedSequence = 0,
|
|
144
|
+
canEnqueue,
|
|
144
145
|
} = {}) {
|
|
145
146
|
return withFileLock(this.lockPath, async () => {
|
|
146
147
|
const state = await this.#read();
|
|
147
148
|
this.#sweep(state);
|
|
149
|
+
if (canEnqueue && !await canEnqueue()) return { queued: 0, skipped: true };
|
|
148
150
|
const pending = state.items.filter((item) => item.sessionHash === sessionHash).flatMap((item) => item.events || []);
|
|
149
151
|
const existingKeys = new Set(pending.map((event) => event.eventKey));
|
|
150
152
|
let sequence = pending.reduce(
|
package/src/runtime.mjs
CHANGED
|
@@ -8,19 +8,26 @@ import {
|
|
|
8
8
|
import { claudeTranscriptDriver } from "./transcript-drivers/claude.mjs";
|
|
9
9
|
import { SignedRuntimeTransport } from "./transport.mjs";
|
|
10
10
|
import { readJson, withFileLock, writePrivateFile } from "./storage.mjs";
|
|
11
|
+
import { capturePaused, captureStateSnapshot, captureAdmission, inspectCaptureHealth } from "./health.mjs";
|
|
12
|
+
import { syncAgentInstructions } from "./instructions.mjs";
|
|
11
13
|
import { RUNTIME_VERSION } from "./version.mjs";
|
|
12
14
|
|
|
13
15
|
export class LifecycleRuntime {
|
|
14
16
|
constructor(connection, {
|
|
15
17
|
root,
|
|
16
18
|
transport = new SignedRuntimeTransport(connection),
|
|
19
|
+
instructionTransport = new SignedRuntimeTransport(connection, { timeoutMs: 2_000 }),
|
|
17
20
|
maxBatchEvents = 100,
|
|
18
21
|
maxBatchBytes = 1024 * 1024,
|
|
19
22
|
commitThreshold = 20,
|
|
23
|
+
captureGeneration,
|
|
20
24
|
} = {}) {
|
|
21
25
|
if (!root) throw new Error("runtime storage root is required");
|
|
22
26
|
this.connection = connection;
|
|
27
|
+
this.root = root;
|
|
28
|
+
this.captureGeneration = captureGeneration;
|
|
23
29
|
this.transport = transport;
|
|
30
|
+
this.instructionTransport = instructionTransport;
|
|
24
31
|
const connectionRoot = join(root, connection.installationId);
|
|
25
32
|
this.queue = new BoundedEncryptedQueue(connectionRoot);
|
|
26
33
|
this.cursors = new CursorStore(connectionRoot);
|
|
@@ -32,6 +39,13 @@ export class LifecycleRuntime {
|
|
|
32
39
|
this.sessions = new Map();
|
|
33
40
|
}
|
|
34
41
|
|
|
42
|
+
async syncInstructions() {
|
|
43
|
+
// Legacy bindings have no consent/profile snapshot. Reinstall the reviewed
|
|
44
|
+
// package to enable global instruction management.
|
|
45
|
+
if (!this.connection.instructionProfile) return { status: "unsupported", reason: "reinstall_required" };
|
|
46
|
+
return syncAgentInstructions(this.connection, { root: this.root, transport: this.instructionTransport });
|
|
47
|
+
}
|
|
48
|
+
|
|
35
49
|
sessionHash(hostSessionId) {
|
|
36
50
|
return deriveSessionHash({
|
|
37
51
|
installationId: this.connection.installationId,
|
|
@@ -115,18 +129,23 @@ export class LifecycleRuntime {
|
|
|
115
129
|
}
|
|
116
130
|
|
|
117
131
|
async captureHostTranscript(hostSessionId, driver, transcriptPath, sessionFacts = {}) {
|
|
132
|
+
const capture = await captureStateSnapshot(this.root, this.connection.installationId);
|
|
133
|
+
if (capture.paused) return { queued: 0, acknowledged: 0, paused: true };
|
|
118
134
|
const sessionHash = this.sessionHash(hostSessionId);
|
|
119
135
|
const policy = await this.capturePolicy();
|
|
120
136
|
const queued = await withFileLock(this.operationLockPath, async () => {
|
|
137
|
+
if (await capturePaused(this.root, this.connection.installationId)) return { queued: 0 };
|
|
121
138
|
const cursor = await this.cursors.get(sessionHash);
|
|
122
|
-
const
|
|
139
|
+
const resumedAt = capture.captureAfter;
|
|
140
|
+
const suffix = await driver.readSuffix(transcriptPath, cursor, sessionHash, { captureAfter: resumedAt });
|
|
123
141
|
const recentEventKeys = new Set(Array.isArray(cursor.recentEventKeys) ? cursor.recentEventKeys : []);
|
|
124
|
-
const unseenEvents = suffix.events.filter((event) => !recentEventKeys.has(event.eventKey)
|
|
142
|
+
const unseenEvents = suffix.events.filter((event) => !recentEventKeys.has(event.eventKey) &&
|
|
143
|
+
(!resumedAt || Date.parse(event.occurredAt) >= Date.parse(resumedAt)));
|
|
125
144
|
// One metadata event whenever the observed content-free session facts
|
|
126
145
|
// change. The event key hashes the payload, so an unchanged snapshot is
|
|
127
146
|
// deduplicated exactly like any repeated event.
|
|
128
147
|
const metadataPayload = driver.buildMetadataPayload(
|
|
129
|
-
{ ...suffix.metadata, ...sessionFacts },
|
|
148
|
+
{ ...suffix.metadata, ...(!resumedAt ? sessionFacts : {}) },
|
|
130
149
|
{ installationId: this.connection.installationId, deviceContext: policy.deviceContext },
|
|
131
150
|
);
|
|
132
151
|
if (Object.keys(metadataPayload).length > 0) {
|
|
@@ -141,7 +160,9 @@ export class LifecycleRuntime {
|
|
|
141
160
|
maxBatchEvents: this.maxBatchEvents,
|
|
142
161
|
maxBatchBytes: this.maxBatchBytes,
|
|
143
162
|
acknowledgedSequence: cursor.sequence,
|
|
163
|
+
canEnqueue: () => captureAdmission(this.root, this.connection.installationId, this.captureGeneration === undefined ? capture.generation : this.captureGeneration),
|
|
144
164
|
});
|
|
165
|
+
if (result.skipped) return result;
|
|
145
166
|
// Complete ignored/malformed JSONL records carry no event, while every
|
|
146
167
|
// normalized event is already durable in the encrypted queue. Advancing
|
|
147
168
|
// this byte cursor prevents unbounded reparsing without advancing the
|
|
@@ -166,36 +187,44 @@ export class LifecycleRuntime {
|
|
|
166
187
|
}
|
|
167
188
|
|
|
168
189
|
async enqueueEvents(hostSessionId, events) {
|
|
190
|
+
const capture = await captureStateSnapshot(this.root, this.connection.installationId);
|
|
191
|
+
if (capture.paused) return { queued: 0, acknowledged: 0, paused: true };
|
|
169
192
|
const sessionHash = this.sessionHash(hostSessionId);
|
|
170
193
|
await withFileLock(this.operationLockPath, async () => {
|
|
171
|
-
await this
|
|
194
|
+
if (await capturePaused(this.root, this.connection.installationId)) return;
|
|
195
|
+
await this.#enqueueEventsUnlocked(sessionHash, events, undefined, this.captureGeneration === undefined ? capture.generation : this.captureGeneration);
|
|
172
196
|
});
|
|
173
197
|
return this.replay();
|
|
174
198
|
}
|
|
175
199
|
|
|
176
200
|
async enqueueSequencedEvents(hostSessionId, factory) {
|
|
201
|
+
const capture = await captureStateSnapshot(this.root, this.connection.installationId);
|
|
202
|
+
if (capture.paused) return { queued: 0, acknowledged: 0, paused: true };
|
|
177
203
|
const sessionHash = this.sessionHash(hostSessionId);
|
|
178
204
|
await withFileLock(this.operationLockPath, async () => {
|
|
205
|
+
if (await capturePaused(this.root, this.connection.installationId)) return;
|
|
179
206
|
const cursor = await this.cursors.get(sessionHash);
|
|
180
207
|
const events = factory({ sessionHash, nextSequence: cursor.sequence + 1 });
|
|
181
208
|
if (!Array.isArray(events) || events.length < 1 || events.length > this.maxBatchEvents) {
|
|
182
209
|
throw new Error("sequenced host event batch is invalid");
|
|
183
210
|
}
|
|
184
|
-
await this.#enqueueEventsUnlocked(sessionHash, events, cursor);
|
|
211
|
+
await this.#enqueueEventsUnlocked(sessionHash, events, cursor, this.captureGeneration === undefined ? capture.generation : this.captureGeneration);
|
|
185
212
|
});
|
|
186
213
|
return this.replay();
|
|
187
214
|
}
|
|
188
215
|
|
|
189
|
-
async #enqueueEventsUnlocked(sessionHash, events, knownCursor) {
|
|
216
|
+
async #enqueueEventsUnlocked(sessionHash, events, knownCursor, generation) {
|
|
190
217
|
const cursor = knownCursor || await this.cursors.get(sessionHash);
|
|
191
218
|
const recentEventKeys = new Set(Array.isArray(cursor.recentEventKeys) ? cursor.recentEventKeys : []);
|
|
192
219
|
const unseenEvents = events.filter((event) => !recentEventKeys.has(event.eventKey));
|
|
193
220
|
if (unseenEvents.length === 0) return;
|
|
194
|
-
await this.queue.enqueueSessionEvents(sessionHash, unseenEvents, {
|
|
221
|
+
const result = await this.queue.enqueueSessionEvents(sessionHash, unseenEvents, {
|
|
195
222
|
maxBatchEvents: this.maxBatchEvents,
|
|
196
223
|
maxBatchBytes: this.maxBatchBytes,
|
|
197
224
|
acknowledgedSequence: cursor.sequence,
|
|
225
|
+
canEnqueue: () => captureAdmission(this.root, this.connection.installationId, generation),
|
|
198
226
|
});
|
|
227
|
+
if (result.skipped) return;
|
|
199
228
|
await this.cursors.rememberEventKeys(sessionHash, unseenEvents.map((event) => event.eventKey));
|
|
200
229
|
}
|
|
201
230
|
|
|
@@ -290,6 +319,7 @@ export class LifecycleRuntime {
|
|
|
290
319
|
const queue = await this.queue.diagnostics();
|
|
291
320
|
const usageGaps = await this.cursors.peekUsageGaps();
|
|
292
321
|
const response = await this.transport.heartbeat(capabilities, {
|
|
322
|
+
health: await inspectCaptureHealth(this.connection, this.root),
|
|
293
323
|
pluginVersion: this.connection.pluginVersion || `${RUNTIME_VERSION}-local`,
|
|
294
324
|
proofStorage: this.connection.proofStorage || "unknown",
|
|
295
325
|
diagnostics: {
|
package/src/session.mjs
CHANGED
|
@@ -664,7 +664,7 @@ function collectClaudeMetadata(metadata, entry) {
|
|
|
664
664
|
if (cwd) metadata.cwd = cwd;
|
|
665
665
|
}
|
|
666
666
|
|
|
667
|
-
export async function readClaudeTranscriptSuffix(path, cursor, sessionHash) {
|
|
667
|
+
export async function readClaudeTranscriptSuffix(path, cursor, sessionHash, { captureAfter } = {}) {
|
|
668
668
|
const bytes = await readFile(path);
|
|
669
669
|
const start = Number.isSafeInteger(cursor?.byteOffset) && cursor.byteOffset <= bytes.length ? cursor.byteOffset : 0;
|
|
670
670
|
const suffix = bytes.subarray(start);
|
|
@@ -691,6 +691,10 @@ export async function readClaudeTranscriptSuffix(path, cursor, sessionHash) {
|
|
|
691
691
|
};
|
|
692
692
|
try {
|
|
693
693
|
const entry = JSON.parse(raw);
|
|
694
|
+
// Filter raw records before fallback timestamps, metadata collection, or
|
|
695
|
+
// normalization. Unknown source time cannot prove post-resume consent.
|
|
696
|
+
if (captureAfter && !(typeof entry?.timestamp === "string" &&
|
|
697
|
+
Date.parse(entry.timestamp) >= Date.parse(captureAfter))) continue;
|
|
694
698
|
if (entry !== null && typeof entry === "object") {
|
|
695
699
|
collectClaudeMetadata(metadata, entry);
|
|
696
700
|
if (entry.type === "assistant" && Array.isArray(entry.message?.content)) {
|
|
@@ -705,7 +709,7 @@ export async function readClaudeTranscriptSuffix(path, cursor, sessionHash) {
|
|
|
705
709
|
}
|
|
706
710
|
events.push(...normalizeClaudeTranscriptEntry(entry, evidence));
|
|
707
711
|
} catch {
|
|
708
|
-
events.push(normalizeMalformedClaudeTranscriptLine(rawBytes, evidence));
|
|
712
|
+
if (!captureAfter) events.push(normalizeMalformedClaudeTranscriptLine(rawBytes, evidence));
|
|
709
713
|
}
|
|
710
714
|
}
|
|
711
715
|
// Claude repeats the same usage record on every JSONL line of a multi-block
|
|
@@ -19,8 +19,8 @@ export const claudeTranscriptDriver = {
|
|
|
19
19
|
return null;
|
|
20
20
|
},
|
|
21
21
|
|
|
22
|
-
readSuffix(path, cursor, sessionHash) {
|
|
23
|
-
return readClaudeTranscriptSuffix(path, cursor, sessionHash);
|
|
22
|
+
readSuffix(path, cursor, sessionHash, options) {
|
|
23
|
+
return readClaudeTranscriptSuffix(path, cursor, sessionHash, options);
|
|
24
24
|
},
|
|
25
25
|
|
|
26
26
|
buildMetadataPayload(metadata, context) {
|
|
@@ -124,7 +124,7 @@ export const codexTranscriptDriver = {
|
|
|
124
124
|
return path === null ? null : { path };
|
|
125
125
|
},
|
|
126
126
|
|
|
127
|
-
async readSuffix(path, cursor, _sessionHash) {
|
|
127
|
+
async readSuffix(path, cursor, _sessionHash, { captureAfter } = {}) {
|
|
128
128
|
const { buffer, start } = await readSuffixWindow(path, cursor?.byteOffset ?? 0);
|
|
129
129
|
const priorState = cursor?.hostState !== null && typeof cursor?.hostState === "object"
|
|
130
130
|
? cursor.hostState : {};
|
|
@@ -139,6 +139,7 @@ export const codexTranscriptDriver = {
|
|
|
139
139
|
lastEnd = line.endOffset;
|
|
140
140
|
const entry = line.entry;
|
|
141
141
|
if (entry === null) continue;
|
|
142
|
+
if (captureAfter && !(typeof entry.timestamp === "string" && Date.parse(entry.timestamp) >= Date.parse(captureAfter))) continue;
|
|
142
143
|
const payload = entry.payload !== null && typeof entry.payload === "object" ? entry.payload : {};
|
|
143
144
|
const occurredAt = typeof entry.timestamp === "string" ? entry.timestamp : new Date().toISOString();
|
|
144
145
|
if (entry.type === "session_meta") {
|
|
@@ -146,7 +146,7 @@ export const kimiTranscriptDriver = {
|
|
|
146
146
|
return { path: located.path, sessionFacts };
|
|
147
147
|
},
|
|
148
148
|
|
|
149
|
-
async readSuffix(path, cursor, _sessionHash) {
|
|
149
|
+
async readSuffix(path, cursor, _sessionHash, { captureAfter } = {}) {
|
|
150
150
|
const { buffer, start } = await readSuffixWindow(path, cursor?.byteOffset ?? 0);
|
|
151
151
|
const metadata = {};
|
|
152
152
|
const events = [];
|
|
@@ -155,6 +155,7 @@ export const kimiTranscriptDriver = {
|
|
|
155
155
|
lastEnd = line.endOffset;
|
|
156
156
|
const entry = line.entry;
|
|
157
157
|
if (entry === null) continue;
|
|
158
|
+
if (captureAfter && !(Number.isSafeInteger(entry.time) && entry.time >= Date.parse(captureAfter))) continue;
|
|
158
159
|
if (entry.type === "usage.record") {
|
|
159
160
|
events.push(usageEventFromRecord(entry, {
|
|
160
161
|
byteOffset: line.byteOffset,
|
package/src/transport.mjs
CHANGED
|
@@ -15,10 +15,11 @@ export class RuntimeHttpError extends Error {
|
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
export class SignedRuntimeTransport {
|
|
18
|
-
constructor(connection, { fetchImpl = globalThis.fetch, timeoutMs = 8_000 } = {}) {
|
|
18
|
+
constructor(connection, { fetchImpl = globalThis.fetch, timeoutMs = 8_000, signal } = {}) {
|
|
19
19
|
this.connection = connection;
|
|
20
20
|
this.fetch = fetchImpl;
|
|
21
21
|
this.timeoutMs = timeoutMs;
|
|
22
|
+
this.signal = signal;
|
|
22
23
|
}
|
|
23
24
|
|
|
24
25
|
async request(path, { method = "POST", body, headers = {}, raw = false, maxResponseBytes } = {}) {
|
|
@@ -43,7 +44,7 @@ export class SignedRuntimeTransport {
|
|
|
43
44
|
...headers,
|
|
44
45
|
},
|
|
45
46
|
body: body === undefined ? undefined : bodyBytes,
|
|
46
|
-
signal: controller.signal,
|
|
47
|
+
signal: this.signal ? AbortSignal.any([controller.signal, this.signal]) : controller.signal,
|
|
47
48
|
});
|
|
48
49
|
if (!response.ok) {
|
|
49
50
|
if (maxResponseBytes !== undefined) {
|
|
@@ -76,15 +77,15 @@ export class SignedRuntimeTransport {
|
|
|
76
77
|
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
77
78
|
} finally { reader.releaseLock(); }
|
|
78
79
|
}
|
|
79
|
-
return response.json();
|
|
80
|
+
return await response.json();
|
|
80
81
|
} finally {
|
|
81
82
|
clearTimeout(timer);
|
|
82
83
|
}
|
|
83
84
|
}
|
|
84
85
|
|
|
85
|
-
heartbeat(capabilities, { pluginVersion, proofStorage, diagnostics }) {
|
|
86
|
+
heartbeat(capabilities, { pluginVersion, proofStorage, diagnostics, health }) {
|
|
86
87
|
return this.request("/v1/agent-runtime/heartbeat", {
|
|
87
|
-
body: { pluginVersion, capabilities, proofStorage, ...(diagnostics ? { diagnostics } : {}) },
|
|
88
|
+
body: { pluginVersion, capabilities, proofStorage, ...(diagnostics ? { diagnostics } : {}), ...(health ? { health } : {}) },
|
|
88
89
|
});
|
|
89
90
|
}
|
|
90
91
|
|
|
@@ -105,6 +106,8 @@ export class SignedRuntimeTransport {
|
|
|
105
106
|
skillDownload(skillId) {
|
|
106
107
|
return this.request(`/v1/agent-runtime/skills/${encodeURIComponent(skillId)}/download`, { method: "GET" });
|
|
107
108
|
}
|
|
109
|
+
instructions() { return this.request("/v1/agent-runtime/instructions", { method: "GET", maxResponseBytes: 64 * 1024 }); }
|
|
110
|
+
instructionStatus(body) { return this.request("/v1/agent-runtime/instructions/status", { body, maxResponseBytes: 16 * 1024 }); }
|
|
108
111
|
|
|
109
112
|
openSession(body) { return this.request("/v1/agent-sessions/open", { body }); }
|
|
110
113
|
appendEvents(sessionId, events) { return this.request(`/v1/agent-sessions/${encodeURIComponent(sessionId)}/events`, { body: { events } }); }
|
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.11.0";
|
|
3
|
+
export const RUNTIME_VERSION = "0.11.0";
|
|
4
|
+
export const DISCLOSURE_VERSION = "halofy-agent-lifecycle-2026-09-10.1";
|