@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
@@ -1,7 +1,7 @@
1
1
  import type { Agent } from "../../agent.ts";
2
2
  import { type FastagentConfig } from "./config.ts";
3
- import type { AgentCommand, SessionControl } from "../../session.ts";
4
- import { type PiAssembly } from "./create.ts";
3
+ import type { AgentCommand, ModelDescriptor, SessionControl } from "../../session.ts";
4
+ import { type IndirectTool, type PiAssembly } from "./create.ts";
5
5
  import type { SessionObserver } from "./turn-kit.ts";
6
6
  import { type LoadedDefinition } from "./definition.ts";
7
7
  import type { ModelRuntime } from "@earendil-works/pi-coding-agent";
@@ -72,10 +72,10 @@ export interface AgentAssembly {
72
72
  stateRoot: string;
73
73
  /** The credential store and model registry every turn and every report reads ({@link agentModels}). */
74
74
  models: AgentModels;
75
- /** The full mounted tool surface (all coding tools + config.tools + discovered tools/, search_tools applied). */
75
+ /** The full mounted tool surface (all coding tools + config.tools + discovered tools/). */
76
76
  tools: MountedTool[];
77
77
  toolNames: string[];
78
- deferredToolNames: string[];
78
+ indirectTools: IndirectTool[];
79
79
  toolCollisions: ToolCollision[];
80
80
  /** Env vars the mounted tools declared, by tool name — already asserted present by this function. */
81
81
  toolSecrets: Map<string, DeclaredSecret[]>;
@@ -96,15 +96,16 @@ export declare function assembleFront(front: AgentAssembly, extra?: {
96
96
  definition: LoadedDefinition;
97
97
  }>;
98
98
  /**
99
- * The model specs `createPiAgentFromDir(dir, { authPath })` could run now: the agent's registry (pi's built-ins plus
100
- * its `models.json`) filtered to the providers whose credentials are configured, sorted. The directory needs no model
101
- * set, and the same placement and credential layers as the opener apply, so a listed spec authenticates there.
102
- * Configuration is checked, not validity: no OAuth token is refreshed and no provider is called.
99
+ * The models `createPiAgentFromDir(dir, { authPath })` could run now, as a picker shows them (spec, name, the thinking
100
+ * levels a session on it accepts, context window), sorted by spec: the agent's registry (pi's built-ins, its
101
+ * `models.json`, and what its `extensions/` declare) filtered to the providers whose credentials are configured. The
102
+ * directory needs no model set, and the same placement and credential layers as the opener apply, so a listed spec
103
+ * authenticates there. Configuration is checked, not validity: no OAuth token is refreshed and no provider is called.
103
104
  *
104
105
  * `warn` reaches the credential store. An unreadable or corrupt credentials file otherwise reads as "nothing
105
106
  * configured", so a client that must not show that as an empty list passes a sink that throws.
106
107
  */
107
- export declare function availableModelsFromDir(dir: string, options?: FastagentAuthOptions & CredentialSourceOptions): Promise<string[]>;
108
+ export declare function availableModelsFromDir(dir: string, options?: FastagentAuthOptions & CredentialSourceOptions): Promise<ModelDescriptor[]>;
108
109
  /**
109
110
  * Refresh the agent's model catalog (`<agent dir>/models-store.json`) with the credentials
110
111
  * `createPiAgentFromDir(dir, { authPath })` would use, so a model released after the installed pi appears in
@@ -122,6 +123,19 @@ export declare function refreshModelCatalog(dir: string, options?: FastagentAuth
122
123
  export declare function refreshModelCatalogOver(dir: string, options: FastagentAuthOptions & CredentialSourceOptions & {
123
124
  signal?: AbortSignal;
124
125
  }, catalogBaseUrl?: string): Promise<void>;
126
+ /**
127
+ * Refresh the MACHINE's model catalog (`~/.fastagent/models-store.json`), which every agent here reads under its own,
128
+ * so one refresh serves them all and no file is written into any agent. Credentials resolve as for
129
+ * {@link refreshModelCatalog} without a directory: `credentialStore`, else `authPath`, else the global credentials file;
130
+ * the environment applies either way. Rejects for the same reasons. The file never ships with a deploy.
131
+ */
132
+ export declare function refreshMachineModelCatalog(options?: FastagentAuthOptions & CredentialSourceOptions & {
133
+ signal?: AbortSignal;
134
+ }): Promise<void>;
135
+ /** {@link refreshMachineModelCatalog} against another catalog server. Not public: a test seam. */
136
+ export declare function refreshMachineModelCatalogOver(options: FastagentAuthOptions & CredentialSourceOptions & {
137
+ signal?: AbortSignal;
138
+ }, catalogBaseUrl?: string): Promise<void>;
125
139
  /**
126
140
  * "Point at a directory → agent": resolve the placement (`dir` may be either end — the workspace or the agent dir
127
141
  * itself), load the config, resolve model and tools, then L2.
@@ -153,7 +167,7 @@ export declare function createPiAgentFromDir(dir: string, options?: CreatePiAgen
153
167
  http?: HttpSurface;
154
168
  /** Non-default, active-by-default tool names in effect: config.tools + discovered tools/. */
155
169
  toolNames: string[];
156
- /** Tools registered but not initially active (deferred) — activated via search_tools. */
157
- deferredToolNames: string[];
170
+ /** Mounted authored tools the model is not given up front, with how each is reached. */
171
+ indirectTools: IndirectTool[];
158
172
  toolCollisions: ToolCollision[];
159
173
  }>;
@@ -6,6 +6,7 @@ import { mkdir } from "node:fs/promises";
6
6
  import { join } from "node:path";
7
7
  import { loadConfig, resolveModelSpec } from "./config.js";
8
8
  import { AGENT_MODEL_CATALOG_FILE, resolveSessionsDir, resolveStateRoot, resolvePlacement } from "../../paths.js";
9
+ import { describeModels } from "./session-settings.js";
9
10
  import { agentOf, assemblePiFromDefinition, resolveAgentTools } from "./create.js";
10
11
  import { createPiSessionControl } from "./session-control.js";
11
12
  import { withWakeTool } from "./wake-tool.js";
@@ -14,7 +15,7 @@ import { loadAgentSkills } from "./definition.js";
14
15
  import { servedExtensionCommands } from "./agent-session-factory.js";
15
16
  import { reportFindingsIfChanged } from "./report.js";
16
17
  import { readMachine, withMachine } from "./machine.js";
17
- import { createPiModelRuntime, refreshCatalog } from "./models.js";
18
+ import { createPiModelRuntime, globalCatalogPath, machineModelRuntime, refreshCatalog } from "./models.js";
18
19
  import { agentModels } from "./agent-models.js";
19
20
  import { piSessionRecordStore } from "./session-store.js";
20
21
  import { gateSecrets } from "../../secrets-gate.js";
@@ -36,9 +37,11 @@ export async function agentCommands(agentDir, workspace, served) {
36
37
  reportFindingsIfChanged(own.dir, own);
37
38
  const machine = await readMachine(workspace);
38
39
  const { extensionPaths } = served;
39
- const extensionCommands = extensionPaths.length === 0
40
- ? []
41
- : await servedExtensionCommands({ cwd: workspace, modelRuntime: await served.modelRuntime(), extensionPaths });
40
+ const extensionCommands = await servedExtensionCommands({
41
+ cwd: workspace,
42
+ modelRuntime: await served.modelRuntime(),
43
+ extensionPaths,
44
+ });
42
45
  // pi dispatches an extension command before it expands a template, so a template it shadows never runs.
43
46
  const shadowed = new Set(extensionCommands.map((command) => command.invocationName));
44
47
  return [
@@ -69,7 +72,7 @@ export async function resolveAgentAssembly(dir, options = {}) {
69
72
  if (!modelSpec) {
70
73
  throw new Error(`missing model: set --model, "model" in fastagent.config.ts, or FASTAGENT_MODEL (e.g. "openai-codex/gpt-5.5")`);
71
74
  }
72
- const { tools, toolNames, deferredToolNames, toolCollisions, toolFailures, toolSecrets } = await resolveAgentTools(config, agentDir, workspace);
75
+ const { tools, toolNames, indirectTools, toolCollisions, toolFailures, toolSecrets } = await resolveAgentTools(config, agentDir, workspace);
73
76
  // THE serving-path gate for tool declarations, in the order that costs the fewest round trips. A file that could
74
77
  // not be imported declares nothing, so its `secrets:` are missing from `toolSecrets` — gating secrets first would
75
78
  // report an incomplete set, and fixing the file could then reveal more missing values. Refuse the broken file
@@ -79,7 +82,7 @@ export async function resolveAgentAssembly(dir, options = {}) {
79
82
  // definition and must survive both faults; every path through this function is about to run ALL of the tools, so
80
83
  // no `owner`.
81
84
  //
82
- // DEFERRED tools gate too, deliberately: `search_tools` can activate one mid-turn, so "registered"
85
+ // DEFERRED tools gate too, deliberately: native discovery can activate one mid-turn, so "registered"
83
86
  // means "may run in this process" — letting it start would put the empty-credential failure back
84
87
  // inside a turn. An author who does not want that opts out per tool by not declaring.
85
88
  gateSecrets({ declared: toolSecrets, failures: [] });
@@ -94,10 +97,10 @@ export async function resolveAgentAssembly(dir, options = {}) {
94
97
  workspace,
95
98
  stateRoot,
96
99
  // Project-level by default (under `<agentDir>/.secrets`), with the global file behind it per provider.
97
- models: agentModels(agentDir, options),
100
+ models: agentModels(agentDir, options, { cwd: workspace }),
98
101
  tools,
99
102
  toolNames,
100
- deferredToolNames,
103
+ indirectTools,
101
104
  toolCollisions,
102
105
  toolSecrets,
103
106
  };
@@ -118,18 +121,19 @@ export function assembleFront(front, extra = {}) {
118
121
  });
119
122
  }
120
123
  /**
121
- * The model specs `createPiAgentFromDir(dir, { authPath })` could run now: the agent's registry (pi's built-ins plus
122
- * its `models.json`) filtered to the providers whose credentials are configured, sorted. The directory needs no model
123
- * set, and the same placement and credential layers as the opener apply, so a listed spec authenticates there.
124
- * Configuration is checked, not validity: no OAuth token is refreshed and no provider is called.
124
+ * The models `createPiAgentFromDir(dir, { authPath })` could run now, as a picker shows them (spec, name, the thinking
125
+ * levels a session on it accepts, context window), sorted by spec: the agent's registry (pi's built-ins, its
126
+ * `models.json`, and what its `extensions/` declare) filtered to the providers whose credentials are configured. The
127
+ * directory needs no model set, and the same placement and credential layers as the opener apply, so a listed spec
128
+ * authenticates there. Configuration is checked, not validity: no OAuth token is refreshed and no provider is called.
125
129
  *
126
130
  * `warn` reaches the credential store. An unreadable or corrupt credentials file otherwise reads as "nothing
127
131
  * configured", so a client that must not show that as an empty list passes a sink that throws.
128
132
  */
129
133
  export async function availableModelsFromDir(dir, options = {}) {
130
- const { agentDir } = resolvePlacement(dir);
131
- const models = await agentModels(agentDir, options).runtime();
132
- return (await models.getAvailable()).map((model) => `${model.provider}/${model.id}`).sort();
134
+ const { agentDir, workspace } = resolvePlacement(dir);
135
+ const models = await agentModels(agentDir, options, { cwd: workspace }).runtime();
136
+ return describeModels(await models.getAvailable());
133
137
  }
134
138
  /**
135
139
  * Refresh the agent's model catalog (`<agent dir>/models-store.json`) with the credentials
@@ -148,10 +152,28 @@ export function refreshModelCatalog(dir, options = {}) {
148
152
  export async function refreshModelCatalogOver(dir, options, catalogBaseUrl) {
149
153
  const { agentDir } = resolvePlacement(dir);
150
154
  const { credentials } = agentModels(agentDir, options);
151
- await refreshCatalog((catalogFile) => createPiModelRuntime({
155
+ await refreshCatalog(join(agentDir, AGENT_MODEL_CATALOG_FILE), (catalogFile) => createPiModelRuntime({
152
156
  agentDir,
153
157
  credentials,
154
- ...(catalogFile ? { catalogFile: join(agentDir, AGENT_MODEL_CATALOG_FILE) } : {}),
158
+ catalogFile,
159
+ ...(catalogBaseUrl ? { catalogBaseUrl } : {}),
160
+ }), options.signal ? { signal: options.signal } : {});
161
+ }
162
+ /**
163
+ * Refresh the MACHINE's model catalog (`~/.fastagent/models-store.json`), which every agent here reads under its own,
164
+ * so one refresh serves them all and no file is written into any agent. Credentials resolve as for
165
+ * {@link refreshModelCatalog} without a directory: `credentialStore`, else `authPath`, else the global credentials file;
166
+ * the environment applies either way. Rejects for the same reasons. The file never ships with a deploy.
167
+ */
168
+ export function refreshMachineModelCatalog(options = {}) {
169
+ return refreshMachineModelCatalogOver(options);
170
+ }
171
+ /** {@link refreshMachineModelCatalog} against another catalog server. Not public: a test seam. */
172
+ export async function refreshMachineModelCatalogOver(options, catalogBaseUrl) {
173
+ const { credentials } = agentModels(undefined, options);
174
+ await refreshCatalog(globalCatalogPath(), (catalogFile) => machineModelRuntime({
175
+ credentials,
176
+ catalogFile,
155
177
  ...(catalogBaseUrl ? { catalogBaseUrl } : {}),
156
178
  }), options.signal ? { signal: options.signal } : {});
157
179
  }
@@ -161,7 +183,7 @@ export async function refreshModelCatalogOver(dir, options, catalogBaseUrl) {
161
183
  */
162
184
  export async function createPiAgentFromDir(dir, options = {}) {
163
185
  const front = await resolveAgentAssembly(dir, options);
164
- const { config, configPath, modelSpec, agentDir, workspace, stateRoot, models, tools, toolNames, deferredToolNames, toolCollisions, } = front;
186
+ const { config, configPath, modelSpec, agentDir, workspace, stateRoot, models, tools, toolNames, indirectTools, toolCollisions, } = front;
165
187
  // Every serve mounts the built-in `wake` tool: the agent's own follow-up work is a default capability, and a serve
166
188
  // is where the poller that honors it runs. A one-shot `invoke` has no poller, so nothing there would fire it.
167
189
  const mountedTools = withWakeTool(tools, stateRoot, !!options.serving);
@@ -201,7 +223,7 @@ export async function createPiAgentFromDir(dir, options = {}) {
201
223
  // the prompt would not expand — a second reading of "which skills exist" is how those two come to disagree.
202
224
  commands: () => agentCommands(agentDir, workspace, {
203
225
  extensionPaths: assembly.extensionPaths,
204
- modelRuntime: async () => (await assembly.engine()).modelRuntime,
226
+ modelRuntime: assembly.createModelRuntime,
205
227
  }),
206
228
  // The caller tap's boundary-event half: state_changed/compaction_* originate in the hub and never cross the
207
229
  // data plane's observer seam.
@@ -233,7 +255,7 @@ export async function createPiAgentFromDir(dir, options = {}) {
233
255
  sessionsDir,
234
256
  models,
235
257
  toolNames,
236
- deferredToolNames,
258
+ indirectTools,
237
259
  toolCollisions,
238
260
  };
239
261
  }
@@ -5,6 +5,7 @@
5
5
  import type { SkillDiagnostic } from "@earendil-works/pi-agent-core";
6
6
  import type { SkillCollision } from "./definition.ts";
7
7
  import type { ToolCollision } from "./tool.ts";
8
+ import type { IndirectTool } from "./create.ts";
8
9
  type Findings = {
9
10
  collisions: SkillCollision[];
10
11
  diagnostics: SkillDiagnostic[];
@@ -12,5 +13,7 @@ type Findings = {
12
13
  /** THE door for definition findings: warns only when this dir's set CHANGED since the last report. */
13
14
  export declare function reportFindingsIfChanged(dir: string, def: Findings): void;
14
15
  export declare function reportDefinitionWarnings(collisions: SkillCollision[], diagnostics: SkillDiagnostic[]): void;
16
+ /** The report line for the mounted tools the model is not given up front: each name with its way in. */
17
+ export declare function describeIndirectTools(tools: readonly IndirectTool[]): string;
15
18
  export declare function reportToolCollisions(collisions: ToolCollision[]): void;
16
19
  export {};
@@ -23,6 +23,17 @@ export function reportDefinitionWarnings(collisions, diagnostics) {
23
23
  log.warn(`[fastagent] ${d.code}: ${d.message} (${d.path})`);
24
24
  }
25
25
  }
26
+ const REACH = {
27
+ tool_search: "loaded via tool_search",
28
+ codemode: "codemode scripts only",
29
+ hidden: "hidden",
30
+ inactive: "inactive until an authored loader activates it",
31
+ unreachable: "unreachable: pi's settings disable the built-in extension it needs",
32
+ };
33
+ /** The report line for the mounted tools the model is not given up front: each name with its way in. */
34
+ export function describeIndirectTools(tools) {
35
+ return tools.map((tool) => `${tool.name} (${REACH[tool.reach]})`).join(", ");
36
+ }
26
37
  export function reportToolCollisions(collisions) {
27
38
  for (const c of collisions) {
28
39
  log.warn(`[fastagent] tool "${c.name}" (${c.source}) dropped — a default/config tool already uses that name`);
@@ -4,9 +4,8 @@
4
4
  */
5
5
  import { existsSync, readFileSync } from "node:fs";
6
6
  import { resolve } from "node:path";
7
- import { SessionManager, SettingsManager, createAgentSessionRuntime, createAgentSessionServices, getAgentDir, } from "@earendil-works/pi-coding-agent";
8
- import { bindPiSession, definitionResourceLoaderOptions, reportExtensionErrors } from "./agent-session-factory.js";
9
- import { readMachine } from "./machine.js";
7
+ import { SessionManager, SettingsManager, createAgentSessionRuntime, getAgentDir, } from "@earendil-works/pi-coding-agent";
8
+ import { bindPiSession, definitionServices } from "./agent-session-factory.js";
10
9
  import { resolveModel } from "./config.js";
11
10
  import { canonicalPath } from "./definition.js";
12
11
  import { reportToolCollisions } from "./report.js";
@@ -42,29 +41,14 @@ export async function buildAgentSessionRuntime(dir, options = {}, sessionManager
42
41
  const createRuntime = async ({ cwd, sessionManager, sessionStartEvent }) => {
43
42
  const { modelSpec, assembly, definition } = await assemblyFor(cwd);
44
43
  // Per session, NOT memoized with the assembly.
45
- const [modelRuntime, machine] = await Promise.all([assembly.modelRuntime(), readMachine(cwd)]);
46
- const loaded = await createAgentSessionServices({
47
- cwd,
48
- // fastagent's models + auth hub replaces pi's default (~/.pi-backed) one — the auth unification point.
49
- modelRuntime,
50
- // PACKAGELESS, for the loader: fastagent never installs a pi package (machine.ts). Handed pi's own settings, the
51
- // loader resolves `packages` itself — installing a missing one, and failing chat's start when that fails.
52
- settingsManager: machine.settingsManager(),
53
- resourceLoaderOptions: definitionResourceLoaderOptions({
54
- systemPrompt: () => definition.systemPrompt,
55
- skills: () => definition.skills,
56
- machine,
57
- extensionPaths: assembly.extensionPaths,
58
- }),
59
- });
44
+ const modelRuntime = await assembly.createModelRuntime();
45
+ const loaded = await definitionServices({ cwd, modelRuntime, definition, extensionPaths: assembly.extensionPaths });
60
46
  // ...while the SESSION keeps pi's own file-backed settings, so `/settings` in the TUI still saves. pi persists by
61
47
  // re-reading the file under its lock and writing only the fields that changed, so `packages` there is untouched.
62
48
  const services = { ...loaded, settingsManager: SettingsManager.create(cwd, loaded.agentDir) };
63
- reportExtensionErrors(services);
64
49
  // AFTER the services, because an extension may be what defines the model.
65
50
  const model = resolveModel(modelRuntime, modelSpec);
66
- // The same bind serving performs, minus the activation record: pi's chat session has nowhere to put one, which is
67
- // the documented divergence.
51
+ // Serving and chat restore the same native transcript declarations.
68
52
  const result = await bindPiSession({
69
53
  services,
70
54
  sessionManager,
@@ -74,9 +58,9 @@ export async function buildAgentSessionRuntime(dir, options = {}, sessionManager
74
58
  // is the definition's (`thinkingLevel` in fastagent.config.ts), like serving's.
75
59
  thinkingLevel: assembly.thinkingLevel,
76
60
  tools: assembly.tools,
61
+ excludedToolNames: assembly.excludedToolNames,
77
62
  // A tool must see one spelling of the workspace, including when opened through a symlink.
78
63
  cwd: rootCwd,
79
- recordActivations: false,
80
64
  });
81
65
  return { ...result, services, diagnostics: services.diagnostics };
82
66
  };
@@ -15,7 +15,7 @@
15
15
  import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
16
16
  import { type Models } from "@earendil-works/pi-ai";
17
17
  import { type AgentCommand, type SessionControl, type SessionEvent } from "../../session.ts";
18
- import type { SessionObserver, Lease } from "./turn-kit.ts";
18
+ import { type SessionObserver, type Lease } from "./turn-kit.ts";
19
19
  import type { AnyModel } from "./models.ts";
20
20
  import type { PiAgentSessionFactory } from "./invoke-session.ts";
21
21
  import type { PiSessionRecordStore } from "./session-store.ts";
@@ -25,9 +25,9 @@ export declare const SUBSCRIBER_BUFFER_CAP = 10000;
25
25
  export declare const SUBSCRIBER_BUFFER_BYTES: number;
26
26
  /** What the plane's writes (`update` / `compact` / `fork` / `delete`) need — the SAME instances the
27
27
  * agent assembly uses: the lease (a write must not race a run), the model registry (validation +
28
- * allowedModels), and the session factory (compaction is a model call). Writes go through the
29
- * record the hub's reader opened — after an existence check, so the control plane never creates
30
- * a session (that is the data plane's monopoly). */
28
+ * allowedModels), and the session factory (compaction is a model call). Two writes create a record:
29
+ * fork's `into`, and `update()` on an id that has none (through the store's `openOrCreate`, like an
30
+ * invoke). Every other write needs a record that exists. */
31
31
  export interface PiBoundaryWiring {
32
32
  lease: Lease;
33
33
  models: Models;
@@ -10,12 +10,12 @@ import { calculateContextTokens, estimateTokens, getLastAssistantUsage, } from "
10
10
  import { getSupportedThinkingLevels } from "@earendil-works/pi-ai";
11
11
  import { SESSION_BUSY_CODE } from "../../agent.js";
12
12
  import { BOUNDARY_COMMAND_FAILED_CODE, INVALID_COMMAND_CODE, isAddressableSession, NO_ACTIVE_RUN_CODE, NOTHING_TO_COMPACT_CODE, NO_SUCH_SESSION_CODE, PARTIAL_UPDATE_CODE, UPDATE_FIELDS, RUN_COMMAND_FAILED_CODE, UNSUPPORTED_CAPABILITY_CODE, } from "../../session.js";
13
- import { listModels } from "./config.js";
14
- import { forkProvenance, isNavigable, publishedLeaf } from "./session-markers.js";
13
+ import { forkProvenance, isConversationMessage, isNavigable, publishedLeaf } from "./session-markers.js";
15
14
  import { entryImages, imageAt } from "./entry-images.js";
15
+ import { answerOutcome } from "./turn-kit.js";
16
16
  import { startCompaction } from "./agent-session-factory.js";
17
17
  import { toRetryScheduledEvent } from "./retry-event.js";
18
- import { THINKING_LEVELS, activePath, resolveSessionSettings } from "./session-settings.js";
18
+ import { THINKING_LEVELS, activePath, describeModels, resolveSessionSettings } from "./session-settings.js";
19
19
  import { log } from "../../log.js";
20
20
  // ── Usage (read from the record) ─────────────────────────────────────────────
21
21
  /**
@@ -92,6 +92,9 @@ function toSessionEntry(entry, parentId) {
92
92
  const data = { text: contentText(m.content, "") };
93
93
  if (toolCalls.length > 0)
94
94
  data.toolCalls = toolCalls;
95
+ const outcome = answerOutcome(m);
96
+ if (outcome)
97
+ data.outcome = outcome;
95
98
  return { ...base, kind: "assistant", data };
96
99
  }
97
100
  if (m.role === "toolResult") {
@@ -267,10 +270,10 @@ export function createPiSessionControl(options) {
267
270
  // The CONTRACT's list, not a copy of it: a field added to SessionUpdate is advertised
268
271
  // without anyone remembering to, and one removed cannot linger here.
269
272
  updatable: b ? [...UPDATE_FIELDS] : [],
270
- // The registry is a deployment fact (any session may be pointed at any of it). Thinking
271
- // LEVELS are a property of the model a session is running, so they ride
272
- // `state().availableThinkingLevels` — a list here could only answer for one model.
273
- ...(b ? { allowedModels: listModels(b.models) } : {}),
273
+ // The registry is a deployment fact (any session may be pointed at any of it). Each model carries the levels
274
+ // a session on it accepts, so a picker can offer them before the session runs on that model;
275
+ // `state().availableThinkingLevels` answers for the model the session is on.
276
+ ...(b ? { allowedModels: describeModels(b.models.getModels()) } : {}),
274
277
  toolProgress: true, // tool_progress IS delivered (replace-semantics snapshots)
275
278
  usage: true, // state().usage, and state_changed{usage} after each run and compaction
276
279
  };
@@ -289,12 +292,27 @@ export function createPiSessionControl(options) {
289
292
  const b = boundary;
290
293
  let settings;
291
294
  let usage;
292
- if (opened) {
295
+ if (!opened) {
296
+ // An id with no record is an empty conversation at the defaults: its first turn would run on these.
297
+ if (b)
298
+ settings = resolveSessionSettings([], b.models, b.defaults);
299
+ }
300
+ else {
293
301
  try {
294
302
  const path = activePath(opened);
295
303
  if (b)
296
304
  settings = resolveSessionSettings(path, b.models, b.defaults);
297
- usage = sessionUsage(path, opened, settings?.model.contextWindow);
305
+ const selected = settings?.model;
306
+ const latest = selected?.api === "pi-virtual"
307
+ ? opened
308
+ .buildSessionContext()
309
+ .messages.filter((message) => message.role === "assistant" &&
310
+ message.stopReason !== "error" &&
311
+ message.stopReason !== "aborted")
312
+ .at(-1)
313
+ : undefined;
314
+ const physical = latest?.role === "assistant" ? b?.models.getModel(latest.provider, latest.model) : undefined;
315
+ usage = sessionUsage(path, opened, (physical ?? selected)?.contextWindow);
298
316
  }
299
317
  catch (error) {
300
318
  log.warn(`[fastagent] session ${session}: state unreadable (entry chain): ${String(error)}`);
@@ -457,6 +475,21 @@ export function createPiSessionControl(options) {
457
475
  ok: false,
458
476
  error: { code: NO_SUCH_SESSION_CODE, message: `session "${session}" does not exist`, retryable: false },
459
477
  });
478
+ /**
479
+ * The refusal for an id no client could then open: the empty string, `.` and `..` are not URL path segments
480
+ * (isAddressableSession), so minting one would put a row in list() that nothing can address — listed, unopenable by
481
+ * the client that just listed it. Both writes that create a record (fork's `into`, and an update to an id with no
482
+ * record) refuse it.
483
+ */
484
+ const unaddressable = (session) => invalid(`${JSON.stringify(session)} cannot be a session id — the control plane could not address it`);
485
+ const nothingToCompact = () => ({
486
+ ok: false,
487
+ error: {
488
+ code: NOTHING_TO_COMPACT_CODE,
489
+ message: "nothing to compact — the session has no compactable history yet; retry after more turns",
490
+ retryable: false,
491
+ },
492
+ });
460
493
  const invalid = (message) => ({
461
494
  ok: false,
462
495
  error: { code: INVALID_COMMAND_CODE, message, retryable: false },
@@ -518,12 +551,12 @@ export function createPiSessionControl(options) {
518
551
  };
519
552
  }
520
553
  const controls = run.controls;
521
- yield* port(() => action.type === "steer"
522
- ? controls.steer(action.prompt)
523
- : action.type === "follow_up"
524
- ? controls.followUp(action.prompt)
525
- : controls.abort());
526
- return { ok: true, runId: run.runId };
554
+ if (action.type === "abort") {
555
+ yield* port(() => controls.abort());
556
+ return { ok: true, runId: run.runId };
557
+ }
558
+ const disposition = yield* port(() => action.type === "steer" ? controls.steer(action.prompt) : controls.followUp(action.prompt));
559
+ return { ok: true, runId: run.runId, disposition };
527
560
  }).pipe(Effect.catchCause((cause) => Effect.succeed({
528
561
  ok: false,
529
562
  error: { code: RUN_COMMAND_FAILED_CODE, message: String(portError(cause)), retryable: false },
@@ -557,7 +590,7 @@ export function createPiSessionControl(options) {
557
590
  const slash = patch.model.indexOf("/");
558
591
  model = slash > 0 ? b.models.getModel(patch.model.slice(0, slash), patch.model.slice(slash + 1)) : undefined;
559
592
  if (!model) {
560
- return invalid(`unknown model "${patch.model}" — capabilities().allowedModels lists the accepted specs`);
593
+ return invalid(`unknown model "${patch.model}" — capabilities().allowedModels lists the accepted models`);
561
594
  }
562
595
  }
563
596
  if (patch.thinkingLevel !== undefined && !THINKING_LEVELS.has(patch.thinkingLevel)) {
@@ -566,18 +599,18 @@ export function createPiSessionControl(options) {
566
599
  // A name is the client's own label; the only thing that cannot be one is nothing.
567
600
  if (patch.name !== undefined && patch.name.trim() === "")
568
601
  return invalid("a session name cannot be empty");
569
- // Sessions are created by invoke or copied by fork, never minted by an update: an unknown id is
570
- // rejected, not turned into a ghost record. (Read-only handle — the WRITE one is opened under
571
- // the lease below, and this one is discarded.)
602
+ // An id with no record takes properties too: its first turn is the "next turn" they apply to, so the write
603
+ // creates its record (below, under the lease). (Read-only handle — the WRITE one is opened under the lease,
604
+ // and this one is discarded.)
572
605
  const existing = yield* port(() => sessions.openIfExists(session));
573
- if (!existing)
574
- return noSuchSession(session);
606
+ if (!existing && !isAddressableSession(session))
607
+ return unaddressable(session);
575
608
  if (patch.leafEntryId !== undefined) {
576
609
  // A target that cannot BE a leaf is a permanent payload error, not a session error — the same
577
610
  // disposition as an unknown model spec. Same predicate `entries()` publishes by, so
578
611
  // "everything published is a position" holds by construction rather than by two literals
579
612
  // agreeing.
580
- const entry = existing.getEntry(patch.leafEntryId);
613
+ const entry = existing?.getEntry(patch.leafEntryId);
581
614
  if (!entry || !isNavigable(entry)) {
582
615
  return invalid(entry
583
616
  ? `entry "${patch.leafEntryId}" is not a position — entries() publishes every id you can move to, and this is not one of them`
@@ -593,7 +626,8 @@ export function createPiSessionControl(options) {
593
626
  // Against the path this patch LANDS on: a leaf move is written first, and the branch it
594
627
  // moves to can carry a model override of its own — validating on the path being left would
595
628
  // reject a level the destination supports, and accept one it does not.
596
- resolved = resolveSessionSettings(activePath(existing, patch.leafEntryId), b.models, b.defaults);
629
+ const path = existing ? activePath(existing, patch.leafEntryId) : [];
630
+ resolved = resolveSessionSettings(path, b.models, b.defaults);
597
631
  }
598
632
  catch (error) {
599
633
  return failed(error);
@@ -611,6 +645,10 @@ export function createPiSessionControl(options) {
611
645
  yield* acquireSessionLease(b.lease, session);
612
646
  // portJoin, not port: the lease is held for the duration of this write, so an interrupted
613
647
  // fiber must not release it while the store is still writing (effect-port.ts, rule 1).
648
+ // A never-run session's record is created through the data plane's own path, under the same lease an
649
+ // invoke creating it takes; one that invoke created meanwhile is simply opened.
650
+ if (!existing)
651
+ yield* portJoin(() => sessions.openOrCreate(session));
614
652
  return yield* portJoin(() => sessions.applyProperties(session, {
615
653
  ...(patch.name !== undefined ? { name: patch.name } : {}),
616
654
  ...(model ? { model: { provider: model.provider, id: model.id } } : {}),
@@ -619,7 +657,7 @@ export function createPiSessionControl(options) {
619
657
  }));
620
658
  }));
621
659
  if (!applied)
622
- return noSuchSession(session); // vanished in the window: same condition, same code
660
+ return noSuchSession(session); // deleted in the window between the read and the lease
623
661
  if (applied.landed.length > 0) {
624
662
  // ONE event for the patch, built from what the RECORD holds. The settings pair rides along
625
663
  // whenever anything but the name changed: model and thinking level are one setting, and a
@@ -688,6 +726,12 @@ export function createPiSessionControl(options) {
688
726
  const existing = yield* port(() => sessions.openIfExists(session));
689
727
  if (!existing)
690
728
  return noSuchSession(session);
729
+ // A record with no conversation in it (the list's `messageCount: 0`, e.g. one `update()` created before a first
730
+ // turn) has nothing to compact on any branch. Said before binding, because binding is a write: pi records the
731
+ // model and thinking level on a session that has no messages, so asking pi would add two entries and then
732
+ // refuse. Whether a record WITH messages has a cut point stays pi's decision (below).
733
+ if (!existing.getEntries().some(isConversationMessage))
734
+ return nothingToCompact();
691
735
  yield* acquireSessionLease(b.lease, session);
692
736
  const bound = yield* acquireSession(b.sessionFactory, session);
693
737
  yield* Effect.acquireRelease(Effect.try({
@@ -713,16 +757,8 @@ export function createPiSessionControl(options) {
713
757
  Deferred.doneUnsafe(admitted, Effect.succeed({ ok: true }));
714
758
  });
715
759
  const admission = yield* portAbort("compact admission", () => compaction.admission, () => bound.abortCompaction());
716
- if (admission === "nothing_to_compact") {
717
- return {
718
- ok: false,
719
- error: {
720
- code: NOTHING_TO_COMPACT_CODE,
721
- message: "nothing to compact — the session has no compactable history yet; retry after more turns",
722
- retryable: false,
723
- },
724
- };
725
- }
760
+ if (admission === "nothing_to_compact")
761
+ return nothingToCompact();
726
762
  // Any other error pi raises before admission. A served session has no known trigger: it always binds a
727
763
  // model, and pi swallows a credential failure here and meets it at the model call (compaction_finished).
728
764
  // NOT retryable all the same: it is pi's decision on this session, not a transport or lease fault that
@@ -767,12 +803,8 @@ export function createPiSessionControl(options) {
767
803
  const b = boundary;
768
804
  if (!b)
769
805
  return unsupported("fork()");
770
- // An id no client could then open: the empty string, `.` and `..` are not URL path segments
771
- // (isAddressableSession), so minting one would put a row in list() that nothing can address —
772
- // listed, unopenable by the client that just listed it.
773
- if (!isAddressableSession(into)) {
774
- return invalid(`${JSON.stringify(into)} cannot be a session id — the control plane could not address it`);
775
- }
806
+ if (!isAddressableSession(into))
807
+ return unaddressable(into);
776
808
  const source = yield* port(() => sessions.openIfExists(from));
777
809
  if (!source)
778
810
  return noSuchSession(from);
@@ -2,8 +2,17 @@
2
2
  import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
3
3
  import { type Models } from "@earendil-works/pi-ai";
4
4
  import type { SessionManager } from "@earendil-works/pi-coding-agent";
5
+ import type { ModelDescriptor } from "../../session.ts";
5
6
  import type { AnyModel } from "./models.ts";
6
7
  export declare const THINKING_LEVELS: ReadonlySet<ThinkingLevel>;
8
+ /**
9
+ * Models as a client's picker shows them, ordered by spec: the ONE description both listings give
10
+ * (`capabilities().allowedModels` and `availableModelsFromDir`). Its levels come from the function
11
+ * `resolveSessionSettings` reads, so a picker offers what `state()` will then report. A model that declares no name or
12
+ * context window (an extension's virtual model may not: pi reads the window as 0) leaves the field out rather than
13
+ * inventing one.
14
+ */
15
+ export declare function describeModels(models: readonly AnyModel[]): ModelDescriptor[];
7
16
  /** The shape both override consumers walk — a session entry, structurally. */
8
17
  export interface OverrideEntryLike {
9
18
  type: string;
@@ -10,6 +10,23 @@ const ALL_THINKING_LEVELS = {
10
10
  max: true,
11
11
  };
12
12
  export const THINKING_LEVELS = new Set(Object.keys(ALL_THINKING_LEVELS));
13
+ /**
14
+ * Models as a client's picker shows them, ordered by spec: the ONE description both listings give
15
+ * (`capabilities().allowedModels` and `availableModelsFromDir`). Its levels come from the function
16
+ * `resolveSessionSettings` reads, so a picker offers what `state()` will then report. A model that declares no name or
17
+ * context window (an extension's virtual model may not: pi reads the window as 0) leaves the field out rather than
18
+ * inventing one.
19
+ */
20
+ export function describeModels(models) {
21
+ return models
22
+ .map((model) => ({
23
+ spec: `${model.provider}/${model.id}`,
24
+ ...(model.name ? { name: model.name } : {}),
25
+ thinkingLevels: getSupportedThinkingLevels(model),
26
+ ...(model.contextWindow > 0 ? { contextWindow: model.contextWindow } : {}),
27
+ }))
28
+ .sort((a, b) => (a.spec < b.spec ? -1 : a.spec > b.spec ? 1 : 0));
29
+ }
13
30
  /**
14
31
  * The last entry of each kind wins, and a malformed one reads as ABSENT rather than falling through to an earlier
15
32
  * record.
@@ -6,8 +6,8 @@ import { type SessionInheritance } from "./session-inheritance.ts";
6
6
  export interface PiSessionRecordStore {
7
7
  openOrCreate(sessionId: string, inherit?: SessionInheritance): Promise<SessionManager>;
8
8
  /**
9
- * OPEN-EXISTING sibling: an unknown session answers undefined, never creates one — sessions are the data plane's
10
- * monopoly.
9
+ * OPEN-EXISTING sibling: an unknown session answers undefined, never creates one. The reads and every write that
10
+ * needs history go through it; creating is `openOrCreate`'s.
11
11
  */
12
12
  openIfExists(sessionId: string): Promise<SessionManager | undefined>;
13
13
  /** Write session properties, in the order pi's leaf pointer requires, and report what LANDED. */