@fastagent-sh/fastagent 0.13.0 → 0.14.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 (90) hide show
  1. package/README.md +2 -2
  2. package/dist/channels/feishu/context-buffer.d.ts +46 -0
  3. package/dist/channels/feishu/context-buffer.js +133 -0
  4. package/dist/channels/feishu/crypto.d.ts +4 -2
  5. package/dist/channels/feishu/crypto.js +4 -2
  6. package/dist/channels/feishu/feishu-api.d.ts +4 -4
  7. package/dist/channels/feishu/feishu-api.js +2 -2
  8. package/dist/channels/feishu/feishu.d.ts +11 -1
  9. package/dist/channels/feishu/feishu.js +143 -30
  10. package/dist/channels/feishu/invoke-turn.d.ts +19 -13
  11. package/dist/channels/feishu/invoke-turn.js +70 -19
  12. package/dist/channels/feishu/model.d.ts +97 -0
  13. package/dist/channels/feishu/model.js +9 -0
  14. package/dist/channels/feishu/normalize.d.ts +22 -0
  15. package/dist/channels/feishu/normalize.js +132 -0
  16. package/dist/channels/feishu/owned-threads.d.ts +11 -0
  17. package/dist/channels/feishu/owned-threads.js +47 -0
  18. package/dist/channels/feishu/parse.d.ts +20 -102
  19. package/dist/channels/feishu/parse.js +35 -145
  20. package/dist/channels/feishu/preview.js +2 -2
  21. package/dist/channels/feishu/scaffold/channel.ts +10 -3
  22. package/dist/channels/feishu/seen.js +12 -12
  23. package/dist/channels/lark/scaffold/channel.ts +10 -3
  24. package/dist/cli/commands/add.d.ts +9 -0
  25. package/dist/cli/commands/add.js +142 -0
  26. package/dist/cli/commands/chat.d.ts +3 -0
  27. package/dist/cli/commands/chat.js +16 -0
  28. package/dist/cli/commands/deploy.d.ts +13 -0
  29. package/dist/cli/commands/deploy.js +338 -0
  30. package/dist/cli/commands/dev.d.ts +11 -0
  31. package/dist/cli/commands/dev.js +76 -0
  32. package/dist/cli/commands/fire.d.ts +7 -0
  33. package/dist/cli/commands/fire.js +45 -0
  34. package/dist/cli/commands/info.d.ts +7 -0
  35. package/dist/cli/commands/info.js +108 -0
  36. package/dist/cli/commands/init.d.ts +8 -0
  37. package/dist/cli/commands/init.js +81 -0
  38. package/dist/cli/commands/invoke.d.ts +7 -0
  39. package/dist/cli/commands/invoke.js +29 -0
  40. package/dist/cli/commands/login.d.ts +6 -0
  41. package/dist/cli/commands/login.js +63 -0
  42. package/dist/cli/commands/models.d.ts +1 -0
  43. package/dist/cli/commands/models.js +15 -0
  44. package/dist/cli/commands/schedule.d.ts +12 -0
  45. package/dist/cli/commands/schedule.js +89 -0
  46. package/dist/cli/commands/start.d.ts +10 -0
  47. package/dist/cli/commands/start.js +90 -0
  48. package/dist/cli/commands/tool.d.ts +1 -0
  49. package/dist/cli/commands/tool.js +37 -0
  50. package/dist/cli/fail.d.ts +19 -0
  51. package/dist/cli/fail.js +32 -0
  52. package/dist/cli/kernel.d.ts +89 -0
  53. package/dist/cli/kernel.js +190 -0
  54. package/dist/cli/program.d.ts +11 -0
  55. package/dist/cli/program.js +421 -0
  56. package/dist/cli/serve.d.ts +28 -0
  57. package/dist/cli/serve.js +90 -0
  58. package/dist/cli/shared.d.ts +24 -0
  59. package/dist/cli/shared.js +116 -0
  60. package/dist/cli.js +8 -1329
  61. package/dist/deploy/docker/plan.d.ts +45 -0
  62. package/dist/deploy/docker/plan.js +139 -0
  63. package/dist/deploy/docker/run.d.ts +40 -0
  64. package/dist/deploy/docker/run.js +126 -0
  65. package/dist/deploy/preflight.js +4 -3
  66. package/dist/deploy/runner.d.ts +4 -1
  67. package/dist/deploy/runner.js +1 -0
  68. package/dist/engines/pi/auth.js +160 -46
  69. package/dist/engines/pi/chat.js +77 -4
  70. package/dist/engines/pi/config.d.ts +12 -3
  71. package/dist/engines/pi/config.js +16 -1
  72. package/dist/engines/pi/create.d.ts +14 -5
  73. package/dist/engines/pi/create.js +44 -9
  74. package/dist/engines/pi/harness.d.ts +16 -1
  75. package/dist/engines/pi/harness.js +77 -1
  76. package/dist/engines/pi/invoke.d.ts +1 -1
  77. package/dist/engines/pi/invoke.js +37 -2
  78. package/dist/engines/pi/login.js +1 -1
  79. package/dist/engines/pi/search-tools.d.ts +10 -0
  80. package/dist/engines/pi/search-tools.js +138 -0
  81. package/dist/engines/pi/tool-context.d.ts +28 -0
  82. package/dist/engines/pi/tool-context.js +8 -0
  83. package/dist/engines/pi/tool.d.ts +32 -1
  84. package/dist/engines/pi/tool.js +42 -1
  85. package/dist/engines/pi/workspace.d.ts +4 -1
  86. package/dist/engines/pi/workspace.js +3 -1
  87. package/dist/pi.d.ts +2 -1
  88. package/dist/scaffold/add-channel.js +3 -1
  89. package/dist/scaffold/templates/fastagent.config.mjs +1 -0
  90. package/package.json +7 -4
@@ -12,22 +12,24 @@
12
12
  * token and persisting the new one elsewhere leaves global stale for every other consumer.
13
13
  *
14
14
  * Sharing is still SAFE the right way: point everything at ONE file (`FASTAGENT_AUTH_PATH` → the
15
- * global path). One file means one refresh lifecycle under `FileAuthStorageBackend`'s cross-process
16
- * lock (refresh re-reads the latest token under the lock) the documented same-machine pattern.
15
+ * global path). One file means one refresh lifecycle under the store's cross-process write lock
16
+ * (refresh re-reads the latest token under the lock), the documented same-machine pattern.
17
17
  * fastagent's store stays SEPARATE from the pi CLI's `~/.pi/agent/auth.json` for the same single-
18
18
  * lifecycle reason: two uncoordinated files over one grant would each rotate and break the other.
19
19
  *
20
- * Persistence + locking reuse pi's `FileAuthStorageBackend` (a cross-process file lock) on the WRITE
21
- * path only. `read` is pi-ai's per-request hot path, so it stays UNLOCKED; the backend's in-place
22
- * write opens only a sub-millisecond torn-read window, which `read` absorbs by re-reading. The write
23
- * path refuses to overwrite a corrupt file (never clobbering other providers' credentials).
20
+ * Locking is vendored here on `proper-lockfile`, with the same parameters pi's file backend used
21
+ * before pi 0.80.8 stopped exporting it (upstream's stated migration path for SDK consumers is a
22
+ * custom pi-ai `CredentialStore`, which this file is). The lock guards the WRITE path only. `read`
23
+ * is pi-ai's per-request hot path, so it stays UNLOCKED; the in-place locked write opens only a
24
+ * sub-millisecond torn-read window, which `read` absorbs by re-reading. The write path refuses to
25
+ * overwrite a corrupt file (never clobbering other providers' credentials).
24
26
  */
25
- import { existsSync, readFileSync } from "node:fs";
27
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
26
28
  import { homedir } from "node:os";
27
- import { join } from "node:path";
29
+ import { dirname, join } from "node:path";
28
30
  import { log } from "../../log.js";
29
31
  import { setTimeout as sleep } from "node:timers/promises";
30
- import { FileAuthStorageBackend } from "@earendil-works/pi-coding-agent";
32
+ import lockfile from "proper-lockfile";
31
33
  /**
32
34
  * The GLOBAL fastagent credentials file (distinct from pi's `~/.pi`). The project-level default is
33
35
  * `<dir>/.fastagent/auth.json` (computed by the opener and by `fastagent login`); this is only the
@@ -42,57 +44,169 @@ function pick(creds, providerId) {
42
44
  const cred = creds[providerId];
43
45
  return cred && (cred.type === "oauth" || cred.type === "api_key") ? cred : undefined;
44
46
  }
47
+ const AUTH_FILE_WRITE_OPTIONS = { encoding: "utf8", mode: 0o600 };
45
48
  /**
46
- * Parse the credentials JSON for a WRITE: a corrupt file must THROW — serializing `{}` over it would
47
- * wipe every other provider's credentials. The throw aborts the locked write, leaving the file intact.
49
+ * Serialized cross-process read-modify-write of the credentials file: exponential-backoff retries,
50
+ * 30s staleness, and compromise detection (the parameters pi's `FileAuthStorageBackend` used).
51
+ * Ensures the file exists first (0700 dir, 0600 file, EXCLUSIVE create: a concurrent first write
52
+ * must never be clobbered by the init) because `proper-lockfile` locks an existing path. A
53
+ * compromised lock aborts before the write rather than clobbering a concurrent writer, and a
54
+ * failed unlock after a successful operation rejects instead of leaving a stale lock silently.
48
55
  */
49
- function parseForWrite(raw, where) {
50
- if (!raw)
51
- return {};
56
+ async function withLockedAuthFile(authPath, fn) {
57
+ const dir = dirname(authPath);
58
+ if (!existsSync(dir))
59
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
60
+ if (!existsSync(authPath)) {
61
+ try {
62
+ writeFileSync(authPath, "{}", { ...AUTH_FILE_WRITE_OPTIONS, flag: "wx" });
63
+ chmodSync(authPath, 0o600);
64
+ }
65
+ catch (error) {
66
+ // EEXIST: another process created the file between the existence check and this exclusive
67
+ // create; its content (possibly already-written credentials) must not be clobbered.
68
+ if (error.code !== "EEXIST")
69
+ throw error;
70
+ }
71
+ }
72
+ let compromised;
73
+ const throwIfCompromised = () => {
74
+ if (compromised)
75
+ throw compromised;
76
+ };
77
+ const release = await lockfile.lock(authPath, {
78
+ retries: { retries: 10, factor: 2, minTimeout: 100, maxTimeout: 10_000, randomize: true },
79
+ stale: 30_000,
80
+ onCompromised: (error) => {
81
+ compromised = error;
82
+ },
83
+ });
84
+ let result;
85
+ try {
86
+ throwIfCompromised();
87
+ const current = existsSync(authPath) ? readFileSync(authPath, "utf8") : undefined;
88
+ const out = await fn(current);
89
+ throwIfCompromised();
90
+ if (out.next !== undefined) {
91
+ writeFileSync(authPath, out.next, AUTH_FILE_WRITE_OPTIONS);
92
+ chmodSync(authPath, 0o600);
93
+ }
94
+ throwIfCompromised();
95
+ result = out.result;
96
+ }
97
+ catch (error) {
98
+ // The primary failure stays the signal; unlock noise must not mask it.
99
+ try {
100
+ await release();
101
+ }
102
+ catch {
103
+ // Secondary: a compromised or stale-reclaimed lock often cannot release cleanly.
104
+ }
105
+ throw error;
106
+ }
107
+ // Success path: a failed release is a real cleanup failure (the leftover auth.json.lock stalls
108
+ // the next writer for the staleness window with zero diagnostics), so it surfaces instead of
109
+ // resolving a silently degraded operation. A compromise detected after the last in-band check
110
+ // surfaces here too.
111
+ try {
112
+ await release();
113
+ }
114
+ catch (releaseError) {
115
+ if (compromised === undefined)
116
+ throw releaseError;
117
+ }
118
+ throwIfCompromised();
119
+ return result;
120
+ }
121
+ /**
122
+ * Decode the credentials JSON, shared by the read and write paths. The root must be a plain
123
+ * non-null, non-array object: `[]`, `null`, and scalar roots pass JSON.parse but break the record
124
+ * semantics (an array root even swallows writes, since JSON.stringify drops string keys on arrays).
125
+ * Structurally invalid = corrupt, exactly like unparsable text.
126
+ */
127
+ function decodeCreds(raw) {
128
+ let parsed;
52
129
  try {
53
- return JSON.parse(raw);
130
+ parsed = JSON.parse(raw);
54
131
  }
55
132
  catch {
56
- throw new Error(`refusing to overwrite corrupt auth file ${where} — fix or remove it`);
133
+ return undefined;
57
134
  }
135
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
136
+ return undefined;
137
+ return parsed;
138
+ }
139
+ /**
140
+ * Tolerant UNLOCKED read of the whole credentials file, shared by `read` and `list`. The only race
141
+ * is a sub-millisecond in-place write during an OAuth rotation, which can yield an empty/partial
142
+ * file; re-read a few times before concluding it is corrupt. A missing file reads as undefined
143
+ * silently (normal not-configured); a valid file returns immediately, so the common case costs one
144
+ * read.
145
+ */
146
+ async function readCreds(authPath, warn) {
147
+ for (let attempt = 0; attempt < 3; attempt++) {
148
+ let raw;
149
+ try {
150
+ raw = readFileSync(authPath, "utf8");
151
+ }
152
+ catch (error) {
153
+ if (error.code === "ENOENT")
154
+ return undefined; // missing/deleted
155
+ warn(`[fastagent] cannot read ${authPath}: ${error.message}`);
156
+ return undefined;
157
+ }
158
+ if (raw !== "") {
159
+ const creds = decodeCreds(raw);
160
+ if (creds !== undefined)
161
+ return creds;
162
+ // A partial read mid-write parses as garbage; fall through and retry. A structurally invalid
163
+ // root lands here too and is reported as corrupt below.
164
+ }
165
+ if (attempt < 2)
166
+ await sleep(2);
167
+ }
168
+ warn(`[fastagent] corrupt auth file ${authPath}: fix or remove it`);
169
+ return undefined;
170
+ }
171
+ /**
172
+ * Parse the credentials JSON for a WRITE: a corrupt file must THROW, because serializing `{}` over
173
+ * it would wipe every other provider's credentials. The throw aborts the locked write, leaving the
174
+ * file intact.
175
+ */
176
+ function parseForWrite(raw, where) {
177
+ if (!raw)
178
+ return {};
179
+ const creds = decodeCreds(raw);
180
+ if (creds === undefined) {
181
+ throw new Error(`refusing to overwrite corrupt auth file ${where}: fix or remove it`);
182
+ }
183
+ return creds;
58
184
  }
59
185
  /** A read-write `CredentialStore` backed by the given credentials file (default {@link GLOBAL_AUTH_PATH};
60
186
  * the directory opener passes the project-level `<dir>/.fastagent/auth.json`). */
61
187
  export function fastagentCredentialStore(authPath = GLOBAL_AUTH_PATH, options = {}) {
62
188
  const warn = options.warn ?? ((message) => log.warn(message));
63
- const backend = new FileAuthStorageBackend(authPath);
64
189
  return {
65
190
  async read(providerId) {
66
- // UNLOCKED hot path: the only race is a sub-millisecond in-place write during an OAuth rotation,
67
- // which can yield an empty/partial file. Re-read a few times before concluding it is corrupt;
68
- // a valid `{}` (provider absent) returns immediately, so a not-configured read costs nothing.
69
- for (let attempt = 0; attempt < 3; attempt++) {
70
- let raw;
71
- try {
72
- raw = readFileSync(authPath, "utf8");
73
- }
74
- catch (error) {
75
- if (error.code === "ENOENT")
76
- return undefined; // missing/deleted
77
- warn(`[fastagent] cannot read ${authPath}: ${error.message}`);
78
- return undefined;
79
- }
80
- if (raw !== "") {
81
- try {
82
- return pick(JSON.parse(raw), providerId);
83
- }
84
- catch {
85
- // A partial read mid-write parses as garbage — fall through and retry.
86
- }
191
+ const creds = await readCreds(authPath, warn);
192
+ return creds ? pick(creds, providerId) : undefined;
193
+ },
194
+ async list() {
195
+ // Metadata only, never secrets (the pi-ai `list` contract). Foreign/old entries are filtered
196
+ // with the same validation as `read`, so both surfaces agree on what "configured" means.
197
+ const creds = await readCreds(authPath, warn);
198
+ if (!creds)
199
+ return [];
200
+ const infos = [];
201
+ for (const [providerId, cred] of Object.entries(creds)) {
202
+ if (cred && (cred.type === "oauth" || cred.type === "api_key")) {
203
+ infos.push({ providerId, type: cred.type });
87
204
  }
88
- if (attempt < 2)
89
- await sleep(2);
90
205
  }
91
- warn(`[fastagent] corrupt auth file ${authPath} — fix or remove it`);
92
- return undefined;
206
+ return infos;
93
207
  },
94
208
  modify(providerId, fn) {
95
- return backend.withLockAsync(async (current) => {
209
+ return withLockedAuthFile(authPath, async (current) => {
96
210
  const creds = parseForWrite(current, authPath); // corrupt → throw → no clobber
97
211
  const next = await fn(pick(creds, providerId));
98
212
  if (next === undefined)
@@ -102,11 +216,11 @@ export function fastagentCredentialStore(authPath = GLOBAL_AUTH_PATH, options =
102
216
  });
103
217
  },
104
218
  async delete(providerId) {
105
- // No-op when nothing is stored: do NOT take the lock (which would create the file via the
106
- // backend's ensureFileExists) on a machine that never stored this provider.
219
+ // No-op when nothing is stored: do NOT take the lock (which would create the file) on a
220
+ // machine that never stored this provider.
107
221
  if (!existsSync(authPath))
108
222
  return;
109
- await backend.withLockAsync(async (current) => {
223
+ await withLockedAuthFile(authPath, async (current) => {
110
224
  const creds = parseForWrite(current, authPath);
111
225
  if (!(providerId in creds))
112
226
  return { result: undefined }; // absent: no write
@@ -28,7 +28,9 @@ import { loadConfig, resolveAgentDir, resolveModel, resolveModelSpec } from "./c
28
28
  import { assembleSystemPrompt, piBasePrompt, piDefaultTools, resolveTools } from "./create.js";
29
29
  import { createPiModels } from "./models.js";
30
30
  import { canonicalPath, loadAgentDefinition } from "./definition.js";
31
- import { loadTools, mergeDiscoveredTools } from "./tool.js";
31
+ import { isDeferredTool, loadTools, mergeDiscoveredTools } from "./tool.js";
32
+ import { withSearchTool } from "./search-tools.js";
33
+ import { additiveActivation, turnContext } from "./tool-context.js";
32
34
  import { reportDefinitionWarnings, reportModuleLoadFailures, reportToolCollisions } from "./report.js";
33
35
  /**
34
36
  * Build pi's interactive runtime driven by fastagent's assembled agent (model, prompt, tools,
@@ -38,6 +40,34 @@ import { reportDefinitionWarnings, reportModuleLoadFailures, reportToolCollision
38
40
  export async function buildChatRuntime(dir, options = {},
39
41
  /** Session backend. Defaults to pi's project-scoped store; tests inject SessionManager.inMemory(). */
40
42
  sessionManager) {
43
+ /** The turn's {@link ToolActivation} over pi's AgentSession — the chat counterpart of invoke.ts's
44
+ * harness bridge, so the SAME builtin search_tools serves both paths. Additive; unknown names
45
+ * filtered (`setActiveToolsByName` is authoritative on the session and rebuilds its prompt — our
46
+ * static override keeps the prompt identical to serving). */
47
+ function chatToolActivation(session) {
48
+ // Same serialization as invoke.ts's bridge (there per turn; here per session — chat turns are
49
+ // interactive, so per-session is equivalent): the read-modify-write below is only race-free while
50
+ // nothing awaits between read and write, and pi's session setters happening to be synchronous today
51
+ // is not a contract worth betting parallel tool batches on. Built ONCE per session (createRuntime),
52
+ // so parallel calls actually share the chain.
53
+ let chain = Promise.resolve([]);
54
+ return {
55
+ active: () => session.getActiveToolNames(),
56
+ registered: () => session.getAllTools().map((t) => ({ name: t.name, description: t.description ?? "" })),
57
+ activate(names) {
58
+ const run = async () => {
59
+ const current = session.getActiveToolNames();
60
+ const added = additiveActivation(session.getAllTools().map((t) => t.name), current, names);
61
+ if (added.length > 0)
62
+ session.setActiveToolsByName([...current, ...added]);
63
+ return added;
64
+ };
65
+ const result = chain.then(run, run); // run after the predecessor settles, success or failure
66
+ chain = result.catch(() => []); // the caller sees a rejection on `result`; the chain stays usable
67
+ return result;
68
+ },
69
+ };
70
+ }
41
71
  async function resolveAssembly(cwd) {
42
72
  const { config } = await loadConfig(cwd);
43
73
  const modelSpec = resolveModelSpec(options.model, config);
@@ -55,18 +85,39 @@ sessionManager) {
55
85
  // Same tool resolution as the dev opener, then split: defaults go to pi by NAME (rebuilt cwd-bound
56
86
  // for rich rendering); customs go through pi's `customTools` path so they survive /new, /resume, fork.
57
87
  const discovered = await loadTools(agentDir);
58
- const { tools, collisions: crossCollisions } = mergeDiscoveredTools(resolveTools(config, cwd), discovered.tools);
88
+ const merged = mergeDiscoveredTools(resolveTools(config, cwd), discovered.tools);
89
+ // Chat EMULATES deferral, like serving (what you iterate is what you serve): the builtin loader
90
+ // mounts when a deferred tool exists, the initial active set excludes deferred tools (applied on
91
+ // the session in createRuntime below — pi's TUI session starts all-active), and the activation
92
+ // bridge below rides the same turn context the serving path uses, so the SAME search_tools works
93
+ // against pi's AgentSession instead of fastagent's harness.
94
+ const tools = withSearchTool(merged.tools);
95
+ const crossCollisions = merged.collisions;
59
96
  reportToolCollisions([...discovered.collisions, ...crossCollisions]);
60
97
  reportModuleLoadFailures(discovered.failures);
61
98
  const defaultNames = piDefaultTools(cwd).map((t) => t.name);
62
99
  const customTools = tools.filter((t) => !defaultNames.includes(t.name));
63
- // Adapt fastagent's AgentTool to pi's ToolDefinition (`parameters` is plain JSON-Schema; pi accepts it).
100
+ // Adapt fastagent's AgentTool to pi's ToolDefinition (`parameters` is plain JSON-Schema; pi accepts
101
+ // it). Each execute runs inside the turn context with the CURRENT session's activation bridge — the
102
+ // assembly is memoized across /new//resume/fork rebuilds while the session changes, so the bridge
103
+ // resolves through sessionRef at call time, exactly like the serving path resolves its harness.
64
104
  const customToolDefs = customTools.map((t) => ({
65
105
  name: t.name,
66
106
  label: t.name,
67
107
  description: t.description ?? "",
68
108
  parameters: t.parameters,
69
- execute: (id, params, signal) => t.execute(id, params, signal),
109
+ // Propagate the execution mode — an activating tool (the builtin loader) declares "sequential"
110
+ // so pi serializes its batch; without this, pi's outer active-set diff double-stamps parallels.
111
+ executionMode: t.executionMode,
112
+ execute: (id, params, signal) => {
113
+ const bound = sessionRef.current;
114
+ // Unreachable by construction (createRuntime sets sessionRef before any turn can run a tool).
115
+ // Throw rather than silently run outside the turn context — that would disguise a broken
116
+ // session-lifecycle invariant as a normal out-of-turn call (fail visibly).
117
+ if (!bound)
118
+ throw new Error("chat tool executed before its session was built (lifecycle invariant broken)");
119
+ return turnContext.run({ session: bound.session.sessionId, tools: bound.activation }, () => t.execute(id, params, signal));
120
+ },
70
121
  }));
71
122
  // base + instructions ONLY — pi appends the skill section and env (cwd) itself (including
72
123
  // them here would duplicate them).
@@ -82,6 +133,13 @@ sessionManager) {
82
133
  // edits. And keep it workspace-scoped — `.env` is process-global, so a switch to another cwd would
83
134
  // leak env or require mutating global env at runtime.
84
135
  const rootCwd = canonicalPath(dir);
136
+ // The CURRENT pi session + its activation bridge, BOUND TOGETHER — rebuilt on /new//resume/fork
137
+ // while the memoized assembly (and its tool execute closures) stays. The bridge must share the
138
+ // session's lifetime, NOT be rebuilt per tool call (a per-call chain serializes nothing). Note on
139
+ // parallel batches: pi wraps SDK customTools in its own before/after active-set diff, so an
140
+ // activating tool must carry `executionMode: "sequential"` (the builtin loader does) — pi then runs
141
+ // the whole batch serially and the outer diff sees correct snapshots.
142
+ const sessionRef = {};
85
143
  let assembly;
86
144
  const assemblyFor = (cwd) => {
87
145
  // Canonical paths: pi's process.cwd() fallback is a realpath, so a symlinked workspace would
@@ -135,6 +193,21 @@ sessionManager) {
135
193
  tools: [...defaultNames, ...customTools.map((t) => t.name)],
136
194
  customTools: customToolDefs,
137
195
  });
196
+ sessionRef.current = { session: result.session, activation: chatToolActivation(result.session) };
197
+ // Deferral emulation: pi's TUI session starts with everything active — narrow it by SUBTRACTING
198
+ // the deferred names from whatever is active (robust to pi mounting tools of its own; an
199
+ // exact-set-equality gate would silently stop narrowing the day pi adds one). Applied on EVERY
200
+ // build including /resume: pi's chat session does not record activations (its SessionContext has
201
+ // no activeToolNames), so "restore prior activations" is not implementable here — deferral stays
202
+ // consistently ON and a resumed conversation re-discovers via search_tools (documented divergence
203
+ // from serving, where activations persist in the session).
204
+ const deferredNames = customTools.filter(isDeferredTool).map((t) => t.name);
205
+ if (deferredNames.length > 0) {
206
+ const active = result.session.getActiveToolNames();
207
+ if (deferredNames.some((n) => active.includes(n))) {
208
+ result.session.setActiveToolsByName(active.filter((n) => !deferredNames.includes(n)));
209
+ }
210
+ }
138
211
  return { ...result, services, diagnostics: services.diagnostics };
139
212
  };
140
213
  const runtime = await createAgentSessionRuntime(createRuntime, {
@@ -1,9 +1,17 @@
1
- import type { AgentTool } from "@earendil-works/pi-agent-core";
1
+ import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
2
+ import type { FastagentTool } from "./tool.ts";
2
3
  import type { Models } from "@earendil-works/pi-ai";
3
4
  import type { AnyModel } from "./harness.ts";
5
+ /** pi's thinking levels (types.d.ts `ThinkingLevel`), as a runtime list for config validation — pi
6
+ * exports only the type. A level the selected model does not support is clamped by pi per model. */
7
+ export declare const THINKING_LEVELS: readonly ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
4
8
  export interface FastagentConfig {
5
9
  /** "provider/modelId". Precedence: CLI --model > FASTAGENT_MODEL > config. */
6
10
  model?: string;
11
+ /** Reasoning effort for the model, pi's scale ("off" | "minimal" | "low" | "medium" | "high" |
12
+ * "xhigh" | "max"). Unset = pi's default. Authors tune thinking in the pi TUI while vibing — this
13
+ * is the serving-side counterpart (fidelity). Levels a model doesn't support are clamped by pi. */
14
+ thinkingLevel?: ThinkingLevel;
7
15
  /**
8
16
  * The agent-definition subdirectory (persona.md, skills/, tools/, channels/), relative to the config
9
17
  * file's directory. Default: the config directory itself (flat — today's behaviour). Point it at a
@@ -12,8 +20,9 @@ export interface FastagentConfig {
12
20
  * subdir and does not collide with the host's `tools/`/`src/` (core.md scenario grid).
13
21
  */
14
22
  agentDir?: string;
15
- /** Extra custom tools, appended after pi defaults — never replaces them. */
16
- tools?: AgentTool[];
23
+ /** Extra custom tools, appended after pi defaults — never replaces them. `FastagentTool` = AgentTool
24
+ * plus the optional `deferred` marker (see defineTool). */
25
+ tools?: FastagentTool[];
17
26
  http?: {
18
27
  port?: number;
19
28
  };
@@ -19,6 +19,17 @@ import { homedir } from "node:os";
19
19
  import { basename, isAbsolute, join, relative, resolve, sep } from "node:path";
20
20
  import { pathToFileURL } from "node:url";
21
21
  import { moduleLoadHint } from "../../loader.js";
22
+ /** pi's thinking levels (types.d.ts `ThinkingLevel`), as a runtime list for config validation — pi
23
+ * exports only the type. A level the selected model does not support is clamped by pi per model. */
24
+ export const THINKING_LEVELS = [
25
+ "off",
26
+ "minimal",
27
+ "low",
28
+ "medium",
29
+ "high",
30
+ "xhigh",
31
+ "max",
32
+ ];
22
33
  /** Identity function for typing and IDE completion (vite/next-style). */
23
34
  export function defineConfig(config) {
24
35
  return config;
@@ -70,17 +81,21 @@ export async function loadConfig(dir) {
70
81
  // (`modle:`) must not silently degrade to zero-config.
71
82
  for (const key of Object.keys(c)) {
72
83
  if (key !== "model" &&
84
+ key !== "thinkingLevel" &&
73
85
  key !== "agentDir" &&
74
86
  key !== "tools" &&
75
87
  key !== "http" &&
76
88
  key !== "deploy" &&
77
89
  key !== "selfSchedule") {
78
- throw new Error(`${path}: unknown key "${key}" (valid keys: model, agentDir, tools, http, deploy, selfSchedule)`);
90
+ throw new Error(`${path}: unknown key "${key}" (valid keys: model, thinkingLevel, agentDir, tools, http, deploy, selfSchedule)`);
79
91
  }
80
92
  }
81
93
  if (c.model !== undefined && typeof c.model !== "string") {
82
94
  throw new Error(`${path}: "model" must be a "provider/modelId" string`);
83
95
  }
96
+ if (c.thinkingLevel !== undefined && !THINKING_LEVELS.includes(c.thinkingLevel)) {
97
+ throw new Error(`${path}: "thinkingLevel" must be one of ${THINKING_LEVELS.join(", ")}`);
98
+ }
84
99
  if (c.agentDir !== undefined && typeof c.agentDir !== "string") {
85
100
  throw new Error(`${path}: "agentDir" must be a string (a subdirectory relative to the config file)`);
86
101
  }
@@ -1,11 +1,11 @@
1
- import type { AgentTool, ExecutionEnv, Skill } from "@earendil-works/pi-agent-core";
1
+ import type { AgentTool, ExecutionEnv, Skill, ThinkingLevel } from "@earendil-works/pi-agent-core";
2
2
  import type { Provider } from "@earendil-works/pi-ai";
3
3
  import type { Agent } from "../../agent.ts";
4
4
  import { type FastagentConfig } from "./config.ts";
5
5
  import { type LoadedDefinition } from "./definition.ts";
6
6
  import { type PiSessionStore } from "./sessions.ts";
7
7
  import type { ModuleLoadFailure } from "../../loader.ts";
8
- import { type ToolCollision } from "./tool.ts";
8
+ import { type FastagentTool, type ToolCollision } from "./tool.ts";
9
9
  import { type Lease } from "./invoke.ts";
10
10
  /** pi's core default toolset (read/bash/edit/write), rooted at cwd. */
11
11
  export declare function piDefaultTools(cwd: string): AgentTool[];
@@ -19,6 +19,9 @@ export declare function resolveTools(config: FastagentConfig, cwd: string): Agen
19
19
  export declare function resolveWorkspaceTools(config: FastagentConfig, agentDir: string, cwd?: string): Promise<{
20
20
  tools: AgentTool[];
21
21
  toolNames: string[];
22
+ /** Tools registered but not initially active (defineTool `deferred: true`) — discovered/activated
23
+ * via the built-in `search_tools` loader. Surfaced so the operator can see deferral took effect. */
24
+ deferredToolNames: string[];
22
25
  toolCollisions: ToolCollision[];
23
26
  toolFailures: ModuleLoadFailure[];
24
27
  }>;
@@ -53,13 +56,16 @@ export declare function assembleSystemPrompt(options: AssembleSystemPromptOption
53
56
  export interface CreatePiAgentOptions {
54
57
  /** Model spec "provider/modelId" (e.g. "openai-codex/gpt-5.5"), resolved against {@link models}. */
55
58
  model: string;
59
+ /** Reasoning effort (pi's scale). Unset = pi's default; unsupported levels are clamped per model. */
60
+ thinkingLevel?: ThinkingLevel;
56
61
  /**
57
62
  * The system prompt itself — verbatim, no engine base and no wrapping (unlike the directory path,
58
63
  * which assembles the engine base + AGENTS.md as segment ② + persona.md as segment ①). A plain string
59
64
  * or a factory re-evaluated per invoke. When {@link skills} are mounted their listing is appended.
60
65
  */
61
66
  instructions?: string | (() => string);
62
- tools?: AgentTool[];
67
+ /** `FastagentTool` = AgentTool plus the optional `deferred` marker (see {@link DefineToolOptions}). */
68
+ tools?: FastagentTool[];
63
69
  skills?: Skill[];
64
70
  /**
65
71
  * Extra providers registered on top of the built-ins — your own gateway / self-hosted endpoint /
@@ -90,11 +96,14 @@ export declare function createPiAgent(options: CreatePiAgentOptions): Agent;
90
96
  export interface CreatePiAgentFromDefinitionOptions {
91
97
  /** Model spec "provider/modelId", resolved against {@link models}. */
92
98
  model: string;
99
+ /** Reasoning effort (pi's scale). Unset = pi's default; unsupported levels are clamped per model. */
100
+ thinkingLevel?: ThinkingLevel;
93
101
  /** Override the engine base prompt (segment ①). Defaults to piBasePrompt({ tools, persona }) using the
94
102
  * live-read persona.md; pass base to fully opt out of persona.md. */
95
103
  base?: string;
96
- /** Override tools. Defaults to piDefaultTools (lock down with a custom list). */
97
- tools?: AgentTool[];
104
+ /** Override tools. Defaults to piDefaultTools (lock down with a custom list). `FastagentTool` =
105
+ * AgentTool plus the optional `deferred` marker. */
106
+ tools?: FastagentTool[];
98
107
  /**
99
108
  * The agent's working directory: where the default tools operate AND whose ancestors are walked for
100
109
  * ② project context (AGENTS.md). Defaults to `dir` (flat: the definition dir is also the run root).
@@ -19,7 +19,8 @@ import { piHarnessFactory } from "./harness.js";
19
19
  import { createPiModels } from "./models.js";
20
20
  import { reportDefinitionWarnings } from "./report.js";
21
21
  import { inMemorySessionStore } from "./sessions.js";
22
- import { loadTools, mergeDiscoveredTools } from "./tool.js";
22
+ import { isDeferredTool, loadTools, mergeDiscoveredTools, } from "./tool.js";
23
+ import { withSearchTool } from "./search-tools.js";
23
24
  import { createPiAgentFromHarness } from "./invoke.js";
24
25
  // ── §1 tools ─────────────────────────────────────────────────────────────────
25
26
  //
@@ -44,11 +45,30 @@ export async function resolveWorkspaceTools(config, agentDir, cwd = agentDir) {
44
45
  // Default coding tools (read/bash/edit/write) are rooted at `cwd` (the run root the agent operates on);
45
46
  // discovered `tools/` come from `agentDir` (the agent's own surface). They coincide in the flat case.
46
47
  const discovered = await loadTools(agentDir);
47
- const { tools, collisions } = mergeDiscoveredTools(resolveTools(config, cwd), discovered.tools);
48
- const toolCollisions = [...discovered.collisions, ...collisions];
48
+ const merged = mergeDiscoveredTools(resolveTools(config, cwd), discovered.tools);
49
+ // The built-in `search_tools` loader mounts here — the one place the workspace's full tool set is
50
+ // computed — so `dev`/`start`/`info`/`fastagent tool` all see the same surface (idempotent; a
51
+ // workspace-defined search_tools wins).
52
+ const tools = withSearchTool(merged.tools);
53
+ // Builtin = a search_tools that was ABSENT before withSearchTool (a reference compare would misfire
54
+ // on the deferred-authored-loader case, where withSearchTool returns a new array without adding one).
55
+ const builtinLoaderMounted = !merged.tools.some((t) => t.name === "search_tools") && tools.some((t) => t.name === "search_tools");
56
+ const toolCollisions = [...discovered.collisions, ...merged.collisions];
57
+ // `toolNames` is the AUTHOR's active-by-default surface (config.tools + tools/): exclude pi
58
+ // defaults, the builtin loader (like wake, a builtin gets its own report line, not an anonymous
59
+ // slot in the author's list — an author-DEFINED search_tools still shows), and deferred tools —
60
+ // each name lives in exactly ONE report slot, and deferred names live in `deferredToolNames`.
49
61
  const defaultNames = new Set(piDefaultTools(cwd).map((t) => t.name));
50
- const toolNames = tools.map((t) => t.name).filter((n) => !defaultNames.has(n));
51
- return { tools, toolNames, toolCollisions, toolFailures: discovered.failures };
62
+ const toolNames = tools
63
+ .filter((t) => !defaultNames.has(t.name) && !isDeferredTool(t) && !(builtinLoaderMounted && t.name === "search_tools"))
64
+ .map((t) => t.name);
65
+ return {
66
+ tools,
67
+ toolNames,
68
+ deferredToolNames: tools.filter(isDeferredTool).map((t) => t.name),
69
+ toolCollisions,
70
+ toolFailures: discovered.failures,
71
+ };
52
72
  }
53
73
  // ── §2 prompt: four-segment systemPrompt assembly ───────────────────────────
54
74
  //
@@ -69,16 +89,25 @@ export async function resolveWorkspaceTools(config, agentDir, cwd = agentDir) {
69
89
  * `persona` (from persona.md) replaces the default identity line, keeping the tools list + guidelines.
70
90
  */
71
91
  export function piBasePrompt(options = {}) {
72
- const tools = options.tools ?? [];
92
+ const mounted = options.tools ?? [];
93
+ // Deferred tools stay OUT of the list: their schemas are not in the request until activated, so
94
+ // naming them here would invite calls to tools that don't exist yet; discovery is search_tools' job
95
+ // (which IS listed — it's active). Computed from the static mounted set, so the prompt — the cached
96
+ // context prefix — does not change when a tool is activated mid-session.
97
+ const tools = mounted.filter((t) => !isDeferredTool(t));
98
+ const deferredCount = mounted.length - tools.length;
73
99
  const toolsList = tools.length > 0 ? tools.map((t) => `- ${t.name}: ${(t.description ?? "").split("\n")[0]}`).join("\n") : "(none)";
74
100
  // Segment ① identity: an authored persona (persona.md) replaces the default engine identity line
75
101
  // (the standalone×code-repo cell's persona; core.md §11), keeping the tools list + guidelines below.
76
102
  const identity = options.persona?.trim() ||
77
103
  "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.";
104
+ const deferredNote = deferredCount > 0
105
+ ? `\n\n${deferredCount} additional tool(s) are registered but inactive — use search_tools to discover and activate them before concluding a capability is missing.`
106
+ : "";
78
107
  return `${identity}
79
108
 
80
109
  Available tools:
81
- ${toolsList}
110
+ ${toolsList}${deferredNote}
82
111
 
83
112
  In addition to the tools above, you may have access to other custom tools depending on the project.
84
113
 
@@ -119,6 +148,7 @@ function buildPiAgent(opts) {
119
148
  env: opts.env ?? new NodeExecutionEnv({ cwd: process.cwd() }),
120
149
  models,
121
150
  model: resolveModel(models, opts.model),
151
+ thinkingLevel: opts.thinkingLevel,
122
152
  systemPrompt: opts.systemPrompt,
123
153
  tools: opts.tools,
124
154
  skills: opts.skills,
@@ -145,10 +175,12 @@ function instructionsPrompt(instructions, skills) {
145
175
  export function createPiAgent(options) {
146
176
  return buildPiAgent({
147
177
  model: options.model,
178
+ thinkingLevel: options.thinkingLevel,
148
179
  providers: options.providers,
149
180
  authPath: options.authPath,
150
181
  systemPrompt: instructionsPrompt(options.instructions, options.skills),
151
- tools: options.tools,
182
+ // Deferred tools need their loader on every rung (idempotent; the caller's own search_tools wins).
183
+ tools: options.tools ? withSearchTool(options.tools) : options.tools,
152
184
  skills: options.skills,
153
185
  sessions: options.sessions,
154
186
  env: options.env,
@@ -177,9 +209,12 @@ export async function createPiAgentFromDefinition(dir, options) {
177
209
  // runtime-written bad skill surfaces the moment it appears, while a static finding does not spam
178
210
  // every turn's log. A log-dedup memo, not session state (stateless invoke holds).
179
211
  let reportedFindings = findingsSignature(definition);
180
- const tools = options.tools ?? piDefaultTools(env.cwd);
212
+ // Deferred tools need their loader on every rung (idempotent — the workspace opener already applied
213
+ // it; a caller's own search_tools wins).
214
+ const tools = withSearchTool(options.tools ?? piDefaultTools(env.cwd));
181
215
  const agent = buildPiAgent({
182
216
  model: options.model,
217
+ thinkingLevel: options.thinkingLevel,
183
218
  providers: options.providers,
184
219
  // Dir-aware default: the same state-root-derived file the opener uses for this dir (the opener
185
220
  // passes an explicit authPath, so this only affects direct L2 callers).