typeclaw 0.17.0 → 0.19.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 (50) hide show
  1. package/auth.schema.json +0 -5
  2. package/package.json +2 -2
  3. package/secrets.schema.json +0 -5
  4. package/src/agent/index.ts +2 -1
  5. package/src/agent/model-overrides.ts +77 -0
  6. package/src/agent/plugin-tools.ts +53 -4
  7. package/src/agent/tools/grant-role.ts +102 -8
  8. package/src/bundled-plugins/github-cli-auth/gh-command.ts +372 -0
  9. package/src/bundled-plugins/github-cli-auth/index.ts +42 -0
  10. package/src/bundled-plugins/github-cli-auth/token-class.ts +11 -0
  11. package/src/bundled-plugins/reviewer/skills/code-review.ts +18 -1
  12. package/src/bundled-plugins/security/policies/secret-exfil-bash.ts +9 -2
  13. package/src/channels/adapters/discord-bot-classify.ts +23 -0
  14. package/src/channels/adapters/discord-bot.ts +22 -4
  15. package/src/channels/adapters/github/auth-app.ts +49 -26
  16. package/src/channels/adapters/github/auth-pat.ts +3 -3
  17. package/src/channels/adapters/github/auth.ts +19 -5
  18. package/src/channels/adapters/github/channel-resolver.ts +3 -2
  19. package/src/channels/adapters/github/history.ts +3 -2
  20. package/src/channels/adapters/github/inbound.ts +30 -55
  21. package/src/channels/adapters/github/index.ts +147 -43
  22. package/src/channels/adapters/github/membership.ts +7 -2
  23. package/src/channels/adapters/github/outbound.ts +6 -2
  24. package/src/channels/adapters/github/team-membership.ts +4 -2
  25. package/src/channels/adapters/github/webhook-register.ts +19 -16
  26. package/src/channels/adapters/slack-bot-slash-commands.ts +78 -1
  27. package/src/channels/adapters/slack-bot.ts +119 -18
  28. package/src/channels/commands.ts +10 -0
  29. package/src/channels/engagement.ts +34 -3
  30. package/src/channels/github-token-bridge.ts +42 -0
  31. package/src/channels/index.ts +6 -0
  32. package/src/channels/manager.ts +6 -0
  33. package/src/channels/membership.ts +9 -0
  34. package/src/channels/router.ts +155 -37
  35. package/src/cli/channel.ts +0 -12
  36. package/src/cli/init.ts +0 -9
  37. package/src/cli/ui.ts +6 -0
  38. package/src/commands/index.ts +54 -4
  39. package/src/init/dockerfile.ts +60 -0
  40. package/src/init/github-webhook-install.ts +1 -2
  41. package/src/init/index.ts +4 -10
  42. package/src/init/validate-api-key.ts +15 -1
  43. package/src/plugin/context.ts +8 -0
  44. package/src/plugin/manager.ts +3 -0
  45. package/src/plugin/types.ts +6 -0
  46. package/src/run/bundled-plugins.ts +9 -0
  47. package/src/run/index.ts +6 -0
  48. package/src/secrets/schema.ts +0 -1
  49. package/src/server/command-runner.ts +14 -0
  50. package/src/skills/typeclaw-channel-github/SKILL.md +70 -43
@@ -7,13 +7,21 @@ 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 { createCommandRegistry } from '@/commands'
10
+ import { type Command, 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'
14
14
  import type { Stream } from '@/stream'
15
15
 
16
- import { decideEngagement, grantStickyForReplyTargets, StickyLedger, type EngagementDecision } from './engagement'
16
+ import { formatChannelCommandHelp } from './commands'
17
+ import {
18
+ countEffectiveHumans,
19
+ decideEngagement,
20
+ grantStickyForReplyTargets,
21
+ isMultiHumanGroup,
22
+ StickyLedger,
23
+ type EngagementDecision,
24
+ } from './engagement'
17
25
  import {
18
26
  MEMBERSHIP_COLD_FETCH_TIMEOUT_MS,
19
27
  type MembershipCount,
@@ -429,6 +437,10 @@ type LiveSession = {
429
437
  recentEngagedPeerBotTurns: { authorId: string; ts: number }[]
430
438
  consecutiveEngagedPeerBotTurns: number
431
439
  loopGuardActive: boolean
440
+ // Set in route() from the same membership+participants the engagement
441
+ // decision used, so the prompt nudge and sticky suppression agree on
442
+ // "is this a multi-human group". Read by composeTurnPrompt().
443
+ multiHumanGroup: boolean
432
444
  membershipFetch: Promise<MembershipCount | null> | null
433
445
  destroyed: boolean
434
446
  unsubProviderErrors: (() => void) | null
@@ -439,14 +451,16 @@ type LiveSession = {
439
451
  // pipeline (e.g. Discord native slash commands fired from listener.on
440
452
  // ('interaction_create')). Handlers that need a real inbound — for some
441
453
  // future hypothetical command like `/quote` — must guard on event !== null
442
- // instead of assuming it.
454
+ // instead of assuming it. `live` is null for session-less commands
455
+ // (requiresLiveSession:false, e.g. /help); session-control handlers run only
456
+ // after the dispatch layer has resolved a live session, so they may assert it.
443
457
  type ChannelCommandContext = {
444
- live: LiveSession
458
+ live: LiveSession | null
445
459
  event: InboundMessage | null
446
460
  }
447
461
 
448
462
  export type ExecuteCommandResult =
449
- | { kind: 'handled'; name: string }
463
+ | { kind: 'handled'; name: string; reply?: string }
450
464
  | { kind: 'unknown-command'; name: string }
451
465
  | { kind: 'no-live-session' }
452
466
  | { kind: 'permission-denied' }
@@ -721,14 +735,31 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
721
735
  const historyCallbacks = new Map<ChannelKey['adapter'], Set<HistoryCallback>>()
722
736
  const fetchAttachmentCallbacks = new Map<ChannelKey['adapter'], Set<FetchAttachmentCallback>>()
723
737
  const stickyLedger = new StickyLedger()
724
- const commands = createCommandRegistry<ChannelCommandContext>([
738
+ // The /help handler reads the live registry to enumerate commands, so it
739
+ // forward-references `commands`. Safe at runtime — the handler only runs on
740
+ // invocation, long after the assignment below completes.
741
+ const channelCommands: readonly Command<ChannelCommandContext>[] = [
742
+ {
743
+ name: 'help',
744
+ description: 'List available commands.',
745
+ permission: 'none',
746
+ requiresLiveSession: false,
747
+ handler: () => ({ reply: formatChannelCommandHelp(commands.list()) }),
748
+ },
725
749
  {
726
750
  name: 'stop',
751
+ description: 'Stop the current agent turn in this channel.',
752
+ permission: 'session.control',
753
+ requiresLiveSession: true,
727
754
  handler: async ({ live }) => {
728
- await stopCurrentChannelTurn(live)
755
+ // requiresLiveSession:true guarantees the dispatch layer resolved a
756
+ // session before running this handler, so `live` is non-null here.
757
+ await stopCurrentChannelTurn(live!)
758
+ return { reply: 'Stopped the current turn.' }
729
759
  },
730
760
  },
731
- ])
761
+ ]
762
+ const commands = createCommandRegistry<ChannelCommandContext>(channelCommands)
732
763
 
733
764
  // Implicit dir-name alias: agent folder basename matches Docker
734
765
  // container name (per AGENTS.md), the typical Discord/Slack bot
@@ -1086,6 +1117,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1086
1117
  recentEngagedPeerBotTurns: [],
1087
1118
  consecutiveEngagedPeerBotTurns: 0,
1088
1119
  loopGuardActive: false,
1120
+ multiHumanGroup: false,
1089
1121
  membershipFetch,
1090
1122
  destroyed: false,
1091
1123
  unsubProviderErrors: null,
@@ -1499,6 +1531,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1499
1531
  const text = composeTurnPrompt(observed, batch, {
1500
1532
  adapter: live.key.adapter,
1501
1533
  loopGuardActive: live.loopGuardActive,
1534
+ groupChatNudge: live.multiHumanGroup,
1502
1535
  systemReminders: reminders,
1503
1536
  role: liveRole,
1504
1537
  })
@@ -1603,6 +1636,28 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1603
1636
  }
1604
1637
  }
1605
1638
 
1639
+ // Executes a parsed channel command and posts its reply (if any) back to the
1640
+ // originating channel. Shared by the pre-gate public-command fast path and the
1641
+ // post-gate command block so the execute→reply shape can't drift between them.
1642
+ // Gating (channel.respond / session.control) and live-session resolution stay
1643
+ // at the call sites — this helper only runs the handler and delivers the reply.
1644
+ const runChannelCommand = async (event: InboundMessage, live: LiveSession | null): Promise<CommandResult> => {
1645
+ const result = await commands.execute(event.text, { live, event })
1646
+ if (result.kind === 'handled' && result.reply !== undefined) {
1647
+ await send(
1648
+ {
1649
+ adapter: event.adapter,
1650
+ workspace: event.workspace,
1651
+ chat: event.chat,
1652
+ thread: event.thread,
1653
+ text: result.reply,
1654
+ },
1655
+ { source: 'system' },
1656
+ )
1657
+ }
1658
+ return result
1659
+ }
1660
+
1606
1661
  const route = async (event: InboundMessage): Promise<void> => {
1607
1662
  const adapterConfig = options.configForAdapter(event.adapter)
1608
1663
  if (!adapterConfig) return
@@ -1650,6 +1705,25 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1650
1705
  }
1651
1706
  }
1652
1707
 
1708
+ // Parse once, here, so the public-command fast path (below) and the
1709
+ // post-gate command block share one parse and lookup.
1710
+ const parsedCommand = commands.parse(event.text)
1711
+ const commandInfo = parsedCommand === null ? undefined : commands.get(parsedCommand.name)
1712
+
1713
+ // Public-command fast path: a known command that is both ungated
1714
+ // (permission:'none') AND informational (requiresLiveSession:false) runs
1715
+ // BEFORE the channel.respond gate, mirroring the native-slash path where
1716
+ // such commands skip permissions entirely. Both conditions are required so
1717
+ // a future "public but live-session-aware" command can't silently bypass
1718
+ // the gate. It only reveals already-public command names — it never creates
1719
+ // a session or prompts the agent — so it is not a channel.respond bypass in
1720
+ // any meaningful sense. Unknown commands, /stop, //escaped text, and plain
1721
+ // messages all fall through to the gate unchanged.
1722
+ if (parsedCommand !== null && commandInfo?.permission === 'none' && !commandInfo.requiresLiveSession) {
1723
+ await runChannelCommand(event, null)
1724
+ return
1725
+ }
1726
+
1653
1727
  if (isChannelRespondDenied(event)) {
1654
1728
  publishInbound(event, 'denied')
1655
1729
  logger.info(
@@ -1658,27 +1732,31 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1658
1732
  return
1659
1733
  }
1660
1734
 
1661
- const parsedCommand = commands.parse(event.text)
1662
1735
  if (parsedCommand !== null) {
1663
1736
  // Commands are control traffic, not engaged inbounds; if the session is stale,
1664
1737
  // the next engaged inbound will perform the rollover before prompting.
1665
1738
  const keyId = channelKeyId(key)
1666
- if (!commands.has(parsedCommand.name)) {
1739
+ if (commandInfo === undefined) {
1667
1740
  logger.info(`[channels] ${keyId}: ignoring unknown command /${parsedCommand.name}`)
1668
1741
  return
1669
1742
  }
1670
- if (isSessionControlDenied(event)) {
1743
+ if (commandInfo.permission === 'session.control' && isSessionControlDenied(event)) {
1671
1744
  logger.info(
1672
1745
  `[channels] ${keyId}: denied command /${parsedCommand.name} by permissions (session.control) author=${event.authorId}`,
1673
1746
  )
1674
1747
  return
1675
1748
  }
1676
- const existingLive = liveSessions.get(keyId)
1677
- if (!existingLive || existingLive.destroyed) {
1678
- logger.info(`[channels] ${keyId}: ignoring command /${parsedCommand.name} with no live session`)
1679
- return
1749
+ // Session-less commands (e.g. /help) are informational and run without a
1750
+ // live session; their handler reply is posted straight back to the channel.
1751
+ let existingLive: LiveSession | null = null
1752
+ if (commandInfo.requiresLiveSession) {
1753
+ existingLive = liveSessions.get(keyId) ?? null
1754
+ if (existingLive === null || existingLive.destroyed) {
1755
+ logger.info(`[channels] ${keyId}: ignoring command /${parsedCommand.name} with no live session`)
1756
+ return
1757
+ }
1680
1758
  }
1681
- const commandResult = await commands.execute(event.text, { live: existingLive, event })
1759
+ const commandResult = await runChannelCommand(event, existingLive)
1682
1760
  if (commandResult.kind !== 'not-command') return
1683
1761
  }
1684
1762
 
@@ -1712,6 +1790,8 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1712
1790
 
1713
1791
  const membership = await membershipForEngagement(live)
1714
1792
 
1793
+ live.multiHumanGroup = isMultiHumanGroup(event.isDm, countEffectiveHumans(live.participants, membership, now()))
1794
+
1715
1795
  const decision: EngagementDecision = decideEngagement({
1716
1796
  message: event,
1717
1797
  config: adapterConfig.engagement,
@@ -2416,38 +2496,49 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2416
2496
  options: ExecuteCommandOptions,
2417
2497
  ): Promise<ExecuteCommandResult> => {
2418
2498
  const lowered = name.toLowerCase()
2419
- if (!commands.has(lowered)) {
2499
+ const commandInfo = commands.get(lowered)
2500
+ if (commandInfo === undefined) {
2420
2501
  return { kind: 'unknown-command', name: lowered }
2421
2502
  }
2422
2503
  // Gates on session.control (not channel.respond) so a respond-capable
2423
2504
  // guest cannot abort another speaker's turn. Runs BEFORE the live-session
2424
2505
  // lookup so an unauthorized invoker gets 'permission-denied' regardless of
2425
2506
  // session state, rather than leaking session presence via the
2426
- // 'no-live-session' vs 'permission-denied' distinction.
2427
- const partial: SessionOrigin = {
2428
- kind: 'channel',
2429
- adapter: key.adapter,
2430
- workspace: key.workspace,
2431
- chat: key.chat,
2432
- thread: key.thread,
2433
- lastInboundAuthorId: options.invokerId,
2434
- }
2435
- if (!permissions.has(partial, CORE_PERMISSIONS.sessionControl)) {
2436
- return { kind: 'permission-denied' }
2437
- }
2438
- const resolved = resolveLiveSessionForCommand(liveSessions, key)
2439
- if (resolved.kind === 'none') {
2440
- return { kind: 'no-live-session' }
2507
+ // 'no-live-session' vs 'permission-denied' distinction. Session-less
2508
+ // informational commands (e.g. /help) declare permission:'none' and skip
2509
+ // both the gate and the lookup so they work in channels with no live turn.
2510
+ if (commandInfo.permission === 'session.control') {
2511
+ const partial: SessionOrigin = {
2512
+ kind: 'channel',
2513
+ adapter: key.adapter,
2514
+ workspace: key.workspace,
2515
+ chat: key.chat,
2516
+ thread: key.thread,
2517
+ lastInboundAuthorId: options.invokerId,
2518
+ }
2519
+ if (!permissions.has(partial, CORE_PERMISSIONS.sessionControl)) {
2520
+ return { kind: 'permission-denied' }
2521
+ }
2441
2522
  }
2442
- if (resolved.kind === 'ambiguous') {
2443
- return { kind: 'ambiguous', matchCount: resolved.count }
2523
+ let live: LiveSession | null = null
2524
+ if (commandInfo.requiresLiveSession) {
2525
+ const resolved = resolveLiveSessionForCommand(liveSessions, key)
2526
+ if (resolved.kind === 'none') {
2527
+ return { kind: 'no-live-session' }
2528
+ }
2529
+ if (resolved.kind === 'ambiguous') {
2530
+ return { kind: 'ambiguous', matchCount: resolved.count }
2531
+ }
2532
+ live = resolved.session
2444
2533
  }
2445
- const result = await commands.execute(`/${lowered}`, { live: resolved.session, event: null })
2534
+ const result = await commands.execute(`/${lowered}`, { live, event: null })
2446
2535
  if (result.kind === 'handled') {
2447
- return { kind: 'handled', name: result.name }
2536
+ return result.reply !== undefined
2537
+ ? { kind: 'handled', name: result.name, reply: result.reply }
2538
+ : { kind: 'handled', name: result.name }
2448
2539
  }
2449
2540
  // commands.execute can only return not-command (impossible — we pass a
2450
- // leading slash), unknown-command (impossible — we just checked has()),
2541
+ // leading slash), unknown-command (impossible — we just checked get()),
2451
2542
  // or handled. Any other outcome is a bug.
2452
2543
  return { kind: 'unknown-command', name: lowered }
2453
2544
  }
@@ -2616,6 +2707,7 @@ function composeTurnPrompt(
2616
2707
  state: {
2617
2708
  adapter?: AdapterId
2618
2709
  loopGuardActive: boolean
2710
+ groupChatNudge?: boolean
2619
2711
  systemReminders?: readonly string[]
2620
2712
  now?: Date
2621
2713
  role?: string
@@ -2689,6 +2781,32 @@ function composeTurnPrompt(
2689
2781
  '',
2690
2782
  )
2691
2783
  }
2784
+ // Group-chat nudge: same SYSTEM MESSAGE convention as the loop guard. We
2785
+ // engaged this turn (explicit mention/reply/alias, or a fresh trigger),
2786
+ // but the room has multiple humans, so the default "answer everything"
2787
+ // posture is wrong. The engagement gate already stopped sticky credit
2788
+ // from waking us on every follow-up; this tells the model to be
2789
+ // selective on the turns it IS woken for. Cache-neutral (user-turn
2790
+ // suffix), and skipped when the loop guard already fired to avoid
2791
+ // stacking two silence notices in one turn.
2792
+ if (state.groupChatNudge === true && !state.loopGuardActive) {
2793
+ parts.push(
2794
+ '---',
2795
+ '**[SYSTEM MESSAGE — not from a human]**',
2796
+ '',
2797
+ 'You are in a group chat with multiple people. This is an automated',
2798
+ 'signal from the channel router, not a message from anyone in the chat.',
2799
+ '**Do not acknowledge or reply to this notice.**',
2800
+ '',
2801
+ 'Guidance:',
2802
+ '- Reply only if the current message is addressed to you or clearly needs your input.',
2803
+ '- For chatter between others, side-conversation, or messages that do not need you,',
2804
+ ' reply with `NO_REPLY` (or call `skip_response`) to stay silent and just keep watching.',
2805
+ '',
2806
+ '---',
2807
+ '',
2808
+ )
2809
+ }
2692
2810
  if (observed.length > 0) {
2693
2811
  parts.push('## Recent context (not addressed to you, for awareness only)')
2694
2812
  for (const o of observed) {
@@ -842,7 +842,6 @@ async function promptGithubAppAuth(): Promise<{
842
842
  type: 'app'
843
843
  appId: number
844
844
  privateKey: string
845
- installationId?: number
846
845
  }> {
847
846
  const appId = await text({
848
847
  message: 'GitHub App ID',
@@ -857,21 +856,10 @@ async function promptGithubAppAuth(): Promise<{
857
856
  cancel('Aborted.')
858
857
  process.exit(0)
859
858
  }
860
- const installationId = await text({
861
- message: 'Installation ID (optional; leave blank to auto-discover)',
862
- validate: (value) =>
863
- value === undefined || value === '' ? undefined : validatePositiveInteger(value, 'Installation ID is required'),
864
- })
865
- if (isCancel(installationId)) {
866
- cancel('Aborted.')
867
- process.exit(0)
868
- }
869
- const parsedInstallationId = installationId === '' ? undefined : Number(installationId)
870
859
  return {
871
860
  type: 'app',
872
861
  appId: Number(appId),
873
862
  privateKey,
874
- ...(parsedInstallationId !== undefined ? { installationId: parsedInstallationId } : {}),
875
863
  }
876
864
  }
877
865
 
package/src/cli/init.ts CHANGED
@@ -1293,7 +1293,6 @@ async function promptGithubAppAuth(): Promise<{
1293
1293
  type: 'app'
1294
1294
  appId: number
1295
1295
  privateKey: string
1296
- installationId?: number
1297
1296
  } | null> {
1298
1297
  const appId = await text({
1299
1298
  message: 'GitHub App ID',
@@ -1302,18 +1301,10 @@ async function promptGithubAppAuth(): Promise<{
1302
1301
  if (isCancel(appId)) return null
1303
1302
  const privateKey = await promptPrivateKeyPem('GitHub App private key PEM, escaped PEM, or path to .pem file')
1304
1303
  if (privateKey === CANCEL_SYMBOL) return null
1305
- const installationId = await text({
1306
- message: 'Installation ID (optional; leave blank to auto-discover)',
1307
- validate: (v) =>
1308
- v === undefined || v === '' ? undefined : validatePositiveInteger(v, 'Installation ID is required'),
1309
- })
1310
- if (isCancel(installationId)) return null
1311
- const parsedInstallationId = installationId === '' ? undefined : Number(installationId)
1312
1304
  return {
1313
1305
  type: 'app',
1314
1306
  appId: Number(appId),
1315
1307
  privateKey,
1316
- ...(parsedInstallationId !== undefined ? { installationId: parsedInstallationId } : {}),
1317
1308
  }
1318
1309
  }
1319
1310
 
package/src/cli/ui.ts CHANGED
@@ -152,6 +152,12 @@ export const SLACK_APP_MANIFEST = {
152
152
  // so a misconfigured (non-Socket-Mode) deployment fails fast rather
153
153
  // than silently routing real slash invocations to a third-party URL.
154
154
  slash_commands: [
155
+ {
156
+ command: '/help',
157
+ description: 'List available commands',
158
+ url: 'https://example.invalid/typeclaw-uses-socket-mode',
159
+ should_escape: false,
160
+ },
155
161
  {
156
162
  command: '/stop',
157
163
  description: 'Abort the current turn in this channel',
@@ -1,8 +1,27 @@
1
- export type CommandHandler<Context> = (context: Context, command: ParsedCommand) => Promise<void> | void
1
+ // Returning nothing keeps the void contract the dispatch layer then falls
2
+ // back to its static per-result-kind reply. A `reply` lets dynamic commands
3
+ // (/help) surface text the dispatcher could not have known statically.
4
+ export type CommandHandlerResult = void | { reply?: string }
5
+
6
+ export type CommandHandler<Context> = (
7
+ context: Context,
8
+ command: ParsedCommand,
9
+ ) => Promise<CommandHandlerResult> | CommandHandlerResult
10
+
11
+ // `permission` and `requiresLiveSession` are command-level policy the dispatch
12
+ // layer (the channel router) enforces. They live on the command, not the
13
+ // dispatcher, so a new command declares its own requirements in one place:
14
+ // 'session.control' + requiresLiveSession:true is the control-command default
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'
2
18
 
3
19
  export type Command<Context> = {
4
20
  name: string
5
21
  aliases?: readonly string[]
22
+ description: string
23
+ permission?: CommandPermission
24
+ requiresLiveSession?: boolean
6
25
  handler: CommandHandler<Context>
7
26
  }
8
27
 
@@ -11,14 +30,27 @@ export type ParsedCommand = {
11
30
  args: string
12
31
  }
13
32
 
33
+ // Read-only view of a registered command, used to generate help text from the
34
+ // registry so the listing can never drift from the actual command set. Aliases
35
+ // are folded into the canonical entry rather than listed as separate commands.
36
+ export type CommandInfo = {
37
+ name: string
38
+ aliases: readonly string[]
39
+ description: string
40
+ permission: CommandPermission
41
+ requiresLiveSession: boolean
42
+ }
43
+
14
44
  export type CommandResult =
15
45
  | { kind: 'not-command' }
16
46
  | { kind: 'unknown-command'; name: string }
17
- | { kind: 'handled'; name: string }
47
+ | { kind: 'handled'; name: string; reply?: string }
18
48
 
19
49
  export type CommandRegistry<Context> = {
20
50
  parse: (text: string) => ParsedCommand | null
21
51
  has: (name: string) => boolean
52
+ get: (name: string) => CommandInfo | undefined
53
+ list: () => readonly CommandInfo[]
22
54
  execute: (text: string, context: Context) => Promise<CommandResult>
23
55
  }
24
56
 
@@ -33,16 +65,34 @@ export function createCommandRegistry<Context>(commands: readonly Command<Contex
33
65
  }
34
66
  }
35
67
 
68
+ const info = (command: Command<Context>): CommandInfo => ({
69
+ name: command.name,
70
+ aliases: command.aliases ?? [],
71
+ description: command.description,
72
+ permission: command.permission ?? 'session.control',
73
+ requiresLiveSession: command.requiresLiveSession ?? true,
74
+ })
75
+
36
76
  return {
37
77
  parse: parseCommand,
38
78
  has: (name) => byName.has(name.toLowerCase()),
79
+ get: (name) => {
80
+ const command = byName.get(name.toLowerCase())
81
+ return command ? info(command) : undefined
82
+ },
83
+ // Canonical commands only, in declaration order. Aliases resolve to the
84
+ // same Command object, so de-dupe by identity to avoid duplicate rows.
85
+ list: () => commands.map(info),
39
86
  execute: async (text, context) => {
40
87
  const parsed = parseCommand(text)
41
88
  if (parsed === null) return { kind: 'not-command' }
42
89
  const command = byName.get(parsed.name)
43
90
  if (!command) return { kind: 'unknown-command', name: parsed.name }
44
- await command.handler(context, parsed)
45
- return { kind: 'handled', name: command.name }
91
+ const result = await command.handler(context, parsed)
92
+ const reply = result?.reply
93
+ return reply !== undefined
94
+ ? { kind: 'handled', name: command.name, reply }
95
+ : { kind: 'handled', name: command.name }
46
96
  },
47
97
  }
48
98
  }
@@ -50,6 +50,16 @@ export type BuildDockerfileOptions = {
50
50
  // flag the seccomp default profile blocks `unshare(CLONE_NEWUSER)` and bwrap
51
51
  // fails at startup. The two changes are load-bearing together — do not drop
52
52
  // one without the other.
53
+ //
54
+ // `jq` is in baseline (not behind a toggle) so it is available to the
55
+ // per-tool bwrap sandbox that wraps agent bash calls. The sandbox only
56
+ // `--ro-bind`s `/usr` + `/bin` from the container (see `src/sandbox/build.ts`),
57
+ // so a binary that is not in the base image is invisible inside the sandbox —
58
+ // there is no per-call install path. JSON munging in one-shot read-only
59
+ // pipelines (`curl ... | jq`, `cat foo.json | jq`) is a baseline expectation
60
+ // for the explorer/reviewer/scout subagents, whose prompts already advertise
61
+ // `jq` as an available pipeline tool, so it ships unconditionally rather than
62
+ // as an opt-in toggle.
53
63
  const BASELINE_APT_PACKAGES = [
54
64
  'git',
55
65
  'ca-certificates',
@@ -58,6 +68,7 @@ const BASELINE_APT_PACKAGES = [
58
68
  'iptables',
59
69
  'util-linux',
60
70
  'bubblewrap',
71
+ 'jq',
61
72
  ] as const
62
73
 
63
74
  // curl-impersonate is the only currently-working way to query DuckDuckGo from
@@ -87,6 +98,33 @@ export const CURL_IMPERSONATE_SHA256_ARM64 = '6766bc67fd3e8e2313875f32b36b5a3fab
87
98
  // the impersonation to whatever `curl_chrome` resolves to.
88
99
  export const CURL_IMPERSONATE_PROFILE = 'chrome136'
89
100
 
101
+ // yq is the YAML/JSON/XML processor the `explorer` and `reviewer` subagent
102
+ // prompts advertise alongside `jq` as a sanctioned one-shot read-only
103
+ // pipeline tool (`... | yq`). Like `jq` (shipped in baseline), a binary that
104
+ // is not in the container base image is invisible inside the per-tool bwrap
105
+ // sandbox that wraps agent bash calls — the sandbox only `--ro-bind`s `/usr`
106
+ // + `/bin` from the container, and there is no per-call install path. So `yq`
107
+ // ships unconditionally, same rationale as `jq`/`bubblewrap`/`util-linux`.
108
+ //
109
+ // This is Mike Farah's Go `yq` (https://github.com/mikefarah/yq), NOT the
110
+ // Python jq-wrapper of the same name in Debian's `yq` apt package. The
111
+ // prompts pair `yq` with `jq` for jq-style expression pipelines, which is
112
+ // Farah's syntax — the Python tool's CLI is incompatible. Distributed as a
113
+ // per-arch static binary (no apt package on trixie for the Go variant), so
114
+ // it follows the pinned-version + per-arch SHA256 + `sha256sum -c` pattern of
115
+ // curl-impersonate and cloudflared rather than the apt baseline list.
116
+ //
117
+ // To bump: pick a release from https://github.com/mikefarah/yq/releases,
118
+ // then for each arch download yq_linux_<arch> and `shasum -a 256` it (or read
119
+ // the SHA-256 column from the release's `checksums` file, cross-indexed via
120
+ // `checksums_hashes_order`). Update all three constants in the same commit;
121
+ // the build fails loudly at `sha256sum -c` on a mismatch. Version literal is
122
+ // the release tag exactly as it appears on GitHub (with `v` prefix).
123
+ export const YQ_VERSION = 'v4.53.2'
124
+ export const YQ_SHA256_AMD64 = 'd56bf5c6819e8e696340c312bd70f849dc1678a7cda9c2ad63eebd906371d56b'
125
+ export const YQ_SHA256_ARM64 = '03061b2a50c7a498de2bbb92d7cb078ce433011f085a4994117c2726be4106ea'
126
+ export const YQ_RELEASE_URL_BASE = 'https://github.com/mikefarah/yq/releases/download'
127
+
90
128
  // cloudflared powers `cloudflare-quick` tunnels. Pinned-version + per-arch
91
129
  // SHA256 mirrors the curl-impersonate pattern above: bumping requires updating
92
130
  // all three constants in the same commit, and the build fails loudly at
@@ -1177,6 +1215,8 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \\
1177
1215
 
1178
1216
  ${LAYER_2_5_CURL_IMPERSONATE}
1179
1217
 
1218
+ ${LAYER_2_6_YQ}
1219
+
1180
1220
  ${LAYER_3_AGENT_BROWSER_ARM64_CONFIG}
1181
1221
 
1182
1222
  ${LAYER_4_AGENT_BROWSER_INSTALL}
@@ -1256,6 +1296,8 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \\
1256
1296
 
1257
1297
  ${LAYER_2_5_CURL_IMPERSONATE}
1258
1298
 
1299
+ ${LAYER_2_6_YQ}
1300
+
1259
1301
  ${LAYER_3_AGENT_BROWSER_ARM64_CONFIG}
1260
1302
 
1261
1303
  ${LAYER_4_AGENT_BROWSER_INSTALL}
@@ -1310,6 +1352,24 @@ RUN ARCH_TARBALL="$(if [ "$TARGETARCH" = "arm64" ]; then echo aarch64-linux-gnu;
1310
1352
  && rm curl-impersonate.tar.gz \\
1311
1353
  && /usr/local/bin/curl_${CURL_IMPERSONATE_PROFILE} --version > /dev/null`
1312
1354
 
1355
+ // Layer 2.6: install pinned Mike Farah `yq` (Go) so the explorer/reviewer
1356
+ // subagents' advertised `... | yq` pipelines resolve inside the bwrap
1357
+ // sandbox. Unconditional like the apt baseline (jq, git): a missing binary
1358
+ // is invisible to sandboxed bash, so this is not behind a toggle. Placed
1359
+ // after curl-impersonate (curl + ca-certificates from baseline guaranteed
1360
+ // present) and before agent-browser so an agent-browser bump doesn't
1361
+ // invalidate this layer. See the YQ_* constants above for the bump recipe
1362
+ // and the Go-vs-Python `yq` rationale.
1363
+ const LAYER_2_6_YQ = `# Layer 2.6 (stable): pinned Mike Farah yq for sandbox-visible YAML pipelines.
1364
+ RUN ARCH_BIN="$(if [ "$TARGETARCH" = "arm64" ]; then echo arm64; else echo amd64; fi)" \\
1365
+ && ARCH_SHA="$(if [ "$TARGETARCH" = "arm64" ]; then echo ${YQ_SHA256_ARM64}; else echo ${YQ_SHA256_AMD64}; fi)" \\
1366
+ && cd /tmp \\
1367
+ && curl -fsSL -o yq "${YQ_RELEASE_URL_BASE}/${YQ_VERSION}/yq_linux_\${ARCH_BIN}" \\
1368
+ && echo "\${ARCH_SHA} yq" | sha256sum -c - \\
1369
+ && chmod +x yq \\
1370
+ && mv yq /usr/local/bin/yq \\
1371
+ && /usr/local/bin/yq --version > /dev/null`
1372
+
1313
1373
  const LAYER_3_AGENT_BROWSER_ARM64_CONFIG = `# Layer 3 (stable, arm64 only): point agent-browser at the apt-installed
1314
1374
  # chromium. Independent of the npm install below so it stays cached across
1315
1375
  # agent-browser version bumps.
@@ -57,7 +57,7 @@ export async function installGithubWebhooksEagerly(
57
57
 
58
58
  try {
59
59
  const result = await registerGithubWebhooks({
60
- token: () => strategy.token(),
60
+ token: (repoSlug: string) => strategy.token({ repoSlug }),
61
61
  webhookUrl,
62
62
  webhookSecret: options.webhookSecret,
63
63
  repos: options.repos,
@@ -86,7 +86,6 @@ function authToSecretBlock(auth: GithubInitCredentials['auth']) {
86
86
  type: 'app' as const,
87
87
  appId: auth.appId,
88
88
  privateKey: { value: auth.privateKey },
89
- ...(auth.installationId !== undefined ? { installationId: auth.installationId } : {}),
90
89
  }
91
90
  }
92
91