@fastagent-sh/fastagent 0.15.0 → 0.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (181) hide show
  1. package/README.md +14 -4
  2. package/dist/bind.d.ts +34 -0
  3. package/dist/bind.js +74 -0
  4. package/dist/channels/agentcore-limits.d.ts +7 -0
  5. package/dist/channels/agentcore-limits.js +9 -0
  6. package/dist/channels/agentcore-state.d.ts +83 -0
  7. package/dist/channels/agentcore-state.js +266 -0
  8. package/dist/channels/agentcore.d.ts +98 -0
  9. package/dist/channels/agentcore.js +260 -0
  10. package/dist/channels/busy.d.ts +23 -0
  11. package/dist/channels/busy.js +53 -0
  12. package/dist/channels/context-buffer.d.ts +5 -0
  13. package/dist/channels/context-buffer.js +5 -0
  14. package/dist/channels/feishu/context-buffer.d.ts +13 -8
  15. package/dist/channels/feishu/context-buffer.js +60 -6
  16. package/dist/channels/feishu/feishu-api.js +4 -1
  17. package/dist/channels/feishu/feishu.d.ts +0 -12
  18. package/dist/channels/feishu/feishu.js +143 -54
  19. package/dist/channels/feishu/invoke-turn.js +35 -18
  20. package/dist/channels/feishu/model.d.ts +0 -1
  21. package/dist/channels/feishu/normalize.js +0 -1
  22. package/dist/channels/feishu/parse.d.ts +21 -7
  23. package/dist/channels/feishu/parse.js +24 -7
  24. package/dist/channels/feishu/preview.js +3 -2
  25. package/dist/channels/feishu/scaffold/channel.ts +9 -8
  26. package/dist/channels/feishu/scaffold/feishu-send.ts +6 -4
  27. package/dist/channels/feishu/setup-mode.d.ts +30 -1
  28. package/dist/channels/feishu/setup-mode.js +27 -1
  29. package/dist/channels/github/github.js +8 -1
  30. package/dist/channels/http.js +1 -1
  31. package/dist/channels/lark/scaffold/channel.ts +9 -8
  32. package/dist/channels/lark/scaffold/lark-send.ts +6 -4
  33. package/dist/channels/preview-kit.d.ts +7 -1
  34. package/dist/channels/preview-kit.js +3 -2
  35. package/dist/channels/slack/parse.d.ts +16 -1
  36. package/dist/channels/slack/parse.js +46 -3
  37. package/dist/channels/slack/preview.d.ts +1 -2
  38. package/dist/channels/slack/preview.js +68 -24
  39. package/dist/channels/slack/scaffold/channel.ts +5 -5
  40. package/dist/channels/slack/slack-api.d.ts +3 -23
  41. package/dist/channels/slack/slack-api.js +6 -22
  42. package/dist/channels/slack/slack.d.ts +13 -20
  43. package/dist/channels/slack/slack.js +95 -50
  44. package/dist/channels/state.d.ts +11 -4
  45. package/dist/channels/state.js +19 -12
  46. package/dist/channels/tasks.d.ts +0 -6
  47. package/dist/channels/tasks.js +16 -1
  48. package/dist/channels/telegram/parse.d.ts +0 -7
  49. package/dist/channels/telegram/parse.js +4 -2
  50. package/dist/channels/telegram/scaffold/telegram-send.ts +6 -3
  51. package/dist/channels/telegram/telegram.js +1 -1
  52. package/dist/channels/text.d.ts +14 -0
  53. package/dist/channels/text.js +14 -0
  54. package/dist/channels/thread-participants.d.ts +21 -0
  55. package/dist/channels/thread-participants.js +132 -0
  56. package/dist/channels/turn-queue.js +7 -0
  57. package/dist/cli/add-feishu.d.ts +7 -4
  58. package/dist/cli/add-feishu.js +57 -37
  59. package/dist/cli/add-slack.d.ts +2 -1
  60. package/dist/cli/add-slack.js +6 -11
  61. package/dist/cli/commands/add.js +50 -51
  62. package/dist/cli/commands/attach.js +8 -4
  63. package/dist/cli/commands/chat.js +8 -8
  64. package/dist/cli/commands/deploy.d.ts +14 -1
  65. package/dist/cli/commands/deploy.js +330 -78
  66. package/dist/cli/commands/dev.d.ts +1 -0
  67. package/dist/cli/commands/dev.js +38 -25
  68. package/dist/cli/commands/fire.js +15 -16
  69. package/dist/cli/commands/info.js +36 -29
  70. package/dist/cli/commands/init.d.ts +1 -1
  71. package/dist/cli/commands/init.js +65 -53
  72. package/dist/cli/commands/invoke.js +9 -6
  73. package/dist/cli/commands/login.js +35 -21
  74. package/dist/cli/commands/schedule.js +6 -8
  75. package/dist/cli/commands/start.d.ts +1 -0
  76. package/dist/cli/commands/start.js +101 -37
  77. package/dist/cli/commands/tool.js +30 -18
  78. package/dist/cli/fail.d.ts +17 -0
  79. package/dist/cli/fail.js +24 -0
  80. package/dist/cli/program.js +66 -36
  81. package/dist/cli/serve.d.ts +52 -8
  82. package/dist/cli/serve.js +133 -32
  83. package/dist/cli/shared.d.ts +21 -2
  84. package/dist/cli/shared.js +44 -18
  85. package/dist/deploy/agentcore/plan.d.ts +117 -0
  86. package/dist/deploy/agentcore/plan.js +721 -0
  87. package/dist/deploy/agentcore/run.d.ts +73 -0
  88. package/dist/deploy/agentcore/run.js +412 -0
  89. package/dist/deploy/agentcore/zip.d.ts +17 -0
  90. package/dist/deploy/agentcore/zip.js +68 -0
  91. package/dist/deploy/container.d.ts +26 -25
  92. package/dist/deploy/container.js +93 -89
  93. package/dist/deploy/docker/plan.d.ts +1 -1
  94. package/dist/deploy/docker/plan.js +12 -17
  95. package/dist/deploy/fly/plan.d.ts +2 -0
  96. package/dist/deploy/fly/plan.js +27 -19
  97. package/dist/deploy/fly/run.d.ts +12 -1
  98. package/dist/deploy/fly/run.js +36 -2
  99. package/dist/deploy/preflight.d.ts +11 -5
  100. package/dist/deploy/preflight.js +235 -65
  101. package/dist/deploy/railway/plan.d.ts +7 -0
  102. package/dist/deploy/railway/plan.js +41 -16
  103. package/dist/deploy/railway/run.d.ts +8 -1
  104. package/dist/deploy/railway/run.js +7 -2
  105. package/dist/deploy/runner.d.ts +5 -2
  106. package/dist/deploy/runner.js +9 -3
  107. package/dist/dev-supervisor.d.ts +11 -8
  108. package/dist/dev-supervisor.js +53 -51
  109. package/dist/engines/pi/auth.d.ts +8 -7
  110. package/dist/engines/pi/auth.js +12 -10
  111. package/dist/engines/pi/channel.d.ts +1 -1
  112. package/dist/engines/pi/channel.js +5 -5
  113. package/dist/engines/pi/chat.js +2 -2
  114. package/dist/engines/pi/config.d.ts +12 -48
  115. package/dist/engines/pi/config.js +29 -110
  116. package/dist/engines/pi/create.d.ts +32 -24
  117. package/dist/engines/pi/create.js +47 -23
  118. package/dist/engines/pi/definition.d.ts +7 -26
  119. package/dist/engines/pi/definition.js +8 -54
  120. package/dist/engines/pi/harness.d.ts +19 -5
  121. package/dist/engines/pi/harness.js +3 -5
  122. package/dist/engines/pi/login.d.ts +1 -1
  123. package/dist/engines/pi/models.d.ts +3 -3
  124. package/dist/engines/pi/models.js +1 -1
  125. package/dist/engines/pi/{workspace.d.ts → open.d.ts} +31 -25
  126. package/dist/engines/pi/{workspace.js → open.js} +27 -29
  127. package/dist/engines/pi/read-image.d.ts +4 -0
  128. package/dist/engines/pi/read-image.js +62 -0
  129. package/dist/engines/pi/search-tools.d.ts +6 -4
  130. package/dist/engines/pi/search-tools.js +3 -1
  131. package/dist/engines/pi/session-builder.d.ts +2 -2
  132. package/dist/engines/pi/session-builder.js +18 -13
  133. package/dist/engines/pi/tool.d.ts +13 -5
  134. package/dist/engines/pi/tool.js +4 -0
  135. package/dist/engines/pi/wake-tool.d.ts +3 -3
  136. package/dist/env.d.ts +16 -4
  137. package/dist/env.js +43 -5
  138. package/dist/host/node.d.ts +4 -2
  139. package/dist/host/node.js +2 -1
  140. package/dist/loader.d.ts +2 -2
  141. package/dist/loader.js +3 -3
  142. package/dist/log.d.ts +1 -1
  143. package/dist/log.js +1 -1
  144. package/dist/paths.d.ts +138 -0
  145. package/dist/paths.js +326 -0
  146. package/dist/pi.d.ts +2 -2
  147. package/dist/pi.js +2 -2
  148. package/dist/runtime.d.ts +7 -5
  149. package/dist/runtime.js +2 -2
  150. package/dist/scaffold/add-channel.d.ts +7 -3
  151. package/dist/scaffold/add-channel.js +55 -29
  152. package/dist/scaffold/init.d.ts +32 -41
  153. package/dist/scaffold/init.js +161 -185
  154. package/dist/scaffold/templates/env.example +15 -6
  155. package/dist/scaffold/templates/fastagent.config.mjs +1 -1
  156. package/dist/scaffold/templates/gitignore +14 -6
  157. package/dist/scaffold/templates/persona.md +4 -2
  158. package/dist/scaffold/templates/secrets.gitignore +5 -0
  159. package/dist/scaffold/templates.d.ts +1 -7
  160. package/dist/scaffold/templates.js +3 -25
  161. package/dist/scaffold/vendor-skill.d.ts +2 -2
  162. package/dist/scaffold/vendor-skill.js +13 -13
  163. package/dist/schedule/discover.js +4 -4
  164. package/dist/schedule/scheduler.d.ts +40 -1
  165. package/dist/schedule/scheduler.js +89 -56
  166. package/dist/schedule/state.js +1 -1
  167. package/dist/schedule/wake-alarm.d.ts +47 -0
  168. package/dist/schedule/wake-alarm.js +136 -0
  169. package/dist/schedule/wakeups.d.ts +1 -0
  170. package/dist/schedule/wakeups.js +18 -0
  171. package/dist/tunnel.d.ts +3 -3
  172. package/dist/tunnel.js +7 -7
  173. package/package.json +7 -4
  174. package/dist/channels/feishu/owned-threads.d.ts +0 -7
  175. package/dist/channels/feishu/owned-threads.js +0 -47
  176. package/dist/channels/slack/owned-threads.d.ts +0 -6
  177. package/dist/channels/slack/owned-threads.js +0 -43
  178. package/dist/scaffold/templates/gitignore.agentdir-root +0 -5
  179. package/dist/scaffold/templates/gitignore.kit +0 -2
  180. package/dist/workspace.d.ts +0 -9
  181. package/dist/workspace.js +0 -45
@@ -1,7 +1,6 @@
1
1
  import type { ChannelModule } from "../../host/node.ts";
2
2
  import { type SlackEventEnvelope, type SlackFile, type SlackMessageEvent, type SlackRoute, defaultSlackRoute, slackEnvelope } from "./parse.ts";
3
3
  import { type SlackFailure, type SlackRendering } from "./preview.ts";
4
- import { type SlackTaskDisplayMode } from "./slack-api.ts";
5
4
  export { defaultSlackRoute, slackEnvelope };
6
5
  export type { SlackEventEnvelope, SlackFailure, SlackFile, SlackMessageEvent, SlackRendering, SlackRoute };
7
6
  export interface SlackChannelOptions {
@@ -14,25 +13,19 @@ export interface SlackChannelOptions {
14
13
  clientId?: string;
15
14
  clientSecret?: string;
16
15
  botTokenExpiresAt?: number;
17
- /** Direct-message policy. `threaded` (default) gives every top-level DM its own session/thread;
18
- * `continuous` keeps one linear session per DM channel. */
19
- directMessageSession?: "continuous" | "threaded";
20
- /** Group-message context + delivery policy. `threaded` (default) gives every top-level summon its
21
- * own session/thread; `continuous` keeps one session for channel-top-level turns while preserving
22
- * existing Slack threads as separate root sessions. */
23
- groupMessageSession?: "continuous" | "threaded";
24
- /** `context` (default) admits bare replies in managed group threads and buffers unsummoned group
25
- * discussion. `mentions` answers only app_mention plus DMs for an explicit least-privilege setup. */
16
+ /** `context` (default) subscribes to group message streams, which is what lets the channel HEAR a
17
+ * thread: bare replies are then admitted by the participation rule (design/participant-model.md §3),
18
+ * and other discussion is buffered. `mentions` answers only app_mention plus DMs for an explicit
19
+ * least-privilege setup. */
26
20
  groupBehavior?: "context" | "mentions";
27
- /** `native` (default) uses Slack Agent streams/tasks for threaded replies. `classic` retains the
28
- * compatibility renderer based on one rate-limited edited message. A top-level target selected by an
29
- * explicit continuous/custom policy necessarily uses the classic renderer because Slack streams
30
- * require a parent user message. */
21
+ /** `native` (default) uses Slack Agent streams for threaded replies. Its inline tool traces carry a
22
+ * bounded summary of each call's first argument and cannot be retracted, so they stay in the
23
+ * delivered message beside the answer. `classic` retains the compatibility renderer based on one
24
+ * rate-limited edited message, which settles into the answer alone. A top-level target necessarily
25
+ * uses the classic renderer, because Slack streams require a parent user message; a custom route
26
+ * reaches one either by returning `threadTs: null` or by redirecting to another channel without
27
+ * naming a thread. */
31
28
  rendering?: SlackRendering;
32
- /** Native task-card layout (`chat.startStream` `task_display_mode`): `plan` (default) groups steps
33
- * under a single collapsible heading, `timeline` lists each step sequentially, `dense` collapses
34
- * consecutive tool calls into one summarized card. Applies to the native renderer only. */
35
- taskDisplay?: SlackTaskDisplayMode;
36
29
  /** Optional footer for successful Agent replies. Omitted or `false` sends no repetitive disclaimer. */
37
30
  aiDisclaimer?: string | false;
38
31
  /** First-run direct-message welcome, sent once when a user first opens the DM (`app_home_opened`,
@@ -46,7 +39,7 @@ export interface SlackChannelOptions {
46
39
  processing?: string;
47
40
  completed?: string;
48
41
  };
49
- /** Custom route policy. Providing it disables the default managed-thread/context admission policy. */
42
+ /** Custom route policy. Providing it disables the default participant-model thread/context admission policy. */
50
43
  route?: (envelope: SlackEventEnvelope) => SlackRoute | null;
51
44
  /** Customer-facing failure formatter; full details always remain in operator logs. */
52
45
  onError?: (failure: SlackFailure) => string | undefined;
@@ -55,4 +48,4 @@ export interface SlackChannelOptions {
55
48
  }
56
49
  /** Verify Slack's v0 HMAC over the exact raw body and reject timestamps outside the replay window. */
57
50
  export declare function verifySlackSignature(signingSecret: string, timestamp: string, signature: string, rawBody: string, nowMs?: number): boolean;
58
- export declare function slackChannel({ botToken, signingSecret, botRefreshToken, clientId, clientSecret, botTokenExpiresAt, directMessageSession, groupMessageSession, groupBehavior, rendering, taskDisplay, aiDisclaimer, welcome, reactionAck, route, onError, apiBaseUrl, }: SlackChannelOptions): ChannelModule;
51
+ export declare function slackChannel(options: SlackChannelOptions): ChannelModule;
@@ -5,8 +5,9 @@ import { log } from "../../log.js";
5
5
  import { readBodyCapped } from "../body.js";
6
6
  import { text } from "../respond.js";
7
7
  import { createSeenRing } from "../seen.js";
8
+ import { createThreadParticipants } from "../thread-participants.js";
8
9
  import { createTaskTracker } from "../tasks.js";
9
- import { ensureStateHome } from "../state.js";
10
+ import { ensureStateHome, removeRetiredStateFile } from "../state.js";
10
11
  import { dispatchStop, isStopText } from "../stop-command.js";
11
12
  import { codePointPrefix } from "../text.js";
12
13
  import { createTurnQueue } from "../turn-queue.js";
@@ -14,8 +15,7 @@ import { createTurnStore } from "../turn-store.js";
14
15
  import { createSlackBotTokenProvider } from "./bot-auth.js";
15
16
  import { collectSlackBufferedFiles, createSlackContextBuffer } from "./context-buffer.js";
16
17
  import { invokeSlackTurn } from "./invoke-turn.js";
17
- import { createOwnedSlackThreads } from "./owned-threads.js";
18
- import { defaultSlackRoute, isSlackDirectMessage, isSlackGroupMessage, isSlackHumanMessage, slackBufferText, slackEnvelope, slackFileIds, slackMessageText, slackPlaceKey, slackSenderLabel, slackTeamId, } from "./parse.js";
18
+ import { defaultSlackRoute, isSlackDirectMessage, isSlackGroupMessage, hasSlackMention, hasSlackUserMention, isSlackHumanMessage, mentionsSlackUser, stripSlackMentions, slackBufferText, slackEnvelope, slackFileIds, slackMessageText, slackPlaceKey, slackSenderLabel, slackTeamId, } from "./parse.js";
19
19
  import { defaultErrorMessage, settleSlackPreview, streamSlackReply, } from "./preview.js";
20
20
  import { resolveReactionEmojis, startSlackReaction } from "./reaction.js";
21
21
  import { createSlackApi } from "./slack-api.js";
@@ -61,12 +61,16 @@ function messageRefOf(turnId) {
61
61
  const parts = turnId.split(":");
62
62
  return parts.length === 3 && parts[1] && parts[2] ? { channelId: parts[1], ts: parts[2] } : undefined;
63
63
  }
64
- export function slackChannel({ botToken, signingSecret, botRefreshToken, clientId, clientSecret, botTokenExpiresAt, directMessageSession = "threaded", groupMessageSession = "threaded", groupBehavior = "context", rendering = "native", taskDisplay = "plan", aiDisclaimer, welcome = DEFAULT_WELCOME, reactionAck = {}, route, onError, apiBaseUrl = "https://slack.com/api", }) {
65
- if (!["continuous", "threaded"].includes(directMessageSession)) {
66
- throw new Error('slackChannel directMessageSession must be "continuous" or "threaded"');
67
- }
68
- if (!["continuous", "threaded"].includes(groupMessageSession)) {
69
- throw new Error('slackChannel groupMessageSession must be "continuous" or "threaded"');
64
+ export function slackChannel(options) {
65
+ const { botToken, signingSecret, botRefreshToken, clientId, clientSecret, botTokenExpiresAt, groupBehavior = "context", rendering = "native", aiDisclaimer, welcome = DEFAULT_WELCOME, reactionAck = {}, route, onError, apiBaseUrl = "https://slack.com/api", } = options;
66
+ // The participant model derives placement instead of selecting it: Slack has no quote primitive, so
67
+ // answering in place means answering in a thread on the ask, whichever renderer draws it. An
68
+ // upgraded workspace still passing one of the removed modes would otherwise start fine and silently
69
+ // get a different placement AND a different memory boundary.
70
+ const removedModes = ["directMessageSession", "groupMessageSession"].filter((name) => options[name] !== undefined);
71
+ if (removedModes.length > 0) {
72
+ throw new Error(`slackChannel no longer accepts ${removedModes.join(" / ")}: an answer goes in a thread on the ask, and ` +
73
+ "that thread is the session — see docs/design/participant-model.md");
70
74
  }
71
75
  if (!["context", "mentions"].includes(groupBehavior)) {
72
76
  throw new Error('slackChannel groupBehavior must be "context" or "mentions"');
@@ -74,9 +78,6 @@ export function slackChannel({ botToken, signingSecret, botRefreshToken, clientI
74
78
  if (!["native", "classic"].includes(rendering)) {
75
79
  throw new Error('slackChannel rendering must be "native" or "classic"');
76
80
  }
77
- if (!["timeline", "plan", "dense"].includes(taskDisplay)) {
78
- throw new Error('slackChannel taskDisplay must be "timeline", "plan", or "dense"');
79
- }
80
81
  if (welcome !== false && typeof welcome !== "string") {
81
82
  throw new Error("slackChannel welcome must be a string or false");
82
83
  }
@@ -134,7 +135,16 @@ export function slackChannel({ botToken, signingSecret, botRefreshToken, clientI
134
135
  });
135
136
  };
136
137
  const seen = createSeenRing(join(stateHome, "seen.json"), label);
137
- const ownedThreads = createOwnedSlackThreads(join(stateHome, "owned-threads.json"), label);
138
+ const threadParticipants = createThreadParticipants(join(stateHome, "thread-participants.json"), label);
139
+ /** A thread's participation is keyed by the SESSION it describes — "the agent answered here" is a
140
+ * claim about a memory, so the two must not be re-keyable independently. What keeps the claim true
141
+ * under a custom route is the `routed.session === undefined` condition on the write, not the
142
+ * absence of a route: a route that supplies its own session records nothing here. */
143
+ const threadKey = (teamId, channelId, threadTs) => `slack:${teamId}:${channelId}:${threadTs}`;
144
+ // The participant model replaced the owned-thread index (a cache, so nothing is lost). REMOVE THIS
145
+ // after the release following the participant model ships — by then no live deployment can still
146
+ // be carrying the file. test/migration-deadline.test.ts fails when due.
147
+ removeRetiredStateFile(stateHome, "owned-threads.json", label);
138
148
  const welcomed = createWelcomedUsers(join(stateHome, "welcomed.json"), label);
139
149
  const buffer = createSlackContextBuffer(join(stateHome, "buffers.json"), label);
140
150
  const store = createTurnStore(join(stateHome, "turns.json"), {
@@ -220,7 +230,6 @@ export function slackChannel({ botToken, signingSecret, botRefreshToken, clientI
220
230
  initialPreviewTs: turn.previewTs,
221
231
  threadTitle: turn.threadTitle,
222
232
  disclaimer: aiDisclaimer,
223
- taskDisplay,
224
233
  label,
225
234
  });
226
235
  log.info(`${label} turn done: turn=${turn.id} session=${turn.session} (${Date.now() - startedAt}ms)`);
@@ -254,6 +263,9 @@ export function slackChannel({ botToken, signingSecret, botRefreshToken, clientI
254
263
  let seq = recovered.reduce((maximum, turn) => Math.max(maximum, turn.seq), 0);
255
264
  for (const { attempts: _attempts, ...intent } of recovered)
256
265
  submit({ ...intent }, false);
266
+ // Acceptance touches no network, so it stays synchronous inside Slack's ACK window and the
267
+ // delivery dedup ring alone is enough — there is no await for a duplicate delivery to race
268
+ // through. The minutes-long Agent turn remains fire-and-forget.
257
269
  const acceptEvent = (envelope) => {
258
270
  const event = envelope.event;
259
271
  if (!isSlackHumanMessage(event))
@@ -274,24 +286,44 @@ export function slackChannel({ botToken, signingSecret, botRefreshToken, clientI
274
286
  const direct = isSlackDirectMessage(event);
275
287
  const rootTs = event.thread_ts ?? event.ts;
276
288
  const bufferKey = slackPlaceKey(teamId, event);
277
- const threadedGroup = group && groupMessageSession === "threaded";
278
- const managedContinuation = groupBehavior === "context" &&
279
- route === undefined &&
280
- threadedGroup &&
281
- event.thread_ts !== undefined &&
282
- ownedThreads.has(teamId, event.channel, event.thread_ts);
289
+ // Listening is not speaking: every message the channel can see refines who takes part in its
290
+ // thread, whether or not it is answered. Humans only — a bot's own posts are recorded where they
291
+ // are known — this channel answering.
292
+ // Structural facts only, never `groupBehavior` or `route` — see thread-participants.ts. Slack
293
+ // adds no delta of its own here; its summon rule is the only consumer.
294
+ if (group && event.thread_ts !== undefined) {
295
+ threadParticipants.merge(threadKey(teamId, event.channel, event.thread_ts), { humans: [event.user] });
296
+ }
283
297
  let routed = decide(envelope);
284
- const hasUserMention = /<@[A-Z0-9]+>/i.test(event.text ?? "");
285
- const structurallyMentionsBot = botUserId !== undefined && (event.text ?? "").includes(`<@${botUserId}>`);
298
+ // Two different questions (see parse.ts). `addressesSomeone` is the "@-mentions only other people
299
+ // is discussion, never an ask" guard (§3) and counts broadcasts and user groups too;
300
+ // `mightBeTheBot` gates the identity-window deferral below, where only a USER mention qualifies.
301
+ const addressesSomeone = hasSlackMention(event.text ?? "");
302
+ const mightBeTheBot = hasSlackUserMention(event.text ?? "");
303
+ const structurallyMentionsBot = botUserId !== undefined && mentionsSlackUser(event.text ?? "", botUserId);
286
304
  // app_mention and message.* subscriptions can overlap. If message.* arrives first, structural bot
287
305
  // identity routes it now; while auth.test is still unresolved, defer any mentioned message rather
288
306
  // than buffer+dedup it and accidentally suppress the later app_mention callback.
289
307
  if (!routed && route === undefined && group && event.type === "message" && structurallyMentionsBot)
290
308
  routed = {};
291
- if (!routed && managedContinuation && event.type !== "app_mention")
309
+ // The participant model's thread rule (§3): a bare message reaches the agent while it takes part
310
+ // and has not heard a second human. Note the ORDER — the mention guard below runs first and is
311
+ // structural: it reads who THIS message addresses, which is the one thing an observation-only
312
+ // store cannot know about someone it has never heard.
313
+ if (!routed &&
314
+ groupBehavior === "context" &&
315
+ route === undefined &&
316
+ group &&
317
+ event.thread_ts !== undefined &&
318
+ event.type !== "app_mention" &&
319
+ // Mentioning only other people is targeted discussion, never an ask (§3) — the same guard
320
+ // Feishu applies with `hasMentions`.
321
+ !addressesSomeone &&
322
+ threadParticipants.admitsBareMessage(threadKey(teamId, event.channel, event.thread_ts))) {
292
323
  routed = {};
324
+ }
293
325
  if (!routed) {
294
- if (route === undefined && group && botUserId === undefined && hasUserMention)
326
+ if (route === undefined && group && botUserId === undefined && mightBeTheBot)
295
327
  return;
296
328
  if (groupBehavior === "context" && route === undefined && group) {
297
329
  const body = slackBufferText(slackMessageText(event));
@@ -312,22 +344,17 @@ export function slackChannel({ botToken, signingSecret, botRefreshToken, clientI
312
344
  }
313
345
  const targetChannel = routed.channelId ?? event.channel;
314
346
  const sameChannel = targetChannel === event.channel;
315
- const defaultThread = group
316
- ? (event.thread_ts ?? (groupMessageSession === "threaded" ? event.ts : undefined))
317
- : (event.thread_ts ?? (directMessageSession === "threaded" ? event.ts : undefined));
347
+ // Answer where asked (participant model §4). Slack has no quote primitive, so the only way to
348
+ // attach an answer to its question is a thread on it — which then IS the place, and carries the
349
+ // memory (§5). Native streaming additionally REQUIRES a thread, but the shape does not depend on
350
+ // the renderer: `classic` attaches its answer the same way.
351
+ const defaultThread = event.thread_ts ?? event.ts;
318
352
  const threadTs = routed.threadTs === null ? undefined : (routed.threadTs ?? (sameChannel ? defaultThread : undefined));
319
- const continuousTopLevel = event.thread_ts === undefined;
320
- const defaultSession = direct
321
- ? directMessageSession === "continuous" && continuousTopLevel
322
- ? `slack:${teamId}:${event.channel}`
323
- : `slack:${teamId}:${event.channel}:${rootTs}`
324
- : groupMessageSession === "continuous" && continuousTopLevel
325
- ? `slack:${teamId}:${event.channel}`
326
- : `slack:${teamId}:${event.channel}:${rootTs}`;
353
+ const defaultSession = threadKey(teamId, event.channel, rootTs);
327
354
  // Explicit user stop: a control action, never a turn — it must not queue behind the run it
328
355
  // stops. Match the bare word after stripping the bot mention; record the logical id so a Slack
329
356
  // redelivery doesn't double-abort or double-notify.
330
- if (isStopText((event.text ?? "").replace(/<@[A-Z0-9]+>/gi, " "))) {
357
+ if (isStopText(stripSlackMentions(event.text ?? ""))) {
331
358
  seen.add(logicalId);
332
359
  const target = { channelId: event.channel, threadTs: event.thread_ts };
333
360
  sideTasks.track(dispatchStop(control, routed.session ?? defaultSession, label)
@@ -340,21 +367,8 @@ export function slackChannel({ botToken, signingSecret, botRefreshToken, clientI
340
367
  if (!baseText.trim() && fileIds.length === 0)
341
368
  return;
342
369
  const threadTitle = direct && event.thread_ts === undefined
343
- ? codePointPrefix(slackMessageText(event)
344
- .replace(/<@[A-Z0-9]+>/gi, "")
345
- .replace(/\s+/g, " ")
346
- .trim(), 80)
370
+ ? codePointPrefix(stripSlackMentions(slackMessageText(event), "").replace(/\s+/g, " ").trim(), 80)
347
371
  : undefined;
348
- // Match Feishu/Lark ownership in context mode: only a top-level summon that creates an
349
- // Agent-managed thread owns the root. Mentioning the Agent inside an existing human thread
350
- // answers once without adopting it; mention-only mode never consumes ownership state.
351
- if (groupBehavior === "context" &&
352
- route === undefined &&
353
- threadedGroup &&
354
- event.thread_ts === undefined &&
355
- sameChannel) {
356
- ownedThreads.add(teamId, event.channel, rootTs);
357
- }
358
372
  submit({
359
373
  id: logicalId,
360
374
  seq: ++seq,
@@ -368,6 +382,37 @@ export function slackChannel({ botToken, signingSecret, botRefreshToken, clientI
368
382
  threadTitle: threadTitle || undefined,
369
383
  fileIds,
370
384
  }, true);
385
+ // Answering inside a GROUP thread makes the agent a participant of it, which is what lets the
386
+ // NEXT bare message address it without a mention.
387
+ //
388
+ // `group` excludes DMs, whose `threadTs` is always defined (the answer opens its assistant
389
+ // thread) and which no rule could ever read — safe because it is structural: a channel never
390
+ // becomes a DM.
391
+ //
392
+ // The two `routed` conditions keep the record describing what it claims. `session` undefined: the
393
+ // flag asserts "the agent answered into THIS thread's session", so a route supplying its own
394
+ // would record participation in a memory that never held the turn. `threadTs` undefined: a route
395
+ // can send the answer to a DIFFERENT thread, where the asker never spoke — recording them there
396
+ // would invent a participant. Feishu carries the same two conditions for the same reasons.
397
+ //
398
+ // Recorded only once the intent is durable: `submit` can throw, and a redelivery must still see
399
+ // the thread as the agent has actually left it.
400
+ if (group &&
401
+ threadTs !== undefined &&
402
+ sameChannel &&
403
+ routed.session === undefined &&
404
+ routed.threadTs === undefined) {
405
+ // The ASKER counts as heard in this thread too, and both halves are written together so the
406
+ // record can never say "the agent takes part and nobody has spoken". When the ask is top
407
+ // level, the answer is what CREATES the thread, so the observation above never ran for it (no
408
+ // `thread_ts` on the ask) — without this, a thread whose root is a human would not count them,
409
+ // and a stranger's first bare reply would read as a two-party exchange. `event.user` is
410
+ // guaranteed here (isSlackHumanMessage). Idempotent when the ask was already in the thread.
411
+ threadParticipants.merge(threadKey(teamId, event.channel, threadTs), {
412
+ agentSpoke: true,
413
+ humans: [event.user],
414
+ });
415
+ }
371
416
  };
372
417
  // Side tasks (stop feedback, DM welcomes) run off the ACK path but drain in turnsIdle.
373
418
  const sideTasks = createTaskTracker();
@@ -1,9 +1,16 @@
1
- /** Create the state home and self-ignore it (`.gitignore="*"`): its contents (buffers, downloaded
2
- * files) can carry chat content and must never be committable. The workspace opener already protects
3
- * an in-tree state root; this local guard also covers direct embedders. `wx` never clobbers an
4
- * operator's own file. */
1
+ /** Create the channel's state home the one shared spelling of it, so no channel invents its own. */
5
2
  export declare function ensureStateHome(dir: string): void;
6
3
  /** Returns `unknown` on purpose — no generic pretending otherwise: the file is an IO boundary, and the
7
4
  * caller owns shape validation (a `<T>` here would be an unchecked cast wearing a type). */
8
5
  export declare function loadStateFile(path: string): unknown;
9
6
  export declare function saveStateFile(path: string, value: unknown): void;
7
+ /**
8
+ * Drop a state file a redesign retired. Best-effort by design: a leftover file is untidy, not fatal,
9
+ * so a failure is debug-level and never blocks a boot. Only for files that are pure CACHE — anything
10
+ * whose loss changes behaviour needs a migration, not a delete.
11
+ *
12
+ * Shared because a retired file is usually retired in every channel at once: one best-effort
13
+ * semantic, one log shape, one place to check what "retired" means here. (The removal DEADLINE is not
14
+ * here — it lives in test/migration-deadline.test.ts, which names every call site to delete.)
15
+ */
16
+ export declare function removeRetiredStateFile(stateHome: string, name: string, label: string): void;
@@ -12,22 +12,12 @@
12
12
  * is an ENVIRONMENT error the operator must fix: it throws, and construction fails loudly — booting
13
13
  * with silently-empty state would hide real data behind a config mistake.
14
14
  */
15
- import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
15
+ import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
16
16
  import { dirname, join } from "node:path";
17
17
  import { log } from "../log.js";
18
- /** Create the state home and self-ignore it (`.gitignore="*"`): its contents (buffers, downloaded
19
- * files) can carry chat content and must never be committable. The workspace opener already protects
20
- * an in-tree state root; this local guard also covers direct embedders. `wx` never clobbers an
21
- * operator's own file. */
18
+ /** Create the channel's state home the one shared spelling of it, so no channel invents its own. */
22
19
  export function ensureStateHome(dir) {
23
20
  mkdirSync(dir, { recursive: true });
24
- try {
25
- writeFileSync(join(dir, ".gitignore"), "*\n", { flag: "wx" });
26
- }
27
- catch (e) {
28
- if (e.code !== "EEXIST")
29
- throw e;
30
- }
31
21
  }
32
22
  /** Returns `unknown` on purpose — no generic pretending otherwise: the file is an IO boundary, and the
33
23
  * caller owns shape validation (a `<T>` here would be an unchecked cast wearing a type). */
@@ -58,3 +48,20 @@ export function saveStateFile(path, value) {
58
48
  writeFileSync(tmp, JSON.stringify(value));
59
49
  renameSync(tmp, path);
60
50
  }
51
+ /**
52
+ * Drop a state file a redesign retired. Best-effort by design: a leftover file is untidy, not fatal,
53
+ * so a failure is debug-level and never blocks a boot. Only for files that are pure CACHE — anything
54
+ * whose loss changes behaviour needs a migration, not a delete.
55
+ *
56
+ * Shared because a retired file is usually retired in every channel at once: one best-effort
57
+ * semantic, one log shape, one place to check what "retired" means here. (The removal DEADLINE is not
58
+ * here — it lives in test/migration-deadline.test.ts, which names every call site to delete.)
59
+ */
60
+ export function removeRetiredStateFile(stateHome, name, label) {
61
+ try {
62
+ rmSync(join(stateHome, name), { force: true });
63
+ }
64
+ catch (error) {
65
+ log.debug(`${label} could not remove the obsolete ${name}: ${String(error)}`);
66
+ }
67
+ }
@@ -1,9 +1,3 @@
1
- /**
2
- * SHARED fire-and-forget side-task tracking. Channels launch work off the request path (stop
3
- * feedback, DM welcomes) that must not block the transport ACK but MUST be drained on shutdown
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.
6
- */
7
1
  export interface TaskTracker {
8
2
  /** Track one task. The caller keeps its own `.catch` — rejections must already be handled. */
9
3
  track(task: Promise<unknown>): void;
@@ -1,9 +1,24 @@
1
+ /**
2
+ * SHARED fire-and-forget side-task tracking. Channels launch work off the request path (stop
3
+ * feedback, DM welcomes) that must not block the transport ACK but MUST be drained on shutdown
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.
6
+ */
7
+ import { beginWork } from "./busy.js";
1
8
  export function createTaskTracker() {
2
9
  const tasks = new Set();
3
10
  return {
4
11
  track(task) {
5
12
  tasks.add(task);
6
- void task.finally(() => tasks.delete(task)).catch(() => { }); // the caller's chain owns the error
13
+ // Tracked side tasks count as process-wide in-flight work (busy.ts) same signal the turn
14
+ // queue reports, read by serving surfaces that must not idle while background work runs.
15
+ const workDone = beginWork();
16
+ void task
17
+ .finally(() => {
18
+ workDone();
19
+ tasks.delete(task);
20
+ })
21
+ .catch(() => { }); // the caller's chain owns the error
7
22
  },
8
23
  drain: () => Promise.all(tasks).then(() => undefined),
9
24
  };
@@ -1,10 +1,3 @@
1
- /**
2
- * Telegram protocol parsing — PURE: message field extraction, the prompt envelope, and the summon/route
3
- * policy. The defining invariant is purity: no state, no IO, no Bot API calls — plain data-in → data-out.
4
- * In telegram.ts's pipeline (verify → decide via `route` → run the turn → stream reply), this is the
5
- * "decide" and prompt-building half; telegram.ts wires it in and owns the stateful lifecycle. Kept
6
- * separate so this layer tests as plain functions and reads without the factory's noise.
7
- */
8
1
  /** A Telegram message (the common subset; `[k]` keeps the rest reachable without a types dependency). */
9
2
  export interface TelegramMessage {
10
3
  message_id: number;
@@ -5,6 +5,8 @@
5
5
  * "decide" and prompt-building half; telegram.ts wires it in and owns the stateful lifecycle. Kept
6
6
  * separate so this layer tests as plain functions and reads without the factory's noise.
7
7
  */
8
+ import { BUFFER_LINE_MAX_CHARS } from "../context-buffer.js";
9
+ import { REFERENT_MAX_CODE_POINTS, truncateCodePointPrefix } from "../text.js";
8
10
  /** The actionable message in an update (a fresh message or channel post). Edits (`edited_message` /
9
11
  * `edited_channel_post`) are deliberately NOT actionable: answering them re-answers every typo fix (a
10
12
  * duplicate reply per edit), so an edited message changes nothing — the standard bot behavior. The
@@ -62,7 +64,7 @@ function bodyOf(m) {
62
64
  }
63
65
  /** A one-line, length-capped rendering of a message's content for the context buffer. */
64
66
  export function messageText(m) {
65
- return (bodyOf(m) ?? "").replace(/\s+/g, " ").trim().slice(0, 280);
67
+ return truncateCodePointPrefix((bodyOf(m) ?? "").replace(/\s+/g, " ").trim(), BUFFER_LINE_MAX_CHARS);
66
68
  }
67
69
  /**
68
70
  * The default base prompt: a context envelope (chat/thread/sender + a group note + reply) then the
@@ -88,7 +90,7 @@ export function telegramEnvelope(m) {
88
90
  const isGroup = m.chat.type === "group" || m.chat.type === "supergroup";
89
91
  const scope = isGroup ? "\n[group chat — multiple people; each message is prefixed with its sender]" : "";
90
92
  const replyTo = r
91
- ? `\n[in reply to ${fromLabel(r.from) ?? `msg ${r.message_id}`} (msg ${r.message_id}): ${(bodyOf(r) ?? "(empty)").slice(0, 280)}]`
93
+ ? `\n[in reply to ${fromLabel(r.from) ?? `msg ${r.message_id}`} (msg ${r.message_id}): ${truncateCodePointPrefix(bodyOf(r) ?? "(empty)", REFERENT_MAX_CODE_POINTS)}]`
92
94
  : "";
93
95
  const parts = [bodyOf(m) ?? ""];
94
96
  if (m.location)
@@ -26,7 +26,7 @@ function splitPlain(text: string): string[] {
26
26
  return parts;
27
27
  }
28
28
 
29
- // Standalone copy of the channel transport's discipline: an upload timeout so a wedged connection
29
+ // Embedded copy of the channel transport's discipline: an upload timeout so a wedged connection
30
30
  // can't hang the tool call (and the turn), named errors, and success gated on the body's own ok.
31
31
  // Deliberately NO 429 retry — a tool error goes back to the agent, which can decide to retry;
32
32
  // fail-fast beats a silently sleeping tool.
@@ -60,8 +60,11 @@ export default defineTool({
60
60
  description:
61
61
  "Send to a Telegram chat: a text message (`text` — long text is split into multiple messages " +
62
62
  "automatically), or a local file (`path` — a document, or a photo if it is an image). Exactly one " +
63
- "of text/path. In a chat turn take chatId from the [telegram: chat …] context line; in a " +
64
- "scheduled/woken turn (no context line) the chat id must come from your instruction.",
63
+ "of text/path. Use it for a turn NO channel is carrying a scheduled or self-scheduled (wake) " +
64
+ "turn or to reach a chat OTHER than the one you are answering. In a normal chat turn the channel " +
65
+ "already delivers your reply, so do NOT call this to answer (it would send it twice). chatId comes " +
66
+ "from the [telegram: chat …] context line in a chat turn; a scheduled/woken turn has no context " +
67
+ "line, so name the destination in your instruction.",
65
68
  input: z.object({
66
69
  chatId: z.union([z.string(), z.number()]).describe("target chat id"),
67
70
  text: z.string().optional().describe("message text to send"),
@@ -109,7 +109,7 @@ export function telegramChannel({ secretToken, botToken, route, onError, botUser
109
109
  throw new Error(`telegramChannel requires an absolute ctx.stateRoot, got "${stateRoot}"`);
110
110
  }
111
111
  const stateHome = join(stateRoot, "channels", "telegram");
112
- ensureStateHome(stateHome); // create + self-ignore — buffers/files may carry chat content
112
+ ensureStateHome(stateHome); // buffers/files may carry chat content; the agent .gitignore covers .state/
113
113
  const buffer = createContextBuffer(join(stateHome, "buffers.json"));
114
114
  // Durable turn intent (L1): persist an accepted turn pre-ACK, remove it when the turn ends; a crash
115
115
  // leaves it for replay on the next start. See turn-store.ts for the at-least-once semantics.
@@ -1,6 +1,20 @@
1
1
  /** Pure Unicode-safe text slicing helpers shared by channel rendering paths (Feishu/Lark cards, the preview kit). JavaScript string
2
2
  * indexes are UTF-16 code units, so direct `slice()` can tear a surrogate pair and send replacement
3
3
  * characters after JSON/UTF-8 encoding. These helpers only cut at Unicode code-point boundaries. */
4
+ /**
5
+ * How much of a replied-to message is quoted back into a prompt.
6
+ *
7
+ * A referent is the exact text the asker is pointing AT, not a summary of it, so the bound is a
8
+ * fidelity bound: it must clear the largest message a chat platform will accept (Telegram's 4096 is
9
+ * the tightest of ours) or a perfectly legal message loses its tail and the agent answers about text
10
+ * it cannot see — silently. Past that point it is only a guard against a pathological message on a
11
+ * platform with no practical cap. One constant for every channel: the failure this replaces was two
12
+ * channels picking their own number and drifting 14x apart.
13
+ *
14
+ * Distinct from a context-buffer line (see BUFFER_LINE_MAX_CHARS), which is a digest competing for a
15
+ * shared budget — different job, different unit, must not share a number.
16
+ */
17
+ export declare const REFERENT_MAX_CODE_POINTS = 4096;
4
18
  /** Take at most `maxPoints` Unicode code points from the start, without adding a marker. */
5
19
  export declare function codePointPrefix(text: string, maxPoints: number): string;
6
20
  /** Ellipsize from the right while keeping the result within `maxPoints` Unicode code points. */
@@ -1,6 +1,20 @@
1
1
  /** Pure Unicode-safe text slicing helpers shared by channel rendering paths (Feishu/Lark cards, the preview kit). JavaScript string
2
2
  * indexes are UTF-16 code units, so direct `slice()` can tear a surrogate pair and send replacement
3
3
  * characters after JSON/UTF-8 encoding. These helpers only cut at Unicode code-point boundaries. */
4
+ /**
5
+ * How much of a replied-to message is quoted back into a prompt.
6
+ *
7
+ * A referent is the exact text the asker is pointing AT, not a summary of it, so the bound is a
8
+ * fidelity bound: it must clear the largest message a chat platform will accept (Telegram's 4096 is
9
+ * the tightest of ours) or a perfectly legal message loses its tail and the agent answers about text
10
+ * it cannot see — silently. Past that point it is only a guard against a pathological message on a
11
+ * platform with no practical cap. One constant for every channel: the failure this replaces was two
12
+ * channels picking their own number and drifting 14x apart.
13
+ *
14
+ * Distinct from a context-buffer line (see BUFFER_LINE_MAX_CHARS), which is a digest competing for a
15
+ * shared budget — different job, different unit, must not share a number.
16
+ */
17
+ export const REFERENT_MAX_CODE_POINTS = 4096;
4
18
  /** Take at most `maxPoints` Unicode code points from the start, without adding a marker. */
5
19
  export function codePointPrefix(text, maxPoints) {
6
20
  if (maxPoints <= 0)
@@ -0,0 +1,21 @@
1
+ export interface ThreadParticipants {
2
+ /**
3
+ * The participant model's summon rule (docs/design/participant-model.md §3): does a bare message in
4
+ * this thread address the agent? True where it takes part AND no second human has been heard. Lives
5
+ * here rather than in each channel because it is one rule over one store — `<= 1` is an easy edge to
6
+ * get wrong twice, and "a second human restores the mention requirement" must have one place to change.
7
+ */
8
+ admitsBareMessage(key: string): boolean;
9
+ /**
10
+ * Merge in what was just heard. Idempotent; a failed write is a warning, never a failed delivery.
11
+ *
12
+ * The parameter only admits values the store can honour: observations accumulate, so `agentSpoke`
13
+ * can be set but never cleared and `humans` unions. Passing `false` would compile and do nothing,
14
+ * so the type refuses it — "never shed" is an invariant, not a convention.
15
+ */
16
+ merge(key: string, heard: {
17
+ humans?: string[];
18
+ agentSpoke?: true;
19
+ }): void;
20
+ }
21
+ export declare function createThreadParticipants(path: string, label: string): ThreadParticipants;