@agentconnect.md/daemon 1.0.0-rc.61 → 1.0.0-rc.63

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -4,7 +4,7 @@ import { EventEmitter } from "node:events";
4
4
  import childProcess, { execFile, spawn } from "node:child_process";
5
5
  import * as sp from "node:path";
6
6
  import path, { delimiter, dirname, isAbsolute, join, normalize, relative, resolve, sep } from "node:path";
7
- import fs, { accessSync, chmodSync, constants, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, stat, unlinkSync, unwatchFile, watch, watchFile, writeFileSync } from "node:fs";
7
+ import fs, { accessSync, chmodSync, constants, existsSync, mkdirSync, mkdtempSync, promises, readFileSync, readdirSync, rmSync, stat, unlinkSync, unwatchFile, watch, watchFile, writeFileSync } from "node:fs";
8
8
  import process$1 from "node:process";
9
9
  import { stripVTControlCharacters } from "node:util";
10
10
  import { fileURLToPath } from "node:url";
@@ -7595,7 +7595,7 @@ function cachedRuntimeNames(root) {
7595
7595
  }
7596
7596
  /**
7597
7597
  * Runtime versions (registry id -> `version`) read from the local cache. Used to
7598
- * stamp the `facts/runtime-profile` the daemon reports per installed runtime.
7598
+ * stamp each entry of the `facts/daemon-runtimes` snapshot the daemon reports.
7599
7599
  * Entries with no `version` are omitted (callers fall back to ''). `{}` when no
7600
7600
  * cached registry doc exists.
7601
7601
  */
@@ -13254,6 +13254,140 @@ const SessionHistoryPage = object({
13254
13254
  nextCursor: string().optional()
13255
13255
  });
13256
13256
  //#endregion
13257
+ //#region ../protocol/dist/frames/workspace.js
13258
+ /**
13259
+ * Agent workspace file browsing (C→D REQ → REP) — the console's on-demand pulls.
13260
+ *
13261
+ * The CP stores NO workspace data — directory listings and file bytes are pulled
13262
+ * live from the owning daemon and proxied to the console, never persisted
13263
+ * (body-locality). Missing paths are DATA (`exists:false`), not errors; only
13264
+ * path-containment violations (`BAD_PAYLOAD`) and unexpected fs failures
13265
+ * (`INTERNAL`) come back as `error` frames.
13266
+ *
13267
+ * - `workspace/list`: one cursor-paginated page of a directory's entries.
13268
+ * - `workspace/read`: one byte slice of a file. `limit` is only a ceiling — the
13269
+ * daemon returns FEWER bytes when needed to keep the JSON-escaped REP under the
13270
+ * 256 KiB frame cap (control bytes escape to `\uXXXX`, a 6× blowup), and always
13271
+ * ends the slice on a UTF-8 character boundary. `nextOffset` is the authoritative
13272
+ * byte offset to request next — callers must NOT recompute it from the decoded
13273
+ * `content` (a split multi-byte char would make the byte count drift).
13274
+ */
13275
+ /** One directory entry in a workspace listing (name-only; not a full path). */
13276
+ const WorkspaceEntry = object({
13277
+ name: string(),
13278
+ type: _enum([
13279
+ "dir",
13280
+ "file",
13281
+ "symlink",
13282
+ "other"
13283
+ ]),
13284
+ size: number().int().nonnegative().optional(),
13285
+ mtime: string().optional()
13286
+ });
13287
+ /** C→D REQ: list one page of a directory in the agent's workspace. */
13288
+ const WorkspaceListReq = object({
13289
+ agentId: string().min(1),
13290
+ path: string().default(""),
13291
+ cursor: string().optional(),
13292
+ limit: number().int().positive().max(500).default(200)
13293
+ });
13294
+ /** D→C REP (corr = the req id): a page of entries + the cursor for the next page. */
13295
+ const WorkspaceListPage = object({
13296
+ agentId: string(),
13297
+ path: string(),
13298
+ exists: boolean(),
13299
+ entries: array(WorkspaceEntry),
13300
+ nextCursor: string().optional()
13301
+ });
13302
+ /** C→D REQ: read one byte slice of a file in the agent's workspace. */
13303
+ const WorkspaceReadReq = object({
13304
+ agentId: string().min(1),
13305
+ path: string().min(1),
13306
+ offset: number().int().nonnegative().default(0),
13307
+ limit: number().int().positive().max(65536).default(65536)
13308
+ });
13309
+ /** D→C REP (corr = the req id): the file slice (or `exists:false` / binary-detected). */
13310
+ const WorkspaceReadContent = object({
13311
+ agentId: string(),
13312
+ path: string(),
13313
+ exists: boolean(),
13314
+ size: number().int().nonnegative().optional(),
13315
+ mtime: string().optional(),
13316
+ encoding: _enum(["utf8", "none"]).optional(),
13317
+ content: string().optional(),
13318
+ offset: number().int().nonnegative().optional(),
13319
+ nextOffset: number().int().nonnegative().optional(),
13320
+ truncated: boolean().optional()
13321
+ });
13322
+ //#endregion
13323
+ //#region ../protocol/dist/frames/telemetry.js
13324
+ /**
13325
+ * Telemetry & facts (D→C) — protocol §7.
13326
+ *
13327
+ * The dashboard's real-time source. `event/session` is the converged milestone
13328
+ * feed (NOT the message stream); `facts/daemon-runtimes` is the observed runtime
13329
+ * capability feed; `heartbeat` drives the watchdog.
13330
+ */
13331
+ const Heartbeat = object({
13332
+ load: object({
13333
+ cpu: number(),
13334
+ mem: number(),
13335
+ agents: number().int()
13336
+ }),
13337
+ health: _enum(["ok", "degraded"]),
13338
+ activeSessions: number().int(),
13339
+ degradedScopes: array(string()).default([])
13340
+ });
13341
+ /** Converged session lifecycle milestone — protocol §7.2. NOT the message stream. */
13342
+ const EventSession = object({
13343
+ sessionId: string().uuid(),
13344
+ agentId: string().uuid(),
13345
+ launchId: string().uuid(),
13346
+ phase: _enum([
13347
+ "start",
13348
+ "plan",
13349
+ "problem",
13350
+ "end"
13351
+ ]),
13352
+ link: string().optional(),
13353
+ summary: string().optional(),
13354
+ ts: string().datetime()
13355
+ });
13356
+ /**
13357
+ * Per-session token-usage report — D→C EVT. The daemon meters usage from the
13358
+ * agent's ACP stream and reports the session's CUMULATIVE snapshot (latest-wins)
13359
+ * so the CP can persist it for the console's historical usage aggregates. This is
13360
+ * dashboard metadata (token counts + cost), never the message stream. Idempotent
13361
+ * by `sessionId`: re-sending the same snapshot is a no-op upsert on the CP.
13362
+ */
13363
+ const UsageReport = object({
13364
+ sessionId: string(),
13365
+ agentId: string().uuid(),
13366
+ platform: string().optional(),
13367
+ channel: string().optional(),
13368
+ lastActivityAt: string(),
13369
+ usage: SessionUsage
13370
+ });
13371
+ /**
13372
+ * Observed capabilities of an installed runtime — protocol §7.3. The element
13373
+ * type of the `facts/daemon-runtimes` snapshot. As a standalone per-runtime
13374
+ * frame (`facts/runtime-profile`, upsert-only) it is DEPRECATED: current
13375
+ * daemons report the full snapshot instead; the CP keeps accepting the old
13376
+ * frame from older daemons.
13377
+ */
13378
+ const FactsRuntimeProfile = object({
13379
+ runtime: string(),
13380
+ version: string(),
13381
+ models: array(string()),
13382
+ contextWindow: number().int().optional(),
13383
+ acpSupport: _enum([
13384
+ "full",
13385
+ "partial",
13386
+ "none"
13387
+ ]),
13388
+ toolCalling: boolean()
13389
+ });
13390
+ //#endregion
13257
13391
  //#region ../protocol/dist/frame.js
13258
13392
  /**
13259
13393
  * The single source of truth for the wire: `type` string → payload zod schema.
@@ -13271,16 +13405,7 @@ const FRAME_SCHEMAS = {
13271
13405
  "auth/ok": AuthOk,
13272
13406
  register: RegisterReq,
13273
13407
  "register/ok": RegisterOk,
13274
- heartbeat: object({
13275
- load: object({
13276
- cpu: number(),
13277
- mem: number(),
13278
- agents: number().int()
13279
- }),
13280
- health: _enum(["ok", "degraded"]),
13281
- activeSessions: number().int(),
13282
- degradedScopes: array(string()).default([])
13283
- }),
13408
+ heartbeat: Heartbeat,
13284
13409
  "agent/launch": AgentLaunch,
13285
13410
  "agent/launched": AgentLaunched,
13286
13411
  "agent/stop": AgentStop,
@@ -13306,44 +13431,18 @@ const FRAME_SCHEMAS = {
13306
13431
  "secrets/renew": SecretsRenew,
13307
13432
  "secrets/revoke": SecretsRevoke,
13308
13433
  "scope-attestation": ScopeAttestation,
13309
- "event/session": object({
13310
- sessionId: string().uuid(),
13311
- agentId: string().uuid(),
13312
- launchId: string().uuid(),
13313
- phase: _enum([
13314
- "start",
13315
- "plan",
13316
- "problem",
13317
- "end"
13318
- ]),
13319
- link: string().optional(),
13320
- summary: string().optional(),
13321
- ts: string().datetime()
13322
- }),
13323
- "usage/report": object({
13324
- sessionId: string(),
13325
- agentId: string().uuid(),
13326
- platform: string().optional(),
13327
- channel: string().optional(),
13328
- lastActivityAt: string(),
13329
- usage: SessionUsage
13330
- }),
13331
- "facts/runtime-profile": object({
13332
- runtime: string(),
13333
- version: string(),
13334
- models: array(string()),
13335
- contextWindow: number().int().optional(),
13336
- acpSupport: _enum([
13337
- "full",
13338
- "partial",
13339
- "none"
13340
- ]),
13341
- toolCalling: boolean()
13342
- }),
13434
+ "event/session": EventSession,
13435
+ "usage/report": UsageReport,
13436
+ "facts/runtime-profile": FactsRuntimeProfile,
13437
+ "facts/daemon-runtimes": object({ runtimes: array(FactsRuntimeProfile) }),
13343
13438
  "session/list": SessionListReq,
13344
13439
  "session/list/page": SessionListPage,
13345
13440
  "session/history": SessionHistoryReq,
13346
13441
  "session/history/page": SessionHistoryPage,
13442
+ "workspace/list": WorkspaceListReq,
13443
+ "workspace/list/page": WorkspaceListPage,
13444
+ "workspace/read": WorkspaceReadReq,
13445
+ "workspace/read/content": WorkspaceReadContent,
13347
13446
  "config/push": object({ keys: record(string(), unknown()) }),
13348
13447
  "daemon/restart": object({
13349
13448
  reason: string(),
@@ -13434,10 +13533,15 @@ discriminatedUnion("type", [
13434
13533
  frame("event/session", FRAME_SCHEMAS["event/session"]),
13435
13534
  frame("usage/report", FRAME_SCHEMAS["usage/report"]),
13436
13535
  frame("facts/runtime-profile", FRAME_SCHEMAS["facts/runtime-profile"]),
13536
+ frame("facts/daemon-runtimes", FRAME_SCHEMAS["facts/daemon-runtimes"]),
13437
13537
  frame("session/list", FRAME_SCHEMAS["session/list"]),
13438
13538
  frame("session/list/page", FRAME_SCHEMAS["session/list/page"]),
13439
13539
  frame("session/history", FRAME_SCHEMAS["session/history"]),
13440
13540
  frame("session/history/page", FRAME_SCHEMAS["session/history/page"]),
13541
+ frame("workspace/list", FRAME_SCHEMAS["workspace/list"]),
13542
+ frame("workspace/list/page", FRAME_SCHEMAS["workspace/list/page"]),
13543
+ frame("workspace/read", FRAME_SCHEMAS["workspace/read"]),
13544
+ frame("workspace/read/content", FRAME_SCHEMAS["workspace/read/content"]),
13441
13545
  frame("config/push", FRAME_SCHEMAS["config/push"]),
13442
13546
  frame("daemon/restart", FRAME_SCHEMAS["daemon/restart"]),
13443
13547
  frame("daemon/upgrade", FRAME_SCHEMAS["daemon/upgrade"]),
@@ -81880,13 +81984,14 @@ async function probeRuntime(id, rt, cwd, opts = {}) {
81880
81984
  return host.modelOptions();
81881
81985
  };
81882
81986
  try {
81883
- const models = await Promise.race([run(), timeout]);
81884
- opts.log?.info(`probe: ${id} ok (models: ${models?.models.length ? models.models.join(", ") : "none advertised"})`);
81987
+ const opt = await Promise.race([run(), timeout]);
81988
+ const models = (opt?.models ?? []).filter((m) => m !== "default");
81989
+ opts.log?.info(`probe: ${id} ok (models: ${models.length ? models.join(", ") : "none advertised"})`);
81885
81990
  return {
81886
81991
  runtime: id,
81887
81992
  ok: true,
81888
- models: models?.models ?? [],
81889
- currentModel: models?.current
81993
+ models,
81994
+ currentModel: opt?.current
81890
81995
  };
81891
81996
  } catch (err) {
81892
81997
  const error = err.message;
@@ -81960,6 +82065,237 @@ function makeLogger(level = "info") {
81960
82065
  };
81961
82066
  }
81962
82067
  //#endregion
82068
+ //#region src/cp/workspace-reader.ts
82069
+ /**
82070
+ * `WorkspaceReader` — the read-only seam answering the CP's `workspace/list` and
82071
+ * `workspace/read` REQs from the agent's local workspace directory. File bytes
82072
+ * live only on the daemon (§1/§12); this streams single pages/slices to the CP,
82073
+ * never whole trees or files.
82074
+ *
82075
+ * Containment is to `workspace.path` EXACTLY — the parent directory holds
82076
+ * `agent.json` and `.env` secrets, so escaping even one level is a secret leak.
82077
+ * Every request path is (1) rejected if absolute, (2) lexically resolved against
82078
+ * the root and prefix-checked, then (3) canonicalised with `realpath` and
82079
+ * re-verified so symlinks (including an intermediate directory component swapped
82080
+ * to a symlink after the check) cannot smuggle a target outside the root; all
82081
+ * I/O runs on the canonical path. Violations throw `WorkspaceViolationError`,
82082
+ * which the dispatcher maps to a `BAD_PAYLOAD` error frame; missing paths are
82083
+ * data (`exists:false`), not errors. Never log file contents.
82084
+ *
82085
+ * Frame-size safety: every REP must serialise under the 256 KiB wire cap. JSON
82086
+ * escaping of control bytes is a 6× blowup, so `read` bounds the slice by the
82087
+ * *encoded* reply size (not the raw byte `limit`) and `list` stops a page early
82088
+ * when the encoded entries would overflow — both leave headroom for the envelope.
82089
+ */
82090
+ /** Bytes sniffed from the head of a file for binary (NUL byte) detection. */
82091
+ const SNIFF_BYTES = 8192;
82092
+ /** Encoded-payload ceiling, leaving headroom under MAX_FRAME_BYTES for the
82093
+ * envelope (id/ts/type/corr + fencing ext, well under 4 KiB). */
82094
+ const REPLY_BUDGET = MAX_FRAME_BYTES - 4096;
82095
+ /** Path-containment / bad-request violation → `BAD_PAYLOAD` on the wire. */
82096
+ var WorkspaceViolationError = class extends Error {
82097
+ constructor(message) {
82098
+ super(message);
82099
+ this.name = "WorkspaceViolationError";
82100
+ }
82101
+ };
82102
+ function isErrno(err, code) {
82103
+ return err?.code === code;
82104
+ }
82105
+ function under(root, p) {
82106
+ return p === root || p.startsWith(root + sp.sep);
82107
+ }
82108
+ /** Largest index ≤ `len` that lands on a UTF-8 character boundary — i.e. never
82109
+ * cuts a multi-byte sequence. If the slice's trailing char is incomplete, the
82110
+ * boundary is placed before that char (its bytes belong to the next slice). */
82111
+ function utf8Boundary(buf, len) {
82112
+ if (len >= buf.length) len = buf.length;
82113
+ if (len <= 0) return 0;
82114
+ let start = len - 1;
82115
+ while (start > 0 && ((buf[start] ?? 0) & 192) === 128) start--;
82116
+ const lead = buf[start] ?? 0;
82117
+ const seqLen = lead < 128 ? 1 : (lead & 224) === 192 ? 2 : (lead & 240) === 224 ? 3 : (lead & 248) === 240 ? 4 : 1;
82118
+ return start + seqLen <= len ? len : start;
82119
+ }
82120
+ /** The encoded size of the payload the wire will carry (JSON.stringify matches
82121
+ * the codec's `encode`), measured in bytes. */
82122
+ function encodedBytes(payload) {
82123
+ return Buffer.byteLength(JSON.stringify(payload));
82124
+ }
82125
+ function createWorkspaceReader(workspaceRootByAgent) {
82126
+ function rootFor(agentId) {
82127
+ const root = workspaceRootByAgent(agentId);
82128
+ if (!root) throw new WorkspaceViolationError(`unknown agent "${agentId}"`);
82129
+ return root;
82130
+ }
82131
+ /**
82132
+ * Lexical containment. Rejects absolute paths and `..` escapes; returns the
82133
+ * lexically-resolved path plus the canonical (realpath'd) root, or `null` for
82134
+ * `realRoot` when the root does not exist yet (callers report `exists:false`).
82135
+ */
82136
+ async function resolveContained(root, relPath) {
82137
+ if (sp.isAbsolute(relPath)) throw new WorkspaceViolationError("absolute paths are not allowed");
82138
+ const resolved = sp.resolve(root, relPath);
82139
+ if (!under(root, resolved)) throw new WorkspaceViolationError("path escapes the workspace root");
82140
+ try {
82141
+ return {
82142
+ resolved,
82143
+ realRoot: await promises.realpath(root)
82144
+ };
82145
+ } catch (err) {
82146
+ if (isErrno(err, "ENOENT")) return {
82147
+ resolved,
82148
+ realRoot: null
82149
+ };
82150
+ throw err;
82151
+ }
82152
+ }
82153
+ /** Canonicalise an existing target and re-verify containment on the REAL path,
82154
+ * closing the check-vs-use gap (a symlinked intermediate component resolves
82155
+ * out and is rejected here). Returns the canonical path used for I/O. */
82156
+ async function canonicalUnder(realRoot, abs) {
82157
+ const canon = await promises.realpath(abs);
82158
+ if (!under(realRoot, canon)) throw new WorkspaceViolationError("path resolves outside the workspace root");
82159
+ return canon;
82160
+ }
82161
+ return {
82162
+ async list(req) {
82163
+ const { resolved, realRoot } = await resolveContained(rootFor(req.agentId), req.path);
82164
+ const notFound = {
82165
+ agentId: req.agentId,
82166
+ path: req.path,
82167
+ exists: false,
82168
+ entries: []
82169
+ };
82170
+ if (realRoot === null) return notFound;
82171
+ let target;
82172
+ try {
82173
+ target = await canonicalUnder(realRoot, resolved);
82174
+ } catch (err) {
82175
+ if (isErrno(err, "ENOENT")) return notFound;
82176
+ throw err;
82177
+ }
82178
+ let dirents;
82179
+ try {
82180
+ dirents = await promises.readdir(target, { withFileTypes: true });
82181
+ } catch (err) {
82182
+ if (isErrno(err, "ENOENT") || isErrno(err, "ENOTDIR")) return notFound;
82183
+ throw err;
82184
+ }
82185
+ const entries = [];
82186
+ for (const d of dirents) {
82187
+ if (d.name === ".git") continue;
82188
+ const type = d.isDirectory() ? "dir" : d.isFile() ? "file" : d.isSymbolicLink() ? "symlink" : "other";
82189
+ const entry = {
82190
+ name: d.name,
82191
+ type
82192
+ };
82193
+ if (type === "file") try {
82194
+ const st = await promises.lstat(sp.join(target, d.name));
82195
+ entry.size = st.size;
82196
+ entry.mtime = st.mtime.toISOString();
82197
+ } catch {}
82198
+ entries.push(entry);
82199
+ }
82200
+ entries.sort(compareEntries);
82201
+ const start = req.cursor !== void 0 && /^\d+$/.test(req.cursor) ? Number(req.cursor) : 0;
82202
+ const page = [];
82203
+ let acc = 0;
82204
+ for (let i = start; i < entries.length && page.length < req.limit; i++) {
82205
+ const e = entries[i];
82206
+ const enc = encodedBytes(e) + 1;
82207
+ if (page.length > 0 && acc + enc > REPLY_BUDGET) break;
82208
+ page.push(e);
82209
+ acc += enc;
82210
+ }
82211
+ const consumed = start + page.length;
82212
+ return {
82213
+ agentId: req.agentId,
82214
+ path: req.path,
82215
+ exists: true,
82216
+ entries: page,
82217
+ ...consumed < entries.length ? { nextCursor: String(consumed) } : {}
82218
+ };
82219
+ },
82220
+ async read(req) {
82221
+ const { resolved, realRoot } = await resolveContained(rootFor(req.agentId), req.path);
82222
+ const notFound = {
82223
+ agentId: req.agentId,
82224
+ path: req.path,
82225
+ exists: false
82226
+ };
82227
+ if (realRoot === null) return notFound;
82228
+ let st;
82229
+ try {
82230
+ st = await promises.lstat(resolved);
82231
+ } catch (err) {
82232
+ if (isErrno(err, "ENOENT")) return notFound;
82233
+ throw err;
82234
+ }
82235
+ if (!st.isFile()) throw new WorkspaceViolationError("not a regular file");
82236
+ const target = await canonicalUnder(realRoot, resolved);
82237
+ const size = st.size;
82238
+ const mtime = st.mtime.toISOString();
82239
+ const fh = await promises.open(target, "r");
82240
+ try {
82241
+ const sniffLen = Math.min(SNIFF_BYTES, size);
82242
+ if (sniffLen > 0) {
82243
+ const sniff = Buffer.alloc(sniffLen);
82244
+ const { bytesRead } = await fh.read(sniff, 0, sniffLen, 0);
82245
+ if (sniff.subarray(0, bytesRead).includes(0)) return {
82246
+ agentId: req.agentId,
82247
+ path: req.path,
82248
+ exists: true,
82249
+ size,
82250
+ mtime,
82251
+ encoding: "none"
82252
+ };
82253
+ }
82254
+ const want = Math.min(req.limit, Math.max(0, size - req.offset));
82255
+ let slice = Buffer.alloc(0);
82256
+ if (want > 0) {
82257
+ const buf = Buffer.alloc(want);
82258
+ const { bytesRead } = await fh.read(buf, 0, want, req.offset);
82259
+ slice = buf.subarray(0, bytesRead);
82260
+ }
82261
+ let end = utf8Boundary(slice, slice.length);
82262
+ let content = slice.toString("utf8", 0, end);
82263
+ while (end > 0 && encodedBytes(content) > REPLY_BUDGET) {
82264
+ const factor = REPLY_BUDGET / encodedBytes(content);
82265
+ const shrunk = Math.max(1, Math.floor(end * factor * .9));
82266
+ end = utf8Boundary(slice, Math.min(shrunk, end - 1));
82267
+ content = slice.toString("utf8", 0, end);
82268
+ }
82269
+ const nextOffset = req.offset + end;
82270
+ return {
82271
+ agentId: req.agentId,
82272
+ path: req.path,
82273
+ exists: true,
82274
+ size,
82275
+ mtime,
82276
+ encoding: "utf8",
82277
+ content,
82278
+ offset: req.offset,
82279
+ nextOffset,
82280
+ truncated: nextOffset < size
82281
+ };
82282
+ } finally {
82283
+ await fh.close();
82284
+ }
82285
+ }
82286
+ };
82287
+ }
82288
+ /** dirs first, then case-insensitive alphabetical (stable within the page). */
82289
+ function compareEntries(a, b) {
82290
+ const aDir = a.type === "dir" ? 0 : 1;
82291
+ const bDir = b.type === "dir" ? 0 : 1;
82292
+ if (aDir !== bDir) return aDir - bDir;
82293
+ const al = a.name.toLowerCase();
82294
+ const bl = b.name.toLowerCase();
82295
+ if (al !== bl) return al < bl ? -1 : 1;
82296
+ return a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
82297
+ }
82298
+ //#endregion
81963
82299
  //#region src/cp/correlator.ts
81964
82300
  /** Transport-free representation of a typed wire `error` REP. */
81965
82301
  var WireError = class extends Error {
@@ -82136,17 +82472,19 @@ var CpClient = class {
82136
82472
  this.heartbeatMs = ok.heartbeatSec > 0 ? ok.heartbeatSec * 1e3 : this.deps.heartbeatDefaultMs;
82137
82473
  this.armHeartbeat();
82138
82474
  this.deps.log.info(`cp: READY (epoch=${this.sessionEpoch}, routingEpoch=${this.routingEpoch})`);
82139
- for (const profile of this.deps.runtimeProfiles()) this.emitRuntimeProfile(profile);
82475
+ this.emitDaemonRuntimes(this.deps.runtimeProfiles());
82140
82476
  this.deps.onReady?.();
82141
82477
  }
82142
82478
  /**
82143
- * Push a single runtime profile mid-session (D→C EVT, fire-and-forget). No-op
82144
- * unless connected and READY/DRAINING — the CP only accepts control frames
82145
- * there, and a reconnect's handshake re-emits the latest profiles anyway.
82479
+ * Push the full runtime snapshot (D→C `facts/daemon-runtimes` EVT,
82480
+ * fire-and-forget) — sent on each register and again when a probe sweep
82481
+ * completes. REPLACE semantics on the CP: it reconciles the daemon's stored
82482
+ * runtime list to exactly this snapshot, pruning runtimes that are no longer
82483
+ * installed. No-op unless READY/DRAINING.
82146
82484
  */
82147
- emitRuntimeProfile(profile) {
82485
+ emitDaemonRuntimes(runtimes) {
82148
82486
  if (this.state !== "READY" && this.state !== "DRAINING") return;
82149
- this.transport?.send(encode(buildEnvelope("facts/runtime-profile", profile)));
82487
+ this.transport?.send(encode(buildEnvelope("facts/daemon-runtimes", { runtimes })));
82150
82488
  }
82151
82489
  /**
82152
82490
  * Report a session's cumulative token usage (D→C `usage/report` EVT,
@@ -82323,6 +82661,12 @@ var CpClient = class {
82323
82661
  this.sendError(frame.id, "INTERNAL", `session/history failed: ${err.message}`, false);
82324
82662
  }
82325
82663
  return;
82664
+ case "workspace/list":
82665
+ this.deps.workspaceRead.list(frame.payload).then((page) => this.reply(frame, "workspace/list/page", page)).catch((err) => this.workspaceError(frame.id, "workspace/list", err));
82666
+ return;
82667
+ case "workspace/read":
82668
+ this.deps.workspaceRead.read(frame.payload).then((content) => this.reply(frame, "workspace/read/content", content)).catch((err) => this.workspaceError(frame.id, "workspace/read", err));
82669
+ return;
82326
82670
  case "agent/prompt":
82327
82671
  this.sendError(frame.id, "INTERNAL", "agent/prompt not implemented", false);
82328
82672
  return;
@@ -82331,6 +82675,18 @@ var CpClient = class {
82331
82675
  return;
82332
82676
  }
82333
82677
  }
82678
+ /** Map a workspace read failure onto the wire: containment/bad-request
82679
+ * violations → BAD_PAYLOAD (their messages are hand-written and path-free);
82680
+ * anything else → INTERNAL with a GENERIC message — raw fs errors (ELOOP,
82681
+ * EACCES, …) embed absolute host paths that must not leak to the CP/UI. */
82682
+ workspaceError(corr, op, err) {
82683
+ if (err instanceof WorkspaceViolationError) {
82684
+ this.sendError(corr, "BAD_PAYLOAD", `${op} failed: ${err.message}`, false);
82685
+ return;
82686
+ }
82687
+ this.deps.log.warn(`cp: ${op} failed: ${err?.message}`);
82688
+ this.sendError(corr, "INTERNAL", `${op} failed`, false);
82689
+ }
82334
82690
  reply(req, type, payload) {
82335
82691
  this.transport?.send(encode(buildEnvelope(type, payload, { corr: req.id })));
82336
82692
  }
@@ -83893,6 +84249,7 @@ var Daemon = class Daemon {
83893
84249
  degradedScopes: () => this.cpDegradedScopes(),
83894
84250
  configApply: this.cpConfigApply(),
83895
84251
  sessionRead: createSessionReader(this.store),
84252
+ workspaceRead: createWorkspaceReader((id) => this.agents.get(id)?.workspace.path),
83896
84253
  clock: systemClock,
83897
84254
  connect: () => ClientTransport.dial(url),
83898
84255
  log: this.log
@@ -83900,8 +84257,9 @@ var Daemon = class Daemon {
83900
84257
  this.cpClient.start();
83901
84258
  this.log.info(`cp: connecting to ${url}…`);
83902
84259
  }
83903
- /** Build the current `facts/runtime-profile` for a runtime, folding in any
83904
- * models learned by the probe sweep. */
84260
+ /** Build the current profile entry for a runtime (one element of the
84261
+ * `facts/daemon-runtimes` snapshot), folding in any models learned by the
84262
+ * probe sweep. */
83905
84263
  runtimeProfileFor(id) {
83906
84264
  return {
83907
84265
  runtime: id,
@@ -83916,8 +84274,8 @@ var Daemon = class Daemon {
83916
84274
  static PROBE_TTL_MS = 5 * 6e4;
83917
84275
  /**
83918
84276
  * Probe every installed runtime in the background (launch → initialize →
83919
- * session/new → read models → tear down), then emit a refreshed
83920
- * `facts/runtime-profile` per runtime once the whole sweep completes.
84277
+ * session/new → read models → tear down), then emit one `facts/daemon-runtimes`
84278
+ * snapshot once the whole sweep completes so the CP replaces its runtime list.
83921
84279
  *
83922
84280
  * Triggered on each CP (re)connect. Deduped while in flight; skipped (with a
83923
84281
  * cached re-emit) when the last sweep is still fresh, so a reconnect storm can't
@@ -83930,7 +84288,7 @@ var Daemon = class Daemon {
83930
84288
  if (ids.length === 0) return;
83931
84289
  if (this.probing) return;
83932
84290
  if (this.lastProbeAtMs > 0 && Date.now() - this.lastProbeAtMs < Daemon.PROBE_TTL_MS) {
83933
- for (const id of ids) this.cpClient?.emitRuntimeProfile(this.runtimeProfileFor(id));
84291
+ this.cpClient?.emitDaemonRuntimes(ids.map((id) => this.runtimeProfileFor(id)));
83934
84292
  return;
83935
84293
  }
83936
84294
  this.probing = true;
@@ -83941,7 +84299,7 @@ var Daemon = class Daemon {
83941
84299
  this.lastProbeAtMs = Date.now();
83942
84300
  const okCount = results.filter((r) => r.ok).length;
83943
84301
  this.log.info(`probe: sweep complete — ${okCount}/${results.length} runtime(s) reachable`);
83944
- for (const id of ids) this.cpClient?.emitRuntimeProfile(this.runtimeProfileFor(id));
84302
+ this.cpClient?.emitDaemonRuntimes(ids.map((id) => this.runtimeProfileFor(id)));
83945
84303
  } catch (err) {
83946
84304
  this.log.warn(`probe: sweep failed: ${formatErr(err)}`);
83947
84305
  } finally {