typeclaw 0.21.0 → 0.23.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 (47) hide show
  1. package/package.json +2 -1
  2. package/src/agent/index.ts +55 -1
  3. package/src/agent/loop-guard.ts +180 -53
  4. package/src/agent/session-origin.ts +41 -2
  5. package/src/bundled-plugins/bun-hygiene/README.md +82 -0
  6. package/src/bundled-plugins/bun-hygiene/index.ts +11 -0
  7. package/src/bundled-plugins/bun-hygiene/policy.ts +318 -0
  8. package/src/bundled-plugins/github-cli-auth/gh-command.ts +98 -6
  9. package/src/bundled-plugins/github-cli-auth/graphql-auth-nudge.ts +80 -0
  10. package/src/bundled-plugins/github-cli-auth/index.ts +7 -0
  11. package/src/bundled-plugins/memory/memory-logger.ts +34 -12
  12. package/src/bundled-plugins/reviewer/skills/code-review.ts +8 -0
  13. package/src/channels/adapters/discord-bot.ts +8 -0
  14. package/src/channels/adapters/github/inbound.ts +23 -1
  15. package/src/channels/adapters/github/index.ts +9 -0
  16. package/src/channels/adapters/slack-bot.ts +112 -5
  17. package/src/channels/adapters/telegram-bot.ts +11 -0
  18. package/src/channels/manager.ts +8 -0
  19. package/src/channels/router.ts +100 -15
  20. package/src/channels/schema.ts +18 -0
  21. package/src/channels/types.ts +27 -0
  22. package/src/cli/dreams.ts +2 -1
  23. package/src/cli/inspect-controller.ts +92 -0
  24. package/src/cli/inspect.ts +21 -123
  25. package/src/cli/ui.ts +34 -0
  26. package/src/commands/index.ts +5 -2
  27. package/src/config/config.ts +89 -0
  28. package/src/inspect/index.ts +8 -26
  29. package/src/inspect/live.ts +17 -3
  30. package/src/inspect/loop.ts +23 -17
  31. package/src/mcp/catalog.ts +29 -0
  32. package/src/mcp/client.ts +236 -0
  33. package/src/mcp/index.ts +25 -0
  34. package/src/mcp/manager.ts +156 -0
  35. package/src/mcp/tools.ts +190 -0
  36. package/src/permissions/builtins.ts +9 -0
  37. package/src/reload/format.ts +14 -0
  38. package/src/reload/index.ts +1 -0
  39. package/src/run/bundled-plugins.ts +7 -0
  40. package/src/run/channel-session-factory.ts +3 -0
  41. package/src/run/index.ts +38 -1
  42. package/src/server/command-runner.ts +5 -0
  43. package/src/server/index.ts +4 -0
  44. package/src/skills/typeclaw-channel-github/SKILL.md +83 -13
  45. package/src/skills/typeclaw-config/SKILL.md +1 -1
  46. package/src/skills/typeclaw-git/SKILL.md +1 -1
  47. package/typeclaw.schema.json +82 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "typeclaw",
3
- "version": "0.21.0",
3
+ "version": "0.23.0",
4
4
  "homepage": "https://github.com/typeclaw/typeclaw#readme",
5
5
  "bugs": {
6
6
  "url": "https://github.com/typeclaw/typeclaw/issues"
@@ -45,6 +45,7 @@
45
45
  "@clack/prompts": "^1.2.0",
46
46
  "@mariozechner/pi-coding-agent": "^0.67.3",
47
47
  "@mariozechner/pi-tui": "^0.67.3",
48
+ "@modelcontextprotocol/sdk": "^1.29.0",
48
49
  "@mozilla/readability": "^0.6.0",
49
50
  "agent-messenger": "2.19.1",
50
51
  "cheerio": "^1.2.0",
@@ -2,13 +2,21 @@ import { existsSync } from 'node:fs'
2
2
  import { dirname, join } from 'node:path'
3
3
  import { fileURLToPath } from 'node:url'
4
4
 
5
- import { createAgentSession, DefaultResourceLoader, SessionManager } from '@mariozechner/pi-coding-agent'
5
+ import {
6
+ createAgentSession,
7
+ DefaultResourceLoader,
8
+ defineTool as definePiTool,
9
+ SessionManager,
10
+ } from '@mariozechner/pi-coding-agent'
6
11
  import type { AgentSession, ToolDefinition } from '@mariozechner/pi-coding-agent'
7
12
 
8
13
  import { loadMemory } from '@/bundled-plugins/memory/load-memory'
9
14
  import type { ChannelRouter } from '@/channels/router'
10
15
  import { getConfig, resolveModel, resolveProfile } from '@/config'
11
16
  import { defaultThinkingLevelForRef, providerForModelRef, type KnownModelRef } from '@/config/providers'
17
+ import { renderMcpCatalog } from '@/mcp/catalog'
18
+ import type { McpManager } from '@/mcp/manager'
19
+ import { createMcpDispatcherTools, MCP_DISPATCHER_TOOL_NAMES } from '@/mcp/tools'
12
20
  import type { PermissionService, RolesConfig } from '@/permissions'
13
21
  import type {
14
22
  BuiltinToolRef,
@@ -34,6 +42,7 @@ import {
34
42
  wrapPluginTool,
35
43
  wrapSystemAgentTool,
36
44
  wrapSystemTool,
45
+ zodToToolParameters,
37
46
  } from './plugin-tools'
38
47
  import { createReloadTool } from './reload-tool'
39
48
  import { loadSelf } from './self'
@@ -98,6 +107,7 @@ export type CreateSessionOptions = {
98
107
  sessionManager?: SessionManager
99
108
  stream?: Stream
100
109
  channelRouter?: ChannelRouter
110
+ mcpManager?: McpManager
101
111
  // Bypass the file-based resource loader (IDENTITY.md, SOUL.md, MEMORY.md,
102
112
  // memory/, bundled skills) and use this string verbatim as the system prompt.
103
113
  systemPromptOverride?: string
@@ -232,6 +242,7 @@ export async function createSessionWithDispose(options: CreateSessionOptions = {
232
242
  ...(options.origin ? { origin: options.origin } : {}),
233
243
  ...(options.permissions ? { permissions: options.permissions } : {}),
234
244
  ...(options.runtimeVersion !== undefined ? { runtimeVersion: options.runtimeVersion } : {}),
245
+ ...(options.mcpManager !== undefined ? { mcpManager: options.mcpManager } : {}),
235
246
  })
236
247
 
237
248
  const getOrigin: () => SessionOrigin | undefined =
@@ -307,6 +318,7 @@ export async function createSessionWithDispose(options: CreateSessionOptions = {
307
318
  websearchTool,
308
319
  webfetchTool,
309
320
  lookAtTool,
321
+ ...(options.mcpManager ? buildMcpDispatcherToolDefinitions(options.mcpManager) : []),
310
322
  ...(options.reloadRegistry ? [createReloadTool({ registry: options.reloadRegistry })] : []),
311
323
  ...(options.stream ? [createStreamSnapshotTool({ stream: options.stream })] : []),
312
324
  ...buildChannelTools(options.channelRouter, options.origin, sessionManager.getSessionId()),
@@ -555,6 +567,40 @@ export function buildChannelTools(
555
567
  return tools
556
568
  }
557
569
 
570
+ export function buildMcpDispatcherToolDefinitions(manager: McpManager): ToolDefinition[] {
571
+ const tools = createMcpDispatcherTools(manager)
572
+ return [
573
+ defineMcpDispatcherTool(MCP_DISPATCHER_TOOL_NAMES[0], tools[0]),
574
+ defineMcpDispatcherTool(MCP_DISPATCHER_TOOL_NAMES[1], tools[1]),
575
+ defineMcpDispatcherTool(MCP_DISPATCHER_TOOL_NAMES[2], tools[2]),
576
+ ]
577
+ }
578
+
579
+ function defineMcpDispatcherTool<P>(name: string, tool: PluginTool<P>): ToolDefinition {
580
+ return definePiTool({
581
+ name,
582
+ label: name,
583
+ description: tool.description,
584
+ parameters: zodToToolParameters(tool.parameters),
585
+ async execute(_toolCallId, params, signal) {
586
+ const validated = tool.parameters.safeParse(params)
587
+ if (!validated.success) {
588
+ return {
589
+ content: [{ type: 'text' as const, text: `invalid arguments: ${validated.error.message}` }],
590
+ details: null,
591
+ }
592
+ }
593
+ const result = await tool.execute(validated.data, {
594
+ signal,
595
+ sessionId: 'mcp-dispatcher',
596
+ agentDir: process.cwd(),
597
+ logger: { info() {}, warn() {}, error() {} },
598
+ })
599
+ return { content: result.content, details: result.details ?? null }
600
+ },
601
+ })
602
+ }
603
+
558
604
  export function buildSubagentOrchestrationTools(opts: {
559
605
  liveRegistry: LiveSubagentRegistry | undefined
560
606
  registry: SubagentRegistry | undefined
@@ -722,6 +768,7 @@ export type CreateResourceLoaderOptions = {
722
768
  plugins?: PluginSessionWiring
723
769
  materializedSkills?: MaterializedSkills | null
724
770
  origin?: SessionOrigin
771
+ mcpManager?: McpManager
725
772
  permissions?: PermissionService
726
773
  runtimeVersion?: string
727
774
  // Explicit override for the prompt mode. When omitted, the mode is derived
@@ -785,6 +832,7 @@ export type SystemPromptComposition = {
785
832
  runtimeVersion?: string
786
833
  origin?: SessionOrigin
787
834
  roleContext?: SessionRoleContext
835
+ mcpCatalog?: string
788
836
  gitNudge: string
789
837
  memorySection: string
790
838
  }
@@ -822,6 +870,9 @@ export function composeSystemPrompt(parts: SystemPromptComposition): string {
822
870
  if (parts.origin !== undefined) {
823
871
  prompt = `${prompt}\n\n${renderSessionOrigin(parts.origin, Date.now(), parts.roleContext)}`
824
872
  }
873
+ if (parts.mcpCatalog !== undefined && parts.mcpCatalog !== '') {
874
+ prompt = `${prompt}\n\n${parts.mcpCatalog}`
875
+ }
825
876
  if (parts.gitNudge !== '') {
826
877
  prompt = `${prompt}\n\n${parts.gitNudge}`
827
878
  }
@@ -901,6 +952,9 @@ export async function createResourceLoader(options: CreateResourceLoaderOptions
901
952
  ...(options.runtimeVersion !== undefined ? { runtimeVersion: options.runtimeVersion } : {}),
902
953
  ...(options.origin !== undefined ? { origin: options.origin } : {}),
903
954
  ...(roleContext !== undefined ? { roleContext } : {}),
955
+ ...(mode === 'full' && options.mcpManager !== undefined
956
+ ? { mcpCatalog: renderMcpCatalog(options.mcpManager.listServers()) }
957
+ : {}),
904
958
  gitNudge,
905
959
  memorySection,
906
960
  })
@@ -1,39 +1,63 @@
1
- // Detects when the model calls the same tool with byte-identical arguments in
2
- // a tight streak the classic "stuck in a thought-loop" failure where the
3
- // agent repeats `bash("ls")` or `read("foo.ts")` indefinitely waiting for a
4
- // different answer. Two-tier escalation:
1
+ // Detects when the model is stuck looping on tool calls. Two independent
2
+ // detectors run per call; the more severe decision wins.
5
3
  //
6
- // - At LOOP_SOFT_WARN consecutive identical calls (default 3), the next call
7
- // completes normally but the wrapped tool's output is suffixed with a nudge
8
- // telling the model it's looping. Soft warning fires ONCE per streak so
9
- // the model isn't drowning in identical reminders.
10
- // - At LOOP_HARD_BLOCK consecutive identical calls (default 5), the call is
11
- // refused outright. The wrapping in plugin-tools.ts maps the refusal to
12
- // `errorResult` for plugin tools (the model sees a tool error and must
13
- // change strategy) and to a thrown Error for system / pi-builtin tools
14
- // (matches the existing `tool.before { block: true }` plumbing).
4
+ // 1. Consecutive-identical (reason: 'consecutive') — catches the tight loop
5
+ // where the agent repeats `bash("ls")` byte-for-byte waiting for a different
6
+ // answer. Soft-warn at LOOP_SOFT_WARN (3), hard-block at LOOP_HARD_BLOCK (5).
15
7
  //
16
- // State is per-session and bounded: the guard keeps at most MAX_SESSIONS
17
- // session entries with LRU eviction, and each session holds at most one
18
- // signature + counter (we only care about the current tail streak). When a
19
- // different tool/args combination arrives, the streak resets to 1.
8
+ // 2. Windowed-frequency (reason: 'windowed') catches interleaved cycles the
9
+ // consecutive detector cannot see, e.g. read(a)→edit(b)→read(a)→edit(b)…, or
10
+ // re-reading one file with drifting offsets. Over a sliding window of the
11
+ // last WINDOW_SIZE calls, if one signature recurs WINDOW_SOFT_WARN times it
12
+ // warns and WINDOW_HARD_BLOCK times it blocks. Path-bearing tools coarsen
13
+ // their signature to the path alone (offsets/limits/line ranges dropped) so
14
+ // that paging the same file in a cycle collapses to one signature.
20
15
  //
21
- // The detector is intentionally placed INSIDE the tool wrappers (not as a
16
+ // Both warn/block decisions carry the byte-identical or coarsened nudge text.
17
+ // The wrapping in plugin-tools.ts maps a block to `errorResult` for plugin tools
18
+ // and to a thrown Error for system / pi-builtin tools (matching the existing
19
+ // `tool.before { block: true }` plumbing).
20
+ //
21
+ // State is per-session and bounded by MAX_SESSIONS with LRU eviction. The
22
+ // detector is intentionally placed INSIDE the tool wrappers (not as a
22
23
  // `tool.before` plugin) so it covers every tool category — plugin tools,
23
24
  // TypeClaw system tools, and pi-coding-agent builtins — through one chokepoint.
24
25
 
25
26
  export const LOOP_SOFT_WARN = 3
26
27
  export const LOOP_HARD_BLOCK = 5
27
28
 
28
- // Caps in-process memory across many sessions. Each entry is small
29
- // (signature string + small counters), so this bound is generous; we just
30
- // don't want unbounded growth if sessionIds churn.
29
+ export const WINDOW_SIZE = 16
30
+ export const WINDOW_SOFT_WARN = 4
31
+ export const WINDOW_HARD_BLOCK = 6
32
+
33
+ // Tools whose first path-like argument identifies the target. Their windowed
34
+ // signature keys on that path alone so paging one file with drifting
35
+ // offset/limit collapses to a single signature. Two classes, because the
36
+ // builtins differ in whether the path is required:
37
+ //
38
+ // - REQUIRED-path tools (read/write/edit): path is mandatory. Coarsen only
39
+ // when a path key is present; an absent path is malformed input, not a
40
+ // default, so we must NOT collapse such calls to a shared target.
41
+ // - DEFAULT-path tools (grep/find/ls): path is OPTIONAL and defaults to the
42
+ // cwd ("."). Omitting the path and varying only non-target args (pattern,
43
+ // limit) still hits the same directory, so an omitted/empty path coarsens
44
+ // to `${tool}#path:.` — otherwise those calls would evade the detector.
45
+ //
46
+ // `glob` is intentionally absent: pi-coding-agent has no `glob` builtin (the
47
+ // glob-pattern arg lives inside grep/find), so listing it here matched nothing.
48
+ const REQUIRED_PATH_TOOLS = new Set(['read', 'write', 'edit'])
49
+ const DEFAULT_PATH_TOOLS = new Set(['grep', 'find', 'ls'])
50
+ const PATH_ARG_KEYS = ['path', 'file', 'filePath', 'filename']
51
+ const DEFAULT_PATH_TARGET = '.'
52
+
31
53
  const MAX_SESSIONS = 256
32
54
 
55
+ export type LoopReason = 'consecutive' | 'windowed'
56
+
33
57
  export type LoopGuardDecision =
34
58
  | { kind: 'ok' }
35
- | { kind: 'warn'; count: number; message: string }
36
- | { kind: 'block'; count: number; message: string }
59
+ | { kind: 'warn'; count: number; reason: LoopReason; message: string }
60
+ | { kind: 'block'; count: number; reason: LoopReason; message: string }
37
61
 
38
62
  export type LoopGuard = {
39
63
  check: (sessionId: string, tool: string, args: unknown) => LoopGuardDecision
@@ -42,28 +66,53 @@ export type LoopGuard = {
42
66
  }
43
67
 
44
68
  type SessionState = {
69
+ // Consecutive-identical streak: the current tail signature, its run length,
70
+ // and whether this streak already emitted its one soft warning.
45
71
  signature: string
46
72
  count: number
47
- // Fires the soft warning exactly once per streak instead of every call
48
- // from the 3rd onwards. Re-arms when the streak breaks.
49
73
  warned: boolean
74
+ // Windowed history: the last WINDOW_SIZE coarsened signatures, plus the set
75
+ // of signatures that already emitted their one windowed soft warning while
76
+ // still present in the window.
77
+ window: string[]
78
+ windowWarned: Set<string>
50
79
  }
51
80
 
52
81
  export type CreateLoopGuardOptions = {
53
82
  softWarn?: number
54
83
  hardBlock?: number
55
84
  maxSessions?: number
85
+ windowSize?: number
86
+ windowSoftWarn?: number
87
+ windowHardBlock?: number
56
88
  }
57
89
 
58
90
  export function createLoopGuard(options: CreateLoopGuardOptions = {}): LoopGuard {
59
91
  const softWarn = options.softWarn ?? LOOP_SOFT_WARN
60
92
  const hardBlock = options.hardBlock ?? LOOP_HARD_BLOCK
61
93
  const maxSessions = options.maxSessions ?? MAX_SESSIONS
94
+ const windowSize = options.windowSize ?? WINDOW_SIZE
95
+ const windowSoftWarn = options.windowSoftWarn ?? WINDOW_SOFT_WARN
96
+ const windowHardBlock = options.windowHardBlock ?? WINDOW_HARD_BLOCK
62
97
 
63
98
  if (softWarn < 2) throw new Error(`loop-guard: softWarn must be >= 2 (got ${softWarn})`)
64
99
  if (hardBlock <= softWarn) {
65
100
  throw new Error(`loop-guard: hardBlock (${hardBlock}) must be greater than softWarn (${softWarn})`)
66
101
  }
102
+ if (windowSoftWarn < 2) {
103
+ throw new Error(`loop-guard: windowSoftWarn must be >= 2 (got ${windowSoftWarn})`)
104
+ }
105
+ if (windowHardBlock <= windowSoftWarn) {
106
+ throw new Error(
107
+ `loop-guard: windowHardBlock (${windowHardBlock}) must be greater than windowSoftWarn (${windowSoftWarn})`,
108
+ )
109
+ }
110
+ if (windowSize < 2) throw new Error(`loop-guard: windowSize must be >= 2 (got ${windowSize})`)
111
+ if (windowSize < windowHardBlock) {
112
+ throw new Error(
113
+ `loop-guard: windowSize (${windowSize}) must be >= windowHardBlock (${windowHardBlock}) for the block to be reachable`,
114
+ )
115
+ }
67
116
 
68
117
  // Map preserves insertion order; we rely on that for LRU eviction.
69
118
  const sessions = new Map<string, SessionState>()
@@ -77,50 +126,90 @@ export function createLoopGuard(options: CreateLoopGuardOptions = {}): LoopGuard
77
126
  }
78
127
  }
79
128
 
129
+ function evaluateConsecutive(state: SessionState, tool: string): LoopGuardDecision {
130
+ if (state.count >= hardBlock) {
131
+ return {
132
+ kind: 'block',
133
+ count: state.count,
134
+ reason: 'consecutive',
135
+ message: formatBlockMessage(tool, state.count),
136
+ }
137
+ }
138
+ if (state.count >= softWarn && !state.warned) {
139
+ state.warned = true
140
+ return { kind: 'warn', count: state.count, reason: 'consecutive', message: formatWarnMessage(tool, state.count) }
141
+ }
142
+ return { kind: 'ok' }
143
+ }
144
+
145
+ function evaluateWindowed(state: SessionState, tool: string, windowSig: string): LoopGuardDecision {
146
+ const count = state.window.reduce((n, sig) => (sig === windowSig ? n + 1 : n), 0)
147
+ if (count >= windowHardBlock) {
148
+ return {
149
+ kind: 'block',
150
+ count,
151
+ reason: 'windowed',
152
+ message: formatWindowedBlockMessage(tool, count),
153
+ }
154
+ }
155
+ if (count >= windowSoftWarn && !state.windowWarned.has(windowSig)) {
156
+ state.windowWarned.add(windowSig)
157
+ return {
158
+ kind: 'warn',
159
+ count,
160
+ reason: 'windowed',
161
+ message: formatWindowedWarnMessage(tool, count),
162
+ }
163
+ }
164
+ return { kind: 'ok' }
165
+ }
166
+
80
167
  return {
81
168
  check(sessionId, tool, args) {
82
169
  const signature = makeCallSignature(tool, args)
170
+ const windowSig = makeWindowSignature(tool, args)
83
171
  const existing = sessions.get(sessionId)
84
172
 
85
- if (!existing || existing.signature !== signature) {
86
- touch(sessionId, { signature, count: 1, warned: false })
87
- return { kind: 'ok' }
88
- }
89
-
90
- const nextCount = existing.count + 1
91
- const nextState: SessionState = {
173
+ const state: SessionState = existing ?? {
92
174
  signature,
93
- count: nextCount,
94
- warned: existing.warned,
175
+ count: 0,
176
+ warned: false,
177
+ window: [],
178
+ windowWarned: new Set(),
95
179
  }
96
180
 
97
- if (nextCount >= hardBlock) {
98
- touch(sessionId, nextState)
99
- return {
100
- kind: 'block',
101
- count: nextCount,
102
- message: formatBlockMessage(tool, nextCount),
103
- }
181
+ if (state.signature !== signature) {
182
+ state.signature = signature
183
+ state.count = 1
184
+ state.warned = false
185
+ } else {
186
+ state.count += 1
104
187
  }
105
188
 
106
- if (nextCount >= softWarn && !nextState.warned) {
107
- nextState.warned = true
108
- touch(sessionId, nextState)
109
- return {
110
- kind: 'warn',
111
- count: nextCount,
112
- message: formatWarnMessage(tool, nextCount),
189
+ state.window.push(windowSig)
190
+ if (state.window.length > windowSize) {
191
+ const evicted = state.window.shift()
192
+ if (evicted !== undefined && !state.window.includes(evicted)) {
193
+ state.windowWarned.delete(evicted)
113
194
  }
114
195
  }
115
196
 
116
- touch(sessionId, nextState)
197
+ touch(sessionId, state)
198
+
199
+ const consecutive = evaluateConsecutive(state, tool)
200
+ if (consecutive.kind === 'block') return consecutive
201
+
202
+ // Back-to-back identical calls are the consecutive detector's domain; let
203
+ // it own them so a tight streak doesn't also trip the windowed detector.
204
+ // The windowed detector exists for INTERLEAVED cycles, so it only acts
205
+ // when this call breaks the immediate streak (count === 1).
206
+ const windowed = state.count === 1 ? evaluateWindowed(state, tool, windowSig) : { kind: 'ok' as const }
207
+ if (windowed.kind === 'block') return windowed
208
+ if (consecutive.kind === 'warn') return consecutive
209
+ if (windowed.kind === 'warn') return windowed
117
210
  return { kind: 'ok' }
118
211
  },
119
212
  reset(sessionId) {
120
- const existing = sessions.get(sessionId)
121
- if (!existing) return
122
- // Resetting is what `tool.after` does on a non-identical call too;
123
- // exposed for callers that observe a strategy change externally.
124
213
  sessions.delete(sessionId)
125
214
  },
126
215
  forget(sessionId) {
@@ -146,6 +235,24 @@ function formatBlockMessage(tool: string, count: number): string {
146
235
  )
147
236
  }
148
237
 
238
+ function formatWindowedWarnMessage(tool: string, count: number): string {
239
+ return (
240
+ `\n\n[loop-guard] You have called \`${tool}\` on the same target ${count} times in a short span. ` +
241
+ `This looks like a cycle — revisiting the same work without making progress. ` +
242
+ `If you have enough information, produce the final answer now. ` +
243
+ `Otherwise change approach instead of repeating this call.`
244
+ )
245
+ }
246
+
247
+ function formatWindowedBlockMessage(tool: string, count: number): string {
248
+ return (
249
+ `loop-guard: refused \`${tool}\` — called on the same target ${count} times in a short span. ` +
250
+ `You are cycling on the same work. Stop. Either (1) produce the final answer with the data you already have, ` +
251
+ `(2) ask the user a clarifying question, or (3) try a meaningfully different approach. ` +
252
+ `Do not keep re-running this on the same target.`
253
+ )
254
+ }
255
+
149
256
  function makeCallSignature(tool: string, args: unknown): string {
150
257
  try {
151
258
  return `${tool}:${stableStringify(args)}`
@@ -154,6 +261,26 @@ function makeCallSignature(tool: string, args: unknown): string {
154
261
  }
155
262
  }
156
263
 
264
+ // Coarsened signature for windowed detection: path-bearing tools key on their
265
+ // target path alone so re-reading one target with drifting non-path args
266
+ // collapses to a single signature. All other tools fall back to the exact
267
+ // signature.
268
+ function makeWindowSignature(tool: string, args: unknown): string {
269
+ const isRequiredPath = REQUIRED_PATH_TOOLS.has(tool)
270
+ const isDefaultPath = DEFAULT_PATH_TOOLS.has(tool)
271
+ if ((isRequiredPath || isDefaultPath) && args !== null && typeof args === 'object') {
272
+ const record = args as Record<string, unknown>
273
+ for (const key of PATH_ARG_KEYS) {
274
+ const value = record[key]
275
+ if (typeof value === 'string' && value.length > 0) return `${tool}#path:${value}`
276
+ }
277
+ // No explicit path. For default-path tools the effective target is the cwd,
278
+ // so coarsen to it; for required-path tools we leave the call uncoarsened.
279
+ if (isDefaultPath) return `${tool}#path:${DEFAULT_PATH_TARGET}`
280
+ }
281
+ return makeCallSignature(tool, args)
282
+ }
283
+
157
284
  // Order-independent JSON serialization so semantically-identical objects
158
285
  // produce identical signatures regardless of key insertion order.
159
286
  function stableStringify(value: unknown): string {
@@ -1,6 +1,6 @@
1
1
  import { MEMBERSHIP_FRESHNESS_MS, type MembershipCount } from '@/channels/membership'
2
2
  import type { AdapterId } from '@/channels/schema'
3
- import type { ReactionRef } from '@/channels/types'
3
+ import type { ChannelSelfIdentity, ReactionRef } from '@/channels/types'
4
4
 
5
5
  export type ChannelParticipant = {
6
6
  authorId: string
@@ -42,6 +42,7 @@ export type SessionOrigin =
42
42
  reactionRef?: ReactionRef
43
43
  participants?: readonly ChannelParticipant[]
44
44
  membership?: MembershipCount
45
+ self?: ChannelSelfIdentity
45
46
  }
46
47
  | {
47
48
  kind: 'subagent'
@@ -262,6 +263,7 @@ function renderChannelOrigin(
262
263
  thread: string | null
263
264
  participants?: readonly ChannelParticipant[]
264
265
  membership?: MembershipCount
266
+ self?: ChannelSelfIdentity
265
267
  },
266
268
  now: number,
267
269
  ): string {
@@ -398,7 +400,7 @@ function renderChannelOrigin(
398
400
  "matching the channel's `allow` rules are accepted (the tool returns",
399
401
  '`{ ok: false }` otherwise).',
400
402
  '',
401
- ...renderMentionGuidance(platformInfo, origin.participants ?? [], now),
403
+ ...renderMentionGuidance(platformInfo, origin.participants ?? [], now, origin.self),
402
404
  )
403
405
 
404
406
  const participantsBlock = renderParticipants(origin.participants ?? [], platformInfo, now)
@@ -437,6 +439,7 @@ function renderMentionGuidance(
437
439
  platformInfo: PlatformInfo,
438
440
  participants: readonly ChannelParticipant[],
439
441
  now: number,
442
+ self?: ChannelSelfIdentity,
440
443
  ): string[] {
441
444
  const cutoff = now - PARTICIPANTS_MAX_AGE_MS
442
445
  const fresh = [...participants]
@@ -454,6 +457,7 @@ function renderMentionGuidance(
454
457
  `For example, to address ${exampleName} in this conversation, write \`<@${exampleId}> hello\` —`,
455
458
  `**not** "${exampleName} hello". Plain-text names do not notify the recipient on ${platformInfo.displayName},`,
456
459
  'and other bots in this channel will not see the message as addressed to them.',
460
+ ...renderSelfMention(platformInfo, self),
457
461
  ]
458
462
  case 'at-username':
459
463
  return [
@@ -462,6 +466,7 @@ function renderMentionGuidance(
462
466
  'block below are a typeclaw convention for parsing inbound mentions — do not echo them back as outbound mentions.',
463
467
  'If you only know an author by their display name and they have no `@username`, address them by display name',
464
468
  'and they will see the message via the reply context.',
469
+ ...renderSelfMention(platformInfo, self),
465
470
  ]
466
471
  case 'alias':
467
472
  return [
@@ -474,6 +479,40 @@ function renderMentionGuidance(
474
479
  }
475
480
  }
476
481
 
482
+ // The model knows its NAME from identity files but not its platform user
483
+ // id, so a message addressed to its own id reads as "addressed to someone
484
+ // else" and it wrongly skips the turn (issue: skipped_by_tool "Message
485
+ // addressed to @U…, not to <name>"). This line closes that gap by stating
486
+ // the bot's own addressing token explicitly. Empty for the alias platform
487
+ // (KakaoTalk has no in-band mention token to recognize) and when identity
488
+ // has not resolved yet — both fall through to "omit the line".
489
+ function renderSelfMention(platformInfo: PlatformInfo, self: ChannelSelfIdentity | undefined): string[] {
490
+ if (self === undefined) return []
491
+ switch (platformInfo.mentionMode) {
492
+ case 'angle-id': {
493
+ const forms =
494
+ platformInfo.displayName === 'Discord' ? `\`<@${self.id}>\` (also \`<@!${self.id}>\`)` : `\`<@${self.id}>\``
495
+ return [
496
+ '',
497
+ `**You are ${forms} on this ${platformInfo.displayName} workspace.** When a message`,
498
+ `contains your id, it is addressed to YOU — treat it as a mention of yourself, not of`,
499
+ 'someone else, and do not skip the turn as "addressed to another user".',
500
+ ]
501
+ }
502
+ case 'at-username': {
503
+ if (self.username === undefined || self.username === '') return []
504
+ return [
505
+ '',
506
+ `**You are \`@${self.username}\` on ${platformInfo.displayName}.** A message mentioning`,
507
+ `\`@${self.username}\` is addressed to YOU — treat it as a mention of yourself, not of`,
508
+ 'someone else.',
509
+ ]
510
+ }
511
+ case 'alias':
512
+ return []
513
+ }
514
+ }
515
+
477
516
  function renderConversationLine(origin: {
478
517
  adapter: AdapterId
479
518
  workspace: string
@@ -0,0 +1,82 @@
1
+ # typeclaw-plugin-bun-hygiene
2
+
3
+ The bundled bun-hygiene plugin. Registers a `tool.before` hook that blocks two classes of `bash` command:
4
+
5
+ 1. **Global package installs** — `npm install -g`, `pnpm add -g`, `yarn global add`, `bun add -g`, and their `--global` / bundled-flag variants.
6
+ 2. **Non-bun package managers** — any `npm`, `npx`, `pnpm`, `pnpx`, or `yarn` invocation.
7
+
8
+ This plugin is **auto-loaded** by every TypeClaw agent. There is no `plugins[]` entry to add. Both guards carry an `acknowledgeGuards` escape hatch (below) for the cases where the agent genuinely needs the blocked command.
9
+
10
+ ## Why it exists
11
+
12
+ **Global installs don't persist.** The agent folder is bind-mounted at `/agent`; everything else in the container — including `~/.bun`, `~/.npm`, and the global `node_modules` a global install writes to — is ephemeral and wiped on every `typeclaw restart`. An agent that runs `npm install -g some-cli` gets a tool that works for the rest of the session and silently vanishes on the next boot, leading to confusing "command not found" failures that look like regressions. The fix is to either add the dependency to `package.json` (`bun add <pkg>`, which lives in the bind-mounted folder and survives) or run it once without installing (`bunx <pkg>`).
13
+
14
+ **The container standardizes on bun.** TypeClaw is Bun-native end to end (see the root README). Mixing in `npm`/`pnpm`/`yarn` produces competing lockfiles and install trees, and `npx` pulls a second package-execution path when `bunx` already covers it. Steering every package-manager call to bun keeps the dependency state coherent.
15
+
16
+ Both guards **block with guidance** rather than silently rewriting the command — the agent sees exactly why the command was rejected and what to run instead, the same UX as the bundled `security` and `guard` policies.
17
+
18
+ ## Guards
19
+
20
+ | Guard | Triggers on | Guidance in the block reason |
21
+ | ---------------------- | ------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- |
22
+ | `globalInstall` | `npm`/`pnpm` install/add with `-g`/`--global`, `yarn global add`, `bun add -g` / `bun install -g` | Use `bun add <pkg>` (persists) or `bunx <pkg>` (ephemeral run). |
23
+ | `nonBunPackageManager` | `npm`, `npx`, `pnpm`, `pnpx`, `yarn` at a command boundary | Use `bun install` / `bun add <pkg>`, and `bunx <pkg>` instead of npx/pnpx. |
24
+
25
+ A global install (e.g. `npm install -g x`) trips **only** `globalInstall`, not both — the global install is the more specific violation, so acknowledging `globalInstall` lets the command through without a second acknowledgement for `nonBunPackageManager`.
26
+
27
+ ## Bypass
28
+
29
+ Both guards follow the repo-wide `acknowledgeGuards` convention (shared with the `security` and `guard` plugins). To run a blocked command intentionally, pass the matching flag in the `bash` tool arguments:
30
+
31
+ ```jsonc
32
+ // bash tool args
33
+ { "command": "npm install", "acknowledgeGuards": { "nonBunPackageManager": true } }
34
+ { "command": "npm install -g some-cli", "acknowledgeGuards": { "globalInstall": true } }
35
+ ```
36
+
37
+ ## How it works
38
+
39
+ `checkBunHygieneGuard` in `policy.ts` does not regex the raw command. It runs a small single-pass tokenizer (`splitSegments`) that turns the command into a list of **segments**, each a list of **words**:
40
+
41
+ - Segments break on real command separators — `;`, `&&`, `||`, `|`, `&`, newline, `\r` — and on subshell / command-substitution openers (`(`, `$(`, backtick), **including `$(`/backtick inside double quotes** (Bash executes those, e.g. `echo "$(npm install -g x)"`; the outer double-quote mode resumes when the substitution closes so a trailing command isn't swallowed). Single-quoted bodies stay literal, matching Bash.
42
+ - The tokenizer is quote-aware (a separator inside `"..."`/`'...'` is literal) and escape-aware (`\x` is a literal `x`, so `\npm` resolves to `npm` and `\;` is not a separator). A `\<newline>` is a POSIX line continuation — it is removed and the surrounding text joined, so `npm install \⏎-g x` is one command (a global install), while a bare newline separates commands.
43
+
44
+ For each segment, the guard strips leading **preamble wrappers** (`sudo`, `env`, `command`, `exec`, `nice`, `nohup`, `stdbuf`, `setsid`, `time`, `xargs`, and any `VAR=val` assignment) — including their options, and the argument a flag consumes (`sudo -u nobody`, `nice -n 10`, `env -i`) — to find the real command word, then classifies:
45
+
46
+ 1. command word is `npm`/`npx`/`pnpm`/`pnpx`/`yarn` (or `bun`) **and** the segment has an install subcommand **and** a global flag → `globalInstall` (for `yarn`, the `global add` sequence must appear adjacent and in command position, so `yarn add global foo` — a local install of a package named `global` — is not misflagged);
47
+ 2. command word is a non-bun manager (not via global) → `nonBunPackageManager`;
48
+ 3. otherwise → allowed.
49
+
50
+ A `globalInstall` verdict on any segment wins over a plain non-bun verdict. This is a command-position detector, not a full shell parser — it doesn't interpret redirections or expansions beyond boundary marking — but it is linear-time and closes the structural gaps a single regex left open.
51
+
52
+ ## Scope: not a security boundary
53
+
54
+ This guard is a **hygiene nudge**, not an isolation mechanism. It deliberately does not chase manager invocations hidden inside a wrapper's code payload — `sh -c 'npm install'`, `bash -lc "pnpm add foo"`, `python -c '...os.system("npx tsc")'`, `node -e`, `eval`, `base64 | sh`, etc. That set is unbounded (any interpreter can reach any binary), and inspecting arbitrary `-c`/`-e` payloads is an arms race with diminishing returns and rising false-positive risk. An agent that genuinely wants a package manager can always reach one; the guard's job is to steer the common, direct invocations toward bun and to stop accidental global installs. The real isolation boundary is the per-tool **bwrap sandbox** (see `/docs/internals/sandbox`), not this policy. Optioned preamble _wrappers_ (`env -i`, `sudo -u`, `nice -n`) are handled because they prefix a real command word that the tokenizer can still see; code-payload wrappers are not, by design.
55
+
56
+ ## Why a tokenizer, not a regex
57
+
58
+ The earlier implementation matched boundary-anchored regexes against an escape/quote-normalized copy of the command. Review surfaced three structural gaps that are awkward to close with one regex but fall out naturally from the segment model:
59
+
60
+ - **Escaped / quoted command words.** `\npm install`, `"npm" install`, `'npm' install`, `n\px …` all run the real binary; the tokenizer collapses escapes and quotes at the word level, so each resolves to its bare command word.
61
+ - **Leading assignments.** `FOO=bar npm install` runs npm with `FOO` set. Stripping `VAR=val` (and `sudo`/`env`/`command`/`exec`/`nice`) preamble words finds the manager behind them.
62
+ - **Newline = separate command.** `npm install\n-g typescript` is two commands; the `-g` does not make the install global. Per-segment scoping means a flag in one segment never combines with an install in another, so this classifies as `nonBunPackageManager` (the `npm install` line), not `globalInstall`.
63
+
64
+ It also recognizes an explicit falsy global flag (`--global=false|0|no|off`) as **not** a global install, and detects managers inside subshells / command substitutions.
65
+
66
+ ## Option placement in global installs
67
+
68
+ Because classification scans a segment's words as a set (after preamble stripping), options may sit anywhere relative to the subcommand and the global flag, in either order: `npm --prefix /tmp install -g x`, `npm install --foo bar -g x`, `npm -g install x`, `pnpm add --reporter silent -g foo`, and `bun --cwd /x add -g foo` all attribute to `globalInstall`.
69
+
70
+ ## What is NOT blocked
71
+
72
+ - `bun`, `bunx`, `bun run`, `bun add`, `bun install` (local) — the intended package commands. (`bun add -g` / `bun install -g` are still blocked as global installs: bun globals live in `~/.bun`, outside `/agent`, and are wiped on restart.)
73
+ - A non-bun manager name appearing as a substring or argument: `my-npm-wrapper`, `./npm`, `cat npm-debug.log`, `git commit -m "drop npm"`, `grep -rn npx src/`, `echo "npm install -g foo"`. Only the **command word** of a segment is classified, so a manager name inside an argument, path, quoted string, or longer token never trips the guard.
74
+
75
+ ## Ordering against other bundled plugins
76
+
77
+ Registered after `guard` in `src/run/bundled-plugins.ts`. It guards a disjoint surface (package-manager bash commands), so its position only matters for precedence: keeping it after `security` and `guard` means any of their blocks wins first.
78
+
79
+ ## Tests
80
+
81
+ - `policy.test.ts` — pure-function unit tests for the detection logic: every global-install form, every non-bun manager, the allowed-command set (bun/bunx, substrings, paths, quoted text), both bypasses, the global-install-takes-precedence rule, escaped/quoted evasions, leading-assignment preambles, newline-as-separator scoping, falsy `--global=`, option placement, and subshell/substitution detection.
82
+ - `index.test.ts` — composition tests: the plugin registers the `tool.before` hook and wires it to the policy (block on global install, block on npx, allow bunx, honor the bypass).
@@ -0,0 +1,11 @@
1
+ import { definePlugin } from '@/plugin'
2
+
3
+ import { checkBunHygieneGuard } from './policy'
4
+
5
+ export default definePlugin({
6
+ plugin: async () => ({
7
+ hooks: {
8
+ 'tool.before': (event) => checkBunHygieneGuard({ tool: event.tool, args: event.args }),
9
+ },
10
+ }),
11
+ })