@fastagent-sh/fastagent 0.13.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (238) hide show
  1. package/README.md +5 -5
  2. package/dist/agent.d.ts +20 -0
  3. package/dist/agent.js +9 -0
  4. package/dist/channels/context-buffer.d.ts +30 -0
  5. package/dist/channels/context-buffer.js +98 -0
  6. package/dist/channels/control.d.ts +28 -0
  7. package/dist/channels/control.js +214 -0
  8. package/dist/channels/feishu/card.js +1 -1
  9. package/dist/channels/feishu/context-buffer.d.ts +43 -0
  10. package/dist/channels/feishu/context-buffer.js +72 -0
  11. package/dist/channels/feishu/crypto.d.ts +4 -2
  12. package/dist/channels/feishu/crypto.js +4 -2
  13. package/dist/channels/feishu/feishu-api.d.ts +15 -7
  14. package/dist/channels/feishu/feishu-api.js +22 -4
  15. package/dist/channels/feishu/feishu.d.ts +38 -16
  16. package/dist/channels/feishu/feishu.js +286 -151
  17. package/dist/channels/feishu/invoke-turn.d.ts +24 -31
  18. package/dist/channels/feishu/invoke-turn.js +61 -62
  19. package/dist/channels/feishu/model.d.ts +98 -0
  20. package/dist/channels/feishu/model.js +9 -0
  21. package/dist/channels/feishu/normalize.d.ts +23 -0
  22. package/dist/channels/feishu/normalize.js +132 -0
  23. package/dist/channels/feishu/owned-threads.d.ts +7 -0
  24. package/dist/channels/feishu/owned-threads.js +47 -0
  25. package/dist/channels/feishu/parse.d.ts +21 -103
  26. package/dist/channels/feishu/parse.js +35 -145
  27. package/dist/channels/feishu/preview.d.ts +4 -7
  28. package/dist/channels/feishu/preview.js +26 -142
  29. package/dist/channels/feishu/register-app.d.ts +2 -1
  30. package/dist/channels/feishu/register-webhook.d.ts +2 -1
  31. package/dist/channels/feishu/scaffold/channel.ts +10 -3
  32. package/dist/channels/feishu/setup-mode.d.ts +8 -0
  33. package/dist/channels/feishu/setup-mode.js +2 -0
  34. package/dist/channels/feishu/ws-ingress.d.ts +28 -0
  35. package/dist/channels/feishu/ws-ingress.js +136 -0
  36. package/dist/channels/github/github.js +8 -6
  37. package/dist/channels/http.d.ts +14 -0
  38. package/dist/channels/http.js +35 -2
  39. package/dist/channels/invoke-turn-kit.d.ts +65 -0
  40. package/dist/channels/invoke-turn-kit.js +87 -0
  41. package/dist/channels/lark/lark.d.ts +4 -2
  42. package/dist/channels/lark/lark.js +4 -1
  43. package/dist/channels/lark/onboard.d.ts +8 -4
  44. package/dist/channels/lark/onboard.js +8 -0
  45. package/dist/channels/lark/scaffold/channel.ts +10 -3
  46. package/dist/channels/preview-kit.d.ts +109 -0
  47. package/dist/channels/preview-kit.js +183 -0
  48. package/dist/channels/seen.d.ts +5 -0
  49. package/dist/channels/seen.js +35 -0
  50. package/dist/channels/slack/bot-auth.d.ts +15 -0
  51. package/dist/channels/slack/bot-auth.js +146 -0
  52. package/dist/channels/slack/config-api.d.ts +60 -0
  53. package/dist/channels/slack/config-api.js +149 -0
  54. package/dist/channels/slack/context-buffer.d.ts +24 -0
  55. package/dist/channels/slack/context-buffer.js +37 -0
  56. package/dist/channels/slack/invoke-turn.d.ts +19 -0
  57. package/dist/channels/slack/invoke-turn.js +63 -0
  58. package/dist/channels/slack/manifest.d.ts +49 -0
  59. package/dist/channels/slack/manifest.js +69 -0
  60. package/dist/channels/slack/model.d.ts +67 -0
  61. package/dist/channels/slack/model.js +2 -0
  62. package/dist/channels/slack/onboard.d.ts +41 -0
  63. package/dist/channels/slack/onboard.js +120 -0
  64. package/dist/channels/slack/onboarding-state.d.ts +31 -0
  65. package/dist/channels/slack/onboarding-state.js +69 -0
  66. package/dist/channels/slack/owned-threads.d.ts +6 -0
  67. package/dist/channels/slack/owned-threads.js +43 -0
  68. package/dist/channels/slack/parse.d.ts +23 -0
  69. package/dist/channels/slack/parse.js +81 -0
  70. package/dist/channels/slack/preview.d.ts +24 -0
  71. package/dist/channels/slack/preview.js +359 -0
  72. package/dist/channels/slack/reaction.d.ts +24 -0
  73. package/dist/channels/slack/reaction.js +62 -0
  74. package/dist/channels/slack/register-webhook.d.ts +10 -0
  75. package/dist/channels/slack/register-webhook.js +49 -0
  76. package/dist/channels/slack/scaffold/channel.ts +33 -0
  77. package/dist/channels/slack/scaffold/slack-send.ts +171 -0
  78. package/dist/channels/slack/setup-server.d.ts +17 -0
  79. package/dist/channels/slack/setup-server.js +103 -0
  80. package/dist/channels/slack/slack-api.d.ts +77 -0
  81. package/dist/channels/slack/slack-api.js +415 -0
  82. package/dist/channels/slack/slack.d.ts +58 -0
  83. package/dist/channels/slack/slack.js +451 -0
  84. package/dist/channels/slack/welcomed.d.ts +5 -0
  85. package/dist/channels/slack/welcomed.js +32 -0
  86. package/dist/channels/state.js +3 -3
  87. package/dist/channels/stop-command.d.ts +6 -0
  88. package/dist/channels/stop-command.js +36 -0
  89. package/dist/channels/tasks.d.ts +13 -0
  90. package/dist/channels/tasks.js +10 -0
  91. package/dist/channels/telegram/context-buffer.d.ts +8 -17
  92. package/dist/channels/telegram/context-buffer.js +6 -85
  93. package/dist/channels/telegram/invoke-turn.d.ts +5 -22
  94. package/dist/channels/telegram/invoke-turn.js +11 -58
  95. package/dist/channels/telegram/preview.d.ts +4 -7
  96. package/dist/channels/telegram/preview.js +24 -142
  97. package/dist/channels/telegram/telegram.js +23 -9
  98. package/dist/channels/{feishu/text.d.ts → text.d.ts} +1 -1
  99. package/dist/channels/{feishu/text.js → text.js} +1 -1
  100. package/dist/channels/turn-queue.js +1 -1
  101. package/dist/channels/turn-store.d.ts +1 -1
  102. package/dist/channels/turn-store.js +2 -3
  103. package/dist/cli/add-feishu.d.ts +27 -0
  104. package/dist/{cli-add-feishu.js → cli/add-feishu.js} +167 -44
  105. package/dist/cli/add-slack.d.ts +10 -0
  106. package/dist/cli/add-slack.js +204 -0
  107. package/dist/{cli-auth.d.ts → cli/auth-view.d.ts} +1 -1
  108. package/dist/{cli-auth.js → cli/auth-view.js} +1 -1
  109. package/dist/cli/commands/add.d.ts +13 -0
  110. package/dist/cli/commands/add.js +274 -0
  111. package/dist/cli/commands/attach.d.ts +82 -0
  112. package/dist/cli/commands/attach.js +559 -0
  113. package/dist/cli/commands/chat.d.ts +4 -0
  114. package/dist/cli/commands/chat.js +21 -0
  115. package/dist/cli/commands/deploy.d.ts +15 -0
  116. package/dist/cli/commands/deploy.js +394 -0
  117. package/dist/cli/commands/dev.d.ts +11 -0
  118. package/dist/cli/commands/dev.js +82 -0
  119. package/dist/cli/commands/fire.d.ts +7 -0
  120. package/dist/cli/commands/fire.js +45 -0
  121. package/dist/cli/commands/info.d.ts +7 -0
  122. package/dist/cli/commands/info.js +108 -0
  123. package/dist/cli/commands/init.d.ts +8 -0
  124. package/dist/cli/commands/init.js +81 -0
  125. package/dist/cli/commands/invoke.d.ts +7 -0
  126. package/dist/cli/commands/invoke.js +28 -0
  127. package/dist/cli/commands/login.d.ts +6 -0
  128. package/dist/cli/commands/login.js +52 -0
  129. package/dist/cli/commands/models.d.ts +1 -0
  130. package/dist/cli/commands/models.js +15 -0
  131. package/dist/cli/commands/schedule.d.ts +12 -0
  132. package/dist/cli/commands/schedule.js +89 -0
  133. package/dist/cli/commands/start.d.ts +10 -0
  134. package/dist/cli/commands/start.js +97 -0
  135. package/dist/cli/commands/tool.d.ts +1 -0
  136. package/dist/cli/commands/tool.js +38 -0
  137. package/dist/cli/fail.d.ts +17 -0
  138. package/dist/cli/fail.js +32 -0
  139. package/dist/{invoke-stream.d.ts → cli/invoke-stream.d.ts} +1 -1
  140. package/dist/{invoke-stream.js → cli/invoke-stream.js} +4 -0
  141. package/dist/cli/kernel.d.ts +90 -0
  142. package/dist/cli/kernel.js +190 -0
  143. package/dist/cli/models-view.d.ts +21 -0
  144. package/dist/cli/models-view.js +66 -0
  145. package/dist/cli/program.d.ts +11 -0
  146. package/dist/cli/program.js +479 -0
  147. package/dist/cli/serve.d.ts +48 -0
  148. package/dist/cli/serve.js +248 -0
  149. package/dist/cli/shared.d.ts +44 -0
  150. package/dist/cli/shared.js +246 -0
  151. package/dist/cli.js +8 -1329
  152. package/dist/collect.d.ts +14 -3
  153. package/dist/collect.js +24 -0
  154. package/dist/core.d.ts +3 -1
  155. package/dist/core.js +2 -0
  156. package/dist/deploy/docker/plan.d.ts +45 -0
  157. package/dist/deploy/docker/plan.js +141 -0
  158. package/dist/deploy/docker/run.d.ts +40 -0
  159. package/dist/deploy/docker/run.js +126 -0
  160. package/dist/deploy/fly/plan.d.ts +3 -1
  161. package/dist/deploy/fly/plan.js +12 -7
  162. package/dist/deploy/fly/run.d.ts +5 -4
  163. package/dist/deploy/fly/run.js +16 -5
  164. package/dist/deploy/preflight.d.ts +10 -4
  165. package/dist/deploy/preflight.js +57 -12
  166. package/dist/deploy/railway/plan.d.ts +3 -1
  167. package/dist/deploy/railway/plan.js +12 -4
  168. package/dist/deploy/railway/run.d.ts +5 -4
  169. package/dist/deploy/railway/run.js +15 -5
  170. package/dist/deploy/runner.d.ts +6 -2
  171. package/dist/deploy/runner.js +1 -0
  172. package/dist/deploy/secrets.d.ts +2 -1
  173. package/dist/deploy/secrets.js +23 -3
  174. package/dist/dev-supervisor.d.ts +0 -2
  175. package/dist/dev-supervisor.js +7 -3
  176. package/dist/engines/pi/auth.js +160 -46
  177. package/dist/engines/pi/channel.d.ts +22 -16
  178. package/dist/engines/pi/channel.js +90 -60
  179. package/dist/engines/pi/chat.d.ts +4 -16
  180. package/dist/engines/pi/chat.js +8 -188
  181. package/dist/engines/pi/config.d.ts +23 -9
  182. package/dist/engines/pi/config.js +35 -5
  183. package/dist/engines/pi/create.d.ts +36 -7
  184. package/dist/engines/pi/create.js +63 -22
  185. package/dist/engines/pi/harness.d.ts +65 -1
  186. package/dist/engines/pi/harness.js +166 -2
  187. package/dist/engines/pi/invoke.d.ts +56 -3
  188. package/dist/engines/pi/invoke.js +340 -20
  189. package/dist/engines/pi/login.d.ts +11 -0
  190. package/dist/engines/pi/login.js +17 -5
  191. package/dist/engines/pi/models.d.ts +56 -10
  192. package/dist/engines/pi/models.js +61 -23
  193. package/dist/engines/pi/search-tools.d.ts +10 -0
  194. package/dist/engines/pi/search-tools.js +138 -0
  195. package/dist/engines/pi/session-builder.d.ts +16 -0
  196. package/dist/engines/pi/session-builder.js +308 -0
  197. package/dist/engines/pi/session-control.d.ts +50 -0
  198. package/dist/engines/pi/session-control.js +604 -0
  199. package/dist/engines/pi/sessions.d.ts +17 -2
  200. package/dist/engines/pi/sessions.js +9 -0
  201. package/dist/engines/pi/tool-context.d.ts +46 -11
  202. package/dist/engines/pi/tool-context.js +11 -9
  203. package/dist/engines/pi/tool.d.ts +35 -6
  204. package/dist/engines/pi/tool.js +47 -1
  205. package/dist/engines/pi/wake-tool.d.ts +0 -3
  206. package/dist/engines/pi/wake-tool.js +9 -7
  207. package/dist/engines/pi/workspace.d.ts +56 -1
  208. package/dist/engines/pi/workspace.js +75 -16
  209. package/dist/feishu.d.ts +1 -1
  210. package/dist/feishu.js +1 -1
  211. package/dist/host/node.d.ts +23 -6
  212. package/dist/host/node.js +5 -4
  213. package/dist/index.d.ts +1 -0
  214. package/dist/index.js +1 -0
  215. package/dist/lark.d.ts +1 -1
  216. package/dist/lark.js +1 -1
  217. package/dist/observe.js +3 -0
  218. package/dist/pi.d.ts +6 -4
  219. package/dist/pi.js +2 -1
  220. package/dist/scaffold/add-channel.d.ts +19 -6
  221. package/dist/scaffold/add-channel.js +127 -16
  222. package/dist/scaffold/templates/fastagent.config.mjs +5 -3
  223. package/dist/schedule/wakeups.d.ts +0 -3
  224. package/dist/schedule/wakeups.js +1 -1
  225. package/dist/session-remote.d.ts +53 -0
  226. package/dist/session-remote.js +336 -0
  227. package/dist/session.d.ts +265 -0
  228. package/dist/session.js +37 -0
  229. package/dist/slack.d.ts +2 -0
  230. package/dist/slack.js +2 -0
  231. package/dist/tunnel.d.ts +7 -4
  232. package/dist/tunnel.js +21 -10
  233. package/package.json +22 -7
  234. package/dist/channels/feishu/seen.d.ts +0 -5
  235. package/dist/channels/feishu/seen.js +0 -47
  236. package/dist/cli-add-feishu.d.ts +0 -8
  237. package/dist/cli-models.d.ts +0 -11
  238. package/dist/cli-models.js +0 -20
@@ -1,6 +1,21 @@
1
+ /**
2
+ * The turn mechanism (request-time): fan pi AgentHarness's two ports (subscribe event side-channel
3
+ * + prompt final value) into SPEC's single event stream, under a single-writer-per-session lease.
4
+ *
5
+ * §1 Lease — single-writer concurrency floor (injectable port + in-process default)
6
+ * §2 translate — the single pi↔SPEC translation point (both directions)
7
+ * §3 EventQueue — push→pull plumbing for pi's two-port shape
8
+ * §4 createPiAgentFromHarness — composes §1–§3 into Agent.invoke
9
+ *
10
+ * Concurrency: at most one in-flight turn per session; a second invoke fails fast with
11
+ * `failed{retryable}` ("session busy"), leaving dedupe/queueing/steering to the channel. Each
12
+ * invoke builds a fresh harness bound to the session and discards it (stateless multi-session).
13
+ */
14
+ import type { AgentHarnessEvent } from "@earendil-works/pi-agent-core";
1
15
  import type { AssistantMessage } from "@earendil-works/pi-ai";
2
- import { type Agent, type AgentEvent } from "../../agent.ts";
3
- import type { PiHarnessFactory } from "./harness.ts";
16
+ import { type Agent, type AgentEvent, type Prompt } from "../../agent.ts";
17
+ import type { SessionEvent } from "../../session.ts";
18
+ import { type PiHarnessFactory } from "./harness.ts";
4
19
  export type Release = () => void;
5
20
  export interface Lease {
6
21
  /** Try to acquire exclusive write access for the session (fail-fast). Returns null if held. */
@@ -12,17 +27,55 @@ export declare function classifyRetryable(details: string, signal: {
12
27
  status?: number;
13
28
  code?: unknown;
14
29
  }): boolean;
30
+ /**
31
+ * In-stream event mapping — pi events are translated ONCE into the rich `SessionEvent` vocabulary;
32
+ * the SPEC `AgentEvent` stream is a narrow {@link projectAgentEvent} of it (design §6: one
33
+ * translation plus one projection, never two parallel translations). pi events with no session
34
+ * vocabulary yet (turn_start, agent_start, …) are dropped.
35
+ */
36
+ export declare function toSessionEvent(pe: AgentHarnessEvent, runId: string): SessionEvent | null;
37
+ /** The SPEC projection of the rich stream. Events with no `AgentEvent` counterpart (progress,
38
+ * message boundaries, run boundaries) project to null — the invoke terminal is produced from the
39
+ * resolved message ({@link toTerminal}), not from `run_settled`. */
40
+ export declare function projectAgentEvent(se: SessionEvent): AgentEvent | null;
41
+ /** Live modulation handles for one active run — what the control plane's `dispatch` routes to.
42
+ * Built inside the invoke closure (it owns the harness); registered with the observer at
43
+ * run_started, gone after run_settled. RACE WINDOW (all three commands, symmetric): the run may
44
+ * resolve between the settled-check and the engine call landing — an accepted `abort` can still
45
+ * settle `completed`, and an accepted `steer`/`followUp` can settle without the prompt ever being
46
+ * consumed. Acceptance is not outcome; the settlement is the truth. */
47
+ export interface RunControls {
48
+ steer(prompt: Prompt): Promise<void>;
49
+ followUp(prompt: Prompt): Promise<void>;
50
+ abort(): Promise<void>;
51
+ }
52
+ /** The DATA-plane observation seam: every rich event of every run, pushed as it happens. `run`
53
+ * carries the live {@link RunControls}, attached to the `run_started` event only. A hub
54
+ * (session-control.ts) implements this to serve `events()`/`state()`/`dispatch`; absent = zero
55
+ * overhead. Scope: RUN events only — the hub's own boundary-mutation events (`state_changed`,
56
+ * `compaction_*`) originate in the hub and reach full-vocabulary taps via the hub's `tap` option,
57
+ * not this seam. TRUST BOUNDARY: since Phase 2a this seam hands every wired observer the run's
58
+ * modulation handles — it is the trusted hub seam, not a public fan-out point. Do not wire
59
+ * untrusted taps here; give third parties the read-only `events()` stream instead. */
60
+ export type SessionObserver = (session: string, event: SessionEvent, run?: RunControls) => void;
15
61
  /**
16
62
  * Terminal mapping, decided by the resolved message's stopReason: pi's prompt() resolves a message
17
63
  * with stopReason "error"/"aborted" rather than throwing, so relying on catch alone would miss this
18
64
  * entire failure class (violating SPEC MUST 1).
19
65
  */
20
66
  export declare function toTerminal(message: AssistantMessage): AgentEvent;
21
- export declare function errorToTerminal(error: unknown): AgentEvent;
67
+ export declare function errorToTerminal(error: unknown): Extract<AgentEvent, {
68
+ type: "failed";
69
+ }>;
22
70
  export interface CreatePiAgentFromHarnessOptions {
23
71
  harnessFactory: PiHarnessFactory;
24
72
  /** Single-writer lease. Defaults to the in-process per-session fail-fast lease. */
25
73
  lease?: Lease;
74
+ /** Observation-plane tap (see {@link SessionObserver}). Optional; invoke behavior is identical
75
+ * with or without it — the SPEC stream is a projection of what the observer sees. */
76
+ observer?: SessionObserver;
77
+ /** Working directory exposed to FastAgent-defined tools. Defaults to process.cwd(). */
78
+ cwd?: string;
26
79
  }
27
80
  /** "From a harness factory": engine wired by the caller; adds only the concurrency/stream shell. */
28
81
  export declare function createPiAgentFromHarness(options: CreatePiAgentFromHarnessOptions): Agent;
@@ -1,7 +1,9 @@
1
1
  import { DEFAULT_COMPACTION_SETTINGS, calculateContextTokens, shouldCompact } from "@earendil-works/pi-agent-core";
2
- import { SESSION_BUSY_CODE } from "../../agent.js";
2
+ import { ABORTED_CODE, SESSION_BUSY_CODE, } from "../../agent.js";
3
+ import { abortFirstIterator } from "../../collect.js";
3
4
  import { log } from "../../log.js";
4
- import { turnContext } from "./tool-context.js";
5
+ import { TOOL_ACTIVATION_ENTRY, harnessSession } from "./harness.js";
6
+ import { additiveActivation, turnContext } from "./tool-context.js";
5
7
  export function inProcessLease() {
6
8
  const busy = new Set();
7
9
  return {
@@ -94,21 +96,109 @@ function messageSignal(message) {
94
96
  }
95
97
  return {};
96
98
  }
97
- /** In-stream event mapping. Non text/tool_* pi events (turn_start, message_start, …) are dropped. */
98
- function toAgentEvent(pe) {
99
+ /**
100
+ * In-stream event mapping — pi events are translated ONCE into the rich `SessionEvent` vocabulary;
101
+ * the SPEC `AgentEvent` stream is a narrow {@link projectAgentEvent} of it (design §6: one
102
+ * translation plus one projection, never two parallel translations). pi events with no session
103
+ * vocabulary yet (turn_start, agent_start, …) are dropped.
104
+ */
105
+ export function toSessionEvent(pe, runId) {
106
+ const at = Date.now();
99
107
  switch (pe.type) {
108
+ case "queue_update":
109
+ return {
110
+ type: "queue_changed",
111
+ timestamp: at,
112
+ runId,
113
+ data: { steering: pe.steer.length, followUp: pe.followUp.length },
114
+ };
115
+ case "message_start":
116
+ // Assistant streaming only — a user/toolResult message is not a live message boundary.
117
+ if (pe.message.role !== "assistant")
118
+ return null;
119
+ return { type: "message_started", timestamp: at, runId, data: {} };
100
120
  case "message_update": {
101
121
  const ev = pe.assistantMessageEvent;
102
- if (ev.type === "text_delta")
103
- return { type: "text", delta: ev.delta };
104
- if (ev.type === "thinking_delta")
105
- return { type: "thinking", delta: ev.delta };
122
+ if (ev.type === "text_delta") {
123
+ return { type: "message_delta", timestamp: at, runId, data: { channel: "text", delta: ev.delta } };
124
+ }
125
+ if (ev.type === "thinking_delta") {
126
+ return { type: "message_delta", timestamp: at, runId, data: { channel: "thinking", delta: ev.delta } };
127
+ }
106
128
  return null;
107
129
  }
130
+ case "message_end":
131
+ if (pe.message.role !== "assistant")
132
+ return null;
133
+ return { type: "message_finished", timestamp: at, runId, data: {} };
108
134
  case "tool_execution_start":
109
- return { type: "tool_started", id: pe.toolCallId, name: pe.toolName, args: pe.args };
135
+ return {
136
+ type: "tool_started",
137
+ timestamp: at,
138
+ runId,
139
+ data: { id: pe.toolCallId, name: pe.toolName, args: pe.args },
140
+ };
141
+ case "tool_execution_update":
142
+ return {
143
+ type: "tool_progress",
144
+ timestamp: at,
145
+ runId,
146
+ data: { id: pe.toolCallId, name: pe.toolName, partialResult: pe.partialResult },
147
+ };
110
148
  case "tool_execution_end":
111
- return { type: "tool_ended", id: pe.toolCallId, isError: pe.isError, content: pe.result };
149
+ return {
150
+ type: "tool_finished",
151
+ timestamp: at,
152
+ runId,
153
+ data: { id: pe.toolCallId, isError: pe.isError, content: pe.result },
154
+ };
155
+ case "retry_scheduled": {
156
+ // Summarization retry backoff (auto-compaction / branch summary, pi ≥0.81.1) — without it,
157
+ // up to ~14s of backoff at the turn's tail reads as a hang. `retry_attempt_start`/
158
+ // `retry_finished` stay dropped: they carry no outcome, and the next event is the closure.
159
+ // Typed against the vocabulary (as is the second construction site, session-control's
160
+ // manual-compact callback) so a payload change breaks both at compile time.
161
+ const event = {
162
+ type: "retry_scheduled",
163
+ timestamp: at,
164
+ runId,
165
+ data: {
166
+ operation: pe.operation,
167
+ attempt: pe.attempt,
168
+ maxAttempts: pe.maxAttempts,
169
+ delayMs: pe.delayMs,
170
+ error: pe.errorMessage,
171
+ },
172
+ };
173
+ return event;
174
+ }
175
+ default:
176
+ return null;
177
+ }
178
+ }
179
+ /** The SPEC projection of the rich stream. Events with no `AgentEvent` counterpart (progress,
180
+ * message boundaries, run boundaries) project to null — the invoke terminal is produced from the
181
+ * resolved message ({@link toTerminal}), not from `run_settled`. */
182
+ export function projectAgentEvent(se) {
183
+ switch (se.type) {
184
+ case "message_delta": {
185
+ const d = se.data;
186
+ return d.channel === "text" ? { type: "text", delta: d.delta } : { type: "thinking", delta: d.delta };
187
+ }
188
+ case "tool_started": {
189
+ const d = se.data;
190
+ return { type: "tool_started", id: d.id, name: d.name, args: d.args };
191
+ }
192
+ case "tool_finished": {
193
+ const d = se.data;
194
+ return { type: "tool_ended", id: d.id, isError: d.isError, content: d.content };
195
+ }
196
+ case "retry_scheduled": {
197
+ // `operation` (compaction | branch_summary) stays session-plane vocabulary — a turn renderer
198
+ // only needs "transient failure, retrying"; the engine detail lives in the control plane.
199
+ const d = se.data;
200
+ return { type: "retrying", attempt: d.attempt, maxAttempts: d.maxAttempts, delayMs: d.delayMs, reason: d.error };
201
+ }
112
202
  default:
113
203
  return null;
114
204
  }
@@ -119,7 +209,13 @@ function toAgentEvent(pe) {
119
209
  * entire failure class (violating SPEC MUST 1).
120
210
  */
121
211
  export function toTerminal(message) {
122
- if (message.stopReason === "error" || message.stopReason === "aborted") {
212
+ if (message.stopReason === "aborted") {
213
+ // A deliberate stop (control-plane abort / harness abort), not an error — see {@link ABORTED_CODE}
214
+ // for the consumer contract (design §6).
215
+ const details = message.errorMessage ?? "run aborted";
216
+ return { type: "failed", details, retryable: false, code: ABORTED_CODE };
217
+ }
218
+ if (message.stopReason === "error") {
123
219
  const details = message.errorMessage ?? `model stopped: ${message.stopReason}`;
124
220
  return { type: "failed", details, retryable: classifyRetryable(details, messageSignal(message)) };
125
221
  }
@@ -129,6 +225,54 @@ export function errorToTerminal(error) {
129
225
  const details = error instanceof Error ? error.message : String(error);
130
226
  return { type: "failed", details, retryable: classifyRetryable(details, errorSignal(error)) };
131
227
  }
228
+ /** Bind the concrete pi-agent-core Session behind FastAgent's tool-runtime manager port. */
229
+ function toolSessionManager(sessionId, harness) {
230
+ const session = harnessSession(harness);
231
+ if (!session)
232
+ return undefined;
233
+ return {
234
+ getSessionId: () => sessionId,
235
+ async getHeader() {
236
+ const metadata = await session.getMetadata();
237
+ return { id: sessionId, timestamp: metadata.createdAt };
238
+ },
239
+ getBranch: () => session.getBranch(),
240
+ };
241
+ }
242
+ /**
243
+ * The turn's {@link ToolActivation} over the live harness. `activate` is additive and filters to the
244
+ * registered names first — pi's `setActiveTools` THROWS on unknown names, and a loader must get a
245
+ * usable "nothing new" answer, not an exception. pi persists the change in the session, so the
246
+ * per-invoke restore (harness.ts) carries it into later turns.
247
+ */
248
+ function toolActivation(harness) {
249
+ // Serialize activations per turn: "who activated first" must be decided HERE, not by whether pi's
250
+ // setActiveTools happens to mutate before its first await — parallel tool calls in one batch race
251
+ // their activate() calls, and the addedToolNames load points must not double-stamp.
252
+ let chain = Promise.resolve([]);
253
+ return {
254
+ active: () => harness.getActiveTools().map((t) => t.name),
255
+ registered: () => harness.getTools().map((t) => ({ name: t.name, description: t.description ?? "" })),
256
+ activate(names) {
257
+ const run = async () => {
258
+ const current = harness.getActiveTools().map((t) => t.name);
259
+ const added = additiveActivation(harness.getTools().map((t) => t.name), current, names);
260
+ if (added.length > 0) {
261
+ await harness.setActiveTools([...current, ...added]);
262
+ // Persist the DELTA in a dedicated entry — what the per-invoke resolve (harness.ts) reads.
263
+ // pi's own active_tools_change record is a full snapshot and is deliberately ignored there.
264
+ // Absent session (a harness built outside piHarnessFactory): in-turn activation still works,
265
+ // it just isn't durable — the factory owns persistence.
266
+ await harnessSession(harness)?.appendCustomEntry(TOOL_ACTIVATION_ENTRY, { names: added });
267
+ }
268
+ return added;
269
+ };
270
+ const result = chain.then(run, run); // run after the predecessor settles, success or failure
271
+ chain = result.catch(() => []); // the caller sees a rejection on `result`; the chain stays usable
272
+ return result;
273
+ },
274
+ };
275
+ }
132
276
  /**
133
277
  * After a successful turn, compact the session if its context has grown past pi's threshold — a long
134
278
  * shared (group) or 1:1 conversation otherwise overflows the model's window. pi owns the mechanism
@@ -208,10 +352,39 @@ class EventQueue {
208
352
  }
209
353
  /** "From a harness factory": engine wired by the caller; adds only the concurrency/stream shell. */
210
354
  export function createPiAgentFromHarness(options) {
211
- const { harnessFactory, lease = inProcessLease() } = options;
212
- async function* invoke(scope, prompt) {
355
+ const { harnessFactory, lease = inProcessLease(), observer } = options;
356
+ function invoke(scope, prompt) {
357
+ // The cancellation DOOR (SPEC MUST 3), via the shared abort-first protocol (see
358
+ // abortFirstIterator): cancel aborts the engine work, which settles the run and releases a
359
+ // generator suspended on a quiet stream (a tool mid-execution). The local for-await pattern
360
+ // never hit the underlying deadlock (it breaks at a yield boundary); pull-driven consumers
361
+ // (the SSE handler's eager reads) do.
362
+ // The cancel intent is LATCHED: a consumer may cancel while the generator is still inside
363
+ // the harness build (the door not yet armed) — abortFirstIterator knocks exactly once, and a
364
+ // knock before prompt() starts would be a no-op on an idle harness (the LATER run would
365
+ // ignore it). So turn consults the latch right after arming and, when the consumer already
366
+ // walked away, never starts the model call at all.
367
+ let externalCancel;
368
+ let cancelled = false;
369
+ const gen = turn(scope, prompt, (cancel) => {
370
+ externalCancel = cancel;
371
+ }, () => cancelled);
372
+ const iterator = abortFirstIterator(gen, () => {
373
+ cancelled = true;
374
+ externalCancel?.();
375
+ });
376
+ return {
377
+ [Symbol.asyncIterator]() {
378
+ return iterator;
379
+ },
380
+ };
381
+ }
382
+ async function* turn(scope, prompt, onCancelReady,
383
+ /** The consumer's cancel latch (see invoke's wrapper) — checked once at arming. */
384
+ wasCancelled) {
213
385
  const release = lease.tryAcquire(scope.session);
214
386
  if (!release) {
387
+ // Rejected BEFORE acceptance: no run exists, so the observer sees nothing (replay-safe).
215
388
  yield {
216
389
  type: "failed",
217
390
  details: "session busy: a turn is already in flight for this session",
@@ -220,6 +393,90 @@ export function createPiAgentFromHarness(options) {
220
393
  };
221
394
  return;
222
395
  }
396
+ // The run exists from here: one run_started, exactly one run_settled. Terminal points only
397
+ // RECORD the outcome; the settlement event is emitted in the outer finally, right before
398
+ // release() — so the observation plane's "running" window equals the lease window (state()
399
+ // must never say idle while a new invoke would still be rejected session_busy), and the
400
+ // post-terminal auto-compaction is naturally inside the run. A run with no recorded outcome
401
+ // was cancelled by the caller (SPEC: cancellation has no terminal event) → aborted.
402
+ const runId = crypto.randomUUID();
403
+ let outcome;
404
+ const observe = (event, run) => {
405
+ if (!event || !observer)
406
+ return;
407
+ try {
408
+ observer(scope.session, event, run);
409
+ }
410
+ catch (error) {
411
+ // The observation plane must never break the data plane; a broken hub is its own problem.
412
+ log.warn(`[fastagent] session observer threw (event ${event.type}): ${String(error)}`);
413
+ }
414
+ };
415
+ // run_started must be observed before the (awaited) harness build so no early event outruns
416
+ // registration — so the controls AWAIT the build instead of erroring on the assembling window:
417
+ // a dispatch that races the build simply queues on the freshly built harness. A setup failure
418
+ // rejects the gate (and the run settles failed); the guard keeps an undispatched rejection from
419
+ // becoming an unhandled-rejection crash.
420
+ let harnessReady;
421
+ let harnessFailed;
422
+ const harnessGate = new Promise((resolve, reject) => {
423
+ harnessReady = resolve;
424
+ harnessFailed = reject;
425
+ });
426
+ harnessGate.catch(() => { }); // observed via controls only when a dispatch actually happens
427
+ // Aborted classification has two attribution sources, either suffices: pi's own
428
+ // stopReason:"aborted" (toTerminal), and control-plane INTENT — needed because providers do
429
+ // not uniformly attribute an aborted stream (verified empirically: the faux path surfaces a
430
+ // plain error). Intent = "an abort() succeeded, OR one was still in flight when the terminal
431
+ // arrived" (the harness error often lands before abort() resolves). A rejected abort that
432
+ // RETURNED before the terminal counts as nothing — no rollback dance, no interleaving hazard.
433
+ // GUARANTEE BOUNDARY: an abort still in flight that ultimately rejects can classify a
434
+ // concurrent real error as aborted — narrow, and non-lossy: the settlement carries
435
+ // `error.message` either way.
436
+ let abortsInFlight = 0;
437
+ let abortSucceeded = false;
438
+ // Stale-controls guard: after settlement pi's steer()/followUp()/abort() would still resolve
439
+ // (they queue / no-op on the to-be-discarded harness) — a silent acceptance of a command that
440
+ // can never take effect. The flag flips at THREE points, earliest wins: (1) the moment the
441
+ // run's terminal is determined (the main window — before the consumer-paced `yield terminal`
442
+ // and auto-compaction), (2) the setup-failure path, (3) the outer finally as the backstop for
443
+ // caller cancellation. A post-settle call throws and the dispatcher maps it to
444
+ // `run_command_failed`.
445
+ let runSettled = false;
446
+ const settledError = () => new Error("run already settled; the command cannot take effect");
447
+ // The settled check and the harness call MUST share one synchronous block (no await between):
448
+ // pi enqueues/aborts synchronously at method entry, so check-then-call in the same tick truly
449
+ // closes the race — a check behind its own await boundary would only shrink it.
450
+ const controls = {
451
+ async steer(p) {
452
+ const opts = await toPiPromptOptions(p);
453
+ const harness = await harnessGate;
454
+ if (runSettled)
455
+ throw settledError();
456
+ await harness.steer(p.text, opts);
457
+ },
458
+ async followUp(p) {
459
+ const opts = await toPiPromptOptions(p);
460
+ const harness = await harnessGate;
461
+ if (runSettled)
462
+ throw settledError();
463
+ await harness.followUp(p.text, opts);
464
+ },
465
+ async abort() {
466
+ const harness = await harnessGate;
467
+ if (runSettled)
468
+ throw settledError();
469
+ abortsInFlight++;
470
+ try {
471
+ await harness.abort();
472
+ abortSucceeded = true;
473
+ }
474
+ finally {
475
+ abortsInFlight--;
476
+ }
477
+ },
478
+ };
479
+ observe({ type: "run_started", timestamp: Date.now(), runId, data: {} }, controls);
223
480
  try {
224
481
  let harness;
225
482
  try {
@@ -227,22 +484,58 @@ export function createPiAgentFromHarness(options) {
227
484
  }
228
485
  catch (error) {
229
486
  // Setup failures (session open / auth / …) MUST surface as a failed event, never a throw.
230
- yield errorToTerminal(error);
231
- return;
487
+ harnessFailed(error); // a pending dispatch learns the run cannot take commands
488
+ const terminal = errorToTerminal(error);
489
+ outcome = { status: "failed", error: { message: terminal.details, retryable: terminal.retryable } };
490
+ runSettled = true; // commands can no longer take effect — reject stale controls from here on
491
+ yield terminal;
492
+ return; // → outer finally emits the settlement
493
+ }
494
+ harnessReady(harness);
495
+ // Arm the cancellation door (see invoke's wrapper): aborting the harness settles the run,
496
+ // releasing any await the generator is parked on so a queued return() can reach the finally.
497
+ onCancelReady(() => {
498
+ void harness.abort().catch(() => { });
499
+ });
500
+ // The consumer cancelled DURING the build (latched — the door above came too late to be
501
+ // knocked): never start the model call; settle as aborted and let the queued return()
502
+ // finish the generator. Same synchronous tick as the arming — a cancel from here on
503
+ // reaches the armed door instead.
504
+ if (wasCancelled()) {
505
+ outcome = { status: "aborted" };
506
+ runSettled = true;
507
+ try {
508
+ await harness.abort(); // teardown — fresh-harness discipline
509
+ }
510
+ catch (error) {
511
+ log.warn(`[fastagent] harness abort failed during cleanup: ${String(error)}`);
512
+ }
513
+ return; // → outer finally emits the settlement
232
514
  }
233
515
  const queue = new EventQueue();
234
516
  const unsub = harness.subscribe((pe) => {
235
- const event = toAgentEvent(pe);
517
+ // Summarization retries also warn to server logs: the session `retry_scheduled` event only
518
+ // reaches attached observers, and an operator tailing logs must see the backoff too.
519
+ if (pe.type === "retry_scheduled") {
520
+ log.warn(`[fastagent] ${pe.operation} retry ${pe.attempt}/${pe.maxAttempts} in ${pe.delayMs}ms (session ${scope.session}): ${pe.errorMessage}`);
521
+ }
522
+ const rich = toSessionEvent(pe, runId);
523
+ if (!rich)
524
+ return;
525
+ observe(rich);
526
+ const event = projectAgentEvent(rich);
236
527
  if (event)
237
528
  queue.push(event);
238
529
  });
239
530
  let completed; // the assistant message of a cleanly completed turn
240
531
  try {
241
- // Run the turn inside the session context so a tool's `execute` can read which session it is in
242
- // (turnContext / ToolContext.session). prompt() starts the async work synchronously here, so the
243
- // store propagates to the tool calls awaited within it.
532
+ // Bind current cwd/session/activation capabilities for every FastAgent-defined tool.
244
533
  const opts = await toPiPromptOptions(prompt);
245
- const run = turnContext.run({ session: scope.session }, () => harness.prompt(prompt.text, opts));
534
+ const run = turnContext.run({
535
+ cwd: options.cwd ?? process.cwd(),
536
+ sessionManager: toolSessionManager(scope.session, harness),
537
+ tools: toolActivation(harness),
538
+ }, () => harness.prompt(prompt.text, opts));
246
539
  yield* queue.drainUntil(run);
247
540
  let terminal;
248
541
  try {
@@ -254,6 +547,28 @@ export function createPiAgentFromHarness(options) {
254
547
  catch (error) {
255
548
  terminal = errorToTerminal(error);
256
549
  }
550
+ if ((abortSucceeded || abortsInFlight > 0) && terminal.type === "failed") {
551
+ terminal = { type: "failed", details: terminal.details, retryable: false, code: ABORTED_CODE };
552
+ }
553
+ if (terminal.type === "completed")
554
+ outcome = { status: "completed" };
555
+ else if (terminal.type === "failed") {
556
+ outcome =
557
+ terminal.code === ABORTED_CODE
558
+ ? // Carry the detail: an independent real error that raced an accepted abort must stay
559
+ // diagnosable in the settlement (audit consumers read run_settled, not the invoke
560
+ // stream) — aborted classifies the run, the message preserves what actually stopped it.
561
+ { status: "aborted", error: { message: terminal.details, retryable: false } }
562
+ : {
563
+ status: "failed",
564
+ error: { code: terminal.code, message: terminal.details, retryable: terminal.retryable },
565
+ };
566
+ }
567
+ // Commands become ineffective the moment the run resolved — NOT at the outer finally, which
568
+ // sits behind `yield terminal` (a consumer-paced suspension) and auto-compaction. Flipping
569
+ // here closes the silent-drop window for steer/follow_up dispatched in that gap; the
570
+ // outer-finally flip remains as the backstop for caller cancellation.
571
+ runSettled = true;
257
572
  yield terminal;
258
573
  }
259
574
  finally {
@@ -291,6 +606,11 @@ export function createPiAgentFromHarness(options) {
291
606
  }
292
607
  }
293
608
  finally {
609
+ // Exactly-one settlement, after ALL run work (incl. auto-compaction) and immediately before
610
+ // the lease releases — see the outcome note above. The stale-controls flag flips FIRST so a
611
+ // dispatch racing this settlement is rejected instead of silently accepted.
612
+ runSettled = true;
613
+ observe({ type: "run_settled", timestamp: Date.now(), runId, data: outcome ?? { status: "aborted" } });
294
614
  release(); // after cleanup, so the next invoke for this session can enter
295
615
  }
296
616
  }
@@ -33,6 +33,17 @@ export interface LoginIO {
33
33
  openUrl(url: string): void;
34
34
  }
35
35
  export type LoginMethod = "oauth" | "api_key";
36
+ /** The user backed out of a prompt/menu — a decision, not a failure. Callers (the first-run picker,
37
+ * the login command) match on this to report neutrally instead of as a login "failure". */
38
+ export declare class LoginCancelled extends Error {
39
+ }
40
+ /** What `loginFlow` can offer a provider interactively: an OAuth flow, an API-key ENTRY prompt, or
41
+ * nothing ("none" — the key must come from the provider's env var). */
42
+ export type InteractiveLoginKind = LoginMethod | "none";
43
+ /** The provider's {@link InteractiveLoginKind}. OAuth wins when both exist (methodForProvider still
44
+ * asks at login time); the first-run picker annotates with this so the hint predicts what picking
45
+ * actually does — a browser login ("oauth"), a key prompt ("api_key"), or neither. */
46
+ export declare function interactiveLoginKind(p: Provider): InteractiveLoginKind;
36
47
  export interface LoginResult {
37
48
  provider: string;
38
49
  method: LoginMethod;
@@ -1,12 +1,24 @@
1
1
  import { builtinProviders } from "@earendil-works/pi-ai/providers/all";
2
2
  import { GLOBAL_AUTH_PATH, fastagentCredentialStore } from "./auth.js";
3
+ /** The user backed out of a prompt/menu — a decision, not a failure. Callers (the first-run picker,
4
+ * the login command) match on this to report neutrally instead of as a login "failure". */
5
+ export class LoginCancelled extends Error {
6
+ }
7
+ /** The provider's {@link InteractiveLoginKind}. OAuth wins when both exist (methodForProvider still
8
+ * asks at login time); the first-run picker annotates with this so the hint predicts what picking
9
+ * actually does — a browser login ("oauth"), a key prompt ("api_key"), or neither. */
10
+ export function interactiveLoginKind(p) {
11
+ if (p.auth.oauth)
12
+ return "oauth";
13
+ return p.auth.apiKey?.login ? "api_key" : "none";
14
+ }
3
15
  /** Combine present abort signals into one (no-op when none/one). */
4
16
  function anySignal(...signals) {
5
17
  const present = signals.filter((s) => s !== undefined);
6
18
  return present.length === 0 ? undefined : present.length === 1 ? present[0] : AbortSignal.any(present);
7
19
  }
8
20
  /**
9
- * Map pi-ai's `AuthLoginCallbacks` onto the injected {@link LoginIO}. `doneSignal` fires when the flow
21
+ * Map pi-ai's `AuthInteraction` onto the injected {@link LoginIO}. `doneSignal` fires when the flow
10
22
  * resolves, cancelling a prompt the provider left pending (a manual-code paste racing a callback
11
23
  * server it just won) so the one-shot CLI exits instead of hanging on stdin.
12
24
  */
@@ -17,13 +29,13 @@ function authCallbacks(io, userSignal, doneSignal) {
17
29
  if (p.type === "select") {
18
30
  const v = await io.select(p.message, p.options.map((o) => ({ value: o.id, label: o.label, hint: o.description })));
19
31
  if (v === undefined)
20
- throw new Error("cancelled");
32
+ throw new LoginCancelled("cancelled");
21
33
  return v;
22
34
  }
23
35
  const signal = anySignal(p.signal, userSignal, doneSignal);
24
36
  const v = await io.prompt(p.message, { hidden: p.type === "secret", signal });
25
37
  if (v === undefined)
26
- throw new Error("cancelled");
38
+ throw new LoginCancelled("cancelled");
27
39
  return v;
28
40
  },
29
41
  notify: (e) => {
@@ -53,7 +65,7 @@ async function selectMethod(io) {
53
65
  { value: "api_key", label: "Use an API key" },
54
66
  ]);
55
67
  if (v !== "oauth" && v !== "api_key")
56
- throw new Error("no authentication method selected");
68
+ throw new LoginCancelled("no authentication method selected");
57
69
  return v;
58
70
  }
59
71
  /** Given a provider arg, pick the method it supports (asking only when it offers both). */
@@ -80,7 +92,7 @@ async function selectProvider(io, providers, method, store) {
80
92
  }));
81
93
  const id = await io.select("Select a provider", options);
82
94
  if (!id)
83
- throw new Error("no provider selected");
95
+ throw new LoginCancelled("no provider selected");
84
96
  return id;
85
97
  }
86
98
  /**