@fastagent-sh/fastagent 0.17.1 → 0.18.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 (54) hide show
  1. package/dist/agent.d.ts +11 -0
  2. package/dist/channels/feishu/feishu-api.d.ts +4 -2
  3. package/dist/channels/feishu/feishu.js +39 -9
  4. package/dist/channels/feishu/invoke-turn.d.ts +8 -2
  5. package/dist/channels/feishu/invoke-turn.js +150 -31
  6. package/dist/channels/feishu/parse.js +6 -0
  7. package/dist/channels/http.js +15 -2
  8. package/dist/channels/invoke-turn-kit.d.ts +5 -2
  9. package/dist/channels/invoke-turn-kit.js +6 -2
  10. package/dist/channels/slack/invoke-turn.js +1 -1
  11. package/dist/channels/slack/slack.js +1 -5
  12. package/dist/channels/state.d.ts +0 -10
  13. package/dist/channels/state.js +2 -19
  14. package/dist/channels/telegram/invoke-turn.js +1 -1
  15. package/dist/channels/thread-participants.d.ts +7 -0
  16. package/dist/channels/thread-participants.js +3 -0
  17. package/dist/cli/commands/deploy.js +13 -5
  18. package/dist/cli/commands/dev.js +1 -1
  19. package/dist/cli/commands/fire.js +1 -1
  20. package/dist/cli/commands/info.js +21 -1
  21. package/dist/cli/commands/invoke.js +1 -1
  22. package/dist/cli/commands/start.js +1 -1
  23. package/dist/cli/shared.d.ts +4 -2
  24. package/dist/cli/shared.js +12 -5
  25. package/dist/collect.d.ts +30 -4
  26. package/dist/collect.js +39 -6
  27. package/dist/deploy/preflight.d.ts +8 -2
  28. package/dist/deploy/preflight.js +21 -3
  29. package/dist/deploy/secrets.d.ts +3 -0
  30. package/dist/deploy/secrets.js +6 -0
  31. package/dist/dev-supervisor.js +8 -2
  32. package/dist/engines/pi/create.d.ts +2 -1
  33. package/dist/engines/pi/create.js +12 -7
  34. package/dist/engines/pi/harness.d.ts +6 -3
  35. package/dist/engines/pi/harness.js +4 -3
  36. package/dist/engines/pi/invoke-session.d.ts +32 -0
  37. package/dist/engines/pi/invoke-session.js +171 -0
  38. package/dist/engines/pi/invoke.d.ts +6 -27
  39. package/dist/engines/pi/invoke.js +49 -208
  40. package/dist/engines/pi/models.d.ts +45 -11
  41. package/dist/engines/pi/models.js +55 -8
  42. package/dist/engines/pi/session-builder.js +4 -2
  43. package/dist/engines/pi/session-control.d.ts +2 -1
  44. package/dist/engines/pi/sessions.d.ts +17 -1
  45. package/dist/engines/pi/sessions.js +292 -10
  46. package/dist/engines/pi/turn-kit.d.ts +56 -0
  47. package/dist/engines/pi/turn-kit.js +161 -0
  48. package/dist/paths.d.ts +6 -0
  49. package/dist/paths.js +6 -0
  50. package/dist/pi.d.ts +3 -2
  51. package/dist/pi.js +1 -1
  52. package/dist/scaffold/templates/fastagent.config.mjs +2 -0
  53. package/dist/session-remote.js +10 -2
  54. package/package.json +1 -1
@@ -4,11 +4,14 @@
4
4
  * it into the harness alongside the selected `model`; the two must come from the same collection so
5
5
  * the model's provider auth is in scope.
6
6
  */
7
+ import { join } from "node:path";
7
8
  import { defaultProviderAuthContext } from "@earendil-works/pi-ai";
8
9
  import { builtinModels } from "@earendil-works/pi-ai/providers/all";
9
10
  import { ModelRuntime } from "@earendil-works/pi-coding-agent";
10
11
  import { fastagentCredentialStore } from "./auth.js";
12
+ import { providerOf } from "./config.js";
11
13
  import { interactiveLoginKind } from "./login.js";
14
+ import { AGENT_MODELS_FILE, resolveStateRoot } from "../../paths.js";
12
15
  /**
13
16
  * A `Models` with every built-in pi provider, wired to fastagent's auth: stored credentials from the
14
17
  * {@link CreatePiModelsOptions.authPath} file (via {@link fastagentCredentialStore}; the global
@@ -28,18 +31,62 @@ export function createPiModels(options = {}) {
28
31
  /**
29
32
  * The `ModelRuntime`-shaped sibling of {@link createPiModels} — the SAME hub semantics (built-in
30
33
  * providers + fastagent's credential store at `authPath`) in the type pi's session services require
31
- * (`createAgentSessionServices({ modelRuntime })`). Builtins only (`modelsPath: null` — pi's
32
- * machine-global models.json is definition-foreign) and no availability network, so the model
33
- * surface equals serving's. No `providers` option: `ModelRuntime` registers providers by config
34
- * record, not `Provider` instance — accepting the option and dropping it would be a silent no-op;
35
- * add the mapping when a consumer actually needs it.
34
+ * (`createAgentSessionServices({ modelRuntime })`). Built-ins PLUS the agent's own
35
+ * {@link AGENT_MODELS_FILE} when `agentDir` is given (a dir-less caller gets built-ins only), and no
36
+ * availability network, so the model surface equals serving's.
37
+ *
38
+ * `ModelRuntime` also takes `Provider` INSTANCES via `registerNativeProvider` (pi 0.83); the
39
+ * declarative file is what this rung wires because it is data that travels with the definition.
36
40
  */
37
- export function createPiModelRuntime(options = {}) {
38
- return ModelRuntime.create({
41
+ export async function createPiModelRuntime(options = {}) {
42
+ const { agentDir } = options;
43
+ const runtime = await ModelRuntime.create({
39
44
  credentials: fastagentCredentialStore(options.authPath, { warn: options.warn }),
40
- modelsPath: null,
45
+ modelsPath: agentDir ? join(agentDir, AGENT_MODELS_FILE) : null,
46
+ // MUST be set whenever modelsPath is: pi defaults this to `<dirname(modelsPath)>/models-store.json`,
47
+ // which would write a generated cache INTO the author's agent dir — and `deploy` bakes the whole
48
+ // tree, so it would travel into the image as stale state. Machinery belongs under the state root.
49
+ ...(agentDir
50
+ ? { modelsStorePath: join(options.stateRoot ?? resolveStateRoot(agentDir), "models-store.json") }
51
+ : {}),
41
52
  allowModelNetwork: false,
42
53
  });
54
+ // A malformed models.json does NOT throw upstream — `create` resolves with the built-ins and parks the
55
+ // reason in getError(). Left unread, a typo'd endpoint would silently degrade to "provider not in
56
+ // registry" at model-resolution time, i.e. the silent fallback this codebase forbids. The upstream
57
+ // message already names both the reason and the file, so it is surfaced verbatim.
58
+ const error = runtime.getError();
59
+ if (error)
60
+ throw new Error(error);
61
+ for (const provider of options.providers ?? [])
62
+ runtime.registerNativeProvider(provider);
63
+ return runtime;
64
+ }
65
+ /**
66
+ * How a model's credential will REACH a deployed agent — the question `deploy` asks, which
67
+ * {@link probeAuthSource} cannot answer: it flattens every models.json endpoint to the display label
68
+ * "configured API key", so a self-hosted endpoint looks credential-less to the deploy gate even when
69
+ * its key is sitting in an env var.
70
+ *
71
+ * - `envVar`: an environment variable backs it, BY NAME — the shape `deploy` already understands, so
72
+ * the value carries as a host secret with no extra declaration from the author.
73
+ * - `inDefinition`: the definition itself carries it (a literal `apiKey`, or a `!command` run on the
74
+ * host). Nothing for `deploy` to carry — and nothing to gate on either, which is the point: the
75
+ * `fastagent login` remedy is meaningless for a provider login cannot serve.
76
+ *
77
+ * Neither set = a stored credential or nothing at all; the existing auth.json / gate paths decide.
78
+ */
79
+ export function modelCredentialCarry(runtime, spec) {
80
+ const status = runtime.getProviderAuthStatus(providerOf(spec));
81
+ if (!status.configured)
82
+ return { inDefinition: false };
83
+ // An env-var name is only useful downstream if it IS one: `"${A}_${B}"` interpolation resolves from
84
+ // the environment but has no single name to carry, so it falls through to the definition-carried
85
+ // branch, where the author's `deploy.secrets` is the mechanism.
86
+ if (status.source === "environment" && status.label && /^[A-Z][A-Z0-9_]*$/.test(status.label)) {
87
+ return { envVar: status.label, inDefinition: false };
88
+ }
89
+ return { inDefinition: status.source !== "stored" };
43
90
  }
44
91
  /**
45
92
  * Probe every provider's auth once (auth is provider-scoped, so any of its models works as the probe)
@@ -100,12 +100,14 @@ sessionManager) {
100
100
  // on the session in createRuntime — pi's session starts all-active), and the activation bridge
101
101
  // above rides the same turn context, so the SAME search_tools works against pi's AgentSession
102
102
  // instead of fastagent's harness.
103
- const { config, modelSpec, agentDir, authPath, tools, deferredToolNames, toolCollisions, toolFailures } = await resolveAgentAssembly(cwd, options);
103
+ const { config, modelSpec, agentDir, authPath, stateRoot, tools, deferredToolNames, toolCollisions, toolFailures } = await resolveAgentAssembly(cwd, options);
104
104
  reportToolCollisions(toolCollisions);
105
105
  reportModuleLoadFailures(toolFailures);
106
106
  // ONE hub owns model resolution AND per-request auth — the ModelRuntime-shaped sibling of
107
107
  // serving's createPiModels; see models.ts.
108
- const modelRuntime = await createPiModelRuntime({ authPath });
108
+ // agentDir carries the agent's own models.json (custom endpoints); stateRoot keeps pi's generated
109
+ // catalog cache out of the definition dir. See createPiModelRuntime.
110
+ const modelRuntime = await createPiModelRuntime({ authPath, agentDir, stateRoot });
109
111
  // MIGRATION HINT (deliberate breaking change): chat historically used pi's own `~/.pi` auth;
110
112
  // it now reads the agent's credential file like every other command. Probe the RESOLVED
111
113
  // model's provider through the normal resolution path (stored credential OR env var — an
@@ -1,7 +1,8 @@
1
1
  import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
2
2
  import type { Models } from "@earendil-works/pi-ai";
3
3
  import { type AgentCommand, type SessionControl, type SessionEvent } from "../../session.ts";
4
- import type { Lease, SessionObserver } from "./invoke.ts";
4
+ import type { SessionObserver } from "./invoke.ts";
5
+ import type { Lease } from "./turn-kit.ts";
5
6
  import { type AnyModel, type PiHarnessFactory } from "./harness.ts";
6
7
  import { type PiSessionReader } from "./sessions.ts";
7
8
  /** Ceiling for one subscriber's unconsumed backlog. A consumer this far behind (a stalled remote
@@ -1,7 +1,21 @@
1
1
  import type { Session, SessionTreeEntry } from "@earendil-works/pi-agent-core";
2
+ /**
3
+ * Where a NEW session starts from, when it names a parent (participant-model.md §5: "a thread starts
4
+ * from what the room knew"). Read only on the create path — an EXISTING session ignores it entirely,
5
+ * which is what makes inheritance one-time by construction: no marker to persist, no decision to
6
+ * retry per turn; the session existing IS the record that the decision was taken.
7
+ */
8
+ export interface SessionInheritance {
9
+ /** The session to fork from. Missing or unreadable → the new session starts empty, with a warn —
10
+ * context is not the ask, and losing it must not cost the turn. */
11
+ parentSession: string;
12
+ /** Opaque markers that MAY locate the branch point on the parent's active path (searched in
13
+ * message content, first hit wins, most recent occurrence). No match → the parent's present. */
14
+ branchHints?: string[];
15
+ }
2
16
  /** What fastagent needs from a session backend: open-or-create by opaque id. */
3
17
  export interface PiSessionStore {
4
- openOrCreate(sessionId: string): Promise<Session>;
18
+ openOrCreate(sessionId: string, inherit?: SessionInheritance): Promise<Session>;
5
19
  }
6
20
  /**
7
21
  * OPEN-EXISTING sibling of {@link PiSessionStore} (session-control.ts): an unknown session answers
@@ -41,4 +55,6 @@ export declare function inMemorySessionStore(): PiSessionStore & PiSessionReader
41
55
  export declare function jsonlSessionStore(options: {
42
56
  dir: string;
43
57
  cwd?: string;
58
+ /** Inheritance guard override (tests): parent journals above this are not forked. Default 32 MiB. */
59
+ forkMaxBytes?: number;
44
60
  }): PiSessionStore & PiSessionReader;
@@ -1,14 +1,17 @@
1
1
  /**
2
2
  * Session persistence — the K-axis port and its first two backends.
3
3
  *
4
- * PiSessionStore is the consumer-owned port: open-or-create by opaque session id, nothing more.
5
- * pi's full SessionRepo surface (list/open/create/delete/fork) stays behind the adapters. The `Pi`
6
- * prefix is honest — `openOrCreate` returns pi's `Session`, so this is pi-coupled, not a neutral
4
+ * PiSessionStore is the consumer-owned port: open-or-create by opaque session id, plus one creation
5
+ * option — inheritance. pi's full SessionRepo surface (list/open/create/delete) stays behind the
6
+ * adapters; `fork` is surfaced only through {@link SessionInheritance}, never raw. The `Pi` prefix
7
+ * is honest — `openOrCreate` returns pi's `Session`, so this is pi-coupled, not a neutral
7
8
  * persistence contract.
8
9
  *
9
10
  * Continuity = same backing store + same session id: in-memory continuity dies with the instance;
10
11
  * jsonl survives process restarts (disk is the truth).
11
12
  */
13
+ import { mkdir, rename, stat } from "node:fs/promises";
14
+ import { basename, dirname, join, resolve } from "node:path";
12
15
  import { InMemorySessionRepo } from "@earendil-works/pi-agent-core";
13
16
  import { JsonlSessionRepo, NodeExecutionEnv } from "@earendil-works/pi-agent-core/node";
14
17
  import { log } from "../../log.js";
@@ -131,14 +134,227 @@ export async function activePathEntries(session) {
131
134
  }
132
135
  return path.reverse(); // walked leaf→root; every consumer reads it root→leaf
133
136
  }
137
+ // ── Inheritance: fork-on-first-open ───────────────────────────────────────────────────────
138
+ //
139
+ // The mechanism behind participant-model.md §5's rule, engine-side and channel-neutral: a channel
140
+ // only names WHERE a place branched from (scope.parentSession) and possibly at WHICH message
141
+ // (branchHints); how much is inherited and where the boundaries sit is decided here.
142
+ //
143
+ // Shape: fork the parent's ACTIVE PATH up to the branch point (everything — text, images, tool
144
+ // results — because they are session entries, not prompt text), then bound what the MODEL sees with
145
+ // one mechanical compaction mark (a plain string; zero model calls). Disk keeps the full copy —
146
+ // storage and context are different budgets (the fork is inspectable; the mark governs the window),
147
+ // and pi's context assembly honors the mark the same as a real compaction.
148
+ /** Inheritance window: at most this many exchanges of the parent reach the child's model context. */
149
+ const INHERIT_MAX_EXCHANGES = 50;
150
+ /** …and at most roughly this many tokens (~1/4 of a 200K context: generous, not everything). Both
151
+ * limits govern how far the window EXTENDS into older history — the newest exchange is a FLOOR,
152
+ * kept whole even when it alone exceeds the budget: the mark's boundary is entry-granular, and an
153
+ * inheritance that drops the exchange the thread branched off would be no inheritance at all. */
154
+ const INHERIT_MAX_TOKENS = 50_000;
155
+ /** Branch hints are IDS, not payloads: each one costs a scan over the parent's serialized path, and
156
+ * the wire accepts arbitrary arrays — so the engine caps them where the cost lives. */
157
+ const MAX_BRANCH_HINTS = 16;
158
+ const MAX_BRANCH_HINT_CHARS = 128;
159
+ /** A vision image is priced FLAT — what a provider bills for a resized image, roughly — because its
160
+ * base64 length (~1M chars for a photo) measures storage, not context: pricing it by chars would
161
+ * let one photo evict the whole text window. */
162
+ const INHERIT_IMAGE_TOKENS = 1_600;
163
+ /** The fork reads the whole parent journal into memory; beyond this it is skipped (empty session +
164
+ * warn) rather than stalling the thread's first turn. */
165
+ const FORK_MAX_BYTES = 32 * 1024 * 1024;
166
+ function isUserMessage(entry) {
167
+ return entry?.type === "message" && entry.message.role === "user";
168
+ }
169
+ /** Rough token estimate for windowing — text at chars/4, images flat. Precision is not the point:
170
+ * the window is a budget, and being 20% off moves a boundary by an exchange, not correctness. */
171
+ function estimateMessageTokens(message) {
172
+ // AgentMessage is a role-keyed union and not every member carries `content` — read it loosely.
173
+ const content = message.content;
174
+ if (typeof content === "string")
175
+ return Math.ceil(content.length / 4);
176
+ if (!Array.isArray(content))
177
+ return 0;
178
+ let tokens = 0;
179
+ // Blocks are role-dependent unions; the estimate only needs `type`/`text`, so read them loosely.
180
+ for (const block of content) {
181
+ if (block.type === "image")
182
+ tokens += INHERIT_IMAGE_TOKENS;
183
+ else if (typeof block.text === "string")
184
+ tokens += Math.ceil(block.text.length / 4);
185
+ else
186
+ tokens += Math.ceil(JSON.stringify(block).length / 4);
187
+ }
188
+ return tokens;
189
+ }
190
+ function estimateEntryTokens(entry) {
191
+ if (entry.type !== "message")
192
+ return 0;
193
+ return estimateMessageTokens(entry.message);
194
+ }
195
+ /** A compaction entry's summary and retained tail DO reach the model — they are the floor under
196
+ * every window that starts above the compaction, so the budget must count them. */
197
+ function estimateCompactionTokens(entry) {
198
+ if (entry?.type !== "compaction")
199
+ return 0;
200
+ let tokens = Math.ceil(entry.summary.length / 4);
201
+ for (const message of entry.retainedTail ?? [])
202
+ tokens += estimateMessageTokens(message);
203
+ return tokens;
204
+ }
205
+ /**
206
+ * Find the fork target on the parent's active path: the LAST message whose content carries a hint
207
+ * (the most recent turn that talked about that message), extended forward to the end of its exchange
208
+ * — forking mid-exchange would inherit a question without its answer. Hints are tried in caller
209
+ * order; the first that matches anywhere wins. No match → undefined (the caller forks the present).
210
+ */
211
+ function locateBranchPoint(path, hints) {
212
+ const usable = hints
213
+ .filter((hint) => hint.length > 0 && hint.length <= MAX_BRANCH_HINT_CHARS)
214
+ .slice(0, MAX_BRANCH_HINTS);
215
+ if (usable.length < hints.length) {
216
+ log.warn(`[fastagent] ignored ${hints.length - usable.length} branch hint(s) (over ${MAX_BRANCH_HINTS} hints or ${MAX_BRANCH_HINT_CHARS} chars each) — hints are message ids, not payloads`);
217
+ }
218
+ if (usable.length === 0)
219
+ return undefined;
220
+ // Serialize each message ONCE — the scan is hints × entries, and stringify must not sit in the
221
+ // inner loop. The whole message, not just content: shape-agnostic, and a hint is a platform id —
222
+ // a false positive would need the id to appear outside content, which is where ids live anyway.
223
+ const serialized = path.map((entry) => (entry.type === "message" ? JSON.stringify(entry.message) : ""));
224
+ for (const hint of usable) {
225
+ for (let i = path.length - 1; i >= 0; i--) {
226
+ if (!serialized[i]?.includes(hint))
227
+ continue;
228
+ let j = i + 1;
229
+ while (j < path.length && !isUserMessage(path[j]))
230
+ j++;
231
+ return path[j - 1]?.id;
232
+ }
233
+ }
234
+ return undefined;
235
+ }
236
+ /**
237
+ * Bound what the child's MODEL CONTEXT starts with: keep the newest exchange unconditionally, extend
238
+ * older while both window limits hold, and mark the boundary with a mechanical compaction entry.
239
+ * Entries above the parent's own last compaction are already outside model context and need no mark;
240
+ * a child whose visible history fits the window gets no mark at all.
241
+ */
242
+ async function markInheritanceWindow(child) {
243
+ const path = await activePathEntries(child);
244
+ let scanFrom = 0;
245
+ for (let i = path.length - 1; i >= 0; i--) {
246
+ if (path[i]?.type === "compaction") {
247
+ scanFrom = i + 1;
248
+ break;
249
+ }
250
+ }
251
+ const scanned = path.slice(scanFrom);
252
+ // The compaction's own summary + retained tail reach the model regardless of where the window
253
+ // lands, so they charge the budget as a base cost — not estimating them would over-admit.
254
+ const baseTokens = estimateCompactionTokens(path[scanFrom - 1]);
255
+ const starts = [];
256
+ scanned.forEach((entry, i) => {
257
+ if (isUserMessage(entry))
258
+ starts.push(i);
259
+ });
260
+ if (starts.length <= 1)
261
+ return; // zero or one visible exchange — nothing to cut
262
+ const suffixTokens = new Array(scanned.length + 1).fill(0);
263
+ for (let i = scanned.length - 1; i >= 0; i--) {
264
+ const entry = scanned[i];
265
+ suffixTokens[i] = (suffixTokens[i + 1] ?? 0) + (entry ? estimateEntryTokens(entry) : 0);
266
+ }
267
+ let chosen = starts.length - 1;
268
+ for (let k = starts.length - 2; k >= 0; k--) {
269
+ const exchanges = starts.length - k;
270
+ const startIdx = starts[k];
271
+ if (startIdx === undefined)
272
+ break;
273
+ if (exchanges > INHERIT_MAX_EXCHANGES || baseTokens + (suffixTokens[startIdx] ?? 0) > INHERIT_MAX_TOKENS)
274
+ break;
275
+ chosen = k;
276
+ }
277
+ if (chosen === 0)
278
+ return; // the whole visible history fits the window
279
+ const boundaryIdx = starts[chosen];
280
+ if (boundaryIdx === undefined)
281
+ return;
282
+ const boundary = scanned[boundaryIdx];
283
+ if (boundary === undefined)
284
+ return;
285
+ await child.appendCompaction(`Inherited from the parent conversation; ${chosen} earlier exchange(s) are not shown.`, boundary.id, (suffixTokens[0] ?? 0) - (suffixTokens[boundaryIdx] ?? 0));
286
+ }
287
+ /**
288
+ * The create-with-parent path (the open path never reaches here) — SEMANTICS only: where to branch
289
+ * and what the newborn is born with. Writing and publishing it is
290
+ * {@link SessionBackend.createAtomically}'s contract.
291
+ *
292
+ * Every failure lands on "start empty + warn": a thread must not lose its first turn to an
293
+ * inheritance edge.
294
+ */
295
+ async function createInheriting(backend, id, inherit, parentId, maxBytes) {
296
+ const startEmpty = () => backend.createAtomically(id, undefined, async () => { });
297
+ const parentMeta = await backend.find(parentId);
298
+ if (!parentMeta) {
299
+ log.warn(`[fastagent] session "${id}" names parent "${parentId}", which does not exist — starting empty`);
300
+ return startEmpty();
301
+ }
302
+ if (backend.bytes) {
303
+ const bytes = await backend.bytes(parentMeta).catch(() => 0);
304
+ if (bytes > maxBytes) {
305
+ log.warn(`[fastagent] parent session "${parentId}" is ${bytes} bytes (limit ${maxBytes}) — starting empty rather than stalling the first turn`);
306
+ return startEmpty();
307
+ }
308
+ }
309
+ try {
310
+ const parent = await backend.open(parentMeta);
311
+ const path = await activePathEntries(parent);
312
+ const leaf = path[path.length - 1];
313
+ if (leaf !== undefined) {
314
+ const hints = inherit.branchHints ?? [];
315
+ const at = locateBranchPoint(path, hints);
316
+ if (at === undefined && hints.length > 0) {
317
+ log.warn(`[fastagent] no branch hint matched in parent "${parentId}" — inheriting from its present instead of the branch point`);
318
+ }
319
+ // The one repair that must run before the child is visible: a mid-turn parent forks with a
320
+ // dangling tool call at its leaf, which would hand the provider an invalid transcript.
321
+ return await backend.createAtomically(id, { meta: parentMeta, atEntryId: at ?? leaf.id }, async (draft) => {
322
+ await reconcileInterruptedToolCalls(draft);
323
+ await markInheritanceWindow(draft);
324
+ });
325
+ }
326
+ }
327
+ catch (error) {
328
+ // Unattributed on purpose: this spans reading the parent AND writing the child, so the fault may
329
+ // belong to either — a torn parent journal, or a store that cannot publish.
330
+ log.warn(`[fastagent] could not inherit from "${parentId}" into "${id}" (${String(error)}) — starting empty`);
331
+ }
332
+ return startEmpty();
333
+ }
134
334
  /** In-process store (pi InMemorySessionRepo). Continuity lives and dies with the instance. */
135
335
  export function inMemorySessionStore() {
136
336
  const repo = new InMemorySessionRepo();
337
+ const backend = {
338
+ find: async (id) => (await repo.list()).find((m) => m.id === id),
339
+ open: (m) => repo.open(m),
340
+ // No draft realm needed: nothing partial outlives the process, and `fill` still runs before the
341
+ // session reaches any caller.
342
+ createAtomically: async (id, from, fill) => {
343
+ const draft = from
344
+ ? await repo.fork(from.meta, { id, entryId: from.atEntryId, position: "at" })
345
+ : await repo.create({ id });
346
+ await fill(draft);
347
+ return draft;
348
+ },
349
+ };
137
350
  return {
138
- async openOrCreate(sessionId) {
351
+ async openOrCreate(sessionId, inherit) {
139
352
  const existing = (await repo.list()).find((m) => m.id === sessionId);
140
- if (!existing)
353
+ if (!existing) {
354
+ if (inherit)
355
+ return createInheriting(backend, sessionId, inherit, inherit.parentSession, FORK_MAX_BYTES);
141
356
  return repo.create({ id: sessionId });
357
+ }
142
358
  const session = await repo.open(existing);
143
359
  await reconcileInterruptedToolCalls(session);
144
360
  return session;
@@ -155,16 +371,63 @@ export function inMemorySessionStore() {
155
371
  */
156
372
  export function jsonlSessionStore(options) {
157
373
  const cwd = options.cwd ?? process.cwd();
158
- const repo = new JsonlSessionRepo({ fs: new NodeExecutionEnv({ cwd }), sessionsRoot: options.dir });
374
+ const forkMaxBytes = options.forkMaxBytes ?? FORK_MAX_BYTES;
375
+ // Resolve ONCE, by pi's rule (`NodeExecutionEnv.absolutePath` is `resolve(cwd, path)`): the direct
376
+ // fs calls below resolve against process.cwd() instead, and a relative `dir` would straddle both.
377
+ const root = resolve(cwd, options.dir);
378
+ const repo = new JsonlSessionRepo({ fs: new NodeExecutionEnv({ cwd }), sessionsRoot: root });
379
+ // Where {@link SessionBackend.createAtomically} stages: a sibling root INSIDE the store root but
380
+ // OUTSIDE every lookup — `list({ cwd })` scans only `<root>/<encodedCwd>`, and a cwd-less `list()`
381
+ // scans `<root>/*/​*.jsonl`, one level, which `.drafts/<encodedCwd>/*.jsonl` sits below.
382
+ //
383
+ // A crash, or a handled failure once the draft file exists (a throw from `fill`, or from the
384
+ // rename), leaves it behind. Drafts are never resumed, so staleness cannot poison anything — but
385
+ // nothing unlinks them either.
386
+ const draftRepo = new JsonlSessionRepo({
387
+ fs: new NodeExecutionEnv({ cwd }),
388
+ sessionsRoot: join(root, ".drafts"),
389
+ });
390
+ const backend = {
391
+ find: async (id) => (await repo.list({ cwd })).find((m) => m.id === id),
392
+ open: (m) => repo.open(m),
393
+ createAtomically: async (id, from, fill) => {
394
+ // `fork` opens the source by its metadata's absolute path, so the draft repo reads the real
395
+ // repo's parent file directly while writing into its own root.
396
+ const draft = from
397
+ ? await draftRepo.fork(from.meta, { cwd, id, entryId: from.atEntryId, position: "at" })
398
+ : await draftRepo.create({ id, cwd });
399
+ await fill(draft);
400
+ // The interface erases the metadata generic; a jsonl draft's metadata always carries `path`.
401
+ const draftPath = (await draft.getMetadata()).path;
402
+ // `<root>/.drafts/<encodedCwd>/<file>` → `<root>/<encodedCwd>/<file>`: the draft's own
403
+ // parent directory NAME is pi's cwd encoding, already computed — read it back rather than
404
+ // re-deriving it, and rather than borrowing the parent session's path (a parentless draft has
405
+ // none). Same filesystem, so the rename is atomic; the real directory may not exist yet when
406
+ // this store has never created a session for this cwd.
407
+ const target = join(root, basename(dirname(draftPath)), basename(draftPath));
408
+ await mkdir(dirname(target), { recursive: true });
409
+ await rename(draftPath, target);
410
+ const published = (await repo.list({ cwd })).find((m) => m.path === target);
411
+ if (!published)
412
+ throw new Error(`published session vanished: ${target}`);
413
+ return repo.open(published);
414
+ },
415
+ bytes: async (m) => (await stat(m.path)).size,
416
+ };
159
417
  return {
160
- async openOrCreate(sessionId) {
418
+ async openOrCreate(sessionId, inherit) {
161
419
  // Caller-provided ids land in jsonl FILENAMES — encode anything unsafe before it reaches disk.
162
420
  const id = encodeSessionId(sessionId);
163
421
  // Scope the lookup to this store's cwd: two stores sharing a sessionsRoot must not open each
164
422
  // other's sessions (pi groups sessions by project dir).
165
423
  const existing = (await repo.list({ cwd })).find((m) => m.id === id);
166
- if (!existing)
424
+ if (!existing) {
425
+ if (inherit)
426
+ return createInheriting(backend, id, inherit, encodeSessionId(inherit.parentSession), forkMaxBytes);
427
+ // Straight into the store, unstaged: an empty session is complete the moment its file
428
+ // exists, so there is no half for a reader to catch.
167
429
  return repo.create({ id, cwd });
430
+ }
168
431
  const session = await repo.open(existing);
169
432
  await reconcileInterruptedToolCalls(session);
170
433
  return session;
@@ -176,7 +439,26 @@ export function jsonlSessionStore(options) {
176
439
  },
177
440
  };
178
441
  }
179
- /** Injective filename-safe encoding: [A-Za-z0-9._-] verbatim, the rest %-escaped. */
442
+ /**
443
+ * Filename-safe encoding, INJECTIVE: `[A-Za-z0-9._-]` verbatim, everything else `%XX` (one byte) or
444
+ * `%uXXXX` (above it). Two different ids must never encode alike: the encoded id is what
445
+ * `openOrCreate` matches on, so a collision is two conversations resolving to ONE session, and —
446
+ * since `parentSession` comes through the same encoder — one inheriting from the wrong room. Not the
447
+ * same thing as a filename clash: a file is `<timestamp>_<id>.jsonl`, and identity is the `id` its
448
+ * metadata carries, not the name on disk.
449
+ *
450
+ * Injectivity is what the previous form lacked: it padded to a MINIMUM of two hex digits, so an
451
+ * escape run could be re-split — `"\u0100"` and `"\u0010" + "0"` both produced `"%100"`. Every
452
+ * escape now has a self-describing width, so no two inputs can produce one output. ASCII ids — every
453
+ * id the built-in channels mint — encode exactly as before, so existing session FILES keep their
454
+ * names; only non-ASCII ids (a custom `route()` could mint one) change, and those are the ones that
455
+ * were unsafe anyway.
456
+ */
180
457
  function encodeSessionId(id) {
181
- return id.replace(/[^A-Za-z0-9._-]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase().padStart(2, "0")}`);
458
+ return id.replace(/[^A-Za-z0-9._-]/g, (c) => {
459
+ const code = c.charCodeAt(0);
460
+ return code < 0x100
461
+ ? `%${code.toString(16).toUpperCase().padStart(2, "0")}`
462
+ : `%u${code.toString(16).toUpperCase().padStart(4, "0")}`;
463
+ });
182
464
  }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * The turn mechanism's pi-CLASS-neutral half, shared by every pi L0. Not "engine-neutral" in this
3
+ * repo's sense — that term is reserved for code with no engine import at all (src/agent.ts), and
4
+ * everything here speaks pi's message and image types. What it is neutral about is which pi class
5
+ * runs the turn.
6
+ *
7
+ * Lease — single-writer concurrency floor (injectable port + in-process default)
8
+ * Terminals — a settled pi message or a thrown error → the SPEC terminal, `retryable` included
9
+ * EventQueue — push→pull plumbing for engines that emit events beside their result
10
+ * Prompt prep — SPEC images → pi's ImageContent
11
+ *
12
+ * What is NOT neutral — the harness's event vocabulary and its observation plane — stays in
13
+ * invoke.ts, and the AgentSession's in invoke-session.ts.
14
+ */
15
+ import type { AssistantMessage, ImageContent } from "@earendil-works/pi-ai";
16
+ import { type AgentEvent, type Prompt } from "../../agent.ts";
17
+ export type Release = () => void;
18
+ export interface Lease {
19
+ /** Try to acquire exclusive write access for the session (fail-fast). Returns null if held. */
20
+ tryAcquire(session: string): Release | null;
21
+ }
22
+ export declare function inProcessLease(): Lease;
23
+ /** Classify `retryable`: structured status/code first, message prose only as the last-resort ceiling. */
24
+ export declare function classifyRetryable(details: string, signal: {
25
+ status?: number;
26
+ code?: unknown;
27
+ }): boolean;
28
+ /**
29
+ * Terminal mapping, decided by the resolved message's stopReason: pi's prompt() resolves a message
30
+ * with stopReason "error"/"aborted" rather than throwing, so relying on catch alone would miss this
31
+ * entire failure class (violating SPEC MUST 1).
32
+ */
33
+ export declare function toTerminal(message: AssistantMessage): AgentEvent;
34
+ export declare function errorToTerminal(error: unknown): Extract<AgentEvent, {
35
+ type: "failed";
36
+ }>;
37
+ /**
38
+ * Map prompt images to pi ImageContent, resizing each to model-friendly dimensions/size with pi's
39
+ * Photon resizer (reused from pi-coding-agent, lazy-imported so the common no-image headless path never
40
+ * loads the TUI module graph). A null resize (unresizable / Photon unavailable) keeps the original
41
+ * bytes — the provider then applies its own limit.
42
+ */
43
+ export declare function toPiPromptOptions(prompt: Prompt): Promise<{
44
+ images?: ImageContent[];
45
+ } | undefined>;
46
+ export declare class EventQueue<T> {
47
+ private buffer;
48
+ private wake?;
49
+ push(item: T): void;
50
+ /**
51
+ * Yield pushed events in order until `done` settles AND the buffer is drained. The terminal is
52
+ * produced separately (toTerminal); rejections of `done` are swallowed here (the caller awaits
53
+ * `run` itself) to avoid unhandled rejections.
54
+ */
55
+ drainUntil(done: Promise<unknown>): AsyncGenerator<T>;
56
+ }