@fastagent-sh/fastagent 0.19.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/atomic-write.d.ts +11 -7
- package/dist/atomic-write.js +16 -11
- package/dist/channels/agentcore-limits.d.ts +9 -0
- package/dist/channels/agentcore-limits.js +9 -0
- package/dist/channels/agentcore-protocol.d.ts +112 -0
- package/dist/channels/agentcore-protocol.js +22 -0
- package/dist/channels/agentcore-service.d.ts +12 -15
- package/dist/channels/agentcore-service.js +15 -24
- package/dist/channels/agentcore-state.d.ts +5 -11
- package/dist/channels/agentcore-state.js +4 -1
- package/dist/channels/agentcore.d.ts +9 -83
- package/dist/channels/agentcore.js +101 -93
- package/dist/channels/control.d.ts +50 -12
- package/dist/channels/control.js +251 -157
- package/dist/channels/discover.d.ts +26 -5
- package/dist/channels/discover.js +13 -26
- 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 +7 -12
- package/dist/channels/feishu/feishu.js +86 -128
- 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/attachment-path.d.ts +12 -0
- package/dist/channels/kit/attachment-path.js +43 -0
- package/dist/channels/kit/context-buffer.d.ts +9 -0
- package/dist/channels/kit/context-buffer.js +11 -0
- package/dist/channels/kit/signature.d.ts +12 -0
- package/dist/channels/kit/signature.js +17 -0
- package/dist/channels/kit/stop-command.js +3 -3
- package/dist/channels/kit/tasks.d.ts +1 -1
- package/dist/channels/kit/tasks.js +10 -4
- package/dist/channels/kit/turn-runner.d.ts +59 -0
- package/dist/channels/kit/turn-runner.js +84 -0
- package/dist/channels/kit/turn-store.d.ts +19 -3
- package/dist/channels/kit/turn-store.js +29 -2
- 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/invoke-turn.d.ts +1 -1
- package/dist/channels/slack/invoke-turn.js +5 -1
- package/dist/channels/slack/manifest.js +5 -1
- package/dist/channels/slack/onboard.d.ts +5 -5
- package/dist/channels/slack/onboard.js +44 -23
- package/dist/channels/slack/onboarding-state.d.ts +7 -3
- package/dist/channels/slack/onboarding-state.js +13 -20
- package/dist/channels/slack/register-webhook.d.ts +3 -2
- package/dist/channels/slack/register-webhook.js +35 -15
- package/dist/channels/slack/scaffold/channel.ts +3 -10
- package/dist/channels/slack/scaffold/slack-send.ts +18 -141
- 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 +100 -73
- package/dist/channels/slack/slack.d.ts +0 -10
- package/dist/channels/slack/slack.js +68 -107
- package/dist/channels/sse.d.ts +4 -0
- package/dist/channels/sse.js +66 -0
- package/dist/channels/telegram/parse.d.ts +21 -1
- package/dist/channels/telegram/parse.js +65 -11
- package/dist/channels/telegram/register-webhook.d.ts +6 -9
- package/dist/channels/telegram/register-webhook.js +44 -42
- package/dist/channels/telegram/scaffold/channel.ts +7 -3
- package/dist/channels/telegram/telegram-api.js +5 -6
- package/dist/channels/telegram/telegram.d.ts +2 -2
- package/dist/channels/telegram/telegram.js +97 -204
- package/dist/channels/wait-health.js +7 -4
- package/dist/cli/add-feishu.js +3 -10
- package/dist/cli/add-slack.js +13 -25
- package/dist/cli/commands/add.d.ts +0 -1
- package/dist/cli/commands/add.js +22 -35
- package/dist/cli/commands/attach.d.ts +2 -4
- package/dist/cli/commands/attach.js +17 -15
- 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 +38 -25
- package/dist/cli/commands/deploy.js +101 -631
- package/dist/cli/commands/dev.js +20 -76
- package/dist/cli/commands/fire.js +6 -12
- package/dist/cli/commands/info.js +1 -1
- package/dist/cli/commands/init.js +1 -2
- package/dist/cli/commands/invoke.js +4 -11
- package/dist/cli/commands/schedule.js +1 -1
- package/dist/cli/commands/start.js +41 -80
- 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 +42 -34
- package/dist/cli/serve.js +107 -28
- package/dist/cli/shared.d.ts +52 -27
- package/dist/cli/shared.js +69 -6
- package/dist/deploy/agentcore/forwarder.js +250 -0
- package/dist/deploy/agentcore/logs.d.ts +2 -2
- package/dist/deploy/agentcore/logs.js +2 -2
- package/dist/deploy/agentcore/plan.d.ts +34 -11
- package/dist/deploy/agentcore/plan.js +52 -259
- package/dist/deploy/agentcore/run.d.ts +9 -24
- package/dist/deploy/agentcore/run.js +35 -43
- package/dist/deploy/channel-ingress.d.ts +73 -0
- package/dist/deploy/channel-ingress.js +101 -0
- package/dist/deploy/docker/plan.d.ts +3 -7
- package/dist/deploy/docker/plan.js +4 -15
- package/dist/deploy/docker/run.d.ts +35 -4
- package/dist/deploy/docker/run.js +40 -8
- package/dist/deploy/fly/plan.d.ts +4 -5
- package/dist/deploy/fly/plan.js +14 -22
- package/dist/deploy/fly/run.d.ts +36 -23
- package/dist/deploy/fly/run.js +129 -83
- package/dist/deploy/hosts.d.ts +5 -0
- package/dist/deploy/hosts.js +4 -0
- package/dist/deploy/preflight.d.ts +6 -7
- package/dist/deploy/preflight.js +27 -23
- package/dist/deploy/railway/plan.d.ts +9 -5
- package/dist/deploy/railway/plan.js +18 -26
- package/dist/deploy/railway/run.d.ts +11 -10
- package/dist/deploy/railway/run.js +44 -43
- package/dist/deploy/runner.js +22 -2
- package/dist/deploy/secrets.d.ts +18 -9
- package/dist/deploy/secrets.js +63 -28
- package/dist/dev-supervisor.js +3 -4
- package/dist/engines/pi/agent-session-factory.d.ts +37 -17
- package/dist/engines/pi/agent-session-factory.js +111 -106
- package/dist/engines/pi/auth.js +43 -43
- package/dist/engines/pi/config.d.ts +2 -2
- package/dist/engines/pi/create.d.ts +22 -27
- package/dist/engines/pi/create.js +59 -74
- package/dist/engines/pi/definition.d.ts +1 -1
- package/dist/engines/pi/definition.js +11 -12
- package/dist/engines/pi/invoke-session.js +13 -30
- package/dist/engines/pi/login.js +32 -16
- package/dist/engines/pi/open.d.ts +3 -4
- package/dist/engines/pi/open.js +32 -42
- package/dist/engines/pi/retry-event.d.ts +6 -0
- package/dist/engines/pi/retry-event.js +15 -0
- package/dist/engines/pi/search-tools.js +1 -1
- package/dist/engines/pi/service.d.ts +1 -1
- package/dist/engines/pi/service.js +8 -0
- package/dist/engines/pi/session-builder.js +26 -140
- package/dist/engines/pi/session-control.d.ts +27 -22
- package/dist/engines/pi/session-control.js +551 -490
- package/dist/engines/pi/session-inheritance.d.ts +8 -22
- package/dist/engines/pi/session-inheritance.js +95 -76
- package/dist/engines/pi/session-markers.d.ts +48 -0
- package/dist/engines/pi/session-markers.js +59 -0
- package/dist/engines/pi/session-settings.d.ts +5 -5
- package/dist/engines/pi/session-settings.js +8 -5
- package/dist/engines/pi/session-store.d.ts +91 -26
- package/dist/engines/pi/session-store.js +413 -82
- package/dist/engines/pi/tool-context.d.ts +24 -11
- package/dist/engines/pi/tool-context.js +29 -4
- package/dist/engines/pi/tool.d.ts +6 -9
- package/dist/engines/pi/tool.js +3 -2
- package/dist/env.js +1 -2
- 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 +51 -7
- package/dist/loader.js +84 -18
- package/dist/log.d.ts +9 -17
- package/dist/log.js +25 -30
- package/dist/paths.d.ts +26 -3
- package/dist/paths.js +43 -5
- package/dist/scaffold/add-channel.d.ts +6 -1
- package/dist/scaffold/add-channel.js +50 -67
- package/dist/scaffold/init.js +7 -2
- package/dist/scaffold/templates/tools/fetch-url.ts +0 -2
- package/dist/schedule/discover.js +3 -15
- package/dist/schedule/wake-alarm.d.ts +14 -19
- package/dist/schedule/wake-alarm.js +89 -48
- package/dist/schedule/wakeups.d.ts +1 -1
- package/dist/schedule/wakeups.js +10 -7
- package/dist/service.d.ts +24 -24
- package/dist/service.js +33 -77
- package/dist/session-remote.d.ts +12 -7
- package/dist/session-remote.js +185 -118
- package/dist/session.d.ts +227 -93
- package/dist/session.js +61 -23
- package/dist/slack.d.ts +2 -0
- package/dist/slack.js +1 -0
- package/dist/telegram.d.ts +1 -1
- package/dist/telegram.js +1 -1
- package/dist/tunnel.d.ts +34 -9
- package/dist/tunnel.js +83 -50
- 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.js
CHANGED
|
@@ -1,32 +1,12 @@
|
|
|
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
|
-
import { createControlPlane } from "./channels/control.js";
|
|
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";
|
|
24
|
-
import { parseRouteKey, pathUnderPrefix } from "./channels/serve.js";
|
|
4
|
+
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 {
|
|
29
|
-
import {
|
|
8
|
+
import { log } from "./log.js";
|
|
9
|
+
import { reportModuleLoadFailures } from "./loader.js";
|
|
30
10
|
/** Default wait for a channel's `closed` before reporting it stuck. A channel that ignores its
|
|
31
11
|
* abort signal must not hang a caller's teardown — or, during a failed start, keep the original
|
|
32
12
|
* error from arriving. The CLI passes a shorter one: its own forced exit must come AFTER this, or
|
|
@@ -71,7 +51,7 @@ export async function routesFor(agentDir, agent, stateRoot, control, options = {
|
|
|
71
51
|
control,
|
|
72
52
|
});
|
|
73
53
|
for (const c of collisions) {
|
|
74
|
-
|
|
54
|
+
log.warn(`[fastagent] channel route "${c.route}" (${c.source}) collides with an earlier channel — not mounted`);
|
|
75
55
|
}
|
|
76
56
|
reportModuleLoadFailures(failures);
|
|
77
57
|
if (failures.length > 0 || collisions.length > 0) {
|
|
@@ -114,51 +94,36 @@ export function assertNoControlPlaneCollision(channelRoutes, plane) {
|
|
|
114
94
|
`rename the channel route or disable sessionControl in fastagent.config`);
|
|
115
95
|
}
|
|
116
96
|
}
|
|
117
|
-
export function mountSessionControl(routes, control,
|
|
97
|
+
export function mountSessionControl(routes, control, options = {}) {
|
|
118
98
|
if (!control)
|
|
119
|
-
return { routes, mounts: []
|
|
120
|
-
|
|
99
|
+
return { routes, mounts: [] };
|
|
100
|
+
// WHO OWNS the secret. Per-boot mint is right locally: discovery is `control.json` and its file
|
|
101
|
+
// permissions, which works because both holders share a filesystem. A deployment removes that
|
|
102
|
+
// premise — a token minted in the container is replaced every restart and reachable only by shelling
|
|
103
|
+
// in — so there the deployer mints it and injects it here, like the wake/ingress secrets.
|
|
104
|
+
// Trimmed on read, like `.env` values already are: a token pasted from a dashboard with a trailing
|
|
105
|
+
// newline would otherwise become the box's token verbatim, and every caller holding the clean value
|
|
106
|
+
// gets a bare 401 — the undiagnosable symptom, one character wide.
|
|
107
|
+
const injected = process.env[CONTROL_TOKEN_ENV]?.trim();
|
|
108
|
+
// SET BUT EMPTY is the deployed default, not an edge case: the generated Compose topology writes
|
|
109
|
+
// every secret as `NAME: "${NAME:-}"`, so an operator who skipped this one lands here. Falling back
|
|
110
|
+
// silently would leave exactly the symptom the injection exists to remove — a token the caller does
|
|
111
|
+
// not have — with nothing in the log to tell it apart from a deployment that never asked.
|
|
112
|
+
if (injected === "") {
|
|
113
|
+
log.warn(`[fastagent] ${CONTROL_TOKEN_ENV} is set but empty — minting a per-boot token instead; callers holding ` +
|
|
114
|
+
"the deploy-time value will get 401 (set it, or read the minted one from control.json on the box)");
|
|
115
|
+
}
|
|
116
|
+
else if (injected !== undefined && injected.length < 16) {
|
|
117
|
+
// Length is a crude proxy for entropy — sixteen `a`s pass. It is aimed at `changeme`, which this
|
|
118
|
+
// change makes newly dangerous: the plane went from unusable-in-a-deployment to usable by whoever
|
|
119
|
+
// holds this string, and the empty case is the only other thing that says anything.
|
|
120
|
+
log.warn(`[fastagent] ${CONTROL_TOKEN_ENV} is ${injected.length} characters — it is the ONLY thing between ` +
|
|
121
|
+
"/control/* (steer, stop, rewrite a session) and anyone who can reach the port; use a random value (uuidgen)");
|
|
122
|
+
}
|
|
123
|
+
const token = injected || crypto.randomUUID();
|
|
121
124
|
const plane = createControlPlane(control, { token, agent: options.agent });
|
|
122
125
|
assertNoControlPlaneCollision(routes, plane);
|
|
123
|
-
return {
|
|
124
|
-
routes,
|
|
125
|
-
mounts: [plane],
|
|
126
|
-
control: { token, prefix: plane.prefix },
|
|
127
|
-
// Writes the discovery file and hands back its removal. It installs NO signal handlers: a
|
|
128
|
-
// library mounted inside someone's app must not change how that app exits — the CLI wires the
|
|
129
|
-
// returned cleanup into its own shutdown, an embedder into `close()`.
|
|
130
|
-
announce: (boundPort) => {
|
|
131
|
-
mkdirSync(stateRoot, { recursive: true, mode: 0o700 });
|
|
132
|
-
const path = join(stateRoot, "control.json");
|
|
133
|
-
const url = `http://${clientHost(options.host)}:${boundPort}`;
|
|
134
|
-
writeFileAtomic(path, `${JSON.stringify({ url, token })}\n`, 0o600);
|
|
135
|
-
log.info(`[fastagent] session control on /control/* (token in ${path})`);
|
|
136
|
-
// LAN-reachable with the bearer token as the only protection — the tunnel and deploy paths
|
|
137
|
-
// warn loudly, and the LAN path must not be the silent third way past the local trust story.
|
|
138
|
-
// A loopback bind closes exactly that reach, so it earns silence.
|
|
139
|
-
const bind = classifyBind(options.host);
|
|
140
|
-
if (bind !== "loopback") {
|
|
141
|
-
log.warn(`[fastagent] the port binds ${bind === "wildcard" ? "all interfaces" : `${options.host} (off this machine)`}: ` +
|
|
142
|
-
"/control/* is reachable on your LAN, protected only by the bearer token — bind loopback " +
|
|
143
|
-
"(--bind 127.0.0.1), firewall the port, or wrap it for real exposure (docs: design §14)");
|
|
144
|
-
}
|
|
145
|
-
if (options.tunnel) {
|
|
146
|
-
// Local trust = the token + its file permissions; --tunnel takes the whole port PUBLIC.
|
|
147
|
-
log.warn("[fastagent] --tunnel exposes /control/* (steer/abort/set_model) at the public tunnel URL, " +
|
|
148
|
-
"protected ONLY by the bearer token — wrap it with real auth before sharing that URL (docs: design §14)");
|
|
149
|
-
}
|
|
150
|
-
// Removed on shutdown so a stale file cannot point a client at a dead port: `attach` then
|
|
151
|
-
// fails with "cannot read" (accurate) instead of a stale token's misleading 401/ECONNREFUSED.
|
|
152
|
-
return () => {
|
|
153
|
-
try {
|
|
154
|
-
rmSync(path, { force: true });
|
|
155
|
-
}
|
|
156
|
-
catch {
|
|
157
|
-
/* the file is advisory — shutdown must not fail on it */
|
|
158
|
-
}
|
|
159
|
-
};
|
|
160
|
-
},
|
|
161
|
-
};
|
|
126
|
+
return { routes, mounts: [plane], control: { token, prefix: plane.prefix } };
|
|
162
127
|
}
|
|
163
128
|
/**
|
|
164
129
|
* Load and start the agent's `schedules/` — a time-trigger firing the agent on each cron. Starts iff
|
|
@@ -199,11 +164,7 @@ export async function mountAgentService(opened, options = {}) {
|
|
|
199
164
|
const agent = options.wrapAgent?.(opened.agent) ?? opened.agent;
|
|
200
165
|
const closeTimeoutMs = options.closeTimeoutMs ?? CLOSE_DEADLINE_MS;
|
|
201
166
|
const routed = await routesFor(agentDir, agent, stateRoot, sessionControl, { builtinInvoke: true });
|
|
202
|
-
const withControl = mountSessionControl(routed.routes, sessionControl,
|
|
203
|
-
agent,
|
|
204
|
-
...(options.control?.tunnel !== undefined ? { tunnel: options.control.tunnel } : {}),
|
|
205
|
-
...(options.control?.host !== undefined ? { host: options.control.host } : {}),
|
|
206
|
-
});
|
|
167
|
+
const withControl = mountSessionControl(routed.routes, sessionControl, { agent });
|
|
207
168
|
// Composed BEFORE anything starts. `router` re-validates what `loadChannels` and the control
|
|
208
169
|
// mount already checked, so on THIS path it should not fail — but "should not" is not an ordering
|
|
209
170
|
// guarantee, and a throw after the scheduler ticks and channels dial would leave both running
|
|
@@ -211,7 +172,6 @@ export async function mountAgentService(opened, options = {}) {
|
|
|
211
172
|
const handler = router(withControl.routes, withControl.mounts);
|
|
212
173
|
const scheduled = await startSchedules(agentDir, agent, stateRoot, opened.selfSchedule);
|
|
213
174
|
const abort = new AbortController();
|
|
214
|
-
let unannounce;
|
|
215
175
|
// A connection that drops while others are still dialling must not be undone by their later
|
|
216
176
|
// readiness: the service is missing a declared channel from that moment on, whatever else arrives.
|
|
217
177
|
let dropped = false;
|
|
@@ -237,7 +197,6 @@ export async function mountAgentService(opened, options = {}) {
|
|
|
237
197
|
abort.abort();
|
|
238
198
|
scheduled.stop();
|
|
239
199
|
options.signal?.removeEventListener("abort", onAbort);
|
|
240
|
-
unannounce?.(); // a stale discovery file would point a client at a dead port
|
|
241
200
|
// A failure to stop is the caller's to know about — swallowing it would let `close()` report
|
|
242
201
|
// success over a channel still holding on. Bounded, because a channel that ignores its abort
|
|
243
202
|
// signal must not hang the teardown either.
|
|
@@ -346,9 +305,6 @@ export async function mountAgentService(opened, options = {}) {
|
|
|
346
305
|
schedules: scheduled.schedules,
|
|
347
306
|
ready,
|
|
348
307
|
...(withControl.control ? { control: withControl.control } : {}),
|
|
349
|
-
announce: (boundPort) => {
|
|
350
|
-
unannounce = withControl.announce(boundPort);
|
|
351
|
-
},
|
|
352
308
|
close,
|
|
353
309
|
};
|
|
354
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
|
|
@@ -15,13 +15,18 @@
|
|
|
15
15
|
* visible as a thrown iteration error, a failed request as a rejected promise.
|
|
16
16
|
*/
|
|
17
17
|
import type { Agent } from "./agent.ts";
|
|
18
|
-
import type
|
|
18
|
+
import { type SessionControl } from "./session.ts";
|
|
19
19
|
/** A control request the server answered with a non-2xx status. Carries the STRUCTURED status so a
|
|
20
20
|
* consumer distinguishing auth failure (401 — stale token, unrecoverable) from transient transport
|
|
21
21
|
* trouble branches on `status`, never on message prose. */
|
|
22
22
|
export declare class ControlRequestError extends Error {
|
|
23
23
|
readonly status: number;
|
|
24
|
-
|
|
24
|
+
/** The plane's own error code, when the reply carried one (`sessions()` is the only read that
|
|
25
|
+
* does today — design §13). Absent for a plain-text rejection (401) or a proxy's page: a caller
|
|
26
|
+
* distinguishing "this deployment cannot list sessions" from "the endpoint is unreachable" reads
|
|
27
|
+
* THIS, not the status. */
|
|
28
|
+
readonly code?: string;
|
|
29
|
+
constructor(status: number, body: string, code?: string);
|
|
25
30
|
}
|
|
26
31
|
/** Connection parameters shared by BOTH remote planes (`connectSessionControl` and
|
|
27
32
|
* `connectAgent`) — plane-neutral on purpose: one endpoint, one token, two contracts. */
|
package/dist/session-remote.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { SSE_HEARTBEAT_MS } from "./channels/
|
|
1
|
+
import { SSE_HEARTBEAT_MS } from "./channels/sse.js";
|
|
2
2
|
import { abortFirstIterator } from "./collect.js";
|
|
3
|
+
import { isAddressableSession, } from "./session.js";
|
|
3
4
|
/** Dead-connection watchdog for SSE reads: the server heartbeats every SSE_HEARTBEAT_MS, so a
|
|
4
5
|
* PENDING READ seeing no bytes (of ANY kind — comments included) for this many missed beats
|
|
5
6
|
* means the connection is a black hole. The stream is aborted and surfaced as an error, so a
|
|
@@ -31,11 +32,34 @@ function idleWatchdog(abort) {
|
|
|
31
32
|
* trouble branches on `status`, never on message prose. */
|
|
32
33
|
export class ControlRequestError extends Error {
|
|
33
34
|
status;
|
|
34
|
-
|
|
35
|
+
/** The plane's own error code, when the reply carried one (`sessions()` is the only read that
|
|
36
|
+
* does today — design §13). Absent for a plain-text rejection (401) or a proxy's page: a caller
|
|
37
|
+
* distinguishing "this deployment cannot list sessions" from "the endpoint is unreachable" reads
|
|
38
|
+
* THIS, not the status. */
|
|
39
|
+
code;
|
|
40
|
+
constructor(status, body, code) {
|
|
35
41
|
super(`control request failed: ${status} ${body}`);
|
|
36
42
|
this.status = status;
|
|
43
|
+
if (code !== undefined)
|
|
44
|
+
this.code = code;
|
|
37
45
|
}
|
|
38
46
|
}
|
|
47
|
+
/** A non-2xx reply as an error, carrying the plane's code when the reply declared one. */
|
|
48
|
+
async function controlError(res) {
|
|
49
|
+
const body = await res.text();
|
|
50
|
+
if (!res.headers.get("content-type")?.includes("application/json"))
|
|
51
|
+
return new ControlRequestError(res.status, body);
|
|
52
|
+
let parsed;
|
|
53
|
+
try {
|
|
54
|
+
parsed = JSON.parse(body);
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
// The reply declared JSON and is not — a protocol fault worth seeing, but not worth losing the
|
|
58
|
+
// status over: both travel in one error rather than a bare SyntaxError from a rejection path.
|
|
59
|
+
return new ControlRequestError(res.status, `${body} (declared application/json but did not parse)`);
|
|
60
|
+
}
|
|
61
|
+
return new ControlRequestError(res.status, body, typeof parsed?.code === "string" ? parsed.code : undefined);
|
|
62
|
+
}
|
|
39
63
|
/**
|
|
40
64
|
* Connect and return a remote `SessionControl`. Async because `capabilities()` is synchronous in
|
|
41
65
|
* the contract: the static declaration is fetched ONCE here and served from memory — which also
|
|
@@ -59,10 +83,110 @@ export async function connectSessionControl(options) {
|
|
|
59
83
|
const get = async (path, timeoutMs = REQUEST_TIMEOUT_MS) => {
|
|
60
84
|
const res = await fetchFn(`${base}${path}`, { headers, signal: AbortSignal.timeout(timeoutMs) });
|
|
61
85
|
if (!res.ok)
|
|
62
|
-
throw
|
|
86
|
+
throw await controlError(res);
|
|
63
87
|
return (await res.json());
|
|
64
88
|
};
|
|
65
89
|
const capabilities = await get("/control/capabilities");
|
|
90
|
+
const eventsOf = (session) => {
|
|
91
|
+
// Each ITERATION opens its own connection (gen/abort created inside asyncIterator), matching
|
|
92
|
+
// the local hub's "every iteration is a fresh subscription" — a shared single-use generator
|
|
93
|
+
// would make the second for-await silently empty, breaking local/remote isomorphism.
|
|
94
|
+
// The abort controller lives OUTSIDE the generator: a consumer's `return()`/`break` while the
|
|
95
|
+
// generator is suspended on a quiet SSE read must abort the fetch FIRST — an async generator's
|
|
96
|
+
// own finally only runs after the pending await settles, which a silent stream never does.
|
|
97
|
+
const openStream = (abort) => (async function* iterate() {
|
|
98
|
+
// Armed BEFORE the fetch: the connect phase (headers never arriving from a black-holed
|
|
99
|
+
// endpoint) is otherwise a window no timeout covers — the same watchdog terminates it,
|
|
100
|
+
// with headers-arrival counting as the first sign of life.
|
|
101
|
+
const watchdog = idleWatchdog(abort);
|
|
102
|
+
watchdog.arm(); // the connect await is a pending read
|
|
103
|
+
try {
|
|
104
|
+
const res = await fetchFn(`${base}/control/sessions/${encodeURIComponent(session)}/events`, {
|
|
105
|
+
headers,
|
|
106
|
+
signal: abort.signal,
|
|
107
|
+
});
|
|
108
|
+
watchdog.disarm(); // headers arrived
|
|
109
|
+
if (!res.ok) {
|
|
110
|
+
// The error body is a pending read too — a half-dead tunnel serving 4xx headers then
|
|
111
|
+
// black-holing the body must not hang the round outside every budget. Re-armed: the
|
|
112
|
+
// watchdog aborts the read and the round fails with the dead-connection diagnosis.
|
|
113
|
+
watchdog.arm();
|
|
114
|
+
throw new ControlRequestError(res.status, await res.text());
|
|
115
|
+
}
|
|
116
|
+
if (!res.body)
|
|
117
|
+
throw new Error("control events: response has no body");
|
|
118
|
+
let nextSeq = 0;
|
|
119
|
+
for await (const data of sseData(res.body, watchdog)) {
|
|
120
|
+
// Parse discipline, same as the other two wire planes (dispatch parses, invoke
|
|
121
|
+
// classifies drift): a non-JSON or non-envelope payload is PROTOCOL MISMATCH —
|
|
122
|
+
// thrown, so a consumer's failure budget applies — never misdiagnosed as an
|
|
123
|
+
// in-transit gap whose remedy (reconnect) can never fix it.
|
|
124
|
+
let wire;
|
|
125
|
+
try {
|
|
126
|
+
// The ONE envelope type (control.ts's WireEvent) — an inline shape would let the
|
|
127
|
+
// envelope drift server-side while this cast silently kept the old fields.
|
|
128
|
+
wire = JSON.parse(data);
|
|
129
|
+
}
|
|
130
|
+
catch (parseError) {
|
|
131
|
+
throw new Error(`control events: non-JSON data on the stream (${String(parseError)}) — protocol mismatch?`);
|
|
132
|
+
}
|
|
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") {
|
|
139
|
+
throw new Error("control events: malformed envelope — the endpoint does not speak this protocol version");
|
|
140
|
+
}
|
|
141
|
+
// Envelope checks — consumed HERE. (epoch is not compared: it cannot change within
|
|
142
|
+
// one connection — see the header note.) A gap THROWS like a protocol mismatch: the
|
|
143
|
+
// consumer's failure path (budget, its own io) owns the diagnostic — a library-level
|
|
144
|
+
// log would bypass consumer output discipline, and a silent clean end would be
|
|
145
|
+
// indistinguishable from the server closing normally.
|
|
146
|
+
if (wire.seq !== nextSeq) {
|
|
147
|
+
throw new Error(`control events: sequence gap (expected ${nextSeq}, got ${wire.seq}) — events were lost in transit; resync via entries()`);
|
|
148
|
+
}
|
|
149
|
+
nextSeq = wire.seq + 1;
|
|
150
|
+
yield wire.event;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
catch (error) {
|
|
154
|
+
if (abort.signal.aborted) {
|
|
155
|
+
if (watchdog.stale()) {
|
|
156
|
+
throw new Error(`control events: no bytes for ${SSE_IDLE_LIMIT_MS / 1000}s (heartbeats absent) — dead connection; resync via entries()`);
|
|
157
|
+
}
|
|
158
|
+
return; // the consumer walked away — clean end, not an error
|
|
159
|
+
}
|
|
160
|
+
throw error;
|
|
161
|
+
}
|
|
162
|
+
finally {
|
|
163
|
+
watchdog.stop();
|
|
164
|
+
abort.abort();
|
|
165
|
+
}
|
|
166
|
+
})();
|
|
167
|
+
return {
|
|
168
|
+
[Symbol.asyncIterator]() {
|
|
169
|
+
const abort = new AbortController();
|
|
170
|
+
// Abort-first cancellation (see abortFirstIterator): aborting the connection unblocks a
|
|
171
|
+
// generator suspended on a quiet stream read.
|
|
172
|
+
return abortFirstIterator(openStream(abort), () => abort.abort());
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
};
|
|
176
|
+
/** A write that answers a `SessionResult`: the result rides HTTP 200 either way (`ok: false` is a
|
|
177
|
+
* protocol answer, not a transport failure), so a non-2xx here is a REAL transport/auth fault. */
|
|
178
|
+
const write = async (path, method, body) => {
|
|
179
|
+
const res = await fetchFn(`${base}${path}`, {
|
|
180
|
+
method,
|
|
181
|
+
headers: body === undefined ? headers : { ...headers, "content-type": "application/json" },
|
|
182
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
183
|
+
signal: AbortSignal.timeout(PAYLOAD_TIMEOUT_MS),
|
|
184
|
+
});
|
|
185
|
+
if (!res.ok)
|
|
186
|
+
throw await controlError(res);
|
|
187
|
+
return (await res.json());
|
|
188
|
+
};
|
|
189
|
+
const id = (session) => encodeURIComponent(session);
|
|
66
190
|
return {
|
|
67
191
|
capabilities: () => capabilities,
|
|
68
192
|
// NOT prefetched like capabilities: a live definition can grow a skill between calls, so the
|
|
@@ -82,98 +206,50 @@ export async function connectSessionControl(options) {
|
|
|
82
206
|
throw error;
|
|
83
207
|
}
|
|
84
208
|
},
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
// Each ITERATION opens its own connection (gen/abort created inside asyncIterator), matching
|
|
100
|
-
// the local hub's "every iteration is a fresh subscription" — a shared single-use generator
|
|
101
|
-
// would make the second for-await silently empty, breaking local/remote isomorphism.
|
|
102
|
-
// The abort controller lives OUTSIDE the generator: a consumer's `return()`/`break` while the
|
|
103
|
-
// generator is suspended on a quiet SSE read must abort the fetch FIRST — an async generator's
|
|
104
|
-
// own finally only runs after the pending await settles, which a silent stream never does.
|
|
105
|
-
const openStream = (abort) => (async function* iterate() {
|
|
106
|
-
// Armed BEFORE the fetch: the connect phase (headers never arriving from a black-holed
|
|
107
|
-
// endpoint) is otherwise a window no timeout covers — the same watchdog terminates it,
|
|
108
|
-
// with headers-arrival counting as the first sign of life.
|
|
109
|
-
const watchdog = idleWatchdog(abort);
|
|
110
|
-
watchdog.arm(); // the connect await is a pending read
|
|
111
|
-
try {
|
|
112
|
-
const res = await fetchFn(`${base}/control/events?session=${encodeURIComponent(session)}`, {
|
|
113
|
-
headers,
|
|
114
|
-
signal: abort.signal,
|
|
115
|
-
});
|
|
116
|
-
watchdog.disarm(); // headers arrived
|
|
117
|
-
if (!res.ok) {
|
|
118
|
-
// The error body is a pending read too — a half-dead tunnel serving 4xx headers then
|
|
119
|
-
// black-holing the body must not hang the round outside every budget. Re-armed: the
|
|
120
|
-
// watchdog aborts the read and the round fails with the dead-connection diagnosis.
|
|
121
|
-
watchdog.arm();
|
|
122
|
-
throw new ControlRequestError(res.status, await res.text());
|
|
123
|
-
}
|
|
124
|
-
if (!res.body)
|
|
125
|
-
throw new Error("control events: response has no body");
|
|
126
|
-
let nextSeq = 0;
|
|
127
|
-
for await (const data of sseData(res.body, watchdog)) {
|
|
128
|
-
// Parse discipline, same as the other two wire planes (dispatch parses, invoke
|
|
129
|
-
// classifies drift): a non-JSON or non-envelope payload is PROTOCOL MISMATCH —
|
|
130
|
-
// thrown, so a consumer's failure budget applies — never misdiagnosed as an
|
|
131
|
-
// in-transit gap whose remedy (reconnect) can never fix it.
|
|
132
|
-
let wire;
|
|
133
|
-
try {
|
|
134
|
-
// The ONE envelope type (control.ts's WireEvent) — an inline shape would let the
|
|
135
|
-
// envelope drift server-side while this cast silently kept the old fields.
|
|
136
|
-
wire = JSON.parse(data);
|
|
137
|
-
}
|
|
138
|
-
catch (parseError) {
|
|
139
|
-
throw new Error(`control events: non-JSON data on the stream (${String(parseError)}) — protocol mismatch?`);
|
|
140
|
-
}
|
|
141
|
-
if (typeof wire.seq !== "number" || typeof wire.event !== "object" || wire.event === null) {
|
|
142
|
-
throw new Error("control events: malformed envelope — the endpoint does not speak this protocol version");
|
|
143
|
-
}
|
|
144
|
-
// Envelope checks — consumed HERE. (epoch is not compared: it cannot change within
|
|
145
|
-
// one connection — see the header note.) A gap THROWS like a protocol mismatch: the
|
|
146
|
-
// consumer's failure path (budget, its own io) owns the diagnostic — a library-level
|
|
147
|
-
// log would bypass consumer output discipline, and a silent clean end would be
|
|
148
|
-
// indistinguishable from the server closing normally.
|
|
149
|
-
if (wire.seq !== nextSeq) {
|
|
150
|
-
throw new Error(`control events: sequence gap (expected ${nextSeq}, got ${wire.seq}) — events were lost in transit; resync via entries()`);
|
|
151
|
-
}
|
|
152
|
-
nextSeq = wire.seq + 1;
|
|
153
|
-
yield wire.event;
|
|
154
|
-
}
|
|
209
|
+
sessions: {
|
|
210
|
+
// Rejects when the deployment cannot enumerate its store — the coded 503 arrives as a
|
|
211
|
+
// ControlRequestError carrying `sessions_unavailable`, so a client can tell it from an
|
|
212
|
+
// unreachable endpoint instead of retrying forever.
|
|
213
|
+
list: () => get("/control/sessions", PAYLOAD_TIMEOUT_MS),
|
|
214
|
+
// PUT: the fork is idempotent, and so is the request that carries it. `into` becomes a path
|
|
215
|
+
// segment exactly like `get`'s id, so it is refused on the same rule — without this the local
|
|
216
|
+
// plane answers `invalid_command` while the wire answers 404 from a URL that normalised away.
|
|
217
|
+
// ASYNC, so the guard REJECTS rather than throwing out of a method typed `Promise`: a caller
|
|
218
|
+
// that wrote `.catch(…)` — or handed this to `Promise.all` — must not be surprised by a
|
|
219
|
+
// synchronous throw. (`get` may throw: it is synchronous by signature.)
|
|
220
|
+
fork: async ({ from, at, into }) => {
|
|
221
|
+
if (!isAddressableSession(into)) {
|
|
222
|
+
throw new Error(`session id ${JSON.stringify(into)} cannot travel as a URL path segment — this transport cannot address it`);
|
|
155
223
|
}
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
224
|
+
return write(`/control/sessions/${id(into)}`, "PUT", { from, at });
|
|
225
|
+
},
|
|
226
|
+
// The local hub's handle is a pure binding; so is this one — an id and the transport above it.
|
|
227
|
+
// Nothing is FETCHED here, which is what keeps the two isomorphic. What is checked is the one
|
|
228
|
+
// thing the wire cannot express: `.` and `..` survive `encodeURIComponent` and are then
|
|
229
|
+
// normalised away by URL parsing, so every call on such a handle would arrive at a DIFFERENT
|
|
230
|
+
// route — `.` reads as the collection (200 JSON, which the SSE reader ends as a silently empty
|
|
231
|
+
// stream) and `..` as a 404 the local plane answers normally. Refused at the binding, where a
|
|
232
|
+
// caller can see it, rather than once per call in a place it looks like a server answer.
|
|
233
|
+
get: (session) => {
|
|
234
|
+
if (!isAddressableSession(session)) {
|
|
235
|
+
throw new Error(`session id ${JSON.stringify(session)} cannot travel as a URL path segment — this transport cannot address it`);
|
|
164
236
|
}
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
237
|
+
return {
|
|
238
|
+
id: session,
|
|
239
|
+
state: () => get(`/control/sessions/${id(session)}`),
|
|
240
|
+
entries: (options) => get(`/control/sessions/${id(session)}/entries${options?.since !== undefined ? `?since=${encodeURIComponent(options.since)}` : ""}`, PAYLOAD_TIMEOUT_MS),
|
|
241
|
+
events: () => eventsOf(session),
|
|
242
|
+
update: (patch) => write(`/control/sessions/${id(session)}`, "PATCH", patch),
|
|
243
|
+
steer: (prompt) => write(`/control/sessions/${id(session)}/actions`, "POST", { type: "steer", prompt }),
|
|
244
|
+
followUp: (prompt) => write(`/control/sessions/${id(session)}/actions`, "POST", { type: "follow_up", prompt }),
|
|
245
|
+
abort: () => write(`/control/sessions/${id(session)}/actions`, "POST", { type: "abort" }),
|
|
246
|
+
compact: (options) => write(`/control/sessions/${id(session)}/actions`, "POST", {
|
|
247
|
+
type: "compact",
|
|
248
|
+
...(options?.instructions !== undefined ? { instructions: options.instructions } : {}),
|
|
249
|
+
}),
|
|
250
|
+
delete: () => write(`/control/sessions/${id(session)}`, "DELETE"),
|
|
251
|
+
};
|
|
252
|
+
},
|
|
177
253
|
},
|
|
178
254
|
};
|
|
179
255
|
}
|
|
@@ -218,9 +294,7 @@ export function connectAgent(options) {
|
|
|
218
294
|
};
|
|
219
295
|
return;
|
|
220
296
|
}
|
|
221
|
-
//
|
|
222
|
-
// must not append a second one (catch included), and a stream that ends WITHOUT one
|
|
223
|
-
// (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.
|
|
224
298
|
let terminalSeen = false;
|
|
225
299
|
// Armed BEFORE the fetch — the run's driver must not hang on a black-holed connect
|
|
226
300
|
// either (the connect await is a pending read; headers arriving disarm it).
|
|
@@ -258,38 +332,30 @@ export function connectAgent(options) {
|
|
|
258
332
|
event = JSON.parse(data);
|
|
259
333
|
}
|
|
260
334
|
catch (parseError) {
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
type: "failed",
|
|
267
|
-
details: `remote invoke: unparseable event on the stream (${String(parseError)})`,
|
|
268
|
-
retryable: false,
|
|
269
|
-
};
|
|
270
|
-
}
|
|
335
|
+
yield {
|
|
336
|
+
type: "failed",
|
|
337
|
+
details: `remote invoke: unparseable event on the stream (${String(parseError)})`,
|
|
338
|
+
retryable: false,
|
|
339
|
+
};
|
|
271
340
|
return;
|
|
272
341
|
}
|
|
273
342
|
// Shape check, same discipline as the events plane: `data: null` / `data: 42` is
|
|
274
343
|
// valid JSON but protocol drift — it must not TypeError into the catch below and be
|
|
275
344
|
// misclassified as retryable network trouble.
|
|
276
345
|
if (typeof event !== "object" || event === null || typeof event.type !== "string") {
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
};
|
|
283
|
-
}
|
|
346
|
+
yield {
|
|
347
|
+
type: "failed",
|
|
348
|
+
details: "remote invoke: non-event data on the stream — protocol mismatch?",
|
|
349
|
+
retryable: false,
|
|
350
|
+
};
|
|
284
351
|
return;
|
|
285
352
|
}
|
|
286
|
-
|
|
287
|
-
terminalSeen = true;
|
|
353
|
+
terminalSeen = event.type === "completed" || event.type === "failed";
|
|
288
354
|
yield event;
|
|
355
|
+
if (terminalSeen)
|
|
356
|
+
return;
|
|
289
357
|
}
|
|
290
|
-
|
|
291
|
-
yield { type: "failed", details: "remote invoke: stream ended without a terminal", retryable: true };
|
|
292
|
-
}
|
|
358
|
+
yield { type: "failed", details: "remote invoke: stream ended without a terminal", retryable: true };
|
|
293
359
|
}
|
|
294
360
|
catch (error) {
|
|
295
361
|
if (abort.signal.aborted) {
|
|
@@ -307,6 +373,7 @@ export function connectAgent(options) {
|
|
|
307
373
|
}
|
|
308
374
|
finally {
|
|
309
375
|
watchdog.stop();
|
|
376
|
+
abort.abort();
|
|
310
377
|
}
|
|
311
378
|
})();
|
|
312
379
|
// ONE stream per invoke, like a local async generator (which is its own iterator): a second
|