@fastagent-sh/fastagent 0.23.0 → 0.24.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 (51) hide show
  1. package/dist/agent.d.ts +10 -0
  2. package/dist/channels/kit/preview-kit.d.ts +6 -1
  3. package/dist/channels/kit/preview-kit.js +21 -4
  4. package/dist/channels/slack/preview.js +2 -1
  5. package/dist/cli/commands/info.js +7 -7
  6. package/dist/cli/commands/login.js +2 -2
  7. package/dist/cli/commands/models.js +3 -3
  8. package/dist/cli/invoke-stream.js +14 -0
  9. package/dist/cli/shared.d.ts +2 -1
  10. package/dist/cli/shared.js +6 -7
  11. package/dist/deploy/preflight.js +19 -1
  12. package/dist/dev-supervisor.js +2 -2
  13. package/dist/engines/pi/agent-models.d.ts +14 -1
  14. package/dist/engines/pi/agent-models.js +35 -8
  15. package/dist/engines/pi/agent-session-factory.d.ts +13 -13
  16. package/dist/engines/pi/agent-session-factory.js +132 -159
  17. package/dist/engines/pi/auth.js +6 -94
  18. package/dist/engines/pi/create.d.ts +16 -9
  19. package/dist/engines/pi/create.js +71 -38
  20. package/dist/engines/pi/invoke-session.js +82 -16
  21. package/dist/engines/pi/locked-file.d.ts +9 -0
  22. package/dist/engines/pi/locked-file.js +102 -0
  23. package/dist/engines/pi/login.d.ts +7 -1
  24. package/dist/engines/pi/login.js +22 -6
  25. package/dist/engines/pi/machine.d.ts +23 -0
  26. package/dist/engines/pi/machine.js +24 -2
  27. package/dist/engines/pi/models.d.ts +47 -42
  28. package/dist/engines/pi/models.js +147 -67
  29. package/dist/engines/pi/open.d.ts +25 -11
  30. package/dist/engines/pi/open.js +42 -20
  31. package/dist/engines/pi/report.d.ts +3 -0
  32. package/dist/engines/pi/report.js +11 -0
  33. package/dist/engines/pi/session-builder.js +6 -22
  34. package/dist/engines/pi/session-control.d.ts +4 -4
  35. package/dist/engines/pi/session-control.js +72 -40
  36. package/dist/engines/pi/session-settings.d.ts +9 -0
  37. package/dist/engines/pi/session-settings.js +17 -0
  38. package/dist/engines/pi/session-store.d.ts +2 -2
  39. package/dist/engines/pi/tool-context.d.ts +3 -5
  40. package/dist/engines/pi/tool-context.js +4 -16
  41. package/dist/engines/pi/tool.d.ts +32 -21
  42. package/dist/engines/pi/tool.js +21 -25
  43. package/dist/engines/pi/turn-kit.d.ts +18 -9
  44. package/dist/engines/pi/turn-kit.js +104 -27
  45. package/dist/pi.d.ts +1 -1
  46. package/dist/pi.js +1 -1
  47. package/dist/session.d.ts +60 -8
  48. package/dist/session.js +5 -1
  49. package/package.json +5 -5
  50. package/dist/engines/pi/search-tools.d.ts +0 -4
  51. package/dist/engines/pi/search-tools.js +0 -109
@@ -7,11 +7,12 @@ import { NodeExecutionEnv } from "@earendil-works/pi-agent-core/node";
7
7
  import { createCodingTools, createPowerShellTool, createReadOnlyTools, } from "@earendil-works/pi-coding-agent";
8
8
  import { resolveModel } from "./config.js";
9
9
  import { isAgentcoreRuntime, isDeployedWorkspace } from "../../paths.js";
10
- import { loadAgentDefinition, loadExtensionPaths } from "./definition.js";
10
+ import { loadAgentDefinition } from "./definition.js";
11
11
  import { reportFindingsIfChanged } from "./report.js";
12
- import { isDeferredTool, loadTools, mergeDiscoveredTools, } from "./tool.js";
12
+ import { log } from "../../log.js";
13
+ import { BUILTIN_EXTENSIONS, DISCOVERY, readMachine } from "./machine.js";
14
+ import { loadTools, mergeDiscoveredTools } from "./tool.js";
13
15
  import { readSecretDeclaration } from "../../declared-secrets.js";
14
- import { withSearchTool } from "./search-tools.js";
15
16
  import { createPiAgentFromSession } from "./invoke-session.js";
16
17
  import { piAgentSessionFactory } from "./agent-session-factory.js";
17
18
  import { DEFAULT_THINKING_LEVEL } from "./models.js";
@@ -57,6 +58,32 @@ function omittedBuiltinNames(mounted, cwd) {
57
58
  const mountedNames = new Set(mounted.map((tool) => tool.name));
58
59
  return [...new Set(piRegisteredToolNames(cwd))].filter((name) => !mountedNames.has(name));
59
60
  }
61
+ function isDefaultActiveTool(tool) {
62
+ return ((!tool.exposure || tool.exposure === "direct" || tool.exposure === "model-only") && tool.defaultActive !== false);
63
+ }
64
+ /** The built-in extension a `codemode`/`deferred` tool is reached through ({@link DISCOVERY}). */
65
+ function wayIn(tool) {
66
+ return tool.exposure === "codemode" || tool.exposure === "deferred" ? DISCOVERY[tool.exposure].extension : undefined;
67
+ }
68
+ /** Whether this machine leaves the tool's way in enabled (a tool that needs none always has one). */
69
+ function hasWayIn(tool, builtinExtensions) {
70
+ const extension = wayIn(tool);
71
+ return extension === undefined || builtinExtensions.includes(extension);
72
+ }
73
+ function indirectReach(tool, builtinExtensions) {
74
+ if (isDefaultActiveTool(tool))
75
+ return undefined;
76
+ if (!hasWayIn(tool, builtinExtensions))
77
+ return "unreachable";
78
+ if (tool.exposure === "deferred")
79
+ return "tool_search";
80
+ if (tool.exposure === "codemode")
81
+ return "codemode";
82
+ if (tool.exposure === "hidden")
83
+ return "hidden";
84
+ // `defaultActive: false`: only an authored loader (`ToolContext.tools.activate`) brings it in.
85
+ return "inactive";
86
+ }
60
87
  /**
61
88
  * The full directory-agent tool set: all pi coding tools + `config.tools` + discovered `tools/` (deduped, existing
62
89
  * win), plus the authored names and collisions to report.
@@ -82,24 +109,22 @@ export async function resolveAgentTools(config, agentDir, cwd) {
82
109
  mountedConfigTools.push(tool);
83
110
  }
84
111
  const merged = mergeDiscoveredTools(configured, discovered.tools);
85
- // The built-in `search_tools` loader mounts here.
86
- const tools = withSearchTool(merged.tools);
87
- // Builtin = a search_tools that was ABSENT before withSearchTool (a reference compare would misfire on the
88
- // deferred-authored-loader case, where withSearchTool returns a new array without adding one).
89
- const builtinLoaderMounted = !merged.tools.some((t) => t.name === "search_tools") && tools.some((t) => t.name === "search_tools");
112
+ const tools = merged.tools;
90
113
  const toolCollisions = [...discovered.collisions, ...configuredCollisions, ...merged.collisions];
91
114
  // `toolNames` is the AUTHOR's active-by-default surface (config.tools + tools/).
92
115
  // The discovered tools that were DROPPED (a coding tool or config.tools already owns the name):
93
116
  // asking the mounted set instead would read the winner's name as proof the loser is mounted.
94
117
  const shadowed = new Set(merged.collisions.map((c) => c.name));
118
+ const { builtinExtensions } = await readMachine(cwd);
95
119
  const defaultNames = new Set(CODING_TOOL_NAMES);
96
- const toolNames = tools
97
- .filter((t) => !defaultNames.has(t.name) && !isDeferredTool(t) && !(builtinLoaderMounted && t.name === "search_tools"))
98
- .map((t) => t.name);
120
+ const toolNames = tools.filter((t) => !defaultNames.has(t.name) && isDefaultActiveTool(t)).map((t) => t.name);
99
121
  return {
100
122
  tools,
101
123
  toolNames,
102
- deferredToolNames: tools.filter(isDeferredTool).map((t) => t.name),
124
+ indirectTools: tools.flatMap((t) => {
125
+ const reach = defaultNames.has(t.name) ? undefined : indirectReach(t, builtinExtensions);
126
+ return reach ? [{ name: t.name, reach }] : [];
127
+ }),
103
128
  toolCollisions,
104
129
  toolFailures: discovered.failures,
105
130
  // Mounted only, and config.tools are FastagentTools too, so a programmatic tool declares the
@@ -124,10 +149,14 @@ export async function resolveAgentTools(config, agentDir, cwd) {
124
149
  /** The pi engine's base prompt (segment ①), mirroring pi-coding-agent's default path with two deviations. */
125
150
  export function piBasePrompt(options = {}) {
126
151
  const mounted = options.tools ?? [];
152
+ const builtinExtensions = options.builtinExtensions ?? BUILTIN_EXTENSIONS;
127
153
  // Deferred tools stay OUT of the list: their schemas are not in the request until activated, so naming them here
128
154
  // would invite calls to tools that don't exist yet.
129
- const tools = mounted.filter((t) => !isDeferredTool(t));
130
- const deferredCount = mounted.length - tools.length;
155
+ const tools = mounted.filter(isDefaultActiveTool);
156
+ // Only `deferred` tools are tool_search's to load (the session activates it for them); `codemode` ones are listed
157
+ // in codemode's own description, and an inactive direct tool is reached only through an authored activation.
158
+ // A disabled tool-search leaves them no way in, and naming tool_search would invite calls to a tool that is not there.
159
+ const deferredCount = mounted.filter((tool) => tool.exposure === "deferred" && hasWayIn(tool, builtinExtensions)).length;
131
160
  const toolsList = tools.length > 0 ? tools.map((t) => `- ${t.name}: ${(t.description ?? "").split("\n")[0]}`).join("\n") : "(none)";
132
161
  // Segment ① identity: an authored persona (persona.md) replaces the default engine identity line (core.md §2),
133
162
  // keeping the tools list + guidelines below.
@@ -138,7 +167,7 @@ export function piBasePrompt(options = {}) {
138
167
  ? "You are an expert coding assistant operating inside pi, a coding agent harness. You help users by reading files, executing commands, editing code, and writing new files."
139
168
  : "You are an AI assistant operating inside pi, an agent harness. Help users using only the tools and context available to you.");
140
169
  const deferredNote = deferredCount > 0
141
- ? `\n\n${deferredCount} additional tool(s) are registered but inactive — use search_tools to discover and activate them before concluding a capability is missing.`
170
+ ? `\n\n${deferredCount} additional tool(s) are registered but not loaded — use tool_search to find and load them before concluding a capability is missing.`
142
171
  : "";
143
172
  // What takes effect when, and the agent's own path to a new capability, are the same on every host; only how long
144
173
  // the storage lives differs, below. The skill it writes lives in the definition directory, which the next
@@ -188,16 +217,10 @@ function assemblePi(opts) {
188
217
  // — boundary mutations must contend on it.
189
218
  const lease = opts.lease ?? inProcessLease();
190
219
  const sessions = opts.sessions ?? piInMemorySessionRecordStore({ cwd });
191
- // The model and its runtime resolve on FIRST USE.
220
+ const createModelRuntime = opts.models;
192
221
  let registry;
193
222
  const modelRuntime = () => {
194
- registry ??= opts.models().then((runtime) => {
195
- // ModelRuntime registers providers by config record, so an injected Provider INSTANCE (a gateway, a self-hosted
196
- // endpoint, a test fake) goes in through its native seam.
197
- for (const provider of opts.providers ?? [])
198
- runtime.registerNativeProvider(provider);
199
- return runtime;
200
- });
223
+ registry ??= opts.catalog();
201
224
  return registry;
202
225
  };
203
226
  let engine;
@@ -205,25 +228,28 @@ function assemblePi(opts) {
205
228
  engine ??= modelRuntime().then((runtime) => ({ modelRuntime: runtime, model: resolveModel(runtime, opts.model) }));
206
229
  return engine;
207
230
  };
231
+ // Deny omitted coding names so discovery cannot reintroduce tools a lower-level caller excluded.
232
+ const excludedToolNames = omittedBuiltinNames(opts.tools ?? [], cwd);
208
233
  const sessionFactory = piAgentSessionFactory({
209
234
  sessions,
210
- engine: resolveEngine,
235
+ engine: async () => ({ modelRuntime: await createModelRuntime() }),
236
+ modelSpec: opts.model,
211
237
  thinkingLevel: opts.thinkingLevel,
212
238
  tools: opts.tools,
213
239
  readDefinition: opts.readDefinition,
214
240
  cwd,
215
241
  ...(opts.extensionPaths ? { extensionPaths: opts.extensionPaths } : {}),
216
- // `noTools: "builtin"` leaves pi's built-ins in the registry; a lower-level replacement must also deny every
217
- // omitted coding name so a loader cannot reactivate one later.
218
- excludedToolNames: omittedBuiltinNames(opts.tools ?? [], cwd),
242
+ excludedToolNames,
219
243
  });
220
244
  return {
221
245
  lease,
222
246
  sessionFactory,
223
247
  modelRuntime,
248
+ createModelRuntime,
224
249
  engine: resolveEngine,
225
250
  thinkingLevel: opts.thinkingLevel ?? DEFAULT_THINKING_LEVEL,
226
251
  tools: opts.tools ?? [],
252
+ excludedToolNames,
227
253
  readDefinition: opts.readDefinition,
228
254
  extensionPaths: opts.extensionPaths ?? [],
229
255
  };
@@ -235,17 +261,17 @@ export function agentOf(assembly, observer) {
235
261
  /** L1: assemble from typed parts. */
236
262
  export function createPiAgent(options) {
237
263
  const { instructions, skills = [] } = options;
264
+ const models = agentModels(undefined, options, { providers: options.providers });
238
265
  return agentOf(assemblePi({
239
266
  model: options.model,
240
267
  thinkingLevel: options.thinkingLevel,
241
- providers: options.providers,
242
- models: agentModels(undefined, options).runtime,
268
+ models: models.createRuntime,
269
+ catalog: models.runtime,
243
270
  readDefinition: () => ({
244
271
  systemPrompt: typeof instructions === "function" ? instructions() : instructions,
245
272
  skills,
246
273
  }),
247
- // Deferred tools need their loader on every rung (idempotent; the caller's own search_tools wins).
248
- tools: options.tools ? withSearchTool(options.tools) : options.tools,
274
+ tools: options.tools,
249
275
  sessions: options.sessions,
250
276
  env: options.env,
251
277
  lease: options.lease,
@@ -264,21 +290,27 @@ export async function assemblePiFromDefinition(dir, options) {
264
290
  // Boot-time load: fail-visibly at startup on a broken directory, and give callers the snapshot to report
265
291
  // (skills/diagnostics/collisions).
266
292
  const definition = await loadAgentDefinition(dir, { cwd, env });
267
- // Deferred tools need their loader on every rung (idempotent — the workspace opener already applied it; a caller's
268
- // own search_tools wins).
269
- const tools = withSearchTool(options.tools ?? piAllCodingTools(cwd));
293
+ const tools = options.tools ?? piAllCodingTools(cwd);
270
294
  // Boot findings go through the SAME memoized reporter every later reader uses (report.ts, keyed by the resolved
271
295
  // dir).
272
296
  reportFindingsIfChanged(definition.dir, definition);
273
297
  const { providers } = options;
274
- const models = options.models ?? agentModels(dir, options, providers ? { providers } : {});
298
+ const models = options.models ?? agentModels(dir, options, { cwd, env, ...(providers ? { providers } : {}) });
275
299
  // Built at boot, so a malformed models.json fails the assembly rather than its first turn. The directory's own
276
300
  // models.json is what a turn resolves against, layered over the machine's (models.ts).
277
301
  await models.runtime();
302
+ const { builtinExtensions } = await readMachine(cwd);
303
+ const unreachable = tools.filter((tool) => !hasWayIn(tool, builtinExtensions));
304
+ // Said once per assembly: the model cannot call these at all, while the tools themselves loaded fine.
305
+ for (const tool of unreachable) {
306
+ log.warn(`[fastagent] tool "${tool.name}" (exposure: ${tool.exposure}) cannot be reached: pi's settings disable ` +
307
+ `builtin:${wayIn(tool)}, its only way in`);
308
+ }
278
309
  const assembly = assemblePi({
279
310
  model: options.model,
280
311
  thinkingLevel: options.thinkingLevel,
281
- models: models.runtime,
312
+ models: models.createRuntime,
313
+ catalog: models.runtime,
282
314
  // The directory is the agent, LIVE: re-read the definition on every invoke, so AGENTS.md/skills edits (the
283
315
  // author's, or the agent's own self-modification) take effect on the next turn with no process restart — restarts
284
316
  // are reserved for code (tools/channels/config, module cache).
@@ -289,7 +321,7 @@ export async function assemblePiFromDefinition(dir, options) {
289
321
  systemPrompt: assembleSystemPrompt({
290
322
  // Segment ①: an authored persona (persona.md, def.persona) overrides the engine identity, re-read per turn
291
323
  // like AGENTS.md so edits go live.
292
- base: options.base ?? piBasePrompt({ tools, persona: def.persona }),
324
+ base: options.base ?? piBasePrompt({ tools, persona: def.persona, builtinExtensions }),
293
325
  // ② project context: AGENTS.md files (agentDir + cwd-ancestor walk) via loadProjectContextFiles.
294
326
  contextFiles: def.contextFiles,
295
327
  }),
@@ -298,7 +330,8 @@ export async function assemblePiFromDefinition(dir, options) {
298
330
  },
299
331
  tools,
300
332
  sessions: options.sessions,
301
- extensionPaths: await loadExtensionPaths(dir, { cwd, env }),
333
+ // The catalog's own discovery: the models it registered and the extensions sessions load are one list.
334
+ extensionPaths: [...(await models.extensionPaths())],
302
335
  cwd,
303
336
  env,
304
337
  lease: options.lease,
@@ -10,7 +10,12 @@ import { log } from "../../log.js";
10
10
  import { toRetryScheduledEvent } from "./retry-event.js";
11
11
  import { PortFailure, port, portAbort, portCleanup, portError } from "../../effect-port.js";
12
12
  import { SessionBusy, acquireSession, acquireSessionLease } from "./session-effects.js";
13
- import { errorToTerminal, inProcessLease, projectAgentEvent, toPiPromptOptions, toTerminal, } from "./turn-kit.js";
13
+ import { errorToTerminal, inProcessLease, agentEventProjection, answerOutcome, toPiPromptOptions, toTerminal, } from "./turn-kit.js";
14
+ // Structured output is for scripts. Observation publishes Pi's display result, including bounded nested traces.
15
+ function displayToolResult(result) {
16
+ const { structuredContent: _internal, ...display } = result;
17
+ return display;
18
+ }
14
19
  function toSessionEvent(event, runId) {
15
20
  const at = Date.now();
16
21
  switch (event.type) {
@@ -33,30 +38,47 @@ function toSessionEvent(event, runId) {
33
38
  }
34
39
  return null;
35
40
  }
36
- case "message_end":
41
+ case "message_end": {
37
42
  if (event.message.role !== "assistant")
38
43
  return null;
39
- return { type: "message_finished", timestamp: at, runId, data: {} };
44
+ const outcome = answerOutcome(event.message);
45
+ return { type: "message_finished", timestamp: at, runId, data: outcome ? { outcome } : {} };
46
+ }
40
47
  case "tool_execution_start":
41
48
  return {
42
49
  type: "tool_started",
43
50
  timestamp: at,
44
51
  runId,
45
- data: { id: event.toolCallId, name: event.toolName, args: event.args },
52
+ data: {
53
+ id: event.toolCallId,
54
+ name: event.toolName,
55
+ args: event.args,
56
+ ...(event.parentToolCallId ? { parentToolCallId: event.parentToolCallId } : {}),
57
+ },
46
58
  };
47
59
  case "tool_execution_update":
48
60
  return {
49
61
  type: "tool_progress",
50
62
  timestamp: at,
51
63
  runId,
52
- data: { id: event.toolCallId, name: event.toolName, partialResult: event.partialResult },
64
+ data: {
65
+ id: event.toolCallId,
66
+ name: event.toolName,
67
+ partialResult: displayToolResult(event.partialResult),
68
+ ...(event.parentToolCallId ? { parentToolCallId: event.parentToolCallId } : {}),
69
+ },
53
70
  };
54
71
  case "tool_execution_end":
55
72
  return {
56
73
  type: "tool_finished",
57
74
  timestamp: at,
58
75
  runId,
59
- data: { id: event.toolCallId, isError: event.isError, content: event.result },
76
+ data: {
77
+ id: event.toolCallId,
78
+ isError: event.isError,
79
+ content: displayToolResult(event.result),
80
+ ...(event.parentToolCallId ? { parentToolCallId: event.parentToolCallId } : {}),
81
+ },
60
82
  };
61
83
  case "queue_update":
62
84
  return {
@@ -125,10 +147,27 @@ export function createPiAgentFromSession(options) {
125
147
  const abort = new AbortController();
126
148
  onCancelReady(() => abort.abort());
127
149
  const runId = crypto.randomUUID();
150
+ const project = agentEventProjection();
151
+ /**
152
+ * Each call's `tool_progress` still waiting in the queue. A newer status replaces its text instead of queuing
153
+ * behind it, so the queue holds at most one per running call, and a consumer that falls behind (a slow `/invoke`
154
+ * client) gets the latest status, not every snapshot in between: pi's bash reports every 100 ms, and a single
155
+ * growing line is up to 50 KB each time. The replaced event keeps its place, which moves a status earlier, never
156
+ * past its own call's `tool_ended`: pi takes no progress from a call once it has returned.
157
+ */
158
+ const unreadProgress = new Map();
128
159
  let settled = false;
129
160
  let outcome;
130
161
  let abortsInFlight = 0;
131
162
  let abortSucceeded = false;
163
+ /**
164
+ * An abort is owed to every model request this invoke makes, including one not yet started. pi's `abort()` stops
165
+ * only an agent run or a compaction already in flight: one that lands while `prompt()` prepares (input handlers,
166
+ * the auth check, `before_agent_start`) finds nothing to stop, and `_runAgentPrompt` then clears pi's own flag.
167
+ * So the run remembers it and aborts each agent run and automatic compaction as it starts (`agent_start`,
168
+ * `compaction_start`: both emitted once pi's controller for it exists).
169
+ */
170
+ let abortRequested = false;
132
171
  const observe = (event, run) => {
133
172
  if (!event || !observer)
134
173
  return;
@@ -144,25 +183,38 @@ export function createPiAgentFromSession(options) {
144
183
  * Every control command obeys one admission rule: wait for this run's binding, then refuse once it has settled.
145
184
  * Kept in one place so a fourth command cannot arrive with a fifth spelling of it.
146
185
  */
186
+ const alreadySettled = () => new PortFailure(new Error("run already settled; the command cannot take effect"));
147
187
  const command = (run) => Effect.runPromise(Effect.gen(function* () {
148
188
  const session = yield* ready;
149
189
  if (settled)
150
- return yield* Effect.fail(new PortFailure(new Error("run already settled; the command cannot take effect")));
151
- yield* run(session);
190
+ return yield* Effect.fail(alreadySettled());
191
+ return yield* run(session);
152
192
  }));
153
- // Prompt preparation stays OUTSIDE admission. An await between the settled check and the enqueue would let the run
154
- // end in between, and pi accepts a message for a finished run without complaint (`_steeringMessages.push`) — the
155
- // command would be dropped and still reported as success. Image resizing is exactly such an await, hundreds of
156
- // milliseconds of dynamic import and Photon work, and doing it here also keeps it overlapping session acquisition.
193
+ // Prompt preparation stays OUTSIDE admission: pi accepts a message for a finished run without complaint
194
+ // (`_steeringMessages.push`), so a run that ends between the settled check and the enqueue drops the command while
195
+ // it reports success. Image resizing would be such a gap, hundreds of milliseconds of dynamic import and Photon
196
+ // work, and doing it here also keeps it overlapping session acquisition. pi has a gap of its own: it awaits the
197
+ // definition's `input` handlers before it queues, so a `queued` answer is checked against settlement once more.
198
+ // A `handled` one stands either way: the handler took the prompt whatever became of the run.
157
199
  const enqueue = async (p, kind) => {
158
200
  const opts = await toPiPromptOptions(p, "queued");
159
- return command((session) => port(() => session[kind](p.text, opts?.images)));
201
+ return command((session) => Effect.gen(function* () {
202
+ const disposition = yield* port(() => session[kind](p.text, opts?.images));
203
+ if (disposition === "queued" && settled) {
204
+ // pi has queued it, and announced it (`queue_update`), on a run that takes nothing in any more. Emptying
205
+ // the queue tells the observation plane so, which would otherwise report the refused prompt pending.
206
+ session.clearQueue();
207
+ return yield* Effect.fail(alreadySettled());
208
+ }
209
+ return disposition;
210
+ }));
160
211
  };
161
212
  const controls = {
162
213
  steer: (p) => enqueue(p, "steer"),
163
214
  followUp: (p) => enqueue(p, "followUp"),
164
215
  abort: () => command((session) => Effect.gen(function* () {
165
216
  abortsInFlight++;
217
+ abortRequested = true;
166
218
  yield* port(() => session.abort()).pipe(Effect.tap(() => Effect.sync(() => {
167
219
  abortSucceeded = true;
168
220
  })), Effect.ensuring(Effect.sync(() => {
@@ -190,9 +242,10 @@ export function createPiAgentFromSession(options) {
190
242
  let streamedAnswer = false;
191
243
  let retriedAfterAnswer;
192
244
  let eventFailure;
193
- const stop = () => {
194
- void Effect.runPromise(portCleanup("event-fault abort", () => session.abort()));
245
+ const abortWith = (operation) => {
246
+ void Effect.runPromise(portCleanup(operation, () => session.abort()));
195
247
  };
248
+ const stop = () => abortWith("event-fault abort");
196
249
  yield* Effect.acquireRelease(Effect.try({
197
250
  try: () => session.subscribe((event) => {
198
251
  if (retriedAfterAnswer !== undefined || eventFailure)
@@ -200,6 +253,8 @@ export function createPiAgentFromSession(options) {
200
253
  try {
201
254
  if (event.type === "agent_start")
202
255
  runStarted = true;
256
+ if ((event.type === "agent_start" || event.type === "compaction_start") && abortRequested)
257
+ abortWith("requested abort");
203
258
  // Compaction rewrites session history; the event's assistant message is the turn's fact.
204
259
  if (event.type === "message_end" && event.message.role === "assistant") {
205
260
  finalAssistant = event.message;
@@ -222,11 +277,19 @@ export function createPiAgentFromSession(options) {
222
277
  observe(rich);
223
278
  if (!rich)
224
279
  return;
225
- const projected = projectAgentEvent(rich);
280
+ const projected = project(rich);
226
281
  if (!projected)
227
282
  return;
228
283
  if (projected.type === "text" || projected.type === "thinking")
229
284
  streamedAnswer = true;
285
+ if (projected.type === "tool_progress") {
286
+ const unread = unreadProgress.get(projected.id);
287
+ if (unread) {
288
+ unread.text = projected.text;
289
+ return;
290
+ }
291
+ unreadProgress.set(projected.id, projected);
292
+ }
230
293
  Queue.offerUnsafe(queue, projected);
231
294
  }
232
295
  catch (error) {
@@ -318,6 +381,9 @@ export function createPiAgentFromSession(options) {
318
381
  }
319
382
  else if (event.type === "completed")
320
383
  outcome = { status: "completed" };
384
+ // Handed over: the consumer owns it now, and the next status queues afresh.
385
+ if (event.type === "tool_progress")
386
+ unreadProgress.delete(event.id);
321
387
  yield event;
322
388
  }
323
389
  }
@@ -0,0 +1,9 @@
1
+ export interface LockResult<T> {
2
+ result: T;
3
+ /** New file content to persist under the lock; undefined = no write. */
4
+ next?: string;
5
+ }
6
+ /** Serialized cross-process read-modify-write of one file, replaced whole so its readers need no lock. */
7
+ export declare function withLockedFile<T>(path: string, fn: (current: string | undefined) => Promise<LockResult<T>>, options?: {
8
+ signal?: AbortSignal;
9
+ }): Promise<T>;
@@ -0,0 +1,102 @@
1
+ /**
2
+ * The one cross-process read-modify-write for a JSON file several processes share: fastagent's credentials file and a
3
+ * model catalog. It takes the lock pi takes for the same files (pi-coding-agent's `FileAuthStorageBackend`), so a
4
+ * fastagent writer and a pi writer queue behind each other, and it REPLACES the file rather than rewriting it in
5
+ * place, which is what lets every reader of these files read without the lock: a reader sees the old file or the new
6
+ * one, never one cut short.
7
+ */
8
+ import { existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, realpathSync, writeFileSync } from "node:fs";
9
+ import { dirname, resolve } from "node:path";
10
+ import lockfile from "proper-lockfile";
11
+ import { writeFileAtomic } from "../../atomic-write.js";
12
+ import { SECRET_FILE_MODE } from "../../paths.js";
13
+ const WRITE_OPTIONS = { encoding: "utf8", mode: SECRET_FILE_MODE };
14
+ /**
15
+ * The file a write must land IN, which is not always the path it is addressed BY. pi writes these files in place,
16
+ * so it follows a symlink to the target; an atomic rename would replace the link with a regular file instead, and
17
+ * from then on the two tools would be editing different files — the same split the shared lock exists to prevent,
18
+ * arriving one write later. A dangling link (a dotfile manager that has not checked the target out yet) still names
19
+ * where the content belongs, so it is followed too rather than overwritten.
20
+ */
21
+ function writeTarget(path) {
22
+ if (existsSync(path))
23
+ return realpathSync(path);
24
+ const link = lstatSync(path, { throwIfNoEntry: false });
25
+ // ponytail: one hop. A chain of dangling links would have its second link replaced; resolve iteratively if that
26
+ // ever shows up in a real layout.
27
+ return link?.isSymbolicLink() ? resolve(dirname(path), readlinkSync(path)) : path;
28
+ }
29
+ /** Serialized cross-process read-modify-write of one file, replaced whole so its readers need no lock. */
30
+ export async function withLockedFile(path, fn, options = {}) {
31
+ mkdirSync(dirname(path), { recursive: true });
32
+ if (!existsSync(path)) {
33
+ try {
34
+ // `wx` makes this an exclusive CREATE, which is the one case `writeFileSync` honours `mode` in — so no chmod
35
+ // after it (unlike writeFileAtomic, whose temp may already exist from a crashed writer).
36
+ writeFileSync(path, "{}", { ...WRITE_OPTIONS, flag: "wx" });
37
+ }
38
+ catch (error) {
39
+ // EEXIST: the path IS taken, by something `existsSync` does not see through — either another process created
40
+ // the file just now (its content must not be clobbered) or the path is a dangling symlink. Both are left
41
+ // alone: the write below resolves where they belong.
42
+ if (error.code !== "EEXIST")
43
+ throw error;
44
+ }
45
+ }
46
+ let compromised;
47
+ const throwIfCompromised = () => {
48
+ if (compromised)
49
+ throw compromised;
50
+ };
51
+ const release = await lockfile.lock(path, {
52
+ // MUST match pi-coding-agent's `core/auth-storage.js`, which locks with `realpath: false`. When the file
53
+ // itself is a symlink (dotfile managers do this), the two settings name DIFFERENT lock files and both locks can
54
+ // be held at once — so `pi` and `fastagent` would refresh the same provider concurrently, and a rotated refresh
55
+ // token logs one of them out. Resolving the link would be the stronger rule, but only if BOTH sides did it, and
56
+ // we do not own the other side.
57
+ realpath: false,
58
+ retries: { retries: 10, factor: 2, minTimeout: 100, maxTimeout: 10_000, randomize: true },
59
+ stale: 30_000,
60
+ onCompromised: (error) => {
61
+ compromised = error;
62
+ },
63
+ });
64
+ let result;
65
+ try {
66
+ throwIfCompromised();
67
+ const current = existsSync(path) ? readFileSync(path, "utf8") : undefined;
68
+ const out = await fn(current);
69
+ throwIfCompromised();
70
+ // The last moment a caller that gave up can keep its change off disk: waiting for the lock may have outlasted it
71
+ // (another process held the lock through the caller's timeout or cancel).
72
+ options.signal?.throwIfAborted();
73
+ // Rename, not an in-place rewrite: it is what lets `read` stay unlocked, and it is the only spelling that applies
74
+ // the mode before the content is reachable.
75
+ if (out.next !== undefined) {
76
+ writeFileAtomic(writeTarget(path), out.next, SECRET_FILE_MODE);
77
+ }
78
+ throwIfCompromised();
79
+ result = out.result;
80
+ }
81
+ catch (error) {
82
+ // The primary failure stays the signal; unlock noise must not mask it.
83
+ try {
84
+ await release();
85
+ }
86
+ catch {
87
+ // Secondary: a compromised or stale-reclaimed lock often cannot release cleanly.
88
+ }
89
+ throw error;
90
+ }
91
+ // Success path: a failed release is a real cleanup failure (the leftover `<file>.lock` stalls the next writer for
92
+ // the staleness window with zero diagnostics), so it surfaces instead of resolving a silently degraded operation.
93
+ try {
94
+ await release();
95
+ }
96
+ catch (releaseError) {
97
+ if (compromised === undefined)
98
+ throw releaseError;
99
+ }
100
+ throwIfCompromised();
101
+ return result;
102
+ }
@@ -31,6 +31,12 @@ export declare function loginOptions(credentials: string | CredentialStore): Pro
31
31
  /** {@link loginOptions} over pi's built-ins plus `providers` (a same id replaces one). Not public: a test seam. */
32
32
  export declare function loginOptionsOver(credentials: string | CredentialStore, providers?: readonly Provider[]): Promise<LoginOption[]>;
33
33
  export type LoginRequest = {
34
+ /**
35
+ * Stable installation identity (Sign in with ChatGPT requires one). With `authPath` it defaults to Pi settings
36
+ * beside that file (`settings.json`, written before the flow runs); over a caller's `credentialStore` there is no
37
+ * file to keep it beside, so a flow that needs one fails unless this supplies it.
38
+ */
39
+ getDeviceId?: () => string;
34
40
  provider: string;
35
41
  method: LoginMethod;
36
42
  /** pi-ai's own interaction: prompts (`text`, `secret`, `select`, `manual_code`) and events (`auth_url`, …). */
@@ -63,7 +69,7 @@ export interface LoginResult {
63
69
  * A provider an agent's models.json points elsewhere is not this call's: store that key with `fastagentCredentialStore`.
64
70
  *
65
71
  * Aborting `interaction.signal` at any point, the verification included, rejects with {@link LoginCancelled} and
66
- * writes nothing. Every prompt carries a signal that aborts on it, on the prompt's own signal (a callback server
72
+ * writes no credential (an OAuth device ID, the installation's identity, is kept once created). Every prompt carries a signal that aborts on it, on the prompt's own signal (a callback server
67
73
  * beating a `manual_code` prompt), and when the flow settles with a prompt still pending.
68
74
  */
69
75
  export declare function login(request: LoginRequest): Promise<LoginResult>;
@@ -5,6 +5,8 @@
5
5
  * one front end ({@link loginFlow}: terminal menus, then {@link login}); a GUI client is another.
6
6
  */
7
7
  import { InMemoryCredentialStore, } from "@earendil-works/pi-ai";
8
+ import { dirname } from "node:path";
9
+ import { SettingsManager } from "@earendil-works/pi-coding-agent";
8
10
  import { fastagentCredentialStore } from "./auth.js";
9
11
  import { createPiModelRuntime, interactiveAuth, loginProviders, piModelsOver, probeApiKey } from "./models.js";
10
12
  import { agentModels } from "./agent-models.js";
@@ -61,7 +63,7 @@ function anySignal(...signals) {
61
63
  * A provider an agent's models.json points elsewhere is not this call's: store that key with `fastagentCredentialStore`.
62
64
  *
63
65
  * Aborting `interaction.signal` at any point, the verification included, rejects with {@link LoginCancelled} and
64
- * writes nothing. Every prompt carries a signal that aborts on it, on the prompt's own signal (a callback server
66
+ * writes no credential (an OAuth device ID, the installation's identity, is kept once created). Every prompt carries a signal that aborts on it, on the prompt's own signal (a callback server
65
67
  * beating a `manual_code` prompt), and when the flow settles with a prompt still pending.
66
68
  */
67
69
  export function login(request) {
@@ -80,6 +82,7 @@ export async function loginOver(request, internals = {}) {
80
82
  const auth = interactiveAuth(provider, method);
81
83
  if (!auth?.login)
82
84
  throw new Error(`provider "${provider.id}" has no interactive ${method} login`);
85
+ const flow = auth.login.bind(auth);
83
86
  // The key under test lives here, not in the file, until it passes.
84
87
  const trial = new InMemoryCredentialStore();
85
88
  // Only a key needs the registry, and building an agent's fails on a broken models.json: an OAuth sign-in must not.
@@ -97,8 +100,21 @@ export async function loginOver(request, internals = {}) {
97
100
  await store.modify(provider.id, async () => undefined);
98
101
  const signal = interaction.signal ?? new AbortController().signal;
99
102
  const notify = (event) => interaction.notify(event);
103
+ let getDeviceId = request.getDeviceId;
104
+ if (method === "oauth" && !getDeviceId && request.authPath !== undefined) {
105
+ // Only the settings file beside the credentials: no project's `.pi/settings.json` has a say in this write.
106
+ const settings = SettingsManager.create(dirname(request.authPath), dirname(request.authPath), {
107
+ projectTrusted: false,
108
+ });
109
+ const deviceId = settings.getOrCreateDeviceId();
110
+ await settings.flush();
111
+ const errors = settings.drainErrors();
112
+ if (errors.length)
113
+ throw new Error(`could not persist login device ID: ${errors.map(({ error }) => error.message).join("; ")}`);
114
+ getDeviceId = () => deviceId;
115
+ }
100
116
  for (;;) {
101
- const credential = await runFlow(auth.login.bind(auth), interaction, signal);
117
+ const credential = await runFlow((io) => flow(io, { getDeviceId }), interaction, signal);
102
118
  let verified = "n/a";
103
119
  if (models) {
104
120
  await trial.modify(provider.id, async () => credential);
@@ -150,7 +166,7 @@ export const PI_DEFAULT_MODELS = {
150
166
  anthropic: "claude-opus-4-8",
151
167
  openai: "gpt-5.5",
152
168
  "azure-openai-responses": "gpt-5.4",
153
- "openai-codex": "gpt-5.5",
169
+ "openai-codex": "gpt-6.1-sol",
154
170
  radius: "balanced",
155
171
  nvidia: "nvidia/nemotron-3-super-120b-a12b",
156
172
  deepseek: "deepseek-v4-pro",
@@ -170,11 +186,11 @@ export const PI_DEFAULT_MODELS = {
170
186
  moonshotai: "kimi-k2.6",
171
187
  "moonshotai-cn": "kimi-k2.6",
172
188
  huggingface: "moonshotai/Kimi-K2.6",
173
- fireworks: "accounts/fireworks/models/kimi-k2p6",
174
- together: "moonshotai/Kimi-K2.6",
189
+ fireworks: "accounts/fireworks/models/kimi-k3",
190
+ together: "moonshotai/Kimi-K3",
175
191
  baseten: "zai-org/GLM-5.2",
176
192
  opencode: "kimi-k2.6",
177
- "opencode-go": "kimi-k2.6",
193
+ "opencode-go": "kimi-k3",
178
194
  "kimi-coding": "kimi-for-coding",
179
195
  meta: "muse-spark-1.3",
180
196
  "cloudflare-workers-ai": "@cf/moonshotai/kimi-k2.6",