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.
- package/package.json +1 -1
- package/src/agent/index.ts +7 -0
- package/src/agent/live-subagents.ts +4 -0
- package/src/agent/restart/index.ts +101 -0
- package/src/agent/session-origin.ts +32 -10
- package/src/agent/tools/channel-react.ts +79 -0
- package/src/agent/tools/restart.ts +23 -52
- package/src/agent/tools/spawn-subagent.ts +1 -0
- package/src/agent/tools/subagent-access.ts +67 -0
- package/src/agent/tools/subagent-cancel.ts +11 -6
- package/src/agent/tools/subagent-output.ts +10 -2
- package/src/channels/adapters/discord-bot-classify.ts +8 -2
- package/src/channels/adapters/discord-bot.ts +265 -22
- package/src/channels/adapters/github/decoy-reviewer.ts +43 -0
- package/src/channels/adapters/github/inbound.ts +79 -0
- package/src/channels/adapters/github/index.ts +19 -0
- package/src/channels/adapters/github/permission-guidance.ts +20 -1
- package/src/channels/adapters/github/reactions.ts +276 -0
- package/src/channels/adapters/slack-bot-classify.ts +2 -2
- package/src/channels/adapters/slack-bot.ts +25 -2
- package/src/channels/engagement.ts +81 -44
- package/src/channels/router.ts +255 -18
- package/src/channels/types.ts +57 -0
- package/src/cli/builtins.ts +1 -0
- package/src/cli/dreams.ts +147 -0
- package/src/cli/index.ts +1 -0
- package/src/cli/inspect.ts +3 -0
- package/src/dreams/git.ts +85 -0
- package/src/dreams/index.ts +134 -0
- package/src/dreams/parse.ts +224 -0
- package/src/dreams/render.ts +155 -0
- package/src/dreams/types.ts +50 -0
- package/src/inspect/loop.ts +12 -1
- package/src/permissions/permissions.ts +24 -0
- package/src/server/index.ts +49 -0
- package/src/shared/protocol.ts +2 -0
- package/src/skills/typeclaw-channel-github/SKILL.md +6 -2
- package/src/tui/index.ts +70 -18
package/package.json
CHANGED
package/src/agent/index.ts
CHANGED
|
@@ -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
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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
|
-
? ' (
|
|
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)
|
|
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 {
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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: ${
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
68
|
-
|
|
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
|
-
|
|
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}`)
|