@prismer/runtime 2.0.4 → 2.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/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.4",
2248
+ version: "2.0.6",
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) {
@@ -2554,10 +2889,10 @@ import { spawnSync as spawnSync2 } from "child_process";
2554
2889
  import {
2555
2890
  copyFileSync,
2556
2891
  existsSync as existsSync5,
2557
- mkdirSync as mkdirSync3,
2892
+ mkdirSync as mkdirSync4,
2558
2893
  readFileSync as readFileSync5,
2559
2894
  statSync,
2560
- writeFileSync as writeFileSync4
2895
+ writeFileSync as writeFileSync5
2561
2896
  } from "fs";
2562
2897
  import { homedir as homedir4 } from "os";
2563
2898
  import { dirname as dirname4, join as join6 } from "path";
@@ -3057,7 +3392,7 @@ init_openclaw();
3057
3392
 
3058
3393
  // src/config.ts
3059
3394
  import * as TOML from "@iarna/toml";
3060
- 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";
3061
3396
  import { homedir as homedir3 } from "os";
3062
3397
  import { dirname as dirname2, join as join4 } from "path";
3063
3398
  import { z as z5 } from "zod";
@@ -3124,13 +3459,13 @@ function loadConfig(paths = resolvePaths()) {
3124
3459
  }
3125
3460
  function saveConfig(config, paths = resolvePaths()) {
3126
3461
  if (!existsSync2(paths.root)) {
3127
- mkdirSync2(paths.root, { recursive: true });
3462
+ mkdirSync3(paths.root, { recursive: true });
3128
3463
  }
3129
3464
  if (!existsSync2(dirname2(paths.configFile))) {
3130
- mkdirSync2(dirname2(paths.configFile), { recursive: true });
3465
+ mkdirSync3(dirname2(paths.configFile), { recursive: true });
3131
3466
  }
3132
3467
  ConfigSchema.parse(config);
3133
- writeFileSync2(paths.configFile, TOML.stringify(config), "utf8");
3468
+ writeFileSync3(paths.configFile, TOML.stringify(config), "utf8");
3134
3469
  }
3135
3470
  function deriveWsUrl(httpBase) {
3136
3471
  const u = new URL(httpBase);
@@ -3393,15 +3728,15 @@ function runHooks(spec, opts) {
3393
3728
  }
3394
3729
  let backup;
3395
3730
  if (planned.kind === "directory") {
3396
- mkdirSync3(planned.path, { recursive: true });
3731
+ mkdirSync4(planned.path, { recursive: true });
3397
3732
  const markerPath = join6(planned.path, "prismer.json");
3398
3733
  backup = backupIfExists(markerPath);
3399
- writeFileSync4(markerPath, JSON.stringify(prismerHookMarker(spec), null, 2) + "\n", "utf8");
3734
+ writeFileSync5(markerPath, JSON.stringify(prismerHookMarker(spec), null, 2) + "\n", "utf8");
3400
3735
  } else {
3401
- mkdirSync3(dirname4(planned.path), { recursive: true });
3736
+ mkdirSync4(dirname4(planned.path), { recursive: true });
3402
3737
  backup = backupIfExists(planned.path);
3403
3738
  const merged = mergeHookJson(planned.path, spec);
3404
- writeFileSync4(planned.path, JSON.stringify(merged, null, 2) + "\n", "utf8");
3739
+ writeFileSync5(planned.path, JSON.stringify(merged, null, 2) + "\n", "utf8");
3405
3740
  }
3406
3741
  return {
3407
3742
  ok: true,
@@ -3799,7 +4134,7 @@ function mapStatusToCode(status) {
3799
4134
 
3800
4135
  // src/sync/store.ts
3801
4136
  import Database2 from "better-sqlite3";
3802
- import { existsSync as existsSync6, mkdirSync as mkdirSync4 } from "fs";
4137
+ import { existsSync as existsSync6, mkdirSync as mkdirSync5 } from "fs";
3803
4138
  import { dirname as dirname5 } from "path";
3804
4139
  var MIGRATIONS = [
3805
4140
  {
@@ -3926,6 +4261,36 @@ var MIGRATIONS = [
3926
4261
  CREATE INDEX IF NOT EXISTS idx_asset_meta_seq
3927
4262
  ON asset_metadata_index(workspace_id, asset_index_seq);
3928
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
+ `
3929
4294
  }
3930
4295
  ];
3931
4296
  function runSql(db, sql) {
@@ -3933,7 +4298,7 @@ function runSql(db, sql) {
3933
4298
  }
3934
4299
  function openLocalDb(path9) {
3935
4300
  if (path9 !== ":memory:" && !existsSync6(dirname5(path9))) {
3936
- mkdirSync4(dirname5(path9), { recursive: true });
4301
+ mkdirSync5(dirname5(path9), { recursive: true });
3937
4302
  }
3938
4303
  const db = new Database2(path9);
3939
4304
  db.pragma("journal_mode = WAL");
@@ -4255,8 +4620,74 @@ function buildAgentCommand() {
4255
4620
  }
4256
4621
  getUI().ok("Skill install requested", `agent=${opts.agent} skill=${slugOrId}`);
4257
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" }));
4258
4671
  return cmd;
4259
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
+ }
4260
4691
  function readLocalAgents(localDb) {
4261
4692
  const db = openLocalDb(localDb);
4262
4693
  try {
@@ -4361,13 +4792,13 @@ function whichBinary2(bin) {
4361
4792
  // src/cli/commands/asset.ts
4362
4793
  import { createHash as createHash2 } from "crypto";
4363
4794
  import { Command as Command3 } from "commander";
4364
- 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";
4365
4796
  import { basename, dirname as dirname7, join as join10, relative } from "path";
4366
4797
 
4367
4798
  // src/asset-cache.ts
4368
4799
  import { createHash } from "crypto";
4369
4800
  import { promises as dnsp } from "dns";
4370
- 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";
4371
4802
  import { isIP, isIPv4, isIPv6 } from "net";
4372
4803
  import { dirname as dirname6, join as join7 } from "path";
4373
4804
  import { Agent as UndiciAgent } from "undici";
@@ -4393,7 +4824,7 @@ var AssetCache = class {
4393
4824
  this.cacheDir = opts.cacheDir;
4394
4825
  this.maxBytes = opts.maxBytes ?? 5 * 1024 * 1024 * 1024;
4395
4826
  if (!existsSync7(this.cacheDir)) {
4396
- mkdirSync5(this.cacheDir, { recursive: true });
4827
+ mkdirSync6(this.cacheDir, { recursive: true });
4397
4828
  }
4398
4829
  }
4399
4830
  /** Local file path for a content hash. Files split by first 2 chars for fs scaling. */
@@ -4468,10 +4899,10 @@ var AssetCache = class {
4468
4899
  }
4469
4900
  const localPath = this.pathFor(hash);
4470
4901
  if (!existsSync7(dirname6(localPath))) {
4471
- mkdirSync5(dirname6(localPath), { recursive: true });
4902
+ mkdirSync6(dirname6(localPath), { recursive: true });
4472
4903
  }
4473
4904
  const tmpPath = `${localPath}.${process.pid}.${Date.now()}.tmp`;
4474
- writeFileSync5(tmpPath, buf);
4905
+ writeFileSync6(tmpPath, buf);
4475
4906
  renameSync(tmpPath, localPath);
4476
4907
  const mime = res.headers.get("content-type");
4477
4908
  const now = Date.now();
@@ -4532,10 +4963,10 @@ var AssetCache = class {
4532
4963
  }
4533
4964
  const localPath = this.pathFor(hash);
4534
4965
  if (!existsSync7(dirname6(localPath))) {
4535
- mkdirSync5(dirname6(localPath), { recursive: true });
4966
+ mkdirSync6(dirname6(localPath), { recursive: true });
4536
4967
  }
4537
4968
  const tmpPath = `${localPath}.${process.pid}.${Date.now()}.tmp`;
4538
- writeFileSync5(tmpPath, body);
4969
+ writeFileSync6(tmpPath, body);
4539
4970
  renameSync(tmpPath, localPath);
4540
4971
  const now = Date.now();
4541
4972
  this.db.prepare(
@@ -4789,7 +5220,7 @@ async function fetchUrlWithGuards(rawUrl, opts) {
4789
5220
  }
4790
5221
 
4791
5222
  // src/daemon/asset/metadata-index.ts
4792
- 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";
4793
5224
  import { join as join8 } from "path";
4794
5225
  var DEFAULT_LIMIT = 8;
4795
5226
  var PULL_PAGE_SIZE = 500;
@@ -4819,7 +5250,7 @@ var AssetMetadataIndex = class {
4819
5250
  this.cloud = opts.cloud;
4820
5251
  this.workspaceId = opts.workspaceId;
4821
5252
  if (!existsSync8(opts.workspaceStateDir)) {
4822
- mkdirSync6(opts.workspaceStateDir, { recursive: true });
5253
+ mkdirSync7(opts.workspaceStateDir, { recursive: true });
4823
5254
  }
4824
5255
  this.cursorPath = join8(opts.workspaceStateDir, "asset-metadata-cursor.json");
4825
5256
  }
@@ -4840,7 +5271,7 @@ var AssetMetadataIndex = class {
4840
5271
  cursor,
4841
5272
  writtenAt: Date.now()
4842
5273
  };
4843
- writeFileSync6(this.cursorPath, JSON.stringify(payload, null, 2));
5274
+ writeFileSync7(this.cursorPath, JSON.stringify(payload, null, 2));
4844
5275
  }
4845
5276
  /**
4846
5277
  * Pull incremental asset metadata changes since the persisted cursor and
@@ -4957,7 +5388,7 @@ var AssetMetadataIndex = class {
4957
5388
  };
4958
5389
 
4959
5390
  // src/daemon/asset/mirror.ts
4960
- 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";
4961
5392
  import { join as join9 } from "path";
4962
5393
  function rowToBinding(row) {
4963
5394
  return {
@@ -4980,7 +5411,7 @@ var WorkspaceMirror = class {
4980
5411
  this.cloud = opts.cloud;
4981
5412
  this.workspaceId = opts.workspaceId;
4982
5413
  if (!existsSync9(opts.workspaceStateDir)) {
4983
- mkdirSync7(opts.workspaceStateDir, { recursive: true });
5414
+ mkdirSync8(opts.workspaceStateDir, { recursive: true });
4984
5415
  }
4985
5416
  this.cursorPath = join9(opts.workspaceStateDir, "asset-cursor.json");
4986
5417
  }
@@ -5001,7 +5432,7 @@ var WorkspaceMirror = class {
5001
5432
  cursor,
5002
5433
  writtenAt: Date.now()
5003
5434
  };
5004
- writeFileSync7(this.cursorPath, JSON.stringify(payload, null, 2));
5435
+ writeFileSync8(this.cursorPath, JSON.stringify(payload, null, 2));
5005
5436
  }
5006
5437
  /**
5007
5438
  * Pull workspace_file changes since the persisted cursor and upsert into
@@ -5439,7 +5870,7 @@ async function downloadAsset(assetId, outPath) {
5439
5870
  exitWithError(`asset download failed (${res.status}): ${errorMessage(body)}`);
5440
5871
  }
5441
5872
  const bytes = Buffer.from(await res.arrayBuffer());
5442
- writeFileSync8(outPath, bytes);
5873
+ writeFileSync9(outPath, bytes);
5443
5874
  }
5444
5875
  function parseMetadata(raw) {
5445
5876
  if (!raw) return {};
@@ -6086,8 +6517,8 @@ function parsePositiveInt3(value) {
6086
6517
  // src/cli/commands/daemon.ts
6087
6518
  import { Command as Command8 } from "commander";
6088
6519
  import { spawn as spawn5 } from "child_process";
6089
- import { createReadStream, existsSync as existsSync14, mkdirSync as mkdirSync9, openSync as openSync2, statSync as statSync4 } from "fs";
6090
- 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";
6091
6522
  import { join as join18 } from "path";
6092
6523
 
6093
6524
  // src/daemon/runner.ts
@@ -6095,7 +6526,7 @@ import { EventEmitter as EventEmitter3 } from "events";
6095
6526
  import { createRequire as createRequire2 } from "module";
6096
6527
  import { existsSync as existsSync13, readFileSync as readFileSync11 } from "fs";
6097
6528
  import { mkdir } from "fs/promises";
6098
- import { homedir as homedir7, platform } from "os";
6529
+ import { homedir as homedir8, platform } from "os";
6099
6530
  import { join as join17 } from "path";
6100
6531
 
6101
6532
  // src/adapters/registry.ts
@@ -6576,6 +7007,262 @@ function createLogger(tag) {
6576
7007
  import { readFileSync as readFileSync10, promises as fsp3 } from "fs";
6577
7008
  import * as path2 from "path";
6578
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
6579
7266
  var DEFAULT_CONTEXT_MAX = 8e3;
6580
7267
  var GOAL_CONTEXT_MAX = 4;
6581
7268
  var MEMORY_DIGEST_MAX_BYTES = 4e3;
@@ -6591,6 +7278,8 @@ async function handleDispatch(payload, requestId, deps) {
6591
7278
  const { taskId, agentImUserId } = payload;
6592
7279
  let resolvedHashes = [];
6593
7280
  let reply;
7281
+ let heartbeatRef;
7282
+ let recorderRef;
6594
7283
  const outboxBase = deps.paths?.runsDir ? path2.join(deps.paths.runsDir, taskId) : null;
6595
7284
  const outboxDir = outboxBase ? path2.join(outboxBase, "_outbox") : null;
6596
7285
  const workDir = outboxBase ? path2.join(outboxBase, "workdir") : null;
@@ -6672,7 +7361,13 @@ async function handleDispatch(payload, requestId, deps) {
6672
7361
  resolvedHashes.push(...r.resolvedHashes);
6673
7362
  rewrittenContext = payload.context.map((e, i) => ({ ...e, content: r.texts[i] }));
6674
7363
  }
6675
- 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
+ });
6676
7371
  resolvedHashes.push(...assetResolution.pinnedHashes);
6677
7372
  const basePrompt = composePrompt(
6678
7373
  rewrittenPrompt.text,
@@ -6707,9 +7402,44 @@ async function handleDispatch(payload, requestId, deps) {
6707
7402
  }
6708
7403
  const operatingPrinciples = resolveOperatingPrinciples(profile.config);
6709
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);
6710
7418
  const taskInput = {
6711
7419
  taskId,
6712
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 } : {},
6713
7443
  metadata: {
6714
7444
  ...payload.metadata,
6715
7445
  conversationId: payload.conversationId,
@@ -6772,6 +7502,9 @@ async function handleDispatch(payload, requestId, deps) {
6772
7502
  }
6773
7503
  }
6774
7504
  const reaperAborted = deps.signal?.aborted && result.error?.code === "task_cancelled";
7505
+ const approvalRequested = Boolean(
7506
+ result.metadata?.approvalRequested
7507
+ );
6775
7508
  let collectedAssetIds = [];
6776
7509
  if (deps.outboxWatcher && outboxDir) {
6777
7510
  try {
@@ -6783,14 +7516,23 @@ async function handleDispatch(payload, requestId, deps) {
6783
7516
  collectedAssetIds = deps.outboxWatcher.flushPending(taskId);
6784
7517
  }
6785
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;
6786
7526
  reply = {
6787
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.
6788
7533
  ok: result.ok,
6789
7534
  output: result.output,
6790
- error: reaperAborted ? {
6791
- code: "daemon_task_timeout",
6792
- message: `Daemon-side reaper aborted after ${reaperLimitMs}ms inactivity (no progress events from adapter)`
6793
- } : result.error,
7535
+ error: finalError,
6794
7536
  ...collectedAssetIds.length > 0 ? { assetIds: collectedAssetIds } : {},
6795
7537
  metrics: result.metrics,
6796
7538
  ...assetResolution.observability.length > 0 || urlObservations.length > 0 ? { assetObservability: [...assetResolution.observability, ...urlObservations] } : {}
@@ -6812,6 +7554,18 @@ async function handleDispatch(payload, requestId, deps) {
6812
7554
  }
6813
7555
  };
6814
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
+ }
6815
7569
  for (const hash of new Set(resolvedHashes)) {
6816
7570
  try {
6817
7571
  deps.assetCache.unpin(hash);
@@ -6888,6 +7642,10 @@ function isTextLikeMime(mime) {
6888
7642
  if (m.endsWith("+json") || m.endsWith("+xml") || m.endsWith("+csv")) return true;
6889
7643
  return false;
6890
7644
  }
7645
+ function isImageMime(mime) {
7646
+ if (!mime) return false;
7647
+ return mime.toLowerCase().split(";")[0].trim().startsWith("image/");
7648
+ }
6891
7649
  var HASH_REF_RE = /(?:^|\s)#([^\s#]+)/g;
6892
7650
  var HEX_COLOR_RE = /^[0-9a-fA-F]{3,8}$/;
6893
7651
  var FILE_EXT_RE = /\.[a-zA-Z0-9]{1,10}$/;
@@ -6966,14 +7724,40 @@ async function resolveHashRefs(prompt, assetIndex, cloud) {
6966
7724
  }
6967
7725
  return { text: result, resolutions };
6968
7726
  }
6969
- async function resolveAssetRefs(refs, cache, taskId) {
6970
- const out = { promptBlocks: [], observability: [], pinnedHashes: [] };
6971
- if (!refs || refs.length === 0) return out;
6972
- for (const ref of refs) {
6973
- let cached;
6974
- try {
6975
- cached = await cache.getOrFetch(ref.contentHash, {
6976
- workspaceIdHint: ref.workspaceId,
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
+ };
7755
+ if (!refs || refs.length === 0) return out;
7756
+ for (const ref of refs) {
7757
+ let cached;
7758
+ try {
7759
+ cached = await cache.getOrFetch(ref.contentHash, {
7760
+ workspaceIdHint: ref.workspaceId,
6977
7761
  assetId: ref.assetId
6978
7762
  });
6979
7763
  } catch (err) {
@@ -7032,7 +7816,42 @@ async function resolveAssetRefs(refs, cache, taskId) {
7032
7816
  continue;
7033
7817
  }
7034
7818
  }
7035
- 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
+ }
7036
7855
  out.observability.push({
7037
7856
  assetId: ref.assetId,
7038
7857
  contentHash: ref.contentHash,
@@ -7043,6 +7862,110 @@ async function resolveAssetRefs(refs, cache, taskId) {
7043
7862
  }
7044
7863
  return out;
7045
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
+ }
7046
7969
  function logAssetResolveFailure(taskId, ref, error, mime = ref.mime) {
7047
7970
  const message = error.replace(/\s+/g, " ").slice(0, 500);
7048
7971
  process.stderr.write(
@@ -7057,8 +7980,19 @@ function formatInlineAssetBlock(ref, mime, body, strategy) {
7057
7980
  ${body}
7058
7981
  ---`;
7059
7982
  }
7060
- function formatUriOnlyAssetBlock(ref, mime, localPath) {
7061
- 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)}`;
7062
7996
  }
7063
7997
  function formatErrorAssetBlock(ref, mime, error) {
7064
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.`;
@@ -7400,6 +8334,7 @@ import { createServer } from "http";
7400
8334
  import { randomUUID, createHash as createHash4, createHmac, timingSafeEqual as cryptoTimingSafeEqual } from "crypto";
7401
8335
  import { promises as fs2 } from "fs";
7402
8336
  import * as path3 from "path";
8337
+ import { homedir as homedir5 } from "os";
7403
8338
 
7404
8339
  // src/daemon/message-dispatch.ts
7405
8340
  var DEFAULT_TIMEOUT_MS = 6e4;
@@ -7610,6 +8545,11 @@ var LocalServer = class {
7610
8545
  void this.handleSnapshot(req, res);
7611
8546
  return;
7612
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
+ }
7613
8553
  if (req.method === "POST" && url === "/local/asset/write") {
7614
8554
  void this.handleAssetWrite(req, res);
7615
8555
  return;
@@ -7687,6 +8627,54 @@ var LocalServer = class {
7687
8627
  }
7688
8628
  void req;
7689
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
+ });
8669
+ } catch (err) {
8670
+ respond(res, 500, {
8671
+ error: "agent_dump_state_failed",
8672
+ agentId,
8673
+ message: err instanceof Error ? err.message : String(err)
8674
+ });
8675
+ }
8676
+ void req;
8677
+ }
7690
8678
  /**
7691
8679
  * POST /local/asset/write — agent-gen adapter RPC.
7692
8680
  *
@@ -7958,6 +8946,39 @@ function validateAgentDispatchRequest(body) {
7958
8946
  if (payload.attachments != null && !Array.isArray(payload.attachments)) return "attachments must be an array";
7959
8947
  return null;
7960
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
+ }
7961
8982
  async function walkAndDigest(root, current) {
7962
8983
  const out = [];
7963
8984
  let entries;
@@ -9745,7 +10766,7 @@ async function readJson3(req) {
9745
10766
 
9746
10767
  // src/daemon/asset/origin/drop-folder.ts
9747
10768
  import { promises as fs4 } from "fs";
9748
- import { homedir as homedir6 } from "os";
10769
+ import { homedir as homedir7 } from "os";
9749
10770
  import * as path6 from "path";
9750
10771
  var DropFolderAdapter = class {
9751
10772
  kind = "drop-folder";
@@ -9753,7 +10774,7 @@ var DropFolderAdapter = class {
9753
10774
  rootDir;
9754
10775
  constructor(opts = {}) {
9755
10776
  this.workspaceDir = opts.workspaceDir;
9756
- this.rootDir = opts.rootDir ?? path6.join(opts.homeDir ?? homedir6(), ".prismer");
10777
+ this.rootDir = opts.rootDir ?? path6.join(opts.homeDir ?? homedir7(), ".prismer");
9757
10778
  }
9758
10779
  /**
9759
10780
  * Long-lived async iterable wrapping `scanOnce` on a 1s polling interval.
@@ -9763,7 +10784,7 @@ var DropFolderAdapter = class {
9763
10784
  while (true) {
9764
10785
  const batch = await this.scanOnce(workspaceId);
9765
10786
  for (const obs of batch) yield obs;
9766
- await sleep(1e3);
10787
+ await sleep2(1e3);
9767
10788
  }
9768
10789
  }
9769
10790
  /**
@@ -9912,7 +10933,7 @@ function mimeFromExtension(p) {
9912
10933
  const ext = path6.extname(p).toLowerCase();
9913
10934
  return MIME_BY_EXT[ext] ?? null;
9914
10935
  }
9915
- function sleep(ms) {
10936
+ function sleep2(ms) {
9916
10937
  return new Promise((r) => setTimeout(r, ms));
9917
10938
  }
9918
10939
 
@@ -10623,9 +11644,120 @@ function extensionOf(filename) {
10623
11644
  return idx >= 0 ? name.slice(idx) : "";
10624
11645
  }
10625
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
+
10626
11757
  // src/daemon/outbox-watcher.ts
10627
11758
  var DEFAULT_INTERVAL_MS = 2e3;
10628
11759
  var RESERVED_SUBDIR = "_uploaded";
11760
+ var REJECTED_SUBDIR = "_rejected";
10629
11761
  var OutboxWatcher = class {
10630
11762
  constructor(opts) {
10631
11763
  this.opts = opts;
@@ -10800,6 +11932,7 @@ var OutboxWatcher = class {
10800
11932
  for (const name of entries) {
10801
11933
  if (name.startsWith(".")) continue;
10802
11934
  if (name === RESERVED_SUBDIR) continue;
11935
+ if (name === REJECTED_SUBDIR) continue;
10803
11936
  const full = path8.join(dir, name);
10804
11937
  let st;
10805
11938
  try {
@@ -10814,10 +11947,77 @@ var OutboxWatcher = class {
10814
11947
  await this.upload(full, st, kind, task);
10815
11948
  this.uploaded.add(key);
10816
11949
  } catch (err) {
10817
- 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
+ }
10818
11961
  }
10819
11962
  }
10820
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
+ }
10821
12021
  async upload(filePath, st, kind, task) {
10822
12022
  const wsId = this.opts.workspaceId();
10823
12023
  if (!wsId) {
@@ -10835,6 +12035,11 @@ var OutboxWatcher = class {
10835
12035
  if (!policy.ok) {
10836
12036
  throw new Error(`agent output rejected: ${policy.reason}`);
10837
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
+ }
10838
12043
  const metadata = {
10839
12044
  taskId: task.taskId,
10840
12045
  ...task.adapter ? { adapter: task.adapter } : {}
@@ -11188,6 +12393,350 @@ var WsClient = class extends EventEmitter2 {
11188
12393
  }
11189
12394
  };
11190
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
+
11191
12740
  // src/daemon/runner.ts
11192
12741
  var DEFAULT_LOCAL_PORT = 3210;
11193
12742
  var log7 = createLogger("Daemon");
@@ -11229,6 +12778,7 @@ var Runner = class extends EventEmitter3 {
11229
12778
  workspaceId = "";
11230
12779
  wsConnected = false;
11231
12780
  hostedAgents = /* @__PURE__ */ new Map();
12781
+ rejectedHostedAgentIds = /* @__PURE__ */ new Map();
11232
12782
  runningTasks = /* @__PURE__ */ new Map();
11233
12783
  lastTaskError;
11234
12784
  heartbeatTimer;
@@ -11236,6 +12786,14 @@ var Runner = class extends EventEmitter3 {
11236
12786
  // F16 (2026-05-20) — periodic skill resync timer (default 10min).
11237
12787
  skillResyncTimer;
11238
12788
  skillSyncInFlight = false;
12789
+ // Wave-4 E7 — local cache of in-flight two-phase dispatch replies. Survives
12790
+ // daemon crash so cold-start can resume `prepared` rows via idempotent
12791
+ // commit (server enforces (taskId, idempotencyKey) UNIQUE).
12792
+ pendingReplyCache;
12793
+ // One-shot guard so we only recover on the first post-authenticated tick,
12794
+ // not every reconnect (server-side commits are idempotent but the log noise
12795
+ // and DB pressure of re-running on every redeclare is wasteful).
12796
+ pendingReplyRecoveryDone = false;
11239
12797
  async start() {
11240
12798
  if (this.state !== "idle") throw new Error(`Runner already in state ${this.state}`);
11241
12799
  this.state = "starting";
@@ -11250,6 +12808,7 @@ var Runner = class extends EventEmitter3 {
11250
12808
  process.env.PRISMER_API_KEY = this.config.api_key;
11251
12809
  this.db = openLocalDb(this.paths.localDb);
11252
12810
  this.cloud = new CloudClient({ baseUrl: this.config.cloud_api_base, apiKey: this.config.api_key });
12811
+ this.pendingReplyCache = createPendingReplyCache(this.db);
11253
12812
  this.assetCache = new AssetCache({
11254
12813
  db: this.db,
11255
12814
  cloud: this.cloud,
@@ -11665,6 +13224,9 @@ var Runner = class extends EventEmitter3 {
11665
13224
  profilesByAgent.set(p.agent_im_user_id, list);
11666
13225
  }
11667
13226
  for (const a of agents) {
13227
+ if (this.rejectedHostedAgentIds.has(a.im_user_id)) {
13228
+ continue;
13229
+ }
11668
13230
  let caps = [];
11669
13231
  try {
11670
13232
  caps = JSON.parse(a.capabilities);
@@ -11714,6 +13276,7 @@ var Runner = class extends EventEmitter3 {
11714
13276
  * Used to populate the `agents` list in agent.host.declare.
11715
13277
  */
11716
13278
  setHostedAgent(agent) {
13279
+ this.rejectedHostedAgentIds.delete(agent.imUserId);
11717
13280
  this.hostedAgents.set(agent.imUserId, {
11718
13281
  imUserId: agent.imUserId,
11719
13282
  name: agent.name,
@@ -11746,6 +13309,7 @@ var Runner = class extends EventEmitter3 {
11746
13309
  );
11747
13310
  });
11748
13311
  tx(payload);
13312
+ this.rejectedHostedAgentIds.delete(payload.imUserId);
11749
13313
  this.loadAgentsFromDb();
11750
13314
  if (this.wsConnected) this.sendDeclare();
11751
13315
  return {
@@ -11856,6 +13420,19 @@ var Runner = class extends EventEmitter3 {
11856
13420
  process.stdout.write(`[daemon] ws authenticated, sending agent.host.declare (${this.hostedAgents.size} agents)
11857
13421
  `);
11858
13422
  this.sendDeclare();
13423
+ if (!this.pendingReplyRecoveryDone) {
13424
+ this.pendingReplyRecoveryDone = true;
13425
+ void recoverPendingReplies({ cloud: this.cloud, cache: this.pendingReplyCache }).then((summary) => {
13426
+ if (summary.attempted === 0) return;
13427
+ process.stdout.write(
13428
+ `[daemon] dispatch.recovery summary attempted=${summary.attempted} committed=${summary.committed} aborted=${summary.aborted} failed=${summary.failed}
13429
+ `
13430
+ );
13431
+ }).catch((err) => {
13432
+ process.stderr.write(`[daemon] dispatch.recovery threw: ${err.message}
13433
+ `);
13434
+ });
13435
+ }
11859
13436
  return;
11860
13437
  }
11861
13438
  this.handleIncoming(msg);
@@ -11902,12 +13479,91 @@ var Runner = class extends EventEmitter3 {
11902
13479
  case "asset.changed":
11903
13480
  void this.onAssetChanged(msg.payload);
11904
13481
  return;
13482
+ // v2.0 §4.8.1 (Wave 4-E4) — webhook reverse-channel RPC. Cloud's
13483
+ // dispatchExternalMention pushes an AgentDispatchRequest plus an
13484
+ // embedded `_rpcId`; the daemon runs the same handler as the
13485
+ // HTTP /dispatch path and echoes the reply back over WS with the
13486
+ // identical `_rpcId` so cloud's WsRpcService can settle the
13487
+ // pending promise.
13488
+ case "webhook.dispatch.request":
13489
+ void this.onWebhookDispatch(msg.payload);
13490
+ return;
11905
13491
  default:
11906
13492
  this.emit("unknown-message", msg);
11907
13493
  }
11908
13494
  }
13495
+ /**
13496
+ * v2.0 §4.8.1 — webhook dispatch over WS reverse channel.
13497
+ *
13498
+ * Acks via `webhook.dispatch.reply { _rpcId, ok, acceptedAt }` immediately
13499
+ * after `handleAgentMessageDispatch()` returns its synchronous response
13500
+ * — the actual reply (the agent's reply text/attachments) still flows
13501
+ * through `postMessageDispatchReply()` which posts to
13502
+ * `/api/im/dispatch/reply` after the adapter finishes. That mirrors the
13503
+ * HTTP /dispatch contract: ack first, asynchronous final reply via the
13504
+ * dispatch-reply REST endpoint.
13505
+ *
13506
+ * The `_rpcId` field is the only addition versus the HTTP path; we strip
13507
+ * it before passing the payload to the dispatch handler so existing
13508
+ * validation logic doesn't trip on an unknown field.
13509
+ */
13510
+ async onWebhookDispatch(payload) {
13511
+ const rpcId = payload._rpcId;
13512
+ if (!rpcId) {
13513
+ process.stderr.write(`[daemon] webhook.dispatch.request without _rpcId \u2014 dropped
13514
+ `);
13515
+ return;
13516
+ }
13517
+ const { _rpcId: _, ...request } = payload;
13518
+ try {
13519
+ const handle = handleAgentMessageDispatch(request, {
13520
+ findAgent: (agentImUserId) => this.findMessageDispatchAgent(agentImUserId),
13521
+ postReply: (replyPayload) => this.postMessageDispatchReply(replyPayload),
13522
+ onError: (err, req) => {
13523
+ process.stderr.write(
13524
+ `[daemon] webhook.dispatch.request failed message=${req.messageId} agent=${req.mentionedAgentImUserId}: ${err instanceof Error ? err.stack ?? err.message : String(err)}
13525
+ `
13526
+ );
13527
+ }
13528
+ });
13529
+ this.ws.send({
13530
+ type: "webhook.dispatch.reply",
13531
+ payload: {
13532
+ _rpcId: rpcId,
13533
+ ok: handle.response.ok,
13534
+ acceptedAt: handle.response.acceptedAt,
13535
+ error: handle.response.error
13536
+ }
13537
+ });
13538
+ void handle.done.catch((err) => {
13539
+ process.stderr.write(
13540
+ `[daemon] webhook.dispatch final reply failed message=${request.messageId}: ${err instanceof Error ? err.message : String(err)}
13541
+ `
13542
+ );
13543
+ });
13544
+ } catch (err) {
13545
+ this.ws.send({
13546
+ type: "webhook.dispatch.reply",
13547
+ payload: {
13548
+ _rpcId: rpcId,
13549
+ ok: false,
13550
+ error: {
13551
+ code: "daemon_dispatch_failed",
13552
+ message: err instanceof Error ? err.message : String(err)
13553
+ }
13554
+ }
13555
+ });
13556
+ }
13557
+ }
11909
13558
  async onHostAcked(payload) {
11910
13559
  this.workspaceId = payload.workspaceId;
13560
+ const ownershipLockChanged = await this.applyHostAckOwnershipLock(payload);
13561
+ void this.reportTransport().catch((err) => {
13562
+ process.stderr.write(
13563
+ `[daemon] transport-probe report failed: ${err.message}
13564
+ `
13565
+ );
13566
+ });
11911
13567
  if (this.memoryWiring && this.workspaceId) {
11912
13568
  syncMemoryFromCloud(this.memoryWiring, this.cloud, [this.workspaceId]).catch(
11913
13569
  (err) => log7.error("Initial memory sync failed", err.message)
@@ -11917,7 +13573,9 @@ var Runner = class extends EventEmitter3 {
11917
13573
  (err) => log7.error("Asset sync failed", err.message)
11918
13574
  );
11919
13575
  await this.ensureDropFolderRuntime(this.workspaceId);
11920
- for (const id of payload.profilesToSync) {
13576
+ const profilesToSync = payload.profilesToSync ?? [];
13577
+ const profilesToDelete = payload.profilesToDelete ?? [];
13578
+ for (const id of profilesToSync) {
11921
13579
  try {
11922
13580
  await this.servicePool.drop(id);
11923
13581
  await this.syncProfileFromCloud(id);
@@ -11925,7 +13583,7 @@ var Runner = class extends EventEmitter3 {
11925
13583
  this.emit("sync-error", err);
11926
13584
  }
11927
13585
  }
11928
- for (const id of payload.profilesToDelete ?? []) {
13586
+ for (const id of profilesToDelete) {
11929
13587
  const row = this.db.prepare("SELECT agent_im_user_id FROM agent_profiles WHERE id = ?").get(id);
11930
13588
  this.db.prepare("DELETE FROM agent_profiles WHERE id = ?").run(id);
11931
13589
  if (row) {
@@ -11934,10 +13592,37 @@ var Runner = class extends EventEmitter3 {
11934
13592
  process.stderr.write(`[daemon] tombstone profile=${id}
11935
13593
  `);
11936
13594
  }
11937
- if (payload.profilesToDelete?.length) this.loadAgentsFromDb();
11938
- if ((payload.profilesToSync.length > 0 || (payload.profilesToDelete?.length ?? 0) > 0) && this.wsConnected) this.sendDeclare();
13595
+ if (profilesToDelete.length) this.loadAgentsFromDb();
13596
+ if ((ownershipLockChanged || profilesToSync.length > 0 || profilesToDelete.length > 0) && this.wsConnected) {
13597
+ this.sendDeclare();
13598
+ }
11939
13599
  this.emit("host-acked", payload);
11940
13600
  }
13601
+ async applyHostAckOwnershipLock(payload) {
13602
+ const rejected = payload.rejectedAgents ?? [];
13603
+ if (rejected.length === 0) return false;
13604
+ let changed = false;
13605
+ for (const rejection of rejected) {
13606
+ if (!rejection?.imUserId) continue;
13607
+ const agentImUserId = rejection.imUserId;
13608
+ this.rejectedHostedAgentIds.set(agentImUserId, {
13609
+ ...rejection,
13610
+ rejectedAt: Date.now()
13611
+ });
13612
+ const wasHosted = this.hostedAgents.delete(agentImUserId);
13613
+ const profiles = this.db.prepare("SELECT id FROM agent_profiles WHERE agent_im_user_id = ?").all(agentImUserId);
13614
+ const deleted = this.db.prepare("DELETE FROM agents WHERE im_user_id = ?").run(agentImUserId);
13615
+ await Promise.all(profiles.map((profile) => this.servicePool.drop(profile.id)));
13616
+ if (wasHosted || deleted.changes > 0) {
13617
+ changed = true;
13618
+ process.stderr.write(
13619
+ `[daemon] ownership rejected agent=${agentImUserId} reason=${rejection.reason} owner=${rejection.ownerDaemonId ?? "(unknown)"} \u2014 stop declaring locally
13620
+ `
13621
+ );
13622
+ }
13623
+ }
13624
+ return changed;
13625
+ }
11941
13626
  async onTaskDispatch(payload, requestId) {
11942
13627
  const targetDaemonId = readTargetDaemonId(payload);
11943
13628
  if (targetDaemonId && targetDaemonId !== this.config.daemon_id) {
@@ -12089,15 +13774,69 @@ var Runner = class extends EventEmitter3 {
12089
13774
  };
12090
13775
  }
12091
13776
  async postMessageDispatchReply(payload) {
12092
- const res = await this.cloud.request("POST", "/api/im/dispatch/reply", {
12093
- auth: false,
12094
- body: payload,
12095
- timeoutMs: 3e4
12096
- });
12097
- if (!res.ok) {
12098
- throw new Error(res.error?.message ?? `dispatch reply failed (${res.status})`);
13777
+ const taskId = `external:${payload.replyToMessageId}`;
13778
+ try {
13779
+ const result = await sendDispatchReplyTwoPhase({
13780
+ taskId,
13781
+ payload: {
13782
+ conversationId: payload.conversationId,
13783
+ replyToken: payload.replyToken,
13784
+ replyToMessageId: payload.replyToMessageId,
13785
+ agentImUserId: payload.agentImUserId,
13786
+ status: payload.status,
13787
+ ...payload.replyText !== void 0 ? { replyText: payload.replyText } : {},
13788
+ ...payload.attachments ? { attachments: payload.attachments } : {},
13789
+ assetIds: payload.attachments?.map((a) => a.assetId).filter((id) => typeof id === "string") ?? [],
13790
+ completedAt: payload.completedAt,
13791
+ ...payload.error ? { error: payload.error } : {}
13792
+ },
13793
+ cloud: this.cloud,
13794
+ cache: this.pendingReplyCache
13795
+ });
13796
+ if (result.status === "aborted") {
13797
+ throw new Error(`dispatch reply aborted by server reaper (replyId=${result.replyId})`);
13798
+ }
13799
+ } catch (err) {
13800
+ throw new Error(
13801
+ err.message?.length ? err.message : `dispatch reply failed`
13802
+ );
12099
13803
  }
12100
13804
  }
13805
+ /**
13806
+ * v2.0 §4.8.1 (Wave 4-E4) — submit the transport-probe report. Called
13807
+ * from `onHostAcked` so the daemon's container row exists in cloud
13808
+ * before we POST to /runtime/transport-report.
13809
+ *
13810
+ * The gatewayUrl reported here is `http://<local-ipv4>:<localPort>`
13811
+ * (typically `http://192.168.x.x:3210`). For a daemon without a local
13812
+ * HTTP server (startLocalServer=false in tests) we still post the
13813
+ * probe with `gatewayUrl=null` so cloud knows transport='ws' is the
13814
+ * only path.
13815
+ */
13816
+ async reportTransport() {
13817
+ const hasLocalServer = this.opts.startLocalServer !== false;
13818
+ let gatewayUrl = null;
13819
+ if (hasLocalServer) {
13820
+ const localIp = pickLocalIPv4();
13821
+ if (localIp) {
13822
+ const port = this.opts.localPort ?? DEFAULT_LOCAL_PORT;
13823
+ gatewayUrl = `http://${localIp}:${port}`;
13824
+ }
13825
+ }
13826
+ const probe = buildTransportProbe(gatewayUrl);
13827
+ const result = await reportTransportProbe(this.cloud, this.config.daemon_id, probe);
13828
+ if (!result.ok) {
13829
+ process.stderr.write(
13830
+ `[daemon] transport-probe report rejected status=${result.status} error=${result.error ?? "-"}
13831
+ `
13832
+ );
13833
+ return;
13834
+ }
13835
+ process.stdout.write(
13836
+ `[daemon] transport-probe reported transport=${probe.transport} gateway=${probe.gatewayUrl ?? "null"} private=${probe.gatewayIsPrivate}
13837
+ `
13838
+ );
13839
+ }
12101
13840
  onAgentChanged(payload) {
12102
13841
  const a = this.hostedAgents.get(payload.agentImUserId);
12103
13842
  if (!a) return;
@@ -12166,18 +13905,22 @@ var Runner = class extends EventEmitter3 {
12166
13905
  const name = agent?.card?.name || agent?.displayName || agent?.username || profile.agentImUserId;
12167
13906
  const now = Date.now();
12168
13907
  const tx = this.db.transaction(() => {
12169
- this.db.prepare(
12170
- `INSERT OR REPLACE INTO agents
12171
- (im_user_id, workspace_id, name, adapter_name, capabilities, status, version, synced_at, dirty)
12172
- VALUES (?, ?, ?, ?, ?, 'offline', 1, ?, 0)`
12173
- ).run(
12174
- profile.agentImUserId,
12175
- profile.workspaceId,
12176
- name,
12177
- profile.adapterName,
12178
- JSON.stringify(capabilities),
12179
- now
12180
- );
13908
+ if (this.rejectedHostedAgentIds.has(profile.agentImUserId)) {
13909
+ this.db.prepare("DELETE FROM agents WHERE im_user_id = ?").run(profile.agentImUserId);
13910
+ } else {
13911
+ this.db.prepare(
13912
+ `INSERT OR REPLACE INTO agents
13913
+ (im_user_id, workspace_id, name, adapter_name, capabilities, status, version, synced_at, dirty)
13914
+ VALUES (?, ?, ?, ?, ?, 'offline', 1, ?, 0)`
13915
+ ).run(
13916
+ profile.agentImUserId,
13917
+ profile.workspaceId,
13918
+ name,
13919
+ profile.adapterName,
13920
+ JSON.stringify(capabilities),
13921
+ now
13922
+ );
13923
+ }
12181
13924
  this.db.prepare(
12182
13925
  `INSERT OR REPLACE INTO agent_profiles
12183
13926
  (id, workspace_id, agent_im_user_id, adapter_name, name, config, version, synced_at, dirty, deleted_at)
@@ -12539,7 +14282,7 @@ var Runner = class extends EventEmitter3 {
12539
14282
  function resolveUserAssetWorkspaceDir(workspaceId) {
12540
14283
  const override = process.env.PRISMER_ASSET_SYNC_ROOT;
12541
14284
  if (override) return join17(override, workspaceId);
12542
- const home = homedir7();
14285
+ const home = homedir8();
12543
14286
  const desktop = join17(home, "Desktop");
12544
14287
  const base = existsSync13(desktop) ? desktop : home;
12545
14288
  return join17(base, "Prismer Assets", workspaceId);
@@ -12658,7 +14401,7 @@ function buildDaemonCommand() {
12658
14401
  while (!configExists(paths)) {
12659
14402
  if (stopRequested) return;
12660
14403
  getUI().info(`[daemon] waiting for ${paths.configFile} \u2014 run \`prismer setup\` to create it`);
12661
- await sleep2(5e3);
14404
+ await sleep3(5e3);
12662
14405
  }
12663
14406
  runner = new Runner({
12664
14407
  startLocalServer: opts.localServer !== false,
@@ -12690,7 +14433,7 @@ function buildDaemonCommand() {
12690
14433
  if (existingPid && pidAlive2(existingPid)) {
12691
14434
  exitWithError(`Daemon already running (pid ${existingPid}). Use \`prismer daemon stop\` first.`);
12692
14435
  }
12693
- if (!existsSync14(paths.logsDir)) mkdirSync9(paths.logsDir, { recursive: true });
14436
+ if (!existsSync14(paths.logsDir)) mkdirSync10(paths.logsDir, { recursive: true });
12694
14437
  const logFile = join18(paths.logsDir, "daemon.log");
12695
14438
  const fd = openSync2(logFile, "a");
12696
14439
  const args = [process.argv[1], "daemon", "run"];
@@ -12704,7 +14447,7 @@ function buildDaemonCommand() {
12704
14447
  child.unref();
12705
14448
  const deadline = Date.now() + 5e3;
12706
14449
  while (Date.now() < deadline) {
12707
- await sleep2(200);
14450
+ await sleep3(200);
12708
14451
  const pid = readPidFile(paths);
12709
14452
  if (pid && pidAlive2(pid)) {
12710
14453
  if (opts.json) printJson({ ok: true, pid, logFile });
@@ -12738,7 +14481,7 @@ function buildDaemonCommand() {
12738
14481
  getUI().ok("Daemon stopped", `pid ${pid}`);
12739
14482
  return;
12740
14483
  }
12741
- await sleep2(200);
14484
+ await sleep3(200);
12742
14485
  }
12743
14486
  process.stderr.write(`Daemon did not exit within timeout; pid ${pid} may still be alive.
12744
14487
  `);
@@ -12819,7 +14562,7 @@ async function tailFromEnd(path9, lines) {
12819
14562
  async function followFile(path9) {
12820
14563
  let offset = statSync4(path9).size;
12821
14564
  for (; ; ) {
12822
- await sleep2(1e3);
14565
+ await sleep3(1e3);
12823
14566
  const size = statSync4(path9).size;
12824
14567
  if (size < offset) offset = 0;
12825
14568
  if (size === offset) continue;
@@ -12833,7 +14576,7 @@ async function followFile(path9) {
12833
14576
  init_util();
12834
14577
  import { Command as Command9 } from "commander";
12835
14578
  import { createReadStream as createReadStream2, existsSync as existsSync15 } from "fs";
12836
- import { homedir as homedir8 } from "os";
14579
+ import { homedir as homedir9 } from "os";
12837
14580
  import { join as join19 } from "path";
12838
14581
  import { createInterface } from "readline";
12839
14582
  var DEFAULT_LIMIT2 = 50;
@@ -12866,7 +14609,7 @@ function buildEventsStatsCommand() {
12866
14609
  const events = await readEvents(file, filters);
12867
14610
  printJson({
12868
14611
  ok: true,
12869
- data: { stats: summarize2(events), count: events.length, limit: filters.limit },
14612
+ data: { stats: summarize3(events), count: events.length, limit: filters.limit },
12870
14613
  checks: { file, exists: true, filters: cleanFilters(filters) }
12871
14614
  });
12872
14615
  });
@@ -12875,7 +14618,7 @@ function addEventOptions(cmd) {
12875
14618
  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)");
12876
14619
  }
12877
14620
  function eventsPath() {
12878
- return join19(process.env.PRISMER_HOME ?? join19(homedir8(), ".prismer"), "para", "events.jsonl");
14621
+ return join19(process.env.PRISMER_HOME ?? join19(homedir9(), ".prismer"), "para", "events.jsonl");
12879
14622
  }
12880
14623
  function unavailable(file) {
12881
14624
  return {
@@ -12923,7 +14666,7 @@ function field(event, names) {
12923
14666
  }
12924
14667
  return void 0;
12925
14668
  }
12926
- function summarize2(events) {
14669
+ function summarize3(events) {
12927
14670
  const byFamily = {};
12928
14671
  const byType = {};
12929
14672
  const byAgentId = {};
@@ -13237,7 +14980,7 @@ import { Command as Command11 } from "commander";
13237
14980
  // src/pair.ts
13238
14981
  import { generateKeyPairSync } from "crypto";
13239
14982
  import { hostname as hostname2 } from "os";
13240
- import { setTimeout as sleep3 } from "timers/promises";
14983
+ import { setTimeout as sleep4 } from "timers/promises";
13241
14984
  import qrcode from "qrcode";
13242
14985
 
13243
14986
  // src/daemon-id.ts
@@ -13324,7 +15067,7 @@ Scan with Lumin to approve, or open: ${offer.qrUrl}
13324
15067
  const pollIntervalMs = opts.pollIntervalMs ?? 5e3;
13325
15068
  for (let i = 0; i < maxAttempts; i += 1) {
13326
15069
  if (i > 0 || !localOnlyMode) {
13327
- await sleep3(pollIntervalMs);
15070
+ await sleep4(pollIntervalMs);
13328
15071
  }
13329
15072
  const res = await cloud.request(
13330
15073
  "GET",
@@ -13405,7 +15148,7 @@ function buildPairCommand() {
13405
15148
 
13406
15149
  // src/cli/commands/profile.ts
13407
15150
  import { Command as Command12 } from "commander";
13408
- import { existsSync as existsSync17, readFileSync as readFileSync12, writeFileSync as writeFileSync9 } from "fs";
15151
+ import { existsSync as existsSync17, readFileSync as readFileSync12, writeFileSync as writeFileSync10 } from "fs";
13409
15152
  import { tmpdir } from "os";
13410
15153
  import { join as join20 } from "path";
13411
15154
  import { spawnSync as spawnSync4 } from "child_process";
@@ -13532,7 +15275,7 @@ function buildProfileCommand() {
13532
15275
  `/api/im/agent_profiles/${encodeURIComponent(profileId)}`
13533
15276
  );
13534
15277
  const tmpFile = join20(tmpdir(), `prismer-profile-${profileId}.json`);
13535
- writeFileSync9(tmpFile, JSON.stringify(profile.config, null, 2), "utf8");
15278
+ writeFileSync10(tmpFile, JSON.stringify(profile.config, null, 2), "utf8");
13536
15279
  const editor = process.env.EDITOR || "vi";
13537
15280
  const ed = spawnSync4(editor, [tmpFile], { stdio: "inherit" });
13538
15281
  if (ed.status !== 0) exitWithError(`editor exited ${ed.status}`, { code: "editor_failed" });
@@ -13573,10 +15316,10 @@ async function resolveDefaultWorkspaceId(cloud) {
13573
15316
  // src/cli/commands/reset.ts
13574
15317
  import { Command as Command13 } from "commander";
13575
15318
  import { execFileSync as execFileSync2 } from "child_process";
13576
- import { existsSync as existsSync18, mkdirSync as mkdirSync10, rmSync, renameSync as renameSync2 } from "fs";
13577
- import { homedir as homedir9 } from "os";
13578
- import { dirname as dirname11, join as join21 } from "path";
13579
- import { setTimeout as sleep4 } from "timers/promises";
15319
+ import { existsSync as existsSync18, mkdirSync as mkdirSync11, rmSync, renameSync as renameSync2 } from "fs";
15320
+ import { homedir as homedir10 } from "os";
15321
+ import { dirname as dirname12, join as join21 } from "path";
15322
+ import { setTimeout as sleep5 } from "timers/promises";
13580
15323
  init_util();
13581
15324
  init_ui();
13582
15325
  function buildResetCommand() {
@@ -13629,8 +15372,8 @@ function buildResetCommand() {
13629
15372
  rmSync(paths.root, { recursive: true, force: true });
13630
15373
  } else {
13631
15374
  if (!archivePath) throw new Error("internal: archivePath missing");
13632
- if (!existsSync18(dirname11(archivePath))) {
13633
- mkdirSync10(dirname11(archivePath), { recursive: true });
15375
+ if (!existsSync18(dirname12(archivePath))) {
15376
+ mkdirSync11(dirname12(archivePath), { recursive: true });
13634
15377
  }
13635
15378
  renameSync2(paths.root, archivePath);
13636
15379
  }
@@ -13640,8 +15383,8 @@ function buildResetCommand() {
13640
15383
  rmSync(assetSyncRoot, { recursive: true, force: true });
13641
15384
  } else {
13642
15385
  if (!assetSyncArchivePath) throw new Error("internal: assetSyncArchivePath missing");
13643
- if (!existsSync18(dirname11(assetSyncArchivePath))) {
13644
- mkdirSync10(dirname11(assetSyncArchivePath), { recursive: true });
15386
+ if (!existsSync18(dirname12(assetSyncArchivePath))) {
15387
+ mkdirSync11(dirname12(assetSyncArchivePath), { recursive: true });
13645
15388
  }
13646
15389
  renameSync2(assetSyncRoot, assetSyncArchivePath);
13647
15390
  }
@@ -13651,13 +15394,13 @@ function buildResetCommand() {
13651
15394
  rmSync(hermesHome, { recursive: true, force: true });
13652
15395
  } else {
13653
15396
  if (!hermesArchivePath) throw new Error("internal: hermesArchivePath missing");
13654
- if (!existsSync18(dirname11(hermesArchivePath))) {
13655
- mkdirSync10(dirname11(hermesArchivePath), { recursive: true });
15397
+ if (!existsSync18(dirname12(hermesArchivePath))) {
15398
+ mkdirSync11(dirname12(hermesArchivePath), { recursive: true });
13656
15399
  }
13657
15400
  renameSync2(hermesHome, hermesArchivePath);
13658
15401
  }
13659
15402
  }
13660
- mkdirSync10(paths.root, { recursive: true });
15403
+ mkdirSync11(paths.root, { recursive: true });
13661
15404
  if (opts.json) {
13662
15405
  printJson({ ok: true, reset: true, plan });
13663
15406
  return;
@@ -13706,7 +15449,7 @@ async function stopProcess(pid, timeoutMs, label) {
13706
15449
  const deadline = Date.now() + timeoutMs;
13707
15450
  while (Date.now() < deadline) {
13708
15451
  if (!pidAlive2(pid)) return;
13709
- await sleep4(100);
15452
+ await sleep5(100);
13710
15453
  }
13711
15454
  try {
13712
15455
  process.kill(pid, "SIGKILL");
@@ -13716,7 +15459,7 @@ async function stopProcess(pid, timeoutMs, label) {
13716
15459
  const killDeadline = Date.now() + 1e3;
13717
15460
  while (Date.now() < killDeadline) {
13718
15461
  if (!pidAlive2(pid)) return;
13719
- await sleep4(100);
15462
+ await sleep5(100);
13720
15463
  }
13721
15464
  throw new Error(`${label} pid ${pid} did not exit within ${timeoutMs + 1e3}ms`);
13722
15465
  }
@@ -13726,13 +15469,13 @@ function timestamp() {
13726
15469
  function resolveAssetSyncRoot() {
13727
15470
  const override = process.env.PRISMER_ASSET_SYNC_ROOT;
13728
15471
  if (override) return override;
13729
- const home = homedir9();
15472
+ const home = homedir10();
13730
15473
  const desktop = join21(home, "Desktop");
13731
15474
  const base = existsSync18(desktop) ? desktop : home;
13732
15475
  return join21(base, "Prismer Assets");
13733
15476
  }
13734
15477
  function resolveHermesHome() {
13735
- return process.env.HERMES_HOME || join21(homedir9(), ".hermes");
15478
+ return process.env.HERMES_HOME || join21(homedir10(), ".hermes");
13736
15479
  }
13737
15480
  function findHermesGatewayPids2() {
13738
15481
  try {
@@ -14287,7 +16030,7 @@ function buildSkillCommand() {
14287
16030
  const cloud = mkCloud6();
14288
16031
  const path9 = opts.agent ? `/api/im/agents/${encodeURIComponent(opts.agent)}/skills` : `/api/im/skills/${encodeURIComponent(slugOrId)}/install`;
14289
16032
  const body = opts.agent ? { skillId: slugOrId, workspaceId: opts.workspaceId, version: opts.version } : { workspaceId: opts.workspaceId, version: opts.version };
14290
- const data = await requestEnvelope(cloud, "POST", path9, body, "skill_install_failed");
16033
+ const data = await requestEnvelope2(cloud, "POST", path9, body, "skill_install_failed");
14291
16034
  if (opts.json) {
14292
16035
  printJson(data);
14293
16036
  return;
@@ -14298,7 +16041,7 @@ function buildSkillCommand() {
14298
16041
  const cloud = mkCloud6();
14299
16042
  const path9 = opts.agent ? `/api/im/agents/${encodeURIComponent(opts.agent)}/skills` : `/api/im/skills/${encodeURIComponent(slugOrId)}/install`;
14300
16043
  const body = opts.agent ? { skillId: slugOrId, workspaceId: opts.workspaceId } : void 0;
14301
- const data = await requestEnvelope(cloud, "DELETE", path9, body, "skill_uninstall_failed");
16044
+ const data = await requestEnvelope2(cloud, "DELETE", path9, body, "skill_uninstall_failed");
14302
16045
  if (opts.json) {
14303
16046
  printJson(data);
14304
16047
  return;
@@ -14351,7 +16094,7 @@ function mkCloud6() {
14351
16094
  const cfg = loadConfig(resolvePaths());
14352
16095
  return new CloudClient({ baseUrl: cfg.cloud_api_base, apiKey: cfg.api_key });
14353
16096
  }
14354
- async function requestEnvelope(cloud, method, path9, body, code) {
16097
+ async function requestEnvelope2(cloud, method, path9, body, code) {
14355
16098
  const res = await cloud.request(method, path9, {
14356
16099
  body
14357
16100
  });
@@ -14501,6 +16244,7 @@ function buildStatusCommand() {
14501
16244
  let me = null;
14502
16245
  let devices = null;
14503
16246
  let agents = null;
16247
+ let diagnose = null;
14504
16248
  try {
14505
16249
  const meRes = await cloud.request("GET", "/api/im/me", { timeoutMs: 3e3 });
14506
16250
  cloudOk = meRes.ok;
@@ -14513,19 +16257,33 @@ function buildStatusCommand() {
14513
16257
  if (Array.isArray(wsList) && wsList.length > 0) {
14514
16258
  const wsId = wsList[0]?.id;
14515
16259
  if (wsId) {
14516
- const devRes = await cloud.request("GET", `/api/workspace/runtime-installations?workspaceId=${encodeURIComponent(wsId)}&includeStopped=false`, { timeoutMs: 3e3 });
14517
- if (devRes.ok) {
14518
- const devBody = devRes.data;
14519
- devices = devBody?.data;
14520
- }
14521
- const agRes = await cloud.request("GET", `/api/im/workspaces/${encodeURIComponent(wsId)}/agents`, { timeoutMs: 3e3 });
14522
- if (agRes.ok) {
14523
- const agBody = agRes.data;
14524
- agents = agBody?.data;
16260
+ const rtRes = await cloud.request("GET", `/api/im/workspaces/${encodeURIComponent(wsId)}/runtime`, { timeoutMs: 3e3 });
16261
+ if (rtRes.ok) {
16262
+ const rtBody = rtRes.data;
16263
+ const snapshot = rtBody?.data;
16264
+ if (snapshot && Array.isArray(snapshot.devices)) {
16265
+ devices = snapshot.devices;
16266
+ agents = snapshot.devices.flatMap((d) => d.agents ?? []);
16267
+ }
14525
16268
  }
14526
16269
  }
14527
16270
  }
14528
16271
  }
16272
+ const daemonId = cfg.daemon_id;
16273
+ if (daemonId) {
16274
+ try {
16275
+ const diagRes = await cloud.request(
16276
+ "GET",
16277
+ `/api/im/runtime/diagnose?daemonId=${encodeURIComponent(daemonId)}`,
16278
+ { timeoutMs: 3e3 }
16279
+ );
16280
+ if (diagRes.ok) {
16281
+ const body = diagRes.data;
16282
+ if (body?.data && typeof body.data === "object") diagnose = body.data;
16283
+ }
16284
+ } catch {
16285
+ }
16286
+ }
14529
16287
  }
14530
16288
  } catch {
14531
16289
  cloudOk = false;
@@ -14542,7 +16300,8 @@ function buildStatusCommand() {
14542
16300
  info: daemonStatus.info ?? {}
14543
16301
  },
14544
16302
  cloud: { base: cfg.cloud_api_base, reachable: cloudOk, me, devices, agents },
14545
- local
16303
+ local,
16304
+ diagnose
14546
16305
  };
14547
16306
  if (opts.json) {
14548
16307
  printJson(report);
@@ -14551,6 +16310,60 @@ function buildStatusCommand() {
14551
16310
  printPretty(report);
14552
16311
  });
14553
16312
  }
16313
+ function computeDeviceIcon(lastSeenAt, now) {
16314
+ if (!lastSeenAt) return "\u25CB";
16315
+ const age = now.getTime() - Date.parse(lastSeenAt);
16316
+ if (!Number.isFinite(age) || age < 0) return "\u25CB";
16317
+ if (age < 5 * 6e4) return "\u25CF";
16318
+ if (age < 60 * 6e4) return "\u25D0";
16319
+ return "\u25CB";
16320
+ }
16321
+ function computeAgentIcon(status, lastHeartbeat, now) {
16322
+ const s = (status || "").toLowerCase();
16323
+ if (s === "error" || s === "failed") return "\u25C6";
16324
+ if (!lastHeartbeat) return s === "offline" ? "\u25CB" : "\u25D0";
16325
+ const age = now.getTime() - Date.parse(lastHeartbeat);
16326
+ if (!Number.isFinite(age) || age < 0 || age >= 60 * 6e4) return "\u25CB";
16327
+ if (age >= 5 * 6e4) return "\u25D0";
16328
+ return "\u25CF";
16329
+ }
16330
+ function pad(value, width) {
16331
+ if (value.length >= width) return value.slice(0, width);
16332
+ return value + " ".repeat(width - value.length);
16333
+ }
16334
+ function formatDeviceBindings(devices, now = /* @__PURE__ */ new Date()) {
16335
+ const lines = [];
16336
+ if (devices.length === 0) {
16337
+ lines.push(" No paired devices.");
16338
+ return lines;
16339
+ }
16340
+ const totalAgents = devices.reduce((s, d) => s + (d.agents?.length ?? 0), 0);
16341
+ const devWord = devices.length === 1 ? "device" : "devices";
16342
+ const agWord = totalAgents === 1 ? "agent" : "agents";
16343
+ lines.push(` Device & Agent Bindings (${devices.length} ${devWord} \xB7 ${totalAgents} ${agWord})`);
16344
+ lines.push("");
16345
+ for (let di = 0; di < devices.length; di++) {
16346
+ const d = devices[di];
16347
+ const dIcon = computeDeviceIcon(d.lastSeenAt, now);
16348
+ const dName = pad(d.name || d.deviceId, 40);
16349
+ const lastSeen = d.lastSeenAt ? formatRelative(d.lastSeenAt, now) : "never seen";
16350
+ lines.push(` ${dIcon} ${dName} ${lastSeen}`);
16351
+ const agents = d.agents ?? [];
16352
+ for (let ai = 0; ai < agents.length; ai++) {
16353
+ const a = agents[ai];
16354
+ const isLast = ai === agents.length - 1;
16355
+ const branch = isLast ? "\u2514\u2500" : "\u251C\u2500";
16356
+ const aIcon = computeAgentIcon(a.status, a.lastHeartbeat, now);
16357
+ const aName = pad(a.name || a.id, 24);
16358
+ const aStatus = pad(a.status || "?", 8);
16359
+ const aVersion = pad(a.version ? `v${a.version}` : "\u2014", 10);
16360
+ const tail = a.currentTaskId ? `task=${a.currentTaskId}` : a.lastHeartbeat ? `\u2764 ${formatRelative(a.lastHeartbeat, now)}` : "";
16361
+ lines.push(` ${branch} ${aIcon} ${aName} ${aStatus} ${aVersion} ${tail}`.trimEnd());
16362
+ }
16363
+ if (di < devices.length - 1) lines.push("");
16364
+ }
16365
+ return lines;
16366
+ }
14554
16367
  async function readDaemonStatus() {
14555
16368
  try {
14556
16369
  const res = await fetch("http://127.0.0.1:3210/healthz", {
@@ -14623,16 +16436,7 @@ function printPretty(report) {
14623
16436
  if (Array.isArray(report.cloud.devices) && report.cloud.devices.length > 0) {
14624
16437
  const devs = report.cloud.devices;
14625
16438
  ui.blank();
14626
- ui.line(` Workspace Devices (${devs.length}):`);
14627
- for (const d of devs) {
14628
- const statusIcon = d.daemonStatus === "connected" ? "\u25CF" : "\u25CB";
14629
- const kind = d.runtimeKind === "docker" ? "Local" : "K8s";
14630
- const declared = d.hostedAgentSummary?.declared ?? 0;
14631
- ui.line(` ${statusIcon} ${d.podName?.replace(/^daemon:/, "")?.slice(0, 28) ?? "?"} kind=${kind} agents=${declared}`);
14632
- }
14633
- }
14634
- if (Array.isArray(report.cloud.agents) && report.cloud.agents.length > 0) {
14635
- ui.line(` Hosted agents: ${report.cloud.agents.length}`);
16439
+ for (const ln of formatDeviceBindings(devs)) ui.line(ln);
14636
16440
  }
14637
16441
  if (report.local) {
14638
16442
  ui.blank();
@@ -14642,11 +16446,63 @@ function printPretty(report) {
14642
16446
  } else {
14643
16447
  warn("Local DB", "unavailable");
14644
16448
  }
16449
+ if (report.diagnose) {
16450
+ ui.blank();
16451
+ ui.header("Cloud dispatch reachability");
16452
+ ui.blank();
16453
+ printDiagnose(report.diagnose, report.daemon.pid ?? null);
16454
+ }
16455
+ }
16456
+ function printDiagnose(d, pid) {
16457
+ const ui = getUI();
16458
+ const heartbeatLine = d.lastHeartbeatAt ? `last heartbeat ${formatRelative(d.lastHeartbeatAt)}` : "no heartbeat seen";
16459
+ if (d.wsAlive) {
16460
+ ok("Daemon", `running${pid ? ` (pid ${pid})` : ""}`);
16461
+ ok("Cloud WS", `connected (${heartbeatLine})`);
16462
+ } else {
16463
+ warn("Daemon", `running${pid ? ` (pid ${pid})` : ""} \u2014 not visible to cloud WS`);
16464
+ fail("Cloud WS", `disconnected (${heartbeatLine})`);
16465
+ }
16466
+ if (d.gatewayUrl) {
16467
+ if (d.gatewayIsPrivate) {
16468
+ warn("HTTP gateway", `${d.gatewayUrl} (private IP \u2014 only reachable on same network)`);
16469
+ } else if (d.httpReachable) {
16470
+ ok(
16471
+ "HTTP gateway",
16472
+ `${d.gatewayUrl} (reachable${d.httpLatencyMs != null ? `, ${d.httpLatencyMs}ms` : ""})`
16473
+ );
16474
+ } else {
16475
+ fail("HTTP gateway", `${d.gatewayUrl} (unreachable${d.httpError ? ` \u2014 ${d.httpError}` : ""})`);
16476
+ }
16477
+ } else {
16478
+ ui.line(" HTTP gateway: not declared (WS-only daemon)");
16479
+ }
16480
+ const activeAgents = /* @__PURE__ */ (() => {
16481
+ return 0;
16482
+ })();
16483
+ if (activeAgents > 0) {
16484
+ ok("Active agents", String(activeAgents));
16485
+ }
16486
+ const recColour = d.recommendedTransport === "ws" ? ok : d.recommendedTransport === "http" ? warn : fail;
16487
+ recColour("Recommended transport", d.recommendedTransport);
16488
+ if (d.lastProbeAt) {
16489
+ ui.line(` Last transport probe: ${formatRelative(d.lastProbeAt)}`);
16490
+ }
16491
+ }
16492
+ function formatRelative(iso, now = /* @__PURE__ */ new Date()) {
16493
+ const ts = Date.parse(iso);
16494
+ if (!Number.isFinite(ts)) return "unknown";
16495
+ const diff = Math.max(0, now.getTime() - ts);
16496
+ if (diff < 3e4) return "just now";
16497
+ if (diff < 6e4) return `${Math.round(diff / 1e3)}s ago`;
16498
+ if (diff < 36e5) return `${Math.round(diff / 6e4)}m ago`;
16499
+ if (diff < 864e5) return `${Math.round(diff / 36e5)}h ago`;
16500
+ return `${Math.round(diff / 864e5)}d ago`;
14645
16501
  }
14646
16502
 
14647
16503
  // src/cli/commands/task.ts
14648
16504
  import { Command as Command18 } from "commander";
14649
- import { setTimeout as sleep5 } from "timers/promises";
16505
+ import { setTimeout as sleep6 } from "timers/promises";
14650
16506
  init_util();
14651
16507
  function describeStatus2(status) {
14652
16508
  return status === 0 ? "network error" : `HTTP ${status}`;
@@ -14729,7 +16585,7 @@ function unwrap2(raw) {
14729
16585
  async function pollUntilTerminal(cloud, taskId, timeoutMs) {
14730
16586
  const deadline = Date.now() + (timeoutMs ?? 5 * 6e4);
14731
16587
  while (Date.now() < deadline) {
14732
- await sleep5(1e3);
16588
+ await sleep6(1e3);
14733
16589
  const res = await cloud.request(
14734
16590
  "GET",
14735
16591
  `/api/im/tasks/${encodeURIComponent(taskId)}`
@@ -15004,7 +16860,7 @@ async function readResponseError(res) {
15004
16860
 
15005
16861
  // src/cli/index.ts
15006
16862
  init_ui();
15007
- var VERSION = "2.0.4";
16863
+ var VERSION = "2.0.6";
15008
16864
  function buildProgram() {
15009
16865
  const program = new Command20("prismer").description("Prismer Cloud daemon CLI (TS-only).").version(VERSION);
15010
16866
  program.addCommand(buildBannerCommand());