@fastagent-sh/fastagent 0.14.0 → 0.16.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 +16 -6
- package/dist/agent.d.ts +20 -0
- package/dist/agent.js +9 -0
- package/dist/channels/agentcore-limits.d.ts +7 -0
- package/dist/channels/agentcore-limits.js +9 -0
- package/dist/channels/agentcore-state.d.ts +83 -0
- package/dist/channels/agentcore-state.js +258 -0
- package/dist/channels/agentcore.d.ts +98 -0
- package/dist/channels/agentcore.js +260 -0
- package/dist/channels/busy.d.ts +23 -0
- package/dist/channels/busy.js +53 -0
- package/dist/channels/context-buffer.d.ts +35 -0
- package/dist/channels/context-buffer.js +103 -0
- package/dist/channels/control.d.ts +28 -0
- package/dist/channels/control.js +214 -0
- package/dist/channels/feishu/card.js +1 -1
- package/dist/channels/feishu/context-buffer.d.ts +15 -13
- package/dist/channels/feishu/context-buffer.js +62 -69
- package/dist/channels/feishu/feishu-api.d.ts +11 -3
- package/dist/channels/feishu/feishu-api.js +24 -3
- package/dist/channels/feishu/feishu.d.ts +27 -27
- package/dist/channels/feishu/feishu.js +318 -207
- package/dist/channels/feishu/invoke-turn.d.ts +7 -20
- package/dist/channels/feishu/invoke-turn.js +44 -79
- package/dist/channels/feishu/model.d.ts +2 -2
- package/dist/channels/feishu/normalize.d.ts +2 -1
- package/dist/channels/feishu/normalize.js +0 -1
- package/dist/channels/feishu/parse.d.ts +22 -8
- package/dist/channels/feishu/parse.js +24 -7
- package/dist/channels/feishu/preview.d.ts +4 -7
- package/dist/channels/feishu/preview.js +27 -142
- package/dist/channels/feishu/register-app.d.ts +2 -1
- package/dist/channels/feishu/register-webhook.d.ts +2 -1
- package/dist/channels/feishu/scaffold/channel.ts +9 -8
- package/dist/channels/feishu/scaffold/feishu-send.ts +6 -4
- package/dist/channels/feishu/setup-mode.d.ts +37 -0
- package/dist/channels/feishu/setup-mode.js +28 -0
- package/dist/channels/feishu/ws-ingress.d.ts +28 -0
- package/dist/channels/feishu/ws-ingress.js +136 -0
- package/dist/channels/github/github.js +16 -7
- package/dist/channels/http.d.ts +14 -0
- package/dist/channels/http.js +36 -3
- package/dist/channels/invoke-turn-kit.d.ts +65 -0
- package/dist/channels/invoke-turn-kit.js +87 -0
- package/dist/channels/lark/lark.d.ts +4 -2
- package/dist/channels/lark/lark.js +4 -1
- package/dist/channels/lark/onboard.d.ts +8 -4
- package/dist/channels/lark/onboard.js +8 -0
- package/dist/channels/lark/scaffold/channel.ts +9 -8
- package/dist/channels/lark/scaffold/lark-send.ts +6 -4
- package/dist/channels/preview-kit.d.ts +115 -0
- package/dist/channels/preview-kit.js +184 -0
- package/dist/channels/seen.d.ts +5 -0
- package/dist/channels/seen.js +35 -0
- package/dist/channels/slack/bot-auth.d.ts +15 -0
- package/dist/channels/slack/bot-auth.js +146 -0
- package/dist/channels/slack/config-api.d.ts +60 -0
- package/dist/channels/slack/config-api.js +149 -0
- package/dist/channels/slack/context-buffer.d.ts +24 -0
- package/dist/channels/slack/context-buffer.js +37 -0
- package/dist/channels/slack/invoke-turn.d.ts +19 -0
- package/dist/channels/slack/invoke-turn.js +63 -0
- package/dist/channels/slack/manifest.d.ts +49 -0
- package/dist/channels/slack/manifest.js +69 -0
- package/dist/channels/slack/model.d.ts +67 -0
- package/dist/channels/slack/model.js +2 -0
- package/dist/channels/slack/onboard.d.ts +41 -0
- package/dist/channels/slack/onboard.js +120 -0
- package/dist/channels/slack/onboarding-state.d.ts +31 -0
- package/dist/channels/slack/onboarding-state.js +69 -0
- package/dist/channels/slack/parse.d.ts +38 -0
- package/dist/channels/slack/parse.js +124 -0
- package/dist/channels/slack/preview.d.ts +23 -0
- package/dist/channels/slack/preview.js +403 -0
- package/dist/channels/slack/reaction.d.ts +24 -0
- package/dist/channels/slack/reaction.js +62 -0
- package/dist/channels/slack/register-webhook.d.ts +10 -0
- package/dist/channels/slack/register-webhook.js +49 -0
- package/dist/channels/slack/scaffold/channel.ts +33 -0
- package/dist/channels/slack/scaffold/slack-send.ts +171 -0
- package/dist/channels/slack/setup-server.d.ts +17 -0
- package/dist/channels/slack/setup-server.js +103 -0
- package/dist/channels/slack/slack-api.d.ts +57 -0
- package/dist/channels/slack/slack-api.js +399 -0
- package/dist/channels/slack/slack.d.ts +51 -0
- package/dist/channels/slack/slack.js +496 -0
- package/dist/channels/slack/welcomed.d.ts +5 -0
- package/dist/channels/slack/welcomed.js +32 -0
- package/dist/channels/state.d.ts +11 -4
- package/dist/channels/state.js +22 -15
- package/dist/channels/stop-command.d.ts +6 -0
- package/dist/channels/stop-command.js +36 -0
- package/dist/channels/tasks.d.ts +7 -0
- package/dist/channels/tasks.js +25 -0
- package/dist/channels/telegram/context-buffer.d.ts +8 -17
- package/dist/channels/telegram/context-buffer.js +6 -85
- package/dist/channels/telegram/invoke-turn.d.ts +5 -22
- package/dist/channels/telegram/invoke-turn.js +11 -58
- package/dist/channels/telegram/parse.d.ts +0 -7
- package/dist/channels/telegram/parse.js +4 -2
- package/dist/channels/telegram/preview.d.ts +4 -7
- package/dist/channels/telegram/preview.js +24 -142
- package/dist/channels/telegram/scaffold/telegram-send.ts +6 -3
- package/dist/channels/telegram/telegram.js +24 -10
- package/dist/channels/{feishu/text.d.ts → text.d.ts} +15 -1
- package/dist/channels/{feishu/text.js → text.js} +15 -1
- package/dist/channels/thread-participants.d.ts +21 -0
- package/dist/channels/thread-participants.js +132 -0
- package/dist/channels/turn-queue.js +8 -1
- package/dist/channels/turn-store.d.ts +1 -1
- package/dist/channels/turn-store.js +2 -3
- package/dist/cli/add-feishu.d.ts +30 -0
- package/dist/cli/add-feishu.js +366 -0
- package/dist/cli/add-slack.d.ts +11 -0
- package/dist/cli/add-slack.js +199 -0
- package/dist/{cli-auth.d.ts → cli/auth-view.d.ts} +1 -1
- package/dist/{cli-auth.js → cli/auth-view.js} +1 -1
- package/dist/cli/commands/add.d.ts +4 -0
- package/dist/cli/commands/add.js +191 -60
- package/dist/cli/commands/attach.d.ts +82 -0
- package/dist/cli/commands/attach.js +563 -0
- package/dist/cli/commands/chat.d.ts +1 -0
- package/dist/cli/commands/chat.js +12 -7
- package/dist/cli/commands/deploy.d.ts +3 -1
- package/dist/cli/commands/deploy.js +384 -82
- package/dist/cli/commands/dev.js +34 -26
- package/dist/cli/commands/fire.js +17 -18
- package/dist/cli/commands/info.js +36 -29
- package/dist/cli/commands/init.d.ts +1 -1
- package/dist/cli/commands/init.js +65 -53
- package/dist/cli/commands/invoke.js +11 -9
- package/dist/cli/commands/login.js +48 -45
- package/dist/cli/commands/models.js +1 -1
- package/dist/cli/commands/schedule.js +6 -8
- package/dist/cli/commands/start.js +102 -39
- package/dist/cli/commands/tool.js +23 -18
- package/dist/cli/fail.d.ts +17 -2
- package/dist/cli/fail.js +25 -1
- package/dist/{invoke-stream.d.ts → cli/invoke-stream.d.ts} +1 -1
- package/dist/{invoke-stream.js → cli/invoke-stream.js} +4 -0
- package/dist/cli/kernel.d.ts +3 -2
- package/dist/cli/models-view.d.ts +21 -0
- package/dist/cli/models-view.js +66 -0
- package/dist/cli/program.js +135 -56
- package/dist/cli/serve.d.ts +60 -20
- package/dist/cli/serve.js +252 -47
- package/dist/cli/shared.d.ts +40 -7
- package/dist/cli/shared.js +180 -38
- package/dist/collect.d.ts +14 -3
- package/dist/collect.js +24 -0
- package/dist/core.d.ts +3 -1
- package/dist/core.js +2 -0
- package/dist/deploy/agentcore/plan.d.ts +117 -0
- package/dist/deploy/agentcore/plan.js +721 -0
- package/dist/deploy/agentcore/run.d.ts +73 -0
- package/dist/deploy/agentcore/run.js +412 -0
- package/dist/deploy/agentcore/zip.d.ts +17 -0
- package/dist/deploy/agentcore/zip.js +68 -0
- package/dist/deploy/container.d.ts +26 -25
- package/dist/deploy/container.js +93 -89
- package/dist/deploy/docker/plan.d.ts +3 -3
- package/dist/deploy/docker/plan.js +20 -23
- package/dist/deploy/fly/plan.d.ts +5 -1
- package/dist/deploy/fly/plan.js +39 -26
- package/dist/deploy/fly/run.d.ts +17 -5
- package/dist/deploy/fly/run.js +52 -7
- package/dist/deploy/preflight.d.ts +20 -8
- package/dist/deploy/preflight.js +267 -71
- package/dist/deploy/railway/plan.d.ts +10 -1
- package/dist/deploy/railway/plan.js +53 -20
- package/dist/deploy/railway/run.d.ts +13 -5
- package/dist/deploy/railway/run.js +22 -7
- package/dist/deploy/runner.d.ts +7 -3
- package/dist/deploy/runner.js +9 -3
- package/dist/deploy/secrets.d.ts +2 -1
- package/dist/deploy/secrets.js +23 -3
- package/dist/dev-supervisor.d.ts +11 -10
- package/dist/dev-supervisor.js +56 -50
- package/dist/engines/pi/auth.d.ts +8 -7
- package/dist/engines/pi/auth.js +12 -10
- package/dist/engines/pi/channel.d.ts +22 -16
- package/dist/engines/pi/channel.js +92 -62
- package/dist/engines/pi/chat.d.ts +4 -16
- package/dist/engines/pi/chat.js +8 -261
- package/dist/engines/pi/config.d.ts +20 -55
- package/dist/engines/pi/config.js +49 -121
- package/dist/engines/pi/create.d.ts +30 -10
- package/dist/engines/pi/create.js +33 -26
- package/dist/engines/pi/definition.d.ts +7 -26
- package/dist/engines/pi/definition.js +8 -54
- package/dist/engines/pi/harness.d.ts +49 -0
- package/dist/engines/pi/harness.js +90 -2
- package/dist/engines/pi/invoke.d.ts +55 -2
- package/dist/engines/pi/invoke.js +304 -19
- package/dist/engines/pi/login.d.ts +12 -1
- package/dist/engines/pi/login.js +16 -4
- package/dist/engines/pi/models.d.ts +59 -13
- package/dist/engines/pi/models.js +62 -24
- package/dist/engines/pi/open.d.ts +114 -0
- package/dist/engines/pi/open.js +127 -0
- package/dist/engines/pi/session-builder.d.ts +16 -0
- package/dist/engines/pi/session-builder.js +308 -0
- package/dist/engines/pi/session-control.d.ts +50 -0
- package/dist/engines/pi/session-control.js +604 -0
- package/dist/engines/pi/sessions.d.ts +17 -2
- package/dist/engines/pi/sessions.js +9 -0
- package/dist/engines/pi/tool-context.d.ts +18 -11
- package/dist/engines/pi/tool-context.js +3 -9
- package/dist/engines/pi/tool.d.ts +6 -8
- package/dist/engines/pi/tool.js +10 -1
- package/dist/engines/pi/wake-tool.d.ts +0 -3
- package/dist/engines/pi/wake-tool.js +9 -7
- package/dist/env.d.ts +16 -4
- package/dist/env.js +43 -5
- package/dist/feishu.d.ts +1 -1
- package/dist/feishu.js +1 -1
- package/dist/host/node.d.ts +25 -8
- package/dist/host/node.js +5 -4
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/lark.d.ts +1 -1
- package/dist/lark.js +1 -1
- package/dist/loader.d.ts +2 -2
- package/dist/loader.js +3 -3
- package/dist/log.d.ts +1 -1
- package/dist/log.js +1 -1
- package/dist/observe.js +3 -0
- package/dist/paths.d.ts +138 -0
- package/dist/paths.js +326 -0
- package/dist/pi.d.ts +6 -5
- package/dist/pi.js +4 -3
- package/dist/runtime.d.ts +7 -5
- package/dist/runtime.js +2 -2
- package/dist/scaffold/add-channel.d.ts +26 -9
- package/dist/scaffold/add-channel.js +175 -40
- package/dist/scaffold/init.d.ts +32 -41
- package/dist/scaffold/init.js +161 -185
- package/dist/scaffold/templates/env.example +15 -6
- package/dist/scaffold/templates/fastagent.config.mjs +5 -4
- package/dist/scaffold/templates/gitignore +14 -6
- package/dist/scaffold/templates/persona.md +4 -2
- package/dist/scaffold/templates/secrets.gitignore +5 -0
- package/dist/scaffold/templates.d.ts +1 -7
- package/dist/scaffold/templates.js +3 -25
- package/dist/scaffold/vendor-skill.d.ts +2 -2
- package/dist/scaffold/vendor-skill.js +13 -13
- package/dist/schedule/discover.js +4 -4
- package/dist/schedule/scheduler.d.ts +40 -1
- package/dist/schedule/scheduler.js +89 -56
- package/dist/schedule/state.js +1 -1
- package/dist/schedule/wake-alarm.d.ts +47 -0
- package/dist/schedule/wake-alarm.js +136 -0
- package/dist/schedule/wakeups.d.ts +1 -3
- package/dist/schedule/wakeups.js +19 -1
- package/dist/session-remote.d.ts +53 -0
- package/dist/session-remote.js +336 -0
- package/dist/session.d.ts +265 -0
- package/dist/session.js +37 -0
- package/dist/slack.d.ts +2 -0
- package/dist/slack.js +2 -0
- package/dist/tunnel.d.ts +7 -4
- package/dist/tunnel.js +23 -12
- package/package.json +22 -7
- package/dist/channels/feishu/owned-threads.d.ts +0 -11
- package/dist/channels/feishu/owned-threads.js +0 -47
- package/dist/channels/feishu/seen.d.ts +0 -5
- package/dist/channels/feishu/seen.js +0 -47
- package/dist/cli-add-feishu.d.ts +0 -8
- package/dist/cli-add-feishu.js +0 -223
- package/dist/cli-models.d.ts +0 -11
- package/dist/cli-models.js +0 -20
- package/dist/engines/pi/workspace.d.ts +0 -56
- package/dist/engines/pi/workspace.js +0 -72
- package/dist/scaffold/templates/gitignore.agentdir-root +0 -5
- package/dist/scaffold/templates/gitignore.kit +0 -2
- package/dist/workspace.d.ts +0 -9
- package/dist/workspace.js +0 -45
|
@@ -0,0 +1,563 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `fastagent attach <session> [dir]`: watch a session's live events from a running serve
|
|
3
|
+
* (`config.sessionControl: true` → dev/start write `<stateRoot>/control.json`) and intervene from
|
|
4
|
+
* stdin — the pair-programming loop of the session control plane, over the SAME wire protocol a Web
|
|
5
|
+
* panel or desktop app uses (`connectSessionControl`).
|
|
6
|
+
*
|
|
7
|
+
* Typed lines dispatch as `steer` while a run is active; `/abort` aborts it. Reconnect-on-drop is
|
|
8
|
+
* the standard client loop, one round per connection: resubscribe → backfill via `entries({ since })`
|
|
9
|
+
* → re-check `state()` (rendered — status changes that happened while away are live-only events and
|
|
10
|
+
* would otherwise be invisible) → drain live.
|
|
11
|
+
*/
|
|
12
|
+
import { readFileSync } from "node:fs";
|
|
13
|
+
import { join, resolve } from "node:path";
|
|
14
|
+
import { createInterface } from "node:readline";
|
|
15
|
+
import { loadDotEnv } from "../../env.js";
|
|
16
|
+
import { resolveStateRoot } from "../../paths.js";
|
|
17
|
+
import { log, setLogLevel } from "../../log.js";
|
|
18
|
+
import { ABORTED_CODE, SESSION_BUSY_CODE } from "../../agent.js";
|
|
19
|
+
import { NO_ACTIVE_RUN_CODE } from "../../session.js";
|
|
20
|
+
import { ControlRequestError, connectAgent, connectSessionControl } from "../../session-remote.js";
|
|
21
|
+
import { failStartup, placementOrExit } from "../fail.js";
|
|
22
|
+
/** Read the serving process's local discovery file. */
|
|
23
|
+
function discover(dir) {
|
|
24
|
+
const path = join(resolveStateRoot(dir), "control.json");
|
|
25
|
+
try {
|
|
26
|
+
// Parse-don't-validate: the file is external input (hand-edited, older format, partial write).
|
|
27
|
+
// A missing token would otherwise become `Bearer undefined` → 401 → a misleading diagnosis.
|
|
28
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
29
|
+
if (typeof parsed.url !== "string" || typeof parsed.token !== "string") {
|
|
30
|
+
throw new Error("missing url/token fields");
|
|
31
|
+
}
|
|
32
|
+
return { url: parsed.url, token: parsed.token };
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
throw new Error(`cannot read ${path} (${error.message}) — is a serve with "sessionControl: true" running here? ` +
|
|
36
|
+
`Or pass --url/--token for a remote one.`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/** One-line rendering per event — a tail, not a TUI. */
|
|
40
|
+
function render(event) {
|
|
41
|
+
const d = event.data;
|
|
42
|
+
switch (event.type) {
|
|
43
|
+
case "run_started":
|
|
44
|
+
return `── run ${event.runId} started ──`;
|
|
45
|
+
case "run_settled":
|
|
46
|
+
return `── run settled: ${String(d.status)}${d.error ? ` (${d.error.message})` : ""} ──`;
|
|
47
|
+
case "message_delta":
|
|
48
|
+
return undefined; // streamed raw below, not line-rendered
|
|
49
|
+
case "message_started":
|
|
50
|
+
case "message_finished":
|
|
51
|
+
return undefined;
|
|
52
|
+
case "tool_started":
|
|
53
|
+
return `[tool ${String(d.name)} started]`;
|
|
54
|
+
case "tool_finished":
|
|
55
|
+
return `[tool ${d.isError ? "FAILED" : "done"}]`;
|
|
56
|
+
case "queue_changed":
|
|
57
|
+
return `[queue: steering ${String(d.steering)}, follow-up ${String(d.followUp)}]`;
|
|
58
|
+
case "state_changed":
|
|
59
|
+
return `[state: ${JSON.stringify(d)}]`;
|
|
60
|
+
case "compaction_started":
|
|
61
|
+
return "[compaction started]";
|
|
62
|
+
case "compaction_finished":
|
|
63
|
+
// aborted is a deliberate stop (this attach's /abort or another client's) — not a failure.
|
|
64
|
+
return `[compaction ${d.aborted ? "aborted" : d.error ? `FAILED: ${String(d.error)}` : "done"}]`;
|
|
65
|
+
default:
|
|
66
|
+
return `[${event.type}]`;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
async function drainEvents(iterator, io) {
|
|
70
|
+
// Only close a line we actually opened: message_finished fires for EVERY assistant message
|
|
71
|
+
// (pure tool-call and pure thinking ones included), and an unconditional newline would dilute a
|
|
72
|
+
// multi-tool run's output with blank lines.
|
|
73
|
+
let wroteText = false;
|
|
74
|
+
let consumed = 0;
|
|
75
|
+
for (;;) {
|
|
76
|
+
const result = await iterator.next();
|
|
77
|
+
if (result.done)
|
|
78
|
+
return consumed;
|
|
79
|
+
consumed++;
|
|
80
|
+
const event = result.value;
|
|
81
|
+
if (event.type === "message_delta") {
|
|
82
|
+
const d = event.data;
|
|
83
|
+
if (d.channel === "text") {
|
|
84
|
+
io.write(d.delta);
|
|
85
|
+
wroteText = true;
|
|
86
|
+
}
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
if (event.type === "message_finished") {
|
|
90
|
+
if (wroteText)
|
|
91
|
+
io.write("\n");
|
|
92
|
+
wroteText = false;
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
// A remote (or version-skewed) serve may send data shapes this renderer does not expect — a
|
|
96
|
+
// rendering surprise degrades to the generic line, never breaks the watch loop.
|
|
97
|
+
let line;
|
|
98
|
+
try {
|
|
99
|
+
line = render(event);
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
line = `[${event.type}]`;
|
|
103
|
+
}
|
|
104
|
+
if (line !== undefined)
|
|
105
|
+
io.println(line);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
export async function runAttach(sessionArg, dirArg, opts) {
|
|
109
|
+
setLogLevel("info");
|
|
110
|
+
// --url and --token travel together, and BOTH must be non-empty: a lone --url (or an empty
|
|
111
|
+
// token) silently falling back to the LOCAL control.json would attach (and steer!) a same-named
|
|
112
|
+
// local session while the user believes they are remote. One predicate decides — the guard and
|
|
113
|
+
// the endpoint selection must never disagree on what "given" means.
|
|
114
|
+
const remote = opts.url !== undefined || opts.token !== undefined;
|
|
115
|
+
if (remote && !(opts.url && opts.token)) {
|
|
116
|
+
failStartup(new Error("--url and --token must be given together and non-empty"));
|
|
117
|
+
}
|
|
118
|
+
// A REMOTE attach reads nothing under `dir` — no control.json to discover, no agent to assemble —
|
|
119
|
+
// so requiring one would refuse the command's whole point (a laptop attaching to a deployed box).
|
|
120
|
+
// Placement is resolved only for the local-discovery path, which genuinely needs an agent.
|
|
121
|
+
const dir = remote ? resolve(dirArg ?? ".") : placementOrExit(resolve(dirArg ?? ".")).agentDir;
|
|
122
|
+
if (!remote)
|
|
123
|
+
loadDotEnv(dir);
|
|
124
|
+
// For a discovered endpoint the FIRST read joins the startup budget below: the dev-watch
|
|
125
|
+
// restart window has two halves — control.json unlinked (not yet rewritten) and port not yet
|
|
126
|
+
// bound — and dying instantly on the first half would contradict the grace the second half gets.
|
|
127
|
+
let endpoint;
|
|
128
|
+
if (remote)
|
|
129
|
+
endpoint = { url: opts.url, token: opts.token };
|
|
130
|
+
const discovered = !remote;
|
|
131
|
+
// ONE policy for both phases: startup and the round loop gather the same facts (errorFacts)
|
|
132
|
+
// and route them through decideRound with their phase — the local-401-unchanged diagnosis, the
|
|
133
|
+
// reattach-on-changed-credentials rule, and every budget claim exist exactly once, tested.
|
|
134
|
+
const errorFacts = (error) => {
|
|
135
|
+
let discovery = "unavailable";
|
|
136
|
+
let fresh;
|
|
137
|
+
if (discovered && endpoint) {
|
|
138
|
+
try {
|
|
139
|
+
const read = discover(dir);
|
|
140
|
+
if (read.url === endpoint.url && read.token === endpoint.token)
|
|
141
|
+
discovery = "unchanged";
|
|
142
|
+
else {
|
|
143
|
+
discovery = "changed";
|
|
144
|
+
fresh = read;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
// Absent or torn — possibly mid-restart; budgets decide, never this read alone.
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return { type: "error", error, isAuth: isAuthError(error), discovery, fresh };
|
|
152
|
+
};
|
|
153
|
+
const connectWithGrace = async () => {
|
|
154
|
+
const startedAt = Date.now();
|
|
155
|
+
for (;;) {
|
|
156
|
+
try {
|
|
157
|
+
if (!endpoint)
|
|
158
|
+
endpoint = discover(dir); // discovered: the first read shares the budget
|
|
159
|
+
const connected = await connectSessionControl(endpoint);
|
|
160
|
+
return { control: connected, state: await connected.state(sessionArg) };
|
|
161
|
+
}
|
|
162
|
+
catch (error) {
|
|
163
|
+
const decision = decideRound(errorFacts(error), {
|
|
164
|
+
discovered,
|
|
165
|
+
downMs: Date.now() - startedAt,
|
|
166
|
+
phase: "startup",
|
|
167
|
+
});
|
|
168
|
+
if (decision.kind === "exit")
|
|
169
|
+
exitWith(new Error(decision.message));
|
|
170
|
+
// try-reattach at startup = adopt the fresh credentials; the next loop iteration connects.
|
|
171
|
+
if (decision.kind === "try-reattach")
|
|
172
|
+
endpoint = decision.fresh;
|
|
173
|
+
else if (decision.kind === "retry")
|
|
174
|
+
log.warn(decision.warn);
|
|
175
|
+
await new Promise((r) => setTimeout(r, 1_000));
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
const startup = await connectWithGrace();
|
|
180
|
+
let control = startup.control;
|
|
181
|
+
const state = startup.state;
|
|
182
|
+
log.info(`[fastagent] attached to ${sessionArg} @ ${endpoint.url} — ${state.status}`);
|
|
183
|
+
if (state.leafEntryId === undefined && state.status === "idle") {
|
|
184
|
+
// A typo'd id and a fresh session render identically otherwise (sessions are lazily created by
|
|
185
|
+
// invoke) — give the human a corrective signal.
|
|
186
|
+
log.warn(`[fastagent] no durable record for "${sessionArg}" yet — a new session, or a typo?`);
|
|
187
|
+
}
|
|
188
|
+
log.info(`[fastagent] type to steer the active run; /abort to stop it; Ctrl+C to detach`);
|
|
189
|
+
// stdin → the two planes: a line steers the ACTIVE run; with no run to join (no_active_run) it
|
|
190
|
+
// falls back to STARTING one over the remote data plane (`POST /control/invoke`) — try-steer-
|
|
191
|
+
// then-prompt avoids a state() pre-check race. Acceptance is not outcome: rejections print and
|
|
192
|
+
// move on. The invoke stream is drained silently (its content already renders via events); it
|
|
193
|
+
// must be held open, though — disconnecting it cancels the run.
|
|
194
|
+
let remoteAgent = connectAgent(endpoint);
|
|
195
|
+
const startRun = async (text) => {
|
|
196
|
+
// Drained quietly EXCEPT failures: a run that never started (transport 401/refused/404, wire
|
|
197
|
+
// errors) surfaces only on this stream — the events plane has nothing to render. A run that
|
|
198
|
+
// started and failed prints twice (here + run_settled) — the replay-overlap precedent: label,
|
|
199
|
+
// never silently drop. connectAgent never throws (SPEC), so failures ARE these events.
|
|
200
|
+
let sawBusy = false;
|
|
201
|
+
for await (const e of remoteAgent.invoke({ session: sessionArg }, { text })) {
|
|
202
|
+
if (e.type !== "failed")
|
|
203
|
+
continue;
|
|
204
|
+
if (e.code === SESSION_BUSY_CODE)
|
|
205
|
+
sawBusy = true;
|
|
206
|
+
else if (e.code !== ABORTED_CODE)
|
|
207
|
+
console.log(`[prompt failed: ${e.details}]`);
|
|
208
|
+
// ABORTED_CODE: a deliberate stop from ANY client (this attach's /abort, another attach, a
|
|
209
|
+
// Web panel) — the settled line reports it either way; no source discrimination here.
|
|
210
|
+
}
|
|
211
|
+
// session_busy = the LEASE is held — by another run OR a boundary mutation (compact/set_model
|
|
212
|
+
// contend on the same lease); "a run started" would be a guess, and "steer it" bad advice
|
|
213
|
+
// against a compact. State the lease fact, promise nothing.
|
|
214
|
+
if (sawBusy)
|
|
215
|
+
console.log("[session busy — another run or a boundary operation holds it; try again shortly]");
|
|
216
|
+
};
|
|
217
|
+
const rl = createInterface({ input: process.stdin });
|
|
218
|
+
rl.on("line", (line) => {
|
|
219
|
+
const trimmed = line.trim();
|
|
220
|
+
if (trimmed === "")
|
|
221
|
+
return;
|
|
222
|
+
// `/` is a reserved command prefix: a typo'd /aboort silently steering the model (injecting a
|
|
223
|
+
// prompt when the user meant to STOP the run) is the dangerous direction of the ambiguity.
|
|
224
|
+
if (trimmed.startsWith("/") && trimmed !== "/abort") {
|
|
225
|
+
console.log(`[unknown command ${trimmed} — /abort stops the run; a leading / is reserved]`);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
const command = trimmed === "/abort" ? { type: "abort" } : { type: "steer", prompt: { text: trimmed } };
|
|
229
|
+
void control.dispatch(sessionArg, command).then((result) => {
|
|
230
|
+
if (result.ok) {
|
|
231
|
+
console.log(`[${command.type} accepted]`);
|
|
232
|
+
}
|
|
233
|
+
else if (command.type === "steer" && result.error.code === NO_ACTIVE_RUN_CODE) {
|
|
234
|
+
// The invoke stream attach holds IS this run's driver (design: disconnect = cancel), so
|
|
235
|
+
// unlike channel-started runs, this one dies with the attach. Say so up front.
|
|
236
|
+
// Intent, not fact: admission may still fail (session_busy — the lease could be held
|
|
237
|
+
// by a compaction or another client's run); startRun's own lines report the outcome,
|
|
238
|
+
// and promising "detaching will cancel it" for a run that never starts would be a lie.
|
|
239
|
+
console.log("[no active run — trying to start one; if it starts, detaching (Ctrl+C) cancels it]");
|
|
240
|
+
void startRun(trimmed).catch((error) => console.log(`[prompt failed: ${String(error)}]`));
|
|
241
|
+
}
|
|
242
|
+
else {
|
|
243
|
+
console.log(`[${command.type} rejected: ${result.error.code} — ${result.error.message}]`);
|
|
244
|
+
}
|
|
245
|
+
}, (error) => console.log(`[${command.type} failed: ${String(error)}]`));
|
|
246
|
+
});
|
|
247
|
+
// Every round has ONE shape: subscribe → backfill (render the durable record since the cursor)
|
|
248
|
+
// → drain live until the stream drops. Subscribing first + the server's eager registration
|
|
249
|
+
// (subscribed before response headers) covers the cursor→subscription window in the normal case;
|
|
250
|
+
// the 300ms wait is a HEURISTIC — on a very slow link an event can still land between the
|
|
251
|
+
// backfill and the subscription taking effect, surfacing only at the next drop-triggered replay.
|
|
252
|
+
// Live events carry no entry ids, so the cursor advances only on backfill; a replay may overlap
|
|
253
|
+
// what was already seen live — labeled, not silently dropped or miscounted. Ctrl+C is the only
|
|
254
|
+
// exit; failure dispositions (401, budgets, reattach) live in decideRound.
|
|
255
|
+
// Initial cursor: `state.leafEntryId` is the ACTIVE-PATH leaf, not an append-order position —
|
|
256
|
+
// an approximation of "now" that avoids downloading the whole history just to find the tail. In
|
|
257
|
+
// a branched record (compaction leaves abandoned branches) the first replay may include a few
|
|
258
|
+
// post-leaf appends; advancement below is append-order, so it does not repeat.
|
|
259
|
+
let cursor = state.leafEntryId;
|
|
260
|
+
// LIVENESS IS PROBED, NEVER INFERRED FROM THE FILE: control.json is advisory — briefly absent
|
|
261
|
+
// during a dev-watch restart (unlink → new worker rewrites seconds later) and stale after a
|
|
262
|
+
// crash (no handler ran) — so its presence is neither necessary nor sufficient. The one state
|
|
263
|
+
// machine for local endpoints: every failed round re-reads discovery (a CHANGED file → reattach
|
|
264
|
+
// with the fresh credentials — 401s from an old token against a restarted serve heal here too);
|
|
265
|
+
// otherwise a consecutive-failure budget decides — reset by any successful round or reattach,
|
|
266
|
+
// exhausted → exit with the honest ambiguous diagnosis. --url endpoints keep plain retries with
|
|
267
|
+
// immediate 401 exit: their token lifecycle is the operator's, not a local boot's.
|
|
268
|
+
let failingSince;
|
|
269
|
+
for (;;) {
|
|
270
|
+
// Gather this round's FACTS (IO), then let decideRound (pure, tested) pick the disposition.
|
|
271
|
+
let outcome;
|
|
272
|
+
try {
|
|
273
|
+
const round = await attachRound(control, sessionArg, cursor, {
|
|
274
|
+
println: (line) => console.log(line),
|
|
275
|
+
write: (chunk) => process.stdout.write(chunk),
|
|
276
|
+
warn: (line) => log.warn(line),
|
|
277
|
+
});
|
|
278
|
+
cursor = round.cursor;
|
|
279
|
+
outcome = round.sawProgress ? { type: "progress" } : { type: "empty" };
|
|
280
|
+
}
|
|
281
|
+
catch (error) {
|
|
282
|
+
// A failed round may still have advanced the cursor (backfill rendered before the stream
|
|
283
|
+
// error) — keep the progress or every retry replays the same records in full.
|
|
284
|
+
const advanced = roundCursor(error);
|
|
285
|
+
if (advanced !== undefined)
|
|
286
|
+
cursor = advanced;
|
|
287
|
+
outcome = errorFacts(error);
|
|
288
|
+
}
|
|
289
|
+
if (outcome.type !== "progress")
|
|
290
|
+
failingSince ??= Date.now();
|
|
291
|
+
const decision = decideRound(outcome, {
|
|
292
|
+
discovered,
|
|
293
|
+
downMs: failingSince === undefined ? 0 : Date.now() - failingSince,
|
|
294
|
+
});
|
|
295
|
+
switch (decision.kind) {
|
|
296
|
+
case "reset":
|
|
297
|
+
failingSince = undefined;
|
|
298
|
+
break; // healthy round; still pause below before resubscribing
|
|
299
|
+
case "exit":
|
|
300
|
+
exitWith(new Error(decision.message));
|
|
301
|
+
break;
|
|
302
|
+
case "try-reattach": {
|
|
303
|
+
// The decision says the credentials changed (a restarted serve mints fresh ones); whether
|
|
304
|
+
// the new endpoint is READY yet is an IO fact only the connect can tell.
|
|
305
|
+
const fresh = decision.fresh;
|
|
306
|
+
try {
|
|
307
|
+
const next = await connectSessionControl(fresh);
|
|
308
|
+
endpoint = fresh;
|
|
309
|
+
control = next;
|
|
310
|
+
remoteAgent = connectAgent(fresh);
|
|
311
|
+
failingSince = undefined;
|
|
312
|
+
console.log("[serve restarted — reattached]");
|
|
313
|
+
continue; // straight into the next round, no pause
|
|
314
|
+
}
|
|
315
|
+
catch (reconnectError) {
|
|
316
|
+
// Mid-restart (file written, port not bound yet): the budget keeps us patient.
|
|
317
|
+
log.warn(`[fastagent] serve restarting? reattach not ready: ${String(reconnectError)}`);
|
|
318
|
+
}
|
|
319
|
+
break;
|
|
320
|
+
}
|
|
321
|
+
case "retry":
|
|
322
|
+
log.warn(decision.warn);
|
|
323
|
+
break;
|
|
324
|
+
}
|
|
325
|
+
await new Promise((r) => setTimeout(r, 1_000)); // the stream dropped — pause, then resubscribe
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
const LOCAL_GRACE_MS = 30_000;
|
|
329
|
+
// Remote endpoints get a LARGER budget (real networks recover slowly), but not an infinite one:
|
|
330
|
+
// steady-state and startup differ on priors, not on principle — at startup nothing has ever
|
|
331
|
+
// succeeded (a wrong --url is likelier than a transient → fail fast), while a drop after a
|
|
332
|
+
// working attach is likelier transient → retry, bounded. Wall-clock like STARTUP_GRACE_MS: a
|
|
333
|
+
// round's duration varies by an order of magnitude (fast ECONNREFUSED ≈ 1s vs the 10s black-hole
|
|
334
|
+
// timeout), so counting rounds would make the "~Ns" claims false exactly when they matter.
|
|
335
|
+
// Startup patience covers the dev-watch restart window's two halves — control.json unlinked (not
|
|
336
|
+
// yet rewritten) and port not yet bound — with discovery re-read each retry.
|
|
337
|
+
const REMOTE_GRACE_MS = 120_000;
|
|
338
|
+
const STARTUP_GRACE_MS = 15_000;
|
|
339
|
+
/**
|
|
340
|
+
* The reconnect policy for BOTH phases (`startup` = connectWithGrace, `steady` = the round loop),
|
|
341
|
+
* pure and testable: every exit diagnosis and budget claim lives here. `downMs` is wall-clock time
|
|
342
|
+
* since the phase's anchor (first connect attempt / first non-progress round). The phases differ
|
|
343
|
+
* on priors, not principle: startup has never succeeded (short budget; a remote non-auth error
|
|
344
|
+
* exits at once), steady-state had a working attach (longer budgets); the local-401-unchanged
|
|
345
|
+
* diagnosis and reattach-on-changed-credentials apply identically to both.
|
|
346
|
+
*/
|
|
347
|
+
export function decideRound(outcome, ctx) {
|
|
348
|
+
const startup = ctx.phase === "startup";
|
|
349
|
+
const limitMs = startup ? STARTUP_GRACE_MS : ctx.discovered ? LOCAL_GRACE_MS : REMOTE_GRACE_MS;
|
|
350
|
+
const downSeconds = Math.round(ctx.downMs / 1000);
|
|
351
|
+
if (outcome.type === "progress")
|
|
352
|
+
return { kind: "reset" };
|
|
353
|
+
if (outcome.type === "empty") {
|
|
354
|
+
// Not health: an endpoint answering 200 and closing every stream immediately (buffering
|
|
355
|
+
// proxy, half-dead tunnel) would otherwise loop forever with the budget never ticking.
|
|
356
|
+
if (ctx.downMs >= limitMs) {
|
|
357
|
+
return {
|
|
358
|
+
kind: "exit",
|
|
359
|
+
message: `the endpoint keeps closing the event stream immediately with nothing delivered (~${downSeconds}s) — a buffering proxy or half-dead tunnel? Re-run attach when the path is fixed`,
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
return {
|
|
363
|
+
kind: "retry",
|
|
364
|
+
warn: `[fastagent] stream closed with nothing delivered (~${downSeconds}s / ${limitMs / 1000}s limit)`,
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
if (!ctx.discovered) {
|
|
368
|
+
// --url mode: re-running with the SAME token would just 401 again — name the real remedy
|
|
369
|
+
// (at startup the shorter "check --token": the token came from the command line seconds ago).
|
|
370
|
+
if (outcome.isAuth) {
|
|
371
|
+
return {
|
|
372
|
+
kind: "exit",
|
|
373
|
+
message: startup
|
|
374
|
+
? `the control endpoint rejected the token (${String(outcome.error)}) — check --token`
|
|
375
|
+
: `the control endpoint rejected the token (${String(outcome.error)}) — obtain the current token from the serve (its <stateRoot>/control.json) and re-run with --token`,
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
// Startup: nothing has ever succeeded on this endpoint — a wrong --url is likelier than a
|
|
379
|
+
// transient, so fail fast instead of burning a budget.
|
|
380
|
+
if (startup) {
|
|
381
|
+
return { kind: "exit", message: outcome.error instanceof Error ? outcome.error.message : String(outcome.error) };
|
|
382
|
+
}
|
|
383
|
+
if (ctx.downMs >= limitMs) {
|
|
384
|
+
return {
|
|
385
|
+
kind: "exit",
|
|
386
|
+
message: `the remote endpoint has been unreachable for ~${downSeconds}s — check the serve and re-run attach`,
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
return {
|
|
390
|
+
kind: "retry",
|
|
391
|
+
warn: `[fastagent] round failed (down ~${downSeconds}s, limit ${limitMs / 1000}s): ${String(outcome.error)}`,
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
// Local: changed credentials mean a restarted serve — reattach BEFORE any 401 verdict (a fresh
|
|
395
|
+
// boot mints a fresh token, so this round's 401 may already be stale).
|
|
396
|
+
if (outcome.discovery === "changed" && outcome.fresh)
|
|
397
|
+
return { kind: "try-reattach", fresh: outcome.fresh };
|
|
398
|
+
// 401 with UNCHANGED control.json is reachable-and-rejecting — the file may belong to another
|
|
399
|
+
// (or dead) serve on this port — and must exit with that fact, not burn budget toward "unreachable".
|
|
400
|
+
if (outcome.isAuth && outcome.discovery === "unchanged") {
|
|
401
|
+
return {
|
|
402
|
+
kind: "exit",
|
|
403
|
+
message: "the endpoint rejected the token though control.json is unchanged — the file may belong to " +
|
|
404
|
+
"another (or dead) serve on this port; restart the serve and re-run attach",
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
if (ctx.downMs >= limitMs) {
|
|
408
|
+
return {
|
|
409
|
+
kind: "exit",
|
|
410
|
+
message: startup
|
|
411
|
+
? `${String(outcome.error)} — the serve is unreachable; it may be down, not yet started, or <stateRoot>/control.json is stale. Start (or restart) the serve and re-run attach.`
|
|
412
|
+
: `the serve has been unreachable for ~${downSeconds}s — it may have crashed (stale control.json) or shut down; restart it and re-run attach`,
|
|
413
|
+
};
|
|
414
|
+
}
|
|
415
|
+
return {
|
|
416
|
+
kind: "retry",
|
|
417
|
+
warn: startup
|
|
418
|
+
? `[fastagent] serve not ready (~${downSeconds}s) — retrying…`
|
|
419
|
+
: `[fastagent] round failed (down ~${downSeconds}s, limit ${limitMs / 1000}s): ${String(outcome.error)}`,
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
const isAuthError = (error) => error instanceof ControlRequestError && error.status === 401;
|
|
423
|
+
/** `failStartup` borrowed for its print-one-line-and-exit behavior — attach can fail long after
|
|
424
|
+
* startup (a serve restart hours in), so the local name must not imply "startup only". */
|
|
425
|
+
const exitWith = failStartup;
|
|
426
|
+
/**
|
|
427
|
+
* ONE attach round: subscribe → backfill (render the durable record since `cursor`) → drain live
|
|
428
|
+
* until the stream drops. Returns the advanced cursor. Subscribing first + the server's eager
|
|
429
|
+
* registration (subscribed before response headers) covers the cursor→subscription window in the
|
|
430
|
+
* normal case; `settleMs` is a HEURISTIC — on a very slow link an event can still land between the
|
|
431
|
+
* backfill and the subscription taking effect, surfacing only at the next round's replay. Live
|
|
432
|
+
* events carry no entry ids, so the cursor advances only on backfill; a replay may overlap what
|
|
433
|
+
* was already seen live — labeled, not silently dropped. A 401 (stale token after a serve restart)
|
|
434
|
+
* is thrown to the caller: unrecoverable here, never retried silently.
|
|
435
|
+
*/
|
|
436
|
+
export async function attachRound(control, session, cursor, io,
|
|
437
|
+
/** The subscribe→sync settle heuristic (see the round comment). Tests shrink it. */
|
|
438
|
+
settleMs = 300) {
|
|
439
|
+
// The round HOLDS its subscription's iterator: one round = one stream, on every path — a
|
|
440
|
+
// backfill failure must close it before propagating, or the caller's retry round would stack a
|
|
441
|
+
// second concurrent stream interleaving the same session's output.
|
|
442
|
+
const iterator = control.events(session)[Symbol.asyncIterator]();
|
|
443
|
+
// Live output is BUFFERED while the replay block prints, then flushed — the drain runs
|
|
444
|
+
// concurrently with the backfill, and interleaving raw deltas into the labeled replay would make
|
|
445
|
+
// both unreadable; the [end of replay] label is only honest if the block is contiguous.
|
|
446
|
+
let hold = true;
|
|
447
|
+
const pending = [];
|
|
448
|
+
const release = () => {
|
|
449
|
+
hold = false;
|
|
450
|
+
for (const emit of pending)
|
|
451
|
+
emit();
|
|
452
|
+
pending.length = 0;
|
|
453
|
+
};
|
|
454
|
+
const liveIo = {
|
|
455
|
+
println: (line) => (hold ? void pending.push(() => io.println(line)) : io.println(line)),
|
|
456
|
+
write: (chunk) => (hold ? void pending.push(() => io.write(chunk)) : io.write(chunk)),
|
|
457
|
+
// warn buffers too: a stream-error warn is not user-action feedback (the stdin exemption), and
|
|
458
|
+
// an interleaved warn would break the replay block's contiguity the same as any other line.
|
|
459
|
+
warn: (line) => (hold ? void pending.push(() => io.warn(line)) : io.warn(line)),
|
|
460
|
+
};
|
|
461
|
+
let authError;
|
|
462
|
+
let streamError;
|
|
463
|
+
let liveCount = 0;
|
|
464
|
+
const draining = drainEvents(iterator, liveIo)
|
|
465
|
+
.then((n) => {
|
|
466
|
+
liveCount = n;
|
|
467
|
+
})
|
|
468
|
+
.catch((error) => {
|
|
469
|
+
if (isAuthError(error)) {
|
|
470
|
+
authError = error;
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
// Recorded and RETHROWN at round end: a stream error (protocol mismatch, dropped transport)
|
|
474
|
+
// must fail the round so the caller's budget ticks — a warn-and-succeed round would loop a
|
|
475
|
+
// permanent mismatch forever. Through liveIo: this warn is concurrent with the replay block
|
|
476
|
+
// and must respect its buffering like every other drain-side line.
|
|
477
|
+
streamError = error;
|
|
478
|
+
liveIo.warn(`[fastagent] event stream error: ${String(error)}`);
|
|
479
|
+
});
|
|
480
|
+
await new Promise((r) => setTimeout(r, settleMs)); // let the subscription land before syncing
|
|
481
|
+
// The WHOLE post-subscribe sync (backfill + state re-check) shares one failure discipline: close
|
|
482
|
+
// this round's stream and drain before propagating — an exception escaping with the subscription
|
|
483
|
+
// alive would stack a second concurrent stream on the caller's retry.
|
|
484
|
+
let next = cursor;
|
|
485
|
+
let sawBackfill = false;
|
|
486
|
+
try {
|
|
487
|
+
const backfill = await control.entries(session, cursor !== undefined ? { since: cursor } : undefined);
|
|
488
|
+
sawBackfill = backfill.entries.length > 0;
|
|
489
|
+
// Advance by APPEND ORDER (the last returned record), never by leafEntryId: `since` is an
|
|
490
|
+
// append-position cursor (design §7), and a leaf that sits before later appends (abandoned
|
|
491
|
+
// branches) would make every reconnect permanently replay the same tail.
|
|
492
|
+
next = backfill.entries.at(-1)?.id ?? cursor;
|
|
493
|
+
if (backfill.entries.length > 0) {
|
|
494
|
+
io.println("[replaying the record since the last sync (may overlap what you saw live)]");
|
|
495
|
+
for (const entry of backfill.entries) {
|
|
496
|
+
let line;
|
|
497
|
+
try {
|
|
498
|
+
line = renderEntry(entry);
|
|
499
|
+
}
|
|
500
|
+
catch {
|
|
501
|
+
line = `[${entry.kind}]`;
|
|
502
|
+
}
|
|
503
|
+
if (line !== undefined)
|
|
504
|
+
io.println(line);
|
|
505
|
+
}
|
|
506
|
+
io.println("[end of replay]");
|
|
507
|
+
}
|
|
508
|
+
// The protocol's reconnect step the replay cannot cover: status changes while away are
|
|
509
|
+
// LIVE-only events (state_changed before a restart is neither replayed nor re-emitted).
|
|
510
|
+
const now = await control.state(session);
|
|
511
|
+
io.println(`[live — ${now.status}${now.activeRunId ? ` (run ${now.activeRunId})` : ""}]`);
|
|
512
|
+
}
|
|
513
|
+
catch (error) {
|
|
514
|
+
release(); // buffered live output must not be lost on the failure path
|
|
515
|
+
await iterator.return?.(undefined)?.catch?.(() => { });
|
|
516
|
+
await draining;
|
|
517
|
+
// The stream's 401 outranks this round's transient sync error (restart window: old connection
|
|
518
|
+
// rejected while the port is still unbound) — auth facts must not degrade to transients.
|
|
519
|
+
throw withCursor(authError ?? error, next);
|
|
520
|
+
}
|
|
521
|
+
release();
|
|
522
|
+
await draining;
|
|
523
|
+
if (authError)
|
|
524
|
+
throw withCursor(authError, next); // the stream's 401 is the round's 401
|
|
525
|
+
if (streamError)
|
|
526
|
+
throw withCursor(streamError, next); // and its protocol/transport error is the round's failure
|
|
527
|
+
// sawProgress feeds the caller's budget: a clean end that delivered NOTHING (no live events, no
|
|
528
|
+
// backfill) is indistinguishable from a half-dead proxy closing every stream immediately — the
|
|
529
|
+
// caller must not treat it as health.
|
|
530
|
+
return { cursor: next, sawProgress: liveCount > 0 || sawBackfill };
|
|
531
|
+
}
|
|
532
|
+
/**
|
|
533
|
+
* A failed round still carries its cursor progress: the backfill may have completed (rendered and
|
|
534
|
+
* advanced) before the stream error surfaced — discarding the advancement would make every retry
|
|
535
|
+
* round replay the same records in full until the budget runs out. The caller reads it back via
|
|
536
|
+
* {@link roundCursor}.
|
|
537
|
+
*/
|
|
538
|
+
function withCursor(error, cursor) {
|
|
539
|
+
const e = error instanceof Error ? error : new Error(String(error));
|
|
540
|
+
return Object.assign(e, { attachCursor: cursor });
|
|
541
|
+
}
|
|
542
|
+
/** The cursor a failed round reached, if it recorded one. */
|
|
543
|
+
export function roundCursor(error) {
|
|
544
|
+
return error.attachCursor;
|
|
545
|
+
}
|
|
546
|
+
/** Render one durable record on replay — the guaranteed kind vocabulary; other kinds are skipped. */
|
|
547
|
+
function renderEntry(entry) {
|
|
548
|
+
const d = entry.data;
|
|
549
|
+
switch (entry.kind) {
|
|
550
|
+
case "user":
|
|
551
|
+
return `> ${String(d.text)}`;
|
|
552
|
+
case "assistant": {
|
|
553
|
+
// Same rule as the live path's wroteText: a tool-only assistant record has empty text —
|
|
554
|
+
// printing it would fill a multi-tool run's replay with blank lines.
|
|
555
|
+
const assistantText = String(d.text ?? "");
|
|
556
|
+
return assistantText === "" ? undefined : assistantText;
|
|
557
|
+
}
|
|
558
|
+
case "tool":
|
|
559
|
+
return `[tool ${String(d.toolName)} ${d.isError ? "FAILED" : "done"}]`;
|
|
560
|
+
default:
|
|
561
|
+
return undefined;
|
|
562
|
+
}
|
|
563
|
+
}
|
|
@@ -2,15 +2,20 @@
|
|
|
2
2
|
import { resolve } from "node:path";
|
|
3
3
|
import { loadDotEnv } from "../../env.js";
|
|
4
4
|
import { installProxyFetch } from "../../proxy.js";
|
|
5
|
-
import { failStartup } from "../fail.js";
|
|
5
|
+
import { failStartup, placementOrExit } from "../fail.js";
|
|
6
|
+
import { resolveFirstRunModel } from "../shared.js";
|
|
6
7
|
export async function runChat(dirArg, opts) {
|
|
7
|
-
const
|
|
8
|
-
loadDotEnv(
|
|
8
|
+
const placement = placementOrExit(resolve(dirArg));
|
|
9
|
+
loadDotEnv(placement.agentDir);
|
|
9
10
|
installProxyFetch(); // model calls (and the login dialog) must go through the proxy too
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
|
|
11
|
+
// First-run funnel, FULL picker: chat authenticates through fastagent's credential store like every
|
|
12
|
+
// other command (the shared session builder injects it — see engines/pi/session-builder.ts), so the
|
|
13
|
+
// credential-annotated catalog and inline login apply here too.
|
|
14
|
+
await resolveFirstRunModel(placement.agentDir, { model: opts.model, authPath: opts.authPath });
|
|
15
|
+
// Run the chat process AT the workspace: pi resolves a session's cwd as `header.cwd ?? process.cwd()`,
|
|
16
|
+
// so aligning process.cwd() with the workspace keeps a cwd-less session on it. Paths are absolute.
|
|
17
|
+
process.chdir(placement.workspace);
|
|
13
18
|
// Lazy-import: chat pulls pi's interactive TUI module graph; headless start/dev never need it.
|
|
14
19
|
const { runPiChat } = await import("../../engines/pi/chat.js");
|
|
15
|
-
await runPiChat(
|
|
20
|
+
await runPiChat(placement.workspace, { model: opts.model, authPath: opts.authPath }).catch(failStartup);
|
|
16
21
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type DeployHost = "docker" | "fly" | "railway";
|
|
1
|
+
export type DeployHost = "docker" | "fly" | "railway" | "agentcore";
|
|
2
2
|
export interface DeployOptions {
|
|
3
3
|
run?: boolean;
|
|
4
4
|
tunnel?: boolean;
|
|
@@ -9,5 +9,7 @@ export interface DeployOptions {
|
|
|
9
9
|
intoLinked?: boolean;
|
|
10
10
|
model?: string;
|
|
11
11
|
authPath?: string;
|
|
12
|
+
/** false ⇔ `--no-input`. */
|
|
13
|
+
input?: boolean;
|
|
12
14
|
}
|
|
13
15
|
export declare function runDeploy(host: DeployHost, dirArg: string, opts: DeployOptions): Promise<void>;
|