@fastagent-sh/fastagent 0.17.0 → 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.
@@ -54,6 +54,15 @@ export type AgentcoreEnvelope = {
54
54
  * "channels with replay re-run it" true rather than aspirational. */
55
55
  | {
56
56
  kind: "checkpoint";
57
+ }
58
+ /** The deploy driver's post-deploy verification (relayed by the forwarder's reserved
59
+ * `/__fastagent/probe` path, which answers on EVERY forwarder topology — schedule-only URLs
60
+ * refuse ordinary public traffic). Runs restore + channel construction end to end and answers a
61
+ * TRANSPORT-200 structured verdict `{ ok, error? }`: the ordinary webhook path folds a non-200
62
+ * transport into an opaque 502 at the forwarder, which would strip exactly the diagnostics this
63
+ * probe exists to carry. */
64
+ | {
65
+ kind: "probe";
57
66
  });
58
67
  /** The webhook envelope's reply: the channel's real HTTP response, ridden inside a transport-200
59
68
  * body so the forwarder can re-emit it verbatim (see the module header on AgentCore's 424 folding). */
@@ -63,8 +72,13 @@ export interface WebhookReply {
63
72
  bodyB64: string;
64
73
  }
65
74
  export interface AgentcoreAdapterOptions {
66
- /** The serving routes a direct deployment would mount (channels or the builtin invoke + health). */
67
- routes: Routes;
75
+ /** The serving routes a direct deployment would mount (channels or the builtin invoke + health).
76
+ * The serving path passes a LAZY factory: channel construction loads channel state and replays
77
+ * durable turn intent, so on AgentCore it must not run until the state root is authoritative —
78
+ * which happens at the first envelope's `stateSync.ready()` (the restore URLs only an envelope
79
+ * carries), never at boot, where the mount is pre-restore (empty after every version update).
80
+ * An eager `Routes` value remains supported for wirings whose state root is already durable. */
81
+ routes: Routes | (() => Promise<Routes> | Routes);
68
82
  agent: Agent;
69
83
  /** Where the forwarder URL from envelopes is persisted for the wake-alarm sink (the state root). */
70
84
  stateRoot: string;
@@ -55,7 +55,28 @@ function secretMatches(actual, expected) {
55
55
  */
56
56
  export function agentcoreRoutes(options) {
57
57
  const { routes, agent, stateRoot, isBusy, fire, stateSync, ingressSecret, onStateReady } = options;
58
- const dispatch = router(routes);
58
+ // Lazy channel construction (see AgentcoreAdapterOptions.routes) — resolved ONCE per process, on
59
+ // the first trusted envelope after the state root is authoritative, and the outcome is cached
60
+ // EITHER WAY. Success: the same resident channels a direct host keeps. Failure too: construction
61
+ // is an ACTIVATION with side effects — loadChannels builds every healthy channel (starting its
62
+ // queues and replaying durable turn intent) before reporting another module's failure — and there
63
+ // is no cleanup contract to unwind it, so re-running it per envelope could replay the same
64
+ // recovered turn concurrently. The first rejection is therefore the process's answer: every later
65
+ // envelope fails with the same message (visible each time), the retry boundary is a fresh session
66
+ // (which scale-to-zero provides naturally), and the deploy driver's probe catches deterministic
67
+ // failures at deploy time.
68
+ let dispatchP;
69
+ const resolveDispatch = () => {
70
+ if (!dispatchP) {
71
+ // The factory runs INSIDE the chain: a synchronous throw must land in the cached rejection,
72
+ // not escape before `dispatchP` is assigned (which would silently re-run the activation).
73
+ dispatchP = Promise.resolve()
74
+ .then(() => (typeof routes === "function" ? routes() : routes))
75
+ .then(router);
76
+ dispatchP.catch(() => { }); // observed here so the CACHED rejection is never "unhandled"
77
+ }
78
+ return dispatchP;
79
+ };
59
80
  const invokeHandler = createInvokeHandler(agent);
60
81
  // Snapshot on the 0-in-flight edge: webhook channels ACK fast and finish the turn in the
61
82
  // background, so "the request returned" is NOT when the state root settles.
@@ -75,7 +96,7 @@ export function agentcoreRoutes(options) {
75
96
  return text("invalid json\n", 400);
76
97
  }
77
98
  if (envelope === null || typeof envelope !== "object" || typeof envelope.kind !== "string") {
78
- return text('need { "kind": "webhook" | "schedule-fire" | "invoke" | "wake-poke" | "checkpoint", ... }\n', 400);
99
+ return text('need { "kind": "webhook" | "schedule-fire" | "invoke" | "wake-poke" | "checkpoint" | "probe", ... }\n', 400);
79
100
  }
80
101
  // AUTHENTICATION BOUNDARY. `InvokeAgentRuntime` is an ordinary IAM action, so "reached this
81
102
  // handler" proves nothing about the sender. Only an envelope carrying the shared secret is the
@@ -113,7 +134,7 @@ export function agentcoreRoutes(options) {
113
134
  stateSync.use(envelope.state);
114
135
  }
115
136
  else if (envelope.kind !== "invoke" && !stateSync.configured() && !warnedUnsnapshotted) {
116
- // webhook/schedule-fire/wake-poke reach us ONLY through the forwarder, which always mints the
137
+ // webhook/schedule-fire/wake-poke/probe reach us ONLY through the forwarder, which mints the
117
138
  // pair. Missing = a broken/stale topology whose state dies at the next deploy: say so, loudly,
118
139
  // once per process (a direct `invoke` legitimately has none — its session storage is its own).
119
140
  warnedUnsnapshotted = true;
@@ -124,6 +145,11 @@ export function agentcoreRoutes(options) {
124
145
  }
125
146
  catch (e) {
126
147
  log.error(`[agentcore] state restore failed: ${String(e)}`);
148
+ // The probe is the deploy driver's verification channel: its diagnostics must survive the
149
+ // forwarder, which folds a non-200 transport into an opaque 502 — so for it the failure
150
+ // rides a transport-200 structured verdict; every other kind keeps the plain 503.
151
+ if (envelope.kind === "probe")
152
+ return json({ ok: false, error: `state restore failed: ${String(e)}` }, 200);
127
153
  return text(`state restore failed: ${String(e)}\n`, 503);
128
154
  }
129
155
  }
@@ -134,6 +160,26 @@ export function agentcoreRoutes(options) {
134
160
  stateReadyFired = true;
135
161
  onStateReady();
136
162
  }
163
+ // PROCESS INITIALIZATION, kind-independent: the (lazy) channels are constructed on the first
164
+ // trusted ingress after the state root became authoritative — whichever kind carries it, so a
165
+ // cold start woken by a schedule fire or an alarm poke still replays checkpointed turn intent.
166
+ // Two deliberate exceptions: `checkpoint` must push state even when a channel is broken, and a
167
+ // public `invoke` runs in its own isolated storage — constructing against THAT root would cache
168
+ // pre-restore emptiness for the ingress session. Failure policy is per kind below: webhook and
169
+ // wake-poke fail their request (503), the probe reports it structurally, and a schedule fire
170
+ // proceeds — cron does not consume channels, and letting an unrelated channel misconfiguration
171
+ // silence the clock would turn one fault into two (the error is logged here either way).
172
+ let constructionError;
173
+ let dispatch;
174
+ if (trusted && envelope.kind !== "checkpoint" && envelope.kind !== "invoke") {
175
+ try {
176
+ dispatch = await resolveDispatch();
177
+ }
178
+ catch (e) {
179
+ constructionError = String(e);
180
+ log.error(`[agentcore] channel construction failed: ${constructionError}`);
181
+ }
182
+ }
137
183
  switch (envelope.kind) {
138
184
  case "webhook": {
139
185
  const { method, path, query, headers, bodyB64 } = envelope;
@@ -154,6 +200,10 @@ export function agentcoreRoutes(options) {
154
200
  ? Buffer.from(bodyB64, "base64")
155
201
  : undefined,
156
202
  });
203
+ // A construction failure is the request's failure (503 through the forwarder, so the
204
+ // platform retries and the operator sees the message), never a silently-empty channel.
205
+ if (!dispatch)
206
+ return text(`channel construction failed: ${constructionError ?? "unavailable"}\n`, 503);
157
207
  const response = await dispatch(inner);
158
208
  // Buffer the channel's ACK (webhook ACKs are small by design — the turn itself runs
159
209
  // fire-and-forget) and ride it inside the transport reply, byte-exact.
@@ -218,9 +268,21 @@ export function agentcoreRoutes(options) {
218
268
  }
219
269
  case "wake-poke": {
220
270
  // The poke's job is DONE by arriving: the invocation woke (or kept awake) the container, and
221
- // the wake pump (boot drain + 30s poll) fires whatever is due. Nothing to dispatch.
271
+ // the wake pump (boot drain + 30s poll) fires whatever is due. Nothing to dispatch — the
272
+ // initialization above already resolved construction (replaying checkpointed turn intent),
273
+ // and its failure is this request's failure so the alarm's log line names it.
274
+ if (constructionError !== undefined)
275
+ return text(`channel construction failed: ${constructionError}\n`, 503);
222
276
  return json({ ok: true }, 200);
223
277
  }
278
+ case "probe": {
279
+ // The structured verdict (transport-200 — see the envelope doc): the deploy driver reads it
280
+ // through the forwarder's reserved path, so the error text survives the hop that turns any
281
+ // non-200 transport into an opaque 502.
282
+ return json(constructionError === undefined
283
+ ? { ok: true }
284
+ : { ok: false, error: `channel construction failed: ${constructionError}` }, 200);
285
+ }
224
286
  case "invoke": {
225
287
  // Reuse the HTTP channel's handler wholesale (SSE, cancellation, backpressure) by handing it
226
288
  // the shape it already validates — one protocol, one implementation.
@@ -3,16 +3,24 @@
3
3
  * final card, and the message content that mounts a card entity into a chat. Kept out of preview.ts so
4
4
  * the card DSL is data-in → string-out and testable without the pump.
5
5
  *
6
- * The preview is ONE markdown element (`element_id` below) inside a card with `streaming_mode` on:
7
- * the pump PUTs full-text snapshots at that element (feishu-api.ts `updateCardElement`) and the client
8
- * renders the typewriter effect. Settling replaces the whole entity (`updateCard`) with the same
9
- * element, `streaming_mode` off one write flips content and mode together.
6
+ * The streaming card is TWO markdown elements: `process` (the volatile block thinking tail, tool
7
+ * lines, retry notice) and `answer` (append-only). The pump PUTs full-text snapshots per element
8
+ * (feishu-api.ts `updateCardElement`) and the client renders the typewriter effect. The split is the
9
+ * prefix-stability rule made structural: the client animates an element's update only when the old
10
+ * text is a PREFIX of the new — otherwise it re-types everything after the first divergent character.
11
+ * The process block's head changes every frame (a sliding thinking tail, `…`→`✓` status flips), so
12
+ * sharing one element with the answer re-typed the whole card once a second; two elements confine the
13
+ * churn to the small process block and keep the answer's typewriter smooth. Settling replaces the
14
+ * whole entity (`updateCard`) with the answer element alone, `streaming_mode` off — one write flips
15
+ * content and mode together and drops the process block.
10
16
  *
11
17
  * Budget: a card entity is capped at 30 KB, so the final answer's card chunk (and the live view) stay
12
18
  * well under it; longer answers overflow into follow-up messages (preview.ts owns that policy).
13
19
  */
14
- /** The one streamed element's id — shared by create (card.ts) and update (preview.ts). */
20
+ /** The append-only answer element's id — shared by create (card.ts) and update (preview.ts). */
15
21
  export declare const ANSWER_ELEMENT_ID = "answer";
22
+ /** The volatile process element's id (thinking tail + tool lines + retry notice; live-only). */
23
+ export declare const PROCESS_ELEMENT_ID = "process";
16
24
  /** Byte budget for markdown carried by ONE card (entity cap 30 KB minus JSON envelope + escaping room). */
17
25
  export declare const CARD_MARKDOWN_MAX_BYTES: number;
18
26
  /**
@@ -23,10 +31,13 @@ export declare const CARD_MARKDOWN_MAX_BYTES: number;
23
31
  * emphasis/heading/list markers removed.
24
32
  */
25
33
  export declare function cardSummary(markdown: string): string;
26
- /** The live-preview card entity: streaming on, seeded with the placeholder/first view. */
27
- export declare function streamingCardJson(initial: string): string;
28
- /** The settled card: final markdown, streaming off (stops the client's streaming affordance), plus
29
- * the answer-derived summary so the chat list / notification shows the reply, not "[Card]". */
34
+ /** The live-preview card entity: streaming on, the process element seeded with the placeholder/queue
35
+ * status and the answer element seeded EMPTY (the platform accepts an empty markdown element; it
36
+ * renders zero-height until the first answer snapshot lands as a clean prefix extension of ""). */
37
+ export declare function streamingCardJson(initialProcess: string): string;
38
+ /** The settled card: final markdown alone (the process block was preview-only), streaming off (stops
39
+ * the client's streaming affordance), plus the answer-derived summary so the chat list / notification
40
+ * shows the reply, not "[Card]". */
30
41
  export declare function finalCardJson(markdown: string): string;
31
42
  /** The `interactive` message content that mounts a card ENTITY (vs an inline static card). */
32
43
  export declare function cardEntityContent(cardId: string): string;
@@ -3,17 +3,25 @@
3
3
  * final card, and the message content that mounts a card entity into a chat. Kept out of preview.ts so
4
4
  * the card DSL is data-in → string-out and testable without the pump.
5
5
  *
6
- * The preview is ONE markdown element (`element_id` below) inside a card with `streaming_mode` on:
7
- * the pump PUTs full-text snapshots at that element (feishu-api.ts `updateCardElement`) and the client
8
- * renders the typewriter effect. Settling replaces the whole entity (`updateCard`) with the same
9
- * element, `streaming_mode` off one write flips content and mode together.
6
+ * The streaming card is TWO markdown elements: `process` (the volatile block thinking tail, tool
7
+ * lines, retry notice) and `answer` (append-only). The pump PUTs full-text snapshots per element
8
+ * (feishu-api.ts `updateCardElement`) and the client renders the typewriter effect. The split is the
9
+ * prefix-stability rule made structural: the client animates an element's update only when the old
10
+ * text is a PREFIX of the new — otherwise it re-types everything after the first divergent character.
11
+ * The process block's head changes every frame (a sliding thinking tail, `…`→`✓` status flips), so
12
+ * sharing one element with the answer re-typed the whole card once a second; two elements confine the
13
+ * churn to the small process block and keep the answer's typewriter smooth. Settling replaces the
14
+ * whole entity (`updateCard`) with the answer element alone, `streaming_mode` off — one write flips
15
+ * content and mode together and drops the process block.
10
16
  *
11
17
  * Budget: a card entity is capped at 30 KB, so the final answer's card chunk (and the live view) stay
12
18
  * well under it; longer answers overflow into follow-up messages (preview.ts owns that policy).
13
19
  */
14
20
  import { truncateCodePointPrefix } from "../text.js";
15
- /** The one streamed element's id — shared by create (card.ts) and update (preview.ts). */
21
+ /** The append-only answer element's id — shared by create (card.ts) and update (preview.ts). */
16
22
  export const ANSWER_ELEMENT_ID = "answer";
23
+ /** The volatile process element's id (thinking tail + tool lines + retry notice; live-only). */
24
+ export const PROCESS_ELEMENT_ID = "process";
17
25
  /** Byte budget for markdown carried by ONE card (entity cap 30 KB minus JSON envelope + escaping room). */
18
26
  export const CARD_MARKDOWN_MAX_BYTES = 20 * 1024;
19
27
  /** Character budget for the settled card's summary (the chat-list / push-notification preview). */
@@ -38,7 +46,7 @@ export function cardSummary(markdown) {
38
46
  .find((l) => l !== "") ?? "";
39
47
  return truncateCodePointPrefix(line, SUMMARY_MAX_CHARS);
40
48
  }
41
- function cardJson(markdown, streaming, summary) {
49
+ function cardJson(elements, streaming, summary) {
42
50
  return JSON.stringify({
43
51
  schema: "2.0",
44
52
  config: {
@@ -48,17 +56,23 @@ function cardJson(markdown, streaming, summary) {
48
56
  // "[Generating…]") is better than any fixed text we could pin.
49
57
  ...(summary ? { summary: { content: summary } } : {}),
50
58
  },
51
- body: { elements: [{ tag: "markdown", content: markdown, element_id: ANSWER_ELEMENT_ID }] },
59
+ body: { elements },
52
60
  });
53
61
  }
54
- /** The live-preview card entity: streaming on, seeded with the placeholder/first view. */
55
- export function streamingCardJson(initial) {
56
- return cardJson(initial, true);
62
+ /** The live-preview card entity: streaming on, the process element seeded with the placeholder/queue
63
+ * status and the answer element seeded EMPTY (the platform accepts an empty markdown element; it
64
+ * renders zero-height until the first answer snapshot lands as a clean prefix extension of ""). */
65
+ export function streamingCardJson(initialProcess) {
66
+ return cardJson([
67
+ { tag: "markdown", content: initialProcess, element_id: PROCESS_ELEMENT_ID },
68
+ { tag: "markdown", content: "", element_id: ANSWER_ELEMENT_ID },
69
+ ], true);
57
70
  }
58
- /** The settled card: final markdown, streaming off (stops the client's streaming affordance), plus
59
- * the answer-derived summary so the chat list / notification shows the reply, not "[Card]". */
71
+ /** The settled card: final markdown alone (the process block was preview-only), streaming off (stops
72
+ * the client's streaming affordance), plus the answer-derived summary so the chat list / notification
73
+ * shows the reply, not "[Card]". */
60
74
  export function finalCardJson(markdown) {
61
- return cardJson(markdown, false, cardSummary(markdown));
75
+ return cardJson([{ tag: "markdown", content: markdown, element_id: ANSWER_ELEMENT_ID }], false, cardSummary(markdown));
62
76
  }
63
77
  /** The `interactive` message content that mounts a card ENTITY (vs an inline static card). */
64
78
  export function cardEntityContent(cardId) {
@@ -65,7 +65,12 @@ export interface FeishuApi {
65
65
  editTextMessage(messageId: string, text: string): Promise<void>;
66
66
  /** Recall (delete) a message the bot sent. */
67
67
  deleteMessage(messageId: string): Promise<void>;
68
- /** Fetch one message (the reply-referent path). Undefined when the API returns no item. */
68
+ /** Fetch one message (the reply-referent path). Undefined when the API returns no item.
69
+ *
70
+ * `sender` is typed rather than `unknown` because the referent path READS it: an app-sent message
71
+ * whose id is THIS app's is the agent's own, which the prompt must say instead of attributing it to
72
+ * "user cli_…". The message object carries more (`parent_id`, `root_id`, `thread_id`); they stay
73
+ * unnamed until something reads them — this type is the surface in use, not a mirror of the wire. */
69
74
  getMessage(messageId: string): Promise<{
70
75
  message_id?: string;
71
76
  msg_type?: string;
@@ -73,7 +78,11 @@ export interface FeishuApi {
73
78
  content?: string;
74
79
  };
75
80
  mentions?: unknown[];
76
- sender?: unknown;
81
+ sender?: {
82
+ id?: string;
83
+ id_type?: string;
84
+ sender_type?: string;
85
+ };
77
86
  } | undefined>;
78
87
  /** Download a message resource (image/file bytes). Caps at {@link MAX_DOWNLOAD_BYTES}. */
79
88
  downloadResource(messageId: string, fileKey: string, type: "image" | "file"): Promise<{
@@ -14,7 +14,7 @@ import { readBodyCapped } from "../body.js";
14
14
  import { text } from "../respond.js";
15
15
  import { createSeenRing } from "../seen.js";
16
16
  import { createTaskTracker } from "../tasks.js";
17
- import { ensureStateHome, removeRetiredStateFile } from "../state.js";
17
+ import { ensureStateHome, loadStateFile, removeRetiredStateFile, saveStateFile } from "../state.js";
18
18
  import { dispatchStop, isStopText } from "../stop-command.js";
19
19
  import { createTurnQueue } from "../turn-queue.js";
20
20
  import { createTurnStore } from "../turn-store.js";
@@ -93,16 +93,45 @@ function createFeishuRuntimeFactory(profile, opts, factoryName) {
93
93
  }
94
94
  const formatError = onError ?? defaultErrorMessage;
95
95
  const api = createFeishuApi({ kind, baseUrl, appId, appSecret });
96
- // One bot/v3/info at startup: the bot's open_id drives the default route's group @mention summon.
97
- // Until it resolves (or if it fails), group summon stays off — fail-closed — while p2p works. A
98
- // mention landing in that first moment is buffered as context rather than answered; it is folded
99
- // into the next answered turn in that place, so the ask is delayed, never lost.
96
+ // One bot/v3/info per process refreshes the bot's own open_id the identity the default route
97
+ // matches group @mentions against. The CACHED copy (bot.json, seeded synchronously once the state
98
+ // home exists below) is what makes the first envelope safe: this fetch is fire-and-forget, and
99
+ // under the AgentCore posture channel construction happens INSIDE the first envelope
100
+ // (channels/agentcore.ts lazy construction), so a network round trip can never beat that same
101
+ // envelope's own dispatch — without the seed, every cold start's FIRST group mention raced this
102
+ // fetch and lost (field-observed: an explicit @ buffered as bystander context). The open_id is a
103
+ // stable property of the app, so disk beats network; the only envelope a deployment ever serves
104
+ // without the file is its first one, which is the deploy driver's probe — it carries no mention.
105
+ // No identity at all (fresh dir, no cache, fetch pending/failed) keeps today's fail-closed
106
+ // behavior: unmatched mentions buffer as context — delayed, never lost.
100
107
  let botOpenId;
108
+ let persistBotIdentity = () => { }; // bound once the state home exists
109
+ let invalidateBotIdentity = () => { }; // likewise
101
110
  void api.botInfo().then((me) => {
102
- botOpenId = me.openId;
103
- if (!botOpenId)
104
- log.warn(`${label} bot/v3/info returned no open_idgroup @mention summon stays off`);
105
- }, (e) => log.warn(`${label} bot/v3/info failed; group @mention summon stays off until restart: ${String(e)}`));
111
+ if (me.openId) {
112
+ if (botOpenId !== undefined && botOpenId !== me.openId) {
113
+ log.info(`${label} bot open_id changed (${botOpenId} → ${me.openId}) updating the cached identity`);
114
+ }
115
+ botOpenId = me.openId;
116
+ persistBotIdentity(me.openId);
117
+ }
118
+ else {
119
+ // The call SUCCEEDED and the platform reported no identity — an affirmative "there is no
120
+ // bot here" (capability off, app reconfigured), not transport weather. This is the one
121
+ // answer that must also INVALIDATE the cache: keeping a summon identity the platform just
122
+ // declined to confirm would quietly turn fail-closed into fail-open.
123
+ log.warn(botOpenId === undefined
124
+ ? `${label} bot/v3/info returned no open_id — group @mention summon stays off`
125
+ : `${label} bot/v3/info returned no open_id — cached identity cleared; group @mention summon stays off`);
126
+ botOpenId = undefined;
127
+ invalidateBotIdentity();
128
+ }
129
+ }, (e) =>
130
+ // A FAILED call is transport weather (network, rate limit): the platform said nothing about
131
+ // the identity, so a cached one keeps serving — the degradation this cache exists for.
132
+ log.warn(botOpenId === undefined
133
+ ? `${label} bot/v3/info failed; group @mention summon stays off until restart: ${String(e)}`
134
+ : `${label} bot/v3/info failed; running on the cached identity (bot.json): ${String(e)}`));
106
135
  void api.listAppScopes().then((scopes) => {
107
136
  const grantedScope = (name) => scopes.some((scope) => scope.name === name && scope.grantStatus === 1 && (scope.type === undefined || scope.type === "tenant"));
108
137
  if (grantedScope(FEISHU_GROUP_CONTEXT_SCOPE)) {
@@ -135,6 +164,51 @@ function createFeishuRuntimeFactory(profile, opts, factoryName) {
135
164
  // after the release following the participant model ships — by then no live deployment can still
136
165
  // be carrying the file. test/migration-deadline.test.ts fails when due.
137
166
  removeRetiredStateFile(stateHome, "owned-threads.json", label);
167
+ // The cached bot identity (rationale at the botInfo block above): seed synchronously — the
168
+ // factory runs to completion before any promise resolves, so botOpenId is still unset here and
169
+ // the seed is what the first envelope's dispatch sees. Refresh keeps the file current.
170
+ //
171
+ // BOUND TO THE APP: the state home is per channel KIND, and an operator can point kept state at
172
+ // a different app (a recreated app, a tenant migration). A cached identity from another app
173
+ // would make THIS bot treat mentions of the OLD bot as its own summons — identity impersonation,
174
+ // strictly worse than the race the cache removes — so the cache counts only when it names the
175
+ // current appId. A mismatch is not noise worth warning about: the next persist IS the migration.
176
+ const botFile = join(stateHome, "bot.json");
177
+ const storedBot = loadStateFile(botFile);
178
+ let cachedOpenId;
179
+ if (storedBot !== undefined) {
180
+ if (typeof storedBot.appId !== "string") {
181
+ log.warn(`${label} unexpected shape in ${botFile} — ignoring the cached bot identity`);
182
+ }
183
+ else if (storedBot.appId === appId && typeof storedBot.openId === "string") {
184
+ cachedOpenId = storedBot.openId;
185
+ }
186
+ }
187
+ botOpenId ??= cachedOpenId;
188
+ persistBotIdentity = (openId) => {
189
+ if (openId === cachedOpenId)
190
+ return;
191
+ cachedOpenId = openId;
192
+ try {
193
+ saveStateFile(botFile, { appId, openId });
194
+ }
195
+ catch (e) {
196
+ log.warn(`${label} could not persist the bot identity to ${botFile} — the next cold start races bot/v3/info again: ${String(e)}`);
197
+ }
198
+ };
199
+ invalidateBotIdentity = () => {
200
+ if (cachedOpenId === undefined)
201
+ return;
202
+ cachedOpenId = undefined;
203
+ try {
204
+ // `{ appId }` with no openId reads as "no cache" at the loader — the atomic write is reused
205
+ // instead of introducing a deletion path.
206
+ saveStateFile(botFile, { appId });
207
+ }
208
+ catch (e) {
209
+ log.warn(`${label} could not clear the cached bot identity ${botFile}: ${String(e)}`);
210
+ }
211
+ };
138
212
  const threadParticipants = createThreadParticipants(join(stateHome, "thread-participants.json"), label);
139
213
  /** This channel's place key for a thread (the shared store is key-agnostic). */
140
214
  // The SAME identity the session uses (`placeKey`) — a thread's place. Defining it twice would let a
@@ -241,7 +315,7 @@ function createFeishuRuntimeFactory(profile, opts, factoryName) {
241
315
  files: rec.files.map((ref) => ({ messageId: ref.msg, key: ref.key, name: ref.name })),
242
316
  });
243
317
  try {
244
- await streamFeishuReply(invokeFeishuTurn(agent, rec.session, prompt, { api, chatId: rec.chatId, filesDir: join(stateHome, "files"), label }, { primary: { images: rec.images, files: rec.files, parentId: rec.parentId }, buffered }, () => {
318
+ await streamFeishuReply(invokeFeishuTurn(agent, rec.session, prompt, { api, chatId: rec.chatId, filesDir: join(stateHome, "files"), label, appId }, { primary: { images: rec.images, files: rec.files, parentId: rec.parentId }, buffered }, () => {
245
319
  // Drop intent first: a crash between these writes may re-fold answered context later,
246
320
  // but can never replay this turn after its context was removed.
247
321
  store.remove(rec.id);
@@ -21,6 +21,10 @@ export interface FeishuTurnTransport {
21
21
  chatId: string;
22
22
  filesDir: string;
23
23
  label: string;
24
+ /** THIS app's own id (`cli_…`) — the identity a fetched message's `sender.id` carries when the
25
+ * sender is an app. Needed to tell the agent's OWN messages from any other bot's in the same chat:
26
+ * `sender_type` alone says "some app", which is not the question the referent path asks. */
27
+ appId: string;
24
28
  }
25
29
  /** An attachment reference: the resource key inside its CARRYING message (the resource API addresses
26
30
  * bytes by message_id + key, so the pair travels together through the turn record). */
@@ -2,9 +2,14 @@ import { log } from "../../log.js";
2
2
  import { DEFAULT_BUSY_RETRY, attachedFilesManifest, attributedFileName, backgroundImagesManifest, missingAttachmentsNote, streamTurnWithBusyRetry, } from "../invoke-turn-kit.js";
3
3
  import { parseContent } from "./parse.js";
4
4
  import { REFERENT_MAX_CODE_POINTS, truncateCodePointPrefix } from "../text.js";
5
- /** Appended to the prompt (not the system prompt): the channel renders the reply in a card, and the
6
- * card's markdown element is the natural fit for LLM output steer away from HTML/plain. */
7
- const MARKDOWN_INSTRUCTION = "\n\n(Format your reply in standard Markdown it is rendered in a Feishu/Lark card.)";
5
+ /** The per-turn REPLY CONTRACT, appended to the prompt (not the system prompt). Two halves, one
6
+ * concept what happens to the reply: its FORMAT (rendered in a card whose markdown element is the
7
+ * natural fit for LLM output steer away from HTML/plain) and its DELIVERY OWNERSHIP (the channel
8
+ * itself delivers it; answering through a send TOOL instead is the observed failure — the channel
9
+ * then settles an empty turn as "(no reply)" next to the tool's un-threaded duplicate). */
10
+ const REPLY_INSTRUCTION = "\n\n(Format your reply in standard Markdown — it is rendered in a Feishu/Lark card. This reply is " +
11
+ "delivered to the current chat by the channel itself: do not call a send tool to answer the " +
12
+ "current chat.)";
8
13
  /**
9
14
  * Resolve a turn's inputs (module header): fetch the reply referent's content, then load every image
10
15
  * (vision) and file (disk). Primary failures throw; buffered resources degrade independently.
@@ -46,9 +51,30 @@ async function resolveTurnInputs(t, attachments) {
46
51
  files.push({ msg: parentId, key: ref.key, name: ref.name });
47
52
  // getMessage's sender is `{ id, id_type, sender_type }` — a DIFFERENT shape from the event's
48
53
  // sender (`{ sender_id: { open_id } }`), so the label is built here, not via parse.senderLabel.
54
+ //
55
+ // OWN means THIS app, not "an app". A group can hold several bots, and `sender_type === "app"`
56
+ // is true for every one of them — matching on it alone would tell the model it wrote another
57
+ // bot's message. The identity to compare is the app id, because an app sender carries
58
+ // `id_type: "app_id"`: the cached bot open_id answers a different question (who was @mentioned)
59
+ // and would never match here. A missing or unexpected id fails CLOSED — labelled by id, never
60
+ // claimed as the agent's own.
61
+ const appSender = parent.sender?.sender_type === "app";
49
62
  const senderId = parent.sender?.id;
50
- const from = senderId ? `user ${senderId}` : undefined;
63
+ const ownMessage = appSender && senderId === t.appId;
64
+ // An app is not a person: labelling another bot's message "user cli_…" is the same misattribution
65
+ // in a quieter form, so the noun follows the sender type.
66
+ const from = ownMessage ? "you, the agent" : senderId ? `${appSender ? "app" : "user"} ${senderId}` : undefined;
51
67
  referentBlock = `\n\n[replied-to message (msg ${parentId}${from ? `, from ${from}` : ""}): ${truncateCodePointPrefix(parsed.text, REFERENT_MAX_CODE_POINTS) || "(empty)"}]`;
68
+ // The chain STOPS here, at the one message the user pointed at. Walking further — to what that
69
+ // message was itself replying to — was built and removed: it reconstructs HISTORY out of reply
70
+ // pointers, and history is the session's job. That framing has no non-arbitrary answers (how
71
+ // many levels? what about the level above that? how is it deduplicated against what the session
72
+ // already holds? how does an IMAGE two levels up become prompt text at all?), and every one of
73
+ // those questions is a symptom of solving a session-layer problem in the prompt layer. The real
74
+ // gap it was papering over — a thread opened on a room answer starts with an EMPTY session while
75
+ // the room's session holds the exchange — belongs to memory inheritance (design/participant-
76
+ // model.md §8, rungs 3-4), where images and tool results come along for free because they are
77
+ // already in the history rather than being re-serialised into a prompt string.
52
78
  }
53
79
  }
54
80
  // Primary first and fail-fast: these are resources the current user explicitly pointed at.
@@ -117,6 +143,6 @@ export async function* invokeFeishuTurn(agent, session, text, transport, attachm
117
143
  yield { type: "failed", details: `could not load attachment: ${String(e)}`, retryable: true };
118
144
  return;
119
145
  }
120
- const prompt = { text: `${text}${resolved.promptSuffix}${MARKDOWN_INSTRUCTION}`, images: resolved.images };
146
+ const prompt = { text: `${text}${resolved.promptSuffix}${REPLY_INSTRUCTION}`, images: resolved.images };
121
147
  yield* streamTurnWithBusyRetry(agent, session, prompt, { label: transport.label, onCompleted, busyRetry });
122
148
  }
@@ -1,3 +1,62 @@
1
+ /**
2
+ * Render one paragraph of tagged nodes to a line, collecting any resource it carries INTO the sink
3
+ * the caller supplies — or none, when the caller passes no sink.
4
+ *
5
+ * Shared by `post` and `interactive` because the platform hands both out in the same shape. Card-only
6
+ * shapes are handled here rather than in a second walker: `note` nests its own `elements`, and the
7
+ * widget tags (button/select/overflow/date_picker) carry their user-visible label in `text` or
8
+ * `placeholder` — a card is read for what it SAYS, so a label is content and an unlabelled control is
9
+ * nothing.
10
+ *
11
+ * The OPTIONAL sink is the whole reason this is a parameter rather than a return value: a card's
12
+ * resources are documented as unfetchable (see the card branch), so that caller renders the same
13
+ * `[image]` / `[video]` markers into the text while collecting nothing. The markers still tell the
14
+ * model what is there; what must not happen is a key entering the turn's PRIMARY inputs, which load
15
+ * fail-fast.
16
+ */
17
+ function renderNodes(nodes, resources) {
18
+ if (!Array.isArray(nodes))
19
+ return "";
20
+ const parts = [];
21
+ for (const raw of nodes) {
22
+ if (typeof raw !== "object" || raw === null)
23
+ continue;
24
+ const node = raw;
25
+ if (node.tag === "at") {
26
+ parts.push(`@${nonEmptyString(node.user_name) ?? nonEmptyString(node.user_id) ?? "user"}`);
27
+ }
28
+ else if (node.tag === "a") {
29
+ parts.push(node.href ? `${nonEmptyString(node.text) ?? node.href} (${node.href})` : (node.text ?? ""));
30
+ }
31
+ else if (node.tag === "img") {
32
+ const key = nonEmptyString(node.image_key);
33
+ if (key)
34
+ resources?.push({ kind: "image", key });
35
+ parts.push("[image]");
36
+ }
37
+ else if (node.tag === "media") {
38
+ const key = nonEmptyString(node.file_key);
39
+ if (key)
40
+ resources?.push({ kind: "video", key, name: nonEmptyString(node.file_name) });
41
+ parts.push("[video]");
42
+ }
43
+ else if (node.tag === "code_block") {
44
+ parts.push(`\n\`\`\`${nonEmptyString(node.language)?.toLowerCase() ?? ""}\n${nonEmptyString(node.text) ?? ""}\n\`\`\`\n`);
45
+ }
46
+ else if (node.tag === "note") {
47
+ const nested = renderNodes(node.elements, resources);
48
+ if (nested)
49
+ parts.push(nested);
50
+ }
51
+ else if (nonEmptyString(node.text)) {
52
+ parts.push(node.text);
53
+ }
54
+ else if (nonEmptyString(node.placeholder)) {
55
+ parts.push(node.placeholder);
56
+ }
57
+ }
58
+ return parts.join("").trim();
59
+ }
1
60
  function nonEmptyString(value) {
2
61
  return typeof value === "string" && value !== "" ? value : undefined;
3
62
  }
@@ -37,43 +96,49 @@ export function decodeFeishuContent(message) {
37
96
  lines.push(title);
38
97
  const paragraphs = Array.isArray(content.content) ? content.content : [];
39
98
  for (const paragraph of paragraphs) {
40
- if (!Array.isArray(paragraph))
41
- continue;
42
- const parts = [];
43
- for (const node of paragraph) {
44
- if (typeof node !== "object" || node === null)
45
- continue;
46
- if (node.tag === "at") {
47
- parts.push(`@${nonEmptyString(node.user_name) ?? nonEmptyString(node.user_id) ?? "user"}`);
48
- }
49
- else if (node.tag === "a") {
50
- parts.push(node.href ? `${nonEmptyString(node.text) ?? node.href} (${node.href})` : (node.text ?? ""));
51
- }
52
- else if (node.tag === "img") {
53
- const key = nonEmptyString(node.image_key);
54
- if (key)
55
- resources.push({ kind: "image", key });
56
- parts.push("[image]");
57
- }
58
- else if (node.tag === "media") {
59
- const key = nonEmptyString(node.file_key);
60
- if (key)
61
- resources.push({ kind: "video", key, name: nonEmptyString(node.file_name) });
62
- parts.push("[video]");
63
- }
64
- else if (node.tag === "code_block") {
65
- parts.push(`\n\`\`\`${nonEmptyString(node.language)?.toLowerCase() ?? ""}\n${nonEmptyString(node.text) ?? ""}\n\`\`\`\n`);
66
- }
67
- else if (nonEmptyString(node.text)) {
68
- parts.push(node.text);
69
- }
70
- }
71
- const line = parts.join("").trim();
99
+ const line = renderNodes(paragraph, resources);
72
100
  if (line)
73
101
  lines.push(line);
74
102
  }
75
103
  return { text: lines.join("\n"), resources };
76
104
  }
105
+ // A CARD, as the platform hands it BACK. What we send is an entity reference
106
+ // (`{type:"card",data:{card_id}}`, card.ts) whose text lives in cardkit — but a query API renders
107
+ // the card down to `title` + `elements` (paragraphs of the same tagged nodes as `post`), so the
108
+ // content is readable without a second remote call. This is the message type the agent's OWN
109
+ // answers are, so the case that matters is a user following up on one: without this branch a
110
+ // reply-referent that is the agent's own card decoded to the bare `[interactive message]` marker
111
+ // and the model was told its own answer was unreadable (field-observed).
112
+ //
113
+ // BOTH spellings on purpose: the platform's own docs disagree with themselves — the field table
114
+ // says `interactive` (what the receive EVENT carries) while the message-object example shows
115
+ // `"msg_type": "card"`. Matching one would leave the other silently on the default branch, which
116
+ // is exactly the symptom this fixes.
117
+ case "interactive":
118
+ case "card": {
119
+ const lines = [];
120
+ const title = nonEmptyString(content.title);
121
+ if (title)
122
+ lines.push(title);
123
+ const paragraphs = Array.isArray(content.elements) ? content.elements : [];
124
+ for (const paragraph of paragraphs) {
125
+ // NO resource sink, deliberately. The platform documents that a card's resources cannot be
126
+ // fetched at all: `im/v1/messages/:id/resources/:key` answers 234043 ("Unsupported message
127
+ // type") for a card message id, by stated limitation rather than by permission. Collecting a
128
+ // key here would hand the turn a PRIMARY input that is guaranteed to fail its fail-fast load
129
+ // — turning "the card reads as a marker" (the old behaviour) into "the whole turn errors",
130
+ // which is strictly worse than the gap this branch exists to close. The text still renders
131
+ // `[image]` / `[video]`, so the model knows what is there and that it does not have it.
132
+ //
133
+ // Tolerate both shapes: elements as paragraphs (array of arrays) and a flat element list.
134
+ const line = Array.isArray(paragraph) ? renderNodes(paragraph) : renderNodes([paragraph]);
135
+ if (line)
136
+ lines.push(line);
137
+ }
138
+ // An unrenderable card (all controls, no labels) still says something by existing — keep the
139
+ // marker rather than returning empty, which reads as "the message was blank".
140
+ return { text: lines.length > 0 ? lines.join("\n") : `[${rawType} message]`, resources };
141
+ }
77
142
  case "image": {
78
143
  const key = nonEmptyString(content.image_key);
79
144
  if (key)
@@ -15,9 +15,10 @@ export type MountedFeishuPreview = {
15
15
  messageId: string;
16
16
  };
17
17
  /**
18
- * Mount one preview message: preferably a streaming card entity, with a static text message as the
19
- * visible fallback. Queue feedback and ordinary turn startup share this constructor so a queued card
20
- * has exactly the same shape the stream pump expects to take over later.
18
+ * Mount one preview message: preferably a streaming card entity (`initial` seeds the process element;
19
+ * the answer element starts empty), with a static text message as the visible fallback. Queue feedback
20
+ * and ordinary turn startup share this constructor so a queued card has exactly the same shape the
21
+ * stream pump expects to take over later.
21
22
  */
22
23
  export declare function mountFeishuPreview(api: FeishuApi, target: FeishuTarget, initial: string, label?: string): Promise<MountedFeishuPreview>;
23
24
  /** Settle an already-mounted queue preview without starting an Agent stream (the poison/defer paths).
@@ -1,8 +1,10 @@
1
1
  /**
2
2
  * Canonical Feishu live-preview rendering (also reused by Lark compatibility). The preview is ONE
3
- * streaming CARD (create entity mount it with a reply/send stream full-text snapshots at its
4
- * markdown element with a strictly increasing `sequence`; the client renders the typewriter effect);
5
- * on completion the same card is settled in place with the final answer (streaming off). Streaming
3
+ * streaming CARD of TWO elements the volatile `process` block and the append-only `answer` (see
4
+ * card.ts for why the split is the prefix-stability fix) (create entity → mount it with a
5
+ * reply/send stream full-text snapshots per element with a strictly increasing `sequence`; the
6
+ * client renders the typewriter effect); on completion the same card is settled in place with the
7
+ * final answer alone (streaming off). Streaming
6
8
  * updates ride the cardkit quota (50 QPS per app, 10 QPS per card entity, no edit ceiling) — NOT the
7
9
  * 5 QPS per-chat message quota or
8
10
  * the 20-edit cap on text messages, which is why the preview is a card and not an edited text message.
@@ -21,10 +23,10 @@
21
23
  */
22
24
  import { setTimeout as sleep } from "node:timers/promises";
23
25
  import { log } from "../../log.js";
24
- import { ANSWER_ELEMENT_ID, CARD_MARKDOWN_MAX_BYTES, cardEntityContent, finalCardJson, streamingCardJson, } from "./card.js";
26
+ import { ANSWER_ELEMENT_ID, CARD_MARKDOWN_MAX_BYTES, PROCESS_ELEMENT_ID, cardEntityContent, finalCardJson, streamingCardJson, } from "./card.js";
25
27
  import { chunkFeishuText, isCardStreamingClosed } from "./feishu-api.js";
26
28
  import { RETRY_NOTICE, THINKING_PLACEHOLDER, applyTurnEvent, composeTurnBody, createPreviewPump, createTurnView, defaultErrorMessage, revealedAnswer, thinkingLine, toolLines, } from "../preview-kit.js";
27
- import { truncateUtf8 } from "../text.js";
29
+ import { truncateCodePointPrefix, truncateUtf8 } from "../text.js";
28
30
  export { defaultErrorMessage };
29
31
  /** How often (ms) to push a live-preview snapshot; tool events still flush on the next loop. Cardkit
30
32
  * allows 10 QPS per card entity (50 per app), but one snapshot a second reads smoothly (the client
@@ -33,12 +35,41 @@ export { defaultErrorMessage };
33
35
  const STREAM_THROTTLE_MS = 1000;
34
36
  /** How much of the (growing) reasoning to peek at in the live view — the most recent tail. */
35
37
  const THINKING_PREVIEW = 280;
36
- /** Cap a live view to the card budget, PREFIX-STABLE: the streaming client animates only when the old
37
- * text is a prefix of the new, so an over-budget view freezes at its head rather than sliding a tail
38
- * window (which would redraw the whole card every frame). The full answer still lands at settle. */
38
+ /** Cap (code points) on the whole process block thinking tail + tool lines + retry notice. It
39
+ * redraws wholly on change anyway (it is volatile by nature), so over budget the newest COMPLETE
40
+ * lines win (see tailLines). ≤1000 points is ≤4 KB UTF-8, which together with the answer's byte cap
41
+ * stays inside the 30 KB entity budget. */
42
+ const PROCESS_MAX_POINTS = 1000;
43
+ /** Cap the live answer to the card budget, PREFIX-STABLE: the streaming client animates only when the
44
+ * old text is a prefix of the new, so an over-budget answer freezes at its head rather than sliding a
45
+ * tail window (which would re-type the element every frame). The full answer still lands at settle. */
39
46
  function capBytes(s, maxBytes) {
40
47
  return truncateUtf8(s, maxBytes);
41
48
  }
49
+ /** Tail-select COMPLETE lines within a code-point budget — the process block's cap. The block's
50
+ * lines are semantic units (a `🔧` tool call, the `💭` peek, the `⏳` notice): cutting mid-line
51
+ * would orphan a marker or tear a label, so elision happens only at line boundaries, newest lines
52
+ * kept, with a leading `…` line marking what was dropped. The in-line guard cannot trigger with the
53
+ * bounded renderers (a thinking tail ≤ ~283 points, a tool line ≤ ~135) — it exists so a future
54
+ * unbounded line degrades to a head-preserving cut instead of an empty block. */
55
+ function tailLines(text, maxPoints) {
56
+ if (Array.from(text).length <= maxPoints)
57
+ return text;
58
+ const lines = text.split("\n");
59
+ const kept = [];
60
+ let used = 2; // the leading "…\n" elision marker
61
+ for (let i = lines.length - 1; i >= 0; i--) {
62
+ const line = lines[i] ?? "";
63
+ const cost = Array.from(line).length + (kept.length > 0 ? 1 : 0); // +1 joining newline
64
+ if (used + cost > maxPoints)
65
+ break;
66
+ used += cost;
67
+ kept.unshift(line);
68
+ }
69
+ if (kept.length === 0)
70
+ return truncateCodePointPrefix(lines.at(-1) ?? "", maxPoints);
71
+ return `…\n${kept.join("\n")}`;
72
+ }
42
73
  /**
43
74
  * The terminal-write POLICY: resolve the preview into `text`. One card → settle it in place (final
44
75
  * markdown, streaming off); an over-budget answer settles the card with its first chunk and sends the
@@ -90,9 +121,10 @@ async function finalize(api, target, preview, text, seq) {
90
121
  await api.sendText(target, text);
91
122
  }
92
123
  /**
93
- * Mount one preview message: preferably a streaming card entity, with a static text message as the
94
- * visible fallback. Queue feedback and ordinary turn startup share this constructor so a queued card
95
- * has exactly the same shape the stream pump expects to take over later.
124
+ * Mount one preview message: preferably a streaming card entity (`initial` seeds the process element;
125
+ * the answer element starts empty), with a static text message as the visible fallback. Queue feedback
126
+ * and ordinary turn startup share this constructor so a queued card has exactly the same shape the
127
+ * stream pump expects to take over later.
96
128
  */
97
129
  export async function mountFeishuPreview(api, target, initial, label = "[feishu]") {
98
130
  try {
@@ -151,17 +183,27 @@ export async function settleFeishuPreview(api, target, preview, text) {
151
183
  */
152
184
  export async function streamFeishuReply(events, api, target, formatError, initialPreview, label = "[feishu]") {
153
185
  // Event → view-state reduction is the shared machine (preview-kit); this renderer owns the reveal
154
- // policy, the card-budget cap, and delivery below.
186
+ // policy, the card-budget caps, and delivery below. The card is TWO elements (card.ts): the process
187
+ // block's head changes every frame (sliding thinking tail, `…`→`✓` flips), so it must never share
188
+ // an element with the answer — the client would re-type the whole card from the divergence point
189
+ // once a second. Each view feeds its own element; only the changed one is written.
155
190
  const turn = createTurnView();
156
- const view = () => {
191
+ const processView = () => {
157
192
  const v = composeTurnBody([
158
193
  thinkingLine(turn, THINKING_PREVIEW),
159
194
  toolLines(turn),
160
195
  turn.retrying ? RETRY_NOTICE : "",
161
- revealedAnswer(turn, STREAM_THROTTLE_MS),
162
196
  ]);
163
- return capBytes(v === "" ? THINKING_PLACEHOLDER : v, CARD_MARKDOWN_MAX_BYTES);
197
+ if (v !== "")
198
+ return tailLines(v, PROCESS_MAX_POINTS);
199
+ // No process content: the placeholder covers only the silence BEFORE the answer reveals — once
200
+ // the answer is streaming, an empty block goes (stays) empty; "Thinking…" pinned above a live
201
+ // answer would misstate the phase. The empty frame is a real write: it clears a mounted
202
+ // placeholder. (The block cannot otherwise flicker: thinking and tools only grow — only the
203
+ // retry notice toggles, and its empty state resolves through this same rule.)
204
+ return revealedAnswer(turn, STREAM_THROTTLE_MS).trim() === "" ? THINKING_PLACEHOLDER : "";
164
205
  };
206
+ const answerView = () => capBytes(revealedAnswer(turn, STREAM_THROTTLE_MS), CARD_MARKDOWN_MAX_BYTES);
165
207
  // The live preview is ONE message: either the queue card/text handed in by the wiring, or a preview
166
208
  // mounted lazily on this turn's first flush. `sequence` must increase strictly per card — the single-
167
209
  // writer pump guarantees it by construction. A queue card has had no updates yet, so sequence starts
@@ -172,22 +214,34 @@ export async function streamFeishuReply(events, api, target, formatError, initia
172
214
  const nextSeq = () => ++sequence;
173
215
  let streamDead = false; // the platform closed streaming (idle timeout) — freeze the live view
174
216
  let finalized = false; // a terminal write (completed/failed) ran — the finally skips its orphan cleanup
175
- let lastSent = "";
217
+ let lastProcess = "";
218
+ let lastAnswer = "";
176
219
  const flushPreview = async () => {
177
- const text = view();
220
+ const process = processView();
178
221
  if (!setupAttempted) {
179
222
  setupAttempted = true;
180
- preview = await mountFeishuPreview(api, target, text, label);
181
- lastSent = text;
223
+ // The mount seeds the process element with the current view; the answer element starts empty
224
+ // (card.ts), so the first answer snapshot is a clean prefix extension.
225
+ preview = await mountFeishuPreview(api, target, process, label);
226
+ lastProcess = process;
182
227
  return;
183
228
  }
184
229
  if (preview.kind !== "card" || streamDead)
185
230
  return; // text tier / dead stream: frozen until the terminal write
186
- if (text === lastSent)
187
- return; // skip an unchanged snapshot
188
- lastSent = text;
189
231
  try {
190
- await api.updateCardElement(preview.cardId, ANSWER_ELEMENT_ID, text, nextSeq());
232
+ // `last*` advances BEFORE each write: a frame that fails for a non-streaming reason is logged
233
+ // once (the pump's onError) and not re-sent until its content actually changes. An EMPTY
234
+ // process frame is written like any other — it is the placeholder being cleared (processView).
235
+ if (process !== lastProcess) {
236
+ lastProcess = process;
237
+ await api.updateCardElement(preview.cardId, PROCESS_ELEMENT_ID, process, nextSeq());
238
+ }
239
+ const answer = answerView();
240
+ // Never write an empty answer snapshot — the element is born empty and the answer only grows.
241
+ if (answer !== "" && answer !== lastAnswer) {
242
+ lastAnswer = answer;
243
+ await api.updateCardElement(preview.cardId, ANSWER_ELEMENT_ID, answer, nextSeq());
244
+ }
191
245
  }
192
246
  catch (e) {
193
247
  if (isCardStreamingClosed(e)) {
@@ -56,12 +56,15 @@ async function tenantToken(): Promise<string> {
56
56
 
57
57
  export default defineTool({
58
58
  description:
59
- "Send a message to a Feishu chat: plain `text`, or `markdown` (rendered as a card headings, " +
60
- "bold, code blocks, links). Exactly one of the two. Use it for a turn NO channel is carrying — a " +
61
- "scheduled or self-scheduled (wake) turn — or to reach a chat OTHER than the one you are " +
62
- "answering. In a normal chat turn the channel already delivers your reply, so do NOT call this to " +
63
- "answer (it would post the message twice). chatId comes from the [feishu: chat ] context line in a " +
64
- "chat turn; a scheduled/woken turn has no context line, so name the destination in your instruction.",
59
+ "Send a message to a Feishu chat, OUTSIDE the normal reply path. Call it only for a turn NO " +
60
+ "channel is carrying a scheduled or self-scheduled (wake) turn, whose plain reply goes " +
61
+ "nowhere — or to reach a chat OTHER than the one you are answering. In a normal chat turn the " +
62
+ "channel streams and delivers your reply itself, so do NOT call this to answer the current " +
63
+ "chat: it would post the message twice, outside the conversation thread. `chatId` (oc_) names " +
64
+ "the DESTINATION and must come from your instructions (the asking message, the schedule prompt, " +
65
+ "or memory); the [feishu: chat …] context line only identifies the chat you are answering — the " +
66
+ "one chat this tool must not target in a chat turn. Pass exactly ONE of `text` (plain) or " +
67
+ "`markdown` (rendered as a card: headings, bold, code blocks, links).",
65
68
  input: z.object({
66
69
  chatId: z.string().describe("target chat id (oc_…)"),
67
70
  text: z.string().optional().describe("plain text message to send"),
@@ -56,12 +56,15 @@ async function tenantToken(): Promise<string> {
56
56
 
57
57
  export default defineTool({
58
58
  description:
59
- "Send a message to a Lark chat: plain `text`, or `markdown` (rendered as a card headings, " +
60
- "bold, code blocks, links). Exactly one of the two. Use it for a turn NO channel is carrying — a " +
61
- "scheduled or self-scheduled (wake) turn — or to reach a chat OTHER than the one you are " +
62
- "answering. In a normal chat turn the channel already delivers your reply, so do NOT call this to " +
63
- "answer (it would post the message twice). chatId comes from the [lark: chat ] context line in a " +
64
- "chat turn; a scheduled/woken turn has no context line, so name the destination in your instruction.",
59
+ "Send a message to a Lark chat, OUTSIDE the normal reply path. Call it only for a turn NO " +
60
+ "channel is carrying a scheduled or self-scheduled (wake) turn, whose plain reply goes " +
61
+ "nowhere — or to reach a chat OTHER than the one you are answering. In a normal chat turn the " +
62
+ "channel streams and delivers your reply itself, so do NOT call this to answer the current " +
63
+ "chat: it would post the message twice, outside the conversation thread. `chatId` (oc_) names " +
64
+ "the DESTINATION and must come from your instructions (the asking message, the schedule prompt, " +
65
+ "or memory); the [lark: chat …] context line only identifies the chat you are answering — the " +
66
+ "one chat this tool must not target in a chat turn. Pass exactly ONE of `text` (plain) or " +
67
+ "`markdown` (rendered as a card: headings, bold, code blocks, links).",
65
68
  input: z.object({
66
69
  chatId: z.string().describe("target chat id (oc_…)"),
67
70
  text: z.string().optional().describe("plain text message to send"),
@@ -18,6 +18,7 @@ import { logAgentLoop } from "../../observe.js";
18
18
  import { installProxyFetch } from "../../proxy.js";
19
19
  import { exists } from "../../paths.js";
20
20
  import { bindAddress } from "../../bind.js";
21
+ import { parseRouteKey } from "../../host/node.js";
21
22
  import { failStartup, placementOrExit } from "../fail.js";
22
23
  import { assertTunnelBindable, maybeTunnel, mountAgentcore, mountSessionControl, routesFor, serve, startSchedules, } from "../serve.js";
23
24
  import { parseBind, parsePort, reportAuth, reportLine, resolveFirstRunModel, reportWorkspaceHint } from "../shared.js";
@@ -89,13 +90,21 @@ export async function runStart(dirArg, opts) {
89
90
  const agentcore = process.env.FASTAGENT_AGENTCORE === "1";
90
91
  // Same debug turn trace as dev; gated out here by the info level (see dev.ts serveOnce).
91
92
  const traced = logAgentLoop(agent);
92
- const routed = await routesFor(agentDir, traced, stateRoot, sessionControl, { builtinInvoke: !agentcore }).catch(failStartup);
93
+ // On AgentCore the channels are constructed LAZILY (mountAgentcore's lazyChannels, resolved on the
94
+ // first envelope after the state-snapshot restore): construction loads channel state and replays
95
+ // turn intent, and at boot the state mount is PRE-RESTORE — empty after every version update — so
96
+ // an eager build would cache that emptiness (thread participation, delivery dedup, pending turns)
97
+ // and then clobber the restored files with it. Everywhere else the state root is durable at boot,
98
+ // so channels mount eagerly and a broken channel fails startup.
99
+ const routed = agentcore
100
+ ? undefined
101
+ : await routesFor(agentDir, traced, stateRoot, sessionControl, { builtinInvoke: true }).catch(failStartup);
93
102
  // `http.host` enters here the way the flag enters `parseBind` — through `bindAddress`, so a
94
103
  // configured `localhost` is an ADDRESS by the time anything binds, renders or dials it.
95
104
  const configured = config.http?.host;
96
105
  const host = bindFlag ?? (configured === undefined ? undefined : bindAddress(configured));
97
106
  assertTunnelBindable(host, opts.tunnel ?? false, bindFlag ? "flag" : "config");
98
- const withControl = mountSessionControl(routed.routes, sessionControl, stateRoot, {
107
+ const withControl = mountSessionControl(routed?.routes ?? {}, sessionControl, stateRoot, {
99
108
  tunnel: opts.tunnel ?? false,
100
109
  agent: traced,
101
110
  host,
@@ -126,17 +135,42 @@ export async function runStart(dirArg, opts) {
126
135
  });
127
136
  let routes = withControl.routes;
128
137
  if (agentcore) {
138
+ // The lazy channel surface the adapter resolves post-restore. Control routes ride along so a
139
+ // forwarder-relayed /control/* request dispatches the same as on a direct host. Long-connection
140
+ // channels cannot serve here — scale-to-zero severs a resident connection and nothing
141
+ // re-establishes it — so their presence is a configuration error, surfaced per envelope and by
142
+ // the deploy driver's health probe (there is no boot to fail on this host).
143
+ const lazyChannels = async () => {
144
+ const surface = await routesFor(agentDir, traced, stateRoot, sessionControl, { builtinInvoke: false });
145
+ if (surface.longConnections.length > 0) {
146
+ throw new Error(`long-connection channel(s) ${surface.longConnections.map((c) => c.name).join(", ")} cannot serve on ` +
147
+ `AgentCore (scale-to-zero severs resident connections) — use the channel's webhook form`);
148
+ }
149
+ // mountSessionControl's PATH-level collision rule, re-asserted here: with no channels at boot
150
+ // its own check ran against an empty base, and a spread merge would silently let control win —
151
+ // but a channel on /control/* is the same configuration error it is on every other host.
152
+ const controlPaths = new Set(Object.keys(withControl.routes).map((key) => parseRouteKey(key).path));
153
+ const collisions = Object.keys(surface.routes).filter((key) => controlPaths.has(parseRouteKey(key).path));
154
+ if (collisions.length > 0) {
155
+ throw new Error(`channel route(s) ${collisions.map((key) => `"${key}"`).join(", ")} collide with the session control ` +
156
+ `plane — rename the channel route or disable sessionControl in fastagent.config`);
157
+ }
158
+ return { ...surface.routes, ...withControl.routes };
159
+ };
129
160
  try {
130
- routes = mountAgentcore(routes, { agent: traced, stateRoot, schedules, onStateReady });
161
+ routes = mountAgentcore(routes, { agent: traced, stateRoot, schedules, onStateReady, lazyChannels });
131
162
  }
132
163
  catch (e) {
133
164
  failStartup(e);
134
165
  }
135
166
  log.info(`[fastagent] agentcore: serving POST /invocations + GET /ping (FASTAGENT_AGENTCORE=1)`);
136
167
  }
137
- serve({ ...routed, routes }, { port: portFlag ?? parsePort(process.env.PORT, "PORT env", "env") ?? config.http?.port ?? 8787, host }, (p) => {
168
+ serve({
169
+ ...(routed ?? { longConnections: [], routeChannels: [], builtinInvoke: false, markReady() { } }),
170
+ routes,
171
+ }, { port: portFlag ?? parsePort(process.env.PORT, "PORT env", "env") ?? config.http?.port ?? 8787, host }, (p) => {
138
172
  withControl.announce(p);
139
- maybeTunnel(agentDir, routed.routeChannels, p, opts.tunnel ?? false, stateRoot);
173
+ maybeTunnel(agentDir, routed?.routeChannels ?? [], p, opts.tunnel ?? false, stateRoot);
140
174
  });
141
175
  // No graceful drain: webhook turns run fire-and-forget; SIGTERM just exits mid-turn. Whether an
142
176
  // in-flight turn is LOST depends on the channel: the Telegram channel persists turn intent pre-ACK
@@ -49,6 +49,10 @@ export declare function mountAgentcore(routes: Routes, options: {
49
49
  stateRoot: string;
50
50
  schedules: LoadedSchedule[];
51
51
  onStateReady?: () => void;
52
+ /** The serving path's LAZY channel surface: constructed by the adapter on the first envelope
53
+ * AFTER the state-snapshot restore, never at boot (channels/agentcore.ts). When absent,
54
+ * `routes` is the dispatch target — for wirings whose state root is already authoritative. */
55
+ lazyChannels?: () => Promise<Routes>;
52
56
  }): Routes;
53
57
  /**
54
58
  * Refuse `--tunnel` with a bind that cloudflared cannot reach: it dials the NAME `localhost:<port>`
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,
@@ -396,6 +396,23 @@ exports.handler = async (event, ctx) => {
396
396
  if (failed > 0) return { statusCode: 500, body: \`\${failed} alarm(s) failed\\n\` };
397
397
  return { statusCode: 200, body: "ok\\n" };
398
398
  }
399
+ // The deploy driver's probe (reserved path, ingress secret): wake the runtime through the SAME
400
+ // trusted envelope pipeline (state URLs included — a direct InvokeAgentRuntime call could not mint
401
+ // them, and would make the runtime construct against a pre-restore mount) and pass its structured
402
+ // transport-200 verdict back VERBATIM. The ordinary webhook path below folds a non-200 transport
403
+ // into an opaque 502, which would strip exactly the diagnostics the probe exists to carry — and it
404
+ // sits BEFORE the WEBHOOKS_ENABLED gate so schedule-only topologies (whose URLs refuse ordinary
405
+ // public traffic) are probeable too.
406
+ if (event.rawPath === "/__fastagent/probe") {
407
+ const req = JSON.parse(event.isBase64Encoded ? Buffer.from(event.body, "base64").toString() : event.body || "{}");
408
+ if (!process.env.INGRESS_SECRET || req.auth !== process.env.INGRESS_SECRET) return { statusCode: 403, body: "forbidden\\n" };
409
+ const r = await invoke({ kind: "probe" });
410
+ if (r.status !== 200) {
411
+ console.log(\`probe transport error \${r.status}: \${r.body}\`);
412
+ return { statusCode: 502, body: "upstream error\\n" };
413
+ }
414
+ return { statusCode: 200, headers: { "content-type": "application/json" }, body: r.body.toString() };
415
+ }
399
416
  // Enforce the advertised ORIGINAL-body ceiling before base64 adds another 4/3 inside the runtime
400
417
  // envelope. This also leaves deterministic room for headers/query/JSON under Lambda's 6 MB cap.
401
418
  const webhookBytes = event.body === undefined ? 0
@@ -70,4 +70,10 @@ export declare function parseCheckpointReply(stdout: string): CheckpointReply |
70
70
  * post-deploy webhook steps from the builder machine against the forwarder's Function URL. Every
71
71
  * gate is fail-visible; `writeSecretFile` is the caller's 0600-temp-file seam (see the header).
72
72
  */
73
- export declare function deployAgentcoreRun(plan: AgentcoreRunPlan, aws: CliRunner, docker: CliRunner, log: (msg: string) => void, writeSecretFile: (content: string) => Promise<string>, writeForwarderZip: (bytes: Uint8Array) => Promise<string>, registerTelegram: (baseUrl: string) => Promise<RegistrationOutcome>, registerFeishu?: (baseUrl: string, kind: "feishu" | "lark") => Promise<RegistrationOutcome>, registerSlack?: (baseUrl: string) => Promise<RegistrationOutcome>): Promise<AgentcoreRunOutcome>;
73
+ export declare function deployAgentcoreRun(plan: AgentcoreRunPlan, aws: CliRunner, docker: CliRunner, log: (msg: string) => void, writeSecretFile: (content: string) => Promise<string>, writeForwarderZip: (bytes: Uint8Array) => Promise<string>, registerTelegram: (baseUrl: string) => Promise<RegistrationOutcome>, registerFeishu?: (baseUrl: string, kind: "feishu" | "lark") => Promise<RegistrationOutcome>, registerSlack?: (baseUrl: string) => Promise<RegistrationOutcome>,
74
+ /** Injected in tests; the probe itself stays inside the run so no deploy can skip it. */
75
+ probe?: {
76
+ fetchImpl?: typeof fetch;
77
+ timeoutMs?: number;
78
+ intervalMs?: number;
79
+ }): Promise<AgentcoreRunOutcome>;
@@ -3,6 +3,66 @@ import { createHash } from "node:crypto";
3
3
  import { Buffer } from "node:buffer";
4
4
  import { AUTH_SEED_CHUNK_SIZE, AUTH_SEED_MAX_CHUNKS, cfnParamName, forwarderSource, ingressSessionId, stateBucketName, } from "./plan.js";
5
5
  import { zipSingleFile } from "./zip.js";
6
+ /** How long the post-deploy probe waits for the fresh session (image pull + microVM boot + snapshot
7
+ * restore + channel construction) before gating with the last answer. */
8
+ const PROBE_TIMEOUT_MS = 120_000;
9
+ const PROBE_INTERVAL_MS = 3_000;
10
+ /**
11
+ * Drive the forwarder's reserved `/__fastagent/probe` path until it answers, and read the runtime's
12
+ * STRUCTURED verdict. The path answers on every forwarder topology (a schedule-only URL refuses
13
+ * ordinary public traffic, so a plain `GET /health` would 404 there), and the verdict rides a
14
+ * transport-200 JSON body `{ ok, error? }` — the ordinary webhook relay folds a non-200 transport
15
+ * into an opaque 502, which would strip the very diagnostics this probe exists to carry.
16
+ *
17
+ * Outcome policy: `ok:true` verifies the deploy; `ok:false` gates IMMEDIATELY with the runtime's own
18
+ * error text (construction rejections are cached per session, so polling cannot change the answer);
19
+ * anything else (unroutable URL, forwarder 4xx/5xx, malformed body) is retried to the deadline —
20
+ * that budget's job is absorbing cold-start provisioning — and then gates with the last answer seen.
21
+ */
22
+ async function probeRuntime(probeUrl, auth, fetchImpl, timeoutMs = PROBE_TIMEOUT_MS, intervalMs = PROBE_INTERVAL_MS) {
23
+ const deadline = Date.now() + timeoutMs;
24
+ let last;
25
+ for (;;) {
26
+ try {
27
+ const res = await fetchImpl(probeUrl, {
28
+ method: "POST",
29
+ headers: { "content-type": "application/json" },
30
+ body: JSON.stringify({ auth }),
31
+ signal: AbortSignal.timeout(65_000),
32
+ });
33
+ const bodyText = await res.text();
34
+ if (res.status === 200) {
35
+ let verdict;
36
+ try {
37
+ verdict = JSON.parse(bodyText);
38
+ }
39
+ catch {
40
+ /* malformed — fall through to retry with it as the last answer */
41
+ }
42
+ if (verdict?.ok === true)
43
+ return { ok: true };
44
+ if (verdict?.ok === false) {
45
+ const error = typeof verdict.error === "string" ? verdict.error : "unknown error";
46
+ return { ok: false, gate: `the deployed runtime failed its probe: ${error} — fix and re-run` };
47
+ }
48
+ }
49
+ const firstLine = bodyText.trim().split("\n")[0] ?? "";
50
+ last = `${res.status}${firstLine ? ` ${firstLine}` : ""}`;
51
+ }
52
+ catch {
53
+ /* not routable yet (Function URL DNS, cold start) — keep polling until the deadline */
54
+ }
55
+ if (Date.now() >= deadline) {
56
+ return {
57
+ ok: false,
58
+ gate: last
59
+ ? `the forwarder probe never verified the deployment (last answer: ${last}) — check the runtime logs and re-run`
60
+ : "the forwarder URL never answered the probe — check the Function URL / runtime logs and re-run",
61
+ };
62
+ }
63
+ await new Promise((r) => setTimeout(r, intervalMs));
64
+ }
65
+ }
6
66
  /** Stack outputs (`describe-stacks --query "Stacks[0].Outputs"`) → { OutputKey: OutputValue }. */
7
67
  export function parseStackOutputs(stdout) {
8
68
  try {
@@ -63,7 +123,9 @@ export function parseCheckpointReply(stdout) {
63
123
  * post-deploy webhook steps from the builder machine against the forwarder's Function URL. Every
64
124
  * gate is fail-visible; `writeSecretFile` is the caller's 0600-temp-file seam (see the header).
65
125
  */
66
- export async function deployAgentcoreRun(plan, aws, docker, log, writeSecretFile, writeForwarderZip, registerTelegram, registerFeishu, registerSlack) {
126
+ export async function deployAgentcoreRun(plan, aws, docker, log, writeSecretFile, writeForwarderZip, registerTelegram, registerFeishu, registerSlack,
127
+ /** Injected in tests; the probe itself stays inside the run so no deploy can skip it. */
128
+ probe = {}) {
67
129
  const gate = (g) => ({ ok: false, gate: g });
68
130
  const stack = `fastagent-${plan.name}`;
69
131
  const repo = `fastagent/${plan.name}`;
@@ -359,19 +421,42 @@ export async function deployAgentcoreRun(plan, aws, docker, log, writeSecretFile
359
421
  log("note: no ingress session to stop (first deploy, or already reclaimed)");
360
422
  }
361
423
  else {
362
- log(`warn: could not stop the ingress session an ACTIVE session may keep serving the PREVIOUS ` +
363
- `image until reclaimed (idle timeout / 8 h ceiling). Stop it manually: aws ${stopCommand.join(" ")}`);
424
+ // A GATE, not a warning: the probe below reaches the SAME fixed session id, so a session
425
+ // still running the previous image would answer it and the deploy would claim to have
426
+ // verified a serving path it never touched. Unable to guarantee the session is fresh =
427
+ // unable to verify = stop.
364
428
  const firstLine = stderr.trim().split("\n")[0];
365
- if (firstLine)
366
- log(`warn: ${firstLine}`);
429
+ return gate(`could not stop the ingress session — it may still be serving the PREVIOUS image, so the ` +
430
+ `deploy cannot verify the new one${firstLine ? ` (${firstLine})` : ""}. ` +
431
+ `Stop it manually (aws ${stopCommand.join(" ")}) and re-run`);
367
432
  }
368
433
  }
369
434
  }
435
+ // 8c. Every forwarder topology MUST carry the ForwarderUrl output — schedule-only and
436
+ // selfSchedule-only deployments included, since the probe below is their only construction
437
+ // check (there is no boot-time failStartup on this host). A missing output means an edited
438
+ // template; skipping the probe silently would let such a deploy report success unverified.
439
+ // Only a pure-invoke deployment (no forwarder) legitimately has no URL and nothing to probe.
440
+ // `channels.length` is belt-and-braces: the planner derives needsForwarder FROM the channel
441
+ // list, but this gate must not silently trust that invariant across callers.
442
+ if ((plan.needsForwarder || plan.channels.length > 0) && !url) {
443
+ return gate("this deployment needs the forwarder but the stack has no ForwarderUrl output — regenerate the " +
444
+ "template with --force");
445
+ }
446
+ // 8d. Warm + verify the NEW serving path end to end, BEFORE registration: the probe wakes a fresh
447
+ // session on the new image through the forwarder's reserved path, which restores the state
448
+ // snapshot and constructs the channels — construction is deferred to exactly that moment
449
+ // (channels/agentcore.ts), so this is where a bad credential, a broken channels/ module, or an
450
+ // unrestorable snapshot surfaces AT DEPLOY TIME with the runtime's own error text.
451
+ if (url) {
452
+ log("probing the deployed runtime (state restore + channel construction)…");
453
+ const verdict = await probeRuntime(`${url}/__fastagent/probe`, plan.secrets.FASTAGENT_INGRESS_SECRET ?? "", probe.fetchImpl ?? fetch, probe.timeoutMs, probe.intervalMs);
454
+ if (!verdict.ok)
455
+ return gate(verdict.gate);
456
+ log("runtime verified (state restored, channels constructed)");
457
+ }
370
458
  // 9. Post-deploy webhook registration — same registrar seam as every host, pointed at the
371
459
  // forwarder's Function URL. Gate policy is the shared registration-gate kernel.
372
- if (plan.channels.length > 0 && !url) {
373
- return gate("channels are declared but the stack has no ForwarderUrl output — regenerate the template with --force");
374
- }
375
460
  const reg = registrationGate(log, "re-run to retry registration (steps already done are skipped)");
376
461
  if (url) {
377
462
  if (plan.channels.includes("telegram")) {
@@ -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] : [])];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fastagent-sh/fastagent",
3
- "version": "0.17.0",
3
+ "version": "0.17.1",
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",