@fastagent-sh/fastagent 0.20.0 → 0.21.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.
- package/README.md +5 -1
- package/dist/channels/agentcore-protocol.d.ts +112 -0
- package/dist/channels/agentcore-protocol.js +22 -0
- package/dist/channels/agentcore-service.d.ts +0 -4
- package/dist/channels/agentcore-service.js +1 -6
- package/dist/channels/agentcore-state.d.ts +5 -11
- package/dist/channels/agentcore-state.js +4 -1
- package/dist/channels/agentcore.d.ts +1 -67
- package/dist/channels/agentcore.js +90 -89
- package/dist/channels/control.d.ts +1 -1
- package/dist/channels/control.js +13 -62
- package/dist/channels/discover.d.ts +0 -1
- package/dist/channels/discover.js +1 -3
- package/dist/channels/feishu/context-buffer.d.ts +6 -0
- package/dist/channels/feishu/context-buffer.js +0 -38
- package/dist/channels/feishu/crypto.d.ts +0 -2
- package/dist/channels/feishu/crypto.js +3 -8
- package/dist/channels/feishu/feishu-api.js +2 -4
- package/dist/channels/feishu/feishu.js +56 -115
- package/dist/channels/feishu/parse.d.ts +4 -3
- package/dist/channels/feishu/parse.js +5 -4
- package/dist/channels/feishu/register-webhook.d.ts +5 -5
- package/dist/channels/feishu/register-webhook.js +43 -57
- package/dist/channels/feishu/scaffold/feishu-send.ts +12 -67
- package/dist/channels/feishu/setup-mode.d.ts +30 -0
- package/dist/channels/feishu/setup-mode.js +26 -0
- package/dist/channels/feishu/shared-api.d.ts +10 -0
- package/dist/channels/feishu/shared-api.js +38 -0
- package/dist/channels/http.d.ts +0 -8
- package/dist/channels/http.js +4 -56
- package/dist/channels/kit/turn-runner.d.ts +59 -0
- package/dist/channels/kit/turn-runner.js +84 -0
- package/dist/channels/lark/scaffold/lark-send.ts +12 -67
- package/dist/channels/registration.d.ts +36 -1
- package/dist/channels/registration.js +57 -1
- package/dist/channels/secret.d.ts +1 -0
- package/dist/channels/secret.js +16 -0
- package/dist/channels/slack/config-api.d.ts +35 -3
- package/dist/channels/slack/config-api.js +51 -9
- package/dist/channels/slack/manifest.js +5 -1
- package/dist/channels/slack/onboard.d.ts +5 -5
- package/dist/channels/slack/onboard.js +41 -20
- package/dist/channels/slack/register-webhook.d.ts +3 -2
- package/dist/channels/slack/register-webhook.js +34 -14
- package/dist/channels/slack/scaffold/channel.ts +3 -10
- package/dist/channels/slack/scaffold/slack-send.ts +18 -126
- package/dist/channels/slack/shared-api.d.ts +10 -0
- package/dist/channels/slack/shared-api.js +34 -0
- package/dist/channels/slack/slack-api.d.ts +20 -2
- package/dist/channels/slack/slack-api.js +81 -22
- package/dist/channels/slack/slack.d.ts +0 -10
- package/dist/channels/slack/slack.js +60 -99
- package/dist/channels/sse.d.ts +4 -0
- package/dist/channels/sse.js +66 -0
- package/dist/channels/telegram/register-webhook.d.ts +6 -9
- package/dist/channels/telegram/register-webhook.js +44 -42
- package/dist/channels/telegram/telegram.js +47 -142
- package/dist/channels/wait-health.js +7 -4
- package/dist/cli/add-feishu.js +3 -10
- package/dist/cli/add-slack.js +7 -20
- package/dist/cli/commands/add.d.ts +0 -1
- package/dist/cli/commands/add.js +22 -31
- package/dist/cli/commands/chat.js +6 -12
- package/dist/cli/commands/deploy/agentcore.d.ts +2 -0
- package/dist/cli/commands/deploy/agentcore.js +178 -0
- package/dist/cli/commands/deploy/docker.d.ts +2 -0
- package/dist/cli/commands/deploy/docker.js +119 -0
- package/dist/cli/commands/deploy/fly.d.ts +2 -0
- package/dist/cli/commands/deploy/fly.js +131 -0
- package/dist/cli/commands/deploy/railway.d.ts +2 -0
- package/dist/cli/commands/deploy/railway.js +71 -0
- package/dist/cli/commands/deploy/shared.d.ts +114 -0
- package/dist/cli/commands/deploy/shared.js +124 -0
- package/dist/cli/commands/deploy.d.ts +17 -49
- package/dist/cli/commands/deploy.js +39 -618
- package/dist/cli/commands/dev.js +17 -46
- package/dist/cli/commands/fire.js +6 -12
- package/dist/cli/commands/info.js +2 -1
- package/dist/cli/commands/invoke.js +4 -11
- package/dist/cli/commands/schedule.js +1 -1
- package/dist/cli/commands/start.js +17 -46
- package/dist/cli/commands/tool.js +3 -8
- package/dist/cli/kernel.d.ts +0 -2
- package/dist/cli/kernel.js +0 -2
- package/dist/cli/program.js +7 -12
- package/dist/cli/serve.d.ts +36 -19
- package/dist/cli/serve.js +91 -7
- package/dist/cli/shared.d.ts +13 -18
- package/dist/cli/shared.js +20 -4
- package/dist/deploy/agentcore/forwarder.js +250 -0
- package/dist/deploy/agentcore/plan.d.ts +23 -2
- package/dist/deploy/agentcore/plan.js +29 -244
- package/dist/deploy/agentcore/run.d.ts +4 -20
- package/dist/deploy/agentcore/run.js +22 -7
- package/dist/deploy/channel-ingress.js +1 -1
- package/dist/deploy/docker/run.d.ts +17 -3
- package/dist/deploy/docker/run.js +30 -8
- package/dist/deploy/fly/plan.js +7 -0
- package/dist/deploy/fly/run.d.ts +27 -0
- package/dist/deploy/fly/run.js +102 -20
- package/dist/deploy/hosts.d.ts +5 -0
- package/dist/deploy/hosts.js +4 -0
- package/dist/deploy/preflight.js +3 -3
- package/dist/deploy/railway/plan.d.ts +5 -0
- package/dist/deploy/railway/plan.js +7 -0
- package/dist/deploy/railway/run.d.ts +2 -1
- package/dist/deploy/railway/run.js +5 -4
- package/dist/deploy/secrets.js +0 -17
- package/dist/engines/pi/agent-session-factory.d.ts +37 -17
- package/dist/engines/pi/agent-session-factory.js +109 -85
- package/dist/engines/pi/config.d.ts +1 -1
- package/dist/engines/pi/create.d.ts +22 -39
- package/dist/engines/pi/create.js +58 -73
- package/dist/engines/pi/definition.js +8 -8
- package/dist/engines/pi/invoke-session.js +13 -30
- package/dist/engines/pi/open.d.ts +2 -2
- package/dist/engines/pi/open.js +31 -41
- package/dist/engines/pi/retry-event.d.ts +6 -0
- package/dist/engines/pi/retry-event.js +15 -0
- package/dist/engines/pi/session-builder.js +26 -113
- package/dist/engines/pi/session-control.d.ts +10 -18
- package/dist/engines/pi/session-control.js +47 -81
- package/dist/engines/pi/session-settings.d.ts +1 -1
- package/dist/engines/pi/session-settings.js +1 -1
- package/dist/engines/pi/session-store.d.ts +12 -17
- package/dist/engines/pi/session-store.js +18 -40
- package/dist/engines/pi/tool-context.d.ts +3 -3
- package/dist/engines/pi/tool.d.ts +6 -9
- package/dist/engines/pi/tool.js +1 -0
- package/dist/feishu.d.ts +1 -0
- package/dist/feishu.js +1 -0
- package/dist/lark.d.ts +1 -0
- package/dist/lark.js +1 -0
- package/dist/loader.d.ts +2 -0
- package/dist/loader.js +5 -0
- package/dist/log.d.ts +9 -17
- package/dist/log.js +25 -30
- package/dist/paths.d.ts +7 -3
- package/dist/paths.js +17 -4
- package/dist/scaffold/add-channel.d.ts +6 -1
- package/dist/scaffold/add-channel.js +48 -65
- package/dist/schedule/wake-alarm.d.ts +1 -12
- package/dist/schedule/wake-alarm.js +2 -3
- package/dist/service.d.ts +23 -22
- package/dist/service.js +7 -73
- package/dist/session-remote.d.ts +5 -5
- package/dist/session-remote.js +25 -28
- package/dist/session.d.ts +1 -1
- package/dist/slack.d.ts +2 -0
- package/dist/slack.js +1 -0
- package/dist/tunnel.d.ts +16 -6
- package/dist/tunnel.js +53 -12
- package/package.json +5 -4
- package/dist/channels/slack/bot-auth.d.ts +0 -15
- package/dist/channels/slack/bot-auth.js +0 -135
package/dist/service.d.ts
CHANGED
|
@@ -1,3 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The product, as one call: an agent directory becomes a live service.
|
|
3
|
+
*
|
|
4
|
+
* That phrase is the promise on the README, and until this existed only the CLI could keep it. The
|
|
5
|
+
* assembly parts live here too — `routesFor`, `mountSessionControl`, `startSchedules` — because a
|
|
6
|
+
* public entry may not reach into `cli/`: that directory decides process-level things (`fail.ts`
|
|
7
|
+
* calls `process.exit`) which a library mounted inside someone's app does not get to decide.
|
|
8
|
+
* Everything else was parts: assemble the agent, discover channels, mount the control plane, start
|
|
9
|
+
* schedules, open long connections, compose a router. An embedder had to know that list and get its
|
|
10
|
+
* order right, and getting it wrong is silent: a plane that 404s while advertising itself, a
|
|
11
|
+
* schedule that never fires.
|
|
12
|
+
*
|
|
13
|
+
* So the assembly lives here, and `dev`/`start` are callers. AgentCore is the one exception, and a
|
|
14
|
+
* substantive one: its channels load lazily after a state-snapshot restore, so it cannot use an
|
|
15
|
+
* assembly that discovers them eagerly (cli/commands/start.ts says so at the branch).
|
|
16
|
+
*/
|
|
1
17
|
import type { Agent } from "./agent.ts";
|
|
2
18
|
import { type PrefixMount } from "./channels/serve.ts";
|
|
3
19
|
import { type LoadedLongConnectionChannel } from "./channels/discover.ts";
|
|
@@ -35,20 +51,17 @@ export declare function routesFor(agentDir: string, agent: Agent, stateRoot: str
|
|
|
35
51
|
* load after this ran against an empty base.
|
|
36
52
|
*/
|
|
37
53
|
export declare function assertNoControlPlaneCollision(channelRoutes: Routes, plane: PrefixMount): void;
|
|
38
|
-
export declare function mountSessionControl(routes: Routes, control: SessionControl | undefined,
|
|
39
|
-
tunnel?: boolean;
|
|
54
|
+
export declare function mountSessionControl(routes: Routes, control: SessionControl | undefined, options?: {
|
|
40
55
|
agent?: Agent;
|
|
41
|
-
host?: string;
|
|
42
56
|
}): {
|
|
43
57
|
routes: Routes;
|
|
44
58
|
mounts: PrefixMount[];
|
|
45
|
-
/** The plane's bearer token and prefix — how
|
|
59
|
+
/** The plane's bearer token and prefix — how a caller distributes access (the CLI writes it to
|
|
60
|
+
* `<stateRoot>/control.json` for local discovery; an embedder hands it out itself). */
|
|
46
61
|
control?: {
|
|
47
62
|
token: string;
|
|
48
63
|
prefix: string;
|
|
49
64
|
};
|
|
50
|
-
/** Write the local discovery file; returns its removal. Installs no signal handlers. */
|
|
51
|
-
announce: (boundPort: number) => () => void;
|
|
52
65
|
};
|
|
53
66
|
/**
|
|
54
67
|
* Load and start the agent's `schedules/` — a time-trigger firing the agent on each cron. Starts iff
|
|
@@ -88,20 +101,14 @@ export interface AgentService {
|
|
|
88
101
|
* fails to come up, after closing the service: a host must not report itself serving while a
|
|
89
102
|
* declared channel is dead, and health answers 503 until this resolves. */
|
|
90
103
|
ready: Promise<void>;
|
|
91
|
-
/** The control plane's bearer token and prefix, when `sessionControl` is on — how
|
|
92
|
-
*
|
|
104
|
+
/** The control plane's bearer token and prefix, when `sessionControl` is on — how a caller hands
|
|
105
|
+
* access to a client. The CLI writes it to `<stateRoot>/control.json` for `fastagent attach`; an
|
|
106
|
+
* embedder mounted inside a larger app has no port of its own to describe and distributes it
|
|
107
|
+
* itself. */
|
|
93
108
|
control?: {
|
|
94
109
|
token: string;
|
|
95
110
|
prefix: string;
|
|
96
111
|
};
|
|
97
|
-
/** Write `<stateRoot>/control.json` so a LOCAL client (`fastagent attach`) can find the plane,
|
|
98
|
-
* once the port is known. Optional: an embedder mounted inside a larger app has no port of its
|
|
99
|
-
* own to describe and uses {@link AgentService.control} instead.
|
|
100
|
-
*
|
|
101
|
-
* Removed by `close()`. Not by an `exit` handler: installing one is a decision about the whole
|
|
102
|
-
* process, which a mounted library does not get to make. A hard exit therefore leaves the file
|
|
103
|
-
* behind — advisory, overwritten by the next boot, and the client's own error stays honest. */
|
|
104
|
-
announce(boundPort: number): void;
|
|
105
112
|
/** Stop long connections and schedules. Idempotent; also runs when `options.signal` aborts. */
|
|
106
113
|
close(): Promise<void>;
|
|
107
114
|
}
|
|
@@ -111,12 +118,6 @@ export interface MountAgentServiceOptions {
|
|
|
111
118
|
* must get the SAME one, which is why this is a hook rather than the caller's own call. `dev`
|
|
112
119
|
* passes `logAgentLoop`. */
|
|
113
120
|
wrapAgent?: (agent: Agent) => Agent;
|
|
114
|
-
/** Passed through to the control plane mount: `--tunnel` widens its warning, `host` names the
|
|
115
|
-
* bind address in the discovery file. */
|
|
116
|
-
control?: {
|
|
117
|
-
tunnel?: boolean;
|
|
118
|
-
host?: string;
|
|
119
|
-
};
|
|
120
121
|
/** Aborting this closes the service, exactly like calling {@link AgentService.close}. */
|
|
121
122
|
signal?: AbortSignal;
|
|
122
123
|
/** Called when a long connection ends on its own — a dropped socket-mode channel, say. The CLI
|
package/dist/service.js
CHANGED
|
@@ -1,23 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The product, as one call: an agent directory becomes a live service.
|
|
3
|
-
*
|
|
4
|
-
* That phrase is the promise on the README, and until this existed only the CLI could keep it. The
|
|
5
|
-
* assembly parts live here too — `routesFor`, `mountSessionControl`, `startSchedules` — because a
|
|
6
|
-
* public entry may not reach into `cli/`: that directory decides process-level things (`fail.ts`
|
|
7
|
-
* calls `process.exit`) which a library mounted inside someone's app does not get to decide.
|
|
8
|
-
* Everything else was parts: assemble the agent, discover channels, mount the control plane, start
|
|
9
|
-
* schedules, open long connections, compose a router. An embedder had to know that list and get its
|
|
10
|
-
* order right, and getting it wrong is silent: a plane that 404s while advertising itself, a
|
|
11
|
-
* schedule that never fires.
|
|
12
|
-
*
|
|
13
|
-
* So the assembly lives here, and `dev`/`start` are callers. AgentCore is the one exception, and a
|
|
14
|
-
* substantive one: its channels load lazily after a state-snapshot restore, so it cannot use an
|
|
15
|
-
* assembly that discovers them eagerly (cli/commands/start.ts says so at the branch).
|
|
16
|
-
*/
|
|
17
|
-
import { mkdirSync, rmSync } from "node:fs";
|
|
18
|
-
import { writeFileAtomic } from "./atomic-write.js";
|
|
19
|
-
import { join } from "node:path";
|
|
20
|
-
import { classifyBind, clientHost } from "./bind.js";
|
|
21
1
|
import { CONTROL_TOKEN_ENV, createControlPlane } from "./channels/control.js";
|
|
22
2
|
import { createInvokeHandler } from "./channels/http.js";
|
|
23
3
|
import { text } from "./channels/respond.js";
|
|
@@ -25,7 +5,8 @@ import { parseRouteKey, pathUnderPrefix, router } from "./channels/serve.js";
|
|
|
25
5
|
import { loadChannels } from "./channels/discover.js";
|
|
26
6
|
import { loadSchedules } from "./schedule/discover.js";
|
|
27
7
|
import { createScheduler } from "./schedule/scheduler.js";
|
|
28
|
-
import { log
|
|
8
|
+
import { log } from "./log.js";
|
|
9
|
+
import { reportModuleLoadFailures } from "./loader.js";
|
|
29
10
|
/** Default wait for a channel's `closed` before reporting it stuck. A channel that ignores its
|
|
30
11
|
* abort signal must not hang a caller's teardown — or, during a failed start, keep the original
|
|
31
12
|
* error from arriving. The CLI passes a shorter one: its own forced exit must come AFTER this, or
|
|
@@ -70,7 +51,7 @@ export async function routesFor(agentDir, agent, stateRoot, control, options = {
|
|
|
70
51
|
control,
|
|
71
52
|
});
|
|
72
53
|
for (const c of collisions) {
|
|
73
|
-
|
|
54
|
+
log.warn(`[fastagent] channel route "${c.route}" (${c.source}) collides with an earlier channel — not mounted`);
|
|
74
55
|
}
|
|
75
56
|
reportModuleLoadFailures(failures);
|
|
76
57
|
if (failures.length > 0 || collisions.length > 0) {
|
|
@@ -113,9 +94,9 @@ export function assertNoControlPlaneCollision(channelRoutes, plane) {
|
|
|
113
94
|
`rename the channel route or disable sessionControl in fastagent.config`);
|
|
114
95
|
}
|
|
115
96
|
}
|
|
116
|
-
export function mountSessionControl(routes, control,
|
|
97
|
+
export function mountSessionControl(routes, control, options = {}) {
|
|
117
98
|
if (!control)
|
|
118
|
-
return { routes, mounts: []
|
|
99
|
+
return { routes, mounts: [] };
|
|
119
100
|
// WHO OWNS the secret. Per-boot mint is right locally: discovery is `control.json` and its file
|
|
120
101
|
// permissions, which works because both holders share a filesystem. A deployment removes that
|
|
121
102
|
// premise — a token minted in the container is replaced every restart and reachable only by shelling
|
|
@@ -142,45 +123,7 @@ export function mountSessionControl(routes, control, stateRoot, options = {}) {
|
|
|
142
123
|
const token = injected || crypto.randomUUID();
|
|
143
124
|
const plane = createControlPlane(control, { token, agent: options.agent });
|
|
144
125
|
assertNoControlPlaneCollision(routes, plane);
|
|
145
|
-
return {
|
|
146
|
-
routes,
|
|
147
|
-
mounts: [plane],
|
|
148
|
-
control: { token, prefix: plane.prefix },
|
|
149
|
-
// Writes the discovery file and hands back its removal. It installs NO signal handlers: a
|
|
150
|
-
// library mounted inside someone's app must not change how that app exits — the CLI wires the
|
|
151
|
-
// returned cleanup into its own shutdown, an embedder into `close()`.
|
|
152
|
-
announce: (boundPort) => {
|
|
153
|
-
mkdirSync(stateRoot, { recursive: true, mode: 0o700 });
|
|
154
|
-
const path = join(stateRoot, "control.json");
|
|
155
|
-
const url = `http://${clientHost(options.host)}:${boundPort}`;
|
|
156
|
-
writeFileAtomic(path, `${JSON.stringify({ url, token })}\n`, 0o600);
|
|
157
|
-
log.info(`[fastagent] session control on /control/* (token in ${path})`);
|
|
158
|
-
// LAN-reachable with the bearer token as the only protection — the tunnel and deploy paths
|
|
159
|
-
// warn loudly, and the LAN path must not be the silent third way past the local trust story.
|
|
160
|
-
// A loopback bind closes exactly that reach, so it earns silence.
|
|
161
|
-
const bind = classifyBind(options.host);
|
|
162
|
-
if (bind !== "loopback") {
|
|
163
|
-
log.warn(`[fastagent] the port binds ${bind === "wildcard" ? "all interfaces" : `${options.host} (off this machine)`}: ` +
|
|
164
|
-
"/control/* is reachable on your LAN, protected only by the bearer token — bind loopback " +
|
|
165
|
-
"(--bind 127.0.0.1), firewall the port, or wrap it for real exposure (docs: design §14)");
|
|
166
|
-
}
|
|
167
|
-
if (options.tunnel) {
|
|
168
|
-
// Local trust = the token + its file permissions; --tunnel takes the whole port PUBLIC.
|
|
169
|
-
log.warn("[fastagent] --tunnel exposes /control/* (steer, stop, rewrite or delete a session) at the public tunnel URL, " +
|
|
170
|
-
"protected ONLY by the bearer token — wrap it with real auth before sharing that URL (docs: design §14)");
|
|
171
|
-
}
|
|
172
|
-
// Removed on shutdown so a stale file cannot point a client at a dead port: `attach` then
|
|
173
|
-
// fails with "cannot read" (accurate) instead of a stale token's misleading 401/ECONNREFUSED.
|
|
174
|
-
return () => {
|
|
175
|
-
try {
|
|
176
|
-
rmSync(path, { force: true });
|
|
177
|
-
}
|
|
178
|
-
catch {
|
|
179
|
-
/* the file is advisory — shutdown must not fail on it */
|
|
180
|
-
}
|
|
181
|
-
};
|
|
182
|
-
},
|
|
183
|
-
};
|
|
126
|
+
return { routes, mounts: [plane], control: { token, prefix: plane.prefix } };
|
|
184
127
|
}
|
|
185
128
|
/**
|
|
186
129
|
* Load and start the agent's `schedules/` — a time-trigger firing the agent on each cron. Starts iff
|
|
@@ -221,11 +164,7 @@ export async function mountAgentService(opened, options = {}) {
|
|
|
221
164
|
const agent = options.wrapAgent?.(opened.agent) ?? opened.agent;
|
|
222
165
|
const closeTimeoutMs = options.closeTimeoutMs ?? CLOSE_DEADLINE_MS;
|
|
223
166
|
const routed = await routesFor(agentDir, agent, stateRoot, sessionControl, { builtinInvoke: true });
|
|
224
|
-
const withControl = mountSessionControl(routed.routes, sessionControl,
|
|
225
|
-
agent,
|
|
226
|
-
...(options.control?.tunnel !== undefined ? { tunnel: options.control.tunnel } : {}),
|
|
227
|
-
...(options.control?.host !== undefined ? { host: options.control.host } : {}),
|
|
228
|
-
});
|
|
167
|
+
const withControl = mountSessionControl(routed.routes, sessionControl, { agent });
|
|
229
168
|
// Composed BEFORE anything starts. `router` re-validates what `loadChannels` and the control
|
|
230
169
|
// mount already checked, so on THIS path it should not fail — but "should not" is not an ordering
|
|
231
170
|
// guarantee, and a throw after the scheduler ticks and channels dial would leave both running
|
|
@@ -233,7 +172,6 @@ export async function mountAgentService(opened, options = {}) {
|
|
|
233
172
|
const handler = router(withControl.routes, withControl.mounts);
|
|
234
173
|
const scheduled = await startSchedules(agentDir, agent, stateRoot, opened.selfSchedule);
|
|
235
174
|
const abort = new AbortController();
|
|
236
|
-
let unannounce;
|
|
237
175
|
// A connection that drops while others are still dialling must not be undone by their later
|
|
238
176
|
// readiness: the service is missing a declared channel from that moment on, whatever else arrives.
|
|
239
177
|
let dropped = false;
|
|
@@ -259,7 +197,6 @@ export async function mountAgentService(opened, options = {}) {
|
|
|
259
197
|
abort.abort();
|
|
260
198
|
scheduled.stop();
|
|
261
199
|
options.signal?.removeEventListener("abort", onAbort);
|
|
262
|
-
unannounce?.(); // a stale discovery file would point a client at a dead port
|
|
263
200
|
// A failure to stop is the caller's to know about — swallowing it would let `close()` report
|
|
264
201
|
// success over a channel still holding on. Bounded, because a channel that ignores its abort
|
|
265
202
|
// signal must not hang the teardown either.
|
|
@@ -368,9 +305,6 @@ export async function mountAgentService(opened, options = {}) {
|
|
|
368
305
|
schedules: scheduled.schedules,
|
|
369
306
|
ready,
|
|
370
307
|
...(withControl.control ? { control: withControl.control } : {}),
|
|
371
|
-
announce: (boundPort) => {
|
|
372
|
-
unannounce = withControl.announce(boundPort);
|
|
373
|
-
},
|
|
374
308
|
close,
|
|
375
309
|
};
|
|
376
310
|
}
|
package/dist/session-remote.d.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The remote `SessionControl` — the client half of the
|
|
3
|
-
* server-neutral: speaks only the wire protocol
|
|
4
|
-
* {sessionId, epoch, seq, event} envelope) and
|
|
5
|
-
* local and remote consumers are isomorphic —
|
|
6
|
-
* of process.
|
|
2
|
+
* The remote `SessionControl` — the client half of the HTTP + SSE transport
|
|
3
|
+
* (docs/design/session-control.md §13). Engine- and server-neutral: speaks only the wire protocol
|
|
4
|
+
* `createControlPlane` serves (HTTP JSON + SSE with the {sessionId, epoch, seq, event} envelope) and
|
|
5
|
+
* re-exposes the SAME `SessionControl` interface, so local and remote consumers are isomorphic —
|
|
6
|
+
* client code does not change when the agent moves out of process.
|
|
7
7
|
*
|
|
8
8
|
* Envelope consumption is internal: a seq gap (loss in transit on this connection) — and any
|
|
9
9
|
* mid-stream transport failure, a server restart included (its connections drop) — THROWS from
|
package/dist/session-remote.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { SSE_HEARTBEAT_MS } from "./channels/
|
|
1
|
+
import { SSE_HEARTBEAT_MS } from "./channels/sse.js";
|
|
2
2
|
import { abortFirstIterator } from "./collect.js";
|
|
3
3
|
import { isAddressableSession, } from "./session.js";
|
|
4
4
|
/** Dead-connection watchdog for SSE reads: the server heartbeats every SSE_HEARTBEAT_MS, so a
|
|
@@ -58,7 +58,7 @@ async function controlError(res) {
|
|
|
58
58
|
// status over: both travel in one error rather than a bare SyntaxError from a rejection path.
|
|
59
59
|
return new ControlRequestError(res.status, `${body} (declared application/json but did not parse)`);
|
|
60
60
|
}
|
|
61
|
-
return new ControlRequestError(res.status, body, typeof parsed
|
|
61
|
+
return new ControlRequestError(res.status, body, typeof parsed?.code === "string" ? parsed.code : undefined);
|
|
62
62
|
}
|
|
63
63
|
/**
|
|
64
64
|
* Connect and return a remote `SessionControl`. Async because `capabilities()` is synchronous in
|
|
@@ -130,7 +130,12 @@ export async function connectSessionControl(options) {
|
|
|
130
130
|
catch (parseError) {
|
|
131
131
|
throw new Error(`control events: non-JSON data on the stream (${String(parseError)}) — protocol mismatch?`);
|
|
132
132
|
}
|
|
133
|
-
if (typeof wire
|
|
133
|
+
if (typeof wire !== "object" ||
|
|
134
|
+
wire === null ||
|
|
135
|
+
typeof wire.seq !== "number" ||
|
|
136
|
+
typeof wire.event !== "object" ||
|
|
137
|
+
wire.event === null ||
|
|
138
|
+
typeof wire.event.type !== "string") {
|
|
134
139
|
throw new Error("control events: malformed envelope — the endpoint does not speak this protocol version");
|
|
135
140
|
}
|
|
136
141
|
// Envelope checks — consumed HERE. (epoch is not compared: it cannot change within
|
|
@@ -156,6 +161,7 @@ export async function connectSessionControl(options) {
|
|
|
156
161
|
}
|
|
157
162
|
finally {
|
|
158
163
|
watchdog.stop();
|
|
164
|
+
abort.abort();
|
|
159
165
|
}
|
|
160
166
|
})();
|
|
161
167
|
return {
|
|
@@ -288,9 +294,7 @@ export function connectAgent(options) {
|
|
|
288
294
|
};
|
|
289
295
|
return;
|
|
290
296
|
}
|
|
291
|
-
//
|
|
292
|
-
// must not append a second one (catch included), and a stream that ends WITHOUT one
|
|
293
|
-
// (server died mid-run) must be closed with a failed — never a terminal-less end.
|
|
297
|
+
// A terminal closes the stream. Cleanup errors must not append a second terminal.
|
|
294
298
|
let terminalSeen = false;
|
|
295
299
|
// Armed BEFORE the fetch — the run's driver must not hang on a black-holed connect
|
|
296
300
|
// either (the connect await is a pending read; headers arriving disarm it).
|
|
@@ -328,38 +332,30 @@ export function connectAgent(options) {
|
|
|
328
332
|
event = JSON.parse(data);
|
|
329
333
|
}
|
|
330
334
|
catch (parseError) {
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
type: "failed",
|
|
337
|
-
details: `remote invoke: unparseable event on the stream (${String(parseError)})`,
|
|
338
|
-
retryable: false,
|
|
339
|
-
};
|
|
340
|
-
}
|
|
335
|
+
yield {
|
|
336
|
+
type: "failed",
|
|
337
|
+
details: `remote invoke: unparseable event on the stream (${String(parseError)})`,
|
|
338
|
+
retryable: false,
|
|
339
|
+
};
|
|
341
340
|
return;
|
|
342
341
|
}
|
|
343
342
|
// Shape check, same discipline as the events plane: `data: null` / `data: 42` is
|
|
344
343
|
// valid JSON but protocol drift — it must not TypeError into the catch below and be
|
|
345
344
|
// misclassified as retryable network trouble.
|
|
346
345
|
if (typeof event !== "object" || event === null || typeof event.type !== "string") {
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
};
|
|
353
|
-
}
|
|
346
|
+
yield {
|
|
347
|
+
type: "failed",
|
|
348
|
+
details: "remote invoke: non-event data on the stream — protocol mismatch?",
|
|
349
|
+
retryable: false,
|
|
350
|
+
};
|
|
354
351
|
return;
|
|
355
352
|
}
|
|
356
|
-
|
|
357
|
-
terminalSeen = true;
|
|
353
|
+
terminalSeen = event.type === "completed" || event.type === "failed";
|
|
358
354
|
yield event;
|
|
355
|
+
if (terminalSeen)
|
|
356
|
+
return;
|
|
359
357
|
}
|
|
360
|
-
|
|
361
|
-
yield { type: "failed", details: "remote invoke: stream ended without a terminal", retryable: true };
|
|
362
|
-
}
|
|
358
|
+
yield { type: "failed", details: "remote invoke: stream ended without a terminal", retryable: true };
|
|
363
359
|
}
|
|
364
360
|
catch (error) {
|
|
365
361
|
if (abort.signal.aborted) {
|
|
@@ -377,6 +373,7 @@ export function connectAgent(options) {
|
|
|
377
373
|
}
|
|
378
374
|
finally {
|
|
379
375
|
watchdog.stop();
|
|
376
|
+
abort.abort();
|
|
380
377
|
}
|
|
381
378
|
})();
|
|
382
379
|
// ONE stream per invoke, like a local async generator (which is its own iterator): a second
|
package/dist/session.d.ts
CHANGED
|
@@ -290,7 +290,7 @@ export interface SessionState {
|
|
|
290
290
|
/** Set by `update({ name })`, so a client that opens a session directly gets the same label the list
|
|
291
291
|
* showed. */
|
|
292
292
|
name?: string;
|
|
293
|
-
/** `compacting` refers to
|
|
293
|
+
/** `compacting` refers to MANUAL compaction (`compact`) at a session boundary. Automatic overflow
|
|
294
294
|
* compaction happens inside a run's activity window and reports as `running`. */
|
|
295
295
|
status: "idle" | "running" | "compacting";
|
|
296
296
|
activeRunId?: string;
|
package/dist/slack.d.ts
CHANGED
|
@@ -1,2 +1,4 @@
|
|
|
1
1
|
/** `@fastagent-sh/fastagent/slack` — the first-party Slack Events API bot-channel surface. */
|
|
2
2
|
export { slackChannel, defaultSlackRoute, slackEnvelope, verifySlackSignature, type SlackChannelOptions, type SlackEventEnvelope, type SlackFile, type SlackMessageEvent, type SlackRendering, type SlackRoute, type SlackFailure, } from "./channels/slack/slack.ts";
|
|
3
|
+
export { slackTransport, type SlackTransport } from "./channels/slack/shared-api.ts";
|
|
4
|
+
export type { SentSlackMessage, SlackTarget, UploadedSlackFile } from "./channels/slack/slack-api.ts";
|
package/dist/slack.js
CHANGED
|
@@ -1,2 +1,3 @@
|
|
|
1
1
|
/** `@fastagent-sh/fastagent/slack` — the first-party Slack Events API bot-channel surface. */
|
|
2
2
|
export { slackChannel, defaultSlackRoute, slackEnvelope, verifySlackSignature, } from "./channels/slack/slack.js";
|
|
3
|
+
export { slackTransport } from "./channels/slack/shared-api.js";
|
package/dist/tunnel.d.ts
CHANGED
|
@@ -15,15 +15,25 @@ export interface Tunnel {
|
|
|
15
15
|
}
|
|
16
16
|
/** Extract a Cloudflare quick-tunnel URL from a chunk of cloudflared output, if present. */
|
|
17
17
|
export declare function parseTunnelUrl(chunk: string): string | undefined;
|
|
18
|
+
/** Whether cloudflared's output so far reports an edge connection — see {@link TUNNEL_CONNECTED_RE}. */
|
|
19
|
+
export declare function hasTunnelConnection(output: string): boolean;
|
|
20
|
+
/**
|
|
21
|
+
* How long after that connection to hand the URL over. The one number here that is not observed: 5s
|
|
22
|
+
* covers the widest of the 7 measured gaps (3.9s), all taken over cloudflared's http2 transport
|
|
23
|
+
* because this network blocks its QUIC. Costs a beat of `dev --tunnel` start-up; buys the first
|
|
24
|
+
* registration attempt landing on a name that resolves.
|
|
25
|
+
*/
|
|
26
|
+
export declare const TUNNEL_DNS_LAG_MS = 5000;
|
|
18
27
|
/** How cloudflared is launched; injectable so tests can drive the child without a real process. */
|
|
19
28
|
type SpawnCloudflared = (port: number) => ChildProcess;
|
|
20
29
|
/**
|
|
21
|
-
* Start a Cloudflare quick tunnel to localhost:`port`, resolving once its public URL
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
30
|
+
* Start a Cloudflare quick tunnel to localhost:`port`, resolving once its public URL is assigned AND
|
|
31
|
+
* the tunnel has an edge connection — the URL is printed first and is not usable yet
|
|
32
|
+
* ({@link hasTunnelConnection}). cloudflared sometimes exits before printing a URL (a transient
|
|
33
|
+
* trycloudflare API error), so retry a few times. ALWAYS resolves to undefined WITH an operator log
|
|
34
|
+
* saying why — missing binary, the exit reason, or "gave up after retries" — never silently; serving
|
|
35
|
+
* continues without a tunnel either way. What remains after this is the PLATFORM's own warm-up, which
|
|
36
|
+
* each registrar absorbs by retrying while the platform reports it cannot yet verify the URL.
|
|
27
37
|
*/
|
|
28
38
|
export declare function startCloudflareTunnel(port: number, spawnFn?: SpawnCloudflared, attemptTimeoutMs?: number): Promise<Tunnel | undefined>;
|
|
29
39
|
/**
|
package/dist/tunnel.js
CHANGED
|
@@ -23,6 +23,28 @@ const TUNNEL_URL_RE = /https:\/\/(?!api\.)[a-z0-9-]+\.trycloudflare\.com/i;
|
|
|
23
23
|
export function parseTunnelUrl(chunk) {
|
|
24
24
|
return chunk.match(TUNNEL_URL_RE)?.[0];
|
|
25
25
|
}
|
|
26
|
+
/**
|
|
27
|
+
* cloudflared's line for an established edge connection. THIS, not the URL, is when the hostname
|
|
28
|
+
* begins to exist: `*.trycloudflare.com` is no wildcard, and a quick tunnel's record is published
|
|
29
|
+
* once the tunnel registers a connection — so the URL is printed while the name is still NXDOMAIN.
|
|
30
|
+
* A platform told about it inside that window answers "Failed to resolve host" and goes on answering
|
|
31
|
+
* it far longer than any registrar's retry budget (#435); the record going live seconds later does
|
|
32
|
+
* not undo the answer it already gave, which is why more retries were never the fix.
|
|
33
|
+
*
|
|
34
|
+
* Measured over 7 quick tunnels: the URL at +0s, this line at +1.1-1.9s, the record 0.5-3.9s later.
|
|
35
|
+
*/
|
|
36
|
+
const TUNNEL_CONNECTED_RE = /Registered tunnel connection/i;
|
|
37
|
+
/** Whether cloudflared's output so far reports an edge connection — see {@link TUNNEL_CONNECTED_RE}. */
|
|
38
|
+
export function hasTunnelConnection(output) {
|
|
39
|
+
return TUNNEL_CONNECTED_RE.test(output);
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* How long after that connection to hand the URL over. The one number here that is not observed: 5s
|
|
43
|
+
* covers the widest of the 7 measured gaps (3.9s), all taken over cloudflared's http2 transport
|
|
44
|
+
* because this network blocks its QUIC. Costs a beat of `dev --tunnel` start-up; buys the first
|
|
45
|
+
* registration attempt landing on a name that resolves.
|
|
46
|
+
*/
|
|
47
|
+
export const TUNNEL_DNS_LAG_MS = 5000;
|
|
26
48
|
/** Global timer (rather than timers/promises) so timeout/retry behavior is deterministic under fake timers. */
|
|
27
49
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
28
50
|
const TUNNEL_ATTEMPTS = 3;
|
|
@@ -31,12 +53,13 @@ const TUNNEL_RETRY_MS = 2000;
|
|
|
31
53
|
const TUNNEL_START_TIMEOUT_MS = 30_000;
|
|
32
54
|
const spawnCloudflared = (port) => spawn("cloudflared", ["tunnel", "--url", `http://localhost:${port}`], { stdio: ["ignore", "pipe", "pipe"] });
|
|
33
55
|
/**
|
|
34
|
-
* Start a Cloudflare quick tunnel to localhost:`port`, resolving once its public URL
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
56
|
+
* Start a Cloudflare quick tunnel to localhost:`port`, resolving once its public URL is assigned AND
|
|
57
|
+
* the tunnel has an edge connection — the URL is printed first and is not usable yet
|
|
58
|
+
* ({@link hasTunnelConnection}). cloudflared sometimes exits before printing a URL (a transient
|
|
59
|
+
* trycloudflare API error), so retry a few times. ALWAYS resolves to undefined WITH an operator log
|
|
60
|
+
* saying why — missing binary, the exit reason, or "gave up after retries" — never silently; serving
|
|
61
|
+
* continues without a tunnel either way. What remains after this is the PLATFORM's own warm-up, which
|
|
62
|
+
* each registrar absorbs by retrying while the platform reports it cannot yet verify the URL.
|
|
40
63
|
*/
|
|
41
64
|
export async function startCloudflareTunnel(port, spawnFn = spawnCloudflared, attemptTimeoutMs = TUNNEL_START_TIMEOUT_MS) {
|
|
42
65
|
for (let attempt = 1; attempt <= TUNNEL_ATTEMPTS; attempt++) {
|
|
@@ -55,26 +78,35 @@ export async function startCloudflareTunnel(port, spawnFn = spawnCloudflared, at
|
|
|
55
78
|
}
|
|
56
79
|
return undefined;
|
|
57
80
|
}
|
|
58
|
-
/** One cloudflared launch: a Tunnel
|
|
81
|
+
/** One cloudflared launch: a Tunnel once its URL is assigned AND the edge connection is up, or a
|
|
82
|
+
* failure (missing binary / exit before a URL). */
|
|
59
83
|
function spawnTunnelOnce(port, spawnFn, timeoutMs) {
|
|
60
84
|
return new Promise((resolve) => {
|
|
61
85
|
const child = spawnFn(port);
|
|
62
86
|
let settled = false;
|
|
63
87
|
let timer;
|
|
88
|
+
let handOver;
|
|
64
89
|
let tail = ""; // recent output, surfaced as the failure reason
|
|
90
|
+
let assigned; // printed well before the tunnel can carry anything
|
|
65
91
|
const finish = (result) => {
|
|
66
92
|
if (settled)
|
|
67
93
|
return;
|
|
68
94
|
settled = true;
|
|
69
95
|
if (timer)
|
|
70
96
|
clearTimeout(timer);
|
|
97
|
+
if (handOver)
|
|
98
|
+
clearTimeout(handOver);
|
|
71
99
|
resolve(result);
|
|
72
100
|
};
|
|
101
|
+
const handOverNow = (url) => finish({ tunnel: { url, close: () => child.kill("SIGTERM") } });
|
|
73
102
|
const onChunk = (buf) => {
|
|
74
103
|
tail = (tail + String(buf)).slice(-600);
|
|
75
|
-
|
|
76
|
-
if (
|
|
77
|
-
|
|
104
|
+
assigned ??= parseTunnelUrl(String(buf));
|
|
105
|
+
if (!assigned || handOver || !hasTunnelConnection(tail))
|
|
106
|
+
return;
|
|
107
|
+
const url = assigned;
|
|
108
|
+
handOver = setTimeout(() => handOverNow(url), TUNNEL_DNS_LAG_MS);
|
|
109
|
+
handOver.unref();
|
|
78
110
|
};
|
|
79
111
|
child.stdout?.on("data", onChunk);
|
|
80
112
|
child.stderr?.on("data", onChunk); // cloudflared prints the URL (and its errors) on stderr
|
|
@@ -91,6 +123,15 @@ function spawnTunnelOnce(port, spawnFn, timeoutMs) {
|
|
|
91
123
|
});
|
|
92
124
|
child.on("exit", () => finish({ fatal: false, detail: lastErrorLine(tail) }));
|
|
93
125
|
timer = setTimeout(() => {
|
|
126
|
+
// A URL whose tunnel never connected is not worth a fresh one — the next attempt meets the same
|
|
127
|
+
// network. Serve it and name what is wrong, rather than retrying into the same wall.
|
|
128
|
+
if (assigned) {
|
|
129
|
+
log.warn(`[fastagent] --tunnel: cloudflared never reported an edge connection for ${assigned} within ` +
|
|
130
|
+
`${Math.round(timeoutMs / 1000)}s — serving it anyway. Nothing reaches a tunnel that has not ` +
|
|
131
|
+
"connected, so a webhook registration that cannot resolve the host is this, not the platform.");
|
|
132
|
+
handOverNow(assigned);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
94
135
|
child.kill("SIGTERM");
|
|
95
136
|
finish({ fatal: false, detail: `timed out after ${Math.round(timeoutMs / 1000)}s waiting for a public URL` });
|
|
96
137
|
}, timeoutMs);
|
|
@@ -135,8 +176,8 @@ channels, opts = {}) {
|
|
|
135
176
|
log.warn(`[fastagent] could not read ${dotEnvPath(dir)}: ${error.message} — continuing without it`);
|
|
136
177
|
}
|
|
137
178
|
// Readiness is the registrar's job: a fresh quick tunnel returns Cloudflare 530 for ~20-30s before its
|
|
138
|
-
// origin connects, and
|
|
139
|
-
//
|
|
179
|
+
// origin connects, and each registrar absorbs that by retrying the platform call whose own URL
|
|
180
|
+
// verification reports it. GitHub needs no wait — the operator adds that webhook by hand.
|
|
140
181
|
//
|
|
141
182
|
// The registrars differ from the deploy path's in what they carry, not in which channels they answer
|
|
142
183
|
// for: this one narrates to the log and opens the console for a manual Feishu step.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fastagent-sh/fastagent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.0",
|
|
4
4
|
"description": "Vibe first. Then FastAgent: turn a local agent directory into a live service in your app, on GitHub, Telegram, Slack, or behind any channel.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"agent",
|
|
@@ -94,6 +94,7 @@
|
|
|
94
94
|
"build": "tsc -p tsconfig.build.json",
|
|
95
95
|
"prepack": "npm run build",
|
|
96
96
|
"test": "vitest --run",
|
|
97
|
+
"test:live": "vitest --run --config vitest.live.config.ts",
|
|
97
98
|
"typecheck": "tsc --noEmit",
|
|
98
99
|
"lint": "biome check src test && node scripts/check-doc-links.mjs && knip",
|
|
99
100
|
"format": "biome check --write src test",
|
|
@@ -105,9 +106,9 @@
|
|
|
105
106
|
},
|
|
106
107
|
"dependencies": {
|
|
107
108
|
"@clack/prompts": "^1.6.0",
|
|
108
|
-
"@earendil-works/pi-agent-core": "^0.
|
|
109
|
-
"@earendil-works/pi-ai": "^0.
|
|
110
|
-
"@earendil-works/pi-coding-agent": "^0.
|
|
109
|
+
"@earendil-works/pi-agent-core": "^0.85.1",
|
|
110
|
+
"@earendil-works/pi-ai": "^0.85.1",
|
|
111
|
+
"@earendil-works/pi-coding-agent": "^0.85.1",
|
|
111
112
|
"@hono/node-server": "^2.1.1",
|
|
112
113
|
"@larksuiteoapi/node-sdk": "^1.71.1",
|
|
113
114
|
"@octokit/webhooks-methods": "^6.0.0",
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
export interface SlackBotTokenProviderOptions {
|
|
2
|
-
statePath: string;
|
|
3
|
-
botToken: string;
|
|
4
|
-
botRefreshToken?: string;
|
|
5
|
-
clientId?: string;
|
|
6
|
-
clientSecret?: string;
|
|
7
|
-
botTokenExpiresAt?: number;
|
|
8
|
-
apiBaseUrl?: string;
|
|
9
|
-
fetch?: typeof fetch;
|
|
10
|
-
}
|
|
11
|
-
/** Latest local rotating credentials for `deploy --run`. The remote runtime still prefers its own
|
|
12
|
-
* durable volume; this overlay prevents a locally consumed refresh token from being redeployed. */
|
|
13
|
-
export declare function readSlackBotAuthEnv(statePath: string): Record<string, string>;
|
|
14
|
-
/** Resolve the current bot token, refreshing once per process when it approaches expiry. */
|
|
15
|
-
export declare function createSlackBotTokenProvider(options: SlackBotTokenProviderOptions): () => Promise<string>;
|