typeclaw 0.16.0 → 0.17.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "typeclaw",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
4
4
  "homepage": "https://github.com/typeclaw/typeclaw#readme",
5
5
  "bugs": {
6
6
  "url": "https://github.com/typeclaw/typeclaw/issues"
@@ -9,7 +9,7 @@ import { loadMemory } from '@/bundled-plugins/memory/load-memory'
9
9
  import type { ChannelRouter } from '@/channels/router'
10
10
  import { getConfig, resolveModel, resolveProfile } from '@/config'
11
11
  import { defaultThinkingLevelForRef, providerForModelRef, type KnownModelRef } from '@/config/providers'
12
- import type { PermissionService } from '@/permissions'
12
+ import type { PermissionService, RolesConfig } from '@/permissions'
13
13
  import type {
14
14
  BuiltinToolRef,
15
15
  HookBus,
@@ -50,6 +50,7 @@ import { createChannelFetchAttachmentTool } from './tools/channel-fetch-attachme
50
50
  import { createChannelHistoryTool } from './tools/channel-history'
51
51
  import { createChannelReplyTool } from './tools/channel-reply'
52
52
  import { createChannelSendTool } from './tools/channel-send'
53
+ import { createGrantRoleTool } from './tools/grant-role'
53
54
  import { createRestartTool } from './tools/restart'
54
55
  import { createSkipResponseTool } from './tools/skip-response'
55
56
  import { createSpawnSubagentTool } from './tools/spawn-subagent'
@@ -141,6 +142,11 @@ export type CreateSessionOptions = {
141
142
  // prompt is not regenerated; see `typeclaw-permissions` skill for how the
142
143
  // agent should interpret the snapshot on later turns.
143
144
  permissions?: PermissionService
145
+ // Re-reads roles from disk for the grant_role tool's hot-reload after a match
146
+ // grant. Production threads a reload-then-read (reloadConfig + getConfig);
147
+ // must not be an in-memory snapshot or the grant reapplies stale roles.
148
+ // Omitted when no grant_role tool is wired (the tool requires permissions).
149
+ reloadRoles?: () => RolesConfig | undefined
144
150
  // Model profile name. Resolved against `config.models` to pick the concrete
145
151
  // model ref this session binds to. Unknown profile names fall back to
146
152
  // `default` with a one-time console warning. Omitted → `default`. Threaded
@@ -322,6 +328,12 @@ export async function createSessionWithDispose(options: CreateSessionOptions = {
322
328
  permissions: options.permissions,
323
329
  stream: options.stream,
324
330
  }),
331
+ ...buildRoleGrantTools({
332
+ agentDir: options.plugins?.agentDir,
333
+ getOrigin,
334
+ permissions: options.permissions,
335
+ reloadRoles: options.reloadRoles,
336
+ }),
325
337
  ]
326
338
  // Hook coverage for pi's builtin coding tools (read/bash/edit/write/grep/
327
339
  // find/ls) — pi 0.67.3 ignores `tools:` for implementation, so the only
@@ -577,6 +589,25 @@ export function buildSubagentOrchestrationTools(opts: {
577
589
  ]
578
590
  }
579
591
 
592
+ export function buildRoleGrantTools(opts: {
593
+ agentDir: string | undefined
594
+ getOrigin: () => SessionOrigin | undefined
595
+ permissions: PermissionService | undefined
596
+ reloadRoles: (() => RolesConfig | undefined) | undefined
597
+ }): ToolDefinition[] {
598
+ if (opts.agentDir === undefined || opts.permissions === undefined || opts.reloadRoles === undefined) {
599
+ return []
600
+ }
601
+ return [
602
+ createGrantRoleTool({
603
+ agentDir: opts.agentDir,
604
+ getOrigin: opts.getOrigin,
605
+ permissions: opts.permissions,
606
+ reloadRoles: opts.reloadRoles,
607
+ }),
608
+ ]
609
+ }
610
+
580
611
  function wrapRegistryTools(
581
612
  plugins: PluginSessionWiring | undefined,
582
613
  getOrigin: () => SessionOrigin | undefined,
@@ -111,11 +111,12 @@ function getPlatformInfo(adapter: AdapterId): PlatformInfo {
111
111
  // TUI is always `owner` by construction — annotating it would add noise to
112
112
  // every interactive session for zero new information.
113
113
  //
114
- // For channel sessions this is a session-creation snapshot. The router
115
- // re-resolves per-turn for tool gating, but the system prompt is not
116
- // regenerated mid-session; the role line is accurate at admission and the
117
- // `typeclaw-permissions` skill spells out how to interpret it on later
118
- // turns when a different speaker may have spoken last.
114
+ // Channel origins do NOT render this concrete role. A channel session is
115
+ // keyed by chat/thread, so the opener's role is wrong for every later
116
+ // speaker and printing their permission list leaks it into shared context.
117
+ // Channel origins render renderChannelRolePolicy() instead, and the
118
+ // authoritative per-turn role rides in the non-cacheable `<your-role>`
119
+ // turn anchor (renderTurnRoleAnchor in system-prompt.ts).
119
120
  export type SessionRoleContext = {
120
121
  role: string
121
122
  permissions: readonly string[]
@@ -128,21 +129,22 @@ export function renderSessionOrigin(
128
129
  ): string {
129
130
  switch (origin.kind) {
130
131
  case 'tui':
131
- return withRoleContext(renderTuiOrigin(), roleContext)
132
+ return withRoleContext(renderTuiOrigin(), roleContext, origin.kind)
132
133
  case 'cron':
133
- return withRoleContext(renderCronOrigin(origin), roleContext)
134
+ return withRoleContext(renderCronOrigin(origin), roleContext, origin.kind)
134
135
  case 'channel':
135
- return withRoleContext(renderChannelOrigin(origin, now), roleContext)
136
+ return withRoleContext(renderChannelOrigin(origin, now), roleContext, origin.kind)
136
137
  case 'subagent':
137
- return withRoleContext(renderSubagentOrigin(origin), roleContext)
138
+ return withRoleContext(renderSubagentOrigin(origin), roleContext, origin.kind)
138
139
  case 'system':
139
- return withRoleContext(renderSystemOrigin(origin), roleContext)
140
+ return withRoleContext(renderSystemOrigin(origin), roleContext, origin.kind)
140
141
  }
141
142
  }
142
143
 
143
- function withRoleContext(block: string, ctx: SessionRoleContext | undefined): string {
144
+ function withRoleContext(block: string, ctx: SessionRoleContext | undefined, kind: SessionOrigin['kind']): string {
144
145
  if (ctx === undefined) return block
145
- return `${block}\n\n${renderRoleContext(ctx)}`
146
+ const roleBlock = kind === 'channel' ? renderChannelRolePolicy() : renderRoleContext(ctx)
147
+ return `${block}\n\n${roleBlock}`
146
148
  }
147
149
 
148
150
  function renderRoleContext(ctx: SessionRoleContext): string {
@@ -160,6 +162,30 @@ function renderRoleContext(ctx: SessionRoleContext): string {
160
162
  ].join('\n')
161
163
  }
162
164
 
165
+ // Channel sessions are keyed by chat/thread, not by author: one session can see
166
+ // many speakers with different roles. Rendering the opener's concrete role here
167
+ // would (1) be wrong for every later speaker and (2) leak the opener's full
168
+ // permission list into shared context. So channel origins get a cache-stable
169
+ // policy instead of a resolved identity; the authoritative per-turn role rides
170
+ // in the non-cacheable `<your-role>` turn anchor.
171
+ function renderChannelRolePolicy(): string {
172
+ return [
173
+ '## Your role in this session',
174
+ '',
175
+ 'This is a channel conversation that may include multiple speakers. Do not',
176
+ 'assume one speaker’s role applies to later messages. For each user turn the',
177
+ 'current speaker’s effective role is provided in the turn context as a',
178
+ '`<your-role>` tag; that per-turn role is authoritative for the current',
179
+ 'message and overrides any role implied by session-opening context. An absent',
180
+ '`<your-role>` tag means the current speaker is the unconstrained default.',
181
+ '',
182
+ 'Tool calls and channel admission are gated by the current speaker’s',
183
+ 'permissions; a `blocked:` or "denied by permissions" message means that',
184
+ 'speaker lacks the permission the guard wanted. See the',
185
+ '`typeclaw-permissions` skill for what each role can do.',
186
+ ].join('\n')
187
+ }
188
+
163
189
  function renderTuiOrigin(): string {
164
190
  return [
165
191
  '## Session origin',
@@ -262,6 +288,22 @@ function renderChannelOrigin(
262
288
  'is a tool call. Plain-text output is invisible.',
263
289
  ]
264
290
 
291
+ // GitHub has no separate "chat" surface — channel_reply IS a public comment
292
+ // on this PR/issue. Without saying so, models default to the Slack-style
293
+ // two-surface split and post operator-facing meta-commentary ("Posted review
294
+ // result for PR #511") straight into the PR thread, where it reads absurdly.
295
+ if (origin.adapter === 'github') {
296
+ lines.push(
297
+ '',
298
+ '**`channel_reply` posts a public comment directly on this PR/issue.** It',
299
+ 'is not a side-report to an operator — the reply lands in this exact',
300
+ 'thread, read by everyone on the PR. Write the substance for that',
301
+ 'audience: post the answer (or review summary) itself, never a status',
302
+ 'line about having posted it elsewhere. A narrated "Posted review result',
303
+ 'for PR #N: …" inside the PR is exactly the failure to avoid.',
304
+ )
305
+ }
306
+
265
307
  const conversationLine = renderConversationLine(origin)
266
308
  if (conversationLine !== null) lines.push('', conversationLine)
267
309
 
@@ -173,7 +173,7 @@ export function renderTurnTimeAnchor(now: Date = new Date()): string {
173
173
  // block for a TUI owner.
174
174
  export function renderTurnRoleAnchor(role: string): string | undefined {
175
175
  if (role === 'owner') return undefined
176
- return `<your-role>${role}</your-role>`
176
+ return `<your-role authority="current-speaker">${role}</your-role> (authoritative for this message; overrides any role implied by the system prompt)`
177
177
  }
178
178
 
179
179
  // Compact replacement for DEFAULT_SYSTEM_PROMPT, used by non-interactive
@@ -0,0 +1,214 @@
1
+ import { Type } from '@mariozechner/pi-ai'
2
+ import { defineTool } from '@mariozechner/pi-coding-agent'
3
+
4
+ import {
5
+ grantRole,
6
+ grantRolePermission,
7
+ isDmChannelOrigin,
8
+ parseMatchRule,
9
+ type PermissionService,
10
+ type RolesConfig,
11
+ } from '@/permissions'
12
+
13
+ import type { SessionOrigin } from '../session-origin'
14
+
15
+ export type GrantRoleToolDetails =
16
+ | { ok: true; mode: 'match' | 'permission'; role: string; value: string; added: boolean; restartRequired: boolean }
17
+ | { ok: false; error: string }
18
+
19
+ export type CreateGrantRoleToolOptions = {
20
+ agentDir: string
21
+ getOrigin: () => SessionOrigin | undefined
22
+ permissions: PermissionService
23
+ // Re-reads roles FROM DISK and returns the fresh set, for hot-reloading a
24
+ // match-grant after grantRole writes typeclaw.json. Must NOT read an
25
+ // in-memory config snapshot: grantRole writes the file directly, so a
26
+ // snapshot taken before the live config pointer is reloaded would be stale
27
+ // and replaceRoles would reapply the pre-grant table. Production wires this
28
+ // to reloadConfig(agentDir) + getConfig().roles, matching the config
29
+ // reloadable. A permission-grant is restart-required, so the reload has no
30
+ // runtime effect for it, but we reload anyway to keep a same-session
31
+ // subsequent match-grant reading a consistent table.
32
+ reloadRoles: () => RolesConfig | undefined
33
+ }
34
+
35
+ // Roles this tool may target, lowest to highest. `guest` is a valid target
36
+ // (an operator opening guest channel.respond) but never a granter — the gate
37
+ // below requires the caller to resolve to owner/trusted.
38
+ const TIER_ORDER = ['guest', 'member', 'trusted', 'owner'] as const
39
+ type TierRole = (typeof TIER_ORDER)[number]
40
+
41
+ function tierOf(role: string): number {
42
+ return TIER_ORDER.indexOf(role as TierRole)
43
+ }
44
+
45
+ function isTierRole(role: string): role is TierRole {
46
+ return (TIER_ORDER as readonly string[]).includes(role)
47
+ }
48
+
49
+ // A single-principal turn carries only the principal's own first-party words:
50
+ // the TUI (a human typing directly) or a 1:1 DM (principal + bot, no
51
+ // third-party messages buffered in). A group/open channel turn always mixes in
52
+ // other authors' messages, which is the confused-deputy surface that lets a
53
+ // guest prompt-inject a trusted turn into rewriting the access-control table.
54
+ // Role grants are confined to single-principal turns so that surface does not
55
+ // exist.
56
+ function isSinglePrincipalOrigin(origin: SessionOrigin | undefined): boolean {
57
+ if (origin === undefined) return false
58
+ if (origin.kind === 'tui') return true
59
+ if (origin.kind === 'channel') return isDmChannelOrigin(origin)
60
+ return false
61
+ }
62
+
63
+ export function createGrantRoleTool(options: CreateGrantRoleToolOptions) {
64
+ const { agentDir, getOrigin, permissions, reloadRoles } = options
65
+
66
+ return defineTool({
67
+ name: 'grant_role',
68
+ label: 'Grant Role',
69
+ description:
70
+ 'Assign an author to a role (match grant) or give a role a capability (permission grant), by editing typeclaw.json#roles. ' +
71
+ 'Use this to onboard a teammate ("respond to author U_X" → grant them member) or to open the agent to a wider audience ' +
72
+ '("let anyone in this channel message you" → grant guest channel.respond). ' +
73
+ 'Only callable from the TUI or a 1:1 DM by an owner or trusted user — group-channel turns cannot use it. ' +
74
+ 'Permission grants are restart-required: they land in typeclaw.json but take effect on the next `typeclaw restart`.',
75
+ parameters: Type.Object({
76
+ role: Type.Union(
77
+ [Type.Literal('owner'), Type.Literal('trusted'), Type.Literal('member'), Type.Literal('guest')],
78
+ {
79
+ description: 'The role to grant TO.',
80
+ },
81
+ ),
82
+ match: Type.Optional(
83
+ Type.String({
84
+ description:
85
+ 'A match rule assigning an author/scope to the role, e.g. "slack:T0123 author:U_WIFE". ' +
86
+ 'Provide exactly one of match or permission.',
87
+ }),
88
+ ),
89
+ permission: Type.Optional(
90
+ Type.String({
91
+ description:
92
+ 'A capability to add to the role, e.g. "channel.respond". Provide exactly one of match or permission. ' +
93
+ 'security.bypass.* permissions cannot be granted through this tool.',
94
+ }),
95
+ ),
96
+ }),
97
+
98
+ async execute(_toolCallId, params): Promise<ToolReturn> {
99
+ const origin = getOrigin()
100
+
101
+ if (!isSinglePrincipalOrigin(origin)) {
102
+ return err(
103
+ 'grant_role is only available from the TUI or a 1:1 DM. A group-channel turn cannot change roles, ' +
104
+ 'because it mixes in other participants\u2019 messages (prompt-injection surface).',
105
+ )
106
+ }
107
+
108
+ const callerRole = permissions.resolveRole(origin)
109
+ if (callerRole !== 'owner' && callerRole !== 'trusted') {
110
+ return err(`grant_role denied: caller resolves to '${callerRole}'; only owner or trusted may grant roles.`)
111
+ }
112
+
113
+ const hasMatch = typeof params.match === 'string' && params.match.length > 0
114
+ const hasPermission = typeof params.permission === 'string' && params.permission.length > 0
115
+ if (hasMatch === hasPermission) {
116
+ return err('Provide exactly one of `match` or `permission`.')
117
+ }
118
+
119
+ if (!isTierRole(params.role)) {
120
+ return err(`Unknown target role '${params.role}'.`)
121
+ }
122
+
123
+ // Tier ceiling: a granter may not assign or empower a role ABOVE its own.
124
+ // owner(3) ≥ trusted(2) ≥ member(1) ≥ guest(0).
125
+ if (tierOf(params.role) > tierOf(callerRole)) {
126
+ return err(`grant_role denied: a ${callerRole} caller cannot grant the higher '${params.role}' role.`)
127
+ }
128
+
129
+ return hasMatch
130
+ ? grantMatch(params.role, params.match as string)
131
+ : grantPermission(callerRole, params.role, params.permission as string)
132
+ },
133
+ })
134
+
135
+ function grantMatch(role: string, matchRule: string): ToolReturn {
136
+ const parsed = parseMatchRule(matchRule)
137
+ if (!parsed.ok) {
138
+ return err(`Invalid match rule '${matchRule}': ${parsed.error}`)
139
+ }
140
+
141
+ const result = grantRole({ cwd: agentDir, roleName: role, matchRule })
142
+ if (!result.ok) return err(result.reason)
143
+
144
+ reload()
145
+ return ok('match', role, matchRule, result.added, false)
146
+ }
147
+
148
+ function grantPermission(callerRole: string, role: string, permission: string): ToolReturn {
149
+ // security.bypass.* defeats guards rather than enabling a feature; never
150
+ // grantable through an agent tool. It stays a deliberate hand-edit gated by
151
+ // the rolePromotion guard + an explicit ack.
152
+ if (permission.startsWith('security.bypass.')) {
153
+ return err(
154
+ `grant_role refuses to grant '${permission}': security.bypass.* permissions disable security guards and ` +
155
+ 'must be set by hand-editing typeclaw.json (gated by the rolePromotion guard), not via this tool.',
156
+ )
157
+ }
158
+
159
+ // "Grant only what you hold": the caller cannot confer a capability it does
160
+ // not itself possess. Owner's resolved set is the expanded wildcard, so an
161
+ // owner can grant any non-bypass core permission; trusted is capped at its
162
+ // literal set.
163
+ const callerPerms = permissions.describe(getOrigin()).permissions
164
+ if (!callerPerms.includes(permission)) {
165
+ return err(
166
+ `grant_role denied: a ${callerRole} caller cannot grant '${permission}' because it does not hold that permission.`,
167
+ )
168
+ }
169
+
170
+ const result = grantRolePermission({ cwd: agentDir, roleName: role, permission })
171
+ if (!result.ok) return err(result.reason)
172
+
173
+ reload()
174
+ return ok('permission', role, permission, result.added, true)
175
+ }
176
+
177
+ function reload(): void {
178
+ try {
179
+ permissions.replaceRoles(reloadRoles())
180
+ } catch {
181
+ // Best-effort hot-reload of match-grants. A failure here does not undo
182
+ // the on-disk write; the next config reload / restart picks it up.
183
+ }
184
+ }
185
+ }
186
+
187
+ function ok(
188
+ mode: 'match' | 'permission',
189
+ role: string,
190
+ value: string,
191
+ added: boolean,
192
+ restartRequired: boolean,
193
+ ): ToolReturn {
194
+ const details: GrantRoleToolDetails = { ok: true, mode, role, value, added, restartRequired }
195
+ const note = added ? '' : ' (already on file)'
196
+ const restart = restartRequired
197
+ ? ' This permission grant is restart-required; run `typeclaw restart` for it to take effect.'
198
+ : ''
199
+ const text =
200
+ mode === 'match'
201
+ ? `Granted role '${role}' the match rule '${value}'${note}.${restart}`
202
+ : `Granted role '${role}' the permission '${value}'${note}.${restart}`
203
+ return { content: [{ type: 'text', text }], details }
204
+ }
205
+
206
+ function err(message: string): ToolReturn {
207
+ const details: GrantRoleToolDetails = { ok: false, error: message }
208
+ return { content: [{ type: 'text', text: message }], details }
209
+ }
210
+
211
+ type ToolReturn = {
212
+ content: { type: 'text'; text: string }[]
213
+ details: GrantRoleToolDetails
214
+ }
@@ -164,6 +164,19 @@ export const SESSION_FRESHNESS_TTL_MS = 5 * 60 * 1000
164
164
  // instead of awaiting the same dead promise forever.
165
165
  export const ENSURE_LIVE_TIMEOUT_MS = 30_000
166
166
 
167
+ // Thrown by ensureLive() when a teardown (roles reload or shutdown) raced
168
+ // ahead of an in-flight creation. route() has no special handling — it
169
+ // propagates to the adapter's outer catch, dropping this one inbound. The
170
+ // next inbound creates a fresh, post-reload session, which is the intended
171
+ // outcome: a message that arrived mid-reload is cheap to drop, far cheaper
172
+ // than answering it through a session built with the stale role.
173
+ export class StaleLiveSessionError extends Error {
174
+ constructor(keyId: string) {
175
+ super(`[channels] ${keyId}: live session creation raced a teardown; discarded`)
176
+ this.name = 'StaleLiveSessionError'
177
+ }
178
+ }
179
+
167
180
  // Per-callback ceilings inside the ensureLive chain. The outer watchdog
168
181
  // catches the worst case, but per-step timeouts give better log
169
182
  // attribution (which step hung) AND graceful degradation: a hung name
@@ -562,6 +575,7 @@ export type ChannelRouter = {
562
575
  | { kind: 'recorded-after-send'; keyId: string }
563
576
  | { kind: 'no-live-session' }
564
577
  stop: () => Promise<void>
578
+ tearDownAllLive: () => Promise<void>
565
579
  liveCount: () => number
566
580
  __testing?: {
567
581
  flushDebounce: (key: ChannelKey) => Promise<void>
@@ -691,6 +705,14 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
691
705
  const stream = options.stream
692
706
  const liveSessions = new Map<string, LiveSession>()
693
707
  const creating = new Map<string, Promise<LiveSession>>()
708
+ // Bumped by tearDownAllLive() and stop() before they tear sessions down. An
709
+ // in-flight ensureLive() captures the value at creation start and re-checks
710
+ // it right before installing into liveSessions; if it changed, a teardown
711
+ // raced ahead of this creation (e.g. a roles.match reload), so the session
712
+ // was built with stale role context and must self-dispose instead of
713
+ // installing — otherwise it would reintroduce the very staleness the
714
+ // teardown was meant to clear.
715
+ let liveGeneration = 0
694
716
  const outboundCallbacks = new Map<ChannelKey['adapter'], Set<OutboundCallback>>()
695
717
  const typingCallbacks = new Map<ChannelKey['adapter'], Set<TypingCallback>>()
696
718
  const channelNameResolvers = new Map<ChannelKey['adapter'], Set<ChannelNameResolver>>()
@@ -909,6 +931,8 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
909
931
  const inFlight = creating.get(keyId)
910
932
  if (inFlight) return inFlight
911
933
 
934
+ const generation = liveGeneration
935
+
912
936
  const promise = (async () => {
913
937
  await ensureLoaded()
914
938
  const record = mappings ? findRecord(mappings, key) : undefined
@@ -1073,6 +1097,17 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1073
1097
  live.unsubTypingActivity = subscribeTypingActivity(created.session, live)
1074
1098
  installChannelReplyTerminalHook(live)
1075
1099
  installChannelOutputCap(live)
1100
+
1101
+ // A teardown (roles reload / shutdown) ran while this session was being
1102
+ // built, so it carries stale role context. Dispose it instead of
1103
+ // installing — installing here is the exact window the race exploits.
1104
+ if (generation !== liveGeneration) {
1105
+ logger.info(
1106
+ `[channels] ${keyId}: discarding session created across a teardown (gen ${generation} → ${liveGeneration})`,
1107
+ )
1108
+ await tearDownLive(live)
1109
+ throw new StaleLiveSessionError(keyId)
1110
+ }
1076
1111
  liveSessions.set(keyId, live)
1077
1112
 
1078
1113
  if (isColdStart) {
@@ -1632,6 +1667,12 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1632
1667
  logger.info(`[channels] ${keyId}: ignoring unknown command /${parsedCommand.name}`)
1633
1668
  return
1634
1669
  }
1670
+ if (isSessionControlDenied(event)) {
1671
+ logger.info(
1672
+ `[channels] ${keyId}: denied command /${parsedCommand.name} by permissions (session.control) author=${event.authorId}`,
1673
+ )
1674
+ return
1675
+ }
1635
1676
  const existingLive = liveSessions.get(keyId)
1636
1677
  if (!existingLive || existingLive.destroyed) {
1637
1678
  logger.info(`[channels] ${keyId}: ignoring command /${parsedCommand.name} with no live session`)
@@ -1714,17 +1755,24 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1714
1755
  scheduleDebouncedDrain(live)
1715
1756
  }
1716
1757
 
1717
- const isChannelRespondDenied = (event: InboundMessage): boolean => {
1718
- const partial: SessionOrigin = {
1719
- kind: 'channel',
1720
- adapter: event.adapter,
1721
- workspace: event.workspace,
1722
- chat: event.chat,
1723
- thread: event.thread,
1724
- lastInboundAuthorId: event.authorId,
1725
- }
1726
- return !permissions.has(partial, CORE_PERMISSIONS.channelRespond)
1727
- }
1758
+ const inboundAuthorOrigin = (event: InboundMessage): SessionOrigin => ({
1759
+ kind: 'channel',
1760
+ adapter: event.adapter,
1761
+ workspace: event.workspace,
1762
+ chat: event.chat,
1763
+ thread: event.thread,
1764
+ lastInboundAuthorId: event.authorId,
1765
+ })
1766
+
1767
+ const isChannelRespondDenied = (event: InboundMessage): boolean =>
1768
+ !permissions.has(inboundAuthorOrigin(event), CORE_PERMISSIONS.channelRespond)
1769
+
1770
+ // Gated separately from channelRespond so a respond-capable guest (an
1771
+ // operator can grant guest channelRespond for masked stranger turns)
1772
+ // cannot /stop another speaker's in-flight turn. session.control is
1773
+ // member-and-up by default.
1774
+ const isSessionControlDenied = (event: InboundMessage): boolean =>
1775
+ !permissions.has(inboundAuthorOrigin(event), CORE_PERMISSIONS.sessionControl)
1728
1776
 
1729
1777
  const updateLoopGuard = (live: LiveSession, event: InboundMessage): void => {
1730
1778
  if (!event.authorIsBot) {
@@ -2334,6 +2382,27 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2334
2382
  const stop = async (): Promise<void> => {
2335
2383
  if (gcTimer) clearInterval(gcTimer)
2336
2384
  gcTimer = null
2385
+ liveGeneration++
2386
+ const all = Array.from(liveSessions.values())
2387
+ liveSessions.clear()
2388
+ for (const live of all) {
2389
+ await tearDownLive(live)
2390
+ }
2391
+ }
2392
+
2393
+ // Drops every in-memory session but KEEPS the on-disk records, so the next
2394
+ // inbound per channel rehydrates the same transcript through a fresh
2395
+ // createSession() — which re-renders the frozen system-prompt role block.
2396
+ // This is how a `roles.<name>.match` reload reaches live channel sessions.
2397
+ // Unlike stop() it leaves the GC timer running; unlike stale-rollover it
2398
+ // keeps the sessionId, so history survives.
2399
+ //
2400
+ // Bumping liveGeneration BEFORE the snapshot is what makes this race-free:
2401
+ // a session mid-creation (in `creating` but not yet in `liveSessions`) won't
2402
+ // appear in the snapshot below, but it captured the old generation and will
2403
+ // self-dispose at its install guard instead of resurrecting stale role state.
2404
+ const tearDownAllLive = async (): Promise<void> => {
2405
+ liveGeneration++
2337
2406
  const all = Array.from(liveSessions.values())
2338
2407
  liveSessions.clear()
2339
2408
  for (const live of all) {
@@ -2350,11 +2419,11 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2350
2419
  if (!commands.has(lowered)) {
2351
2420
  return { kind: 'unknown-command', name: lowered }
2352
2421
  }
2353
- // Permission gate runs BEFORE the live-session lookup so a guest user
2354
- // invoking /stop on a non-existent session gets 'permission-denied'
2355
- // (consistent answer regardless of session state) rather than leaking
2356
- // session presence via the 'no-live-session' vs 'permission-denied'
2357
- // distinction.
2422
+ // Gates on session.control (not channel.respond) so a respond-capable
2423
+ // guest cannot abort another speaker's turn. Runs BEFORE the live-session
2424
+ // lookup so an unauthorized invoker gets 'permission-denied' regardless of
2425
+ // session state, rather than leaking session presence via the
2426
+ // 'no-live-session' vs 'permission-denied' distinction.
2358
2427
  const partial: SessionOrigin = {
2359
2428
  kind: 'channel',
2360
2429
  adapter: key.adapter,
@@ -2363,7 +2432,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2363
2432
  thread: key.thread,
2364
2433
  lastInboundAuthorId: options.invokerId,
2365
2434
  }
2366
- if (!permissions.has(partial, CORE_PERMISSIONS.channelRespond)) {
2435
+ if (!permissions.has(partial, CORE_PERMISSIONS.sessionControl)) {
2367
2436
  return { kind: 'permission-denied' }
2368
2437
  }
2369
2438
  const resolved = resolveLiveSessionForCommand(liveSessions, key)
@@ -2476,6 +2545,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2476
2545
  injectSubagentCompletionReminder,
2477
2546
  markTurnSkipped,
2478
2547
  stop,
2548
+ tearDownAllLive,
2479
2549
  liveCount: () => liveSessions.size,
2480
2550
  __testing: {
2481
2551
  flushDebounce: async (key: ChannelKey) => {
package/src/cli/role.ts CHANGED
@@ -3,7 +3,7 @@ import { defineCommand } from 'citty'
3
3
 
4
4
  import { requireContainerRunning, resolveHostPort, resolveTuiToken } from '@/container'
5
5
  import { findAgentDir } from '@/init'
6
- import { runClaimSession } from '@/role-claim'
6
+ import { reloadAfterClaim, runClaimSession } from '@/role-claim'
7
7
 
8
8
  import { c, errorLine } from './ui'
9
9
 
@@ -76,6 +76,15 @@ const claimSub = defineCommand({
76
76
 
77
77
  if (result.kind === 'completed') {
78
78
  s.stop(c.green(`Paired as ${result.payload.role}.`))
79
+ s.start('Reloading config so the new match rule takes effect...')
80
+ const reloaded = await reloadAfterClaim({ url })
81
+ if (reloaded.ok) {
82
+ s.stop(c.green('Config reloaded.'))
83
+ } else {
84
+ // The role is already persisted; a reload failure is non-fatal.
85
+ s.stop(c.yellow(`Config reload failed: ${reloaded.reason}`))
86
+ console.log(c.dim('Run `typeclaw reload` manually to apply the new match rule.'))
87
+ }
79
88
  outro(`Match rule added: ${c.bold(result.payload.matchRule)}`)
80
89
  return
81
90
  }
package/src/cli/ui.ts CHANGED
@@ -252,16 +252,18 @@ export function printSlackAppManifestSetup(output: NodeJS.WritableStream = proce
252
252
  // the exact permission bitfield the adapter uses. No-ops when the token isn't
253
253
  // parseable as a Discord bot token so we never block onboarding on best-effort
254
254
  // guidance.
255
- export function printDiscordInviteHint(token: string): void {
255
+ export function printDiscordInviteHint(token: string, output: NodeJS.WritableStream = process.stdout): void {
256
256
  const appId = deriveAppIdFromBotToken(token)
257
257
  if (appId === null) return
258
+ // URL stays OUT of note(): clack wraps long lines with a `│` gutter that
259
+ // corrupts copy-pasted URLs. Same fix as src/cli/oauth-callbacks.ts.
258
260
  note(
259
261
  [
260
- buildDiscordInviteUrl(appId),
261
- '',
262
- 'Open it, pick a server, click Authorize.',
262
+ 'Open the URL below, pick a server, click Authorize.',
263
263
  "The bot won't receive messages until it's in at least one server.",
264
264
  ].join('\n'),
265
265
  'Invite the bot to a server',
266
266
  )
267
+ output.write(`${buildDiscordInviteUrl(appId)}\n`)
268
+ output.write('\n')
267
269
  }
@@ -11,6 +11,10 @@ export type CreateConfigReloadableOptions = {
11
11
  // hand-edits) take effect without a container restart. `roles.<name>.permissions`
12
12
  // changes still require a restart — see FIELD_EFFECTS in config.ts.
13
13
  permissions?: PermissionService
14
+ // Fired after replaceRoles when a `roles.<name>.match` edit is applied. The
15
+ // run stage wires this to the channel router so live sessions are recreated
16
+ // and pick up the new role in their (otherwise frozen) system prompt.
17
+ onRolesChanged?: () => void | Promise<void>
14
18
  // Skip the mount-path accessibility check inside validateConfig. Mount paths
15
19
  // in typeclaw.json are host paths — they don't resolve inside the container,
16
20
  // so the check would always fail on any agent that declares mounts. `mounts`
@@ -22,18 +26,20 @@ export type CreateConfigReloadableOptions = {
22
26
  export function createConfigReloadable({
23
27
  cwd,
24
28
  permissions,
29
+ onRolesChanged,
25
30
  skipMountValidation = false,
26
31
  }: CreateConfigReloadableOptions): Reloadable {
27
32
  return {
28
33
  scope: 'config',
29
34
  description: 'typeclaw.json runtime config',
30
- reload: async () => doReload(cwd, permissions, skipMountValidation),
35
+ reload: async () => doReload(cwd, permissions, onRolesChanged, skipMountValidation),
31
36
  }
32
37
  }
33
38
 
34
39
  async function doReload(
35
40
  cwd: string,
36
41
  permissions: PermissionService | undefined,
42
+ onRolesChanged: (() => void | Promise<void>) | undefined,
37
43
  skipMountValidation: boolean,
38
44
  ): Promise<ReloadResult> {
39
45
  // Mount accessibility belongs to the validation surface, not loadConfigSync —
@@ -59,8 +65,9 @@ async function doReload(
59
65
  return { scope: 'config', ok: false, reason: message }
60
66
  }
61
67
 
62
- if (permissions !== undefined && diff.applied.some((c) => c.path === 'roles.match')) {
63
- permissions.replaceRoles(getConfig().roles)
68
+ if (diff.applied.some((c) => c.path === 'roles.match')) {
69
+ permissions?.replaceRoles(getConfig().roles)
70
+ await onRolesChanged?.()
64
71
  }
65
72
 
66
73
  return {
package/src/init/index.ts CHANGED
@@ -39,14 +39,6 @@ const CONFIG_FILE = 'typeclaw.json'
39
39
  const CRON_FILE = 'cron.json'
40
40
  const PACKAGE_FILE = 'package.json'
41
41
 
42
- // Seeded into `typeclaw.json#roles.member.match[]` whenever a chat adapter
43
- // (slack-bot, discord-bot, telegram-bot, kakaotalk) is wired. The "*" rule
44
- // matches every channel session on every platform, so the built-in `member`
45
- // role (which already carries `channel.respond`) covers any inbound the
46
- // router sees. Without this, freshly-hatched agents silently drop every
47
- // chat message — see scaffold() and ensureDefaultChatMemberMatch() below.
48
- const DEFAULT_CHAT_MEMBER_MATCH_RULE = '*'
49
-
50
42
  const MARKDOWN_FILES = ['AGENTS.md', 'IDENTITY.md', 'SOUL.md', 'USER.md'] as const
51
43
 
52
44
  // `packages/` is a bun workspace root (see `workspaces` in buildPackageJson).
@@ -586,11 +578,11 @@ export async function scaffold(root: string, options: ScaffoldOptions = {}): Pro
586
578
  if (options.withTelegram) channels['telegram-bot'] = {}
587
579
  if (options.withKakaotalk) channels.kakaotalk = {}
588
580
  if (Object.keys(channels).length > 0) config.channels = channels
589
- // See DEFAULT_CHAT_MEMBER_MATCH_RULE for why this is here. GitHub is wired
590
- // separately (writeGithubChannelForInit) and seeds per-repo member.match
591
- // entries instead of the wildcard, so a github-only init stays scoped to
592
- // the repos the operator opted in to.
593
- if (Object.keys(channels).length > 0) config.roles = { member: { match: [DEFAULT_CHAT_MEMBER_MATCH_RULE] } }
581
+ // No default `member` match is seeded. A fresh chat agent starts with every
582
+ // inbound author resolving to `guest` (dropped) until the operator claims
583
+ // `owner` (runOwnerClaim, post-hatch) and explicitly grants others. GitHub is
584
+ // wired separately and seeds per-repo `member.match` entries scoped to the
585
+ // opted-in repos. See runOwnerClaim for the mute-until-claimed warning.
594
586
  await writeFile(join(root, CONFIG_FILE), `${JSON.stringify(config, null, 2)}\n`)
595
587
 
596
588
  const cron = {
@@ -1046,8 +1038,6 @@ export async function runAddChannel(options: AddChannelOptions): Promise<void> {
1046
1038
  if (options.channel === 'github') {
1047
1039
  await appendGithubMatchRules(options.cwd, options.repos)
1048
1040
  await maybeInstallGithubWebhooks(options, emit)
1049
- } else {
1050
- await ensureDefaultChatMemberMatch(options.cwd)
1051
1041
  }
1052
1042
 
1053
1043
  // Commit the typeclaw.json change so the agent folder isn't silently
@@ -1329,24 +1319,6 @@ async function appendGithubMatchRules(cwd: string, repos: readonly string[]): Pr
1329
1319
  await writeFile(path, `${JSON.stringify(parsed, null, 2)}\n`)
1330
1320
  }
1331
1321
 
1332
- // Chat-adapter counterpart of appendGithubMatchRules. See
1333
- // DEFAULT_CHAT_MEMBER_MATCH_RULE for the rationale. Set-union semantics: re-
1334
- // running `typeclaw channel add` for additional chat adapters is a no-op on
1335
- // the match list, and any pre-existing rules the operator hand-authored
1336
- // (e.g. owner-claim's per-author entry on `owner`) are left intact.
1337
- async function ensureDefaultChatMemberMatch(cwd: string): Promise<void> {
1338
- const path = join(cwd, CONFIG_FILE)
1339
- const parsed = JSON.parse(await readFile(path, 'utf8')) as Record<string, unknown>
1340
- const roles = isObjectRecord(parsed.roles) ? { ...parsed.roles } : {}
1341
- const member = isObjectRecord(roles.member) ? { ...roles.member } : {}
1342
- const existing = Array.isArray(member.match) ? member.match.filter((v): v is string => typeof v === 'string') : []
1343
- if (existing.includes(DEFAULT_CHAT_MEMBER_MATCH_RULE)) return
1344
- member.match = [...existing, DEFAULT_CHAT_MEMBER_MATCH_RULE]
1345
- roles.member = member
1346
- parsed.roles = roles
1347
- await writeFile(path, `${JSON.stringify(parsed, null, 2)}\n`)
1348
- }
1349
-
1350
1322
  // Writes per-adapter field values into `secrets.json#channels.<adapter>`.
1351
1323
  // Refuses to overwrite existing fields: if the user already has e.g.
1352
1324
  // `botToken` recorded (from a prior `channel add` whose follow-up steps
@@ -43,7 +43,7 @@ export async function runOwnerClaim({ url, configuredChannels }: RunOwnerClaimOp
43
43
  initialValue: true,
44
44
  })
45
45
  if (isCancel(proceed) || proceed === false) {
46
- log.info(`Skipping. Run ${c.bold('typeclaw role claim')} later when you're ready.`)
46
+ warnMuteUntilClaimed()
47
47
  return
48
48
  }
49
49
 
@@ -78,9 +78,27 @@ export async function runOwnerClaim({ url, configuredChannels }: RunOwnerClaimOp
78
78
  }
79
79
  if (result.kind === 'error') {
80
80
  s.stop(c.red(`Claim failed: ${result.payload.reason}`))
81
- log.info(`You can retry with ${c.bold('typeclaw role claim')} anytime.`)
81
+ warnMuteUntilClaimed()
82
82
  return
83
83
  }
84
84
  s.stop(c.yellow(`Claim timed out — no DM received within the window.`))
85
- log.info(`Run ${c.bold('typeclaw role claim')} when you're ready.`)
85
+ warnMuteUntilClaimed()
86
+ }
87
+
88
+ // Printed on every path that finishes init WITHOUT a successful owner claim.
89
+ // Since the scoped-by-default change removed the `member: ["*"]` seeding, an
90
+ // unclaimed chat agent resolves every inbound to `guest` and drops it — the
91
+ // agent answers no one. This is a footgun unless the operator is told plainly,
92
+ // so the warning is loud and names the exact recovery command.
93
+ function warnMuteUntilClaimed(): void {
94
+ note(
95
+ [
96
+ `Your agent will respond to ${c.bold('no one')} until you claim the owner role —`,
97
+ `every inbound message is currently dropped.`,
98
+ '',
99
+ `Run ${c.bold('typeclaw role claim')} to pair yourself, then grant others`,
100
+ `with the agent (ask it: "respond to <user>") or by editing typeclaw.json#roles.`,
101
+ ].join('\n'),
102
+ c.yellow('Heads up: agent is muted'),
103
+ )
86
104
  }
@@ -10,6 +10,12 @@ export const BUILTIN_ROLE_NAMES: readonly BuiltinRoleName[] = ['owner', 'trusted
10
10
  // set at boot via expandOwnerWildcard.
11
11
  export const CORE_PERMISSIONS = {
12
12
  channelRespond: 'channel.respond',
13
+ // Distinct from channelRespond so a respond-capable guest cannot abort
14
+ // other speakers' sessions. The /stop command (both the text-prefix and
15
+ // native-slash paths in the router) gates on this, not on channelRespond:
16
+ // an operator may grant guest channelRespond to let strangers drive masked
17
+ // turns, but session lifecycle control stays member-and-up.
18
+ sessionControl: 'session.control',
13
19
  cronSchedule: 'cron.schedule',
14
20
  cronModify: 'cron.modify',
15
21
  subagentSpawn: 'subagent.spawn',
@@ -17,10 +23,11 @@ export const CORE_PERMISSIONS = {
17
23
  subagentOutput: 'subagent.output',
18
24
  subagentSpawnOperator: 'subagent.spawn.operator',
19
25
  // Phrased as capabilities to SEE, not to hide, so the role tower stays
20
- // monotonic (a higher tier sees a strict superset of a lower tier) and the
21
- // empty-permission guest is the fail-safe floor. resolveHiddenPaths masks
22
- // whatever the resolved role lacks: fsSeePrivate gates workspace/+memory/+
23
- // sessions/, fsSeeSecrets gates .env+secrets.json.
26
+ // monotonic (a higher tier sees a strict superset of a lower tier).
27
+ // resolveHiddenPaths masks whatever the resolved role lacks: fsSeePrivate
28
+ // gates workspace/+memory/+sessions/, fsSeeSecrets gates .env+secrets.json.
29
+ // The fail-safe floor is the undefined origin (see has() in
30
+ // permissions.ts), not the guest role, which is now grantable.
24
31
  fsSeePrivate: 'fs.see.private',
25
32
  fsSeeSecrets: 'fs.see.secrets',
26
33
  } as const
@@ -62,6 +69,7 @@ export const BUILTIN_ROLES: Readonly<Record<BuiltinRoleName, BuiltinRoleSpec>> =
62
69
  match: [{ kind: 'tui' }],
63
70
  permissions: [
64
71
  CORE_PERMISSIONS.channelRespond,
72
+ CORE_PERMISSIONS.sessionControl,
65
73
  CORE_PERMISSIONS.cronSchedule,
66
74
  CORE_PERMISSIONS.cronModify,
67
75
  CORE_PERMISSIONS.subagentSpawn,
@@ -80,6 +88,7 @@ export const BUILTIN_ROLES: Readonly<Record<BuiltinRoleName, BuiltinRoleSpec>> =
80
88
  match: [],
81
89
  permissions: [
82
90
  CORE_PERMISSIONS.channelRespond,
91
+ CORE_PERMISSIONS.sessionControl,
83
92
  CORE_PERMISSIONS.cronSchedule,
84
93
  CORE_PERMISSIONS.subagentSpawn,
85
94
  CORE_PERMISSIONS.subagentCancel,
@@ -95,6 +104,7 @@ export const BUILTIN_ROLES: Readonly<Record<BuiltinRoleName, BuiltinRoleSpec>> =
95
104
  match: [],
96
105
  permissions: [
97
106
  CORE_PERMISSIONS.channelRespond,
107
+ CORE_PERMISSIONS.sessionControl,
98
108
  CORE_PERMISSIONS.subagentSpawn,
99
109
  CORE_PERMISSIONS.subagentCancel,
100
110
  CORE_PERMISSIONS.subagentOutput,
@@ -1,6 +1,9 @@
1
1
  import { existsSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
3
 
4
+ import { commitSystemFileSync } from '@/git/system-commit'
5
+
6
+ import { BUILTIN_ROLES, isBuiltinRoleName } from './builtins'
4
7
  import { parseMatchRule } from './match-rule'
5
8
 
6
9
  // Appends `rule` to `typeclaw.json#roles.<name>.match`, creating the role
@@ -23,15 +26,97 @@ export type GrantOptions = {
23
26
  matchRule: string
24
27
  }
25
28
 
29
+ export type GrantPermissionOptions = {
30
+ cwd: string
31
+ roleName: string
32
+ permission: string
33
+ }
34
+
26
35
  export function grantRole(opts: GrantOptions): GrantResult {
27
36
  const validation = parseMatchRule(opts.matchRule)
28
37
  if (!validation.ok) {
29
38
  return { ok: false, reason: `invalid match rule '${opts.matchRule}': ${validation.error}` }
30
39
  }
31
40
 
32
- const path = join(opts.cwd, CONFIG_FILE)
41
+ const loaded = loadConfigObject(opts.cwd, opts.roleName)
42
+ if (!loaded.ok) return loaded
43
+
44
+ const { obj, roles, role } = loaded
45
+ const existingMatch = Array.isArray(role.match) ? [...(role.match as unknown[])] : []
46
+
47
+ // Dedup by exact string equality — match rules canonicalize during load,
48
+ // and a literal duplicate in the file is a noise source the schema doesn't
49
+ // currently dedupe for us.
50
+ if (existingMatch.includes(opts.matchRule)) {
51
+ return { ok: true, added: false }
52
+ }
53
+
54
+ role.match = [...existingMatch, opts.matchRule]
55
+ roles[opts.roleName] = role
56
+ obj.roles = roles
57
+
58
+ const written = writeConfigObject(opts.cwd, obj)
59
+ if (!written.ok) return written
60
+
61
+ // Best-effort commit so a claimed role survives a fresh clone/rebuild — a
62
+ // failing commit leaves the on-disk grant intact (history, not correctness).
63
+ // Subject stays neutral: every grantRole caller hits this, not just claim.
64
+ commitSystemFileSync(opts.cwd, CONFIG_FILE, `${CONFIG_FILE}: grant ${opts.roleName} role`)
65
+
66
+ return written
67
+ }
68
+
69
+ // Appends `permission` to `typeclaw.json#roles.<name>.permissions`. For a
70
+ // built-in role with no explicit `permissions[]`, the runtime treats the
71
+ // field as "use built-in defaults" (see resolveOne in permissions.ts), so a
72
+ // naive write of `[permission]` would NARROW the role to a single capability,
73
+ // silently dropping its defaults. We materialize the current effective set
74
+ // (explicit field if present, else the built-in default list) and append to
75
+ // THAT, preserving every existing capability. Idempotent: a permission the
76
+ // role already holds is a no-op.
77
+ //
78
+ // NOTE: `roles.permissions` is `restart-required` (FIELD_EFFECTS); the write
79
+ // lands on disk but does not take effect until the next container restart.
80
+ // Callers must surface that to the operator.
81
+ export function grantRolePermission(opts: GrantPermissionOptions): GrantResult {
82
+ const loaded = loadConfigObject(opts.cwd, opts.roleName)
83
+ if (!loaded.ok) return loaded
84
+
85
+ const { obj, roles, role } = loaded
86
+ const effective = effectivePermissions(opts.roleName, role)
87
+
88
+ if (effective.includes(opts.permission)) {
89
+ return { ok: true, added: false }
90
+ }
91
+
92
+ role.permissions = [...effective, opts.permission]
93
+ roles[opts.roleName] = role
94
+ obj.roles = roles
95
+
96
+ return writeConfigObject(opts.cwd, obj)
97
+ }
98
+
99
+ function effectivePermissions(roleName: string, role: Record<string, unknown>): string[] {
100
+ if (Array.isArray(role.permissions)) {
101
+ return role.permissions.filter((p): p is string => typeof p === 'string')
102
+ }
103
+ if (isBuiltinRoleName(roleName)) {
104
+ return [...BUILTIN_ROLES[roleName].permissions]
105
+ }
106
+ return []
107
+ }
108
+
109
+ type LoadedConfig = {
110
+ ok: true
111
+ obj: Record<string, unknown>
112
+ roles: Record<string, unknown>
113
+ role: Record<string, unknown>
114
+ }
115
+
116
+ function loadConfigObject(cwd: string, roleName: string): LoadedConfig | { ok: false; reason: string } {
117
+ const path = join(cwd, CONFIG_FILE)
33
118
  if (!existsSync(path)) {
34
- return { ok: false, reason: `${CONFIG_FILE} not found at ${opts.cwd}` }
119
+ return { ok: false, reason: `${CONFIG_FILE} not found at ${cwd}` }
35
120
  }
36
121
 
37
122
  let raw: string
@@ -54,26 +139,17 @@ export function grantRole(opts: GrantOptions): GrantResult {
54
139
 
55
140
  const obj = json as Record<string, unknown>
56
141
  const roles = isPlainObject(obj.roles) ? { ...obj.roles } : {}
57
- const role = isPlainObject(roles[opts.roleName]) ? { ...(roles[opts.roleName] as Record<string, unknown>) } : {}
58
- const existingMatch = Array.isArray(role.match) ? [...(role.match as unknown[])] : []
59
-
60
- // Dedup by exact string equality — match rules canonicalize during load,
61
- // and a literal duplicate in the file is a noise source the schema doesn't
62
- // currently dedupe for us.
63
- if (existingMatch.includes(opts.matchRule)) {
64
- return { ok: true, added: false }
65
- }
66
-
67
- role.match = [...existingMatch, opts.matchRule]
68
- roles[opts.roleName] = role
69
- obj.roles = roles
142
+ const role = isPlainObject(roles[roleName]) ? { ...(roles[roleName] as Record<string, unknown>) } : {}
143
+ return { ok: true, obj, roles, role }
144
+ }
70
145
 
146
+ function writeConfigObject(cwd: string, obj: Record<string, unknown>): GrantResult {
147
+ const path = join(cwd, CONFIG_FILE)
71
148
  try {
72
149
  writeAtomic(path, `${JSON.stringify(obj, null, 2)}\n`)
73
150
  } catch (error) {
74
151
  return { ok: false, reason: `failed to write ${CONFIG_FILE}: ${describeError(error)}` }
75
152
  }
76
-
77
153
  return { ok: true, added: true }
78
154
  }
79
155
 
@@ -24,6 +24,12 @@ export {
24
24
  type PermissionService,
25
25
  type UnknownPermissionWarning,
26
26
  } from './permissions'
27
- export { grantRole, type GrantOptions, type GrantResult } from './grant'
28
- export { matchesOrigin, type MatchableOrigin } from './resolve'
27
+ export {
28
+ grantRole,
29
+ grantRolePermission,
30
+ type GrantOptions,
31
+ type GrantPermissionOptions,
32
+ type GrantResult,
33
+ } from './grant'
34
+ export { matchesOrigin, isDmChannelOrigin, type MatchableOrigin } from './resolve'
29
35
  export { MATCH_RULE_JSON_SCHEMA_PATTERN, rolesConfigSchema, type RoleConfig, type RolesConfig } from './schema'
@@ -141,6 +141,15 @@ export function createPermissionService(opts: CreatePermissionServiceOptions = {
141
141
 
142
142
  return {
143
143
  has(origin, permission) {
144
+ // Fail-safe floor: an undefined origin holds nothing, regardless of
145
+ // role permissions. Previously this floor was implicit in `guest`
146
+ // being empty; now `guest` is grantable (an operator may grant it
147
+ // `channel.respond`), so the floor must live on the no-actor input
148
+ // instead. Keeps every downstream `has(maybeUndefined, ...)` check
149
+ // (bypass tiers, fs.see.*, subagent spawn) closed even after a guest
150
+ // grant. resolveRole/describe still report 'guest' for audit/display;
151
+ // only authorization is forced closed.
152
+ if (origin === undefined) return false
144
153
  const roleName = resolveRole(origin)
145
154
  const role = byName.get(roleName)
146
155
  if (!role) return false
@@ -79,3 +79,13 @@ function matchesBucket(
79
79
  if (platform === 'telegram') return origin.workspace === 'dm' || origin.workspace.startsWith('@')
80
80
  return false
81
81
  }
82
+
83
+ // True only for a 1:1 direct-message channel origin (a two-participant
84
+ // conversation: the principal + the bot). Reuses the same per-adapter
85
+ // workspace markers as the `dm` match-rule bucket. A DM is the only channel
86
+ // shape with no third-party content buffered into a turn, so role-grant tools
87
+ // treat an owner/trusted DM as injection-equivalent to the TUI; group/open
88
+ // channels never qualify.
89
+ export function isDmChannelOrigin(origin: { adapter: AdapterId; workspace: string }): boolean {
90
+ return matchesBucket('dm', { kind: 'channel', adapter: origin.adapter, workspace: origin.workspace, chat: '' })
91
+ }
@@ -10,6 +10,7 @@ export {
10
10
  type CreateClaimControllerOptions,
11
11
  } from './controller'
12
12
  export { formatClaimMatchRule, type PartialChannelOrigin } from './match-rule'
13
+ export { reloadAfterClaim, type ReloadAfterClaimOptions, type ReloadAfterClaimResult } from './reload-after-claim'
13
14
  export {
14
15
  createPendingClaimRegistry,
15
16
  type ClaimResult,
@@ -0,0 +1,34 @@
1
+ import { requestReload, type ReloadResult } from '@/reload'
2
+
3
+ export interface ReloadAfterClaimOptions {
4
+ url: string
5
+ reload?: (opts: { url: string; scope: string }) => Promise<ReloadResult[]>
6
+ }
7
+
8
+ export type ReloadAfterClaimResult = { ok: true; results: ReloadResult[] } | { ok: false; reason: string }
9
+
10
+ // Best-effort by contract: the role is already persisted to typeclaw.json by
11
+ // the time a claim completes, so a reload failure here must NOT fail the claim.
12
+ // Callers surface the reason but keep the claim successful.
13
+ export async function reloadAfterClaim(opts: ReloadAfterClaimOptions): Promise<ReloadAfterClaimResult> {
14
+ const reload = opts.reload ?? requestReload
15
+ let results: ReloadResult[]
16
+ try {
17
+ results = await reload({ url: opts.url, scope: 'config' })
18
+ } catch (err) {
19
+ return { ok: false, reason: err instanceof Error ? err.message : String(err) }
20
+ }
21
+
22
+ // requestReload resolves even when the server reports a per-scope failure, so
23
+ // an exception-free call is not proof the config actually reloaded. Surface
24
+ // any failed scope (and an empty result, which means nothing reloaded) as a
25
+ // failure so the caller can tell the user to reload manually.
26
+ const failed = results.filter((r) => !r.ok)
27
+ if (failed.length > 0) {
28
+ return { ok: false, reason: failed.map((r) => `${r.scope}: ${r.reason}`).join('; ') }
29
+ }
30
+ if (results.length === 0) {
31
+ return { ok: false, reason: 'no reloadable config scope responded' }
32
+ }
33
+ return { ok: true, results }
34
+ }
@@ -7,7 +7,7 @@ import type { CreateSessionForSubagent, SubagentRegistry } from '@/agent/subagen
7
7
  import { capJsonlFileInPlace } from '@/bundled-plugins/tool-result-cap/cap-jsonl'
8
8
  import type { CapOptions } from '@/bundled-plugins/tool-result-cap/cap-result'
9
9
  import type { CreateSessionForChannel, ChannelRouter } from '@/channels'
10
- import type { PermissionService } from '@/permissions'
10
+ import type { PermissionService, RolesConfig } from '@/permissions'
11
11
  import type { ReloadRegistry } from '@/reload'
12
12
  import type { SessionFactory } from '@/sessions'
13
13
  import type { Stream } from '@/stream'
@@ -47,6 +47,10 @@ export type BuildChannelSessionFactoryDeps = {
47
47
  // the production wiring can plumb in pluginsLoaded.permissions while tests
48
48
  // (or stand-alone callers) keep the previous no-annotation behavior.
49
49
  permissions?: PermissionService
50
+ // Re-reads roles from disk for the grant_role tool's hot-reload (reload then
51
+ // read, not an in-memory snapshot). Forwarded to createSession; the tool only
52
+ // mounts when permissions is also present.
53
+ reloadRoles?: () => RolesConfig | undefined
50
54
  // Test seam: lets a fake stand in for the agent session creator so tests
51
55
  // can assert exactly which CreateSessionOptions the factory builds without
52
56
  // needing a live LLM, plugin runtime, or session manager on disk.
@@ -124,6 +128,7 @@ export function buildChannelSessionFactory(deps: BuildChannelSessionFactoryDeps)
124
128
  ...(deps.containerName !== undefined ? { containerName: deps.containerName } : {}),
125
129
  ...(deps.runtimeVersion !== undefined ? { runtimeVersion: deps.runtimeVersion } : {}),
126
130
  ...(deps.permissions !== undefined ? { permissions: deps.permissions } : {}),
131
+ ...(deps.reloadRoles !== undefined ? { reloadRoles: deps.reloadRoles } : {}),
127
132
  ...(deps.liveSubagentRegistry !== undefined ? { liveSubagentRegistry: deps.liveSubagentRegistry } : {}),
128
133
  ...(deps.subagentRegistry !== undefined ? { subagentRegistry: deps.subagentRegistry } : {}),
129
134
  ...(deps.getCreateSessionForSubagent !== undefined
package/src/run/index.ts CHANGED
@@ -24,7 +24,7 @@ import {
24
24
  type SubagentCompletionBridge,
25
25
  } from '@/channels'
26
26
  import { createTunnelBridge, type TunnelBridge } from '@/channels/tunnel-bridge'
27
- import { createConfigReloadable, getConfig, loadConfigSync, loadPluginConfigsSync } from '@/config'
27
+ import { createConfigReloadable, getConfig, loadConfigSync, loadPluginConfigsSync, reloadConfig } from '@/config'
28
28
  import {
29
29
  type CronConsumer,
30
30
  type CronJob,
@@ -150,6 +150,7 @@ export async function startAgent({
150
150
  createConfigReloadable({
151
151
  cwd,
152
152
  permissions: pluginsLoaded.permissions,
153
+ onRolesChanged: () => channelManager.router.tearDownAllLive(),
153
154
  skipMountValidation: containerName !== undefined,
154
155
  }),
155
156
  )
@@ -244,6 +245,7 @@ export async function startAgent({
244
245
  getChannelRouter: () => channelManager.router,
245
246
  rehydrateCapOptions: resolveCapOptionsFromConfig(pluginConfigsByName['tool-result-cap']),
246
247
  permissions: pluginsLoaded.permissions,
248
+ reloadRoles: () => reloadRolesFromDisk(cwd),
247
249
  liveSubagentRegistry,
248
250
  liveSessionRegistry,
249
251
  subagentRegistry: pluginRuntime.get().subagents,
@@ -688,6 +690,21 @@ async function disposeMaterializedSkills(pluginRuntime: PluginRuntime): Promise<
688
690
  await Promise.allSettled(all.map((m) => m.dispose()))
689
691
  }
690
692
 
693
+ // grant_role's hot-reload hook: reload the live config FROM DISK (grantRole
694
+ // wrote typeclaw.json directly, bypassing the in-memory snapshot) and return
695
+ // the fresh roles for permissions.replaceRoles. Mirrors the config reloadable's
696
+ // reload-then-read order. Falls back to the current snapshot if the just-written
697
+ // file fails to parse — the on-disk write still stands and the next reload picks
698
+ // it up; replaceRoles with stale roles is no worse than not reloading.
699
+ function reloadRolesFromDisk(cwd: string): ReturnType<typeof getConfig>['roles'] {
700
+ try {
701
+ reloadConfig(cwd)
702
+ } catch {
703
+ // keep the current pointer; see above
704
+ }
705
+ return getConfig().roles
706
+ }
707
+
691
708
  async function startScheduler({
692
709
  cwd,
693
710
  loadCron,
@@ -65,6 +65,38 @@ function buildArgv(command: string, policy: SandboxPolicy): string[] {
65
65
 
66
66
  argv.push('--ro-bind', '/usr', '/usr', '--ro-bind', '/etc', '/etc', '--dev', '/dev', '--tmpfs', '/tmp')
67
67
 
68
+ // Recreate the usr-merge root symlinks that --ro-bind /usr does NOT bring
69
+ // along. On the Debian base (oven/bun:1-slim) /bin /sbin /lib /lib64 are
70
+ // root-level symlinks into /usr; binding /usr exposes /usr/bin etc. but the
71
+ // root entries themselves are absent in the sandbox. That breaks every
72
+ // ABSOLUTE-path reference the kernel resolves WITHOUT consulting PATH:
73
+ // - ELF interpreter (the dynamic loader) baked into PT_INTERP:
74
+ // /lib/ld-linux-aarch64.so.1 (arm64) or /lib64/ld-linux-x86-64.so.2
75
+ // (amd64). Missing it makes bwrap report "execvp bash: No such file or
76
+ // directory" — the missing file is the loader, not bash.
77
+ // - shebang lines: /bin/sh and /bin/bash are the most common interpreters
78
+ // on earth; a script with "#!/bin/sh" fails "cannot execute: required
79
+ // file not found" without /bin, even though /usr/bin/sh exists, because
80
+ // the shebang path is literal and skips PATH.
81
+ // --ro-bind-try, not --ro-bind: the set is arch- and base-dependent (arm64
82
+ // oven/bun:1-slim ships /lib but no /lib64), and a hard bind of an absent
83
+ // source aborts bwrap. -try binds each only when present, keeping this
84
+ // builder pure (no host filesystem probe) and correct across arches/bases.
85
+ argv.push(
86
+ '--ro-bind-try',
87
+ '/bin',
88
+ '/bin',
89
+ '--ro-bind-try',
90
+ '/sbin',
91
+ '/sbin',
92
+ '--ro-bind-try',
93
+ '/lib',
94
+ '/lib',
95
+ '--ro-bind-try',
96
+ '/lib64',
97
+ '/lib64',
98
+ )
99
+
68
100
  if ((policy.proc ?? 'tmpfs') === 'tmpfs') {
69
101
  // --tmpfs /proc, never --proc /proc (OrbStack's kernel blocks
70
102
  // mount("proc",...) from user namespaces) and never --dev-bind /proc /proc
@@ -63,7 +63,11 @@ Why delegate: the `reviewer` subagent runs on the `deep` model profile, loads a
63
63
  </review>
64
64
  ```
65
65
 
66
- 4. **Translate findings into a `gh api` review payload.** Each `<finding>` with `severity` of `blocker`, `concern`, or `nit` and a `location="path:line"` becomes one entry in `comments[]`. Compose the inline `body` from the reviewer's `<issue>` + `<evidence>` + `<suggestion>` — preserve the reviewer's wording, do not paraphrase. Findings whose `location` is `general` (no file:line anchor) go into the top-level review `body` instead. **Skip `praise` findings when building `comments[]`** — they are not actionable, and inline praise comments are exactly the noise the reviewer is supposed to filter out at the source; if you want to surface them, weave them into the top-level review `body` alongside the summary. Map the reviewer's `<verdict>` to the GitHub `event`:
66
+ 4. **Translate findings into a `gh api` review payload.** Each `<finding>` with `severity` of `blocker`, `concern`, or `nit` and a `location="path:line"` becomes one entry in `comments[]`. Compose the inline `body` from the reviewer's `<issue>` + `<evidence>` + `<suggestion>` — preserve the reviewer's wording, do not paraphrase. Findings whose `location` is `general` (no file:line anchor) go into the top-level review `body` instead. **Skip `praise` findings when building `comments[]`** — they are not actionable, and inline praise comments are exactly the noise the reviewer is supposed to filter out at the source; if you want to surface them, weave them into the top-level review `body` alongside the summary.
67
+
68
+ **The verdict and the inline comments are independent. The verdict sets only the `event` field; it never decides whether you post `comments[]`.** Whenever there is at least one actionable finding (`blocker`/`concern`/`nit`) with a `location="path:line"`, you MUST submit a formal review via `POST /pulls/<N>/reviews` carrying those findings in `comments[]` — including when the verdict is `approve`. An `approve` with three nits is still a formal `APPROVE` review with three inline comments, **not** a plain approval and **not** a flattened summary posted as a top-level comment. Collapsing inline findings into a single `channel_reply` or issue comment loses the line anchors the reviewer worked to produce — that is the exact failure mode this step exists to prevent.
69
+
70
+ Map the reviewer's `<verdict>` to the GitHub `event`:
67
71
 
68
72
  | Reviewer verdict | GitHub `event` |
69
73
  | ----------------- | ----------------- |
@@ -88,13 +92,21 @@ Why delegate: the `reviewer` subagent runs on the `deep` model profile, loads a
88
92
 
89
93
  **Always use `--input -` with a quoted heredoc (`<<'JSON'`) for review bodies.** Do **not** use `-f body=...` or `-F 'comments[][body]=...'`: those go through shell argument parsing, so backticks (\`) trigger command substitution and have to be backslash-escaped, which leaks the literal `\` into the rendered comment. The quoted heredoc passes the JSON through untouched — backticks, newlines, and `${...}` all survive verbatim. The same applies to any other `gh api` POST whose body contains backticks, embedded newlines, or shell metacharacters.
90
94
 
91
- 5. **Post a one-line summary with `channel_reply`** so the conversation has a human-readable trace pointing at the review (e.g., "Posted review on PR #N: <verdict>, N findings.").
95
+ 5. **Verify the review actually landed before announcing it.** The `gh api` call can fail silently from the model's perspective a permission denial, a bad `line` anchor, or a malformed payload returns an error you must not paper over. After submitting, confirm the review exists:
96
+
97
+ ```sh
98
+ gh api /repos/owner/repo/pulls/<N>/reviews --jq '.[-1] | {id, state, user: .user.login}'
99
+ ```
100
+
101
+ The returned `id`/`state` is your proof the formal review posted. If the call errored or the review is absent, do **not** fall back to a top-level `channel_reply` that _claims_ a review was posted — fix the payload (most often a `line` that isn't part of the diff; re-anchor it or move that finding to the top-level `body`) and resubmit. A trace reply that says "Posted review" when no review exists is worse than silence.
102
+
103
+ 6. **End the turn with `skip_response`, not a trace reply.** The formal review from step 4 already landed _in this PR_ — it carries the summary, the verdict, and the inline comments. A `channel_reply` here does **not** go to a separate operator channel; on GitHub it posts another public comment on the same PR. A one-line "Posted review on PR #N: …" narrated into the PR thread is meta-commentary addressed to a phantom operator, and it reads absurdly next to the review it claims to point at. So once step 5 confirms the review exists, call `skip_response({ reason: "review posted via gh api" })` to close the turn silently. Only fall back to `channel_reply` when there was **no** formal review to post — the zero-actionable-findings branches in Rules below already use `channel_reply`/issue comments _as_ the substantive reply.
92
104
 
93
105
  ### Rules
94
106
 
95
107
  - **Always delegate to the `reviewer` subagent.** Do not perform the review craft yourself. The reviewer is the source of truth for severity, evidence quality, and what counts as a finding. Your job is mechanics: spawn, wait, translate, post.
96
108
  - **Trust the verdict.** Use the GitHub `event` mapped from the reviewer's `<verdict>`. Do not upgrade `comment` → `APPROVE` to seem agreeable, and do not downgrade `request-changes` → `COMMENT` to soften the tone. The reviewer chose deliberately.
97
- - **No actionable findings → no inline review post.** A finding is "actionable" if its severity is `blocker`, `concern`, or `nit`. If the reviewer returns zero actionable findings:
109
+ - **No actionable findings → no inline review post.** A finding is "actionable" if its severity is `blocker`, `concern`, or `nit`. This branch applies **only when the actionable count is exactly zero** — if there is even one actionable finding with a line anchor, follow step 4 and submit a formal review with `comments[]` regardless of verdict. When the reviewer returns zero actionable findings:
98
110
  - `approve` verdict → post a plain `APPROVE` with the `<summary>` as the review body (no `comments[]` array).
99
111
  - `comment` verdict → post the summary as a top-level PR comment via `gh api -X POST /repos/.../issues/<N>/comments` instead of submitting an empty review.
100
112
  - `request-changes` verdict → submit `REQUEST_CHANGES` with the `<summary>` as the review body and no `comments[]` array. This combination is rare (the reviewer's contract says `request-changes` requires at least one blocker or load-bearing concern), so if it happens, faithfully encode the verdict and trust the reviewer's reasoning is in the summary.
@@ -42,11 +42,19 @@ When the runtime knows your permissions, it prepends a block under your `## Sess
42
42
  Role: `member`. Permissions: `channel.respond`.
43
43
  ```
44
44
 
45
- The block renders for cron / channel / subagent sessions. For TUI sessions, the block is omitted because TUI always resolves to `owner` under severity-then-declaration ordering (built-in `owner.match` includes `tui` and is appended-to, never replaced, by user config — and `owner` is walked first). If you don't see the block in a TUI session, treat yourself as `owner`.
45
+ This concrete role/permissions block renders for **cron and subagent** sessions, which have a single fixed actor. For TUI sessions the block is omitted because TUI always resolves to `owner` under severity-then-declaration ordering (built-in `owner.match` includes `tui` and is appended-to, never replaced, by user config — and `owner` is walked first). If you don't see the block in a TUI session, treat yourself as `owner`.
46
46
 
47
- **The role line reflects the session at creation time.** For channel sessions, the speaker on subsequent turns may resolve to a different role; the runtime updates that internally for tool gating (the channel router and the security plugin re-resolve on each turn), but the system prompt is not regenerated mid-session. If the user asks "what role am I right now in this channel", read `typeclaw.json` `roles` and match their author id against `match[]` yourself — do not parrot the system-prompt line as if it always applied.
47
+ **Channel sessions are different.** A channel session is keyed by chat/thread, not by author, so it can see many speakers with different roles. It does NOT print one concrete role; instead the block is a policy reminder:
48
48
 
49
- **The permission list is exhaustive at session-creation time** for the resolved role. If a permission you expect isn't listed there, the role doesn't carry it — adding it requires editing `roles.<role>.permissions[]` and restarting.
49
+ ```
50
+ ## Your role in this session
51
+
52
+ This is a channel conversation that may include multiple speakers...
53
+ ```
54
+
55
+ For each user turn, the current speaker's effective role is delivered in the turn context as a `<your-role authority="current-speaker">…</your-role>` tag (omitted for `owner`, the unconstrained default). **That per-turn tag is authoritative for the current message and overrides any role implied by the system prompt.** If the user asks "what role am I right now in this channel", read the `<your-role>` tag on the current turn (or, if absent, treat them as `owner`); do not consult a session-creation role line — channel sessions no longer carry one.
56
+
57
+ **The permission list (cron/subagent block) is exhaustive at session-creation time** for the resolved role. If a permission you expect isn't listed there, the role doesn't carry it — adding it requires editing `roles.<role>.permissions[]` and restarting.
50
58
 
51
59
  ## The match-rule DSL
52
60
 
@@ -5,7 +5,9 @@ description: Use this skill whenever the user asks you to install, find, list, u
5
5
 
6
6
  # typeclaw-skills
7
7
 
8
- You operate inside an agent folder. Skills — markdown files with YAML frontmatter — are how this folder teaches you new procedures, conventions, and APIs without changing your code. The runtime discovers them on session start, parses each `SKILL.md`'s frontmatter, and surfaces the `name` + `description` to you so you can decide when to read the body. **You do not import or invoke skills; you read them when their description matches the current request.**
8
+ You operate inside an agent folder. Skills — markdown files with YAML frontmatter — are how this folder teaches you new procedures, conventions, and APIs without changing your code. The runtime discovers them on session start, parses each `SKILL.md`'s frontmatter, and surfaces the `name` + `description` (plus an absolute `<location>` path) to you in the `<available_skills>` section so you can decide when to read the body. **You do not import or invoke skills; you load one by `read`-ing the `SKILL.md` at the exact `<location>` path that section gives you.**
9
+
10
+ **Never construct or guess a skill's path.** Use the `<location>` verbatim. Do not assume a layout like `node_modules/typeclaw/src/skills/<name>/SKILL.md` — bundled skills resolve through the installed package, user skills live under `.agents/skills/`, and muscle-memory skills under `memory/skills/`. If a skill you expect is not listed in `<available_skills>`, it is not a file-based skill and has no `SKILL.md` to read — stop looking on disk. (Subagent-only skills loaded via the `load_skill` tool, e.g. the `reviewer` subagent's `code-review`, are never files.)
9
11
 
10
12
  This skill exists so you (a) understand which skills you can edit and which you must not, (b) can install new skills cleanly when the user asks, and (c) can author your own skills without colliding with the rest of the system.
11
13