@prismer/runtime 2.0.4 → 2.0.6

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
  });
@@ -1618,7 +1952,7 @@ async function syncInstalledSkillsForDispatch(profile, agentImUserId, cloud, sig
1618
1952
  continue;
1619
1953
  }
1620
1954
  const skillDir = (0, import_node_path8.join)(skillsRoot, slug);
1621
- await import_node_fs7.promises.mkdir(skillDir, { recursive: true });
1955
+ await import_node_fs8.promises.mkdir(skillDir, { recursive: true });
1622
1956
  const localFiles = await walkLocalDir(skillDir);
1623
1957
  let dirty = false;
1624
1958
  let perFileFailures = 0;
@@ -1673,14 +2007,14 @@ async function syncInstalledSkillsForDispatch(profile, agentImUserId, cloud, sig
1673
2007
  perFileFailures++;
1674
2008
  continue;
1675
2009
  }
1676
- await import_node_fs7.promises.mkdir((0, import_node_path8.dirname)(targetPath), { recursive: true });
1677
- 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);
1678
2012
  localFiles.delete(file.path);
1679
2013
  dirty = true;
1680
2014
  }
1681
2015
  for (const orphan of localFiles.keys()) {
1682
2016
  try {
1683
- 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));
1684
2018
  dirty = true;
1685
2019
  } catch (err) {
1686
2020
  if (err.code !== "ENOENT") {
@@ -1793,7 +2127,7 @@ async function walkLocalDir(root) {
1793
2127
  async function walk2(dir) {
1794
2128
  let entries;
1795
2129
  try {
1796
- entries = await import_node_fs7.promises.readdir(dir, { withFileTypes: true });
2130
+ entries = await import_node_fs8.promises.readdir(dir, { withFileTypes: true });
1797
2131
  } catch (err) {
1798
2132
  if (err.code === "ENOENT") return;
1799
2133
  throw err;
@@ -1804,7 +2138,7 @@ async function walkLocalDir(root) {
1804
2138
  await walk2(full);
1805
2139
  } else if (ent.isFile()) {
1806
2140
  try {
1807
- const buf = await import_node_fs7.promises.readFile(full);
2141
+ const buf = await import_node_fs8.promises.readFile(full);
1808
2142
  const rel = (0, import_node_path8.relative)(root, full).split(import_node_path8.sep).join("/");
1809
2143
  out.set(rel, sha256Buffer(buf));
1810
2144
  } catch {
@@ -1878,11 +2212,11 @@ async function ackSkillSync(cloud, agentImUserId, input, signal) {
1878
2212
  `);
1879
2213
  }
1880
2214
  }
1881
- var import_node_fs7, import_node_path8, import_node_crypto3, skillSyncWarnedProfiles;
2215
+ var import_node_fs8, import_node_path8, import_node_crypto3, skillSyncWarnedProfiles;
1882
2216
  var init_skill_sync = __esm({
1883
2217
  "src/daemon/skill-sync.ts"() {
1884
2218
  "use strict";
1885
- import_node_fs7 = require("fs");
2219
+ import_node_fs8 = require("fs");
1886
2220
  import_node_path8 = require("path");
1887
2221
  import_node_crypto3 = require("crypto");
1888
2222
  init_hermes();
@@ -2323,7 +2657,7 @@ var require_package = __commonJS({
2323
2657
  "package.json"(exports2, module2) {
2324
2658
  module2.exports = {
2325
2659
  name: "@prismer/runtime",
2326
- version: "2.0.4",
2660
+ version: "2.0.6",
2327
2661
  description: "Prismer Cloud daemon runtime \u2014 TS-only adapter host for hosted IM agents",
2328
2662
  type: "module",
2329
2663
  main: "dist/index.js",
@@ -2357,6 +2691,7 @@ var require_package = __commonJS({
2357
2691
  },
2358
2692
  dependencies: {
2359
2693
  "@iarna/toml": "^2.2.5",
2694
+ "@modelcontextprotocol/sdk": "^1.29.0",
2360
2695
  "better-sqlite3": "^11.8.0",
2361
2696
  commander: "^12.1.0",
2362
2697
  qrcode: "^1.5.4",
@@ -2514,21 +2849,21 @@ function pidFilePath(paths) {
2514
2849
  return (0, import_node_path12.join)(paths.root, "daemon.pid");
2515
2850
  }
2516
2851
  function writePidFile(paths, pid) {
2517
- (0, import_node_fs16.writeFileSync)(pidFilePath(paths), `${pid}
2852
+ (0, import_node_fs17.writeFileSync)(pidFilePath(paths), `${pid}
2518
2853
  `, "utf8");
2519
2854
  }
2520
2855
  function readPidFile(paths) {
2521
2856
  const p = pidFilePath(paths);
2522
- if (!(0, import_node_fs16.existsSync)(p)) return void 0;
2523
- 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();
2524
2859
  const pid = Number.parseInt(raw, 10);
2525
2860
  return Number.isFinite(pid) ? pid : void 0;
2526
2861
  }
2527
2862
  function clearPidFile(paths) {
2528
2863
  const p = pidFilePath(paths);
2529
- if ((0, import_node_fs16.existsSync)(p)) {
2864
+ if ((0, import_node_fs17.existsSync)(p)) {
2530
2865
  try {
2531
- (0, import_node_fs16.unlinkSync)(p);
2866
+ (0, import_node_fs17.unlinkSync)(p);
2532
2867
  } catch {
2533
2868
  }
2534
2869
  }
@@ -2541,11 +2876,11 @@ function pidAlive2(pid) {
2541
2876
  return false;
2542
2877
  }
2543
2878
  }
2544
- 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;
2545
2880
  var init_util = __esm({
2546
2881
  "src/cli/util.ts"() {
2547
2882
  "use strict";
2548
- import_node_fs16 = require("fs");
2883
+ import_node_fs17 = require("fs");
2549
2884
  import_node_path12 = require("path");
2550
2885
  init_ui();
2551
2886
  DEFAULT_CLOUD_BASE_URL = "https://prismer.cloud";
@@ -2789,9 +3124,9 @@ var WsClient = class extends import_node_events.EventEmitter {
2789
3124
 
2790
3125
  // src/sync/store.ts
2791
3126
  var import_better_sqlite32 = __toESM(require("better-sqlite3"), 1);
2792
- var import_node_fs3 = require("fs");
3127
+ var import_node_fs4 = require("fs");
2793
3128
  var import_node_path4 = require("path");
2794
- var SCHEMA_VERSION = 3;
3129
+ var SCHEMA_VERSION = 4;
2795
3130
  var MIGRATIONS = [
2796
3131
  {
2797
3132
  version: 1,
@@ -2917,14 +3252,44 @@ var MIGRATIONS = [
2917
3252
  CREATE INDEX IF NOT EXISTS idx_asset_meta_seq
2918
3253
  ON asset_metadata_index(workspace_id, asset_index_seq);
2919
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
+ `
2920
3285
  }
2921
3286
  ];
2922
3287
  function runSql(db, sql) {
2923
3288
  db["exec"](sql);
2924
3289
  }
2925
3290
  function openLocalDb(path9) {
2926
- if (path9 !== ":memory:" && !(0, import_node_fs3.existsSync)((0, import_node_path4.dirname)(path9))) {
2927
- (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 });
2928
3293
  }
2929
3294
  const db = new import_better_sqlite32.default(path9);
2930
3295
  db.pragma("journal_mode = WAL");
@@ -3161,7 +3526,7 @@ function listRoleTemplates() {
3161
3526
 
3162
3527
  // src/config.ts
3163
3528
  var TOML = __toESM(require("@iarna/toml"), 1);
3164
- var import_node_fs4 = require("fs");
3529
+ var import_node_fs5 = require("fs");
3165
3530
  var import_node_os3 = require("os");
3166
3531
  var import_node_path5 = require("path");
3167
3532
  var import_zod3 = require("zod");
@@ -3204,15 +3569,15 @@ function resolvePaths(home) {
3204
3569
  };
3205
3570
  }
3206
3571
  function configExists(paths = resolvePaths()) {
3207
- return (0, import_node_fs4.existsSync)(paths.configFile);
3572
+ return (0, import_node_fs5.existsSync)(paths.configFile);
3208
3573
  }
3209
3574
  function loadConfig(paths = resolvePaths()) {
3210
- if (!(0, import_node_fs4.existsSync)(paths.configFile)) {
3575
+ if (!(0, import_node_fs5.existsSync)(paths.configFile)) {
3211
3576
  throw new Error(
3212
3577
  `Config not found at ${paths.configFile}. Run \`prismer setup\` to create it.`
3213
3578
  );
3214
3579
  }
3215
- const raw = (0, import_node_fs4.readFileSync)(paths.configFile, "utf8");
3580
+ const raw = (0, import_node_fs5.readFileSync)(paths.configFile, "utf8");
3216
3581
  const parsed = TOML.parse(raw);
3217
3582
  const merged = {
3218
3583
  ...parsed,
@@ -3227,14 +3592,14 @@ function loadConfig(paths = resolvePaths()) {
3227
3592
  return result.data;
3228
3593
  }
3229
3594
  function saveConfig(config, paths = resolvePaths()) {
3230
- if (!(0, import_node_fs4.existsSync)(paths.root)) {
3231
- (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 });
3232
3597
  }
3233
- if (!(0, import_node_fs4.existsSync)((0, import_node_path5.dirname)(paths.configFile))) {
3234
- (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 });
3235
3600
  }
3236
3601
  ConfigSchema.parse(config);
3237
- (0, import_node_fs4.writeFileSync)(paths.configFile, TOML.stringify(config), "utf8");
3602
+ (0, import_node_fs5.writeFileSync)(paths.configFile, TOML.stringify(config), "utf8");
3238
3603
  }
3239
3604
  function deriveWsUrl(httpBase) {
3240
3605
  const u = new URL(httpBase);
@@ -3407,7 +3772,7 @@ function envelope(type, payload, requestId) {
3407
3772
  // src/asset-cache.ts
3408
3773
  var import_node_crypto2 = require("crypto");
3409
3774
  var import_node_dns = require("dns");
3410
- var import_node_fs5 = require("fs");
3775
+ var import_node_fs6 = require("fs");
3411
3776
  var import_node_net = require("net");
3412
3777
  var import_node_path6 = require("path");
3413
3778
  var import_undici = require("undici");
@@ -3432,8 +3797,8 @@ var AssetCache = class {
3432
3797
  this.cloud = opts.cloud;
3433
3798
  this.cacheDir = opts.cacheDir;
3434
3799
  this.maxBytes = opts.maxBytes ?? 5 * 1024 * 1024 * 1024;
3435
- if (!(0, import_node_fs5.existsSync)(this.cacheDir)) {
3436
- (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 });
3437
3802
  }
3438
3803
  }
3439
3804
  /** Local file path for a content hash. Files split by first 2 chars for fs scaling. */
@@ -3443,7 +3808,7 @@ var AssetCache = class {
3443
3808
  get(hash) {
3444
3809
  const row = this.db.prepare("SELECT * FROM cached_assets WHERE content_hash = ?").get(hash);
3445
3810
  if (!row) return void 0;
3446
- if (!(0, import_node_fs5.existsSync)(row.local_path)) {
3811
+ if (!(0, import_node_fs6.existsSync)(row.local_path)) {
3447
3812
  this.db.prepare("DELETE FROM cached_assets WHERE content_hash = ?").run(hash);
3448
3813
  return void 0;
3449
3814
  }
@@ -3507,12 +3872,12 @@ var AssetCache = class {
3507
3872
  throw new Error(`Asset hash mismatch for ${hash}: downloaded ${actualHash}`);
3508
3873
  }
3509
3874
  const localPath = this.pathFor(hash);
3510
- if (!(0, import_node_fs5.existsSync)((0, import_node_path6.dirname)(localPath))) {
3511
- (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 });
3512
3877
  }
3513
3878
  const tmpPath = `${localPath}.${process.pid}.${Date.now()}.tmp`;
3514
- (0, import_node_fs5.writeFileSync)(tmpPath, buf);
3515
- (0, import_node_fs5.renameSync)(tmpPath, localPath);
3879
+ (0, import_node_fs6.writeFileSync)(tmpPath, buf);
3880
+ (0, import_node_fs6.renameSync)(tmpPath, localPath);
3516
3881
  const mime = res.headers.get("content-type");
3517
3882
  const now = Date.now();
3518
3883
  this.db.prepare(
@@ -3571,12 +3936,12 @@ var AssetCache = class {
3571
3936
  return { cached: existing, finalUrl, durationMs: Date.now() - started };
3572
3937
  }
3573
3938
  const localPath = this.pathFor(hash);
3574
- if (!(0, import_node_fs5.existsSync)((0, import_node_path6.dirname)(localPath))) {
3575
- (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 });
3576
3941
  }
3577
3942
  const tmpPath = `${localPath}.${process.pid}.${Date.now()}.tmp`;
3578
- (0, import_node_fs5.writeFileSync)(tmpPath, body);
3579
- (0, import_node_fs5.renameSync)(tmpPath, localPath);
3943
+ (0, import_node_fs6.writeFileSync)(tmpPath, body);
3944
+ (0, import_node_fs6.renameSync)(tmpPath, localPath);
3580
3945
  const now = Date.now();
3581
3946
  this.db.prepare(
3582
3947
  `INSERT OR REPLACE INTO cached_assets
@@ -3604,7 +3969,7 @@ var AssetCache = class {
3604
3969
  if (actualHash !== hash) {
3605
3970
  throw new Error(`Asset hash mismatch for ${hash}: local file is ${actualHash}`);
3606
3971
  }
3607
- const size = (0, import_node_fs5.statSync)(localPath).size;
3972
+ const size = (0, import_node_fs6.statSync)(localPath).size;
3608
3973
  const now = Date.now();
3609
3974
  this.db.prepare(
3610
3975
  `INSERT OR REPLACE INTO cached_assets
@@ -3632,7 +3997,7 @@ var AssetCache = class {
3632
3997
  for (const row of candidates) {
3633
3998
  if (total <= this.maxBytes) break;
3634
3999
  try {
3635
- 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);
3636
4001
  } catch {
3637
4002
  }
3638
4003
  this.db.prepare("DELETE FROM cached_assets WHERE content_hash = ?").run(row.content_hash);
@@ -3647,7 +4012,7 @@ function sha256(buf) {
3647
4012
  return (0, import_node_crypto2.createHash)("sha256").update(buf).digest("hex");
3648
4013
  }
3649
4014
  function readFileBuffer(path9) {
3650
- return (0, import_node_fs5.readFileSync)(path9);
4015
+ return (0, import_node_fs6.readFileSync)(path9);
3651
4016
  }
3652
4017
  var DEFAULT_URL_FETCH_MAX_BYTES = 5 * 1024 * 1024;
3653
4018
  var DEFAULT_URL_FETCH_TIMEOUT_MS = 15e3;
@@ -3829,7 +4194,7 @@ async function fetchUrlWithGuards(rawUrl, opts) {
3829
4194
  }
3830
4195
 
3831
4196
  // src/daemon/asset/mirror.ts
3832
- var import_node_fs6 = require("fs");
4197
+ var import_node_fs7 = require("fs");
3833
4198
  var import_node_path7 = require("path");
3834
4199
  function rowToBinding(row) {
3835
4200
  return {
@@ -3851,16 +4216,16 @@ var WorkspaceMirror = class {
3851
4216
  this.db = opts.db;
3852
4217
  this.cloud = opts.cloud;
3853
4218
  this.workspaceId = opts.workspaceId;
3854
- if (!(0, import_node_fs6.existsSync)(opts.workspaceStateDir)) {
3855
- (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 });
3856
4221
  }
3857
4222
  this.cursorPath = (0, import_node_path7.join)(opts.workspaceStateDir, "asset-cursor.json");
3858
4223
  }
3859
4224
  /** Persisted cursor for this workspace, or null on first run / corrupted file. */
3860
4225
  readCursor() {
3861
- if (!(0, import_node_fs6.existsSync)(this.cursorPath)) return null;
4226
+ if (!(0, import_node_fs7.existsSync)(this.cursorPath)) return null;
3862
4227
  try {
3863
- 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"));
3864
4229
  if (parsed.workspaceId !== this.workspaceId) return null;
3865
4230
  return parsed.cursor;
3866
4231
  } catch {
@@ -3873,7 +4238,7 @@ var WorkspaceMirror = class {
3873
4238
  cursor,
3874
4239
  writtenAt: Date.now()
3875
4240
  };
3876
- (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));
3877
4242
  }
3878
4243
  /**
3879
4244
  * Pull workspace_file changes since the persisted cursor and upsert into
@@ -4279,9 +4644,265 @@ function extractHttpUrls(text) {
4279
4644
  }
4280
4645
 
4281
4646
  // src/daemon/dispatch.ts
4282
- var import_node_fs8 = require("fs");
4647
+ var import_node_fs9 = require("fs");
4283
4648
  var path = __toESM(require("path"), 1);
4284
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
4285
4906
  var DEFAULT_CONTEXT_MAX = 8e3;
4286
4907
  var GOAL_CONTEXT_MAX = 4;
4287
4908
  var MEMORY_DIGEST_MAX_BYTES = 4e3;
@@ -4297,12 +4918,14 @@ async function handleDispatch(payload, requestId, deps) {
4297
4918
  const { taskId, agentImUserId } = payload;
4298
4919
  let resolvedHashes = [];
4299
4920
  let reply;
4921
+ let heartbeatRef;
4922
+ let recorderRef;
4300
4923
  const outboxBase = deps.paths?.runsDir ? path.join(deps.paths.runsDir, taskId) : null;
4301
4924
  const outboxDir = outboxBase ? path.join(outboxBase, "_outbox") : null;
4302
4925
  const workDir = outboxBase ? path.join(outboxBase, "workdir") : null;
4303
4926
  if (outboxDir) {
4304
4927
  try {
4305
- await import_node_fs8.promises.mkdir(outboxDir, { recursive: true });
4928
+ await import_node_fs9.promises.mkdir(outboxDir, { recursive: true });
4306
4929
  } catch (err) {
4307
4930
  process.stderr.write(
4308
4931
  `[daemon] outbox provision failed task=${taskId} dir=${outboxDir}: ${err.message}
@@ -4312,7 +4935,7 @@ async function handleDispatch(payload, requestId, deps) {
4312
4935
  }
4313
4936
  if (workDir) {
4314
4937
  try {
4315
- await import_node_fs8.promises.mkdir(workDir, { recursive: true });
4938
+ await import_node_fs9.promises.mkdir(workDir, { recursive: true });
4316
4939
  } catch (err) {
4317
4940
  process.stderr.write(
4318
4941
  `[daemon] workdir provision failed task=${taskId} dir=${workDir}: ${err.message}
@@ -4378,7 +5001,13 @@ async function handleDispatch(payload, requestId, deps) {
4378
5001
  resolvedHashes.push(...r.resolvedHashes);
4379
5002
  rewrittenContext = payload.context.map((e, i) => ({ ...e, content: r.texts[i] }));
4380
5003
  }
4381
- 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
+ });
4382
5011
  resolvedHashes.push(...assetResolution.pinnedHashes);
4383
5012
  const basePrompt = composePrompt(
4384
5013
  rewrittenPrompt.text,
@@ -4413,9 +5042,44 @@ async function handleDispatch(payload, requestId, deps) {
4413
5042
  }
4414
5043
  const operatingPrinciples = resolveOperatingPrinciples(profile.config);
4415
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);
4416
5058
  const taskInput = {
4417
5059
  taskId,
4418
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 } : {},
4419
5083
  metadata: {
4420
5084
  ...payload.metadata,
4421
5085
  conversationId: payload.conversationId,
@@ -4478,6 +5142,9 @@ async function handleDispatch(payload, requestId, deps) {
4478
5142
  }
4479
5143
  }
4480
5144
  const reaperAborted = deps.signal?.aborted && result.error?.code === "task_cancelled";
5145
+ const approvalRequested = Boolean(
5146
+ result.metadata?.approvalRequested
5147
+ );
4481
5148
  let collectedAssetIds = [];
4482
5149
  if (deps.outboxWatcher && outboxDir) {
4483
5150
  try {
@@ -4489,14 +5156,23 @@ async function handleDispatch(payload, requestId, deps) {
4489
5156
  collectedAssetIds = deps.outboxWatcher.flushPending(taskId);
4490
5157
  }
4491
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;
4492
5166
  reply = {
4493
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.
4494
5173
  ok: result.ok,
4495
5174
  output: result.output,
4496
- error: reaperAborted ? {
4497
- code: "daemon_task_timeout",
4498
- message: `Daemon-side reaper aborted after ${reaperLimitMs}ms inactivity (no progress events from adapter)`
4499
- } : result.error,
5175
+ error: finalError,
4500
5176
  ...collectedAssetIds.length > 0 ? { assetIds: collectedAssetIds } : {},
4501
5177
  metrics: result.metrics,
4502
5178
  ...assetResolution.observability.length > 0 || urlObservations.length > 0 ? { assetObservability: [...assetResolution.observability, ...urlObservations] } : {}
@@ -4518,6 +5194,18 @@ async function handleDispatch(payload, requestId, deps) {
4518
5194
  }
4519
5195
  };
4520
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
+ }
4521
5209
  for (const hash of new Set(resolvedHashes)) {
4522
5210
  try {
4523
5211
  deps.assetCache.unpin(hash);
@@ -4594,6 +5282,10 @@ function isTextLikeMime(mime) {
4594
5282
  if (m.endsWith("+json") || m.endsWith("+xml") || m.endsWith("+csv")) return true;
4595
5283
  return false;
4596
5284
  }
5285
+ function isImageMime(mime) {
5286
+ if (!mime) return false;
5287
+ return mime.toLowerCase().split(";")[0].trim().startsWith("image/");
5288
+ }
4597
5289
  var HASH_REF_RE = /(?:^|\s)#([^\s#]+)/g;
4598
5290
  var HEX_COLOR_RE = /^[0-9a-fA-F]{3,8}$/;
4599
5291
  var FILE_EXT_RE = /\.[a-zA-Z0-9]{1,10}$/;
@@ -4672,8 +5364,34 @@ async function resolveHashRefs(prompt, assetIndex, cloud) {
4672
5364
  }
4673
5365
  return { text: result, resolutions };
4674
5366
  }
4675
- async function resolveAssetRefs(refs, cache, taskId) {
4676
- 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
+ };
4677
5395
  if (!refs || refs.length === 0) return out;
4678
5396
  for (const ref of refs) {
4679
5397
  let cached;
@@ -4702,7 +5420,7 @@ async function resolveAssetRefs(refs, cache, taskId) {
4702
5420
  const inlineCandidate = isTextLikeMime(effectiveMime);
4703
5421
  if (inlineCandidate) {
4704
5422
  try {
4705
- const buf = (0, import_node_fs8.readFileSync)(cached.localPath);
5423
+ const buf = (0, import_node_fs9.readFileSync)(cached.localPath);
4706
5424
  let strategy;
4707
5425
  let body;
4708
5426
  if (buf.byteLength <= ASSET_INLINE_FULL_MAX_BYTES) {
@@ -4738,7 +5456,42 @@ async function resolveAssetRefs(refs, cache, taskId) {
4738
5456
  continue;
4739
5457
  }
4740
5458
  }
4741
- 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
+ }
4742
5495
  out.observability.push({
4743
5496
  assetId: ref.assetId,
4744
5497
  contentHash: ref.contentHash,
@@ -4749,6 +5502,110 @@ async function resolveAssetRefs(refs, cache, taskId) {
4749
5502
  }
4750
5503
  return out;
4751
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
+ }
4752
5609
  function logAssetResolveFailure(taskId, ref, error, mime = ref.mime) {
4753
5610
  const message = error.replace(/\s+/g, " ").slice(0, 500);
4754
5611
  process.stderr.write(
@@ -4763,8 +5620,19 @@ function formatInlineAssetBlock(ref, mime, body, strategy) {
4763
5620
  ${body}
4764
5621
  ---`;
4765
5622
  }
4766
- function formatUriOnlyAssetBlock(ref, mime, localPath) {
4767
- 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)}`;
4768
5636
  }
4769
5637
  function formatErrorAssetBlock(ref, mime, error) {
4770
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.`;
@@ -5247,8 +6115,9 @@ var ServicePool = class {
5247
6115
  // src/daemon/local-server.ts
5248
6116
  var import_node_http = require("http");
5249
6117
  var import_node_crypto4 = require("crypto");
5250
- var import_node_fs9 = require("fs");
6118
+ var import_node_fs10 = require("fs");
5251
6119
  var path2 = __toESM(require("path"), 1);
6120
+ var import_node_os5 = require("os");
5252
6121
  var LocalServer = class {
5253
6122
  constructor(opts) {
5254
6123
  this.opts = opts;
@@ -5360,6 +6229,11 @@ var LocalServer = class {
5360
6229
  void this.handleSnapshot(req, res);
5361
6230
  return;
5362
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
+ }
5363
6237
  if (req.method === "POST" && url === "/local/asset/write") {
5364
6238
  void this.handleAssetWrite(req, res);
5365
6239
  return;
@@ -5410,7 +6284,7 @@ var LocalServer = class {
5410
6284
  async handleSnapshot(req, res) {
5411
6285
  const root = this.opts.snapshotRoot ?? "/workspace";
5412
6286
  try {
5413
- const st = await import_node_fs9.promises.stat(root);
6287
+ const st = await import_node_fs10.promises.stat(root);
5414
6288
  if (!st.isDirectory()) {
5415
6289
  respond(res, 400, { error: "snapshot_root_not_directory", rootPath: root });
5416
6290
  return;
@@ -5437,6 +6311,54 @@ var LocalServer = class {
5437
6311
  }
5438
6312
  void req;
5439
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
+ }
5440
6362
  /**
5441
6363
  * POST /local/asset/write — agent-gen adapter RPC.
5442
6364
  *
@@ -5708,11 +6630,44 @@ function validateAgentDispatchRequest(body) {
5708
6630
  if (payload.attachments != null && !Array.isArray(payload.attachments)) return "attachments must be an array";
5709
6631
  return null;
5710
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
+ }
5711
6666
  async function walkAndDigest(root, current) {
5712
6667
  const out = [];
5713
6668
  let entries;
5714
6669
  try {
5715
- entries = await import_node_fs9.promises.readdir(current);
6670
+ entries = await import_node_fs10.promises.readdir(current);
5716
6671
  } catch (err) {
5717
6672
  if (err.code === "ENOENT") return out;
5718
6673
  throw err;
@@ -5723,7 +6678,7 @@ async function walkAndDigest(root, current) {
5723
6678
  const rel = path2.relative(root, full);
5724
6679
  let st;
5725
6680
  try {
5726
- st = await import_node_fs9.promises.stat(full);
6681
+ st = await import_node_fs10.promises.stat(full);
5727
6682
  } catch {
5728
6683
  continue;
5729
6684
  }
@@ -5734,7 +6689,7 @@ async function walkAndDigest(root, current) {
5734
6689
  continue;
5735
6690
  }
5736
6691
  if (!st.isFile()) continue;
5737
- const buf = await import_node_fs9.promises.readFile(full);
6692
+ const buf = await import_node_fs10.promises.readFile(full);
5738
6693
  const sha2563 = (0, import_node_crypto4.createHash)("sha256").update(buf).digest("hex");
5739
6694
  out.push({
5740
6695
  path: rel,
@@ -5749,9 +6704,9 @@ async function walkAndDigest(root, current) {
5749
6704
  // src/daemon/runner.ts
5750
6705
  var import_node_events3 = require("events");
5751
6706
  var import_node_module2 = require("module");
5752
- var import_node_fs15 = require("fs");
6707
+ var import_node_fs16 = require("fs");
5753
6708
  var import_promises = require("fs/promises");
5754
- var import_node_os6 = require("os");
6709
+ var import_node_os8 = require("os");
5755
6710
  var import_node_path11 = require("path");
5756
6711
 
5757
6712
  // src/adapters/claude-code/index.ts
@@ -7849,7 +8804,7 @@ function parsePrismerUri(uri) {
7849
8804
  }
7850
8805
 
7851
8806
  // src/daemon/asset/metadata-index.ts
7852
- var import_node_fs10 = require("fs");
8807
+ var import_node_fs11 = require("fs");
7853
8808
  var import_node_path9 = require("path");
7854
8809
  var DEFAULT_LIMIT = 8;
7855
8810
  var PULL_PAGE_SIZE = 500;
@@ -7878,16 +8833,16 @@ var AssetMetadataIndex = class {
7878
8833
  this.db = opts.db;
7879
8834
  this.cloud = opts.cloud;
7880
8835
  this.workspaceId = opts.workspaceId;
7881
- if (!(0, import_node_fs10.existsSync)(opts.workspaceStateDir)) {
7882
- (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 });
7883
8838
  }
7884
8839
  this.cursorPath = (0, import_node_path9.join)(opts.workspaceStateDir, "asset-metadata-cursor.json");
7885
8840
  }
7886
8841
  /** Persisted cursor for this workspace, or 0 on first run / corrupted file. */
7887
8842
  readCursor() {
7888
- if (!(0, import_node_fs10.existsSync)(this.cursorPath)) return 0;
8843
+ if (!(0, import_node_fs11.existsSync)(this.cursorPath)) return 0;
7889
8844
  try {
7890
- 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"));
7891
8846
  if (parsed.workspaceId !== this.workspaceId) return 0;
7892
8847
  return parsed.cursor;
7893
8848
  } catch {
@@ -7900,7 +8855,7 @@ var AssetMetadataIndex = class {
7900
8855
  cursor,
7901
8856
  writtenAt: Date.now()
7902
8857
  };
7903
- (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));
7904
8859
  }
7905
8860
  /**
7906
8861
  * Pull incremental asset metadata changes since the persisted cursor and
@@ -8017,7 +8972,7 @@ var AssetMetadataIndex = class {
8017
8972
  };
8018
8973
 
8019
8974
  // src/daemon/asset/rpc.ts
8020
- var import_node_fs11 = require("fs");
8975
+ var import_node_fs12 = require("fs");
8021
8976
  var ASSET_PATH_PREFIX = "/local/asset/";
8022
8977
  var MAX_READ_BYTES = 256 * 1024;
8023
8978
  var MAX_SEARCH_BYTES = 1024 * 1024;
@@ -8192,16 +9147,16 @@ function isTextLike(row) {
8192
9147
  );
8193
9148
  }
8194
9149
  function readFileRange(path9, offset, length) {
8195
- const sizeBytes = (0, import_node_fs11.statSync)(path9).size;
9150
+ const sizeBytes = (0, import_node_fs12.statSync)(path9).size;
8196
9151
  if (offset >= sizeBytes) return { buffer: Buffer.alloc(0), bytesRead: 0, sizeBytes };
8197
- const fd = (0, import_node_fs11.openSync)(path9, "r");
9152
+ const fd = (0, import_node_fs12.openSync)(path9, "r");
8198
9153
  try {
8199
9154
  const target = Math.min(length, sizeBytes - offset);
8200
9155
  const buffer = Buffer.alloc(target);
8201
- 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);
8202
9157
  return { buffer: buffer.subarray(0, bytesRead), bytesRead, sizeBytes };
8203
9158
  } finally {
8204
- (0, import_node_fs11.closeSync)(fd);
9159
+ (0, import_node_fs12.closeSync)(fd);
8205
9160
  }
8206
9161
  }
8207
9162
  function respond3(res, status, body) {
@@ -8226,8 +9181,8 @@ async function readJson3(req) {
8226
9181
  }
8227
9182
 
8228
9183
  // src/daemon/asset/origin/drop-folder.ts
8229
- var import_node_fs12 = require("fs");
8230
- var import_node_os5 = require("os");
9184
+ var import_node_fs13 = require("fs");
9185
+ var import_node_os6 = require("os");
8231
9186
  var path5 = __toESM(require("path"), 1);
8232
9187
  var DropFolderAdapter = class {
8233
9188
  kind = "drop-folder";
@@ -8235,7 +9190,7 @@ var DropFolderAdapter = class {
8235
9190
  rootDir;
8236
9191
  constructor(opts = {}) {
8237
9192
  this.workspaceDir = opts.workspaceDir;
8238
- 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");
8239
9194
  }
8240
9195
  /**
8241
9196
  * Long-lived async iterable wrapping `scanOnce` on a 1s polling interval.
@@ -8245,7 +9200,7 @@ var DropFolderAdapter = class {
8245
9200
  while (true) {
8246
9201
  const batch = await this.scanOnce(workspaceId);
8247
9202
  for (const obs of batch) yield obs;
8248
- await sleep(1e3);
9203
+ await sleep2(1e3);
8249
9204
  }
8250
9205
  }
8251
9206
  /**
@@ -8259,7 +9214,7 @@ var DropFolderAdapter = class {
8259
9214
  const drop = this.dropDir(workspaceId);
8260
9215
  let entries = [];
8261
9216
  try {
8262
- entries = await import_node_fs12.promises.readdir(drop);
9217
+ entries = await import_node_fs13.promises.readdir(drop);
8263
9218
  } catch (err) {
8264
9219
  if (err.code === "ENOENT") return [];
8265
9220
  throw err;
@@ -8268,12 +9223,12 @@ var DropFolderAdapter = class {
8268
9223
  for (const name of entries) {
8269
9224
  if (name.startsWith(".")) continue;
8270
9225
  const fullPath = path5.join(drop, name);
8271
- const stat = await import_node_fs12.promises.lstat(fullPath).catch(() => null);
9226
+ const stat = await import_node_fs13.promises.lstat(fullPath).catch(() => null);
8272
9227
  if (!stat) continue;
8273
9228
  if (stat.isSymbolicLink()) continue;
8274
9229
  if (stat.isDirectory()) {
8275
9230
  for (const nested of await walk(fullPath)) {
8276
- const nstat = await import_node_fs12.promises.lstat(nested).catch(() => null);
9231
+ const nstat = await import_node_fs13.promises.lstat(nested).catch(() => null);
8277
9232
  if (!nstat || !nstat.isFile()) continue;
8278
9233
  out.push(makeObservation(workspaceId, nested, drop, nstat.size, Math.floor(nstat.mtimeMs)));
8279
9234
  }
@@ -8298,7 +9253,7 @@ var DropFolderAdapter = class {
8298
9253
  }
8299
9254
  async fetch(obs) {
8300
9255
  const detail = obs.detail;
8301
- const bytes = await import_node_fs12.promises.readFile(detail.path);
9256
+ const bytes = await import_node_fs13.promises.readFile(detail.path);
8302
9257
  return {
8303
9258
  bytes,
8304
9259
  mime: mimeFromExtension(detail.path),
@@ -8320,8 +9275,8 @@ var DropFolderAdapter = class {
8320
9275
  const rel = typeof detail.watchDir === "string" ? path5.relative(detail.watchDir, detail.path) : path5.basename(detail.path);
8321
9276
  const safeRel = rel && !rel.startsWith("..") && !path5.isAbsolute(rel) ? rel : path5.basename(detail.path);
8322
9277
  const destPath = path5.join(destDir, safeRel);
8323
- await import_node_fs12.promises.mkdir(path5.dirname(destPath), { recursive: true });
8324
- 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);
8325
9280
  } catch (err) {
8326
9281
  if (err.code === "ENOENT") return;
8327
9282
  throw err;
@@ -8349,11 +9304,11 @@ function makeObservation(workspaceId, filePath, watchDir, size, mtime) {
8349
9304
  }
8350
9305
  async function walk(root) {
8351
9306
  const out = [];
8352
- const entries = await import_node_fs12.promises.readdir(root).catch(() => []);
9307
+ const entries = await import_node_fs13.promises.readdir(root).catch(() => []);
8353
9308
  for (const name of entries) {
8354
9309
  if (name.startsWith(".")) continue;
8355
9310
  const full = path5.join(root, name);
8356
- const stat = await import_node_fs12.promises.lstat(full).catch(() => null);
9311
+ const stat = await import_node_fs13.promises.lstat(full).catch(() => null);
8357
9312
  if (!stat) continue;
8358
9313
  if (stat.isSymbolicLink()) continue;
8359
9314
  if (stat.isDirectory()) {
@@ -8394,7 +9349,7 @@ function mimeFromExtension(p) {
8394
9349
  const ext = path5.extname(p).toLowerCase();
8395
9350
  return MIME_BY_EXT[ext] ?? null;
8396
9351
  }
8397
- function sleep(ms) {
9352
+ function sleep2(ms) {
8398
9353
  return new Promise((r) => setTimeout(r, ms));
8399
9354
  }
8400
9355
 
@@ -8949,7 +9904,7 @@ function stringValue(obj, key) {
8949
9904
  }
8950
9905
 
8951
9906
  // src/daemon/outbox-watcher.ts
8952
- var import_node_fs13 = require("fs");
9907
+ var import_node_fs14 = require("fs");
8953
9908
  var path7 = __toESM(require("path"), 1);
8954
9909
 
8955
9910
  // src/daemon/asset/agent-output-policy.ts
@@ -9105,9 +10060,120 @@ function extensionOf(filename) {
9105
10060
  return idx >= 0 ? name.slice(idx) : "";
9106
10061
  }
9107
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
+
9108
10173
  // src/daemon/outbox-watcher.ts
9109
10174
  var DEFAULT_INTERVAL_MS = 2e3;
9110
10175
  var RESERVED_SUBDIR = "_uploaded";
10176
+ var REJECTED_SUBDIR = "_rejected";
9111
10177
  var OutboxWatcher = class {
9112
10178
  constructor(opts) {
9113
10179
  this.opts = opts;
@@ -9272,7 +10338,7 @@ var OutboxWatcher = class {
9272
10338
  async scanDir(dir, kind, task) {
9273
10339
  let entries = [];
9274
10340
  try {
9275
- entries = await import_node_fs13.promises.readdir(dir);
10341
+ entries = await import_node_fs14.promises.readdir(dir);
9276
10342
  } catch (err) {
9277
10343
  const code = err.code;
9278
10344
  if (code === "ENOENT") return;
@@ -9282,10 +10348,11 @@ var OutboxWatcher = class {
9282
10348
  for (const name of entries) {
9283
10349
  if (name.startsWith(".")) continue;
9284
10350
  if (name === RESERVED_SUBDIR) continue;
10351
+ if (name === REJECTED_SUBDIR) continue;
9285
10352
  const full = path7.join(dir, name);
9286
10353
  let st;
9287
10354
  try {
9288
- st = await import_node_fs13.promises.stat(full);
10355
+ st = await import_node_fs14.promises.stat(full);
9289
10356
  } catch {
9290
10357
  continue;
9291
10358
  }
@@ -9296,10 +10363,77 @@ var OutboxWatcher = class {
9296
10363
  await this.upload(full, st, kind, task);
9297
10364
  this.uploaded.add(key);
9298
10365
  } catch (err) {
9299
- 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
+ }
9300
10377
  }
9301
10378
  }
9302
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
+ }
9303
10437
  async upload(filePath, st, kind, task) {
9304
10438
  const wsId = this.opts.workspaceId();
9305
10439
  if (!wsId) {
@@ -9310,13 +10444,18 @@ var OutboxWatcher = class {
9310
10444
  this.log("warn", `skip ${filePath}: no active taskId (no dispatch/handoff received yet)`);
9311
10445
  return;
9312
10446
  }
9313
- const bytes = await import_node_fs13.promises.readFile(filePath);
10447
+ const bytes = await import_node_fs14.promises.readFile(filePath);
9314
10448
  const fileName = path7.basename(filePath);
9315
10449
  const inferredMime = inferAgentOutputMime(fileName);
9316
10450
  const policy = validateAgentOutputAsset({ filename: fileName, mime: inferredMime, sizeBytes: bytes.length });
9317
10451
  if (!policy.ok) {
9318
10452
  throw new Error(`agent output rejected: ${policy.reason}`);
9319
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
+ }
9320
10459
  const metadata = {
9321
10460
  taskId: task.taskId,
9322
10461
  ...task.adapter ? { adapter: task.adapter } : {}
@@ -9352,7 +10491,7 @@ var OutboxWatcher = class {
9352
10491
 
9353
10492
  // src/daemon/shell-executor.ts
9354
10493
  var import_node_child_process4 = require("child_process");
9355
- var import_node_fs14 = require("fs");
10494
+ var import_node_fs15 = require("fs");
9356
10495
  var import_node_path10 = require("path");
9357
10496
  var DEFAULT_OUTPUT_LIMIT = 256 * 1024;
9358
10497
  var DEFAULT_TIMEOUT = 6e4;
@@ -9389,7 +10528,7 @@ async function executeShellDispatch(payload, deps) {
9389
10528
  const command = readCommand(payload, execution);
9390
10529
  if (!command.trim()) return fail(payload.taskId, "shell_command_required", "Shell command is required");
9391
10530
  const cwd = resolveCwd(execution.cwd, deps.config.defaultCwd);
9392
- 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}`);
9393
10532
  const timeoutMs = Math.min(
9394
10533
  typeof payload.timeoutMs === "number" && payload.timeoutMs > 0 ? payload.timeoutMs : deps.config.maxTimeoutMs,
9395
10534
  deps.config.maxTimeoutMs
@@ -9481,45 +10620,391 @@ async function executeShellDispatch(payload, deps) {
9481
10620
  });
9482
10621
  });
9483
10622
  }
9484
- function readExecutionMetadata(payload) {
9485
- const execution = payload.metadata?.execution;
9486
- return execution && typeof execution === "object" && !Array.isArray(execution) ? execution : {};
10623
+ function readExecutionMetadata(payload) {
10624
+ const execution = payload.metadata?.execution;
10625
+ return execution && typeof execution === "object" && !Array.isArray(execution) ? execution : {};
10626
+ }
10627
+ function readCommand(payload, execution) {
10628
+ if (typeof execution.command === "string") return execution.command;
10629
+ if (Array.isArray(execution.command)) return execution.command.map(String).join(" ");
10630
+ return payload.prompt;
10631
+ }
10632
+ function resolveCwd(raw, fallback) {
10633
+ if (typeof raw !== "string" || raw.trim() === "") return (0, import_node_path10.resolve)(fallback);
10634
+ return (0, import_node_path10.resolve)(raw);
10635
+ }
10636
+ function clampNumber(value, fallback, min, max) {
10637
+ const n = typeof value === "number" ? value : typeof value === "string" ? Number(value) : NaN;
10638
+ if (!Number.isFinite(n)) return fallback;
10639
+ return Math.max(min, Math.min(max, Math.floor(n)));
10640
+ }
10641
+ function fail(taskId, code, message, durationMs) {
10642
+ return { taskId, ok: false, error: { code, message }, metrics: durationMs ? { durationMs } : void 0 };
10643
+ }
10644
+ function formatOutput(input) {
10645
+ return [
10646
+ `$ ${input.command}`,
10647
+ `cwd: ${input.cwd}`,
10648
+ `shell: ${input.shell}`,
10649
+ `exitCode: ${input.exitCode}`,
10650
+ input.truncated ? "output: truncated" : null,
10651
+ input.stdout ? `
10652
+ [stdout]
10653
+ ${input.stdout}` : null,
10654
+ input.stderr ? `
10655
+ [stderr]
10656
+ ${input.stderr}` : null
10657
+ ].filter(Boolean).join("\n");
10658
+ }
10659
+
10660
+ // src/daemon/runner.ts
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
+ };
9487
10866
  }
9488
- function readCommand(payload, execution) {
9489
- if (typeof execution.command === "string") return execution.command;
9490
- if (Array.isArray(execution.command)) return execution.command.map(String).join(" ");
9491
- return payload.prompt;
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 };
9492
10893
  }
9493
- function resolveCwd(raw, fallback) {
9494
- if (typeof raw !== "string" || raw.trim() === "") return (0, import_node_path10.resolve)(fallback);
9495
- return (0, import_node_path10.resolve)(raw);
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 };
9496
10931
  }
9497
- function clampNumber(value, fallback, min, max) {
9498
- const n = typeof value === "number" ? value : typeof value === "string" ? Number(value) : NaN;
9499
- if (!Number.isFinite(n)) return fallback;
9500
- return Math.max(min, Math.min(max, Math.floor(n)));
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;
9501
10955
  }
9502
- function fail(taskId, code, message, durationMs) {
9503
- return { taskId, ok: false, error: { code, message }, metrics: durationMs ? { durationMs } : void 0 };
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
+ };
9504
10972
  }
9505
- function formatOutput(input) {
9506
- return [
9507
- `$ ${input.command}`,
9508
- `cwd: ${input.cwd}`,
9509
- `shell: ${input.shell}`,
9510
- `exitCode: ${input.exitCode}`,
9511
- input.truncated ? "output: truncated" : null,
9512
- input.stdout ? `
9513
- [stdout]
9514
- ${input.stdout}` : null,
9515
- input.stderr ? `
9516
- [stderr]
9517
- ${input.stderr}` : null
9518
- ].filter(Boolean).join("\n");
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 };
9519
11005
  }
9520
11006
 
9521
11007
  // src/daemon/runner.ts
9522
- init_skill_sync();
9523
11008
  var import_meta2 = {};
9524
11009
  var DEFAULT_LOCAL_PORT = 3210;
9525
11010
  var log7 = createLogger("Daemon");
@@ -9561,6 +11046,7 @@ var Runner = class extends import_node_events3.EventEmitter {
9561
11046
  workspaceId = "";
9562
11047
  wsConnected = false;
9563
11048
  hostedAgents = /* @__PURE__ */ new Map();
11049
+ rejectedHostedAgentIds = /* @__PURE__ */ new Map();
9564
11050
  runningTasks = /* @__PURE__ */ new Map();
9565
11051
  lastTaskError;
9566
11052
  heartbeatTimer;
@@ -9568,6 +11054,14 @@ var Runner = class extends import_node_events3.EventEmitter {
9568
11054
  // F16 (2026-05-20) — periodic skill resync timer (default 10min).
9569
11055
  skillResyncTimer;
9570
11056
  skillSyncInFlight = false;
11057
+ // Wave-4 E7 — local cache of in-flight two-phase dispatch replies. Survives
11058
+ // daemon crash so cold-start can resume `prepared` rows via idempotent
11059
+ // commit (server enforces (taskId, idempotencyKey) UNIQUE).
11060
+ pendingReplyCache;
11061
+ // One-shot guard so we only recover on the first post-authenticated tick,
11062
+ // not every reconnect (server-side commits are idempotent but the log noise
11063
+ // and DB pressure of re-running on every redeclare is wasteful).
11064
+ pendingReplyRecoveryDone = false;
9571
11065
  async start() {
9572
11066
  if (this.state !== "idle") throw new Error(`Runner already in state ${this.state}`);
9573
11067
  this.state = "starting";
@@ -9582,6 +11076,7 @@ var Runner = class extends import_node_events3.EventEmitter {
9582
11076
  process.env.PRISMER_API_KEY = this.config.api_key;
9583
11077
  this.db = openLocalDb(this.paths.localDb);
9584
11078
  this.cloud = new CloudClient({ baseUrl: this.config.cloud_api_base, apiKey: this.config.api_key });
11079
+ this.pendingReplyCache = createPendingReplyCache(this.db);
9585
11080
  this.assetCache = new AssetCache({
9586
11081
  db: this.db,
9587
11082
  cloud: this.cloud,
@@ -9908,19 +11403,19 @@ var Runner = class extends import_node_events3.EventEmitter {
9908
11403
  sampleCgroupResources() {
9909
11404
  const baseline = { cpu: { usagePct: 0 }, mem: { usedBytes: 0, limitBytes: 0 } };
9910
11405
  try {
9911
- if (!(0, import_node_fs15.existsSync)("/sys/fs/cgroup/memory.current")) {
11406
+ if (!(0, import_node_fs16.existsSync)("/sys/fs/cgroup/memory.current")) {
9912
11407
  return baseline;
9913
11408
  }
9914
- const memUsedRaw = (0, import_node_fs15.readFileSync)("/sys/fs/cgroup/memory.current", "utf-8").trim();
11409
+ const memUsedRaw = (0, import_node_fs16.readFileSync)("/sys/fs/cgroup/memory.current", "utf-8").trim();
9915
11410
  const memUsed = Number.parseInt(memUsedRaw, 10);
9916
11411
  let memLimit = 0;
9917
- if ((0, import_node_fs15.existsSync)("/sys/fs/cgroup/memory.max")) {
9918
- const limitRaw = (0, import_node_fs15.readFileSync)("/sys/fs/cgroup/memory.max", "utf-8").trim();
11412
+ if ((0, import_node_fs16.existsSync)("/sys/fs/cgroup/memory.max")) {
11413
+ const limitRaw = (0, import_node_fs16.readFileSync)("/sys/fs/cgroup/memory.max", "utf-8").trim();
9919
11414
  memLimit = limitRaw === "max" ? 0 : Number.parseInt(limitRaw, 10) || 0;
9920
11415
  }
9921
11416
  let cpuUsagePct = 0;
9922
- if ((0, import_node_fs15.existsSync)("/sys/fs/cgroup/cpu.stat")) {
9923
- const statLines = (0, import_node_fs15.readFileSync)("/sys/fs/cgroup/cpu.stat", "utf-8").split("\n");
11417
+ if ((0, import_node_fs16.existsSync)("/sys/fs/cgroup/cpu.stat")) {
11418
+ const statLines = (0, import_node_fs16.readFileSync)("/sys/fs/cgroup/cpu.stat", "utf-8").split("\n");
9924
11419
  const usecLine = statLines.find((line) => line.startsWith("usage_usec "));
9925
11420
  if (usecLine) {
9926
11421
  const usec = Number.parseInt(usecLine.split(" ")[1] ?? "0", 10);
@@ -9997,6 +11492,9 @@ var Runner = class extends import_node_events3.EventEmitter {
9997
11492
  profilesByAgent.set(p.agent_im_user_id, list);
9998
11493
  }
9999
11494
  for (const a of agents) {
11495
+ if (this.rejectedHostedAgentIds.has(a.im_user_id)) {
11496
+ continue;
11497
+ }
10000
11498
  let caps = [];
10001
11499
  try {
10002
11500
  caps = JSON.parse(a.capabilities);
@@ -10046,6 +11544,7 @@ var Runner = class extends import_node_events3.EventEmitter {
10046
11544
  * Used to populate the `agents` list in agent.host.declare.
10047
11545
  */
10048
11546
  setHostedAgent(agent) {
11547
+ this.rejectedHostedAgentIds.delete(agent.imUserId);
10049
11548
  this.hostedAgents.set(agent.imUserId, {
10050
11549
  imUserId: agent.imUserId,
10051
11550
  name: agent.name,
@@ -10078,6 +11577,7 @@ var Runner = class extends import_node_events3.EventEmitter {
10078
11577
  );
10079
11578
  });
10080
11579
  tx(payload);
11580
+ this.rejectedHostedAgentIds.delete(payload.imUserId);
10081
11581
  this.loadAgentsFromDb();
10082
11582
  if (this.wsConnected) this.sendDeclare();
10083
11583
  return {
@@ -10098,10 +11598,10 @@ var Runner = class extends import_node_events3.EventEmitter {
10098
11598
  const rawJson = process.env.PRISMER_HOSTED_AGENT_JSON;
10099
11599
  let raw;
10100
11600
  if (rawFile) {
10101
- if (!(0, import_node_fs15.existsSync)(rawFile)) {
11601
+ if (!(0, import_node_fs16.existsSync)(rawFile)) {
10102
11602
  throw new Error(`PRISMER_HOSTED_AGENT_FILE not found: ${rawFile}`);
10103
11603
  }
10104
- raw = (0, import_node_fs15.readFileSync)(rawFile, "utf8");
11604
+ raw = (0, import_node_fs16.readFileSync)(rawFile, "utf8");
10105
11605
  } else if (rawJson) {
10106
11606
  raw = rawJson;
10107
11607
  }
@@ -10188,6 +11688,19 @@ var Runner = class extends import_node_events3.EventEmitter {
10188
11688
  process.stdout.write(`[daemon] ws authenticated, sending agent.host.declare (${this.hostedAgents.size} agents)
10189
11689
  `);
10190
11690
  this.sendDeclare();
11691
+ if (!this.pendingReplyRecoveryDone) {
11692
+ this.pendingReplyRecoveryDone = true;
11693
+ void recoverPendingReplies({ cloud: this.cloud, cache: this.pendingReplyCache }).then((summary) => {
11694
+ if (summary.attempted === 0) return;
11695
+ process.stdout.write(
11696
+ `[daemon] dispatch.recovery summary attempted=${summary.attempted} committed=${summary.committed} aborted=${summary.aborted} failed=${summary.failed}
11697
+ `
11698
+ );
11699
+ }).catch((err) => {
11700
+ process.stderr.write(`[daemon] dispatch.recovery threw: ${err.message}
11701
+ `);
11702
+ });
11703
+ }
10191
11704
  return;
10192
11705
  }
10193
11706
  this.handleIncoming(msg);
@@ -10197,7 +11710,7 @@ var Runner = class extends import_node_events3.EventEmitter {
10197
11710
  const payload = {
10198
11711
  daemonId: this.config.daemon_id,
10199
11712
  daemonVersion: this.opts.daemonVersion ?? "0.0.0",
10200
- platform: (0, import_node_os6.platform)() === "win32" ? "win32" : (0, import_node_os6.platform)() === "linux" ? "linux" : "darwin",
11713
+ platform: (0, import_node_os8.platform)() === "win32" ? "win32" : (0, import_node_os8.platform)() === "linux" ? "linux" : "darwin",
10201
11714
  agents: Array.from(this.hostedAgents.values()).map((a) => ({
10202
11715
  imUserId: a.imUserId,
10203
11716
  name: a.name,
@@ -10234,12 +11747,91 @@ var Runner = class extends import_node_events3.EventEmitter {
10234
11747
  case "asset.changed":
10235
11748
  void this.onAssetChanged(msg.payload);
10236
11749
  return;
11750
+ // v2.0 §4.8.1 (Wave 4-E4) — webhook reverse-channel RPC. Cloud's
11751
+ // dispatchExternalMention pushes an AgentDispatchRequest plus an
11752
+ // embedded `_rpcId`; the daemon runs the same handler as the
11753
+ // HTTP /dispatch path and echoes the reply back over WS with the
11754
+ // identical `_rpcId` so cloud's WsRpcService can settle the
11755
+ // pending promise.
11756
+ case "webhook.dispatch.request":
11757
+ void this.onWebhookDispatch(msg.payload);
11758
+ return;
10237
11759
  default:
10238
11760
  this.emit("unknown-message", msg);
10239
11761
  }
10240
11762
  }
11763
+ /**
11764
+ * v2.0 §4.8.1 — webhook dispatch over WS reverse channel.
11765
+ *
11766
+ * Acks via `webhook.dispatch.reply { _rpcId, ok, acceptedAt }` immediately
11767
+ * after `handleAgentMessageDispatch()` returns its synchronous response
11768
+ * — the actual reply (the agent's reply text/attachments) still flows
11769
+ * through `postMessageDispatchReply()` which posts to
11770
+ * `/api/im/dispatch/reply` after the adapter finishes. That mirrors the
11771
+ * HTTP /dispatch contract: ack first, asynchronous final reply via the
11772
+ * dispatch-reply REST endpoint.
11773
+ *
11774
+ * The `_rpcId` field is the only addition versus the HTTP path; we strip
11775
+ * it before passing the payload to the dispatch handler so existing
11776
+ * validation logic doesn't trip on an unknown field.
11777
+ */
11778
+ async onWebhookDispatch(payload) {
11779
+ const rpcId = payload._rpcId;
11780
+ if (!rpcId) {
11781
+ process.stderr.write(`[daemon] webhook.dispatch.request without _rpcId \u2014 dropped
11782
+ `);
11783
+ return;
11784
+ }
11785
+ const { _rpcId: _, ...request } = payload;
11786
+ try {
11787
+ const handle = handleAgentMessageDispatch(request, {
11788
+ findAgent: (agentImUserId) => this.findMessageDispatchAgent(agentImUserId),
11789
+ postReply: (replyPayload) => this.postMessageDispatchReply(replyPayload),
11790
+ onError: (err, req) => {
11791
+ process.stderr.write(
11792
+ `[daemon] webhook.dispatch.request failed message=${req.messageId} agent=${req.mentionedAgentImUserId}: ${err instanceof Error ? err.stack ?? err.message : String(err)}
11793
+ `
11794
+ );
11795
+ }
11796
+ });
11797
+ this.ws.send({
11798
+ type: "webhook.dispatch.reply",
11799
+ payload: {
11800
+ _rpcId: rpcId,
11801
+ ok: handle.response.ok,
11802
+ acceptedAt: handle.response.acceptedAt,
11803
+ error: handle.response.error
11804
+ }
11805
+ });
11806
+ void handle.done.catch((err) => {
11807
+ process.stderr.write(
11808
+ `[daemon] webhook.dispatch final reply failed message=${request.messageId}: ${err instanceof Error ? err.message : String(err)}
11809
+ `
11810
+ );
11811
+ });
11812
+ } catch (err) {
11813
+ this.ws.send({
11814
+ type: "webhook.dispatch.reply",
11815
+ payload: {
11816
+ _rpcId: rpcId,
11817
+ ok: false,
11818
+ error: {
11819
+ code: "daemon_dispatch_failed",
11820
+ message: err instanceof Error ? err.message : String(err)
11821
+ }
11822
+ }
11823
+ });
11824
+ }
11825
+ }
10241
11826
  async onHostAcked(payload) {
10242
11827
  this.workspaceId = payload.workspaceId;
11828
+ const ownershipLockChanged = await this.applyHostAckOwnershipLock(payload);
11829
+ void this.reportTransport().catch((err) => {
11830
+ process.stderr.write(
11831
+ `[daemon] transport-probe report failed: ${err.message}
11832
+ `
11833
+ );
11834
+ });
10243
11835
  if (this.memoryWiring && this.workspaceId) {
10244
11836
  syncMemoryFromCloud(this.memoryWiring, this.cloud, [this.workspaceId]).catch(
10245
11837
  (err) => log7.error("Initial memory sync failed", err.message)
@@ -10249,7 +11841,9 @@ var Runner = class extends import_node_events3.EventEmitter {
10249
11841
  (err) => log7.error("Asset sync failed", err.message)
10250
11842
  );
10251
11843
  await this.ensureDropFolderRuntime(this.workspaceId);
10252
- for (const id of payload.profilesToSync) {
11844
+ const profilesToSync = payload.profilesToSync ?? [];
11845
+ const profilesToDelete = payload.profilesToDelete ?? [];
11846
+ for (const id of profilesToSync) {
10253
11847
  try {
10254
11848
  await this.servicePool.drop(id);
10255
11849
  await this.syncProfileFromCloud(id);
@@ -10257,7 +11851,7 @@ var Runner = class extends import_node_events3.EventEmitter {
10257
11851
  this.emit("sync-error", err);
10258
11852
  }
10259
11853
  }
10260
- for (const id of payload.profilesToDelete ?? []) {
11854
+ for (const id of profilesToDelete) {
10261
11855
  const row = this.db.prepare("SELECT agent_im_user_id FROM agent_profiles WHERE id = ?").get(id);
10262
11856
  this.db.prepare("DELETE FROM agent_profiles WHERE id = ?").run(id);
10263
11857
  if (row) {
@@ -10266,10 +11860,37 @@ var Runner = class extends import_node_events3.EventEmitter {
10266
11860
  process.stderr.write(`[daemon] tombstone profile=${id}
10267
11861
  `);
10268
11862
  }
10269
- if (payload.profilesToDelete?.length) this.loadAgentsFromDb();
10270
- if ((payload.profilesToSync.length > 0 || (payload.profilesToDelete?.length ?? 0) > 0) && this.wsConnected) this.sendDeclare();
11863
+ if (profilesToDelete.length) this.loadAgentsFromDb();
11864
+ if ((ownershipLockChanged || profilesToSync.length > 0 || profilesToDelete.length > 0) && this.wsConnected) {
11865
+ this.sendDeclare();
11866
+ }
10271
11867
  this.emit("host-acked", payload);
10272
11868
  }
11869
+ async applyHostAckOwnershipLock(payload) {
11870
+ const rejected = payload.rejectedAgents ?? [];
11871
+ if (rejected.length === 0) return false;
11872
+ let changed = false;
11873
+ for (const rejection of rejected) {
11874
+ if (!rejection?.imUserId) continue;
11875
+ const agentImUserId = rejection.imUserId;
11876
+ this.rejectedHostedAgentIds.set(agentImUserId, {
11877
+ ...rejection,
11878
+ rejectedAt: Date.now()
11879
+ });
11880
+ const wasHosted = this.hostedAgents.delete(agentImUserId);
11881
+ const profiles = this.db.prepare("SELECT id FROM agent_profiles WHERE agent_im_user_id = ?").all(agentImUserId);
11882
+ const deleted = this.db.prepare("DELETE FROM agents WHERE im_user_id = ?").run(agentImUserId);
11883
+ await Promise.all(profiles.map((profile) => this.servicePool.drop(profile.id)));
11884
+ if (wasHosted || deleted.changes > 0) {
11885
+ changed = true;
11886
+ process.stderr.write(
11887
+ `[daemon] ownership rejected agent=${agentImUserId} reason=${rejection.reason} owner=${rejection.ownerDaemonId ?? "(unknown)"} \u2014 stop declaring locally
11888
+ `
11889
+ );
11890
+ }
11891
+ }
11892
+ return changed;
11893
+ }
10273
11894
  async onTaskDispatch(payload, requestId) {
10274
11895
  const targetDaemonId = readTargetDaemonId(payload);
10275
11896
  if (targetDaemonId && targetDaemonId !== this.config.daemon_id) {
@@ -10421,14 +12042,68 @@ var Runner = class extends import_node_events3.EventEmitter {
10421
12042
  };
10422
12043
  }
10423
12044
  async postMessageDispatchReply(payload) {
10424
- const res = await this.cloud.request("POST", "/api/im/dispatch/reply", {
10425
- auth: false,
10426
- body: payload,
10427
- timeoutMs: 3e4
10428
- });
10429
- if (!res.ok) {
10430
- throw new Error(res.error?.message ?? `dispatch reply failed (${res.status})`);
12045
+ const taskId = `external:${payload.replyToMessageId}`;
12046
+ try {
12047
+ const result = await sendDispatchReplyTwoPhase({
12048
+ taskId,
12049
+ payload: {
12050
+ conversationId: payload.conversationId,
12051
+ replyToken: payload.replyToken,
12052
+ replyToMessageId: payload.replyToMessageId,
12053
+ agentImUserId: payload.agentImUserId,
12054
+ status: payload.status,
12055
+ ...payload.replyText !== void 0 ? { replyText: payload.replyText } : {},
12056
+ ...payload.attachments ? { attachments: payload.attachments } : {},
12057
+ assetIds: payload.attachments?.map((a) => a.assetId).filter((id) => typeof id === "string") ?? [],
12058
+ completedAt: payload.completedAt,
12059
+ ...payload.error ? { error: payload.error } : {}
12060
+ },
12061
+ cloud: this.cloud,
12062
+ cache: this.pendingReplyCache
12063
+ });
12064
+ if (result.status === "aborted") {
12065
+ throw new Error(`dispatch reply aborted by server reaper (replyId=${result.replyId})`);
12066
+ }
12067
+ } catch (err) {
12068
+ throw new Error(
12069
+ err.message?.length ? err.message : `dispatch reply failed`
12070
+ );
12071
+ }
12072
+ }
12073
+ /**
12074
+ * v2.0 §4.8.1 (Wave 4-E4) — submit the transport-probe report. Called
12075
+ * from `onHostAcked` so the daemon's container row exists in cloud
12076
+ * before we POST to /runtime/transport-report.
12077
+ *
12078
+ * The gatewayUrl reported here is `http://<local-ipv4>:<localPort>`
12079
+ * (typically `http://192.168.x.x:3210`). For a daemon without a local
12080
+ * HTTP server (startLocalServer=false in tests) we still post the
12081
+ * probe with `gatewayUrl=null` so cloud knows transport='ws' is the
12082
+ * only path.
12083
+ */
12084
+ async reportTransport() {
12085
+ const hasLocalServer = this.opts.startLocalServer !== false;
12086
+ let gatewayUrl = null;
12087
+ if (hasLocalServer) {
12088
+ const localIp = pickLocalIPv4();
12089
+ if (localIp) {
12090
+ const port = this.opts.localPort ?? DEFAULT_LOCAL_PORT;
12091
+ gatewayUrl = `http://${localIp}:${port}`;
12092
+ }
12093
+ }
12094
+ const probe = buildTransportProbe(gatewayUrl);
12095
+ const result = await reportTransportProbe(this.cloud, this.config.daemon_id, probe);
12096
+ if (!result.ok) {
12097
+ process.stderr.write(
12098
+ `[daemon] transport-probe report rejected status=${result.status} error=${result.error ?? "-"}
12099
+ `
12100
+ );
12101
+ return;
10431
12102
  }
12103
+ process.stdout.write(
12104
+ `[daemon] transport-probe reported transport=${probe.transport} gateway=${probe.gatewayUrl ?? "null"} private=${probe.gatewayIsPrivate}
12105
+ `
12106
+ );
10432
12107
  }
10433
12108
  onAgentChanged(payload) {
10434
12109
  const a = this.hostedAgents.get(payload.agentImUserId);
@@ -10498,18 +12173,22 @@ var Runner = class extends import_node_events3.EventEmitter {
10498
12173
  const name = agent?.card?.name || agent?.displayName || agent?.username || profile.agentImUserId;
10499
12174
  const now = Date.now();
10500
12175
  const tx = this.db.transaction(() => {
10501
- this.db.prepare(
10502
- `INSERT OR REPLACE INTO agents
10503
- (im_user_id, workspace_id, name, adapter_name, capabilities, status, version, synced_at, dirty)
10504
- VALUES (?, ?, ?, ?, ?, 'offline', 1, ?, 0)`
10505
- ).run(
10506
- profile.agentImUserId,
10507
- profile.workspaceId,
10508
- name,
10509
- profile.adapterName,
10510
- JSON.stringify(capabilities),
10511
- now
10512
- );
12176
+ if (this.rejectedHostedAgentIds.has(profile.agentImUserId)) {
12177
+ this.db.prepare("DELETE FROM agents WHERE im_user_id = ?").run(profile.agentImUserId);
12178
+ } else {
12179
+ this.db.prepare(
12180
+ `INSERT OR REPLACE INTO agents
12181
+ (im_user_id, workspace_id, name, adapter_name, capabilities, status, version, synced_at, dirty)
12182
+ VALUES (?, ?, ?, ?, ?, 'offline', 1, ?, 0)`
12183
+ ).run(
12184
+ profile.agentImUserId,
12185
+ profile.workspaceId,
12186
+ name,
12187
+ profile.adapterName,
12188
+ JSON.stringify(capabilities),
12189
+ now
12190
+ );
12191
+ }
10513
12192
  this.db.prepare(
10514
12193
  `INSERT OR REPLACE INTO agent_profiles
10515
12194
  (id, workspace_id, agent_im_user_id, adapter_name, name, config, version, synced_at, dirty, deleted_at)
@@ -10871,9 +12550,9 @@ var Runner = class extends import_node_events3.EventEmitter {
10871
12550
  function resolveUserAssetWorkspaceDir(workspaceId) {
10872
12551
  const override = process.env.PRISMER_ASSET_SYNC_ROOT;
10873
12552
  if (override) return (0, import_node_path11.join)(override, workspaceId);
10874
- const home = (0, import_node_os6.homedir)();
12553
+ const home = (0, import_node_os8.homedir)();
10875
12554
  const desktop = (0, import_node_path11.join)(home, "Desktop");
10876
- const base = (0, import_node_fs15.existsSync)(desktop) ? desktop : home;
12555
+ const base = (0, import_node_fs16.existsSync)(desktop) ? desktop : home;
10877
12556
  return (0, import_node_path11.join)(base, "Prismer Assets", workspaceId);
10878
12557
  }
10879
12558
  function readTargetDaemonId(payload) {
@@ -10950,7 +12629,7 @@ function safeJsonParse(raw) {
10950
12629
 
10951
12630
  // src/pair.ts
10952
12631
  var import_node_crypto10 = require("crypto");
10953
- var import_node_os7 = require("os");
12632
+ var import_node_os9 = require("os");
10954
12633
  var import_promises2 = require("timers/promises");
10955
12634
  var import_qrcode = __toESM(require("qrcode"), 1);
10956
12635
  async function pair(opts) {
@@ -10980,7 +12659,7 @@ async function pair(opts) {
10980
12659
  "/api/im/pair/offer",
10981
12660
  {
10982
12661
  auth: false,
10983
- body: { devicePub, deviceName: opts.deviceName ?? (0, import_node_os7.hostname)() }
12662
+ body: { devicePub, deviceName: opts.deviceName ?? (0, import_node_os9.hostname)() }
10984
12663
  }
10985
12664
  );
10986
12665
  if (!offerRes.ok) {
@@ -11061,8 +12740,8 @@ var import_commander20 = require("commander");
11061
12740
 
11062
12741
  // src/cli/commands/adapter.ts
11063
12742
  var import_node_child_process5 = require("child_process");
11064
- var import_node_fs17 = require("fs");
11065
- var import_node_os8 = require("os");
12743
+ var import_node_fs18 = require("fs");
12744
+ var import_node_os10 = require("os");
11066
12745
  var import_node_path13 = require("path");
11067
12746
  var import_commander = require("commander");
11068
12747
  init_hermes();
@@ -11078,7 +12757,7 @@ var INSTALL_SPECS = {
11078
12757
  binary: "claude",
11079
12758
  hint: "Set ANTHROPIC_API_KEY in your shell profile, or rely on Claude Code OAuth login. Run `claude login` to sign in.",
11080
12759
  authHints: ["ANTHROPIC_API_KEY or Claude Code OAuth login (`claude login`)"],
11081
- hookTarget: { path: (0, import_node_path13.join)((0, import_node_os8.homedir)(), ".claude", "hooks.json"), kind: "json-file" }
12760
+ hookTarget: { path: (0, import_node_path13.join)((0, import_node_os10.homedir)(), ".claude", "hooks.json"), kind: "json-file" }
11082
12761
  },
11083
12762
  openclaw: {
11084
12763
  name: "openclaw",
@@ -11087,7 +12766,7 @@ var INSTALL_SPECS = {
11087
12766
  binary: "openclaw",
11088
12767
  hint: "OpenClaw runs as a gateway. Configure ~/.openclaw/openclaw.json and start it with `openclaw gateway` before tasks dispatch.",
11089
12768
  authHints: ["~/.openclaw/openclaw.json gateway.auth.bearerTokens", "Prismer daemon api_key"],
11090
- hookTarget: { path: (0, import_node_path13.join)((0, import_node_os8.homedir)(), ".openclaw", "hooks"), kind: "directory" }
12769
+ hookTarget: { path: (0, import_node_path13.join)((0, import_node_os10.homedir)(), ".openclaw", "hooks"), kind: "directory" }
11091
12770
  },
11092
12771
  codex: {
11093
12772
  name: "codex",
@@ -11096,7 +12775,7 @@ var INSTALL_SPECS = {
11096
12775
  binary: "codex",
11097
12776
  hint: "Set OPENAI_API_KEY in your shell profile. Verify with `codex --help`.",
11098
12777
  authHints: ["OPENAI_API_KEY or Codex CLI account login"],
11099
- hookTarget: { path: (0, import_node_path13.join)((0, import_node_os8.homedir)(), ".codex", "hooks.json"), kind: "json-file" }
12778
+ hookTarget: { path: (0, import_node_path13.join)((0, import_node_os10.homedir)(), ".codex", "hooks.json"), kind: "json-file" }
11100
12779
  },
11101
12780
  hermes: {
11102
12781
  name: "hermes",
@@ -11105,7 +12784,7 @@ var INSTALL_SPECS = {
11105
12784
  binary: "hermes",
11106
12785
  hint: "Hermes runs as a long-lived HTTP gateway in your own Python venv. Start with `hermes -p <profile> gateway` after configuring ~/.hermes/.env.",
11107
12786
  authHints: ["~/.hermes/.env API_SERVER_KEY", "Hermes profile provider credentials"],
11108
- hookTarget: { path: (0, import_node_path13.join)((0, import_node_os8.homedir)(), ".hermes", "hooks.json"), kind: "json-file" }
12787
+ hookTarget: { path: (0, import_node_path13.join)((0, import_node_os10.homedir)(), ".hermes", "hooks.json"), kind: "json-file" }
11109
12788
  }
11110
12789
  };
11111
12790
  function buildAdapterCommand() {
@@ -11320,15 +12999,15 @@ function runHooks(spec, opts) {
11320
12999
  }
11321
13000
  let backup;
11322
13001
  if (planned.kind === "directory") {
11323
- (0, import_node_fs17.mkdirSync)(planned.path, { recursive: true });
13002
+ (0, import_node_fs18.mkdirSync)(planned.path, { recursive: true });
11324
13003
  const markerPath = (0, import_node_path13.join)(planned.path, "prismer.json");
11325
13004
  backup = backupIfExists(markerPath);
11326
- (0, import_node_fs17.writeFileSync)(markerPath, JSON.stringify(prismerHookMarker(spec), null, 2) + "\n", "utf8");
13005
+ (0, import_node_fs18.writeFileSync)(markerPath, JSON.stringify(prismerHookMarker(spec), null, 2) + "\n", "utf8");
11327
13006
  } else {
11328
- (0, import_node_fs17.mkdirSync)((0, import_node_path13.dirname)(planned.path), { recursive: true });
13007
+ (0, import_node_fs18.mkdirSync)((0, import_node_path13.dirname)(planned.path), { recursive: true });
11329
13008
  backup = backupIfExists(planned.path);
11330
13009
  const merged = mergeHookJson(planned.path, spec);
11331
- (0, import_node_fs17.writeFileSync)(planned.path, JSON.stringify(merged, null, 2) + "\n", "utf8");
13010
+ (0, import_node_fs18.writeFileSync)(planned.path, JSON.stringify(merged, null, 2) + "\n", "utf8");
11332
13011
  }
11333
13012
  return {
11334
13013
  ok: true,
@@ -11395,23 +13074,23 @@ function inspectAuthEnv(spec) {
11395
13074
  hints: spec.authHints ?? []
11396
13075
  };
11397
13076
  case "hermes": {
11398
- const envFile = (0, import_node_path13.join)((0, import_node_os8.homedir)(), ".hermes", ".env");
13077
+ const envFile = (0, import_node_path13.join)((0, import_node_os10.homedir)(), ".hermes", ".env");
11399
13078
  return {
11400
- ok: (0, import_node_fs17.existsSync)(envFile) || Boolean(process.env.HERMES_API_KEY || process.env.API_SERVER_KEY),
13079
+ ok: (0, import_node_fs18.existsSync)(envFile) || Boolean(process.env.HERMES_API_KEY || process.env.API_SERVER_KEY),
11401
13080
  present: [
11402
13081
  ...["HERMES_API_KEY", "API_SERVER_KEY"].filter((k) => Boolean(process.env[k])),
11403
- ...(0, import_node_fs17.existsSync)(envFile) ? [envFile] : []
13082
+ ...(0, import_node_fs18.existsSync)(envFile) ? [envFile] : []
11404
13083
  ],
11405
13084
  hints: spec.authHints ?? []
11406
13085
  };
11407
13086
  }
11408
13087
  case "openclaw": {
11409
- const cfgFile = (0, import_node_path13.join)((0, import_node_os8.homedir)(), ".openclaw", "openclaw.json");
13088
+ const cfgFile = (0, import_node_path13.join)((0, import_node_os10.homedir)(), ".openclaw", "openclaw.json");
11410
13089
  return {
11411
- ok: (0, import_node_fs17.existsSync)(cfgFile) || Boolean(process.env.OPENCLAW_API_KEY),
13090
+ ok: (0, import_node_fs18.existsSync)(cfgFile) || Boolean(process.env.OPENCLAW_API_KEY),
11412
13091
  present: [
11413
13092
  ...["OPENCLAW_API_KEY"].filter((k) => Boolean(process.env[k])),
11414
- ...(0, import_node_fs17.existsSync)(cfgFile) ? [cfgFile] : []
13093
+ ...(0, import_node_fs18.existsSync)(cfgFile) ? [cfgFile] : []
11415
13094
  ],
11416
13095
  hints: spec.authHints ?? []
11417
13096
  };
@@ -11426,16 +13105,16 @@ function inspectHook(spec) {
11426
13105
  return { supported: false, markerPresent: false };
11427
13106
  }
11428
13107
  if (spec.name === "openclaw") {
11429
- const jsonPath = (0, import_node_path13.join)((0, import_node_os8.homedir)(), ".openclaw", "hooks.json");
13108
+ const jsonPath = (0, import_node_path13.join)((0, import_node_os10.homedir)(), ".openclaw", "hooks.json");
11430
13109
  const markerPath = (0, import_node_path13.join)(target.path, "prismer.json");
11431
- const jsonMarkerPresent = (0, import_node_fs17.existsSync)(jsonPath) && fileContainsPrismerMarker(jsonPath);
11432
- const dirMarkerPresent = (0, import_node_fs17.existsSync)(markerPath) && fileContainsPrismerMarker(markerPath);
13110
+ const jsonMarkerPresent = (0, import_node_fs18.existsSync)(jsonPath) && fileContainsPrismerMarker(jsonPath);
13111
+ const dirMarkerPresent = (0, import_node_fs18.existsSync)(markerPath) && fileContainsPrismerMarker(markerPath);
11433
13112
  return {
11434
13113
  supported: true,
11435
13114
  kind: "json-file-or-directory",
11436
13115
  path: target.path,
11437
13116
  jsonPath,
11438
- exists: (0, import_node_fs17.existsSync)(target.path) || (0, import_node_fs17.existsSync)(jsonPath),
13117
+ exists: (0, import_node_fs18.existsSync)(target.path) || (0, import_node_fs18.existsSync)(jsonPath),
11439
13118
  markerPath,
11440
13119
  markerPresent: jsonMarkerPresent || dirMarkerPresent
11441
13120
  };
@@ -11446,12 +13125,12 @@ function inspectHook(spec) {
11446
13125
  supported: true,
11447
13126
  kind: target.kind,
11448
13127
  path: target.path,
11449
- exists: (0, import_node_fs17.existsSync)(target.path),
13128
+ exists: (0, import_node_fs18.existsSync)(target.path),
11450
13129
  markerPath,
11451
- markerPresent: (0, import_node_fs17.existsSync)(markerPath) && fileContainsPrismerMarker(markerPath)
13130
+ markerPresent: (0, import_node_fs18.existsSync)(markerPath) && fileContainsPrismerMarker(markerPath)
11452
13131
  };
11453
13132
  }
11454
- const exists = (0, import_node_fs17.existsSync)(target.path);
13133
+ const exists = (0, import_node_fs18.existsSync)(target.path);
11455
13134
  if (!exists) {
11456
13135
  return {
11457
13136
  supported: true,
@@ -11487,14 +13166,14 @@ function prismerHookMarker(spec) {
11487
13166
  }
11488
13167
  function mergeHookJson(path9, spec) {
11489
13168
  const marker = prismerHookMarker(spec);
11490
- if (!(0, import_node_fs17.existsSync)(path9)) {
13169
+ if (!(0, import_node_fs18.existsSync)(path9)) {
11491
13170
  return {
11492
13171
  prismer: marker
11493
13172
  };
11494
13173
  }
11495
13174
  let parsed;
11496
13175
  try {
11497
- parsed = JSON.parse((0, import_node_fs17.readFileSync)(path9, "utf8"));
13176
+ parsed = JSON.parse((0, import_node_fs18.readFileSync)(path9, "utf8"));
11498
13177
  } catch (err) {
11499
13178
  throw new Error(`Cannot parse ${path9} as JSON: ${err.message}`);
11500
13179
  }
@@ -11518,17 +13197,17 @@ function mergeHookJson(path9, spec) {
11518
13197
  return next;
11519
13198
  }
11520
13199
  function backupIfExists(path9) {
11521
- if (!(0, import_node_fs17.existsSync)(path9)) return null;
13200
+ if (!(0, import_node_fs18.existsSync)(path9)) return null;
11522
13201
  const suffix = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
11523
13202
  const backup = `${path9}.bak.${suffix}`;
11524
- const stat = (0, import_node_fs17.statSync)(path9);
13203
+ const stat = (0, import_node_fs18.statSync)(path9);
11525
13204
  if (stat.isDirectory()) return null;
11526
- (0, import_node_fs17.copyFileSync)(path9, backup);
13205
+ (0, import_node_fs18.copyFileSync)(path9, backup);
11527
13206
  return backup;
11528
13207
  }
11529
13208
  function fileContainsPrismerMarker(path9) {
11530
13209
  try {
11531
- return (0, import_node_fs17.readFileSync)(path9, "utf8").includes("prismer-daemon-runtime");
13210
+ return (0, import_node_fs18.readFileSync)(path9, "utf8").includes("prismer-daemon-runtime");
11532
13211
  } catch {
11533
13212
  return false;
11534
13213
  }
@@ -11888,8 +13567,74 @@ function buildAgentCommand() {
11888
13567
  }
11889
13568
  getUI().ok("Skill install requested", `agent=${opts.agent} skill=${slugOrId}`);
11890
13569
  }, { code: "agent_install_skill_failed" }));
13570
+ 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) => {
13571
+ const cloud = makeCloudClient();
13572
+ const data = await requestEnvelope(cloud, "POST", `/api/im/agents/${encodeURIComponent(imUserId)}/snapshot`, {
13573
+ includeMemory: Boolean(opts.includeMemory),
13574
+ label: opts.label
13575
+ }, "agent_snapshot_failed");
13576
+ if (opts.json) {
13577
+ printJson(data);
13578
+ return;
13579
+ }
13580
+ const rec = data;
13581
+ getUI().ok("Snapshot created", String(rec.id ?? "<unknown>"));
13582
+ getUI().line(`agent=${String(rec.agentImUserId ?? imUserId)} memory=${rec.includeMemory ? "included" : "stripped"}`);
13583
+ }, { code: "agent_snapshot_failed" }));
13584
+ 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) => {
13585
+ const cloud = makeCloudClient();
13586
+ const data = await requestEnvelope(cloud, "POST", `/api/im/agents/${encodeURIComponent(imUserId)}/publish`, {
13587
+ slug: opts.slug,
13588
+ version: opts.version,
13589
+ license: opts.license,
13590
+ metadata: {
13591
+ ...opts.title ? { title: opts.title } : {},
13592
+ ...opts.description ? { description: opts.description } : {}
13593
+ },
13594
+ stripMemory: true
13595
+ }, "agent_publish_failed");
13596
+ if (opts.json) {
13597
+ printJson(data);
13598
+ return;
13599
+ }
13600
+ const rec = data;
13601
+ getUI().ok("Agent Pack published", `${String(rec.slug ?? opts.slug)}@${String(rec.version ?? opts.version ?? "1.0.0")}`);
13602
+ getUI().line(`package=${String(rec.id ?? "<unknown>")}`);
13603
+ }, { code: "agent_publish_failed" }));
13604
+ 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) => {
13605
+ const cloud = makeCloudClient();
13606
+ const data = await requestEnvelope(cloud, "POST", `/api/im/agent-packs/${encodeURIComponent(packIdOrSlug)}/fork`, {
13607
+ targetWorkspaceId: opts.workspaceId,
13608
+ displayName: opts.displayName
13609
+ }, "agent_fork_failed");
13610
+ if (opts.json) {
13611
+ printJson(data);
13612
+ return;
13613
+ }
13614
+ const rec = data;
13615
+ getUI().ok("Agent Pack forked", `newAgent=${String(rec.newImUserId ?? "<unknown>")}`);
13616
+ getUI().line(`did=${String(rec.newDid ?? "not issued")}`);
13617
+ }, { code: "agent_fork_failed" }));
11891
13618
  return cmd;
11892
13619
  }
13620
+ function makeCloudClient() {
13621
+ const cfg = loadConfig(resolvePaths());
13622
+ return new CloudClient({ baseUrl: cfg.cloud_api_base, apiKey: cfg.api_key });
13623
+ }
13624
+ async function requestEnvelope(cloud, method, path9, body, code) {
13625
+ const res = await cloud.request(
13626
+ method,
13627
+ path9,
13628
+ body === void 0 ? void 0 : { body }
13629
+ );
13630
+ if (!res.ok || res.data?.ok === false) {
13631
+ const message = res.error?.message ?? res.data?.error?.message ?? "request failed";
13632
+ exitWithError(`${path9} failed (${res.status === 0 ? "network error" : `HTTP ${res.status}`}): ${message}`, {
13633
+ code: res.error?.code ?? res.data?.error?.code ?? code
13634
+ });
13635
+ }
13636
+ return res.data && typeof res.data === "object" && "data" in res.data ? res.data.data : res.data;
13637
+ }
11893
13638
  function readLocalAgents(localDb) {
11894
13639
  const db = openLocalDb(localDb);
11895
13640
  try {
@@ -11994,7 +13739,7 @@ function whichBinary2(bin) {
11994
13739
  // src/cli/commands/asset.ts
11995
13740
  var import_node_crypto11 = require("crypto");
11996
13741
  var import_commander3 = require("commander");
11997
- var import_node_fs18 = require("fs");
13742
+ var import_node_fs19 = require("fs");
11998
13743
  var import_node_path14 = require("path");
11999
13744
  init_util();
12000
13745
  init_ui();
@@ -12167,8 +13912,8 @@ async function prefetchAssetBytes(opts) {
12167
13912
  return { considered: rows.length, fetched, bytes, failed };
12168
13913
  }
12169
13914
  async function uploadAssetPath(inputPath, opts) {
12170
- if (!(0, import_node_fs18.existsSync)(inputPath)) exitWithError(`path not found: ${inputPath}`);
12171
- const stat = (0, import_node_fs18.lstatSync)(inputPath);
13915
+ if (!(0, import_node_fs19.existsSync)(inputPath)) exitWithError(`path not found: ${inputPath}`);
13916
+ const stat = (0, import_node_fs19.lstatSync)(inputPath);
12172
13917
  if (stat.isSymbolicLink()) exitWithError(`refusing to upload symlink: ${inputPath}`);
12173
13918
  if (stat.isFile()) {
12174
13919
  return uploadAsset(inputPath, { ...opts, folderPath: normalizeFolderPath(opts.folderPath) });
@@ -12186,9 +13931,9 @@ async function uploadAssetPath(inputPath, opts) {
12186
13931
  return { ok: true, data: { count: items.length, items } };
12187
13932
  }
12188
13933
  async function uploadAsset(file, opts) {
12189
- if (!(0, import_node_fs18.existsSync)(file)) exitWithError(`file not found: ${file}`);
13934
+ if (!(0, import_node_fs19.existsSync)(file)) exitWithError(`file not found: ${file}`);
12190
13935
  const cfg = loadConfig(resolvePaths());
12191
- const bytes = (0, import_node_fs18.readFileSync)(file);
13936
+ const bytes = (0, import_node_fs19.readFileSync)(file);
12192
13937
  const contentHash = (0, import_node_crypto11.createHash)("sha256").update(bytes).digest("hex");
12193
13938
  const metadata = parseMetadata(opts.metadata);
12194
13939
  if (opts.taskId && metadata.taskId === void 0) metadata.taskId = opts.taskId;
@@ -12324,10 +14069,10 @@ async function putDirectAssetBytes(plan, bytes, mime) {
12324
14069
  }
12325
14070
  function listFilesRecursive(root) {
12326
14071
  const out = [];
12327
- for (const name of (0, import_node_fs18.readdirSync)(root)) {
14072
+ for (const name of (0, import_node_fs19.readdirSync)(root)) {
12328
14073
  if (name.startsWith(".")) continue;
12329
14074
  const full = (0, import_node_path14.join)(root, name);
12330
- const stat = (0, import_node_fs18.lstatSync)(full);
14075
+ const stat = (0, import_node_fs19.lstatSync)(full);
12331
14076
  if (stat.isSymbolicLink()) continue;
12332
14077
  if (stat.isDirectory()) {
12333
14078
  out.push(...listFilesRecursive(full));
@@ -12361,7 +14106,7 @@ async function downloadAsset(assetId, outPath) {
12361
14106
  exitWithError(`asset download failed (${res.status}): ${errorMessage(body)}`);
12362
14107
  }
12363
14108
  const bytes = Buffer.from(await res.arrayBuffer());
12364
- (0, import_node_fs18.writeFileSync)(outPath, bytes);
14109
+ (0, import_node_fs19.writeFileSync)(outPath, bytes);
12365
14110
  }
12366
14111
  function parseMetadata(raw) {
12367
14112
  if (!raw) return {};
@@ -12760,7 +14505,7 @@ function buildCookbookCommand() {
12760
14505
  for (const suite of suites) {
12761
14506
  checks.push(...await runSuite(suite, cloud, opts));
12762
14507
  }
12763
- const summary = summarize(checks, Boolean(opts.strict));
14508
+ const summary = summarize2(checks, Boolean(opts.strict));
12764
14509
  if (opts.json) {
12765
14510
  printJson(summary);
12766
14511
  } else {
@@ -12925,7 +14670,7 @@ function parseSuites(raw) {
12925
14670
  const expanded = selected.includes("all") ? ["status", "im", "task", "group", "asset", "sandbox"] : selected;
12926
14671
  return [...new Set(expanded)];
12927
14672
  }
12928
- function summarize(checks, strict) {
14673
+ function summarize2(checks, strict) {
12929
14674
  const totals = { pass: 0, fail: 0, skip: 0 };
12930
14675
  for (const check of checks) totals[check.status] += 1;
12931
14676
  return {
@@ -13008,7 +14753,7 @@ function parsePositiveInt3(value) {
13008
14753
  // src/cli/commands/daemon.ts
13009
14754
  var import_commander8 = require("commander");
13010
14755
  var import_node_child_process7 = require("child_process");
13011
- var import_node_fs19 = require("fs");
14756
+ var import_node_fs20 = require("fs");
13012
14757
  var import_promises3 = require("timers/promises");
13013
14758
  var import_node_path15 = require("path");
13014
14759
  init_util();
@@ -13084,9 +14829,9 @@ function buildDaemonCommand() {
13084
14829
  if (existingPid && pidAlive2(existingPid)) {
13085
14830
  exitWithError(`Daemon already running (pid ${existingPid}). Use \`prismer daemon stop\` first.`);
13086
14831
  }
13087
- if (!(0, import_node_fs19.existsSync)(paths.logsDir)) (0, import_node_fs19.mkdirSync)(paths.logsDir, { recursive: true });
14832
+ if (!(0, import_node_fs20.existsSync)(paths.logsDir)) (0, import_node_fs20.mkdirSync)(paths.logsDir, { recursive: true });
13088
14833
  const logFile = (0, import_node_path15.join)(paths.logsDir, "daemon.log");
13089
- const fd = (0, import_node_fs19.openSync)(logFile, "a");
14834
+ const fd = (0, import_node_fs20.openSync)(logFile, "a");
13090
14835
  const args = [process.argv[1], "daemon", "run"];
13091
14836
  if (opts.port) args.push("--port", String(opts.port));
13092
14837
  if (opts.localServer === false) args.push("--no-local-server");
@@ -13170,7 +14915,7 @@ function buildDaemonCommand() {
13170
14915
  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) => {
13171
14916
  const paths = resolvePaths();
13172
14917
  const logFile = (0, import_node_path15.join)(paths.logsDir, "daemon.log");
13173
- if (!(0, import_node_fs19.existsSync)(logFile)) {
14918
+ if (!(0, import_node_fs20.existsSync)(logFile)) {
13174
14919
  exitWithError(`No daemon log found at ${logFile}. Start the daemon with \`prismer daemon start\`.`);
13175
14920
  }
13176
14921
  const lines = Math.max(1, opts.tail);
@@ -13211,13 +14956,13 @@ async function tailFromEnd(path9, lines) {
13211
14956
  }
13212
14957
  }
13213
14958
  async function followFile(path9) {
13214
- let offset = (0, import_node_fs19.statSync)(path9).size;
14959
+ let offset = (0, import_node_fs20.statSync)(path9).size;
13215
14960
  for (; ; ) {
13216
14961
  await (0, import_promises3.setTimeout)(1e3);
13217
- const size = (0, import_node_fs19.statSync)(path9).size;
14962
+ const size = (0, import_node_fs20.statSync)(path9).size;
13218
14963
  if (size < offset) offset = 0;
13219
14964
  if (size === offset) continue;
13220
- const stream = (0, import_node_fs19.createReadStream)(path9, { start: offset, end: size - 1, encoding: "utf8" });
14965
+ const stream = (0, import_node_fs20.createReadStream)(path9, { start: offset, end: size - 1, encoding: "utf8" });
13221
14966
  for await (const chunk of stream) process.stdout.write(chunk);
13222
14967
  offset = size;
13223
14968
  }
@@ -13225,8 +14970,8 @@ async function followFile(path9) {
13225
14970
 
13226
14971
  // src/cli/commands/events.ts
13227
14972
  var import_commander9 = require("commander");
13228
- var import_node_fs20 = require("fs");
13229
- var import_node_os9 = require("os");
14973
+ var import_node_fs21 = require("fs");
14974
+ var import_node_os11 = require("os");
13230
14975
  var import_node_path16 = require("path");
13231
14976
  var import_node_readline = require("readline");
13232
14977
  init_util();
@@ -13234,7 +14979,7 @@ var DEFAULT_LIMIT2 = 50;
13234
14979
  function buildEventsCommand() {
13235
14980
  return addEventOptions(new import_commander9.Command("events").description("Read local PARA events from ~/.prismer/para/events.jsonl")).action(async (opts) => {
13236
14981
  const file = eventsPath();
13237
- if (!(0, import_node_fs20.existsSync)(file)) {
14982
+ if (!(0, import_node_fs21.existsSync)(file)) {
13238
14983
  printJson(unavailable(file));
13239
14984
  process.exitCode = 1;
13240
14985
  return;
@@ -13251,7 +14996,7 @@ function buildEventsCommand() {
13251
14996
  function buildEventsStatsCommand() {
13252
14997
  return addEventOptions(new import_commander9.Command("events:stats").description("Summarize local PARA events from ~/.prismer/para/events.jsonl")).action(async (opts) => {
13253
14998
  const file = eventsPath();
13254
- if (!(0, import_node_fs20.existsSync)(file)) {
14999
+ if (!(0, import_node_fs21.existsSync)(file)) {
13255
15000
  printJson(unavailable(file));
13256
15001
  process.exitCode = 1;
13257
15002
  return;
@@ -13260,7 +15005,7 @@ function buildEventsStatsCommand() {
13260
15005
  const events = await readEvents(file, filters);
13261
15006
  printJson({
13262
15007
  ok: true,
13263
- data: { stats: summarize2(events), count: events.length, limit: filters.limit },
15008
+ data: { stats: summarize3(events), count: events.length, limit: filters.limit },
13264
15009
  checks: { file, exists: true, filters: cleanFilters(filters) }
13265
15010
  });
13266
15011
  });
@@ -13269,7 +15014,7 @@ function addEventOptions(cmd) {
13269
15014
  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)");
13270
15015
  }
13271
15016
  function eventsPath() {
13272
- return (0, import_node_path16.join)(process.env.PRISMER_HOME ?? (0, import_node_path16.join)((0, import_node_os9.homedir)(), ".prismer"), "para", "events.jsonl");
15017
+ return (0, import_node_path16.join)(process.env.PRISMER_HOME ?? (0, import_node_path16.join)((0, import_node_os11.homedir)(), ".prismer"), "para", "events.jsonl");
13273
15018
  }
13274
15019
  function unavailable(file) {
13275
15020
  return {
@@ -13285,7 +15030,7 @@ function unavailable(file) {
13285
15030
  async function readEvents(file, filters) {
13286
15031
  const out = [];
13287
15032
  const rl = (0, import_node_readline.createInterface)({
13288
- input: (0, import_node_fs20.createReadStream)(file, { encoding: "utf8" }),
15033
+ input: (0, import_node_fs21.createReadStream)(file, { encoding: "utf8" }),
13289
15034
  crlfDelay: Infinity
13290
15035
  });
13291
15036
  for await (const line of rl) {
@@ -13317,7 +15062,7 @@ function field(event, names) {
13317
15062
  }
13318
15063
  return void 0;
13319
15064
  }
13320
- function summarize2(events) {
15065
+ function summarize3(events) {
13321
15066
  const byFamily = {};
13322
15067
  const byType = {};
13323
15068
  const byAgentId = {};
@@ -13355,7 +15100,7 @@ function parsePositiveInt4(v) {
13355
15100
  // src/cli/commands/memory.ts
13356
15101
  var import_better_sqlite35 = __toESM(require("better-sqlite3"), 1);
13357
15102
  var import_commander10 = require("commander");
13358
- var import_node_fs21 = require("fs");
15103
+ var import_node_fs22 = require("fs");
13359
15104
  init_util();
13360
15105
  var LOCAL_BASE = process.env.PRISMER_DAEMON_URL ?? "http://127.0.0.1:3210";
13361
15106
  function buildMemoryCommand() {
@@ -13507,7 +15252,7 @@ function readCacheSnapshot(limit) {
13507
15252
  const paths = resolvePaths();
13508
15253
  const empty = {
13509
15254
  dbPath: paths.localDb,
13510
- dbExists: (0, import_node_fs21.existsSync)(paths.localDb),
15255
+ dbExists: (0, import_node_fs22.existsSync)(paths.localDb),
13511
15256
  tables: {
13512
15257
  cached_assets: { exists: false, count: 0, sizeBytes: 0 },
13513
15258
  workspace_files_mirror: { exists: false, count: 0 }
@@ -13671,8 +15416,8 @@ function buildPairCommand() {
13671
15416
 
13672
15417
  // src/cli/commands/profile.ts
13673
15418
  var import_commander12 = require("commander");
13674
- var import_node_fs22 = require("fs");
13675
- var import_node_os10 = require("os");
15419
+ var import_node_fs23 = require("fs");
15420
+ var import_node_os12 = require("os");
13676
15421
  var import_node_path17 = require("path");
13677
15422
  var import_node_child_process8 = require("child_process");
13678
15423
  init_util();
@@ -13721,12 +15466,12 @@ function buildProfileCommand() {
13721
15466
  const profile = await cloud.get(
13722
15467
  `/api/im/agent_profiles/${encodeURIComponent(profileId)}`
13723
15468
  );
13724
- const tmpFile = (0, import_node_path17.join)((0, import_node_os10.tmpdir)(), `prismer-profile-${profileId}.json`);
13725
- (0, import_node_fs22.writeFileSync)(tmpFile, JSON.stringify(profile.config, null, 2), "utf8");
15469
+ const tmpFile = (0, import_node_path17.join)((0, import_node_os12.tmpdir)(), `prismer-profile-${profileId}.json`);
15470
+ (0, import_node_fs23.writeFileSync)(tmpFile, JSON.stringify(profile.config, null, 2), "utf8");
13726
15471
  const editor = process.env.EDITOR || "vi";
13727
15472
  const ed = (0, import_node_child_process8.spawnSync)(editor, [tmpFile], { stdio: "inherit" });
13728
15473
  if (ed.status !== 0) exitWithError(`editor exited ${ed.status}`, { code: "editor_failed" });
13729
- const newConfig = JSON.parse((0, import_node_fs22.readFileSync)(tmpFile, "utf8"));
15474
+ const newConfig = JSON.parse((0, import_node_fs23.readFileSync)(tmpFile, "utf8"));
13730
15475
  const res = await cloud.request("PATCH", `/api/im/agent_profiles/${encodeURIComponent(profileId)}`, {
13731
15476
  body: { config: newConfig, version: profile.version }
13732
15477
  });
@@ -13748,8 +15493,8 @@ function mkCloud4() {
13748
15493
  function readJsonArg(arg) {
13749
15494
  if (arg.startsWith("@")) {
13750
15495
  const path9 = arg.slice(1);
13751
- if (!(0, import_node_fs22.existsSync)(path9)) throw new Error(`File not found: ${path9}`);
13752
- return JSON.parse((0, import_node_fs22.readFileSync)(path9, "utf8"));
15496
+ if (!(0, import_node_fs23.existsSync)(path9)) throw new Error(`File not found: ${path9}`);
15497
+ return JSON.parse((0, import_node_fs23.readFileSync)(path9, "utf8"));
13753
15498
  }
13754
15499
  return JSON.parse(arg);
13755
15500
  }
@@ -13763,8 +15508,8 @@ async function resolveDefaultWorkspaceId(cloud) {
13763
15508
  // src/cli/commands/reset.ts
13764
15509
  var import_commander13 = require("commander");
13765
15510
  var import_node_child_process9 = require("child_process");
13766
- var import_node_fs23 = require("fs");
13767
- var import_node_os11 = require("os");
15511
+ var import_node_fs24 = require("fs");
15512
+ var import_node_os13 = require("os");
13768
15513
  var import_node_path18 = require("path");
13769
15514
  var import_promises4 = require("timers/promises");
13770
15515
  init_util();
@@ -13782,15 +15527,15 @@ function buildResetCommand() {
13782
15527
  const hermesGatewayPids = findHermesGatewayPids2();
13783
15528
  const plan = {
13784
15529
  root: paths.root,
13785
- exists: (0, import_node_fs23.existsSync)(paths.root),
15530
+ exists: (0, import_node_fs24.existsSync)(paths.root),
13786
15531
  runningPid,
13787
15532
  mode: opts.wipe ? "wipe" : "archive",
13788
15533
  archivePath,
13789
15534
  assetSyncRoot,
13790
- assetSyncExists: (0, import_node_fs23.existsSync)(assetSyncRoot),
15535
+ assetSyncExists: (0, import_node_fs24.existsSync)(assetSyncRoot),
13791
15536
  assetSyncArchivePath,
13792
15537
  hermesHome,
13793
- hermesExists: (0, import_node_fs23.existsSync)(hermesHome),
15538
+ hermesExists: (0, import_node_fs24.existsSync)(hermesHome),
13794
15539
  hermesArchivePath,
13795
15540
  hermesGatewayPids,
13796
15541
  willStopDaemon: runningPid !== null,
@@ -13814,40 +15559,40 @@ function buildResetCommand() {
13814
15559
  for (const gatewayPid of hermesGatewayPids) {
13815
15560
  await stopProcess(gatewayPid, opts.timeout ?? 5e3, "Hermes gateway");
13816
15561
  }
13817
- if ((0, import_node_fs23.existsSync)(paths.root)) {
15562
+ if ((0, import_node_fs24.existsSync)(paths.root)) {
13818
15563
  if (opts.wipe) {
13819
- (0, import_node_fs23.rmSync)(paths.root, { recursive: true, force: true });
15564
+ (0, import_node_fs24.rmSync)(paths.root, { recursive: true, force: true });
13820
15565
  } else {
13821
15566
  if (!archivePath) throw new Error("internal: archivePath missing");
13822
- if (!(0, import_node_fs23.existsSync)((0, import_node_path18.dirname)(archivePath))) {
13823
- (0, import_node_fs23.mkdirSync)((0, import_node_path18.dirname)(archivePath), { recursive: true });
15567
+ if (!(0, import_node_fs24.existsSync)((0, import_node_path18.dirname)(archivePath))) {
15568
+ (0, import_node_fs24.mkdirSync)((0, import_node_path18.dirname)(archivePath), { recursive: true });
13824
15569
  }
13825
- (0, import_node_fs23.renameSync)(paths.root, archivePath);
15570
+ (0, import_node_fs24.renameSync)(paths.root, archivePath);
13826
15571
  }
13827
15572
  }
13828
- if ((0, import_node_fs23.existsSync)(assetSyncRoot)) {
15573
+ if ((0, import_node_fs24.existsSync)(assetSyncRoot)) {
13829
15574
  if (opts.wipe) {
13830
- (0, import_node_fs23.rmSync)(assetSyncRoot, { recursive: true, force: true });
15575
+ (0, import_node_fs24.rmSync)(assetSyncRoot, { recursive: true, force: true });
13831
15576
  } else {
13832
15577
  if (!assetSyncArchivePath) throw new Error("internal: assetSyncArchivePath missing");
13833
- if (!(0, import_node_fs23.existsSync)((0, import_node_path18.dirname)(assetSyncArchivePath))) {
13834
- (0, import_node_fs23.mkdirSync)((0, import_node_path18.dirname)(assetSyncArchivePath), { recursive: true });
15578
+ if (!(0, import_node_fs24.existsSync)((0, import_node_path18.dirname)(assetSyncArchivePath))) {
15579
+ (0, import_node_fs24.mkdirSync)((0, import_node_path18.dirname)(assetSyncArchivePath), { recursive: true });
13835
15580
  }
13836
- (0, import_node_fs23.renameSync)(assetSyncRoot, assetSyncArchivePath);
15581
+ (0, import_node_fs24.renameSync)(assetSyncRoot, assetSyncArchivePath);
13837
15582
  }
13838
15583
  }
13839
- if ((0, import_node_fs23.existsSync)(hermesHome)) {
15584
+ if ((0, import_node_fs24.existsSync)(hermesHome)) {
13840
15585
  if (opts.wipe) {
13841
- (0, import_node_fs23.rmSync)(hermesHome, { recursive: true, force: true });
15586
+ (0, import_node_fs24.rmSync)(hermesHome, { recursive: true, force: true });
13842
15587
  } else {
13843
15588
  if (!hermesArchivePath) throw new Error("internal: hermesArchivePath missing");
13844
- if (!(0, import_node_fs23.existsSync)((0, import_node_path18.dirname)(hermesArchivePath))) {
13845
- (0, import_node_fs23.mkdirSync)((0, import_node_path18.dirname)(hermesArchivePath), { recursive: true });
15589
+ if (!(0, import_node_fs24.existsSync)((0, import_node_path18.dirname)(hermesArchivePath))) {
15590
+ (0, import_node_fs24.mkdirSync)((0, import_node_path18.dirname)(hermesArchivePath), { recursive: true });
13846
15591
  }
13847
- (0, import_node_fs23.renameSync)(hermesHome, hermesArchivePath);
15592
+ (0, import_node_fs24.renameSync)(hermesHome, hermesArchivePath);
13848
15593
  }
13849
15594
  }
13850
- (0, import_node_fs23.mkdirSync)(paths.root, { recursive: true });
15595
+ (0, import_node_fs24.mkdirSync)(paths.root, { recursive: true });
13851
15596
  if (opts.json) {
13852
15597
  printJson({ ok: true, reset: true, plan });
13853
15598
  return;
@@ -13916,13 +15661,13 @@ function timestamp() {
13916
15661
  function resolveAssetSyncRoot() {
13917
15662
  const override = process.env.PRISMER_ASSET_SYNC_ROOT;
13918
15663
  if (override) return override;
13919
- const home = (0, import_node_os11.homedir)();
15664
+ const home = (0, import_node_os13.homedir)();
13920
15665
  const desktop = (0, import_node_path18.join)(home, "Desktop");
13921
- const base = (0, import_node_fs23.existsSync)(desktop) ? desktop : home;
15666
+ const base = (0, import_node_fs24.existsSync)(desktop) ? desktop : home;
13922
15667
  return (0, import_node_path18.join)(base, "Prismer Assets");
13923
15668
  }
13924
15669
  function resolveHermesHome() {
13925
- return process.env.HERMES_HOME || (0, import_node_path18.join)((0, import_node_os11.homedir)(), ".hermes");
15670
+ return process.env.HERMES_HOME || (0, import_node_path18.join)((0, import_node_os13.homedir)(), ".hermes");
13926
15671
  }
13927
15672
  function findHermesGatewayPids2() {
13928
15673
  try {
@@ -14152,10 +15897,10 @@ async function safeParseResponse(res) {
14152
15897
 
14153
15898
  // src/cli/commands/setup.ts
14154
15899
  var import_commander15 = require("commander");
14155
- var import_node_os12 = require("os");
15900
+ var import_node_os14 = require("os");
14156
15901
  var import_node_child_process10 = require("child_process");
14157
15902
  var import_node_crypto12 = require("crypto");
14158
- var import_node_fs24 = require("fs");
15903
+ var import_node_fs25 = require("fs");
14159
15904
  var import_node_http2 = require("http");
14160
15905
  init_util();
14161
15906
  init_ui();
@@ -14197,7 +15942,7 @@ function buildSetupCommand() {
14197
15942
  }
14198
15943
  const result = await pair({
14199
15944
  cloudBaseUrl,
14200
- deviceName: opts.deviceName ?? (0, import_node_os12.hostname)(),
15945
+ deviceName: opts.deviceName ?? (0, import_node_os14.hostname)(),
14201
15946
  force: opts.force,
14202
15947
  paths,
14203
15948
  asUserEmail: opts.asUser
@@ -14214,13 +15959,13 @@ function buildSetupCommand() {
14214
15959
  apiKey = await mintDaemonApiKey({
14215
15960
  cloudBaseUrl,
14216
15961
  token: authToken,
14217
- deviceName: opts.deviceName ?? (0, import_node_os12.hostname)()
15962
+ deviceName: opts.deviceName ?? (0, import_node_os14.hostname)()
14218
15963
  });
14219
15964
  }
14220
15965
  if (!apiKey && !authToken && !opts.check && opts.browser !== false) {
14221
15966
  apiKey = await runBrowserSetup({
14222
15967
  cloudBaseUrl,
14223
- deviceName: opts.deviceName ?? (0, import_node_os12.hostname)(),
15968
+ deviceName: opts.deviceName ?? (0, import_node_os14.hostname)(),
14224
15969
  json: Boolean(opts.json)
14225
15970
  });
14226
15971
  }
@@ -14406,9 +16151,9 @@ function shouldArchiveLocalDb(previous, next) {
14406
16151
  return previous.api_key !== next.api_key || previous.cloud_api_base !== next.cloud_api_base || previous.daemon_id !== next.daemon_id;
14407
16152
  }
14408
16153
  function archiveLocalDb(localDbPath) {
14409
- if (!(0, import_node_fs24.existsSync)(localDbPath)) return;
16154
+ if (!(0, import_node_fs25.existsSync)(localDbPath)) return;
14410
16155
  const archived = `${localDbPath}.${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}.bak`;
14411
- (0, import_node_fs24.renameSync)(localDbPath, archived);
16156
+ (0, import_node_fs25.renameSync)(localDbPath, archived);
14412
16157
  }
14413
16158
  async function mintDaemonApiKey(input) {
14414
16159
  const label = `Daemon: ${input.deviceName} ${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}`;
@@ -14477,7 +16222,7 @@ function buildSkillCommand() {
14477
16222
  const cloud = mkCloud6();
14478
16223
  const path9 = opts.agent ? `/api/im/agents/${encodeURIComponent(opts.agent)}/skills` : `/api/im/skills/${encodeURIComponent(slugOrId)}/install`;
14479
16224
  const body = opts.agent ? { skillId: slugOrId, workspaceId: opts.workspaceId, version: opts.version } : { workspaceId: opts.workspaceId, version: opts.version };
14480
- const data = await requestEnvelope(cloud, "POST", path9, body, "skill_install_failed");
16225
+ const data = await requestEnvelope2(cloud, "POST", path9, body, "skill_install_failed");
14481
16226
  if (opts.json) {
14482
16227
  printJson(data);
14483
16228
  return;
@@ -14488,7 +16233,7 @@ function buildSkillCommand() {
14488
16233
  const cloud = mkCloud6();
14489
16234
  const path9 = opts.agent ? `/api/im/agents/${encodeURIComponent(opts.agent)}/skills` : `/api/im/skills/${encodeURIComponent(slugOrId)}/install`;
14490
16235
  const body = opts.agent ? { skillId: slugOrId, workspaceId: opts.workspaceId } : void 0;
14491
- const data = await requestEnvelope(cloud, "DELETE", path9, body, "skill_uninstall_failed");
16236
+ const data = await requestEnvelope2(cloud, "DELETE", path9, body, "skill_uninstall_failed");
14492
16237
  if (opts.json) {
14493
16238
  printJson(data);
14494
16239
  return;
@@ -14541,7 +16286,7 @@ function mkCloud6() {
14541
16286
  const cfg = loadConfig(resolvePaths());
14542
16287
  return new CloudClient({ baseUrl: cfg.cloud_api_base, apiKey: cfg.api_key });
14543
16288
  }
14544
- async function requestEnvelope(cloud, method, path9, body, code) {
16289
+ async function requestEnvelope2(cloud, method, path9, body, code) {
14545
16290
  const res = await cloud.request(method, path9, {
14546
16291
  body
14547
16292
  });
@@ -14660,7 +16405,7 @@ function printSkill(skill, includeContent) {
14660
16405
 
14661
16406
  // src/cli/commands/status.ts
14662
16407
  var import_commander17 = require("commander");
14663
- var import_node_os13 = require("os");
16408
+ var import_node_os15 = require("os");
14664
16409
  init_util();
14665
16410
  init_ui();
14666
16411
  function buildStatusCommand() {
@@ -14691,6 +16436,7 @@ function buildStatusCommand() {
14691
16436
  let me = null;
14692
16437
  let devices = null;
14693
16438
  let agents = null;
16439
+ let diagnose = null;
14694
16440
  try {
14695
16441
  const meRes = await cloud.request("GET", "/api/im/me", { timeoutMs: 3e3 });
14696
16442
  cloudOk = meRes.ok;
@@ -14703,19 +16449,33 @@ function buildStatusCommand() {
14703
16449
  if (Array.isArray(wsList) && wsList.length > 0) {
14704
16450
  const wsId = wsList[0]?.id;
14705
16451
  if (wsId) {
14706
- const devRes = await cloud.request("GET", `/api/workspace/runtime-installations?workspaceId=${encodeURIComponent(wsId)}&includeStopped=false`, { timeoutMs: 3e3 });
14707
- if (devRes.ok) {
14708
- const devBody = devRes.data;
14709
- devices = devBody?.data;
14710
- }
14711
- const agRes = await cloud.request("GET", `/api/im/workspaces/${encodeURIComponent(wsId)}/agents`, { timeoutMs: 3e3 });
14712
- if (agRes.ok) {
14713
- const agBody = agRes.data;
14714
- agents = agBody?.data;
16452
+ const rtRes = await cloud.request("GET", `/api/im/workspaces/${encodeURIComponent(wsId)}/runtime`, { timeoutMs: 3e3 });
16453
+ if (rtRes.ok) {
16454
+ const rtBody = rtRes.data;
16455
+ const snapshot = rtBody?.data;
16456
+ if (snapshot && Array.isArray(snapshot.devices)) {
16457
+ devices = snapshot.devices;
16458
+ agents = snapshot.devices.flatMap((d) => d.agents ?? []);
16459
+ }
14715
16460
  }
14716
16461
  }
14717
16462
  }
14718
16463
  }
16464
+ const daemonId = cfg.daemon_id;
16465
+ if (daemonId) {
16466
+ try {
16467
+ const diagRes = await cloud.request(
16468
+ "GET",
16469
+ `/api/im/runtime/diagnose?daemonId=${encodeURIComponent(daemonId)}`,
16470
+ { timeoutMs: 3e3 }
16471
+ );
16472
+ if (diagRes.ok) {
16473
+ const body = diagRes.data;
16474
+ if (body?.data && typeof body.data === "object") diagnose = body.data;
16475
+ }
16476
+ } catch {
16477
+ }
16478
+ }
14719
16479
  }
14720
16480
  } catch {
14721
16481
  cloudOk = false;
@@ -14732,7 +16492,8 @@ function buildStatusCommand() {
14732
16492
  info: daemonStatus.info ?? {}
14733
16493
  },
14734
16494
  cloud: { base: cfg.cloud_api_base, reachable: cloudOk, me, devices, agents },
14735
- local
16495
+ local,
16496
+ diagnose
14736
16497
  };
14737
16498
  if (opts.json) {
14738
16499
  printJson(report);
@@ -14741,6 +16502,60 @@ function buildStatusCommand() {
14741
16502
  printPretty(report);
14742
16503
  });
14743
16504
  }
16505
+ function computeDeviceIcon(lastSeenAt, now) {
16506
+ if (!lastSeenAt) return "\u25CB";
16507
+ const age = now.getTime() - Date.parse(lastSeenAt);
16508
+ if (!Number.isFinite(age) || age < 0) return "\u25CB";
16509
+ if (age < 5 * 6e4) return "\u25CF";
16510
+ if (age < 60 * 6e4) return "\u25D0";
16511
+ return "\u25CB";
16512
+ }
16513
+ function computeAgentIcon(status, lastHeartbeat, now) {
16514
+ const s = (status || "").toLowerCase();
16515
+ if (s === "error" || s === "failed") return "\u25C6";
16516
+ if (!lastHeartbeat) return s === "offline" ? "\u25CB" : "\u25D0";
16517
+ const age = now.getTime() - Date.parse(lastHeartbeat);
16518
+ if (!Number.isFinite(age) || age < 0 || age >= 60 * 6e4) return "\u25CB";
16519
+ if (age >= 5 * 6e4) return "\u25D0";
16520
+ return "\u25CF";
16521
+ }
16522
+ function pad(value, width) {
16523
+ if (value.length >= width) return value.slice(0, width);
16524
+ return value + " ".repeat(width - value.length);
16525
+ }
16526
+ function formatDeviceBindings(devices, now = /* @__PURE__ */ new Date()) {
16527
+ const lines = [];
16528
+ if (devices.length === 0) {
16529
+ lines.push(" No paired devices.");
16530
+ return lines;
16531
+ }
16532
+ const totalAgents = devices.reduce((s, d) => s + (d.agents?.length ?? 0), 0);
16533
+ const devWord = devices.length === 1 ? "device" : "devices";
16534
+ const agWord = totalAgents === 1 ? "agent" : "agents";
16535
+ lines.push(` Device & Agent Bindings (${devices.length} ${devWord} \xB7 ${totalAgents} ${agWord})`);
16536
+ lines.push("");
16537
+ for (let di = 0; di < devices.length; di++) {
16538
+ const d = devices[di];
16539
+ const dIcon = computeDeviceIcon(d.lastSeenAt, now);
16540
+ const dName = pad(d.name || d.deviceId, 40);
16541
+ const lastSeen = d.lastSeenAt ? formatRelative(d.lastSeenAt, now) : "never seen";
16542
+ lines.push(` ${dIcon} ${dName} ${lastSeen}`);
16543
+ const agents = d.agents ?? [];
16544
+ for (let ai = 0; ai < agents.length; ai++) {
16545
+ const a = agents[ai];
16546
+ const isLast = ai === agents.length - 1;
16547
+ const branch = isLast ? "\u2514\u2500" : "\u251C\u2500";
16548
+ const aIcon = computeAgentIcon(a.status, a.lastHeartbeat, now);
16549
+ const aName = pad(a.name || a.id, 24);
16550
+ const aStatus = pad(a.status || "?", 8);
16551
+ const aVersion = pad(a.version ? `v${a.version}` : "\u2014", 10);
16552
+ const tail = a.currentTaskId ? `task=${a.currentTaskId}` : a.lastHeartbeat ? `\u2764 ${formatRelative(a.lastHeartbeat, now)}` : "";
16553
+ lines.push(` ${branch} ${aIcon} ${aName} ${aStatus} ${aVersion} ${tail}`.trimEnd());
16554
+ }
16555
+ if (di < devices.length - 1) lines.push("");
16556
+ }
16557
+ return lines;
16558
+ }
14744
16559
  async function readDaemonStatus() {
14745
16560
  try {
14746
16561
  const res = await fetch("http://127.0.0.1:3210/healthz", {
@@ -14784,7 +16599,7 @@ function printPretty(report) {
14784
16599
  ui.blank();
14785
16600
  ok("Config", report.paths.config);
14786
16601
  if (report.daemon.running) {
14787
- 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)();
16602
+ 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)();
14788
16603
  const ws = report.daemon.wsConnected ? "connected" : "pending";
14789
16604
  ok("Daemon", `${daemonName} pid=${report.daemon.pid} ws=${ws}`);
14790
16605
  if (report.daemon.info) {
@@ -14813,16 +16628,7 @@ function printPretty(report) {
14813
16628
  if (Array.isArray(report.cloud.devices) && report.cloud.devices.length > 0) {
14814
16629
  const devs = report.cloud.devices;
14815
16630
  ui.blank();
14816
- ui.line(` Workspace Devices (${devs.length}):`);
14817
- for (const d of devs) {
14818
- const statusIcon = d.daemonStatus === "connected" ? "\u25CF" : "\u25CB";
14819
- const kind = d.runtimeKind === "docker" ? "Local" : "K8s";
14820
- const declared = d.hostedAgentSummary?.declared ?? 0;
14821
- ui.line(` ${statusIcon} ${d.podName?.replace(/^daemon:/, "")?.slice(0, 28) ?? "?"} kind=${kind} agents=${declared}`);
14822
- }
14823
- }
14824
- if (Array.isArray(report.cloud.agents) && report.cloud.agents.length > 0) {
14825
- ui.line(` Hosted agents: ${report.cloud.agents.length}`);
16631
+ for (const ln of formatDeviceBindings(devs)) ui.line(ln);
14826
16632
  }
14827
16633
  if (report.local) {
14828
16634
  ui.blank();
@@ -14832,6 +16638,58 @@ function printPretty(report) {
14832
16638
  } else {
14833
16639
  warn("Local DB", "unavailable");
14834
16640
  }
16641
+ if (report.diagnose) {
16642
+ ui.blank();
16643
+ ui.header("Cloud dispatch reachability");
16644
+ ui.blank();
16645
+ printDiagnose(report.diagnose, report.daemon.pid ?? null);
16646
+ }
16647
+ }
16648
+ function printDiagnose(d, pid) {
16649
+ const ui = getUI();
16650
+ const heartbeatLine = d.lastHeartbeatAt ? `last heartbeat ${formatRelative(d.lastHeartbeatAt)}` : "no heartbeat seen";
16651
+ if (d.wsAlive) {
16652
+ ok("Daemon", `running${pid ? ` (pid ${pid})` : ""}`);
16653
+ ok("Cloud WS", `connected (${heartbeatLine})`);
16654
+ } else {
16655
+ warn("Daemon", `running${pid ? ` (pid ${pid})` : ""} \u2014 not visible to cloud WS`);
16656
+ fail2("Cloud WS", `disconnected (${heartbeatLine})`);
16657
+ }
16658
+ if (d.gatewayUrl) {
16659
+ if (d.gatewayIsPrivate) {
16660
+ warn("HTTP gateway", `${d.gatewayUrl} (private IP \u2014 only reachable on same network)`);
16661
+ } else if (d.httpReachable) {
16662
+ ok(
16663
+ "HTTP gateway",
16664
+ `${d.gatewayUrl} (reachable${d.httpLatencyMs != null ? `, ${d.httpLatencyMs}ms` : ""})`
16665
+ );
16666
+ } else {
16667
+ fail2("HTTP gateway", `${d.gatewayUrl} (unreachable${d.httpError ? ` \u2014 ${d.httpError}` : ""})`);
16668
+ }
16669
+ } else {
16670
+ ui.line(" HTTP gateway: not declared (WS-only daemon)");
16671
+ }
16672
+ const activeAgents = /* @__PURE__ */ (() => {
16673
+ return 0;
16674
+ })();
16675
+ if (activeAgents > 0) {
16676
+ ok("Active agents", String(activeAgents));
16677
+ }
16678
+ const recColour = d.recommendedTransport === "ws" ? ok : d.recommendedTransport === "http" ? warn : fail2;
16679
+ recColour("Recommended transport", d.recommendedTransport);
16680
+ if (d.lastProbeAt) {
16681
+ ui.line(` Last transport probe: ${formatRelative(d.lastProbeAt)}`);
16682
+ }
16683
+ }
16684
+ function formatRelative(iso, now = /* @__PURE__ */ new Date()) {
16685
+ const ts = Date.parse(iso);
16686
+ if (!Number.isFinite(ts)) return "unknown";
16687
+ const diff = Math.max(0, now.getTime() - ts);
16688
+ if (diff < 3e4) return "just now";
16689
+ if (diff < 6e4) return `${Math.round(diff / 1e3)}s ago`;
16690
+ if (diff < 36e5) return `${Math.round(diff / 6e4)}m ago`;
16691
+ if (diff < 864e5) return `${Math.round(diff / 36e5)}h ago`;
16692
+ return `${Math.round(diff / 864e5)}d ago`;
14835
16693
  }
14836
16694
 
14837
16695
  // src/cli/commands/task.ts
@@ -15194,7 +17052,7 @@ async function readResponseError(res) {
15194
17052
 
15195
17053
  // src/cli/index.ts
15196
17054
  init_ui();
15197
- var VERSION = "2.0.4";
17055
+ var VERSION = "2.0.6";
15198
17056
  function buildProgram() {
15199
17057
  const program = new import_commander20.Command("prismer").description("Prismer Cloud daemon CLI (TS-only).").version(VERSION);
15200
17058
  program.addCommand(buildBannerCommand());