@prismer/runtime 2.0.3 → 2.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -153,6 +153,42 @@ import { fileURLToPath } from "url";
153
153
  import Database from "better-sqlite3";
154
154
  import * as YAML from "yaml";
155
155
  import { z } from "zod";
156
+ function readPlainRecord(value) {
157
+ return value && typeof value === "object" && !Array.isArray(value) ? value : null;
158
+ }
159
+ function readNonEmptyString(value) {
160
+ return typeof value === "string" && value.trim() ? value.trim() : null;
161
+ }
162
+ function sanitizeRoleSkillSlug(value) {
163
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 60) || "template";
164
+ }
165
+ function renderRoleTemplateSkill(skillName, agents, roleTemplate) {
166
+ const mcpServers = Array.isArray(roleTemplate?.mcpServers) ? roleTemplate.mcpServers : [];
167
+ const tools = /* @__PURE__ */ new Set();
168
+ for (const server of mcpServers) {
169
+ const rec = readPlainRecord(server);
170
+ const allowlist = Array.isArray(rec?.toolsAllowlist) ? rec.toolsAllowlist : [];
171
+ for (const tool of allowlist) {
172
+ if (typeof tool === "string" && tool.trim()) tools.add(tool.trim());
173
+ }
174
+ }
175
+ const allowedTools = [...tools].join(" ");
176
+ const roleTemplateSlug = JSON.stringify(readNonEmptyString(roleTemplate?.slug) ?? null);
177
+ return `---
178
+ name: ${skillName}
179
+ description: Role-template operating playbook projected from Prismer RoleTemplate.
180
+ ${allowedTools ? `allowed-tools: ${allowedTools}
181
+ ` : ""}metadata:
182
+ prismer:
183
+ source: role-template
184
+ roleTemplateSlug: ${roleTemplateSlug}
185
+ ---
186
+
187
+ # Role Template Playbook
188
+
189
+ ${agents.trim()}
190
+ `;
191
+ }
156
192
  function parsePrismerGoals(value) {
157
193
  if (!Array.isArray(value)) return [];
158
194
  return value.map((entry) => {
@@ -562,13 +598,14 @@ function failure(status, body) {
562
598
  error: { code: "adapter_dispatch_failed", message: `Hermes ${status}: ${body || "<no body>"}` }
563
599
  };
564
600
  }
565
- async function consumeSse(body, task) {
601
+ async function consumeSse(body, task, state) {
566
602
  const reader = body.getReader();
567
603
  const decoder = new TextDecoder();
568
604
  let buf = "";
569
605
  let deltas = "";
570
606
  let finalOutput;
571
607
  let lastProgress = 0;
608
+ let approvalRequested = false;
572
609
  for (; ; ) {
573
610
  const readStart = Date.now();
574
611
  const { value, done } = await reader.read();
@@ -617,6 +654,10 @@ async function consumeSse(body, task) {
617
654
  const next = Math.min(0.99, lastProgress + 0.05);
618
655
  lastProgress = next;
619
656
  const toolName = typeof payload.tool === "string" ? payload.tool : typeof payload.name === "string" ? payload.name : void 0;
657
+ if (eventName === "tool.started" && toolName === APPROVAL_TOOL_NAME) {
658
+ approvalRequested = true;
659
+ if (state) state.approvalRequested = true;
660
+ }
620
661
  const previewStr = typeof payload.preview === "string" ? payload.preview : payload.preview != null ? JSON.stringify(payload.preview).slice(0, 200) : void 0;
621
662
  task.onProgress?.({
622
663
  progress: next,
@@ -646,7 +687,53 @@ async function consumeSse(body, task) {
646
687
  }
647
688
  }
648
689
  }
649
- return finalOutput && finalOutput.length > 0 ? finalOutput : deltas;
690
+ return {
691
+ output: finalOutput && finalOutput.length > 0 ? finalOutput : deltas,
692
+ approvalRequested
693
+ };
694
+ }
695
+ async function consumeChatCompletionsSse(body, task) {
696
+ const reader = body.getReader();
697
+ const decoder = new TextDecoder();
698
+ let buf = "";
699
+ let output = "";
700
+ let lastProgress = 0;
701
+ for (; ; ) {
702
+ const { value, done } = await reader.read();
703
+ if (done) break;
704
+ buf += decoder.decode(value, { stream: true });
705
+ const events = buf.split("\n\n");
706
+ buf = events.pop() ?? "";
707
+ for (const ev of events) {
708
+ const dataLine = ev.match(/^data: (.+)$/m);
709
+ if (!dataLine) continue;
710
+ const raw = dataLine[1].trim();
711
+ if (raw === "[DONE]") continue;
712
+ let payload;
713
+ try {
714
+ payload = JSON.parse(raw);
715
+ } catch (err) {
716
+ process.stderr.write(
717
+ `[hermes-adapter] chat-completions SSE parse skipped: ${err.message}
718
+ `
719
+ );
720
+ continue;
721
+ }
722
+ const delta = payload.choices?.[0]?.delta?.content;
723
+ if (typeof delta === "string") {
724
+ output += delta;
725
+ } else if (Array.isArray(delta)) {
726
+ for (const part of delta) {
727
+ if (part?.type === "text" && typeof part.text === "string") output += part.text;
728
+ }
729
+ }
730
+ if (output.length > 0 && lastProgress < 0.99) {
731
+ lastProgress = Math.min(0.99, lastProgress + 0.05);
732
+ task.onProgress?.({ progress: lastProgress, message: "streaming" });
733
+ }
734
+ }
735
+ }
736
+ return output;
650
737
  }
651
738
  async function checkHealth(baseUrl, apiKey) {
652
739
  try {
@@ -727,13 +814,14 @@ function pidAlive(pid) {
727
814
  function escapeRegExp(value) {
728
815
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
729
816
  }
730
- var PRISMER_IM_SKILL_NAME, PRISMER_IM_SKILL_CONTENT, HermesProfileConfigSchema, hermesAdapter, HermesService;
817
+ var PRISMER_IM_SKILL_NAME, PRISMER_ROLE_SKILL_PREFIX, PRISMER_IM_SKILL_CONTENT, HermesProfileConfigSchema, hermesAdapter, HermesService, APPROVAL_TOOL_NAME;
731
818
  var init_hermes = __esm({
732
819
  "src/adapters/hermes/index.ts"() {
733
820
  "use strict";
734
821
  init_contract();
735
822
  init_skill_loader2();
736
823
  PRISMER_IM_SKILL_NAME = "prismer-im-collab";
824
+ PRISMER_ROLE_SKILL_PREFIX = "prismer-role";
737
825
  PRISMER_IM_SKILL_CONTENT = `---
738
826
  name: prismer-im-collab
739
827
  description: Coordinate reliably in Prismer conversations, use workspace assets through bounded MCP tools, and keep task work on the board.
@@ -1046,6 +1134,7 @@ No @ needed; the human is the only other party.
1046
1134
  const startedAt = Date.now();
1047
1135
  let runId;
1048
1136
  const sysPrompt = typeof task.metadata?.systemPrompt === "string" ? task.metadata.systemPrompt : void 0;
1137
+ this.installRoleTemplateSkill();
1049
1138
  const skillPrompt = await this.skillLoader.loadSystemPromptFragment().catch((err) => {
1050
1139
  process.stderr.write(
1051
1140
  `[hermes-adapter] failed to read skills for ${this.profileName}: ${err.message}
@@ -1053,7 +1142,21 @@ No @ needed; the human is the only other party.
1053
1142
  );
1054
1143
  return void 0;
1055
1144
  });
1056
- const instructions = [sysPrompt, skillPrompt].filter(Boolean).join("\n\n");
1145
+ const outboxDir = typeof task.metadata?.prismerOutboxDir === "string" ? task.metadata.prismerOutboxDir : void 0;
1146
+ const outboxDirective = outboxDir ? [
1147
+ "[Outbox directive \u2014 MANDATORY, overrides any prior outbox path]",
1148
+ "",
1149
+ "For THIS turn ONLY, your active outbox is:",
1150
+ ` ${outboxDir}`,
1151
+ "",
1152
+ "Any user-deliverable file (PDF, image, CSV, archive, doc) MUST be",
1153
+ "written to that EXACT absolute path. Do NOT reuse the outbox path",
1154
+ "from any previous turn \u2014 those directories no longer accept new",
1155
+ "uploads. Do NOT copy files into the previous outbox; copy them",
1156
+ "into the path above. Files outside this directory will not become",
1157
+ 'chat attachments and the user will see "no files were attached".'
1158
+ ].join("\n") : "";
1159
+ const instructions = [outboxDirective, sysPrompt, skillPrompt].filter(Boolean).join("\n\n");
1057
1160
  if (sysPrompt) {
1058
1161
  try {
1059
1162
  const profileDir = getHermesProfileDir(this.profileName);
@@ -1077,8 +1180,15 @@ No @ needed; the human is the only other party.
1077
1180
  `
1078
1181
  );
1079
1182
  }
1183
+ const sseState = { approvalRequested: false };
1080
1184
  try {
1081
1185
  const nativeBridgePatch = await this.prepareNativeBridgePatch(task);
1186
+ const imageRefs = (task.assetRefs ?? []).filter(
1187
+ (r) => r.mime?.startsWith("image/")
1188
+ );
1189
+ if (imageRefs.length > 0) {
1190
+ return await this.dispatchMultimodalChat(task, instructions, imageRefs, startedAt);
1191
+ }
1082
1192
  const createRes = await fetch(`${this.baseUrl}/v1/runs`, {
1083
1193
  method: "POST",
1084
1194
  headers: {
@@ -1129,10 +1239,10 @@ No @ needed; the human is the only other party.
1129
1239
  }
1130
1240
  };
1131
1241
  }
1132
- const output = await consumeSse(eventsRes.body, task);
1242
+ const sseResult = await consumeSse(eventsRes.body, task, sseState);
1133
1243
  return {
1134
1244
  ok: true,
1135
- output,
1245
+ output: sseResult.output,
1136
1246
  metrics: { durationMs: Date.now() - startedAt },
1137
1247
  metadata: {
1138
1248
  hermes: this.bridgeSnapshot("dispatched", {
@@ -1140,7 +1250,13 @@ No @ needed; the human is the only other party.
1140
1250
  runId,
1141
1251
  baseUrl: this.baseUrl,
1142
1252
  model: this.config.model
1143
- })
1253
+ }),
1254
+ // Surfaced to dispatch.ts so a subsequent reaper kill on this
1255
+ // task is reclassified as `awaiting_human_approval` rather than
1256
+ // `daemon_task_timeout`. Lives under a stable key (not under
1257
+ // `hermes.*`) so non-hermes adapters can adopt the same signal
1258
+ // later without bleeding hermes-specific metadata shape.
1259
+ ...sseResult.approvalRequested ? { approvalRequested: true } : {}
1144
1260
  }
1145
1261
  };
1146
1262
  } catch (err) {
@@ -1150,6 +1266,7 @@ No @ needed; the human is the only other party.
1150
1266
  );
1151
1267
  const categorized = categorizeDispatchError(err, task.signal);
1152
1268
  const bridgeKind = categorized.error?.code === "task_cancelled" ? "cancelled" : "failed";
1269
+ const approvalSignal = sseState.approvalRequested ? { approvalRequested: true } : {};
1153
1270
  return {
1154
1271
  ...categorized,
1155
1272
  metadata: {
@@ -1158,13 +1275,87 @@ No @ needed; the human is the only other party.
1158
1275
  baseUrl: this.baseUrl,
1159
1276
  model: this.config.model,
1160
1277
  ...bridgeKind === "failed" ? { error: err.message } : {}
1161
- })
1278
+ }),
1279
+ ...approvalSignal
1162
1280
  }
1163
1281
  };
1164
1282
  } finally {
1165
1283
  if (runId && this.currentRunId === runId) this.currentRunId = void 0;
1166
1284
  }
1167
1285
  }
1286
+ /**
1287
+ * 14b rev.3 §3.0.4 — Hermes multimodal path.
1288
+ *
1289
+ * Builds an OpenAI-shape chat/completions body where the user message
1290
+ * `content` is a multipart array (`{type:'text'}` + N × `{type:'image_url'}`).
1291
+ * Hermes `_normalize_multimodal_content` (api_server.py:170) collapses the
1292
+ * blocks into its native multimodal representation before routing into
1293
+ * the agent pipeline.
1294
+ *
1295
+ * `image_url.url` is the cdn URL when the daemon's reachability probe said
1296
+ * the upstream LLM can see it (D35 path A); otherwise it's a `data:` URI
1297
+ * carrying the base64 bytes the daemon pre-fetched (D35 path B fallback).
1298
+ * The adapter doesn't decide — `resolveAssetRefs` does, and the adapter
1299
+ * just shuttles `cdnUrl ?? data:base64` into the wire.
1300
+ */
1301
+ async dispatchMultimodalChat(task, instructions, imageRefs, startedAt) {
1302
+ const userContent = [{ type: "text", text: task.prompt }];
1303
+ for (const r of imageRefs) {
1304
+ const url = r.reachable === "cdn" && r.cdnUrl ? r.cdnUrl : r.base64 ? `data:${r.mime ?? "image/png"};base64,${r.base64}` : r.cdnUrl ?? "";
1305
+ if (!url) {
1306
+ userContent.push({
1307
+ type: "text",
1308
+ text: `[image attachment id=${r.assetId} mime=${r.mime ?? "unknown"} unavailable: cdn not reachable and bytes exceeded inline cap]`
1309
+ });
1310
+ continue;
1311
+ }
1312
+ userContent.push({ type: "image_url", image_url: { url, detail: "auto" } });
1313
+ }
1314
+ const messages = [];
1315
+ if (instructions) messages.push({ role: "system", content: instructions });
1316
+ messages.push({ role: "user", content: userContent });
1317
+ const res = await fetch(`${this.baseUrl}/v1/chat/completions`, {
1318
+ method: "POST",
1319
+ headers: {
1320
+ Authorization: `Bearer ${this.config.apiKey}`,
1321
+ "Content-Type": "application/json"
1322
+ },
1323
+ body: JSON.stringify({
1324
+ model: this.config.model,
1325
+ messages,
1326
+ stream: true
1327
+ }),
1328
+ signal: task.signal
1329
+ });
1330
+ if (!res.ok) {
1331
+ const body = await res.text().catch(() => "");
1332
+ return failure(res.status, body);
1333
+ }
1334
+ if (!res.body) {
1335
+ return {
1336
+ ok: false,
1337
+ error: {
1338
+ code: "adapter_dispatch_failed",
1339
+ message: "Hermes /v1/chat/completions returned no body"
1340
+ }
1341
+ };
1342
+ }
1343
+ const output = await consumeChatCompletionsSse(res.body, task);
1344
+ return {
1345
+ ok: true,
1346
+ output,
1347
+ metrics: { durationMs: Date.now() - startedAt },
1348
+ metadata: {
1349
+ hermes: this.bridgeSnapshot("dispatched", {
1350
+ endpoint: "/v1/chat/completions",
1351
+ multimodal: true,
1352
+ imageRefs: imageRefs.length,
1353
+ baseUrl: this.baseUrl,
1354
+ model: this.config.model
1355
+ })
1356
+ }
1357
+ };
1358
+ }
1168
1359
  async shutdown() {
1169
1360
  if (this.currentRunId) await this.stopRun(this.currentRunId);
1170
1361
  }
@@ -1186,6 +1377,25 @@ No @ needed; the human is the only other party.
1186
1377
  ]);
1187
1378
  return { ...kanbanPatch, ...goalsPatch };
1188
1379
  }
1380
+ installRoleTemplateSkill() {
1381
+ const roleTemplate = readPlainRecord(this.config.roleTemplate);
1382
+ const hermesConfig = readPlainRecord(roleTemplate?.hermesConfig);
1383
+ const agents = readNonEmptyString(hermesConfig?.agents);
1384
+ if (!agents) return;
1385
+ const slug = sanitizeRoleSkillSlug(readNonEmptyString(roleTemplate?.slug) ?? "template");
1386
+ const skillName = `${PRISMER_ROLE_SKILL_PREFIX}-${slug}`;
1387
+ const content = renderRoleTemplateSkill(skillName, agents, roleTemplate);
1388
+ try {
1389
+ const skillDir = join2(this.skillLoader.getSkillsRoot(), skillName);
1390
+ mkdirSync(skillDir, { recursive: true });
1391
+ writeFileSync(join2(skillDir, "SKILL.md"), content, "utf8");
1392
+ } catch (err) {
1393
+ process.stderr.write(
1394
+ `[hermes-adapter] failed to install role-template skill ${skillName} for ${this.profileName}: ${err.message}
1395
+ `
1396
+ );
1397
+ }
1398
+ }
1189
1399
  async prepareNativeKanbanPatch(task) {
1190
1400
  const sourceKind = typeof task.metadata?.sourceKind === "string" ? task.metadata.sourceKind : null;
1191
1401
  const parentTaskId = typeof task.metadata?.parentTaskId === "string" ? task.metadata.parentTaskId : null;
@@ -1294,6 +1504,7 @@ No @ needed; the human is the only other party.
1294
1504
  };
1295
1505
  }
1296
1506
  };
1507
+ APPROVAL_TOOL_NAME = "prismer.approval.request_human_approval";
1297
1508
  }
1298
1509
  });
1299
1510
 
@@ -1326,8 +1537,61 @@ var init_skill_loader3 = __esm({
1326
1537
 
1327
1538
  // src/adapters/openclaw/index.ts
1328
1539
  import { z as z2 } from "zod";
1540
+ import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync2 } from "fs";
1329
1541
  import { homedir as homedir2 } from "os";
1330
1542
  import { join as join3 } from "path";
1543
+ function readPlainRecord2(value) {
1544
+ return value && typeof value === "object" && !Array.isArray(value) ? value : null;
1545
+ }
1546
+ function readNonEmptyString2(value) {
1547
+ return typeof value === "string" && value.trim() ? value.trim() : null;
1548
+ }
1549
+ function sanitizeRoleSkillSlug2(value) {
1550
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 60) || "template";
1551
+ }
1552
+ function renderRoleTemplateSkill2(skillName, agents, roleTemplate) {
1553
+ const mcpServers = Array.isArray(roleTemplate?.mcpServers) ? roleTemplate.mcpServers : [];
1554
+ const tools = /* @__PURE__ */ new Set();
1555
+ for (const server of mcpServers) {
1556
+ const rec = readPlainRecord2(server);
1557
+ const allowlist = Array.isArray(rec?.toolsAllowlist) ? rec.toolsAllowlist : [];
1558
+ for (const tool of allowlist) {
1559
+ if (typeof tool === "string" && tool.trim()) tools.add(tool.trim());
1560
+ }
1561
+ }
1562
+ const allowedTools = [...tools].join(" ");
1563
+ const roleTemplateSlug = JSON.stringify(readNonEmptyString2(roleTemplate?.slug) ?? null);
1564
+ return `---
1565
+ name: ${skillName}
1566
+ description: Role-template operating playbook projected from Prismer RoleTemplate.
1567
+ ${allowedTools ? `allowed-tools: ${allowedTools}
1568
+ ` : ""}metadata:
1569
+ prismer:
1570
+ source: role-template
1571
+ roleTemplateSlug: ${roleTemplateSlug}
1572
+ ---
1573
+
1574
+ # Role Template Playbook
1575
+
1576
+ ${agents.trim()}
1577
+ `;
1578
+ }
1579
+ function pickResponsesSource(ref) {
1580
+ if (ref.reachable === "cdn" && ref.cdnUrl) {
1581
+ return { type: "url", url: ref.cdnUrl };
1582
+ }
1583
+ if (ref.base64) {
1584
+ return {
1585
+ type: "base64",
1586
+ data: ref.base64,
1587
+ media_type: ref.mime ?? "application/octet-stream"
1588
+ };
1589
+ }
1590
+ if (ref.cdnUrl) {
1591
+ return { type: "url", url: ref.cdnUrl };
1592
+ }
1593
+ return null;
1594
+ }
1331
1595
  function getOpenClawSkillsDir(profile) {
1332
1596
  const config = OpenClawProfileConfigSchema.parse(profile.config);
1333
1597
  return resolveOpenClawSkillsDir(profile, config);
@@ -1348,12 +1612,13 @@ async function checkHealth2(baseUrl, apiKey) {
1348
1612
  return false;
1349
1613
  }
1350
1614
  }
1351
- var OpenClawProfileConfigSchema, openclawAdapter, OpenClawService;
1615
+ var PRISMER_ROLE_SKILL_PREFIX2, OpenClawProfileConfigSchema, openclawAdapter, OpenClawService;
1352
1616
  var init_openclaw = __esm({
1353
1617
  "src/adapters/openclaw/index.ts"() {
1354
1618
  "use strict";
1355
1619
  init_contract();
1356
1620
  init_skill_loader3();
1621
+ PRISMER_ROLE_SKILL_PREFIX2 = "prismer-role";
1357
1622
  OpenClawProfileConfigSchema = z2.object({
1358
1623
  /** OpenClaw chat-completions HTTP port. */
1359
1624
  port: z2.number().int().min(1).max(65535).default(18789),
@@ -1398,23 +1663,25 @@ var init_openclaw = __esm({
1398
1663
  );
1399
1664
  }
1400
1665
  const skillsDir = resolveOpenClawSkillsDir(profile, config);
1401
- return new OpenClawService(baseUrl, config.apiKey, config.model, new OpenClawSkillLoader(skillsDir));
1666
+ return new OpenClawService(baseUrl, config.apiKey, config.model, config, new OpenClawSkillLoader(skillsDir));
1402
1667
  },
1403
1668
  async health() {
1404
1669
  return { available: true };
1405
1670
  }
1406
1671
  };
1407
1672
  OpenClawService = class {
1408
- constructor(baseUrl, apiKey, model, skillLoader) {
1673
+ constructor(baseUrl, apiKey, model, config, skillLoader) {
1409
1674
  this.baseUrl = baseUrl;
1410
1675
  this.apiKey = apiKey;
1411
1676
  this.model = model;
1677
+ this.config = config;
1412
1678
  this.skillLoader = skillLoader;
1413
1679
  this.id = baseUrl;
1414
1680
  }
1415
1681
  baseUrl;
1416
1682
  apiKey;
1417
1683
  model;
1684
+ config;
1418
1685
  skillLoader;
1419
1686
  id;
1420
1687
  async healthy() {
@@ -1423,17 +1690,47 @@ var init_openclaw = __esm({
1423
1690
  async dispatch(task) {
1424
1691
  const startedAt = Date.now();
1425
1692
  const systemPrompt = typeof task.metadata?.systemPrompt === "string" ? task.metadata.systemPrompt : void 0;
1693
+ this.installRoleTemplateSkill();
1426
1694
  const skillPrompt = await this.skillLoader.loadSystemPromptFragment().catch((err) => {
1427
1695
  process.stderr.write(`[openclaw-adapter] failed to read skills: ${err.message}
1428
1696
  `);
1429
1697
  return void 0;
1430
1698
  });
1431
1699
  const systemContent = [systemPrompt, skillPrompt].filter(Boolean).join("\n\n");
1432
- const messages = [];
1433
- if (systemContent) messages.push({ role: "system", content: systemContent });
1434
- messages.push({ role: "user", content: task.prompt });
1700
+ const refs = task.assetRefs ?? [];
1701
+ const imageRefs = refs.filter((r) => r.mime?.startsWith("image/"));
1702
+ const fileRefs = refs.filter((r) => r.mime && !r.mime.startsWith("image/"));
1703
+ const inputContent = [
1704
+ { type: "input_text", text: task.prompt }
1705
+ ];
1706
+ for (const r of imageRefs) {
1707
+ const source = pickResponsesSource(r);
1708
+ if (!source) {
1709
+ inputContent.push({
1710
+ type: "input_text",
1711
+ text: `[image attachment id=${r.assetId} unavailable: cdn unreachable and bytes exceeded inline cap]`
1712
+ });
1713
+ continue;
1714
+ }
1715
+ inputContent.push({ type: "input_image", source });
1716
+ }
1717
+ for (const r of fileRefs) {
1718
+ const source = pickResponsesSource(r);
1719
+ if (!source) {
1720
+ inputContent.push({
1721
+ type: "input_text",
1722
+ text: `[file attachment id=${r.assetId} mime=${r.mime ?? "unknown"} unavailable]`
1723
+ });
1724
+ continue;
1725
+ }
1726
+ if (r.filename && source.type === "base64") {
1727
+ source.filename = r.filename;
1728
+ }
1729
+ inputContent.push({ type: "input_file", source });
1730
+ }
1731
+ const input = [{ type: "message", role: "user", content: inputContent }];
1435
1732
  try {
1436
- const res = await fetch(`${this.baseUrl}/v1/chat/completions`, {
1733
+ const res = await fetch(`${this.baseUrl}/v1/responses`, {
1437
1734
  method: "POST",
1438
1735
  headers: {
1439
1736
  Authorization: `Bearer ${this.apiKey}`,
@@ -1441,7 +1738,11 @@ var init_openclaw = __esm({
1441
1738
  },
1442
1739
  body: JSON.stringify({
1443
1740
  model: this.model,
1444
- messages,
1741
+ input,
1742
+ // OpenClaw `/v1/responses` accepts top-level `instructions` for
1743
+ // the system prompt — keeping it out of the user message
1744
+ // preserves cleanish channel-context separation.
1745
+ ...systemContent ? { instructions: systemContent } : {},
1445
1746
  stream: false
1446
1747
  }),
1447
1748
  signal: task.signal
@@ -1457,7 +1758,21 @@ var init_openclaw = __esm({
1457
1758
  };
1458
1759
  }
1459
1760
  const json = await res.json();
1460
- let output = json.choices?.[0]?.message?.content ?? "";
1761
+ let output = "";
1762
+ if (Array.isArray(json.output)) {
1763
+ for (const item of json.output) {
1764
+ if (item?.type === "message" && Array.isArray(item.content)) {
1765
+ for (const part of item.content) {
1766
+ if (part?.type === "output_text" && typeof part.text === "string") {
1767
+ output += part.text;
1768
+ }
1769
+ }
1770
+ }
1771
+ }
1772
+ }
1773
+ if (!output) {
1774
+ output = json.choices?.[0]?.message?.content ?? "";
1775
+ }
1461
1776
  const ERR_PREFIX_RE = /^Cannot read properties of undefined \(reading '[^']+'\)\s*\n+/;
1462
1777
  if (ERR_PREFIX_RE.test(output)) {
1463
1778
  process.stderr.write(
@@ -1475,6 +1790,25 @@ var init_openclaw = __esm({
1475
1790
  return categorizeDispatchError(err, task.signal);
1476
1791
  }
1477
1792
  }
1793
+ installRoleTemplateSkill() {
1794
+ const roleTemplate = readPlainRecord2(this.config.roleTemplate);
1795
+ const openclawConfig = readPlainRecord2(roleTemplate?.openclawConfig);
1796
+ const agents = readNonEmptyString2(openclawConfig?.agents);
1797
+ if (!agents) return;
1798
+ const slug = sanitizeRoleSkillSlug2(readNonEmptyString2(roleTemplate?.slug) ?? "template");
1799
+ const skillName = `${PRISMER_ROLE_SKILL_PREFIX2}-${slug}`;
1800
+ const content = renderRoleTemplateSkill2(skillName, agents, roleTemplate);
1801
+ try {
1802
+ const skillDir = join3(this.skillLoader.getSkillsRoot(), skillName);
1803
+ mkdirSync2(skillDir, { recursive: true });
1804
+ writeFileSync2(join3(skillDir, "SKILL.md"), content, "utf8");
1805
+ } catch (err) {
1806
+ process.stderr.write(
1807
+ `[openclaw-adapter] failed to install role-template skill ${skillName}: ${err.message}
1808
+ `
1809
+ );
1810
+ }
1811
+ }
1478
1812
  };
1479
1813
  }
1480
1814
  });
@@ -1525,11 +1859,39 @@ async function syncAllAgentSkills(profiles, cloud, options = {}) {
1525
1859
  }
1526
1860
  async function syncInstalledSkillsForDispatch(profile, agentImUserId, cloud, signal) {
1527
1861
  if (!agentImUserId) return { synced: 0, skipped: 0, unchanged: 0 };
1528
- const data = await cloud.get(
1862
+ let data = await cloud.get(
1529
1863
  `/api/im/skills/installed?agentId=${encodeURIComponent(agentImUserId)}`,
1530
1864
  { signal }
1531
1865
  );
1532
- const entries = normalizeInstalledSkills(data);
1866
+ let entries = normalizeInstalledSkills(data);
1867
+ if (entries.length === 0 && (profile.adapterName === "hermes" || profile.adapterName === "openclaw")) {
1868
+ try {
1869
+ const backfill = await cloud.request(
1870
+ "POST",
1871
+ `/api/im/agents/${encodeURIComponent(agentImUserId)}/skills/install-builtins`,
1872
+ { body: {}, signal }
1873
+ );
1874
+ if (backfill.ok && backfill.data && typeof backfill.data === "object" && "data" in backfill.data) {
1875
+ const installed = backfill.data.data?.installed ?? 0;
1876
+ if (installed > 0) {
1877
+ process.stdout.write(
1878
+ `[daemon] skill sync: backfilled ${installed} built-in skills for agent ${agentImUserId.slice(-8)}
1879
+ `
1880
+ );
1881
+ data = await cloud.get(
1882
+ `/api/im/skills/installed?agentId=${encodeURIComponent(agentImUserId)}`,
1883
+ { signal }
1884
+ );
1885
+ entries = normalizeInstalledSkills(data);
1886
+ }
1887
+ }
1888
+ } catch (err) {
1889
+ process.stderr.write(
1890
+ `[daemon] skill sync: backfill skipped for ${agentImUserId.slice(-8)}: ${err.message}
1891
+ `
1892
+ );
1893
+ }
1894
+ }
1533
1895
  const skillsRoot = resolveSkillsRoot(profile);
1534
1896
  if (!skillsRoot) return { synced: 0, skipped: 0, unchanged: 0 };
1535
1897
  let synced = 0;
@@ -2271,7 +2633,7 @@ var require_package = __commonJS({
2271
2633
  "package.json"(exports, module) {
2272
2634
  module.exports = {
2273
2635
  name: "@prismer/runtime",
2274
- version: "2.0.3",
2636
+ version: "2.0.5",
2275
2637
  description: "Prismer Cloud daemon runtime \u2014 TS-only adapter host for hosted IM agents",
2276
2638
  type: "module",
2277
2639
  main: "dist/index.js",
@@ -2305,6 +2667,7 @@ var require_package = __commonJS({
2305
2667
  },
2306
2668
  dependencies: {
2307
2669
  "@iarna/toml": "^2.2.5",
2670
+ "@modelcontextprotocol/sdk": "^1.29.0",
2308
2671
  "better-sqlite3": "^11.8.0",
2309
2672
  commander: "^12.1.0",
2310
2673
  qrcode: "^1.5.4",
@@ -2366,7 +2729,7 @@ __export(util_exports, {
2366
2729
  warn: () => warn,
2367
2730
  writePidFile: () => writePidFile
2368
2731
  });
2369
- import { existsSync as existsSync11, readFileSync as readFileSync9, unlinkSync as unlinkSync2, writeFileSync as writeFileSync6 } from "fs";
2732
+ import { existsSync as existsSync11, readFileSync as readFileSync9, unlinkSync as unlinkSync2, writeFileSync as writeFileSync7 } from "fs";
2370
2733
  import { join as join15 } from "path";
2371
2734
  function color(kind, text) {
2372
2735
  if (process.env.NO_COLOR === "1" || process.env.NO_COLOR === "true") return text;
@@ -2464,7 +2827,7 @@ function pidFilePath(paths) {
2464
2827
  return join15(paths.root, "daemon.pid");
2465
2828
  }
2466
2829
  function writePidFile(paths, pid) {
2467
- writeFileSync6(pidFilePath(paths), `${pid}
2830
+ writeFileSync7(pidFilePath(paths), `${pid}
2468
2831
  `, "utf8");
2469
2832
  }
2470
2833
  function readPidFile(paths) {
@@ -2687,9 +3050,9 @@ var WsClient = class extends EventEmitter {
2687
3050
 
2688
3051
  // src/sync/store.ts
2689
3052
  import Database2 from "better-sqlite3";
2690
- import { existsSync as existsSync2, mkdirSync as mkdirSync2 } from "fs";
3053
+ import { existsSync as existsSync2, mkdirSync as mkdirSync3 } from "fs";
2691
3054
  import { dirname as dirname2 } from "path";
2692
- var SCHEMA_VERSION = 3;
3055
+ var SCHEMA_VERSION = 4;
2693
3056
  var MIGRATIONS = [
2694
3057
  {
2695
3058
  version: 1,
@@ -2815,6 +3178,36 @@ var MIGRATIONS = [
2815
3178
  CREATE INDEX IF NOT EXISTS idx_asset_meta_seq
2816
3179
  ON asset_metadata_index(workspace_id, asset_index_seq);
2817
3180
  `
3181
+ },
3182
+ {
3183
+ version: 4,
3184
+ up: `
3185
+ -- Wave 4 / W3 \xA74.3 \u2014 pending dispatch replies for crash recovery.
3186
+ --
3187
+ -- The two-phase reply protocol writes a row here BEFORE calling
3188
+ -- POST /dispatch/:taskId/prepare so that a crash between prepare
3189
+ -- and commit leaves a recoverable trail. On daemon cold-start, any
3190
+ -- row with status='prepared' triggers an idempotent commit retry.
3191
+ --
3192
+ -- The server-side cutoff for orphaned 'prepared' rows is 1h
3193
+ -- (PREPARED_REPLY_ORPHAN_MS in dispatch-reply.service.ts); rows
3194
+ -- older than that will fail commit with DISPATCH_REPLY_INVALID_STATE
3195
+ -- and must be reported to the user as 'dispatch lost'.
3196
+ CREATE TABLE IF NOT EXISTS pending_dispatch_replies (
3197
+ idempotency_key TEXT PRIMARY KEY,
3198
+ task_id TEXT NOT NULL,
3199
+ reply_id TEXT,
3200
+ status TEXT NOT NULL DEFAULT 'pending',
3201
+ payload_json TEXT NOT NULL,
3202
+ prepared_at INTEGER,
3203
+ created_at INTEGER NOT NULL,
3204
+ last_attempt_at INTEGER,
3205
+ attempt_count INTEGER NOT NULL DEFAULT 0,
3206
+ last_error TEXT
3207
+ );
3208
+ CREATE INDEX IF NOT EXISTS idx_pending_reply_task ON pending_dispatch_replies (task_id);
3209
+ CREATE INDEX IF NOT EXISTS idx_pending_reply_status ON pending_dispatch_replies (status, created_at);
3210
+ `
2818
3211
  }
2819
3212
  ];
2820
3213
  function runSql(db, sql) {
@@ -2822,7 +3215,7 @@ function runSql(db, sql) {
2822
3215
  }
2823
3216
  function openLocalDb(path9) {
2824
3217
  if (path9 !== ":memory:" && !existsSync2(dirname2(path9))) {
2825
- mkdirSync2(dirname2(path9), { recursive: true });
3218
+ mkdirSync3(dirname2(path9), { recursive: true });
2826
3219
  }
2827
3220
  const db = new Database2(path9);
2828
3221
  db.pragma("journal_mode = WAL");
@@ -3059,7 +3452,7 @@ function listRoleTemplates() {
3059
3452
 
3060
3453
  // src/config.ts
3061
3454
  import * as TOML from "@iarna/toml";
3062
- import { existsSync as existsSync3, mkdirSync as mkdirSync3, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
3455
+ import { existsSync as existsSync3, mkdirSync as mkdirSync4, readFileSync as readFileSync2, writeFileSync as writeFileSync3 } from "fs";
3063
3456
  import { homedir as homedir3 } from "os";
3064
3457
  import { dirname as dirname3, join as join4 } from "path";
3065
3458
  import { z as z3 } from "zod";
@@ -3126,13 +3519,13 @@ function loadConfig(paths = resolvePaths()) {
3126
3519
  }
3127
3520
  function saveConfig(config, paths = resolvePaths()) {
3128
3521
  if (!existsSync3(paths.root)) {
3129
- mkdirSync3(paths.root, { recursive: true });
3522
+ mkdirSync4(paths.root, { recursive: true });
3130
3523
  }
3131
3524
  if (!existsSync3(dirname3(paths.configFile))) {
3132
- mkdirSync3(dirname3(paths.configFile), { recursive: true });
3525
+ mkdirSync4(dirname3(paths.configFile), { recursive: true });
3133
3526
  }
3134
3527
  ConfigSchema.parse(config);
3135
- writeFileSync2(paths.configFile, TOML.stringify(config), "utf8");
3528
+ writeFileSync3(paths.configFile, TOML.stringify(config), "utf8");
3136
3529
  }
3137
3530
  function deriveWsUrl(httpBase) {
3138
3531
  const u = new URL(httpBase);
@@ -3305,7 +3698,7 @@ function envelope(type, payload, requestId) {
3305
3698
  // src/asset-cache.ts
3306
3699
  import { createHash as createHash2 } from "crypto";
3307
3700
  import { promises as dnsp } from "dns";
3308
- import { existsSync as existsSync4, mkdirSync as mkdirSync4, readFileSync as readFileSync3, renameSync, statSync, unlinkSync, writeFileSync as writeFileSync3 } from "fs";
3701
+ import { existsSync as existsSync4, mkdirSync as mkdirSync5, readFileSync as readFileSync3, renameSync, statSync, unlinkSync, writeFileSync as writeFileSync4 } from "fs";
3309
3702
  import { isIP, isIPv4, isIPv6 } from "net";
3310
3703
  import { dirname as dirname4, join as join5 } from "path";
3311
3704
  import { Agent as UndiciAgent } from "undici";
@@ -3331,7 +3724,7 @@ var AssetCache = class {
3331
3724
  this.cacheDir = opts.cacheDir;
3332
3725
  this.maxBytes = opts.maxBytes ?? 5 * 1024 * 1024 * 1024;
3333
3726
  if (!existsSync4(this.cacheDir)) {
3334
- mkdirSync4(this.cacheDir, { recursive: true });
3727
+ mkdirSync5(this.cacheDir, { recursive: true });
3335
3728
  }
3336
3729
  }
3337
3730
  /** Local file path for a content hash. Files split by first 2 chars for fs scaling. */
@@ -3406,10 +3799,10 @@ var AssetCache = class {
3406
3799
  }
3407
3800
  const localPath = this.pathFor(hash);
3408
3801
  if (!existsSync4(dirname4(localPath))) {
3409
- mkdirSync4(dirname4(localPath), { recursive: true });
3802
+ mkdirSync5(dirname4(localPath), { recursive: true });
3410
3803
  }
3411
3804
  const tmpPath = `${localPath}.${process.pid}.${Date.now()}.tmp`;
3412
- writeFileSync3(tmpPath, buf);
3805
+ writeFileSync4(tmpPath, buf);
3413
3806
  renameSync(tmpPath, localPath);
3414
3807
  const mime = res.headers.get("content-type");
3415
3808
  const now = Date.now();
@@ -3470,10 +3863,10 @@ var AssetCache = class {
3470
3863
  }
3471
3864
  const localPath = this.pathFor(hash);
3472
3865
  if (!existsSync4(dirname4(localPath))) {
3473
- mkdirSync4(dirname4(localPath), { recursive: true });
3866
+ mkdirSync5(dirname4(localPath), { recursive: true });
3474
3867
  }
3475
3868
  const tmpPath = `${localPath}.${process.pid}.${Date.now()}.tmp`;
3476
- writeFileSync3(tmpPath, body);
3869
+ writeFileSync4(tmpPath, body);
3477
3870
  renameSync(tmpPath, localPath);
3478
3871
  const now = Date.now();
3479
3872
  this.db.prepare(
@@ -3727,7 +4120,7 @@ async function fetchUrlWithGuards(rawUrl, opts) {
3727
4120
  }
3728
4121
 
3729
4122
  // src/daemon/asset/mirror.ts
3730
- import { existsSync as existsSync5, mkdirSync as mkdirSync5, readFileSync as readFileSync4, writeFileSync as writeFileSync4 } from "fs";
4123
+ import { existsSync as existsSync5, mkdirSync as mkdirSync6, readFileSync as readFileSync4, writeFileSync as writeFileSync5 } from "fs";
3731
4124
  import { join as join6 } from "path";
3732
4125
  function rowToBinding(row) {
3733
4126
  return {
@@ -3750,7 +4143,7 @@ var WorkspaceMirror = class {
3750
4143
  this.cloud = opts.cloud;
3751
4144
  this.workspaceId = opts.workspaceId;
3752
4145
  if (!existsSync5(opts.workspaceStateDir)) {
3753
- mkdirSync5(opts.workspaceStateDir, { recursive: true });
4146
+ mkdirSync6(opts.workspaceStateDir, { recursive: true });
3754
4147
  }
3755
4148
  this.cursorPath = join6(opts.workspaceStateDir, "asset-cursor.json");
3756
4149
  }
@@ -3771,7 +4164,7 @@ var WorkspaceMirror = class {
3771
4164
  cursor,
3772
4165
  writtenAt: Date.now()
3773
4166
  };
3774
- writeFileSync4(this.cursorPath, JSON.stringify(payload, null, 2));
4167
+ writeFileSync5(this.cursorPath, JSON.stringify(payload, null, 2));
3775
4168
  }
3776
4169
  /**
3777
4170
  * Pull workspace_file changes since the persisted cursor and upsert into
@@ -4180,6 +4573,262 @@ function extractHttpUrls(text) {
4180
4573
  import { readFileSync as readFileSync5, promises as fsp3 } from "fs";
4181
4574
  import * as path from "path";
4182
4575
  init_skill_sync();
4576
+
4577
+ // src/daemon/task-heartbeat.ts
4578
+ var TaskHeartbeat = class {
4579
+ constructor(opts) {
4580
+ this.opts = opts;
4581
+ this.intervalMs = opts.intervalMs ?? 15e3;
4582
+ this.maxRetries = opts.maxRetries ?? 3;
4583
+ this.retryBackoffMs = opts.retryBackoffMs ?? 100;
4584
+ this.now = opts.now ?? Date.now;
4585
+ }
4586
+ opts;
4587
+ timer;
4588
+ version = 0;
4589
+ taskId;
4590
+ getPhase;
4591
+ /** Tracks the last step time pushed via touchStep() — informational. */
4592
+ lastStepAt;
4593
+ intervalMs;
4594
+ maxRetries;
4595
+ retryBackoffMs;
4596
+ now;
4597
+ /**
4598
+ * Begin heartbeating for `taskId`. `getPhase` is invoked at every tick to
4599
+ * pick up the live phase string — adapters can mutate their phase state
4600
+ * freely and the heartbeat will surface it on the next 15s boundary.
4601
+ *
4602
+ * Calling start() twice on the same instance replaces the active task
4603
+ * (previous timer is cleared). Returns silently when already started for
4604
+ * the same taskId.
4605
+ */
4606
+ start(taskId, getPhase) {
4607
+ if (this.taskId === taskId && this.timer) return;
4608
+ this.stop();
4609
+ this.taskId = taskId;
4610
+ this.getPhase = getPhase;
4611
+ this.timer = setInterval(() => {
4612
+ void this.push();
4613
+ }, this.intervalMs);
4614
+ this.timer.unref?.();
4615
+ }
4616
+ /**
4617
+ * Stop the heartbeat loop. Idempotent.
4618
+ */
4619
+ stop() {
4620
+ if (this.timer) {
4621
+ clearInterval(this.timer);
4622
+ this.timer = void 0;
4623
+ }
4624
+ this.taskId = void 0;
4625
+ this.getPhase = void 0;
4626
+ }
4627
+ /**
4628
+ * Phase transition — push immediately AND update what the timer-driven
4629
+ * tick will report next time. Adapter calls this whenever the lifecycle
4630
+ * advances (thinking → tool_use → reasoning → responding → ...).
4631
+ *
4632
+ * No-op when no task is active.
4633
+ */
4634
+ setPhase(phase) {
4635
+ if (!this.taskId) return;
4636
+ this.getPhase = () => phase;
4637
+ void this.push();
4638
+ }
4639
+ /**
4640
+ * Mark "I just made observable progress." Updates lastStepAt; next push
4641
+ * (timer-driven or setPhase-driven) carries it. Lightweight — no immediate
4642
+ * send. Adapters call this around tool calls / token boundaries so the
4643
+ * cloud reaper can distinguish "alive but slow" from "truly stuck".
4644
+ */
4645
+ touchStep() {
4646
+ this.lastStepAt = this.now();
4647
+ }
4648
+ /**
4649
+ * Manually trigger one heartbeat send (does not affect the timer). Mainly
4650
+ * useful for tests that don't want to await a 15s tick. Returns the
4651
+ * heartbeatVersion that was sent, or undefined if no task is active.
4652
+ */
4653
+ async forceTick() {
4654
+ if (!this.taskId) return void 0;
4655
+ return this.push();
4656
+ }
4657
+ /** Test helper: current heartbeatVersion (last value sent, may be 0 before first tick). */
4658
+ get currentVersion() {
4659
+ return this.version;
4660
+ }
4661
+ async push() {
4662
+ if (!this.taskId || !this.getPhase) return void 0;
4663
+ const taskId = this.taskId;
4664
+ const phase = this.getPhase();
4665
+ const heartbeatVersion = ++this.version;
4666
+ const payload = {
4667
+ taskId,
4668
+ heartbeatVersion,
4669
+ currentPhase: phase,
4670
+ ...this.lastStepAt !== void 0 ? { lastStepAt: this.lastStepAt } : {}
4671
+ };
4672
+ const msg = envelope("task.heartbeat", payload);
4673
+ let lastErr;
4674
+ for (let attempt = 0; attempt <= this.maxRetries; attempt += 1) {
4675
+ try {
4676
+ if (this.opts.ws.isOpen && !this.opts.ws.isOpen()) {
4677
+ return heartbeatVersion;
4678
+ }
4679
+ this.opts.ws.send(msg);
4680
+ return heartbeatVersion;
4681
+ } catch (err) {
4682
+ lastErr = err instanceof Error ? err : new Error(String(err));
4683
+ if (attempt < this.maxRetries) {
4684
+ const backoff = this.retryBackoffMs * Math.pow(2, attempt);
4685
+ await sleep(backoff);
4686
+ }
4687
+ }
4688
+ }
4689
+ if (lastErr) {
4690
+ try {
4691
+ this.opts.onGiveUp?.(taskId, lastErr);
4692
+ } catch {
4693
+ }
4694
+ }
4695
+ return heartbeatVersion;
4696
+ }
4697
+ };
4698
+ function sleep(ms) {
4699
+ return new Promise((resolve3) => {
4700
+ const handle = setTimeout(resolve3, ms);
4701
+ handle.unref?.();
4702
+ });
4703
+ }
4704
+
4705
+ // src/daemon/step-recorder.ts
4706
+ var StepRecorder = class {
4707
+ constructor(opts) {
4708
+ this.opts = opts;
4709
+ this.reasoningThrottleMs = opts.reasoningThrottleMs ?? 500;
4710
+ this.now = opts.now ?? Date.now;
4711
+ }
4712
+ opts;
4713
+ seq = 0;
4714
+ reasoningBuf = { texts: [], firstAt: 0 };
4715
+ reasoningTimer;
4716
+ reasoningThrottleMs;
4717
+ now;
4718
+ /** Push a phase transition step. Immediate (no throttle). */
4719
+ recordPhaseChange(phase) {
4720
+ this.emit("phase_change", { phase });
4721
+ }
4722
+ /**
4723
+ * Push a tool-call step. Immediate.
4724
+ *
4725
+ * `toolCallId` is the daemon-side correlation id used to pair with the
4726
+ * subsequent recordToolResult call. Free-form; adapter decides.
4727
+ */
4728
+ recordToolCall(toolName, input, toolCallId) {
4729
+ this.emit("tool_call", {
4730
+ toolName,
4731
+ inputSummary: summarize(input),
4732
+ ...toolCallId ? { toolCallId } : {}
4733
+ });
4734
+ }
4735
+ /** Push a tool-result step. Immediate. */
4736
+ recordToolResult(toolCallId, output) {
4737
+ this.emit("tool_result", {
4738
+ toolCallId,
4739
+ outputSummary: summarize(output)
4740
+ });
4741
+ }
4742
+ /**
4743
+ * Buffer a reasoning chunk for batched dispatch. Multiple chunks within
4744
+ * `reasoningThrottleMs` are concatenated and emitted as a single
4745
+ * `reasoning_chunk` step when the timer fires.
4746
+ */
4747
+ recordReasoningChunk(text) {
4748
+ if (!text) return;
4749
+ if (this.reasoningBuf.texts.length === 0) {
4750
+ this.reasoningBuf.firstAt = this.now();
4751
+ }
4752
+ this.reasoningBuf.texts.push(text);
4753
+ if (this.reasoningTimer) return;
4754
+ this.reasoningTimer = setTimeout(() => {
4755
+ this.flushReasoning();
4756
+ }, this.reasoningThrottleMs);
4757
+ this.reasoningTimer.unref?.();
4758
+ }
4759
+ /** Push an error step. Immediate. */
4760
+ recordError(message, payload) {
4761
+ this.emit("error", { message, ...payload ?? {} });
4762
+ }
4763
+ /**
4764
+ * Force any pending reasoning buffer out the door. Call on shutdown so
4765
+ * trailing tokens aren't lost.
4766
+ */
4767
+ flush() {
4768
+ this.flushReasoning();
4769
+ }
4770
+ /** Test helper. */
4771
+ get currentSeq() {
4772
+ return this.seq;
4773
+ }
4774
+ flushReasoning() {
4775
+ if (this.reasoningTimer) {
4776
+ clearTimeout(this.reasoningTimer);
4777
+ this.reasoningTimer = void 0;
4778
+ }
4779
+ if (this.reasoningBuf.texts.length === 0) return;
4780
+ const text = this.reasoningBuf.texts.join("");
4781
+ const firstAt = this.reasoningBuf.firstAt;
4782
+ this.reasoningBuf = { texts: [], firstAt: 0 };
4783
+ this.emit("reasoning_chunk", { text }, firstAt);
4784
+ }
4785
+ emit(kind, payload, occurredAt) {
4786
+ const frame = {
4787
+ seq: ++this.seq,
4788
+ kind,
4789
+ payload,
4790
+ occurredAt: occurredAt ?? this.now()
4791
+ };
4792
+ const msg = envelope("task.step.append", {
4793
+ taskRunId: this.opts.taskRunId,
4794
+ step: frame
4795
+ });
4796
+ try {
4797
+ if (this.opts.ws.isOpen && !this.opts.ws.isOpen()) {
4798
+ try {
4799
+ this.opts.onSend?.(frame);
4800
+ } catch {
4801
+ }
4802
+ return;
4803
+ }
4804
+ this.opts.ws.send(msg);
4805
+ try {
4806
+ this.opts.onSend?.(frame);
4807
+ } catch {
4808
+ }
4809
+ } catch {
4810
+ }
4811
+ }
4812
+ };
4813
+ var MAX_SUMMARY_CHARS = 512;
4814
+ function summarize(value) {
4815
+ let text;
4816
+ if (value == null) text = "";
4817
+ else if (typeof value === "string") text = value;
4818
+ else {
4819
+ try {
4820
+ text = JSON.stringify(value);
4821
+ } catch {
4822
+ text = String(value);
4823
+ }
4824
+ }
4825
+ if (text.length > MAX_SUMMARY_CHARS) {
4826
+ return `${text.slice(0, MAX_SUMMARY_CHARS)}\u2026(+${text.length - MAX_SUMMARY_CHARS} chars)`;
4827
+ }
4828
+ return text;
4829
+ }
4830
+
4831
+ // src/daemon/dispatch.ts
4183
4832
  var DEFAULT_CONTEXT_MAX = 8e3;
4184
4833
  var GOAL_CONTEXT_MAX = 4;
4185
4834
  var MEMORY_DIGEST_MAX_BYTES = 4e3;
@@ -4195,6 +4844,8 @@ async function handleDispatch(payload, requestId, deps) {
4195
4844
  const { taskId, agentImUserId } = payload;
4196
4845
  let resolvedHashes = [];
4197
4846
  let reply;
4847
+ let heartbeatRef;
4848
+ let recorderRef;
4198
4849
  const outboxBase = deps.paths?.runsDir ? path.join(deps.paths.runsDir, taskId) : null;
4199
4850
  const outboxDir = outboxBase ? path.join(outboxBase, "_outbox") : null;
4200
4851
  const workDir = outboxBase ? path.join(outboxBase, "workdir") : null;
@@ -4276,7 +4927,13 @@ async function handleDispatch(payload, requestId, deps) {
4276
4927
  resolvedHashes.push(...r.resolvedHashes);
4277
4928
  rewrittenContext = payload.context.map((e, i) => ({ ...e, content: r.texts[i] }));
4278
4929
  }
4279
- const assetResolution = await resolveAssetRefs(payload.assetRefs, deps.assetCache, taskId);
4930
+ const assetResolution = await resolveAssetRefs(payload.assetRefs, deps.assetCache, taskId, {
4931
+ cloud: deps.cloud,
4932
+ enabled: deps.visionAux?.enabled !== false,
4933
+ cacheDir: deps.visionAux?.cacheDir ?? (deps.paths ? path.join(deps.paths.cacheDir, "vision-cache") : void 0),
4934
+ timeoutMs: deps.visionAux?.timeoutMs,
4935
+ signal: deps.signal
4936
+ });
4280
4937
  resolvedHashes.push(...assetResolution.pinnedHashes);
4281
4938
  const basePrompt = composePrompt(
4282
4939
  rewrittenPrompt.text,
@@ -4311,9 +4968,44 @@ async function handleDispatch(payload, requestId, deps) {
4311
4968
  }
4312
4969
  const operatingPrinciples = resolveOperatingPrinciples(profile.config);
4313
4970
  const composedSystemPrompt = [profileSystemPrompt, operatingPrinciples].filter((part) => typeof part === "string" && part.length > 0).join("\n\n");
4971
+ let activePhase = "thinking";
4972
+ const heartbeat = new TaskHeartbeat({
4973
+ ws: deps.ws,
4974
+ onGiveUp: (tid, err) => process.stderr.write(
4975
+ `[daemon] task.heartbeat give-up task=${tid}: ${err.message}
4976
+ `
4977
+ )
4978
+ });
4979
+ heartbeatRef = heartbeat;
4980
+ heartbeat.start(taskId, () => activePhase);
4981
+ const recorder = new StepRecorder({ ws: deps.ws, taskRunId: taskId });
4982
+ recorderRef = recorder;
4983
+ recorder.recordPhaseChange(activePhase);
4314
4984
  const taskInput = {
4315
4985
  taskId,
4316
4986
  prompt: adapterPrompt,
4987
+ heartbeat: {
4988
+ setPhase: (phase) => {
4989
+ activePhase = phase;
4990
+ heartbeat.setPhase(phase);
4991
+ recorder.recordPhaseChange(phase);
4992
+ },
4993
+ touchStep: () => heartbeat.touchStep()
4994
+ },
4995
+ recorder: {
4996
+ recordPhaseChange: (phase) => {
4997
+ activePhase = phase;
4998
+ heartbeat.setPhase(phase);
4999
+ recorder.recordPhaseChange(phase);
5000
+ },
5001
+ recordToolCall: (toolName, input, toolCallId) => recorder.recordToolCall(toolName, input, toolCallId),
5002
+ recordToolResult: (toolCallId, output) => recorder.recordToolResult(toolCallId, output),
5003
+ recordReasoningChunk: (text) => recorder.recordReasoningChunk(text),
5004
+ recordError: (message, payload2) => recorder.recordError(message, payload2)
5005
+ },
5006
+ // 14b rev.3 §9 P3 — multimodal-aware adapters (Hermes, OpenClaw) pick
5007
+ // up the resolved bytes/URLs here; text-only adapters ignore the field.
5008
+ ...assetResolution.resolvedRefs.length > 0 ? { assetRefs: assetResolution.resolvedRefs } : {},
4317
5009
  metadata: {
4318
5010
  ...payload.metadata,
4319
5011
  conversationId: payload.conversationId,
@@ -4376,6 +5068,9 @@ async function handleDispatch(payload, requestId, deps) {
4376
5068
  }
4377
5069
  }
4378
5070
  const reaperAborted = deps.signal?.aborted && result.error?.code === "task_cancelled";
5071
+ const approvalRequested = Boolean(
5072
+ result.metadata?.approvalRequested
5073
+ );
4379
5074
  let collectedAssetIds = [];
4380
5075
  if (deps.outboxWatcher && outboxDir) {
4381
5076
  try {
@@ -4387,14 +5082,23 @@ async function handleDispatch(payload, requestId, deps) {
4387
5082
  collectedAssetIds = deps.outboxWatcher.flushPending(taskId);
4388
5083
  }
4389
5084
  const reaperLimitMs = typeof payload.timeoutMs === "number" && payload.timeoutMs > 0 ? payload.timeoutMs : 5 * 6e4;
5085
+ const finalError = approvalRequested ? {
5086
+ code: "awaiting_human_approval",
5087
+ message: "Agent requested human approval and is suspended pending decision. Cloud will redispatch with `approval.decided` once the human responds."
5088
+ } : reaperAborted ? {
5089
+ code: "daemon_task_timeout",
5090
+ message: `Daemon-side reaper aborted after ${reaperLimitMs}ms inactivity (no progress events from adapter)`
5091
+ } : result.error;
4390
5092
  reply = {
4391
5093
  taskId,
5094
+ // When approval is pending we keep ok=false (the run did not produce a
5095
+ // completion-style output) but the cloud-side handler treats the
5096
+ // `awaiting_human_approval` code as non-terminal — task stays alive,
5097
+ // no red failure pill, and the next dispatch (after the human decides)
5098
+ // continues normally.
4392
5099
  ok: result.ok,
4393
5100
  output: result.output,
4394
- error: reaperAborted ? {
4395
- code: "daemon_task_timeout",
4396
- message: `Daemon-side reaper aborted after ${reaperLimitMs}ms inactivity (no progress events from adapter)`
4397
- } : result.error,
5101
+ error: finalError,
4398
5102
  ...collectedAssetIds.length > 0 ? { assetIds: collectedAssetIds } : {},
4399
5103
  metrics: result.metrics,
4400
5104
  ...assetResolution.observability.length > 0 || urlObservations.length > 0 ? { assetObservability: [...assetResolution.observability, ...urlObservations] } : {}
@@ -4416,6 +5120,18 @@ async function handleDispatch(payload, requestId, deps) {
4416
5120
  }
4417
5121
  };
4418
5122
  } finally {
5123
+ try {
5124
+ recorderRef?.flush();
5125
+ } catch (err) {
5126
+ process.stderr.write(`[daemon] step recorder flush failed task=${taskId}: ${err.message}
5127
+ `);
5128
+ }
5129
+ try {
5130
+ heartbeatRef?.stop();
5131
+ } catch (err) {
5132
+ process.stderr.write(`[daemon] heartbeat stop failed task=${taskId}: ${err.message}
5133
+ `);
5134
+ }
4419
5135
  for (const hash of new Set(resolvedHashes)) {
4420
5136
  try {
4421
5137
  deps.assetCache.unpin(hash);
@@ -4492,6 +5208,10 @@ function isTextLikeMime(mime) {
4492
5208
  if (m.endsWith("+json") || m.endsWith("+xml") || m.endsWith("+csv")) return true;
4493
5209
  return false;
4494
5210
  }
5211
+ function isImageMime(mime) {
5212
+ if (!mime) return false;
5213
+ return mime.toLowerCase().split(";")[0].trim().startsWith("image/");
5214
+ }
4495
5215
  var HASH_REF_RE = /(?:^|\s)#([^\s#]+)/g;
4496
5216
  var HEX_COLOR_RE = /^[0-9a-fA-F]{3,8}$/;
4497
5217
  var FILE_EXT_RE = /\.[a-zA-Z0-9]{1,10}$/;
@@ -4570,8 +5290,34 @@ async function resolveHashRefs(prompt, assetIndex, cloud) {
4570
5290
  }
4571
5291
  return { text: result, resolutions };
4572
5292
  }
4573
- async function resolveAssetRefs(refs, cache, taskId) {
4574
- const out = { promptBlocks: [], observability: [], pinnedHashes: [] };
5293
+ var ASSET_BASE64_INLINE_MAX_BYTES = 10 * 1024 * 1024;
5294
+ async function probeCdnReachable(cdnUrl, timeoutMs = 1500) {
5295
+ try {
5296
+ const u = new URL(cdnUrl);
5297
+ if (u.hostname === "localhost" || u.hostname === "127.0.0.1" || u.hostname.startsWith("192.168.")) {
5298
+ return false;
5299
+ }
5300
+ } catch {
5301
+ return false;
5302
+ }
5303
+ try {
5304
+ const r = await fetch(cdnUrl, {
5305
+ method: "HEAD",
5306
+ signal: AbortSignal.timeout(timeoutMs)
5307
+ });
5308
+ if (r.ok) return true;
5309
+ return false;
5310
+ } catch {
5311
+ return false;
5312
+ }
5313
+ }
5314
+ async function resolveAssetRefs(refs, cache, taskId, visionAux) {
5315
+ const out = {
5316
+ promptBlocks: [],
5317
+ observability: [],
5318
+ pinnedHashes: [],
5319
+ resolvedRefs: []
5320
+ };
4575
5321
  if (!refs || refs.length === 0) return out;
4576
5322
  for (const ref of refs) {
4577
5323
  let cached;
@@ -4636,7 +5382,42 @@ async function resolveAssetRefs(refs, cache, taskId) {
4636
5382
  continue;
4637
5383
  }
4638
5384
  }
4639
- out.promptBlocks.push(formatUriOnlyAssetBlock(ref, effectiveMime, cached.localPath));
5385
+ let reachable = "unknown";
5386
+ let base64;
5387
+ if (ref.cdnUrl) {
5388
+ const ok2 = await probeCdnReachable(ref.cdnUrl);
5389
+ if (ok2) {
5390
+ reachable = "cdn";
5391
+ }
5392
+ }
5393
+ if (reachable !== "cdn") {
5394
+ try {
5395
+ const buf = readFileSync5(cached.localPath);
5396
+ if (buf.byteLength <= ASSET_BASE64_INLINE_MAX_BYTES) {
5397
+ base64 = buf.toString("base64");
5398
+ reachable = "base64";
5399
+ } else {
5400
+ reachable = "unknown";
5401
+ }
5402
+ } catch (err) {
5403
+ const errMsg = `base64 fallback read failed: ${err.message}`;
5404
+ logAssetResolveFailure(taskId, ref, errMsg, effectiveMime);
5405
+ }
5406
+ }
5407
+ const resolvedRef = {
5408
+ ...ref,
5409
+ mime: effectiveMime,
5410
+ localPath: cached.localPath,
5411
+ base64,
5412
+ reachable
5413
+ };
5414
+ out.resolvedRefs.push(resolvedRef);
5415
+ if (isImageMime(effectiveMime) && visionAux?.enabled) {
5416
+ const block = await describeImageAttachment(ref, effectiveMime, resolvedRef, visionAux, taskId);
5417
+ out.promptBlocks.push(block);
5418
+ } else {
5419
+ out.promptBlocks.push(formatAttachmentReminderBlock(ref, effectiveMime));
5420
+ }
4640
5421
  out.observability.push({
4641
5422
  assetId: ref.assetId,
4642
5423
  contentHash: ref.contentHash,
@@ -4647,6 +5428,110 @@ async function resolveAssetRefs(refs, cache, taskId) {
4647
5428
  }
4648
5429
  return out;
4649
5430
  }
5431
+ async function describeImageAttachment(ref, mime, resolved, opts, taskId) {
5432
+ const cached = await readVisionAuxLocalCache(opts.cacheDir, ref.contentHash, mime);
5433
+ if (cached) return formatVisionAuxBlock(ref, mime, cached.description, true);
5434
+ const source = buildVisionAuxSource(resolved, mime);
5435
+ if (!source) {
5436
+ return formatVisionAuxUnavailableBlock(ref, mime, "no reachable URL or inline bytes");
5437
+ }
5438
+ try {
5439
+ const description = await callVisionAux(ref, mime, source, opts);
5440
+ await writeVisionAuxLocalCache(opts.cacheDir, ref.contentHash, mime, description);
5441
+ return formatVisionAuxBlock(ref, mime, description.description, false);
5442
+ } catch (err) {
5443
+ const message = err instanceof Error ? err.message : String(err);
5444
+ process.stderr.write(
5445
+ `[daemon] vision-aux failed task=${taskId ?? "unknown"} assetId=${ref.assetId} hash=${ref.contentHash}: ${message}
5446
+ `
5447
+ );
5448
+ return formatVisionAuxUnavailableBlock(ref, mime, message);
5449
+ }
5450
+ }
5451
+ function buildVisionAuxSource(resolved, mime) {
5452
+ if (resolved.reachable === "cdn" && resolved.cdnUrl) return { kind: "url", url: resolved.cdnUrl };
5453
+ if (resolved.base64 && mime) return { kind: "data_url", url: `data:${mime};base64,${resolved.base64}` };
5454
+ return null;
5455
+ }
5456
+ async function callVisionAux(ref, mime, source, opts) {
5457
+ const secret = process.env.VISION_AUX_INTERNAL_SECRET || process.env.INTERNAL_API_SECRET || process.env.PRISMER_INTERNAL_SECRET;
5458
+ const headers = secret ? { "x-prismer-internal-secret": secret } : void 0;
5459
+ const res = await opts.cloud.request(
5460
+ "POST",
5461
+ "/api/internal/vision-aux/describe",
5462
+ {
5463
+ body: {
5464
+ assetId: ref.assetId,
5465
+ contentHash: ref.contentHash,
5466
+ mime: mime ?? ref.mime ?? "image/unknown",
5467
+ source
5468
+ },
5469
+ headers,
5470
+ timeoutMs: opts.timeoutMs ?? 12e3,
5471
+ signal: opts.signal
5472
+ }
5473
+ );
5474
+ const env = res.data;
5475
+ if (!res.ok || env?.ok === false || !env?.data?.description) {
5476
+ const message = res.error?.message || (typeof env?.error === "string" ? env.error : env?.error?.message) || env?.message || `HTTP ${res.status}`;
5477
+ throw new Error(message);
5478
+ }
5479
+ const now = /* @__PURE__ */ new Date();
5480
+ const ttlSec = Number.isFinite(env.data.cacheTtlSec) ? Number(env.data.cacheTtlSec) : 300;
5481
+ return {
5482
+ description: env.data.description,
5483
+ modelUsed: env.data.modelUsed || "unknown",
5484
+ provider: env.data.provider || "vision-aux",
5485
+ generatedAt: now.toISOString(),
5486
+ expiresAt: new Date(now.getTime() + Math.max(1, ttlSec) * 1e3).toISOString(),
5487
+ mime: mime ?? ref.mime ?? "image/unknown"
5488
+ };
5489
+ }
5490
+ async function readVisionAuxLocalCache(cacheDir, contentHash, mime) {
5491
+ if (!cacheDir) return null;
5492
+ const file = visionAuxCachePath(cacheDir, contentHash);
5493
+ try {
5494
+ const raw = await fsp3.readFile(file, "utf8");
5495
+ const parsed = parseVisionAuxCache(raw);
5496
+ if (!parsed) {
5497
+ await fsp3.unlink(file).catch(() => void 0);
5498
+ return null;
5499
+ }
5500
+ if (parsed.mime !== mime) return null;
5501
+ const expiresAt = Date.parse(parsed.expiresAt);
5502
+ if (!Number.isFinite(expiresAt) || expiresAt <= Date.now()) return null;
5503
+ return parsed;
5504
+ } catch (err) {
5505
+ if (err.code !== "ENOENT") {
5506
+ await fsp3.unlink(file).catch(() => void 0);
5507
+ }
5508
+ return null;
5509
+ }
5510
+ }
5511
+ async function writeVisionAuxLocalCache(cacheDir, contentHash, mime, description) {
5512
+ if (!cacheDir || !mime) return;
5513
+ const file = visionAuxCachePath(cacheDir, contentHash);
5514
+ await fsp3.mkdir(path.dirname(file), { recursive: true });
5515
+ await fsp3.writeFile(file, JSON.stringify({ ...description, mime }, null, 2), "utf8");
5516
+ }
5517
+ function visionAuxCachePath(cacheDir, contentHash) {
5518
+ const safeHash = /^[a-f0-9]{32,128}$/i.test(contentHash) ? contentHash : createSafeCacheKey(contentHash);
5519
+ return path.join(cacheDir, `${safeHash}.json`);
5520
+ }
5521
+ function createSafeCacheKey(value) {
5522
+ return Buffer.from(value).toString("base64url").slice(0, 128) || "unknown";
5523
+ }
5524
+ function parseVisionAuxCache(raw) {
5525
+ try {
5526
+ const parsed = JSON.parse(raw);
5527
+ 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") {
5528
+ return parsed;
5529
+ }
5530
+ } catch {
5531
+ return null;
5532
+ }
5533
+ return null;
5534
+ }
4650
5535
  function logAssetResolveFailure(taskId, ref, error, mime = ref.mime) {
4651
5536
  const message = error.replace(/\s+/g, " ").slice(0, 500);
4652
5537
  process.stderr.write(
@@ -4661,8 +5546,19 @@ function formatInlineAssetBlock(ref, mime, body, strategy) {
4661
5546
  ${body}
4662
5547
  ---`;
4663
5548
  }
4664
- function formatUriOnlyAssetBlock(ref, mime, localPath) {
4665
- return `[Attached file] id=${ref.assetId} mime=${mime ?? "unknown"} path=file://${localPath} \u2014 open with your read/parse tool when needed.`;
5549
+ function formatAttachmentReminderBlock(ref, mime) {
5550
+ const name = ref.filename ? ` name=${ref.filename}` : "";
5551
+ return `[Attached file] id=${ref.assetId} mime=${mime ?? "unknown"}${name} \u2014 the user uploaded this file; multimodal adapters receive the bytes directly.`;
5552
+ }
5553
+ function formatVisionAuxBlock(ref, mime, description, cached) {
5554
+ const name = ref.filename ? `: ${ref.filename}` : "";
5555
+ const cacheLabel = cached ? " local-cache" : "";
5556
+ return `[Image attachment${name}] id=${ref.assetId} mime=${mime ?? "unknown"}${cacheLabel}
5557
+ ${description.trim()}`;
5558
+ }
5559
+ function formatVisionAuxUnavailableBlock(ref, mime, reason) {
5560
+ const name = ref.filename ? `: ${ref.filename}` : "";
5561
+ return `[Image attachment${name}; description unavailable due to vision-aux error] id=${ref.assetId} mime=${mime ?? "unknown"} reason=${reason.slice(0, 180)}`;
4666
5562
  }
4667
5563
  function formatErrorAssetBlock(ref, mime, error) {
4668
5564
  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.`;
@@ -5147,6 +6043,7 @@ import { createServer } from "http";
5147
6043
  import { randomUUID, createHash as createHash4, createHmac, timingSafeEqual as cryptoTimingSafeEqual } from "crypto";
5148
6044
  import { promises as fs } from "fs";
5149
6045
  import * as path2 from "path";
6046
+ import { homedir as homedir4 } from "os";
5150
6047
  var LocalServer = class {
5151
6048
  constructor(opts) {
5152
6049
  this.opts = opts;
@@ -5258,6 +6155,11 @@ var LocalServer = class {
5258
6155
  void this.handleSnapshot(req, res);
5259
6156
  return;
5260
6157
  }
6158
+ const agentDumpMatch = /^\/v1\/agents\/([^/]+)\/dump-state$/.exec(url);
6159
+ if (req.method === "POST" && agentDumpMatch) {
6160
+ void this.handleAgentDumpState(req, res, decodeURIComponent(agentDumpMatch[1]));
6161
+ return;
6162
+ }
5261
6163
  if (req.method === "POST" && url === "/local/asset/write") {
5262
6164
  void this.handleAssetWrite(req, res);
5263
6165
  return;
@@ -5335,6 +6237,54 @@ var LocalServer = class {
5335
6237
  }
5336
6238
  void req;
5337
6239
  }
6240
+ /**
6241
+ * POST /v1/agents/:agentId/dump-state — agent-scoped daemon manifest.
6242
+ *
6243
+ * This is narrower than `/v1/snapshot`: it walks only the current agent's
6244
+ * profile/work dirs so cloud can attach the result to IMAgentSnapshot without
6245
+ * capturing unrelated agents hosted in the same daemon/container.
6246
+ */
6247
+ async handleAgentDumpState(req, res, agentId) {
6248
+ const state = this.opts.getState();
6249
+ const agent = state.hostedAgents.find((item) => item.imUserId === agentId);
6250
+ if (!agent) {
6251
+ respond(res, 404, { error: "agent_not_hosted", agentId });
6252
+ return;
6253
+ }
6254
+ const roots = getAgentStateRoots(agent, this.opts.snapshotRoot ?? "/workspace");
6255
+ try {
6256
+ const manifests = await Promise.all(
6257
+ roots.map(async (root) => {
6258
+ const stat = await statOptional(root.rootPath);
6259
+ if (!stat) return { ...root, exists: false, files: [] };
6260
+ if (!stat.isDirectory()) return { ...root, exists: true, error: "not_directory", files: [] };
6261
+ return { ...root, exists: true, files: await walkAndDigest(root.rootPath, root.rootPath) };
6262
+ })
6263
+ );
6264
+ const files = manifests.flatMap(
6265
+ (manifest) => manifest.files.map((file) => ({
6266
+ ...file,
6267
+ path: `${manifest.kind}/${file.path}`,
6268
+ rootKind: manifest.kind,
6269
+ rootPath: manifest.rootPath
6270
+ }))
6271
+ );
6272
+ respond(res, 200, {
6273
+ agentId,
6274
+ adapterName: agent.adapterName,
6275
+ dumpedAt: (/* @__PURE__ */ new Date()).toISOString(),
6276
+ roots: manifests,
6277
+ files
6278
+ });
6279
+ } catch (err) {
6280
+ respond(res, 500, {
6281
+ error: "agent_dump_state_failed",
6282
+ agentId,
6283
+ message: err instanceof Error ? err.message : String(err)
6284
+ });
6285
+ }
6286
+ void req;
6287
+ }
5338
6288
  /**
5339
6289
  * POST /local/asset/write — agent-gen adapter RPC.
5340
6290
  *
@@ -5606,6 +6556,39 @@ function validateAgentDispatchRequest(body) {
5606
6556
  if (payload.attachments != null && !Array.isArray(payload.attachments)) return "attachments must be an array";
5607
6557
  return null;
5608
6558
  }
6559
+ function getAgentStateRoots(agent, snapshotRoot) {
6560
+ const profileName = sanitizeProfileName(agent.name || agent.imUserId);
6561
+ const roots = [
6562
+ {
6563
+ kind: "workspace-agent",
6564
+ rootPath: path2.join(snapshotRoot, "agents", agent.imUserId)
6565
+ }
6566
+ ];
6567
+ if (agent.adapterName === "hermes" || agent.adapterName === "claude-code") {
6568
+ roots.unshift({
6569
+ kind: "hermes-profile",
6570
+ rootPath: path2.join(process.env.HERMES_HOME || path2.join(homedir4(), ".hermes"), "profiles", profileName)
6571
+ });
6572
+ }
6573
+ if (agent.adapterName === "openclaw") {
6574
+ roots.unshift({
6575
+ kind: "openclaw-profile",
6576
+ rootPath: path2.join(process.env.OPENCLAW_HOME || path2.join(homedir4(), ".openclaw"), "profiles", profileName)
6577
+ });
6578
+ }
6579
+ return roots;
6580
+ }
6581
+ function sanitizeProfileName(value) {
6582
+ return value.replace(/[\\/]/g, "_").trim() || "default";
6583
+ }
6584
+ async function statOptional(rootPath) {
6585
+ try {
6586
+ return await fs.stat(rootPath);
6587
+ } catch (err) {
6588
+ if (err.code === "ENOENT") return null;
6589
+ throw err;
6590
+ }
6591
+ }
5609
6592
  async function walkAndDigest(root, current) {
5610
6593
  const out = [];
5611
6594
  let entries;
@@ -5649,7 +6632,7 @@ import { EventEmitter as EventEmitter3 } from "events";
5649
6632
  import { createRequire as createRequire2 } from "module";
5650
6633
  import { existsSync as existsSync9, readFileSync as readFileSync7 } from "fs";
5651
6634
  import { mkdir } from "fs/promises";
5652
- import { homedir as homedir6, platform } from "os";
6635
+ import { homedir as homedir7, platform } from "os";
5653
6636
  import { join as join14 } from "path";
5654
6637
 
5655
6638
  // src/adapters/claude-code/index.ts
@@ -7747,7 +8730,7 @@ function parsePrismerUri(uri) {
7747
8730
  }
7748
8731
 
7749
8732
  // src/daemon/asset/metadata-index.ts
7750
- import { existsSync as existsSync7, mkdirSync as mkdirSync7, readFileSync as readFileSync6, writeFileSync as writeFileSync5 } from "fs";
8733
+ import { existsSync as existsSync7, mkdirSync as mkdirSync8, readFileSync as readFileSync6, writeFileSync as writeFileSync6 } from "fs";
7751
8734
  import { join as join11 } from "path";
7752
8735
  var DEFAULT_LIMIT = 8;
7753
8736
  var PULL_PAGE_SIZE = 500;
@@ -7777,7 +8760,7 @@ var AssetMetadataIndex = class {
7777
8760
  this.cloud = opts.cloud;
7778
8761
  this.workspaceId = opts.workspaceId;
7779
8762
  if (!existsSync7(opts.workspaceStateDir)) {
7780
- mkdirSync7(opts.workspaceStateDir, { recursive: true });
8763
+ mkdirSync8(opts.workspaceStateDir, { recursive: true });
7781
8764
  }
7782
8765
  this.cursorPath = join11(opts.workspaceStateDir, "asset-metadata-cursor.json");
7783
8766
  }
@@ -7798,7 +8781,7 @@ var AssetMetadataIndex = class {
7798
8781
  cursor,
7799
8782
  writtenAt: Date.now()
7800
8783
  };
7801
- writeFileSync5(this.cursorPath, JSON.stringify(payload, null, 2));
8784
+ writeFileSync6(this.cursorPath, JSON.stringify(payload, null, 2));
7802
8785
  }
7803
8786
  /**
7804
8787
  * Pull incremental asset metadata changes since the persisted cursor and
@@ -8125,7 +9108,7 @@ async function readJson3(req) {
8125
9108
 
8126
9109
  // src/daemon/asset/origin/drop-folder.ts
8127
9110
  import { promises as fs3 } from "fs";
8128
- import { homedir as homedir5 } from "os";
9111
+ import { homedir as homedir6 } from "os";
8129
9112
  import * as path5 from "path";
8130
9113
  var DropFolderAdapter = class {
8131
9114
  kind = "drop-folder";
@@ -8133,7 +9116,7 @@ var DropFolderAdapter = class {
8133
9116
  rootDir;
8134
9117
  constructor(opts = {}) {
8135
9118
  this.workspaceDir = opts.workspaceDir;
8136
- this.rootDir = opts.rootDir ?? path5.join(opts.homeDir ?? homedir5(), ".prismer");
9119
+ this.rootDir = opts.rootDir ?? path5.join(opts.homeDir ?? homedir6(), ".prismer");
8137
9120
  }
8138
9121
  /**
8139
9122
  * Long-lived async iterable wrapping `scanOnce` on a 1s polling interval.
@@ -8143,7 +9126,7 @@ var DropFolderAdapter = class {
8143
9126
  while (true) {
8144
9127
  const batch = await this.scanOnce(workspaceId);
8145
9128
  for (const obs of batch) yield obs;
8146
- await sleep(1e3);
9129
+ await sleep2(1e3);
8147
9130
  }
8148
9131
  }
8149
9132
  /**
@@ -8292,7 +9275,7 @@ function mimeFromExtension(p) {
8292
9275
  const ext = path5.extname(p).toLowerCase();
8293
9276
  return MIME_BY_EXT[ext] ?? null;
8294
9277
  }
8295
- function sleep(ms) {
9278
+ function sleep2(ms) {
8296
9279
  return new Promise((r) => setTimeout(r, ms));
8297
9280
  }
8298
9281
 
@@ -8997,15 +9980,126 @@ function normalizeMime(mime) {
8997
9980
  const normalized = mime.toLowerCase().split(";")[0]?.trim();
8998
9981
  return normalized || null;
8999
9982
  }
9000
- function extensionOf(filename) {
9001
- const name = filename.toLowerCase().trim();
9002
- const idx = name.lastIndexOf(".");
9003
- return idx >= 0 ? name.slice(idx) : "";
9983
+ function extensionOf(filename) {
9984
+ const name = filename.toLowerCase().trim();
9985
+ const idx = name.lastIndexOf(".");
9986
+ return idx >= 0 ? name.slice(idx) : "";
9987
+ }
9988
+
9989
+ // src/daemon/asset/magic-bytes.ts
9990
+ var PDF_SIG = Buffer.from("%PDF-", "ascii");
9991
+ var PNG_SIG = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
9992
+ var JPEG_SIG = Buffer.from([255, 216, 255]);
9993
+ var GIF87 = Buffer.from("GIF87a", "ascii");
9994
+ var GIF89 = Buffer.from("GIF89a", "ascii");
9995
+ var ZIP_LOCAL = Buffer.from([80, 75, 3, 4]);
9996
+ var ZIP_EMPTY = Buffer.from([80, 75, 5, 6]);
9997
+ var ZIP_SPANNED = Buffer.from([80, 75, 7, 8]);
9998
+ function detectMagicBytes(bytes) {
9999
+ const head = bytes.subarray(0, Math.min(bytes.length, 4096));
10000
+ if (head.length === 0) return { detected: null, mime: null };
10001
+ if (head.length >= PDF_SIG.length && head.subarray(0, PDF_SIG.length).equals(PDF_SIG)) {
10002
+ return { detected: "pdf", mime: "application/pdf" };
10003
+ }
10004
+ if (head.length >= PNG_SIG.length && head.subarray(0, PNG_SIG.length).equals(PNG_SIG)) {
10005
+ return { detected: "png", mime: "image/png" };
10006
+ }
10007
+ if (head.length >= JPEG_SIG.length && head.subarray(0, JPEG_SIG.length).equals(JPEG_SIG)) {
10008
+ return { detected: "jpeg", mime: "image/jpeg" };
10009
+ }
10010
+ if (head.length >= GIF87.length && head.subarray(0, GIF87.length).equals(GIF87) || head.length >= GIF89.length && head.subarray(0, GIF89.length).equals(GIF89)) {
10011
+ return { detected: "gif", mime: "image/gif" };
10012
+ }
10013
+ 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))) {
10014
+ return { detected: "zip", mime: "application/zip" };
10015
+ }
10016
+ const text = head.toString("utf8");
10017
+ const trimmed = text.trimStart();
10018
+ if (/^<\?xml[^>]*>\s*<svg[\s>]/i.test(trimmed) || /^<svg[\s>]/i.test(trimmed)) {
10019
+ return { detected: "svg", mime: "image/svg+xml" };
10020
+ }
10021
+ if (/^<(!doctype html|html[\s>])/i.test(trimmed)) {
10022
+ return { detected: "html", mime: "text/html" };
10023
+ }
10024
+ if (/^[{[]/.test(trimmed)) {
10025
+ return { detected: "json", mime: "application/json" };
10026
+ }
10027
+ if (looksLikePrintableText(head)) {
10028
+ return { detected: "markdown-or-text", mime: "text/plain" };
10029
+ }
10030
+ return { detected: null, mime: null };
10031
+ }
10032
+ var ZIP_DERIVED_MIMES = /* @__PURE__ */ new Set([
10033
+ "application/zip",
10034
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
10035
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
10036
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation",
10037
+ "application/vnd.oasis.opendocument.text",
10038
+ "application/vnd.oasis.opendocument.spreadsheet",
10039
+ "application/vnd.oasis.opendocument.presentation",
10040
+ "application/epub+zip",
10041
+ "application/java-archive"
10042
+ ]);
10043
+ function mimeMismatchReason(detected, declared) {
10044
+ if (detected.detected === null) return null;
10045
+ const dec = (declared ?? "").toLowerCase().split(";")[0]?.trim() ?? "";
10046
+ if (!dec || dec === "application/octet-stream") return null;
10047
+ switch (detected.detected) {
10048
+ case "pdf":
10049
+ return dec === "application/pdf" ? null : `declared ${dec} but content is application/pdf`;
10050
+ case "png":
10051
+ return dec === "image/png" ? null : `declared ${dec} but content is image/png`;
10052
+ case "jpeg":
10053
+ return dec === "image/jpeg" || dec === "image/jpg" ? null : `declared ${dec} but content is image/jpeg`;
10054
+ case "gif":
10055
+ return dec === "image/gif" ? null : `declared ${dec} but content is image/gif`;
10056
+ case "zip":
10057
+ if (ZIP_DERIVED_MIMES.has(dec)) return null;
10058
+ return `declared ${dec} but content is a ZIP-container (application/zip family)`;
10059
+ case "svg":
10060
+ if (dec === "image/svg+xml" || dec.startsWith("text/")) return null;
10061
+ return `declared ${dec} but content is image/svg+xml`;
10062
+ case "html":
10063
+ if (dec === "text/html" || dec === "application/xhtml+xml" || dec.startsWith("text/")) return null;
10064
+ if (dec === "application/pdf" || dec.startsWith("image/") || dec === "application/zip") {
10065
+ return `declared ${dec} but content is text/html`;
10066
+ }
10067
+ return null;
10068
+ case "json":
10069
+ if (dec === "application/json" || dec.startsWith("text/")) return null;
10070
+ if (dec === "application/pdf" || dec.startsWith("image/") || dec === "application/zip") {
10071
+ return `declared ${dec} but content is application/json`;
10072
+ }
10073
+ return null;
10074
+ case "markdown-or-text":
10075
+ if (dec === "application/pdf" || dec.startsWith("image/") || dec === "application/zip") {
10076
+ return `declared ${dec} but content is text (not a real ${dec} payload)`;
10077
+ }
10078
+ return null;
10079
+ default:
10080
+ return null;
10081
+ }
10082
+ }
10083
+ function looksLikePrintableText(buf) {
10084
+ if (buf.length === 0) return false;
10085
+ let printable = 0;
10086
+ let total = 0;
10087
+ for (let i = 0; i < buf.length; i++) {
10088
+ const b = buf[i];
10089
+ if (b === 0) return false;
10090
+ total++;
10091
+ if (b >= 32 && b <= 126 || b === 9 || b === 10 || b === 13 || // utf-8 lead bytes
10092
+ b >= 128) {
10093
+ printable++;
10094
+ }
10095
+ }
10096
+ return total > 0 && printable / total >= 0.95;
9004
10097
  }
9005
10098
 
9006
10099
  // src/daemon/outbox-watcher.ts
9007
10100
  var DEFAULT_INTERVAL_MS = 2e3;
9008
10101
  var RESERVED_SUBDIR = "_uploaded";
10102
+ var REJECTED_SUBDIR = "_rejected";
9009
10103
  var OutboxWatcher = class {
9010
10104
  constructor(opts) {
9011
10105
  this.opts = opts;
@@ -9180,6 +10274,7 @@ var OutboxWatcher = class {
9180
10274
  for (const name of entries) {
9181
10275
  if (name.startsWith(".")) continue;
9182
10276
  if (name === RESERVED_SUBDIR) continue;
10277
+ if (name === REJECTED_SUBDIR) continue;
9183
10278
  const full = path7.join(dir, name);
9184
10279
  let st;
9185
10280
  try {
@@ -9194,10 +10289,77 @@ var OutboxWatcher = class {
9194
10289
  await this.upload(full, st, kind, task);
9195
10290
  this.uploaded.add(key);
9196
10291
  } catch (err) {
9197
- this.log("warn", `upload ${full} failed: ${err.message}`);
10292
+ const msg = err.message;
10293
+ this.log("warn", `upload ${full} failed: ${msg}`);
10294
+ if (/\bMIME_MISMATCH\b/i.test(msg) || /\bmime mismatch\b/i.test(msg)) {
10295
+ await this.quarantine(dir, full).catch(
10296
+ (moveErr) => this.log("warn", `quarantine ${full} failed: ${moveErr.message}`)
10297
+ );
10298
+ this.uploaded.add(key);
10299
+ await this.reportMimeMismatchToCloud(task, full, msg).catch(
10300
+ (reportErr) => this.log("warn", `report MIME_MISMATCH failed: ${reportErr.message}`)
10301
+ );
10302
+ }
9198
10303
  }
9199
10304
  }
9200
10305
  }
10306
+ /**
10307
+ * F2B — report an OUTBOX_MIME_MISMATCH task event to cloud so the task log
10308
+ * gains an auditable record of the rejection. Without this, the failure is
10309
+ * silent (file quarantined locally, agent unaware) and the next dispatch
10310
+ * loops on the same bad strategy.
10311
+ *
10312
+ * Best-effort: every catch site swallows errors. We never want this
10313
+ * upstream call to block local quarantine or trigger a retry loop, because
10314
+ * the reject decision is already final by the time we get here.
10315
+ *
10316
+ * Endpoint contract: POST /api/im/tasks/:id/event
10317
+ * { code: 'OUTBOX_MIME_MISMATCH', payload: { file, daemonError }, message }
10318
+ * Only callable when `task?.taskId` is known — pre-dispatch outbox files
10319
+ * (no active task) are quarantined silently as before.
10320
+ */
10321
+ async reportMimeMismatchToCloud(task, filePath, daemonErrorMessage) {
10322
+ if (!task?.taskId) return;
10323
+ const fileName = path7.basename(filePath);
10324
+ const reasonMatch = daemonErrorMessage.match(/MIME_MISMATCH[^:]*:\s*(.+)$/i);
10325
+ const reason = reasonMatch?.[1] ? reasonMatch[1].trim() : daemonErrorMessage;
10326
+ const body = {
10327
+ code: "OUTBOX_MIME_MISMATCH",
10328
+ payload: {
10329
+ file: fileName,
10330
+ reason,
10331
+ daemonError: daemonErrorMessage
10332
+ },
10333
+ 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.`
10334
+ };
10335
+ const res = await this.opts.cloud.request("POST", `/api/im/tasks/${encodeURIComponent(task.taskId)}/event`, {
10336
+ body
10337
+ });
10338
+ if (!res.ok) {
10339
+ throw new Error(`cloud rejected event: ${res.status} ${res.error?.code ?? ""} ${res.error?.message ?? ""}`);
10340
+ }
10341
+ }
10342
+ /**
10343
+ * Move a file under `<dir>/_rejected/` so it stops being a scan candidate
10344
+ * (the loop skips the reserved subdir) and operators can find the
10345
+ * offending artifact. Filename collision is rare per task; on collision
10346
+ * we append a millisecond suffix to keep the original observable.
10347
+ */
10348
+ async quarantine(dir, full) {
10349
+ const rejectedDir = path7.join(dir, REJECTED_SUBDIR);
10350
+ await fs4.mkdir(rejectedDir, { recursive: true });
10351
+ const base = path7.basename(full);
10352
+ let dest = path7.join(rejectedDir, base);
10353
+ try {
10354
+ await fs4.access(dest);
10355
+ const ext = path7.extname(base);
10356
+ const stem = base.slice(0, base.length - ext.length);
10357
+ dest = path7.join(rejectedDir, `${stem}.${Date.now()}${ext}`);
10358
+ } catch {
10359
+ }
10360
+ await fs4.rename(full, dest);
10361
+ this.log("warn", `quarantined ${full} \u2192 ${dest}`);
10362
+ }
9201
10363
  async upload(filePath, st, kind, task) {
9202
10364
  const wsId = this.opts.workspaceId();
9203
10365
  if (!wsId) {
@@ -9215,6 +10377,11 @@ var OutboxWatcher = class {
9215
10377
  if (!policy.ok) {
9216
10378
  throw new Error(`agent output rejected: ${policy.reason}`);
9217
10379
  }
10380
+ const detection = detectMagicBytes(bytes);
10381
+ const reason = mimeMismatchReason(detection, policy.mime);
10382
+ if (reason) {
10383
+ throw new Error(`MIME_MISMATCH for ${fileName}: ${reason}`);
10384
+ }
9218
10385
  const metadata = {
9219
10386
  taskId: task.taskId,
9220
10387
  ...task.adapter ? { adapter: task.adapter } : {}
@@ -9418,6 +10585,352 @@ ${input.stderr}` : null
9418
10585
 
9419
10586
  // src/daemon/runner.ts
9420
10587
  init_skill_sync();
10588
+
10589
+ // src/daemon/pending-reply-cache.ts
10590
+ function fromRaw(r) {
10591
+ return {
10592
+ idempotencyKey: r.idempotency_key,
10593
+ taskId: r.task_id,
10594
+ replyId: r.reply_id,
10595
+ status: r.status,
10596
+ payloadJson: r.payload_json,
10597
+ createdAt: r.created_at,
10598
+ preparedAt: r.prepared_at,
10599
+ lastAttemptAt: r.last_attempt_at,
10600
+ attemptCount: r.attempt_count,
10601
+ lastError: r.last_error
10602
+ };
10603
+ }
10604
+ function createPendingReplyCache(db) {
10605
+ const insert = db.prepare(
10606
+ `INSERT OR REPLACE INTO pending_dispatch_replies
10607
+ (idempotency_key, task_id, reply_id, status, payload_json,
10608
+ prepared_at, created_at, last_attempt_at, attempt_count, last_error)
10609
+ VALUES (?, ?, NULL, 'pending', ?, NULL, ?, NULL, 0, NULL)`
10610
+ );
10611
+ const markPreparedStmt = db.prepare(
10612
+ `UPDATE pending_dispatch_replies
10613
+ SET reply_id = ?, status = 'prepared', prepared_at = ?,
10614
+ last_attempt_at = ?, attempt_count = attempt_count + 1,
10615
+ last_error = NULL
10616
+ WHERE idempotency_key = ?`
10617
+ );
10618
+ const markAttemptStmt = db.prepare(
10619
+ `UPDATE pending_dispatch_replies
10620
+ SET last_attempt_at = ?, attempt_count = attempt_count + 1,
10621
+ last_error = ?
10622
+ WHERE idempotency_key = ?`
10623
+ );
10624
+ const clearStmt = db.prepare(
10625
+ `DELETE FROM pending_dispatch_replies WHERE idempotency_key = ?`
10626
+ );
10627
+ const listRecoveryStmt = db.prepare(
10628
+ `SELECT * FROM pending_dispatch_replies
10629
+ WHERE status IN ('pending', 'prepared')
10630
+ ORDER BY created_at ASC`
10631
+ );
10632
+ const countStmt = db.prepare(
10633
+ `SELECT status, COUNT(*) as cnt FROM pending_dispatch_replies
10634
+ WHERE status IN ('pending','prepared') GROUP BY status`
10635
+ );
10636
+ const findStmt = db.prepare(
10637
+ `SELECT * FROM pending_dispatch_replies WHERE idempotency_key = ?`
10638
+ );
10639
+ return {
10640
+ savePending(idempotencyKey, taskId, payloadJson, now = Date.now()) {
10641
+ insert.run(idempotencyKey, taskId, payloadJson, now);
10642
+ },
10643
+ markPrepared(idempotencyKey, replyId, now = Date.now()) {
10644
+ markPreparedStmt.run(replyId, now, now, idempotencyKey);
10645
+ },
10646
+ markAttempt(idempotencyKey, now = Date.now(), error) {
10647
+ markAttemptStmt.run(now, error ?? null, idempotencyKey);
10648
+ },
10649
+ clearPending(idempotencyKey) {
10650
+ clearStmt.run(idempotencyKey);
10651
+ },
10652
+ listForRecovery() {
10653
+ const rows = listRecoveryStmt.all();
10654
+ return rows.map(fromRaw);
10655
+ },
10656
+ countByStatus() {
10657
+ const rows = countStmt.all();
10658
+ const result = { pending: 0, prepared: 0 };
10659
+ for (const r of rows) {
10660
+ if (r.status === "pending") result.pending = r.cnt;
10661
+ else if (r.status === "prepared") result.prepared = r.cnt;
10662
+ }
10663
+ return result;
10664
+ },
10665
+ findByIdempotencyKey(idempotencyKey) {
10666
+ const row = findStmt.get(idempotencyKey);
10667
+ return row ? fromRaw(row) : null;
10668
+ }
10669
+ };
10670
+ }
10671
+ function generateIdempotencyKey() {
10672
+ const c = globalThis.crypto;
10673
+ if (c?.randomUUID) return c.randomUUID();
10674
+ const bytes = new Uint8Array(16);
10675
+ const cryptoNode = globalThis.crypto;
10676
+ if (cryptoNode?.getRandomValues) {
10677
+ cryptoNode.getRandomValues(bytes);
10678
+ } else {
10679
+ for (let i = 0; i < 16; i += 1) bytes[i] = Math.floor(Math.random() * 256);
10680
+ }
10681
+ bytes[6] = bytes[6] & 15 | 64;
10682
+ bytes[8] = bytes[8] & 63 | 128;
10683
+ const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
10684
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
10685
+ }
10686
+
10687
+ // src/daemon/dispatch-reply-transport.ts
10688
+ var DispatchReplyAbortedError = class extends Error {
10689
+ constructor(replyId) {
10690
+ super(`dispatch reply ${replyId} aborted by server reaper (likely >1h since prepare)`);
10691
+ this.replyId = replyId;
10692
+ this.name = "DispatchReplyAbortedError";
10693
+ }
10694
+ replyId;
10695
+ code = "DISPATCH_REPLY_INVALID_STATE";
10696
+ };
10697
+ function defaultLog2(line) {
10698
+ process.stderr.write(`${line}
10699
+ `);
10700
+ }
10701
+ async function sendDispatchReplyTwoPhase(args) {
10702
+ const { taskId, payload, cloud, cache } = args;
10703
+ const log8 = args.log ?? defaultLog2;
10704
+ const idempotencyKey = args.idempotencyKey ?? generateIdempotencyKey();
10705
+ const payloadJson = JSON.stringify({ ...payload, _taskId: taskId, _idempotencyKey: idempotencyKey });
10706
+ cache.savePending(idempotencyKey, taskId, payloadJson);
10707
+ const prepareRes = await cloud.request("POST", `/api/im/dispatch/${encodeURIComponent(taskId)}/prepare`, {
10708
+ body: { ...payload, idempotencyKey },
10709
+ timeoutMs: 3e4
10710
+ });
10711
+ if (!prepareRes.ok || !prepareRes.data) {
10712
+ const errCode = prepareRes.error?.code ?? "unknown";
10713
+ const errMsg = prepareRes.error?.message ?? `prepare failed (status=${prepareRes.status})`;
10714
+ cache.markAttempt(idempotencyKey, Date.now(), `prepare: ${errCode}: ${errMsg}`);
10715
+ throw new Error(`dispatch prepare failed: ${errCode}: ${errMsg}`);
10716
+ }
10717
+ const envelope2 = prepareRes.data;
10718
+ const inner = "data" in envelope2 && envelope2.data ? envelope2.data : envelope2;
10719
+ if (!inner || typeof inner.replyId !== "string") {
10720
+ cache.markAttempt(idempotencyKey, Date.now(), "prepare: missing replyId in response");
10721
+ throw new Error("dispatch prepare returned no replyId");
10722
+ }
10723
+ if ("ok" in envelope2 && envelope2.ok === false) {
10724
+ const errCode = envelope2.error?.code ?? "prepare_failed";
10725
+ const errMsg = envelope2.error?.message ?? "prepare ok=false";
10726
+ cache.markAttempt(idempotencyKey, Date.now(), `prepare: ${errCode}: ${errMsg}`);
10727
+ throw new Error(`dispatch prepare failed: ${errCode}: ${errMsg}`);
10728
+ }
10729
+ cache.markPrepared(idempotencyKey, inner.replyId);
10730
+ log8(
10731
+ `[daemon] dispatch.prepare task=${taskId} replyId=${inner.replyId} status=${inner.status} idem=${idempotencyKey}`
10732
+ );
10733
+ if (inner.status === "committed") {
10734
+ cache.clearPending(idempotencyKey);
10735
+ return {
10736
+ taskId,
10737
+ replyId: inner.replyId,
10738
+ status: "committed",
10739
+ alreadyCommitted: true
10740
+ };
10741
+ }
10742
+ try {
10743
+ const result = await commitDispatchReply({ taskId, replyId: inner.replyId, cloud, log: log8 });
10744
+ cache.clearPending(idempotencyKey);
10745
+ return { taskId, replyId: inner.replyId, ...result };
10746
+ } catch (err) {
10747
+ if (err instanceof DispatchReplyAbortedError) {
10748
+ cache.clearPending(idempotencyKey);
10749
+ log8(`[daemon] dispatch.commit aborted task=${taskId} replyId=${inner.replyId} \u2014 dispatch lost`);
10750
+ return {
10751
+ taskId,
10752
+ replyId: inner.replyId,
10753
+ status: "aborted",
10754
+ alreadyCommitted: false
10755
+ };
10756
+ }
10757
+ cache.markAttempt(idempotencyKey, Date.now(), `commit: ${err.message}`);
10758
+ throw err;
10759
+ }
10760
+ }
10761
+ async function commitDispatchReply(args) {
10762
+ const log8 = args.log ?? defaultLog2;
10763
+ const res = await args.cloud.request("POST", `/api/im/dispatch/${encodeURIComponent(args.taskId)}/commit`, {
10764
+ body: { replyId: args.replyId },
10765
+ timeoutMs: 3e4
10766
+ });
10767
+ if (!res.ok || !res.data) {
10768
+ const code = res.error?.code ?? "unknown";
10769
+ const msg = res.error?.message ?? `commit failed (status=${res.status})`;
10770
+ if (code === "DISPATCH_REPLY_INVALID_STATE") {
10771
+ throw new DispatchReplyAbortedError(args.replyId);
10772
+ }
10773
+ throw new Error(`dispatch commit failed: ${code}: ${msg}`);
10774
+ }
10775
+ const env = res.data;
10776
+ const inner = "data" in env && env.data ? env.data : env;
10777
+ if ("ok" in env && env.ok === false) {
10778
+ const code = env.error?.code ?? "commit_failed";
10779
+ if (code === "DISPATCH_REPLY_INVALID_STATE") {
10780
+ throw new DispatchReplyAbortedError(args.replyId);
10781
+ }
10782
+ throw new Error(`dispatch commit failed: ${code}: ${env.error?.message ?? "commit ok=false"}`);
10783
+ }
10784
+ log8(
10785
+ `[daemon] dispatch.commit task=${args.taskId} replyId=${args.replyId} alreadyCommitted=${inner.alreadyCommitted ?? false}`
10786
+ );
10787
+ return {
10788
+ status: "committed",
10789
+ alreadyCommitted: Boolean(inner.alreadyCommitted),
10790
+ ...inner.messageId ? { messageId: inner.messageId } : {}
10791
+ };
10792
+ }
10793
+ async function recoverPendingReplies(deps) {
10794
+ const log8 = deps.log ?? defaultLog2;
10795
+ const rows = deps.cache.listForRecovery();
10796
+ if (rows.length === 0) {
10797
+ return { attempted: 0, committed: 0, aborted: 0, failed: 0 };
10798
+ }
10799
+ log8(`[daemon] dispatch.recovery: ${rows.length} pending row(s) to replay`);
10800
+ let committed = 0;
10801
+ let aborted = 0;
10802
+ let failed = 0;
10803
+ for (const row of rows) {
10804
+ try {
10805
+ const result = await replayRow(row, deps);
10806
+ if (result.status === "committed") committed += 1;
10807
+ else if (result.status === "aborted") aborted += 1;
10808
+ } catch (err) {
10809
+ failed += 1;
10810
+ log8(
10811
+ `[daemon] dispatch.recovery FAIL idem=${row.idempotencyKey} task=${row.taskId}: ${err.message}`
10812
+ );
10813
+ }
10814
+ }
10815
+ log8(
10816
+ `[daemon] dispatch.recovery done attempted=${rows.length} committed=${committed} aborted=${aborted} failed=${failed}`
10817
+ );
10818
+ return { attempted: rows.length, committed, aborted, failed };
10819
+ }
10820
+ async function replayRow(row, deps) {
10821
+ const log8 = deps.log ?? defaultLog2;
10822
+ if (row.status === "prepared" && row.replyId) {
10823
+ try {
10824
+ const result2 = await commitDispatchReply({
10825
+ taskId: row.taskId,
10826
+ replyId: row.replyId,
10827
+ cloud: deps.cloud,
10828
+ log: log8
10829
+ });
10830
+ deps.cache.clearPending(row.idempotencyKey);
10831
+ return { status: result2.status };
10832
+ } catch (err) {
10833
+ if (err instanceof DispatchReplyAbortedError) {
10834
+ deps.cache.clearPending(row.idempotencyKey);
10835
+ return { status: "aborted" };
10836
+ }
10837
+ throw err;
10838
+ }
10839
+ }
10840
+ let parsed;
10841
+ try {
10842
+ parsed = JSON.parse(row.payloadJson);
10843
+ } catch (err) {
10844
+ deps.cache.clearPending(row.idempotencyKey);
10845
+ throw new Error(`pending row ${row.idempotencyKey} has unparseable payload: ${err.message}`);
10846
+ }
10847
+ const { _taskId, _idempotencyKey, ...payload } = parsed;
10848
+ const result = await sendDispatchReplyTwoPhase({
10849
+ taskId: row.taskId,
10850
+ payload,
10851
+ cloud: deps.cloud,
10852
+ cache: deps.cache,
10853
+ idempotencyKey: row.idempotencyKey,
10854
+ log: deps.log
10855
+ });
10856
+ return { status: result.status };
10857
+ }
10858
+
10859
+ // src/daemon/transport-probe.ts
10860
+ import { networkInterfaces } from "os";
10861
+ function isPrivateAddress(addr) {
10862
+ if (!addr) return true;
10863
+ const h = String(addr).trim().toLowerCase();
10864
+ if (!h) return true;
10865
+ if (h === "localhost" || h === "::1") return true;
10866
+ const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(h);
10867
+ if (m) {
10868
+ const a = Number(m[1]);
10869
+ const b = Number(m[2]);
10870
+ if (a === 10) return true;
10871
+ if (a === 127) return true;
10872
+ if (a === 169 && b === 254) return true;
10873
+ if (a === 172 && b >= 16 && b <= 31) return true;
10874
+ if (a === 192 && b === 168) return true;
10875
+ if (a === 100 && b >= 64 && b <= 127) return true;
10876
+ return false;
10877
+ }
10878
+ if (h.endsWith(".local")) return true;
10879
+ if (h.endsWith(".localhost")) return true;
10880
+ return false;
10881
+ }
10882
+ function buildTransportProbe(gatewayUrl) {
10883
+ if (!gatewayUrl) {
10884
+ return { transport: "ws", gatewayUrl: null, gatewayIsPrivate: true };
10885
+ }
10886
+ let host = null;
10887
+ try {
10888
+ host = new URL(gatewayUrl).hostname;
10889
+ } catch {
10890
+ host = null;
10891
+ }
10892
+ const gatewayIsPrivate = isPrivateAddress(host);
10893
+ return {
10894
+ transport: gatewayIsPrivate ? "ws" : "both",
10895
+ gatewayUrl,
10896
+ gatewayIsPrivate
10897
+ };
10898
+ }
10899
+ function pickLocalIPv4() {
10900
+ const nics = networkInterfaces();
10901
+ let firstPrivate = null;
10902
+ for (const list of Object.values(nics)) {
10903
+ if (!list) continue;
10904
+ for (const ni of list) {
10905
+ if (ni.internal || ni.family !== "IPv4") continue;
10906
+ if (!isPrivateAddress(ni.address)) return ni.address;
10907
+ if (!firstPrivate) firstPrivate = ni.address;
10908
+ }
10909
+ }
10910
+ return firstPrivate;
10911
+ }
10912
+ async function reportTransportProbe(cloud, daemonId, probe) {
10913
+ const body = {
10914
+ daemonId,
10915
+ transport: probe.transport,
10916
+ gatewayUrl: probe.gatewayUrl,
10917
+ gatewayIsPrivate: probe.gatewayIsPrivate
10918
+ };
10919
+ const res = await cloud.request("POST", "/api/im/runtime/transport-report", {
10920
+ body,
10921
+ timeoutMs: 5e3
10922
+ });
10923
+ if (!res.ok) {
10924
+ return {
10925
+ ok: false,
10926
+ status: res.status,
10927
+ error: res.error?.message ?? `transport-report failed (${res.status})`
10928
+ };
10929
+ }
10930
+ return { ok: true, status: res.status };
10931
+ }
10932
+
10933
+ // src/daemon/runner.ts
9421
10934
  var DEFAULT_LOCAL_PORT = 3210;
9422
10935
  var log7 = createLogger("Daemon");
9423
10936
  var assetMetaLog = createLogger("AssetMeta");
@@ -9465,6 +10978,14 @@ var Runner = class extends EventEmitter3 {
9465
10978
  // F16 (2026-05-20) — periodic skill resync timer (default 10min).
9466
10979
  skillResyncTimer;
9467
10980
  skillSyncInFlight = false;
10981
+ // Wave-4 E7 — local cache of in-flight two-phase dispatch replies. Survives
10982
+ // daemon crash so cold-start can resume `prepared` rows via idempotent
10983
+ // commit (server enforces (taskId, idempotencyKey) UNIQUE).
10984
+ pendingReplyCache;
10985
+ // One-shot guard so we only recover on the first post-authenticated tick,
10986
+ // not every reconnect (server-side commits are idempotent but the log noise
10987
+ // and DB pressure of re-running on every redeclare is wasteful).
10988
+ pendingReplyRecoveryDone = false;
9468
10989
  async start() {
9469
10990
  if (this.state !== "idle") throw new Error(`Runner already in state ${this.state}`);
9470
10991
  this.state = "starting";
@@ -9479,6 +11000,7 @@ var Runner = class extends EventEmitter3 {
9479
11000
  process.env.PRISMER_API_KEY = this.config.api_key;
9480
11001
  this.db = openLocalDb(this.paths.localDb);
9481
11002
  this.cloud = new CloudClient({ baseUrl: this.config.cloud_api_base, apiKey: this.config.api_key });
11003
+ this.pendingReplyCache = createPendingReplyCache(this.db);
9482
11004
  this.assetCache = new AssetCache({
9483
11005
  db: this.db,
9484
11006
  cloud: this.cloud,
@@ -10085,6 +11607,19 @@ var Runner = class extends EventEmitter3 {
10085
11607
  process.stdout.write(`[daemon] ws authenticated, sending agent.host.declare (${this.hostedAgents.size} agents)
10086
11608
  `);
10087
11609
  this.sendDeclare();
11610
+ if (!this.pendingReplyRecoveryDone) {
11611
+ this.pendingReplyRecoveryDone = true;
11612
+ void recoverPendingReplies({ cloud: this.cloud, cache: this.pendingReplyCache }).then((summary) => {
11613
+ if (summary.attempted === 0) return;
11614
+ process.stdout.write(
11615
+ `[daemon] dispatch.recovery summary attempted=${summary.attempted} committed=${summary.committed} aborted=${summary.aborted} failed=${summary.failed}
11616
+ `
11617
+ );
11618
+ }).catch((err) => {
11619
+ process.stderr.write(`[daemon] dispatch.recovery threw: ${err.message}
11620
+ `);
11621
+ });
11622
+ }
10088
11623
  return;
10089
11624
  }
10090
11625
  this.handleIncoming(msg);
@@ -10131,12 +11666,90 @@ var Runner = class extends EventEmitter3 {
10131
11666
  case "asset.changed":
10132
11667
  void this.onAssetChanged(msg.payload);
10133
11668
  return;
11669
+ // v2.0 §4.8.1 (Wave 4-E4) — webhook reverse-channel RPC. Cloud's
11670
+ // dispatchExternalMention pushes an AgentDispatchRequest plus an
11671
+ // embedded `_rpcId`; the daemon runs the same handler as the
11672
+ // HTTP /dispatch path and echoes the reply back over WS with the
11673
+ // identical `_rpcId` so cloud's WsRpcService can settle the
11674
+ // pending promise.
11675
+ case "webhook.dispatch.request":
11676
+ void this.onWebhookDispatch(msg.payload);
11677
+ return;
10134
11678
  default:
10135
11679
  this.emit("unknown-message", msg);
10136
11680
  }
10137
11681
  }
11682
+ /**
11683
+ * v2.0 §4.8.1 — webhook dispatch over WS reverse channel.
11684
+ *
11685
+ * Acks via `webhook.dispatch.reply { _rpcId, ok, acceptedAt }` immediately
11686
+ * after `handleAgentMessageDispatch()` returns its synchronous response
11687
+ * — the actual reply (the agent's reply text/attachments) still flows
11688
+ * through `postMessageDispatchReply()` which posts to
11689
+ * `/api/im/dispatch/reply` after the adapter finishes. That mirrors the
11690
+ * HTTP /dispatch contract: ack first, asynchronous final reply via the
11691
+ * dispatch-reply REST endpoint.
11692
+ *
11693
+ * The `_rpcId` field is the only addition versus the HTTP path; we strip
11694
+ * it before passing the payload to the dispatch handler so existing
11695
+ * validation logic doesn't trip on an unknown field.
11696
+ */
11697
+ async onWebhookDispatch(payload) {
11698
+ const rpcId = payload._rpcId;
11699
+ if (!rpcId) {
11700
+ process.stderr.write(`[daemon] webhook.dispatch.request without _rpcId \u2014 dropped
11701
+ `);
11702
+ return;
11703
+ }
11704
+ const { _rpcId: _, ...request } = payload;
11705
+ try {
11706
+ const handle = handleAgentMessageDispatch(request, {
11707
+ findAgent: (agentImUserId) => this.findMessageDispatchAgent(agentImUserId),
11708
+ postReply: (replyPayload) => this.postMessageDispatchReply(replyPayload),
11709
+ onError: (err, req) => {
11710
+ process.stderr.write(
11711
+ `[daemon] webhook.dispatch.request failed message=${req.messageId} agent=${req.mentionedAgentImUserId}: ${err instanceof Error ? err.stack ?? err.message : String(err)}
11712
+ `
11713
+ );
11714
+ }
11715
+ });
11716
+ this.ws.send({
11717
+ type: "webhook.dispatch.reply",
11718
+ payload: {
11719
+ _rpcId: rpcId,
11720
+ ok: handle.response.ok,
11721
+ acceptedAt: handle.response.acceptedAt,
11722
+ error: handle.response.error
11723
+ }
11724
+ });
11725
+ void handle.done.catch((err) => {
11726
+ process.stderr.write(
11727
+ `[daemon] webhook.dispatch final reply failed message=${request.messageId}: ${err instanceof Error ? err.message : String(err)}
11728
+ `
11729
+ );
11730
+ });
11731
+ } catch (err) {
11732
+ this.ws.send({
11733
+ type: "webhook.dispatch.reply",
11734
+ payload: {
11735
+ _rpcId: rpcId,
11736
+ ok: false,
11737
+ error: {
11738
+ code: "daemon_dispatch_failed",
11739
+ message: err instanceof Error ? err.message : String(err)
11740
+ }
11741
+ }
11742
+ });
11743
+ }
11744
+ }
10138
11745
  async onHostAcked(payload) {
10139
11746
  this.workspaceId = payload.workspaceId;
11747
+ void this.reportTransport().catch((err) => {
11748
+ process.stderr.write(
11749
+ `[daemon] transport-probe report failed: ${err.message}
11750
+ `
11751
+ );
11752
+ });
10140
11753
  if (this.memoryWiring && this.workspaceId) {
10141
11754
  syncMemoryFromCloud(this.memoryWiring, this.cloud, [this.workspaceId]).catch(
10142
11755
  (err) => log7.error("Initial memory sync failed", err.message)
@@ -10318,14 +11931,68 @@ var Runner = class extends EventEmitter3 {
10318
11931
  };
10319
11932
  }
10320
11933
  async postMessageDispatchReply(payload) {
10321
- const res = await this.cloud.request("POST", "/api/im/dispatch/reply", {
10322
- auth: false,
10323
- body: payload,
10324
- timeoutMs: 3e4
10325
- });
10326
- if (!res.ok) {
10327
- throw new Error(res.error?.message ?? `dispatch reply failed (${res.status})`);
11934
+ const taskId = `external:${payload.replyToMessageId}`;
11935
+ try {
11936
+ const result = await sendDispatchReplyTwoPhase({
11937
+ taskId,
11938
+ payload: {
11939
+ conversationId: payload.conversationId,
11940
+ replyToken: payload.replyToken,
11941
+ replyToMessageId: payload.replyToMessageId,
11942
+ agentImUserId: payload.agentImUserId,
11943
+ status: payload.status,
11944
+ ...payload.replyText !== void 0 ? { replyText: payload.replyText } : {},
11945
+ ...payload.attachments ? { attachments: payload.attachments } : {},
11946
+ assetIds: payload.attachments?.map((a) => a.assetId).filter((id) => typeof id === "string") ?? [],
11947
+ completedAt: payload.completedAt,
11948
+ ...payload.error ? { error: payload.error } : {}
11949
+ },
11950
+ cloud: this.cloud,
11951
+ cache: this.pendingReplyCache
11952
+ });
11953
+ if (result.status === "aborted") {
11954
+ throw new Error(`dispatch reply aborted by server reaper (replyId=${result.replyId})`);
11955
+ }
11956
+ } catch (err) {
11957
+ throw new Error(
11958
+ err.message?.length ? err.message : `dispatch reply failed`
11959
+ );
11960
+ }
11961
+ }
11962
+ /**
11963
+ * v2.0 §4.8.1 (Wave 4-E4) — submit the transport-probe report. Called
11964
+ * from `onHostAcked` so the daemon's container row exists in cloud
11965
+ * before we POST to /runtime/transport-report.
11966
+ *
11967
+ * The gatewayUrl reported here is `http://<local-ipv4>:<localPort>`
11968
+ * (typically `http://192.168.x.x:3210`). For a daemon without a local
11969
+ * HTTP server (startLocalServer=false in tests) we still post the
11970
+ * probe with `gatewayUrl=null` so cloud knows transport='ws' is the
11971
+ * only path.
11972
+ */
11973
+ async reportTransport() {
11974
+ const hasLocalServer = this.opts.startLocalServer !== false;
11975
+ let gatewayUrl = null;
11976
+ if (hasLocalServer) {
11977
+ const localIp = pickLocalIPv4();
11978
+ if (localIp) {
11979
+ const port = this.opts.localPort ?? DEFAULT_LOCAL_PORT;
11980
+ gatewayUrl = `http://${localIp}:${port}`;
11981
+ }
11982
+ }
11983
+ const probe = buildTransportProbe(gatewayUrl);
11984
+ const result = await reportTransportProbe(this.cloud, this.config.daemon_id, probe);
11985
+ if (!result.ok) {
11986
+ process.stderr.write(
11987
+ `[daemon] transport-probe report rejected status=${result.status} error=${result.error ?? "-"}
11988
+ `
11989
+ );
11990
+ return;
10328
11991
  }
11992
+ process.stdout.write(
11993
+ `[daemon] transport-probe reported transport=${probe.transport} gateway=${probe.gatewayUrl ?? "null"} private=${probe.gatewayIsPrivate}
11994
+ `
11995
+ );
10329
11996
  }
10330
11997
  onAgentChanged(payload) {
10331
11998
  const a = this.hostedAgents.get(payload.agentImUserId);
@@ -10768,7 +12435,7 @@ var Runner = class extends EventEmitter3 {
10768
12435
  function resolveUserAssetWorkspaceDir(workspaceId) {
10769
12436
  const override = process.env.PRISMER_ASSET_SYNC_ROOT;
10770
12437
  if (override) return join14(override, workspaceId);
10771
- const home = homedir6();
12438
+ const home = homedir7();
10772
12439
  const desktop = join14(home, "Desktop");
10773
12440
  const base = existsSync9(desktop) ? desktop : home;
10774
12441
  return join14(base, "Prismer Assets", workspaceId);
@@ -10848,7 +12515,7 @@ function safeJsonParse(raw) {
10848
12515
  // src/pair.ts
10849
12516
  import { generateKeyPairSync } from "crypto";
10850
12517
  import { hostname as hostname2 } from "os";
10851
- import { setTimeout as sleep2 } from "timers/promises";
12518
+ import { setTimeout as sleep3 } from "timers/promises";
10852
12519
  import qrcode from "qrcode";
10853
12520
  async function pair(opts) {
10854
12521
  const paths = opts.paths ?? resolvePaths();
@@ -10917,7 +12584,7 @@ Scan with Lumin to approve, or open: ${offer.qrUrl}
10917
12584
  const pollIntervalMs = opts.pollIntervalMs ?? 5e3;
10918
12585
  for (let i = 0; i < maxAttempts; i += 1) {
10919
12586
  if (i > 0 || !localOnlyMode) {
10920
- await sleep2(pollIntervalMs);
12587
+ await sleep3(pollIntervalMs);
10921
12588
  }
10922
12589
  const res = await cloud.request(
10923
12590
  "GET",
@@ -10961,13 +12628,13 @@ import { spawnSync as spawnSync2 } from "child_process";
10961
12628
  import {
10962
12629
  copyFileSync,
10963
12630
  existsSync as existsSync12,
10964
- mkdirSync as mkdirSync8,
12631
+ mkdirSync as mkdirSync9,
10965
12632
  readFileSync as readFileSync10,
10966
12633
  statSync as statSync3,
10967
- writeFileSync as writeFileSync7
12634
+ writeFileSync as writeFileSync8
10968
12635
  } from "fs";
10969
- import { homedir as homedir7 } from "os";
10970
- import { dirname as dirname9, join as join16 } from "path";
12636
+ import { homedir as homedir8 } from "os";
12637
+ import { dirname as dirname10, join as join16 } from "path";
10971
12638
  import { Command } from "commander";
10972
12639
  init_hermes();
10973
12640
  init_openclaw();
@@ -10982,7 +12649,7 @@ var INSTALL_SPECS = {
10982
12649
  binary: "claude",
10983
12650
  hint: "Set ANTHROPIC_API_KEY in your shell profile, or rely on Claude Code OAuth login. Run `claude login` to sign in.",
10984
12651
  authHints: ["ANTHROPIC_API_KEY or Claude Code OAuth login (`claude login`)"],
10985
- hookTarget: { path: join16(homedir7(), ".claude", "hooks.json"), kind: "json-file" }
12652
+ hookTarget: { path: join16(homedir8(), ".claude", "hooks.json"), kind: "json-file" }
10986
12653
  },
10987
12654
  openclaw: {
10988
12655
  name: "openclaw",
@@ -10991,7 +12658,7 @@ var INSTALL_SPECS = {
10991
12658
  binary: "openclaw",
10992
12659
  hint: "OpenClaw runs as a gateway. Configure ~/.openclaw/openclaw.json and start it with `openclaw gateway` before tasks dispatch.",
10993
12660
  authHints: ["~/.openclaw/openclaw.json gateway.auth.bearerTokens", "Prismer daemon api_key"],
10994
- hookTarget: { path: join16(homedir7(), ".openclaw", "hooks"), kind: "directory" }
12661
+ hookTarget: { path: join16(homedir8(), ".openclaw", "hooks"), kind: "directory" }
10995
12662
  },
10996
12663
  codex: {
10997
12664
  name: "codex",
@@ -11000,7 +12667,7 @@ var INSTALL_SPECS = {
11000
12667
  binary: "codex",
11001
12668
  hint: "Set OPENAI_API_KEY in your shell profile. Verify with `codex --help`.",
11002
12669
  authHints: ["OPENAI_API_KEY or Codex CLI account login"],
11003
- hookTarget: { path: join16(homedir7(), ".codex", "hooks.json"), kind: "json-file" }
12670
+ hookTarget: { path: join16(homedir8(), ".codex", "hooks.json"), kind: "json-file" }
11004
12671
  },
11005
12672
  hermes: {
11006
12673
  name: "hermes",
@@ -11009,7 +12676,7 @@ var INSTALL_SPECS = {
11009
12676
  binary: "hermes",
11010
12677
  hint: "Hermes runs as a long-lived HTTP gateway in your own Python venv. Start with `hermes -p <profile> gateway` after configuring ~/.hermes/.env.",
11011
12678
  authHints: ["~/.hermes/.env API_SERVER_KEY", "Hermes profile provider credentials"],
11012
- hookTarget: { path: join16(homedir7(), ".hermes", "hooks.json"), kind: "json-file" }
12679
+ hookTarget: { path: join16(homedir8(), ".hermes", "hooks.json"), kind: "json-file" }
11013
12680
  }
11014
12681
  };
11015
12682
  function buildAdapterCommand() {
@@ -11224,15 +12891,15 @@ function runHooks(spec, opts) {
11224
12891
  }
11225
12892
  let backup;
11226
12893
  if (planned.kind === "directory") {
11227
- mkdirSync8(planned.path, { recursive: true });
12894
+ mkdirSync9(planned.path, { recursive: true });
11228
12895
  const markerPath = join16(planned.path, "prismer.json");
11229
12896
  backup = backupIfExists(markerPath);
11230
- writeFileSync7(markerPath, JSON.stringify(prismerHookMarker(spec), null, 2) + "\n", "utf8");
12897
+ writeFileSync8(markerPath, JSON.stringify(prismerHookMarker(spec), null, 2) + "\n", "utf8");
11231
12898
  } else {
11232
- mkdirSync8(dirname9(planned.path), { recursive: true });
12899
+ mkdirSync9(dirname10(planned.path), { recursive: true });
11233
12900
  backup = backupIfExists(planned.path);
11234
12901
  const merged = mergeHookJson(planned.path, spec);
11235
- writeFileSync7(planned.path, JSON.stringify(merged, null, 2) + "\n", "utf8");
12902
+ writeFileSync8(planned.path, JSON.stringify(merged, null, 2) + "\n", "utf8");
11236
12903
  }
11237
12904
  return {
11238
12905
  ok: true,
@@ -11299,7 +12966,7 @@ function inspectAuthEnv(spec) {
11299
12966
  hints: spec.authHints ?? []
11300
12967
  };
11301
12968
  case "hermes": {
11302
- const envFile = join16(homedir7(), ".hermes", ".env");
12969
+ const envFile = join16(homedir8(), ".hermes", ".env");
11303
12970
  return {
11304
12971
  ok: existsSync12(envFile) || Boolean(process.env.HERMES_API_KEY || process.env.API_SERVER_KEY),
11305
12972
  present: [
@@ -11310,7 +12977,7 @@ function inspectAuthEnv(spec) {
11310
12977
  };
11311
12978
  }
11312
12979
  case "openclaw": {
11313
- const cfgFile = join16(homedir7(), ".openclaw", "openclaw.json");
12980
+ const cfgFile = join16(homedir8(), ".openclaw", "openclaw.json");
11314
12981
  return {
11315
12982
  ok: existsSync12(cfgFile) || Boolean(process.env.OPENCLAW_API_KEY),
11316
12983
  present: [
@@ -11330,7 +12997,7 @@ function inspectHook(spec) {
11330
12997
  return { supported: false, markerPresent: false };
11331
12998
  }
11332
12999
  if (spec.name === "openclaw") {
11333
- const jsonPath = join16(homedir7(), ".openclaw", "hooks.json");
13000
+ const jsonPath = join16(homedir8(), ".openclaw", "hooks.json");
11334
13001
  const markerPath = join16(target.path, "prismer.json");
11335
13002
  const jsonMarkerPresent = existsSync12(jsonPath) && fileContainsPrismerMarker(jsonPath);
11336
13003
  const dirMarkerPresent = existsSync12(markerPath) && fileContainsPrismerMarker(markerPath);
@@ -11792,8 +13459,74 @@ function buildAgentCommand() {
11792
13459
  }
11793
13460
  getUI().ok("Skill install requested", `agent=${opts.agent} skill=${slugOrId}`);
11794
13461
  }, { code: "agent_install_skill_failed" }));
13462
+ 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) => {
13463
+ const cloud = makeCloudClient();
13464
+ const data = await requestEnvelope(cloud, "POST", `/api/im/agents/${encodeURIComponent(imUserId)}/snapshot`, {
13465
+ includeMemory: Boolean(opts.includeMemory),
13466
+ label: opts.label
13467
+ }, "agent_snapshot_failed");
13468
+ if (opts.json) {
13469
+ printJson(data);
13470
+ return;
13471
+ }
13472
+ const rec = data;
13473
+ getUI().ok("Snapshot created", String(rec.id ?? "<unknown>"));
13474
+ getUI().line(`agent=${String(rec.agentImUserId ?? imUserId)} memory=${rec.includeMemory ? "included" : "stripped"}`);
13475
+ }, { code: "agent_snapshot_failed" }));
13476
+ 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) => {
13477
+ const cloud = makeCloudClient();
13478
+ const data = await requestEnvelope(cloud, "POST", `/api/im/agents/${encodeURIComponent(imUserId)}/publish`, {
13479
+ slug: opts.slug,
13480
+ version: opts.version,
13481
+ license: opts.license,
13482
+ metadata: {
13483
+ ...opts.title ? { title: opts.title } : {},
13484
+ ...opts.description ? { description: opts.description } : {}
13485
+ },
13486
+ stripMemory: true
13487
+ }, "agent_publish_failed");
13488
+ if (opts.json) {
13489
+ printJson(data);
13490
+ return;
13491
+ }
13492
+ const rec = data;
13493
+ getUI().ok("Agent Pack published", `${String(rec.slug ?? opts.slug)}@${String(rec.version ?? opts.version ?? "1.0.0")}`);
13494
+ getUI().line(`package=${String(rec.id ?? "<unknown>")}`);
13495
+ }, { code: "agent_publish_failed" }));
13496
+ 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) => {
13497
+ const cloud = makeCloudClient();
13498
+ const data = await requestEnvelope(cloud, "POST", `/api/im/agent-packs/${encodeURIComponent(packIdOrSlug)}/fork`, {
13499
+ targetWorkspaceId: opts.workspaceId,
13500
+ displayName: opts.displayName
13501
+ }, "agent_fork_failed");
13502
+ if (opts.json) {
13503
+ printJson(data);
13504
+ return;
13505
+ }
13506
+ const rec = data;
13507
+ getUI().ok("Agent Pack forked", `newAgent=${String(rec.newImUserId ?? "<unknown>")}`);
13508
+ getUI().line(`did=${String(rec.newDid ?? "not issued")}`);
13509
+ }, { code: "agent_fork_failed" }));
11795
13510
  return cmd;
11796
13511
  }
13512
+ function makeCloudClient() {
13513
+ const cfg = loadConfig(resolvePaths());
13514
+ return new CloudClient({ baseUrl: cfg.cloud_api_base, apiKey: cfg.api_key });
13515
+ }
13516
+ async function requestEnvelope(cloud, method, path9, body, code) {
13517
+ const res = await cloud.request(
13518
+ method,
13519
+ path9,
13520
+ body === void 0 ? void 0 : { body }
13521
+ );
13522
+ if (!res.ok || res.data?.ok === false) {
13523
+ const message = res.error?.message ?? res.data?.error?.message ?? "request failed";
13524
+ exitWithError(`${path9} failed (${res.status === 0 ? "network error" : `HTTP ${res.status}`}): ${message}`, {
13525
+ code: res.error?.code ?? res.data?.error?.code ?? code
13526
+ });
13527
+ }
13528
+ return res.data && typeof res.data === "object" && "data" in res.data ? res.data.data : res.data;
13529
+ }
11797
13530
  function readLocalAgents(localDb) {
11798
13531
  const db = openLocalDb(localDb);
11799
13532
  try {
@@ -11898,8 +13631,8 @@ function whichBinary2(bin) {
11898
13631
  // src/cli/commands/asset.ts
11899
13632
  import { createHash as createHash8 } from "crypto";
11900
13633
  import { Command as Command3 } from "commander";
11901
- import { existsSync as existsSync13, lstatSync, readdirSync, readFileSync as readFileSync11, writeFileSync as writeFileSync8 } from "fs";
11902
- import { basename as basename3, dirname as dirname10, join as join17, relative as relative4 } from "path";
13634
+ import { existsSync as existsSync13, lstatSync, readdirSync, readFileSync as readFileSync11, writeFileSync as writeFileSync9 } from "fs";
13635
+ import { basename as basename3, dirname as dirname11, join as join17, relative as relative4 } from "path";
11903
13636
  init_util();
11904
13637
  init_ui();
11905
13638
  function buildAssetCommand() {
@@ -12082,7 +13815,7 @@ async function uploadAssetPath(inputPath, opts) {
12082
13815
  if (files.length === 0) exitWithError(`directory has no uploadable files: ${inputPath}`);
12083
13816
  const items = [];
12084
13817
  for (const file of files) {
12085
- const relDir = dirname10(relative4(inputPath, file));
13818
+ const relDir = dirname11(relative4(inputPath, file));
12086
13819
  const folderPath = combineFolderPath(opts.folderPath, relDir === "." ? "" : relDir);
12087
13820
  const response = await uploadAsset(file, { ...opts, folderPath });
12088
13821
  items.push({ file, folderPath: folderPath ?? null, response });
@@ -12265,7 +13998,7 @@ async function downloadAsset(assetId, outPath) {
12265
13998
  exitWithError(`asset download failed (${res.status}): ${errorMessage(body)}`);
12266
13999
  }
12267
14000
  const bytes = Buffer.from(await res.arrayBuffer());
12268
- writeFileSync8(outPath, bytes);
14001
+ writeFileSync9(outPath, bytes);
12269
14002
  }
12270
14003
  function parseMetadata(raw) {
12271
14004
  if (!raw) return {};
@@ -12664,7 +14397,7 @@ function buildCookbookCommand() {
12664
14397
  for (const suite of suites) {
12665
14398
  checks.push(...await runSuite(suite, cloud, opts));
12666
14399
  }
12667
- const summary = summarize(checks, Boolean(opts.strict));
14400
+ const summary = summarize2(checks, Boolean(opts.strict));
12668
14401
  if (opts.json) {
12669
14402
  printJson(summary);
12670
14403
  } else {
@@ -12829,7 +14562,7 @@ function parseSuites(raw) {
12829
14562
  const expanded = selected.includes("all") ? ["status", "im", "task", "group", "asset", "sandbox"] : selected;
12830
14563
  return [...new Set(expanded)];
12831
14564
  }
12832
- function summarize(checks, strict) {
14565
+ function summarize2(checks, strict) {
12833
14566
  const totals = { pass: 0, fail: 0, skip: 0 };
12834
14567
  for (const check of checks) totals[check.status] += 1;
12835
14568
  return {
@@ -12912,8 +14645,8 @@ function parsePositiveInt3(value) {
12912
14645
  // src/cli/commands/daemon.ts
12913
14646
  import { Command as Command8 } from "commander";
12914
14647
  import { spawn as spawn5 } from "child_process";
12915
- import { createReadStream, existsSync as existsSync14, mkdirSync as mkdirSync9, openSync as openSync2, statSync as statSync4 } from "fs";
12916
- import { setTimeout as sleep3 } from "timers/promises";
14648
+ import { createReadStream, existsSync as existsSync14, mkdirSync as mkdirSync10, openSync as openSync2, statSync as statSync4 } from "fs";
14649
+ import { setTimeout as sleep4 } from "timers/promises";
12917
14650
  import { join as join18 } from "path";
12918
14651
  init_util();
12919
14652
  init_ui();
@@ -12956,7 +14689,7 @@ function buildDaemonCommand() {
12956
14689
  while (!configExists(paths)) {
12957
14690
  if (stopRequested) return;
12958
14691
  getUI().info(`[daemon] waiting for ${paths.configFile} \u2014 run \`prismer setup\` to create it`);
12959
- await sleep3(5e3);
14692
+ await sleep4(5e3);
12960
14693
  }
12961
14694
  runner = new Runner({
12962
14695
  startLocalServer: opts.localServer !== false,
@@ -12988,7 +14721,7 @@ function buildDaemonCommand() {
12988
14721
  if (existingPid && pidAlive2(existingPid)) {
12989
14722
  exitWithError(`Daemon already running (pid ${existingPid}). Use \`prismer daemon stop\` first.`);
12990
14723
  }
12991
- if (!existsSync14(paths.logsDir)) mkdirSync9(paths.logsDir, { recursive: true });
14724
+ if (!existsSync14(paths.logsDir)) mkdirSync10(paths.logsDir, { recursive: true });
12992
14725
  const logFile = join18(paths.logsDir, "daemon.log");
12993
14726
  const fd = openSync2(logFile, "a");
12994
14727
  const args = [process.argv[1], "daemon", "run"];
@@ -13002,7 +14735,7 @@ function buildDaemonCommand() {
13002
14735
  child.unref();
13003
14736
  const deadline = Date.now() + 5e3;
13004
14737
  while (Date.now() < deadline) {
13005
- await sleep3(200);
14738
+ await sleep4(200);
13006
14739
  const pid = readPidFile(paths);
13007
14740
  if (pid && pidAlive2(pid)) {
13008
14741
  if (opts.json) printJson({ ok: true, pid, logFile });
@@ -13036,7 +14769,7 @@ function buildDaemonCommand() {
13036
14769
  getUI().ok("Daemon stopped", `pid ${pid}`);
13037
14770
  return;
13038
14771
  }
13039
- await sleep3(200);
14772
+ await sleep4(200);
13040
14773
  }
13041
14774
  process.stderr.write(`Daemon did not exit within timeout; pid ${pid} may still be alive.
13042
14775
  `);
@@ -13117,7 +14850,7 @@ async function tailFromEnd(path9, lines) {
13117
14850
  async function followFile(path9) {
13118
14851
  let offset = statSync4(path9).size;
13119
14852
  for (; ; ) {
13120
- await sleep3(1e3);
14853
+ await sleep4(1e3);
13121
14854
  const size = statSync4(path9).size;
13122
14855
  if (size < offset) offset = 0;
13123
14856
  if (size === offset) continue;
@@ -13131,7 +14864,7 @@ async function followFile(path9) {
13131
14864
  init_util();
13132
14865
  import { Command as Command9 } from "commander";
13133
14866
  import { createReadStream as createReadStream2, existsSync as existsSync15 } from "fs";
13134
- import { homedir as homedir8 } from "os";
14867
+ import { homedir as homedir9 } from "os";
13135
14868
  import { join as join19 } from "path";
13136
14869
  import { createInterface } from "readline";
13137
14870
  var DEFAULT_LIMIT2 = 50;
@@ -13164,7 +14897,7 @@ function buildEventsStatsCommand() {
13164
14897
  const events = await readEvents(file, filters);
13165
14898
  printJson({
13166
14899
  ok: true,
13167
- data: { stats: summarize2(events), count: events.length, limit: filters.limit },
14900
+ data: { stats: summarize3(events), count: events.length, limit: filters.limit },
13168
14901
  checks: { file, exists: true, filters: cleanFilters(filters) }
13169
14902
  });
13170
14903
  });
@@ -13173,7 +14906,7 @@ function addEventOptions(cmd) {
13173
14906
  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)");
13174
14907
  }
13175
14908
  function eventsPath() {
13176
- return join19(process.env.PRISMER_HOME ?? join19(homedir8(), ".prismer"), "para", "events.jsonl");
14909
+ return join19(process.env.PRISMER_HOME ?? join19(homedir9(), ".prismer"), "para", "events.jsonl");
13177
14910
  }
13178
14911
  function unavailable(file) {
13179
14912
  return {
@@ -13221,7 +14954,7 @@ function field(event, names) {
13221
14954
  }
13222
14955
  return void 0;
13223
14956
  }
13224
- function summarize2(events) {
14957
+ function summarize3(events) {
13225
14958
  const byFamily = {};
13226
14959
  const byType = {};
13227
14960
  const byAgentId = {};
@@ -13575,7 +15308,7 @@ function buildPairCommand() {
13575
15308
 
13576
15309
  // src/cli/commands/profile.ts
13577
15310
  import { Command as Command12 } from "commander";
13578
- import { existsSync as existsSync17, readFileSync as readFileSync12, writeFileSync as writeFileSync9 } from "fs";
15311
+ import { existsSync as existsSync17, readFileSync as readFileSync12, writeFileSync as writeFileSync10 } from "fs";
13579
15312
  import { tmpdir } from "os";
13580
15313
  import { join as join20 } from "path";
13581
15314
  import { spawnSync as spawnSync4 } from "child_process";
@@ -13626,7 +15359,7 @@ function buildProfileCommand() {
13626
15359
  `/api/im/agent_profiles/${encodeURIComponent(profileId)}`
13627
15360
  );
13628
15361
  const tmpFile = join20(tmpdir(), `prismer-profile-${profileId}.json`);
13629
- writeFileSync9(tmpFile, JSON.stringify(profile.config, null, 2), "utf8");
15362
+ writeFileSync10(tmpFile, JSON.stringify(profile.config, null, 2), "utf8");
13630
15363
  const editor = process.env.EDITOR || "vi";
13631
15364
  const ed = spawnSync4(editor, [tmpFile], { stdio: "inherit" });
13632
15365
  if (ed.status !== 0) exitWithError(`editor exited ${ed.status}`, { code: "editor_failed" });
@@ -13667,10 +15400,10 @@ async function resolveDefaultWorkspaceId(cloud) {
13667
15400
  // src/cli/commands/reset.ts
13668
15401
  import { Command as Command13 } from "commander";
13669
15402
  import { execFileSync as execFileSync2 } from "child_process";
13670
- import { existsSync as existsSync18, mkdirSync as mkdirSync10, rmSync, renameSync as renameSync2 } from "fs";
13671
- import { homedir as homedir9 } from "os";
13672
- import { dirname as dirname11, join as join21 } from "path";
13673
- import { setTimeout as sleep4 } from "timers/promises";
15403
+ import { existsSync as existsSync18, mkdirSync as mkdirSync11, rmSync, renameSync as renameSync2 } from "fs";
15404
+ import { homedir as homedir10 } from "os";
15405
+ import { dirname as dirname12, join as join21 } from "path";
15406
+ import { setTimeout as sleep5 } from "timers/promises";
13674
15407
  init_util();
13675
15408
  init_ui();
13676
15409
  function buildResetCommand() {
@@ -13723,8 +15456,8 @@ function buildResetCommand() {
13723
15456
  rmSync(paths.root, { recursive: true, force: true });
13724
15457
  } else {
13725
15458
  if (!archivePath) throw new Error("internal: archivePath missing");
13726
- if (!existsSync18(dirname11(archivePath))) {
13727
- mkdirSync10(dirname11(archivePath), { recursive: true });
15459
+ if (!existsSync18(dirname12(archivePath))) {
15460
+ mkdirSync11(dirname12(archivePath), { recursive: true });
13728
15461
  }
13729
15462
  renameSync2(paths.root, archivePath);
13730
15463
  }
@@ -13734,8 +15467,8 @@ function buildResetCommand() {
13734
15467
  rmSync(assetSyncRoot, { recursive: true, force: true });
13735
15468
  } else {
13736
15469
  if (!assetSyncArchivePath) throw new Error("internal: assetSyncArchivePath missing");
13737
- if (!existsSync18(dirname11(assetSyncArchivePath))) {
13738
- mkdirSync10(dirname11(assetSyncArchivePath), { recursive: true });
15470
+ if (!existsSync18(dirname12(assetSyncArchivePath))) {
15471
+ mkdirSync11(dirname12(assetSyncArchivePath), { recursive: true });
13739
15472
  }
13740
15473
  renameSync2(assetSyncRoot, assetSyncArchivePath);
13741
15474
  }
@@ -13745,13 +15478,13 @@ function buildResetCommand() {
13745
15478
  rmSync(hermesHome, { recursive: true, force: true });
13746
15479
  } else {
13747
15480
  if (!hermesArchivePath) throw new Error("internal: hermesArchivePath missing");
13748
- if (!existsSync18(dirname11(hermesArchivePath))) {
13749
- mkdirSync10(dirname11(hermesArchivePath), { recursive: true });
15481
+ if (!existsSync18(dirname12(hermesArchivePath))) {
15482
+ mkdirSync11(dirname12(hermesArchivePath), { recursive: true });
13750
15483
  }
13751
15484
  renameSync2(hermesHome, hermesArchivePath);
13752
15485
  }
13753
15486
  }
13754
- mkdirSync10(paths.root, { recursive: true });
15487
+ mkdirSync11(paths.root, { recursive: true });
13755
15488
  if (opts.json) {
13756
15489
  printJson({ ok: true, reset: true, plan });
13757
15490
  return;
@@ -13800,7 +15533,7 @@ async function stopProcess(pid, timeoutMs, label) {
13800
15533
  const deadline = Date.now() + timeoutMs;
13801
15534
  while (Date.now() < deadline) {
13802
15535
  if (!pidAlive2(pid)) return;
13803
- await sleep4(100);
15536
+ await sleep5(100);
13804
15537
  }
13805
15538
  try {
13806
15539
  process.kill(pid, "SIGKILL");
@@ -13810,7 +15543,7 @@ async function stopProcess(pid, timeoutMs, label) {
13810
15543
  const killDeadline = Date.now() + 1e3;
13811
15544
  while (Date.now() < killDeadline) {
13812
15545
  if (!pidAlive2(pid)) return;
13813
- await sleep4(100);
15546
+ await sleep5(100);
13814
15547
  }
13815
15548
  throw new Error(`${label} pid ${pid} did not exit within ${timeoutMs + 1e3}ms`);
13816
15549
  }
@@ -13820,13 +15553,13 @@ function timestamp() {
13820
15553
  function resolveAssetSyncRoot() {
13821
15554
  const override = process.env.PRISMER_ASSET_SYNC_ROOT;
13822
15555
  if (override) return override;
13823
- const home = homedir9();
15556
+ const home = homedir10();
13824
15557
  const desktop = join21(home, "Desktop");
13825
15558
  const base = existsSync18(desktop) ? desktop : home;
13826
15559
  return join21(base, "Prismer Assets");
13827
15560
  }
13828
15561
  function resolveHermesHome() {
13829
- return process.env.HERMES_HOME || join21(homedir9(), ".hermes");
15562
+ return process.env.HERMES_HOME || join21(homedir10(), ".hermes");
13830
15563
  }
13831
15564
  function findHermesGatewayPids2() {
13832
15565
  try {
@@ -14381,7 +16114,7 @@ function buildSkillCommand() {
14381
16114
  const cloud = mkCloud6();
14382
16115
  const path9 = opts.agent ? `/api/im/agents/${encodeURIComponent(opts.agent)}/skills` : `/api/im/skills/${encodeURIComponent(slugOrId)}/install`;
14383
16116
  const body = opts.agent ? { skillId: slugOrId, workspaceId: opts.workspaceId, version: opts.version } : { workspaceId: opts.workspaceId, version: opts.version };
14384
- const data = await requestEnvelope(cloud, "POST", path9, body, "skill_install_failed");
16117
+ const data = await requestEnvelope2(cloud, "POST", path9, body, "skill_install_failed");
14385
16118
  if (opts.json) {
14386
16119
  printJson(data);
14387
16120
  return;
@@ -14392,7 +16125,7 @@ function buildSkillCommand() {
14392
16125
  const cloud = mkCloud6();
14393
16126
  const path9 = opts.agent ? `/api/im/agents/${encodeURIComponent(opts.agent)}/skills` : `/api/im/skills/${encodeURIComponent(slugOrId)}/install`;
14394
16127
  const body = opts.agent ? { skillId: slugOrId, workspaceId: opts.workspaceId } : void 0;
14395
- const data = await requestEnvelope(cloud, "DELETE", path9, body, "skill_uninstall_failed");
16128
+ const data = await requestEnvelope2(cloud, "DELETE", path9, body, "skill_uninstall_failed");
14396
16129
  if (opts.json) {
14397
16130
  printJson(data);
14398
16131
  return;
@@ -14445,7 +16178,7 @@ function mkCloud6() {
14445
16178
  const cfg = loadConfig(resolvePaths());
14446
16179
  return new CloudClient({ baseUrl: cfg.cloud_api_base, apiKey: cfg.api_key });
14447
16180
  }
14448
- async function requestEnvelope(cloud, method, path9, body, code) {
16181
+ async function requestEnvelope2(cloud, method, path9, body, code) {
14449
16182
  const res = await cloud.request(method, path9, {
14450
16183
  body
14451
16184
  });
@@ -14595,6 +16328,7 @@ function buildStatusCommand() {
14595
16328
  let me = null;
14596
16329
  let devices = null;
14597
16330
  let agents = null;
16331
+ let diagnose = null;
14598
16332
  try {
14599
16333
  const meRes = await cloud.request("GET", "/api/im/me", { timeoutMs: 3e3 });
14600
16334
  cloudOk = meRes.ok;
@@ -14607,19 +16341,33 @@ function buildStatusCommand() {
14607
16341
  if (Array.isArray(wsList) && wsList.length > 0) {
14608
16342
  const wsId = wsList[0]?.id;
14609
16343
  if (wsId) {
14610
- const devRes = await cloud.request("GET", `/api/workspace/runtime-installations?workspaceId=${encodeURIComponent(wsId)}&includeStopped=false`, { timeoutMs: 3e3 });
14611
- if (devRes.ok) {
14612
- const devBody = devRes.data;
14613
- devices = devBody?.data;
14614
- }
14615
- const agRes = await cloud.request("GET", `/api/im/workspaces/${encodeURIComponent(wsId)}/agents`, { timeoutMs: 3e3 });
14616
- if (agRes.ok) {
14617
- const agBody = agRes.data;
14618
- agents = agBody?.data;
16344
+ const rtRes = await cloud.request("GET", `/api/im/workspaces/${encodeURIComponent(wsId)}/runtime`, { timeoutMs: 3e3 });
16345
+ if (rtRes.ok) {
16346
+ const rtBody = rtRes.data;
16347
+ const snapshot = rtBody?.data;
16348
+ if (snapshot && Array.isArray(snapshot.devices)) {
16349
+ devices = snapshot.devices;
16350
+ agents = snapshot.devices.flatMap((d) => d.agents ?? []);
16351
+ }
14619
16352
  }
14620
16353
  }
14621
16354
  }
14622
16355
  }
16356
+ const daemonId = cfg.daemon_id;
16357
+ if (daemonId) {
16358
+ try {
16359
+ const diagRes = await cloud.request(
16360
+ "GET",
16361
+ `/api/im/runtime/diagnose?daemonId=${encodeURIComponent(daemonId)}`,
16362
+ { timeoutMs: 3e3 }
16363
+ );
16364
+ if (diagRes.ok) {
16365
+ const body = diagRes.data;
16366
+ if (body?.data && typeof body.data === "object") diagnose = body.data;
16367
+ }
16368
+ } catch {
16369
+ }
16370
+ }
14623
16371
  }
14624
16372
  } catch {
14625
16373
  cloudOk = false;
@@ -14636,7 +16384,8 @@ function buildStatusCommand() {
14636
16384
  info: daemonStatus.info ?? {}
14637
16385
  },
14638
16386
  cloud: { base: cfg.cloud_api_base, reachable: cloudOk, me, devices, agents },
14639
- local
16387
+ local,
16388
+ diagnose
14640
16389
  };
14641
16390
  if (opts.json) {
14642
16391
  printJson(report);
@@ -14645,6 +16394,60 @@ function buildStatusCommand() {
14645
16394
  printPretty(report);
14646
16395
  });
14647
16396
  }
16397
+ function computeDeviceIcon(lastSeenAt, now) {
16398
+ if (!lastSeenAt) return "\u25CB";
16399
+ const age = now.getTime() - Date.parse(lastSeenAt);
16400
+ if (!Number.isFinite(age) || age < 0) return "\u25CB";
16401
+ if (age < 5 * 6e4) return "\u25CF";
16402
+ if (age < 60 * 6e4) return "\u25D0";
16403
+ return "\u25CB";
16404
+ }
16405
+ function computeAgentIcon(status, lastHeartbeat, now) {
16406
+ const s = (status || "").toLowerCase();
16407
+ if (s === "error" || s === "failed") return "\u25C6";
16408
+ if (!lastHeartbeat) return s === "offline" ? "\u25CB" : "\u25D0";
16409
+ const age = now.getTime() - Date.parse(lastHeartbeat);
16410
+ if (!Number.isFinite(age) || age < 0 || age >= 60 * 6e4) return "\u25CB";
16411
+ if (age >= 5 * 6e4) return "\u25D0";
16412
+ return "\u25CF";
16413
+ }
16414
+ function pad(value, width) {
16415
+ if (value.length >= width) return value.slice(0, width);
16416
+ return value + " ".repeat(width - value.length);
16417
+ }
16418
+ function formatDeviceBindings(devices, now = /* @__PURE__ */ new Date()) {
16419
+ const lines = [];
16420
+ if (devices.length === 0) {
16421
+ lines.push(" No paired devices.");
16422
+ return lines;
16423
+ }
16424
+ const totalAgents = devices.reduce((s, d) => s + (d.agents?.length ?? 0), 0);
16425
+ const devWord = devices.length === 1 ? "device" : "devices";
16426
+ const agWord = totalAgents === 1 ? "agent" : "agents";
16427
+ lines.push(` Device & Agent Bindings (${devices.length} ${devWord} \xB7 ${totalAgents} ${agWord})`);
16428
+ lines.push("");
16429
+ for (let di = 0; di < devices.length; di++) {
16430
+ const d = devices[di];
16431
+ const dIcon = computeDeviceIcon(d.lastSeenAt, now);
16432
+ const dName = pad(d.name || d.deviceId, 40);
16433
+ const lastSeen = d.lastSeenAt ? formatRelative(d.lastSeenAt, now) : "never seen";
16434
+ lines.push(` ${dIcon} ${dName} ${lastSeen}`);
16435
+ const agents = d.agents ?? [];
16436
+ for (let ai = 0; ai < agents.length; ai++) {
16437
+ const a = agents[ai];
16438
+ const isLast = ai === agents.length - 1;
16439
+ const branch = isLast ? "\u2514\u2500" : "\u251C\u2500";
16440
+ const aIcon = computeAgentIcon(a.status, a.lastHeartbeat, now);
16441
+ const aName = pad(a.name || a.id, 24);
16442
+ const aStatus = pad(a.status || "?", 8);
16443
+ const aVersion = pad(a.version ? `v${a.version}` : "\u2014", 10);
16444
+ const tail = a.currentTaskId ? `task=${a.currentTaskId}` : a.lastHeartbeat ? `\u2764 ${formatRelative(a.lastHeartbeat, now)}` : "";
16445
+ lines.push(` ${branch} ${aIcon} ${aName} ${aStatus} ${aVersion} ${tail}`.trimEnd());
16446
+ }
16447
+ if (di < devices.length - 1) lines.push("");
16448
+ }
16449
+ return lines;
16450
+ }
14648
16451
  async function readDaemonStatus() {
14649
16452
  try {
14650
16453
  const res = await fetch("http://127.0.0.1:3210/healthz", {
@@ -14717,16 +16520,7 @@ function printPretty(report) {
14717
16520
  if (Array.isArray(report.cloud.devices) && report.cloud.devices.length > 0) {
14718
16521
  const devs = report.cloud.devices;
14719
16522
  ui.blank();
14720
- ui.line(` Workspace Devices (${devs.length}):`);
14721
- for (const d of devs) {
14722
- const statusIcon = d.daemonStatus === "connected" ? "\u25CF" : "\u25CB";
14723
- const kind = d.runtimeKind === "docker" ? "Local" : "K8s";
14724
- const declared = d.hostedAgentSummary?.declared ?? 0;
14725
- ui.line(` ${statusIcon} ${d.podName?.replace(/^daemon:/, "")?.slice(0, 28) ?? "?"} kind=${kind} agents=${declared}`);
14726
- }
14727
- }
14728
- if (Array.isArray(report.cloud.agents) && report.cloud.agents.length > 0) {
14729
- ui.line(` Hosted agents: ${report.cloud.agents.length}`);
16523
+ for (const ln of formatDeviceBindings(devs)) ui.line(ln);
14730
16524
  }
14731
16525
  if (report.local) {
14732
16526
  ui.blank();
@@ -14736,11 +16530,63 @@ function printPretty(report) {
14736
16530
  } else {
14737
16531
  warn("Local DB", "unavailable");
14738
16532
  }
16533
+ if (report.diagnose) {
16534
+ ui.blank();
16535
+ ui.header("Cloud dispatch reachability");
16536
+ ui.blank();
16537
+ printDiagnose(report.diagnose, report.daemon.pid ?? null);
16538
+ }
16539
+ }
16540
+ function printDiagnose(d, pid) {
16541
+ const ui = getUI();
16542
+ const heartbeatLine = d.lastHeartbeatAt ? `last heartbeat ${formatRelative(d.lastHeartbeatAt)}` : "no heartbeat seen";
16543
+ if (d.wsAlive) {
16544
+ ok("Daemon", `running${pid ? ` (pid ${pid})` : ""}`);
16545
+ ok("Cloud WS", `connected (${heartbeatLine})`);
16546
+ } else {
16547
+ warn("Daemon", `running${pid ? ` (pid ${pid})` : ""} \u2014 not visible to cloud WS`);
16548
+ fail2("Cloud WS", `disconnected (${heartbeatLine})`);
16549
+ }
16550
+ if (d.gatewayUrl) {
16551
+ if (d.gatewayIsPrivate) {
16552
+ warn("HTTP gateway", `${d.gatewayUrl} (private IP \u2014 only reachable on same network)`);
16553
+ } else if (d.httpReachable) {
16554
+ ok(
16555
+ "HTTP gateway",
16556
+ `${d.gatewayUrl} (reachable${d.httpLatencyMs != null ? `, ${d.httpLatencyMs}ms` : ""})`
16557
+ );
16558
+ } else {
16559
+ fail2("HTTP gateway", `${d.gatewayUrl} (unreachable${d.httpError ? ` \u2014 ${d.httpError}` : ""})`);
16560
+ }
16561
+ } else {
16562
+ ui.line(" HTTP gateway: not declared (WS-only daemon)");
16563
+ }
16564
+ const activeAgents = /* @__PURE__ */ (() => {
16565
+ return 0;
16566
+ })();
16567
+ if (activeAgents > 0) {
16568
+ ok("Active agents", String(activeAgents));
16569
+ }
16570
+ const recColour = d.recommendedTransport === "ws" ? ok : d.recommendedTransport === "http" ? warn : fail2;
16571
+ recColour("Recommended transport", d.recommendedTransport);
16572
+ if (d.lastProbeAt) {
16573
+ ui.line(` Last transport probe: ${formatRelative(d.lastProbeAt)}`);
16574
+ }
16575
+ }
16576
+ function formatRelative(iso, now = /* @__PURE__ */ new Date()) {
16577
+ const ts = Date.parse(iso);
16578
+ if (!Number.isFinite(ts)) return "unknown";
16579
+ const diff = Math.max(0, now.getTime() - ts);
16580
+ if (diff < 3e4) return "just now";
16581
+ if (diff < 6e4) return `${Math.round(diff / 1e3)}s ago`;
16582
+ if (diff < 36e5) return `${Math.round(diff / 6e4)}m ago`;
16583
+ if (diff < 864e5) return `${Math.round(diff / 36e5)}h ago`;
16584
+ return `${Math.round(diff / 864e5)}d ago`;
14739
16585
  }
14740
16586
 
14741
16587
  // src/cli/commands/task.ts
14742
16588
  import { Command as Command18 } from "commander";
14743
- import { setTimeout as sleep5 } from "timers/promises";
16589
+ import { setTimeout as sleep6 } from "timers/promises";
14744
16590
  init_util();
14745
16591
  function describeStatus2(status) {
14746
16592
  return status === 0 ? "network error" : `HTTP ${status}`;
@@ -14823,7 +16669,7 @@ function unwrap2(raw) {
14823
16669
  async function pollUntilTerminal(cloud, taskId, timeoutMs) {
14824
16670
  const deadline = Date.now() + (timeoutMs ?? 5 * 6e4);
14825
16671
  while (Date.now() < deadline) {
14826
- await sleep5(1e3);
16672
+ await sleep6(1e3);
14827
16673
  const res = await cloud.request(
14828
16674
  "GET",
14829
16675
  `/api/im/tasks/${encodeURIComponent(taskId)}`
@@ -15098,7 +16944,7 @@ async function readResponseError(res) {
15098
16944
 
15099
16945
  // src/cli/index.ts
15100
16946
  init_ui();
15101
- var VERSION = "2.0.3";
16947
+ var VERSION = "2.0.5";
15102
16948
  function buildProgram() {
15103
16949
  const program = new Command20("prismer").description("Prismer Cloud daemon CLI (TS-only).").version(VERSION);
15104
16950
  program.addCommand(buildBannerCommand());