typeclaw 0.21.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 (38) 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/bundled-plugins/bun-hygiene/README.md +82 -0
  5. package/src/bundled-plugins/bun-hygiene/index.ts +11 -0
  6. package/src/bundled-plugins/bun-hygiene/policy.ts +318 -0
  7. package/src/bundled-plugins/github-cli-auth/gh-command.ts +98 -6
  8. package/src/bundled-plugins/github-cli-auth/graphql-auth-nudge.ts +80 -0
  9. package/src/bundled-plugins/github-cli-auth/index.ts +7 -0
  10. package/src/bundled-plugins/memory/memory-logger.ts +6 -2
  11. package/src/bundled-plugins/reviewer/skills/code-review.ts +8 -0
  12. package/src/channels/adapters/discord-bot.ts +2 -0
  13. package/src/channels/adapters/github/inbound.ts +23 -1
  14. package/src/channels/adapters/github/index.ts +1 -0
  15. package/src/channels/adapters/slack-bot.ts +104 -5
  16. package/src/channels/manager.ts +8 -0
  17. package/src/channels/router.ts +68 -15
  18. package/src/channels/schema.ts +18 -0
  19. package/src/cli/dreams.ts +2 -1
  20. package/src/cli/inspect.ts +2 -1
  21. package/src/cli/ui.ts +34 -0
  22. package/src/commands/index.ts +5 -2
  23. package/src/config/config.ts +89 -0
  24. package/src/mcp/catalog.ts +29 -0
  25. package/src/mcp/client.ts +236 -0
  26. package/src/mcp/index.ts +25 -0
  27. package/src/mcp/manager.ts +156 -0
  28. package/src/mcp/tools.ts +190 -0
  29. package/src/permissions/builtins.ts +9 -0
  30. package/src/reload/format.ts +14 -0
  31. package/src/reload/index.ts +1 -0
  32. package/src/run/bundled-plugins.ts +7 -0
  33. package/src/run/channel-session-factory.ts +3 -0
  34. package/src/run/index.ts +38 -1
  35. package/src/server/command-runner.ts +5 -0
  36. package/src/server/index.ts +4 -0
  37. package/src/skills/typeclaw-channel-github/SKILL.md +83 -13
  38. package/typeclaw.schema.json +82 -0
@@ -19,6 +19,10 @@ export type GithubWebhookHandlerOptions = {
19
19
  // Defaults to 'pat' when omitted. In 'app' mode classifyReviewRequest also
20
20
  // matches the App's decoy reviewer login; see resolveDecoyReviewerLogin.
21
21
  authType?: () => 'pat' | 'app'
22
+ // Defaults to true when omitted. When it returns false, every inbound carries
23
+ // an appended operator-policy note telling the agent not to submit an APPROVE
24
+ // review; the github skill keys off that note to downgrade approve→COMMENT.
25
+ allowApprove?: () => boolean
22
26
  route: (message: InboundMessage) => void
23
27
  logger: GithubInboundLogger
24
28
  // Optional: resolves whether the bot is a member of the given team. When
@@ -75,11 +79,29 @@ export function createGithubWebhookHandler(options: GithubWebhookHandlerOptions)
75
79
  if (classified === null) return ok()
76
80
 
77
81
  if (delivery !== '') options.dedup.add(delivery)
78
- options.route(classified)
82
+ options.route(withApprovalPolicy(classified, options.allowApprove?.() ?? true))
79
83
  return ok()
80
84
  }
81
85
  }
82
86
 
87
+ export const PR_APPROVAL_DISABLED_NOTE =
88
+ 'Operator policy: PR approval is disabled for this agent ' +
89
+ '(`channels.github.review.approve: false`). If you review a PR and the ' +
90
+ 'verdict is `approve`, submit a `COMMENT` review instead of `APPROVE` — post ' +
91
+ 'the findings, but never formally approve.'
92
+
93
+ // Gating PR approval lives here (inbound text), not at the bash layer: the
94
+ // review is posted via `gh api --input <file>`, so the `event: APPROVE` value
95
+ // sits in a temp file the gh-cli-auth command interceptor never inspects. The
96
+ // note rides on every inbound (cheap: one line, only when an operator has
97
+ // opted out) so it reaches the agent for both webhook review requests and
98
+ // plain-language "@bot review this" asks, which arrive on arbitrary inbounds.
99
+ function withApprovalPolicy(message: InboundMessage, allowApprove: boolean): InboundMessage {
100
+ if (allowApprove) return message
101
+ const text = message.text === '' ? PR_APPROVAL_DISABLED_NOTE : `${message.text}\n\n${PR_APPROVAL_DISABLED_NOTE}`
102
+ return { ...message, text }
103
+ }
104
+
83
105
  // GitHub auto-records the App as a reviewer the moment its review posts, but
84
106
  // leaves the decoy user pinned as a perpetual "review requested". When the bot
85
107
  // drops its own review (the self-authored event we're about to discard), fire a
@@ -149,6 +149,7 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
149
149
  selfId: () => selfId,
150
150
  selfLogin: () => selfLogin,
151
151
  authType: () => options.secrets.auth.type,
152
+ allowApprove: () => options.configRef().review.approve,
152
153
  isBotInTeam,
153
154
  authToken,
154
155
  fetchImpl,
@@ -6,6 +6,8 @@ import {
6
6
  } from 'agent-messenger/slackbot'
7
7
 
8
8
  import {
9
+ MEMBERSHIP_CACHE_TRANSIENT_TTL_MS,
10
+ MEMBERSHIP_CACHE_TTL_MS,
9
11
  MEMBERSHIP_ENUMERATION_CAP,
10
12
  type MembershipResolver,
11
13
  type MembershipResolverFailure,
@@ -58,7 +60,7 @@ import { slackTsToMillis } from './slack-bot-time'
58
60
  // slash_commands events we route vs drop. The ui.test.ts manifest-drift
59
61
  // test asserts equality between this set and SLACK_APP_MANIFEST.features.
60
62
  // slash_commands so the two can never silently diverge.
61
- 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'])
62
64
 
63
65
  // Resolvers fall back to the raw id on failure, so a name equal to the id
64
66
  // means resolution failed; we render the bare id rather than `id(id)`. The
@@ -404,6 +406,16 @@ type SlackUserInfoResponse = {
404
406
  user?: { is_bot?: boolean; deleted?: boolean }
405
407
  }
406
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
+
407
419
  export function createSlackMembershipResolver(deps: {
408
420
  token: string
409
421
  logger: SlackBotAdapterLogger
@@ -414,6 +426,43 @@ export function createSlackMembershipResolver(deps: {
414
426
  const fetchFn = deps.fetchImpl ?? fetch
415
427
  const now = deps.now ?? Date.now
416
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
+
417
466
  return async (key): Promise<MembershipResolverResult> => {
418
467
  if (key.workspace === '@dm') return { humans: 1, bots: 1, fetchedAt: now(), truncated: false }
419
468
 
@@ -466,11 +515,22 @@ export function createSlackMembershipResolver(deps: {
466
515
  return members.failure
467
516
  }
468
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)
469
529
  let bots = 0
470
530
  const humanMemberIds: string[] = []
471
- for (const userId of members.value.members ?? []) {
472
- const cached = userBotCache.get(userId)
473
- 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))
474
534
  if (isBot) bots++
475
535
  else humanMemberIds.push(userId)
476
536
  }
@@ -512,10 +572,17 @@ async function resolveSlackUserIsBot(
512
572
  logger: SlackBotAdapterLogger,
513
573
  cache: Map<string, boolean>,
514
574
  ): Promise<boolean> {
575
+ const cached = cache.get(userId)
576
+ if (cached !== undefined) return cached
515
577
  const info = await slackApi<SlackUserInfoResponse>(fetchFn, token, 'users.info', { user: userId })
516
578
  if (!info.ok) {
517
579
  logger.warn(`[slack-bot] membership users.info user=${userId} failed: ${info.reason}`)
518
- 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)
519
586
  return false
520
587
  }
521
588
  const isBot = info.value.user?.is_bot === true
@@ -523,6 +590,38 @@ async function resolveSlackUserIsBot(
523
590
  return isBot
524
591
  }
525
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
+
526
625
  function slackFailureForError(error: string): MembershipResolverFailure {
527
626
  if (['invalid_auth', 'not_authed', 'not_in_channel', 'channel_not_found', 'missing_scope'].includes(error)) {
528
627
  return { kind: 'permanent' }
@@ -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
@@ -7,7 +7,7 @@ import { createSession, renderTurnRoleAnchor, renderTurnTimeAnchor, type AgentSe
7
7
  import { subscribeProviderErrors } from '@/agent/provider-error'
8
8
  import type { ChannelParticipant, SessionOrigin } from '@/agent/session-origin'
9
9
  import { renderSubagentCompletionReminder } from '@/agent/subagent-completion-reminder'
10
- import { type Command, type CommandResult, createCommandRegistry } from '@/commands'
10
+ import { type Command, type CommandPermission, type CommandResult, createCommandRegistry } from '@/commands'
11
11
  import { CORE_PERMISSIONS, type PermissionService } from '@/permissions'
12
12
  import type { HookBus } from '@/plugin'
13
13
  import { extractClaimCode } from '@/role-claim'
@@ -720,6 +720,17 @@ export type CreateChannelRouterOptions = {
720
720
  // can diagnose silent drops from `typeclaw inspect` alone. Omitted in
721
721
  // tests that don't care about inspect surfacing.
722
722
  stream?: Stream
723
+ // Operate-the-agent command handlers. When set, the router registers the
724
+ // matching channel command (/reload, /restart) gated on session.admin
725
+ // (owner+trusted). Omitted means the command is not registered at all — it
726
+ // won't appear in /help and a text-prefix or native-slash invocation is
727
+ // treated as unknown. Production wiring (src/run/index.ts via the channel
728
+ // manager) supplies both; tests opt in per-case. `onReload` returns a short
729
+ // human-readable summary posted back to the channel; `onRestart` returns a
730
+ // confirmation string (the container exits shortly after, so the reply is
731
+ // best-effort).
732
+ onReload?: () => Promise<string>
733
+ onRestart?: () => Promise<string>
723
734
  }
724
735
 
725
736
  export type ClaimHandlerInput = {
@@ -756,6 +767,8 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
756
767
  const permissions = options.permissions ?? GRANT_ALL_PERMISSIONS
757
768
  const claimHandler = options.claimHandler
758
769
  const stream = options.stream
770
+ const onReload = options.onReload
771
+ const onRestart = options.onRestart
759
772
  const liveSessions = new Map<string, LiveSession>()
760
773
  const creating = new Map<string, Promise<LiveSession>>()
761
774
  // Bumped by tearDownAllLive() and stop() before they tear sessions down. An
@@ -779,7 +792,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
779
792
  // The /help handler reads the live registry to enumerate commands, so it
780
793
  // forward-references `commands`. Safe at runtime — the handler only runs on
781
794
  // invocation, long after the assignment below completes.
782
- const channelCommands: readonly Command<ChannelCommandContext>[] = [
795
+ const channelCommands: Command<ChannelCommandContext>[] = [
783
796
  {
784
797
  name: 'help',
785
798
  description: 'List available commands.',
@@ -800,6 +813,28 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
800
813
  },
801
814
  },
802
815
  ]
816
+ // /reload and /restart are registered only when the operate-the-agent
817
+ // callbacks are wired (production via the channel manager). Without them the
818
+ // capability doesn't exist for this router, so the commands stay absent from
819
+ // /help and resolve as unknown — never a silent no-op.
820
+ if (onReload !== undefined) {
821
+ channelCommands.push({
822
+ name: 'reload',
823
+ description: 'Reload typeclaw config and subsystems from disk.',
824
+ permission: 'session.admin',
825
+ requiresLiveSession: false,
826
+ handler: async () => ({ reply: await onReload() }),
827
+ })
828
+ }
829
+ if (onRestart !== undefined) {
830
+ channelCommands.push({
831
+ name: 'restart',
832
+ description: 'Restart the typeclaw container.',
833
+ permission: 'session.admin',
834
+ requiresLiveSession: false,
835
+ handler: async () => ({ reply: await onRestart() }),
836
+ })
837
+ }
803
838
  const commands = createCommandRegistry<ChannelCommandContext>(channelCommands)
804
839
 
805
840
  // Implicit dir-name alias: agent folder basename matches Docker
@@ -1800,9 +1835,10 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1800
1835
  logger.info(`[channels] ${keyId}: ignoring unknown command /${parsedCommand.name}`)
1801
1836
  return
1802
1837
  }
1803
- if (commandInfo.permission === 'session.control' && isSessionControlDenied(event)) {
1838
+ const requiredPermission = commandPermissionString(commandInfo.permission)
1839
+ if (requiredPermission !== null && !permissions.has(inboundAuthorOrigin(event), requiredPermission)) {
1804
1840
  logger.info(
1805
- `[channels] ${keyId}: denied command /${parsedCommand.name} by permissions (session.control) author=${event.authorId}`,
1841
+ `[channels] ${keyId}: denied command /${parsedCommand.name} by permissions (${requiredPermission}) author=${event.authorId}`,
1806
1842
  )
1807
1843
  return
1808
1844
  }
@@ -1913,8 +1949,22 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1913
1949
  // operator can grant guest channelRespond for masked stranger turns)
1914
1950
  // cannot /stop another speaker's in-flight turn. session.control is
1915
1951
  // member-and-up by default.
1916
- const isSessionControlDenied = (event: InboundMessage): boolean =>
1917
- !permissions.has(inboundAuthorOrigin(event), CORE_PERMISSIONS.sessionControl)
1952
+ // Maps a command's declared permission tier to the concrete permission
1953
+ // string gated on both the text-prefix path (route) and the native-slash
1954
+ // path (executeCommand). 'none' is never gated. session.admin (owner+trusted,
1955
+ // not member) covers /reload and /restart, which mutate global agent state
1956
+ // and drop every in-flight session. Centralized so a new tier can't be
1957
+ // honored on one path and silently skipped on the other.
1958
+ const commandPermissionString = (permission: CommandPermission): string | null => {
1959
+ switch (permission) {
1960
+ case 'none':
1961
+ return null
1962
+ case 'session.control':
1963
+ return CORE_PERMISSIONS.sessionControl
1964
+ case 'session.admin':
1965
+ return CORE_PERMISSIONS.sessionAdmin
1966
+ }
1967
+ }
1918
1968
 
1919
1969
  const updateLoopGuard = (live: LiveSession, event: InboundMessage): void => {
1920
1970
  if (!event.authorIsBot) {
@@ -2702,14 +2752,17 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2702
2752
  if (commandInfo === undefined) {
2703
2753
  return { kind: 'unknown-command', name: lowered }
2704
2754
  }
2705
- // Gates on session.control (not channel.respond) so a respond-capable
2706
- // guest cannot abort another speaker's turn. Runs BEFORE the live-session
2707
- // lookup so an unauthorized invoker gets 'permission-denied' regardless of
2708
- // session state, rather than leaking session presence via the
2709
- // 'no-live-session' vs 'permission-denied' distinction. Session-less
2710
- // informational commands (e.g. /help) declare permission:'none' and skip
2711
- // both the gate and the lookup so they work in channels with no live turn.
2712
- if (commandInfo.permission === 'session.control') {
2755
+ // Gates on the command's declared tier (session.control for /stop,
2756
+ // session.admin for /reload and /restart) — never channel.respond so a
2757
+ // respond-capable guest cannot abort another speaker's turn or bounce the
2758
+ // container. Runs BEFORE the live-session lookup so an unauthorized invoker
2759
+ // gets 'permission-denied' regardless of session state, rather than leaking
2760
+ // session presence via the 'no-live-session' vs 'permission-denied'
2761
+ // distinction. Session-less informational commands (e.g. /help) declare
2762
+ // permission:'none' and skip both the gate and the lookup so they work in
2763
+ // channels with no live turn.
2764
+ const requiredPermission = commandPermissionString(commandInfo.permission)
2765
+ if (requiredPermission !== null) {
2713
2766
  const partial: SessionOrigin = {
2714
2767
  kind: 'channel',
2715
2768
  adapter: key.adapter,
@@ -2718,7 +2771,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2718
2771
  thread: key.thread,
2719
2772
  lastInboundAuthorId: options.invokerId,
2720
2773
  }
2721
- if (!permissions.has(partial, CORE_PERMISSIONS.sessionControl)) {
2774
+ if (!permissions.has(partial, requiredPermission)) {
2722
2775
  return { kind: 'permission-denied' }
2723
2776
  }
2724
2777
  }
@@ -131,6 +131,23 @@ export const DEFAULT_GITHUB_EVENT_ALLOWLIST = [
131
131
  'pull_request_review.submitted',
132
132
  ] as const
133
133
 
134
+ // PR-review policy knobs. Grouped under `review` so future toggles
135
+ // (`requestChanges`, auto-review-on-request, severity thresholds) cluster
136
+ // here instead of flattening onto the channel root.
137
+ //
138
+ // `approve` gates whether the agent may submit a formal review with
139
+ // `event: APPROVE`. When `false`, the adapter appends an operator-policy note
140
+ // to inbounds and the `typeclaw-channel-github` skill downgrades an `approve`
141
+ // verdict to a `COMMENT` review (findings still posted, no formal approval).
142
+ // Enforced in the inbound text rather than at the bash layer because the
143
+ // review posts via `gh api --input <file>`, so the `event` value lives in a
144
+ // temp file the command interceptor never sees.
145
+ const githubReviewSchema = z
146
+ .object({
147
+ approve: z.boolean().default(true),
148
+ })
149
+ .default({ approve: true })
150
+
134
151
  const githubChannelSchema = adapterSchema.extend({
135
152
  // Optional now (PR 2): when omitted and a `tunnels[]` entry with
136
153
  // `for: { kind: 'channel', name: 'github' }` exists, the runtime resolves
@@ -146,6 +163,7 @@ const githubChannelSchema = adapterSchema.extend({
146
163
  // this session is deleted so a restart with a different webhookUrl (e.g.
147
164
  // a tunnel reassigning a URL) doesn't leave orphaned hooks on GitHub.
148
165
  repos: z.array(z.string()).default([]),
166
+ review: githubReviewSchema,
149
167
  })
150
168
 
151
169
  // KakaoTalk uses the same shape as every other adapter. There used to be an
package/src/cli/dreams.ts CHANGED
@@ -4,7 +4,7 @@ import { type DreamEntry, renderListRow, runDreams, type ViewAction } from '@/dr
4
4
  import { findAgentDir } from '@/init'
5
5
 
6
6
  import { createEscController } from './inspect-controller'
7
- import { c, cancel, errorLine, isCancel } from './ui'
7
+ import { c, cancel, errorLine, isCancel, prepareStdinForClack } from './ui'
8
8
 
9
9
  const ESC_DEBOUNCE_MS = 50
10
10
  const QUIT_KEY = 0x71
@@ -123,6 +123,7 @@ async function clackSelect(
123
123
  initialSha: string | undefined,
124
124
  ): Promise<DreamEntry | null> {
125
125
  const { select } = await import('@clack/prompts')
126
+ prepareStdinForClack()
126
127
  const preferred = initialSha !== undefined && entries.some((e) => e.sha === initialSha) ? initialSha : entries[0]?.sha
127
128
  const picked = await select<string>({
128
129
  message: `Pick a dream to open (${entries.length} total)`,
@@ -6,7 +6,7 @@ import { runInspectLoop, streamLive, type LiveSourceFactory, type SessionSummary
6
6
  import { originLabel, shortSessionId } from '@/inspect/label'
7
7
 
8
8
  import { createEscController } from './inspect-controller'
9
- import { cancel, c, errorLine, isCancel } from './ui'
9
+ import { cancel, c, errorLine, isCancel, prepareStdinForClack } from './ui'
10
10
 
11
11
  const ESC_LISTEN_DELAY_MS = 50
12
12
 
@@ -212,6 +212,7 @@ async function clackSelect(
212
212
  initialSessionId: string | undefined,
213
213
  ): Promise<SessionSummary | null> {
214
214
  const { select } = await import('@clack/prompts')
215
+ prepareStdinForClack()
215
216
  const preferred =
216
217
  initialSessionId !== undefined && sessions.some((s) => s.sessionId === initialSessionId)
217
218
  ? initialSessionId
package/src/cli/ui.ts CHANGED
@@ -7,6 +7,28 @@ import { type AutoUpgradeOutcome, describeAutoUpgrade } from '@/init/auto-upgrad
7
7
 
8
8
  export { cancel, intro, isCancel, log, note, outro }
9
9
 
10
+ type ClackInput = Pick<NodeJS.ReadStream, 'isTTY' | 'setRawMode' | 'resume'>
11
+
12
+ // Hand stdin to a clack picker in a state it can own. Over an SSH pseudo-TTY,
13
+ // Bun's readline keypress wiring only transitions stdin into flowing raw mode
14
+ // reliably once the stream has already been resumed; on a never-resumed stdin
15
+ // the picker renders but arrow keys echo as raw `^[[B` and never advance it.
16
+ // Local terminals dodge this because stdin was already flowing. So before every
17
+ // picker: clear any stale raw mode for a clean baseline, then resume the stream.
18
+ // Never pause() here — a previously-paused process.stdin does not reliably
19
+ // re-flow under Bun, which is the same failure this resume() is fixing.
20
+ export function prepareStdinForClack(input: ClackInput = process.stdin): void {
21
+ if (!input.isTTY) return
22
+ if (typeof input.setRawMode === 'function') {
23
+ try {
24
+ input.setRawMode(false)
25
+ } catch {
26
+ /* terminal already torn down */
27
+ }
28
+ }
29
+ input.resume()
30
+ }
31
+
10
32
  function colorize(modifier: Parameters<typeof styleText>[0], s: string): string {
11
33
  if (!colorsEnabled()) return s
12
34
  return styleText(modifier, s)
@@ -169,6 +191,18 @@ export const SLACK_APP_MANIFEST = {
169
191
  url: 'https://example.invalid/typeclaw-uses-socket-mode',
170
192
  should_escape: false,
171
193
  },
194
+ {
195
+ command: '/reload',
196
+ description: 'Reload typeclaw config and subsystems from disk',
197
+ url: 'https://example.invalid/typeclaw-uses-socket-mode',
198
+ should_escape: false,
199
+ },
200
+ {
201
+ command: '/restart',
202
+ description: 'Restart the typeclaw container',
203
+ url: 'https://example.invalid/typeclaw-uses-socket-mode',
204
+ should_escape: false,
205
+ },
172
206
  ],
173
207
  },
174
208
  oauth_config: {
@@ -13,8 +13,11 @@ export type CommandHandler<Context> = (
13
13
  // dispatcher, so a new command declares its own requirements in one place:
14
14
  // 'session.control' + requiresLiveSession:true is the control-command default
15
15
  // (/stop); 'none' + requiresLiveSession:false is the informational default
16
- // (/help). Both are optional so plain registries (tests, TUI) need not care.
17
- export type CommandPermission = 'none' | 'session.control'
16
+ // (/help). 'session.admin' + requiresLiveSession:false is the operate-the-agent
17
+ // tier (/reload, /restart) owner+trusted only, no live session required since
18
+ // it acts on the container, not a channel turn. Both are optional so plain
19
+ // registries (tests, TUI) need not care.
20
+ export type CommandPermission = 'none' | 'session.control' | 'session.admin'
18
21
 
19
22
  export type Command<Context> = {
20
23
  name: string
@@ -8,6 +8,7 @@ import { z } from 'zod'
8
8
  import { channelsSchema } from '@/channels/schema'
9
9
  import { commitSystemFileSync } from '@/git/system-commit'
10
10
  import { rolesConfigSchema } from '@/permissions/schema'
11
+ import { secretFieldSchema } from '@/secrets/resolve'
11
12
 
12
13
  import {
13
14
  DEFAULT_MODEL_REF,
@@ -30,6 +31,30 @@ const DEFAULT_PORT = 8973
30
31
  // of files like `mounts/.git` or `mounts/Hello`.
31
32
  const MOUNT_NAME_PATTERN = /^[a-z0-9][a-z0-9-_]*$/
32
33
 
34
+ // Shell-portable env var identifier: a leading letter or underscore followed by
35
+ // letters, digits, or underscores. MCP `env` keys are passed verbatim to a child
36
+ // process environment, so an invalid identifier (spaces, `=`, leading digit)
37
+ // would be silently dropped or corrupt the spawned server's env.
38
+ const ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/
39
+
40
+ // Upper bound for a per-server MCP request timeout: 10 minutes. Long-running
41
+ // MCP tools (large crawls, builds) can legitimately take minutes, but a ceiling
42
+ // guards against fat-finger values that would re-introduce the unbounded-hang
43
+ // failure mode the explicit timeouts exist to prevent.
44
+ const MCP_MAX_TIMEOUT_MS = 600_000
45
+
46
+ // URL schemes are case-insensitive (RFC 3986), and the WHATWG parser normalizes
47
+ // `.protocol` to lowercase. Checking the parsed protocol instead of a raw
48
+ // `startsWith` keeps `HTTPS://…` valid, which `z.string().url()` already accepts.
49
+ function isHttpProtocol(value: string): boolean {
50
+ try {
51
+ const protocol = new URL(value).protocol
52
+ return protocol === 'http:' || protocol === 'https:'
53
+ } catch {
54
+ return false
55
+ }
56
+ }
57
+
33
58
  export const mountSchema = z.object({
34
59
  name: z.string().regex(MOUNT_NAME_PATTERN, 'mount name must be lowercase alphanumeric with - or _'),
35
60
  path: z.string().min(1),
@@ -39,6 +64,66 @@ export const mountSchema = z.object({
39
64
 
40
65
  export type Mount = z.infer<typeof mountSchema>
41
66
 
67
+ // MCP servers are keyed by the same shell/disk-safe namespace as mounts because
68
+ // the name becomes the tool namespace exposed to the agent. The transport is an
69
+ // XOR on purpose: stdio servers are child processes (`command` + `args` + env),
70
+ // while Streamable HTTP servers are remote endpoints (`url`); accepting both
71
+ // would make ownership, lifetime, and credential injection ambiguous at boot.
72
+ export const mcpServerSchema = z
73
+ .object({
74
+ name: z
75
+ .string()
76
+ .regex(MOUNT_NAME_PATTERN, 'MCP server name must be lowercase alphanumeric with - or _')
77
+ .refine((name) => !name.includes('__'), {
78
+ message: "MCP server name must not contain '__' (reserved as the tool-namespace separator)",
79
+ }),
80
+ description: z.string().optional(),
81
+ // Default true so omitting the field keeps the server on; set false to keep config but skip connecting.
82
+ enabled: z.boolean().default(true),
83
+ timeoutMs: z.number().int().positive().max(MCP_MAX_TIMEOUT_MS).optional(),
84
+ command: z.string().trim().min(1).optional(),
85
+ args: z.array(z.string()).default([]),
86
+ url: z
87
+ .string()
88
+ .url()
89
+ .refine((u) => isHttpProtocol(u), {
90
+ message: 'MCP server url must use http:// or https://',
91
+ })
92
+ .optional(),
93
+ env: z
94
+ .record(z.string().regex(ENV_NAME_PATTERN, 'env var name must be a valid identifier'), secretFieldSchema)
95
+ .default({}),
96
+ })
97
+ .refine((server) => (server.command !== undefined) !== (server.url !== undefined), {
98
+ message: 'MCP server must be either stdio (command) or http (url), not both or neither',
99
+ })
100
+
101
+ export type McpServer = z.infer<typeof mcpServerSchema>
102
+
103
+ // The name becomes the `<server>__<tool>` namespace at dispatch, so duplicates
104
+ // would make tool lookup ambiguous and silently shadow one server behind
105
+ // another. Reject them with an indexed path so the error points at the
106
+ // offending entry instead of the whole array.
107
+ const mcpServersArraySchema = z
108
+ .array(mcpServerSchema)
109
+ .default([])
110
+ .superRefine((entries, ctx) => {
111
+ const seen = new Map<string, number>()
112
+ for (let i = 0; i < entries.length; i++) {
113
+ const name = entries[i]!.name
114
+ const prev = seen.get(name)
115
+ if (prev !== undefined) {
116
+ ctx.addIssue({
117
+ code: 'custom',
118
+ path: [i, 'name'],
119
+ message: `mcpServers[${i}].name duplicates mcpServers[${prev}].name ('${name}')`,
120
+ })
121
+ } else {
122
+ seen.set(name, i)
123
+ }
124
+ }
125
+ })
126
+
42
127
  const portNumber = z.number().int().min(1).max(65535)
43
128
 
44
129
  // `allow` is the discriminator between "forward everything" ('*') and a fixed
@@ -391,6 +476,7 @@ export const configSchema = z
391
476
  // host paths exposed) without failing the whole config load. `typeclaw
392
477
  // init` omits this field so users don't see noise for the empty case.
393
478
  mounts: z.array(mountSchema).default([]),
479
+ mcpServers: mcpServersArraySchema,
394
480
  plugins: z.array(z.string().min(1)).default([]),
395
481
  // Additional names the agent answers to in channel engagement, on top
396
482
  // of `basename(agentDir)` which is always implicit. Each entry is a
@@ -538,6 +624,7 @@ export const FIELD_EFFECTS: Record<string, FieldEffect> = {
538
624
  models: 'applied',
539
625
  port: 'restart-required',
540
626
  mounts: 'restart-required',
627
+ mcpServers: 'restart-required',
541
628
  plugins: 'restart-required',
542
629
  alias: 'applied',
543
630
  channels: 'applied',
@@ -638,6 +725,8 @@ export function extractPluginConfigs(raw: unknown): Record<string, unknown> {
638
725
  'git',
639
726
  'roles',
640
727
  'permissions',
728
+ 'tunnels',
729
+ 'mcpServers',
641
730
  ])
642
731
  const result: Record<string, unknown> = {}
643
732
  for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {