@prismer/runtime 2.0.4 → 2.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -166,6 +166,42 @@ var init_skill_loader2 = __esm({
166
166
  });
167
167
 
168
168
  // src/adapters/hermes/index.ts
169
+ function readPlainRecord(value) {
170
+ return value && typeof value === "object" && !Array.isArray(value) ? value : null;
171
+ }
172
+ function readNonEmptyString(value) {
173
+ return typeof value === "string" && value.trim() ? value.trim() : null;
174
+ }
175
+ function sanitizeRoleSkillSlug(value) {
176
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 60) || "template";
177
+ }
178
+ function renderRoleTemplateSkill(skillName, agents, roleTemplate) {
179
+ const mcpServers = Array.isArray(roleTemplate?.mcpServers) ? roleTemplate.mcpServers : [];
180
+ const tools = /* @__PURE__ */ new Set();
181
+ for (const server of mcpServers) {
182
+ const rec = readPlainRecord(server);
183
+ const allowlist = Array.isArray(rec?.toolsAllowlist) ? rec.toolsAllowlist : [];
184
+ for (const tool of allowlist) {
185
+ if (typeof tool === "string" && tool.trim()) tools.add(tool.trim());
186
+ }
187
+ }
188
+ const allowedTools = [...tools].join(" ");
189
+ const roleTemplateSlug = JSON.stringify(readNonEmptyString(roleTemplate?.slug) ?? null);
190
+ return `---
191
+ name: ${skillName}
192
+ description: Role-template operating playbook projected from Prismer RoleTemplate.
193
+ ${allowedTools ? `allowed-tools: ${allowedTools}
194
+ ` : ""}metadata:
195
+ prismer:
196
+ source: role-template
197
+ roleTemplateSlug: ${roleTemplateSlug}
198
+ ---
199
+
200
+ # Role Template Playbook
201
+
202
+ ${agents.trim()}
203
+ `;
204
+ }
169
205
  function parsePrismerGoals(value) {
170
206
  if (!Array.isArray(value)) return [];
171
207
  return value.map((entry) => {
@@ -575,13 +611,14 @@ function failure(status, body) {
575
611
  error: { code: "adapter_dispatch_failed", message: `Hermes ${status}: ${body || "<no body>"}` }
576
612
  };
577
613
  }
578
- async function consumeSse(body, task) {
614
+ async function consumeSse(body, task, state) {
579
615
  const reader = body.getReader();
580
616
  const decoder = new TextDecoder();
581
617
  let buf = "";
582
618
  let deltas = "";
583
619
  let finalOutput;
584
620
  let lastProgress = 0;
621
+ let approvalRequested = false;
585
622
  for (; ; ) {
586
623
  const readStart = Date.now();
587
624
  const { value, done } = await reader.read();
@@ -630,6 +667,10 @@ async function consumeSse(body, task) {
630
667
  const next = Math.min(0.99, lastProgress + 0.05);
631
668
  lastProgress = next;
632
669
  const toolName = typeof payload.tool === "string" ? payload.tool : typeof payload.name === "string" ? payload.name : void 0;
670
+ if (eventName === "tool.started" && toolName === APPROVAL_TOOL_NAME) {
671
+ approvalRequested = true;
672
+ if (state) state.approvalRequested = true;
673
+ }
633
674
  const previewStr = typeof payload.preview === "string" ? payload.preview : payload.preview != null ? JSON.stringify(payload.preview).slice(0, 200) : void 0;
634
675
  task.onProgress?.({
635
676
  progress: next,
@@ -659,7 +700,53 @@ async function consumeSse(body, task) {
659
700
  }
660
701
  }
661
702
  }
662
- return finalOutput && finalOutput.length > 0 ? finalOutput : deltas;
703
+ return {
704
+ output: finalOutput && finalOutput.length > 0 ? finalOutput : deltas,
705
+ approvalRequested
706
+ };
707
+ }
708
+ async function consumeChatCompletionsSse(body, task) {
709
+ const reader = body.getReader();
710
+ const decoder = new TextDecoder();
711
+ let buf = "";
712
+ let output = "";
713
+ let lastProgress = 0;
714
+ for (; ; ) {
715
+ const { value, done } = await reader.read();
716
+ if (done) break;
717
+ buf += decoder.decode(value, { stream: true });
718
+ const events = buf.split("\n\n");
719
+ buf = events.pop() ?? "";
720
+ for (const ev of events) {
721
+ const dataLine = ev.match(/^data: (.+)$/m);
722
+ if (!dataLine) continue;
723
+ const raw = dataLine[1].trim();
724
+ if (raw === "[DONE]") continue;
725
+ let payload;
726
+ try {
727
+ payload = JSON.parse(raw);
728
+ } catch (err) {
729
+ process.stderr.write(
730
+ `[hermes-adapter] chat-completions SSE parse skipped: ${err.message}
731
+ `
732
+ );
733
+ continue;
734
+ }
735
+ const delta = payload.choices?.[0]?.delta?.content;
736
+ if (typeof delta === "string") {
737
+ output += delta;
738
+ } else if (Array.isArray(delta)) {
739
+ for (const part of delta) {
740
+ if (part?.type === "text" && typeof part.text === "string") output += part.text;
741
+ }
742
+ }
743
+ if (output.length > 0 && lastProgress < 0.99) {
744
+ lastProgress = Math.min(0.99, lastProgress + 0.05);
745
+ task.onProgress?.({ progress: lastProgress, message: "streaming" });
746
+ }
747
+ }
748
+ }
749
+ return output;
663
750
  }
664
751
  async function checkHealth(baseUrl, apiKey) {
665
752
  try {
@@ -740,7 +827,7 @@ function pidAlive(pid) {
740
827
  function escapeRegExp(value) {
741
828
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
742
829
  }
743
- var import_node_child_process, import_node_fs2, import_node_module, import_node_os, import_node_path2, import_node_url, import_better_sqlite3, YAML, import_zod, import_meta, PRISMER_IM_SKILL_NAME, PRISMER_IM_SKILL_CONTENT, HermesProfileConfigSchema, hermesAdapter, HermesService;
830
+ var import_node_child_process, import_node_fs2, import_node_module, import_node_os, import_node_path2, import_node_url, import_better_sqlite3, YAML, import_zod, import_meta, PRISMER_IM_SKILL_NAME, PRISMER_ROLE_SKILL_PREFIX, PRISMER_IM_SKILL_CONTENT, HermesProfileConfigSchema, hermesAdapter, HermesService, APPROVAL_TOOL_NAME;
744
831
  var init_hermes = __esm({
745
832
  "src/adapters/hermes/index.ts"() {
746
833
  "use strict";
@@ -757,6 +844,7 @@ var init_hermes = __esm({
757
844
  init_skill_loader2();
758
845
  import_meta = {};
759
846
  PRISMER_IM_SKILL_NAME = "prismer-im-collab";
847
+ PRISMER_ROLE_SKILL_PREFIX = "prismer-role";
760
848
  PRISMER_IM_SKILL_CONTENT = `---
761
849
  name: prismer-im-collab
762
850
  description: Coordinate reliably in Prismer conversations, use workspace assets through bounded MCP tools, and keep task work on the board.
@@ -1069,6 +1157,7 @@ No @ needed; the human is the only other party.
1069
1157
  const startedAt = Date.now();
1070
1158
  let runId;
1071
1159
  const sysPrompt = typeof task.metadata?.systemPrompt === "string" ? task.metadata.systemPrompt : void 0;
1160
+ this.installRoleTemplateSkill();
1072
1161
  const skillPrompt = await this.skillLoader.loadSystemPromptFragment().catch((err) => {
1073
1162
  process.stderr.write(
1074
1163
  `[hermes-adapter] failed to read skills for ${this.profileName}: ${err.message}
@@ -1076,7 +1165,21 @@ No @ needed; the human is the only other party.
1076
1165
  );
1077
1166
  return void 0;
1078
1167
  });
1079
- const instructions = [sysPrompt, skillPrompt].filter(Boolean).join("\n\n");
1168
+ const outboxDir = typeof task.metadata?.prismerOutboxDir === "string" ? task.metadata.prismerOutboxDir : void 0;
1169
+ const outboxDirective = outboxDir ? [
1170
+ "[Outbox directive \u2014 MANDATORY, overrides any prior outbox path]",
1171
+ "",
1172
+ "For THIS turn ONLY, your active outbox is:",
1173
+ ` ${outboxDir}`,
1174
+ "",
1175
+ "Any user-deliverable file (PDF, image, CSV, archive, doc) MUST be",
1176
+ "written to that EXACT absolute path. Do NOT reuse the outbox path",
1177
+ "from any previous turn \u2014 those directories no longer accept new",
1178
+ "uploads. Do NOT copy files into the previous outbox; copy them",
1179
+ "into the path above. Files outside this directory will not become",
1180
+ 'chat attachments and the user will see "no files were attached".'
1181
+ ].join("\n") : "";
1182
+ const instructions = [outboxDirective, sysPrompt, skillPrompt].filter(Boolean).join("\n\n");
1080
1183
  if (sysPrompt) {
1081
1184
  try {
1082
1185
  const profileDir = getHermesProfileDir(this.profileName);
@@ -1100,8 +1203,15 @@ No @ needed; the human is the only other party.
1100
1203
  `
1101
1204
  );
1102
1205
  }
1206
+ const sseState = { approvalRequested: false };
1103
1207
  try {
1104
1208
  const nativeBridgePatch = await this.prepareNativeBridgePatch(task);
1209
+ const imageRefs = (task.assetRefs ?? []).filter(
1210
+ (r) => r.mime?.startsWith("image/")
1211
+ );
1212
+ if (imageRefs.length > 0) {
1213
+ return await this.dispatchMultimodalChat(task, instructions, imageRefs, startedAt);
1214
+ }
1105
1215
  const createRes = await fetch(`${this.baseUrl}/v1/runs`, {
1106
1216
  method: "POST",
1107
1217
  headers: {
@@ -1152,10 +1262,10 @@ No @ needed; the human is the only other party.
1152
1262
  }
1153
1263
  };
1154
1264
  }
1155
- const output = await consumeSse(eventsRes.body, task);
1265
+ const sseResult = await consumeSse(eventsRes.body, task, sseState);
1156
1266
  return {
1157
1267
  ok: true,
1158
- output,
1268
+ output: sseResult.output,
1159
1269
  metrics: { durationMs: Date.now() - startedAt },
1160
1270
  metadata: {
1161
1271
  hermes: this.bridgeSnapshot("dispatched", {
@@ -1163,7 +1273,13 @@ No @ needed; the human is the only other party.
1163
1273
  runId,
1164
1274
  baseUrl: this.baseUrl,
1165
1275
  model: this.config.model
1166
- })
1276
+ }),
1277
+ // Surfaced to dispatch.ts so a subsequent reaper kill on this
1278
+ // task is reclassified as `awaiting_human_approval` rather than
1279
+ // `daemon_task_timeout`. Lives under a stable key (not under
1280
+ // `hermes.*`) so non-hermes adapters can adopt the same signal
1281
+ // later without bleeding hermes-specific metadata shape.
1282
+ ...sseResult.approvalRequested ? { approvalRequested: true } : {}
1167
1283
  }
1168
1284
  };
1169
1285
  } catch (err) {
@@ -1173,6 +1289,7 @@ No @ needed; the human is the only other party.
1173
1289
  );
1174
1290
  const categorized = categorizeDispatchError(err, task.signal);
1175
1291
  const bridgeKind = categorized.error?.code === "task_cancelled" ? "cancelled" : "failed";
1292
+ const approvalSignal = sseState.approvalRequested ? { approvalRequested: true } : {};
1176
1293
  return {
1177
1294
  ...categorized,
1178
1295
  metadata: {
@@ -1181,13 +1298,87 @@ No @ needed; the human is the only other party.
1181
1298
  baseUrl: this.baseUrl,
1182
1299
  model: this.config.model,
1183
1300
  ...bridgeKind === "failed" ? { error: err.message } : {}
1184
- })
1301
+ }),
1302
+ ...approvalSignal
1185
1303
  }
1186
1304
  };
1187
1305
  } finally {
1188
1306
  if (runId && this.currentRunId === runId) this.currentRunId = void 0;
1189
1307
  }
1190
1308
  }
1309
+ /**
1310
+ * 14b rev.3 §3.0.4 — Hermes multimodal path.
1311
+ *
1312
+ * Builds an OpenAI-shape chat/completions body where the user message
1313
+ * `content` is a multipart array (`{type:'text'}` + N × `{type:'image_url'}`).
1314
+ * Hermes `_normalize_multimodal_content` (api_server.py:170) collapses the
1315
+ * blocks into its native multimodal representation before routing into
1316
+ * the agent pipeline.
1317
+ *
1318
+ * `image_url.url` is the cdn URL when the daemon's reachability probe said
1319
+ * the upstream LLM can see it (D35 path A); otherwise it's a `data:` URI
1320
+ * carrying the base64 bytes the daemon pre-fetched (D35 path B fallback).
1321
+ * The adapter doesn't decide — `resolveAssetRefs` does, and the adapter
1322
+ * just shuttles `cdnUrl ?? data:base64` into the wire.
1323
+ */
1324
+ async dispatchMultimodalChat(task, instructions, imageRefs, startedAt) {
1325
+ const userContent = [{ type: "text", text: task.prompt }];
1326
+ for (const r of imageRefs) {
1327
+ const url = r.reachable === "cdn" && r.cdnUrl ? r.cdnUrl : r.base64 ? `data:${r.mime ?? "image/png"};base64,${r.base64}` : r.cdnUrl ?? "";
1328
+ if (!url) {
1329
+ userContent.push({
1330
+ type: "text",
1331
+ text: `[image attachment id=${r.assetId} mime=${r.mime ?? "unknown"} unavailable: cdn not reachable and bytes exceeded inline cap]`
1332
+ });
1333
+ continue;
1334
+ }
1335
+ userContent.push({ type: "image_url", image_url: { url, detail: "auto" } });
1336
+ }
1337
+ const messages = [];
1338
+ if (instructions) messages.push({ role: "system", content: instructions });
1339
+ messages.push({ role: "user", content: userContent });
1340
+ const res = await fetch(`${this.baseUrl}/v1/chat/completions`, {
1341
+ method: "POST",
1342
+ headers: {
1343
+ Authorization: `Bearer ${this.config.apiKey}`,
1344
+ "Content-Type": "application/json"
1345
+ },
1346
+ body: JSON.stringify({
1347
+ model: this.config.model,
1348
+ messages,
1349
+ stream: true
1350
+ }),
1351
+ signal: task.signal
1352
+ });
1353
+ if (!res.ok) {
1354
+ const body = await res.text().catch(() => "");
1355
+ return failure(res.status, body);
1356
+ }
1357
+ if (!res.body) {
1358
+ return {
1359
+ ok: false,
1360
+ error: {
1361
+ code: "adapter_dispatch_failed",
1362
+ message: "Hermes /v1/chat/completions returned no body"
1363
+ }
1364
+ };
1365
+ }
1366
+ const output = await consumeChatCompletionsSse(res.body, task);
1367
+ return {
1368
+ ok: true,
1369
+ output,
1370
+ metrics: { durationMs: Date.now() - startedAt },
1371
+ metadata: {
1372
+ hermes: this.bridgeSnapshot("dispatched", {
1373
+ endpoint: "/v1/chat/completions",
1374
+ multimodal: true,
1375
+ imageRefs: imageRefs.length,
1376
+ baseUrl: this.baseUrl,
1377
+ model: this.config.model
1378
+ })
1379
+ }
1380
+ };
1381
+ }
1191
1382
  async shutdown() {
1192
1383
  if (this.currentRunId) await this.stopRun(this.currentRunId);
1193
1384
  }
@@ -1209,6 +1400,25 @@ No @ needed; the human is the only other party.
1209
1400
  ]);
1210
1401
  return { ...kanbanPatch, ...goalsPatch };
1211
1402
  }
1403
+ installRoleTemplateSkill() {
1404
+ const roleTemplate = readPlainRecord(this.config.roleTemplate);
1405
+ const hermesConfig = readPlainRecord(roleTemplate?.hermesConfig);
1406
+ const agents = readNonEmptyString(hermesConfig?.agents);
1407
+ if (!agents) return;
1408
+ const slug = sanitizeRoleSkillSlug(readNonEmptyString(roleTemplate?.slug) ?? "template");
1409
+ const skillName = `${PRISMER_ROLE_SKILL_PREFIX}-${slug}`;
1410
+ const content = renderRoleTemplateSkill(skillName, agents, roleTemplate);
1411
+ try {
1412
+ const skillDir = (0, import_node_path2.join)(this.skillLoader.getSkillsRoot(), skillName);
1413
+ (0, import_node_fs2.mkdirSync)(skillDir, { recursive: true });
1414
+ (0, import_node_fs2.writeFileSync)((0, import_node_path2.join)(skillDir, "SKILL.md"), content, "utf8");
1415
+ } catch (err) {
1416
+ process.stderr.write(
1417
+ `[hermes-adapter] failed to install role-template skill ${skillName} for ${this.profileName}: ${err.message}
1418
+ `
1419
+ );
1420
+ }
1421
+ }
1212
1422
  async prepareNativeKanbanPatch(task) {
1213
1423
  const sourceKind = typeof task.metadata?.sourceKind === "string" ? task.metadata.sourceKind : null;
1214
1424
  const parentTaskId = typeof task.metadata?.parentTaskId === "string" ? task.metadata.parentTaskId : null;
@@ -1317,6 +1527,7 @@ No @ needed; the human is the only other party.
1317
1527
  };
1318
1528
  }
1319
1529
  };
1530
+ APPROVAL_TOOL_NAME = "prismer.approval.request_human_approval";
1320
1531
  }
1321
1532
  });
1322
1533
 
@@ -1348,6 +1559,58 @@ var init_skill_loader3 = __esm({
1348
1559
  });
1349
1560
 
1350
1561
  // src/adapters/openclaw/index.ts
1562
+ function readPlainRecord2(value) {
1563
+ return value && typeof value === "object" && !Array.isArray(value) ? value : null;
1564
+ }
1565
+ function readNonEmptyString2(value) {
1566
+ return typeof value === "string" && value.trim() ? value.trim() : null;
1567
+ }
1568
+ function sanitizeRoleSkillSlug2(value) {
1569
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 60) || "template";
1570
+ }
1571
+ function renderRoleTemplateSkill2(skillName, agents, roleTemplate) {
1572
+ const mcpServers = Array.isArray(roleTemplate?.mcpServers) ? roleTemplate.mcpServers : [];
1573
+ const tools = /* @__PURE__ */ new Set();
1574
+ for (const server of mcpServers) {
1575
+ const rec = readPlainRecord2(server);
1576
+ const allowlist = Array.isArray(rec?.toolsAllowlist) ? rec.toolsAllowlist : [];
1577
+ for (const tool of allowlist) {
1578
+ if (typeof tool === "string" && tool.trim()) tools.add(tool.trim());
1579
+ }
1580
+ }
1581
+ const allowedTools = [...tools].join(" ");
1582
+ const roleTemplateSlug = JSON.stringify(readNonEmptyString2(roleTemplate?.slug) ?? null);
1583
+ return `---
1584
+ name: ${skillName}
1585
+ description: Role-template operating playbook projected from Prismer RoleTemplate.
1586
+ ${allowedTools ? `allowed-tools: ${allowedTools}
1587
+ ` : ""}metadata:
1588
+ prismer:
1589
+ source: role-template
1590
+ roleTemplateSlug: ${roleTemplateSlug}
1591
+ ---
1592
+
1593
+ # Role Template Playbook
1594
+
1595
+ ${agents.trim()}
1596
+ `;
1597
+ }
1598
+ function pickResponsesSource(ref) {
1599
+ if (ref.reachable === "cdn" && ref.cdnUrl) {
1600
+ return { type: "url", url: ref.cdnUrl };
1601
+ }
1602
+ if (ref.base64) {
1603
+ return {
1604
+ type: "base64",
1605
+ data: ref.base64,
1606
+ media_type: ref.mime ?? "application/octet-stream"
1607
+ };
1608
+ }
1609
+ if (ref.cdnUrl) {
1610
+ return { type: "url", url: ref.cdnUrl };
1611
+ }
1612
+ return null;
1613
+ }
1351
1614
  function getOpenClawSkillsDir(profile) {
1352
1615
  const config = OpenClawProfileConfigSchema.parse(profile.config);
1353
1616
  return resolveOpenClawSkillsDir(profile, config);
@@ -1368,15 +1631,17 @@ async function checkHealth2(baseUrl, apiKey) {
1368
1631
  return false;
1369
1632
  }
1370
1633
  }
1371
- var import_zod2, import_node_os2, import_node_path3, OpenClawProfileConfigSchema, openclawAdapter, OpenClawService;
1634
+ var import_zod2, import_node_fs3, import_node_os2, import_node_path3, PRISMER_ROLE_SKILL_PREFIX2, OpenClawProfileConfigSchema, openclawAdapter, OpenClawService;
1372
1635
  var init_openclaw = __esm({
1373
1636
  "src/adapters/openclaw/index.ts"() {
1374
1637
  "use strict";
1375
1638
  import_zod2 = require("zod");
1639
+ import_node_fs3 = require("fs");
1376
1640
  import_node_os2 = require("os");
1377
1641
  import_node_path3 = require("path");
1378
1642
  init_contract();
1379
1643
  init_skill_loader3();
1644
+ PRISMER_ROLE_SKILL_PREFIX2 = "prismer-role";
1380
1645
  OpenClawProfileConfigSchema = import_zod2.z.object({
1381
1646
  /** OpenClaw chat-completions HTTP port. */
1382
1647
  port: import_zod2.z.number().int().min(1).max(65535).default(18789),
@@ -1421,23 +1686,25 @@ var init_openclaw = __esm({
1421
1686
  );
1422
1687
  }
1423
1688
  const skillsDir = resolveOpenClawSkillsDir(profile, config);
1424
- return new OpenClawService(baseUrl, config.apiKey, config.model, new OpenClawSkillLoader(skillsDir));
1689
+ return new OpenClawService(baseUrl, config.apiKey, config.model, config, new OpenClawSkillLoader(skillsDir));
1425
1690
  },
1426
1691
  async health() {
1427
1692
  return { available: true };
1428
1693
  }
1429
1694
  };
1430
1695
  OpenClawService = class {
1431
- constructor(baseUrl, apiKey, model, skillLoader) {
1696
+ constructor(baseUrl, apiKey, model, config, skillLoader) {
1432
1697
  this.baseUrl = baseUrl;
1433
1698
  this.apiKey = apiKey;
1434
1699
  this.model = model;
1700
+ this.config = config;
1435
1701
  this.skillLoader = skillLoader;
1436
1702
  this.id = baseUrl;
1437
1703
  }
1438
1704
  baseUrl;
1439
1705
  apiKey;
1440
1706
  model;
1707
+ config;
1441
1708
  skillLoader;
1442
1709
  id;
1443
1710
  async healthy() {
@@ -1446,17 +1713,47 @@ var init_openclaw = __esm({
1446
1713
  async dispatch(task) {
1447
1714
  const startedAt = Date.now();
1448
1715
  const systemPrompt = typeof task.metadata?.systemPrompt === "string" ? task.metadata.systemPrompt : void 0;
1716
+ this.installRoleTemplateSkill();
1449
1717
  const skillPrompt = await this.skillLoader.loadSystemPromptFragment().catch((err) => {
1450
1718
  process.stderr.write(`[openclaw-adapter] failed to read skills: ${err.message}
1451
1719
  `);
1452
1720
  return void 0;
1453
1721
  });
1454
1722
  const systemContent = [systemPrompt, skillPrompt].filter(Boolean).join("\n\n");
1455
- const messages = [];
1456
- if (systemContent) messages.push({ role: "system", content: systemContent });
1457
- messages.push({ role: "user", content: task.prompt });
1723
+ const refs = task.assetRefs ?? [];
1724
+ const imageRefs = refs.filter((r) => r.mime?.startsWith("image/"));
1725
+ const fileRefs = refs.filter((r) => r.mime && !r.mime.startsWith("image/"));
1726
+ const inputContent = [
1727
+ { type: "input_text", text: task.prompt }
1728
+ ];
1729
+ for (const r of imageRefs) {
1730
+ const source = pickResponsesSource(r);
1731
+ if (!source) {
1732
+ inputContent.push({
1733
+ type: "input_text",
1734
+ text: `[image attachment id=${r.assetId} unavailable: cdn unreachable and bytes exceeded inline cap]`
1735
+ });
1736
+ continue;
1737
+ }
1738
+ inputContent.push({ type: "input_image", source });
1739
+ }
1740
+ for (const r of fileRefs) {
1741
+ const source = pickResponsesSource(r);
1742
+ if (!source) {
1743
+ inputContent.push({
1744
+ type: "input_text",
1745
+ text: `[file attachment id=${r.assetId} mime=${r.mime ?? "unknown"} unavailable]`
1746
+ });
1747
+ continue;
1748
+ }
1749
+ if (r.filename && source.type === "base64") {
1750
+ source.filename = r.filename;
1751
+ }
1752
+ inputContent.push({ type: "input_file", source });
1753
+ }
1754
+ const input = [{ type: "message", role: "user", content: inputContent }];
1458
1755
  try {
1459
- const res = await fetch(`${this.baseUrl}/v1/chat/completions`, {
1756
+ const res = await fetch(`${this.baseUrl}/v1/responses`, {
1460
1757
  method: "POST",
1461
1758
  headers: {
1462
1759
  Authorization: `Bearer ${this.apiKey}`,
@@ -1464,7 +1761,11 @@ var init_openclaw = __esm({
1464
1761
  },
1465
1762
  body: JSON.stringify({
1466
1763
  model: this.model,
1467
- messages,
1764
+ input,
1765
+ // OpenClaw `/v1/responses` accepts top-level `instructions` for
1766
+ // the system prompt — keeping it out of the user message
1767
+ // preserves cleanish channel-context separation.
1768
+ ...systemContent ? { instructions: systemContent } : {},
1468
1769
  stream: false
1469
1770
  }),
1470
1771
  signal: task.signal
@@ -1480,7 +1781,21 @@ var init_openclaw = __esm({
1480
1781
  };
1481
1782
  }
1482
1783
  const json = await res.json();
1483
- let output = json.choices?.[0]?.message?.content ?? "";
1784
+ let output = "";
1785
+ if (Array.isArray(json.output)) {
1786
+ for (const item of json.output) {
1787
+ if (item?.type === "message" && Array.isArray(item.content)) {
1788
+ for (const part of item.content) {
1789
+ if (part?.type === "output_text" && typeof part.text === "string") {
1790
+ output += part.text;
1791
+ }
1792
+ }
1793
+ }
1794
+ }
1795
+ }
1796
+ if (!output) {
1797
+ output = json.choices?.[0]?.message?.content ?? "";
1798
+ }
1484
1799
  const ERR_PREFIX_RE = /^Cannot read properties of undefined \(reading '[^']+'\)\s*\n+/;
1485
1800
  if (ERR_PREFIX_RE.test(output)) {
1486
1801
  process.stderr.write(
@@ -1498,6 +1813,25 @@ var init_openclaw = __esm({
1498
1813
  return categorizeDispatchError(err, task.signal);
1499
1814
  }
1500
1815
  }
1816
+ installRoleTemplateSkill() {
1817
+ const roleTemplate = readPlainRecord2(this.config.roleTemplate);
1818
+ const openclawConfig = readPlainRecord2(roleTemplate?.openclawConfig);
1819
+ const agents = readNonEmptyString2(openclawConfig?.agents);
1820
+ if (!agents) return;
1821
+ const slug = sanitizeRoleSkillSlug2(readNonEmptyString2(roleTemplate?.slug) ?? "template");
1822
+ const skillName = `${PRISMER_ROLE_SKILL_PREFIX2}-${slug}`;
1823
+ const content = renderRoleTemplateSkill2(skillName, agents, roleTemplate);
1824
+ try {
1825
+ const skillDir = (0, import_node_path3.join)(this.skillLoader.getSkillsRoot(), skillName);
1826
+ (0, import_node_fs3.mkdirSync)(skillDir, { recursive: true });
1827
+ (0, import_node_fs3.writeFileSync)((0, import_node_path3.join)(skillDir, "SKILL.md"), content, "utf8");
1828
+ } catch (err) {
1829
+ process.stderr.write(
1830
+ `[openclaw-adapter] failed to install role-template skill ${skillName}: ${err.message}
1831
+ `
1832
+ );
1833
+ }
1834
+ }
1501
1835
  };
1502
1836
  }
1503
1837
  });
@@ -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.5",
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
@@ -9520,6 +10659,352 @@ ${input.stderr}` : null
9520
10659
 
9521
10660
  // src/daemon/runner.ts
9522
10661
  init_skill_sync();
10662
+
10663
+ // src/daemon/pending-reply-cache.ts
10664
+ function fromRaw(r) {
10665
+ return {
10666
+ idempotencyKey: r.idempotency_key,
10667
+ taskId: r.task_id,
10668
+ replyId: r.reply_id,
10669
+ status: r.status,
10670
+ payloadJson: r.payload_json,
10671
+ createdAt: r.created_at,
10672
+ preparedAt: r.prepared_at,
10673
+ lastAttemptAt: r.last_attempt_at,
10674
+ attemptCount: r.attempt_count,
10675
+ lastError: r.last_error
10676
+ };
10677
+ }
10678
+ function createPendingReplyCache(db) {
10679
+ const insert = db.prepare(
10680
+ `INSERT OR REPLACE INTO pending_dispatch_replies
10681
+ (idempotency_key, task_id, reply_id, status, payload_json,
10682
+ prepared_at, created_at, last_attempt_at, attempt_count, last_error)
10683
+ VALUES (?, ?, NULL, 'pending', ?, NULL, ?, NULL, 0, NULL)`
10684
+ );
10685
+ const markPreparedStmt = db.prepare(
10686
+ `UPDATE pending_dispatch_replies
10687
+ SET reply_id = ?, status = 'prepared', prepared_at = ?,
10688
+ last_attempt_at = ?, attempt_count = attempt_count + 1,
10689
+ last_error = NULL
10690
+ WHERE idempotency_key = ?`
10691
+ );
10692
+ const markAttemptStmt = db.prepare(
10693
+ `UPDATE pending_dispatch_replies
10694
+ SET last_attempt_at = ?, attempt_count = attempt_count + 1,
10695
+ last_error = ?
10696
+ WHERE idempotency_key = ?`
10697
+ );
10698
+ const clearStmt = db.prepare(
10699
+ `DELETE FROM pending_dispatch_replies WHERE idempotency_key = ?`
10700
+ );
10701
+ const listRecoveryStmt = db.prepare(
10702
+ `SELECT * FROM pending_dispatch_replies
10703
+ WHERE status IN ('pending', 'prepared')
10704
+ ORDER BY created_at ASC`
10705
+ );
10706
+ const countStmt = db.prepare(
10707
+ `SELECT status, COUNT(*) as cnt FROM pending_dispatch_replies
10708
+ WHERE status IN ('pending','prepared') GROUP BY status`
10709
+ );
10710
+ const findStmt = db.prepare(
10711
+ `SELECT * FROM pending_dispatch_replies WHERE idempotency_key = ?`
10712
+ );
10713
+ return {
10714
+ savePending(idempotencyKey, taskId, payloadJson, now = Date.now()) {
10715
+ insert.run(idempotencyKey, taskId, payloadJson, now);
10716
+ },
10717
+ markPrepared(idempotencyKey, replyId, now = Date.now()) {
10718
+ markPreparedStmt.run(replyId, now, now, idempotencyKey);
10719
+ },
10720
+ markAttempt(idempotencyKey, now = Date.now(), error) {
10721
+ markAttemptStmt.run(now, error ?? null, idempotencyKey);
10722
+ },
10723
+ clearPending(idempotencyKey) {
10724
+ clearStmt.run(idempotencyKey);
10725
+ },
10726
+ listForRecovery() {
10727
+ const rows = listRecoveryStmt.all();
10728
+ return rows.map(fromRaw);
10729
+ },
10730
+ countByStatus() {
10731
+ const rows = countStmt.all();
10732
+ const result = { pending: 0, prepared: 0 };
10733
+ for (const r of rows) {
10734
+ if (r.status === "pending") result.pending = r.cnt;
10735
+ else if (r.status === "prepared") result.prepared = r.cnt;
10736
+ }
10737
+ return result;
10738
+ },
10739
+ findByIdempotencyKey(idempotencyKey) {
10740
+ const row = findStmt.get(idempotencyKey);
10741
+ return row ? fromRaw(row) : null;
10742
+ }
10743
+ };
10744
+ }
10745
+ function generateIdempotencyKey() {
10746
+ const c = globalThis.crypto;
10747
+ if (c?.randomUUID) return c.randomUUID();
10748
+ const bytes = new Uint8Array(16);
10749
+ const cryptoNode = globalThis.crypto;
10750
+ if (cryptoNode?.getRandomValues) {
10751
+ cryptoNode.getRandomValues(bytes);
10752
+ } else {
10753
+ for (let i = 0; i < 16; i += 1) bytes[i] = Math.floor(Math.random() * 256);
10754
+ }
10755
+ bytes[6] = bytes[6] & 15 | 64;
10756
+ bytes[8] = bytes[8] & 63 | 128;
10757
+ const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
10758
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
10759
+ }
10760
+
10761
+ // src/daemon/dispatch-reply-transport.ts
10762
+ var DispatchReplyAbortedError = class extends Error {
10763
+ constructor(replyId) {
10764
+ super(`dispatch reply ${replyId} aborted by server reaper (likely >1h since prepare)`);
10765
+ this.replyId = replyId;
10766
+ this.name = "DispatchReplyAbortedError";
10767
+ }
10768
+ replyId;
10769
+ code = "DISPATCH_REPLY_INVALID_STATE";
10770
+ };
10771
+ function defaultLog2(line) {
10772
+ process.stderr.write(`${line}
10773
+ `);
10774
+ }
10775
+ async function sendDispatchReplyTwoPhase(args) {
10776
+ const { taskId, payload, cloud, cache } = args;
10777
+ const log8 = args.log ?? defaultLog2;
10778
+ const idempotencyKey = args.idempotencyKey ?? generateIdempotencyKey();
10779
+ const payloadJson = JSON.stringify({ ...payload, _taskId: taskId, _idempotencyKey: idempotencyKey });
10780
+ cache.savePending(idempotencyKey, taskId, payloadJson);
10781
+ const prepareRes = await cloud.request("POST", `/api/im/dispatch/${encodeURIComponent(taskId)}/prepare`, {
10782
+ body: { ...payload, idempotencyKey },
10783
+ timeoutMs: 3e4
10784
+ });
10785
+ if (!prepareRes.ok || !prepareRes.data) {
10786
+ const errCode = prepareRes.error?.code ?? "unknown";
10787
+ const errMsg = prepareRes.error?.message ?? `prepare failed (status=${prepareRes.status})`;
10788
+ cache.markAttempt(idempotencyKey, Date.now(), `prepare: ${errCode}: ${errMsg}`);
10789
+ throw new Error(`dispatch prepare failed: ${errCode}: ${errMsg}`);
10790
+ }
10791
+ const envelope2 = prepareRes.data;
10792
+ const inner = "data" in envelope2 && envelope2.data ? envelope2.data : envelope2;
10793
+ if (!inner || typeof inner.replyId !== "string") {
10794
+ cache.markAttempt(idempotencyKey, Date.now(), "prepare: missing replyId in response");
10795
+ throw new Error("dispatch prepare returned no replyId");
10796
+ }
10797
+ if ("ok" in envelope2 && envelope2.ok === false) {
10798
+ const errCode = envelope2.error?.code ?? "prepare_failed";
10799
+ const errMsg = envelope2.error?.message ?? "prepare ok=false";
10800
+ cache.markAttempt(idempotencyKey, Date.now(), `prepare: ${errCode}: ${errMsg}`);
10801
+ throw new Error(`dispatch prepare failed: ${errCode}: ${errMsg}`);
10802
+ }
10803
+ cache.markPrepared(idempotencyKey, inner.replyId);
10804
+ log8(
10805
+ `[daemon] dispatch.prepare task=${taskId} replyId=${inner.replyId} status=${inner.status} idem=${idempotencyKey}`
10806
+ );
10807
+ if (inner.status === "committed") {
10808
+ cache.clearPending(idempotencyKey);
10809
+ return {
10810
+ taskId,
10811
+ replyId: inner.replyId,
10812
+ status: "committed",
10813
+ alreadyCommitted: true
10814
+ };
10815
+ }
10816
+ try {
10817
+ const result = await commitDispatchReply({ taskId, replyId: inner.replyId, cloud, log: log8 });
10818
+ cache.clearPending(idempotencyKey);
10819
+ return { taskId, replyId: inner.replyId, ...result };
10820
+ } catch (err) {
10821
+ if (err instanceof DispatchReplyAbortedError) {
10822
+ cache.clearPending(idempotencyKey);
10823
+ log8(`[daemon] dispatch.commit aborted task=${taskId} replyId=${inner.replyId} \u2014 dispatch lost`);
10824
+ return {
10825
+ taskId,
10826
+ replyId: inner.replyId,
10827
+ status: "aborted",
10828
+ alreadyCommitted: false
10829
+ };
10830
+ }
10831
+ cache.markAttempt(idempotencyKey, Date.now(), `commit: ${err.message}`);
10832
+ throw err;
10833
+ }
10834
+ }
10835
+ async function commitDispatchReply(args) {
10836
+ const log8 = args.log ?? defaultLog2;
10837
+ const res = await args.cloud.request("POST", `/api/im/dispatch/${encodeURIComponent(args.taskId)}/commit`, {
10838
+ body: { replyId: args.replyId },
10839
+ timeoutMs: 3e4
10840
+ });
10841
+ if (!res.ok || !res.data) {
10842
+ const code = res.error?.code ?? "unknown";
10843
+ const msg = res.error?.message ?? `commit failed (status=${res.status})`;
10844
+ if (code === "DISPATCH_REPLY_INVALID_STATE") {
10845
+ throw new DispatchReplyAbortedError(args.replyId);
10846
+ }
10847
+ throw new Error(`dispatch commit failed: ${code}: ${msg}`);
10848
+ }
10849
+ const env = res.data;
10850
+ const inner = "data" in env && env.data ? env.data : env;
10851
+ if ("ok" in env && env.ok === false) {
10852
+ const code = env.error?.code ?? "commit_failed";
10853
+ if (code === "DISPATCH_REPLY_INVALID_STATE") {
10854
+ throw new DispatchReplyAbortedError(args.replyId);
10855
+ }
10856
+ throw new Error(`dispatch commit failed: ${code}: ${env.error?.message ?? "commit ok=false"}`);
10857
+ }
10858
+ log8(
10859
+ `[daemon] dispatch.commit task=${args.taskId} replyId=${args.replyId} alreadyCommitted=${inner.alreadyCommitted ?? false}`
10860
+ );
10861
+ return {
10862
+ status: "committed",
10863
+ alreadyCommitted: Boolean(inner.alreadyCommitted),
10864
+ ...inner.messageId ? { messageId: inner.messageId } : {}
10865
+ };
10866
+ }
10867
+ async function recoverPendingReplies(deps) {
10868
+ const log8 = deps.log ?? defaultLog2;
10869
+ const rows = deps.cache.listForRecovery();
10870
+ if (rows.length === 0) {
10871
+ return { attempted: 0, committed: 0, aborted: 0, failed: 0 };
10872
+ }
10873
+ log8(`[daemon] dispatch.recovery: ${rows.length} pending row(s) to replay`);
10874
+ let committed = 0;
10875
+ let aborted = 0;
10876
+ let failed = 0;
10877
+ for (const row of rows) {
10878
+ try {
10879
+ const result = await replayRow(row, deps);
10880
+ if (result.status === "committed") committed += 1;
10881
+ else if (result.status === "aborted") aborted += 1;
10882
+ } catch (err) {
10883
+ failed += 1;
10884
+ log8(
10885
+ `[daemon] dispatch.recovery FAIL idem=${row.idempotencyKey} task=${row.taskId}: ${err.message}`
10886
+ );
10887
+ }
10888
+ }
10889
+ log8(
10890
+ `[daemon] dispatch.recovery done attempted=${rows.length} committed=${committed} aborted=${aborted} failed=${failed}`
10891
+ );
10892
+ return { attempted: rows.length, committed, aborted, failed };
10893
+ }
10894
+ async function replayRow(row, deps) {
10895
+ const log8 = deps.log ?? defaultLog2;
10896
+ if (row.status === "prepared" && row.replyId) {
10897
+ try {
10898
+ const result2 = await commitDispatchReply({
10899
+ taskId: row.taskId,
10900
+ replyId: row.replyId,
10901
+ cloud: deps.cloud,
10902
+ log: log8
10903
+ });
10904
+ deps.cache.clearPending(row.idempotencyKey);
10905
+ return { status: result2.status };
10906
+ } catch (err) {
10907
+ if (err instanceof DispatchReplyAbortedError) {
10908
+ deps.cache.clearPending(row.idempotencyKey);
10909
+ return { status: "aborted" };
10910
+ }
10911
+ throw err;
10912
+ }
10913
+ }
10914
+ let parsed;
10915
+ try {
10916
+ parsed = JSON.parse(row.payloadJson);
10917
+ } catch (err) {
10918
+ deps.cache.clearPending(row.idempotencyKey);
10919
+ throw new Error(`pending row ${row.idempotencyKey} has unparseable payload: ${err.message}`);
10920
+ }
10921
+ const { _taskId, _idempotencyKey, ...payload } = parsed;
10922
+ const result = await sendDispatchReplyTwoPhase({
10923
+ taskId: row.taskId,
10924
+ payload,
10925
+ cloud: deps.cloud,
10926
+ cache: deps.cache,
10927
+ idempotencyKey: row.idempotencyKey,
10928
+ log: deps.log
10929
+ });
10930
+ return { status: result.status };
10931
+ }
10932
+
10933
+ // src/daemon/transport-probe.ts
10934
+ var import_node_os7 = require("os");
10935
+ function isPrivateAddress(addr) {
10936
+ if (!addr) return true;
10937
+ const h = String(addr).trim().toLowerCase();
10938
+ if (!h) return true;
10939
+ if (h === "localhost" || h === "::1") return true;
10940
+ const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(h);
10941
+ if (m) {
10942
+ const a = Number(m[1]);
10943
+ const b = Number(m[2]);
10944
+ if (a === 10) return true;
10945
+ if (a === 127) return true;
10946
+ if (a === 169 && b === 254) return true;
10947
+ if (a === 172 && b >= 16 && b <= 31) return true;
10948
+ if (a === 192 && b === 168) return true;
10949
+ if (a === 100 && b >= 64 && b <= 127) return true;
10950
+ return false;
10951
+ }
10952
+ if (h.endsWith(".local")) return true;
10953
+ if (h.endsWith(".localhost")) return true;
10954
+ return false;
10955
+ }
10956
+ function buildTransportProbe(gatewayUrl) {
10957
+ if (!gatewayUrl) {
10958
+ return { transport: "ws", gatewayUrl: null, gatewayIsPrivate: true };
10959
+ }
10960
+ let host = null;
10961
+ try {
10962
+ host = new URL(gatewayUrl).hostname;
10963
+ } catch {
10964
+ host = null;
10965
+ }
10966
+ const gatewayIsPrivate = isPrivateAddress(host);
10967
+ return {
10968
+ transport: gatewayIsPrivate ? "ws" : "both",
10969
+ gatewayUrl,
10970
+ gatewayIsPrivate
10971
+ };
10972
+ }
10973
+ function pickLocalIPv4() {
10974
+ const nics = (0, import_node_os7.networkInterfaces)();
10975
+ let firstPrivate = null;
10976
+ for (const list of Object.values(nics)) {
10977
+ if (!list) continue;
10978
+ for (const ni of list) {
10979
+ if (ni.internal || ni.family !== "IPv4") continue;
10980
+ if (!isPrivateAddress(ni.address)) return ni.address;
10981
+ if (!firstPrivate) firstPrivate = ni.address;
10982
+ }
10983
+ }
10984
+ return firstPrivate;
10985
+ }
10986
+ async function reportTransportProbe(cloud, daemonId, probe) {
10987
+ const body = {
10988
+ daemonId,
10989
+ transport: probe.transport,
10990
+ gatewayUrl: probe.gatewayUrl,
10991
+ gatewayIsPrivate: probe.gatewayIsPrivate
10992
+ };
10993
+ const res = await cloud.request("POST", "/api/im/runtime/transport-report", {
10994
+ body,
10995
+ timeoutMs: 5e3
10996
+ });
10997
+ if (!res.ok) {
10998
+ return {
10999
+ ok: false,
11000
+ status: res.status,
11001
+ error: res.error?.message ?? `transport-report failed (${res.status})`
11002
+ };
11003
+ }
11004
+ return { ok: true, status: res.status };
11005
+ }
11006
+
11007
+ // src/daemon/runner.ts
9523
11008
  var import_meta2 = {};
9524
11009
  var DEFAULT_LOCAL_PORT = 3210;
9525
11010
  var log7 = createLogger("Daemon");
@@ -9568,6 +11053,14 @@ var Runner = class extends import_node_events3.EventEmitter {
9568
11053
  // F16 (2026-05-20) — periodic skill resync timer (default 10min).
9569
11054
  skillResyncTimer;
9570
11055
  skillSyncInFlight = false;
11056
+ // Wave-4 E7 — local cache of in-flight two-phase dispatch replies. Survives
11057
+ // daemon crash so cold-start can resume `prepared` rows via idempotent
11058
+ // commit (server enforces (taskId, idempotencyKey) UNIQUE).
11059
+ pendingReplyCache;
11060
+ // One-shot guard so we only recover on the first post-authenticated tick,
11061
+ // not every reconnect (server-side commits are idempotent but the log noise
11062
+ // and DB pressure of re-running on every redeclare is wasteful).
11063
+ pendingReplyRecoveryDone = false;
9571
11064
  async start() {
9572
11065
  if (this.state !== "idle") throw new Error(`Runner already in state ${this.state}`);
9573
11066
  this.state = "starting";
@@ -9582,6 +11075,7 @@ var Runner = class extends import_node_events3.EventEmitter {
9582
11075
  process.env.PRISMER_API_KEY = this.config.api_key;
9583
11076
  this.db = openLocalDb(this.paths.localDb);
9584
11077
  this.cloud = new CloudClient({ baseUrl: this.config.cloud_api_base, apiKey: this.config.api_key });
11078
+ this.pendingReplyCache = createPendingReplyCache(this.db);
9585
11079
  this.assetCache = new AssetCache({
9586
11080
  db: this.db,
9587
11081
  cloud: this.cloud,
@@ -9908,19 +11402,19 @@ var Runner = class extends import_node_events3.EventEmitter {
9908
11402
  sampleCgroupResources() {
9909
11403
  const baseline = { cpu: { usagePct: 0 }, mem: { usedBytes: 0, limitBytes: 0 } };
9910
11404
  try {
9911
- if (!(0, import_node_fs15.existsSync)("/sys/fs/cgroup/memory.current")) {
11405
+ if (!(0, import_node_fs16.existsSync)("/sys/fs/cgroup/memory.current")) {
9912
11406
  return baseline;
9913
11407
  }
9914
- const memUsedRaw = (0, import_node_fs15.readFileSync)("/sys/fs/cgroup/memory.current", "utf-8").trim();
11408
+ const memUsedRaw = (0, import_node_fs16.readFileSync)("/sys/fs/cgroup/memory.current", "utf-8").trim();
9915
11409
  const memUsed = Number.parseInt(memUsedRaw, 10);
9916
11410
  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();
11411
+ if ((0, import_node_fs16.existsSync)("/sys/fs/cgroup/memory.max")) {
11412
+ const limitRaw = (0, import_node_fs16.readFileSync)("/sys/fs/cgroup/memory.max", "utf-8").trim();
9919
11413
  memLimit = limitRaw === "max" ? 0 : Number.parseInt(limitRaw, 10) || 0;
9920
11414
  }
9921
11415
  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");
11416
+ if ((0, import_node_fs16.existsSync)("/sys/fs/cgroup/cpu.stat")) {
11417
+ const statLines = (0, import_node_fs16.readFileSync)("/sys/fs/cgroup/cpu.stat", "utf-8").split("\n");
9924
11418
  const usecLine = statLines.find((line) => line.startsWith("usage_usec "));
9925
11419
  if (usecLine) {
9926
11420
  const usec = Number.parseInt(usecLine.split(" ")[1] ?? "0", 10);
@@ -10098,10 +11592,10 @@ var Runner = class extends import_node_events3.EventEmitter {
10098
11592
  const rawJson = process.env.PRISMER_HOSTED_AGENT_JSON;
10099
11593
  let raw;
10100
11594
  if (rawFile) {
10101
- if (!(0, import_node_fs15.existsSync)(rawFile)) {
11595
+ if (!(0, import_node_fs16.existsSync)(rawFile)) {
10102
11596
  throw new Error(`PRISMER_HOSTED_AGENT_FILE not found: ${rawFile}`);
10103
11597
  }
10104
- raw = (0, import_node_fs15.readFileSync)(rawFile, "utf8");
11598
+ raw = (0, import_node_fs16.readFileSync)(rawFile, "utf8");
10105
11599
  } else if (rawJson) {
10106
11600
  raw = rawJson;
10107
11601
  }
@@ -10188,6 +11682,19 @@ var Runner = class extends import_node_events3.EventEmitter {
10188
11682
  process.stdout.write(`[daemon] ws authenticated, sending agent.host.declare (${this.hostedAgents.size} agents)
10189
11683
  `);
10190
11684
  this.sendDeclare();
11685
+ if (!this.pendingReplyRecoveryDone) {
11686
+ this.pendingReplyRecoveryDone = true;
11687
+ void recoverPendingReplies({ cloud: this.cloud, cache: this.pendingReplyCache }).then((summary) => {
11688
+ if (summary.attempted === 0) return;
11689
+ process.stdout.write(
11690
+ `[daemon] dispatch.recovery summary attempted=${summary.attempted} committed=${summary.committed} aborted=${summary.aborted} failed=${summary.failed}
11691
+ `
11692
+ );
11693
+ }).catch((err) => {
11694
+ process.stderr.write(`[daemon] dispatch.recovery threw: ${err.message}
11695
+ `);
11696
+ });
11697
+ }
10191
11698
  return;
10192
11699
  }
10193
11700
  this.handleIncoming(msg);
@@ -10197,7 +11704,7 @@ var Runner = class extends import_node_events3.EventEmitter {
10197
11704
  const payload = {
10198
11705
  daemonId: this.config.daemon_id,
10199
11706
  daemonVersion: this.opts.daemonVersion ?? "0.0.0",
10200
- platform: (0, import_node_os6.platform)() === "win32" ? "win32" : (0, import_node_os6.platform)() === "linux" ? "linux" : "darwin",
11707
+ platform: (0, import_node_os8.platform)() === "win32" ? "win32" : (0, import_node_os8.platform)() === "linux" ? "linux" : "darwin",
10201
11708
  agents: Array.from(this.hostedAgents.values()).map((a) => ({
10202
11709
  imUserId: a.imUserId,
10203
11710
  name: a.name,
@@ -10234,12 +11741,90 @@ var Runner = class extends import_node_events3.EventEmitter {
10234
11741
  case "asset.changed":
10235
11742
  void this.onAssetChanged(msg.payload);
10236
11743
  return;
11744
+ // v2.0 §4.8.1 (Wave 4-E4) — webhook reverse-channel RPC. Cloud's
11745
+ // dispatchExternalMention pushes an AgentDispatchRequest plus an
11746
+ // embedded `_rpcId`; the daemon runs the same handler as the
11747
+ // HTTP /dispatch path and echoes the reply back over WS with the
11748
+ // identical `_rpcId` so cloud's WsRpcService can settle the
11749
+ // pending promise.
11750
+ case "webhook.dispatch.request":
11751
+ void this.onWebhookDispatch(msg.payload);
11752
+ return;
10237
11753
  default:
10238
11754
  this.emit("unknown-message", msg);
10239
11755
  }
10240
11756
  }
11757
+ /**
11758
+ * v2.0 §4.8.1 — webhook dispatch over WS reverse channel.
11759
+ *
11760
+ * Acks via `webhook.dispatch.reply { _rpcId, ok, acceptedAt }` immediately
11761
+ * after `handleAgentMessageDispatch()` returns its synchronous response
11762
+ * — the actual reply (the agent's reply text/attachments) still flows
11763
+ * through `postMessageDispatchReply()` which posts to
11764
+ * `/api/im/dispatch/reply` after the adapter finishes. That mirrors the
11765
+ * HTTP /dispatch contract: ack first, asynchronous final reply via the
11766
+ * dispatch-reply REST endpoint.
11767
+ *
11768
+ * The `_rpcId` field is the only addition versus the HTTP path; we strip
11769
+ * it before passing the payload to the dispatch handler so existing
11770
+ * validation logic doesn't trip on an unknown field.
11771
+ */
11772
+ async onWebhookDispatch(payload) {
11773
+ const rpcId = payload._rpcId;
11774
+ if (!rpcId) {
11775
+ process.stderr.write(`[daemon] webhook.dispatch.request without _rpcId \u2014 dropped
11776
+ `);
11777
+ return;
11778
+ }
11779
+ const { _rpcId: _, ...request } = payload;
11780
+ try {
11781
+ const handle = handleAgentMessageDispatch(request, {
11782
+ findAgent: (agentImUserId) => this.findMessageDispatchAgent(agentImUserId),
11783
+ postReply: (replyPayload) => this.postMessageDispatchReply(replyPayload),
11784
+ onError: (err, req) => {
11785
+ process.stderr.write(
11786
+ `[daemon] webhook.dispatch.request failed message=${req.messageId} agent=${req.mentionedAgentImUserId}: ${err instanceof Error ? err.stack ?? err.message : String(err)}
11787
+ `
11788
+ );
11789
+ }
11790
+ });
11791
+ this.ws.send({
11792
+ type: "webhook.dispatch.reply",
11793
+ payload: {
11794
+ _rpcId: rpcId,
11795
+ ok: handle.response.ok,
11796
+ acceptedAt: handle.response.acceptedAt,
11797
+ error: handle.response.error
11798
+ }
11799
+ });
11800
+ void handle.done.catch((err) => {
11801
+ process.stderr.write(
11802
+ `[daemon] webhook.dispatch final reply failed message=${request.messageId}: ${err instanceof Error ? err.message : String(err)}
11803
+ `
11804
+ );
11805
+ });
11806
+ } catch (err) {
11807
+ this.ws.send({
11808
+ type: "webhook.dispatch.reply",
11809
+ payload: {
11810
+ _rpcId: rpcId,
11811
+ ok: false,
11812
+ error: {
11813
+ code: "daemon_dispatch_failed",
11814
+ message: err instanceof Error ? err.message : String(err)
11815
+ }
11816
+ }
11817
+ });
11818
+ }
11819
+ }
10241
11820
  async onHostAcked(payload) {
10242
11821
  this.workspaceId = payload.workspaceId;
11822
+ void this.reportTransport().catch((err) => {
11823
+ process.stderr.write(
11824
+ `[daemon] transport-probe report failed: ${err.message}
11825
+ `
11826
+ );
11827
+ });
10243
11828
  if (this.memoryWiring && this.workspaceId) {
10244
11829
  syncMemoryFromCloud(this.memoryWiring, this.cloud, [this.workspaceId]).catch(
10245
11830
  (err) => log7.error("Initial memory sync failed", err.message)
@@ -10421,14 +12006,68 @@ var Runner = class extends import_node_events3.EventEmitter {
10421
12006
  };
10422
12007
  }
10423
12008
  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})`);
12009
+ const taskId = `external:${payload.replyToMessageId}`;
12010
+ try {
12011
+ const result = await sendDispatchReplyTwoPhase({
12012
+ taskId,
12013
+ payload: {
12014
+ conversationId: payload.conversationId,
12015
+ replyToken: payload.replyToken,
12016
+ replyToMessageId: payload.replyToMessageId,
12017
+ agentImUserId: payload.agentImUserId,
12018
+ status: payload.status,
12019
+ ...payload.replyText !== void 0 ? { replyText: payload.replyText } : {},
12020
+ ...payload.attachments ? { attachments: payload.attachments } : {},
12021
+ assetIds: payload.attachments?.map((a) => a.assetId).filter((id) => typeof id === "string") ?? [],
12022
+ completedAt: payload.completedAt,
12023
+ ...payload.error ? { error: payload.error } : {}
12024
+ },
12025
+ cloud: this.cloud,
12026
+ cache: this.pendingReplyCache
12027
+ });
12028
+ if (result.status === "aborted") {
12029
+ throw new Error(`dispatch reply aborted by server reaper (replyId=${result.replyId})`);
12030
+ }
12031
+ } catch (err) {
12032
+ throw new Error(
12033
+ err.message?.length ? err.message : `dispatch reply failed`
12034
+ );
12035
+ }
12036
+ }
12037
+ /**
12038
+ * v2.0 §4.8.1 (Wave 4-E4) — submit the transport-probe report. Called
12039
+ * from `onHostAcked` so the daemon's container row exists in cloud
12040
+ * before we POST to /runtime/transport-report.
12041
+ *
12042
+ * The gatewayUrl reported here is `http://<local-ipv4>:<localPort>`
12043
+ * (typically `http://192.168.x.x:3210`). For a daemon without a local
12044
+ * HTTP server (startLocalServer=false in tests) we still post the
12045
+ * probe with `gatewayUrl=null` so cloud knows transport='ws' is the
12046
+ * only path.
12047
+ */
12048
+ async reportTransport() {
12049
+ const hasLocalServer = this.opts.startLocalServer !== false;
12050
+ let gatewayUrl = null;
12051
+ if (hasLocalServer) {
12052
+ const localIp = pickLocalIPv4();
12053
+ if (localIp) {
12054
+ const port = this.opts.localPort ?? DEFAULT_LOCAL_PORT;
12055
+ gatewayUrl = `http://${localIp}:${port}`;
12056
+ }
12057
+ }
12058
+ const probe = buildTransportProbe(gatewayUrl);
12059
+ const result = await reportTransportProbe(this.cloud, this.config.daemon_id, probe);
12060
+ if (!result.ok) {
12061
+ process.stderr.write(
12062
+ `[daemon] transport-probe report rejected status=${result.status} error=${result.error ?? "-"}
12063
+ `
12064
+ );
12065
+ return;
10431
12066
  }
12067
+ process.stdout.write(
12068
+ `[daemon] transport-probe reported transport=${probe.transport} gateway=${probe.gatewayUrl ?? "null"} private=${probe.gatewayIsPrivate}
12069
+ `
12070
+ );
10432
12071
  }
10433
12072
  onAgentChanged(payload) {
10434
12073
  const a = this.hostedAgents.get(payload.agentImUserId);
@@ -10871,9 +12510,9 @@ var Runner = class extends import_node_events3.EventEmitter {
10871
12510
  function resolveUserAssetWorkspaceDir(workspaceId) {
10872
12511
  const override = process.env.PRISMER_ASSET_SYNC_ROOT;
10873
12512
  if (override) return (0, import_node_path11.join)(override, workspaceId);
10874
- const home = (0, import_node_os6.homedir)();
12513
+ const home = (0, import_node_os8.homedir)();
10875
12514
  const desktop = (0, import_node_path11.join)(home, "Desktop");
10876
- const base = (0, import_node_fs15.existsSync)(desktop) ? desktop : home;
12515
+ const base = (0, import_node_fs16.existsSync)(desktop) ? desktop : home;
10877
12516
  return (0, import_node_path11.join)(base, "Prismer Assets", workspaceId);
10878
12517
  }
10879
12518
  function readTargetDaemonId(payload) {
@@ -10950,7 +12589,7 @@ function safeJsonParse(raw) {
10950
12589
 
10951
12590
  // src/pair.ts
10952
12591
  var import_node_crypto10 = require("crypto");
10953
- var import_node_os7 = require("os");
12592
+ var import_node_os9 = require("os");
10954
12593
  var import_promises2 = require("timers/promises");
10955
12594
  var import_qrcode = __toESM(require("qrcode"), 1);
10956
12595
  async function pair(opts) {
@@ -10980,7 +12619,7 @@ async function pair(opts) {
10980
12619
  "/api/im/pair/offer",
10981
12620
  {
10982
12621
  auth: false,
10983
- body: { devicePub, deviceName: opts.deviceName ?? (0, import_node_os7.hostname)() }
12622
+ body: { devicePub, deviceName: opts.deviceName ?? (0, import_node_os9.hostname)() }
10984
12623
  }
10985
12624
  );
10986
12625
  if (!offerRes.ok) {
@@ -11061,8 +12700,8 @@ var import_commander20 = require("commander");
11061
12700
 
11062
12701
  // src/cli/commands/adapter.ts
11063
12702
  var import_node_child_process5 = require("child_process");
11064
- var import_node_fs17 = require("fs");
11065
- var import_node_os8 = require("os");
12703
+ var import_node_fs18 = require("fs");
12704
+ var import_node_os10 = require("os");
11066
12705
  var import_node_path13 = require("path");
11067
12706
  var import_commander = require("commander");
11068
12707
  init_hermes();
@@ -11078,7 +12717,7 @@ var INSTALL_SPECS = {
11078
12717
  binary: "claude",
11079
12718
  hint: "Set ANTHROPIC_API_KEY in your shell profile, or rely on Claude Code OAuth login. Run `claude login` to sign in.",
11080
12719
  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" }
12720
+ hookTarget: { path: (0, import_node_path13.join)((0, import_node_os10.homedir)(), ".claude", "hooks.json"), kind: "json-file" }
11082
12721
  },
11083
12722
  openclaw: {
11084
12723
  name: "openclaw",
@@ -11087,7 +12726,7 @@ var INSTALL_SPECS = {
11087
12726
  binary: "openclaw",
11088
12727
  hint: "OpenClaw runs as a gateway. Configure ~/.openclaw/openclaw.json and start it with `openclaw gateway` before tasks dispatch.",
11089
12728
  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" }
12729
+ hookTarget: { path: (0, import_node_path13.join)((0, import_node_os10.homedir)(), ".openclaw", "hooks"), kind: "directory" }
11091
12730
  },
11092
12731
  codex: {
11093
12732
  name: "codex",
@@ -11096,7 +12735,7 @@ var INSTALL_SPECS = {
11096
12735
  binary: "codex",
11097
12736
  hint: "Set OPENAI_API_KEY in your shell profile. Verify with `codex --help`.",
11098
12737
  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" }
12738
+ hookTarget: { path: (0, import_node_path13.join)((0, import_node_os10.homedir)(), ".codex", "hooks.json"), kind: "json-file" }
11100
12739
  },
11101
12740
  hermes: {
11102
12741
  name: "hermes",
@@ -11105,7 +12744,7 @@ var INSTALL_SPECS = {
11105
12744
  binary: "hermes",
11106
12745
  hint: "Hermes runs as a long-lived HTTP gateway in your own Python venv. Start with `hermes -p <profile> gateway` after configuring ~/.hermes/.env.",
11107
12746
  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" }
12747
+ hookTarget: { path: (0, import_node_path13.join)((0, import_node_os10.homedir)(), ".hermes", "hooks.json"), kind: "json-file" }
11109
12748
  }
11110
12749
  };
11111
12750
  function buildAdapterCommand() {
@@ -11320,15 +12959,15 @@ function runHooks(spec, opts) {
11320
12959
  }
11321
12960
  let backup;
11322
12961
  if (planned.kind === "directory") {
11323
- (0, import_node_fs17.mkdirSync)(planned.path, { recursive: true });
12962
+ (0, import_node_fs18.mkdirSync)(planned.path, { recursive: true });
11324
12963
  const markerPath = (0, import_node_path13.join)(planned.path, "prismer.json");
11325
12964
  backup = backupIfExists(markerPath);
11326
- (0, import_node_fs17.writeFileSync)(markerPath, JSON.stringify(prismerHookMarker(spec), null, 2) + "\n", "utf8");
12965
+ (0, import_node_fs18.writeFileSync)(markerPath, JSON.stringify(prismerHookMarker(spec), null, 2) + "\n", "utf8");
11327
12966
  } else {
11328
- (0, import_node_fs17.mkdirSync)((0, import_node_path13.dirname)(planned.path), { recursive: true });
12967
+ (0, import_node_fs18.mkdirSync)((0, import_node_path13.dirname)(planned.path), { recursive: true });
11329
12968
  backup = backupIfExists(planned.path);
11330
12969
  const merged = mergeHookJson(planned.path, spec);
11331
- (0, import_node_fs17.writeFileSync)(planned.path, JSON.stringify(merged, null, 2) + "\n", "utf8");
12970
+ (0, import_node_fs18.writeFileSync)(planned.path, JSON.stringify(merged, null, 2) + "\n", "utf8");
11332
12971
  }
11333
12972
  return {
11334
12973
  ok: true,
@@ -11395,23 +13034,23 @@ function inspectAuthEnv(spec) {
11395
13034
  hints: spec.authHints ?? []
11396
13035
  };
11397
13036
  case "hermes": {
11398
- const envFile = (0, import_node_path13.join)((0, import_node_os8.homedir)(), ".hermes", ".env");
13037
+ const envFile = (0, import_node_path13.join)((0, import_node_os10.homedir)(), ".hermes", ".env");
11399
13038
  return {
11400
- ok: (0, import_node_fs17.existsSync)(envFile) || Boolean(process.env.HERMES_API_KEY || process.env.API_SERVER_KEY),
13039
+ ok: (0, import_node_fs18.existsSync)(envFile) || Boolean(process.env.HERMES_API_KEY || process.env.API_SERVER_KEY),
11401
13040
  present: [
11402
13041
  ...["HERMES_API_KEY", "API_SERVER_KEY"].filter((k) => Boolean(process.env[k])),
11403
- ...(0, import_node_fs17.existsSync)(envFile) ? [envFile] : []
13042
+ ...(0, import_node_fs18.existsSync)(envFile) ? [envFile] : []
11404
13043
  ],
11405
13044
  hints: spec.authHints ?? []
11406
13045
  };
11407
13046
  }
11408
13047
  case "openclaw": {
11409
- const cfgFile = (0, import_node_path13.join)((0, import_node_os8.homedir)(), ".openclaw", "openclaw.json");
13048
+ const cfgFile = (0, import_node_path13.join)((0, import_node_os10.homedir)(), ".openclaw", "openclaw.json");
11410
13049
  return {
11411
- ok: (0, import_node_fs17.existsSync)(cfgFile) || Boolean(process.env.OPENCLAW_API_KEY),
13050
+ ok: (0, import_node_fs18.existsSync)(cfgFile) || Boolean(process.env.OPENCLAW_API_KEY),
11412
13051
  present: [
11413
13052
  ...["OPENCLAW_API_KEY"].filter((k) => Boolean(process.env[k])),
11414
- ...(0, import_node_fs17.existsSync)(cfgFile) ? [cfgFile] : []
13053
+ ...(0, import_node_fs18.existsSync)(cfgFile) ? [cfgFile] : []
11415
13054
  ],
11416
13055
  hints: spec.authHints ?? []
11417
13056
  };
@@ -11426,16 +13065,16 @@ function inspectHook(spec) {
11426
13065
  return { supported: false, markerPresent: false };
11427
13066
  }
11428
13067
  if (spec.name === "openclaw") {
11429
- const jsonPath = (0, import_node_path13.join)((0, import_node_os8.homedir)(), ".openclaw", "hooks.json");
13068
+ const jsonPath = (0, import_node_path13.join)((0, import_node_os10.homedir)(), ".openclaw", "hooks.json");
11430
13069
  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);
13070
+ const jsonMarkerPresent = (0, import_node_fs18.existsSync)(jsonPath) && fileContainsPrismerMarker(jsonPath);
13071
+ const dirMarkerPresent = (0, import_node_fs18.existsSync)(markerPath) && fileContainsPrismerMarker(markerPath);
11433
13072
  return {
11434
13073
  supported: true,
11435
13074
  kind: "json-file-or-directory",
11436
13075
  path: target.path,
11437
13076
  jsonPath,
11438
- exists: (0, import_node_fs17.existsSync)(target.path) || (0, import_node_fs17.existsSync)(jsonPath),
13077
+ exists: (0, import_node_fs18.existsSync)(target.path) || (0, import_node_fs18.existsSync)(jsonPath),
11439
13078
  markerPath,
11440
13079
  markerPresent: jsonMarkerPresent || dirMarkerPresent
11441
13080
  };
@@ -11446,12 +13085,12 @@ function inspectHook(spec) {
11446
13085
  supported: true,
11447
13086
  kind: target.kind,
11448
13087
  path: target.path,
11449
- exists: (0, import_node_fs17.existsSync)(target.path),
13088
+ exists: (0, import_node_fs18.existsSync)(target.path),
11450
13089
  markerPath,
11451
- markerPresent: (0, import_node_fs17.existsSync)(markerPath) && fileContainsPrismerMarker(markerPath)
13090
+ markerPresent: (0, import_node_fs18.existsSync)(markerPath) && fileContainsPrismerMarker(markerPath)
11452
13091
  };
11453
13092
  }
11454
- const exists = (0, import_node_fs17.existsSync)(target.path);
13093
+ const exists = (0, import_node_fs18.existsSync)(target.path);
11455
13094
  if (!exists) {
11456
13095
  return {
11457
13096
  supported: true,
@@ -11487,14 +13126,14 @@ function prismerHookMarker(spec) {
11487
13126
  }
11488
13127
  function mergeHookJson(path9, spec) {
11489
13128
  const marker = prismerHookMarker(spec);
11490
- if (!(0, import_node_fs17.existsSync)(path9)) {
13129
+ if (!(0, import_node_fs18.existsSync)(path9)) {
11491
13130
  return {
11492
13131
  prismer: marker
11493
13132
  };
11494
13133
  }
11495
13134
  let parsed;
11496
13135
  try {
11497
- parsed = JSON.parse((0, import_node_fs17.readFileSync)(path9, "utf8"));
13136
+ parsed = JSON.parse((0, import_node_fs18.readFileSync)(path9, "utf8"));
11498
13137
  } catch (err) {
11499
13138
  throw new Error(`Cannot parse ${path9} as JSON: ${err.message}`);
11500
13139
  }
@@ -11518,17 +13157,17 @@ function mergeHookJson(path9, spec) {
11518
13157
  return next;
11519
13158
  }
11520
13159
  function backupIfExists(path9) {
11521
- if (!(0, import_node_fs17.existsSync)(path9)) return null;
13160
+ if (!(0, import_node_fs18.existsSync)(path9)) return null;
11522
13161
  const suffix = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
11523
13162
  const backup = `${path9}.bak.${suffix}`;
11524
- const stat = (0, import_node_fs17.statSync)(path9);
13163
+ const stat = (0, import_node_fs18.statSync)(path9);
11525
13164
  if (stat.isDirectory()) return null;
11526
- (0, import_node_fs17.copyFileSync)(path9, backup);
13165
+ (0, import_node_fs18.copyFileSync)(path9, backup);
11527
13166
  return backup;
11528
13167
  }
11529
13168
  function fileContainsPrismerMarker(path9) {
11530
13169
  try {
11531
- return (0, import_node_fs17.readFileSync)(path9, "utf8").includes("prismer-daemon-runtime");
13170
+ return (0, import_node_fs18.readFileSync)(path9, "utf8").includes("prismer-daemon-runtime");
11532
13171
  } catch {
11533
13172
  return false;
11534
13173
  }
@@ -11888,8 +13527,74 @@ function buildAgentCommand() {
11888
13527
  }
11889
13528
  getUI().ok("Skill install requested", `agent=${opts.agent} skill=${slugOrId}`);
11890
13529
  }, { code: "agent_install_skill_failed" }));
13530
+ cmd.command("snapshot <imUserId>").description("Create an agent-level snapshot").option("--include-memory", "Include memory dump reference").option("--label <label>", "Snapshot label").option("--json", "Output JSON").action(runAction(async (imUserId, opts) => {
13531
+ const cloud = makeCloudClient();
13532
+ const data = await requestEnvelope(cloud, "POST", `/api/im/agents/${encodeURIComponent(imUserId)}/snapshot`, {
13533
+ includeMemory: Boolean(opts.includeMemory),
13534
+ label: opts.label
13535
+ }, "agent_snapshot_failed");
13536
+ if (opts.json) {
13537
+ printJson(data);
13538
+ return;
13539
+ }
13540
+ const rec = data;
13541
+ getUI().ok("Snapshot created", String(rec.id ?? "<unknown>"));
13542
+ getUI().line(`agent=${String(rec.agentImUserId ?? imUserId)} memory=${rec.includeMemory ? "included" : "stripped"}`);
13543
+ }, { code: "agent_snapshot_failed" }));
13544
+ cmd.command("publish <imUserId>").description("Publish an agent as an Agent Pack").requiredOption("--slug <slug>", "Agent Pack slug").option("--version <version>", "Package version", "1.0.0").option("--license <license>", "Package license", "proprietary").option("--title <title>", "Display title metadata").option("--description <description>", "Description metadata").option("--json", "Output JSON").action(runAction(async (imUserId, opts) => {
13545
+ const cloud = makeCloudClient();
13546
+ const data = await requestEnvelope(cloud, "POST", `/api/im/agents/${encodeURIComponent(imUserId)}/publish`, {
13547
+ slug: opts.slug,
13548
+ version: opts.version,
13549
+ license: opts.license,
13550
+ metadata: {
13551
+ ...opts.title ? { title: opts.title } : {},
13552
+ ...opts.description ? { description: opts.description } : {}
13553
+ },
13554
+ stripMemory: true
13555
+ }, "agent_publish_failed");
13556
+ if (opts.json) {
13557
+ printJson(data);
13558
+ return;
13559
+ }
13560
+ const rec = data;
13561
+ getUI().ok("Agent Pack published", `${String(rec.slug ?? opts.slug)}@${String(rec.version ?? opts.version ?? "1.0.0")}`);
13562
+ getUI().line(`package=${String(rec.id ?? "<unknown>")}`);
13563
+ }, { code: "agent_publish_failed" }));
13564
+ cmd.command("fork <packIdOrSlug>").description("Fork an Agent Pack into a workspace").requiredOption("--workspace-id <id>", "Target workspace id").option("--display-name <name>", "New agent display name").option("--json", "Output JSON").action(runAction(async (packIdOrSlug, opts) => {
13565
+ const cloud = makeCloudClient();
13566
+ const data = await requestEnvelope(cloud, "POST", `/api/im/agent-packs/${encodeURIComponent(packIdOrSlug)}/fork`, {
13567
+ targetWorkspaceId: opts.workspaceId,
13568
+ displayName: opts.displayName
13569
+ }, "agent_fork_failed");
13570
+ if (opts.json) {
13571
+ printJson(data);
13572
+ return;
13573
+ }
13574
+ const rec = data;
13575
+ getUI().ok("Agent Pack forked", `newAgent=${String(rec.newImUserId ?? "<unknown>")}`);
13576
+ getUI().line(`did=${String(rec.newDid ?? "not issued")}`);
13577
+ }, { code: "agent_fork_failed" }));
11891
13578
  return cmd;
11892
13579
  }
13580
+ function makeCloudClient() {
13581
+ const cfg = loadConfig(resolvePaths());
13582
+ return new CloudClient({ baseUrl: cfg.cloud_api_base, apiKey: cfg.api_key });
13583
+ }
13584
+ async function requestEnvelope(cloud, method, path9, body, code) {
13585
+ const res = await cloud.request(
13586
+ method,
13587
+ path9,
13588
+ body === void 0 ? void 0 : { body }
13589
+ );
13590
+ if (!res.ok || res.data?.ok === false) {
13591
+ const message = res.error?.message ?? res.data?.error?.message ?? "request failed";
13592
+ exitWithError(`${path9} failed (${res.status === 0 ? "network error" : `HTTP ${res.status}`}): ${message}`, {
13593
+ code: res.error?.code ?? res.data?.error?.code ?? code
13594
+ });
13595
+ }
13596
+ return res.data && typeof res.data === "object" && "data" in res.data ? res.data.data : res.data;
13597
+ }
11893
13598
  function readLocalAgents(localDb) {
11894
13599
  const db = openLocalDb(localDb);
11895
13600
  try {
@@ -11994,7 +13699,7 @@ function whichBinary2(bin) {
11994
13699
  // src/cli/commands/asset.ts
11995
13700
  var import_node_crypto11 = require("crypto");
11996
13701
  var import_commander3 = require("commander");
11997
- var import_node_fs18 = require("fs");
13702
+ var import_node_fs19 = require("fs");
11998
13703
  var import_node_path14 = require("path");
11999
13704
  init_util();
12000
13705
  init_ui();
@@ -12167,8 +13872,8 @@ async function prefetchAssetBytes(opts) {
12167
13872
  return { considered: rows.length, fetched, bytes, failed };
12168
13873
  }
12169
13874
  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);
13875
+ if (!(0, import_node_fs19.existsSync)(inputPath)) exitWithError(`path not found: ${inputPath}`);
13876
+ const stat = (0, import_node_fs19.lstatSync)(inputPath);
12172
13877
  if (stat.isSymbolicLink()) exitWithError(`refusing to upload symlink: ${inputPath}`);
12173
13878
  if (stat.isFile()) {
12174
13879
  return uploadAsset(inputPath, { ...opts, folderPath: normalizeFolderPath(opts.folderPath) });
@@ -12186,9 +13891,9 @@ async function uploadAssetPath(inputPath, opts) {
12186
13891
  return { ok: true, data: { count: items.length, items } };
12187
13892
  }
12188
13893
  async function uploadAsset(file, opts) {
12189
- if (!(0, import_node_fs18.existsSync)(file)) exitWithError(`file not found: ${file}`);
13894
+ if (!(0, import_node_fs19.existsSync)(file)) exitWithError(`file not found: ${file}`);
12190
13895
  const cfg = loadConfig(resolvePaths());
12191
- const bytes = (0, import_node_fs18.readFileSync)(file);
13896
+ const bytes = (0, import_node_fs19.readFileSync)(file);
12192
13897
  const contentHash = (0, import_node_crypto11.createHash)("sha256").update(bytes).digest("hex");
12193
13898
  const metadata = parseMetadata(opts.metadata);
12194
13899
  if (opts.taskId && metadata.taskId === void 0) metadata.taskId = opts.taskId;
@@ -12324,10 +14029,10 @@ async function putDirectAssetBytes(plan, bytes, mime) {
12324
14029
  }
12325
14030
  function listFilesRecursive(root) {
12326
14031
  const out = [];
12327
- for (const name of (0, import_node_fs18.readdirSync)(root)) {
14032
+ for (const name of (0, import_node_fs19.readdirSync)(root)) {
12328
14033
  if (name.startsWith(".")) continue;
12329
14034
  const full = (0, import_node_path14.join)(root, name);
12330
- const stat = (0, import_node_fs18.lstatSync)(full);
14035
+ const stat = (0, import_node_fs19.lstatSync)(full);
12331
14036
  if (stat.isSymbolicLink()) continue;
12332
14037
  if (stat.isDirectory()) {
12333
14038
  out.push(...listFilesRecursive(full));
@@ -12361,7 +14066,7 @@ async function downloadAsset(assetId, outPath) {
12361
14066
  exitWithError(`asset download failed (${res.status}): ${errorMessage(body)}`);
12362
14067
  }
12363
14068
  const bytes = Buffer.from(await res.arrayBuffer());
12364
- (0, import_node_fs18.writeFileSync)(outPath, bytes);
14069
+ (0, import_node_fs19.writeFileSync)(outPath, bytes);
12365
14070
  }
12366
14071
  function parseMetadata(raw) {
12367
14072
  if (!raw) return {};
@@ -12760,7 +14465,7 @@ function buildCookbookCommand() {
12760
14465
  for (const suite of suites) {
12761
14466
  checks.push(...await runSuite(suite, cloud, opts));
12762
14467
  }
12763
- const summary = summarize(checks, Boolean(opts.strict));
14468
+ const summary = summarize2(checks, Boolean(opts.strict));
12764
14469
  if (opts.json) {
12765
14470
  printJson(summary);
12766
14471
  } else {
@@ -12925,7 +14630,7 @@ function parseSuites(raw) {
12925
14630
  const expanded = selected.includes("all") ? ["status", "im", "task", "group", "asset", "sandbox"] : selected;
12926
14631
  return [...new Set(expanded)];
12927
14632
  }
12928
- function summarize(checks, strict) {
14633
+ function summarize2(checks, strict) {
12929
14634
  const totals = { pass: 0, fail: 0, skip: 0 };
12930
14635
  for (const check of checks) totals[check.status] += 1;
12931
14636
  return {
@@ -13008,7 +14713,7 @@ function parsePositiveInt3(value) {
13008
14713
  // src/cli/commands/daemon.ts
13009
14714
  var import_commander8 = require("commander");
13010
14715
  var import_node_child_process7 = require("child_process");
13011
- var import_node_fs19 = require("fs");
14716
+ var import_node_fs20 = require("fs");
13012
14717
  var import_promises3 = require("timers/promises");
13013
14718
  var import_node_path15 = require("path");
13014
14719
  init_util();
@@ -13084,9 +14789,9 @@ function buildDaemonCommand() {
13084
14789
  if (existingPid && pidAlive2(existingPid)) {
13085
14790
  exitWithError(`Daemon already running (pid ${existingPid}). Use \`prismer daemon stop\` first.`);
13086
14791
  }
13087
- if (!(0, import_node_fs19.existsSync)(paths.logsDir)) (0, import_node_fs19.mkdirSync)(paths.logsDir, { recursive: true });
14792
+ if (!(0, import_node_fs20.existsSync)(paths.logsDir)) (0, import_node_fs20.mkdirSync)(paths.logsDir, { recursive: true });
13088
14793
  const logFile = (0, import_node_path15.join)(paths.logsDir, "daemon.log");
13089
- const fd = (0, import_node_fs19.openSync)(logFile, "a");
14794
+ const fd = (0, import_node_fs20.openSync)(logFile, "a");
13090
14795
  const args = [process.argv[1], "daemon", "run"];
13091
14796
  if (opts.port) args.push("--port", String(opts.port));
13092
14797
  if (opts.localServer === false) args.push("--no-local-server");
@@ -13170,7 +14875,7 @@ function buildDaemonCommand() {
13170
14875
  cmd.command("logs").description("Show daemon logs").option("--tail <n>", "Number of lines to show", (v) => Number.parseInt(v, 10), 80).option("--follow", "Follow log output").option("--json", "Accept --json for global flag compatibility (raw log bytes are streamed)").action(async (opts) => {
13171
14876
  const paths = resolvePaths();
13172
14877
  const logFile = (0, import_node_path15.join)(paths.logsDir, "daemon.log");
13173
- if (!(0, import_node_fs19.existsSync)(logFile)) {
14878
+ if (!(0, import_node_fs20.existsSync)(logFile)) {
13174
14879
  exitWithError(`No daemon log found at ${logFile}. Start the daemon with \`prismer daemon start\`.`);
13175
14880
  }
13176
14881
  const lines = Math.max(1, opts.tail);
@@ -13211,13 +14916,13 @@ async function tailFromEnd(path9, lines) {
13211
14916
  }
13212
14917
  }
13213
14918
  async function followFile(path9) {
13214
- let offset = (0, import_node_fs19.statSync)(path9).size;
14919
+ let offset = (0, import_node_fs20.statSync)(path9).size;
13215
14920
  for (; ; ) {
13216
14921
  await (0, import_promises3.setTimeout)(1e3);
13217
- const size = (0, import_node_fs19.statSync)(path9).size;
14922
+ const size = (0, import_node_fs20.statSync)(path9).size;
13218
14923
  if (size < offset) offset = 0;
13219
14924
  if (size === offset) continue;
13220
- const stream = (0, import_node_fs19.createReadStream)(path9, { start: offset, end: size - 1, encoding: "utf8" });
14925
+ const stream = (0, import_node_fs20.createReadStream)(path9, { start: offset, end: size - 1, encoding: "utf8" });
13221
14926
  for await (const chunk of stream) process.stdout.write(chunk);
13222
14927
  offset = size;
13223
14928
  }
@@ -13225,8 +14930,8 @@ async function followFile(path9) {
13225
14930
 
13226
14931
  // src/cli/commands/events.ts
13227
14932
  var import_commander9 = require("commander");
13228
- var import_node_fs20 = require("fs");
13229
- var import_node_os9 = require("os");
14933
+ var import_node_fs21 = require("fs");
14934
+ var import_node_os11 = require("os");
13230
14935
  var import_node_path16 = require("path");
13231
14936
  var import_node_readline = require("readline");
13232
14937
  init_util();
@@ -13234,7 +14939,7 @@ var DEFAULT_LIMIT2 = 50;
13234
14939
  function buildEventsCommand() {
13235
14940
  return addEventOptions(new import_commander9.Command("events").description("Read local PARA events from ~/.prismer/para/events.jsonl")).action(async (opts) => {
13236
14941
  const file = eventsPath();
13237
- if (!(0, import_node_fs20.existsSync)(file)) {
14942
+ if (!(0, import_node_fs21.existsSync)(file)) {
13238
14943
  printJson(unavailable(file));
13239
14944
  process.exitCode = 1;
13240
14945
  return;
@@ -13251,7 +14956,7 @@ function buildEventsCommand() {
13251
14956
  function buildEventsStatsCommand() {
13252
14957
  return addEventOptions(new import_commander9.Command("events:stats").description("Summarize local PARA events from ~/.prismer/para/events.jsonl")).action(async (opts) => {
13253
14958
  const file = eventsPath();
13254
- if (!(0, import_node_fs20.existsSync)(file)) {
14959
+ if (!(0, import_node_fs21.existsSync)(file)) {
13255
14960
  printJson(unavailable(file));
13256
14961
  process.exitCode = 1;
13257
14962
  return;
@@ -13260,7 +14965,7 @@ function buildEventsStatsCommand() {
13260
14965
  const events = await readEvents(file, filters);
13261
14966
  printJson({
13262
14967
  ok: true,
13263
- data: { stats: summarize2(events), count: events.length, limit: filters.limit },
14968
+ data: { stats: summarize3(events), count: events.length, limit: filters.limit },
13264
14969
  checks: { file, exists: true, filters: cleanFilters(filters) }
13265
14970
  });
13266
14971
  });
@@ -13269,7 +14974,7 @@ function addEventOptions(cmd) {
13269
14974
  return cmd.option("--limit <n>", "Max events to return/read", parsePositiveInt4, DEFAULT_LIMIT2).option("--agent-id <id>", "Filter by agent id").option("--session-id <id>", "Filter by session id").option("--family <name>", "Filter by event family").option("--type <name>", "Filter by event type").option("--json", "Output JSON (default)");
13270
14975
  }
13271
14976
  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");
14977
+ return (0, import_node_path16.join)(process.env.PRISMER_HOME ?? (0, import_node_path16.join)((0, import_node_os11.homedir)(), ".prismer"), "para", "events.jsonl");
13273
14978
  }
13274
14979
  function unavailable(file) {
13275
14980
  return {
@@ -13285,7 +14990,7 @@ function unavailable(file) {
13285
14990
  async function readEvents(file, filters) {
13286
14991
  const out = [];
13287
14992
  const rl = (0, import_node_readline.createInterface)({
13288
- input: (0, import_node_fs20.createReadStream)(file, { encoding: "utf8" }),
14993
+ input: (0, import_node_fs21.createReadStream)(file, { encoding: "utf8" }),
13289
14994
  crlfDelay: Infinity
13290
14995
  });
13291
14996
  for await (const line of rl) {
@@ -13317,7 +15022,7 @@ function field(event, names) {
13317
15022
  }
13318
15023
  return void 0;
13319
15024
  }
13320
- function summarize2(events) {
15025
+ function summarize3(events) {
13321
15026
  const byFamily = {};
13322
15027
  const byType = {};
13323
15028
  const byAgentId = {};
@@ -13355,7 +15060,7 @@ function parsePositiveInt4(v) {
13355
15060
  // src/cli/commands/memory.ts
13356
15061
  var import_better_sqlite35 = __toESM(require("better-sqlite3"), 1);
13357
15062
  var import_commander10 = require("commander");
13358
- var import_node_fs21 = require("fs");
15063
+ var import_node_fs22 = require("fs");
13359
15064
  init_util();
13360
15065
  var LOCAL_BASE = process.env.PRISMER_DAEMON_URL ?? "http://127.0.0.1:3210";
13361
15066
  function buildMemoryCommand() {
@@ -13507,7 +15212,7 @@ function readCacheSnapshot(limit) {
13507
15212
  const paths = resolvePaths();
13508
15213
  const empty = {
13509
15214
  dbPath: paths.localDb,
13510
- dbExists: (0, import_node_fs21.existsSync)(paths.localDb),
15215
+ dbExists: (0, import_node_fs22.existsSync)(paths.localDb),
13511
15216
  tables: {
13512
15217
  cached_assets: { exists: false, count: 0, sizeBytes: 0 },
13513
15218
  workspace_files_mirror: { exists: false, count: 0 }
@@ -13671,8 +15376,8 @@ function buildPairCommand() {
13671
15376
 
13672
15377
  // src/cli/commands/profile.ts
13673
15378
  var import_commander12 = require("commander");
13674
- var import_node_fs22 = require("fs");
13675
- var import_node_os10 = require("os");
15379
+ var import_node_fs23 = require("fs");
15380
+ var import_node_os12 = require("os");
13676
15381
  var import_node_path17 = require("path");
13677
15382
  var import_node_child_process8 = require("child_process");
13678
15383
  init_util();
@@ -13721,12 +15426,12 @@ function buildProfileCommand() {
13721
15426
  const profile = await cloud.get(
13722
15427
  `/api/im/agent_profiles/${encodeURIComponent(profileId)}`
13723
15428
  );
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");
15429
+ const tmpFile = (0, import_node_path17.join)((0, import_node_os12.tmpdir)(), `prismer-profile-${profileId}.json`);
15430
+ (0, import_node_fs23.writeFileSync)(tmpFile, JSON.stringify(profile.config, null, 2), "utf8");
13726
15431
  const editor = process.env.EDITOR || "vi";
13727
15432
  const ed = (0, import_node_child_process8.spawnSync)(editor, [tmpFile], { stdio: "inherit" });
13728
15433
  if (ed.status !== 0) exitWithError(`editor exited ${ed.status}`, { code: "editor_failed" });
13729
- const newConfig = JSON.parse((0, import_node_fs22.readFileSync)(tmpFile, "utf8"));
15434
+ const newConfig = JSON.parse((0, import_node_fs23.readFileSync)(tmpFile, "utf8"));
13730
15435
  const res = await cloud.request("PATCH", `/api/im/agent_profiles/${encodeURIComponent(profileId)}`, {
13731
15436
  body: { config: newConfig, version: profile.version }
13732
15437
  });
@@ -13748,8 +15453,8 @@ function mkCloud4() {
13748
15453
  function readJsonArg(arg) {
13749
15454
  if (arg.startsWith("@")) {
13750
15455
  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"));
15456
+ if (!(0, import_node_fs23.existsSync)(path9)) throw new Error(`File not found: ${path9}`);
15457
+ return JSON.parse((0, import_node_fs23.readFileSync)(path9, "utf8"));
13753
15458
  }
13754
15459
  return JSON.parse(arg);
13755
15460
  }
@@ -13763,8 +15468,8 @@ async function resolveDefaultWorkspaceId(cloud) {
13763
15468
  // src/cli/commands/reset.ts
13764
15469
  var import_commander13 = require("commander");
13765
15470
  var import_node_child_process9 = require("child_process");
13766
- var import_node_fs23 = require("fs");
13767
- var import_node_os11 = require("os");
15471
+ var import_node_fs24 = require("fs");
15472
+ var import_node_os13 = require("os");
13768
15473
  var import_node_path18 = require("path");
13769
15474
  var import_promises4 = require("timers/promises");
13770
15475
  init_util();
@@ -13782,15 +15487,15 @@ function buildResetCommand() {
13782
15487
  const hermesGatewayPids = findHermesGatewayPids2();
13783
15488
  const plan = {
13784
15489
  root: paths.root,
13785
- exists: (0, import_node_fs23.existsSync)(paths.root),
15490
+ exists: (0, import_node_fs24.existsSync)(paths.root),
13786
15491
  runningPid,
13787
15492
  mode: opts.wipe ? "wipe" : "archive",
13788
15493
  archivePath,
13789
15494
  assetSyncRoot,
13790
- assetSyncExists: (0, import_node_fs23.existsSync)(assetSyncRoot),
15495
+ assetSyncExists: (0, import_node_fs24.existsSync)(assetSyncRoot),
13791
15496
  assetSyncArchivePath,
13792
15497
  hermesHome,
13793
- hermesExists: (0, import_node_fs23.existsSync)(hermesHome),
15498
+ hermesExists: (0, import_node_fs24.existsSync)(hermesHome),
13794
15499
  hermesArchivePath,
13795
15500
  hermesGatewayPids,
13796
15501
  willStopDaemon: runningPid !== null,
@@ -13814,40 +15519,40 @@ function buildResetCommand() {
13814
15519
  for (const gatewayPid of hermesGatewayPids) {
13815
15520
  await stopProcess(gatewayPid, opts.timeout ?? 5e3, "Hermes gateway");
13816
15521
  }
13817
- if ((0, import_node_fs23.existsSync)(paths.root)) {
15522
+ if ((0, import_node_fs24.existsSync)(paths.root)) {
13818
15523
  if (opts.wipe) {
13819
- (0, import_node_fs23.rmSync)(paths.root, { recursive: true, force: true });
15524
+ (0, import_node_fs24.rmSync)(paths.root, { recursive: true, force: true });
13820
15525
  } else {
13821
15526
  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 });
15527
+ if (!(0, import_node_fs24.existsSync)((0, import_node_path18.dirname)(archivePath))) {
15528
+ (0, import_node_fs24.mkdirSync)((0, import_node_path18.dirname)(archivePath), { recursive: true });
13824
15529
  }
13825
- (0, import_node_fs23.renameSync)(paths.root, archivePath);
15530
+ (0, import_node_fs24.renameSync)(paths.root, archivePath);
13826
15531
  }
13827
15532
  }
13828
- if ((0, import_node_fs23.existsSync)(assetSyncRoot)) {
15533
+ if ((0, import_node_fs24.existsSync)(assetSyncRoot)) {
13829
15534
  if (opts.wipe) {
13830
- (0, import_node_fs23.rmSync)(assetSyncRoot, { recursive: true, force: true });
15535
+ (0, import_node_fs24.rmSync)(assetSyncRoot, { recursive: true, force: true });
13831
15536
  } else {
13832
15537
  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 });
15538
+ if (!(0, import_node_fs24.existsSync)((0, import_node_path18.dirname)(assetSyncArchivePath))) {
15539
+ (0, import_node_fs24.mkdirSync)((0, import_node_path18.dirname)(assetSyncArchivePath), { recursive: true });
13835
15540
  }
13836
- (0, import_node_fs23.renameSync)(assetSyncRoot, assetSyncArchivePath);
15541
+ (0, import_node_fs24.renameSync)(assetSyncRoot, assetSyncArchivePath);
13837
15542
  }
13838
15543
  }
13839
- if ((0, import_node_fs23.existsSync)(hermesHome)) {
15544
+ if ((0, import_node_fs24.existsSync)(hermesHome)) {
13840
15545
  if (opts.wipe) {
13841
- (0, import_node_fs23.rmSync)(hermesHome, { recursive: true, force: true });
15546
+ (0, import_node_fs24.rmSync)(hermesHome, { recursive: true, force: true });
13842
15547
  } else {
13843
15548
  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 });
15549
+ if (!(0, import_node_fs24.existsSync)((0, import_node_path18.dirname)(hermesArchivePath))) {
15550
+ (0, import_node_fs24.mkdirSync)((0, import_node_path18.dirname)(hermesArchivePath), { recursive: true });
13846
15551
  }
13847
- (0, import_node_fs23.renameSync)(hermesHome, hermesArchivePath);
15552
+ (0, import_node_fs24.renameSync)(hermesHome, hermesArchivePath);
13848
15553
  }
13849
15554
  }
13850
- (0, import_node_fs23.mkdirSync)(paths.root, { recursive: true });
15555
+ (0, import_node_fs24.mkdirSync)(paths.root, { recursive: true });
13851
15556
  if (opts.json) {
13852
15557
  printJson({ ok: true, reset: true, plan });
13853
15558
  return;
@@ -13916,13 +15621,13 @@ function timestamp() {
13916
15621
  function resolveAssetSyncRoot() {
13917
15622
  const override = process.env.PRISMER_ASSET_SYNC_ROOT;
13918
15623
  if (override) return override;
13919
- const home = (0, import_node_os11.homedir)();
15624
+ const home = (0, import_node_os13.homedir)();
13920
15625
  const desktop = (0, import_node_path18.join)(home, "Desktop");
13921
- const base = (0, import_node_fs23.existsSync)(desktop) ? desktop : home;
15626
+ const base = (0, import_node_fs24.existsSync)(desktop) ? desktop : home;
13922
15627
  return (0, import_node_path18.join)(base, "Prismer Assets");
13923
15628
  }
13924
15629
  function resolveHermesHome() {
13925
- return process.env.HERMES_HOME || (0, import_node_path18.join)((0, import_node_os11.homedir)(), ".hermes");
15630
+ return process.env.HERMES_HOME || (0, import_node_path18.join)((0, import_node_os13.homedir)(), ".hermes");
13926
15631
  }
13927
15632
  function findHermesGatewayPids2() {
13928
15633
  try {
@@ -14152,10 +15857,10 @@ async function safeParseResponse(res) {
14152
15857
 
14153
15858
  // src/cli/commands/setup.ts
14154
15859
  var import_commander15 = require("commander");
14155
- var import_node_os12 = require("os");
15860
+ var import_node_os14 = require("os");
14156
15861
  var import_node_child_process10 = require("child_process");
14157
15862
  var import_node_crypto12 = require("crypto");
14158
- var import_node_fs24 = require("fs");
15863
+ var import_node_fs25 = require("fs");
14159
15864
  var import_node_http2 = require("http");
14160
15865
  init_util();
14161
15866
  init_ui();
@@ -14197,7 +15902,7 @@ function buildSetupCommand() {
14197
15902
  }
14198
15903
  const result = await pair({
14199
15904
  cloudBaseUrl,
14200
- deviceName: opts.deviceName ?? (0, import_node_os12.hostname)(),
15905
+ deviceName: opts.deviceName ?? (0, import_node_os14.hostname)(),
14201
15906
  force: opts.force,
14202
15907
  paths,
14203
15908
  asUserEmail: opts.asUser
@@ -14214,13 +15919,13 @@ function buildSetupCommand() {
14214
15919
  apiKey = await mintDaemonApiKey({
14215
15920
  cloudBaseUrl,
14216
15921
  token: authToken,
14217
- deviceName: opts.deviceName ?? (0, import_node_os12.hostname)()
15922
+ deviceName: opts.deviceName ?? (0, import_node_os14.hostname)()
14218
15923
  });
14219
15924
  }
14220
15925
  if (!apiKey && !authToken && !opts.check && opts.browser !== false) {
14221
15926
  apiKey = await runBrowserSetup({
14222
15927
  cloudBaseUrl,
14223
- deviceName: opts.deviceName ?? (0, import_node_os12.hostname)(),
15928
+ deviceName: opts.deviceName ?? (0, import_node_os14.hostname)(),
14224
15929
  json: Boolean(opts.json)
14225
15930
  });
14226
15931
  }
@@ -14406,9 +16111,9 @@ function shouldArchiveLocalDb(previous, next) {
14406
16111
  return previous.api_key !== next.api_key || previous.cloud_api_base !== next.cloud_api_base || previous.daemon_id !== next.daemon_id;
14407
16112
  }
14408
16113
  function archiveLocalDb(localDbPath) {
14409
- if (!(0, import_node_fs24.existsSync)(localDbPath)) return;
16114
+ if (!(0, import_node_fs25.existsSync)(localDbPath)) return;
14410
16115
  const archived = `${localDbPath}.${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}.bak`;
14411
- (0, import_node_fs24.renameSync)(localDbPath, archived);
16116
+ (0, import_node_fs25.renameSync)(localDbPath, archived);
14412
16117
  }
14413
16118
  async function mintDaemonApiKey(input) {
14414
16119
  const label = `Daemon: ${input.deviceName} ${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}`;
@@ -14477,7 +16182,7 @@ function buildSkillCommand() {
14477
16182
  const cloud = mkCloud6();
14478
16183
  const path9 = opts.agent ? `/api/im/agents/${encodeURIComponent(opts.agent)}/skills` : `/api/im/skills/${encodeURIComponent(slugOrId)}/install`;
14479
16184
  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");
16185
+ const data = await requestEnvelope2(cloud, "POST", path9, body, "skill_install_failed");
14481
16186
  if (opts.json) {
14482
16187
  printJson(data);
14483
16188
  return;
@@ -14488,7 +16193,7 @@ function buildSkillCommand() {
14488
16193
  const cloud = mkCloud6();
14489
16194
  const path9 = opts.agent ? `/api/im/agents/${encodeURIComponent(opts.agent)}/skills` : `/api/im/skills/${encodeURIComponent(slugOrId)}/install`;
14490
16195
  const body = opts.agent ? { skillId: slugOrId, workspaceId: opts.workspaceId } : void 0;
14491
- const data = await requestEnvelope(cloud, "DELETE", path9, body, "skill_uninstall_failed");
16196
+ const data = await requestEnvelope2(cloud, "DELETE", path9, body, "skill_uninstall_failed");
14492
16197
  if (opts.json) {
14493
16198
  printJson(data);
14494
16199
  return;
@@ -14541,7 +16246,7 @@ function mkCloud6() {
14541
16246
  const cfg = loadConfig(resolvePaths());
14542
16247
  return new CloudClient({ baseUrl: cfg.cloud_api_base, apiKey: cfg.api_key });
14543
16248
  }
14544
- async function requestEnvelope(cloud, method, path9, body, code) {
16249
+ async function requestEnvelope2(cloud, method, path9, body, code) {
14545
16250
  const res = await cloud.request(method, path9, {
14546
16251
  body
14547
16252
  });
@@ -14660,7 +16365,7 @@ function printSkill(skill, includeContent) {
14660
16365
 
14661
16366
  // src/cli/commands/status.ts
14662
16367
  var import_commander17 = require("commander");
14663
- var import_node_os13 = require("os");
16368
+ var import_node_os15 = require("os");
14664
16369
  init_util();
14665
16370
  init_ui();
14666
16371
  function buildStatusCommand() {
@@ -14691,6 +16396,7 @@ function buildStatusCommand() {
14691
16396
  let me = null;
14692
16397
  let devices = null;
14693
16398
  let agents = null;
16399
+ let diagnose = null;
14694
16400
  try {
14695
16401
  const meRes = await cloud.request("GET", "/api/im/me", { timeoutMs: 3e3 });
14696
16402
  cloudOk = meRes.ok;
@@ -14703,19 +16409,33 @@ function buildStatusCommand() {
14703
16409
  if (Array.isArray(wsList) && wsList.length > 0) {
14704
16410
  const wsId = wsList[0]?.id;
14705
16411
  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;
16412
+ const rtRes = await cloud.request("GET", `/api/im/workspaces/${encodeURIComponent(wsId)}/runtime`, { timeoutMs: 3e3 });
16413
+ if (rtRes.ok) {
16414
+ const rtBody = rtRes.data;
16415
+ const snapshot = rtBody?.data;
16416
+ if (snapshot && Array.isArray(snapshot.devices)) {
16417
+ devices = snapshot.devices;
16418
+ agents = snapshot.devices.flatMap((d) => d.agents ?? []);
16419
+ }
14715
16420
  }
14716
16421
  }
14717
16422
  }
14718
16423
  }
16424
+ const daemonId = cfg.daemon_id;
16425
+ if (daemonId) {
16426
+ try {
16427
+ const diagRes = await cloud.request(
16428
+ "GET",
16429
+ `/api/im/runtime/diagnose?daemonId=${encodeURIComponent(daemonId)}`,
16430
+ { timeoutMs: 3e3 }
16431
+ );
16432
+ if (diagRes.ok) {
16433
+ const body = diagRes.data;
16434
+ if (body?.data && typeof body.data === "object") diagnose = body.data;
16435
+ }
16436
+ } catch {
16437
+ }
16438
+ }
14719
16439
  }
14720
16440
  } catch {
14721
16441
  cloudOk = false;
@@ -14732,7 +16452,8 @@ function buildStatusCommand() {
14732
16452
  info: daemonStatus.info ?? {}
14733
16453
  },
14734
16454
  cloud: { base: cfg.cloud_api_base, reachable: cloudOk, me, devices, agents },
14735
- local
16455
+ local,
16456
+ diagnose
14736
16457
  };
14737
16458
  if (opts.json) {
14738
16459
  printJson(report);
@@ -14741,6 +16462,60 @@ function buildStatusCommand() {
14741
16462
  printPretty(report);
14742
16463
  });
14743
16464
  }
16465
+ function computeDeviceIcon(lastSeenAt, now) {
16466
+ if (!lastSeenAt) return "\u25CB";
16467
+ const age = now.getTime() - Date.parse(lastSeenAt);
16468
+ if (!Number.isFinite(age) || age < 0) return "\u25CB";
16469
+ if (age < 5 * 6e4) return "\u25CF";
16470
+ if (age < 60 * 6e4) return "\u25D0";
16471
+ return "\u25CB";
16472
+ }
16473
+ function computeAgentIcon(status, lastHeartbeat, now) {
16474
+ const s = (status || "").toLowerCase();
16475
+ if (s === "error" || s === "failed") return "\u25C6";
16476
+ if (!lastHeartbeat) return s === "offline" ? "\u25CB" : "\u25D0";
16477
+ const age = now.getTime() - Date.parse(lastHeartbeat);
16478
+ if (!Number.isFinite(age) || age < 0 || age >= 60 * 6e4) return "\u25CB";
16479
+ if (age >= 5 * 6e4) return "\u25D0";
16480
+ return "\u25CF";
16481
+ }
16482
+ function pad(value, width) {
16483
+ if (value.length >= width) return value.slice(0, width);
16484
+ return value + " ".repeat(width - value.length);
16485
+ }
16486
+ function formatDeviceBindings(devices, now = /* @__PURE__ */ new Date()) {
16487
+ const lines = [];
16488
+ if (devices.length === 0) {
16489
+ lines.push(" No paired devices.");
16490
+ return lines;
16491
+ }
16492
+ const totalAgents = devices.reduce((s, d) => s + (d.agents?.length ?? 0), 0);
16493
+ const devWord = devices.length === 1 ? "device" : "devices";
16494
+ const agWord = totalAgents === 1 ? "agent" : "agents";
16495
+ lines.push(` Device & Agent Bindings (${devices.length} ${devWord} \xB7 ${totalAgents} ${agWord})`);
16496
+ lines.push("");
16497
+ for (let di = 0; di < devices.length; di++) {
16498
+ const d = devices[di];
16499
+ const dIcon = computeDeviceIcon(d.lastSeenAt, now);
16500
+ const dName = pad(d.name || d.deviceId, 40);
16501
+ const lastSeen = d.lastSeenAt ? formatRelative(d.lastSeenAt, now) : "never seen";
16502
+ lines.push(` ${dIcon} ${dName} ${lastSeen}`);
16503
+ const agents = d.agents ?? [];
16504
+ for (let ai = 0; ai < agents.length; ai++) {
16505
+ const a = agents[ai];
16506
+ const isLast = ai === agents.length - 1;
16507
+ const branch = isLast ? "\u2514\u2500" : "\u251C\u2500";
16508
+ const aIcon = computeAgentIcon(a.status, a.lastHeartbeat, now);
16509
+ const aName = pad(a.name || a.id, 24);
16510
+ const aStatus = pad(a.status || "?", 8);
16511
+ const aVersion = pad(a.version ? `v${a.version}` : "\u2014", 10);
16512
+ const tail = a.currentTaskId ? `task=${a.currentTaskId}` : a.lastHeartbeat ? `\u2764 ${formatRelative(a.lastHeartbeat, now)}` : "";
16513
+ lines.push(` ${branch} ${aIcon} ${aName} ${aStatus} ${aVersion} ${tail}`.trimEnd());
16514
+ }
16515
+ if (di < devices.length - 1) lines.push("");
16516
+ }
16517
+ return lines;
16518
+ }
14744
16519
  async function readDaemonStatus() {
14745
16520
  try {
14746
16521
  const res = await fetch("http://127.0.0.1:3210/healthz", {
@@ -14784,7 +16559,7 @@ function printPretty(report) {
14784
16559
  ui.blank();
14785
16560
  ok("Config", report.paths.config);
14786
16561
  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)();
16562
+ const daemonName = report.cloud.me && typeof report.cloud.me === "object" ? report.cloud.me.user?.username ?? (0, import_node_os15.hostname)() : (0, import_node_os15.hostname)();
14788
16563
  const ws = report.daemon.wsConnected ? "connected" : "pending";
14789
16564
  ok("Daemon", `${daemonName} pid=${report.daemon.pid} ws=${ws}`);
14790
16565
  if (report.daemon.info) {
@@ -14813,16 +16588,7 @@ function printPretty(report) {
14813
16588
  if (Array.isArray(report.cloud.devices) && report.cloud.devices.length > 0) {
14814
16589
  const devs = report.cloud.devices;
14815
16590
  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}`);
16591
+ for (const ln of formatDeviceBindings(devs)) ui.line(ln);
14826
16592
  }
14827
16593
  if (report.local) {
14828
16594
  ui.blank();
@@ -14832,6 +16598,58 @@ function printPretty(report) {
14832
16598
  } else {
14833
16599
  warn("Local DB", "unavailable");
14834
16600
  }
16601
+ if (report.diagnose) {
16602
+ ui.blank();
16603
+ ui.header("Cloud dispatch reachability");
16604
+ ui.blank();
16605
+ printDiagnose(report.diagnose, report.daemon.pid ?? null);
16606
+ }
16607
+ }
16608
+ function printDiagnose(d, pid) {
16609
+ const ui = getUI();
16610
+ const heartbeatLine = d.lastHeartbeatAt ? `last heartbeat ${formatRelative(d.lastHeartbeatAt)}` : "no heartbeat seen";
16611
+ if (d.wsAlive) {
16612
+ ok("Daemon", `running${pid ? ` (pid ${pid})` : ""}`);
16613
+ ok("Cloud WS", `connected (${heartbeatLine})`);
16614
+ } else {
16615
+ warn("Daemon", `running${pid ? ` (pid ${pid})` : ""} \u2014 not visible to cloud WS`);
16616
+ fail2("Cloud WS", `disconnected (${heartbeatLine})`);
16617
+ }
16618
+ if (d.gatewayUrl) {
16619
+ if (d.gatewayIsPrivate) {
16620
+ warn("HTTP gateway", `${d.gatewayUrl} (private IP \u2014 only reachable on same network)`);
16621
+ } else if (d.httpReachable) {
16622
+ ok(
16623
+ "HTTP gateway",
16624
+ `${d.gatewayUrl} (reachable${d.httpLatencyMs != null ? `, ${d.httpLatencyMs}ms` : ""})`
16625
+ );
16626
+ } else {
16627
+ fail2("HTTP gateway", `${d.gatewayUrl} (unreachable${d.httpError ? ` \u2014 ${d.httpError}` : ""})`);
16628
+ }
16629
+ } else {
16630
+ ui.line(" HTTP gateway: not declared (WS-only daemon)");
16631
+ }
16632
+ const activeAgents = /* @__PURE__ */ (() => {
16633
+ return 0;
16634
+ })();
16635
+ if (activeAgents > 0) {
16636
+ ok("Active agents", String(activeAgents));
16637
+ }
16638
+ const recColour = d.recommendedTransport === "ws" ? ok : d.recommendedTransport === "http" ? warn : fail2;
16639
+ recColour("Recommended transport", d.recommendedTransport);
16640
+ if (d.lastProbeAt) {
16641
+ ui.line(` Last transport probe: ${formatRelative(d.lastProbeAt)}`);
16642
+ }
16643
+ }
16644
+ function formatRelative(iso, now = /* @__PURE__ */ new Date()) {
16645
+ const ts = Date.parse(iso);
16646
+ if (!Number.isFinite(ts)) return "unknown";
16647
+ const diff = Math.max(0, now.getTime() - ts);
16648
+ if (diff < 3e4) return "just now";
16649
+ if (diff < 6e4) return `${Math.round(diff / 1e3)}s ago`;
16650
+ if (diff < 36e5) return `${Math.round(diff / 6e4)}m ago`;
16651
+ if (diff < 864e5) return `${Math.round(diff / 36e5)}h ago`;
16652
+ return `${Math.round(diff / 864e5)}d ago`;
14835
16653
  }
14836
16654
 
14837
16655
  // src/cli/commands/task.ts
@@ -15194,7 +17012,7 @@ async function readResponseError(res) {
15194
17012
 
15195
17013
  // src/cli/index.ts
15196
17014
  init_ui();
15197
- var VERSION = "2.0.4";
17015
+ var VERSION = "2.0.5";
15198
17016
  function buildProgram() {
15199
17017
  const program = new import_commander20.Command("prismer").description("Prismer Cloud daemon CLI (TS-only).").version(VERSION);
15200
17018
  program.addCommand(buildBannerCommand());