mercury-agent 0.16.0 → 0.16.2

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.
Files changed (80) hide show
  1. package/README.md +6 -0
  2. package/container/Dockerfile +6 -1
  3. package/container/Dockerfile.base +6 -1
  4. package/docs/README.md +50 -0
  5. package/docs/authoring-profiles.md +8 -0
  6. package/docs/behavior-layers.md +193 -0
  7. package/docs/extensions.md +2 -0
  8. package/docs/goals/football-reporter-profile/decisions.md +479 -0
  9. package/docs/goals/football-reporter-profile/goal.md +91 -0
  10. package/docs/goals/football-reporter-profile/roadmap.md +196 -0
  11. package/docs/goals/whatsapp-bot-hardening/README.md +43 -0
  12. package/docs/goals/whatsapp-bot-hardening/archive/ambient-group-context.md +211 -0
  13. package/docs/goals/whatsapp-bot-hardening/archive/command-routing-consistency.md +210 -0
  14. package/docs/goals/whatsapp-bot-hardening/archive/destructive-command-guards.md +218 -0
  15. package/docs/goals/whatsapp-bot-hardening/archive/docs-extension.md +182 -0
  16. package/docs/goals/whatsapp-bot-hardening/archive/handoff-2026-08-09.md +588 -0
  17. package/docs/goals/whatsapp-bot-hardening/archive/media-size-and-silent-drop.md +178 -0
  18. package/docs/goals/whatsapp-bot-hardening/archive/member-memory.md +199 -0
  19. package/docs/goals/whatsapp-bot-hardening/archive/message-author-attribution.md +211 -0
  20. package/docs/goals/whatsapp-bot-hardening/archive/run-from-source-switchover.md +212 -0
  21. package/docs/goals/whatsapp-bot-hardening/archive/setup-plan-2026-08-06.md +1117 -0
  22. package/docs/goals/whatsapp-bot-hardening/decisions.md +200 -0
  23. package/docs/goals/whatsapp-bot-hardening/goal.md +75 -0
  24. package/docs/goals/whatsapp-bot-hardening/open-threads.md +396 -0
  25. package/docs/goals/whatsapp-bot-hardening/roadmap.md +245 -0
  26. package/docs/memory.md +30 -0
  27. package/docs/profile-guide.md +483 -0
  28. package/docs/refactor/.gitkeep +0 -0
  29. package/docs/refactor/archive/.gitkeep +0 -0
  30. package/docs/refactor/audits/.gitkeep +0 -0
  31. package/docs/refactor/backlog/.gitkeep +0 -0
  32. package/docs/skills-guide.md +154 -0
  33. package/examples/extensions/README.md +1 -0
  34. package/examples/extensions/feed-watch/config.ts +354 -0
  35. package/examples/extensions/feed-watch/digest.ts +344 -0
  36. package/examples/extensions/feed-watch/feeds.ts +341 -0
  37. package/examples/extensions/feed-watch/index.ts +302 -0
  38. package/examples/extensions/feed-watch/items.ts +171 -0
  39. package/examples/extensions/feed-watch/match.ts +107 -0
  40. package/examples/extensions/feed-watch/prompts/verify.md +35 -0
  41. package/examples/extensions/feed-watch/skill/SKILL.md +128 -0
  42. package/examples/extensions/feed-watch/watch.ts +548 -0
  43. package/examples/extensions/longview/hook.ts +179 -16
  44. package/examples/extensions/longview/index.ts +13 -2
  45. package/examples/extensions/longview/prompts/summarize.md +3 -2
  46. package/examples/extensions/longview/render/telegraph-nodes.ts +21 -1
  47. package/examples/extensions/longview/summarize.ts +57 -14
  48. package/examples/extensions/napkin/index.ts +334 -86
  49. package/examples/extensions/napkin/pi-spawn.ts +196 -0
  50. package/examples/extensions/pinchtab/index.ts +37 -4
  51. package/examples/extensions/pinchtab/lib/session-injector.ts +31 -9
  52. package/examples/extensions/pinchtab/skill/SKILL.md +29 -0
  53. package/examples/profiles/_template/AGENTS.md +138 -0
  54. package/examples/profiles/_template/README.md +65 -0
  55. package/examples/profiles/_template/config.yaml +57 -0
  56. package/examples/profiles/_template/tasks/daily.md +32 -0
  57. package/examples/profiles/football-reporter/AGENTS.md +219 -0
  58. package/examples/profiles/football-reporter/README.md +138 -0
  59. package/examples/profiles/football-reporter/config.yaml +174 -0
  60. package/examples/profiles/football-reporter/seed/MEMORY.md +42 -0
  61. package/examples/profiles/football-reporter/seed/episodes/barcelona-2026-27.md +25 -0
  62. package/examples/profiles/football-reporter/seed/episodes/beitar-jerusalem-2026-27.md +25 -0
  63. package/examples/profiles/football-reporter/seed/episodes/maccabi-haifa-2026-27.md +24 -0
  64. package/examples/profiles/football-reporter/seed/episodes/man-united-2026-27.md +26 -0
  65. package/examples/profiles/football-reporter/seed/episodes/real-madrid-2026-27.md +25 -0
  66. package/examples/profiles/football-reporter/seed/napkin-distill.md +55 -0
  67. package/examples/profiles/football-reporter/tasks/daily-article.md +142 -0
  68. package/package.json +8 -5
  69. package/src/adapters/whatsapp.ts +75 -13
  70. package/src/bridges/whatsapp.ts +6 -4
  71. package/src/cli/mrctl.ts +9 -1
  72. package/src/core/handler.ts +51 -6
  73. package/src/core/routes/dashboard.ts +106 -5
  74. package/src/core/routes/tasks.ts +28 -0
  75. package/src/core/runtime.ts +63 -2
  76. package/src/core/task-scheduler.ts +103 -12
  77. package/src/extensions/catalog.ts +9 -0
  78. package/src/profile/space-profile.ts +780 -0
  79. package/src/storage/db.ts +215 -0
  80. package/src/types.ts +56 -0
@@ -1657,6 +1657,19 @@ export function createDashboardRoutes(ctx: DashboardContext) {
1657
1657
  const label =
1658
1658
  spacesById.get(t.spaceId)?.name ?? (t.spaceId || "global");
1659
1659
  const swatchClass = spaceSwatchClass(t.spaceId, knownSpaceIds);
1660
+ // One indexed lookup per task, and there are single digits of
1661
+ // them. A latest-per-task join would be cheaper in principle and
1662
+ // harder to read for a table this size.
1663
+ const lastRun = core.db.listTaskRuns(t.id, 1)[0];
1664
+ // `failed` still comes from the snapshot, so the badge means the
1665
+ // same thing it did before the ledger existed. `quiet` is the new
1666
+ // answer — a run that succeeded and deliberately said nothing.
1667
+ const outcomeBadge =
1668
+ t.lastStatus === "error"
1669
+ ? ' <span class="badge">failed</span>'
1670
+ : lastRun?.outcome === "no_update"
1671
+ ? ' <span class="badge muted">quiet</span>'
1672
+ : "";
1660
1673
  return `
1661
1674
  <tr data-space-id="${escapeHtml(t.spaceId || "")}">
1662
1675
  <td class="mono">#${t.id}</td>
@@ -1668,16 +1681,13 @@ export function createDashboardRoutes(ctx: DashboardContext) {
1668
1681
  <td class="muted"${t.lastError ? ` title="${escapeHtml(t.lastError)}"` : ""}>${
1669
1682
  t.lastRunAt === null
1670
1683
  ? "never"
1671
- : `${formatRelativeTime(t.lastRunAt)}${
1672
- t.lastStatus === "error"
1673
- ? ' <span class="badge">failed</span>'
1674
- : ""
1675
- }`
1684
+ : `${formatRelativeTime(t.lastRunAt)}${outcomeBadge}`
1676
1685
  }</td>
1677
1686
  <td><span class="badge ${t.silent === 1 ? "muted" : ""}">${t.silent === 1 ? "silent" : "chat"}</span></td>
1678
1687
  <td><span class="badge ${t.active ? "green" : ""}">${t.active ? "active" : "paused"}</span></td>
1679
1688
  <td class="actions">
1680
1689
  <button class="btn btn-sm" hx-post="/dashboard/api/tasks/${t.id}/run" hx-swap="none" hx-sync="this:drop" hx-disabled-elt="this" title="Run now">▶</button>
1690
+ <button class="btn btn-sm" hx-get="/dashboard/page/tasks/${t.id}/runs" hx-target="#task-runs-drawer" hx-swap="innerHTML" title="Run history">☰</button>
1681
1691
  ${
1682
1692
  t.active
1683
1693
  ? `<button class="btn btn-sm" hx-post="/dashboard/api/tasks/${t.id}/pause" hx-swap="none" title="Pause">⏸</button>`
@@ -1742,6 +1752,97 @@ export function createDashboardRoutes(ctx: DashboardContext) {
1742
1752
  </table>
1743
1753
  </div>
1744
1754
  </div>
1755
+
1756
+ <div id="task-runs-drawer"></div>
1757
+ `);
1758
+ });
1759
+
1760
+ /**
1761
+ * Run history for one task. Served into the drawer under the tasks table
1762
+ * rather than as a page of its own — the question it answers ("what has this
1763
+ * task actually been doing?") is always asked while looking at the row.
1764
+ */
1765
+ app.get("/page/tasks/:id/runs", (c) => {
1766
+ const taskId = Number(c.req.param("id"));
1767
+ if (!Number.isFinite(taskId) || taskId < 1) {
1768
+ return c.html(
1769
+ html`<div class="panel"><p class="empty">Invalid task ID</p></div>`,
1770
+ );
1771
+ }
1772
+ const task = core.db.getTask(taskId);
1773
+ if (!task) {
1774
+ return c.html(
1775
+ html`<div class="panel"><p class="empty">Task not found</p></div>`,
1776
+ );
1777
+ }
1778
+ const runs = core.db.listTaskRuns(taskId, 25);
1779
+
1780
+ const outcomeCell = (run: (typeof runs)[number]): string => {
1781
+ if (run.status === "error") return '<span class="badge">failed</span>';
1782
+ if (run.outcome === "no_update")
1783
+ return '<span class="badge muted">quiet</span>';
1784
+ if (run.outcome === "posted")
1785
+ return '<span class="badge green">posted</span>';
1786
+ return '<span class="muted">—</span>';
1787
+ };
1788
+
1789
+ const rowsHtml =
1790
+ runs.length > 0
1791
+ ? runs
1792
+ .map((run) => {
1793
+ // A clock moved backwards mid-run makes this negative. Say
1794
+ // nothing rather than report a negative duration as fact.
1795
+ const ms = run.finishedAt - run.startedAt;
1796
+ const duration = ms >= 0 ? `${Math.round(ms / 1000)}s` : "—";
1797
+ return `
1798
+ <tr>
1799
+ <td class="muted">${formatRelativeTime(run.startedAt)}</td>
1800
+ <td>${outcomeCell(run)}</td>
1801
+ <td class="mono muted">${run.model ? escapeHtml(run.model) : "—"}</td>
1802
+ <td class="mono">${run.totalTokens === null ? "—" : formatTokenCount(run.totalTokens)}</td>
1803
+ <td class="mono">${run.cost === null ? "—" : formatCost(run.cost)}</td>
1804
+ <td class="mono muted">${duration}</td>
1805
+ <td class="mono muted">${run.replyChars === null ? "—" : run.replyChars}</td>
1806
+ <td class="mono muted">${run.files ? run.files : "—"}</td>
1807
+ <td><span class="badge ${run.manual ? "" : "muted"}">${run.manual ? "manual" : "cron"}</span></td>
1808
+ <td class="mono muted">${run.attempt > 1 ? `#${run.attempt}` : "—"}</td>
1809
+ <td class="truncate muted"${run.error ? ` title="${escapeHtml(run.error)}"` : ""}>${run.error ? escapeHtml(truncate(run.error, 40)) : "—"}</td>
1810
+ </tr>
1811
+ `;
1812
+ })
1813
+ .join("")
1814
+ : `<tr><td colspan="11" class="empty">No runs recorded yet${
1815
+ task.lastRunAt === null
1816
+ ? ""
1817
+ : " — this task last ran before the ledger existed"
1818
+ }</td></tr>`;
1819
+
1820
+ return c.html(html`
1821
+ <div class="panel">
1822
+ <div class="page-header">
1823
+ <h3>Run history — task #${taskId}</h3>
1824
+ </div>
1825
+ <div class="table-scroll">
1826
+ <table class="table">
1827
+ <thead>
1828
+ <tr>
1829
+ <th>When</th>
1830
+ <th>Outcome</th>
1831
+ <th>Model</th>
1832
+ <th>Tokens</th>
1833
+ <th>Cost</th>
1834
+ <th>Took</th>
1835
+ <th>Chars</th>
1836
+ <th>Files</th>
1837
+ <th>Trigger</th>
1838
+ <th>Attempt</th>
1839
+ <th>Error</th>
1840
+ </tr>
1841
+ </thead>
1842
+ <tbody>${raw(rowsHtml)}</tbody>
1843
+ </table>
1844
+ </div>
1845
+ </div>
1745
1846
  `);
1746
1847
  });
1747
1848
 
@@ -180,6 +180,34 @@ tasks.post("/:id/run", (c) => {
180
180
  return c.json({ id: taskId, triggered: true });
181
181
  });
182
182
 
183
+ /**
184
+ * The last N attempts of a task. Read-only, space-scoped like its siblings,
185
+ * and empty rather than 404 for a task that predates the ledger.
186
+ */
187
+ tasks.get("/:id/runs", (c) => {
188
+ const { spaceId } = getAuth(c);
189
+ const denied = checkPerm(c, "tasks.list");
190
+ if (denied) return denied;
191
+
192
+ const { db } = getApiCtx(c);
193
+ const taskId = Number(c.req.param("id"));
194
+ if (!Number.isFinite(taskId) || taskId < 1) {
195
+ return c.json({ error: "Invalid task ID" }, 400);
196
+ }
197
+
198
+ const task = db.getTask(taskId);
199
+ if (!task || task.spaceId !== spaceId) {
200
+ return c.json({ error: "Task not found" }, 404);
201
+ }
202
+
203
+ // A caller asking for nonsense gets the default rather than an error: the
204
+ // number is a page size, not an assertion about the data.
205
+ const rawLimit = Number(c.req.query("limit"));
206
+ const limit = Number.isFinite(rawLimit) && rawLimit > 0 ? rawLimit : 50;
207
+
208
+ return c.json({ id: taskId, runs: db.listTaskRuns(taskId, limit) });
209
+ });
210
+
183
211
  tasks.delete("/:id", (c) => {
184
212
  const { spaceId } = getAuth(c);
185
213
  const denied = checkPerm(c, "tasks.delete");
@@ -22,6 +22,7 @@ import type {
22
22
  MessageAttachment,
23
23
  MessageRunMeta,
24
24
  MessageSender,
25
+ TaskRunDetail,
25
26
  TokenUsage,
26
27
  } from "../types.js";
27
28
  import { formatDroppedAttachmentNote } from "./attachment-notes.js";
@@ -50,7 +51,11 @@ import {
50
51
  resolveLocale,
51
52
  } from "./system-messages.js";
52
53
  import { isNoOpTaskReply } from "./task-output.js";
53
- import { type TaskFailure, TaskScheduler } from "./task-scheduler.js";
54
+ import {
55
+ TaskDeliveryError,
56
+ type TaskFailure,
57
+ TaskScheduler,
58
+ } from "./task-scheduler.js";
54
59
 
55
60
  export type InputSource = "cli" | "scheduler" | "chat-sdk";
56
61
 
@@ -363,12 +368,35 @@ export class MercuryCoreRuntime {
363
368
  const text = noOp ? "" : result.reply;
364
369
  const worthSending = !noOp || result.files.length > 0;
365
370
 
371
+ // What the run *produced*, for the ledger. `worthSending` is the same
372
+ // judgement the send path is about to make, so the recorded outcome
373
+ // can never disagree with what the space was offered.
374
+ //
375
+ // Delivery is not folded in: a silent task that reports something is
376
+ // `posted` here, because "the task found news" and "the group heard
377
+ // about it" are different questions and `silent` already answers the
378
+ // second one.
379
+ const detail: TaskRunDetail = {
380
+ outcome: worthSending ? "posted" : "no_update",
381
+ usage: result.usage,
382
+ replyChars: result.reply?.length ?? 0,
383
+ files: result.files.length,
384
+ };
385
+
366
386
  if (!task.silent && sender && worthSending) {
367
- await sender.send(task.spaceId, text, result.files);
387
+ try {
388
+ await sender.send(task.spaceId, text, result.files);
389
+ } catch (error) {
390
+ // Still a failed run — retry, report, post nothing — but the
391
+ // model work is done and paid for, and the ledger should say so.
392
+ throw new TaskDeliveryError(error, detail);
393
+ }
368
394
  }
369
395
  if (!task.silent && text) {
370
396
  this.deliverTaskOutput(task.spaceId, text);
371
397
  }
398
+
399
+ return detail;
372
400
  },
373
401
  (failure) => this.announceTaskFailure(failure, sender),
374
402
  );
@@ -2015,6 +2043,39 @@ export class MercuryCoreRuntime {
2015
2043
  }
2016
2044
  // ────────────────────────────────────────────────────────────────────
2017
2045
 
2046
+ // ── Scheduler no-op: leave no trace in the space history ─────────────
2047
+ // A monitoring task is silent on most runs by design. The scheduler
2048
+ // already declines to *send* such a reply (`isNoOpTaskReply`, at the
2049
+ // callback in `startScheduler`), but both rows were still written — so a
2050
+ // space polled five times a day accumulated ten messages nobody ever saw,
2051
+ // and the agent read its own prompt back as conversation history on the
2052
+ // next run.
2053
+ //
2054
+ // Scoped three ways on purpose. Only `scheduler` — chat behaviour is
2055
+ // untouched, and a person who types "ok" gets their turn recorded. Only
2056
+ // when there are no files — the scheduler still delivers an attachment
2057
+ // with an empty caption, and a delivered message with no stored row would
2058
+ // be worse than the noise this removes. And the usage is still recorded
2059
+ // below either way: the run cost real tokens whether or not it had
2060
+ // anything to say, and the ledger has to agree with the bill.
2061
+ const schedulerNoOp =
2062
+ source === "scheduler" &&
2063
+ containerResult.files.length === 0 &&
2064
+ isNoOpTaskReply(containerResult.reply);
2065
+
2066
+ if (schedulerNoOp) {
2067
+ this.db.deleteMessage(userMessageId);
2068
+ if (containerResult.usage) {
2069
+ this.db.recordUsage(spaceId, containerResult.usage);
2070
+ }
2071
+ logger.debug("Scheduled no-op turn not persisted", {
2072
+ spaceId,
2073
+ userMessageId,
2074
+ });
2075
+ return containerResult;
2076
+ }
2077
+ // ─────────────────────────────────────────────────────────────────────
2078
+
2018
2079
  const assistantMessageId = this.db.addMessage(
2019
2080
  spaceId,
2020
2081
  "assistant",
@@ -1,15 +1,37 @@
1
1
  import { CronExpressionParser } from "cron-parser";
2
2
  import { logger } from "../logger.js";
3
3
  import { type Db, TASK_LAST_ERROR_MAX_LENGTH } from "../storage/db.js";
4
- import type { ScheduledTask } from "../types.js";
4
+ import type { ScheduledTask, TaskRunDetail, TaskRunStatus } from "../types.js";
5
5
 
6
+ /**
7
+ * A handler may report what the run produced, for the ledger. Returning
8
+ * nothing stays legal and costs only detail: the row is still written, with a
9
+ * null outcome rather than a guessed one.
10
+ */
6
11
  type TaskHandler = (task: {
7
12
  id: number;
8
13
  spaceId: string;
9
14
  prompt: string;
10
15
  createdBy: string;
11
16
  silent: boolean;
12
- }) => Promise<void>;
17
+ }) => Promise<TaskRunDetail | void>;
18
+
19
+ /**
20
+ * A run whose model work completed but whose delivery did not. Thrown by a
21
+ * handler so the scheduler still treats the run as failed — retry, report,
22
+ * post nothing — while the ledger keeps the usage the run already paid for.
23
+ * Without it a delivery failure and a model failure look identical in the
24
+ * run history, and the cost of the former is simply lost.
25
+ */
26
+ export class TaskDeliveryError extends Error {
27
+ readonly detail: TaskRunDetail;
28
+ constructor(cause: unknown, detail: TaskRunDetail) {
29
+ const message = cause instanceof Error ? cause.message : String(cause);
30
+ super(`Delivery failed: ${message}`);
31
+ this.name = "TaskDeliveryError";
32
+ this.detail = detail;
33
+ }
34
+ }
13
35
 
14
36
  /**
15
37
  * A task that has run out of attempts. Reported once per due cycle, never
@@ -127,7 +149,7 @@ export class TaskScheduler {
127
149
  const fresh = this.db.getTask(task.id);
128
150
  if (!fresh?.active || fresh.nextRunAt > Date.now()) continue;
129
151
 
130
- await this.runTask(fresh, true);
152
+ await this.runTask(fresh, true, false);
131
153
  }
132
154
  } catch (error) {
133
155
  logger.error(
@@ -215,6 +237,7 @@ export class TaskScheduler {
215
237
  private async runTask(
216
238
  task: ScheduledTask,
217
239
  applyBookkeeping: boolean,
240
+ manual: boolean,
218
241
  ): Promise<void> {
219
242
  const handler = this.handler;
220
243
  if (!handler) {
@@ -230,16 +253,20 @@ export class TaskScheduler {
230
253
  this.running.add(task.id);
231
254
  try {
232
255
  let failure: string | null = null;
256
+ let detail: TaskRunDetail | undefined;
257
+ const startedAt = Date.now();
233
258
  try {
234
- await handler({
235
- id: task.id,
236
- spaceId: task.spaceId,
237
- prompt: task.prompt,
238
- createdBy: task.createdBy,
239
- silent: task.silent === 1,
240
- });
259
+ detail =
260
+ (await handler({
261
+ id: task.id,
262
+ spaceId: task.spaceId,
263
+ prompt: task.prompt,
264
+ createdBy: task.createdBy,
265
+ silent: task.silent === 1,
266
+ })) ?? undefined;
241
267
  } catch (error) {
242
268
  failure = error instanceof Error ? error.message : String(error);
269
+ if (error instanceof TaskDeliveryError) detail = error.detail;
243
270
  }
244
271
 
245
272
  // A preview run has no schedule to consume, so it must not touch the
@@ -249,7 +276,14 @@ export class TaskScheduler {
249
276
  const attempts = applyBookkeeping
250
277
  ? (this.attempts.get(task.id) ?? 0) + 1
251
278
  : 1;
252
- this.db.recordTaskRun(task.id, failure ? "error" : "ok", failure);
279
+ this.recordRun(task, {
280
+ status: failure ? "error" : "ok",
281
+ failure,
282
+ startedAt,
283
+ detail,
284
+ manual,
285
+ attempt: attempts,
286
+ });
253
287
 
254
288
  if (!failure) {
255
289
  if (applyBookkeeping) {
@@ -296,6 +330,63 @@ export class TaskScheduler {
296
330
  }
297
331
  }
298
332
 
333
+ /**
334
+ * Write the run down — one grep-able log line, then the snapshot and the
335
+ * ledger row (a single `recordTaskRun` call, so the two can never drift).
336
+ *
337
+ * The log comes first on purpose: it is the record that survives a database
338
+ * that is refusing writes, which is exactly when knowing the run happened
339
+ * matters most.
340
+ *
341
+ * Bookkeeping runs *after* the handler, so a throw here would be a second
342
+ * failure charged to a run that may well have succeeded — the shape that
343
+ * turned an unparseable cron into a full agent turn every five seconds
344
+ * (docs/debug/major/2026-08-11-scheduled-task-timeout-silent.md). It is
345
+ * caught and logged, never propagated.
346
+ */
347
+ private recordRun(
348
+ task: ScheduledTask,
349
+ run: {
350
+ status: TaskRunStatus;
351
+ failure: string | null;
352
+ startedAt: number;
353
+ detail?: TaskRunDetail;
354
+ manual: boolean;
355
+ attempt: number;
356
+ },
357
+ ): void {
358
+ const finishedAt = Date.now();
359
+ logger.info("Scheduled run finished", {
360
+ taskId: task.id,
361
+ spaceId: task.spaceId,
362
+ status: run.status,
363
+ outcome: run.status === "error" ? "error" : (run.detail?.outcome ?? null),
364
+ model: run.detail?.usage?.model ?? null,
365
+ durationMs: finishedAt - run.startedAt,
366
+ totalTokens: run.detail?.usage?.totalTokens ?? null,
367
+ cost: run.detail?.usage?.cost ?? null,
368
+ replyChars: run.detail?.replyChars ?? null,
369
+ files: run.detail?.files ?? null,
370
+ manual: run.manual,
371
+ attempt: run.attempt,
372
+ });
373
+ try {
374
+ this.db.recordTaskRun(task.id, run.status, run.failure, finishedAt, {
375
+ spaceId: task.spaceId,
376
+ startedAt: run.startedAt,
377
+ detail: run.detail,
378
+ manual: run.manual,
379
+ attempt: run.attempt,
380
+ });
381
+ } catch (error) {
382
+ logger.error("Failed to record a scheduled run", {
383
+ taskId: task.id,
384
+ spaceId: task.spaceId,
385
+ error: error instanceof Error ? error.message : String(error),
386
+ });
387
+ }
388
+ }
389
+
299
390
  /**
300
391
  * When to attempt a failed task again. Normally now + the retry delay, but
301
392
  * never later than the task's own next occurrence — a 5-minute retry delay
@@ -399,7 +490,7 @@ export class TaskScheduler {
399
490
  // net, so anything runTask throws after the handler (a DB write racing
400
491
  // shutdown, say) would surface as an unhandled rejection and take the
401
492
  // process down. Catch it here instead.
402
- void this.runTask(task, applyBookkeeping).catch((error) => {
493
+ void this.runTask(task, applyBookkeeping, true).catch((error) => {
403
494
  logger.error("Manual task run failed after the handler", {
404
495
  taskId: task.id,
405
496
  spaceId: task.spaceId,
@@ -112,6 +112,15 @@ export const EXTENSION_CATALOG: ExtensionCatalogEntry[] = [
112
112
  ],
113
113
  requiresRestart: true,
114
114
  },
115
+ {
116
+ name: "feed-watch",
117
+ label: "Feed watch",
118
+ description:
119
+ "Poll RSS and Google News on the host, match headlines against a per-space watchlist, and open a short verify run only when something matched. Feeds a daily article prompt everything seen since the last one. No model runs on the host side. Off for every space until enabled on the space page.",
120
+ category: "automation",
121
+ sourceDir: "feed-watch",
122
+ requiresRestart: true,
123
+ },
115
124
  {
116
125
  name: "yahoo-mail",
117
126
  label: "Yahoo Mail",