@prismer/runtime 2.0.3 → 2.0.5

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.cjs CHANGED
@@ -166,6 +166,42 @@ var init_skill_loader2 = __esm({
166
166
  });
167
167
 
168
168
  // src/adapters/hermes/index.ts
169
+ function readPlainRecord(value) {
170
+ return value && typeof value === "object" && !Array.isArray(value) ? value : null;
171
+ }
172
+ function readNonEmptyString(value) {
173
+ return typeof value === "string" && value.trim() ? value.trim() : null;
174
+ }
175
+ function sanitizeRoleSkillSlug(value) {
176
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 60) || "template";
177
+ }
178
+ function renderRoleTemplateSkill(skillName, agents, roleTemplate) {
179
+ const mcpServers = Array.isArray(roleTemplate?.mcpServers) ? roleTemplate.mcpServers : [];
180
+ const tools = /* @__PURE__ */ new Set();
181
+ for (const server of mcpServers) {
182
+ const rec = readPlainRecord(server);
183
+ const allowlist = Array.isArray(rec?.toolsAllowlist) ? rec.toolsAllowlist : [];
184
+ for (const tool of allowlist) {
185
+ if (typeof tool === "string" && tool.trim()) tools.add(tool.trim());
186
+ }
187
+ }
188
+ const allowedTools = [...tools].join(" ");
189
+ const roleTemplateSlug = JSON.stringify(readNonEmptyString(roleTemplate?.slug) ?? null);
190
+ return `---
191
+ name: ${skillName}
192
+ description: Role-template operating playbook projected from Prismer RoleTemplate.
193
+ ${allowedTools ? `allowed-tools: ${allowedTools}
194
+ ` : ""}metadata:
195
+ prismer:
196
+ source: role-template
197
+ roleTemplateSlug: ${roleTemplateSlug}
198
+ ---
199
+
200
+ # Role Template Playbook
201
+
202
+ ${agents.trim()}
203
+ `;
204
+ }
169
205
  function parsePrismerGoals(value) {
170
206
  if (!Array.isArray(value)) return [];
171
207
  return value.map((entry) => {
@@ -575,13 +611,14 @@ function failure(status, body) {
575
611
  error: { code: "adapter_dispatch_failed", message: `Hermes ${status}: ${body || "<no body>"}` }
576
612
  };
577
613
  }
578
- async function consumeSse(body, task) {
614
+ async function consumeSse(body, task, state) {
579
615
  const reader = body.getReader();
580
616
  const decoder = new TextDecoder();
581
617
  let buf = "";
582
618
  let deltas = "";
583
619
  let finalOutput;
584
620
  let lastProgress = 0;
621
+ let approvalRequested = false;
585
622
  for (; ; ) {
586
623
  const readStart = Date.now();
587
624
  const { value, done } = await reader.read();
@@ -630,6 +667,10 @@ async function consumeSse(body, task) {
630
667
  const next = Math.min(0.99, lastProgress + 0.05);
631
668
  lastProgress = next;
632
669
  const toolName = typeof payload.tool === "string" ? payload.tool : typeof payload.name === "string" ? payload.name : void 0;
670
+ if (eventName === "tool.started" && toolName === APPROVAL_TOOL_NAME) {
671
+ approvalRequested = true;
672
+ if (state) state.approvalRequested = true;
673
+ }
633
674
  const previewStr = typeof payload.preview === "string" ? payload.preview : payload.preview != null ? JSON.stringify(payload.preview).slice(0, 200) : void 0;
634
675
  task.onProgress?.({
635
676
  progress: next,
@@ -659,7 +700,53 @@ async function consumeSse(body, task) {
659
700
  }
660
701
  }
661
702
  }
662
- return finalOutput && finalOutput.length > 0 ? finalOutput : deltas;
703
+ return {
704
+ output: finalOutput && finalOutput.length > 0 ? finalOutput : deltas,
705
+ approvalRequested
706
+ };
707
+ }
708
+ async function consumeChatCompletionsSse(body, task) {
709
+ const reader = body.getReader();
710
+ const decoder = new TextDecoder();
711
+ let buf = "";
712
+ let output = "";
713
+ let lastProgress = 0;
714
+ for (; ; ) {
715
+ const { value, done } = await reader.read();
716
+ if (done) break;
717
+ buf += decoder.decode(value, { stream: true });
718
+ const events = buf.split("\n\n");
719
+ buf = events.pop() ?? "";
720
+ for (const ev of events) {
721
+ const dataLine = ev.match(/^data: (.+)$/m);
722
+ if (!dataLine) continue;
723
+ const raw = dataLine[1].trim();
724
+ if (raw === "[DONE]") continue;
725
+ let payload;
726
+ try {
727
+ payload = JSON.parse(raw);
728
+ } catch (err) {
729
+ process.stderr.write(
730
+ `[hermes-adapter] chat-completions SSE parse skipped: ${err.message}
731
+ `
732
+ );
733
+ continue;
734
+ }
735
+ const delta = payload.choices?.[0]?.delta?.content;
736
+ if (typeof delta === "string") {
737
+ output += delta;
738
+ } else if (Array.isArray(delta)) {
739
+ for (const part of delta) {
740
+ if (part?.type === "text" && typeof part.text === "string") output += part.text;
741
+ }
742
+ }
743
+ if (output.length > 0 && lastProgress < 0.99) {
744
+ lastProgress = Math.min(0.99, lastProgress + 0.05);
745
+ task.onProgress?.({ progress: lastProgress, message: "streaming" });
746
+ }
747
+ }
748
+ }
749
+ return output;
663
750
  }
664
751
  async function checkHealth(baseUrl, apiKey) {
665
752
  try {
@@ -740,7 +827,7 @@ function pidAlive(pid) {
740
827
  function escapeRegExp(value) {
741
828
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
742
829
  }
743
- var import_node_child_process, import_node_fs2, import_node_module, import_node_os, import_node_path2, import_node_url, import_better_sqlite3, YAML, import_zod, import_meta, PRISMER_IM_SKILL_NAME, PRISMER_IM_SKILL_CONTENT, HermesProfileConfigSchema, hermesAdapter, HermesService;
830
+ var import_node_child_process, import_node_fs2, import_node_module, import_node_os, import_node_path2, import_node_url, import_better_sqlite3, YAML, import_zod, import_meta, PRISMER_IM_SKILL_NAME, PRISMER_ROLE_SKILL_PREFIX, PRISMER_IM_SKILL_CONTENT, HermesProfileConfigSchema, hermesAdapter, HermesService, APPROVAL_TOOL_NAME;
744
831
  var init_hermes = __esm({
745
832
  "src/adapters/hermes/index.ts"() {
746
833
  "use strict";
@@ -757,6 +844,7 @@ var init_hermes = __esm({
757
844
  init_skill_loader2();
758
845
  import_meta = {};
759
846
  PRISMER_IM_SKILL_NAME = "prismer-im-collab";
847
+ PRISMER_ROLE_SKILL_PREFIX = "prismer-role";
760
848
  PRISMER_IM_SKILL_CONTENT = `---
761
849
  name: prismer-im-collab
762
850
  description: Coordinate reliably in Prismer conversations, use workspace assets through bounded MCP tools, and keep task work on the board.
@@ -1069,6 +1157,7 @@ No @ needed; the human is the only other party.
1069
1157
  const startedAt = Date.now();
1070
1158
  let runId;
1071
1159
  const sysPrompt = typeof task.metadata?.systemPrompt === "string" ? task.metadata.systemPrompt : void 0;
1160
+ this.installRoleTemplateSkill();
1072
1161
  const skillPrompt = await this.skillLoader.loadSystemPromptFragment().catch((err) => {
1073
1162
  process.stderr.write(
1074
1163
  `[hermes-adapter] failed to read skills for ${this.profileName}: ${err.message}
@@ -1076,7 +1165,21 @@ No @ needed; the human is the only other party.
1076
1165
  );
1077
1166
  return void 0;
1078
1167
  });
1079
- const instructions = [sysPrompt, skillPrompt].filter(Boolean).join("\n\n");
1168
+ const outboxDir = typeof task.metadata?.prismerOutboxDir === "string" ? task.metadata.prismerOutboxDir : void 0;
1169
+ const outboxDirective = outboxDir ? [
1170
+ "[Outbox directive \u2014 MANDATORY, overrides any prior outbox path]",
1171
+ "",
1172
+ "For THIS turn ONLY, your active outbox is:",
1173
+ ` ${outboxDir}`,
1174
+ "",
1175
+ "Any user-deliverable file (PDF, image, CSV, archive, doc) MUST be",
1176
+ "written to that EXACT absolute path. Do NOT reuse the outbox path",
1177
+ "from any previous turn \u2014 those directories no longer accept new",
1178
+ "uploads. Do NOT copy files into the previous outbox; copy them",
1179
+ "into the path above. Files outside this directory will not become",
1180
+ 'chat attachments and the user will see "no files were attached".'
1181
+ ].join("\n") : "";
1182
+ const instructions = [outboxDirective, sysPrompt, skillPrompt].filter(Boolean).join("\n\n");
1080
1183
  if (sysPrompt) {
1081
1184
  try {
1082
1185
  const profileDir = getHermesProfileDir(this.profileName);
@@ -1100,8 +1203,15 @@ No @ needed; the human is the only other party.
1100
1203
  `
1101
1204
  );
1102
1205
  }
1206
+ const sseState = { approvalRequested: false };
1103
1207
  try {
1104
1208
  const nativeBridgePatch = await this.prepareNativeBridgePatch(task);
1209
+ const imageRefs = (task.assetRefs ?? []).filter(
1210
+ (r) => r.mime?.startsWith("image/")
1211
+ );
1212
+ if (imageRefs.length > 0) {
1213
+ return await this.dispatchMultimodalChat(task, instructions, imageRefs, startedAt);
1214
+ }
1105
1215
  const createRes = await fetch(`${this.baseUrl}/v1/runs`, {
1106
1216
  method: "POST",
1107
1217
  headers: {
@@ -1152,10 +1262,10 @@ No @ needed; the human is the only other party.
1152
1262
  }
1153
1263
  };
1154
1264
  }
1155
- const output = await consumeSse(eventsRes.body, task);
1265
+ const sseResult = await consumeSse(eventsRes.body, task, sseState);
1156
1266
  return {
1157
1267
  ok: true,
1158
- output,
1268
+ output: sseResult.output,
1159
1269
  metrics: { durationMs: Date.now() - startedAt },
1160
1270
  metadata: {
1161
1271
  hermes: this.bridgeSnapshot("dispatched", {
@@ -1163,7 +1273,13 @@ No @ needed; the human is the only other party.
1163
1273
  runId,
1164
1274
  baseUrl: this.baseUrl,
1165
1275
  model: this.config.model
1166
- })
1276
+ }),
1277
+ // Surfaced to dispatch.ts so a subsequent reaper kill on this
1278
+ // task is reclassified as `awaiting_human_approval` rather than
1279
+ // `daemon_task_timeout`. Lives under a stable key (not under
1280
+ // `hermes.*`) so non-hermes adapters can adopt the same signal
1281
+ // later without bleeding hermes-specific metadata shape.
1282
+ ...sseResult.approvalRequested ? { approvalRequested: true } : {}
1167
1283
  }
1168
1284
  };
1169
1285
  } catch (err) {
@@ -1173,6 +1289,7 @@ No @ needed; the human is the only other party.
1173
1289
  );
1174
1290
  const categorized = categorizeDispatchError(err, task.signal);
1175
1291
  const bridgeKind = categorized.error?.code === "task_cancelled" ? "cancelled" : "failed";
1292
+ const approvalSignal = sseState.approvalRequested ? { approvalRequested: true } : {};
1176
1293
  return {
1177
1294
  ...categorized,
1178
1295
  metadata: {
@@ -1181,13 +1298,87 @@ No @ needed; the human is the only other party.
1181
1298
  baseUrl: this.baseUrl,
1182
1299
  model: this.config.model,
1183
1300
  ...bridgeKind === "failed" ? { error: err.message } : {}
1184
- })
1301
+ }),
1302
+ ...approvalSignal
1185
1303
  }
1186
1304
  };
1187
1305
  } finally {
1188
1306
  if (runId && this.currentRunId === runId) this.currentRunId = void 0;
1189
1307
  }
1190
1308
  }
1309
+ /**
1310
+ * 14b rev.3 §3.0.4 — Hermes multimodal path.
1311
+ *
1312
+ * Builds an OpenAI-shape chat/completions body where the user message
1313
+ * `content` is a multipart array (`{type:'text'}` + N × `{type:'image_url'}`).
1314
+ * Hermes `_normalize_multimodal_content` (api_server.py:170) collapses the
1315
+ * blocks into its native multimodal representation before routing into
1316
+ * the agent pipeline.
1317
+ *
1318
+ * `image_url.url` is the cdn URL when the daemon's reachability probe said
1319
+ * the upstream LLM can see it (D35 path A); otherwise it's a `data:` URI
1320
+ * carrying the base64 bytes the daemon pre-fetched (D35 path B fallback).
1321
+ * The adapter doesn't decide — `resolveAssetRefs` does, and the adapter
1322
+ * just shuttles `cdnUrl ?? data:base64` into the wire.
1323
+ */
1324
+ async dispatchMultimodalChat(task, instructions, imageRefs, startedAt) {
1325
+ const userContent = [{ type: "text", text: task.prompt }];
1326
+ for (const r of imageRefs) {
1327
+ const url = r.reachable === "cdn" && r.cdnUrl ? r.cdnUrl : r.base64 ? `data:${r.mime ?? "image/png"};base64,${r.base64}` : r.cdnUrl ?? "";
1328
+ if (!url) {
1329
+ userContent.push({
1330
+ type: "text",
1331
+ text: `[image attachment id=${r.assetId} mime=${r.mime ?? "unknown"} unavailable: cdn not reachable and bytes exceeded inline cap]`
1332
+ });
1333
+ continue;
1334
+ }
1335
+ userContent.push({ type: "image_url", image_url: { url, detail: "auto" } });
1336
+ }
1337
+ const messages = [];
1338
+ if (instructions) messages.push({ role: "system", content: instructions });
1339
+ messages.push({ role: "user", content: userContent });
1340
+ const res = await fetch(`${this.baseUrl}/v1/chat/completions`, {
1341
+ method: "POST",
1342
+ headers: {
1343
+ Authorization: `Bearer ${this.config.apiKey}`,
1344
+ "Content-Type": "application/json"
1345
+ },
1346
+ body: JSON.stringify({
1347
+ model: this.config.model,
1348
+ messages,
1349
+ stream: true
1350
+ }),
1351
+ signal: task.signal
1352
+ });
1353
+ if (!res.ok) {
1354
+ const body = await res.text().catch(() => "");
1355
+ return failure(res.status, body);
1356
+ }
1357
+ if (!res.body) {
1358
+ return {
1359
+ ok: false,
1360
+ error: {
1361
+ code: "adapter_dispatch_failed",
1362
+ message: "Hermes /v1/chat/completions returned no body"
1363
+ }
1364
+ };
1365
+ }
1366
+ const output = await consumeChatCompletionsSse(res.body, task);
1367
+ return {
1368
+ ok: true,
1369
+ output,
1370
+ metrics: { durationMs: Date.now() - startedAt },
1371
+ metadata: {
1372
+ hermes: this.bridgeSnapshot("dispatched", {
1373
+ endpoint: "/v1/chat/completions",
1374
+ multimodal: true,
1375
+ imageRefs: imageRefs.length,
1376
+ baseUrl: this.baseUrl,
1377
+ model: this.config.model
1378
+ })
1379
+ }
1380
+ };
1381
+ }
1191
1382
  async shutdown() {
1192
1383
  if (this.currentRunId) await this.stopRun(this.currentRunId);
1193
1384
  }
@@ -1209,6 +1400,25 @@ No @ needed; the human is the only other party.
1209
1400
  ]);
1210
1401
  return { ...kanbanPatch, ...goalsPatch };
1211
1402
  }
1403
+ installRoleTemplateSkill() {
1404
+ const roleTemplate = readPlainRecord(this.config.roleTemplate);
1405
+ const hermesConfig = readPlainRecord(roleTemplate?.hermesConfig);
1406
+ const agents = readNonEmptyString(hermesConfig?.agents);
1407
+ if (!agents) return;
1408
+ const slug = sanitizeRoleSkillSlug(readNonEmptyString(roleTemplate?.slug) ?? "template");
1409
+ const skillName = `${PRISMER_ROLE_SKILL_PREFIX}-${slug}`;
1410
+ const content = renderRoleTemplateSkill(skillName, agents, roleTemplate);
1411
+ try {
1412
+ const skillDir = (0, import_node_path2.join)(this.skillLoader.getSkillsRoot(), skillName);
1413
+ (0, import_node_fs2.mkdirSync)(skillDir, { recursive: true });
1414
+ (0, import_node_fs2.writeFileSync)((0, import_node_path2.join)(skillDir, "SKILL.md"), content, "utf8");
1415
+ } catch (err) {
1416
+ process.stderr.write(
1417
+ `[hermes-adapter] failed to install role-template skill ${skillName} for ${this.profileName}: ${err.message}
1418
+ `
1419
+ );
1420
+ }
1421
+ }
1212
1422
  async prepareNativeKanbanPatch(task) {
1213
1423
  const sourceKind = typeof task.metadata?.sourceKind === "string" ? task.metadata.sourceKind : null;
1214
1424
  const parentTaskId = typeof task.metadata?.parentTaskId === "string" ? task.metadata.parentTaskId : null;
@@ -1317,6 +1527,7 @@ No @ needed; the human is the only other party.
1317
1527
  };
1318
1528
  }
1319
1529
  };
1530
+ APPROVAL_TOOL_NAME = "prismer.approval.request_human_approval";
1320
1531
  }
1321
1532
  });
1322
1533
 
@@ -1348,6 +1559,58 @@ var init_skill_loader3 = __esm({
1348
1559
  });
1349
1560
 
1350
1561
  // src/adapters/openclaw/index.ts
1562
+ function readPlainRecord2(value) {
1563
+ return value && typeof value === "object" && !Array.isArray(value) ? value : null;
1564
+ }
1565
+ function readNonEmptyString2(value) {
1566
+ return typeof value === "string" && value.trim() ? value.trim() : null;
1567
+ }
1568
+ function sanitizeRoleSkillSlug2(value) {
1569
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 60) || "template";
1570
+ }
1571
+ function renderRoleTemplateSkill2(skillName, agents, roleTemplate) {
1572
+ const mcpServers = Array.isArray(roleTemplate?.mcpServers) ? roleTemplate.mcpServers : [];
1573
+ const tools = /* @__PURE__ */ new Set();
1574
+ for (const server of mcpServers) {
1575
+ const rec = readPlainRecord2(server);
1576
+ const allowlist = Array.isArray(rec?.toolsAllowlist) ? rec.toolsAllowlist : [];
1577
+ for (const tool of allowlist) {
1578
+ if (typeof tool === "string" && tool.trim()) tools.add(tool.trim());
1579
+ }
1580
+ }
1581
+ const allowedTools = [...tools].join(" ");
1582
+ const roleTemplateSlug = JSON.stringify(readNonEmptyString2(roleTemplate?.slug) ?? null);
1583
+ return `---
1584
+ name: ${skillName}
1585
+ description: Role-template operating playbook projected from Prismer RoleTemplate.
1586
+ ${allowedTools ? `allowed-tools: ${allowedTools}
1587
+ ` : ""}metadata:
1588
+ prismer:
1589
+ source: role-template
1590
+ roleTemplateSlug: ${roleTemplateSlug}
1591
+ ---
1592
+
1593
+ # Role Template Playbook
1594
+
1595
+ ${agents.trim()}
1596
+ `;
1597
+ }
1598
+ function pickResponsesSource(ref) {
1599
+ if (ref.reachable === "cdn" && ref.cdnUrl) {
1600
+ return { type: "url", url: ref.cdnUrl };
1601
+ }
1602
+ if (ref.base64) {
1603
+ return {
1604
+ type: "base64",
1605
+ data: ref.base64,
1606
+ media_type: ref.mime ?? "application/octet-stream"
1607
+ };
1608
+ }
1609
+ if (ref.cdnUrl) {
1610
+ return { type: "url", url: ref.cdnUrl };
1611
+ }
1612
+ return null;
1613
+ }
1351
1614
  function getOpenClawSkillsDir(profile) {
1352
1615
  const config = OpenClawProfileConfigSchema.parse(profile.config);
1353
1616
  return resolveOpenClawSkillsDir(profile, config);
@@ -1368,15 +1631,17 @@ async function checkHealth2(baseUrl, apiKey) {
1368
1631
  return false;
1369
1632
  }
1370
1633
  }
1371
- var import_zod2, import_node_os2, import_node_path3, OpenClawProfileConfigSchema, openclawAdapter, OpenClawService;
1634
+ var import_zod2, import_node_fs3, import_node_os2, import_node_path3, PRISMER_ROLE_SKILL_PREFIX2, OpenClawProfileConfigSchema, openclawAdapter, OpenClawService;
1372
1635
  var init_openclaw = __esm({
1373
1636
  "src/adapters/openclaw/index.ts"() {
1374
1637
  "use strict";
1375
1638
  import_zod2 = require("zod");
1639
+ import_node_fs3 = require("fs");
1376
1640
  import_node_os2 = require("os");
1377
1641
  import_node_path3 = require("path");
1378
1642
  init_contract();
1379
1643
  init_skill_loader3();
1644
+ PRISMER_ROLE_SKILL_PREFIX2 = "prismer-role";
1380
1645
  OpenClawProfileConfigSchema = import_zod2.z.object({
1381
1646
  /** OpenClaw chat-completions HTTP port. */
1382
1647
  port: import_zod2.z.number().int().min(1).max(65535).default(18789),
@@ -1421,23 +1686,25 @@ var init_openclaw = __esm({
1421
1686
  );
1422
1687
  }
1423
1688
  const skillsDir = resolveOpenClawSkillsDir(profile, config);
1424
- return new OpenClawService(baseUrl, config.apiKey, config.model, new OpenClawSkillLoader(skillsDir));
1689
+ return new OpenClawService(baseUrl, config.apiKey, config.model, config, new OpenClawSkillLoader(skillsDir));
1425
1690
  },
1426
1691
  async health() {
1427
1692
  return { available: true };
1428
1693
  }
1429
1694
  };
1430
1695
  OpenClawService = class {
1431
- constructor(baseUrl, apiKey, model, skillLoader) {
1696
+ constructor(baseUrl, apiKey, model, config, skillLoader) {
1432
1697
  this.baseUrl = baseUrl;
1433
1698
  this.apiKey = apiKey;
1434
1699
  this.model = model;
1700
+ this.config = config;
1435
1701
  this.skillLoader = skillLoader;
1436
1702
  this.id = baseUrl;
1437
1703
  }
1438
1704
  baseUrl;
1439
1705
  apiKey;
1440
1706
  model;
1707
+ config;
1441
1708
  skillLoader;
1442
1709
  id;
1443
1710
  async healthy() {
@@ -1446,17 +1713,47 @@ var init_openclaw = __esm({
1446
1713
  async dispatch(task) {
1447
1714
  const startedAt = Date.now();
1448
1715
  const systemPrompt = typeof task.metadata?.systemPrompt === "string" ? task.metadata.systemPrompt : void 0;
1716
+ this.installRoleTemplateSkill();
1449
1717
  const skillPrompt = await this.skillLoader.loadSystemPromptFragment().catch((err) => {
1450
1718
  process.stderr.write(`[openclaw-adapter] failed to read skills: ${err.message}
1451
1719
  `);
1452
1720
  return void 0;
1453
1721
  });
1454
1722
  const systemContent = [systemPrompt, skillPrompt].filter(Boolean).join("\n\n");
1455
- const messages = [];
1456
- if (systemContent) messages.push({ role: "system", content: systemContent });
1457
- messages.push({ role: "user", content: task.prompt });
1723
+ const refs = task.assetRefs ?? [];
1724
+ const imageRefs = refs.filter((r) => r.mime?.startsWith("image/"));
1725
+ const fileRefs = refs.filter((r) => r.mime && !r.mime.startsWith("image/"));
1726
+ const inputContent = [
1727
+ { type: "input_text", text: task.prompt }
1728
+ ];
1729
+ for (const r of imageRefs) {
1730
+ const source = pickResponsesSource(r);
1731
+ if (!source) {
1732
+ inputContent.push({
1733
+ type: "input_text",
1734
+ text: `[image attachment id=${r.assetId} unavailable: cdn unreachable and bytes exceeded inline cap]`
1735
+ });
1736
+ continue;
1737
+ }
1738
+ inputContent.push({ type: "input_image", source });
1739
+ }
1740
+ for (const r of fileRefs) {
1741
+ const source = pickResponsesSource(r);
1742
+ if (!source) {
1743
+ inputContent.push({
1744
+ type: "input_text",
1745
+ text: `[file attachment id=${r.assetId} mime=${r.mime ?? "unknown"} unavailable]`
1746
+ });
1747
+ continue;
1748
+ }
1749
+ if (r.filename && source.type === "base64") {
1750
+ source.filename = r.filename;
1751
+ }
1752
+ inputContent.push({ type: "input_file", source });
1753
+ }
1754
+ const input = [{ type: "message", role: "user", content: inputContent }];
1458
1755
  try {
1459
- const res = await fetch(`${this.baseUrl}/v1/chat/completions`, {
1756
+ const res = await fetch(`${this.baseUrl}/v1/responses`, {
1460
1757
  method: "POST",
1461
1758
  headers: {
1462
1759
  Authorization: `Bearer ${this.apiKey}`,
@@ -1464,7 +1761,11 @@ var init_openclaw = __esm({
1464
1761
  },
1465
1762
  body: JSON.stringify({
1466
1763
  model: this.model,
1467
- messages,
1764
+ input,
1765
+ // OpenClaw `/v1/responses` accepts top-level `instructions` for
1766
+ // the system prompt — keeping it out of the user message
1767
+ // preserves cleanish channel-context separation.
1768
+ ...systemContent ? { instructions: systemContent } : {},
1468
1769
  stream: false
1469
1770
  }),
1470
1771
  signal: task.signal
@@ -1480,7 +1781,21 @@ var init_openclaw = __esm({
1480
1781
  };
1481
1782
  }
1482
1783
  const json = await res.json();
1483
- let output = json.choices?.[0]?.message?.content ?? "";
1784
+ let output = "";
1785
+ if (Array.isArray(json.output)) {
1786
+ for (const item of json.output) {
1787
+ if (item?.type === "message" && Array.isArray(item.content)) {
1788
+ for (const part of item.content) {
1789
+ if (part?.type === "output_text" && typeof part.text === "string") {
1790
+ output += part.text;
1791
+ }
1792
+ }
1793
+ }
1794
+ }
1795
+ }
1796
+ if (!output) {
1797
+ output = json.choices?.[0]?.message?.content ?? "";
1798
+ }
1484
1799
  const ERR_PREFIX_RE = /^Cannot read properties of undefined \(reading '[^']+'\)\s*\n+/;
1485
1800
  if (ERR_PREFIX_RE.test(output)) {
1486
1801
  process.stderr.write(
@@ -1498,6 +1813,25 @@ var init_openclaw = __esm({
1498
1813
  return categorizeDispatchError(err, task.signal);
1499
1814
  }
1500
1815
  }
1816
+ installRoleTemplateSkill() {
1817
+ const roleTemplate = readPlainRecord2(this.config.roleTemplate);
1818
+ const openclawConfig = readPlainRecord2(roleTemplate?.openclawConfig);
1819
+ const agents = readNonEmptyString2(openclawConfig?.agents);
1820
+ if (!agents) return;
1821
+ const slug = sanitizeRoleSkillSlug2(readNonEmptyString2(roleTemplate?.slug) ?? "template");
1822
+ const skillName = `${PRISMER_ROLE_SKILL_PREFIX2}-${slug}`;
1823
+ const content = renderRoleTemplateSkill2(skillName, agents, roleTemplate);
1824
+ try {
1825
+ const skillDir = (0, import_node_path3.join)(this.skillLoader.getSkillsRoot(), skillName);
1826
+ (0, import_node_fs3.mkdirSync)(skillDir, { recursive: true });
1827
+ (0, import_node_fs3.writeFileSync)((0, import_node_path3.join)(skillDir, "SKILL.md"), content, "utf8");
1828
+ } catch (err) {
1829
+ process.stderr.write(
1830
+ `[openclaw-adapter] failed to install role-template skill ${skillName}: ${err.message}
1831
+ `
1832
+ );
1833
+ }
1834
+ }
1501
1835
  };
1502
1836
  }
1503
1837
  });
@@ -1545,11 +1879,39 @@ async function syncAllAgentSkills(profiles, cloud, options = {}) {
1545
1879
  }
1546
1880
  async function syncInstalledSkillsForDispatch(profile, agentImUserId, cloud, signal) {
1547
1881
  if (!agentImUserId) return { synced: 0, skipped: 0, unchanged: 0 };
1548
- const data = await cloud.get(
1882
+ let data = await cloud.get(
1549
1883
  `/api/im/skills/installed?agentId=${encodeURIComponent(agentImUserId)}`,
1550
1884
  { signal }
1551
1885
  );
1552
- const entries = normalizeInstalledSkills(data);
1886
+ let entries = normalizeInstalledSkills(data);
1887
+ if (entries.length === 0 && (profile.adapterName === "hermes" || profile.adapterName === "openclaw")) {
1888
+ try {
1889
+ const backfill = await cloud.request(
1890
+ "POST",
1891
+ `/api/im/agents/${encodeURIComponent(agentImUserId)}/skills/install-builtins`,
1892
+ { body: {}, signal }
1893
+ );
1894
+ if (backfill.ok && backfill.data && typeof backfill.data === "object" && "data" in backfill.data) {
1895
+ const installed = backfill.data.data?.installed ?? 0;
1896
+ if (installed > 0) {
1897
+ process.stdout.write(
1898
+ `[daemon] skill sync: backfilled ${installed} built-in skills for agent ${agentImUserId.slice(-8)}
1899
+ `
1900
+ );
1901
+ data = await cloud.get(
1902
+ `/api/im/skills/installed?agentId=${encodeURIComponent(agentImUserId)}`,
1903
+ { signal }
1904
+ );
1905
+ entries = normalizeInstalledSkills(data);
1906
+ }
1907
+ }
1908
+ } catch (err) {
1909
+ process.stderr.write(
1910
+ `[daemon] skill sync: backfill skipped for ${agentImUserId.slice(-8)}: ${err.message}
1911
+ `
1912
+ );
1913
+ }
1914
+ }
1553
1915
  const skillsRoot = resolveSkillsRoot(profile);
1554
1916
  if (!skillsRoot) return { synced: 0, skipped: 0, unchanged: 0 };
1555
1917
  let synced = 0;
@@ -1590,7 +1952,7 @@ async function syncInstalledSkillsForDispatch(profile, agentImUserId, cloud, sig
1590
1952
  continue;
1591
1953
  }
1592
1954
  const skillDir = (0, import_node_path8.join)(skillsRoot, slug);
1593
- await import_node_fs7.promises.mkdir(skillDir, { recursive: true });
1955
+ await import_node_fs8.promises.mkdir(skillDir, { recursive: true });
1594
1956
  const localFiles = await walkLocalDir(skillDir);
1595
1957
  let dirty = false;
1596
1958
  let perFileFailures = 0;
@@ -1645,14 +2007,14 @@ async function syncInstalledSkillsForDispatch(profile, agentImUserId, cloud, sig
1645
2007
  perFileFailures++;
1646
2008
  continue;
1647
2009
  }
1648
- await import_node_fs7.promises.mkdir((0, import_node_path8.dirname)(targetPath), { recursive: true });
1649
- await import_node_fs7.promises.writeFile(targetPath, bytes);
2010
+ await import_node_fs8.promises.mkdir((0, import_node_path8.dirname)(targetPath), { recursive: true });
2011
+ await import_node_fs8.promises.writeFile(targetPath, bytes);
1650
2012
  localFiles.delete(file.path);
1651
2013
  dirty = true;
1652
2014
  }
1653
2015
  for (const orphan of localFiles.keys()) {
1654
2016
  try {
1655
- await import_node_fs7.promises.unlink((0, import_node_path8.join)(skillDir, orphan));
2017
+ await import_node_fs8.promises.unlink((0, import_node_path8.join)(skillDir, orphan));
1656
2018
  dirty = true;
1657
2019
  } catch (err) {
1658
2020
  if (err.code !== "ENOENT") {
@@ -1765,7 +2127,7 @@ async function walkLocalDir(root) {
1765
2127
  async function walk2(dir) {
1766
2128
  let entries;
1767
2129
  try {
1768
- entries = await import_node_fs7.promises.readdir(dir, { withFileTypes: true });
2130
+ entries = await import_node_fs8.promises.readdir(dir, { withFileTypes: true });
1769
2131
  } catch (err) {
1770
2132
  if (err.code === "ENOENT") return;
1771
2133
  throw err;
@@ -1776,7 +2138,7 @@ async function walkLocalDir(root) {
1776
2138
  await walk2(full);
1777
2139
  } else if (ent.isFile()) {
1778
2140
  try {
1779
- const buf = await import_node_fs7.promises.readFile(full);
2141
+ const buf = await import_node_fs8.promises.readFile(full);
1780
2142
  const rel = (0, import_node_path8.relative)(root, full).split(import_node_path8.sep).join("/");
1781
2143
  out.set(rel, sha256Buffer(buf));
1782
2144
  } catch {
@@ -1850,11 +2212,11 @@ async function ackSkillSync(cloud, agentImUserId, input, signal) {
1850
2212
  `);
1851
2213
  }
1852
2214
  }
1853
- var import_node_fs7, import_node_path8, import_node_crypto3, skillSyncWarnedProfiles;
2215
+ var import_node_fs8, import_node_path8, import_node_crypto3, skillSyncWarnedProfiles;
1854
2216
  var init_skill_sync = __esm({
1855
2217
  "src/daemon/skill-sync.ts"() {
1856
2218
  "use strict";
1857
- import_node_fs7 = require("fs");
2219
+ import_node_fs8 = require("fs");
1858
2220
  import_node_path8 = require("path");
1859
2221
  import_node_crypto3 = require("crypto");
1860
2222
  init_hermes();
@@ -2295,7 +2657,7 @@ var require_package = __commonJS({
2295
2657
  "package.json"(exports2, module2) {
2296
2658
  module2.exports = {
2297
2659
  name: "@prismer/runtime",
2298
- version: "2.0.3",
2660
+ version: "2.0.5",
2299
2661
  description: "Prismer Cloud daemon runtime \u2014 TS-only adapter host for hosted IM agents",
2300
2662
  type: "module",
2301
2663
  main: "dist/index.js",
@@ -2329,6 +2691,7 @@ var require_package = __commonJS({
2329
2691
  },
2330
2692
  dependencies: {
2331
2693
  "@iarna/toml": "^2.2.5",
2694
+ "@modelcontextprotocol/sdk": "^1.29.0",
2332
2695
  "better-sqlite3": "^11.8.0",
2333
2696
  commander: "^12.1.0",
2334
2697
  qrcode: "^1.5.4",
@@ -2486,21 +2849,21 @@ function pidFilePath(paths) {
2486
2849
  return (0, import_node_path12.join)(paths.root, "daemon.pid");
2487
2850
  }
2488
2851
  function writePidFile(paths, pid) {
2489
- (0, import_node_fs16.writeFileSync)(pidFilePath(paths), `${pid}
2852
+ (0, import_node_fs17.writeFileSync)(pidFilePath(paths), `${pid}
2490
2853
  `, "utf8");
2491
2854
  }
2492
2855
  function readPidFile(paths) {
2493
2856
  const p = pidFilePath(paths);
2494
- if (!(0, import_node_fs16.existsSync)(p)) return void 0;
2495
- const raw = (0, import_node_fs16.readFileSync)(p, "utf8").trim();
2857
+ if (!(0, import_node_fs17.existsSync)(p)) return void 0;
2858
+ const raw = (0, import_node_fs17.readFileSync)(p, "utf8").trim();
2496
2859
  const pid = Number.parseInt(raw, 10);
2497
2860
  return Number.isFinite(pid) ? pid : void 0;
2498
2861
  }
2499
2862
  function clearPidFile(paths) {
2500
2863
  const p = pidFilePath(paths);
2501
- if ((0, import_node_fs16.existsSync)(p)) {
2864
+ if ((0, import_node_fs17.existsSync)(p)) {
2502
2865
  try {
2503
- (0, import_node_fs16.unlinkSync)(p);
2866
+ (0, import_node_fs17.unlinkSync)(p);
2504
2867
  } catch {
2505
2868
  }
2506
2869
  }
@@ -2513,11 +2876,11 @@ function pidAlive2(pid) {
2513
2876
  return false;
2514
2877
  }
2515
2878
  }
2516
- var import_node_fs16, import_node_path12, DEFAULT_CLOUD_BASE_URL, ANSI, PKG_VERSION, RUNTIME_SUBTITLE;
2879
+ var import_node_fs17, import_node_path12, DEFAULT_CLOUD_BASE_URL, ANSI, PKG_VERSION, RUNTIME_SUBTITLE;
2517
2880
  var init_util = __esm({
2518
2881
  "src/cli/util.ts"() {
2519
2882
  "use strict";
2520
- import_node_fs16 = require("fs");
2883
+ import_node_fs17 = require("fs");
2521
2884
  import_node_path12 = require("path");
2522
2885
  init_ui();
2523
2886
  DEFAULT_CLOUD_BASE_URL = "https://prismer.cloud";
@@ -2761,9 +3124,9 @@ var WsClient = class extends import_node_events.EventEmitter {
2761
3124
 
2762
3125
  // src/sync/store.ts
2763
3126
  var import_better_sqlite32 = __toESM(require("better-sqlite3"), 1);
2764
- var import_node_fs3 = require("fs");
3127
+ var import_node_fs4 = require("fs");
2765
3128
  var import_node_path4 = require("path");
2766
- var SCHEMA_VERSION = 3;
3129
+ var SCHEMA_VERSION = 4;
2767
3130
  var MIGRATIONS = [
2768
3131
  {
2769
3132
  version: 1,
@@ -2889,14 +3252,44 @@ var MIGRATIONS = [
2889
3252
  CREATE INDEX IF NOT EXISTS idx_asset_meta_seq
2890
3253
  ON asset_metadata_index(workspace_id, asset_index_seq);
2891
3254
  `
3255
+ },
3256
+ {
3257
+ version: 4,
3258
+ up: `
3259
+ -- Wave 4 / W3 \xA74.3 \u2014 pending dispatch replies for crash recovery.
3260
+ --
3261
+ -- The two-phase reply protocol writes a row here BEFORE calling
3262
+ -- POST /dispatch/:taskId/prepare so that a crash between prepare
3263
+ -- and commit leaves a recoverable trail. On daemon cold-start, any
3264
+ -- row with status='prepared' triggers an idempotent commit retry.
3265
+ --
3266
+ -- The server-side cutoff for orphaned 'prepared' rows is 1h
3267
+ -- (PREPARED_REPLY_ORPHAN_MS in dispatch-reply.service.ts); rows
3268
+ -- older than that will fail commit with DISPATCH_REPLY_INVALID_STATE
3269
+ -- and must be reported to the user as 'dispatch lost'.
3270
+ CREATE TABLE IF NOT EXISTS pending_dispatch_replies (
3271
+ idempotency_key TEXT PRIMARY KEY,
3272
+ task_id TEXT NOT NULL,
3273
+ reply_id TEXT,
3274
+ status TEXT NOT NULL DEFAULT 'pending',
3275
+ payload_json TEXT NOT NULL,
3276
+ prepared_at INTEGER,
3277
+ created_at INTEGER NOT NULL,
3278
+ last_attempt_at INTEGER,
3279
+ attempt_count INTEGER NOT NULL DEFAULT 0,
3280
+ last_error TEXT
3281
+ );
3282
+ CREATE INDEX IF NOT EXISTS idx_pending_reply_task ON pending_dispatch_replies (task_id);
3283
+ CREATE INDEX IF NOT EXISTS idx_pending_reply_status ON pending_dispatch_replies (status, created_at);
3284
+ `
2892
3285
  }
2893
3286
  ];
2894
3287
  function runSql(db, sql) {
2895
3288
  db["exec"](sql);
2896
3289
  }
2897
3290
  function openLocalDb(path9) {
2898
- if (path9 !== ":memory:" && !(0, import_node_fs3.existsSync)((0, import_node_path4.dirname)(path9))) {
2899
- (0, import_node_fs3.mkdirSync)((0, import_node_path4.dirname)(path9), { recursive: true });
3291
+ if (path9 !== ":memory:" && !(0, import_node_fs4.existsSync)((0, import_node_path4.dirname)(path9))) {
3292
+ (0, import_node_fs4.mkdirSync)((0, import_node_path4.dirname)(path9), { recursive: true });
2900
3293
  }
2901
3294
  const db = new import_better_sqlite32.default(path9);
2902
3295
  db.pragma("journal_mode = WAL");
@@ -3133,7 +3526,7 @@ function listRoleTemplates() {
3133
3526
 
3134
3527
  // src/config.ts
3135
3528
  var TOML = __toESM(require("@iarna/toml"), 1);
3136
- var import_node_fs4 = require("fs");
3529
+ var import_node_fs5 = require("fs");
3137
3530
  var import_node_os3 = require("os");
3138
3531
  var import_node_path5 = require("path");
3139
3532
  var import_zod3 = require("zod");
@@ -3176,15 +3569,15 @@ function resolvePaths(home) {
3176
3569
  };
3177
3570
  }
3178
3571
  function configExists(paths = resolvePaths()) {
3179
- return (0, import_node_fs4.existsSync)(paths.configFile);
3572
+ return (0, import_node_fs5.existsSync)(paths.configFile);
3180
3573
  }
3181
3574
  function loadConfig(paths = resolvePaths()) {
3182
- if (!(0, import_node_fs4.existsSync)(paths.configFile)) {
3575
+ if (!(0, import_node_fs5.existsSync)(paths.configFile)) {
3183
3576
  throw new Error(
3184
3577
  `Config not found at ${paths.configFile}. Run \`prismer setup\` to create it.`
3185
3578
  );
3186
3579
  }
3187
- const raw = (0, import_node_fs4.readFileSync)(paths.configFile, "utf8");
3580
+ const raw = (0, import_node_fs5.readFileSync)(paths.configFile, "utf8");
3188
3581
  const parsed = TOML.parse(raw);
3189
3582
  const merged = {
3190
3583
  ...parsed,
@@ -3199,14 +3592,14 @@ function loadConfig(paths = resolvePaths()) {
3199
3592
  return result.data;
3200
3593
  }
3201
3594
  function saveConfig(config, paths = resolvePaths()) {
3202
- if (!(0, import_node_fs4.existsSync)(paths.root)) {
3203
- (0, import_node_fs4.mkdirSync)(paths.root, { recursive: true });
3595
+ if (!(0, import_node_fs5.existsSync)(paths.root)) {
3596
+ (0, import_node_fs5.mkdirSync)(paths.root, { recursive: true });
3204
3597
  }
3205
- if (!(0, import_node_fs4.existsSync)((0, import_node_path5.dirname)(paths.configFile))) {
3206
- (0, import_node_fs4.mkdirSync)((0, import_node_path5.dirname)(paths.configFile), { recursive: true });
3598
+ if (!(0, import_node_fs5.existsSync)((0, import_node_path5.dirname)(paths.configFile))) {
3599
+ (0, import_node_fs5.mkdirSync)((0, import_node_path5.dirname)(paths.configFile), { recursive: true });
3207
3600
  }
3208
3601
  ConfigSchema.parse(config);
3209
- (0, import_node_fs4.writeFileSync)(paths.configFile, TOML.stringify(config), "utf8");
3602
+ (0, import_node_fs5.writeFileSync)(paths.configFile, TOML.stringify(config), "utf8");
3210
3603
  }
3211
3604
  function deriveWsUrl(httpBase) {
3212
3605
  const u = new URL(httpBase);
@@ -3379,7 +3772,7 @@ function envelope(type, payload, requestId) {
3379
3772
  // src/asset-cache.ts
3380
3773
  var import_node_crypto2 = require("crypto");
3381
3774
  var import_node_dns = require("dns");
3382
- var import_node_fs5 = require("fs");
3775
+ var import_node_fs6 = require("fs");
3383
3776
  var import_node_net = require("net");
3384
3777
  var import_node_path6 = require("path");
3385
3778
  var import_undici = require("undici");
@@ -3404,8 +3797,8 @@ var AssetCache = class {
3404
3797
  this.cloud = opts.cloud;
3405
3798
  this.cacheDir = opts.cacheDir;
3406
3799
  this.maxBytes = opts.maxBytes ?? 5 * 1024 * 1024 * 1024;
3407
- if (!(0, import_node_fs5.existsSync)(this.cacheDir)) {
3408
- (0, import_node_fs5.mkdirSync)(this.cacheDir, { recursive: true });
3800
+ if (!(0, import_node_fs6.existsSync)(this.cacheDir)) {
3801
+ (0, import_node_fs6.mkdirSync)(this.cacheDir, { recursive: true });
3409
3802
  }
3410
3803
  }
3411
3804
  /** Local file path for a content hash. Files split by first 2 chars for fs scaling. */
@@ -3415,7 +3808,7 @@ var AssetCache = class {
3415
3808
  get(hash) {
3416
3809
  const row = this.db.prepare("SELECT * FROM cached_assets WHERE content_hash = ?").get(hash);
3417
3810
  if (!row) return void 0;
3418
- if (!(0, import_node_fs5.existsSync)(row.local_path)) {
3811
+ if (!(0, import_node_fs6.existsSync)(row.local_path)) {
3419
3812
  this.db.prepare("DELETE FROM cached_assets WHERE content_hash = ?").run(hash);
3420
3813
  return void 0;
3421
3814
  }
@@ -3479,12 +3872,12 @@ var AssetCache = class {
3479
3872
  throw new Error(`Asset hash mismatch for ${hash}: downloaded ${actualHash}`);
3480
3873
  }
3481
3874
  const localPath = this.pathFor(hash);
3482
- if (!(0, import_node_fs5.existsSync)((0, import_node_path6.dirname)(localPath))) {
3483
- (0, import_node_fs5.mkdirSync)((0, import_node_path6.dirname)(localPath), { recursive: true });
3875
+ if (!(0, import_node_fs6.existsSync)((0, import_node_path6.dirname)(localPath))) {
3876
+ (0, import_node_fs6.mkdirSync)((0, import_node_path6.dirname)(localPath), { recursive: true });
3484
3877
  }
3485
3878
  const tmpPath = `${localPath}.${process.pid}.${Date.now()}.tmp`;
3486
- (0, import_node_fs5.writeFileSync)(tmpPath, buf);
3487
- (0, import_node_fs5.renameSync)(tmpPath, localPath);
3879
+ (0, import_node_fs6.writeFileSync)(tmpPath, buf);
3880
+ (0, import_node_fs6.renameSync)(tmpPath, localPath);
3488
3881
  const mime = res.headers.get("content-type");
3489
3882
  const now = Date.now();
3490
3883
  this.db.prepare(
@@ -3543,12 +3936,12 @@ var AssetCache = class {
3543
3936
  return { cached: existing, finalUrl, durationMs: Date.now() - started };
3544
3937
  }
3545
3938
  const localPath = this.pathFor(hash);
3546
- if (!(0, import_node_fs5.existsSync)((0, import_node_path6.dirname)(localPath))) {
3547
- (0, import_node_fs5.mkdirSync)((0, import_node_path6.dirname)(localPath), { recursive: true });
3939
+ if (!(0, import_node_fs6.existsSync)((0, import_node_path6.dirname)(localPath))) {
3940
+ (0, import_node_fs6.mkdirSync)((0, import_node_path6.dirname)(localPath), { recursive: true });
3548
3941
  }
3549
3942
  const tmpPath = `${localPath}.${process.pid}.${Date.now()}.tmp`;
3550
- (0, import_node_fs5.writeFileSync)(tmpPath, body);
3551
- (0, import_node_fs5.renameSync)(tmpPath, localPath);
3943
+ (0, import_node_fs6.writeFileSync)(tmpPath, body);
3944
+ (0, import_node_fs6.renameSync)(tmpPath, localPath);
3552
3945
  const now = Date.now();
3553
3946
  this.db.prepare(
3554
3947
  `INSERT OR REPLACE INTO cached_assets
@@ -3576,7 +3969,7 @@ var AssetCache = class {
3576
3969
  if (actualHash !== hash) {
3577
3970
  throw new Error(`Asset hash mismatch for ${hash}: local file is ${actualHash}`);
3578
3971
  }
3579
- const size = (0, import_node_fs5.statSync)(localPath).size;
3972
+ const size = (0, import_node_fs6.statSync)(localPath).size;
3580
3973
  const now = Date.now();
3581
3974
  this.db.prepare(
3582
3975
  `INSERT OR REPLACE INTO cached_assets
@@ -3604,7 +3997,7 @@ var AssetCache = class {
3604
3997
  for (const row of candidates) {
3605
3998
  if (total <= this.maxBytes) break;
3606
3999
  try {
3607
- if ((0, import_node_fs5.existsSync)(row.local_path)) (0, import_node_fs5.unlinkSync)(row.local_path);
4000
+ if ((0, import_node_fs6.existsSync)(row.local_path)) (0, import_node_fs6.unlinkSync)(row.local_path);
3608
4001
  } catch {
3609
4002
  }
3610
4003
  this.db.prepare("DELETE FROM cached_assets WHERE content_hash = ?").run(row.content_hash);
@@ -3619,7 +4012,7 @@ function sha256(buf) {
3619
4012
  return (0, import_node_crypto2.createHash)("sha256").update(buf).digest("hex");
3620
4013
  }
3621
4014
  function readFileBuffer(path9) {
3622
- return (0, import_node_fs5.readFileSync)(path9);
4015
+ return (0, import_node_fs6.readFileSync)(path9);
3623
4016
  }
3624
4017
  var DEFAULT_URL_FETCH_MAX_BYTES = 5 * 1024 * 1024;
3625
4018
  var DEFAULT_URL_FETCH_TIMEOUT_MS = 15e3;
@@ -3801,7 +4194,7 @@ async function fetchUrlWithGuards(rawUrl, opts) {
3801
4194
  }
3802
4195
 
3803
4196
  // src/daemon/asset/mirror.ts
3804
- var import_node_fs6 = require("fs");
4197
+ var import_node_fs7 = require("fs");
3805
4198
  var import_node_path7 = require("path");
3806
4199
  function rowToBinding(row) {
3807
4200
  return {
@@ -3823,16 +4216,16 @@ var WorkspaceMirror = class {
3823
4216
  this.db = opts.db;
3824
4217
  this.cloud = opts.cloud;
3825
4218
  this.workspaceId = opts.workspaceId;
3826
- if (!(0, import_node_fs6.existsSync)(opts.workspaceStateDir)) {
3827
- (0, import_node_fs6.mkdirSync)(opts.workspaceStateDir, { recursive: true });
4219
+ if (!(0, import_node_fs7.existsSync)(opts.workspaceStateDir)) {
4220
+ (0, import_node_fs7.mkdirSync)(opts.workspaceStateDir, { recursive: true });
3828
4221
  }
3829
4222
  this.cursorPath = (0, import_node_path7.join)(opts.workspaceStateDir, "asset-cursor.json");
3830
4223
  }
3831
4224
  /** Persisted cursor for this workspace, or null on first run / corrupted file. */
3832
4225
  readCursor() {
3833
- if (!(0, import_node_fs6.existsSync)(this.cursorPath)) return null;
4226
+ if (!(0, import_node_fs7.existsSync)(this.cursorPath)) return null;
3834
4227
  try {
3835
- const parsed = JSON.parse((0, import_node_fs6.readFileSync)(this.cursorPath, "utf8"));
4228
+ const parsed = JSON.parse((0, import_node_fs7.readFileSync)(this.cursorPath, "utf8"));
3836
4229
  if (parsed.workspaceId !== this.workspaceId) return null;
3837
4230
  return parsed.cursor;
3838
4231
  } catch {
@@ -3845,7 +4238,7 @@ var WorkspaceMirror = class {
3845
4238
  cursor,
3846
4239
  writtenAt: Date.now()
3847
4240
  };
3848
- (0, import_node_fs6.writeFileSync)(this.cursorPath, JSON.stringify(payload, null, 2));
4241
+ (0, import_node_fs7.writeFileSync)(this.cursorPath, JSON.stringify(payload, null, 2));
3849
4242
  }
3850
4243
  /**
3851
4244
  * Pull workspace_file changes since the persisted cursor and upsert into
@@ -4251,9 +4644,265 @@ function extractHttpUrls(text) {
4251
4644
  }
4252
4645
 
4253
4646
  // src/daemon/dispatch.ts
4254
- var import_node_fs8 = require("fs");
4647
+ var import_node_fs9 = require("fs");
4255
4648
  var path = __toESM(require("path"), 1);
4256
4649
  init_skill_sync();
4650
+
4651
+ // src/daemon/task-heartbeat.ts
4652
+ var TaskHeartbeat = class {
4653
+ constructor(opts) {
4654
+ this.opts = opts;
4655
+ this.intervalMs = opts.intervalMs ?? 15e3;
4656
+ this.maxRetries = opts.maxRetries ?? 3;
4657
+ this.retryBackoffMs = opts.retryBackoffMs ?? 100;
4658
+ this.now = opts.now ?? Date.now;
4659
+ }
4660
+ opts;
4661
+ timer;
4662
+ version = 0;
4663
+ taskId;
4664
+ getPhase;
4665
+ /** Tracks the last step time pushed via touchStep() — informational. */
4666
+ lastStepAt;
4667
+ intervalMs;
4668
+ maxRetries;
4669
+ retryBackoffMs;
4670
+ now;
4671
+ /**
4672
+ * Begin heartbeating for `taskId`. `getPhase` is invoked at every tick to
4673
+ * pick up the live phase string — adapters can mutate their phase state
4674
+ * freely and the heartbeat will surface it on the next 15s boundary.
4675
+ *
4676
+ * Calling start() twice on the same instance replaces the active task
4677
+ * (previous timer is cleared). Returns silently when already started for
4678
+ * the same taskId.
4679
+ */
4680
+ start(taskId, getPhase) {
4681
+ if (this.taskId === taskId && this.timer) return;
4682
+ this.stop();
4683
+ this.taskId = taskId;
4684
+ this.getPhase = getPhase;
4685
+ this.timer = setInterval(() => {
4686
+ void this.push();
4687
+ }, this.intervalMs);
4688
+ this.timer.unref?.();
4689
+ }
4690
+ /**
4691
+ * Stop the heartbeat loop. Idempotent.
4692
+ */
4693
+ stop() {
4694
+ if (this.timer) {
4695
+ clearInterval(this.timer);
4696
+ this.timer = void 0;
4697
+ }
4698
+ this.taskId = void 0;
4699
+ this.getPhase = void 0;
4700
+ }
4701
+ /**
4702
+ * Phase transition — push immediately AND update what the timer-driven
4703
+ * tick will report next time. Adapter calls this whenever the lifecycle
4704
+ * advances (thinking → tool_use → reasoning → responding → ...).
4705
+ *
4706
+ * No-op when no task is active.
4707
+ */
4708
+ setPhase(phase) {
4709
+ if (!this.taskId) return;
4710
+ this.getPhase = () => phase;
4711
+ void this.push();
4712
+ }
4713
+ /**
4714
+ * Mark "I just made observable progress." Updates lastStepAt; next push
4715
+ * (timer-driven or setPhase-driven) carries it. Lightweight — no immediate
4716
+ * send. Adapters call this around tool calls / token boundaries so the
4717
+ * cloud reaper can distinguish "alive but slow" from "truly stuck".
4718
+ */
4719
+ touchStep() {
4720
+ this.lastStepAt = this.now();
4721
+ }
4722
+ /**
4723
+ * Manually trigger one heartbeat send (does not affect the timer). Mainly
4724
+ * useful for tests that don't want to await a 15s tick. Returns the
4725
+ * heartbeatVersion that was sent, or undefined if no task is active.
4726
+ */
4727
+ async forceTick() {
4728
+ if (!this.taskId) return void 0;
4729
+ return this.push();
4730
+ }
4731
+ /** Test helper: current heartbeatVersion (last value sent, may be 0 before first tick). */
4732
+ get currentVersion() {
4733
+ return this.version;
4734
+ }
4735
+ async push() {
4736
+ if (!this.taskId || !this.getPhase) return void 0;
4737
+ const taskId = this.taskId;
4738
+ const phase = this.getPhase();
4739
+ const heartbeatVersion = ++this.version;
4740
+ const payload = {
4741
+ taskId,
4742
+ heartbeatVersion,
4743
+ currentPhase: phase,
4744
+ ...this.lastStepAt !== void 0 ? { lastStepAt: this.lastStepAt } : {}
4745
+ };
4746
+ const msg = envelope("task.heartbeat", payload);
4747
+ let lastErr;
4748
+ for (let attempt = 0; attempt <= this.maxRetries; attempt += 1) {
4749
+ try {
4750
+ if (this.opts.ws.isOpen && !this.opts.ws.isOpen()) {
4751
+ return heartbeatVersion;
4752
+ }
4753
+ this.opts.ws.send(msg);
4754
+ return heartbeatVersion;
4755
+ } catch (err) {
4756
+ lastErr = err instanceof Error ? err : new Error(String(err));
4757
+ if (attempt < this.maxRetries) {
4758
+ const backoff = this.retryBackoffMs * Math.pow(2, attempt);
4759
+ await sleep(backoff);
4760
+ }
4761
+ }
4762
+ }
4763
+ if (lastErr) {
4764
+ try {
4765
+ this.opts.onGiveUp?.(taskId, lastErr);
4766
+ } catch {
4767
+ }
4768
+ }
4769
+ return heartbeatVersion;
4770
+ }
4771
+ };
4772
+ function sleep(ms) {
4773
+ return new Promise((resolve3) => {
4774
+ const handle = setTimeout(resolve3, ms);
4775
+ handle.unref?.();
4776
+ });
4777
+ }
4778
+
4779
+ // src/daemon/step-recorder.ts
4780
+ var StepRecorder = class {
4781
+ constructor(opts) {
4782
+ this.opts = opts;
4783
+ this.reasoningThrottleMs = opts.reasoningThrottleMs ?? 500;
4784
+ this.now = opts.now ?? Date.now;
4785
+ }
4786
+ opts;
4787
+ seq = 0;
4788
+ reasoningBuf = { texts: [], firstAt: 0 };
4789
+ reasoningTimer;
4790
+ reasoningThrottleMs;
4791
+ now;
4792
+ /** Push a phase transition step. Immediate (no throttle). */
4793
+ recordPhaseChange(phase) {
4794
+ this.emit("phase_change", { phase });
4795
+ }
4796
+ /**
4797
+ * Push a tool-call step. Immediate.
4798
+ *
4799
+ * `toolCallId` is the daemon-side correlation id used to pair with the
4800
+ * subsequent recordToolResult call. Free-form; adapter decides.
4801
+ */
4802
+ recordToolCall(toolName, input, toolCallId) {
4803
+ this.emit("tool_call", {
4804
+ toolName,
4805
+ inputSummary: summarize(input),
4806
+ ...toolCallId ? { toolCallId } : {}
4807
+ });
4808
+ }
4809
+ /** Push a tool-result step. Immediate. */
4810
+ recordToolResult(toolCallId, output) {
4811
+ this.emit("tool_result", {
4812
+ toolCallId,
4813
+ outputSummary: summarize(output)
4814
+ });
4815
+ }
4816
+ /**
4817
+ * Buffer a reasoning chunk for batched dispatch. Multiple chunks within
4818
+ * `reasoningThrottleMs` are concatenated and emitted as a single
4819
+ * `reasoning_chunk` step when the timer fires.
4820
+ */
4821
+ recordReasoningChunk(text) {
4822
+ if (!text) return;
4823
+ if (this.reasoningBuf.texts.length === 0) {
4824
+ this.reasoningBuf.firstAt = this.now();
4825
+ }
4826
+ this.reasoningBuf.texts.push(text);
4827
+ if (this.reasoningTimer) return;
4828
+ this.reasoningTimer = setTimeout(() => {
4829
+ this.flushReasoning();
4830
+ }, this.reasoningThrottleMs);
4831
+ this.reasoningTimer.unref?.();
4832
+ }
4833
+ /** Push an error step. Immediate. */
4834
+ recordError(message, payload) {
4835
+ this.emit("error", { message, ...payload ?? {} });
4836
+ }
4837
+ /**
4838
+ * Force any pending reasoning buffer out the door. Call on shutdown so
4839
+ * trailing tokens aren't lost.
4840
+ */
4841
+ flush() {
4842
+ this.flushReasoning();
4843
+ }
4844
+ /** Test helper. */
4845
+ get currentSeq() {
4846
+ return this.seq;
4847
+ }
4848
+ flushReasoning() {
4849
+ if (this.reasoningTimer) {
4850
+ clearTimeout(this.reasoningTimer);
4851
+ this.reasoningTimer = void 0;
4852
+ }
4853
+ if (this.reasoningBuf.texts.length === 0) return;
4854
+ const text = this.reasoningBuf.texts.join("");
4855
+ const firstAt = this.reasoningBuf.firstAt;
4856
+ this.reasoningBuf = { texts: [], firstAt: 0 };
4857
+ this.emit("reasoning_chunk", { text }, firstAt);
4858
+ }
4859
+ emit(kind, payload, occurredAt) {
4860
+ const frame = {
4861
+ seq: ++this.seq,
4862
+ kind,
4863
+ payload,
4864
+ occurredAt: occurredAt ?? this.now()
4865
+ };
4866
+ const msg = envelope("task.step.append", {
4867
+ taskRunId: this.opts.taskRunId,
4868
+ step: frame
4869
+ });
4870
+ try {
4871
+ if (this.opts.ws.isOpen && !this.opts.ws.isOpen()) {
4872
+ try {
4873
+ this.opts.onSend?.(frame);
4874
+ } catch {
4875
+ }
4876
+ return;
4877
+ }
4878
+ this.opts.ws.send(msg);
4879
+ try {
4880
+ this.opts.onSend?.(frame);
4881
+ } catch {
4882
+ }
4883
+ } catch {
4884
+ }
4885
+ }
4886
+ };
4887
+ var MAX_SUMMARY_CHARS = 512;
4888
+ function summarize(value) {
4889
+ let text;
4890
+ if (value == null) text = "";
4891
+ else if (typeof value === "string") text = value;
4892
+ else {
4893
+ try {
4894
+ text = JSON.stringify(value);
4895
+ } catch {
4896
+ text = String(value);
4897
+ }
4898
+ }
4899
+ if (text.length > MAX_SUMMARY_CHARS) {
4900
+ return `${text.slice(0, MAX_SUMMARY_CHARS)}\u2026(+${text.length - MAX_SUMMARY_CHARS} chars)`;
4901
+ }
4902
+ return text;
4903
+ }
4904
+
4905
+ // src/daemon/dispatch.ts
4257
4906
  var DEFAULT_CONTEXT_MAX = 8e3;
4258
4907
  var GOAL_CONTEXT_MAX = 4;
4259
4908
  var MEMORY_DIGEST_MAX_BYTES = 4e3;
@@ -4269,12 +4918,14 @@ async function handleDispatch(payload, requestId, deps) {
4269
4918
  const { taskId, agentImUserId } = payload;
4270
4919
  let resolvedHashes = [];
4271
4920
  let reply;
4921
+ let heartbeatRef;
4922
+ let recorderRef;
4272
4923
  const outboxBase = deps.paths?.runsDir ? path.join(deps.paths.runsDir, taskId) : null;
4273
4924
  const outboxDir = outboxBase ? path.join(outboxBase, "_outbox") : null;
4274
4925
  const workDir = outboxBase ? path.join(outboxBase, "workdir") : null;
4275
4926
  if (outboxDir) {
4276
4927
  try {
4277
- await import_node_fs8.promises.mkdir(outboxDir, { recursive: true });
4928
+ await import_node_fs9.promises.mkdir(outboxDir, { recursive: true });
4278
4929
  } catch (err) {
4279
4930
  process.stderr.write(
4280
4931
  `[daemon] outbox provision failed task=${taskId} dir=${outboxDir}: ${err.message}
@@ -4284,7 +4935,7 @@ async function handleDispatch(payload, requestId, deps) {
4284
4935
  }
4285
4936
  if (workDir) {
4286
4937
  try {
4287
- await import_node_fs8.promises.mkdir(workDir, { recursive: true });
4938
+ await import_node_fs9.promises.mkdir(workDir, { recursive: true });
4288
4939
  } catch (err) {
4289
4940
  process.stderr.write(
4290
4941
  `[daemon] workdir provision failed task=${taskId} dir=${workDir}: ${err.message}
@@ -4350,7 +5001,13 @@ async function handleDispatch(payload, requestId, deps) {
4350
5001
  resolvedHashes.push(...r.resolvedHashes);
4351
5002
  rewrittenContext = payload.context.map((e, i) => ({ ...e, content: r.texts[i] }));
4352
5003
  }
4353
- const assetResolution = await resolveAssetRefs(payload.assetRefs, deps.assetCache, taskId);
5004
+ const assetResolution = await resolveAssetRefs(payload.assetRefs, deps.assetCache, taskId, {
5005
+ cloud: deps.cloud,
5006
+ enabled: deps.visionAux?.enabled !== false,
5007
+ cacheDir: deps.visionAux?.cacheDir ?? (deps.paths ? path.join(deps.paths.cacheDir, "vision-cache") : void 0),
5008
+ timeoutMs: deps.visionAux?.timeoutMs,
5009
+ signal: deps.signal
5010
+ });
4354
5011
  resolvedHashes.push(...assetResolution.pinnedHashes);
4355
5012
  const basePrompt = composePrompt(
4356
5013
  rewrittenPrompt.text,
@@ -4385,9 +5042,44 @@ async function handleDispatch(payload, requestId, deps) {
4385
5042
  }
4386
5043
  const operatingPrinciples = resolveOperatingPrinciples(profile.config);
4387
5044
  const composedSystemPrompt = [profileSystemPrompt, operatingPrinciples].filter((part) => typeof part === "string" && part.length > 0).join("\n\n");
5045
+ let activePhase = "thinking";
5046
+ const heartbeat = new TaskHeartbeat({
5047
+ ws: deps.ws,
5048
+ onGiveUp: (tid, err) => process.stderr.write(
5049
+ `[daemon] task.heartbeat give-up task=${tid}: ${err.message}
5050
+ `
5051
+ )
5052
+ });
5053
+ heartbeatRef = heartbeat;
5054
+ heartbeat.start(taskId, () => activePhase);
5055
+ const recorder = new StepRecorder({ ws: deps.ws, taskRunId: taskId });
5056
+ recorderRef = recorder;
5057
+ recorder.recordPhaseChange(activePhase);
4388
5058
  const taskInput = {
4389
5059
  taskId,
4390
5060
  prompt: adapterPrompt,
5061
+ heartbeat: {
5062
+ setPhase: (phase) => {
5063
+ activePhase = phase;
5064
+ heartbeat.setPhase(phase);
5065
+ recorder.recordPhaseChange(phase);
5066
+ },
5067
+ touchStep: () => heartbeat.touchStep()
5068
+ },
5069
+ recorder: {
5070
+ recordPhaseChange: (phase) => {
5071
+ activePhase = phase;
5072
+ heartbeat.setPhase(phase);
5073
+ recorder.recordPhaseChange(phase);
5074
+ },
5075
+ recordToolCall: (toolName, input, toolCallId) => recorder.recordToolCall(toolName, input, toolCallId),
5076
+ recordToolResult: (toolCallId, output) => recorder.recordToolResult(toolCallId, output),
5077
+ recordReasoningChunk: (text) => recorder.recordReasoningChunk(text),
5078
+ recordError: (message, payload2) => recorder.recordError(message, payload2)
5079
+ },
5080
+ // 14b rev.3 §9 P3 — multimodal-aware adapters (Hermes, OpenClaw) pick
5081
+ // up the resolved bytes/URLs here; text-only adapters ignore the field.
5082
+ ...assetResolution.resolvedRefs.length > 0 ? { assetRefs: assetResolution.resolvedRefs } : {},
4391
5083
  metadata: {
4392
5084
  ...payload.metadata,
4393
5085
  conversationId: payload.conversationId,
@@ -4450,6 +5142,9 @@ async function handleDispatch(payload, requestId, deps) {
4450
5142
  }
4451
5143
  }
4452
5144
  const reaperAborted = deps.signal?.aborted && result.error?.code === "task_cancelled";
5145
+ const approvalRequested = Boolean(
5146
+ result.metadata?.approvalRequested
5147
+ );
4453
5148
  let collectedAssetIds = [];
4454
5149
  if (deps.outboxWatcher && outboxDir) {
4455
5150
  try {
@@ -4461,14 +5156,23 @@ async function handleDispatch(payload, requestId, deps) {
4461
5156
  collectedAssetIds = deps.outboxWatcher.flushPending(taskId);
4462
5157
  }
4463
5158
  const reaperLimitMs = typeof payload.timeoutMs === "number" && payload.timeoutMs > 0 ? payload.timeoutMs : 5 * 6e4;
5159
+ const finalError = approvalRequested ? {
5160
+ code: "awaiting_human_approval",
5161
+ message: "Agent requested human approval and is suspended pending decision. Cloud will redispatch with `approval.decided` once the human responds."
5162
+ } : reaperAborted ? {
5163
+ code: "daemon_task_timeout",
5164
+ message: `Daemon-side reaper aborted after ${reaperLimitMs}ms inactivity (no progress events from adapter)`
5165
+ } : result.error;
4464
5166
  reply = {
4465
5167
  taskId,
5168
+ // When approval is pending we keep ok=false (the run did not produce a
5169
+ // completion-style output) but the cloud-side handler treats the
5170
+ // `awaiting_human_approval` code as non-terminal — task stays alive,
5171
+ // no red failure pill, and the next dispatch (after the human decides)
5172
+ // continues normally.
4466
5173
  ok: result.ok,
4467
5174
  output: result.output,
4468
- error: reaperAborted ? {
4469
- code: "daemon_task_timeout",
4470
- message: `Daemon-side reaper aborted after ${reaperLimitMs}ms inactivity (no progress events from adapter)`
4471
- } : result.error,
5175
+ error: finalError,
4472
5176
  ...collectedAssetIds.length > 0 ? { assetIds: collectedAssetIds } : {},
4473
5177
  metrics: result.metrics,
4474
5178
  ...assetResolution.observability.length > 0 || urlObservations.length > 0 ? { assetObservability: [...assetResolution.observability, ...urlObservations] } : {}
@@ -4490,6 +5194,18 @@ async function handleDispatch(payload, requestId, deps) {
4490
5194
  }
4491
5195
  };
4492
5196
  } finally {
5197
+ try {
5198
+ recorderRef?.flush();
5199
+ } catch (err) {
5200
+ process.stderr.write(`[daemon] step recorder flush failed task=${taskId}: ${err.message}
5201
+ `);
5202
+ }
5203
+ try {
5204
+ heartbeatRef?.stop();
5205
+ } catch (err) {
5206
+ process.stderr.write(`[daemon] heartbeat stop failed task=${taskId}: ${err.message}
5207
+ `);
5208
+ }
4493
5209
  for (const hash of new Set(resolvedHashes)) {
4494
5210
  try {
4495
5211
  deps.assetCache.unpin(hash);
@@ -4566,6 +5282,10 @@ function isTextLikeMime(mime) {
4566
5282
  if (m.endsWith("+json") || m.endsWith("+xml") || m.endsWith("+csv")) return true;
4567
5283
  return false;
4568
5284
  }
5285
+ function isImageMime(mime) {
5286
+ if (!mime) return false;
5287
+ return mime.toLowerCase().split(";")[0].trim().startsWith("image/");
5288
+ }
4569
5289
  var HASH_REF_RE = /(?:^|\s)#([^\s#]+)/g;
4570
5290
  var HEX_COLOR_RE = /^[0-9a-fA-F]{3,8}$/;
4571
5291
  var FILE_EXT_RE = /\.[a-zA-Z0-9]{1,10}$/;
@@ -4644,8 +5364,34 @@ async function resolveHashRefs(prompt, assetIndex, cloud) {
4644
5364
  }
4645
5365
  return { text: result, resolutions };
4646
5366
  }
4647
- async function resolveAssetRefs(refs, cache, taskId) {
4648
- const out = { promptBlocks: [], observability: [], pinnedHashes: [] };
5367
+ var ASSET_BASE64_INLINE_MAX_BYTES = 10 * 1024 * 1024;
5368
+ async function probeCdnReachable(cdnUrl, timeoutMs = 1500) {
5369
+ try {
5370
+ const u = new URL(cdnUrl);
5371
+ if (u.hostname === "localhost" || u.hostname === "127.0.0.1" || u.hostname.startsWith("192.168.")) {
5372
+ return false;
5373
+ }
5374
+ } catch {
5375
+ return false;
5376
+ }
5377
+ try {
5378
+ const r = await fetch(cdnUrl, {
5379
+ method: "HEAD",
5380
+ signal: AbortSignal.timeout(timeoutMs)
5381
+ });
5382
+ if (r.ok) return true;
5383
+ return false;
5384
+ } catch {
5385
+ return false;
5386
+ }
5387
+ }
5388
+ async function resolveAssetRefs(refs, cache, taskId, visionAux) {
5389
+ const out = {
5390
+ promptBlocks: [],
5391
+ observability: [],
5392
+ pinnedHashes: [],
5393
+ resolvedRefs: []
5394
+ };
4649
5395
  if (!refs || refs.length === 0) return out;
4650
5396
  for (const ref of refs) {
4651
5397
  let cached;
@@ -4674,7 +5420,7 @@ async function resolveAssetRefs(refs, cache, taskId) {
4674
5420
  const inlineCandidate = isTextLikeMime(effectiveMime);
4675
5421
  if (inlineCandidate) {
4676
5422
  try {
4677
- const buf = (0, import_node_fs8.readFileSync)(cached.localPath);
5423
+ const buf = (0, import_node_fs9.readFileSync)(cached.localPath);
4678
5424
  let strategy;
4679
5425
  let body;
4680
5426
  if (buf.byteLength <= ASSET_INLINE_FULL_MAX_BYTES) {
@@ -4710,7 +5456,42 @@ async function resolveAssetRefs(refs, cache, taskId) {
4710
5456
  continue;
4711
5457
  }
4712
5458
  }
4713
- out.promptBlocks.push(formatUriOnlyAssetBlock(ref, effectiveMime, cached.localPath));
5459
+ let reachable = "unknown";
5460
+ let base64;
5461
+ if (ref.cdnUrl) {
5462
+ const ok2 = await probeCdnReachable(ref.cdnUrl);
5463
+ if (ok2) {
5464
+ reachable = "cdn";
5465
+ }
5466
+ }
5467
+ if (reachable !== "cdn") {
5468
+ try {
5469
+ const buf = (0, import_node_fs9.readFileSync)(cached.localPath);
5470
+ if (buf.byteLength <= ASSET_BASE64_INLINE_MAX_BYTES) {
5471
+ base64 = buf.toString("base64");
5472
+ reachable = "base64";
5473
+ } else {
5474
+ reachable = "unknown";
5475
+ }
5476
+ } catch (err) {
5477
+ const errMsg = `base64 fallback read failed: ${err.message}`;
5478
+ logAssetResolveFailure(taskId, ref, errMsg, effectiveMime);
5479
+ }
5480
+ }
5481
+ const resolvedRef = {
5482
+ ...ref,
5483
+ mime: effectiveMime,
5484
+ localPath: cached.localPath,
5485
+ base64,
5486
+ reachable
5487
+ };
5488
+ out.resolvedRefs.push(resolvedRef);
5489
+ if (isImageMime(effectiveMime) && visionAux?.enabled) {
5490
+ const block = await describeImageAttachment(ref, effectiveMime, resolvedRef, visionAux, taskId);
5491
+ out.promptBlocks.push(block);
5492
+ } else {
5493
+ out.promptBlocks.push(formatAttachmentReminderBlock(ref, effectiveMime));
5494
+ }
4714
5495
  out.observability.push({
4715
5496
  assetId: ref.assetId,
4716
5497
  contentHash: ref.contentHash,
@@ -4721,6 +5502,110 @@ async function resolveAssetRefs(refs, cache, taskId) {
4721
5502
  }
4722
5503
  return out;
4723
5504
  }
5505
+ async function describeImageAttachment(ref, mime, resolved, opts, taskId) {
5506
+ const cached = await readVisionAuxLocalCache(opts.cacheDir, ref.contentHash, mime);
5507
+ if (cached) return formatVisionAuxBlock(ref, mime, cached.description, true);
5508
+ const source = buildVisionAuxSource(resolved, mime);
5509
+ if (!source) {
5510
+ return formatVisionAuxUnavailableBlock(ref, mime, "no reachable URL or inline bytes");
5511
+ }
5512
+ try {
5513
+ const description = await callVisionAux(ref, mime, source, opts);
5514
+ await writeVisionAuxLocalCache(opts.cacheDir, ref.contentHash, mime, description);
5515
+ return formatVisionAuxBlock(ref, mime, description.description, false);
5516
+ } catch (err) {
5517
+ const message = err instanceof Error ? err.message : String(err);
5518
+ process.stderr.write(
5519
+ `[daemon] vision-aux failed task=${taskId ?? "unknown"} assetId=${ref.assetId} hash=${ref.contentHash}: ${message}
5520
+ `
5521
+ );
5522
+ return formatVisionAuxUnavailableBlock(ref, mime, message);
5523
+ }
5524
+ }
5525
+ function buildVisionAuxSource(resolved, mime) {
5526
+ if (resolved.reachable === "cdn" && resolved.cdnUrl) return { kind: "url", url: resolved.cdnUrl };
5527
+ if (resolved.base64 && mime) return { kind: "data_url", url: `data:${mime};base64,${resolved.base64}` };
5528
+ return null;
5529
+ }
5530
+ async function callVisionAux(ref, mime, source, opts) {
5531
+ const secret = process.env.VISION_AUX_INTERNAL_SECRET || process.env.INTERNAL_API_SECRET || process.env.PRISMER_INTERNAL_SECRET;
5532
+ const headers = secret ? { "x-prismer-internal-secret": secret } : void 0;
5533
+ const res = await opts.cloud.request(
5534
+ "POST",
5535
+ "/api/internal/vision-aux/describe",
5536
+ {
5537
+ body: {
5538
+ assetId: ref.assetId,
5539
+ contentHash: ref.contentHash,
5540
+ mime: mime ?? ref.mime ?? "image/unknown",
5541
+ source
5542
+ },
5543
+ headers,
5544
+ timeoutMs: opts.timeoutMs ?? 12e3,
5545
+ signal: opts.signal
5546
+ }
5547
+ );
5548
+ const env = res.data;
5549
+ if (!res.ok || env?.ok === false || !env?.data?.description) {
5550
+ const message = res.error?.message || (typeof env?.error === "string" ? env.error : env?.error?.message) || env?.message || `HTTP ${res.status}`;
5551
+ throw new Error(message);
5552
+ }
5553
+ const now = /* @__PURE__ */ new Date();
5554
+ const ttlSec = Number.isFinite(env.data.cacheTtlSec) ? Number(env.data.cacheTtlSec) : 300;
5555
+ return {
5556
+ description: env.data.description,
5557
+ modelUsed: env.data.modelUsed || "unknown",
5558
+ provider: env.data.provider || "vision-aux",
5559
+ generatedAt: now.toISOString(),
5560
+ expiresAt: new Date(now.getTime() + Math.max(1, ttlSec) * 1e3).toISOString(),
5561
+ mime: mime ?? ref.mime ?? "image/unknown"
5562
+ };
5563
+ }
5564
+ async function readVisionAuxLocalCache(cacheDir, contentHash, mime) {
5565
+ if (!cacheDir) return null;
5566
+ const file = visionAuxCachePath(cacheDir, contentHash);
5567
+ try {
5568
+ const raw = await import_node_fs9.promises.readFile(file, "utf8");
5569
+ const parsed = parseVisionAuxCache(raw);
5570
+ if (!parsed) {
5571
+ await import_node_fs9.promises.unlink(file).catch(() => void 0);
5572
+ return null;
5573
+ }
5574
+ if (parsed.mime !== mime) return null;
5575
+ const expiresAt = Date.parse(parsed.expiresAt);
5576
+ if (!Number.isFinite(expiresAt) || expiresAt <= Date.now()) return null;
5577
+ return parsed;
5578
+ } catch (err) {
5579
+ if (err.code !== "ENOENT") {
5580
+ await import_node_fs9.promises.unlink(file).catch(() => void 0);
5581
+ }
5582
+ return null;
5583
+ }
5584
+ }
5585
+ async function writeVisionAuxLocalCache(cacheDir, contentHash, mime, description) {
5586
+ if (!cacheDir || !mime) return;
5587
+ const file = visionAuxCachePath(cacheDir, contentHash);
5588
+ await import_node_fs9.promises.mkdir(path.dirname(file), { recursive: true });
5589
+ await import_node_fs9.promises.writeFile(file, JSON.stringify({ ...description, mime }, null, 2), "utf8");
5590
+ }
5591
+ function visionAuxCachePath(cacheDir, contentHash) {
5592
+ const safeHash = /^[a-f0-9]{32,128}$/i.test(contentHash) ? contentHash : createSafeCacheKey(contentHash);
5593
+ return path.join(cacheDir, `${safeHash}.json`);
5594
+ }
5595
+ function createSafeCacheKey(value) {
5596
+ return Buffer.from(value).toString("base64url").slice(0, 128) || "unknown";
5597
+ }
5598
+ function parseVisionAuxCache(raw) {
5599
+ try {
5600
+ const parsed = JSON.parse(raw);
5601
+ if (typeof parsed.description === "string" && typeof parsed.modelUsed === "string" && typeof parsed.provider === "string" && typeof parsed.generatedAt === "string" && typeof parsed.expiresAt === "string" && typeof parsed.mime === "string") {
5602
+ return parsed;
5603
+ }
5604
+ } catch {
5605
+ return null;
5606
+ }
5607
+ return null;
5608
+ }
4724
5609
  function logAssetResolveFailure(taskId, ref, error, mime = ref.mime) {
4725
5610
  const message = error.replace(/\s+/g, " ").slice(0, 500);
4726
5611
  process.stderr.write(
@@ -4735,8 +5620,19 @@ function formatInlineAssetBlock(ref, mime, body, strategy) {
4735
5620
  ${body}
4736
5621
  ---`;
4737
5622
  }
4738
- function formatUriOnlyAssetBlock(ref, mime, localPath) {
4739
- return `[Attached file] id=${ref.assetId} mime=${mime ?? "unknown"} path=file://${localPath} \u2014 open with your read/parse tool when needed.`;
5623
+ function formatAttachmentReminderBlock(ref, mime) {
5624
+ const name = ref.filename ? ` name=${ref.filename}` : "";
5625
+ return `[Attached file] id=${ref.assetId} mime=${mime ?? "unknown"}${name} \u2014 the user uploaded this file; multimodal adapters receive the bytes directly.`;
5626
+ }
5627
+ function formatVisionAuxBlock(ref, mime, description, cached) {
5628
+ const name = ref.filename ? `: ${ref.filename}` : "";
5629
+ const cacheLabel = cached ? " local-cache" : "";
5630
+ return `[Image attachment${name}] id=${ref.assetId} mime=${mime ?? "unknown"}${cacheLabel}
5631
+ ${description.trim()}`;
5632
+ }
5633
+ function formatVisionAuxUnavailableBlock(ref, mime, reason) {
5634
+ const name = ref.filename ? `: ${ref.filename}` : "";
5635
+ return `[Image attachment${name}; description unavailable due to vision-aux error] id=${ref.assetId} mime=${mime ?? "unknown"} reason=${reason.slice(0, 180)}`;
4740
5636
  }
4741
5637
  function formatErrorAssetBlock(ref, mime, error) {
4742
5638
  return `[Attached file] id=${ref.assetId} mime=${mime ?? "unknown"} ERROR: ${error} \u2014 this file failed to load on the daemon side. Tell the user the specific error and ask them to re-upload or paste the content directly; do not guess at the cause.`;
@@ -5219,8 +6115,9 @@ var ServicePool = class {
5219
6115
  // src/daemon/local-server.ts
5220
6116
  var import_node_http = require("http");
5221
6117
  var import_node_crypto4 = require("crypto");
5222
- var import_node_fs9 = require("fs");
6118
+ var import_node_fs10 = require("fs");
5223
6119
  var path2 = __toESM(require("path"), 1);
6120
+ var import_node_os5 = require("os");
5224
6121
  var LocalServer = class {
5225
6122
  constructor(opts) {
5226
6123
  this.opts = opts;
@@ -5332,6 +6229,11 @@ var LocalServer = class {
5332
6229
  void this.handleSnapshot(req, res);
5333
6230
  return;
5334
6231
  }
6232
+ const agentDumpMatch = /^\/v1\/agents\/([^/]+)\/dump-state$/.exec(url);
6233
+ if (req.method === "POST" && agentDumpMatch) {
6234
+ void this.handleAgentDumpState(req, res, decodeURIComponent(agentDumpMatch[1]));
6235
+ return;
6236
+ }
5335
6237
  if (req.method === "POST" && url === "/local/asset/write") {
5336
6238
  void this.handleAssetWrite(req, res);
5337
6239
  return;
@@ -5382,7 +6284,7 @@ var LocalServer = class {
5382
6284
  async handleSnapshot(req, res) {
5383
6285
  const root = this.opts.snapshotRoot ?? "/workspace";
5384
6286
  try {
5385
- const st = await import_node_fs9.promises.stat(root);
6287
+ const st = await import_node_fs10.promises.stat(root);
5386
6288
  if (!st.isDirectory()) {
5387
6289
  respond(res, 400, { error: "snapshot_root_not_directory", rootPath: root });
5388
6290
  return;
@@ -5409,6 +6311,54 @@ var LocalServer = class {
5409
6311
  }
5410
6312
  void req;
5411
6313
  }
6314
+ /**
6315
+ * POST /v1/agents/:agentId/dump-state — agent-scoped daemon manifest.
6316
+ *
6317
+ * This is narrower than `/v1/snapshot`: it walks only the current agent's
6318
+ * profile/work dirs so cloud can attach the result to IMAgentSnapshot without
6319
+ * capturing unrelated agents hosted in the same daemon/container.
6320
+ */
6321
+ async handleAgentDumpState(req, res, agentId) {
6322
+ const state = this.opts.getState();
6323
+ const agent = state.hostedAgents.find((item) => item.imUserId === agentId);
6324
+ if (!agent) {
6325
+ respond(res, 404, { error: "agent_not_hosted", agentId });
6326
+ return;
6327
+ }
6328
+ const roots = getAgentStateRoots(agent, this.opts.snapshotRoot ?? "/workspace");
6329
+ try {
6330
+ const manifests = await Promise.all(
6331
+ roots.map(async (root) => {
6332
+ const stat = await statOptional(root.rootPath);
6333
+ if (!stat) return { ...root, exists: false, files: [] };
6334
+ if (!stat.isDirectory()) return { ...root, exists: true, error: "not_directory", files: [] };
6335
+ return { ...root, exists: true, files: await walkAndDigest(root.rootPath, root.rootPath) };
6336
+ })
6337
+ );
6338
+ const files = manifests.flatMap(
6339
+ (manifest) => manifest.files.map((file) => ({
6340
+ ...file,
6341
+ path: `${manifest.kind}/${file.path}`,
6342
+ rootKind: manifest.kind,
6343
+ rootPath: manifest.rootPath
6344
+ }))
6345
+ );
6346
+ respond(res, 200, {
6347
+ agentId,
6348
+ adapterName: agent.adapterName,
6349
+ dumpedAt: (/* @__PURE__ */ new Date()).toISOString(),
6350
+ roots: manifests,
6351
+ files
6352
+ });
6353
+ } catch (err) {
6354
+ respond(res, 500, {
6355
+ error: "agent_dump_state_failed",
6356
+ agentId,
6357
+ message: err instanceof Error ? err.message : String(err)
6358
+ });
6359
+ }
6360
+ void req;
6361
+ }
5412
6362
  /**
5413
6363
  * POST /local/asset/write — agent-gen adapter RPC.
5414
6364
  *
@@ -5680,11 +6630,44 @@ function validateAgentDispatchRequest(body) {
5680
6630
  if (payload.attachments != null && !Array.isArray(payload.attachments)) return "attachments must be an array";
5681
6631
  return null;
5682
6632
  }
6633
+ function getAgentStateRoots(agent, snapshotRoot) {
6634
+ const profileName = sanitizeProfileName(agent.name || agent.imUserId);
6635
+ const roots = [
6636
+ {
6637
+ kind: "workspace-agent",
6638
+ rootPath: path2.join(snapshotRoot, "agents", agent.imUserId)
6639
+ }
6640
+ ];
6641
+ if (agent.adapterName === "hermes" || agent.adapterName === "claude-code") {
6642
+ roots.unshift({
6643
+ kind: "hermes-profile",
6644
+ rootPath: path2.join(process.env.HERMES_HOME || path2.join((0, import_node_os5.homedir)(), ".hermes"), "profiles", profileName)
6645
+ });
6646
+ }
6647
+ if (agent.adapterName === "openclaw") {
6648
+ roots.unshift({
6649
+ kind: "openclaw-profile",
6650
+ rootPath: path2.join(process.env.OPENCLAW_HOME || path2.join((0, import_node_os5.homedir)(), ".openclaw"), "profiles", profileName)
6651
+ });
6652
+ }
6653
+ return roots;
6654
+ }
6655
+ function sanitizeProfileName(value) {
6656
+ return value.replace(/[\\/]/g, "_").trim() || "default";
6657
+ }
6658
+ async function statOptional(rootPath) {
6659
+ try {
6660
+ return await import_node_fs10.promises.stat(rootPath);
6661
+ } catch (err) {
6662
+ if (err.code === "ENOENT") return null;
6663
+ throw err;
6664
+ }
6665
+ }
5683
6666
  async function walkAndDigest(root, current) {
5684
6667
  const out = [];
5685
6668
  let entries;
5686
6669
  try {
5687
- entries = await import_node_fs9.promises.readdir(current);
6670
+ entries = await import_node_fs10.promises.readdir(current);
5688
6671
  } catch (err) {
5689
6672
  if (err.code === "ENOENT") return out;
5690
6673
  throw err;
@@ -5695,7 +6678,7 @@ async function walkAndDigest(root, current) {
5695
6678
  const rel = path2.relative(root, full);
5696
6679
  let st;
5697
6680
  try {
5698
- st = await import_node_fs9.promises.stat(full);
6681
+ st = await import_node_fs10.promises.stat(full);
5699
6682
  } catch {
5700
6683
  continue;
5701
6684
  }
@@ -5706,7 +6689,7 @@ async function walkAndDigest(root, current) {
5706
6689
  continue;
5707
6690
  }
5708
6691
  if (!st.isFile()) continue;
5709
- const buf = await import_node_fs9.promises.readFile(full);
6692
+ const buf = await import_node_fs10.promises.readFile(full);
5710
6693
  const sha2563 = (0, import_node_crypto4.createHash)("sha256").update(buf).digest("hex");
5711
6694
  out.push({
5712
6695
  path: rel,
@@ -5721,9 +6704,9 @@ async function walkAndDigest(root, current) {
5721
6704
  // src/daemon/runner.ts
5722
6705
  var import_node_events3 = require("events");
5723
6706
  var import_node_module2 = require("module");
5724
- var import_node_fs15 = require("fs");
6707
+ var import_node_fs16 = require("fs");
5725
6708
  var import_promises = require("fs/promises");
5726
- var import_node_os6 = require("os");
6709
+ var import_node_os8 = require("os");
5727
6710
  var import_node_path11 = require("path");
5728
6711
 
5729
6712
  // src/adapters/claude-code/index.ts
@@ -7821,7 +8804,7 @@ function parsePrismerUri(uri) {
7821
8804
  }
7822
8805
 
7823
8806
  // src/daemon/asset/metadata-index.ts
7824
- var import_node_fs10 = require("fs");
8807
+ var import_node_fs11 = require("fs");
7825
8808
  var import_node_path9 = require("path");
7826
8809
  var DEFAULT_LIMIT = 8;
7827
8810
  var PULL_PAGE_SIZE = 500;
@@ -7850,16 +8833,16 @@ var AssetMetadataIndex = class {
7850
8833
  this.db = opts.db;
7851
8834
  this.cloud = opts.cloud;
7852
8835
  this.workspaceId = opts.workspaceId;
7853
- if (!(0, import_node_fs10.existsSync)(opts.workspaceStateDir)) {
7854
- (0, import_node_fs10.mkdirSync)(opts.workspaceStateDir, { recursive: true });
8836
+ if (!(0, import_node_fs11.existsSync)(opts.workspaceStateDir)) {
8837
+ (0, import_node_fs11.mkdirSync)(opts.workspaceStateDir, { recursive: true });
7855
8838
  }
7856
8839
  this.cursorPath = (0, import_node_path9.join)(opts.workspaceStateDir, "asset-metadata-cursor.json");
7857
8840
  }
7858
8841
  /** Persisted cursor for this workspace, or 0 on first run / corrupted file. */
7859
8842
  readCursor() {
7860
- if (!(0, import_node_fs10.existsSync)(this.cursorPath)) return 0;
8843
+ if (!(0, import_node_fs11.existsSync)(this.cursorPath)) return 0;
7861
8844
  try {
7862
- const parsed = JSON.parse((0, import_node_fs10.readFileSync)(this.cursorPath, "utf8"));
8845
+ const parsed = JSON.parse((0, import_node_fs11.readFileSync)(this.cursorPath, "utf8"));
7863
8846
  if (parsed.workspaceId !== this.workspaceId) return 0;
7864
8847
  return parsed.cursor;
7865
8848
  } catch {
@@ -7872,7 +8855,7 @@ var AssetMetadataIndex = class {
7872
8855
  cursor,
7873
8856
  writtenAt: Date.now()
7874
8857
  };
7875
- (0, import_node_fs10.writeFileSync)(this.cursorPath, JSON.stringify(payload, null, 2));
8858
+ (0, import_node_fs11.writeFileSync)(this.cursorPath, JSON.stringify(payload, null, 2));
7876
8859
  }
7877
8860
  /**
7878
8861
  * Pull incremental asset metadata changes since the persisted cursor and
@@ -7989,7 +8972,7 @@ var AssetMetadataIndex = class {
7989
8972
  };
7990
8973
 
7991
8974
  // src/daemon/asset/rpc.ts
7992
- var import_node_fs11 = require("fs");
8975
+ var import_node_fs12 = require("fs");
7993
8976
  var ASSET_PATH_PREFIX = "/local/asset/";
7994
8977
  var MAX_READ_BYTES = 256 * 1024;
7995
8978
  var MAX_SEARCH_BYTES = 1024 * 1024;
@@ -8164,16 +9147,16 @@ function isTextLike(row) {
8164
9147
  );
8165
9148
  }
8166
9149
  function readFileRange(path9, offset, length) {
8167
- const sizeBytes = (0, import_node_fs11.statSync)(path9).size;
9150
+ const sizeBytes = (0, import_node_fs12.statSync)(path9).size;
8168
9151
  if (offset >= sizeBytes) return { buffer: Buffer.alloc(0), bytesRead: 0, sizeBytes };
8169
- const fd = (0, import_node_fs11.openSync)(path9, "r");
9152
+ const fd = (0, import_node_fs12.openSync)(path9, "r");
8170
9153
  try {
8171
9154
  const target = Math.min(length, sizeBytes - offset);
8172
9155
  const buffer = Buffer.alloc(target);
8173
- const bytesRead = (0, import_node_fs11.readSync)(fd, buffer, 0, target, offset);
9156
+ const bytesRead = (0, import_node_fs12.readSync)(fd, buffer, 0, target, offset);
8174
9157
  return { buffer: buffer.subarray(0, bytesRead), bytesRead, sizeBytes };
8175
9158
  } finally {
8176
- (0, import_node_fs11.closeSync)(fd);
9159
+ (0, import_node_fs12.closeSync)(fd);
8177
9160
  }
8178
9161
  }
8179
9162
  function respond3(res, status, body) {
@@ -8198,8 +9181,8 @@ async function readJson3(req) {
8198
9181
  }
8199
9182
 
8200
9183
  // src/daemon/asset/origin/drop-folder.ts
8201
- var import_node_fs12 = require("fs");
8202
- var import_node_os5 = require("os");
9184
+ var import_node_fs13 = require("fs");
9185
+ var import_node_os6 = require("os");
8203
9186
  var path5 = __toESM(require("path"), 1);
8204
9187
  var DropFolderAdapter = class {
8205
9188
  kind = "drop-folder";
@@ -8207,7 +9190,7 @@ var DropFolderAdapter = class {
8207
9190
  rootDir;
8208
9191
  constructor(opts = {}) {
8209
9192
  this.workspaceDir = opts.workspaceDir;
8210
- this.rootDir = opts.rootDir ?? path5.join(opts.homeDir ?? (0, import_node_os5.homedir)(), ".prismer");
9193
+ this.rootDir = opts.rootDir ?? path5.join(opts.homeDir ?? (0, import_node_os6.homedir)(), ".prismer");
8211
9194
  }
8212
9195
  /**
8213
9196
  * Long-lived async iterable wrapping `scanOnce` on a 1s polling interval.
@@ -8217,7 +9200,7 @@ var DropFolderAdapter = class {
8217
9200
  while (true) {
8218
9201
  const batch = await this.scanOnce(workspaceId);
8219
9202
  for (const obs of batch) yield obs;
8220
- await sleep(1e3);
9203
+ await sleep2(1e3);
8221
9204
  }
8222
9205
  }
8223
9206
  /**
@@ -8231,7 +9214,7 @@ var DropFolderAdapter = class {
8231
9214
  const drop = this.dropDir(workspaceId);
8232
9215
  let entries = [];
8233
9216
  try {
8234
- entries = await import_node_fs12.promises.readdir(drop);
9217
+ entries = await import_node_fs13.promises.readdir(drop);
8235
9218
  } catch (err) {
8236
9219
  if (err.code === "ENOENT") return [];
8237
9220
  throw err;
@@ -8240,12 +9223,12 @@ var DropFolderAdapter = class {
8240
9223
  for (const name of entries) {
8241
9224
  if (name.startsWith(".")) continue;
8242
9225
  const fullPath = path5.join(drop, name);
8243
- const stat = await import_node_fs12.promises.lstat(fullPath).catch(() => null);
9226
+ const stat = await import_node_fs13.promises.lstat(fullPath).catch(() => null);
8244
9227
  if (!stat) continue;
8245
9228
  if (stat.isSymbolicLink()) continue;
8246
9229
  if (stat.isDirectory()) {
8247
9230
  for (const nested of await walk(fullPath)) {
8248
- const nstat = await import_node_fs12.promises.lstat(nested).catch(() => null);
9231
+ const nstat = await import_node_fs13.promises.lstat(nested).catch(() => null);
8249
9232
  if (!nstat || !nstat.isFile()) continue;
8250
9233
  out.push(makeObservation(workspaceId, nested, drop, nstat.size, Math.floor(nstat.mtimeMs)));
8251
9234
  }
@@ -8270,7 +9253,7 @@ var DropFolderAdapter = class {
8270
9253
  }
8271
9254
  async fetch(obs) {
8272
9255
  const detail = obs.detail;
8273
- const bytes = await import_node_fs12.promises.readFile(detail.path);
9256
+ const bytes = await import_node_fs13.promises.readFile(detail.path);
8274
9257
  return {
8275
9258
  bytes,
8276
9259
  mime: mimeFromExtension(detail.path),
@@ -8292,8 +9275,8 @@ var DropFolderAdapter = class {
8292
9275
  const rel = typeof detail.watchDir === "string" ? path5.relative(detail.watchDir, detail.path) : path5.basename(detail.path);
8293
9276
  const safeRel = rel && !rel.startsWith("..") && !path5.isAbsolute(rel) ? rel : path5.basename(detail.path);
8294
9277
  const destPath = path5.join(destDir, safeRel);
8295
- await import_node_fs12.promises.mkdir(path5.dirname(destPath), { recursive: true });
8296
- await import_node_fs12.promises.rename(detail.path, destPath);
9278
+ await import_node_fs13.promises.mkdir(path5.dirname(destPath), { recursive: true });
9279
+ await import_node_fs13.promises.rename(detail.path, destPath);
8297
9280
  } catch (err) {
8298
9281
  if (err.code === "ENOENT") return;
8299
9282
  throw err;
@@ -8321,11 +9304,11 @@ function makeObservation(workspaceId, filePath, watchDir, size, mtime) {
8321
9304
  }
8322
9305
  async function walk(root) {
8323
9306
  const out = [];
8324
- const entries = await import_node_fs12.promises.readdir(root).catch(() => []);
9307
+ const entries = await import_node_fs13.promises.readdir(root).catch(() => []);
8325
9308
  for (const name of entries) {
8326
9309
  if (name.startsWith(".")) continue;
8327
9310
  const full = path5.join(root, name);
8328
- const stat = await import_node_fs12.promises.lstat(full).catch(() => null);
9311
+ const stat = await import_node_fs13.promises.lstat(full).catch(() => null);
8329
9312
  if (!stat) continue;
8330
9313
  if (stat.isSymbolicLink()) continue;
8331
9314
  if (stat.isDirectory()) {
@@ -8366,7 +9349,7 @@ function mimeFromExtension(p) {
8366
9349
  const ext = path5.extname(p).toLowerCase();
8367
9350
  return MIME_BY_EXT[ext] ?? null;
8368
9351
  }
8369
- function sleep(ms) {
9352
+ function sleep2(ms) {
8370
9353
  return new Promise((r) => setTimeout(r, ms));
8371
9354
  }
8372
9355
 
@@ -8921,7 +9904,7 @@ function stringValue(obj, key) {
8921
9904
  }
8922
9905
 
8923
9906
  // src/daemon/outbox-watcher.ts
8924
- var import_node_fs13 = require("fs");
9907
+ var import_node_fs14 = require("fs");
8925
9908
  var path7 = __toESM(require("path"), 1);
8926
9909
 
8927
9910
  // src/daemon/asset/agent-output-policy.ts
@@ -9077,9 +10060,120 @@ function extensionOf(filename) {
9077
10060
  return idx >= 0 ? name.slice(idx) : "";
9078
10061
  }
9079
10062
 
10063
+ // src/daemon/asset/magic-bytes.ts
10064
+ var PDF_SIG = Buffer.from("%PDF-", "ascii");
10065
+ var PNG_SIG = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
10066
+ var JPEG_SIG = Buffer.from([255, 216, 255]);
10067
+ var GIF87 = Buffer.from("GIF87a", "ascii");
10068
+ var GIF89 = Buffer.from("GIF89a", "ascii");
10069
+ var ZIP_LOCAL = Buffer.from([80, 75, 3, 4]);
10070
+ var ZIP_EMPTY = Buffer.from([80, 75, 5, 6]);
10071
+ var ZIP_SPANNED = Buffer.from([80, 75, 7, 8]);
10072
+ function detectMagicBytes(bytes) {
10073
+ const head = bytes.subarray(0, Math.min(bytes.length, 4096));
10074
+ if (head.length === 0) return { detected: null, mime: null };
10075
+ if (head.length >= PDF_SIG.length && head.subarray(0, PDF_SIG.length).equals(PDF_SIG)) {
10076
+ return { detected: "pdf", mime: "application/pdf" };
10077
+ }
10078
+ if (head.length >= PNG_SIG.length && head.subarray(0, PNG_SIG.length).equals(PNG_SIG)) {
10079
+ return { detected: "png", mime: "image/png" };
10080
+ }
10081
+ if (head.length >= JPEG_SIG.length && head.subarray(0, JPEG_SIG.length).equals(JPEG_SIG)) {
10082
+ return { detected: "jpeg", mime: "image/jpeg" };
10083
+ }
10084
+ if (head.length >= GIF87.length && head.subarray(0, GIF87.length).equals(GIF87) || head.length >= GIF89.length && head.subarray(0, GIF89.length).equals(GIF89)) {
10085
+ return { detected: "gif", mime: "image/gif" };
10086
+ }
10087
+ if (head.length >= 4 && (head.subarray(0, 4).equals(ZIP_LOCAL) || head.subarray(0, 4).equals(ZIP_EMPTY) || head.subarray(0, 4).equals(ZIP_SPANNED))) {
10088
+ return { detected: "zip", mime: "application/zip" };
10089
+ }
10090
+ const text = head.toString("utf8");
10091
+ const trimmed = text.trimStart();
10092
+ if (/^<\?xml[^>]*>\s*<svg[\s>]/i.test(trimmed) || /^<svg[\s>]/i.test(trimmed)) {
10093
+ return { detected: "svg", mime: "image/svg+xml" };
10094
+ }
10095
+ if (/^<(!doctype html|html[\s>])/i.test(trimmed)) {
10096
+ return { detected: "html", mime: "text/html" };
10097
+ }
10098
+ if (/^[{[]/.test(trimmed)) {
10099
+ return { detected: "json", mime: "application/json" };
10100
+ }
10101
+ if (looksLikePrintableText(head)) {
10102
+ return { detected: "markdown-or-text", mime: "text/plain" };
10103
+ }
10104
+ return { detected: null, mime: null };
10105
+ }
10106
+ var ZIP_DERIVED_MIMES = /* @__PURE__ */ new Set([
10107
+ "application/zip",
10108
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
10109
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
10110
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation",
10111
+ "application/vnd.oasis.opendocument.text",
10112
+ "application/vnd.oasis.opendocument.spreadsheet",
10113
+ "application/vnd.oasis.opendocument.presentation",
10114
+ "application/epub+zip",
10115
+ "application/java-archive"
10116
+ ]);
10117
+ function mimeMismatchReason(detected, declared) {
10118
+ if (detected.detected === null) return null;
10119
+ const dec = (declared ?? "").toLowerCase().split(";")[0]?.trim() ?? "";
10120
+ if (!dec || dec === "application/octet-stream") return null;
10121
+ switch (detected.detected) {
10122
+ case "pdf":
10123
+ return dec === "application/pdf" ? null : `declared ${dec} but content is application/pdf`;
10124
+ case "png":
10125
+ return dec === "image/png" ? null : `declared ${dec} but content is image/png`;
10126
+ case "jpeg":
10127
+ return dec === "image/jpeg" || dec === "image/jpg" ? null : `declared ${dec} but content is image/jpeg`;
10128
+ case "gif":
10129
+ return dec === "image/gif" ? null : `declared ${dec} but content is image/gif`;
10130
+ case "zip":
10131
+ if (ZIP_DERIVED_MIMES.has(dec)) return null;
10132
+ return `declared ${dec} but content is a ZIP-container (application/zip family)`;
10133
+ case "svg":
10134
+ if (dec === "image/svg+xml" || dec.startsWith("text/")) return null;
10135
+ return `declared ${dec} but content is image/svg+xml`;
10136
+ case "html":
10137
+ if (dec === "text/html" || dec === "application/xhtml+xml" || dec.startsWith("text/")) return null;
10138
+ if (dec === "application/pdf" || dec.startsWith("image/") || dec === "application/zip") {
10139
+ return `declared ${dec} but content is text/html`;
10140
+ }
10141
+ return null;
10142
+ case "json":
10143
+ if (dec === "application/json" || dec.startsWith("text/")) return null;
10144
+ if (dec === "application/pdf" || dec.startsWith("image/") || dec === "application/zip") {
10145
+ return `declared ${dec} but content is application/json`;
10146
+ }
10147
+ return null;
10148
+ case "markdown-or-text":
10149
+ if (dec === "application/pdf" || dec.startsWith("image/") || dec === "application/zip") {
10150
+ return `declared ${dec} but content is text (not a real ${dec} payload)`;
10151
+ }
10152
+ return null;
10153
+ default:
10154
+ return null;
10155
+ }
10156
+ }
10157
+ function looksLikePrintableText(buf) {
10158
+ if (buf.length === 0) return false;
10159
+ let printable = 0;
10160
+ let total = 0;
10161
+ for (let i = 0; i < buf.length; i++) {
10162
+ const b = buf[i];
10163
+ if (b === 0) return false;
10164
+ total++;
10165
+ if (b >= 32 && b <= 126 || b === 9 || b === 10 || b === 13 || // utf-8 lead bytes
10166
+ b >= 128) {
10167
+ printable++;
10168
+ }
10169
+ }
10170
+ return total > 0 && printable / total >= 0.95;
10171
+ }
10172
+
9080
10173
  // src/daemon/outbox-watcher.ts
9081
10174
  var DEFAULT_INTERVAL_MS = 2e3;
9082
10175
  var RESERVED_SUBDIR = "_uploaded";
10176
+ var REJECTED_SUBDIR = "_rejected";
9083
10177
  var OutboxWatcher = class {
9084
10178
  constructor(opts) {
9085
10179
  this.opts = opts;
@@ -9244,7 +10338,7 @@ var OutboxWatcher = class {
9244
10338
  async scanDir(dir, kind, task) {
9245
10339
  let entries = [];
9246
10340
  try {
9247
- entries = await import_node_fs13.promises.readdir(dir);
10341
+ entries = await import_node_fs14.promises.readdir(dir);
9248
10342
  } catch (err) {
9249
10343
  const code = err.code;
9250
10344
  if (code === "ENOENT") return;
@@ -9254,10 +10348,11 @@ var OutboxWatcher = class {
9254
10348
  for (const name of entries) {
9255
10349
  if (name.startsWith(".")) continue;
9256
10350
  if (name === RESERVED_SUBDIR) continue;
10351
+ if (name === REJECTED_SUBDIR) continue;
9257
10352
  const full = path7.join(dir, name);
9258
10353
  let st;
9259
10354
  try {
9260
- st = await import_node_fs13.promises.stat(full);
10355
+ st = await import_node_fs14.promises.stat(full);
9261
10356
  } catch {
9262
10357
  continue;
9263
10358
  }
@@ -9268,10 +10363,77 @@ var OutboxWatcher = class {
9268
10363
  await this.upload(full, st, kind, task);
9269
10364
  this.uploaded.add(key);
9270
10365
  } catch (err) {
9271
- this.log("warn", `upload ${full} failed: ${err.message}`);
10366
+ const msg = err.message;
10367
+ this.log("warn", `upload ${full} failed: ${msg}`);
10368
+ if (/\bMIME_MISMATCH\b/i.test(msg) || /\bmime mismatch\b/i.test(msg)) {
10369
+ await this.quarantine(dir, full).catch(
10370
+ (moveErr) => this.log("warn", `quarantine ${full} failed: ${moveErr.message}`)
10371
+ );
10372
+ this.uploaded.add(key);
10373
+ await this.reportMimeMismatchToCloud(task, full, msg).catch(
10374
+ (reportErr) => this.log("warn", `report MIME_MISMATCH failed: ${reportErr.message}`)
10375
+ );
10376
+ }
9272
10377
  }
9273
10378
  }
9274
10379
  }
10380
+ /**
10381
+ * F2B — report an OUTBOX_MIME_MISMATCH task event to cloud so the task log
10382
+ * gains an auditable record of the rejection. Without this, the failure is
10383
+ * silent (file quarantined locally, agent unaware) and the next dispatch
10384
+ * loops on the same bad strategy.
10385
+ *
10386
+ * Best-effort: every catch site swallows errors. We never want this
10387
+ * upstream call to block local quarantine or trigger a retry loop, because
10388
+ * the reject decision is already final by the time we get here.
10389
+ *
10390
+ * Endpoint contract: POST /api/im/tasks/:id/event
10391
+ * { code: 'OUTBOX_MIME_MISMATCH', payload: { file, daemonError }, message }
10392
+ * Only callable when `task?.taskId` is known — pre-dispatch outbox files
10393
+ * (no active task) are quarantined silently as before.
10394
+ */
10395
+ async reportMimeMismatchToCloud(task, filePath, daemonErrorMessage) {
10396
+ if (!task?.taskId) return;
10397
+ const fileName = path7.basename(filePath);
10398
+ const reasonMatch = daemonErrorMessage.match(/MIME_MISMATCH[^:]*:\s*(.+)$/i);
10399
+ const reason = reasonMatch?.[1] ? reasonMatch[1].trim() : daemonErrorMessage;
10400
+ const body = {
10401
+ code: "OUTBOX_MIME_MISMATCH",
10402
+ payload: {
10403
+ file: fileName,
10404
+ reason,
10405
+ daemonError: daemonErrorMessage
10406
+ },
10407
+ message: `Your output file ${fileName} was rejected because its bytes do not match the claimed format. Use a real library (e.g. reportlab for PDF, openpyxl for XLSX) instead of renaming an extension.`
10408
+ };
10409
+ const res = await this.opts.cloud.request("POST", `/api/im/tasks/${encodeURIComponent(task.taskId)}/event`, {
10410
+ body
10411
+ });
10412
+ if (!res.ok) {
10413
+ throw new Error(`cloud rejected event: ${res.status} ${res.error?.code ?? ""} ${res.error?.message ?? ""}`);
10414
+ }
10415
+ }
10416
+ /**
10417
+ * Move a file under `<dir>/_rejected/` so it stops being a scan candidate
10418
+ * (the loop skips the reserved subdir) and operators can find the
10419
+ * offending artifact. Filename collision is rare per task; on collision
10420
+ * we append a millisecond suffix to keep the original observable.
10421
+ */
10422
+ async quarantine(dir, full) {
10423
+ const rejectedDir = path7.join(dir, REJECTED_SUBDIR);
10424
+ await import_node_fs14.promises.mkdir(rejectedDir, { recursive: true });
10425
+ const base = path7.basename(full);
10426
+ let dest = path7.join(rejectedDir, base);
10427
+ try {
10428
+ await import_node_fs14.promises.access(dest);
10429
+ const ext = path7.extname(base);
10430
+ const stem = base.slice(0, base.length - ext.length);
10431
+ dest = path7.join(rejectedDir, `${stem}.${Date.now()}${ext}`);
10432
+ } catch {
10433
+ }
10434
+ await import_node_fs14.promises.rename(full, dest);
10435
+ this.log("warn", `quarantined ${full} \u2192 ${dest}`);
10436
+ }
9275
10437
  async upload(filePath, st, kind, task) {
9276
10438
  const wsId = this.opts.workspaceId();
9277
10439
  if (!wsId) {
@@ -9282,13 +10444,18 @@ var OutboxWatcher = class {
9282
10444
  this.log("warn", `skip ${filePath}: no active taskId (no dispatch/handoff received yet)`);
9283
10445
  return;
9284
10446
  }
9285
- const bytes = await import_node_fs13.promises.readFile(filePath);
10447
+ const bytes = await import_node_fs14.promises.readFile(filePath);
9286
10448
  const fileName = path7.basename(filePath);
9287
10449
  const inferredMime = inferAgentOutputMime(fileName);
9288
10450
  const policy = validateAgentOutputAsset({ filename: fileName, mime: inferredMime, sizeBytes: bytes.length });
9289
10451
  if (!policy.ok) {
9290
10452
  throw new Error(`agent output rejected: ${policy.reason}`);
9291
10453
  }
10454
+ const detection = detectMagicBytes(bytes);
10455
+ const reason = mimeMismatchReason(detection, policy.mime);
10456
+ if (reason) {
10457
+ throw new Error(`MIME_MISMATCH for ${fileName}: ${reason}`);
10458
+ }
9292
10459
  const metadata = {
9293
10460
  taskId: task.taskId,
9294
10461
  ...task.adapter ? { adapter: task.adapter } : {}
@@ -9324,7 +10491,7 @@ var OutboxWatcher = class {
9324
10491
 
9325
10492
  // src/daemon/shell-executor.ts
9326
10493
  var import_node_child_process4 = require("child_process");
9327
- var import_node_fs14 = require("fs");
10494
+ var import_node_fs15 = require("fs");
9328
10495
  var import_node_path10 = require("path");
9329
10496
  var DEFAULT_OUTPUT_LIMIT = 256 * 1024;
9330
10497
  var DEFAULT_TIMEOUT = 6e4;
@@ -9361,7 +10528,7 @@ async function executeShellDispatch(payload, deps) {
9361
10528
  const command = readCommand(payload, execution);
9362
10529
  if (!command.trim()) return fail(payload.taskId, "shell_command_required", "Shell command is required");
9363
10530
  const cwd = resolveCwd(execution.cwd, deps.config.defaultCwd);
9364
- if (!(0, import_node_fs14.existsSync)(cwd)) return fail(payload.taskId, "shell_cwd_missing", `cwd does not exist: ${cwd}`);
10531
+ if (!(0, import_node_fs15.existsSync)(cwd)) return fail(payload.taskId, "shell_cwd_missing", `cwd does not exist: ${cwd}`);
9365
10532
  const timeoutMs = Math.min(
9366
10533
  typeof payload.timeoutMs === "number" && payload.timeoutMs > 0 ? payload.timeoutMs : deps.config.maxTimeoutMs,
9367
10534
  deps.config.maxTimeoutMs
@@ -9492,6 +10659,352 @@ ${input.stderr}` : null
9492
10659
 
9493
10660
  // src/daemon/runner.ts
9494
10661
  init_skill_sync();
10662
+
10663
+ // src/daemon/pending-reply-cache.ts
10664
+ function fromRaw(r) {
10665
+ return {
10666
+ idempotencyKey: r.idempotency_key,
10667
+ taskId: r.task_id,
10668
+ replyId: r.reply_id,
10669
+ status: r.status,
10670
+ payloadJson: r.payload_json,
10671
+ createdAt: r.created_at,
10672
+ preparedAt: r.prepared_at,
10673
+ lastAttemptAt: r.last_attempt_at,
10674
+ attemptCount: r.attempt_count,
10675
+ lastError: r.last_error
10676
+ };
10677
+ }
10678
+ function createPendingReplyCache(db) {
10679
+ const insert = db.prepare(
10680
+ `INSERT OR REPLACE INTO pending_dispatch_replies
10681
+ (idempotency_key, task_id, reply_id, status, payload_json,
10682
+ prepared_at, created_at, last_attempt_at, attempt_count, last_error)
10683
+ VALUES (?, ?, NULL, 'pending', ?, NULL, ?, NULL, 0, NULL)`
10684
+ );
10685
+ const markPreparedStmt = db.prepare(
10686
+ `UPDATE pending_dispatch_replies
10687
+ SET reply_id = ?, status = 'prepared', prepared_at = ?,
10688
+ last_attempt_at = ?, attempt_count = attempt_count + 1,
10689
+ last_error = NULL
10690
+ WHERE idempotency_key = ?`
10691
+ );
10692
+ const markAttemptStmt = db.prepare(
10693
+ `UPDATE pending_dispatch_replies
10694
+ SET last_attempt_at = ?, attempt_count = attempt_count + 1,
10695
+ last_error = ?
10696
+ WHERE idempotency_key = ?`
10697
+ );
10698
+ const clearStmt = db.prepare(
10699
+ `DELETE FROM pending_dispatch_replies WHERE idempotency_key = ?`
10700
+ );
10701
+ const listRecoveryStmt = db.prepare(
10702
+ `SELECT * FROM pending_dispatch_replies
10703
+ WHERE status IN ('pending', 'prepared')
10704
+ ORDER BY created_at ASC`
10705
+ );
10706
+ const countStmt = db.prepare(
10707
+ `SELECT status, COUNT(*) as cnt FROM pending_dispatch_replies
10708
+ WHERE status IN ('pending','prepared') GROUP BY status`
10709
+ );
10710
+ const findStmt = db.prepare(
10711
+ `SELECT * FROM pending_dispatch_replies WHERE idempotency_key = ?`
10712
+ );
10713
+ return {
10714
+ savePending(idempotencyKey, taskId, payloadJson, now = Date.now()) {
10715
+ insert.run(idempotencyKey, taskId, payloadJson, now);
10716
+ },
10717
+ markPrepared(idempotencyKey, replyId, now = Date.now()) {
10718
+ markPreparedStmt.run(replyId, now, now, idempotencyKey);
10719
+ },
10720
+ markAttempt(idempotencyKey, now = Date.now(), error) {
10721
+ markAttemptStmt.run(now, error ?? null, idempotencyKey);
10722
+ },
10723
+ clearPending(idempotencyKey) {
10724
+ clearStmt.run(idempotencyKey);
10725
+ },
10726
+ listForRecovery() {
10727
+ const rows = listRecoveryStmt.all();
10728
+ return rows.map(fromRaw);
10729
+ },
10730
+ countByStatus() {
10731
+ const rows = countStmt.all();
10732
+ const result = { pending: 0, prepared: 0 };
10733
+ for (const r of rows) {
10734
+ if (r.status === "pending") result.pending = r.cnt;
10735
+ else if (r.status === "prepared") result.prepared = r.cnt;
10736
+ }
10737
+ return result;
10738
+ },
10739
+ findByIdempotencyKey(idempotencyKey) {
10740
+ const row = findStmt.get(idempotencyKey);
10741
+ return row ? fromRaw(row) : null;
10742
+ }
10743
+ };
10744
+ }
10745
+ function generateIdempotencyKey() {
10746
+ const c = globalThis.crypto;
10747
+ if (c?.randomUUID) return c.randomUUID();
10748
+ const bytes = new Uint8Array(16);
10749
+ const cryptoNode = globalThis.crypto;
10750
+ if (cryptoNode?.getRandomValues) {
10751
+ cryptoNode.getRandomValues(bytes);
10752
+ } else {
10753
+ for (let i = 0; i < 16; i += 1) bytes[i] = Math.floor(Math.random() * 256);
10754
+ }
10755
+ bytes[6] = bytes[6] & 15 | 64;
10756
+ bytes[8] = bytes[8] & 63 | 128;
10757
+ const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
10758
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
10759
+ }
10760
+
10761
+ // src/daemon/dispatch-reply-transport.ts
10762
+ var DispatchReplyAbortedError = class extends Error {
10763
+ constructor(replyId) {
10764
+ super(`dispatch reply ${replyId} aborted by server reaper (likely >1h since prepare)`);
10765
+ this.replyId = replyId;
10766
+ this.name = "DispatchReplyAbortedError";
10767
+ }
10768
+ replyId;
10769
+ code = "DISPATCH_REPLY_INVALID_STATE";
10770
+ };
10771
+ function defaultLog2(line) {
10772
+ process.stderr.write(`${line}
10773
+ `);
10774
+ }
10775
+ async function sendDispatchReplyTwoPhase(args) {
10776
+ const { taskId, payload, cloud, cache } = args;
10777
+ const log8 = args.log ?? defaultLog2;
10778
+ const idempotencyKey = args.idempotencyKey ?? generateIdempotencyKey();
10779
+ const payloadJson = JSON.stringify({ ...payload, _taskId: taskId, _idempotencyKey: idempotencyKey });
10780
+ cache.savePending(idempotencyKey, taskId, payloadJson);
10781
+ const prepareRes = await cloud.request("POST", `/api/im/dispatch/${encodeURIComponent(taskId)}/prepare`, {
10782
+ body: { ...payload, idempotencyKey },
10783
+ timeoutMs: 3e4
10784
+ });
10785
+ if (!prepareRes.ok || !prepareRes.data) {
10786
+ const errCode = prepareRes.error?.code ?? "unknown";
10787
+ const errMsg = prepareRes.error?.message ?? `prepare failed (status=${prepareRes.status})`;
10788
+ cache.markAttempt(idempotencyKey, Date.now(), `prepare: ${errCode}: ${errMsg}`);
10789
+ throw new Error(`dispatch prepare failed: ${errCode}: ${errMsg}`);
10790
+ }
10791
+ const envelope2 = prepareRes.data;
10792
+ const inner = "data" in envelope2 && envelope2.data ? envelope2.data : envelope2;
10793
+ if (!inner || typeof inner.replyId !== "string") {
10794
+ cache.markAttempt(idempotencyKey, Date.now(), "prepare: missing replyId in response");
10795
+ throw new Error("dispatch prepare returned no replyId");
10796
+ }
10797
+ if ("ok" in envelope2 && envelope2.ok === false) {
10798
+ const errCode = envelope2.error?.code ?? "prepare_failed";
10799
+ const errMsg = envelope2.error?.message ?? "prepare ok=false";
10800
+ cache.markAttempt(idempotencyKey, Date.now(), `prepare: ${errCode}: ${errMsg}`);
10801
+ throw new Error(`dispatch prepare failed: ${errCode}: ${errMsg}`);
10802
+ }
10803
+ cache.markPrepared(idempotencyKey, inner.replyId);
10804
+ log8(
10805
+ `[daemon] dispatch.prepare task=${taskId} replyId=${inner.replyId} status=${inner.status} idem=${idempotencyKey}`
10806
+ );
10807
+ if (inner.status === "committed") {
10808
+ cache.clearPending(idempotencyKey);
10809
+ return {
10810
+ taskId,
10811
+ replyId: inner.replyId,
10812
+ status: "committed",
10813
+ alreadyCommitted: true
10814
+ };
10815
+ }
10816
+ try {
10817
+ const result = await commitDispatchReply({ taskId, replyId: inner.replyId, cloud, log: log8 });
10818
+ cache.clearPending(idempotencyKey);
10819
+ return { taskId, replyId: inner.replyId, ...result };
10820
+ } catch (err) {
10821
+ if (err instanceof DispatchReplyAbortedError) {
10822
+ cache.clearPending(idempotencyKey);
10823
+ log8(`[daemon] dispatch.commit aborted task=${taskId} replyId=${inner.replyId} \u2014 dispatch lost`);
10824
+ return {
10825
+ taskId,
10826
+ replyId: inner.replyId,
10827
+ status: "aborted",
10828
+ alreadyCommitted: false
10829
+ };
10830
+ }
10831
+ cache.markAttempt(idempotencyKey, Date.now(), `commit: ${err.message}`);
10832
+ throw err;
10833
+ }
10834
+ }
10835
+ async function commitDispatchReply(args) {
10836
+ const log8 = args.log ?? defaultLog2;
10837
+ const res = await args.cloud.request("POST", `/api/im/dispatch/${encodeURIComponent(args.taskId)}/commit`, {
10838
+ body: { replyId: args.replyId },
10839
+ timeoutMs: 3e4
10840
+ });
10841
+ if (!res.ok || !res.data) {
10842
+ const code = res.error?.code ?? "unknown";
10843
+ const msg = res.error?.message ?? `commit failed (status=${res.status})`;
10844
+ if (code === "DISPATCH_REPLY_INVALID_STATE") {
10845
+ throw new DispatchReplyAbortedError(args.replyId);
10846
+ }
10847
+ throw new Error(`dispatch commit failed: ${code}: ${msg}`);
10848
+ }
10849
+ const env = res.data;
10850
+ const inner = "data" in env && env.data ? env.data : env;
10851
+ if ("ok" in env && env.ok === false) {
10852
+ const code = env.error?.code ?? "commit_failed";
10853
+ if (code === "DISPATCH_REPLY_INVALID_STATE") {
10854
+ throw new DispatchReplyAbortedError(args.replyId);
10855
+ }
10856
+ throw new Error(`dispatch commit failed: ${code}: ${env.error?.message ?? "commit ok=false"}`);
10857
+ }
10858
+ log8(
10859
+ `[daemon] dispatch.commit task=${args.taskId} replyId=${args.replyId} alreadyCommitted=${inner.alreadyCommitted ?? false}`
10860
+ );
10861
+ return {
10862
+ status: "committed",
10863
+ alreadyCommitted: Boolean(inner.alreadyCommitted),
10864
+ ...inner.messageId ? { messageId: inner.messageId } : {}
10865
+ };
10866
+ }
10867
+ async function recoverPendingReplies(deps) {
10868
+ const log8 = deps.log ?? defaultLog2;
10869
+ const rows = deps.cache.listForRecovery();
10870
+ if (rows.length === 0) {
10871
+ return { attempted: 0, committed: 0, aborted: 0, failed: 0 };
10872
+ }
10873
+ log8(`[daemon] dispatch.recovery: ${rows.length} pending row(s) to replay`);
10874
+ let committed = 0;
10875
+ let aborted = 0;
10876
+ let failed = 0;
10877
+ for (const row of rows) {
10878
+ try {
10879
+ const result = await replayRow(row, deps);
10880
+ if (result.status === "committed") committed += 1;
10881
+ else if (result.status === "aborted") aborted += 1;
10882
+ } catch (err) {
10883
+ failed += 1;
10884
+ log8(
10885
+ `[daemon] dispatch.recovery FAIL idem=${row.idempotencyKey} task=${row.taskId}: ${err.message}`
10886
+ );
10887
+ }
10888
+ }
10889
+ log8(
10890
+ `[daemon] dispatch.recovery done attempted=${rows.length} committed=${committed} aborted=${aborted} failed=${failed}`
10891
+ );
10892
+ return { attempted: rows.length, committed, aborted, failed };
10893
+ }
10894
+ async function replayRow(row, deps) {
10895
+ const log8 = deps.log ?? defaultLog2;
10896
+ if (row.status === "prepared" && row.replyId) {
10897
+ try {
10898
+ const result2 = await commitDispatchReply({
10899
+ taskId: row.taskId,
10900
+ replyId: row.replyId,
10901
+ cloud: deps.cloud,
10902
+ log: log8
10903
+ });
10904
+ deps.cache.clearPending(row.idempotencyKey);
10905
+ return { status: result2.status };
10906
+ } catch (err) {
10907
+ if (err instanceof DispatchReplyAbortedError) {
10908
+ deps.cache.clearPending(row.idempotencyKey);
10909
+ return { status: "aborted" };
10910
+ }
10911
+ throw err;
10912
+ }
10913
+ }
10914
+ let parsed;
10915
+ try {
10916
+ parsed = JSON.parse(row.payloadJson);
10917
+ } catch (err) {
10918
+ deps.cache.clearPending(row.idempotencyKey);
10919
+ throw new Error(`pending row ${row.idempotencyKey} has unparseable payload: ${err.message}`);
10920
+ }
10921
+ const { _taskId, _idempotencyKey, ...payload } = parsed;
10922
+ const result = await sendDispatchReplyTwoPhase({
10923
+ taskId: row.taskId,
10924
+ payload,
10925
+ cloud: deps.cloud,
10926
+ cache: deps.cache,
10927
+ idempotencyKey: row.idempotencyKey,
10928
+ log: deps.log
10929
+ });
10930
+ return { status: result.status };
10931
+ }
10932
+
10933
+ // src/daemon/transport-probe.ts
10934
+ var import_node_os7 = require("os");
10935
+ function isPrivateAddress(addr) {
10936
+ if (!addr) return true;
10937
+ const h = String(addr).trim().toLowerCase();
10938
+ if (!h) return true;
10939
+ if (h === "localhost" || h === "::1") return true;
10940
+ const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(h);
10941
+ if (m) {
10942
+ const a = Number(m[1]);
10943
+ const b = Number(m[2]);
10944
+ if (a === 10) return true;
10945
+ if (a === 127) return true;
10946
+ if (a === 169 && b === 254) return true;
10947
+ if (a === 172 && b >= 16 && b <= 31) return true;
10948
+ if (a === 192 && b === 168) return true;
10949
+ if (a === 100 && b >= 64 && b <= 127) return true;
10950
+ return false;
10951
+ }
10952
+ if (h.endsWith(".local")) return true;
10953
+ if (h.endsWith(".localhost")) return true;
10954
+ return false;
10955
+ }
10956
+ function buildTransportProbe(gatewayUrl) {
10957
+ if (!gatewayUrl) {
10958
+ return { transport: "ws", gatewayUrl: null, gatewayIsPrivate: true };
10959
+ }
10960
+ let host = null;
10961
+ try {
10962
+ host = new URL(gatewayUrl).hostname;
10963
+ } catch {
10964
+ host = null;
10965
+ }
10966
+ const gatewayIsPrivate = isPrivateAddress(host);
10967
+ return {
10968
+ transport: gatewayIsPrivate ? "ws" : "both",
10969
+ gatewayUrl,
10970
+ gatewayIsPrivate
10971
+ };
10972
+ }
10973
+ function pickLocalIPv4() {
10974
+ const nics = (0, import_node_os7.networkInterfaces)();
10975
+ let firstPrivate = null;
10976
+ for (const list of Object.values(nics)) {
10977
+ if (!list) continue;
10978
+ for (const ni of list) {
10979
+ if (ni.internal || ni.family !== "IPv4") continue;
10980
+ if (!isPrivateAddress(ni.address)) return ni.address;
10981
+ if (!firstPrivate) firstPrivate = ni.address;
10982
+ }
10983
+ }
10984
+ return firstPrivate;
10985
+ }
10986
+ async function reportTransportProbe(cloud, daemonId, probe) {
10987
+ const body = {
10988
+ daemonId,
10989
+ transport: probe.transport,
10990
+ gatewayUrl: probe.gatewayUrl,
10991
+ gatewayIsPrivate: probe.gatewayIsPrivate
10992
+ };
10993
+ const res = await cloud.request("POST", "/api/im/runtime/transport-report", {
10994
+ body,
10995
+ timeoutMs: 5e3
10996
+ });
10997
+ if (!res.ok) {
10998
+ return {
10999
+ ok: false,
11000
+ status: res.status,
11001
+ error: res.error?.message ?? `transport-report failed (${res.status})`
11002
+ };
11003
+ }
11004
+ return { ok: true, status: res.status };
11005
+ }
11006
+
11007
+ // src/daemon/runner.ts
9495
11008
  var import_meta2 = {};
9496
11009
  var DEFAULT_LOCAL_PORT = 3210;
9497
11010
  var log7 = createLogger("Daemon");
@@ -9540,6 +11053,14 @@ var Runner = class extends import_node_events3.EventEmitter {
9540
11053
  // F16 (2026-05-20) — periodic skill resync timer (default 10min).
9541
11054
  skillResyncTimer;
9542
11055
  skillSyncInFlight = false;
11056
+ // Wave-4 E7 — local cache of in-flight two-phase dispatch replies. Survives
11057
+ // daemon crash so cold-start can resume `prepared` rows via idempotent
11058
+ // commit (server enforces (taskId, idempotencyKey) UNIQUE).
11059
+ pendingReplyCache;
11060
+ // One-shot guard so we only recover on the first post-authenticated tick,
11061
+ // not every reconnect (server-side commits are idempotent but the log noise
11062
+ // and DB pressure of re-running on every redeclare is wasteful).
11063
+ pendingReplyRecoveryDone = false;
9543
11064
  async start() {
9544
11065
  if (this.state !== "idle") throw new Error(`Runner already in state ${this.state}`);
9545
11066
  this.state = "starting";
@@ -9554,6 +11075,7 @@ var Runner = class extends import_node_events3.EventEmitter {
9554
11075
  process.env.PRISMER_API_KEY = this.config.api_key;
9555
11076
  this.db = openLocalDb(this.paths.localDb);
9556
11077
  this.cloud = new CloudClient({ baseUrl: this.config.cloud_api_base, apiKey: this.config.api_key });
11078
+ this.pendingReplyCache = createPendingReplyCache(this.db);
9557
11079
  this.assetCache = new AssetCache({
9558
11080
  db: this.db,
9559
11081
  cloud: this.cloud,
@@ -9880,19 +11402,19 @@ var Runner = class extends import_node_events3.EventEmitter {
9880
11402
  sampleCgroupResources() {
9881
11403
  const baseline = { cpu: { usagePct: 0 }, mem: { usedBytes: 0, limitBytes: 0 } };
9882
11404
  try {
9883
- if (!(0, import_node_fs15.existsSync)("/sys/fs/cgroup/memory.current")) {
11405
+ if (!(0, import_node_fs16.existsSync)("/sys/fs/cgroup/memory.current")) {
9884
11406
  return baseline;
9885
11407
  }
9886
- const memUsedRaw = (0, import_node_fs15.readFileSync)("/sys/fs/cgroup/memory.current", "utf-8").trim();
11408
+ const memUsedRaw = (0, import_node_fs16.readFileSync)("/sys/fs/cgroup/memory.current", "utf-8").trim();
9887
11409
  const memUsed = Number.parseInt(memUsedRaw, 10);
9888
11410
  let memLimit = 0;
9889
- if ((0, import_node_fs15.existsSync)("/sys/fs/cgroup/memory.max")) {
9890
- const limitRaw = (0, import_node_fs15.readFileSync)("/sys/fs/cgroup/memory.max", "utf-8").trim();
11411
+ if ((0, import_node_fs16.existsSync)("/sys/fs/cgroup/memory.max")) {
11412
+ const limitRaw = (0, import_node_fs16.readFileSync)("/sys/fs/cgroup/memory.max", "utf-8").trim();
9891
11413
  memLimit = limitRaw === "max" ? 0 : Number.parseInt(limitRaw, 10) || 0;
9892
11414
  }
9893
11415
  let cpuUsagePct = 0;
9894
- if ((0, import_node_fs15.existsSync)("/sys/fs/cgroup/cpu.stat")) {
9895
- const statLines = (0, import_node_fs15.readFileSync)("/sys/fs/cgroup/cpu.stat", "utf-8").split("\n");
11416
+ if ((0, import_node_fs16.existsSync)("/sys/fs/cgroup/cpu.stat")) {
11417
+ const statLines = (0, import_node_fs16.readFileSync)("/sys/fs/cgroup/cpu.stat", "utf-8").split("\n");
9896
11418
  const usecLine = statLines.find((line) => line.startsWith("usage_usec "));
9897
11419
  if (usecLine) {
9898
11420
  const usec = Number.parseInt(usecLine.split(" ")[1] ?? "0", 10);
@@ -10070,10 +11592,10 @@ var Runner = class extends import_node_events3.EventEmitter {
10070
11592
  const rawJson = process.env.PRISMER_HOSTED_AGENT_JSON;
10071
11593
  let raw;
10072
11594
  if (rawFile) {
10073
- if (!(0, import_node_fs15.existsSync)(rawFile)) {
11595
+ if (!(0, import_node_fs16.existsSync)(rawFile)) {
10074
11596
  throw new Error(`PRISMER_HOSTED_AGENT_FILE not found: ${rawFile}`);
10075
11597
  }
10076
- raw = (0, import_node_fs15.readFileSync)(rawFile, "utf8");
11598
+ raw = (0, import_node_fs16.readFileSync)(rawFile, "utf8");
10077
11599
  } else if (rawJson) {
10078
11600
  raw = rawJson;
10079
11601
  }
@@ -10160,6 +11682,19 @@ var Runner = class extends import_node_events3.EventEmitter {
10160
11682
  process.stdout.write(`[daemon] ws authenticated, sending agent.host.declare (${this.hostedAgents.size} agents)
10161
11683
  `);
10162
11684
  this.sendDeclare();
11685
+ if (!this.pendingReplyRecoveryDone) {
11686
+ this.pendingReplyRecoveryDone = true;
11687
+ void recoverPendingReplies({ cloud: this.cloud, cache: this.pendingReplyCache }).then((summary) => {
11688
+ if (summary.attempted === 0) return;
11689
+ process.stdout.write(
11690
+ `[daemon] dispatch.recovery summary attempted=${summary.attempted} committed=${summary.committed} aborted=${summary.aborted} failed=${summary.failed}
11691
+ `
11692
+ );
11693
+ }).catch((err) => {
11694
+ process.stderr.write(`[daemon] dispatch.recovery threw: ${err.message}
11695
+ `);
11696
+ });
11697
+ }
10163
11698
  return;
10164
11699
  }
10165
11700
  this.handleIncoming(msg);
@@ -10169,7 +11704,7 @@ var Runner = class extends import_node_events3.EventEmitter {
10169
11704
  const payload = {
10170
11705
  daemonId: this.config.daemon_id,
10171
11706
  daemonVersion: this.opts.daemonVersion ?? "0.0.0",
10172
- platform: (0, import_node_os6.platform)() === "win32" ? "win32" : (0, import_node_os6.platform)() === "linux" ? "linux" : "darwin",
11707
+ platform: (0, import_node_os8.platform)() === "win32" ? "win32" : (0, import_node_os8.platform)() === "linux" ? "linux" : "darwin",
10173
11708
  agents: Array.from(this.hostedAgents.values()).map((a) => ({
10174
11709
  imUserId: a.imUserId,
10175
11710
  name: a.name,
@@ -10206,12 +11741,90 @@ var Runner = class extends import_node_events3.EventEmitter {
10206
11741
  case "asset.changed":
10207
11742
  void this.onAssetChanged(msg.payload);
10208
11743
  return;
11744
+ // v2.0 §4.8.1 (Wave 4-E4) — webhook reverse-channel RPC. Cloud's
11745
+ // dispatchExternalMention pushes an AgentDispatchRequest plus an
11746
+ // embedded `_rpcId`; the daemon runs the same handler as the
11747
+ // HTTP /dispatch path and echoes the reply back over WS with the
11748
+ // identical `_rpcId` so cloud's WsRpcService can settle the
11749
+ // pending promise.
11750
+ case "webhook.dispatch.request":
11751
+ void this.onWebhookDispatch(msg.payload);
11752
+ return;
10209
11753
  default:
10210
11754
  this.emit("unknown-message", msg);
10211
11755
  }
10212
11756
  }
11757
+ /**
11758
+ * v2.0 §4.8.1 — webhook dispatch over WS reverse channel.
11759
+ *
11760
+ * Acks via `webhook.dispatch.reply { _rpcId, ok, acceptedAt }` immediately
11761
+ * after `handleAgentMessageDispatch()` returns its synchronous response
11762
+ * — the actual reply (the agent's reply text/attachments) still flows
11763
+ * through `postMessageDispatchReply()` which posts to
11764
+ * `/api/im/dispatch/reply` after the adapter finishes. That mirrors the
11765
+ * HTTP /dispatch contract: ack first, asynchronous final reply via the
11766
+ * dispatch-reply REST endpoint.
11767
+ *
11768
+ * The `_rpcId` field is the only addition versus the HTTP path; we strip
11769
+ * it before passing the payload to the dispatch handler so existing
11770
+ * validation logic doesn't trip on an unknown field.
11771
+ */
11772
+ async onWebhookDispatch(payload) {
11773
+ const rpcId = payload._rpcId;
11774
+ if (!rpcId) {
11775
+ process.stderr.write(`[daemon] webhook.dispatch.request without _rpcId \u2014 dropped
11776
+ `);
11777
+ return;
11778
+ }
11779
+ const { _rpcId: _, ...request } = payload;
11780
+ try {
11781
+ const handle = handleAgentMessageDispatch(request, {
11782
+ findAgent: (agentImUserId) => this.findMessageDispatchAgent(agentImUserId),
11783
+ postReply: (replyPayload) => this.postMessageDispatchReply(replyPayload),
11784
+ onError: (err, req) => {
11785
+ process.stderr.write(
11786
+ `[daemon] webhook.dispatch.request failed message=${req.messageId} agent=${req.mentionedAgentImUserId}: ${err instanceof Error ? err.stack ?? err.message : String(err)}
11787
+ `
11788
+ );
11789
+ }
11790
+ });
11791
+ this.ws.send({
11792
+ type: "webhook.dispatch.reply",
11793
+ payload: {
11794
+ _rpcId: rpcId,
11795
+ ok: handle.response.ok,
11796
+ acceptedAt: handle.response.acceptedAt,
11797
+ error: handle.response.error
11798
+ }
11799
+ });
11800
+ void handle.done.catch((err) => {
11801
+ process.stderr.write(
11802
+ `[daemon] webhook.dispatch final reply failed message=${request.messageId}: ${err instanceof Error ? err.message : String(err)}
11803
+ `
11804
+ );
11805
+ });
11806
+ } catch (err) {
11807
+ this.ws.send({
11808
+ type: "webhook.dispatch.reply",
11809
+ payload: {
11810
+ _rpcId: rpcId,
11811
+ ok: false,
11812
+ error: {
11813
+ code: "daemon_dispatch_failed",
11814
+ message: err instanceof Error ? err.message : String(err)
11815
+ }
11816
+ }
11817
+ });
11818
+ }
11819
+ }
10213
11820
  async onHostAcked(payload) {
10214
11821
  this.workspaceId = payload.workspaceId;
11822
+ void this.reportTransport().catch((err) => {
11823
+ process.stderr.write(
11824
+ `[daemon] transport-probe report failed: ${err.message}
11825
+ `
11826
+ );
11827
+ });
10215
11828
  if (this.memoryWiring && this.workspaceId) {
10216
11829
  syncMemoryFromCloud(this.memoryWiring, this.cloud, [this.workspaceId]).catch(
10217
11830
  (err) => log7.error("Initial memory sync failed", err.message)
@@ -10393,14 +12006,68 @@ var Runner = class extends import_node_events3.EventEmitter {
10393
12006
  };
10394
12007
  }
10395
12008
  async postMessageDispatchReply(payload) {
10396
- const res = await this.cloud.request("POST", "/api/im/dispatch/reply", {
10397
- auth: false,
10398
- body: payload,
10399
- timeoutMs: 3e4
10400
- });
10401
- if (!res.ok) {
10402
- throw new Error(res.error?.message ?? `dispatch reply failed (${res.status})`);
12009
+ const taskId = `external:${payload.replyToMessageId}`;
12010
+ try {
12011
+ const result = await sendDispatchReplyTwoPhase({
12012
+ taskId,
12013
+ payload: {
12014
+ conversationId: payload.conversationId,
12015
+ replyToken: payload.replyToken,
12016
+ replyToMessageId: payload.replyToMessageId,
12017
+ agentImUserId: payload.agentImUserId,
12018
+ status: payload.status,
12019
+ ...payload.replyText !== void 0 ? { replyText: payload.replyText } : {},
12020
+ ...payload.attachments ? { attachments: payload.attachments } : {},
12021
+ assetIds: payload.attachments?.map((a) => a.assetId).filter((id) => typeof id === "string") ?? [],
12022
+ completedAt: payload.completedAt,
12023
+ ...payload.error ? { error: payload.error } : {}
12024
+ },
12025
+ cloud: this.cloud,
12026
+ cache: this.pendingReplyCache
12027
+ });
12028
+ if (result.status === "aborted") {
12029
+ throw new Error(`dispatch reply aborted by server reaper (replyId=${result.replyId})`);
12030
+ }
12031
+ } catch (err) {
12032
+ throw new Error(
12033
+ err.message?.length ? err.message : `dispatch reply failed`
12034
+ );
12035
+ }
12036
+ }
12037
+ /**
12038
+ * v2.0 §4.8.1 (Wave 4-E4) — submit the transport-probe report. Called
12039
+ * from `onHostAcked` so the daemon's container row exists in cloud
12040
+ * before we POST to /runtime/transport-report.
12041
+ *
12042
+ * The gatewayUrl reported here is `http://<local-ipv4>:<localPort>`
12043
+ * (typically `http://192.168.x.x:3210`). For a daemon without a local
12044
+ * HTTP server (startLocalServer=false in tests) we still post the
12045
+ * probe with `gatewayUrl=null` so cloud knows transport='ws' is the
12046
+ * only path.
12047
+ */
12048
+ async reportTransport() {
12049
+ const hasLocalServer = this.opts.startLocalServer !== false;
12050
+ let gatewayUrl = null;
12051
+ if (hasLocalServer) {
12052
+ const localIp = pickLocalIPv4();
12053
+ if (localIp) {
12054
+ const port = this.opts.localPort ?? DEFAULT_LOCAL_PORT;
12055
+ gatewayUrl = `http://${localIp}:${port}`;
12056
+ }
12057
+ }
12058
+ const probe = buildTransportProbe(gatewayUrl);
12059
+ const result = await reportTransportProbe(this.cloud, this.config.daemon_id, probe);
12060
+ if (!result.ok) {
12061
+ process.stderr.write(
12062
+ `[daemon] transport-probe report rejected status=${result.status} error=${result.error ?? "-"}
12063
+ `
12064
+ );
12065
+ return;
10403
12066
  }
12067
+ process.stdout.write(
12068
+ `[daemon] transport-probe reported transport=${probe.transport} gateway=${probe.gatewayUrl ?? "null"} private=${probe.gatewayIsPrivate}
12069
+ `
12070
+ );
10404
12071
  }
10405
12072
  onAgentChanged(payload) {
10406
12073
  const a = this.hostedAgents.get(payload.agentImUserId);
@@ -10843,9 +12510,9 @@ var Runner = class extends import_node_events3.EventEmitter {
10843
12510
  function resolveUserAssetWorkspaceDir(workspaceId) {
10844
12511
  const override = process.env.PRISMER_ASSET_SYNC_ROOT;
10845
12512
  if (override) return (0, import_node_path11.join)(override, workspaceId);
10846
- const home = (0, import_node_os6.homedir)();
12513
+ const home = (0, import_node_os8.homedir)();
10847
12514
  const desktop = (0, import_node_path11.join)(home, "Desktop");
10848
- const base = (0, import_node_fs15.existsSync)(desktop) ? desktop : home;
12515
+ const base = (0, import_node_fs16.existsSync)(desktop) ? desktop : home;
10849
12516
  return (0, import_node_path11.join)(base, "Prismer Assets", workspaceId);
10850
12517
  }
10851
12518
  function readTargetDaemonId(payload) {
@@ -10922,7 +12589,7 @@ function safeJsonParse(raw) {
10922
12589
 
10923
12590
  // src/pair.ts
10924
12591
  var import_node_crypto10 = require("crypto");
10925
- var import_node_os7 = require("os");
12592
+ var import_node_os9 = require("os");
10926
12593
  var import_promises2 = require("timers/promises");
10927
12594
  var import_qrcode = __toESM(require("qrcode"), 1);
10928
12595
  async function pair(opts) {
@@ -10952,7 +12619,7 @@ async function pair(opts) {
10952
12619
  "/api/im/pair/offer",
10953
12620
  {
10954
12621
  auth: false,
10955
- body: { devicePub, deviceName: opts.deviceName ?? (0, import_node_os7.hostname)() }
12622
+ body: { devicePub, deviceName: opts.deviceName ?? (0, import_node_os9.hostname)() }
10956
12623
  }
10957
12624
  );
10958
12625
  if (!offerRes.ok) {
@@ -11033,8 +12700,8 @@ var import_commander20 = require("commander");
11033
12700
 
11034
12701
  // src/cli/commands/adapter.ts
11035
12702
  var import_node_child_process5 = require("child_process");
11036
- var import_node_fs17 = require("fs");
11037
- var import_node_os8 = require("os");
12703
+ var import_node_fs18 = require("fs");
12704
+ var import_node_os10 = require("os");
11038
12705
  var import_node_path13 = require("path");
11039
12706
  var import_commander = require("commander");
11040
12707
  init_hermes();
@@ -11050,7 +12717,7 @@ var INSTALL_SPECS = {
11050
12717
  binary: "claude",
11051
12718
  hint: "Set ANTHROPIC_API_KEY in your shell profile, or rely on Claude Code OAuth login. Run `claude login` to sign in.",
11052
12719
  authHints: ["ANTHROPIC_API_KEY or Claude Code OAuth login (`claude login`)"],
11053
- hookTarget: { path: (0, import_node_path13.join)((0, import_node_os8.homedir)(), ".claude", "hooks.json"), kind: "json-file" }
12720
+ hookTarget: { path: (0, import_node_path13.join)((0, import_node_os10.homedir)(), ".claude", "hooks.json"), kind: "json-file" }
11054
12721
  },
11055
12722
  openclaw: {
11056
12723
  name: "openclaw",
@@ -11059,7 +12726,7 @@ var INSTALL_SPECS = {
11059
12726
  binary: "openclaw",
11060
12727
  hint: "OpenClaw runs as a gateway. Configure ~/.openclaw/openclaw.json and start it with `openclaw gateway` before tasks dispatch.",
11061
12728
  authHints: ["~/.openclaw/openclaw.json gateway.auth.bearerTokens", "Prismer daemon api_key"],
11062
- hookTarget: { path: (0, import_node_path13.join)((0, import_node_os8.homedir)(), ".openclaw", "hooks"), kind: "directory" }
12729
+ hookTarget: { path: (0, import_node_path13.join)((0, import_node_os10.homedir)(), ".openclaw", "hooks"), kind: "directory" }
11063
12730
  },
11064
12731
  codex: {
11065
12732
  name: "codex",
@@ -11068,7 +12735,7 @@ var INSTALL_SPECS = {
11068
12735
  binary: "codex",
11069
12736
  hint: "Set OPENAI_API_KEY in your shell profile. Verify with `codex --help`.",
11070
12737
  authHints: ["OPENAI_API_KEY or Codex CLI account login"],
11071
- hookTarget: { path: (0, import_node_path13.join)((0, import_node_os8.homedir)(), ".codex", "hooks.json"), kind: "json-file" }
12738
+ hookTarget: { path: (0, import_node_path13.join)((0, import_node_os10.homedir)(), ".codex", "hooks.json"), kind: "json-file" }
11072
12739
  },
11073
12740
  hermes: {
11074
12741
  name: "hermes",
@@ -11077,7 +12744,7 @@ var INSTALL_SPECS = {
11077
12744
  binary: "hermes",
11078
12745
  hint: "Hermes runs as a long-lived HTTP gateway in your own Python venv. Start with `hermes -p <profile> gateway` after configuring ~/.hermes/.env.",
11079
12746
  authHints: ["~/.hermes/.env API_SERVER_KEY", "Hermes profile provider credentials"],
11080
- hookTarget: { path: (0, import_node_path13.join)((0, import_node_os8.homedir)(), ".hermes", "hooks.json"), kind: "json-file" }
12747
+ hookTarget: { path: (0, import_node_path13.join)((0, import_node_os10.homedir)(), ".hermes", "hooks.json"), kind: "json-file" }
11081
12748
  }
11082
12749
  };
11083
12750
  function buildAdapterCommand() {
@@ -11292,15 +12959,15 @@ function runHooks(spec, opts) {
11292
12959
  }
11293
12960
  let backup;
11294
12961
  if (planned.kind === "directory") {
11295
- (0, import_node_fs17.mkdirSync)(planned.path, { recursive: true });
12962
+ (0, import_node_fs18.mkdirSync)(planned.path, { recursive: true });
11296
12963
  const markerPath = (0, import_node_path13.join)(planned.path, "prismer.json");
11297
12964
  backup = backupIfExists(markerPath);
11298
- (0, import_node_fs17.writeFileSync)(markerPath, JSON.stringify(prismerHookMarker(spec), null, 2) + "\n", "utf8");
12965
+ (0, import_node_fs18.writeFileSync)(markerPath, JSON.stringify(prismerHookMarker(spec), null, 2) + "\n", "utf8");
11299
12966
  } else {
11300
- (0, import_node_fs17.mkdirSync)((0, import_node_path13.dirname)(planned.path), { recursive: true });
12967
+ (0, import_node_fs18.mkdirSync)((0, import_node_path13.dirname)(planned.path), { recursive: true });
11301
12968
  backup = backupIfExists(planned.path);
11302
12969
  const merged = mergeHookJson(planned.path, spec);
11303
- (0, import_node_fs17.writeFileSync)(planned.path, JSON.stringify(merged, null, 2) + "\n", "utf8");
12970
+ (0, import_node_fs18.writeFileSync)(planned.path, JSON.stringify(merged, null, 2) + "\n", "utf8");
11304
12971
  }
11305
12972
  return {
11306
12973
  ok: true,
@@ -11367,23 +13034,23 @@ function inspectAuthEnv(spec) {
11367
13034
  hints: spec.authHints ?? []
11368
13035
  };
11369
13036
  case "hermes": {
11370
- const envFile = (0, import_node_path13.join)((0, import_node_os8.homedir)(), ".hermes", ".env");
13037
+ const envFile = (0, import_node_path13.join)((0, import_node_os10.homedir)(), ".hermes", ".env");
11371
13038
  return {
11372
- ok: (0, import_node_fs17.existsSync)(envFile) || Boolean(process.env.HERMES_API_KEY || process.env.API_SERVER_KEY),
13039
+ ok: (0, import_node_fs18.existsSync)(envFile) || Boolean(process.env.HERMES_API_KEY || process.env.API_SERVER_KEY),
11373
13040
  present: [
11374
13041
  ...["HERMES_API_KEY", "API_SERVER_KEY"].filter((k) => Boolean(process.env[k])),
11375
- ...(0, import_node_fs17.existsSync)(envFile) ? [envFile] : []
13042
+ ...(0, import_node_fs18.existsSync)(envFile) ? [envFile] : []
11376
13043
  ],
11377
13044
  hints: spec.authHints ?? []
11378
13045
  };
11379
13046
  }
11380
13047
  case "openclaw": {
11381
- const cfgFile = (0, import_node_path13.join)((0, import_node_os8.homedir)(), ".openclaw", "openclaw.json");
13048
+ const cfgFile = (0, import_node_path13.join)((0, import_node_os10.homedir)(), ".openclaw", "openclaw.json");
11382
13049
  return {
11383
- ok: (0, import_node_fs17.existsSync)(cfgFile) || Boolean(process.env.OPENCLAW_API_KEY),
13050
+ ok: (0, import_node_fs18.existsSync)(cfgFile) || Boolean(process.env.OPENCLAW_API_KEY),
11384
13051
  present: [
11385
13052
  ...["OPENCLAW_API_KEY"].filter((k) => Boolean(process.env[k])),
11386
- ...(0, import_node_fs17.existsSync)(cfgFile) ? [cfgFile] : []
13053
+ ...(0, import_node_fs18.existsSync)(cfgFile) ? [cfgFile] : []
11387
13054
  ],
11388
13055
  hints: spec.authHints ?? []
11389
13056
  };
@@ -11398,16 +13065,16 @@ function inspectHook(spec) {
11398
13065
  return { supported: false, markerPresent: false };
11399
13066
  }
11400
13067
  if (spec.name === "openclaw") {
11401
- const jsonPath = (0, import_node_path13.join)((0, import_node_os8.homedir)(), ".openclaw", "hooks.json");
13068
+ const jsonPath = (0, import_node_path13.join)((0, import_node_os10.homedir)(), ".openclaw", "hooks.json");
11402
13069
  const markerPath = (0, import_node_path13.join)(target.path, "prismer.json");
11403
- const jsonMarkerPresent = (0, import_node_fs17.existsSync)(jsonPath) && fileContainsPrismerMarker(jsonPath);
11404
- const dirMarkerPresent = (0, import_node_fs17.existsSync)(markerPath) && fileContainsPrismerMarker(markerPath);
13070
+ const jsonMarkerPresent = (0, import_node_fs18.existsSync)(jsonPath) && fileContainsPrismerMarker(jsonPath);
13071
+ const dirMarkerPresent = (0, import_node_fs18.existsSync)(markerPath) && fileContainsPrismerMarker(markerPath);
11405
13072
  return {
11406
13073
  supported: true,
11407
13074
  kind: "json-file-or-directory",
11408
13075
  path: target.path,
11409
13076
  jsonPath,
11410
- exists: (0, import_node_fs17.existsSync)(target.path) || (0, import_node_fs17.existsSync)(jsonPath),
13077
+ exists: (0, import_node_fs18.existsSync)(target.path) || (0, import_node_fs18.existsSync)(jsonPath),
11411
13078
  markerPath,
11412
13079
  markerPresent: jsonMarkerPresent || dirMarkerPresent
11413
13080
  };
@@ -11418,12 +13085,12 @@ function inspectHook(spec) {
11418
13085
  supported: true,
11419
13086
  kind: target.kind,
11420
13087
  path: target.path,
11421
- exists: (0, import_node_fs17.existsSync)(target.path),
13088
+ exists: (0, import_node_fs18.existsSync)(target.path),
11422
13089
  markerPath,
11423
- markerPresent: (0, import_node_fs17.existsSync)(markerPath) && fileContainsPrismerMarker(markerPath)
13090
+ markerPresent: (0, import_node_fs18.existsSync)(markerPath) && fileContainsPrismerMarker(markerPath)
11424
13091
  };
11425
13092
  }
11426
- const exists = (0, import_node_fs17.existsSync)(target.path);
13093
+ const exists = (0, import_node_fs18.existsSync)(target.path);
11427
13094
  if (!exists) {
11428
13095
  return {
11429
13096
  supported: true,
@@ -11459,14 +13126,14 @@ function prismerHookMarker(spec) {
11459
13126
  }
11460
13127
  function mergeHookJson(path9, spec) {
11461
13128
  const marker = prismerHookMarker(spec);
11462
- if (!(0, import_node_fs17.existsSync)(path9)) {
13129
+ if (!(0, import_node_fs18.existsSync)(path9)) {
11463
13130
  return {
11464
13131
  prismer: marker
11465
13132
  };
11466
13133
  }
11467
13134
  let parsed;
11468
13135
  try {
11469
- parsed = JSON.parse((0, import_node_fs17.readFileSync)(path9, "utf8"));
13136
+ parsed = JSON.parse((0, import_node_fs18.readFileSync)(path9, "utf8"));
11470
13137
  } catch (err) {
11471
13138
  throw new Error(`Cannot parse ${path9} as JSON: ${err.message}`);
11472
13139
  }
@@ -11490,17 +13157,17 @@ function mergeHookJson(path9, spec) {
11490
13157
  return next;
11491
13158
  }
11492
13159
  function backupIfExists(path9) {
11493
- if (!(0, import_node_fs17.existsSync)(path9)) return null;
13160
+ if (!(0, import_node_fs18.existsSync)(path9)) return null;
11494
13161
  const suffix = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
11495
13162
  const backup = `${path9}.bak.${suffix}`;
11496
- const stat = (0, import_node_fs17.statSync)(path9);
13163
+ const stat = (0, import_node_fs18.statSync)(path9);
11497
13164
  if (stat.isDirectory()) return null;
11498
- (0, import_node_fs17.copyFileSync)(path9, backup);
13165
+ (0, import_node_fs18.copyFileSync)(path9, backup);
11499
13166
  return backup;
11500
13167
  }
11501
13168
  function fileContainsPrismerMarker(path9) {
11502
13169
  try {
11503
- return (0, import_node_fs17.readFileSync)(path9, "utf8").includes("prismer-daemon-runtime");
13170
+ return (0, import_node_fs18.readFileSync)(path9, "utf8").includes("prismer-daemon-runtime");
11504
13171
  } catch {
11505
13172
  return false;
11506
13173
  }
@@ -11860,8 +13527,74 @@ function buildAgentCommand() {
11860
13527
  }
11861
13528
  getUI().ok("Skill install requested", `agent=${opts.agent} skill=${slugOrId}`);
11862
13529
  }, { code: "agent_install_skill_failed" }));
13530
+ cmd.command("snapshot <imUserId>").description("Create an agent-level snapshot").option("--include-memory", "Include memory dump reference").option("--label <label>", "Snapshot label").option("--json", "Output JSON").action(runAction(async (imUserId, opts) => {
13531
+ const cloud = makeCloudClient();
13532
+ const data = await requestEnvelope(cloud, "POST", `/api/im/agents/${encodeURIComponent(imUserId)}/snapshot`, {
13533
+ includeMemory: Boolean(opts.includeMemory),
13534
+ label: opts.label
13535
+ }, "agent_snapshot_failed");
13536
+ if (opts.json) {
13537
+ printJson(data);
13538
+ return;
13539
+ }
13540
+ const rec = data;
13541
+ getUI().ok("Snapshot created", String(rec.id ?? "<unknown>"));
13542
+ getUI().line(`agent=${String(rec.agentImUserId ?? imUserId)} memory=${rec.includeMemory ? "included" : "stripped"}`);
13543
+ }, { code: "agent_snapshot_failed" }));
13544
+ cmd.command("publish <imUserId>").description("Publish an agent as an Agent Pack").requiredOption("--slug <slug>", "Agent Pack slug").option("--version <version>", "Package version", "1.0.0").option("--license <license>", "Package license", "proprietary").option("--title <title>", "Display title metadata").option("--description <description>", "Description metadata").option("--json", "Output JSON").action(runAction(async (imUserId, opts) => {
13545
+ const cloud = makeCloudClient();
13546
+ const data = await requestEnvelope(cloud, "POST", `/api/im/agents/${encodeURIComponent(imUserId)}/publish`, {
13547
+ slug: opts.slug,
13548
+ version: opts.version,
13549
+ license: opts.license,
13550
+ metadata: {
13551
+ ...opts.title ? { title: opts.title } : {},
13552
+ ...opts.description ? { description: opts.description } : {}
13553
+ },
13554
+ stripMemory: true
13555
+ }, "agent_publish_failed");
13556
+ if (opts.json) {
13557
+ printJson(data);
13558
+ return;
13559
+ }
13560
+ const rec = data;
13561
+ getUI().ok("Agent Pack published", `${String(rec.slug ?? opts.slug)}@${String(rec.version ?? opts.version ?? "1.0.0")}`);
13562
+ getUI().line(`package=${String(rec.id ?? "<unknown>")}`);
13563
+ }, { code: "agent_publish_failed" }));
13564
+ cmd.command("fork <packIdOrSlug>").description("Fork an Agent Pack into a workspace").requiredOption("--workspace-id <id>", "Target workspace id").option("--display-name <name>", "New agent display name").option("--json", "Output JSON").action(runAction(async (packIdOrSlug, opts) => {
13565
+ const cloud = makeCloudClient();
13566
+ const data = await requestEnvelope(cloud, "POST", `/api/im/agent-packs/${encodeURIComponent(packIdOrSlug)}/fork`, {
13567
+ targetWorkspaceId: opts.workspaceId,
13568
+ displayName: opts.displayName
13569
+ }, "agent_fork_failed");
13570
+ if (opts.json) {
13571
+ printJson(data);
13572
+ return;
13573
+ }
13574
+ const rec = data;
13575
+ getUI().ok("Agent Pack forked", `newAgent=${String(rec.newImUserId ?? "<unknown>")}`);
13576
+ getUI().line(`did=${String(rec.newDid ?? "not issued")}`);
13577
+ }, { code: "agent_fork_failed" }));
11863
13578
  return cmd;
11864
13579
  }
13580
+ function makeCloudClient() {
13581
+ const cfg = loadConfig(resolvePaths());
13582
+ return new CloudClient({ baseUrl: cfg.cloud_api_base, apiKey: cfg.api_key });
13583
+ }
13584
+ async function requestEnvelope(cloud, method, path9, body, code) {
13585
+ const res = await cloud.request(
13586
+ method,
13587
+ path9,
13588
+ body === void 0 ? void 0 : { body }
13589
+ );
13590
+ if (!res.ok || res.data?.ok === false) {
13591
+ const message = res.error?.message ?? res.data?.error?.message ?? "request failed";
13592
+ exitWithError(`${path9} failed (${res.status === 0 ? "network error" : `HTTP ${res.status}`}): ${message}`, {
13593
+ code: res.error?.code ?? res.data?.error?.code ?? code
13594
+ });
13595
+ }
13596
+ return res.data && typeof res.data === "object" && "data" in res.data ? res.data.data : res.data;
13597
+ }
11865
13598
  function readLocalAgents(localDb) {
11866
13599
  const db = openLocalDb(localDb);
11867
13600
  try {
@@ -11966,7 +13699,7 @@ function whichBinary2(bin) {
11966
13699
  // src/cli/commands/asset.ts
11967
13700
  var import_node_crypto11 = require("crypto");
11968
13701
  var import_commander3 = require("commander");
11969
- var import_node_fs18 = require("fs");
13702
+ var import_node_fs19 = require("fs");
11970
13703
  var import_node_path14 = require("path");
11971
13704
  init_util();
11972
13705
  init_ui();
@@ -12139,8 +13872,8 @@ async function prefetchAssetBytes(opts) {
12139
13872
  return { considered: rows.length, fetched, bytes, failed };
12140
13873
  }
12141
13874
  async function uploadAssetPath(inputPath, opts) {
12142
- if (!(0, import_node_fs18.existsSync)(inputPath)) exitWithError(`path not found: ${inputPath}`);
12143
- const stat = (0, import_node_fs18.lstatSync)(inputPath);
13875
+ if (!(0, import_node_fs19.existsSync)(inputPath)) exitWithError(`path not found: ${inputPath}`);
13876
+ const stat = (0, import_node_fs19.lstatSync)(inputPath);
12144
13877
  if (stat.isSymbolicLink()) exitWithError(`refusing to upload symlink: ${inputPath}`);
12145
13878
  if (stat.isFile()) {
12146
13879
  return uploadAsset(inputPath, { ...opts, folderPath: normalizeFolderPath(opts.folderPath) });
@@ -12158,9 +13891,9 @@ async function uploadAssetPath(inputPath, opts) {
12158
13891
  return { ok: true, data: { count: items.length, items } };
12159
13892
  }
12160
13893
  async function uploadAsset(file, opts) {
12161
- if (!(0, import_node_fs18.existsSync)(file)) exitWithError(`file not found: ${file}`);
13894
+ if (!(0, import_node_fs19.existsSync)(file)) exitWithError(`file not found: ${file}`);
12162
13895
  const cfg = loadConfig(resolvePaths());
12163
- const bytes = (0, import_node_fs18.readFileSync)(file);
13896
+ const bytes = (0, import_node_fs19.readFileSync)(file);
12164
13897
  const contentHash = (0, import_node_crypto11.createHash)("sha256").update(bytes).digest("hex");
12165
13898
  const metadata = parseMetadata(opts.metadata);
12166
13899
  if (opts.taskId && metadata.taskId === void 0) metadata.taskId = opts.taskId;
@@ -12296,10 +14029,10 @@ async function putDirectAssetBytes(plan, bytes, mime) {
12296
14029
  }
12297
14030
  function listFilesRecursive(root) {
12298
14031
  const out = [];
12299
- for (const name of (0, import_node_fs18.readdirSync)(root)) {
14032
+ for (const name of (0, import_node_fs19.readdirSync)(root)) {
12300
14033
  if (name.startsWith(".")) continue;
12301
14034
  const full = (0, import_node_path14.join)(root, name);
12302
- const stat = (0, import_node_fs18.lstatSync)(full);
14035
+ const stat = (0, import_node_fs19.lstatSync)(full);
12303
14036
  if (stat.isSymbolicLink()) continue;
12304
14037
  if (stat.isDirectory()) {
12305
14038
  out.push(...listFilesRecursive(full));
@@ -12333,7 +14066,7 @@ async function downloadAsset(assetId, outPath) {
12333
14066
  exitWithError(`asset download failed (${res.status}): ${errorMessage(body)}`);
12334
14067
  }
12335
14068
  const bytes = Buffer.from(await res.arrayBuffer());
12336
- (0, import_node_fs18.writeFileSync)(outPath, bytes);
14069
+ (0, import_node_fs19.writeFileSync)(outPath, bytes);
12337
14070
  }
12338
14071
  function parseMetadata(raw) {
12339
14072
  if (!raw) return {};
@@ -12732,7 +14465,7 @@ function buildCookbookCommand() {
12732
14465
  for (const suite of suites) {
12733
14466
  checks.push(...await runSuite(suite, cloud, opts));
12734
14467
  }
12735
- const summary = summarize(checks, Boolean(opts.strict));
14468
+ const summary = summarize2(checks, Boolean(opts.strict));
12736
14469
  if (opts.json) {
12737
14470
  printJson(summary);
12738
14471
  } else {
@@ -12897,7 +14630,7 @@ function parseSuites(raw) {
12897
14630
  const expanded = selected.includes("all") ? ["status", "im", "task", "group", "asset", "sandbox"] : selected;
12898
14631
  return [...new Set(expanded)];
12899
14632
  }
12900
- function summarize(checks, strict) {
14633
+ function summarize2(checks, strict) {
12901
14634
  const totals = { pass: 0, fail: 0, skip: 0 };
12902
14635
  for (const check of checks) totals[check.status] += 1;
12903
14636
  return {
@@ -12980,7 +14713,7 @@ function parsePositiveInt3(value) {
12980
14713
  // src/cli/commands/daemon.ts
12981
14714
  var import_commander8 = require("commander");
12982
14715
  var import_node_child_process7 = require("child_process");
12983
- var import_node_fs19 = require("fs");
14716
+ var import_node_fs20 = require("fs");
12984
14717
  var import_promises3 = require("timers/promises");
12985
14718
  var import_node_path15 = require("path");
12986
14719
  init_util();
@@ -13056,9 +14789,9 @@ function buildDaemonCommand() {
13056
14789
  if (existingPid && pidAlive2(existingPid)) {
13057
14790
  exitWithError(`Daemon already running (pid ${existingPid}). Use \`prismer daemon stop\` first.`);
13058
14791
  }
13059
- if (!(0, import_node_fs19.existsSync)(paths.logsDir)) (0, import_node_fs19.mkdirSync)(paths.logsDir, { recursive: true });
14792
+ if (!(0, import_node_fs20.existsSync)(paths.logsDir)) (0, import_node_fs20.mkdirSync)(paths.logsDir, { recursive: true });
13060
14793
  const logFile = (0, import_node_path15.join)(paths.logsDir, "daemon.log");
13061
- const fd = (0, import_node_fs19.openSync)(logFile, "a");
14794
+ const fd = (0, import_node_fs20.openSync)(logFile, "a");
13062
14795
  const args = [process.argv[1], "daemon", "run"];
13063
14796
  if (opts.port) args.push("--port", String(opts.port));
13064
14797
  if (opts.localServer === false) args.push("--no-local-server");
@@ -13142,7 +14875,7 @@ function buildDaemonCommand() {
13142
14875
  cmd.command("logs").description("Show daemon logs").option("--tail <n>", "Number of lines to show", (v) => Number.parseInt(v, 10), 80).option("--follow", "Follow log output").option("--json", "Accept --json for global flag compatibility (raw log bytes are streamed)").action(async (opts) => {
13143
14876
  const paths = resolvePaths();
13144
14877
  const logFile = (0, import_node_path15.join)(paths.logsDir, "daemon.log");
13145
- if (!(0, import_node_fs19.existsSync)(logFile)) {
14878
+ if (!(0, import_node_fs20.existsSync)(logFile)) {
13146
14879
  exitWithError(`No daemon log found at ${logFile}. Start the daemon with \`prismer daemon start\`.`);
13147
14880
  }
13148
14881
  const lines = Math.max(1, opts.tail);
@@ -13183,13 +14916,13 @@ async function tailFromEnd(path9, lines) {
13183
14916
  }
13184
14917
  }
13185
14918
  async function followFile(path9) {
13186
- let offset = (0, import_node_fs19.statSync)(path9).size;
14919
+ let offset = (0, import_node_fs20.statSync)(path9).size;
13187
14920
  for (; ; ) {
13188
14921
  await (0, import_promises3.setTimeout)(1e3);
13189
- const size = (0, import_node_fs19.statSync)(path9).size;
14922
+ const size = (0, import_node_fs20.statSync)(path9).size;
13190
14923
  if (size < offset) offset = 0;
13191
14924
  if (size === offset) continue;
13192
- const stream = (0, import_node_fs19.createReadStream)(path9, { start: offset, end: size - 1, encoding: "utf8" });
14925
+ const stream = (0, import_node_fs20.createReadStream)(path9, { start: offset, end: size - 1, encoding: "utf8" });
13193
14926
  for await (const chunk of stream) process.stdout.write(chunk);
13194
14927
  offset = size;
13195
14928
  }
@@ -13197,8 +14930,8 @@ async function followFile(path9) {
13197
14930
 
13198
14931
  // src/cli/commands/events.ts
13199
14932
  var import_commander9 = require("commander");
13200
- var import_node_fs20 = require("fs");
13201
- var import_node_os9 = require("os");
14933
+ var import_node_fs21 = require("fs");
14934
+ var import_node_os11 = require("os");
13202
14935
  var import_node_path16 = require("path");
13203
14936
  var import_node_readline = require("readline");
13204
14937
  init_util();
@@ -13206,7 +14939,7 @@ var DEFAULT_LIMIT2 = 50;
13206
14939
  function buildEventsCommand() {
13207
14940
  return addEventOptions(new import_commander9.Command("events").description("Read local PARA events from ~/.prismer/para/events.jsonl")).action(async (opts) => {
13208
14941
  const file = eventsPath();
13209
- if (!(0, import_node_fs20.existsSync)(file)) {
14942
+ if (!(0, import_node_fs21.existsSync)(file)) {
13210
14943
  printJson(unavailable(file));
13211
14944
  process.exitCode = 1;
13212
14945
  return;
@@ -13223,7 +14956,7 @@ function buildEventsCommand() {
13223
14956
  function buildEventsStatsCommand() {
13224
14957
  return addEventOptions(new import_commander9.Command("events:stats").description("Summarize local PARA events from ~/.prismer/para/events.jsonl")).action(async (opts) => {
13225
14958
  const file = eventsPath();
13226
- if (!(0, import_node_fs20.existsSync)(file)) {
14959
+ if (!(0, import_node_fs21.existsSync)(file)) {
13227
14960
  printJson(unavailable(file));
13228
14961
  process.exitCode = 1;
13229
14962
  return;
@@ -13232,7 +14965,7 @@ function buildEventsStatsCommand() {
13232
14965
  const events = await readEvents(file, filters);
13233
14966
  printJson({
13234
14967
  ok: true,
13235
- data: { stats: summarize2(events), count: events.length, limit: filters.limit },
14968
+ data: { stats: summarize3(events), count: events.length, limit: filters.limit },
13236
14969
  checks: { file, exists: true, filters: cleanFilters(filters) }
13237
14970
  });
13238
14971
  });
@@ -13241,7 +14974,7 @@ function addEventOptions(cmd) {
13241
14974
  return cmd.option("--limit <n>", "Max events to return/read", parsePositiveInt4, DEFAULT_LIMIT2).option("--agent-id <id>", "Filter by agent id").option("--session-id <id>", "Filter by session id").option("--family <name>", "Filter by event family").option("--type <name>", "Filter by event type").option("--json", "Output JSON (default)");
13242
14975
  }
13243
14976
  function eventsPath() {
13244
- return (0, import_node_path16.join)(process.env.PRISMER_HOME ?? (0, import_node_path16.join)((0, import_node_os9.homedir)(), ".prismer"), "para", "events.jsonl");
14977
+ return (0, import_node_path16.join)(process.env.PRISMER_HOME ?? (0, import_node_path16.join)((0, import_node_os11.homedir)(), ".prismer"), "para", "events.jsonl");
13245
14978
  }
13246
14979
  function unavailable(file) {
13247
14980
  return {
@@ -13257,7 +14990,7 @@ function unavailable(file) {
13257
14990
  async function readEvents(file, filters) {
13258
14991
  const out = [];
13259
14992
  const rl = (0, import_node_readline.createInterface)({
13260
- input: (0, import_node_fs20.createReadStream)(file, { encoding: "utf8" }),
14993
+ input: (0, import_node_fs21.createReadStream)(file, { encoding: "utf8" }),
13261
14994
  crlfDelay: Infinity
13262
14995
  });
13263
14996
  for await (const line of rl) {
@@ -13289,7 +15022,7 @@ function field(event, names) {
13289
15022
  }
13290
15023
  return void 0;
13291
15024
  }
13292
- function summarize2(events) {
15025
+ function summarize3(events) {
13293
15026
  const byFamily = {};
13294
15027
  const byType = {};
13295
15028
  const byAgentId = {};
@@ -13327,7 +15060,7 @@ function parsePositiveInt4(v) {
13327
15060
  // src/cli/commands/memory.ts
13328
15061
  var import_better_sqlite35 = __toESM(require("better-sqlite3"), 1);
13329
15062
  var import_commander10 = require("commander");
13330
- var import_node_fs21 = require("fs");
15063
+ var import_node_fs22 = require("fs");
13331
15064
  init_util();
13332
15065
  var LOCAL_BASE = process.env.PRISMER_DAEMON_URL ?? "http://127.0.0.1:3210";
13333
15066
  function buildMemoryCommand() {
@@ -13479,7 +15212,7 @@ function readCacheSnapshot(limit) {
13479
15212
  const paths = resolvePaths();
13480
15213
  const empty = {
13481
15214
  dbPath: paths.localDb,
13482
- dbExists: (0, import_node_fs21.existsSync)(paths.localDb),
15215
+ dbExists: (0, import_node_fs22.existsSync)(paths.localDb),
13483
15216
  tables: {
13484
15217
  cached_assets: { exists: false, count: 0, sizeBytes: 0 },
13485
15218
  workspace_files_mirror: { exists: false, count: 0 }
@@ -13643,8 +15376,8 @@ function buildPairCommand() {
13643
15376
 
13644
15377
  // src/cli/commands/profile.ts
13645
15378
  var import_commander12 = require("commander");
13646
- var import_node_fs22 = require("fs");
13647
- var import_node_os10 = require("os");
15379
+ var import_node_fs23 = require("fs");
15380
+ var import_node_os12 = require("os");
13648
15381
  var import_node_path17 = require("path");
13649
15382
  var import_node_child_process8 = require("child_process");
13650
15383
  init_util();
@@ -13693,12 +15426,12 @@ function buildProfileCommand() {
13693
15426
  const profile = await cloud.get(
13694
15427
  `/api/im/agent_profiles/${encodeURIComponent(profileId)}`
13695
15428
  );
13696
- const tmpFile = (0, import_node_path17.join)((0, import_node_os10.tmpdir)(), `prismer-profile-${profileId}.json`);
13697
- (0, import_node_fs22.writeFileSync)(tmpFile, JSON.stringify(profile.config, null, 2), "utf8");
15429
+ const tmpFile = (0, import_node_path17.join)((0, import_node_os12.tmpdir)(), `prismer-profile-${profileId}.json`);
15430
+ (0, import_node_fs23.writeFileSync)(tmpFile, JSON.stringify(profile.config, null, 2), "utf8");
13698
15431
  const editor = process.env.EDITOR || "vi";
13699
15432
  const ed = (0, import_node_child_process8.spawnSync)(editor, [tmpFile], { stdio: "inherit" });
13700
15433
  if (ed.status !== 0) exitWithError(`editor exited ${ed.status}`, { code: "editor_failed" });
13701
- const newConfig = JSON.parse((0, import_node_fs22.readFileSync)(tmpFile, "utf8"));
15434
+ const newConfig = JSON.parse((0, import_node_fs23.readFileSync)(tmpFile, "utf8"));
13702
15435
  const res = await cloud.request("PATCH", `/api/im/agent_profiles/${encodeURIComponent(profileId)}`, {
13703
15436
  body: { config: newConfig, version: profile.version }
13704
15437
  });
@@ -13720,8 +15453,8 @@ function mkCloud4() {
13720
15453
  function readJsonArg(arg) {
13721
15454
  if (arg.startsWith("@")) {
13722
15455
  const path9 = arg.slice(1);
13723
- if (!(0, import_node_fs22.existsSync)(path9)) throw new Error(`File not found: ${path9}`);
13724
- return JSON.parse((0, import_node_fs22.readFileSync)(path9, "utf8"));
15456
+ if (!(0, import_node_fs23.existsSync)(path9)) throw new Error(`File not found: ${path9}`);
15457
+ return JSON.parse((0, import_node_fs23.readFileSync)(path9, "utf8"));
13725
15458
  }
13726
15459
  return JSON.parse(arg);
13727
15460
  }
@@ -13735,8 +15468,8 @@ async function resolveDefaultWorkspaceId(cloud) {
13735
15468
  // src/cli/commands/reset.ts
13736
15469
  var import_commander13 = require("commander");
13737
15470
  var import_node_child_process9 = require("child_process");
13738
- var import_node_fs23 = require("fs");
13739
- var import_node_os11 = require("os");
15471
+ var import_node_fs24 = require("fs");
15472
+ var import_node_os13 = require("os");
13740
15473
  var import_node_path18 = require("path");
13741
15474
  var import_promises4 = require("timers/promises");
13742
15475
  init_util();
@@ -13754,15 +15487,15 @@ function buildResetCommand() {
13754
15487
  const hermesGatewayPids = findHermesGatewayPids2();
13755
15488
  const plan = {
13756
15489
  root: paths.root,
13757
- exists: (0, import_node_fs23.existsSync)(paths.root),
15490
+ exists: (0, import_node_fs24.existsSync)(paths.root),
13758
15491
  runningPid,
13759
15492
  mode: opts.wipe ? "wipe" : "archive",
13760
15493
  archivePath,
13761
15494
  assetSyncRoot,
13762
- assetSyncExists: (0, import_node_fs23.existsSync)(assetSyncRoot),
15495
+ assetSyncExists: (0, import_node_fs24.existsSync)(assetSyncRoot),
13763
15496
  assetSyncArchivePath,
13764
15497
  hermesHome,
13765
- hermesExists: (0, import_node_fs23.existsSync)(hermesHome),
15498
+ hermesExists: (0, import_node_fs24.existsSync)(hermesHome),
13766
15499
  hermesArchivePath,
13767
15500
  hermesGatewayPids,
13768
15501
  willStopDaemon: runningPid !== null,
@@ -13786,40 +15519,40 @@ function buildResetCommand() {
13786
15519
  for (const gatewayPid of hermesGatewayPids) {
13787
15520
  await stopProcess(gatewayPid, opts.timeout ?? 5e3, "Hermes gateway");
13788
15521
  }
13789
- if ((0, import_node_fs23.existsSync)(paths.root)) {
15522
+ if ((0, import_node_fs24.existsSync)(paths.root)) {
13790
15523
  if (opts.wipe) {
13791
- (0, import_node_fs23.rmSync)(paths.root, { recursive: true, force: true });
15524
+ (0, import_node_fs24.rmSync)(paths.root, { recursive: true, force: true });
13792
15525
  } else {
13793
15526
  if (!archivePath) throw new Error("internal: archivePath missing");
13794
- if (!(0, import_node_fs23.existsSync)((0, import_node_path18.dirname)(archivePath))) {
13795
- (0, import_node_fs23.mkdirSync)((0, import_node_path18.dirname)(archivePath), { recursive: true });
15527
+ if (!(0, import_node_fs24.existsSync)((0, import_node_path18.dirname)(archivePath))) {
15528
+ (0, import_node_fs24.mkdirSync)((0, import_node_path18.dirname)(archivePath), { recursive: true });
13796
15529
  }
13797
- (0, import_node_fs23.renameSync)(paths.root, archivePath);
15530
+ (0, import_node_fs24.renameSync)(paths.root, archivePath);
13798
15531
  }
13799
15532
  }
13800
- if ((0, import_node_fs23.existsSync)(assetSyncRoot)) {
15533
+ if ((0, import_node_fs24.existsSync)(assetSyncRoot)) {
13801
15534
  if (opts.wipe) {
13802
- (0, import_node_fs23.rmSync)(assetSyncRoot, { recursive: true, force: true });
15535
+ (0, import_node_fs24.rmSync)(assetSyncRoot, { recursive: true, force: true });
13803
15536
  } else {
13804
15537
  if (!assetSyncArchivePath) throw new Error("internal: assetSyncArchivePath missing");
13805
- if (!(0, import_node_fs23.existsSync)((0, import_node_path18.dirname)(assetSyncArchivePath))) {
13806
- (0, import_node_fs23.mkdirSync)((0, import_node_path18.dirname)(assetSyncArchivePath), { recursive: true });
15538
+ if (!(0, import_node_fs24.existsSync)((0, import_node_path18.dirname)(assetSyncArchivePath))) {
15539
+ (0, import_node_fs24.mkdirSync)((0, import_node_path18.dirname)(assetSyncArchivePath), { recursive: true });
13807
15540
  }
13808
- (0, import_node_fs23.renameSync)(assetSyncRoot, assetSyncArchivePath);
15541
+ (0, import_node_fs24.renameSync)(assetSyncRoot, assetSyncArchivePath);
13809
15542
  }
13810
15543
  }
13811
- if ((0, import_node_fs23.existsSync)(hermesHome)) {
15544
+ if ((0, import_node_fs24.existsSync)(hermesHome)) {
13812
15545
  if (opts.wipe) {
13813
- (0, import_node_fs23.rmSync)(hermesHome, { recursive: true, force: true });
15546
+ (0, import_node_fs24.rmSync)(hermesHome, { recursive: true, force: true });
13814
15547
  } else {
13815
15548
  if (!hermesArchivePath) throw new Error("internal: hermesArchivePath missing");
13816
- if (!(0, import_node_fs23.existsSync)((0, import_node_path18.dirname)(hermesArchivePath))) {
13817
- (0, import_node_fs23.mkdirSync)((0, import_node_path18.dirname)(hermesArchivePath), { recursive: true });
15549
+ if (!(0, import_node_fs24.existsSync)((0, import_node_path18.dirname)(hermesArchivePath))) {
15550
+ (0, import_node_fs24.mkdirSync)((0, import_node_path18.dirname)(hermesArchivePath), { recursive: true });
13818
15551
  }
13819
- (0, import_node_fs23.renameSync)(hermesHome, hermesArchivePath);
15552
+ (0, import_node_fs24.renameSync)(hermesHome, hermesArchivePath);
13820
15553
  }
13821
15554
  }
13822
- (0, import_node_fs23.mkdirSync)(paths.root, { recursive: true });
15555
+ (0, import_node_fs24.mkdirSync)(paths.root, { recursive: true });
13823
15556
  if (opts.json) {
13824
15557
  printJson({ ok: true, reset: true, plan });
13825
15558
  return;
@@ -13888,13 +15621,13 @@ function timestamp() {
13888
15621
  function resolveAssetSyncRoot() {
13889
15622
  const override = process.env.PRISMER_ASSET_SYNC_ROOT;
13890
15623
  if (override) return override;
13891
- const home = (0, import_node_os11.homedir)();
15624
+ const home = (0, import_node_os13.homedir)();
13892
15625
  const desktop = (0, import_node_path18.join)(home, "Desktop");
13893
- const base = (0, import_node_fs23.existsSync)(desktop) ? desktop : home;
15626
+ const base = (0, import_node_fs24.existsSync)(desktop) ? desktop : home;
13894
15627
  return (0, import_node_path18.join)(base, "Prismer Assets");
13895
15628
  }
13896
15629
  function resolveHermesHome() {
13897
- return process.env.HERMES_HOME || (0, import_node_path18.join)((0, import_node_os11.homedir)(), ".hermes");
15630
+ return process.env.HERMES_HOME || (0, import_node_path18.join)((0, import_node_os13.homedir)(), ".hermes");
13898
15631
  }
13899
15632
  function findHermesGatewayPids2() {
13900
15633
  try {
@@ -14124,10 +15857,10 @@ async function safeParseResponse(res) {
14124
15857
 
14125
15858
  // src/cli/commands/setup.ts
14126
15859
  var import_commander15 = require("commander");
14127
- var import_node_os12 = require("os");
15860
+ var import_node_os14 = require("os");
14128
15861
  var import_node_child_process10 = require("child_process");
14129
15862
  var import_node_crypto12 = require("crypto");
14130
- var import_node_fs24 = require("fs");
15863
+ var import_node_fs25 = require("fs");
14131
15864
  var import_node_http2 = require("http");
14132
15865
  init_util();
14133
15866
  init_ui();
@@ -14169,7 +15902,7 @@ function buildSetupCommand() {
14169
15902
  }
14170
15903
  const result = await pair({
14171
15904
  cloudBaseUrl,
14172
- deviceName: opts.deviceName ?? (0, import_node_os12.hostname)(),
15905
+ deviceName: opts.deviceName ?? (0, import_node_os14.hostname)(),
14173
15906
  force: opts.force,
14174
15907
  paths,
14175
15908
  asUserEmail: opts.asUser
@@ -14186,13 +15919,13 @@ function buildSetupCommand() {
14186
15919
  apiKey = await mintDaemonApiKey({
14187
15920
  cloudBaseUrl,
14188
15921
  token: authToken,
14189
- deviceName: opts.deviceName ?? (0, import_node_os12.hostname)()
15922
+ deviceName: opts.deviceName ?? (0, import_node_os14.hostname)()
14190
15923
  });
14191
15924
  }
14192
15925
  if (!apiKey && !authToken && !opts.check && opts.browser !== false) {
14193
15926
  apiKey = await runBrowserSetup({
14194
15927
  cloudBaseUrl,
14195
- deviceName: opts.deviceName ?? (0, import_node_os12.hostname)(),
15928
+ deviceName: opts.deviceName ?? (0, import_node_os14.hostname)(),
14196
15929
  json: Boolean(opts.json)
14197
15930
  });
14198
15931
  }
@@ -14378,9 +16111,9 @@ function shouldArchiveLocalDb(previous, next) {
14378
16111
  return previous.api_key !== next.api_key || previous.cloud_api_base !== next.cloud_api_base || previous.daemon_id !== next.daemon_id;
14379
16112
  }
14380
16113
  function archiveLocalDb(localDbPath) {
14381
- if (!(0, import_node_fs24.existsSync)(localDbPath)) return;
16114
+ if (!(0, import_node_fs25.existsSync)(localDbPath)) return;
14382
16115
  const archived = `${localDbPath}.${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}.bak`;
14383
- (0, import_node_fs24.renameSync)(localDbPath, archived);
16116
+ (0, import_node_fs25.renameSync)(localDbPath, archived);
14384
16117
  }
14385
16118
  async function mintDaemonApiKey(input) {
14386
16119
  const label = `Daemon: ${input.deviceName} ${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}`;
@@ -14449,7 +16182,7 @@ function buildSkillCommand() {
14449
16182
  const cloud = mkCloud6();
14450
16183
  const path9 = opts.agent ? `/api/im/agents/${encodeURIComponent(opts.agent)}/skills` : `/api/im/skills/${encodeURIComponent(slugOrId)}/install`;
14451
16184
  const body = opts.agent ? { skillId: slugOrId, workspaceId: opts.workspaceId, version: opts.version } : { workspaceId: opts.workspaceId, version: opts.version };
14452
- const data = await requestEnvelope(cloud, "POST", path9, body, "skill_install_failed");
16185
+ const data = await requestEnvelope2(cloud, "POST", path9, body, "skill_install_failed");
14453
16186
  if (opts.json) {
14454
16187
  printJson(data);
14455
16188
  return;
@@ -14460,7 +16193,7 @@ function buildSkillCommand() {
14460
16193
  const cloud = mkCloud6();
14461
16194
  const path9 = opts.agent ? `/api/im/agents/${encodeURIComponent(opts.agent)}/skills` : `/api/im/skills/${encodeURIComponent(slugOrId)}/install`;
14462
16195
  const body = opts.agent ? { skillId: slugOrId, workspaceId: opts.workspaceId } : void 0;
14463
- const data = await requestEnvelope(cloud, "DELETE", path9, body, "skill_uninstall_failed");
16196
+ const data = await requestEnvelope2(cloud, "DELETE", path9, body, "skill_uninstall_failed");
14464
16197
  if (opts.json) {
14465
16198
  printJson(data);
14466
16199
  return;
@@ -14513,7 +16246,7 @@ function mkCloud6() {
14513
16246
  const cfg = loadConfig(resolvePaths());
14514
16247
  return new CloudClient({ baseUrl: cfg.cloud_api_base, apiKey: cfg.api_key });
14515
16248
  }
14516
- async function requestEnvelope(cloud, method, path9, body, code) {
16249
+ async function requestEnvelope2(cloud, method, path9, body, code) {
14517
16250
  const res = await cloud.request(method, path9, {
14518
16251
  body
14519
16252
  });
@@ -14632,7 +16365,7 @@ function printSkill(skill, includeContent) {
14632
16365
 
14633
16366
  // src/cli/commands/status.ts
14634
16367
  var import_commander17 = require("commander");
14635
- var import_node_os13 = require("os");
16368
+ var import_node_os15 = require("os");
14636
16369
  init_util();
14637
16370
  init_ui();
14638
16371
  function buildStatusCommand() {
@@ -14663,6 +16396,7 @@ function buildStatusCommand() {
14663
16396
  let me = null;
14664
16397
  let devices = null;
14665
16398
  let agents = null;
16399
+ let diagnose = null;
14666
16400
  try {
14667
16401
  const meRes = await cloud.request("GET", "/api/im/me", { timeoutMs: 3e3 });
14668
16402
  cloudOk = meRes.ok;
@@ -14675,19 +16409,33 @@ function buildStatusCommand() {
14675
16409
  if (Array.isArray(wsList) && wsList.length > 0) {
14676
16410
  const wsId = wsList[0]?.id;
14677
16411
  if (wsId) {
14678
- const devRes = await cloud.request("GET", `/api/workspace/runtime-installations?workspaceId=${encodeURIComponent(wsId)}&includeStopped=false`, { timeoutMs: 3e3 });
14679
- if (devRes.ok) {
14680
- const devBody = devRes.data;
14681
- devices = devBody?.data;
14682
- }
14683
- const agRes = await cloud.request("GET", `/api/im/workspaces/${encodeURIComponent(wsId)}/agents`, { timeoutMs: 3e3 });
14684
- if (agRes.ok) {
14685
- const agBody = agRes.data;
14686
- agents = agBody?.data;
16412
+ const rtRes = await cloud.request("GET", `/api/im/workspaces/${encodeURIComponent(wsId)}/runtime`, { timeoutMs: 3e3 });
16413
+ if (rtRes.ok) {
16414
+ const rtBody = rtRes.data;
16415
+ const snapshot = rtBody?.data;
16416
+ if (snapshot && Array.isArray(snapshot.devices)) {
16417
+ devices = snapshot.devices;
16418
+ agents = snapshot.devices.flatMap((d) => d.agents ?? []);
16419
+ }
14687
16420
  }
14688
16421
  }
14689
16422
  }
14690
16423
  }
16424
+ const daemonId = cfg.daemon_id;
16425
+ if (daemonId) {
16426
+ try {
16427
+ const diagRes = await cloud.request(
16428
+ "GET",
16429
+ `/api/im/runtime/diagnose?daemonId=${encodeURIComponent(daemonId)}`,
16430
+ { timeoutMs: 3e3 }
16431
+ );
16432
+ if (diagRes.ok) {
16433
+ const body = diagRes.data;
16434
+ if (body?.data && typeof body.data === "object") diagnose = body.data;
16435
+ }
16436
+ } catch {
16437
+ }
16438
+ }
14691
16439
  }
14692
16440
  } catch {
14693
16441
  cloudOk = false;
@@ -14704,7 +16452,8 @@ function buildStatusCommand() {
14704
16452
  info: daemonStatus.info ?? {}
14705
16453
  },
14706
16454
  cloud: { base: cfg.cloud_api_base, reachable: cloudOk, me, devices, agents },
14707
- local
16455
+ local,
16456
+ diagnose
14708
16457
  };
14709
16458
  if (opts.json) {
14710
16459
  printJson(report);
@@ -14713,6 +16462,60 @@ function buildStatusCommand() {
14713
16462
  printPretty(report);
14714
16463
  });
14715
16464
  }
16465
+ function computeDeviceIcon(lastSeenAt, now) {
16466
+ if (!lastSeenAt) return "\u25CB";
16467
+ const age = now.getTime() - Date.parse(lastSeenAt);
16468
+ if (!Number.isFinite(age) || age < 0) return "\u25CB";
16469
+ if (age < 5 * 6e4) return "\u25CF";
16470
+ if (age < 60 * 6e4) return "\u25D0";
16471
+ return "\u25CB";
16472
+ }
16473
+ function computeAgentIcon(status, lastHeartbeat, now) {
16474
+ const s = (status || "").toLowerCase();
16475
+ if (s === "error" || s === "failed") return "\u25C6";
16476
+ if (!lastHeartbeat) return s === "offline" ? "\u25CB" : "\u25D0";
16477
+ const age = now.getTime() - Date.parse(lastHeartbeat);
16478
+ if (!Number.isFinite(age) || age < 0 || age >= 60 * 6e4) return "\u25CB";
16479
+ if (age >= 5 * 6e4) return "\u25D0";
16480
+ return "\u25CF";
16481
+ }
16482
+ function pad(value, width) {
16483
+ if (value.length >= width) return value.slice(0, width);
16484
+ return value + " ".repeat(width - value.length);
16485
+ }
16486
+ function formatDeviceBindings(devices, now = /* @__PURE__ */ new Date()) {
16487
+ const lines = [];
16488
+ if (devices.length === 0) {
16489
+ lines.push(" No paired devices.");
16490
+ return lines;
16491
+ }
16492
+ const totalAgents = devices.reduce((s, d) => s + (d.agents?.length ?? 0), 0);
16493
+ const devWord = devices.length === 1 ? "device" : "devices";
16494
+ const agWord = totalAgents === 1 ? "agent" : "agents";
16495
+ lines.push(` Device & Agent Bindings (${devices.length} ${devWord} \xB7 ${totalAgents} ${agWord})`);
16496
+ lines.push("");
16497
+ for (let di = 0; di < devices.length; di++) {
16498
+ const d = devices[di];
16499
+ const dIcon = computeDeviceIcon(d.lastSeenAt, now);
16500
+ const dName = pad(d.name || d.deviceId, 40);
16501
+ const lastSeen = d.lastSeenAt ? formatRelative(d.lastSeenAt, now) : "never seen";
16502
+ lines.push(` ${dIcon} ${dName} ${lastSeen}`);
16503
+ const agents = d.agents ?? [];
16504
+ for (let ai = 0; ai < agents.length; ai++) {
16505
+ const a = agents[ai];
16506
+ const isLast = ai === agents.length - 1;
16507
+ const branch = isLast ? "\u2514\u2500" : "\u251C\u2500";
16508
+ const aIcon = computeAgentIcon(a.status, a.lastHeartbeat, now);
16509
+ const aName = pad(a.name || a.id, 24);
16510
+ const aStatus = pad(a.status || "?", 8);
16511
+ const aVersion = pad(a.version ? `v${a.version}` : "\u2014", 10);
16512
+ const tail = a.currentTaskId ? `task=${a.currentTaskId}` : a.lastHeartbeat ? `\u2764 ${formatRelative(a.lastHeartbeat, now)}` : "";
16513
+ lines.push(` ${branch} ${aIcon} ${aName} ${aStatus} ${aVersion} ${tail}`.trimEnd());
16514
+ }
16515
+ if (di < devices.length - 1) lines.push("");
16516
+ }
16517
+ return lines;
16518
+ }
14716
16519
  async function readDaemonStatus() {
14717
16520
  try {
14718
16521
  const res = await fetch("http://127.0.0.1:3210/healthz", {
@@ -14756,7 +16559,7 @@ function printPretty(report) {
14756
16559
  ui.blank();
14757
16560
  ok("Config", report.paths.config);
14758
16561
  if (report.daemon.running) {
14759
- const daemonName = report.cloud.me && typeof report.cloud.me === "object" ? report.cloud.me.user?.username ?? (0, import_node_os13.hostname)() : (0, import_node_os13.hostname)();
16562
+ const daemonName = report.cloud.me && typeof report.cloud.me === "object" ? report.cloud.me.user?.username ?? (0, import_node_os15.hostname)() : (0, import_node_os15.hostname)();
14760
16563
  const ws = report.daemon.wsConnected ? "connected" : "pending";
14761
16564
  ok("Daemon", `${daemonName} pid=${report.daemon.pid} ws=${ws}`);
14762
16565
  if (report.daemon.info) {
@@ -14785,16 +16588,7 @@ function printPretty(report) {
14785
16588
  if (Array.isArray(report.cloud.devices) && report.cloud.devices.length > 0) {
14786
16589
  const devs = report.cloud.devices;
14787
16590
  ui.blank();
14788
- ui.line(` Workspace Devices (${devs.length}):`);
14789
- for (const d of devs) {
14790
- const statusIcon = d.daemonStatus === "connected" ? "\u25CF" : "\u25CB";
14791
- const kind = d.runtimeKind === "docker" ? "Local" : "K8s";
14792
- const declared = d.hostedAgentSummary?.declared ?? 0;
14793
- ui.line(` ${statusIcon} ${d.podName?.replace(/^daemon:/, "")?.slice(0, 28) ?? "?"} kind=${kind} agents=${declared}`);
14794
- }
14795
- }
14796
- if (Array.isArray(report.cloud.agents) && report.cloud.agents.length > 0) {
14797
- ui.line(` Hosted agents: ${report.cloud.agents.length}`);
16591
+ for (const ln of formatDeviceBindings(devs)) ui.line(ln);
14798
16592
  }
14799
16593
  if (report.local) {
14800
16594
  ui.blank();
@@ -14804,6 +16598,58 @@ function printPretty(report) {
14804
16598
  } else {
14805
16599
  warn("Local DB", "unavailable");
14806
16600
  }
16601
+ if (report.diagnose) {
16602
+ ui.blank();
16603
+ ui.header("Cloud dispatch reachability");
16604
+ ui.blank();
16605
+ printDiagnose(report.diagnose, report.daemon.pid ?? null);
16606
+ }
16607
+ }
16608
+ function printDiagnose(d, pid) {
16609
+ const ui = getUI();
16610
+ const heartbeatLine = d.lastHeartbeatAt ? `last heartbeat ${formatRelative(d.lastHeartbeatAt)}` : "no heartbeat seen";
16611
+ if (d.wsAlive) {
16612
+ ok("Daemon", `running${pid ? ` (pid ${pid})` : ""}`);
16613
+ ok("Cloud WS", `connected (${heartbeatLine})`);
16614
+ } else {
16615
+ warn("Daemon", `running${pid ? ` (pid ${pid})` : ""} \u2014 not visible to cloud WS`);
16616
+ fail2("Cloud WS", `disconnected (${heartbeatLine})`);
16617
+ }
16618
+ if (d.gatewayUrl) {
16619
+ if (d.gatewayIsPrivate) {
16620
+ warn("HTTP gateway", `${d.gatewayUrl} (private IP \u2014 only reachable on same network)`);
16621
+ } else if (d.httpReachable) {
16622
+ ok(
16623
+ "HTTP gateway",
16624
+ `${d.gatewayUrl} (reachable${d.httpLatencyMs != null ? `, ${d.httpLatencyMs}ms` : ""})`
16625
+ );
16626
+ } else {
16627
+ fail2("HTTP gateway", `${d.gatewayUrl} (unreachable${d.httpError ? ` \u2014 ${d.httpError}` : ""})`);
16628
+ }
16629
+ } else {
16630
+ ui.line(" HTTP gateway: not declared (WS-only daemon)");
16631
+ }
16632
+ const activeAgents = /* @__PURE__ */ (() => {
16633
+ return 0;
16634
+ })();
16635
+ if (activeAgents > 0) {
16636
+ ok("Active agents", String(activeAgents));
16637
+ }
16638
+ const recColour = d.recommendedTransport === "ws" ? ok : d.recommendedTransport === "http" ? warn : fail2;
16639
+ recColour("Recommended transport", d.recommendedTransport);
16640
+ if (d.lastProbeAt) {
16641
+ ui.line(` Last transport probe: ${formatRelative(d.lastProbeAt)}`);
16642
+ }
16643
+ }
16644
+ function formatRelative(iso, now = /* @__PURE__ */ new Date()) {
16645
+ const ts = Date.parse(iso);
16646
+ if (!Number.isFinite(ts)) return "unknown";
16647
+ const diff = Math.max(0, now.getTime() - ts);
16648
+ if (diff < 3e4) return "just now";
16649
+ if (diff < 6e4) return `${Math.round(diff / 1e3)}s ago`;
16650
+ if (diff < 36e5) return `${Math.round(diff / 6e4)}m ago`;
16651
+ if (diff < 864e5) return `${Math.round(diff / 36e5)}h ago`;
16652
+ return `${Math.round(diff / 864e5)}d ago`;
14807
16653
  }
14808
16654
 
14809
16655
  // src/cli/commands/task.ts
@@ -15166,7 +17012,7 @@ async function readResponseError(res) {
15166
17012
 
15167
17013
  // src/cli/index.ts
15168
17014
  init_ui();
15169
- var VERSION = "2.0.3";
17015
+ var VERSION = "2.0.5";
15170
17016
  function buildProgram() {
15171
17017
  const program = new import_commander20.Command("prismer").description("Prismer Cloud daemon CLI (TS-only).").version(VERSION);
15172
17018
  program.addCommand(buildBannerCommand());