@fastagent-sh/fastagent 0.14.0 → 0.15.0

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