@fastagent-sh/fastagent 0.12.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 (158) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +214 -0
  3. package/dist/agent.d.ts +72 -0
  4. package/dist/agent.js +14 -0
  5. package/dist/channels/body.d.ts +9 -0
  6. package/dist/channels/body.js +29 -0
  7. package/dist/channels/github/github.d.ts +31 -0
  8. package/dist/channels/github/github.js +80 -0
  9. package/dist/channels/github/scaffold/channel.ts +25 -0
  10. package/dist/channels/http.d.ts +13 -0
  11. package/dist/channels/http.js +161 -0
  12. package/dist/channels/respond.d.ts +5 -0
  13. package/dist/channels/respond.js +3 -0
  14. package/dist/channels/telegram/context-buffer.d.ts +59 -0
  15. package/dist/channels/telegram/context-buffer.js +139 -0
  16. package/dist/channels/telegram/invoke-turn.d.ts +53 -0
  17. package/dist/channels/telegram/invoke-turn.js +127 -0
  18. package/dist/channels/telegram/parse.d.ts +160 -0
  19. package/dist/channels/telegram/parse.js +153 -0
  20. package/dist/channels/telegram/preview.d.ts +22 -0
  21. package/dist/channels/telegram/preview.js +266 -0
  22. package/dist/channels/telegram/register-webhook.d.ts +13 -0
  23. package/dist/channels/telegram/register-webhook.js +73 -0
  24. package/dist/channels/telegram/scaffold/channel.ts +25 -0
  25. package/dist/channels/telegram/scaffold/telegram-send.ts +107 -0
  26. package/dist/channels/telegram/state.d.ts +9 -0
  27. package/dist/channels/telegram/state.js +58 -0
  28. package/dist/channels/telegram/telegram-api.d.ts +77 -0
  29. package/dist/channels/telegram/telegram-api.js +345 -0
  30. package/dist/channels/telegram/telegram.d.ts +35 -0
  31. package/dist/channels/telegram/telegram.js +352 -0
  32. package/dist/channels/telegram/turn-queue.d.ts +21 -0
  33. package/dist/channels/telegram/turn-queue.js +47 -0
  34. package/dist/channels/telegram/turn-store.d.ts +38 -0
  35. package/dist/channels/telegram/turn-store.js +150 -0
  36. package/dist/cli-auth.d.ts +24 -0
  37. package/dist/cli-auth.js +30 -0
  38. package/dist/cli-models.d.ts +11 -0
  39. package/dist/cli-models.js +20 -0
  40. package/dist/cli.d.ts +2 -0
  41. package/dist/cli.js +1288 -0
  42. package/dist/collect.d.ts +20 -0
  43. package/dist/collect.js +27 -0
  44. package/dist/core.d.ts +10 -0
  45. package/dist/core.js +8 -0
  46. package/dist/deploy/container.d.ts +56 -0
  47. package/dist/deploy/container.js +169 -0
  48. package/dist/deploy/fly/plan.d.ts +65 -0
  49. package/dist/deploy/fly/plan.js +142 -0
  50. package/dist/deploy/fly/run.d.ts +51 -0
  51. package/dist/deploy/fly/run.js +89 -0
  52. package/dist/deploy/preflight.d.ts +59 -0
  53. package/dist/deploy/preflight.js +191 -0
  54. package/dist/deploy/railway/plan.d.ts +48 -0
  55. package/dist/deploy/railway/plan.js +91 -0
  56. package/dist/deploy/railway/run.d.ts +81 -0
  57. package/dist/deploy/railway/run.js +176 -0
  58. package/dist/deploy/runner.d.ts +18 -0
  59. package/dist/deploy/runner.js +25 -0
  60. package/dist/deploy/secrets.d.ts +49 -0
  61. package/dist/deploy/secrets.js +88 -0
  62. package/dist/dev-supervisor.d.ts +14 -0
  63. package/dist/dev-supervisor.js +158 -0
  64. package/dist/engines/pi/auth.d.ts +17 -0
  65. package/dist/engines/pi/auth.js +118 -0
  66. package/dist/engines/pi/channel.d.ts +31 -0
  67. package/dist/engines/pi/channel.js +113 -0
  68. package/dist/engines/pi/chat.d.ts +19 -0
  69. package/dist/engines/pi/chat.js +200 -0
  70. package/dist/engines/pi/config.d.ts +110 -0
  71. package/dist/engines/pi/config.js +285 -0
  72. package/dist/engines/pi/create.d.ts +127 -0
  73. package/dist/engines/pi/create.js +222 -0
  74. package/dist/engines/pi/definition.d.ts +72 -0
  75. package/dist/engines/pi/definition.js +129 -0
  76. package/dist/engines/pi/harness.d.ts +50 -0
  77. package/dist/engines/pi/harness.js +41 -0
  78. package/dist/engines/pi/invoke.d.ts +28 -0
  79. package/dist/engines/pi/invoke.js +298 -0
  80. package/dist/engines/pi/login.d.ts +51 -0
  81. package/dist/engines/pi/login.js +125 -0
  82. package/dist/engines/pi/models.d.ts +41 -0
  83. package/dist/engines/pi/models.js +76 -0
  84. package/dist/engines/pi/report.d.ts +13 -0
  85. package/dist/engines/pi/report.js +21 -0
  86. package/dist/engines/pi/sessions.d.ts +15 -0
  87. package/dist/engines/pi/sessions.js +130 -0
  88. package/dist/engines/pi/tool-context.d.ts +17 -0
  89. package/dist/engines/pi/tool-context.js +13 -0
  90. package/dist/engines/pi/tool.d.ts +45 -0
  91. package/dist/engines/pi/tool.js +85 -0
  92. package/dist/engines/pi/wake-tool.d.ts +20 -0
  93. package/dist/engines/pi/wake-tool.js +110 -0
  94. package/dist/engines/pi/workspace.d.ts +53 -0
  95. package/dist/engines/pi/workspace.js +70 -0
  96. package/dist/env.d.ts +24 -0
  97. package/dist/env.js +57 -0
  98. package/dist/github.d.ts +3 -0
  99. package/dist/github.js +2 -0
  100. package/dist/host/node.d.ts +41 -0
  101. package/dist/host/node.js +50 -0
  102. package/dist/index.d.ts +2 -0
  103. package/dist/index.js +5 -0
  104. package/dist/invoke-stream.d.ts +10 -0
  105. package/dist/invoke-stream.js +26 -0
  106. package/dist/loader.d.ts +38 -0
  107. package/dist/loader.js +69 -0
  108. package/dist/log.d.ts +26 -0
  109. package/dist/log.js +51 -0
  110. package/dist/observe.d.ts +12 -0
  111. package/dist/observe.js +44 -0
  112. package/dist/pi.d.ts +16 -0
  113. package/dist/pi.js +12 -0
  114. package/dist/proxy.d.ts +9 -0
  115. package/dist/proxy.js +23 -0
  116. package/dist/runtime.d.ts +26 -0
  117. package/dist/runtime.js +29 -0
  118. package/dist/scaffold/add-channel.d.ts +46 -0
  119. package/dist/scaffold/add-channel.js +227 -0
  120. package/dist/scaffold/init.d.ts +54 -0
  121. package/dist/scaffold/init.js +249 -0
  122. package/dist/scaffold/templates/env.example +24 -0
  123. package/dist/scaffold/templates/fastagent.config.mjs +19 -0
  124. package/dist/scaffold/templates/gitignore +8 -0
  125. package/dist/scaffold/templates/gitignore.agentdir-root +5 -0
  126. package/dist/scaffold/templates/gitignore.kit +2 -0
  127. package/dist/scaffold/templates/persona.md +10 -0
  128. package/dist/scaffold/templates/skills/writing-great-skills/GLOSSARY.md +195 -0
  129. package/dist/scaffold/templates/skills/writing-great-skills/LICENSE +21 -0
  130. package/dist/scaffold/templates/skills/writing-great-skills/SKILL.md +82 -0
  131. package/dist/scaffold/templates/tools/fetch-url.ts +27 -0
  132. package/dist/scaffold/templates.d.ts +17 -0
  133. package/dist/scaffold/templates.js +56 -0
  134. package/dist/scaffold/vendor-skill.d.ts +24 -0
  135. package/dist/scaffold/vendor-skill.js +157 -0
  136. package/dist/schedule/audit.d.ts +18 -0
  137. package/dist/schedule/audit.js +57 -0
  138. package/dist/schedule/cron.d.ts +7 -0
  139. package/dist/schedule/cron.js +33 -0
  140. package/dist/schedule/discover.d.ts +17 -0
  141. package/dist/schedule/discover.js +64 -0
  142. package/dist/schedule/schedule.d.ts +35 -0
  143. package/dist/schedule/schedule.js +23 -0
  144. package/dist/schedule/scheduler.d.ts +36 -0
  145. package/dist/schedule/scheduler.js +253 -0
  146. package/dist/schedule/state.d.ts +14 -0
  147. package/dist/schedule/state.js +56 -0
  148. package/dist/schedule/wakeups.d.ts +82 -0
  149. package/dist/schedule/wakeups.js +196 -0
  150. package/dist/telegram.d.ts +2 -0
  151. package/dist/telegram.js +2 -0
  152. package/dist/tunnel.d.ts +31 -0
  153. package/dist/tunnel.js +139 -0
  154. package/dist/version.d.ts +5 -0
  155. package/dist/version.js +13 -0
  156. package/dist/workspace.d.ts +9 -0
  157. package/dist/workspace.js +45 -0
  158. package/package.json +97 -0
@@ -0,0 +1,36 @@
1
+ /**
2
+ * The scheduler: a time-trigger that fires the agent on each schedule's cron. SINGLE-PROCESS (like all
3
+ * fastagent state today) — a deployment with schedules must keep one machine running, since cron has no
4
+ * external wake-up (`deploy` enforces that). Started/stopped by the serve path (dev/start).
5
+ *
6
+ * Fire model:
7
+ * - **stable session per schedule** (`schedule:<name>`), so a schedule's turns share one continuing
8
+ * conversation persisted by the core session store; the scheduler is ZERO-touch on session storage.
9
+ * - **output is the agent's tools' job** — the scheduler only fires and logs the outcome.
10
+ * - **durability = catch up ONCE** (`state.ts`): on start, if the next instant after the last fire is
11
+ * already past (the process was down across it), fire once and advance — not once per missed slot.
12
+ * lastFired is claimed BEFORE the invoke (at-most-once per slot: a crash mid-turn won't re-fire it;
13
+ * "a digest late once" beats "twice"). Strict at-least-once (a per-turn WAL) is a later tier.
14
+ */
15
+ import { type Agent } from "../agent.ts";
16
+ import type { LoadedSchedule } from "./schedule.ts";
17
+ /** A schedule's turns share this stable session — a continuing conversation, like the telegram channel's
18
+ * per-chat session. Derived at RUNTIME from the name (never an authored field). */
19
+ export declare function scheduleSession(name: string): string;
20
+ export interface Scheduler {
21
+ /** Arm every schedule (catching up an overdue one once). Idempotent-ish: call once per process. */
22
+ start(): void;
23
+ /** Clear all armed timers. Does NOT drain an in-flight fire (SIGTERM exits mid-turn by design; the
24
+ * interrupted turn is not re-fired — lastFired was already claimed). A catch-up fire in flight has NO
25
+ * timer entry to clear either (the overdue branch fires directly, without arming), so `stop()` simply
26
+ * lets it run out or be cut by process exit — same non-drain, and its claim is already persisted. */
27
+ stop(): void;
28
+ }
29
+ export interface SchedulerOptions {
30
+ agent: Agent;
31
+ stateRoot: string;
32
+ schedules: LoadedSchedule[];
33
+ /** Injectable clock for tests; defaults to the wall clock. */
34
+ now?: () => Date;
35
+ }
36
+ export declare function createScheduler({ agent, stateRoot, schedules, now }: SchedulerOptions): Scheduler;
@@ -0,0 +1,253 @@
1
+ /**
2
+ * The scheduler: a time-trigger that fires the agent on each schedule's cron. SINGLE-PROCESS (like all
3
+ * fastagent state today) — a deployment with schedules must keep one machine running, since cron has no
4
+ * external wake-up (`deploy` enforces that). Started/stopped by the serve path (dev/start).
5
+ *
6
+ * Fire model:
7
+ * - **stable session per schedule** (`schedule:<name>`), so a schedule's turns share one continuing
8
+ * conversation persisted by the core session store; the scheduler is ZERO-touch on session storage.
9
+ * - **output is the agent's tools' job** — the scheduler only fires and logs the outcome.
10
+ * - **durability = catch up ONCE** (`state.ts`): on start, if the next instant after the last fire is
11
+ * already past (the process was down across it), fire once and advance — not once per missed slot.
12
+ * lastFired is claimed BEFORE the invoke (at-most-once per slot: a crash mid-turn won't re-fire it;
13
+ * "a digest late once" beats "twice"). Strict at-least-once (a per-turn WAL) is a later tier.
14
+ */
15
+ import { SESSION_BUSY_CODE } from "../agent.js";
16
+ import { log } from "../log.js";
17
+ import { appendRun } from "./audit.js";
18
+ import { nextRun } from "./cron.js";
19
+ import { loadFires, saveFires } from "./state.js";
20
+ import { deferWakeup, takeFirstDueWakeup } from "./wakeups.js";
21
+ /** A schedule's turns share this stable session — a continuing conversation, like the telegram channel's
22
+ * per-chat session. Derived at RUNTIME from the name (never an authored field). */
23
+ export function scheduleSession(name) {
24
+ return `schedule:${name}`;
25
+ }
26
+ // A single setTimeout maxes out at ~24.8 days and drifts over long sleeps; cap each wait so a long
27
+ // interval (or a suspended machine) re-checks against the wall clock rather than firing wildly early/late.
28
+ const MAX_WAIT_MS = 6 * 60 * 60 * 1000; // 6h
29
+ // How often to poll the agent's self-scheduled wake-ups (wakeups.ts). A wake fires within this of its
30
+ // due time — fine for "wake me in N minutes"; cheap (reads a small JSON, writes only when one is due).
31
+ const WAKEUP_POLL_MS = 30 * 1000;
32
+ export function createScheduler({ agent, stateRoot, schedules, now = () => new Date() }) {
33
+ const timers = new Map();
34
+ let wakeupTimer;
35
+ let stopped = false;
36
+ /** Drive ONE turn (a cron fire or a wake-up) and log its outcome. Total — never throws (its callers are
37
+ * void-scheduled). Output is the agent's tools' job; this only fires and logs. Returns the turn's audit
38
+ * material — `failed` (details, if it failed), the accumulated `reply` text, `ms` — plus `busy`: whether
39
+ * it failed specifically because the session was BUSY (the turn never started) — the ONLY replay-safe
40
+ * reason to re-fire a wake-up; every other outcome is terminal (side effects may have run). */
41
+ async function runTurn(label, session, prompt) {
42
+ const startedAt = Date.now();
43
+ log.info(`[schedule] ${label} firing (session=${session})`);
44
+ try {
45
+ let failed;
46
+ let busy = false;
47
+ let reply = "";
48
+ for await (const e of agent.invoke({ session }, { text: prompt })) {
49
+ if (e.type === "text")
50
+ reply += e.delta;
51
+ if (e.type === "failed") {
52
+ failed = e.details;
53
+ busy = e.code === SESSION_BUSY_CODE; // structured (SPEC §8), not a details-text match
54
+ }
55
+ }
56
+ if (failed)
57
+ log.error(`[schedule] ${label} failed (${Date.now() - startedAt}ms): ${failed}`);
58
+ else
59
+ log.info(`[schedule] ${label} completed (${Date.now() - startedAt}ms)`);
60
+ return { busy: failed !== undefined && busy, failed, reply, ms: Date.now() - startedAt };
61
+ }
62
+ catch (e) {
63
+ // invoke shouldn't throw (SPEC MUST 2 turns failures into events), but stay total regardless. A throw
64
+ // is not the busy case, so don't defer on it.
65
+ log.error(`[schedule] ${label} errored (${Date.now() - startedAt}ms): ${String(e)}`);
66
+ return { busy: false, failed: String(e), reply: "", ms: Date.now() - startedAt };
67
+ }
68
+ }
69
+ /** Fire one schedule's turn: claim the slot (persist lastFired BEFORE invoking) so a crash mid-turn
70
+ * does not re-fire this slot on restart, then run the turn. A state fault while claiming (loadFires or
71
+ * saveFires — both before the invoke, so nothing ran) THROWS to the single skip+audit boundary in
72
+ * {@link fireThenReArm}; skipping rather than firing unclaimed also avoids an infinite catch-up loop
73
+ * on restart. */
74
+ async function fire(s) {
75
+ const fires = loadFires(stateRoot);
76
+ fires[s.name] = now().toISOString();
77
+ saveFires(stateRoot, fires);
78
+ const firedAt = now().toISOString();
79
+ const r = await runTurn(s.name, scheduleSession(s.name), s.prompt);
80
+ appendRun(stateRoot, {
81
+ name: s.name,
82
+ session: scheduleSession(s.name),
83
+ firedAt,
84
+ ms: r.ms,
85
+ outcome: r.failed ? "failed" : "completed",
86
+ reply: r.failed ? undefined : r.reply,
87
+ error: r.failed,
88
+ });
89
+ }
90
+ /**
91
+ * The woken turn's prompt arrives ENVELOPED: without it the model sees its own instruction as a bare
92
+ * user message — in a chat session it may answer a "user" who said nothing — and it has no way to know
93
+ * the wake-up's id (buried in a long-past tool result), which `unwake` needs. Static cron fires stay raw
94
+ * on purpose: their prompt is the AUTHOR's instruction in a dedicated `schedule:<name>` session, where
95
+ * every turn is a fire and an envelope would only dilute it.
96
+ */
97
+ function wakeEnvelope(w) {
98
+ const tag = w.cron
99
+ ? `[wake-up ${w.id} fired — YOUR recurring self-scheduled turn (cron "${w.cron}"${w.tz ? ` ${w.tz}` : ""}), not a user message; unwake({ id: "${w.id}" }) stops it]`
100
+ : `[wake-up ${w.id} fired — YOUR self-scheduled turn, not a user message]`;
101
+ return `${tag} ${w.prompt}`;
102
+ }
103
+ /**
104
+ * Fire every due self-scheduled wake-up, ONE at a time (claim → fire → claim next) so a crash loses at
105
+ * most one occurrence. Each fires back into the session it was set in. Busy handling splits by kind: a
106
+ * ONE-SHOT that failed because the session was BUSY (`code: session_busy` — the turn never started; the
107
+ * very case "wake me in 10 min" hits while the user is still chatting) is deferred to the next poll,
108
+ * bounded, because a dropped one-shot has no "next time"; a RECURRING busy occurrence is skipped
109
+ * immediately (its claim already advanced the entry — the next occurrence comes by definition). Any
110
+ * other failure is terminal for that occurrence (the turn ran — re-running risks duplicate side effects).
111
+ */
112
+ async function pollWakeups() {
113
+ for (;;) {
114
+ if (stopped)
115
+ break; // stop() must halt an in-flight drain, like it clears the cron timers
116
+ const w = takeFirstDueWakeup(stateRoot, now());
117
+ if (!w)
118
+ break;
119
+ const label = `wake ${w.id.slice(0, 8)}`;
120
+ const firedAt = now().toISOString();
121
+ const r = await runTurn(label, w.session, wakeEnvelope(w));
122
+ // Busy handling differs by kind (busy = the turn never started — replay-safe; every other outcome is
123
+ // terminal for this occurrence, since a turn that DID start may have run side effects). ONE-SHOT: defer (bounded) — it has no "next time", dropping it
124
+ // would lose it forever. RECURRING: the claim already ADVANCED the entry to the next instant (see
125
+ // takeFirstDueWakeup), so a busy occurrence is simply SKIPPED and audited — the next one comes by
126
+ // definition, and never touching the stored entry here is what keeps unwake/cancel race-free.
127
+ let kept = false;
128
+ if (r.busy && !w.cron) {
129
+ kept = deferWakeup(stateRoot, w, new Date(now().getTime() + WAKEUP_POLL_MS));
130
+ if (kept)
131
+ log.info(`[schedule] ${label}: session busy — retrying next poll`);
132
+ else
133
+ log.error(`[schedule] ${label}: dropped after too many busy retries`);
134
+ }
135
+ else if (r.busy && w.cron) {
136
+ log.error(`[schedule] ${label}: occurrence skipped (session busy); next fires per cron`);
137
+ }
138
+ // Audit honesty: `deferred` ONLY when the same occurrence was actually re-scheduled. A busy one-shot
139
+ // dropped at the ceiling, and a busy recurring occurrence (skipped — its recurrence survives), are
140
+ // both FINAL for that occurrence → `failed`.
141
+ appendRun(stateRoot, {
142
+ name: "wake",
143
+ session: w.session,
144
+ firedAt,
145
+ ms: r.ms,
146
+ outcome: r.busy ? (kept ? "deferred" : "failed") : r.failed ? "failed" : "completed",
147
+ reply: r.failed || r.busy ? undefined : r.reply,
148
+ error: r.busy
149
+ ? kept
150
+ ? undefined
151
+ : w.cron
152
+ ? "occurrence skipped (session busy); the recurrence continues"
153
+ : "dropped after too many busy retries"
154
+ : r.failed,
155
+ });
156
+ }
157
+ }
158
+ /** Drain due wake-ups, then chain the next poll AFTER — never overlapping two drains. TOTAL: a state-IO
159
+ * fault (an unreadable store) is caught + logged and the chain continues, never a crash / a silent stop
160
+ * (this is `void`-scheduled, so an escaping throw would be an unhandled rejection). */
161
+ async function pumpWakeups() {
162
+ if (stopped)
163
+ return;
164
+ try {
165
+ await pollWakeups();
166
+ }
167
+ catch (e) {
168
+ log.error(`[schedule] wake-up poll failed (continuing next poll): ${String(e)}`);
169
+ }
170
+ if (stopped)
171
+ return;
172
+ wakeupTimer = setTimeout(() => void pumpWakeups(), WAKEUP_POLL_MS);
173
+ }
174
+ /** Arm a timer for `at`, capped so a long wait re-checks the wall clock instead of trusting one sleep. */
175
+ function arm(s, at) {
176
+ if (stopped)
177
+ return;
178
+ const delay = Math.min(Math.max(0, at.getTime() - now().getTime()), MAX_WAIT_MS);
179
+ timers.set(s.name, setTimeout(() => {
180
+ if (stopped)
181
+ return;
182
+ if (now().getTime() >= at.getTime())
183
+ void fireThenReArm(s);
184
+ else
185
+ arm(s, at); // woke early (the cap) — keep waiting for the real instant
186
+ }, delay));
187
+ }
188
+ /** Fire, then arm the NEXT run computed from now — so a slow fire never double-fires the same slot and
189
+ * missed slots collapse to the single catch-up already done. TOTAL, like pumpWakeups: this is
190
+ * void-scheduled from a timer, so an escaping throw (a state-IO fault while claiming — an unreadable
191
+ * fires.json, an unpersistable claim) would be an unhandled rejection = the WHOLE service crashing over
192
+ * one skipped fire. Log it and keep the schedule armed instead; boot-time state faults still fail `start()`
193
+ * loudly (an operator fixes those before serving). */
194
+ async function fireThenReArm(s) {
195
+ try {
196
+ await fire(s);
197
+ }
198
+ catch (e) {
199
+ // Audited too (appendRun is total, and runs.jsonl ≠ the broken state file) — a skipped fire must
200
+ // show up in `schedule history`, not only in stderr.
201
+ log.error(`[schedule] ${s.name}: fire failed (skipping this run, schedule stays armed): ${String(e)}`);
202
+ appendRun(stateRoot, {
203
+ name: s.name,
204
+ session: scheduleSession(s.name),
205
+ firedAt: now().toISOString(),
206
+ ms: 0,
207
+ outcome: "failed",
208
+ error: `run skipped — fire failed: ${String(e)}`,
209
+ });
210
+ }
211
+ const due = nextRun(s.cron, s.tz, now());
212
+ if (due)
213
+ arm(s, due);
214
+ }
215
+ return {
216
+ start() {
217
+ stopped = false;
218
+ const fires = loadFires(stateRoot);
219
+ const current = now();
220
+ for (const s of schedules) {
221
+ // Anchor on the last fire (catch-up basis), or `now` on a first-ever run so a brand-new schedule
222
+ // never back-fires before the process first booted.
223
+ const lastFired = fires[s.name];
224
+ const anchor = lastFired ? new Date(lastFired) : current;
225
+ const due = nextRun(s.cron, s.tz, anchor);
226
+ if (!due) {
227
+ log.warn(`[schedule] ${s.name}: cron "${s.cron}" will never fire again — not armed`);
228
+ continue;
229
+ }
230
+ if (due.getTime() <= current.getTime()) {
231
+ log.info(`[schedule] ${s.name}: catching up a missed run`);
232
+ void fireThenReArm(s); // overdue → fire once, then arm the next
233
+ }
234
+ else {
235
+ arm(s, due);
236
+ }
237
+ }
238
+ // Self-scheduled wake-ups: drain now (catch any overdue while the process was down), then chain the
239
+ // next poll AFTER this drain finishes — a CHAIN, not setInterval, so a wake turn longer than the poll
240
+ // interval never overlaps two drains (which would break the one-at-a-time claim→fire→claim promise).
241
+ void pumpWakeups();
242
+ },
243
+ stop() {
244
+ stopped = true;
245
+ for (const t of timers.values())
246
+ clearTimeout(t);
247
+ timers.clear();
248
+ if (wakeupTimer)
249
+ clearTimeout(wakeupTimer);
250
+ wakeupTimer = undefined;
251
+ },
252
+ };
253
+ }
@@ -0,0 +1,14 @@
1
+ /** Path of a JSON file under `<stateRoot>/schedule/`. */
2
+ export declare function scheduleFile(stateRoot: string, name: string): string;
3
+ /**
4
+ * Read a JSON state file. Missing is normal (first run → undefined); a corrupt file degrades visibly
5
+ * (warn + undefined) — schedule state is recoverable, not worth refusing to boot over; an unreadable
6
+ * file (permissions/IO) throws — a real environment fault the operator must fix, not hide behind empty
7
+ * state. The caller owns shape validation (an IO boundary).
8
+ */
9
+ export declare function readScheduleFile(path: string): unknown;
10
+ export declare function writeScheduleFile(path: string, value: unknown): void;
11
+ /** name → last-fired ISO timestamp. */
12
+ export type Fires = Record<string, string>;
13
+ export declare function loadFires(stateRoot: string): Fires;
14
+ export declare function saveFires(stateRoot: string, fires: Fires): void;
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Durable scheduler state for a SINGLE-PROCESS deployment, under `<stateRoot>/schedule/`. Small JSON
3
+ * files, atomic (tmp+rename) so a crash never leaves a torn file:
4
+ * - `fires.json` — schedule name → last-fired ISO (durability for the cron catch-up-once);
5
+ * - `wakeups.json` — the agent's pending self-scheduled one-shot wake-ups (wakeups.ts).
6
+ * The root state dir already self-ignores (`.fastagent/.gitignore`), so no per-dir .gitignore.
7
+ *
8
+ * ponytail: this atomic read/write duplicates channels/telegram/state.ts's primitive (both KB-JSON
9
+ * tmp+rename). Extract a neutral src/state.ts and have both import it when a third consumer appears.
10
+ */
11
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
12
+ import { dirname, join } from "node:path";
13
+ import { log } from "../log.js";
14
+ /** Path of a JSON file under `<stateRoot>/schedule/`. */
15
+ export function scheduleFile(stateRoot, name) {
16
+ return join(stateRoot, "schedule", `${name}.json`);
17
+ }
18
+ /**
19
+ * Read a JSON state file. Missing is normal (first run → undefined); a corrupt file degrades visibly
20
+ * (warn + undefined) — schedule state is recoverable, not worth refusing to boot over; an unreadable
21
+ * file (permissions/IO) throws — a real environment fault the operator must fix, not hide behind empty
22
+ * state. The caller owns shape validation (an IO boundary).
23
+ */
24
+ export function readScheduleFile(path) {
25
+ let raw;
26
+ try {
27
+ raw = readFileSync(path, "utf8");
28
+ }
29
+ catch (e) {
30
+ if (e.code === "ENOENT")
31
+ return undefined;
32
+ throw new Error(`schedule state ${path} is unreadable — fix permissions/disk and restart: ${String(e)}`, {
33
+ cause: e,
34
+ });
35
+ }
36
+ try {
37
+ return JSON.parse(raw);
38
+ }
39
+ catch (e) {
40
+ log.warn(`[schedule] corrupt state file ${path} — ignoring: ${String(e)}`);
41
+ return undefined;
42
+ }
43
+ }
44
+ export function writeScheduleFile(path, value) {
45
+ mkdirSync(dirname(path), { recursive: true });
46
+ const tmp = `${path}.tmp`;
47
+ writeFileSync(tmp, JSON.stringify(value));
48
+ renameSync(tmp, path);
49
+ }
50
+ export function loadFires(stateRoot) {
51
+ const v = readScheduleFile(scheduleFile(stateRoot, "fires"));
52
+ return v && typeof v === "object" && !Array.isArray(v) ? v : {};
53
+ }
54
+ export function saveFires(stateRoot, fires) {
55
+ writeScheduleFile(scheduleFile(stateRoot, "fires"), fires);
56
+ }
@@ -0,0 +1,82 @@
1
+ export interface Wakeup {
2
+ id: string;
3
+ /** The session to fire back into (the conversation the `wake` call ran in). */
4
+ session: string;
5
+ /** The instruction for the woken turn. */
6
+ prompt: string;
7
+ /** When to fire next (ISO). For a recurring wake this advances to the next cron instant on each fire. */
8
+ fireAt: string;
9
+ /** RECURRING: the cron expression (5-field). Absent = one-shot (fires once, removed). */
10
+ cron?: string;
11
+ /** IANA timezone for `cron` (default UTC). */
12
+ tz?: string;
13
+ /** ONE-SHOT ONLY: consecutive busy-defer attempts. A one-shot into a BUSY session (`code: session_busy`
14
+ * — the turn never started) is deferred, bounded by {@link MAX_WAKE_ATTEMPTS}, then dropped. A RECURRING
15
+ * occurrence never defers (this field never increments for it): a busy one is skipped on FIRST contact
16
+ * — its claim already advanced the entry, and the next occurrence comes by definition. */
17
+ attempts?: number;
18
+ }
19
+ /** The minimum delay a wake-up may be scheduled for — rejects a busy-loop (`wake in 1s`, then again…). */
20
+ export declare const MIN_WAKE_MS = 60000;
21
+ /** The cap on pending wake-ups PER SESSION — rejects one conversation's unbounded self-fan-out without
22
+ * letting it starve others' quota (a global cap would make one chatty session a DoS on everyone else's
23
+ * `wake`, since a multi-user deploy is one session per chat). */
24
+ export declare const MAX_PENDING_WAKEUPS = 20;
25
+ /** How many times a busy/transient wake is retried (deferred) before being dropped. At the ~30s poll a
26
+ * wake into an active conversation fires in the first gap between the user's turns; this generous
27
+ * ceiling (~1h) only gives up on a pathologically stuck session (then logs, operator-visible). */
28
+ export declare const MAX_WAKE_ATTEMPTS = 120;
29
+ /** The minimum gap between two consecutive fires of a RECURRING wake — stricter than the one-shot floor:
30
+ * a recurring runs forever, so a tight cron is a permanent token burner, not a one-time mistake. */
31
+ export declare const MIN_RECURRING_GAP_MS: number;
32
+ /** The current pending wake-ups (`fastagent schedule list` uses this). */
33
+ export declare function listWakeups(stateRoot: string): Wakeup[];
34
+ export type AddWakeupResult = {
35
+ ok: true;
36
+ id: string;
37
+ fireAt: string;
38
+ } | {
39
+ ok: false;
40
+ error: string;
41
+ };
42
+ /**
43
+ * Add a wake-up — one-shot (`fireAt`) or recurring (`cron`/`tz`, `fireAt` = the first instant) —
44
+ * enforcing the guardrails (min delay / min recurring gap, per-session pending cap). Returns a
45
+ * MODEL-facing error string on rejection (the `wake` tool passes it back so the model can adjust),
46
+ * never throws for a bad request.
47
+ */
48
+ export declare function addWakeup(stateRoot: string, input: {
49
+ session: string;
50
+ prompt: string;
51
+ fireAt?: Date;
52
+ cron?: string;
53
+ tz?: string;
54
+ }, now?: Date): AddWakeupResult;
55
+ /**
56
+ * Remove a wake-up by id. `session` (when given — the agent's `unwake`) must ALSO match, so a
57
+ * conversation can only cancel its OWN wake-ups; the operator's `schedule cancel` passes none.
58
+ * Returns whether anything was removed.
59
+ */
60
+ export declare function removeWakeup(stateRoot: string, id: string, session?: string): boolean;
61
+ /**
62
+ * CLAIM the FIRST due wake-up (at or before `now`) in stored order, remove + return it, or undefined if
63
+ * none is due. Stored order, not earliest-`fireAt`: the caller drains them all in one poll, so order among
64
+ * due entries doesn't matter. One at a time on purpose — the caller fires it before claiming the next, so a
65
+ * crash loses AT MOST one wake-up (claiming a whole due batch up front would lose the lot). Claim-before-fire
66
+ * = a turn EXECUTES at most once per claim; a wake that couldn't start (busy session) is re-claimed via
67
+ * {@link deferWakeup}.
68
+ */
69
+ export declare function takeFirstDueWakeup(stateRoot: string, now?: Date): Wakeup | undefined;
70
+ /**
71
+ * Re-schedule a ONE-SHOT wake whose fire failed TRANSIENTLY (its session was busy — a channel is mid-turn
72
+ * on it), deferred to `fireAt`. Returns false (dropped, NOT re-added) once attempts exceed
73
+ * {@link MAX_WAKE_ATTEMPTS} — a one-shot has no "next time", so a busy-skip that just dropped it would lose
74
+ * it forever; bounded retries, then give up visibly. A RECURRING occurrence is never deferred: its claim
75
+ * already advanced the entry, and its next occurrence comes by definition — a busy one is skipped + audited.
76
+ *
77
+ * Known residual: between the claim (removed from the store) and this re-add there is a microtask-scale
78
+ * window (the busy reject yields before any harness IO) where an `unwake` for this id reports "not found"
79
+ * and the defer then resurrects it — the one-shot cousin of the recurring resurrection the advance-in-place
80
+ * claim eliminated. Accepted: closing it needs a claim-lease with expiry, disproportionate to the window.
81
+ */
82
+ export declare function deferWakeup(stateRoot: string, w: Wakeup, fireAt: Date): boolean;
@@ -0,0 +1,196 @@
1
+ /**
2
+ * The agent's self-scheduled wake-ups — the SECOND producer of scheduled invocations (the first is the
3
+ * author's `schedules/` files). The `wake` tool writes one here; the scheduler polls and fires it back
4
+ * into the SAME session so the agent resumes the conversation it was in. Persisted
5
+ * (`<stateRoot>/schedule/wakeups.json`) so a wake survives a restart. ONE-SHOT (`in`) or RECURRING
6
+ * (`cron` — the entry keeps its id; each fire re-arms `fireAt` to the next cron instant).
7
+ *
8
+ * Guardrails (self-scheduling is a real runaway surface): a minimum one-shot delay (no busy-looping), a
9
+ * minimum RECURRING gap (a high-frequency recurring burns tokens forever — stricter than one-shot), a
10
+ * per-session pending cap (no fan-out, no cross-session quota DoS; a recurring occupies a slot for its
11
+ * whole life), and the agent's `unwake` / the operator's `schedule cancel` as the kill switches.
12
+ */
13
+ import { randomUUID } from "node:crypto";
14
+ import { log } from "../log.js";
15
+ import { cronError, nextRun } from "./cron.js";
16
+ import { readScheduleFile, scheduleFile, writeScheduleFile } from "./state.js";
17
+ /** The minimum delay a wake-up may be scheduled for — rejects a busy-loop (`wake in 1s`, then again…). */
18
+ export const MIN_WAKE_MS = 60_000; // 1 minute
19
+ /** The cap on pending wake-ups PER SESSION — rejects one conversation's unbounded self-fan-out without
20
+ * letting it starve others' quota (a global cap would make one chatty session a DoS on everyone else's
21
+ * `wake`, since a multi-user deploy is one session per chat). */
22
+ export const MAX_PENDING_WAKEUPS = 20;
23
+ /** How many times a busy/transient wake is retried (deferred) before being dropped. At the ~30s poll a
24
+ * wake into an active conversation fires in the first gap between the user's turns; this generous
25
+ * ceiling (~1h) only gives up on a pathologically stuck session (then logs, operator-visible). */
26
+ export const MAX_WAKE_ATTEMPTS = 120;
27
+ /** The minimum gap between two consecutive fires of a RECURRING wake — stricter than the one-shot floor:
28
+ * a recurring runs forever, so a tight cron is a permanent token burner, not a one-time mistake. */
29
+ export const MIN_RECURRING_GAP_MS = 10 * 60_000; // 10 minutes
30
+ /** A stored entry is a real Wakeup: the fields are present and `fireAt` is a parseable date. A malformed
31
+ * one (bad/missing fireAt) would compare NaN <= now = false forever — never due, never cleared, but still
32
+ * eating the pending quota. So validate at this IO boundary and drop it (warn), like a corrupt file. */
33
+ function isWakeup(e) {
34
+ if (!e || typeof e !== "object")
35
+ return false;
36
+ const w = e;
37
+ return (typeof w.id === "string" &&
38
+ typeof w.session === "string" &&
39
+ typeof w.prompt === "string" &&
40
+ typeof w.fireAt === "string" &&
41
+ !Number.isNaN(Date.parse(w.fireAt)) &&
42
+ (w.attempts === undefined || typeof w.attempts === "number") && // a non-number would make deferWakeup's count NaN
43
+ (w.tz === undefined || typeof w.tz === "string") &&
44
+ // A stored cron must PARSE: a bad one would throw inside the claim's nextRun — before the advance-save
45
+ // — so its fireAt never moves, it stays first-due forever, and every wakeup behind it starves (a poison
46
+ // pill worse than the bad-fireAt zombie this validator already guards).
47
+ (w.cron === undefined ||
48
+ (typeof w.cron === "string" && cronError(w.cron, typeof w.tz === "string" ? w.tz : undefined) === undefined)));
49
+ }
50
+ function load(stateRoot) {
51
+ const v = readScheduleFile(scheduleFile(stateRoot, "wakeups"));
52
+ if (v === undefined)
53
+ return []; // absent (first run) or a corrupt file readScheduleFile already warned on
54
+ if (!Array.isArray(v)) {
55
+ // Valid JSON but the wrong SHAPE (an object, say) — fail-visible, don't silently drop the whole store
56
+ // (a per-entry malformed drop already warns; a whole-store shape error must too).
57
+ log.warn(`[schedule] wakeups store is not an array — ignoring it: ${JSON.stringify(v).slice(0, 80)}`);
58
+ return [];
59
+ }
60
+ const valid = [];
61
+ for (const e of v) {
62
+ if (isWakeup(e))
63
+ valid.push(e);
64
+ else
65
+ log.warn(`[schedule] dropping a malformed wake-up entry: ${JSON.stringify(e)}`);
66
+ }
67
+ return valid;
68
+ }
69
+ function save(stateRoot, wakeups) {
70
+ writeScheduleFile(scheduleFile(stateRoot, "wakeups"), wakeups);
71
+ }
72
+ /** The current pending wake-ups (`fastagent schedule list` uses this). */
73
+ export function listWakeups(stateRoot) {
74
+ return load(stateRoot);
75
+ }
76
+ /**
77
+ * Add a wake-up — one-shot (`fireAt`) or recurring (`cron`/`tz`, `fireAt` = the first instant) —
78
+ * enforcing the guardrails (min delay / min recurring gap, per-session pending cap). Returns a
79
+ * MODEL-facing error string on rejection (the `wake` tool passes it back so the model can adjust),
80
+ * never throws for a bad request.
81
+ */
82
+ export function addWakeup(stateRoot, input, now = new Date()) {
83
+ let fireAtDate;
84
+ if (input.cron !== undefined) {
85
+ const err = cronError(input.cron, input.tz);
86
+ if (err)
87
+ return { ok: false, error: `invalid cron/tz: ${err}` };
88
+ // A recurring wake runs FOREVER — gate its frequency harder than a one-shot: the gap between the next
89
+ // two instants must be ≥ the recurring floor. (First-pair heuristic; an irregular cron with one tight
90
+ // pair can slip through — an accepted ceiling, the per-session cap still bounds total load.)
91
+ const first = nextRun(input.cron, input.tz, now);
92
+ const second = first && nextRun(input.cron, input.tz, first);
93
+ if (!first || !second)
94
+ return { ok: false, error: "this cron never fires (or fires only once) — use `in` for a one-shot." };
95
+ if (second.getTime() - first.getTime() < MIN_RECURRING_GAP_MS) {
96
+ return {
97
+ ok: false,
98
+ error: `too frequent — a recurring wake must fire at most every ${MIN_RECURRING_GAP_MS / 60_000} minutes.`,
99
+ };
100
+ }
101
+ fireAtDate = first; // DERIVED from the cron — a caller-passed fireAt can't disagree with the schedule
102
+ }
103
+ else {
104
+ if (!input.fireAt)
105
+ return { ok: false, error: "a one-shot wake needs its fire time (`in`)." };
106
+ if (input.fireAt.getTime() < now.getTime() + MIN_WAKE_MS) {
107
+ return { ok: false, error: `too soon — the minimum wake delay is ${MIN_WAKE_MS / 1000}s.` };
108
+ }
109
+ fireAtDate = input.fireAt;
110
+ }
111
+ const all = load(stateRoot);
112
+ const mine = all.filter((w) => w.session === input.session);
113
+ if (mine.length >= MAX_PENDING_WAKEUPS) {
114
+ // List what's pending WITH a prompt preview: "unwake one" is only actionable if the model has the
115
+ // ids AND can choose by meaning — both were returned when set, but that may be buried far back in
116
+ // the conversation.
117
+ const pending = mine
118
+ .map((w) => `${w.id}${w.cron ? ` (recurring "${w.cron}")` : ""} at ${w.fireAt}: ${w.prompt.length > 60 ? `${w.prompt.slice(0, 60)}…` : w.prompt}`)
119
+ .join("; ");
120
+ return {
121
+ ok: false,
122
+ error: `too many pending wake-ups for this conversation (${MAX_PENDING_WAKEUPS}) — wait for some to fire, or unwake one. Pending: ${pending}`,
123
+ };
124
+ }
125
+ const id = randomUUID();
126
+ const fireAt = fireAtDate.toISOString();
127
+ save(stateRoot, [
128
+ ...all,
129
+ { id, session: input.session, prompt: input.prompt, fireAt, cron: input.cron, tz: input.tz },
130
+ ]);
131
+ return { ok: true, id, fireAt };
132
+ }
133
+ /**
134
+ * Remove a wake-up by id. `session` (when given — the agent's `unwake`) must ALSO match, so a
135
+ * conversation can only cancel its OWN wake-ups; the operator's `schedule cancel` passes none.
136
+ * Returns whether anything was removed.
137
+ */
138
+ export function removeWakeup(stateRoot, id, session) {
139
+ const all = load(stateRoot);
140
+ const kept = all.filter((w) => !(w.id === id && (session === undefined || w.session === session)));
141
+ if (kept.length === all.length)
142
+ return false;
143
+ save(stateRoot, kept);
144
+ return true;
145
+ }
146
+ /**
147
+ * CLAIM the FIRST due wake-up (at or before `now`) in stored order, remove + return it, or undefined if
148
+ * none is due. Stored order, not earliest-`fireAt`: the caller drains them all in one poll, so order among
149
+ * due entries doesn't matter. One at a time on purpose — the caller fires it before claiming the next, so a
150
+ * crash loses AT MOST one wake-up (claiming a whole due batch up front would lose the lot). Claim-before-fire
151
+ * = a turn EXECUTES at most once per claim; a wake that couldn't start (busy session) is re-claimed via
152
+ * {@link deferWakeup}.
153
+ */
154
+ export function takeFirstDueWakeup(stateRoot, now = new Date()) {
155
+ const all = load(stateRoot);
156
+ const idx = all.findIndex((w) => new Date(w.fireAt).getTime() <= now.getTime());
157
+ if (idx === -1)
158
+ return undefined;
159
+ const w = all[idx];
160
+ if (w.cron !== undefined) {
161
+ // RECURRING claim = ADVANCE IN PLACE: the entry STAYS in the store with fireAt pushed to the next cron
162
+ // instant (attempts cleared). So (a) `unwake`/`schedule cancel` work at ANY moment — including inside
163
+ // the woken turn itself, the documented way to stop a done recurring job (a remove+re-add claim would
164
+ // resurrect what that turn just cancelled); and (b) a crash mid-fire loses at most ONE occurrence,
165
+ // never the recurrence — matching the static cron schedules' claim semantics.
166
+ const next = nextRun(w.cron, w.tz, now);
167
+ if (next)
168
+ all[idx] = { ...w, fireAt: next.toISOString(), attempts: undefined };
169
+ else
170
+ all.splice(idx, 1); // the cron has no next instant — this is its final occurrence
171
+ save(stateRoot, all);
172
+ return { ...w }; // THIS occurrence (original fireAt); the store already holds the next
173
+ }
174
+ all.splice(idx, 1);
175
+ save(stateRoot, all);
176
+ return w;
177
+ }
178
+ /**
179
+ * Re-schedule a ONE-SHOT wake whose fire failed TRANSIENTLY (its session was busy — a channel is mid-turn
180
+ * on it), deferred to `fireAt`. Returns false (dropped, NOT re-added) once attempts exceed
181
+ * {@link MAX_WAKE_ATTEMPTS} — a one-shot has no "next time", so a busy-skip that just dropped it would lose
182
+ * it forever; bounded retries, then give up visibly. A RECURRING occurrence is never deferred: its claim
183
+ * already advanced the entry, and its next occurrence comes by definition — a busy one is skipped + audited.
184
+ *
185
+ * Known residual: between the claim (removed from the store) and this re-add there is a microtask-scale
186
+ * window (the busy reject yields before any harness IO) where an `unwake` for this id reports "not found"
187
+ * and the defer then resurrects it — the one-shot cousin of the recurring resurrection the advance-in-place
188
+ * claim eliminated. Accepted: closing it needs a claim-lease with expiry, disproportionate to the window.
189
+ */
190
+ export function deferWakeup(stateRoot, w, fireAt) {
191
+ const attempts = (w.attempts ?? 0) + 1;
192
+ if (attempts > MAX_WAKE_ATTEMPTS)
193
+ return false;
194
+ save(stateRoot, [...load(stateRoot), { ...w, fireAt: fireAt.toISOString(), attempts }]);
195
+ return true;
196
+ }
@@ -0,0 +1,2 @@
1
+ /** `@fastagent-sh/fastagent/telegram` — the Telegram bot channel subpath export, kept off the root surface. */
2
+ export { telegramChannel, defaultTelegramRoute, telegramEnvelope, type TelegramChannelOptions, type TelegramUpdate, type TelegramMessage, type TelegramRoute, type TelegramFailure, } from "./channels/telegram/telegram.ts";
@@ -0,0 +1,2 @@
1
+ /** `@fastagent-sh/fastagent/telegram` — the Telegram bot channel subpath export, kept off the root surface. */
2
+ export { telegramChannel, defaultTelegramRoute, telegramEnvelope, } from "./channels/telegram/telegram.js";