@fastagent-sh/fastagent 0.16.2 → 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.
@@ -19,7 +19,8 @@
19
19
  * - `{ kind: "invoke", session, text }` — the programmatic data plane; streams the invoke back as
20
20
  * SSE (AgentCore's streaming response form), reusing the HTTP channel's handler wholesale.
21
21
  *
22
- * `/ping` reports `HealthyBusy` while process-wide background work is in flight (busy.ts) — webhook
22
+ * `/ping` reports `HealthyBusy` (+ `time_of_last_update`, required see the handler) while
23
+ * process-wide background work is in flight (busy.ts) — webhook
23
24
  * channels ACK fast and run turns fire-and-forget, and AgentCore ends an idle session, so without
24
25
  * this signal a long turn would be killed mid-flight right after its ACK. `Healthy` when idle lets
25
26
  * the platform reclaim the microVM (that idle-to-zero IS the point of this deployment).
@@ -242,12 +243,30 @@ export function agentcoreRoutes(options) {
242
243
  stateSync.save();
243
244
  return response;
244
245
  };
246
+ // The Runtime ping contract: Healthy = reclaimable, HealthyBusy = keep the session alive
247
+ // (background turns in flight). `time_of_last_update` is REQUIRED for the keep-alive to work,
248
+ // despite the contract documenting it as optional ("If you omit the field, the platform tracks
249
+ // status changes on its own"): measured on a live Runtime (us-east-1, 2026-08-04), the platform's
250
+ // idle measurement reads ONLY this field — with it omitted, a session polling every ~2s and
251
+ // receiving HealthyBusy 200s was still reclaimed at exactly IdleRuntimeSessionTimeout after the
252
+ // last InvokeAgentRuntime, mid-turn, 2s after the last HealthyBusy answer; with the field present
253
+ // the same turn survived 3.5× the idle timeout with zero invocations and completed. The value
254
+ // updates ONLY on a real status change: a timestamp advancing on every ping declares a perpetual
255
+ // status change, so the idle timeout never fires and dead-idle sessions live to MaxLifetime
256
+ // (quota exhaustion — the failure mode the contract's warning describes).
257
+ let lastStatus = "Healthy";
258
+ let lastTransition = Math.floor(Date.now() / 1000);
245
259
  return {
246
260
  "POST /invocations": invocations,
247
- // The Runtime ping contract: Healthy = reclaimable, HealthyBusy = keep the session alive
248
- // (background turns in flight). No time_of_last_update the platform tracks status changes
249
- // itself, and a timestamp advancing every ping would defeat the idle timeout (their docs warn).
250
- "GET /ping": () => json({ status: isBusy() ? "HealthyBusy" : "Healthy" }, 200),
261
+ "GET /ping": () => {
262
+ const status = isBusy() ? "HealthyBusy" : "Healthy";
263
+ if (status !== lastStatus) {
264
+ lastTransition = Math.floor(Date.now() / 1000);
265
+ log.debug(`[agentcore] ping status: ${lastStatus} → ${status}`);
266
+ lastStatus = status;
267
+ }
268
+ return json({ status, time_of_last_update: lastTransition }, 200);
269
+ },
251
270
  };
252
271
  }
253
272
  /** Thrown by the mount-site `fire` binding when the envelope names a schedule this workspace does
@@ -22,7 +22,7 @@ export interface ControlRoutesOptions {
22
22
  agent?: Agent;
23
23
  }
24
24
  /**
25
- * Mount the control plane: `GET /control/capabilities|state|entries|events` + `POST
25
+ * Mount the control plane: `GET /control/capabilities|commands|state|entries|events` + `POST
26
26
  * /control/dispatch`, all bearer-authenticated. `events` streams SSE (`data: <WireEvent>` lines).
27
27
  */
28
28
  export declare function controlRoutes(control: SessionControl, options: ControlRoutesOptions): Routes;
@@ -84,7 +84,7 @@ function parseWireCommand(raw) {
84
84
  }
85
85
  }
86
86
  /**
87
- * Mount the control plane: `GET /control/capabilities|state|entries|events` + `POST
87
+ * Mount the control plane: `GET /control/capabilities|commands|state|entries|events` + `POST
88
88
  * /control/dispatch`, all bearer-authenticated. `events` streams SSE (`data: <WireEvent>` lines).
89
89
  */
90
90
  export function controlRoutes(control, options) {
@@ -111,6 +111,7 @@ export function controlRoutes(control, options) {
111
111
  return {
112
112
  ...(invokeHandler ? { "POST /control/invoke": guard((req) => invokeHandler(req)) } : {}),
113
113
  "GET /control/capabilities": guard(() => json(control.capabilities())),
114
+ "GET /control/commands": guard(async () => json(await control.commands())),
114
115
  "GET /control/state": guard(async (_req, url) => {
115
116
  const session = sessionParam(url);
116
117
  if (!session)
@@ -72,6 +72,24 @@ export interface AttachIo {
72
72
  * whole — the one case where "unknown" must not read as "off-path".
73
73
  */
74
74
  export declare function activePathSlice(entries: SessionEntry[], leafEntryId: string | undefined): SessionEntry[];
75
+ /**
76
+ * Answer a RESERVED-SLASH line (anything starting with `/` that is not `/abort`). Two intents share
77
+ * the prefix — a mistyped control command and an attempt to invoke a name — and they are answered
78
+ * differently:
79
+ *
80
+ * - a mistyped slash gets the certain half NOW (a leading `/` is reserved here, whatever the token
81
+ * turns out to be), because waiting on a remote read that can be slow or fail would leave the
82
+ * input unanswered; it deliberately does not pre-judge the token as unknown — the read may be
83
+ * about to prove it names a real skill;
84
+ * - `/commands` prints nothing first: its whole answer IS the read, and a placeholder is noise;
85
+ * - the enumeration answers `/commands` ONLY — dumping every skill at a mistyped `/aboort` answers
86
+ * an intent the typo did not express.
87
+ *
88
+ * Names print BARE: this composer cannot expand `/name` (the data plane takes prompts as text), so
89
+ * printing them with a slash would invite the user straight back into this branch. Returns the
90
+ * promise for the remote half, so a caller (or a test) can await the second line.
91
+ */
92
+ export declare function answerSlashInput(trimmed: string, control: Pick<SessionControl, "commands">, println: (line: string) => void): Promise<void>;
75
93
  /**
76
94
  * ONE attach round: subscribe → backfill (render the durable record since `cursor`) → drain live
77
95
  * until the stream drops. Returns the advanced cursor. Subscribing first + the server's eager
@@ -185,7 +185,7 @@ export async function runAttach(sessionArg, dirArg, opts) {
185
185
  // invoke) — give the human a corrective signal.
186
186
  log.warn(`[fastagent] no durable record for "${sessionArg}" yet — a new session, or a typo?`);
187
187
  }
188
- log.info(`[fastagent] type to steer the active run; /abort to stop it; Ctrl+C to detach`);
188
+ log.info(`[fastagent] type to steer the active run; /abort to stop it; /commands to list what this agent defines; Ctrl+C to detach`);
189
189
  // stdin → the two planes: a line steers the ACTIVE run; with no run to join (no_active_run) it
190
190
  // falls back to STARTING one over the remote data plane (`POST /control/invoke`) — try-steer-
191
191
  // then-prompt avoids a state() pre-check race. Acceptance is not outcome: rejections print and
@@ -222,7 +222,7 @@ export async function runAttach(sessionArg, dirArg, opts) {
222
222
  // `/` is a reserved command prefix: a typo'd /aboort silently steering the model (injecting a
223
223
  // prompt when the user meant to STOP the run) is the dangerous direction of the ambiguity.
224
224
  if (trimmed.startsWith("/") && trimmed !== "/abort") {
225
- console.log(`[unknown command ${trimmed} /abort stops the run; a leading / is reserved]`);
225
+ void answerSlashInput(trimmed, control, (l) => console.log(l));
226
226
  return;
227
227
  }
228
228
  const command = trimmed === "/abort" ? { type: "abort" } : { type: "steer", prompt: { text: trimmed } };
@@ -449,6 +449,50 @@ export function activePathSlice(entries, leafEntryId) {
449
449
  }
450
450
  return entries.filter((e) => onPath.has(e.id));
451
451
  }
452
+ /**
453
+ * Answer a RESERVED-SLASH line (anything starting with `/` that is not `/abort`). Two intents share
454
+ * the prefix — a mistyped control command and an attempt to invoke a name — and they are answered
455
+ * differently:
456
+ *
457
+ * - a mistyped slash gets the certain half NOW (a leading `/` is reserved here, whatever the token
458
+ * turns out to be), because waiting on a remote read that can be slow or fail would leave the
459
+ * input unanswered; it deliberately does not pre-judge the token as unknown — the read may be
460
+ * about to prove it names a real skill;
461
+ * - `/commands` prints nothing first: its whole answer IS the read, and a placeholder is noise;
462
+ * - the enumeration answers `/commands` ONLY — dumping every skill at a mistyped `/aboort` answers
463
+ * an intent the typo did not express.
464
+ *
465
+ * Names print BARE: this composer cannot expand `/name` (the data plane takes prompts as text), so
466
+ * printing them with a slash would invite the user straight back into this branch. Returns the
467
+ * promise for the remote half, so a caller (or a test) can await the second line.
468
+ */
469
+ export async function answerSlashInput(trimmed, control, println) {
470
+ // The first WORD is the token: slash input naturally carries arguments (`/triage my inbox`), and
471
+ // taking the whole line would answer "names nothing" for a name the user did give.
472
+ const word = trimmed.slice(1).split(/\s+/)[0] ?? "";
473
+ const listing = word === "commands";
474
+ if (!listing)
475
+ println("[a leading / is reserved — /abort stops the run, /commands lists what this agent defines]");
476
+ let commands;
477
+ try {
478
+ commands = await control.commands();
479
+ }
480
+ catch (error) {
481
+ println(`[command list unavailable: ${error}]`);
482
+ return;
483
+ }
484
+ if (listing) {
485
+ // `description` is what makes a listing usable — a bare name tells the author nothing they did
486
+ // not already know from the directory.
487
+ const listed = commands.map((c) => (c.description ? `${c.name} — ${c.description}` : c.name));
488
+ println(listed.length ? `[this agent defines: ${listed.join("; ")}]` : "[this agent defines no names]");
489
+ return;
490
+ }
491
+ const hit = commands.find((c) => c.name === word);
492
+ println(hit
493
+ ? `[${hit.name} is a ${hit.source}${hit.description ? ` — ${hit.description}` : ""}; name it in a normal message, without the /]`
494
+ : `[/${word} names nothing this agent defines]`);
495
+ }
452
496
  /**
453
497
  * ONE attach round: subscribe → backfill (render the durable record since `cursor`) → drain live
454
498
  * until the stream drops. Returns the advanced cursor. Subscribing first + the server's eager
@@ -6,7 +6,7 @@
6
6
  import { resolve } from "node:path";
7
7
  import { runDevSupervisor } from "../../dev-supervisor.js";
8
8
  import { loadDotEnv } from "../../env.js";
9
- import { reportDefinitionWarnings, reportModuleLoadFailures, reportToolCollisions } from "../../engines/pi/report.js";
9
+ import { reportFindingsIfChanged, reportModuleLoadFailures, reportToolCollisions } from "../../engines/pi/report.js";
10
10
  import { createPiAgentFromDir } from "../../engines/pi/open.js";
11
11
  import { setLogLevel } from "../../log.js";
12
12
  import { logAgentLoop } from "../../observe.js";
@@ -91,5 +91,5 @@ function reportAgentsSkillsTools(a) {
91
91
  }
92
92
  reportToolCollisions(a.toolCollisions);
93
93
  reportModuleLoadFailures(a.toolFailures);
94
- reportDefinitionWarnings(a.definition.collisions, a.definition.diagnostics);
94
+ reportFindingsIfChanged(a.definition.dir, a.definition);
95
95
  }
@@ -6,7 +6,7 @@ import { defaultSessionsDir, loadConfig, resolveAuthPath, resolveModelSpec, reso
6
6
  import { resolveStateRoot, workspaceHint } from "../../paths.js";
7
7
  import { resolveAgentTools } from "../../engines/pi/create.js";
8
8
  import { loadAgentDefinition } from "../../engines/pi/definition.js";
9
- import { reportDefinitionWarnings, reportModuleLoadFailures, reportToolCollisions } from "../../engines/pi/report.js";
9
+ import { reportFindingsIfChanged, reportModuleLoadFailures, reportToolCollisions } from "../../engines/pi/report.js";
10
10
  import { log } from "../../log.js";
11
11
  import { nextRun } from "../../schedule/cron.js";
12
12
  import { loadSchedules } from "../../schedule/discover.js";
@@ -111,5 +111,5 @@ export async function runInfo(dirArg, opts) {
111
111
  reportModuleLoadFailures(sched.failures);
112
112
  if (tools.error)
113
113
  log.warn(`[fastagent] ${tools.error}`);
114
- reportDefinitionWarnings(definition.collisions, definition.diagnostics);
114
+ reportFindingsIfChanged(definition.dir, definition);
115
115
  }
@@ -9,7 +9,7 @@ import { loadDotEnv } from "../../env.js";
9
9
  import { resolveAuthPath, resolveSessionsDirOverride } from "../../engines/pi/config.js";
10
10
  import { resolveSecretsDir, workspaceHint } from "../../paths.js";
11
11
  import { isUnderDir } from "../../engines/pi/definition.js";
12
- import { reportDefinitionWarnings, reportModuleLoadFailures, reportToolCollisions } from "../../engines/pi/report.js";
12
+ import { reportFindingsIfChanged, reportModuleLoadFailures, reportToolCollisions } from "../../engines/pi/report.js";
13
13
  import { createPiAgentFromDir } from "../../engines/pi/open.js";
14
14
  import { log, setLogLevel } from "../../log.js";
15
15
  import { createWakeAlarmSink, reconcileWakeAlarms } from "../../schedule/wake-alarm.js";
@@ -80,7 +80,7 @@ export async function runStart(dirArg, opts) {
80
80
  log.info(`[fastagent] note: secrets (.env, rotated auth.json) live under the definition dir; point ` +
81
81
  `FASTAGENT_SECRETS_DIR at a persistent volume so a redeploy that replaces the dir does not wipe them.`);
82
82
  }
83
- reportDefinitionWarnings(definition.collisions, definition.diagnostics);
83
+ reportFindingsIfChanged(definition.dir, definition);
84
84
  // AgentCore Runtime posture (FASTAGENT_AGENTCORE=1, set by the generated deploy artifacts): the
85
85
  // adapter (POST /invocations + GET /ping) is the container's only reachable surface, and cron
86
86
  // slots arrive from the external clock through it — so no resident cron timers. In particular,
@@ -457,8 +457,8 @@ const logs = {
457
457
  name: "logs",
458
458
  summary: "find and tail a deployed host's application logs",
459
459
  description: "Find the CloudWatch log group for the AgentCore stack derived from dir, then run aws logs tail. " +
460
- "The default Runtime source selects only [runtime-logs], so application stdout/stderr is not mixed " +
461
- "with OTEL/spans in the same AWS log group; the forwarder source shows Lambda ingress transport logs.",
460
+ "The default Runtime source shows the agent process's own stdout/stderr; the forwarder source shows " +
461
+ "the Lambda ingress transport logs.",
462
462
  args: [{ name: "<host>", description: "deployed host", choices: ["agentcore"] }, DIR_ARG],
463
463
  flags: [
464
464
  { flags: "--source <source>", description: "agentcore log source: runtime (default) or forwarder" },
@@ -1,9 +1,14 @@
1
1
  /**
2
- * AgentCore log discovery + tailing. AWS puts the container's stdout/stderr and its OTEL telemetry
3
- * in the same per-endpoint CloudWatch log group, while the forwarder Lambda has a separate group.
4
- * This operator surface resolves the stack's RuntimeArn, discovers the endpoint group by prefix, and
5
- * tails ONLY `[runtime-logs]` for the runtime source — the same application lines shown locally,
6
- * without mixing in spans/otel-rt-logs.
2
+ * AgentCore log discovery + tailing. The container's stdout/stderr lives in a per-endpoint CloudWatch
3
+ * log group, while the forwarder Lambda has a separate group. This operator surface resolves the
4
+ * stack's RuntimeArn, discovers the endpoint group by prefix, and tails it.
5
+ *
6
+ * NO STREAM FILTER, deliberately: AgentCore names its streams `YYYY/MM/DD/[runtime-logs]<session-id>`
7
+ * (the Lambda `2024/01/01/[$LATEST]abc` convention), so `[runtime-logs]` is an INFIX after the UTC date
8
+ * path, not a prefix. `--log-stream-name-prefix` is a literal prefix match, and the AWS CLI has no
9
+ * substring filter (`--log-stream-names` takes exact names, which `--follow` could never extend to the
10
+ * new session streams). Passing the marker as a prefix therefore matches zero streams and `aws logs
11
+ * tail` prints nothing and exits 0 — a silent empty tail. Do not add it back.
7
12
  */
8
13
  import type { CliRunner } from "../runner.ts";
9
14
  export type AgentcoreLogSource = "runtime" | "forwarder";
@@ -93,9 +93,8 @@ export async function tailAgentcoreLogs(plan, aws, announce = () => { }) {
93
93
  if (matches.length > 1) {
94
94
  return {
95
95
  ok: false,
96
- gate: `several Runtime log groups match this stack: ${matches.join(", ")} — tail the intended one with the ` +
97
- `same stream filter this command applies: aws logs tail <group> --log-stream-name-prefix '[runtime-logs]' ` +
98
- `(quote the prefix — [...] is a shell glob)`,
96
+ gate: `several Runtime log groups match this stack: ${matches.join(", ")} — tail the intended one directly: ` +
97
+ `aws logs tail <group> --format short --follow`,
99
98
  };
100
99
  }
101
100
  const logGroup = matches[0];
@@ -103,8 +102,6 @@ export async function tailAgentcoreLogs(plan, aws, announce = () => { }) {
103
102
  const tailArgs = ["logs", "tail", logGroup, "--format", "short"];
104
103
  if (plan.since)
105
104
  tailArgs.push("--since", plan.since);
106
- if (plan.source === "runtime")
107
- tailArgs.push("--log-stream-name-prefix", "[runtime-logs]");
108
105
  if (plan.follow)
109
106
  tailArgs.push("--follow");
110
107
  const tailed = await aws(tailArgs);
@@ -63,8 +63,9 @@ export declare const SECRETS_DIR = "/mnt/state/.secrets";
63
63
  * — idle included, at the peak level reached — so this tail is the standing cost of every burst of
64
64
  * activity, while CPU stops billing the moment the agent stops working. 3 minutes rather than the
65
65
  * platform's 15: the tail shrinks 5×, and the cost is a cold start (image + Node + snapshot restore)
66
- * for anyone who returns after a longer gap. `/ping` reports HealthyBusy while work is in flight, so
67
- * this timer only ever starts once the agent has genuinely settled a long turn is never cut short.
66
+ * for anyone who returns after a longer gap. `/ping` reports HealthyBusy + time_of_last_update while
67
+ * work is in flight (the FIELD is what the platform's idle measurement actually reads agentcore.ts),
68
+ * so this timer only ever starts once the agent has genuinely settled — a long turn is never cut short.
68
69
  * AWS accepts 60–28800.
69
70
  */
70
71
  export declare const IDLE_TIMEOUT_SECONDS = 180;
@@ -62,8 +62,9 @@ export const SECRETS_DIR = `${MOUNT}/${SECRETS_DIRNAME}`;
62
62
  * — idle included, at the peak level reached — so this tail is the standing cost of every burst of
63
63
  * activity, while CPU stops billing the moment the agent stops working. 3 minutes rather than the
64
64
  * platform's 15: the tail shrinks 5×, and the cost is a cold start (image + Node + snapshot restore)
65
- * for anyone who returns after a longer gap. `/ping` reports HealthyBusy while work is in flight, so
66
- * 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.
67
68
  * AWS accepts 60–28800.
68
69
  */
69
70
  export const IDLE_TIMEOUT_SECONDS = 180;
@@ -562,7 +563,7 @@ function template(input, translated) {
562
563
  ` # forces a NAT gateway for model/channel egress (~$33/mo) — deliberately not the default.`,
563
564
  ` FilesystemConfigurations:`,
564
565
  ` - SessionStorage: { MountPath: ${MOUNT} }`,
565
- ` # 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`,
566
567
  ` # lifetime ${MAX_LIFETIME_SECONDS}s — the platform ceiling; the session id stays valid, so the next invoke`,
567
568
  ` # just gets fresh compute with the same storage. Memory bills per second for the whole`,
568
569
  ` # session INCLUDING the idle tail, so a shorter tail is the main cost lever here.`,
@@ -712,7 +713,7 @@ export function planAgentcoreDeploy(input) {
712
713
  ? `# 4. Read the outputs (the runtime ARN + callback URL; it serves webhooks only when configured):`
713
714
  : `# 4. Read the outputs (the runtime ARN — this topology has NO public URL: nothing outside AWS`, ...(needsFunctionUrl
714
715
  ? []
715
- : [`# 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 filters to [runtime-logs], excluding OTEL/spans AWS stores in the same log group:`, `fastagent logs agentcore --follow`, ...(needsForwarder
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
716
717
  ? [
717
718
  `# The ingress transport is a separate Lambda and therefore a separate log source:`,
718
719
  `fastagent logs agentcore --source forwarder --follow`,
@@ -732,7 +733,7 @@ export function planAgentcoreDeploy(input) {
732
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>`);
733
734
  }
734
735
  if (channels.includes("github")) {
735
- 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).`);
736
737
  }
737
738
  if (channels.includes("slack")) {
738
739
  post.push(`# Set Slack Event Subscriptions → Request URL = <ForwarderUrl>/slack (scopes per channels/slack.ts).`);
@@ -19,7 +19,7 @@ import { resolveSecretsDir } from "../../paths.js";
19
19
  import { loadAgentDefinition } from "./definition.js";
20
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";
@@ -221,12 +221,6 @@ export function createPiAgent(options) {
221
221
  observer: options.observer,
222
222
  });
223
223
  }
224
- /** Stable identity of a definition's non-fatal findings, for change-detection in `live` (dedup only). */
225
- function findingsSignature(def) {
226
- const collisions = def.collisions.map((c) => `c:${c.name}:${c.winnerPath}:${c.loserPath}`);
227
- const diagnostics = def.diagnostics.map((d) => `d:${d.code}:${d.path}`);
228
- return [...collisions, ...diagnostics].sort().join("\n");
229
- }
230
224
  /**
231
225
  * L2: "point at a directory → agent": load + assemble (base + AGENTS.md + skills + env) + L1 in one
232
226
  * call. Returns the definition so callers can surface diagnostics/collisions.
@@ -239,10 +233,11 @@ export async function createPiAgentFromDefinition(dir, options) {
239
233
  // Boot-time load: fail-visibly at startup on a broken directory, and give callers the snapshot to
240
234
  // report (skills/diagnostics/collisions). Serving does NOT close over it — see `live` below.
241
235
  const definition = await loadAgentDefinition(dir, { cwd: env.cwd, env });
242
- // Findings the caller already reported at boot; `live` re-reports only when the set CHANGES — a
243
- // runtime-written bad skill surfaces the moment it appears, while a static finding does not spam
244
- // every turn's log. A log-dedup memo, not session state (stateless invoke holds).
245
- 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);
246
241
  // Deferred tools need their loader on every rung (idempotent — the workspace opener already applied
247
242
  // it; a caller's own search_tools wins).
248
243
  const tools = withSearchTool(options.tools ?? piDefaultTools());
@@ -265,11 +260,7 @@ export async function createPiAgentFromDefinition(dir, options) {
265
260
  // next good edit heals both.
266
261
  live: async () => {
267
262
  const def = await loadAgentDefinition(dir, { cwd: env.cwd, env });
268
- const sig = findingsSignature(def);
269
- if (sig !== reportedFindings) {
270
- reportedFindings = sig;
271
- reportDefinitionWarnings(def.collisions, def.diagnostics);
272
- }
263
+ reportFindingsIfChanged(def.dir, def);
273
264
  return {
274
265
  systemPrompt: assembleSystemPrompt({
275
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
@@ -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).
@@ -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 {};
@@ -1,4 +1,34 @@
1
1
  import { log } from "../../log.js";
2
+ /** Stable identity of a definition's non-fatal findings — the dedup key below. */
3
+ function findingsSignature(def) {
4
+ const collisions = def.collisions.map((c) => `c:${c.name}:${c.winnerPath}:${c.loserPath}`);
5
+ const diagnostics = def.diagnostics.map((d) => `d:${d.code}:${d.path}`);
6
+ return [...collisions, ...diagnostics].sort().join("\n");
7
+ }
8
+ /**
9
+ * The last reported finding set PER DEFINITION DIR. One memo for every reader of a definition (the
10
+ * per-turn live read, the control plane's command list), because the thing being deduped is the
11
+ * FINDING, not the reader: a newly broken skill deserves one warning, not one per code path that
12
+ * noticed it, and a static one deserves none at all.
13
+ */
14
+ const lastFindings = new Map();
15
+ /**
16
+ * THE door for definition findings: warns only when this dir's set CHANGED since the last report.
17
+ * Every reader calls it — boot, the per-turn live read, the control plane's command list — so a
18
+ * finding is announced when it appears and never repeated. There is deliberately no "record without
19
+ * printing" variant: a memo entry that trusts some other caller to have printed would silently
20
+ * swallow findings for a caller that does not.
21
+ *
22
+ * `dir` must be the RESOLVED definition root (`LoadedDefinition.dir`), or two spellings of one path
23
+ * become two memos and warn twice.
24
+ */
25
+ export function reportFindingsIfChanged(dir, def) {
26
+ const sig = findingsSignature(def);
27
+ if (lastFindings.get(dir) === sig)
28
+ return;
29
+ lastFindings.set(dir, sig);
30
+ reportDefinitionWarnings(def.collisions, def.diagnostics);
31
+ }
2
32
  export function reportDefinitionWarnings(collisions, diagnostics) {
3
33
  for (const c of collisions) {
4
34
  log.warn(`[fastagent] skill "${c.name}" collision — using ${c.winnerPath}, ignoring ${c.loserPath}`);
@@ -37,7 +37,7 @@ import { canonicalPath, loadAgentDefinition } from "./definition.js";
37
37
  import { createPiModelRuntime, probeAuthSource } from "./models.js";
38
38
  import { log } from "../../log.js";
39
39
  import { additiveActivation, turnContext } from "./tool-context.js";
40
- import { reportDefinitionWarnings, reportModuleLoadFailures, reportToolCollisions } from "./report.js";
40
+ import { reportFindingsIfChanged, reportModuleLoadFailures, reportToolCollisions } from "./report.js";
41
41
  import { resolveAgentAssembly } from "./open.js";
42
42
  /** Adapt coding-agent's resident SessionManager to FastAgent's shared tool-runtime manager port. */
43
43
  function toolChatSessionManager(session) {
@@ -119,7 +119,7 @@ sessionManager) {
119
119
  const model = resolveModel(modelRuntime, modelSpec);
120
120
  const env = new NodeExecutionEnv({ cwd });
121
121
  const definition = await loadAgentDefinition(agentDir, { cwd, env });
122
- reportDefinitionWarnings(definition.collisions, definition.diagnostics);
122
+ reportFindingsIfChanged(definition.dir, definition);
123
123
  const defaultNames = piDefaultTools().map((t) => t.name);
124
124
  const customTools = tools.filter((t) => !defaultNames.includes(t.name));
125
125
  // Adapt fastagent's AgentTool to pi's ToolDefinition (`parameters` is plain JSON-Schema; pi accepts
@@ -1,6 +1,6 @@
1
1
  import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
2
2
  import type { Models } from "@earendil-works/pi-ai";
3
- import { type SessionControl, type SessionEvent } from "../../session.ts";
3
+ import { type AgentCommand, type SessionControl, type SessionEvent } from "../../session.ts";
4
4
  import type { Lease, SessionObserver } from "./invoke.ts";
5
5
  import { type AnyModel, type PiHarnessFactory } from "./harness.ts";
6
6
  import { type PiSessionReader } from "./sessions.ts";
@@ -39,6 +39,16 @@ export interface CreatePiSessionControlOptions {
39
39
  * (assembly completes before any dispatch can arrive). Absent / undefined → boundary commands
40
40
  * are gated off in `capabilities()` and rejected `unsupported_capability`. */
41
41
  boundary?: () => PiBoundaryWiring | undefined;
42
+ /** The definition's names, as a LAZY thunk for the same reason {@link boundary} is one (the hub
43
+ * exists before the assembly that can read a definition) — and async because the definition is
44
+ * live: this must re-read it, not close over a boot snapshot, or `commands()` would advertise a
45
+ * list the next turn no longer runs.
46
+ *
47
+ * OPTIONAL because absence is a TRUE answer for the assembly that omits it: a hub over an L1
48
+ * agent (`createPiAgent({ model, instructions, tools })`) has no definition and therefore no
49
+ * names, and `[]` says exactly that. Wire it whenever the agent came from a DIRECTORY — there
50
+ * `[]` would be a lie; the directory constructor (`createPiAgentFromDir`) always does. */
51
+ commands?: () => Promise<AgentCommand[]>;
42
52
  /** Tap for the events the HUB ITSELF generates (boundary mutations: `state_changed`,
43
53
  * `compaction_*`) — those never pass through the data plane's observer seam, so a consumer
44
54
  * composing a full-vocabulary tap wires the run events via the observer AND this. Called after
@@ -200,6 +200,9 @@ export function createPiSessionControl(options) {
200
200
  fanOut(session, event);
201
201
  };
202
202
  const control = {
203
+ async commands() {
204
+ return (await options.commands?.()) ?? [];
205
+ },
203
206
  capabilities: () => {
204
207
  const b = boundary?.();
205
208
  return {
@@ -65,6 +65,23 @@ export async function connectSessionControl(options) {
65
65
  const capabilities = await get("/control/capabilities");
66
66
  return {
67
67
  capabilities: () => capabilities,
68
+ // NOT prefetched like capabilities: a live definition can grow a skill between calls, so the
69
+ // list is fetched per call. The endpoint is UNCACHED server-side (it re-reads the definition's
70
+ // skills/ per request), which is what keeps it honest about a directory that changes underneath
71
+ // it. A 404 is SKEW, not a fault in the definition: without this the two read identically
72
+ // (uncoded non-2xx), and a client would report "this agent's skills are unreadable" about a
73
+ // serve that simply predates the route.
74
+ async commands() {
75
+ try {
76
+ return await get("/control/commands");
77
+ }
78
+ catch (error) {
79
+ if (error instanceof ControlRequestError && error.status === 404) {
80
+ throw new ControlRequestError(404, "this serve does not implement /control/commands (it predates the route)");
81
+ }
82
+ throw error;
83
+ }
84
+ },
68
85
  state: (session) => get(`/control/state?session=${encodeURIComponent(session)}`),
69
86
  entries: (session, opts) => get(`/control/entries?session=${encodeURIComponent(session)}${opts?.since !== undefined ? `&since=${encodeURIComponent(opts.since)}` : ""}`, PAYLOAD_TIMEOUT_MS),
70
87
  async dispatch(session, command) {
package/dist/session.d.ts CHANGED
@@ -9,6 +9,14 @@
9
9
  import type { Json, Prompt } from "./agent.ts";
10
10
  export interface SessionControl {
11
11
  capabilities(): SessionCapabilities;
12
+ /** The names this agent exposes — what a composer's `/` completion LISTS. A listing, not a
13
+ * dispatch surface: the data plane takes prompts as text, so what typing one means (expanding it,
14
+ * sending "use the X skill", filtering a menu) is the client's business. Sessionless (the
15
+ * definition is a deployment fact) but ASYNC, because a definition is allowed to be live: an
16
+ * implementation that re-reads it per turn must answer from that same read, or the list and the
17
+ * behavior diverge. `[]` is a complete answer, not a missing one; a definition the implementation
18
+ * cannot read at all is a deployment fault and MAY reject. */
19
+ commands(): Promise<AgentCommand[]>;
12
20
  state(session: string): Promise<SessionState>;
13
21
  /** `since` is an APPEND-ORDER position cursor: "every record appended after the one with this
14
22
  * id", regardless of branch structure. Reconstructing the active path in a branched session is
@@ -53,6 +61,18 @@ export interface SessionCapabilities {
53
61
  toolProgress: boolean;
54
62
  usage: boolean;
55
63
  }
64
+ /**
65
+ * One name a client can offer the user. Field NAMES follow pi's RPC `get_commands` so a client
66
+ * porting from it maps directly; its `sourceInfo` (file provenance) is deliberately not carried, and
67
+ * `source` is a free-form string rather than pi's closed union — which kinds exist is an engine's
68
+ * business ("skill" is the only one fastagent assembles today), and an engine with none answers `[]`
69
+ * rather than the contract enumerating a set it cannot know.
70
+ */
71
+ export interface AgentCommand {
72
+ name: string;
73
+ description?: string;
74
+ source: string;
75
+ }
56
76
  /** Stable `SessionResult.error.code` for a command the implementation does not support. */
57
77
  export declare const UNSUPPORTED_CAPABILITY_CODE = "unsupported_capability";
58
78
  /** Stable `SessionResult.error.code` for a run-modulating command (`steer`/`follow_up`/`abort`)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fastagent-sh/fastagent",
3
- "version": "0.16.2",
3
+ "version": "0.17.0",
4
4
  "description": "Vibe first. Then FastAgent: turn a local agent directory into a live service in your app, on GitHub, Telegram, Slack, or behind any channel.",
5
5
  "keywords": [
6
6
  "agent",