@fastagent-sh/fastagent 0.13.0 → 0.15.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 (238) hide show
  1. package/README.md +5 -5
  2. package/dist/agent.d.ts +20 -0
  3. package/dist/agent.js +9 -0
  4. package/dist/channels/context-buffer.d.ts +30 -0
  5. package/dist/channels/context-buffer.js +98 -0
  6. package/dist/channels/control.d.ts +28 -0
  7. package/dist/channels/control.js +214 -0
  8. package/dist/channels/feishu/card.js +1 -1
  9. package/dist/channels/feishu/context-buffer.d.ts +43 -0
  10. package/dist/channels/feishu/context-buffer.js +72 -0
  11. package/dist/channels/feishu/crypto.d.ts +4 -2
  12. package/dist/channels/feishu/crypto.js +4 -2
  13. package/dist/channels/feishu/feishu-api.d.ts +15 -7
  14. package/dist/channels/feishu/feishu-api.js +22 -4
  15. package/dist/channels/feishu/feishu.d.ts +38 -16
  16. package/dist/channels/feishu/feishu.js +286 -151
  17. package/dist/channels/feishu/invoke-turn.d.ts +24 -31
  18. package/dist/channels/feishu/invoke-turn.js +61 -62
  19. package/dist/channels/feishu/model.d.ts +98 -0
  20. package/dist/channels/feishu/model.js +9 -0
  21. package/dist/channels/feishu/normalize.d.ts +23 -0
  22. package/dist/channels/feishu/normalize.js +132 -0
  23. package/dist/channels/feishu/owned-threads.d.ts +7 -0
  24. package/dist/channels/feishu/owned-threads.js +47 -0
  25. package/dist/channels/feishu/parse.d.ts +21 -103
  26. package/dist/channels/feishu/parse.js +35 -145
  27. package/dist/channels/feishu/preview.d.ts +4 -7
  28. package/dist/channels/feishu/preview.js +26 -142
  29. package/dist/channels/feishu/register-app.d.ts +2 -1
  30. package/dist/channels/feishu/register-webhook.d.ts +2 -1
  31. package/dist/channels/feishu/scaffold/channel.ts +10 -3
  32. package/dist/channels/feishu/setup-mode.d.ts +8 -0
  33. package/dist/channels/feishu/setup-mode.js +2 -0
  34. package/dist/channels/feishu/ws-ingress.d.ts +28 -0
  35. package/dist/channels/feishu/ws-ingress.js +136 -0
  36. package/dist/channels/github/github.js +8 -6
  37. package/dist/channels/http.d.ts +14 -0
  38. package/dist/channels/http.js +35 -2
  39. package/dist/channels/invoke-turn-kit.d.ts +65 -0
  40. package/dist/channels/invoke-turn-kit.js +87 -0
  41. package/dist/channels/lark/lark.d.ts +4 -2
  42. package/dist/channels/lark/lark.js +4 -1
  43. package/dist/channels/lark/onboard.d.ts +8 -4
  44. package/dist/channels/lark/onboard.js +8 -0
  45. package/dist/channels/lark/scaffold/channel.ts +10 -3
  46. package/dist/channels/preview-kit.d.ts +109 -0
  47. package/dist/channels/preview-kit.js +183 -0
  48. package/dist/channels/seen.d.ts +5 -0
  49. package/dist/channels/seen.js +35 -0
  50. package/dist/channels/slack/bot-auth.d.ts +15 -0
  51. package/dist/channels/slack/bot-auth.js +146 -0
  52. package/dist/channels/slack/config-api.d.ts +60 -0
  53. package/dist/channels/slack/config-api.js +149 -0
  54. package/dist/channels/slack/context-buffer.d.ts +24 -0
  55. package/dist/channels/slack/context-buffer.js +37 -0
  56. package/dist/channels/slack/invoke-turn.d.ts +19 -0
  57. package/dist/channels/slack/invoke-turn.js +63 -0
  58. package/dist/channels/slack/manifest.d.ts +49 -0
  59. package/dist/channels/slack/manifest.js +69 -0
  60. package/dist/channels/slack/model.d.ts +67 -0
  61. package/dist/channels/slack/model.js +2 -0
  62. package/dist/channels/slack/onboard.d.ts +41 -0
  63. package/dist/channels/slack/onboard.js +120 -0
  64. package/dist/channels/slack/onboarding-state.d.ts +31 -0
  65. package/dist/channels/slack/onboarding-state.js +69 -0
  66. package/dist/channels/slack/owned-threads.d.ts +6 -0
  67. package/dist/channels/slack/owned-threads.js +43 -0
  68. package/dist/channels/slack/parse.d.ts +23 -0
  69. package/dist/channels/slack/parse.js +81 -0
  70. package/dist/channels/slack/preview.d.ts +24 -0
  71. package/dist/channels/slack/preview.js +359 -0
  72. package/dist/channels/slack/reaction.d.ts +24 -0
  73. package/dist/channels/slack/reaction.js +62 -0
  74. package/dist/channels/slack/register-webhook.d.ts +10 -0
  75. package/dist/channels/slack/register-webhook.js +49 -0
  76. package/dist/channels/slack/scaffold/channel.ts +33 -0
  77. package/dist/channels/slack/scaffold/slack-send.ts +171 -0
  78. package/dist/channels/slack/setup-server.d.ts +17 -0
  79. package/dist/channels/slack/setup-server.js +103 -0
  80. package/dist/channels/slack/slack-api.d.ts +77 -0
  81. package/dist/channels/slack/slack-api.js +415 -0
  82. package/dist/channels/slack/slack.d.ts +58 -0
  83. package/dist/channels/slack/slack.js +451 -0
  84. package/dist/channels/slack/welcomed.d.ts +5 -0
  85. package/dist/channels/slack/welcomed.js +32 -0
  86. package/dist/channels/state.js +3 -3
  87. package/dist/channels/stop-command.d.ts +6 -0
  88. package/dist/channels/stop-command.js +36 -0
  89. package/dist/channels/tasks.d.ts +13 -0
  90. package/dist/channels/tasks.js +10 -0
  91. package/dist/channels/telegram/context-buffer.d.ts +8 -17
  92. package/dist/channels/telegram/context-buffer.js +6 -85
  93. package/dist/channels/telegram/invoke-turn.d.ts +5 -22
  94. package/dist/channels/telegram/invoke-turn.js +11 -58
  95. package/dist/channels/telegram/preview.d.ts +4 -7
  96. package/dist/channels/telegram/preview.js +24 -142
  97. package/dist/channels/telegram/telegram.js +23 -9
  98. package/dist/channels/{feishu/text.d.ts → text.d.ts} +1 -1
  99. package/dist/channels/{feishu/text.js → text.js} +1 -1
  100. package/dist/channels/turn-queue.js +1 -1
  101. package/dist/channels/turn-store.d.ts +1 -1
  102. package/dist/channels/turn-store.js +2 -3
  103. package/dist/cli/add-feishu.d.ts +27 -0
  104. package/dist/{cli-add-feishu.js → cli/add-feishu.js} +167 -44
  105. package/dist/cli/add-slack.d.ts +10 -0
  106. package/dist/cli/add-slack.js +204 -0
  107. package/dist/{cli-auth.d.ts → cli/auth-view.d.ts} +1 -1
  108. package/dist/{cli-auth.js → cli/auth-view.js} +1 -1
  109. package/dist/cli/commands/add.d.ts +13 -0
  110. package/dist/cli/commands/add.js +274 -0
  111. package/dist/cli/commands/attach.d.ts +82 -0
  112. package/dist/cli/commands/attach.js +559 -0
  113. package/dist/cli/commands/chat.d.ts +4 -0
  114. package/dist/cli/commands/chat.js +21 -0
  115. package/dist/cli/commands/deploy.d.ts +15 -0
  116. package/dist/cli/commands/deploy.js +394 -0
  117. package/dist/cli/commands/dev.d.ts +11 -0
  118. package/dist/cli/commands/dev.js +82 -0
  119. package/dist/cli/commands/fire.d.ts +7 -0
  120. package/dist/cli/commands/fire.js +45 -0
  121. package/dist/cli/commands/info.d.ts +7 -0
  122. package/dist/cli/commands/info.js +108 -0
  123. package/dist/cli/commands/init.d.ts +8 -0
  124. package/dist/cli/commands/init.js +81 -0
  125. package/dist/cli/commands/invoke.d.ts +7 -0
  126. package/dist/cli/commands/invoke.js +28 -0
  127. package/dist/cli/commands/login.d.ts +6 -0
  128. package/dist/cli/commands/login.js +52 -0
  129. package/dist/cli/commands/models.d.ts +1 -0
  130. package/dist/cli/commands/models.js +15 -0
  131. package/dist/cli/commands/schedule.d.ts +12 -0
  132. package/dist/cli/commands/schedule.js +89 -0
  133. package/dist/cli/commands/start.d.ts +10 -0
  134. package/dist/cli/commands/start.js +97 -0
  135. package/dist/cli/commands/tool.d.ts +1 -0
  136. package/dist/cli/commands/tool.js +38 -0
  137. package/dist/cli/fail.d.ts +17 -0
  138. package/dist/cli/fail.js +32 -0
  139. package/dist/{invoke-stream.d.ts → cli/invoke-stream.d.ts} +1 -1
  140. package/dist/{invoke-stream.js → cli/invoke-stream.js} +4 -0
  141. package/dist/cli/kernel.d.ts +90 -0
  142. package/dist/cli/kernel.js +190 -0
  143. package/dist/cli/models-view.d.ts +21 -0
  144. package/dist/cli/models-view.js +66 -0
  145. package/dist/cli/program.d.ts +11 -0
  146. package/dist/cli/program.js +479 -0
  147. package/dist/cli/serve.d.ts +48 -0
  148. package/dist/cli/serve.js +248 -0
  149. package/dist/cli/shared.d.ts +44 -0
  150. package/dist/cli/shared.js +246 -0
  151. package/dist/cli.js +8 -1329
  152. package/dist/collect.d.ts +14 -3
  153. package/dist/collect.js +24 -0
  154. package/dist/core.d.ts +3 -1
  155. package/dist/core.js +2 -0
  156. package/dist/deploy/docker/plan.d.ts +45 -0
  157. package/dist/deploy/docker/plan.js +141 -0
  158. package/dist/deploy/docker/run.d.ts +40 -0
  159. package/dist/deploy/docker/run.js +126 -0
  160. package/dist/deploy/fly/plan.d.ts +3 -1
  161. package/dist/deploy/fly/plan.js +12 -7
  162. package/dist/deploy/fly/run.d.ts +5 -4
  163. package/dist/deploy/fly/run.js +16 -5
  164. package/dist/deploy/preflight.d.ts +10 -4
  165. package/dist/deploy/preflight.js +57 -12
  166. package/dist/deploy/railway/plan.d.ts +3 -1
  167. package/dist/deploy/railway/plan.js +12 -4
  168. package/dist/deploy/railway/run.d.ts +5 -4
  169. package/dist/deploy/railway/run.js +15 -5
  170. package/dist/deploy/runner.d.ts +6 -2
  171. package/dist/deploy/runner.js +1 -0
  172. package/dist/deploy/secrets.d.ts +2 -1
  173. package/dist/deploy/secrets.js +23 -3
  174. package/dist/dev-supervisor.d.ts +0 -2
  175. package/dist/dev-supervisor.js +7 -3
  176. package/dist/engines/pi/auth.js +160 -46
  177. package/dist/engines/pi/channel.d.ts +22 -16
  178. package/dist/engines/pi/channel.js +90 -60
  179. package/dist/engines/pi/chat.d.ts +4 -16
  180. package/dist/engines/pi/chat.js +8 -188
  181. package/dist/engines/pi/config.d.ts +23 -9
  182. package/dist/engines/pi/config.js +35 -5
  183. package/dist/engines/pi/create.d.ts +36 -7
  184. package/dist/engines/pi/create.js +63 -22
  185. package/dist/engines/pi/harness.d.ts +65 -1
  186. package/dist/engines/pi/harness.js +166 -2
  187. package/dist/engines/pi/invoke.d.ts +56 -3
  188. package/dist/engines/pi/invoke.js +340 -20
  189. package/dist/engines/pi/login.d.ts +11 -0
  190. package/dist/engines/pi/login.js +17 -5
  191. package/dist/engines/pi/models.d.ts +56 -10
  192. package/dist/engines/pi/models.js +61 -23
  193. package/dist/engines/pi/search-tools.d.ts +10 -0
  194. package/dist/engines/pi/search-tools.js +138 -0
  195. package/dist/engines/pi/session-builder.d.ts +16 -0
  196. package/dist/engines/pi/session-builder.js +308 -0
  197. package/dist/engines/pi/session-control.d.ts +50 -0
  198. package/dist/engines/pi/session-control.js +604 -0
  199. package/dist/engines/pi/sessions.d.ts +17 -2
  200. package/dist/engines/pi/sessions.js +9 -0
  201. package/dist/engines/pi/tool-context.d.ts +46 -11
  202. package/dist/engines/pi/tool-context.js +11 -9
  203. package/dist/engines/pi/tool.d.ts +35 -6
  204. package/dist/engines/pi/tool.js +47 -1
  205. package/dist/engines/pi/wake-tool.d.ts +0 -3
  206. package/dist/engines/pi/wake-tool.js +9 -7
  207. package/dist/engines/pi/workspace.d.ts +56 -1
  208. package/dist/engines/pi/workspace.js +75 -16
  209. package/dist/feishu.d.ts +1 -1
  210. package/dist/feishu.js +1 -1
  211. package/dist/host/node.d.ts +23 -6
  212. package/dist/host/node.js +5 -4
  213. package/dist/index.d.ts +1 -0
  214. package/dist/index.js +1 -0
  215. package/dist/lark.d.ts +1 -1
  216. package/dist/lark.js +1 -1
  217. package/dist/observe.js +3 -0
  218. package/dist/pi.d.ts +6 -4
  219. package/dist/pi.js +2 -1
  220. package/dist/scaffold/add-channel.d.ts +19 -6
  221. package/dist/scaffold/add-channel.js +127 -16
  222. package/dist/scaffold/templates/fastagent.config.mjs +5 -3
  223. package/dist/schedule/wakeups.d.ts +0 -3
  224. package/dist/schedule/wakeups.js +1 -1
  225. package/dist/session-remote.d.ts +53 -0
  226. package/dist/session-remote.js +336 -0
  227. package/dist/session.d.ts +265 -0
  228. package/dist/session.js +37 -0
  229. package/dist/slack.d.ts +2 -0
  230. package/dist/slack.js +2 -0
  231. package/dist/tunnel.d.ts +7 -4
  232. package/dist/tunnel.js +21 -10
  233. package/package.json +22 -7
  234. package/dist/channels/feishu/seen.d.ts +0 -5
  235. package/dist/channels/feishu/seen.js +0 -47
  236. package/dist/cli-add-feishu.d.ts +0 -8
  237. package/dist/cli-models.d.ts +0 -11
  238. package/dist/cli-models.js +0 -20
@@ -1,31 +1,37 @@
1
- import { type ChannelContext, type Routes } from "../../host/node.ts";
1
+ import { type ChannelContext, type LongConnection, type Routes } from "../../host/node.ts";
2
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;
6
6
  source: string;
7
7
  }
8
+ /** A long-connection module bound to the same context route factories receive. Internal serving shape. */
9
+ export interface LoadedLongConnectionChannel {
10
+ name: string;
11
+ connect(signal: AbortSignal): LongConnection;
12
+ }
8
13
  /**
9
- * Channel file basenames under `<dir>/channels/` the authoring view (`fastagent info`), which lists
10
- * WITHOUT importing, unlike {@link loadChannels}. It enforces the SAME containment guard so info reports
11
- * exactly the surface dev/start would accept: a channels/ symlink escaping the workspace is rejected
12
- * here too. This path is independent of loadChannels', so it must guard the boundary on its own.
14
+ * Import channel files without mounting route factories or opening connections. Deployment needs only
15
+ * the authored structural fact: function exports are route channels; `{ connect() }` exports are
16
+ * long-connection channels. There is no second ingress/lifecycle declaration to keep in sync.
13
17
  */
14
- export declare function discoverChannelFiles(dir: string): Promise<string[]>;
18
+ export declare function inspectChannels(dir: string): Promise<{
19
+ channels: string[];
20
+ routeChannels: string[];
21
+ longConnectionChannels: string[];
22
+ failures: ModuleLoadFailure[];
23
+ }>;
15
24
  /**
16
- * Discover channels in `<dir>/channels/`: each `*.ts|.js|.mjs` default-exports a `(ctx) => Routes`
17
- * factory ({@link ChannelModule}), called here with the mount context; the returned route maps are
18
- * merged (first file wins a route-key clash, the dropped route surfaced).
19
- *
20
- * A channel file broken for ANY reason — a failed import, a factory that throws when called (a missing
21
- * env var is the common deploy case), or a malformed shape (not a function, not a Routes object, a bad
22
- * handler/route key) — is collected in `failures` without preventing validation of sibling files. The
23
- * serving CLI treats any such failure as fatal: a declared channel must not silently disappear or cause
24
- * the default `/invoke` route to mount. Programmatic callers can inspect the returned data themselves.
25
- * Routes are validated fully before any merge, so a throw mounts no partial routes.
25
+ * Channel file basenames under `<dir>/channels/` the authoring view (`fastagent info`), which lists
26
+ * WITHOUT importing. A symlinked channels directory must remain inside the workspace.
26
27
  */
28
+ export declare function discoverChannelFiles(dir: string): Promise<string[]>;
29
+ /** Discover, validate, and bind all channel modules. No long connection is opened here; the CLI owns it. */
27
30
  export declare function loadChannels(dir: string, ctx: ChannelContext): Promise<{
28
31
  routes: Routes;
32
+ longConnections: LoadedLongConnectionChannel[];
33
+ routeChannels: string[];
34
+ longConnectionChannels: string[];
29
35
  collisions: ChannelCollision[];
30
36
  failures: ModuleLoadFailure[];
31
37
  }>;
@@ -1,19 +1,55 @@
1
1
  /**
2
- * Channel discovery (the N axis, filesystem form): a workspace declares its inbound surface by
3
- * dropping files in `channels/`, mirroring `tools/`. Each file wires a third-party adapter to the
4
- * app's `on()` glue and returns the routes it mounts. There is no config-level channel list — a
5
- * channel always needs glue, so it is always a file.
2
+ * Channel discovery (the N axis, filesystem form). A channel file default-exports either the existing
3
+ * route factory `(ctx) => Routes`, or an explicit long-connection module `{ name, connect(ctx, signal) }`.
6
4
  */
7
5
  import { readdir } from "node:fs/promises";
8
6
  import { isAbsolute, join } from "node:path";
9
- import { parseRouteKey } from "../../host/node.js";
10
- import { assertInsideWorkspace } from "../../workspace.js";
7
+ import { parseRouteKey, } from "../../host/node.js";
11
8
  import { isModuleFile, loadModuleDir } from "../../loader.js";
9
+ import { assertInsideWorkspace } from "../../workspace.js";
10
+ function longConnectionModule(value) {
11
+ return value !== null && typeof value === "object" && typeof value.connect === "function";
12
+ }
13
+ function validateLongConnectionModule(value, label) {
14
+ if (typeof value.name !== "string" || value.name.trim() === "") {
15
+ throw new Error(`${label}: long-connection channel name must be a non-empty string`);
16
+ }
17
+ }
18
+ /**
19
+ * Import channel files without mounting route factories or opening connections. Deployment needs only
20
+ * the authored structural fact: function exports are route channels; `{ connect() }` exports are
21
+ * long-connection channels. There is no second ingress/lifecycle declaration to keep in sync.
22
+ */
23
+ export async function inspectChannels(dir) {
24
+ await assertInsideWorkspace(dir, "channels");
25
+ const { modules, failures } = await loadModuleDir(join(dir, "channels"));
26
+ const channels = [];
27
+ const routeChannels = [];
28
+ const longConnectionChannels = [];
29
+ for (const { name, label, file, mod } of modules) {
30
+ try {
31
+ if (typeof mod.default === "function") {
32
+ channels.push(name);
33
+ routeChannels.push(name);
34
+ continue;
35
+ }
36
+ if (longConnectionModule(mod.default)) {
37
+ validateLongConnectionModule(mod.default, label);
38
+ channels.push(name);
39
+ longConnectionChannels.push(name);
40
+ continue;
41
+ }
42
+ throw new Error(`${label} must default-export (ctx) => Routes or { name, connect(ctx, signal) }`);
43
+ }
44
+ catch (error) {
45
+ failures.push({ label, file, message: error.message });
46
+ }
47
+ }
48
+ return { channels, routeChannels, longConnectionChannels, failures };
49
+ }
12
50
  /**
13
51
  * Channel file basenames under `<dir>/channels/` — the authoring view (`fastagent info`), which lists
14
- * WITHOUT importing, unlike {@link loadChannels}. It enforces the SAME containment guard so info reports
15
- * exactly the surface dev/start would accept: a channels/ symlink escaping the workspace is rejected
16
- * here too. This path is independent of loadChannels', so it must guard the boundary on its own.
52
+ * WITHOUT importing. A symlinked channels directory must remain inside the workspace.
17
53
  */
18
54
  export async function discoverChannelFiles(dir) {
19
55
  await assertInsideWorkspace(dir, "channels");
@@ -28,75 +64,68 @@ export async function discoverChannelFiles(dir) {
28
64
  }
29
65
  return names
30
66
  .filter(isModuleFile)
31
- .map((n) => n.replace(/\.(ts|js|mjs)$/, ""))
67
+ .map((name) => name.replace(/\.(ts|js|mjs)$/, ""))
32
68
  .sort();
33
69
  }
34
- /**
35
- * Discover channels in `<dir>/channels/`: each `*.ts|.js|.mjs` default-exports a `(ctx) => Routes`
36
- * factory ({@link ChannelModule}), called here with the mount context; the returned route maps are
37
- * merged (first file wins a route-key clash, the dropped route surfaced).
38
- *
39
- * A channel file broken for ANY reason — a failed import, a factory that throws when called (a missing
40
- * env var is the common deploy case), or a malformed shape (not a function, not a Routes object, a bad
41
- * handler/route key) — is collected in `failures` without preventing validation of sibling files. The
42
- * serving CLI treats any such failure as fatal: a declared channel must not silently disappear or cause
43
- * the default `/invoke` route to mount. Programmatic callers can inspect the returned data themselves.
44
- * Routes are validated fully before any merge, so a throw mounts no partial routes.
45
- */
70
+ function validateRoutes(value, label) {
71
+ if (value === null || typeof value !== "object" || value instanceof Map) {
72
+ throw new Error(`${label} must return a Routes object`);
73
+ }
74
+ const routes = Object.entries(value);
75
+ if (routes.length === 0) {
76
+ throw new Error(`${label} declared no routes return a non-empty { "METHOD /path": handler } object`);
77
+ }
78
+ for (const [route, handler] of routes) {
79
+ if (typeof handler !== "function") {
80
+ throw new Error(`${label}: route "${route}" must map to a handler function, got ${typeof handler}`);
81
+ }
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
+ }
86
+ return routes;
87
+ }
88
+ /** Discover, validate, and bind all channel modules. No long connection is opened here; the CLI owns it. */
46
89
  export async function loadChannels(dir, ctx) {
47
- // The contract says stateRoot is absolute; enforce it at the mount boundary so a relative root fails
48
- // fast HERE instead of silently re-anchoring some channel's state on the process cwd.
49
90
  if (!isAbsolute(ctx.stateRoot)) {
50
91
  throw new Error(`ChannelContext.stateRoot must be absolute, got "${ctx.stateRoot}"`);
51
92
  }
52
- // A symlinked channels/ is followed only if it stays inside the workspace, so a deploy that copies
53
- // the dir includes it (the directory is the agent).
54
93
  await assertInsideWorkspace(dir, "channels");
55
94
  const { modules, failures } = await loadModuleDir(join(dir, "channels"));
56
95
  const routes = {};
96
+ const longConnections = [];
97
+ const routeChannels = [];
98
+ const longConnectionChannels = [];
57
99
  const collisions = [];
58
- for (const { label, file, mod } of modules) {
59
- // Collect every per-file failure so the caller can report all broken channels in one pass. The CLI
60
- // then fails startup rather than silently dropping a declared route; direct callers own their policy.
61
- // Routes are VALIDATED fully before any are merged, so a throw mid-validation mounts NO partial routes.
100
+ for (const { name, label, file, mod } of modules) {
62
101
  try {
63
- const factory = mod.default;
64
- if (typeof factory !== "function") {
65
- throw new Error(`${label} must default-export (ctx) => Routes`);
102
+ if (longConnectionModule(mod.default)) {
103
+ validateLongConnectionModule(mod.default, label);
104
+ const channel = mod.default;
105
+ longConnections.push({
106
+ name: channel.name,
107
+ connect: (signal) => channel.connect(ctx, signal),
108
+ });
109
+ longConnectionChannels.push(name);
110
+ continue;
111
+ }
112
+ if (typeof mod.default !== "function") {
113
+ throw new Error(`${label} must default-export (ctx) => Routes or { name, connect(ctx, signal) }`);
66
114
  }
67
- const declared = factory(ctx);
68
- // A Promise needs its own branch before the object check: mark it handled (a rejected async setup
69
- // must not go unhandled) and reject it with a precise message rather than the zero-routes one.
115
+ const declared = mod.default(ctx);
70
116
  if (declared !== null &&
71
117
  typeof declared === "object" &&
72
118
  typeof declared.then === "function") {
73
119
  declared.catch(() => { });
74
- throw new Error(`${label} must return Routes synchronously, not a Promise (an async factory is not supported)`);
75
- }
76
- if (declared === null || typeof declared !== "object") {
77
- throw new Error(`${label} must return a Routes object, got ${declared === null ? "null" : typeof declared}`);
78
- }
79
- const declaredRoutes = Object.entries(declared);
80
- if (declaredRoutes.length === 0) {
81
- throw new Error(`${label} declared no routes — return a non-empty { "METHOD /path": handler } object (a Promise, Map, array, or {} yields none)`);
82
- }
83
- // Validate every route BEFORE merging any (no partial mount on a later throw).
84
- for (const [route, handler] of declaredRoutes) {
85
- if (typeof handler !== "function") {
86
- throw new Error(`${label}: route "${route}" must map to a handler function, got ${typeof handler}`);
87
- }
88
- if (!parseRouteKey(route).path.startsWith("/")) {
89
- throw new Error(`${label}: route "${route}" is not a valid route key (expected "METHOD /path" or "/path")`);
90
- }
120
+ throw new Error(`${label} must return Routes synchronously, not a Promise`);
91
121
  }
122
+ const declaredRoutes = validateRoutes(declared, label);
92
123
  for (const [route, handler] of declaredRoutes) {
93
124
  const parsed = parseRouteKey(route);
94
- // Overlap, not literal-key, equality: the router treats a bare `/path` as any-method, so
95
- // `/webhook` and `POST /webhook` clash. `GET /x` vs `POST /x` is fine.
96
- const clash = Object.keys(routes).some((k) => {
97
- const e = parseRouteKey(k);
98
- return (e.path === parsed.path &&
99
- (e.method === undefined || parsed.method === undefined || e.method === parsed.method));
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));
100
129
  });
101
130
  if (clash) {
102
131
  collisions.push({ route, source: label });
@@ -104,10 +133,11 @@ export async function loadChannels(dir, ctx) {
104
133
  }
105
134
  routes[route] = handler;
106
135
  }
136
+ routeChannels.push(name);
107
137
  }
108
138
  catch (error) {
109
139
  failures.push({ label, file, message: error.message });
110
140
  }
111
141
  }
112
- return { routes, collisions, failures };
142
+ return { routes, longConnections, routeChannels, longConnectionChannels, collisions, failures };
113
143
  }
@@ -1,19 +1,7 @@
1
- import { type AgentSessionRuntime, SessionManager } from "@earendil-works/pi-coding-agent";
2
- export interface RunPiChatOptions {
3
- /** Model spec override (the CLI --model flag). Precedence: this > FASTAGENT_MODEL > config.model. */
4
- model?: string;
5
- }
6
- /**
7
- * Build pi's interactive runtime driven by fastagent's assembled agent (model, prompt, tools,
8
- * skills resolved exactly as the dev opener does). Split from {@link runPiChat} so the assembly —
9
- * the fidelity-critical part — is inspectable without launching the TUI.
10
- */
11
- export declare function buildChatRuntime(dir: string, options?: RunPiChatOptions,
12
- /** Session backend. Defaults to pi's project-scoped store; tests inject SessionManager.inMemory(). */
13
- sessionManager?: SessionManager): Promise<AgentSessionRuntime>;
1
+ import { type BuildSessionRuntimeOptions } from "./session-builder.ts";
14
2
  /**
15
3
  * Open the workspace's agent in pi's interactive TUI and run until the user exits. The agent is
16
- * fastagent's assembled agent (same model/tools/skills/prompt as dev/start serve); pi's TUI handles
17
- * login, rendering, and same-workspace sessions natively.
4
+ * fastagent's assembled agent (same model/tools/skills/prompt/auth as dev/start serve); pi's TUI
5
+ * handles login, rendering, and same-workspace sessions natively.
18
6
  */
19
- export declare function runPiChat(dir: string, options?: RunPiChatOptions): Promise<void>;
7
+ export declare function runPiChat(dir: string, options?: BuildSessionRuntimeOptions): Promise<void>;
@@ -3,198 +3,18 @@
3
3
  * engine-neutral channel: it drives pi's full session API (InteractiveMode) for fidelity, so it lives
4
4
  * under engines/pi/ and is not re-exported.
5
5
  *
6
- * FIDELITY: chat must run the SAME agent dev/start serve, not pi's vanilla discovery (which would walk
7
- * AGENTS.md up to the repo root and discover skills from pi's own global dirs). So fastagent's
8
- * assembly is INJECTED into pi's session:
9
- * - prompt → systemPromptOverride = base + instructions ONLY; pi appends the skill section and env
10
- * (cwd) itself (including it here would duplicate it).
11
- * - skills → skillsOverride (fastagent's skills, for the section + invocation).
12
- * - tools → default coding tools by NAME (pi rebuilds them cwd-bound for rich rendering) +
13
- * fastagent's custom tools via pi's customTools path (so they survive /new, /resume, fork).
14
- *
15
- * Cross-workspace session switches are rejected: `.env` is process-global, so one chat TUI is one
16
- * workspace.
17
- *
18
- * AUTH: chat is the one command that does NOT use fastagent's credential file. It drives pi's own
19
- * session services (`createAgentSessionServices`, auth from pi's `~/.pi` via `getAgentDir()`), so you
20
- * log in through pi's TUI, not `fastagent login`. `--auth-path`/`FASTAGENT_AUTH_PATH` therefore do not
21
- * apply here, and `createPiModels()` below is used only to RESOLVE the model descriptor (never for auth).
22
- */
23
- import { existsSync, readFileSync } from "node:fs";
24
- import { dirname, resolve } from "node:path";
25
- import { NodeExecutionEnv } from "@earendil-works/pi-agent-core/node";
26
- import { InteractiveMode, SessionManager, createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, getAgentDir, } from "@earendil-works/pi-coding-agent";
27
- import { loadConfig, resolveAgentDir, resolveModel, resolveModelSpec } from "./config.js";
28
- import { assembleSystemPrompt, piBasePrompt, piDefaultTools, resolveTools } from "./create.js";
29
- import { createPiModels } from "./models.js";
30
- import { canonicalPath, loadAgentDefinition } from "./definition.js";
31
- import { loadTools, mergeDiscoveredTools } from "./tool.js";
32
- import { reportDefinitionWarnings, reportModuleLoadFailures, reportToolCollisions } from "./report.js";
33
- /**
34
- * Build pi's interactive runtime driven by fastagent's assembled agent (model, prompt, tools,
35
- * skills resolved exactly as the dev opener does). Split from {@link runPiChat} so the assembly —
36
- * the fidelity-critical part — is inspectable without launching the TUI.
37
- */
38
- export async function buildChatRuntime(dir, options = {},
39
- /** Session backend. Defaults to pi's project-scoped store; tests inject SessionManager.inMemory(). */
40
- sessionManager) {
41
- async function resolveAssembly(cwd) {
42
- const { config } = await loadConfig(cwd);
43
- const modelSpec = resolveModelSpec(options.model, config);
44
- if (!modelSpec) {
45
- throw new Error(`missing model: set --model, "model" in fastagent.config.ts, or FASTAGENT_MODEL (e.g. "openai-codex/gpt-5.5")`);
46
- }
47
- // Resolution only — the Models' auth is unused here; chat's auth is pi's own (~/.pi via the session
48
- // services), so authPath is intentionally not threaded in. See the AUTH note in the header.
49
- const model = resolveModel(createPiModels(), modelSpec);
50
- const env = new NodeExecutionEnv({ cwd });
51
- // Same agentDir/cwd split as dev/start: persona/skills/tools from agentDir, ② context walked from cwd.
52
- const agentDir = resolveAgentDir(cwd, config);
53
- const definition = await loadAgentDefinition(agentDir, { cwd, env });
54
- reportDefinitionWarnings(definition.collisions, definition.diagnostics);
55
- // Same tool resolution as the dev opener, then split: defaults go to pi by NAME (rebuilt cwd-bound
56
- // for rich rendering); customs go through pi's `customTools` path so they survive /new, /resume, fork.
57
- const discovered = await loadTools(agentDir);
58
- const { tools, collisions: crossCollisions } = mergeDiscoveredTools(resolveTools(config, cwd), discovered.tools);
59
- reportToolCollisions([...discovered.collisions, ...crossCollisions]);
60
- reportModuleLoadFailures(discovered.failures);
61
- const defaultNames = piDefaultTools(cwd).map((t) => t.name);
62
- const customTools = tools.filter((t) => !defaultNames.includes(t.name));
63
- // Adapt fastagent's AgentTool to pi's ToolDefinition (`parameters` is plain JSON-Schema; pi accepts it).
64
- const customToolDefs = customTools.map((t) => ({
65
- name: t.name,
66
- label: t.name,
67
- description: t.description ?? "",
68
- parameters: t.parameters,
69
- execute: (id, params, signal) => t.execute(id, params, signal),
70
- }));
71
- // base + instructions ONLY — pi appends the skill section and env (cwd) itself (including
72
- // them here would duplicate them).
73
- const systemPrompt = assembleSystemPrompt({
74
- base: piBasePrompt({ tools, persona: definition.persona }),
75
- contextFiles: definition.contextFiles,
76
- });
77
- return { model, definition, defaultNames, customTools, customToolDefs, systemPrompt };
78
- }
79
- // pi calls the factory again on /new, /resume, switch, and fork. Config/tools dynamic imports are
80
- // ESM-cached, so treating same-cwd rebuilds as hot reload would yield a half-fresh agent (fresh
81
- // AGENTS.md/skills, stale config/tools). Keep chat a coherent startup snapshot: restart to load
82
- // edits. And keep it workspace-scoped — `.env` is process-global, so a switch to another cwd would
83
- // leak env or require mutating global env at runtime.
84
- const rootCwd = canonicalPath(dir);
85
- let assembly;
86
- const assemblyFor = (cwd) => {
87
- // Canonical paths: pi's process.cwd() fallback is a realpath, so a symlinked workspace would
88
- // otherwise mismatch a non-realpath rootCwd.
89
- const activeCwd = canonicalPath(cwd);
90
- if (activeCwd !== rootCwd) {
91
- throw workspaceScopeError(activeCwd);
92
- }
93
- assembly ??= resolveAssembly(rootCwd);
94
- return assembly;
95
- };
96
- const createRuntime = async ({ cwd, sessionManager, sessionStartEvent }) => {
97
- const { model, definition, defaultNames, customTools, customToolDefs, systemPrompt } = await assemblyFor(cwd);
98
- const services = await createAgentSessionServices({
99
- cwd,
100
- resourceLoaderOptions: {
101
- // Definition-only, like dev/start: suppress pi's machine-global discovery (the developer's own
102
- // ~/.pi extensions, slash commands, global AGENTS.md, APPEND_SYSTEM.md) so chat runs the same
103
- // agent that gets served, not the authoring machine's pi setup on top.
104
- noExtensions: true,
105
- noPromptTemplates: true,
106
- noContextFiles: true,
107
- systemPromptOverride: () => systemPrompt,
108
- appendSystemPromptOverride: () => [],
109
- // Replace pi's discovered skills with fastagent's. fastagent's Skill (content inline) is
110
- // reshaped to pi-coding-agent's (read from filePath/baseDir at invocation time).
111
- skillsOverride: (base) => ({
112
- skills: definition.skills.map((s) => ({
113
- name: s.name,
114
- description: s.description,
115
- filePath: s.filePath,
116
- baseDir: dirname(s.filePath),
117
- sourceInfo: {
118
- path: s.filePath,
119
- source: "fastagent",
120
- scope: "project",
121
- origin: "top-level",
122
- baseDir: dirname(s.filePath),
123
- },
124
- disableModelInvocation: s.disableModelInvocation ?? false,
125
- })),
126
- diagnostics: base.diagnostics,
127
- }),
128
- },
129
- });
130
- const result = await createAgentSessionFromServices({
131
- services,
132
- sessionManager,
133
- sessionStartEvent,
134
- model,
135
- tools: [...defaultNames, ...customTools.map((t) => t.name)],
136
- customTools: customToolDefs,
137
- });
138
- return { ...result, services, diagnostics: services.diagnostics };
139
- };
140
- const runtime = await createAgentSessionRuntime(createRuntime, {
141
- cwd: rootCwd,
142
- agentDir: getAgentDir(),
143
- sessionManager: sessionManager ?? SessionManager.create(rootCwd),
144
- });
145
- enforceWorkspaceScopedSessionSwitches(runtime, rootCwd);
146
- return runtime;
147
- }
148
- function workspaceScopeError(targetCwd) {
149
- return new Error(`fastagent chat is workspace-scoped: cannot switch to ${targetCwd}; run \`fastagent chat ${targetCwd}\` instead`);
150
- }
151
- function readSessionHeaderCwd(sessionPath) {
152
- const resolvedPath = resolve(sessionPath);
153
- if (!existsSync(resolvedPath))
154
- return undefined;
155
- for (const line of readFileSync(resolvedPath, "utf8").split(/\r?\n/)) {
156
- if (!line.trim())
157
- continue;
158
- try {
159
- const entry = JSON.parse(line);
160
- if (entry.type === "session")
161
- return typeof entry.cwd === "string" ? canonicalPath(entry.cwd) : undefined;
162
- }
163
- catch {
164
- // Ignore malformed lines the same way pi's session loader does; no header cwd → caller pins root.
165
- }
166
- }
167
- return undefined;
168
- }
169
- /**
170
- * Keep resume/import inside the chat's single workspace, deciding BEFORE delegating to pi. The chat
171
- * process is chdir'd into rootCwd, so a session with no cwd header already lands on rootCwd. The gap
172
- * is a session that EXPLICITLY records a different cwd: pi would bind it and the factory would reject
173
- * it — but only AFTER tearing the live session down. Reject such a switch up front.
6
+ * The TUI is ONE consumer of the shared definition-aware builder (session-builder.ts) the same
7
+ * assembly, model surface, and fastagent auth that serving uses. Log in with `fastagent login` (or
8
+ * pi's TUI `/login`, which writes through the same credential store into the workspace auth file).
174
9
  */
175
- function enforceWorkspaceScopedSessionSwitches(runtime, rootCwd) {
176
- const rejectForeignTarget = (sessionPath, cwdOverride) => {
177
- const target = cwdOverride !== undefined ? canonicalPath(cwdOverride) : readSessionHeaderCwd(sessionPath);
178
- if (target !== undefined && target !== rootCwd)
179
- throw workspaceScopeError(target);
180
- };
181
- const switchSession = runtime.switchSession.bind(runtime);
182
- runtime.switchSession = async (...args) => {
183
- rejectForeignTarget(args[0], args[1]?.cwdOverride);
184
- return switchSession(...args);
185
- };
186
- const importFromJsonl = runtime.importFromJsonl.bind(runtime);
187
- runtime.importFromJsonl = async (...args) => {
188
- rejectForeignTarget(args[0], args[1]);
189
- return importFromJsonl(...args);
190
- };
191
- }
10
+ import { InteractiveMode } from "@earendil-works/pi-coding-agent";
11
+ import { buildWorkspaceSessionRuntime } from "./session-builder.js";
192
12
  /**
193
13
  * Open the workspace's agent in pi's interactive TUI and run until the user exits. The agent is
194
- * fastagent's assembled agent (same model/tools/skills/prompt as dev/start serve); pi's TUI handles
195
- * login, rendering, and same-workspace sessions natively.
14
+ * fastagent's assembled agent (same model/tools/skills/prompt/auth as dev/start serve); pi's TUI
15
+ * handles login, rendering, and same-workspace sessions natively.
196
16
  */
197
17
  export async function runPiChat(dir, options = {}) {
198
- const runtime = await buildChatRuntime(dir, options);
18
+ const runtime = await buildWorkspaceSessionRuntime(dir, options);
199
19
  await new InteractiveMode(runtime, {}).run();
200
20
  }
@@ -1,9 +1,14 @@
1
- import type { AgentTool } from "@earendil-works/pi-agent-core";
1
+ import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
2
+ import type { FastagentTool } from "./tool.ts";
2
3
  import type { Models } from "@earendil-works/pi-ai";
3
- import type { AnyModel } from "./harness.ts";
4
+ import { type AnyModel } from "./harness.ts";
4
5
  export interface FastagentConfig {
5
6
  /** "provider/modelId". Precedence: CLI --model > FASTAGENT_MODEL > config. */
6
7
  model?: string;
8
+ /** Reasoning effort for the model, pi's scale ("off" | "minimal" | "low" | "medium" | "high" |
9
+ * "xhigh" | "max"). Unset = pi's default. Authors tune thinking in the pi TUI while vibing — this
10
+ * is the serving-side counterpart (fidelity). Levels a model doesn't support are clamped by pi. */
11
+ thinkingLevel?: ThinkingLevel;
7
12
  /**
8
13
  * The agent-definition subdirectory (persona.md, skills/, tools/, channels/), relative to the config
9
14
  * file's directory. Default: the config directory itself (flat — today's behaviour). Point it at a
@@ -12,8 +17,9 @@ export interface FastagentConfig {
12
17
  * subdir and does not collide with the host's `tools/`/`src/` (core.md scenario grid).
13
18
  */
14
19
  agentDir?: string;
15
- /** Extra custom tools, appended after pi defaults — never replaces them. */
16
- tools?: AgentTool[];
20
+ /** Extra custom tools, appended after pi defaults — never replaces them. `FastagentTool` = AgentTool
21
+ * plus the optional `deferred` marker (see defineTool). */
22
+ tools?: FastagentTool[];
17
23
  http?: {
18
24
  port?: number;
19
25
  };
@@ -21,6 +27,15 @@ export interface FastagentConfig {
21
27
  * Off by default — self-scheduling is an autonomy capability, opt in when you want it. Only takes
22
28
  * effect on the serving path (`dev`/`start`, where the scheduler poller honors a wake-up). */
23
29
  selfSchedule?: boolean;
30
+ /**
31
+ * Serve the session control plane over HTTP (`/control/*`: state/entries/events + dispatch —
32
+ * steer/abort/compact/set_model…) for remote consumers: a Web panel, a desktop app, `fastagent
33
+ * attach`. Default off (it is a remote-control surface). When on, `dev`/`start` generate a
34
+ * per-boot bearer token and write `<stateRoot>/control.json` for local discovery. The serve
35
+ * binds all interfaces, so the routes are LAN-reachable with the token as the only protection —
36
+ * firewall the port, or wrap it for real exposure (design §14).
37
+ */
38
+ sessionControl?: boolean;
24
39
  /** Deploy-time declarations for what the agent needs on the box, so real agents don't hand-write a
25
40
  * Dockerfile / hand-set variables. */
26
41
  deploy?: {
@@ -56,6 +71,10 @@ export declare function loadConfig(dir: string): Promise<LoadedConfig>;
56
71
  * already validated that agentDir stays under `dir`.
57
72
  */
58
73
  export declare function resolveAgentDir(dir: string, config: FastagentConfig): string;
74
+ /** The provider prefix of a "provider/modelId" spec. A spec without "/" returns whole — downstream
75
+ * lookups then miss visibly (an unknown-provider error / a login-required hint), never a mangled id
76
+ * (`slice(0, indexOf("/"))` silently drops the last char when "/" is absent). */
77
+ export declare function providerOf(spec: string): string;
59
78
  /** Resolve "provider/modelId" → a pi Model from `models`, so the harness resolves auth from the same collection. */
60
79
  export declare function resolveModel(models: Models, spec: string): AnyModel;
61
80
  /** All registered "provider/modelId" specs in `models`, sorted — the list behind `fastagent models`. */
@@ -84,11 +103,6 @@ export declare function resolveSessionsDirOverride(flag: string | undefined, env
84
103
  * `~/.fastagent/auth.json` — sharing ONE file is safe under the store's cross-process refresh lock.
85
104
  */
86
105
  export declare function resolveAuthPathOverride(flag: string | undefined, env?: NodeJS.ProcessEnv): string | undefined;
87
- /**
88
- * The IN-TREE default state root, `<dir>/.fastagent` — what {@link resolveStateRoot} falls back to when
89
- * `FASTAGENT_STATE_DIR` moves state nowhere. THE single definition of that path segment.
90
- */
91
- export declare function projectStateDir(dir: string): string;
92
106
  /**
93
107
  * The resolved state root — the ONE durable machine-state home everything derives from (auth.json,
94
108
  * sessions/, channels/<kind>/): `FASTAGENT_STATE_DIR` env > `<dir>/.fastagent`. Absolute, so channels