typeclaw 0.19.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/package.json +1 -1
  2. package/src/agent/index.ts +7 -0
  3. package/src/agent/live-subagents.ts +4 -0
  4. package/src/agent/restart/index.ts +101 -0
  5. package/src/agent/session-origin.ts +32 -10
  6. package/src/agent/tools/channel-react.ts +79 -0
  7. package/src/agent/tools/restart.ts +23 -52
  8. package/src/agent/tools/spawn-subagent.ts +1 -0
  9. package/src/agent/tools/subagent-access.ts +67 -0
  10. package/src/agent/tools/subagent-cancel.ts +11 -6
  11. package/src/agent/tools/subagent-output.ts +10 -2
  12. package/src/channels/adapters/discord-bot-classify.ts +8 -2
  13. package/src/channels/adapters/discord-bot.ts +265 -22
  14. package/src/channels/adapters/github/decoy-reviewer.ts +43 -0
  15. package/src/channels/adapters/github/inbound.ts +79 -0
  16. package/src/channels/adapters/github/index.ts +19 -0
  17. package/src/channels/adapters/github/permission-guidance.ts +20 -1
  18. package/src/channels/adapters/github/reactions.ts +276 -0
  19. package/src/channels/adapters/slack-bot-classify.ts +2 -2
  20. package/src/channels/adapters/slack-bot.ts +25 -2
  21. package/src/channels/engagement.ts +81 -44
  22. package/src/channels/router.ts +255 -18
  23. package/src/channels/types.ts +57 -0
  24. package/src/cli/builtins.ts +1 -0
  25. package/src/cli/dreams.ts +147 -0
  26. package/src/cli/index.ts +1 -0
  27. package/src/cli/inspect.ts +3 -0
  28. package/src/dreams/git.ts +85 -0
  29. package/src/dreams/index.ts +134 -0
  30. package/src/dreams/parse.ts +224 -0
  31. package/src/dreams/render.ts +155 -0
  32. package/src/dreams/types.ts +50 -0
  33. package/src/inspect/loop.ts +12 -1
  34. package/src/permissions/permissions.ts +24 -0
  35. package/src/server/index.ts +49 -0
  36. package/src/shared/protocol.ts +2 -0
  37. package/src/skills/typeclaw-channel-github/SKILL.md +6 -2
  38. package/src/tui/index.ts +70 -18
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "typeclaw",
3
- "version": "0.19.0",
3
+ "version": "0.21.0",
4
4
  "homepage": "https://github.com/typeclaw/typeclaw#readme",
5
5
  "bugs": {
6
6
  "url": "https://github.com/typeclaw/typeclaw/issues"
@@ -49,6 +49,7 @@ import {
49
49
  } from './tool-result-budget'
50
50
  import { createChannelFetchAttachmentTool } from './tools/channel-fetch-attachment'
51
51
  import { createChannelHistoryTool } from './tools/channel-history'
52
+ import { createChannelReactTool } from './tools/channel-react'
52
53
  import { createChannelReplyTool } from './tools/channel-reply'
53
54
  import { createChannelSendTool } from './tools/channel-send'
54
55
  import { createGrantRoleTool } from './tools/grant-role'
@@ -532,6 +533,12 @@ export function buildChannelTools(
532
533
  tools.push(createChannelReplyTool({ router: channelRouter, origin: channelOrigin }))
533
534
  tools.push(createChannelHistoryTool({ router: channelRouter, origin: channelOrigin }))
534
535
  tools.push(createChannelSendTool({ router: channelRouter, origin: channelOrigin }))
536
+ tools.push(
537
+ createChannelReactTool({
538
+ router: channelRouter,
539
+ origin: { ...channelOrigin, ...(origin.reactionRef !== undefined ? { reactionRef: origin.reactionRef } : {}) },
540
+ }),
541
+ )
535
542
  tools.push(
536
543
  createChannelFetchAttachmentTool({
537
544
  router: channelRouter,
@@ -19,6 +19,10 @@ export type LiveSubagent = {
19
19
  sessionId: string
20
20
  subagentName: string
21
21
  parentSessionId?: string
22
+ // Role that resolved at spawn time, captured for the provenance cap on
23
+ // subagent_output/subagent_cancel. Absent when no permission service was
24
+ // active at spawn, in which case the cap fails closed.
25
+ spawnedByRole?: string
22
26
  startedAt: number
23
27
  status: SubagentStatus
24
28
  completion?: SubagentCompletion
@@ -0,0 +1,101 @@
1
+ import { basename } from 'node:path'
2
+
3
+ import { writeRestartHandoff } from '@/agent/restart-handoff'
4
+ import { send, sendHttp } from '@/hostd/client'
5
+ import { containerSocketPath } from '@/hostd/paths'
6
+ import type { Stream } from '@/stream'
7
+
8
+ const ACK_TIMEOUT_MS = 5_000
9
+
10
+ export type ContainerRestartingBroadcast = {
11
+ kind: 'container-restarting'
12
+ restartedAt: string
13
+ originatingSessionId: string
14
+ }
15
+
16
+ export type RequestContainerRestartOptions = {
17
+ containerName: string
18
+ build?: boolean
19
+ socketPath?: string
20
+ hostdUrl?: string
21
+ hostdToken?: string
22
+ ackTimeoutMs?: number
23
+ // When present together with originatingSessionId, the post-ACK
24
+ // container-restarting broadcast is published here so every live session's
25
+ // subscribeRestartNotice fans out the restart notice (originator gets
26
+ // typeclaw.restart-self, siblings get typeclaw.restart). Both the tool and
27
+ // the server /restart path route through this so the broadcast->handoff
28
+ // ordering lives in one place.
29
+ stream?: Stream
30
+ agentDir?: string
31
+ originatingSessionId?: string
32
+ originatingSessionFile?: string
33
+ restartedAt?: string
34
+ }
35
+
36
+ export type RequestContainerRestartResult =
37
+ | { ok: true; containerName: string; restartedAt: string }
38
+ | { ok: false; containerName: string; reason: string }
39
+
40
+ export async function requestContainerRestart({
41
+ containerName,
42
+ build,
43
+ socketPath,
44
+ hostdUrl,
45
+ hostdToken,
46
+ ackTimeoutMs,
47
+ stream,
48
+ agentDir,
49
+ originatingSessionId,
50
+ originatingSessionFile,
51
+ restartedAt,
52
+ }: RequestContainerRestartOptions): Promise<RequestContainerRestartResult> {
53
+ const request = { kind: 'restart' as const, containerName, build: build === true }
54
+ const httpUrl = hostdUrl ?? process.env.TYPECLAW_HOSTD_URL
55
+ const httpToken = hostdToken ?? process.env.TYPECLAW_HOSTD_TOKEN
56
+ const ackBudget = ackTimeoutMs ?? ACK_TIMEOUT_MS
57
+ const reply =
58
+ httpUrl && httpToken
59
+ ? await sendHttp(request, { timeoutMs: ackBudget, url: httpUrl, token: httpToken })
60
+ : await send(request, { timeoutMs: ackBudget, socket: socketPath ?? containerSocketPath() })
61
+
62
+ if (!reply.ok) return { ok: false, containerName, reason: reply.reason }
63
+
64
+ const restartTimestamp = restartedAt ?? new Date().toISOString()
65
+
66
+ // Fan out the restart notice to every live session BEFORE writing the handoff.
67
+ // The originating session's subscribeRestartNotice appends the
68
+ // typeclaw.restart-self entry synchronously (broker delivery + the JSONL
69
+ // append are both synchronous), so the handoff below points at a JSONL that
70
+ // already carries the "I'm back" instruction the rebooted container hydrates.
71
+ // Only after an accepted ACK, never on a failed/timed-out restart.
72
+ if (stream !== undefined && originatingSessionId !== undefined) {
73
+ const broadcast: ContainerRestartingBroadcast = {
74
+ kind: 'container-restarting',
75
+ restartedAt: restartTimestamp,
76
+ originatingSessionId,
77
+ }
78
+ stream.publish({ target: { kind: 'broadcast' }, payload: broadcast })
79
+ }
80
+
81
+ // Post-ACK: hostd has committed the restart, so a handoff-write failure must
82
+ // never demote it to a failure — that would render a false error in the TUI
83
+ // and swallow the accepted response. The handoff is a best-effort resume hint
84
+ // only; a missing one just cold-starts the rebooted container without the
85
+ // "I'm back" greeting. writeRestartHandoff swallows its own errors today, but
86
+ // guard here too so this contract survives the writer being changed later.
87
+ if (agentDir !== undefined && originatingSessionId !== undefined && originatingSessionFile !== undefined) {
88
+ try {
89
+ await writeRestartHandoff(agentDir, {
90
+ schemaVersion: 1,
91
+ restartedAt: restartTimestamp,
92
+ originatingSessionId,
93
+ originatingSessionFile: basename(originatingSessionFile),
94
+ })
95
+ } catch {
96
+ // intentional swallow — see the post-ACK rationale above
97
+ }
98
+ }
99
+
100
+ return { ok: true, containerName, restartedAt: restartTimestamp }
101
+ }
@@ -1,5 +1,6 @@
1
1
  import { MEMBERSHIP_FRESHNESS_MS, type MembershipCount } from '@/channels/membership'
2
2
  import type { AdapterId } from '@/channels/schema'
3
+ import type { ReactionRef } from '@/channels/types'
3
4
 
4
5
  export type ChannelParticipant = {
5
6
  authorId: string
@@ -38,6 +39,7 @@ export type SessionOrigin =
38
39
  chatName?: string
39
40
  thread: string | null
40
41
  lastInboundAuthorId?: string
42
+ reactionRef?: ReactionRef
41
43
  participants?: readonly ChannelParticipant[]
42
44
  membership?: MembershipCount
43
45
  }
@@ -301,6 +303,13 @@ function renderChannelOrigin(
301
303
  'audience: post the answer (or review summary) itself, never a status',
302
304
  'line about having posted it elsewhere. A narrated "Posted review result',
303
305
  'for PR #N: …" inside the PR is exactly the failure to avoid.',
306
+ '',
307
+ '**Do not post an "On it" acknowledgment comment.** The runtime already',
308
+ 'adds an :eyes: reaction to the triggering item the moment it engages, so a',
309
+ 'separate "looking into this" comment is redundant noise on the PR. If you',
310
+ 'want to signal acknowledgment explicitly, use `channel_react({ emoji })`',
311
+ '(it reacts, it does not comment) — never a text ack. Reserve `channel_reply`',
312
+ 'for the actual substantive answer.',
304
313
  )
305
314
  }
306
315
 
@@ -339,12 +348,21 @@ function renderChannelOrigin(
339
348
  '`channel_reply` call, not narration. This includes acks.',
340
349
  '',
341
350
  '**One substantive reply per inbound.** If the answer needs more than one',
342
- 'tool call, send a one-line ack first via `channel_reply({ text: "On it.",',
343
- 'continue: true })`, keep working, then send the answer with a final',
344
- '`channel_reply`. The ack is not your reply; the answer is. Once the answer',
345
- 'lands, end your turn. The `continue: true` is not optional on that ack:',
346
- 'without it the turn ends the instant the ack lands and the rest of your',
347
- 'work the fetch, the subagent, the actual answer is silently dropped.',
351
+ ...(origin.adapter === 'github'
352
+ ? [
353
+ 'tool call, keep working and post the answer with a single final',
354
+ '`channel_reply`. Do not post an "On it" ack comment first the runtime',
355
+ 'already added an :eyes: reaction on engage; use `channel_react` if you',
356
+ 'want to acknowledge explicitly. The answer is your reply.',
357
+ ]
358
+ : [
359
+ 'tool call, send a one-line ack first via `channel_reply({ text: "On it.",',
360
+ 'continue: true })`, keep working, then send the answer with a final',
361
+ '`channel_reply`. The ack is not your reply; the answer is. Once the answer',
362
+ 'lands, end your turn. The `continue: true` is not optional on that ack:',
363
+ 'without it the turn ends the instant the ack lands and the rest of your',
364
+ 'work — the fetch, the subagent, the actual answer — is silently dropped.',
365
+ ]),
348
366
  '',
349
367
  '**Backgrounded work does not end the obligation.** If you spawn a',
350
368
  'subagent with `run_in_background: true` to answer the current inbound,',
@@ -400,15 +418,19 @@ function renderMembershipSummary(
400
418
  if (membership === undefined) return null
401
419
 
402
420
  const total = membership.humans + membership.bots
421
+ // Exact Discord counts are channel-scoped (filtered by who can VIEW_CHANNEL),
422
+ // so the count is the channel's room, not the guild. The truncated branch is
423
+ // history-derived recent speakers, which is not a channel-membership claim,
424
+ // so the caveat would mislead there.
425
+ const isExact = !membership.truncated && now - membership.fetchedAt < MEMBERSHIP_FRESHNESS_MS
403
426
  const caveat =
404
- origin.adapter === 'discord-bot' && origin.workspace !== '@dm'
405
- ? ' (Note: this is the count of guild members; private channels with permission overwrites may have fewer actual viewers.)'
427
+ isExact && origin.adapter === 'discord-bot' && origin.workspace !== '@dm'
428
+ ? ' (This counts only members who can view this channel, not the whole guild.)'
406
429
  : ''
407
- const isExact = !membership.truncated && now - membership.fetchedAt < MEMBERSHIP_FRESHNESS_MS
408
430
  if (isExact) {
409
431
  return `This channel has ${total} members: ${membership.humans} humans, ${membership.bots} bots.${caveat} The 10 most recent speakers are listed below.`
410
432
  }
411
- return `This channel has approximately ${total} members (about ${membership.humans} humans, ${membership.bots} bots — the bot count is approximate, the full member list was not enumerated because it exceeds the 50-member cap).${caveat} The 10 most recent speakers are listed below.`
433
+ return `This channel has approximately ${total} members (about ${membership.humans} humans, ${membership.bots} bots — the bot count is approximate, the full member list was not enumerated because it exceeds the 50-member cap). The 10 most recent speakers are listed below.`
412
434
  }
413
435
 
414
436
  function renderMentionGuidance(
@@ -0,0 +1,79 @@
1
+ import { Type } from '@mariozechner/pi-ai'
2
+ import { defineTool } from '@mariozechner/pi-coding-agent'
3
+
4
+ import type { ChannelRouter } from '@/channels/router'
5
+ import type { AdapterId } from '@/channels/schema'
6
+ import type { ReactionRef } from '@/channels/types'
7
+
8
+ import { type ChannelToolLogger, consoleChannelLogger, formatChannelToolFailure } from './channel-log'
9
+ import { TOOL_RESULT_PREFIX } from './channel-reply'
10
+
11
+ export type ChannelReactOrigin = {
12
+ adapter: AdapterId
13
+ workspace: string
14
+ chat: string
15
+ thread: string | null
16
+ reactionRef?: ReactionRef
17
+ }
18
+
19
+ export type CreateChannelReactToolOptions = {
20
+ router: ChannelRouter
21
+ origin: ChannelReactOrigin
22
+ logger?: ChannelToolLogger
23
+ }
24
+
25
+ export function createChannelReactTool({
26
+ router,
27
+ origin,
28
+ logger = consoleChannelLogger,
29
+ }: CreateChannelReactToolOptions) {
30
+ return defineTool({
31
+ name: 'channel_react',
32
+ label: 'Channel React',
33
+ description:
34
+ 'Add an emoji reaction to the message that triggered this turn — a lightweight acknowledgment that does not post a comment. ' +
35
+ 'On GitHub this reacts to the triggering issue/PR/comment (e.g. :eyes: to signal "I am looking at this"). ' +
36
+ 'Use this instead of a textual "on it" reply when a reaction is enough. Pass the bare emoji name, no colons.',
37
+ parameters: Type.Object({
38
+ emoji: Type.String({
39
+ description: 'Bare emoji name, no surrounding colons. e.g. "eyes", "+1", "rocket", "heart".',
40
+ minLength: 1,
41
+ }),
42
+ }),
43
+
44
+ async execute(_toolCallId, params) {
45
+ const deny = (error: string) => {
46
+ logger.warn(formatChannelToolFailure('channel_react', error))
47
+ const details: { ok: boolean; error?: string } = { ok: false, error }
48
+ return {
49
+ content: [{ type: 'text' as const, text: `${TOOL_RESULT_PREFIX}channel_react denied: ${error}` }],
50
+ details,
51
+ }
52
+ }
53
+
54
+ if (origin.reactionRef === undefined) return deny('this conversation has no message to react to')
55
+
56
+ const result = await router.react({
57
+ adapter: origin.adapter,
58
+ workspace: origin.workspace,
59
+ chat: origin.chat,
60
+ thread: origin.thread,
61
+ reactionRef: origin.reactionRef,
62
+ emoji: params.emoji,
63
+ })
64
+
65
+ if (!result.ok) return deny(`${origin.adapter}:${origin.workspace}/${origin.chat}: ${result.error}`)
66
+
67
+ const details: { ok: boolean; error?: string } = { ok: true }
68
+ return {
69
+ content: [
70
+ {
71
+ type: 'text' as const,
72
+ text: `${TOOL_RESULT_PREFIX}reacted with :${params.emoji}: on ${origin.adapter}:${origin.workspace}/${origin.chat}`,
73
+ },
74
+ ],
75
+ details,
76
+ }
77
+ },
78
+ })
79
+ }
@@ -1,14 +1,9 @@
1
- import { basename } from 'node:path'
2
-
3
1
  import { Type } from '@mariozechner/pi-ai'
4
2
  import { defineTool } from '@mariozechner/pi-coding-agent'
5
3
 
6
- import { writeRestartHandoff } from '@/agent/restart-handoff'
7
- import { send, sendHttp } from '@/hostd/client'
8
- import { containerSocketPath } from '@/hostd/paths'
4
+ import { requestContainerRestart } from '@/agent/restart'
9
5
  import type { Stream } from '@/stream'
10
6
 
11
- const ACK_TIMEOUT_MS = 5_000
12
7
  const EXIT_DELAY_MS = 500
13
8
 
14
9
  export type CreateRestartToolOptions = {
@@ -61,11 +56,7 @@ export type CreateRestartToolOptions = {
61
56
 
62
57
  export type RestartToolDetails = { ok: boolean; containerName: string; reason?: string }
63
58
 
64
- export type ContainerRestartingBroadcast = {
65
- kind: 'container-restarting'
66
- restartedAt: string
67
- originatingSessionId: string
68
- }
59
+ export type { ContainerRestartingBroadcast } from '@/agent/restart'
69
60
 
70
61
  export function createRestartTool({
71
62
  containerName,
@@ -80,9 +71,6 @@ export function createRestartTool({
80
71
  originatingSessionFile,
81
72
  }: CreateRestartToolOptions) {
82
73
  const doExit = exit ?? ((code: number) => process.exit(code))
83
- const httpUrl = hostdUrl ?? process.env.TYPECLAW_HOSTD_URL
84
- const ackBudget = ackTimeoutMs ?? ACK_TIMEOUT_MS
85
- const httpToken = hostdToken ?? process.env.TYPECLAW_HOSTD_TOKEN
86
74
 
87
75
  return defineTool({
88
76
  name: 'restart',
@@ -109,49 +97,32 @@ export function createRestartTool({
109
97
  }),
110
98
  async execute(_toolCallId, params) {
111
99
  const build = params.build === true
112
- const request = { kind: 'restart' as const, containerName, build }
113
- const reply =
114
- httpUrl && httpToken
115
- ? await sendHttp(request, { timeoutMs: ackBudget, url: httpUrl, token: httpToken })
116
- : await send(request, { timeoutMs: ackBudget, socket: socketPath ?? containerSocketPath() })
117
- if (!reply.ok) {
118
- const details: RestartToolDetails = { ok: false, containerName, reason: reply.reason }
100
+ // requestContainerRestart owns the post-ACK broadcast->handoff ordering:
101
+ // on a successful ACK it publishes the container-restarting notice (which
102
+ // every live session's subscribeRestartNotice turns into a transcript
103
+ // entry) and then writes the handoff. Handoff fields are gated to TUI
104
+ // origins by the caller passing originatingSessionFile only for those
105
+ // see issue #291's scoping concerns.
106
+ const result = await requestContainerRestart({
107
+ containerName,
108
+ build,
109
+ originatingSessionId,
110
+ ...(socketPath !== undefined ? { socketPath } : {}),
111
+ ...(hostdUrl !== undefined ? { hostdUrl } : {}),
112
+ ...(hostdToken !== undefined ? { hostdToken } : {}),
113
+ ...(ackTimeoutMs !== undefined ? { ackTimeoutMs } : {}),
114
+ ...(stream !== undefined ? { stream } : {}),
115
+ ...(agentDir !== undefined ? { agentDir } : {}),
116
+ ...(originatingSessionFile !== undefined ? { originatingSessionFile } : {}),
117
+ })
118
+ if (!result.ok) {
119
+ const details: RestartToolDetails = { ok: false, containerName, reason: result.reason }
119
120
  return {
120
- content: [{ type: 'text' as const, text: `restart denied: ${reply.reason}` }],
121
+ content: [{ type: 'text' as const, text: `restart denied: ${result.reason}` }],
121
122
  details,
122
123
  }
123
124
  }
124
125
 
125
- // Hostd ACK == restart is committed. Fan out the notice to every live
126
- // session BEFORE arming the exit timer. Stream broker delivery is
127
- // synchronous (broker.ts deliver()) and SessionManager.appendCustomMessageEntry
128
- // does a synchronous JSONL write, so the fan-out completes inside this
129
- // tick — well before the EXIT_DELAY_MS timer fires.
130
- const restartedAt = new Date().toISOString()
131
- const broadcast: ContainerRestartingBroadcast = {
132
- kind: 'container-restarting',
133
- restartedAt,
134
- originatingSessionId,
135
- }
136
- stream?.publish({ target: { kind: 'broadcast' }, payload: broadcast })
137
-
138
- // Write the cross-restart handoff AFTER the broadcast has run so the
139
- // originating session's JSONL already contains the `typeclaw.restart-self`
140
- // custom message entry that the next container will hydrate on
141
- // `SessionManager.open`. Without that ordering, the new container could
142
- // theoretically open the JSONL before the entry was flushed and miss
143
- // the model-instruction the entry carries. Gated on agentDir +
144
- // originatingSessionFile so non-TUI origins (channel/cron/subagent)
145
- // skip the file write — see issue #291's scoping concerns.
146
- if (agentDir !== undefined && originatingSessionFile !== undefined) {
147
- await writeRestartHandoff(agentDir, {
148
- schemaVersion: 1,
149
- restartedAt,
150
- originatingSessionId,
151
- originatingSessionFile: basename(originatingSessionFile),
152
- })
153
- }
154
-
155
126
  // Schedule the exit on the next tick so the tool result is delivered to
156
127
  // the model before the process dies. The host daemon polls for the
157
128
  // container's removal before re-running `start`, so a small delay here
@@ -129,6 +129,7 @@ export function createSpawnSubagentTool(options: CreateSpawnSubagentToolOptions)
129
129
  sessionId: resolvedHandle.sessionId ?? '<pending>',
130
130
  subagentName,
131
131
  parentSessionId,
132
+ ...(spawnedByRole !== undefined ? { spawnedByRole } : {}),
132
133
  startedAt,
133
134
  status: 'running' as const,
134
135
  abort: resolvedHandle.abort,
@@ -0,0 +1,67 @@
1
+ import type { PermissionService } from '@/permissions'
2
+
3
+ import type { LiveSubagent, LiveSubagentRegistry } from '../live-subagents'
4
+ import type { SessionOrigin } from '../session-origin'
5
+
6
+ export type SubagentAccessPermission = 'subagent.output' | 'subagent.cancel'
7
+
8
+ export type SubagentAccessResult = { ok: true; live: LiveSubagent } | { ok: false; message: string }
9
+
10
+ export type AuthorizeLiveSubagentAccessArgs = {
11
+ permissions: PermissionService | undefined
12
+ origin: SessionOrigin | undefined
13
+ liveRegistry: LiveSubagentRegistry
14
+ taskId: string
15
+ permission: SubagentAccessPermission
16
+ }
17
+
18
+ // Authorizes a single subagent_output/subagent_cancel call and resolves the
19
+ // live entry in one place so the two tools cannot drift. Caps access to the
20
+ // requester's role: the caller must hold the permission AND resolve to a role
21
+ // at least as high as the role that spawned the subagent.
22
+ //
23
+ // The ordering closes an existence oracle: the task-independent base-permission
24
+ // check runs BEFORE any registry lookup, and for non-owner callers an absent
25
+ // task, a capped task, and a task with missing provenance all collapse to one
26
+ // identical denial — so a lower-role caller cannot probe which task IDs are
27
+ // live. Only `owner` (the trust root, which outranks every spawner) learns the
28
+ // truthful `Unknown task_id` for a genuine miss. The cap fails closed.
29
+ export function authorizeLiveSubagentAccess(args: AuthorizeLiveSubagentAccessArgs): SubagentAccessResult {
30
+ const { permissions, origin, liveRegistry, taskId, permission } = args
31
+
32
+ if (permissions === undefined) {
33
+ const live = liveRegistry.get(taskId)
34
+ if (live === undefined) {
35
+ return { ok: false, message: `Unknown task_id: ${taskId}.` }
36
+ }
37
+ return { ok: true, live }
38
+ }
39
+
40
+ if (!permissions.has(origin, permission)) {
41
+ return { ok: false, message: `${permission} denied: insufficient permissions` }
42
+ }
43
+
44
+ const requesterRole = permissions.resolveRole(origin)
45
+ const accessAll = requesterRole === 'owner'
46
+ const opaqueDenial = `${permission} denied: unknown task_id or insufficient role`
47
+
48
+ const live = liveRegistry.get(taskId)
49
+ if (live === undefined) {
50
+ return { ok: false, message: accessAll ? `Unknown task_id: ${taskId}.` : opaqueDenial }
51
+ }
52
+ if (accessAll) {
53
+ return { ok: true, live }
54
+ }
55
+
56
+ const spawnedByRole = live.spawnedByRole
57
+ if (spawnedByRole === undefined) {
58
+ return { ok: false, message: opaqueDenial }
59
+ }
60
+
61
+ const cmp = permissions.compareRoleSeverity(requesterRole, spawnedByRole)
62
+ if (cmp === undefined || cmp < 0) {
63
+ return { ok: false, message: opaqueDenial }
64
+ }
65
+
66
+ return { ok: true, live }
67
+ }
@@ -5,6 +5,7 @@ import type { PermissionService } from '@/permissions'
5
5
 
6
6
  import type { LiveSubagentRegistry } from '../live-subagents'
7
7
  import type { SessionOrigin } from '../session-origin'
8
+ import { authorizeLiveSubagentAccess } from './subagent-access'
8
9
 
9
10
  export type SubagentCancelToolDetails =
10
11
  | { ok: true; taskId: string; subagent: string; alreadyDone: boolean }
@@ -33,13 +34,17 @@ export function createSubagentCancelTool(options: CreateSubagentCancelToolOption
33
34
  }),
34
35
 
35
36
  async execute(_toolCallId, params): Promise<ToolReturn> {
36
- if (permissions !== undefined && !permissions.has(getOrigin(), 'subagent.cancel')) {
37
- return errorResult('subagent.cancel denied: insufficient permissions')
38
- }
39
- const live = liveRegistry.get(params.task_id)
40
- if (live === undefined) {
41
- return errorResult(`Unknown task_id: ${params.task_id}.`)
37
+ const access = authorizeLiveSubagentAccess({
38
+ permissions,
39
+ origin: getOrigin(),
40
+ liveRegistry,
41
+ taskId: params.task_id,
42
+ permission: 'subagent.cancel',
43
+ })
44
+ if (!access.ok) {
45
+ return errorResult(access.message)
42
46
  }
47
+ const live = access.live
43
48
  if (live.status !== 'running') {
44
49
  const details: SubagentCancelToolDetails = {
45
50
  ok: true,
@@ -5,6 +5,7 @@ import type { PermissionService } from '@/permissions'
5
5
 
6
6
  import type { LiveSubagentRegistry, StatusSnapshot, SubagentProgressEvent } from '../live-subagents'
7
7
  import type { SessionOrigin } from '../session-origin'
8
+ import { authorizeLiveSubagentAccess } from './subagent-access'
8
9
 
9
10
  export type SubagentOutputToolDetails =
10
11
  | {
@@ -64,8 +65,15 @@ export function createSubagentOutputTool(options: CreateSubagentOutputToolOption
64
65
  }),
65
66
 
66
67
  async execute(_toolCallId, params) {
67
- if (permissions !== undefined && !permissions.has(getOrigin(), 'subagent.output')) {
68
- return errorResult('subagent.output denied: insufficient permissions')
68
+ const access = authorizeLiveSubagentAccess({
69
+ permissions,
70
+ origin: getOrigin(),
71
+ liveRegistry,
72
+ taskId: params.task_id,
73
+ permission: 'subagent.output',
74
+ })
75
+ if (!access.ok) {
76
+ return errorResult(access.message)
69
77
  }
70
78
  const snap = liveRegistry.snapshot(params.task_id, now())
71
79
  if (snap === undefined) {
@@ -141,7 +141,13 @@ function splitInbound(event: DiscordGatewayMessageCreateEvent): SplitInbound {
141
141
  return { text, attachments }
142
142
  }
143
143
 
144
- function describeDiscordMedia(event: DiscordGatewayMessageCreateEvent): InboundAttachment[] {
144
+ export type DiscordMediaCarrier = {
145
+ attachments?: DiscordFile[]
146
+ embeds?: DiscordGatewayEmbed[]
147
+ sticker_items?: DiscordGatewayStickerItem[]
148
+ }
149
+
150
+ export function describeDiscordMedia(event: DiscordMediaCarrier): InboundAttachment[] {
145
151
  return [
146
152
  ...(event.attachments ?? []).map(describeAttachment),
147
153
  ...(event.embeds ?? []).map(describeEmbed),
@@ -167,7 +173,7 @@ function describeSticker(sticker: DiscordGatewayStickerItem): Omit<InboundAttach
167
173
  return { kind: 'sticker', ref: '', filename: sticker.name }
168
174
  }
169
175
 
170
- function renderPlaceholder(attachment: InboundAttachment): string {
176
+ export function renderPlaceholder(attachment: InboundAttachment): string {
171
177
  const parts: string[] = [`Discord attachment #${attachment.id}: ${attachment.kind}`]
172
178
  if (attachment.mimetype !== undefined) parts.push(attachment.mimetype)
173
179
  if (attachment.filename !== undefined) parts.push(`name=${attachment.filename}`)