typeclaw 0.21.0 → 0.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -1
- package/src/agent/index.ts +55 -1
- package/src/agent/loop-guard.ts +180 -53
- package/src/bundled-plugins/bun-hygiene/README.md +82 -0
- package/src/bundled-plugins/bun-hygiene/index.ts +11 -0
- package/src/bundled-plugins/bun-hygiene/policy.ts +318 -0
- package/src/bundled-plugins/github-cli-auth/gh-command.ts +98 -6
- package/src/bundled-plugins/github-cli-auth/graphql-auth-nudge.ts +80 -0
- package/src/bundled-plugins/github-cli-auth/index.ts +7 -0
- package/src/bundled-plugins/memory/memory-logger.ts +6 -2
- package/src/bundled-plugins/reviewer/skills/code-review.ts +8 -0
- package/src/channels/adapters/discord-bot.ts +2 -0
- package/src/channels/adapters/github/inbound.ts +23 -1
- package/src/channels/adapters/github/index.ts +1 -0
- package/src/channels/adapters/slack-bot.ts +104 -5
- package/src/channels/manager.ts +8 -0
- package/src/channels/router.ts +68 -15
- package/src/channels/schema.ts +18 -0
- package/src/cli/dreams.ts +2 -1
- package/src/cli/inspect.ts +2 -1
- package/src/cli/ui.ts +34 -0
- package/src/commands/index.ts +5 -2
- package/src/config/config.ts +89 -0
- package/src/mcp/catalog.ts +29 -0
- package/src/mcp/client.ts +236 -0
- package/src/mcp/index.ts +25 -0
- package/src/mcp/manager.ts +156 -0
- package/src/mcp/tools.ts +190 -0
- package/src/permissions/builtins.ts +9 -0
- package/src/reload/format.ts +14 -0
- package/src/reload/index.ts +1 -0
- package/src/run/bundled-plugins.ts +7 -0
- package/src/run/channel-session-factory.ts +3 -0
- package/src/run/index.ts +38 -1
- package/src/server/command-runner.ts +5 -0
- package/src/server/index.ts +4 -0
- package/src/skills/typeclaw-channel-github/SKILL.md +83 -13
- package/typeclaw.schema.json +82 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "typeclaw",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.22.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",
|
package/src/agent/index.ts
CHANGED
|
@@ -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 {
|
|
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
|
})
|
package/src/agent/loop-guard.ts
CHANGED
|
@@ -1,39 +1,63 @@
|
|
|
1
|
-
// Detects when the model
|
|
2
|
-
//
|
|
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
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
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
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
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:
|
|
94
|
-
warned:
|
|
175
|
+
count: 0,
|
|
176
|
+
warned: false,
|
|
177
|
+
window: [],
|
|
178
|
+
windowWarned: new Set(),
|
|
95
179
|
}
|
|
96
180
|
|
|
97
|
-
if (
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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,
|
|
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 {
|
|
@@ -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
|
+
})
|