typeclaw 0.16.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/auth.schema.json +0 -5
  2. package/package.json +2 -2
  3. package/secrets.schema.json +0 -5
  4. package/src/agent/index.ts +32 -1
  5. package/src/agent/session-origin.ts +54 -12
  6. package/src/agent/system-prompt.ts +1 -1
  7. package/src/agent/tools/grant-role.ts +214 -0
  8. package/src/channels/adapters/discord-bot-classify.ts +23 -0
  9. package/src/channels/adapters/discord-bot.ts +1 -0
  10. package/src/channels/adapters/github/auth-app.ts +49 -26
  11. package/src/channels/adapters/github/auth-pat.ts +3 -3
  12. package/src/channels/adapters/github/auth.ts +19 -5
  13. package/src/channels/adapters/github/channel-resolver.ts +3 -2
  14. package/src/channels/adapters/github/history.ts +3 -2
  15. package/src/channels/adapters/github/index.ts +85 -43
  16. package/src/channels/adapters/github/membership.ts +3 -2
  17. package/src/channels/adapters/github/outbound.ts +6 -2
  18. package/src/channels/adapters/github/team-membership.ts +4 -2
  19. package/src/channels/adapters/github/webhook-register.ts +19 -16
  20. package/src/channels/adapters/slack-bot-slash-commands.ts +76 -1
  21. package/src/channels/adapters/slack-bot.ts +115 -14
  22. package/src/channels/router.ts +87 -17
  23. package/src/cli/channel.ts +0 -12
  24. package/src/cli/init.ts +0 -9
  25. package/src/cli/role.ts +10 -1
  26. package/src/cli/ui.ts +6 -4
  27. package/src/config/reloadable.ts +10 -3
  28. package/src/init/github-webhook-install.ts +1 -2
  29. package/src/init/index.ts +9 -43
  30. package/src/init/run-owner-claim.ts +21 -3
  31. package/src/permissions/builtins.ts +14 -4
  32. package/src/permissions/grant.ts +92 -16
  33. package/src/permissions/index.ts +8 -2
  34. package/src/permissions/permissions.ts +9 -0
  35. package/src/permissions/resolve.ts +10 -0
  36. package/src/role-claim/index.ts +1 -0
  37. package/src/role-claim/reload-after-claim.ts +34 -0
  38. package/src/run/channel-session-factory.ts +6 -1
  39. package/src/run/index.ts +20 -1
  40. package/src/sandbox/build.ts +32 -0
  41. package/src/secrets/schema.ts +0 -1
  42. package/src/server/command-runner.ts +14 -0
  43. package/src/skills/typeclaw-channel-github/SKILL.md +15 -3
  44. package/src/skills/typeclaw-permissions/SKILL.md +11 -3
  45. package/src/skills/typeclaw-skills/SKILL.md +3 -1
@@ -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,
@@ -369,6 +371,7 @@ export async function startAgent({
369
371
  runtimeVersion: runtimeVersionOpt.runtimeVersion,
370
372
  containerName: containerNameOpt.containerName,
371
373
  sessionFactory,
374
+ channelRouter: channelManager.router,
372
375
  }),
373
376
  subagent: (subName: string, payload?: unknown) =>
374
377
  dispatchSpawnSubagent(subName, payload, {
@@ -583,6 +586,7 @@ export async function startAgent({
583
586
  containerName,
584
587
  outbound,
585
588
  sessionFactory,
589
+ channelRouter: channelManager.router,
586
590
  })
587
591
 
588
592
  const server = createServer({
@@ -688,6 +692,21 @@ async function disposeMaterializedSkills(pluginRuntime: PluginRuntime): Promise<
688
692
  await Promise.allSettled(all.map((m) => m.dispose()))
689
693
  }
690
694
 
695
+ // grant_role's hot-reload hook: reload the live config FROM DISK (grantRole
696
+ // wrote typeclaw.json directly, bypassing the in-memory snapshot) and return
697
+ // the fresh roles for permissions.replaceRoles. Mirrors the config reloadable's
698
+ // reload-then-read order. Falls back to the current snapshot if the just-written
699
+ // file fails to parse — the on-disk write still stands and the next reload picks
700
+ // it up; replaceRoles with stale roles is no worse than not reloading.
701
+ function reloadRolesFromDisk(cwd: string): ReturnType<typeof getConfig>['roles'] {
702
+ try {
703
+ reloadConfig(cwd)
704
+ } catch {
705
+ // keep the current pointer; see above
706
+ }
707
+ return getConfig().roles
708
+ }
709
+
691
710
  async function startScheduler({
692
711
  cwd,
693
712
  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
@@ -49,7 +49,6 @@ const githubAppAuthSchema = z.object({
49
49
  type: z.literal('app'),
50
50
  appId: z.number().int().positive(),
51
51
  privateKey: secretFieldSchema,
52
- installationId: z.number().int().positive().optional(),
53
52
  })
54
53
 
55
54
  const githubChannelSchema = z.object({
@@ -5,6 +5,7 @@ import {
5
5
  type CreateSessionResult,
6
6
  type SessionOrigin,
7
7
  } from '@/agent'
8
+ import type { ChannelRouter } from '@/channels/router'
8
9
  import type { PermissionService } from '@/permissions'
9
10
  import type {
10
11
  CommandExecResult,
@@ -44,6 +45,14 @@ export type CommandRunnerOptions = {
44
45
  // `SessionManager.inMemory()` and never persist usage — see
45
46
  // `runPromptForCommand` below.
46
47
  sessionFactory: SessionFactory
48
+ // Channel router threaded into every `ctx.prompt` session so the model can
49
+ // call `channel_send`. Without this, `buildChannelTools` (src/agent/index.ts)
50
+ // receives `undefined` and emits no channel tools — a plugin command or cron
51
+ // handler told to post to a channel then has no tool to do it and falls back
52
+ // to flailing bash loops. The cron `prompt` path already passes
53
+ // `channelManager.router` via `createSessionForCron`; this is the matching
54
+ // wire for the handler/command path.
55
+ channelRouter: ChannelRouter | undefined
47
56
  }
48
57
 
49
58
  type CommandHandle = {
@@ -182,6 +191,7 @@ export function createCommandRunner(opts: CommandRunnerOptions): CommandRunner {
182
191
  permissions: opts.permissions,
183
192
  signal: abortController.signal,
184
193
  sessionFactory: opts.sessionFactory,
194
+ channelRouter: opts.channelRouter,
185
195
  }),
186
196
  subagent: (subName, payload) =>
187
197
  opts.spawnSubagent(subName, payload, {
@@ -363,6 +373,9 @@ export async function runPromptForCommand(args: {
363
373
  // cron `prompt` path uses in src/run/index.ts. Passing in-memory here
364
374
  // regresses `typeclaw usage` (see CommandRunnerOptions.sessionFactory).
365
375
  sessionFactory: SessionFactory
376
+ // See CommandRunnerOptions.channelRouter. Threaded to createSessionWithDispose
377
+ // so the spawned session exposes `channel_send`.
378
+ channelRouter?: ChannelRouter
366
379
  // Test seam for the agent-session boundary. Production passes the real
367
380
  // `createSessionWithDispose`; tests inject a fake to verify wiring
368
381
  // (specifically: the sessionManager handed off must be persisted, not
@@ -388,6 +401,7 @@ export async function runPromptForCommand(args: {
388
401
  sessionId,
389
402
  agentDir: args.agentDir,
390
403
  },
404
+ ...(args.channelRouter !== undefined ? { channelRouter: args.channelRouter } : {}),
391
405
  ...(args.runtimeVersion !== undefined ? { runtimeVersion: args.runtimeVersion } : {}),
392
406
  ...(args.containerName !== undefined ? { containerName: args.containerName } : {}),
393
407
  })
@@ -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