@botcord/daemon 0.2.86 → 0.2.87

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.
@@ -158,19 +158,24 @@ export async function startCloudDaemon(opts) {
158
158
  });
159
159
  };
160
160
  const installedAgentIds = new Set();
161
+ const runtimeByAgentId = new Map();
161
162
  let controlChannel = null;
162
163
  const pushInstalledAgentSkillSnapshot = (agentId, reason) => {
163
164
  if (!controlChannel)
164
165
  return;
165
- const pushed = pushAgentSkillSnapshot(controlChannel, agentId);
166
+ const runtime = runtimeByAgentId.get(agentId) ?? opts.config.defaultRoute.adapter;
167
+ const pushed = pushAgentSkillSnapshot(controlChannel, agentId, { runtime });
166
168
  logger.info("cloud control-channel: agent_skill_snapshot pushed", {
167
169
  agentId,
170
+ runtime,
168
171
  reason,
169
172
  ok: pushed,
170
173
  });
171
174
  };
172
175
  const onAgentInstalled = (info) => {
173
176
  installedAgentIds.add(info.agentId);
177
+ if (info.runtime)
178
+ runtimeByAgentId.set(info.agentId, info.runtime);
174
179
  credentialPathByAgentId.set(info.agentId, info.credentialsFile);
175
180
  if (info.hubUrl)
176
181
  hubUrlByAgentId.set(info.agentId, info.hubUrl);
package/dist/daemon.d.ts CHANGED
@@ -65,7 +65,9 @@ export interface RuntimeSnapshotSink {
65
65
  * or wait for the next daemon restart). Exported for unit tests.
66
66
  */
67
67
  export declare function pushRuntimeSnapshot(sink: RuntimeSnapshotSink, liveSnapshot?: GatewayRuntimeSnapshot): boolean;
68
- export declare function pushAgentSkillSnapshot(sink: RuntimeSnapshotSink, agentId: string): boolean;
68
+ export declare function pushAgentSkillSnapshot(sink: RuntimeSnapshotSink, agentId: string, opts?: {
69
+ runtime?: string;
70
+ }): boolean;
69
71
  /** Options accepted by {@link startDaemon} — the P0.5 compatibility shim. */
70
72
  export interface DaemonRuntimeOptions {
71
73
  config: DaemonConfig;
package/dist/daemon.js CHANGED
@@ -165,8 +165,8 @@ export function pushRuntimeSnapshot(sink, liveSnapshot) {
165
165
  }
166
166
  return ok;
167
167
  }
168
- export function pushAgentSkillSnapshot(sink, agentId) {
169
- const snap = collectAgentSkillSnapshot(agentId);
168
+ export function pushAgentSkillSnapshot(sink, agentId, opts = {}) {
169
+ const snap = collectAgentSkillSnapshot(agentId, opts);
170
170
  const ok = sink.send({
171
171
  id: `skill_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
172
172
  type: "agent_skill_snapshot",
@@ -372,6 +372,12 @@ export async function startDaemon(opts) {
372
372
  // next room-context fetch re-loads the BotCordClient against the new
373
373
  // credential file.
374
374
  credentialPathByAgentId.set(info.agentId, info.credentialsFile);
375
+ if (info.runtime) {
376
+ agentRuntimes[info.agentId] = {
377
+ ...(agentRuntimes[info.agentId] ?? {}),
378
+ runtime: info.runtime,
379
+ };
380
+ }
375
381
  if (info.hubUrl)
376
382
  hubUrlByAgentId.set(info.agentId, info.hubUrl);
377
383
  if (info.displayName)
@@ -501,9 +507,11 @@ export async function startDaemon(opts) {
501
507
  ok: pushed,
502
508
  });
503
509
  for (const agentId of agentIds) {
504
- const skillsPushed = pushAgentSkillSnapshot(controlChannel, agentId);
510
+ const runtime = agentRuntimes[agentId]?.runtime ?? opts.config.defaultRoute.adapter;
511
+ const skillsPushed = pushAgentSkillSnapshot(controlChannel, agentId, { runtime });
505
512
  logger.info("control-channel: initial agent_skill_snapshot push", {
506
513
  agentId,
514
+ runtime,
507
515
  ok: skillsPushed,
508
516
  });
509
517
  }
@@ -2,6 +2,22 @@ import type { GatewayLogger } from "./log.js";
2
2
  import { type SessionStore } from "./session-store.js";
3
3
  import { type TranscriptWriter } from "./transcript.js";
4
4
  import type { ChannelAdapter, GatewayConfig, GatewayInboundEnvelope, GatewayInboundMessage, GatewayRoute, InboundObserver, MemoryContextBuilder, OutboundObserver, RuntimeAdapter, RuntimeRecoveryContextBuilder, RuntimeRunResult, RuntimeCircuitBreakerSnapshot, SystemContextBuilder, TurnStatusSnapshot, UserTurnBuilder } from "./types.js";
5
+ /**
6
+ * Pick the canonical reply_to value to attach to outbound replies for a given
7
+ * inbound `GatewayInboundMessage`. Priority:
8
+ *
9
+ * 1. `msg.replyTo` — the inbound was itself a reply; preserve the chain so
10
+ * receipts and threaded replies point at the original target.
11
+ * 2. `raw.envelope.msg_id` — the wire-protocol identifier (UUID per a2a/0.1).
12
+ * This is the canonical form the hub stores in `reply_to_msg_id`.
13
+ * 3. `msg.id` — fallback to the hub_msg_id (`h_*`) the BotCord channel
14
+ * stamps on every inbound. The hub accepts this form via
15
+ * `_load_reply_target`'s prefix-based discriminator, but emitting it is
16
+ * lossy because the hub then has to resolve it back to msg_id.
17
+ *
18
+ * Exported for unit testing; production code paths use Dispatcher.providerReplyTo.
19
+ */
20
+ export declare function pickReplyToTarget(msg: GatewayInboundMessage): string;
5
21
  /** Factory signature for building a runtime adapter at turn dispatch time. */
6
22
  export type RuntimeFactory = (runtimeId: string, extraArgs?: string[]) => RuntimeAdapter;
7
23
  /** Constructor options for `Dispatcher`. */
@@ -143,6 +143,30 @@ function buildRuntimeRecoveryPrompt(args) {
143
143
  args.userTurn,
144
144
  ].join("\n");
145
145
  }
146
+ /**
147
+ * Pick the canonical reply_to value to attach to outbound replies for a given
148
+ * inbound `GatewayInboundMessage`. Priority:
149
+ *
150
+ * 1. `msg.replyTo` — the inbound was itself a reply; preserve the chain so
151
+ * receipts and threaded replies point at the original target.
152
+ * 2. `raw.envelope.msg_id` — the wire-protocol identifier (UUID per a2a/0.1).
153
+ * This is the canonical form the hub stores in `reply_to_msg_id`.
154
+ * 3. `msg.id` — fallback to the hub_msg_id (`h_*`) the BotCord channel
155
+ * stamps on every inbound. The hub accepts this form via
156
+ * `_load_reply_target`'s prefix-based discriminator, but emitting it is
157
+ * lossy because the hub then has to resolve it back to msg_id.
158
+ *
159
+ * Exported for unit testing; production code paths use Dispatcher.providerReplyTo.
160
+ */
161
+ export function pickReplyToTarget(msg) {
162
+ if (msg.replyTo)
163
+ return msg.replyTo;
164
+ const raw = msg.raw;
165
+ const envMsgId = raw && typeof raw.envelope?.msg_id === "string" && raw.envelope.msg_id
166
+ ? raw.envelope.msg_id
167
+ : null;
168
+ return envMsgId ?? msg.id;
169
+ }
146
170
  /**
147
171
  * Reason carried on `AbortController.abort()` when a cancel-previous wave
148
172
  * is taking over the slot. Distinguishing this from a timeout abort lets
@@ -1775,7 +1799,7 @@ export class Dispatcher {
1775
1799
  return { ok: true };
1776
1800
  }
1777
1801
  providerReplyTo(msg) {
1778
- return msg.replyTo ?? msg.id;
1802
+ return pickReplyToTarget(msg);
1779
1803
  }
1780
1804
  emitInbound(turnId, msg) {
1781
1805
  if (!this.transcript.enabled)
package/dist/provision.js CHANGED
@@ -22,6 +22,11 @@ import { discoverRuntimeModelCatalog } from "./runtime-models.js";
22
22
  import { collectAgentSkillSnapshot } from "./skill-index.js";
23
23
  import { buildRuntimeSelectionExtraArgs, mergeRuntimeExtraArgs, } from "./runtime-route-options.js";
24
24
  import { handleCloudGatewayRuntimeInbound, } from "./cloud-gateway-runtime.js";
25
+ function runtimeForLoadedAgent(gateway, agentId) {
26
+ return gateway.listManagedRoutes()
27
+ .find((route) => route.match?.accountId === agentId)
28
+ ?.runtime;
29
+ }
25
30
  /**
26
31
  * Build a dispatcher function that routes a `ControlFrame` to the right
27
32
  * handler. Returned function signature matches
@@ -334,9 +339,11 @@ export function createProvisioner(opts) {
334
339
  },
335
340
  };
336
341
  }
337
- const result = collectAgentSkillSnapshot(params.agentId);
342
+ const runtime = runtimeForLoadedAgent(gateway, params.agentId);
343
+ const result = collectAgentSkillSnapshot(params.agentId, { runtime });
338
344
  daemonLog.debug("list_agent_skills", {
339
345
  agentId: params.agentId,
346
+ runtime,
340
347
  count: result.skills.length,
341
348
  });
342
349
  return { ok: true, result };
@@ -19,6 +19,7 @@ export interface AgentSkillSnapshot {
19
19
  export interface SkillIndexOptions {
20
20
  extraDirs?: string[];
21
21
  includeGlobal?: boolean;
22
+ runtime?: string;
22
23
  }
23
24
  export declare function defaultSkillDirs(agentId: string, opts?: SkillIndexOptions): Array<{
24
25
  dir: string;
@@ -7,24 +7,29 @@ const MAX_DESCRIPTION_CHARS = 260;
7
7
  const MAX_SKILL_MD_READ_CHARS = 8192;
8
8
  export function defaultSkillDirs(agentId, opts = {}) {
9
9
  const includeGlobal = opts.includeGlobal !== false;
10
- const dirs = [
11
- {
12
- dir: path.join(agentWorkspaceDir(agentId), ".claude", "skills"),
13
- source: "agent-claude",
14
- },
15
- {
16
- dir: path.join(agentCodexHomeDir(agentId), "skills"),
17
- source: "agent-codex",
18
- },
19
- ];
10
+ const agentClaude = {
11
+ dir: path.join(agentWorkspaceDir(agentId), ".claude", "skills"),
12
+ source: "agent-claude",
13
+ };
14
+ const agentCodex = {
15
+ dir: path.join(agentCodexHomeDir(agentId), "skills"),
16
+ source: "agent-codex",
17
+ };
18
+ const dirs = runtimeFamily(opts.runtime) === "codex"
19
+ ? [agentCodex, agentClaude]
20
+ : [agentClaude, agentCodex];
20
21
  if (includeGlobal) {
21
- dirs.push({ dir: path.join(homedir(), ".claude", "skills"), source: "global-claude" }, { dir: path.join(homedir(), ".codex", "skills"), source: "global-codex" });
22
+ const globalClaude = { dir: path.join(homedir(), ".claude", "skills"), source: "global-claude" };
23
+ const globalCodex = { dir: path.join(homedir(), ".codex", "skills"), source: "global-codex" };
24
+ dirs.push(...(runtimeFamily(opts.runtime) === "codex"
25
+ ? [globalCodex, globalClaude]
26
+ : [globalClaude, globalCodex]));
22
27
  }
23
28
  const envDirs = parseSkillDirsEnv(process.env.BOTCORD_SKILL_DIRS);
24
29
  for (const dir of [...envDirs, ...(opts.extraDirs ?? [])]) {
25
30
  dirs.push({ dir, source: "external" });
26
31
  }
27
- return dedupeDirs(dirs);
32
+ return dedupeDirs(expandSkillRoots(dirs));
28
33
  }
29
34
  export function scanSoftSkills(agentId, opts = {}) {
30
35
  const byName = new Map();
@@ -63,7 +68,7 @@ export function scanSoftSkills(agentId, opts = {}) {
63
68
  description: parsed.description,
64
69
  mtimeMs: st.mtimeMs,
65
70
  };
66
- if (!existing || priority(root.source) < priority(existing.source)) {
71
+ if (!existing || priority(root.source, opts.runtime) < priority(existing.source, opts.runtime)) {
67
72
  byName.set(entry.name, entry);
68
73
  }
69
74
  }
@@ -156,7 +161,38 @@ function dedupeDirs(dirs) {
156
161
  }
157
162
  return out;
158
163
  }
159
- function priority(source) {
164
+ function expandSkillRoots(dirs) {
165
+ const out = [];
166
+ for (const entry of dirs) {
167
+ out.push(entry);
168
+ if (entry.source.includes("codex")) {
169
+ out.push({ dir: path.join(entry.dir, ".system"), source: entry.source });
170
+ }
171
+ }
172
+ return out;
173
+ }
174
+ function runtimeFamily(runtime) {
175
+ if (runtime === "codex")
176
+ return "codex";
177
+ if (runtime === "claude-code")
178
+ return "claude";
179
+ return "other";
180
+ }
181
+ function priority(source, runtime) {
182
+ if (runtimeFamily(runtime) === "codex") {
183
+ switch (source) {
184
+ case "agent-codex":
185
+ return 0;
186
+ case "global-codex":
187
+ return 1;
188
+ case "agent-claude":
189
+ return 2;
190
+ case "global-claude":
191
+ return 3;
192
+ default:
193
+ return 4;
194
+ }
195
+ }
160
196
  switch (source) {
161
197
  case "agent-claude":
162
198
  return 0;
package/dist/turn-text.js CHANGED
@@ -118,6 +118,39 @@ function entryText(e) {
118
118
  return e.envelope.payload.text;
119
119
  return "";
120
120
  }
121
+ /**
122
+ * Format the inline quote-reply context line that prefixes a message body
123
+ * when the inbound envelope replies to another message. Single layer — we
124
+ * never render a quote-of-a-quote chain. Returns `null` when the source
125
+ * carries no reply_preview, so the caller can skip emitting an empty line.
126
+ *
127
+ * Both sender label and preview body are sanitized; the preview is hard-
128
+ * capped at 120 chars to mirror the backend truncation.
129
+ */
130
+ function formatReplyQuoteLine(raw) {
131
+ if (!raw || typeof raw !== "object")
132
+ return null;
133
+ const rp = raw.reply_preview;
134
+ if (!rp || typeof rp !== "object")
135
+ return null;
136
+ if (rp.deleted === true) {
137
+ return "[quoting (deleted message)]";
138
+ }
139
+ const senderRaw = typeof rp.sender_display_name === "string" && rp.sender_display_name
140
+ ? rp.sender_display_name
141
+ : typeof rp.sender_id === "string" && rp.sender_id
142
+ ? rp.sender_id
143
+ : "unknown";
144
+ const sender = sanitizeSenderName(senderRaw);
145
+ const previewRaw = typeof rp.text_preview === "string" ? rp.text_preview : "";
146
+ const previewClean = sanitizeUntrustedContent(previewRaw)
147
+ .replace(/[\r\n]+/g, " ")
148
+ .slice(0, 120);
149
+ if (!previewClean) {
150
+ return `[quoting ${sender}]`;
151
+ }
152
+ return `[quoting ${sender}: "${previewClean}"]`;
153
+ }
121
154
  function formatRoomContext(raw, fallback) {
122
155
  const r = raw && typeof raw === "object" ? raw : {};
123
156
  const roomId = typeof r.room_id === "string" && r.room_id ? r.room_id : fallback.id;
@@ -206,11 +239,13 @@ export function composeBotCordUserTurn(msg) {
206
239
  "they can decide whether to accept or reject it. Include the sender's " +
207
240
  "agent ID and any message they attached.]"
208
241
  : null;
242
+ const quoteLine = formatReplyQuoteLine(msg.raw);
209
243
  const lines = [
210
244
  headerFields.join(" | "),
211
245
  ...formatScheduleContext(msg.raw),
212
246
  ...(isGroup ? formatRoomContext(msg.raw, { id: conversation.id, title: roomTitle }) : []),
213
247
  `<${tag} sender="${sanitizedSenderLabel}" sender_kind="${senderKindAttr}">`,
248
+ ...(quoteLine ? [quoteLine] : []),
214
249
  trimmed,
215
250
  `</${tag}>`,
216
251
  "",
@@ -256,7 +291,9 @@ function composeBatchedTurn(msg, batch) {
256
291
  // non-owner. Still sanitize defensively.
257
292
  const safeBody = sanitizeUntrustedContent(raw);
258
293
  const tag = kind === "human" ? "human-message" : "agent-message";
259
- blocks.push(`<${tag} sender="${safeLabel}" sender_kind="${kind}">\n${safeBody}\n</${tag}>`);
294
+ const quoteLine = formatReplyQuoteLine(entry);
295
+ const inner = quoteLine ? `${quoteLine}\n${safeBody}` : safeBody;
296
+ blocks.push(`<${tag} sender="${safeLabel}" sender_kind="${kind}">\n${inner}\n</${tag}>`);
260
297
  if (envelopeType === "contact_request") {
261
298
  contactRequestSenders.push(safeLabel);
262
299
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@botcord/daemon",
3
- "version": "0.2.86",
3
+ "version": "0.2.87",
4
4
  "description": "BotCord local daemon — bridges Hub inbox push to local Claude Code / Codex / Gemini CLIs",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,61 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { pickReplyToTarget } from "../gateway/dispatcher.js";
3
+ import type { GatewayInboundMessage } from "../gateway/index.js";
4
+
5
+ function makeMsg(partial: Partial<GatewayInboundMessage> = {}): GatewayInboundMessage {
6
+ return {
7
+ id: partial.id ?? "h_abc123",
8
+ channel: partial.channel ?? "botcord",
9
+ accountId: partial.accountId ?? "ag_me",
10
+ conversation: partial.conversation ?? { id: "rm_room", kind: "group" },
11
+ sender: partial.sender ?? { id: "ag_alice", kind: "agent" },
12
+ text: partial.text ?? "hi",
13
+ raw: partial.raw ?? null,
14
+ receivedAt: partial.receivedAt ?? Date.now(),
15
+ mentioned: partial.mentioned ?? false,
16
+ replyTo: partial.replyTo ?? null,
17
+ };
18
+ }
19
+
20
+ describe("pickReplyToTarget", () => {
21
+ it("returns msg.replyTo when the inbound was already a reply (chain semantics)", () => {
22
+ const result = pickReplyToTarget(
23
+ makeMsg({
24
+ replyTo: "11111111-2222-3333-4444-555555555555",
25
+ id: "h_inbound",
26
+ raw: { envelope: { msg_id: "ignored-because-chain-takes-priority" } },
27
+ }),
28
+ );
29
+ expect(result).toBe("11111111-2222-3333-4444-555555555555");
30
+ });
31
+
32
+ it("returns envelope.msg_id (canonical UUID) when present and not chained", () => {
33
+ const result = pickReplyToTarget(
34
+ makeMsg({
35
+ id: "h_inbound",
36
+ raw: { envelope: { msg_id: "11111111-2222-3333-4444-555555555555" } },
37
+ }),
38
+ );
39
+ expect(result).toBe("11111111-2222-3333-4444-555555555555");
40
+ expect(result).not.toMatch(/^h_/);
41
+ });
42
+
43
+ it("falls back to hub_msg_id when envelope.msg_id is missing", () => {
44
+ const result = pickReplyToTarget(
45
+ makeMsg({ id: "h_inbound", raw: null }),
46
+ );
47
+ // Hub is lenient (accepts h_* via _load_reply_target), so this is still
48
+ // resolvable on the wire — but the helper should clearly mark the fallback.
49
+ expect(result).toBe("h_inbound");
50
+ });
51
+
52
+ it("ignores non-string envelope.msg_id and falls back to hub_msg_id", () => {
53
+ const result = pickReplyToTarget(
54
+ makeMsg({
55
+ id: "h_inbound",
56
+ raw: { envelope: { msg_id: 42 as unknown as string } },
57
+ }),
58
+ );
59
+ expect(result).toBe("h_inbound");
60
+ });
61
+ });
@@ -62,6 +62,45 @@ describe("skill snapshots", () => {
62
62
  expect(snapshot.probedAt).toBeGreaterThan(0);
63
63
  });
64
64
 
65
+ it("scans Codex .system skills and prefers Codex copies for Codex agents", () => {
66
+ const agentId = "ag_codex_system";
67
+ writeSkill(path.join(agentWorkspaceDir(agentId), ".claude", "skills"), "shared", "Claude copy");
68
+ writeSkill(path.join(agentCodexHomeDir(agentId), "skills"), "shared", "Codex copy");
69
+ writeSkill(
70
+ path.join(agentCodexHomeDir(agentId), "skills", ".system"),
71
+ "agent-system",
72
+ "Agent Codex system skill",
73
+ );
74
+ writeSkill(
75
+ path.join(tmpDir, ".codex", "skills", ".system"),
76
+ "imagegen",
77
+ "Codex global system skill",
78
+ );
79
+
80
+ const codexScanned = scanSoftSkills(agentId, { runtime: "codex" });
81
+ expect(codexScanned.map((s) => s.name).sort()).toEqual([
82
+ "agent-system",
83
+ "imagegen",
84
+ "shared",
85
+ ]);
86
+ expect(codexScanned.find((s) => s.name === "shared")).toMatchObject({
87
+ source: "agent-codex",
88
+ description: "Codex copy",
89
+ });
90
+
91
+ const claudeScanned = scanSoftSkills(agentId, { runtime: "claude-code" });
92
+ expect(claudeScanned.find((s) => s.name === "shared")).toMatchObject({
93
+ source: "agent-claude",
94
+ description: "Claude copy",
95
+ });
96
+
97
+ const snapshot = collectAgentSkillSnapshot(agentId, { runtime: "codex" });
98
+ expect(snapshot.skills.find((s) => s.name === "agent-system")?.source)
99
+ .toBe("workspace");
100
+ expect(snapshot.skills.find((s) => s.name === "imagegen")?.source)
101
+ .toBe("runtime-global");
102
+ });
103
+
65
104
  it("returns complete snapshots while keeping the prompt soft index capped", () => {
66
105
  const agentId = "ag_manyskills";
67
106
  const workspaceSkills = path.join(agentWorkspaceDir(agentId), ".claude", "skills");
@@ -370,3 +370,124 @@ describe("composeBotCordUserTurn", () => {
370
370
  expect(headerLines.length).toBe(1);
371
371
  });
372
372
  });
373
+
374
+ describe("composeBotCordUserTurn quote-reply", () => {
375
+ it("inserts a [quoting …] line above the body when reply_preview is present", () => {
376
+ const out = composeBotCordUserTurn(
377
+ makeMessage({
378
+ text: "agreed, ship it",
379
+ sender: { id: "ag_alice", name: "Alice", kind: "agent" },
380
+ raw: {
381
+ reply_preview: {
382
+ msg_id: "h_orig",
383
+ sender_id: "ag_bob",
384
+ sender_display_name: "Bob",
385
+ text_preview: "We should ship the feature next sprint",
386
+ topic_id: null,
387
+ deleted: false,
388
+ },
389
+ },
390
+ }),
391
+ );
392
+ expect(out).toContain('<agent-message sender="ag_alice" sender_kind="agent">');
393
+ expect(out).toContain('[quoting Bob: "We should ship the feature next sprint"]');
394
+ expect(out).toContain("agreed, ship it");
395
+ // Quote line precedes body inside the tag block.
396
+ const quoteIdx = out.indexOf("[quoting Bob");
397
+ const bodyIdx = out.indexOf("agreed, ship it");
398
+ expect(quoteIdx).toBeGreaterThan(-1);
399
+ expect(quoteIdx).toBeLessThan(bodyIdx);
400
+ });
401
+
402
+ it("renders a tombstone line when the quote target was deleted", () => {
403
+ const out = composeBotCordUserTurn(
404
+ makeMessage({
405
+ text: "RE: that thing",
406
+ sender: { id: "ag_alice", kind: "agent" },
407
+ raw: {
408
+ reply_preview: {
409
+ msg_id: "h_gone",
410
+ sender_id: null,
411
+ sender_display_name: null,
412
+ text_preview: null,
413
+ topic_id: null,
414
+ deleted: true,
415
+ },
416
+ },
417
+ }),
418
+ );
419
+ expect(out).toContain("[quoting (deleted message)]");
420
+ expect(out).toContain("RE: that thing");
421
+ });
422
+
423
+ it("falls back to sender_id when display name is missing", () => {
424
+ const out = composeBotCordUserTurn(
425
+ makeMessage({
426
+ text: "ack",
427
+ sender: { id: "ag_alice", kind: "agent" },
428
+ raw: {
429
+ reply_preview: {
430
+ msg_id: "h_orig",
431
+ sender_id: "ag_bob",
432
+ sender_display_name: null,
433
+ text_preview: "hi",
434
+ topic_id: null,
435
+ deleted: false,
436
+ },
437
+ },
438
+ }),
439
+ );
440
+ expect(out).toContain('[quoting ag_bob: "hi"]');
441
+ });
442
+
443
+ it("emits no quote line when reply_preview is absent (regression guard)", () => {
444
+ const out = composeBotCordUserTurn(
445
+ makeMessage({
446
+ text: "just a normal message",
447
+ sender: { id: "ag_alice", kind: "agent" },
448
+ }),
449
+ );
450
+ expect(out).not.toContain("[quoting");
451
+ });
452
+
453
+ it("renders per-entry quote lines in a batched turn", () => {
454
+ const batchedRaw = {
455
+ batch: [
456
+ {
457
+ hub_msg_id: "h_1",
458
+ text: "first reply",
459
+ envelope: { from: "ag_alice", type: "message" },
460
+ source_type: "agent",
461
+ reply_preview: {
462
+ msg_id: "h_orig1",
463
+ sender_id: "ag_bob",
464
+ sender_display_name: "Bob",
465
+ text_preview: "the plan",
466
+ topic_id: null,
467
+ deleted: false,
468
+ },
469
+ },
470
+ {
471
+ hub_msg_id: "h_2",
472
+ text: "second reply (no quote)",
473
+ envelope: { from: "ag_alice", type: "message" },
474
+ source_type: "agent",
475
+ },
476
+ ],
477
+ };
478
+ const out = composeBotCordUserTurn(
479
+ makeMessage({
480
+ text: "ignored — batch path reads raw.batch",
481
+ sender: { id: "ag_alice", kind: "agent" },
482
+ raw: batchedRaw,
483
+ }),
484
+ );
485
+ expect(out).toContain("[BotCord Messages (2 new)]");
486
+ expect(out).toContain('[quoting Bob: "the plan"]');
487
+ expect(out).toContain("first reply");
488
+ expect(out).toContain("second reply (no quote)");
489
+ // The second entry has no quote line.
490
+ const quoteCount = (out.match(/\[quoting /g) || []).length;
491
+ expect(quoteCount).toBe(1);
492
+ });
493
+ });
@@ -235,12 +235,15 @@ export async function startCloudDaemon(
235
235
  };
236
236
 
237
237
  const installedAgentIds = new Set<string>();
238
+ const runtimeByAgentId = new Map<string, string>();
238
239
  let controlChannel: ControlChannel | null = null;
239
240
  const pushInstalledAgentSkillSnapshot = (agentId: string, reason: string): void => {
240
241
  if (!controlChannel) return;
241
- const pushed = pushAgentSkillSnapshot(controlChannel, agentId);
242
+ const runtime = runtimeByAgentId.get(agentId) ?? opts.config.defaultRoute.adapter;
243
+ const pushed = pushAgentSkillSnapshot(controlChannel, agentId, { runtime });
242
244
  logger.info("cloud control-channel: agent_skill_snapshot pushed", {
243
245
  agentId,
246
+ runtime,
244
247
  reason,
245
248
  ok: pushed,
246
249
  });
@@ -248,6 +251,7 @@ export async function startCloudDaemon(
248
251
 
249
252
  const onAgentInstalled: OnAgentInstalledHook = (info: InstalledAgentInfo) => {
250
253
  installedAgentIds.add(info.agentId);
254
+ if (info.runtime) runtimeByAgentId.set(info.agentId, info.runtime);
251
255
  credentialPathByAgentId.set(info.agentId, info.credentialsFile);
252
256
  if (info.hubUrl) hubUrlByAgentId.set(info.agentId, info.hubUrl);
253
257
  if (info.displayName) displayNameByAgent.set(info.agentId, info.displayName);
package/src/daemon.ts CHANGED
@@ -249,8 +249,9 @@ export function pushRuntimeSnapshot(
249
249
  export function pushAgentSkillSnapshot(
250
250
  sink: RuntimeSnapshotSink,
251
251
  agentId: string,
252
+ opts: { runtime?: string } = {},
252
253
  ): boolean {
253
- const snap = collectAgentSkillSnapshot(agentId);
254
+ const snap = collectAgentSkillSnapshot(agentId, opts);
254
255
  const ok = sink.send({
255
256
  id: `skill_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
256
257
  type: "agent_skill_snapshot",
@@ -529,6 +530,12 @@ export async function startDaemon(opts: DaemonRuntimeOptions): Promise<DaemonHan
529
530
  // next room-context fetch re-loads the BotCordClient against the new
530
531
  // credential file.
531
532
  credentialPathByAgentId.set(info.agentId, info.credentialsFile);
533
+ if (info.runtime) {
534
+ agentRuntimes[info.agentId] = {
535
+ ...(agentRuntimes[info.agentId] ?? {}),
536
+ runtime: info.runtime,
537
+ };
538
+ }
532
539
  if (info.hubUrl) hubUrlByAgentId.set(info.agentId, info.hubUrl);
533
540
  if (info.displayName) displayNameByAgent.set(info.agentId, info.displayName);
534
541
  if (!scBuilders.has(info.agentId)) {
@@ -670,9 +677,11 @@ export async function startDaemon(opts: DaemonRuntimeOptions): Promise<DaemonHan
670
677
  ok: pushed,
671
678
  });
672
679
  for (const agentId of agentIds) {
673
- const skillsPushed = pushAgentSkillSnapshot(controlChannel, agentId);
680
+ const runtime = agentRuntimes[agentId]?.runtime ?? opts.config.defaultRoute.adapter;
681
+ const skillsPushed = pushAgentSkillSnapshot(controlChannel, agentId, { runtime });
674
682
  logger.info("control-channel: initial agent_skill_snapshot push", {
675
683
  agentId,
684
+ runtime,
676
685
  ok: skillsPushed,
677
686
  });
678
687
  }
@@ -208,6 +208,31 @@ function buildRuntimeRecoveryPrompt(args: {
208
208
  ].join("\n");
209
209
  }
210
210
 
211
+ /**
212
+ * Pick the canonical reply_to value to attach to outbound replies for a given
213
+ * inbound `GatewayInboundMessage`. Priority:
214
+ *
215
+ * 1. `msg.replyTo` — the inbound was itself a reply; preserve the chain so
216
+ * receipts and threaded replies point at the original target.
217
+ * 2. `raw.envelope.msg_id` — the wire-protocol identifier (UUID per a2a/0.1).
218
+ * This is the canonical form the hub stores in `reply_to_msg_id`.
219
+ * 3. `msg.id` — fallback to the hub_msg_id (`h_*`) the BotCord channel
220
+ * stamps on every inbound. The hub accepts this form via
221
+ * `_load_reply_target`'s prefix-based discriminator, but emitting it is
222
+ * lossy because the hub then has to resolve it back to msg_id.
223
+ *
224
+ * Exported for unit testing; production code paths use Dispatcher.providerReplyTo.
225
+ */
226
+ export function pickReplyToTarget(msg: GatewayInboundMessage): string {
227
+ if (msg.replyTo) return msg.replyTo;
228
+ const raw = msg.raw as { envelope?: { msg_id?: unknown } } | null | undefined;
229
+ const envMsgId =
230
+ raw && typeof raw.envelope?.msg_id === "string" && raw.envelope.msg_id
231
+ ? raw.envelope.msg_id
232
+ : null;
233
+ return envMsgId ?? msg.id;
234
+ }
235
+
211
236
  /** Factory signature for building a runtime adapter at turn dispatch time. */
212
237
  export type RuntimeFactory = (
213
238
  runtimeId: string,
@@ -2132,7 +2157,7 @@ export class Dispatcher {
2132
2157
  }
2133
2158
 
2134
2159
  private providerReplyTo(msg: GatewayInboundMessage): string {
2135
- return msg.replyTo ?? msg.id;
2160
+ return pickReplyToTarget(msg);
2136
2161
  }
2137
2162
 
2138
2163
  private emitInbound(turnId: string, msg: GatewayInboundEnvelope["message"]): void {
package/src/provision.ts CHANGED
@@ -89,6 +89,12 @@ interface ListAgentSkillsParams {
89
89
  agentId: string;
90
90
  }
91
91
 
92
+ function runtimeForLoadedAgent(gateway: Gateway, agentId: string): string | undefined {
93
+ return gateway.listManagedRoutes()
94
+ .find((route) => route.match?.accountId === agentId)
95
+ ?.runtime;
96
+ }
97
+
92
98
  /**
93
99
  * Information passed to {@link OnAgentInstalledHook} after a successful
94
100
  * provision. Mirrors the credential fields the daemon's per-agent caches
@@ -510,9 +516,11 @@ export function createProvisioner(opts: ProvisionerOptions): (
510
516
  },
511
517
  };
512
518
  }
513
- const result = collectAgentSkillSnapshot(params.agentId);
519
+ const runtime = runtimeForLoadedAgent(gateway, params.agentId);
520
+ const result = collectAgentSkillSnapshot(params.agentId, { runtime });
514
521
  daemonLog.debug("list_agent_skills", {
515
522
  agentId: params.agentId,
523
+ runtime,
516
524
  count: result.skills.length,
517
525
  });
518
526
  return { ok: true, result };
@@ -39,6 +39,7 @@ export interface AgentSkillSnapshot {
39
39
  export interface SkillIndexOptions {
40
40
  extraDirs?: string[];
41
41
  includeGlobal?: boolean;
42
+ runtime?: string;
42
43
  }
43
44
 
44
45
  export function defaultSkillDirs(
@@ -46,21 +47,26 @@ export function defaultSkillDirs(
46
47
  opts: SkillIndexOptions = {},
47
48
  ): Array<{ dir: string; source: string }> {
48
49
  const includeGlobal = opts.includeGlobal !== false;
49
- const dirs: Array<{ dir: string; source: string }> = [
50
- {
51
- dir: path.join(agentWorkspaceDir(agentId), ".claude", "skills"),
52
- source: "agent-claude",
53
- },
54
- {
55
- dir: path.join(agentCodexHomeDir(agentId), "skills"),
56
- source: "agent-codex",
57
- },
58
- ];
50
+ const agentClaude = {
51
+ dir: path.join(agentWorkspaceDir(agentId), ".claude", "skills"),
52
+ source: "agent-claude",
53
+ };
54
+ const agentCodex = {
55
+ dir: path.join(agentCodexHomeDir(agentId), "skills"),
56
+ source: "agent-codex",
57
+ };
58
+ const dirs: Array<{ dir: string; source: string }> =
59
+ runtimeFamily(opts.runtime) === "codex"
60
+ ? [agentCodex, agentClaude]
61
+ : [agentClaude, agentCodex];
59
62
 
60
63
  if (includeGlobal) {
64
+ const globalClaude = { dir: path.join(homedir(), ".claude", "skills"), source: "global-claude" };
65
+ const globalCodex = { dir: path.join(homedir(), ".codex", "skills"), source: "global-codex" };
61
66
  dirs.push(
62
- { dir: path.join(homedir(), ".claude", "skills"), source: "global-claude" },
63
- { dir: path.join(homedir(), ".codex", "skills"), source: "global-codex" },
67
+ ...(runtimeFamily(opts.runtime) === "codex"
68
+ ? [globalCodex, globalClaude]
69
+ : [globalClaude, globalCodex]),
64
70
  );
65
71
  }
66
72
 
@@ -69,7 +75,7 @@ export function defaultSkillDirs(
69
75
  dirs.push({ dir, source: "external" });
70
76
  }
71
77
 
72
- return dedupeDirs(dirs);
78
+ return dedupeDirs(expandSkillRoots(dirs));
73
79
  }
74
80
 
75
81
  export function scanSoftSkills(
@@ -111,7 +117,7 @@ export function scanSoftSkills(
111
117
  description: parsed.description,
112
118
  mtimeMs: st.mtimeMs,
113
119
  };
114
- if (!existing || priority(root.source) < priority(existing.source)) {
120
+ if (!existing || priority(root.source, opts.runtime) < priority(existing.source, opts.runtime)) {
115
121
  byName.set(entry.name, entry);
116
122
  }
117
123
  }
@@ -224,7 +230,41 @@ function dedupeDirs(
224
230
  return out;
225
231
  }
226
232
 
227
- function priority(source: string): number {
233
+ function expandSkillRoots(
234
+ dirs: Array<{ dir: string; source: string }>,
235
+ ): Array<{ dir: string; source: string }> {
236
+ const out: Array<{ dir: string; source: string }> = [];
237
+ for (const entry of dirs) {
238
+ out.push(entry);
239
+ if (entry.source.includes("codex")) {
240
+ out.push({ dir: path.join(entry.dir, ".system"), source: entry.source });
241
+ }
242
+ }
243
+ return out;
244
+ }
245
+
246
+ function runtimeFamily(runtime: string | undefined): "codex" | "claude" | "other" {
247
+ if (runtime === "codex") return "codex";
248
+ if (runtime === "claude-code") return "claude";
249
+ return "other";
250
+ }
251
+
252
+ function priority(source: string, runtime: string | undefined): number {
253
+ if (runtimeFamily(runtime) === "codex") {
254
+ switch (source) {
255
+ case "agent-codex":
256
+ return 0;
257
+ case "global-codex":
258
+ return 1;
259
+ case "agent-claude":
260
+ return 2;
261
+ case "global-claude":
262
+ return 3;
263
+ default:
264
+ return 4;
265
+ }
266
+ }
267
+
228
268
  switch (source) {
229
269
  case "agent-claude":
230
270
  return 0;
package/src/turn-text.ts CHANGED
@@ -112,6 +112,18 @@ interface BatchedEntry {
112
112
  source_type?: unknown;
113
113
  source_user_name?: unknown;
114
114
  mentioned?: unknown;
115
+ reply_preview?: ReplyPreviewRaw | null;
116
+ }
117
+
118
+ /** Structural mirror of protocol-core ReplyPreview — kept local so the
119
+ * composer has no cross-package import. */
120
+ interface ReplyPreviewRaw {
121
+ msg_id?: unknown;
122
+ sender_id?: unknown;
123
+ sender_display_name?: unknown;
124
+ text_preview?: unknown;
125
+ topic_id?: unknown;
126
+ deleted?: unknown;
115
127
  }
116
128
 
117
129
  interface RoomContextRaw {
@@ -196,6 +208,40 @@ function entryText(e: BatchedEntry): string {
196
208
  return "";
197
209
  }
198
210
 
211
+ /**
212
+ * Format the inline quote-reply context line that prefixes a message body
213
+ * when the inbound envelope replies to another message. Single layer — we
214
+ * never render a quote-of-a-quote chain. Returns `null` when the source
215
+ * carries no reply_preview, so the caller can skip emitting an empty line.
216
+ *
217
+ * Both sender label and preview body are sanitized; the preview is hard-
218
+ * capped at 120 chars to mirror the backend truncation.
219
+ */
220
+ function formatReplyQuoteLine(raw: unknown): string | null {
221
+ if (!raw || typeof raw !== "object") return null;
222
+ const rp = (raw as { reply_preview?: ReplyPreviewRaw | null }).reply_preview;
223
+ if (!rp || typeof rp !== "object") return null;
224
+ if (rp.deleted === true) {
225
+ return "[quoting (deleted message)]";
226
+ }
227
+ const senderRaw =
228
+ typeof rp.sender_display_name === "string" && rp.sender_display_name
229
+ ? rp.sender_display_name
230
+ : typeof rp.sender_id === "string" && rp.sender_id
231
+ ? rp.sender_id
232
+ : "unknown";
233
+ const sender = sanitizeSenderName(senderRaw);
234
+ const previewRaw =
235
+ typeof rp.text_preview === "string" ? rp.text_preview : "";
236
+ const previewClean = sanitizeUntrustedContent(previewRaw)
237
+ .replace(/[\r\n]+/g, " ")
238
+ .slice(0, 120);
239
+ if (!previewClean) {
240
+ return `[quoting ${sender}]`;
241
+ }
242
+ return `[quoting ${sender}: "${previewClean}"]`;
243
+ }
244
+
199
245
  function formatRoomContext(raw: unknown, fallback: { id: string; title?: string }): string[] {
200
246
  const r = raw && typeof raw === "object" ? (raw as RoomContextRaw) : {};
201
247
  const roomId = typeof r.room_id === "string" && r.room_id ? r.room_id : fallback.id;
@@ -293,11 +339,13 @@ export function composeBotCordUserTurn(msg: GatewayInboundMessage): string {
293
339
  "agent ID and any message they attached.]"
294
340
  : null;
295
341
 
342
+ const quoteLine = formatReplyQuoteLine(msg.raw);
296
343
  const lines: string[] = [
297
344
  headerFields.join(" | "),
298
345
  ...formatScheduleContext(msg.raw),
299
346
  ...(isGroup ? formatRoomContext(msg.raw, { id: conversation.id, title: roomTitle }) : []),
300
347
  `<${tag} sender="${sanitizedSenderLabel}" sender_kind="${senderKindAttr}">`,
348
+ ...(quoteLine ? [quoteLine] : []),
301
349
  trimmed,
302
350
  `</${tag}>`,
303
351
  "",
@@ -350,8 +398,10 @@ function composeBatchedTurn(
350
398
  // non-owner. Still sanitize defensively.
351
399
  const safeBody = sanitizeUntrustedContent(raw);
352
400
  const tag = kind === "human" ? "human-message" : "agent-message";
401
+ const quoteLine = formatReplyQuoteLine(entry);
402
+ const inner = quoteLine ? `${quoteLine}\n${safeBody}` : safeBody;
353
403
  blocks.push(
354
- `<${tag} sender="${safeLabel}" sender_kind="${kind}">\n${safeBody}\n</${tag}>`,
404
+ `<${tag} sender="${safeLabel}" sender_kind="${kind}">\n${inner}\n</${tag}>`,
355
405
  );
356
406
  if (envelopeType === "contact_request") {
357
407
  contactRequestSenders.push(safeLabel);