@prismer/runtime 2.0.4 → 2.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.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.6",
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
 
@@ -9031,9 +9986,120 @@ function extensionOf(filename) {
9031
9986
  return idx >= 0 ? name.slice(idx) : "";
9032
9987
  }
9033
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;
10097
+ }
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");
@@ -9486,6 +10971,7 @@ var Runner = class extends EventEmitter3 {
9486
10971
  workspaceId = "";
9487
10972
  wsConnected = false;
9488
10973
  hostedAgents = /* @__PURE__ */ new Map();
10974
+ rejectedHostedAgentIds = /* @__PURE__ */ new Map();
9489
10975
  runningTasks = /* @__PURE__ */ new Map();
9490
10976
  lastTaskError;
9491
10977
  heartbeatTimer;
@@ -9493,6 +10979,14 @@ var Runner = class extends EventEmitter3 {
9493
10979
  // F16 (2026-05-20) — periodic skill resync timer (default 10min).
9494
10980
  skillResyncTimer;
9495
10981
  skillSyncInFlight = false;
10982
+ // Wave-4 E7 — local cache of in-flight two-phase dispatch replies. Survives
10983
+ // daemon crash so cold-start can resume `prepared` rows via idempotent
10984
+ // commit (server enforces (taskId, idempotencyKey) UNIQUE).
10985
+ pendingReplyCache;
10986
+ // One-shot guard so we only recover on the first post-authenticated tick,
10987
+ // not every reconnect (server-side commits are idempotent but the log noise
10988
+ // and DB pressure of re-running on every redeclare is wasteful).
10989
+ pendingReplyRecoveryDone = false;
9496
10990
  async start() {
9497
10991
  if (this.state !== "idle") throw new Error(`Runner already in state ${this.state}`);
9498
10992
  this.state = "starting";
@@ -9507,6 +11001,7 @@ var Runner = class extends EventEmitter3 {
9507
11001
  process.env.PRISMER_API_KEY = this.config.api_key;
9508
11002
  this.db = openLocalDb(this.paths.localDb);
9509
11003
  this.cloud = new CloudClient({ baseUrl: this.config.cloud_api_base, apiKey: this.config.api_key });
11004
+ this.pendingReplyCache = createPendingReplyCache(this.db);
9510
11005
  this.assetCache = new AssetCache({
9511
11006
  db: this.db,
9512
11007
  cloud: this.cloud,
@@ -9922,6 +11417,9 @@ var Runner = class extends EventEmitter3 {
9922
11417
  profilesByAgent.set(p.agent_im_user_id, list);
9923
11418
  }
9924
11419
  for (const a of agents) {
11420
+ if (this.rejectedHostedAgentIds.has(a.im_user_id)) {
11421
+ continue;
11422
+ }
9925
11423
  let caps = [];
9926
11424
  try {
9927
11425
  caps = JSON.parse(a.capabilities);
@@ -9971,6 +11469,7 @@ var Runner = class extends EventEmitter3 {
9971
11469
  * Used to populate the `agents` list in agent.host.declare.
9972
11470
  */
9973
11471
  setHostedAgent(agent) {
11472
+ this.rejectedHostedAgentIds.delete(agent.imUserId);
9974
11473
  this.hostedAgents.set(agent.imUserId, {
9975
11474
  imUserId: agent.imUserId,
9976
11475
  name: agent.name,
@@ -10003,6 +11502,7 @@ var Runner = class extends EventEmitter3 {
10003
11502
  );
10004
11503
  });
10005
11504
  tx(payload);
11505
+ this.rejectedHostedAgentIds.delete(payload.imUserId);
10006
11506
  this.loadAgentsFromDb();
10007
11507
  if (this.wsConnected) this.sendDeclare();
10008
11508
  return {
@@ -10113,6 +11613,19 @@ var Runner = class extends EventEmitter3 {
10113
11613
  process.stdout.write(`[daemon] ws authenticated, sending agent.host.declare (${this.hostedAgents.size} agents)
10114
11614
  `);
10115
11615
  this.sendDeclare();
11616
+ if (!this.pendingReplyRecoveryDone) {
11617
+ this.pendingReplyRecoveryDone = true;
11618
+ void recoverPendingReplies({ cloud: this.cloud, cache: this.pendingReplyCache }).then((summary) => {
11619
+ if (summary.attempted === 0) return;
11620
+ process.stdout.write(
11621
+ `[daemon] dispatch.recovery summary attempted=${summary.attempted} committed=${summary.committed} aborted=${summary.aborted} failed=${summary.failed}
11622
+ `
11623
+ );
11624
+ }).catch((err) => {
11625
+ process.stderr.write(`[daemon] dispatch.recovery threw: ${err.message}
11626
+ `);
11627
+ });
11628
+ }
10116
11629
  return;
10117
11630
  }
10118
11631
  this.handleIncoming(msg);
@@ -10159,12 +11672,91 @@ var Runner = class extends EventEmitter3 {
10159
11672
  case "asset.changed":
10160
11673
  void this.onAssetChanged(msg.payload);
10161
11674
  return;
11675
+ // v2.0 §4.8.1 (Wave 4-E4) — webhook reverse-channel RPC. Cloud's
11676
+ // dispatchExternalMention pushes an AgentDispatchRequest plus an
11677
+ // embedded `_rpcId`; the daemon runs the same handler as the
11678
+ // HTTP /dispatch path and echoes the reply back over WS with the
11679
+ // identical `_rpcId` so cloud's WsRpcService can settle the
11680
+ // pending promise.
11681
+ case "webhook.dispatch.request":
11682
+ void this.onWebhookDispatch(msg.payload);
11683
+ return;
10162
11684
  default:
10163
11685
  this.emit("unknown-message", msg);
10164
11686
  }
10165
11687
  }
11688
+ /**
11689
+ * v2.0 §4.8.1 — webhook dispatch over WS reverse channel.
11690
+ *
11691
+ * Acks via `webhook.dispatch.reply { _rpcId, ok, acceptedAt }` immediately
11692
+ * after `handleAgentMessageDispatch()` returns its synchronous response
11693
+ * — the actual reply (the agent's reply text/attachments) still flows
11694
+ * through `postMessageDispatchReply()` which posts to
11695
+ * `/api/im/dispatch/reply` after the adapter finishes. That mirrors the
11696
+ * HTTP /dispatch contract: ack first, asynchronous final reply via the
11697
+ * dispatch-reply REST endpoint.
11698
+ *
11699
+ * The `_rpcId` field is the only addition versus the HTTP path; we strip
11700
+ * it before passing the payload to the dispatch handler so existing
11701
+ * validation logic doesn't trip on an unknown field.
11702
+ */
11703
+ async onWebhookDispatch(payload) {
11704
+ const rpcId = payload._rpcId;
11705
+ if (!rpcId) {
11706
+ process.stderr.write(`[daemon] webhook.dispatch.request without _rpcId \u2014 dropped
11707
+ `);
11708
+ return;
11709
+ }
11710
+ const { _rpcId: _, ...request } = payload;
11711
+ try {
11712
+ const handle = handleAgentMessageDispatch(request, {
11713
+ findAgent: (agentImUserId) => this.findMessageDispatchAgent(agentImUserId),
11714
+ postReply: (replyPayload) => this.postMessageDispatchReply(replyPayload),
11715
+ onError: (err, req) => {
11716
+ process.stderr.write(
11717
+ `[daemon] webhook.dispatch.request failed message=${req.messageId} agent=${req.mentionedAgentImUserId}: ${err instanceof Error ? err.stack ?? err.message : String(err)}
11718
+ `
11719
+ );
11720
+ }
11721
+ });
11722
+ this.ws.send({
11723
+ type: "webhook.dispatch.reply",
11724
+ payload: {
11725
+ _rpcId: rpcId,
11726
+ ok: handle.response.ok,
11727
+ acceptedAt: handle.response.acceptedAt,
11728
+ error: handle.response.error
11729
+ }
11730
+ });
11731
+ void handle.done.catch((err) => {
11732
+ process.stderr.write(
11733
+ `[daemon] webhook.dispatch final reply failed message=${request.messageId}: ${err instanceof Error ? err.message : String(err)}
11734
+ `
11735
+ );
11736
+ });
11737
+ } catch (err) {
11738
+ this.ws.send({
11739
+ type: "webhook.dispatch.reply",
11740
+ payload: {
11741
+ _rpcId: rpcId,
11742
+ ok: false,
11743
+ error: {
11744
+ code: "daemon_dispatch_failed",
11745
+ message: err instanceof Error ? err.message : String(err)
11746
+ }
11747
+ }
11748
+ });
11749
+ }
11750
+ }
10166
11751
  async onHostAcked(payload) {
10167
11752
  this.workspaceId = payload.workspaceId;
11753
+ const ownershipLockChanged = await this.applyHostAckOwnershipLock(payload);
11754
+ void this.reportTransport().catch((err) => {
11755
+ process.stderr.write(
11756
+ `[daemon] transport-probe report failed: ${err.message}
11757
+ `
11758
+ );
11759
+ });
10168
11760
  if (this.memoryWiring && this.workspaceId) {
10169
11761
  syncMemoryFromCloud(this.memoryWiring, this.cloud, [this.workspaceId]).catch(
10170
11762
  (err) => log7.error("Initial memory sync failed", err.message)
@@ -10174,7 +11766,9 @@ var Runner = class extends EventEmitter3 {
10174
11766
  (err) => log7.error("Asset sync failed", err.message)
10175
11767
  );
10176
11768
  await this.ensureDropFolderRuntime(this.workspaceId);
10177
- for (const id of payload.profilesToSync) {
11769
+ const profilesToSync = payload.profilesToSync ?? [];
11770
+ const profilesToDelete = payload.profilesToDelete ?? [];
11771
+ for (const id of profilesToSync) {
10178
11772
  try {
10179
11773
  await this.servicePool.drop(id);
10180
11774
  await this.syncProfileFromCloud(id);
@@ -10182,7 +11776,7 @@ var Runner = class extends EventEmitter3 {
10182
11776
  this.emit("sync-error", err);
10183
11777
  }
10184
11778
  }
10185
- for (const id of payload.profilesToDelete ?? []) {
11779
+ for (const id of profilesToDelete) {
10186
11780
  const row = this.db.prepare("SELECT agent_im_user_id FROM agent_profiles WHERE id = ?").get(id);
10187
11781
  this.db.prepare("DELETE FROM agent_profiles WHERE id = ?").run(id);
10188
11782
  if (row) {
@@ -10191,10 +11785,37 @@ var Runner = class extends EventEmitter3 {
10191
11785
  process.stderr.write(`[daemon] tombstone profile=${id}
10192
11786
  `);
10193
11787
  }
10194
- if (payload.profilesToDelete?.length) this.loadAgentsFromDb();
10195
- if ((payload.profilesToSync.length > 0 || (payload.profilesToDelete?.length ?? 0) > 0) && this.wsConnected) this.sendDeclare();
11788
+ if (profilesToDelete.length) this.loadAgentsFromDb();
11789
+ if ((ownershipLockChanged || profilesToSync.length > 0 || profilesToDelete.length > 0) && this.wsConnected) {
11790
+ this.sendDeclare();
11791
+ }
10196
11792
  this.emit("host-acked", payload);
10197
11793
  }
11794
+ async applyHostAckOwnershipLock(payload) {
11795
+ const rejected = payload.rejectedAgents ?? [];
11796
+ if (rejected.length === 0) return false;
11797
+ let changed = false;
11798
+ for (const rejection of rejected) {
11799
+ if (!rejection?.imUserId) continue;
11800
+ const agentImUserId = rejection.imUserId;
11801
+ this.rejectedHostedAgentIds.set(agentImUserId, {
11802
+ ...rejection,
11803
+ rejectedAt: Date.now()
11804
+ });
11805
+ const wasHosted = this.hostedAgents.delete(agentImUserId);
11806
+ const profiles = this.db.prepare("SELECT id FROM agent_profiles WHERE agent_im_user_id = ?").all(agentImUserId);
11807
+ const deleted = this.db.prepare("DELETE FROM agents WHERE im_user_id = ?").run(agentImUserId);
11808
+ await Promise.all(profiles.map((profile) => this.servicePool.drop(profile.id)));
11809
+ if (wasHosted || deleted.changes > 0) {
11810
+ changed = true;
11811
+ process.stderr.write(
11812
+ `[daemon] ownership rejected agent=${agentImUserId} reason=${rejection.reason} owner=${rejection.ownerDaemonId ?? "(unknown)"} \u2014 stop declaring locally
11813
+ `
11814
+ );
11815
+ }
11816
+ }
11817
+ return changed;
11818
+ }
10198
11819
  async onTaskDispatch(payload, requestId) {
10199
11820
  const targetDaemonId = readTargetDaemonId(payload);
10200
11821
  if (targetDaemonId && targetDaemonId !== this.config.daemon_id) {
@@ -10346,14 +11967,68 @@ var Runner = class extends EventEmitter3 {
10346
11967
  };
10347
11968
  }
10348
11969
  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})`);
11970
+ const taskId = `external:${payload.replyToMessageId}`;
11971
+ try {
11972
+ const result = await sendDispatchReplyTwoPhase({
11973
+ taskId,
11974
+ payload: {
11975
+ conversationId: payload.conversationId,
11976
+ replyToken: payload.replyToken,
11977
+ replyToMessageId: payload.replyToMessageId,
11978
+ agentImUserId: payload.agentImUserId,
11979
+ status: payload.status,
11980
+ ...payload.replyText !== void 0 ? { replyText: payload.replyText } : {},
11981
+ ...payload.attachments ? { attachments: payload.attachments } : {},
11982
+ assetIds: payload.attachments?.map((a) => a.assetId).filter((id) => typeof id === "string") ?? [],
11983
+ completedAt: payload.completedAt,
11984
+ ...payload.error ? { error: payload.error } : {}
11985
+ },
11986
+ cloud: this.cloud,
11987
+ cache: this.pendingReplyCache
11988
+ });
11989
+ if (result.status === "aborted") {
11990
+ throw new Error(`dispatch reply aborted by server reaper (replyId=${result.replyId})`);
11991
+ }
11992
+ } catch (err) {
11993
+ throw new Error(
11994
+ err.message?.length ? err.message : `dispatch reply failed`
11995
+ );
11996
+ }
11997
+ }
11998
+ /**
11999
+ * v2.0 §4.8.1 (Wave 4-E4) — submit the transport-probe report. Called
12000
+ * from `onHostAcked` so the daemon's container row exists in cloud
12001
+ * before we POST to /runtime/transport-report.
12002
+ *
12003
+ * The gatewayUrl reported here is `http://<local-ipv4>:<localPort>`
12004
+ * (typically `http://192.168.x.x:3210`). For a daemon without a local
12005
+ * HTTP server (startLocalServer=false in tests) we still post the
12006
+ * probe with `gatewayUrl=null` so cloud knows transport='ws' is the
12007
+ * only path.
12008
+ */
12009
+ async reportTransport() {
12010
+ const hasLocalServer = this.opts.startLocalServer !== false;
12011
+ let gatewayUrl = null;
12012
+ if (hasLocalServer) {
12013
+ const localIp = pickLocalIPv4();
12014
+ if (localIp) {
12015
+ const port = this.opts.localPort ?? DEFAULT_LOCAL_PORT;
12016
+ gatewayUrl = `http://${localIp}:${port}`;
12017
+ }
12018
+ }
12019
+ const probe = buildTransportProbe(gatewayUrl);
12020
+ const result = await reportTransportProbe(this.cloud, this.config.daemon_id, probe);
12021
+ if (!result.ok) {
12022
+ process.stderr.write(
12023
+ `[daemon] transport-probe report rejected status=${result.status} error=${result.error ?? "-"}
12024
+ `
12025
+ );
12026
+ return;
10356
12027
  }
12028
+ process.stdout.write(
12029
+ `[daemon] transport-probe reported transport=${probe.transport} gateway=${probe.gatewayUrl ?? "null"} private=${probe.gatewayIsPrivate}
12030
+ `
12031
+ );
10357
12032
  }
10358
12033
  onAgentChanged(payload) {
10359
12034
  const a = this.hostedAgents.get(payload.agentImUserId);
@@ -10423,18 +12098,22 @@ var Runner = class extends EventEmitter3 {
10423
12098
  const name = agent?.card?.name || agent?.displayName || agent?.username || profile.agentImUserId;
10424
12099
  const now = Date.now();
10425
12100
  const tx = this.db.transaction(() => {
10426
- this.db.prepare(
10427
- `INSERT OR REPLACE INTO agents
10428
- (im_user_id, workspace_id, name, adapter_name, capabilities, status, version, synced_at, dirty)
10429
- VALUES (?, ?, ?, ?, ?, 'offline', 1, ?, 0)`
10430
- ).run(
10431
- profile.agentImUserId,
10432
- profile.workspaceId,
10433
- name,
10434
- profile.adapterName,
10435
- JSON.stringify(capabilities),
10436
- now
10437
- );
12101
+ if (this.rejectedHostedAgentIds.has(profile.agentImUserId)) {
12102
+ this.db.prepare("DELETE FROM agents WHERE im_user_id = ?").run(profile.agentImUserId);
12103
+ } else {
12104
+ this.db.prepare(
12105
+ `INSERT OR REPLACE INTO agents
12106
+ (im_user_id, workspace_id, name, adapter_name, capabilities, status, version, synced_at, dirty)
12107
+ VALUES (?, ?, ?, ?, ?, 'offline', 1, ?, 0)`
12108
+ ).run(
12109
+ profile.agentImUserId,
12110
+ profile.workspaceId,
12111
+ name,
12112
+ profile.adapterName,
12113
+ JSON.stringify(capabilities),
12114
+ now
12115
+ );
12116
+ }
10438
12117
  this.db.prepare(
10439
12118
  `INSERT OR REPLACE INTO agent_profiles
10440
12119
  (id, workspace_id, agent_im_user_id, adapter_name, name, config, version, synced_at, dirty, deleted_at)
@@ -10796,7 +12475,7 @@ var Runner = class extends EventEmitter3 {
10796
12475
  function resolveUserAssetWorkspaceDir(workspaceId) {
10797
12476
  const override = process.env.PRISMER_ASSET_SYNC_ROOT;
10798
12477
  if (override) return join14(override, workspaceId);
10799
- const home = homedir6();
12478
+ const home = homedir7();
10800
12479
  const desktop = join14(home, "Desktop");
10801
12480
  const base = existsSync9(desktop) ? desktop : home;
10802
12481
  return join14(base, "Prismer Assets", workspaceId);
@@ -10876,7 +12555,7 @@ function safeJsonParse(raw) {
10876
12555
  // src/pair.ts
10877
12556
  import { generateKeyPairSync } from "crypto";
10878
12557
  import { hostname as hostname2 } from "os";
10879
- import { setTimeout as sleep2 } from "timers/promises";
12558
+ import { setTimeout as sleep3 } from "timers/promises";
10880
12559
  import qrcode from "qrcode";
10881
12560
  async function pair(opts) {
10882
12561
  const paths = opts.paths ?? resolvePaths();
@@ -10945,7 +12624,7 @@ Scan with Lumin to approve, or open: ${offer.qrUrl}
10945
12624
  const pollIntervalMs = opts.pollIntervalMs ?? 5e3;
10946
12625
  for (let i = 0; i < maxAttempts; i += 1) {
10947
12626
  if (i > 0 || !localOnlyMode) {
10948
- await sleep2(pollIntervalMs);
12627
+ await sleep3(pollIntervalMs);
10949
12628
  }
10950
12629
  const res = await cloud.request(
10951
12630
  "GET",
@@ -10989,13 +12668,13 @@ import { spawnSync as spawnSync2 } from "child_process";
10989
12668
  import {
10990
12669
  copyFileSync,
10991
12670
  existsSync as existsSync12,
10992
- mkdirSync as mkdirSync8,
12671
+ mkdirSync as mkdirSync9,
10993
12672
  readFileSync as readFileSync10,
10994
12673
  statSync as statSync3,
10995
- writeFileSync as writeFileSync7
12674
+ writeFileSync as writeFileSync8
10996
12675
  } from "fs";
10997
- import { homedir as homedir7 } from "os";
10998
- import { dirname as dirname9, join as join16 } from "path";
12676
+ import { homedir as homedir8 } from "os";
12677
+ import { dirname as dirname10, join as join16 } from "path";
10999
12678
  import { Command } from "commander";
11000
12679
  init_hermes();
11001
12680
  init_openclaw();
@@ -11010,7 +12689,7 @@ var INSTALL_SPECS = {
11010
12689
  binary: "claude",
11011
12690
  hint: "Set ANTHROPIC_API_KEY in your shell profile, or rely on Claude Code OAuth login. Run `claude login` to sign in.",
11012
12691
  authHints: ["ANTHROPIC_API_KEY or Claude Code OAuth login (`claude login`)"],
11013
- hookTarget: { path: join16(homedir7(), ".claude", "hooks.json"), kind: "json-file" }
12692
+ hookTarget: { path: join16(homedir8(), ".claude", "hooks.json"), kind: "json-file" }
11014
12693
  },
11015
12694
  openclaw: {
11016
12695
  name: "openclaw",
@@ -11019,7 +12698,7 @@ var INSTALL_SPECS = {
11019
12698
  binary: "openclaw",
11020
12699
  hint: "OpenClaw runs as a gateway. Configure ~/.openclaw/openclaw.json and start it with `openclaw gateway` before tasks dispatch.",
11021
12700
  authHints: ["~/.openclaw/openclaw.json gateway.auth.bearerTokens", "Prismer daemon api_key"],
11022
- hookTarget: { path: join16(homedir7(), ".openclaw", "hooks"), kind: "directory" }
12701
+ hookTarget: { path: join16(homedir8(), ".openclaw", "hooks"), kind: "directory" }
11023
12702
  },
11024
12703
  codex: {
11025
12704
  name: "codex",
@@ -11028,7 +12707,7 @@ var INSTALL_SPECS = {
11028
12707
  binary: "codex",
11029
12708
  hint: "Set OPENAI_API_KEY in your shell profile. Verify with `codex --help`.",
11030
12709
  authHints: ["OPENAI_API_KEY or Codex CLI account login"],
11031
- hookTarget: { path: join16(homedir7(), ".codex", "hooks.json"), kind: "json-file" }
12710
+ hookTarget: { path: join16(homedir8(), ".codex", "hooks.json"), kind: "json-file" }
11032
12711
  },
11033
12712
  hermes: {
11034
12713
  name: "hermes",
@@ -11037,7 +12716,7 @@ var INSTALL_SPECS = {
11037
12716
  binary: "hermes",
11038
12717
  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
12718
  authHints: ["~/.hermes/.env API_SERVER_KEY", "Hermes profile provider credentials"],
11040
- hookTarget: { path: join16(homedir7(), ".hermes", "hooks.json"), kind: "json-file" }
12719
+ hookTarget: { path: join16(homedir8(), ".hermes", "hooks.json"), kind: "json-file" }
11041
12720
  }
11042
12721
  };
11043
12722
  function buildAdapterCommand() {
@@ -11252,15 +12931,15 @@ function runHooks(spec, opts) {
11252
12931
  }
11253
12932
  let backup;
11254
12933
  if (planned.kind === "directory") {
11255
- mkdirSync8(planned.path, { recursive: true });
12934
+ mkdirSync9(planned.path, { recursive: true });
11256
12935
  const markerPath = join16(planned.path, "prismer.json");
11257
12936
  backup = backupIfExists(markerPath);
11258
- writeFileSync7(markerPath, JSON.stringify(prismerHookMarker(spec), null, 2) + "\n", "utf8");
12937
+ writeFileSync8(markerPath, JSON.stringify(prismerHookMarker(spec), null, 2) + "\n", "utf8");
11259
12938
  } else {
11260
- mkdirSync8(dirname9(planned.path), { recursive: true });
12939
+ mkdirSync9(dirname10(planned.path), { recursive: true });
11261
12940
  backup = backupIfExists(planned.path);
11262
12941
  const merged = mergeHookJson(planned.path, spec);
11263
- writeFileSync7(planned.path, JSON.stringify(merged, null, 2) + "\n", "utf8");
12942
+ writeFileSync8(planned.path, JSON.stringify(merged, null, 2) + "\n", "utf8");
11264
12943
  }
11265
12944
  return {
11266
12945
  ok: true,
@@ -11327,7 +13006,7 @@ function inspectAuthEnv(spec) {
11327
13006
  hints: spec.authHints ?? []
11328
13007
  };
11329
13008
  case "hermes": {
11330
- const envFile = join16(homedir7(), ".hermes", ".env");
13009
+ const envFile = join16(homedir8(), ".hermes", ".env");
11331
13010
  return {
11332
13011
  ok: existsSync12(envFile) || Boolean(process.env.HERMES_API_KEY || process.env.API_SERVER_KEY),
11333
13012
  present: [
@@ -11338,7 +13017,7 @@ function inspectAuthEnv(spec) {
11338
13017
  };
11339
13018
  }
11340
13019
  case "openclaw": {
11341
- const cfgFile = join16(homedir7(), ".openclaw", "openclaw.json");
13020
+ const cfgFile = join16(homedir8(), ".openclaw", "openclaw.json");
11342
13021
  return {
11343
13022
  ok: existsSync12(cfgFile) || Boolean(process.env.OPENCLAW_API_KEY),
11344
13023
  present: [
@@ -11358,7 +13037,7 @@ function inspectHook(spec) {
11358
13037
  return { supported: false, markerPresent: false };
11359
13038
  }
11360
13039
  if (spec.name === "openclaw") {
11361
- const jsonPath = join16(homedir7(), ".openclaw", "hooks.json");
13040
+ const jsonPath = join16(homedir8(), ".openclaw", "hooks.json");
11362
13041
  const markerPath = join16(target.path, "prismer.json");
11363
13042
  const jsonMarkerPresent = existsSync12(jsonPath) && fileContainsPrismerMarker(jsonPath);
11364
13043
  const dirMarkerPresent = existsSync12(markerPath) && fileContainsPrismerMarker(markerPath);
@@ -11820,8 +13499,74 @@ function buildAgentCommand() {
11820
13499
  }
11821
13500
  getUI().ok("Skill install requested", `agent=${opts.agent} skill=${slugOrId}`);
11822
13501
  }, { code: "agent_install_skill_failed" }));
13502
+ 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) => {
13503
+ const cloud = makeCloudClient();
13504
+ const data = await requestEnvelope(cloud, "POST", `/api/im/agents/${encodeURIComponent(imUserId)}/snapshot`, {
13505
+ includeMemory: Boolean(opts.includeMemory),
13506
+ label: opts.label
13507
+ }, "agent_snapshot_failed");
13508
+ if (opts.json) {
13509
+ printJson(data);
13510
+ return;
13511
+ }
13512
+ const rec = data;
13513
+ getUI().ok("Snapshot created", String(rec.id ?? "<unknown>"));
13514
+ getUI().line(`agent=${String(rec.agentImUserId ?? imUserId)} memory=${rec.includeMemory ? "included" : "stripped"}`);
13515
+ }, { code: "agent_snapshot_failed" }));
13516
+ 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) => {
13517
+ const cloud = makeCloudClient();
13518
+ const data = await requestEnvelope(cloud, "POST", `/api/im/agents/${encodeURIComponent(imUserId)}/publish`, {
13519
+ slug: opts.slug,
13520
+ version: opts.version,
13521
+ license: opts.license,
13522
+ metadata: {
13523
+ ...opts.title ? { title: opts.title } : {},
13524
+ ...opts.description ? { description: opts.description } : {}
13525
+ },
13526
+ stripMemory: true
13527
+ }, "agent_publish_failed");
13528
+ if (opts.json) {
13529
+ printJson(data);
13530
+ return;
13531
+ }
13532
+ const rec = data;
13533
+ getUI().ok("Agent Pack published", `${String(rec.slug ?? opts.slug)}@${String(rec.version ?? opts.version ?? "1.0.0")}`);
13534
+ getUI().line(`package=${String(rec.id ?? "<unknown>")}`);
13535
+ }, { code: "agent_publish_failed" }));
13536
+ 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) => {
13537
+ const cloud = makeCloudClient();
13538
+ const data = await requestEnvelope(cloud, "POST", `/api/im/agent-packs/${encodeURIComponent(packIdOrSlug)}/fork`, {
13539
+ targetWorkspaceId: opts.workspaceId,
13540
+ displayName: opts.displayName
13541
+ }, "agent_fork_failed");
13542
+ if (opts.json) {
13543
+ printJson(data);
13544
+ return;
13545
+ }
13546
+ const rec = data;
13547
+ getUI().ok("Agent Pack forked", `newAgent=${String(rec.newImUserId ?? "<unknown>")}`);
13548
+ getUI().line(`did=${String(rec.newDid ?? "not issued")}`);
13549
+ }, { code: "agent_fork_failed" }));
11823
13550
  return cmd;
11824
13551
  }
13552
+ function makeCloudClient() {
13553
+ const cfg = loadConfig(resolvePaths());
13554
+ return new CloudClient({ baseUrl: cfg.cloud_api_base, apiKey: cfg.api_key });
13555
+ }
13556
+ async function requestEnvelope(cloud, method, path9, body, code) {
13557
+ const res = await cloud.request(
13558
+ method,
13559
+ path9,
13560
+ body === void 0 ? void 0 : { body }
13561
+ );
13562
+ if (!res.ok || res.data?.ok === false) {
13563
+ const message = res.error?.message ?? res.data?.error?.message ?? "request failed";
13564
+ exitWithError(`${path9} failed (${res.status === 0 ? "network error" : `HTTP ${res.status}`}): ${message}`, {
13565
+ code: res.error?.code ?? res.data?.error?.code ?? code
13566
+ });
13567
+ }
13568
+ return res.data && typeof res.data === "object" && "data" in res.data ? res.data.data : res.data;
13569
+ }
11825
13570
  function readLocalAgents(localDb) {
11826
13571
  const db = openLocalDb(localDb);
11827
13572
  try {
@@ -11926,8 +13671,8 @@ function whichBinary2(bin) {
11926
13671
  // src/cli/commands/asset.ts
11927
13672
  import { createHash as createHash8 } from "crypto";
11928
13673
  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";
13674
+ import { existsSync as existsSync13, lstatSync, readdirSync, readFileSync as readFileSync11, writeFileSync as writeFileSync9 } from "fs";
13675
+ import { basename as basename3, dirname as dirname11, join as join17, relative as relative4 } from "path";
11931
13676
  init_util();
11932
13677
  init_ui();
11933
13678
  function buildAssetCommand() {
@@ -12110,7 +13855,7 @@ async function uploadAssetPath(inputPath, opts) {
12110
13855
  if (files.length === 0) exitWithError(`directory has no uploadable files: ${inputPath}`);
12111
13856
  const items = [];
12112
13857
  for (const file of files) {
12113
- const relDir = dirname10(relative4(inputPath, file));
13858
+ const relDir = dirname11(relative4(inputPath, file));
12114
13859
  const folderPath = combineFolderPath(opts.folderPath, relDir === "." ? "" : relDir);
12115
13860
  const response = await uploadAsset(file, { ...opts, folderPath });
12116
13861
  items.push({ file, folderPath: folderPath ?? null, response });
@@ -12293,7 +14038,7 @@ async function downloadAsset(assetId, outPath) {
12293
14038
  exitWithError(`asset download failed (${res.status}): ${errorMessage(body)}`);
12294
14039
  }
12295
14040
  const bytes = Buffer.from(await res.arrayBuffer());
12296
- writeFileSync8(outPath, bytes);
14041
+ writeFileSync9(outPath, bytes);
12297
14042
  }
12298
14043
  function parseMetadata(raw) {
12299
14044
  if (!raw) return {};
@@ -12692,7 +14437,7 @@ function buildCookbookCommand() {
12692
14437
  for (const suite of suites) {
12693
14438
  checks.push(...await runSuite(suite, cloud, opts));
12694
14439
  }
12695
- const summary = summarize(checks, Boolean(opts.strict));
14440
+ const summary = summarize2(checks, Boolean(opts.strict));
12696
14441
  if (opts.json) {
12697
14442
  printJson(summary);
12698
14443
  } else {
@@ -12857,7 +14602,7 @@ function parseSuites(raw) {
12857
14602
  const expanded = selected.includes("all") ? ["status", "im", "task", "group", "asset", "sandbox"] : selected;
12858
14603
  return [...new Set(expanded)];
12859
14604
  }
12860
- function summarize(checks, strict) {
14605
+ function summarize2(checks, strict) {
12861
14606
  const totals = { pass: 0, fail: 0, skip: 0 };
12862
14607
  for (const check of checks) totals[check.status] += 1;
12863
14608
  return {
@@ -12940,8 +14685,8 @@ function parsePositiveInt3(value) {
12940
14685
  // src/cli/commands/daemon.ts
12941
14686
  import { Command as Command8 } from "commander";
12942
14687
  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";
14688
+ import { createReadStream, existsSync as existsSync14, mkdirSync as mkdirSync10, openSync as openSync2, statSync as statSync4 } from "fs";
14689
+ import { setTimeout as sleep4 } from "timers/promises";
12945
14690
  import { join as join18 } from "path";
12946
14691
  init_util();
12947
14692
  init_ui();
@@ -12984,7 +14729,7 @@ function buildDaemonCommand() {
12984
14729
  while (!configExists(paths)) {
12985
14730
  if (stopRequested) return;
12986
14731
  getUI().info(`[daemon] waiting for ${paths.configFile} \u2014 run \`prismer setup\` to create it`);
12987
- await sleep3(5e3);
14732
+ await sleep4(5e3);
12988
14733
  }
12989
14734
  runner = new Runner({
12990
14735
  startLocalServer: opts.localServer !== false,
@@ -13016,7 +14761,7 @@ function buildDaemonCommand() {
13016
14761
  if (existingPid && pidAlive2(existingPid)) {
13017
14762
  exitWithError(`Daemon already running (pid ${existingPid}). Use \`prismer daemon stop\` first.`);
13018
14763
  }
13019
- if (!existsSync14(paths.logsDir)) mkdirSync9(paths.logsDir, { recursive: true });
14764
+ if (!existsSync14(paths.logsDir)) mkdirSync10(paths.logsDir, { recursive: true });
13020
14765
  const logFile = join18(paths.logsDir, "daemon.log");
13021
14766
  const fd = openSync2(logFile, "a");
13022
14767
  const args = [process.argv[1], "daemon", "run"];
@@ -13030,7 +14775,7 @@ function buildDaemonCommand() {
13030
14775
  child.unref();
13031
14776
  const deadline = Date.now() + 5e3;
13032
14777
  while (Date.now() < deadline) {
13033
- await sleep3(200);
14778
+ await sleep4(200);
13034
14779
  const pid = readPidFile(paths);
13035
14780
  if (pid && pidAlive2(pid)) {
13036
14781
  if (opts.json) printJson({ ok: true, pid, logFile });
@@ -13064,7 +14809,7 @@ function buildDaemonCommand() {
13064
14809
  getUI().ok("Daemon stopped", `pid ${pid}`);
13065
14810
  return;
13066
14811
  }
13067
- await sleep3(200);
14812
+ await sleep4(200);
13068
14813
  }
13069
14814
  process.stderr.write(`Daemon did not exit within timeout; pid ${pid} may still be alive.
13070
14815
  `);
@@ -13145,7 +14890,7 @@ async function tailFromEnd(path9, lines) {
13145
14890
  async function followFile(path9) {
13146
14891
  let offset = statSync4(path9).size;
13147
14892
  for (; ; ) {
13148
- await sleep3(1e3);
14893
+ await sleep4(1e3);
13149
14894
  const size = statSync4(path9).size;
13150
14895
  if (size < offset) offset = 0;
13151
14896
  if (size === offset) continue;
@@ -13159,7 +14904,7 @@ async function followFile(path9) {
13159
14904
  init_util();
13160
14905
  import { Command as Command9 } from "commander";
13161
14906
  import { createReadStream as createReadStream2, existsSync as existsSync15 } from "fs";
13162
- import { homedir as homedir8 } from "os";
14907
+ import { homedir as homedir9 } from "os";
13163
14908
  import { join as join19 } from "path";
13164
14909
  import { createInterface } from "readline";
13165
14910
  var DEFAULT_LIMIT2 = 50;
@@ -13192,7 +14937,7 @@ function buildEventsStatsCommand() {
13192
14937
  const events = await readEvents(file, filters);
13193
14938
  printJson({
13194
14939
  ok: true,
13195
- data: { stats: summarize2(events), count: events.length, limit: filters.limit },
14940
+ data: { stats: summarize3(events), count: events.length, limit: filters.limit },
13196
14941
  checks: { file, exists: true, filters: cleanFilters(filters) }
13197
14942
  });
13198
14943
  });
@@ -13201,7 +14946,7 @@ function addEventOptions(cmd) {
13201
14946
  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
14947
  }
13203
14948
  function eventsPath() {
13204
- return join19(process.env.PRISMER_HOME ?? join19(homedir8(), ".prismer"), "para", "events.jsonl");
14949
+ return join19(process.env.PRISMER_HOME ?? join19(homedir9(), ".prismer"), "para", "events.jsonl");
13205
14950
  }
13206
14951
  function unavailable(file) {
13207
14952
  return {
@@ -13249,7 +14994,7 @@ function field(event, names) {
13249
14994
  }
13250
14995
  return void 0;
13251
14996
  }
13252
- function summarize2(events) {
14997
+ function summarize3(events) {
13253
14998
  const byFamily = {};
13254
14999
  const byType = {};
13255
15000
  const byAgentId = {};
@@ -13603,7 +15348,7 @@ function buildPairCommand() {
13603
15348
 
13604
15349
  // src/cli/commands/profile.ts
13605
15350
  import { Command as Command12 } from "commander";
13606
- import { existsSync as existsSync17, readFileSync as readFileSync12, writeFileSync as writeFileSync9 } from "fs";
15351
+ import { existsSync as existsSync17, readFileSync as readFileSync12, writeFileSync as writeFileSync10 } from "fs";
13607
15352
  import { tmpdir } from "os";
13608
15353
  import { join as join20 } from "path";
13609
15354
  import { spawnSync as spawnSync4 } from "child_process";
@@ -13654,7 +15399,7 @@ function buildProfileCommand() {
13654
15399
  `/api/im/agent_profiles/${encodeURIComponent(profileId)}`
13655
15400
  );
13656
15401
  const tmpFile = join20(tmpdir(), `prismer-profile-${profileId}.json`);
13657
- writeFileSync9(tmpFile, JSON.stringify(profile.config, null, 2), "utf8");
15402
+ writeFileSync10(tmpFile, JSON.stringify(profile.config, null, 2), "utf8");
13658
15403
  const editor = process.env.EDITOR || "vi";
13659
15404
  const ed = spawnSync4(editor, [tmpFile], { stdio: "inherit" });
13660
15405
  if (ed.status !== 0) exitWithError(`editor exited ${ed.status}`, { code: "editor_failed" });
@@ -13695,10 +15440,10 @@ async function resolveDefaultWorkspaceId(cloud) {
13695
15440
  // src/cli/commands/reset.ts
13696
15441
  import { Command as Command13 } from "commander";
13697
15442
  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";
15443
+ import { existsSync as existsSync18, mkdirSync as mkdirSync11, rmSync, renameSync as renameSync2 } from "fs";
15444
+ import { homedir as homedir10 } from "os";
15445
+ import { dirname as dirname12, join as join21 } from "path";
15446
+ import { setTimeout as sleep5 } from "timers/promises";
13702
15447
  init_util();
13703
15448
  init_ui();
13704
15449
  function buildResetCommand() {
@@ -13751,8 +15496,8 @@ function buildResetCommand() {
13751
15496
  rmSync(paths.root, { recursive: true, force: true });
13752
15497
  } else {
13753
15498
  if (!archivePath) throw new Error("internal: archivePath missing");
13754
- if (!existsSync18(dirname11(archivePath))) {
13755
- mkdirSync10(dirname11(archivePath), { recursive: true });
15499
+ if (!existsSync18(dirname12(archivePath))) {
15500
+ mkdirSync11(dirname12(archivePath), { recursive: true });
13756
15501
  }
13757
15502
  renameSync2(paths.root, archivePath);
13758
15503
  }
@@ -13762,8 +15507,8 @@ function buildResetCommand() {
13762
15507
  rmSync(assetSyncRoot, { recursive: true, force: true });
13763
15508
  } else {
13764
15509
  if (!assetSyncArchivePath) throw new Error("internal: assetSyncArchivePath missing");
13765
- if (!existsSync18(dirname11(assetSyncArchivePath))) {
13766
- mkdirSync10(dirname11(assetSyncArchivePath), { recursive: true });
15510
+ if (!existsSync18(dirname12(assetSyncArchivePath))) {
15511
+ mkdirSync11(dirname12(assetSyncArchivePath), { recursive: true });
13767
15512
  }
13768
15513
  renameSync2(assetSyncRoot, assetSyncArchivePath);
13769
15514
  }
@@ -13773,13 +15518,13 @@ function buildResetCommand() {
13773
15518
  rmSync(hermesHome, { recursive: true, force: true });
13774
15519
  } else {
13775
15520
  if (!hermesArchivePath) throw new Error("internal: hermesArchivePath missing");
13776
- if (!existsSync18(dirname11(hermesArchivePath))) {
13777
- mkdirSync10(dirname11(hermesArchivePath), { recursive: true });
15521
+ if (!existsSync18(dirname12(hermesArchivePath))) {
15522
+ mkdirSync11(dirname12(hermesArchivePath), { recursive: true });
13778
15523
  }
13779
15524
  renameSync2(hermesHome, hermesArchivePath);
13780
15525
  }
13781
15526
  }
13782
- mkdirSync10(paths.root, { recursive: true });
15527
+ mkdirSync11(paths.root, { recursive: true });
13783
15528
  if (opts.json) {
13784
15529
  printJson({ ok: true, reset: true, plan });
13785
15530
  return;
@@ -13828,7 +15573,7 @@ async function stopProcess(pid, timeoutMs, label) {
13828
15573
  const deadline = Date.now() + timeoutMs;
13829
15574
  while (Date.now() < deadline) {
13830
15575
  if (!pidAlive2(pid)) return;
13831
- await sleep4(100);
15576
+ await sleep5(100);
13832
15577
  }
13833
15578
  try {
13834
15579
  process.kill(pid, "SIGKILL");
@@ -13838,7 +15583,7 @@ async function stopProcess(pid, timeoutMs, label) {
13838
15583
  const killDeadline = Date.now() + 1e3;
13839
15584
  while (Date.now() < killDeadline) {
13840
15585
  if (!pidAlive2(pid)) return;
13841
- await sleep4(100);
15586
+ await sleep5(100);
13842
15587
  }
13843
15588
  throw new Error(`${label} pid ${pid} did not exit within ${timeoutMs + 1e3}ms`);
13844
15589
  }
@@ -13848,13 +15593,13 @@ function timestamp() {
13848
15593
  function resolveAssetSyncRoot() {
13849
15594
  const override = process.env.PRISMER_ASSET_SYNC_ROOT;
13850
15595
  if (override) return override;
13851
- const home = homedir9();
15596
+ const home = homedir10();
13852
15597
  const desktop = join21(home, "Desktop");
13853
15598
  const base = existsSync18(desktop) ? desktop : home;
13854
15599
  return join21(base, "Prismer Assets");
13855
15600
  }
13856
15601
  function resolveHermesHome() {
13857
- return process.env.HERMES_HOME || join21(homedir9(), ".hermes");
15602
+ return process.env.HERMES_HOME || join21(homedir10(), ".hermes");
13858
15603
  }
13859
15604
  function findHermesGatewayPids2() {
13860
15605
  try {
@@ -14409,7 +16154,7 @@ function buildSkillCommand() {
14409
16154
  const cloud = mkCloud6();
14410
16155
  const path9 = opts.agent ? `/api/im/agents/${encodeURIComponent(opts.agent)}/skills` : `/api/im/skills/${encodeURIComponent(slugOrId)}/install`;
14411
16156
  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");
16157
+ const data = await requestEnvelope2(cloud, "POST", path9, body, "skill_install_failed");
14413
16158
  if (opts.json) {
14414
16159
  printJson(data);
14415
16160
  return;
@@ -14420,7 +16165,7 @@ function buildSkillCommand() {
14420
16165
  const cloud = mkCloud6();
14421
16166
  const path9 = opts.agent ? `/api/im/agents/${encodeURIComponent(opts.agent)}/skills` : `/api/im/skills/${encodeURIComponent(slugOrId)}/install`;
14422
16167
  const body = opts.agent ? { skillId: slugOrId, workspaceId: opts.workspaceId } : void 0;
14423
- const data = await requestEnvelope(cloud, "DELETE", path9, body, "skill_uninstall_failed");
16168
+ const data = await requestEnvelope2(cloud, "DELETE", path9, body, "skill_uninstall_failed");
14424
16169
  if (opts.json) {
14425
16170
  printJson(data);
14426
16171
  return;
@@ -14473,7 +16218,7 @@ function mkCloud6() {
14473
16218
  const cfg = loadConfig(resolvePaths());
14474
16219
  return new CloudClient({ baseUrl: cfg.cloud_api_base, apiKey: cfg.api_key });
14475
16220
  }
14476
- async function requestEnvelope(cloud, method, path9, body, code) {
16221
+ async function requestEnvelope2(cloud, method, path9, body, code) {
14477
16222
  const res = await cloud.request(method, path9, {
14478
16223
  body
14479
16224
  });
@@ -14623,6 +16368,7 @@ function buildStatusCommand() {
14623
16368
  let me = null;
14624
16369
  let devices = null;
14625
16370
  let agents = null;
16371
+ let diagnose = null;
14626
16372
  try {
14627
16373
  const meRes = await cloud.request("GET", "/api/im/me", { timeoutMs: 3e3 });
14628
16374
  cloudOk = meRes.ok;
@@ -14635,19 +16381,33 @@ function buildStatusCommand() {
14635
16381
  if (Array.isArray(wsList) && wsList.length > 0) {
14636
16382
  const wsId = wsList[0]?.id;
14637
16383
  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;
16384
+ const rtRes = await cloud.request("GET", `/api/im/workspaces/${encodeURIComponent(wsId)}/runtime`, { timeoutMs: 3e3 });
16385
+ if (rtRes.ok) {
16386
+ const rtBody = rtRes.data;
16387
+ const snapshot = rtBody?.data;
16388
+ if (snapshot && Array.isArray(snapshot.devices)) {
16389
+ devices = snapshot.devices;
16390
+ agents = snapshot.devices.flatMap((d) => d.agents ?? []);
16391
+ }
14647
16392
  }
14648
16393
  }
14649
16394
  }
14650
16395
  }
16396
+ const daemonId = cfg.daemon_id;
16397
+ if (daemonId) {
16398
+ try {
16399
+ const diagRes = await cloud.request(
16400
+ "GET",
16401
+ `/api/im/runtime/diagnose?daemonId=${encodeURIComponent(daemonId)}`,
16402
+ { timeoutMs: 3e3 }
16403
+ );
16404
+ if (diagRes.ok) {
16405
+ const body = diagRes.data;
16406
+ if (body?.data && typeof body.data === "object") diagnose = body.data;
16407
+ }
16408
+ } catch {
16409
+ }
16410
+ }
14651
16411
  }
14652
16412
  } catch {
14653
16413
  cloudOk = false;
@@ -14664,7 +16424,8 @@ function buildStatusCommand() {
14664
16424
  info: daemonStatus.info ?? {}
14665
16425
  },
14666
16426
  cloud: { base: cfg.cloud_api_base, reachable: cloudOk, me, devices, agents },
14667
- local
16427
+ local,
16428
+ diagnose
14668
16429
  };
14669
16430
  if (opts.json) {
14670
16431
  printJson(report);
@@ -14673,6 +16434,60 @@ function buildStatusCommand() {
14673
16434
  printPretty(report);
14674
16435
  });
14675
16436
  }
16437
+ function computeDeviceIcon(lastSeenAt, now) {
16438
+ if (!lastSeenAt) return "\u25CB";
16439
+ const age = now.getTime() - Date.parse(lastSeenAt);
16440
+ if (!Number.isFinite(age) || age < 0) return "\u25CB";
16441
+ if (age < 5 * 6e4) return "\u25CF";
16442
+ if (age < 60 * 6e4) return "\u25D0";
16443
+ return "\u25CB";
16444
+ }
16445
+ function computeAgentIcon(status, lastHeartbeat, now) {
16446
+ const s = (status || "").toLowerCase();
16447
+ if (s === "error" || s === "failed") return "\u25C6";
16448
+ if (!lastHeartbeat) return s === "offline" ? "\u25CB" : "\u25D0";
16449
+ const age = now.getTime() - Date.parse(lastHeartbeat);
16450
+ if (!Number.isFinite(age) || age < 0 || age >= 60 * 6e4) return "\u25CB";
16451
+ if (age >= 5 * 6e4) return "\u25D0";
16452
+ return "\u25CF";
16453
+ }
16454
+ function pad(value, width) {
16455
+ if (value.length >= width) return value.slice(0, width);
16456
+ return value + " ".repeat(width - value.length);
16457
+ }
16458
+ function formatDeviceBindings(devices, now = /* @__PURE__ */ new Date()) {
16459
+ const lines = [];
16460
+ if (devices.length === 0) {
16461
+ lines.push(" No paired devices.");
16462
+ return lines;
16463
+ }
16464
+ const totalAgents = devices.reduce((s, d) => s + (d.agents?.length ?? 0), 0);
16465
+ const devWord = devices.length === 1 ? "device" : "devices";
16466
+ const agWord = totalAgents === 1 ? "agent" : "agents";
16467
+ lines.push(` Device & Agent Bindings (${devices.length} ${devWord} \xB7 ${totalAgents} ${agWord})`);
16468
+ lines.push("");
16469
+ for (let di = 0; di < devices.length; di++) {
16470
+ const d = devices[di];
16471
+ const dIcon = computeDeviceIcon(d.lastSeenAt, now);
16472
+ const dName = pad(d.name || d.deviceId, 40);
16473
+ const lastSeen = d.lastSeenAt ? formatRelative(d.lastSeenAt, now) : "never seen";
16474
+ lines.push(` ${dIcon} ${dName} ${lastSeen}`);
16475
+ const agents = d.agents ?? [];
16476
+ for (let ai = 0; ai < agents.length; ai++) {
16477
+ const a = agents[ai];
16478
+ const isLast = ai === agents.length - 1;
16479
+ const branch = isLast ? "\u2514\u2500" : "\u251C\u2500";
16480
+ const aIcon = computeAgentIcon(a.status, a.lastHeartbeat, now);
16481
+ const aName = pad(a.name || a.id, 24);
16482
+ const aStatus = pad(a.status || "?", 8);
16483
+ const aVersion = pad(a.version ? `v${a.version}` : "\u2014", 10);
16484
+ const tail = a.currentTaskId ? `task=${a.currentTaskId}` : a.lastHeartbeat ? `\u2764 ${formatRelative(a.lastHeartbeat, now)}` : "";
16485
+ lines.push(` ${branch} ${aIcon} ${aName} ${aStatus} ${aVersion} ${tail}`.trimEnd());
16486
+ }
16487
+ if (di < devices.length - 1) lines.push("");
16488
+ }
16489
+ return lines;
16490
+ }
14676
16491
  async function readDaemonStatus() {
14677
16492
  try {
14678
16493
  const res = await fetch("http://127.0.0.1:3210/healthz", {
@@ -14745,16 +16560,7 @@ function printPretty(report) {
14745
16560
  if (Array.isArray(report.cloud.devices) && report.cloud.devices.length > 0) {
14746
16561
  const devs = report.cloud.devices;
14747
16562
  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}`);
16563
+ for (const ln of formatDeviceBindings(devs)) ui.line(ln);
14758
16564
  }
14759
16565
  if (report.local) {
14760
16566
  ui.blank();
@@ -14764,11 +16570,63 @@ function printPretty(report) {
14764
16570
  } else {
14765
16571
  warn("Local DB", "unavailable");
14766
16572
  }
16573
+ if (report.diagnose) {
16574
+ ui.blank();
16575
+ ui.header("Cloud dispatch reachability");
16576
+ ui.blank();
16577
+ printDiagnose(report.diagnose, report.daemon.pid ?? null);
16578
+ }
16579
+ }
16580
+ function printDiagnose(d, pid) {
16581
+ const ui = getUI();
16582
+ const heartbeatLine = d.lastHeartbeatAt ? `last heartbeat ${formatRelative(d.lastHeartbeatAt)}` : "no heartbeat seen";
16583
+ if (d.wsAlive) {
16584
+ ok("Daemon", `running${pid ? ` (pid ${pid})` : ""}`);
16585
+ ok("Cloud WS", `connected (${heartbeatLine})`);
16586
+ } else {
16587
+ warn("Daemon", `running${pid ? ` (pid ${pid})` : ""} \u2014 not visible to cloud WS`);
16588
+ fail2("Cloud WS", `disconnected (${heartbeatLine})`);
16589
+ }
16590
+ if (d.gatewayUrl) {
16591
+ if (d.gatewayIsPrivate) {
16592
+ warn("HTTP gateway", `${d.gatewayUrl} (private IP \u2014 only reachable on same network)`);
16593
+ } else if (d.httpReachable) {
16594
+ ok(
16595
+ "HTTP gateway",
16596
+ `${d.gatewayUrl} (reachable${d.httpLatencyMs != null ? `, ${d.httpLatencyMs}ms` : ""})`
16597
+ );
16598
+ } else {
16599
+ fail2("HTTP gateway", `${d.gatewayUrl} (unreachable${d.httpError ? ` \u2014 ${d.httpError}` : ""})`);
16600
+ }
16601
+ } else {
16602
+ ui.line(" HTTP gateway: not declared (WS-only daemon)");
16603
+ }
16604
+ const activeAgents = /* @__PURE__ */ (() => {
16605
+ return 0;
16606
+ })();
16607
+ if (activeAgents > 0) {
16608
+ ok("Active agents", String(activeAgents));
16609
+ }
16610
+ const recColour = d.recommendedTransport === "ws" ? ok : d.recommendedTransport === "http" ? warn : fail2;
16611
+ recColour("Recommended transport", d.recommendedTransport);
16612
+ if (d.lastProbeAt) {
16613
+ ui.line(` Last transport probe: ${formatRelative(d.lastProbeAt)}`);
16614
+ }
16615
+ }
16616
+ function formatRelative(iso, now = /* @__PURE__ */ new Date()) {
16617
+ const ts = Date.parse(iso);
16618
+ if (!Number.isFinite(ts)) return "unknown";
16619
+ const diff = Math.max(0, now.getTime() - ts);
16620
+ if (diff < 3e4) return "just now";
16621
+ if (diff < 6e4) return `${Math.round(diff / 1e3)}s ago`;
16622
+ if (diff < 36e5) return `${Math.round(diff / 6e4)}m ago`;
16623
+ if (diff < 864e5) return `${Math.round(diff / 36e5)}h ago`;
16624
+ return `${Math.round(diff / 864e5)}d ago`;
14767
16625
  }
14768
16626
 
14769
16627
  // src/cli/commands/task.ts
14770
16628
  import { Command as Command18 } from "commander";
14771
- import { setTimeout as sleep5 } from "timers/promises";
16629
+ import { setTimeout as sleep6 } from "timers/promises";
14772
16630
  init_util();
14773
16631
  function describeStatus2(status) {
14774
16632
  return status === 0 ? "network error" : `HTTP ${status}`;
@@ -14851,7 +16709,7 @@ function unwrap2(raw) {
14851
16709
  async function pollUntilTerminal(cloud, taskId, timeoutMs) {
14852
16710
  const deadline = Date.now() + (timeoutMs ?? 5 * 6e4);
14853
16711
  while (Date.now() < deadline) {
14854
- await sleep5(1e3);
16712
+ await sleep6(1e3);
14855
16713
  const res = await cloud.request(
14856
16714
  "GET",
14857
16715
  `/api/im/tasks/${encodeURIComponent(taskId)}`
@@ -15126,7 +16984,7 @@ async function readResponseError(res) {
15126
16984
 
15127
16985
  // src/cli/index.ts
15128
16986
  init_ui();
15129
- var VERSION = "2.0.4";
16987
+ var VERSION = "2.0.6";
15130
16988
  function buildProgram() {
15131
16989
  const program = new Command20("prismer").description("Prismer Cloud daemon CLI (TS-only).").version(VERSION);
15132
16990
  program.addCommand(buildBannerCommand());