typeclaw 0.20.0 → 0.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -1
- package/src/agent/index.ts +55 -1
- package/src/agent/loop-guard.ts +180 -53
- package/src/agent/restart/index.ts +101 -0
- package/src/agent/tools/restart.ts +23 -52
- package/src/bundled-plugins/bun-hygiene/README.md +82 -0
- package/src/bundled-plugins/bun-hygiene/index.ts +11 -0
- package/src/bundled-plugins/bun-hygiene/policy.ts +318 -0
- package/src/bundled-plugins/github-cli-auth/gh-command.ts +98 -6
- package/src/bundled-plugins/github-cli-auth/graphql-auth-nudge.ts +80 -0
- package/src/bundled-plugins/github-cli-auth/index.ts +7 -0
- package/src/bundled-plugins/memory/memory-logger.ts +6 -2
- package/src/bundled-plugins/reviewer/skills/code-review.ts +8 -0
- package/src/channels/adapters/discord-bot-classify.ts +8 -2
- package/src/channels/adapters/discord-bot.ts +29 -2
- package/src/channels/adapters/github/decoy-reviewer.ts +43 -0
- package/src/channels/adapters/github/inbound.ts +92 -1
- package/src/channels/adapters/github/index.ts +12 -1
- package/src/channels/adapters/github/reactions.ts +138 -4
- package/src/channels/adapters/slack-bot-classify.ts +2 -2
- package/src/channels/adapters/slack-bot.ts +129 -7
- package/src/channels/engagement.ts +71 -31
- package/src/channels/manager.ts +8 -0
- package/src/channels/router.ts +180 -25
- package/src/channels/schema.ts +18 -0
- package/src/channels/types.ts +16 -1
- package/src/cli/builtins.ts +1 -0
- package/src/cli/dreams.ts +148 -0
- package/src/cli/index.ts +1 -0
- package/src/cli/inspect.ts +2 -1
- package/src/cli/ui.ts +34 -0
- package/src/commands/index.ts +5 -2
- package/src/config/config.ts +89 -0
- package/src/dreams/git.ts +85 -0
- package/src/dreams/index.ts +134 -0
- package/src/dreams/parse.ts +224 -0
- package/src/dreams/render.ts +155 -0
- package/src/dreams/types.ts +50 -0
- package/src/mcp/catalog.ts +29 -0
- package/src/mcp/client.ts +236 -0
- package/src/mcp/index.ts +25 -0
- package/src/mcp/manager.ts +156 -0
- package/src/mcp/tools.ts +190 -0
- package/src/permissions/builtins.ts +9 -0
- package/src/reload/format.ts +14 -0
- package/src/reload/index.ts +1 -0
- package/src/run/bundled-plugins.ts +7 -0
- package/src/run/channel-session-factory.ts +3 -0
- package/src/run/index.ts +38 -1
- package/src/server/command-runner.ts +5 -0
- package/src/server/index.ts +53 -0
- package/src/shared/protocol.ts +2 -0
- package/src/skills/typeclaw-channel-github/SKILL.md +86 -22
- package/src/tui/index.ts +70 -18
- package/typeclaw.schema.json +82 -0
package/src/channels/router.ts
CHANGED
|
@@ -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'
|
|
@@ -51,6 +51,8 @@ import type {
|
|
|
51
51
|
HistoryCallback,
|
|
52
52
|
InboundAttachment,
|
|
53
53
|
InboundMessage,
|
|
54
|
+
RemoveReactionCallback,
|
|
55
|
+
RemoveReactionRequest,
|
|
54
56
|
OutboundCallback,
|
|
55
57
|
OutboundMessage,
|
|
56
58
|
QuoteAnchorSource,
|
|
@@ -282,6 +284,7 @@ type QueuedInbound = {
|
|
|
282
284
|
authorIsBot: boolean
|
|
283
285
|
externalMessageId: string
|
|
284
286
|
reactionRef?: ReactionRef
|
|
287
|
+
engageReaction?: Promise<ReactionRef | null>
|
|
285
288
|
isBotMention: boolean
|
|
286
289
|
replyToBotMessageId: string | null
|
|
287
290
|
isDm: boolean
|
|
@@ -353,6 +356,11 @@ type LiveSession = {
|
|
|
353
356
|
// origin so `channel_react` reacts to the triggering message, not whichever
|
|
354
357
|
// inbound happens to be latest in the queue. Null on reminder-only turns.
|
|
355
358
|
currentTurnReactionRef: ReactionRef | null
|
|
359
|
+
// One engage-:eyes:-add promise per inbound coalesced into THIS turn, each
|
|
360
|
+
// resolving to its removable per-instance ref (or null). A debounced turn can
|
|
361
|
+
// batch several inbounds that each got their own :eyes:, so every entry is
|
|
362
|
+
// removed after the reply. Empty on turns with no reactable inbound.
|
|
363
|
+
currentTurnEngageReactions: Array<Promise<ReactionRef | null>>
|
|
356
364
|
lastTurnAuthorIds: Set<string>
|
|
357
365
|
// Mirror of currentTurnAuthorId at end-of-turn (the LAST speaker of the
|
|
358
366
|
// prior batch), preserved across the drain finally-block which resets
|
|
@@ -538,6 +546,9 @@ export type ChannelRouter = {
|
|
|
538
546
|
registerReaction: (adapter: ChannelKey['adapter'], cb: ReactionCallback) => void
|
|
539
547
|
unregisterReaction: (adapter: ChannelKey['adapter'], cb: ReactionCallback) => void
|
|
540
548
|
react: (req: ReactionRequest) => Promise<ReactionResult>
|
|
549
|
+
registerRemoveReaction: (adapter: ChannelKey['adapter'], cb: RemoveReactionCallback) => void
|
|
550
|
+
unregisterRemoveReaction: (adapter: ChannelKey['adapter'], cb: RemoveReactionCallback) => void
|
|
551
|
+
removeReaction: (req: RemoveReactionRequest) => Promise<ReactionResult>
|
|
541
552
|
registerTyping: (adapter: ChannelKey['adapter'], cb: TypingCallback) => void
|
|
542
553
|
unregisterTyping: (adapter: ChannelKey['adapter'], cb: TypingCallback) => void
|
|
543
554
|
registerChannelNameResolver: (adapter: ChannelKey['adapter'], resolver: ChannelNameResolver) => void
|
|
@@ -709,6 +720,17 @@ export type CreateChannelRouterOptions = {
|
|
|
709
720
|
// can diagnose silent drops from `typeclaw inspect` alone. Omitted in
|
|
710
721
|
// tests that don't care about inspect surfacing.
|
|
711
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>
|
|
712
734
|
}
|
|
713
735
|
|
|
714
736
|
export type ClaimHandlerInput = {
|
|
@@ -745,6 +767,8 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
745
767
|
const permissions = options.permissions ?? GRANT_ALL_PERMISSIONS
|
|
746
768
|
const claimHandler = options.claimHandler
|
|
747
769
|
const stream = options.stream
|
|
770
|
+
const onReload = options.onReload
|
|
771
|
+
const onRestart = options.onRestart
|
|
748
772
|
const liveSessions = new Map<string, LiveSession>()
|
|
749
773
|
const creating = new Map<string, Promise<LiveSession>>()
|
|
750
774
|
// Bumped by tearDownAllLive() and stop() before they tear sessions down. An
|
|
@@ -757,6 +781,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
757
781
|
let liveGeneration = 0
|
|
758
782
|
const outboundCallbacks = new Map<ChannelKey['adapter'], Set<OutboundCallback>>()
|
|
759
783
|
const reactionCallbacks = new Map<ChannelKey['adapter'], Set<ReactionCallback>>()
|
|
784
|
+
const removeReactionCallbacks = new Map<ChannelKey['adapter'], Set<RemoveReactionCallback>>()
|
|
760
785
|
const typingCallbacks = new Map<ChannelKey['adapter'], Set<TypingCallback>>()
|
|
761
786
|
const channelNameResolvers = new Map<ChannelKey['adapter'], Set<ChannelNameResolver>>()
|
|
762
787
|
const membershipResolvers = new Map<ChannelKey['adapter'], Set<MembershipResolver>>()
|
|
@@ -767,7 +792,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
767
792
|
// The /help handler reads the live registry to enumerate commands, so it
|
|
768
793
|
// forward-references `commands`. Safe at runtime — the handler only runs on
|
|
769
794
|
// invocation, long after the assignment below completes.
|
|
770
|
-
const channelCommands:
|
|
795
|
+
const channelCommands: Command<ChannelCommandContext>[] = [
|
|
771
796
|
{
|
|
772
797
|
name: 'help',
|
|
773
798
|
description: 'List available commands.',
|
|
@@ -788,6 +813,28 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
788
813
|
},
|
|
789
814
|
},
|
|
790
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
|
+
}
|
|
791
838
|
const commands = createCommandRegistry<ChannelCommandContext>(channelCommands)
|
|
792
839
|
|
|
793
840
|
// Implicit dir-name alias: agent folder basename matches Docker
|
|
@@ -1122,6 +1169,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1122
1169
|
currentTurnAuthorId: null,
|
|
1123
1170
|
currentTurnAuthorIds: new Set(),
|
|
1124
1171
|
currentTurnReactionRef: null,
|
|
1172
|
+
currentTurnEngageReactions: [],
|
|
1125
1173
|
// `lastTurnAuthorId` (string, used for `lastInboundAuthorId` in
|
|
1126
1174
|
// origin) and `lastTurnAuthorIds` (Set, used by
|
|
1127
1175
|
// `grantStickyForReplyTargets` as the fallback when
|
|
@@ -1254,6 +1302,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1254
1302
|
receivedAt: now(),
|
|
1255
1303
|
ts: item.message.ts,
|
|
1256
1304
|
source: 'prefetch',
|
|
1305
|
+
...(item.message.attachments !== undefined ? { attachments: item.message.attachments } : {}),
|
|
1257
1306
|
})
|
|
1258
1307
|
} else {
|
|
1259
1308
|
observed.push({
|
|
@@ -1532,10 +1581,14 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1532
1581
|
live.currentTurnAuthorId = batch[batch.length - 1]!.authorId
|
|
1533
1582
|
live.currentTurnAuthorIds = new Set(batch.map((m) => m.authorId))
|
|
1534
1583
|
live.currentTurnReactionRef = batch[batch.length - 1]!.reactionRef ?? null
|
|
1584
|
+
live.currentTurnEngageReactions = batch.flatMap((m) =>
|
|
1585
|
+
m.engageReaction !== undefined ? [m.engageReaction] : [],
|
|
1586
|
+
)
|
|
1535
1587
|
live.consecutiveSends.clear()
|
|
1536
1588
|
live.lastSentText.clear()
|
|
1537
1589
|
live.pendingQuoteCandidate = captureQuoteCandidate(live.key.adapter, batch, observed)
|
|
1538
1590
|
} else if (live.lastTurnAuthorId !== null) {
|
|
1591
|
+
live.currentTurnEngageReactions = []
|
|
1539
1592
|
// Reminder-only turn (batch.length === 0, reminders.length > 0):
|
|
1540
1593
|
// restore the author identity from the prior turn so author-
|
|
1541
1594
|
// scoped role resolution still works on this turn. The drain
|
|
@@ -1550,6 +1603,8 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1550
1603
|
// author prior turn like alice→bob restores `bob`, not alice.
|
|
1551
1604
|
live.currentTurnAuthorId = live.lastTurnAuthorId
|
|
1552
1605
|
live.currentTurnAuthorIds = new Set(live.lastTurnAuthorIds)
|
|
1606
|
+
} else {
|
|
1607
|
+
live.currentTurnEngageReactions = []
|
|
1553
1608
|
}
|
|
1554
1609
|
|
|
1555
1610
|
// Update the live origin holder so this turn's tool.before events
|
|
@@ -1576,6 +1631,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1576
1631
|
logger.info(`[channels] ${live.keyId} prompting batch=${batch.length} text_len=${text.length}`)
|
|
1577
1632
|
const promptStart = now()
|
|
1578
1633
|
const successfulSendsBeforePrompt = live.successfulChannelSends
|
|
1634
|
+
const engageAddPromises = live.currentTurnEngageReactions
|
|
1579
1635
|
live.turnSeq++
|
|
1580
1636
|
live.successfulSendsAtTurnStart = successfulSendsBeforePrompt
|
|
1581
1637
|
live.policyDeniedToolSendsThisTurn.clear()
|
|
@@ -1590,6 +1646,8 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1590
1646
|
live.consecutiveSends.clear()
|
|
1591
1647
|
live.lastSentText.clear()
|
|
1592
1648
|
} finally {
|
|
1649
|
+
const sentReplyThisTurn = live.successfulChannelSends > successfulSendsBeforePrompt
|
|
1650
|
+
if (sentReplyThisTurn) dropEngageReactionsAfterReply(live, engageAddPromises)
|
|
1593
1651
|
await fireSessionTurnEnd(live)
|
|
1594
1652
|
}
|
|
1595
1653
|
await fireSessionIdle(live)
|
|
@@ -1603,6 +1661,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1603
1661
|
live.currentTurnAuthorId = null
|
|
1604
1662
|
live.currentTurnAuthorIds = new Set()
|
|
1605
1663
|
live.currentTurnReactionRef = null
|
|
1664
|
+
live.currentTurnEngageReactions = []
|
|
1606
1665
|
live.currentTurnAttachments = []
|
|
1607
1666
|
await stopTypingHeartbeat(live)
|
|
1608
1667
|
}
|
|
@@ -1776,9 +1835,10 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1776
1835
|
logger.info(`[channels] ${keyId}: ignoring unknown command /${parsedCommand.name}`)
|
|
1777
1836
|
return
|
|
1778
1837
|
}
|
|
1779
|
-
|
|
1838
|
+
const requiredPermission = commandPermissionString(commandInfo.permission)
|
|
1839
|
+
if (requiredPermission !== null && !permissions.has(inboundAuthorOrigin(event), requiredPermission)) {
|
|
1780
1840
|
logger.info(
|
|
1781
|
-
`[channels] ${keyId}: denied command /${parsedCommand.name} by permissions (
|
|
1841
|
+
`[channels] ${keyId}: denied command /${parsedCommand.name} by permissions (${requiredPermission}) author=${event.authorId}`,
|
|
1782
1842
|
)
|
|
1783
1843
|
return
|
|
1784
1844
|
}
|
|
@@ -1852,11 +1912,11 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1852
1912
|
|
|
1853
1913
|
publishInbound(event, 'engage', live.sessionId)
|
|
1854
1914
|
|
|
1855
|
-
autoReactOnEngage(event)
|
|
1915
|
+
const engageReaction = autoReactOnEngage(event)
|
|
1856
1916
|
|
|
1857
1917
|
updateLoopGuard(live, event)
|
|
1858
1918
|
|
|
1859
|
-
enqueue(live, event)
|
|
1919
|
+
enqueue(live, event, engageReaction)
|
|
1860
1920
|
|
|
1861
1921
|
// Start showing "typing..." the moment we know we're going to engage,
|
|
1862
1922
|
// so users see the indicator during the debounce window — not just
|
|
@@ -1889,8 +1949,22 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1889
1949
|
// operator can grant guest channelRespond for masked stranger turns)
|
|
1890
1950
|
// cannot /stop another speaker's in-flight turn. session.control is
|
|
1891
1951
|
// member-and-up by default.
|
|
1892
|
-
|
|
1893
|
-
|
|
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
|
+
}
|
|
1894
1968
|
|
|
1895
1969
|
const updateLoopGuard = (live: LiveSession, event: InboundMessage): void => {
|
|
1896
1970
|
if (!event.authorIsBot) {
|
|
@@ -1938,7 +2012,11 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1938
2012
|
}
|
|
1939
2013
|
}
|
|
1940
2014
|
|
|
1941
|
-
const enqueue = (
|
|
2015
|
+
const enqueue = (
|
|
2016
|
+
live: LiveSession,
|
|
2017
|
+
event: InboundMessage,
|
|
2018
|
+
engageReaction: Promise<ReactionRef | null> | null,
|
|
2019
|
+
): void => {
|
|
1942
2020
|
live.promptQueue.push({
|
|
1943
2021
|
text: event.text,
|
|
1944
2022
|
...(event.attachments !== undefined && event.attachments.length > 0 ? { attachments: event.attachments } : {}),
|
|
@@ -1947,6 +2025,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1947
2025
|
authorIsBot: event.authorIsBot,
|
|
1948
2026
|
externalMessageId: event.externalMessageId,
|
|
1949
2027
|
...(event.reactionRef !== undefined ? { reactionRef: event.reactionRef } : {}),
|
|
2028
|
+
...(engageReaction !== null ? { engageReaction } : {}),
|
|
1950
2029
|
isBotMention: event.isBotMention,
|
|
1951
2030
|
replyToBotMessageId: event.replyToBotMessageId,
|
|
1952
2031
|
isDm: event.isDm,
|
|
@@ -1977,6 +2056,19 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1977
2056
|
reactionCallbacks.get(adapter)?.delete(cb)
|
|
1978
2057
|
}
|
|
1979
2058
|
|
|
2059
|
+
const registerRemoveReaction = (adapter: ChannelKey['adapter'], cb: RemoveReactionCallback): void => {
|
|
2060
|
+
let set = removeReactionCallbacks.get(adapter)
|
|
2061
|
+
if (!set) {
|
|
2062
|
+
set = new Set()
|
|
2063
|
+
removeReactionCallbacks.set(adapter, set)
|
|
2064
|
+
}
|
|
2065
|
+
set.add(cb)
|
|
2066
|
+
}
|
|
2067
|
+
|
|
2068
|
+
const unregisterRemoveReaction = (adapter: ChannelKey['adapter'], cb: RemoveReactionCallback): void => {
|
|
2069
|
+
removeReactionCallbacks.get(adapter)?.delete(cb)
|
|
2070
|
+
}
|
|
2071
|
+
|
|
1980
2072
|
const react = async (req: ReactionRequest): Promise<ReactionResult> => {
|
|
1981
2073
|
if (req.reactionRef.adapter !== req.adapter) {
|
|
1982
2074
|
return { ok: false, error: 'reaction ref adapter mismatch', code: 'unsupported' }
|
|
@@ -2001,15 +2093,34 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
2001
2093
|
return lastError ?? { ok: false, error: 'no reaction callback handled request', code: 'unsupported' }
|
|
2002
2094
|
}
|
|
2003
2095
|
|
|
2096
|
+
const removeReaction = async (req: RemoveReactionRequest): Promise<ReactionResult> => {
|
|
2097
|
+
if (req.reactionRef.adapter !== req.adapter) {
|
|
2098
|
+
return { ok: false, error: 'reaction ref adapter mismatch', code: 'unsupported' }
|
|
2099
|
+
}
|
|
2100
|
+
const callbacks = removeReactionCallbacks.get(req.adapter)
|
|
2101
|
+
if (!callbacks || callbacks.size === 0) {
|
|
2102
|
+
return { ok: false, error: `adapter "${req.adapter}" does not support reaction removal`, code: 'unsupported' }
|
|
2103
|
+
}
|
|
2104
|
+
let lastError: ReactionResult | undefined
|
|
2105
|
+
for (const cb of Array.from(callbacks)) {
|
|
2106
|
+
const result = await cb(req).catch(
|
|
2107
|
+
(err): ReactionResult => ({ ok: false, error: describe(err), code: 'transient' }),
|
|
2108
|
+
)
|
|
2109
|
+
if (result.ok) return result
|
|
2110
|
+
lastError = result
|
|
2111
|
+
}
|
|
2112
|
+
return lastError ?? { ok: false, error: 'no reaction removal callback handled request', code: 'unsupported' }
|
|
2113
|
+
}
|
|
2114
|
+
|
|
2004
2115
|
// Best-effort acknowledgment: drop an :eyes: on the triggering inbound the
|
|
2005
2116
|
// moment we decide to engage, replacing the old "On it" ack comment on
|
|
2006
2117
|
// GitHub. Fire-and-forget so a reaction failure (missing permission, the
|
|
2007
2118
|
// adapter not supporting reactions, a transient API error) can NEVER block
|
|
2008
2119
|
// engagement, enqueueing, or the agent's actual reply. No reactionRef =
|
|
2009
2120
|
// nothing reactable (synthetic inbounds, reaction-less adapters) = silent skip.
|
|
2010
|
-
const autoReactOnEngage = (event: InboundMessage):
|
|
2011
|
-
if (event.reactionRef === undefined) return
|
|
2012
|
-
|
|
2121
|
+
const autoReactOnEngage = (event: InboundMessage): Promise<ReactionRef | null> | null => {
|
|
2122
|
+
if (event.reactionRef === undefined) return null
|
|
2123
|
+
const addResult = react({
|
|
2013
2124
|
adapter: event.adapter,
|
|
2014
2125
|
workspace: event.workspace,
|
|
2015
2126
|
chat: event.chat,
|
|
@@ -2017,6 +2128,8 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
2017
2128
|
reactionRef: event.reactionRef,
|
|
2018
2129
|
emoji: ENGAGE_REACTION_EMOJI,
|
|
2019
2130
|
})
|
|
2131
|
+
const addReactionRef = addResult.then((r) => (r.ok ? (r.reactionRef ?? null) : null)).catch(() => null)
|
|
2132
|
+
void addResult
|
|
2020
2133
|
.then((result) => {
|
|
2021
2134
|
if (!result.ok && result.code !== 'unsupported') {
|
|
2022
2135
|
logger.info(`[channels] engage-react failed adapter=${event.adapter} chat=${event.chat}: ${result.error}`)
|
|
@@ -2025,6 +2138,37 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
2025
2138
|
.catch((err) => {
|
|
2026
2139
|
logger.info(`[channels] engage-react threw adapter=${event.adapter} chat=${event.chat}: ${describe(err)}`)
|
|
2027
2140
|
})
|
|
2141
|
+
return addReactionRef
|
|
2142
|
+
}
|
|
2143
|
+
|
|
2144
|
+
const dropEngageReactionsAfterReply = (live: LiveSession, addPromises: Array<Promise<ReactionRef | null>>): void => {
|
|
2145
|
+
for (const addPromise of addPromises) dropOneEngageReactionAfterReply(live, addPromise)
|
|
2146
|
+
}
|
|
2147
|
+
|
|
2148
|
+
const dropOneEngageReactionAfterReply = (live: LiveSession, addPromise: Promise<ReactionRef | null>): void => {
|
|
2149
|
+
void addPromise
|
|
2150
|
+
.then((reactionRef) => {
|
|
2151
|
+
if (reactionRef === null) return undefined
|
|
2152
|
+
return removeReaction({
|
|
2153
|
+
adapter: live.key.adapter,
|
|
2154
|
+
workspace: live.key.workspace,
|
|
2155
|
+
chat: live.key.chat,
|
|
2156
|
+
thread: live.key.thread,
|
|
2157
|
+
reactionRef,
|
|
2158
|
+
})
|
|
2159
|
+
})
|
|
2160
|
+
.then((result) => {
|
|
2161
|
+
if (result && !result.ok && result.code !== 'unsupported' && result.code !== 'not-found') {
|
|
2162
|
+
logger.info(
|
|
2163
|
+
`[channels] engage-unreact failed adapter=${live.key.adapter} chat=${live.key.chat}: ${result.error}`,
|
|
2164
|
+
)
|
|
2165
|
+
}
|
|
2166
|
+
})
|
|
2167
|
+
.catch((err) => {
|
|
2168
|
+
logger.info(
|
|
2169
|
+
`[channels] engage-unreact threw adapter=${live.key.adapter} chat=${live.key.chat}: ${describe(err)}`,
|
|
2170
|
+
)
|
|
2171
|
+
})
|
|
2028
2172
|
}
|
|
2029
2173
|
|
|
2030
2174
|
const unregisterOutbound = (adapter: ChannelKey['adapter'], cb: OutboundCallback): void => {
|
|
@@ -2608,14 +2752,17 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
2608
2752
|
if (commandInfo === undefined) {
|
|
2609
2753
|
return { kind: 'unknown-command', name: lowered }
|
|
2610
2754
|
}
|
|
2611
|
-
// Gates on session.control
|
|
2612
|
-
//
|
|
2613
|
-
//
|
|
2614
|
-
//
|
|
2615
|
-
//
|
|
2616
|
-
//
|
|
2617
|
-
//
|
|
2618
|
-
|
|
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) {
|
|
2619
2766
|
const partial: SessionOrigin = {
|
|
2620
2767
|
kind: 'channel',
|
|
2621
2768
|
adapter: key.adapter,
|
|
@@ -2624,7 +2771,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
2624
2771
|
thread: key.thread,
|
|
2625
2772
|
lastInboundAuthorId: options.invokerId,
|
|
2626
2773
|
}
|
|
2627
|
-
if (!permissions.has(partial,
|
|
2774
|
+
if (!permissions.has(partial, requiredPermission)) {
|
|
2628
2775
|
return { kind: 'permission-denied' }
|
|
2629
2776
|
}
|
|
2630
2777
|
}
|
|
@@ -2728,6 +2875,9 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
2728
2875
|
registerReaction,
|
|
2729
2876
|
unregisterReaction,
|
|
2730
2877
|
react,
|
|
2878
|
+
registerRemoveReaction,
|
|
2879
|
+
unregisterRemoveReaction,
|
|
2880
|
+
removeReaction,
|
|
2731
2881
|
registerTyping,
|
|
2732
2882
|
unregisterTyping,
|
|
2733
2883
|
registerChannelNameResolver,
|
|
@@ -2929,14 +3079,19 @@ function composeTurnPrompt(
|
|
|
2929
3079
|
'**Do not acknowledge or reply to this notice.**',
|
|
2930
3080
|
'',
|
|
2931
3081
|
'You are woken on every message from someone you recently talked with, so',
|
|
2932
|
-
'most turns you should stay quiet.
|
|
3082
|
+
'most turns you should stay quiet. In a group the target shifts every',
|
|
3083
|
+
'message: before replying, identify who THIS latest message is aimed at.',
|
|
3084
|
+
'Reply ONLY when:',
|
|
2933
3085
|
'- the current message is addressed to you (by name, @-mention, or reply), or',
|
|
2934
3086
|
'- it directly continues your own last exchange and clearly wants an answer',
|
|
2935
3087
|
' (e.g. a follow-up question about what you just said).',
|
|
2936
3088
|
'',
|
|
2937
|
-
'
|
|
2938
|
-
'
|
|
2939
|
-
'
|
|
3089
|
+
'If it is aimed at someone else — another person by name or @-mention, a',
|
|
3090
|
+
'reply to their message, or another bot — it is not your turn, even if you',
|
|
3091
|
+
'were just talking with its author. Otherwise too — chatter, side-',
|
|
3092
|
+
'conversation, banter, or anything not actually waiting on you — reply with',
|
|
3093
|
+
'`NO_REPLY` (or call `skip_response`) to stay silent and keep watching.',
|
|
3094
|
+
'When unsure, prefer silence.',
|
|
2940
3095
|
'',
|
|
2941
3096
|
'---',
|
|
2942
3097
|
'',
|
package/src/channels/schema.ts
CHANGED
|
@@ -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/channels/types.ts
CHANGED
|
@@ -132,12 +132,27 @@ export type ReactionRequest = {
|
|
|
132
132
|
emoji: string
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
+
export type RemoveReactionRequest = {
|
|
136
|
+
adapter: AdapterId
|
|
137
|
+
workspace: string
|
|
138
|
+
chat: string
|
|
139
|
+
thread?: string | null
|
|
140
|
+
// Per-reaction-instance ref returned by ReactionResult.reactionRef from the
|
|
141
|
+
// add request, not the inbound message target ref used by ReactionRequest.
|
|
142
|
+
reactionRef: ReactionRef
|
|
143
|
+
}
|
|
144
|
+
|
|
135
145
|
export type ReactionErrorCode = 'permission-denied' | 'not-found' | 'unsupported' | 'rate-limited' | 'transient'
|
|
136
146
|
|
|
137
|
-
export type ReactionResult =
|
|
147
|
+
export type ReactionResult =
|
|
148
|
+
// Optional success ref identifies THIS created reaction instance for later
|
|
149
|
+
// removal, not the original message target. Adapters that cannot remove omit it.
|
|
150
|
+
{ ok: true; reactionRef?: ReactionRef } | { ok: false; error: string; code?: ReactionErrorCode }
|
|
138
151
|
|
|
139
152
|
export type ReactionCallback = (req: ReactionRequest) => Promise<ReactionResult>
|
|
140
153
|
|
|
154
|
+
export type RemoveReactionCallback = (req: RemoveReactionRequest) => Promise<ReactionResult>
|
|
155
|
+
|
|
141
156
|
// File on disk that the agent wants to attach to an outbound message. The
|
|
142
157
|
// agent runs inside a container with /agent bind-mounted from the host;
|
|
143
158
|
// `path` should be an absolute path the container can `readFile`. The
|
package/src/cli/builtins.ts
CHANGED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { defineCommand } from 'citty'
|
|
2
|
+
|
|
3
|
+
import { type DreamEntry, renderListRow, runDreams, type ViewAction } from '@/dreams'
|
|
4
|
+
import { findAgentDir } from '@/init'
|
|
5
|
+
|
|
6
|
+
import { createEscController } from './inspect-controller'
|
|
7
|
+
import { c, cancel, errorLine, isCancel, prepareStdinForClack } from './ui'
|
|
8
|
+
|
|
9
|
+
const ESC_DEBOUNCE_MS = 50
|
|
10
|
+
const QUIT_KEY = 0x71
|
|
11
|
+
|
|
12
|
+
export const dreamsCommand = defineCommand({
|
|
13
|
+
meta: {
|
|
14
|
+
name: 'dreams',
|
|
15
|
+
description: "browse the dreaming subagent's memory-consolidation journal from git history (host stage)",
|
|
16
|
+
},
|
|
17
|
+
args: {
|
|
18
|
+
limit: {
|
|
19
|
+
type: 'string',
|
|
20
|
+
description: 'show at most N most-recent dreams',
|
|
21
|
+
},
|
|
22
|
+
json: {
|
|
23
|
+
type: 'boolean',
|
|
24
|
+
description: 'emit one JSON object per dream (subject-level)',
|
|
25
|
+
default: false,
|
|
26
|
+
},
|
|
27
|
+
details: {
|
|
28
|
+
type: 'boolean',
|
|
29
|
+
description: 'with --json, hydrate each dream with its consolidated fragments/shards/skills',
|
|
30
|
+
default: false,
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
async run({ args }) {
|
|
34
|
+
const cwd = findAgentDir(process.cwd()) ?? process.cwd()
|
|
35
|
+
const color = useColor()
|
|
36
|
+
const limit = parseLimit(args.limit)
|
|
37
|
+
const interactive = isInteractive() && args.json !== true
|
|
38
|
+
|
|
39
|
+
const result = await runDreams({
|
|
40
|
+
agentDir: cwd,
|
|
41
|
+
json: args.json === true,
|
|
42
|
+
details: args.details === true,
|
|
43
|
+
color,
|
|
44
|
+
...(limit !== undefined ? { limit } : {}),
|
|
45
|
+
selectDream: (entries, selectOpts) => clackSelect(entries, color, selectOpts?.initialSha),
|
|
46
|
+
...(interactive ? { viewDream: () => waitForViewerKey(color) } : {}),
|
|
47
|
+
stdout: (line) => process.stdout.write(`${line}\n`),
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
if (!result.ok) {
|
|
51
|
+
process.stderr.write(`${errorLine(result.reason)}\n`)
|
|
52
|
+
process.exit(result.exitCode)
|
|
53
|
+
}
|
|
54
|
+
process.exit(result.exitCode)
|
|
55
|
+
},
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
function isInteractive(): boolean {
|
|
59
|
+
return Boolean(process.stdout.isTTY) && Boolean(process.stdin.isTTY)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
type RawInput = Pick<NodeJS.ReadStream, 'isTTY' | 'setRawMode' | 'resume' | 'pause' | 'on' | 'off'>
|
|
63
|
+
|
|
64
|
+
// Esc routes through createEscController so a standalone Esc returns 'back'
|
|
65
|
+
// while a multi-byte CSI sequence (↑/↓ arrows) does not. Teardown restores
|
|
66
|
+
// raw mode but deliberately does NOT pause stdin: clack cannot re-flow a
|
|
67
|
+
// paused process.stdin under Bun, so the next picker would freeze — the same
|
|
68
|
+
// reason cli/inspect.ts leaves the stream flowing on its return path.
|
|
69
|
+
export async function waitForViewerKey(color: boolean, input: RawInput = process.stdin): Promise<ViewAction> {
|
|
70
|
+
const stdin = input
|
|
71
|
+
if (!stdin.isTTY || typeof stdin.setRawMode !== 'function') return 'exit'
|
|
72
|
+
|
|
73
|
+
process.stdout.write(`${viewerHintLine(color)}\n`)
|
|
74
|
+
|
|
75
|
+
const ctrl = createEscController({ debounceMs: ESC_DEBOUNCE_MS })
|
|
76
|
+
const escSignal = ctrl.armForStream()
|
|
77
|
+
|
|
78
|
+
return new Promise<ViewAction>((resolve) => {
|
|
79
|
+
let settled = false
|
|
80
|
+
const finish = (action: ViewAction): void => {
|
|
81
|
+
if (settled) return
|
|
82
|
+
settled = true
|
|
83
|
+
escSignal.removeEventListener('abort', onEscAbort)
|
|
84
|
+
stdin.off('data', onData)
|
|
85
|
+
ctrl.dispose()
|
|
86
|
+
try {
|
|
87
|
+
stdin.setRawMode(false)
|
|
88
|
+
} catch {
|
|
89
|
+
/* terminal already torn down */
|
|
90
|
+
}
|
|
91
|
+
resolve(action)
|
|
92
|
+
}
|
|
93
|
+
const onEscAbort = (): void => finish('back')
|
|
94
|
+
const onData = (chunk: Buffer): void => {
|
|
95
|
+
if (chunk[0] === QUIT_KEY) {
|
|
96
|
+
finish('exit')
|
|
97
|
+
return
|
|
98
|
+
}
|
|
99
|
+
const { sigint } = ctrl.onChunk(chunk)
|
|
100
|
+
if (sigint) finish('exit')
|
|
101
|
+
}
|
|
102
|
+
escSignal.addEventListener('abort', onEscAbort, { once: true })
|
|
103
|
+
stdin.setRawMode(true)
|
|
104
|
+
stdin.resume()
|
|
105
|
+
stdin.on('data', onData)
|
|
106
|
+
})
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function viewerHintLine(color: boolean): string {
|
|
110
|
+
const text = '(esc to go back to the list · q to quit)'
|
|
111
|
+
return color ? c.dim(text) : text
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function parseLimit(raw: unknown): number | undefined {
|
|
115
|
+
if (typeof raw !== 'string') return undefined
|
|
116
|
+
const n = Number.parseInt(raw, 10)
|
|
117
|
+
return Number.isFinite(n) && n > 0 ? n : undefined
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function clackSelect(
|
|
121
|
+
entries: DreamEntry[],
|
|
122
|
+
color: boolean,
|
|
123
|
+
initialSha: string | undefined,
|
|
124
|
+
): Promise<DreamEntry | null> {
|
|
125
|
+
const { select } = await import('@clack/prompts')
|
|
126
|
+
prepareStdinForClack()
|
|
127
|
+
const preferred = initialSha !== undefined && entries.some((e) => e.sha === initialSha) ? initialSha : entries[0]?.sha
|
|
128
|
+
const picked = await select<string>({
|
|
129
|
+
message: `Pick a dream to open (${entries.length} total)`,
|
|
130
|
+
options: entries.map((entry) => ({
|
|
131
|
+
value: entry.sha,
|
|
132
|
+
label: renderListRow(entry, { color }),
|
|
133
|
+
})),
|
|
134
|
+
initialValue: preferred,
|
|
135
|
+
})
|
|
136
|
+
if (isCancel(picked)) {
|
|
137
|
+
cancel('Cancelled.')
|
|
138
|
+
return null
|
|
139
|
+
}
|
|
140
|
+
return entries.find((entry) => entry.sha === picked) ?? null
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function useColor(): boolean {
|
|
144
|
+
if (process.env.NO_COLOR !== undefined && process.env.NO_COLOR !== '') return false
|
|
145
|
+
if (process.env.FORCE_COLOR === '0') return false
|
|
146
|
+
if (process.env.FORCE_COLOR) return true
|
|
147
|
+
return Boolean(process.stdout.isTTY)
|
|
148
|
+
}
|
package/src/cli/index.ts
CHANGED
|
@@ -23,6 +23,7 @@ const main = defineCommand({
|
|
|
23
23
|
reload: () => import('./reload').then((m) => m.reload),
|
|
24
24
|
logs: () => import('./logs').then((m) => m.logsCommand),
|
|
25
25
|
inspect: () => import('./inspect').then((m) => m.inspectCommand),
|
|
26
|
+
dreams: () => import('./dreams').then((m) => m.dreamsCommand),
|
|
26
27
|
shell: () => import('./shell').then((m) => m.shellCommand),
|
|
27
28
|
compose: () => import('./compose').then((m) => m.composeCommand),
|
|
28
29
|
channel: () => import('./channel').then((m) => m.channelCommand),
|
package/src/cli/inspect.ts
CHANGED
|
@@ -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
|