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.
Files changed (47) hide show
  1. package/package.json +2 -1
  2. package/src/agent/index.ts +55 -1
  3. package/src/agent/loop-guard.ts +180 -53
  4. package/src/agent/session-origin.ts +41 -2
  5. package/src/bundled-plugins/bun-hygiene/README.md +82 -0
  6. package/src/bundled-plugins/bun-hygiene/index.ts +11 -0
  7. package/src/bundled-plugins/bun-hygiene/policy.ts +318 -0
  8. package/src/bundled-plugins/github-cli-auth/gh-command.ts +98 -6
  9. package/src/bundled-plugins/github-cli-auth/graphql-auth-nudge.ts +80 -0
  10. package/src/bundled-plugins/github-cli-auth/index.ts +7 -0
  11. package/src/bundled-plugins/memory/memory-logger.ts +34 -12
  12. package/src/bundled-plugins/reviewer/skills/code-review.ts +8 -0
  13. package/src/channels/adapters/discord-bot.ts +8 -0
  14. package/src/channels/adapters/github/inbound.ts +23 -1
  15. package/src/channels/adapters/github/index.ts +9 -0
  16. package/src/channels/adapters/slack-bot.ts +112 -5
  17. package/src/channels/adapters/telegram-bot.ts +11 -0
  18. package/src/channels/manager.ts +8 -0
  19. package/src/channels/router.ts +100 -15
  20. package/src/channels/schema.ts +18 -0
  21. package/src/channels/types.ts +27 -0
  22. package/src/cli/dreams.ts +2 -1
  23. package/src/cli/inspect-controller.ts +92 -0
  24. package/src/cli/inspect.ts +21 -123
  25. package/src/cli/ui.ts +34 -0
  26. package/src/commands/index.ts +5 -2
  27. package/src/config/config.ts +89 -0
  28. package/src/inspect/index.ts +8 -26
  29. package/src/inspect/live.ts +17 -3
  30. package/src/inspect/loop.ts +23 -17
  31. package/src/mcp/catalog.ts +29 -0
  32. package/src/mcp/client.ts +236 -0
  33. package/src/mcp/index.ts +25 -0
  34. package/src/mcp/manager.ts +156 -0
  35. package/src/mcp/tools.ts +190 -0
  36. package/src/permissions/builtins.ts +9 -0
  37. package/src/reload/format.ts +14 -0
  38. package/src/reload/index.ts +1 -0
  39. package/src/run/bundled-plugins.ts +7 -0
  40. package/src/run/channel-session-factory.ts +3 -0
  41. package/src/run/index.ts +38 -1
  42. package/src/server/command-runner.ts +5 -0
  43. package/src/server/index.ts +4 -0
  44. package/src/skills/typeclaw-channel-github/SKILL.md +83 -13
  45. package/src/skills/typeclaw-config/SKILL.md +1 -1
  46. package/src/skills/typeclaw-git/SKILL.md +1 -1
  47. package/typeclaw.schema.json +82 -0
@@ -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: readonly Command<ChannelCommandContext>[] = [
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
- if (commandInfo.permission === 'session.control' && isSessionControlDenied(event)) {
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 (session.control) author=${event.authorId}`,
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
- const isSessionControlDenied = (event: InboundMessage): boolean =>
1917
- !permissions.has(inboundAuthorOrigin(event), CORE_PERMISSIONS.sessionControl)
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 (not channel.respond) so a respond-capable
2706
- // guest cannot abort another speaker's turn. Runs BEFORE the live-session
2707
- // lookup so an unauthorized invoker gets 'permission-denied' regardless of
2708
- // session state, rather than leaking session presence via the
2709
- // 'no-live-session' vs 'permission-denied' distinction. Session-less
2710
- // informational commands (e.g. /help) declare permission:'none' and skip
2711
- // both the gate and the lookup so they work in channels with no live turn.
2712
- if (commandInfo.permission === 'session.control') {
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, CORE_PERMISSIONS.sessionControl)) {
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,
@@ -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
@@ -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
+ }
@@ -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 { createEscController } from './inspect-controller'
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 ESC_LISTEN_DELAY_MS = 50
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 signalCtrl = installSigintAbort()
49
- const signal = signalCtrl.signal
50
- // Raw-mode Ctrl-C arrives as byte 0x03 and must abort the exit controller
51
- // directly: under Bun a self-issued process.kill(SIGINT) does not reliably
52
- // re-enter our process.once('SIGINT') handler, so the live tail never exits.
53
- const escListener = isJson ? null : createEscListener(() => signalCtrl.abort())
54
- const liveHint = escListener === null ? undefined : escHintLine(color)
55
-
56
- // try/finally so a thrown loop never leaves the terminal stuck in raw mode.
57
- let result: Awaited<ReturnType<typeof runInspectLoop>>
58
- try {
59
- result = await runInspectLoop({
60
- agentDir: cwd,
61
- ...(sessionArg !== undefined ? { sessionIdOrPrefix: sessionArg } : {}),
62
- ...(filterArg !== undefined ? { filter: filterArg } : {}),
63
- ...(sinceArg !== undefined ? { since: sinceArg } : {}),
64
- json: isJson,
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: {
@@ -13,8 +13,11 @@ export type CommandHandler<Context> = (
13
13
  // dispatcher, so a new command declares its own requirements in one place:
14
14
  // 'session.control' + requiresLiveSession:true is the control-command default
15
15
  // (/stop); 'none' + requiresLiveSession:false is the informational default
16
- // (/help). Both are optional so plain registries (tests, TUI) need not care.
17
- export type CommandPermission = 'none' | 'session.control'
16
+ // (/help). 'session.admin' + requiresLiveSession:false is the operate-the-agent
17
+ // tier (/reload, /restart) owner+trusted only, no live session required since
18
+ // it acts on the container, not a channel turn. Both are optional so plain
19
+ // registries (tests, TUI) need not care.
20
+ export type CommandPermission = 'none' | 'session.control' | 'session.admin'
18
21
 
19
22
  export type Command<Context> = {
20
23
  name: string