typeclaw 0.20.0 → 0.22.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 (55) hide show
  1. package/package.json +2 -1
  2. package/src/agent/index.ts +55 -1
  3. package/src/agent/loop-guard.ts +180 -53
  4. package/src/agent/restart/index.ts +101 -0
  5. package/src/agent/tools/restart.ts +23 -52
  6. package/src/bundled-plugins/bun-hygiene/README.md +82 -0
  7. package/src/bundled-plugins/bun-hygiene/index.ts +11 -0
  8. package/src/bundled-plugins/bun-hygiene/policy.ts +318 -0
  9. package/src/bundled-plugins/github-cli-auth/gh-command.ts +98 -6
  10. package/src/bundled-plugins/github-cli-auth/graphql-auth-nudge.ts +80 -0
  11. package/src/bundled-plugins/github-cli-auth/index.ts +7 -0
  12. package/src/bundled-plugins/memory/memory-logger.ts +6 -2
  13. package/src/bundled-plugins/reviewer/skills/code-review.ts +8 -0
  14. package/src/channels/adapters/discord-bot-classify.ts +8 -2
  15. package/src/channels/adapters/discord-bot.ts +29 -2
  16. package/src/channels/adapters/github/decoy-reviewer.ts +43 -0
  17. package/src/channels/adapters/github/inbound.ts +92 -1
  18. package/src/channels/adapters/github/index.ts +12 -1
  19. package/src/channels/adapters/github/reactions.ts +138 -4
  20. package/src/channels/adapters/slack-bot-classify.ts +2 -2
  21. package/src/channels/adapters/slack-bot.ts +129 -7
  22. package/src/channels/engagement.ts +71 -31
  23. package/src/channels/manager.ts +8 -0
  24. package/src/channels/router.ts +180 -25
  25. package/src/channels/schema.ts +18 -0
  26. package/src/channels/types.ts +16 -1
  27. package/src/cli/builtins.ts +1 -0
  28. package/src/cli/dreams.ts +148 -0
  29. package/src/cli/index.ts +1 -0
  30. package/src/cli/inspect.ts +2 -1
  31. package/src/cli/ui.ts +34 -0
  32. package/src/commands/index.ts +5 -2
  33. package/src/config/config.ts +89 -0
  34. package/src/dreams/git.ts +85 -0
  35. package/src/dreams/index.ts +134 -0
  36. package/src/dreams/parse.ts +224 -0
  37. package/src/dreams/render.ts +155 -0
  38. package/src/dreams/types.ts +50 -0
  39. package/src/mcp/catalog.ts +29 -0
  40. package/src/mcp/client.ts +236 -0
  41. package/src/mcp/index.ts +25 -0
  42. package/src/mcp/manager.ts +156 -0
  43. package/src/mcp/tools.ts +190 -0
  44. package/src/permissions/builtins.ts +9 -0
  45. package/src/reload/format.ts +14 -0
  46. package/src/reload/index.ts +1 -0
  47. package/src/run/bundled-plugins.ts +7 -0
  48. package/src/run/channel-session-factory.ts +3 -0
  49. package/src/run/index.ts +38 -1
  50. package/src/server/command-runner.ts +5 -0
  51. package/src/server/index.ts +53 -0
  52. package/src/shared/protocol.ts +2 -0
  53. package/src/skills/typeclaw-channel-github/SKILL.md +86 -22
  54. package/src/tui/index.ts +70 -18
  55. package/typeclaw.schema.json +82 -0
@@ -1,6 +1,13 @@
1
- import { SlackBotClient, SlackBotListener, type SlackSocketModeSlashCommandArgs } from 'agent-messenger/slackbot'
1
+ import {
2
+ SlackBotClient,
3
+ SlackBotListener,
4
+ type SlackFile,
5
+ type SlackSocketModeSlashCommandArgs,
6
+ } from 'agent-messenger/slackbot'
2
7
 
3
8
  import {
9
+ MEMBERSHIP_CACHE_TRANSIENT_TTL_MS,
10
+ MEMBERSHIP_CACHE_TTL_MS,
4
11
  MEMBERSHIP_ENUMERATION_CAP,
5
12
  type MembershipResolver,
6
13
  type MembershipResolverFailure,
@@ -28,7 +35,9 @@ import { createSlackAuthorResolver } from './slack-bot-author-resolver'
28
35
  import { createSlackChannelResolver } from './slack-bot-channel-resolver'
29
36
  import {
30
37
  classifyInbound,
38
+ describeSlackFile,
31
39
  type InboundDropReason,
40
+ renderPlaceholder,
32
41
  type SlackInboundAppMentionEvent,
33
42
  type SlackInboundMessageEvent,
34
43
  } from './slack-bot-classify'
@@ -51,7 +60,7 @@ import { slackTsToMillis } from './slack-bot-time'
51
60
  // slash_commands events we route vs drop. The ui.test.ts manifest-drift
52
61
  // test asserts equality between this set and SLACK_APP_MANIFEST.features.
53
62
  // slash_commands so the two can never silently diverge.
54
- export const SLACK_SLASH_COMMAND_NAMES: ReadonlySet<string> = new Set(['help', 'stop'])
63
+ export const SLACK_SLASH_COMMAND_NAMES: ReadonlySet<string> = new Set(['help', 'stop', 'reload', 'restart'])
55
64
 
56
65
  // Resolvers fall back to the raw id on failure, so a name equal to the id
57
66
  // means resolution failed; we render the bare id rather than `id(id)`. The
@@ -369,6 +378,7 @@ type SlackRawHistoryMessage = {
369
378
  text?: string
370
379
  thread_ts?: string
371
380
  parent_user_id?: string
381
+ files?: SlackFile[]
372
382
  }
373
383
 
374
384
  type SlackHistoryResponse = {
@@ -396,6 +406,16 @@ type SlackUserInfoResponse = {
396
406
  user?: { is_bot?: boolean; deleted?: boolean }
397
407
  }
398
408
 
409
+ type SlackUsersListResponse = {
410
+ ok: boolean
411
+ error?: string
412
+ members?: Array<{ id?: string; is_bot?: boolean }>
413
+ response_metadata?: { next_cursor?: string }
414
+ }
415
+
416
+ const USERS_LIST_PAGE_LIMIT = 200
417
+ const USERS_LIST_MAX_PAGES = 50
418
+
399
419
  export function createSlackMembershipResolver(deps: {
400
420
  token: string
401
421
  logger: SlackBotAdapterLogger
@@ -406,6 +426,43 @@ export function createSlackMembershipResolver(deps: {
406
426
  const fetchFn = deps.fetchImpl ?? fetch
407
427
  const now = deps.now ?? Date.now
408
428
  const userBotCache = new Map<string, boolean>()
429
+
430
+ // Keyed by workspace. One resolver instance is bound to a single token/team
431
+ // today, but the router dispatches by adapter (not by adapter+workspace), so
432
+ // scoping the warm set by `key.workspace` keeps a set built for one workspace
433
+ // from ever classifying another's members if a multi-workspace mode is added.
434
+ const botSetCache = new Map<string, { ids: ReadonlySet<string>; fetchedAt: number }>()
435
+ const botSetFailedAt = new Map<string, number>()
436
+ const botSetInFlight = new Map<string, Promise<ReadonlySet<string> | null>>()
437
+
438
+ const warmBotSet = async (workspace: string): Promise<ReadonlySet<string> | null> => {
439
+ const cached = botSetCache.get(workspace)
440
+ if (cached !== undefined && now() - cached.fetchedAt < MEMBERSHIP_CACHE_TTL_MS) return cached.ids
441
+ // Negative-cache a failed warm so a rate-limited workspace doesn't re-run
442
+ // the full paginated `users.list` crawl on every membership read — that
443
+ // would keep the hot path expensive under the exact failure this PR fixes.
444
+ // Members fall back to per-id `users.info` during the cooldown.
445
+ const failedAt = botSetFailedAt.get(workspace)
446
+ if (failedAt !== undefined && now() - failedAt < MEMBERSHIP_CACHE_TRANSIENT_TTL_MS) return null
447
+ const inFlight = botSetInFlight.get(workspace)
448
+ if (inFlight !== undefined) return await inFlight
449
+ const promise = fetchWorkspaceBotIds(fetchFn, deps.token, deps.logger)
450
+ .then((ids) => {
451
+ if (ids !== null) {
452
+ botSetCache.set(workspace, { ids, fetchedAt: now() })
453
+ botSetFailedAt.delete(workspace)
454
+ } else {
455
+ botSetFailedAt.set(workspace, now())
456
+ }
457
+ return ids
458
+ })
459
+ .finally(() => {
460
+ botSetInFlight.delete(workspace)
461
+ })
462
+ botSetInFlight.set(workspace, promise)
463
+ return await promise
464
+ }
465
+
409
466
  return async (key): Promise<MembershipResolverResult> => {
410
467
  if (key.workspace === '@dm') return { humans: 1, bots: 1, fetchedAt: now(), truncated: false }
411
468
 
@@ -458,11 +515,22 @@ export function createSlackMembershipResolver(deps: {
458
515
  return members.failure
459
516
  }
460
517
 
518
+ // Reached only for channels at or under the cap (larger ones returned
519
+ // `truncated` above). `conversations.members` gives ids with no bot/human
520
+ // flag and Slack has no bulk-classify-ids call, so per-member `users.info`
521
+ // is an N+1 that exceeds the router cold-fetch timeout near the cap; the
522
+ // read then returns null and engagement misreads the busy channel as solo.
523
+ // Classify against a workspace bot-id set from one paginated `users.list`
524
+ // (bots are a small set, shared across channels). `users.info` stays as a
525
+ // per-id fallback for ids minted after the last warm, keeping `bots` and
526
+ // `humanMemberIds` exact for `grant_role`'s "no peer bot present" proof.
527
+ const memberIds = members.value.members ?? []
528
+ const botSet = await warmBotSet(key.workspace)
461
529
  let bots = 0
462
530
  const humanMemberIds: string[] = []
463
- for (const userId of members.value.members ?? []) {
464
- const cached = userBotCache.get(userId)
465
- const isBot = cached ?? (await resolveSlackUserIsBot(fetchFn, deps.token, userId, deps.logger, userBotCache))
531
+ for (const userId of memberIds) {
532
+ const isBot =
533
+ botSet?.has(userId) ?? (await resolveSlackUserIsBot(fetchFn, deps.token, userId, deps.logger, userBotCache))
466
534
  if (isBot) bots++
467
535
  else humanMemberIds.push(userId)
468
536
  }
@@ -504,10 +572,17 @@ async function resolveSlackUserIsBot(
504
572
  logger: SlackBotAdapterLogger,
505
573
  cache: Map<string, boolean>,
506
574
  ): Promise<boolean> {
575
+ const cached = cache.get(userId)
576
+ if (cached !== undefined) return cached
507
577
  const info = await slackApi<SlackUserInfoResponse>(fetchFn, token, 'users.info', { user: userId })
508
578
  if (!info.ok) {
509
579
  logger.warn(`[slack-bot] membership users.info user=${userId} failed: ${info.reason}`)
510
- cache.set(userId, false)
580
+ // Only a definitive answer is cached. A transient failure (429/network)
581
+ // must not be memoized as "human" — that would poison classification until
582
+ // restart and let a peer bot read as human, skewing engagement and
583
+ // `grant_role`'s "no peer bot" proof. Default this read to human (the
584
+ // safe, count-conservative direction) but let the next read retry.
585
+ if (info.failure.kind === 'permanent') cache.set(userId, false)
511
586
  return false
512
587
  }
513
588
  const isBot = info.value.user?.is_bot === true
@@ -515,6 +590,38 @@ async function resolveSlackUserIsBot(
515
590
  return isBot
516
591
  }
517
592
 
593
+ // Enumerates the workspace and returns the set of bot user ids. Slack has no
594
+ // server-side `is_bot` filter, so we page the full `users.list` and keep only
595
+ // bots — a complete pass is required so silent lurking bots (never seen in
596
+ // history) are still counted, which `grant_role`'s "no peer bot" proof relies
597
+ // on. Returns null on any failure so the caller can fall back to per-id
598
+ // `users.info` rather than trusting an incomplete set. Page count is bounded so
599
+ // a pathologically large workspace cannot stall the read indefinitely.
600
+ async function fetchWorkspaceBotIds(
601
+ fetchFn: typeof fetch,
602
+ token: string,
603
+ logger: SlackBotAdapterLogger,
604
+ ): Promise<ReadonlySet<string> | null> {
605
+ const botIds = new Set<string>()
606
+ let cursor: string | undefined
607
+ for (let page = 0; page < USERS_LIST_MAX_PAGES; page++) {
608
+ const fields: Record<string, string> = { limit: String(USERS_LIST_PAGE_LIMIT) }
609
+ if (cursor !== undefined && cursor !== '') fields.cursor = cursor
610
+ const res = await slackApi<SlackUsersListResponse>(fetchFn, token, 'users.list', fields)
611
+ if (!res.ok) {
612
+ logger.warn(`[slack-bot] users.list failed: ${res.reason}; falling back to per-member classification`)
613
+ return null
614
+ }
615
+ for (const member of res.value.members ?? []) {
616
+ if (member.is_bot === true && typeof member.id === 'string') botIds.add(member.id)
617
+ }
618
+ cursor = res.value.response_metadata?.next_cursor
619
+ if (cursor === undefined || cursor === '') return botIds
620
+ }
621
+ logger.warn(`[slack-bot] users.list exceeded ${USERS_LIST_MAX_PAGES} pages; bot set may be incomplete`)
622
+ return null
623
+ }
624
+
518
625
  function slackFailureForError(error: string): MembershipResolverFailure {
519
626
  if (['invalid_auth', 'not_authed', 'not_in_channel', 'channel_not_found', 'missing_scope'].includes(error)) {
520
627
  return { kind: 'permanent' }
@@ -601,14 +708,29 @@ function mapSlackMessage(msg: SlackRawHistoryMessage, botUserId: string | null):
601
708
  msg.parent_user_id === botUserId
602
709
  ? msg.thread_ts
603
710
  : null
711
+ // The history fetch bypasses the inbound classifier, so files on
712
+ // already-posted messages (e.g. an image on a thread root the agent is
713
+ // later @-mentioned under) must be mapped here too — otherwise they are
714
+ // silently dropped and look_at_channel_attachment can never resolve them.
715
+ // Mirror splitInbound: bake placeholders into text and carry the structured
716
+ // attachments so the router can resolve ids.
717
+ const attachments = (msg.files ?? []).map((file, index) => describeSlackFile(file, index + 1))
718
+ const rawText = msg.text ?? ''
719
+ const text =
720
+ attachments.length === 0
721
+ ? rawText
722
+ : rawText === ''
723
+ ? attachments.map(renderPlaceholder).join('\n')
724
+ : `${rawText}\n${attachments.map(renderPlaceholder).join('\n')}`
604
725
  return {
605
726
  externalMessageId: msg.ts,
606
727
  authorId: msg.user ?? msg.bot_id ?? 'unknown',
607
728
  authorName: msg.user ?? msg.bot_id ?? 'unknown',
608
- text: msg.text ?? '',
729
+ text,
609
730
  ts: slackTsToMillis(msg.ts),
610
731
  isBot,
611
732
  replyToBotMessageId,
733
+ ...(attachments.length > 0 ? { attachments } : {}),
612
734
  }
613
735
  }
614
736
 
@@ -89,14 +89,51 @@ export function decideEngagement(input: EngagementInput): EngagementDecision {
89
89
  if (config.trigger.includes('mention') && message.isBotMention) return 'engage'
90
90
  if (config.trigger.includes('reply') && message.replyToBotMessageId !== null) return 'engage'
91
91
 
92
- // Sticky credit force-engages in EVERY context (groups included) for the
93
- // full window. This gate is deliberately content-blind: it answers "am I in
94
- // an active conversation with this author?", not "does THIS message need a
95
- // reply?" a boolean over membership cannot tell "where did you send it?"
96
- // (reply) from "lol ok" (chatter). Selectivity is the MODEL's job: engaged
92
+ // Multi-human pre-sticky target check. In a busy group the conversational
93
+ // target shifts every message: the author we're mid-exchange with (and hold
94
+ // a sticky credit for) may, on THIS turn, structurally address a third party
95
+ // "@bob what do you think?", a reply to another human's message, or a
96
+ // peer bot by name. Sticky below is content-blind by design (it answers "am
97
+ // I mid-conversation with this author?"), so without this guard it would
98
+ // force-engage on a message plainly aimed elsewhere and burn the credit.
99
+ //
100
+ // This stays inside the pinned content-blind philosophy: it adds NO semantic
101
+ // text interpretation. It reuses the SAME structural booleans the post-alias
102
+ // suppressors already trust (`mentionsOthers`, `replyToOtherMessageId`,
103
+ // `textTargetsAnyPeerBot`) — the adapter classifiers decide "addressed to
104
+ // someone else", not this gate. The only refinement is ordering: when those
105
+ // structural signals fire in a multi-human group, observe BEFORE consuming
106
+ // sticky, and PRESERVE the credit so the author's next untargeted follow-up
107
+ // still wakes us within the window. A plain follow-up (no suppressor set)
108
+ // is untouched: it falls through to sticky and engages exactly as before.
109
+ //
110
+ // Solo-human channels are deliberately excluded (`effectiveHumans > 1`):
111
+ // there the sticky-over-mentionsOthers behavior is intentional and tested.
112
+ // The `!matchesAnyAlias` guard preserves the ladder invariant "explicit
113
+ // address to us beats structural targeting of others": a message that names
114
+ // us by alias engages on the alias rule below even when it ALSO tags a third
115
+ // party (the "봉봉아 펭펭아 둘 다 봐" multi-bot case), so we must not pre-empt
116
+ // it here. We only step aside for a credited author whose message is aimed
117
+ // PURELY elsewhere.
118
+ if (
119
+ effectiveHumans > 1 &&
120
+ config.stickiness !== 'off' &&
121
+ !matchesAnyAlias(message.text, selfAliases) &&
122
+ targetsSomeoneElse(message, participants, botInThread)
123
+ ) {
124
+ return 'observe'
125
+ }
126
+
127
+ // Sticky credit force-engages for the full window. This gate is deliberately
128
+ // content-blind: it answers "am I in an active conversation with this
129
+ // author?", not "does THIS message need a reply?" — a boolean over
130
+ // membership cannot tell "where did you send it?" (reply) from "lol ok"
131
+ // (chatter). Selectivity for plain follow-ups is the MODEL's job: engaged
97
132
  // group turns get a `composeTurnPrompt` nudge (keyed off `isMultiHumanGroup`)
98
133
  // to answer real follow-ups and `NO_REPLY` chatter. Gating sticky off in
99
- // groups instead (the prior approach) dropped genuine follow-ups outright.
134
+ // groups wholesale (the prior approach) dropped genuine follow-ups outright;
135
+ // the pre-check above is narrower — it only steps aside when the message is
136
+ // STRUCTURALLY addressed elsewhere, leaving plain follow-ups engaged.
100
137
  if (config.stickiness !== 'off' && ledger.consume(key, message.authorId, now)) {
101
138
  return 'engage'
102
139
  }
@@ -155,37 +192,40 @@ export function decideEngagement(input: EngagementInput): EngagementDecision {
155
192
  // has rejected that design repeatedly. The right knob is `trigger`
156
193
  // (which already applies symmetrically to humans and bots) plus this
157
194
  // fallback fix.
158
- if (message.mentionsOthers) return 'observe'
159
- // The replyToOtherMessageId suppressor exists to keep the bot out of
160
- // human-to-human side conversations in busy channels. But Slack's
161
- // `parent_user_id` is the THREAD ROOT author, not the immediate parent
162
- // author — so a thread the human starts by @-mentioning the bot
163
- // produces `replyToOtherMessageId` on every follow-up (root author is
164
- // the human, not us), which would silently drop every reply after the
165
- // first. Once the bot has actually sent into this thread, subsequent
166
- // replies are part of OUR conversation regardless of who started it,
167
- // so the suppressor stops applying. The two-humans-in-a-thread case
168
- // PR #58 fixed is preserved because the bot never sent into that
169
- // thread in the first place.
170
- if (message.replyToOtherMessageId !== null && !botInThread) return 'observe'
171
-
172
- // Plain-text peer-bot addressing as a fallback suppressor. We've reached
173
- // here because the message lacks a structural mention/reply/dm AND
174
- // doesn't contain our own alias. If it DOES contain a known peer bot's
175
- // observed display name, the solo-human fallback would still engage us
176
- // — same wrong behavior the alias trigger is meant to fix, just for
177
- // peers instead of self. Each bot only configures its own aliases, so
178
- // the only source of peer names is `participants[]` (observed
179
- // authorName once a peer has spoken at least once in this channel).
180
- // First-time addressing of a never-seen peer slips through; after that
181
- // peer's first message it's caught forever.
182
- if (textTargetsAnyPeerBot(message.text, participants)) return 'observe'
195
+ if (targetsSomeoneElse(message, participants, botInThread)) return 'observe'
183
196
 
184
197
  if (effectiveHumans <= 1 && !message.authorIsBot) return 'engage'
185
198
 
186
199
  return 'observe'
187
200
  }
188
201
 
202
+ // Structural "this message is addressed to someone other than us" test. Pure
203
+ // over adapter-classified booleans + observed peer names — no semantic text
204
+ // interpretation, so it is safe for the content-blind engagement gate. Shared
205
+ // by the multi-human pre-sticky check and the post-alias fallback suppressors
206
+ // so the two can never drift apart.
207
+ //
208
+ // - `mentionsOthers` — the message tags at least one other user and none of
209
+ // the mentions resolve to us.
210
+ // - `replyToOtherMessageId !== null && !botInThread` — the message replies to
211
+ // a non-bot message AND we haven't sent into this thread yet. Slack's
212
+ // `parent_user_id` is the THREAD ROOT author, not the immediate parent, so
213
+ // a thread a human opens by @-mentioning us would otherwise drop every
214
+ // follow-up; `botInThread` is the escape hatch once we're participating.
215
+ // - `textTargetsAnyPeerBot` — the text names a known peer bot (observed via
216
+ // `participants[]`). A never-seen peer's first addressing slips through,
217
+ // then is caught forever once that peer has spoken once.
218
+ function targetsSomeoneElse(
219
+ message: InboundMessage,
220
+ participants: readonly ChannelParticipant[],
221
+ botInThread: boolean,
222
+ ): boolean {
223
+ if (message.mentionsOthers) return true
224
+ if (message.replyToOtherMessageId !== null && !botInThread) return true
225
+ if (textTargetsAnyPeerBot(message.text, participants)) return true
226
+ return false
227
+ }
228
+
189
229
  export function countEffectiveHumans(
190
230
  participants: readonly ChannelParticipant[],
191
231
  membership: MembershipCount | null,
@@ -89,6 +89,12 @@ export type ChannelManagerOptions = {
89
89
  // per-repo App token minter here on start (App auth only) so plugin hooks
90
90
  // can resolve a token for ad-hoc `gh` commands. Tests omit it.
91
91
  githubTokenBridge?: GithubTokenBridge
92
+ // Forwarded to the router as the /reload and /restart command handlers.
93
+ // Production wiring (src/run/index.ts) supplies the reload-registry and
94
+ // container-restart bindings; tests omit them so the commands stay
95
+ // unregistered. See CreateChannelRouterOptions.onReload/onRestart.
96
+ onReload?: () => Promise<string>
97
+ onRestart?: () => Promise<string>
92
98
  }
93
99
 
94
100
  export type ChannelManager = {
@@ -125,6 +131,8 @@ export function createChannelManager(options: ChannelManagerOptions): ChannelMan
125
131
  ...(options.permissions ? { permissions: options.permissions } : {}),
126
132
  ...(options.claimHandler ? { claimHandler: options.claimHandler } : {}),
127
133
  ...(options.stream ? { stream: options.stream } : {}),
134
+ ...(options.onReload ? { onReload: options.onReload } : {}),
135
+ ...(options.onRestart ? { onRestart: options.onRestart } : {}),
128
136
  })
129
137
  const createDiscordAdapter = options.createDiscordAdapter ?? createDiscordBotAdapter
130
138
  const createGithub = options.createGithubAdapter ?? createGithubAdapter