@prismer/runtime 2.0.4 → 2.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.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
  });
@@ -2299,7 +2633,7 @@ var require_package = __commonJS({
2299
2633
  "package.json"(exports, module) {
2300
2634
  module.exports = {
2301
2635
  name: "@prismer/runtime",
2302
- version: "2.0.4",
2636
+ version: "2.0.5",
2303
2637
  description: "Prismer Cloud daemon runtime \u2014 TS-only adapter host for hosted IM agents",
2304
2638
  type: "module",
2305
2639
  main: "dist/index.js",
@@ -2333,6 +2667,7 @@ var require_package = __commonJS({
2333
2667
  },
2334
2668
  dependencies: {
2335
2669
  "@iarna/toml": "^2.2.5",
2670
+ "@modelcontextprotocol/sdk": "^1.29.0",
2336
2671
  "better-sqlite3": "^11.8.0",
2337
2672
  commander: "^12.1.0",
2338
2673
  qrcode: "^1.5.4",
@@ -2394,7 +2729,7 @@ __export(util_exports, {
2394
2729
  warn: () => warn,
2395
2730
  writePidFile: () => writePidFile
2396
2731
  });
2397
- 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";
2398
2733
  import { join as join15 } from "path";
2399
2734
  function color(kind, text) {
2400
2735
  if (process.env.NO_COLOR === "1" || process.env.NO_COLOR === "true") return text;
@@ -2492,7 +2827,7 @@ function pidFilePath(paths) {
2492
2827
  return join15(paths.root, "daemon.pid");
2493
2828
  }
2494
2829
  function writePidFile(paths, pid) {
2495
- writeFileSync6(pidFilePath(paths), `${pid}
2830
+ writeFileSync7(pidFilePath(paths), `${pid}
2496
2831
  `, "utf8");
2497
2832
  }
2498
2833
  function readPidFile(paths) {
@@ -2715,9 +3050,9 @@ var WsClient = class extends EventEmitter {
2715
3050
 
2716
3051
  // src/sync/store.ts
2717
3052
  import Database2 from "better-sqlite3";
2718
- import { existsSync as existsSync2, mkdirSync as mkdirSync2 } from "fs";
3053
+ import { existsSync as existsSync2, mkdirSync as mkdirSync3 } from "fs";
2719
3054
  import { dirname as dirname2 } from "path";
2720
- var SCHEMA_VERSION = 3;
3055
+ var SCHEMA_VERSION = 4;
2721
3056
  var MIGRATIONS = [
2722
3057
  {
2723
3058
  version: 1,
@@ -2843,6 +3178,36 @@ var MIGRATIONS = [
2843
3178
  CREATE INDEX IF NOT EXISTS idx_asset_meta_seq
2844
3179
  ON asset_metadata_index(workspace_id, asset_index_seq);
2845
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
+ `
2846
3211
  }
2847
3212
  ];
2848
3213
  function runSql(db, sql) {
@@ -2850,7 +3215,7 @@ function runSql(db, sql) {
2850
3215
  }
2851
3216
  function openLocalDb(path9) {
2852
3217
  if (path9 !== ":memory:" && !existsSync2(dirname2(path9))) {
2853
- mkdirSync2(dirname2(path9), { recursive: true });
3218
+ mkdirSync3(dirname2(path9), { recursive: true });
2854
3219
  }
2855
3220
  const db = new Database2(path9);
2856
3221
  db.pragma("journal_mode = WAL");
@@ -3087,7 +3452,7 @@ function listRoleTemplates() {
3087
3452
 
3088
3453
  // src/config.ts
3089
3454
  import * as TOML from "@iarna/toml";
3090
- 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";
3091
3456
  import { homedir as homedir3 } from "os";
3092
3457
  import { dirname as dirname3, join as join4 } from "path";
3093
3458
  import { z as z3 } from "zod";
@@ -3154,13 +3519,13 @@ function loadConfig(paths = resolvePaths()) {
3154
3519
  }
3155
3520
  function saveConfig(config, paths = resolvePaths()) {
3156
3521
  if (!existsSync3(paths.root)) {
3157
- mkdirSync3(paths.root, { recursive: true });
3522
+ mkdirSync4(paths.root, { recursive: true });
3158
3523
  }
3159
3524
  if (!existsSync3(dirname3(paths.configFile))) {
3160
- mkdirSync3(dirname3(paths.configFile), { recursive: true });
3525
+ mkdirSync4(dirname3(paths.configFile), { recursive: true });
3161
3526
  }
3162
3527
  ConfigSchema.parse(config);
3163
- writeFileSync2(paths.configFile, TOML.stringify(config), "utf8");
3528
+ writeFileSync3(paths.configFile, TOML.stringify(config), "utf8");
3164
3529
  }
3165
3530
  function deriveWsUrl(httpBase) {
3166
3531
  const u = new URL(httpBase);
@@ -3333,7 +3698,7 @@ function envelope(type, payload, requestId) {
3333
3698
  // src/asset-cache.ts
3334
3699
  import { createHash as createHash2 } from "crypto";
3335
3700
  import { promises as dnsp } from "dns";
3336
- 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";
3337
3702
  import { isIP, isIPv4, isIPv6 } from "net";
3338
3703
  import { dirname as dirname4, join as join5 } from "path";
3339
3704
  import { Agent as UndiciAgent } from "undici";
@@ -3359,7 +3724,7 @@ var AssetCache = class {
3359
3724
  this.cacheDir = opts.cacheDir;
3360
3725
  this.maxBytes = opts.maxBytes ?? 5 * 1024 * 1024 * 1024;
3361
3726
  if (!existsSync4(this.cacheDir)) {
3362
- mkdirSync4(this.cacheDir, { recursive: true });
3727
+ mkdirSync5(this.cacheDir, { recursive: true });
3363
3728
  }
3364
3729
  }
3365
3730
  /** Local file path for a content hash. Files split by first 2 chars for fs scaling. */
@@ -3434,10 +3799,10 @@ var AssetCache = class {
3434
3799
  }
3435
3800
  const localPath = this.pathFor(hash);
3436
3801
  if (!existsSync4(dirname4(localPath))) {
3437
- mkdirSync4(dirname4(localPath), { recursive: true });
3802
+ mkdirSync5(dirname4(localPath), { recursive: true });
3438
3803
  }
3439
3804
  const tmpPath = `${localPath}.${process.pid}.${Date.now()}.tmp`;
3440
- writeFileSync3(tmpPath, buf);
3805
+ writeFileSync4(tmpPath, buf);
3441
3806
  renameSync(tmpPath, localPath);
3442
3807
  const mime = res.headers.get("content-type");
3443
3808
  const now = Date.now();
@@ -3498,10 +3863,10 @@ var AssetCache = class {
3498
3863
  }
3499
3864
  const localPath = this.pathFor(hash);
3500
3865
  if (!existsSync4(dirname4(localPath))) {
3501
- mkdirSync4(dirname4(localPath), { recursive: true });
3866
+ mkdirSync5(dirname4(localPath), { recursive: true });
3502
3867
  }
3503
3868
  const tmpPath = `${localPath}.${process.pid}.${Date.now()}.tmp`;
3504
- writeFileSync3(tmpPath, body);
3869
+ writeFileSync4(tmpPath, body);
3505
3870
  renameSync(tmpPath, localPath);
3506
3871
  const now = Date.now();
3507
3872
  this.db.prepare(
@@ -3755,7 +4120,7 @@ async function fetchUrlWithGuards(rawUrl, opts) {
3755
4120
  }
3756
4121
 
3757
4122
  // src/daemon/asset/mirror.ts
3758
- 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";
3759
4124
  import { join as join6 } from "path";
3760
4125
  function rowToBinding(row) {
3761
4126
  return {
@@ -3778,7 +4143,7 @@ var WorkspaceMirror = class {
3778
4143
  this.cloud = opts.cloud;
3779
4144
  this.workspaceId = opts.workspaceId;
3780
4145
  if (!existsSync5(opts.workspaceStateDir)) {
3781
- mkdirSync5(opts.workspaceStateDir, { recursive: true });
4146
+ mkdirSync6(opts.workspaceStateDir, { recursive: true });
3782
4147
  }
3783
4148
  this.cursorPath = join6(opts.workspaceStateDir, "asset-cursor.json");
3784
4149
  }
@@ -3799,7 +4164,7 @@ var WorkspaceMirror = class {
3799
4164
  cursor,
3800
4165
  writtenAt: Date.now()
3801
4166
  };
3802
- writeFileSync4(this.cursorPath, JSON.stringify(payload, null, 2));
4167
+ writeFileSync5(this.cursorPath, JSON.stringify(payload, null, 2));
3803
4168
  }
3804
4169
  /**
3805
4170
  * Pull workspace_file changes since the persisted cursor and upsert into
@@ -4208,6 +4573,262 @@ function extractHttpUrls(text) {
4208
4573
  import { readFileSync as readFileSync5, promises as fsp3 } from "fs";
4209
4574
  import * as path from "path";
4210
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
4211
4832
  var DEFAULT_CONTEXT_MAX = 8e3;
4212
4833
  var GOAL_CONTEXT_MAX = 4;
4213
4834
  var MEMORY_DIGEST_MAX_BYTES = 4e3;
@@ -4223,6 +4844,8 @@ async function handleDispatch(payload, requestId, deps) {
4223
4844
  const { taskId, agentImUserId } = payload;
4224
4845
  let resolvedHashes = [];
4225
4846
  let reply;
4847
+ let heartbeatRef;
4848
+ let recorderRef;
4226
4849
  const outboxBase = deps.paths?.runsDir ? path.join(deps.paths.runsDir, taskId) : null;
4227
4850
  const outboxDir = outboxBase ? path.join(outboxBase, "_outbox") : null;
4228
4851
  const workDir = outboxBase ? path.join(outboxBase, "workdir") : null;
@@ -4304,7 +4927,13 @@ async function handleDispatch(payload, requestId, deps) {
4304
4927
  resolvedHashes.push(...r.resolvedHashes);
4305
4928
  rewrittenContext = payload.context.map((e, i) => ({ ...e, content: r.texts[i] }));
4306
4929
  }
4307
- 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
+ });
4308
4937
  resolvedHashes.push(...assetResolution.pinnedHashes);
4309
4938
  const basePrompt = composePrompt(
4310
4939
  rewrittenPrompt.text,
@@ -4339,9 +4968,44 @@ async function handleDispatch(payload, requestId, deps) {
4339
4968
  }
4340
4969
  const operatingPrinciples = resolveOperatingPrinciples(profile.config);
4341
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);
4342
4984
  const taskInput = {
4343
4985
  taskId,
4344
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 } : {},
4345
5009
  metadata: {
4346
5010
  ...payload.metadata,
4347
5011
  conversationId: payload.conversationId,
@@ -4404,6 +5068,9 @@ async function handleDispatch(payload, requestId, deps) {
4404
5068
  }
4405
5069
  }
4406
5070
  const reaperAborted = deps.signal?.aborted && result.error?.code === "task_cancelled";
5071
+ const approvalRequested = Boolean(
5072
+ result.metadata?.approvalRequested
5073
+ );
4407
5074
  let collectedAssetIds = [];
4408
5075
  if (deps.outboxWatcher && outboxDir) {
4409
5076
  try {
@@ -4415,14 +5082,23 @@ async function handleDispatch(payload, requestId, deps) {
4415
5082
  collectedAssetIds = deps.outboxWatcher.flushPending(taskId);
4416
5083
  }
4417
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;
4418
5092
  reply = {
4419
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.
4420
5099
  ok: result.ok,
4421
5100
  output: result.output,
4422
- error: reaperAborted ? {
4423
- code: "daemon_task_timeout",
4424
- message: `Daemon-side reaper aborted after ${reaperLimitMs}ms inactivity (no progress events from adapter)`
4425
- } : result.error,
5101
+ error: finalError,
4426
5102
  ...collectedAssetIds.length > 0 ? { assetIds: collectedAssetIds } : {},
4427
5103
  metrics: result.metrics,
4428
5104
  ...assetResolution.observability.length > 0 || urlObservations.length > 0 ? { assetObservability: [...assetResolution.observability, ...urlObservations] } : {}
@@ -4444,6 +5120,18 @@ async function handleDispatch(payload, requestId, deps) {
4444
5120
  }
4445
5121
  };
4446
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
+ }
4447
5135
  for (const hash of new Set(resolvedHashes)) {
4448
5136
  try {
4449
5137
  deps.assetCache.unpin(hash);
@@ -4520,6 +5208,10 @@ function isTextLikeMime(mime) {
4520
5208
  if (m.endsWith("+json") || m.endsWith("+xml") || m.endsWith("+csv")) return true;
4521
5209
  return false;
4522
5210
  }
5211
+ function isImageMime(mime) {
5212
+ if (!mime) return false;
5213
+ return mime.toLowerCase().split(";")[0].trim().startsWith("image/");
5214
+ }
4523
5215
  var HASH_REF_RE = /(?:^|\s)#([^\s#]+)/g;
4524
5216
  var HEX_COLOR_RE = /^[0-9a-fA-F]{3,8}$/;
4525
5217
  var FILE_EXT_RE = /\.[a-zA-Z0-9]{1,10}$/;
@@ -4598,8 +5290,34 @@ async function resolveHashRefs(prompt, assetIndex, cloud) {
4598
5290
  }
4599
5291
  return { text: result, resolutions };
4600
5292
  }
4601
- async function resolveAssetRefs(refs, cache, taskId) {
4602
- 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
+ };
4603
5321
  if (!refs || refs.length === 0) return out;
4604
5322
  for (const ref of refs) {
4605
5323
  let cached;
@@ -4664,7 +5382,42 @@ async function resolveAssetRefs(refs, cache, taskId) {
4664
5382
  continue;
4665
5383
  }
4666
5384
  }
4667
- 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
+ }
4668
5421
  out.observability.push({
4669
5422
  assetId: ref.assetId,
4670
5423
  contentHash: ref.contentHash,
@@ -4675,6 +5428,110 @@ async function resolveAssetRefs(refs, cache, taskId) {
4675
5428
  }
4676
5429
  return out;
4677
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
+ }
4678
5535
  function logAssetResolveFailure(taskId, ref, error, mime = ref.mime) {
4679
5536
  const message = error.replace(/\s+/g, " ").slice(0, 500);
4680
5537
  process.stderr.write(
@@ -4689,8 +5546,19 @@ function formatInlineAssetBlock(ref, mime, body, strategy) {
4689
5546
  ${body}
4690
5547
  ---`;
4691
5548
  }
4692
- function formatUriOnlyAssetBlock(ref, mime, localPath) {
4693
- 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)}`;
4694
5562
  }
4695
5563
  function formatErrorAssetBlock(ref, mime, error) {
4696
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.`;
@@ -5175,6 +6043,7 @@ import { createServer } from "http";
5175
6043
  import { randomUUID, createHash as createHash4, createHmac, timingSafeEqual as cryptoTimingSafeEqual } from "crypto";
5176
6044
  import { promises as fs } from "fs";
5177
6045
  import * as path2 from "path";
6046
+ import { homedir as homedir4 } from "os";
5178
6047
  var LocalServer = class {
5179
6048
  constructor(opts) {
5180
6049
  this.opts = opts;
@@ -5286,6 +6155,11 @@ var LocalServer = class {
5286
6155
  void this.handleSnapshot(req, res);
5287
6156
  return;
5288
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
+ }
5289
6163
  if (req.method === "POST" && url === "/local/asset/write") {
5290
6164
  void this.handleAssetWrite(req, res);
5291
6165
  return;
@@ -5363,6 +6237,54 @@ var LocalServer = class {
5363
6237
  }
5364
6238
  void req;
5365
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
+ }
5366
6288
  /**
5367
6289
  * POST /local/asset/write — agent-gen adapter RPC.
5368
6290
  *
@@ -5634,6 +6556,39 @@ function validateAgentDispatchRequest(body) {
5634
6556
  if (payload.attachments != null && !Array.isArray(payload.attachments)) return "attachments must be an array";
5635
6557
  return null;
5636
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
+ }
5637
6592
  async function walkAndDigest(root, current) {
5638
6593
  const out = [];
5639
6594
  let entries;
@@ -5677,7 +6632,7 @@ import { EventEmitter as EventEmitter3 } from "events";
5677
6632
  import { createRequire as createRequire2 } from "module";
5678
6633
  import { existsSync as existsSync9, readFileSync as readFileSync7 } from "fs";
5679
6634
  import { mkdir } from "fs/promises";
5680
- import { homedir as homedir6, platform } from "os";
6635
+ import { homedir as homedir7, platform } from "os";
5681
6636
  import { join as join14 } from "path";
5682
6637
 
5683
6638
  // src/adapters/claude-code/index.ts
@@ -7775,7 +8730,7 @@ function parsePrismerUri(uri) {
7775
8730
  }
7776
8731
 
7777
8732
  // src/daemon/asset/metadata-index.ts
7778
- 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";
7779
8734
  import { join as join11 } from "path";
7780
8735
  var DEFAULT_LIMIT = 8;
7781
8736
  var PULL_PAGE_SIZE = 500;
@@ -7805,7 +8760,7 @@ var AssetMetadataIndex = class {
7805
8760
  this.cloud = opts.cloud;
7806
8761
  this.workspaceId = opts.workspaceId;
7807
8762
  if (!existsSync7(opts.workspaceStateDir)) {
7808
- mkdirSync7(opts.workspaceStateDir, { recursive: true });
8763
+ mkdirSync8(opts.workspaceStateDir, { recursive: true });
7809
8764
  }
7810
8765
  this.cursorPath = join11(opts.workspaceStateDir, "asset-metadata-cursor.json");
7811
8766
  }
@@ -7826,7 +8781,7 @@ var AssetMetadataIndex = class {
7826
8781
  cursor,
7827
8782
  writtenAt: Date.now()
7828
8783
  };
7829
- writeFileSync5(this.cursorPath, JSON.stringify(payload, null, 2));
8784
+ writeFileSync6(this.cursorPath, JSON.stringify(payload, null, 2));
7830
8785
  }
7831
8786
  /**
7832
8787
  * Pull incremental asset metadata changes since the persisted cursor and
@@ -8153,7 +9108,7 @@ async function readJson3(req) {
8153
9108
 
8154
9109
  // src/daemon/asset/origin/drop-folder.ts
8155
9110
  import { promises as fs3 } from "fs";
8156
- import { homedir as homedir5 } from "os";
9111
+ import { homedir as homedir6 } from "os";
8157
9112
  import * as path5 from "path";
8158
9113
  var DropFolderAdapter = class {
8159
9114
  kind = "drop-folder";
@@ -8161,7 +9116,7 @@ var DropFolderAdapter = class {
8161
9116
  rootDir;
8162
9117
  constructor(opts = {}) {
8163
9118
  this.workspaceDir = opts.workspaceDir;
8164
- this.rootDir = opts.rootDir ?? path5.join(opts.homeDir ?? homedir5(), ".prismer");
9119
+ this.rootDir = opts.rootDir ?? path5.join(opts.homeDir ?? homedir6(), ".prismer");
8165
9120
  }
8166
9121
  /**
8167
9122
  * Long-lived async iterable wrapping `scanOnce` on a 1s polling interval.
@@ -8171,7 +9126,7 @@ var DropFolderAdapter = class {
8171
9126
  while (true) {
8172
9127
  const batch = await this.scanOnce(workspaceId);
8173
9128
  for (const obs of batch) yield obs;
8174
- await sleep(1e3);
9129
+ await sleep2(1e3);
8175
9130
  }
8176
9131
  }
8177
9132
  /**
@@ -8320,7 +9275,7 @@ function mimeFromExtension(p) {
8320
9275
  const ext = path5.extname(p).toLowerCase();
8321
9276
  return MIME_BY_EXT[ext] ?? null;
8322
9277
  }
8323
- function sleep(ms) {
9278
+ function sleep2(ms) {
8324
9279
  return new Promise((r) => setTimeout(r, ms));
8325
9280
  }
8326
9281
 
@@ -9025,15 +9980,126 @@ function normalizeMime(mime) {
9025
9980
  const normalized = mime.toLowerCase().split(";")[0]?.trim();
9026
9981
  return normalized || null;
9027
9982
  }
9028
- function extensionOf(filename) {
9029
- const name = filename.toLowerCase().trim();
9030
- const idx = name.lastIndexOf(".");
9031
- 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;
9032
10097
  }
9033
10098
 
9034
10099
  // src/daemon/outbox-watcher.ts
9035
10100
  var DEFAULT_INTERVAL_MS = 2e3;
9036
10101
  var RESERVED_SUBDIR = "_uploaded";
10102
+ var REJECTED_SUBDIR = "_rejected";
9037
10103
  var OutboxWatcher = class {
9038
10104
  constructor(opts) {
9039
10105
  this.opts = opts;
@@ -9208,6 +10274,7 @@ var OutboxWatcher = class {
9208
10274
  for (const name of entries) {
9209
10275
  if (name.startsWith(".")) continue;
9210
10276
  if (name === RESERVED_SUBDIR) continue;
10277
+ if (name === REJECTED_SUBDIR) continue;
9211
10278
  const full = path7.join(dir, name);
9212
10279
  let st;
9213
10280
  try {
@@ -9222,10 +10289,77 @@ var OutboxWatcher = class {
9222
10289
  await this.upload(full, st, kind, task);
9223
10290
  this.uploaded.add(key);
9224
10291
  } catch (err) {
9225
- 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
+ }
9226
10303
  }
9227
10304
  }
9228
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
+ }
9229
10363
  async upload(filePath, st, kind, task) {
9230
10364
  const wsId = this.opts.workspaceId();
9231
10365
  if (!wsId) {
@@ -9243,6 +10377,11 @@ var OutboxWatcher = class {
9243
10377
  if (!policy.ok) {
9244
10378
  throw new Error(`agent output rejected: ${policy.reason}`);
9245
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
+ }
9246
10385
  const metadata = {
9247
10386
  taskId: task.taskId,
9248
10387
  ...task.adapter ? { adapter: task.adapter } : {}
@@ -9446,6 +10585,352 @@ ${input.stderr}` : null
9446
10585
 
9447
10586
  // src/daemon/runner.ts
9448
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
9449
10934
  var DEFAULT_LOCAL_PORT = 3210;
9450
10935
  var log7 = createLogger("Daemon");
9451
10936
  var assetMetaLog = createLogger("AssetMeta");
@@ -9493,6 +10978,14 @@ var Runner = class extends EventEmitter3 {
9493
10978
  // F16 (2026-05-20) — periodic skill resync timer (default 10min).
9494
10979
  skillResyncTimer;
9495
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;
9496
10989
  async start() {
9497
10990
  if (this.state !== "idle") throw new Error(`Runner already in state ${this.state}`);
9498
10991
  this.state = "starting";
@@ -9507,6 +11000,7 @@ var Runner = class extends EventEmitter3 {
9507
11000
  process.env.PRISMER_API_KEY = this.config.api_key;
9508
11001
  this.db = openLocalDb(this.paths.localDb);
9509
11002
  this.cloud = new CloudClient({ baseUrl: this.config.cloud_api_base, apiKey: this.config.api_key });
11003
+ this.pendingReplyCache = createPendingReplyCache(this.db);
9510
11004
  this.assetCache = new AssetCache({
9511
11005
  db: this.db,
9512
11006
  cloud: this.cloud,
@@ -10113,6 +11607,19 @@ var Runner = class extends EventEmitter3 {
10113
11607
  process.stdout.write(`[daemon] ws authenticated, sending agent.host.declare (${this.hostedAgents.size} agents)
10114
11608
  `);
10115
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
+ }
10116
11623
  return;
10117
11624
  }
10118
11625
  this.handleIncoming(msg);
@@ -10159,12 +11666,90 @@ var Runner = class extends EventEmitter3 {
10159
11666
  case "asset.changed":
10160
11667
  void this.onAssetChanged(msg.payload);
10161
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;
10162
11678
  default:
10163
11679
  this.emit("unknown-message", msg);
10164
11680
  }
10165
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
+ }
10166
11745
  async onHostAcked(payload) {
10167
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
+ });
10168
11753
  if (this.memoryWiring && this.workspaceId) {
10169
11754
  syncMemoryFromCloud(this.memoryWiring, this.cloud, [this.workspaceId]).catch(
10170
11755
  (err) => log7.error("Initial memory sync failed", err.message)
@@ -10346,14 +11931,68 @@ var Runner = class extends EventEmitter3 {
10346
11931
  };
10347
11932
  }
10348
11933
  async postMessageDispatchReply(payload) {
10349
- const res = await this.cloud.request("POST", "/api/im/dispatch/reply", {
10350
- auth: false,
10351
- body: payload,
10352
- timeoutMs: 3e4
10353
- });
10354
- if (!res.ok) {
10355
- 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;
10356
11991
  }
11992
+ process.stdout.write(
11993
+ `[daemon] transport-probe reported transport=${probe.transport} gateway=${probe.gatewayUrl ?? "null"} private=${probe.gatewayIsPrivate}
11994
+ `
11995
+ );
10357
11996
  }
10358
11997
  onAgentChanged(payload) {
10359
11998
  const a = this.hostedAgents.get(payload.agentImUserId);
@@ -10796,7 +12435,7 @@ var Runner = class extends EventEmitter3 {
10796
12435
  function resolveUserAssetWorkspaceDir(workspaceId) {
10797
12436
  const override = process.env.PRISMER_ASSET_SYNC_ROOT;
10798
12437
  if (override) return join14(override, workspaceId);
10799
- const home = homedir6();
12438
+ const home = homedir7();
10800
12439
  const desktop = join14(home, "Desktop");
10801
12440
  const base = existsSync9(desktop) ? desktop : home;
10802
12441
  return join14(base, "Prismer Assets", workspaceId);
@@ -10876,7 +12515,7 @@ function safeJsonParse(raw) {
10876
12515
  // src/pair.ts
10877
12516
  import { generateKeyPairSync } from "crypto";
10878
12517
  import { hostname as hostname2 } from "os";
10879
- import { setTimeout as sleep2 } from "timers/promises";
12518
+ import { setTimeout as sleep3 } from "timers/promises";
10880
12519
  import qrcode from "qrcode";
10881
12520
  async function pair(opts) {
10882
12521
  const paths = opts.paths ?? resolvePaths();
@@ -10945,7 +12584,7 @@ Scan with Lumin to approve, or open: ${offer.qrUrl}
10945
12584
  const pollIntervalMs = opts.pollIntervalMs ?? 5e3;
10946
12585
  for (let i = 0; i < maxAttempts; i += 1) {
10947
12586
  if (i > 0 || !localOnlyMode) {
10948
- await sleep2(pollIntervalMs);
12587
+ await sleep3(pollIntervalMs);
10949
12588
  }
10950
12589
  const res = await cloud.request(
10951
12590
  "GET",
@@ -10989,13 +12628,13 @@ import { spawnSync as spawnSync2 } from "child_process";
10989
12628
  import {
10990
12629
  copyFileSync,
10991
12630
  existsSync as existsSync12,
10992
- mkdirSync as mkdirSync8,
12631
+ mkdirSync as mkdirSync9,
10993
12632
  readFileSync as readFileSync10,
10994
12633
  statSync as statSync3,
10995
- writeFileSync as writeFileSync7
12634
+ writeFileSync as writeFileSync8
10996
12635
  } from "fs";
10997
- import { homedir as homedir7 } from "os";
10998
- 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";
10999
12638
  import { Command } from "commander";
11000
12639
  init_hermes();
11001
12640
  init_openclaw();
@@ -11010,7 +12649,7 @@ var INSTALL_SPECS = {
11010
12649
  binary: "claude",
11011
12650
  hint: "Set ANTHROPIC_API_KEY in your shell profile, or rely on Claude Code OAuth login. Run `claude login` to sign in.",
11012
12651
  authHints: ["ANTHROPIC_API_KEY or Claude Code OAuth login (`claude login`)"],
11013
- hookTarget: { path: join16(homedir7(), ".claude", "hooks.json"), kind: "json-file" }
12652
+ hookTarget: { path: join16(homedir8(), ".claude", "hooks.json"), kind: "json-file" }
11014
12653
  },
11015
12654
  openclaw: {
11016
12655
  name: "openclaw",
@@ -11019,7 +12658,7 @@ var INSTALL_SPECS = {
11019
12658
  binary: "openclaw",
11020
12659
  hint: "OpenClaw runs as a gateway. Configure ~/.openclaw/openclaw.json and start it with `openclaw gateway` before tasks dispatch.",
11021
12660
  authHints: ["~/.openclaw/openclaw.json gateway.auth.bearerTokens", "Prismer daemon api_key"],
11022
- hookTarget: { path: join16(homedir7(), ".openclaw", "hooks"), kind: "directory" }
12661
+ hookTarget: { path: join16(homedir8(), ".openclaw", "hooks"), kind: "directory" }
11023
12662
  },
11024
12663
  codex: {
11025
12664
  name: "codex",
@@ -11028,7 +12667,7 @@ var INSTALL_SPECS = {
11028
12667
  binary: "codex",
11029
12668
  hint: "Set OPENAI_API_KEY in your shell profile. Verify with `codex --help`.",
11030
12669
  authHints: ["OPENAI_API_KEY or Codex CLI account login"],
11031
- hookTarget: { path: join16(homedir7(), ".codex", "hooks.json"), kind: "json-file" }
12670
+ hookTarget: { path: join16(homedir8(), ".codex", "hooks.json"), kind: "json-file" }
11032
12671
  },
11033
12672
  hermes: {
11034
12673
  name: "hermes",
@@ -11037,7 +12676,7 @@ var INSTALL_SPECS = {
11037
12676
  binary: "hermes",
11038
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.",
11039
12678
  authHints: ["~/.hermes/.env API_SERVER_KEY", "Hermes profile provider credentials"],
11040
- hookTarget: { path: join16(homedir7(), ".hermes", "hooks.json"), kind: "json-file" }
12679
+ hookTarget: { path: join16(homedir8(), ".hermes", "hooks.json"), kind: "json-file" }
11041
12680
  }
11042
12681
  };
11043
12682
  function buildAdapterCommand() {
@@ -11252,15 +12891,15 @@ function runHooks(spec, opts) {
11252
12891
  }
11253
12892
  let backup;
11254
12893
  if (planned.kind === "directory") {
11255
- mkdirSync8(planned.path, { recursive: true });
12894
+ mkdirSync9(planned.path, { recursive: true });
11256
12895
  const markerPath = join16(planned.path, "prismer.json");
11257
12896
  backup = backupIfExists(markerPath);
11258
- writeFileSync7(markerPath, JSON.stringify(prismerHookMarker(spec), null, 2) + "\n", "utf8");
12897
+ writeFileSync8(markerPath, JSON.stringify(prismerHookMarker(spec), null, 2) + "\n", "utf8");
11259
12898
  } else {
11260
- mkdirSync8(dirname9(planned.path), { recursive: true });
12899
+ mkdirSync9(dirname10(planned.path), { recursive: true });
11261
12900
  backup = backupIfExists(planned.path);
11262
12901
  const merged = mergeHookJson(planned.path, spec);
11263
- writeFileSync7(planned.path, JSON.stringify(merged, null, 2) + "\n", "utf8");
12902
+ writeFileSync8(planned.path, JSON.stringify(merged, null, 2) + "\n", "utf8");
11264
12903
  }
11265
12904
  return {
11266
12905
  ok: true,
@@ -11327,7 +12966,7 @@ function inspectAuthEnv(spec) {
11327
12966
  hints: spec.authHints ?? []
11328
12967
  };
11329
12968
  case "hermes": {
11330
- const envFile = join16(homedir7(), ".hermes", ".env");
12969
+ const envFile = join16(homedir8(), ".hermes", ".env");
11331
12970
  return {
11332
12971
  ok: existsSync12(envFile) || Boolean(process.env.HERMES_API_KEY || process.env.API_SERVER_KEY),
11333
12972
  present: [
@@ -11338,7 +12977,7 @@ function inspectAuthEnv(spec) {
11338
12977
  };
11339
12978
  }
11340
12979
  case "openclaw": {
11341
- const cfgFile = join16(homedir7(), ".openclaw", "openclaw.json");
12980
+ const cfgFile = join16(homedir8(), ".openclaw", "openclaw.json");
11342
12981
  return {
11343
12982
  ok: existsSync12(cfgFile) || Boolean(process.env.OPENCLAW_API_KEY),
11344
12983
  present: [
@@ -11358,7 +12997,7 @@ function inspectHook(spec) {
11358
12997
  return { supported: false, markerPresent: false };
11359
12998
  }
11360
12999
  if (spec.name === "openclaw") {
11361
- const jsonPath = join16(homedir7(), ".openclaw", "hooks.json");
13000
+ const jsonPath = join16(homedir8(), ".openclaw", "hooks.json");
11362
13001
  const markerPath = join16(target.path, "prismer.json");
11363
13002
  const jsonMarkerPresent = existsSync12(jsonPath) && fileContainsPrismerMarker(jsonPath);
11364
13003
  const dirMarkerPresent = existsSync12(markerPath) && fileContainsPrismerMarker(markerPath);
@@ -11820,8 +13459,74 @@ function buildAgentCommand() {
11820
13459
  }
11821
13460
  getUI().ok("Skill install requested", `agent=${opts.agent} skill=${slugOrId}`);
11822
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" }));
11823
13510
  return cmd;
11824
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
+ }
11825
13530
  function readLocalAgents(localDb) {
11826
13531
  const db = openLocalDb(localDb);
11827
13532
  try {
@@ -11926,8 +13631,8 @@ function whichBinary2(bin) {
11926
13631
  // src/cli/commands/asset.ts
11927
13632
  import { createHash as createHash8 } from "crypto";
11928
13633
  import { Command as Command3 } from "commander";
11929
- import { existsSync as existsSync13, lstatSync, readdirSync, readFileSync as readFileSync11, writeFileSync as writeFileSync8 } from "fs";
11930
- 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";
11931
13636
  init_util();
11932
13637
  init_ui();
11933
13638
  function buildAssetCommand() {
@@ -12110,7 +13815,7 @@ async function uploadAssetPath(inputPath, opts) {
12110
13815
  if (files.length === 0) exitWithError(`directory has no uploadable files: ${inputPath}`);
12111
13816
  const items = [];
12112
13817
  for (const file of files) {
12113
- const relDir = dirname10(relative4(inputPath, file));
13818
+ const relDir = dirname11(relative4(inputPath, file));
12114
13819
  const folderPath = combineFolderPath(opts.folderPath, relDir === "." ? "" : relDir);
12115
13820
  const response = await uploadAsset(file, { ...opts, folderPath });
12116
13821
  items.push({ file, folderPath: folderPath ?? null, response });
@@ -12293,7 +13998,7 @@ async function downloadAsset(assetId, outPath) {
12293
13998
  exitWithError(`asset download failed (${res.status}): ${errorMessage(body)}`);
12294
13999
  }
12295
14000
  const bytes = Buffer.from(await res.arrayBuffer());
12296
- writeFileSync8(outPath, bytes);
14001
+ writeFileSync9(outPath, bytes);
12297
14002
  }
12298
14003
  function parseMetadata(raw) {
12299
14004
  if (!raw) return {};
@@ -12692,7 +14397,7 @@ function buildCookbookCommand() {
12692
14397
  for (const suite of suites) {
12693
14398
  checks.push(...await runSuite(suite, cloud, opts));
12694
14399
  }
12695
- const summary = summarize(checks, Boolean(opts.strict));
14400
+ const summary = summarize2(checks, Boolean(opts.strict));
12696
14401
  if (opts.json) {
12697
14402
  printJson(summary);
12698
14403
  } else {
@@ -12857,7 +14562,7 @@ function parseSuites(raw) {
12857
14562
  const expanded = selected.includes("all") ? ["status", "im", "task", "group", "asset", "sandbox"] : selected;
12858
14563
  return [...new Set(expanded)];
12859
14564
  }
12860
- function summarize(checks, strict) {
14565
+ function summarize2(checks, strict) {
12861
14566
  const totals = { pass: 0, fail: 0, skip: 0 };
12862
14567
  for (const check of checks) totals[check.status] += 1;
12863
14568
  return {
@@ -12940,8 +14645,8 @@ function parsePositiveInt3(value) {
12940
14645
  // src/cli/commands/daemon.ts
12941
14646
  import { Command as Command8 } from "commander";
12942
14647
  import { spawn as spawn5 } from "child_process";
12943
- import { createReadStream, existsSync as existsSync14, mkdirSync as mkdirSync9, openSync as openSync2, statSync as statSync4 } from "fs";
12944
- 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";
12945
14650
  import { join as join18 } from "path";
12946
14651
  init_util();
12947
14652
  init_ui();
@@ -12984,7 +14689,7 @@ function buildDaemonCommand() {
12984
14689
  while (!configExists(paths)) {
12985
14690
  if (stopRequested) return;
12986
14691
  getUI().info(`[daemon] waiting for ${paths.configFile} \u2014 run \`prismer setup\` to create it`);
12987
- await sleep3(5e3);
14692
+ await sleep4(5e3);
12988
14693
  }
12989
14694
  runner = new Runner({
12990
14695
  startLocalServer: opts.localServer !== false,
@@ -13016,7 +14721,7 @@ function buildDaemonCommand() {
13016
14721
  if (existingPid && pidAlive2(existingPid)) {
13017
14722
  exitWithError(`Daemon already running (pid ${existingPid}). Use \`prismer daemon stop\` first.`);
13018
14723
  }
13019
- if (!existsSync14(paths.logsDir)) mkdirSync9(paths.logsDir, { recursive: true });
14724
+ if (!existsSync14(paths.logsDir)) mkdirSync10(paths.logsDir, { recursive: true });
13020
14725
  const logFile = join18(paths.logsDir, "daemon.log");
13021
14726
  const fd = openSync2(logFile, "a");
13022
14727
  const args = [process.argv[1], "daemon", "run"];
@@ -13030,7 +14735,7 @@ function buildDaemonCommand() {
13030
14735
  child.unref();
13031
14736
  const deadline = Date.now() + 5e3;
13032
14737
  while (Date.now() < deadline) {
13033
- await sleep3(200);
14738
+ await sleep4(200);
13034
14739
  const pid = readPidFile(paths);
13035
14740
  if (pid && pidAlive2(pid)) {
13036
14741
  if (opts.json) printJson({ ok: true, pid, logFile });
@@ -13064,7 +14769,7 @@ function buildDaemonCommand() {
13064
14769
  getUI().ok("Daemon stopped", `pid ${pid}`);
13065
14770
  return;
13066
14771
  }
13067
- await sleep3(200);
14772
+ await sleep4(200);
13068
14773
  }
13069
14774
  process.stderr.write(`Daemon did not exit within timeout; pid ${pid} may still be alive.
13070
14775
  `);
@@ -13145,7 +14850,7 @@ async function tailFromEnd(path9, lines) {
13145
14850
  async function followFile(path9) {
13146
14851
  let offset = statSync4(path9).size;
13147
14852
  for (; ; ) {
13148
- await sleep3(1e3);
14853
+ await sleep4(1e3);
13149
14854
  const size = statSync4(path9).size;
13150
14855
  if (size < offset) offset = 0;
13151
14856
  if (size === offset) continue;
@@ -13159,7 +14864,7 @@ async function followFile(path9) {
13159
14864
  init_util();
13160
14865
  import { Command as Command9 } from "commander";
13161
14866
  import { createReadStream as createReadStream2, existsSync as existsSync15 } from "fs";
13162
- import { homedir as homedir8 } from "os";
14867
+ import { homedir as homedir9 } from "os";
13163
14868
  import { join as join19 } from "path";
13164
14869
  import { createInterface } from "readline";
13165
14870
  var DEFAULT_LIMIT2 = 50;
@@ -13192,7 +14897,7 @@ function buildEventsStatsCommand() {
13192
14897
  const events = await readEvents(file, filters);
13193
14898
  printJson({
13194
14899
  ok: true,
13195
- data: { stats: summarize2(events), count: events.length, limit: filters.limit },
14900
+ data: { stats: summarize3(events), count: events.length, limit: filters.limit },
13196
14901
  checks: { file, exists: true, filters: cleanFilters(filters) }
13197
14902
  });
13198
14903
  });
@@ -13201,7 +14906,7 @@ function addEventOptions(cmd) {
13201
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)");
13202
14907
  }
13203
14908
  function eventsPath() {
13204
- 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");
13205
14910
  }
13206
14911
  function unavailable(file) {
13207
14912
  return {
@@ -13249,7 +14954,7 @@ function field(event, names) {
13249
14954
  }
13250
14955
  return void 0;
13251
14956
  }
13252
- function summarize2(events) {
14957
+ function summarize3(events) {
13253
14958
  const byFamily = {};
13254
14959
  const byType = {};
13255
14960
  const byAgentId = {};
@@ -13603,7 +15308,7 @@ function buildPairCommand() {
13603
15308
 
13604
15309
  // src/cli/commands/profile.ts
13605
15310
  import { Command as Command12 } from "commander";
13606
- 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";
13607
15312
  import { tmpdir } from "os";
13608
15313
  import { join as join20 } from "path";
13609
15314
  import { spawnSync as spawnSync4 } from "child_process";
@@ -13654,7 +15359,7 @@ function buildProfileCommand() {
13654
15359
  `/api/im/agent_profiles/${encodeURIComponent(profileId)}`
13655
15360
  );
13656
15361
  const tmpFile = join20(tmpdir(), `prismer-profile-${profileId}.json`);
13657
- writeFileSync9(tmpFile, JSON.stringify(profile.config, null, 2), "utf8");
15362
+ writeFileSync10(tmpFile, JSON.stringify(profile.config, null, 2), "utf8");
13658
15363
  const editor = process.env.EDITOR || "vi";
13659
15364
  const ed = spawnSync4(editor, [tmpFile], { stdio: "inherit" });
13660
15365
  if (ed.status !== 0) exitWithError(`editor exited ${ed.status}`, { code: "editor_failed" });
@@ -13695,10 +15400,10 @@ async function resolveDefaultWorkspaceId(cloud) {
13695
15400
  // src/cli/commands/reset.ts
13696
15401
  import { Command as Command13 } from "commander";
13697
15402
  import { execFileSync as execFileSync2 } from "child_process";
13698
- import { existsSync as existsSync18, mkdirSync as mkdirSync10, rmSync, renameSync as renameSync2 } from "fs";
13699
- import { homedir as homedir9 } from "os";
13700
- import { dirname as dirname11, join as join21 } from "path";
13701
- 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";
13702
15407
  init_util();
13703
15408
  init_ui();
13704
15409
  function buildResetCommand() {
@@ -13751,8 +15456,8 @@ function buildResetCommand() {
13751
15456
  rmSync(paths.root, { recursive: true, force: true });
13752
15457
  } else {
13753
15458
  if (!archivePath) throw new Error("internal: archivePath missing");
13754
- if (!existsSync18(dirname11(archivePath))) {
13755
- mkdirSync10(dirname11(archivePath), { recursive: true });
15459
+ if (!existsSync18(dirname12(archivePath))) {
15460
+ mkdirSync11(dirname12(archivePath), { recursive: true });
13756
15461
  }
13757
15462
  renameSync2(paths.root, archivePath);
13758
15463
  }
@@ -13762,8 +15467,8 @@ function buildResetCommand() {
13762
15467
  rmSync(assetSyncRoot, { recursive: true, force: true });
13763
15468
  } else {
13764
15469
  if (!assetSyncArchivePath) throw new Error("internal: assetSyncArchivePath missing");
13765
- if (!existsSync18(dirname11(assetSyncArchivePath))) {
13766
- mkdirSync10(dirname11(assetSyncArchivePath), { recursive: true });
15470
+ if (!existsSync18(dirname12(assetSyncArchivePath))) {
15471
+ mkdirSync11(dirname12(assetSyncArchivePath), { recursive: true });
13767
15472
  }
13768
15473
  renameSync2(assetSyncRoot, assetSyncArchivePath);
13769
15474
  }
@@ -13773,13 +15478,13 @@ function buildResetCommand() {
13773
15478
  rmSync(hermesHome, { recursive: true, force: true });
13774
15479
  } else {
13775
15480
  if (!hermesArchivePath) throw new Error("internal: hermesArchivePath missing");
13776
- if (!existsSync18(dirname11(hermesArchivePath))) {
13777
- mkdirSync10(dirname11(hermesArchivePath), { recursive: true });
15481
+ if (!existsSync18(dirname12(hermesArchivePath))) {
15482
+ mkdirSync11(dirname12(hermesArchivePath), { recursive: true });
13778
15483
  }
13779
15484
  renameSync2(hermesHome, hermesArchivePath);
13780
15485
  }
13781
15486
  }
13782
- mkdirSync10(paths.root, { recursive: true });
15487
+ mkdirSync11(paths.root, { recursive: true });
13783
15488
  if (opts.json) {
13784
15489
  printJson({ ok: true, reset: true, plan });
13785
15490
  return;
@@ -13828,7 +15533,7 @@ async function stopProcess(pid, timeoutMs, label) {
13828
15533
  const deadline = Date.now() + timeoutMs;
13829
15534
  while (Date.now() < deadline) {
13830
15535
  if (!pidAlive2(pid)) return;
13831
- await sleep4(100);
15536
+ await sleep5(100);
13832
15537
  }
13833
15538
  try {
13834
15539
  process.kill(pid, "SIGKILL");
@@ -13838,7 +15543,7 @@ async function stopProcess(pid, timeoutMs, label) {
13838
15543
  const killDeadline = Date.now() + 1e3;
13839
15544
  while (Date.now() < killDeadline) {
13840
15545
  if (!pidAlive2(pid)) return;
13841
- await sleep4(100);
15546
+ await sleep5(100);
13842
15547
  }
13843
15548
  throw new Error(`${label} pid ${pid} did not exit within ${timeoutMs + 1e3}ms`);
13844
15549
  }
@@ -13848,13 +15553,13 @@ function timestamp() {
13848
15553
  function resolveAssetSyncRoot() {
13849
15554
  const override = process.env.PRISMER_ASSET_SYNC_ROOT;
13850
15555
  if (override) return override;
13851
- const home = homedir9();
15556
+ const home = homedir10();
13852
15557
  const desktop = join21(home, "Desktop");
13853
15558
  const base = existsSync18(desktop) ? desktop : home;
13854
15559
  return join21(base, "Prismer Assets");
13855
15560
  }
13856
15561
  function resolveHermesHome() {
13857
- return process.env.HERMES_HOME || join21(homedir9(), ".hermes");
15562
+ return process.env.HERMES_HOME || join21(homedir10(), ".hermes");
13858
15563
  }
13859
15564
  function findHermesGatewayPids2() {
13860
15565
  try {
@@ -14409,7 +16114,7 @@ function buildSkillCommand() {
14409
16114
  const cloud = mkCloud6();
14410
16115
  const path9 = opts.agent ? `/api/im/agents/${encodeURIComponent(opts.agent)}/skills` : `/api/im/skills/${encodeURIComponent(slugOrId)}/install`;
14411
16116
  const body = opts.agent ? { skillId: slugOrId, workspaceId: opts.workspaceId, version: opts.version } : { workspaceId: opts.workspaceId, version: opts.version };
14412
- const data = await requestEnvelope(cloud, "POST", path9, body, "skill_install_failed");
16117
+ const data = await requestEnvelope2(cloud, "POST", path9, body, "skill_install_failed");
14413
16118
  if (opts.json) {
14414
16119
  printJson(data);
14415
16120
  return;
@@ -14420,7 +16125,7 @@ function buildSkillCommand() {
14420
16125
  const cloud = mkCloud6();
14421
16126
  const path9 = opts.agent ? `/api/im/agents/${encodeURIComponent(opts.agent)}/skills` : `/api/im/skills/${encodeURIComponent(slugOrId)}/install`;
14422
16127
  const body = opts.agent ? { skillId: slugOrId, workspaceId: opts.workspaceId } : void 0;
14423
- const data = await requestEnvelope(cloud, "DELETE", path9, body, "skill_uninstall_failed");
16128
+ const data = await requestEnvelope2(cloud, "DELETE", path9, body, "skill_uninstall_failed");
14424
16129
  if (opts.json) {
14425
16130
  printJson(data);
14426
16131
  return;
@@ -14473,7 +16178,7 @@ function mkCloud6() {
14473
16178
  const cfg = loadConfig(resolvePaths());
14474
16179
  return new CloudClient({ baseUrl: cfg.cloud_api_base, apiKey: cfg.api_key });
14475
16180
  }
14476
- async function requestEnvelope(cloud, method, path9, body, code) {
16181
+ async function requestEnvelope2(cloud, method, path9, body, code) {
14477
16182
  const res = await cloud.request(method, path9, {
14478
16183
  body
14479
16184
  });
@@ -14623,6 +16328,7 @@ function buildStatusCommand() {
14623
16328
  let me = null;
14624
16329
  let devices = null;
14625
16330
  let agents = null;
16331
+ let diagnose = null;
14626
16332
  try {
14627
16333
  const meRes = await cloud.request("GET", "/api/im/me", { timeoutMs: 3e3 });
14628
16334
  cloudOk = meRes.ok;
@@ -14635,19 +16341,33 @@ function buildStatusCommand() {
14635
16341
  if (Array.isArray(wsList) && wsList.length > 0) {
14636
16342
  const wsId = wsList[0]?.id;
14637
16343
  if (wsId) {
14638
- const devRes = await cloud.request("GET", `/api/workspace/runtime-installations?workspaceId=${encodeURIComponent(wsId)}&includeStopped=false`, { timeoutMs: 3e3 });
14639
- if (devRes.ok) {
14640
- const devBody = devRes.data;
14641
- devices = devBody?.data;
14642
- }
14643
- const agRes = await cloud.request("GET", `/api/im/workspaces/${encodeURIComponent(wsId)}/agents`, { timeoutMs: 3e3 });
14644
- if (agRes.ok) {
14645
- const agBody = agRes.data;
14646
- 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
+ }
14647
16352
  }
14648
16353
  }
14649
16354
  }
14650
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
+ }
14651
16371
  }
14652
16372
  } catch {
14653
16373
  cloudOk = false;
@@ -14664,7 +16384,8 @@ function buildStatusCommand() {
14664
16384
  info: daemonStatus.info ?? {}
14665
16385
  },
14666
16386
  cloud: { base: cfg.cloud_api_base, reachable: cloudOk, me, devices, agents },
14667
- local
16387
+ local,
16388
+ diagnose
14668
16389
  };
14669
16390
  if (opts.json) {
14670
16391
  printJson(report);
@@ -14673,6 +16394,60 @@ function buildStatusCommand() {
14673
16394
  printPretty(report);
14674
16395
  });
14675
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
+ }
14676
16451
  async function readDaemonStatus() {
14677
16452
  try {
14678
16453
  const res = await fetch("http://127.0.0.1:3210/healthz", {
@@ -14745,16 +16520,7 @@ function printPretty(report) {
14745
16520
  if (Array.isArray(report.cloud.devices) && report.cloud.devices.length > 0) {
14746
16521
  const devs = report.cloud.devices;
14747
16522
  ui.blank();
14748
- ui.line(` Workspace Devices (${devs.length}):`);
14749
- for (const d of devs) {
14750
- const statusIcon = d.daemonStatus === "connected" ? "\u25CF" : "\u25CB";
14751
- const kind = d.runtimeKind === "docker" ? "Local" : "K8s";
14752
- const declared = d.hostedAgentSummary?.declared ?? 0;
14753
- ui.line(` ${statusIcon} ${d.podName?.replace(/^daemon:/, "")?.slice(0, 28) ?? "?"} kind=${kind} agents=${declared}`);
14754
- }
14755
- }
14756
- if (Array.isArray(report.cloud.agents) && report.cloud.agents.length > 0) {
14757
- ui.line(` Hosted agents: ${report.cloud.agents.length}`);
16523
+ for (const ln of formatDeviceBindings(devs)) ui.line(ln);
14758
16524
  }
14759
16525
  if (report.local) {
14760
16526
  ui.blank();
@@ -14764,11 +16530,63 @@ function printPretty(report) {
14764
16530
  } else {
14765
16531
  warn("Local DB", "unavailable");
14766
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`;
14767
16585
  }
14768
16586
 
14769
16587
  // src/cli/commands/task.ts
14770
16588
  import { Command as Command18 } from "commander";
14771
- import { setTimeout as sleep5 } from "timers/promises";
16589
+ import { setTimeout as sleep6 } from "timers/promises";
14772
16590
  init_util();
14773
16591
  function describeStatus2(status) {
14774
16592
  return status === 0 ? "network error" : `HTTP ${status}`;
@@ -14851,7 +16669,7 @@ function unwrap2(raw) {
14851
16669
  async function pollUntilTerminal(cloud, taskId, timeoutMs) {
14852
16670
  const deadline = Date.now() + (timeoutMs ?? 5 * 6e4);
14853
16671
  while (Date.now() < deadline) {
14854
- await sleep5(1e3);
16672
+ await sleep6(1e3);
14855
16673
  const res = await cloud.request(
14856
16674
  "GET",
14857
16675
  `/api/im/tasks/${encodeURIComponent(taskId)}`
@@ -15126,7 +16944,7 @@ async function readResponseError(res) {
15126
16944
 
15127
16945
  // src/cli/index.ts
15128
16946
  init_ui();
15129
- var VERSION = "2.0.4";
16947
+ var VERSION = "2.0.5";
15130
16948
  function buildProgram() {
15131
16949
  const program = new Command20("prismer").description("Prismer Cloud daemon CLI (TS-only).").version(VERSION);
15132
16950
  program.addCommand(buildBannerCommand());