talon-agent 3.33.4 → 3.34.0

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 (74) hide show
  1. package/package.json +2 -1
  2. package/src/app.ts +16 -8
  3. package/src/backend/claude-sdk/stream.ts +76 -53
  4. package/src/backend/remote-server/turn.ts +58 -55
  5. package/src/bootstrap.ts +120 -79
  6. package/src/cli/setup.ts +375 -349
  7. package/src/core/background/cron-spec.ts +273 -0
  8. package/src/core/background/heartbeat/agent.ts +167 -104
  9. package/src/core/engine/backend-controller/pool.ts +2 -0
  10. package/src/core/engine/backend-controller/state.ts +25 -12
  11. package/src/core/engine/gateway-actions/cron.ts +28 -292
  12. package/src/core/engine/gateway-routes.ts +239 -0
  13. package/src/core/engine/gateway.ts +66 -238
  14. package/src/core/vfs/mounts/files.ts +128 -115
  15. package/src/core/weaver/shuttle.ts +29 -2
  16. package/src/core/weaver/weaver.ts +37 -6
  17. package/src/frontend/discord/callbacks/components/agent-buttons.ts +82 -0
  18. package/src/frontend/discord/callbacks/components/backend-select.ts +149 -0
  19. package/src/frontend/discord/callbacks/components/effort.ts +72 -0
  20. package/src/frontend/discord/callbacks/components/index.ts +120 -0
  21. package/src/frontend/discord/callbacks/components/metrics.ts +24 -0
  22. package/src/frontend/discord/callbacks/components/model-nav.ts +93 -0
  23. package/src/frontend/discord/callbacks/components/model-select.ts +118 -0
  24. package/src/frontend/discord/callbacks/components/model.ts +33 -0
  25. package/src/frontend/discord/callbacks/components/pulse.ts +93 -0
  26. package/src/frontend/discord/callbacks/components/settings.ts +243 -0
  27. package/src/frontend/discord/callbacks/components/types.ts +34 -0
  28. package/src/frontend/discord/callbacks/index.ts +4 -4
  29. package/src/frontend/discord/connection.ts +36 -0
  30. package/src/frontend/discord/diagnostics.ts +62 -0
  31. package/src/frontend/discord/guild-policy.ts +89 -0
  32. package/src/frontend/discord/index.ts +44 -305
  33. package/src/frontend/discord/outbound.ts +61 -0
  34. package/src/frontend/discord/ready.ts +73 -0
  35. package/src/frontend/discord/runtime.ts +55 -0
  36. package/src/frontend/native/chat-lifecycle.ts +39 -0
  37. package/src/frontend/native/chat-wire.ts +69 -0
  38. package/src/frontend/native/context.ts +106 -0
  39. package/src/frontend/native/control.ts +78 -0
  40. package/src/frontend/native/emit.ts +167 -0
  41. package/src/frontend/native/empty-chat-sweep.ts +50 -0
  42. package/src/frontend/native/handlers.ts +121 -0
  43. package/src/frontend/native/history.ts +101 -0
  44. package/src/frontend/native/index.ts +90 -1293
  45. package/src/frontend/native/media.ts +43 -0
  46. package/src/frontend/native/models.ts +221 -0
  47. package/src/frontend/native/queue.ts +47 -0
  48. package/src/frontend/native/reset.ts +50 -0
  49. package/src/frontend/native/routes/chats.ts +115 -0
  50. package/src/frontend/native/routes/daemon.ts +70 -0
  51. package/src/frontend/native/routes/host.ts +151 -0
  52. package/src/frontend/native/routes/index.ts +22 -0
  53. package/src/frontend/native/routes/mesh.ts +72 -0
  54. package/src/frontend/native/routes/models.ts +54 -0
  55. package/src/frontend/native/routes/params.ts +29 -0
  56. package/src/frontend/native/routes/pre-auth.ts +94 -0
  57. package/src/frontend/native/routes/table.ts +92 -0
  58. package/src/frontend/native/runtime.ts +109 -0
  59. package/src/frontend/native/server.ts +30 -555
  60. package/src/frontend/native/status.ts +26 -0
  61. package/src/frontend/native/tool-result.ts +48 -0
  62. package/src/frontend/native/turn.ts +341 -0
  63. package/src/frontend/whatsapp/access.ts +67 -0
  64. package/src/frontend/whatsapp/connection.ts +280 -0
  65. package/src/frontend/whatsapp/inbound.ts +327 -0
  66. package/src/frontend/whatsapp/index.ts +28 -599
  67. package/src/frontend/whatsapp/runtime.ts +74 -0
  68. package/src/storage/metrics.ts +18 -0
  69. package/src/storage/session-record.ts +29 -0
  70. package/src/storage/sessions.ts +24 -0
  71. package/src/util/boot-timer.ts +31 -0
  72. package/src/util/concurrency.ts +28 -0
  73. package/src/frontend/discord/callbacks/components.ts +0 -793
  74. package/src/frontend/discord/callbacks/shared.ts +0 -22
@@ -0,0 +1,273 @@
1
+ /**
2
+ * The schedule half of a cron job — cadence, lifecycle bounds, run cap,
3
+ * catch-up policy, and the query overrides — parsed and validated once for
4
+ * both `create_cron_job` and `edit_cron_job`.
5
+ *
6
+ * The two actions read the same body fields but with different absence
7
+ * semantics: on create an absent field takes its default, on edit an absent
8
+ * field is left alone and a blank one (`null` / `""`) clears the stored
9
+ * value. `parseCronSpec` folds both into one pass: pass `existing` to parse
10
+ * as an edit. Cross-field rules (end after start, provider needs model,
11
+ * overrides only on query jobs) are checked against the *effective* job —
12
+ * the merge of what's stored and what's changing — so a partial edit is
13
+ * judged on the job it produces, not the fields it touches.
14
+ */
15
+
16
+ import {
17
+ validateCronExpression,
18
+ type CatchupPolicy,
19
+ type CronJob,
20
+ type CronJobType,
21
+ } from "../../storage/cron-store.js";
22
+
23
+ /** The scheduler ticks once a minute, so sub-minute intervals are meaningless. */
24
+ const MIN_INTERVAL_SECONDS = 60;
25
+ const MAX_CONTENT_LENGTH = 10_000;
26
+ const CATCHUP_POLICIES = new Set<CatchupPolicy>(["skip", "once", "all"]);
27
+
28
+ /** The job fields the spec covers; everything else is identity or telemetry. */
29
+ type CronSpec = Pick<CronJob, "name" | "type" | "content" | "catchup"> &
30
+ Partial<
31
+ Pick<
32
+ CronJob,
33
+ | "enabled"
34
+ | "timezone"
35
+ | "schedule"
36
+ | "everyMs"
37
+ | "startAt"
38
+ | "endAt"
39
+ | "maxRuns"
40
+ | "model"
41
+ | "provider"
42
+ | "instructions"
43
+ >
44
+ >;
45
+
46
+ /**
47
+ * The fields an edit touches, in body order. A key present with value
48
+ * `undefined` clears that field (`updateCronJob` has Object.assign
49
+ * semantics), so key *presence* is the signal — not truthiness.
50
+ */
51
+ type CronSpecUpdates = Partial<CronSpec>;
52
+
53
+ export type ParsedCronSpec =
54
+ | {
55
+ ok: true;
56
+ /** What to write: the full spec on create, the touched fields on edit. */
57
+ updates: CronSpecUpdates;
58
+ /** The job as it will be after the write — what the cross-field rules saw. */
59
+ effective: CronSpec;
60
+ }
61
+ | { ok: false; error: string };
62
+
63
+ /** True for a body field that was actually supplied (not absent/blank). */
64
+ function provided(v: unknown): boolean {
65
+ return v !== undefined && v !== null && v !== "";
66
+ }
67
+
68
+ /**
69
+ * Parse an instant given as an ISO-8601 string or epoch-ms number into epoch
70
+ * ms. Returns undefined when the field is absent or unparseable — callers
71
+ * distinguish the two via `provided()`.
72
+ */
73
+ function parseInstant(v: unknown): number | undefined {
74
+ if (!provided(v)) return undefined;
75
+ if (typeof v === "number") return Number.isFinite(v) ? v : undefined;
76
+ const s = String(v).trim();
77
+ if (/^\d+$/.test(s)) return Number(s);
78
+ const d = Date.parse(s);
79
+ return Number.isFinite(d) ? d : undefined;
80
+ }
81
+
82
+ /** What one parse pass works on; each section parser fills `updates`. */
83
+ type Parse = {
84
+ body: Record<string, unknown>;
85
+ existing: CronJob | undefined;
86
+ editing: boolean;
87
+ /** On create a blank is absent; on edit it is a request to clear. */
88
+ touched: (key: string) => boolean;
89
+ updates: CronSpecUpdates;
90
+ };
91
+
92
+ /** A section parser returns an error message, or null when its fields are fine. */
93
+ type Section = (p: Parse) => string | null;
94
+
95
+ /**
96
+ * The job the write produces. On create `updates` is the whole spec (name,
97
+ * type, content and catchup are always filled in), so the cast only papers
98
+ * over what the parse order already guarantees.
99
+ */
100
+ const merge = (p: Parse) => ({ ...p.existing, ...p.updates }) as CronSpec;
101
+
102
+ const parsePayload: Section = ({ body, editing, touched, updates }) => {
103
+ if (editing) {
104
+ if (touched("name")) updates.name = String(body.name);
105
+ if (touched("content")) updates.content = String(body.content);
106
+ if (touched("enabled")) updates.enabled = Boolean(body.enabled);
107
+ if (touched("type")) updates.type = String(body.type) as CronJobType;
108
+ } else {
109
+ updates.name = String(body.name ?? "Unnamed job");
110
+ updates.type = (body.type as CronJobType) ?? "message";
111
+ updates.content = String(body.content ?? "");
112
+ if (!updates.content) return "Missing content";
113
+ if (updates.content.length > MAX_CONTENT_LENGTH)
114
+ return "Content too long (max 10,000 chars)";
115
+ }
116
+ if (touched("timezone"))
117
+ updates.timezone = body.timezone ? String(body.timezone) : undefined;
118
+ return null;
119
+ };
120
+
121
+ /**
122
+ * Exactly one of `schedule` (cron expression) or `every_seconds` (fixed
123
+ * interval). On edit, setting one switches mode and clears the other.
124
+ */
125
+ const parseCadence: Section = ({
126
+ body,
127
+ existing,
128
+ editing,
129
+ touched,
130
+ updates,
131
+ }) => {
132
+ const schedule = provided(body.schedule) ? String(body.schedule) : undefined;
133
+ const hasEvery = provided(body.every_seconds);
134
+ if (!editing && !schedule && !hasEvery)
135
+ return "Provide either 'schedule' (a cron expression) or 'every_seconds' (a fixed interval).";
136
+ if (schedule && hasEvery)
137
+ return "Provide only one of 'schedule' or 'every_seconds', not both.";
138
+ if (schedule) {
139
+ const timezone = touched("timezone")
140
+ ? updates.timezone
141
+ : existing?.timezone;
142
+ const validation = validateCronExpression(schedule, timezone);
143
+ if (!validation.valid)
144
+ return `Invalid cron expression: ${validation.error}`;
145
+ updates.schedule = schedule;
146
+ if (editing) updates.everyMs = undefined;
147
+ }
148
+ if (hasEvery) {
149
+ const everySeconds = Number(body.every_seconds);
150
+ if (!Number.isFinite(everySeconds) || everySeconds < MIN_INTERVAL_SECONDS)
151
+ return `'every_seconds' must be a number >= ${MIN_INTERVAL_SECONDS}${
152
+ editing ? "" : " (the scheduler ticks once a minute)"
153
+ }.`;
154
+ updates.everyMs = Math.round(everySeconds * 1000);
155
+ if (editing) updates.schedule = undefined;
156
+ }
157
+ return null;
158
+ };
159
+
160
+ /** Lifecycle bounds; on edit pass null/"" to clear one. */
161
+ const parseBounds: Section = (p) => {
162
+ const { body, editing, touched, updates } = p;
163
+ const hint = editing ? "" : " (use an ISO-8601 timestamp or epoch ms)";
164
+ if (touched("start_at")) {
165
+ const startAt = parseInstant(body.start_at);
166
+ if (provided(body.start_at) && startAt === undefined)
167
+ return `Could not parse 'start_at'${hint}.`;
168
+ updates.startAt = startAt;
169
+ }
170
+ if (touched("end_at")) {
171
+ const endAt = parseInstant(body.end_at);
172
+ if (provided(body.end_at) && endAt === undefined)
173
+ return `Could not parse 'end_at'${hint}.`;
174
+ updates.endAt = endAt;
175
+ }
176
+ const { startAt, endAt } = merge(p);
177
+ if (startAt !== undefined && endAt !== undefined && endAt <= startAt)
178
+ return "'end_at' must be after 'start_at'.";
179
+ if (updates.endAt !== undefined && updates.endAt <= Date.now())
180
+ return "'end_at' is in the past — the job would never run.";
181
+ return null;
182
+ };
183
+
184
+ /** Run cap. `once: true` is sugar for max_runs = 1 (one-shot). */
185
+ const parseRunCap: Section = ({ body, touched, updates }) => {
186
+ if (body.once === true) {
187
+ updates.maxRuns = 1;
188
+ return null;
189
+ }
190
+ if (!touched("max_runs")) return null;
191
+ if (!provided(body.max_runs)) {
192
+ updates.maxRuns = undefined;
193
+ return null;
194
+ }
195
+ const m = Number(body.max_runs);
196
+ if (!Number.isInteger(m) || m < 1)
197
+ return "'max_runs' must be a positive integer.";
198
+ updates.maxRuns = m;
199
+ return null;
200
+ };
201
+
202
+ /**
203
+ * Missed-run catch-up policy. New jobs default to "once": a run that came
204
+ * due while Talon was down (or while the scheduler was wedged) replays a
205
+ * single time at startup instead of being lost silently — a live audit
206
+ * found one-shot reminders that missed their date under the old "skip"
207
+ * default and quietly rolled over a full year. Explicit "skip" remains
208
+ * available for jobs where a late run is worthless.
209
+ */
210
+ const parseCatchup: Section = ({ body, editing, touched, updates }) => {
211
+ if (!touched("catchup")) {
212
+ if (!editing) updates.catchup = "once";
213
+ return null;
214
+ }
215
+ const catchup = String(body.catchup) as CatchupPolicy;
216
+ if (!CATCHUP_POLICIES.has(catchup))
217
+ return "'catchup' must be one of: skip, once, all.";
218
+ updates.catchup = catchup;
219
+ return null;
220
+ };
221
+
222
+ /**
223
+ * Model / provider / instructions only make sense for "query" jobs (a
224
+ * "message" job just sends text — no model runs), and a provider override
225
+ * needs a model to pick on it.
226
+ */
227
+ const parseOverrides: Section = (p) => {
228
+ const { body, touched, updates } = p;
229
+ for (const key of ["model", "provider", "instructions"] as const) {
230
+ if (touched(key))
231
+ updates[key] = provided(body[key]) ? String(body[key]) : undefined;
232
+ }
233
+ const { type, model, provider, instructions } = merge(p);
234
+ if (type !== "query" && (model || provider || instructions))
235
+ return "Model/provider/instructions only apply to 'query' jobs.";
236
+ if (provider && !model)
237
+ return "A 'provider' override also requires a 'model'.";
238
+ return null;
239
+ };
240
+
241
+ const SECTIONS: Section[] = [
242
+ parsePayload,
243
+ parseCadence,
244
+ parseBounds,
245
+ parseRunCap,
246
+ parseCatchup,
247
+ parseOverrides,
248
+ ];
249
+
250
+ /**
251
+ * Validate a create/edit body's schedule fields and normalise them into job
252
+ * fields. Without `existing` this is a create: cadence is required, blanks
253
+ * are absent, defaults fill in. With `existing` it is an edit of that job:
254
+ * only supplied fields are emitted, blanks clear.
255
+ */
256
+ export function parseCronSpec(
257
+ body: Record<string, unknown>,
258
+ existing?: CronJob,
259
+ ): ParsedCronSpec {
260
+ const editing = existing !== undefined;
261
+ const p: Parse = {
262
+ body,
263
+ existing,
264
+ editing,
265
+ touched: (key) => (editing ? body[key] !== undefined : provided(body[key])),
266
+ updates: {},
267
+ };
268
+ for (const section of SECTIONS) {
269
+ const error = section(p);
270
+ if (error) return { ok: false, error };
271
+ }
272
+ return { ok: true, updates: p.updates, effective: merge(p) };
273
+ }
@@ -12,7 +12,8 @@ import { toYMD } from "../../../util/time.js";
12
12
  import { getDefaultModel } from "../../models/catalog.js";
13
13
  import { loadSystemTemplate } from "../../prompt/templates.js";
14
14
  import { formatGoal, getOpenGoals } from "../../../storage/goal-store.js";
15
- import { taskTable } from "../../tasks/index.js";
15
+ import { taskTable, type TaskHandle } from "../../tasks/index.js";
16
+ import type { Backend } from "../../agent-runtime/capabilities.js";
16
17
  import type { OneShotAgentParams } from "../../types.js";
17
18
  import { resolveBackgroundEffort } from "../effort.js";
18
19
  import { hb } from "./state.js";
@@ -102,29 +103,25 @@ function renderGoalsBlock(): { text: string; count: number } {
102
103
  return { text, count };
103
104
  }
104
105
 
105
- export async function runHeartbeatAgent(
106
- lastRunTimestamp: number,
107
- runCount: number,
108
- ): Promise<string> {
109
- const config = hb.config;
110
- if (!config) {
111
- throw new Error("Heartbeat agent not initialized");
112
- }
106
+ /** Everything the seeded heartbeat.md template interpolates. */
107
+ type HeartbeatPromptInputs = {
108
+ lastRunIso: string;
109
+ runCount: number;
110
+ workspace: string;
111
+ logsDir: string;
112
+ memoryFile: string;
113
+ instructionsFile: string;
114
+ dailyMemoryFile: string;
115
+ };
113
116
 
114
- const lastRunIso =
115
- lastRunTimestamp > 0 ? new Date(lastRunTimestamp).toISOString() : "never";
116
-
117
- const logsDir = dirs.logs;
118
- const memoryFile = pathFiles.memory;
119
- const workspace = config.workspace ?? dirs.workspace;
120
- const instructionsFile = resolve(workspace, "heartbeat-instructions.md");
121
- const dailyMemoryFile = resolve(dirs.dailyMemory, `${toYMD(new Date())}.md`);
122
-
123
- // Load prompt template from the prompts directory (seeded to ~/.talon/prompts/)
117
+ /**
118
+ * Load the user's heartbeat.md (seeded to ~/.talon/prompts/) and fill its
119
+ * placeholders. Seeded copies are never rewritten once the user owns them,
120
+ * so two older vintages get their missing sections appended instead.
121
+ */
122
+ function renderHeartbeatPrompt(inputs: HeartbeatPromptInputs): string {
124
123
  const promptPath = resolve(dirs.prompts, "heartbeat.md");
125
-
126
124
  const goalsBlock = renderGoalsBlock();
127
-
128
125
  let prompt: string;
129
126
  let hadGoalsVar: boolean;
130
127
  let hadStateVar: boolean;
@@ -133,14 +130,14 @@ export async function runHeartbeatAgent(
133
130
  hadGoalsVar = raw.includes("{{goals}}");
134
131
  hadStateVar = raw.includes("{{stateFile}}");
135
132
  prompt = raw
136
- .replace(/\{\{workspace\}\}/g, workspace)
137
- .replace(/\{\{logsDir\}\}/g, logsDir)
138
- .replace(/\{\{lastRunIso\}\}/g, lastRunIso)
139
- .replace(/\{\{memoryFile\}\}/g, memoryFile)
133
+ .replace(/\{\{workspace\}\}/g, inputs.workspace)
134
+ .replace(/\{\{logsDir\}\}/g, inputs.logsDir)
135
+ .replace(/\{\{lastRunIso\}\}/g, inputs.lastRunIso)
136
+ .replace(/\{\{memoryFile\}\}/g, inputs.memoryFile)
140
137
  .replace(/\{\{stateFile\}\}/g, pathFiles.state)
141
- .replace(/\{\{instructionsFile\}\}/g, instructionsFile)
142
- .replace(/\{\{dailyMemoryFile\}\}/g, dailyMemoryFile)
143
- .replace(/\{\{runCount\}\}/g, String(runCount))
138
+ .replace(/\{\{instructionsFile\}\}/g, inputs.instructionsFile)
139
+ .replace(/\{\{dailyMemoryFile\}\}/g, inputs.dailyMemoryFile)
140
+ .replace(/\{\{runCount\}\}/g, String(inputs.runCount))
144
141
  .replace(/\{\{intervalMinutes\}\}/g, String(hb.intervalMinutesRef))
145
142
  .replace(/\{\{goals\}\}/g, goalsBlock.text);
146
143
  } catch {
@@ -161,8 +158,6 @@ export async function runHeartbeatAgent(
161
158
  // Same vintage problem for the memory/state split: a seeded heartbeat.md
162
159
  // from before it still instructs the agent to write memory.md, which is
163
160
  // how status snapshots accreted in the durable store in the first place.
164
- // Append the ownership rules so the split holds regardless of template
165
- // vintage — the seeded copy is never rewritten once the user owns it.
166
161
  if (!hadStateVar) {
167
162
  logWarn(
168
163
  "heartbeat",
@@ -172,33 +167,20 @@ export async function runHeartbeatAgent(
172
167
  prompt += `\n\n${loadSystemTemplate("heartbeat-agent", {
173
168
  mode: "state-fallback",
174
169
  stateFile: pathFiles.state,
175
- memoryFile,
170
+ memoryFile: inputs.memoryFile,
176
171
  }).trim()}`;
177
172
  }
173
+ return prompt;
174
+ }
178
175
 
179
- const model = config.heartbeatModel ?? config.model ?? getDefaultModel();
180
-
181
- const backend = config.getBackend?.() ?? null;
182
- const background = backend?.background;
183
- if (!background) {
184
- throw new Error(
185
- "Heartbeat requires a backend that implements the background capability",
186
- );
187
- }
188
-
189
- // Effort is resolved against the heartbeat backend's catalog, not just
190
- // copied from config — a level the model doesn't offer is dropped with a
191
- // reason rather than handed to the SDK.
192
- const effort = await resolveBackgroundEffort({
193
- requested: config.heartbeatEffort,
194
- model,
195
- backend,
196
- });
197
- if (effort.dropped) {
198
- logWarn("heartbeat", effort.dropped);
199
- }
200
-
201
- // Set up heartbeat log file
176
+ /** Create this run's log file and write its header; returns the path. */
177
+ async function openHeartbeatLog(
178
+ runCount: number,
179
+ lastRunIso: string,
180
+ model: string,
181
+ effort: { effort?: string; dropped?: string },
182
+ prompt: string,
183
+ ): Promise<string> {
202
184
  const heartbeatLogFile = await createHeartbeatLogFile();
203
185
  await appendHeartbeatLog(
204
186
  heartbeatLogFile,
@@ -219,32 +201,23 @@ export async function runHeartbeatAgent(
219
201
  heartbeatLogFile,
220
202
  `**Prompt:**\n\`\`\`\n${prompt}\n\`\`\`\n\n---\n`,
221
203
  );
204
+ return heartbeatLogFile;
205
+ }
222
206
 
223
- // AbortController is the canonical way to signal a cancellation to a backend.
224
- // .abort() should tear down any spawned subprocess (Claude SDK) or stop
225
- // streaming (Kilo/OpenCode). We defend against backends that ignore it — see
226
- // heartbeatAbortGraceMs below.
227
- const abortController = new AbortController();
228
-
229
- const task = taskTable.begin({
230
- kind: "heartbeat",
231
- label: `#${runCount}`,
232
- abort: () => abortController.abort(),
233
- });
234
- task.bind({ model });
235
-
236
- const oneShotParams: OneShotAgentParams = {
237
- prompt,
238
- systemPrompt: buildHeartbeatSystemPrompt(),
239
- workspace,
240
- model,
241
- ...(effort.effort ? { reasoningEffort: effort.effort } : {}),
242
- contextLabel: "heartbeat",
243
- abortController,
244
- appendLog: (text) => appendHeartbeatLog(heartbeatLogFile, text),
245
- };
246
-
247
- // Timeout that requests eviction (graceful first, force-kill on grace exit).
207
+ /**
208
+ * Run the one-shot agent under the heartbeat's soft timeout. On timeout the
209
+ * abort signal goes out, the backend gets a bounded grace window to clean
210
+ * up, and a backend that ignores it is asked to evict its orphans; either
211
+ * way the error propagates so the caller releases the lock.
212
+ */
213
+ async function runOneShotWithTimeout(
214
+ background: NonNullable<Backend["background"]>,
215
+ params: OneShotAgentParams,
216
+ abortController: AbortController,
217
+ task: TaskHandle,
218
+ runCount: number,
219
+ heartbeatLogFile: string,
220
+ ): Promise<Awaited<ReturnType<typeof background.runOneShotAgent>>> {
248
221
  let timeoutFired = false;
249
222
  let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
250
223
  const timeoutPromise = new Promise<never>((_, reject) => {
@@ -262,7 +235,7 @@ export async function runHeartbeatAgent(
262
235
  });
263
236
 
264
237
  const agentPromise = (async () => {
265
- const usage = await background.runOneShotAgent(oneShotParams);
238
+ const usage = await background.runOneShotAgent(params);
266
239
  await appendHeartbeatLog(
267
240
  heartbeatLogFile,
268
241
  `\n---\n**Heartbeat #${runCount} completed at ${new Date().toISOString()}**\n`,
@@ -270,9 +243,8 @@ export async function runHeartbeatAgent(
270
243
  return usage;
271
244
  })();
272
245
 
273
- let usage: Awaited<typeof agentPromise>;
274
246
  try {
275
- usage = await Promise.race([agentPromise, timeoutPromise]);
247
+ return await Promise.race([agentPromise, timeoutPromise]);
276
248
  } catch (err) {
277
249
  // Snapshot timeout state and clear the timer immediately, BEFORE any awaits
278
250
  // in the error-handling path. Otherwise the timer can fire during the async
@@ -289,28 +261,7 @@ export async function runHeartbeatAgent(
289
261
  `\n---\n**Heartbeat #${runCount} FAILED at ${new Date().toISOString()}:** ${err}\n`,
290
262
  );
291
263
  if (wasTimeout) {
292
- // Give the backend a bounded grace window to clean up after the abort
293
- // signal — but never wait indefinitely. If the backend ignores the abort,
294
- // release the lock anyway and ask it to evict any orphan subprocesses.
295
- const settled = await raceWithTimeout(
296
- agentPromise.catch(() => "settled"),
297
- heartbeatAbortGraceMs(),
298
- );
299
- if (settled === "timed_out") {
300
- logWarn(
301
- "heartbeat",
302
- `Heartbeat #${runCount} backend ignored abort after ${heartbeatAbortGraceMs()}ms — releasing lock and evicting orphan subprocesses`,
303
- );
304
- // Fire-and-forget — we don't block the next heartbeat on subprocess
305
- // cleanup. Backends that don't spawn per-run subprocesses leave
306
- // evictOrphanSubprocesses unimplemented; that's fine.
307
- const evict = background.evictOrphanSubprocesses;
308
- if (evict) {
309
- evict("heartbeat").catch((sweepErr: unknown) => {
310
- logError("heartbeat", "Orphan subprocess sweep failed", sweepErr);
311
- });
312
- }
313
- }
264
+ await evictAfterIgnoredAbort(background, agentPromise, runCount);
314
265
  } else {
315
266
  // Non-timeout failure path — agentPromise has already settled.
316
267
  await agentPromise.catch(() => {});
@@ -321,7 +272,119 @@ export async function runHeartbeatAgent(
321
272
  // resolution of Promise.race() needs this too.
322
273
  if (timeoutHandle) clearTimeout(timeoutHandle);
323
274
  }
275
+ }
324
276
 
277
+ /**
278
+ * Give the backend a bounded grace window to clean up after the abort
279
+ * signal — but never wait indefinitely. If the backend ignores the abort,
280
+ * release the lock anyway and ask it to evict any orphan subprocesses.
281
+ */
282
+ async function evictAfterIgnoredAbort(
283
+ background: NonNullable<Backend["background"]>,
284
+ agentPromise: Promise<unknown>,
285
+ runCount: number,
286
+ ): Promise<void> {
287
+ const settled = await raceWithTimeout(
288
+ agentPromise.catch(() => "settled"),
289
+ heartbeatAbortGraceMs(),
290
+ );
291
+ if (settled !== "timed_out") return;
292
+ logWarn(
293
+ "heartbeat",
294
+ `Heartbeat #${runCount} backend ignored abort after ${heartbeatAbortGraceMs()}ms — releasing lock and evicting orphan subprocesses`,
295
+ );
296
+ // Fire-and-forget — we don't block the next heartbeat on subprocess
297
+ // cleanup. Backends that don't spawn per-run subprocesses leave
298
+ // evictOrphanSubprocesses unimplemented; that's fine.
299
+ const evict = background.evictOrphanSubprocesses;
300
+ if (evict) {
301
+ evict("heartbeat").catch((sweepErr: unknown) => {
302
+ logError("heartbeat", "Orphan subprocess sweep failed", sweepErr);
303
+ });
304
+ }
305
+ }
306
+
307
+ export async function runHeartbeatAgent(
308
+ lastRunTimestamp: number,
309
+ runCount: number,
310
+ ): Promise<string> {
311
+ const config = hb.config;
312
+ if (!config) {
313
+ throw new Error("Heartbeat agent not initialized");
314
+ }
315
+
316
+ const lastRunIso =
317
+ lastRunTimestamp > 0 ? new Date(lastRunTimestamp).toISOString() : "never";
318
+ const workspace = config.workspace ?? dirs.workspace;
319
+ const memoryFile = pathFiles.memory;
320
+ const prompt = renderHeartbeatPrompt({
321
+ lastRunIso,
322
+ runCount,
323
+ workspace,
324
+ logsDir: dirs.logs,
325
+ memoryFile,
326
+ instructionsFile: resolve(workspace, "heartbeat-instructions.md"),
327
+ dailyMemoryFile: resolve(dirs.dailyMemory, `${toYMD(new Date())}.md`),
328
+ });
329
+
330
+ const model = config.heartbeatModel ?? config.model ?? getDefaultModel();
331
+ const backend = config.getBackend?.() ?? null;
332
+ const background = backend?.background;
333
+ if (!background) {
334
+ throw new Error(
335
+ "Heartbeat requires a backend that implements the background capability",
336
+ );
337
+ }
338
+
339
+ // Effort is resolved against the heartbeat backend's catalog, not just
340
+ // copied from config — a level the model doesn't offer is dropped with a
341
+ // reason rather than handed to the SDK.
342
+ const effort = await resolveBackgroundEffort({
343
+ requested: config.heartbeatEffort,
344
+ model,
345
+ backend,
346
+ });
347
+ if (effort.dropped) {
348
+ logWarn("heartbeat", effort.dropped);
349
+ }
350
+
351
+ const heartbeatLogFile = await openHeartbeatLog(
352
+ runCount,
353
+ lastRunIso,
354
+ model,
355
+ effort,
356
+ prompt,
357
+ );
358
+
359
+ // AbortController is the canonical way to signal a cancellation to a backend.
360
+ // .abort() should tear down any spawned subprocess (Claude SDK) or stop
361
+ // streaming (Kilo/OpenCode). We defend against backends that ignore it — see
362
+ // heartbeatAbortGraceMs.
363
+ const abortController = new AbortController();
364
+ const task = taskTable.begin({
365
+ kind: "heartbeat",
366
+ label: `#${runCount}`,
367
+ abort: () => abortController.abort(),
368
+ });
369
+ task.bind({ model });
370
+
371
+ const usage = await runOneShotWithTimeout(
372
+ background,
373
+ {
374
+ prompt,
375
+ systemPrompt: buildHeartbeatSystemPrompt(),
376
+ workspace,
377
+ model,
378
+ ...(effort.effort ? { reasoningEffort: effort.effort } : {}),
379
+ contextLabel: "heartbeat",
380
+ abortController,
381
+ appendLog: (text) => appendHeartbeatLog(heartbeatLogFile, text),
382
+ },
383
+ abortController,
384
+ task,
385
+ runCount,
386
+ heartbeatLogFile,
387
+ );
325
388
  task.succeed(usage ?? undefined);
326
389
  return heartbeatLogFile;
327
390
  }
@@ -14,6 +14,7 @@ import { log, logWarn } from "../../../util/log.js";
14
14
  import { roleHolder } from "./holders.js";
15
15
  import {
16
16
  pool,
17
+ initInFlight,
17
18
  bindings,
18
19
  listeners,
19
20
  ctx,
@@ -285,6 +286,7 @@ export async function cleanupBackendPool(): Promise<void> {
285
286
  /** Test-only state reset. */
286
287
  export function resetBackendPoolForTest(): void {
287
288
  pool.clear();
289
+ initInFlight.clear();
288
290
  bindings.clear();
289
291
  ctx.initCtx = null;
290
292
  ctx.poolConfig = null;