@fastagent-sh/fastagent 0.17.0 → 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 (68) hide show
  1. package/dist/agent.d.ts +11 -0
  2. package/dist/channels/agentcore.d.ts +16 -2
  3. package/dist/channels/agentcore.js +66 -4
  4. package/dist/channels/feishu/card.d.ts +20 -9
  5. package/dist/channels/feishu/card.js +27 -13
  6. package/dist/channels/feishu/feishu-api.d.ts +13 -2
  7. package/dist/channels/feishu/feishu.js +121 -17
  8. package/dist/channels/feishu/invoke-turn.d.ts +12 -2
  9. package/dist/channels/feishu/invoke-turn.js +159 -14
  10. package/dist/channels/feishu/normalize.js +97 -32
  11. package/dist/channels/feishu/parse.js +6 -0
  12. package/dist/channels/feishu/preview.d.ts +4 -3
  13. package/dist/channels/feishu/preview.js +77 -23
  14. package/dist/channels/feishu/scaffold/feishu-send.ts +9 -6
  15. package/dist/channels/http.js +15 -2
  16. package/dist/channels/invoke-turn-kit.d.ts +5 -2
  17. package/dist/channels/invoke-turn-kit.js +6 -2
  18. package/dist/channels/lark/scaffold/lark-send.ts +9 -6
  19. package/dist/channels/slack/invoke-turn.js +1 -1
  20. package/dist/channels/slack/slack.js +1 -5
  21. package/dist/channels/state.d.ts +0 -10
  22. package/dist/channels/state.js +2 -19
  23. package/dist/channels/telegram/invoke-turn.js +1 -1
  24. package/dist/channels/thread-participants.d.ts +7 -0
  25. package/dist/channels/thread-participants.js +3 -0
  26. package/dist/cli/commands/deploy.js +13 -5
  27. package/dist/cli/commands/dev.js +1 -1
  28. package/dist/cli/commands/fire.js +1 -1
  29. package/dist/cli/commands/info.js +21 -1
  30. package/dist/cli/commands/invoke.js +1 -1
  31. package/dist/cli/commands/start.js +40 -6
  32. package/dist/cli/serve.d.ts +4 -0
  33. package/dist/cli/serve.js +2 -2
  34. package/dist/cli/shared.d.ts +4 -2
  35. package/dist/cli/shared.js +12 -5
  36. package/dist/collect.d.ts +30 -4
  37. package/dist/collect.js +39 -6
  38. package/dist/deploy/agentcore/plan.js +17 -0
  39. package/dist/deploy/agentcore/run.d.ts +7 -1
  40. package/dist/deploy/agentcore/run.js +93 -8
  41. package/dist/deploy/preflight.d.ts +8 -2
  42. package/dist/deploy/preflight.js +55 -10
  43. package/dist/deploy/secrets.d.ts +3 -0
  44. package/dist/deploy/secrets.js +6 -0
  45. package/dist/dev-supervisor.js +8 -2
  46. package/dist/engines/pi/create.d.ts +2 -1
  47. package/dist/engines/pi/create.js +12 -7
  48. package/dist/engines/pi/harness.d.ts +6 -3
  49. package/dist/engines/pi/harness.js +4 -3
  50. package/dist/engines/pi/invoke-session.d.ts +32 -0
  51. package/dist/engines/pi/invoke-session.js +171 -0
  52. package/dist/engines/pi/invoke.d.ts +6 -27
  53. package/dist/engines/pi/invoke.js +49 -208
  54. package/dist/engines/pi/models.d.ts +45 -11
  55. package/dist/engines/pi/models.js +55 -8
  56. package/dist/engines/pi/session-builder.js +4 -2
  57. package/dist/engines/pi/session-control.d.ts +2 -1
  58. package/dist/engines/pi/sessions.d.ts +17 -1
  59. package/dist/engines/pi/sessions.js +292 -10
  60. package/dist/engines/pi/turn-kit.d.ts +56 -0
  61. package/dist/engines/pi/turn-kit.js +161 -0
  62. package/dist/paths.d.ts +6 -0
  63. package/dist/paths.js +6 -0
  64. package/dist/pi.d.ts +3 -2
  65. package/dist/pi.js +1 -1
  66. package/dist/scaffold/templates/fastagent.config.mjs +2 -0
  67. package/dist/session-remote.js +10 -2
  68. package/package.json +1 -1
@@ -396,6 +396,23 @@ exports.handler = async (event, ctx) => {
396
396
  if (failed > 0) return { statusCode: 500, body: \`\${failed} alarm(s) failed\\n\` };
397
397
  return { statusCode: 200, body: "ok\\n" };
398
398
  }
399
+ // The deploy driver's probe (reserved path, ingress secret): wake the runtime through the SAME
400
+ // trusted envelope pipeline (state URLs included — a direct InvokeAgentRuntime call could not mint
401
+ // them, and would make the runtime construct against a pre-restore mount) and pass its structured
402
+ // transport-200 verdict back VERBATIM. The ordinary webhook path below folds a non-200 transport
403
+ // into an opaque 502, which would strip exactly the diagnostics the probe exists to carry — and it
404
+ // sits BEFORE the WEBHOOKS_ENABLED gate so schedule-only topologies (whose URLs refuse ordinary
405
+ // public traffic) are probeable too.
406
+ if (event.rawPath === "/__fastagent/probe") {
407
+ const req = JSON.parse(event.isBase64Encoded ? Buffer.from(event.body, "base64").toString() : event.body || "{}");
408
+ if (!process.env.INGRESS_SECRET || req.auth !== process.env.INGRESS_SECRET) return { statusCode: 403, body: "forbidden\\n" };
409
+ const r = await invoke({ kind: "probe" });
410
+ if (r.status !== 200) {
411
+ console.log(\`probe transport error \${r.status}: \${r.body}\`);
412
+ return { statusCode: 502, body: "upstream error\\n" };
413
+ }
414
+ return { statusCode: 200, headers: { "content-type": "application/json" }, body: r.body.toString() };
415
+ }
399
416
  // Enforce the advertised ORIGINAL-body ceiling before base64 adds another 4/3 inside the runtime
400
417
  // envelope. This also leaves deterministic room for headers/query/JSON under Lambda's 6 MB cap.
401
418
  const webhookBytes = event.body === undefined ? 0
@@ -70,4 +70,10 @@ export declare function parseCheckpointReply(stdout: string): CheckpointReply |
70
70
  * post-deploy webhook steps from the builder machine against the forwarder's Function URL. Every
71
71
  * gate is fail-visible; `writeSecretFile` is the caller's 0600-temp-file seam (see the header).
72
72
  */
73
- export declare function deployAgentcoreRun(plan: AgentcoreRunPlan, aws: CliRunner, docker: CliRunner, log: (msg: string) => void, writeSecretFile: (content: string) => Promise<string>, writeForwarderZip: (bytes: Uint8Array) => Promise<string>, registerTelegram: (baseUrl: string) => Promise<RegistrationOutcome>, registerFeishu?: (baseUrl: string, kind: "feishu" | "lark") => Promise<RegistrationOutcome>, registerSlack?: (baseUrl: string) => Promise<RegistrationOutcome>): Promise<AgentcoreRunOutcome>;
73
+ export declare function deployAgentcoreRun(plan: AgentcoreRunPlan, aws: CliRunner, docker: CliRunner, log: (msg: string) => void, writeSecretFile: (content: string) => Promise<string>, writeForwarderZip: (bytes: Uint8Array) => Promise<string>, registerTelegram: (baseUrl: string) => Promise<RegistrationOutcome>, registerFeishu?: (baseUrl: string, kind: "feishu" | "lark") => Promise<RegistrationOutcome>, registerSlack?: (baseUrl: string) => Promise<RegistrationOutcome>,
74
+ /** Injected in tests; the probe itself stays inside the run so no deploy can skip it. */
75
+ probe?: {
76
+ fetchImpl?: typeof fetch;
77
+ timeoutMs?: number;
78
+ intervalMs?: number;
79
+ }): Promise<AgentcoreRunOutcome>;
@@ -3,6 +3,66 @@ import { createHash } from "node:crypto";
3
3
  import { Buffer } from "node:buffer";
4
4
  import { AUTH_SEED_CHUNK_SIZE, AUTH_SEED_MAX_CHUNKS, cfnParamName, forwarderSource, ingressSessionId, stateBucketName, } from "./plan.js";
5
5
  import { zipSingleFile } from "./zip.js";
6
+ /** How long the post-deploy probe waits for the fresh session (image pull + microVM boot + snapshot
7
+ * restore + channel construction) before gating with the last answer. */
8
+ const PROBE_TIMEOUT_MS = 120_000;
9
+ const PROBE_INTERVAL_MS = 3_000;
10
+ /**
11
+ * Drive the forwarder's reserved `/__fastagent/probe` path until it answers, and read the runtime's
12
+ * STRUCTURED verdict. The path answers on every forwarder topology (a schedule-only URL refuses
13
+ * ordinary public traffic, so a plain `GET /health` would 404 there), and the verdict rides a
14
+ * transport-200 JSON body `{ ok, error? }` — the ordinary webhook relay folds a non-200 transport
15
+ * into an opaque 502, which would strip the very diagnostics this probe exists to carry.
16
+ *
17
+ * Outcome policy: `ok:true` verifies the deploy; `ok:false` gates IMMEDIATELY with the runtime's own
18
+ * error text (construction rejections are cached per session, so polling cannot change the answer);
19
+ * anything else (unroutable URL, forwarder 4xx/5xx, malformed body) is retried to the deadline —
20
+ * that budget's job is absorbing cold-start provisioning — and then gates with the last answer seen.
21
+ */
22
+ async function probeRuntime(probeUrl, auth, fetchImpl, timeoutMs = PROBE_TIMEOUT_MS, intervalMs = PROBE_INTERVAL_MS) {
23
+ const deadline = Date.now() + timeoutMs;
24
+ let last;
25
+ for (;;) {
26
+ try {
27
+ const res = await fetchImpl(probeUrl, {
28
+ method: "POST",
29
+ headers: { "content-type": "application/json" },
30
+ body: JSON.stringify({ auth }),
31
+ signal: AbortSignal.timeout(65_000),
32
+ });
33
+ const bodyText = await res.text();
34
+ if (res.status === 200) {
35
+ let verdict;
36
+ try {
37
+ verdict = JSON.parse(bodyText);
38
+ }
39
+ catch {
40
+ /* malformed — fall through to retry with it as the last answer */
41
+ }
42
+ if (verdict?.ok === true)
43
+ return { ok: true };
44
+ if (verdict?.ok === false) {
45
+ const error = typeof verdict.error === "string" ? verdict.error : "unknown error";
46
+ return { ok: false, gate: `the deployed runtime failed its probe: ${error} — fix and re-run` };
47
+ }
48
+ }
49
+ const firstLine = bodyText.trim().split("\n")[0] ?? "";
50
+ last = `${res.status}${firstLine ? ` ${firstLine}` : ""}`;
51
+ }
52
+ catch {
53
+ /* not routable yet (Function URL DNS, cold start) — keep polling until the deadline */
54
+ }
55
+ if (Date.now() >= deadline) {
56
+ return {
57
+ ok: false,
58
+ gate: last
59
+ ? `the forwarder probe never verified the deployment (last answer: ${last}) — check the runtime logs and re-run`
60
+ : "the forwarder URL never answered the probe — check the Function URL / runtime logs and re-run",
61
+ };
62
+ }
63
+ await new Promise((r) => setTimeout(r, intervalMs));
64
+ }
65
+ }
6
66
  /** Stack outputs (`describe-stacks --query "Stacks[0].Outputs"`) → { OutputKey: OutputValue }. */
7
67
  export function parseStackOutputs(stdout) {
8
68
  try {
@@ -63,7 +123,9 @@ export function parseCheckpointReply(stdout) {
63
123
  * post-deploy webhook steps from the builder machine against the forwarder's Function URL. Every
64
124
  * gate is fail-visible; `writeSecretFile` is the caller's 0600-temp-file seam (see the header).
65
125
  */
66
- export async function deployAgentcoreRun(plan, aws, docker, log, writeSecretFile, writeForwarderZip, registerTelegram, registerFeishu, registerSlack) {
126
+ export async function deployAgentcoreRun(plan, aws, docker, log, writeSecretFile, writeForwarderZip, registerTelegram, registerFeishu, registerSlack,
127
+ /** Injected in tests; the probe itself stays inside the run so no deploy can skip it. */
128
+ probe = {}) {
67
129
  const gate = (g) => ({ ok: false, gate: g });
68
130
  const stack = `fastagent-${plan.name}`;
69
131
  const repo = `fastagent/${plan.name}`;
@@ -359,19 +421,42 @@ export async function deployAgentcoreRun(plan, aws, docker, log, writeSecretFile
359
421
  log("note: no ingress session to stop (first deploy, or already reclaimed)");
360
422
  }
361
423
  else {
362
- log(`warn: could not stop the ingress session an ACTIVE session may keep serving the PREVIOUS ` +
363
- `image until reclaimed (idle timeout / 8 h ceiling). Stop it manually: aws ${stopCommand.join(" ")}`);
424
+ // A GATE, not a warning: the probe below reaches the SAME fixed session id, so a session
425
+ // still running the previous image would answer it and the deploy would claim to have
426
+ // verified a serving path it never touched. Unable to guarantee the session is fresh =
427
+ // unable to verify = stop.
364
428
  const firstLine = stderr.trim().split("\n")[0];
365
- if (firstLine)
366
- log(`warn: ${firstLine}`);
429
+ return gate(`could not stop the ingress session — it may still be serving the PREVIOUS image, so the ` +
430
+ `deploy cannot verify the new one${firstLine ? ` (${firstLine})` : ""}. ` +
431
+ `Stop it manually (aws ${stopCommand.join(" ")}) and re-run`);
367
432
  }
368
433
  }
369
434
  }
435
+ // 8c. Every forwarder topology MUST carry the ForwarderUrl output — schedule-only and
436
+ // selfSchedule-only deployments included, since the probe below is their only construction
437
+ // check (there is no boot-time failStartup on this host). A missing output means an edited
438
+ // template; skipping the probe silently would let such a deploy report success unverified.
439
+ // Only a pure-invoke deployment (no forwarder) legitimately has no URL and nothing to probe.
440
+ // `channels.length` is belt-and-braces: the planner derives needsForwarder FROM the channel
441
+ // list, but this gate must not silently trust that invariant across callers.
442
+ if ((plan.needsForwarder || plan.channels.length > 0) && !url) {
443
+ return gate("this deployment needs the forwarder but the stack has no ForwarderUrl output — regenerate the " +
444
+ "template with --force");
445
+ }
446
+ // 8d. Warm + verify the NEW serving path end to end, BEFORE registration: the probe wakes a fresh
447
+ // session on the new image through the forwarder's reserved path, which restores the state
448
+ // snapshot and constructs the channels — construction is deferred to exactly that moment
449
+ // (channels/agentcore.ts), so this is where a bad credential, a broken channels/ module, or an
450
+ // unrestorable snapshot surfaces AT DEPLOY TIME with the runtime's own error text.
451
+ if (url) {
452
+ log("probing the deployed runtime (state restore + channel construction)…");
453
+ const verdict = await probeRuntime(`${url}/__fastagent/probe`, plan.secrets.FASTAGENT_INGRESS_SECRET ?? "", probe.fetchImpl ?? fetch, probe.timeoutMs, probe.intervalMs);
454
+ if (!verdict.ok)
455
+ return gate(verdict.gate);
456
+ log("runtime verified (state restored, channels constructed)");
457
+ }
370
458
  // 9. Post-deploy webhook registration — same registrar seam as every host, pointed at the
371
459
  // forwarder's Function URL. Gate policy is the shared registration-gate kernel.
372
- if (plan.channels.length > 0 && !url) {
373
- return gate("channels are declared but the stack has no ForwarderUrl output — regenerate the template with --force");
374
- }
375
460
  const reg = registrationGate(log, "re-run to retry registration (steps already done are skipped)");
376
461
  if (url) {
377
462
  if (plan.channels.includes("telegram")) {
@@ -19,9 +19,15 @@ interface DeployFacts {
19
19
  * has no external wake-up, so the deployment must keep one machine running: the fly plan forces
20
20
  * `min_machines_running=1`, the railway runbook forbids App Sleeping. */
21
21
  hasTimeTriggers: boolean;
22
- /** What satisfies model auth locally ({@link probeAuthSource}) — an env-var name, an OAuth/stored label,
23
- * or undefined. Drives the runbook's secret guidance and `--run`'s credential carry. */
22
+ /** What satisfies model auth locally — an env-var name, an OAuth/stored label, or undefined. Drives the
23
+ * runbook's secret guidance and `--run`'s credential carry. For a models.json endpoint keyed from the
24
+ * environment this is the VARIABLE NAME (see {@link modelCredentialCarry}), not the display label, so
25
+ * the value carries like any provider key. */
24
26
  modelAuth: string | undefined;
27
+ /** The definition itself carries the model key (a models.json literal `apiKey`, or a `!command` run on
28
+ * the host), so there is nothing for `--run` to carry AND nothing to gate: `fastagent login` cannot
29
+ * serve a custom provider, so gating on it would strand a correctly configured agent. */
30
+ modelKeyInDefinition: boolean;
25
31
  /** The project-level auth file `--run` reads to carry the credential (probed with the same path). */
26
32
  authPath: string;
27
33
  /** Container facts shared by the plan and the generated Dockerfile — ONE source, so they can't drift. */
@@ -17,12 +17,13 @@ import { resolveAuthPath } from "../engines/pi/config.js";
17
17
  import { resolveSecretsDir, resolveStateRoot } from "../paths.js";
18
18
  import { inspectChannels } from "../engines/pi/channel.js";
19
19
  import { discoverScheduleFiles } from "../schedule/discover.js";
20
- import { createPiModels, probeAuthSource } from "../engines/pi/models.js";
20
+ import { createPiModelRuntime, modelCredentialCarry, probeAuthSource } from "../engines/pi/models.js";
21
21
  import { CHANNEL_KINDS } from "../scaffold/add-channel.js";
22
22
  import { exists } from "../paths.js";
23
23
  import { detectRuntime, readPackageJson } from "../runtime.js";
24
24
  import { fastagentVersion } from "../version.js";
25
25
  import { isGeneratedDockerfile, isGeneratedDockerignore } from "./container.js";
26
+ import { isEnvKey } from "./secrets.js";
26
27
  /**
27
28
  * "Would docker's packer drop this path?" — built from a `.dockerignore`'s text via the `ignore`
28
29
  * matcher (the same library the workspace ignore files use), so `!` negation and last-match-wins are
@@ -126,9 +127,25 @@ export async function preflightDeploy(input) {
126
127
  });
127
128
  }
128
129
  // Probe auth from the SAME project-level file the opener/login use — not the global default, which would
129
- // miss a `fastagent login` credential and falsely report "none configured".
130
+ // miss a `fastagent login` credential and falsely report "none configured". Through the AGENT's model
131
+ // surface too (its models.json travels into the image), so a custom endpoint is not read as an unknown
132
+ // provider — this probe feeds the gate that decides whether `--run` may proceed.
130
133
  const authPath = resolveAuthPath(agentDir, authPathFlag);
131
- const modelAuth = modelSpec ? await probeAuthSource(createPiModels({ authPath }), modelSpec) : undefined;
134
+ const models = await createPiModelRuntime({ agentDir, authPath });
135
+ let modelAuth = modelSpec ? await probeAuthSource(models, modelSpec) : undefined;
136
+ let modelKeyInDefinition = false;
137
+ // probeAuthSource answers "is it authenticated here", which is not the deploy question ("how does the
138
+ // credential REACH the host"). It reports every models.json endpoint as "configured API key" — not an
139
+ // env-var name — so without this the gate below sees no credential and stops the deploy with two
140
+ // remedies that are both wrong for such an agent: `fastagent login` cannot serve a custom provider,
141
+ // and the key is already in the environment.
142
+ if (modelSpec && !isEnvKey(modelAuth)) {
143
+ const carry = modelCredentialCarry(models, modelSpec);
144
+ if (carry.envVar)
145
+ modelAuth = carry.envVar;
146
+ else
147
+ modelKeyInDefinition = carry.inDefinition;
148
+ }
132
149
  // Container facts (shared by every host) + the warnings that follow. The facts describe the AGENT —
133
150
  // its package.json/runtime/lockfile drive the image's install step — never the workspace's (the bake
134
151
  // ships the whole tree, but the workspace's own manifest belongs to its own deploy).
@@ -195,12 +212,17 @@ export async function preflightDeploy(input) {
195
212
  const rel = relative(workspace, p);
196
213
  return rel === "" || rel.startsWith("..") || isAbsolute(rel) ? undefined : rel.split(sep).join("/");
197
214
  };
198
- // The secrets DIR is the unit, not the two filenames we happen to know: an atomic-write temp beside
199
- // auth.json, a second key file, an editor backup of `.env` all of it must stay out of the image, and
200
- // `resolveSecretsDir` says as much ("everything fastagent manages that must never leave the machine").
201
- // The auth path adds an entry only when an override puts it OUTSIDE that dir. An external secrets dir
202
- // (the deployed posture: a mounted volume) is outside the context nothing to check, nothing to
203
- // exclude.
215
+ // The secrets DIR is the unit of RESPONSIBILITY, but never the unit of the leak QUESTION below: the
216
+ // generated ignore excludes the dir's CONTENTS (`**/.secrets/**`) so its two value-free tracked
217
+ // scaffolds can be re-included, and a directory-level question reads that correct file as "not
218
+ // excluded" the generator's own default output gated its own deploy (field-hit: a fresh
219
+ // kit-layout workspace without --force; --force skips checking our own file, which is why the
220
+ // combination stayed invisible). What leaks is a FILE, so files are what the gate asks about — see
221
+ // secretDirFiles below, which enumerates what is actually inside (an atomic-write temp beside
222
+ // auth.json, a second key file, an editor backup of `.env`: the dir-as-unit worry, covered per
223
+ // file). The auth path adds an entry only when an override puts it OUTSIDE that dir. An external
224
+ // secrets dir (the deployed posture: a mounted volume) is outside the context — nothing to check,
225
+ // nothing to exclude.
204
226
  const secretsRel = inContext(resolveSecretsDir(agentDir));
205
227
  const authRel = inContext(authPath);
206
228
  const authElsewhere = authRel !== undefined && (secretsRel === undefined || !authRel.startsWith(`${secretsRel}/`));
@@ -246,7 +268,29 @@ export async function preflightDeploy(input) {
246
268
  .map((n) => join(relDir, n).split(sep).join("/"));
247
269
  };
248
270
  const envFiles = (await Promise.all([...new Set(["", agentPrefix])].map(dotEnvFiles))).flat();
249
- const leakCandidates = [...(await present(secretPaths)), ...envFiles];
271
+ // Everything ACTUALLY inside the secrets dir, minus the two tracked scaffolds the image ships on
272
+ // purpose (they carry no values; the generated ignore re-includes them by name). Existence is the
273
+ // enumeration itself — readdir lists exactly what could be baked — and a hand-written ignore that
274
+ // misses the dir now gates NAMING the leaking file, a better diagnostic than pointing at a
275
+ // directory. Recurses: a subdirectory inside .secrets is unusual but its files leak all the same.
276
+ const secretDirFiles = async (dirRel) => {
277
+ const entries = await readdir(join(workspace, dirRel), { withFileTypes: true }).catch(() => []);
278
+ const files = [];
279
+ for (const entry of entries) {
280
+ if (entry.name === ".gitignore" || entry.name === ".env.example")
281
+ continue;
282
+ if (entry.isDirectory())
283
+ files.push(...(await secretDirFiles(`${dirRel}/${entry.name}`)));
284
+ else
285
+ files.push(`${dirRel}/${entry.name}`);
286
+ }
287
+ return files;
288
+ };
289
+ const leakCandidates = [
290
+ ...(secretsRel ? await secretDirFiles(secretsRel) : []),
291
+ ...(await present(authElsewhere && authRel !== undefined ? [authRel] : [])),
292
+ ...envFiles,
293
+ ];
250
294
  // Same existence rule: a node_modules that is not there cannot be uploaded.
251
295
  const depDirs = await present([...new Set([`${agentPrefix}node_modules`, "node_modules"])]);
252
296
  const machineryPaths = [...secretPaths, ...(stateRel ? [stateRel] : [])];
@@ -383,6 +427,7 @@ export async function preflightDeploy(input) {
383
427
  longConnectionChannels,
384
428
  hasTimeTriggers,
385
429
  modelAuth,
430
+ modelKeyInDefinition,
386
431
  authPath,
387
432
  container,
388
433
  port,
@@ -38,6 +38,9 @@ export declare function deploymentSecrets(modelAuth: string | undefined, channel
38
38
  */
39
39
  export declare function assembleSecrets(input: {
40
40
  modelAuth: string | undefined;
41
+ /** The definition carries the model key itself (a models.json literal `apiKey` / `!command`): there is
42
+ * no value to carry and no gate to raise — see {@link modelCredentialCarry}. */
43
+ modelKeyInDefinition?: boolean;
41
44
  authFile: Buffer | undefined;
42
45
  channels: ChannelKind[];
43
46
  longConnectionChannels?: string[];
@@ -65,6 +65,12 @@ export function assembleSecrets(input) {
65
65
  else if (input.authFile) {
66
66
  secrets.FASTAGENT_AUTH_SEED = input.authFile.toString("base64");
67
67
  }
68
+ else if (input.modelKeyInDefinition) {
69
+ // The definition authenticates itself (models.json literal key, or a command run on the host), so it
70
+ // travels in the image with everything else. Gating here would be the worst kind of wrong: both
71
+ // remedies we print are impossible for such an agent — `fastagent login` has no flow for a custom
72
+ // provider, and there is no provider env key to set.
73
+ }
68
74
  else {
69
75
  needsModelCredential = true; // no env key, no auth.json — `fastagent login` remediation
70
76
  }
@@ -13,7 +13,7 @@
13
13
  import { spawn } from "node:child_process";
14
14
  import { relative, sep } from "node:path";
15
15
  import { watch as watchTree } from "chokidar";
16
- import { AGENT_CONFIG_NAMES, resolveStateRoot } from "./paths.js";
16
+ import { AGENT_CONFIG_NAMES, AGENT_MODELS_FILE, resolveStateRoot } from "./paths.js";
17
17
  import { isUnderDir } from "./engines/pi/definition.js";
18
18
  import { dotEnvPath } from "./env.js";
19
19
  import { log } from "./log.js";
@@ -21,7 +21,7 @@ import { installProxyFetch } from "./proxy.js";
21
21
  import { openExternalUrl } from "./open-url.js";
22
22
  import { announceWebhooks, startCloudflareTunnel } from "./tunnel.js";
23
23
  /** What the dev watcher restarts on (agent-dir-relative): the process-bound code inputs only. */
24
- const WATCHED_HINT = "tools/, channels/, schedules/, package.json, fastagent.config.*, .secrets/.env";
24
+ const WATCHED_HINT = "tools/, channels/, schedules/, package.json, fastagent.config.*, models.json, .secrets/.env";
25
25
  /**
26
26
  * chokidar `ignored` matcher for the narrow watch scope (true = ignore), rooted at the AGENT DIR. When
27
27
  * the agent sits INSIDE the workspace that means the surrounding tree never triggers a restart at all;
@@ -49,6 +49,12 @@ export function devWatchIgnored(root, envFile) {
49
49
  return false;
50
50
  if (rel === "package.json")
51
51
  return false;
52
+ // models.json is read ONCE per worker (the model hub is built during assembly), so an edit needs a
53
+ // restart like any other code input. It is also the one input whose breakage KILLS the worker — a
54
+ // malformed file fails assembly — so leaving it unwatched would strand the author: the fix that
55
+ // repairs it would not be the edit that restarts.
56
+ if (rel === AGENT_MODELS_FILE)
57
+ return false;
52
58
  const segments = rel.split(sep);
53
59
  if (segments[0] === "tools" || segments[0] === "channels" || segments[0] === "schedules")
54
60
  return false;
@@ -7,7 +7,8 @@ import { type AnyModel, piHarnessFactory } from "./harness.ts";
7
7
  import { type PiSessionStore } from "./sessions.ts";
8
8
  import type { ModuleLoadFailure } from "../../loader.ts";
9
9
  import { type ToolCollision, type MountedTool } from "./tool.ts";
10
- import { type Lease, type SessionObserver } from "./invoke.ts";
10
+ import { type SessionObserver } from "./invoke.ts";
11
+ import { type Lease } from "./turn-kit.ts";
11
12
  /** pi's core default toolset (read/bash/edit/write). Rooted at the ExecutionEnv's cwd, supplied per
12
13
  * turn as the harness tool context — hence no argument here. */
13
14
  export declare function piDefaultTools(): MountedTool[];
@@ -18,12 +18,13 @@ import { defaultAuthPath, resolveModel } from "./config.js";
18
18
  import { resolveSecretsDir } from "../../paths.js";
19
19
  import { loadAgentDefinition } from "./definition.js";
20
20
  import { DEFAULT_THINKING_LEVEL, piHarnessFactory } from "./harness.js";
21
- import { createPiModels } from "./models.js";
21
+ import { createPiModelRuntime, createPiModels } from "./models.js";
22
22
  import { reportFindingsIfChanged } from "./report.js";
23
23
  import { inMemorySessionStore } from "./sessions.js";
24
24
  import { isDeferredTool, loadTools, mergeDiscoveredTools, } from "./tool.js";
25
25
  import { withSearchTool } from "./search-tools.js";
26
- import { createPiAgentFromHarness, inProcessLease } from "./invoke.js";
26
+ import { createPiAgentFromHarness } from "./invoke.js";
27
+ import { inProcessLease } from "./turn-kit.js";
27
28
  // ── §1 tools ─────────────────────────────────────────────────────────────────
28
29
  //
29
30
  // The full pi toolset is the default for fidelity: authors vibe in local pi with it, so serving with
@@ -164,7 +165,7 @@ export function assembleSystemPrompt(options) {
164
165
  * directory) and route through here.
165
166
  */
166
167
  function buildPiAgent(opts) {
167
- const models = createPiModels({ providers: opts.providers, authPath: opts.authPath });
168
+ const models = opts.models ?? createPiModels({ providers: opts.providers, authPath: opts.authPath });
168
169
  const env = opts.env ?? new NodeExecutionEnv({ cwd: process.cwd() });
169
170
  // Materialized here (not defaulted inside createPiAgentFromHarness) so the exposed parts carry
170
171
  // the SAME lease instance the agent runs under — boundary mutations must contend on it.
@@ -241,13 +242,17 @@ export async function createPiAgentFromDefinition(dir, options) {
241
242
  // Deferred tools need their loader on every rung (idempotent — the workspace opener already applied
242
243
  // it; a caller's own search_tools wins).
243
244
  const tools = withSearchTool(options.tools ?? piDefaultTools());
245
+ // Dir-aware default: the same secrets-dir-derived file the opener uses for this dir (the opener
246
+ // passes an explicit authPath, so this only affects direct L2 callers).
247
+ const authPath = options.authPath ?? defaultAuthPath(resolveSecretsDir(dir));
244
248
  const agent = buildPiAgent({
245
249
  model: options.model,
246
250
  thinkingLevel: options.thinkingLevel,
247
- providers: options.providers,
248
- // Dir-aware default: the same secrets-dir-derived file the opener uses for this dir (the opener
249
- // passes an explicit authPath, so this only affects direct L2 callers).
250
- authPath: options.authPath ?? defaultAuthPath(resolveSecretsDir(dir)),
251
+ // THE directory rung's model surface: built-ins + the agent's own models.json (custom endpoints,
252
+ // which are definition data and travel with the artifact) + any injected Provider instance. This
253
+ // is what makes `dev`/`start`/`invoke` and an embedded L2 caller resolve the same specs.
254
+ models: await createPiModelRuntime({ agentDir: dir, authPath, providers: options.providers }),
255
+ authPath,
251
256
  // The directory is the agent, LIVE: re-read the definition on every invoke, so AGENTS.md/skills
252
257
  // edits (the author's, or the agent's own self-modification) take effect on the next turn with
253
258
  // no process restart — restarts are reserved for code (tools/channels/config, module cache).
@@ -9,7 +9,7 @@
9
9
  import { AgentHarness } from "@earendil-works/pi-agent-core";
10
10
  import type { ExecutionEnv, ExecutionToolContext, Skill, ThinkingLevel } from "@earendil-works/pi-agent-core";
11
11
  import type { Model, Models } from "@earendil-works/pi-ai";
12
- import { type PiSessionStore } from "./sessions.ts";
12
+ import { type PiSessionStore, type SessionInheritance } from "./sessions.ts";
13
13
  import { type MountedTool } from "./tool.ts";
14
14
  import { type OverrideEntryLike } from "./session-settings.ts";
15
15
  /**
@@ -38,7 +38,9 @@ export type AnyModel = Model<any>;
38
38
  * pi's env-backed default tools read (pi 0.83). Custom tools are context-FREE and stay assignable — a
39
39
  * four-parameter `execute` satisfies the five-parameter one, so `defineTool` is untouched by this. */
40
40
  type PiHarness = AgentHarness<ExecutionToolContext>;
41
- export type PiHarnessFactory = (session: string) => PiHarness | Promise<PiHarness>;
41
+ export type PiHarnessFactory = (session: string,
42
+ /** Where a NEW session starts from (sessions.ts) — an existing session ignores it. */
43
+ inherit?: SessionInheritance) => PiHarness | Promise<PiHarness>;
42
44
  export interface PiHarnessFactoryOptions {
43
45
  /** Session persistence. Continuity = same backing store + same session id. */
44
46
  sessions: PiSessionStore;
@@ -107,6 +109,7 @@ export declare function resolveHarnessOverrides(entries: OverrideEntryLike[], mo
107
109
  thinkingLevel: ThinkingLevel;
108
110
  };
109
111
  export declare function resolveHarnessActiveToolNames(recorded: string[] | null, tools: MountedTool[], sessionId: string): string[] | undefined;
110
- /** Open-or-create the session per invoke: existing → open (history via buildContext); missing → create. */
112
+ /** Open-or-create the session per invoke: existing → open (history via buildContext); missing →
113
+ * create, seeded from `inherit` when the scope names a parent. */
111
114
  export declare function piHarnessFactory(options: PiHarnessFactoryOptions): PiHarnessFactory;
112
115
  export {};
@@ -114,10 +114,11 @@ export function resolveHarnessActiveToolNames(recorded, tools, sessionId) {
114
114
  }
115
115
  return [...new Set([...initial, ...known])];
116
116
  }
117
- /** Open-or-create the session per invoke: existing → open (history via buildContext); missing → create. */
117
+ /** Open-or-create the session per invoke: existing → open (history via buildContext); missing →
118
+ * create, seeded from `inherit` when the scope names a parent. */
118
119
  export function piHarnessFactory(options) {
119
- return async (sessionId) => {
120
- const session = await options.sessions.openOrCreate(sessionId);
120
+ return async (sessionId, inherit) => {
121
+ const session = await options.sessions.openOrCreate(sessionId, inherit);
121
122
  // One extra entry walk per invoke to collect the activation deltas — negligible against the model
122
123
  // call, same trade as L2's per-invoke definition re-read. The walk is over the ACTIVE PATH, not
123
124
  // the flat journal: `navigate` moves the leaf, so the tree can hold an abandoned branch whose
@@ -0,0 +1,32 @@
1
+ /**
2
+ * L0 over pi-coding-agent's `AgentSession`, in the `per-invoke` state locality
3
+ * ([conformance-levels.md](../../../docs/design/conformance-levels.md) §2, top-right cell): build a
4
+ * session per invoke over the SAME durable jsonl, run one turn, dispose.
5
+ *
6
+ * Why this exists next to {@link createPiAgentFromHarness}: pi 0.84 replaced `AgentHarness` with an
7
+ * unimplemented lane-based skeleton, and pi does not consume that class itself — its TUI, RPC and SDK
8
+ * all run on `AgentSession`. This is the executable proof that the SPEC's four Agent-side MUSTs hold
9
+ * on the class pi actually maintains (test/conformance-session.test.ts).
10
+ *
11
+ * SCOPE, deliberately narrow: the concurrency floor, the event stream, and cancellation. The
12
+ * observation plane (SessionObserver / RunControls / the rich `SessionEvent` vocabulary), the tool
13
+ * activation bridge, auto-compaction and session inheritance are NOT wired.
14
+ *
15
+ * WHICH L0 SERVES: {@link createPiAgentFromHarness}, still — this one is reachable only from its
16
+ * conformance test (deliberately absent from `src/pi.ts`), because a serving path needs the pieces
17
+ * above. Its one known debt is {@link toAgentEvent}: translating pi events straight to SPEC
18
+ * `AgentEvent`s is the second parallel translation `docs/design/session-control.md` §6 forbids. It
19
+ * retires the moment this L0 grows the observation plane — the rich `SessionEvent` layer comes back
20
+ * with it, and the harness L0 goes away.
21
+ */
22
+ import type { AgentSession } from "@earendil-works/pi-coding-agent";
23
+ import { type Agent } from "../../agent.ts";
24
+ import { type Lease } from "./turn-kit.ts";
25
+ /** Open-or-create the session behind `sessionId` and bind an `AgentSession` to it, per invoke. */
26
+ export type PiAgentSessionFactory = (sessionId: string) => Promise<AgentSession>;
27
+ export interface CreatePiAgentFromSessionOptions {
28
+ sessionFactory: PiAgentSessionFactory;
29
+ /** Single-writer lease. Defaults to the in-process per-session fail-fast lease. */
30
+ lease?: Lease;
31
+ }
32
+ export declare function createPiAgentFromSession(options: CreatePiAgentFromSessionOptions): Agent;