cronScheduler.ts 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565
  1. // Non-React scheduler core for .claude/scheduled_tasks.json.
  2. // Shared by REPL (via useScheduledTasks) and SDK/-p mode (print.ts).
  3. //
  4. // Lifecycle: poll getScheduledTasksEnabled() until true (flag flips when
  5. // CronCreate runs or a skill on: trigger fires) → load tasks + watch the
  6. // file + start a 1s check timer → on fire, call onFire(prompt). stop()
  7. // tears everything down.
  8. import type { FSWatcher } from 'chokidar'
  9. import {
  10. getScheduledTasksEnabled,
  11. getSessionCronTasks,
  12. removeSessionCronTasks,
  13. setScheduledTasksEnabled,
  14. } from '../bootstrap/state.js'
  15. import {
  16. type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
  17. logEvent,
  18. } from '../services/analytics/index.js'
  19. import { cronToHuman } from './cron.js'
  20. import {
  21. type CronJitterConfig,
  22. type CronTask,
  23. DEFAULT_CRON_JITTER_CONFIG,
  24. findMissedTasks,
  25. getCronFilePath,
  26. hasCronTasksSync,
  27. jitteredNextCronRunMs,
  28. markCronTasksFired,
  29. oneShotJitteredNextCronRunMs,
  30. readCronTasks,
  31. removeCronTasks,
  32. } from './cronTasks.js'
  33. import {
  34. releaseSchedulerLock,
  35. tryAcquireSchedulerLock,
  36. } from './cronTasksLock.js'
  37. import { logForDebugging } from './debug.js'
  38. const CHECK_INTERVAL_MS = 1000
  39. const FILE_STABILITY_MS = 300
  40. // How often a non-owning session re-probes the scheduler lock. Coarse
  41. // because takeover only matters when the owning session has crashed.
  42. const LOCK_PROBE_INTERVAL_MS = 5000
  43. /**
  44. * True when a recurring task was created more than `maxAgeMs` ago and should
  45. * be deleted on its next fire. Permanent tasks never age. `maxAgeMs === 0`
  46. * means unlimited (never ages out). Sourced from
  47. * {@link CronJitterConfig.recurringMaxAgeMs} at call time.
  48. * Extracted for testability — the scheduler's check() is buried under
  49. * setInterval/chokidar/lock machinery.
  50. */
  51. export function isRecurringTaskAged(
  52. t: CronTask,
  53. nowMs: number,
  54. maxAgeMs: number,
  55. ): boolean {
  56. if (maxAgeMs === 0) return false
  57. return Boolean(t.recurring && !t.permanent && nowMs - t.createdAt >= maxAgeMs)
  58. }
  59. type CronSchedulerOptions = {
  60. /** Called when a task fires (regular or missed-on-startup). */
  61. onFire: (prompt: string) => void
  62. /** While true, firing is deferred to the next tick. */
  63. isLoading: () => boolean
  64. /**
  65. * When true, bypasses the isLoading gate in check() and auto-enables the
  66. * scheduler without waiting for setScheduledTasksEnabled(). The
  67. * auto-enable is the load-bearing part — assistant mode has tasks in
  68. * scheduled_tasks.json at install time and shouldn't wait on a loader
  69. * skill to flip the flag. The isLoading bypass is minor post-#20425
  70. * (assistant mode now idles between turns like a normal REPL).
  71. */
  72. assistantMode?: boolean
  73. /**
  74. * When provided, receives the full CronTask on normal fires (and onFire is
  75. * NOT called for that fire). Lets daemon callers see the task id/cron/etc
  76. * instead of just the prompt string.
  77. */
  78. onFireTask?: (task: CronTask) => void
  79. /**
  80. * When provided, receives the missed one-shot tasks on initial load (and
  81. * onFire is NOT called with the pre-formatted notification). Daemon decides
  82. * how to surface them.
  83. */
  84. onMissed?: (tasks: CronTask[]) => void
  85. /**
  86. * Directory containing .claude/scheduled_tasks.json. When provided, the
  87. * scheduler never touches bootstrap state: getProjectRoot/getSessionId are
  88. * not read, and the getScheduledTasksEnabled() poll is skipped (enable()
  89. * runs immediately on start). Required for Agent SDK daemon callers.
  90. */
  91. dir?: string
  92. /**
  93. * Owner key written into the lock file. Defaults to getSessionId().
  94. * Daemon callers must pass a stable per-process UUID since they have no
  95. * session. PID remains the liveness probe regardless.
  96. */
  97. lockIdentity?: string
  98. /**
  99. * Returns the cron jitter config to use for this tick. Called once per
  100. * check() cycle. REPL callers pass a GrowthBook-backed implementation
  101. * (see cronJitterConfig.ts) for live tuning — ops can widen the jitter
  102. * window mid-session during a :00 load spike without restarting clients.
  103. * Agent SDK daemon callers omit this and get DEFAULT_CRON_JITTER_CONFIG,
  104. * which is safe since daemons restart on config change anyway, and the
  105. * growthbook.ts → config.ts → commands.ts → REPL chain stays out of
  106. * sdk.mjs.
  107. */
  108. getJitterConfig?: () => CronJitterConfig
  109. /**
  110. * Killswitch: polled once per check() tick. When true, check() bails
  111. * before firing anything — existing crons stop dead mid-session. CLI
  112. * callers inject `() => !isKairosCronEnabled()` so flipping the
  113. * tengu_kairos_cron gate off stops already-running schedulers (not just
  114. * new ones). Daemon callers omit this, same rationale as getJitterConfig.
  115. */
  116. isKilled?: () => boolean
  117. /**
  118. * Per-task gate applied before any side effect. Tasks returning false are
  119. * invisible to this scheduler: never fired, never stamped with
  120. * `lastFiredAt`, never deleted, never surfaced as missed, absent from
  121. * `getNextFireTime()`. The daemon cron worker uses `t => t.permanent` so
  122. * non-permanent tasks in the same scheduled_tasks.json are untouched.
  123. */
  124. filter?: (t: CronTask) => boolean
  125. }
  126. export type CronScheduler = {
  127. start: () => void
  128. stop: () => void
  129. /**
  130. * Epoch ms of the soonest scheduled fire across all loaded tasks, or null
  131. * if nothing is scheduled (no tasks, or all tasks already in-flight).
  132. * Daemon callers use this to decide whether to tear down an idle agent
  133. * subprocess or keep it warm for an imminent fire.
  134. */
  135. getNextFireTime: () => number | null
  136. }
  137. export function createCronScheduler(
  138. options: CronSchedulerOptions,
  139. ): CronScheduler {
  140. const {
  141. onFire,
  142. isLoading,
  143. assistantMode = false,
  144. onFireTask,
  145. onMissed,
  146. dir,
  147. lockIdentity,
  148. getJitterConfig,
  149. isKilled,
  150. filter,
  151. } = options
  152. const lockOpts = dir || lockIdentity ? { dir, lockIdentity } : undefined
  153. // File-backed tasks only. Session tasks (durable: false) are NOT loaded
  154. // here — they can be added/removed mid-session with no file event, so
  155. // check() reads them fresh from bootstrap state on every tick instead.
  156. let tasks: CronTask[] = []
  157. // Per-task next-fire times (epoch ms).
  158. const nextFireAt = new Map<string, number>()
  159. // Ids we've already enqueued a "missed task" prompt for — prevents
  160. // re-asking on every file change before the user answers.
  161. const missedAsked = new Set<string>()
  162. // Tasks currently enqueued but not yet removed from the file. Prevents
  163. // double-fire if the interval ticks again before removeCronTasks lands.
  164. const inFlight = new Set<string>()
  165. let enablePoll: ReturnType<typeof setInterval> | null = null
  166. let checkTimer: ReturnType<typeof setInterval> | null = null
  167. let lockProbeTimer: ReturnType<typeof setInterval> | null = null
  168. let watcher: FSWatcher | null = null
  169. let stopped = false
  170. let isOwner = false
  171. async function load(initial: boolean) {
  172. const next = await readCronTasks(dir)
  173. if (stopped) return
  174. tasks = next
  175. // Only surface missed tasks on initial load. Chokidar-triggered
  176. // reloads leave overdue tasks to check() (which anchors from createdAt
  177. // and fires immediately). This avoids a misleading "missed while Claude
  178. // was not running" prompt for tasks that became overdue mid-session.
  179. //
  180. // Recurring tasks are NOT surfaced or deleted — check() handles them
  181. // correctly (fires on first tick, reschedules forward). Only one-shot
  182. // missed tasks need user input (run once now, or discard forever).
  183. if (!initial) return
  184. const now = Date.now()
  185. const missed = findMissedTasks(next, now).filter(
  186. t => !t.recurring && !missedAsked.has(t.id) && (!filter || filter(t)),
  187. )
  188. if (missed.length > 0) {
  189. for (const t of missed) {
  190. missedAsked.add(t.id)
  191. // Prevent check() from re-firing the raw prompt while the async
  192. // removeCronTasks + chokidar reload chain is in progress.
  193. nextFireAt.set(t.id, Infinity)
  194. }
  195. logEvent('tengu_scheduled_task_missed', {
  196. count: missed.length,
  197. taskIds: missed
  198. .map(t => t.id)
  199. .join(
  200. ',',
  201. ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
  202. })
  203. if (onMissed) {
  204. onMissed(missed)
  205. } else {
  206. onFire(buildMissedTaskNotification(missed))
  207. }
  208. void removeCronTasks(
  209. missed.map(t => t.id),
  210. dir,
  211. ).catch(e =>
  212. logForDebugging(`[ScheduledTasks] failed to remove missed tasks: ${e}`),
  213. )
  214. logForDebugging(
  215. `[ScheduledTasks] surfaced ${missed.length} missed one-shot task(s)`,
  216. )
  217. }
  218. }
  219. function check() {
  220. if (isKilled?.()) return
  221. if (isLoading() && !assistantMode) return
  222. const now = Date.now()
  223. const seen = new Set<string>()
  224. // File-backed recurring tasks that fired this tick. Batched into one
  225. // markCronTasksFired call after the loop so N fires = one write. Session
  226. // tasks excluded — they die with the process, no point persisting.
  227. const firedFileRecurring: string[] = []
  228. // Read once per tick. REPL callers pass getJitterConfig backed by
  229. // GrowthBook so a config push takes effect without restart. Daemon and
  230. // SDK callers omit it and get DEFAULT_CRON_JITTER_CONFIG (safe — jitter
  231. // is an ops lever for REPL fleet load-shedding, not a daemon concern).
  232. const jitterCfg = getJitterConfig?.() ?? DEFAULT_CRON_JITTER_CONFIG
  233. // Shared loop body. `isSession` routes the one-shot cleanup path:
  234. // session tasks are removed synchronously from memory, file tasks go
  235. // through the async removeCronTasks + chokidar reload.
  236. function process(t: CronTask, isSession: boolean) {
  237. if (filter && !filter(t)) return
  238. seen.add(t.id)
  239. if (inFlight.has(t.id)) return
  240. let next = nextFireAt.get(t.id)
  241. if (next === undefined) {
  242. // First sight — anchor from lastFiredAt (recurring) or createdAt.
  243. // Never-fired recurring tasks use createdAt: if isLoading delayed
  244. // this tick past the fire time, anchoring from `now` would compute
  245. // next-year for pinned crons (`30 14 27 2 *`). Fired-before tasks
  246. // use lastFiredAt: the reschedule below writes `now` back to disk,
  247. // so on next process spawn first-sight computes the SAME newNext we
  248. // set in-memory here. Without this, a daemon child despawning on
  249. // idle loses nextFireAt and the next spawn re-anchors from 10-day-
  250. // old createdAt → fires every task every cycle.
  251. next = t.recurring
  252. ? (jitteredNextCronRunMs(
  253. t.cron,
  254. t.lastFiredAt ?? t.createdAt,
  255. t.id,
  256. jitterCfg,
  257. ) ?? Infinity)
  258. : (oneShotJitteredNextCronRunMs(
  259. t.cron,
  260. t.createdAt,
  261. t.id,
  262. jitterCfg,
  263. ) ?? Infinity)
  264. nextFireAt.set(t.id, next)
  265. logForDebugging(
  266. `[ScheduledTasks] scheduled ${t.id} for ${next === Infinity ? 'never' : new Date(next).toISOString()}`,
  267. )
  268. }
  269. if (now < next) return
  270. logForDebugging(
  271. `[ScheduledTasks] firing ${t.id}${t.recurring ? ' (recurring)' : ''}`,
  272. )
  273. logEvent('tengu_scheduled_task_fire', {
  274. recurring: t.recurring ?? false,
  275. taskId:
  276. t.id as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
  277. })
  278. if (onFireTask) {
  279. onFireTask(t)
  280. } else {
  281. onFire(t.prompt)
  282. }
  283. // Aged-out recurring tasks fall through to the one-shot delete paths
  284. // below (session tasks get synchronous removal; file tasks get the
  285. // async inFlight/chokidar path). Fires one last time, then is removed.
  286. const aged = isRecurringTaskAged(t, now, jitterCfg.recurringMaxAgeMs)
  287. if (aged) {
  288. const ageHours = Math.floor((now - t.createdAt) / 1000 / 60 / 60)
  289. logForDebugging(
  290. `[ScheduledTasks] recurring task ${t.id} aged out (${ageHours}h since creation), deleting after final fire`,
  291. )
  292. logEvent('tengu_scheduled_task_expired', {
  293. taskId:
  294. t.id as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
  295. ageHours,
  296. })
  297. }
  298. if (t.recurring && !aged) {
  299. // Recurring: reschedule from now (not from next) to avoid rapid
  300. // catch-up if the session was blocked. Jitter keeps us off the
  301. // exact :00 wall-clock boundary every cycle.
  302. const newNext =
  303. jitteredNextCronRunMs(t.cron, now, t.id, jitterCfg) ?? Infinity
  304. nextFireAt.set(t.id, newNext)
  305. // Persist lastFiredAt=now so next process spawn reconstructs this
  306. // same newNext on first-sight. Session tasks skip — process-local.
  307. if (!isSession) firedFileRecurring.push(t.id)
  308. } else if (isSession) {
  309. // One-shot (or aged-out recurring) session task: synchronous memory
  310. // removal. No inFlight window — the next tick will read a session
  311. // store without this id.
  312. removeSessionCronTasks([t.id])
  313. nextFireAt.delete(t.id)
  314. } else {
  315. // One-shot (or aged-out recurring) file task: delete from disk.
  316. // inFlight guards against double-fire during the async
  317. // removeCronTasks + chokidar reload.
  318. inFlight.add(t.id)
  319. void removeCronTasks([t.id], dir)
  320. .catch(e =>
  321. logForDebugging(
  322. `[ScheduledTasks] failed to remove task ${t.id}: ${e}`,
  323. ),
  324. )
  325. .finally(() => inFlight.delete(t.id))
  326. nextFireAt.delete(t.id)
  327. }
  328. }
  329. // File-backed tasks: only when we own the scheduler lock. The lock
  330. // exists to stop two Claude sessions in the same cwd from double-firing
  331. // the same on-disk task.
  332. if (isOwner) {
  333. for (const t of tasks) process(t, false)
  334. // Batched lastFiredAt write. inFlight guards against double-fire
  335. // during the chokidar-triggered reload (same pattern as removeCronTasks
  336. // below) — the reload re-seeds `tasks` with the just-written
  337. // lastFiredAt, and first-sight on that yields the same newNext we
  338. // already set in-memory, so it's idempotent even without inFlight.
  339. // Guarding anyway keeps the semantics obvious.
  340. if (firedFileRecurring.length > 0) {
  341. for (const id of firedFileRecurring) inFlight.add(id)
  342. void markCronTasksFired(firedFileRecurring, now, dir)
  343. .catch(e =>
  344. logForDebugging(
  345. `[ScheduledTasks] failed to persist lastFiredAt: ${e}`,
  346. ),
  347. )
  348. .finally(() => {
  349. for (const id of firedFileRecurring) inFlight.delete(id)
  350. })
  351. }
  352. }
  353. // Session-only tasks: process-private, the lock does not apply — the
  354. // other session cannot see them and there is no double-fire risk. Read
  355. // fresh from bootstrap state every tick (no chokidar, no load()). This
  356. // is skipped on the daemon path (`dir !== undefined`) which never
  357. // touches bootstrap state.
  358. if (dir === undefined) {
  359. for (const t of getSessionCronTasks()) process(t, true)
  360. }
  361. if (seen.size === 0) {
  362. // No live tasks this tick — clear the whole schedule so
  363. // getNextFireTime() returns null. The eviction loop below is
  364. // unreachable here (seen is empty), so stale entries would
  365. // otherwise survive indefinitely and keep the daemon agent warm.
  366. nextFireAt.clear()
  367. return
  368. }
  369. // Evict schedule entries for tasks no longer present. When !isOwner,
  370. // file-task ids aren't in `seen` and get evicted — harmless: they
  371. // re-anchor from createdAt on the first owned tick.
  372. for (const id of nextFireAt.keys()) {
  373. if (!seen.has(id)) nextFireAt.delete(id)
  374. }
  375. }
  376. async function enable() {
  377. if (stopped) return
  378. if (enablePoll) {
  379. clearInterval(enablePoll)
  380. enablePoll = null
  381. }
  382. const { default: chokidar } = await import('chokidar')
  383. if (stopped) return
  384. // Acquire the per-project scheduler lock. Only the owning session runs
  385. // check(). Other sessions probe periodically to take over if the owner
  386. // dies. Prevents double-firing when multiple Claudes share a cwd.
  387. isOwner = await tryAcquireSchedulerLock(lockOpts).catch(() => false)
  388. if (stopped) {
  389. if (isOwner) {
  390. isOwner = false
  391. void releaseSchedulerLock(lockOpts)
  392. }
  393. return
  394. }
  395. if (!isOwner) {
  396. lockProbeTimer = setInterval(() => {
  397. void tryAcquireSchedulerLock(lockOpts)
  398. .then(owned => {
  399. if (stopped) {
  400. if (owned) void releaseSchedulerLock(lockOpts)
  401. return
  402. }
  403. if (owned) {
  404. isOwner = true
  405. if (lockProbeTimer) {
  406. clearInterval(lockProbeTimer)
  407. lockProbeTimer = null
  408. }
  409. }
  410. })
  411. .catch(e => logForDebugging(String(e), { level: 'error' }))
  412. }, LOCK_PROBE_INTERVAL_MS)
  413. lockProbeTimer.unref?.()
  414. }
  415. void load(true)
  416. const path = getCronFilePath(dir)
  417. watcher = chokidar.watch(path, {
  418. persistent: false,
  419. ignoreInitial: true,
  420. awaitWriteFinish: { stabilityThreshold: FILE_STABILITY_MS },
  421. ignorePermissionErrors: true,
  422. })
  423. watcher.on('add', () => void load(false))
  424. watcher.on('change', () => void load(false))
  425. watcher.on('unlink', () => {
  426. if (!stopped) {
  427. tasks = []
  428. nextFireAt.clear()
  429. }
  430. })
  431. checkTimer = setInterval(check, CHECK_INTERVAL_MS)
  432. // Don't keep the process alive for the scheduler alone — in -p text mode
  433. // the process should exit after the single turn even if a cron was created.
  434. checkTimer.unref?.()
  435. }
  436. return {
  437. start() {
  438. stopped = false
  439. // Daemon path (dir explicitly given): don't touch bootstrap state —
  440. // getScheduledTasksEnabled() would read a never-initialized flag. The
  441. // daemon is asking to schedule; just enable.
  442. if (dir !== undefined) {
  443. logForDebugging(
  444. `[ScheduledTasks] scheduler start() — dir=${dir}, hasTasks=${hasCronTasksSync(dir)}`,
  445. )
  446. void enable()
  447. return
  448. }
  449. logForDebugging(
  450. `[ScheduledTasks] scheduler start() — enabled=${getScheduledTasksEnabled()}, hasTasks=${hasCronTasksSync()}`,
  451. )
  452. // Auto-enable when scheduled_tasks.json has entries. CronCreateTool
  453. // also sets this when a task is created mid-session.
  454. if (
  455. !getScheduledTasksEnabled() &&
  456. (assistantMode || hasCronTasksSync())
  457. ) {
  458. setScheduledTasksEnabled(true)
  459. }
  460. if (getScheduledTasksEnabled()) {
  461. void enable()
  462. return
  463. }
  464. enablePoll = setInterval(
  465. en => {
  466. if (getScheduledTasksEnabled()) void en()
  467. },
  468. CHECK_INTERVAL_MS,
  469. enable,
  470. )
  471. enablePoll.unref?.()
  472. },
  473. stop() {
  474. stopped = true
  475. if (enablePoll) {
  476. clearInterval(enablePoll)
  477. enablePoll = null
  478. }
  479. if (checkTimer) {
  480. clearInterval(checkTimer)
  481. checkTimer = null
  482. }
  483. if (lockProbeTimer) {
  484. clearInterval(lockProbeTimer)
  485. lockProbeTimer = null
  486. }
  487. void watcher?.close()
  488. watcher = null
  489. if (isOwner) {
  490. isOwner = false
  491. void releaseSchedulerLock(lockOpts)
  492. }
  493. },
  494. getNextFireTime() {
  495. // nextFireAt uses Infinity for "never" (in-flight one-shots, bad cron
  496. // strings). Filter those out so callers can distinguish "soon" from
  497. // "nothing pending".
  498. let min = Infinity
  499. for (const t of nextFireAt.values()) {
  500. if (t < min) min = t
  501. }
  502. return min === Infinity ? null : min
  503. },
  504. }
  505. }
  506. /**
  507. * Build the missed-task notification text. Guidance precedes the task list
  508. * and the list is wrapped in a code fence so a multi-line imperative prompt
  509. * is not interpreted as immediate instructions to avoid self-inflicted
  510. * prompt injection. The full prompt body is preserved — this path DOES
  511. * need the model to execute the prompt after user
  512. * confirmation, and tasks are already deleted from JSON before the model
  513. * sees this notification.
  514. */
  515. export function buildMissedTaskNotification(missed: CronTask[]): string {
  516. const plural = missed.length > 1
  517. const header =
  518. `The following one-shot scheduled task${plural ? 's were' : ' was'} missed while Claude was not running. ` +
  519. `${plural ? 'They have' : 'It has'} already been removed from .claude/scheduled_tasks.json.\n\n` +
  520. `Do NOT execute ${plural ? 'these prompts' : 'this prompt'} yet. ` +
  521. `First use the AskUserQuestion tool to ask whether to run ${plural ? 'each one' : 'it'} now. ` +
  522. `Only execute if the user confirms.`
  523. const blocks = missed.map(t => {
  524. const meta = `[${cronToHuman(t.cron)}, created ${new Date(t.createdAt).toLocaleString()}]`
  525. // Use a fence one longer than any backtick run in the prompt so a
  526. // prompt containing ``` cannot close the fence early and un-wrap the
  527. // trailing text (CommonMark fence-matching rule).
  528. const longestRun = (t.prompt.match(/`+/g) ?? ([] as string[])).reduce(
  529. (max: number, run: string) => Math.max(max, run.length),
  530. 0,
  531. )
  532. const fence = '`'.repeat(Math.max(3, longestRun + 1))
  533. return `${meta}\n${fence}\n${t.prompt}\n${fence}`
  534. })
  535. return `${header}\n\n${blocks.join('\n\n')}`
  536. }