@fastagent-sh/fastagent 0.18.0 → 0.20.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 (222) hide show
  1. package/README.md +25 -7
  2. package/dist/agent.d.ts +1 -1
  3. package/dist/agent.js +1 -1
  4. package/dist/atomic-write.d.ts +23 -0
  5. package/dist/atomic-write.js +53 -0
  6. package/dist/{host/node.d.ts → channel.d.ts} +24 -28
  7. package/dist/channel.js +1 -0
  8. package/dist/channels/agentcore-limits.d.ts +9 -0
  9. package/dist/channels/agentcore-limits.js +9 -0
  10. package/dist/channels/agentcore-service.d.ts +55 -0
  11. package/dist/channels/agentcore-service.js +107 -0
  12. package/dist/channels/agentcore.d.ts +22 -20
  13. package/dist/channels/agentcore.js +20 -11
  14. package/dist/channels/busy.d.ts +1 -1
  15. package/dist/channels/busy.js +1 -1
  16. package/dist/channels/control.d.ts +55 -7
  17. package/dist/channels/control.js +312 -67
  18. package/dist/channels/discover.d.ts +59 -0
  19. package/dist/{engines/pi/channel.js → channels/discover.js} +19 -34
  20. package/dist/channels/feishu/card.js +1 -1
  21. package/dist/channels/feishu/context-buffer.d.ts +1 -1
  22. package/dist/channels/feishu/context-buffer.js +4 -4
  23. package/dist/channels/feishu/feishu-api.js +6 -9
  24. package/dist/channels/feishu/feishu.d.ts +1 -1
  25. package/dist/channels/feishu/feishu.js +41 -24
  26. package/dist/channels/feishu/invoke-turn.d.ts +1 -1
  27. package/dist/channels/feishu/invoke-turn.js +3 -4
  28. package/dist/channels/feishu/preview.d.ts +1 -1
  29. package/dist/channels/feishu/preview.js +7 -3
  30. package/dist/channels/feishu/ws-ingress.d.ts +1 -1
  31. package/dist/channels/github/github.d.ts +1 -1
  32. package/dist/channels/http.d.ts +11 -7
  33. package/dist/channels/http.js +1 -109
  34. package/dist/channels/kit/attachment-path.d.ts +12 -0
  35. package/dist/channels/kit/attachment-path.js +43 -0
  36. package/dist/channels/{context-buffer.d.ts → kit/context-buffer.d.ts} +9 -0
  37. package/dist/channels/{context-buffer.js → kit/context-buffer.js} +13 -2
  38. package/dist/channels/{invoke-turn-kit.d.ts → kit/invoke-turn-kit.d.ts} +14 -3
  39. package/dist/channels/{invoke-turn-kit.js → kit/invoke-turn-kit.js} +17 -6
  40. package/dist/channels/{preview-kit.d.ts → kit/preview-kit.d.ts} +3 -1
  41. package/dist/channels/{seen.js → kit/seen.js} +1 -1
  42. package/dist/channels/kit/signature.d.ts +12 -0
  43. package/dist/channels/kit/signature.js +17 -0
  44. package/dist/channels/{state.js → kit/state.js} +4 -7
  45. package/dist/channels/{stop-command.d.ts → kit/stop-command.d.ts} +1 -1
  46. package/dist/channels/{stop-command.js → kit/stop-command.js} +5 -5
  47. package/dist/channels/{tasks.d.ts → kit/tasks.d.ts} +1 -1
  48. package/dist/channels/{tasks.js → kit/tasks.js} +11 -5
  49. package/dist/channels/{thread-participants.js → kit/thread-participants.js} +1 -1
  50. package/dist/channels/{turn-queue.js → kit/turn-queue.js} +2 -2
  51. package/dist/channels/{turn-store.d.ts → kit/turn-store.d.ts} +19 -3
  52. package/dist/channels/{turn-store.js → kit/turn-store.js} +30 -3
  53. package/dist/channels/lark/lark.d.ts +1 -1
  54. package/dist/channels/serve.d.ts +73 -0
  55. package/dist/channels/serve.js +243 -0
  56. package/dist/channels/slack/bot-auth.js +3 -14
  57. package/dist/channels/slack/context-buffer.d.ts +2 -2
  58. package/dist/channels/slack/context-buffer.js +2 -2
  59. package/dist/channels/slack/invoke-turn.d.ts +2 -2
  60. package/dist/channels/slack/invoke-turn.js +13 -6
  61. package/dist/channels/slack/onboard.js +5 -5
  62. package/dist/channels/slack/onboarding-state.d.ts +7 -3
  63. package/dist/channels/slack/onboarding-state.js +13 -20
  64. package/dist/channels/slack/parse.js +2 -2
  65. package/dist/channels/slack/preview.d.ts +1 -1
  66. package/dist/channels/slack/preview.js +11 -3
  67. package/dist/channels/slack/register-webhook.js +1 -1
  68. package/dist/channels/slack/scaffold/slack-send.ts +2 -17
  69. package/dist/channels/slack/slack-api.js +23 -55
  70. package/dist/channels/slack/slack.d.ts +1 -1
  71. package/dist/channels/slack/slack.js +20 -20
  72. package/dist/channels/slack/welcomed.js +1 -1
  73. package/dist/channels/telegram/context-buffer.d.ts +2 -2
  74. package/dist/channels/telegram/context-buffer.js +2 -2
  75. package/dist/channels/telegram/invoke-turn.d.ts +1 -1
  76. package/dist/channels/telegram/invoke-turn.js +1 -1
  77. package/dist/channels/telegram/parse.d.ts +21 -1
  78. package/dist/channels/telegram/parse.js +67 -13
  79. package/dist/channels/telegram/preview.d.ts +1 -1
  80. package/dist/channels/telegram/preview.js +6 -2
  81. package/dist/channels/telegram/scaffold/channel.ts +7 -3
  82. package/dist/channels/telegram/telegram-api.js +5 -6
  83. package/dist/channels/telegram/telegram.d.ts +3 -3
  84. package/dist/channels/telegram/telegram.js +60 -72
  85. package/dist/channels/telegram/turn-store.d.ts +2 -2
  86. package/dist/channels/telegram/turn-store.js +2 -2
  87. package/dist/cli/add-slack.js +6 -5
  88. package/dist/cli/commands/add.js +1 -5
  89. package/dist/cli/commands/attach.d.ts +2 -4
  90. package/dist/cli/commands/attach.js +17 -15
  91. package/dist/cli/commands/deploy.d.ts +50 -5
  92. package/dist/cli/commands/deploy.js +183 -134
  93. package/dist/cli/commands/dev.js +25 -40
  94. package/dist/cli/commands/fire.js +1 -1
  95. package/dist/cli/commands/info.js +7 -5
  96. package/dist/cli/commands/init.js +1 -2
  97. package/dist/cli/commands/schedule.js +1 -1
  98. package/dist/cli/commands/start.js +80 -121
  99. package/dist/cli/commands/tool.js +5 -7
  100. package/dist/cli/program.js +1 -1
  101. package/dist/cli/serve.d.ts +22 -70
  102. package/dist/cli/serve.js +66 -257
  103. package/dist/cli/shared.d.ts +40 -10
  104. package/dist/cli/shared.js +53 -6
  105. package/dist/core.d.ts +3 -5
  106. package/dist/core.js +7 -5
  107. package/dist/deploy/agentcore/logs.d.ts +2 -2
  108. package/dist/deploy/agentcore/logs.js +2 -2
  109. package/dist/deploy/agentcore/plan.d.ts +12 -10
  110. package/dist/deploy/agentcore/plan.js +39 -31
  111. package/dist/deploy/agentcore/run.d.ts +5 -4
  112. package/dist/deploy/agentcore/run.js +13 -36
  113. package/dist/deploy/channel-ingress.d.ts +73 -0
  114. package/dist/deploy/channel-ingress.js +101 -0
  115. package/dist/deploy/docker/plan.d.ts +3 -7
  116. package/dist/deploy/docker/plan.js +4 -15
  117. package/dist/deploy/docker/run.d.ts +18 -1
  118. package/dist/deploy/docker/run.js +10 -0
  119. package/dist/deploy/fly/plan.d.ts +4 -5
  120. package/dist/deploy/fly/plan.js +7 -22
  121. package/dist/deploy/fly/run.d.ts +9 -23
  122. package/dist/deploy/fly/run.js +32 -68
  123. package/dist/deploy/preflight.d.ts +6 -7
  124. package/dist/deploy/preflight.js +27 -23
  125. package/dist/deploy/railway/plan.d.ts +4 -5
  126. package/dist/deploy/railway/plan.js +11 -26
  127. package/dist/deploy/railway/run.d.ts +9 -9
  128. package/dist/deploy/railway/run.js +40 -40
  129. package/dist/deploy/runner.js +22 -2
  130. package/dist/deploy/secrets.d.ts +18 -9
  131. package/dist/deploy/secrets.js +64 -12
  132. package/dist/dev-supervisor.js +11 -6
  133. package/dist/engines/pi/agent-session-factory.d.ts +104 -0
  134. package/dist/engines/pi/agent-session-factory.js +295 -0
  135. package/dist/engines/pi/auth.js +43 -43
  136. package/dist/engines/pi/config.d.ts +5 -5
  137. package/dist/engines/pi/config.js +1 -1
  138. package/dist/engines/pi/create.d.ts +67 -42
  139. package/dist/engines/pi/create.js +142 -72
  140. package/dist/engines/pi/definition.d.ts +42 -8
  141. package/dist/engines/pi/definition.js +114 -11
  142. package/dist/engines/pi/invoke-session.d.ts +22 -19
  143. package/dist/engines/pi/invoke-session.js +286 -100
  144. package/dist/engines/pi/login.js +39 -20
  145. package/dist/engines/pi/models.d.ts +14 -0
  146. package/dist/engines/pi/models.js +9 -1
  147. package/dist/engines/pi/open.d.ts +10 -8
  148. package/dist/engines/pi/open.js +21 -11
  149. package/dist/engines/pi/report.d.ts +0 -4
  150. package/dist/engines/pi/report.js +0 -7
  151. package/dist/engines/pi/search-tools.js +1 -1
  152. package/dist/engines/pi/service.d.ts +23 -0
  153. package/dist/engines/pi/service.js +27 -0
  154. package/dist/engines/pi/session-builder.js +87 -118
  155. package/dist/engines/pi/session-control.d.ts +28 -14
  156. package/dist/engines/pi/session-control.js +570 -438
  157. package/dist/engines/pi/session-inheritance.d.ts +30 -0
  158. package/dist/engines/pi/session-inheritance.js +261 -0
  159. package/dist/engines/pi/session-markers.d.ts +48 -0
  160. package/dist/engines/pi/session-markers.js +59 -0
  161. package/dist/engines/pi/session-settings.d.ts +15 -3
  162. package/dist/engines/pi/session-settings.js +22 -1
  163. package/dist/engines/pi/session-store.d.ts +140 -0
  164. package/dist/engines/pi/session-store.js +666 -0
  165. package/dist/engines/pi/tool-context.d.ts +36 -13
  166. package/dist/engines/pi/tool-context.js +51 -4
  167. package/dist/engines/pi/tool.d.ts +5 -5
  168. package/dist/engines/pi/tool.js +3 -3
  169. package/dist/engines/pi/turn-kit.d.ts +32 -6
  170. package/dist/engines/pi/turn-kit.js +28 -1
  171. package/dist/engines/pi/wake-tool.js +1 -1
  172. package/dist/env.js +1 -2
  173. package/dist/index.d.ts +1 -0
  174. package/dist/index.js +1 -0
  175. package/dist/loader.d.ts +49 -7
  176. package/dist/loader.js +79 -18
  177. package/dist/log.d.ts +3 -0
  178. package/dist/log.js +5 -1
  179. package/dist/node.d.ts +16 -0
  180. package/dist/node.js +20 -0
  181. package/dist/paths.d.ts +26 -0
  182. package/dist/paths.js +37 -2
  183. package/dist/pi.d.ts +14 -8
  184. package/dist/pi.js +6 -5
  185. package/dist/scaffold/add-channel.js +2 -2
  186. package/dist/scaffold/init.js +7 -2
  187. package/dist/scaffold/templates/persona.md +2 -2
  188. package/dist/scaffold/templates/tools/fetch-url.ts +0 -2
  189. package/dist/schedule/discover.js +3 -15
  190. package/dist/schedule/state.js +4 -9
  191. package/dist/schedule/wake-alarm.d.ts +13 -7
  192. package/dist/schedule/wake-alarm.js +88 -46
  193. package/dist/schedule/wakeups.d.ts +2 -2
  194. package/dist/schedule/wakeups.js +11 -8
  195. package/dist/service.d.ts +157 -0
  196. package/dist/service.js +376 -0
  197. package/dist/session-remote.d.ts +8 -3
  198. package/dist/session-remote.js +162 -92
  199. package/dist/session.d.ts +229 -93
  200. package/dist/session.js +61 -23
  201. package/dist/telegram.d.ts +1 -1
  202. package/dist/telegram.js +1 -1
  203. package/dist/tunnel.d.ts +18 -3
  204. package/dist/tunnel.js +32 -40
  205. package/package.json +14 -5
  206. package/dist/engines/pi/channel.d.ts +0 -37
  207. package/dist/engines/pi/harness.d.ts +0 -115
  208. package/dist/engines/pi/harness.js +0 -154
  209. package/dist/engines/pi/invoke.d.ts +0 -60
  210. package/dist/engines/pi/invoke.js +0 -459
  211. package/dist/engines/pi/read-image.d.ts +0 -4
  212. package/dist/engines/pi/read-image.js +0 -62
  213. package/dist/engines/pi/sessions.d.ts +0 -60
  214. package/dist/engines/pi/sessions.js +0 -464
  215. package/dist/host/node.js +0 -52
  216. /package/dist/channels/{preview-kit.js → kit/preview-kit.js} +0 -0
  217. /package/dist/channels/{seen.d.ts → kit/seen.d.ts} +0 -0
  218. /package/dist/channels/{state.d.ts → kit/state.d.ts} +0 -0
  219. /package/dist/channels/{text.d.ts → kit/text.d.ts} +0 -0
  220. /package/dist/channels/{text.js → kit/text.js} +0 -0
  221. /package/dist/channels/{thread-participants.d.ts → kit/thread-participants.d.ts} +0 -0
  222. /package/dist/channels/{turn-queue.d.ts → kit/turn-queue.d.ts} +0 -0
@@ -1,35 +1,100 @@
1
- import { SESSION_BUSY_CODE } from "../../agent.js";
1
+ import { ABORTED_CODE, SESSION_BUSY_CODE, } from "../../agent.js";
2
2
  import { cancellableStream } from "../../collect.js";
3
3
  import { log } from "../../log.js";
4
- import { EventQueue, errorToTerminal, inProcessLease, toPiPromptOptions, toTerminal } from "./turn-kit.js";
4
+ import { EventQueue, errorToTerminal, inProcessLease, projectAgentEvent, toPiPromptOptions, toTerminal, } from "./turn-kit.js";
5
5
  /**
6
- * SPEC events from pi's session stream. `auto_retry_start` has no harness counterpart: an
7
- * `AgentSession` retries a failed assistant request itself, which the SPEC already has a word for.
6
+ * pi session events into the rich `SessionEvent` vocabulary the SINGLE translation point. Events
7
+ * with no vocabulary yet (agent_start, turn_start, entry_appended, …) are dropped.
8
+ *
9
+ * `auto_retry_start` reports as a `retry_scheduled` with `operation: "assistant"`: pi retries a
10
+ * failed ANSWER request itself, which the summarization-only cases of that vocabulary predate.
8
11
  */
9
- function toAgentEvent(event) {
12
+ function toSessionEvent(event, runId) {
13
+ const at = Date.now();
10
14
  switch (event.type) {
15
+ case "message_start":
16
+ // Assistant streaming only — a user/toolResult message is not a live message boundary.
17
+ if (event.message.role !== "assistant")
18
+ return null;
19
+ return { type: "message_started", timestamp: at, runId, data: {} };
11
20
  case "message_update": {
12
21
  // An empty delta is not output: it moves no consumer's state, and treating it as output would
13
22
  // spend the silent window that auto-retry is allowed to use (see runOnSession).
14
23
  const ev = event.assistantMessageEvent;
15
- if (ev.type === "text_delta")
16
- return ev.delta === "" ? null : { type: "text", delta: ev.delta };
17
- if (ev.type === "thinking_delta")
18
- return ev.delta === "" ? null : { type: "thinking", delta: ev.delta };
24
+ if (ev.type === "text_delta") {
25
+ return ev.delta === ""
26
+ ? null
27
+ : { type: "message_delta", timestamp: at, runId, data: { channel: "text", delta: ev.delta } };
28
+ }
29
+ if (ev.type === "thinking_delta") {
30
+ return ev.delta === ""
31
+ ? null
32
+ : { type: "message_delta", timestamp: at, runId, data: { channel: "thinking", delta: ev.delta } };
33
+ }
19
34
  return null;
20
35
  }
36
+ case "message_end":
37
+ if (event.message.role !== "assistant")
38
+ return null;
39
+ return { type: "message_finished", timestamp: at, runId, data: {} };
21
40
  case "tool_execution_start":
22
- return { type: "tool_started", id: event.toolCallId, name: event.toolName, args: event.args };
41
+ return {
42
+ type: "tool_started",
43
+ timestamp: at,
44
+ runId,
45
+ data: { id: event.toolCallId, name: event.toolName, args: event.args },
46
+ };
47
+ case "tool_execution_update":
48
+ return {
49
+ type: "tool_progress",
50
+ timestamp: at,
51
+ runId,
52
+ data: { id: event.toolCallId, name: event.toolName, partialResult: event.partialResult },
53
+ };
23
54
  case "tool_execution_end":
24
- return { type: "tool_ended", id: event.toolCallId, isError: event.isError, content: event.result };
25
- case "auto_retry_start":
26
55
  return {
27
- type: "retrying",
28
- attempt: event.attempt,
29
- maxAttempts: event.maxAttempts,
30
- delayMs: event.delayMs,
31
- reason: event.errorMessage,
56
+ type: "tool_finished",
57
+ timestamp: at,
58
+ runId,
59
+ data: { id: event.toolCallId, isError: event.isError, content: event.result },
60
+ };
61
+ case "queue_update":
62
+ return {
63
+ type: "queue_changed",
64
+ timestamp: at,
65
+ runId,
66
+ data: { steering: event.steering.length, followUp: event.followUp.length },
32
67
  };
68
+ case "auto_retry_start": {
69
+ const retry = {
70
+ type: "retry_scheduled",
71
+ timestamp: at,
72
+ runId,
73
+ data: {
74
+ operation: "assistant",
75
+ attempt: event.attempt,
76
+ maxAttempts: event.maxAttempts,
77
+ delayMs: event.delayMs,
78
+ error: event.errorMessage,
79
+ },
80
+ };
81
+ return retry;
82
+ }
83
+ case "summarization_retry_scheduled": {
84
+ const retry = {
85
+ type: "retry_scheduled",
86
+ timestamp: at,
87
+ runId,
88
+ data: {
89
+ operation: "compaction",
90
+ attempt: event.attempt,
91
+ maxAttempts: event.maxAttempts,
92
+ delayMs: event.delayMs,
93
+ error: event.errorMessage,
94
+ },
95
+ };
96
+ return retry;
97
+ }
33
98
  default:
34
99
  return null;
35
100
  }
@@ -37,7 +102,7 @@ function toAgentEvent(event) {
37
102
  /**
38
103
  * The turn's outcome. `prompt()` resolves void and never throws for an engine-side failure (measured:
39
104
  * a provider error and an abort both resolve normally), so the terminal comes from the assistant
40
- * message the run ended on — the same read {@link toTerminal} performs on the harness path.
105
+ * message the run ended on.
41
106
  *
42
107
  * That message is taken from the EVENT STREAM, not from `session.state.messages`. Session state is
43
108
  * mutable mid-turn: compaction replaces the array, and overflow recovery splices the last assistant
@@ -54,11 +119,11 @@ const ENGINE_PRODUCED_NOTHING = {
54
119
  retryable: false,
55
120
  };
56
121
  export function createPiAgentFromSession(options) {
57
- const { sessionFactory, lease = inProcessLease() } = options;
58
- /** Own the session's lifetime: one writer, built here, disposed here whatever the turn did. */
59
- async function* turn(scope, prompt, hooks) {
122
+ const { sessionFactory, lease = inProcessLease(), observer } = options;
123
+ async function* turn(scope, prompt, { onCancelReady, wasCancelled }) {
60
124
  const release = lease.tryAcquire(scope.session);
61
125
  if (!release) {
126
+ // Rejected BEFORE acceptance: no run exists, so the observer sees nothing (replay-safe).
62
127
  yield {
63
128
  type: "failed",
64
129
  details: "session busy: a turn is already in flight for this session",
@@ -67,19 +132,213 @@ export function createPiAgentFromSession(options) {
67
132
  };
68
133
  return;
69
134
  }
135
+ // The run exists from here: exactly one run_started, exactly one run_settled. The settlement is
136
+ // emitted in the outer finally, immediately before the lease releases, so the observation
137
+ // plane's "running" window equals the lease window — state() must never read idle while a new
138
+ // invoke would still be rejected session_busy. A run with no recorded outcome was cancelled by
139
+ // the caller (SPEC: cancellation has no terminal event), which settles as aborted.
140
+ const runId = crypto.randomUUID();
141
+ let outcome;
142
+ const observe = (event, run) => {
143
+ if (!event || !observer)
144
+ return;
145
+ try {
146
+ observer(scope.session, event, run);
147
+ }
148
+ catch (error) {
149
+ // The observation plane must never break the data plane; a broken hub is its own problem.
150
+ log.warn(`[fastagent] session observer threw (event ${event.type}): ${String(error)}`);
151
+ }
152
+ };
153
+ // run_started is published BEFORE the session is built, so no early event can outrun the
154
+ // registration — which means the controls have to await the build rather than reject during it:
155
+ // a dispatch that races it simply queues on the freshly bound session. A build failure rejects
156
+ // the gate, so a pending dispatch learns why instead of hanging.
157
+ let sessionReady;
158
+ let sessionFailed;
159
+ const bound = new Promise((resolve, reject) => {
160
+ sessionReady = resolve;
161
+ sessionFailed = reject;
162
+ });
163
+ bound.catch(() => { }); // observed through the controls only when a dispatch actually happens
164
+ // Stale-controls guard: after settlement pi's steer()/followUp()/abort() would still resolve
165
+ // (they queue onto a session about to be disposed), which is a silent acceptance of a command
166
+ // that can never take effect. The check and the engine call share one synchronous block — pi
167
+ // enqueues at method entry, so a check behind its own await would only shrink the race.
168
+ let settled = false;
169
+ const settledError = () => new Error("run already settled; the command cannot take effect");
170
+ // Aborted classification has two sources, either sufficient: pi's own stopReason "aborted", and
171
+ // control-plane INTENT — providers do not uniformly attribute an aborted stream, so an abort
172
+ // that was still in flight when the terminal arrived counts too.
173
+ let abortsInFlight = 0;
174
+ let abortSucceeded = false;
175
+ const controls = {
176
+ async steer(p) {
177
+ const opts = await toPiPromptOptions(p);
178
+ const session = await bound;
179
+ if (settled)
180
+ throw settledError();
181
+ await session.steer(p.text, opts?.images);
182
+ },
183
+ async followUp(p) {
184
+ const opts = await toPiPromptOptions(p);
185
+ const session = await bound;
186
+ if (settled)
187
+ throw settledError();
188
+ await session.followUp(p.text, opts?.images);
189
+ },
190
+ async abort() {
191
+ const session = await bound;
192
+ if (settled)
193
+ throw settledError();
194
+ abortsInFlight++;
195
+ try {
196
+ await session.abort();
197
+ abortSucceeded = true;
198
+ }
199
+ finally {
200
+ abortsInFlight--;
201
+ }
202
+ },
203
+ };
204
+ observe({ type: "run_started", timestamp: Date.now(), runId, data: {} }, controls);
70
205
  try {
71
206
  let session;
72
207
  try {
73
- session = await sessionFactory(scope.session);
208
+ // The scope's lineage reaches the store's CREATE path only — an existing session opens
209
+ // exactly as before, whatever the scope names.
210
+ session = await sessionFactory(scope.session, scope.parentSession === undefined
211
+ ? undefined
212
+ : {
213
+ parentSession: scope.parentSession,
214
+ ...(scope.branchHints !== undefined ? { branchHints: scope.branchHints } : {}),
215
+ });
216
+ sessionReady(session);
74
217
  }
75
218
  catch (error) {
76
- yield errorToTerminal(error); // setup failures are events, never throws (MUST 2)
219
+ // Setup failures (session open, auth, a broken definition) are EVENTS, never throws
220
+ // (MUST 2) — and they settle the run as failed: an unrecorded outcome means the caller
221
+ // cancelled, which this is not.
222
+ sessionFailed(error); // a pending dispatch learns the run cannot take commands
223
+ const terminal = errorToTerminal(error);
224
+ outcome = { status: "failed", error: { message: terminal.details, retryable: terminal.retryable } };
225
+ settled = true;
226
+ yield terminal;
77
227
  return;
78
228
  }
79
229
  try {
80
- yield* runOnSession(session, prompt, hooks);
230
+ const abort = () => session.abort().catch(() => { });
231
+ onCancelReady(() => void abort());
232
+ const queue = new EventQueue();
233
+ let finalAssistant;
234
+ /** Whether any of THIS attempt's answer has been streamed — the only output a retry duplicates. */
235
+ let streamedAnswer = false;
236
+ /** Set when a retry is refused because the answer already streamed — carries the ending error. */
237
+ let retriedAfterAnswer;
238
+ const unsub = session.subscribe((event) => {
239
+ if (retriedAfterAnswer !== undefined)
240
+ return; // decided; the retry's output is not ours
241
+ if (event.type === "message_end" && event.message.role === "assistant") {
242
+ finalAssistant = event.message;
243
+ }
244
+ // pi retries a failed assistant request by DISCARDING that attempt's assistant message and
245
+ // asking again. Everything the turn achieved before it survives — executed tools keep
246
+ // their persisted results, and the retry resumes from them — so the only thing a retry can
247
+ // duplicate is answer text already streamed, which SPEC deltas cannot retract. Refuse it
248
+ // exactly there: refusing on tool events instead would push the retry out to the CALLER,
249
+ // who can only re-run the whole prompt and execute the tool a second time.
250
+ if (event.type === "auto_retry_start" && streamedAnswer) {
251
+ retriedAfterAnswer = event.errorMessage;
252
+ // Not synchronously: pi emits this event BEFORE creating the controller that makes its
253
+ // backoff abortable, so an abort from inside the listener would find nothing to cancel
254
+ // and the turn would still pay the delay and burn a provider call on a discarded answer.
255
+ queueMicrotask(() => void abort());
256
+ return;
257
+ }
258
+ const rich = toSessionEvent(event, runId);
259
+ if (!rich)
260
+ return;
261
+ observe(rich);
262
+ const projected = projectAgentEvent(rich);
263
+ if (!projected)
264
+ return;
265
+ if (projected.type === "text" || projected.type === "thinking")
266
+ streamedAnswer = true;
267
+ queue.push(projected);
268
+ });
269
+ try {
270
+ // Resolving prompt options lazy-loads the image pipeline and re-encodes every attachment,
271
+ // so it both takes time and can throw before any engine work exists to fail. Hence the two
272
+ // guards, in this order and no earlier: its failure is a turn failure (MUST 2), and the
273
+ // latch has to be read after the LAST await before the call — the door armed above only
274
+ // stops a RUNNING session, so a consumer who walked away during the build or the resize
275
+ // would knock on an idle one and have the turn start anyway.
276
+ let promptOptions;
277
+ try {
278
+ promptOptions = await toPiPromptOptions(prompt);
279
+ }
280
+ catch (error) {
281
+ const terminal = errorToTerminal(error);
282
+ outcome = { status: "failed", error: { message: terminal.details, retryable: terminal.retryable } };
283
+ settled = true;
284
+ yield terminal;
285
+ return;
286
+ }
287
+ if (wasCancelled()) {
288
+ settled = true;
289
+ await abort();
290
+ return; // cancelled: the outer finally settles it as aborted
291
+ }
292
+ const run = session.prompt(prompt.text, promptOptions);
293
+ yield* queue.drainUntil(run);
294
+ let terminal;
295
+ try {
296
+ await run;
297
+ terminal =
298
+ retriedAfterAnswer !== undefined
299
+ ? { type: "failed", details: retriedAfterAnswer, retryable: true }
300
+ : finalAssistant
301
+ ? toTerminal(finalAssistant)
302
+ : ENGINE_PRODUCED_NOTHING;
303
+ }
304
+ catch (error) {
305
+ terminal = errorToTerminal(error);
306
+ }
307
+ if ((abortSucceeded || abortsInFlight > 0) && terminal.type === "failed") {
308
+ terminal = { type: "failed", details: terminal.details, retryable: false, code: ABORTED_CODE };
309
+ }
310
+ if (terminal.type === "failed") {
311
+ outcome =
312
+ terminal.code === ABORTED_CODE
313
+ ? // Carry the detail: an independent error that raced an accepted abort must stay
314
+ // diagnosable in the settlement, which is what audit consumers read.
315
+ { status: "aborted", error: { message: terminal.details, retryable: false } }
316
+ : {
317
+ status: "failed",
318
+ error: { code: terminal.code, message: terminal.details, retryable: terminal.retryable },
319
+ };
320
+ }
321
+ else {
322
+ outcome = { status: "completed" };
323
+ }
324
+ // Commands become ineffective the moment the run resolved — not at the outer finally,
325
+ // which sits behind a consumer-paced `yield`.
326
+ settled = true;
327
+ yield terminal;
328
+ }
329
+ finally {
330
+ settled = true;
331
+ unsub();
332
+ }
81
333
  }
82
334
  finally {
335
+ // NO session_shutdown here, deliberately. A per-invoke session makes one look right, but the
336
+ // extension INSTANCE it would tear down is not per-invoke: extensions belong to the agent's
337
+ // assembly and every turn shares one. Emitting a shutdown per turn had a finished
338
+ // turn clearing a timer a concurrent turn had just opened (measured, and pinned in
339
+ // definition-extensions.test.ts). The lifecycle has to match the instance, not the session
340
+ // wrapper: one agent, one instance, no per-turn teardown. Extensions that need per-turn
341
+ // cleanup do it in the tool or handler that opened the resource.
83
342
  try {
84
343
  session.dispose();
85
344
  }
@@ -89,83 +348,10 @@ export function createPiAgentFromSession(options) {
89
348
  }
90
349
  }
91
350
  finally {
92
- release();
351
+ settled = true;
352
+ observe({ type: "run_settled", timestamp: Date.now(), runId, data: outcome ?? { status: "aborted" } });
353
+ release(); // after the settlement, so the next invoke for this session cannot outrun it
93
354
  }
94
355
  }
95
356
  return { invoke: (scope, prompt) => cancellableStream((hooks) => turn(scope, prompt, hooks)) };
96
357
  }
97
- /** One turn on a session someone else owns: subscribe, prompt, stream, terminal. */
98
- async function* runOnSession(session, prompt, { onCancelReady, wasCancelled }) {
99
- const abort = () => session.abort().catch(() => { });
100
- onCancelReady(() => void abort());
101
- const queue = new EventQueue();
102
- let finalAssistant;
103
- /** Whether any of THIS attempt's answer has been streamed — the only output a retry would duplicate. */
104
- let streamedAnswer = false;
105
- /** Set when a retry is refused because the answer already streamed — carries the error that ends it. */
106
- let retriedAfterAnswer;
107
- const unsub = session.subscribe((event) => {
108
- if (retriedAfterAnswer !== undefined)
109
- return; // the turn is decided; the retry's output is not ours
110
- if (event.type === "message_end" && event.message.role === "assistant") {
111
- finalAssistant = event.message;
112
- }
113
- // pi retries a failed assistant request by DISCARDING that attempt's assistant message and asking
114
- // again. Everything the turn achieved before it survives - executed tools keep their persisted
115
- // results, and the retry resumes from them - so the only thing a retry can duplicate is answer
116
- // text this L0 already streamed, which SPEC deltas cannot retract. Refuse it exactly there:
117
- // refusing on tool events instead would push the retry out to the CALLER, who can only re-run the
118
- // whole prompt and execute the tool a second time.
119
- if (event.type === "auto_retry_start" && streamedAnswer) {
120
- retriedAfterAnswer = event.errorMessage;
121
- // Not synchronously: pi emits this event BEFORE creating the controller that makes its backoff
122
- // abortable, so an abort from inside the listener would find nothing to cancel and the turn
123
- // would still pay the full delay and burn a provider call on an answer we must discard.
124
- queueMicrotask(() => void abort());
125
- return;
126
- }
127
- const projected = toAgentEvent(event);
128
- if (!projected)
129
- return;
130
- if (projected.type === "text" || projected.type === "thinking")
131
- streamedAnswer = true;
132
- queue.push(projected);
133
- });
134
- try {
135
- // Resolving prompt options lazy-loads the image pipeline and re-encodes every attachment, so it
136
- // both takes time and can throw before any engine work exists to fail. Hence the two guards, in
137
- // this order and no earlier: its failure is a turn failure (MUST 2), and the latch has to be read
138
- // after the LAST await before the call — the door armed above only stops a RUNNING session, so a
139
- // consumer who walked away during the build or the resize would knock on an idle one and have
140
- // the turn start anyway.
141
- let options;
142
- try {
143
- options = await toPiPromptOptions(prompt);
144
- }
145
- catch (error) {
146
- yield errorToTerminal(error);
147
- return;
148
- }
149
- if (wasCancelled()) {
150
- await abort();
151
- return;
152
- }
153
- const run = session.prompt(prompt.text, options);
154
- yield* queue.drainUntil(run);
155
- try {
156
- await run;
157
- if (retriedAfterAnswer !== undefined) {
158
- yield { type: "failed", details: retriedAfterAnswer, retryable: true };
159
- }
160
- else {
161
- yield finalAssistant ? toTerminal(finalAssistant) : ENGINE_PRODUCED_NOTHING;
162
- }
163
- }
164
- catch (error) {
165
- yield errorToTerminal(error);
166
- }
167
- }
168
- finally {
169
- unsub();
170
- }
171
- }
@@ -18,15 +18,30 @@ function anySignal(...signals) {
18
18
  return present.length === 0 ? undefined : present.length === 1 ? present[0] : AbortSignal.any(present);
19
19
  }
20
20
  /**
21
- * Map pi-ai's `AuthInteraction` onto the injected {@link LoginIO}. `doneSignal` fires when the flow
22
- * resolves, cancelling a prompt the provider left pending (a manual-code paste racing a callback
23
- * server it just won) so the one-shot CLI exits instead of hanging on stdin.
21
+ * Map pi-ai's `ProviderAuthInteraction` onto the injected {@link LoginIO}. `doneSignal` fires when
22
+ * the flow resolves, cancelling a prompt the provider left pending (a manual-code paste racing a
23
+ * callback server it just won) so the one-shot CLI exits instead of hanging on stdin.
24
+ *
25
+ * The signal is REQUIRED by that type (pi normalizes it before calling a provider), so a caller that
26
+ * passes none gets one that never fires — the same "no cancellation" the optional field meant.
24
27
  */
25
28
  function authCallbacks(io, userSignal, doneSignal) {
26
29
  return {
27
- signal: userSignal,
30
+ signal: userSignal ?? new AbortController().signal,
28
31
  prompt: async (p) => {
29
32
  if (p.type === "select") {
33
+ // This branch is UNCANCELLABLE: `LoginIO.select` takes no signal, so all three the line
34
+ // below composes — the provider's own, the caller's `loginFlow({ signal })`, and the
35
+ // pending-prompt backstop — are dropped here.
36
+ //
37
+ // It costs nothing against pi 0.84's providers, where every `select` is the FIRST call of
38
+ // `login()` (bedrock, vertex, openai-codex, radius — all of them asking which login method
39
+ // to use). Nothing is racing it: no callback server is up yet, and `doneSignal` fires when
40
+ // `auth.login()` RETURNS, which it cannot do while blocked on this await.
41
+ //
42
+ // That is a property of the providers, not a guarantee, and nothing here holds it. A
43
+ // provider that ever issues a `select` after starting its callback server shows up as the
44
+ // CLI parked on stdin — widen `LoginIO.select` with a signal then.
30
45
  const v = await io.select(p.message, p.options.map((o) => ({ value: o.id, label: o.label, hint: o.description })));
31
46
  if (v === undefined)
32
47
  throw new LoginCancelled("cancelled");
@@ -91,9 +106,14 @@ async function selectProvider(io, providers, method, store) {
91
106
  return { value: p.id, label: auth?.name ?? p.name, hint: cred ? `configured (${cred.type})` : undefined };
92
107
  }));
93
108
  const id = await io.select("Select a provider", options);
94
- if (!id)
109
+ // Answers the PROVIDER, not its id: the caller needs the object, and resolving it here means the
110
+ // one place that can fail to is the one that just offered the list. Cancel and an id that was
111
+ // never offered are the same answer — nothing was chosen — and `find` gives both, since a
112
+ // provider id is always a string and `undefined` matches none of them.
113
+ const chosen = candidates.find((p) => p.id === id);
114
+ if (!chosen)
95
115
  throw new LoginCancelled("no provider selected");
96
- return id;
116
+ return chosen;
97
117
  }
98
118
  /**
99
119
  * Resolve method + provider (asking only what is not given), run the login flow, and persist. A no-op
@@ -103,26 +123,25 @@ export async function loginFlow(io, options = {}) {
103
123
  const store = options.store ?? fastagentCredentialStore(options.authPath ?? GLOBAL_AUTH_PATH);
104
124
  const providers = options.providers ?? builtinProviders();
105
125
  let method;
106
- let providerId;
126
+ let provider;
107
127
  if (options.provider) {
108
- providerId = options.provider;
109
- const p = providers.find((x) => x.id === providerId);
110
- if (!p)
111
- throw new Error(`unknown provider "${providerId}"`);
112
- method = options.method ?? (await methodForProvider(io, p));
128
+ const named = providers.find((x) => x.id === options.provider);
129
+ if (!named)
130
+ throw new Error(`unknown provider "${options.provider}"`);
131
+ provider = named;
132
+ method = options.method ?? (await methodForProvider(io, provider));
113
133
  }
114
134
  else {
115
135
  method = options.method ?? (await selectMethod(io));
116
- providerId = await selectProvider(io, providers, method, store);
136
+ provider = await selectProvider(io, providers, method, store);
117
137
  }
118
138
  // Preflight: a no-op modify runs the refuse-corrupt / writability check BEFORE the flow.
119
- await store.modify(providerId, async () => undefined);
120
- const provider = providers.find((p) => p.id === providerId);
121
- if (!provider)
122
- throw new Error(`unknown provider "${providerId}"`);
139
+ await store.modify(provider.id, async () => undefined);
140
+ // Reachable even though both branches above resolved a provider: an explicit `method` bypasses
141
+ // methodForProvider, so `{ provider: "openai-codex", method: "api_key" }` arrives here.
123
142
  const auth = method === "oauth" ? provider.auth.oauth : provider.auth.apiKey;
124
143
  if (!auth?.login)
125
- throw new Error(`provider "${providerId}" has no ${method} login`);
144
+ throw new Error(`provider "${provider.id}" has no ${method} login`);
126
145
  // `done` cancels any prompt left pending when login resolves (manual-code race backstop).
127
146
  const done = new AbortController();
128
147
  let credential;
@@ -132,6 +151,6 @@ export async function loginFlow(io, options = {}) {
132
151
  finally {
133
152
  done.abort();
134
153
  }
135
- await store.modify(providerId, async () => credential);
136
- return { provider: providerId, method };
154
+ await store.modify(provider.id, async () => credential);
155
+ return { provider: provider.id, method };
137
156
  }
@@ -1,3 +1,4 @@
1
+ import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
1
2
  import { type Api, type Model, type Models, type Provider } from "@earendil-works/pi-ai";
2
3
  import { ModelRuntime } from "@earendil-works/pi-coding-agent";
3
4
  import { type FastagentAuthOptions } from "./auth.ts";
@@ -26,6 +27,19 @@ export interface CreatePiModelsOptions extends FastagentAuthOptions {
26
27
  * is upstream-owned).
27
28
  */
28
29
  export declare function createPiModels(options?: CreatePiModelsOptions): Models;
30
+ /**
31
+ * pi's Model with the API-shape generic erased — fastagent only passes models through to the engine,
32
+ * so the generic carries no information. One alias keeps the `any` auditable.
33
+ */
34
+ export type AnyModel = Model<any>;
35
+ /**
36
+ * The serving default for reasoning effort, pinned to what pi's TUI defaults to (its own
37
+ * DEFAULT_THINKING_LEVEL) — NOT inherited from the engine, whose fallback is "off": an author vibes
38
+ * at "medium" in pi and must get "medium" when served (fidelity), and pinning the value here means
39
+ * an upstream default change in either place cannot silently alter deployments. Models that do not
40
+ * support a level are clamped by pi per model.
41
+ */
42
+ export declare const DEFAULT_THINKING_LEVEL: ThinkingLevel;
29
43
  /**
30
44
  * The `ModelRuntime`-shaped sibling of {@link createPiModels} — the SAME hub semantics (built-in
31
45
  * providers + fastagent's credential store at `authPath`) in the type pi's session services require
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * The pi `Models` collection — the single hub that owns BOTH model resolution (provider/modelId
3
3
  * lookup) AND auth (per-request credential resolution). fastagent builds one per opener and threads
4
- * it into the harness alongside the selected `model`; the two must come from the same collection so
4
+ * it into the engine alongside the selected `model`; the two must come from the same collection so
5
5
  * the model's provider auth is in scope.
6
6
  */
7
7
  import { join } from "node:path";
@@ -28,6 +28,14 @@ export function createPiModels(options = {}) {
28
28
  models.setProvider(provider);
29
29
  return models;
30
30
  }
31
+ /**
32
+ * The serving default for reasoning effort, pinned to what pi's TUI defaults to (its own
33
+ * DEFAULT_THINKING_LEVEL) — NOT inherited from the engine, whose fallback is "off": an author vibes
34
+ * at "medium" in pi and must get "medium" when served (fidelity), and pinning the value here means
35
+ * an upstream default change in either place cannot silently alter deployments. Models that do not
36
+ * support a level are clamped by pi per model.
37
+ */
38
+ export const DEFAULT_THINKING_LEVEL = "medium";
31
39
  /**
32
40
  * The `ModelRuntime`-shaped sibling of {@link createPiModels} — the SAME hub semantics (built-in
33
41
  * providers + fastagent's credential store at `authPath`) in the type pi's session services require
@@ -1,12 +1,11 @@
1
1
  import type { Agent } from "../../agent.ts";
2
2
  import { type FastagentConfig } from "./config.ts";
3
3
  import type { SessionControl } from "../../session.ts";
4
- import type { SessionObserver } from "./invoke.ts";
5
- import type { PiSessionReader, PiSessionStore } from "./sessions.ts";
4
+ import type { SessionObserver } from "./turn-kit.ts";
6
5
  import type { ModuleLoadFailure } from "../../loader.ts";
7
6
  import { type LoadedDefinition } from "./definition.ts";
8
- import type { ToolCollision } from "./tool.ts";
9
- import type { MountedTool } from "./tool.ts";
7
+ import { type PiSessionRecordStore } from "./session-store.ts";
8
+ import type { ToolCollision, MountedTool } from "./tool.ts";
10
9
  export interface CreatePiAgentFromDirOptions {
11
10
  /** Model spec override (e.g. the CLI --model flag). Precedence: this > FASTAGENT_MODEL > config.model. */
12
11
  model?: string;
@@ -44,7 +43,7 @@ export interface CreatePiAgentFromDirOptions {
44
43
  }
45
44
  /**
46
45
  * The agent assembly FRONT HALF — everything that is independent of how pi consumes the
47
- * definition (transient harness for serving vs resident AgentSession for chat / session control):
46
+ * definition (a per-invoke session for serving vs a resident one for chat):
48
47
  * placement resolution → config → model spec → the full tool surface ({@link resolveAgentTools} — the
49
48
  * ONE place it is computed) → state root → auth path. Both {@link createPiAgentFromDir} and the
50
49
  * session builder (session-builder.ts) consume this, so THESE inputs cannot drift between the two
@@ -65,7 +64,7 @@ export interface AgentAssembly {
65
64
  stateRoot: string;
66
65
  /** Absolute credentials file (--auth-path/authPath option > FASTAGENT_AUTH_PATH > <agentDir>/.secrets/auth.json). */
67
66
  authPath: string;
68
- /** The full mounted tool surface (config.tools + discovered tools/, search_tools applied). */
67
+ /** The full mounted tool surface (all coding tools + config.tools + discovered tools/, search_tools applied). */
69
68
  tools: MountedTool[];
70
69
  toolNames: string[];
71
70
  deferredToolNames: string[];
@@ -99,10 +98,13 @@ export declare function createPiAgentFromDir(dir: string, options?: CreatePiAgen
99
98
  sessionsDir: string;
100
99
  /** Absolute credentials file in use (for the startup report). */
101
100
  authPath: string;
102
- /** The session store in use — also a {@link PiSessionReader}. */
103
- sessions: PiSessionStore & PiSessionReader;
101
+ /** The session store in use. */
102
+ sessions: PiSessionRecordStore;
104
103
  /** The observation plane over this agent's sessions; present iff `options.sessionControl`. */
105
104
  sessionControl?: SessionControl;
105
+ /** Whether the agent schedules its own follow-up turns — read from the config, so a caller
106
+ * assembling a service does not have to reach back into it (MountableAgent). */
107
+ selfSchedule: boolean;
106
108
  /** Non-default, active-by-default tool names in effect: config.tools + discovered tools/. Each name
107
109
  * lives in exactly one report slot — deferred names are in {@link deferredToolNames} instead. */
108
110
  toolNames: string[];