@fastagent-sh/fastagent 0.18.0 → 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 (159) 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 +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.js +1 -1
  22. package/dist/channels/feishu/feishu.d.ts +1 -1
  23. package/dist/channels/feishu/feishu.js +7 -7
  24. package/dist/channels/feishu/invoke-turn.d.ts +1 -1
  25. package/dist/channels/feishu/invoke-turn.js +3 -4
  26. package/dist/channels/feishu/preview.d.ts +1 -1
  27. package/dist/channels/feishu/preview.js +7 -3
  28. package/dist/channels/feishu/ws-ingress.d.ts +1 -1
  29. package/dist/channels/github/github.d.ts +1 -1
  30. package/dist/channels/http.d.ts +11 -7
  31. package/dist/channels/http.js +1 -109
  32. package/dist/channels/{context-buffer.js → kit/context-buffer.js} +2 -2
  33. package/dist/channels/{invoke-turn-kit.d.ts → kit/invoke-turn-kit.d.ts} +14 -3
  34. package/dist/channels/{invoke-turn-kit.js → kit/invoke-turn-kit.js} +17 -6
  35. package/dist/channels/{preview-kit.d.ts → kit/preview-kit.d.ts} +3 -1
  36. package/dist/channels/{seen.js → kit/seen.js} +1 -1
  37. package/dist/channels/{state.js → kit/state.js} +4 -7
  38. package/dist/channels/{stop-command.d.ts → kit/stop-command.d.ts} +1 -1
  39. package/dist/channels/{stop-command.js → kit/stop-command.js} +2 -2
  40. package/dist/channels/{tasks.js → kit/tasks.js} +1 -1
  41. package/dist/channels/{thread-participants.js → kit/thread-participants.js} +1 -1
  42. package/dist/channels/{turn-queue.js → kit/turn-queue.js} +2 -2
  43. package/dist/channels/{turn-store.js → kit/turn-store.js} +1 -1
  44. package/dist/channels/lark/lark.d.ts +1 -1
  45. package/dist/channels/serve.d.ts +73 -0
  46. package/dist/channels/serve.js +243 -0
  47. package/dist/channels/slack/bot-auth.js +3 -14
  48. package/dist/channels/slack/context-buffer.d.ts +2 -2
  49. package/dist/channels/slack/context-buffer.js +2 -2
  50. package/dist/channels/slack/invoke-turn.d.ts +1 -1
  51. package/dist/channels/slack/invoke-turn.js +8 -5
  52. package/dist/channels/slack/parse.js +2 -2
  53. package/dist/channels/slack/preview.d.ts +1 -1
  54. package/dist/channels/slack/preview.js +11 -3
  55. package/dist/channels/slack/slack-api.js +1 -1
  56. package/dist/channels/slack/slack.d.ts +1 -1
  57. package/dist/channels/slack/slack.js +8 -8
  58. package/dist/channels/slack/welcomed.js +1 -1
  59. package/dist/channels/telegram/context-buffer.d.ts +2 -2
  60. package/dist/channels/telegram/context-buffer.js +2 -2
  61. package/dist/channels/telegram/invoke-turn.d.ts +1 -1
  62. package/dist/channels/telegram/invoke-turn.js +1 -1
  63. package/dist/channels/telegram/parse.js +2 -2
  64. package/dist/channels/telegram/preview.d.ts +1 -1
  65. package/dist/channels/telegram/preview.js +6 -2
  66. package/dist/channels/telegram/telegram.d.ts +1 -1
  67. package/dist/channels/telegram/telegram.js +3 -3
  68. package/dist/channels/telegram/turn-store.d.ts +2 -2
  69. package/dist/channels/telegram/turn-store.js +2 -2
  70. package/dist/cli/commands/add.js +1 -1
  71. package/dist/cli/commands/dev.js +25 -13
  72. package/dist/cli/commands/fire.js +1 -1
  73. package/dist/cli/commands/info.js +7 -4
  74. package/dist/cli/commands/schedule.js +1 -1
  75. package/dist/cli/commands/start.js +63 -94
  76. package/dist/cli/commands/tool.js +5 -7
  77. package/dist/cli/program.js +1 -1
  78. package/dist/cli/serve.d.ts +18 -57
  79. package/dist/cli/serve.js +57 -243
  80. package/dist/cli/shared.d.ts +1 -1
  81. package/dist/cli/shared.js +2 -2
  82. package/dist/core.d.ts +3 -5
  83. package/dist/core.js +7 -5
  84. package/dist/deploy/preflight.js +1 -1
  85. package/dist/dev-supervisor.js +9 -3
  86. package/dist/engines/pi/agent-session-factory.d.ts +104 -0
  87. package/dist/engines/pi/agent-session-factory.js +314 -0
  88. package/dist/engines/pi/config.d.ts +4 -4
  89. package/dist/engines/pi/config.js +1 -1
  90. package/dist/engines/pi/create.d.ts +56 -43
  91. package/dist/engines/pi/create.js +141 -71
  92. package/dist/engines/pi/definition.d.ts +41 -7
  93. package/dist/engines/pi/definition.js +115 -11
  94. package/dist/engines/pi/invoke-session.d.ts +22 -19
  95. package/dist/engines/pi/invoke-session.js +286 -100
  96. package/dist/engines/pi/login.js +7 -4
  97. package/dist/engines/pi/models.d.ts +14 -0
  98. package/dist/engines/pi/models.js +9 -1
  99. package/dist/engines/pi/open.d.ts +9 -6
  100. package/dist/engines/pi/open.js +20 -10
  101. package/dist/engines/pi/report.d.ts +0 -4
  102. package/dist/engines/pi/report.js +0 -7
  103. package/dist/engines/pi/service.d.ts +23 -0
  104. package/dist/engines/pi/service.js +19 -0
  105. package/dist/engines/pi/session-builder.js +84 -88
  106. package/dist/engines/pi/session-control.d.ts +10 -9
  107. package/dist/engines/pi/session-control.js +114 -77
  108. package/dist/engines/pi/session-inheritance.d.ts +44 -0
  109. package/dist/engines/pi/session-inheritance.js +242 -0
  110. package/dist/engines/pi/session-settings.d.ts +14 -2
  111. package/dist/engines/pi/session-settings.js +18 -0
  112. package/dist/engines/pi/session-store.d.ts +70 -0
  113. package/dist/engines/pi/session-store.js +313 -0
  114. package/dist/engines/pi/tool-context.d.ts +18 -8
  115. package/dist/engines/pi/tool-context.js +23 -1
  116. package/dist/engines/pi/tool.d.ts +5 -5
  117. package/dist/engines/pi/tool.js +1 -1
  118. package/dist/engines/pi/turn-kit.d.ts +32 -6
  119. package/dist/engines/pi/turn-kit.js +28 -1
  120. package/dist/engines/pi/wake-tool.js +1 -1
  121. package/dist/index.d.ts +1 -0
  122. package/dist/index.js +1 -0
  123. package/dist/log.d.ts +3 -0
  124. package/dist/log.js +5 -1
  125. package/dist/node.d.ts +16 -0
  126. package/dist/node.js +20 -0
  127. package/dist/paths.d.ts +7 -0
  128. package/dist/paths.js +10 -0
  129. package/dist/pi.d.ts +14 -8
  130. package/dist/pi.js +6 -5
  131. package/dist/scaffold/templates/persona.md +2 -2
  132. package/dist/schedule/state.js +4 -9
  133. package/dist/schedule/wakeups.d.ts +1 -1
  134. package/dist/schedule/wakeups.js +1 -1
  135. package/dist/service.d.ts +158 -0
  136. package/dist/service.js +354 -0
  137. package/dist/session-remote.d.ts +1 -1
  138. package/dist/session.d.ts +4 -2
  139. package/dist/session.js +1 -1
  140. package/package.json +14 -5
  141. package/dist/engines/pi/harness.d.ts +0 -115
  142. package/dist/engines/pi/harness.js +0 -154
  143. package/dist/engines/pi/invoke.d.ts +0 -60
  144. package/dist/engines/pi/invoke.js +0 -459
  145. package/dist/engines/pi/read-image.d.ts +0 -4
  146. package/dist/engines/pi/read-image.js +0 -62
  147. package/dist/engines/pi/sessions.d.ts +0 -60
  148. package/dist/engines/pi/sessions.js +0 -464
  149. package/dist/host/node.js +0 -52
  150. /package/dist/channels/{context-buffer.d.ts → kit/context-buffer.d.ts} +0 -0
  151. /package/dist/channels/{preview-kit.js → kit/preview-kit.js} +0 -0
  152. /package/dist/channels/{seen.d.ts → kit/seen.d.ts} +0 -0
  153. /package/dist/channels/{state.d.ts → kit/state.d.ts} +0 -0
  154. /package/dist/channels/{tasks.d.ts → kit/tasks.d.ts} +0 -0
  155. /package/dist/channels/{text.d.ts → kit/text.d.ts} +0 -0
  156. /package/dist/channels/{text.js → kit/text.js} +0 -0
  157. /package/dist/channels/{thread-participants.d.ts → kit/thread-participants.d.ts} +0 -0
  158. /package/dist/channels/{turn-queue.d.ts → kit/turn-queue.d.ts} +0 -0
  159. /package/dist/channels/{turn-store.d.ts → kit/turn-store.d.ts} +0 -0
@@ -1,5 +1,5 @@
1
- import { type ChannelContext, type LongConnection, type Routes } from "../../host/node.ts";
2
- import { type ModuleLoadFailure } from "../../loader.ts";
1
+ import type { ChannelContext, LongConnection, Routes } from "../channel.ts";
2
+ import { type ModuleLoadFailure } from "../loader.ts";
3
3
  /** A dropped route: two channels claim the same key. Surfaced, never silent. */
4
4
  export interface ChannelCollision {
5
5
  route: string;
@@ -1,12 +1,15 @@
1
1
  /**
2
2
  * Channel discovery (the N axis, filesystem form). A channel file default-exports either the existing
3
3
  * route factory `(ctx) => Routes`, or an explicit long-connection module `{ name, connect(ctx, signal) }`.
4
+ *
5
+ * Engine-neutral, and living here rather than under `engines/` because of it: reading `channels/*.ts`
6
+ * is the Channel contract plus a directory, with no engine in sight.
4
7
  */
5
8
  import { readdir } from "node:fs/promises";
6
9
  import { isAbsolute, join } from "node:path";
7
- import { parseRouteKey, } from "../../host/node.js";
8
- import { isModuleFile, loadModuleDir } from "../../loader.js";
9
- import { assertInsideAgentDir } from "../../paths.js";
10
+ import { assertRouteKey, routeKeysConflict } from "./serve.js";
11
+ import { isModuleFile, loadModuleDir } from "../loader.js";
12
+ import { assertInsideAgentDir } from "../paths.js";
10
13
  function longConnectionModule(value) {
11
14
  return value !== null && typeof value === "object" && typeof value.connect === "function";
12
15
  }
@@ -79,9 +82,7 @@ function validateRoutes(value, label) {
79
82
  if (typeof handler !== "function") {
80
83
  throw new Error(`${label}: route "${route}" must map to a handler function, got ${typeof handler}`);
81
84
  }
82
- if (!parseRouteKey(route).path.startsWith("/")) {
83
- throw new Error(`${label}: route "${route}" is not a valid route key (expected "METHOD /path" or "/path")`);
84
- }
85
+ assertRouteKey(route, (problem) => `${label}: route "${route}" is not a valid route key — ${problem}`);
85
86
  }
86
87
  return routes;
87
88
  }
@@ -121,12 +122,7 @@ export async function loadChannels(dir, ctx) {
121
122
  }
122
123
  const declaredRoutes = validateRoutes(declared, label);
123
124
  for (const [route, handler] of declaredRoutes) {
124
- const parsed = parseRouteKey(route);
125
- const clash = Object.keys(routes).some((key) => {
126
- const existing = parseRouteKey(key);
127
- return (existing.path === parsed.path &&
128
- (existing.method === undefined || parsed.method === undefined || existing.method === parsed.method));
129
- });
125
+ const clash = Object.keys(routes).some((key) => routeKeysConflict(key, route));
130
126
  if (clash) {
131
127
  collisions.push({ route, source: label });
132
128
  continue;
@@ -17,7 +17,7 @@
17
17
  * Budget: a card entity is capped at 30 KB, so the final answer's card chunk (and the live view) stay
18
18
  * well under it; longer answers overflow into follow-up messages (preview.ts owns that policy).
19
19
  */
20
- import { truncateCodePointPrefix } from "../text.js";
20
+ import { truncateCodePointPrefix } from "../kit/text.js";
21
21
  /** The append-only answer element's id — shared by create (card.ts) and update (preview.ts). */
22
22
  export const ANSWER_ELEMENT_ID = "answer";
23
23
  /** The volatile process element's id (thinking tail + tool lines + retry notice; live-only). */
@@ -1,4 +1,4 @@
1
- import { type ContextBuffer } from "../context-buffer.ts";
1
+ import { type ContextBuffer } from "../kit/context-buffer.ts";
2
2
  import type { NormalizedFeishuMessage } from "./model.ts";
3
3
  export interface FeishuBufferedResource {
4
4
  messageId: string;
@@ -1,13 +1,13 @@
1
1
  /**
2
2
  * Feishu/Lark's half of the shared context buffer (mechanics + consume protocol:
3
- * ../context-buffer.ts): the entry shape, its fold-line rendering, place-key derivation, and
3
+ * ../kit/context-buffer.ts): the entry shape, its fold-line rendering, place-key derivation, and
4
4
  * buffered-resource selection. Entries are bucketed by conversation place (main chat, or one
5
5
  * concrete thread root) and folded into the next answered turn in that place.
6
6
  */
7
7
  import { log } from "../../log.js";
8
- import { BUFFER_ATTACH_MAX, BUFFER_LINE_MAX_CHARS, createContextBuffer as createGenericContextBuffer, } from "../context-buffer.js";
9
- import { loadStateFile, saveStateFile } from "../state.js";
10
- import { truncateCodePointPrefix } from "../text.js";
8
+ import { BUFFER_ATTACH_MAX, BUFFER_LINE_MAX_CHARS, createContextBuffer as createGenericContextBuffer, } from "../kit/context-buffer.js";
9
+ import { loadStateFile, saveStateFile } from "../kit/state.js";
10
+ import { truncateCodePointPrefix } from "../kit/text.js";
11
11
  function bufferLine(entry) {
12
12
  const meta = [`msg ${entry.messageId}`, entry.replyTo ? `reply to msg ${entry.replyTo}` : undefined]
13
13
  .filter(Boolean)
@@ -21,7 +21,7 @@
21
21
  */
22
22
  import { mkdir, writeFile } from "node:fs/promises";
23
23
  import { join } from "node:path";
24
- import { utf8Prefix } from "../text.js";
24
+ import { utf8Prefix } from "../kit/text.js";
25
25
  /** Per-attempt timeout for a JSON API call — small JSON round-trips, so 30s is generous. */
26
26
  const API_TIMEOUT_MS = 30_000;
27
27
  /** Timeout for downloading resource bytes — sized for a slow link, not a JSON call. */
@@ -1,4 +1,4 @@
1
- import type { ChannelModule, LongConnectionChannelModule } from "../../host/node.ts";
1
+ import type { ChannelModule, LongConnectionChannelModule } from "../../channel.ts";
2
2
  import { type FeishuCloudProfile } from "./cloud.ts";
3
3
  import { type FeishuMessage, type FeishuMessageEvent, type FeishuRoute, defaultFeishuRoute, feishuEnvelope } from "./parse.ts";
4
4
  import { type FeishuFailure } from "./preview.ts";
@@ -12,19 +12,19 @@ import { isAbsolute, join } from "node:path";
12
12
  import { log } from "../../log.js";
13
13
  import { readBodyCapped } from "../body.js";
14
14
  import { text } from "../respond.js";
15
- import { createSeenRing } from "../seen.js";
16
- import { createTaskTracker } from "../tasks.js";
17
- import { ensureStateHome, loadStateFile, saveStateFile } from "../state.js";
18
- import { dispatchStop, isStopText } from "../stop-command.js";
19
- import { createTurnQueue } from "../turn-queue.js";
20
- import { createTurnStore } from "../turn-store.js";
15
+ import { createSeenRing } from "../kit/seen.js";
16
+ import { createTaskTracker } from "../kit/tasks.js";
17
+ import { ensureStateHome, loadStateFile, saveStateFile } from "../kit/state.js";
18
+ import { dispatchStop, isStopText } from "../kit/stop-command.js";
19
+ import { createTurnQueue } from "../kit/turn-queue.js";
20
+ import { createTurnStore } from "../kit/turn-store.js";
21
21
  import { FEISHU_CLOUD } from "./cloud.js";
22
22
  import { collectFeishuBufferedAttachments, createFeishuContextBuffer, feishuBufferPlaceKey, feishuBufferText, } from "./context-buffer.js";
23
23
  import { decryptEvent, timingSafeEqualStr, verifySignature } from "./crypto.js";
24
24
  import { invokeFeishuTurn } from "./invoke-turn.js";
25
25
  import { createFeishuApi } from "./feishu-api.js";
26
26
  import { normalizeFeishuMessage } from "./normalize.js";
27
- import { createThreadParticipants } from "../thread-participants.js";
27
+ import { createThreadParticipants } from "../kit/thread-participants.js";
28
28
  import { FEISHU_GROUP_CONTEXT_SCOPE, FEISHU_MESSAGE_READ_REQUEST, FEISHU_MESSAGE_READ_SCOPE, scopeSatisfied, } from "./setup-mode.js";
29
29
  import { cloudEnvelope, defaultFeishuRoute, feishuEnvelope, placeKey, senderId, senderLabel, } from "./parse.js";
30
30
  import { defaultErrorMessage, mountFeishuPreview, settleFeishuPreview, streamFeishuReply, } from "./preview.js";
@@ -13,7 +13,7 @@
13
13
  * or hide its still-readable siblings.
14
14
  */
15
15
  import type { Agent, AgentEvent } from "../../agent.ts";
16
- import { type BusyRetry } from "../invoke-turn-kit.ts";
16
+ import { type BusyRetry } from "../kit/invoke-turn-kit.ts";
17
17
  import type { FeishuBufferedRef } from "./context-buffer.ts";
18
18
  import type { FeishuApi } from "./feishu-api.ts";
19
19
  /** Everything the transport needs to fetch a turn's attachments. */
@@ -1,8 +1,8 @@
1
1
  import { log } from "../../log.js";
2
- import { DEFAULT_BUSY_RETRY, attachedFilesManifest, attributedFileName, backgroundImagesManifest, missingAttachmentsNote, streamTurnWithBusyRetry, } from "../invoke-turn-kit.js";
3
- import { BUFFER_ATTACH_MAX } from "../context-buffer.js";
2
+ import { DEFAULT_BUSY_RETRY, attachedFilesManifest, attributedFileName, backgroundImagesManifest, missingAttachmentsNote, streamTurnWithBusyRetry, } from "../kit/invoke-turn-kit.js";
3
+ import { BUFFER_ATTACH_MAX } from "../kit/context-buffer.js";
4
4
  import { parseContent } from "./parse.js";
5
- import { REFERENT_MAX_CODE_POINTS, truncateCodePointPrefix } from "../text.js";
5
+ import { REFERENT_MAX_CODE_POINTS, truncateCodePointPrefix } from "../kit/text.js";
6
6
  /** The per-turn REPLY CONTRACT, appended to the prompt (not the system prompt). Two halves, one
7
7
  * concept — what happens to the reply: its FORMAT (rendered in a card whose markdown element is the
8
8
  * natural fit for LLM output — steer away from HTML/plain) and its DELIVERY OWNERSHIP (the channel
@@ -169,7 +169,6 @@ async function resolveTurnInputs(t, attachments) {
169
169
  referentBlock += chain.block;
170
170
  }
171
171
  }
172
- // Primary first and fail-fast: these are resources the current user explicitly pointed at.
173
172
  const imageRefs = [];
174
173
  for (const ref of images)
175
174
  imageRefs.push(await t.api.fetchImage(ref.msg, ref.key));
@@ -1,6 +1,6 @@
1
1
  import type { AgentEvent } from "../../agent.ts";
2
2
  import { type FeishuApi, type FeishuTarget } from "./feishu-api.ts";
3
- import { type ChannelFailure, defaultErrorMessage } from "../preview-kit.ts";
3
+ import { type ChannelFailure, defaultErrorMessage } from "../kit/preview-kit.ts";
4
4
  /** A terminal failure, as the channel hands it to `onError` — the shared channel shape. */
5
5
  export type FeishuFailure = ChannelFailure;
6
6
  export { defaultErrorMessage };
@@ -25,8 +25,8 @@ import { setTimeout as sleep } from "node:timers/promises";
25
25
  import { log } from "../../log.js";
26
26
  import { ANSWER_ELEMENT_ID, CARD_MARKDOWN_MAX_BYTES, PROCESS_ELEMENT_ID, cardEntityContent, finalCardJson, streamingCardJson, } from "./card.js";
27
27
  import { chunkFeishuText, isCardStreamingClosed } from "./feishu-api.js";
28
- import { RETRY_NOTICE, THINKING_PLACEHOLDER, applyTurnEvent, composeTurnBody, createPreviewPump, createTurnView, defaultErrorMessage, revealedAnswer, thinkingLine, toolLines, } from "../preview-kit.js";
29
- import { truncateCodePointPrefix, truncateUtf8 } from "../text.js";
28
+ import { RETRY_NOTICE, THINKING_PLACEHOLDER, applyTurnEvent, composeTurnBody, createPreviewPump, createTurnView, defaultErrorMessage, revealedAnswer, thinkingLine, toolLines, } from "../kit/preview-kit.js";
29
+ import { truncateCodePointPrefix, truncateUtf8 } from "../kit/text.js";
30
30
  export { defaultErrorMessage };
31
31
  /** How often (ms) to push a live-preview snapshot; tool events still flush on the next loop. Cardkit
32
32
  * allows 10 QPS per card entity (50 per app), but one snapshot a second reads smoothly (the client
@@ -286,7 +286,11 @@ export async function streamFeishuReply(events, api, target, formatError, initia
286
286
  // we throw below regardless.
287
287
  finalized = true;
288
288
  {
289
- const msg = formatError({ details: e.details, retryable: e.retryable }) ?? "";
289
+ const msg = formatError({
290
+ details: e.details,
291
+ retryable: e.retryable,
292
+ ...(e.code !== undefined ? { code: e.code } : {}),
293
+ }) ?? "";
290
294
  try {
291
295
  await settle(msg);
292
296
  }
@@ -1,4 +1,4 @@
1
- import type { LongConnection } from "../../host/node.ts";
1
+ import type { LongConnection } from "../../channel.ts";
2
2
  import type { FeishuCloudKind } from "./cloud.ts";
3
3
  import type { FeishuMessageEvent } from "./parse.ts";
4
4
  interface FeishuWsClient {
@@ -1,5 +1,5 @@
1
1
  import type { Schema } from "@octokit/webhooks-types";
2
- import type { ChannelModule } from "../../host/node.ts";
2
+ import type { ChannelModule } from "../../channel.ts";
3
3
  /** A verified GitHub webhook event. Header fields plus the official typed payload. */
4
4
  export interface GithubEvent {
5
5
  /** `X-GitHub-Event` (e.g. "pull_request", "issue_comment"). */
@@ -1,4 +1,14 @@
1
- import type { IncomingMessage, ServerResponse } from "node:http";
1
+ /**
2
+ * HTTP/SSE channel: fan one invoke stream out to Server-Sent Events.
3
+ *
4
+ * The handler is Fetch-shaped (`(Request) => Promise<Response>`) — the cross-runtime form every
5
+ * embedding host speaks, so it mounts inside an existing app's own route. It is path-agnostic. The
6
+ * web stream primitives give cancellation (consumer disconnect → cancel() → iterator.return() →
7
+ * invoke cancellation, SPEC MUST 3), backpressure (pull-based), and the body cap natively.
8
+ *
9
+ * Serving it is somebody else's job: the node:http bridge and the route table belong to the HOST
10
+ * (`./serve.ts`). This file knows only the Agent contract and the wire shape of one stream.
11
+ */
2
12
  import type { Agent } from "../agent.ts";
3
13
  /** Request body cap (1 MiB) — shared by every Prompt-bearing wire surface (the control plane's
4
14
  * dispatch imports it), so the two caps cannot drift apart. */
@@ -19,9 +29,3 @@ export declare const INVOKE_EXAMPLE_BODY = "{\"session\":\"dev\",\"text\":\"hell
19
29
  * Returns SSE (`text/event-stream`) with one `data:` line per AgentEvent.
20
30
  */
21
31
  export declare function createInvokeHandler(agent: Agent): (req: Request) => Promise<Response>;
22
- /**
23
- * node:http adapter for a Fetch handler. Bridges IncomingMessage → Request and pumps the
24
- * Response body back to ServerResponse with backpressure; a client disconnect (`res` close)
25
- * cancels both the request signal and the response stream (→ invoke cancellation).
26
- */
27
- export declare function nodeListener(handler: (req: Request) => Promise<Response>): (req: IncomingMessage, res: ServerResponse) => void;
@@ -1,17 +1,5 @@
1
- /**
2
- * HTTP/SSE channel: fan one invoke stream out to Server-Sent Events.
3
- *
4
- * The handler is Fetch-shaped (`(Request) => Promise<Response>`) — the cross-runtime form every
5
- * embedding host speaks, so it mounts inside an existing app's own route. It is path-agnostic. The
6
- * web stream primitives give cancellation (consumer disconnect → cancel() → iterator.return() →
7
- * invoke cancellation, SPEC MUST 3), backpressure (pull-based), and the body cap natively.
8
- *
9
- * `nodeListener` is the thin node:http adapter for the embedded `fastagent dev/start` server.
10
- */
11
- import { Readable } from "node:stream";
12
- import { log } from "../log.js";
13
1
  import { readBodyCapped } from "./body.js";
14
- import { text, textHeaders } from "./respond.js";
2
+ import { text } from "./respond.js";
15
3
  /** Request body cap (1 MiB) — shared by every Prompt-bearing wire surface (the control plane's
16
4
  * dispatch imports it), so the two caps cannot drift apart. */
17
5
  export const MAX_BODY_BYTES = 1 << 20;
@@ -109,99 +97,3 @@ export function createInvokeHandler(agent) {
109
97
  });
110
98
  };
111
99
  }
112
- /**
113
- * node:http adapter for a Fetch handler. Bridges IncomingMessage → Request and pumps the
114
- * Response body back to ServerResponse with backpressure; a client disconnect (`res` close)
115
- * cancels both the request signal and the response stream (→ invoke cancellation).
116
- */
117
- export function nodeListener(handler) {
118
- return (req, res) => {
119
- void pump(handler, req, res); // safe: pump is TOTAL (never rejects) — see its contract below
120
- };
121
- }
122
- /**
123
- * Consume ONE request and drive its response to a terminal state. pump is TOTAL: a SINGLE try/catch wraps
124
- * the whole request→response→stream path, so EVERY failure — a handler throw, a non-Response return
125
- * (`response.headers` undefined), a header Node rejects, `getReader`, or a body stream that errors
126
- * mid-flight — ends the response and the returned promise NEVER rejects, which is what lets the
127
- * `void pump(...)` above be safe. Before any byte goes out (headers not sent) it is a clean 500; once the
128
- * response is streaming, the only honest signal left is to destroy the socket (truncated stream, not a
129
- * hang). The process installs no `unhandledRejection` handler by design: robustness against a background
130
- * throw is each fire-and-forget's OWN contract (fail into a terminal HTTP response here), not a global net
131
- * that would blanket-swallow.
132
- */
133
- async function pump(handler, req, res) {
134
- const controller = new AbortController();
135
- res.on("close", () => controller.abort());
136
- try {
137
- const method = req.method ?? "GET";
138
- const hasBody = method !== "GET" && method !== "HEAD";
139
- const headers = new Headers();
140
- for (const [k, v] of Object.entries(req.headers)) {
141
- if (Array.isArray(v))
142
- for (const vv of v)
143
- headers.append(k, vv);
144
- else if (v != null)
145
- headers.set(k, v);
146
- }
147
- const request = new Request(`http://${req.headers.host ?? "localhost"}${req.url ?? "/"}`, {
148
- method,
149
- headers,
150
- body: hasBody ? Readable.toWeb(req) : undefined,
151
- duplex: "half",
152
- signal: controller.signal,
153
- });
154
- const response = await handler(request);
155
- const outHeaders = {};
156
- response.headers.forEach((value, key) => {
157
- outHeaders[key] = value;
158
- });
159
- res.writeHead(response.status, outHeaders);
160
- if (!response.body) {
161
- res.end();
162
- return;
163
- }
164
- const reader = response.body.getReader();
165
- res.on("close", () => void reader.cancel());
166
- for (;;) {
167
- const { done, value } = await reader.read();
168
- if (done || res.destroyed)
169
- break;
170
- // Backpressure: wait for drain, but ALSO resolve on close. A client disconnect after write()
171
- // returned false never emits 'drain' on the closed socket, so waiting on 'drain' alone would
172
- // suspend pump() forever (leaking the request/stream).
173
- if (!res.write(value)) {
174
- await new Promise((resolve) => {
175
- const settle = () => {
176
- res.off("drain", settle);
177
- res.off("close", settle);
178
- resolve();
179
- };
180
- res.once("drain", settle);
181
- res.once("close", settle);
182
- });
183
- }
184
- }
185
- if (!res.destroyed)
186
- res.end(); // normal completion
187
- }
188
- catch (error) {
189
- // The ONE totality boundary: every failure above lands here, so pump never rejects (see the header
190
- // doc) — which REQUIRES the catch itself not to throw. Don't leak the internal message to the client.
191
- log.error(`[host] request failed: ${String(error)}`);
192
- // Never touch an already-terminal res: a client that disconnects during the handler await destroys res
193
- // (headers not yet sent), and writeHead/end on a dead socket can throw ERR_STREAM_DESTROYED here — which
194
- // WOULD be the unhandled rejection this boundary exists to kill. One named gate states the invariant;
195
- // with it the catch is provably non-throwing (writeHead only when !headersSent && !destroyed, destroy is
196
- // idempotent).
197
- if (res.destroyed)
198
- return;
199
- if (res.headersSent) {
200
- res.destroy(error instanceof Error ? error : undefined); // streaming → truncate (not a hang)
201
- }
202
- else {
203
- res.writeHead(500, textHeaders); // pre-header → a clean 500
204
- res.end("internal error\n");
205
- }
206
- }
207
- }
@@ -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
@@ -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);
@@ -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());
@@ -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 {
@@ -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
@@ -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;