@fastagent-sh/fastagent 0.16.1 → 0.17.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 (40) hide show
  1. package/dist/channels/agentcore-state.js +7 -0
  2. package/dist/channels/agentcore.js +24 -5
  3. package/dist/channels/control.d.ts +1 -1
  4. package/dist/channels/control.js +5 -1
  5. package/dist/cli/commands/attach.d.ts +29 -1
  6. package/dist/cli/commands/attach.js +76 -4
  7. package/dist/cli/commands/deploy.js +11 -5
  8. package/dist/cli/commands/dev.js +2 -2
  9. package/dist/cli/commands/info.js +2 -2
  10. package/dist/cli/commands/logs.d.ts +6 -0
  11. package/dist/cli/commands/logs.js +27 -0
  12. package/dist/cli/commands/start.js +2 -2
  13. package/dist/cli/program.js +26 -0
  14. package/dist/deploy/agentcore/logs.d.ts +35 -0
  15. package/dist/deploy/agentcore/logs.js +112 -0
  16. package/dist/deploy/agentcore/plan.d.ts +26 -2
  17. package/dist/deploy/agentcore/plan.js +45 -6
  18. package/dist/deploy/container.js +6 -3
  19. package/dist/engines/pi/config.d.ts +1 -1
  20. package/dist/engines/pi/config.js +1 -1
  21. package/dist/engines/pi/create.d.ts +6 -1
  22. package/dist/engines/pi/create.js +16 -19
  23. package/dist/engines/pi/definition.d.ts +15 -0
  24. package/dist/engines/pi/definition.js +22 -1
  25. package/dist/engines/pi/harness.d.ts +12 -28
  26. package/dist/engines/pi/harness.js +21 -71
  27. package/dist/engines/pi/open.d.ts +1 -1
  28. package/dist/engines/pi/open.js +20 -0
  29. package/dist/engines/pi/report.d.ts +16 -0
  30. package/dist/engines/pi/report.js +30 -0
  31. package/dist/engines/pi/session-builder.js +2 -2
  32. package/dist/engines/pi/session-control.d.ts +23 -4
  33. package/dist/engines/pi/session-control.js +159 -27
  34. package/dist/engines/pi/session-settings.d.ts +51 -0
  35. package/dist/engines/pi/session-settings.js +73 -0
  36. package/dist/engines/pi/sessions.d.ts +21 -7
  37. package/dist/engines/pi/sessions.js +43 -0
  38. package/dist/session-remote.js +17 -0
  39. package/dist/session.d.ts +54 -9
  40. package/package.json +1 -1
@@ -29,19 +29,42 @@
29
29
  */
30
30
  import { createHash } from "node:crypto";
31
31
  import { MAX_WEBHOOK_BODY_BYTES } from "../../channels/agentcore-limits.js";
32
+ import { SECRETS_DIRNAME } from "../../paths.js";
32
33
  import { containerArtifacts } from "../container.js";
33
34
  import { deploymentSecrets, isEnvKey } from "../secrets.js";
34
35
  /** SessionStorage mount = FASTAGENT_STATE_DIR (AgentCore requires exactly `/mnt/<one-level>`). It is
35
36
  * a fast LOCAL disk only: the platform wipes it on every runtime version update (= every deploy).
36
37
  * Durability across deploys comes from the S3 snapshot (channels/agentcore-state.ts). */
37
38
  export const MOUNT = "/mnt/state";
39
+ /**
40
+ * FASTAGENT_SECRETS_DIR — the seeded-then-ROTATED auth.json, deliberately INSIDE the state root
41
+ * rather than beside it.
42
+ *
43
+ * Every other host mounts a real volume and puts the two machinery dirs side by side (`/data/.state`
44
+ * + `/data/.secrets`), because there the persistence boundary is the MOUNT POINT: anything under it
45
+ * survives. AgentCore has no volume. Its persistence boundary is `packStateRoot(stateRoot)` — the one
46
+ * directory tree the S3 snapshot copies out and back (channels/agentcore-state.ts) — while {@link MOUNT}
47
+ * itself is wiped on every runtime version update, i.e. on every deploy.
48
+ *
49
+ * So the sibling layout would put credentials INSIDE the mount but OUTSIDE the snapshot: nothing
50
+ * copies them out, the platform wipes them, and the next microVM re-seeds the deploy-time copy. With
51
+ * single-use OAuth refresh tokens that is a slow-motion outage — the box works until the seeded token
52
+ * is rotated away, then loses model access with only a redeploy to restore it.
53
+ *
54
+ * Nesting is what makes agentcore-state.ts's stated contract ("restores VERBATIM — including
55
+ * auth.json") reachable at all; `packStateRoot` walks the whole tree, so no snapshot code knows about
56
+ * this. Tests assert the containment, not just the two names — the sibling spelling looks tidier and
57
+ * reintroduces the outage silently.
58
+ */
59
+ export const SECRETS_DIR = `${MOUNT}/${SECRETS_DIRNAME}`;
38
60
  /**
39
61
  * How long an idle session keeps its microVM. Memory is billed per second across the WHOLE session
40
62
  * — idle included, at the peak level reached — so this tail is the standing cost of every burst of
41
63
  * activity, while CPU stops billing the moment the agent stops working. 3 minutes rather than the
42
64
  * platform's 15: the tail shrinks 5×, and the cost is a cold start (image + Node + snapshot restore)
43
- * for anyone who returns after a longer gap. `/ping` reports HealthyBusy while work is in flight, so
44
- * this timer only ever starts once the agent has genuinely settled a long turn is never cut short.
65
+ * for anyone who returns after a longer gap. `/ping` reports HealthyBusy + time_of_last_update while
66
+ * work is in flight (the FIELD is what the platform's idle measurement actually reads agentcore.ts),
67
+ * so this timer only ever starts once the agent has genuinely settled — a long turn is never cut short.
45
68
  * AWS accepts 60–28800.
46
69
  */
47
70
  export const IDLE_TIMEOUT_SECONDS = 180;
@@ -72,6 +95,13 @@ export const GENERATED_TEMPLATE_MARKER = "# Generated by `fastagent deploy agent
72
95
  export function isGeneratedAgentcoreTemplate(content) {
73
96
  return content.startsWith(GENERATED_TEMPLATE_MARKER);
74
97
  }
98
+ /** Deployment base name from the workspace basename — the ONE mapping used to find its stack later. */
99
+ export function agentcoreName(workspaceBasename) {
100
+ return (workspaceBasename
101
+ .toLowerCase()
102
+ .replace(/[^a-z0-9-]+/g, "-")
103
+ .replace(/^-+|-+$/g, "") || "agent");
104
+ }
75
105
  /** Runtime name (`[a-zA-Z][a-zA-Z0-9_]{0,47}`) from a dir basename. */
76
106
  export function toRuntimeName(basename) {
77
107
  const slug = basename.replace(/[^a-zA-Z0-9]+/g, "_").replace(/^_+|_+$/g, "");
@@ -437,6 +467,10 @@ function template(input, translated) {
437
467
  ` PORT: "8080"`, // the Runtime service contract's fixed port (config.http.port does not apply here)
438
468
  ` FASTAGENT_AGENTCORE: "1"`, // serve mounts /invocations + /ping, arms no resident cron
439
469
  ` FASTAGENT_STATE_DIR: ${MOUNT}`,
470
+ // Inside the state root on purpose — the snapshot is this host's only durable store, and it copies
471
+ // exactly one tree. See {@link SECRETS_DIR}: the sibling layout every other host uses would leave a
472
+ // rotated OAuth credential outside it, i.e. discarded with the microVM.
473
+ ` FASTAGENT_SECRETS_DIR: ${SECRETS_DIR}`,
440
474
  ];
441
475
  // The auth seed is chunked (env values max 2048 chars — see AUTH_SEED_CHUNK_SIZE): N parameters,
442
476
  // each riding its own env var; `start` reassembles them (collectAuthSeed). Empty defaults = unused.
@@ -529,7 +563,7 @@ function template(input, translated) {
529
563
  ` # forces a NAT gateway for model/channel egress (~$33/mo) — deliberately not the default.`,
530
564
  ` FilesystemConfigurations:`,
531
565
  ` - SessionStorage: { MountPath: ${MOUNT} }`,
532
- ` # Idle ${IDLE_TIMEOUT_SECONDS}s (the ping's HealthyBusy keeps BUSY sessions alive regardless), max compute`,
566
+ ` # Idle ${IDLE_TIMEOUT_SECONDS}s (the ping's HealthyBusy + time_of_last_update keeps BUSY sessions alive), max compute`,
533
567
  ` # lifetime ${MAX_LIFETIME_SECONDS}s — the platform ceiling; the session id stays valid, so the next invoke`,
534
568
  ` # just gets fresh compute with the same storage. Memory bills per second for the whole`,
535
569
  ` # session INCLUDING the idle tail, so a shorter tail is the main cost lever here.`,
@@ -679,7 +713,12 @@ export function planAgentcoreDeploy(input) {
679
713
  ? `# 4. Read the outputs (the runtime ARN + callback URL; it serves webhooks only when configured):`
680
714
  : `# 4. Read the outputs (the runtime ARN — this topology has NO public URL: nothing outside AWS`, ...(needsFunctionUrl
681
715
  ? []
682
- : [`# sends to it, so no Function URL is created and the agent is reachable only via SigV4).`]), `aws cloudformation describe-stacks --stack-name ${stack} --query "Stacks[0].Outputs"`);
716
+ : [`# sends to it, so no Function URL is created and the agent is reachable only via SigV4).`]), `aws cloudformation describe-stacks --stack-name ${stack} --query "Stacks[0].Outputs"`, ``, `# 5. Tail the Runtime's application stdout/stderr (same fastagent messages + log level as locally).`, `# Discovery resolves the per-endpoint log group from the stack's RuntimeArn:`, `fastagent logs agentcore --follow`, ...(needsForwarder
717
+ ? [
718
+ `# The ingress transport is a separate Lambda and therefore a separate log source:`,
719
+ `fastagent logs agentcore --source forwarder --follow`,
720
+ ]
721
+ : []));
683
722
  // Model-auth guidance mirrors the other hosts: an env key became a parameter above; OAuth/stored
684
723
  // can't be read at plan time — `--run` carries it as FastagentAuthSeed.
685
724
  if (!isEnvKey(input.modelAuth)) {
@@ -694,7 +733,7 @@ export function planAgentcoreDeploy(input) {
694
733
  post.push(`# Register the Telegram webhook (default route POST /telegram; secret_token MUST equal TELEGRAM_SECRET_TOKEN):`, `curl "https://api.telegram.org/bot<TELEGRAM_BOT_TOKEN>/setWebhook" \\`, ` -d url=<ForwarderUrl>/telegram -d secret_token=<TELEGRAM_SECRET_TOKEN>`);
695
734
  }
696
735
  if (channels.includes("github")) {
697
- post.push(`# Set the GitHub webhook (repo Settings → Webhooks): Payload URL = <ForwarderUrl>/webhook,`, `# content type application/json, secret = GITHUB_WEBHOOK_SECRET.`, `# NOTE: github turns are fire-and-forget with no replay — a compute reclaimed mid-review drops it`, `# (the ping's HealthyBusy holds the session while turns run, but the 8 h compute ceiling is hard).`);
736
+ post.push(`# Set the GitHub webhook (repo Settings → Webhooks): Payload URL = <ForwarderUrl>/webhook,`, `# content type application/json, secret = GITHUB_WEBHOOK_SECRET.`, `# NOTE: github turns are fire-and-forget with no replay — a compute reclaimed mid-review drops it`, `# (the ping's HealthyBusy + time_of_last_update holds the session while turns run, but the 8 h compute ceiling is hard).`);
698
737
  }
699
738
  if (channels.includes("slack")) {
700
739
  post.push(`# Set Slack Event Subscriptions → Request URL = <ForwarderUrl>/slack (scopes per channels/slack.ts).`);
@@ -716,6 +755,6 @@ export function planAgentcoreDeploy(input) {
716
755
  if (needsForwarder) {
717
756
  runbook.push(``, `# After a REDEPLOY, stop the ingress session so the new image serves immediately — a live session`, `# keeps its old compute (and the OLD image) until ${IDLE_TIMEOUT_SECONDS}s idle / the 8 h compute ceiling`, `# (\`--run\` does this automatically):`, `aws bedrock-agentcore stop-runtime-session --agent-runtime-arn <RuntimeArn> \\`, ` --runtime-session-id "${ingressSessionId(name)}"`);
718
757
  }
719
- runbook.push(``, `# Redeploy = step 1b (new forwarder key, if its code changed) + step 2 with a NEW tag + step 3.`, `# STATE: ${MOUNT} is a LOCAL disk — AWS wipes it on every runtime version update (i.e. every`, `# deploy) and after 14 idle days. What survives is the S3 snapshot under s3://${bucketHint}/${STATE_KEY}:`, `# the container restores it on its first invocation and pushes it whenever work settles. Keep that`, `# bucket and the agent keeps its sessions, channel state and pending wake-ups across deploys;`, `# delete it and the agent starts blank. (A persistent MOUNT would need EFS + VPC mode + a NAT`, `# gateway for model/channel egress — see the template comment.)`);
758
+ runbook.push(``, `# Redeploy = step 1b (new forwarder key, if its code changed) + step 2 with a NEW tag + step 3.`, `# STATE: ${MOUNT} is a LOCAL disk — AWS wipes it on every runtime version update (i.e. every`, `# deploy) and after 14 idle days. What survives is the S3 snapshot under s3://${bucketHint}/${STATE_KEY}:`, `# the container restores it on its first invocation and pushes it whenever work settles. Keep that`, `# bucket and the agent keeps its sessions, channel state and pending wake-ups across deploys;`, `# delete it and the agent starts blank. (A persistent MOUNT would need EFS + VPC mode + a NAT`, `# gateway for model/channel egress — see the template comment.)`, `# CREDENTIALS RIDE THAT SNAPSHOT TOO: FASTAGENT_SECRETS_DIR is ${SECRETS_DIR}, inside the state`, `# root, so an OAuth auth.json ROTATED on the box persists (a refresh token is single-use — without`, `# this the next microVM would re-seed the deploy-time copy and eventually fail to authenticate).`, `# The bucket is therefore credential storage: it is created with public access blocked and`, `# versioning on, and deleting it costs model access until the next deploy re-seeds.`);
720
759
  return { artifacts, runbook, untranslatableSchedules: untranslatable };
721
760
  }
@@ -128,8 +128,10 @@ CMD ["./${into("node_modules/.bin/fastagent")}", "start", "/app"]
128
128
  /** Patterns are RECURSIVE (`**​/`) on purpose — dockerignore patterns are root-anchored (unlike
129
129
  * .gitignore), and a baked workspace can hold nested projects: a bare `node_modules` would upload
130
130
  * their build-machine deps (macOS binaries!) and a bare `.env` would bake their secrets into the
131
- * image. `.secrets`/`.state` are fastagent machinery — secrets travel through the host's secret
132
- * store, state lives on the volume; neither may ever enter an image. `.cache` is generic hygiene
131
+ * image. `.secrets`/`.state` are fastagent machinery — credential contents travel through the host's
132
+ * secret store and state lives on the volume, so neither may enter an image. The two tracked secrets
133
+ * scaffolds (`.env.example` + `.gitignore`) are the narrow exception: they carry no values and must stay
134
+ * beside the shipped `.git`, or the image starts with tracked deletions. `.cache` is generic hygiene
133
135
  * (a baked project's own build cache), not a fastagent directory.
134
136
  * `.git` is deliberately SHIPPED: the deployed agent's write-back (pull/commit/push) needs the
135
137
  * repo's history+remote — the WYSIWYG bake's freshness/durability loop runs through git, driven by
@@ -145,12 +147,13 @@ const dockerignore = (input) => DOCKERIGNORE_BASE +
145
147
  .join("");
146
148
  const DOCKERIGNORE_BASE = `${GENERATED_DOCKERIGNORE_MARKER}. Delete this line to take ownership (deploy then keeps your file).
147
149
  **/node_modules
148
- **/${SECRETS_DIRNAME}
150
+ **/${SECRETS_DIRNAME}/**
149
151
  **/${STATE_DIRNAME}
150
152
  **/.cache
151
153
  **/.env
152
154
  **/.env.*
153
155
  !**/.env.example
156
+ !**/${SECRETS_DIRNAME}/.gitignore
154
157
  **/*.log
155
158
  # .git is deliberately shipped: the agent can pull to freshen content and push its work back
156
159
  # (the generated image installs the git binary when this directory ships a .git; otherwise
@@ -1,7 +1,7 @@
1
1
  import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
2
2
  import type { FastagentTool } from "./tool.ts";
3
3
  import type { Models } from "@earendil-works/pi-ai";
4
- import { type AnyModel } from "./harness.ts";
4
+ import type { AnyModel } from "./harness.ts";
5
5
  export interface FastagentConfig {
6
6
  /** "provider/modelId". Precedence: CLI --model > FASTAGENT_MODEL > config. */
7
7
  model?: string;
@@ -20,7 +20,7 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
20
20
  import { existsSync, statSync } from "node:fs";
21
21
  import { basename, join } from "node:path";
22
22
  import { pathToFileURL } from "node:url";
23
- import { THINKING_LEVELS } from "./harness.js";
23
+ import { THINKING_LEVELS } from "./session-settings.js";
24
24
  import { isBindAddress } from "../../bind.js";
25
25
  import { moduleLoadHint } from "../../loader.js";
26
26
  import { AGENT_CONFIG_NAMES, resolveOverridePath, resolveSecretsDir } from "../../paths.js";
@@ -3,7 +3,7 @@ import type { Models, 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
- import { piHarnessFactory } from "./harness.ts";
6
+ 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";
@@ -64,6 +64,11 @@ type OnAssembly = (parts: {
64
64
  models: Models;
65
65
  harnessFactory: ReturnType<typeof piHarnessFactory>;
66
66
  lease: Lease;
67
+ /** The resolved configured pair — what a session without overrides runs on. */
68
+ defaults: {
69
+ model: AnyModel;
70
+ thinkingLevel: ThinkingLevel;
71
+ };
67
72
  }) => void;
68
73
  /** L1 options. Tier 1: model (spec) + instructions + tools. Tier 2: the injectable ports. */
69
74
  export interface CreatePiAgentOptions {
@@ -17,9 +17,9 @@ import { readImageProcessor } from "./read-image.js";
17
17
  import { defaultAuthPath, resolveModel } from "./config.js";
18
18
  import { resolveSecretsDir } from "../../paths.js";
19
19
  import { loadAgentDefinition } from "./definition.js";
20
- import { piHarnessFactory } from "./harness.js";
20
+ import { DEFAULT_THINKING_LEVEL, piHarnessFactory } from "./harness.js";
21
21
  import { createPiModels } from "./models.js";
22
- import { reportDefinitionWarnings } from "./report.js";
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";
@@ -169,18 +169,24 @@ function buildPiAgent(opts) {
169
169
  // Materialized here (not defaulted inside createPiAgentFromHarness) so the exposed parts carry
170
170
  // the SAME lease instance the agent runs under — boundary mutations must contend on it.
171
171
  const lease = opts.lease ?? inProcessLease();
172
+ // The assembly's configured PAIR — handed to the factory and to the control plane as ONE value, so
173
+ // there is no wiring in which they could disagree (which levels exist depends on the model).
174
+ const defaults = {
175
+ model: resolveModel(models, opts.model),
176
+ thinkingLevel: opts.thinkingLevel ?? DEFAULT_THINKING_LEVEL,
177
+ };
172
178
  const harnessFactory = piHarnessFactory({
173
179
  sessions: opts.sessions ?? inMemorySessionStore(),
174
180
  env,
175
181
  models,
176
- model: resolveModel(models, opts.model),
182
+ model: defaults.model,
177
183
  thinkingLevel: opts.thinkingLevel,
178
184
  systemPrompt: opts.systemPrompt,
179
185
  tools: opts.tools,
180
186
  skills: opts.skills,
181
187
  live: opts.live,
182
188
  });
183
- opts.onAssembly?.({ models, harnessFactory, lease });
189
+ opts.onAssembly?.({ models, harnessFactory, lease, defaults });
184
190
  return createPiAgentFromHarness({ lease, observer: opts.observer, cwd: env.cwd, harnessFactory });
185
191
  }
186
192
  /**
@@ -215,12 +221,6 @@ export function createPiAgent(options) {
215
221
  observer: options.observer,
216
222
  });
217
223
  }
218
- /** Stable identity of a definition's non-fatal findings, for change-detection in `live` (dedup only). */
219
- function findingsSignature(def) {
220
- const collisions = def.collisions.map((c) => `c:${c.name}:${c.winnerPath}:${c.loserPath}`);
221
- const diagnostics = def.diagnostics.map((d) => `d:${d.code}:${d.path}`);
222
- return [...collisions, ...diagnostics].sort().join("\n");
223
- }
224
224
  /**
225
225
  * L2: "point at a directory → agent": load + assemble (base + AGENTS.md + skills + env) + L1 in one
226
226
  * call. Returns the definition so callers can surface diagnostics/collisions.
@@ -233,10 +233,11 @@ export async function createPiAgentFromDefinition(dir, options) {
233
233
  // Boot-time load: fail-visibly at startup on a broken directory, and give callers the snapshot to
234
234
  // report (skills/diagnostics/collisions). Serving does NOT close over it — see `live` below.
235
235
  const definition = await loadAgentDefinition(dir, { cwd: env.cwd, env });
236
- // Findings the caller already reported at boot; `live` re-reports only when the set CHANGES — a
237
- // runtime-written bad skill surfaces the moment it appears, while a static finding does not spam
238
- // every turn's log. A log-dedup memo, not session state (stateless invoke holds).
239
- let reportedFindings = findingsSignature(definition);
236
+ // Boot findings go through the SAME memoized reporter every later reader uses (report.ts, keyed by
237
+ // the resolved dir): announced once here, and re-announced by a turn or by the control plane's
238
+ // command list only when the set CHANGES — a runtime-written bad skill surfaces the moment it
239
+ // appears, a static one does not spam. Log dedup, not session state (stateless invoke holds).
240
+ reportFindingsIfChanged(definition.dir, definition);
240
241
  // Deferred tools need their loader on every rung (idempotent — the workspace opener already applied
241
242
  // it; a caller's own search_tools wins).
242
243
  const tools = withSearchTool(options.tools ?? piDefaultTools());
@@ -259,11 +260,7 @@ export async function createPiAgentFromDefinition(dir, options) {
259
260
  // next good edit heals both.
260
261
  live: async () => {
261
262
  const def = await loadAgentDefinition(dir, { cwd: env.cwd, env });
262
- const sig = findingsSignature(def);
263
- if (sig !== reportedFindings) {
264
- reportedFindings = sig;
265
- reportDefinitionWarnings(def.collisions, def.diagnostics);
266
- }
263
+ reportFindingsIfChanged(def.dir, def);
267
264
  return {
268
265
  systemPrompt: assembleSystemPrompt({
269
266
  // Segment ①: an authored persona (persona.md, def.persona) overrides the engine identity,
@@ -41,6 +41,21 @@ export interface LoadAgentDefinitionOptions {
41
41
  }
42
42
  /** Read an agent definition. persona.md/skills come from `agentDir`; ② context = pi's loadProjectContextFiles({ cwd, agentDir }). */
43
43
  export declare function loadAgentDefinition(agentDir: string, options?: LoadAgentDefinitionOptions): Promise<LoadedDefinition>;
44
+ /**
45
+ * The definition's skills ALONE, resolved the same way `loadAgentDefinition` resolves them (same
46
+ * loader, same containment guard, same first-wins collision rule) — for readers that need only the
47
+ * names and must not pay the full load's ② context walk (every AGENTS.md from cwd to root) for them.
48
+ * The control plane's `commands()` is that reader, called when a composer opens its completion list.
49
+ */
50
+ export declare function loadAgentSkills(agentDir: string, options?: {
51
+ cwd?: string;
52
+ env?: ExecutionEnv;
53
+ }): Promise<{
54
+ skills: Skill[];
55
+ diagnostics: SkillDiagnostic[];
56
+ collisions: SkillCollision[];
57
+ dir: string;
58
+ }>;
44
59
  /**
45
60
  * Whether `targetPath` lives inside `baseDir` (same path counts). Used to ask "did an override move
46
61
  * this OUT of the agent?" — the startup report's redeploy notes, `add`'s printed `.env` label, and the
@@ -42,6 +42,11 @@ export async function loadAgentDefinition(agentDir, options = {}) {
42
42
  throw new Error(`cannot read ${personaPath}: ${personaRead.error.message}`);
43
43
  }
44
44
  const persona = personaRead.ok ? personaRead.value : undefined;
45
+ const { skills, diagnostics, collisions } = await readSkills(e, root);
46
+ return { contextFiles, persona, skills, diagnostics, collisions, dir: root };
47
+ }
48
+ /** The skills half, shared by the full load and {@link loadAgentSkills}. `root` is already resolved. */
49
+ async function readSkills(e, root) {
45
50
  // Skills come ONLY from the definition's own skills/ (no external/global mount), so the same
46
51
  // definition loads the same skills on every machine — and, like tools/channels/schedules, a symlink
47
52
  // that escapes the agent dir is refused rather than followed (the fourth of four surfaces).
@@ -58,7 +63,23 @@ export async function loadAgentDefinition(agentDir, options = {}) {
58
63
  byName.set(skill.name, skill);
59
64
  }
60
65
  }
61
- return { contextFiles, persona, skills: [...byName.values()], diagnostics, collisions, dir: root };
66
+ return { skills: [...byName.values()], diagnostics, collisions };
67
+ }
68
+ /**
69
+ * The definition's skills ALONE, resolved the same way `loadAgentDefinition` resolves them (same
70
+ * loader, same containment guard, same first-wins collision rule) — for readers that need only the
71
+ * names and must not pay the full load's ② context walk (every AGENTS.md from cwd to root) for them.
72
+ * The control plane's `commands()` is that reader, called when a composer opens its completion list.
73
+ */
74
+ export async function loadAgentSkills(agentDir, options = {}) {
75
+ const cwd = options.cwd ?? agentDir;
76
+ const e = options.env ?? new NodeExecutionEnv({ cwd });
77
+ const rootResult = await e.absolutePath(agentDir);
78
+ if (!rootResult.ok)
79
+ throw new Error(`cannot resolve agent dir "${agentDir}": ${rootResult.error.message}`);
80
+ // `dir` is the RESOLVED root, like {@link LoadedDefinition.dir}: readers key per-definition state
81
+ // (the findings memo) on it, and "./agent" vs an absolute path must not become two definitions.
82
+ return { ...(await readSkills(e, rootResult.value)), dir: rootResult.value };
62
83
  }
63
84
  /**
64
85
  * Whether `targetPath` lives inside `baseDir` (same path counts). Used to ask "did an override move
@@ -9,8 +9,9 @@
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 } from "./sessions.ts";
13
13
  import { type MountedTool } from "./tool.ts";
14
+ import { type OverrideEntryLike } from "./session-settings.ts";
14
15
  /**
15
16
  * The session custom-entry type recording ONE activation delta: `{ names }` — exactly the deferred
16
17
  * tools a loader activated in that call. The DEDICATED record the resolve below reads: pi's own
@@ -84,36 +85,19 @@ export declare const SUMMARIZATION_RETRY_POLICY: {
84
85
  readonly maxRetries: 3;
85
86
  readonly baseDelayMs: 2000;
86
87
  };
87
- export declare const THINKING_LEVELS: ReadonlySet<ThinkingLevel>;
88
- /** The shape both override consumers walk — a session entry, structurally. */
89
- export interface OverrideEntryLike {
90
- type: string;
91
- provider?: string;
92
- modelId?: string;
93
- thinkingLevel?: string;
94
- }
95
88
  /**
96
- * The session's durable override FACTS the ONE walk both surfaces consume (`state()` reports the
97
- * recorded truth; `resolveHarnessOverrides` below applies registry/scale fallbacks on top). The
98
- * LAST entry of each kind wins, and a malformed record reads as ABSENT for that kind — never
99
- * skipped over to an earlier record: the reporting surface and the execution surface must agree on
100
- * which record is "the" override.
89
+ * The serving default for reasoning effort, pinned to what pi's TUI defaults to (its
90
+ * DEFAULT_THINKING_LEVEL) NOT inherited from the bare harness, whose own fallback is "off": an
91
+ * author vibes at "medium" in pi and must get "medium" when served (fidelity), and pinning the value
92
+ * here means an upstream default change in either place cannot silently alter deployments. Models
93
+ * that don't support a level are clamped by pi per model.
101
94
  */
102
- export declare function lastOverrideEntries(entries: OverrideEntryLike[]): {
103
- model?: {
104
- provider: string;
105
- modelId: string;
106
- };
107
- thinkingLevel?: string;
108
- };
95
+ export declare const DEFAULT_THINKING_LEVEL: ThinkingLevel;
109
96
  /**
110
- * Resolve the session's model/thinking OVERRIDES for a fresh harness same shape as the
111
- * active-tools resolve above: pi writes `model_change`/`thinking_level_change` entries on explicit
112
- * setModel/setThinkingLevel (the control plane's `set_model`/`set_thinking` append them directly)
113
- * but a fresh harness never reads them back. Override facts come from {@link lastOverrideEntries};
114
- * this adds the EXECUTION fallbacks: a recorded model no longer in this deployment's registry falls
115
- * back to the default with a deduped warn (fail visibly without bricking the session — the
116
- * conversation must survive a registry change across deploys); an unknown thinking level likewise.
97
+ * {@link resolveSessionSettings} plus the warn only the execution path owes: a recorded pair can stop
98
+ * being executable with no control-plane command involved (pi appends these entries itself; a
99
+ * deployment's configured model can change between restarts). Deduped per session+cause — it would
100
+ * otherwise repeat every turn.
117
101
  */
118
102
  export declare function resolveHarnessOverrides(entries: OverrideEntryLike[], models: Models, defaults: {
119
103
  model: AnyModel;
@@ -8,7 +8,9 @@
8
8
  */
9
9
  import { AgentHarness } from "@earendil-works/pi-agent-core";
10
10
  import { log } from "../../log.js";
11
+ import { activePathEntries } from "./sessions.js";
11
12
  import { isDeferredTool } from "./tool.js";
13
+ import { resolveSessionSettings } from "./session-settings.js";
12
14
  /**
13
15
  * The session custom-entry type recording ONE activation delta: `{ names }` — exactly the deferred
14
16
  * tools a loader activated in that call. The DEDICATED record the resolve below reads: pi's own
@@ -48,7 +50,7 @@ export const SUMMARIZATION_RETRY_POLICY = { enabled: true, maxRetries: 3, baseDe
48
50
  * here means an upstream default change in either place cannot silently alter deployments. Models
49
51
  * that don't support a level are clamped by pi per model.
50
52
  */
51
- const DEFAULT_THINKING_LEVEL = "medium";
53
+ export const DEFAULT_THINKING_LEVEL = "medium";
52
54
  /**
53
55
  * Resolve the active-tool set for a fresh harness — the ONE place both fallbacks live. pi's harness
54
56
  * WRITES active-tool changes to the session (`setActiveTools` → `active_tools_change`) but its
@@ -72,83 +74,30 @@ const DEFAULT_THINKING_LEVEL = "medium";
72
74
  * (like L2's findings memo), not session state — the resolve stays derived from the session.
73
75
  */
74
76
  const warnedRestores = new Set();
75
- /** pi's ThinkingLevel scale as a checkable set — THE single source for fastagent (session entries
76
- * store plain strings; session-control's dispatch validation and capabilities derive from this).
77
- * The `satisfies Record<ThinkingLevel, …>` anchor makes it EXHAUSTIVE against pi's union: pi
78
- * adding a level turns this into a type error instead of a silent drift where `set_thinking`
79
- * rejects a value pi supports. */
80
- const ALL_THINKING_LEVELS = {
81
- off: true,
82
- minimal: true,
83
- low: true,
84
- medium: true,
85
- high: true,
86
- xhigh: true,
87
- max: true,
88
- };
89
- export const THINKING_LEVELS = new Set(Object.keys(ALL_THINKING_LEVELS));
90
77
  /**
91
- * The session's durable override FACTS the ONE walk both surfaces consume (`state()` reports the
92
- * recorded truth; `resolveHarnessOverrides` below applies registry/scale fallbacks on top). The
93
- * LAST entry of each kind wins, and a malformed record reads as ABSENT for that kind — never
94
- * skipped over to an earlier record: the reporting surface and the execution surface must agree on
95
- * which record is "the" override.
96
- */
97
- export function lastOverrideEntries(entries) {
98
- let model;
99
- let modelSeen = false;
100
- let thinkingLevel;
101
- let thinkingSeen = false;
102
- for (let i = entries.length - 1; i >= 0 && !(modelSeen && thinkingSeen); i--) {
103
- const e = entries[i];
104
- if (!modelSeen && e?.type === "model_change") {
105
- modelSeen = true;
106
- if (e.provider !== undefined && e.modelId !== undefined)
107
- model = { provider: e.provider, modelId: e.modelId };
108
- }
109
- if (!thinkingSeen && e?.type === "thinking_level_change") {
110
- thinkingSeen = true;
111
- if (e.thinkingLevel !== undefined)
112
- thinkingLevel = e.thinkingLevel;
113
- }
114
- }
115
- return { model, thinkingLevel };
116
- }
117
- /**
118
- * Resolve the session's model/thinking OVERRIDES for a fresh harness — same shape as the
119
- * active-tools resolve above: pi writes `model_change`/`thinking_level_change` entries on explicit
120
- * setModel/setThinkingLevel (the control plane's `set_model`/`set_thinking` append them directly)
121
- * but a fresh harness never reads them back. Override facts come from {@link lastOverrideEntries};
122
- * this adds the EXECUTION fallbacks: a recorded model no longer in this deployment's registry falls
123
- * back to the default with a deduped warn (fail visibly without bricking the session — the
124
- * conversation must survive a registry change across deploys); an unknown thinking level likewise.
78
+ * {@link resolveSessionSettings} plus the warn only the execution path owes: a recorded pair can stop
79
+ * being executable with no control-plane command involved (pi appends these entries itself; a
80
+ * deployment's configured model can change between restarts). Deduped per session+cause it would
81
+ * otherwise repeat every turn.
125
82
  */
126
83
  export function resolveHarnessOverrides(entries, models, defaults, sessionId) {
127
- let model = defaults.model;
128
- let thinkingLevel = defaults.thinkingLevel;
84
+ const settings = resolveSessionSettings(entries, models, defaults);
129
85
  const warnOnce = (key, message) => {
130
86
  const emit = warnedRestores.has(key) ? log.debug : log.warn;
131
87
  warnedRestores.add(key);
132
88
  emit(message);
133
89
  };
134
- const recorded = lastOverrideEntries(entries);
135
- if (recorded.model) {
136
- const found = models.getModel(recorded.model.provider, recorded.model.modelId);
137
- if (found)
138
- model = found;
139
- else {
140
- warnOnce(`${sessionId}\u0000model\u0000${recorded.model.provider}/${recorded.model.modelId}`, `[fastagent] session ${sessionId}: recorded model override ${recorded.model.provider}/${recorded.model.modelId} is not in this deployment's registry — using the configured default`);
141
- }
90
+ const dropped = settings.dropped;
91
+ if (dropped?.model) {
92
+ warnOnce(`${sessionId}\u0000model\u0000${dropped.model}`, `[fastagent] session ${sessionId}: recorded model override ${dropped.model} is not in this deployment's registry — using the configured default`);
142
93
  }
143
- if (recorded.thinkingLevel !== undefined) {
144
- if (THINKING_LEVELS.has(recorded.thinkingLevel)) {
145
- thinkingLevel = recorded.thinkingLevel;
146
- }
147
- else {
148
- warnOnce(`${sessionId}\u0000thinking\u0000${recorded.thinkingLevel}`, `[fastagent] session ${sessionId}: recorded thinking level "${recorded.thinkingLevel}" is unknown — using the configured default`);
149
- }
94
+ if (dropped?.thinkingLevel) {
95
+ const { recorded, running, known } = dropped.thinkingLevel;
96
+ warnOnce(`${sessionId}\u0000thinking\u0000${settings.model.provider}/${settings.model.id}\u0000${recorded}`, known
97
+ ? `[fastagent] session ${sessionId}: recorded thinking level "${recorded}" is not supported by ${settings.model.provider}/${settings.model.id} — running at "${running}"`
98
+ : `[fastagent] session ${sessionId}: recorded thinking level "${recorded}" is unknown — using the configured default`);
150
99
  }
151
- return { model, thinkingLevel };
100
+ return { model: settings.model, thinkingLevel: settings.thinkingLevel };
152
101
  }
153
102
  export function resolveHarnessActiveToolNames(recorded, tools, sessionId) {
154
103
  const anyDeferred = tools.some(isDeferredTool);
@@ -170,9 +119,10 @@ export function piHarnessFactory(options) {
170
119
  return async (sessionId) => {
171
120
  const session = await options.sessions.openOrCreate(sessionId);
172
121
  // One extra entry walk per invoke to collect the activation deltas — negligible against the model
173
- // call, same trade as L2's per-invoke definition re-read. Serving sessions never branch, so a flat
174
- // getEntries() read (no leaf-path walk) is correct.
175
- const entries = await session.getEntries();
122
+ // call, same trade as L2's per-invoke definition re-read. The walk is over the ACTIVE PATH, not
123
+ // the flat journal: `navigate` moves the leaf, so the tree can hold an abandoned branch whose
124
+ // activations and overrides this session has left behind.
125
+ const entries = await activePathEntries(session);
176
126
  const activated = entries.flatMap((e) => e.type === "custom" && e.customType === TOOL_ACTIVATION_ENTRY
177
127
  ? (e.data?.names ?? [])
178
128
  : []);
@@ -4,7 +4,7 @@ import type { SessionControl } from "../../session.ts";
4
4
  import type { SessionObserver } from "./invoke.ts";
5
5
  import type { PiSessionReader, PiSessionStore } from "./sessions.ts";
6
6
  import type { ModuleLoadFailure } from "../../loader.ts";
7
- import type { LoadedDefinition } from "./definition.ts";
7
+ import { type LoadedDefinition } from "./definition.ts";
8
8
  import type { ToolCollision } from "./tool.ts";
9
9
  import type { MountedTool } from "./tool.ts";
10
10
  export interface CreatePiAgentFromDirOptions {
@@ -14,6 +14,8 @@ import { resolveStateRoot, resolvePlacement } from "../../paths.js";
14
14
  import { createPiAgentFromDefinition, resolveAgentTools } from "./create.js";
15
15
  import { createPiSessionControl } from "./session-control.js";
16
16
  import { withWakeTool } from "./wake-tool.js";
17
+ import { loadAgentSkills } from "./definition.js";
18
+ import { reportFindingsIfChanged } from "./report.js";
17
19
  import { jsonlSessionStore } from "./sessions.js";
18
20
  export async function resolveAgentAssembly(dir, options = {}) {
19
21
  // Placement is structural (resolvePlacement): the AGENT DIR carries definition + config + machinery;
@@ -73,6 +75,23 @@ export async function createPiAgentFromDir(dir, options = {}) {
73
75
  ? createPiSessionControl({
74
76
  sessions,
75
77
  boundary: () => boundaryParts,
78
+ // Skills ARE the names a client offers — the resolved set, after collisions were decided
79
+ // first-wins, which a client cannot reconstruct from the directory. Read LIVE (the directory
80
+ // is the agent: a skill added while serving is in play on the next turn, so it must be
81
+ // listable now) and SKILLS-ONLY: this is called when a composer opens its completion list,
82
+ // and the full load's ② context walk buys nothing here.
83
+ commands: async () => {
84
+ const loaded = await loadAgentSkills(agentDir, { cwd: workspace });
85
+ // A skill whose frontmatter broke simply is not in `skills` — it would disappear from the
86
+ // author's composer with no signal anywhere. The memo is SHARED with the turn path (keyed
87
+ // by dir), so a finding is warned when it appears, not once per reader that notices it.
88
+ reportFindingsIfChanged(loaded.dir, loaded);
89
+ return loaded.skills.map((skill) => ({
90
+ name: skill.name,
91
+ description: skill.description,
92
+ source: "skill",
93
+ }));
94
+ },
76
95
  // The caller tap's boundary-event half: state_changed/compaction_* originate in the hub
77
96
  // and never cross the data plane's observer seam — without this, an audit tap wired here
78
97
  // would miss exactly the mutations it most needs to see (set_model).
@@ -102,6 +121,7 @@ export async function createPiAgentFromDir(dir, options = {}) {
102
121
  lease: parts.lease,
103
122
  models: parts.models,
104
123
  harnessFactory: parts.harnessFactory,
124
+ defaults: parts.defaults,
105
125
  };
106
126
  }
107
127
  : undefined,
@@ -6,8 +6,24 @@ import type { SkillDiagnostic } from "@earendil-works/pi-agent-core";
6
6
  import type { SkillCollision } from "./definition.ts";
7
7
  import type { ModuleLoadFailure } from "../../loader.ts";
8
8
  import type { ToolCollision } from "./tool.ts";
9
+ type Findings = {
10
+ collisions: SkillCollision[];
11
+ diagnostics: SkillDiagnostic[];
12
+ };
13
+ /**
14
+ * THE door for definition findings: warns only when this dir's set CHANGED since the last report.
15
+ * Every reader calls it — boot, the per-turn live read, the control plane's command list — so a
16
+ * finding is announced when it appears and never repeated. There is deliberately no "record without
17
+ * printing" variant: a memo entry that trusts some other caller to have printed would silently
18
+ * swallow findings for a caller that does not.
19
+ *
20
+ * `dir` must be the RESOLVED definition root (`LoadedDefinition.dir`), or two spellings of one path
21
+ * become two memos and warn twice.
22
+ */
23
+ export declare function reportFindingsIfChanged(dir: string, def: Findings): void;
9
24
  export declare function reportDefinitionWarnings(collisions: SkillCollision[], diagnostics: SkillDiagnostic[]): void;
10
25
  export declare function reportToolCollisions(collisions: ToolCollision[]): void;
11
26
  /** Report per-file module failures. The caller decides whether they are degradations (tools/schedules)
12
27
  * or fatal (declared channels on the serving path). */
13
28
  export declare function reportModuleLoadFailures(failures: ModuleLoadFailure[]): void;
29
+ export {};