typeclaw 0.21.0 → 0.23.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/session-origin.ts +41 -2
- 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 +34 -12
- package/src/bundled-plugins/reviewer/skills/code-review.ts +8 -0
- package/src/channels/adapters/discord-bot.ts +8 -0
- package/src/channels/adapters/github/inbound.ts +23 -1
- package/src/channels/adapters/github/index.ts +9 -0
- package/src/channels/adapters/slack-bot.ts +112 -5
- package/src/channels/adapters/telegram-bot.ts +11 -0
- package/src/channels/manager.ts +8 -0
- package/src/channels/router.ts +100 -15
- package/src/channels/schema.ts +18 -0
- package/src/channels/types.ts +27 -0
- package/src/cli/dreams.ts +2 -1
- package/src/cli/inspect-controller.ts +92 -0
- package/src/cli/inspect.ts +21 -123
- package/src/cli/ui.ts +34 -0
- package/src/commands/index.ts +5 -2
- package/src/config/config.ts +89 -0
- package/src/inspect/index.ts +8 -26
- package/src/inspect/live.ts +17 -3
- package/src/inspect/loop.ts +23 -17
- 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 +4 -0
- package/src/skills/typeclaw-channel-github/SKILL.md +83 -13
- package/src/skills/typeclaw-config/SKILL.md +1 -1
- package/src/skills/typeclaw-git/SKILL.md +1 -1
- 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'
|
|
@@ -43,6 +43,8 @@ import type {
|
|
|
43
43
|
ChannelHistoryMessage,
|
|
44
44
|
ChannelKey,
|
|
45
45
|
ChannelNameResolver,
|
|
46
|
+
ChannelSelfIdentity,
|
|
47
|
+
ChannelSelfIdentityResolver,
|
|
46
48
|
FetchAttachmentArgs,
|
|
47
49
|
FetchAttachmentCallback,
|
|
48
50
|
FetchAttachmentResult,
|
|
@@ -553,6 +555,13 @@ export type ChannelRouter = {
|
|
|
553
555
|
unregisterTyping: (adapter: ChannelKey['adapter'], cb: TypingCallback) => void
|
|
554
556
|
registerChannelNameResolver: (adapter: ChannelKey['adapter'], resolver: ChannelNameResolver) => void
|
|
555
557
|
unregisterChannelNameResolver: (adapter: ChannelKey['adapter'], resolver: ChannelNameResolver) => void
|
|
558
|
+
// Self-identity is a per-adapter singleton (one bot account per adapter),
|
|
559
|
+
// so unlike the multi-resolver registries above this is last-write-wins:
|
|
560
|
+
// register overwrites, unregister clears only if the current resolver is
|
|
561
|
+
// the one being removed (guards against a late stop() of a replaced adapter
|
|
562
|
+
// wiping a fresh registration).
|
|
563
|
+
registerSelfIdentity: (adapter: ChannelKey['adapter'], resolver: ChannelSelfIdentityResolver) => void
|
|
564
|
+
unregisterSelfIdentity: (adapter: ChannelKey['adapter'], resolver: ChannelSelfIdentityResolver) => void
|
|
556
565
|
registerMembership: (adapter: ChannelKey['adapter'], resolver: MembershipResolver) => void
|
|
557
566
|
unregisterMembership: (adapter: ChannelKey['adapter'], resolver: MembershipResolver) => void
|
|
558
567
|
registerHistory: (adapter: ChannelKey['adapter'], cb: HistoryCallback) => void
|
|
@@ -720,6 +729,17 @@ export type CreateChannelRouterOptions = {
|
|
|
720
729
|
// can diagnose silent drops from `typeclaw inspect` alone. Omitted in
|
|
721
730
|
// tests that don't care about inspect surfacing.
|
|
722
731
|
stream?: Stream
|
|
732
|
+
// Operate-the-agent command handlers. When set, the router registers the
|
|
733
|
+
// matching channel command (/reload, /restart) gated on session.admin
|
|
734
|
+
// (owner+trusted). Omitted means the command is not registered at all — it
|
|
735
|
+
// won't appear in /help and a text-prefix or native-slash invocation is
|
|
736
|
+
// treated as unknown. Production wiring (src/run/index.ts via the channel
|
|
737
|
+
// manager) supplies both; tests opt in per-case. `onReload` returns a short
|
|
738
|
+
// human-readable summary posted back to the channel; `onRestart` returns a
|
|
739
|
+
// confirmation string (the container exits shortly after, so the reply is
|
|
740
|
+
// best-effort).
|
|
741
|
+
onReload?: () => Promise<string>
|
|
742
|
+
onRestart?: () => Promise<string>
|
|
723
743
|
}
|
|
724
744
|
|
|
725
745
|
export type ClaimHandlerInput = {
|
|
@@ -756,6 +776,8 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
756
776
|
const permissions = options.permissions ?? GRANT_ALL_PERMISSIONS
|
|
757
777
|
const claimHandler = options.claimHandler
|
|
758
778
|
const stream = options.stream
|
|
779
|
+
const onReload = options.onReload
|
|
780
|
+
const onRestart = options.onRestart
|
|
759
781
|
const liveSessions = new Map<string, LiveSession>()
|
|
760
782
|
const creating = new Map<string, Promise<LiveSession>>()
|
|
761
783
|
// Bumped by tearDownAllLive() and stop() before they tear sessions down. An
|
|
@@ -772,6 +794,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
772
794
|
const typingCallbacks = new Map<ChannelKey['adapter'], Set<TypingCallback>>()
|
|
773
795
|
const channelNameResolvers = new Map<ChannelKey['adapter'], Set<ChannelNameResolver>>()
|
|
774
796
|
const membershipResolvers = new Map<ChannelKey['adapter'], Set<MembershipResolver>>()
|
|
797
|
+
const selfIdentityResolvers = new Map<ChannelKey['adapter'], ChannelSelfIdentityResolver>()
|
|
775
798
|
const membershipCaches = new Map<ChannelKey['adapter'], MembershipCache>()
|
|
776
799
|
const historyCallbacks = new Map<ChannelKey['adapter'], Set<HistoryCallback>>()
|
|
777
800
|
const fetchAttachmentCallbacks = new Map<ChannelKey['adapter'], Set<FetchAttachmentCallback>>()
|
|
@@ -779,7 +802,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
779
802
|
// The /help handler reads the live registry to enumerate commands, so it
|
|
780
803
|
// forward-references `commands`. Safe at runtime — the handler only runs on
|
|
781
804
|
// invocation, long after the assignment below completes.
|
|
782
|
-
const channelCommands:
|
|
805
|
+
const channelCommands: Command<ChannelCommandContext>[] = [
|
|
783
806
|
{
|
|
784
807
|
name: 'help',
|
|
785
808
|
description: 'List available commands.',
|
|
@@ -800,6 +823,28 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
800
823
|
},
|
|
801
824
|
},
|
|
802
825
|
]
|
|
826
|
+
// /reload and /restart are registered only when the operate-the-agent
|
|
827
|
+
// callbacks are wired (production via the channel manager). Without them the
|
|
828
|
+
// capability doesn't exist for this router, so the commands stay absent from
|
|
829
|
+
// /help and resolve as unknown — never a silent no-op.
|
|
830
|
+
if (onReload !== undefined) {
|
|
831
|
+
channelCommands.push({
|
|
832
|
+
name: 'reload',
|
|
833
|
+
description: 'Reload typeclaw config and subsystems from disk.',
|
|
834
|
+
permission: 'session.admin',
|
|
835
|
+
requiresLiveSession: false,
|
|
836
|
+
handler: async () => ({ reply: await onReload() }),
|
|
837
|
+
})
|
|
838
|
+
}
|
|
839
|
+
if (onRestart !== undefined) {
|
|
840
|
+
channelCommands.push({
|
|
841
|
+
name: 'restart',
|
|
842
|
+
description: 'Restart the typeclaw container.',
|
|
843
|
+
permission: 'session.admin',
|
|
844
|
+
requiresLiveSession: false,
|
|
845
|
+
handler: async () => ({ reply: await onRestart() }),
|
|
846
|
+
})
|
|
847
|
+
}
|
|
803
848
|
const commands = createCommandRegistry<ChannelCommandContext>(channelCommands)
|
|
804
849
|
|
|
805
850
|
// Implicit dir-name alias: agent folder basename matches Docker
|
|
@@ -1053,6 +1098,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1053
1098
|
// channel.respond gate just admitted on. Per-turn updates after this
|
|
1054
1099
|
// point are handled by `originRef.current = buildLiveOrigin(live)`
|
|
1055
1100
|
// before each prompt() call.
|
|
1101
|
+
const self = resolveSelfIdentity(key)
|
|
1056
1102
|
const origin: SessionOrigin = {
|
|
1057
1103
|
kind: 'channel',
|
|
1058
1104
|
adapter: key.adapter,
|
|
@@ -1064,6 +1110,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1064
1110
|
...(triggeringAuthorId !== undefined ? { lastInboundAuthorId: triggeringAuthorId } : {}),
|
|
1065
1111
|
participants,
|
|
1066
1112
|
...(membership !== null ? { membership } : {}),
|
|
1113
|
+
...(self !== undefined ? { self } : {}),
|
|
1067
1114
|
}
|
|
1068
1115
|
|
|
1069
1116
|
const isColdStart = resolvedRecord?.sessionId === undefined
|
|
@@ -1487,6 +1534,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1487
1534
|
|
|
1488
1535
|
const buildLiveOrigin = (live: LiveSession): SessionOrigin => {
|
|
1489
1536
|
const membership = readMembership(live.key)
|
|
1537
|
+
const self = resolveSelfIdentity(live.key)
|
|
1490
1538
|
return {
|
|
1491
1539
|
kind: 'channel',
|
|
1492
1540
|
adapter: live.key.adapter,
|
|
@@ -1499,6 +1547,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1499
1547
|
...(live.currentTurnReactionRef !== null ? { reactionRef: live.currentTurnReactionRef } : {}),
|
|
1500
1548
|
participants: live.participants,
|
|
1501
1549
|
...(membership !== null ? { membership } : {}),
|
|
1550
|
+
...(self !== undefined ? { self } : {}),
|
|
1502
1551
|
}
|
|
1503
1552
|
}
|
|
1504
1553
|
|
|
@@ -1800,9 +1849,10 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1800
1849
|
logger.info(`[channels] ${keyId}: ignoring unknown command /${parsedCommand.name}`)
|
|
1801
1850
|
return
|
|
1802
1851
|
}
|
|
1803
|
-
|
|
1852
|
+
const requiredPermission = commandPermissionString(commandInfo.permission)
|
|
1853
|
+
if (requiredPermission !== null && !permissions.has(inboundAuthorOrigin(event), requiredPermission)) {
|
|
1804
1854
|
logger.info(
|
|
1805
|
-
`[channels] ${keyId}: denied command /${parsedCommand.name} by permissions (
|
|
1855
|
+
`[channels] ${keyId}: denied command /${parsedCommand.name} by permissions (${requiredPermission}) author=${event.authorId}`,
|
|
1806
1856
|
)
|
|
1807
1857
|
return
|
|
1808
1858
|
}
|
|
@@ -1913,8 +1963,22 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
1913
1963
|
// operator can grant guest channelRespond for masked stranger turns)
|
|
1914
1964
|
// cannot /stop another speaker's in-flight turn. session.control is
|
|
1915
1965
|
// member-and-up by default.
|
|
1916
|
-
|
|
1917
|
-
|
|
1966
|
+
// Maps a command's declared permission tier to the concrete permission
|
|
1967
|
+
// string gated on both the text-prefix path (route) and the native-slash
|
|
1968
|
+
// path (executeCommand). 'none' is never gated. session.admin (owner+trusted,
|
|
1969
|
+
// not member) covers /reload and /restart, which mutate global agent state
|
|
1970
|
+
// and drop every in-flight session. Centralized so a new tier can't be
|
|
1971
|
+
// honored on one path and silently skipped on the other.
|
|
1972
|
+
const commandPermissionString = (permission: CommandPermission): string | null => {
|
|
1973
|
+
switch (permission) {
|
|
1974
|
+
case 'none':
|
|
1975
|
+
return null
|
|
1976
|
+
case 'session.control':
|
|
1977
|
+
return CORE_PERMISSIONS.sessionControl
|
|
1978
|
+
case 'session.admin':
|
|
1979
|
+
return CORE_PERMISSIONS.sessionAdmin
|
|
1980
|
+
}
|
|
1981
|
+
}
|
|
1918
1982
|
|
|
1919
1983
|
const updateLoopGuard = (live: LiveSession, event: InboundMessage): void => {
|
|
1920
1984
|
if (!event.authorIsBot) {
|
|
@@ -2151,6 +2215,22 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
2151
2215
|
channelNameResolvers.get(adapter)?.delete(resolver)
|
|
2152
2216
|
}
|
|
2153
2217
|
|
|
2218
|
+
const registerSelfIdentity = (adapter: ChannelKey['adapter'], resolver: ChannelSelfIdentityResolver): void => {
|
|
2219
|
+
selfIdentityResolvers.set(adapter, resolver)
|
|
2220
|
+
}
|
|
2221
|
+
|
|
2222
|
+
const unregisterSelfIdentity = (adapter: ChannelKey['adapter'], resolver: ChannelSelfIdentityResolver): void => {
|
|
2223
|
+
if (selfIdentityResolvers.get(adapter) === resolver) {
|
|
2224
|
+
selfIdentityResolvers.delete(adapter)
|
|
2225
|
+
}
|
|
2226
|
+
}
|
|
2227
|
+
|
|
2228
|
+
const resolveSelfIdentity = (key: ChannelKey): ChannelSelfIdentity | undefined => {
|
|
2229
|
+
const resolver = selfIdentityResolvers.get(key.adapter)
|
|
2230
|
+
if (resolver === undefined) return undefined
|
|
2231
|
+
return resolver(key.workspace) ?? undefined
|
|
2232
|
+
}
|
|
2233
|
+
|
|
2154
2234
|
const registerMembership = (adapter: ChannelKey['adapter'], resolver: MembershipResolver): void => {
|
|
2155
2235
|
let set = membershipResolvers.get(adapter)
|
|
2156
2236
|
if (!set) {
|
|
@@ -2702,14 +2782,17 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
2702
2782
|
if (commandInfo === undefined) {
|
|
2703
2783
|
return { kind: 'unknown-command', name: lowered }
|
|
2704
2784
|
}
|
|
2705
|
-
// Gates on session.control
|
|
2706
|
-
//
|
|
2707
|
-
//
|
|
2708
|
-
//
|
|
2709
|
-
//
|
|
2710
|
-
//
|
|
2711
|
-
//
|
|
2712
|
-
|
|
2785
|
+
// Gates on the command's declared tier (session.control for /stop,
|
|
2786
|
+
// session.admin for /reload and /restart) — never channel.respond — so a
|
|
2787
|
+
// respond-capable guest cannot abort another speaker's turn or bounce the
|
|
2788
|
+
// container. Runs BEFORE the live-session lookup so an unauthorized invoker
|
|
2789
|
+
// gets 'permission-denied' regardless of session state, rather than leaking
|
|
2790
|
+
// session presence via the 'no-live-session' vs 'permission-denied'
|
|
2791
|
+
// distinction. Session-less informational commands (e.g. /help) declare
|
|
2792
|
+
// permission:'none' and skip both the gate and the lookup so they work in
|
|
2793
|
+
// channels with no live turn.
|
|
2794
|
+
const requiredPermission = commandPermissionString(commandInfo.permission)
|
|
2795
|
+
if (requiredPermission !== null) {
|
|
2713
2796
|
const partial: SessionOrigin = {
|
|
2714
2797
|
kind: 'channel',
|
|
2715
2798
|
adapter: key.adapter,
|
|
@@ -2718,7 +2801,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
2718
2801
|
thread: key.thread,
|
|
2719
2802
|
lastInboundAuthorId: options.invokerId,
|
|
2720
2803
|
}
|
|
2721
|
-
if (!permissions.has(partial,
|
|
2804
|
+
if (!permissions.has(partial, requiredPermission)) {
|
|
2722
2805
|
return { kind: 'permission-denied' }
|
|
2723
2806
|
}
|
|
2724
2807
|
}
|
|
@@ -2829,6 +2912,8 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
|
|
|
2829
2912
|
unregisterTyping,
|
|
2830
2913
|
registerChannelNameResolver,
|
|
2831
2914
|
unregisterChannelNameResolver,
|
|
2915
|
+
registerSelfIdentity,
|
|
2916
|
+
unregisterSelfIdentity,
|
|
2832
2917
|
registerMembership,
|
|
2833
2918
|
unregisterMembership,
|
|
2834
2919
|
registerHistory,
|
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
|
@@ -243,6 +243,33 @@ export type ResolvedChannelNames = {
|
|
|
243
243
|
|
|
244
244
|
export type ChannelNameResolver = (key: ChannelKey) => Promise<ResolvedChannelNames>
|
|
245
245
|
|
|
246
|
+
// The bot's OWN identity on a platform, surfaced into the channel system
|
|
247
|
+
// prompt so the model recognizes mentions of itself. The engagement gate
|
|
248
|
+
// already knows this id (it sets `isBotMention`), but the model only knows
|
|
249
|
+
// its NAME (from identity files) — not its platform user id. Without this,
|
|
250
|
+
// a message addressed to `<@U0ABFG8TYN7>` (the bot's own Slack id) reads to
|
|
251
|
+
// the model as "addressed to someone else" and it skips a turn it was
|
|
252
|
+
// correctly engaged for.
|
|
253
|
+
//
|
|
254
|
+
// - `id` is the raw platform user id (Slack `U…`, Discord snowflake,
|
|
255
|
+
// Telegram numeric id as string, GitHub numeric id as string). For
|
|
256
|
+
// angle-id platforms this is what appears inside `<@…>`.
|
|
257
|
+
// - `username` is the human-typed handle used for at-mentions on platforms
|
|
258
|
+
// where the id is NOT what gets typed (Telegram `@username`, GitHub
|
|
259
|
+
// `@login`). Omitted when the platform mentions by id, or when the
|
|
260
|
+
// account simply has no username.
|
|
261
|
+
export type ChannelSelfIdentity = {
|
|
262
|
+
id: string
|
|
263
|
+
username?: string
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Resolves the bot's own identity for a given workspace. `workspace` is
|
|
267
|
+
// passed because identity is conceptually per-workspace (Slack team); most
|
|
268
|
+
// adapters serve a single identity and ignore the argument. Returns null
|
|
269
|
+
// when identity is not yet resolved (startup race) or unknown — callers
|
|
270
|
+
// MUST treat null as "omit the self-mention prompt line", never as an error.
|
|
271
|
+
export type ChannelSelfIdentityResolver = (workspace: string) => ChannelSelfIdentity | null
|
|
272
|
+
|
|
246
273
|
// History entries are intentionally distinct from InboundMessage:
|
|
247
274
|
// `InboundMessage` carries router-classification fields (`isBotMention`,
|
|
248
275
|
// `isDm`) that are turn-delivery concerns, not history concerns. History
|
package/src/cli/dreams.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { type DreamEntry, renderListRow, runDreams, type ViewAction } from '@/dr
|
|
|
4
4
|
import { findAgentDir } from '@/init'
|
|
5
5
|
|
|
6
6
|
import { createEscController } from './inspect-controller'
|
|
7
|
-
import { c, cancel, errorLine, isCancel } from './ui'
|
|
7
|
+
import { c, cancel, errorLine, isCancel, prepareStdinForClack } from './ui'
|
|
8
8
|
|
|
9
9
|
const ESC_DEBOUNCE_MS = 50
|
|
10
10
|
const QUIT_KEY = 0x71
|
|
@@ -123,6 +123,7 @@ async function clackSelect(
|
|
|
123
123
|
initialSha: string | undefined,
|
|
124
124
|
): Promise<DreamEntry | null> {
|
|
125
125
|
const { select } = await import('@clack/prompts')
|
|
126
|
+
prepareStdinForClack()
|
|
126
127
|
const preferred = initialSha !== undefined && entries.some((e) => e.sha === initialSha) ? initialSha : entries[0]?.sha
|
|
127
128
|
const picked = await select<string>({
|
|
128
129
|
message: `Pick a dream to open (${entries.length} total)`,
|
|
@@ -64,3 +64,95 @@ export function createEscController({ debounceMs }: { debounceMs: number }): Esc
|
|
|
64
64
|
},
|
|
65
65
|
}
|
|
66
66
|
}
|
|
67
|
+
|
|
68
|
+
export type TailIntent = 'back' | 'exit'
|
|
69
|
+
|
|
70
|
+
export type TailScope = {
|
|
71
|
+
signal: AbortSignal
|
|
72
|
+
// null when the tail ended on its own (stream closed / replay-only); the loop
|
|
73
|
+
// treats null the same as 'back'. The stream only ever sees signal.aborted —
|
|
74
|
+
// intent is read by the loop, keeping abort decoupled from what abort meant.
|
|
75
|
+
intent: () => TailIntent | null
|
|
76
|
+
dispose: () => void
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
type RawInput = Pick<NodeJS.ReadStream, 'isTTY' | 'setRawMode' | 'resume' | 'on' | 'off'>
|
|
80
|
+
|
|
81
|
+
type ProcessSignals = Pick<NodeJS.Process, 'once' | 'off'>
|
|
82
|
+
|
|
83
|
+
// One disposable interaction scope per live-tail iteration. Creates a FRESH
|
|
84
|
+
// AbortController, installs a temporary raw-mode 'data' listener plus
|
|
85
|
+
// SIGINT/SIGTERM handlers, and tears all of it down on dispose(). This mirrors
|
|
86
|
+
// the `dreams` viewer-key pattern: raw mode is scoped to a single tail attempt
|
|
87
|
+
// and never survives into the clack picker, which removes the pause/resume
|
|
88
|
+
// state machine that made the old inspect listener fragile.
|
|
89
|
+
export function createTailScope(opts: { debounceMs: number; input?: RawInput; proc?: ProcessSignals }): TailScope {
|
|
90
|
+
const stdin = opts.input ?? process.stdin
|
|
91
|
+
const proc = opts.proc ?? process
|
|
92
|
+
const controller = new AbortController()
|
|
93
|
+
let intent: TailIntent | null = null
|
|
94
|
+
let disposed = false
|
|
95
|
+
|
|
96
|
+
const settle = (next: TailIntent): void => {
|
|
97
|
+
if (intent === null) intent = next
|
|
98
|
+
controller.abort()
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const onSigExit = (): void => {
|
|
102
|
+
settle('exit')
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const isTty = Boolean(stdin.isTTY) && typeof stdin.setRawMode === 'function'
|
|
106
|
+
const esc = isTty ? createEscController({ debounceMs: opts.debounceMs }) : null
|
|
107
|
+
const escSignal = esc?.armForStream()
|
|
108
|
+
// A bare ESC fires through the debounce controller, not the 'data' handler:
|
|
109
|
+
// route its abort into 'back' intent here so the loop can re-open the picker.
|
|
110
|
+
const onEscAbort = (): void => settle('back')
|
|
111
|
+
|
|
112
|
+
const onData = (chunk: Buffer): void => {
|
|
113
|
+
if (esc === null) return
|
|
114
|
+
const { sigint } = esc.onChunk(chunk)
|
|
115
|
+
if (sigint) settle('exit')
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const dispose = (): void => {
|
|
119
|
+
if (disposed) return
|
|
120
|
+
disposed = true
|
|
121
|
+
proc.off('SIGINT', onSigExit)
|
|
122
|
+
proc.off('SIGTERM', onSigExit)
|
|
123
|
+
escSignal?.removeEventListener('abort', onEscAbort)
|
|
124
|
+
if (esc !== null) {
|
|
125
|
+
stdin.off('data', onData)
|
|
126
|
+
esc.dispose()
|
|
127
|
+
try {
|
|
128
|
+
stdin.setRawMode(false)
|
|
129
|
+
} catch {
|
|
130
|
+
/* terminal already torn down */
|
|
131
|
+
}
|
|
132
|
+
// Deliberately NOT stdin.pause(): a paused process.stdin does not reliably
|
|
133
|
+
// re-flow into the next clack picker under Bun (same reason as
|
|
134
|
+
// prepareStdinForClack / dreams' waitForViewerKey). Leave it flowing.
|
|
135
|
+
}
|
|
136
|
+
// Abort last so a stream still awaiting on this signal unblocks during
|
|
137
|
+
// teardown rather than hanging.
|
|
138
|
+
controller.abort()
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
proc.once('SIGINT', onSigExit)
|
|
142
|
+
proc.once('SIGTERM', onSigExit)
|
|
143
|
+
|
|
144
|
+
if (esc !== null && escSignal !== undefined) {
|
|
145
|
+
escSignal.addEventListener('abort', onEscAbort, { once: true })
|
|
146
|
+
stdin.setRawMode(true)
|
|
147
|
+
// Attach the data handler before resume() so no raw-mode keystroke slips
|
|
148
|
+
// through between resuming the stream and registering the listener.
|
|
149
|
+
stdin.on('data', onData)
|
|
150
|
+
stdin.resume()
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
signal: controller.signal,
|
|
155
|
+
intent: () => intent,
|
|
156
|
+
dispose,
|
|
157
|
+
}
|
|
158
|
+
}
|
package/src/cli/inspect.ts
CHANGED
|
@@ -5,10 +5,10 @@ import { findAgentDir } from '@/init'
|
|
|
5
5
|
import { runInspectLoop, streamLive, type LiveSourceFactory, type SessionSummary } from '@/inspect'
|
|
6
6
|
import { originLabel, shortSessionId } from '@/inspect/label'
|
|
7
7
|
|
|
8
|
-
import {
|
|
9
|
-
import { cancel, c, errorLine, isCancel } from './ui'
|
|
8
|
+
import { createTailScope } from './inspect-controller'
|
|
9
|
+
import { cancel, c, errorLine, isCancel, prepareStdinForClack } from './ui'
|
|
10
10
|
|
|
11
|
-
const
|
|
11
|
+
const ESC_DEBOUNCE_MS = 50
|
|
12
12
|
|
|
13
13
|
export const inspectCommand = defineCommand({
|
|
14
14
|
meta: {
|
|
@@ -45,46 +45,23 @@ export const inspectCommand = defineCommand({
|
|
|
45
45
|
|
|
46
46
|
const isJson = args.json === true
|
|
47
47
|
const liveSource = isJson ? undefined : await buildLiveSource(cwd)
|
|
48
|
-
const
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
color,
|
|
66
|
-
selectSession: (sessions, selectOpts) => {
|
|
67
|
-
escListener?.pause()
|
|
68
|
-
return clackSelect(sessions, selectOpts?.initialSessionId).finally(() => {
|
|
69
|
-
escListener?.resume()
|
|
70
|
-
})
|
|
71
|
-
},
|
|
72
|
-
...(liveSource !== undefined ? { liveSource } : {}),
|
|
73
|
-
signal,
|
|
74
|
-
newEscSignal: () => {
|
|
75
|
-
if (escListener === null) return new AbortController().signal
|
|
76
|
-
return escListener.armForStream()
|
|
77
|
-
},
|
|
78
|
-
afterEscStream: () => {
|
|
79
|
-
escListener?.pause()
|
|
80
|
-
},
|
|
81
|
-
...(liveHint !== undefined ? { liveHint } : {}),
|
|
82
|
-
stdout: (line) => process.stdout.write(`${line}\n`),
|
|
83
|
-
stderr: (line) => process.stderr.write(`${line}\n`),
|
|
84
|
-
})
|
|
85
|
-
} finally {
|
|
86
|
-
escListener?.stop()
|
|
87
|
-
}
|
|
48
|
+
const interactive = !isJson && Boolean(process.stdin.isTTY)
|
|
49
|
+
const liveHint = interactive ? escHintLine(color) : undefined
|
|
50
|
+
|
|
51
|
+
const result = await runInspectLoop({
|
|
52
|
+
agentDir: cwd,
|
|
53
|
+
...(sessionArg !== undefined ? { sessionIdOrPrefix: sessionArg } : {}),
|
|
54
|
+
...(filterArg !== undefined ? { filter: filterArg } : {}),
|
|
55
|
+
...(sinceArg !== undefined ? { since: sinceArg } : {}),
|
|
56
|
+
json: isJson,
|
|
57
|
+
color,
|
|
58
|
+
selectSession: (sessions, selectOpts) => clackSelect(sessions, selectOpts?.initialSessionId),
|
|
59
|
+
...(liveSource !== undefined ? { liveSource } : {}),
|
|
60
|
+
createTailScope: () => createTailScope({ debounceMs: ESC_DEBOUNCE_MS }),
|
|
61
|
+
...(liveHint !== undefined ? { liveHint } : {}),
|
|
62
|
+
stdout: (line) => process.stdout.write(`${line}\n`),
|
|
63
|
+
stderr: (line) => process.stderr.write(`${line}\n`),
|
|
64
|
+
})
|
|
88
65
|
|
|
89
66
|
if (!result.ok) {
|
|
90
67
|
process.stderr.write(`${errorLine(result.reason)}\n`)
|
|
@@ -115,86 +92,6 @@ async function buildLiveSource(cwd: string): Promise<LiveSourceFactory | undefin
|
|
|
115
92
|
})
|
|
116
93
|
}
|
|
117
94
|
|
|
118
|
-
function installSigintAbort(): AbortController {
|
|
119
|
-
const ctrl = new AbortController()
|
|
120
|
-
const onSig = (): void => {
|
|
121
|
-
ctrl.abort()
|
|
122
|
-
}
|
|
123
|
-
process.once('SIGINT', onSig)
|
|
124
|
-
process.once('SIGTERM', onSig)
|
|
125
|
-
return ctrl
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
type EscListener = {
|
|
129
|
-
armForStream: () => AbortSignal
|
|
130
|
-
pause: () => void
|
|
131
|
-
resume: () => void
|
|
132
|
-
stop: () => void
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
type RawInput = Pick<NodeJS.ReadStream, 'isTTY' | 'setRawMode' | 'resume' | 'pause' | 'on' | 'off'>
|
|
136
|
-
|
|
137
|
-
export function createEscListener(onSigint: () => void, input: RawInput = process.stdin): EscListener | null {
|
|
138
|
-
const stdin = input
|
|
139
|
-
if (!stdin.isTTY || typeof stdin.setRawMode !== 'function') return null
|
|
140
|
-
|
|
141
|
-
const ctrl = createEscController({ debounceMs: ESC_LISTEN_DELAY_MS })
|
|
142
|
-
let active = false
|
|
143
|
-
|
|
144
|
-
const onData = (chunk: Buffer): void => {
|
|
145
|
-
const { sigint } = ctrl.onChunk(chunk)
|
|
146
|
-
if (sigint) onSigint()
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
const start = (): void => {
|
|
150
|
-
if (active) return
|
|
151
|
-
active = true
|
|
152
|
-
stdin.setRawMode(true)
|
|
153
|
-
// Attach the data handler before resume() so no raw-mode keystroke can slip
|
|
154
|
-
// through between resuming the stream and registering the listener.
|
|
155
|
-
stdin.on('data', onData)
|
|
156
|
-
stdin.resume()
|
|
157
|
-
}
|
|
158
|
-
const stop = (): void => {
|
|
159
|
-
if (!active) return
|
|
160
|
-
active = false
|
|
161
|
-
stdin.off('data', onData)
|
|
162
|
-
try {
|
|
163
|
-
stdin.setRawMode(false)
|
|
164
|
-
} catch {
|
|
165
|
-
/* terminal already torn down */
|
|
166
|
-
}
|
|
167
|
-
// Do NOT pause stdin here: this teardown hands control to the clack picker,
|
|
168
|
-
// and under Bun clack does not reliably re-flow a previously paused
|
|
169
|
-
// process.stdin, so its keypresses never arrive and arrow keys echo as raw
|
|
170
|
-
// bytes. Leaving the stream flowing lets clack own raw mode during the picker.
|
|
171
|
-
ctrl.clearPending()
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
return {
|
|
175
|
-
armForStream: () => {
|
|
176
|
-
const signal = ctrl.armForStream()
|
|
177
|
-
start()
|
|
178
|
-
return signal
|
|
179
|
-
},
|
|
180
|
-
pause: () => {
|
|
181
|
-
stop()
|
|
182
|
-
},
|
|
183
|
-
resume: () => {
|
|
184
|
-
// Resume the listener WITHOUT replacing the AbortController.
|
|
185
|
-
// The signal returned by armForStream() is held by the live source
|
|
186
|
-
// through streamSession's combinedSignal; replacing the controller
|
|
187
|
-
// here would orphan that signal so a subsequent ESC press could
|
|
188
|
-
// not abort the live tail.
|
|
189
|
-
start()
|
|
190
|
-
},
|
|
191
|
-
stop: () => {
|
|
192
|
-
ctrl.dispose()
|
|
193
|
-
stop()
|
|
194
|
-
},
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
|
|
198
95
|
function escHintLine(color: boolean): string {
|
|
199
96
|
const text = '(press esc to return to session list)'
|
|
200
97
|
return color ? `\u001b[2m${text}\u001b[0m` : text
|
|
@@ -212,6 +109,7 @@ async function clackSelect(
|
|
|
212
109
|
initialSessionId: string | undefined,
|
|
213
110
|
): Promise<SessionSummary | null> {
|
|
214
111
|
const { select } = await import('@clack/prompts')
|
|
112
|
+
prepareStdinForClack()
|
|
215
113
|
const preferred =
|
|
216
114
|
initialSessionId !== undefined && sessions.some((s) => s.sessionId === initialSessionId)
|
|
217
115
|
? initialSessionId
|
package/src/cli/ui.ts
CHANGED
|
@@ -7,6 +7,28 @@ import { type AutoUpgradeOutcome, describeAutoUpgrade } from '@/init/auto-upgrad
|
|
|
7
7
|
|
|
8
8
|
export { cancel, intro, isCancel, log, note, outro }
|
|
9
9
|
|
|
10
|
+
type ClackInput = Pick<NodeJS.ReadStream, 'isTTY' | 'setRawMode' | 'resume'>
|
|
11
|
+
|
|
12
|
+
// Hand stdin to a clack picker in a state it can own. Over an SSH pseudo-TTY,
|
|
13
|
+
// Bun's readline keypress wiring only transitions stdin into flowing raw mode
|
|
14
|
+
// reliably once the stream has already been resumed; on a never-resumed stdin
|
|
15
|
+
// the picker renders but arrow keys echo as raw `^[[B` and never advance it.
|
|
16
|
+
// Local terminals dodge this because stdin was already flowing. So before every
|
|
17
|
+
// picker: clear any stale raw mode for a clean baseline, then resume the stream.
|
|
18
|
+
// Never pause() here — a previously-paused process.stdin does not reliably
|
|
19
|
+
// re-flow under Bun, which is the same failure this resume() is fixing.
|
|
20
|
+
export function prepareStdinForClack(input: ClackInput = process.stdin): void {
|
|
21
|
+
if (!input.isTTY) return
|
|
22
|
+
if (typeof input.setRawMode === 'function') {
|
|
23
|
+
try {
|
|
24
|
+
input.setRawMode(false)
|
|
25
|
+
} catch {
|
|
26
|
+
/* terminal already torn down */
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
input.resume()
|
|
30
|
+
}
|
|
31
|
+
|
|
10
32
|
function colorize(modifier: Parameters<typeof styleText>[0], s: string): string {
|
|
11
33
|
if (!colorsEnabled()) return s
|
|
12
34
|
return styleText(modifier, s)
|
|
@@ -169,6 +191,18 @@ export const SLACK_APP_MANIFEST = {
|
|
|
169
191
|
url: 'https://example.invalid/typeclaw-uses-socket-mode',
|
|
170
192
|
should_escape: false,
|
|
171
193
|
},
|
|
194
|
+
{
|
|
195
|
+
command: '/reload',
|
|
196
|
+
description: 'Reload typeclaw config and subsystems from disk',
|
|
197
|
+
url: 'https://example.invalid/typeclaw-uses-socket-mode',
|
|
198
|
+
should_escape: false,
|
|
199
|
+
},
|
|
200
|
+
{
|
|
201
|
+
command: '/restart',
|
|
202
|
+
description: 'Restart the typeclaw container',
|
|
203
|
+
url: 'https://example.invalid/typeclaw-uses-socket-mode',
|
|
204
|
+
should_escape: false,
|
|
205
|
+
},
|
|
172
206
|
],
|
|
173
207
|
},
|
|
174
208
|
oauth_config: {
|
package/src/commands/index.ts
CHANGED
|
@@ -13,8 +13,11 @@ export type CommandHandler<Context> = (
|
|
|
13
13
|
// dispatcher, so a new command declares its own requirements in one place:
|
|
14
14
|
// 'session.control' + requiresLiveSession:true is the control-command default
|
|
15
15
|
// (/stop); 'none' + requiresLiveSession:false is the informational default
|
|
16
|
-
// (/help).
|
|
17
|
-
|
|
16
|
+
// (/help). 'session.admin' + requiresLiveSession:false is the operate-the-agent
|
|
17
|
+
// tier (/reload, /restart) — owner+trusted only, no live session required since
|
|
18
|
+
// it acts on the container, not a channel turn. Both are optional so plain
|
|
19
|
+
// registries (tests, TUI) need not care.
|
|
20
|
+
export type CommandPermission = 'none' | 'session.control' | 'session.admin'
|
|
18
21
|
|
|
19
22
|
export type Command<Context> = {
|
|
20
23
|
name: string
|