typeclaw 0.6.0 → 0.8.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.
@@ -1,4 +1,4 @@
1
- import { SlackBotClient, SlackBotListener } from 'agent-messenger/slackbot'
1
+ import { SlackBotClient, SlackBotListener, type SlackSocketModeSlashCommandArgs } from 'agent-messenger/slackbot'
2
2
 
3
3
  import {
4
4
  MEMBERSHIP_ENUMERATION_CAP,
@@ -33,8 +33,27 @@ import {
33
33
  type SlackInboundMessageEvent,
34
34
  } from './slack-bot-classify'
35
35
  import { createSlackDedupe } from './slack-bot-dedupe'
36
+ import {
37
+ buildSlashAckPayload,
38
+ parseSlashCommand,
39
+ SLACK_SLASH_REPLY_ABORTED,
40
+ SLACK_SLASH_REPLY_AMBIGUOUS,
41
+ SLACK_SLASH_REPLY_FAILED,
42
+ SLACK_SLASH_REPLY_NO_LIVE_SESSION,
43
+ SLACK_SLASH_REPLY_PERMISSION_DENIED,
44
+ } from './slack-bot-slash-commands'
36
45
  import { slackTsToMillis } from './slack-bot-time'
37
46
 
47
+ // One slash command per logical agent gesture. Mirrors the discord-bot
48
+ // SLASH_COMMANDS constant so the cross-platform set stays consistent — when
49
+ // we add a new command (e.g. /memory), it appears in both adapters together.
50
+ // The actual registration lives in the Slack App Manifest at src/cli/ui.ts;
51
+ // this constant is the runtime allow-list that gates which delivered
52
+ // slash_commands events we route vs drop. The ui.test.ts manifest-drift
53
+ // test asserts equality between this set and SLACK_APP_MANIFEST.features.
54
+ // slash_commands so the two can never silently diverge.
55
+ export const SLACK_SLASH_COMMAND_NAMES: ReadonlySet<string> = new Set(['stop'])
56
+
38
57
  // Resolvers fall back to the raw id on failure, so a name equal to the id
39
58
  // means resolution failed; we render the bare id rather than `id(id)`. The
40
59
  // prefix is intentionally only applied to the named form so we never log
@@ -44,6 +63,101 @@ function formatLabel(name: string | undefined, id: string, prefix = ''): string
44
63
  return `${prefix}${name}(${id})`
45
64
  }
46
65
 
66
+ export type SlackBotAdapterLoggerLike = {
67
+ info: (msg: string) => void
68
+ warn: (msg: string) => void
69
+ error: (msg: string) => void
70
+ }
71
+
72
+ export type SlashCommandHandlerDeps = {
73
+ router: Pick<ChannelRouter, 'executeCommand'>
74
+ knownCommandNames: ReadonlySet<string>
75
+ logger: SlackBotAdapterLoggerLike
76
+ formatChannelTag: (workspace: string, chat: string) => Promise<string>
77
+ }
78
+
79
+ // Ack-first invariant: the handler must call args.ack() exactly once on
80
+ // every path AND must do so before any slow network work (resolver calls,
81
+ // post-ack logging). Slack's 3s ack deadline starts when the slash command
82
+ // envelope arrives on the WebSocket; missing it shows the user
83
+ // "/stop didn't respond in time". The synchronous executeCommand happy
84
+ // path is fast (in-memory map lookup + abort), so ack-after-execute is
85
+ // safe; everything else (formatChannelTag, post-ack logging) runs after.
86
+ //
87
+ // Ack failure handling: a thrown ack on the happy path is logged but does
88
+ // NOT trigger the catch-all error-ack below, which would attempt a second
89
+ // ack call and break the exactly-once contract.
90
+ export function createSlashCommandHandler(
91
+ deps: SlashCommandHandlerDeps,
92
+ ): (args: SlackSocketModeSlashCommandArgs) => Promise<void> {
93
+ return async ({ ack, body }) => {
94
+ const parsed = parseSlashCommand(body, deps.knownCommandNames)
95
+ if (parsed.kind === 'ignore') {
96
+ deps.logger.warn(`[slack-bot] slash command dropped reason=${parsed.reason} command=${body.command}`)
97
+ try {
98
+ ack(buildSlashAckPayload(SLACK_SLASH_REPLY_FAILED))
99
+ } catch (err) {
100
+ deps.logger.warn(`[slack-bot] slash command ack (drop path) failed: ${describe(err)}`)
101
+ }
102
+ return
103
+ }
104
+ const { command } = parsed
105
+
106
+ // Pre-ACK log: bare ids only (no formatChannelTag — would burn ack budget
107
+ // on a slow Slack API minute via the channel-name resolver).
108
+ deps.logger.info(
109
+ `[slack-bot] slash /${command.name} invoker=${command.invokerId} team=${command.key.workspace} channel=${command.key.chat}`,
110
+ )
111
+
112
+ let result: Awaited<ReturnType<typeof deps.router.executeCommand>>
113
+ try {
114
+ result = await deps.router.executeCommand(command.key, command.name, {
115
+ invokerId: command.invokerId,
116
+ })
117
+ } catch (err) {
118
+ deps.logger.error(`[slack-bot] slash command handler failed: ${describe(err)}`)
119
+ try {
120
+ ack(buildSlashAckPayload(SLACK_SLASH_REPLY_FAILED))
121
+ } catch (ackErr) {
122
+ deps.logger.warn(`[slack-bot] slash command error-ack failed: ${describe(ackErr)}`)
123
+ }
124
+ return
125
+ }
126
+
127
+ const replyContent =
128
+ result.kind === 'handled'
129
+ ? SLACK_SLASH_REPLY_ABORTED
130
+ : result.kind === 'no-live-session'
131
+ ? SLACK_SLASH_REPLY_NO_LIVE_SESSION
132
+ : result.kind === 'permission-denied'
133
+ ? SLACK_SLASH_REPLY_PERMISSION_DENIED
134
+ : result.kind === 'ambiguous'
135
+ ? SLACK_SLASH_REPLY_AMBIGUOUS
136
+ : SLACK_SLASH_REPLY_FAILED
137
+
138
+ // Final ack on the happy path: own try/catch so a thrown ack here does
139
+ // NOT cascade into the error-path ack above (which would violate the
140
+ // exactly-once contract). The abort already happened server-side; only
141
+ // the user-visible confirmation is lost.
142
+ try {
143
+ ack(buildSlashAckPayload(replyContent))
144
+ } catch (err) {
145
+ deps.logger.warn(`[slack-bot] slash command ack failed: ${describe(err)}`)
146
+ }
147
+
148
+ // Decorative post-ack logging: resolve channel names now that the 3s
149
+ // budget is no longer a concern. Best-effort.
150
+ try {
151
+ const inboundTag = await deps.formatChannelTag(command.key.workspace, command.key.chat)
152
+ deps.logger.info(`[slack-bot] slash /${command.name} result=${result.kind} ${inboundTag}`)
153
+ } catch (err) {
154
+ deps.logger.info(
155
+ `[slack-bot] slash /${command.name} result=${result.kind} (channel-tag resolution failed: ${describe(err)})`,
156
+ )
157
+ }
158
+ }
159
+ }
160
+
47
161
  // app_mention payloads omit channel_type and never carry a subtype, so we
48
162
  // promote them to a message-shaped event for the shared classifier. The
49
163
  // promoted event is classified as a regular channel message; the
@@ -661,6 +775,13 @@ export function createSlackBotAdapter(options: SlackBotAdapterOptions): SlackBot
661
775
 
662
776
  const dedupe = createSlackDedupe()
663
777
 
778
+ const handleSlashCommand = createSlashCommandHandler({
779
+ router: options.router,
780
+ knownCommandNames: SLACK_SLASH_COMMAND_NAMES,
781
+ logger,
782
+ formatChannelTag,
783
+ })
784
+
664
785
  const handleMessageEvent = async (
665
786
  event: SlackInboundMessageEvent,
666
787
  source: 'message' | 'app_mention',
@@ -777,6 +898,23 @@ export function createSlackBotAdapter(options: SlackBotAdapterOptions): SlackBot
777
898
  ack()
778
899
  void handleMessageEvent(promoteAppMentionToMessage(event as SlackInboundAppMentionEvent), 'app_mention')
779
900
  })
901
+ listener.on('slash_commands', (args) => {
902
+ // The handler owns the ack call itself (the ack payload carries the
903
+ // user-visible reply text), so we do NOT ack here. inflightInbounds
904
+ // wrapping mirrors handleMessageEvent so stop() can drain the
905
+ // handler before tearing down the listener — otherwise a /stop
906
+ // arriving during stop() would lose its ack and the user sees
907
+ // "didn't respond in time" even though the abort succeeded.
908
+ inflightInbounds++
909
+ void handleSlashCommand(args).finally(() => {
910
+ inflightInbounds--
911
+ if (inflightInbounds === 0 && stopWaiters.length > 0) {
912
+ const waiters = stopWaiters
913
+ stopWaiters = []
914
+ for (const w of waiters) w()
915
+ }
916
+ })
917
+ })
780
918
 
781
919
  options.router.registerOutbound('slack-bot', outboundCallback)
782
920
  options.router.registerTyping('slack-bot', typingCallback)
@@ -297,9 +297,30 @@ type LiveSession = {
297
297
  unsubProviderErrors: (() => void) | null
298
298
  }
299
299
 
300
+ // `event` is null for command invocations that originated outside the inbound
301
+ // pipeline (e.g. Discord native slash commands fired from listener.on
302
+ // ('interaction_create')). Handlers that need a real inbound — for some
303
+ // future hypothetical command like `/quote` — must guard on event !== null
304
+ // instead of assuming it.
300
305
  type ChannelCommandContext = {
301
306
  live: LiveSession
302
- event: InboundMessage
307
+ event: InboundMessage | null
308
+ }
309
+
310
+ export type ExecuteCommandResult =
311
+ | { kind: 'handled'; name: string }
312
+ | { kind: 'unknown-command'; name: string }
313
+ | { kind: 'no-live-session' }
314
+ | { kind: 'permission-denied' }
315
+ | { kind: 'ambiguous'; matchCount: number }
316
+
317
+ // Identifies who invoked an adapter-driven command. Required so the router
318
+ // can run the same channel.respond permission gate the text-prefix command
319
+ // path runs (isChannelRespondDenied in route()). Without it, a guest user
320
+ // in a public Slack channel could /stop an owner-created session that
321
+ // happened to be live, bypassing role gating entirely.
322
+ export type ExecuteCommandOptions = {
323
+ invokerId: string
303
324
  }
304
325
 
305
326
  export type SendSource = 'tool' | 'system'
@@ -345,6 +366,22 @@ export type ChannelRouter = {
345
366
  registerFetchAttachment: (adapter: ChannelKey['adapter'], cb: FetchAttachmentCallback) => void
346
367
  unregisterFetchAttachment: (adapter: ChannelKey['adapter'], cb: FetchAttachmentCallback) => void
347
368
  fetchAttachment: (adapter: ChannelKey['adapter'], args: FetchAttachmentArgs) => Promise<FetchAttachmentResult>
369
+ // Execute a command by name against an existing live session, bypassing
370
+ // the inbound classifier, engagement gate, debounce, and prompt queue.
371
+ // Used by adapters that receive commands through a native surface
372
+ // (Discord application-command interactions) rather than text. Gates
373
+ // the invoker on channel.respond — same permission gate the text-prefix
374
+ // command path runs — so a guest user cannot abort an owner's session
375
+ // by clicking the slash-command picker. Adapters MUST forward the
376
+ // invoker's platform-specific user id; without it the gate cannot
377
+ // identify the actor and resolves to 'guest' which denies. Returns:
378
+ // - handled: command ran
379
+ // - permission-denied: invoker lacks channel.respond
380
+ // - no-live-session: channel has no active session
381
+ // - ambiguous: multiple thread-keyed sessions in same chat (Slack);
382
+ // caller should refuse to act rather than abort an arbitrary one
383
+ // - unknown-command: name is not registered
384
+ executeCommand: (key: ChannelKey, name: string, options: ExecuteCommandOptions) => Promise<ExecuteCommandResult>
348
385
  // Lowered self-aliases (configured + implicit dir-name). Adapters use
349
386
  // this to anchor outbound threading on alias-only inbounds — see
350
387
  // slack-bot-classify.ts. Read live so a reload of `alias` propagates
@@ -1733,6 +1770,48 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1733
1770
  }
1734
1771
  }
1735
1772
 
1773
+ const executeCommand = async (
1774
+ key: ChannelKey,
1775
+ name: string,
1776
+ options: ExecuteCommandOptions,
1777
+ ): Promise<ExecuteCommandResult> => {
1778
+ const lowered = name.toLowerCase()
1779
+ if (!commands.has(lowered)) {
1780
+ return { kind: 'unknown-command', name: lowered }
1781
+ }
1782
+ // Permission gate runs BEFORE the live-session lookup so a guest user
1783
+ // invoking /stop on a non-existent session gets 'permission-denied'
1784
+ // (consistent answer regardless of session state) rather than leaking
1785
+ // session presence via the 'no-live-session' vs 'permission-denied'
1786
+ // distinction.
1787
+ const partial: SessionOrigin = {
1788
+ kind: 'channel',
1789
+ adapter: key.adapter,
1790
+ workspace: key.workspace,
1791
+ chat: key.chat,
1792
+ thread: key.thread,
1793
+ lastInboundAuthorId: options.invokerId,
1794
+ }
1795
+ if (!permissions.has(partial, CORE_PERMISSIONS.channelRespond)) {
1796
+ return { kind: 'permission-denied' }
1797
+ }
1798
+ const resolved = resolveLiveSessionForCommand(liveSessions, key)
1799
+ if (resolved.kind === 'none') {
1800
+ return { kind: 'no-live-session' }
1801
+ }
1802
+ if (resolved.kind === 'ambiguous') {
1803
+ return { kind: 'ambiguous', matchCount: resolved.count }
1804
+ }
1805
+ const result = await commands.execute(`/${lowered}`, { live: resolved.session, event: null })
1806
+ if (result.kind === 'handled') {
1807
+ return { kind: 'handled', name: result.name }
1808
+ }
1809
+ // commands.execute can only return not-command (impossible — we pass a
1810
+ // leading slash), unknown-command (impossible — we just checked has()),
1811
+ // or handled. Any other outcome is a bug.
1812
+ return { kind: 'unknown-command', name: lowered }
1813
+ }
1814
+
1736
1815
  return {
1737
1816
  route,
1738
1817
  send,
@@ -1752,6 +1831,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1752
1831
  registerFetchAttachment,
1753
1832
  unregisterFetchAttachment,
1754
1833
  fetchAttachment,
1834
+ executeCommand,
1755
1835
  getSelfAliases: computeSelfAliases,
1756
1836
  stop,
1757
1837
  liveCount: () => liveSessions.size,
@@ -1912,6 +1992,52 @@ function consecutiveSendKey(chat: string, thread: string | null | undefined): st
1912
1992
  return `${chat}:${thread ?? ''}`
1913
1993
  }
1914
1994
 
1995
+ export type ResolveLiveSessionResult =
1996
+ | { kind: 'found'; session: LiveSession }
1997
+ | { kind: 'none' }
1998
+ | { kind: 'ambiguous'; count: number }
1999
+
2000
+ // Lookup policy for adapter-driven commands. Exact-key match always wins.
2001
+ // On miss, fall back to (adapter, workspace, chat) without thread — but
2002
+ // only when EXACTLY ONE non-destroyed candidate exists. Ambiguous matches
2003
+ // return 'ambiguous' so the caller can refuse to act rather than abort an
2004
+ // arbitrary session.
2005
+ //
2006
+ // Why the fallback: Slack slash commands carry channel_id but no thread_ts,
2007
+ // so a slash invocation from a thread-keyed live session would otherwise
2008
+ // report no-live-session. Discord doesn't hit this — Discord treats threads
2009
+ // as channels, so the exact-key path already resolves.
2010
+ //
2011
+ // Why ambiguity-rejection: "first match wins" map-iteration semantics would
2012
+ // abort an arbitrary thread when multiple thread-keyed sessions coexist in
2013
+ // one channel (plausible on Slack: bot mentioned in multiple threads). The
2014
+ // user's slash command picker doesn't know about threads; we don't know
2015
+ // which they meant; refusing is safer than guessing.
2016
+ export function resolveLiveSessionForCommand(
2017
+ liveSessions: ReadonlyMap<string, LiveSession>,
2018
+ key: ChannelKey,
2019
+ ): ResolveLiveSessionResult {
2020
+ const exact = liveSessions.get(channelKeyId(key))
2021
+ if (exact && !exact.destroyed) return { kind: 'found', session: exact }
2022
+
2023
+ const matches: LiveSession[] = []
2024
+ for (const candidate of liveSessions.values()) {
2025
+ if (candidate.destroyed) continue
2026
+ if (
2027
+ candidate.key.adapter === key.adapter &&
2028
+ candidate.key.workspace === key.workspace &&
2029
+ candidate.key.chat === key.chat
2030
+ ) {
2031
+ matches.push(candidate)
2032
+ if (matches.length > 1) {
2033
+ return { kind: 'ambiguous', count: matches.length }
2034
+ }
2035
+ }
2036
+ }
2037
+ if (matches.length === 1) return { kind: 'found', session: matches[0]! }
2038
+ return { kind: 'none' }
2039
+ }
2040
+
1915
2041
  function normalizeSendText(text: string | undefined): string | undefined {
1916
2042
  if (text === undefined) return undefined
1917
2043
  if (text === '') return undefined
package/src/cli/init.ts CHANGED
@@ -374,7 +374,14 @@ export const defaultWizardPrompts: WizardPrompts = {
374
374
  hasExistingChannelSecrets,
375
375
  askReuseExistingChannel,
376
376
  runChannelFlow,
377
- runOAuthLogin: (provider, cwd, model) => makeOAuthLoginRunner(buildOAuthCallbacks(provider.name))({ cwd, model }),
377
+ runOAuthLogin: async (provider, cwd, model) => {
378
+ const { callbacks, dispose } = buildOAuthCallbacks(provider.name)
379
+ try {
380
+ return await makeOAuthLoginRunner(callbacks)({ cwd, model })
381
+ } finally {
382
+ dispose()
383
+ }
384
+ },
378
385
  askOAuthFailureRecovery,
379
386
  }
380
387
 
@@ -9,41 +9,71 @@ import type { OAuthCallbacks } from '@/init/oauth-login'
9
9
  // concurrent `onManualCodeInput` prompt for users whose browser is on a
10
10
  // different host than the CLI. See src/init/oauth-login.ts for the contract
11
11
  // on each callback and why onManualCodeInput is required for cross-device.
12
- export function buildOAuthCallbacks(providerName: string): OAuthCallbacks {
12
+ //
13
+ // Returns `{ callbacks, dispose }` rather than bare callbacks because of a
14
+ // pi-ai contract gap: pi-ai races `onManualCodeInput()` against the local
15
+ // callback server (packages/ai/src/utils/oauth/anthropic.ts:210-253). When
16
+ // the browser wins the race, pi-ai sets `result.code` and falls through to
17
+ // token exchange WITHOUT calling `server.cancelWait()` on the manual side —
18
+ // the manual `text()` prompt is left dangling in clack's render pipeline,
19
+ // re-appearing after every subsequent log line. Without the dispose hook,
20
+ // the user sees "Logged in to {Provider}" immediately followed by the stale
21
+ // "paste the redirect URL here" prompt that's now meaningless. Each call
22
+ // site (init/provider) MUST call `dispose()` in a finally after the OAuth
23
+ // runner returns so the orphaned prompt aborts cleanly; clack honors the
24
+ // signal by resolving the prompt with cancel state, the cancel branch
25
+ // throws inside our callback, and pi-ai's outer `.catch()` swallows it
26
+ // (since it stops awaiting the manual promise on the winning-browser path).
27
+ export type OAuthCallbackHandle = {
28
+ callbacks: OAuthCallbacks
29
+ dispose: () => void
30
+ }
31
+
32
+ export function buildOAuthCallbacks(providerName: string): OAuthCallbackHandle {
33
+ const controller = new AbortController()
34
+ const { signal } = controller
13
35
  return {
14
- onAuth: (url, instructions) => {
15
- // Don't put the URL inside note(): clack wraps long lines with the box
16
- // border `│` on each wrapped segment, which corrupts the URL when the
17
- // user copy-pastes it. Keep instructional text in the box, but print
18
- // the URL itself as a bare console.log line that any terminal will
19
- // hyperlink intact.
20
- const preamble = [
21
- `Open this URL in your browser to sign in to ${providerName}.`,
22
- '',
23
- 'If your browser shows "this site can\'t be reached" after you sign in,',
24
- 'copy the full address from the top of the browser and paste it below.',
25
- ]
26
- if (instructions) preamble.push('', instructions)
27
- note(preamble.join('\n'), 'Browser login')
28
- console.log(url)
29
- console.log('')
30
- },
31
- onProgress: (message) => {
32
- log.info(message)
33
- },
34
- onPrompt: async (message, placeholder) => {
35
- const value = await text({ message, ...(placeholder !== undefined ? { placeholder } : {}) })
36
- if (isCancel(value)) return null
37
- return value
38
- },
39
- onManualCodeInput: async () => {
40
- const value = await text({
41
- message:
42
- 'If your browser shows "this site can\'t be reached" after you sign in, copy the full address from the top of the browser and paste it here:',
43
- placeholder: 'http://localhost:1455/auth/callback?code=...&state=...',
44
- })
45
- if (isCancel(value)) throw new Error('Login cancelled by user')
46
- return value
36
+ dispose: () => controller.abort(),
37
+ callbacks: {
38
+ onAuth: (url, instructions) => {
39
+ // Don't put the URL inside note(): clack wraps long lines with the box
40
+ // border `│` on each wrapped segment, which corrupts the URL when the
41
+ // user copy-pastes it. Keep instructional text in the box, but print
42
+ // the URL itself as a bare console.log line that any terminal will
43
+ // hyperlink intact.
44
+ const preamble = [
45
+ `Open this URL in your browser to sign in to ${providerName}.`,
46
+ '',
47
+ 'If your browser shows "this site can\'t be reached" after you sign in,',
48
+ 'copy the full address from the top of the browser and paste it below.',
49
+ ]
50
+ if (instructions) preamble.push('', instructions)
51
+ note(preamble.join('\n'), 'Browser login')
52
+ console.log(url)
53
+ console.log('')
54
+ },
55
+ onProgress: (message) => {
56
+ log.info(message)
57
+ },
58
+ onPrompt: async (message, placeholder) => {
59
+ const value = await text({
60
+ message,
61
+ signal,
62
+ ...(placeholder !== undefined ? { placeholder } : {}),
63
+ })
64
+ if (isCancel(value)) return null
65
+ return value
66
+ },
67
+ onManualCodeInput: async () => {
68
+ const value = await text({
69
+ message:
70
+ 'If your browser shows "this site can\'t be reached" after you sign in, copy the full address from the top of the browser and paste it here:',
71
+ placeholder: 'http://localhost:1455/auth/callback?code=...&state=...',
72
+ signal,
73
+ })
74
+ if (isCancel(value)) throw new Error('Login cancelled by user')
75
+ return value
76
+ },
47
77
  },
48
78
  }
49
79
  }
@@ -367,10 +367,15 @@ async function runOAuthLogin(cwd: string, providerId: KnownProviderId): Promise<
367
367
  }
368
368
  const modelRef = `${providerId}/${ref}` as const
369
369
 
370
- const runner = makeOAuthLoginRunner(buildOAuthCallbacks(provider.name))
371
- const result = await runner({ cwd, model: modelRef as Parameters<typeof runner>[0]['model'] })
372
- if (!result.ok) return { ok: false, reason: result.reason }
373
- return { ok: true }
370
+ const { callbacks, dispose } = buildOAuthCallbacks(provider.name)
371
+ try {
372
+ const runner = makeOAuthLoginRunner(callbacks)
373
+ const result = await runner({ cwd, model: modelRef as Parameters<typeof runner>[0]['model'] })
374
+ if (!result.ok) return { ok: false, reason: result.reason }
375
+ return { ok: true }
376
+ } finally {
377
+ dispose()
378
+ }
374
379
  }
375
380
 
376
381
  function authHint(id: KnownProviderId): string {
package/src/cli/role.ts CHANGED
@@ -95,8 +95,13 @@ const listSub = defineCommand({
95
95
  },
96
96
  async run() {
97
97
  const cwd = findAgentDir(process.cwd()) ?? process.cwd()
98
- const { loadConfigSync } = await import('@/config')
99
- const config = loadConfigSync(cwd)
98
+ // Diagnostic command: route through `loadConfigSyncOrDefaults` (same
99
+ // soft-fail pattern as PR #288's `status`/`doctor` and the follow-up for
100
+ // `model list`) so a broken `typeclaw.json` doesn't crash the very
101
+ // command users reach for to see which roles the agent thinks it has.
102
+ // Defaults have no `roles` block, so the empty-state hint fires next.
103
+ const { loadConfigSyncOrDefaults } = await import('@/config')
104
+ const config = loadConfigSyncOrDefaults(cwd)
100
105
  if (!config.roles || Object.keys(config.roles).length === 0) {
101
106
  console.log(c.dim('No roles declared. Run `typeclaw role claim` to add one, or edit typeclaw.json by hand.'))
102
107
  return
package/src/cli/tunnel.ts CHANGED
@@ -4,7 +4,7 @@ import { join } from 'node:path'
4
4
  import { select, text, isCancel, cancel, log } from '@clack/prompts'
5
5
  import { defineCommand } from 'citty'
6
6
 
7
- import { loadConfigSync } from '@/config'
7
+ import { loadConfigSync, validateConfig } from '@/config'
8
8
  import { resolveHostPort, resolveTuiToken } from '@/container'
9
9
  import { findAgentDir, isInitialized } from '@/init'
10
10
  import type { ClientMessage, ServerMessage, TunnelLogsServerMessage, TunnelSnapshot } from '@/shared'
@@ -168,6 +168,15 @@ export async function runTunnelAddFlow(
168
168
  args: AddArgs,
169
169
  prompts: TunnelPrompts = defaultPrompts,
170
170
  ): Promise<LiveResult<TunnelConfig>> {
171
+ // Strict gate before any read: a malformed or schema-invalid `typeclaw.json`
172
+ // would otherwise throw out of the subsequent `loadConfigSync` and surface
173
+ // as an uncaught exception instead of the clean exit-1-with-reason that
174
+ // every other LiveResult consumer expects. Same fence PR #288 documented
175
+ // for the `start`/`restart`/`reload` path: destructive paths route through
176
+ // `validateConfig` so the file's invariants are checked once, up front,
177
+ // and the rest of the flow can lean on them.
178
+ const validation = validateConfig(cwd)
179
+ if (!validation.ok) return { ok: false, reason: validation.reason }
171
180
  const config = loadConfigSync(cwd)
172
181
  if (config.tunnels.some((entry) => entry.name === args.name))
173
182
  return { ok: false, reason: `tunnel "${args.name}" already exists` }
@@ -206,6 +215,9 @@ export async function runTunnelAddFlow(
206
215
  }
207
216
 
208
217
  export function runTunnelRemoveFlow(cwd: string, args: RemoveArgs): LiveResult<{ removed: TunnelConfig }> {
218
+ // Same strict gate as `runTunnelAddFlow`. See the comment there for why.
219
+ const validation = validateConfig(cwd)
220
+ if (!validation.ok) return { ok: false, reason: validation.reason }
209
221
  const config = loadConfigSync(cwd)
210
222
  const tunnel = config.tunnels.find((entry) => entry.name === args.name)
211
223
  if (tunnel === undefined) return { ok: false, reason: `unknown tunnel: ${args.name}` }
package/src/cli/ui.ts CHANGED
@@ -142,6 +142,27 @@ export const SLACK_APP_MANIFEST = {
142
142
  messages_tab_enabled: true,
143
143
  messages_tab_read_only_enabled: false,
144
144
  },
145
+ // Slash commands listed here appear in Slack's compose-box picker with
146
+ // their description as a tooltip. `url` is required by Slack's manifest
147
+ // schema even for Socket Mode bots, but is ignored at runtime when the
148
+ // app is in Socket Mode — Slack delivers `slash_commands` envelopes
149
+ // over the same WebSocket as message events. We point it at a
150
+ // deliberately-invalid placeholder (RFC 6761 reserved .invalid TLD)
151
+ // so a misconfigured (non-Socket-Mode) deployment fails fast rather
152
+ // than silently routing real slash invocations to a third-party URL.
153
+ slash_commands: [
154
+ {
155
+ command: '/stop',
156
+ description: 'Abort the current turn in this channel',
157
+ // usage_hint is intentionally omitted. Slack's manifest validator
158
+ // rejects an empty string ("Must be more than 0 characters") but
159
+ // the field is optional, so the cleanest answer is to leave it out
160
+ // rather than invent placeholder text for a command that takes no
161
+ // arguments.
162
+ url: 'https://example.invalid/typeclaw-uses-socket-mode',
163
+ should_escape: false,
164
+ },
165
+ ],
145
166
  },
146
167
  oauth_config: {
147
168
  scopes: {
@@ -150,13 +171,16 @@ export const SLACK_APP_MANIFEST = {
150
171
  // write scopes (chat, files, im/mpim/groups, pins, reactions) let the
151
172
  // agent post replies, upload attachments, open DMs, pin messages, and
152
173
  // react to messages. `channels:join` lets the bot self-join public
153
- // channels it's invited to discuss in.
174
+ // channels it's invited to discuss in. `commands` is required for
175
+ // Slack to deliver `slash_commands` envelopes — without it, slash
176
+ // commands registered in `features` would silently fail to route.
154
177
  bot: [
155
178
  'app_mentions:read',
156
179
  'channels:history',
157
180
  'channels:join',
158
181
  'channels:read',
159
182
  'chat:write',
183
+ 'commands',
160
184
  'emoji:read',
161
185
  'files:read',
162
186
  'files:write',
@@ -420,15 +420,39 @@ export function expandMountPath(input: string, cwd: string): string {
420
420
 
421
421
  // Loaded eagerly from process.cwd()/typeclaw.json at module-import time so
422
422
  // citty arg defaults (e.g. config.port in src/cli/*.ts) see real values, not
423
- // hardcoded fallbacks. Missing file → schema defaults; malformed file → throw,
424
- // which surfaces during CLI startup instead of silently reverting to defaults
425
- // and confusing the user.
423
+ // hardcoded fallbacks. Missing file → schema defaults; malformed file → ALSO
424
+ // schema defaults plus a stderr warning.
425
+ //
426
+ // Why soft-fail and not throw: every CLI command — including diagnostic ones
427
+ // (`typeclaw status`, `typeclaw doctor`, `typeclaw logs`, `typeclaw stop`,
428
+ // `typeclaw usage`, `typeclaw tui`) — pays this eager-load cost through its
429
+ // import graph, regardless of whether the command actually reads config. A
430
+ // hard throw here turns every read-only diagnostic into a crash exactly when
431
+ // the user needs the diagnostic to figure out what's wrong with their config.
432
+ // `validateConfig` (called by `start`/`restart`/`reload`/host-side mutations)
433
+ // is the strict gate for destructive paths; that's where malformed-config
434
+ // errors should surface, not at module-import time.
426
435
  //
427
436
  // `config` is a module-import-time snapshot. Container-stage code that must
428
437
  // observe `typeclaw run` reloads should call `getConfig()` instead, which
429
438
  // returns the current swapped-in value. Host-stage CLI processes are
430
439
  // short-lived, so they keep using `config` directly.
431
- export const config: Config = loadConfigSync(process.cwd())
440
+ export const config: Config = loadConfigSyncOrDefaults(process.cwd())
441
+
442
+ export function loadConfigSyncOrDefaults(cwd: string, options: { warn?: (message: string) => void } = {}): Config {
443
+ try {
444
+ return loadConfigSync(cwd)
445
+ } catch (error) {
446
+ const detail = error instanceof Error ? error.message : String(error)
447
+ const warn = options.warn ?? ((message: string) => process.stderr.write(message))
448
+ warn(
449
+ `warning: ${detail}\n` +
450
+ `warning: continuing with default config so diagnostic commands still work; ` +
451
+ `run \`typeclaw doctor\` or fix ${CONFIG_FILE} before \`typeclaw start\`/\`restart\`/\`reload\`.\n`,
452
+ )
453
+ return configSchema.parse({})
454
+ }
455
+ }
432
456
 
433
457
  let current: Config = config
434
458
 
@@ -10,6 +10,7 @@ export {
10
10
  gitSchema,
11
11
  gitignoreSchema,
12
12
  loadConfigSync,
13
+ loadConfigSyncOrDefaults,
13
14
  loadPluginConfigsSync,
14
15
  migrateLegacyConfigShape,
15
16
  modelsSchema,
@@ -3,7 +3,7 @@ import { join } from 'node:path'
3
3
 
4
4
  import { commitSystemFileSync } from '@/git/system-commit'
5
5
 
6
- import { configSchema, loadConfigSync, validateConfig } from './config'
6
+ import { configSchema, loadConfigSyncOrDefaults, validateConfig } from './config'
7
7
  import {
8
8
  KNOWN_PROVIDERS,
9
9
  listKnownModelRefs,
@@ -33,8 +33,16 @@ export type ModelProfileEntry = {
33
33
 
34
34
  export type ModelMutationResult = { ok: true } | { ok: false; reason: string }
35
35
 
36
+ // `listModelProfiles` is the read-only path behind `typeclaw model list`, a
37
+ // diagnostic command. It routes through `loadConfigSyncOrDefaults` (same
38
+ // soft-fail pattern as `typeclaw status` / `doctor`, PR #288) so a broken
39
+ // `typeclaw.json` doesn't crash the command users reach for to see what
40
+ // model config the agent thinks it has. Mutation paths (`setProfile`,
41
+ // `addProfile`, `removeProfile`) stay on the strict gate via `validateConfig`
42
+ // in `writeModels`, because writing through a broken-on-disk file would
43
+ // silently land schema-invalid bytes.
36
44
  export function listModelProfiles(cwd: string, env: NodeJS.ProcessEnv = process.env): ModelProfileEntry[] {
37
- const models = loadConfigSync(cwd).models
45
+ const models = loadConfigSyncOrDefaults(cwd).models
38
46
  const out: ModelProfileEntry[] = []
39
47
  for (const [profile, refs] of Object.entries(models)) {
40
48
  const headRef = refs[0]!