@fastagent-sh/fastagent 0.19.0 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. package/dist/atomic-write.d.ts +11 -7
  2. package/dist/atomic-write.js +16 -11
  3. package/dist/channels/agentcore-limits.d.ts +9 -0
  4. package/dist/channels/agentcore-limits.js +9 -0
  5. package/dist/channels/agentcore-service.d.ts +12 -11
  6. package/dist/channels/agentcore-service.js +14 -18
  7. package/dist/channels/agentcore.d.ts +11 -19
  8. package/dist/channels/agentcore.js +14 -7
  9. package/dist/channels/control.d.ts +50 -12
  10. package/dist/channels/control.js +239 -96
  11. package/dist/channels/discover.d.ts +26 -4
  12. package/dist/channels/discover.js +12 -23
  13. package/dist/channels/feishu/feishu-api.js +5 -8
  14. package/dist/channels/feishu/feishu.js +35 -18
  15. package/dist/channels/kit/attachment-path.d.ts +12 -0
  16. package/dist/channels/kit/attachment-path.js +43 -0
  17. package/dist/channels/kit/context-buffer.d.ts +9 -0
  18. package/dist/channels/kit/context-buffer.js +11 -0
  19. package/dist/channels/kit/signature.d.ts +12 -0
  20. package/dist/channels/kit/signature.js +17 -0
  21. package/dist/channels/kit/stop-command.js +3 -3
  22. package/dist/channels/kit/tasks.d.ts +1 -1
  23. package/dist/channels/kit/tasks.js +10 -4
  24. package/dist/channels/kit/turn-store.d.ts +19 -3
  25. package/dist/channels/kit/turn-store.js +29 -2
  26. package/dist/channels/slack/invoke-turn.d.ts +1 -1
  27. package/dist/channels/slack/invoke-turn.js +5 -1
  28. package/dist/channels/slack/onboard.js +5 -5
  29. package/dist/channels/slack/onboarding-state.d.ts +7 -3
  30. package/dist/channels/slack/onboarding-state.js +13 -20
  31. package/dist/channels/slack/register-webhook.js +1 -1
  32. package/dist/channels/slack/scaffold/slack-send.ts +2 -17
  33. package/dist/channels/slack/slack-api.js +22 -54
  34. package/dist/channels/slack/slack.js +13 -13
  35. package/dist/channels/telegram/parse.d.ts +21 -1
  36. package/dist/channels/telegram/parse.js +65 -11
  37. package/dist/channels/telegram/scaffold/channel.ts +7 -3
  38. package/dist/channels/telegram/telegram-api.js +5 -6
  39. package/dist/channels/telegram/telegram.d.ts +2 -2
  40. package/dist/channels/telegram/telegram.js +57 -69
  41. package/dist/cli/add-slack.js +6 -5
  42. package/dist/cli/commands/add.js +1 -5
  43. package/dist/cli/commands/attach.d.ts +2 -4
  44. package/dist/cli/commands/attach.js +17 -15
  45. package/dist/cli/commands/deploy.d.ts +50 -5
  46. package/dist/cli/commands/deploy.js +183 -134
  47. package/dist/cli/commands/dev.js +4 -31
  48. package/dist/cli/commands/info.js +1 -2
  49. package/dist/cli/commands/init.js +1 -2
  50. package/dist/cli/commands/start.js +31 -41
  51. package/dist/cli/serve.d.ts +12 -21
  52. package/dist/cli/serve.js +20 -25
  53. package/dist/cli/shared.d.ts +40 -10
  54. package/dist/cli/shared.js +51 -4
  55. package/dist/deploy/agentcore/logs.d.ts +2 -2
  56. package/dist/deploy/agentcore/logs.js +2 -2
  57. package/dist/deploy/agentcore/plan.d.ts +12 -10
  58. package/dist/deploy/agentcore/plan.js +39 -31
  59. package/dist/deploy/agentcore/run.d.ts +5 -4
  60. package/dist/deploy/agentcore/run.js +13 -36
  61. package/dist/deploy/channel-ingress.d.ts +73 -0
  62. package/dist/deploy/channel-ingress.js +101 -0
  63. package/dist/deploy/docker/plan.d.ts +3 -7
  64. package/dist/deploy/docker/plan.js +4 -15
  65. package/dist/deploy/docker/run.d.ts +18 -1
  66. package/dist/deploy/docker/run.js +10 -0
  67. package/dist/deploy/fly/plan.d.ts +4 -5
  68. package/dist/deploy/fly/plan.js +7 -22
  69. package/dist/deploy/fly/run.d.ts +9 -23
  70. package/dist/deploy/fly/run.js +32 -68
  71. package/dist/deploy/preflight.d.ts +6 -7
  72. package/dist/deploy/preflight.js +26 -22
  73. package/dist/deploy/railway/plan.d.ts +4 -5
  74. package/dist/deploy/railway/plan.js +11 -26
  75. package/dist/deploy/railway/run.d.ts +9 -9
  76. package/dist/deploy/railway/run.js +40 -40
  77. package/dist/deploy/runner.js +22 -2
  78. package/dist/deploy/secrets.d.ts +18 -9
  79. package/dist/deploy/secrets.js +64 -12
  80. package/dist/dev-supervisor.js +3 -4
  81. package/dist/engines/pi/agent-session-factory.js +20 -39
  82. package/dist/engines/pi/auth.js +43 -43
  83. package/dist/engines/pi/config.d.ts +1 -1
  84. package/dist/engines/pi/create.d.ts +14 -2
  85. package/dist/engines/pi/create.js +2 -2
  86. package/dist/engines/pi/definition.d.ts +1 -1
  87. package/dist/engines/pi/definition.js +3 -4
  88. package/dist/engines/pi/login.js +32 -16
  89. package/dist/engines/pi/open.d.ts +1 -2
  90. package/dist/engines/pi/open.js +1 -1
  91. package/dist/engines/pi/search-tools.js +1 -1
  92. package/dist/engines/pi/service.d.ts +1 -1
  93. package/dist/engines/pi/service.js +8 -0
  94. package/dist/engines/pi/session-builder.js +6 -33
  95. package/dist/engines/pi/session-control.d.ts +19 -6
  96. package/dist/engines/pi/session-control.js +565 -470
  97. package/dist/engines/pi/session-inheritance.d.ts +8 -22
  98. package/dist/engines/pi/session-inheritance.js +95 -76
  99. package/dist/engines/pi/session-markers.d.ts +48 -0
  100. package/dist/engines/pi/session-markers.js +59 -0
  101. package/dist/engines/pi/session-settings.d.ts +4 -4
  102. package/dist/engines/pi/session-settings.js +7 -4
  103. package/dist/engines/pi/session-store.d.ts +83 -13
  104. package/dist/engines/pi/session-store.js +408 -55
  105. package/dist/engines/pi/tool-context.d.ts +21 -8
  106. package/dist/engines/pi/tool-context.js +29 -4
  107. package/dist/engines/pi/tool.js +2 -2
  108. package/dist/env.js +1 -2
  109. package/dist/loader.d.ts +49 -7
  110. package/dist/loader.js +79 -18
  111. package/dist/paths.d.ts +19 -0
  112. package/dist/paths.js +27 -2
  113. package/dist/scaffold/add-channel.js +2 -2
  114. package/dist/scaffold/init.js +7 -2
  115. package/dist/scaffold/templates/tools/fetch-url.ts +0 -2
  116. package/dist/schedule/discover.js +3 -15
  117. package/dist/schedule/wake-alarm.d.ts +13 -7
  118. package/dist/schedule/wake-alarm.js +88 -46
  119. package/dist/schedule/wakeups.d.ts +1 -1
  120. package/dist/schedule/wakeups.js +10 -7
  121. package/dist/service.d.ts +1 -2
  122. package/dist/service.js +27 -5
  123. package/dist/session-remote.d.ts +7 -2
  124. package/dist/session-remote.js +162 -92
  125. package/dist/session.d.ts +226 -92
  126. package/dist/session.js +61 -23
  127. package/dist/telegram.d.ts +1 -1
  128. package/dist/telegram.js +1 -1
  129. package/dist/tunnel.d.ts +18 -3
  130. package/dist/tunnel.js +32 -40
  131. package/package.json +1 -1
@@ -15,9 +15,11 @@ import { text } from "../respond.js";
15
15
  import { createSeenRing } from "../kit/seen.js";
16
16
  import { createTaskTracker } from "../kit/tasks.js";
17
17
  import { ensureStateHome, loadStateFile, saveStateFile } from "../kit/state.js";
18
+ import { signatureIsFresh } from "../kit/signature.js";
18
19
  import { dispatchStop, isStopText } from "../kit/stop-command.js";
19
20
  import { createTurnQueue } from "../kit/turn-queue.js";
20
- import { createTurnStore } from "../kit/turn-store.js";
21
+ import { commitAnsweredTurn, createTurnStore } from "../kit/turn-store.js";
22
+ import { discussionBlock } from "../kit/context-buffer.js";
21
23
  import { FEISHU_CLOUD } from "./cloud.js";
22
24
  import { collectFeishuBufferedAttachments, createFeishuContextBuffer, feishuBufferPlaceKey, feishuBufferText, } from "./context-buffer.js";
23
25
  import { decryptEvent, timingSafeEqualStr, verifySignature } from "./crypto.js";
@@ -31,12 +33,17 @@ import { defaultErrorMessage, mountFeishuPreview, settleFeishuPreview, streamFei
31
33
  import { connectFeishuWs } from "./ws-ingress.js";
32
34
  // Canonical public surface; the Lark subpath aliases these types/functions at its compatibility boundary.
33
35
  export { defaultFeishuRoute, feishuEnvelope };
34
- /** Execution ceiling: a turn that has STARTED running this many times without finishing is dropped
35
- * rather than run again (a poison turn must not loop forever under a restart policy). Counted per turn
36
- * at dequeue, so a never-run turn queued behind a poison one keeps its full budget. */
37
- const MAX_TURN_ATTEMPTS = 3;
38
36
  /** Event body cap — events are small JSON; 1 MiB is generous and guards a public endpoint. */
39
37
  const MAX_EVENT_BYTES = 1 << 20;
38
+ /**
39
+ * Replay window for a SIGNED event, sized by the OPEN PLATFORM'S REDELIVERY SCHEDULE, not by Slack's
40
+ * 5 minutes: a failed push is retried at 15s / 5min / 1h / 6h. It is not established whether a retry
41
+ * is re-signed with a fresh timestamp or replays the original one, and only the second case is safe
42
+ * to guess wrong about in one direction — a window under 6h would 401 three of the four retries, i.e.
43
+ * turn one transient fault into a permanently lost user message (and a callback-health alarm). Wide
44
+ * enough to cover the chain, still bounded, and the `seen` ring dedups an event_id inside it.
45
+ */
46
+ const MAX_SIGNATURE_AGE_S = 7 * 60 * 60;
40
47
  /** Queue feedback is immediate by default: it is the user's acknowledgement that this exact ask was
41
48
  * accepted behind another turn. The same reply-quoted card becomes the preview/final answer, so there
42
49
  * is no extra message or recall tombstone to avoid. Authors may still configure a delay explicitly. */
@@ -220,7 +227,7 @@ function createFeishuRuntimeFactory(profile, opts, factoryName) {
220
227
  });
221
228
  const seen = createSeenRing(join(stateHome, "seen.json"), label);
222
229
  // Side tasks (stop feedback) run off the ingress path but drain in turnsIdle.
223
- const sideTasks = createTaskTracker();
230
+ const sideTasks = createTaskTracker(label);
224
231
  const toStored = (r) => {
225
232
  const { preview: _live, ...intent } = r; // drop the live-only field; TS enforces the rest is complete
226
233
  return { ...intent, attempts: 0 };
@@ -285,7 +292,7 @@ function createFeishuRuntimeFactory(profile, opts, factoryName) {
285
292
  await notice?.done;
286
293
  notices.delete(rec.id);
287
294
  // Count this execution against the durable record (poison-turn ceiling) before running it again.
288
- const decision = store.startAttempt(rec.id, MAX_TURN_ATTEMPTS);
295
+ const decision = store.startAttempt(rec.id);
289
296
  if (decision === "exceeded") {
290
297
  notifyDropped(rec);
291
298
  return;
@@ -312,8 +319,7 @@ function createFeishuRuntimeFactory(profile, opts, factoryName) {
312
319
  const roomBlock = room?.text
313
320
  ? `[recent discussion in the room this thread branched from — not yet answered there:\n${room.text}\n]\n\n`
314
321
  : "";
315
- const threadBlock = recent ? `[recent group discussion:\n${recent}\n]\n\n` : "";
316
- const prompt = `${roomBlock}${threadBlock}${rec.baseText}`;
322
+ const prompt = `${roomBlock}${discussionBlock(recent)}${rec.baseText}`;
317
323
  // Room entries FIRST: the collector keeps the TAIL under its cap, so the thread's own
318
324
  // attachments win the slots.
319
325
  const buffered = collectFeishuBufferedAttachments([...(room?.consumed ?? []), ...consumed], {
@@ -331,12 +337,7 @@ function createFeishuRuntimeFactory(profile, opts, factoryName) {
331
337
  label,
332
338
  appId,
333
339
  ...(parentSession !== undefined ? { parentSession } : {}),
334
- }, { primary: { images: rec.images, files: rec.files, parentId: rec.parentId }, buffered }, () => {
335
- // Drop intent first: a crash between these writes may re-fold answered context later,
336
- // but can never replay this turn after its context was removed.
337
- store.remove(rec.id);
338
- buffer.commit(rec.bufferKey, consumed);
339
- }), api, targetOf(rec), formatError, rec.preview, label);
340
+ }, { primary: { images: rec.images, files: rec.files, parentId: rec.parentId }, buffered }, () => commitAnsweredTurn(store, buffer, { id: rec.id, bufferKey: rec.bufferKey, consumed })), api, targetOf(rec), formatError, rec.preview, label);
340
341
  log.info(`${label} turn done: turn=${rec.id} session=${rec.session} (${Date.now() - startedAt}ms)`);
341
342
  }
342
343
  catch (error) {
@@ -583,6 +584,12 @@ function createFeishuWebhookRoutes(profile, opts, runtime) {
583
584
  const { verificationToken, encryptKey } = opts;
584
585
  const { kind, envPrefix } = profile;
585
586
  const label = `[${kind}]`;
587
+ if (!encryptKey) {
588
+ // Said once at wiring, not per request: without an encrypt key events arrive in plaintext and
589
+ // carry no signature, so the freshness window below never runs and a captured body replays for
590
+ // as long as the verification token lives.
591
+ log.warn(`${label} no ${envPrefix}_ENCRYPT_KEY: events are accepted unsigned, with no replay window — set one in the console to enable it`);
592
+ }
586
593
  const handler = async (req) => {
587
594
  if (req.method !== "POST")
588
595
  return text("POST only\n", 405);
@@ -609,9 +616,19 @@ function createFeishuWebhookRoutes(profile, opts, runtime) {
609
616
  nonce: req.headers.get("x-lark-request-nonce") ?? "",
610
617
  signature: req.headers.get("x-lark-signature") ?? "",
611
618
  };
612
- if (sig.signature && !verifySignature(encryptKey, sig, body.text)) {
613
- log.warn(`${label} rejected an event: invalid X-Lark-Signature (encrypt key mismatch, or a forgery)`);
614
- return text("invalid signature\n", 401);
619
+ if (sig.signature) {
620
+ // Freshness BEFORE the signature: the signature covers the timestamp but proves nothing about
621
+ // it, so without a window a captured body + its three x-lark-* headers replays forever. The
622
+ // `seen` ring is not that defence — it is bounded, and past its rollover a replay re-runs the
623
+ // turn (a re-sent message, a re-fired tool).
624
+ if (!signatureIsFresh(sig.timestamp, MAX_SIGNATURE_AGE_S)) {
625
+ log.warn(`${label} rejected an event: X-Lark-Request-Timestamp outside the ±${MAX_SIGNATURE_AGE_S / 3600} h replay window`);
626
+ return text("stale signature\n", 401);
627
+ }
628
+ if (!verifySignature(encryptKey, sig, body.text)) {
629
+ log.warn(`${label} rejected an event: invalid X-Lark-Signature (encrypt key mismatch, or a forgery)`);
630
+ return text("invalid signature\n", 401);
631
+ }
615
632
  }
616
633
  try {
617
634
  envelope = JSON.parse(decryptEvent(encryptKey, outer.encrypt));
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Attachment destination for one conversation. Never rejects an id — but the path it returns is not
3
+ * promised to be one the filesystem accepts: percent-encoding spends up to 9 bytes per character, so
4
+ * an id of ~29 CJK characters exceeds a 255-byte `NAME_MAX` and the caller's `mkdir` fails with
5
+ * `ENAMETOOLONG`. Left to fail there rather than truncated here: a digest suffix would buy a length
6
+ * no platform id approaches, at the cost of the readability this encoding exists to keep.
7
+ */
8
+ export declare function attachmentPath(filesDir: string, conversationId: string | number, fileName: string): {
9
+ dir: string;
10
+ name: string;
11
+ path: string;
12
+ };
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Where an inbound attachment lands: `<filesDir>/<conversation>/<file>`.
3
+ *
4
+ * Both halves come from outside the channel, and they are NOT the same question:
5
+ *
6
+ * - The conversation is an id, and turning an arbitrary caller id into a storage name is a job this
7
+ * repo already answers by ENCODING it, never by rejecting it (`piSessionId` in
8
+ * engines/pi/session-store.ts, same shape: a character whitelist under a fixed prefix). An id is
9
+ * the caller's — a route returning `../..` gets an odd directory, not a failed turn, because the
10
+ * place to notice that id is broken is the reply it cannot deliver, not the disk.
11
+ * - The file name is not an id: it is read by a human and by the model, in a path this puts into the
12
+ * prompt. Encoding it would cost that, so it is reduced instead — losing the odd character rather
13
+ * than the readability. A leading dot survives: `.gitignore` is a name, not an escape, and nothing
14
+ * lists this directory, so hiding is not a property anyone here depends on.
15
+ *
16
+ * The encoding makes containment structural rather than checked: `encodeURIComponent` emits no path
17
+ * separator (`/` → `%2F`, `\` → `%5C`, `:` → `%3A`), and the prefix leaves no way to spell `.`, `..`
18
+ * or the empty string.
19
+ *
20
+ * It is lossless for a well-formed id, so `a/b` and `a_b` are different places. Two exceptions, both
21
+ * outside what a platform hands over: ids differing only in malformed UTF-16 (see the substitution
22
+ * below), and ids differing only in case on a case-insensitive filesystem.
23
+ */
24
+ import { resolve, sep } from "node:path";
25
+ /**
26
+ * Attachment destination for one conversation. Never rejects an id — but the path it returns is not
27
+ * promised to be one the filesystem accepts: percent-encoding spends up to 9 bytes per character, so
28
+ * an id of ~29 CJK characters exceeds a 255-byte `NAME_MAX` and the caller's `mkdir` fails with
29
+ * `ENAMETOOLONG`. Left to fail there rather than truncated here: a digest suffix would buy a length
30
+ * no platform id approaches, at the cost of the readability this encoding exists to keep.
31
+ */
32
+ export function attachmentPath(filesDir, conversationId, fileName) {
33
+ // A lone surrogate makes `encodeURIComponent` throw `URIError`, and this function rejecting an id
34
+ // is the one thing it must not do — a permanent bad id would surface as a retryable turn failure.
35
+ // The `u` flag matches by code point, so a valid pair (an emoji in a route's id) is left alone.
36
+ const id = String(conversationId).replace(/\p{Surrogate}/gu, "\uFFFD");
37
+ const dir = resolve(filesDir, `c-${encodeURIComponent(id)}`);
38
+ // The name keeps its own check because it is NOT encoded: separators go, and `resolve` settles
39
+ // whether what is left (`..`, a Windows `D:foo`) still lands in `dir`.
40
+ const cleaned = fileName.replace(/[/\\]/g, "_");
41
+ const name = resolve(dir, cleaned).startsWith(dir + sep) ? cleaned : "file";
42
+ return { dir, name, path: resolve(dir, name) };
43
+ }
@@ -9,6 +9,15 @@ export declare const BUFFER_LINE_MAX_CHARS = 280;
9
9
  * note, so the model never sees an attachment reference it silently cannot open. Shared policy:
10
10
  * each channel's attachment collector caps against this. */
11
11
  export declare const BUFFER_ATTACH_MAX = 3;
12
+ /**
13
+ * The folded discussion as it reaches the model — the prompt block, or nothing when the buffer is
14
+ * empty. One renderer for every channel: what the agent is told about un-summoned discussion should
15
+ * not depend on which chat platform delivered it, and three copies of the literal is how that drifts.
16
+ *
17
+ * A channel that folds a SECOND source (feishu's originating room) labels that one itself — it is a
18
+ * different claim about a different place, not this block with another name.
19
+ */
20
+ export declare function discussionBlock(text: string): string;
12
21
  export interface ContextBuffer<E> {
13
22
  /** Record an un-summoned message. Persists BEFORE returning (pre-ACK; see the module header). */
14
23
  push(placeKey: string, entry: E): void;
@@ -39,6 +39,17 @@ export const BUFFER_LINE_MAX_CHARS = 280;
39
39
  * note, so the model never sees an attachment reference it silently cannot open. Shared policy:
40
40
  * each channel's attachment collector caps against this. */
41
41
  export const BUFFER_ATTACH_MAX = 3;
42
+ /**
43
+ * The folded discussion as it reaches the model — the prompt block, or nothing when the buffer is
44
+ * empty. One renderer for every channel: what the agent is told about un-summoned discussion should
45
+ * not depend on which chat platform delivered it, and three copies of the literal is how that drifts.
46
+ *
47
+ * A channel that folds a SECOND source (feishu's originating room) labels that one itself — it is a
48
+ * different claim about a different place, not this block with another name.
49
+ */
50
+ export function discussionBlock(text) {
51
+ return text ? `[recent group discussion:\n${text}\n]\n\n` : "";
52
+ }
42
53
  export function createContextBuffer(options) {
43
54
  const { path, label, isEntry, line } = options;
44
55
  const load = () => {
@@ -0,0 +1,12 @@
1
+ /**
2
+ * The replay window every signed webhook ingress needs. A signature covers its timestamp but proves
3
+ * nothing about it, so without a window a captured body plus its signed headers replays forever.
4
+ * The window LENGTH is the caller's: it is set by the platform's own redelivery schedule, not by us.
5
+ */
6
+ /**
7
+ * Whether a Unix-SECONDS timestamp header is within `maxAgeS` of now, in either direction (a clock
8
+ * ahead of ours is as suspect as one behind). Non-numeric is not fresh: the header is part of the
9
+ * signed material, so a value the signature commits to but this cannot read is a reason to refuse,
10
+ * not to wave through.
11
+ */
12
+ export declare function signatureIsFresh(timestamp: string, maxAgeS: number, nowMs?: number): boolean;
@@ -0,0 +1,17 @@
1
+ /**
2
+ * The replay window every signed webhook ingress needs. A signature covers its timestamp but proves
3
+ * nothing about it, so without a window a captured body plus its signed headers replays forever.
4
+ * The window LENGTH is the caller's: it is set by the platform's own redelivery schedule, not by us.
5
+ */
6
+ /**
7
+ * Whether a Unix-SECONDS timestamp header is within `maxAgeS` of now, in either direction (a clock
8
+ * ahead of ours is as suspect as one behind). Non-numeric is not fresh: the header is part of the
9
+ * signed material, so a value the signature commits to but this cannot read is a reason to refuse,
10
+ * not to wave through.
11
+ */
12
+ export function signatureIsFresh(timestamp, maxAgeS, nowMs = Date.now()) {
13
+ if (!/^\d+$/.test(timestamp))
14
+ return false;
15
+ const seconds = Number(timestamp);
16
+ return Number.isSafeInteger(seconds) && Math.abs(Math.floor(nowMs / 1000) - seconds) <= maxAgeS;
17
+ }
@@ -21,16 +21,16 @@ export async function dispatchStop(control, session, label) {
21
21
  if (!control)
22
22
  return STOP_UNAVAILABLE_NOTICE;
23
23
  try {
24
- const result = await control.dispatch(session, { type: "abort" });
24
+ const result = await control.sessions.get(session).abort();
25
25
  if (result.ok)
26
26
  return STOPPED_NOTICE;
27
27
  if (result.error.code === NO_ACTIVE_RUN_CODE)
28
28
  return NOTHING_RUNNING_NOTICE;
29
- log.warn(`${label} stop dispatch rejected for ${session}: ${result.error.code} — ${result.error.message}`);
29
+ log.warn(`${label} stop rejected for ${session}: ${result.error.code} — ${result.error.message}`);
30
30
  return `⚠️ Could not stop (${result.error.code}).`;
31
31
  }
32
32
  catch (error) {
33
- log.warn(`${label} stop dispatch failed for ${session}: ${String(error)}`);
33
+ log.warn(`${label} stop failed for ${session}: ${String(error)}`);
34
34
  return "⚠️ Could not stop — see the server logs.";
35
35
  }
36
36
  }
@@ -4,4 +4,4 @@ export interface TaskTracker {
4
4
  /** Resolves when every currently-tracked task has settled. */
5
5
  drain(): Promise<void>;
6
6
  }
7
- export declare function createTaskTracker(): TaskTracker;
7
+ export declare function createTaskTracker(label: string): TaskTracker;
@@ -2,10 +2,16 @@
2
2
  * SHARED fire-and-forget side-task tracking. Channels launch work off the request path (stop
3
3
  * feedback, DM welcomes) that must not block the transport ACK but MUST be drained on shutdown
4
4
  * (`turnsIdle`) — otherwise a reply in flight when the process exits is silently dropped. Error
5
- * handling stays with the caller: track() only guarantees the drain sees the task settle.
5
+ * handling stays with the caller: track() only guarantees the drain sees the task SETTLE, and settle
6
+ * includes reject. A caller that handles its error on a separate branch (`p.catch(log); track(p)`)
7
+ * still hands us a promise that rejects, and a drain that propagated it would fail the channel's whole
8
+ * `turnsIdle` over one side task. A rejection that reaches us is logged — we
9
+ * cannot tell a missing `.catch` from one on a separate branch, so the line is a visibility floor
10
+ * rather than a diagnosis, and without it a dropped side task leaves no trace anywhere.
6
11
  */
7
12
  import { beginWork } from "../busy.js";
8
- export function createTaskTracker() {
13
+ import { log } from "../../log.js";
14
+ export function createTaskTracker(label) {
9
15
  const tasks = new Set();
10
16
  return {
11
17
  track(task) {
@@ -18,8 +24,8 @@ export function createTaskTracker() {
18
24
  workDone();
19
25
  tasks.delete(task);
20
26
  })
21
- .catch(() => { }); // the caller's chain owns the error
27
+ .catch((error) => log.warn(`${label} side task rejected: ${String(error)}`));
22
28
  },
23
- drain: () => Promise.all(tasks).then(() => undefined),
29
+ drain: () => Promise.allSettled(tasks).then(() => undefined),
24
30
  };
25
31
  }
@@ -1,3 +1,4 @@
1
+ import type { ContextBuffer } from "./context-buffer.ts";
1
2
  /** What every persisted turn record carries regardless of channel: identity, the session whose FIFO
2
3
  * chain it runs on, and how many times it has STARTED executing without finishing (0 until its first
3
4
  * run; bumped at each `startAttempt`). The channel's record type extends this with everything its
@@ -20,14 +21,14 @@ export interface TurnStore<T extends TurnRecordBase> {
20
21
  recover(): T[];
21
22
  /** Called when a turn is about to RUN (dequeued). Returns:
22
23
  * - "run": bumped its persisted execution count; go ahead.
23
- * - "exceeded": over `maxAttempts` starts without finishing (killed mid-run every time, whatever the
24
- * cause); the record is dropped and the runner notifies the asker.
24
+ * - "exceeded": over {@link MAX_TURN_ATTEMPTS} starts without finishing (killed mid-run every time,
25
+ * whatever the cause); the record is dropped and the runner notifies the asker.
25
26
  * - "defer": the bump could not be persisted — skip this cycle (fail closed: an unpersisted count
26
27
  * would let a poison turn re-run forever); the record stays on disk and replays on the next start
27
28
  * (a restart is required — disk recovery alone does not re-run it). The runner does NOT notify.
28
29
  * An id with no record returns "run" (untracked): a completed turn's `remove` cleared it, so the
29
30
  * redelivery-double-run tail (see the header's pre-ACK window) lands here. */
30
- startAttempt(id: string, maxAttempts: number): "run" | "exceeded" | "defer";
31
+ startAttempt(id: string): "run" | "exceeded" | "defer";
31
32
  }
32
33
  export interface TurnStoreOptions<T extends TurnRecordBase> {
33
34
  /** Log prefix naming the consumer (e.g. "[telegram]") — the store itself is channel-neutral. */
@@ -39,4 +40,19 @@ export interface TurnStoreOptions<T extends TurnRecordBase> {
39
40
  * (telegram: numeric update_id; lark: an explicit per-record seq). */
40
41
  order: (a: T, b: T) => number;
41
42
  }
43
+ /**
44
+ * End an ANSWERED turn: drop its durable intent, then commit the discussion it folded in.
45
+ *
46
+ * The ORDER is the safety property, which is why this is a function and not two lines at each call
47
+ * site. A crash between the two writes may re-fold already-answered context into the next summon —
48
+ * additive and harmless. The reverse order leaves intent on disk with its context already consumed,
49
+ * so the replay runs the same turn with its context stripped.
50
+ *
51
+ * Called from the turn's `completed` event, when the fold provably lives in the durable session.
52
+ */
53
+ export declare function commitAnsweredTurn<T extends TurnRecordBase, E>(store: TurnStore<T>, buffer: ContextBuffer<E>, turn: {
54
+ id: string;
55
+ bufferKey: string;
56
+ consumed: E[];
57
+ }): void;
42
58
  export declare function createTurnStore<T extends TurnRecordBase>(path: string, opts: TurnStoreOptions<T>): TurnStore<T>;
@@ -51,6 +51,33 @@
51
51
  */
52
52
  import { log } from "../../log.js";
53
53
  import { loadStateFile, saveStateFile } from "./state.js";
54
+ /**
55
+ * How many times a turn may START without finishing before it is dropped rather than run again — the
56
+ * poison-turn ceiling described at length above. One value for every channel: it prices replay against
57
+ * DEPLOY frequency, which is a property of how fastagent is operated, not of which chat platform is in
58
+ * front of it.
59
+ *
60
+ * Known limitation: the count cannot tell a self-inflicted crash from an external SIGTERM (there is no
61
+ * graceful drain), so a legitimately long turn interrupted by this many successive deploys is dropped
62
+ * as if it were poison. Three is a bet that such a turn is an outlier, not a defence against one:
63
+ * catching SIGTERM to spare it would reintroduce the drain the design refuses — raise this constant
64
+ * instead if such turns are expected.
65
+ */
66
+ const MAX_TURN_ATTEMPTS = 3;
67
+ /**
68
+ * End an ANSWERED turn: drop its durable intent, then commit the discussion it folded in.
69
+ *
70
+ * The ORDER is the safety property, which is why this is a function and not two lines at each call
71
+ * site. A crash between the two writes may re-fold already-answered context into the next summon —
72
+ * additive and harmless. The reverse order leaves intent on disk with its context already consumed,
73
+ * so the replay runs the same turn with its context stripped.
74
+ *
75
+ * Called from the turn's `completed` event, when the fold provably lives in the durable session.
76
+ */
77
+ export function commitAnsweredTurn(store, buffer, turn) {
78
+ store.remove(turn.id);
79
+ buffer.commit(turn.bufferKey, turn.consumed);
80
+ }
54
81
  export function createTurnStore(path, opts) {
55
82
  const { label, isRecord, order } = opts;
56
83
  const load = () => {
@@ -102,12 +129,12 @@ export function createTurnStore(path, opts) {
102
129
  // happening to survive the load's JSON round-trip.
103
130
  return [...turns.values()].sort(order);
104
131
  },
105
- startAttempt(id, maxAttempts) {
132
+ startAttempt(id) {
106
133
  const rec = turns.get(id);
107
134
  if (!rec)
108
135
  return "run"; // no record — run untracked (a redelivery double-run whose first run removed it)
109
136
  const attempts = rec.attempts + 1;
110
- if (attempts > maxAttempts) {
137
+ if (attempts > MAX_TURN_ATTEMPTS) {
111
138
  // State the fact, not a cause the counter can't prove: a turn killed mid-run every time bumps
112
139
  // this whether IT poisoned the process or a deploy/OOM took it down each time.
113
140
  log.error(`${label} dropping turn ${id} after starting ${rec.attempts} time(s) without finishing ` +
@@ -2,7 +2,7 @@
2
2
  import type { Agent, AgentEvent } from "../../agent.ts";
3
3
  import { type BusyRetry } from "../kit/invoke-turn-kit.ts";
4
4
  import type { SlackBufferedFileRef } from "./context-buffer.ts";
5
- import type { SlackApi } from "./slack-api.ts";
5
+ import { type SlackApi } from "./slack-api.ts";
6
6
  export interface SlackTurnTransport {
7
7
  api: SlackApi;
8
8
  channelId: string;
@@ -1,5 +1,6 @@
1
1
  import { log } from "../../log.js";
2
2
  import { DEFAULT_BUSY_RETRY, attachedFilesManifest, attributedFileName, backgroundImagesManifest, missingAttachmentsNote, streamTurnWithBusyRetry, } from "../kit/invoke-turn-kit.js";
3
+ import { SlackApiError } from "./slack-api.js";
3
4
  const MARKDOWN_INSTRUCTION = "\n\n(Format your reply as standard Markdown. Slack renders it natively. Do not use HTML or Slack control-mention syntax such as <!here>, <!channel>, or <!everyone>.)";
4
5
  async function resolveFile(transport, fileId) {
5
6
  const info = await transport.api.fileInfo(fileId);
@@ -58,7 +59,10 @@ export async function* invokeSlackTurn(agent, session, text, transport, attachme
58
59
  resolved = await resolveInputs(transport, attachments);
59
60
  }
60
61
  catch (error) {
61
- yield { type: "failed", details: `could not load Slack attachment: ${String(error)}`, retryable: true };
62
+ // transient (network, exhausted 429 retries, Slack 5xx) is worth re-sending; an access or shape
63
+ // error reads the same every time, and "try again in a moment" is the wrong thing to tell the user
64
+ const retryable = error instanceof SlackApiError && (error.status === 0 || error.status === 429 || error.status >= 500);
65
+ yield { type: "failed", details: `could not load Slack attachment: ${String(error)}`, retryable };
62
66
  return;
63
67
  }
64
68
  const prompt = { text: `${text}${resolved.promptSuffix}${MARKDOWN_INSTRUCTION}`, images: resolved.images };
@@ -22,7 +22,7 @@ export async function onboardSlackApp(input, io, deps = {}) {
22
22
  // Record BEFORE the non-idempotent API call. A transport/internal failure may have created the app;
23
23
  // refusing a blind retry is safer than silently producing duplicates.
24
24
  state = { ...state, createAttemptedAt: new Date().toISOString() };
25
- await writeSlackOnboardingState(input.stateRoot, state);
25
+ writeSlackOnboardingState(input.stateRoot, state);
26
26
  let created;
27
27
  try {
28
28
  created = await (deps.createApp ?? createSlackApp)(current.token, manifest);
@@ -32,7 +32,7 @@ export async function onboardSlackApp(input, io, deps = {}) {
32
32
  ["fatal_error", "internal_error", "request_timeout", "service_unavailable", "failed_creating_app"].includes(error.code);
33
33
  if (!ambiguous) {
34
34
  state = { ...state, createAttemptedAt: undefined };
35
- await writeSlackOnboardingState(input.stateRoot, state);
35
+ writeSlackOnboardingState(input.stateRoot, state);
36
36
  }
37
37
  throw error;
38
38
  }
@@ -45,7 +45,7 @@ export async function onboardSlackApp(input, io, deps = {}) {
45
45
  signingSecret: created.signingSecret,
46
46
  };
47
47
  // Irreversible boundary first: a cancellation or .env write failure can resume without creating a duplicate.
48
- await writeSlackOnboardingState(input.stateRoot, state);
48
+ writeSlackOnboardingState(input.stateRoot, state);
49
49
  io.note(`Created Slack app ${created.appId}; credentials captured locally.`);
50
50
  }
51
51
  else {
@@ -59,7 +59,7 @@ export async function onboardSlackApp(input, io, deps = {}) {
59
59
  if (state.signingSecret) {
60
60
  await io.writeRuntimeSecrets({ signingSecret: state.signingSecret });
61
61
  state = { ...state, signingSecret: undefined };
62
- await writeSlackOnboardingState(input.stateRoot, state);
62
+ writeSlackOnboardingState(input.stateRoot, state);
63
63
  }
64
64
  if (!state.appId || !state.clientId || !state.clientSecret) {
65
65
  throw new Error("Slack onboarding state lost app OAuth credentials before installation");
@@ -104,7 +104,7 @@ export async function onboardSlackApp(input, io, deps = {}) {
104
104
  teamName: oauth.teamName,
105
105
  installedAt: new Date().toISOString(),
106
106
  };
107
- await writeSlackOnboardingState(input.stateRoot, state);
107
+ writeSlackOnboardingState(input.stateRoot, state);
108
108
  return state;
109
109
  }
110
110
  export function newSlackOnboardingState(input) {
@@ -18,9 +18,13 @@ export interface SlackOnboardingState {
18
18
  teamName?: string;
19
19
  installedAt?: string;
20
20
  }
21
- export declare function readSlackOnboardingState(stateRoot: string): Promise<SlackOnboardingState | undefined>;
22
- /** Atomic replacement with owner-only permissions: this file carries a workspace-wide config refresh token. */
23
- export declare function writeSlackOnboardingState(stateRoot: string, state: SlackOnboardingState): Promise<void>;
21
+ export declare function readSlackOnboardingState(stateRoot: string): SlackOnboardingState | undefined;
22
+ /** Atomic replacement with owner-only permissions: this file carries a workspace-wide config refresh
23
+ * token. Synchronous, through the shared writer, like every other piece of state this repo keeps
24
+ * (kit/state.ts): the file is ~1 KB and its writers are `fastagent add slack` and one config-token
25
+ * rotation at tunnel startup, so the async spelling bought nothing and cost a fifth set of temp-name
26
+ * and permission rules to keep true. */
27
+ export declare function writeSlackOnboardingState(stateRoot: string, state: SlackOnboardingState): void;
24
28
  export declare function currentSlackConfigToken(stateRoot: string, state: SlackOnboardingState, options?: {
25
29
  now?: number;
26
30
  apiBaseUrl?: string;
@@ -1,5 +1,6 @@
1
- import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
2
- import { dirname, join } from "node:path";
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { writeFileAtomic } from "../../atomic-write.js";
3
4
  import { rotateSlackConfigToken } from "./config-api.js";
4
5
  function slackOnboardingStatePath(stateRoot) {
5
6
  return join(stateRoot, "channels", "slack", "onboarding.json");
@@ -15,11 +16,11 @@ function validState(value) {
15
16
  typeof state.configRefreshToken === "string" &&
16
17
  typeof state.configTokenExpiresAt === "number");
17
18
  }
18
- export async function readSlackOnboardingState(stateRoot) {
19
+ export function readSlackOnboardingState(stateRoot) {
19
20
  const file = slackOnboardingStatePath(stateRoot);
20
21
  let raw;
21
22
  try {
22
- raw = await readFile(file, "utf8");
23
+ raw = readFileSync(file, "utf8");
23
24
  }
24
25
  catch (error) {
25
26
  if (error.code === "ENOENT")
@@ -36,21 +37,13 @@ export async function readSlackOnboardingState(stateRoot) {
36
37
  throw new Error(`invalid Slack onboarding state ${file}: ${error.message}`);
37
38
  }
38
39
  }
39
- /** Atomic replacement with owner-only permissions: this file carries a workspace-wide config refresh token. */
40
- export async function writeSlackOnboardingState(stateRoot, state) {
41
- const file = slackOnboardingStatePath(stateRoot);
42
- await mkdir(dirname(file), { recursive: true });
43
- const temp = `${file}.${process.pid}.${Date.now()}.tmp`;
44
- try {
45
- await writeFile(temp, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
46
- await chmod(temp, 0o600);
47
- await rename(temp, file);
48
- await chmod(file, 0o600);
49
- }
50
- catch (error) {
51
- await rm(temp, { force: true }).catch(() => { });
52
- throw error;
53
- }
40
+ /** Atomic replacement with owner-only permissions: this file carries a workspace-wide config refresh
41
+ * token. Synchronous, through the shared writer, like every other piece of state this repo keeps
42
+ * (kit/state.ts): the file is ~1 KB and its writers are `fastagent add slack` and one config-token
43
+ * rotation at tunnel startup, so the async spelling bought nothing and cost a fifth set of temp-name
44
+ * and permission rules to keep true. */
45
+ export function writeSlackOnboardingState(stateRoot, state) {
46
+ writeFileAtomic(slackOnboardingStatePath(stateRoot), `${JSON.stringify(state, null, 2)}\n`, 0o600);
54
47
  }
55
48
  export async function currentSlackConfigToken(stateRoot, state, options = {}) {
56
49
  const now = options.now ?? Date.now();
@@ -64,6 +57,6 @@ export async function currentSlackConfigToken(stateRoot, state, options = {}) {
64
57
  configTokenExpiresAt: rotated.expiresAt,
65
58
  teamId: state.teamId ?? rotated.teamId,
66
59
  };
67
- await writeSlackOnboardingState(stateRoot, next);
60
+ writeSlackOnboardingState(stateRoot, next);
68
61
  return { token: next.configToken, state: next };
69
62
  }
@@ -8,7 +8,7 @@ export async function registerSlackWebhook(baseUrl, options) {
8
8
  const publicBaseUrl = baseUrl.replace(/\/$/, "");
9
9
  let state;
10
10
  try {
11
- state = await readSlackOnboardingState(options.stateRoot);
11
+ state = readSlackOnboardingState(options.stateRoot);
12
12
  }
13
13
  catch (error) {
14
14
  note(`[fastagent] slack: cannot read local onboarding state: ${String(error)}`);
@@ -71,22 +71,6 @@ async function callSlack<T extends { ok?: boolean; error?: string }>(
71
71
  }
72
72
  }
73
73
 
74
- function trustedUploadUrl(value: string): URL {
75
- const url = new URL(value);
76
- const host = url.hostname.toLowerCase();
77
- const slackHost =
78
- host === "slack-files.com" ||
79
- host.endsWith(".slack-files.com") ||
80
- host === "slack.com" ||
81
- host.endsWith(".slack.com") ||
82
- host === "slack-edge.com" ||
83
- host.endsWith(".slack-edge.com");
84
- if (url.protocol !== "https:" || !slackHost) {
85
- throw new Error(`Slack returned an untrusted upload URL host: ${host}`);
86
- }
87
- return url;
88
- }
89
-
90
74
  export default defineTool({
91
75
  description:
92
76
  "Upload one local file to Slack (`path`), or send a message (`text`) for a turn NO channel is " +
@@ -140,7 +124,8 @@ export default defineTool({
140
124
  let byteResponse: Response;
141
125
  const handle = await open(filePath, "r");
142
126
  try {
143
- byteResponse = await fetch(trustedUploadUrl(upload.upload_url), {
127
+ // where getUploadURLExternal points is Slack's call: these bytes are on their way to Slack either way
128
+ byteResponse = await fetch(upload.upload_url, {
144
129
  method: "POST",
145
130
  headers: { "content-type": "application/octet-stream" },
146
131
  body: handle.readableWebStream(),