@fastagent-sh/fastagent 0.17.1 → 0.19.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 (170) hide show
  1. package/README.md +25 -7
  2. package/dist/agent.d.ts +12 -1
  3. package/dist/agent.js +1 -1
  4. package/dist/atomic-write.d.ts +19 -0
  5. package/dist/atomic-write.js +48 -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-service.d.ts +54 -0
  9. package/dist/channels/agentcore-service.js +111 -0
  10. package/dist/channels/agentcore.d.ts +12 -2
  11. package/dist/channels/agentcore.js +6 -4
  12. package/dist/channels/busy.d.ts +1 -1
  13. package/dist/channels/busy.js +1 -1
  14. package/dist/channels/control.d.ts +16 -6
  15. package/dist/channels/control.js +106 -4
  16. package/dist/{engines/pi/channel.d.ts → channels/discover.d.ts} +2 -2
  17. package/dist/{engines/pi/channel.js → channels/discover.js} +8 -12
  18. package/dist/channels/feishu/card.js +1 -1
  19. package/dist/channels/feishu/context-buffer.d.ts +1 -1
  20. package/dist/channels/feishu/context-buffer.js +4 -4
  21. package/dist/channels/feishu/feishu-api.d.ts +4 -2
  22. package/dist/channels/feishu/feishu-api.js +1 -1
  23. package/dist/channels/feishu/feishu.d.ts +1 -1
  24. package/dist/channels/feishu/feishu.js +45 -15
  25. package/dist/channels/feishu/invoke-turn.d.ts +9 -3
  26. package/dist/channels/feishu/invoke-turn.js +152 -34
  27. package/dist/channels/feishu/parse.js +6 -0
  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 +16 -111
  34. package/dist/channels/{context-buffer.js → kit/context-buffer.js} +2 -2
  35. package/dist/channels/{invoke-turn-kit.d.ts → kit/invoke-turn-kit.d.ts} +18 -4
  36. package/dist/channels/{invoke-turn-kit.js → kit/invoke-turn-kit.js} +23 -8
  37. package/dist/channels/{preview-kit.d.ts → kit/preview-kit.d.ts} +3 -1
  38. package/dist/channels/{seen.js → kit/seen.js} +1 -1
  39. package/dist/channels/kit/state.d.ts +6 -0
  40. package/dist/channels/{state.js → kit/state.js} +4 -24
  41. package/dist/channels/{stop-command.d.ts → kit/stop-command.d.ts} +1 -1
  42. package/dist/channels/{stop-command.js → kit/stop-command.js} +2 -2
  43. package/dist/channels/{tasks.js → kit/tasks.js} +1 -1
  44. package/dist/channels/{thread-participants.d.ts → kit/thread-participants.d.ts} +7 -0
  45. package/dist/channels/{thread-participants.js → kit/thread-participants.js} +4 -1
  46. package/dist/channels/{turn-queue.js → kit/turn-queue.js} +2 -2
  47. package/dist/channels/{turn-store.js → kit/turn-store.js} +1 -1
  48. package/dist/channels/lark/lark.d.ts +1 -1
  49. package/dist/channels/serve.d.ts +73 -0
  50. package/dist/channels/serve.js +243 -0
  51. package/dist/channels/slack/bot-auth.js +3 -14
  52. package/dist/channels/slack/context-buffer.d.ts +2 -2
  53. package/dist/channels/slack/context-buffer.js +2 -2
  54. package/dist/channels/slack/invoke-turn.d.ts +1 -1
  55. package/dist/channels/slack/invoke-turn.js +9 -6
  56. package/dist/channels/slack/parse.js +2 -2
  57. package/dist/channels/slack/preview.d.ts +1 -1
  58. package/dist/channels/slack/preview.js +11 -3
  59. package/dist/channels/slack/slack-api.js +1 -1
  60. package/dist/channels/slack/slack.d.ts +1 -1
  61. package/dist/channels/slack/slack.js +8 -12
  62. package/dist/channels/slack/welcomed.js +1 -1
  63. package/dist/channels/telegram/context-buffer.d.ts +2 -2
  64. package/dist/channels/telegram/context-buffer.js +2 -2
  65. package/dist/channels/telegram/invoke-turn.d.ts +1 -1
  66. package/dist/channels/telegram/invoke-turn.js +2 -2
  67. package/dist/channels/telegram/parse.js +2 -2
  68. package/dist/channels/telegram/preview.d.ts +1 -1
  69. package/dist/channels/telegram/preview.js +6 -2
  70. package/dist/channels/telegram/telegram.d.ts +1 -1
  71. package/dist/channels/telegram/telegram.js +3 -3
  72. package/dist/channels/telegram/turn-store.d.ts +2 -2
  73. package/dist/channels/telegram/turn-store.js +2 -2
  74. package/dist/cli/commands/add.js +1 -1
  75. package/dist/cli/commands/deploy.js +13 -5
  76. package/dist/cli/commands/dev.js +26 -14
  77. package/dist/cli/commands/fire.js +2 -2
  78. package/dist/cli/commands/info.js +28 -5
  79. package/dist/cli/commands/invoke.js +1 -1
  80. package/dist/cli/commands/schedule.js +1 -1
  81. package/dist/cli/commands/start.js +64 -95
  82. package/dist/cli/commands/tool.js +5 -7
  83. package/dist/cli/program.js +1 -1
  84. package/dist/cli/serve.d.ts +18 -57
  85. package/dist/cli/serve.js +57 -243
  86. package/dist/cli/shared.d.ts +5 -3
  87. package/dist/cli/shared.js +14 -7
  88. package/dist/collect.d.ts +30 -4
  89. package/dist/collect.js +39 -6
  90. package/dist/core.d.ts +3 -5
  91. package/dist/core.js +7 -5
  92. package/dist/deploy/preflight.d.ts +8 -2
  93. package/dist/deploy/preflight.js +22 -4
  94. package/dist/deploy/secrets.d.ts +3 -0
  95. package/dist/deploy/secrets.js +6 -0
  96. package/dist/dev-supervisor.js +16 -4
  97. package/dist/engines/pi/agent-session-factory.d.ts +104 -0
  98. package/dist/engines/pi/agent-session-factory.js +314 -0
  99. package/dist/engines/pi/config.d.ts +4 -4
  100. package/dist/engines/pi/config.js +1 -1
  101. package/dist/engines/pi/create.d.ts +56 -42
  102. package/dist/engines/pi/create.js +150 -75
  103. package/dist/engines/pi/definition.d.ts +41 -7
  104. package/dist/engines/pi/definition.js +115 -11
  105. package/dist/engines/pi/invoke-session.d.ts +35 -0
  106. package/dist/engines/pi/invoke-session.js +357 -0
  107. package/dist/engines/pi/login.js +7 -4
  108. package/dist/engines/pi/models.d.ts +59 -11
  109. package/dist/engines/pi/models.js +64 -9
  110. package/dist/engines/pi/open.d.ts +9 -6
  111. package/dist/engines/pi/open.js +20 -10
  112. package/dist/engines/pi/report.d.ts +0 -4
  113. package/dist/engines/pi/report.js +0 -7
  114. package/dist/engines/pi/service.d.ts +23 -0
  115. package/dist/engines/pi/service.js +19 -0
  116. package/dist/engines/pi/session-builder.js +88 -90
  117. package/dist/engines/pi/session-control.d.ts +11 -9
  118. package/dist/engines/pi/session-control.js +114 -77
  119. package/dist/engines/pi/session-inheritance.d.ts +44 -0
  120. package/dist/engines/pi/session-inheritance.js +242 -0
  121. package/dist/engines/pi/session-settings.d.ts +14 -2
  122. package/dist/engines/pi/session-settings.js +18 -0
  123. package/dist/engines/pi/session-store.d.ts +70 -0
  124. package/dist/engines/pi/session-store.js +313 -0
  125. package/dist/engines/pi/tool-context.d.ts +18 -8
  126. package/dist/engines/pi/tool-context.js +23 -1
  127. package/dist/engines/pi/tool.d.ts +5 -5
  128. package/dist/engines/pi/tool.js +1 -1
  129. package/dist/engines/pi/{invoke.d.ts → turn-kit.d.ts} +46 -45
  130. package/dist/engines/pi/turn-kit.js +188 -0
  131. package/dist/engines/pi/wake-tool.js +1 -1
  132. package/dist/index.d.ts +1 -0
  133. package/dist/index.js +1 -0
  134. package/dist/log.d.ts +3 -0
  135. package/dist/log.js +5 -1
  136. package/dist/node.d.ts +16 -0
  137. package/dist/node.js +20 -0
  138. package/dist/paths.d.ts +13 -0
  139. package/dist/paths.js +16 -0
  140. package/dist/pi.d.ts +15 -8
  141. package/dist/pi.js +7 -6
  142. package/dist/scaffold/templates/fastagent.config.mjs +2 -0
  143. package/dist/scaffold/templates/persona.md +2 -2
  144. package/dist/schedule/state.js +4 -9
  145. package/dist/schedule/wakeups.d.ts +1 -1
  146. package/dist/schedule/wakeups.js +1 -1
  147. package/dist/service.d.ts +158 -0
  148. package/dist/service.js +354 -0
  149. package/dist/session-remote.d.ts +1 -1
  150. package/dist/session-remote.js +10 -2
  151. package/dist/session.d.ts +4 -2
  152. package/dist/session.js +1 -1
  153. package/package.json +14 -5
  154. package/dist/channels/state.d.ts +0 -16
  155. package/dist/engines/pi/harness.d.ts +0 -112
  156. package/dist/engines/pi/harness.js +0 -153
  157. package/dist/engines/pi/invoke.js +0 -618
  158. package/dist/engines/pi/read-image.d.ts +0 -4
  159. package/dist/engines/pi/read-image.js +0 -62
  160. package/dist/engines/pi/sessions.d.ts +0 -44
  161. package/dist/engines/pi/sessions.js +0 -182
  162. package/dist/host/node.js +0 -52
  163. /package/dist/channels/{context-buffer.d.ts → kit/context-buffer.d.ts} +0 -0
  164. /package/dist/channels/{preview-kit.js → kit/preview-kit.js} +0 -0
  165. /package/dist/channels/{seen.d.ts → kit/seen.d.ts} +0 -0
  166. /package/dist/channels/{tasks.d.ts → kit/tasks.d.ts} +0 -0
  167. /package/dist/channels/{text.d.ts → kit/text.d.ts} +0 -0
  168. /package/dist/channels/{text.js → kit/text.js} +0 -0
  169. /package/dist/channels/{turn-queue.d.ts → kit/turn-queue.d.ts} +0 -0
  170. /package/dist/channels/{turn-store.d.ts → kit/turn-store.d.ts} +0 -0
@@ -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, rmSync, writeFileSync } from "node:fs";
16
- import { dirname, join } 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,25 +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);
50
- }
51
- /**
52
- * Drop a state file a redesign retired. Best-effort by design: a leftover file is untidy, not fatal,
53
- * so a failure is debug-level and never blocks a boot. Only for files that are pure CACHE — anything
54
- * whose loss changes behaviour needs a migration, not a delete.
55
- *
56
- * Shared because a retired file is usually retired in every channel at once: one best-effort
57
- * semantic, one log shape, one place to check what "retired" means here. (The removal DEADLINE is not
58
- * here — it lives in test/migration-deadline.test.ts, which names every call site to delete.)
59
- */
60
- export function removeRetiredStateFile(stateHome, name, label) {
61
- try {
62
- rmSync(join(stateHome, name), { force: true });
63
- }
64
- catch (error) {
65
- log.debug(`${label} could not remove the obsolete ${name}: ${String(error)}`);
66
- }
46
+ writeFileAtomic(path, JSON.stringify(value));
67
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());
@@ -4,7 +4,7 @@
4
4
  * (`turnsIdle`) — otherwise a reply in flight when the process exits is silently dropped. Error
5
5
  * handling stays with the caller: track() only guarantees the drain sees the task settle.
6
6
  */
7
- import { beginWork } from "./busy.js";
7
+ import { beginWork } from "../busy.js";
8
8
  export function createTaskTracker() {
9
9
  const tasks = new Set();
10
10
  return {
@@ -6,6 +6,13 @@ export interface ThreadParticipants {
6
6
  * get wrong twice, and "a second human restores the mention requirement" must have one place to change.
7
7
  */
8
8
  admitsBareMessage(key: string): boolean;
9
+ /**
10
+ * Has the agent answered into this thread before — the "first answered turn" fact
11
+ * (participant-model.md §8), unlike {@link ThreadParticipants.admitsBareMessage} which also weighs
12
+ * the second-human rule. An evicted record answers false (this store is a cache — see the header),
13
+ * so gate a repeatable read on it, never a durable claim.
14
+ */
15
+ agentSpokeIn(key: string): boolean;
9
16
  /**
10
17
  * Merge in what was just heard. Idempotent; a failed write is a warning, never a failed delivery.
11
18
  *
@@ -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
@@ -68,6 +68,9 @@ export function createThreadParticipants(path, label) {
68
68
  const heard = records.get(key);
69
69
  return heard?.agentSpoke === true && heard.humans.length <= 1;
70
70
  },
71
+ agentSpokeIn(key) {
72
+ return records.get(key)?.agentSpoke === true;
73
+ },
71
74
  merge(key, heard) {
72
75
  const previous = records.get(key);
73
76
  const humans = new Set(previous?.humans ?? []);
@@ -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
@@ -49,7 +49,7 @@
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
54
  export function createTurnStore(path, opts) {
55
55
  const { label, isRecord, order } = opts;
@@ -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
+ };
@@ -0,0 +1,243 @@
1
+ /**
2
+ * How a {@link Routes} table becomes a running server: the path rule, dispatch, the totality
3
+ * boundary, and the node:http binding.
4
+ *
5
+ * Shared ground, not a deployment target — every host in `src/deploy/` runs this same process; what
6
+ * differs between them is process, storage, credentials and deployment (core.md §1), none of it
7
+ * here.
8
+ *
9
+ * Dispatch is a Map lookup rather than a router because a deployment mounts a handful of LITERAL
10
+ * paths. A routing library would answer through a pattern language we do not use, and its extra
11
+ * semantics (decode-before-match, HEAD fallback, wildcard precedence) would have to be predicted by
12
+ * every collision check.
13
+ *
14
+ * The types a channel author or an embedder writes stay pure Fetch — SPEC §11 fixes the gateway
15
+ * contract as `(Request) => Response`, and an embedding app may already run its own framework.
16
+ * `overrideGlobalObjects: false` is part of that: the adapter otherwise swaps
17
+ * `globalThis.Request`/`Response` process-wide, which breaks a channel holding a `Response`
18
+ * captured before mount (it fails `instanceof` in {@link totalFetch} and is answered 500).
19
+ *
20
+ * Post-ACK work (a webhook channel's fire-and-forget turns) runs on this event loop and is lost on
21
+ * shutdown — the accepted tradeoff until durable execution (the K axis) exists.
22
+ */
23
+ import { serve, getRequestListener } from "@hono/node-server";
24
+ import { log } from "../log.js";
25
+ import { text } from "./respond.js";
26
+ /** Parse a route key: `"METHOD /path"` → `{ method, path }`, or `"/path"` → `{ path }` (any method).
27
+ * An empty method (`" /x"`) parses as `""`, which {@link assertRouteKey} refuses — see there. */
28
+ export function parseRouteKey(key) {
29
+ const sp = key.indexOf(" ");
30
+ return sp === -1 ? { path: key } : { method: key.slice(0, sp).toUpperCase(), path: key.slice(sp + 1) };
31
+ }
32
+ /**
33
+ * A route key is `"METHOD /path"` or `"/path"`, with a literal path.
34
+ *
35
+ * Every rule here exists because breaking it costs SOMEONE ELSE. Two keys naming one route, a route
36
+ * under a mount, a path a URL rewrites (`/a/../x` and `/x` are one route spelled two ways, while
37
+ * the conflict check compares strings) — in each case a channel goes dark and its author did
38
+ * nothing wrong.
39
+ *
40
+ * Mistakes that only cost their own author a debugging session are NOT policed: `:id` and `*` are
41
+ * literal path characters here, so such a key simply never matches, and an unusual method is the
42
+ * same. Refusing those would trade a working capability for a lecture.
43
+ */
44
+ export function assertRouteKey(key, describe) {
45
+ const { method, path } = parseRouteKey(key);
46
+ if (method === "")
47
+ throw new Error(describe('a leading space is not a method — write "/path" for any method'));
48
+ if (!path.startsWith("/"))
49
+ throw new Error(describe('must start with "/"'));
50
+ // Asked of `URL` rather than by listing what it rewrites (`?`/`#`, `.`/`..`, `\`, `%2e`). A key it
51
+ // rewrites is unreachable AND compares as a different string, hiding that `/a/../x` and `/x` are
52
+ // one route.
53
+ const arrives = new URL(path, "http://x").pathname;
54
+ if (arrives !== path) {
55
+ throw new Error(describe(`is not the path a request would carry — that request arrives as "${arrives}"`));
56
+ }
57
+ }
58
+ /** Do these two keys fight over the same request? Equal paths, and a method each answers; a key
59
+ * with no method answers all of them. */
60
+ export function routeKeysConflict(a, b) {
61
+ const ka = parseRouteKey(a);
62
+ const kb = parseRouteKey(b);
63
+ if (ka.path !== kb.path)
64
+ return false;
65
+ return ka.method === undefined || kb.method === undefined || ka.method === kb.method;
66
+ }
67
+ /** Same status and headers, no content (RFC 9110's HEAD). The discarded body is cancelled, or a
68
+ * streaming producer keeps running with no reader. Shared with the control plane, which answers
69
+ * HEAD too. */
70
+ export function withoutBody(res) {
71
+ void res.body?.cancel().catch(() => { });
72
+ return new Response(null, { status: res.status, statusText: res.statusText, headers: res.headers });
73
+ }
74
+ /** Is `path` inside `prefix`? Segment-wise, so `/controlled` is not inside `/control`. */
75
+ export function pathUnderPrefix(path, prefix) {
76
+ return path === prefix || path.startsWith(`${prefix}/`);
77
+ }
78
+ /**
79
+ * Compose a {@link Routes} table and its {@link PrefixMount}s into one handler.
80
+ *
81
+ * Refuses at assembly anything that could not receive a request: a key naming the same route as
82
+ * another (`"/x"` and `"GET /x"`), a route inside a mount, two mounts claiming the same ground. A
83
+ * channel must never go dark unannounced.
84
+ *
85
+ * 404 and 405 stay distinct: a remote client reads 404 as "this serve predates the route" (version
86
+ * skew) rather than as a fault.
87
+ */
88
+ export function router(routes, mounts = []) {
89
+ for (const [i, mount] of mounts.entries()) {
90
+ assertRouteKey(mount.prefix, (problem) => `mount prefix "${mount.prefix}" is invalid — ${problem}`);
91
+ if (mount.prefix === "/") {
92
+ throw new Error(`mount prefix "/" is invalid — a handler owning every path IS that handler; serve it directly`);
93
+ }
94
+ if (mount.prefix.endsWith("/")) {
95
+ throw new Error(`mount prefix "${mount.prefix}" is invalid — no trailing slash (write "/control")`);
96
+ }
97
+ const clash = mounts
98
+ .slice(0, i)
99
+ .find((other) => pathUnderPrefix(mount.prefix, other.prefix) || pathUnderPrefix(other.prefix, mount.prefix));
100
+ if (clash) {
101
+ throw new Error(`mount "${mount.prefix}" overlaps "${clash.prefix}" — one of them would never receive a request`);
102
+ }
103
+ }
104
+ const byKey = new Map();
105
+ const paths = new Set();
106
+ for (const [key, handler] of Object.entries(routes)) {
107
+ assertRouteKey(key, (problem) => `route "${key}" is not a valid route key — ${problem}`);
108
+ const { path } = parseRouteKey(key);
109
+ for (const mount of mounts) {
110
+ if (pathUnderPrefix(path, mount.prefix)) {
111
+ throw new Error(`route "${key}" is inside the mount "${mount.prefix}" — it would never receive a request`);
112
+ }
113
+ }
114
+ const shadowed = [...byKey.keys()].find((other) => routeKeysConflict(other, key));
115
+ if (shadowed) {
116
+ throw new Error(`route "${key}" conflicts with "${shadowed}" — one of them would never receive a request`);
117
+ }
118
+ // Stored normalised: `parseRouteKey` upper-cases the method, so `"get /x"` validates under
119
+ // `GET` and would otherwise be looked up under a name nothing stores.
120
+ const { method } = parseRouteKey(key);
121
+ byKey.set(method ? `${method} ${path}` : path, handler);
122
+ paths.add(path);
123
+ }
124
+ return (req) => {
125
+ // `URL` normalises the path (`/a/../x` → `/x`) and drops query/fragment.
126
+ const path = new URL(req.url).pathname;
127
+ const answer = () => {
128
+ for (const mount of mounts)
129
+ if (pathUnderPrefix(path, mount.prefix))
130
+ return mount.handler(req);
131
+ const exact = byKey.get(`${req.method} ${path}`) ?? byKey.get(path);
132
+ if (exact)
133
+ return exact(req);
134
+ if (req.method === "HEAD") {
135
+ const get = byKey.get(`GET ${path}`);
136
+ if (get)
137
+ return get(req);
138
+ }
139
+ return paths.has(path) ? text("method not allowed\n", 405) : text("not found\n", 404);
140
+ };
141
+ // ONE exit, so the HEAD rule holds for every reply — a mount's, a route's, the GET fallback's,
142
+ // and the 404/405 this router writes itself. RFC 9110: HEAD is GET without the content.
143
+ const res = answer();
144
+ if (req.method !== "HEAD")
145
+ return res;
146
+ return res instanceof Promise ? res.then(withoutBody) : withoutBody(res);
147
+ };
148
+ }
149
+ /**
150
+ * The totality boundary every serving path shares. `loadChannels` imports arbitrary author code, so
151
+ * a channel that throws — or forgets to return — must become a logged 500 rather than escape as an
152
+ * unhandled rejection. Both `nodeListener` and `serveNode` route through it.
153
+ *
154
+ * The message stays internal: an adapter's default error page would echo exception text (a stack, a
155
+ * path, a key inside an error string) to whoever made the request. Visibility is the LOG's job.
156
+ */
157
+ function totalFetch(handler) {
158
+ return async (req) => {
159
+ try {
160
+ const response = await handler(req);
161
+ if (!(response instanceof Response))
162
+ throw new TypeError("handler did not return a Response");
163
+ return response;
164
+ }
165
+ catch (error) {
166
+ log.error(`[serve] request failed: ${String(error)}`);
167
+ return text("internal error\n", 500);
168
+ }
169
+ };
170
+ }
171
+ /**
172
+ * Has upstream middleware already drained the request body?
173
+ *
174
+ * Node's `req` is one-shot, so a body parser mounted ahead of this route consumes it — a property of
175
+ * the Node/Fetch seam, not of this adapter. What is avoidable is the diagnosis: undici answers
176
+ * `TypeError: Body is unusable`, naming neither the cause nor the fix, and for a webhook channel
177
+ * that reads as "the integration is broken and the platform keeps retrying".
178
+ *
179
+ * Answers only when CERTAIN — a positive `content-length`. `readableEnded` alone is true for any
180
+ * request something upstream merely touched, and an empty chunked body is legal, arrives drained,
181
+ * and is indistinguishable from an eaten one. Guessing there rejects valid requests, making this
182
+ * guard the outage it explains.
183
+ */
184
+ function bodyAlreadyRead(req) {
185
+ const length = Number(req.headers["content-length"]);
186
+ return Number.isFinite(length) && length > 0 && req.readableEnded;
187
+ }
188
+ /** The node:http adapter for a Fetch handler — the embedded server uses it, and an embedder mounting
189
+ * fastagent on its OWN node:http server can too.
190
+ *
191
+ * Takes the same {@link ChannelHandler} `serveNode` does, so both doors accept the same thing: a
192
+ * handler may answer synchronously, and `router()` returns exactly that. Requiring a Promise here
193
+ * made the most natural mount — `nodeListener(router(routes))`, the whole agent on the app's own
194
+ * server — a type error. */
195
+ export function nodeListener(handler) {
196
+ const listener = getRequestListener(totalFetch(handler), { overrideGlobalObjects: false });
197
+ return (req, res) => {
198
+ if (bodyAlreadyRead(req)) {
199
+ log.error(`[serve] ${req.method} ${req.url}: the request body was already read by upstream middleware ` +
200
+ `(e.g. express.json()) — mount fastagent BEFORE the body parser, or scope the parser away ` +
201
+ `from this route. Channels that verify webhook signatures need the RAW body.`);
202
+ res.writeHead(500, { "content-type": "text/plain" });
203
+ res.end("internal error\n");
204
+ return;
205
+ }
206
+ listener(req, res);
207
+ };
208
+ }
209
+ /**
210
+ * Serve `handler` on a Node HTTP server. Thin mechanism: bind, report the port, let the caller stop
211
+ * accepting or force-close active connections — no logging/signals/exit (the CLI owns those).
212
+ * `listening` resolves with the bound port (useful for port 0) or rejects on a bind error.
213
+ * `host` is the bind address; unset means all interfaces (what containers need).
214
+ */
215
+ export function serveNode(handler, options) {
216
+ let onListening;
217
+ let onBindError;
218
+ const listening = new Promise((resolve, reject) => {
219
+ onListening = resolve;
220
+ onBindError = reject;
221
+ });
222
+ // serve() types its return as the union of every server it CAN build (incl. http2). We never pass
223
+ // a createServer/http2 option, so it is always node:http's Server — the one carrying
224
+ // closeAllConnections, which the caller-owned force-close depends on.
225
+ const server = serve({
226
+ fetch: totalFetch(handler),
227
+ overrideGlobalObjects: false,
228
+ port: options.port,
229
+ ...(options.host !== undefined ? { hostname: options.host } : {}),
230
+ }, (info) => {
231
+ // Detach before resolving: this listener answers the BIND, and leaving it attached would let
232
+ // a later runtime error call reject() on a settled promise — swallowed, with nothing raised
233
+ // anywhere. Detached, an error after bind is an unhandled 'error' event, which is loud.
234
+ // (Not reachable from a test without forging an event on a server this function does not
235
+ // expose; the bind-failure half below is covered.)
236
+ server.off("error", onBindError);
237
+ onListening(info.port);
238
+ });
239
+ server.once("error", onBindError); // a bind failure surfaces here, before "listening"
240
+ const close = () => new Promise((resolve, reject) => server.close((e) => (e ? reject(e) : resolve())));
241
+ const closeAllConnections = () => server.closeAllConnections();
242
+ return { listening, close, closeAllConnections };
243
+ }
@@ -1,6 +1,6 @@
1
1
  /** Rotating Slack bot-token provider backed by owner-only channel state. */
2
- import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
- import { dirname } from "node:path";
2
+ import { readFileSync } from "node:fs";
3
+ import { writeFileAtomic } from "../../atomic-write.js";
4
4
  const REFRESH_EARLY_MS = 5 * 60_000;
5
5
  const REQUEST_TIMEOUT_MS = 30_000;
6
6
  function isState(value) {
@@ -35,18 +35,7 @@ function load(path) {
35
35
  }
36
36
  }
37
37
  function save(path, state) {
38
- mkdirSync(dirname(path), { recursive: true });
39
- const temp = `${path}.${process.pid}.${Date.now()}.tmp`;
40
- try {
41
- writeFileSync(temp, JSON.stringify(state), { mode: 0o600 });
42
- chmodSync(temp, 0o600);
43
- renameSync(temp, path);
44
- chmodSync(path, 0o600);
45
- }
46
- catch (error) {
47
- rmSync(temp, { force: true });
48
- throw error;
49
- }
38
+ writeFileAtomic(path, JSON.stringify(state), 0o600);
50
39
  }
51
40
  async function refreshSlackBotToken(input) {
52
41
  const authorization = Buffer.from(`${input.clientId}:${input.clientSecret}`, "utf8").toString("base64");
@@ -1,9 +1,9 @@
1
1
  /**
2
- * Slack's half of the shared context buffer (mechanics + consume protocol: ../context-buffer.ts):
2
+ * Slack's half of the shared context buffer (mechanics + consume protocol: ../kit/context-buffer.ts):
3
3
  * the entry shape, its fold-line rendering, and buffered-file selection. Durable, bounded
4
4
  * unsummoned Slack discussion folded into the next answered turn in the same place.
5
5
  */
6
- import { type ContextBuffer } from "../context-buffer.ts";
6
+ import { type ContextBuffer } from "../kit/context-buffer.ts";
7
7
  export interface SlackBufferEntry {
8
8
  sender: string;
9
9
  body: string;
@@ -1,9 +1,9 @@
1
1
  /**
2
- * Slack's half of the shared context buffer (mechanics + consume protocol: ../context-buffer.ts):
2
+ * Slack's half of the shared context buffer (mechanics + consume protocol: ../kit/context-buffer.ts):
3
3
  * the entry shape, its fold-line rendering, and buffered-file selection. Durable, bounded
4
4
  * unsummoned Slack discussion folded into the next answered turn in the same place.
5
5
  */
6
- import { BUFFER_ATTACH_MAX, createContextBuffer as createGenericContextBuffer, } from "../context-buffer.js";
6
+ import { BUFFER_ATTACH_MAX, createContextBuffer as createGenericContextBuffer, } from "../kit/context-buffer.js";
7
7
  function bufferLine(entry) {
8
8
  const meta = [`msg ${entry.messageId}`, entry.replyTo ? `thread root ${entry.replyTo}` : undefined]
9
9
  .filter(Boolean)
@@ -1,6 +1,6 @@
1
1
  /** Resolve Slack file IDs at dequeue, then stream one engine-neutral Agent turn. */
2
2
  import type { Agent, AgentEvent } from "../../agent.ts";
3
- import { type BusyRetry } from "../invoke-turn-kit.ts";
3
+ import { type BusyRetry } from "../kit/invoke-turn-kit.ts";
4
4
  import type { SlackBufferedFileRef } from "./context-buffer.ts";
5
5
  import type { SlackApi } from "./slack-api.ts";
6
6
  export interface SlackTurnTransport {
@@ -1,11 +1,11 @@
1
1
  import { log } from "../../log.js";
2
- import { DEFAULT_BUSY_RETRY, attachedFilesManifest, attributedFileName, backgroundImagesManifest, missingAttachmentsNote, streamTurnWithBusyRetry, } from "../invoke-turn-kit.js";
2
+ import { DEFAULT_BUSY_RETRY, attachedFilesManifest, attributedFileName, backgroundImagesManifest, missingAttachmentsNote, streamTurnWithBusyRetry, } from "../kit/invoke-turn-kit.js";
3
3
  const MARKDOWN_INSTRUCTION = "\n\n(Format your reply as standard Markdown. Slack renders it natively. Do not use HTML or Slack control-mention syntax such as <!here>, <!channel>, or <!everyone>.)";
4
4
  async function resolveFile(transport, fileId) {
5
5
  const info = await transport.api.fileInfo(fileId);
6
- return info.mimetype?.toLowerCase().startsWith("image/")
7
- ? { image: await transport.api.fetchImage(info) }
8
- : { file: await transport.api.fetchFile(info, transport.channelId, transport.filesDir) };
6
+ if (info.mimetype?.toLowerCase().startsWith("image/"))
7
+ return { image: await transport.api.fetchImage(info) };
8
+ return { file: await transport.api.fetchFile(info, transport.channelId, transport.filesDir) };
9
9
  }
10
10
  async function resolveInputs(transport, attachments) {
11
11
  const images = [];
@@ -20,7 +20,10 @@ async function resolveInputs(transport, attachments) {
20
20
  const backgroundImages = [];
21
21
  const backgroundFiles = [];
22
22
  let lost = 0;
23
- const results = await Promise.allSettled(attachments.buffered.files.map(async (ref) => ({ ref, resolved: await resolveFile(transport, ref.id) })));
23
+ const results = await Promise.allSettled(attachments.buffered.files.map(async (ref) => ({
24
+ ref,
25
+ resolved: await resolveFile(transport, ref.id),
26
+ })));
24
27
  for (const result of results) {
25
28
  if (result.status === "fulfilled") {
26
29
  const { ref, resolved } = result.value;
@@ -59,5 +62,5 @@ export async function* invokeSlackTurn(agent, session, text, transport, attachme
59
62
  return;
60
63
  }
61
64
  const prompt = { text: `${text}${resolved.promptSuffix}${MARKDOWN_INSTRUCTION}`, images: resolved.images };
62
- yield* streamTurnWithBusyRetry(agent, session, prompt, { label: transport.label, onCompleted, busyRetry });
65
+ yield* streamTurnWithBusyRetry(agent, { session }, prompt, { label: transport.label, onCompleted, busyRetry });
63
66
  }
@@ -1,5 +1,5 @@
1
- import { BUFFER_LINE_MAX_CHARS } from "../context-buffer.js";
2
- import { codePointPrefix, truncateCodePointPrefix } from "../text.js";
1
+ import { BUFFER_LINE_MAX_CHARS } from "../kit/context-buffer.js";
2
+ import { codePointPrefix, truncateCodePointPrefix } from "../kit/text.js";
3
3
  const HUMAN_MESSAGE_SUBTYPES = new Set(["file_share", "thread_broadcast"]);
4
4
  /**
5
5
  * Both forms Slack writes a mention in: `<@U123>` and the labelled `<@U123|name>`. ONE definition,
@@ -1,6 +1,6 @@
1
1
  /** Slack reply rendering: native Agent streams first, rate-safe edited-message compatibility second. */
2
2
  import type { AgentEvent } from "../../agent.ts";
3
- import { type ChannelFailure, defaultErrorMessage } from "../preview-kit.ts";
3
+ import { type ChannelFailure, defaultErrorMessage } from "../kit/preview-kit.ts";
4
4
  import { type SlackApi, type SlackTarget } from "./slack-api.ts";
5
5
  export type SlackFailure = ChannelFailure;
6
6
  export type SlackRendering = "native" | "classic";
@@ -1,5 +1,5 @@
1
1
  import { log } from "../../log.js";
2
- import { RETRY_NOTICE, THINKING_PLACEHOLDER, applyTurnEvent, composeTurnBody, createTurnView, defaultErrorMessage, humanizeToolName, revealedAnswer, summarizeToolArgs, toolLines, } from "../preview-kit.js";
2
+ import { RETRY_NOTICE, THINKING_PLACEHOLDER, applyTurnEvent, composeTurnBody, createTurnView, defaultErrorMessage, humanizeToolName, revealedAnswer, summarizeToolArgs, toolLines, } from "../kit/preview-kit.js";
3
3
  import { chunkSlackMarkdown, chunkSlackText, isSlackNativeUnavailable, } from "./slack-api.js";
4
4
  export { defaultErrorMessage };
5
5
  const CLASSIC_UPDATE_INTERVAL_MS = 3_000;
@@ -175,7 +175,11 @@ async function streamClassicSlackReply(events, api, target, formatError, initial
175
175
  if (event.type === "failed") {
176
176
  await finishPump();
177
177
  finalized = true;
178
- const notice = formatError({ details: event.details, retryable: event.retryable }) ?? "";
178
+ const notice = formatError({
179
+ details: event.details,
180
+ retryable: event.retryable,
181
+ ...(event.code !== undefined ? { code: event.code } : {}),
182
+ }) ?? "";
179
183
  await finalize(notice).catch((error) => log.error(`${label} failed to deliver the agent-failure notice: ${String(error)}`));
180
184
  throw new Error(`agent failed: ${event.details} (retryable=${event.retryable})`);
181
185
  }
@@ -367,7 +371,11 @@ async function streamNativeSlackReply(events, api, target, formatError, initialP
367
371
  }
368
372
  else if (event.type === "failed") {
369
373
  finalized = true;
370
- const notice = formatError({ details: event.details, retryable: event.retryable }) ?? "";
374
+ const notice = formatError({
375
+ details: event.details,
376
+ retryable: event.retryable,
377
+ ...(event.code !== undefined ? { code: event.code } : {}),
378
+ }) ?? "";
371
379
  if (notice) {
372
380
  pendingText += `${fullAnswer.trim() ? "\n\n" : ""}${notice}`;
373
381
  fullAnswer += `${fullAnswer.trim() ? "\n\n" : ""}${notice}`;
@@ -1,7 +1,7 @@
1
1
  /** Slack Web API transport: one JSON pipeline plus authenticated, capped private-file downloads. */
2
2
  import { mkdir, writeFile } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
- import { codePointPrefix } from "../text.js";
4
+ import { codePointPrefix } from "../kit/text.js";
5
5
  const API_TIMEOUT_MS = 30_000;
6
6
  const DOWNLOAD_TIMEOUT_MS = 120_000;
7
7
  const MAX_DOWNLOAD_BYTES = 20 * 1024 * 1024;
@@ -1,4 +1,4 @@
1
- import type { ChannelModule } from "../../host/node.ts";
1
+ import type { ChannelModule } from "../../channel.ts";
2
2
  import { type SlackEventEnvelope, type SlackFile, type SlackMessageEvent, type SlackRoute, defaultSlackRoute, slackEnvelope } from "./parse.ts";
3
3
  import { type SlackFailure, type SlackRendering } from "./preview.ts";
4
4
  export { defaultSlackRoute, slackEnvelope };