typeclaw 0.4.0 → 0.5.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 (56) hide show
  1. package/package.json +1 -1
  2. package/src/agent/auth.ts +4 -2
  3. package/src/agent/index.ts +16 -28
  4. package/src/agent/model-fallback.ts +127 -0
  5. package/src/agent/tools/curl-impersonate.ts +300 -0
  6. package/src/agent/tools/ddg.ts +13 -88
  7. package/src/agent/tools/webfetch/fetch.ts +105 -2
  8. package/src/agent/tools/webfetch/tool.ts +4 -0
  9. package/src/bundled-plugins/agent-browser/shim.ts +47 -0
  10. package/src/bundled-plugins/backup/subagents.ts +2 -0
  11. package/src/bundled-plugins/memory/README.md +49 -12
  12. package/src/bundled-plugins/memory/citation-superset.ts +63 -0
  13. package/src/bundled-plugins/memory/dreaming.ts +105 -17
  14. package/src/bundled-plugins/memory/index.ts +2 -2
  15. package/src/bundled-plugins/memory/memory-logger.ts +45 -26
  16. package/src/bundled-plugins/memory/strength.ts +127 -0
  17. package/src/bundled-plugins/memory/topics.ts +75 -0
  18. package/src/bundled-plugins/security/index.ts +87 -43
  19. package/src/bundled-plugins/security/permissions.ts +36 -0
  20. package/src/bundled-plugins/security/policies/git-exfil.ts +20 -0
  21. package/src/bundled-plugins/security/policies/outbound-secret-scan.ts +12 -0
  22. package/src/bundled-plugins/security/policies/prompt-injection.ts +23 -3
  23. package/src/bundled-plugins/security/policies/secret-exfil-bash.ts +7 -0
  24. package/src/bundled-plugins/security/policies/secret-exfil-read.ts +6 -0
  25. package/src/bundled-plugins/security/policies/session-search-secrets.ts +9 -0
  26. package/src/bundled-plugins/security/policies/ssrf.ts +6 -0
  27. package/src/bundled-plugins/security/policies/system-prompt-leak.ts +7 -0
  28. package/src/channels/adapters/github/index.ts +87 -3
  29. package/src/channels/router.ts +194 -28
  30. package/src/channels/types.ts +3 -1
  31. package/src/cli/channel.ts +2 -45
  32. package/src/cli/init.ts +148 -87
  33. package/src/cli/model.ts +12 -3
  34. package/src/cli/oauth-callbacks.ts +49 -0
  35. package/src/cli/provider.ts +3 -20
  36. package/src/cli/ui.ts +95 -0
  37. package/src/config/config.ts +59 -24
  38. package/src/config/models-mutation.ts +42 -8
  39. package/src/config/providers-mutation.ts +12 -8
  40. package/src/container/start.ts +18 -1
  41. package/src/cron/consumer.ts +129 -43
  42. package/src/init/dockerfile.ts +221 -3
  43. package/src/init/hatching.ts +2 -2
  44. package/src/init/index.ts +47 -3
  45. package/src/init/oauth-login.ts +17 -3
  46. package/src/permissions/builtins.ts +29 -7
  47. package/src/permissions/permissions.ts +24 -7
  48. package/src/plugin/define.ts +2 -0
  49. package/src/plugin/manager.ts +14 -0
  50. package/src/plugin/types.ts +6 -0
  51. package/src/run/index.ts +2 -1
  52. package/src/skills/typeclaw-memory/SKILL.md +25 -15
  53. package/src/skills/typeclaw-permissions/SKILL.md +35 -17
  54. package/src/tui/index.ts +35 -3
  55. package/src/usage/report.ts +15 -12
  56. package/typeclaw.schema.json +57 -25
@@ -1,8 +1,28 @@
1
+ import type { SecuritySeverity } from '../permissions'
1
2
  import { ACKNOWLEDGE_GUARDS, type SecurityBlock, isGuardAcknowledged } from '../policy'
2
3
  import { getRemoteTaint, recordRemoteTaint } from './remote-taint-state'
3
4
 
4
5
  export const GUARD_GIT_EXFIL = 'gitExfil'
6
+ // Classified `high` (audience-leak axis): `git push` sends every tracked
7
+ // file to a remote git host. The host (GitHub/GitLab/attacker-controlled
8
+ // box) is a third-party audience outside the operator's control loop.
9
+ // Even a private remote owned by an attacker is now outside the
10
+ // perimeter. No role auto-bypasses high — owner pushing from TUI must ack
11
+ // each push. The historical per-guard string `security.bypass.gitExfil`
12
+ // remains valid as an explicit grant for operators who knowingly want to
13
+ // re-open the auto-bypass (see SKILL.md must-not-do guidance).
14
+ export const GUARD_GIT_EXFIL_SEVERITY: SecuritySeverity = 'high'
5
15
  export const GUARD_GIT_REMOTE_TAINTED = 'gitRemoteTainted'
16
+ // Classified `high` (audience-leak axis): same path as gitExfil, second
17
+ // step. A push after a mid-session `git remote set-url` to an
18
+ // attacker-controlled URL is exactly the breach pattern that motivated
19
+ // the entire security plugin per PR #134. The recorder-vs-checker split
20
+ // (see comment on recordGitRemoteTaintIfAny below) is still load-bearing:
21
+ // the recorder fires for anyone who can run the underlying command (ack
22
+ // or the per-guard `bypassGitExfil` grant), so even if an operator
23
+ // explicitly grants `bypassGitExfil` to a role, the second-step taint
24
+ // check still fires on the eventual push.
25
+ export const GUARD_GIT_REMOTE_TAINTED_SEVERITY: SecuritySeverity = 'high'
6
26
 
7
27
  // Anchors we reuse: a `git` token must be at start-of-line or follow a shell
8
28
  // separator. This blocks `git push` while letting `cgit-something` through
@@ -1,6 +1,18 @@
1
+ import type { SecuritySeverity } from '../permissions'
1
2
  import { ACKNOWLEDGE_GUARDS, type SecurityBlock, isGuardAcknowledged } from '../policy'
2
3
 
3
4
  export const GUARD_OUTBOUND_SECRET = 'outboundSecret'
5
+ // Classified `high` (audience-leak axis): bypass posts credential-shaped
6
+ // text to a chat channel whose readership is a third-party audience
7
+ // outside the operator's control loop. Channel readers, push-notification
8
+ // previews, search indexes, and other bots in the channel all see the
9
+ // secret before the operator can intervene. Owner-in-public-channel is
10
+ // the canonical motivating case: even owner asking the agent to "post the
11
+ // deploy status" should not be able to silently include a stack-trace
12
+ // `Bearer ghp_...` line. The whole point of the high tier is that
13
+ // audience-leak guards require per-call ack from every role, including
14
+ // owner — see AGENTS.md `## Permissions` rules of thumb.
15
+ export const GUARD_OUTBOUND_SECRET_SEVERITY: SecuritySeverity = 'high'
4
16
 
5
17
  const SIGNATURE_PATTERNS: ReadonlyArray<{ kind: string; pattern: RegExp }> = [
6
18
  { kind: 'aws_access_key_id', pattern: /\b(?:AKIA|ASIA|AGPA|AIDA|AROA|AIPA|ANPA|ANVA|ABIA|ACCA)[A-Z0-9]{16}\b/ },
@@ -463,10 +463,30 @@ export function detectPromptInjection(prompt: string): InjectionMatch[] {
463
463
 
464
464
  const DEFENSE_MARKER = '[security/prompt-injection]'
465
465
 
466
+ // Subagent prompts are constructed by trusted bundled code, not from raw
467
+ // user input. The backup-diagnose subagent in particular embeds raw git
468
+ // stderr (which legitimately contains literal "git push --help" hint
469
+ // strings on fast-forward rejection or missing-upstream failures) — those
470
+ // hits would otherwise trigger the git_exfil category and inject a "do
471
+ // NOT run git push" rule that contradicts the subagent's own
472
+ // system-prompt instructions to retry with an ack. Under the audience-
473
+ // leak policy the runtime tool.before is the universal backstop for
474
+ // `git push` regardless of role (no role auto-bypasses), so the prompt-
475
+ // side git_exfil category is strictly redundant for subagent origins.
476
+ // Other categories (system_prompt_dump, secret_demand,
477
+ // fake_privileged_skill) still fire for subagents because their threats
478
+ // (e.g. memory-logger ingesting an attacker's transcript) are real.
479
+ function filterForOrigin(matches: InjectionMatch[], origin: SessionPromptEvent['origin']): InjectionMatch[] {
480
+ if (origin?.kind !== 'subagent') return matches
481
+ return matches.filter((m) => m.category !== 'git_exfil')
482
+ }
483
+
466
484
  export function applyPromptInjectionDefense(event: SessionPromptEvent): InjectionMatch[] {
467
- const matches = detectPromptInjection(event.prompt)
468
- if (matches.length === 0) return matches
469
- if (event.prompt.includes(DEFENSE_MARKER)) return matches
485
+ const allMatches = detectPromptInjection(event.prompt)
486
+ if (allMatches.length === 0) return allMatches
487
+ if (event.prompt.includes(DEFENSE_MARKER)) return allMatches
488
+ const matches = filterForOrigin(allMatches, event.origin)
489
+ if (matches.length === 0) return allMatches
470
490
 
471
491
  const categories = Array.from(new Set(matches.map((m) => m.category))).join(', ')
472
492
  const note = [
@@ -1,6 +1,13 @@
1
+ import type { SecuritySeverity } from '../permissions'
1
2
  import { ACKNOWLEDGE_GUARDS, type SecurityBlock, isGuardAcknowledged } from '../policy'
2
3
 
3
4
  export const GUARD_SECRET_EXFIL_BASH = 'secretExfilBash'
5
+ // Classified `medium` (silent-attack axis): bypass dumps the whole
6
+ // environment (every API key, every token) into the agent's tool-result
7
+ // buffer. No direct channel side effect — operator only sees on session
8
+ // review — but the secrets are now in model context and one channel_send
9
+ // away from a third-party audience. Silent at the moment of leak.
10
+ export const GUARD_SECRET_EXFIL_BASH_SEVERITY: SecuritySeverity = 'medium'
4
11
 
5
12
  const DANGEROUS_COMMAND_PATTERNS: ReadonlyArray<{ pattern: RegExp; label: string }> = [
6
13
  { pattern: /(^|[\s;|&(`$])(env|printenv)([\s;|&)`]|$)/, label: 'env / printenv (full environment dump)' },
@@ -1,8 +1,14 @@
1
1
  import path from 'node:path'
2
2
 
3
+ import type { SecuritySeverity } from '../permissions'
3
4
  import { ACKNOWLEDGE_GUARDS, type SecurityBlock, isGuardAcknowledged } from '../policy'
4
5
 
5
6
  export const GUARD_SECRET_EXFIL_READ = 'secretExfilRead'
7
+ // Classified `medium` (silent-attack axis): bypass returns `.env` /
8
+ // credential-file contents into model context. Same shape as
9
+ // secretExfilBash — silent at the moment of read, becomes catastrophic on
10
+ // the next channel-side tool call that quotes it.
11
+ export const GUARD_SECRET_EXFIL_READ_SEVERITY: SecuritySeverity = 'medium'
6
12
 
7
13
  const SENSITIVE_BASENAMES = new Set([
8
14
  '.env',
@@ -1,6 +1,15 @@
1
+ import type { SecuritySeverity } from '../permissions'
1
2
  import { ACKNOWLEDGE_GUARDS, type SecurityBlock, isGuardAcknowledged } from '../policy'
2
3
 
3
4
  export const GUARD_SESSION_SEARCH_SECRETS = 'sessionSearchSecrets'
5
+ // Classified `medium` (silent-attack axis): bypass returns secret-shaped
6
+ // session-search hits into the agent's tool-result buffer. The operator
7
+ // doesn't see the raw hits — the agent summarizes them — so the leak is
8
+ // silent from the operator's perspective even though it's a read tool.
9
+ // The hits then live in model context as a precondition for a later
10
+ // channel_send leak; outboundSecret would catch the actual send, but
11
+ // silent-recon-then-summarize is its own attack shape.
12
+ export const GUARD_SESSION_SEARCH_SECRETS_SEVERITY: SecuritySeverity = 'medium'
4
13
 
5
14
  const SESSION_SEARCH_TOOLS: ReadonlySet<string> = new Set([
6
15
  'session_search',
@@ -1,6 +1,12 @@
1
+ import type { SecuritySeverity } from '../permissions'
1
2
  import { ACKNOWLEDGE_GUARDS, type SecurityBlock, isGuardAcknowledged } from '../policy'
2
3
 
3
4
  export const GUARD_SSRF = 'ssrf'
5
+ // Classified `medium` (silent-attack axis): bypass lets `curl
6
+ // http://169.254.169.254/...` return cloud-metadata IAM credentials into
7
+ // model context. Silent — no channel side effect at the moment of fetch.
8
+ // Catastrophic on follow-up because the model now has live cloud creds.
9
+ export const GUARD_SSRF_SEVERITY: SecuritySeverity = 'medium'
4
10
 
5
11
  const ALWAYS_BLOCKED_HOSTS = new Set([
6
12
  'localhost',
@@ -1,6 +1,13 @@
1
+ import type { SecuritySeverity } from '../permissions'
1
2
  import { ACKNOWLEDGE_GUARDS, type SecurityBlock, isGuardAcknowledged } from '../policy'
2
3
 
3
4
  export const GUARD_SYSTEM_PROMPT_LEAK = 'systemPromptLeak'
5
+ // Classified `high` (audience-leak axis): bypass posts TypeClaw runtime
6
+ // fingerprints / system-prompt fragments to a chat. Same shape as
7
+ // outboundSecret — third-party audience, no operator intervention before
8
+ // the leak lands. Disclosure also enables recon for later targeted
9
+ // prompt-injection attacks against this agent.
10
+ export const GUARD_SYSTEM_PROMPT_LEAK_SEVERITY: SecuritySeverity = 'high'
4
11
 
5
12
  const FINGERPRINT_PATTERNS: ReadonlyArray<{ label: string; pattern: RegExp }> = [
6
13
  { label: 'TypeClaw runtime preamble', pattern: /You are a general-purpose AI agent running inside TypeClaw\./ },
@@ -175,7 +175,7 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
175
175
  managedHooks = registration.repos.flatMap((r) =>
176
176
  r.action === 'created' || r.action === 'updated' ? [{ repo: r.repo, hookId: r.hookId }] : [],
177
177
  )
178
- logRegistrationOutcome(logger, registration)
178
+ logRegistrationOutcome(logger, registration, options.secrets.auth.type)
179
179
  }
180
180
  },
181
181
  async stop(): Promise<void> {
@@ -264,14 +264,98 @@ function detectLegacyProviderHostSuffix(url: string): string | undefined {
264
264
  return undefined
265
265
  }
266
266
 
267
- function logRegistrationOutcome(logger: GithubAdapterLogger, result: WebhookRegistrationResult): void {
267
+ function logRegistrationOutcome(
268
+ logger: GithubAdapterLogger,
269
+ result: WebhookRegistrationResult,
270
+ authType: 'pat' | 'app',
271
+ ): void {
272
+ const permissionFailures: Array<{ repo: string; status: number }> = []
268
273
  for (const r of result.repos) {
269
274
  if (r.action === 'created') logger.info(`[github] registered webhook ${r.hookId} on ${r.repo}`)
270
275
  else if (r.action === 'updated') {
271
276
  const tail = r.stalePruned > 0 ? ` (pruned ${r.stalePruned} stale)` : ''
272
277
  logger.info(`[github] updated webhook ${r.hookId} on ${r.repo}${tail}`)
273
- } else logger.warn(`[github] webhook register failed for ${r.repo}: ${r.error}`)
278
+ } else {
279
+ logger.warn(`[github] webhook register failed for ${r.repo}: ${r.error}`)
280
+ const status = parseListHooksPermissionStatus(r.error)
281
+ if (status !== null) permissionFailures.push({ repo: r.repo, status })
282
+ }
283
+ }
284
+ // One guidance block per start() (not per repo) so a 10-repo permission
285
+ // failure doesn't paste the same paragraph 10 times. The names below MUST
286
+ // match the current github.com UI labels — see comment in
287
+ // buildPermissionGuidance.
288
+ if (permissionFailures.length > 0) {
289
+ logger.warn(buildPermissionGuidance(authType, permissionFailures))
290
+ }
291
+ }
292
+
293
+ // Parses webhook-register errors of the shape `list hooks failed: <status> <body>`.
294
+ // Returns the status code when it matches the two shapes GitHub emits for
295
+ // missing access on the list-hooks endpoint:
296
+ // - 404 Not Found: the token cannot see the repo at all (private repo
297
+ // gated behind missing repository access — GitHub returns 404 instead of
298
+ // 403 to avoid leaking the existence of private repos).
299
+ // - 403 Forbidden: the token sees the repo but lacks webhook-management
300
+ // permission, OR is blocked by an org SSO/SAML authorization gate.
301
+ // Returns null for any other error (network, malformed slug, create-hook
302
+ // failures, etc.) so the guidance only fires on the actual symptom.
303
+ export function parseListHooksPermissionStatus(error: string): number | null {
304
+ const match = error.match(/^list hooks failed: (404|403)\b/)
305
+ if (match === null) return null
306
+ return Number(match[1])
307
+ }
308
+
309
+ // The labels below intentionally mirror github.com's current UI verbatim so a
310
+ // user can grep their settings page for the exact string. If GitHub renames
311
+ // any of these in a future redesign, update both here and the
312
+ // `permissionGuidance` tests in lifecycle.test.ts.
313
+ //
314
+ // Fine-grained PAT:
315
+ // Settings → Developer settings → Personal access tokens → Fine-grained tokens
316
+ // "Resource owner", "Repository access", "Repository permissions" → "Webhooks" → "Read and write", "Metadata" → "Read-only"
317
+ // GitHub App:
318
+ // Settings → Developer settings → GitHub Apps → <app> → Permissions & events
319
+ // "Repository permissions" → "Webhooks" → "Read and write"
320
+ // Install/configure on the org: <app settings> → Install App / Configure → "Repository access"
321
+ // Classic PAT (legacy, still supported by GitHub but we don't surface it in
322
+ // channel-add prompts):
323
+ // Settings → Developer settings → Personal access tokens (classic)
324
+ // Scope: "admin:repo_hook" (or full "repo" for private repositories)
325
+ export function buildPermissionGuidance(
326
+ authType: 'pat' | 'app',
327
+ failures: ReadonlyArray<{ repo: string; status: number }>,
328
+ ): string {
329
+ const repoList = failures.map((f) => `${f.repo} (${f.status})`).join(', ')
330
+ const lines: string[] = [
331
+ `[github] webhook setup needs more access for: ${repoList}.`,
332
+ ' - 404 from GitHub means the token cannot see the repo (GitHub hides private repos behind 404 instead of 403).',
333
+ ' - 403 means the token sees the repo but lacks webhook permission, or is blocked by org SAML/SSO.',
334
+ '',
335
+ ]
336
+ if (authType === 'pat') {
337
+ lines.push(
338
+ ' Fix (fine-grained personal access token):',
339
+ ' 1. Open https://github.com/settings/personal-access-tokens and edit the token TypeClaw is using.',
340
+ ' 2. Under "Resource owner", select the org that owns the failing repos (e.g. the org in the slug above).',
341
+ ' 3. Under "Repository access", choose "Only select repositories" and add every failing repo (or pick "All repositories").',
342
+ ' 4. Under "Repository permissions", set "Webhooks" to "Read and write" and "Metadata" to "Read-only".',
343
+ ' 5. Save. If the org enforces SAML SSO, click "Configure SSO" next to the token and authorize the org.',
344
+ '',
345
+ ' Or (classic personal access token): grant the "admin:repo_hook" scope (or "repo" for private repos),',
346
+ ' and on a SAML-protected org click "Authorize" next to the token.',
347
+ )
348
+ } else {
349
+ lines.push(
350
+ ' Fix (GitHub App):',
351
+ ' 1. Open https://github.com/settings/apps and edit the app TypeClaw is using.',
352
+ ' 2. Under "Permissions & events" → "Repository permissions", set "Webhooks" to "Read and write". Save.',
353
+ ' 3. From the app page, click "Install App" (or "Configure" if already installed) and select the org that owns the failing repos.',
354
+ ' 4. Under "Repository access", choose "Only select repositories" and add every failing repo (or pick "All repositories").',
355
+ ' 5. If the app permissions changed in step 2, install owners must accept the updated permissions from the install page before the new access takes effect.',
356
+ )
274
357
  }
358
+ return lines.join('\n')
275
359
  }
276
360
 
277
361
  function logDeregistrationOutcome(
@@ -78,6 +78,24 @@ export const MAX_TYPING_HEARTBEAT_MS = 2 * 60 * 1000
78
78
  export const SESSION_IDLE_MS = 30 * 60 * 1000
79
79
  export const SESSION_GC_INTERVAL_MS = 60 * 1000
80
80
 
81
+ // Hard cap on tool-initiated outbound sends per (chat:thread) per turn.
82
+ // The original loop-incident emitted ~50 sends in one turn; even
83
+ // legitimate split replies rarely cross 8. 10 leaves headroom for
84
+ // genuine multi-part answers while definitively stopping runaway loops.
85
+ // Enforced inside router.send for `source: 'tool'` callers; system
86
+ // recovery paths (`source: 'system'`) bypass.
87
+ export const MAX_CHANNEL_SENDS_PER_TURN = 10
88
+ // Rolling window for outbound send-rate telemetry. 5s matches Discord's
89
+ // rate-limit shape (5 msg / 5 s / channel) and comfortably covers Slack's
90
+ // 1 msg/s sustained. The window is observational; exceeding the burst
91
+ // threshold below escalates the per-send log to a warning.
92
+ export const SEND_RATE_WINDOW_MS = 5_000
93
+ // Above this in-window count, the per-send log line escalates to a
94
+ // `send_rate_warning` so a burst stands out in the log stream. Every
95
+ // send still emits a structured log line regardless of rate — this
96
+ // constant only controls when the warning marker appears.
97
+ export const SEND_RATE_WARN_THRESHOLD = 3
98
+
81
99
  /**
82
100
  * Maximum age of the last engaged inbound before the next inbound triggers a fresh session.
83
101
  * Set to the LLM provider's KV-cache TTL (5 min) so the new session's system prompt is
@@ -244,6 +262,26 @@ type LiveSession = {
244
262
  // router.send so the hint reflects the position of the about-to-happen send
245
263
  // (n-th in a row), nudging the model to yield without forcing it to.
246
264
  consecutiveSends: Map<string, number>
265
+ // Per-(chat:thread) text of the last reserved bot send. Set
266
+ // SYNCHRONOUSLY inside router.send before the outbound callback awaits,
267
+ // so two concurrent `router.send` calls for the same target cannot both
268
+ // pass the duplicate guard. Cleared on every new prompt batch (same
269
+ // lifecycle as `consecutiveSends`). The scope is "last 1 send within
270
+ // this turn" so legitimate multi-part replies (different bodies) and
271
+ // across-turn callbacks ("yes, I'm here" twice) are not blocked. Empty
272
+ // strings are normalized to undefined before storage so attachments-only
273
+ // sends never poison the tracker. The fuzzy-match upgrade is intentionally
274
+ // deferred — exact-match has zero false-positive risk by construction.
275
+ lastSentText: Map<string, string>
276
+ // Per-(chat:thread) ring of send timestamps (epoch ms) within the rolling
277
+ // SEND_RATE_WINDOW_MS window. Append-on-send, prune-on-read. Lifecycle is
278
+ // wall-clock (NOT cleared on new prompt batches) because rate is a
279
+ // property of the channel over time, not the agent's turn structure — a
280
+ // burst that straddles two adjacent turns is still a burst from the chat
281
+ // platform's POV. Telemetry-only today; the rate is logged when count
282
+ // crosses SEND_RATE_LOG_THRESHOLD so production data can inform a
283
+ // future hard cap without picking a threshold out of thin air.
284
+ sendTimestamps: Map<string, number[]>
247
285
  successfulChannelSends: number
248
286
  // Loop-guard state. See PEER_BOT_TURNS_WINDOW_MS / MAX_* constants
249
287
  // above. Updated in route() on every engaged peer-bot inbound, reset on
@@ -264,15 +302,35 @@ type ChannelCommandContext = {
264
302
  event: InboundMessage
265
303
  }
266
304
 
305
+ export type SendSource = 'tool' | 'system'
306
+
307
+ export type SendOptions = {
308
+ source?: SendSource
309
+ }
310
+
311
+ export const DUPLICATE_SEND_ERROR =
312
+ 'Duplicate not sent. Do not call channel_send/channel_reply again this turn. ' +
313
+ 'End with NO_REPLY unless you have genuinely new, non-redundant information.'
314
+
315
+ export const TURN_CAP_ERROR =
316
+ `Send-cap reached for this turn (${MAX_CHANNEL_SENDS_PER_TURN} messages already sent to this conversation). ` +
317
+ 'End your turn now. The user can prompt you again for more output.'
318
+
267
319
  export type ChannelRouter = {
268
320
  route: (event: InboundMessage) => Promise<void>
269
- send: (msg: OutboundMessage) => Promise<SendResult>
321
+ send: (msg: OutboundMessage, opts?: SendOptions) => Promise<SendResult>
270
322
  getConsecutiveSendCount: (target: {
271
323
  adapter: ChannelKey['adapter']
272
324
  workspace: string
273
325
  chat: string
274
326
  thread?: string | null
275
327
  }) => number
328
+ getSendRate: (target: {
329
+ adapter: ChannelKey['adapter']
330
+ workspace: string
331
+ chat: string
332
+ thread?: string | null
333
+ }) => { count: number; windowMs: number }
276
334
  registerOutbound: (adapter: ChannelKey['adapter'], cb: OutboundCallback) => void
277
335
  unregisterOutbound: (adapter: ChannelKey['adapter'], cb: OutboundCallback) => void
278
336
  registerTyping: (adapter: ChannelKey['adapter'], cb: TypingCallback) => void
@@ -719,6 +777,8 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
719
777
  lastTurnAuthorIds: new Set(),
720
778
  consecutiveAborts: 0,
721
779
  consecutiveSends: new Map(),
780
+ lastSentText: new Map(),
781
+ sendTimestamps: new Map(),
722
782
  successfulChannelSends: 0,
723
783
  recentEngagedPeerBotTurns: [],
724
784
  consecutiveEngagedPeerBotTurns: 0,
@@ -1011,7 +1071,10 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1011
1071
 
1012
1072
  live.currentTurnAuthorId = batch.length > 0 ? batch[batch.length - 1]!.authorId : null
1013
1073
  live.currentTurnAuthorIds = new Set(batch.map((m) => m.authorId))
1014
- if (batch.length > 0) live.consecutiveSends.clear()
1074
+ if (batch.length > 0) {
1075
+ live.consecutiveSends.clear()
1076
+ live.lastSentText.clear()
1077
+ }
1015
1078
 
1016
1079
  // Update the live origin holder so this turn's tool.before events
1017
1080
  // carry the current actor's id. The DefaultResourceLoader still
@@ -1036,6 +1099,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1036
1099
  } catch (err) {
1037
1100
  logger.error(`[channels] ${live.keyId}: prompt threw: ${describe(err)}`)
1038
1101
  live.consecutiveSends.clear()
1102
+ live.lastSentText.clear()
1039
1103
  } finally {
1040
1104
  await fireSessionTurnEnd(live)
1041
1105
  }
@@ -1108,13 +1172,16 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1108
1172
  logger.info(
1109
1173
  `[channels] ${channelKeyId(key)}: claim ${outcome.kind} author=${event.authorId} id=${event.externalMessageId}`,
1110
1174
  )
1111
- await send({
1112
- adapter: event.adapter,
1113
- workspace: event.workspace,
1114
- chat: event.chat,
1115
- thread: event.thread,
1116
- text: outcome.reply,
1117
- })
1175
+ await send(
1176
+ {
1177
+ adapter: event.adapter,
1178
+ workspace: event.workspace,
1179
+ chat: event.chat,
1180
+ thread: event.thread,
1181
+ text: outcome.reply,
1182
+ },
1183
+ { source: 'system' },
1184
+ )
1118
1185
  return
1119
1186
  }
1120
1187
  }
@@ -1421,10 +1488,52 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1421
1488
  return lastError
1422
1489
  }
1423
1490
 
1424
- const send = async (msg: OutboundMessage): Promise<SendResult> => {
1491
+ const send = async (msg: OutboundMessage, opts?: SendOptions): Promise<SendResult> => {
1492
+ const source: SendSource = opts?.source ?? 'tool'
1425
1493
  const callbacks = outboundCallbacks.get(msg.adapter)
1426
1494
  if (!callbacks || callbacks.size === 0) {
1427
- return { ok: false, error: `no adapter registered for "${msg.adapter}"` }
1495
+ return { ok: false, error: `no adapter registered for "${msg.adapter}"`, code: 'no-adapter' }
1496
+ }
1497
+
1498
+ const keyId = channelKeyId({
1499
+ adapter: msg.adapter,
1500
+ workspace: msg.workspace,
1501
+ chat: msg.chat,
1502
+ thread: msg.thread ?? null,
1503
+ })
1504
+ const live = liveSessions.get(keyId)
1505
+ const sendKey = consecutiveSendKey(msg.chat, msg.thread)
1506
+ const text = normalizeSendText(msg.text)
1507
+
1508
+ // Central enforcement. Tool-initiated sends are subject to two policies:
1509
+ // a per-turn count cap (kills runaway loops regardless of content) and
1510
+ // an exact-duplicate guard (kills the byte-identical-spam sub-mode).
1511
+ // Both checks AND the state mutations they consult happen synchronously
1512
+ // before any `await`, so two concurrent `router.send` calls for the same
1513
+ // target (the parallel-tool-execution race) cannot both pass: the
1514
+ // second observer sees the first one's increment / lastSentText write.
1515
+ // System sources (validateChannelTurn recovery, role-claim reply) bypass
1516
+ // — those are one-shot paths the policy doesn't apply to.
1517
+ let priorLastSentText: string | undefined
1518
+ let reserved = false
1519
+ if (live && source === 'tool') {
1520
+ const currentCount = live.consecutiveSends.get(sendKey) ?? 0
1521
+ if (currentCount >= MAX_CHANNEL_SENDS_PER_TURN) {
1522
+ return { ok: false, error: TURN_CAP_ERROR, code: 'turn-cap' }
1523
+ }
1524
+ if (text !== undefined && live.lastSentText.get(sendKey) === text) {
1525
+ return { ok: false, error: DUPLICATE_SEND_ERROR, code: 'duplicate' }
1526
+ }
1527
+ // Reserve the slot before awaiting. If the callback rejects we roll
1528
+ // back below; if it succeeds we keep the increment. The slot reserve
1529
+ // is what makes parallel tool calls safe. We also snapshot the prior
1530
+ // lastSentText so a transient delivery failure can be retried with
1531
+ // the same text — the dup-guard exists to stop runaway loops, not to
1532
+ // strand the model on a flaky adapter.
1533
+ priorLastSentText = live.lastSentText.get(sendKey)
1534
+ live.consecutiveSends.set(sendKey, currentCount + 1)
1535
+ if (text !== undefined) live.lastSentText.set(sendKey, text)
1536
+ reserved = true
1428
1537
  }
1429
1538
 
1430
1539
  // Snapshot the callbacks before iterating so a callback that mutates the
@@ -1443,16 +1552,20 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1443
1552
  }
1444
1553
 
1445
1554
  if (!delivered) {
1446
- return { ok: false, error: lastError ?? 'no callback accepted the outbound' }
1555
+ // Roll back the slot reservation so a failed send doesn't burn cap
1556
+ // budget or poison the dup-guard. Restoring lastSentText to its
1557
+ // prior value (which may be undefined) lets a legitimate retry of
1558
+ // the same text succeed — the dup-guard is for loops, not flake.
1559
+ if (live && reserved) {
1560
+ const after = (live.consecutiveSends.get(sendKey) ?? 1) - 1
1561
+ if (after <= 0) live.consecutiveSends.delete(sendKey)
1562
+ else live.consecutiveSends.set(sendKey, after)
1563
+ if (priorLastSentText === undefined) live.lastSentText.delete(sendKey)
1564
+ else live.lastSentText.set(sendKey, priorLastSentText)
1565
+ }
1566
+ return { ok: false, error: lastError ?? 'no callback accepted the outbound', code: 'callback-rejected' }
1447
1567
  }
1448
1568
 
1449
- const keyId = channelKeyId({
1450
- adapter: msg.adapter,
1451
- workspace: msg.workspace,
1452
- chat: msg.chat,
1453
- thread: msg.thread ?? null,
1454
- })
1455
- const live = liveSessions.get(keyId)
1456
1569
  if (live) {
1457
1570
  live.successfulChannelSends++
1458
1571
  // Don't stop the heartbeat here: the agent may still be mid-turn and
@@ -1477,8 +1590,13 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1477
1590
  grantStickyForReplyTargets(stickyLedger, keyId, targetIds, adapterConfig.engagement, now())
1478
1591
  }
1479
1592
  }
1480
- const sendKey = consecutiveSendKey(msg.chat, msg.thread)
1481
- live.consecutiveSends.set(sendKey, (live.consecutiveSends.get(sendKey) ?? 0) + 1)
1593
+ const turnCount = live.consecutiveSends.get(sendKey) ?? 0
1594
+ const rateCount = recordSendTimestamp(live, sendKey, now())
1595
+ const level = rateCount >= SEND_RATE_WARN_THRESHOLD ? 'warn' : 'info'
1596
+ const warn = rateCount >= SEND_RATE_WARN_THRESHOLD ? ' send_rate_warning' : ''
1597
+ const textLen = text !== undefined ? text.length : 0
1598
+ const fields = `source=${source} turn=${turnCount} rate=${rateCount}/${SEND_RATE_WINDOW_MS}ms text_len=${textLen}`
1599
+ logger[level](`[channels] ${live.keyId} send ${fields}${warn}`)
1482
1600
  }
1483
1601
 
1484
1602
  return { ok: true }
@@ -1498,13 +1616,16 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1498
1616
  logger.warn(
1499
1617
  `[channels] ${live.keyId}: recovering assistant_text_without_channel_tool text_len=${assistantText.length}`,
1500
1618
  )
1501
- const result = await send({
1502
- adapter: live.key.adapter,
1503
- workspace: live.key.workspace,
1504
- chat: live.key.chat,
1505
- thread: live.key.thread,
1506
- text: assistantText,
1507
- })
1619
+ const result = await send(
1620
+ {
1621
+ adapter: live.key.adapter,
1622
+ workspace: live.key.workspace,
1623
+ chat: live.key.chat,
1624
+ thread: live.key.thread,
1625
+ text: assistantText,
1626
+ },
1627
+ { source: 'system' },
1628
+ )
1508
1629
  if (!result.ok) {
1509
1630
  logger.warn(`[channels] ${live.keyId}: recovery send failed: ${result.error}`)
1510
1631
  }
@@ -1527,6 +1648,30 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1527
1648
  return live.consecutiveSends.get(consecutiveSendKey(target.chat, target.thread)) ?? 0
1528
1649
  }
1529
1650
 
1651
+ const getSendRate = (target: {
1652
+ adapter: ChannelKey['adapter']
1653
+ workspace: string
1654
+ chat: string
1655
+ thread?: string | null
1656
+ }): { count: number; windowMs: number } => {
1657
+ const keyId = channelKeyId({
1658
+ adapter: target.adapter,
1659
+ workspace: target.workspace,
1660
+ chat: target.chat,
1661
+ thread: target.thread ?? null,
1662
+ })
1663
+ const live = liveSessions.get(keyId)
1664
+ if (!live) return { count: 0, windowMs: SEND_RATE_WINDOW_MS }
1665
+ const sendKey = consecutiveSendKey(target.chat, target.thread)
1666
+ const buf = live.sendTimestamps.get(sendKey)
1667
+ if (!buf || buf.length === 0) return { count: 0, windowMs: SEND_RATE_WINDOW_MS }
1668
+ const cutoff = now() - SEND_RATE_WINDOW_MS
1669
+ let i = 0
1670
+ while (i < buf.length && buf[i]! <= cutoff) i++
1671
+ if (i > 0) buf.splice(0, i)
1672
+ return { count: buf.length, windowMs: SEND_RATE_WINDOW_MS }
1673
+ }
1674
+
1530
1675
  const tearDownLive = async (live: LiveSession): Promise<void> => {
1531
1676
  live.destroyed = true
1532
1677
  if (live.debounceTimer) clearTimeout(live.debounceTimer)
@@ -1585,6 +1730,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1585
1730
  route,
1586
1731
  send,
1587
1732
  getConsecutiveSendCount,
1733
+ getSendRate,
1588
1734
  registerOutbound,
1589
1735
  unregisterOutbound,
1590
1736
  registerTyping,
@@ -1759,6 +1905,26 @@ function consecutiveSendKey(chat: string, thread: string | null | undefined): st
1759
1905
  return `${chat}:${thread ?? ''}`
1760
1906
  }
1761
1907
 
1908
+ function normalizeSendText(text: string | undefined): string | undefined {
1909
+ if (text === undefined) return undefined
1910
+ if (text === '') return undefined
1911
+ return text
1912
+ }
1913
+
1914
+ function recordSendTimestamp(live: LiveSession, sendKey: string, ts: number): number {
1915
+ const buf = live.sendTimestamps.get(sendKey)
1916
+ const cutoff = ts - SEND_RATE_WINDOW_MS
1917
+ if (!buf) {
1918
+ live.sendTimestamps.set(sendKey, [ts])
1919
+ return 1
1920
+ }
1921
+ let i = 0
1922
+ while (i < buf.length && buf[i]! <= cutoff) i++
1923
+ if (i > 0) buf.splice(0, i)
1924
+ buf.push(ts)
1925
+ return buf.length
1926
+ }
1927
+
1762
1928
  function dmMembership(fetchedAt: number): MembershipCount {
1763
1929
  return { humans: 1, bots: 1, fetchedAt, truncated: false }
1764
1930
  }
@@ -84,7 +84,9 @@ export type OutboundMessage = {
84
84
  attachments?: OutboundAttachment[]
85
85
  }
86
86
 
87
- export type SendResult = { ok: true } | { ok: false; error: string }
87
+ export type SendErrorCode = 'duplicate' | 'turn-cap' | 'no-adapter' | 'callback-rejected'
88
+
89
+ export type SendResult = { ok: true } | { ok: false; error: string; code?: SendErrorCode }
88
90
 
89
91
  export type OutboundCallback = (msg: OutboundMessage) => Promise<SendResult>
90
92