typeclaw 0.15.2 → 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.
Files changed (48) hide show
  1. package/package.json +1 -1
  2. package/src/agent/index.ts +35 -2
  3. package/src/agent/plugin-tools.ts +38 -0
  4. package/src/agent/session-meta.ts +6 -2
  5. package/src/agent/session-origin.ts +111 -14
  6. package/src/agent/subagents.ts +6 -1
  7. package/src/agent/system-prompt.ts +41 -32
  8. package/src/agent/tools/channel-reply.ts +3 -2
  9. package/src/agent/tools/grant-role.ts +214 -0
  10. package/src/bundled-plugins/guard/policies/non-workspace-write.ts +8 -6
  11. package/src/bundled-plugins/memory/index.ts +25 -6
  12. package/src/bundled-plugins/security/index.ts +12 -0
  13. package/src/bundled-plugins/security/policies/private-surface-read.ts +215 -0
  14. package/src/channels/adapters/github/inbound.ts +54 -1
  15. package/src/channels/adapters/github/index.ts +1 -0
  16. package/src/channels/router.ts +150 -37
  17. package/src/cli/inspect.ts +20 -9
  18. package/src/cli/role.ts +10 -1
  19. package/src/cli/ui.ts +6 -4
  20. package/src/config/reloadable.ts +10 -3
  21. package/src/init/index.ts +24 -42
  22. package/src/init/paths.ts +1 -0
  23. package/src/init/run-owner-claim.ts +21 -3
  24. package/src/inspect/label.ts +2 -0
  25. package/src/inspect/live.ts +6 -1
  26. package/src/inspect/render.ts +8 -2
  27. package/src/inspect/replay.ts +6 -1
  28. package/src/inspect/types.ts +4 -1
  29. package/src/permissions/builtins.ts +22 -0
  30. package/src/permissions/grant.ts +92 -16
  31. package/src/permissions/index.ts +8 -2
  32. package/src/permissions/permissions.ts +16 -0
  33. package/src/permissions/resolve.ts +10 -0
  34. package/src/plugin/types.ts +12 -0
  35. package/src/role-claim/index.ts +1 -0
  36. package/src/role-claim/reload-after-claim.ts +34 -0
  37. package/src/run/channel-session-factory.ts +6 -1
  38. package/src/run/index.ts +18 -1
  39. package/src/sandbox/build.ts +51 -1
  40. package/src/sandbox/hidden-paths.ts +41 -0
  41. package/src/sandbox/index.ts +2 -1
  42. package/src/sandbox/policy.ts +15 -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
  46. package/src/skills/typeclaw-troubleshooting/SKILL.md +104 -0
  47. package/src/usage/report.ts +4 -0
  48. package/src/usage/scan.ts +1 -1
@@ -3,7 +3,7 @@ import { basename } from 'node:path'
3
3
  import type { AssistantMessage } from '@mariozechner/pi-ai'
4
4
  import { SessionManager } from '@mariozechner/pi-coding-agent'
5
5
 
6
- import { createSession, renderTurnTimeAnchor, type AgentSession } from '@/agent'
6
+ import { createSession, renderTurnRoleAnchor, renderTurnTimeAnchor, type AgentSession } from '@/agent'
7
7
  import { subscribeProviderErrors } from '@/agent/provider-error'
8
8
  import type { ChannelParticipant, SessionOrigin } from '@/agent/session-origin'
9
9
  import { renderSubagentCompletionReminder } from '@/agent/subagent-completion-reminder'
@@ -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) {
@@ -1427,11 +1462,6 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1427
1462
  const batch = live.promptQueue.splice(0, live.promptQueue.length)
1428
1463
  const observed = live.contextBuffer.splice(0, live.contextBuffer.length)
1429
1464
  const reminders = live.pendingSystemReminders.splice(0, live.pendingSystemReminders.length)
1430
- const text = composeTurnPrompt(observed, batch, {
1431
- adapter: live.key.adapter,
1432
- loopGuardActive: live.loopGuardActive,
1433
- systemReminders: reminders,
1434
- })
1435
1465
 
1436
1466
  if (batch.length > 0) {
1437
1467
  live.currentTurnAuthorId = batch[batch.length - 1]!.authorId
@@ -1457,12 +1487,21 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1457
1487
  }
1458
1488
 
1459
1489
  // Update the live origin holder so this turn's tool.before events
1460
- // carry the current actor's id. The DefaultResourceLoader still
1461
- // renders the session-creation origin into the system prompt (v0.2
1462
- // work to regenerate that per-turn); but permission gating off
1463
- // `lastInboundAuthorId` happens in the tool layer and now sees the
1490
+ // carry the current actor's id, and resolve the live role from it for
1491
+ // the per-turn <your-role> anchor below. Done BEFORE composeTurnPrompt
1492
+ // so the anchor reflects the speaker of THIS turn, not the session-
1493
+ // creation snapshot the system prompt still renders. Permission gating
1494
+ // off `lastInboundAuthorId` happens in the tool layer and sees the same
1464
1495
  // live value.
1465
1496
  live.originRef.current = buildLiveOrigin(live)
1497
+ const liveRole = permissions.describe(live.originRef.current).role
1498
+
1499
+ const text = composeTurnPrompt(observed, batch, {
1500
+ adapter: live.key.adapter,
1501
+ loopGuardActive: live.loopGuardActive,
1502
+ systemReminders: reminders,
1503
+ role: liveRole,
1504
+ })
1466
1505
 
1467
1506
  // Bracketing logs around the LLM call so a hung prompt() is
1468
1507
  // diagnosable from logs alone (we see prompting without prompted).
@@ -1628,6 +1667,12 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1628
1667
  logger.info(`[channels] ${keyId}: ignoring unknown command /${parsedCommand.name}`)
1629
1668
  return
1630
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
+ }
1631
1676
  const existingLive = liveSessions.get(keyId)
1632
1677
  if (!existingLive || existingLive.destroyed) {
1633
1678
  logger.info(`[channels] ${keyId}: ignoring command /${parsedCommand.name} with no live session`)
@@ -1710,17 +1755,24 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
1710
1755
  scheduleDebouncedDrain(live)
1711
1756
  }
1712
1757
 
1713
- const isChannelRespondDenied = (event: InboundMessage): boolean => {
1714
- const partial: SessionOrigin = {
1715
- kind: 'channel',
1716
- adapter: event.adapter,
1717
- workspace: event.workspace,
1718
- chat: event.chat,
1719
- thread: event.thread,
1720
- lastInboundAuthorId: event.authorId,
1721
- }
1722
- return !permissions.has(partial, CORE_PERMISSIONS.channelRespond)
1723
- }
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)
1724
1776
 
1725
1777
  const updateLoopGuard = (live: LiveSession, event: InboundMessage): void => {
1726
1778
  if (!event.authorIsBot) {
@@ -2199,9 +2251,14 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2199
2251
  return
2200
2252
  }
2201
2253
 
2202
- // `source` distinguishes the two recovery shapes for log triage:
2203
- // - 'leaf': the assistant message IS the leaf (existing behavior; model
2204
- // ended its turn with text but forgot to call channel_reply).
2254
+ // `source` distinguishes the three recovery shapes for log triage:
2255
+ // - 'leaf': the assistant message IS the leaf with stopReason 'stop'
2256
+ // (existing behavior; model ended its turn with text but forgot to
2257
+ // call channel_reply).
2258
+ // - 'mid-turn': the assistant message IS the leaf with stopReason
2259
+ // 'toolUse'; the model narrated a reply, committed to a tool plan, and
2260
+ // the turn ended before a follow-up that would have called a channel
2261
+ // tool was persisted. The narration is the only user-facing text.
2205
2262
  // - 'pre-tool': the leaf is a toolResult (or other non-assistant entry)
2206
2263
  // and the assistant message lives upstream in the branch. This is the
2207
2264
  // Kimi-on-Fireworks `kimi-k2p6-turbo` failure mode where the post-tool
@@ -2325,6 +2382,27 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2325
2382
  const stop = async (): Promise<void> => {
2326
2383
  if (gcTimer) clearInterval(gcTimer)
2327
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++
2328
2406
  const all = Array.from(liveSessions.values())
2329
2407
  liveSessions.clear()
2330
2408
  for (const live of all) {
@@ -2341,11 +2419,11 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2341
2419
  if (!commands.has(lowered)) {
2342
2420
  return { kind: 'unknown-command', name: lowered }
2343
2421
  }
2344
- // Permission gate runs BEFORE the live-session lookup so a guest user
2345
- // invoking /stop on a non-existent session gets 'permission-denied'
2346
- // (consistent answer regardless of session state) rather than leaking
2347
- // session presence via the 'no-live-session' vs 'permission-denied'
2348
- // 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.
2349
2427
  const partial: SessionOrigin = {
2350
2428
  kind: 'channel',
2351
2429
  adapter: key.adapter,
@@ -2354,7 +2432,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2354
2432
  thread: key.thread,
2355
2433
  lastInboundAuthorId: options.invokerId,
2356
2434
  }
2357
- if (!permissions.has(partial, CORE_PERMISSIONS.channelRespond)) {
2435
+ if (!permissions.has(partial, CORE_PERMISSIONS.sessionControl)) {
2358
2436
  return { kind: 'permission-denied' }
2359
2437
  }
2360
2438
  const resolved = resolveLiveSessionForCommand(liveSessions, key)
@@ -2467,6 +2545,7 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2467
2545
  injectSubagentCompletionReminder,
2468
2546
  markTurnSkipped,
2469
2547
  stop,
2548
+ tearDownAllLive,
2470
2549
  liveCount: () => liveSessions.size,
2471
2550
  __testing: {
2472
2551
  flushDebounce: async (key: ChannelKey) => {
@@ -2534,13 +2613,21 @@ export function createChannelRouter(options: CreateChannelRouterOptions): Channe
2534
2613
  function composeTurnPrompt(
2535
2614
  observed: readonly ObservedInbound[],
2536
2615
  batch: readonly QueuedInbound[],
2537
- state: { adapter?: AdapterId; loopGuardActive: boolean; systemReminders?: readonly string[]; now?: Date } = {
2616
+ state: {
2617
+ adapter?: AdapterId
2618
+ loopGuardActive: boolean
2619
+ systemReminders?: readonly string[]
2620
+ now?: Date
2621
+ role?: string
2622
+ } = {
2538
2623
  loopGuardActive: false,
2539
2624
  },
2540
2625
  ): string {
2541
2626
  const adapter = state.adapter ?? 'discord-bot'
2542
2627
  const parts: string[] = []
2543
2628
  parts.push(renderTurnTimeAnchor(state.now), '')
2629
+ const roleAnchor = state.role !== undefined ? renderTurnRoleAnchor(state.role) : undefined
2630
+ if (roleAnchor !== undefined) parts.push(roleAnchor, '')
2544
2631
  // System reminders (subagent-completion wakeups today) lead the turn body
2545
2632
  // because they are typically what triggered the drain — when the prompt
2546
2633
  // queue is empty and the only thing in this iteration is a reminder, the
@@ -3001,7 +3088,7 @@ async function raceWithTimeout<T>(work: Promise<T>, ms: number, label: string):
3001
3088
  // assistant message — i.e., text the user should see but didn't, because the
3002
3089
  // model failed to call `channel_reply`/`channel_send` before its turn ended.
3003
3090
  //
3004
- // Two recovery shapes:
3091
+ // Three recovery shapes:
3005
3092
  //
3006
3093
  // - source: 'leaf'
3007
3094
  // The leaf entry IS an assistant message with `stopReason === 'stop'`.
@@ -3009,6 +3096,20 @@ async function raceWithTimeout<T>(work: Promise<T>, ms: number, label: string):
3009
3096
  // tool. Pre-existing behavior; this is what the historical
3010
3097
  // `latestAssistantText` covered.
3011
3098
  //
3099
+ // - source: 'mid-turn'
3100
+ // The leaf IS an assistant message with `stopReason === 'toolUse'` that
3101
+ // carries visible text. The model narrated a user-facing reply ("on it,
3102
+ // bumping to 16x now") AND committed to a tool plan in the same message,
3103
+ // but the turn ended before any follow-up assistant message that would
3104
+ // have called `channel_reply` was persisted — the upstream pi-agent-core
3105
+ // loop's post-tool follow-up never landed, or the run was aborted
3106
+ // mid-loop. The model treated its visible prose as ambient narration; in
3107
+ // a channel session that prose is dead text. Recovers it so the user gets
3108
+ // the reply the model thought it had already given. Observed against
3109
+ // Fireworks' `kimi-k2p6-turbo` on KakaoTalk: the agent posted speed-change
3110
+ // status as narration, kept taking screenshots, and the user saw nothing.
3111
+ // This is the leaf-is-assistant twin of the 'pre-tool' shape below.
3112
+ //
3012
3113
  // - source: 'pre-tool'
3013
3114
  // The leaf is a `toolResult` and the immediately-prior assistant message
3014
3115
  // has `stopReason === 'toolUse'` (it called the tool that produced this
@@ -3020,22 +3121,34 @@ async function raceWithTimeout<T>(work: Promise<T>, ms: number, label: string):
3020
3121
  //
3021
3122
  // Returns null when no recovery is appropriate:
3022
3123
  // - No leaf, no messages in branch, branch is malformed
3023
- // - Leaf is an assistant with non-'stop' stopReason (e.g. mid-stream error)
3124
+ // - Leaf is an assistant with `stopReason` of 'length' / 'error' / 'aborted'
3024
3125
  // and is NOT preceded by a toolResult pattern — we don't recover partial
3025
3126
  // errored output because it's typically a truncation, not a deliberate
3026
- // reply
3127
+ // reply. Only 'stop' (turn-complete) and 'toolUse' (committed to a tool
3128
+ // plan, prose stranded) signal text the model meant for the user.
3027
3129
  // - Leaf is a user/system message (model hasn't responded yet)
3028
3130
  //
3029
3131
  // `visibleAssistantText` returning '' (empty string) is a valid recovery
3030
3132
  // target — the caller's downstream guards (`endsWithNoReplySignal('')` returns
3031
3133
  // true) handle the no-content case explicitly via the `no_reply` log.
3032
- function recoverableAssistantText(session: AgentSession): { text: string; source: 'leaf' | 'pre-tool' } | null {
3134
+ function recoverableAssistantText(
3135
+ session: AgentSession,
3136
+ ): { text: string; source: 'leaf' | 'mid-turn' | 'pre-tool' } | null {
3033
3137
  const leaf = session.sessionManager.getLeafEntry()
3034
3138
  if (!leaf) return null
3035
3139
 
3036
3140
  if (leaf.type === 'message' && leaf.message.role === 'assistant') {
3037
- if (leaf.message.stopReason !== 'stop') return null
3038
- return { text: visibleAssistantText(leaf.message), source: 'leaf' }
3141
+ if (leaf.message.stopReason === 'stop') {
3142
+ return { text: visibleAssistantText(leaf.message), source: 'leaf' }
3143
+ }
3144
+ // The model committed to a tool plan but its visible prose never reached
3145
+ // the channel and no follow-up message that would have called a channel
3146
+ // tool was persisted. Recover the stranded prose. Other non-'stop' stop
3147
+ // reasons (length/error/aborted) are truncations, not deliberate replies.
3148
+ if (leaf.message.stopReason === 'toolUse') {
3149
+ return { text: visibleAssistantText(leaf.message), source: 'mid-turn' }
3150
+ }
3151
+ return null
3039
3152
  }
3040
3153
 
3041
3154
  // Pre-tool recovery: the leaf must be a toolResult message, and walking
@@ -45,8 +45,12 @@ export const inspectCommand = defineCommand({
45
45
 
46
46
  const isJson = args.json === true
47
47
  const liveSource = isJson ? undefined : await buildLiveSource(cwd)
48
- const signal = installSigintAbort()
49
- const escListener = isJson ? null : createEscListener()
48
+ const signalCtrl = installSigintAbort()
49
+ const signal = signalCtrl.signal
50
+ // Raw-mode Ctrl-C arrives as byte 0x03 and must abort the exit controller
51
+ // directly: under Bun a self-issued process.kill(SIGINT) does not reliably
52
+ // re-enter our process.once('SIGINT') handler, so the live tail never exits.
53
+ const escListener = isJson ? null : createEscListener(() => signalCtrl.abort())
50
54
  const liveHint = escListener === null ? undefined : escHintLine(color)
51
55
 
52
56
  // try/finally so a thrown loop never leaves the terminal stuck in raw mode.
@@ -108,14 +112,14 @@ async function buildLiveSource(cwd: string): Promise<LiveSourceFactory | undefin
108
112
  })
109
113
  }
110
114
 
111
- function installSigintAbort(): AbortSignal {
115
+ function installSigintAbort(): AbortController {
112
116
  const ctrl = new AbortController()
113
117
  const onSig = (): void => {
114
118
  ctrl.abort()
115
119
  }
116
120
  process.once('SIGINT', onSig)
117
121
  process.once('SIGTERM', onSig)
118
- return ctrl.signal
122
+ return ctrl
119
123
  }
120
124
 
121
125
  type EscListener = {
@@ -125,8 +129,10 @@ type EscListener = {
125
129
  stop: () => void
126
130
  }
127
131
 
128
- function createEscListener(): EscListener | null {
129
- const stdin = process.stdin
132
+ type RawInput = Pick<NodeJS.ReadStream, 'isTTY' | 'setRawMode' | 'resume' | 'pause' | 'on' | 'off'>
133
+
134
+ export function createEscListener(onSigint: () => void, input: RawInput = process.stdin): EscListener | null {
135
+ const stdin = input
130
136
  if (!stdin.isTTY || typeof stdin.setRawMode !== 'function') return null
131
137
 
132
138
  const ctrl = createEscController({ debounceMs: ESC_LISTEN_DELAY_MS })
@@ -134,15 +140,17 @@ function createEscListener(): EscListener | null {
134
140
 
135
141
  const onData = (chunk: Buffer): void => {
136
142
  const { sigint } = ctrl.onChunk(chunk)
137
- if (sigint) process.kill(process.pid, 'SIGINT')
143
+ if (sigint) onSigint()
138
144
  }
139
145
 
140
146
  const start = (): void => {
141
147
  if (active) return
142
148
  active = true
143
149
  stdin.setRawMode(true)
144
- stdin.resume()
150
+ // Attach the data handler before resume() so no raw-mode keystroke can slip
151
+ // through between resuming the stream and registering the listener.
145
152
  stdin.on('data', onData)
153
+ stdin.resume()
146
154
  }
147
155
  const stop = (): void => {
148
156
  if (!active) return
@@ -153,7 +161,10 @@ function createEscListener(): EscListener | null {
153
161
  } catch {
154
162
  /* terminal already torn down */
155
163
  }
156
- stdin.pause()
164
+ // Do NOT pause stdin here: this teardown hands control to the clack picker,
165
+ // and under Bun clack does not reliably re-flow a previously paused
166
+ // process.stdin, so its keypresses never arrive and arrow keys echo as raw
167
+ // bytes. Leaving the stream flowing lets clack own raw mode during the picker.
157
168
  ctrl.clearPending()
158
169
  }
159
170
 
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
@@ -23,7 +23,7 @@ import { installGithubWebhooksEagerly, type EagerGithubWebhookInstallResult } fr
23
23
  import { buildGitignore, GITIGNORE_FILE } from './gitignore'
24
24
  import { buildHatchingPrompt } from './hatching'
25
25
  import type { OAuthLoginRunner, OAuthLoginResult } from './oauth-login'
26
- import { GITKEEP_FILE, PACKAGES_DIR } from './paths'
26
+ import { GITKEEP_FILE, PACKAGES_DIR, PUBLIC_DIR } from './paths'
27
27
  import { type InstallResult, type InstallRunner, runBunInstall } from './run-bun-install'
28
28
 
29
29
  export { type InstallResult, type InstallRunner, runBunInstall } from './run-bun-install'
@@ -31,7 +31,7 @@ export { type InstallResult, type InstallRunner, runBunInstall } from './run-bun
31
31
  export type { EagerGithubWebhookInstallResult } from './github-webhook-install'
32
32
  export { formatEagerGithubWebhookInstallResult, installGithubWebhooksEagerly } from './github-webhook-install'
33
33
 
34
- export { GITKEEP_FILE, PACKAGES_DIR } from './paths'
34
+ export { GITKEEP_FILE, PACKAGES_DIR, PUBLIC_DIR } from './paths'
35
35
 
36
36
  export { appendOrReplaceEnvKey, hasEnvKey, readEnvFile } from './env-file'
37
37
 
@@ -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).
@@ -55,7 +47,15 @@ const MARKDOWN_FILES = ['AGENTS.md', 'IDENTITY.md', 'SOUL.md', 'USER.md'] as con
55
47
  // stay in `workspace/`. The directory is scaffolded empty so the layout is
56
48
  // discoverable on day one; a `.gitkeep` is written below so it survives the
57
49
  // initial commit.
58
- const DIRECTORIES = ['workspace', 'sessions', '.agents/skills', 'mounts', 'packages'] as const
50
+ //
51
+ // `public/` is a top-level sibling, NOT `workspace/public/`, on purpose:
52
+ // role-based path hiding (src/sandbox/hidden-paths.ts) masks `workspace/` from
53
+ // the guest tier but never masks `public/`, so `public/` is the one place a
54
+ // guest turn can read and write. `workspace/` is an arbitrary free-write zone
55
+ // with no reserved subdir names; a magic `workspace/public/` would silently
56
+ // expose any subdir an agent happened to name `public`. A root sibling keeps
57
+ // the deny-list flat (no carve-out) and the public/private split legible.
58
+ const DIRECTORIES = ['workspace', 'public', 'sessions', '.agents/skills', 'mounts', 'packages'] as const
59
59
 
60
60
  export type GitInitResult = { ok: true; skipped: boolean } | { ok: false; reason: string }
61
61
  export type DockerAssetsResult = { ok: true; devMode: boolean } | { ok: false; reason: string }
@@ -552,12 +552,14 @@ export type ScaffoldOptions = {
552
552
  export async function scaffold(root: string, options: ScaffoldOptions = {}): Promise<void> {
553
553
  await Promise.all(DIRECTORIES.map((dir) => mkdir(join(root, dir), { recursive: true })))
554
554
 
555
- // git does not track empty directories, so without this file the `packages/`
556
- // workspace root would silently disappear from the initial commit and confuse
557
- // the agent (its workspaces glob would resolve to nothing). The other
558
- // DIRECTORIES are either gitignored (workspace, sessions, mounts) or
559
- // immediately populated, so packages/ is the only one that needs this.
560
- await writeFile(join(root, PACKAGES_DIR, GITKEEP_FILE), '', { flag: 'wx' }).catch(ignoreExists)
555
+ // git does not track empty directories, so without these files the empty
556
+ // `packages/` (a bun workspace root) and `public/` (the guest-visible zone)
557
+ // would silently disappear from the initial commit. The other DIRECTORIES are
558
+ // either gitignored (workspace, sessions, mounts) or immediately populated.
559
+ await Promise.all([
560
+ writeFile(join(root, PACKAGES_DIR, GITKEEP_FILE), '', { flag: 'wx' }).catch(ignoreExists),
561
+ writeFile(join(root, PUBLIC_DIR, GITKEEP_FILE), '', { flag: 'wx' }).catch(ignoreExists),
562
+ ])
561
563
 
562
564
  // Only fields without sensible defaults elsewhere are emitted. Everything
563
565
  // with a schema-provided default (e.g. `network.blockInternal`, `mounts`,
@@ -576,11 +578,11 @@ export async function scaffold(root: string, options: ScaffoldOptions = {}): Pro
576
578
  if (options.withTelegram) channels['telegram-bot'] = {}
577
579
  if (options.withKakaotalk) channels.kakaotalk = {}
578
580
  if (Object.keys(channels).length > 0) config.channels = channels
579
- // See DEFAULT_CHAT_MEMBER_MATCH_RULE for why this is here. GitHub is wired
580
- // separately (writeGithubChannelForInit) and seeds per-repo member.match
581
- // entries instead of the wildcard, so a github-only init stays scoped to
582
- // the repos the operator opted in to.
583
- 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.
584
586
  await writeFile(join(root, CONFIG_FILE), `${JSON.stringify(config, null, 2)}\n`)
585
587
 
586
588
  const cron = {
@@ -1036,8 +1038,6 @@ export async function runAddChannel(options: AddChannelOptions): Promise<void> {
1036
1038
  if (options.channel === 'github') {
1037
1039
  await appendGithubMatchRules(options.cwd, options.repos)
1038
1040
  await maybeInstallGithubWebhooks(options, emit)
1039
- } else {
1040
- await ensureDefaultChatMemberMatch(options.cwd)
1041
1041
  }
1042
1042
 
1043
1043
  // Commit the typeclaw.json change so the agent folder isn't silently
@@ -1319,24 +1319,6 @@ async function appendGithubMatchRules(cwd: string, repos: readonly string[]): Pr
1319
1319
  await writeFile(path, `${JSON.stringify(parsed, null, 2)}\n`)
1320
1320
  }
1321
1321
 
1322
- // Chat-adapter counterpart of appendGithubMatchRules. See
1323
- // DEFAULT_CHAT_MEMBER_MATCH_RULE for the rationale. Set-union semantics: re-
1324
- // running `typeclaw channel add` for additional chat adapters is a no-op on
1325
- // the match list, and any pre-existing rules the operator hand-authored
1326
- // (e.g. owner-claim's per-author entry on `owner`) are left intact.
1327
- async function ensureDefaultChatMemberMatch(cwd: string): Promise<void> {
1328
- const path = join(cwd, CONFIG_FILE)
1329
- const parsed = JSON.parse(await readFile(path, 'utf8')) as Record<string, unknown>
1330
- const roles = isObjectRecord(parsed.roles) ? { ...parsed.roles } : {}
1331
- const member = isObjectRecord(roles.member) ? { ...roles.member } : {}
1332
- const existing = Array.isArray(member.match) ? member.match.filter((v): v is string => typeof v === 'string') : []
1333
- if (existing.includes(DEFAULT_CHAT_MEMBER_MATCH_RULE)) return
1334
- member.match = [...existing, DEFAULT_CHAT_MEMBER_MATCH_RULE]
1335
- roles.member = member
1336
- parsed.roles = roles
1337
- await writeFile(path, `${JSON.stringify(parsed, null, 2)}\n`)
1338
- }
1339
-
1340
1322
  // Writes per-adapter field values into `secrets.json#channels.<adapter>`.
1341
1323
  // Refuses to overwrite existing fields: if the user already has e.g.
1342
1324
  // `botToken` recorded (from a prior `channel add` whose follow-up steps
package/src/init/paths.ts CHANGED
@@ -1,2 +1,3 @@
1
1
  export const PACKAGES_DIR = 'packages'
2
+ export const PUBLIC_DIR = 'public'
2
3
  export const GITKEEP_FILE = '.gitkeep'