@fastagent-sh/fastagent 0.18.0 → 0.20.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 (222) hide show
  1. package/README.md +25 -7
  2. package/dist/agent.d.ts +1 -1
  3. package/dist/agent.js +1 -1
  4. package/dist/atomic-write.d.ts +23 -0
  5. package/dist/atomic-write.js +53 -0
  6. package/dist/{host/node.d.ts → channel.d.ts} +24 -28
  7. package/dist/channel.js +1 -0
  8. package/dist/channels/agentcore-limits.d.ts +9 -0
  9. package/dist/channels/agentcore-limits.js +9 -0
  10. package/dist/channels/agentcore-service.d.ts +55 -0
  11. package/dist/channels/agentcore-service.js +107 -0
  12. package/dist/channels/agentcore.d.ts +22 -20
  13. package/dist/channels/agentcore.js +20 -11
  14. package/dist/channels/busy.d.ts +1 -1
  15. package/dist/channels/busy.js +1 -1
  16. package/dist/channels/control.d.ts +55 -7
  17. package/dist/channels/control.js +312 -67
  18. package/dist/channels/discover.d.ts +59 -0
  19. package/dist/{engines/pi/channel.js → channels/discover.js} +19 -34
  20. package/dist/channels/feishu/card.js +1 -1
  21. package/dist/channels/feishu/context-buffer.d.ts +1 -1
  22. package/dist/channels/feishu/context-buffer.js +4 -4
  23. package/dist/channels/feishu/feishu-api.js +6 -9
  24. package/dist/channels/feishu/feishu.d.ts +1 -1
  25. package/dist/channels/feishu/feishu.js +41 -24
  26. package/dist/channels/feishu/invoke-turn.d.ts +1 -1
  27. package/dist/channels/feishu/invoke-turn.js +3 -4
  28. package/dist/channels/feishu/preview.d.ts +1 -1
  29. package/dist/channels/feishu/preview.js +7 -3
  30. package/dist/channels/feishu/ws-ingress.d.ts +1 -1
  31. package/dist/channels/github/github.d.ts +1 -1
  32. package/dist/channels/http.d.ts +11 -7
  33. package/dist/channels/http.js +1 -109
  34. package/dist/channels/kit/attachment-path.d.ts +12 -0
  35. package/dist/channels/kit/attachment-path.js +43 -0
  36. package/dist/channels/{context-buffer.d.ts → kit/context-buffer.d.ts} +9 -0
  37. package/dist/channels/{context-buffer.js → kit/context-buffer.js} +13 -2
  38. package/dist/channels/{invoke-turn-kit.d.ts → kit/invoke-turn-kit.d.ts} +14 -3
  39. package/dist/channels/{invoke-turn-kit.js → kit/invoke-turn-kit.js} +17 -6
  40. package/dist/channels/{preview-kit.d.ts → kit/preview-kit.d.ts} +3 -1
  41. package/dist/channels/{seen.js → kit/seen.js} +1 -1
  42. package/dist/channels/kit/signature.d.ts +12 -0
  43. package/dist/channels/kit/signature.js +17 -0
  44. package/dist/channels/{state.js → kit/state.js} +4 -7
  45. package/dist/channels/{stop-command.d.ts → kit/stop-command.d.ts} +1 -1
  46. package/dist/channels/{stop-command.js → kit/stop-command.js} +5 -5
  47. package/dist/channels/{tasks.d.ts → kit/tasks.d.ts} +1 -1
  48. package/dist/channels/{tasks.js → kit/tasks.js} +11 -5
  49. package/dist/channels/{thread-participants.js → kit/thread-participants.js} +1 -1
  50. package/dist/channels/{turn-queue.js → kit/turn-queue.js} +2 -2
  51. package/dist/channels/{turn-store.d.ts → kit/turn-store.d.ts} +19 -3
  52. package/dist/channels/{turn-store.js → kit/turn-store.js} +30 -3
  53. package/dist/channels/lark/lark.d.ts +1 -1
  54. package/dist/channels/serve.d.ts +73 -0
  55. package/dist/channels/serve.js +243 -0
  56. package/dist/channels/slack/bot-auth.js +3 -14
  57. package/dist/channels/slack/context-buffer.d.ts +2 -2
  58. package/dist/channels/slack/context-buffer.js +2 -2
  59. package/dist/channels/slack/invoke-turn.d.ts +2 -2
  60. package/dist/channels/slack/invoke-turn.js +13 -6
  61. package/dist/channels/slack/onboard.js +5 -5
  62. package/dist/channels/slack/onboarding-state.d.ts +7 -3
  63. package/dist/channels/slack/onboarding-state.js +13 -20
  64. package/dist/channels/slack/parse.js +2 -2
  65. package/dist/channels/slack/preview.d.ts +1 -1
  66. package/dist/channels/slack/preview.js +11 -3
  67. package/dist/channels/slack/register-webhook.js +1 -1
  68. package/dist/channels/slack/scaffold/slack-send.ts +2 -17
  69. package/dist/channels/slack/slack-api.js +23 -55
  70. package/dist/channels/slack/slack.d.ts +1 -1
  71. package/dist/channels/slack/slack.js +20 -20
  72. package/dist/channels/slack/welcomed.js +1 -1
  73. package/dist/channels/telegram/context-buffer.d.ts +2 -2
  74. package/dist/channels/telegram/context-buffer.js +2 -2
  75. package/dist/channels/telegram/invoke-turn.d.ts +1 -1
  76. package/dist/channels/telegram/invoke-turn.js +1 -1
  77. package/dist/channels/telegram/parse.d.ts +21 -1
  78. package/dist/channels/telegram/parse.js +67 -13
  79. package/dist/channels/telegram/preview.d.ts +1 -1
  80. package/dist/channels/telegram/preview.js +6 -2
  81. package/dist/channels/telegram/scaffold/channel.ts +7 -3
  82. package/dist/channels/telegram/telegram-api.js +5 -6
  83. package/dist/channels/telegram/telegram.d.ts +3 -3
  84. package/dist/channels/telegram/telegram.js +60 -72
  85. package/dist/channels/telegram/turn-store.d.ts +2 -2
  86. package/dist/channels/telegram/turn-store.js +2 -2
  87. package/dist/cli/add-slack.js +6 -5
  88. package/dist/cli/commands/add.js +1 -5
  89. package/dist/cli/commands/attach.d.ts +2 -4
  90. package/dist/cli/commands/attach.js +17 -15
  91. package/dist/cli/commands/deploy.d.ts +50 -5
  92. package/dist/cli/commands/deploy.js +183 -134
  93. package/dist/cli/commands/dev.js +25 -40
  94. package/dist/cli/commands/fire.js +1 -1
  95. package/dist/cli/commands/info.js +7 -5
  96. package/dist/cli/commands/init.js +1 -2
  97. package/dist/cli/commands/schedule.js +1 -1
  98. package/dist/cli/commands/start.js +80 -121
  99. package/dist/cli/commands/tool.js +5 -7
  100. package/dist/cli/program.js +1 -1
  101. package/dist/cli/serve.d.ts +22 -70
  102. package/dist/cli/serve.js +66 -257
  103. package/dist/cli/shared.d.ts +40 -10
  104. package/dist/cli/shared.js +53 -6
  105. package/dist/core.d.ts +3 -5
  106. package/dist/core.js +7 -5
  107. package/dist/deploy/agentcore/logs.d.ts +2 -2
  108. package/dist/deploy/agentcore/logs.js +2 -2
  109. package/dist/deploy/agentcore/plan.d.ts +12 -10
  110. package/dist/deploy/agentcore/plan.js +39 -31
  111. package/dist/deploy/agentcore/run.d.ts +5 -4
  112. package/dist/deploy/agentcore/run.js +13 -36
  113. package/dist/deploy/channel-ingress.d.ts +73 -0
  114. package/dist/deploy/channel-ingress.js +101 -0
  115. package/dist/deploy/docker/plan.d.ts +3 -7
  116. package/dist/deploy/docker/plan.js +4 -15
  117. package/dist/deploy/docker/run.d.ts +18 -1
  118. package/dist/deploy/docker/run.js +10 -0
  119. package/dist/deploy/fly/plan.d.ts +4 -5
  120. package/dist/deploy/fly/plan.js +7 -22
  121. package/dist/deploy/fly/run.d.ts +9 -23
  122. package/dist/deploy/fly/run.js +32 -68
  123. package/dist/deploy/preflight.d.ts +6 -7
  124. package/dist/deploy/preflight.js +27 -23
  125. package/dist/deploy/railway/plan.d.ts +4 -5
  126. package/dist/deploy/railway/plan.js +11 -26
  127. package/dist/deploy/railway/run.d.ts +9 -9
  128. package/dist/deploy/railway/run.js +40 -40
  129. package/dist/deploy/runner.js +22 -2
  130. package/dist/deploy/secrets.d.ts +18 -9
  131. package/dist/deploy/secrets.js +64 -12
  132. package/dist/dev-supervisor.js +11 -6
  133. package/dist/engines/pi/agent-session-factory.d.ts +104 -0
  134. package/dist/engines/pi/agent-session-factory.js +295 -0
  135. package/dist/engines/pi/auth.js +43 -43
  136. package/dist/engines/pi/config.d.ts +5 -5
  137. package/dist/engines/pi/config.js +1 -1
  138. package/dist/engines/pi/create.d.ts +67 -42
  139. package/dist/engines/pi/create.js +142 -72
  140. package/dist/engines/pi/definition.d.ts +42 -8
  141. package/dist/engines/pi/definition.js +114 -11
  142. package/dist/engines/pi/invoke-session.d.ts +22 -19
  143. package/dist/engines/pi/invoke-session.js +286 -100
  144. package/dist/engines/pi/login.js +39 -20
  145. package/dist/engines/pi/models.d.ts +14 -0
  146. package/dist/engines/pi/models.js +9 -1
  147. package/dist/engines/pi/open.d.ts +10 -8
  148. package/dist/engines/pi/open.js +21 -11
  149. package/dist/engines/pi/report.d.ts +0 -4
  150. package/dist/engines/pi/report.js +0 -7
  151. package/dist/engines/pi/search-tools.js +1 -1
  152. package/dist/engines/pi/service.d.ts +23 -0
  153. package/dist/engines/pi/service.js +27 -0
  154. package/dist/engines/pi/session-builder.js +87 -118
  155. package/dist/engines/pi/session-control.d.ts +28 -14
  156. package/dist/engines/pi/session-control.js +570 -438
  157. package/dist/engines/pi/session-inheritance.d.ts +30 -0
  158. package/dist/engines/pi/session-inheritance.js +261 -0
  159. package/dist/engines/pi/session-markers.d.ts +48 -0
  160. package/dist/engines/pi/session-markers.js +59 -0
  161. package/dist/engines/pi/session-settings.d.ts +15 -3
  162. package/dist/engines/pi/session-settings.js +22 -1
  163. package/dist/engines/pi/session-store.d.ts +140 -0
  164. package/dist/engines/pi/session-store.js +666 -0
  165. package/dist/engines/pi/tool-context.d.ts +36 -13
  166. package/dist/engines/pi/tool-context.js +51 -4
  167. package/dist/engines/pi/tool.d.ts +5 -5
  168. package/dist/engines/pi/tool.js +3 -3
  169. package/dist/engines/pi/turn-kit.d.ts +32 -6
  170. package/dist/engines/pi/turn-kit.js +28 -1
  171. package/dist/engines/pi/wake-tool.js +1 -1
  172. package/dist/env.js +1 -2
  173. package/dist/index.d.ts +1 -0
  174. package/dist/index.js +1 -0
  175. package/dist/loader.d.ts +49 -7
  176. package/dist/loader.js +79 -18
  177. package/dist/log.d.ts +3 -0
  178. package/dist/log.js +5 -1
  179. package/dist/node.d.ts +16 -0
  180. package/dist/node.js +20 -0
  181. package/dist/paths.d.ts +26 -0
  182. package/dist/paths.js +37 -2
  183. package/dist/pi.d.ts +14 -8
  184. package/dist/pi.js +6 -5
  185. package/dist/scaffold/add-channel.js +2 -2
  186. package/dist/scaffold/init.js +7 -2
  187. package/dist/scaffold/templates/persona.md +2 -2
  188. package/dist/scaffold/templates/tools/fetch-url.ts +0 -2
  189. package/dist/schedule/discover.js +3 -15
  190. package/dist/schedule/state.js +4 -9
  191. package/dist/schedule/wake-alarm.d.ts +13 -7
  192. package/dist/schedule/wake-alarm.js +88 -46
  193. package/dist/schedule/wakeups.d.ts +2 -2
  194. package/dist/schedule/wakeups.js +11 -8
  195. package/dist/service.d.ts +157 -0
  196. package/dist/service.js +376 -0
  197. package/dist/session-remote.d.ts +8 -3
  198. package/dist/session-remote.js +162 -92
  199. package/dist/session.d.ts +229 -93
  200. package/dist/session.js +61 -23
  201. package/dist/telegram.d.ts +1 -1
  202. package/dist/telegram.js +1 -1
  203. package/dist/tunnel.d.ts +18 -3
  204. package/dist/tunnel.js +32 -40
  205. package/package.json +14 -5
  206. package/dist/engines/pi/channel.d.ts +0 -37
  207. package/dist/engines/pi/harness.d.ts +0 -115
  208. package/dist/engines/pi/harness.js +0 -154
  209. package/dist/engines/pi/invoke.d.ts +0 -60
  210. package/dist/engines/pi/invoke.js +0 -459
  211. package/dist/engines/pi/read-image.d.ts +0 -4
  212. package/dist/engines/pi/read-image.js +0 -62
  213. package/dist/engines/pi/sessions.d.ts +0 -60
  214. package/dist/engines/pi/sessions.js +0 -464
  215. package/dist/host/node.js +0 -52
  216. /package/dist/channels/{preview-kit.js → kit/preview-kit.js} +0 -0
  217. /package/dist/channels/{seen.d.ts → kit/seen.d.ts} +0 -0
  218. /package/dist/channels/{state.d.ts → kit/state.d.ts} +0 -0
  219. /package/dist/channels/{text.d.ts → kit/text.d.ts} +0 -0
  220. /package/dist/channels/{text.js → kit/text.js} +0 -0
  221. /package/dist/channels/{thread-participants.d.ts → kit/thread-participants.d.ts} +0 -0
  222. /package/dist/channels/{turn-queue.d.ts → kit/turn-queue.d.ts} +0 -0
@@ -4,7 +4,7 @@
4
4
  * un-summoned messages per conversation "place", kept under a char budget and folded into the next
5
5
  * answered turn in that place, so a summoned agent has the discussion it didn't see turn-by-turn.
6
6
  *
7
- * Channel-neutral and generic over the entry shape (like ../turn-store.ts): the channel supplies its
7
+ * Channel-neutral and generic over the entry shape (like ../kit/turn-store.ts): the channel supplies its
8
8
  * entry type, the shape validator (state files are an IO boundary — valid JSON of the WRONG shape
9
9
  * must degrade exactly like a corrupt file: warn + empty, never flow in as trusted data), the
10
10
  * fold-line renderer, and its log label. What stays per channel: the entry type itself, place-key
@@ -21,7 +21,7 @@
21
21
  * message that arrives while the turn runs survives for the next answered turn (a whole-bucket
22
22
  * delete would lose it).
23
23
  */
24
- import { log } from "../log.js";
24
+ import { log } from "../../log.js";
25
25
  import { loadStateFile, saveStateFile } from "./state.js";
26
26
  /** Char budget for the per-place buffer — bounds the cost of folding it into a prompt; when exceeded
27
27
  * the OLDEST un-summoned messages are dropped (not a time window: a quiet group keeps its
@@ -39,6 +39,17 @@ export const BUFFER_LINE_MAX_CHARS = 280;
39
39
  * note, so the model never sees an attachment reference it silently cannot open. Shared policy:
40
40
  * each channel's attachment collector caps against this. */
41
41
  export const BUFFER_ATTACH_MAX = 3;
42
+ /**
43
+ * The folded discussion as it reaches the model — the prompt block, or nothing when the buffer is
44
+ * empty. One renderer for every channel: what the agent is told about un-summoned discussion should
45
+ * not depend on which chat platform delivered it, and three copies of the literal is how that drifts.
46
+ *
47
+ * A channel that folds a SECOND source (feishu's originating room) labels that one itself — it is a
48
+ * different claim about a different place, not this block with another name.
49
+ */
50
+ export function discussionBlock(text) {
51
+ return text ? `[recent group discussion:\n${text}\n]\n\n` : "";
52
+ }
42
53
  export function createContextBuffer(options) {
43
54
  const { path, label, isEntry, line } = options;
44
55
  const load = () => {
@@ -7,10 +7,14 @@
7
7
  * - the prompt-suffix wording: {@link attachedFilesManifest}, {@link backgroundImagesManifest},
8
8
  * {@link missingAttachmentsNote}, {@link attributedFileName}.
9
9
  *
10
+ * None of it asks what the agent can DO with an attachment. A channel resolves platform resources and
11
+ * states what it found; deciding whether to open a file is the agent's, and one assembled without a
12
+ * file tool answers that it cannot — visibly, at the moment it is asked.
13
+ *
10
14
  * Attachment RESOLUTION stays per channel — the platform resource models (Bot API file_ids,
11
15
  * message-scoped Feishu keys, Slack file objects) are real differences.
12
16
  */
13
- import { type Agent, type AgentEvent, type Prompt, type Scope } from "../agent.ts";
17
+ import { type Agent, type AgentEvent, type Prompt, type Scope } from "../../agent.ts";
14
18
  /** How the busy-wait paces: retry the invoke every `delayMs` while the session's lease is held by an
15
19
  * EXTERNAL turn (a self-scheduled wake, a concurrent embedder invoke), up to `maxWaitMs` total. The
16
20
  * channel's own turns never collide (the turn-queue serializes per session), so a busy reject here is
@@ -49,8 +53,15 @@ export interface ManifestFile {
49
53
  size: number;
50
54
  path: string;
51
55
  }
52
- /** The downloaded-file manifest appended to the prompt — the agent reads the paths with its tools.
53
- * Empty input renders nothing. */
56
+ /**
57
+ * The downloaded-file manifest appended to the prompt: name, size, path. Empty input renders nothing.
58
+ *
59
+ * It STATES, it does not instruct. The earlier wording ("read them with your tools") was an
60
+ * assumption about the reader, and an assumption has to be verified — which is where a capability
61
+ * flag threaded through eight files came from. An agent with a file tool decides for itself whether
62
+ * to open one, and how much of it; an agent without one says so. Neither needs this line to have
63
+ * guessed first.
64
+ */
54
65
  export declare function attachedFilesManifest(files: readonly ManifestFile[]): string;
55
66
  /** Decorate a background file's display name with its attribution ("the file Bob sent" resolves),
56
67
  * the way the fold attributes text lines. */
@@ -7,12 +7,16 @@
7
7
  * - the prompt-suffix wording: {@link attachedFilesManifest}, {@link backgroundImagesManifest},
8
8
  * {@link missingAttachmentsNote}, {@link attributedFileName}.
9
9
  *
10
+ * None of it asks what the agent can DO with an attachment. A channel resolves platform resources and
11
+ * states what it found; deciding whether to open a file is the agent's, and one assembled without a
12
+ * file tool answers that it cannot — visibly, at the moment it is asked.
13
+ *
10
14
  * Attachment RESOLUTION stays per channel — the platform resource models (Bot API file_ids,
11
15
  * message-scoped Feishu keys, Slack file objects) are real differences.
12
16
  */
13
- import { SESSION_BUSY_CODE } from "../agent.js";
14
- import { log } from "../log.js";
15
- // Each retry is a lease-check-level reject (tryAcquire runs before harness assembly) — waiting is nearly
17
+ import { SESSION_BUSY_CODE } from "../../agent.js";
18
+ import { log } from "../../log.js";
19
+ // Each retry is a lease-check-level reject (tryAcquire runs before the session is bound) — waiting is nearly
16
20
  // free, and the loop exits within one delay of the holder finishing. So the cap is sized to outlast a
17
21
  // real tool-using wake turn (minutes), not to be short: 10 min. CEILING: a holder that runs longer than
18
22
  // this still surfaces the busy error to the user — the bound exists so a stuck lease can't hang a chat
@@ -59,11 +63,18 @@ scope, prompt, options) {
59
63
  await new Promise((r) => setTimeout(r, busyRetry.delayMs));
60
64
  }
61
65
  }
62
- /** The downloaded-file manifest appended to the prompt — the agent reads the paths with its tools.
63
- * Empty input renders nothing. */
66
+ /**
67
+ * The downloaded-file manifest appended to the prompt: name, size, path. Empty input renders nothing.
68
+ *
69
+ * It STATES, it does not instruct. The earlier wording ("read them with your tools") was an
70
+ * assumption about the reader, and an assumption has to be verified — which is where a capability
71
+ * flag threaded through eight files came from. An agent with a file tool decides for itself whether
72
+ * to open one, and how much of it; an agent without one says so. Neither needs this line to have
73
+ * guessed first.
74
+ */
64
75
  export function attachedFilesManifest(files) {
65
76
  return files.length
66
- ? `\n\n[attached files — read them with your tools:\n${files.map((f) => `- ${f.name} (${f.size} bytes) → ${f.path}`).join("\n")}\n]`
77
+ ? `\n\n[attached files:\n${files.map((f) => `- ${f.name} (${f.size} bytes) → ${f.path}`).join("\n")}\n]`
67
78
  : "";
68
79
  }
69
80
  /** Decorate a background file's display name with its attribution ("the file Bob sent" resolves),
@@ -8,11 +8,13 @@
8
8
  * everything platform-independent lives here, so a new event type or a wording change lands in ONE
9
9
  * place instead of one hunk per channel.
10
10
  */
11
- import type { AgentEvent, Json } from "../agent.ts";
11
+ import type { AgentEvent, Json } from "../../agent.ts";
12
12
  /** A terminal failure, as a channel hands it to its `onError`. */
13
13
  export interface ChannelFailure {
14
14
  details: string;
15
15
  retryable: boolean;
16
+ /** The engine's failure code, when it set one. */
17
+ code?: string;
16
18
  }
17
19
  /** The customer-facing default: neutral, no leaked internals. Differentiate on retryability and always
18
20
  * offer a next step (Slack agent-design: never leave the user with a dead-end "something went wrong").
@@ -1,5 +1,5 @@
1
1
  /** Best-effort bounded durable dedup ring, recorded only after the caller's pre-ACK side effect is durable. */
2
- import { log } from "../log.js";
2
+ import { log } from "../../log.js";
3
3
  import { loadStateFile, saveStateFile } from "./state.js";
4
4
  export function createSeenRing(path, label, cap = 2000) {
5
5
  const raw = loadStateFile(path);
@@ -0,0 +1,12 @@
1
+ /**
2
+ * The replay window every signed webhook ingress needs. A signature covers its timestamp but proves
3
+ * nothing about it, so without a window a captured body plus its signed headers replays forever.
4
+ * The window LENGTH is the caller's: it is set by the platform's own redelivery schedule, not by us.
5
+ */
6
+ /**
7
+ * Whether a Unix-SECONDS timestamp header is within `maxAgeS` of now, in either direction (a clock
8
+ * ahead of ours is as suspect as one behind). Non-numeric is not fresh: the header is part of the
9
+ * signed material, so a value the signature commits to but this cannot read is a reason to refuse,
10
+ * not to wave through.
11
+ */
12
+ export declare function signatureIsFresh(timestamp: string, maxAgeS: number, nowMs?: number): boolean;
@@ -0,0 +1,17 @@
1
+ /**
2
+ * The replay window every signed webhook ingress needs. A signature covers its timestamp but proves
3
+ * nothing about it, so without a window a captured body plus its signed headers replays forever.
4
+ * The window LENGTH is the caller's: it is set by the platform's own redelivery schedule, not by us.
5
+ */
6
+ /**
7
+ * Whether a Unix-SECONDS timestamp header is within `maxAgeS` of now, in either direction (a clock
8
+ * ahead of ours is as suspect as one behind). Non-numeric is not fresh: the header is part of the
9
+ * signed material, so a value the signature commits to but this cannot read is a reason to refuse,
10
+ * not to wave through.
11
+ */
12
+ export function signatureIsFresh(timestamp, maxAgeS, nowMs = Date.now()) {
13
+ if (!/^\d+$/.test(timestamp))
14
+ return false;
15
+ const seconds = Number(timestamp);
16
+ return Number.isSafeInteger(seconds) && Math.abs(Math.floor(nowMs / 1000) - seconds) <= maxAgeS;
17
+ }
@@ -12,9 +12,9 @@
12
12
  * is an ENVIRONMENT error the operator must fix: it throws, and construction fails loudly — booting
13
13
  * with silently-empty state would hide real data behind a config mistake.
14
14
  */
15
- import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
16
- import { dirname } from "node:path";
17
- import { log } from "../log.js";
15
+ import { mkdirSync, readFileSync } from "node:fs";
16
+ import { writeFileAtomic } from "../../atomic-write.js";
17
+ import { log } from "../../log.js";
18
18
  /** Create the channel's state home — the one shared spelling of it, so no channel invents its own. */
19
19
  export function ensureStateHome(dir) {
20
20
  mkdirSync(dir, { recursive: true });
@@ -43,8 +43,5 @@ export function loadStateFile(path) {
43
43
  }
44
44
  }
45
45
  export function saveStateFile(path, value) {
46
- mkdirSync(dirname(path), { recursive: true });
47
- const tmp = `${path}.tmp`;
48
- writeFileSync(tmp, JSON.stringify(value));
49
- renameSync(tmp, path);
46
+ writeFileAtomic(path, JSON.stringify(value));
50
47
  }
@@ -1,4 +1,4 @@
1
- import { type SessionControl } from "../session.ts";
1
+ import { type SessionControl } from "../../session.ts";
2
2
  /** Bare stop word for summon-body matching (Slack/Feishu); Telegram uses its native `/stop` command. */
3
3
  export declare function isStopText(text: string): boolean;
4
4
  /** Dispatch `abort` for the session and map the outcome to the customer-facing line. Never throws;
@@ -6,8 +6,8 @@
6
6
  * at-least-once floor; and the hub stays gated by `config.sessionControl`, so without it the command
7
7
  * degrades to a visible "not enabled" notice, never a silent ignore.
8
8
  */
9
- import { log } from "../log.js";
10
- import { NO_ACTIVE_RUN_CODE } from "../session.js";
9
+ import { log } from "../../log.js";
10
+ import { NO_ACTIVE_RUN_CODE } from "../../session.js";
11
11
  /** Bare stop word for summon-body matching (Slack/Feishu); Telegram uses its native `/stop` command. */
12
12
  export function isStopText(text) {
13
13
  return /^(stop|cancel)[.!]?$/i.test(text.trim());
@@ -21,16 +21,16 @@ export async function dispatchStop(control, session, label) {
21
21
  if (!control)
22
22
  return STOP_UNAVAILABLE_NOTICE;
23
23
  try {
24
- const result = await control.dispatch(session, { type: "abort" });
24
+ const result = await control.sessions.get(session).abort();
25
25
  if (result.ok)
26
26
  return STOPPED_NOTICE;
27
27
  if (result.error.code === NO_ACTIVE_RUN_CODE)
28
28
  return NOTHING_RUNNING_NOTICE;
29
- log.warn(`${label} stop dispatch rejected for ${session}: ${result.error.code} — ${result.error.message}`);
29
+ log.warn(`${label} stop rejected for ${session}: ${result.error.code} — ${result.error.message}`);
30
30
  return `⚠️ Could not stop (${result.error.code}).`;
31
31
  }
32
32
  catch (error) {
33
- log.warn(`${label} stop dispatch failed for ${session}: ${String(error)}`);
33
+ log.warn(`${label} stop failed for ${session}: ${String(error)}`);
34
34
  return "⚠️ Could not stop — see the server logs.";
35
35
  }
36
36
  }
@@ -4,4 +4,4 @@ export interface TaskTracker {
4
4
  /** Resolves when every currently-tracked task has settled. */
5
5
  drain(): Promise<void>;
6
6
  }
7
- export declare function createTaskTracker(): TaskTracker;
7
+ export declare function createTaskTracker(label: string): TaskTracker;
@@ -2,10 +2,16 @@
2
2
  * SHARED fire-and-forget side-task tracking. Channels launch work off the request path (stop
3
3
  * feedback, DM welcomes) that must not block the transport ACK but MUST be drained on shutdown
4
4
  * (`turnsIdle`) — otherwise a reply in flight when the process exits is silently dropped. Error
5
- * handling stays with the caller: track() only guarantees the drain sees the task settle.
5
+ * handling stays with the caller: track() only guarantees the drain sees the task SETTLE, and settle
6
+ * includes reject. A caller that handles its error on a separate branch (`p.catch(log); track(p)`)
7
+ * still hands us a promise that rejects, and a drain that propagated it would fail the channel's whole
8
+ * `turnsIdle` over one side task. A rejection that reaches us is logged — we
9
+ * cannot tell a missing `.catch` from one on a separate branch, so the line is a visibility floor
10
+ * rather than a diagnosis, and without it a dropped side task leaves no trace anywhere.
6
11
  */
7
- import { beginWork } from "./busy.js";
8
- export function createTaskTracker() {
12
+ import { beginWork } from "../busy.js";
13
+ import { log } from "../../log.js";
14
+ export function createTaskTracker(label) {
9
15
  const tasks = new Set();
10
16
  return {
11
17
  track(task) {
@@ -18,8 +24,8 @@ export function createTaskTracker() {
18
24
  workDone();
19
25
  tasks.delete(task);
20
26
  })
21
- .catch(() => { }); // the caller's chain owns the error
27
+ .catch((error) => log.warn(`${label} side task rejected: ${String(error)}`));
22
28
  },
23
- drain: () => Promise.all(tasks).then(() => undefined),
29
+ drain: () => Promise.allSettled(tasks).then(() => undefined),
24
30
  };
25
31
  }
@@ -19,7 +19,7 @@
19
19
  * - Keyed by `thread_id`, never a reply-chain root: Feishu's `root_id` moves with the chain, so it
20
20
  * cannot identify a side conversation at all.
21
21
  */
22
- import { log } from "../log.js";
22
+ import { log } from "../../log.js";
23
23
  import { loadStateFile, saveStateFile } from "./state.js";
24
24
  /** Cap on remembered threads. Losing one costs a mention to re-enter that thread, so an unbounded file
25
25
  * buys little — and a merge that carries new information rewrites the whole map synchronously, so the
@@ -10,8 +10,8 @@
10
10
  * process-crash recovery, at-least-once). Exactly-once / deterministic step-replay (L2) is the K-axis
11
11
  * backend — an external queue with distributed locking (SPEC §11) — not this in-memory queue.
12
12
  */
13
- import { log } from "../log.js";
14
- import { beginWork } from "./busy.js";
13
+ import { log } from "../../log.js";
14
+ import { beginWork } from "../busy.js";
15
15
  export function createTurnQueue(opts) {
16
16
  const { label, run, onQueuedBehind } = opts;
17
17
  // Per-session serial chains: a second turn for the same session waits its turn (FIFO) instead of
@@ -1,3 +1,4 @@
1
+ import type { ContextBuffer } from "./context-buffer.ts";
1
2
  /** What every persisted turn record carries regardless of channel: identity, the session whose FIFO
2
3
  * chain it runs on, and how many times it has STARTED executing without finishing (0 until its first
3
4
  * run; bumped at each `startAttempt`). The channel's record type extends this with everything its
@@ -20,14 +21,14 @@ export interface TurnStore<T extends TurnRecordBase> {
20
21
  recover(): T[];
21
22
  /** Called when a turn is about to RUN (dequeued). Returns:
22
23
  * - "run": bumped its persisted execution count; go ahead.
23
- * - "exceeded": over `maxAttempts` starts without finishing (killed mid-run every time, whatever the
24
- * cause); the record is dropped and the runner notifies the asker.
24
+ * - "exceeded": over {@link MAX_TURN_ATTEMPTS} starts without finishing (killed mid-run every time,
25
+ * whatever the cause); the record is dropped and the runner notifies the asker.
25
26
  * - "defer": the bump could not be persisted — skip this cycle (fail closed: an unpersisted count
26
27
  * would let a poison turn re-run forever); the record stays on disk and replays on the next start
27
28
  * (a restart is required — disk recovery alone does not re-run it). The runner does NOT notify.
28
29
  * An id with no record returns "run" (untracked): a completed turn's `remove` cleared it, so the
29
30
  * redelivery-double-run tail (see the header's pre-ACK window) lands here. */
30
- startAttempt(id: string, maxAttempts: number): "run" | "exceeded" | "defer";
31
+ startAttempt(id: string): "run" | "exceeded" | "defer";
31
32
  }
32
33
  export interface TurnStoreOptions<T extends TurnRecordBase> {
33
34
  /** Log prefix naming the consumer (e.g. "[telegram]") — the store itself is channel-neutral. */
@@ -39,4 +40,19 @@ export interface TurnStoreOptions<T extends TurnRecordBase> {
39
40
  * (telegram: numeric update_id; lark: an explicit per-record seq). */
40
41
  order: (a: T, b: T) => number;
41
42
  }
43
+ /**
44
+ * End an ANSWERED turn: drop its durable intent, then commit the discussion it folded in.
45
+ *
46
+ * The ORDER is the safety property, which is why this is a function and not two lines at each call
47
+ * site. A crash between the two writes may re-fold already-answered context into the next summon —
48
+ * additive and harmless. The reverse order leaves intent on disk with its context already consumed,
49
+ * so the replay runs the same turn with its context stripped.
50
+ *
51
+ * Called from the turn's `completed` event, when the fold provably lives in the durable session.
52
+ */
53
+ export declare function commitAnsweredTurn<T extends TurnRecordBase, E>(store: TurnStore<T>, buffer: ContextBuffer<E>, turn: {
54
+ id: string;
55
+ bufferKey: string;
56
+ consumed: E[];
57
+ }): void;
42
58
  export declare function createTurnStore<T extends TurnRecordBase>(path: string, opts: TurnStoreOptions<T>): TurnStore<T>;
@@ -49,8 +49,35 @@
49
49
  * Single-process, single-writer: same durability model as state.ts (crash-safe via atomic rename;
50
50
  * power-loss is best-effort — no fsync, consistent with the rest of the channel's state).
51
51
  */
52
- import { log } from "../log.js";
52
+ import { log } from "../../log.js";
53
53
  import { loadStateFile, saveStateFile } from "./state.js";
54
+ /**
55
+ * How many times a turn may START without finishing before it is dropped rather than run again — the
56
+ * poison-turn ceiling described at length above. One value for every channel: it prices replay against
57
+ * DEPLOY frequency, which is a property of how fastagent is operated, not of which chat platform is in
58
+ * front of it.
59
+ *
60
+ * Known limitation: the count cannot tell a self-inflicted crash from an external SIGTERM (there is no
61
+ * graceful drain), so a legitimately long turn interrupted by this many successive deploys is dropped
62
+ * as if it were poison. Three is a bet that such a turn is an outlier, not a defence against one:
63
+ * catching SIGTERM to spare it would reintroduce the drain the design refuses — raise this constant
64
+ * instead if such turns are expected.
65
+ */
66
+ const MAX_TURN_ATTEMPTS = 3;
67
+ /**
68
+ * End an ANSWERED turn: drop its durable intent, then commit the discussion it folded in.
69
+ *
70
+ * The ORDER is the safety property, which is why this is a function and not two lines at each call
71
+ * site. A crash between the two writes may re-fold already-answered context into the next summon —
72
+ * additive and harmless. The reverse order leaves intent on disk with its context already consumed,
73
+ * so the replay runs the same turn with its context stripped.
74
+ *
75
+ * Called from the turn's `completed` event, when the fold provably lives in the durable session.
76
+ */
77
+ export function commitAnsweredTurn(store, buffer, turn) {
78
+ store.remove(turn.id);
79
+ buffer.commit(turn.bufferKey, turn.consumed);
80
+ }
54
81
  export function createTurnStore(path, opts) {
55
82
  const { label, isRecord, order } = opts;
56
83
  const load = () => {
@@ -102,12 +129,12 @@ export function createTurnStore(path, opts) {
102
129
  // happening to survive the load's JSON round-trip.
103
130
  return [...turns.values()].sort(order);
104
131
  },
105
- startAttempt(id, maxAttempts) {
132
+ startAttempt(id) {
106
133
  const rec = turns.get(id);
107
134
  if (!rec)
108
135
  return "run"; // no record — run untracked (a redelivery double-run whose first run removed it)
109
136
  const attempts = rec.attempts + 1;
110
- if (attempts > maxAttempts) {
137
+ if (attempts > MAX_TURN_ATTEMPTS) {
111
138
  // State the fact, not a cause the counter can't prove: a turn killed mid-run every time bumps
112
139
  // this whether IT poisoned the process or a deploy/OOM took it down each time.
113
140
  log.error(`${label} dropping turn ${id} after starting ${rec.attempts} time(s) without finishing ` +
@@ -3,7 +3,7 @@
3
3
  * this module binds it to Lark's cloud profile and exposes natural Lark-branded public names. Lark's
4
4
  * weaker control-plane capabilities live in onboarding/registration, not in a fork of the turn engine.
5
5
  */
6
- import type { ChannelModule, LongConnectionChannelModule } from "../../host/node.ts";
6
+ import type { ChannelModule, LongConnectionChannelModule } from "../../channel.ts";
7
7
  import { type FeishuChannelOptions, type FeishuFailure, type FeishuMessage, type FeishuMessageEvent, type FeishuRoute, type FeishuWebSocketChannelOptions, defaultFeishuRoute } from "../feishu/feishu.ts";
8
8
  export type LarkChannelOptions = FeishuChannelOptions;
9
9
  export type LarkWebSocketChannelOptions = FeishuWebSocketChannelOptions;
@@ -0,0 +1,73 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import type { ChannelHandler, Routes } from "../channel.ts";
3
+ /** Parse a route key: `"METHOD /path"` → `{ method, path }`, or `"/path"` → `{ path }` (any method).
4
+ * An empty method (`" /x"`) parses as `""`, which {@link assertRouteKey} refuses — see there. */
5
+ export declare function parseRouteKey(key: string): {
6
+ method?: string;
7
+ path: string;
8
+ };
9
+ /**
10
+ * A route key is `"METHOD /path"` or `"/path"`, with a literal path.
11
+ *
12
+ * Every rule here exists because breaking it costs SOMEONE ELSE. Two keys naming one route, a route
13
+ * under a mount, a path a URL rewrites (`/a/../x` and `/x` are one route spelled two ways, while
14
+ * the conflict check compares strings) — in each case a channel goes dark and its author did
15
+ * nothing wrong.
16
+ *
17
+ * Mistakes that only cost their own author a debugging session are NOT policed: `:id` and `*` are
18
+ * literal path characters here, so such a key simply never matches, and an unusual method is the
19
+ * same. Refusing those would trade a working capability for a lecture.
20
+ */
21
+ export declare function assertRouteKey(key: string, describe: (problem: string) => string): void;
22
+ /** Do these two keys fight over the same request? Equal paths, and a method each answers; a key
23
+ * with no method answers all of them. */
24
+ export declare function routeKeysConflict(a: string, b: string): boolean;
25
+ /** A handler owning a path prefix and everything beneath it — the session control plane is the one
26
+ * user. Kept out of {@link Routes} so a key is always a literal path and collision checks stay
27
+ * comparisons. */
28
+ export interface PrefixMount {
29
+ /** Absolute, no trailing slash, and not `/` (`/control`). Owns `/control` and everything below it.
30
+ * The root is excluded deliberately: a handler owning every path is that handler, and routing to
31
+ * it through here would only add a table nothing can reach. */
32
+ prefix: string;
33
+ handler: ChannelHandler;
34
+ }
35
+ /** Same status and headers, no content (RFC 9110's HEAD). The discarded body is cancelled, or a
36
+ * streaming producer keeps running with no reader. Shared with the control plane, which answers
37
+ * HEAD too. */
38
+ export declare function withoutBody(res: Response): Response;
39
+ /** Is `path` inside `prefix`? Segment-wise, so `/controlled` is not inside `/control`. */
40
+ export declare function pathUnderPrefix(path: string, prefix: string): boolean;
41
+ /**
42
+ * Compose a {@link Routes} table and its {@link PrefixMount}s into one handler.
43
+ *
44
+ * Refuses at assembly anything that could not receive a request: a key naming the same route as
45
+ * another (`"/x"` and `"GET /x"`), a route inside a mount, two mounts claiming the same ground. A
46
+ * channel must never go dark unannounced.
47
+ *
48
+ * 404 and 405 stay distinct: a remote client reads 404 as "this serve predates the route" (version
49
+ * skew) rather than as a fault.
50
+ */
51
+ export declare function router(routes: Routes, mounts?: readonly PrefixMount[]): ChannelHandler;
52
+ /** The node:http adapter for a Fetch handler — the embedded server uses it, and an embedder mounting
53
+ * fastagent on its OWN node:http server can too.
54
+ *
55
+ * Takes the same {@link ChannelHandler} `serveNode` does, so both doors accept the same thing: a
56
+ * handler may answer synchronously, and `router()` returns exactly that. Requiring a Promise here
57
+ * made the most natural mount — `nodeListener(router(routes))`, the whole agent on the app's own
58
+ * server — a type error. */
59
+ export declare function nodeListener(handler: ChannelHandler): (req: IncomingMessage, res: ServerResponse) => void;
60
+ /**
61
+ * Serve `handler` on a Node HTTP server. Thin mechanism: bind, report the port, let the caller stop
62
+ * accepting or force-close active connections — no logging/signals/exit (the CLI owns those).
63
+ * `listening` resolves with the bound port (useful for port 0) or rejects on a bind error.
64
+ * `host` is the bind address; unset means all interfaces (what containers need).
65
+ */
66
+ export declare function serveNode(handler: ChannelHandler, options: {
67
+ port: number;
68
+ host?: string;
69
+ }): {
70
+ listening: Promise<number>;
71
+ close: () => Promise<void>;
72
+ closeAllConnections: () => void;
73
+ };