@fastagent-sh/fastagent 0.22.1 → 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 (128) hide show
  1. package/dist/agent.d.ts +10 -0
  2. package/dist/channels/agentcore-protocol.d.ts +2 -1
  3. package/dist/channels/agentcore-service.js +10 -18
  4. package/dist/channels/agentcore.js +6 -3
  5. package/dist/channels/control.js +23 -0
  6. package/dist/channels/kit/preview-kit.d.ts +6 -1
  7. package/dist/channels/kit/preview-kit.js +21 -4
  8. package/dist/channels/slack/config-api.d.ts +2 -2
  9. package/dist/channels/slack/config-api.js +2 -2
  10. package/dist/channels/slack/onboard.js +2 -2
  11. package/dist/channels/slack/preview.js +2 -1
  12. package/dist/channels/slack/register-webhook.js +2 -2
  13. package/dist/channels/slack/slack-api.js +4 -4
  14. package/dist/cli/add-feishu.js +4 -2
  15. package/dist/cli/auth-view.d.ts +15 -6
  16. package/dist/cli/auth-view.js +24 -8
  17. package/dist/cli/box-login.d.ts +33 -0
  18. package/dist/cli/box-login.js +162 -0
  19. package/dist/cli/commands/deploy/agentcore.js +48 -11
  20. package/dist/cli/commands/deploy/docker.js +24 -13
  21. package/dist/cli/commands/deploy/fly.js +30 -7
  22. package/dist/cli/commands/deploy/railway.js +21 -7
  23. package/dist/cli/commands/deploy/shared.d.ts +18 -17
  24. package/dist/cli/commands/deploy/shared.js +20 -21
  25. package/dist/cli/commands/destroy.js +2 -2
  26. package/dist/cli/commands/info.js +15 -12
  27. package/dist/cli/commands/invoke.js +2 -2
  28. package/dist/cli/commands/login.d.ts +7 -1
  29. package/dist/cli/commands/login.js +123 -10
  30. package/dist/cli/commands/logs.js +2 -2
  31. package/dist/cli/commands/models.d.ts +4 -1
  32. package/dist/cli/commands/models.js +37 -4
  33. package/dist/cli/commands/routine-run.js +2 -2
  34. package/dist/cli/commands/start.d.ts +0 -5
  35. package/dist/cli/commands/start.js +3 -13
  36. package/dist/cli/invoke-stream.js +14 -0
  37. package/dist/cli/kernel.d.ts +2 -0
  38. package/dist/cli/kernel.js +1 -1
  39. package/dist/cli/login-relay.d.ts +38 -0
  40. package/dist/cli/login-relay.js +121 -0
  41. package/dist/cli/program.js +46 -8
  42. package/dist/cli/shared.d.ts +6 -5
  43. package/dist/cli/shared.js +29 -36
  44. package/dist/deploy/agentcore/plan.d.ts +10 -11
  45. package/dist/deploy/agentcore/plan.js +8 -14
  46. package/dist/deploy/agentcore/run.d.ts +8 -1
  47. package/dist/deploy/agentcore/run.js +20 -14
  48. package/dist/deploy/agentcore/shell.d.ts +25 -0
  49. package/dist/deploy/agentcore/shell.js +202 -0
  50. package/dist/deploy/box-shell.d.ts +43 -0
  51. package/dist/deploy/box-shell.js +28 -0
  52. package/dist/deploy/build-context.js +16 -7
  53. package/dist/deploy/channel-ingress.d.ts +20 -1
  54. package/dist/deploy/channel-ingress.js +34 -7
  55. package/dist/deploy/container.d.ts +14 -0
  56. package/dist/deploy/container.js +45 -1
  57. package/dist/deploy/docker/plan.d.ts +7 -3
  58. package/dist/deploy/docker/plan.js +9 -14
  59. package/dist/deploy/docker/run.d.ts +14 -7
  60. package/dist/deploy/docker/run.js +58 -35
  61. package/dist/deploy/fly/plan.d.ts +6 -3
  62. package/dist/deploy/fly/plan.js +6 -8
  63. package/dist/deploy/fly/run.d.ts +8 -2
  64. package/dist/deploy/fly/run.js +15 -25
  65. package/dist/deploy/preflight.d.ts +9 -7
  66. package/dist/deploy/preflight.js +92 -20
  67. package/dist/deploy/railway/plan.d.ts +6 -3
  68. package/dist/deploy/railway/plan.js +7 -9
  69. package/dist/deploy/railway/run.d.ts +10 -6
  70. package/dist/deploy/railway/run.js +29 -13
  71. package/dist/deploy/runner.d.ts +16 -0
  72. package/dist/deploy/runner.js +31 -0
  73. package/dist/deploy/secrets.d.ts +4 -13
  74. package/dist/deploy/secrets.js +8 -27
  75. package/dist/dev-supervisor.js +7 -4
  76. package/dist/engines/pi/agent-models.d.ts +83 -0
  77. package/dist/engines/pi/agent-models.js +120 -0
  78. package/dist/engines/pi/agent-session-factory.d.ts +13 -13
  79. package/dist/engines/pi/agent-session-factory.js +132 -159
  80. package/dist/engines/pi/auth.d.ts +56 -3
  81. package/dist/engines/pi/auth.js +68 -109
  82. package/dist/engines/pi/config.d.ts +0 -25
  83. package/dist/engines/pi/config.js +3 -37
  84. package/dist/engines/pi/create.d.ts +34 -21
  85. package/dist/engines/pi/create.js +90 -57
  86. package/dist/engines/pi/entry-images.d.ts +14 -0
  87. package/dist/engines/pi/entry-images.js +23 -0
  88. package/dist/engines/pi/invoke-session.js +88 -20
  89. package/dist/engines/pi/locked-file.d.ts +9 -0
  90. package/dist/engines/pi/locked-file.js +102 -0
  91. package/dist/engines/pi/login.d.ts +27 -14
  92. package/dist/engines/pi/login.js +41 -18
  93. package/dist/engines/pi/machine.d.ts +23 -0
  94. package/dist/engines/pi/machine.js +24 -2
  95. package/dist/engines/pi/models.d.ts +120 -36
  96. package/dist/engines/pi/models.js +316 -66
  97. package/dist/engines/pi/open.d.ts +65 -20
  98. package/dist/engines/pi/open.js +88 -34
  99. package/dist/engines/pi/report.d.ts +3 -0
  100. package/dist/engines/pi/report.js +11 -0
  101. package/dist/engines/pi/service.d.ts +2 -2
  102. package/dist/engines/pi/service.js +1 -0
  103. package/dist/engines/pi/session-builder.d.ts +3 -2
  104. package/dist/engines/pi/session-builder.js +30 -76
  105. package/dist/engines/pi/session-control.d.ts +4 -4
  106. package/dist/engines/pi/session-control.js +86 -43
  107. package/dist/engines/pi/session-settings.d.ts +9 -0
  108. package/dist/engines/pi/session-settings.js +17 -0
  109. package/dist/engines/pi/session-store.d.ts +2 -2
  110. package/dist/engines/pi/tool-context.d.ts +3 -5
  111. package/dist/engines/pi/tool-context.js +4 -16
  112. package/dist/engines/pi/tool.d.ts +32 -21
  113. package/dist/engines/pi/tool.js +21 -25
  114. package/dist/engines/pi/turn-kit.d.ts +18 -9
  115. package/dist/engines/pi/turn-kit.js +115 -32
  116. package/dist/open-url.d.ts +15 -1
  117. package/dist/open-url.js +35 -3
  118. package/dist/paths.d.ts +4 -2
  119. package/dist/paths.js +6 -2
  120. package/dist/pi.d.ts +5 -3
  121. package/dist/pi.js +3 -2
  122. package/dist/service.d.ts +5 -8
  123. package/dist/session-remote.js +21 -0
  124. package/dist/session.d.ts +88 -15
  125. package/dist/session.js +5 -1
  126. package/package.json +6 -5
  127. package/dist/engines/pi/search-tools.d.ts +0 -4
  128. package/dist/engines/pi/search-tools.js +0 -109
@@ -5,16 +5,18 @@
5
5
  import { toUSVString } from "node:util";
6
6
  import { NodeExecutionEnv } from "@earendil-works/pi-agent-core/node";
7
7
  import { createCodingTools, createPowerShellTool, createReadOnlyTools, } from "@earendil-works/pi-coding-agent";
8
- import { resolveAuthLayers, resolveModel } from "./config.js";
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
- import { DEFAULT_THINKING_LEVEL, createPiModelRuntime } from "./models.js";
18
+ import { DEFAULT_THINKING_LEVEL } from "./models.js";
19
+ import { agentModels } from "./agent-models.js";
18
20
  import { piInMemorySessionRecordStore } from "./session-store.js";
19
21
  import { inProcessLease } from "./turn-kit.js";
20
22
  // ── §1 tools ─────────────────────────────────────────────────────────────────
@@ -56,6 +58,32 @@ function omittedBuiltinNames(mounted, cwd) {
56
58
  const mountedNames = new Set(mounted.map((tool) => tool.name));
57
59
  return [...new Set(piRegisteredToolNames(cwd))].filter((name) => !mountedNames.has(name));
58
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
+ }
59
87
  /**
60
88
  * The full directory-agent tool set: all pi coding tools + `config.tools` + discovered `tools/` (deduped, existing
61
89
  * win), plus the authored names and collisions to report.
@@ -81,24 +109,22 @@ export async function resolveAgentTools(config, agentDir, cwd) {
81
109
  mountedConfigTools.push(tool);
82
110
  }
83
111
  const merged = mergeDiscoveredTools(configured, discovered.tools);
84
- // The built-in `search_tools` loader mounts here.
85
- const tools = withSearchTool(merged.tools);
86
- // Builtin = a search_tools that was ABSENT before withSearchTool (a reference compare would misfire on the
87
- // deferred-authored-loader case, where withSearchTool returns a new array without adding one).
88
- const builtinLoaderMounted = !merged.tools.some((t) => t.name === "search_tools") && tools.some((t) => t.name === "search_tools");
112
+ const tools = merged.tools;
89
113
  const toolCollisions = [...discovered.collisions, ...configuredCollisions, ...merged.collisions];
90
114
  // `toolNames` is the AUTHOR's active-by-default surface (config.tools + tools/).
91
115
  // The discovered tools that were DROPPED (a coding tool or config.tools already owns the name):
92
116
  // asking the mounted set instead would read the winner's name as proof the loser is mounted.
93
117
  const shadowed = new Set(merged.collisions.map((c) => c.name));
118
+ const { builtinExtensions } = await readMachine(cwd);
94
119
  const defaultNames = new Set(CODING_TOOL_NAMES);
95
- const toolNames = tools
96
- .filter((t) => !defaultNames.has(t.name) && !isDeferredTool(t) && !(builtinLoaderMounted && t.name === "search_tools"))
97
- .map((t) => t.name);
120
+ const toolNames = tools.filter((t) => !defaultNames.has(t.name) && isDefaultActiveTool(t)).map((t) => t.name);
98
121
  return {
99
122
  tools,
100
123
  toolNames,
101
- 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
+ }),
102
128
  toolCollisions,
103
129
  toolFailures: discovered.failures,
104
130
  // Mounted only, and config.tools are FastagentTools too, so a programmatic tool declares the
@@ -123,10 +149,14 @@ export async function resolveAgentTools(config, agentDir, cwd) {
123
149
  /** The pi engine's base prompt (segment ①), mirroring pi-coding-agent's default path with two deviations. */
124
150
  export function piBasePrompt(options = {}) {
125
151
  const mounted = options.tools ?? [];
152
+ const builtinExtensions = options.builtinExtensions ?? BUILTIN_EXTENSIONS;
126
153
  // Deferred tools stay OUT of the list: their schemas are not in the request until activated, so naming them here
127
154
  // would invite calls to tools that don't exist yet.
128
- const tools = mounted.filter((t) => !isDeferredTool(t));
129
- 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;
130
160
  const toolsList = tools.length > 0 ? tools.map((t) => `- ${t.name}: ${(t.description ?? "").split("\n")[0]}`).join("\n") : "(none)";
131
161
  // Segment ① identity: an authored persona (persona.md) replaces the default engine identity line (core.md §2),
132
162
  // keeping the tools list + guidelines below.
@@ -137,7 +167,7 @@ export function piBasePrompt(options = {}) {
137
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."
138
168
  : "You are an AI assistant operating inside pi, an agent harness. Help users using only the tools and context available to you.");
139
169
  const deferredNote = deferredCount > 0
140
- ? `\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.`
141
171
  : "";
142
172
  // What takes effect when, and the agent's own path to a new capability, are the same on every host; only how long
143
173
  // the storage lives differs, below. The skill it writes lives in the definition directory, which the next
@@ -187,38 +217,40 @@ function assemblePi(opts) {
187
217
  // — boundary mutations must contend on it.
188
218
  const lease = opts.lease ?? inProcessLease();
189
219
  const sessions = opts.sessions ?? piInMemorySessionRecordStore({ cwd });
190
- // The model and its runtime resolve on FIRST USE.
220
+ const createModelRuntime = opts.models;
221
+ let registry;
222
+ const modelRuntime = () => {
223
+ registry ??= opts.catalog();
224
+ return registry;
225
+ };
191
226
  let engine;
192
227
  const resolveEngine = () => {
193
- engine ??= (async () => {
194
- // The caller's registry when there is one — the directory rung builds it from the agent's own models.json, so a
195
- // custom endpoint declared there is the one a turn resolves against.
196
- const modelRuntime = opts.models ?? (await createPiModelRuntime({ auth: opts.auth }));
197
- // ModelRuntime registers providers by config record, so an injected Provider INSTANCE (a gateway, a self-hosted
198
- // endpoint, a test fake) goes in through its native seam.
199
- for (const provider of opts.providers ?? [])
200
- modelRuntime.registerNativeProvider(provider);
201
- return { modelRuntime, model: resolveModel(modelRuntime, opts.model) };
202
- })();
228
+ engine ??= modelRuntime().then((runtime) => ({ modelRuntime: runtime, model: resolveModel(runtime, opts.model) }));
203
229
  return engine;
204
230
  };
231
+ // Deny omitted coding names so discovery cannot reintroduce tools a lower-level caller excluded.
232
+ const excludedToolNames = omittedBuiltinNames(opts.tools ?? [], cwd);
205
233
  const sessionFactory = piAgentSessionFactory({
206
234
  sessions,
207
- engine: resolveEngine,
235
+ engine: async () => ({ modelRuntime: await createModelRuntime() }),
236
+ modelSpec: opts.model,
208
237
  thinkingLevel: opts.thinkingLevel,
209
238
  tools: opts.tools,
210
239
  readDefinition: opts.readDefinition,
211
240
  cwd,
212
241
  ...(opts.extensionPaths ? { extensionPaths: opts.extensionPaths } : {}),
213
- // `noTools: "builtin"` leaves pi's built-ins in the registry; a lower-level replacement must also deny every
214
- // omitted coding name so a loader cannot reactivate one later.
215
- excludedToolNames: omittedBuiltinNames(opts.tools ?? [], cwd),
242
+ excludedToolNames,
216
243
  });
217
244
  return {
218
245
  lease,
219
246
  sessionFactory,
247
+ modelRuntime,
248
+ createModelRuntime,
220
249
  engine: resolveEngine,
221
250
  thinkingLevel: opts.thinkingLevel ?? DEFAULT_THINKING_LEVEL,
251
+ tools: opts.tools ?? [],
252
+ excludedToolNames,
253
+ readDefinition: opts.readDefinition,
222
254
  extensionPaths: opts.extensionPaths ?? [],
223
255
  };
224
256
  }
@@ -229,35 +261,28 @@ export function agentOf(assembly, observer) {
229
261
  /** L1: assemble from typed parts. */
230
262
  export function createPiAgent(options) {
231
263
  const { instructions, skills = [] } = options;
264
+ const models = agentModels(undefined, options, { providers: options.providers });
232
265
  return agentOf(assemblePi({
233
266
  model: options.model,
234
267
  thinkingLevel: options.thinkingLevel,
235
- providers: options.providers,
236
- ...(options.authPath !== undefined ? { auth: { path: options.authPath } } : {}),
268
+ models: models.createRuntime,
269
+ catalog: models.runtime,
237
270
  readDefinition: () => ({
238
271
  systemPrompt: typeof instructions === "function" ? instructions() : instructions,
239
272
  skills,
240
273
  }),
241
- // Deferred tools need their loader on every rung (idempotent; the caller's own search_tools wins).
242
- tools: options.tools ? withSearchTool(options.tools) : options.tools,
274
+ tools: options.tools,
243
275
  sessions: options.sessions,
244
276
  env: options.env,
245
277
  lease: options.lease,
246
278
  }), options.observer);
247
279
  }
248
280
  /**
249
- * THE model registry an agent directory runs on: pi's built-ins, the directory's own `models.json` (custom endpoints
250
- * are definition data and travel with the artifact) layered over the machine's `~/.fastagent/models.json` (environment,
251
- * which does not travel), and any injected Provider instance, over the given credentials.
252
- * The directory rung assembles from it and `availableModelsFromDir` lists from it, so a layer added here reaches both.
281
+ * L2, as the value: load the directory (base + AGENTS.md + skills + env) and assemble. `models` is the directory's
282
+ * model environment when the caller already built it (the opener, whose report must describe what runs); otherwise it
283
+ * is built here from the credential options.
253
284
  */
254
- export function definitionModelRuntime(dir, options) {
255
- return createPiModelRuntime({ agentDir: dir, ...options });
256
- }
257
- /** L2, as the value: load the directory (base + AGENTS.md + skills + env) and assemble. */
258
- export async function assemblePiFromDefinition(dir,
259
- /** `auth` is the opener's already-resolved layers, which take the place of `authPath`. */
260
- options) {
285
+ export async function assemblePiFromDefinition(dir, options) {
261
286
  // `dir` = the agent-definition dir (persona.md/skills/); `cwd` (default = dir) is the run root where tools operate
262
287
  // and whose ancestors are walked for ② context.
263
288
  const cwd = options.cwd ?? dir;
@@ -265,20 +290,27 @@ options) {
265
290
  // Boot-time load: fail-visibly at startup on a broken directory, and give callers the snapshot to report
266
291
  // (skills/diagnostics/collisions).
267
292
  const definition = await loadAgentDefinition(dir, { cwd, env });
268
- // Deferred tools need their loader on every rung (idempotent — the workspace opener already applied it; a caller's
269
- // own search_tools wins).
270
- const tools = withSearchTool(options.tools ?? piAllCodingTools(cwd));
293
+ const tools = options.tools ?? piAllCodingTools(cwd);
271
294
  // Boot findings go through the SAME memoized reporter every later reader uses (report.ts, keyed by the resolved
272
295
  // dir).
273
296
  reportFindingsIfChanged(definition.dir, definition);
274
- const auth = options.auth ?? (options.authPath === undefined ? resolveAuthLayers(dir) : { path: options.authPath });
297
+ const { providers } = options;
298
+ const models = options.models ?? agentModels(dir, options, { cwd, env, ...(providers ? { providers } : {}) });
299
+ // Built at boot, so a malformed models.json fails the assembly rather than its first turn. The directory's own
300
+ // models.json is what a turn resolves against, layered over the machine's (models.ts).
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
+ }
275
309
  const assembly = assemblePi({
276
310
  model: options.model,
277
311
  thinkingLevel: options.thinkingLevel,
278
- models: await definitionModelRuntime(dir, {
279
- auth,
280
- ...(options.providers ? { providers: options.providers } : {}),
281
- }),
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 @@ 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 @@ 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,
@@ -0,0 +1,14 @@
1
+ import type { SessionManager } from "@earendil-works/pi-coding-agent";
2
+ import type { ImageRef } from "../../agent.ts";
3
+ import type { EntryImage } from "../../session.ts";
4
+ type Message = {
5
+ role: string;
6
+ content?: unknown;
7
+ };
8
+ /** `data.images` of the entry `entryId` holding `message`, absent when it has none. */
9
+ export declare function entryImages(entryId: string, message: Message): {
10
+ images?: EntryImage[];
11
+ };
12
+ /** The image `ref` names in `record`, or undefined when it names none there. The inverse of `entryImages`. */
13
+ export declare function imageAt(record: SessionManager, ref: string): ImageRef | undefined;
14
+ export {};
@@ -0,0 +1,23 @@
1
+ /** A user prompt's and a tool result's images; no other message publishes any, so no ref can reach them. */
2
+ function publishedImages(message) {
3
+ if (message.role !== "user" && message.role !== "toolResult")
4
+ return [];
5
+ const content = message.content;
6
+ return Array.isArray(content) ? content.filter((b) => b.type === "image") : [];
7
+ }
8
+ /** `data.images` of the entry `entryId` holding `message`, absent when it has none. */
9
+ export function entryImages(entryId, message) {
10
+ const images = publishedImages(message).map((b, i) => ({ ref: `${entryId}:${i}`, mimeType: b.mimeType }));
11
+ return images.length > 0 ? { images } : {};
12
+ }
13
+ /** The image `ref` names in `record`, or undefined when it names none there. The inverse of `entryImages`. */
14
+ export function imageAt(record, ref) {
15
+ const at = ref.lastIndexOf(":");
16
+ const index = ref.slice(at + 1);
17
+ // Canonical digits only, so one image has one ref (`:00` is not `:0`).
18
+ if (at <= 0 || !/^(0|[1-9]\d*)$/.test(index))
19
+ return undefined;
20
+ const entry = record.getEntry(ref.slice(0, at));
21
+ const image = entry?.type === "message" ? publishedImages(entry.message)[Number(index)] : undefined;
22
+ return image ? { data: image.data, mimeType: image.mimeType } : undefined;
23
+ }
@@ -4,12 +4,18 @@ import * as Effect from "effect/Effect";
4
4
  import * as Fiber from "effect/Fiber";
5
5
  import * as Queue from "effect/Queue";
6
6
  import { ABORTED_CODE, SESSION_BUSY_CODE, } from "../../agent.js";
7
+ import { entryImages } from "./entry-images.js";
7
8
  import { cancellableStream } from "../../collect.js";
8
9
  import { log } from "../../log.js";
9
10
  import { toRetryScheduledEvent } from "./retry-event.js";
10
11
  import { PortFailure, port, portAbort, portCleanup, portError } from "../../effect-port.js";
11
12
  import { SessionBusy, acquireSession, acquireSessionLease } from "./session-effects.js";
12
- 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
+ }
13
19
  function toSessionEvent(event, runId) {
14
20
  const at = Date.now();
15
21
  switch (event.type) {
@@ -32,30 +38,47 @@ function toSessionEvent(event, runId) {
32
38
  }
33
39
  return null;
34
40
  }
35
- case "message_end":
41
+ case "message_end": {
36
42
  if (event.message.role !== "assistant")
37
43
  return null;
38
- 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
+ }
39
47
  case "tool_execution_start":
40
48
  return {
41
49
  type: "tool_started",
42
50
  timestamp: at,
43
51
  runId,
44
- 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
+ },
45
58
  };
46
59
  case "tool_execution_update":
47
60
  return {
48
61
  type: "tool_progress",
49
62
  timestamp: at,
50
63
  runId,
51
- 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
+ },
52
70
  };
53
71
  case "tool_execution_end":
54
72
  return {
55
73
  type: "tool_finished",
56
74
  timestamp: at,
57
75
  runId,
58
- 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
+ },
59
82
  };
60
83
  case "queue_update":
61
84
  return {
@@ -105,9 +128,10 @@ function reportUserMessages(session, report) {
105
128
  return Effect.acquireRelease(Effect.sync(() => {
106
129
  record.appendMessage = (message) => {
107
130
  const entryId = append.call(record, message);
108
- // The same text `entries()` publishes for this entry (session-control.ts toSessionEntry).
109
- if (message.role === "user")
110
- report(entryId, contentText(message.content, ""));
131
+ // The same text and images `entries()` publishes for this entry (session-control.ts toSessionEntry).
132
+ if (message.role === "user") {
133
+ report({ entryId, text: contentText(message.content, ""), ...entryImages(entryId, message) });
134
+ }
111
135
  return entryId;
112
136
  };
113
137
  }), () => Effect.sync(() => {
@@ -123,10 +147,27 @@ export function createPiAgentFromSession(options) {
123
147
  const abort = new AbortController();
124
148
  onCancelReady(() => abort.abort());
125
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();
126
159
  let settled = false;
127
160
  let outcome;
128
161
  let abortsInFlight = 0;
129
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;
130
171
  const observe = (event, run) => {
131
172
  if (!event || !observer)
132
173
  return;
@@ -142,25 +183,38 @@ export function createPiAgentFromSession(options) {
142
183
  * Every control command obeys one admission rule: wait for this run's binding, then refuse once it has settled.
143
184
  * Kept in one place so a fourth command cannot arrive with a fifth spelling of it.
144
185
  */
186
+ const alreadySettled = () => new PortFailure(new Error("run already settled; the command cannot take effect"));
145
187
  const command = (run) => Effect.runPromise(Effect.gen(function* () {
146
188
  const session = yield* ready;
147
189
  if (settled)
148
- return yield* Effect.fail(new PortFailure(new Error("run already settled; the command cannot take effect")));
149
- yield* run(session);
190
+ return yield* Effect.fail(alreadySettled());
191
+ return yield* run(session);
150
192
  }));
151
- // Prompt preparation stays OUTSIDE admission. An await between the settled check and the enqueue would let the run
152
- // end in between, and pi accepts a message for a finished run without complaint (`_steeringMessages.push`) — the
153
- // command would be dropped and still reported as success. Image resizing is exactly such an await, hundreds of
154
- // 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.
155
199
  const enqueue = async (p, kind) => {
156
200
  const opts = await toPiPromptOptions(p, "queued");
157
- 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
+ }));
158
211
  };
159
212
  const controls = {
160
213
  steer: (p) => enqueue(p, "steer"),
161
214
  followUp: (p) => enqueue(p, "followUp"),
162
215
  abort: () => command((session) => Effect.gen(function* () {
163
216
  abortsInFlight++;
217
+ abortRequested = true;
164
218
  yield* port(() => session.abort()).pipe(Effect.tap(() => Effect.sync(() => {
165
219
  abortSucceeded = true;
166
220
  })), Effect.ensuring(Effect.sync(() => {
@@ -188,9 +242,10 @@ export function createPiAgentFromSession(options) {
188
242
  let streamedAnswer = false;
189
243
  let retriedAfterAnswer;
190
244
  let eventFailure;
191
- const stop = () => {
192
- void Effect.runPromise(portCleanup("event-fault abort", () => session.abort()));
245
+ const abortWith = (operation) => {
246
+ void Effect.runPromise(portCleanup(operation, () => session.abort()));
193
247
  };
248
+ const stop = () => abortWith("event-fault abort");
194
249
  yield* Effect.acquireRelease(Effect.try({
195
250
  try: () => session.subscribe((event) => {
196
251
  if (retriedAfterAnswer !== undefined || eventFailure)
@@ -198,6 +253,8 @@ export function createPiAgentFromSession(options) {
198
253
  try {
199
254
  if (event.type === "agent_start")
200
255
  runStarted = true;
256
+ if ((event.type === "agent_start" || event.type === "compaction_start") && abortRequested)
257
+ abortWith("requested abort");
201
258
  // Compaction rewrites session history; the event's assistant message is the turn's fact.
202
259
  if (event.type === "message_end" && event.message.role === "assistant") {
203
260
  finalAssistant = event.message;
@@ -220,11 +277,19 @@ export function createPiAgentFromSession(options) {
220
277
  observe(rich);
221
278
  if (!rich)
222
279
  return;
223
- const projected = projectAgentEvent(rich);
280
+ const projected = project(rich);
224
281
  if (!projected)
225
282
  return;
226
283
  if (projected.type === "text" || projected.type === "thinking")
227
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
+ }
228
293
  Queue.offerUnsafe(queue, projected);
229
294
  }
230
295
  catch (error) {
@@ -240,7 +305,7 @@ export function createPiAgentFromSession(options) {
240
305
  if (OWED_BY_THE_RUN.has(error.event))
241
306
  extensionFailure ??= `${error.event} failed (${error.extensionPath}): ${error.error}`;
242
307
  })), (unsubscribe) => Effect.sync(unsubscribe));
243
- yield* reportUserMessages(session, (entryId, text) => observe({ type: "user_message", timestamp: Date.now(), runId, data: { entryId, text } }));
308
+ yield* reportUserMessages(session, (data) => observe({ type: "user_message", timestamp: Date.now(), runId, data }));
244
309
  const extensionTurns = trackExtensionTurns(session);
245
310
  // Completing the gate can run waiting controls synchronously; their queue events must be observed.
246
311
  yield* Deferred.succeed(bound, session);
@@ -316,6 +381,9 @@ export function createPiAgentFromSession(options) {
316
381
  }
317
382
  else if (event.type === "completed")
318
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);
319
387
  yield event;
320
388
  }
321
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
+ }