@agentconnect.md/daemon 1.0.0-rc.100 → 1.0.0-rc.102

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/index.js CHANGED
@@ -7670,7 +7670,8 @@ const CronDefSchema = object({
7670
7670
  schedule: string(),
7671
7671
  target: object({
7672
7672
  platform: literal("slack"),
7673
- channel: string()
7673
+ channel: string(),
7674
+ integrationId: string().optional()
7674
7675
  }).optional(),
7675
7676
  trigger: string(),
7676
7677
  enabled: boolean().default(true),
@@ -12935,7 +12936,8 @@ const DrainDone = object({ released: array(SessionKey) });
12935
12936
  */
12936
12937
  const CronTarget = object({
12937
12938
  platform: _enum(["slack", "telegram"]).default("slack"),
12938
- channel: string()
12939
+ channel: string(),
12940
+ integrationId: string().uuid().optional()
12939
12941
  });
12940
12942
  const CronUpsert = object({
12941
12943
  cronId: string().uuid(),
@@ -12946,6 +12948,36 @@ const CronUpsert = object({
12946
12948
  enabled: boolean().default(true)
12947
12949
  });
12948
12950
  const CronRemove = object({ cronId: string().uuid() });
12951
+ /**
12952
+ * `cron/report` (D→C EVT, fire-and-forget) — one CP-owned cron fired. The
12953
+ * daemon stamps the fire into its local store first (it stays authoritative,
12954
+ * §5.4) and reports it here so the console's `lastRunAt` converges; the CP
12955
+ * upsert is latest-wins, so the daemon re-asserting its stored stamps on
12956
+ * reconnect (fires while the CP was unreachable) is idempotent and can never
12957
+ * regress the value. Hand-authored (no-origin) crons are never reported.
12958
+ *
12959
+ * Two reports per fire, keyed by `(cronId, firedAt)`: the FIRE report (no
12960
+ * `status`) opens the run, and the COMPLETION report (with `status` + outcome
12961
+ * fields) closes it once the dispatched turn ends — feeding the console's
12962
+ * per-schedule run history. A completion without a prior fire report (CP was
12963
+ * down at fire time) still creates the run row.
12964
+ */
12965
+ const CronRunStatus = _enum(["success", "failed"]);
12966
+ const CronReport = object({
12967
+ cronId: string().uuid(),
12968
+ agentId: string().uuid(),
12969
+ firedAt: string().datetime(),
12970
+ status: CronRunStatus.optional(),
12971
+ durationMs: number().int().nonnegative().optional(),
12972
+ sessionId: string().optional(),
12973
+ reason: string().optional()
12974
+ });
12975
+ /**
12976
+ * `cron/run` (C→D REQ → ack) — fire one CP-owned cron NOW (console "Run now").
12977
+ * The daemon accepts (`ok:true`) and runs the fire asynchronously — outcome
12978
+ * arrives as normal `cron/report`s; `ok:false` when it holds no such cron.
12979
+ */
12980
+ const CronRunNow = object({ cronId: string().uuid() });
12949
12981
  //#endregion
12950
12982
  //#region ../protocol/dist/frames/secrets.js
12951
12983
  /**
@@ -13546,6 +13578,8 @@ const FRAME_SCHEMAS = {
13546
13578
  "drain/done": DrainDone,
13547
13579
  "cron/upsert": CronUpsert,
13548
13580
  "cron/remove": CronRemove,
13581
+ "cron/report": CronReport,
13582
+ "cron/run": CronRunNow,
13549
13583
  "integration/upsert": IntegrationUpsert,
13550
13584
  "integration/remove": IntegrationRemove,
13551
13585
  "integration/channels": IntegrationChannels,
@@ -13651,6 +13685,8 @@ discriminatedUnion("type", [
13651
13685
  frame("drain/done", FRAME_SCHEMAS["drain/done"]),
13652
13686
  frame("cron/upsert", FRAME_SCHEMAS["cron/upsert"]),
13653
13687
  frame("cron/remove", FRAME_SCHEMAS["cron/remove"]),
13688
+ frame("cron/report", FRAME_SCHEMAS["cron/report"]),
13689
+ frame("cron/run", FRAME_SCHEMAS["cron/run"]),
13654
13690
  frame("integration/upsert", FRAME_SCHEMAS["integration/upsert"]),
13655
13691
  frame("integration/remove", FRAME_SCHEMAS["integration/remove"]),
13656
13692
  frame("integration/channels", FRAME_SCHEMAS["integration/channels"]),
@@ -24896,7 +24932,7 @@ const SLACK_TOOLS = [
24896
24932
  inputSchema: obj({
24897
24933
  text: {
24898
24934
  type: "string",
24899
- description: "Message body, in Slack mrkdwn."
24935
+ description: "Message body, in standard Markdown (CommonMark/GFM). Use `**bold**`, `~~strikethrough~~`, `# headings`, tables, etc. — Slack renders standard Markdown natively (do NOT use single-char Slack mrkdwn like `*bold*` or `~strike~`)."
24900
24936
  },
24901
24937
  channel: {
24902
24938
  type: "string",
@@ -80973,7 +81009,7 @@ var import_dist = /* @__PURE__ */ __toESM((/* @__PURE__ */ __commonJSMin(((expor
80973
81009
  }
80974
81010
  });
80975
81011
  })))(), 1);
80976
- const MENTION_RE = /<@([A-Z0-9]+)>/g;
81012
+ const MENTION_RE$1 = /<@([A-Z0-9]+)>/g;
80977
81013
  /** Map a Slack file element to a NormalizedMessage Attachment, dropping any
80978
81014
  * without a usable id + fetch URL (e.g. external/tombstoned files). Tolerates a
80979
81015
  * malformed (null / non-object) element off the wire without throwing. */
@@ -80991,7 +81027,7 @@ function toAttachment(f) {
80991
81027
  }
80992
81028
  function normalizeSlackEvent(event, ctx) {
80993
81029
  const text = event.text ?? "";
80994
- const mentionedBots = [...text.matchAll(MENTION_RE)].map((m) => m[1]);
81030
+ const mentionedBots = [...text.matchAll(MENTION_RE$1)].map((m) => m[1]);
80995
81031
  const attachments = (event.files ?? []).map(toAttachment).filter((a) => a !== null);
80996
81032
  return {
80997
81033
  msgId: `slack:${event.channel}:${event.ts}`,
@@ -81098,6 +81134,21 @@ function consolidate(agents) {
81098
81134
  }
81099
81135
  /** Cap on members enriched per `listChannelMembers` call (bounds users.info fan-out). */
81100
81136
  const MEMBER_ENRICH_CAP = 50;
81137
+ /**
81138
+ * Build a chat.postMessage/update payload that renders the body as a Block Kit
81139
+ * `markdown` block — Slack renders standard CommonMark there natively, so the agent's
81140
+ * markdown is sent verbatim (no markdown→mrkdwn conversion). `text` is kept as the
81141
+ * notification/accessibility fallback. Callers pre-chunk to SLACK_MARKDOWN_BLOCK_LIMIT.
81142
+ */
81143
+ function markdownBlock(text) {
81144
+ return {
81145
+ text,
81146
+ blocks: [{
81147
+ type: "markdown",
81148
+ text
81149
+ }]
81150
+ };
81151
+ }
81101
81152
  var SlackConnection = class {
81102
81153
  deps;
81103
81154
  app;
@@ -81170,8 +81221,8 @@ var SlackConnection = class {
81170
81221
  return this.queue.enqueue(async () => {
81171
81222
  return (await this.app.client.chat.postMessage({
81172
81223
  channel,
81173
- text,
81174
- thread_ts: threadTs
81224
+ thread_ts: threadTs,
81225
+ ...markdownBlock(text)
81175
81226
  }))?.ts;
81176
81227
  });
81177
81228
  }
@@ -81183,7 +81234,7 @@ var SlackConnection = class {
81183
81234
  await this.app.client.chat.update({
81184
81235
  channel,
81185
81236
  ts,
81186
- text
81237
+ ...markdownBlock(text)
81187
81238
  });
81188
81239
  } catch (err) {
81189
81240
  this.deps.log?.debug(`slack: chat.update failed (ch=${channel} ts=${ts}): ${err.message}`);
@@ -81356,6 +81407,31 @@ var SlackConnection = class {
81356
81407
  }
81357
81408
  };
81358
81409
  //#endregion
81410
+ //#region src/slack/mentions.ts
81411
+ /**
81412
+ * Slack `<@U…>` user-mention helpers, shared by the name-resolver (which warms the
81413
+ * display-name cache for mentioned users) and the session reader (which rewrites
81414
+ * mentions to `@name` for read-back). Slack encodes a user mention as `<@U123>` or
81415
+ * `<@U123|label>`; the id is a `U…`/`W…` user id (resolvable via users.info even for
81416
+ * a bot's user id, unlike a bot SENDER's `B…` id).
81417
+ */
81418
+ const MENTION_RE = /<@([UW][A-Z0-9]+)(?:\|[^>]*)?>/g;
81419
+ /** Distinct `U…/W…` user ids mentioned in a message body (empty for none/nullish). */
81420
+ function mentionedUserIds(text) {
81421
+ if (!text) return [];
81422
+ const ids = /* @__PURE__ */ new Set();
81423
+ for (const m of text.matchAll(MENTION_RE)) ids.add(m[1]);
81424
+ return [...ids];
81425
+ }
81426
+ /** Rewrite `<@U123>` / `<@U123|x>` to `@<display name>` when the id is known; an
81427
+ * unknown id is left as the raw token so the web renderer's own fallback applies. */
81428
+ function substituteUserMentions(text, names) {
81429
+ return text.replace(MENTION_RE, (raw, id) => {
81430
+ const name = names.get(id);
81431
+ return name ? `@${name}` : raw;
81432
+ });
81433
+ }
81434
+ //#endregion
81359
81435
  //#region src/slack/name-resolver.ts
81360
81436
  /**
81361
81437
  * Resolves Slack ids (channel "C…", user "U…/W…") to human display names off the
@@ -81385,10 +81461,14 @@ var SlackNameResolver = class {
81385
81461
  this.log = log;
81386
81462
  this.now = now;
81387
81463
  }
81388
- /** Kick off (cached) resolution for a message's channel + human sender. */
81464
+ /** Kick off (cached) resolution for a message's channel, human sender, and any
81465
+ * `<@U…>` user mentions in its body — so session read-back can render mentions
81466
+ * as `@name` (not the raw id). Mention ids use the `U…/W…` user id (resolvable
81467
+ * via users.info even for a bot's user id), unlike a bot SENDER's `B…` id. */
81389
81468
  noteMessage(conn, msg) {
81390
81469
  this.resolveChannel(conn, msg.channel);
81391
81470
  if (!msg.sender.isBot && /^[UW]/.test(msg.sender.id)) this.resolveUser(conn, msg.sender.id);
81471
+ for (const id of mentionedUserIds(msg.text)) this.resolveUser(conn, id);
81392
81472
  }
81393
81473
  claim(id) {
81394
81474
  const at = this.nextAttemptAt.get(id);
@@ -81426,61 +81506,22 @@ var SlackNameResolver = class {
81426
81506
  //#endregion
81427
81507
  //#region src/slack/formatter.ts
81428
81508
  /**
81429
- * Last-mile Slack formatting (§9.1 / §9.3). Two pure helpers:
81430
- * - markdownToMrkdwn: convert the agent's CommonMark-ish output to Slack mrkdwn.
81431
- * - splitIntoSections: break a message into <= maxLen sections (Slack ~3000/section).
81432
- * Both are platform-specific; the upstream convergence (ACP->IR) stays platform-agnostic.
81433
- */
81434
- /** Slack section soft cap (§9.3: Slack splits at ~3000 chars per section). */
81435
- const SLACK_SECTION_LIMIT = 3e3;
81436
- const CODE_SENTINEL = "\0";
81437
- const BOLD = "";
81438
- /** Replace fenced ```code``` and inline `code` with placeholders so inline
81439
- * formatting rules never fire inside them; returns the masked text + restorer. */
81440
- function protectCode(input) {
81441
- const spans = [];
81442
- const masked = input.replace(/```[\s\S]*?```|`[^`\n]*`/g, (m) => {
81443
- return `${CODE_SENTINEL}${spans.push(m) - 1}${CODE_SENTINEL}`;
81444
- });
81445
- const restore = (s) => s.replace(new RegExp(`${CODE_SENTINEL}(\\d+)${CODE_SENTINEL}`, "g"), (_, i) => spans[Number(i)] ?? "");
81446
- return {
81447
- masked,
81448
- restore
81449
- };
81450
- }
81451
- /**
81452
- * Convert common Markdown to Slack mrkdwn. Handled: bold (`**`/`__` -> `*`),
81453
- * italic (`*` -> `_`; bare `_` is already valid mrkdwn italic and left alone to
81454
- * avoid mangling snake_case), strikethrough (`~~` -> `~`), links (`[t](u)` ->
81455
- * `<u|t>`), ATX headings (`# H` -> `*H*`), and `-`/`*`/`+` bullets -> `•`. Code
81456
- * spans and fences are preserved verbatim.
81509
+ * Last-mile Slack formatting (§9.1 / §9.3). One pure helper:
81510
+ * - splitIntoSections: break a message into <= maxLen sections.
81457
81511
  *
81458
- * Best-effort, not a full CommonMark parser: pathological overlapping emphasis
81459
- * (e.g. `***a** b*`, or a lone `*` inside `**bold**`) can still render imperfectly.
81460
- * Such inputs are rare in agent output and only affect cosmetics — never content.
81512
+ * The daemon posts the agent's reply as a Block Kit `markdown` block (which renders
81513
+ * standard CommonMark natively), so NO markdown→mrkdwn conversion happens here — the
81514
+ * agent's text is sent verbatim. We only chunk it: a single `markdown` block caps at
81515
+ * 12000 chars, so a long body is split across blocks/messages on line boundaries.
81461
81516
  */
81462
- function markdownToMrkdwn(input) {
81463
- if (!input) return input;
81464
- const { masked, restore } = protectCode(input);
81465
- let out = masked;
81466
- out = out.replace(/^[ \t]*#{1,6}[ \t]+(.+?)[ \t]*#*$/gm, (_m, h) => `${BOLD}${h.replace(/\*+|__/g, "")}${BOLD}`);
81467
- out = out.replace(/^([ \t]*)[-*+][ \t]+/gm, "$1• ");
81468
- out = out.replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, "<$2|$1>");
81469
- out = out.replace(/\*\*\*(?!\s)([^\n]+?)(?<!\s)\*\*\*/g, `${BOLD}_$1_${BOLD}`);
81470
- out = out.replace(/___(?!\s)([^\n]+?)(?<!\s)___/g, `${BOLD}_$1_${BOLD}`);
81471
- out = out.replace(/\*\*(?!\s)([^\n]+?)(?<!\s)\*\*/g, `${BOLD}$1${BOLD}`);
81472
- out = out.replace(/__(?!\s)([^\n]+?)(?<!\s)__/g, `${BOLD}$1${BOLD}`);
81473
- out = out.replace(/\*(?!\s)([^*\n]+?)(?<!\s)\*/g, "_$1_");
81474
- out = out.split(BOLD).join("*");
81475
- out = out.replace(/~~([^\n]+?)~~/g, "~$1~");
81476
- return restore(out);
81477
- }
81517
+ /** Slack `markdown` block hard cap (12000 chars per block). */
81518
+ const SLACK_MARKDOWN_BLOCK_LIMIT = 12e3;
81478
81519
  /**
81479
81520
  * Split text into sections no longer than maxLen, preferring line boundaries;
81480
81521
  * a single line longer than maxLen is hard-cut. Whitespace-only input yields no
81481
81522
  * sections. Content is never lost across sections.
81482
81523
  */
81483
- function splitIntoSections(text, maxLen = SLACK_SECTION_LIMIT) {
81524
+ function splitIntoSections(text, maxLen = SLACK_MARKDOWN_BLOCK_LIMIT) {
81484
81525
  if (!text.trim()) return [];
81485
81526
  if (text.length <= maxLen) return [text];
81486
81527
  const sections = [];
@@ -81520,9 +81561,10 @@ function planIcon(status) {
81520
81561
  if (status === "in_progress") return ":hourglass_flowing_sand:";
81521
81562
  return ":white_large_square:";
81522
81563
  }
81523
- /** Render an ACP plan (full entry list, resent on every update) as a compact summary. */
81564
+ /** Render an ACP plan (full entry list, resent on every update) as a compact summary.
81565
+ * Posted as a `markdown` block, so emphasis is CommonMark (`**bold**`), not mrkdwn. */
81524
81566
  function renderPlan(entries) {
81525
- return [":clipboard: *Plan*", ...entries.map((e) => `${planIcon(e.status)} ${clampLabel(e.content ?? "")}`)].join("\n");
81567
+ return [":clipboard: **Plan**", ...entries.map((e) => `${planIcon(e.status)} ${clampLabel(e.content ?? "")}`)].join("\n");
81526
81568
  }
81527
81569
  var OutputConverger = class {
81528
81570
  mode;
@@ -81537,8 +81579,9 @@ var OutputConverger = class {
81537
81579
  hasBuffered() {
81538
81580
  return this.buf.trim().length > 0;
81539
81581
  }
81540
- /** Flush the buffered body: markdown→mrkdwn, then split into ≤3000-char sections,
81541
- * one `post` per section (§9.1/§9.3). Public so the daemon's idle timer can call it. */
81582
+ /** Flush the buffered body: split the agent's verbatim markdown into ≤block-limit
81583
+ * sections, one `post` per section (each posted as a Block Kit `markdown` block, so
81584
+ * no mrkdwn conversion). Public so the daemon's idle timer can call it. */
81542
81585
  flushBuffered() {
81543
81586
  return this.flush();
81544
81587
  }
@@ -81546,7 +81589,7 @@ var OutputConverger = class {
81546
81589
  const text = this.buf;
81547
81590
  this.buf = "";
81548
81591
  if (!text.trim()) return [];
81549
- return splitIntoSections(markdownToMrkdwn(text)).map((t) => ({
81592
+ return splitIntoSections(text).map((t) => ({
81550
81593
  kind: "post",
81551
81594
  text: t
81552
81595
  }));
@@ -81637,7 +81680,7 @@ var OutputConverger = class {
81637
81680
  if (this.mode === "low") return [...this.flush(), clear];
81638
81681
  const footer = link ? [{
81639
81682
  kind: "notice",
81640
- text: `:white_check_mark: done — <${link}|details>`
81683
+ text: `:white_check_mark: done — [details](${link})`
81641
81684
  }] : [];
81642
81685
  return [
81643
81686
  ...this.flush(),
@@ -83001,6 +83044,17 @@ var CpClient = class {
83001
83044
  if (this.state !== "READY" && this.state !== "DRAINING") return;
83002
83045
  this.transport?.send(encode(buildEnvelope("integration/channels", snapshot)));
83003
83046
  }
83047
+ /**
83048
+ * Report a CP-owned cron fire (D→C `cron/report` EVT, fire-and-forget) so the
83049
+ * console's `lastRunAt` converges. The local D11 stamp stays authoritative and
83050
+ * the CP upsert is latest-wins, so re-asserting stored stamps on each
83051
+ * (re)connect (see onReady in daemon.ts) makes a dropped report only a delay,
83052
+ * never a loss. No-op unless READY/DRAINING.
83053
+ */
83054
+ emitCronReport(report) {
83055
+ if (this.state !== "READY" && this.state !== "DRAINING") return;
83056
+ this.transport?.send(encode(buildEnvelope("cron/report", report)));
83057
+ }
83004
83058
  request(type, payload) {
83005
83059
  const frame = buildEnvelope(type, payload);
83006
83060
  return this.correlator.request(frame, (e) => this.transport.send(e));
@@ -83087,6 +83141,9 @@ var CpClient = class {
83087
83141
  this.deps.configApply.removeCron(frame.payload.cronId);
83088
83142
  this.reply(frame, "ack", { ok: true });
83089
83143
  return;
83144
+ case "cron/run":
83145
+ this.reply(frame, "ack", this.deps.configApply.runCron(frame.payload.cronId));
83146
+ return;
83090
83147
  case "route/assign": {
83091
83148
  const a = frame.payload;
83092
83149
  this.deps.configApply.applyRouteAssign(a);
@@ -83293,11 +83350,16 @@ function createSessionReader(store) {
83293
83350
  return {
83294
83351
  list(req) {
83295
83352
  const rows = store.listSessions(req.agentId);
83296
- const names = store.getDisplayNames(rows.flatMap((r) => [r.channel, ...r.triggeredBy ? [r.triggeredBy] : []]));
83353
+ const names = store.getDisplayNames(rows.flatMap((r) => [
83354
+ r.channel,
83355
+ ...r.triggeredBy ? [r.triggeredBy] : [],
83356
+ ...mentionedUserIds(r.title)
83357
+ ]));
83297
83358
  return { sessions: rows.map((r) => {
83298
83359
  const usage = parseUsage(r.usage);
83299
83360
  const channelName = names.get(r.channel);
83300
83361
  const triggeredByName = r.triggeredBy ? names.get(r.triggeredBy) : void 0;
83362
+ const title = r.title ? substituteUserMentions(r.title, names) : void 0;
83301
83363
  return {
83302
83364
  sessionId: r.acpSessionId,
83303
83365
  sessionKey: {
@@ -83309,7 +83371,7 @@ function createSessionReader(store) {
83309
83371
  status: r.state,
83310
83372
  lastActivityAt: new Date(r.updatedAt).toISOString(),
83311
83373
  ...usage ? { usage } : {},
83312
- ...r.title ? { title: r.title } : {},
83374
+ ...title ? { title } : {},
83313
83375
  ...r.triggeredBy ? { triggeredBy: r.triggeredBy } : {},
83314
83376
  ...channelName ? { channelName } : {},
83315
83377
  ...triggeredByName ? { triggeredByName } : {}
@@ -83325,7 +83387,7 @@ function createSessionReader(store) {
83325
83387
  const before = req.cursor != null && Number.isFinite(Number(req.cursor)) ? Number(req.cursor) : null;
83326
83388
  const { rows, hasMore } = store.transcriptPage(rec.channel, rec.thread, before, req.limit);
83327
83389
  const ordered = rows.slice().reverse();
83328
- const names = store.getDisplayNames(ordered.map((r) => r.sender));
83390
+ const names = store.getDisplayNames(ordered.flatMap((r) => [r.sender, ...mentionedUserIds(r.text)]));
83329
83391
  const built = ordered.map((r) => {
83330
83392
  const senderName = names.get(r.sender);
83331
83393
  const base = {
@@ -83334,7 +83396,7 @@ function createSessionReader(store) {
83334
83396
  ...senderName ? { senderName } : {},
83335
83397
  ts: r.ts,
83336
83398
  kind: r.kind,
83337
- text: r.text
83399
+ text: substituteUserMentions(r.text, names)
83338
83400
  };
83339
83401
  if (r.kind !== "tool" || !r.body) return base;
83340
83402
  let full;
@@ -83707,7 +83769,8 @@ function toCronDef(cron) {
83707
83769
  schedule: cron.schedule,
83708
83770
  ...cron.target && cron.target.platform === "slack" ? { target: {
83709
83771
  platform: "slack",
83710
- channel: cron.target.channel
83772
+ channel: cron.target.channel,
83773
+ ...cron.target.integrationId ? { integrationId: cron.target.integrationId } : {}
83711
83774
  } } : {},
83712
83775
  trigger: cron.trigger,
83713
83776
  enabled: cron.enabled !== false,
@@ -84651,7 +84714,7 @@ var Daemon = class Daemon {
84651
84714
  async dispatch(agentId, msg, integrationId) {
84652
84715
  if (this.draining || this.drainingAgents.has(agentId)) {
84653
84716
  this.log.debug(`dispatch: skipped for agent "${agentId}" (draining)`);
84654
- return;
84717
+ return null;
84655
84718
  }
84656
84719
  const conv = new OutputConverger(this.agents.get(agentId).output.mode);
84657
84720
  const rec = new TranscriptRecorder();
@@ -84723,6 +84786,7 @@ var Daemon = class Daemon {
84723
84786
  p.resolveDone();
84724
84787
  }
84725
84788
  this.flushQueued(agentId, sessionId, integrationId);
84789
+ return sessionId;
84726
84790
  }
84727
84791
  /** After `!stop` sends session/cancel, give the agent `cancelBackstopMs` to yield.
84728
84792
  * If the turn is still in flight when the timer fires, the agent ignored the
@@ -85074,6 +85138,7 @@ var Daemon = class Daemon {
85074
85138
  applyIntegrationRemove: (integrationId) => this.cpIntegrations?.remove(integrationId),
85075
85139
  upsertCron: (cron) => this.cpCrons.upsert(cron),
85076
85140
  removeCron: (cronId) => this.cpCrons.remove(cronId),
85141
+ runCron: (cronId) => this.runCronNow(cronId),
85077
85142
  applyRouteAssign: (a) => this.cpRouting?.upsertAssign(a),
85078
85143
  applyRouteUpdate: (u) => this.cpRouting?.applyUpdate(u),
85079
85144
  applyAgentLaunch: async (launch) => {
@@ -85107,9 +85172,15 @@ var Daemon = class Daemon {
85107
85172
  * additionally suppresses all platform output in dispatch.
85108
85173
  */
85109
85174
  async onCronFire(agentId, msg, cron) {
85110
- this.store.setCronLastRun(`${agentId}:${cron.id}`, this.clock.now());
85175
+ const firedAt = this.clock.now();
85176
+ this.store.setCronLastRun(`${agentId}:${cron.id}`, firedAt);
85177
+ if (cron.origin === "cp") this.cpClient?.emitCronReport({
85178
+ cronId: cron.id,
85179
+ agentId,
85180
+ firedAt: new Date(firedAt).toISOString()
85181
+ });
85111
85182
  if (cron.target?.channel) {
85112
- const conn = this.replyConnFor(agentId);
85183
+ const conn = this.replyConnFor(agentId, cron.target.integrationId);
85113
85184
  if (!conn) this.log.warn(`cron "${cron.id}": agent "${agentId}" has no live slack connection — running without output`);
85114
85185
  else try {
85115
85186
  const ts = await conn.postMessage(cron.target.channel, `⏰ ${cron.trigger}`);
@@ -85121,7 +85192,43 @@ var Daemon = class Daemon {
85121
85192
  this.log.warn(`cron "${cron.id}": failed to post trigger to ${cron.target.channel} (${formatErr(err)}) — running without anchor`);
85122
85193
  }
85123
85194
  }
85124
- await this.dispatch(agentId, msg);
85195
+ try {
85196
+ const sessionId = await this.dispatch(agentId, msg, cron.target?.integrationId);
85197
+ if (cron.origin === "cp" && sessionId !== null) this.cpClient?.emitCronReport({
85198
+ cronId: cron.id,
85199
+ agentId,
85200
+ firedAt: new Date(firedAt).toISOString(),
85201
+ status: "success",
85202
+ durationMs: Math.max(0, this.clock.now() - firedAt),
85203
+ sessionId
85204
+ });
85205
+ } catch (err) {
85206
+ if (cron.origin === "cp") this.cpClient?.emitCronReport({
85207
+ cronId: cron.id,
85208
+ agentId,
85209
+ firedAt: new Date(firedAt).toISOString(),
85210
+ status: "failed",
85211
+ durationMs: Math.max(0, this.clock.now() - firedAt),
85212
+ reason: err.message?.slice(0, 300) || "dispatch failed"
85213
+ });
85214
+ throw err;
85215
+ }
85216
+ }
85217
+ /** Console "Run now" (`cron/run` REQ): fire one CP cron immediately. The fire
85218
+ * runs asynchronously — the ack only says whether the cron exists here; the
85219
+ * outcome arrives as normal `cron/report`s. */
85220
+ runCronNow(cronId) {
85221
+ for (const a of this.effectiveAgents()) {
85222
+ const cron = a.crons.find((c) => c.id === cronId && c.origin === "cp");
85223
+ if (!cron) continue;
85224
+ const { msg } = buildSyntheticMessage(a.id, cron, randomUUID());
85225
+ this.onCronFire(a.id, msg, cron).catch((err) => this.log.error(`cron/run dispatch failed for agent "${a.id}": ${formatErr(err)}`));
85226
+ return { ok: true };
85227
+ }
85228
+ return {
85229
+ ok: false,
85230
+ reason: "unknown cron"
85231
+ };
85125
85232
  }
85126
85233
  /** Real CPU utilization (0..1) across all cores since the previous call — the busy
85127
85234
  * fraction of the time delta. Called once per heartbeat, so it reflects load over the
@@ -85168,6 +85275,15 @@ var Daemon = class Daemon {
85168
85275
  integrationId,
85169
85276
  channels
85170
85277
  });
85278
+ for (const a of this.effectiveAgents()) for (const c of a.crons) {
85279
+ if (c.origin !== "cp") continue;
85280
+ const at = this.store.getCronLastRun(`${a.id}:${c.id}`);
85281
+ if (at !== void 0) this.cpClient?.emitCronReport({
85282
+ cronId: c.id,
85283
+ agentId: a.id,
85284
+ firedAt: new Date(at).toISOString()
85285
+ });
85286
+ }
85171
85287
  },
85172
85288
  localState: () => ({
85173
85289
  assignments: [],