@agentconnect.md/daemon 1.0.0-rc.234 → 1.0.0-rc.235

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.
@@ -4,13 +4,13 @@ import { t as DAEMON_VERSION } from "./version-DGaRXjBK.js";
4
4
  import { C as _coercedNumber, T as NEVER, a as array, d as literal, f as looseObject, i as any, m as object, o as boolean, p as number$1, t as ZodNumber, v as string, x as url$4 } from "./schemas-CQkO9hC5.js";
5
5
  import { a as agentChildEnv, i as selectAgent, n as findAgentFiles, o as loadConfig, r as loadAgents, s as persistDaemonId } from "./load-agents-tb8Rcwan.js";
6
6
  import { l as resolveRoot, o as mcpSocketPath, u as statePath } from "./paths-DEcUO8lC.js";
7
- import { _ as cachedRuntimeNames, a as initGitInjection, c as simpleGit, d as buildEnvelope, f as decodeEnvelope, g as RESERVED_MCP_SERVER_NAME, h as MEMORY_INDEX, i as prepareWorkspace, m as MAX_FRAME_BYTES, n as installedRuntimes, o as probeGitVersion, p as encode, r as prefetchWorkspace, s as sessionGitEnv, t as AcpHost, u as normalizeGitUrl, v as cachedRuntimeVersions, y as resolveRuntimes } from "./acp-host-CF3j4yST.js";
7
+ import { C as resolveRuntimes, S as cachedRuntimeVersions, _ as encode, a as initGitInjection, b as RESERVED_MCP_SERVER_NAME, c as simpleGit, d as RELAY_DAEMON_SUBPROTOCOL, f as RELAY_DAEMON_WS_PATH, g as decodeEnvelope, h as buildEnvelope, i as prepareWorkspace, m as decodeRelayDaemonFrame, n as installedRuntimes, o as probeGitVersion, p as buildRelayDaemonFrame, r as prefetchWorkspace, s as sessionGitEnv, t as AcpHost, u as normalizeGitUrl, v as MAX_FRAME_BYTES, x as cachedRuntimeNames, y as MEMORY_INDEX } from "./acp-host-D9z7hKHJ.js";
8
8
  import { i as GitCredentialCache, n as gitcredSocketPath, r as writeGitcredShim, t as GitCredServer } from "./gitcred-server-Dcsq6QJe.js";
9
9
  import { B as isInitializedNotification, C as JSONRPCMessageSchema, D as ListResourceTemplatesResultSchema, E as ListPromptsResultSchema, F as ReadResourceResultSchema, G as safeParse, H as isJSONRPCResultResponse, I as ResourceListChangedNotificationSchema, K as decodeFrames, L as SUPPORTED_PROTOCOL_VERSIONS, N as McpError, O as ListResourcesResultSchema, P as PromptListChangedNotificationSchema, T as ListChangedOptionsBaseSchema, U as getObjectShape, V as isJSONRPCRequest, W as isZ4Schema, _ as EmptyResultSchema, a as AjvJsonSchemaValidator, d as CreateMessageRequestSchema, f as CreateMessageResultSchema, g as ElicitResultSchema, h as ElicitRequestSchema, i as assertToolsCallTaskCapability, j as ListToolsResultSchema, l as CallToolResultSchema, m as CreateTaskResultSchema, n as serializeMessage, o as Protocol, p as CreateMessageResultWithToolsSchema, q as encodeFrame, r as assertClientRequestTaskCapability, s as mergeCapabilities, t as ReadBuffer, u as CompleteResultSchema, v as ErrorCode, w as LATEST_PROTOCOL_VERSION, x as InitializeResultSchema, y as GetPromptResultSchema, z as ToolListChangedNotificationSchema } from "./stdio-CwrB9yrX.js";
10
10
  import { URL as URL$1 } from "url";
11
11
  import * as tls from "tls";
12
12
  import * as net$2 from "net";
13
- import * as sp from "node:path";
13
+ import * as path$13 from "node:path";
14
14
  import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
15
15
  import * as http$7 from "http";
16
16
  import { Agent } from "https";
@@ -48,6 +48,145 @@ function number(params) {
48
48
  return _coercedNumber(ZodNumber, params);
49
49
  }
50
50
  //#endregion
51
+ //#region ../connection/dist/clock.js
52
+ var SystemClock = class {
53
+ now() {
54
+ return Date.now();
55
+ }
56
+ setTimeout(fn, ms) {
57
+ return globalThis.setTimeout(fn, ms);
58
+ }
59
+ clearTimeout(h) {
60
+ globalThis.clearTimeout(h);
61
+ }
62
+ };
63
+ const systemClock = new SystemClock();
64
+ /**
65
+ * A small stateful backoff helper: `next()` yields the delay for the current
66
+ * attempt and advances the counter; `reset()` returns to attempt 0 (call on a
67
+ * successful connect). Keeps the exponent/jitter math in one place instead of
68
+ * inline in each client FSM.
69
+ */
70
+ var Backoff = class {
71
+ attempt = 0;
72
+ baseMs;
73
+ capMs;
74
+ jitter;
75
+ constructor(opts = {}) {
76
+ this.baseMs = opts.baseMs ?? 1e3;
77
+ this.capMs = opts.capMs ?? 3e4;
78
+ this.jitter = opts.jitter ?? Math.random;
79
+ }
80
+ /** The delay for the current attempt, then advance (so the next call grows). */
81
+ next() {
82
+ const base = Math.min(this.capMs, this.baseMs * 2 ** this.attempt);
83
+ const delay = Math.min(this.capMs, base + Math.floor(this.jitter() * base));
84
+ this.attempt += 1;
85
+ return delay;
86
+ }
87
+ /** Reset to attempt 0 — call once a connect succeeds. */
88
+ reset() {
89
+ this.attempt = 0;
90
+ }
91
+ };
92
+ //#endregion
93
+ //#region ../connection/dist/correlator.js
94
+ /** Transport-free representation of a typed wire `error` REP. */
95
+ var WireError = class extends Error {
96
+ code;
97
+ retryable;
98
+ details;
99
+ constructor(code, message, retryable = false, details) {
100
+ super(message);
101
+ this.name = "WireError";
102
+ this.code = code;
103
+ this.retryable = retryable;
104
+ if (details) this.details = details;
105
+ }
106
+ };
107
+ var ReqRep = class {
108
+ clock;
109
+ ackTimeoutMs;
110
+ maxTries;
111
+ pending = /* @__PURE__ */ new Map();
112
+ constructor(clock, ackTimeoutMs, maxTries = 5) {
113
+ this.clock = clock;
114
+ this.ackTimeoutMs = ackTimeoutMs;
115
+ this.maxTries = maxTries;
116
+ }
117
+ request(frame, write, opts = {}) {
118
+ const encoded = JSON.stringify(frame);
119
+ return new Promise((resolve, reject) => {
120
+ const entry = {
121
+ id: frame.id,
122
+ encoded,
123
+ resolve,
124
+ reject,
125
+ tries: 1,
126
+ maxTries: opts.maxTries ?? this.maxTries
127
+ };
128
+ this.pending.set(frame.id, entry);
129
+ this.arm(entry, write, opts.ackTimeoutMs ?? this.ackTimeoutMs);
130
+ try {
131
+ write(encoded);
132
+ } catch (e) {
133
+ this.fail(entry, new WireError("INTERNAL", `send failed: ${e.message}`, true));
134
+ }
135
+ });
136
+ }
137
+ arm(entry, write, ackTimeoutMs) {
138
+ entry.timer = this.clock.setTimeout(() => {
139
+ if (!this.pending.has(entry.id)) return;
140
+ if (entry.tries >= entry.maxTries) {
141
+ this.pending.delete(entry.id);
142
+ entry.reject(new WireError("INTERNAL", `no ack after ${entry.maxTries} tries for ${entry.id}`, true));
143
+ return;
144
+ }
145
+ entry.tries += 1;
146
+ this.arm(entry, write, ackTimeoutMs);
147
+ try {
148
+ write(entry.encoded);
149
+ } catch (e) {
150
+ this.fail(entry, new WireError("INTERNAL", `send failed: ${e.message}`, true));
151
+ }
152
+ }, ackTimeoutMs);
153
+ }
154
+ fail(entry, err) {
155
+ if (!this.pending.has(entry.id)) return;
156
+ this.pending.delete(entry.id);
157
+ if (entry.timer !== void 0) this.clock.clearTimeout(entry.timer);
158
+ entry.reject(err);
159
+ }
160
+ /**
161
+ * Settle a pending REQ from an inbound correlated frame. Returns true when
162
+ * `frame.corr` matched a pending REQ (a REP resolves it; an `error` frame
163
+ * rejects with a {@link WireError}), false when it correlates nothing.
164
+ */
165
+ settle(frame) {
166
+ const corr = frame.corr;
167
+ if (!corr) return false;
168
+ const entry = this.pending.get(corr);
169
+ if (!entry) return false;
170
+ this.pending.delete(corr);
171
+ if (entry.timer !== void 0) this.clock.clearTimeout(entry.timer);
172
+ if (frame.type === "error") {
173
+ const e = frame.payload;
174
+ entry.reject(new WireError(e.code, e.message, e.retryable, e.details));
175
+ } else entry.resolve(frame);
176
+ return true;
177
+ }
178
+ inflight() {
179
+ return this.pending.size;
180
+ }
181
+ rejectAll(err) {
182
+ for (const entry of this.pending.values()) {
183
+ if (entry.timer !== void 0) this.clock.clearTimeout(entry.timer);
184
+ entry.reject(err);
185
+ }
186
+ this.pending.clear();
187
+ }
188
+ };
189
+ //#endregion
51
190
  //#region ../../node_modules/.pnpm/ws@8.21.0/node_modules/ws/lib/constants.js
52
191
  var require_constants$2 = /* @__PURE__ */ __commonJSMin(((exports, module) => {
53
192
  const BINARY_TYPES = [
@@ -3616,9 +3755,6 @@ var require_websocket_server = /* @__PURE__ */ __commonJSMin(((exports, module)
3616
3755
  } else abortHandshake(socket, code, message, headers);
3617
3756
  }
3618
3757
  }));
3619
- //#endregion
3620
- //#region ../../node_modules/.pnpm/ws@8.21.0/node_modules/ws/wrapper.mjs
3621
- var import_src = /* @__PURE__ */ __toESM(require_src$2(), 1);
3622
3758
  require_stream();
3623
3759
  require_extension();
3624
3760
  require_permessage_deflate();
@@ -3629,25 +3765,25 @@ var import_websocket = /* @__PURE__ */ __toESM(require_websocket(), 1);
3629
3765
  require_websocket_server();
3630
3766
  var wrapper_default = import_websocket.default;
3631
3767
  //#endregion
3632
- //#region src/cp/transport.ts
3768
+ //#region ../connection/dist/ws-client-transport.js
3633
3769
  /**
3634
- * Transport seam — the no-real-socket boundary for the daemon's CP client. The
3635
- * FSM depends ONLY on this interface, so tests inject a `FakeTransport`.
3636
- * `ClientTransport` is the production impl: it DIALS OUT (the daemon connects to
3637
- * the CP, never the reverse) and resolves once the socket is open.
3770
+ * Dial-out WS transport seam — the no-real-socket boundary a client FSM depends
3771
+ * on, so tests inject a fake. {@link ClientTransport} is the production impl: it
3772
+ * DIALS OUT (the daemon/relay connects to its peer, never the reverse), resolves
3773
+ * once the socket is open, and arms a receive-idle watchdog to detect a half-open
3774
+ * peer. Wire-agnostic: the subprotocol and dial path are passed in, so the same
3775
+ * transport serves the daemon↔CP, relay↔CP, and relay↔daemon wires.
3638
3776
  */
3639
- const SUBPROTOCOL = "agentconnect.v1";
3640
- const DEFAULT_WS_PATH = "/daemon/ws";
3641
- const HANDSHAKE_TIMEOUT_MS = 1e4;
3642
- const SELF_PING_MS = 15e3;
3643
- const RX_IDLE_MS = 45e3;
3777
+ const DEFAULT_HANDSHAKE_TIMEOUT_MS = 1e4;
3778
+ const DEFAULT_SELF_PING_MS = 15e3;
3779
+ const DEFAULT_RX_IDLE_MS = 45e3;
3644
3780
  /**
3645
3781
  * Arm a receive-idle watchdog on an open socket: every `selfPingMs` send a ping
3646
3782
  * (the peer auto-pongs, which counts as inbound), and if NOTHING has arrived for
3647
3783
  * `idleMs`, `terminate()` the socket — routing a dead half-open connection into
3648
3784
  * the normal `close` → reconnect path. Any inbound frame (message/ping/pong)
3649
- * resets the idle clock. Cleared on `close`. Kept off the FSM's injected Clock
3650
- * so it never perturbs the fake-clock CpClient tests.
3785
+ * resets the idle clock. Cleared on `close`. Kept off the FSM's injected Clock so
3786
+ * it never perturbs the fake-clock client tests.
3651
3787
  */
3652
3788
  function armRxWatchdog(ws, opts) {
3653
3789
  const now = opts.now ?? (() => Date.now());
@@ -3675,24 +3811,26 @@ var ClientTransport = class ClientTransport {
3675
3811
  get subprotocol() {
3676
3812
  return this.ws.protocol;
3677
3813
  }
3678
- constructor(ws) {
3814
+ constructor(ws, selfPingMs, idleMs) {
3679
3815
  this.ws = ws;
3680
3816
  armRxWatchdog(ws, {
3681
- selfPingMs: SELF_PING_MS,
3682
- idleMs: RX_IDLE_MS
3817
+ selfPingMs,
3818
+ idleMs
3683
3819
  });
3684
3820
  }
3685
3821
  /**
3686
- * Dial `wss://<host>/daemon/ws` with the `agentconnect.v1` subprotocol.
3822
+ * Dial `<url normalized to end in opts.path>` with `opts.subprotocol`.
3687
3823
  * Resolves once the socket is open; rejects on any error before open
3688
3824
  * (refused, bad handshake/subprotocol → HTTP 400).
3689
3825
  */
3690
- static dial(url) {
3691
- const wsUrl = url.endsWith(DEFAULT_WS_PATH) ? url : url.replace(/\/+$/, "") + DEFAULT_WS_PATH;
3826
+ static dial(url, opts) {
3827
+ const wsUrl = url.endsWith(opts.path) ? url : url.replace(/\/+$/, "") + opts.path;
3828
+ const selfPingMs = opts.selfPingMs ?? DEFAULT_SELF_PING_MS;
3829
+ const idleMs = opts.idleMs ?? DEFAULT_RX_IDLE_MS;
3692
3830
  return new Promise((resolve, reject) => {
3693
- const ws = new wrapper_default(wsUrl, [SUBPROTOCOL], {
3831
+ const ws = new wrapper_default(wsUrl, [opts.subprotocol], {
3694
3832
  maxPayload: MAX_FRAME_BYTES,
3695
- handshakeTimeout: HANDSHAKE_TIMEOUT_MS
3833
+ handshakeTimeout: opts.handshakeTimeoutMs ?? DEFAULT_HANDSHAKE_TIMEOUT_MS
3696
3834
  });
3697
3835
  const onPreOpenError = (err) => {
3698
3836
  ws.removeAllListeners();
@@ -3702,7 +3840,7 @@ var ClientTransport = class ClientTransport {
3702
3840
  ws.once("open", () => {
3703
3841
  ws.removeListener("error", onPreOpenError);
3704
3842
  ws.on("error", () => ws.terminate());
3705
- resolve(new ClientTransport(ws));
3843
+ resolve(new ClientTransport(ws, selfPingMs, idleMs));
3706
3844
  });
3707
3845
  });
3708
3846
  }
@@ -3723,6 +3861,957 @@ var ClientTransport = class ClientTransport {
3723
3861
  }
3724
3862
  };
3725
3863
  //#endregion
3864
+ //#region src/cp/wire-slice.ts
3865
+ var import_src = /* @__PURE__ */ __toESM(require_src$2(), 1);
3866
+ /**
3867
+ * Pure helpers for slicing file bytes into a single WS frame — shared by the
3868
+ * workspace and memory readers so both honour the 256 KiB wire cap and never
3869
+ * split a UTF-8 character across slices.
3870
+ */
3871
+ /** Encoded-payload ceiling, leaving headroom under MAX_FRAME_BYTES for the
3872
+ * envelope (id/ts/type/corr + fencing ext, well under 4 KiB). */
3873
+ const REPLY_BUDGET$1 = MAX_FRAME_BYTES - 4096;
3874
+ /** The encoded size of the payload the wire will carry (JSON.stringify matches
3875
+ * the codec's `encode`), measured in bytes. */
3876
+ function encodedBytes$1(payload) {
3877
+ return Buffer.byteLength(JSON.stringify(payload));
3878
+ }
3879
+ /** Largest index ≤ `len` that lands on a UTF-8 character boundary — i.e. never
3880
+ * cuts a multi-byte sequence. If the slice's trailing char is incomplete, the
3881
+ * boundary is placed before that char (its bytes belong to the next slice). */
3882
+ function utf8Boundary$1(buf, len) {
3883
+ if (len >= buf.length) len = buf.length;
3884
+ if (len <= 0) return 0;
3885
+ let start = len - 1;
3886
+ while (start > 0 && ((buf[start] ?? 0) & 192) === 128) start--;
3887
+ const lead = buf[start] ?? 0;
3888
+ const seqLen = lead < 128 ? 1 : (lead & 224) === 192 ? 2 : (lead & 240) === 224 ? 3 : (lead & 248) === 240 ? 4 : 1;
3889
+ return start + seqLen <= len ? len : start;
3890
+ }
3891
+ /** Shrink `content` (a utf8 view of `slice[0..end]`) until its JSON-escaped size
3892
+ * fits `REPLY_BUDGET`, keeping the cut on a UTF-8 boundary. Returns the final
3893
+ * `{ end, content }`. Control-byte-heavy text escapes ~6×, so the raw byte limit
3894
+ * is not enough on its own. */
3895
+ function fitToBudget(slice, end) {
3896
+ let content = slice.toString("utf8", 0, end);
3897
+ while (end > 0 && encodedBytes$1(content) > REPLY_BUDGET$1) {
3898
+ const factor = REPLY_BUDGET$1 / encodedBytes$1(content);
3899
+ const shrunk = Math.max(1, Math.floor(end * factor * .9));
3900
+ end = utf8Boundary$1(slice, Math.min(shrunk, end - 1));
3901
+ content = slice.toString("utf8", 0, end);
3902
+ }
3903
+ return {
3904
+ end,
3905
+ content
3906
+ };
3907
+ }
3908
+ //#endregion
3909
+ //#region src/cp/workspace-reader.ts
3910
+ /**
3911
+ * `WorkspaceReader` — the read-only seam answering the CP's `workspace/list` and
3912
+ * `workspace/read` REQs from the agent's local workspace directory. File bytes
3913
+ * live only on the daemon (§1/§12); this streams single pages/slices to the CP,
3914
+ * never whole trees or files.
3915
+ *
3916
+ * Containment is to `workspace.path` EXACTLY — the parent directory holds
3917
+ * `agent.json` and `.env` secrets, so escaping even one level is a secret leak.
3918
+ * Every request path is (1) rejected if absolute, (2) lexically resolved against
3919
+ * the root and prefix-checked, then (3) canonicalised with `realpath` and
3920
+ * re-verified so symlinks (including an intermediate directory component swapped
3921
+ * to a symlink after the check) cannot smuggle a target outside the root; all
3922
+ * I/O runs on the canonical path. Violations throw `WorkspaceViolationError`,
3923
+ * which the dispatcher maps to a `BAD_PAYLOAD` error frame; missing paths are
3924
+ * data (`exists:false`), not errors. Never log file contents.
3925
+ *
3926
+ * Frame-size safety: every REP must serialise under the 256 KiB wire cap. JSON
3927
+ * escaping of control bytes is a 6× blowup, so `read` bounds the slice by the
3928
+ * *encoded* reply size (not the raw byte `limit`) and `list` stops a page early
3929
+ * when the encoded entries would overflow — both leave headroom for the envelope.
3930
+ */
3931
+ /** Bytes sniffed from the head of a file for binary (NUL byte) detection. */
3932
+ const SNIFF_BYTES = 8192;
3933
+ /** Path-containment / bad-request violation → `BAD_PAYLOAD` on the wire. */
3934
+ var WorkspaceViolationError = class extends Error {
3935
+ constructor(message) {
3936
+ super(message);
3937
+ this.name = "WorkspaceViolationError";
3938
+ }
3939
+ };
3940
+ function isErrno$1(err, code) {
3941
+ return err?.code === code;
3942
+ }
3943
+ function under(root, p) {
3944
+ return p === root || p.startsWith(root + path$13.sep);
3945
+ }
3946
+ function createWorkspaceReader(workspaceRootByAgent) {
3947
+ function rootFor(agentId) {
3948
+ const root = workspaceRootByAgent(agentId);
3949
+ if (!root) throw new WorkspaceViolationError(`unknown agent "${agentId}"`);
3950
+ return root;
3951
+ }
3952
+ /**
3953
+ * Lexical containment. Rejects absolute paths and `..` escapes; returns the
3954
+ * lexically-resolved path plus the canonical (realpath'd) root, or `null` for
3955
+ * `realRoot` when the root does not exist yet (callers report `exists:false`).
3956
+ */
3957
+ async function resolveContained(root, relPath) {
3958
+ if (path$13.isAbsolute(relPath)) throw new WorkspaceViolationError("absolute paths are not allowed");
3959
+ const resolved = path$13.resolve(root, relPath);
3960
+ if (!under(root, resolved)) throw new WorkspaceViolationError("path escapes the workspace root");
3961
+ try {
3962
+ return {
3963
+ resolved,
3964
+ realRoot: await promises.realpath(root)
3965
+ };
3966
+ } catch (err) {
3967
+ if (isErrno$1(err, "ENOENT")) return {
3968
+ resolved,
3969
+ realRoot: null
3970
+ };
3971
+ throw err;
3972
+ }
3973
+ }
3974
+ /** Canonicalise an existing target and re-verify containment on the REAL path,
3975
+ * closing the check-vs-use gap (a symlinked intermediate component resolves
3976
+ * out and is rejected here). Returns the canonical path used for I/O. */
3977
+ async function canonicalUnder(realRoot, abs) {
3978
+ const canon = await promises.realpath(abs);
3979
+ if (!under(realRoot, canon)) throw new WorkspaceViolationError("path resolves outside the workspace root");
3980
+ return canon;
3981
+ }
3982
+ return {
3983
+ async list(req) {
3984
+ const { resolved, realRoot } = await resolveContained(rootFor(req.agentId), req.path);
3985
+ const notFound = {
3986
+ agentId: req.agentId,
3987
+ path: req.path,
3988
+ exists: false,
3989
+ entries: []
3990
+ };
3991
+ if (realRoot === null) return notFound;
3992
+ let target;
3993
+ try {
3994
+ target = await canonicalUnder(realRoot, resolved);
3995
+ } catch (err) {
3996
+ if (isErrno$1(err, "ENOENT")) return notFound;
3997
+ throw err;
3998
+ }
3999
+ let dirents;
4000
+ try {
4001
+ dirents = await promises.readdir(target, { withFileTypes: true });
4002
+ } catch (err) {
4003
+ if (isErrno$1(err, "ENOENT") || isErrno$1(err, "ENOTDIR")) return notFound;
4004
+ throw err;
4005
+ }
4006
+ const entries = [];
4007
+ for (const d of dirents) {
4008
+ if (d.name === ".git") continue;
4009
+ const type = d.isDirectory() ? "dir" : d.isFile() ? "file" : d.isSymbolicLink() ? "symlink" : "other";
4010
+ const entry = {
4011
+ name: d.name,
4012
+ type
4013
+ };
4014
+ if (type === "file") try {
4015
+ const st = await promises.lstat(path$13.join(target, d.name));
4016
+ entry.size = st.size;
4017
+ entry.mtime = st.mtime.toISOString();
4018
+ } catch {}
4019
+ entries.push(entry);
4020
+ }
4021
+ entries.sort(compareEntries);
4022
+ const start = req.cursor !== void 0 && /^\d+$/.test(req.cursor) ? Number(req.cursor) : 0;
4023
+ const page = [];
4024
+ let acc = 0;
4025
+ for (let i = start; i < entries.length && page.length < req.limit; i++) {
4026
+ const e = entries[i];
4027
+ const enc = encodedBytes$1(e) + 1;
4028
+ if (page.length > 0 && acc + enc > REPLY_BUDGET$1) break;
4029
+ page.push(e);
4030
+ acc += enc;
4031
+ }
4032
+ const consumed = start + page.length;
4033
+ return {
4034
+ agentId: req.agentId,
4035
+ path: req.path,
4036
+ exists: true,
4037
+ entries: page,
4038
+ ...consumed < entries.length ? { nextCursor: String(consumed) } : {}
4039
+ };
4040
+ },
4041
+ async read(req) {
4042
+ const { resolved, realRoot } = await resolveContained(rootFor(req.agentId), req.path);
4043
+ const notFound = {
4044
+ agentId: req.agentId,
4045
+ path: req.path,
4046
+ exists: false
4047
+ };
4048
+ if (realRoot === null) return notFound;
4049
+ let st;
4050
+ try {
4051
+ st = await promises.lstat(resolved);
4052
+ } catch (err) {
4053
+ if (isErrno$1(err, "ENOENT")) return notFound;
4054
+ throw err;
4055
+ }
4056
+ if (!st.isFile()) throw new WorkspaceViolationError("not a regular file");
4057
+ const target = await canonicalUnder(realRoot, resolved);
4058
+ const size = st.size;
4059
+ const mtime = st.mtime.toISOString();
4060
+ const fh = await promises.open(target, "r");
4061
+ try {
4062
+ const sniffLen = Math.min(SNIFF_BYTES, size);
4063
+ if (sniffLen > 0) {
4064
+ const sniff = Buffer.alloc(sniffLen);
4065
+ const { bytesRead } = await fh.read(sniff, 0, sniffLen, 0);
4066
+ if (sniff.subarray(0, bytesRead).includes(0)) return {
4067
+ agentId: req.agentId,
4068
+ path: req.path,
4069
+ exists: true,
4070
+ size,
4071
+ mtime,
4072
+ encoding: "none"
4073
+ };
4074
+ }
4075
+ const want = Math.min(req.limit, Math.max(0, size - req.offset));
4076
+ let slice = Buffer.alloc(0);
4077
+ if (want > 0) {
4078
+ const buf = Buffer.alloc(want);
4079
+ const { bytesRead } = await fh.read(buf, 0, want, req.offset);
4080
+ slice = buf.subarray(0, bytesRead);
4081
+ }
4082
+ let end = utf8Boundary$1(slice, slice.length);
4083
+ const fitted = fitToBudget(slice, end);
4084
+ end = fitted.end;
4085
+ const content = fitted.content;
4086
+ const nextOffset = req.offset + end;
4087
+ return {
4088
+ agentId: req.agentId,
4089
+ path: req.path,
4090
+ exists: true,
4091
+ size,
4092
+ mtime,
4093
+ encoding: "utf8",
4094
+ content,
4095
+ offset: req.offset,
4096
+ nextOffset,
4097
+ truncated: nextOffset < size
4098
+ };
4099
+ } finally {
4100
+ await fh.close();
4101
+ }
4102
+ }
4103
+ };
4104
+ }
4105
+ /** dirs first, then case-insensitive alphabetical (stable within the page). */
4106
+ function compareEntries(a, b) {
4107
+ const aDir = a.type === "dir" ? 0 : 1;
4108
+ const bDir = b.type === "dir" ? 0 : 1;
4109
+ if (aDir !== bDir) return aDir - bDir;
4110
+ const al = a.name.toLowerCase();
4111
+ const bl = b.name.toLowerCase();
4112
+ if (al !== bl) return al < bl ? -1 : 1;
4113
+ return a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
4114
+ }
4115
+ //#endregion
4116
+ //#region src/agents/memory.ts
4117
+ /**
4118
+ * Agent memory — a DIRECTORY at the agent's ROOT (`<agent-root>/memory/`), OUTSIDE
4119
+ * the workspace so it survives a workspace reset / re-clone and is never committed
4120
+ * into a github-workspace repo.
4121
+ *
4122
+ * Index-plus-topics shape (the structure Claude Code's auto-memory uses): a lean
4123
+ * `MEMORY.md` index — injected into the prompt every session, so it must stay small
4124
+ * — plus any number of `<topic>.md` files the agent reads on demand. The agent
4125
+ * maintains it via the `readMemory` / `writeMemory` / `listMemory` MCP tools; the
4126
+ * daemon serves the same directory to the CP for the console.
4127
+ *
4128
+ * SECURITY: topic paths are agent/console-supplied, so every path is contained to
4129
+ * the memory dir — absolute paths and `..` escapes are rejected, and the resolved
4130
+ * path is re-checked so a symlink can't smuggle a target out. Flat by design (one
4131
+ * level): a topic is a file directly under `memory/`, no nested dirs.
4132
+ */
4133
+ const MEMORY_DIRNAME = "memory";
4134
+ /** Max bytes of the index injected into the prompt each session — keeps a large
4135
+ * memory from swelling the context (mirrors Claude Code's 25 KB entrypoint cap). */
4136
+ const MAX_INDEX_INJECT_BYTES = 25e3;
4137
+ /** Hard cap on a single memory file. Injected/read every session, so an unbounded
4138
+ * file would swell the prompt and the wire; writes over this are rejected. */
4139
+ const MAX_MEMORY_FILE_BYTES = 256e3;
4140
+ /** Raised when a memory path escapes the memory dir. Surfaces as `BAD_PAYLOAD`. */
4141
+ var MemoryPathError = class extends Error {
4142
+ constructor(message) {
4143
+ super(message);
4144
+ this.name = "MemoryPathError";
4145
+ }
4146
+ };
4147
+ /** Raised when a write exceeds {@link MAX_MEMORY_FILE_BYTES}. `BAD_PAYLOAD`. */
4148
+ var MemoryTooLargeError = class extends Error {
4149
+ constructor(message) {
4150
+ super(message);
4151
+ this.name = "MemoryTooLargeError";
4152
+ }
4153
+ };
4154
+ /** Raised when an `ifMatchMtime` precondition fails (the file changed under the
4155
+ * writer — e.g. a console edit racing a newer agent write). Surfaces as CONFLICT. */
4156
+ var MemoryConflictError = class extends Error {
4157
+ constructor(message) {
4158
+ super(message);
4159
+ this.name = "MemoryConflictError";
4160
+ }
4161
+ };
4162
+ /** The memory directory for an agent, given its root dir (where agent.json lives). */
4163
+ function memoryDir(agentDir) {
4164
+ return join(agentDir, MEMORY_DIRNAME);
4165
+ }
4166
+ /** Resolve a memory-dir-relative path to an absolute one, contained to the dir.
4167
+ * Rejects absolute paths, `..` escapes, and any nested directory (flat only). */
4168
+ function resolveInMemoryDir(agentDir, relPath) {
4169
+ if (isAbsolute(relPath)) throw new MemoryPathError("absolute paths are not allowed");
4170
+ const dir = memoryDir(agentDir);
4171
+ const abs = resolve(dir, relPath);
4172
+ if (abs !== dir && !abs.startsWith(dir + sep)) throw new MemoryPathError("path escapes the memory dir");
4173
+ const rel = abs.slice(dir.length + 1);
4174
+ if (rel.includes(sep)) throw new MemoryPathError("memory is a flat directory (no subdirectories)");
4175
+ if (rel.length === 0) throw new MemoryPathError("a file name is required");
4176
+ return abs;
4177
+ }
4178
+ /** Seed `<agent-root>/memory/MEMORY.md` with a header if the dir/index is absent
4179
+ * (idempotent). Called at session start so a brand-new agent always has an index. */
4180
+ function ensureMemory(agentDir, agentName) {
4181
+ const dir = memoryDir(agentDir);
4182
+ mkdirSync(dir, { recursive: true });
4183
+ const index = join(dir, MEMORY_INDEX);
4184
+ if (!existsSync(index)) writeFileSync(index, `# ${agentName} memory\n\nYour long-term memory index. Keep it short — link out to topic files (e.g. \`[deploys](deploys.md)\`) for detail and read them on demand with the readMemory tool.\n`);
4185
+ }
4186
+ /** Read the memory index (`MEMORY.md`) for prompt injection, trimmed to the inject
4187
+ * cap so a large index can't blow up the prompt; '' when absent. */
4188
+ async function readIndex(agentDir) {
4189
+ const text = await readMemoryFile(agentDir, MEMORY_INDEX);
4190
+ if (Buffer.byteLength(text) <= 25e3) return text;
4191
+ return Buffer.from(text, "utf8").subarray(0, MAX_INDEX_INJECT_BYTES).toString("utf8") + "\n\n[…memory index truncated — trim MEMORY.md]";
4192
+ }
4193
+ /** Read a memory file's text; '' when it does not exist (never throws on ENOENT). */
4194
+ async function readMemoryFile(agentDir, relPath) {
4195
+ const abs = resolveInMemoryDir(agentDir, relPath);
4196
+ try {
4197
+ return await promises.readFile(abs, "utf8");
4198
+ } catch (err) {
4199
+ if (err.code === "ENOENT") return "";
4200
+ throw err;
4201
+ }
4202
+ }
4203
+ /** Overwrite a memory file with `content` (creating the dir if needed). Atomic
4204
+ * (tmp + rename) so a concurrent read never sees a partial file.
4205
+ *
4206
+ * - Rejects content over {@link MAX_MEMORY_FILE_BYTES} ({@link MemoryTooLargeError}).
4207
+ * - `ifMatchMtime` (optimistic concurrency): when given, the current file's mtime
4208
+ * must equal it, else {@link MemoryConflictError} — so a console edit can't
4209
+ * clobber a newer write. A brand-new file (no mtime) matches `ifMatchMtime`
4210
+ * only when the caller passes none (or the empty string). */
4211
+ async function writeMemoryFile(agentDir, relPath, content, ifMatchMtime) {
4212
+ if (Buffer.byteLength(content) > 256e3) throw new MemoryTooLargeError(`memory file exceeds the ${MAX_MEMORY_FILE_BYTES}-byte limit`);
4213
+ const abs = resolveInMemoryDir(agentDir, relPath);
4214
+ if (ifMatchMtime) {
4215
+ let current = null;
4216
+ try {
4217
+ current = (await promises.stat(abs)).mtime.toISOString();
4218
+ } catch (err) {
4219
+ if (err.code !== "ENOENT") throw err;
4220
+ }
4221
+ if (current !== ifMatchMtime) throw new MemoryConflictError("the memory file changed since it was read; reload and retry");
4222
+ }
4223
+ await promises.mkdir(memoryDir(agentDir), { recursive: true });
4224
+ const tmp = `${abs}.tmp`;
4225
+ await promises.writeFile(tmp, content, "utf8");
4226
+ await promises.rename(tmp, abs);
4227
+ const st = await promises.stat(abs);
4228
+ return {
4229
+ size: st.size,
4230
+ mtime: st.mtime.toISOString()
4231
+ };
4232
+ }
4233
+ /** List the files in the memory dir (flat; index + topics), sorted with the index
4234
+ * first. Empty when the dir does not exist. `.tmp` write artifacts are skipped. */
4235
+ async function listMemory(agentDir) {
4236
+ const dir = memoryDir(agentDir);
4237
+ let dirents;
4238
+ try {
4239
+ dirents = await promises.readdir(dir, { withFileTypes: true });
4240
+ } catch (err) {
4241
+ if (err.code === "ENOENT") return [];
4242
+ throw err;
4243
+ }
4244
+ const files = [];
4245
+ for (const d of dirents) {
4246
+ if (!d.isFile() || d.name.endsWith(".tmp")) continue;
4247
+ try {
4248
+ const st = await promises.stat(join(dir, d.name));
4249
+ files.push({
4250
+ name: d.name,
4251
+ size: st.size,
4252
+ mtime: st.mtime.toISOString()
4253
+ });
4254
+ } catch {}
4255
+ }
4256
+ files.sort((a, b) => a.name === "MEMORY.md" ? -1 : b.name === "MEMORY.md" ? 1 : a.name.localeCompare(b.name));
4257
+ return files;
4258
+ }
4259
+ //#endregion
4260
+ //#region src/cp/memory-reader.ts
4261
+ /**
4262
+ * `MemoryReader` — the seam answering the CP's `memory/list`, `memory/read` and
4263
+ * `memory/write` REQs for an agent's memory directory (`<agent-root>/memory/`).
4264
+ * The files live only on the daemon (§1/§12); reads stream single byte slices,
4265
+ * writes replace one whole file.
4266
+ *
4267
+ * Path containment lives in `agents/memory.ts` (`resolveInMemoryDir`): a topic
4268
+ * path that escapes the memory dir throws `MemoryPathError` → `BAD_PAYLOAD`. An
4269
+ * unknown agent is also `BAD_PAYLOAD`; a not-yet-created file/dir is data
4270
+ * (`exists:false`), not an error. Frame-size safety mirrors the workspace reader:
4271
+ * the read slice is bounded by the *encoded* reply size and ends on a UTF-8
4272
+ * boundary. Never log memory contents.
4273
+ */
4274
+ /** Unknown-agent violation → `BAD_PAYLOAD` on the wire (path escapes surface as
4275
+ * MemoryPathError from agents/memory.ts, mapped the same way by the dispatcher). */
4276
+ var MemoryViolationError = class extends Error {
4277
+ constructor(message) {
4278
+ super(message);
4279
+ this.name = "MemoryViolationError";
4280
+ }
4281
+ };
4282
+ function isErrno(err, code) {
4283
+ return err?.code === code;
4284
+ }
4285
+ /** `agentDirByAgent` resolves an agent id → its root dir (which holds `memory/`),
4286
+ * or undefined for an unknown agent. */
4287
+ function createMemoryReader(agentDirByAgent) {
4288
+ function dirFor(agentId) {
4289
+ const dir = agentDirByAgent(agentId);
4290
+ if (!dir) throw new MemoryViolationError(`unknown agent "${agentId}"`);
4291
+ return dir;
4292
+ }
4293
+ return {
4294
+ async list(req) {
4295
+ const files = await listMemory(dirFor(req.agentId));
4296
+ return {
4297
+ agentId: req.agentId,
4298
+ exists: files.length > 0,
4299
+ entries: files
4300
+ };
4301
+ },
4302
+ async read(req) {
4303
+ const file = resolveInMemoryDir(dirFor(req.agentId), req.path);
4304
+ const notFound = {
4305
+ agentId: req.agentId,
4306
+ path: req.path,
4307
+ exists: false
4308
+ };
4309
+ let st;
4310
+ try {
4311
+ st = await promises.stat(file);
4312
+ } catch (err) {
4313
+ if (isErrno(err, "ENOENT")) return notFound;
4314
+ throw err;
4315
+ }
4316
+ if (!st.isFile()) return notFound;
4317
+ const size = st.size;
4318
+ const mtime = st.mtime.toISOString();
4319
+ const fh = await promises.open(file, "r");
4320
+ try {
4321
+ const want = Math.min(req.limit, Math.max(0, size - req.offset));
4322
+ let slice = Buffer.alloc(0);
4323
+ if (want > 0) {
4324
+ const buf = Buffer.alloc(want);
4325
+ const { bytesRead } = await fh.read(buf, 0, want, req.offset);
4326
+ slice = buf.subarray(0, bytesRead);
4327
+ }
4328
+ const { end, content } = fitToBudget(slice, utf8Boundary$1(slice, slice.length));
4329
+ const nextOffset = req.offset + end;
4330
+ return {
4331
+ agentId: req.agentId,
4332
+ path: req.path,
4333
+ exists: true,
4334
+ size,
4335
+ mtime,
4336
+ content,
4337
+ offset: req.offset,
4338
+ nextOffset,
4339
+ truncated: nextOffset < size
4340
+ };
4341
+ } finally {
4342
+ await fh.close();
4343
+ }
4344
+ },
4345
+ async write(req) {
4346
+ const { size, mtime } = await writeMemoryFile(dirFor(req.agentId), req.path, req.content, req.ifMatchMtime);
4347
+ return {
4348
+ agentId: req.agentId,
4349
+ path: req.path,
4350
+ size,
4351
+ mtime
4352
+ };
4353
+ }
4354
+ };
4355
+ }
4356
+ //#endregion
4357
+ //#region src/cp/client.ts
4358
+ /** The daemon↔CP wire's subprotocol + mount path — the two values the shared
4359
+ * `ClientTransport.dial` needs (formerly hardcoded in the daemon-local transport). */
4360
+ const CP_SUBPROTOCOL = "agentconnect.v1";
4361
+ const CP_WS_PATH = "/daemon/ws";
4362
+ const ACK_TIMEOUT_MS$1 = 5e3;
4363
+ const BACKOFF_BASE_MS = 1e3;
4364
+ const BACKOFF_CAP_MS = 3e4;
4365
+ var CpClient = class {
4366
+ deps;
4367
+ state = "CLOSED";
4368
+ sessionEpoch = 0;
4369
+ routingEpoch = 0;
4370
+ transport;
4371
+ correlator;
4372
+ stopped = false;
4373
+ fatal = false;
4374
+ attempt = 0;
4375
+ reconnectTimer;
4376
+ lastAuthedEpoch = 0;
4377
+ heartbeatTimer;
4378
+ heartbeatMs = 0;
4379
+ constructor(deps) {
4380
+ this.deps = deps;
4381
+ this.correlator = new ReqRep(deps.clock, ACK_TIMEOUT_MS$1);
4382
+ }
4383
+ /** Non-blocking: kicks off the connect loop and returns. */
4384
+ start() {
4385
+ this.stopped = false;
4386
+ this.fatal = false;
4387
+ this.attemptConnect();
4388
+ }
4389
+ async stop() {
4390
+ this.stopped = true;
4391
+ if (this.reconnectTimer !== void 0) {
4392
+ this.deps.clock.clearTimeout(this.reconnectTimer);
4393
+ this.reconnectTimer = void 0;
4394
+ }
4395
+ this.stopHeartbeat();
4396
+ this.correlator.rejectAll(/* @__PURE__ */ new Error("stopping"));
4397
+ this.transport?.close(1e3, "shutdown");
4398
+ this.state = "CLOSED";
4399
+ }
4400
+ async attemptConnect() {
4401
+ if (this.stopped || this.fatal) return;
4402
+ this.state = "CONNECTING";
4403
+ try {
4404
+ const t = await this.deps.connect();
4405
+ if (this.stopped || this.fatal) {
4406
+ t.close(1e3, "shutdown");
4407
+ return;
4408
+ }
4409
+ this.transport = t;
4410
+ t.onMessage((txt) => void this.onText(txt));
4411
+ t.onClose((c, r) => this.onClose(c, r));
4412
+ await this.handshake();
4413
+ this.attempt = 0;
4414
+ } catch (err) {
4415
+ this.deps.log.warn(`cp: connect/handshake failed: ${err.message}`);
4416
+ this.transport?.close(1011, "handshake failed");
4417
+ this.transport = void 0;
4418
+ this.scheduleReconnect();
4419
+ }
4420
+ }
4421
+ scheduleReconnect() {
4422
+ if (this.stopped || this.fatal) return;
4423
+ if (this.reconnectTimer !== void 0) return;
4424
+ const jitter = this.deps.jitter ?? Math.random;
4425
+ const base = Math.min(BACKOFF_CAP_MS, BACKOFF_BASE_MS * 2 ** this.attempt);
4426
+ const delay = Math.min(BACKOFF_CAP_MS, base + Math.floor(jitter() * base));
4427
+ this.attempt += 1;
4428
+ this.reconnectTimer = this.deps.clock.setTimeout(() => {
4429
+ this.reconnectTimer = void 0;
4430
+ this.attemptConnect();
4431
+ }, delay);
4432
+ }
4433
+ async handshake() {
4434
+ this.state = "AUTHENTICATING";
4435
+ const authPayload = {
4436
+ apiKey: this.deps.token,
4437
+ agentVersion: this.deps.agentVersion
4438
+ };
4439
+ if (this.deps.daemonId) authPayload.daemonId = this.deps.daemonId;
4440
+ if (this.lastAuthedEpoch > 0) authPayload.resume = { lastEpoch: this.lastAuthedEpoch };
4441
+ const ok = (await this.request("auth", authPayload)).payload;
4442
+ this.sessionEpoch = ok.sessionEpoch;
4443
+ this.lastAuthedEpoch = ok.sessionEpoch;
4444
+ if (ok.daemonId && ok.daemonId !== this.deps.daemonId) {
4445
+ this.deps.daemonId = ok.daemonId;
4446
+ this.deps.onDaemonId?.(ok.daemonId);
4447
+ }
4448
+ this.deps.onWebAppUrl?.(ok.webAppUrl);
4449
+ this.deps.onOrgSlug?.(ok.orgSlug);
4450
+ this.state = "REGISTERING";
4451
+ const snap = (await this.request("register", {
4452
+ host: this.deps.host,
4453
+ capabilities: this.deps.capabilities(),
4454
+ maxAgents: this.deps.maxAgents,
4455
+ localState: this.deps.localState()
4456
+ })).payload;
4457
+ this.routingEpoch = snap.routingEpoch;
4458
+ this.deps.configApply.applyReconcileSnapshot(snap);
4459
+ this.state = "READY";
4460
+ this.heartbeatMs = ok.heartbeatSec > 0 ? ok.heartbeatSec * 1e3 : this.deps.heartbeatDefaultMs;
4461
+ this.armHeartbeat();
4462
+ this.deps.log.info(`cp: READY (epoch=${this.sessionEpoch}, routingEpoch=${this.routingEpoch})`);
4463
+ this.emitDaemonRuntimes(this.deps.runtimeProfiles(), this.deps.mcpServerFacts?.() ?? []);
4464
+ this.deps.onReady?.();
4465
+ }
4466
+ /**
4467
+ * Push the full runtime snapshot (D→C `facts/daemon-runtimes` EVT,
4468
+ * fire-and-forget) — sent on each register and again when a probe sweep
4469
+ * completes. REPLACE semantics on the CP: it reconciles the daemon's stored
4470
+ * runtime list to exactly this snapshot, pruning runtimes that are no longer
4471
+ * installed. No-op unless READY/DRAINING.
4472
+ */
4473
+ emitDaemonRuntimes(runtimes, mcpServers = []) {
4474
+ if (this.state !== "READY" && this.state !== "DRAINING") return;
4475
+ this.transport?.send(encode(buildEnvelope("facts/daemon-runtimes", {
4476
+ runtimes,
4477
+ mcpServers
4478
+ })));
4479
+ }
4480
+ /**
4481
+ * Report a session's cumulative token usage (D→C `usage/report` EVT,
4482
+ * fire-and-forget). No-op unless READY/DRAINING — usage is dashboard telemetry,
4483
+ * so a dropped report just means that turn's delta is absent from the CP's
4484
+ * historical aggregates until the next report (which re-sends the cumulative
4485
+ * snapshot, latest-wins). Never blocks the turn.
4486
+ */
4487
+ emitUsageReport(report) {
4488
+ if (this.state !== "READY" && this.state !== "DRAINING") return;
4489
+ this.transport?.send(encode(buildEnvelope("usage/report", report)));
4490
+ }
4491
+ /**
4492
+ * Report a session's converged milestone + sessionKey echo (D→C `event/session`
4493
+ * EVT, fire-and-forget, latest-wins per `sessionId`). This is what lets the CP
4494
+ * store session metadata so a deep-link detail page (…/sessions/:id) resolves
4495
+ * even when the daemon is offline. Metadata only — never the message stream
4496
+ * (that stays daemon-local, §1/§12). No-op unless READY/DRAINING; a dropped
4497
+ * `start` is re-covered by the next milestone (the CP upsert is latest-wins).
4498
+ */
4499
+ emitEventSession(event) {
4500
+ if (this.state !== "READY" && this.state !== "DRAINING") return;
4501
+ this.transport?.send(encode(buildEnvelope("event/session", event)));
4502
+ }
4503
+ /**
4504
+ * Report an integration's channel-membership snapshot (D→C `integration/channels`
4505
+ * EVT, fire-and-forget, latest-wins). No-op unless READY/DRAINING — the daemon
4506
+ * re-emits its cached snapshots on each (re)connect (see onReady in daemon.ts),
4507
+ * so a dropped report only delays the console's channel list, never loses it.
4508
+ */
4509
+ emitIntegrationChannels(snapshot) {
4510
+ if (this.state !== "READY" && this.state !== "DRAINING") return;
4511
+ this.transport?.send(encode(buildEnvelope("integration/channels", snapshot)));
4512
+ }
4513
+ /**
4514
+ * Report a CP-owned cron fire (D→C `cron/report` EVT, fire-and-forget) so the
4515
+ * console's `lastRunAt` converges. The local D11 stamp stays authoritative and
4516
+ * the CP upsert is latest-wins, so re-asserting stored stamps on each
4517
+ * (re)connect (see onReady in daemon.ts) makes a dropped report only a delay,
4518
+ * never a loss. No-op unless READY/DRAINING.
4519
+ */
4520
+ emitCronReport(report) {
4521
+ if (this.state !== "READY" && this.state !== "DRAINING") return;
4522
+ this.transport?.send(encode(buildEnvelope("cron/report", report)));
4523
+ }
4524
+ /**
4525
+ * Emit one chunk of a webchat turn's reply stream (D→C `webchat/output` EVT).
4526
+ * Ordered per-connection (TCP); `output.index` is the per-turn assembly counter.
4527
+ * No-op unless READY/DRAINING — a dropped chunk is lost (unlike latest-wins
4528
+ * telemetry), so the daemon only emits while connected. Content is inline, bounded
4529
+ * by the 256 KiB frame cap; the caller chunks any oversize single event.
4530
+ */
4531
+ emitWebchatOutput(output) {
4532
+ if (this.state !== "READY" && this.state !== "DRAINING") return;
4533
+ this.transport?.send(encode(buildEnvelope("webchat/output", output)));
4534
+ }
4535
+ /** Emit a webchat turn's completion (D→C `webchat/done` EVT). No-op unless READY/DRAINING. */
4536
+ emitWebchatDone(done) {
4537
+ if (this.state !== "READY" && this.state !== "DRAINING") return;
4538
+ this.transport?.send(encode(buildEnvelope("webchat/done", done)));
4539
+ }
4540
+ request(type, payload) {
4541
+ const frame = buildEnvelope(type, payload);
4542
+ return this.correlator.request(frame, (e) => this.transport.send(e));
4543
+ }
4544
+ /**
4545
+ * `gitcred/request` (D→C REQ) → grant payload. The FIRST post-handshake
4546
+ * daemon-issued REQ, so unlike the handshake `request()` it is state-GATED:
4547
+ * outside READY/DRAINING it fails immediately (the credential cache degrades
4548
+ * to its unexpired copy or errors) — never queued, never a bare
4549
+ * `transport!.send` on a dead socket. Single send + 10s budget: the default
4550
+ * 5s×5 retransmit would blow every git-side timeout, and CP-side mint
4551
+ * single-flight makes a later fresh request cheap anyway.
4552
+ */
4553
+ async requestGitCred(payload) {
4554
+ if (this.state !== "READY" && this.state !== "DRAINING" || !this.transport) throw new WireError("INTERNAL", `control plane unreachable (client ${this.state})`, true);
4555
+ const frame = buildEnvelope("gitcred/request", payload);
4556
+ const rep = await this.correlator.request(frame, (e) => this.transport.send(e), {
4557
+ maxTries: 1,
4558
+ ackTimeoutMs: 1e4
4559
+ });
4560
+ if (rep.type !== "gitcred/grant") throw new WireError("INTERNAL", `expected gitcred/grant, got ${rep.type}`, false);
4561
+ return rep.payload;
4562
+ }
4563
+ async onText(text) {
4564
+ const decoded = decodeEnvelope(text);
4565
+ if (!decoded.ok) {
4566
+ this.sendError(decoded.id, this.decodeErrorCode(decoded.msg), decoded.msg, false);
4567
+ return;
4568
+ }
4569
+ const frame = decoded.frame;
4570
+ if (frame.corr && this.correlator.settle(frame)) return;
4571
+ if (this.state !== "READY" && this.state !== "DRAINING") {
4572
+ this.sendError(frame.id, "PROTOCOL_STATE", `${frame.type} illegal in ${this.state}`, false);
4573
+ return;
4574
+ }
4575
+ if (decoded.ext?.epoch !== void 0 && decoded.ext.epoch < this.sessionEpoch) {
4576
+ this.sendError(frame.id, "STALE_EPOCH", "epoch < current", true);
4577
+ return;
4578
+ }
4579
+ this.dispatchControl(frame);
4580
+ }
4581
+ decodeErrorCode(msg) {
4582
+ if (msg === "FRAME_TOO_LARGE") return "FRAME_TOO_LARGE";
4583
+ if (msg === "UNKNOWN_FRAME") return "UNKNOWN_FRAME";
4584
+ return "BAD_PAYLOAD";
4585
+ }
4586
+ sendError(corr, code, message, retryable) {
4587
+ if (!this.transport) return;
4588
+ this.transport.send(encode(buildEnvelope("error", {
4589
+ code,
4590
+ message,
4591
+ retryable
4592
+ }, { corr })));
4593
+ }
4594
+ onClose(code, _reason) {
4595
+ this.stopHeartbeat();
4596
+ this.transport = void 0;
4597
+ this.correlator.rejectAll(new WireError("INTERNAL", "connection closed", true));
4598
+ if (code === 4401) {
4599
+ this.fatal = true;
4600
+ this.state = "CLOSED";
4601
+ this.deps.log.error("cp: AUTH_FAILED (4401) — not reconnecting; re-mint the daemon token");
4602
+ return;
4603
+ }
4604
+ if (this.stopped) {
4605
+ this.state = "CLOSED";
4606
+ return;
4607
+ }
4608
+ this.state = "DEGRADED";
4609
+ this.scheduleReconnect();
4610
+ }
4611
+ armHeartbeat() {
4612
+ this.heartbeatTimer = this.deps.clock.setTimeout(() => {
4613
+ if (this.state === "READY") this.transport?.send(encode(buildEnvelope("heartbeat", {
4614
+ load: this.deps.loadSnapshot(),
4615
+ health: "ok",
4616
+ activeSessions: this.deps.activeSessions(),
4617
+ degradedScopes: this.deps.degradedScopes?.() ?? []
4618
+ })));
4619
+ if (this.state === "READY" || this.state === "DRAINING") this.armHeartbeat();
4620
+ }, this.heartbeatMs);
4621
+ }
4622
+ stopHeartbeat() {
4623
+ if (this.heartbeatTimer !== void 0) {
4624
+ this.deps.clock.clearTimeout(this.heartbeatTimer);
4625
+ this.heartbeatTimer = void 0;
4626
+ }
4627
+ }
4628
+ /** C→D control dispatch. The CP changes config, never live routing. */
4629
+ dispatchControl(frame) {
4630
+ switch (frame.type) {
4631
+ case "config/push":
4632
+ this.deps.configApply.applyConfigPush(frame.payload.keys);
4633
+ return;
4634
+ case "cron/upsert":
4635
+ try {
4636
+ this.deps.configApply.upsertCron(frame.payload);
4637
+ this.reply(frame, "ack", { ok: true });
4638
+ } catch (err) {
4639
+ this.sendError(frame.id, "BAD_PAYLOAD", `cron upsert failed: ${err.message}`, false);
4640
+ }
4641
+ return;
4642
+ case "cron/remove":
4643
+ this.deps.configApply.removeCron(frame.payload.cronId);
4644
+ this.reply(frame, "ack", { ok: true });
4645
+ return;
4646
+ case "cron/run":
4647
+ this.reply(frame, "ack", this.deps.configApply.runCron(frame.payload.cronId));
4648
+ return;
4649
+ case "route/assign": {
4650
+ const a = frame.payload;
4651
+ this.deps.configApply.applyRouteAssign(a);
4652
+ this.reply(frame, "route/assign/ack", {
4653
+ ok: true,
4654
+ sessionKey: a.sessionKey
4655
+ });
4656
+ return;
4657
+ }
4658
+ case "route/update":
4659
+ this.deps.configApply.applyRouteUpdate(frame.payload);
4660
+ return;
4661
+ case "relay/roster":
4662
+ this.deps.configApply.applyRelayRoster(frame.payload.relays);
4663
+ return;
4664
+ case "agent/upsert": {
4665
+ const u = frame.payload;
4666
+ this.deps.configApply.applyAgentUpsert(u.agentId, u.spec);
4667
+ return;
4668
+ }
4669
+ case "agent/remove":
4670
+ this.deps.configApply.applyAgentRemove(frame.payload.agentId);
4671
+ return;
4672
+ case "integration/upsert":
4673
+ this.deps.configApply.applyIntegrationUpsert(frame.payload);
4674
+ return;
4675
+ case "integration/remove":
4676
+ this.deps.configApply.applyIntegrationRemove(frame.payload.integrationId);
4677
+ return;
4678
+ case "agent/launch": {
4679
+ const launch = frame.payload;
4680
+ this.deps.configApply.applyAgentLaunch(launch).then((launched) => this.reply(frame, "agent/launched", launched)).catch((err) => this.sendError(frame.id, "INTERNAL", `agent/launch failed: ${err.message}`, false));
4681
+ return;
4682
+ }
4683
+ case "agent/stop": {
4684
+ const stop = frame.payload;
4685
+ this.deps.configApply.applyAgentStop(stop).then((ack) => this.reply(frame, "ack", ack)).catch((err) => this.sendError(frame.id, "INTERNAL", `agent/stop failed: ${err.message}`, false));
4686
+ return;
4687
+ }
4688
+ case "daemon/drain": {
4689
+ const drain = frame.payload;
4690
+ this.state = "DRAINING";
4691
+ this.deps.configApply.applyDaemonDrain(drain, (p) => this.emit("drain/progress", p)).then((done) => {
4692
+ this.reply(frame, "drain/done", done);
4693
+ if (this.state === "DRAINING") this.state = "READY";
4694
+ }).catch((err) => {
4695
+ this.sendError(frame.id, "INTERNAL", `drain failed: ${err.message}`, false);
4696
+ if (this.state === "DRAINING") this.state = "READY";
4697
+ });
4698
+ return;
4699
+ }
4700
+ case "daemon/restart":
4701
+ this.reply(frame, "daemon/control/ack", this.deps.configApply.applyDaemonRestart(frame.payload));
4702
+ return;
4703
+ case "daemon/upgrade":
4704
+ this.reply(frame, "daemon/control/ack", this.deps.configApply.applyDaemonUpgrade(frame.payload));
4705
+ return;
4706
+ case "session/list":
4707
+ try {
4708
+ this.reply(frame, "session/list/page", this.deps.sessionRead.list(frame.payload));
4709
+ } catch (err) {
4710
+ this.sendError(frame.id, "INTERNAL", `session/list failed: ${err.message}`, false);
4711
+ }
4712
+ return;
4713
+ case "session/history":
4714
+ try {
4715
+ this.reply(frame, "session/history/page", this.deps.sessionRead.history(frame.payload));
4716
+ } catch (err) {
4717
+ this.sendError(frame.id, "INTERNAL", `session/history failed: ${err.message}`, false);
4718
+ }
4719
+ return;
4720
+ case "session/tool-body":
4721
+ try {
4722
+ this.reply(frame, "session/tool-body/chunk", this.deps.sessionRead.toolBody(frame.payload));
4723
+ } catch (err) {
4724
+ this.sendError(frame.id, "INTERNAL", `session/tool-body failed: ${err.message}`, false);
4725
+ }
4726
+ return;
4727
+ case "workspace/list":
4728
+ this.deps.workspaceRead.list(frame.payload).then((page) => this.reply(frame, "workspace/list/page", page)).catch((err) => this.workspaceError(frame.id, "workspace/list", err));
4729
+ return;
4730
+ case "workspace/read":
4731
+ this.deps.workspaceRead.read(frame.payload).then((content) => this.reply(frame, "workspace/read/content", content)).catch((err) => this.workspaceError(frame.id, "workspace/read", err));
4732
+ return;
4733
+ case "workspace/gitstatus":
4734
+ this.deps.workspaceGit.status(frame.payload.agentId).then((status) => this.reply(frame, "workspace/gitstatus/result", status)).catch((err) => this.workspaceError(frame.id, "workspace/gitstatus", err));
4735
+ return;
4736
+ case "workspace/gitpull":
4737
+ this.deps.workspaceGit.pull(frame.payload.agentId).then((result) => this.reply(frame, "workspace/gitpull/result", result)).catch((err) => this.workspaceError(frame.id, "workspace/gitpull", err));
4738
+ return;
4739
+ case "memory/list":
4740
+ this.deps.memoryReader.list(frame.payload).then((page) => this.reply(frame, "memory/list/page", page)).catch((err) => this.memoryError(frame.id, "memory/list", err));
4741
+ return;
4742
+ case "memory/read":
4743
+ this.deps.memoryReader.read(frame.payload).then((content) => this.reply(frame, "memory/read/content", content)).catch((err) => this.memoryError(frame.id, "memory/read", err));
4744
+ return;
4745
+ case "memory/write":
4746
+ this.deps.memoryReader.write(frame.payload).then((ok) => this.reply(frame, "memory/write/ok", ok)).catch((err) => this.memoryError(frame.id, "memory/write", err));
4747
+ return;
4748
+ case "webchat/message":
4749
+ if (!this.deps.onWebchatMessage) {
4750
+ this.sendError(frame.id, "INTERNAL", "webchat not supported", false);
4751
+ return;
4752
+ }
4753
+ try {
4754
+ this.reply(frame, "webchat/ack", this.deps.onWebchatMessage(frame.payload));
4755
+ } catch (err) {
4756
+ this.sendError(frame.id, "INTERNAL", `webchat/message failed: ${err.message}`, false);
4757
+ }
4758
+ return;
4759
+ case "webchat/close":
4760
+ this.deps.onWebchatClose?.(frame.payload);
4761
+ return;
4762
+ case "webchat/set-model":
4763
+ this.deps.onWebchatSetModel?.(frame.payload);
4764
+ return;
4765
+ case "webchat/set-effort":
4766
+ this.deps.onWebchatSetEffort?.(frame.payload);
4767
+ return;
4768
+ case "webchat/set-permission-mode":
4769
+ this.deps.onWebchatSetPermissionMode?.(frame.payload);
4770
+ return;
4771
+ case "webchat/set-fast":
4772
+ this.deps.onWebchatSetFast?.(frame.payload);
4773
+ return;
4774
+ case "webchat/cancel":
4775
+ this.deps.onWebchatCancel?.(frame.payload);
4776
+ return;
4777
+ default:
4778
+ this.deps.log.debug(`cp: ignoring ${frame.type}`);
4779
+ return;
4780
+ }
4781
+ }
4782
+ /** Map a workspace read failure onto the wire: containment/bad-request
4783
+ * violations → BAD_PAYLOAD (their messages are hand-written and path-free);
4784
+ * anything else → INTERNAL with a GENERIC message — raw fs errors (ELOOP,
4785
+ * EACCES, …) embed absolute host paths that must not leak to the CP/UI. */
4786
+ workspaceError(corr, op, err) {
4787
+ if (err instanceof WorkspaceViolationError) {
4788
+ this.sendError(corr, "BAD_PAYLOAD", `${op} failed: ${err.message}`, false);
4789
+ return;
4790
+ }
4791
+ this.deps.log.warn(`cp: ${op} failed: ${err?.message}`);
4792
+ this.sendError(corr, "INTERNAL", `${op} failed`, false);
4793
+ }
4794
+ memoryError(corr, op, err) {
4795
+ if (err instanceof MemoryConflictError) {
4796
+ this.sendError(corr, "CONFLICT", `${op} failed: ${err.message}`, false);
4797
+ return;
4798
+ }
4799
+ if (err instanceof MemoryViolationError || err instanceof MemoryPathError || err instanceof MemoryTooLargeError) {
4800
+ this.sendError(corr, "BAD_PAYLOAD", `${op} failed: ${err.message}`, false);
4801
+ return;
4802
+ }
4803
+ this.deps.log.warn(`cp: ${op} failed: ${err?.message}`);
4804
+ this.sendError(corr, "INTERNAL", `${op} failed`, false);
4805
+ }
4806
+ reply(req, type, payload) {
4807
+ this.transport?.send(encode(buildEnvelope(type, payload, { corr: req.id })));
4808
+ }
4809
+ /** Emit an uncorrelated EVT (e.g. `drain/progress`). */
4810
+ emit(type, payload) {
4811
+ this.transport?.send(encode(buildEnvelope(type, payload)));
4812
+ }
4813
+ };
4814
+ //#endregion
3726
4815
  //#region ../../node_modules/.pnpm/readdirp@5.0.0/node_modules/readdirp/index.js
3727
4816
  const EntryTypes = {
3728
4817
  FILE_TYPE: "files",
@@ -4252,7 +5341,7 @@ const binaryExtensions = /* @__PURE__ */ new Set([
4252
5341
  "zip",
4253
5342
  "zipx"
4254
5343
  ]);
4255
- const isBinaryPath = (filePath) => binaryExtensions.has(sp.extname(filePath).slice(1).toLowerCase());
5344
+ const isBinaryPath = (filePath) => binaryExtensions.has(path$13.extname(filePath).slice(1).toLowerCase());
4256
5345
  const foreach = (val, fn) => {
4257
5346
  if (val instanceof Set) val.forEach(fn);
4258
5347
  else fn(val);
@@ -4287,7 +5376,7 @@ function createFsWatchInstance(path, options, listener, errHandler, emitRaw) {
4287
5376
  const handleEvent = (rawEvent, evPath) => {
4288
5377
  listener(path);
4289
5378
  emitRaw(rawEvent, evPath, { watchedPath: path });
4290
- if (evPath && path !== evPath) fsWatchBroadcast(sp.resolve(path, evPath), KEY_LISTENERS, sp.join(path, evPath));
5379
+ if (evPath && path !== evPath) fsWatchBroadcast(path$13.resolve(path, evPath), KEY_LISTENERS, path$13.join(path, evPath));
4291
5380
  };
4292
5381
  try {
4293
5382
  return watch(path, { persistent: options.persistent }, handleEvent);
@@ -4429,10 +5518,10 @@ var NodeFsHandler = class {
4429
5518
  */
4430
5519
  _watchWithNodeFs(path, listener) {
4431
5520
  const opts = this.fsw.options;
4432
- const directory = sp.dirname(path);
4433
- const basename = sp.basename(path);
5521
+ const directory = path$13.dirname(path);
5522
+ const basename = path$13.basename(path);
4434
5523
  this.fsw._getWatchedDir(directory).add(basename);
4435
- const absolutePath = sp.resolve(path);
5524
+ const absolutePath = path$13.resolve(path);
4436
5525
  const options = { persistent: opts.persistent };
4437
5526
  if (!listener) listener = EMPTY_FN;
4438
5527
  let closer;
@@ -4455,8 +5544,8 @@ var NodeFsHandler = class {
4455
5544
  */
4456
5545
  _handleFile(file, stats, initialAdd) {
4457
5546
  if (this.fsw.closed) return;
4458
- const dirname = sp.dirname(file);
4459
- const basename = sp.basename(file);
5547
+ const dirname = path$13.dirname(file);
5548
+ const basename = path$13.basename(file);
4460
5549
  const parent = this.fsw._getWatchedDir(dirname);
4461
5550
  let prevStats = stats;
4462
5551
  if (parent.has(basename)) return;
@@ -4530,7 +5619,7 @@ var NodeFsHandler = class {
4530
5619
  this.fsw._symlinkPaths.set(full, true);
4531
5620
  }
4532
5621
  _handleRead(directory, initialAdd, wh, target, dir, depth, throttler) {
4533
- directory = sp.join(directory, "");
5622
+ directory = path$13.join(directory, "");
4534
5623
  const throttleKey = target ? `${directory}:${target}` : directory;
4535
5624
  throttler = this.fsw._throttle("readdir", throttleKey, 1e3);
4536
5625
  if (!throttler) return;
@@ -4547,7 +5636,7 @@ var NodeFsHandler = class {
4547
5636
  return;
4548
5637
  }
4549
5638
  const item = entry.path;
4550
- let path = sp.join(directory, item);
5639
+ let path = path$13.join(directory, item);
4551
5640
  current.add(item);
4552
5641
  if (entry.stats.isSymbolicLink() && await this._handleSymlink(entry, directory, path, item)) return;
4553
5642
  if (this.fsw.closed) {
@@ -4556,7 +5645,7 @@ var NodeFsHandler = class {
4556
5645
  }
4557
5646
  if (item === target || !target && !previous.has(item)) {
4558
5647
  this.fsw._incrReadyCount();
4559
- path = sp.join(dir, sp.relative(dir, path));
5648
+ path = path$13.join(dir, path$13.relative(dir, path));
4560
5649
  this._addToNodeFs(path, initialAdd, wh, depth + 1);
4561
5650
  }
4562
5651
  }).on(EV.ERROR, this._boundHandleError);
@@ -4591,10 +5680,10 @@ var NodeFsHandler = class {
4591
5680
  * @returns closer for the watcher instance.
4592
5681
  */
4593
5682
  async _handleDir(dir, stats, initialAdd, depth, target, wh, realpath) {
4594
- const parentDir = this.fsw._getWatchedDir(sp.dirname(dir));
4595
- const tracked = parentDir.has(sp.basename(dir));
5683
+ const parentDir = this.fsw._getWatchedDir(path$13.dirname(dir));
5684
+ const tracked = parentDir.has(path$13.basename(dir));
4596
5685
  if (!(initialAdd && this.fsw.options.ignoreInitial) && !target && !tracked) this.fsw._emit(EV.ADD_DIR, dir, stats);
4597
- parentDir.add(sp.basename(dir));
5686
+ parentDir.add(path$13.basename(dir));
4598
5687
  this.fsw._getWatchedDir(dir);
4599
5688
  let throttler;
4600
5689
  let closer;
@@ -4641,7 +5730,7 @@ var NodeFsHandler = class {
4641
5730
  const follow = this.fsw.options.followSymlinks;
4642
5731
  let closer;
4643
5732
  if (stats.isDirectory()) {
4644
- const absPath = sp.resolve(path);
5733
+ const absPath = path$13.resolve(path);
4645
5734
  const targetPath = follow ? await realpath(path) : path;
4646
5735
  if (this.fsw.closed) return;
4647
5736
  closer = await this._handleDir(wh.watchPath, stats, initialAdd, depth, target, wh, targetPath);
@@ -4650,12 +5739,12 @@ var NodeFsHandler = class {
4650
5739
  } else if (stats.isSymbolicLink()) {
4651
5740
  const targetPath = follow ? await realpath(path) : path;
4652
5741
  if (this.fsw.closed) return;
4653
- const parent = sp.dirname(wh.watchPath);
5742
+ const parent = path$13.dirname(wh.watchPath);
4654
5743
  this.fsw._getWatchedDir(parent).add(wh.watchPath);
4655
5744
  this.fsw._emit(EV.ADD, wh.watchPath, stats);
4656
5745
  closer = await this._handleDir(parent, stats, initialAdd, depth, path, wh, targetPath);
4657
5746
  if (this.fsw.closed) return;
4658
- if (targetPath !== void 0) this.fsw._symlinkPaths.set(sp.resolve(path), targetPath);
5747
+ if (targetPath !== void 0) this.fsw._symlinkPaths.set(path$13.resolve(path), targetPath);
4659
5748
  } else closer = this._handleFile(wh.watchPath, stats, initialAdd);
4660
5749
  ready();
4661
5750
  if (closer) this.fsw._addPathCloser(path, closer);
@@ -4691,9 +5780,9 @@ function createPattern(matcher) {
4691
5780
  if (typeof matcher === "object" && matcher !== null) return (string) => {
4692
5781
  if (matcher.path === string) return true;
4693
5782
  if (matcher.recursive) {
4694
- const relative = sp.relative(matcher.path, string);
5783
+ const relative = path$13.relative(matcher.path, string);
4695
5784
  if (!relative) return false;
4696
- return !relative.startsWith("..") && !sp.isAbsolute(relative);
5785
+ return !relative.startsWith("..") && !path$13.isAbsolute(relative);
4697
5786
  }
4698
5787
  return false;
4699
5788
  };
@@ -4701,7 +5790,7 @@ function createPattern(matcher) {
4701
5790
  }
4702
5791
  function normalizePath(path) {
4703
5792
  if (typeof path !== "string") throw new Error("string expected");
4704
- path = sp.normalize(path);
5793
+ path = path$13.normalize(path);
4705
5794
  path = path.replace(/\\/g, "/");
4706
5795
  let prepend = false;
4707
5796
  if (path.startsWith("//")) prepend = true;
@@ -4738,14 +5827,14 @@ const toUnix = (string) => {
4738
5827
  if (prepend) str = SLASH + str;
4739
5828
  return str;
4740
5829
  };
4741
- const normalizePathToUnix = (path) => toUnix(sp.normalize(toUnix(path)));
5830
+ const normalizePathToUnix = (path) => toUnix(path$13.normalize(toUnix(path)));
4742
5831
  const normalizeIgnored = (cwd = "") => (path) => {
4743
- if (typeof path === "string") return normalizePathToUnix(sp.isAbsolute(path) ? path : sp.join(cwd, path));
5832
+ if (typeof path === "string") return normalizePathToUnix(path$13.isAbsolute(path) ? path : path$13.join(cwd, path));
4744
5833
  else return path;
4745
5834
  };
4746
5835
  const getAbsolutePath = (path, cwd) => {
4747
- if (sp.isAbsolute(path)) return path;
4748
- return sp.join(cwd, path);
5836
+ if (path$13.isAbsolute(path)) return path;
5837
+ return path$13.join(cwd, path);
4749
5838
  };
4750
5839
  const EMPTY_SET = Object.freeze(/* @__PURE__ */ new Set());
4751
5840
  /**
@@ -4774,7 +5863,7 @@ var DirEntry = class {
4774
5863
  try {
4775
5864
  await readdir(dir);
4776
5865
  } catch (err) {
4777
- if (this._removeWatcher) this._removeWatcher(sp.dirname(dir), sp.basename(dir));
5866
+ if (this._removeWatcher) this._removeWatcher(path$13.dirname(dir), path$13.basename(dir));
4778
5867
  }
4779
5868
  }
4780
5869
  has(item) {
@@ -4810,7 +5899,7 @@ var WatchHelper = class {
4810
5899
  const watchPath = path;
4811
5900
  this.path = path = path.replace(REPLACER_RE, "");
4812
5901
  this.watchPath = watchPath;
4813
- this.fullWatchPath = sp.resolve(watchPath);
5902
+ this.fullWatchPath = path$13.resolve(watchPath);
4814
5903
  this.dirParts = [];
4815
5904
  this.dirParts.forEach((parts) => {
4816
5905
  if (parts.length > 1) parts.pop();
@@ -4819,7 +5908,7 @@ var WatchHelper = class {
4819
5908
  this.statMethod = follow ? STAT_METHOD_F : STAT_METHOD_L;
4820
5909
  }
4821
5910
  entryPath(entry) {
4822
- return sp.join(this.watchPath, sp.relative(this.watchPath, entry.fullPath));
5911
+ return path$13.join(this.watchPath, path$13.relative(this.watchPath, entry.fullPath));
4823
5912
  }
4824
5913
  filterPath(entry) {
4825
5914
  const { stats } = entry;
@@ -4955,7 +6044,7 @@ var FSWatcher = class extends EventEmitter {
4955
6044
  })).then((results) => {
4956
6045
  if (this.closed) return;
4957
6046
  results.forEach((item) => {
4958
- if (item) this.add(sp.dirname(item), sp.basename(_origAdd || item));
6047
+ if (item) this.add(path$13.dirname(item), path$13.basename(_origAdd || item));
4959
6048
  });
4960
6049
  });
4961
6050
  return this;
@@ -4968,9 +6057,9 @@ var FSWatcher = class extends EventEmitter {
4968
6057
  const paths = unifyPaths(paths_);
4969
6058
  const { cwd } = this.options;
4970
6059
  paths.forEach((path) => {
4971
- if (!sp.isAbsolute(path) && !this._closers.has(path)) {
4972
- if (cwd) path = sp.join(cwd, path);
4973
- path = sp.resolve(path);
6060
+ if (!path$13.isAbsolute(path) && !this._closers.has(path)) {
6061
+ if (cwd) path = path$13.join(cwd, path);
6062
+ path = path$13.resolve(path);
4974
6063
  }
4975
6064
  this._closePath(path);
4976
6065
  this._addIgnoredPath(path);
@@ -5014,7 +6103,7 @@ var FSWatcher = class extends EventEmitter {
5014
6103
  getWatched() {
5015
6104
  const watchList = {};
5016
6105
  this._watched.forEach((entry, dir) => {
5017
- const index = (this.options.cwd ? sp.relative(this.options.cwd, dir) : dir) || ONE_DOT;
6106
+ const index = (this.options.cwd ? path$13.relative(this.options.cwd, dir) : dir) || ONE_DOT;
5018
6107
  watchList[index] = entry.getChildren().sort();
5019
6108
  });
5020
6109
  return watchList;
@@ -5034,8 +6123,8 @@ var FSWatcher = class extends EventEmitter {
5034
6123
  async _emit(event, path, stats) {
5035
6124
  if (this.closed) return;
5036
6125
  const opts = this.options;
5037
- if (isWindows) path = sp.normalize(path);
5038
- if (opts.cwd) path = sp.relative(opts.cwd, path);
6126
+ if (isWindows) path = path$13.normalize(path);
6127
+ if (opts.cwd) path = path$13.relative(opts.cwd, path);
5039
6128
  const args = [path];
5040
6129
  if (stats != null) args.push(stats);
5041
6130
  const awf = opts.awaitWriteFinish;
@@ -5080,7 +6169,7 @@ var FSWatcher = class extends EventEmitter {
5080
6169
  if (!this._throttle(EVENTS.CHANGE, path, 50)) return this;
5081
6170
  }
5082
6171
  if (opts.alwaysStat && stats === void 0 && (event === EVENTS.ADD || event === EVENTS.ADD_DIR || event === EVENTS.CHANGE)) {
5083
- const fullPath = opts.cwd ? sp.join(opts.cwd, path) : path;
6172
+ const fullPath = opts.cwd ? path$13.join(opts.cwd, path) : path;
5084
6173
  let stats;
5085
6174
  try {
5086
6175
  stats = await stat$1(fullPath);
@@ -5151,7 +6240,7 @@ var FSWatcher = class extends EventEmitter {
5151
6240
  const pollInterval = awf.pollInterval;
5152
6241
  let timeoutHandler;
5153
6242
  let fullPath = path;
5154
- if (this.options.cwd && !sp.isAbsolute(path)) fullPath = sp.join(this.options.cwd, path);
6243
+ if (this.options.cwd && !path$13.isAbsolute(path)) fullPath = path$13.join(this.options.cwd, path);
5155
6244
  const now = /* @__PURE__ */ new Date();
5156
6245
  const writes = this._pendingWrites;
5157
6246
  function awaitWriteFinishFn(prevStat) {
@@ -5208,7 +6297,7 @@ var FSWatcher = class extends EventEmitter {
5208
6297
  * @param directory path of the directory
5209
6298
  */
5210
6299
  _getWatchedDir(directory) {
5211
- const dir = sp.resolve(directory);
6300
+ const dir = path$13.resolve(directory);
5212
6301
  if (!this._watched.has(dir)) this._watched.set(dir, new DirEntry(dir, this._boundRemove));
5213
6302
  return this._watched.get(dir);
5214
6303
  }
@@ -5227,8 +6316,8 @@ var FSWatcher = class extends EventEmitter {
5227
6316
  * @param item base path of item/directory
5228
6317
  */
5229
6318
  _remove(directory, item, isDirectory) {
5230
- const path = sp.join(directory, item);
5231
- const fullPath = sp.resolve(path);
6319
+ const path = path$13.join(directory, item);
6320
+ const fullPath = path$13.resolve(path);
5232
6321
  isDirectory = isDirectory != null ? isDirectory : this._watched.has(path) || this._watched.has(fullPath);
5233
6322
  if (!this._throttle("remove", path, 100)) return;
5234
6323
  if (!isDirectory && this._watched.size === 1) this.add(directory, item, true);
@@ -5238,7 +6327,7 @@ var FSWatcher = class extends EventEmitter {
5238
6327
  parent.remove(item);
5239
6328
  if (this._symlinkPaths.has(fullPath)) this._symlinkPaths.delete(fullPath);
5240
6329
  let relPath = path;
5241
- if (this.options.cwd) relPath = sp.relative(this.options.cwd, path);
6330
+ if (this.options.cwd) relPath = path$13.relative(this.options.cwd, path);
5242
6331
  if (this.options.awaitWriteFinish && this._pendingWrites.has(relPath)) {
5243
6332
  if (this._pendingWrites.get(relPath).cancelWait() === EVENTS.ADD) return;
5244
6333
  }
@@ -5253,8 +6342,8 @@ var FSWatcher = class extends EventEmitter {
5253
6342
  */
5254
6343
  _closePath(path) {
5255
6344
  this._closeFile(path);
5256
- const dir = sp.dirname(path);
5257
- this._getWatchedDir(dir).remove(sp.basename(path));
6345
+ const dir = path$13.dirname(path);
6346
+ this._getWatchedDir(dir).remove(path$13.basename(path));
5258
6347
  }
5259
6348
  /**
5260
6349
  * Closes only file-specific watchers
@@ -5820,150 +6909,6 @@ function monotonicTs() {
5820
6909
  return String(last);
5821
6910
  }
5822
6911
  //#endregion
5823
- //#region src/agents/memory.ts
5824
- /**
5825
- * Agent memory — a DIRECTORY at the agent's ROOT (`<agent-root>/memory/`), OUTSIDE
5826
- * the workspace so it survives a workspace reset / re-clone and is never committed
5827
- * into a github-workspace repo.
5828
- *
5829
- * Index-plus-topics shape (the structure Claude Code's auto-memory uses): a lean
5830
- * `MEMORY.md` index — injected into the prompt every session, so it must stay small
5831
- * — plus any number of `<topic>.md` files the agent reads on demand. The agent
5832
- * maintains it via the `readMemory` / `writeMemory` / `listMemory` MCP tools; the
5833
- * daemon serves the same directory to the CP for the console.
5834
- *
5835
- * SECURITY: topic paths are agent/console-supplied, so every path is contained to
5836
- * the memory dir — absolute paths and `..` escapes are rejected, and the resolved
5837
- * path is re-checked so a symlink can't smuggle a target out. Flat by design (one
5838
- * level): a topic is a file directly under `memory/`, no nested dirs.
5839
- */
5840
- const MEMORY_DIRNAME = "memory";
5841
- /** Max bytes of the index injected into the prompt each session — keeps a large
5842
- * memory from swelling the context (mirrors Claude Code's 25 KB entrypoint cap). */
5843
- const MAX_INDEX_INJECT_BYTES = 25e3;
5844
- /** Hard cap on a single memory file. Injected/read every session, so an unbounded
5845
- * file would swell the prompt and the wire; writes over this are rejected. */
5846
- const MAX_MEMORY_FILE_BYTES = 256e3;
5847
- /** Raised when a memory path escapes the memory dir. Surfaces as `BAD_PAYLOAD`. */
5848
- var MemoryPathError = class extends Error {
5849
- constructor(message) {
5850
- super(message);
5851
- this.name = "MemoryPathError";
5852
- }
5853
- };
5854
- /** Raised when a write exceeds {@link MAX_MEMORY_FILE_BYTES}. `BAD_PAYLOAD`. */
5855
- var MemoryTooLargeError = class extends Error {
5856
- constructor(message) {
5857
- super(message);
5858
- this.name = "MemoryTooLargeError";
5859
- }
5860
- };
5861
- /** Raised when an `ifMatchMtime` precondition fails (the file changed under the
5862
- * writer — e.g. a console edit racing a newer agent write). Surfaces as CONFLICT. */
5863
- var MemoryConflictError = class extends Error {
5864
- constructor(message) {
5865
- super(message);
5866
- this.name = "MemoryConflictError";
5867
- }
5868
- };
5869
- /** The memory directory for an agent, given its root dir (where agent.json lives). */
5870
- function memoryDir(agentDir) {
5871
- return join(agentDir, MEMORY_DIRNAME);
5872
- }
5873
- /** Resolve a memory-dir-relative path to an absolute one, contained to the dir.
5874
- * Rejects absolute paths, `..` escapes, and any nested directory (flat only). */
5875
- function resolveInMemoryDir(agentDir, relPath) {
5876
- if (isAbsolute(relPath)) throw new MemoryPathError("absolute paths are not allowed");
5877
- const dir = memoryDir(agentDir);
5878
- const abs = resolve(dir, relPath);
5879
- if (abs !== dir && !abs.startsWith(dir + sep)) throw new MemoryPathError("path escapes the memory dir");
5880
- const rel = abs.slice(dir.length + 1);
5881
- if (rel.includes(sep)) throw new MemoryPathError("memory is a flat directory (no subdirectories)");
5882
- if (rel.length === 0) throw new MemoryPathError("a file name is required");
5883
- return abs;
5884
- }
5885
- /** Seed `<agent-root>/memory/MEMORY.md` with a header if the dir/index is absent
5886
- * (idempotent). Called at session start so a brand-new agent always has an index. */
5887
- function ensureMemory(agentDir, agentName) {
5888
- const dir = memoryDir(agentDir);
5889
- mkdirSync(dir, { recursive: true });
5890
- const index = join(dir, MEMORY_INDEX);
5891
- if (!existsSync(index)) writeFileSync(index, `# ${agentName} memory\n\nYour long-term memory index. Keep it short — link out to topic files (e.g. \`[deploys](deploys.md)\`) for detail and read them on demand with the readMemory tool.\n`);
5892
- }
5893
- /** Read the memory index (`MEMORY.md`) for prompt injection, trimmed to the inject
5894
- * cap so a large index can't blow up the prompt; '' when absent. */
5895
- async function readIndex(agentDir) {
5896
- const text = await readMemoryFile(agentDir, MEMORY_INDEX);
5897
- if (Buffer.byteLength(text) <= 25e3) return text;
5898
- return Buffer.from(text, "utf8").subarray(0, MAX_INDEX_INJECT_BYTES).toString("utf8") + "\n\n[…memory index truncated — trim MEMORY.md]";
5899
- }
5900
- /** Read a memory file's text; '' when it does not exist (never throws on ENOENT). */
5901
- async function readMemoryFile(agentDir, relPath) {
5902
- const abs = resolveInMemoryDir(agentDir, relPath);
5903
- try {
5904
- return await promises.readFile(abs, "utf8");
5905
- } catch (err) {
5906
- if (err.code === "ENOENT") return "";
5907
- throw err;
5908
- }
5909
- }
5910
- /** Overwrite a memory file with `content` (creating the dir if needed). Atomic
5911
- * (tmp + rename) so a concurrent read never sees a partial file.
5912
- *
5913
- * - Rejects content over {@link MAX_MEMORY_FILE_BYTES} ({@link MemoryTooLargeError}).
5914
- * - `ifMatchMtime` (optimistic concurrency): when given, the current file's mtime
5915
- * must equal it, else {@link MemoryConflictError} — so a console edit can't
5916
- * clobber a newer write. A brand-new file (no mtime) matches `ifMatchMtime`
5917
- * only when the caller passes none (or the empty string). */
5918
- async function writeMemoryFile(agentDir, relPath, content, ifMatchMtime) {
5919
- if (Buffer.byteLength(content) > 256e3) throw new MemoryTooLargeError(`memory file exceeds the ${MAX_MEMORY_FILE_BYTES}-byte limit`);
5920
- const abs = resolveInMemoryDir(agentDir, relPath);
5921
- if (ifMatchMtime) {
5922
- let current = null;
5923
- try {
5924
- current = (await promises.stat(abs)).mtime.toISOString();
5925
- } catch (err) {
5926
- if (err.code !== "ENOENT") throw err;
5927
- }
5928
- if (current !== ifMatchMtime) throw new MemoryConflictError("the memory file changed since it was read; reload and retry");
5929
- }
5930
- await promises.mkdir(memoryDir(agentDir), { recursive: true });
5931
- const tmp = `${abs}.tmp`;
5932
- await promises.writeFile(tmp, content, "utf8");
5933
- await promises.rename(tmp, abs);
5934
- const st = await promises.stat(abs);
5935
- return {
5936
- size: st.size,
5937
- mtime: st.mtime.toISOString()
5938
- };
5939
- }
5940
- /** List the files in the memory dir (flat; index + topics), sorted with the index
5941
- * first. Empty when the dir does not exist. `.tmp` write artifacts are skipped. */
5942
- async function listMemory(agentDir) {
5943
- const dir = memoryDir(agentDir);
5944
- let dirents;
5945
- try {
5946
- dirents = await promises.readdir(dir, { withFileTypes: true });
5947
- } catch (err) {
5948
- if (err.code === "ENOENT") return [];
5949
- throw err;
5950
- }
5951
- const files = [];
5952
- for (const d of dirents) {
5953
- if (!d.isFile() || d.name.endsWith(".tmp")) continue;
5954
- try {
5955
- const st = await promises.stat(join(dir, d.name));
5956
- files.push({
5957
- name: d.name,
5958
- size: st.size,
5959
- mtime: st.mtime.toISOString()
5960
- });
5961
- } catch {}
5962
- }
5963
- files.sort((a, b) => a.name === "MEMORY.md" ? -1 : b.name === "MEMORY.md" ? 1 : a.name.localeCompare(b.name));
5964
- return files;
5965
- }
5966
- //#endregion
5967
6912
  //#region src/session/attachment-block.ts
5968
6913
  /**
5969
6914
  * Turn one Slack attachment into the richest ACP content block the agent can
@@ -119024,469 +119969,41 @@ function makeLogger(level = "info") {
119024
119969
  };
119025
119970
  }
119026
119971
  //#endregion
119027
- //#region src/cp/wire-slice.ts
119028
- /**
119029
- * Pure helpers for slicing file bytes into a single WS frame — shared by the
119030
- * workspace and memory readers so both honour the 256 KiB wire cap and never
119031
- * split a UTF-8 character across slices.
119032
- */
119033
- /** Encoded-payload ceiling, leaving headroom under MAX_FRAME_BYTES for the
119034
- * envelope (id/ts/type/corr + fencing ext, well under 4 KiB). */
119035
- const REPLY_BUDGET$1 = MAX_FRAME_BYTES - 4096;
119036
- /** The encoded size of the payload the wire will carry (JSON.stringify matches
119037
- * the codec's `encode`), measured in bytes. */
119038
- function encodedBytes$1(payload) {
119039
- return Buffer.byteLength(JSON.stringify(payload));
119040
- }
119041
- /** Largest index ≤ `len` that lands on a UTF-8 character boundary — i.e. never
119042
- * cuts a multi-byte sequence. If the slice's trailing char is incomplete, the
119043
- * boundary is placed before that char (its bytes belong to the next slice). */
119044
- function utf8Boundary$1(buf, len) {
119045
- if (len >= buf.length) len = buf.length;
119046
- if (len <= 0) return 0;
119047
- let start = len - 1;
119048
- while (start > 0 && ((buf[start] ?? 0) & 192) === 128) start--;
119049
- const lead = buf[start] ?? 0;
119050
- const seqLen = lead < 128 ? 1 : (lead & 224) === 192 ? 2 : (lead & 240) === 224 ? 3 : (lead & 248) === 240 ? 4 : 1;
119051
- return start + seqLen <= len ? len : start;
119052
- }
119053
- /** Shrink `content` (a utf8 view of `slice[0..end]`) until its JSON-escaped size
119054
- * fits `REPLY_BUDGET`, keeping the cut on a UTF-8 boundary. Returns the final
119055
- * `{ end, content }`. Control-byte-heavy text escapes ~6×, so the raw byte limit
119056
- * is not enough on its own. */
119057
- function fitToBudget(slice, end) {
119058
- let content = slice.toString("utf8", 0, end);
119059
- while (end > 0 && encodedBytes$1(content) > REPLY_BUDGET$1) {
119060
- const factor = REPLY_BUDGET$1 / encodedBytes$1(content);
119061
- const shrunk = Math.max(1, Math.floor(end * factor * .9));
119062
- end = utf8Boundary$1(slice, Math.min(shrunk, end - 1));
119063
- content = slice.toString("utf8", 0, end);
119064
- }
119065
- return {
119066
- end,
119067
- content
119068
- };
119069
- }
119070
- //#endregion
119071
- //#region src/cp/workspace-reader.ts
119972
+ //#region src/cp/relay-client.ts
119072
119973
  /**
119073
- * `WorkspaceReader` — the read-only seam answering the CP's `workspace/list` and
119074
- * `workspace/read` REQs from the agent's local workspace directory. File bytes
119075
- * live only on the daemon (§1/§12); this streams single pages/slices to the CP,
119076
- * never whole trees or files.
119974
+ * `RelayClient` — the daemon's dial-out client to ONE relay over the `rd/*` wire
119975
+ * (shared-bot-relay.md §11 / §7.2). The daemon holds one to every relay in its
119976
+ * roster; {@link RelayManager} set-converges them.
119077
119977
  *
119078
- * Containment is to `workspace.path` EXACTLY — the parent directory holds
119079
- * `agent.json` and `.env` secrets, so escaping even one level is a secret leak.
119080
- * Every request path is (1) rejected if absolute, (2) lexically resolved against
119081
- * the root and prefix-checked, then (3) canonicalised with `realpath` and
119082
- * re-verified so symlinks (including an intermediate directory component swapped
119083
- * to a symlink after the check) cannot smuggle a target outside the root; all
119084
- * I/O runs on the canonical path. Violations throw `WorkspaceViolationError`,
119085
- * which the dispatcher maps to a `BAD_PAYLOAD` error frame; missing paths are
119086
- * data (`exists:false`), not errors. Never log file contents.
119087
- *
119088
- * Frame-size safety: every REP must serialise under the 256 KiB wire cap. JSON
119089
- * escaping of control bytes is a 6× blowup, so `read` bounds the slice by the
119090
- * *encoded* reply size (not the raw byte `limit`) and `list` stops a page early
119091
- * when the encoded entries would overflow — both leave headroom for the envelope.
119978
+ * Milestone A2 establishes the connection: dial → `rd/hello` (presenting the
119979
+ * daemon's existing CP API key, which the relay delegates to the CP via
119980
+ * `rc/verify`) → `rd/hello/ok`, whose echoed `relayId` MUST match the roster entry
119981
+ * dialed (a mismatch = a pool LB in front of the daemon dial path → close +
119982
+ * backoff, never continue on the wrong instance, §5). Content (`rd/msg`) flows in
119983
+ * PR 3. Built on the shared `@agentconnect.md/connection` primitives — the first
119984
+ * daemon consumer of that package.
119092
119985
  */
119093
- /** Bytes sniffed from the head of a file for binary (NUL byte) detection. */
119094
- const SNIFF_BYTES = 8192;
119095
- /** Path-containment / bad-request violation → `BAD_PAYLOAD` on the wire. */
119096
- var WorkspaceViolationError = class extends Error {
119097
- constructor(message) {
119098
- super(message);
119099
- this.name = "WorkspaceViolationError";
119100
- }
119101
- };
119102
- function isErrno$1(err, code) {
119103
- return err?.code === code;
119104
- }
119105
- function under(root, p) {
119106
- return p === root || p.startsWith(root + sp.sep);
119107
- }
119108
- function createWorkspaceReader(workspaceRootByAgent) {
119109
- function rootFor(agentId) {
119110
- const root = workspaceRootByAgent(agentId);
119111
- if (!root) throw new WorkspaceViolationError(`unknown agent "${agentId}"`);
119112
- return root;
119113
- }
119114
- /**
119115
- * Lexical containment. Rejects absolute paths and `..` escapes; returns the
119116
- * lexically-resolved path plus the canonical (realpath'd) root, or `null` for
119117
- * `realRoot` when the root does not exist yet (callers report `exists:false`).
119118
- */
119119
- async function resolveContained(root, relPath) {
119120
- if (sp.isAbsolute(relPath)) throw new WorkspaceViolationError("absolute paths are not allowed");
119121
- const resolved = sp.resolve(root, relPath);
119122
- if (!under(root, resolved)) throw new WorkspaceViolationError("path escapes the workspace root");
119123
- try {
119124
- return {
119125
- resolved,
119126
- realRoot: await promises.realpath(root)
119127
- };
119128
- } catch (err) {
119129
- if (isErrno$1(err, "ENOENT")) return {
119130
- resolved,
119131
- realRoot: null
119132
- };
119133
- throw err;
119134
- }
119135
- }
119136
- /** Canonicalise an existing target and re-verify containment on the REAL path,
119137
- * closing the check-vs-use gap (a symlinked intermediate component resolves
119138
- * out and is rejected here). Returns the canonical path used for I/O. */
119139
- async function canonicalUnder(realRoot, abs) {
119140
- const canon = await promises.realpath(abs);
119141
- if (!under(realRoot, canon)) throw new WorkspaceViolationError("path resolves outside the workspace root");
119142
- return canon;
119143
- }
119144
- return {
119145
- async list(req) {
119146
- const { resolved, realRoot } = await resolveContained(rootFor(req.agentId), req.path);
119147
- const notFound = {
119148
- agentId: req.agentId,
119149
- path: req.path,
119150
- exists: false,
119151
- entries: []
119152
- };
119153
- if (realRoot === null) return notFound;
119154
- let target;
119155
- try {
119156
- target = await canonicalUnder(realRoot, resolved);
119157
- } catch (err) {
119158
- if (isErrno$1(err, "ENOENT")) return notFound;
119159
- throw err;
119160
- }
119161
- let dirents;
119162
- try {
119163
- dirents = await promises.readdir(target, { withFileTypes: true });
119164
- } catch (err) {
119165
- if (isErrno$1(err, "ENOENT") || isErrno$1(err, "ENOTDIR")) return notFound;
119166
- throw err;
119167
- }
119168
- const entries = [];
119169
- for (const d of dirents) {
119170
- if (d.name === ".git") continue;
119171
- const type = d.isDirectory() ? "dir" : d.isFile() ? "file" : d.isSymbolicLink() ? "symlink" : "other";
119172
- const entry = {
119173
- name: d.name,
119174
- type
119175
- };
119176
- if (type === "file") try {
119177
- const st = await promises.lstat(sp.join(target, d.name));
119178
- entry.size = st.size;
119179
- entry.mtime = st.mtime.toISOString();
119180
- } catch {}
119181
- entries.push(entry);
119182
- }
119183
- entries.sort(compareEntries);
119184
- const start = req.cursor !== void 0 && /^\d+$/.test(req.cursor) ? Number(req.cursor) : 0;
119185
- const page = [];
119186
- let acc = 0;
119187
- for (let i = start; i < entries.length && page.length < req.limit; i++) {
119188
- const e = entries[i];
119189
- const enc = encodedBytes$1(e) + 1;
119190
- if (page.length > 0 && acc + enc > REPLY_BUDGET$1) break;
119191
- page.push(e);
119192
- acc += enc;
119193
- }
119194
- const consumed = start + page.length;
119195
- return {
119196
- agentId: req.agentId,
119197
- path: req.path,
119198
- exists: true,
119199
- entries: page,
119200
- ...consumed < entries.length ? { nextCursor: String(consumed) } : {}
119201
- };
119202
- },
119203
- async read(req) {
119204
- const { resolved, realRoot } = await resolveContained(rootFor(req.agentId), req.path);
119205
- const notFound = {
119206
- agentId: req.agentId,
119207
- path: req.path,
119208
- exists: false
119209
- };
119210
- if (realRoot === null) return notFound;
119211
- let st;
119212
- try {
119213
- st = await promises.lstat(resolved);
119214
- } catch (err) {
119215
- if (isErrno$1(err, "ENOENT")) return notFound;
119216
- throw err;
119217
- }
119218
- if (!st.isFile()) throw new WorkspaceViolationError("not a regular file");
119219
- const target = await canonicalUnder(realRoot, resolved);
119220
- const size = st.size;
119221
- const mtime = st.mtime.toISOString();
119222
- const fh = await promises.open(target, "r");
119223
- try {
119224
- const sniffLen = Math.min(SNIFF_BYTES, size);
119225
- if (sniffLen > 0) {
119226
- const sniff = Buffer.alloc(sniffLen);
119227
- const { bytesRead } = await fh.read(sniff, 0, sniffLen, 0);
119228
- if (sniff.subarray(0, bytesRead).includes(0)) return {
119229
- agentId: req.agentId,
119230
- path: req.path,
119231
- exists: true,
119232
- size,
119233
- mtime,
119234
- encoding: "none"
119235
- };
119236
- }
119237
- const want = Math.min(req.limit, Math.max(0, size - req.offset));
119238
- let slice = Buffer.alloc(0);
119239
- if (want > 0) {
119240
- const buf = Buffer.alloc(want);
119241
- const { bytesRead } = await fh.read(buf, 0, want, req.offset);
119242
- slice = buf.subarray(0, bytesRead);
119243
- }
119244
- let end = utf8Boundary$1(slice, slice.length);
119245
- const fitted = fitToBudget(slice, end);
119246
- end = fitted.end;
119247
- const content = fitted.content;
119248
- const nextOffset = req.offset + end;
119249
- return {
119250
- agentId: req.agentId,
119251
- path: req.path,
119252
- exists: true,
119253
- size,
119254
- mtime,
119255
- encoding: "utf8",
119256
- content,
119257
- offset: req.offset,
119258
- nextOffset,
119259
- truncated: nextOffset < size
119260
- };
119261
- } finally {
119262
- await fh.close();
119263
- }
119264
- }
119265
- };
119266
- }
119267
- /** dirs first, then case-insensitive alphabetical (stable within the page). */
119268
- function compareEntries(a, b) {
119269
- const aDir = a.type === "dir" ? 0 : 1;
119270
- const bDir = b.type === "dir" ? 0 : 1;
119271
- if (aDir !== bDir) return aDir - bDir;
119272
- const al = a.name.toLowerCase();
119273
- const bl = b.name.toLowerCase();
119274
- if (al !== bl) return al < bl ? -1 : 1;
119275
- return a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
119276
- }
119277
- //#endregion
119278
- //#region src/cp/memory-reader.ts
119279
- /**
119280
- * `MemoryReader` — the seam answering the CP's `memory/list`, `memory/read` and
119281
- * `memory/write` REQs for an agent's memory directory (`<agent-root>/memory/`).
119282
- * The files live only on the daemon (§1/§12); reads stream single byte slices,
119283
- * writes replace one whole file.
119284
- *
119285
- * Path containment lives in `agents/memory.ts` (`resolveInMemoryDir`): a topic
119286
- * path that escapes the memory dir throws `MemoryPathError` → `BAD_PAYLOAD`. An
119287
- * unknown agent is also `BAD_PAYLOAD`; a not-yet-created file/dir is data
119288
- * (`exists:false`), not an error. Frame-size safety mirrors the workspace reader:
119289
- * the read slice is bounded by the *encoded* reply size and ends on a UTF-8
119290
- * boundary. Never log memory contents.
119291
- */
119292
- /** Unknown-agent violation → `BAD_PAYLOAD` on the wire (path escapes surface as
119293
- * MemoryPathError from agents/memory.ts, mapped the same way by the dispatcher). */
119294
- var MemoryViolationError = class extends Error {
119295
- constructor(message) {
119296
- super(message);
119297
- this.name = "MemoryViolationError";
119298
- }
119299
- };
119300
- function isErrno(err, code) {
119301
- return err?.code === code;
119302
- }
119303
- /** `agentDirByAgent` resolves an agent id → its root dir (which holds `memory/`),
119304
- * or undefined for an unknown agent. */
119305
- function createMemoryReader(agentDirByAgent) {
119306
- function dirFor(agentId) {
119307
- const dir = agentDirByAgent(agentId);
119308
- if (!dir) throw new MemoryViolationError(`unknown agent "${agentId}"`);
119309
- return dir;
119310
- }
119311
- return {
119312
- async list(req) {
119313
- const files = await listMemory(dirFor(req.agentId));
119314
- return {
119315
- agentId: req.agentId,
119316
- exists: files.length > 0,
119317
- entries: files
119318
- };
119319
- },
119320
- async read(req) {
119321
- const file = resolveInMemoryDir(dirFor(req.agentId), req.path);
119322
- const notFound = {
119323
- agentId: req.agentId,
119324
- path: req.path,
119325
- exists: false
119326
- };
119327
- let st;
119328
- try {
119329
- st = await promises.stat(file);
119330
- } catch (err) {
119331
- if (isErrno(err, "ENOENT")) return notFound;
119332
- throw err;
119333
- }
119334
- if (!st.isFile()) return notFound;
119335
- const size = st.size;
119336
- const mtime = st.mtime.toISOString();
119337
- const fh = await promises.open(file, "r");
119338
- try {
119339
- const want = Math.min(req.limit, Math.max(0, size - req.offset));
119340
- let slice = Buffer.alloc(0);
119341
- if (want > 0) {
119342
- const buf = Buffer.alloc(want);
119343
- const { bytesRead } = await fh.read(buf, 0, want, req.offset);
119344
- slice = buf.subarray(0, bytesRead);
119345
- }
119346
- const { end, content } = fitToBudget(slice, utf8Boundary$1(slice, slice.length));
119347
- const nextOffset = req.offset + end;
119348
- return {
119349
- agentId: req.agentId,
119350
- path: req.path,
119351
- exists: true,
119352
- size,
119353
- mtime,
119354
- content,
119355
- offset: req.offset,
119356
- nextOffset,
119357
- truncated: nextOffset < size
119358
- };
119359
- } finally {
119360
- await fh.close();
119361
- }
119362
- },
119363
- async write(req) {
119364
- const { size, mtime } = await writeMemoryFile(dirFor(req.agentId), req.path, req.content, req.ifMatchMtime);
119365
- return {
119366
- agentId: req.agentId,
119367
- path: req.path,
119368
- size,
119369
- mtime
119370
- };
119371
- }
119372
- };
119373
- }
119374
- //#endregion
119375
- //#region src/cp/correlator.ts
119376
- /** Transport-free representation of a typed wire `error` REP. */
119377
- var WireError = class extends Error {
119378
- code;
119379
- retryable;
119380
- details;
119381
- constructor(code, message, retryable = false, details) {
119382
- super(message);
119383
- this.name = "WireError";
119384
- this.code = code;
119385
- this.retryable = retryable;
119386
- if (details) this.details = details;
119387
- }
119388
- };
119389
- var ReqRep = class {
119390
- clock;
119391
- ackTimeoutMs;
119392
- maxTries;
119393
- pending = /* @__PURE__ */ new Map();
119394
- constructor(clock, ackTimeoutMs, maxTries = 5) {
119395
- this.clock = clock;
119396
- this.ackTimeoutMs = ackTimeoutMs;
119397
- this.maxTries = maxTries;
119398
- }
119399
- request(frame, write, opts = {}) {
119400
- const encoded = JSON.stringify(frame);
119401
- return new Promise((resolve, reject) => {
119402
- const entry = {
119403
- id: frame.id,
119404
- encoded,
119405
- resolve,
119406
- reject,
119407
- tries: 1,
119408
- maxTries: opts.maxTries ?? this.maxTries
119409
- };
119410
- this.pending.set(frame.id, entry);
119411
- this.arm(entry, write, opts.ackTimeoutMs ?? this.ackTimeoutMs);
119412
- try {
119413
- write(encoded);
119414
- } catch (e) {
119415
- this.fail(entry, new WireError("INTERNAL", `send failed: ${e.message}`, true));
119416
- }
119417
- });
119418
- }
119419
- arm(entry, write, ackTimeoutMs) {
119420
- entry.timer = this.clock.setTimeout(() => {
119421
- if (!this.pending.has(entry.id)) return;
119422
- if (entry.tries >= entry.maxTries) {
119423
- this.pending.delete(entry.id);
119424
- entry.reject(new WireError("INTERNAL", `no ack after ${entry.maxTries} tries for ${entry.id}`, true));
119425
- return;
119426
- }
119427
- entry.tries += 1;
119428
- this.arm(entry, write, ackTimeoutMs);
119429
- try {
119430
- write(entry.encoded);
119431
- } catch (e) {
119432
- this.fail(entry, new WireError("INTERNAL", `send failed: ${e.message}`, true));
119433
- }
119434
- }, ackTimeoutMs);
119435
- }
119436
- fail(entry, err) {
119437
- if (!this.pending.has(entry.id)) return;
119438
- this.pending.delete(entry.id);
119439
- if (entry.timer !== void 0) this.clock.clearTimeout(entry.timer);
119440
- entry.reject(err);
119441
- }
119442
- settle(frame) {
119443
- const corr = frame.corr;
119444
- if (!corr) return false;
119445
- const entry = this.pending.get(corr);
119446
- if (!entry) return false;
119447
- this.pending.delete(corr);
119448
- if (entry.timer !== void 0) this.clock.clearTimeout(entry.timer);
119449
- if (frame.type === "error") {
119450
- const e = frame.payload;
119451
- entry.reject(new WireError(e.code, e.message, e.retryable, e.details));
119452
- } else entry.resolve(frame);
119453
- return true;
119454
- }
119455
- inflight() {
119456
- return this.pending.size;
119457
- }
119458
- rejectAll(err) {
119459
- for (const entry of this.pending.values()) {
119460
- if (entry.timer !== void 0) this.clock.clearTimeout(entry.timer);
119461
- entry.reject(err);
119462
- }
119463
- this.pending.clear();
119464
- }
119465
- };
119466
- //#endregion
119467
- //#region src/cp/client.ts
119468
119986
  const ACK_TIMEOUT_MS = 5e3;
119469
- const BACKOFF_BASE_MS = 1e3;
119470
- const BACKOFF_CAP_MS = 3e4;
119471
- var CpClient = class {
119987
+ /** rd/hello definitively rejected (bad/revoked key, daemonId mismatch) → stop dialing THIS relay. */
119988
+ const CLOSE_AUTH_FAILED = 4401;
119989
+ var RelayClient = class {
119990
+ relayId;
119991
+ url;
119472
119992
  deps;
119473
119993
  state = "CLOSED";
119474
- sessionEpoch = 0;
119475
- routingEpoch = 0;
119476
119994
  transport;
119477
119995
  correlator;
119996
+ backoff;
119478
119997
  stopped = false;
119479
119998
  fatal = false;
119480
- attempt = 0;
119481
119999
  reconnectTimer;
119482
- lastAuthedEpoch = 0;
119483
- heartbeatTimer;
119484
- heartbeatMs = 0;
119485
- constructor(deps) {
120000
+ constructor(relayId, url, deps) {
120001
+ this.relayId = relayId;
120002
+ this.url = url;
119486
120003
  this.deps = deps;
119487
120004
  this.correlator = new ReqRep(deps.clock, ACK_TIMEOUT_MS);
120005
+ this.backoff = new Backoff(deps.jitter ? { jitter: deps.jitter } : {});
119488
120006
  }
119489
- /** Non-blocking: kicks off the connect loop and returns. */
119490
120007
  start() {
119491
120008
  this.stopped = false;
119492
120009
  this.fatal = false;
@@ -119498,16 +120015,24 @@ var CpClient = class {
119498
120015
  this.deps.clock.clearTimeout(this.reconnectTimer);
119499
120016
  this.reconnectTimer = void 0;
119500
120017
  }
119501
- this.stopHeartbeat();
119502
120018
  this.correlator.rejectAll(/* @__PURE__ */ new Error("stopping"));
119503
120019
  this.transport?.close(1e3, "shutdown");
119504
120020
  this.state = "CLOSED";
119505
120021
  }
120022
+ isReady() {
120023
+ return this.state === "READY";
120024
+ }
119506
120025
  async attemptConnect() {
119507
120026
  if (this.stopped || this.fatal) return;
120027
+ const daemonId = this.deps.daemonId();
120028
+ if (!daemonId) {
120029
+ this.state = "DEGRADED";
120030
+ this.scheduleReconnect();
120031
+ return;
120032
+ }
119508
120033
  this.state = "CONNECTING";
119509
120034
  try {
119510
- const t = await this.deps.connect();
120035
+ const t = await this.deps.connect(this.url);
119511
120036
  if (this.stopped || this.fatal) {
119512
120037
  t.close(1e3, "shutdown");
119513
120038
  return;
@@ -119515,196 +120040,44 @@ var CpClient = class {
119515
120040
  this.transport = t;
119516
120041
  t.onMessage((txt) => void this.onText(txt));
119517
120042
  t.onClose((c, r) => this.onClose(c, r));
119518
- await this.handshake();
119519
- this.attempt = 0;
120043
+ await this.handshake(daemonId);
120044
+ this.backoff.reset();
119520
120045
  } catch (err) {
119521
- this.deps.log.warn(`cp: connect/handshake failed: ${err.message}`);
120046
+ this.deps.log.warn(`relay(${this.relayId}): connect/hello failed: ${err.message}`);
119522
120047
  this.transport?.close(1011, "handshake failed");
119523
120048
  this.transport = void 0;
119524
120049
  this.scheduleReconnect();
119525
120050
  }
119526
120051
  }
119527
- scheduleReconnect() {
119528
- if (this.stopped || this.fatal) return;
119529
- if (this.reconnectTimer !== void 0) return;
119530
- const jitter = this.deps.jitter ?? Math.random;
119531
- const base = Math.min(BACKOFF_CAP_MS, BACKOFF_BASE_MS * 2 ** this.attempt);
119532
- const delay = Math.min(BACKOFF_CAP_MS, base + Math.floor(jitter() * base));
119533
- this.attempt += 1;
119534
- this.reconnectTimer = this.deps.clock.setTimeout(() => {
119535
- this.reconnectTimer = void 0;
119536
- this.attemptConnect();
119537
- }, delay);
119538
- }
119539
- async handshake() {
119540
- this.state = "AUTHENTICATING";
119541
- const authPayload = {
119542
- apiKey: this.deps.token,
119543
- agentVersion: this.deps.agentVersion
119544
- };
119545
- if (this.deps.daemonId) authPayload.daemonId = this.deps.daemonId;
119546
- if (this.lastAuthedEpoch > 0) authPayload.resume = { lastEpoch: this.lastAuthedEpoch };
119547
- const ok = (await this.request("auth", authPayload)).payload;
119548
- this.sessionEpoch = ok.sessionEpoch;
119549
- this.lastAuthedEpoch = ok.sessionEpoch;
119550
- if (ok.daemonId && ok.daemonId !== this.deps.daemonId) {
119551
- this.deps.daemonId = ok.daemonId;
119552
- this.deps.onDaemonId?.(ok.daemonId);
119553
- }
119554
- this.deps.onWebAppUrl?.(ok.webAppUrl);
119555
- this.deps.onOrgSlug?.(ok.orgSlug);
119556
- this.state = "REGISTERING";
119557
- const snap = (await this.request("register", {
119558
- host: this.deps.host,
119559
- capabilities: this.deps.capabilities(),
119560
- maxAgents: this.deps.maxAgents,
119561
- localState: this.deps.localState()
119562
- })).payload;
119563
- this.routingEpoch = snap.routingEpoch;
119564
- this.deps.configApply.applyReconcileSnapshot(snap);
120052
+ async handshake(daemonId) {
120053
+ this.state = "HELLO";
120054
+ const rep = await this.sendRequest(buildRelayDaemonFrame("rd/hello", {
120055
+ apiKey: this.deps.apiKey(),
120056
+ daemonId
120057
+ }));
120058
+ if (rep.type !== "rd/hello/ok") throw new WireError("INTERNAL", `expected rd/hello/ok, got ${rep.type}`, false);
120059
+ const ok = rep.payload;
120060
+ if (ok.relayId !== this.relayId) throw new WireError("INTERNAL", `relay misroute: dialed ${this.relayId}, reached ${ok.relayId}`, true);
119565
120061
  this.state = "READY";
119566
- this.heartbeatMs = ok.heartbeatSec > 0 ? ok.heartbeatSec * 1e3 : this.deps.heartbeatDefaultMs;
119567
- this.armHeartbeat();
119568
- this.deps.log.info(`cp: READY (epoch=${this.sessionEpoch}, routingEpoch=${this.routingEpoch})`);
119569
- this.emitDaemonRuntimes(this.deps.runtimeProfiles(), this.deps.mcpServerFacts?.() ?? []);
119570
- this.deps.onReady?.();
119571
- }
119572
- /**
119573
- * Push the full runtime snapshot (D→C `facts/daemon-runtimes` EVT,
119574
- * fire-and-forget) — sent on each register and again when a probe sweep
119575
- * completes. REPLACE semantics on the CP: it reconciles the daemon's stored
119576
- * runtime list to exactly this snapshot, pruning runtimes that are no longer
119577
- * installed. No-op unless READY/DRAINING.
119578
- */
119579
- emitDaemonRuntimes(runtimes, mcpServers = []) {
119580
- if (this.state !== "READY" && this.state !== "DRAINING") return;
119581
- this.transport?.send(encode(buildEnvelope("facts/daemon-runtimes", {
119582
- runtimes,
119583
- mcpServers
119584
- })));
119585
- }
119586
- /**
119587
- * Report a session's cumulative token usage (D→C `usage/report` EVT,
119588
- * fire-and-forget). No-op unless READY/DRAINING — usage is dashboard telemetry,
119589
- * so a dropped report just means that turn's delta is absent from the CP's
119590
- * historical aggregates until the next report (which re-sends the cumulative
119591
- * snapshot, latest-wins). Never blocks the turn.
119592
- */
119593
- emitUsageReport(report) {
119594
- if (this.state !== "READY" && this.state !== "DRAINING") return;
119595
- this.transport?.send(encode(buildEnvelope("usage/report", report)));
119596
- }
119597
- /**
119598
- * Report a session's converged milestone + sessionKey echo (D→C `event/session`
119599
- * EVT, fire-and-forget, latest-wins per `sessionId`). This is what lets the CP
119600
- * store session metadata so a deep-link detail page (…/sessions/:id) resolves
119601
- * even when the daemon is offline. Metadata only — never the message stream
119602
- * (that stays daemon-local, §1/§12). No-op unless READY/DRAINING; a dropped
119603
- * `start` is re-covered by the next milestone (the CP upsert is latest-wins).
119604
- */
119605
- emitEventSession(event) {
119606
- if (this.state !== "READY" && this.state !== "DRAINING") return;
119607
- this.transport?.send(encode(buildEnvelope("event/session", event)));
119608
- }
119609
- /**
119610
- * Report an integration's channel-membership snapshot (D→C `integration/channels`
119611
- * EVT, fire-and-forget, latest-wins). No-op unless READY/DRAINING — the daemon
119612
- * re-emits its cached snapshots on each (re)connect (see onReady in daemon.ts),
119613
- * so a dropped report only delays the console's channel list, never loses it.
119614
- */
119615
- emitIntegrationChannels(snapshot) {
119616
- if (this.state !== "READY" && this.state !== "DRAINING") return;
119617
- this.transport?.send(encode(buildEnvelope("integration/channels", snapshot)));
120062
+ this.deps.log.info(`relay(${this.relayId}): rd/* connected`);
119618
120063
  }
119619
- /**
119620
- * Report a CP-owned cron fire (D→C `cron/report` EVT, fire-and-forget) so the
119621
- * console's `lastRunAt` converges. The local D11 stamp stays authoritative and
119622
- * the CP upsert is latest-wins, so re-asserting stored stamps on each
119623
- * (re)connect (see onReady in daemon.ts) makes a dropped report only a delay,
119624
- * never a loss. No-op unless READY/DRAINING.
119625
- */
119626
- emitCronReport(report) {
119627
- if (this.state !== "READY" && this.state !== "DRAINING") return;
119628
- this.transport?.send(encode(buildEnvelope("cron/report", report)));
119629
- }
119630
- /**
119631
- * Emit one chunk of a webchat turn's reply stream (D→C `webchat/output` EVT).
119632
- * Ordered per-connection (TCP); `output.index` is the per-turn assembly counter.
119633
- * No-op unless READY/DRAINING — a dropped chunk is lost (unlike latest-wins
119634
- * telemetry), so the daemon only emits while connected. Content is inline, bounded
119635
- * by the 256 KiB frame cap; the caller chunks any oversize single event.
119636
- */
119637
- emitWebchatOutput(output) {
119638
- if (this.state !== "READY" && this.state !== "DRAINING") return;
119639
- this.transport?.send(encode(buildEnvelope("webchat/output", output)));
119640
- }
119641
- /** Emit a webchat turn's completion (D→C `webchat/done` EVT). No-op unless READY/DRAINING. */
119642
- emitWebchatDone(done) {
119643
- if (this.state !== "READY" && this.state !== "DRAINING") return;
119644
- this.transport?.send(encode(buildEnvelope("webchat/done", done)));
119645
- }
119646
- request(type, payload) {
119647
- const frame = buildEnvelope(type, payload);
120064
+ sendRequest(frame) {
119648
120065
  return this.correlator.request(frame, (e) => this.transport.send(e));
119649
120066
  }
119650
- /**
119651
- * `gitcred/request` (D→C REQ) → grant payload. The FIRST post-handshake
119652
- * daemon-issued REQ, so unlike the handshake `request()` it is state-GATED:
119653
- * outside READY/DRAINING it fails immediately (the credential cache degrades
119654
- * to its unexpired copy or errors) — never queued, never a bare
119655
- * `transport!.send` on a dead socket. Single send + 10s budget: the default
119656
- * 5s×5 retransmit would blow every git-side timeout, and CP-side mint
119657
- * single-flight makes a later fresh request cheap anyway.
119658
- */
119659
- async requestGitCred(payload) {
119660
- if (this.state !== "READY" && this.state !== "DRAINING" || !this.transport) throw new WireError("INTERNAL", `control plane unreachable (client ${this.state})`, true);
119661
- const frame = buildEnvelope("gitcred/request", payload);
119662
- const rep = await this.correlator.request(frame, (e) => this.transport.send(e), {
119663
- maxTries: 1,
119664
- ackTimeoutMs: 1e4
119665
- });
119666
- if (rep.type !== "gitcred/grant") throw new WireError("INTERNAL", `expected gitcred/grant, got ${rep.type}`, false);
119667
- return rep.payload;
119668
- }
119669
120067
  async onText(text) {
119670
- const decoded = decodeEnvelope(text);
119671
- if (!decoded.ok) {
119672
- this.sendError(decoded.id, this.decodeErrorCode(decoded.msg), decoded.msg, false);
119673
- return;
119674
- }
120068
+ const decoded = decodeRelayDaemonFrame(text);
120069
+ if (!decoded.ok) return;
119675
120070
  const frame = decoded.frame;
119676
120071
  if (frame.corr && this.correlator.settle(frame)) return;
119677
- if (this.state !== "READY" && this.state !== "DRAINING") {
119678
- this.sendError(frame.id, "PROTOCOL_STATE", `${frame.type} illegal in ${this.state}`, false);
119679
- return;
119680
- }
119681
- if (decoded.ext?.epoch !== void 0 && decoded.ext.epoch < this.sessionEpoch) {
119682
- this.sendError(frame.id, "STALE_EPOCH", "epoch < current", true);
119683
- return;
119684
- }
119685
- this.dispatchControl(frame);
119686
- }
119687
- decodeErrorCode(msg) {
119688
- if (msg === "FRAME_TOO_LARGE") return "FRAME_TOO_LARGE";
119689
- if (msg === "UNKNOWN_FRAME") return "UNKNOWN_FRAME";
119690
- return "BAD_PAYLOAD";
119691
- }
119692
- sendError(corr, code, message, retryable) {
119693
- if (!this.transport) return;
119694
- this.transport.send(encode(buildEnvelope("error", {
119695
- code,
119696
- message,
119697
- retryable
119698
- }, { corr })));
120072
+ this.deps.log.debug(`relay(${this.relayId}): ignoring ${frame.type}`);
119699
120073
  }
119700
120074
  onClose(code, _reason) {
119701
- this.stopHeartbeat();
119702
120075
  this.transport = void 0;
119703
120076
  this.correlator.rejectAll(new WireError("INTERNAL", "connection closed", true));
119704
- if (code === 4401) {
120077
+ if (code === CLOSE_AUTH_FAILED) {
119705
120078
  this.fatal = true;
119706
120079
  this.state = "CLOSED";
119707
- this.deps.log.error("cp: AUTH_FAILED (4401) — not reconnecting; re-mint the daemon token");
120080
+ this.deps.log.warn(`relay(${this.relayId}): rd/hello rejected (4401) — not redialing`);
119708
120081
  return;
119709
120082
  }
119710
120083
  if (this.stopped) {
@@ -119714,204 +120087,50 @@ var CpClient = class {
119714
120087
  this.state = "DEGRADED";
119715
120088
  this.scheduleReconnect();
119716
120089
  }
119717
- armHeartbeat() {
119718
- this.heartbeatTimer = this.deps.clock.setTimeout(() => {
119719
- if (this.state === "READY") this.transport?.send(encode(buildEnvelope("heartbeat", {
119720
- load: this.deps.loadSnapshot(),
119721
- health: "ok",
119722
- activeSessions: this.deps.activeSessions(),
119723
- degradedScopes: this.deps.degradedScopes?.() ?? []
119724
- })));
119725
- if (this.state === "READY" || this.state === "DRAINING") this.armHeartbeat();
119726
- }, this.heartbeatMs);
119727
- }
119728
- stopHeartbeat() {
119729
- if (this.heartbeatTimer !== void 0) {
119730
- this.deps.clock.clearTimeout(this.heartbeatTimer);
119731
- this.heartbeatTimer = void 0;
119732
- }
119733
- }
119734
- /** C→D control dispatch. The CP changes config, never live routing. */
119735
- dispatchControl(frame) {
119736
- switch (frame.type) {
119737
- case "config/push":
119738
- this.deps.configApply.applyConfigPush(frame.payload.keys);
119739
- return;
119740
- case "cron/upsert":
119741
- try {
119742
- this.deps.configApply.upsertCron(frame.payload);
119743
- this.reply(frame, "ack", { ok: true });
119744
- } catch (err) {
119745
- this.sendError(frame.id, "BAD_PAYLOAD", `cron upsert failed: ${err.message}`, false);
119746
- }
119747
- return;
119748
- case "cron/remove":
119749
- this.deps.configApply.removeCron(frame.payload.cronId);
119750
- this.reply(frame, "ack", { ok: true });
119751
- return;
119752
- case "cron/run":
119753
- this.reply(frame, "ack", this.deps.configApply.runCron(frame.payload.cronId));
119754
- return;
119755
- case "route/assign": {
119756
- const a = frame.payload;
119757
- this.deps.configApply.applyRouteAssign(a);
119758
- this.reply(frame, "route/assign/ack", {
119759
- ok: true,
119760
- sessionKey: a.sessionKey
119761
- });
119762
- return;
119763
- }
119764
- case "route/update":
119765
- this.deps.configApply.applyRouteUpdate(frame.payload);
119766
- return;
119767
- case "agent/upsert": {
119768
- const u = frame.payload;
119769
- this.deps.configApply.applyAgentUpsert(u.agentId, u.spec);
119770
- return;
119771
- }
119772
- case "agent/remove":
119773
- this.deps.configApply.applyAgentRemove(frame.payload.agentId);
119774
- return;
119775
- case "integration/upsert":
119776
- this.deps.configApply.applyIntegrationUpsert(frame.payload);
119777
- return;
119778
- case "integration/remove":
119779
- this.deps.configApply.applyIntegrationRemove(frame.payload.integrationId);
119780
- return;
119781
- case "agent/launch": {
119782
- const launch = frame.payload;
119783
- this.deps.configApply.applyAgentLaunch(launch).then((launched) => this.reply(frame, "agent/launched", launched)).catch((err) => this.sendError(frame.id, "INTERNAL", `agent/launch failed: ${err.message}`, false));
119784
- return;
119785
- }
119786
- case "agent/stop": {
119787
- const stop = frame.payload;
119788
- this.deps.configApply.applyAgentStop(stop).then((ack) => this.reply(frame, "ack", ack)).catch((err) => this.sendError(frame.id, "INTERNAL", `agent/stop failed: ${err.message}`, false));
119789
- return;
119790
- }
119791
- case "daemon/drain": {
119792
- const drain = frame.payload;
119793
- this.state = "DRAINING";
119794
- this.deps.configApply.applyDaemonDrain(drain, (p) => this.emit("drain/progress", p)).then((done) => {
119795
- this.reply(frame, "drain/done", done);
119796
- if (this.state === "DRAINING") this.state = "READY";
119797
- }).catch((err) => {
119798
- this.sendError(frame.id, "INTERNAL", `drain failed: ${err.message}`, false);
119799
- if (this.state === "DRAINING") this.state = "READY";
119800
- });
119801
- return;
119802
- }
119803
- case "daemon/restart":
119804
- this.reply(frame, "daemon/control/ack", this.deps.configApply.applyDaemonRestart(frame.payload));
119805
- return;
119806
- case "daemon/upgrade":
119807
- this.reply(frame, "daemon/control/ack", this.deps.configApply.applyDaemonUpgrade(frame.payload));
119808
- return;
119809
- case "session/list":
119810
- try {
119811
- this.reply(frame, "session/list/page", this.deps.sessionRead.list(frame.payload));
119812
- } catch (err) {
119813
- this.sendError(frame.id, "INTERNAL", `session/list failed: ${err.message}`, false);
119814
- }
119815
- return;
119816
- case "session/history":
119817
- try {
119818
- this.reply(frame, "session/history/page", this.deps.sessionRead.history(frame.payload));
119819
- } catch (err) {
119820
- this.sendError(frame.id, "INTERNAL", `session/history failed: ${err.message}`, false);
119821
- }
119822
- return;
119823
- case "session/tool-body":
119824
- try {
119825
- this.reply(frame, "session/tool-body/chunk", this.deps.sessionRead.toolBody(frame.payload));
119826
- } catch (err) {
119827
- this.sendError(frame.id, "INTERNAL", `session/tool-body failed: ${err.message}`, false);
119828
- }
119829
- return;
119830
- case "workspace/list":
119831
- this.deps.workspaceRead.list(frame.payload).then((page) => this.reply(frame, "workspace/list/page", page)).catch((err) => this.workspaceError(frame.id, "workspace/list", err));
119832
- return;
119833
- case "workspace/read":
119834
- this.deps.workspaceRead.read(frame.payload).then((content) => this.reply(frame, "workspace/read/content", content)).catch((err) => this.workspaceError(frame.id, "workspace/read", err));
119835
- return;
119836
- case "workspace/gitstatus":
119837
- this.deps.workspaceGit.status(frame.payload.agentId).then((status) => this.reply(frame, "workspace/gitstatus/result", status)).catch((err) => this.workspaceError(frame.id, "workspace/gitstatus", err));
119838
- return;
119839
- case "workspace/gitpull":
119840
- this.deps.workspaceGit.pull(frame.payload.agentId).then((result) => this.reply(frame, "workspace/gitpull/result", result)).catch((err) => this.workspaceError(frame.id, "workspace/gitpull", err));
119841
- return;
119842
- case "memory/list":
119843
- this.deps.memoryReader.list(frame.payload).then((page) => this.reply(frame, "memory/list/page", page)).catch((err) => this.memoryError(frame.id, "memory/list", err));
119844
- return;
119845
- case "memory/read":
119846
- this.deps.memoryReader.read(frame.payload).then((content) => this.reply(frame, "memory/read/content", content)).catch((err) => this.memoryError(frame.id, "memory/read", err));
119847
- return;
119848
- case "memory/write":
119849
- this.deps.memoryReader.write(frame.payload).then((ok) => this.reply(frame, "memory/write/ok", ok)).catch((err) => this.memoryError(frame.id, "memory/write", err));
119850
- return;
119851
- case "webchat/message":
119852
- if (!this.deps.onWebchatMessage) {
119853
- this.sendError(frame.id, "INTERNAL", "webchat not supported", false);
119854
- return;
119855
- }
119856
- try {
119857
- this.reply(frame, "webchat/ack", this.deps.onWebchatMessage(frame.payload));
119858
- } catch (err) {
119859
- this.sendError(frame.id, "INTERNAL", `webchat/message failed: ${err.message}`, false);
119860
- }
119861
- return;
119862
- case "webchat/close":
119863
- this.deps.onWebchatClose?.(frame.payload);
119864
- return;
119865
- case "webchat/set-model":
119866
- this.deps.onWebchatSetModel?.(frame.payload);
119867
- return;
119868
- case "webchat/set-effort":
119869
- this.deps.onWebchatSetEffort?.(frame.payload);
119870
- return;
119871
- case "webchat/set-permission-mode":
119872
- this.deps.onWebchatSetPermissionMode?.(frame.payload);
119873
- return;
119874
- case "webchat/set-fast":
119875
- this.deps.onWebchatSetFast?.(frame.payload);
119876
- return;
119877
- case "webchat/cancel":
119878
- this.deps.onWebchatCancel?.(frame.payload);
119879
- return;
119880
- default:
119881
- this.deps.log.debug(`cp: ignoring ${frame.type}`);
119882
- return;
119883
- }
120090
+ scheduleReconnect() {
120091
+ if (this.stopped || this.fatal) return;
120092
+ if (this.reconnectTimer !== void 0) return;
120093
+ const delay = this.backoff.next();
120094
+ this.reconnectTimer = this.deps.clock.setTimeout(() => {
120095
+ this.reconnectTimer = void 0;
120096
+ this.attemptConnect();
120097
+ }, delay);
119884
120098
  }
119885
- /** Map a workspace read failure onto the wire: containment/bad-request
119886
- * violations → BAD_PAYLOAD (their messages are hand-written and path-free);
119887
- * anything else → INTERNAL with a GENERIC message — raw fs errors (ELOOP,
119888
- * EACCES, …) embed absolute host paths that must not leak to the CP/UI. */
119889
- workspaceError(corr, op, err) {
119890
- if (err instanceof WorkspaceViolationError) {
119891
- this.sendError(corr, "BAD_PAYLOAD", `${op} failed: ${err.message}`, false);
119892
- return;
119893
- }
119894
- this.deps.log.warn(`cp: ${op} failed: ${err?.message}`);
119895
- this.sendError(corr, "INTERNAL", `${op} failed`, false);
120099
+ };
120100
+ //#endregion
120101
+ //#region src/cp/relay-manager.ts
120102
+ var RelayManager = class {
120103
+ deps;
120104
+ clients = /* @__PURE__ */ new Map();
120105
+ constructor(deps) {
120106
+ this.deps = deps;
119896
120107
  }
119897
- memoryError(corr, op, err) {
119898
- if (err instanceof MemoryConflictError) {
119899
- this.sendError(corr, "CONFLICT", `${op} failed: ${err.message}`, false);
119900
- return;
120108
+ /**
120109
+ * Converge to EXACTLY `roster`: stop clients for relays no longer present (or whose
120110
+ * url moved), and start a dial loop for newly-listed relays. Idempotent — re-applying
120111
+ * the same roster is a no-op.
120112
+ */
120113
+ converge(roster) {
120114
+ const desired = new Map(roster.map((r) => [r.relayId, r.url]));
120115
+ for (const [relayId, client] of this.clients) if (desired.get(relayId) !== client.url) {
120116
+ client.stop();
120117
+ this.clients.delete(relayId);
119901
120118
  }
119902
- if (err instanceof MemoryViolationError || err instanceof MemoryPathError || err instanceof MemoryTooLargeError) {
119903
- this.sendError(corr, "BAD_PAYLOAD", `${op} failed: ${err.message}`, false);
119904
- return;
120119
+ for (const { relayId, url } of roster) {
120120
+ if (this.clients.has(relayId)) continue;
120121
+ const client = new RelayClient(relayId, url, this.deps);
120122
+ this.clients.set(relayId, client);
120123
+ client.start();
119905
120124
  }
119906
- this.deps.log.warn(`cp: ${op} failed: ${err?.message}`);
119907
- this.sendError(corr, "INTERNAL", `${op} failed`, false);
119908
120125
  }
119909
- reply(req, type, payload) {
119910
- this.transport?.send(encode(buildEnvelope(type, payload, { corr: req.id })));
120126
+ /** Tear down every relay connection (daemon shutdown). */
120127
+ async stop() {
120128
+ await Promise.allSettled([...this.clients.values()].map((c) => c.stop()));
120129
+ this.clients.clear();
119911
120130
  }
119912
- /** Emit an uncorrelated EVT (e.g. `drain/progress`). */
119913
- emit(type, payload) {
119914
- this.transport?.send(encode(buildEnvelope(type, payload)));
120131
+ /** Number of relays currently being dialed (observability / tests). */
120132
+ size() {
120133
+ return this.clients.size;
119915
120134
  }
119916
120135
  };
119917
120136
  //#endregion
@@ -120776,20 +120995,6 @@ function mergeConfigPush(cfg, keys) {
120776
120995
  };
120777
120996
  }
120778
120997
  //#endregion
120779
- //#region src/cp/clock.ts
120780
- var SystemClock = class {
120781
- now() {
120782
- return Date.now();
120783
- }
120784
- setTimeout(fn, ms) {
120785
- return globalThis.setTimeout(fn, ms);
120786
- }
120787
- clearTimeout(h) {
120788
- globalThis.clearTimeout(h);
120789
- }
120790
- };
120791
- const systemClock = new SystemClock();
120792
- //#endregion
120793
120998
  //#region ../../node_modules/.pnpm/systeminformation@5.31.12/node_modules/systeminformation/package.json
120794
120999
  var require_package = /* @__PURE__ */ __commonJSMin(((exports, module) => {
120795
121000
  module.exports = {
@@ -136507,6 +136712,7 @@ var Daemon = class Daemon {
136507
136712
  probing = false;
136508
136713
  lastProbeAtMs = 0;
136509
136714
  cpClient;
136715
+ relays;
136510
136716
  cpCrons;
136511
136717
  channelSnapshots = /* @__PURE__ */ new Map();
136512
136718
  cpAgents;
@@ -138608,6 +138814,7 @@ var Daemon = class Daemon {
138608
138814
  applyReconcileSnapshot: (snap) => {
138609
138815
  this.cpAgents?.converge(snap.agents);
138610
138816
  this.cpIntegrations?.converge(snap.integrations);
138817
+ this.relays?.converge(snap.relays);
138611
138818
  for (const id of snap.drop.crons) this.cpCrons?.remove(id);
138612
138819
  this.cpCrons?.converge(snap.crons);
138613
138820
  this.cpRouting?.converge({
@@ -138635,6 +138842,7 @@ var Daemon = class Daemon {
138635
138842
  runCron: (cronId) => this.runCronNow(cronId),
138636
138843
  applyRouteAssign: (a) => this.cpRouting?.upsertAssign(a),
138637
138844
  applyRouteUpdate: (u) => this.cpRouting?.applyUpdate(u),
138845
+ applyRelayRoster: (relays) => this.relays?.converge(relays),
138638
138846
  applyAgentLaunch: async (launch) => {
138639
138847
  const agent = this.agents.get(launch.agentId);
138640
138848
  if (!agent) throw new Error(`agent/launch: unknown agent ${launch.agentId}`);
@@ -138737,6 +138945,17 @@ var Daemon = class Daemon {
138737
138945
  this.metrics.start();
138738
138946
  const echoDaemonId = this.opts.overrides?.daemonId;
138739
138947
  const url = cp.url;
138948
+ const cpKey = cp.key;
138949
+ this.relays = new RelayManager({
138950
+ apiKey: () => cpKey,
138951
+ daemonId: () => this.cfg.daemonId,
138952
+ clock: systemClock,
138953
+ connect: (relayUrl) => ClientTransport.dial(relayUrl, {
138954
+ subprotocol: RELAY_DAEMON_SUBPROTOCOL,
138955
+ path: RELAY_DAEMON_WS_PATH
138956
+ }),
138957
+ log: this.log
138958
+ });
138740
138959
  this.cpClient = new CpClient({
138741
138960
  url,
138742
138961
  token: cp.key,
@@ -138809,7 +139028,10 @@ var Daemon = class Daemon {
138809
139028
  onWebchatSetFast: (m) => this.handleWebchatSetFast(m),
138810
139029
  onWebchatCancel: (m) => this.handleWebchatCancel(m),
138811
139030
  clock: systemClock,
138812
- connect: () => ClientTransport.dial(url),
139031
+ connect: () => ClientTransport.dial(url, {
139032
+ subprotocol: CP_SUBPROTOCOL,
139033
+ path: CP_WS_PATH
139034
+ }),
138813
139035
  log: this.log
138814
139036
  });
138815
139037
  this.cpClient.start();
@@ -138897,6 +139119,7 @@ var Daemon = class Daemon {
138897
139119
  const errors = [];
138898
139120
  this.scheduler?.stop();
138899
139121
  await Promise.resolve(this.cpClient?.stop()).catch((e) => errors.push(e));
139122
+ await Promise.resolve(this.relays?.stop()).catch((e) => errors.push(e));
138900
139123
  for (const c of this.connections) await Promise.resolve(c.stop()).catch((e) => errors.push(e));
138901
139124
  for (const c of this.telegramConns) await Promise.resolve(c.stop()).catch((e) => errors.push(e));
138902
139125
  for (const h of this.hosts.values()) await Promise.resolve(h.stop()).catch((e) => errors.push(e));
@@ -138932,6 +139155,6 @@ async function runForeground(opts, deps = {}) {
138932
139155
  });
138933
139156
  }
138934
139157
  //#endregion
138935
- export { ClientTransport as n, runForeground as t };
139158
+ export { ClientTransport as i, CP_SUBPROTOCOL as n, CP_WS_PATH as r, runForeground as t };
138936
139159
 
138937
- //# sourceMappingURL=run-foreground-DrL_bDro.js.map
139160
+ //# sourceMappingURL=run-foreground-B5RyE_L1.js.map