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.
- package/auth.schema.json +0 -5
- package/package.json +2 -2
- package/secrets.schema.json +0 -5
- package/src/agent/index.ts +2 -1
- package/src/agent/model-overrides.ts +77 -0
- package/src/agent/plugin-tools.ts +53 -4
- package/src/agent/tools/grant-role.ts +102 -8
- package/src/bundled-plugins/github-cli-auth/gh-command.ts +372 -0
- package/src/bundled-plugins/github-cli-auth/index.ts +42 -0
- package/src/bundled-plugins/github-cli-auth/token-class.ts +11 -0
- package/src/bundled-plugins/reviewer/skills/code-review.ts +18 -1
- package/src/bundled-plugins/security/policies/secret-exfil-bash.ts +9 -2
- package/src/channels/adapters/discord-bot-classify.ts +23 -0
- package/src/channels/adapters/discord-bot.ts +22 -4
- package/src/channels/adapters/github/auth-app.ts +49 -26
- package/src/channels/adapters/github/auth-pat.ts +3 -3
- package/src/channels/adapters/github/auth.ts +19 -5
- package/src/channels/adapters/github/channel-resolver.ts +3 -2
- package/src/channels/adapters/github/history.ts +3 -2
- package/src/channels/adapters/github/inbound.ts +30 -55
- package/src/channels/adapters/github/index.ts +147 -43
- package/src/channels/adapters/github/membership.ts +7 -2
- package/src/channels/adapters/github/outbound.ts +6 -2
- package/src/channels/adapters/github/team-membership.ts +4 -2
- package/src/channels/adapters/github/webhook-register.ts +19 -16
- package/src/channels/adapters/slack-bot-slash-commands.ts +78 -1
- package/src/channels/adapters/slack-bot.ts +119 -18
- package/src/channels/commands.ts +10 -0
- package/src/channels/engagement.ts +34 -3
- package/src/channels/github-token-bridge.ts +42 -0
- package/src/channels/index.ts +6 -0
- package/src/channels/manager.ts +6 -0
- package/src/channels/membership.ts +9 -0
- package/src/channels/router.ts +155 -37
- package/src/cli/channel.ts +0 -12
- package/src/cli/init.ts +0 -9
- package/src/cli/ui.ts +6 -0
- package/src/commands/index.ts +54 -4
- package/src/init/dockerfile.ts +60 -0
- package/src/init/github-webhook-install.ts +1 -2
- package/src/init/index.ts +4 -10
- package/src/init/validate-api-key.ts +15 -1
- package/src/plugin/context.ts +8 -0
- package/src/plugin/manager.ts +3 -0
- package/src/plugin/types.ts +6 -0
- package/src/run/bundled-plugins.ts +9 -0
- package/src/run/index.ts +6 -0
- package/src/secrets/schema.ts +0 -1
- package/src/server/command-runner.ts +14 -0
- package/src/skills/typeclaw-channel-github/SKILL.md +70 -43
package/src/channels/router.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
|
|
2437
|
-
|
|
2438
|
-
|
|
2439
|
-
|
|
2440
|
-
|
|
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
|
-
|
|
2443
|
-
|
|
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
|
|
2534
|
+
const result = await commands.execute(`/${lowered}`, { live, event: null })
|
|
2446
2535
|
if (result.kind === 'handled') {
|
|
2447
|
-
return
|
|
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
|
|
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) {
|
package/src/cli/channel.ts
CHANGED
|
@@ -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',
|
package/src/commands/index.ts
CHANGED
|
@@ -1,8 +1,27 @@
|
|
|
1
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/init/dockerfile.ts
CHANGED
|
@@ -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
|
|