@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
@@ -1,9 +1,98 @@
1
1
  import { INVALID_COMMAND_CODE } from "../session.js";
2
2
  import { timingSafeEqual } from "node:crypto";
3
+ import { parseRouteKey, withoutBody } from "./serve.js";
4
+ import { log } from "../log.js";
3
5
  import { readBodyCapped } from "./body.js";
4
6
  import { MAX_BODY_BYTES, createInvokeHandler, sseHeartbeat } from "./http.js";
5
7
  import { text } from "./respond.js";
8
+ /** The prefix this plane OWNS: everything under it is the plane's to answer. */
9
+ const CONTROL_PREFIX = "/control";
6
10
  const json = (value, status = 200) => new Response(`${JSON.stringify(value)}\n`, { status, headers: { "content-type": "application/json" } });
11
+ /**
12
+ * The plane as one mounted sub-application rather than routes sharing a prefix.
13
+ *
14
+ * CORS belongs to every reply that leaves the plane — including the ones no route produces (an
15
+ * unknown path, an unserved method, a throwing handler). As separate routes those came from the
16
+ * host, outside anything the plane could decorate. Owning the prefix makes them its own answers,
17
+ * headers applied at the single exit they share.
18
+ *
19
+ * `*` is the right origin: authorisation is the bearer token — never the origin, never a cookie —
20
+ * so an origin that cannot present it gets 401 either way, and a deployment cannot know the origins
21
+ * of the GUIs that will manage it (§14's asymmetry).
22
+ *
23
+ * `authorization` is not CORS-safelisted, so EVERY call preflights, including a plain GET.
24
+ * `content-type` is not either (only three values are, and `application/json` is not among them),
25
+ * so a browser POSTing to dispatch/invoke names it — allowing just `authorization` leaves precisely
26
+ * the WRITE routes unreachable while reads work.
27
+ */
28
+ function planeApp(routes) {
29
+ // Normalised keys (see serve.ts): the method is upper-cased when parsed.
30
+ const byKey = new Map(Object.entries(routes).map(([key, handler]) => {
31
+ const { method, path } = parseRouteKey(key);
32
+ return [method ? `${method} ${path}` : path, handler];
33
+ }));
34
+ const methodsByPath = new Map();
35
+ for (const key of Object.keys(routes)) {
36
+ const { method, path } = parseRouteKey(key);
37
+ const methods = methodsByPath.get(path) ?? new Set();
38
+ if (method)
39
+ methods.add(method);
40
+ methodsByPath.set(path, methods);
41
+ }
42
+ // Per PATH, stating what it actually serves — omitting a method it does serve has the browser
43
+ // refuse a call that would have worked. `HEAD` is that case: every GET route answers it.
44
+ const allowMethods = (path, requested) => {
45
+ const methods = new Set(methodsByPath.get(path) ?? []);
46
+ if (methods.has("GET"))
47
+ methods.add("HEAD");
48
+ // The requested method is always allowed, even where this path does not serve it: preflight is a
49
+ // gate applied BEFORE the request exists, so refusing there means the real request is never sent
50
+ // and the client sees an opaque network error. Allowing it lets the plane's own 404/405 arrive,
51
+ // with these headers and an explanation.
52
+ if (requested)
53
+ methods.add(requested.toUpperCase());
54
+ return [...methods, "OPTIONS"].join(", ");
55
+ };
56
+ return async (req) => {
57
+ const path = new URL(req.url).pathname;
58
+ const known = methodsByPath.has(path);
59
+ const answer = async () => {
60
+ // A preflight carries no token — that is its purpose — so it is answered before auth, and for
61
+ // ANY path under the prefix: gating it would stop the request the 404 below is waiting for.
62
+ if (req.method === "OPTIONS")
63
+ return new Response(null, { status: 204 });
64
+ const handler = byKey.get(`${req.method} ${path}`) ?? byKey.get(path);
65
+ if (handler)
66
+ return await handler(req);
67
+ if (known && req.method === "HEAD") {
68
+ const get = byKey.get(`GET ${path}`);
69
+ if (get)
70
+ return await get(req);
71
+ }
72
+ // 404 vs 405 as in the host router: a client reads 404 as "this serve predates the route".
73
+ if (known)
74
+ return text("method not allowed\n", 405);
75
+ return text("not found\n", 404);
76
+ };
77
+ let res;
78
+ try {
79
+ // HEAD carries no content, whichever branch answered — including this plane's own 404/405.
80
+ const answered = await answer();
81
+ res = req.method === "HEAD" ? withoutBody(answered) : answered;
82
+ }
83
+ catch (error) {
84
+ // The plane's own totality boundary: a rejecting handler (`commands()` on an unreadable
85
+ // definition) must still answer with the headers; the message stays internal.
86
+ log.error(`[control] ${req.method} ${path} failed: ${String(error)}`);
87
+ res = text("internal error\n", 500);
88
+ }
89
+ // THE single exit. Every reply above — route, preflight, 404, 405, 500 — leaves through here.
90
+ res.headers.set("access-control-allow-origin", "*");
91
+ res.headers.set("access-control-allow-headers", "authorization, content-type");
92
+ res.headers.set("access-control-allow-methods", allowMethods(path, req.headers.get("access-control-request-method")));
93
+ return res;
94
+ };
95
+ }
7
96
  // ONE constant for every Prompt-bearing wire surface (imported from the invoke channel — the two
8
97
  // caps cannot drift apart): commands carry Prompts, which may ride base64 images.
9
98
  const DISPATCH_BODY_LIMIT = MAX_BODY_BYTES;
@@ -84,13 +173,26 @@ function parseWireCommand(raw) {
84
173
  }
85
174
  }
86
175
  /**
87
- * Mount the control plane: `GET /control/capabilities|commands|state|entries|events` + `POST
88
- * /control/dispatch`, all bearer-authenticated. `events` streams SSE (`data: <WireEvent>` lines).
176
+ * Create the control plane as a mountable prefix owner: `GET
177
+ * /control/capabilities|commands|state|entries|events` + `POST /control/dispatch`, all
178
+ * bearer-authenticated. `events` streams SSE (`data: <WireEvent>` lines).
179
+ * The plane OWNS {@link CONTROL_PREFIX}: it is mounted as one sub-application, answers its own
180
+ * 404/405/preflight, and puts CORS headers on every reply — see {@link planeApp}.
89
181
  */
90
- export function controlRoutes(control, options) {
182
+ export function createControlPlane(control, options) {
183
+ return mountControlPlane(controlPlaneRoutes(control, options));
184
+ }
185
+ /** Mount a plane route table as a {@link PrefixMount} — the plane owns a PREFIX, while a route
186
+ * table is a set of literal paths. */
187
+ export function mountControlPlane(routes) {
188
+ return { prefix: CONTROL_PREFIX, handler: planeApp(routes) };
189
+ }
190
+ /** The plane's route table. Exported so the conformance sweeps derive their route list from what is
191
+ * actually mounted, rather than from a hand-kept copy that cannot notice a new route. */
192
+ export function controlPlaneRoutes(control, options) {
91
193
  const { token } = options;
92
194
  if (!token)
93
- throw new Error("controlRoutes: a bearer token is required (empty tokens are not a mode)");
195
+ throw new Error("createControlPlane: a bearer token is required (empty tokens are not a mode)");
94
196
  const epoch = crypto.randomUUID();
95
197
  // Timing-safe: the bearer token is this surface's ONLY auth (and the --tunnel warning names it
96
198
  // as the sole protection on a public URL) — a plain === would leak byte-by-byte via timing.
@@ -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)
@@ -69,11 +69,13 @@ export interface FeishuApi {
69
69
  *
70
70
  * `sender` is typed rather than `unknown` because the referent path READS it: an app-sent message
71
71
  * whose id is THIS app's is the agent's own, which the prompt must say instead of attributing it to
72
- * "user cli_…". The message object carries more (`parent_id`, `root_id`, `thread_id`); they stay
73
- * unnamed until something reads them this type is the surface in use, not a mirror of the wire. */
72
+ * "user cli_…". `parent_id` is read by the reply-chain walk (invoke-turn): the message this one
73
+ * itself replied to. The message object carries more (`root_id`, `thread_id`); they stay unnamed
74
+ * until something reads them — this type is the surface in use, not a mirror of the wire. */
74
75
  getMessage(messageId: string): Promise<{
75
76
  message_id?: string;
76
77
  msg_type?: string;
78
+ parent_id?: string;
77
79
  body?: {
78
80
  content?: string;
79
81
  };
@@ -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, removeRetiredStateFile, 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";
@@ -58,6 +58,8 @@ function isStoredFeishuTurn(t) {
58
58
  (r.queueReplyTo === undefined || typeof r.queueReplyTo === "string") &&
59
59
  (r.replyInThread === undefined || typeof r.replyInThread === "boolean") &&
60
60
  (r.parentId === undefined || typeof r.parentId === "string") &&
61
+ (r.parentSession === undefined || typeof r.parentSession === "string") &&
62
+ (r.roomBufferKey === undefined || typeof r.roomBufferKey === "string") &&
61
63
  refs(r.images) &&
62
64
  refs(r.files) &&
63
65
  typeof r.attempts === "number");
@@ -160,10 +162,6 @@ function createFeishuRuntimeFactory(profile, opts, factoryName) {
160
162
  }
161
163
  const stateHome = join(stateRoot, "channels", kind);
162
164
  ensureStateHome(stateHome); // buffers/files may carry chat content; the agent .gitignore covers .state/
163
- // The participant model replaced the owned-thread index (a cache, so nothing is lost). REMOVE THIS
164
- // after the release following the participant model ships — by then no live deployment can still
165
- // be carrying the file. test/migration-deadline.test.ts fails when due.
166
- removeRetiredStateFile(stateHome, "owned-threads.json", label);
167
165
  // The cached bot identity (rationale at the botInfo block above): seed synchronously — the
168
166
  // factory runs to completion before any promise resolves, so botOpenId is still unset here and
169
167
  // the seed is what the first envelope's dispatch sees. Refresh keeps the file current.
@@ -309,13 +307,31 @@ function createFeishuRuntimeFactory(profile, opts, factoryName) {
309
307
  // this snapshot before either commits it. That fan-out loses nothing; claiming by buffer key
310
308
  // would instead couple otherwise-independent root sessions and require failure rollback.
311
309
  const { text: recent, consumed } = buffer.peek(rec.bufferKey);
312
- const prompt = recent ? `[recent group discussion:\n${recent}\n]\n\n${rec.baseText}` : rec.baseText;
313
- const buffered = collectFeishuBufferedAttachments(consumed, {
310
+ // PEEK and never commit: the room still owes this discussion to its OWN memory (§8).
311
+ const room = rec.roomBufferKey !== undefined ? buffer.peek(rec.roomBufferKey) : undefined;
312
+ const roomBlock = room?.text
313
+ ? `[recent discussion in the room this thread branched from — not yet answered there:\n${room.text}\n]\n\n`
314
+ : "";
315
+ const threadBlock = recent ? `[recent group discussion:\n${recent}\n]\n\n` : "";
316
+ const prompt = `${roomBlock}${threadBlock}${rec.baseText}`;
317
+ // Room entries FIRST: the collector keeps the TAIL under its cap, so the thread's own
318
+ // attachments win the slots.
319
+ const buffered = collectFeishuBufferedAttachments([...(room?.consumed ?? []), ...consumed], {
314
320
  images: rec.images.map((ref) => ({ messageId: ref.msg, key: ref.key })),
315
321
  files: rec.files.map((ref) => ({ messageId: ref.msg, key: ref.key, name: ref.name })),
316
322
  });
323
+ // Recorded at ingress (see submit) — never re-derived from the session key, which may be a
324
+ // routed OPAQUE id that only looks like a place key.
325
+ const parentSession = rec.parentSession;
317
326
  try {
318
- await streamFeishuReply(invokeFeishuTurn(agent, rec.session, prompt, { api, chatId: rec.chatId, filesDir: join(stateHome, "files"), label, appId }, { primary: { images: rec.images, files: rec.files, parentId: rec.parentId }, buffered }, () => {
327
+ await streamFeishuReply(invokeFeishuTurn(agent, rec.session, prompt, {
328
+ api,
329
+ chatId: rec.chatId,
330
+ filesDir: join(stateHome, "files"),
331
+ label,
332
+ appId,
333
+ ...(parentSession !== undefined ? { parentSession } : {}),
334
+ }, { primary: { images: rec.images, files: rec.files, parentId: rec.parentId }, buffered }, () => {
319
335
  // Drop intent first: a crash between these writes may re-fold answered context later,
320
336
  // but can never replay this turn after its context was removed.
321
337
  store.remove(rec.id);
@@ -465,7 +481,19 @@ function createFeishuRuntimeFactory(profile, opts, factoryName) {
465
481
  // Memory follows the place (participant model §5): one session per chat, and one per thread.
466
482
  // Keyed by `thread_id`, never `root_id` — the platform's root_id tracks the reply chain and can
467
483
  // differ between messages of ONE thread, which would split a side conversation in two.
468
- const session = r.session ?? placeKey(kind, m);
484
+ const routed = r.session;
485
+ const session = routed ?? placeKey(kind, m);
486
+ // Lineage is recorded ONLY for the default place-derived session. A routed session id is
487
+ // OPAQUE (the route contract), and re-parsing it as a place key would let a three-segment id
488
+ // like "tenant:user:alice" masquerade as a thread and inherit from "tenant:user" — a
489
+ // cross-session injection. Derived from the MESSAGE (the fact this channel owns), at record
490
+ // time, where routed-ness is still known; the dequeue path only reads it back.
491
+ const parentSession = routed === undefined && m.thread_id !== undefined ? placeKey(kind, { chat_id: m.chat_id }) : undefined;
492
+ // Read BEFORE this turn records its own participation below, or it is always true. Keyed by the
493
+ // SOURCE chat, never the answer target a route may name (§8).
494
+ const roomBufferKey = parentSession !== undefined && !threadParticipants.agentSpokeIn(session)
495
+ ? feishuBufferPlaceKey({ chatId: m.chat_id })
496
+ : undefined;
469
497
  const chatId = r.chatId ?? m.chat_id;
470
498
  const sameTarget = chatId === m.chat_id;
471
499
  // Answer where asked (§4): quote in a group so the ask is identifiable among many speakers,
@@ -517,6 +545,8 @@ function createFeishuRuntimeFactory(profile, opts, factoryName) {
517
545
  // already have; it also pins WHICH message is being answered, which a long thread benefits
518
546
  // from anyway.
519
547
  parentId: m.parent_id,
548
+ ...(parentSession !== undefined ? { parentSession } : {}),
549
+ ...(roomBufferKey !== undefined ? { roomBufferKey } : {}),
520
550
  images,
521
551
  files,
522
552
  }, true);
@@ -8,11 +8,12 @@
8
8
  *
9
9
  * Inputs have two tiers. PRIMARY is the summoning message plus the message it explicitly replied to;
10
10
  * any load failure there aborts visibly so the Agent never runs without an input the user pointed at.
11
- * BUFFERED resources come from earlier un-summoned thread/group discussion and degrade per attachment:
12
- * one expired background file must not block the current ask or hide its still-readable siblings.
11
+ * BUFFERED resources come from earlier un-summoned thread/group discussion and from reply-chain
12
+ * ancestors, and degrade per attachment: one expired background file must not block the current ask
13
+ * or hide its still-readable siblings.
13
14
  */
14
15
  import type { Agent, AgentEvent } from "../../agent.ts";
15
- import { type BusyRetry } from "../invoke-turn-kit.ts";
16
+ import { type BusyRetry } from "../kit/invoke-turn-kit.ts";
16
17
  import type { FeishuBufferedRef } from "./context-buffer.ts";
17
18
  import type { FeishuApi } from "./feishu-api.ts";
18
19
  /** Everything the transport needs to fetch a turn's attachments. */
@@ -25,6 +26,11 @@ export interface FeishuTurnTransport {
25
26
  * sender is an app. Needed to tell the agent's OWN messages from any other bot's in the same chat:
26
27
  * `sender_type` alone says "some app", which is not the question the referent path asks. */
27
28
  appId: string;
29
+ /** The place this thread branched from (the chat's main place), when the turn runs in a thread —
30
+ * rides the Scope's lineage extension so a NEW thread session starts from what the room knew
31
+ * (participant-model.md §5). The engine reads it once, at session creation; every later turn
32
+ * carries it inertly. */
33
+ parentSession?: string;
28
34
  }
29
35
  /** An attachment reference: the resource key inside its CARRYING message (the resource API addresses
30
36
  * bytes by message_id + key, so the pair travels together through the turn record). */