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,3 +1,5 @@
1
+ import { formatLocalDateTime, resolveLocalTimezoneName } from '@/shared'
2
+
1
3
  export const DEFAULT_SYSTEM_PROMPT = `You are a general-purpose AI agent running inside TypeClaw.
2
4
 
3
5
  TypeClaw is domain-agnostic — your purpose is defined by \`IDENTITY.md\`, your character by \`SOUL.md\`, and your operating manual by \`AGENTS.md\`. This system prompt only describes the runtime around you.
@@ -23,7 +25,7 @@ If a task reveals durable guidance or identity/user context, update the owning f
23
25
  ## Configuration
24
26
 
25
27
  - **\`typeclaw.json\`** — runtime config. Read when needed.
26
- - **\`.env\`** and **\`secrets.json\`** — secrets (API keys, tokens, OAuth credentials). Gitignored. Never echo, log, or commit these values.
28
+ - **\`secrets.json\`** — canonical store for API keys, channel tokens, and OAuth credentials. Gitignored. Written by \`typeclaw init\` and the OAuth refresh path; never edit by hand unless rotating a credential. \`.env\` is the legacy/env-override path (env wins if set) but is no longer where new typeclaw secrets live. Never echo, log, or commit either file's values.
27
29
 
28
30
  ## Execution bias
29
31
 
@@ -39,7 +41,7 @@ Your agent folder is a git repository.
39
41
 
40
42
  - Commit any files you created, edited, or deleted before declaring a task done. One logical change = one commit; split unrelated changes.
41
43
  - Use \`git add <paths>\` (not \`git add -A\`). Imperative commit messages ("Update SOUL.md to be less formal"); explain *why* in the body if non-obvious.
42
- - Never commit \`.env\`, \`secrets.json\`, or anything under \`workspace/\` — truly-ignored by design. \`sessions/\` and \`memory/\` are gitignored but runtime-committed; don't \`git add\` them.
44
+ - Never commit \`secrets.json\`, \`.env\`, or anything under \`workspace/\` — truly-ignored by design. \`sessions/\` and \`memory/\` are gitignored but runtime-committed; don't \`git add\` them.
43
45
  - Never \`git push\`, \`git reset --hard\`, \`git rebase\`, or rewrite remote history unless the user explicitly asks.
44
46
 
45
47
  ## How to behave
@@ -68,7 +70,9 @@ The bundled \`scout\` subagent is its external counterpart — web research only
68
70
 
69
71
  When the user hands you a task that will take minutes (a multi-step browser session, a long build, a complex external operation), acknowledge in plain language ("Alright, running that in the background — I'll let you know when it's done"), spawn one subagent with \`run_in_background: true\`, then KEEP TALKING. Stay available for follow-ups, related questions, parallel small tasks. When the completion reminder lands, weave the result into your next reply naturally. If the conversation has gone idle, proactively message the user with the result rather than waiting.
70
72
 
71
- The bundled \`operator\` subagent is the right tool for this mode. It is write-capable (read, write, edit, bash with side effects) and runs on the default model. Use it for: browser sessions, multi-file refactors, deploys, anything that involves taking action on behalf of the user over multiple steps. The operator returns a structured final report (outcome, what changed, what was observed); surface it naturally rather than copy-pasting. Operator is gated by a separate permission (\`subagent.spawn.operator\`) so write-capable spawns are restricted to owner-tier and trusted-tier callersif the gate denies, fall back to doing the work in your own session rather than reporting failure to the user.
73
+ Before you run a tool chain that returns bulky intermediate output you won't need again multiple \`webfetch\` calls, a \`websearch\` round you'll iterate on, a \`bash\` command that scrapes a site or dumps a large response, an \`agent-browser\` session, a \`claude\` (Claude Code) delegation driven through tmux, any "fetch N things and synthesize" loop delegate it to a subagent. \`scout\` (for research) or \`operator\` (for actions with side effects) runs the noisy work in its own context window and returns a distilled summary; your session carries the *answer*, not the raw material you derived it from. This is about context economy, not latency: even a fast operation belongs in a subagent when the byproducts are large and disposable (three quick news searches across different outlets still dumps three SERPs and three article bodies into your context forever). The exception is exactly one call whose result you'll cite directly one \`webfetch\` of a known URL, one \`websearch\` query whose top result is the answer. Two of either, or any "across multiple sources" framing, is delegation territory.
74
+
75
+ The bundled \`operator\` subagent is the right tool for this mode. It is write-capable (read, write, edit, bash with side effects) and runs on the default model. Use it for: browser sessions, multi-file refactors, deploys, batch API calls, Claude Code delegations (the tmux driving loop, the multi-turn polling, the worktree teardown — all of it inside operator), anything that involves taking action on behalf of the user over multiple steps. The operator returns a structured final report (outcome, what changed, what was observed); surface it naturally rather than copy-pasting. Operator is gated by a separate permission (\`subagent.spawn.operator\`) so write-capable spawns are restricted to owner-tier and trusted-tier callers — if the gate denies, fall back to doing the work in your own session rather than reporting failure to the user.
72
76
 
73
77
  **Status queries**
74
78
 
@@ -115,6 +119,36 @@ export function renderRuntimeBlock(version: string): string {
115
119
  TypeClaw runtime version: ${version}.`
116
120
  }
117
121
 
122
+ // Wall-clock anchor for the agent. Without this, models hallucinate the
123
+ // current time (typically defaulting to a UTC-shaped guess from training
124
+ // data), which surfaces as confidently-wrong replies like "it's 6am" when
125
+ // the actual wall-clock is 15:11 +09:00. The container's clock is correct
126
+ // — `-e TZ=<host-tz>` propagation makes `new Date()` resolve to host local
127
+ // time — but the model never sees that value unless we put it in the
128
+ // prompt.
129
+ //
130
+ // Positioned as the very last block of the system prompt (after memory)
131
+ // because it changes on every session creation, which is more frequent
132
+ // than any other section: memory changes per dreaming/memory-logger cycle,
133
+ // gitNudge changes per session, but `now` changes per second. Pinning it
134
+ // to the tail means every byte UP TO this block stays in the provider's
135
+ // cache prefix across session resurrections, and only the trailing ~60
136
+ // bytes invalidate.
137
+ //
138
+ // The model still needs to know this is a session-creation snapshot, not
139
+ // a live clock: long-lived channel sessions can outlive the stamp by
140
+ // hours, and the resource loader is not re-rendered per turn (see the
141
+ // CreateSessionOptions doc at the top of src/agent/index.ts). The prose
142
+ // names the snapshot semantics and tells the model how to get a fresh
143
+ // reading when it matters (run `date` via bash).
144
+ export function renderNowBlock(now: Date): string {
145
+ const iso = formatLocalDateTime(now)
146
+ const zone = resolveLocalTimezoneName()
147
+ return `## Now
148
+
149
+ Session started at \`${iso}\` (${zone}). This is a session-creation snapshot, not a live clock — the value above does not advance during this session. If you need the current wall-clock time precisely (e.g. before scheduling a cron, replying with "it's 3pm", or computing a deadline), run \`date\` via bash instead of trusting this stamp; the container's timezone is set to the host's, so \`date\` returns the user's local time.`
150
+ }
151
+
118
152
  // Compact replacement for DEFAULT_SYSTEM_PROMPT, used by non-interactive
119
153
  // sessions (cron jobs, and default subagents that don't supply their own
120
154
  // `systemPromptOverride`). The full prompt is ~2155 tokens of operator-facing
@@ -125,14 +159,14 @@ TypeClaw runtime version: ${version}.`
125
159
  // What stays here is what survives without a human backstop, plus what no
126
160
  // runtime guard catches today:
127
161
  // 1. Runtime identity — names TypeClaw so the model can self-report.
128
- // 2. .env redaction — the one safety rule that compounds silently if dropped.
162
+ // 2. secrets.json/.env redaction — the one safety rule that compounds silently if dropped.
129
163
  // 3. Error/result honesty — the highest-risk drop. Unattended cron that
130
164
  // fabricates success or swallows errors damages real state. The security
131
165
  // plugin does not catch this.
132
166
  // 4. Output discipline — keeps tool-call narration from bloating the
133
167
  // ever-growing transcript that the next memory-logger pass has to read.
134
168
  // 5. Filesystem hygiene — workspace boundary, MEMORY.md ownership, and
135
- // runtime-managed paths (.env / sessions/ / memory/ / workspace/). The
169
+ // runtime-managed paths (secrets.json / .env / sessions/ / memory/ / workspace/). The
136
170
  // guard plugin blocks non-workspace writes for write/edit, but it
137
171
  // explicitly allows MEMORY.md writes and does not gate bash/git on the
138
172
  // runtime-managed paths.
@@ -149,12 +183,12 @@ TypeClaw runtime version: ${version}.`
149
183
  // to maintain its agent folder over time, and conversational register matters.
150
184
  export const SLIM_SYSTEM_PROMPT = `You are an AI agent running inside TypeClaw.
151
185
 
152
- Never echo secrets from \`.env\` or \`secrets.json\`, or any credential you see in the environment. Never include them in tool calls, logs, or commit messages.
186
+ Never echo secrets from \`secrets.json\` or \`.env\`, or any credential you see in the environment. Never include them in tool calls, logs, or commit messages.
153
187
 
154
188
  Never suppress errors to make things "work", and never fabricate results. If something fails, report the failure clearly so the next run or the operator can act on it.
155
189
 
156
190
  Do not narrate routine, low-risk tool calls — just call the tool. Do not over-explain what you did unless asked.
157
191
 
158
- Your free-write zone is \`workspace/\`. Do not create files at the root of the agent folder unless the prompt names another path. Do not edit \`MEMORY.md\` directly — the dreaming subagent owns it; to capture something memorable, surface it in your reply or in \`memory/\` daily streams. Never stage or commit \`.env\`, \`sessions/\`, \`memory/\`, or \`workspace/\` — those are runtime- or user-managed.
192
+ Your free-write zone is \`workspace/\`. Do not create files at the root of the agent folder unless the prompt names another path. Do not edit \`MEMORY.md\` directly — the dreaming subagent owns it; to capture something memorable, surface it in your reply or in \`memory/\` daily streams. Never stage or commit \`secrets.json\`, \`.env\`, \`sessions/\`, \`memory/\`, or \`workspace/\` — those are runtime- or user-managed.
159
193
 
160
194
  See the session-origin block below for what kind of session this is and what's expected of you.`
@@ -0,0 +1,186 @@
1
+ import type { DiscordGatewayInteractionEvent } from 'agent-messenger/discordbot'
2
+
3
+ import type { ChannelKey } from '@/channels/types'
4
+
5
+ const DISCORD_API_BASE = 'https://discord.com/api/v10'
6
+
7
+ // CHAT_INPUT is the only Discord application-command type that maps to the
8
+ // existing text-prefix command registry. USER (2) and MESSAGE (3) are
9
+ // right-click context-menu surfaces with no /name args equivalent — we don't
10
+ // register them and we drop their interactions.
11
+ const APPLICATION_COMMAND_TYPE_CHAT_INPUT = 1
12
+
13
+ // type 4 = CHANNEL_MESSAGE_WITH_SOURCE; flag 64 = EPHEMERAL (only the invoker
14
+ // sees it). Ephemeral keeps /stop replies out of the channel transcript.
15
+ // Discord drops the interaction with "This interaction failed" if we don't
16
+ // ack within ~3 seconds.
17
+ const INTERACTION_CALLBACK_TYPE_CHANNEL_MESSAGE_WITH_SOURCE = 4
18
+ const INTERACTION_MESSAGE_FLAG_EPHEMERAL = 64
19
+ export const DISCORD_INTERACTION_ACK_BUDGET_MS = 3000
20
+
21
+ export type DiscordCommandDeclaration = {
22
+ name: string
23
+ description: string
24
+ }
25
+
26
+ export type RegisterCommandsArgs = {
27
+ token: string
28
+ applicationId: string
29
+ commands: readonly DiscordCommandDeclaration[]
30
+ fetchImpl?: typeof fetch
31
+ }
32
+
33
+ export type RegisterCommandsResult = { ok: true } | { ok: false; error: string }
34
+
35
+ // Bulk-overwrite is idempotent — Discord replaces the entire registered set
36
+ // with whatever the body declares, so re-running `typeclaw start` with the
37
+ // same commands is a no-op server-side. Global (vs. per-guild) registration
38
+ // avoids the bot-needs-to-know-its-guilds bootstrap, at the cost of
39
+ // Discord's documented up-to-1-hour propagation for new commands. Text-
40
+ // prefix /stop continues to work the entire time, so the propagation
41
+ // window doesn't regress existing behavior.
42
+ //
43
+ // CAUTION: this PUT replaces ALL global commands on the application with the
44
+ // declared list. Sharing the bot application with another integration that
45
+ // also registers global commands would delete those commands. Don't share
46
+ // the application; TypeClaw owns the application's command set.
47
+ export async function registerCommands(args: RegisterCommandsArgs): Promise<RegisterCommandsResult> {
48
+ const fetchImpl = args.fetchImpl ?? fetch
49
+ const body = args.commands.map((cmd) => ({
50
+ name: cmd.name,
51
+ description: cmd.description,
52
+ type: APPLICATION_COMMAND_TYPE_CHAT_INPUT,
53
+ }))
54
+ try {
55
+ const res = await fetchImpl(`${DISCORD_API_BASE}/applications/${encodeURIComponent(args.applicationId)}/commands`, {
56
+ method: 'PUT',
57
+ headers: { Authorization: `Bot ${args.token}`, 'Content-Type': 'application/json' },
58
+ body: JSON.stringify(body),
59
+ })
60
+ if (!res.ok) {
61
+ const text = await res.text().catch(() => '')
62
+ return { ok: false, error: `http ${res.status}${text ? `: ${text.slice(0, 200)}` : ''}` }
63
+ }
64
+ return { ok: true }
65
+ } catch (err) {
66
+ return { ok: false, error: err instanceof Error ? err.message : String(err) }
67
+ }
68
+ }
69
+
70
+ export type ParsedSlashCommand = {
71
+ name: string
72
+ key: ChannelKey
73
+ invokerId: string
74
+ interactionId: string
75
+ interactionToken: string
76
+ }
77
+
78
+ export type ParseInteractionResult =
79
+ | { kind: 'parsed'; command: ParsedSlashCommand }
80
+ | { kind: 'ignore'; reason: 'not-application-command' | 'unknown-command' | 'no-invoker' | 'no-channel' }
81
+
82
+ export function parseInteractionAsCommand(
83
+ event: DiscordGatewayInteractionEvent,
84
+ knownCommands: ReadonlySet<string>,
85
+ ): ParseInteractionResult {
86
+ const data = event.data as { name?: string; type?: number } | undefined
87
+ if (!data || data.type !== APPLICATION_COMMAND_TYPE_CHAT_INPUT) {
88
+ return { kind: 'ignore', reason: 'not-application-command' }
89
+ }
90
+ const name = typeof data.name === 'string' ? data.name.toLowerCase() : ''
91
+ if (name === '' || !knownCommands.has(name)) {
92
+ return { kind: 'ignore', reason: 'unknown-command' }
93
+ }
94
+ // Guild interactions carry the invoker in member.user.id; DM interactions
95
+ // carry it in user.id. Exactly one is present.
96
+ const member = event.member as { user?: { id?: string } } | undefined
97
+ const invokerId = member?.user?.id ?? event.user?.id ?? ''
98
+ if (invokerId === '') {
99
+ return { kind: 'ignore', reason: 'no-invoker' }
100
+ }
101
+ if (typeof event.channel_id !== 'string' || event.channel_id === '') {
102
+ return { kind: 'ignore', reason: 'no-channel' }
103
+ }
104
+ // Mirror discord-bot-classify: DM workspace is '@dm', threads are stored
105
+ // as their channel id in `chat` with `thread: null` (Discord treats threads
106
+ // as channels; interaction.channel_id is the thread id when the user
107
+ // invoked from a thread).
108
+ const workspace = typeof event.guild_id === 'string' && event.guild_id !== '' ? event.guild_id : '@dm'
109
+ return {
110
+ kind: 'parsed',
111
+ command: {
112
+ name,
113
+ key: { adapter: 'discord-bot', workspace, chat: event.channel_id, thread: null },
114
+ invokerId,
115
+ interactionId: event.id,
116
+ interactionToken: event.token,
117
+ },
118
+ }
119
+ }
120
+
121
+ // Content is required even when there's nothing to stop, because Discord
122
+ // rejects empty CHANNEL_MESSAGE_WITH_SOURCE responses.
123
+ export function buildInteractionAck(content: string): {
124
+ type: number
125
+ data: { content: string; flags: number }
126
+ } {
127
+ return {
128
+ type: INTERACTION_CALLBACK_TYPE_CHANNEL_MESSAGE_WITH_SOURCE,
129
+ data: { content, flags: INTERACTION_MESSAGE_FLAG_EPHEMERAL },
130
+ }
131
+ }
132
+
133
+ export type AckInteractionArgs = {
134
+ interactionId: string
135
+ interactionToken: string
136
+ content: string
137
+ fetchImpl?: typeof fetch
138
+ }
139
+
140
+ export type AckInteractionResult = { ok: true } | { ok: false; error: string }
141
+
142
+ // Interaction acks must NOT carry the bot token — the interaction token in
143
+ // the URL is the only credential Discord expects on this endpoint, and
144
+ // adding Authorization sometimes triggers a 401.
145
+ //
146
+ // Errors are scrubbed before being returned: a thrown network error from
147
+ // fetch may include the full request URL (including the interaction token,
148
+ // which is a short-lived credential) in its message string depending on
149
+ // the runtime. We surface only the error class to avoid leaking the token
150
+ // into logs.
151
+ export async function ackInteraction(args: AckInteractionArgs): Promise<AckInteractionResult> {
152
+ const fetchImpl = args.fetchImpl ?? fetch
153
+ const body = buildInteractionAck(args.content)
154
+ try {
155
+ const res = await fetchImpl(
156
+ `${DISCORD_API_BASE}/interactions/${encodeURIComponent(args.interactionId)}/${encodeURIComponent(args.interactionToken)}/callback`,
157
+ {
158
+ method: 'POST',
159
+ headers: { 'Content-Type': 'application/json' },
160
+ body: JSON.stringify(body),
161
+ },
162
+ )
163
+ if (!res.ok) {
164
+ const text = await res.text().catch(() => '')
165
+ return { ok: false, error: `http ${res.status}${text ? `: ${text.slice(0, 200)}` : ''}` }
166
+ }
167
+ return { ok: true }
168
+ } catch (err) {
169
+ return { ok: false, error: `network error: ${sanitizeErrorName(err)}` }
170
+ }
171
+ }
172
+
173
+ // Returns the error class name without the message, so callers can log the
174
+ // failure mode without leaking URLs/tokens that some runtimes embed in
175
+ // error.message (e.g. Node's "fetch failed: TypeError: fetch failed,
176
+ // cause: Error: ... https://discord.com/api/v10/interactions/123/<token>/callback").
177
+ function sanitizeErrorName(err: unknown): string {
178
+ if (err instanceof Error) return err.name
179
+ return typeof err === 'string' ? 'string error' : 'unknown error'
180
+ }
181
+
182
+ export function synthesizeCommandText(name: string): string {
183
+ return `/${name}`
184
+ }
185
+
186
+ export const DISCORD_SLASH_COMMAND_TYPE_CHAT_INPUT = APPLICATION_COMMAND_TYPE_CHAT_INPUT
@@ -1,5 +1,9 @@
1
1
  import { DiscordBotClient, DiscordBotListener } from 'agent-messenger/discordbot'
2
- import { DiscordIntent, type DiscordGatewayMessageCreateEvent } from 'agent-messenger/discordbot'
2
+ import {
3
+ DiscordIntent,
4
+ type DiscordGatewayInteractionEvent,
5
+ type DiscordGatewayMessageCreateEvent,
6
+ } from 'agent-messenger/discordbot'
3
7
 
4
8
  import {
5
9
  MEMBERSHIP_ENUMERATION_CAP,
@@ -26,6 +30,26 @@ import type {
26
30
 
27
31
  import { createDiscordChannelResolver } from './discord-bot-channel-resolver'
28
32
  import { classifyInbound, type InboundDropReason } from './discord-bot-classify'
33
+ import {
34
+ ackInteraction,
35
+ parseInteractionAsCommand,
36
+ registerCommands,
37
+ type DiscordCommandDeclaration,
38
+ } from './discord-bot-slash-commands'
39
+
40
+ // One declared slash command per logical agent gesture. /stop maps to the
41
+ // existing channel-command of the same name in the router. Adding new
42
+ // commands here is the documented extension point: declare the entry here,
43
+ // then add the matching handler in createChannelRouter's command registry.
44
+ const SLASH_COMMANDS: readonly DiscordCommandDeclaration[] = [
45
+ { name: 'stop', description: 'Abort the current turn in this channel' },
46
+ ]
47
+ const SLASH_COMMAND_NAMES: ReadonlySet<string> = new Set(SLASH_COMMANDS.map((c) => c.name))
48
+
49
+ const STOP_REPLY_ABORTED = 'Stopped the current turn.'
50
+ const STOP_REPLY_NO_LIVE_SESSION = 'Nothing to stop — no active turn in this channel.'
51
+ const STOP_REPLY_FAILED = 'Could not stop the current turn (internal error).'
52
+ const STOP_REPLY_PERMISSION_DENIED = 'You do not have permission to stop the current turn in this channel.'
29
53
 
30
54
  const DISCORD_API_BASE = 'https://discord.com/api/v10'
31
55
 
@@ -66,6 +90,10 @@ export type DiscordBotAdapterOptions = {
66
90
  configRef: () => ChannelAdapterConfig
67
91
  token: string
68
92
  logger?: DiscordBotAdapterLogger
93
+ // Injectable for tests so adapter integration tests can assert on the
94
+ // exact REST calls without monkey-patching globalThis.fetch. Production
95
+ // callers leave it undefined to use the global fetch.
96
+ fetchImpl?: typeof fetch
69
97
  }
70
98
 
71
99
  export type DiscordBotAdapter = {
@@ -433,9 +461,91 @@ export function createFetchAttachmentCallback(deps: {
433
461
  }
434
462
  }
435
463
 
464
+ export type InteractionHandlerDeps = {
465
+ router: Pick<ChannelRouter, 'executeCommand'>
466
+ knownCommandNames: ReadonlySet<string>
467
+ logger: DiscordBotAdapterLogger
468
+ formatChannelTag: (workspace: string, chat: string) => Promise<string>
469
+ fetchImpl?: typeof fetch
470
+ }
471
+
472
+ export function createInteractionHandler(
473
+ deps: InteractionHandlerDeps,
474
+ ): (event: DiscordGatewayInteractionEvent) => Promise<void> {
475
+ const fetchImpl = deps.fetchImpl ?? fetch
476
+ return async (event) => {
477
+ try {
478
+ const parsed = parseInteractionAsCommand(event, deps.knownCommandNames)
479
+ if (parsed.kind === 'ignore') {
480
+ // 'not-application-command' is the common case (buttons, modals,
481
+ // autocomplete); emit at warn only when we dropped something we
482
+ // ostensibly handle.
483
+ if (parsed.reason !== 'not-application-command') {
484
+ deps.logger.warn(`[discord-bot] interaction id=${event.id} dropped reason=${parsed.reason}`)
485
+ }
486
+ return
487
+ }
488
+ const { command } = parsed
489
+
490
+ // Pre-ACK: emit ONE line with bare ids only (no formatChannelTag).
491
+ // Discord's 3s ack budget covers everything until the callback POST
492
+ // returns 2xx; name resolution involves two Discord REST calls that
493
+ // can blow the budget on a slow API minute. Decorative logging with
494
+ // resolved names happens AFTER the ack.
495
+ deps.logger.info(
496
+ `[discord-bot] interaction /${command.name} id=${event.id} invoker=${command.invokerId} guild=${command.key.workspace} channel=${command.key.chat}`,
497
+ )
498
+
499
+ const result = await deps.router.executeCommand(command.key, command.name, {
500
+ invokerId: command.invokerId,
501
+ })
502
+ const replyContent =
503
+ result.kind === 'handled'
504
+ ? STOP_REPLY_ABORTED
505
+ : result.kind === 'no-live-session'
506
+ ? STOP_REPLY_NO_LIVE_SESSION
507
+ : result.kind === 'permission-denied'
508
+ ? STOP_REPLY_PERMISSION_DENIED
509
+ : STOP_REPLY_FAILED
510
+
511
+ const ack = await ackInteraction({
512
+ interactionId: command.interactionId,
513
+ interactionToken: command.interactionToken,
514
+ content: replyContent,
515
+ fetchImpl,
516
+ })
517
+ if (!ack.ok) {
518
+ // Discord's interaction token is single-use per callback type and
519
+ // ~15min total; once we miss the 3s ack window the user sees
520
+ // "This interaction failed" in the UI. The abort still happened
521
+ // server-side — only the user-visible confirmation is lost.
522
+ deps.logger.warn(`[discord-bot] interaction /${command.name} ack failed: ${ack.error}`)
523
+ }
524
+
525
+ // Decorative post-ack logging: resolve channel/guild names now that
526
+ // the 3s budget is no longer a concern. Best-effort — if name
527
+ // resolution fails we already logged bare ids above.
528
+ try {
529
+ const inboundTag = await deps.formatChannelTag(command.key.workspace, command.key.chat)
530
+ deps.logger.info(`[discord-bot] interaction /${command.name} result=${result.kind} ${inboundTag}`)
531
+ } catch (err) {
532
+ deps.logger.info(
533
+ `[discord-bot] interaction /${command.name} result=${result.kind} (channel-tag resolution failed: ${describe(err)})`,
534
+ )
535
+ }
536
+ } catch (err) {
537
+ deps.logger.error(`[discord-bot] handleInteraction failed: ${describe(err)}`)
538
+ }
539
+ }
540
+ }
541
+
542
+ export const DISCORD_SLASH_COMMANDS = SLASH_COMMANDS
543
+ export const DISCORD_SLASH_COMMAND_NAMES = SLASH_COMMAND_NAMES
544
+
436
545
  export function createDiscordBotAdapter(options: DiscordBotAdapterOptions): DiscordBotAdapter {
437
546
  const logger = options.logger ?? consoleLogger
438
547
  const client = new DiscordBotClient()
548
+ const fetchImpl = options.fetchImpl ?? fetch
439
549
  let listener: DiscordBotListener | null = null
440
550
  let botUserId: string | null = null
441
551
  let started = false
@@ -479,6 +589,28 @@ export function createDiscordBotAdapter(options: DiscordBotAdapterOptions): Disc
479
589
 
480
590
  const fetchAttachmentCallback = createFetchAttachmentCallback({ token: options.token, logger })
481
591
 
592
+ const interactionHandler = createInteractionHandler({
593
+ router: options.router,
594
+ knownCommandNames: SLASH_COMMAND_NAMES,
595
+ logger,
596
+ formatChannelTag,
597
+ fetchImpl,
598
+ })
599
+
600
+ const handleInteractionCreate = async (event: DiscordGatewayInteractionEvent): Promise<void> => {
601
+ inflightInbounds++
602
+ try {
603
+ await interactionHandler(event)
604
+ } finally {
605
+ inflightInbounds--
606
+ if (inflightInbounds === 0 && stopWaiters.length > 0) {
607
+ const waiters = stopWaiters
608
+ stopWaiters = []
609
+ for (const w of waiters) w()
610
+ }
611
+ }
612
+ }
613
+
482
614
  const handleMessageCreate = async (event: DiscordGatewayMessageCreateEvent): Promise<void> => {
483
615
  inflightInbounds++
484
616
  try {
@@ -530,6 +662,33 @@ export function createDiscordBotAdapter(options: DiscordBotAdapterOptions): Disc
530
662
  listener.on('connected', (info) => {
531
663
  botUserId = info.user.id
532
664
  logger.info(`[discord-bot] connected as ${info.user.username} (${info.user.id})`)
665
+ // For bots, the gateway's user.id IS the application id — the same
666
+ // value is required for both /me lookups and /applications/{id}/
667
+ // commands. Fire-and-forget registration so a slow Discord API
668
+ // call (or a 403 from missing applications.commands scope) doesn't
669
+ // block the listener from receiving messages. Text-prefix /stop
670
+ // keeps working regardless.
671
+ void registerCommands({
672
+ token: options.token,
673
+ applicationId: info.user.id,
674
+ commands: SLASH_COMMANDS,
675
+ fetchImpl,
676
+ }).then((result) => {
677
+ if (result.ok) {
678
+ logger.info(
679
+ `[discord-bot] slash commands registered (${SLASH_COMMANDS.map((c) => `/${c.name}`).join(' ')})`,
680
+ )
681
+ } else {
682
+ // 403 here is almost always missing applications.commands scope
683
+ // on the OAuth invite URL — operator-fixable, but the listener
684
+ // continues. Adding the hint inline so an operator doesn't have
685
+ // to grep docs to recognize the failure mode.
686
+ logger.warn(
687
+ `[discord-bot] slash command registration failed: ${result.error}` +
688
+ ' (if 403, re-invite the bot with the applications.commands scope)',
689
+ )
690
+ }
691
+ })
533
692
  })
534
693
  listener.on('disconnected', () => {
535
694
  logger.warn('[discord-bot] disconnected; SDK will reconnect with backoff')
@@ -540,6 +699,9 @@ export function createDiscordBotAdapter(options: DiscordBotAdapterOptions): Disc
540
699
  listener.on('message_create', (event) => {
541
700
  void handleMessageCreate(event)
542
701
  })
702
+ listener.on('interaction_create', (event) => {
703
+ void handleInteractionCreate(event)
704
+ })
543
705
 
544
706
  options.router.registerOutbound('discord-bot', outboundCallback)
545
707
  options.router.registerTyping('discord-bot', typingCallback)
@@ -0,0 +1,82 @@
1
+ import type { SlackSocketModeSlashCommandArgs } from 'agent-messenger/slackbot'
2
+
3
+ import type { ChannelKey } from '@/channels/types'
4
+
5
+ // Slack channel ids: 'C' = public, 'G' = private/legacy multi-party DM,
6
+ // 'D' = direct message. Slash-command payloads don't carry `channel_type`,
7
+ // so we read the id prefix directly. The slack-bot inbound classifier uses
8
+ // `event.channel_type === 'im'` for the same purpose, but that field isn't
9
+ // in the slash-command body. Group DMs ('G' prefix) are NOT treated as DMs
10
+ // here — they map to `workspace: team_id` like a regular channel, matching
11
+ // how the inbound classifier handles MPIM messages (channel_type 'mpim'
12
+ // is not 'im' and therefore falls through to the team workspace branch).
13
+ const SLACK_DM_CHANNEL_PREFIXES: readonly string[] = ['D']
14
+
15
+ export type ParsedSlackSlashCommand = {
16
+ name: string
17
+ key: ChannelKey
18
+ invokerId: string
19
+ }
20
+
21
+ export type ParseSlashCommandResult =
22
+ | { kind: 'parsed'; command: ParsedSlackSlashCommand }
23
+ | { kind: 'ignore'; reason: 'unknown-command' | 'no-invoker' | 'no-channel' | 'no-team' | 'malformed' }
24
+
25
+ export function parseSlashCommand(
26
+ body: SlackSocketModeSlashCommandArgs['body'],
27
+ knownCommands: ReadonlySet<string>,
28
+ ): ParseSlashCommandResult {
29
+ if (typeof body.command !== 'string' || !body.command.startsWith('/')) {
30
+ return { kind: 'ignore', reason: 'malformed' }
31
+ }
32
+ const name = body.command.slice(1).toLowerCase()
33
+ if (name === '' || !knownCommands.has(name)) {
34
+ return { kind: 'ignore', reason: 'unknown-command' }
35
+ }
36
+ if (typeof body.user_id !== 'string' || body.user_id === '') {
37
+ return { kind: 'ignore', reason: 'no-invoker' }
38
+ }
39
+ if (typeof body.channel_id !== 'string' || body.channel_id === '') {
40
+ return { kind: 'ignore', reason: 'no-channel' }
41
+ }
42
+ // team_id is required for slash commands per Slack's API, but defensively
43
+ // refuse to construct a ChannelKey without it — otherwise the workspace
44
+ // field would collide with a real workspace id named '' downstream.
45
+ if (typeof body.team_id !== 'string' || body.team_id === '') {
46
+ return { kind: 'ignore', reason: 'no-team' }
47
+ }
48
+
49
+ const isDm = SLACK_DM_CHANNEL_PREFIXES.some((prefix) => body.channel_id.startsWith(prefix))
50
+ const workspace = isDm ? '@dm' : body.team_id
51
+
52
+ return {
53
+ kind: 'parsed',
54
+ command: {
55
+ name,
56
+ // thread is null because Slack slash commands cannot be invoked from
57
+ // inside a thread — Slack's compose box always targets the top-level
58
+ // channel. The router's executeCommand falls back to any live session
59
+ // in the same workspace+chat when an exact key match misses, so a
60
+ // thread-keyed live session still gets hit by a thread-less slash.
61
+ key: { adapter: 'slack-bot', workspace, chat: body.channel_id, thread: null },
62
+ invokerId: body.user_id,
63
+ },
64
+ }
65
+ }
66
+
67
+ export const SLACK_SLASH_REPLY_ABORTED = 'Stopped the current turn.'
68
+ export const SLACK_SLASH_REPLY_NO_LIVE_SESSION = 'Nothing to stop — no active turn in this channel.'
69
+ export const SLACK_SLASH_REPLY_FAILED = 'Could not stop the current turn (internal error).'
70
+ export const SLACK_SLASH_REPLY_PERMISSION_DENIED =
71
+ 'You do not have permission to stop the current turn in this channel.'
72
+ export const SLACK_SLASH_REPLY_AMBIGUOUS =
73
+ 'Multiple active turns in this channel. Reply `/stop` from inside the specific thread you want to stop.'
74
+
75
+ // Slack's ack callback accepts an optional response payload that becomes
76
+ // the user-visible reply. `response_type: 'ephemeral'` keeps the reply
77
+ // visible only to the invoker (vs. 'in_channel' which posts to everyone).
78
+ // Control gestures should stay ephemeral — same rationale as Discord's
79
+ // EPHEMERAL flag on interaction callbacks.
80
+ export function buildSlashAckPayload(text: string): { response_type: 'ephemeral'; text: string } {
81
+ return { response_type: 'ephemeral', text }
82
+ }