@fastagent-sh/fastagent 0.16.2 → 0.17.1

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 (43) hide show
  1. package/dist/channels/agentcore.d.ts +16 -2
  2. package/dist/channels/agentcore.js +90 -9
  3. package/dist/channels/control.d.ts +1 -1
  4. package/dist/channels/control.js +2 -1
  5. package/dist/channels/feishu/card.d.ts +20 -9
  6. package/dist/channels/feishu/card.js +27 -13
  7. package/dist/channels/feishu/feishu-api.d.ts +11 -2
  8. package/dist/channels/feishu/feishu.js +84 -10
  9. package/dist/channels/feishu/invoke-turn.d.ts +4 -0
  10. package/dist/channels/feishu/invoke-turn.js +31 -5
  11. package/dist/channels/feishu/normalize.js +97 -32
  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/lark/scaffold/lark-send.ts +9 -6
  16. package/dist/cli/commands/attach.d.ts +18 -0
  17. package/dist/cli/commands/attach.js +46 -2
  18. package/dist/cli/commands/dev.js +2 -2
  19. package/dist/cli/commands/info.js +2 -2
  20. package/dist/cli/commands/start.js +41 -7
  21. package/dist/cli/program.js +2 -2
  22. package/dist/cli/serve.d.ts +4 -0
  23. package/dist/cli/serve.js +2 -2
  24. package/dist/deploy/agentcore/logs.d.ts +10 -5
  25. package/dist/deploy/agentcore/logs.js +2 -5
  26. package/dist/deploy/agentcore/plan.d.ts +3 -2
  27. package/dist/deploy/agentcore/plan.js +23 -5
  28. package/dist/deploy/agentcore/run.d.ts +7 -1
  29. package/dist/deploy/agentcore/run.js +93 -8
  30. package/dist/deploy/preflight.js +34 -7
  31. package/dist/engines/pi/create.js +7 -16
  32. package/dist/engines/pi/definition.d.ts +15 -0
  33. package/dist/engines/pi/definition.js +22 -1
  34. package/dist/engines/pi/open.d.ts +1 -1
  35. package/dist/engines/pi/open.js +19 -0
  36. package/dist/engines/pi/report.d.ts +16 -0
  37. package/dist/engines/pi/report.js +30 -0
  38. package/dist/engines/pi/session-builder.js +2 -2
  39. package/dist/engines/pi/session-control.d.ts +11 -1
  40. package/dist/engines/pi/session-control.js +3 -0
  41. package/dist/session-remote.js +17 -0
  42. package/dist/session.d.ts +20 -0
  43. package/package.json +1 -1
package/dist/cli/serve.js CHANGED
@@ -151,9 +151,9 @@ export function mountSessionControl(routes, control, stateRoot, options = {}) {
151
151
  * the platform's contract, so a channel shadowing them would silently unserve the whole deployment.
152
152
  */
153
153
  export function mountAgentcore(routes, options) {
154
- const { agent, stateRoot, schedules, onStateReady } = options;
154
+ const { agent, stateRoot, schedules, onStateReady, lazyChannels } = options;
155
155
  const mounted = agentcoreRoutes({
156
- routes,
156
+ routes: lazyChannels ?? routes,
157
157
  agent,
158
158
  stateRoot,
159
159
  isBusy: () => activeWork() > 0,
@@ -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;
@@ -395,6 +396,23 @@ exports.handler = async (event, ctx) => {
395
396
  if (failed > 0) return { statusCode: 500, body: \`\${failed} alarm(s) failed\\n\` };
396
397
  return { statusCode: 200, body: "ok\\n" };
397
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
+ }
398
416
  // Enforce the advertised ORIGINAL-body ceiling before base64 adds another 4/3 inside the runtime
399
417
  // envelope. This also leaves deterministic room for headers/query/JSON under Lambda's 6 MB cap.
400
418
  const webhookBytes = event.body === undefined ? 0
@@ -562,7 +580,7 @@ function template(input, translated) {
562
580
  ` # forces a NAT gateway for model/channel egress (~$33/mo) — deliberately not the default.`,
563
581
  ` FilesystemConfigurations:`,
564
582
  ` - SessionStorage: { MountPath: ${MOUNT} }`,
565
- ` # Idle ${IDLE_TIMEOUT_SECONDS}s (the ping's HealthyBusy keeps BUSY sessions alive regardless), max compute`,
583
+ ` # Idle ${IDLE_TIMEOUT_SECONDS}s (the ping's HealthyBusy + time_of_last_update keeps BUSY sessions alive), max compute`,
566
584
  ` # lifetime ${MAX_LIFETIME_SECONDS}s — the platform ceiling; the session id stays valid, so the next invoke`,
567
585
  ` # just gets fresh compute with the same storage. Memory bills per second for the whole`,
568
586
  ` # session INCLUDING the idle tail, so a shorter tail is the main cost lever here.`,
@@ -712,7 +730,7 @@ export function planAgentcoreDeploy(input) {
712
730
  ? `# 4. Read the outputs (the runtime ARN + callback URL; it serves webhooks only when configured):`
713
731
  : `# 4. Read the outputs (the runtime ARN — this topology has NO public URL: nothing outside AWS`, ...(needsFunctionUrl
714
732
  ? []
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
733
+ : [`# 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
734
  ? [
717
735
  `# The ingress transport is a separate Lambda and therefore a separate log source:`,
718
736
  `fastagent logs agentcore --source forwarder --follow`,
@@ -732,7 +750,7 @@ export function planAgentcoreDeploy(input) {
732
750
  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
751
  }
734
752
  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).`);
753
+ 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
754
  }
737
755
  if (channels.includes("slack")) {
738
756
  post.push(`# Set Slack Event Subscriptions → Request URL = <ForwarderUrl>/slack (scopes per channels/slack.ts).`);
@@ -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")) {
@@ -195,12 +195,17 @@ export async function preflightDeploy(input) {
195
195
  const rel = relative(workspace, p);
196
196
  return rel === "" || rel.startsWith("..") || isAbsolute(rel) ? undefined : rel.split(sep).join("/");
197
197
  };
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.
198
+ // The secrets DIR is the unit of RESPONSIBILITY, but never the unit of the leak QUESTION below: the
199
+ // generated ignore excludes the dir's CONTENTS (`**/.secrets/**`) so its two value-free tracked
200
+ // scaffolds can be re-included, and a directory-level question reads that correct file as "not
201
+ // excluded" the generator's own default output gated its own deploy (field-hit: a fresh
202
+ // kit-layout workspace without --force; --force skips checking our own file, which is why the
203
+ // combination stayed invisible). What leaks is a FILE, so files are what the gate asks about — see
204
+ // secretDirFiles below, which enumerates what is actually inside (an atomic-write temp beside
205
+ // auth.json, a second key file, an editor backup of `.env`: the dir-as-unit worry, covered per
206
+ // file). The auth path adds an entry only when an override puts it OUTSIDE that dir. An external
207
+ // secrets dir (the deployed posture: a mounted volume) is outside the context — nothing to check,
208
+ // nothing to exclude.
204
209
  const secretsRel = inContext(resolveSecretsDir(agentDir));
205
210
  const authRel = inContext(authPath);
206
211
  const authElsewhere = authRel !== undefined && (secretsRel === undefined || !authRel.startsWith(`${secretsRel}/`));
@@ -246,7 +251,29 @@ export async function preflightDeploy(input) {
246
251
  .map((n) => join(relDir, n).split(sep).join("/"));
247
252
  };
248
253
  const envFiles = (await Promise.all([...new Set(["", agentPrefix])].map(dotEnvFiles))).flat();
249
- const leakCandidates = [...(await present(secretPaths)), ...envFiles];
254
+ // Everything ACTUALLY inside the secrets dir, minus the two tracked scaffolds the image ships on
255
+ // purpose (they carry no values; the generated ignore re-includes them by name). Existence is the
256
+ // enumeration itself — readdir lists exactly what could be baked — and a hand-written ignore that
257
+ // misses the dir now gates NAMING the leaking file, a better diagnostic than pointing at a
258
+ // directory. Recurses: a subdirectory inside .secrets is unusual but its files leak all the same.
259
+ const secretDirFiles = async (dirRel) => {
260
+ const entries = await readdir(join(workspace, dirRel), { withFileTypes: true }).catch(() => []);
261
+ const files = [];
262
+ for (const entry of entries) {
263
+ if (entry.name === ".gitignore" || entry.name === ".env.example")
264
+ continue;
265
+ if (entry.isDirectory())
266
+ files.push(...(await secretDirFiles(`${dirRel}/${entry.name}`)));
267
+ else
268
+ files.push(`${dirRel}/${entry.name}`);
269
+ }
270
+ return files;
271
+ };
272
+ const leakCandidates = [
273
+ ...(secretsRel ? await secretDirFiles(secretsRel) : []),
274
+ ...(await present(authElsewhere && authRel !== undefined ? [authRel] : [])),
275
+ ...envFiles,
276
+ ];
250
277
  // Same existence rule: a node_modules that is not there cannot be uploaded.
251
278
  const depDirs = await present([...new Set([`${agentPrefix}node_modules`, "node_modules"])]);
252
279
  const machineryPaths = [...secretPaths, ...(stateRel ? [stateRel] : [])];
@@ -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 {