@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.cjs CHANGED
@@ -166,6 +166,42 @@ var init_skill_loader2 = __esm({
166
166
  });
167
167
 
168
168
  // src/adapters/hermes/index.ts
169
+ function readPlainRecord(value) {
170
+ return value && typeof value === "object" && !Array.isArray(value) ? value : null;
171
+ }
172
+ function readNonEmptyString(value) {
173
+ return typeof value === "string" && value.trim() ? value.trim() : null;
174
+ }
175
+ function sanitizeRoleSkillSlug(value) {
176
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 60) || "template";
177
+ }
178
+ function renderRoleTemplateSkill(skillName, agents, roleTemplate) {
179
+ const mcpServers = Array.isArray(roleTemplate?.mcpServers) ? roleTemplate.mcpServers : [];
180
+ const tools = /* @__PURE__ */ new Set();
181
+ for (const server of mcpServers) {
182
+ const rec = readPlainRecord(server);
183
+ const allowlist = Array.isArray(rec?.toolsAllowlist) ? rec.toolsAllowlist : [];
184
+ for (const tool of allowlist) {
185
+ if (typeof tool === "string" && tool.trim()) tools.add(tool.trim());
186
+ }
187
+ }
188
+ const allowedTools = [...tools].join(" ");
189
+ const roleTemplateSlug = JSON.stringify(readNonEmptyString(roleTemplate?.slug) ?? null);
190
+ return `---
191
+ name: ${skillName}
192
+ description: Role-template operating playbook projected from Prismer RoleTemplate.
193
+ ${allowedTools ? `allowed-tools: ${allowedTools}
194
+ ` : ""}metadata:
195
+ prismer:
196
+ source: role-template
197
+ roleTemplateSlug: ${roleTemplateSlug}
198
+ ---
199
+
200
+ # Role Template Playbook
201
+
202
+ ${agents.trim()}
203
+ `;
204
+ }
169
205
  function parsePrismerGoals(value) {
170
206
  if (!Array.isArray(value)) return [];
171
207
  return value.map((entry) => {
@@ -575,13 +611,14 @@ function failure(status, body) {
575
611
  error: { code: "adapter_dispatch_failed", message: `Hermes ${status}: ${body || "<no body>"}` }
576
612
  };
577
613
  }
578
- async function consumeSse(body, task) {
614
+ async function consumeSse(body, task, state) {
579
615
  const reader = body.getReader();
580
616
  const decoder = new TextDecoder();
581
617
  let buf = "";
582
618
  let deltas = "";
583
619
  let finalOutput;
584
620
  let lastProgress = 0;
621
+ let approvalRequested = false;
585
622
  for (; ; ) {
586
623
  const readStart = Date.now();
587
624
  const { value, done } = await reader.read();
@@ -630,6 +667,10 @@ async function consumeSse(body, task) {
630
667
  const next = Math.min(0.99, lastProgress + 0.05);
631
668
  lastProgress = next;
632
669
  const toolName = typeof payload.tool === "string" ? payload.tool : typeof payload.name === "string" ? payload.name : void 0;
670
+ if (eventName === "tool.started" && toolName === APPROVAL_TOOL_NAME) {
671
+ approvalRequested = true;
672
+ if (state) state.approvalRequested = true;
673
+ }
633
674
  const previewStr = typeof payload.preview === "string" ? payload.preview : payload.preview != null ? JSON.stringify(payload.preview).slice(0, 200) : void 0;
634
675
  task.onProgress?.({
635
676
  progress: next,
@@ -659,7 +700,53 @@ async function consumeSse(body, task) {
659
700
  }
660
701
  }
661
702
  }
662
- return finalOutput && finalOutput.length > 0 ? finalOutput : deltas;
703
+ return {
704
+ output: finalOutput && finalOutput.length > 0 ? finalOutput : deltas,
705
+ approvalRequested
706
+ };
707
+ }
708
+ async function consumeChatCompletionsSse(body, task) {
709
+ const reader = body.getReader();
710
+ const decoder = new TextDecoder();
711
+ let buf = "";
712
+ let output = "";
713
+ let lastProgress = 0;
714
+ for (; ; ) {
715
+ const { value, done } = await reader.read();
716
+ if (done) break;
717
+ buf += decoder.decode(value, { stream: true });
718
+ const events = buf.split("\n\n");
719
+ buf = events.pop() ?? "";
720
+ for (const ev of events) {
721
+ const dataLine = ev.match(/^data: (.+)$/m);
722
+ if (!dataLine) continue;
723
+ const raw = dataLine[1].trim();
724
+ if (raw === "[DONE]") continue;
725
+ let payload;
726
+ try {
727
+ payload = JSON.parse(raw);
728
+ } catch (err) {
729
+ process.stderr.write(
730
+ `[hermes-adapter] chat-completions SSE parse skipped: ${err.message}
731
+ `
732
+ );
733
+ continue;
734
+ }
735
+ const delta = payload.choices?.[0]?.delta?.content;
736
+ if (typeof delta === "string") {
737
+ output += delta;
738
+ } else if (Array.isArray(delta)) {
739
+ for (const part of delta) {
740
+ if (part?.type === "text" && typeof part.text === "string") output += part.text;
741
+ }
742
+ }
743
+ if (output.length > 0 && lastProgress < 0.99) {
744
+ lastProgress = Math.min(0.99, lastProgress + 0.05);
745
+ task.onProgress?.({ progress: lastProgress, message: "streaming" });
746
+ }
747
+ }
748
+ }
749
+ return output;
663
750
  }
664
751
  async function checkHealth(baseUrl, apiKey) {
665
752
  try {
@@ -740,7 +827,7 @@ function pidAlive(pid) {
740
827
  function escapeRegExp(value) {
741
828
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
742
829
  }
743
- var import_node_child_process3, import_node_fs2, import_node_module, import_node_os, import_node_path2, import_node_url, import_better_sqlite3, YAML, import_zod3, import_meta, PRISMER_IM_SKILL_NAME, PRISMER_IM_SKILL_CONTENT, HermesProfileConfigSchema, hermesAdapter, HermesService;
830
+ var import_node_child_process3, import_node_fs2, import_node_module, import_node_os, import_node_path2, import_node_url, import_better_sqlite3, YAML, import_zod3, import_meta, PRISMER_IM_SKILL_NAME, PRISMER_ROLE_SKILL_PREFIX, PRISMER_IM_SKILL_CONTENT, HermesProfileConfigSchema, hermesAdapter, HermesService, APPROVAL_TOOL_NAME;
744
831
  var init_hermes = __esm({
745
832
  "src/adapters/hermes/index.ts"() {
746
833
  "use strict";
@@ -757,6 +844,7 @@ var init_hermes = __esm({
757
844
  init_skill_loader2();
758
845
  import_meta = {};
759
846
  PRISMER_IM_SKILL_NAME = "prismer-im-collab";
847
+ PRISMER_ROLE_SKILL_PREFIX = "prismer-role";
760
848
  PRISMER_IM_SKILL_CONTENT = `---
761
849
  name: prismer-im-collab
762
850
  description: Coordinate reliably in Prismer conversations, use workspace assets through bounded MCP tools, and keep task work on the board.
@@ -1069,6 +1157,7 @@ No @ needed; the human is the only other party.
1069
1157
  const startedAt = Date.now();
1070
1158
  let runId;
1071
1159
  const sysPrompt = typeof task.metadata?.systemPrompt === "string" ? task.metadata.systemPrompt : void 0;
1160
+ this.installRoleTemplateSkill();
1072
1161
  const skillPrompt = await this.skillLoader.loadSystemPromptFragment().catch((err) => {
1073
1162
  process.stderr.write(
1074
1163
  `[hermes-adapter] failed to read skills for ${this.profileName}: ${err.message}
@@ -1076,7 +1165,21 @@ No @ needed; the human is the only other party.
1076
1165
  );
1077
1166
  return void 0;
1078
1167
  });
1079
- const instructions = [sysPrompt, skillPrompt].filter(Boolean).join("\n\n");
1168
+ const outboxDir = typeof task.metadata?.prismerOutboxDir === "string" ? task.metadata.prismerOutboxDir : void 0;
1169
+ const outboxDirective = outboxDir ? [
1170
+ "[Outbox directive \u2014 MANDATORY, overrides any prior outbox path]",
1171
+ "",
1172
+ "For THIS turn ONLY, your active outbox is:",
1173
+ ` ${outboxDir}`,
1174
+ "",
1175
+ "Any user-deliverable file (PDF, image, CSV, archive, doc) MUST be",
1176
+ "written to that EXACT absolute path. Do NOT reuse the outbox path",
1177
+ "from any previous turn \u2014 those directories no longer accept new",
1178
+ "uploads. Do NOT copy files into the previous outbox; copy them",
1179
+ "into the path above. Files outside this directory will not become",
1180
+ 'chat attachments and the user will see "no files were attached".'
1181
+ ].join("\n") : "";
1182
+ const instructions = [outboxDirective, sysPrompt, skillPrompt].filter(Boolean).join("\n\n");
1080
1183
  if (sysPrompt) {
1081
1184
  try {
1082
1185
  const profileDir = getHermesProfileDir(this.profileName);
@@ -1100,8 +1203,15 @@ No @ needed; the human is the only other party.
1100
1203
  `
1101
1204
  );
1102
1205
  }
1206
+ const sseState = { approvalRequested: false };
1103
1207
  try {
1104
1208
  const nativeBridgePatch = await this.prepareNativeBridgePatch(task);
1209
+ const imageRefs = (task.assetRefs ?? []).filter(
1210
+ (r) => r.mime?.startsWith("image/")
1211
+ );
1212
+ if (imageRefs.length > 0) {
1213
+ return await this.dispatchMultimodalChat(task, instructions, imageRefs, startedAt);
1214
+ }
1105
1215
  const createRes = await fetch(`${this.baseUrl}/v1/runs`, {
1106
1216
  method: "POST",
1107
1217
  headers: {
@@ -1152,10 +1262,10 @@ No @ needed; the human is the only other party.
1152
1262
  }
1153
1263
  };
1154
1264
  }
1155
- const output = await consumeSse(eventsRes.body, task);
1265
+ const sseResult = await consumeSse(eventsRes.body, task, sseState);
1156
1266
  return {
1157
1267
  ok: true,
1158
- output,
1268
+ output: sseResult.output,
1159
1269
  metrics: { durationMs: Date.now() - startedAt },
1160
1270
  metadata: {
1161
1271
  hermes: this.bridgeSnapshot("dispatched", {
@@ -1163,7 +1273,13 @@ No @ needed; the human is the only other party.
1163
1273
  runId,
1164
1274
  baseUrl: this.baseUrl,
1165
1275
  model: this.config.model
1166
- })
1276
+ }),
1277
+ // Surfaced to dispatch.ts so a subsequent reaper kill on this
1278
+ // task is reclassified as `awaiting_human_approval` rather than
1279
+ // `daemon_task_timeout`. Lives under a stable key (not under
1280
+ // `hermes.*`) so non-hermes adapters can adopt the same signal
1281
+ // later without bleeding hermes-specific metadata shape.
1282
+ ...sseResult.approvalRequested ? { approvalRequested: true } : {}
1167
1283
  }
1168
1284
  };
1169
1285
  } catch (err) {
@@ -1173,6 +1289,7 @@ No @ needed; the human is the only other party.
1173
1289
  );
1174
1290
  const categorized = categorizeDispatchError(err, task.signal);
1175
1291
  const bridgeKind = categorized.error?.code === "task_cancelled" ? "cancelled" : "failed";
1292
+ const approvalSignal = sseState.approvalRequested ? { approvalRequested: true } : {};
1176
1293
  return {
1177
1294
  ...categorized,
1178
1295
  metadata: {
@@ -1181,13 +1298,87 @@ No @ needed; the human is the only other party.
1181
1298
  baseUrl: this.baseUrl,
1182
1299
  model: this.config.model,
1183
1300
  ...bridgeKind === "failed" ? { error: err.message } : {}
1184
- })
1301
+ }),
1302
+ ...approvalSignal
1185
1303
  }
1186
1304
  };
1187
1305
  } finally {
1188
1306
  if (runId && this.currentRunId === runId) this.currentRunId = void 0;
1189
1307
  }
1190
1308
  }
1309
+ /**
1310
+ * 14b rev.3 §3.0.4 — Hermes multimodal path.
1311
+ *
1312
+ * Builds an OpenAI-shape chat/completions body where the user message
1313
+ * `content` is a multipart array (`{type:'text'}` + N × `{type:'image_url'}`).
1314
+ * Hermes `_normalize_multimodal_content` (api_server.py:170) collapses the
1315
+ * blocks into its native multimodal representation before routing into
1316
+ * the agent pipeline.
1317
+ *
1318
+ * `image_url.url` is the cdn URL when the daemon's reachability probe said
1319
+ * the upstream LLM can see it (D35 path A); otherwise it's a `data:` URI
1320
+ * carrying the base64 bytes the daemon pre-fetched (D35 path B fallback).
1321
+ * The adapter doesn't decide — `resolveAssetRefs` does, and the adapter
1322
+ * just shuttles `cdnUrl ?? data:base64` into the wire.
1323
+ */
1324
+ async dispatchMultimodalChat(task, instructions, imageRefs, startedAt) {
1325
+ const userContent = [{ type: "text", text: task.prompt }];
1326
+ for (const r of imageRefs) {
1327
+ const url = r.reachable === "cdn" && r.cdnUrl ? r.cdnUrl : r.base64 ? `data:${r.mime ?? "image/png"};base64,${r.base64}` : r.cdnUrl ?? "";
1328
+ if (!url) {
1329
+ userContent.push({
1330
+ type: "text",
1331
+ text: `[image attachment id=${r.assetId} mime=${r.mime ?? "unknown"} unavailable: cdn not reachable and bytes exceeded inline cap]`
1332
+ });
1333
+ continue;
1334
+ }
1335
+ userContent.push({ type: "image_url", image_url: { url, detail: "auto" } });
1336
+ }
1337
+ const messages = [];
1338
+ if (instructions) messages.push({ role: "system", content: instructions });
1339
+ messages.push({ role: "user", content: userContent });
1340
+ const res = await fetch(`${this.baseUrl}/v1/chat/completions`, {
1341
+ method: "POST",
1342
+ headers: {
1343
+ Authorization: `Bearer ${this.config.apiKey}`,
1344
+ "Content-Type": "application/json"
1345
+ },
1346
+ body: JSON.stringify({
1347
+ model: this.config.model,
1348
+ messages,
1349
+ stream: true
1350
+ }),
1351
+ signal: task.signal
1352
+ });
1353
+ if (!res.ok) {
1354
+ const body = await res.text().catch(() => "");
1355
+ return failure(res.status, body);
1356
+ }
1357
+ if (!res.body) {
1358
+ return {
1359
+ ok: false,
1360
+ error: {
1361
+ code: "adapter_dispatch_failed",
1362
+ message: "Hermes /v1/chat/completions returned no body"
1363
+ }
1364
+ };
1365
+ }
1366
+ const output = await consumeChatCompletionsSse(res.body, task);
1367
+ return {
1368
+ ok: true,
1369
+ output,
1370
+ metrics: { durationMs: Date.now() - startedAt },
1371
+ metadata: {
1372
+ hermes: this.bridgeSnapshot("dispatched", {
1373
+ endpoint: "/v1/chat/completions",
1374
+ multimodal: true,
1375
+ imageRefs: imageRefs.length,
1376
+ baseUrl: this.baseUrl,
1377
+ model: this.config.model
1378
+ })
1379
+ }
1380
+ };
1381
+ }
1191
1382
  async shutdown() {
1192
1383
  if (this.currentRunId) await this.stopRun(this.currentRunId);
1193
1384
  }
@@ -1209,6 +1400,25 @@ No @ needed; the human is the only other party.
1209
1400
  ]);
1210
1401
  return { ...kanbanPatch, ...goalsPatch };
1211
1402
  }
1403
+ installRoleTemplateSkill() {
1404
+ const roleTemplate = readPlainRecord(this.config.roleTemplate);
1405
+ const hermesConfig = readPlainRecord(roleTemplate?.hermesConfig);
1406
+ const agents = readNonEmptyString(hermesConfig?.agents);
1407
+ if (!agents) return;
1408
+ const slug = sanitizeRoleSkillSlug(readNonEmptyString(roleTemplate?.slug) ?? "template");
1409
+ const skillName = `${PRISMER_ROLE_SKILL_PREFIX}-${slug}`;
1410
+ const content = renderRoleTemplateSkill(skillName, agents, roleTemplate);
1411
+ try {
1412
+ const skillDir = (0, import_node_path2.join)(this.skillLoader.getSkillsRoot(), skillName);
1413
+ (0, import_node_fs2.mkdirSync)(skillDir, { recursive: true });
1414
+ (0, import_node_fs2.writeFileSync)((0, import_node_path2.join)(skillDir, "SKILL.md"), content, "utf8");
1415
+ } catch (err) {
1416
+ process.stderr.write(
1417
+ `[hermes-adapter] failed to install role-template skill ${skillName} for ${this.profileName}: ${err.message}
1418
+ `
1419
+ );
1420
+ }
1421
+ }
1212
1422
  async prepareNativeKanbanPatch(task) {
1213
1423
  const sourceKind = typeof task.metadata?.sourceKind === "string" ? task.metadata.sourceKind : null;
1214
1424
  const parentTaskId = typeof task.metadata?.parentTaskId === "string" ? task.metadata.parentTaskId : null;
@@ -1317,6 +1527,7 @@ No @ needed; the human is the only other party.
1317
1527
  };
1318
1528
  }
1319
1529
  };
1530
+ APPROVAL_TOOL_NAME = "prismer.approval.request_human_approval";
1320
1531
  }
1321
1532
  });
1322
1533
 
@@ -1348,6 +1559,58 @@ var init_skill_loader3 = __esm({
1348
1559
  });
1349
1560
 
1350
1561
  // src/adapters/openclaw/index.ts
1562
+ function readPlainRecord2(value) {
1563
+ return value && typeof value === "object" && !Array.isArray(value) ? value : null;
1564
+ }
1565
+ function readNonEmptyString2(value) {
1566
+ return typeof value === "string" && value.trim() ? value.trim() : null;
1567
+ }
1568
+ function sanitizeRoleSkillSlug2(value) {
1569
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 60) || "template";
1570
+ }
1571
+ function renderRoleTemplateSkill2(skillName, agents, roleTemplate) {
1572
+ const mcpServers = Array.isArray(roleTemplate?.mcpServers) ? roleTemplate.mcpServers : [];
1573
+ const tools = /* @__PURE__ */ new Set();
1574
+ for (const server of mcpServers) {
1575
+ const rec = readPlainRecord2(server);
1576
+ const allowlist = Array.isArray(rec?.toolsAllowlist) ? rec.toolsAllowlist : [];
1577
+ for (const tool of allowlist) {
1578
+ if (typeof tool === "string" && tool.trim()) tools.add(tool.trim());
1579
+ }
1580
+ }
1581
+ const allowedTools = [...tools].join(" ");
1582
+ const roleTemplateSlug = JSON.stringify(readNonEmptyString2(roleTemplate?.slug) ?? null);
1583
+ return `---
1584
+ name: ${skillName}
1585
+ description: Role-template operating playbook projected from Prismer RoleTemplate.
1586
+ ${allowedTools ? `allowed-tools: ${allowedTools}
1587
+ ` : ""}metadata:
1588
+ prismer:
1589
+ source: role-template
1590
+ roleTemplateSlug: ${roleTemplateSlug}
1591
+ ---
1592
+
1593
+ # Role Template Playbook
1594
+
1595
+ ${agents.trim()}
1596
+ `;
1597
+ }
1598
+ function pickResponsesSource(ref) {
1599
+ if (ref.reachable === "cdn" && ref.cdnUrl) {
1600
+ return { type: "url", url: ref.cdnUrl };
1601
+ }
1602
+ if (ref.base64) {
1603
+ return {
1604
+ type: "base64",
1605
+ data: ref.base64,
1606
+ media_type: ref.mime ?? "application/octet-stream"
1607
+ };
1608
+ }
1609
+ if (ref.cdnUrl) {
1610
+ return { type: "url", url: ref.cdnUrl };
1611
+ }
1612
+ return null;
1613
+ }
1351
1614
  function getOpenClawSkillsDir(profile) {
1352
1615
  const config = OpenClawProfileConfigSchema.parse(profile.config);
1353
1616
  return resolveOpenClawSkillsDir(profile, config);
@@ -1368,15 +1631,17 @@ async function checkHealth2(baseUrl, apiKey) {
1368
1631
  return false;
1369
1632
  }
1370
1633
  }
1371
- var import_zod4, import_node_os2, import_node_path3, OpenClawProfileConfigSchema, openclawAdapter, OpenClawService;
1634
+ var import_zod4, import_node_fs3, import_node_os2, import_node_path3, PRISMER_ROLE_SKILL_PREFIX2, OpenClawProfileConfigSchema, openclawAdapter, OpenClawService;
1372
1635
  var init_openclaw = __esm({
1373
1636
  "src/adapters/openclaw/index.ts"() {
1374
1637
  "use strict";
1375
1638
  import_zod4 = require("zod");
1639
+ import_node_fs3 = require("fs");
1376
1640
  import_node_os2 = require("os");
1377
1641
  import_node_path3 = require("path");
1378
1642
  init_contract();
1379
1643
  init_skill_loader3();
1644
+ PRISMER_ROLE_SKILL_PREFIX2 = "prismer-role";
1380
1645
  OpenClawProfileConfigSchema = import_zod4.z.object({
1381
1646
  /** OpenClaw chat-completions HTTP port. */
1382
1647
  port: import_zod4.z.number().int().min(1).max(65535).default(18789),
@@ -1421,23 +1686,25 @@ var init_openclaw = __esm({
1421
1686
  );
1422
1687
  }
1423
1688
  const skillsDir = resolveOpenClawSkillsDir(profile, config);
1424
- return new OpenClawService(baseUrl, config.apiKey, config.model, new OpenClawSkillLoader(skillsDir));
1689
+ return new OpenClawService(baseUrl, config.apiKey, config.model, config, new OpenClawSkillLoader(skillsDir));
1425
1690
  },
1426
1691
  async health() {
1427
1692
  return { available: true };
1428
1693
  }
1429
1694
  };
1430
1695
  OpenClawService = class {
1431
- constructor(baseUrl, apiKey, model, skillLoader) {
1696
+ constructor(baseUrl, apiKey, model, config, skillLoader) {
1432
1697
  this.baseUrl = baseUrl;
1433
1698
  this.apiKey = apiKey;
1434
1699
  this.model = model;
1700
+ this.config = config;
1435
1701
  this.skillLoader = skillLoader;
1436
1702
  this.id = baseUrl;
1437
1703
  }
1438
1704
  baseUrl;
1439
1705
  apiKey;
1440
1706
  model;
1707
+ config;
1441
1708
  skillLoader;
1442
1709
  id;
1443
1710
  async healthy() {
@@ -1446,17 +1713,47 @@ var init_openclaw = __esm({
1446
1713
  async dispatch(task) {
1447
1714
  const startedAt = Date.now();
1448
1715
  const systemPrompt = typeof task.metadata?.systemPrompt === "string" ? task.metadata.systemPrompt : void 0;
1716
+ this.installRoleTemplateSkill();
1449
1717
  const skillPrompt = await this.skillLoader.loadSystemPromptFragment().catch((err) => {
1450
1718
  process.stderr.write(`[openclaw-adapter] failed to read skills: ${err.message}
1451
1719
  `);
1452
1720
  return void 0;
1453
1721
  });
1454
1722
  const systemContent = [systemPrompt, skillPrompt].filter(Boolean).join("\n\n");
1455
- const messages = [];
1456
- if (systemContent) messages.push({ role: "system", content: systemContent });
1457
- messages.push({ role: "user", content: task.prompt });
1723
+ const refs = task.assetRefs ?? [];
1724
+ const imageRefs = refs.filter((r) => r.mime?.startsWith("image/"));
1725
+ const fileRefs = refs.filter((r) => r.mime && !r.mime.startsWith("image/"));
1726
+ const inputContent = [
1727
+ { type: "input_text", text: task.prompt }
1728
+ ];
1729
+ for (const r of imageRefs) {
1730
+ const source = pickResponsesSource(r);
1731
+ if (!source) {
1732
+ inputContent.push({
1733
+ type: "input_text",
1734
+ text: `[image attachment id=${r.assetId} unavailable: cdn unreachable and bytes exceeded inline cap]`
1735
+ });
1736
+ continue;
1737
+ }
1738
+ inputContent.push({ type: "input_image", source });
1739
+ }
1740
+ for (const r of fileRefs) {
1741
+ const source = pickResponsesSource(r);
1742
+ if (!source) {
1743
+ inputContent.push({
1744
+ type: "input_text",
1745
+ text: `[file attachment id=${r.assetId} mime=${r.mime ?? "unknown"} unavailable]`
1746
+ });
1747
+ continue;
1748
+ }
1749
+ if (r.filename && source.type === "base64") {
1750
+ source.filename = r.filename;
1751
+ }
1752
+ inputContent.push({ type: "input_file", source });
1753
+ }
1754
+ const input = [{ type: "message", role: "user", content: inputContent }];
1458
1755
  try {
1459
- const res = await fetch(`${this.baseUrl}/v1/chat/completions`, {
1756
+ const res = await fetch(`${this.baseUrl}/v1/responses`, {
1460
1757
  method: "POST",
1461
1758
  headers: {
1462
1759
  Authorization: `Bearer ${this.apiKey}`,
@@ -1464,7 +1761,11 @@ var init_openclaw = __esm({
1464
1761
  },
1465
1762
  body: JSON.stringify({
1466
1763
  model: this.model,
1467
- messages,
1764
+ input,
1765
+ // OpenClaw `/v1/responses` accepts top-level `instructions` for
1766
+ // the system prompt — keeping it out of the user message
1767
+ // preserves cleanish channel-context separation.
1768
+ ...systemContent ? { instructions: systemContent } : {},
1468
1769
  stream: false
1469
1770
  }),
1470
1771
  signal: task.signal
@@ -1480,7 +1781,21 @@ var init_openclaw = __esm({
1480
1781
  };
1481
1782
  }
1482
1783
  const json = await res.json();
1483
- let output = json.choices?.[0]?.message?.content ?? "";
1784
+ let output = "";
1785
+ if (Array.isArray(json.output)) {
1786
+ for (const item of json.output) {
1787
+ if (item?.type === "message" && Array.isArray(item.content)) {
1788
+ for (const part of item.content) {
1789
+ if (part?.type === "output_text" && typeof part.text === "string") {
1790
+ output += part.text;
1791
+ }
1792
+ }
1793
+ }
1794
+ }
1795
+ }
1796
+ if (!output) {
1797
+ output = json.choices?.[0]?.message?.content ?? "";
1798
+ }
1484
1799
  const ERR_PREFIX_RE = /^Cannot read properties of undefined \(reading '[^']+'\)\s*\n+/;
1485
1800
  if (ERR_PREFIX_RE.test(output)) {
1486
1801
  process.stderr.write(
@@ -1498,6 +1813,25 @@ var init_openclaw = __esm({
1498
1813
  return categorizeDispatchError(err, task.signal);
1499
1814
  }
1500
1815
  }
1816
+ installRoleTemplateSkill() {
1817
+ const roleTemplate = readPlainRecord2(this.config.roleTemplate);
1818
+ const openclawConfig = readPlainRecord2(roleTemplate?.openclawConfig);
1819
+ const agents = readNonEmptyString2(openclawConfig?.agents);
1820
+ if (!agents) return;
1821
+ const slug = sanitizeRoleSkillSlug2(readNonEmptyString2(roleTemplate?.slug) ?? "template");
1822
+ const skillName = `${PRISMER_ROLE_SKILL_PREFIX2}-${slug}`;
1823
+ const content = renderRoleTemplateSkill2(skillName, agents, roleTemplate);
1824
+ try {
1825
+ const skillDir = (0, import_node_path3.join)(this.skillLoader.getSkillsRoot(), skillName);
1826
+ (0, import_node_fs3.mkdirSync)(skillDir, { recursive: true });
1827
+ (0, import_node_fs3.writeFileSync)((0, import_node_path3.join)(skillDir, "SKILL.md"), content, "utf8");
1828
+ } catch (err) {
1829
+ process.stderr.write(
1830
+ `[openclaw-adapter] failed to install role-template skill ${skillName}: ${err.message}
1831
+ `
1832
+ );
1833
+ }
1834
+ }
1501
1835
  };
1502
1836
  }
1503
1837
  });
@@ -1934,7 +2268,7 @@ var require_package = __commonJS({
1934
2268
  "package.json"(exports2, module2) {
1935
2269
  module2.exports = {
1936
2270
  name: "@prismer/runtime",
1937
- version: "2.0.4",
2271
+ version: "2.0.6",
1938
2272
  description: "Prismer Cloud daemon runtime \u2014 TS-only adapter host for hosted IM agents",
1939
2273
  type: "module",
1940
2274
  main: "dist/index.js",
@@ -1968,6 +2302,7 @@ var require_package = __commonJS({
1968
2302
  },
1969
2303
  dependencies: {
1970
2304
  "@iarna/toml": "^2.2.5",
2305
+ "@modelcontextprotocol/sdk": "^1.29.0",
1971
2306
  "better-sqlite3": "^11.8.0",
1972
2307
  commander: "^12.1.0",
1973
2308
  qrcode: "^1.5.4",
@@ -2125,21 +2460,21 @@ function pidFilePath(paths) {
2125
2460
  return (0, import_node_path5.join)(paths.root, "daemon.pid");
2126
2461
  }
2127
2462
  function writePidFile(paths, pid) {
2128
- (0, import_node_fs4.writeFileSync)(pidFilePath(paths), `${pid}
2463
+ (0, import_node_fs5.writeFileSync)(pidFilePath(paths), `${pid}
2129
2464
  `, "utf8");
2130
2465
  }
2131
2466
  function readPidFile(paths) {
2132
2467
  const p = pidFilePath(paths);
2133
- if (!(0, import_node_fs4.existsSync)(p)) return void 0;
2134
- const raw = (0, import_node_fs4.readFileSync)(p, "utf8").trim();
2468
+ if (!(0, import_node_fs5.existsSync)(p)) return void 0;
2469
+ const raw = (0, import_node_fs5.readFileSync)(p, "utf8").trim();
2135
2470
  const pid = Number.parseInt(raw, 10);
2136
2471
  return Number.isFinite(pid) ? pid : void 0;
2137
2472
  }
2138
2473
  function clearPidFile(paths) {
2139
2474
  const p = pidFilePath(paths);
2140
- if ((0, import_node_fs4.existsSync)(p)) {
2475
+ if ((0, import_node_fs5.existsSync)(p)) {
2141
2476
  try {
2142
- (0, import_node_fs4.unlinkSync)(p);
2477
+ (0, import_node_fs5.unlinkSync)(p);
2143
2478
  } catch {
2144
2479
  }
2145
2480
  }
@@ -2152,11 +2487,11 @@ function pidAlive2(pid) {
2152
2487
  return false;
2153
2488
  }
2154
2489
  }
2155
- var import_node_fs4, import_node_path5, DEFAULT_CLOUD_BASE_URL, ANSI, PKG_VERSION, RUNTIME_SUBTITLE;
2490
+ var import_node_fs5, import_node_path5, DEFAULT_CLOUD_BASE_URL, ANSI, PKG_VERSION, RUNTIME_SUBTITLE;
2156
2491
  var init_util = __esm({
2157
2492
  "src/cli/util.ts"() {
2158
2493
  "use strict";
2159
- import_node_fs4 = require("fs");
2494
+ import_node_fs5 = require("fs");
2160
2495
  import_node_path5 = require("path");
2161
2496
  init_ui();
2162
2497
  DEFAULT_CLOUD_BASE_URL = "https://prismer.cloud";
@@ -2296,7 +2631,7 @@ async function syncInstalledSkillsForDispatch(profile, agentImUserId, cloud, sig
2296
2631
  continue;
2297
2632
  }
2298
2633
  const skillDir = (0, import_node_path12.join)(skillsRoot, slug);
2299
- await import_node_fs11.promises.mkdir(skillDir, { recursive: true });
2634
+ await import_node_fs12.promises.mkdir(skillDir, { recursive: true });
2300
2635
  const localFiles = await walkLocalDir(skillDir);
2301
2636
  let dirty = false;
2302
2637
  let perFileFailures = 0;
@@ -2351,14 +2686,14 @@ async function syncInstalledSkillsForDispatch(profile, agentImUserId, cloud, sig
2351
2686
  perFileFailures++;
2352
2687
  continue;
2353
2688
  }
2354
- await import_node_fs11.promises.mkdir((0, import_node_path12.dirname)(targetPath), { recursive: true });
2355
- await import_node_fs11.promises.writeFile(targetPath, bytes);
2689
+ await import_node_fs12.promises.mkdir((0, import_node_path12.dirname)(targetPath), { recursive: true });
2690
+ await import_node_fs12.promises.writeFile(targetPath, bytes);
2356
2691
  localFiles.delete(file.path);
2357
2692
  dirty = true;
2358
2693
  }
2359
2694
  for (const orphan of localFiles.keys()) {
2360
2695
  try {
2361
- await import_node_fs11.promises.unlink((0, import_node_path12.join)(skillDir, orphan));
2696
+ await import_node_fs12.promises.unlink((0, import_node_path12.join)(skillDir, orphan));
2362
2697
  dirty = true;
2363
2698
  } catch (err) {
2364
2699
  if (err.code !== "ENOENT") {
@@ -2471,7 +2806,7 @@ async function walkLocalDir(root) {
2471
2806
  async function walk2(dir) {
2472
2807
  let entries;
2473
2808
  try {
2474
- entries = await import_node_fs11.promises.readdir(dir, { withFileTypes: true });
2809
+ entries = await import_node_fs12.promises.readdir(dir, { withFileTypes: true });
2475
2810
  } catch (err) {
2476
2811
  if (err.code === "ENOENT") return;
2477
2812
  throw err;
@@ -2482,7 +2817,7 @@ async function walkLocalDir(root) {
2482
2817
  await walk2(full);
2483
2818
  } else if (ent.isFile()) {
2484
2819
  try {
2485
- const buf = await import_node_fs11.promises.readFile(full);
2820
+ const buf = await import_node_fs12.promises.readFile(full);
2486
2821
  const rel = (0, import_node_path12.relative)(root, full).split(import_node_path12.sep).join("/");
2487
2822
  out.set(rel, sha256Buffer(buf));
2488
2823
  } catch {
@@ -2556,11 +2891,11 @@ async function ackSkillSync(cloud, agentImUserId, input, signal) {
2556
2891
  `);
2557
2892
  }
2558
2893
  }
2559
- var import_node_fs11, import_node_path12, import_node_crypto3, skillSyncWarnedProfiles;
2894
+ var import_node_fs12, import_node_path12, import_node_crypto3, skillSyncWarnedProfiles;
2560
2895
  var init_skill_sync = __esm({
2561
2896
  "src/daemon/skill-sync.ts"() {
2562
2897
  "use strict";
2563
- import_node_fs11 = require("fs");
2898
+ import_node_fs12 = require("fs");
2564
2899
  import_node_path12 = require("path");
2565
2900
  import_node_crypto3 = require("crypto");
2566
2901
  init_hermes();
@@ -2574,7 +2909,7 @@ var import_commander20 = require("commander");
2574
2909
 
2575
2910
  // src/cli/commands/adapter.ts
2576
2911
  var import_node_child_process4 = require("child_process");
2577
- var import_node_fs5 = require("fs");
2912
+ var import_node_fs6 = require("fs");
2578
2913
  var import_node_os4 = require("os");
2579
2914
  var import_node_path6 = require("path");
2580
2915
  var import_commander = require("commander");
@@ -3073,7 +3408,7 @@ init_openclaw();
3073
3408
 
3074
3409
  // src/config.ts
3075
3410
  var TOML = __toESM(require("@iarna/toml"), 1);
3076
- var import_node_fs3 = require("fs");
3411
+ var import_node_fs4 = require("fs");
3077
3412
  var import_node_os3 = require("os");
3078
3413
  var import_node_path4 = require("path");
3079
3414
  var import_zod5 = require("zod");
@@ -3116,15 +3451,15 @@ function resolvePaths(home) {
3116
3451
  };
3117
3452
  }
3118
3453
  function configExists(paths = resolvePaths()) {
3119
- return (0, import_node_fs3.existsSync)(paths.configFile);
3454
+ return (0, import_node_fs4.existsSync)(paths.configFile);
3120
3455
  }
3121
3456
  function loadConfig(paths = resolvePaths()) {
3122
- if (!(0, import_node_fs3.existsSync)(paths.configFile)) {
3457
+ if (!(0, import_node_fs4.existsSync)(paths.configFile)) {
3123
3458
  throw new Error(
3124
3459
  `Config not found at ${paths.configFile}. Run \`prismer setup\` to create it.`
3125
3460
  );
3126
3461
  }
3127
- const raw = (0, import_node_fs3.readFileSync)(paths.configFile, "utf8");
3462
+ const raw = (0, import_node_fs4.readFileSync)(paths.configFile, "utf8");
3128
3463
  const parsed = TOML.parse(raw);
3129
3464
  const merged = {
3130
3465
  ...parsed,
@@ -3139,14 +3474,14 @@ function loadConfig(paths = resolvePaths()) {
3139
3474
  return result.data;
3140
3475
  }
3141
3476
  function saveConfig(config, paths = resolvePaths()) {
3142
- if (!(0, import_node_fs3.existsSync)(paths.root)) {
3143
- (0, import_node_fs3.mkdirSync)(paths.root, { recursive: true });
3477
+ if (!(0, import_node_fs4.existsSync)(paths.root)) {
3478
+ (0, import_node_fs4.mkdirSync)(paths.root, { recursive: true });
3144
3479
  }
3145
- if (!(0, import_node_fs3.existsSync)((0, import_node_path4.dirname)(paths.configFile))) {
3146
- (0, import_node_fs3.mkdirSync)((0, import_node_path4.dirname)(paths.configFile), { recursive: true });
3480
+ if (!(0, import_node_fs4.existsSync)((0, import_node_path4.dirname)(paths.configFile))) {
3481
+ (0, import_node_fs4.mkdirSync)((0, import_node_path4.dirname)(paths.configFile), { recursive: true });
3147
3482
  }
3148
3483
  ConfigSchema.parse(config);
3149
- (0, import_node_fs3.writeFileSync)(paths.configFile, TOML.stringify(config), "utf8");
3484
+ (0, import_node_fs4.writeFileSync)(paths.configFile, TOML.stringify(config), "utf8");
3150
3485
  }
3151
3486
  function deriveWsUrl(httpBase) {
3152
3487
  const u = new URL(httpBase);
@@ -3409,15 +3744,15 @@ function runHooks(spec, opts) {
3409
3744
  }
3410
3745
  let backup;
3411
3746
  if (planned.kind === "directory") {
3412
- (0, import_node_fs5.mkdirSync)(planned.path, { recursive: true });
3747
+ (0, import_node_fs6.mkdirSync)(planned.path, { recursive: true });
3413
3748
  const markerPath = (0, import_node_path6.join)(planned.path, "prismer.json");
3414
3749
  backup = backupIfExists(markerPath);
3415
- (0, import_node_fs5.writeFileSync)(markerPath, JSON.stringify(prismerHookMarker(spec), null, 2) + "\n", "utf8");
3750
+ (0, import_node_fs6.writeFileSync)(markerPath, JSON.stringify(prismerHookMarker(spec), null, 2) + "\n", "utf8");
3416
3751
  } else {
3417
- (0, import_node_fs5.mkdirSync)((0, import_node_path6.dirname)(planned.path), { recursive: true });
3752
+ (0, import_node_fs6.mkdirSync)((0, import_node_path6.dirname)(planned.path), { recursive: true });
3418
3753
  backup = backupIfExists(planned.path);
3419
3754
  const merged = mergeHookJson(planned.path, spec);
3420
- (0, import_node_fs5.writeFileSync)(planned.path, JSON.stringify(merged, null, 2) + "\n", "utf8");
3755
+ (0, import_node_fs6.writeFileSync)(planned.path, JSON.stringify(merged, null, 2) + "\n", "utf8");
3421
3756
  }
3422
3757
  return {
3423
3758
  ok: true,
@@ -3486,10 +3821,10 @@ function inspectAuthEnv(spec) {
3486
3821
  case "hermes": {
3487
3822
  const envFile = (0, import_node_path6.join)((0, import_node_os4.homedir)(), ".hermes", ".env");
3488
3823
  return {
3489
- ok: (0, import_node_fs5.existsSync)(envFile) || Boolean(process.env.HERMES_API_KEY || process.env.API_SERVER_KEY),
3824
+ ok: (0, import_node_fs6.existsSync)(envFile) || Boolean(process.env.HERMES_API_KEY || process.env.API_SERVER_KEY),
3490
3825
  present: [
3491
3826
  ...["HERMES_API_KEY", "API_SERVER_KEY"].filter((k) => Boolean(process.env[k])),
3492
- ...(0, import_node_fs5.existsSync)(envFile) ? [envFile] : []
3827
+ ...(0, import_node_fs6.existsSync)(envFile) ? [envFile] : []
3493
3828
  ],
3494
3829
  hints: spec.authHints ?? []
3495
3830
  };
@@ -3497,10 +3832,10 @@ function inspectAuthEnv(spec) {
3497
3832
  case "openclaw": {
3498
3833
  const cfgFile = (0, import_node_path6.join)((0, import_node_os4.homedir)(), ".openclaw", "openclaw.json");
3499
3834
  return {
3500
- ok: (0, import_node_fs5.existsSync)(cfgFile) || Boolean(process.env.OPENCLAW_API_KEY),
3835
+ ok: (0, import_node_fs6.existsSync)(cfgFile) || Boolean(process.env.OPENCLAW_API_KEY),
3501
3836
  present: [
3502
3837
  ...["OPENCLAW_API_KEY"].filter((k) => Boolean(process.env[k])),
3503
- ...(0, import_node_fs5.existsSync)(cfgFile) ? [cfgFile] : []
3838
+ ...(0, import_node_fs6.existsSync)(cfgFile) ? [cfgFile] : []
3504
3839
  ],
3505
3840
  hints: spec.authHints ?? []
3506
3841
  };
@@ -3517,14 +3852,14 @@ function inspectHook(spec) {
3517
3852
  if (spec.name === "openclaw") {
3518
3853
  const jsonPath = (0, import_node_path6.join)((0, import_node_os4.homedir)(), ".openclaw", "hooks.json");
3519
3854
  const markerPath = (0, import_node_path6.join)(target.path, "prismer.json");
3520
- const jsonMarkerPresent = (0, import_node_fs5.existsSync)(jsonPath) && fileContainsPrismerMarker(jsonPath);
3521
- const dirMarkerPresent = (0, import_node_fs5.existsSync)(markerPath) && fileContainsPrismerMarker(markerPath);
3855
+ const jsonMarkerPresent = (0, import_node_fs6.existsSync)(jsonPath) && fileContainsPrismerMarker(jsonPath);
3856
+ const dirMarkerPresent = (0, import_node_fs6.existsSync)(markerPath) && fileContainsPrismerMarker(markerPath);
3522
3857
  return {
3523
3858
  supported: true,
3524
3859
  kind: "json-file-or-directory",
3525
3860
  path: target.path,
3526
3861
  jsonPath,
3527
- exists: (0, import_node_fs5.existsSync)(target.path) || (0, import_node_fs5.existsSync)(jsonPath),
3862
+ exists: (0, import_node_fs6.existsSync)(target.path) || (0, import_node_fs6.existsSync)(jsonPath),
3528
3863
  markerPath,
3529
3864
  markerPresent: jsonMarkerPresent || dirMarkerPresent
3530
3865
  };
@@ -3535,12 +3870,12 @@ function inspectHook(spec) {
3535
3870
  supported: true,
3536
3871
  kind: target.kind,
3537
3872
  path: target.path,
3538
- exists: (0, import_node_fs5.existsSync)(target.path),
3873
+ exists: (0, import_node_fs6.existsSync)(target.path),
3539
3874
  markerPath,
3540
- markerPresent: (0, import_node_fs5.existsSync)(markerPath) && fileContainsPrismerMarker(markerPath)
3875
+ markerPresent: (0, import_node_fs6.existsSync)(markerPath) && fileContainsPrismerMarker(markerPath)
3541
3876
  };
3542
3877
  }
3543
- const exists = (0, import_node_fs5.existsSync)(target.path);
3878
+ const exists = (0, import_node_fs6.existsSync)(target.path);
3544
3879
  if (!exists) {
3545
3880
  return {
3546
3881
  supported: true,
@@ -3576,14 +3911,14 @@ function prismerHookMarker(spec) {
3576
3911
  }
3577
3912
  function mergeHookJson(path9, spec) {
3578
3913
  const marker = prismerHookMarker(spec);
3579
- if (!(0, import_node_fs5.existsSync)(path9)) {
3914
+ if (!(0, import_node_fs6.existsSync)(path9)) {
3580
3915
  return {
3581
3916
  prismer: marker
3582
3917
  };
3583
3918
  }
3584
3919
  let parsed;
3585
3920
  try {
3586
- parsed = JSON.parse((0, import_node_fs5.readFileSync)(path9, "utf8"));
3921
+ parsed = JSON.parse((0, import_node_fs6.readFileSync)(path9, "utf8"));
3587
3922
  } catch (err) {
3588
3923
  throw new Error(`Cannot parse ${path9} as JSON: ${err.message}`);
3589
3924
  }
@@ -3607,17 +3942,17 @@ function mergeHookJson(path9, spec) {
3607
3942
  return next;
3608
3943
  }
3609
3944
  function backupIfExists(path9) {
3610
- if (!(0, import_node_fs5.existsSync)(path9)) return null;
3945
+ if (!(0, import_node_fs6.existsSync)(path9)) return null;
3611
3946
  const suffix = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
3612
3947
  const backup = `${path9}.bak.${suffix}`;
3613
- const stat = (0, import_node_fs5.statSync)(path9);
3948
+ const stat = (0, import_node_fs6.statSync)(path9);
3614
3949
  if (stat.isDirectory()) return null;
3615
- (0, import_node_fs5.copyFileSync)(path9, backup);
3950
+ (0, import_node_fs6.copyFileSync)(path9, backup);
3616
3951
  return backup;
3617
3952
  }
3618
3953
  function fileContainsPrismerMarker(path9) {
3619
3954
  try {
3620
- return (0, import_node_fs5.readFileSync)(path9, "utf8").includes("prismer-daemon-runtime");
3955
+ return (0, import_node_fs6.readFileSync)(path9, "utf8").includes("prismer-daemon-runtime");
3621
3956
  } catch {
3622
3957
  return false;
3623
3958
  }
@@ -3815,7 +4150,7 @@ function mapStatusToCode(status) {
3815
4150
 
3816
4151
  // src/sync/store.ts
3817
4152
  var import_better_sqlite32 = __toESM(require("better-sqlite3"), 1);
3818
- var import_node_fs6 = require("fs");
4153
+ var import_node_fs7 = require("fs");
3819
4154
  var import_node_path7 = require("path");
3820
4155
  var MIGRATIONS = [
3821
4156
  {
@@ -3942,14 +4277,44 @@ var MIGRATIONS = [
3942
4277
  CREATE INDEX IF NOT EXISTS idx_asset_meta_seq
3943
4278
  ON asset_metadata_index(workspace_id, asset_index_seq);
3944
4279
  `
4280
+ },
4281
+ {
4282
+ version: 4,
4283
+ up: `
4284
+ -- Wave 4 / W3 \xA74.3 \u2014 pending dispatch replies for crash recovery.
4285
+ --
4286
+ -- The two-phase reply protocol writes a row here BEFORE calling
4287
+ -- POST /dispatch/:taskId/prepare so that a crash between prepare
4288
+ -- and commit leaves a recoverable trail. On daemon cold-start, any
4289
+ -- row with status='prepared' triggers an idempotent commit retry.
4290
+ --
4291
+ -- The server-side cutoff for orphaned 'prepared' rows is 1h
4292
+ -- (PREPARED_REPLY_ORPHAN_MS in dispatch-reply.service.ts); rows
4293
+ -- older than that will fail commit with DISPATCH_REPLY_INVALID_STATE
4294
+ -- and must be reported to the user as 'dispatch lost'.
4295
+ CREATE TABLE IF NOT EXISTS pending_dispatch_replies (
4296
+ idempotency_key TEXT PRIMARY KEY,
4297
+ task_id TEXT NOT NULL,
4298
+ reply_id TEXT,
4299
+ status TEXT NOT NULL DEFAULT 'pending',
4300
+ payload_json TEXT NOT NULL,
4301
+ prepared_at INTEGER,
4302
+ created_at INTEGER NOT NULL,
4303
+ last_attempt_at INTEGER,
4304
+ attempt_count INTEGER NOT NULL DEFAULT 0,
4305
+ last_error TEXT
4306
+ );
4307
+ CREATE INDEX IF NOT EXISTS idx_pending_reply_task ON pending_dispatch_replies (task_id);
4308
+ CREATE INDEX IF NOT EXISTS idx_pending_reply_status ON pending_dispatch_replies (status, created_at);
4309
+ `
3945
4310
  }
3946
4311
  ];
3947
4312
  function runSql(db, sql) {
3948
4313
  db["exec"](sql);
3949
4314
  }
3950
4315
  function openLocalDb(path9) {
3951
- if (path9 !== ":memory:" && !(0, import_node_fs6.existsSync)((0, import_node_path7.dirname)(path9))) {
3952
- (0, import_node_fs6.mkdirSync)((0, import_node_path7.dirname)(path9), { recursive: true });
4316
+ if (path9 !== ":memory:" && !(0, import_node_fs7.existsSync)((0, import_node_path7.dirname)(path9))) {
4317
+ (0, import_node_fs7.mkdirSync)((0, import_node_path7.dirname)(path9), { recursive: true });
3953
4318
  }
3954
4319
  const db = new import_better_sqlite32.default(path9);
3955
4320
  db.pragma("journal_mode = WAL");
@@ -4271,8 +4636,74 @@ function buildAgentCommand() {
4271
4636
  }
4272
4637
  getUI().ok("Skill install requested", `agent=${opts.agent} skill=${slugOrId}`);
4273
4638
  }, { code: "agent_install_skill_failed" }));
4639
+ 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) => {
4640
+ const cloud = makeCloudClient();
4641
+ const data = await requestEnvelope(cloud, "POST", `/api/im/agents/${encodeURIComponent(imUserId)}/snapshot`, {
4642
+ includeMemory: Boolean(opts.includeMemory),
4643
+ label: opts.label
4644
+ }, "agent_snapshot_failed");
4645
+ if (opts.json) {
4646
+ printJson(data);
4647
+ return;
4648
+ }
4649
+ const rec = data;
4650
+ getUI().ok("Snapshot created", String(rec.id ?? "<unknown>"));
4651
+ getUI().line(`agent=${String(rec.agentImUserId ?? imUserId)} memory=${rec.includeMemory ? "included" : "stripped"}`);
4652
+ }, { code: "agent_snapshot_failed" }));
4653
+ 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) => {
4654
+ const cloud = makeCloudClient();
4655
+ const data = await requestEnvelope(cloud, "POST", `/api/im/agents/${encodeURIComponent(imUserId)}/publish`, {
4656
+ slug: opts.slug,
4657
+ version: opts.version,
4658
+ license: opts.license,
4659
+ metadata: {
4660
+ ...opts.title ? { title: opts.title } : {},
4661
+ ...opts.description ? { description: opts.description } : {}
4662
+ },
4663
+ stripMemory: true
4664
+ }, "agent_publish_failed");
4665
+ if (opts.json) {
4666
+ printJson(data);
4667
+ return;
4668
+ }
4669
+ const rec = data;
4670
+ getUI().ok("Agent Pack published", `${String(rec.slug ?? opts.slug)}@${String(rec.version ?? opts.version ?? "1.0.0")}`);
4671
+ getUI().line(`package=${String(rec.id ?? "<unknown>")}`);
4672
+ }, { code: "agent_publish_failed" }));
4673
+ 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) => {
4674
+ const cloud = makeCloudClient();
4675
+ const data = await requestEnvelope(cloud, "POST", `/api/im/agent-packs/${encodeURIComponent(packIdOrSlug)}/fork`, {
4676
+ targetWorkspaceId: opts.workspaceId,
4677
+ displayName: opts.displayName
4678
+ }, "agent_fork_failed");
4679
+ if (opts.json) {
4680
+ printJson(data);
4681
+ return;
4682
+ }
4683
+ const rec = data;
4684
+ getUI().ok("Agent Pack forked", `newAgent=${String(rec.newImUserId ?? "<unknown>")}`);
4685
+ getUI().line(`did=${String(rec.newDid ?? "not issued")}`);
4686
+ }, { code: "agent_fork_failed" }));
4274
4687
  return cmd;
4275
4688
  }
4689
+ function makeCloudClient() {
4690
+ const cfg = loadConfig(resolvePaths());
4691
+ return new CloudClient({ baseUrl: cfg.cloud_api_base, apiKey: cfg.api_key });
4692
+ }
4693
+ async function requestEnvelope(cloud, method, path9, body, code) {
4694
+ const res = await cloud.request(
4695
+ method,
4696
+ path9,
4697
+ body === void 0 ? void 0 : { body }
4698
+ );
4699
+ if (!res.ok || res.data?.ok === false) {
4700
+ const message = res.error?.message ?? res.data?.error?.message ?? "request failed";
4701
+ exitWithError(`${path9} failed (${res.status === 0 ? "network error" : `HTTP ${res.status}`}): ${message}`, {
4702
+ code: res.error?.code ?? res.data?.error?.code ?? code
4703
+ });
4704
+ }
4705
+ return res.data && typeof res.data === "object" && "data" in res.data ? res.data.data : res.data;
4706
+ }
4276
4707
  function readLocalAgents(localDb) {
4277
4708
  const db = openLocalDb(localDb);
4278
4709
  try {
@@ -4377,13 +4808,13 @@ function whichBinary2(bin) {
4377
4808
  // src/cli/commands/asset.ts
4378
4809
  var import_node_crypto2 = require("crypto");
4379
4810
  var import_commander3 = require("commander");
4380
- var import_node_fs10 = require("fs");
4811
+ var import_node_fs11 = require("fs");
4381
4812
  var import_node_path11 = require("path");
4382
4813
 
4383
4814
  // src/asset-cache.ts
4384
4815
  var import_node_crypto = require("crypto");
4385
4816
  var import_node_dns = require("dns");
4386
- var import_node_fs7 = require("fs");
4817
+ var import_node_fs8 = require("fs");
4387
4818
  var import_node_net = require("net");
4388
4819
  var import_node_path8 = require("path");
4389
4820
  var import_undici = require("undici");
@@ -4408,8 +4839,8 @@ var AssetCache = class {
4408
4839
  this.cloud = opts.cloud;
4409
4840
  this.cacheDir = opts.cacheDir;
4410
4841
  this.maxBytes = opts.maxBytes ?? 5 * 1024 * 1024 * 1024;
4411
- if (!(0, import_node_fs7.existsSync)(this.cacheDir)) {
4412
- (0, import_node_fs7.mkdirSync)(this.cacheDir, { recursive: true });
4842
+ if (!(0, import_node_fs8.existsSync)(this.cacheDir)) {
4843
+ (0, import_node_fs8.mkdirSync)(this.cacheDir, { recursive: true });
4413
4844
  }
4414
4845
  }
4415
4846
  /** Local file path for a content hash. Files split by first 2 chars for fs scaling. */
@@ -4419,7 +4850,7 @@ var AssetCache = class {
4419
4850
  get(hash) {
4420
4851
  const row = this.db.prepare("SELECT * FROM cached_assets WHERE content_hash = ?").get(hash);
4421
4852
  if (!row) return void 0;
4422
- if (!(0, import_node_fs7.existsSync)(row.local_path)) {
4853
+ if (!(0, import_node_fs8.existsSync)(row.local_path)) {
4423
4854
  this.db.prepare("DELETE FROM cached_assets WHERE content_hash = ?").run(hash);
4424
4855
  return void 0;
4425
4856
  }
@@ -4483,12 +4914,12 @@ var AssetCache = class {
4483
4914
  throw new Error(`Asset hash mismatch for ${hash}: downloaded ${actualHash}`);
4484
4915
  }
4485
4916
  const localPath = this.pathFor(hash);
4486
- if (!(0, import_node_fs7.existsSync)((0, import_node_path8.dirname)(localPath))) {
4487
- (0, import_node_fs7.mkdirSync)((0, import_node_path8.dirname)(localPath), { recursive: true });
4917
+ if (!(0, import_node_fs8.existsSync)((0, import_node_path8.dirname)(localPath))) {
4918
+ (0, import_node_fs8.mkdirSync)((0, import_node_path8.dirname)(localPath), { recursive: true });
4488
4919
  }
4489
4920
  const tmpPath = `${localPath}.${process.pid}.${Date.now()}.tmp`;
4490
- (0, import_node_fs7.writeFileSync)(tmpPath, buf);
4491
- (0, import_node_fs7.renameSync)(tmpPath, localPath);
4921
+ (0, import_node_fs8.writeFileSync)(tmpPath, buf);
4922
+ (0, import_node_fs8.renameSync)(tmpPath, localPath);
4492
4923
  const mime = res.headers.get("content-type");
4493
4924
  const now = Date.now();
4494
4925
  this.db.prepare(
@@ -4547,12 +4978,12 @@ var AssetCache = class {
4547
4978
  return { cached: existing, finalUrl, durationMs: Date.now() - started };
4548
4979
  }
4549
4980
  const localPath = this.pathFor(hash);
4550
- if (!(0, import_node_fs7.existsSync)((0, import_node_path8.dirname)(localPath))) {
4551
- (0, import_node_fs7.mkdirSync)((0, import_node_path8.dirname)(localPath), { recursive: true });
4981
+ if (!(0, import_node_fs8.existsSync)((0, import_node_path8.dirname)(localPath))) {
4982
+ (0, import_node_fs8.mkdirSync)((0, import_node_path8.dirname)(localPath), { recursive: true });
4552
4983
  }
4553
4984
  const tmpPath = `${localPath}.${process.pid}.${Date.now()}.tmp`;
4554
- (0, import_node_fs7.writeFileSync)(tmpPath, body);
4555
- (0, import_node_fs7.renameSync)(tmpPath, localPath);
4985
+ (0, import_node_fs8.writeFileSync)(tmpPath, body);
4986
+ (0, import_node_fs8.renameSync)(tmpPath, localPath);
4556
4987
  const now = Date.now();
4557
4988
  this.db.prepare(
4558
4989
  `INSERT OR REPLACE INTO cached_assets
@@ -4580,7 +5011,7 @@ var AssetCache = class {
4580
5011
  if (actualHash !== hash) {
4581
5012
  throw new Error(`Asset hash mismatch for ${hash}: local file is ${actualHash}`);
4582
5013
  }
4583
- const size = (0, import_node_fs7.statSync)(localPath).size;
5014
+ const size = (0, import_node_fs8.statSync)(localPath).size;
4584
5015
  const now = Date.now();
4585
5016
  this.db.prepare(
4586
5017
  `INSERT OR REPLACE INTO cached_assets
@@ -4608,7 +5039,7 @@ var AssetCache = class {
4608
5039
  for (const row of candidates) {
4609
5040
  if (total <= this.maxBytes) break;
4610
5041
  try {
4611
- if ((0, import_node_fs7.existsSync)(row.local_path)) (0, import_node_fs7.unlinkSync)(row.local_path);
5042
+ if ((0, import_node_fs8.existsSync)(row.local_path)) (0, import_node_fs8.unlinkSync)(row.local_path);
4612
5043
  } catch {
4613
5044
  }
4614
5045
  this.db.prepare("DELETE FROM cached_assets WHERE content_hash = ?").run(row.content_hash);
@@ -4623,7 +5054,7 @@ function sha256(buf) {
4623
5054
  return (0, import_node_crypto.createHash)("sha256").update(buf).digest("hex");
4624
5055
  }
4625
5056
  function readFileBuffer(path9) {
4626
- return (0, import_node_fs7.readFileSync)(path9);
5057
+ return (0, import_node_fs8.readFileSync)(path9);
4627
5058
  }
4628
5059
  var DEFAULT_URL_FETCH_MAX_BYTES = 5 * 1024 * 1024;
4629
5060
  var DEFAULT_URL_FETCH_TIMEOUT_MS = 15e3;
@@ -4805,7 +5236,7 @@ async function fetchUrlWithGuards(rawUrl, opts) {
4805
5236
  }
4806
5237
 
4807
5238
  // src/daemon/asset/metadata-index.ts
4808
- var import_node_fs8 = require("fs");
5239
+ var import_node_fs9 = require("fs");
4809
5240
  var import_node_path9 = require("path");
4810
5241
  var DEFAULT_LIMIT = 8;
4811
5242
  var PULL_PAGE_SIZE = 500;
@@ -4834,16 +5265,16 @@ var AssetMetadataIndex = class {
4834
5265
  this.db = opts.db;
4835
5266
  this.cloud = opts.cloud;
4836
5267
  this.workspaceId = opts.workspaceId;
4837
- if (!(0, import_node_fs8.existsSync)(opts.workspaceStateDir)) {
4838
- (0, import_node_fs8.mkdirSync)(opts.workspaceStateDir, { recursive: true });
5268
+ if (!(0, import_node_fs9.existsSync)(opts.workspaceStateDir)) {
5269
+ (0, import_node_fs9.mkdirSync)(opts.workspaceStateDir, { recursive: true });
4839
5270
  }
4840
5271
  this.cursorPath = (0, import_node_path9.join)(opts.workspaceStateDir, "asset-metadata-cursor.json");
4841
5272
  }
4842
5273
  /** Persisted cursor for this workspace, or 0 on first run / corrupted file. */
4843
5274
  readCursor() {
4844
- if (!(0, import_node_fs8.existsSync)(this.cursorPath)) return 0;
5275
+ if (!(0, import_node_fs9.existsSync)(this.cursorPath)) return 0;
4845
5276
  try {
4846
- const parsed = JSON.parse((0, import_node_fs8.readFileSync)(this.cursorPath, "utf8"));
5277
+ const parsed = JSON.parse((0, import_node_fs9.readFileSync)(this.cursorPath, "utf8"));
4847
5278
  if (parsed.workspaceId !== this.workspaceId) return 0;
4848
5279
  return parsed.cursor;
4849
5280
  } catch {
@@ -4856,7 +5287,7 @@ var AssetMetadataIndex = class {
4856
5287
  cursor,
4857
5288
  writtenAt: Date.now()
4858
5289
  };
4859
- (0, import_node_fs8.writeFileSync)(this.cursorPath, JSON.stringify(payload, null, 2));
5290
+ (0, import_node_fs9.writeFileSync)(this.cursorPath, JSON.stringify(payload, null, 2));
4860
5291
  }
4861
5292
  /**
4862
5293
  * Pull incremental asset metadata changes since the persisted cursor and
@@ -4973,7 +5404,7 @@ var AssetMetadataIndex = class {
4973
5404
  };
4974
5405
 
4975
5406
  // src/daemon/asset/mirror.ts
4976
- var import_node_fs9 = require("fs");
5407
+ var import_node_fs10 = require("fs");
4977
5408
  var import_node_path10 = require("path");
4978
5409
  function rowToBinding(row) {
4979
5410
  return {
@@ -4995,16 +5426,16 @@ var WorkspaceMirror = class {
4995
5426
  this.db = opts.db;
4996
5427
  this.cloud = opts.cloud;
4997
5428
  this.workspaceId = opts.workspaceId;
4998
- if (!(0, import_node_fs9.existsSync)(opts.workspaceStateDir)) {
4999
- (0, import_node_fs9.mkdirSync)(opts.workspaceStateDir, { recursive: true });
5429
+ if (!(0, import_node_fs10.existsSync)(opts.workspaceStateDir)) {
5430
+ (0, import_node_fs10.mkdirSync)(opts.workspaceStateDir, { recursive: true });
5000
5431
  }
5001
5432
  this.cursorPath = (0, import_node_path10.join)(opts.workspaceStateDir, "asset-cursor.json");
5002
5433
  }
5003
5434
  /** Persisted cursor for this workspace, or null on first run / corrupted file. */
5004
5435
  readCursor() {
5005
- if (!(0, import_node_fs9.existsSync)(this.cursorPath)) return null;
5436
+ if (!(0, import_node_fs10.existsSync)(this.cursorPath)) return null;
5006
5437
  try {
5007
- const parsed = JSON.parse((0, import_node_fs9.readFileSync)(this.cursorPath, "utf8"));
5438
+ const parsed = JSON.parse((0, import_node_fs10.readFileSync)(this.cursorPath, "utf8"));
5008
5439
  if (parsed.workspaceId !== this.workspaceId) return null;
5009
5440
  return parsed.cursor;
5010
5441
  } catch {
@@ -5017,7 +5448,7 @@ var WorkspaceMirror = class {
5017
5448
  cursor,
5018
5449
  writtenAt: Date.now()
5019
5450
  };
5020
- (0, import_node_fs9.writeFileSync)(this.cursorPath, JSON.stringify(payload, null, 2));
5451
+ (0, import_node_fs10.writeFileSync)(this.cursorPath, JSON.stringify(payload, null, 2));
5021
5452
  }
5022
5453
  /**
5023
5454
  * Pull workspace_file changes since the persisted cursor and upsert into
@@ -5261,8 +5692,8 @@ async function prefetchAssetBytes(opts) {
5261
5692
  return { considered: rows.length, fetched, bytes, failed };
5262
5693
  }
5263
5694
  async function uploadAssetPath(inputPath, opts) {
5264
- if (!(0, import_node_fs10.existsSync)(inputPath)) exitWithError(`path not found: ${inputPath}`);
5265
- const stat = (0, import_node_fs10.lstatSync)(inputPath);
5695
+ if (!(0, import_node_fs11.existsSync)(inputPath)) exitWithError(`path not found: ${inputPath}`);
5696
+ const stat = (0, import_node_fs11.lstatSync)(inputPath);
5266
5697
  if (stat.isSymbolicLink()) exitWithError(`refusing to upload symlink: ${inputPath}`);
5267
5698
  if (stat.isFile()) {
5268
5699
  return uploadAsset(inputPath, { ...opts, folderPath: normalizeFolderPath(opts.folderPath) });
@@ -5280,9 +5711,9 @@ async function uploadAssetPath(inputPath, opts) {
5280
5711
  return { ok: true, data: { count: items.length, items } };
5281
5712
  }
5282
5713
  async function uploadAsset(file, opts) {
5283
- if (!(0, import_node_fs10.existsSync)(file)) exitWithError(`file not found: ${file}`);
5714
+ if (!(0, import_node_fs11.existsSync)(file)) exitWithError(`file not found: ${file}`);
5284
5715
  const cfg = loadConfig(resolvePaths());
5285
- const bytes = (0, import_node_fs10.readFileSync)(file);
5716
+ const bytes = (0, import_node_fs11.readFileSync)(file);
5286
5717
  const contentHash = (0, import_node_crypto2.createHash)("sha256").update(bytes).digest("hex");
5287
5718
  const metadata = parseMetadata(opts.metadata);
5288
5719
  if (opts.taskId && metadata.taskId === void 0) metadata.taskId = opts.taskId;
@@ -5418,10 +5849,10 @@ async function putDirectAssetBytes(plan, bytes, mime) {
5418
5849
  }
5419
5850
  function listFilesRecursive(root) {
5420
5851
  const out = [];
5421
- for (const name of (0, import_node_fs10.readdirSync)(root)) {
5852
+ for (const name of (0, import_node_fs11.readdirSync)(root)) {
5422
5853
  if (name.startsWith(".")) continue;
5423
5854
  const full = (0, import_node_path11.join)(root, name);
5424
- const stat = (0, import_node_fs10.lstatSync)(full);
5855
+ const stat = (0, import_node_fs11.lstatSync)(full);
5425
5856
  if (stat.isSymbolicLink()) continue;
5426
5857
  if (stat.isDirectory()) {
5427
5858
  out.push(...listFilesRecursive(full));
@@ -5455,7 +5886,7 @@ async function downloadAsset(assetId, outPath) {
5455
5886
  exitWithError(`asset download failed (${res.status}): ${errorMessage(body)}`);
5456
5887
  }
5457
5888
  const bytes = Buffer.from(await res.arrayBuffer());
5458
- (0, import_node_fs10.writeFileSync)(outPath, bytes);
5889
+ (0, import_node_fs11.writeFileSync)(outPath, bytes);
5459
5890
  }
5460
5891
  function parseMetadata(raw) {
5461
5892
  if (!raw) return {};
@@ -6102,16 +6533,16 @@ function parsePositiveInt3(value) {
6102
6533
  // src/cli/commands/daemon.ts
6103
6534
  var import_commander8 = require("commander");
6104
6535
  var import_node_child_process7 = require("child_process");
6105
- var import_node_fs19 = require("fs");
6536
+ var import_node_fs20 = require("fs");
6106
6537
  var import_promises2 = require("timers/promises");
6107
6538
  var import_node_path15 = require("path");
6108
6539
 
6109
6540
  // src/daemon/runner.ts
6110
6541
  var import_node_events3 = require("events");
6111
6542
  var import_node_module2 = require("module");
6112
- var import_node_fs18 = require("fs");
6543
+ var import_node_fs19 = require("fs");
6113
6544
  var import_promises = require("fs/promises");
6114
- var import_node_os6 = require("os");
6545
+ var import_node_os8 = require("os");
6115
6546
  var import_node_path14 = require("path");
6116
6547
 
6117
6548
  // src/adapters/registry.ts
@@ -6589,9 +7020,265 @@ function createLogger(tag) {
6589
7020
  }
6590
7021
 
6591
7022
  // src/daemon/dispatch.ts
6592
- var import_node_fs12 = require("fs");
7023
+ var import_node_fs13 = require("fs");
6593
7024
  var path2 = __toESM(require("path"), 1);
6594
7025
  init_skill_sync();
7026
+
7027
+ // src/daemon/task-heartbeat.ts
7028
+ var TaskHeartbeat = class {
7029
+ constructor(opts) {
7030
+ this.opts = opts;
7031
+ this.intervalMs = opts.intervalMs ?? 15e3;
7032
+ this.maxRetries = opts.maxRetries ?? 3;
7033
+ this.retryBackoffMs = opts.retryBackoffMs ?? 100;
7034
+ this.now = opts.now ?? Date.now;
7035
+ }
7036
+ opts;
7037
+ timer;
7038
+ version = 0;
7039
+ taskId;
7040
+ getPhase;
7041
+ /** Tracks the last step time pushed via touchStep() — informational. */
7042
+ lastStepAt;
7043
+ intervalMs;
7044
+ maxRetries;
7045
+ retryBackoffMs;
7046
+ now;
7047
+ /**
7048
+ * Begin heartbeating for `taskId`. `getPhase` is invoked at every tick to
7049
+ * pick up the live phase string — adapters can mutate their phase state
7050
+ * freely and the heartbeat will surface it on the next 15s boundary.
7051
+ *
7052
+ * Calling start() twice on the same instance replaces the active task
7053
+ * (previous timer is cleared). Returns silently when already started for
7054
+ * the same taskId.
7055
+ */
7056
+ start(taskId, getPhase) {
7057
+ if (this.taskId === taskId && this.timer) return;
7058
+ this.stop();
7059
+ this.taskId = taskId;
7060
+ this.getPhase = getPhase;
7061
+ this.timer = setInterval(() => {
7062
+ void this.push();
7063
+ }, this.intervalMs);
7064
+ this.timer.unref?.();
7065
+ }
7066
+ /**
7067
+ * Stop the heartbeat loop. Idempotent.
7068
+ */
7069
+ stop() {
7070
+ if (this.timer) {
7071
+ clearInterval(this.timer);
7072
+ this.timer = void 0;
7073
+ }
7074
+ this.taskId = void 0;
7075
+ this.getPhase = void 0;
7076
+ }
7077
+ /**
7078
+ * Phase transition — push immediately AND update what the timer-driven
7079
+ * tick will report next time. Adapter calls this whenever the lifecycle
7080
+ * advances (thinking → tool_use → reasoning → responding → ...).
7081
+ *
7082
+ * No-op when no task is active.
7083
+ */
7084
+ setPhase(phase) {
7085
+ if (!this.taskId) return;
7086
+ this.getPhase = () => phase;
7087
+ void this.push();
7088
+ }
7089
+ /**
7090
+ * Mark "I just made observable progress." Updates lastStepAt; next push
7091
+ * (timer-driven or setPhase-driven) carries it. Lightweight — no immediate
7092
+ * send. Adapters call this around tool calls / token boundaries so the
7093
+ * cloud reaper can distinguish "alive but slow" from "truly stuck".
7094
+ */
7095
+ touchStep() {
7096
+ this.lastStepAt = this.now();
7097
+ }
7098
+ /**
7099
+ * Manually trigger one heartbeat send (does not affect the timer). Mainly
7100
+ * useful for tests that don't want to await a 15s tick. Returns the
7101
+ * heartbeatVersion that was sent, or undefined if no task is active.
7102
+ */
7103
+ async forceTick() {
7104
+ if (!this.taskId) return void 0;
7105
+ return this.push();
7106
+ }
7107
+ /** Test helper: current heartbeatVersion (last value sent, may be 0 before first tick). */
7108
+ get currentVersion() {
7109
+ return this.version;
7110
+ }
7111
+ async push() {
7112
+ if (!this.taskId || !this.getPhase) return void 0;
7113
+ const taskId = this.taskId;
7114
+ const phase = this.getPhase();
7115
+ const heartbeatVersion = ++this.version;
7116
+ const payload = {
7117
+ taskId,
7118
+ heartbeatVersion,
7119
+ currentPhase: phase,
7120
+ ...this.lastStepAt !== void 0 ? { lastStepAt: this.lastStepAt } : {}
7121
+ };
7122
+ const msg = envelope("task.heartbeat", payload);
7123
+ let lastErr;
7124
+ for (let attempt = 0; attempt <= this.maxRetries; attempt += 1) {
7125
+ try {
7126
+ if (this.opts.ws.isOpen && !this.opts.ws.isOpen()) {
7127
+ return heartbeatVersion;
7128
+ }
7129
+ this.opts.ws.send(msg);
7130
+ return heartbeatVersion;
7131
+ } catch (err) {
7132
+ lastErr = err instanceof Error ? err : new Error(String(err));
7133
+ if (attempt < this.maxRetries) {
7134
+ const backoff = this.retryBackoffMs * Math.pow(2, attempt);
7135
+ await sleep(backoff);
7136
+ }
7137
+ }
7138
+ }
7139
+ if (lastErr) {
7140
+ try {
7141
+ this.opts.onGiveUp?.(taskId, lastErr);
7142
+ } catch {
7143
+ }
7144
+ }
7145
+ return heartbeatVersion;
7146
+ }
7147
+ };
7148
+ function sleep(ms) {
7149
+ return new Promise((resolve3) => {
7150
+ const handle = setTimeout(resolve3, ms);
7151
+ handle.unref?.();
7152
+ });
7153
+ }
7154
+
7155
+ // src/daemon/step-recorder.ts
7156
+ var StepRecorder = class {
7157
+ constructor(opts) {
7158
+ this.opts = opts;
7159
+ this.reasoningThrottleMs = opts.reasoningThrottleMs ?? 500;
7160
+ this.now = opts.now ?? Date.now;
7161
+ }
7162
+ opts;
7163
+ seq = 0;
7164
+ reasoningBuf = { texts: [], firstAt: 0 };
7165
+ reasoningTimer;
7166
+ reasoningThrottleMs;
7167
+ now;
7168
+ /** Push a phase transition step. Immediate (no throttle). */
7169
+ recordPhaseChange(phase) {
7170
+ this.emit("phase_change", { phase });
7171
+ }
7172
+ /**
7173
+ * Push a tool-call step. Immediate.
7174
+ *
7175
+ * `toolCallId` is the daemon-side correlation id used to pair with the
7176
+ * subsequent recordToolResult call. Free-form; adapter decides.
7177
+ */
7178
+ recordToolCall(toolName, input, toolCallId) {
7179
+ this.emit("tool_call", {
7180
+ toolName,
7181
+ inputSummary: summarize2(input),
7182
+ ...toolCallId ? { toolCallId } : {}
7183
+ });
7184
+ }
7185
+ /** Push a tool-result step. Immediate. */
7186
+ recordToolResult(toolCallId, output) {
7187
+ this.emit("tool_result", {
7188
+ toolCallId,
7189
+ outputSummary: summarize2(output)
7190
+ });
7191
+ }
7192
+ /**
7193
+ * Buffer a reasoning chunk for batched dispatch. Multiple chunks within
7194
+ * `reasoningThrottleMs` are concatenated and emitted as a single
7195
+ * `reasoning_chunk` step when the timer fires.
7196
+ */
7197
+ recordReasoningChunk(text) {
7198
+ if (!text) return;
7199
+ if (this.reasoningBuf.texts.length === 0) {
7200
+ this.reasoningBuf.firstAt = this.now();
7201
+ }
7202
+ this.reasoningBuf.texts.push(text);
7203
+ if (this.reasoningTimer) return;
7204
+ this.reasoningTimer = setTimeout(() => {
7205
+ this.flushReasoning();
7206
+ }, this.reasoningThrottleMs);
7207
+ this.reasoningTimer.unref?.();
7208
+ }
7209
+ /** Push an error step. Immediate. */
7210
+ recordError(message, payload) {
7211
+ this.emit("error", { message, ...payload ?? {} });
7212
+ }
7213
+ /**
7214
+ * Force any pending reasoning buffer out the door. Call on shutdown so
7215
+ * trailing tokens aren't lost.
7216
+ */
7217
+ flush() {
7218
+ this.flushReasoning();
7219
+ }
7220
+ /** Test helper. */
7221
+ get currentSeq() {
7222
+ return this.seq;
7223
+ }
7224
+ flushReasoning() {
7225
+ if (this.reasoningTimer) {
7226
+ clearTimeout(this.reasoningTimer);
7227
+ this.reasoningTimer = void 0;
7228
+ }
7229
+ if (this.reasoningBuf.texts.length === 0) return;
7230
+ const text = this.reasoningBuf.texts.join("");
7231
+ const firstAt = this.reasoningBuf.firstAt;
7232
+ this.reasoningBuf = { texts: [], firstAt: 0 };
7233
+ this.emit("reasoning_chunk", { text }, firstAt);
7234
+ }
7235
+ emit(kind, payload, occurredAt) {
7236
+ const frame = {
7237
+ seq: ++this.seq,
7238
+ kind,
7239
+ payload,
7240
+ occurredAt: occurredAt ?? this.now()
7241
+ };
7242
+ const msg = envelope("task.step.append", {
7243
+ taskRunId: this.opts.taskRunId,
7244
+ step: frame
7245
+ });
7246
+ try {
7247
+ if (this.opts.ws.isOpen && !this.opts.ws.isOpen()) {
7248
+ try {
7249
+ this.opts.onSend?.(frame);
7250
+ } catch {
7251
+ }
7252
+ return;
7253
+ }
7254
+ this.opts.ws.send(msg);
7255
+ try {
7256
+ this.opts.onSend?.(frame);
7257
+ } catch {
7258
+ }
7259
+ } catch {
7260
+ }
7261
+ }
7262
+ };
7263
+ var MAX_SUMMARY_CHARS = 512;
7264
+ function summarize2(value) {
7265
+ let text;
7266
+ if (value == null) text = "";
7267
+ else if (typeof value === "string") text = value;
7268
+ else {
7269
+ try {
7270
+ text = JSON.stringify(value);
7271
+ } catch {
7272
+ text = String(value);
7273
+ }
7274
+ }
7275
+ if (text.length > MAX_SUMMARY_CHARS) {
7276
+ return `${text.slice(0, MAX_SUMMARY_CHARS)}\u2026(+${text.length - MAX_SUMMARY_CHARS} chars)`;
7277
+ }
7278
+ return text;
7279
+ }
7280
+
7281
+ // src/daemon/dispatch.ts
6595
7282
  var DEFAULT_CONTEXT_MAX = 8e3;
6596
7283
  var GOAL_CONTEXT_MAX = 4;
6597
7284
  var MEMORY_DIGEST_MAX_BYTES = 4e3;
@@ -6607,12 +7294,14 @@ async function handleDispatch(payload, requestId, deps) {
6607
7294
  const { taskId, agentImUserId } = payload;
6608
7295
  let resolvedHashes = [];
6609
7296
  let reply;
7297
+ let heartbeatRef;
7298
+ let recorderRef;
6610
7299
  const outboxBase = deps.paths?.runsDir ? path2.join(deps.paths.runsDir, taskId) : null;
6611
7300
  const outboxDir = outboxBase ? path2.join(outboxBase, "_outbox") : null;
6612
7301
  const workDir = outboxBase ? path2.join(outboxBase, "workdir") : null;
6613
7302
  if (outboxDir) {
6614
7303
  try {
6615
- await import_node_fs12.promises.mkdir(outboxDir, { recursive: true });
7304
+ await import_node_fs13.promises.mkdir(outboxDir, { recursive: true });
6616
7305
  } catch (err) {
6617
7306
  process.stderr.write(
6618
7307
  `[daemon] outbox provision failed task=${taskId} dir=${outboxDir}: ${err.message}
@@ -6622,7 +7311,7 @@ async function handleDispatch(payload, requestId, deps) {
6622
7311
  }
6623
7312
  if (workDir) {
6624
7313
  try {
6625
- await import_node_fs12.promises.mkdir(workDir, { recursive: true });
7314
+ await import_node_fs13.promises.mkdir(workDir, { recursive: true });
6626
7315
  } catch (err) {
6627
7316
  process.stderr.write(
6628
7317
  `[daemon] workdir provision failed task=${taskId} dir=${workDir}: ${err.message}
@@ -6688,7 +7377,13 @@ async function handleDispatch(payload, requestId, deps) {
6688
7377
  resolvedHashes.push(...r.resolvedHashes);
6689
7378
  rewrittenContext = payload.context.map((e, i) => ({ ...e, content: r.texts[i] }));
6690
7379
  }
6691
- const assetResolution = await resolveAssetRefs(payload.assetRefs, deps.assetCache, taskId);
7380
+ const assetResolution = await resolveAssetRefs(payload.assetRefs, deps.assetCache, taskId, {
7381
+ cloud: deps.cloud,
7382
+ enabled: deps.visionAux?.enabled !== false,
7383
+ cacheDir: deps.visionAux?.cacheDir ?? (deps.paths ? path2.join(deps.paths.cacheDir, "vision-cache") : void 0),
7384
+ timeoutMs: deps.visionAux?.timeoutMs,
7385
+ signal: deps.signal
7386
+ });
6692
7387
  resolvedHashes.push(...assetResolution.pinnedHashes);
6693
7388
  const basePrompt = composePrompt(
6694
7389
  rewrittenPrompt.text,
@@ -6723,10 +7418,45 @@ async function handleDispatch(payload, requestId, deps) {
6723
7418
  }
6724
7419
  const operatingPrinciples = resolveOperatingPrinciples(profile.config);
6725
7420
  const composedSystemPrompt = [profileSystemPrompt, operatingPrinciples].filter((part) => typeof part === "string" && part.length > 0).join("\n\n");
7421
+ let activePhase = "thinking";
7422
+ const heartbeat = new TaskHeartbeat({
7423
+ ws: deps.ws,
7424
+ onGiveUp: (tid, err) => process.stderr.write(
7425
+ `[daemon] task.heartbeat give-up task=${tid}: ${err.message}
7426
+ `
7427
+ )
7428
+ });
7429
+ heartbeatRef = heartbeat;
7430
+ heartbeat.start(taskId, () => activePhase);
7431
+ const recorder = new StepRecorder({ ws: deps.ws, taskRunId: taskId });
7432
+ recorderRef = recorder;
7433
+ recorder.recordPhaseChange(activePhase);
6726
7434
  const taskInput = {
6727
7435
  taskId,
6728
7436
  prompt: adapterPrompt,
6729
- metadata: {
7437
+ heartbeat: {
7438
+ setPhase: (phase) => {
7439
+ activePhase = phase;
7440
+ heartbeat.setPhase(phase);
7441
+ recorder.recordPhaseChange(phase);
7442
+ },
7443
+ touchStep: () => heartbeat.touchStep()
7444
+ },
7445
+ recorder: {
7446
+ recordPhaseChange: (phase) => {
7447
+ activePhase = phase;
7448
+ heartbeat.setPhase(phase);
7449
+ recorder.recordPhaseChange(phase);
7450
+ },
7451
+ recordToolCall: (toolName, input, toolCallId) => recorder.recordToolCall(toolName, input, toolCallId),
7452
+ recordToolResult: (toolCallId, output) => recorder.recordToolResult(toolCallId, output),
7453
+ recordReasoningChunk: (text) => recorder.recordReasoningChunk(text),
7454
+ recordError: (message, payload2) => recorder.recordError(message, payload2)
7455
+ },
7456
+ // 14b rev.3 §9 P3 — multimodal-aware adapters (Hermes, OpenClaw) pick
7457
+ // up the resolved bytes/URLs here; text-only adapters ignore the field.
7458
+ ...assetResolution.resolvedRefs.length > 0 ? { assetRefs: assetResolution.resolvedRefs } : {},
7459
+ metadata: {
6730
7460
  ...payload.metadata,
6731
7461
  conversationId: payload.conversationId,
6732
7462
  prismerGoals: goalContext.map(toGoalMirrorPayload),
@@ -6788,6 +7518,9 @@ async function handleDispatch(payload, requestId, deps) {
6788
7518
  }
6789
7519
  }
6790
7520
  const reaperAborted = deps.signal?.aborted && result.error?.code === "task_cancelled";
7521
+ const approvalRequested = Boolean(
7522
+ result.metadata?.approvalRequested
7523
+ );
6791
7524
  let collectedAssetIds = [];
6792
7525
  if (deps.outboxWatcher && outboxDir) {
6793
7526
  try {
@@ -6799,14 +7532,23 @@ async function handleDispatch(payload, requestId, deps) {
6799
7532
  collectedAssetIds = deps.outboxWatcher.flushPending(taskId);
6800
7533
  }
6801
7534
  const reaperLimitMs = typeof payload.timeoutMs === "number" && payload.timeoutMs > 0 ? payload.timeoutMs : 5 * 6e4;
7535
+ const finalError = approvalRequested ? {
7536
+ code: "awaiting_human_approval",
7537
+ message: "Agent requested human approval and is suspended pending decision. Cloud will redispatch with `approval.decided` once the human responds."
7538
+ } : reaperAborted ? {
7539
+ code: "daemon_task_timeout",
7540
+ message: `Daemon-side reaper aborted after ${reaperLimitMs}ms inactivity (no progress events from adapter)`
7541
+ } : result.error;
6802
7542
  reply = {
6803
7543
  taskId,
7544
+ // When approval is pending we keep ok=false (the run did not produce a
7545
+ // completion-style output) but the cloud-side handler treats the
7546
+ // `awaiting_human_approval` code as non-terminal — task stays alive,
7547
+ // no red failure pill, and the next dispatch (after the human decides)
7548
+ // continues normally.
6804
7549
  ok: result.ok,
6805
7550
  output: result.output,
6806
- error: reaperAborted ? {
6807
- code: "daemon_task_timeout",
6808
- message: `Daemon-side reaper aborted after ${reaperLimitMs}ms inactivity (no progress events from adapter)`
6809
- } : result.error,
7551
+ error: finalError,
6810
7552
  ...collectedAssetIds.length > 0 ? { assetIds: collectedAssetIds } : {},
6811
7553
  metrics: result.metrics,
6812
7554
  ...assetResolution.observability.length > 0 || urlObservations.length > 0 ? { assetObservability: [...assetResolution.observability, ...urlObservations] } : {}
@@ -6828,6 +7570,18 @@ async function handleDispatch(payload, requestId, deps) {
6828
7570
  }
6829
7571
  };
6830
7572
  } finally {
7573
+ try {
7574
+ recorderRef?.flush();
7575
+ } catch (err) {
7576
+ process.stderr.write(`[daemon] step recorder flush failed task=${taskId}: ${err.message}
7577
+ `);
7578
+ }
7579
+ try {
7580
+ heartbeatRef?.stop();
7581
+ } catch (err) {
7582
+ process.stderr.write(`[daemon] heartbeat stop failed task=${taskId}: ${err.message}
7583
+ `);
7584
+ }
6831
7585
  for (const hash of new Set(resolvedHashes)) {
6832
7586
  try {
6833
7587
  deps.assetCache.unpin(hash);
@@ -6904,6 +7658,10 @@ function isTextLikeMime(mime) {
6904
7658
  if (m.endsWith("+json") || m.endsWith("+xml") || m.endsWith("+csv")) return true;
6905
7659
  return false;
6906
7660
  }
7661
+ function isImageMime(mime) {
7662
+ if (!mime) return false;
7663
+ return mime.toLowerCase().split(";")[0].trim().startsWith("image/");
7664
+ }
6907
7665
  var HASH_REF_RE = /(?:^|\s)#([^\s#]+)/g;
6908
7666
  var HEX_COLOR_RE = /^[0-9a-fA-F]{3,8}$/;
6909
7667
  var FILE_EXT_RE = /\.[a-zA-Z0-9]{1,10}$/;
@@ -6982,8 +7740,34 @@ async function resolveHashRefs(prompt, assetIndex, cloud) {
6982
7740
  }
6983
7741
  return { text: result, resolutions };
6984
7742
  }
6985
- async function resolveAssetRefs(refs, cache, taskId) {
6986
- const out = { promptBlocks: [], observability: [], pinnedHashes: [] };
7743
+ var ASSET_BASE64_INLINE_MAX_BYTES = 10 * 1024 * 1024;
7744
+ async function probeCdnReachable(cdnUrl, timeoutMs = 1500) {
7745
+ try {
7746
+ const u = new URL(cdnUrl);
7747
+ if (u.hostname === "localhost" || u.hostname === "127.0.0.1" || u.hostname.startsWith("192.168.")) {
7748
+ return false;
7749
+ }
7750
+ } catch {
7751
+ return false;
7752
+ }
7753
+ try {
7754
+ const r = await fetch(cdnUrl, {
7755
+ method: "HEAD",
7756
+ signal: AbortSignal.timeout(timeoutMs)
7757
+ });
7758
+ if (r.ok) return true;
7759
+ return false;
7760
+ } catch {
7761
+ return false;
7762
+ }
7763
+ }
7764
+ async function resolveAssetRefs(refs, cache, taskId, visionAux) {
7765
+ const out = {
7766
+ promptBlocks: [],
7767
+ observability: [],
7768
+ pinnedHashes: [],
7769
+ resolvedRefs: []
7770
+ };
6987
7771
  if (!refs || refs.length === 0) return out;
6988
7772
  for (const ref of refs) {
6989
7773
  let cached;
@@ -7012,7 +7796,7 @@ async function resolveAssetRefs(refs, cache, taskId) {
7012
7796
  const inlineCandidate = isTextLikeMime(effectiveMime);
7013
7797
  if (inlineCandidate) {
7014
7798
  try {
7015
- const buf = (0, import_node_fs12.readFileSync)(cached.localPath);
7799
+ const buf = (0, import_node_fs13.readFileSync)(cached.localPath);
7016
7800
  let strategy;
7017
7801
  let body;
7018
7802
  if (buf.byteLength <= ASSET_INLINE_FULL_MAX_BYTES) {
@@ -7048,7 +7832,42 @@ async function resolveAssetRefs(refs, cache, taskId) {
7048
7832
  continue;
7049
7833
  }
7050
7834
  }
7051
- out.promptBlocks.push(formatUriOnlyAssetBlock(ref, effectiveMime, cached.localPath));
7835
+ let reachable = "unknown";
7836
+ let base64;
7837
+ if (ref.cdnUrl) {
7838
+ const ok2 = await probeCdnReachable(ref.cdnUrl);
7839
+ if (ok2) {
7840
+ reachable = "cdn";
7841
+ }
7842
+ }
7843
+ if (reachable !== "cdn") {
7844
+ try {
7845
+ const buf = (0, import_node_fs13.readFileSync)(cached.localPath);
7846
+ if (buf.byteLength <= ASSET_BASE64_INLINE_MAX_BYTES) {
7847
+ base64 = buf.toString("base64");
7848
+ reachable = "base64";
7849
+ } else {
7850
+ reachable = "unknown";
7851
+ }
7852
+ } catch (err) {
7853
+ const errMsg = `base64 fallback read failed: ${err.message}`;
7854
+ logAssetResolveFailure(taskId, ref, errMsg, effectiveMime);
7855
+ }
7856
+ }
7857
+ const resolvedRef = {
7858
+ ...ref,
7859
+ mime: effectiveMime,
7860
+ localPath: cached.localPath,
7861
+ base64,
7862
+ reachable
7863
+ };
7864
+ out.resolvedRefs.push(resolvedRef);
7865
+ if (isImageMime(effectiveMime) && visionAux?.enabled) {
7866
+ const block = await describeImageAttachment(ref, effectiveMime, resolvedRef, visionAux, taskId);
7867
+ out.promptBlocks.push(block);
7868
+ } else {
7869
+ out.promptBlocks.push(formatAttachmentReminderBlock(ref, effectiveMime));
7870
+ }
7052
7871
  out.observability.push({
7053
7872
  assetId: ref.assetId,
7054
7873
  contentHash: ref.contentHash,
@@ -7059,6 +7878,110 @@ async function resolveAssetRefs(refs, cache, taskId) {
7059
7878
  }
7060
7879
  return out;
7061
7880
  }
7881
+ async function describeImageAttachment(ref, mime, resolved, opts, taskId) {
7882
+ const cached = await readVisionAuxLocalCache(opts.cacheDir, ref.contentHash, mime);
7883
+ if (cached) return formatVisionAuxBlock(ref, mime, cached.description, true);
7884
+ const source = buildVisionAuxSource(resolved, mime);
7885
+ if (!source) {
7886
+ return formatVisionAuxUnavailableBlock(ref, mime, "no reachable URL or inline bytes");
7887
+ }
7888
+ try {
7889
+ const description = await callVisionAux(ref, mime, source, opts);
7890
+ await writeVisionAuxLocalCache(opts.cacheDir, ref.contentHash, mime, description);
7891
+ return formatVisionAuxBlock(ref, mime, description.description, false);
7892
+ } catch (err) {
7893
+ const message = err instanceof Error ? err.message : String(err);
7894
+ process.stderr.write(
7895
+ `[daemon] vision-aux failed task=${taskId ?? "unknown"} assetId=${ref.assetId} hash=${ref.contentHash}: ${message}
7896
+ `
7897
+ );
7898
+ return formatVisionAuxUnavailableBlock(ref, mime, message);
7899
+ }
7900
+ }
7901
+ function buildVisionAuxSource(resolved, mime) {
7902
+ if (resolved.reachable === "cdn" && resolved.cdnUrl) return { kind: "url", url: resolved.cdnUrl };
7903
+ if (resolved.base64 && mime) return { kind: "data_url", url: `data:${mime};base64,${resolved.base64}` };
7904
+ return null;
7905
+ }
7906
+ async function callVisionAux(ref, mime, source, opts) {
7907
+ const secret = process.env.VISION_AUX_INTERNAL_SECRET || process.env.INTERNAL_API_SECRET || process.env.PRISMER_INTERNAL_SECRET;
7908
+ const headers = secret ? { "x-prismer-internal-secret": secret } : void 0;
7909
+ const res = await opts.cloud.request(
7910
+ "POST",
7911
+ "/api/internal/vision-aux/describe",
7912
+ {
7913
+ body: {
7914
+ assetId: ref.assetId,
7915
+ contentHash: ref.contentHash,
7916
+ mime: mime ?? ref.mime ?? "image/unknown",
7917
+ source
7918
+ },
7919
+ headers,
7920
+ timeoutMs: opts.timeoutMs ?? 12e3,
7921
+ signal: opts.signal
7922
+ }
7923
+ );
7924
+ const env = res.data;
7925
+ if (!res.ok || env?.ok === false || !env?.data?.description) {
7926
+ const message = res.error?.message || (typeof env?.error === "string" ? env.error : env?.error?.message) || env?.message || `HTTP ${res.status}`;
7927
+ throw new Error(message);
7928
+ }
7929
+ const now = /* @__PURE__ */ new Date();
7930
+ const ttlSec = Number.isFinite(env.data.cacheTtlSec) ? Number(env.data.cacheTtlSec) : 300;
7931
+ return {
7932
+ description: env.data.description,
7933
+ modelUsed: env.data.modelUsed || "unknown",
7934
+ provider: env.data.provider || "vision-aux",
7935
+ generatedAt: now.toISOString(),
7936
+ expiresAt: new Date(now.getTime() + Math.max(1, ttlSec) * 1e3).toISOString(),
7937
+ mime: mime ?? ref.mime ?? "image/unknown"
7938
+ };
7939
+ }
7940
+ async function readVisionAuxLocalCache(cacheDir, contentHash, mime) {
7941
+ if (!cacheDir) return null;
7942
+ const file = visionAuxCachePath(cacheDir, contentHash);
7943
+ try {
7944
+ const raw = await import_node_fs13.promises.readFile(file, "utf8");
7945
+ const parsed = parseVisionAuxCache(raw);
7946
+ if (!parsed) {
7947
+ await import_node_fs13.promises.unlink(file).catch(() => void 0);
7948
+ return null;
7949
+ }
7950
+ if (parsed.mime !== mime) return null;
7951
+ const expiresAt = Date.parse(parsed.expiresAt);
7952
+ if (!Number.isFinite(expiresAt) || expiresAt <= Date.now()) return null;
7953
+ return parsed;
7954
+ } catch (err) {
7955
+ if (err.code !== "ENOENT") {
7956
+ await import_node_fs13.promises.unlink(file).catch(() => void 0);
7957
+ }
7958
+ return null;
7959
+ }
7960
+ }
7961
+ async function writeVisionAuxLocalCache(cacheDir, contentHash, mime, description) {
7962
+ if (!cacheDir || !mime) return;
7963
+ const file = visionAuxCachePath(cacheDir, contentHash);
7964
+ await import_node_fs13.promises.mkdir(path2.dirname(file), { recursive: true });
7965
+ await import_node_fs13.promises.writeFile(file, JSON.stringify({ ...description, mime }, null, 2), "utf8");
7966
+ }
7967
+ function visionAuxCachePath(cacheDir, contentHash) {
7968
+ const safeHash = /^[a-f0-9]{32,128}$/i.test(contentHash) ? contentHash : createSafeCacheKey(contentHash);
7969
+ return path2.join(cacheDir, `${safeHash}.json`);
7970
+ }
7971
+ function createSafeCacheKey(value) {
7972
+ return Buffer.from(value).toString("base64url").slice(0, 128) || "unknown";
7973
+ }
7974
+ function parseVisionAuxCache(raw) {
7975
+ try {
7976
+ const parsed = JSON.parse(raw);
7977
+ 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") {
7978
+ return parsed;
7979
+ }
7980
+ } catch {
7981
+ return null;
7982
+ }
7983
+ return null;
7984
+ }
7062
7985
  function logAssetResolveFailure(taskId, ref, error, mime = ref.mime) {
7063
7986
  const message = error.replace(/\s+/g, " ").slice(0, 500);
7064
7987
  process.stderr.write(
@@ -7073,8 +7996,19 @@ function formatInlineAssetBlock(ref, mime, body, strategy) {
7073
7996
  ${body}
7074
7997
  ---`;
7075
7998
  }
7076
- function formatUriOnlyAssetBlock(ref, mime, localPath) {
7077
- return `[Attached file] id=${ref.assetId} mime=${mime ?? "unknown"} path=file://${localPath} \u2014 open with your read/parse tool when needed.`;
7999
+ function formatAttachmentReminderBlock(ref, mime) {
8000
+ const name = ref.filename ? ` name=${ref.filename}` : "";
8001
+ return `[Attached file] id=${ref.assetId} mime=${mime ?? "unknown"}${name} \u2014 the user uploaded this file; multimodal adapters receive the bytes directly.`;
8002
+ }
8003
+ function formatVisionAuxBlock(ref, mime, description, cached) {
8004
+ const name = ref.filename ? `: ${ref.filename}` : "";
8005
+ const cacheLabel = cached ? " local-cache" : "";
8006
+ return `[Image attachment${name}] id=${ref.assetId} mime=${mime ?? "unknown"}${cacheLabel}
8007
+ ${description.trim()}`;
8008
+ }
8009
+ function formatVisionAuxUnavailableBlock(ref, mime, reason) {
8010
+ const name = ref.filename ? `: ${ref.filename}` : "";
8011
+ return `[Image attachment${name}; description unavailable due to vision-aux error] id=${ref.assetId} mime=${mime ?? "unknown"} reason=${reason.slice(0, 180)}`;
7078
8012
  }
7079
8013
  function formatErrorAssetBlock(ref, mime, error) {
7080
8014
  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.`;
@@ -7414,8 +8348,9 @@ function updatedTime(task) {
7414
8348
  // src/daemon/local-server.ts
7415
8349
  var import_node_http = require("http");
7416
8350
  var import_node_crypto4 = require("crypto");
7417
- var import_node_fs13 = require("fs");
8351
+ var import_node_fs14 = require("fs");
7418
8352
  var path3 = __toESM(require("path"), 1);
8353
+ var import_node_os5 = require("os");
7419
8354
 
7420
8355
  // src/daemon/message-dispatch.ts
7421
8356
  var DEFAULT_TIMEOUT_MS = 6e4;
@@ -7626,6 +8561,11 @@ var LocalServer = class {
7626
8561
  void this.handleSnapshot(req, res);
7627
8562
  return;
7628
8563
  }
8564
+ const agentDumpMatch = /^\/v1\/agents\/([^/]+)\/dump-state$/.exec(url);
8565
+ if (req.method === "POST" && agentDumpMatch) {
8566
+ void this.handleAgentDumpState(req, res, decodeURIComponent(agentDumpMatch[1]));
8567
+ return;
8568
+ }
7629
8569
  if (req.method === "POST" && url === "/local/asset/write") {
7630
8570
  void this.handleAssetWrite(req, res);
7631
8571
  return;
@@ -7676,7 +8616,7 @@ var LocalServer = class {
7676
8616
  async handleSnapshot(req, res) {
7677
8617
  const root = this.opts.snapshotRoot ?? "/workspace";
7678
8618
  try {
7679
- const st = await import_node_fs13.promises.stat(root);
8619
+ const st = await import_node_fs14.promises.stat(root);
7680
8620
  if (!st.isDirectory()) {
7681
8621
  respond(res, 400, { error: "snapshot_root_not_directory", rootPath: root });
7682
8622
  return;
@@ -7703,6 +8643,54 @@ var LocalServer = class {
7703
8643
  }
7704
8644
  void req;
7705
8645
  }
8646
+ /**
8647
+ * POST /v1/agents/:agentId/dump-state — agent-scoped daemon manifest.
8648
+ *
8649
+ * This is narrower than `/v1/snapshot`: it walks only the current agent's
8650
+ * profile/work dirs so cloud can attach the result to IMAgentSnapshot without
8651
+ * capturing unrelated agents hosted in the same daemon/container.
8652
+ */
8653
+ async handleAgentDumpState(req, res, agentId) {
8654
+ const state = this.opts.getState();
8655
+ const agent = state.hostedAgents.find((item) => item.imUserId === agentId);
8656
+ if (!agent) {
8657
+ respond(res, 404, { error: "agent_not_hosted", agentId });
8658
+ return;
8659
+ }
8660
+ const roots = getAgentStateRoots(agent, this.opts.snapshotRoot ?? "/workspace");
8661
+ try {
8662
+ const manifests = await Promise.all(
8663
+ roots.map(async (root) => {
8664
+ const stat = await statOptional(root.rootPath);
8665
+ if (!stat) return { ...root, exists: false, files: [] };
8666
+ if (!stat.isDirectory()) return { ...root, exists: true, error: "not_directory", files: [] };
8667
+ return { ...root, exists: true, files: await walkAndDigest(root.rootPath, root.rootPath) };
8668
+ })
8669
+ );
8670
+ const files = manifests.flatMap(
8671
+ (manifest) => manifest.files.map((file) => ({
8672
+ ...file,
8673
+ path: `${manifest.kind}/${file.path}`,
8674
+ rootKind: manifest.kind,
8675
+ rootPath: manifest.rootPath
8676
+ }))
8677
+ );
8678
+ respond(res, 200, {
8679
+ agentId,
8680
+ adapterName: agent.adapterName,
8681
+ dumpedAt: (/* @__PURE__ */ new Date()).toISOString(),
8682
+ roots: manifests,
8683
+ files
8684
+ });
8685
+ } catch (err) {
8686
+ respond(res, 500, {
8687
+ error: "agent_dump_state_failed",
8688
+ agentId,
8689
+ message: err instanceof Error ? err.message : String(err)
8690
+ });
8691
+ }
8692
+ void req;
8693
+ }
7706
8694
  /**
7707
8695
  * POST /local/asset/write — agent-gen adapter RPC.
7708
8696
  *
@@ -7974,11 +8962,44 @@ function validateAgentDispatchRequest(body) {
7974
8962
  if (payload.attachments != null && !Array.isArray(payload.attachments)) return "attachments must be an array";
7975
8963
  return null;
7976
8964
  }
8965
+ function getAgentStateRoots(agent, snapshotRoot) {
8966
+ const profileName = sanitizeProfileName(agent.name || agent.imUserId);
8967
+ const roots = [
8968
+ {
8969
+ kind: "workspace-agent",
8970
+ rootPath: path3.join(snapshotRoot, "agents", agent.imUserId)
8971
+ }
8972
+ ];
8973
+ if (agent.adapterName === "hermes" || agent.adapterName === "claude-code") {
8974
+ roots.unshift({
8975
+ kind: "hermes-profile",
8976
+ rootPath: path3.join(process.env.HERMES_HOME || path3.join((0, import_node_os5.homedir)(), ".hermes"), "profiles", profileName)
8977
+ });
8978
+ }
8979
+ if (agent.adapterName === "openclaw") {
8980
+ roots.unshift({
8981
+ kind: "openclaw-profile",
8982
+ rootPath: path3.join(process.env.OPENCLAW_HOME || path3.join((0, import_node_os5.homedir)(), ".openclaw"), "profiles", profileName)
8983
+ });
8984
+ }
8985
+ return roots;
8986
+ }
8987
+ function sanitizeProfileName(value) {
8988
+ return value.replace(/[\\/]/g, "_").trim() || "default";
8989
+ }
8990
+ async function statOptional(rootPath) {
8991
+ try {
8992
+ return await import_node_fs14.promises.stat(rootPath);
8993
+ } catch (err) {
8994
+ if (err.code === "ENOENT") return null;
8995
+ throw err;
8996
+ }
8997
+ }
7977
8998
  async function walkAndDigest(root, current) {
7978
8999
  const out = [];
7979
9000
  let entries;
7980
9001
  try {
7981
- entries = await import_node_fs13.promises.readdir(current);
9002
+ entries = await import_node_fs14.promises.readdir(current);
7982
9003
  } catch (err) {
7983
9004
  if (err.code === "ENOENT") return out;
7984
9005
  throw err;
@@ -7989,7 +9010,7 @@ async function walkAndDigest(root, current) {
7989
9010
  const rel = path3.relative(root, full);
7990
9011
  let st;
7991
9012
  try {
7992
- st = await import_node_fs13.promises.stat(full);
9013
+ st = await import_node_fs14.promises.stat(full);
7993
9014
  } catch {
7994
9015
  continue;
7995
9016
  }
@@ -8000,7 +9021,7 @@ async function walkAndDigest(root, current) {
8000
9021
  continue;
8001
9022
  }
8002
9023
  if (!st.isFile()) continue;
8003
- const buf = await import_node_fs13.promises.readFile(full);
9024
+ const buf = await import_node_fs14.promises.readFile(full);
8004
9025
  const sha2563 = (0, import_node_crypto4.createHash)("sha256").update(buf).digest("hex");
8005
9026
  out.push({
8006
9027
  path: rel,
@@ -9551,7 +10572,7 @@ function parsePrismerUri(uri) {
9551
10572
  }
9552
10573
 
9553
10574
  // src/daemon/asset/rpc.ts
9554
- var import_node_fs14 = require("fs");
10575
+ var import_node_fs15 = require("fs");
9555
10576
  var ASSET_PATH_PREFIX = "/local/asset/";
9556
10577
  var MAX_READ_BYTES = 256 * 1024;
9557
10578
  var MAX_SEARCH_BYTES = 1024 * 1024;
@@ -9726,16 +10747,16 @@ function isTextLike(row) {
9726
10747
  );
9727
10748
  }
9728
10749
  function readFileRange(path9, offset, length) {
9729
- const sizeBytes = (0, import_node_fs14.statSync)(path9).size;
10750
+ const sizeBytes = (0, import_node_fs15.statSync)(path9).size;
9730
10751
  if (offset >= sizeBytes) return { buffer: Buffer.alloc(0), bytesRead: 0, sizeBytes };
9731
- const fd = (0, import_node_fs14.openSync)(path9, "r");
10752
+ const fd = (0, import_node_fs15.openSync)(path9, "r");
9732
10753
  try {
9733
10754
  const target = Math.min(length, sizeBytes - offset);
9734
10755
  const buffer = Buffer.alloc(target);
9735
- const bytesRead = (0, import_node_fs14.readSync)(fd, buffer, 0, target, offset);
10756
+ const bytesRead = (0, import_node_fs15.readSync)(fd, buffer, 0, target, offset);
9736
10757
  return { buffer: buffer.subarray(0, bytesRead), bytesRead, sizeBytes };
9737
10758
  } finally {
9738
- (0, import_node_fs14.closeSync)(fd);
10759
+ (0, import_node_fs15.closeSync)(fd);
9739
10760
  }
9740
10761
  }
9741
10762
  function respond3(res, status, body) {
@@ -9760,8 +10781,8 @@ async function readJson3(req) {
9760
10781
  }
9761
10782
 
9762
10783
  // src/daemon/asset/origin/drop-folder.ts
9763
- var import_node_fs15 = require("fs");
9764
- var import_node_os5 = require("os");
10784
+ var import_node_fs16 = require("fs");
10785
+ var import_node_os6 = require("os");
9765
10786
  var path6 = __toESM(require("path"), 1);
9766
10787
  var DropFolderAdapter = class {
9767
10788
  kind = "drop-folder";
@@ -9769,7 +10790,7 @@ var DropFolderAdapter = class {
9769
10790
  rootDir;
9770
10791
  constructor(opts = {}) {
9771
10792
  this.workspaceDir = opts.workspaceDir;
9772
- this.rootDir = opts.rootDir ?? path6.join(opts.homeDir ?? (0, import_node_os5.homedir)(), ".prismer");
10793
+ this.rootDir = opts.rootDir ?? path6.join(opts.homeDir ?? (0, import_node_os6.homedir)(), ".prismer");
9773
10794
  }
9774
10795
  /**
9775
10796
  * Long-lived async iterable wrapping `scanOnce` on a 1s polling interval.
@@ -9779,7 +10800,7 @@ var DropFolderAdapter = class {
9779
10800
  while (true) {
9780
10801
  const batch = await this.scanOnce(workspaceId);
9781
10802
  for (const obs of batch) yield obs;
9782
- await sleep(1e3);
10803
+ await sleep2(1e3);
9783
10804
  }
9784
10805
  }
9785
10806
  /**
@@ -9793,7 +10814,7 @@ var DropFolderAdapter = class {
9793
10814
  const drop = this.dropDir(workspaceId);
9794
10815
  let entries = [];
9795
10816
  try {
9796
- entries = await import_node_fs15.promises.readdir(drop);
10817
+ entries = await import_node_fs16.promises.readdir(drop);
9797
10818
  } catch (err) {
9798
10819
  if (err.code === "ENOENT") return [];
9799
10820
  throw err;
@@ -9802,12 +10823,12 @@ var DropFolderAdapter = class {
9802
10823
  for (const name of entries) {
9803
10824
  if (name.startsWith(".")) continue;
9804
10825
  const fullPath = path6.join(drop, name);
9805
- const stat = await import_node_fs15.promises.lstat(fullPath).catch(() => null);
10826
+ const stat = await import_node_fs16.promises.lstat(fullPath).catch(() => null);
9806
10827
  if (!stat) continue;
9807
10828
  if (stat.isSymbolicLink()) continue;
9808
10829
  if (stat.isDirectory()) {
9809
10830
  for (const nested of await walk(fullPath)) {
9810
- const nstat = await import_node_fs15.promises.lstat(nested).catch(() => null);
10831
+ const nstat = await import_node_fs16.promises.lstat(nested).catch(() => null);
9811
10832
  if (!nstat || !nstat.isFile()) continue;
9812
10833
  out.push(makeObservation(workspaceId, nested, drop, nstat.size, Math.floor(nstat.mtimeMs)));
9813
10834
  }
@@ -9832,7 +10853,7 @@ var DropFolderAdapter = class {
9832
10853
  }
9833
10854
  async fetch(obs) {
9834
10855
  const detail = obs.detail;
9835
- const bytes = await import_node_fs15.promises.readFile(detail.path);
10856
+ const bytes = await import_node_fs16.promises.readFile(detail.path);
9836
10857
  return {
9837
10858
  bytes,
9838
10859
  mime: mimeFromExtension(detail.path),
@@ -9854,8 +10875,8 @@ var DropFolderAdapter = class {
9854
10875
  const rel = typeof detail.watchDir === "string" ? path6.relative(detail.watchDir, detail.path) : path6.basename(detail.path);
9855
10876
  const safeRel = rel && !rel.startsWith("..") && !path6.isAbsolute(rel) ? rel : path6.basename(detail.path);
9856
10877
  const destPath = path6.join(destDir, safeRel);
9857
- await import_node_fs15.promises.mkdir(path6.dirname(destPath), { recursive: true });
9858
- await import_node_fs15.promises.rename(detail.path, destPath);
10878
+ await import_node_fs16.promises.mkdir(path6.dirname(destPath), { recursive: true });
10879
+ await import_node_fs16.promises.rename(detail.path, destPath);
9859
10880
  } catch (err) {
9860
10881
  if (err.code === "ENOENT") return;
9861
10882
  throw err;
@@ -9883,11 +10904,11 @@ function makeObservation(workspaceId, filePath, watchDir, size, mtime) {
9883
10904
  }
9884
10905
  async function walk(root) {
9885
10906
  const out = [];
9886
- const entries = await import_node_fs15.promises.readdir(root).catch(() => []);
10907
+ const entries = await import_node_fs16.promises.readdir(root).catch(() => []);
9887
10908
  for (const name of entries) {
9888
10909
  if (name.startsWith(".")) continue;
9889
10910
  const full = path6.join(root, name);
9890
- const stat = await import_node_fs15.promises.lstat(full).catch(() => null);
10911
+ const stat = await import_node_fs16.promises.lstat(full).catch(() => null);
9891
10912
  if (!stat) continue;
9892
10913
  if (stat.isSymbolicLink()) continue;
9893
10914
  if (stat.isDirectory()) {
@@ -9928,7 +10949,7 @@ function mimeFromExtension(p) {
9928
10949
  const ext = path6.extname(p).toLowerCase();
9929
10950
  return MIME_BY_EXT[ext] ?? null;
9930
10951
  }
9931
- function sleep(ms) {
10952
+ function sleep2(ms) {
9932
10953
  return new Promise((r) => setTimeout(r, ms));
9933
10954
  }
9934
10955
 
@@ -10483,7 +11504,7 @@ function stringValue(obj, key) {
10483
11504
  }
10484
11505
 
10485
11506
  // src/daemon/outbox-watcher.ts
10486
- var import_node_fs16 = require("fs");
11507
+ var import_node_fs17 = require("fs");
10487
11508
  var path8 = __toESM(require("path"), 1);
10488
11509
 
10489
11510
  // src/daemon/asset/agent-output-policy.ts
@@ -10639,9 +11660,120 @@ function extensionOf(filename) {
10639
11660
  return idx >= 0 ? name.slice(idx) : "";
10640
11661
  }
10641
11662
 
11663
+ // src/daemon/asset/magic-bytes.ts
11664
+ var PDF_SIG = Buffer.from("%PDF-", "ascii");
11665
+ var PNG_SIG = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
11666
+ var JPEG_SIG = Buffer.from([255, 216, 255]);
11667
+ var GIF87 = Buffer.from("GIF87a", "ascii");
11668
+ var GIF89 = Buffer.from("GIF89a", "ascii");
11669
+ var ZIP_LOCAL = Buffer.from([80, 75, 3, 4]);
11670
+ var ZIP_EMPTY = Buffer.from([80, 75, 5, 6]);
11671
+ var ZIP_SPANNED = Buffer.from([80, 75, 7, 8]);
11672
+ function detectMagicBytes(bytes) {
11673
+ const head = bytes.subarray(0, Math.min(bytes.length, 4096));
11674
+ if (head.length === 0) return { detected: null, mime: null };
11675
+ if (head.length >= PDF_SIG.length && head.subarray(0, PDF_SIG.length).equals(PDF_SIG)) {
11676
+ return { detected: "pdf", mime: "application/pdf" };
11677
+ }
11678
+ if (head.length >= PNG_SIG.length && head.subarray(0, PNG_SIG.length).equals(PNG_SIG)) {
11679
+ return { detected: "png", mime: "image/png" };
11680
+ }
11681
+ if (head.length >= JPEG_SIG.length && head.subarray(0, JPEG_SIG.length).equals(JPEG_SIG)) {
11682
+ return { detected: "jpeg", mime: "image/jpeg" };
11683
+ }
11684
+ if (head.length >= GIF87.length && head.subarray(0, GIF87.length).equals(GIF87) || head.length >= GIF89.length && head.subarray(0, GIF89.length).equals(GIF89)) {
11685
+ return { detected: "gif", mime: "image/gif" };
11686
+ }
11687
+ 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))) {
11688
+ return { detected: "zip", mime: "application/zip" };
11689
+ }
11690
+ const text = head.toString("utf8");
11691
+ const trimmed = text.trimStart();
11692
+ if (/^<\?xml[^>]*>\s*<svg[\s>]/i.test(trimmed) || /^<svg[\s>]/i.test(trimmed)) {
11693
+ return { detected: "svg", mime: "image/svg+xml" };
11694
+ }
11695
+ if (/^<(!doctype html|html[\s>])/i.test(trimmed)) {
11696
+ return { detected: "html", mime: "text/html" };
11697
+ }
11698
+ if (/^[{[]/.test(trimmed)) {
11699
+ return { detected: "json", mime: "application/json" };
11700
+ }
11701
+ if (looksLikePrintableText(head)) {
11702
+ return { detected: "markdown-or-text", mime: "text/plain" };
11703
+ }
11704
+ return { detected: null, mime: null };
11705
+ }
11706
+ var ZIP_DERIVED_MIMES = /* @__PURE__ */ new Set([
11707
+ "application/zip",
11708
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
11709
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
11710
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation",
11711
+ "application/vnd.oasis.opendocument.text",
11712
+ "application/vnd.oasis.opendocument.spreadsheet",
11713
+ "application/vnd.oasis.opendocument.presentation",
11714
+ "application/epub+zip",
11715
+ "application/java-archive"
11716
+ ]);
11717
+ function mimeMismatchReason(detected, declared) {
11718
+ if (detected.detected === null) return null;
11719
+ const dec = (declared ?? "").toLowerCase().split(";")[0]?.trim() ?? "";
11720
+ if (!dec || dec === "application/octet-stream") return null;
11721
+ switch (detected.detected) {
11722
+ case "pdf":
11723
+ return dec === "application/pdf" ? null : `declared ${dec} but content is application/pdf`;
11724
+ case "png":
11725
+ return dec === "image/png" ? null : `declared ${dec} but content is image/png`;
11726
+ case "jpeg":
11727
+ return dec === "image/jpeg" || dec === "image/jpg" ? null : `declared ${dec} but content is image/jpeg`;
11728
+ case "gif":
11729
+ return dec === "image/gif" ? null : `declared ${dec} but content is image/gif`;
11730
+ case "zip":
11731
+ if (ZIP_DERIVED_MIMES.has(dec)) return null;
11732
+ return `declared ${dec} but content is a ZIP-container (application/zip family)`;
11733
+ case "svg":
11734
+ if (dec === "image/svg+xml" || dec.startsWith("text/")) return null;
11735
+ return `declared ${dec} but content is image/svg+xml`;
11736
+ case "html":
11737
+ if (dec === "text/html" || dec === "application/xhtml+xml" || dec.startsWith("text/")) return null;
11738
+ if (dec === "application/pdf" || dec.startsWith("image/") || dec === "application/zip") {
11739
+ return `declared ${dec} but content is text/html`;
11740
+ }
11741
+ return null;
11742
+ case "json":
11743
+ if (dec === "application/json" || dec.startsWith("text/")) return null;
11744
+ if (dec === "application/pdf" || dec.startsWith("image/") || dec === "application/zip") {
11745
+ return `declared ${dec} but content is application/json`;
11746
+ }
11747
+ return null;
11748
+ case "markdown-or-text":
11749
+ if (dec === "application/pdf" || dec.startsWith("image/") || dec === "application/zip") {
11750
+ return `declared ${dec} but content is text (not a real ${dec} payload)`;
11751
+ }
11752
+ return null;
11753
+ default:
11754
+ return null;
11755
+ }
11756
+ }
11757
+ function looksLikePrintableText(buf) {
11758
+ if (buf.length === 0) return false;
11759
+ let printable = 0;
11760
+ let total = 0;
11761
+ for (let i = 0; i < buf.length; i++) {
11762
+ const b = buf[i];
11763
+ if (b === 0) return false;
11764
+ total++;
11765
+ if (b >= 32 && b <= 126 || b === 9 || b === 10 || b === 13 || // utf-8 lead bytes
11766
+ b >= 128) {
11767
+ printable++;
11768
+ }
11769
+ }
11770
+ return total > 0 && printable / total >= 0.95;
11771
+ }
11772
+
10642
11773
  // src/daemon/outbox-watcher.ts
10643
11774
  var DEFAULT_INTERVAL_MS = 2e3;
10644
11775
  var RESERVED_SUBDIR = "_uploaded";
11776
+ var REJECTED_SUBDIR = "_rejected";
10645
11777
  var OutboxWatcher = class {
10646
11778
  constructor(opts) {
10647
11779
  this.opts = opts;
@@ -10806,7 +11938,7 @@ var OutboxWatcher = class {
10806
11938
  async scanDir(dir, kind, task) {
10807
11939
  let entries = [];
10808
11940
  try {
10809
- entries = await import_node_fs16.promises.readdir(dir);
11941
+ entries = await import_node_fs17.promises.readdir(dir);
10810
11942
  } catch (err) {
10811
11943
  const code = err.code;
10812
11944
  if (code === "ENOENT") return;
@@ -10816,10 +11948,11 @@ var OutboxWatcher = class {
10816
11948
  for (const name of entries) {
10817
11949
  if (name.startsWith(".")) continue;
10818
11950
  if (name === RESERVED_SUBDIR) continue;
11951
+ if (name === REJECTED_SUBDIR) continue;
10819
11952
  const full = path8.join(dir, name);
10820
11953
  let st;
10821
11954
  try {
10822
- st = await import_node_fs16.promises.stat(full);
11955
+ st = await import_node_fs17.promises.stat(full);
10823
11956
  } catch {
10824
11957
  continue;
10825
11958
  }
@@ -10830,10 +11963,77 @@ var OutboxWatcher = class {
10830
11963
  await this.upload(full, st, kind, task);
10831
11964
  this.uploaded.add(key);
10832
11965
  } catch (err) {
10833
- this.log("warn", `upload ${full} failed: ${err.message}`);
11966
+ const msg = err.message;
11967
+ this.log("warn", `upload ${full} failed: ${msg}`);
11968
+ if (/\bMIME_MISMATCH\b/i.test(msg) || /\bmime mismatch\b/i.test(msg)) {
11969
+ await this.quarantine(dir, full).catch(
11970
+ (moveErr) => this.log("warn", `quarantine ${full} failed: ${moveErr.message}`)
11971
+ );
11972
+ this.uploaded.add(key);
11973
+ await this.reportMimeMismatchToCloud(task, full, msg).catch(
11974
+ (reportErr) => this.log("warn", `report MIME_MISMATCH failed: ${reportErr.message}`)
11975
+ );
11976
+ }
10834
11977
  }
10835
11978
  }
10836
11979
  }
11980
+ /**
11981
+ * F2B — report an OUTBOX_MIME_MISMATCH task event to cloud so the task log
11982
+ * gains an auditable record of the rejection. Without this, the failure is
11983
+ * silent (file quarantined locally, agent unaware) and the next dispatch
11984
+ * loops on the same bad strategy.
11985
+ *
11986
+ * Best-effort: every catch site swallows errors. We never want this
11987
+ * upstream call to block local quarantine or trigger a retry loop, because
11988
+ * the reject decision is already final by the time we get here.
11989
+ *
11990
+ * Endpoint contract: POST /api/im/tasks/:id/event
11991
+ * { code: 'OUTBOX_MIME_MISMATCH', payload: { file, daemonError }, message }
11992
+ * Only callable when `task?.taskId` is known — pre-dispatch outbox files
11993
+ * (no active task) are quarantined silently as before.
11994
+ */
11995
+ async reportMimeMismatchToCloud(task, filePath, daemonErrorMessage) {
11996
+ if (!task?.taskId) return;
11997
+ const fileName = path8.basename(filePath);
11998
+ const reasonMatch = daemonErrorMessage.match(/MIME_MISMATCH[^:]*:\s*(.+)$/i);
11999
+ const reason = reasonMatch?.[1] ? reasonMatch[1].trim() : daemonErrorMessage;
12000
+ const body = {
12001
+ code: "OUTBOX_MIME_MISMATCH",
12002
+ payload: {
12003
+ file: fileName,
12004
+ reason,
12005
+ daemonError: daemonErrorMessage
12006
+ },
12007
+ 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.`
12008
+ };
12009
+ const res = await this.opts.cloud.request("POST", `/api/im/tasks/${encodeURIComponent(task.taskId)}/event`, {
12010
+ body
12011
+ });
12012
+ if (!res.ok) {
12013
+ throw new Error(`cloud rejected event: ${res.status} ${res.error?.code ?? ""} ${res.error?.message ?? ""}`);
12014
+ }
12015
+ }
12016
+ /**
12017
+ * Move a file under `<dir>/_rejected/` so it stops being a scan candidate
12018
+ * (the loop skips the reserved subdir) and operators can find the
12019
+ * offending artifact. Filename collision is rare per task; on collision
12020
+ * we append a millisecond suffix to keep the original observable.
12021
+ */
12022
+ async quarantine(dir, full) {
12023
+ const rejectedDir = path8.join(dir, REJECTED_SUBDIR);
12024
+ await import_node_fs17.promises.mkdir(rejectedDir, { recursive: true });
12025
+ const base = path8.basename(full);
12026
+ let dest = path8.join(rejectedDir, base);
12027
+ try {
12028
+ await import_node_fs17.promises.access(dest);
12029
+ const ext = path8.extname(base);
12030
+ const stem = base.slice(0, base.length - ext.length);
12031
+ dest = path8.join(rejectedDir, `${stem}.${Date.now()}${ext}`);
12032
+ } catch {
12033
+ }
12034
+ await import_node_fs17.promises.rename(full, dest);
12035
+ this.log("warn", `quarantined ${full} \u2192 ${dest}`);
12036
+ }
10837
12037
  async upload(filePath, st, kind, task) {
10838
12038
  const wsId = this.opts.workspaceId();
10839
12039
  if (!wsId) {
@@ -10844,13 +12044,18 @@ var OutboxWatcher = class {
10844
12044
  this.log("warn", `skip ${filePath}: no active taskId (no dispatch/handoff received yet)`);
10845
12045
  return;
10846
12046
  }
10847
- const bytes = await import_node_fs16.promises.readFile(filePath);
12047
+ const bytes = await import_node_fs17.promises.readFile(filePath);
10848
12048
  const fileName = path8.basename(filePath);
10849
12049
  const inferredMime = inferAgentOutputMime(fileName);
10850
12050
  const policy = validateAgentOutputAsset({ filename: fileName, mime: inferredMime, sizeBytes: bytes.length });
10851
12051
  if (!policy.ok) {
10852
12052
  throw new Error(`agent output rejected: ${policy.reason}`);
10853
12053
  }
12054
+ const detection = detectMagicBytes(bytes);
12055
+ const reason = mimeMismatchReason(detection, policy.mime);
12056
+ if (reason) {
12057
+ throw new Error(`MIME_MISMATCH for ${fileName}: ${reason}`);
12058
+ }
10854
12059
  const metadata = {
10855
12060
  taskId: task.taskId,
10856
12061
  ...task.adapter ? { adapter: task.adapter } : {}
@@ -10932,7 +12137,7 @@ var ServicePool = class {
10932
12137
 
10933
12138
  // src/daemon/shell-executor.ts
10934
12139
  var import_node_child_process6 = require("child_process");
10935
- var import_node_fs17 = require("fs");
12140
+ var import_node_fs18 = require("fs");
10936
12141
  var import_node_path13 = require("path");
10937
12142
  var DEFAULT_OUTPUT_LIMIT = 256 * 1024;
10938
12143
  var DEFAULT_TIMEOUT = 6e4;
@@ -10969,7 +12174,7 @@ async function executeShellDispatch(payload, deps) {
10969
12174
  const command = readCommand(payload, execution);
10970
12175
  if (!command.trim()) return fail2(payload.taskId, "shell_command_required", "Shell command is required");
10971
12176
  const cwd = resolveCwd(execution.cwd, deps.config.defaultCwd);
10972
- if (!(0, import_node_fs17.existsSync)(cwd)) return fail2(payload.taskId, "shell_cwd_missing", `cwd does not exist: ${cwd}`);
12177
+ if (!(0, import_node_fs18.existsSync)(cwd)) return fail2(payload.taskId, "shell_cwd_missing", `cwd does not exist: ${cwd}`);
10973
12178
  const timeoutMs = Math.min(
10974
12179
  typeof payload.timeoutMs === "number" && payload.timeoutMs > 0 ? payload.timeoutMs : deps.config.maxTimeoutMs,
10975
12180
  deps.config.maxTimeoutMs
@@ -11204,6 +12409,350 @@ var WsClient = class extends import_node_events2.EventEmitter {
11204
12409
  }
11205
12410
  };
11206
12411
 
12412
+ // src/daemon/pending-reply-cache.ts
12413
+ function fromRaw(r) {
12414
+ return {
12415
+ idempotencyKey: r.idempotency_key,
12416
+ taskId: r.task_id,
12417
+ replyId: r.reply_id,
12418
+ status: r.status,
12419
+ payloadJson: r.payload_json,
12420
+ createdAt: r.created_at,
12421
+ preparedAt: r.prepared_at,
12422
+ lastAttemptAt: r.last_attempt_at,
12423
+ attemptCount: r.attempt_count,
12424
+ lastError: r.last_error
12425
+ };
12426
+ }
12427
+ function createPendingReplyCache(db) {
12428
+ const insert = db.prepare(
12429
+ `INSERT OR REPLACE INTO pending_dispatch_replies
12430
+ (idempotency_key, task_id, reply_id, status, payload_json,
12431
+ prepared_at, created_at, last_attempt_at, attempt_count, last_error)
12432
+ VALUES (?, ?, NULL, 'pending', ?, NULL, ?, NULL, 0, NULL)`
12433
+ );
12434
+ const markPreparedStmt = db.prepare(
12435
+ `UPDATE pending_dispatch_replies
12436
+ SET reply_id = ?, status = 'prepared', prepared_at = ?,
12437
+ last_attempt_at = ?, attempt_count = attempt_count + 1,
12438
+ last_error = NULL
12439
+ WHERE idempotency_key = ?`
12440
+ );
12441
+ const markAttemptStmt = db.prepare(
12442
+ `UPDATE pending_dispatch_replies
12443
+ SET last_attempt_at = ?, attempt_count = attempt_count + 1,
12444
+ last_error = ?
12445
+ WHERE idempotency_key = ?`
12446
+ );
12447
+ const clearStmt = db.prepare(
12448
+ `DELETE FROM pending_dispatch_replies WHERE idempotency_key = ?`
12449
+ );
12450
+ const listRecoveryStmt = db.prepare(
12451
+ `SELECT * FROM pending_dispatch_replies
12452
+ WHERE status IN ('pending', 'prepared')
12453
+ ORDER BY created_at ASC`
12454
+ );
12455
+ const countStmt = db.prepare(
12456
+ `SELECT status, COUNT(*) as cnt FROM pending_dispatch_replies
12457
+ WHERE status IN ('pending','prepared') GROUP BY status`
12458
+ );
12459
+ const findStmt = db.prepare(
12460
+ `SELECT * FROM pending_dispatch_replies WHERE idempotency_key = ?`
12461
+ );
12462
+ return {
12463
+ savePending(idempotencyKey, taskId, payloadJson, now = Date.now()) {
12464
+ insert.run(idempotencyKey, taskId, payloadJson, now);
12465
+ },
12466
+ markPrepared(idempotencyKey, replyId, now = Date.now()) {
12467
+ markPreparedStmt.run(replyId, now, now, idempotencyKey);
12468
+ },
12469
+ markAttempt(idempotencyKey, now = Date.now(), error) {
12470
+ markAttemptStmt.run(now, error ?? null, idempotencyKey);
12471
+ },
12472
+ clearPending(idempotencyKey) {
12473
+ clearStmt.run(idempotencyKey);
12474
+ },
12475
+ listForRecovery() {
12476
+ const rows = listRecoveryStmt.all();
12477
+ return rows.map(fromRaw);
12478
+ },
12479
+ countByStatus() {
12480
+ const rows = countStmt.all();
12481
+ const result = { pending: 0, prepared: 0 };
12482
+ for (const r of rows) {
12483
+ if (r.status === "pending") result.pending = r.cnt;
12484
+ else if (r.status === "prepared") result.prepared = r.cnt;
12485
+ }
12486
+ return result;
12487
+ },
12488
+ findByIdempotencyKey(idempotencyKey) {
12489
+ const row = findStmt.get(idempotencyKey);
12490
+ return row ? fromRaw(row) : null;
12491
+ }
12492
+ };
12493
+ }
12494
+ function generateIdempotencyKey() {
12495
+ const c = globalThis.crypto;
12496
+ if (c?.randomUUID) return c.randomUUID();
12497
+ const bytes = new Uint8Array(16);
12498
+ const cryptoNode = globalThis.crypto;
12499
+ if (cryptoNode?.getRandomValues) {
12500
+ cryptoNode.getRandomValues(bytes);
12501
+ } else {
12502
+ for (let i = 0; i < 16; i += 1) bytes[i] = Math.floor(Math.random() * 256);
12503
+ }
12504
+ bytes[6] = bytes[6] & 15 | 64;
12505
+ bytes[8] = bytes[8] & 63 | 128;
12506
+ const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
12507
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
12508
+ }
12509
+
12510
+ // src/daemon/dispatch-reply-transport.ts
12511
+ var DispatchReplyAbortedError = class extends Error {
12512
+ constructor(replyId) {
12513
+ super(`dispatch reply ${replyId} aborted by server reaper (likely >1h since prepare)`);
12514
+ this.replyId = replyId;
12515
+ this.name = "DispatchReplyAbortedError";
12516
+ }
12517
+ replyId;
12518
+ code = "DISPATCH_REPLY_INVALID_STATE";
12519
+ };
12520
+ function defaultLog2(line) {
12521
+ process.stderr.write(`${line}
12522
+ `);
12523
+ }
12524
+ async function sendDispatchReplyTwoPhase(args) {
12525
+ const { taskId, payload, cloud, cache } = args;
12526
+ const log8 = args.log ?? defaultLog2;
12527
+ const idempotencyKey = args.idempotencyKey ?? generateIdempotencyKey();
12528
+ const payloadJson = JSON.stringify({ ...payload, _taskId: taskId, _idempotencyKey: idempotencyKey });
12529
+ cache.savePending(idempotencyKey, taskId, payloadJson);
12530
+ const prepareRes = await cloud.request("POST", `/api/im/dispatch/${encodeURIComponent(taskId)}/prepare`, {
12531
+ body: { ...payload, idempotencyKey },
12532
+ timeoutMs: 3e4
12533
+ });
12534
+ if (!prepareRes.ok || !prepareRes.data) {
12535
+ const errCode = prepareRes.error?.code ?? "unknown";
12536
+ const errMsg = prepareRes.error?.message ?? `prepare failed (status=${prepareRes.status})`;
12537
+ cache.markAttempt(idempotencyKey, Date.now(), `prepare: ${errCode}: ${errMsg}`);
12538
+ throw new Error(`dispatch prepare failed: ${errCode}: ${errMsg}`);
12539
+ }
12540
+ const envelope2 = prepareRes.data;
12541
+ const inner = "data" in envelope2 && envelope2.data ? envelope2.data : envelope2;
12542
+ if (!inner || typeof inner.replyId !== "string") {
12543
+ cache.markAttempt(idempotencyKey, Date.now(), "prepare: missing replyId in response");
12544
+ throw new Error("dispatch prepare returned no replyId");
12545
+ }
12546
+ if ("ok" in envelope2 && envelope2.ok === false) {
12547
+ const errCode = envelope2.error?.code ?? "prepare_failed";
12548
+ const errMsg = envelope2.error?.message ?? "prepare ok=false";
12549
+ cache.markAttempt(idempotencyKey, Date.now(), `prepare: ${errCode}: ${errMsg}`);
12550
+ throw new Error(`dispatch prepare failed: ${errCode}: ${errMsg}`);
12551
+ }
12552
+ cache.markPrepared(idempotencyKey, inner.replyId);
12553
+ log8(
12554
+ `[daemon] dispatch.prepare task=${taskId} replyId=${inner.replyId} status=${inner.status} idem=${idempotencyKey}`
12555
+ );
12556
+ if (inner.status === "committed") {
12557
+ cache.clearPending(idempotencyKey);
12558
+ return {
12559
+ taskId,
12560
+ replyId: inner.replyId,
12561
+ status: "committed",
12562
+ alreadyCommitted: true
12563
+ };
12564
+ }
12565
+ try {
12566
+ const result = await commitDispatchReply({ taskId, replyId: inner.replyId, cloud, log: log8 });
12567
+ cache.clearPending(idempotencyKey);
12568
+ return { taskId, replyId: inner.replyId, ...result };
12569
+ } catch (err) {
12570
+ if (err instanceof DispatchReplyAbortedError) {
12571
+ cache.clearPending(idempotencyKey);
12572
+ log8(`[daemon] dispatch.commit aborted task=${taskId} replyId=${inner.replyId} \u2014 dispatch lost`);
12573
+ return {
12574
+ taskId,
12575
+ replyId: inner.replyId,
12576
+ status: "aborted",
12577
+ alreadyCommitted: false
12578
+ };
12579
+ }
12580
+ cache.markAttempt(idempotencyKey, Date.now(), `commit: ${err.message}`);
12581
+ throw err;
12582
+ }
12583
+ }
12584
+ async function commitDispatchReply(args) {
12585
+ const log8 = args.log ?? defaultLog2;
12586
+ const res = await args.cloud.request("POST", `/api/im/dispatch/${encodeURIComponent(args.taskId)}/commit`, {
12587
+ body: { replyId: args.replyId },
12588
+ timeoutMs: 3e4
12589
+ });
12590
+ if (!res.ok || !res.data) {
12591
+ const code = res.error?.code ?? "unknown";
12592
+ const msg = res.error?.message ?? `commit failed (status=${res.status})`;
12593
+ if (code === "DISPATCH_REPLY_INVALID_STATE") {
12594
+ throw new DispatchReplyAbortedError(args.replyId);
12595
+ }
12596
+ throw new Error(`dispatch commit failed: ${code}: ${msg}`);
12597
+ }
12598
+ const env = res.data;
12599
+ const inner = "data" in env && env.data ? env.data : env;
12600
+ if ("ok" in env && env.ok === false) {
12601
+ const code = env.error?.code ?? "commit_failed";
12602
+ if (code === "DISPATCH_REPLY_INVALID_STATE") {
12603
+ throw new DispatchReplyAbortedError(args.replyId);
12604
+ }
12605
+ throw new Error(`dispatch commit failed: ${code}: ${env.error?.message ?? "commit ok=false"}`);
12606
+ }
12607
+ log8(
12608
+ `[daemon] dispatch.commit task=${args.taskId} replyId=${args.replyId} alreadyCommitted=${inner.alreadyCommitted ?? false}`
12609
+ );
12610
+ return {
12611
+ status: "committed",
12612
+ alreadyCommitted: Boolean(inner.alreadyCommitted),
12613
+ ...inner.messageId ? { messageId: inner.messageId } : {}
12614
+ };
12615
+ }
12616
+ async function recoverPendingReplies(deps) {
12617
+ const log8 = deps.log ?? defaultLog2;
12618
+ const rows = deps.cache.listForRecovery();
12619
+ if (rows.length === 0) {
12620
+ return { attempted: 0, committed: 0, aborted: 0, failed: 0 };
12621
+ }
12622
+ log8(`[daemon] dispatch.recovery: ${rows.length} pending row(s) to replay`);
12623
+ let committed = 0;
12624
+ let aborted = 0;
12625
+ let failed = 0;
12626
+ for (const row of rows) {
12627
+ try {
12628
+ const result = await replayRow(row, deps);
12629
+ if (result.status === "committed") committed += 1;
12630
+ else if (result.status === "aborted") aborted += 1;
12631
+ } catch (err) {
12632
+ failed += 1;
12633
+ log8(
12634
+ `[daemon] dispatch.recovery FAIL idem=${row.idempotencyKey} task=${row.taskId}: ${err.message}`
12635
+ );
12636
+ }
12637
+ }
12638
+ log8(
12639
+ `[daemon] dispatch.recovery done attempted=${rows.length} committed=${committed} aborted=${aborted} failed=${failed}`
12640
+ );
12641
+ return { attempted: rows.length, committed, aborted, failed };
12642
+ }
12643
+ async function replayRow(row, deps) {
12644
+ const log8 = deps.log ?? defaultLog2;
12645
+ if (row.status === "prepared" && row.replyId) {
12646
+ try {
12647
+ const result2 = await commitDispatchReply({
12648
+ taskId: row.taskId,
12649
+ replyId: row.replyId,
12650
+ cloud: deps.cloud,
12651
+ log: log8
12652
+ });
12653
+ deps.cache.clearPending(row.idempotencyKey);
12654
+ return { status: result2.status };
12655
+ } catch (err) {
12656
+ if (err instanceof DispatchReplyAbortedError) {
12657
+ deps.cache.clearPending(row.idempotencyKey);
12658
+ return { status: "aborted" };
12659
+ }
12660
+ throw err;
12661
+ }
12662
+ }
12663
+ let parsed;
12664
+ try {
12665
+ parsed = JSON.parse(row.payloadJson);
12666
+ } catch (err) {
12667
+ deps.cache.clearPending(row.idempotencyKey);
12668
+ throw new Error(`pending row ${row.idempotencyKey} has unparseable payload: ${err.message}`);
12669
+ }
12670
+ const { _taskId, _idempotencyKey, ...payload } = parsed;
12671
+ const result = await sendDispatchReplyTwoPhase({
12672
+ taskId: row.taskId,
12673
+ payload,
12674
+ cloud: deps.cloud,
12675
+ cache: deps.cache,
12676
+ idempotencyKey: row.idempotencyKey,
12677
+ log: deps.log
12678
+ });
12679
+ return { status: result.status };
12680
+ }
12681
+
12682
+ // src/daemon/transport-probe.ts
12683
+ var import_node_os7 = require("os");
12684
+ function isPrivateAddress(addr) {
12685
+ if (!addr) return true;
12686
+ const h = String(addr).trim().toLowerCase();
12687
+ if (!h) return true;
12688
+ if (h === "localhost" || h === "::1") return true;
12689
+ const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(h);
12690
+ if (m) {
12691
+ const a = Number(m[1]);
12692
+ const b = Number(m[2]);
12693
+ if (a === 10) return true;
12694
+ if (a === 127) return true;
12695
+ if (a === 169 && b === 254) return true;
12696
+ if (a === 172 && b >= 16 && b <= 31) return true;
12697
+ if (a === 192 && b === 168) return true;
12698
+ if (a === 100 && b >= 64 && b <= 127) return true;
12699
+ return false;
12700
+ }
12701
+ if (h.endsWith(".local")) return true;
12702
+ if (h.endsWith(".localhost")) return true;
12703
+ return false;
12704
+ }
12705
+ function buildTransportProbe(gatewayUrl) {
12706
+ if (!gatewayUrl) {
12707
+ return { transport: "ws", gatewayUrl: null, gatewayIsPrivate: true };
12708
+ }
12709
+ let host = null;
12710
+ try {
12711
+ host = new URL(gatewayUrl).hostname;
12712
+ } catch {
12713
+ host = null;
12714
+ }
12715
+ const gatewayIsPrivate = isPrivateAddress(host);
12716
+ return {
12717
+ transport: gatewayIsPrivate ? "ws" : "both",
12718
+ gatewayUrl,
12719
+ gatewayIsPrivate
12720
+ };
12721
+ }
12722
+ function pickLocalIPv4() {
12723
+ const nics = (0, import_node_os7.networkInterfaces)();
12724
+ let firstPrivate = null;
12725
+ for (const list of Object.values(nics)) {
12726
+ if (!list) continue;
12727
+ for (const ni of list) {
12728
+ if (ni.internal || ni.family !== "IPv4") continue;
12729
+ if (!isPrivateAddress(ni.address)) return ni.address;
12730
+ if (!firstPrivate) firstPrivate = ni.address;
12731
+ }
12732
+ }
12733
+ return firstPrivate;
12734
+ }
12735
+ async function reportTransportProbe(cloud, daemonId, probe) {
12736
+ const body = {
12737
+ daemonId,
12738
+ transport: probe.transport,
12739
+ gatewayUrl: probe.gatewayUrl,
12740
+ gatewayIsPrivate: probe.gatewayIsPrivate
12741
+ };
12742
+ const res = await cloud.request("POST", "/api/im/runtime/transport-report", {
12743
+ body,
12744
+ timeoutMs: 5e3
12745
+ });
12746
+ if (!res.ok) {
12747
+ return {
12748
+ ok: false,
12749
+ status: res.status,
12750
+ error: res.error?.message ?? `transport-report failed (${res.status})`
12751
+ };
12752
+ }
12753
+ return { ok: true, status: res.status };
12754
+ }
12755
+
11207
12756
  // src/daemon/runner.ts
11208
12757
  var import_meta3 = {};
11209
12758
  var DEFAULT_LOCAL_PORT = 3210;
@@ -11246,6 +12795,7 @@ var Runner = class extends import_node_events3.EventEmitter {
11246
12795
  workspaceId = "";
11247
12796
  wsConnected = false;
11248
12797
  hostedAgents = /* @__PURE__ */ new Map();
12798
+ rejectedHostedAgentIds = /* @__PURE__ */ new Map();
11249
12799
  runningTasks = /* @__PURE__ */ new Map();
11250
12800
  lastTaskError;
11251
12801
  heartbeatTimer;
@@ -11253,6 +12803,14 @@ var Runner = class extends import_node_events3.EventEmitter {
11253
12803
  // F16 (2026-05-20) — periodic skill resync timer (default 10min).
11254
12804
  skillResyncTimer;
11255
12805
  skillSyncInFlight = false;
12806
+ // Wave-4 E7 — local cache of in-flight two-phase dispatch replies. Survives
12807
+ // daemon crash so cold-start can resume `prepared` rows via idempotent
12808
+ // commit (server enforces (taskId, idempotencyKey) UNIQUE).
12809
+ pendingReplyCache;
12810
+ // One-shot guard so we only recover on the first post-authenticated tick,
12811
+ // not every reconnect (server-side commits are idempotent but the log noise
12812
+ // and DB pressure of re-running on every redeclare is wasteful).
12813
+ pendingReplyRecoveryDone = false;
11256
12814
  async start() {
11257
12815
  if (this.state !== "idle") throw new Error(`Runner already in state ${this.state}`);
11258
12816
  this.state = "starting";
@@ -11267,6 +12825,7 @@ var Runner = class extends import_node_events3.EventEmitter {
11267
12825
  process.env.PRISMER_API_KEY = this.config.api_key;
11268
12826
  this.db = openLocalDb(this.paths.localDb);
11269
12827
  this.cloud = new CloudClient({ baseUrl: this.config.cloud_api_base, apiKey: this.config.api_key });
12828
+ this.pendingReplyCache = createPendingReplyCache(this.db);
11270
12829
  this.assetCache = new AssetCache({
11271
12830
  db: this.db,
11272
12831
  cloud: this.cloud,
@@ -11593,19 +13152,19 @@ var Runner = class extends import_node_events3.EventEmitter {
11593
13152
  sampleCgroupResources() {
11594
13153
  const baseline = { cpu: { usagePct: 0 }, mem: { usedBytes: 0, limitBytes: 0 } };
11595
13154
  try {
11596
- if (!(0, import_node_fs18.existsSync)("/sys/fs/cgroup/memory.current")) {
13155
+ if (!(0, import_node_fs19.existsSync)("/sys/fs/cgroup/memory.current")) {
11597
13156
  return baseline;
11598
13157
  }
11599
- const memUsedRaw = (0, import_node_fs18.readFileSync)("/sys/fs/cgroup/memory.current", "utf-8").trim();
13158
+ const memUsedRaw = (0, import_node_fs19.readFileSync)("/sys/fs/cgroup/memory.current", "utf-8").trim();
11600
13159
  const memUsed = Number.parseInt(memUsedRaw, 10);
11601
13160
  let memLimit = 0;
11602
- if ((0, import_node_fs18.existsSync)("/sys/fs/cgroup/memory.max")) {
11603
- const limitRaw = (0, import_node_fs18.readFileSync)("/sys/fs/cgroup/memory.max", "utf-8").trim();
13161
+ if ((0, import_node_fs19.existsSync)("/sys/fs/cgroup/memory.max")) {
13162
+ const limitRaw = (0, import_node_fs19.readFileSync)("/sys/fs/cgroup/memory.max", "utf-8").trim();
11604
13163
  memLimit = limitRaw === "max" ? 0 : Number.parseInt(limitRaw, 10) || 0;
11605
13164
  }
11606
13165
  let cpuUsagePct = 0;
11607
- if ((0, import_node_fs18.existsSync)("/sys/fs/cgroup/cpu.stat")) {
11608
- const statLines = (0, import_node_fs18.readFileSync)("/sys/fs/cgroup/cpu.stat", "utf-8").split("\n");
13166
+ if ((0, import_node_fs19.existsSync)("/sys/fs/cgroup/cpu.stat")) {
13167
+ const statLines = (0, import_node_fs19.readFileSync)("/sys/fs/cgroup/cpu.stat", "utf-8").split("\n");
11609
13168
  const usecLine = statLines.find((line) => line.startsWith("usage_usec "));
11610
13169
  if (usecLine) {
11611
13170
  const usec = Number.parseInt(usecLine.split(" ")[1] ?? "0", 10);
@@ -11682,6 +13241,9 @@ var Runner = class extends import_node_events3.EventEmitter {
11682
13241
  profilesByAgent.set(p.agent_im_user_id, list);
11683
13242
  }
11684
13243
  for (const a of agents) {
13244
+ if (this.rejectedHostedAgentIds.has(a.im_user_id)) {
13245
+ continue;
13246
+ }
11685
13247
  let caps = [];
11686
13248
  try {
11687
13249
  caps = JSON.parse(a.capabilities);
@@ -11731,6 +13293,7 @@ var Runner = class extends import_node_events3.EventEmitter {
11731
13293
  * Used to populate the `agents` list in agent.host.declare.
11732
13294
  */
11733
13295
  setHostedAgent(agent) {
13296
+ this.rejectedHostedAgentIds.delete(agent.imUserId);
11734
13297
  this.hostedAgents.set(agent.imUserId, {
11735
13298
  imUserId: agent.imUserId,
11736
13299
  name: agent.name,
@@ -11763,6 +13326,7 @@ var Runner = class extends import_node_events3.EventEmitter {
11763
13326
  );
11764
13327
  });
11765
13328
  tx(payload);
13329
+ this.rejectedHostedAgentIds.delete(payload.imUserId);
11766
13330
  this.loadAgentsFromDb();
11767
13331
  if (this.wsConnected) this.sendDeclare();
11768
13332
  return {
@@ -11783,10 +13347,10 @@ var Runner = class extends import_node_events3.EventEmitter {
11783
13347
  const rawJson = process.env.PRISMER_HOSTED_AGENT_JSON;
11784
13348
  let raw;
11785
13349
  if (rawFile) {
11786
- if (!(0, import_node_fs18.existsSync)(rawFile)) {
13350
+ if (!(0, import_node_fs19.existsSync)(rawFile)) {
11787
13351
  throw new Error(`PRISMER_HOSTED_AGENT_FILE not found: ${rawFile}`);
11788
13352
  }
11789
- raw = (0, import_node_fs18.readFileSync)(rawFile, "utf8");
13353
+ raw = (0, import_node_fs19.readFileSync)(rawFile, "utf8");
11790
13354
  } else if (rawJson) {
11791
13355
  raw = rawJson;
11792
13356
  }
@@ -11873,6 +13437,19 @@ var Runner = class extends import_node_events3.EventEmitter {
11873
13437
  process.stdout.write(`[daemon] ws authenticated, sending agent.host.declare (${this.hostedAgents.size} agents)
11874
13438
  `);
11875
13439
  this.sendDeclare();
13440
+ if (!this.pendingReplyRecoveryDone) {
13441
+ this.pendingReplyRecoveryDone = true;
13442
+ void recoverPendingReplies({ cloud: this.cloud, cache: this.pendingReplyCache }).then((summary) => {
13443
+ if (summary.attempted === 0) return;
13444
+ process.stdout.write(
13445
+ `[daemon] dispatch.recovery summary attempted=${summary.attempted} committed=${summary.committed} aborted=${summary.aborted} failed=${summary.failed}
13446
+ `
13447
+ );
13448
+ }).catch((err) => {
13449
+ process.stderr.write(`[daemon] dispatch.recovery threw: ${err.message}
13450
+ `);
13451
+ });
13452
+ }
11876
13453
  return;
11877
13454
  }
11878
13455
  this.handleIncoming(msg);
@@ -11882,7 +13459,7 @@ var Runner = class extends import_node_events3.EventEmitter {
11882
13459
  const payload = {
11883
13460
  daemonId: this.config.daemon_id,
11884
13461
  daemonVersion: this.opts.daemonVersion ?? "0.0.0",
11885
- platform: (0, import_node_os6.platform)() === "win32" ? "win32" : (0, import_node_os6.platform)() === "linux" ? "linux" : "darwin",
13462
+ platform: (0, import_node_os8.platform)() === "win32" ? "win32" : (0, import_node_os8.platform)() === "linux" ? "linux" : "darwin",
11886
13463
  agents: Array.from(this.hostedAgents.values()).map((a) => ({
11887
13464
  imUserId: a.imUserId,
11888
13465
  name: a.name,
@@ -11919,12 +13496,91 @@ var Runner = class extends import_node_events3.EventEmitter {
11919
13496
  case "asset.changed":
11920
13497
  void this.onAssetChanged(msg.payload);
11921
13498
  return;
13499
+ // v2.0 §4.8.1 (Wave 4-E4) — webhook reverse-channel RPC. Cloud's
13500
+ // dispatchExternalMention pushes an AgentDispatchRequest plus an
13501
+ // embedded `_rpcId`; the daemon runs the same handler as the
13502
+ // HTTP /dispatch path and echoes the reply back over WS with the
13503
+ // identical `_rpcId` so cloud's WsRpcService can settle the
13504
+ // pending promise.
13505
+ case "webhook.dispatch.request":
13506
+ void this.onWebhookDispatch(msg.payload);
13507
+ return;
11922
13508
  default:
11923
13509
  this.emit("unknown-message", msg);
11924
13510
  }
11925
13511
  }
13512
+ /**
13513
+ * v2.0 §4.8.1 — webhook dispatch over WS reverse channel.
13514
+ *
13515
+ * Acks via `webhook.dispatch.reply { _rpcId, ok, acceptedAt }` immediately
13516
+ * after `handleAgentMessageDispatch()` returns its synchronous response
13517
+ * — the actual reply (the agent's reply text/attachments) still flows
13518
+ * through `postMessageDispatchReply()` which posts to
13519
+ * `/api/im/dispatch/reply` after the adapter finishes. That mirrors the
13520
+ * HTTP /dispatch contract: ack first, asynchronous final reply via the
13521
+ * dispatch-reply REST endpoint.
13522
+ *
13523
+ * The `_rpcId` field is the only addition versus the HTTP path; we strip
13524
+ * it before passing the payload to the dispatch handler so existing
13525
+ * validation logic doesn't trip on an unknown field.
13526
+ */
13527
+ async onWebhookDispatch(payload) {
13528
+ const rpcId = payload._rpcId;
13529
+ if (!rpcId) {
13530
+ process.stderr.write(`[daemon] webhook.dispatch.request without _rpcId \u2014 dropped
13531
+ `);
13532
+ return;
13533
+ }
13534
+ const { _rpcId: _, ...request } = payload;
13535
+ try {
13536
+ const handle = handleAgentMessageDispatch(request, {
13537
+ findAgent: (agentImUserId) => this.findMessageDispatchAgent(agentImUserId),
13538
+ postReply: (replyPayload) => this.postMessageDispatchReply(replyPayload),
13539
+ onError: (err, req) => {
13540
+ process.stderr.write(
13541
+ `[daemon] webhook.dispatch.request failed message=${req.messageId} agent=${req.mentionedAgentImUserId}: ${err instanceof Error ? err.stack ?? err.message : String(err)}
13542
+ `
13543
+ );
13544
+ }
13545
+ });
13546
+ this.ws.send({
13547
+ type: "webhook.dispatch.reply",
13548
+ payload: {
13549
+ _rpcId: rpcId,
13550
+ ok: handle.response.ok,
13551
+ acceptedAt: handle.response.acceptedAt,
13552
+ error: handle.response.error
13553
+ }
13554
+ });
13555
+ void handle.done.catch((err) => {
13556
+ process.stderr.write(
13557
+ `[daemon] webhook.dispatch final reply failed message=${request.messageId}: ${err instanceof Error ? err.message : String(err)}
13558
+ `
13559
+ );
13560
+ });
13561
+ } catch (err) {
13562
+ this.ws.send({
13563
+ type: "webhook.dispatch.reply",
13564
+ payload: {
13565
+ _rpcId: rpcId,
13566
+ ok: false,
13567
+ error: {
13568
+ code: "daemon_dispatch_failed",
13569
+ message: err instanceof Error ? err.message : String(err)
13570
+ }
13571
+ }
13572
+ });
13573
+ }
13574
+ }
11926
13575
  async onHostAcked(payload) {
11927
13576
  this.workspaceId = payload.workspaceId;
13577
+ const ownershipLockChanged = await this.applyHostAckOwnershipLock(payload);
13578
+ void this.reportTransport().catch((err) => {
13579
+ process.stderr.write(
13580
+ `[daemon] transport-probe report failed: ${err.message}
13581
+ `
13582
+ );
13583
+ });
11928
13584
  if (this.memoryWiring && this.workspaceId) {
11929
13585
  syncMemoryFromCloud(this.memoryWiring, this.cloud, [this.workspaceId]).catch(
11930
13586
  (err) => log7.error("Initial memory sync failed", err.message)
@@ -11934,7 +13590,9 @@ var Runner = class extends import_node_events3.EventEmitter {
11934
13590
  (err) => log7.error("Asset sync failed", err.message)
11935
13591
  );
11936
13592
  await this.ensureDropFolderRuntime(this.workspaceId);
11937
- for (const id of payload.profilesToSync) {
13593
+ const profilesToSync = payload.profilesToSync ?? [];
13594
+ const profilesToDelete = payload.profilesToDelete ?? [];
13595
+ for (const id of profilesToSync) {
11938
13596
  try {
11939
13597
  await this.servicePool.drop(id);
11940
13598
  await this.syncProfileFromCloud(id);
@@ -11942,7 +13600,7 @@ var Runner = class extends import_node_events3.EventEmitter {
11942
13600
  this.emit("sync-error", err);
11943
13601
  }
11944
13602
  }
11945
- for (const id of payload.profilesToDelete ?? []) {
13603
+ for (const id of profilesToDelete) {
11946
13604
  const row = this.db.prepare("SELECT agent_im_user_id FROM agent_profiles WHERE id = ?").get(id);
11947
13605
  this.db.prepare("DELETE FROM agent_profiles WHERE id = ?").run(id);
11948
13606
  if (row) {
@@ -11951,10 +13609,37 @@ var Runner = class extends import_node_events3.EventEmitter {
11951
13609
  process.stderr.write(`[daemon] tombstone profile=${id}
11952
13610
  `);
11953
13611
  }
11954
- if (payload.profilesToDelete?.length) this.loadAgentsFromDb();
11955
- if ((payload.profilesToSync.length > 0 || (payload.profilesToDelete?.length ?? 0) > 0) && this.wsConnected) this.sendDeclare();
13612
+ if (profilesToDelete.length) this.loadAgentsFromDb();
13613
+ if ((ownershipLockChanged || profilesToSync.length > 0 || profilesToDelete.length > 0) && this.wsConnected) {
13614
+ this.sendDeclare();
13615
+ }
11956
13616
  this.emit("host-acked", payload);
11957
13617
  }
13618
+ async applyHostAckOwnershipLock(payload) {
13619
+ const rejected = payload.rejectedAgents ?? [];
13620
+ if (rejected.length === 0) return false;
13621
+ let changed = false;
13622
+ for (const rejection of rejected) {
13623
+ if (!rejection?.imUserId) continue;
13624
+ const agentImUserId = rejection.imUserId;
13625
+ this.rejectedHostedAgentIds.set(agentImUserId, {
13626
+ ...rejection,
13627
+ rejectedAt: Date.now()
13628
+ });
13629
+ const wasHosted = this.hostedAgents.delete(agentImUserId);
13630
+ const profiles = this.db.prepare("SELECT id FROM agent_profiles WHERE agent_im_user_id = ?").all(agentImUserId);
13631
+ const deleted = this.db.prepare("DELETE FROM agents WHERE im_user_id = ?").run(agentImUserId);
13632
+ await Promise.all(profiles.map((profile) => this.servicePool.drop(profile.id)));
13633
+ if (wasHosted || deleted.changes > 0) {
13634
+ changed = true;
13635
+ process.stderr.write(
13636
+ `[daemon] ownership rejected agent=${agentImUserId} reason=${rejection.reason} owner=${rejection.ownerDaemonId ?? "(unknown)"} \u2014 stop declaring locally
13637
+ `
13638
+ );
13639
+ }
13640
+ }
13641
+ return changed;
13642
+ }
11958
13643
  async onTaskDispatch(payload, requestId) {
11959
13644
  const targetDaemonId = readTargetDaemonId(payload);
11960
13645
  if (targetDaemonId && targetDaemonId !== this.config.daemon_id) {
@@ -12106,15 +13791,69 @@ var Runner = class extends import_node_events3.EventEmitter {
12106
13791
  };
12107
13792
  }
12108
13793
  async postMessageDispatchReply(payload) {
12109
- const res = await this.cloud.request("POST", "/api/im/dispatch/reply", {
12110
- auth: false,
12111
- body: payload,
12112
- timeoutMs: 3e4
12113
- });
12114
- if (!res.ok) {
12115
- throw new Error(res.error?.message ?? `dispatch reply failed (${res.status})`);
13794
+ const taskId = `external:${payload.replyToMessageId}`;
13795
+ try {
13796
+ const result = await sendDispatchReplyTwoPhase({
13797
+ taskId,
13798
+ payload: {
13799
+ conversationId: payload.conversationId,
13800
+ replyToken: payload.replyToken,
13801
+ replyToMessageId: payload.replyToMessageId,
13802
+ agentImUserId: payload.agentImUserId,
13803
+ status: payload.status,
13804
+ ...payload.replyText !== void 0 ? { replyText: payload.replyText } : {},
13805
+ ...payload.attachments ? { attachments: payload.attachments } : {},
13806
+ assetIds: payload.attachments?.map((a) => a.assetId).filter((id) => typeof id === "string") ?? [],
13807
+ completedAt: payload.completedAt,
13808
+ ...payload.error ? { error: payload.error } : {}
13809
+ },
13810
+ cloud: this.cloud,
13811
+ cache: this.pendingReplyCache
13812
+ });
13813
+ if (result.status === "aborted") {
13814
+ throw new Error(`dispatch reply aborted by server reaper (replyId=${result.replyId})`);
13815
+ }
13816
+ } catch (err) {
13817
+ throw new Error(
13818
+ err.message?.length ? err.message : `dispatch reply failed`
13819
+ );
12116
13820
  }
12117
13821
  }
13822
+ /**
13823
+ * v2.0 §4.8.1 (Wave 4-E4) — submit the transport-probe report. Called
13824
+ * from `onHostAcked` so the daemon's container row exists in cloud
13825
+ * before we POST to /runtime/transport-report.
13826
+ *
13827
+ * The gatewayUrl reported here is `http://<local-ipv4>:<localPort>`
13828
+ * (typically `http://192.168.x.x:3210`). For a daemon without a local
13829
+ * HTTP server (startLocalServer=false in tests) we still post the
13830
+ * probe with `gatewayUrl=null` so cloud knows transport='ws' is the
13831
+ * only path.
13832
+ */
13833
+ async reportTransport() {
13834
+ const hasLocalServer = this.opts.startLocalServer !== false;
13835
+ let gatewayUrl = null;
13836
+ if (hasLocalServer) {
13837
+ const localIp = pickLocalIPv4();
13838
+ if (localIp) {
13839
+ const port = this.opts.localPort ?? DEFAULT_LOCAL_PORT;
13840
+ gatewayUrl = `http://${localIp}:${port}`;
13841
+ }
13842
+ }
13843
+ const probe = buildTransportProbe(gatewayUrl);
13844
+ const result = await reportTransportProbe(this.cloud, this.config.daemon_id, probe);
13845
+ if (!result.ok) {
13846
+ process.stderr.write(
13847
+ `[daemon] transport-probe report rejected status=${result.status} error=${result.error ?? "-"}
13848
+ `
13849
+ );
13850
+ return;
13851
+ }
13852
+ process.stdout.write(
13853
+ `[daemon] transport-probe reported transport=${probe.transport} gateway=${probe.gatewayUrl ?? "null"} private=${probe.gatewayIsPrivate}
13854
+ `
13855
+ );
13856
+ }
12118
13857
  onAgentChanged(payload) {
12119
13858
  const a = this.hostedAgents.get(payload.agentImUserId);
12120
13859
  if (!a) return;
@@ -12183,18 +13922,22 @@ var Runner = class extends import_node_events3.EventEmitter {
12183
13922
  const name = agent?.card?.name || agent?.displayName || agent?.username || profile.agentImUserId;
12184
13923
  const now = Date.now();
12185
13924
  const tx = this.db.transaction(() => {
12186
- this.db.prepare(
12187
- `INSERT OR REPLACE INTO agents
12188
- (im_user_id, workspace_id, name, adapter_name, capabilities, status, version, synced_at, dirty)
12189
- VALUES (?, ?, ?, ?, ?, 'offline', 1, ?, 0)`
12190
- ).run(
12191
- profile.agentImUserId,
12192
- profile.workspaceId,
12193
- name,
12194
- profile.adapterName,
12195
- JSON.stringify(capabilities),
12196
- now
12197
- );
13925
+ if (this.rejectedHostedAgentIds.has(profile.agentImUserId)) {
13926
+ this.db.prepare("DELETE FROM agents WHERE im_user_id = ?").run(profile.agentImUserId);
13927
+ } else {
13928
+ this.db.prepare(
13929
+ `INSERT OR REPLACE INTO agents
13930
+ (im_user_id, workspace_id, name, adapter_name, capabilities, status, version, synced_at, dirty)
13931
+ VALUES (?, ?, ?, ?, ?, 'offline', 1, ?, 0)`
13932
+ ).run(
13933
+ profile.agentImUserId,
13934
+ profile.workspaceId,
13935
+ name,
13936
+ profile.adapterName,
13937
+ JSON.stringify(capabilities),
13938
+ now
13939
+ );
13940
+ }
12198
13941
  this.db.prepare(
12199
13942
  `INSERT OR REPLACE INTO agent_profiles
12200
13943
  (id, workspace_id, agent_im_user_id, adapter_name, name, config, version, synced_at, dirty, deleted_at)
@@ -12556,9 +14299,9 @@ var Runner = class extends import_node_events3.EventEmitter {
12556
14299
  function resolveUserAssetWorkspaceDir(workspaceId) {
12557
14300
  const override = process.env.PRISMER_ASSET_SYNC_ROOT;
12558
14301
  if (override) return (0, import_node_path14.join)(override, workspaceId);
12559
- const home = (0, import_node_os6.homedir)();
14302
+ const home = (0, import_node_os8.homedir)();
12560
14303
  const desktop = (0, import_node_path14.join)(home, "Desktop");
12561
- const base = (0, import_node_fs18.existsSync)(desktop) ? desktop : home;
14304
+ const base = (0, import_node_fs19.existsSync)(desktop) ? desktop : home;
12562
14305
  return (0, import_node_path14.join)(base, "Prismer Assets", workspaceId);
12563
14306
  }
12564
14307
  function readTargetDaemonId(payload) {
@@ -12707,9 +14450,9 @@ function buildDaemonCommand() {
12707
14450
  if (existingPid && pidAlive2(existingPid)) {
12708
14451
  exitWithError(`Daemon already running (pid ${existingPid}). Use \`prismer daemon stop\` first.`);
12709
14452
  }
12710
- if (!(0, import_node_fs19.existsSync)(paths.logsDir)) (0, import_node_fs19.mkdirSync)(paths.logsDir, { recursive: true });
14453
+ if (!(0, import_node_fs20.existsSync)(paths.logsDir)) (0, import_node_fs20.mkdirSync)(paths.logsDir, { recursive: true });
12711
14454
  const logFile = (0, import_node_path15.join)(paths.logsDir, "daemon.log");
12712
- const fd = (0, import_node_fs19.openSync)(logFile, "a");
14455
+ const fd = (0, import_node_fs20.openSync)(logFile, "a");
12713
14456
  const args = [process.argv[1], "daemon", "run"];
12714
14457
  if (opts.port) args.push("--port", String(opts.port));
12715
14458
  if (opts.localServer === false) args.push("--no-local-server");
@@ -12793,7 +14536,7 @@ function buildDaemonCommand() {
12793
14536
  cmd.command("logs").description("Show daemon logs").option("--tail <n>", "Number of lines to show", (v) => Number.parseInt(v, 10), 80).option("--follow", "Follow log output").option("--json", "Accept --json for global flag compatibility (raw log bytes are streamed)").action(async (opts) => {
12794
14537
  const paths = resolvePaths();
12795
14538
  const logFile = (0, import_node_path15.join)(paths.logsDir, "daemon.log");
12796
- if (!(0, import_node_fs19.existsSync)(logFile)) {
14539
+ if (!(0, import_node_fs20.existsSync)(logFile)) {
12797
14540
  exitWithError(`No daemon log found at ${logFile}. Start the daemon with \`prismer daemon start\`.`);
12798
14541
  }
12799
14542
  const lines = Math.max(1, opts.tail);
@@ -12834,13 +14577,13 @@ async function tailFromEnd(path9, lines) {
12834
14577
  }
12835
14578
  }
12836
14579
  async function followFile(path9) {
12837
- let offset = (0, import_node_fs19.statSync)(path9).size;
14580
+ let offset = (0, import_node_fs20.statSync)(path9).size;
12838
14581
  for (; ; ) {
12839
14582
  await (0, import_promises2.setTimeout)(1e3);
12840
- const size = (0, import_node_fs19.statSync)(path9).size;
14583
+ const size = (0, import_node_fs20.statSync)(path9).size;
12841
14584
  if (size < offset) offset = 0;
12842
14585
  if (size === offset) continue;
12843
- const stream = (0, import_node_fs19.createReadStream)(path9, { start: offset, end: size - 1, encoding: "utf8" });
14586
+ const stream = (0, import_node_fs20.createReadStream)(path9, { start: offset, end: size - 1, encoding: "utf8" });
12844
14587
  for await (const chunk of stream) process.stdout.write(chunk);
12845
14588
  offset = size;
12846
14589
  }
@@ -12848,8 +14591,8 @@ async function followFile(path9) {
12848
14591
 
12849
14592
  // src/cli/commands/events.ts
12850
14593
  var import_commander9 = require("commander");
12851
- var import_node_fs20 = require("fs");
12852
- var import_node_os7 = require("os");
14594
+ var import_node_fs21 = require("fs");
14595
+ var import_node_os9 = require("os");
12853
14596
  var import_node_path16 = require("path");
12854
14597
  var import_node_readline = require("readline");
12855
14598
  init_util();
@@ -12857,7 +14600,7 @@ var DEFAULT_LIMIT2 = 50;
12857
14600
  function buildEventsCommand() {
12858
14601
  return addEventOptions(new import_commander9.Command("events").description("Read local PARA events from ~/.prismer/para/events.jsonl")).action(async (opts) => {
12859
14602
  const file = eventsPath();
12860
- if (!(0, import_node_fs20.existsSync)(file)) {
14603
+ if (!(0, import_node_fs21.existsSync)(file)) {
12861
14604
  printJson(unavailable(file));
12862
14605
  process.exitCode = 1;
12863
14606
  return;
@@ -12874,7 +14617,7 @@ function buildEventsCommand() {
12874
14617
  function buildEventsStatsCommand() {
12875
14618
  return addEventOptions(new import_commander9.Command("events:stats").description("Summarize local PARA events from ~/.prismer/para/events.jsonl")).action(async (opts) => {
12876
14619
  const file = eventsPath();
12877
- if (!(0, import_node_fs20.existsSync)(file)) {
14620
+ if (!(0, import_node_fs21.existsSync)(file)) {
12878
14621
  printJson(unavailable(file));
12879
14622
  process.exitCode = 1;
12880
14623
  return;
@@ -12883,7 +14626,7 @@ function buildEventsStatsCommand() {
12883
14626
  const events = await readEvents(file, filters);
12884
14627
  printJson({
12885
14628
  ok: true,
12886
- data: { stats: summarize2(events), count: events.length, limit: filters.limit },
14629
+ data: { stats: summarize3(events), count: events.length, limit: filters.limit },
12887
14630
  checks: { file, exists: true, filters: cleanFilters(filters) }
12888
14631
  });
12889
14632
  });
@@ -12892,7 +14635,7 @@ function addEventOptions(cmd) {
12892
14635
  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)");
12893
14636
  }
12894
14637
  function eventsPath() {
12895
- return (0, import_node_path16.join)(process.env.PRISMER_HOME ?? (0, import_node_path16.join)((0, import_node_os7.homedir)(), ".prismer"), "para", "events.jsonl");
14638
+ return (0, import_node_path16.join)(process.env.PRISMER_HOME ?? (0, import_node_path16.join)((0, import_node_os9.homedir)(), ".prismer"), "para", "events.jsonl");
12896
14639
  }
12897
14640
  function unavailable(file) {
12898
14641
  return {
@@ -12908,7 +14651,7 @@ function unavailable(file) {
12908
14651
  async function readEvents(file, filters) {
12909
14652
  const out = [];
12910
14653
  const rl = (0, import_node_readline.createInterface)({
12911
- input: (0, import_node_fs20.createReadStream)(file, { encoding: "utf8" }),
14654
+ input: (0, import_node_fs21.createReadStream)(file, { encoding: "utf8" }),
12912
14655
  crlfDelay: Infinity
12913
14656
  });
12914
14657
  for await (const line of rl) {
@@ -12940,7 +14683,7 @@ function field(event, names) {
12940
14683
  }
12941
14684
  return void 0;
12942
14685
  }
12943
- function summarize2(events) {
14686
+ function summarize3(events) {
12944
14687
  const byFamily = {};
12945
14688
  const byType = {};
12946
14689
  const byAgentId = {};
@@ -12978,7 +14721,7 @@ function parsePositiveInt4(v) {
12978
14721
  // src/cli/commands/memory.ts
12979
14722
  var import_better_sqlite35 = __toESM(require("better-sqlite3"), 1);
12980
14723
  var import_commander10 = require("commander");
12981
- var import_node_fs21 = require("fs");
14724
+ var import_node_fs22 = require("fs");
12982
14725
  init_util();
12983
14726
  var LOCAL_BASE = process.env.PRISMER_DAEMON_URL ?? "http://127.0.0.1:3210";
12984
14727
  function buildMemoryCommand() {
@@ -13130,7 +14873,7 @@ function readCacheSnapshot(limit) {
13130
14873
  const paths = resolvePaths();
13131
14874
  const empty = {
13132
14875
  dbPath: paths.localDb,
13133
- dbExists: (0, import_node_fs21.existsSync)(paths.localDb),
14876
+ dbExists: (0, import_node_fs22.existsSync)(paths.localDb),
13134
14877
  tables: {
13135
14878
  cached_assets: { exists: false, count: 0, sizeBytes: 0 },
13136
14879
  workspace_files_mirror: { exists: false, count: 0 }
@@ -13253,16 +14996,16 @@ var import_commander11 = require("commander");
13253
14996
 
13254
14997
  // src/pair.ts
13255
14998
  var import_node_crypto11 = require("crypto");
13256
- var import_node_os9 = require("os");
14999
+ var import_node_os11 = require("os");
13257
15000
  var import_promises3 = require("timers/promises");
13258
15001
  var import_qrcode = __toESM(require("qrcode"), 1);
13259
15002
 
13260
15003
  // src/daemon-id.ts
13261
15004
  var import_node_crypto10 = require("crypto");
13262
- var import_node_os8 = require("os");
15005
+ var import_node_os10 = require("os");
13263
15006
  var PREFIX = "daemon-";
13264
15007
  function sanitizeHostname() {
13265
- return (0, import_node_os8.hostname)().replace(/[^a-zA-Z0-9_-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "") || "unknown";
15008
+ return (0, import_node_os10.hostname)().replace(/[^a-zA-Z0-9_-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "") || "unknown";
13266
15009
  }
13267
15010
  function newDaemonId(opts = {}) {
13268
15011
  const host = opts.hostnameOverride ?? sanitizeHostname();
@@ -13301,7 +15044,7 @@ async function pair(opts) {
13301
15044
  "/api/im/pair/offer",
13302
15045
  {
13303
15046
  auth: false,
13304
- body: { devicePub, deviceName: opts.deviceName ?? (0, import_node_os9.hostname)() }
15047
+ body: { devicePub, deviceName: opts.deviceName ?? (0, import_node_os11.hostname)() }
13305
15048
  }
13306
15049
  );
13307
15050
  if (!offerRes.ok) {
@@ -13422,8 +15165,8 @@ function buildPairCommand() {
13422
15165
 
13423
15166
  // src/cli/commands/profile.ts
13424
15167
  var import_commander12 = require("commander");
13425
- var import_node_fs22 = require("fs");
13426
- var import_node_os10 = require("os");
15168
+ var import_node_fs23 = require("fs");
15169
+ var import_node_os12 = require("os");
13427
15170
  var import_node_path17 = require("path");
13428
15171
  var import_node_child_process8 = require("child_process");
13429
15172
 
@@ -13548,12 +15291,12 @@ function buildProfileCommand() {
13548
15291
  const profile = await cloud.get(
13549
15292
  `/api/im/agent_profiles/${encodeURIComponent(profileId)}`
13550
15293
  );
13551
- const tmpFile = (0, import_node_path17.join)((0, import_node_os10.tmpdir)(), `prismer-profile-${profileId}.json`);
13552
- (0, import_node_fs22.writeFileSync)(tmpFile, JSON.stringify(profile.config, null, 2), "utf8");
15294
+ const tmpFile = (0, import_node_path17.join)((0, import_node_os12.tmpdir)(), `prismer-profile-${profileId}.json`);
15295
+ (0, import_node_fs23.writeFileSync)(tmpFile, JSON.stringify(profile.config, null, 2), "utf8");
13553
15296
  const editor = process.env.EDITOR || "vi";
13554
15297
  const ed = (0, import_node_child_process8.spawnSync)(editor, [tmpFile], { stdio: "inherit" });
13555
15298
  if (ed.status !== 0) exitWithError(`editor exited ${ed.status}`, { code: "editor_failed" });
13556
- const newConfig = JSON.parse((0, import_node_fs22.readFileSync)(tmpFile, "utf8"));
15299
+ const newConfig = JSON.parse((0, import_node_fs23.readFileSync)(tmpFile, "utf8"));
13557
15300
  const res = await cloud.request("PATCH", `/api/im/agent_profiles/${encodeURIComponent(profileId)}`, {
13558
15301
  body: { config: newConfig, version: profile.version }
13559
15302
  });
@@ -13575,8 +15318,8 @@ function mkCloud4() {
13575
15318
  function readJsonArg(arg) {
13576
15319
  if (arg.startsWith("@")) {
13577
15320
  const path9 = arg.slice(1);
13578
- if (!(0, import_node_fs22.existsSync)(path9)) throw new Error(`File not found: ${path9}`);
13579
- return JSON.parse((0, import_node_fs22.readFileSync)(path9, "utf8"));
15321
+ if (!(0, import_node_fs23.existsSync)(path9)) throw new Error(`File not found: ${path9}`);
15322
+ return JSON.parse((0, import_node_fs23.readFileSync)(path9, "utf8"));
13580
15323
  }
13581
15324
  return JSON.parse(arg);
13582
15325
  }
@@ -13590,8 +15333,8 @@ async function resolveDefaultWorkspaceId(cloud) {
13590
15333
  // src/cli/commands/reset.ts
13591
15334
  var import_commander13 = require("commander");
13592
15335
  var import_node_child_process9 = require("child_process");
13593
- var import_node_fs23 = require("fs");
13594
- var import_node_os11 = require("os");
15336
+ var import_node_fs24 = require("fs");
15337
+ var import_node_os13 = require("os");
13595
15338
  var import_node_path18 = require("path");
13596
15339
  var import_promises4 = require("timers/promises");
13597
15340
  init_util();
@@ -13609,15 +15352,15 @@ function buildResetCommand() {
13609
15352
  const hermesGatewayPids = findHermesGatewayPids2();
13610
15353
  const plan = {
13611
15354
  root: paths.root,
13612
- exists: (0, import_node_fs23.existsSync)(paths.root),
15355
+ exists: (0, import_node_fs24.existsSync)(paths.root),
13613
15356
  runningPid,
13614
15357
  mode: opts.wipe ? "wipe" : "archive",
13615
15358
  archivePath,
13616
15359
  assetSyncRoot,
13617
- assetSyncExists: (0, import_node_fs23.existsSync)(assetSyncRoot),
15360
+ assetSyncExists: (0, import_node_fs24.existsSync)(assetSyncRoot),
13618
15361
  assetSyncArchivePath,
13619
15362
  hermesHome,
13620
- hermesExists: (0, import_node_fs23.existsSync)(hermesHome),
15363
+ hermesExists: (0, import_node_fs24.existsSync)(hermesHome),
13621
15364
  hermesArchivePath,
13622
15365
  hermesGatewayPids,
13623
15366
  willStopDaemon: runningPid !== null,
@@ -13641,40 +15384,40 @@ function buildResetCommand() {
13641
15384
  for (const gatewayPid of hermesGatewayPids) {
13642
15385
  await stopProcess(gatewayPid, opts.timeout ?? 5e3, "Hermes gateway");
13643
15386
  }
13644
- if ((0, import_node_fs23.existsSync)(paths.root)) {
15387
+ if ((0, import_node_fs24.existsSync)(paths.root)) {
13645
15388
  if (opts.wipe) {
13646
- (0, import_node_fs23.rmSync)(paths.root, { recursive: true, force: true });
15389
+ (0, import_node_fs24.rmSync)(paths.root, { recursive: true, force: true });
13647
15390
  } else {
13648
15391
  if (!archivePath) throw new Error("internal: archivePath missing");
13649
- if (!(0, import_node_fs23.existsSync)((0, import_node_path18.dirname)(archivePath))) {
13650
- (0, import_node_fs23.mkdirSync)((0, import_node_path18.dirname)(archivePath), { recursive: true });
15392
+ if (!(0, import_node_fs24.existsSync)((0, import_node_path18.dirname)(archivePath))) {
15393
+ (0, import_node_fs24.mkdirSync)((0, import_node_path18.dirname)(archivePath), { recursive: true });
13651
15394
  }
13652
- (0, import_node_fs23.renameSync)(paths.root, archivePath);
15395
+ (0, import_node_fs24.renameSync)(paths.root, archivePath);
13653
15396
  }
13654
15397
  }
13655
- if ((0, import_node_fs23.existsSync)(assetSyncRoot)) {
15398
+ if ((0, import_node_fs24.existsSync)(assetSyncRoot)) {
13656
15399
  if (opts.wipe) {
13657
- (0, import_node_fs23.rmSync)(assetSyncRoot, { recursive: true, force: true });
15400
+ (0, import_node_fs24.rmSync)(assetSyncRoot, { recursive: true, force: true });
13658
15401
  } else {
13659
15402
  if (!assetSyncArchivePath) throw new Error("internal: assetSyncArchivePath missing");
13660
- if (!(0, import_node_fs23.existsSync)((0, import_node_path18.dirname)(assetSyncArchivePath))) {
13661
- (0, import_node_fs23.mkdirSync)((0, import_node_path18.dirname)(assetSyncArchivePath), { recursive: true });
15403
+ if (!(0, import_node_fs24.existsSync)((0, import_node_path18.dirname)(assetSyncArchivePath))) {
15404
+ (0, import_node_fs24.mkdirSync)((0, import_node_path18.dirname)(assetSyncArchivePath), { recursive: true });
13662
15405
  }
13663
- (0, import_node_fs23.renameSync)(assetSyncRoot, assetSyncArchivePath);
15406
+ (0, import_node_fs24.renameSync)(assetSyncRoot, assetSyncArchivePath);
13664
15407
  }
13665
15408
  }
13666
- if ((0, import_node_fs23.existsSync)(hermesHome)) {
15409
+ if ((0, import_node_fs24.existsSync)(hermesHome)) {
13667
15410
  if (opts.wipe) {
13668
- (0, import_node_fs23.rmSync)(hermesHome, { recursive: true, force: true });
15411
+ (0, import_node_fs24.rmSync)(hermesHome, { recursive: true, force: true });
13669
15412
  } else {
13670
15413
  if (!hermesArchivePath) throw new Error("internal: hermesArchivePath missing");
13671
- if (!(0, import_node_fs23.existsSync)((0, import_node_path18.dirname)(hermesArchivePath))) {
13672
- (0, import_node_fs23.mkdirSync)((0, import_node_path18.dirname)(hermesArchivePath), { recursive: true });
15414
+ if (!(0, import_node_fs24.existsSync)((0, import_node_path18.dirname)(hermesArchivePath))) {
15415
+ (0, import_node_fs24.mkdirSync)((0, import_node_path18.dirname)(hermesArchivePath), { recursive: true });
13673
15416
  }
13674
- (0, import_node_fs23.renameSync)(hermesHome, hermesArchivePath);
15417
+ (0, import_node_fs24.renameSync)(hermesHome, hermesArchivePath);
13675
15418
  }
13676
15419
  }
13677
- (0, import_node_fs23.mkdirSync)(paths.root, { recursive: true });
15420
+ (0, import_node_fs24.mkdirSync)(paths.root, { recursive: true });
13678
15421
  if (opts.json) {
13679
15422
  printJson({ ok: true, reset: true, plan });
13680
15423
  return;
@@ -13743,13 +15486,13 @@ function timestamp() {
13743
15486
  function resolveAssetSyncRoot() {
13744
15487
  const override = process.env.PRISMER_ASSET_SYNC_ROOT;
13745
15488
  if (override) return override;
13746
- const home = (0, import_node_os11.homedir)();
15489
+ const home = (0, import_node_os13.homedir)();
13747
15490
  const desktop = (0, import_node_path18.join)(home, "Desktop");
13748
- const base = (0, import_node_fs23.existsSync)(desktop) ? desktop : home;
15491
+ const base = (0, import_node_fs24.existsSync)(desktop) ? desktop : home;
13749
15492
  return (0, import_node_path18.join)(base, "Prismer Assets");
13750
15493
  }
13751
15494
  function resolveHermesHome() {
13752
- return process.env.HERMES_HOME || (0, import_node_path18.join)((0, import_node_os11.homedir)(), ".hermes");
15495
+ return process.env.HERMES_HOME || (0, import_node_path18.join)((0, import_node_os13.homedir)(), ".hermes");
13753
15496
  }
13754
15497
  function findHermesGatewayPids2() {
13755
15498
  try {
@@ -13979,10 +15722,10 @@ async function safeParseResponse(res) {
13979
15722
 
13980
15723
  // src/cli/commands/setup.ts
13981
15724
  var import_commander15 = require("commander");
13982
- var import_node_os12 = require("os");
15725
+ var import_node_os14 = require("os");
13983
15726
  var import_node_child_process10 = require("child_process");
13984
15727
  var import_node_crypto12 = require("crypto");
13985
- var import_node_fs24 = require("fs");
15728
+ var import_node_fs25 = require("fs");
13986
15729
  var import_node_http2 = require("http");
13987
15730
  init_util();
13988
15731
  init_ui();
@@ -14024,7 +15767,7 @@ function buildSetupCommand() {
14024
15767
  }
14025
15768
  const result = await pair({
14026
15769
  cloudBaseUrl,
14027
- deviceName: opts.deviceName ?? (0, import_node_os12.hostname)(),
15770
+ deviceName: opts.deviceName ?? (0, import_node_os14.hostname)(),
14028
15771
  force: opts.force,
14029
15772
  paths,
14030
15773
  asUserEmail: opts.asUser
@@ -14041,13 +15784,13 @@ function buildSetupCommand() {
14041
15784
  apiKey = await mintDaemonApiKey({
14042
15785
  cloudBaseUrl,
14043
15786
  token: authToken,
14044
- deviceName: opts.deviceName ?? (0, import_node_os12.hostname)()
15787
+ deviceName: opts.deviceName ?? (0, import_node_os14.hostname)()
14045
15788
  });
14046
15789
  }
14047
15790
  if (!apiKey && !authToken && !opts.check && opts.browser !== false) {
14048
15791
  apiKey = await runBrowserSetup({
14049
15792
  cloudBaseUrl,
14050
- deviceName: opts.deviceName ?? (0, import_node_os12.hostname)(),
15793
+ deviceName: opts.deviceName ?? (0, import_node_os14.hostname)(),
14051
15794
  json: Boolean(opts.json)
14052
15795
  });
14053
15796
  }
@@ -14233,9 +15976,9 @@ function shouldArchiveLocalDb(previous, next) {
14233
15976
  return previous.api_key !== next.api_key || previous.cloud_api_base !== next.cloud_api_base || previous.daemon_id !== next.daemon_id;
14234
15977
  }
14235
15978
  function archiveLocalDb(localDbPath) {
14236
- if (!(0, import_node_fs24.existsSync)(localDbPath)) return;
15979
+ if (!(0, import_node_fs25.existsSync)(localDbPath)) return;
14237
15980
  const archived = `${localDbPath}.${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}.bak`;
14238
- (0, import_node_fs24.renameSync)(localDbPath, archived);
15981
+ (0, import_node_fs25.renameSync)(localDbPath, archived);
14239
15982
  }
14240
15983
  async function mintDaemonApiKey(input) {
14241
15984
  const label = `Daemon: ${input.deviceName} ${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}`;
@@ -14304,7 +16047,7 @@ function buildSkillCommand() {
14304
16047
  const cloud = mkCloud6();
14305
16048
  const path9 = opts.agent ? `/api/im/agents/${encodeURIComponent(opts.agent)}/skills` : `/api/im/skills/${encodeURIComponent(slugOrId)}/install`;
14306
16049
  const body = opts.agent ? { skillId: slugOrId, workspaceId: opts.workspaceId, version: opts.version } : { workspaceId: opts.workspaceId, version: opts.version };
14307
- const data = await requestEnvelope(cloud, "POST", path9, body, "skill_install_failed");
16050
+ const data = await requestEnvelope2(cloud, "POST", path9, body, "skill_install_failed");
14308
16051
  if (opts.json) {
14309
16052
  printJson(data);
14310
16053
  return;
@@ -14315,7 +16058,7 @@ function buildSkillCommand() {
14315
16058
  const cloud = mkCloud6();
14316
16059
  const path9 = opts.agent ? `/api/im/agents/${encodeURIComponent(opts.agent)}/skills` : `/api/im/skills/${encodeURIComponent(slugOrId)}/install`;
14317
16060
  const body = opts.agent ? { skillId: slugOrId, workspaceId: opts.workspaceId } : void 0;
14318
- const data = await requestEnvelope(cloud, "DELETE", path9, body, "skill_uninstall_failed");
16061
+ const data = await requestEnvelope2(cloud, "DELETE", path9, body, "skill_uninstall_failed");
14319
16062
  if (opts.json) {
14320
16063
  printJson(data);
14321
16064
  return;
@@ -14368,7 +16111,7 @@ function mkCloud6() {
14368
16111
  const cfg = loadConfig(resolvePaths());
14369
16112
  return new CloudClient({ baseUrl: cfg.cloud_api_base, apiKey: cfg.api_key });
14370
16113
  }
14371
- async function requestEnvelope(cloud, method, path9, body, code) {
16114
+ async function requestEnvelope2(cloud, method, path9, body, code) {
14372
16115
  const res = await cloud.request(method, path9, {
14373
16116
  body
14374
16117
  });
@@ -14487,7 +16230,7 @@ function printSkill(skill, includeContent) {
14487
16230
 
14488
16231
  // src/cli/commands/status.ts
14489
16232
  var import_commander17 = require("commander");
14490
- var import_node_os13 = require("os");
16233
+ var import_node_os15 = require("os");
14491
16234
  init_util();
14492
16235
  init_ui();
14493
16236
  function buildStatusCommand() {
@@ -14518,6 +16261,7 @@ function buildStatusCommand() {
14518
16261
  let me = null;
14519
16262
  let devices = null;
14520
16263
  let agents = null;
16264
+ let diagnose = null;
14521
16265
  try {
14522
16266
  const meRes = await cloud.request("GET", "/api/im/me", { timeoutMs: 3e3 });
14523
16267
  cloudOk = meRes.ok;
@@ -14530,19 +16274,33 @@ function buildStatusCommand() {
14530
16274
  if (Array.isArray(wsList) && wsList.length > 0) {
14531
16275
  const wsId = wsList[0]?.id;
14532
16276
  if (wsId) {
14533
- const devRes = await cloud.request("GET", `/api/workspace/runtime-installations?workspaceId=${encodeURIComponent(wsId)}&includeStopped=false`, { timeoutMs: 3e3 });
14534
- if (devRes.ok) {
14535
- const devBody = devRes.data;
14536
- devices = devBody?.data;
14537
- }
14538
- const agRes = await cloud.request("GET", `/api/im/workspaces/${encodeURIComponent(wsId)}/agents`, { timeoutMs: 3e3 });
14539
- if (agRes.ok) {
14540
- const agBody = agRes.data;
14541
- agents = agBody?.data;
16277
+ const rtRes = await cloud.request("GET", `/api/im/workspaces/${encodeURIComponent(wsId)}/runtime`, { timeoutMs: 3e3 });
16278
+ if (rtRes.ok) {
16279
+ const rtBody = rtRes.data;
16280
+ const snapshot = rtBody?.data;
16281
+ if (snapshot && Array.isArray(snapshot.devices)) {
16282
+ devices = snapshot.devices;
16283
+ agents = snapshot.devices.flatMap((d) => d.agents ?? []);
16284
+ }
14542
16285
  }
14543
16286
  }
14544
16287
  }
14545
16288
  }
16289
+ const daemonId = cfg.daemon_id;
16290
+ if (daemonId) {
16291
+ try {
16292
+ const diagRes = await cloud.request(
16293
+ "GET",
16294
+ `/api/im/runtime/diagnose?daemonId=${encodeURIComponent(daemonId)}`,
16295
+ { timeoutMs: 3e3 }
16296
+ );
16297
+ if (diagRes.ok) {
16298
+ const body = diagRes.data;
16299
+ if (body?.data && typeof body.data === "object") diagnose = body.data;
16300
+ }
16301
+ } catch {
16302
+ }
16303
+ }
14546
16304
  }
14547
16305
  } catch {
14548
16306
  cloudOk = false;
@@ -14559,7 +16317,8 @@ function buildStatusCommand() {
14559
16317
  info: daemonStatus.info ?? {}
14560
16318
  },
14561
16319
  cloud: { base: cfg.cloud_api_base, reachable: cloudOk, me, devices, agents },
14562
- local
16320
+ local,
16321
+ diagnose
14563
16322
  };
14564
16323
  if (opts.json) {
14565
16324
  printJson(report);
@@ -14568,6 +16327,60 @@ function buildStatusCommand() {
14568
16327
  printPretty(report);
14569
16328
  });
14570
16329
  }
16330
+ function computeDeviceIcon(lastSeenAt, now) {
16331
+ if (!lastSeenAt) return "\u25CB";
16332
+ const age = now.getTime() - Date.parse(lastSeenAt);
16333
+ if (!Number.isFinite(age) || age < 0) return "\u25CB";
16334
+ if (age < 5 * 6e4) return "\u25CF";
16335
+ if (age < 60 * 6e4) return "\u25D0";
16336
+ return "\u25CB";
16337
+ }
16338
+ function computeAgentIcon(status, lastHeartbeat, now) {
16339
+ const s = (status || "").toLowerCase();
16340
+ if (s === "error" || s === "failed") return "\u25C6";
16341
+ if (!lastHeartbeat) return s === "offline" ? "\u25CB" : "\u25D0";
16342
+ const age = now.getTime() - Date.parse(lastHeartbeat);
16343
+ if (!Number.isFinite(age) || age < 0 || age >= 60 * 6e4) return "\u25CB";
16344
+ if (age >= 5 * 6e4) return "\u25D0";
16345
+ return "\u25CF";
16346
+ }
16347
+ function pad(value, width) {
16348
+ if (value.length >= width) return value.slice(0, width);
16349
+ return value + " ".repeat(width - value.length);
16350
+ }
16351
+ function formatDeviceBindings(devices, now = /* @__PURE__ */ new Date()) {
16352
+ const lines = [];
16353
+ if (devices.length === 0) {
16354
+ lines.push(" No paired devices.");
16355
+ return lines;
16356
+ }
16357
+ const totalAgents = devices.reduce((s, d) => s + (d.agents?.length ?? 0), 0);
16358
+ const devWord = devices.length === 1 ? "device" : "devices";
16359
+ const agWord = totalAgents === 1 ? "agent" : "agents";
16360
+ lines.push(` Device & Agent Bindings (${devices.length} ${devWord} \xB7 ${totalAgents} ${agWord})`);
16361
+ lines.push("");
16362
+ for (let di = 0; di < devices.length; di++) {
16363
+ const d = devices[di];
16364
+ const dIcon = computeDeviceIcon(d.lastSeenAt, now);
16365
+ const dName = pad(d.name || d.deviceId, 40);
16366
+ const lastSeen = d.lastSeenAt ? formatRelative(d.lastSeenAt, now) : "never seen";
16367
+ lines.push(` ${dIcon} ${dName} ${lastSeen}`);
16368
+ const agents = d.agents ?? [];
16369
+ for (let ai = 0; ai < agents.length; ai++) {
16370
+ const a = agents[ai];
16371
+ const isLast = ai === agents.length - 1;
16372
+ const branch = isLast ? "\u2514\u2500" : "\u251C\u2500";
16373
+ const aIcon = computeAgentIcon(a.status, a.lastHeartbeat, now);
16374
+ const aName = pad(a.name || a.id, 24);
16375
+ const aStatus = pad(a.status || "?", 8);
16376
+ const aVersion = pad(a.version ? `v${a.version}` : "\u2014", 10);
16377
+ const tail = a.currentTaskId ? `task=${a.currentTaskId}` : a.lastHeartbeat ? `\u2764 ${formatRelative(a.lastHeartbeat, now)}` : "";
16378
+ lines.push(` ${branch} ${aIcon} ${aName} ${aStatus} ${aVersion} ${tail}`.trimEnd());
16379
+ }
16380
+ if (di < devices.length - 1) lines.push("");
16381
+ }
16382
+ return lines;
16383
+ }
14571
16384
  async function readDaemonStatus() {
14572
16385
  try {
14573
16386
  const res = await fetch("http://127.0.0.1:3210/healthz", {
@@ -14611,7 +16424,7 @@ function printPretty(report) {
14611
16424
  ui.blank();
14612
16425
  ok("Config", report.paths.config);
14613
16426
  if (report.daemon.running) {
14614
- const daemonName = report.cloud.me && typeof report.cloud.me === "object" ? report.cloud.me.user?.username ?? (0, import_node_os13.hostname)() : (0, import_node_os13.hostname)();
16427
+ const daemonName = report.cloud.me && typeof report.cloud.me === "object" ? report.cloud.me.user?.username ?? (0, import_node_os15.hostname)() : (0, import_node_os15.hostname)();
14615
16428
  const ws = report.daemon.wsConnected ? "connected" : "pending";
14616
16429
  ok("Daemon", `${daemonName} pid=${report.daemon.pid} ws=${ws}`);
14617
16430
  if (report.daemon.info) {
@@ -14640,16 +16453,7 @@ function printPretty(report) {
14640
16453
  if (Array.isArray(report.cloud.devices) && report.cloud.devices.length > 0) {
14641
16454
  const devs = report.cloud.devices;
14642
16455
  ui.blank();
14643
- ui.line(` Workspace Devices (${devs.length}):`);
14644
- for (const d of devs) {
14645
- const statusIcon = d.daemonStatus === "connected" ? "\u25CF" : "\u25CB";
14646
- const kind = d.runtimeKind === "docker" ? "Local" : "K8s";
14647
- const declared = d.hostedAgentSummary?.declared ?? 0;
14648
- ui.line(` ${statusIcon} ${d.podName?.replace(/^daemon:/, "")?.slice(0, 28) ?? "?"} kind=${kind} agents=${declared}`);
14649
- }
14650
- }
14651
- if (Array.isArray(report.cloud.agents) && report.cloud.agents.length > 0) {
14652
- ui.line(` Hosted agents: ${report.cloud.agents.length}`);
16456
+ for (const ln of formatDeviceBindings(devs)) ui.line(ln);
14653
16457
  }
14654
16458
  if (report.local) {
14655
16459
  ui.blank();
@@ -14659,6 +16463,58 @@ function printPretty(report) {
14659
16463
  } else {
14660
16464
  warn("Local DB", "unavailable");
14661
16465
  }
16466
+ if (report.diagnose) {
16467
+ ui.blank();
16468
+ ui.header("Cloud dispatch reachability");
16469
+ ui.blank();
16470
+ printDiagnose(report.diagnose, report.daemon.pid ?? null);
16471
+ }
16472
+ }
16473
+ function printDiagnose(d, pid) {
16474
+ const ui = getUI();
16475
+ const heartbeatLine = d.lastHeartbeatAt ? `last heartbeat ${formatRelative(d.lastHeartbeatAt)}` : "no heartbeat seen";
16476
+ if (d.wsAlive) {
16477
+ ok("Daemon", `running${pid ? ` (pid ${pid})` : ""}`);
16478
+ ok("Cloud WS", `connected (${heartbeatLine})`);
16479
+ } else {
16480
+ warn("Daemon", `running${pid ? ` (pid ${pid})` : ""} \u2014 not visible to cloud WS`);
16481
+ fail("Cloud WS", `disconnected (${heartbeatLine})`);
16482
+ }
16483
+ if (d.gatewayUrl) {
16484
+ if (d.gatewayIsPrivate) {
16485
+ warn("HTTP gateway", `${d.gatewayUrl} (private IP \u2014 only reachable on same network)`);
16486
+ } else if (d.httpReachable) {
16487
+ ok(
16488
+ "HTTP gateway",
16489
+ `${d.gatewayUrl} (reachable${d.httpLatencyMs != null ? `, ${d.httpLatencyMs}ms` : ""})`
16490
+ );
16491
+ } else {
16492
+ fail("HTTP gateway", `${d.gatewayUrl} (unreachable${d.httpError ? ` \u2014 ${d.httpError}` : ""})`);
16493
+ }
16494
+ } else {
16495
+ ui.line(" HTTP gateway: not declared (WS-only daemon)");
16496
+ }
16497
+ const activeAgents = /* @__PURE__ */ (() => {
16498
+ return 0;
16499
+ })();
16500
+ if (activeAgents > 0) {
16501
+ ok("Active agents", String(activeAgents));
16502
+ }
16503
+ const recColour = d.recommendedTransport === "ws" ? ok : d.recommendedTransport === "http" ? warn : fail;
16504
+ recColour("Recommended transport", d.recommendedTransport);
16505
+ if (d.lastProbeAt) {
16506
+ ui.line(` Last transport probe: ${formatRelative(d.lastProbeAt)}`);
16507
+ }
16508
+ }
16509
+ function formatRelative(iso, now = /* @__PURE__ */ new Date()) {
16510
+ const ts = Date.parse(iso);
16511
+ if (!Number.isFinite(ts)) return "unknown";
16512
+ const diff = Math.max(0, now.getTime() - ts);
16513
+ if (diff < 3e4) return "just now";
16514
+ if (diff < 6e4) return `${Math.round(diff / 1e3)}s ago`;
16515
+ if (diff < 36e5) return `${Math.round(diff / 6e4)}m ago`;
16516
+ if (diff < 864e5) return `${Math.round(diff / 36e5)}h ago`;
16517
+ return `${Math.round(diff / 864e5)}d ago`;
14662
16518
  }
14663
16519
 
14664
16520
  // src/cli/commands/task.ts
@@ -15021,7 +16877,7 @@ async function readResponseError(res) {
15021
16877
 
15022
16878
  // src/cli/index.ts
15023
16879
  init_ui();
15024
- var VERSION = "2.0.4";
16880
+ var VERSION = "2.0.6";
15025
16881
  function buildProgram() {
15026
16882
  const program = new import_commander20.Command("prismer").description("Prismer Cloud daemon CLI (TS-only).").version(VERSION);
15027
16883
  program.addCommand(buildBannerCommand());