@prismer/runtime 2.0.3 → 2.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.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.3",
2271
+ version: "2.0.5",
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";
@@ -2223,11 +2558,39 @@ async function syncAllAgentSkills(profiles, cloud, options = {}) {
2223
2558
  }
2224
2559
  async function syncInstalledSkillsForDispatch(profile, agentImUserId, cloud, signal) {
2225
2560
  if (!agentImUserId) return { synced: 0, skipped: 0, unchanged: 0 };
2226
- const data = await cloud.get(
2561
+ let data = await cloud.get(
2227
2562
  `/api/im/skills/installed?agentId=${encodeURIComponent(agentImUserId)}`,
2228
2563
  { signal }
2229
2564
  );
2230
- const entries = normalizeInstalledSkills(data);
2565
+ let entries = normalizeInstalledSkills(data);
2566
+ if (entries.length === 0 && (profile.adapterName === "hermes" || profile.adapterName === "openclaw")) {
2567
+ try {
2568
+ const backfill = await cloud.request(
2569
+ "POST",
2570
+ `/api/im/agents/${encodeURIComponent(agentImUserId)}/skills/install-builtins`,
2571
+ { body: {}, signal }
2572
+ );
2573
+ if (backfill.ok && backfill.data && typeof backfill.data === "object" && "data" in backfill.data) {
2574
+ const installed = backfill.data.data?.installed ?? 0;
2575
+ if (installed > 0) {
2576
+ process.stdout.write(
2577
+ `[daemon] skill sync: backfilled ${installed} built-in skills for agent ${agentImUserId.slice(-8)}
2578
+ `
2579
+ );
2580
+ data = await cloud.get(
2581
+ `/api/im/skills/installed?agentId=${encodeURIComponent(agentImUserId)}`,
2582
+ { signal }
2583
+ );
2584
+ entries = normalizeInstalledSkills(data);
2585
+ }
2586
+ }
2587
+ } catch (err) {
2588
+ process.stderr.write(
2589
+ `[daemon] skill sync: backfill skipped for ${agentImUserId.slice(-8)}: ${err.message}
2590
+ `
2591
+ );
2592
+ }
2593
+ }
2231
2594
  const skillsRoot = resolveSkillsRoot(profile);
2232
2595
  if (!skillsRoot) return { synced: 0, skipped: 0, unchanged: 0 };
2233
2596
  let synced = 0;
@@ -2268,7 +2631,7 @@ async function syncInstalledSkillsForDispatch(profile, agentImUserId, cloud, sig
2268
2631
  continue;
2269
2632
  }
2270
2633
  const skillDir = (0, import_node_path12.join)(skillsRoot, slug);
2271
- await import_node_fs11.promises.mkdir(skillDir, { recursive: true });
2634
+ await import_node_fs12.promises.mkdir(skillDir, { recursive: true });
2272
2635
  const localFiles = await walkLocalDir(skillDir);
2273
2636
  let dirty = false;
2274
2637
  let perFileFailures = 0;
@@ -2323,14 +2686,14 @@ async function syncInstalledSkillsForDispatch(profile, agentImUserId, cloud, sig
2323
2686
  perFileFailures++;
2324
2687
  continue;
2325
2688
  }
2326
- await import_node_fs11.promises.mkdir((0, import_node_path12.dirname)(targetPath), { recursive: true });
2327
- 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);
2328
2691
  localFiles.delete(file.path);
2329
2692
  dirty = true;
2330
2693
  }
2331
2694
  for (const orphan of localFiles.keys()) {
2332
2695
  try {
2333
- 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));
2334
2697
  dirty = true;
2335
2698
  } catch (err) {
2336
2699
  if (err.code !== "ENOENT") {
@@ -2443,7 +2806,7 @@ async function walkLocalDir(root) {
2443
2806
  async function walk2(dir) {
2444
2807
  let entries;
2445
2808
  try {
2446
- entries = await import_node_fs11.promises.readdir(dir, { withFileTypes: true });
2809
+ entries = await import_node_fs12.promises.readdir(dir, { withFileTypes: true });
2447
2810
  } catch (err) {
2448
2811
  if (err.code === "ENOENT") return;
2449
2812
  throw err;
@@ -2454,7 +2817,7 @@ async function walkLocalDir(root) {
2454
2817
  await walk2(full);
2455
2818
  } else if (ent.isFile()) {
2456
2819
  try {
2457
- const buf = await import_node_fs11.promises.readFile(full);
2820
+ const buf = await import_node_fs12.promises.readFile(full);
2458
2821
  const rel = (0, import_node_path12.relative)(root, full).split(import_node_path12.sep).join("/");
2459
2822
  out.set(rel, sha256Buffer(buf));
2460
2823
  } catch {
@@ -2528,11 +2891,11 @@ async function ackSkillSync(cloud, agentImUserId, input, signal) {
2528
2891
  `);
2529
2892
  }
2530
2893
  }
2531
- var import_node_fs11, import_node_path12, import_node_crypto3, skillSyncWarnedProfiles;
2894
+ var import_node_fs12, import_node_path12, import_node_crypto3, skillSyncWarnedProfiles;
2532
2895
  var init_skill_sync = __esm({
2533
2896
  "src/daemon/skill-sync.ts"() {
2534
2897
  "use strict";
2535
- import_node_fs11 = require("fs");
2898
+ import_node_fs12 = require("fs");
2536
2899
  import_node_path12 = require("path");
2537
2900
  import_node_crypto3 = require("crypto");
2538
2901
  init_hermes();
@@ -2546,7 +2909,7 @@ var import_commander20 = require("commander");
2546
2909
 
2547
2910
  // src/cli/commands/adapter.ts
2548
2911
  var import_node_child_process4 = require("child_process");
2549
- var import_node_fs5 = require("fs");
2912
+ var import_node_fs6 = require("fs");
2550
2913
  var import_node_os4 = require("os");
2551
2914
  var import_node_path6 = require("path");
2552
2915
  var import_commander = require("commander");
@@ -3045,7 +3408,7 @@ init_openclaw();
3045
3408
 
3046
3409
  // src/config.ts
3047
3410
  var TOML = __toESM(require("@iarna/toml"), 1);
3048
- var import_node_fs3 = require("fs");
3411
+ var import_node_fs4 = require("fs");
3049
3412
  var import_node_os3 = require("os");
3050
3413
  var import_node_path4 = require("path");
3051
3414
  var import_zod5 = require("zod");
@@ -3088,15 +3451,15 @@ function resolvePaths(home) {
3088
3451
  };
3089
3452
  }
3090
3453
  function configExists(paths = resolvePaths()) {
3091
- return (0, import_node_fs3.existsSync)(paths.configFile);
3454
+ return (0, import_node_fs4.existsSync)(paths.configFile);
3092
3455
  }
3093
3456
  function loadConfig(paths = resolvePaths()) {
3094
- if (!(0, import_node_fs3.existsSync)(paths.configFile)) {
3457
+ if (!(0, import_node_fs4.existsSync)(paths.configFile)) {
3095
3458
  throw new Error(
3096
3459
  `Config not found at ${paths.configFile}. Run \`prismer setup\` to create it.`
3097
3460
  );
3098
3461
  }
3099
- const raw = (0, import_node_fs3.readFileSync)(paths.configFile, "utf8");
3462
+ const raw = (0, import_node_fs4.readFileSync)(paths.configFile, "utf8");
3100
3463
  const parsed = TOML.parse(raw);
3101
3464
  const merged = {
3102
3465
  ...parsed,
@@ -3111,14 +3474,14 @@ function loadConfig(paths = resolvePaths()) {
3111
3474
  return result.data;
3112
3475
  }
3113
3476
  function saveConfig(config, paths = resolvePaths()) {
3114
- if (!(0, import_node_fs3.existsSync)(paths.root)) {
3115
- (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 });
3116
3479
  }
3117
- if (!(0, import_node_fs3.existsSync)((0, import_node_path4.dirname)(paths.configFile))) {
3118
- (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 });
3119
3482
  }
3120
3483
  ConfigSchema.parse(config);
3121
- (0, import_node_fs3.writeFileSync)(paths.configFile, TOML.stringify(config), "utf8");
3484
+ (0, import_node_fs4.writeFileSync)(paths.configFile, TOML.stringify(config), "utf8");
3122
3485
  }
3123
3486
  function deriveWsUrl(httpBase) {
3124
3487
  const u = new URL(httpBase);
@@ -3381,15 +3744,15 @@ function runHooks(spec, opts) {
3381
3744
  }
3382
3745
  let backup;
3383
3746
  if (planned.kind === "directory") {
3384
- (0, import_node_fs5.mkdirSync)(planned.path, { recursive: true });
3747
+ (0, import_node_fs6.mkdirSync)(planned.path, { recursive: true });
3385
3748
  const markerPath = (0, import_node_path6.join)(planned.path, "prismer.json");
3386
3749
  backup = backupIfExists(markerPath);
3387
- (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");
3388
3751
  } else {
3389
- (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 });
3390
3753
  backup = backupIfExists(planned.path);
3391
3754
  const merged = mergeHookJson(planned.path, spec);
3392
- (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");
3393
3756
  }
3394
3757
  return {
3395
3758
  ok: true,
@@ -3458,10 +3821,10 @@ function inspectAuthEnv(spec) {
3458
3821
  case "hermes": {
3459
3822
  const envFile = (0, import_node_path6.join)((0, import_node_os4.homedir)(), ".hermes", ".env");
3460
3823
  return {
3461
- 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),
3462
3825
  present: [
3463
3826
  ...["HERMES_API_KEY", "API_SERVER_KEY"].filter((k) => Boolean(process.env[k])),
3464
- ...(0, import_node_fs5.existsSync)(envFile) ? [envFile] : []
3827
+ ...(0, import_node_fs6.existsSync)(envFile) ? [envFile] : []
3465
3828
  ],
3466
3829
  hints: spec.authHints ?? []
3467
3830
  };
@@ -3469,10 +3832,10 @@ function inspectAuthEnv(spec) {
3469
3832
  case "openclaw": {
3470
3833
  const cfgFile = (0, import_node_path6.join)((0, import_node_os4.homedir)(), ".openclaw", "openclaw.json");
3471
3834
  return {
3472
- 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),
3473
3836
  present: [
3474
3837
  ...["OPENCLAW_API_KEY"].filter((k) => Boolean(process.env[k])),
3475
- ...(0, import_node_fs5.existsSync)(cfgFile) ? [cfgFile] : []
3838
+ ...(0, import_node_fs6.existsSync)(cfgFile) ? [cfgFile] : []
3476
3839
  ],
3477
3840
  hints: spec.authHints ?? []
3478
3841
  };
@@ -3489,14 +3852,14 @@ function inspectHook(spec) {
3489
3852
  if (spec.name === "openclaw") {
3490
3853
  const jsonPath = (0, import_node_path6.join)((0, import_node_os4.homedir)(), ".openclaw", "hooks.json");
3491
3854
  const markerPath = (0, import_node_path6.join)(target.path, "prismer.json");
3492
- const jsonMarkerPresent = (0, import_node_fs5.existsSync)(jsonPath) && fileContainsPrismerMarker(jsonPath);
3493
- 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);
3494
3857
  return {
3495
3858
  supported: true,
3496
3859
  kind: "json-file-or-directory",
3497
3860
  path: target.path,
3498
3861
  jsonPath,
3499
- 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),
3500
3863
  markerPath,
3501
3864
  markerPresent: jsonMarkerPresent || dirMarkerPresent
3502
3865
  };
@@ -3507,12 +3870,12 @@ function inspectHook(spec) {
3507
3870
  supported: true,
3508
3871
  kind: target.kind,
3509
3872
  path: target.path,
3510
- exists: (0, import_node_fs5.existsSync)(target.path),
3873
+ exists: (0, import_node_fs6.existsSync)(target.path),
3511
3874
  markerPath,
3512
- markerPresent: (0, import_node_fs5.existsSync)(markerPath) && fileContainsPrismerMarker(markerPath)
3875
+ markerPresent: (0, import_node_fs6.existsSync)(markerPath) && fileContainsPrismerMarker(markerPath)
3513
3876
  };
3514
3877
  }
3515
- const exists = (0, import_node_fs5.existsSync)(target.path);
3878
+ const exists = (0, import_node_fs6.existsSync)(target.path);
3516
3879
  if (!exists) {
3517
3880
  return {
3518
3881
  supported: true,
@@ -3548,14 +3911,14 @@ function prismerHookMarker(spec) {
3548
3911
  }
3549
3912
  function mergeHookJson(path9, spec) {
3550
3913
  const marker = prismerHookMarker(spec);
3551
- if (!(0, import_node_fs5.existsSync)(path9)) {
3914
+ if (!(0, import_node_fs6.existsSync)(path9)) {
3552
3915
  return {
3553
3916
  prismer: marker
3554
3917
  };
3555
3918
  }
3556
3919
  let parsed;
3557
3920
  try {
3558
- parsed = JSON.parse((0, import_node_fs5.readFileSync)(path9, "utf8"));
3921
+ parsed = JSON.parse((0, import_node_fs6.readFileSync)(path9, "utf8"));
3559
3922
  } catch (err) {
3560
3923
  throw new Error(`Cannot parse ${path9} as JSON: ${err.message}`);
3561
3924
  }
@@ -3579,17 +3942,17 @@ function mergeHookJson(path9, spec) {
3579
3942
  return next;
3580
3943
  }
3581
3944
  function backupIfExists(path9) {
3582
- if (!(0, import_node_fs5.existsSync)(path9)) return null;
3945
+ if (!(0, import_node_fs6.existsSync)(path9)) return null;
3583
3946
  const suffix = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
3584
3947
  const backup = `${path9}.bak.${suffix}`;
3585
- const stat = (0, import_node_fs5.statSync)(path9);
3948
+ const stat = (0, import_node_fs6.statSync)(path9);
3586
3949
  if (stat.isDirectory()) return null;
3587
- (0, import_node_fs5.copyFileSync)(path9, backup);
3950
+ (0, import_node_fs6.copyFileSync)(path9, backup);
3588
3951
  return backup;
3589
3952
  }
3590
3953
  function fileContainsPrismerMarker(path9) {
3591
3954
  try {
3592
- 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");
3593
3956
  } catch {
3594
3957
  return false;
3595
3958
  }
@@ -3787,7 +4150,7 @@ function mapStatusToCode(status) {
3787
4150
 
3788
4151
  // src/sync/store.ts
3789
4152
  var import_better_sqlite32 = __toESM(require("better-sqlite3"), 1);
3790
- var import_node_fs6 = require("fs");
4153
+ var import_node_fs7 = require("fs");
3791
4154
  var import_node_path7 = require("path");
3792
4155
  var MIGRATIONS = [
3793
4156
  {
@@ -3914,14 +4277,44 @@ var MIGRATIONS = [
3914
4277
  CREATE INDEX IF NOT EXISTS idx_asset_meta_seq
3915
4278
  ON asset_metadata_index(workspace_id, asset_index_seq);
3916
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
+ `
3917
4310
  }
3918
4311
  ];
3919
4312
  function runSql(db, sql) {
3920
4313
  db["exec"](sql);
3921
4314
  }
3922
4315
  function openLocalDb(path9) {
3923
- if (path9 !== ":memory:" && !(0, import_node_fs6.existsSync)((0, import_node_path7.dirname)(path9))) {
3924
- (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 });
3925
4318
  }
3926
4319
  const db = new import_better_sqlite32.default(path9);
3927
4320
  db.pragma("journal_mode = WAL");
@@ -4243,8 +4636,74 @@ function buildAgentCommand() {
4243
4636
  }
4244
4637
  getUI().ok("Skill install requested", `agent=${opts.agent} skill=${slugOrId}`);
4245
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" }));
4246
4687
  return cmd;
4247
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
+ }
4248
4707
  function readLocalAgents(localDb) {
4249
4708
  const db = openLocalDb(localDb);
4250
4709
  try {
@@ -4349,13 +4808,13 @@ function whichBinary2(bin) {
4349
4808
  // src/cli/commands/asset.ts
4350
4809
  var import_node_crypto2 = require("crypto");
4351
4810
  var import_commander3 = require("commander");
4352
- var import_node_fs10 = require("fs");
4811
+ var import_node_fs11 = require("fs");
4353
4812
  var import_node_path11 = require("path");
4354
4813
 
4355
4814
  // src/asset-cache.ts
4356
4815
  var import_node_crypto = require("crypto");
4357
4816
  var import_node_dns = require("dns");
4358
- var import_node_fs7 = require("fs");
4817
+ var import_node_fs8 = require("fs");
4359
4818
  var import_node_net = require("net");
4360
4819
  var import_node_path8 = require("path");
4361
4820
  var import_undici = require("undici");
@@ -4380,8 +4839,8 @@ var AssetCache = class {
4380
4839
  this.cloud = opts.cloud;
4381
4840
  this.cacheDir = opts.cacheDir;
4382
4841
  this.maxBytes = opts.maxBytes ?? 5 * 1024 * 1024 * 1024;
4383
- if (!(0, import_node_fs7.existsSync)(this.cacheDir)) {
4384
- (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 });
4385
4844
  }
4386
4845
  }
4387
4846
  /** Local file path for a content hash. Files split by first 2 chars for fs scaling. */
@@ -4391,7 +4850,7 @@ var AssetCache = class {
4391
4850
  get(hash) {
4392
4851
  const row = this.db.prepare("SELECT * FROM cached_assets WHERE content_hash = ?").get(hash);
4393
4852
  if (!row) return void 0;
4394
- if (!(0, import_node_fs7.existsSync)(row.local_path)) {
4853
+ if (!(0, import_node_fs8.existsSync)(row.local_path)) {
4395
4854
  this.db.prepare("DELETE FROM cached_assets WHERE content_hash = ?").run(hash);
4396
4855
  return void 0;
4397
4856
  }
@@ -4455,12 +4914,12 @@ var AssetCache = class {
4455
4914
  throw new Error(`Asset hash mismatch for ${hash}: downloaded ${actualHash}`);
4456
4915
  }
4457
4916
  const localPath = this.pathFor(hash);
4458
- if (!(0, import_node_fs7.existsSync)((0, import_node_path8.dirname)(localPath))) {
4459
- (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 });
4460
4919
  }
4461
4920
  const tmpPath = `${localPath}.${process.pid}.${Date.now()}.tmp`;
4462
- (0, import_node_fs7.writeFileSync)(tmpPath, buf);
4463
- (0, import_node_fs7.renameSync)(tmpPath, localPath);
4921
+ (0, import_node_fs8.writeFileSync)(tmpPath, buf);
4922
+ (0, import_node_fs8.renameSync)(tmpPath, localPath);
4464
4923
  const mime = res.headers.get("content-type");
4465
4924
  const now = Date.now();
4466
4925
  this.db.prepare(
@@ -4519,12 +4978,12 @@ var AssetCache = class {
4519
4978
  return { cached: existing, finalUrl, durationMs: Date.now() - started };
4520
4979
  }
4521
4980
  const localPath = this.pathFor(hash);
4522
- if (!(0, import_node_fs7.existsSync)((0, import_node_path8.dirname)(localPath))) {
4523
- (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 });
4524
4983
  }
4525
4984
  const tmpPath = `${localPath}.${process.pid}.${Date.now()}.tmp`;
4526
- (0, import_node_fs7.writeFileSync)(tmpPath, body);
4527
- (0, import_node_fs7.renameSync)(tmpPath, localPath);
4985
+ (0, import_node_fs8.writeFileSync)(tmpPath, body);
4986
+ (0, import_node_fs8.renameSync)(tmpPath, localPath);
4528
4987
  const now = Date.now();
4529
4988
  this.db.prepare(
4530
4989
  `INSERT OR REPLACE INTO cached_assets
@@ -4552,7 +5011,7 @@ var AssetCache = class {
4552
5011
  if (actualHash !== hash) {
4553
5012
  throw new Error(`Asset hash mismatch for ${hash}: local file is ${actualHash}`);
4554
5013
  }
4555
- const size = (0, import_node_fs7.statSync)(localPath).size;
5014
+ const size = (0, import_node_fs8.statSync)(localPath).size;
4556
5015
  const now = Date.now();
4557
5016
  this.db.prepare(
4558
5017
  `INSERT OR REPLACE INTO cached_assets
@@ -4580,7 +5039,7 @@ var AssetCache = class {
4580
5039
  for (const row of candidates) {
4581
5040
  if (total <= this.maxBytes) break;
4582
5041
  try {
4583
- 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);
4584
5043
  } catch {
4585
5044
  }
4586
5045
  this.db.prepare("DELETE FROM cached_assets WHERE content_hash = ?").run(row.content_hash);
@@ -4595,7 +5054,7 @@ function sha256(buf) {
4595
5054
  return (0, import_node_crypto.createHash)("sha256").update(buf).digest("hex");
4596
5055
  }
4597
5056
  function readFileBuffer(path9) {
4598
- return (0, import_node_fs7.readFileSync)(path9);
5057
+ return (0, import_node_fs8.readFileSync)(path9);
4599
5058
  }
4600
5059
  var DEFAULT_URL_FETCH_MAX_BYTES = 5 * 1024 * 1024;
4601
5060
  var DEFAULT_URL_FETCH_TIMEOUT_MS = 15e3;
@@ -4777,7 +5236,7 @@ async function fetchUrlWithGuards(rawUrl, opts) {
4777
5236
  }
4778
5237
 
4779
5238
  // src/daemon/asset/metadata-index.ts
4780
- var import_node_fs8 = require("fs");
5239
+ var import_node_fs9 = require("fs");
4781
5240
  var import_node_path9 = require("path");
4782
5241
  var DEFAULT_LIMIT = 8;
4783
5242
  var PULL_PAGE_SIZE = 500;
@@ -4806,16 +5265,16 @@ var AssetMetadataIndex = class {
4806
5265
  this.db = opts.db;
4807
5266
  this.cloud = opts.cloud;
4808
5267
  this.workspaceId = opts.workspaceId;
4809
- if (!(0, import_node_fs8.existsSync)(opts.workspaceStateDir)) {
4810
- (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 });
4811
5270
  }
4812
5271
  this.cursorPath = (0, import_node_path9.join)(opts.workspaceStateDir, "asset-metadata-cursor.json");
4813
5272
  }
4814
5273
  /** Persisted cursor for this workspace, or 0 on first run / corrupted file. */
4815
5274
  readCursor() {
4816
- if (!(0, import_node_fs8.existsSync)(this.cursorPath)) return 0;
5275
+ if (!(0, import_node_fs9.existsSync)(this.cursorPath)) return 0;
4817
5276
  try {
4818
- 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"));
4819
5278
  if (parsed.workspaceId !== this.workspaceId) return 0;
4820
5279
  return parsed.cursor;
4821
5280
  } catch {
@@ -4828,7 +5287,7 @@ var AssetMetadataIndex = class {
4828
5287
  cursor,
4829
5288
  writtenAt: Date.now()
4830
5289
  };
4831
- (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));
4832
5291
  }
4833
5292
  /**
4834
5293
  * Pull incremental asset metadata changes since the persisted cursor and
@@ -4945,7 +5404,7 @@ var AssetMetadataIndex = class {
4945
5404
  };
4946
5405
 
4947
5406
  // src/daemon/asset/mirror.ts
4948
- var import_node_fs9 = require("fs");
5407
+ var import_node_fs10 = require("fs");
4949
5408
  var import_node_path10 = require("path");
4950
5409
  function rowToBinding(row) {
4951
5410
  return {
@@ -4967,16 +5426,16 @@ var WorkspaceMirror = class {
4967
5426
  this.db = opts.db;
4968
5427
  this.cloud = opts.cloud;
4969
5428
  this.workspaceId = opts.workspaceId;
4970
- if (!(0, import_node_fs9.existsSync)(opts.workspaceStateDir)) {
4971
- (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 });
4972
5431
  }
4973
5432
  this.cursorPath = (0, import_node_path10.join)(opts.workspaceStateDir, "asset-cursor.json");
4974
5433
  }
4975
5434
  /** Persisted cursor for this workspace, or null on first run / corrupted file. */
4976
5435
  readCursor() {
4977
- if (!(0, import_node_fs9.existsSync)(this.cursorPath)) return null;
5436
+ if (!(0, import_node_fs10.existsSync)(this.cursorPath)) return null;
4978
5437
  try {
4979
- 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"));
4980
5439
  if (parsed.workspaceId !== this.workspaceId) return null;
4981
5440
  return parsed.cursor;
4982
5441
  } catch {
@@ -4989,7 +5448,7 @@ var WorkspaceMirror = class {
4989
5448
  cursor,
4990
5449
  writtenAt: Date.now()
4991
5450
  };
4992
- (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));
4993
5452
  }
4994
5453
  /**
4995
5454
  * Pull workspace_file changes since the persisted cursor and upsert into
@@ -5233,8 +5692,8 @@ async function prefetchAssetBytes(opts) {
5233
5692
  return { considered: rows.length, fetched, bytes, failed };
5234
5693
  }
5235
5694
  async function uploadAssetPath(inputPath, opts) {
5236
- if (!(0, import_node_fs10.existsSync)(inputPath)) exitWithError(`path not found: ${inputPath}`);
5237
- 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);
5238
5697
  if (stat.isSymbolicLink()) exitWithError(`refusing to upload symlink: ${inputPath}`);
5239
5698
  if (stat.isFile()) {
5240
5699
  return uploadAsset(inputPath, { ...opts, folderPath: normalizeFolderPath(opts.folderPath) });
@@ -5252,9 +5711,9 @@ async function uploadAssetPath(inputPath, opts) {
5252
5711
  return { ok: true, data: { count: items.length, items } };
5253
5712
  }
5254
5713
  async function uploadAsset(file, opts) {
5255
- 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}`);
5256
5715
  const cfg = loadConfig(resolvePaths());
5257
- const bytes = (0, import_node_fs10.readFileSync)(file);
5716
+ const bytes = (0, import_node_fs11.readFileSync)(file);
5258
5717
  const contentHash = (0, import_node_crypto2.createHash)("sha256").update(bytes).digest("hex");
5259
5718
  const metadata = parseMetadata(opts.metadata);
5260
5719
  if (opts.taskId && metadata.taskId === void 0) metadata.taskId = opts.taskId;
@@ -5390,10 +5849,10 @@ async function putDirectAssetBytes(plan, bytes, mime) {
5390
5849
  }
5391
5850
  function listFilesRecursive(root) {
5392
5851
  const out = [];
5393
- for (const name of (0, import_node_fs10.readdirSync)(root)) {
5852
+ for (const name of (0, import_node_fs11.readdirSync)(root)) {
5394
5853
  if (name.startsWith(".")) continue;
5395
5854
  const full = (0, import_node_path11.join)(root, name);
5396
- const stat = (0, import_node_fs10.lstatSync)(full);
5855
+ const stat = (0, import_node_fs11.lstatSync)(full);
5397
5856
  if (stat.isSymbolicLink()) continue;
5398
5857
  if (stat.isDirectory()) {
5399
5858
  out.push(...listFilesRecursive(full));
@@ -5427,7 +5886,7 @@ async function downloadAsset(assetId, outPath) {
5427
5886
  exitWithError(`asset download failed (${res.status}): ${errorMessage(body)}`);
5428
5887
  }
5429
5888
  const bytes = Buffer.from(await res.arrayBuffer());
5430
- (0, import_node_fs10.writeFileSync)(outPath, bytes);
5889
+ (0, import_node_fs11.writeFileSync)(outPath, bytes);
5431
5890
  }
5432
5891
  function parseMetadata(raw) {
5433
5892
  if (!raw) return {};
@@ -6074,16 +6533,16 @@ function parsePositiveInt3(value) {
6074
6533
  // src/cli/commands/daemon.ts
6075
6534
  var import_commander8 = require("commander");
6076
6535
  var import_node_child_process7 = require("child_process");
6077
- var import_node_fs19 = require("fs");
6536
+ var import_node_fs20 = require("fs");
6078
6537
  var import_promises2 = require("timers/promises");
6079
6538
  var import_node_path15 = require("path");
6080
6539
 
6081
6540
  // src/daemon/runner.ts
6082
6541
  var import_node_events3 = require("events");
6083
6542
  var import_node_module2 = require("module");
6084
- var import_node_fs18 = require("fs");
6543
+ var import_node_fs19 = require("fs");
6085
6544
  var import_promises = require("fs/promises");
6086
- var import_node_os6 = require("os");
6545
+ var import_node_os8 = require("os");
6087
6546
  var import_node_path14 = require("path");
6088
6547
 
6089
6548
  // src/adapters/registry.ts
@@ -6561,59 +7020,317 @@ function createLogger(tag) {
6561
7020
  }
6562
7021
 
6563
7022
  // src/daemon/dispatch.ts
6564
- var import_node_fs12 = require("fs");
7023
+ var import_node_fs13 = require("fs");
6565
7024
  var path2 = __toESM(require("path"), 1);
6566
7025
  init_skill_sync();
6567
- var DEFAULT_CONTEXT_MAX = 8e3;
6568
- var GOAL_CONTEXT_MAX = 4;
6569
- var MEMORY_DIGEST_MAX_BYTES = 4e3;
6570
- var DEFAULT_OPERATING_PRINCIPLES = [
6571
- "- Decide and act; only request human approval for destructive operations or irreversible external effects.",
6572
- "- Assignable work should become explicit tasks; do not leave concrete work as chat discussion only.",
6573
- "- Use workspace asset tools for uploaded files: search/describe first, then read bounded ranges; do not claim file contents unless a tool call succeeded.",
6574
- "- Keep users informed with concise status and concrete next steps."
6575
- ].join("\n");
6576
- var ASSET_INLINE_FULL_MAX_BYTES = 64 * 1024;
6577
- var ASSET_INLINE_TRUNCATE_BYTES = 16 * 1024;
6578
- async function handleDispatch(payload, requestId, deps) {
6579
- const { taskId, agentImUserId } = payload;
6580
- let resolvedHashes = [];
6581
- let reply;
6582
- const outboxBase = deps.paths?.runsDir ? path2.join(deps.paths.runsDir, taskId) : null;
6583
- const outboxDir = outboxBase ? path2.join(outboxBase, "_outbox") : null;
6584
- const workDir = outboxBase ? path2.join(outboxBase, "workdir") : null;
6585
- if (outboxDir) {
6586
- try {
6587
- await import_node_fs12.promises.mkdir(outboxDir, { recursive: true });
6588
- } catch (err) {
6589
- process.stderr.write(
6590
- `[daemon] outbox provision failed task=${taskId} dir=${outboxDir}: ${err.message}
6591
- `
6592
- );
6593
- }
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;
6594
7035
  }
6595
- if (workDir) {
6596
- try {
6597
- await import_node_fs12.promises.mkdir(workDir, { recursive: true });
6598
- } catch (err) {
6599
- process.stderr.write(
6600
- `[daemon] workdir provision failed task=${taskId} dir=${workDir}: ${err.message}
6601
- `
6602
- );
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;
6603
7073
  }
7074
+ this.taskId = void 0;
7075
+ this.getPhase = void 0;
6604
7076
  }
6605
- if (deps.outboxWatcher) {
6606
- if (outboxDir) {
6607
- deps.outboxWatcher.addActiveTask({ taskId, outboxDir });
6608
- } else {
6609
- deps.outboxWatcher.setActiveTask({ taskId });
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
+ }
6610
7144
  }
7145
+ return heartbeatVersion;
6611
7146
  }
6612
- try {
6613
- const profile = await resolveProfile(payload, deps.cloud);
6614
- const adapter = deps.registry.get(profile.adapterName);
6615
- if (!adapter) {
6616
- reply = {
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
7282
+ var DEFAULT_CONTEXT_MAX = 8e3;
7283
+ var GOAL_CONTEXT_MAX = 4;
7284
+ var MEMORY_DIGEST_MAX_BYTES = 4e3;
7285
+ var DEFAULT_OPERATING_PRINCIPLES = [
7286
+ "- Decide and act; only request human approval for destructive operations or irreversible external effects.",
7287
+ "- Assignable work should become explicit tasks; do not leave concrete work as chat discussion only.",
7288
+ "- Use workspace asset tools for uploaded files: search/describe first, then read bounded ranges; do not claim file contents unless a tool call succeeded.",
7289
+ "- Keep users informed with concise status and concrete next steps."
7290
+ ].join("\n");
7291
+ var ASSET_INLINE_FULL_MAX_BYTES = 64 * 1024;
7292
+ var ASSET_INLINE_TRUNCATE_BYTES = 16 * 1024;
7293
+ async function handleDispatch(payload, requestId, deps) {
7294
+ const { taskId, agentImUserId } = payload;
7295
+ let resolvedHashes = [];
7296
+ let reply;
7297
+ let heartbeatRef;
7298
+ let recorderRef;
7299
+ const outboxBase = deps.paths?.runsDir ? path2.join(deps.paths.runsDir, taskId) : null;
7300
+ const outboxDir = outboxBase ? path2.join(outboxBase, "_outbox") : null;
7301
+ const workDir = outboxBase ? path2.join(outboxBase, "workdir") : null;
7302
+ if (outboxDir) {
7303
+ try {
7304
+ await import_node_fs13.promises.mkdir(outboxDir, { recursive: true });
7305
+ } catch (err) {
7306
+ process.stderr.write(
7307
+ `[daemon] outbox provision failed task=${taskId} dir=${outboxDir}: ${err.message}
7308
+ `
7309
+ );
7310
+ }
7311
+ }
7312
+ if (workDir) {
7313
+ try {
7314
+ await import_node_fs13.promises.mkdir(workDir, { recursive: true });
7315
+ } catch (err) {
7316
+ process.stderr.write(
7317
+ `[daemon] workdir provision failed task=${taskId} dir=${workDir}: ${err.message}
7318
+ `
7319
+ );
7320
+ }
7321
+ }
7322
+ if (deps.outboxWatcher) {
7323
+ if (outboxDir) {
7324
+ deps.outboxWatcher.addActiveTask({ taskId, outboxDir });
7325
+ } else {
7326
+ deps.outboxWatcher.setActiveTask({ taskId });
7327
+ }
7328
+ }
7329
+ try {
7330
+ const profile = await resolveProfile(payload, deps.cloud);
7331
+ const adapter = deps.registry.get(profile.adapterName);
7332
+ if (!adapter) {
7333
+ reply = {
6617
7334
  taskId,
6618
7335
  ok: false,
6619
7336
  error: { code: "adapter_unhealthy", message: `Adapter ${profile.adapterName} not registered` }
@@ -6660,7 +7377,13 @@ async function handleDispatch(payload, requestId, deps) {
6660
7377
  resolvedHashes.push(...r.resolvedHashes);
6661
7378
  rewrittenContext = payload.context.map((e, i) => ({ ...e, content: r.texts[i] }));
6662
7379
  }
6663
- 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
+ });
6664
7387
  resolvedHashes.push(...assetResolution.pinnedHashes);
6665
7388
  const basePrompt = composePrompt(
6666
7389
  rewrittenPrompt.text,
@@ -6695,9 +7418,44 @@ async function handleDispatch(payload, requestId, deps) {
6695
7418
  }
6696
7419
  const operatingPrinciples = resolveOperatingPrinciples(profile.config);
6697
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);
6698
7434
  const taskInput = {
6699
7435
  taskId,
6700
7436
  prompt: adapterPrompt,
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 } : {},
6701
7459
  metadata: {
6702
7460
  ...payload.metadata,
6703
7461
  conversationId: payload.conversationId,
@@ -6760,6 +7518,9 @@ async function handleDispatch(payload, requestId, deps) {
6760
7518
  }
6761
7519
  }
6762
7520
  const reaperAborted = deps.signal?.aborted && result.error?.code === "task_cancelled";
7521
+ const approvalRequested = Boolean(
7522
+ result.metadata?.approvalRequested
7523
+ );
6763
7524
  let collectedAssetIds = [];
6764
7525
  if (deps.outboxWatcher && outboxDir) {
6765
7526
  try {
@@ -6771,14 +7532,23 @@ async function handleDispatch(payload, requestId, deps) {
6771
7532
  collectedAssetIds = deps.outboxWatcher.flushPending(taskId);
6772
7533
  }
6773
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;
6774
7542
  reply = {
6775
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.
6776
7549
  ok: result.ok,
6777
7550
  output: result.output,
6778
- error: reaperAborted ? {
6779
- code: "daemon_task_timeout",
6780
- message: `Daemon-side reaper aborted after ${reaperLimitMs}ms inactivity (no progress events from adapter)`
6781
- } : result.error,
7551
+ error: finalError,
6782
7552
  ...collectedAssetIds.length > 0 ? { assetIds: collectedAssetIds } : {},
6783
7553
  metrics: result.metrics,
6784
7554
  ...assetResolution.observability.length > 0 || urlObservations.length > 0 ? { assetObservability: [...assetResolution.observability, ...urlObservations] } : {}
@@ -6800,6 +7570,18 @@ async function handleDispatch(payload, requestId, deps) {
6800
7570
  }
6801
7571
  };
6802
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
+ }
6803
7585
  for (const hash of new Set(resolvedHashes)) {
6804
7586
  try {
6805
7587
  deps.assetCache.unpin(hash);
@@ -6876,6 +7658,10 @@ function isTextLikeMime(mime) {
6876
7658
  if (m.endsWith("+json") || m.endsWith("+xml") || m.endsWith("+csv")) return true;
6877
7659
  return false;
6878
7660
  }
7661
+ function isImageMime(mime) {
7662
+ if (!mime) return false;
7663
+ return mime.toLowerCase().split(";")[0].trim().startsWith("image/");
7664
+ }
6879
7665
  var HASH_REF_RE = /(?:^|\s)#([^\s#]+)/g;
6880
7666
  var HEX_COLOR_RE = /^[0-9a-fA-F]{3,8}$/;
6881
7667
  var FILE_EXT_RE = /\.[a-zA-Z0-9]{1,10}$/;
@@ -6954,8 +7740,34 @@ async function resolveHashRefs(prompt, assetIndex, cloud) {
6954
7740
  }
6955
7741
  return { text: result, resolutions };
6956
7742
  }
6957
- async function resolveAssetRefs(refs, cache, taskId) {
6958
- 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
+ };
6959
7771
  if (!refs || refs.length === 0) return out;
6960
7772
  for (const ref of refs) {
6961
7773
  let cached;
@@ -6984,7 +7796,7 @@ async function resolveAssetRefs(refs, cache, taskId) {
6984
7796
  const inlineCandidate = isTextLikeMime(effectiveMime);
6985
7797
  if (inlineCandidate) {
6986
7798
  try {
6987
- const buf = (0, import_node_fs12.readFileSync)(cached.localPath);
7799
+ const buf = (0, import_node_fs13.readFileSync)(cached.localPath);
6988
7800
  let strategy;
6989
7801
  let body;
6990
7802
  if (buf.byteLength <= ASSET_INLINE_FULL_MAX_BYTES) {
@@ -7020,7 +7832,42 @@ async function resolveAssetRefs(refs, cache, taskId) {
7020
7832
  continue;
7021
7833
  }
7022
7834
  }
7023
- 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
+ }
7024
7871
  out.observability.push({
7025
7872
  assetId: ref.assetId,
7026
7873
  contentHash: ref.contentHash,
@@ -7031,6 +7878,110 @@ async function resolveAssetRefs(refs, cache, taskId) {
7031
7878
  }
7032
7879
  return out;
7033
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
+ }
7034
7985
  function logAssetResolveFailure(taskId, ref, error, mime = ref.mime) {
7035
7986
  const message = error.replace(/\s+/g, " ").slice(0, 500);
7036
7987
  process.stderr.write(
@@ -7045,8 +7996,19 @@ function formatInlineAssetBlock(ref, mime, body, strategy) {
7045
7996
  ${body}
7046
7997
  ---`;
7047
7998
  }
7048
- function formatUriOnlyAssetBlock(ref, mime, localPath) {
7049
- 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)}`;
7050
8012
  }
7051
8013
  function formatErrorAssetBlock(ref, mime, error) {
7052
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.`;
@@ -7386,8 +8348,9 @@ function updatedTime(task) {
7386
8348
  // src/daemon/local-server.ts
7387
8349
  var import_node_http = require("http");
7388
8350
  var import_node_crypto4 = require("crypto");
7389
- var import_node_fs13 = require("fs");
8351
+ var import_node_fs14 = require("fs");
7390
8352
  var path3 = __toESM(require("path"), 1);
8353
+ var import_node_os5 = require("os");
7391
8354
 
7392
8355
  // src/daemon/message-dispatch.ts
7393
8356
  var DEFAULT_TIMEOUT_MS = 6e4;
@@ -7598,6 +8561,11 @@ var LocalServer = class {
7598
8561
  void this.handleSnapshot(req, res);
7599
8562
  return;
7600
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
+ }
7601
8569
  if (req.method === "POST" && url === "/local/asset/write") {
7602
8570
  void this.handleAssetWrite(req, res);
7603
8571
  return;
@@ -7648,7 +8616,7 @@ var LocalServer = class {
7648
8616
  async handleSnapshot(req, res) {
7649
8617
  const root = this.opts.snapshotRoot ?? "/workspace";
7650
8618
  try {
7651
- const st = await import_node_fs13.promises.stat(root);
8619
+ const st = await import_node_fs14.promises.stat(root);
7652
8620
  if (!st.isDirectory()) {
7653
8621
  respond(res, 400, { error: "snapshot_root_not_directory", rootPath: root });
7654
8622
  return;
@@ -7675,6 +8643,54 @@ var LocalServer = class {
7675
8643
  }
7676
8644
  void req;
7677
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
+ }
7678
8694
  /**
7679
8695
  * POST /local/asset/write — agent-gen adapter RPC.
7680
8696
  *
@@ -7946,11 +8962,44 @@ function validateAgentDispatchRequest(body) {
7946
8962
  if (payload.attachments != null && !Array.isArray(payload.attachments)) return "attachments must be an array";
7947
8963
  return null;
7948
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
+ }
7949
8998
  async function walkAndDigest(root, current) {
7950
8999
  const out = [];
7951
9000
  let entries;
7952
9001
  try {
7953
- entries = await import_node_fs13.promises.readdir(current);
9002
+ entries = await import_node_fs14.promises.readdir(current);
7954
9003
  } catch (err) {
7955
9004
  if (err.code === "ENOENT") return out;
7956
9005
  throw err;
@@ -7961,7 +9010,7 @@ async function walkAndDigest(root, current) {
7961
9010
  const rel = path3.relative(root, full);
7962
9011
  let st;
7963
9012
  try {
7964
- st = await import_node_fs13.promises.stat(full);
9013
+ st = await import_node_fs14.promises.stat(full);
7965
9014
  } catch {
7966
9015
  continue;
7967
9016
  }
@@ -7972,7 +9021,7 @@ async function walkAndDigest(root, current) {
7972
9021
  continue;
7973
9022
  }
7974
9023
  if (!st.isFile()) continue;
7975
- const buf = await import_node_fs13.promises.readFile(full);
9024
+ const buf = await import_node_fs14.promises.readFile(full);
7976
9025
  const sha2563 = (0, import_node_crypto4.createHash)("sha256").update(buf).digest("hex");
7977
9026
  out.push({
7978
9027
  path: rel,
@@ -9523,7 +10572,7 @@ function parsePrismerUri(uri) {
9523
10572
  }
9524
10573
 
9525
10574
  // src/daemon/asset/rpc.ts
9526
- var import_node_fs14 = require("fs");
10575
+ var import_node_fs15 = require("fs");
9527
10576
  var ASSET_PATH_PREFIX = "/local/asset/";
9528
10577
  var MAX_READ_BYTES = 256 * 1024;
9529
10578
  var MAX_SEARCH_BYTES = 1024 * 1024;
@@ -9698,16 +10747,16 @@ function isTextLike(row) {
9698
10747
  );
9699
10748
  }
9700
10749
  function readFileRange(path9, offset, length) {
9701
- const sizeBytes = (0, import_node_fs14.statSync)(path9).size;
10750
+ const sizeBytes = (0, import_node_fs15.statSync)(path9).size;
9702
10751
  if (offset >= sizeBytes) return { buffer: Buffer.alloc(0), bytesRead: 0, sizeBytes };
9703
- const fd = (0, import_node_fs14.openSync)(path9, "r");
10752
+ const fd = (0, import_node_fs15.openSync)(path9, "r");
9704
10753
  try {
9705
10754
  const target = Math.min(length, sizeBytes - offset);
9706
10755
  const buffer = Buffer.alloc(target);
9707
- 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);
9708
10757
  return { buffer: buffer.subarray(0, bytesRead), bytesRead, sizeBytes };
9709
10758
  } finally {
9710
- (0, import_node_fs14.closeSync)(fd);
10759
+ (0, import_node_fs15.closeSync)(fd);
9711
10760
  }
9712
10761
  }
9713
10762
  function respond3(res, status, body) {
@@ -9732,8 +10781,8 @@ async function readJson3(req) {
9732
10781
  }
9733
10782
 
9734
10783
  // src/daemon/asset/origin/drop-folder.ts
9735
- var import_node_fs15 = require("fs");
9736
- var import_node_os5 = require("os");
10784
+ var import_node_fs16 = require("fs");
10785
+ var import_node_os6 = require("os");
9737
10786
  var path6 = __toESM(require("path"), 1);
9738
10787
  var DropFolderAdapter = class {
9739
10788
  kind = "drop-folder";
@@ -9741,7 +10790,7 @@ var DropFolderAdapter = class {
9741
10790
  rootDir;
9742
10791
  constructor(opts = {}) {
9743
10792
  this.workspaceDir = opts.workspaceDir;
9744
- 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");
9745
10794
  }
9746
10795
  /**
9747
10796
  * Long-lived async iterable wrapping `scanOnce` on a 1s polling interval.
@@ -9751,7 +10800,7 @@ var DropFolderAdapter = class {
9751
10800
  while (true) {
9752
10801
  const batch = await this.scanOnce(workspaceId);
9753
10802
  for (const obs of batch) yield obs;
9754
- await sleep(1e3);
10803
+ await sleep2(1e3);
9755
10804
  }
9756
10805
  }
9757
10806
  /**
@@ -9765,7 +10814,7 @@ var DropFolderAdapter = class {
9765
10814
  const drop = this.dropDir(workspaceId);
9766
10815
  let entries = [];
9767
10816
  try {
9768
- entries = await import_node_fs15.promises.readdir(drop);
10817
+ entries = await import_node_fs16.promises.readdir(drop);
9769
10818
  } catch (err) {
9770
10819
  if (err.code === "ENOENT") return [];
9771
10820
  throw err;
@@ -9774,12 +10823,12 @@ var DropFolderAdapter = class {
9774
10823
  for (const name of entries) {
9775
10824
  if (name.startsWith(".")) continue;
9776
10825
  const fullPath = path6.join(drop, name);
9777
- const stat = await import_node_fs15.promises.lstat(fullPath).catch(() => null);
10826
+ const stat = await import_node_fs16.promises.lstat(fullPath).catch(() => null);
9778
10827
  if (!stat) continue;
9779
10828
  if (stat.isSymbolicLink()) continue;
9780
10829
  if (stat.isDirectory()) {
9781
10830
  for (const nested of await walk(fullPath)) {
9782
- const nstat = await import_node_fs15.promises.lstat(nested).catch(() => null);
10831
+ const nstat = await import_node_fs16.promises.lstat(nested).catch(() => null);
9783
10832
  if (!nstat || !nstat.isFile()) continue;
9784
10833
  out.push(makeObservation(workspaceId, nested, drop, nstat.size, Math.floor(nstat.mtimeMs)));
9785
10834
  }
@@ -9804,7 +10853,7 @@ var DropFolderAdapter = class {
9804
10853
  }
9805
10854
  async fetch(obs) {
9806
10855
  const detail = obs.detail;
9807
- const bytes = await import_node_fs15.promises.readFile(detail.path);
10856
+ const bytes = await import_node_fs16.promises.readFile(detail.path);
9808
10857
  return {
9809
10858
  bytes,
9810
10859
  mime: mimeFromExtension(detail.path),
@@ -9826,8 +10875,8 @@ var DropFolderAdapter = class {
9826
10875
  const rel = typeof detail.watchDir === "string" ? path6.relative(detail.watchDir, detail.path) : path6.basename(detail.path);
9827
10876
  const safeRel = rel && !rel.startsWith("..") && !path6.isAbsolute(rel) ? rel : path6.basename(detail.path);
9828
10877
  const destPath = path6.join(destDir, safeRel);
9829
- await import_node_fs15.promises.mkdir(path6.dirname(destPath), { recursive: true });
9830
- 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);
9831
10880
  } catch (err) {
9832
10881
  if (err.code === "ENOENT") return;
9833
10882
  throw err;
@@ -9855,11 +10904,11 @@ function makeObservation(workspaceId, filePath, watchDir, size, mtime) {
9855
10904
  }
9856
10905
  async function walk(root) {
9857
10906
  const out = [];
9858
- const entries = await import_node_fs15.promises.readdir(root).catch(() => []);
10907
+ const entries = await import_node_fs16.promises.readdir(root).catch(() => []);
9859
10908
  for (const name of entries) {
9860
10909
  if (name.startsWith(".")) continue;
9861
10910
  const full = path6.join(root, name);
9862
- const stat = await import_node_fs15.promises.lstat(full).catch(() => null);
10911
+ const stat = await import_node_fs16.promises.lstat(full).catch(() => null);
9863
10912
  if (!stat) continue;
9864
10913
  if (stat.isSymbolicLink()) continue;
9865
10914
  if (stat.isDirectory()) {
@@ -9900,7 +10949,7 @@ function mimeFromExtension(p) {
9900
10949
  const ext = path6.extname(p).toLowerCase();
9901
10950
  return MIME_BY_EXT[ext] ?? null;
9902
10951
  }
9903
- function sleep(ms) {
10952
+ function sleep2(ms) {
9904
10953
  return new Promise((r) => setTimeout(r, ms));
9905
10954
  }
9906
10955
 
@@ -10455,7 +11504,7 @@ function stringValue(obj, key) {
10455
11504
  }
10456
11505
 
10457
11506
  // src/daemon/outbox-watcher.ts
10458
- var import_node_fs16 = require("fs");
11507
+ var import_node_fs17 = require("fs");
10459
11508
  var path8 = __toESM(require("path"), 1);
10460
11509
 
10461
11510
  // src/daemon/asset/agent-output-policy.ts
@@ -10611,9 +11660,120 @@ function extensionOf(filename) {
10611
11660
  return idx >= 0 ? name.slice(idx) : "";
10612
11661
  }
10613
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
+
10614
11773
  // src/daemon/outbox-watcher.ts
10615
11774
  var DEFAULT_INTERVAL_MS = 2e3;
10616
11775
  var RESERVED_SUBDIR = "_uploaded";
11776
+ var REJECTED_SUBDIR = "_rejected";
10617
11777
  var OutboxWatcher = class {
10618
11778
  constructor(opts) {
10619
11779
  this.opts = opts;
@@ -10778,7 +11938,7 @@ var OutboxWatcher = class {
10778
11938
  async scanDir(dir, kind, task) {
10779
11939
  let entries = [];
10780
11940
  try {
10781
- entries = await import_node_fs16.promises.readdir(dir);
11941
+ entries = await import_node_fs17.promises.readdir(dir);
10782
11942
  } catch (err) {
10783
11943
  const code = err.code;
10784
11944
  if (code === "ENOENT") return;
@@ -10788,10 +11948,11 @@ var OutboxWatcher = class {
10788
11948
  for (const name of entries) {
10789
11949
  if (name.startsWith(".")) continue;
10790
11950
  if (name === RESERVED_SUBDIR) continue;
11951
+ if (name === REJECTED_SUBDIR) continue;
10791
11952
  const full = path8.join(dir, name);
10792
11953
  let st;
10793
11954
  try {
10794
- st = await import_node_fs16.promises.stat(full);
11955
+ st = await import_node_fs17.promises.stat(full);
10795
11956
  } catch {
10796
11957
  continue;
10797
11958
  }
@@ -10802,10 +11963,77 @@ var OutboxWatcher = class {
10802
11963
  await this.upload(full, st, kind, task);
10803
11964
  this.uploaded.add(key);
10804
11965
  } catch (err) {
10805
- 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
+ }
10806
11977
  }
10807
11978
  }
10808
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
+ }
10809
12037
  async upload(filePath, st, kind, task) {
10810
12038
  const wsId = this.opts.workspaceId();
10811
12039
  if (!wsId) {
@@ -10816,13 +12044,18 @@ var OutboxWatcher = class {
10816
12044
  this.log("warn", `skip ${filePath}: no active taskId (no dispatch/handoff received yet)`);
10817
12045
  return;
10818
12046
  }
10819
- const bytes = await import_node_fs16.promises.readFile(filePath);
12047
+ const bytes = await import_node_fs17.promises.readFile(filePath);
10820
12048
  const fileName = path8.basename(filePath);
10821
12049
  const inferredMime = inferAgentOutputMime(fileName);
10822
12050
  const policy = validateAgentOutputAsset({ filename: fileName, mime: inferredMime, sizeBytes: bytes.length });
10823
12051
  if (!policy.ok) {
10824
12052
  throw new Error(`agent output rejected: ${policy.reason}`);
10825
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
+ }
10826
12059
  const metadata = {
10827
12060
  taskId: task.taskId,
10828
12061
  ...task.adapter ? { adapter: task.adapter } : {}
@@ -10904,7 +12137,7 @@ var ServicePool = class {
10904
12137
 
10905
12138
  // src/daemon/shell-executor.ts
10906
12139
  var import_node_child_process6 = require("child_process");
10907
- var import_node_fs17 = require("fs");
12140
+ var import_node_fs18 = require("fs");
10908
12141
  var import_node_path13 = require("path");
10909
12142
  var DEFAULT_OUTPUT_LIMIT = 256 * 1024;
10910
12143
  var DEFAULT_TIMEOUT = 6e4;
@@ -10941,7 +12174,7 @@ async function executeShellDispatch(payload, deps) {
10941
12174
  const command = readCommand(payload, execution);
10942
12175
  if (!command.trim()) return fail2(payload.taskId, "shell_command_required", "Shell command is required");
10943
12176
  const cwd = resolveCwd(execution.cwd, deps.config.defaultCwd);
10944
- 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}`);
10945
12178
  const timeoutMs = Math.min(
10946
12179
  typeof payload.timeoutMs === "number" && payload.timeoutMs > 0 ? payload.timeoutMs : deps.config.maxTimeoutMs,
10947
12180
  deps.config.maxTimeoutMs
@@ -11176,6 +12409,350 @@ var WsClient = class extends import_node_events2.EventEmitter {
11176
12409
  }
11177
12410
  };
11178
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
+
11179
12756
  // src/daemon/runner.ts
11180
12757
  var import_meta3 = {};
11181
12758
  var DEFAULT_LOCAL_PORT = 3210;
@@ -11225,6 +12802,14 @@ var Runner = class extends import_node_events3.EventEmitter {
11225
12802
  // F16 (2026-05-20) — periodic skill resync timer (default 10min).
11226
12803
  skillResyncTimer;
11227
12804
  skillSyncInFlight = false;
12805
+ // Wave-4 E7 — local cache of in-flight two-phase dispatch replies. Survives
12806
+ // daemon crash so cold-start can resume `prepared` rows via idempotent
12807
+ // commit (server enforces (taskId, idempotencyKey) UNIQUE).
12808
+ pendingReplyCache;
12809
+ // One-shot guard so we only recover on the first post-authenticated tick,
12810
+ // not every reconnect (server-side commits are idempotent but the log noise
12811
+ // and DB pressure of re-running on every redeclare is wasteful).
12812
+ pendingReplyRecoveryDone = false;
11228
12813
  async start() {
11229
12814
  if (this.state !== "idle") throw new Error(`Runner already in state ${this.state}`);
11230
12815
  this.state = "starting";
@@ -11239,6 +12824,7 @@ var Runner = class extends import_node_events3.EventEmitter {
11239
12824
  process.env.PRISMER_API_KEY = this.config.api_key;
11240
12825
  this.db = openLocalDb(this.paths.localDb);
11241
12826
  this.cloud = new CloudClient({ baseUrl: this.config.cloud_api_base, apiKey: this.config.api_key });
12827
+ this.pendingReplyCache = createPendingReplyCache(this.db);
11242
12828
  this.assetCache = new AssetCache({
11243
12829
  db: this.db,
11244
12830
  cloud: this.cloud,
@@ -11565,19 +13151,19 @@ var Runner = class extends import_node_events3.EventEmitter {
11565
13151
  sampleCgroupResources() {
11566
13152
  const baseline = { cpu: { usagePct: 0 }, mem: { usedBytes: 0, limitBytes: 0 } };
11567
13153
  try {
11568
- if (!(0, import_node_fs18.existsSync)("/sys/fs/cgroup/memory.current")) {
13154
+ if (!(0, import_node_fs19.existsSync)("/sys/fs/cgroup/memory.current")) {
11569
13155
  return baseline;
11570
13156
  }
11571
- const memUsedRaw = (0, import_node_fs18.readFileSync)("/sys/fs/cgroup/memory.current", "utf-8").trim();
13157
+ const memUsedRaw = (0, import_node_fs19.readFileSync)("/sys/fs/cgroup/memory.current", "utf-8").trim();
11572
13158
  const memUsed = Number.parseInt(memUsedRaw, 10);
11573
13159
  let memLimit = 0;
11574
- if ((0, import_node_fs18.existsSync)("/sys/fs/cgroup/memory.max")) {
11575
- const limitRaw = (0, import_node_fs18.readFileSync)("/sys/fs/cgroup/memory.max", "utf-8").trim();
13160
+ if ((0, import_node_fs19.existsSync)("/sys/fs/cgroup/memory.max")) {
13161
+ const limitRaw = (0, import_node_fs19.readFileSync)("/sys/fs/cgroup/memory.max", "utf-8").trim();
11576
13162
  memLimit = limitRaw === "max" ? 0 : Number.parseInt(limitRaw, 10) || 0;
11577
13163
  }
11578
13164
  let cpuUsagePct = 0;
11579
- if ((0, import_node_fs18.existsSync)("/sys/fs/cgroup/cpu.stat")) {
11580
- const statLines = (0, import_node_fs18.readFileSync)("/sys/fs/cgroup/cpu.stat", "utf-8").split("\n");
13165
+ if ((0, import_node_fs19.existsSync)("/sys/fs/cgroup/cpu.stat")) {
13166
+ const statLines = (0, import_node_fs19.readFileSync)("/sys/fs/cgroup/cpu.stat", "utf-8").split("\n");
11581
13167
  const usecLine = statLines.find((line) => line.startsWith("usage_usec "));
11582
13168
  if (usecLine) {
11583
13169
  const usec = Number.parseInt(usecLine.split(" ")[1] ?? "0", 10);
@@ -11755,10 +13341,10 @@ var Runner = class extends import_node_events3.EventEmitter {
11755
13341
  const rawJson = process.env.PRISMER_HOSTED_AGENT_JSON;
11756
13342
  let raw;
11757
13343
  if (rawFile) {
11758
- if (!(0, import_node_fs18.existsSync)(rawFile)) {
13344
+ if (!(0, import_node_fs19.existsSync)(rawFile)) {
11759
13345
  throw new Error(`PRISMER_HOSTED_AGENT_FILE not found: ${rawFile}`);
11760
13346
  }
11761
- raw = (0, import_node_fs18.readFileSync)(rawFile, "utf8");
13347
+ raw = (0, import_node_fs19.readFileSync)(rawFile, "utf8");
11762
13348
  } else if (rawJson) {
11763
13349
  raw = rawJson;
11764
13350
  }
@@ -11845,6 +13431,19 @@ var Runner = class extends import_node_events3.EventEmitter {
11845
13431
  process.stdout.write(`[daemon] ws authenticated, sending agent.host.declare (${this.hostedAgents.size} agents)
11846
13432
  `);
11847
13433
  this.sendDeclare();
13434
+ if (!this.pendingReplyRecoveryDone) {
13435
+ this.pendingReplyRecoveryDone = true;
13436
+ void recoverPendingReplies({ cloud: this.cloud, cache: this.pendingReplyCache }).then((summary) => {
13437
+ if (summary.attempted === 0) return;
13438
+ process.stdout.write(
13439
+ `[daemon] dispatch.recovery summary attempted=${summary.attempted} committed=${summary.committed} aborted=${summary.aborted} failed=${summary.failed}
13440
+ `
13441
+ );
13442
+ }).catch((err) => {
13443
+ process.stderr.write(`[daemon] dispatch.recovery threw: ${err.message}
13444
+ `);
13445
+ });
13446
+ }
11848
13447
  return;
11849
13448
  }
11850
13449
  this.handleIncoming(msg);
@@ -11854,7 +13453,7 @@ var Runner = class extends import_node_events3.EventEmitter {
11854
13453
  const payload = {
11855
13454
  daemonId: this.config.daemon_id,
11856
13455
  daemonVersion: this.opts.daemonVersion ?? "0.0.0",
11857
- platform: (0, import_node_os6.platform)() === "win32" ? "win32" : (0, import_node_os6.platform)() === "linux" ? "linux" : "darwin",
13456
+ platform: (0, import_node_os8.platform)() === "win32" ? "win32" : (0, import_node_os8.platform)() === "linux" ? "linux" : "darwin",
11858
13457
  agents: Array.from(this.hostedAgents.values()).map((a) => ({
11859
13458
  imUserId: a.imUserId,
11860
13459
  name: a.name,
@@ -11891,12 +13490,90 @@ var Runner = class extends import_node_events3.EventEmitter {
11891
13490
  case "asset.changed":
11892
13491
  void this.onAssetChanged(msg.payload);
11893
13492
  return;
13493
+ // v2.0 §4.8.1 (Wave 4-E4) — webhook reverse-channel RPC. Cloud's
13494
+ // dispatchExternalMention pushes an AgentDispatchRequest plus an
13495
+ // embedded `_rpcId`; the daemon runs the same handler as the
13496
+ // HTTP /dispatch path and echoes the reply back over WS with the
13497
+ // identical `_rpcId` so cloud's WsRpcService can settle the
13498
+ // pending promise.
13499
+ case "webhook.dispatch.request":
13500
+ void this.onWebhookDispatch(msg.payload);
13501
+ return;
11894
13502
  default:
11895
13503
  this.emit("unknown-message", msg);
11896
13504
  }
11897
13505
  }
13506
+ /**
13507
+ * v2.0 §4.8.1 — webhook dispatch over WS reverse channel.
13508
+ *
13509
+ * Acks via `webhook.dispatch.reply { _rpcId, ok, acceptedAt }` immediately
13510
+ * after `handleAgentMessageDispatch()` returns its synchronous response
13511
+ * — the actual reply (the agent's reply text/attachments) still flows
13512
+ * through `postMessageDispatchReply()` which posts to
13513
+ * `/api/im/dispatch/reply` after the adapter finishes. That mirrors the
13514
+ * HTTP /dispatch contract: ack first, asynchronous final reply via the
13515
+ * dispatch-reply REST endpoint.
13516
+ *
13517
+ * The `_rpcId` field is the only addition versus the HTTP path; we strip
13518
+ * it before passing the payload to the dispatch handler so existing
13519
+ * validation logic doesn't trip on an unknown field.
13520
+ */
13521
+ async onWebhookDispatch(payload) {
13522
+ const rpcId = payload._rpcId;
13523
+ if (!rpcId) {
13524
+ process.stderr.write(`[daemon] webhook.dispatch.request without _rpcId \u2014 dropped
13525
+ `);
13526
+ return;
13527
+ }
13528
+ const { _rpcId: _, ...request } = payload;
13529
+ try {
13530
+ const handle = handleAgentMessageDispatch(request, {
13531
+ findAgent: (agentImUserId) => this.findMessageDispatchAgent(agentImUserId),
13532
+ postReply: (replyPayload) => this.postMessageDispatchReply(replyPayload),
13533
+ onError: (err, req) => {
13534
+ process.stderr.write(
13535
+ `[daemon] webhook.dispatch.request failed message=${req.messageId} agent=${req.mentionedAgentImUserId}: ${err instanceof Error ? err.stack ?? err.message : String(err)}
13536
+ `
13537
+ );
13538
+ }
13539
+ });
13540
+ this.ws.send({
13541
+ type: "webhook.dispatch.reply",
13542
+ payload: {
13543
+ _rpcId: rpcId,
13544
+ ok: handle.response.ok,
13545
+ acceptedAt: handle.response.acceptedAt,
13546
+ error: handle.response.error
13547
+ }
13548
+ });
13549
+ void handle.done.catch((err) => {
13550
+ process.stderr.write(
13551
+ `[daemon] webhook.dispatch final reply failed message=${request.messageId}: ${err instanceof Error ? err.message : String(err)}
13552
+ `
13553
+ );
13554
+ });
13555
+ } catch (err) {
13556
+ this.ws.send({
13557
+ type: "webhook.dispatch.reply",
13558
+ payload: {
13559
+ _rpcId: rpcId,
13560
+ ok: false,
13561
+ error: {
13562
+ code: "daemon_dispatch_failed",
13563
+ message: err instanceof Error ? err.message : String(err)
13564
+ }
13565
+ }
13566
+ });
13567
+ }
13568
+ }
11898
13569
  async onHostAcked(payload) {
11899
13570
  this.workspaceId = payload.workspaceId;
13571
+ void this.reportTransport().catch((err) => {
13572
+ process.stderr.write(
13573
+ `[daemon] transport-probe report failed: ${err.message}
13574
+ `
13575
+ );
13576
+ });
11900
13577
  if (this.memoryWiring && this.workspaceId) {
11901
13578
  syncMemoryFromCloud(this.memoryWiring, this.cloud, [this.workspaceId]).catch(
11902
13579
  (err) => log7.error("Initial memory sync failed", err.message)
@@ -12078,15 +13755,69 @@ var Runner = class extends import_node_events3.EventEmitter {
12078
13755
  };
12079
13756
  }
12080
13757
  async postMessageDispatchReply(payload) {
12081
- const res = await this.cloud.request("POST", "/api/im/dispatch/reply", {
12082
- auth: false,
12083
- body: payload,
12084
- timeoutMs: 3e4
12085
- });
12086
- if (!res.ok) {
12087
- throw new Error(res.error?.message ?? `dispatch reply failed (${res.status})`);
13758
+ const taskId = `external:${payload.replyToMessageId}`;
13759
+ try {
13760
+ const result = await sendDispatchReplyTwoPhase({
13761
+ taskId,
13762
+ payload: {
13763
+ conversationId: payload.conversationId,
13764
+ replyToken: payload.replyToken,
13765
+ replyToMessageId: payload.replyToMessageId,
13766
+ agentImUserId: payload.agentImUserId,
13767
+ status: payload.status,
13768
+ ...payload.replyText !== void 0 ? { replyText: payload.replyText } : {},
13769
+ ...payload.attachments ? { attachments: payload.attachments } : {},
13770
+ assetIds: payload.attachments?.map((a) => a.assetId).filter((id) => typeof id === "string") ?? [],
13771
+ completedAt: payload.completedAt,
13772
+ ...payload.error ? { error: payload.error } : {}
13773
+ },
13774
+ cloud: this.cloud,
13775
+ cache: this.pendingReplyCache
13776
+ });
13777
+ if (result.status === "aborted") {
13778
+ throw new Error(`dispatch reply aborted by server reaper (replyId=${result.replyId})`);
13779
+ }
13780
+ } catch (err) {
13781
+ throw new Error(
13782
+ err.message?.length ? err.message : `dispatch reply failed`
13783
+ );
12088
13784
  }
12089
13785
  }
13786
+ /**
13787
+ * v2.0 §4.8.1 (Wave 4-E4) — submit the transport-probe report. Called
13788
+ * from `onHostAcked` so the daemon's container row exists in cloud
13789
+ * before we POST to /runtime/transport-report.
13790
+ *
13791
+ * The gatewayUrl reported here is `http://<local-ipv4>:<localPort>`
13792
+ * (typically `http://192.168.x.x:3210`). For a daemon without a local
13793
+ * HTTP server (startLocalServer=false in tests) we still post the
13794
+ * probe with `gatewayUrl=null` so cloud knows transport='ws' is the
13795
+ * only path.
13796
+ */
13797
+ async reportTransport() {
13798
+ const hasLocalServer = this.opts.startLocalServer !== false;
13799
+ let gatewayUrl = null;
13800
+ if (hasLocalServer) {
13801
+ const localIp = pickLocalIPv4();
13802
+ if (localIp) {
13803
+ const port = this.opts.localPort ?? DEFAULT_LOCAL_PORT;
13804
+ gatewayUrl = `http://${localIp}:${port}`;
13805
+ }
13806
+ }
13807
+ const probe = buildTransportProbe(gatewayUrl);
13808
+ const result = await reportTransportProbe(this.cloud, this.config.daemon_id, probe);
13809
+ if (!result.ok) {
13810
+ process.stderr.write(
13811
+ `[daemon] transport-probe report rejected status=${result.status} error=${result.error ?? "-"}
13812
+ `
13813
+ );
13814
+ return;
13815
+ }
13816
+ process.stdout.write(
13817
+ `[daemon] transport-probe reported transport=${probe.transport} gateway=${probe.gatewayUrl ?? "null"} private=${probe.gatewayIsPrivate}
13818
+ `
13819
+ );
13820
+ }
12090
13821
  onAgentChanged(payload) {
12091
13822
  const a = this.hostedAgents.get(payload.agentImUserId);
12092
13823
  if (!a) return;
@@ -12528,9 +14259,9 @@ var Runner = class extends import_node_events3.EventEmitter {
12528
14259
  function resolveUserAssetWorkspaceDir(workspaceId) {
12529
14260
  const override = process.env.PRISMER_ASSET_SYNC_ROOT;
12530
14261
  if (override) return (0, import_node_path14.join)(override, workspaceId);
12531
- const home = (0, import_node_os6.homedir)();
14262
+ const home = (0, import_node_os8.homedir)();
12532
14263
  const desktop = (0, import_node_path14.join)(home, "Desktop");
12533
- const base = (0, import_node_fs18.existsSync)(desktop) ? desktop : home;
14264
+ const base = (0, import_node_fs19.existsSync)(desktop) ? desktop : home;
12534
14265
  return (0, import_node_path14.join)(base, "Prismer Assets", workspaceId);
12535
14266
  }
12536
14267
  function readTargetDaemonId(payload) {
@@ -12679,9 +14410,9 @@ function buildDaemonCommand() {
12679
14410
  if (existingPid && pidAlive2(existingPid)) {
12680
14411
  exitWithError(`Daemon already running (pid ${existingPid}). Use \`prismer daemon stop\` first.`);
12681
14412
  }
12682
- if (!(0, import_node_fs19.existsSync)(paths.logsDir)) (0, import_node_fs19.mkdirSync)(paths.logsDir, { recursive: true });
14413
+ if (!(0, import_node_fs20.existsSync)(paths.logsDir)) (0, import_node_fs20.mkdirSync)(paths.logsDir, { recursive: true });
12683
14414
  const logFile = (0, import_node_path15.join)(paths.logsDir, "daemon.log");
12684
- const fd = (0, import_node_fs19.openSync)(logFile, "a");
14415
+ const fd = (0, import_node_fs20.openSync)(logFile, "a");
12685
14416
  const args = [process.argv[1], "daemon", "run"];
12686
14417
  if (opts.port) args.push("--port", String(opts.port));
12687
14418
  if (opts.localServer === false) args.push("--no-local-server");
@@ -12765,7 +14496,7 @@ function buildDaemonCommand() {
12765
14496
  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) => {
12766
14497
  const paths = resolvePaths();
12767
14498
  const logFile = (0, import_node_path15.join)(paths.logsDir, "daemon.log");
12768
- if (!(0, import_node_fs19.existsSync)(logFile)) {
14499
+ if (!(0, import_node_fs20.existsSync)(logFile)) {
12769
14500
  exitWithError(`No daemon log found at ${logFile}. Start the daemon with \`prismer daemon start\`.`);
12770
14501
  }
12771
14502
  const lines = Math.max(1, opts.tail);
@@ -12806,13 +14537,13 @@ async function tailFromEnd(path9, lines) {
12806
14537
  }
12807
14538
  }
12808
14539
  async function followFile(path9) {
12809
- let offset = (0, import_node_fs19.statSync)(path9).size;
14540
+ let offset = (0, import_node_fs20.statSync)(path9).size;
12810
14541
  for (; ; ) {
12811
14542
  await (0, import_promises2.setTimeout)(1e3);
12812
- const size = (0, import_node_fs19.statSync)(path9).size;
14543
+ const size = (0, import_node_fs20.statSync)(path9).size;
12813
14544
  if (size < offset) offset = 0;
12814
14545
  if (size === offset) continue;
12815
- const stream = (0, import_node_fs19.createReadStream)(path9, { start: offset, end: size - 1, encoding: "utf8" });
14546
+ const stream = (0, import_node_fs20.createReadStream)(path9, { start: offset, end: size - 1, encoding: "utf8" });
12816
14547
  for await (const chunk of stream) process.stdout.write(chunk);
12817
14548
  offset = size;
12818
14549
  }
@@ -12820,8 +14551,8 @@ async function followFile(path9) {
12820
14551
 
12821
14552
  // src/cli/commands/events.ts
12822
14553
  var import_commander9 = require("commander");
12823
- var import_node_fs20 = require("fs");
12824
- var import_node_os7 = require("os");
14554
+ var import_node_fs21 = require("fs");
14555
+ var import_node_os9 = require("os");
12825
14556
  var import_node_path16 = require("path");
12826
14557
  var import_node_readline = require("readline");
12827
14558
  init_util();
@@ -12829,7 +14560,7 @@ var DEFAULT_LIMIT2 = 50;
12829
14560
  function buildEventsCommand() {
12830
14561
  return addEventOptions(new import_commander9.Command("events").description("Read local PARA events from ~/.prismer/para/events.jsonl")).action(async (opts) => {
12831
14562
  const file = eventsPath();
12832
- if (!(0, import_node_fs20.existsSync)(file)) {
14563
+ if (!(0, import_node_fs21.existsSync)(file)) {
12833
14564
  printJson(unavailable(file));
12834
14565
  process.exitCode = 1;
12835
14566
  return;
@@ -12846,7 +14577,7 @@ function buildEventsCommand() {
12846
14577
  function buildEventsStatsCommand() {
12847
14578
  return addEventOptions(new import_commander9.Command("events:stats").description("Summarize local PARA events from ~/.prismer/para/events.jsonl")).action(async (opts) => {
12848
14579
  const file = eventsPath();
12849
- if (!(0, import_node_fs20.existsSync)(file)) {
14580
+ if (!(0, import_node_fs21.existsSync)(file)) {
12850
14581
  printJson(unavailable(file));
12851
14582
  process.exitCode = 1;
12852
14583
  return;
@@ -12855,7 +14586,7 @@ function buildEventsStatsCommand() {
12855
14586
  const events = await readEvents(file, filters);
12856
14587
  printJson({
12857
14588
  ok: true,
12858
- data: { stats: summarize2(events), count: events.length, limit: filters.limit },
14589
+ data: { stats: summarize3(events), count: events.length, limit: filters.limit },
12859
14590
  checks: { file, exists: true, filters: cleanFilters(filters) }
12860
14591
  });
12861
14592
  });
@@ -12864,7 +14595,7 @@ function addEventOptions(cmd) {
12864
14595
  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)");
12865
14596
  }
12866
14597
  function eventsPath() {
12867
- return (0, import_node_path16.join)(process.env.PRISMER_HOME ?? (0, import_node_path16.join)((0, import_node_os7.homedir)(), ".prismer"), "para", "events.jsonl");
14598
+ return (0, import_node_path16.join)(process.env.PRISMER_HOME ?? (0, import_node_path16.join)((0, import_node_os9.homedir)(), ".prismer"), "para", "events.jsonl");
12868
14599
  }
12869
14600
  function unavailable(file) {
12870
14601
  return {
@@ -12880,7 +14611,7 @@ function unavailable(file) {
12880
14611
  async function readEvents(file, filters) {
12881
14612
  const out = [];
12882
14613
  const rl = (0, import_node_readline.createInterface)({
12883
- input: (0, import_node_fs20.createReadStream)(file, { encoding: "utf8" }),
14614
+ input: (0, import_node_fs21.createReadStream)(file, { encoding: "utf8" }),
12884
14615
  crlfDelay: Infinity
12885
14616
  });
12886
14617
  for await (const line of rl) {
@@ -12912,7 +14643,7 @@ function field(event, names) {
12912
14643
  }
12913
14644
  return void 0;
12914
14645
  }
12915
- function summarize2(events) {
14646
+ function summarize3(events) {
12916
14647
  const byFamily = {};
12917
14648
  const byType = {};
12918
14649
  const byAgentId = {};
@@ -12950,7 +14681,7 @@ function parsePositiveInt4(v) {
12950
14681
  // src/cli/commands/memory.ts
12951
14682
  var import_better_sqlite35 = __toESM(require("better-sqlite3"), 1);
12952
14683
  var import_commander10 = require("commander");
12953
- var import_node_fs21 = require("fs");
14684
+ var import_node_fs22 = require("fs");
12954
14685
  init_util();
12955
14686
  var LOCAL_BASE = process.env.PRISMER_DAEMON_URL ?? "http://127.0.0.1:3210";
12956
14687
  function buildMemoryCommand() {
@@ -13102,7 +14833,7 @@ function readCacheSnapshot(limit) {
13102
14833
  const paths = resolvePaths();
13103
14834
  const empty = {
13104
14835
  dbPath: paths.localDb,
13105
- dbExists: (0, import_node_fs21.existsSync)(paths.localDb),
14836
+ dbExists: (0, import_node_fs22.existsSync)(paths.localDb),
13106
14837
  tables: {
13107
14838
  cached_assets: { exists: false, count: 0, sizeBytes: 0 },
13108
14839
  workspace_files_mirror: { exists: false, count: 0 }
@@ -13225,16 +14956,16 @@ var import_commander11 = require("commander");
13225
14956
 
13226
14957
  // src/pair.ts
13227
14958
  var import_node_crypto11 = require("crypto");
13228
- var import_node_os9 = require("os");
14959
+ var import_node_os11 = require("os");
13229
14960
  var import_promises3 = require("timers/promises");
13230
14961
  var import_qrcode = __toESM(require("qrcode"), 1);
13231
14962
 
13232
14963
  // src/daemon-id.ts
13233
14964
  var import_node_crypto10 = require("crypto");
13234
- var import_node_os8 = require("os");
14965
+ var import_node_os10 = require("os");
13235
14966
  var PREFIX = "daemon-";
13236
14967
  function sanitizeHostname() {
13237
- return (0, import_node_os8.hostname)().replace(/[^a-zA-Z0-9_-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "") || "unknown";
14968
+ return (0, import_node_os10.hostname)().replace(/[^a-zA-Z0-9_-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "") || "unknown";
13238
14969
  }
13239
14970
  function newDaemonId(opts = {}) {
13240
14971
  const host = opts.hostnameOverride ?? sanitizeHostname();
@@ -13273,7 +15004,7 @@ async function pair(opts) {
13273
15004
  "/api/im/pair/offer",
13274
15005
  {
13275
15006
  auth: false,
13276
- body: { devicePub, deviceName: opts.deviceName ?? (0, import_node_os9.hostname)() }
15007
+ body: { devicePub, deviceName: opts.deviceName ?? (0, import_node_os11.hostname)() }
13277
15008
  }
13278
15009
  );
13279
15010
  if (!offerRes.ok) {
@@ -13394,8 +15125,8 @@ function buildPairCommand() {
13394
15125
 
13395
15126
  // src/cli/commands/profile.ts
13396
15127
  var import_commander12 = require("commander");
13397
- var import_node_fs22 = require("fs");
13398
- var import_node_os10 = require("os");
15128
+ var import_node_fs23 = require("fs");
15129
+ var import_node_os12 = require("os");
13399
15130
  var import_node_path17 = require("path");
13400
15131
  var import_node_child_process8 = require("child_process");
13401
15132
 
@@ -13520,12 +15251,12 @@ function buildProfileCommand() {
13520
15251
  const profile = await cloud.get(
13521
15252
  `/api/im/agent_profiles/${encodeURIComponent(profileId)}`
13522
15253
  );
13523
- const tmpFile = (0, import_node_path17.join)((0, import_node_os10.tmpdir)(), `prismer-profile-${profileId}.json`);
13524
- (0, import_node_fs22.writeFileSync)(tmpFile, JSON.stringify(profile.config, null, 2), "utf8");
15254
+ const tmpFile = (0, import_node_path17.join)((0, import_node_os12.tmpdir)(), `prismer-profile-${profileId}.json`);
15255
+ (0, import_node_fs23.writeFileSync)(tmpFile, JSON.stringify(profile.config, null, 2), "utf8");
13525
15256
  const editor = process.env.EDITOR || "vi";
13526
15257
  const ed = (0, import_node_child_process8.spawnSync)(editor, [tmpFile], { stdio: "inherit" });
13527
15258
  if (ed.status !== 0) exitWithError(`editor exited ${ed.status}`, { code: "editor_failed" });
13528
- const newConfig = JSON.parse((0, import_node_fs22.readFileSync)(tmpFile, "utf8"));
15259
+ const newConfig = JSON.parse((0, import_node_fs23.readFileSync)(tmpFile, "utf8"));
13529
15260
  const res = await cloud.request("PATCH", `/api/im/agent_profiles/${encodeURIComponent(profileId)}`, {
13530
15261
  body: { config: newConfig, version: profile.version }
13531
15262
  });
@@ -13547,8 +15278,8 @@ function mkCloud4() {
13547
15278
  function readJsonArg(arg) {
13548
15279
  if (arg.startsWith("@")) {
13549
15280
  const path9 = arg.slice(1);
13550
- if (!(0, import_node_fs22.existsSync)(path9)) throw new Error(`File not found: ${path9}`);
13551
- return JSON.parse((0, import_node_fs22.readFileSync)(path9, "utf8"));
15281
+ if (!(0, import_node_fs23.existsSync)(path9)) throw new Error(`File not found: ${path9}`);
15282
+ return JSON.parse((0, import_node_fs23.readFileSync)(path9, "utf8"));
13552
15283
  }
13553
15284
  return JSON.parse(arg);
13554
15285
  }
@@ -13562,8 +15293,8 @@ async function resolveDefaultWorkspaceId(cloud) {
13562
15293
  // src/cli/commands/reset.ts
13563
15294
  var import_commander13 = require("commander");
13564
15295
  var import_node_child_process9 = require("child_process");
13565
- var import_node_fs23 = require("fs");
13566
- var import_node_os11 = require("os");
15296
+ var import_node_fs24 = require("fs");
15297
+ var import_node_os13 = require("os");
13567
15298
  var import_node_path18 = require("path");
13568
15299
  var import_promises4 = require("timers/promises");
13569
15300
  init_util();
@@ -13581,15 +15312,15 @@ function buildResetCommand() {
13581
15312
  const hermesGatewayPids = findHermesGatewayPids2();
13582
15313
  const plan = {
13583
15314
  root: paths.root,
13584
- exists: (0, import_node_fs23.existsSync)(paths.root),
15315
+ exists: (0, import_node_fs24.existsSync)(paths.root),
13585
15316
  runningPid,
13586
15317
  mode: opts.wipe ? "wipe" : "archive",
13587
15318
  archivePath,
13588
15319
  assetSyncRoot,
13589
- assetSyncExists: (0, import_node_fs23.existsSync)(assetSyncRoot),
15320
+ assetSyncExists: (0, import_node_fs24.existsSync)(assetSyncRoot),
13590
15321
  assetSyncArchivePath,
13591
15322
  hermesHome,
13592
- hermesExists: (0, import_node_fs23.existsSync)(hermesHome),
15323
+ hermesExists: (0, import_node_fs24.existsSync)(hermesHome),
13593
15324
  hermesArchivePath,
13594
15325
  hermesGatewayPids,
13595
15326
  willStopDaemon: runningPid !== null,
@@ -13613,40 +15344,40 @@ function buildResetCommand() {
13613
15344
  for (const gatewayPid of hermesGatewayPids) {
13614
15345
  await stopProcess(gatewayPid, opts.timeout ?? 5e3, "Hermes gateway");
13615
15346
  }
13616
- if ((0, import_node_fs23.existsSync)(paths.root)) {
15347
+ if ((0, import_node_fs24.existsSync)(paths.root)) {
13617
15348
  if (opts.wipe) {
13618
- (0, import_node_fs23.rmSync)(paths.root, { recursive: true, force: true });
15349
+ (0, import_node_fs24.rmSync)(paths.root, { recursive: true, force: true });
13619
15350
  } else {
13620
15351
  if (!archivePath) throw new Error("internal: archivePath missing");
13621
- if (!(0, import_node_fs23.existsSync)((0, import_node_path18.dirname)(archivePath))) {
13622
- (0, import_node_fs23.mkdirSync)((0, import_node_path18.dirname)(archivePath), { recursive: true });
15352
+ if (!(0, import_node_fs24.existsSync)((0, import_node_path18.dirname)(archivePath))) {
15353
+ (0, import_node_fs24.mkdirSync)((0, import_node_path18.dirname)(archivePath), { recursive: true });
13623
15354
  }
13624
- (0, import_node_fs23.renameSync)(paths.root, archivePath);
15355
+ (0, import_node_fs24.renameSync)(paths.root, archivePath);
13625
15356
  }
13626
15357
  }
13627
- if ((0, import_node_fs23.existsSync)(assetSyncRoot)) {
15358
+ if ((0, import_node_fs24.existsSync)(assetSyncRoot)) {
13628
15359
  if (opts.wipe) {
13629
- (0, import_node_fs23.rmSync)(assetSyncRoot, { recursive: true, force: true });
15360
+ (0, import_node_fs24.rmSync)(assetSyncRoot, { recursive: true, force: true });
13630
15361
  } else {
13631
15362
  if (!assetSyncArchivePath) throw new Error("internal: assetSyncArchivePath missing");
13632
- if (!(0, import_node_fs23.existsSync)((0, import_node_path18.dirname)(assetSyncArchivePath))) {
13633
- (0, import_node_fs23.mkdirSync)((0, import_node_path18.dirname)(assetSyncArchivePath), { recursive: true });
15363
+ if (!(0, import_node_fs24.existsSync)((0, import_node_path18.dirname)(assetSyncArchivePath))) {
15364
+ (0, import_node_fs24.mkdirSync)((0, import_node_path18.dirname)(assetSyncArchivePath), { recursive: true });
13634
15365
  }
13635
- (0, import_node_fs23.renameSync)(assetSyncRoot, assetSyncArchivePath);
15366
+ (0, import_node_fs24.renameSync)(assetSyncRoot, assetSyncArchivePath);
13636
15367
  }
13637
15368
  }
13638
- if ((0, import_node_fs23.existsSync)(hermesHome)) {
15369
+ if ((0, import_node_fs24.existsSync)(hermesHome)) {
13639
15370
  if (opts.wipe) {
13640
- (0, import_node_fs23.rmSync)(hermesHome, { recursive: true, force: true });
15371
+ (0, import_node_fs24.rmSync)(hermesHome, { recursive: true, force: true });
13641
15372
  } else {
13642
15373
  if (!hermesArchivePath) throw new Error("internal: hermesArchivePath missing");
13643
- if (!(0, import_node_fs23.existsSync)((0, import_node_path18.dirname)(hermesArchivePath))) {
13644
- (0, import_node_fs23.mkdirSync)((0, import_node_path18.dirname)(hermesArchivePath), { recursive: true });
15374
+ if (!(0, import_node_fs24.existsSync)((0, import_node_path18.dirname)(hermesArchivePath))) {
15375
+ (0, import_node_fs24.mkdirSync)((0, import_node_path18.dirname)(hermesArchivePath), { recursive: true });
13645
15376
  }
13646
- (0, import_node_fs23.renameSync)(hermesHome, hermesArchivePath);
15377
+ (0, import_node_fs24.renameSync)(hermesHome, hermesArchivePath);
13647
15378
  }
13648
15379
  }
13649
- (0, import_node_fs23.mkdirSync)(paths.root, { recursive: true });
15380
+ (0, import_node_fs24.mkdirSync)(paths.root, { recursive: true });
13650
15381
  if (opts.json) {
13651
15382
  printJson({ ok: true, reset: true, plan });
13652
15383
  return;
@@ -13715,13 +15446,13 @@ function timestamp() {
13715
15446
  function resolveAssetSyncRoot() {
13716
15447
  const override = process.env.PRISMER_ASSET_SYNC_ROOT;
13717
15448
  if (override) return override;
13718
- const home = (0, import_node_os11.homedir)();
15449
+ const home = (0, import_node_os13.homedir)();
13719
15450
  const desktop = (0, import_node_path18.join)(home, "Desktop");
13720
- const base = (0, import_node_fs23.existsSync)(desktop) ? desktop : home;
15451
+ const base = (0, import_node_fs24.existsSync)(desktop) ? desktop : home;
13721
15452
  return (0, import_node_path18.join)(base, "Prismer Assets");
13722
15453
  }
13723
15454
  function resolveHermesHome() {
13724
- return process.env.HERMES_HOME || (0, import_node_path18.join)((0, import_node_os11.homedir)(), ".hermes");
15455
+ return process.env.HERMES_HOME || (0, import_node_path18.join)((0, import_node_os13.homedir)(), ".hermes");
13725
15456
  }
13726
15457
  function findHermesGatewayPids2() {
13727
15458
  try {
@@ -13951,10 +15682,10 @@ async function safeParseResponse(res) {
13951
15682
 
13952
15683
  // src/cli/commands/setup.ts
13953
15684
  var import_commander15 = require("commander");
13954
- var import_node_os12 = require("os");
15685
+ var import_node_os14 = require("os");
13955
15686
  var import_node_child_process10 = require("child_process");
13956
15687
  var import_node_crypto12 = require("crypto");
13957
- var import_node_fs24 = require("fs");
15688
+ var import_node_fs25 = require("fs");
13958
15689
  var import_node_http2 = require("http");
13959
15690
  init_util();
13960
15691
  init_ui();
@@ -13996,7 +15727,7 @@ function buildSetupCommand() {
13996
15727
  }
13997
15728
  const result = await pair({
13998
15729
  cloudBaseUrl,
13999
- deviceName: opts.deviceName ?? (0, import_node_os12.hostname)(),
15730
+ deviceName: opts.deviceName ?? (0, import_node_os14.hostname)(),
14000
15731
  force: opts.force,
14001
15732
  paths,
14002
15733
  asUserEmail: opts.asUser
@@ -14013,13 +15744,13 @@ function buildSetupCommand() {
14013
15744
  apiKey = await mintDaemonApiKey({
14014
15745
  cloudBaseUrl,
14015
15746
  token: authToken,
14016
- deviceName: opts.deviceName ?? (0, import_node_os12.hostname)()
15747
+ deviceName: opts.deviceName ?? (0, import_node_os14.hostname)()
14017
15748
  });
14018
15749
  }
14019
15750
  if (!apiKey && !authToken && !opts.check && opts.browser !== false) {
14020
15751
  apiKey = await runBrowserSetup({
14021
15752
  cloudBaseUrl,
14022
- deviceName: opts.deviceName ?? (0, import_node_os12.hostname)(),
15753
+ deviceName: opts.deviceName ?? (0, import_node_os14.hostname)(),
14023
15754
  json: Boolean(opts.json)
14024
15755
  });
14025
15756
  }
@@ -14205,9 +15936,9 @@ function shouldArchiveLocalDb(previous, next) {
14205
15936
  return previous.api_key !== next.api_key || previous.cloud_api_base !== next.cloud_api_base || previous.daemon_id !== next.daemon_id;
14206
15937
  }
14207
15938
  function archiveLocalDb(localDbPath) {
14208
- if (!(0, import_node_fs24.existsSync)(localDbPath)) return;
15939
+ if (!(0, import_node_fs25.existsSync)(localDbPath)) return;
14209
15940
  const archived = `${localDbPath}.${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}.bak`;
14210
- (0, import_node_fs24.renameSync)(localDbPath, archived);
15941
+ (0, import_node_fs25.renameSync)(localDbPath, archived);
14211
15942
  }
14212
15943
  async function mintDaemonApiKey(input) {
14213
15944
  const label = `Daemon: ${input.deviceName} ${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}`;
@@ -14276,7 +16007,7 @@ function buildSkillCommand() {
14276
16007
  const cloud = mkCloud6();
14277
16008
  const path9 = opts.agent ? `/api/im/agents/${encodeURIComponent(opts.agent)}/skills` : `/api/im/skills/${encodeURIComponent(slugOrId)}/install`;
14278
16009
  const body = opts.agent ? { skillId: slugOrId, workspaceId: opts.workspaceId, version: opts.version } : { workspaceId: opts.workspaceId, version: opts.version };
14279
- const data = await requestEnvelope(cloud, "POST", path9, body, "skill_install_failed");
16010
+ const data = await requestEnvelope2(cloud, "POST", path9, body, "skill_install_failed");
14280
16011
  if (opts.json) {
14281
16012
  printJson(data);
14282
16013
  return;
@@ -14287,7 +16018,7 @@ function buildSkillCommand() {
14287
16018
  const cloud = mkCloud6();
14288
16019
  const path9 = opts.agent ? `/api/im/agents/${encodeURIComponent(opts.agent)}/skills` : `/api/im/skills/${encodeURIComponent(slugOrId)}/install`;
14289
16020
  const body = opts.agent ? { skillId: slugOrId, workspaceId: opts.workspaceId } : void 0;
14290
- const data = await requestEnvelope(cloud, "DELETE", path9, body, "skill_uninstall_failed");
16021
+ const data = await requestEnvelope2(cloud, "DELETE", path9, body, "skill_uninstall_failed");
14291
16022
  if (opts.json) {
14292
16023
  printJson(data);
14293
16024
  return;
@@ -14340,7 +16071,7 @@ function mkCloud6() {
14340
16071
  const cfg = loadConfig(resolvePaths());
14341
16072
  return new CloudClient({ baseUrl: cfg.cloud_api_base, apiKey: cfg.api_key });
14342
16073
  }
14343
- async function requestEnvelope(cloud, method, path9, body, code) {
16074
+ async function requestEnvelope2(cloud, method, path9, body, code) {
14344
16075
  const res = await cloud.request(method, path9, {
14345
16076
  body
14346
16077
  });
@@ -14459,7 +16190,7 @@ function printSkill(skill, includeContent) {
14459
16190
 
14460
16191
  // src/cli/commands/status.ts
14461
16192
  var import_commander17 = require("commander");
14462
- var import_node_os13 = require("os");
16193
+ var import_node_os15 = require("os");
14463
16194
  init_util();
14464
16195
  init_ui();
14465
16196
  function buildStatusCommand() {
@@ -14490,6 +16221,7 @@ function buildStatusCommand() {
14490
16221
  let me = null;
14491
16222
  let devices = null;
14492
16223
  let agents = null;
16224
+ let diagnose = null;
14493
16225
  try {
14494
16226
  const meRes = await cloud.request("GET", "/api/im/me", { timeoutMs: 3e3 });
14495
16227
  cloudOk = meRes.ok;
@@ -14502,19 +16234,33 @@ function buildStatusCommand() {
14502
16234
  if (Array.isArray(wsList) && wsList.length > 0) {
14503
16235
  const wsId = wsList[0]?.id;
14504
16236
  if (wsId) {
14505
- const devRes = await cloud.request("GET", `/api/workspace/runtime-installations?workspaceId=${encodeURIComponent(wsId)}&includeStopped=false`, { timeoutMs: 3e3 });
14506
- if (devRes.ok) {
14507
- const devBody = devRes.data;
14508
- devices = devBody?.data;
14509
- }
14510
- const agRes = await cloud.request("GET", `/api/im/workspaces/${encodeURIComponent(wsId)}/agents`, { timeoutMs: 3e3 });
14511
- if (agRes.ok) {
14512
- const agBody = agRes.data;
14513
- agents = agBody?.data;
16237
+ const rtRes = await cloud.request("GET", `/api/im/workspaces/${encodeURIComponent(wsId)}/runtime`, { timeoutMs: 3e3 });
16238
+ if (rtRes.ok) {
16239
+ const rtBody = rtRes.data;
16240
+ const snapshot = rtBody?.data;
16241
+ if (snapshot && Array.isArray(snapshot.devices)) {
16242
+ devices = snapshot.devices;
16243
+ agents = snapshot.devices.flatMap((d) => d.agents ?? []);
16244
+ }
14514
16245
  }
14515
16246
  }
14516
16247
  }
14517
16248
  }
16249
+ const daemonId = cfg.daemon_id;
16250
+ if (daemonId) {
16251
+ try {
16252
+ const diagRes = await cloud.request(
16253
+ "GET",
16254
+ `/api/im/runtime/diagnose?daemonId=${encodeURIComponent(daemonId)}`,
16255
+ { timeoutMs: 3e3 }
16256
+ );
16257
+ if (diagRes.ok) {
16258
+ const body = diagRes.data;
16259
+ if (body?.data && typeof body.data === "object") diagnose = body.data;
16260
+ }
16261
+ } catch {
16262
+ }
16263
+ }
14518
16264
  }
14519
16265
  } catch {
14520
16266
  cloudOk = false;
@@ -14531,7 +16277,8 @@ function buildStatusCommand() {
14531
16277
  info: daemonStatus.info ?? {}
14532
16278
  },
14533
16279
  cloud: { base: cfg.cloud_api_base, reachable: cloudOk, me, devices, agents },
14534
- local
16280
+ local,
16281
+ diagnose
14535
16282
  };
14536
16283
  if (opts.json) {
14537
16284
  printJson(report);
@@ -14540,6 +16287,60 @@ function buildStatusCommand() {
14540
16287
  printPretty(report);
14541
16288
  });
14542
16289
  }
16290
+ function computeDeviceIcon(lastSeenAt, now) {
16291
+ if (!lastSeenAt) return "\u25CB";
16292
+ const age = now.getTime() - Date.parse(lastSeenAt);
16293
+ if (!Number.isFinite(age) || age < 0) return "\u25CB";
16294
+ if (age < 5 * 6e4) return "\u25CF";
16295
+ if (age < 60 * 6e4) return "\u25D0";
16296
+ return "\u25CB";
16297
+ }
16298
+ function computeAgentIcon(status, lastHeartbeat, now) {
16299
+ const s = (status || "").toLowerCase();
16300
+ if (s === "error" || s === "failed") return "\u25C6";
16301
+ if (!lastHeartbeat) return s === "offline" ? "\u25CB" : "\u25D0";
16302
+ const age = now.getTime() - Date.parse(lastHeartbeat);
16303
+ if (!Number.isFinite(age) || age < 0 || age >= 60 * 6e4) return "\u25CB";
16304
+ if (age >= 5 * 6e4) return "\u25D0";
16305
+ return "\u25CF";
16306
+ }
16307
+ function pad(value, width) {
16308
+ if (value.length >= width) return value.slice(0, width);
16309
+ return value + " ".repeat(width - value.length);
16310
+ }
16311
+ function formatDeviceBindings(devices, now = /* @__PURE__ */ new Date()) {
16312
+ const lines = [];
16313
+ if (devices.length === 0) {
16314
+ lines.push(" No paired devices.");
16315
+ return lines;
16316
+ }
16317
+ const totalAgents = devices.reduce((s, d) => s + (d.agents?.length ?? 0), 0);
16318
+ const devWord = devices.length === 1 ? "device" : "devices";
16319
+ const agWord = totalAgents === 1 ? "agent" : "agents";
16320
+ lines.push(` Device & Agent Bindings (${devices.length} ${devWord} \xB7 ${totalAgents} ${agWord})`);
16321
+ lines.push("");
16322
+ for (let di = 0; di < devices.length; di++) {
16323
+ const d = devices[di];
16324
+ const dIcon = computeDeviceIcon(d.lastSeenAt, now);
16325
+ const dName = pad(d.name || d.deviceId, 40);
16326
+ const lastSeen = d.lastSeenAt ? formatRelative(d.lastSeenAt, now) : "never seen";
16327
+ lines.push(` ${dIcon} ${dName} ${lastSeen}`);
16328
+ const agents = d.agents ?? [];
16329
+ for (let ai = 0; ai < agents.length; ai++) {
16330
+ const a = agents[ai];
16331
+ const isLast = ai === agents.length - 1;
16332
+ const branch = isLast ? "\u2514\u2500" : "\u251C\u2500";
16333
+ const aIcon = computeAgentIcon(a.status, a.lastHeartbeat, now);
16334
+ const aName = pad(a.name || a.id, 24);
16335
+ const aStatus = pad(a.status || "?", 8);
16336
+ const aVersion = pad(a.version ? `v${a.version}` : "\u2014", 10);
16337
+ const tail = a.currentTaskId ? `task=${a.currentTaskId}` : a.lastHeartbeat ? `\u2764 ${formatRelative(a.lastHeartbeat, now)}` : "";
16338
+ lines.push(` ${branch} ${aIcon} ${aName} ${aStatus} ${aVersion} ${tail}`.trimEnd());
16339
+ }
16340
+ if (di < devices.length - 1) lines.push("");
16341
+ }
16342
+ return lines;
16343
+ }
14543
16344
  async function readDaemonStatus() {
14544
16345
  try {
14545
16346
  const res = await fetch("http://127.0.0.1:3210/healthz", {
@@ -14583,7 +16384,7 @@ function printPretty(report) {
14583
16384
  ui.blank();
14584
16385
  ok("Config", report.paths.config);
14585
16386
  if (report.daemon.running) {
14586
- 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)();
16387
+ 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)();
14587
16388
  const ws = report.daemon.wsConnected ? "connected" : "pending";
14588
16389
  ok("Daemon", `${daemonName} pid=${report.daemon.pid} ws=${ws}`);
14589
16390
  if (report.daemon.info) {
@@ -14612,16 +16413,7 @@ function printPretty(report) {
14612
16413
  if (Array.isArray(report.cloud.devices) && report.cloud.devices.length > 0) {
14613
16414
  const devs = report.cloud.devices;
14614
16415
  ui.blank();
14615
- ui.line(` Workspace Devices (${devs.length}):`);
14616
- for (const d of devs) {
14617
- const statusIcon = d.daemonStatus === "connected" ? "\u25CF" : "\u25CB";
14618
- const kind = d.runtimeKind === "docker" ? "Local" : "K8s";
14619
- const declared = d.hostedAgentSummary?.declared ?? 0;
14620
- ui.line(` ${statusIcon} ${d.podName?.replace(/^daemon:/, "")?.slice(0, 28) ?? "?"} kind=${kind} agents=${declared}`);
14621
- }
14622
- }
14623
- if (Array.isArray(report.cloud.agents) && report.cloud.agents.length > 0) {
14624
- ui.line(` Hosted agents: ${report.cloud.agents.length}`);
16416
+ for (const ln of formatDeviceBindings(devs)) ui.line(ln);
14625
16417
  }
14626
16418
  if (report.local) {
14627
16419
  ui.blank();
@@ -14631,6 +16423,58 @@ function printPretty(report) {
14631
16423
  } else {
14632
16424
  warn("Local DB", "unavailable");
14633
16425
  }
16426
+ if (report.diagnose) {
16427
+ ui.blank();
16428
+ ui.header("Cloud dispatch reachability");
16429
+ ui.blank();
16430
+ printDiagnose(report.diagnose, report.daemon.pid ?? null);
16431
+ }
16432
+ }
16433
+ function printDiagnose(d, pid) {
16434
+ const ui = getUI();
16435
+ const heartbeatLine = d.lastHeartbeatAt ? `last heartbeat ${formatRelative(d.lastHeartbeatAt)}` : "no heartbeat seen";
16436
+ if (d.wsAlive) {
16437
+ ok("Daemon", `running${pid ? ` (pid ${pid})` : ""}`);
16438
+ ok("Cloud WS", `connected (${heartbeatLine})`);
16439
+ } else {
16440
+ warn("Daemon", `running${pid ? ` (pid ${pid})` : ""} \u2014 not visible to cloud WS`);
16441
+ fail("Cloud WS", `disconnected (${heartbeatLine})`);
16442
+ }
16443
+ if (d.gatewayUrl) {
16444
+ if (d.gatewayIsPrivate) {
16445
+ warn("HTTP gateway", `${d.gatewayUrl} (private IP \u2014 only reachable on same network)`);
16446
+ } else if (d.httpReachable) {
16447
+ ok(
16448
+ "HTTP gateway",
16449
+ `${d.gatewayUrl} (reachable${d.httpLatencyMs != null ? `, ${d.httpLatencyMs}ms` : ""})`
16450
+ );
16451
+ } else {
16452
+ fail("HTTP gateway", `${d.gatewayUrl} (unreachable${d.httpError ? ` \u2014 ${d.httpError}` : ""})`);
16453
+ }
16454
+ } else {
16455
+ ui.line(" HTTP gateway: not declared (WS-only daemon)");
16456
+ }
16457
+ const activeAgents = /* @__PURE__ */ (() => {
16458
+ return 0;
16459
+ })();
16460
+ if (activeAgents > 0) {
16461
+ ok("Active agents", String(activeAgents));
16462
+ }
16463
+ const recColour = d.recommendedTransport === "ws" ? ok : d.recommendedTransport === "http" ? warn : fail;
16464
+ recColour("Recommended transport", d.recommendedTransport);
16465
+ if (d.lastProbeAt) {
16466
+ ui.line(` Last transport probe: ${formatRelative(d.lastProbeAt)}`);
16467
+ }
16468
+ }
16469
+ function formatRelative(iso, now = /* @__PURE__ */ new Date()) {
16470
+ const ts = Date.parse(iso);
16471
+ if (!Number.isFinite(ts)) return "unknown";
16472
+ const diff = Math.max(0, now.getTime() - ts);
16473
+ if (diff < 3e4) return "just now";
16474
+ if (diff < 6e4) return `${Math.round(diff / 1e3)}s ago`;
16475
+ if (diff < 36e5) return `${Math.round(diff / 6e4)}m ago`;
16476
+ if (diff < 864e5) return `${Math.round(diff / 36e5)}h ago`;
16477
+ return `${Math.round(diff / 864e5)}d ago`;
14634
16478
  }
14635
16479
 
14636
16480
  // src/cli/commands/task.ts
@@ -14993,7 +16837,7 @@ async function readResponseError(res) {
14993
16837
 
14994
16838
  // src/cli/index.ts
14995
16839
  init_ui();
14996
- var VERSION = "2.0.3";
16840
+ var VERSION = "2.0.5";
14997
16841
  function buildProgram() {
14998
16842
  const program = new import_commander20.Command("prismer").description("Prismer Cloud daemon CLI (TS-only).").version(VERSION);
14999
16843
  program.addCommand(buildBannerCommand());