@prismer/runtime 2.0.3 → 2.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -154,6 +154,42 @@ import { fileURLToPath } from "url";
154
154
  import Database from "better-sqlite3";
155
155
  import * as YAML from "yaml";
156
156
  import { z as z3 } from "zod";
157
+ function readPlainRecord(value) {
158
+ return value && typeof value === "object" && !Array.isArray(value) ? value : null;
159
+ }
160
+ function readNonEmptyString(value) {
161
+ return typeof value === "string" && value.trim() ? value.trim() : null;
162
+ }
163
+ function sanitizeRoleSkillSlug(value) {
164
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 60) || "template";
165
+ }
166
+ function renderRoleTemplateSkill(skillName, agents, roleTemplate) {
167
+ const mcpServers = Array.isArray(roleTemplate?.mcpServers) ? roleTemplate.mcpServers : [];
168
+ const tools = /* @__PURE__ */ new Set();
169
+ for (const server of mcpServers) {
170
+ const rec = readPlainRecord(server);
171
+ const allowlist = Array.isArray(rec?.toolsAllowlist) ? rec.toolsAllowlist : [];
172
+ for (const tool of allowlist) {
173
+ if (typeof tool === "string" && tool.trim()) tools.add(tool.trim());
174
+ }
175
+ }
176
+ const allowedTools = [...tools].join(" ");
177
+ const roleTemplateSlug = JSON.stringify(readNonEmptyString(roleTemplate?.slug) ?? null);
178
+ return `---
179
+ name: ${skillName}
180
+ description: Role-template operating playbook projected from Prismer RoleTemplate.
181
+ ${allowedTools ? `allowed-tools: ${allowedTools}
182
+ ` : ""}metadata:
183
+ prismer:
184
+ source: role-template
185
+ roleTemplateSlug: ${roleTemplateSlug}
186
+ ---
187
+
188
+ # Role Template Playbook
189
+
190
+ ${agents.trim()}
191
+ `;
192
+ }
157
193
  function parsePrismerGoals(value) {
158
194
  if (!Array.isArray(value)) return [];
159
195
  return value.map((entry) => {
@@ -563,13 +599,14 @@ function failure(status, body) {
563
599
  error: { code: "adapter_dispatch_failed", message: `Hermes ${status}: ${body || "<no body>"}` }
564
600
  };
565
601
  }
566
- async function consumeSse(body, task) {
602
+ async function consumeSse(body, task, state) {
567
603
  const reader = body.getReader();
568
604
  const decoder = new TextDecoder();
569
605
  let buf = "";
570
606
  let deltas = "";
571
607
  let finalOutput;
572
608
  let lastProgress = 0;
609
+ let approvalRequested = false;
573
610
  for (; ; ) {
574
611
  const readStart = Date.now();
575
612
  const { value, done } = await reader.read();
@@ -618,6 +655,10 @@ async function consumeSse(body, task) {
618
655
  const next = Math.min(0.99, lastProgress + 0.05);
619
656
  lastProgress = next;
620
657
  const toolName = typeof payload.tool === "string" ? payload.tool : typeof payload.name === "string" ? payload.name : void 0;
658
+ if (eventName === "tool.started" && toolName === APPROVAL_TOOL_NAME) {
659
+ approvalRequested = true;
660
+ if (state) state.approvalRequested = true;
661
+ }
621
662
  const previewStr = typeof payload.preview === "string" ? payload.preview : payload.preview != null ? JSON.stringify(payload.preview).slice(0, 200) : void 0;
622
663
  task.onProgress?.({
623
664
  progress: next,
@@ -647,7 +688,53 @@ async function consumeSse(body, task) {
647
688
  }
648
689
  }
649
690
  }
650
- return finalOutput && finalOutput.length > 0 ? finalOutput : deltas;
691
+ return {
692
+ output: finalOutput && finalOutput.length > 0 ? finalOutput : deltas,
693
+ approvalRequested
694
+ };
695
+ }
696
+ async function consumeChatCompletionsSse(body, task) {
697
+ const reader = body.getReader();
698
+ const decoder = new TextDecoder();
699
+ let buf = "";
700
+ let output = "";
701
+ let lastProgress = 0;
702
+ for (; ; ) {
703
+ const { value, done } = await reader.read();
704
+ if (done) break;
705
+ buf += decoder.decode(value, { stream: true });
706
+ const events = buf.split("\n\n");
707
+ buf = events.pop() ?? "";
708
+ for (const ev of events) {
709
+ const dataLine = ev.match(/^data: (.+)$/m);
710
+ if (!dataLine) continue;
711
+ const raw = dataLine[1].trim();
712
+ if (raw === "[DONE]") continue;
713
+ let payload;
714
+ try {
715
+ payload = JSON.parse(raw);
716
+ } catch (err) {
717
+ process.stderr.write(
718
+ `[hermes-adapter] chat-completions SSE parse skipped: ${err.message}
719
+ `
720
+ );
721
+ continue;
722
+ }
723
+ const delta = payload.choices?.[0]?.delta?.content;
724
+ if (typeof delta === "string") {
725
+ output += delta;
726
+ } else if (Array.isArray(delta)) {
727
+ for (const part of delta) {
728
+ if (part?.type === "text" && typeof part.text === "string") output += part.text;
729
+ }
730
+ }
731
+ if (output.length > 0 && lastProgress < 0.99) {
732
+ lastProgress = Math.min(0.99, lastProgress + 0.05);
733
+ task.onProgress?.({ progress: lastProgress, message: "streaming" });
734
+ }
735
+ }
736
+ }
737
+ return output;
651
738
  }
652
739
  async function checkHealth(baseUrl, apiKey) {
653
740
  try {
@@ -728,13 +815,14 @@ function pidAlive(pid) {
728
815
  function escapeRegExp(value) {
729
816
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
730
817
  }
731
- var PRISMER_IM_SKILL_NAME, PRISMER_IM_SKILL_CONTENT, HermesProfileConfigSchema, hermesAdapter, HermesService;
818
+ var PRISMER_IM_SKILL_NAME, PRISMER_ROLE_SKILL_PREFIX, PRISMER_IM_SKILL_CONTENT, HermesProfileConfigSchema, hermesAdapter, HermesService, APPROVAL_TOOL_NAME;
732
819
  var init_hermes = __esm({
733
820
  "src/adapters/hermes/index.ts"() {
734
821
  "use strict";
735
822
  init_contract();
736
823
  init_skill_loader2();
737
824
  PRISMER_IM_SKILL_NAME = "prismer-im-collab";
825
+ PRISMER_ROLE_SKILL_PREFIX = "prismer-role";
738
826
  PRISMER_IM_SKILL_CONTENT = `---
739
827
  name: prismer-im-collab
740
828
  description: Coordinate reliably in Prismer conversations, use workspace assets through bounded MCP tools, and keep task work on the board.
@@ -1047,6 +1135,7 @@ No @ needed; the human is the only other party.
1047
1135
  const startedAt = Date.now();
1048
1136
  let runId;
1049
1137
  const sysPrompt = typeof task.metadata?.systemPrompt === "string" ? task.metadata.systemPrompt : void 0;
1138
+ this.installRoleTemplateSkill();
1050
1139
  const skillPrompt = await this.skillLoader.loadSystemPromptFragment().catch((err) => {
1051
1140
  process.stderr.write(
1052
1141
  `[hermes-adapter] failed to read skills for ${this.profileName}: ${err.message}
@@ -1054,7 +1143,21 @@ No @ needed; the human is the only other party.
1054
1143
  );
1055
1144
  return void 0;
1056
1145
  });
1057
- const instructions = [sysPrompt, skillPrompt].filter(Boolean).join("\n\n");
1146
+ const outboxDir = typeof task.metadata?.prismerOutboxDir === "string" ? task.metadata.prismerOutboxDir : void 0;
1147
+ const outboxDirective = outboxDir ? [
1148
+ "[Outbox directive \u2014 MANDATORY, overrides any prior outbox path]",
1149
+ "",
1150
+ "For THIS turn ONLY, your active outbox is:",
1151
+ ` ${outboxDir}`,
1152
+ "",
1153
+ "Any user-deliverable file (PDF, image, CSV, archive, doc) MUST be",
1154
+ "written to that EXACT absolute path. Do NOT reuse the outbox path",
1155
+ "from any previous turn \u2014 those directories no longer accept new",
1156
+ "uploads. Do NOT copy files into the previous outbox; copy them",
1157
+ "into the path above. Files outside this directory will not become",
1158
+ 'chat attachments and the user will see "no files were attached".'
1159
+ ].join("\n") : "";
1160
+ const instructions = [outboxDirective, sysPrompt, skillPrompt].filter(Boolean).join("\n\n");
1058
1161
  if (sysPrompt) {
1059
1162
  try {
1060
1163
  const profileDir = getHermesProfileDir(this.profileName);
@@ -1078,8 +1181,15 @@ No @ needed; the human is the only other party.
1078
1181
  `
1079
1182
  );
1080
1183
  }
1184
+ const sseState = { approvalRequested: false };
1081
1185
  try {
1082
1186
  const nativeBridgePatch = await this.prepareNativeBridgePatch(task);
1187
+ const imageRefs = (task.assetRefs ?? []).filter(
1188
+ (r) => r.mime?.startsWith("image/")
1189
+ );
1190
+ if (imageRefs.length > 0) {
1191
+ return await this.dispatchMultimodalChat(task, instructions, imageRefs, startedAt);
1192
+ }
1083
1193
  const createRes = await fetch(`${this.baseUrl}/v1/runs`, {
1084
1194
  method: "POST",
1085
1195
  headers: {
@@ -1130,10 +1240,10 @@ No @ needed; the human is the only other party.
1130
1240
  }
1131
1241
  };
1132
1242
  }
1133
- const output = await consumeSse(eventsRes.body, task);
1243
+ const sseResult = await consumeSse(eventsRes.body, task, sseState);
1134
1244
  return {
1135
1245
  ok: true,
1136
- output,
1246
+ output: sseResult.output,
1137
1247
  metrics: { durationMs: Date.now() - startedAt },
1138
1248
  metadata: {
1139
1249
  hermes: this.bridgeSnapshot("dispatched", {
@@ -1141,7 +1251,13 @@ No @ needed; the human is the only other party.
1141
1251
  runId,
1142
1252
  baseUrl: this.baseUrl,
1143
1253
  model: this.config.model
1144
- })
1254
+ }),
1255
+ // Surfaced to dispatch.ts so a subsequent reaper kill on this
1256
+ // task is reclassified as `awaiting_human_approval` rather than
1257
+ // `daemon_task_timeout`. Lives under a stable key (not under
1258
+ // `hermes.*`) so non-hermes adapters can adopt the same signal
1259
+ // later without bleeding hermes-specific metadata shape.
1260
+ ...sseResult.approvalRequested ? { approvalRequested: true } : {}
1145
1261
  }
1146
1262
  };
1147
1263
  } catch (err) {
@@ -1151,6 +1267,7 @@ No @ needed; the human is the only other party.
1151
1267
  );
1152
1268
  const categorized = categorizeDispatchError(err, task.signal);
1153
1269
  const bridgeKind = categorized.error?.code === "task_cancelled" ? "cancelled" : "failed";
1270
+ const approvalSignal = sseState.approvalRequested ? { approvalRequested: true } : {};
1154
1271
  return {
1155
1272
  ...categorized,
1156
1273
  metadata: {
@@ -1159,13 +1276,87 @@ No @ needed; the human is the only other party.
1159
1276
  baseUrl: this.baseUrl,
1160
1277
  model: this.config.model,
1161
1278
  ...bridgeKind === "failed" ? { error: err.message } : {}
1162
- })
1279
+ }),
1280
+ ...approvalSignal
1163
1281
  }
1164
1282
  };
1165
1283
  } finally {
1166
1284
  if (runId && this.currentRunId === runId) this.currentRunId = void 0;
1167
1285
  }
1168
1286
  }
1287
+ /**
1288
+ * 14b rev.3 §3.0.4 — Hermes multimodal path.
1289
+ *
1290
+ * Builds an OpenAI-shape chat/completions body where the user message
1291
+ * `content` is a multipart array (`{type:'text'}` + N × `{type:'image_url'}`).
1292
+ * Hermes `_normalize_multimodal_content` (api_server.py:170) collapses the
1293
+ * blocks into its native multimodal representation before routing into
1294
+ * the agent pipeline.
1295
+ *
1296
+ * `image_url.url` is the cdn URL when the daemon's reachability probe said
1297
+ * the upstream LLM can see it (D35 path A); otherwise it's a `data:` URI
1298
+ * carrying the base64 bytes the daemon pre-fetched (D35 path B fallback).
1299
+ * The adapter doesn't decide — `resolveAssetRefs` does, and the adapter
1300
+ * just shuttles `cdnUrl ?? data:base64` into the wire.
1301
+ */
1302
+ async dispatchMultimodalChat(task, instructions, imageRefs, startedAt) {
1303
+ const userContent = [{ type: "text", text: task.prompt }];
1304
+ for (const r of imageRefs) {
1305
+ const url = r.reachable === "cdn" && r.cdnUrl ? r.cdnUrl : r.base64 ? `data:${r.mime ?? "image/png"};base64,${r.base64}` : r.cdnUrl ?? "";
1306
+ if (!url) {
1307
+ userContent.push({
1308
+ type: "text",
1309
+ text: `[image attachment id=${r.assetId} mime=${r.mime ?? "unknown"} unavailable: cdn not reachable and bytes exceeded inline cap]`
1310
+ });
1311
+ continue;
1312
+ }
1313
+ userContent.push({ type: "image_url", image_url: { url, detail: "auto" } });
1314
+ }
1315
+ const messages = [];
1316
+ if (instructions) messages.push({ role: "system", content: instructions });
1317
+ messages.push({ role: "user", content: userContent });
1318
+ const res = await fetch(`${this.baseUrl}/v1/chat/completions`, {
1319
+ method: "POST",
1320
+ headers: {
1321
+ Authorization: `Bearer ${this.config.apiKey}`,
1322
+ "Content-Type": "application/json"
1323
+ },
1324
+ body: JSON.stringify({
1325
+ model: this.config.model,
1326
+ messages,
1327
+ stream: true
1328
+ }),
1329
+ signal: task.signal
1330
+ });
1331
+ if (!res.ok) {
1332
+ const body = await res.text().catch(() => "");
1333
+ return failure(res.status, body);
1334
+ }
1335
+ if (!res.body) {
1336
+ return {
1337
+ ok: false,
1338
+ error: {
1339
+ code: "adapter_dispatch_failed",
1340
+ message: "Hermes /v1/chat/completions returned no body"
1341
+ }
1342
+ };
1343
+ }
1344
+ const output = await consumeChatCompletionsSse(res.body, task);
1345
+ return {
1346
+ ok: true,
1347
+ output,
1348
+ metrics: { durationMs: Date.now() - startedAt },
1349
+ metadata: {
1350
+ hermes: this.bridgeSnapshot("dispatched", {
1351
+ endpoint: "/v1/chat/completions",
1352
+ multimodal: true,
1353
+ imageRefs: imageRefs.length,
1354
+ baseUrl: this.baseUrl,
1355
+ model: this.config.model
1356
+ })
1357
+ }
1358
+ };
1359
+ }
1169
1360
  async shutdown() {
1170
1361
  if (this.currentRunId) await this.stopRun(this.currentRunId);
1171
1362
  }
@@ -1187,6 +1378,25 @@ No @ needed; the human is the only other party.
1187
1378
  ]);
1188
1379
  return { ...kanbanPatch, ...goalsPatch };
1189
1380
  }
1381
+ installRoleTemplateSkill() {
1382
+ const roleTemplate = readPlainRecord(this.config.roleTemplate);
1383
+ const hermesConfig = readPlainRecord(roleTemplate?.hermesConfig);
1384
+ const agents = readNonEmptyString(hermesConfig?.agents);
1385
+ if (!agents) return;
1386
+ const slug = sanitizeRoleSkillSlug(readNonEmptyString(roleTemplate?.slug) ?? "template");
1387
+ const skillName = `${PRISMER_ROLE_SKILL_PREFIX}-${slug}`;
1388
+ const content = renderRoleTemplateSkill(skillName, agents, roleTemplate);
1389
+ try {
1390
+ const skillDir = join2(this.skillLoader.getSkillsRoot(), skillName);
1391
+ mkdirSync(skillDir, { recursive: true });
1392
+ writeFileSync(join2(skillDir, "SKILL.md"), content, "utf8");
1393
+ } catch (err) {
1394
+ process.stderr.write(
1395
+ `[hermes-adapter] failed to install role-template skill ${skillName} for ${this.profileName}: ${err.message}
1396
+ `
1397
+ );
1398
+ }
1399
+ }
1190
1400
  async prepareNativeKanbanPatch(task) {
1191
1401
  const sourceKind = typeof task.metadata?.sourceKind === "string" ? task.metadata.sourceKind : null;
1192
1402
  const parentTaskId = typeof task.metadata?.parentTaskId === "string" ? task.metadata.parentTaskId : null;
@@ -1295,6 +1505,7 @@ No @ needed; the human is the only other party.
1295
1505
  };
1296
1506
  }
1297
1507
  };
1508
+ APPROVAL_TOOL_NAME = "prismer.approval.request_human_approval";
1298
1509
  }
1299
1510
  });
1300
1511
 
@@ -1327,8 +1538,61 @@ var init_skill_loader3 = __esm({
1327
1538
 
1328
1539
  // src/adapters/openclaw/index.ts
1329
1540
  import { z as z4 } from "zod";
1541
+ import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync2 } from "fs";
1330
1542
  import { homedir as homedir2 } from "os";
1331
1543
  import { join as join3 } from "path";
1544
+ function readPlainRecord2(value) {
1545
+ return value && typeof value === "object" && !Array.isArray(value) ? value : null;
1546
+ }
1547
+ function readNonEmptyString2(value) {
1548
+ return typeof value === "string" && value.trim() ? value.trim() : null;
1549
+ }
1550
+ function sanitizeRoleSkillSlug2(value) {
1551
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 60) || "template";
1552
+ }
1553
+ function renderRoleTemplateSkill2(skillName, agents, roleTemplate) {
1554
+ const mcpServers = Array.isArray(roleTemplate?.mcpServers) ? roleTemplate.mcpServers : [];
1555
+ const tools = /* @__PURE__ */ new Set();
1556
+ for (const server of mcpServers) {
1557
+ const rec = readPlainRecord2(server);
1558
+ const allowlist = Array.isArray(rec?.toolsAllowlist) ? rec.toolsAllowlist : [];
1559
+ for (const tool of allowlist) {
1560
+ if (typeof tool === "string" && tool.trim()) tools.add(tool.trim());
1561
+ }
1562
+ }
1563
+ const allowedTools = [...tools].join(" ");
1564
+ const roleTemplateSlug = JSON.stringify(readNonEmptyString2(roleTemplate?.slug) ?? null);
1565
+ return `---
1566
+ name: ${skillName}
1567
+ description: Role-template operating playbook projected from Prismer RoleTemplate.
1568
+ ${allowedTools ? `allowed-tools: ${allowedTools}
1569
+ ` : ""}metadata:
1570
+ prismer:
1571
+ source: role-template
1572
+ roleTemplateSlug: ${roleTemplateSlug}
1573
+ ---
1574
+
1575
+ # Role Template Playbook
1576
+
1577
+ ${agents.trim()}
1578
+ `;
1579
+ }
1580
+ function pickResponsesSource(ref) {
1581
+ if (ref.reachable === "cdn" && ref.cdnUrl) {
1582
+ return { type: "url", url: ref.cdnUrl };
1583
+ }
1584
+ if (ref.base64) {
1585
+ return {
1586
+ type: "base64",
1587
+ data: ref.base64,
1588
+ media_type: ref.mime ?? "application/octet-stream"
1589
+ };
1590
+ }
1591
+ if (ref.cdnUrl) {
1592
+ return { type: "url", url: ref.cdnUrl };
1593
+ }
1594
+ return null;
1595
+ }
1332
1596
  function getOpenClawSkillsDir(profile) {
1333
1597
  const config = OpenClawProfileConfigSchema.parse(profile.config);
1334
1598
  return resolveOpenClawSkillsDir(profile, config);
@@ -1349,12 +1613,13 @@ async function checkHealth2(baseUrl, apiKey) {
1349
1613
  return false;
1350
1614
  }
1351
1615
  }
1352
- var OpenClawProfileConfigSchema, openclawAdapter, OpenClawService;
1616
+ var PRISMER_ROLE_SKILL_PREFIX2, OpenClawProfileConfigSchema, openclawAdapter, OpenClawService;
1353
1617
  var init_openclaw = __esm({
1354
1618
  "src/adapters/openclaw/index.ts"() {
1355
1619
  "use strict";
1356
1620
  init_contract();
1357
1621
  init_skill_loader3();
1622
+ PRISMER_ROLE_SKILL_PREFIX2 = "prismer-role";
1358
1623
  OpenClawProfileConfigSchema = z4.object({
1359
1624
  /** OpenClaw chat-completions HTTP port. */
1360
1625
  port: z4.number().int().min(1).max(65535).default(18789),
@@ -1399,23 +1664,25 @@ var init_openclaw = __esm({
1399
1664
  );
1400
1665
  }
1401
1666
  const skillsDir = resolveOpenClawSkillsDir(profile, config);
1402
- return new OpenClawService(baseUrl, config.apiKey, config.model, new OpenClawSkillLoader(skillsDir));
1667
+ return new OpenClawService(baseUrl, config.apiKey, config.model, config, new OpenClawSkillLoader(skillsDir));
1403
1668
  },
1404
1669
  async health() {
1405
1670
  return { available: true };
1406
1671
  }
1407
1672
  };
1408
1673
  OpenClawService = class {
1409
- constructor(baseUrl, apiKey, model, skillLoader) {
1674
+ constructor(baseUrl, apiKey, model, config, skillLoader) {
1410
1675
  this.baseUrl = baseUrl;
1411
1676
  this.apiKey = apiKey;
1412
1677
  this.model = model;
1678
+ this.config = config;
1413
1679
  this.skillLoader = skillLoader;
1414
1680
  this.id = baseUrl;
1415
1681
  }
1416
1682
  baseUrl;
1417
1683
  apiKey;
1418
1684
  model;
1685
+ config;
1419
1686
  skillLoader;
1420
1687
  id;
1421
1688
  async healthy() {
@@ -1424,17 +1691,47 @@ var init_openclaw = __esm({
1424
1691
  async dispatch(task) {
1425
1692
  const startedAt = Date.now();
1426
1693
  const systemPrompt = typeof task.metadata?.systemPrompt === "string" ? task.metadata.systemPrompt : void 0;
1694
+ this.installRoleTemplateSkill();
1427
1695
  const skillPrompt = await this.skillLoader.loadSystemPromptFragment().catch((err) => {
1428
1696
  process.stderr.write(`[openclaw-adapter] failed to read skills: ${err.message}
1429
1697
  `);
1430
1698
  return void 0;
1431
1699
  });
1432
1700
  const systemContent = [systemPrompt, skillPrompt].filter(Boolean).join("\n\n");
1433
- const messages = [];
1434
- if (systemContent) messages.push({ role: "system", content: systemContent });
1435
- messages.push({ role: "user", content: task.prompt });
1701
+ const refs = task.assetRefs ?? [];
1702
+ const imageRefs = refs.filter((r) => r.mime?.startsWith("image/"));
1703
+ const fileRefs = refs.filter((r) => r.mime && !r.mime.startsWith("image/"));
1704
+ const inputContent = [
1705
+ { type: "input_text", text: task.prompt }
1706
+ ];
1707
+ for (const r of imageRefs) {
1708
+ const source = pickResponsesSource(r);
1709
+ if (!source) {
1710
+ inputContent.push({
1711
+ type: "input_text",
1712
+ text: `[image attachment id=${r.assetId} unavailable: cdn unreachable and bytes exceeded inline cap]`
1713
+ });
1714
+ continue;
1715
+ }
1716
+ inputContent.push({ type: "input_image", source });
1717
+ }
1718
+ for (const r of fileRefs) {
1719
+ const source = pickResponsesSource(r);
1720
+ if (!source) {
1721
+ inputContent.push({
1722
+ type: "input_text",
1723
+ text: `[file attachment id=${r.assetId} mime=${r.mime ?? "unknown"} unavailable]`
1724
+ });
1725
+ continue;
1726
+ }
1727
+ if (r.filename && source.type === "base64") {
1728
+ source.filename = r.filename;
1729
+ }
1730
+ inputContent.push({ type: "input_file", source });
1731
+ }
1732
+ const input = [{ type: "message", role: "user", content: inputContent }];
1436
1733
  try {
1437
- const res = await fetch(`${this.baseUrl}/v1/chat/completions`, {
1734
+ const res = await fetch(`${this.baseUrl}/v1/responses`, {
1438
1735
  method: "POST",
1439
1736
  headers: {
1440
1737
  Authorization: `Bearer ${this.apiKey}`,
@@ -1442,7 +1739,11 @@ var init_openclaw = __esm({
1442
1739
  },
1443
1740
  body: JSON.stringify({
1444
1741
  model: this.model,
1445
- messages,
1742
+ input,
1743
+ // OpenClaw `/v1/responses` accepts top-level `instructions` for
1744
+ // the system prompt — keeping it out of the user message
1745
+ // preserves cleanish channel-context separation.
1746
+ ...systemContent ? { instructions: systemContent } : {},
1446
1747
  stream: false
1447
1748
  }),
1448
1749
  signal: task.signal
@@ -1458,7 +1759,21 @@ var init_openclaw = __esm({
1458
1759
  };
1459
1760
  }
1460
1761
  const json = await res.json();
1461
- let output = json.choices?.[0]?.message?.content ?? "";
1762
+ let output = "";
1763
+ if (Array.isArray(json.output)) {
1764
+ for (const item of json.output) {
1765
+ if (item?.type === "message" && Array.isArray(item.content)) {
1766
+ for (const part of item.content) {
1767
+ if (part?.type === "output_text" && typeof part.text === "string") {
1768
+ output += part.text;
1769
+ }
1770
+ }
1771
+ }
1772
+ }
1773
+ }
1774
+ if (!output) {
1775
+ output = json.choices?.[0]?.message?.content ?? "";
1776
+ }
1462
1777
  const ERR_PREFIX_RE = /^Cannot read properties of undefined \(reading '[^']+'\)\s*\n+/;
1463
1778
  if (ERR_PREFIX_RE.test(output)) {
1464
1779
  process.stderr.write(
@@ -1476,6 +1791,25 @@ var init_openclaw = __esm({
1476
1791
  return categorizeDispatchError(err, task.signal);
1477
1792
  }
1478
1793
  }
1794
+ installRoleTemplateSkill() {
1795
+ const roleTemplate = readPlainRecord2(this.config.roleTemplate);
1796
+ const openclawConfig = readPlainRecord2(roleTemplate?.openclawConfig);
1797
+ const agents = readNonEmptyString2(openclawConfig?.agents);
1798
+ if (!agents) return;
1799
+ const slug = sanitizeRoleSkillSlug2(readNonEmptyString2(roleTemplate?.slug) ?? "template");
1800
+ const skillName = `${PRISMER_ROLE_SKILL_PREFIX2}-${slug}`;
1801
+ const content = renderRoleTemplateSkill2(skillName, agents, roleTemplate);
1802
+ try {
1803
+ const skillDir = join3(this.skillLoader.getSkillsRoot(), skillName);
1804
+ mkdirSync2(skillDir, { recursive: true });
1805
+ writeFileSync2(join3(skillDir, "SKILL.md"), content, "utf8");
1806
+ } catch (err) {
1807
+ process.stderr.write(
1808
+ `[openclaw-adapter] failed to install role-template skill ${skillName}: ${err.message}
1809
+ `
1810
+ );
1811
+ }
1812
+ }
1479
1813
  };
1480
1814
  }
1481
1815
  });
@@ -1911,7 +2245,7 @@ var require_package = __commonJS({
1911
2245
  "package.json"(exports, module) {
1912
2246
  module.exports = {
1913
2247
  name: "@prismer/runtime",
1914
- version: "2.0.3",
2248
+ version: "2.0.5",
1915
2249
  description: "Prismer Cloud daemon runtime \u2014 TS-only adapter host for hosted IM agents",
1916
2250
  type: "module",
1917
2251
  main: "dist/index.js",
@@ -1945,6 +2279,7 @@ var require_package = __commonJS({
1945
2279
  },
1946
2280
  dependencies: {
1947
2281
  "@iarna/toml": "^2.2.5",
2282
+ "@modelcontextprotocol/sdk": "^1.29.0",
1948
2283
  "better-sqlite3": "^11.8.0",
1949
2284
  commander: "^12.1.0",
1950
2285
  qrcode: "^1.5.4",
@@ -2006,7 +2341,7 @@ __export(util_exports, {
2006
2341
  warn: () => warn,
2007
2342
  writePidFile: () => writePidFile
2008
2343
  });
2009
- import { existsSync as existsSync4, readFileSync as readFileSync4, unlinkSync, writeFileSync as writeFileSync3 } from "fs";
2344
+ import { existsSync as existsSync4, readFileSync as readFileSync4, unlinkSync, writeFileSync as writeFileSync4 } from "fs";
2010
2345
  import { join as join5 } from "path";
2011
2346
  function color(kind, text) {
2012
2347
  if (process.env.NO_COLOR === "1" || process.env.NO_COLOR === "true") return text;
@@ -2104,7 +2439,7 @@ function pidFilePath(paths) {
2104
2439
  return join5(paths.root, "daemon.pid");
2105
2440
  }
2106
2441
  function writePidFile(paths, pid) {
2107
- writeFileSync3(pidFilePath(paths), `${pid}
2442
+ writeFileSync4(pidFilePath(paths), `${pid}
2108
2443
  `, "utf8");
2109
2444
  }
2110
2445
  function readPidFile(paths) {
@@ -2203,11 +2538,39 @@ async function syncAllAgentSkills(profiles, cloud, options = {}) {
2203
2538
  }
2204
2539
  async function syncInstalledSkillsForDispatch(profile, agentImUserId, cloud, signal) {
2205
2540
  if (!agentImUserId) return { synced: 0, skipped: 0, unchanged: 0 };
2206
- const data = await cloud.get(
2541
+ let data = await cloud.get(
2207
2542
  `/api/im/skills/installed?agentId=${encodeURIComponent(agentImUserId)}`,
2208
2543
  { signal }
2209
2544
  );
2210
- const entries = normalizeInstalledSkills(data);
2545
+ let entries = normalizeInstalledSkills(data);
2546
+ if (entries.length === 0 && (profile.adapterName === "hermes" || profile.adapterName === "openclaw")) {
2547
+ try {
2548
+ const backfill = await cloud.request(
2549
+ "POST",
2550
+ `/api/im/agents/${encodeURIComponent(agentImUserId)}/skills/install-builtins`,
2551
+ { body: {}, signal }
2552
+ );
2553
+ if (backfill.ok && backfill.data && typeof backfill.data === "object" && "data" in backfill.data) {
2554
+ const installed = backfill.data.data?.installed ?? 0;
2555
+ if (installed > 0) {
2556
+ process.stdout.write(
2557
+ `[daemon] skill sync: backfilled ${installed} built-in skills for agent ${agentImUserId.slice(-8)}
2558
+ `
2559
+ );
2560
+ data = await cloud.get(
2561
+ `/api/im/skills/installed?agentId=${encodeURIComponent(agentImUserId)}`,
2562
+ { signal }
2563
+ );
2564
+ entries = normalizeInstalledSkills(data);
2565
+ }
2566
+ }
2567
+ } catch (err) {
2568
+ process.stderr.write(
2569
+ `[daemon] skill sync: backfill skipped for ${agentImUserId.slice(-8)}: ${err.message}
2570
+ `
2571
+ );
2572
+ }
2573
+ }
2211
2574
  const skillsRoot = resolveSkillsRoot(profile);
2212
2575
  if (!skillsRoot) return { synced: 0, skipped: 0, unchanged: 0 };
2213
2576
  let synced = 0;
@@ -2526,10 +2889,10 @@ import { spawnSync as spawnSync2 } from "child_process";
2526
2889
  import {
2527
2890
  copyFileSync,
2528
2891
  existsSync as existsSync5,
2529
- mkdirSync as mkdirSync3,
2892
+ mkdirSync as mkdirSync4,
2530
2893
  readFileSync as readFileSync5,
2531
2894
  statSync,
2532
- writeFileSync as writeFileSync4
2895
+ writeFileSync as writeFileSync5
2533
2896
  } from "fs";
2534
2897
  import { homedir as homedir4 } from "os";
2535
2898
  import { dirname as dirname4, join as join6 } from "path";
@@ -3029,7 +3392,7 @@ init_openclaw();
3029
3392
 
3030
3393
  // src/config.ts
3031
3394
  import * as TOML from "@iarna/toml";
3032
- import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
3395
+ import { existsSync as existsSync2, mkdirSync as mkdirSync3, readFileSync as readFileSync2, writeFileSync as writeFileSync3 } from "fs";
3033
3396
  import { homedir as homedir3 } from "os";
3034
3397
  import { dirname as dirname2, join as join4 } from "path";
3035
3398
  import { z as z5 } from "zod";
@@ -3096,13 +3459,13 @@ function loadConfig(paths = resolvePaths()) {
3096
3459
  }
3097
3460
  function saveConfig(config, paths = resolvePaths()) {
3098
3461
  if (!existsSync2(paths.root)) {
3099
- mkdirSync2(paths.root, { recursive: true });
3462
+ mkdirSync3(paths.root, { recursive: true });
3100
3463
  }
3101
3464
  if (!existsSync2(dirname2(paths.configFile))) {
3102
- mkdirSync2(dirname2(paths.configFile), { recursive: true });
3465
+ mkdirSync3(dirname2(paths.configFile), { recursive: true });
3103
3466
  }
3104
3467
  ConfigSchema.parse(config);
3105
- writeFileSync2(paths.configFile, TOML.stringify(config), "utf8");
3468
+ writeFileSync3(paths.configFile, TOML.stringify(config), "utf8");
3106
3469
  }
3107
3470
  function deriveWsUrl(httpBase) {
3108
3471
  const u = new URL(httpBase);
@@ -3365,15 +3728,15 @@ function runHooks(spec, opts) {
3365
3728
  }
3366
3729
  let backup;
3367
3730
  if (planned.kind === "directory") {
3368
- mkdirSync3(planned.path, { recursive: true });
3731
+ mkdirSync4(planned.path, { recursive: true });
3369
3732
  const markerPath = join6(planned.path, "prismer.json");
3370
3733
  backup = backupIfExists(markerPath);
3371
- writeFileSync4(markerPath, JSON.stringify(prismerHookMarker(spec), null, 2) + "\n", "utf8");
3734
+ writeFileSync5(markerPath, JSON.stringify(prismerHookMarker(spec), null, 2) + "\n", "utf8");
3372
3735
  } else {
3373
- mkdirSync3(dirname4(planned.path), { recursive: true });
3736
+ mkdirSync4(dirname4(planned.path), { recursive: true });
3374
3737
  backup = backupIfExists(planned.path);
3375
3738
  const merged = mergeHookJson(planned.path, spec);
3376
- writeFileSync4(planned.path, JSON.stringify(merged, null, 2) + "\n", "utf8");
3739
+ writeFileSync5(planned.path, JSON.stringify(merged, null, 2) + "\n", "utf8");
3377
3740
  }
3378
3741
  return {
3379
3742
  ok: true,
@@ -3771,7 +4134,7 @@ function mapStatusToCode(status) {
3771
4134
 
3772
4135
  // src/sync/store.ts
3773
4136
  import Database2 from "better-sqlite3";
3774
- import { existsSync as existsSync6, mkdirSync as mkdirSync4 } from "fs";
4137
+ import { existsSync as existsSync6, mkdirSync as mkdirSync5 } from "fs";
3775
4138
  import { dirname as dirname5 } from "path";
3776
4139
  var MIGRATIONS = [
3777
4140
  {
@@ -3898,6 +4261,36 @@ var MIGRATIONS = [
3898
4261
  CREATE INDEX IF NOT EXISTS idx_asset_meta_seq
3899
4262
  ON asset_metadata_index(workspace_id, asset_index_seq);
3900
4263
  `
4264
+ },
4265
+ {
4266
+ version: 4,
4267
+ up: `
4268
+ -- Wave 4 / W3 \xA74.3 \u2014 pending dispatch replies for crash recovery.
4269
+ --
4270
+ -- The two-phase reply protocol writes a row here BEFORE calling
4271
+ -- POST /dispatch/:taskId/prepare so that a crash between prepare
4272
+ -- and commit leaves a recoverable trail. On daemon cold-start, any
4273
+ -- row with status='prepared' triggers an idempotent commit retry.
4274
+ --
4275
+ -- The server-side cutoff for orphaned 'prepared' rows is 1h
4276
+ -- (PREPARED_REPLY_ORPHAN_MS in dispatch-reply.service.ts); rows
4277
+ -- older than that will fail commit with DISPATCH_REPLY_INVALID_STATE
4278
+ -- and must be reported to the user as 'dispatch lost'.
4279
+ CREATE TABLE IF NOT EXISTS pending_dispatch_replies (
4280
+ idempotency_key TEXT PRIMARY KEY,
4281
+ task_id TEXT NOT NULL,
4282
+ reply_id TEXT,
4283
+ status TEXT NOT NULL DEFAULT 'pending',
4284
+ payload_json TEXT NOT NULL,
4285
+ prepared_at INTEGER,
4286
+ created_at INTEGER NOT NULL,
4287
+ last_attempt_at INTEGER,
4288
+ attempt_count INTEGER NOT NULL DEFAULT 0,
4289
+ last_error TEXT
4290
+ );
4291
+ CREATE INDEX IF NOT EXISTS idx_pending_reply_task ON pending_dispatch_replies (task_id);
4292
+ CREATE INDEX IF NOT EXISTS idx_pending_reply_status ON pending_dispatch_replies (status, created_at);
4293
+ `
3901
4294
  }
3902
4295
  ];
3903
4296
  function runSql(db, sql) {
@@ -3905,7 +4298,7 @@ function runSql(db, sql) {
3905
4298
  }
3906
4299
  function openLocalDb(path9) {
3907
4300
  if (path9 !== ":memory:" && !existsSync6(dirname5(path9))) {
3908
- mkdirSync4(dirname5(path9), { recursive: true });
4301
+ mkdirSync5(dirname5(path9), { recursive: true });
3909
4302
  }
3910
4303
  const db = new Database2(path9);
3911
4304
  db.pragma("journal_mode = WAL");
@@ -4227,8 +4620,74 @@ function buildAgentCommand() {
4227
4620
  }
4228
4621
  getUI().ok("Skill install requested", `agent=${opts.agent} skill=${slugOrId}`);
4229
4622
  }, { code: "agent_install_skill_failed" }));
4623
+ 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) => {
4624
+ const cloud = makeCloudClient();
4625
+ const data = await requestEnvelope(cloud, "POST", `/api/im/agents/${encodeURIComponent(imUserId)}/snapshot`, {
4626
+ includeMemory: Boolean(opts.includeMemory),
4627
+ label: opts.label
4628
+ }, "agent_snapshot_failed");
4629
+ if (opts.json) {
4630
+ printJson(data);
4631
+ return;
4632
+ }
4633
+ const rec = data;
4634
+ getUI().ok("Snapshot created", String(rec.id ?? "<unknown>"));
4635
+ getUI().line(`agent=${String(rec.agentImUserId ?? imUserId)} memory=${rec.includeMemory ? "included" : "stripped"}`);
4636
+ }, { code: "agent_snapshot_failed" }));
4637
+ 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) => {
4638
+ const cloud = makeCloudClient();
4639
+ const data = await requestEnvelope(cloud, "POST", `/api/im/agents/${encodeURIComponent(imUserId)}/publish`, {
4640
+ slug: opts.slug,
4641
+ version: opts.version,
4642
+ license: opts.license,
4643
+ metadata: {
4644
+ ...opts.title ? { title: opts.title } : {},
4645
+ ...opts.description ? { description: opts.description } : {}
4646
+ },
4647
+ stripMemory: true
4648
+ }, "agent_publish_failed");
4649
+ if (opts.json) {
4650
+ printJson(data);
4651
+ return;
4652
+ }
4653
+ const rec = data;
4654
+ getUI().ok("Agent Pack published", `${String(rec.slug ?? opts.slug)}@${String(rec.version ?? opts.version ?? "1.0.0")}`);
4655
+ getUI().line(`package=${String(rec.id ?? "<unknown>")}`);
4656
+ }, { code: "agent_publish_failed" }));
4657
+ 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) => {
4658
+ const cloud = makeCloudClient();
4659
+ const data = await requestEnvelope(cloud, "POST", `/api/im/agent-packs/${encodeURIComponent(packIdOrSlug)}/fork`, {
4660
+ targetWorkspaceId: opts.workspaceId,
4661
+ displayName: opts.displayName
4662
+ }, "agent_fork_failed");
4663
+ if (opts.json) {
4664
+ printJson(data);
4665
+ return;
4666
+ }
4667
+ const rec = data;
4668
+ getUI().ok("Agent Pack forked", `newAgent=${String(rec.newImUserId ?? "<unknown>")}`);
4669
+ getUI().line(`did=${String(rec.newDid ?? "not issued")}`);
4670
+ }, { code: "agent_fork_failed" }));
4230
4671
  return cmd;
4231
4672
  }
4673
+ function makeCloudClient() {
4674
+ const cfg = loadConfig(resolvePaths());
4675
+ return new CloudClient({ baseUrl: cfg.cloud_api_base, apiKey: cfg.api_key });
4676
+ }
4677
+ async function requestEnvelope(cloud, method, path9, body, code) {
4678
+ const res = await cloud.request(
4679
+ method,
4680
+ path9,
4681
+ body === void 0 ? void 0 : { body }
4682
+ );
4683
+ if (!res.ok || res.data?.ok === false) {
4684
+ const message = res.error?.message ?? res.data?.error?.message ?? "request failed";
4685
+ exitWithError(`${path9} failed (${res.status === 0 ? "network error" : `HTTP ${res.status}`}): ${message}`, {
4686
+ code: res.error?.code ?? res.data?.error?.code ?? code
4687
+ });
4688
+ }
4689
+ return res.data && typeof res.data === "object" && "data" in res.data ? res.data.data : res.data;
4690
+ }
4232
4691
  function readLocalAgents(localDb) {
4233
4692
  const db = openLocalDb(localDb);
4234
4693
  try {
@@ -4333,13 +4792,13 @@ function whichBinary2(bin) {
4333
4792
  // src/cli/commands/asset.ts
4334
4793
  import { createHash as createHash2 } from "crypto";
4335
4794
  import { Command as Command3 } from "commander";
4336
- import { existsSync as existsSync10, lstatSync, readdirSync, readFileSync as readFileSync9, writeFileSync as writeFileSync8 } from "fs";
4795
+ import { existsSync as existsSync10, lstatSync, readdirSync, readFileSync as readFileSync9, writeFileSync as writeFileSync9 } from "fs";
4337
4796
  import { basename, dirname as dirname7, join as join10, relative } from "path";
4338
4797
 
4339
4798
  // src/asset-cache.ts
4340
4799
  import { createHash } from "crypto";
4341
4800
  import { promises as dnsp } from "dns";
4342
- import { existsSync as existsSync7, mkdirSync as mkdirSync5, readFileSync as readFileSync6, renameSync, statSync as statSync2, unlinkSync as unlinkSync2, writeFileSync as writeFileSync5 } from "fs";
4801
+ import { existsSync as existsSync7, mkdirSync as mkdirSync6, readFileSync as readFileSync6, renameSync, statSync as statSync2, unlinkSync as unlinkSync2, writeFileSync as writeFileSync6 } from "fs";
4343
4802
  import { isIP, isIPv4, isIPv6 } from "net";
4344
4803
  import { dirname as dirname6, join as join7 } from "path";
4345
4804
  import { Agent as UndiciAgent } from "undici";
@@ -4365,7 +4824,7 @@ var AssetCache = class {
4365
4824
  this.cacheDir = opts.cacheDir;
4366
4825
  this.maxBytes = opts.maxBytes ?? 5 * 1024 * 1024 * 1024;
4367
4826
  if (!existsSync7(this.cacheDir)) {
4368
- mkdirSync5(this.cacheDir, { recursive: true });
4827
+ mkdirSync6(this.cacheDir, { recursive: true });
4369
4828
  }
4370
4829
  }
4371
4830
  /** Local file path for a content hash. Files split by first 2 chars for fs scaling. */
@@ -4440,10 +4899,10 @@ var AssetCache = class {
4440
4899
  }
4441
4900
  const localPath = this.pathFor(hash);
4442
4901
  if (!existsSync7(dirname6(localPath))) {
4443
- mkdirSync5(dirname6(localPath), { recursive: true });
4902
+ mkdirSync6(dirname6(localPath), { recursive: true });
4444
4903
  }
4445
4904
  const tmpPath = `${localPath}.${process.pid}.${Date.now()}.tmp`;
4446
- writeFileSync5(tmpPath, buf);
4905
+ writeFileSync6(tmpPath, buf);
4447
4906
  renameSync(tmpPath, localPath);
4448
4907
  const mime = res.headers.get("content-type");
4449
4908
  const now = Date.now();
@@ -4504,10 +4963,10 @@ var AssetCache = class {
4504
4963
  }
4505
4964
  const localPath = this.pathFor(hash);
4506
4965
  if (!existsSync7(dirname6(localPath))) {
4507
- mkdirSync5(dirname6(localPath), { recursive: true });
4966
+ mkdirSync6(dirname6(localPath), { recursive: true });
4508
4967
  }
4509
4968
  const tmpPath = `${localPath}.${process.pid}.${Date.now()}.tmp`;
4510
- writeFileSync5(tmpPath, body);
4969
+ writeFileSync6(tmpPath, body);
4511
4970
  renameSync(tmpPath, localPath);
4512
4971
  const now = Date.now();
4513
4972
  this.db.prepare(
@@ -4761,7 +5220,7 @@ async function fetchUrlWithGuards(rawUrl, opts) {
4761
5220
  }
4762
5221
 
4763
5222
  // src/daemon/asset/metadata-index.ts
4764
- import { existsSync as existsSync8, mkdirSync as mkdirSync6, readFileSync as readFileSync7, writeFileSync as writeFileSync6 } from "fs";
5223
+ import { existsSync as existsSync8, mkdirSync as mkdirSync7, readFileSync as readFileSync7, writeFileSync as writeFileSync7 } from "fs";
4765
5224
  import { join as join8 } from "path";
4766
5225
  var DEFAULT_LIMIT = 8;
4767
5226
  var PULL_PAGE_SIZE = 500;
@@ -4791,7 +5250,7 @@ var AssetMetadataIndex = class {
4791
5250
  this.cloud = opts.cloud;
4792
5251
  this.workspaceId = opts.workspaceId;
4793
5252
  if (!existsSync8(opts.workspaceStateDir)) {
4794
- mkdirSync6(opts.workspaceStateDir, { recursive: true });
5253
+ mkdirSync7(opts.workspaceStateDir, { recursive: true });
4795
5254
  }
4796
5255
  this.cursorPath = join8(opts.workspaceStateDir, "asset-metadata-cursor.json");
4797
5256
  }
@@ -4812,7 +5271,7 @@ var AssetMetadataIndex = class {
4812
5271
  cursor,
4813
5272
  writtenAt: Date.now()
4814
5273
  };
4815
- writeFileSync6(this.cursorPath, JSON.stringify(payload, null, 2));
5274
+ writeFileSync7(this.cursorPath, JSON.stringify(payload, null, 2));
4816
5275
  }
4817
5276
  /**
4818
5277
  * Pull incremental asset metadata changes since the persisted cursor and
@@ -4929,7 +5388,7 @@ var AssetMetadataIndex = class {
4929
5388
  };
4930
5389
 
4931
5390
  // src/daemon/asset/mirror.ts
4932
- import { existsSync as existsSync9, mkdirSync as mkdirSync7, readFileSync as readFileSync8, writeFileSync as writeFileSync7 } from "fs";
5391
+ import { existsSync as existsSync9, mkdirSync as mkdirSync8, readFileSync as readFileSync8, writeFileSync as writeFileSync8 } from "fs";
4933
5392
  import { join as join9 } from "path";
4934
5393
  function rowToBinding(row) {
4935
5394
  return {
@@ -4952,7 +5411,7 @@ var WorkspaceMirror = class {
4952
5411
  this.cloud = opts.cloud;
4953
5412
  this.workspaceId = opts.workspaceId;
4954
5413
  if (!existsSync9(opts.workspaceStateDir)) {
4955
- mkdirSync7(opts.workspaceStateDir, { recursive: true });
5414
+ mkdirSync8(opts.workspaceStateDir, { recursive: true });
4956
5415
  }
4957
5416
  this.cursorPath = join9(opts.workspaceStateDir, "asset-cursor.json");
4958
5417
  }
@@ -4973,7 +5432,7 @@ var WorkspaceMirror = class {
4973
5432
  cursor,
4974
5433
  writtenAt: Date.now()
4975
5434
  };
4976
- writeFileSync7(this.cursorPath, JSON.stringify(payload, null, 2));
5435
+ writeFileSync8(this.cursorPath, JSON.stringify(payload, null, 2));
4977
5436
  }
4978
5437
  /**
4979
5438
  * Pull workspace_file changes since the persisted cursor and upsert into
@@ -5411,7 +5870,7 @@ async function downloadAsset(assetId, outPath) {
5411
5870
  exitWithError(`asset download failed (${res.status}): ${errorMessage(body)}`);
5412
5871
  }
5413
5872
  const bytes = Buffer.from(await res.arrayBuffer());
5414
- writeFileSync8(outPath, bytes);
5873
+ writeFileSync9(outPath, bytes);
5415
5874
  }
5416
5875
  function parseMetadata(raw) {
5417
5876
  if (!raw) return {};
@@ -6058,8 +6517,8 @@ function parsePositiveInt3(value) {
6058
6517
  // src/cli/commands/daemon.ts
6059
6518
  import { Command as Command8 } from "commander";
6060
6519
  import { spawn as spawn5 } from "child_process";
6061
- import { createReadStream, existsSync as existsSync14, mkdirSync as mkdirSync9, openSync as openSync2, statSync as statSync4 } from "fs";
6062
- import { setTimeout as sleep2 } from "timers/promises";
6520
+ import { createReadStream, existsSync as existsSync14, mkdirSync as mkdirSync10, openSync as openSync2, statSync as statSync4 } from "fs";
6521
+ import { setTimeout as sleep3 } from "timers/promises";
6063
6522
  import { join as join18 } from "path";
6064
6523
 
6065
6524
  // src/daemon/runner.ts
@@ -6067,7 +6526,7 @@ import { EventEmitter as EventEmitter3 } from "events";
6067
6526
  import { createRequire as createRequire2 } from "module";
6068
6527
  import { existsSync as existsSync13, readFileSync as readFileSync11 } from "fs";
6069
6528
  import { mkdir } from "fs/promises";
6070
- import { homedir as homedir7, platform } from "os";
6529
+ import { homedir as homedir8, platform } from "os";
6071
6530
  import { join as join17 } from "path";
6072
6531
 
6073
6532
  // src/adapters/registry.ts
@@ -6548,6 +7007,262 @@ function createLogger(tag) {
6548
7007
  import { readFileSync as readFileSync10, promises as fsp3 } from "fs";
6549
7008
  import * as path2 from "path";
6550
7009
  init_skill_sync();
7010
+
7011
+ // src/daemon/task-heartbeat.ts
7012
+ var TaskHeartbeat = class {
7013
+ constructor(opts) {
7014
+ this.opts = opts;
7015
+ this.intervalMs = opts.intervalMs ?? 15e3;
7016
+ this.maxRetries = opts.maxRetries ?? 3;
7017
+ this.retryBackoffMs = opts.retryBackoffMs ?? 100;
7018
+ this.now = opts.now ?? Date.now;
7019
+ }
7020
+ opts;
7021
+ timer;
7022
+ version = 0;
7023
+ taskId;
7024
+ getPhase;
7025
+ /** Tracks the last step time pushed via touchStep() — informational. */
7026
+ lastStepAt;
7027
+ intervalMs;
7028
+ maxRetries;
7029
+ retryBackoffMs;
7030
+ now;
7031
+ /**
7032
+ * Begin heartbeating for `taskId`. `getPhase` is invoked at every tick to
7033
+ * pick up the live phase string — adapters can mutate their phase state
7034
+ * freely and the heartbeat will surface it on the next 15s boundary.
7035
+ *
7036
+ * Calling start() twice on the same instance replaces the active task
7037
+ * (previous timer is cleared). Returns silently when already started for
7038
+ * the same taskId.
7039
+ */
7040
+ start(taskId, getPhase) {
7041
+ if (this.taskId === taskId && this.timer) return;
7042
+ this.stop();
7043
+ this.taskId = taskId;
7044
+ this.getPhase = getPhase;
7045
+ this.timer = setInterval(() => {
7046
+ void this.push();
7047
+ }, this.intervalMs);
7048
+ this.timer.unref?.();
7049
+ }
7050
+ /**
7051
+ * Stop the heartbeat loop. Idempotent.
7052
+ */
7053
+ stop() {
7054
+ if (this.timer) {
7055
+ clearInterval(this.timer);
7056
+ this.timer = void 0;
7057
+ }
7058
+ this.taskId = void 0;
7059
+ this.getPhase = void 0;
7060
+ }
7061
+ /**
7062
+ * Phase transition — push immediately AND update what the timer-driven
7063
+ * tick will report next time. Adapter calls this whenever the lifecycle
7064
+ * advances (thinking → tool_use → reasoning → responding → ...).
7065
+ *
7066
+ * No-op when no task is active.
7067
+ */
7068
+ setPhase(phase) {
7069
+ if (!this.taskId) return;
7070
+ this.getPhase = () => phase;
7071
+ void this.push();
7072
+ }
7073
+ /**
7074
+ * Mark "I just made observable progress." Updates lastStepAt; next push
7075
+ * (timer-driven or setPhase-driven) carries it. Lightweight — no immediate
7076
+ * send. Adapters call this around tool calls / token boundaries so the
7077
+ * cloud reaper can distinguish "alive but slow" from "truly stuck".
7078
+ */
7079
+ touchStep() {
7080
+ this.lastStepAt = this.now();
7081
+ }
7082
+ /**
7083
+ * Manually trigger one heartbeat send (does not affect the timer). Mainly
7084
+ * useful for tests that don't want to await a 15s tick. Returns the
7085
+ * heartbeatVersion that was sent, or undefined if no task is active.
7086
+ */
7087
+ async forceTick() {
7088
+ if (!this.taskId) return void 0;
7089
+ return this.push();
7090
+ }
7091
+ /** Test helper: current heartbeatVersion (last value sent, may be 0 before first tick). */
7092
+ get currentVersion() {
7093
+ return this.version;
7094
+ }
7095
+ async push() {
7096
+ if (!this.taskId || !this.getPhase) return void 0;
7097
+ const taskId = this.taskId;
7098
+ const phase = this.getPhase();
7099
+ const heartbeatVersion = ++this.version;
7100
+ const payload = {
7101
+ taskId,
7102
+ heartbeatVersion,
7103
+ currentPhase: phase,
7104
+ ...this.lastStepAt !== void 0 ? { lastStepAt: this.lastStepAt } : {}
7105
+ };
7106
+ const msg = envelope("task.heartbeat", payload);
7107
+ let lastErr;
7108
+ for (let attempt = 0; attempt <= this.maxRetries; attempt += 1) {
7109
+ try {
7110
+ if (this.opts.ws.isOpen && !this.opts.ws.isOpen()) {
7111
+ return heartbeatVersion;
7112
+ }
7113
+ this.opts.ws.send(msg);
7114
+ return heartbeatVersion;
7115
+ } catch (err) {
7116
+ lastErr = err instanceof Error ? err : new Error(String(err));
7117
+ if (attempt < this.maxRetries) {
7118
+ const backoff = this.retryBackoffMs * Math.pow(2, attempt);
7119
+ await sleep(backoff);
7120
+ }
7121
+ }
7122
+ }
7123
+ if (lastErr) {
7124
+ try {
7125
+ this.opts.onGiveUp?.(taskId, lastErr);
7126
+ } catch {
7127
+ }
7128
+ }
7129
+ return heartbeatVersion;
7130
+ }
7131
+ };
7132
+ function sleep(ms) {
7133
+ return new Promise((resolve3) => {
7134
+ const handle = setTimeout(resolve3, ms);
7135
+ handle.unref?.();
7136
+ });
7137
+ }
7138
+
7139
+ // src/daemon/step-recorder.ts
7140
+ var StepRecorder = class {
7141
+ constructor(opts) {
7142
+ this.opts = opts;
7143
+ this.reasoningThrottleMs = opts.reasoningThrottleMs ?? 500;
7144
+ this.now = opts.now ?? Date.now;
7145
+ }
7146
+ opts;
7147
+ seq = 0;
7148
+ reasoningBuf = { texts: [], firstAt: 0 };
7149
+ reasoningTimer;
7150
+ reasoningThrottleMs;
7151
+ now;
7152
+ /** Push a phase transition step. Immediate (no throttle). */
7153
+ recordPhaseChange(phase) {
7154
+ this.emit("phase_change", { phase });
7155
+ }
7156
+ /**
7157
+ * Push a tool-call step. Immediate.
7158
+ *
7159
+ * `toolCallId` is the daemon-side correlation id used to pair with the
7160
+ * subsequent recordToolResult call. Free-form; adapter decides.
7161
+ */
7162
+ recordToolCall(toolName, input, toolCallId) {
7163
+ this.emit("tool_call", {
7164
+ toolName,
7165
+ inputSummary: summarize2(input),
7166
+ ...toolCallId ? { toolCallId } : {}
7167
+ });
7168
+ }
7169
+ /** Push a tool-result step. Immediate. */
7170
+ recordToolResult(toolCallId, output) {
7171
+ this.emit("tool_result", {
7172
+ toolCallId,
7173
+ outputSummary: summarize2(output)
7174
+ });
7175
+ }
7176
+ /**
7177
+ * Buffer a reasoning chunk for batched dispatch. Multiple chunks within
7178
+ * `reasoningThrottleMs` are concatenated and emitted as a single
7179
+ * `reasoning_chunk` step when the timer fires.
7180
+ */
7181
+ recordReasoningChunk(text) {
7182
+ if (!text) return;
7183
+ if (this.reasoningBuf.texts.length === 0) {
7184
+ this.reasoningBuf.firstAt = this.now();
7185
+ }
7186
+ this.reasoningBuf.texts.push(text);
7187
+ if (this.reasoningTimer) return;
7188
+ this.reasoningTimer = setTimeout(() => {
7189
+ this.flushReasoning();
7190
+ }, this.reasoningThrottleMs);
7191
+ this.reasoningTimer.unref?.();
7192
+ }
7193
+ /** Push an error step. Immediate. */
7194
+ recordError(message, payload) {
7195
+ this.emit("error", { message, ...payload ?? {} });
7196
+ }
7197
+ /**
7198
+ * Force any pending reasoning buffer out the door. Call on shutdown so
7199
+ * trailing tokens aren't lost.
7200
+ */
7201
+ flush() {
7202
+ this.flushReasoning();
7203
+ }
7204
+ /** Test helper. */
7205
+ get currentSeq() {
7206
+ return this.seq;
7207
+ }
7208
+ flushReasoning() {
7209
+ if (this.reasoningTimer) {
7210
+ clearTimeout(this.reasoningTimer);
7211
+ this.reasoningTimer = void 0;
7212
+ }
7213
+ if (this.reasoningBuf.texts.length === 0) return;
7214
+ const text = this.reasoningBuf.texts.join("");
7215
+ const firstAt = this.reasoningBuf.firstAt;
7216
+ this.reasoningBuf = { texts: [], firstAt: 0 };
7217
+ this.emit("reasoning_chunk", { text }, firstAt);
7218
+ }
7219
+ emit(kind, payload, occurredAt) {
7220
+ const frame = {
7221
+ seq: ++this.seq,
7222
+ kind,
7223
+ payload,
7224
+ occurredAt: occurredAt ?? this.now()
7225
+ };
7226
+ const msg = envelope("task.step.append", {
7227
+ taskRunId: this.opts.taskRunId,
7228
+ step: frame
7229
+ });
7230
+ try {
7231
+ if (this.opts.ws.isOpen && !this.opts.ws.isOpen()) {
7232
+ try {
7233
+ this.opts.onSend?.(frame);
7234
+ } catch {
7235
+ }
7236
+ return;
7237
+ }
7238
+ this.opts.ws.send(msg);
7239
+ try {
7240
+ this.opts.onSend?.(frame);
7241
+ } catch {
7242
+ }
7243
+ } catch {
7244
+ }
7245
+ }
7246
+ };
7247
+ var MAX_SUMMARY_CHARS = 512;
7248
+ function summarize2(value) {
7249
+ let text;
7250
+ if (value == null) text = "";
7251
+ else if (typeof value === "string") text = value;
7252
+ else {
7253
+ try {
7254
+ text = JSON.stringify(value);
7255
+ } catch {
7256
+ text = String(value);
7257
+ }
7258
+ }
7259
+ if (text.length > MAX_SUMMARY_CHARS) {
7260
+ return `${text.slice(0, MAX_SUMMARY_CHARS)}\u2026(+${text.length - MAX_SUMMARY_CHARS} chars)`;
7261
+ }
7262
+ return text;
7263
+ }
7264
+
7265
+ // src/daemon/dispatch.ts
6551
7266
  var DEFAULT_CONTEXT_MAX = 8e3;
6552
7267
  var GOAL_CONTEXT_MAX = 4;
6553
7268
  var MEMORY_DIGEST_MAX_BYTES = 4e3;
@@ -6563,6 +7278,8 @@ async function handleDispatch(payload, requestId, deps) {
6563
7278
  const { taskId, agentImUserId } = payload;
6564
7279
  let resolvedHashes = [];
6565
7280
  let reply;
7281
+ let heartbeatRef;
7282
+ let recorderRef;
6566
7283
  const outboxBase = deps.paths?.runsDir ? path2.join(deps.paths.runsDir, taskId) : null;
6567
7284
  const outboxDir = outboxBase ? path2.join(outboxBase, "_outbox") : null;
6568
7285
  const workDir = outboxBase ? path2.join(outboxBase, "workdir") : null;
@@ -6644,7 +7361,13 @@ async function handleDispatch(payload, requestId, deps) {
6644
7361
  resolvedHashes.push(...r.resolvedHashes);
6645
7362
  rewrittenContext = payload.context.map((e, i) => ({ ...e, content: r.texts[i] }));
6646
7363
  }
6647
- const assetResolution = await resolveAssetRefs(payload.assetRefs, deps.assetCache, taskId);
7364
+ const assetResolution = await resolveAssetRefs(payload.assetRefs, deps.assetCache, taskId, {
7365
+ cloud: deps.cloud,
7366
+ enabled: deps.visionAux?.enabled !== false,
7367
+ cacheDir: deps.visionAux?.cacheDir ?? (deps.paths ? path2.join(deps.paths.cacheDir, "vision-cache") : void 0),
7368
+ timeoutMs: deps.visionAux?.timeoutMs,
7369
+ signal: deps.signal
7370
+ });
6648
7371
  resolvedHashes.push(...assetResolution.pinnedHashes);
6649
7372
  const basePrompt = composePrompt(
6650
7373
  rewrittenPrompt.text,
@@ -6679,9 +7402,44 @@ async function handleDispatch(payload, requestId, deps) {
6679
7402
  }
6680
7403
  const operatingPrinciples = resolveOperatingPrinciples(profile.config);
6681
7404
  const composedSystemPrompt = [profileSystemPrompt, operatingPrinciples].filter((part) => typeof part === "string" && part.length > 0).join("\n\n");
7405
+ let activePhase = "thinking";
7406
+ const heartbeat = new TaskHeartbeat({
7407
+ ws: deps.ws,
7408
+ onGiveUp: (tid, err) => process.stderr.write(
7409
+ `[daemon] task.heartbeat give-up task=${tid}: ${err.message}
7410
+ `
7411
+ )
7412
+ });
7413
+ heartbeatRef = heartbeat;
7414
+ heartbeat.start(taskId, () => activePhase);
7415
+ const recorder = new StepRecorder({ ws: deps.ws, taskRunId: taskId });
7416
+ recorderRef = recorder;
7417
+ recorder.recordPhaseChange(activePhase);
6682
7418
  const taskInput = {
6683
7419
  taskId,
6684
7420
  prompt: adapterPrompt,
7421
+ heartbeat: {
7422
+ setPhase: (phase) => {
7423
+ activePhase = phase;
7424
+ heartbeat.setPhase(phase);
7425
+ recorder.recordPhaseChange(phase);
7426
+ },
7427
+ touchStep: () => heartbeat.touchStep()
7428
+ },
7429
+ recorder: {
7430
+ recordPhaseChange: (phase) => {
7431
+ activePhase = phase;
7432
+ heartbeat.setPhase(phase);
7433
+ recorder.recordPhaseChange(phase);
7434
+ },
7435
+ recordToolCall: (toolName, input, toolCallId) => recorder.recordToolCall(toolName, input, toolCallId),
7436
+ recordToolResult: (toolCallId, output) => recorder.recordToolResult(toolCallId, output),
7437
+ recordReasoningChunk: (text) => recorder.recordReasoningChunk(text),
7438
+ recordError: (message, payload2) => recorder.recordError(message, payload2)
7439
+ },
7440
+ // 14b rev.3 §9 P3 — multimodal-aware adapters (Hermes, OpenClaw) pick
7441
+ // up the resolved bytes/URLs here; text-only adapters ignore the field.
7442
+ ...assetResolution.resolvedRefs.length > 0 ? { assetRefs: assetResolution.resolvedRefs } : {},
6685
7443
  metadata: {
6686
7444
  ...payload.metadata,
6687
7445
  conversationId: payload.conversationId,
@@ -6744,6 +7502,9 @@ async function handleDispatch(payload, requestId, deps) {
6744
7502
  }
6745
7503
  }
6746
7504
  const reaperAborted = deps.signal?.aborted && result.error?.code === "task_cancelled";
7505
+ const approvalRequested = Boolean(
7506
+ result.metadata?.approvalRequested
7507
+ );
6747
7508
  let collectedAssetIds = [];
6748
7509
  if (deps.outboxWatcher && outboxDir) {
6749
7510
  try {
@@ -6755,14 +7516,23 @@ async function handleDispatch(payload, requestId, deps) {
6755
7516
  collectedAssetIds = deps.outboxWatcher.flushPending(taskId);
6756
7517
  }
6757
7518
  const reaperLimitMs = typeof payload.timeoutMs === "number" && payload.timeoutMs > 0 ? payload.timeoutMs : 5 * 6e4;
7519
+ const finalError = approvalRequested ? {
7520
+ code: "awaiting_human_approval",
7521
+ message: "Agent requested human approval and is suspended pending decision. Cloud will redispatch with `approval.decided` once the human responds."
7522
+ } : reaperAborted ? {
7523
+ code: "daemon_task_timeout",
7524
+ message: `Daemon-side reaper aborted after ${reaperLimitMs}ms inactivity (no progress events from adapter)`
7525
+ } : result.error;
6758
7526
  reply = {
6759
7527
  taskId,
7528
+ // When approval is pending we keep ok=false (the run did not produce a
7529
+ // completion-style output) but the cloud-side handler treats the
7530
+ // `awaiting_human_approval` code as non-terminal — task stays alive,
7531
+ // no red failure pill, and the next dispatch (after the human decides)
7532
+ // continues normally.
6760
7533
  ok: result.ok,
6761
7534
  output: result.output,
6762
- error: reaperAborted ? {
6763
- code: "daemon_task_timeout",
6764
- message: `Daemon-side reaper aborted after ${reaperLimitMs}ms inactivity (no progress events from adapter)`
6765
- } : result.error,
7535
+ error: finalError,
6766
7536
  ...collectedAssetIds.length > 0 ? { assetIds: collectedAssetIds } : {},
6767
7537
  metrics: result.metrics,
6768
7538
  ...assetResolution.observability.length > 0 || urlObservations.length > 0 ? { assetObservability: [...assetResolution.observability, ...urlObservations] } : {}
@@ -6784,6 +7554,18 @@ async function handleDispatch(payload, requestId, deps) {
6784
7554
  }
6785
7555
  };
6786
7556
  } finally {
7557
+ try {
7558
+ recorderRef?.flush();
7559
+ } catch (err) {
7560
+ process.stderr.write(`[daemon] step recorder flush failed task=${taskId}: ${err.message}
7561
+ `);
7562
+ }
7563
+ try {
7564
+ heartbeatRef?.stop();
7565
+ } catch (err) {
7566
+ process.stderr.write(`[daemon] heartbeat stop failed task=${taskId}: ${err.message}
7567
+ `);
7568
+ }
6787
7569
  for (const hash of new Set(resolvedHashes)) {
6788
7570
  try {
6789
7571
  deps.assetCache.unpin(hash);
@@ -6860,6 +7642,10 @@ function isTextLikeMime(mime) {
6860
7642
  if (m.endsWith("+json") || m.endsWith("+xml") || m.endsWith("+csv")) return true;
6861
7643
  return false;
6862
7644
  }
7645
+ function isImageMime(mime) {
7646
+ if (!mime) return false;
7647
+ return mime.toLowerCase().split(";")[0].trim().startsWith("image/");
7648
+ }
6863
7649
  var HASH_REF_RE = /(?:^|\s)#([^\s#]+)/g;
6864
7650
  var HEX_COLOR_RE = /^[0-9a-fA-F]{3,8}$/;
6865
7651
  var FILE_EXT_RE = /\.[a-zA-Z0-9]{1,10}$/;
@@ -6938,8 +7724,34 @@ async function resolveHashRefs(prompt, assetIndex, cloud) {
6938
7724
  }
6939
7725
  return { text: result, resolutions };
6940
7726
  }
6941
- async function resolveAssetRefs(refs, cache, taskId) {
6942
- const out = { promptBlocks: [], observability: [], pinnedHashes: [] };
7727
+ var ASSET_BASE64_INLINE_MAX_BYTES = 10 * 1024 * 1024;
7728
+ async function probeCdnReachable(cdnUrl, timeoutMs = 1500) {
7729
+ try {
7730
+ const u = new URL(cdnUrl);
7731
+ if (u.hostname === "localhost" || u.hostname === "127.0.0.1" || u.hostname.startsWith("192.168.")) {
7732
+ return false;
7733
+ }
7734
+ } catch {
7735
+ return false;
7736
+ }
7737
+ try {
7738
+ const r = await fetch(cdnUrl, {
7739
+ method: "HEAD",
7740
+ signal: AbortSignal.timeout(timeoutMs)
7741
+ });
7742
+ if (r.ok) return true;
7743
+ return false;
7744
+ } catch {
7745
+ return false;
7746
+ }
7747
+ }
7748
+ async function resolveAssetRefs(refs, cache, taskId, visionAux) {
7749
+ const out = {
7750
+ promptBlocks: [],
7751
+ observability: [],
7752
+ pinnedHashes: [],
7753
+ resolvedRefs: []
7754
+ };
6943
7755
  if (!refs || refs.length === 0) return out;
6944
7756
  for (const ref of refs) {
6945
7757
  let cached;
@@ -7004,7 +7816,42 @@ async function resolveAssetRefs(refs, cache, taskId) {
7004
7816
  continue;
7005
7817
  }
7006
7818
  }
7007
- out.promptBlocks.push(formatUriOnlyAssetBlock(ref, effectiveMime, cached.localPath));
7819
+ let reachable = "unknown";
7820
+ let base64;
7821
+ if (ref.cdnUrl) {
7822
+ const ok2 = await probeCdnReachable(ref.cdnUrl);
7823
+ if (ok2) {
7824
+ reachable = "cdn";
7825
+ }
7826
+ }
7827
+ if (reachable !== "cdn") {
7828
+ try {
7829
+ const buf = readFileSync10(cached.localPath);
7830
+ if (buf.byteLength <= ASSET_BASE64_INLINE_MAX_BYTES) {
7831
+ base64 = buf.toString("base64");
7832
+ reachable = "base64";
7833
+ } else {
7834
+ reachable = "unknown";
7835
+ }
7836
+ } catch (err) {
7837
+ const errMsg = `base64 fallback read failed: ${err.message}`;
7838
+ logAssetResolveFailure(taskId, ref, errMsg, effectiveMime);
7839
+ }
7840
+ }
7841
+ const resolvedRef = {
7842
+ ...ref,
7843
+ mime: effectiveMime,
7844
+ localPath: cached.localPath,
7845
+ base64,
7846
+ reachable
7847
+ };
7848
+ out.resolvedRefs.push(resolvedRef);
7849
+ if (isImageMime(effectiveMime) && visionAux?.enabled) {
7850
+ const block = await describeImageAttachment(ref, effectiveMime, resolvedRef, visionAux, taskId);
7851
+ out.promptBlocks.push(block);
7852
+ } else {
7853
+ out.promptBlocks.push(formatAttachmentReminderBlock(ref, effectiveMime));
7854
+ }
7008
7855
  out.observability.push({
7009
7856
  assetId: ref.assetId,
7010
7857
  contentHash: ref.contentHash,
@@ -7015,6 +7862,110 @@ async function resolveAssetRefs(refs, cache, taskId) {
7015
7862
  }
7016
7863
  return out;
7017
7864
  }
7865
+ async function describeImageAttachment(ref, mime, resolved, opts, taskId) {
7866
+ const cached = await readVisionAuxLocalCache(opts.cacheDir, ref.contentHash, mime);
7867
+ if (cached) return formatVisionAuxBlock(ref, mime, cached.description, true);
7868
+ const source = buildVisionAuxSource(resolved, mime);
7869
+ if (!source) {
7870
+ return formatVisionAuxUnavailableBlock(ref, mime, "no reachable URL or inline bytes");
7871
+ }
7872
+ try {
7873
+ const description = await callVisionAux(ref, mime, source, opts);
7874
+ await writeVisionAuxLocalCache(opts.cacheDir, ref.contentHash, mime, description);
7875
+ return formatVisionAuxBlock(ref, mime, description.description, false);
7876
+ } catch (err) {
7877
+ const message = err instanceof Error ? err.message : String(err);
7878
+ process.stderr.write(
7879
+ `[daemon] vision-aux failed task=${taskId ?? "unknown"} assetId=${ref.assetId} hash=${ref.contentHash}: ${message}
7880
+ `
7881
+ );
7882
+ return formatVisionAuxUnavailableBlock(ref, mime, message);
7883
+ }
7884
+ }
7885
+ function buildVisionAuxSource(resolved, mime) {
7886
+ if (resolved.reachable === "cdn" && resolved.cdnUrl) return { kind: "url", url: resolved.cdnUrl };
7887
+ if (resolved.base64 && mime) return { kind: "data_url", url: `data:${mime};base64,${resolved.base64}` };
7888
+ return null;
7889
+ }
7890
+ async function callVisionAux(ref, mime, source, opts) {
7891
+ const secret = process.env.VISION_AUX_INTERNAL_SECRET || process.env.INTERNAL_API_SECRET || process.env.PRISMER_INTERNAL_SECRET;
7892
+ const headers = secret ? { "x-prismer-internal-secret": secret } : void 0;
7893
+ const res = await opts.cloud.request(
7894
+ "POST",
7895
+ "/api/internal/vision-aux/describe",
7896
+ {
7897
+ body: {
7898
+ assetId: ref.assetId,
7899
+ contentHash: ref.contentHash,
7900
+ mime: mime ?? ref.mime ?? "image/unknown",
7901
+ source
7902
+ },
7903
+ headers,
7904
+ timeoutMs: opts.timeoutMs ?? 12e3,
7905
+ signal: opts.signal
7906
+ }
7907
+ );
7908
+ const env = res.data;
7909
+ if (!res.ok || env?.ok === false || !env?.data?.description) {
7910
+ const message = res.error?.message || (typeof env?.error === "string" ? env.error : env?.error?.message) || env?.message || `HTTP ${res.status}`;
7911
+ throw new Error(message);
7912
+ }
7913
+ const now = /* @__PURE__ */ new Date();
7914
+ const ttlSec = Number.isFinite(env.data.cacheTtlSec) ? Number(env.data.cacheTtlSec) : 300;
7915
+ return {
7916
+ description: env.data.description,
7917
+ modelUsed: env.data.modelUsed || "unknown",
7918
+ provider: env.data.provider || "vision-aux",
7919
+ generatedAt: now.toISOString(),
7920
+ expiresAt: new Date(now.getTime() + Math.max(1, ttlSec) * 1e3).toISOString(),
7921
+ mime: mime ?? ref.mime ?? "image/unknown"
7922
+ };
7923
+ }
7924
+ async function readVisionAuxLocalCache(cacheDir, contentHash, mime) {
7925
+ if (!cacheDir) return null;
7926
+ const file = visionAuxCachePath(cacheDir, contentHash);
7927
+ try {
7928
+ const raw = await fsp3.readFile(file, "utf8");
7929
+ const parsed = parseVisionAuxCache(raw);
7930
+ if (!parsed) {
7931
+ await fsp3.unlink(file).catch(() => void 0);
7932
+ return null;
7933
+ }
7934
+ if (parsed.mime !== mime) return null;
7935
+ const expiresAt = Date.parse(parsed.expiresAt);
7936
+ if (!Number.isFinite(expiresAt) || expiresAt <= Date.now()) return null;
7937
+ return parsed;
7938
+ } catch (err) {
7939
+ if (err.code !== "ENOENT") {
7940
+ await fsp3.unlink(file).catch(() => void 0);
7941
+ }
7942
+ return null;
7943
+ }
7944
+ }
7945
+ async function writeVisionAuxLocalCache(cacheDir, contentHash, mime, description) {
7946
+ if (!cacheDir || !mime) return;
7947
+ const file = visionAuxCachePath(cacheDir, contentHash);
7948
+ await fsp3.mkdir(path2.dirname(file), { recursive: true });
7949
+ await fsp3.writeFile(file, JSON.stringify({ ...description, mime }, null, 2), "utf8");
7950
+ }
7951
+ function visionAuxCachePath(cacheDir, contentHash) {
7952
+ const safeHash = /^[a-f0-9]{32,128}$/i.test(contentHash) ? contentHash : createSafeCacheKey(contentHash);
7953
+ return path2.join(cacheDir, `${safeHash}.json`);
7954
+ }
7955
+ function createSafeCacheKey(value) {
7956
+ return Buffer.from(value).toString("base64url").slice(0, 128) || "unknown";
7957
+ }
7958
+ function parseVisionAuxCache(raw) {
7959
+ try {
7960
+ const parsed = JSON.parse(raw);
7961
+ 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") {
7962
+ return parsed;
7963
+ }
7964
+ } catch {
7965
+ return null;
7966
+ }
7967
+ return null;
7968
+ }
7018
7969
  function logAssetResolveFailure(taskId, ref, error, mime = ref.mime) {
7019
7970
  const message = error.replace(/\s+/g, " ").slice(0, 500);
7020
7971
  process.stderr.write(
@@ -7029,8 +7980,19 @@ function formatInlineAssetBlock(ref, mime, body, strategy) {
7029
7980
  ${body}
7030
7981
  ---`;
7031
7982
  }
7032
- function formatUriOnlyAssetBlock(ref, mime, localPath) {
7033
- return `[Attached file] id=${ref.assetId} mime=${mime ?? "unknown"} path=file://${localPath} \u2014 open with your read/parse tool when needed.`;
7983
+ function formatAttachmentReminderBlock(ref, mime) {
7984
+ const name = ref.filename ? ` name=${ref.filename}` : "";
7985
+ return `[Attached file] id=${ref.assetId} mime=${mime ?? "unknown"}${name} \u2014 the user uploaded this file; multimodal adapters receive the bytes directly.`;
7986
+ }
7987
+ function formatVisionAuxBlock(ref, mime, description, cached) {
7988
+ const name = ref.filename ? `: ${ref.filename}` : "";
7989
+ const cacheLabel = cached ? " local-cache" : "";
7990
+ return `[Image attachment${name}] id=${ref.assetId} mime=${mime ?? "unknown"}${cacheLabel}
7991
+ ${description.trim()}`;
7992
+ }
7993
+ function formatVisionAuxUnavailableBlock(ref, mime, reason) {
7994
+ const name = ref.filename ? `: ${ref.filename}` : "";
7995
+ return `[Image attachment${name}; description unavailable due to vision-aux error] id=${ref.assetId} mime=${mime ?? "unknown"} reason=${reason.slice(0, 180)}`;
7034
7996
  }
7035
7997
  function formatErrorAssetBlock(ref, mime, error) {
7036
7998
  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.`;
@@ -7372,6 +8334,7 @@ import { createServer } from "http";
7372
8334
  import { randomUUID, createHash as createHash4, createHmac, timingSafeEqual as cryptoTimingSafeEqual } from "crypto";
7373
8335
  import { promises as fs2 } from "fs";
7374
8336
  import * as path3 from "path";
8337
+ import { homedir as homedir5 } from "os";
7375
8338
 
7376
8339
  // src/daemon/message-dispatch.ts
7377
8340
  var DEFAULT_TIMEOUT_MS = 6e4;
@@ -7582,6 +8545,11 @@ var LocalServer = class {
7582
8545
  void this.handleSnapshot(req, res);
7583
8546
  return;
7584
8547
  }
8548
+ const agentDumpMatch = /^\/v1\/agents\/([^/]+)\/dump-state$/.exec(url);
8549
+ if (req.method === "POST" && agentDumpMatch) {
8550
+ void this.handleAgentDumpState(req, res, decodeURIComponent(agentDumpMatch[1]));
8551
+ return;
8552
+ }
7585
8553
  if (req.method === "POST" && url === "/local/asset/write") {
7586
8554
  void this.handleAssetWrite(req, res);
7587
8555
  return;
@@ -7646,14 +8614,62 @@ var LocalServer = class {
7646
8614
  error: "snapshot_failed",
7647
8615
  message: err instanceof Error ? err.message : String(err)
7648
8616
  });
7649
- return;
7650
- }
7651
- try {
7652
- const files = await walkAndDigest(root, root);
7653
- respond(res, 200, { rootPath: root, files });
8617
+ return;
8618
+ }
8619
+ try {
8620
+ const files = await walkAndDigest(root, root);
8621
+ respond(res, 200, { rootPath: root, files });
8622
+ } catch (err) {
8623
+ respond(res, 500, {
8624
+ error: "snapshot_failed",
8625
+ message: err instanceof Error ? err.message : String(err)
8626
+ });
8627
+ }
8628
+ void req;
8629
+ }
8630
+ /**
8631
+ * POST /v1/agents/:agentId/dump-state — agent-scoped daemon manifest.
8632
+ *
8633
+ * This is narrower than `/v1/snapshot`: it walks only the current agent's
8634
+ * profile/work dirs so cloud can attach the result to IMAgentSnapshot without
8635
+ * capturing unrelated agents hosted in the same daemon/container.
8636
+ */
8637
+ async handleAgentDumpState(req, res, agentId) {
8638
+ const state = this.opts.getState();
8639
+ const agent = state.hostedAgents.find((item) => item.imUserId === agentId);
8640
+ if (!agent) {
8641
+ respond(res, 404, { error: "agent_not_hosted", agentId });
8642
+ return;
8643
+ }
8644
+ const roots = getAgentStateRoots(agent, this.opts.snapshotRoot ?? "/workspace");
8645
+ try {
8646
+ const manifests = await Promise.all(
8647
+ roots.map(async (root) => {
8648
+ const stat = await statOptional(root.rootPath);
8649
+ if (!stat) return { ...root, exists: false, files: [] };
8650
+ if (!stat.isDirectory()) return { ...root, exists: true, error: "not_directory", files: [] };
8651
+ return { ...root, exists: true, files: await walkAndDigest(root.rootPath, root.rootPath) };
8652
+ })
8653
+ );
8654
+ const files = manifests.flatMap(
8655
+ (manifest) => manifest.files.map((file) => ({
8656
+ ...file,
8657
+ path: `${manifest.kind}/${file.path}`,
8658
+ rootKind: manifest.kind,
8659
+ rootPath: manifest.rootPath
8660
+ }))
8661
+ );
8662
+ respond(res, 200, {
8663
+ agentId,
8664
+ adapterName: agent.adapterName,
8665
+ dumpedAt: (/* @__PURE__ */ new Date()).toISOString(),
8666
+ roots: manifests,
8667
+ files
8668
+ });
7654
8669
  } catch (err) {
7655
8670
  respond(res, 500, {
7656
- error: "snapshot_failed",
8671
+ error: "agent_dump_state_failed",
8672
+ agentId,
7657
8673
  message: err instanceof Error ? err.message : String(err)
7658
8674
  });
7659
8675
  }
@@ -7930,6 +8946,39 @@ function validateAgentDispatchRequest(body) {
7930
8946
  if (payload.attachments != null && !Array.isArray(payload.attachments)) return "attachments must be an array";
7931
8947
  return null;
7932
8948
  }
8949
+ function getAgentStateRoots(agent, snapshotRoot) {
8950
+ const profileName = sanitizeProfileName(agent.name || agent.imUserId);
8951
+ const roots = [
8952
+ {
8953
+ kind: "workspace-agent",
8954
+ rootPath: path3.join(snapshotRoot, "agents", agent.imUserId)
8955
+ }
8956
+ ];
8957
+ if (agent.adapterName === "hermes" || agent.adapterName === "claude-code") {
8958
+ roots.unshift({
8959
+ kind: "hermes-profile",
8960
+ rootPath: path3.join(process.env.HERMES_HOME || path3.join(homedir5(), ".hermes"), "profiles", profileName)
8961
+ });
8962
+ }
8963
+ if (agent.adapterName === "openclaw") {
8964
+ roots.unshift({
8965
+ kind: "openclaw-profile",
8966
+ rootPath: path3.join(process.env.OPENCLAW_HOME || path3.join(homedir5(), ".openclaw"), "profiles", profileName)
8967
+ });
8968
+ }
8969
+ return roots;
8970
+ }
8971
+ function sanitizeProfileName(value) {
8972
+ return value.replace(/[\\/]/g, "_").trim() || "default";
8973
+ }
8974
+ async function statOptional(rootPath) {
8975
+ try {
8976
+ return await fs2.stat(rootPath);
8977
+ } catch (err) {
8978
+ if (err.code === "ENOENT") return null;
8979
+ throw err;
8980
+ }
8981
+ }
7933
8982
  async function walkAndDigest(root, current) {
7934
8983
  const out = [];
7935
8984
  let entries;
@@ -9717,7 +10766,7 @@ async function readJson3(req) {
9717
10766
 
9718
10767
  // src/daemon/asset/origin/drop-folder.ts
9719
10768
  import { promises as fs4 } from "fs";
9720
- import { homedir as homedir6 } from "os";
10769
+ import { homedir as homedir7 } from "os";
9721
10770
  import * as path6 from "path";
9722
10771
  var DropFolderAdapter = class {
9723
10772
  kind = "drop-folder";
@@ -9725,7 +10774,7 @@ var DropFolderAdapter = class {
9725
10774
  rootDir;
9726
10775
  constructor(opts = {}) {
9727
10776
  this.workspaceDir = opts.workspaceDir;
9728
- this.rootDir = opts.rootDir ?? path6.join(opts.homeDir ?? homedir6(), ".prismer");
10777
+ this.rootDir = opts.rootDir ?? path6.join(opts.homeDir ?? homedir7(), ".prismer");
9729
10778
  }
9730
10779
  /**
9731
10780
  * Long-lived async iterable wrapping `scanOnce` on a 1s polling interval.
@@ -9735,7 +10784,7 @@ var DropFolderAdapter = class {
9735
10784
  while (true) {
9736
10785
  const batch = await this.scanOnce(workspaceId);
9737
10786
  for (const obs of batch) yield obs;
9738
- await sleep(1e3);
10787
+ await sleep2(1e3);
9739
10788
  }
9740
10789
  }
9741
10790
  /**
@@ -9884,7 +10933,7 @@ function mimeFromExtension(p) {
9884
10933
  const ext = path6.extname(p).toLowerCase();
9885
10934
  return MIME_BY_EXT[ext] ?? null;
9886
10935
  }
9887
- function sleep(ms) {
10936
+ function sleep2(ms) {
9888
10937
  return new Promise((r) => setTimeout(r, ms));
9889
10938
  }
9890
10939
 
@@ -10595,9 +11644,120 @@ function extensionOf(filename) {
10595
11644
  return idx >= 0 ? name.slice(idx) : "";
10596
11645
  }
10597
11646
 
11647
+ // src/daemon/asset/magic-bytes.ts
11648
+ var PDF_SIG = Buffer.from("%PDF-", "ascii");
11649
+ var PNG_SIG = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
11650
+ var JPEG_SIG = Buffer.from([255, 216, 255]);
11651
+ var GIF87 = Buffer.from("GIF87a", "ascii");
11652
+ var GIF89 = Buffer.from("GIF89a", "ascii");
11653
+ var ZIP_LOCAL = Buffer.from([80, 75, 3, 4]);
11654
+ var ZIP_EMPTY = Buffer.from([80, 75, 5, 6]);
11655
+ var ZIP_SPANNED = Buffer.from([80, 75, 7, 8]);
11656
+ function detectMagicBytes(bytes) {
11657
+ const head = bytes.subarray(0, Math.min(bytes.length, 4096));
11658
+ if (head.length === 0) return { detected: null, mime: null };
11659
+ if (head.length >= PDF_SIG.length && head.subarray(0, PDF_SIG.length).equals(PDF_SIG)) {
11660
+ return { detected: "pdf", mime: "application/pdf" };
11661
+ }
11662
+ if (head.length >= PNG_SIG.length && head.subarray(0, PNG_SIG.length).equals(PNG_SIG)) {
11663
+ return { detected: "png", mime: "image/png" };
11664
+ }
11665
+ if (head.length >= JPEG_SIG.length && head.subarray(0, JPEG_SIG.length).equals(JPEG_SIG)) {
11666
+ return { detected: "jpeg", mime: "image/jpeg" };
11667
+ }
11668
+ if (head.length >= GIF87.length && head.subarray(0, GIF87.length).equals(GIF87) || head.length >= GIF89.length && head.subarray(0, GIF89.length).equals(GIF89)) {
11669
+ return { detected: "gif", mime: "image/gif" };
11670
+ }
11671
+ 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))) {
11672
+ return { detected: "zip", mime: "application/zip" };
11673
+ }
11674
+ const text = head.toString("utf8");
11675
+ const trimmed = text.trimStart();
11676
+ if (/^<\?xml[^>]*>\s*<svg[\s>]/i.test(trimmed) || /^<svg[\s>]/i.test(trimmed)) {
11677
+ return { detected: "svg", mime: "image/svg+xml" };
11678
+ }
11679
+ if (/^<(!doctype html|html[\s>])/i.test(trimmed)) {
11680
+ return { detected: "html", mime: "text/html" };
11681
+ }
11682
+ if (/^[{[]/.test(trimmed)) {
11683
+ return { detected: "json", mime: "application/json" };
11684
+ }
11685
+ if (looksLikePrintableText(head)) {
11686
+ return { detected: "markdown-or-text", mime: "text/plain" };
11687
+ }
11688
+ return { detected: null, mime: null };
11689
+ }
11690
+ var ZIP_DERIVED_MIMES = /* @__PURE__ */ new Set([
11691
+ "application/zip",
11692
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
11693
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
11694
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation",
11695
+ "application/vnd.oasis.opendocument.text",
11696
+ "application/vnd.oasis.opendocument.spreadsheet",
11697
+ "application/vnd.oasis.opendocument.presentation",
11698
+ "application/epub+zip",
11699
+ "application/java-archive"
11700
+ ]);
11701
+ function mimeMismatchReason(detected, declared) {
11702
+ if (detected.detected === null) return null;
11703
+ const dec = (declared ?? "").toLowerCase().split(";")[0]?.trim() ?? "";
11704
+ if (!dec || dec === "application/octet-stream") return null;
11705
+ switch (detected.detected) {
11706
+ case "pdf":
11707
+ return dec === "application/pdf" ? null : `declared ${dec} but content is application/pdf`;
11708
+ case "png":
11709
+ return dec === "image/png" ? null : `declared ${dec} but content is image/png`;
11710
+ case "jpeg":
11711
+ return dec === "image/jpeg" || dec === "image/jpg" ? null : `declared ${dec} but content is image/jpeg`;
11712
+ case "gif":
11713
+ return dec === "image/gif" ? null : `declared ${dec} but content is image/gif`;
11714
+ case "zip":
11715
+ if (ZIP_DERIVED_MIMES.has(dec)) return null;
11716
+ return `declared ${dec} but content is a ZIP-container (application/zip family)`;
11717
+ case "svg":
11718
+ if (dec === "image/svg+xml" || dec.startsWith("text/")) return null;
11719
+ return `declared ${dec} but content is image/svg+xml`;
11720
+ case "html":
11721
+ if (dec === "text/html" || dec === "application/xhtml+xml" || dec.startsWith("text/")) return null;
11722
+ if (dec === "application/pdf" || dec.startsWith("image/") || dec === "application/zip") {
11723
+ return `declared ${dec} but content is text/html`;
11724
+ }
11725
+ return null;
11726
+ case "json":
11727
+ if (dec === "application/json" || dec.startsWith("text/")) return null;
11728
+ if (dec === "application/pdf" || dec.startsWith("image/") || dec === "application/zip") {
11729
+ return `declared ${dec} but content is application/json`;
11730
+ }
11731
+ return null;
11732
+ case "markdown-or-text":
11733
+ if (dec === "application/pdf" || dec.startsWith("image/") || dec === "application/zip") {
11734
+ return `declared ${dec} but content is text (not a real ${dec} payload)`;
11735
+ }
11736
+ return null;
11737
+ default:
11738
+ return null;
11739
+ }
11740
+ }
11741
+ function looksLikePrintableText(buf) {
11742
+ if (buf.length === 0) return false;
11743
+ let printable = 0;
11744
+ let total = 0;
11745
+ for (let i = 0; i < buf.length; i++) {
11746
+ const b = buf[i];
11747
+ if (b === 0) return false;
11748
+ total++;
11749
+ if (b >= 32 && b <= 126 || b === 9 || b === 10 || b === 13 || // utf-8 lead bytes
11750
+ b >= 128) {
11751
+ printable++;
11752
+ }
11753
+ }
11754
+ return total > 0 && printable / total >= 0.95;
11755
+ }
11756
+
10598
11757
  // src/daemon/outbox-watcher.ts
10599
11758
  var DEFAULT_INTERVAL_MS = 2e3;
10600
11759
  var RESERVED_SUBDIR = "_uploaded";
11760
+ var REJECTED_SUBDIR = "_rejected";
10601
11761
  var OutboxWatcher = class {
10602
11762
  constructor(opts) {
10603
11763
  this.opts = opts;
@@ -10772,6 +11932,7 @@ var OutboxWatcher = class {
10772
11932
  for (const name of entries) {
10773
11933
  if (name.startsWith(".")) continue;
10774
11934
  if (name === RESERVED_SUBDIR) continue;
11935
+ if (name === REJECTED_SUBDIR) continue;
10775
11936
  const full = path8.join(dir, name);
10776
11937
  let st;
10777
11938
  try {
@@ -10786,10 +11947,77 @@ var OutboxWatcher = class {
10786
11947
  await this.upload(full, st, kind, task);
10787
11948
  this.uploaded.add(key);
10788
11949
  } catch (err) {
10789
- this.log("warn", `upload ${full} failed: ${err.message}`);
11950
+ const msg = err.message;
11951
+ this.log("warn", `upload ${full} failed: ${msg}`);
11952
+ if (/\bMIME_MISMATCH\b/i.test(msg) || /\bmime mismatch\b/i.test(msg)) {
11953
+ await this.quarantine(dir, full).catch(
11954
+ (moveErr) => this.log("warn", `quarantine ${full} failed: ${moveErr.message}`)
11955
+ );
11956
+ this.uploaded.add(key);
11957
+ await this.reportMimeMismatchToCloud(task, full, msg).catch(
11958
+ (reportErr) => this.log("warn", `report MIME_MISMATCH failed: ${reportErr.message}`)
11959
+ );
11960
+ }
10790
11961
  }
10791
11962
  }
10792
11963
  }
11964
+ /**
11965
+ * F2B — report an OUTBOX_MIME_MISMATCH task event to cloud so the task log
11966
+ * gains an auditable record of the rejection. Without this, the failure is
11967
+ * silent (file quarantined locally, agent unaware) and the next dispatch
11968
+ * loops on the same bad strategy.
11969
+ *
11970
+ * Best-effort: every catch site swallows errors. We never want this
11971
+ * upstream call to block local quarantine or trigger a retry loop, because
11972
+ * the reject decision is already final by the time we get here.
11973
+ *
11974
+ * Endpoint contract: POST /api/im/tasks/:id/event
11975
+ * { code: 'OUTBOX_MIME_MISMATCH', payload: { file, daemonError }, message }
11976
+ * Only callable when `task?.taskId` is known — pre-dispatch outbox files
11977
+ * (no active task) are quarantined silently as before.
11978
+ */
11979
+ async reportMimeMismatchToCloud(task, filePath, daemonErrorMessage) {
11980
+ if (!task?.taskId) return;
11981
+ const fileName = path8.basename(filePath);
11982
+ const reasonMatch = daemonErrorMessage.match(/MIME_MISMATCH[^:]*:\s*(.+)$/i);
11983
+ const reason = reasonMatch?.[1] ? reasonMatch[1].trim() : daemonErrorMessage;
11984
+ const body = {
11985
+ code: "OUTBOX_MIME_MISMATCH",
11986
+ payload: {
11987
+ file: fileName,
11988
+ reason,
11989
+ daemonError: daemonErrorMessage
11990
+ },
11991
+ 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.`
11992
+ };
11993
+ const res = await this.opts.cloud.request("POST", `/api/im/tasks/${encodeURIComponent(task.taskId)}/event`, {
11994
+ body
11995
+ });
11996
+ if (!res.ok) {
11997
+ throw new Error(`cloud rejected event: ${res.status} ${res.error?.code ?? ""} ${res.error?.message ?? ""}`);
11998
+ }
11999
+ }
12000
+ /**
12001
+ * Move a file under `<dir>/_rejected/` so it stops being a scan candidate
12002
+ * (the loop skips the reserved subdir) and operators can find the
12003
+ * offending artifact. Filename collision is rare per task; on collision
12004
+ * we append a millisecond suffix to keep the original observable.
12005
+ */
12006
+ async quarantine(dir, full) {
12007
+ const rejectedDir = path8.join(dir, REJECTED_SUBDIR);
12008
+ await fs5.mkdir(rejectedDir, { recursive: true });
12009
+ const base = path8.basename(full);
12010
+ let dest = path8.join(rejectedDir, base);
12011
+ try {
12012
+ await fs5.access(dest);
12013
+ const ext = path8.extname(base);
12014
+ const stem = base.slice(0, base.length - ext.length);
12015
+ dest = path8.join(rejectedDir, `${stem}.${Date.now()}${ext}`);
12016
+ } catch {
12017
+ }
12018
+ await fs5.rename(full, dest);
12019
+ this.log("warn", `quarantined ${full} \u2192 ${dest}`);
12020
+ }
10793
12021
  async upload(filePath, st, kind, task) {
10794
12022
  const wsId = this.opts.workspaceId();
10795
12023
  if (!wsId) {
@@ -10807,6 +12035,11 @@ var OutboxWatcher = class {
10807
12035
  if (!policy.ok) {
10808
12036
  throw new Error(`agent output rejected: ${policy.reason}`);
10809
12037
  }
12038
+ const detection = detectMagicBytes(bytes);
12039
+ const reason = mimeMismatchReason(detection, policy.mime);
12040
+ if (reason) {
12041
+ throw new Error(`MIME_MISMATCH for ${fileName}: ${reason}`);
12042
+ }
10810
12043
  const metadata = {
10811
12044
  taskId: task.taskId,
10812
12045
  ...task.adapter ? { adapter: task.adapter } : {}
@@ -11160,6 +12393,350 @@ var WsClient = class extends EventEmitter2 {
11160
12393
  }
11161
12394
  };
11162
12395
 
12396
+ // src/daemon/pending-reply-cache.ts
12397
+ function fromRaw(r) {
12398
+ return {
12399
+ idempotencyKey: r.idempotency_key,
12400
+ taskId: r.task_id,
12401
+ replyId: r.reply_id,
12402
+ status: r.status,
12403
+ payloadJson: r.payload_json,
12404
+ createdAt: r.created_at,
12405
+ preparedAt: r.prepared_at,
12406
+ lastAttemptAt: r.last_attempt_at,
12407
+ attemptCount: r.attempt_count,
12408
+ lastError: r.last_error
12409
+ };
12410
+ }
12411
+ function createPendingReplyCache(db) {
12412
+ const insert = db.prepare(
12413
+ `INSERT OR REPLACE INTO pending_dispatch_replies
12414
+ (idempotency_key, task_id, reply_id, status, payload_json,
12415
+ prepared_at, created_at, last_attempt_at, attempt_count, last_error)
12416
+ VALUES (?, ?, NULL, 'pending', ?, NULL, ?, NULL, 0, NULL)`
12417
+ );
12418
+ const markPreparedStmt = db.prepare(
12419
+ `UPDATE pending_dispatch_replies
12420
+ SET reply_id = ?, status = 'prepared', prepared_at = ?,
12421
+ last_attempt_at = ?, attempt_count = attempt_count + 1,
12422
+ last_error = NULL
12423
+ WHERE idempotency_key = ?`
12424
+ );
12425
+ const markAttemptStmt = db.prepare(
12426
+ `UPDATE pending_dispatch_replies
12427
+ SET last_attempt_at = ?, attempt_count = attempt_count + 1,
12428
+ last_error = ?
12429
+ WHERE idempotency_key = ?`
12430
+ );
12431
+ const clearStmt = db.prepare(
12432
+ `DELETE FROM pending_dispatch_replies WHERE idempotency_key = ?`
12433
+ );
12434
+ const listRecoveryStmt = db.prepare(
12435
+ `SELECT * FROM pending_dispatch_replies
12436
+ WHERE status IN ('pending', 'prepared')
12437
+ ORDER BY created_at ASC`
12438
+ );
12439
+ const countStmt = db.prepare(
12440
+ `SELECT status, COUNT(*) as cnt FROM pending_dispatch_replies
12441
+ WHERE status IN ('pending','prepared') GROUP BY status`
12442
+ );
12443
+ const findStmt = db.prepare(
12444
+ `SELECT * FROM pending_dispatch_replies WHERE idempotency_key = ?`
12445
+ );
12446
+ return {
12447
+ savePending(idempotencyKey, taskId, payloadJson, now = Date.now()) {
12448
+ insert.run(idempotencyKey, taskId, payloadJson, now);
12449
+ },
12450
+ markPrepared(idempotencyKey, replyId, now = Date.now()) {
12451
+ markPreparedStmt.run(replyId, now, now, idempotencyKey);
12452
+ },
12453
+ markAttempt(idempotencyKey, now = Date.now(), error) {
12454
+ markAttemptStmt.run(now, error ?? null, idempotencyKey);
12455
+ },
12456
+ clearPending(idempotencyKey) {
12457
+ clearStmt.run(idempotencyKey);
12458
+ },
12459
+ listForRecovery() {
12460
+ const rows = listRecoveryStmt.all();
12461
+ return rows.map(fromRaw);
12462
+ },
12463
+ countByStatus() {
12464
+ const rows = countStmt.all();
12465
+ const result = { pending: 0, prepared: 0 };
12466
+ for (const r of rows) {
12467
+ if (r.status === "pending") result.pending = r.cnt;
12468
+ else if (r.status === "prepared") result.prepared = r.cnt;
12469
+ }
12470
+ return result;
12471
+ },
12472
+ findByIdempotencyKey(idempotencyKey) {
12473
+ const row = findStmt.get(idempotencyKey);
12474
+ return row ? fromRaw(row) : null;
12475
+ }
12476
+ };
12477
+ }
12478
+ function generateIdempotencyKey() {
12479
+ const c = globalThis.crypto;
12480
+ if (c?.randomUUID) return c.randomUUID();
12481
+ const bytes = new Uint8Array(16);
12482
+ const cryptoNode = globalThis.crypto;
12483
+ if (cryptoNode?.getRandomValues) {
12484
+ cryptoNode.getRandomValues(bytes);
12485
+ } else {
12486
+ for (let i = 0; i < 16; i += 1) bytes[i] = Math.floor(Math.random() * 256);
12487
+ }
12488
+ bytes[6] = bytes[6] & 15 | 64;
12489
+ bytes[8] = bytes[8] & 63 | 128;
12490
+ const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
12491
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
12492
+ }
12493
+
12494
+ // src/daemon/dispatch-reply-transport.ts
12495
+ var DispatchReplyAbortedError = class extends Error {
12496
+ constructor(replyId) {
12497
+ super(`dispatch reply ${replyId} aborted by server reaper (likely >1h since prepare)`);
12498
+ this.replyId = replyId;
12499
+ this.name = "DispatchReplyAbortedError";
12500
+ }
12501
+ replyId;
12502
+ code = "DISPATCH_REPLY_INVALID_STATE";
12503
+ };
12504
+ function defaultLog2(line) {
12505
+ process.stderr.write(`${line}
12506
+ `);
12507
+ }
12508
+ async function sendDispatchReplyTwoPhase(args) {
12509
+ const { taskId, payload, cloud, cache } = args;
12510
+ const log8 = args.log ?? defaultLog2;
12511
+ const idempotencyKey = args.idempotencyKey ?? generateIdempotencyKey();
12512
+ const payloadJson = JSON.stringify({ ...payload, _taskId: taskId, _idempotencyKey: idempotencyKey });
12513
+ cache.savePending(idempotencyKey, taskId, payloadJson);
12514
+ const prepareRes = await cloud.request("POST", `/api/im/dispatch/${encodeURIComponent(taskId)}/prepare`, {
12515
+ body: { ...payload, idempotencyKey },
12516
+ timeoutMs: 3e4
12517
+ });
12518
+ if (!prepareRes.ok || !prepareRes.data) {
12519
+ const errCode = prepareRes.error?.code ?? "unknown";
12520
+ const errMsg = prepareRes.error?.message ?? `prepare failed (status=${prepareRes.status})`;
12521
+ cache.markAttempt(idempotencyKey, Date.now(), `prepare: ${errCode}: ${errMsg}`);
12522
+ throw new Error(`dispatch prepare failed: ${errCode}: ${errMsg}`);
12523
+ }
12524
+ const envelope2 = prepareRes.data;
12525
+ const inner = "data" in envelope2 && envelope2.data ? envelope2.data : envelope2;
12526
+ if (!inner || typeof inner.replyId !== "string") {
12527
+ cache.markAttempt(idempotencyKey, Date.now(), "prepare: missing replyId in response");
12528
+ throw new Error("dispatch prepare returned no replyId");
12529
+ }
12530
+ if ("ok" in envelope2 && envelope2.ok === false) {
12531
+ const errCode = envelope2.error?.code ?? "prepare_failed";
12532
+ const errMsg = envelope2.error?.message ?? "prepare ok=false";
12533
+ cache.markAttempt(idempotencyKey, Date.now(), `prepare: ${errCode}: ${errMsg}`);
12534
+ throw new Error(`dispatch prepare failed: ${errCode}: ${errMsg}`);
12535
+ }
12536
+ cache.markPrepared(idempotencyKey, inner.replyId);
12537
+ log8(
12538
+ `[daemon] dispatch.prepare task=${taskId} replyId=${inner.replyId} status=${inner.status} idem=${idempotencyKey}`
12539
+ );
12540
+ if (inner.status === "committed") {
12541
+ cache.clearPending(idempotencyKey);
12542
+ return {
12543
+ taskId,
12544
+ replyId: inner.replyId,
12545
+ status: "committed",
12546
+ alreadyCommitted: true
12547
+ };
12548
+ }
12549
+ try {
12550
+ const result = await commitDispatchReply({ taskId, replyId: inner.replyId, cloud, log: log8 });
12551
+ cache.clearPending(idempotencyKey);
12552
+ return { taskId, replyId: inner.replyId, ...result };
12553
+ } catch (err) {
12554
+ if (err instanceof DispatchReplyAbortedError) {
12555
+ cache.clearPending(idempotencyKey);
12556
+ log8(`[daemon] dispatch.commit aborted task=${taskId} replyId=${inner.replyId} \u2014 dispatch lost`);
12557
+ return {
12558
+ taskId,
12559
+ replyId: inner.replyId,
12560
+ status: "aborted",
12561
+ alreadyCommitted: false
12562
+ };
12563
+ }
12564
+ cache.markAttempt(idempotencyKey, Date.now(), `commit: ${err.message}`);
12565
+ throw err;
12566
+ }
12567
+ }
12568
+ async function commitDispatchReply(args) {
12569
+ const log8 = args.log ?? defaultLog2;
12570
+ const res = await args.cloud.request("POST", `/api/im/dispatch/${encodeURIComponent(args.taskId)}/commit`, {
12571
+ body: { replyId: args.replyId },
12572
+ timeoutMs: 3e4
12573
+ });
12574
+ if (!res.ok || !res.data) {
12575
+ const code = res.error?.code ?? "unknown";
12576
+ const msg = res.error?.message ?? `commit failed (status=${res.status})`;
12577
+ if (code === "DISPATCH_REPLY_INVALID_STATE") {
12578
+ throw new DispatchReplyAbortedError(args.replyId);
12579
+ }
12580
+ throw new Error(`dispatch commit failed: ${code}: ${msg}`);
12581
+ }
12582
+ const env = res.data;
12583
+ const inner = "data" in env && env.data ? env.data : env;
12584
+ if ("ok" in env && env.ok === false) {
12585
+ const code = env.error?.code ?? "commit_failed";
12586
+ if (code === "DISPATCH_REPLY_INVALID_STATE") {
12587
+ throw new DispatchReplyAbortedError(args.replyId);
12588
+ }
12589
+ throw new Error(`dispatch commit failed: ${code}: ${env.error?.message ?? "commit ok=false"}`);
12590
+ }
12591
+ log8(
12592
+ `[daemon] dispatch.commit task=${args.taskId} replyId=${args.replyId} alreadyCommitted=${inner.alreadyCommitted ?? false}`
12593
+ );
12594
+ return {
12595
+ status: "committed",
12596
+ alreadyCommitted: Boolean(inner.alreadyCommitted),
12597
+ ...inner.messageId ? { messageId: inner.messageId } : {}
12598
+ };
12599
+ }
12600
+ async function recoverPendingReplies(deps) {
12601
+ const log8 = deps.log ?? defaultLog2;
12602
+ const rows = deps.cache.listForRecovery();
12603
+ if (rows.length === 0) {
12604
+ return { attempted: 0, committed: 0, aborted: 0, failed: 0 };
12605
+ }
12606
+ log8(`[daemon] dispatch.recovery: ${rows.length} pending row(s) to replay`);
12607
+ let committed = 0;
12608
+ let aborted = 0;
12609
+ let failed = 0;
12610
+ for (const row of rows) {
12611
+ try {
12612
+ const result = await replayRow(row, deps);
12613
+ if (result.status === "committed") committed += 1;
12614
+ else if (result.status === "aborted") aborted += 1;
12615
+ } catch (err) {
12616
+ failed += 1;
12617
+ log8(
12618
+ `[daemon] dispatch.recovery FAIL idem=${row.idempotencyKey} task=${row.taskId}: ${err.message}`
12619
+ );
12620
+ }
12621
+ }
12622
+ log8(
12623
+ `[daemon] dispatch.recovery done attempted=${rows.length} committed=${committed} aborted=${aborted} failed=${failed}`
12624
+ );
12625
+ return { attempted: rows.length, committed, aborted, failed };
12626
+ }
12627
+ async function replayRow(row, deps) {
12628
+ const log8 = deps.log ?? defaultLog2;
12629
+ if (row.status === "prepared" && row.replyId) {
12630
+ try {
12631
+ const result2 = await commitDispatchReply({
12632
+ taskId: row.taskId,
12633
+ replyId: row.replyId,
12634
+ cloud: deps.cloud,
12635
+ log: log8
12636
+ });
12637
+ deps.cache.clearPending(row.idempotencyKey);
12638
+ return { status: result2.status };
12639
+ } catch (err) {
12640
+ if (err instanceof DispatchReplyAbortedError) {
12641
+ deps.cache.clearPending(row.idempotencyKey);
12642
+ return { status: "aborted" };
12643
+ }
12644
+ throw err;
12645
+ }
12646
+ }
12647
+ let parsed;
12648
+ try {
12649
+ parsed = JSON.parse(row.payloadJson);
12650
+ } catch (err) {
12651
+ deps.cache.clearPending(row.idempotencyKey);
12652
+ throw new Error(`pending row ${row.idempotencyKey} has unparseable payload: ${err.message}`);
12653
+ }
12654
+ const { _taskId, _idempotencyKey, ...payload } = parsed;
12655
+ const result = await sendDispatchReplyTwoPhase({
12656
+ taskId: row.taskId,
12657
+ payload,
12658
+ cloud: deps.cloud,
12659
+ cache: deps.cache,
12660
+ idempotencyKey: row.idempotencyKey,
12661
+ log: deps.log
12662
+ });
12663
+ return { status: result.status };
12664
+ }
12665
+
12666
+ // src/daemon/transport-probe.ts
12667
+ import { networkInterfaces } from "os";
12668
+ function isPrivateAddress(addr) {
12669
+ if (!addr) return true;
12670
+ const h = String(addr).trim().toLowerCase();
12671
+ if (!h) return true;
12672
+ if (h === "localhost" || h === "::1") return true;
12673
+ const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(h);
12674
+ if (m) {
12675
+ const a = Number(m[1]);
12676
+ const b = Number(m[2]);
12677
+ if (a === 10) return true;
12678
+ if (a === 127) return true;
12679
+ if (a === 169 && b === 254) return true;
12680
+ if (a === 172 && b >= 16 && b <= 31) return true;
12681
+ if (a === 192 && b === 168) return true;
12682
+ if (a === 100 && b >= 64 && b <= 127) return true;
12683
+ return false;
12684
+ }
12685
+ if (h.endsWith(".local")) return true;
12686
+ if (h.endsWith(".localhost")) return true;
12687
+ return false;
12688
+ }
12689
+ function buildTransportProbe(gatewayUrl) {
12690
+ if (!gatewayUrl) {
12691
+ return { transport: "ws", gatewayUrl: null, gatewayIsPrivate: true };
12692
+ }
12693
+ let host = null;
12694
+ try {
12695
+ host = new URL(gatewayUrl).hostname;
12696
+ } catch {
12697
+ host = null;
12698
+ }
12699
+ const gatewayIsPrivate = isPrivateAddress(host);
12700
+ return {
12701
+ transport: gatewayIsPrivate ? "ws" : "both",
12702
+ gatewayUrl,
12703
+ gatewayIsPrivate
12704
+ };
12705
+ }
12706
+ function pickLocalIPv4() {
12707
+ const nics = networkInterfaces();
12708
+ let firstPrivate = null;
12709
+ for (const list of Object.values(nics)) {
12710
+ if (!list) continue;
12711
+ for (const ni of list) {
12712
+ if (ni.internal || ni.family !== "IPv4") continue;
12713
+ if (!isPrivateAddress(ni.address)) return ni.address;
12714
+ if (!firstPrivate) firstPrivate = ni.address;
12715
+ }
12716
+ }
12717
+ return firstPrivate;
12718
+ }
12719
+ async function reportTransportProbe(cloud, daemonId, probe) {
12720
+ const body = {
12721
+ daemonId,
12722
+ transport: probe.transport,
12723
+ gatewayUrl: probe.gatewayUrl,
12724
+ gatewayIsPrivate: probe.gatewayIsPrivate
12725
+ };
12726
+ const res = await cloud.request("POST", "/api/im/runtime/transport-report", {
12727
+ body,
12728
+ timeoutMs: 5e3
12729
+ });
12730
+ if (!res.ok) {
12731
+ return {
12732
+ ok: false,
12733
+ status: res.status,
12734
+ error: res.error?.message ?? `transport-report failed (${res.status})`
12735
+ };
12736
+ }
12737
+ return { ok: true, status: res.status };
12738
+ }
12739
+
11163
12740
  // src/daemon/runner.ts
11164
12741
  var DEFAULT_LOCAL_PORT = 3210;
11165
12742
  var log7 = createLogger("Daemon");
@@ -11208,6 +12785,14 @@ var Runner = class extends EventEmitter3 {
11208
12785
  // F16 (2026-05-20) — periodic skill resync timer (default 10min).
11209
12786
  skillResyncTimer;
11210
12787
  skillSyncInFlight = false;
12788
+ // Wave-4 E7 — local cache of in-flight two-phase dispatch replies. Survives
12789
+ // daemon crash so cold-start can resume `prepared` rows via idempotent
12790
+ // commit (server enforces (taskId, idempotencyKey) UNIQUE).
12791
+ pendingReplyCache;
12792
+ // One-shot guard so we only recover on the first post-authenticated tick,
12793
+ // not every reconnect (server-side commits are idempotent but the log noise
12794
+ // and DB pressure of re-running on every redeclare is wasteful).
12795
+ pendingReplyRecoveryDone = false;
11211
12796
  async start() {
11212
12797
  if (this.state !== "idle") throw new Error(`Runner already in state ${this.state}`);
11213
12798
  this.state = "starting";
@@ -11222,6 +12807,7 @@ var Runner = class extends EventEmitter3 {
11222
12807
  process.env.PRISMER_API_KEY = this.config.api_key;
11223
12808
  this.db = openLocalDb(this.paths.localDb);
11224
12809
  this.cloud = new CloudClient({ baseUrl: this.config.cloud_api_base, apiKey: this.config.api_key });
12810
+ this.pendingReplyCache = createPendingReplyCache(this.db);
11225
12811
  this.assetCache = new AssetCache({
11226
12812
  db: this.db,
11227
12813
  cloud: this.cloud,
@@ -11828,6 +13414,19 @@ var Runner = class extends EventEmitter3 {
11828
13414
  process.stdout.write(`[daemon] ws authenticated, sending agent.host.declare (${this.hostedAgents.size} agents)
11829
13415
  `);
11830
13416
  this.sendDeclare();
13417
+ if (!this.pendingReplyRecoveryDone) {
13418
+ this.pendingReplyRecoveryDone = true;
13419
+ void recoverPendingReplies({ cloud: this.cloud, cache: this.pendingReplyCache }).then((summary) => {
13420
+ if (summary.attempted === 0) return;
13421
+ process.stdout.write(
13422
+ `[daemon] dispatch.recovery summary attempted=${summary.attempted} committed=${summary.committed} aborted=${summary.aborted} failed=${summary.failed}
13423
+ `
13424
+ );
13425
+ }).catch((err) => {
13426
+ process.stderr.write(`[daemon] dispatch.recovery threw: ${err.message}
13427
+ `);
13428
+ });
13429
+ }
11831
13430
  return;
11832
13431
  }
11833
13432
  this.handleIncoming(msg);
@@ -11874,12 +13473,90 @@ var Runner = class extends EventEmitter3 {
11874
13473
  case "asset.changed":
11875
13474
  void this.onAssetChanged(msg.payload);
11876
13475
  return;
13476
+ // v2.0 §4.8.1 (Wave 4-E4) — webhook reverse-channel RPC. Cloud's
13477
+ // dispatchExternalMention pushes an AgentDispatchRequest plus an
13478
+ // embedded `_rpcId`; the daemon runs the same handler as the
13479
+ // HTTP /dispatch path and echoes the reply back over WS with the
13480
+ // identical `_rpcId` so cloud's WsRpcService can settle the
13481
+ // pending promise.
13482
+ case "webhook.dispatch.request":
13483
+ void this.onWebhookDispatch(msg.payload);
13484
+ return;
11877
13485
  default:
11878
13486
  this.emit("unknown-message", msg);
11879
13487
  }
11880
13488
  }
13489
+ /**
13490
+ * v2.0 §4.8.1 — webhook dispatch over WS reverse channel.
13491
+ *
13492
+ * Acks via `webhook.dispatch.reply { _rpcId, ok, acceptedAt }` immediately
13493
+ * after `handleAgentMessageDispatch()` returns its synchronous response
13494
+ * — the actual reply (the agent's reply text/attachments) still flows
13495
+ * through `postMessageDispatchReply()` which posts to
13496
+ * `/api/im/dispatch/reply` after the adapter finishes. That mirrors the
13497
+ * HTTP /dispatch contract: ack first, asynchronous final reply via the
13498
+ * dispatch-reply REST endpoint.
13499
+ *
13500
+ * The `_rpcId` field is the only addition versus the HTTP path; we strip
13501
+ * it before passing the payload to the dispatch handler so existing
13502
+ * validation logic doesn't trip on an unknown field.
13503
+ */
13504
+ async onWebhookDispatch(payload) {
13505
+ const rpcId = payload._rpcId;
13506
+ if (!rpcId) {
13507
+ process.stderr.write(`[daemon] webhook.dispatch.request without _rpcId \u2014 dropped
13508
+ `);
13509
+ return;
13510
+ }
13511
+ const { _rpcId: _, ...request } = payload;
13512
+ try {
13513
+ const handle = handleAgentMessageDispatch(request, {
13514
+ findAgent: (agentImUserId) => this.findMessageDispatchAgent(agentImUserId),
13515
+ postReply: (replyPayload) => this.postMessageDispatchReply(replyPayload),
13516
+ onError: (err, req) => {
13517
+ process.stderr.write(
13518
+ `[daemon] webhook.dispatch.request failed message=${req.messageId} agent=${req.mentionedAgentImUserId}: ${err instanceof Error ? err.stack ?? err.message : String(err)}
13519
+ `
13520
+ );
13521
+ }
13522
+ });
13523
+ this.ws.send({
13524
+ type: "webhook.dispatch.reply",
13525
+ payload: {
13526
+ _rpcId: rpcId,
13527
+ ok: handle.response.ok,
13528
+ acceptedAt: handle.response.acceptedAt,
13529
+ error: handle.response.error
13530
+ }
13531
+ });
13532
+ void handle.done.catch((err) => {
13533
+ process.stderr.write(
13534
+ `[daemon] webhook.dispatch final reply failed message=${request.messageId}: ${err instanceof Error ? err.message : String(err)}
13535
+ `
13536
+ );
13537
+ });
13538
+ } catch (err) {
13539
+ this.ws.send({
13540
+ type: "webhook.dispatch.reply",
13541
+ payload: {
13542
+ _rpcId: rpcId,
13543
+ ok: false,
13544
+ error: {
13545
+ code: "daemon_dispatch_failed",
13546
+ message: err instanceof Error ? err.message : String(err)
13547
+ }
13548
+ }
13549
+ });
13550
+ }
13551
+ }
11881
13552
  async onHostAcked(payload) {
11882
13553
  this.workspaceId = payload.workspaceId;
13554
+ void this.reportTransport().catch((err) => {
13555
+ process.stderr.write(
13556
+ `[daemon] transport-probe report failed: ${err.message}
13557
+ `
13558
+ );
13559
+ });
11883
13560
  if (this.memoryWiring && this.workspaceId) {
11884
13561
  syncMemoryFromCloud(this.memoryWiring, this.cloud, [this.workspaceId]).catch(
11885
13562
  (err) => log7.error("Initial memory sync failed", err.message)
@@ -12061,14 +13738,68 @@ var Runner = class extends EventEmitter3 {
12061
13738
  };
12062
13739
  }
12063
13740
  async postMessageDispatchReply(payload) {
12064
- const res = await this.cloud.request("POST", "/api/im/dispatch/reply", {
12065
- auth: false,
12066
- body: payload,
12067
- timeoutMs: 3e4
12068
- });
12069
- if (!res.ok) {
12070
- throw new Error(res.error?.message ?? `dispatch reply failed (${res.status})`);
13741
+ const taskId = `external:${payload.replyToMessageId}`;
13742
+ try {
13743
+ const result = await sendDispatchReplyTwoPhase({
13744
+ taskId,
13745
+ payload: {
13746
+ conversationId: payload.conversationId,
13747
+ replyToken: payload.replyToken,
13748
+ replyToMessageId: payload.replyToMessageId,
13749
+ agentImUserId: payload.agentImUserId,
13750
+ status: payload.status,
13751
+ ...payload.replyText !== void 0 ? { replyText: payload.replyText } : {},
13752
+ ...payload.attachments ? { attachments: payload.attachments } : {},
13753
+ assetIds: payload.attachments?.map((a) => a.assetId).filter((id) => typeof id === "string") ?? [],
13754
+ completedAt: payload.completedAt,
13755
+ ...payload.error ? { error: payload.error } : {}
13756
+ },
13757
+ cloud: this.cloud,
13758
+ cache: this.pendingReplyCache
13759
+ });
13760
+ if (result.status === "aborted") {
13761
+ throw new Error(`dispatch reply aborted by server reaper (replyId=${result.replyId})`);
13762
+ }
13763
+ } catch (err) {
13764
+ throw new Error(
13765
+ err.message?.length ? err.message : `dispatch reply failed`
13766
+ );
13767
+ }
13768
+ }
13769
+ /**
13770
+ * v2.0 §4.8.1 (Wave 4-E4) — submit the transport-probe report. Called
13771
+ * from `onHostAcked` so the daemon's container row exists in cloud
13772
+ * before we POST to /runtime/transport-report.
13773
+ *
13774
+ * The gatewayUrl reported here is `http://<local-ipv4>:<localPort>`
13775
+ * (typically `http://192.168.x.x:3210`). For a daemon without a local
13776
+ * HTTP server (startLocalServer=false in tests) we still post the
13777
+ * probe with `gatewayUrl=null` so cloud knows transport='ws' is the
13778
+ * only path.
13779
+ */
13780
+ async reportTransport() {
13781
+ const hasLocalServer = this.opts.startLocalServer !== false;
13782
+ let gatewayUrl = null;
13783
+ if (hasLocalServer) {
13784
+ const localIp = pickLocalIPv4();
13785
+ if (localIp) {
13786
+ const port = this.opts.localPort ?? DEFAULT_LOCAL_PORT;
13787
+ gatewayUrl = `http://${localIp}:${port}`;
13788
+ }
12071
13789
  }
13790
+ const probe = buildTransportProbe(gatewayUrl);
13791
+ const result = await reportTransportProbe(this.cloud, this.config.daemon_id, probe);
13792
+ if (!result.ok) {
13793
+ process.stderr.write(
13794
+ `[daemon] transport-probe report rejected status=${result.status} error=${result.error ?? "-"}
13795
+ `
13796
+ );
13797
+ return;
13798
+ }
13799
+ process.stdout.write(
13800
+ `[daemon] transport-probe reported transport=${probe.transport} gateway=${probe.gatewayUrl ?? "null"} private=${probe.gatewayIsPrivate}
13801
+ `
13802
+ );
12072
13803
  }
12073
13804
  onAgentChanged(payload) {
12074
13805
  const a = this.hostedAgents.get(payload.agentImUserId);
@@ -12511,7 +14242,7 @@ var Runner = class extends EventEmitter3 {
12511
14242
  function resolveUserAssetWorkspaceDir(workspaceId) {
12512
14243
  const override = process.env.PRISMER_ASSET_SYNC_ROOT;
12513
14244
  if (override) return join17(override, workspaceId);
12514
- const home = homedir7();
14245
+ const home = homedir8();
12515
14246
  const desktop = join17(home, "Desktop");
12516
14247
  const base = existsSync13(desktop) ? desktop : home;
12517
14248
  return join17(base, "Prismer Assets", workspaceId);
@@ -12630,7 +14361,7 @@ function buildDaemonCommand() {
12630
14361
  while (!configExists(paths)) {
12631
14362
  if (stopRequested) return;
12632
14363
  getUI().info(`[daemon] waiting for ${paths.configFile} \u2014 run \`prismer setup\` to create it`);
12633
- await sleep2(5e3);
14364
+ await sleep3(5e3);
12634
14365
  }
12635
14366
  runner = new Runner({
12636
14367
  startLocalServer: opts.localServer !== false,
@@ -12662,7 +14393,7 @@ function buildDaemonCommand() {
12662
14393
  if (existingPid && pidAlive2(existingPid)) {
12663
14394
  exitWithError(`Daemon already running (pid ${existingPid}). Use \`prismer daemon stop\` first.`);
12664
14395
  }
12665
- if (!existsSync14(paths.logsDir)) mkdirSync9(paths.logsDir, { recursive: true });
14396
+ if (!existsSync14(paths.logsDir)) mkdirSync10(paths.logsDir, { recursive: true });
12666
14397
  const logFile = join18(paths.logsDir, "daemon.log");
12667
14398
  const fd = openSync2(logFile, "a");
12668
14399
  const args = [process.argv[1], "daemon", "run"];
@@ -12676,7 +14407,7 @@ function buildDaemonCommand() {
12676
14407
  child.unref();
12677
14408
  const deadline = Date.now() + 5e3;
12678
14409
  while (Date.now() < deadline) {
12679
- await sleep2(200);
14410
+ await sleep3(200);
12680
14411
  const pid = readPidFile(paths);
12681
14412
  if (pid && pidAlive2(pid)) {
12682
14413
  if (opts.json) printJson({ ok: true, pid, logFile });
@@ -12710,7 +14441,7 @@ function buildDaemonCommand() {
12710
14441
  getUI().ok("Daemon stopped", `pid ${pid}`);
12711
14442
  return;
12712
14443
  }
12713
- await sleep2(200);
14444
+ await sleep3(200);
12714
14445
  }
12715
14446
  process.stderr.write(`Daemon did not exit within timeout; pid ${pid} may still be alive.
12716
14447
  `);
@@ -12791,7 +14522,7 @@ async function tailFromEnd(path9, lines) {
12791
14522
  async function followFile(path9) {
12792
14523
  let offset = statSync4(path9).size;
12793
14524
  for (; ; ) {
12794
- await sleep2(1e3);
14525
+ await sleep3(1e3);
12795
14526
  const size = statSync4(path9).size;
12796
14527
  if (size < offset) offset = 0;
12797
14528
  if (size === offset) continue;
@@ -12805,7 +14536,7 @@ async function followFile(path9) {
12805
14536
  init_util();
12806
14537
  import { Command as Command9 } from "commander";
12807
14538
  import { createReadStream as createReadStream2, existsSync as existsSync15 } from "fs";
12808
- import { homedir as homedir8 } from "os";
14539
+ import { homedir as homedir9 } from "os";
12809
14540
  import { join as join19 } from "path";
12810
14541
  import { createInterface } from "readline";
12811
14542
  var DEFAULT_LIMIT2 = 50;
@@ -12838,7 +14569,7 @@ function buildEventsStatsCommand() {
12838
14569
  const events = await readEvents(file, filters);
12839
14570
  printJson({
12840
14571
  ok: true,
12841
- data: { stats: summarize2(events), count: events.length, limit: filters.limit },
14572
+ data: { stats: summarize3(events), count: events.length, limit: filters.limit },
12842
14573
  checks: { file, exists: true, filters: cleanFilters(filters) }
12843
14574
  });
12844
14575
  });
@@ -12847,7 +14578,7 @@ function addEventOptions(cmd) {
12847
14578
  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)");
12848
14579
  }
12849
14580
  function eventsPath() {
12850
- return join19(process.env.PRISMER_HOME ?? join19(homedir8(), ".prismer"), "para", "events.jsonl");
14581
+ return join19(process.env.PRISMER_HOME ?? join19(homedir9(), ".prismer"), "para", "events.jsonl");
12851
14582
  }
12852
14583
  function unavailable(file) {
12853
14584
  return {
@@ -12895,7 +14626,7 @@ function field(event, names) {
12895
14626
  }
12896
14627
  return void 0;
12897
14628
  }
12898
- function summarize2(events) {
14629
+ function summarize3(events) {
12899
14630
  const byFamily = {};
12900
14631
  const byType = {};
12901
14632
  const byAgentId = {};
@@ -13209,7 +14940,7 @@ import { Command as Command11 } from "commander";
13209
14940
  // src/pair.ts
13210
14941
  import { generateKeyPairSync } from "crypto";
13211
14942
  import { hostname as hostname2 } from "os";
13212
- import { setTimeout as sleep3 } from "timers/promises";
14943
+ import { setTimeout as sleep4 } from "timers/promises";
13213
14944
  import qrcode from "qrcode";
13214
14945
 
13215
14946
  // src/daemon-id.ts
@@ -13296,7 +15027,7 @@ Scan with Lumin to approve, or open: ${offer.qrUrl}
13296
15027
  const pollIntervalMs = opts.pollIntervalMs ?? 5e3;
13297
15028
  for (let i = 0; i < maxAttempts; i += 1) {
13298
15029
  if (i > 0 || !localOnlyMode) {
13299
- await sleep3(pollIntervalMs);
15030
+ await sleep4(pollIntervalMs);
13300
15031
  }
13301
15032
  const res = await cloud.request(
13302
15033
  "GET",
@@ -13377,7 +15108,7 @@ function buildPairCommand() {
13377
15108
 
13378
15109
  // src/cli/commands/profile.ts
13379
15110
  import { Command as Command12 } from "commander";
13380
- import { existsSync as existsSync17, readFileSync as readFileSync12, writeFileSync as writeFileSync9 } from "fs";
15111
+ import { existsSync as existsSync17, readFileSync as readFileSync12, writeFileSync as writeFileSync10 } from "fs";
13381
15112
  import { tmpdir } from "os";
13382
15113
  import { join as join20 } from "path";
13383
15114
  import { spawnSync as spawnSync4 } from "child_process";
@@ -13504,7 +15235,7 @@ function buildProfileCommand() {
13504
15235
  `/api/im/agent_profiles/${encodeURIComponent(profileId)}`
13505
15236
  );
13506
15237
  const tmpFile = join20(tmpdir(), `prismer-profile-${profileId}.json`);
13507
- writeFileSync9(tmpFile, JSON.stringify(profile.config, null, 2), "utf8");
15238
+ writeFileSync10(tmpFile, JSON.stringify(profile.config, null, 2), "utf8");
13508
15239
  const editor = process.env.EDITOR || "vi";
13509
15240
  const ed = spawnSync4(editor, [tmpFile], { stdio: "inherit" });
13510
15241
  if (ed.status !== 0) exitWithError(`editor exited ${ed.status}`, { code: "editor_failed" });
@@ -13545,10 +15276,10 @@ async function resolveDefaultWorkspaceId(cloud) {
13545
15276
  // src/cli/commands/reset.ts
13546
15277
  import { Command as Command13 } from "commander";
13547
15278
  import { execFileSync as execFileSync2 } from "child_process";
13548
- import { existsSync as existsSync18, mkdirSync as mkdirSync10, rmSync, renameSync as renameSync2 } from "fs";
13549
- import { homedir as homedir9 } from "os";
13550
- import { dirname as dirname11, join as join21 } from "path";
13551
- import { setTimeout as sleep4 } from "timers/promises";
15279
+ import { existsSync as existsSync18, mkdirSync as mkdirSync11, rmSync, renameSync as renameSync2 } from "fs";
15280
+ import { homedir as homedir10 } from "os";
15281
+ import { dirname as dirname12, join as join21 } from "path";
15282
+ import { setTimeout as sleep5 } from "timers/promises";
13552
15283
  init_util();
13553
15284
  init_ui();
13554
15285
  function buildResetCommand() {
@@ -13601,8 +15332,8 @@ function buildResetCommand() {
13601
15332
  rmSync(paths.root, { recursive: true, force: true });
13602
15333
  } else {
13603
15334
  if (!archivePath) throw new Error("internal: archivePath missing");
13604
- if (!existsSync18(dirname11(archivePath))) {
13605
- mkdirSync10(dirname11(archivePath), { recursive: true });
15335
+ if (!existsSync18(dirname12(archivePath))) {
15336
+ mkdirSync11(dirname12(archivePath), { recursive: true });
13606
15337
  }
13607
15338
  renameSync2(paths.root, archivePath);
13608
15339
  }
@@ -13612,8 +15343,8 @@ function buildResetCommand() {
13612
15343
  rmSync(assetSyncRoot, { recursive: true, force: true });
13613
15344
  } else {
13614
15345
  if (!assetSyncArchivePath) throw new Error("internal: assetSyncArchivePath missing");
13615
- if (!existsSync18(dirname11(assetSyncArchivePath))) {
13616
- mkdirSync10(dirname11(assetSyncArchivePath), { recursive: true });
15346
+ if (!existsSync18(dirname12(assetSyncArchivePath))) {
15347
+ mkdirSync11(dirname12(assetSyncArchivePath), { recursive: true });
13617
15348
  }
13618
15349
  renameSync2(assetSyncRoot, assetSyncArchivePath);
13619
15350
  }
@@ -13623,13 +15354,13 @@ function buildResetCommand() {
13623
15354
  rmSync(hermesHome, { recursive: true, force: true });
13624
15355
  } else {
13625
15356
  if (!hermesArchivePath) throw new Error("internal: hermesArchivePath missing");
13626
- if (!existsSync18(dirname11(hermesArchivePath))) {
13627
- mkdirSync10(dirname11(hermesArchivePath), { recursive: true });
15357
+ if (!existsSync18(dirname12(hermesArchivePath))) {
15358
+ mkdirSync11(dirname12(hermesArchivePath), { recursive: true });
13628
15359
  }
13629
15360
  renameSync2(hermesHome, hermesArchivePath);
13630
15361
  }
13631
15362
  }
13632
- mkdirSync10(paths.root, { recursive: true });
15363
+ mkdirSync11(paths.root, { recursive: true });
13633
15364
  if (opts.json) {
13634
15365
  printJson({ ok: true, reset: true, plan });
13635
15366
  return;
@@ -13678,7 +15409,7 @@ async function stopProcess(pid, timeoutMs, label) {
13678
15409
  const deadline = Date.now() + timeoutMs;
13679
15410
  while (Date.now() < deadline) {
13680
15411
  if (!pidAlive2(pid)) return;
13681
- await sleep4(100);
15412
+ await sleep5(100);
13682
15413
  }
13683
15414
  try {
13684
15415
  process.kill(pid, "SIGKILL");
@@ -13688,7 +15419,7 @@ async function stopProcess(pid, timeoutMs, label) {
13688
15419
  const killDeadline = Date.now() + 1e3;
13689
15420
  while (Date.now() < killDeadline) {
13690
15421
  if (!pidAlive2(pid)) return;
13691
- await sleep4(100);
15422
+ await sleep5(100);
13692
15423
  }
13693
15424
  throw new Error(`${label} pid ${pid} did not exit within ${timeoutMs + 1e3}ms`);
13694
15425
  }
@@ -13698,13 +15429,13 @@ function timestamp() {
13698
15429
  function resolveAssetSyncRoot() {
13699
15430
  const override = process.env.PRISMER_ASSET_SYNC_ROOT;
13700
15431
  if (override) return override;
13701
- const home = homedir9();
15432
+ const home = homedir10();
13702
15433
  const desktop = join21(home, "Desktop");
13703
15434
  const base = existsSync18(desktop) ? desktop : home;
13704
15435
  return join21(base, "Prismer Assets");
13705
15436
  }
13706
15437
  function resolveHermesHome() {
13707
- return process.env.HERMES_HOME || join21(homedir9(), ".hermes");
15438
+ return process.env.HERMES_HOME || join21(homedir10(), ".hermes");
13708
15439
  }
13709
15440
  function findHermesGatewayPids2() {
13710
15441
  try {
@@ -14259,7 +15990,7 @@ function buildSkillCommand() {
14259
15990
  const cloud = mkCloud6();
14260
15991
  const path9 = opts.agent ? `/api/im/agents/${encodeURIComponent(opts.agent)}/skills` : `/api/im/skills/${encodeURIComponent(slugOrId)}/install`;
14261
15992
  const body = opts.agent ? { skillId: slugOrId, workspaceId: opts.workspaceId, version: opts.version } : { workspaceId: opts.workspaceId, version: opts.version };
14262
- const data = await requestEnvelope(cloud, "POST", path9, body, "skill_install_failed");
15993
+ const data = await requestEnvelope2(cloud, "POST", path9, body, "skill_install_failed");
14263
15994
  if (opts.json) {
14264
15995
  printJson(data);
14265
15996
  return;
@@ -14270,7 +16001,7 @@ function buildSkillCommand() {
14270
16001
  const cloud = mkCloud6();
14271
16002
  const path9 = opts.agent ? `/api/im/agents/${encodeURIComponent(opts.agent)}/skills` : `/api/im/skills/${encodeURIComponent(slugOrId)}/install`;
14272
16003
  const body = opts.agent ? { skillId: slugOrId, workspaceId: opts.workspaceId } : void 0;
14273
- const data = await requestEnvelope(cloud, "DELETE", path9, body, "skill_uninstall_failed");
16004
+ const data = await requestEnvelope2(cloud, "DELETE", path9, body, "skill_uninstall_failed");
14274
16005
  if (opts.json) {
14275
16006
  printJson(data);
14276
16007
  return;
@@ -14323,7 +16054,7 @@ function mkCloud6() {
14323
16054
  const cfg = loadConfig(resolvePaths());
14324
16055
  return new CloudClient({ baseUrl: cfg.cloud_api_base, apiKey: cfg.api_key });
14325
16056
  }
14326
- async function requestEnvelope(cloud, method, path9, body, code) {
16057
+ async function requestEnvelope2(cloud, method, path9, body, code) {
14327
16058
  const res = await cloud.request(method, path9, {
14328
16059
  body
14329
16060
  });
@@ -14473,6 +16204,7 @@ function buildStatusCommand() {
14473
16204
  let me = null;
14474
16205
  let devices = null;
14475
16206
  let agents = null;
16207
+ let diagnose = null;
14476
16208
  try {
14477
16209
  const meRes = await cloud.request("GET", "/api/im/me", { timeoutMs: 3e3 });
14478
16210
  cloudOk = meRes.ok;
@@ -14485,19 +16217,33 @@ function buildStatusCommand() {
14485
16217
  if (Array.isArray(wsList) && wsList.length > 0) {
14486
16218
  const wsId = wsList[0]?.id;
14487
16219
  if (wsId) {
14488
- const devRes = await cloud.request("GET", `/api/workspace/runtime-installations?workspaceId=${encodeURIComponent(wsId)}&includeStopped=false`, { timeoutMs: 3e3 });
14489
- if (devRes.ok) {
14490
- const devBody = devRes.data;
14491
- devices = devBody?.data;
14492
- }
14493
- const agRes = await cloud.request("GET", `/api/im/workspaces/${encodeURIComponent(wsId)}/agents`, { timeoutMs: 3e3 });
14494
- if (agRes.ok) {
14495
- const agBody = agRes.data;
14496
- agents = agBody?.data;
16220
+ const rtRes = await cloud.request("GET", `/api/im/workspaces/${encodeURIComponent(wsId)}/runtime`, { timeoutMs: 3e3 });
16221
+ if (rtRes.ok) {
16222
+ const rtBody = rtRes.data;
16223
+ const snapshot = rtBody?.data;
16224
+ if (snapshot && Array.isArray(snapshot.devices)) {
16225
+ devices = snapshot.devices;
16226
+ agents = snapshot.devices.flatMap((d) => d.agents ?? []);
16227
+ }
14497
16228
  }
14498
16229
  }
14499
16230
  }
14500
16231
  }
16232
+ const daemonId = cfg.daemon_id;
16233
+ if (daemonId) {
16234
+ try {
16235
+ const diagRes = await cloud.request(
16236
+ "GET",
16237
+ `/api/im/runtime/diagnose?daemonId=${encodeURIComponent(daemonId)}`,
16238
+ { timeoutMs: 3e3 }
16239
+ );
16240
+ if (diagRes.ok) {
16241
+ const body = diagRes.data;
16242
+ if (body?.data && typeof body.data === "object") diagnose = body.data;
16243
+ }
16244
+ } catch {
16245
+ }
16246
+ }
14501
16247
  }
14502
16248
  } catch {
14503
16249
  cloudOk = false;
@@ -14514,7 +16260,8 @@ function buildStatusCommand() {
14514
16260
  info: daemonStatus.info ?? {}
14515
16261
  },
14516
16262
  cloud: { base: cfg.cloud_api_base, reachable: cloudOk, me, devices, agents },
14517
- local
16263
+ local,
16264
+ diagnose
14518
16265
  };
14519
16266
  if (opts.json) {
14520
16267
  printJson(report);
@@ -14523,6 +16270,60 @@ function buildStatusCommand() {
14523
16270
  printPretty(report);
14524
16271
  });
14525
16272
  }
16273
+ function computeDeviceIcon(lastSeenAt, now) {
16274
+ if (!lastSeenAt) return "\u25CB";
16275
+ const age = now.getTime() - Date.parse(lastSeenAt);
16276
+ if (!Number.isFinite(age) || age < 0) return "\u25CB";
16277
+ if (age < 5 * 6e4) return "\u25CF";
16278
+ if (age < 60 * 6e4) return "\u25D0";
16279
+ return "\u25CB";
16280
+ }
16281
+ function computeAgentIcon(status, lastHeartbeat, now) {
16282
+ const s = (status || "").toLowerCase();
16283
+ if (s === "error" || s === "failed") return "\u25C6";
16284
+ if (!lastHeartbeat) return s === "offline" ? "\u25CB" : "\u25D0";
16285
+ const age = now.getTime() - Date.parse(lastHeartbeat);
16286
+ if (!Number.isFinite(age) || age < 0 || age >= 60 * 6e4) return "\u25CB";
16287
+ if (age >= 5 * 6e4) return "\u25D0";
16288
+ return "\u25CF";
16289
+ }
16290
+ function pad(value, width) {
16291
+ if (value.length >= width) return value.slice(0, width);
16292
+ return value + " ".repeat(width - value.length);
16293
+ }
16294
+ function formatDeviceBindings(devices, now = /* @__PURE__ */ new Date()) {
16295
+ const lines = [];
16296
+ if (devices.length === 0) {
16297
+ lines.push(" No paired devices.");
16298
+ return lines;
16299
+ }
16300
+ const totalAgents = devices.reduce((s, d) => s + (d.agents?.length ?? 0), 0);
16301
+ const devWord = devices.length === 1 ? "device" : "devices";
16302
+ const agWord = totalAgents === 1 ? "agent" : "agents";
16303
+ lines.push(` Device & Agent Bindings (${devices.length} ${devWord} \xB7 ${totalAgents} ${agWord})`);
16304
+ lines.push("");
16305
+ for (let di = 0; di < devices.length; di++) {
16306
+ const d = devices[di];
16307
+ const dIcon = computeDeviceIcon(d.lastSeenAt, now);
16308
+ const dName = pad(d.name || d.deviceId, 40);
16309
+ const lastSeen = d.lastSeenAt ? formatRelative(d.lastSeenAt, now) : "never seen";
16310
+ lines.push(` ${dIcon} ${dName} ${lastSeen}`);
16311
+ const agents = d.agents ?? [];
16312
+ for (let ai = 0; ai < agents.length; ai++) {
16313
+ const a = agents[ai];
16314
+ const isLast = ai === agents.length - 1;
16315
+ const branch = isLast ? "\u2514\u2500" : "\u251C\u2500";
16316
+ const aIcon = computeAgentIcon(a.status, a.lastHeartbeat, now);
16317
+ const aName = pad(a.name || a.id, 24);
16318
+ const aStatus = pad(a.status || "?", 8);
16319
+ const aVersion = pad(a.version ? `v${a.version}` : "\u2014", 10);
16320
+ const tail = a.currentTaskId ? `task=${a.currentTaskId}` : a.lastHeartbeat ? `\u2764 ${formatRelative(a.lastHeartbeat, now)}` : "";
16321
+ lines.push(` ${branch} ${aIcon} ${aName} ${aStatus} ${aVersion} ${tail}`.trimEnd());
16322
+ }
16323
+ if (di < devices.length - 1) lines.push("");
16324
+ }
16325
+ return lines;
16326
+ }
14526
16327
  async function readDaemonStatus() {
14527
16328
  try {
14528
16329
  const res = await fetch("http://127.0.0.1:3210/healthz", {
@@ -14595,16 +16396,7 @@ function printPretty(report) {
14595
16396
  if (Array.isArray(report.cloud.devices) && report.cloud.devices.length > 0) {
14596
16397
  const devs = report.cloud.devices;
14597
16398
  ui.blank();
14598
- ui.line(` Workspace Devices (${devs.length}):`);
14599
- for (const d of devs) {
14600
- const statusIcon = d.daemonStatus === "connected" ? "\u25CF" : "\u25CB";
14601
- const kind = d.runtimeKind === "docker" ? "Local" : "K8s";
14602
- const declared = d.hostedAgentSummary?.declared ?? 0;
14603
- ui.line(` ${statusIcon} ${d.podName?.replace(/^daemon:/, "")?.slice(0, 28) ?? "?"} kind=${kind} agents=${declared}`);
14604
- }
14605
- }
14606
- if (Array.isArray(report.cloud.agents) && report.cloud.agents.length > 0) {
14607
- ui.line(` Hosted agents: ${report.cloud.agents.length}`);
16399
+ for (const ln of formatDeviceBindings(devs)) ui.line(ln);
14608
16400
  }
14609
16401
  if (report.local) {
14610
16402
  ui.blank();
@@ -14614,11 +16406,63 @@ function printPretty(report) {
14614
16406
  } else {
14615
16407
  warn("Local DB", "unavailable");
14616
16408
  }
16409
+ if (report.diagnose) {
16410
+ ui.blank();
16411
+ ui.header("Cloud dispatch reachability");
16412
+ ui.blank();
16413
+ printDiagnose(report.diagnose, report.daemon.pid ?? null);
16414
+ }
16415
+ }
16416
+ function printDiagnose(d, pid) {
16417
+ const ui = getUI();
16418
+ const heartbeatLine = d.lastHeartbeatAt ? `last heartbeat ${formatRelative(d.lastHeartbeatAt)}` : "no heartbeat seen";
16419
+ if (d.wsAlive) {
16420
+ ok("Daemon", `running${pid ? ` (pid ${pid})` : ""}`);
16421
+ ok("Cloud WS", `connected (${heartbeatLine})`);
16422
+ } else {
16423
+ warn("Daemon", `running${pid ? ` (pid ${pid})` : ""} \u2014 not visible to cloud WS`);
16424
+ fail("Cloud WS", `disconnected (${heartbeatLine})`);
16425
+ }
16426
+ if (d.gatewayUrl) {
16427
+ if (d.gatewayIsPrivate) {
16428
+ warn("HTTP gateway", `${d.gatewayUrl} (private IP \u2014 only reachable on same network)`);
16429
+ } else if (d.httpReachable) {
16430
+ ok(
16431
+ "HTTP gateway",
16432
+ `${d.gatewayUrl} (reachable${d.httpLatencyMs != null ? `, ${d.httpLatencyMs}ms` : ""})`
16433
+ );
16434
+ } else {
16435
+ fail("HTTP gateway", `${d.gatewayUrl} (unreachable${d.httpError ? ` \u2014 ${d.httpError}` : ""})`);
16436
+ }
16437
+ } else {
16438
+ ui.line(" HTTP gateway: not declared (WS-only daemon)");
16439
+ }
16440
+ const activeAgents = /* @__PURE__ */ (() => {
16441
+ return 0;
16442
+ })();
16443
+ if (activeAgents > 0) {
16444
+ ok("Active agents", String(activeAgents));
16445
+ }
16446
+ const recColour = d.recommendedTransport === "ws" ? ok : d.recommendedTransport === "http" ? warn : fail;
16447
+ recColour("Recommended transport", d.recommendedTransport);
16448
+ if (d.lastProbeAt) {
16449
+ ui.line(` Last transport probe: ${formatRelative(d.lastProbeAt)}`);
16450
+ }
16451
+ }
16452
+ function formatRelative(iso, now = /* @__PURE__ */ new Date()) {
16453
+ const ts = Date.parse(iso);
16454
+ if (!Number.isFinite(ts)) return "unknown";
16455
+ const diff = Math.max(0, now.getTime() - ts);
16456
+ if (diff < 3e4) return "just now";
16457
+ if (diff < 6e4) return `${Math.round(diff / 1e3)}s ago`;
16458
+ if (diff < 36e5) return `${Math.round(diff / 6e4)}m ago`;
16459
+ if (diff < 864e5) return `${Math.round(diff / 36e5)}h ago`;
16460
+ return `${Math.round(diff / 864e5)}d ago`;
14617
16461
  }
14618
16462
 
14619
16463
  // src/cli/commands/task.ts
14620
16464
  import { Command as Command18 } from "commander";
14621
- import { setTimeout as sleep5 } from "timers/promises";
16465
+ import { setTimeout as sleep6 } from "timers/promises";
14622
16466
  init_util();
14623
16467
  function describeStatus2(status) {
14624
16468
  return status === 0 ? "network error" : `HTTP ${status}`;
@@ -14701,7 +16545,7 @@ function unwrap2(raw) {
14701
16545
  async function pollUntilTerminal(cloud, taskId, timeoutMs) {
14702
16546
  const deadline = Date.now() + (timeoutMs ?? 5 * 6e4);
14703
16547
  while (Date.now() < deadline) {
14704
- await sleep5(1e3);
16548
+ await sleep6(1e3);
14705
16549
  const res = await cloud.request(
14706
16550
  "GET",
14707
16551
  `/api/im/tasks/${encodeURIComponent(taskId)}`
@@ -14976,7 +16820,7 @@ async function readResponseError(res) {
14976
16820
 
14977
16821
  // src/cli/index.ts
14978
16822
  init_ui();
14979
- var VERSION = "2.0.3";
16823
+ var VERSION = "2.0.5";
14980
16824
  function buildProgram() {
14981
16825
  const program = new Command20("prismer").description("Prismer Cloud daemon CLI (TS-only).").version(VERSION);
14982
16826
  program.addCommand(buildBannerCommand());