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.
- package/README.md +29 -77
- package/package.json +1 -1
- package/scripts/dump-system-prompt.ts +12 -1
- package/src/agent/auth.ts +3 -3
- package/src/agent/index.ts +61 -8
- package/src/agent/multimodal/read-redirect.ts +43 -0
- package/src/agent/plugin-tools.ts +95 -13
- package/src/agent/session-origin.ts +6 -13
- package/src/agent/system-prompt.ts +41 -7
- package/src/channels/adapters/discord-bot-slash-commands.ts +186 -0
- package/src/channels/adapters/discord-bot.ts +163 -1
- package/src/channels/adapters/slack-bot-slash-commands.ts +82 -0
- package/src/channels/adapters/slack-bot.ts +139 -1
- package/src/channels/router.ts +127 -1
- package/src/cli/init.ts +8 -1
- package/src/cli/oauth-callbacks.ts +64 -34
- package/src/cli/provider.ts +9 -4
- package/src/cli/role.ts +7 -2
- package/src/cli/tunnel.ts +13 -1
- package/src/cli/ui.ts +25 -1
- package/src/config/config.ts +28 -4
- package/src/config/index.ts +1 -0
- package/src/config/models-mutation.ts +10 -2
- package/src/config/providers.ts +106 -0
- package/src/init/dockerfile.ts +89 -2
- package/src/init/models-dev.ts +1 -0
- package/src/shared/index.ts +1 -1
- package/src/shared/local-time.ts +17 -0
- package/src/skills/typeclaw-claude-code/SKILL.md +30 -5
- package/src/skills/typeclaw-config/SKILL.md +37 -32
- package/src/skills/typeclaw-git/SKILL.md +2 -2
- package/src/skills/typeclaw-plugins/SKILL.md +1 -1
- package/typeclaw.schema.json +6 -0
|
@@ -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)
|
package/src/channels/router.ts
CHANGED
|
@@ -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) =>
|
|
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
|
-
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
|
|
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
|
}
|
package/src/cli/provider.ts
CHANGED
|
@@ -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
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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
|
-
|
|
99
|
-
|
|
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',
|
package/src/config/config.ts
CHANGED
|
@@ -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 →
|
|
424
|
-
//
|
|
425
|
-
//
|
|
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 =
|
|
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
|
|
package/src/config/index.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { join } from 'node:path'
|
|
|
3
3
|
|
|
4
4
|
import { commitSystemFileSync } from '@/git/system-commit'
|
|
5
5
|
|
|
6
|
-
import { configSchema,
|
|
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 =
|
|
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]!
|