typeclaw 0.20.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/agent/restart/index.ts +101 -0
- package/src/agent/tools/restart.ts +23 -52
- 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-classify.ts +8 -2
- package/src/channels/adapters/discord-bot.ts +29 -2
- package/src/channels/adapters/github/decoy-reviewer.ts +43 -0
- package/src/channels/adapters/github/inbound.ts +92 -1
- package/src/channels/adapters/github/index.ts +12 -1
- package/src/channels/adapters/github/reactions.ts +138 -4
- package/src/channels/adapters/slack-bot-classify.ts +2 -2
- package/src/channels/adapters/slack-bot.ts +129 -7
- package/src/channels/engagement.ts +71 -31
- package/src/channels/manager.ts +8 -0
- package/src/channels/router.ts +180 -25
- package/src/channels/schema.ts +18 -0
- package/src/channels/types.ts +16 -1
- package/src/cli/builtins.ts +1 -0
- package/src/cli/dreams.ts +148 -0
- package/src/cli/index.ts +1 -0
- 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/dreams/git.ts +85 -0
- package/src/dreams/index.ts +134 -0
- package/src/dreams/parse.ts +224 -0
- package/src/dreams/render.ts +155 -0
- package/src/dreams/types.ts +50 -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 +53 -0
- package/src/shared/protocol.ts +2 -0
- package/src/skills/typeclaw-channel-github/SKILL.md +86 -22
- package/src/tui/index.ts +70 -18
- 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,101 @@
|
|
|
1
|
+
import { basename } from 'node:path'
|
|
2
|
+
|
|
3
|
+
import { writeRestartHandoff } from '@/agent/restart-handoff'
|
|
4
|
+
import { send, sendHttp } from '@/hostd/client'
|
|
5
|
+
import { containerSocketPath } from '@/hostd/paths'
|
|
6
|
+
import type { Stream } from '@/stream'
|
|
7
|
+
|
|
8
|
+
const ACK_TIMEOUT_MS = 5_000
|
|
9
|
+
|
|
10
|
+
export type ContainerRestartingBroadcast = {
|
|
11
|
+
kind: 'container-restarting'
|
|
12
|
+
restartedAt: string
|
|
13
|
+
originatingSessionId: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type RequestContainerRestartOptions = {
|
|
17
|
+
containerName: string
|
|
18
|
+
build?: boolean
|
|
19
|
+
socketPath?: string
|
|
20
|
+
hostdUrl?: string
|
|
21
|
+
hostdToken?: string
|
|
22
|
+
ackTimeoutMs?: number
|
|
23
|
+
// When present together with originatingSessionId, the post-ACK
|
|
24
|
+
// container-restarting broadcast is published here so every live session's
|
|
25
|
+
// subscribeRestartNotice fans out the restart notice (originator gets
|
|
26
|
+
// typeclaw.restart-self, siblings get typeclaw.restart). Both the tool and
|
|
27
|
+
// the server /restart path route through this so the broadcast->handoff
|
|
28
|
+
// ordering lives in one place.
|
|
29
|
+
stream?: Stream
|
|
30
|
+
agentDir?: string
|
|
31
|
+
originatingSessionId?: string
|
|
32
|
+
originatingSessionFile?: string
|
|
33
|
+
restartedAt?: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type RequestContainerRestartResult =
|
|
37
|
+
| { ok: true; containerName: string; restartedAt: string }
|
|
38
|
+
| { ok: false; containerName: string; reason: string }
|
|
39
|
+
|
|
40
|
+
export async function requestContainerRestart({
|
|
41
|
+
containerName,
|
|
42
|
+
build,
|
|
43
|
+
socketPath,
|
|
44
|
+
hostdUrl,
|
|
45
|
+
hostdToken,
|
|
46
|
+
ackTimeoutMs,
|
|
47
|
+
stream,
|
|
48
|
+
agentDir,
|
|
49
|
+
originatingSessionId,
|
|
50
|
+
originatingSessionFile,
|
|
51
|
+
restartedAt,
|
|
52
|
+
}: RequestContainerRestartOptions): Promise<RequestContainerRestartResult> {
|
|
53
|
+
const request = { kind: 'restart' as const, containerName, build: build === true }
|
|
54
|
+
const httpUrl = hostdUrl ?? process.env.TYPECLAW_HOSTD_URL
|
|
55
|
+
const httpToken = hostdToken ?? process.env.TYPECLAW_HOSTD_TOKEN
|
|
56
|
+
const ackBudget = ackTimeoutMs ?? ACK_TIMEOUT_MS
|
|
57
|
+
const reply =
|
|
58
|
+
httpUrl && httpToken
|
|
59
|
+
? await sendHttp(request, { timeoutMs: ackBudget, url: httpUrl, token: httpToken })
|
|
60
|
+
: await send(request, { timeoutMs: ackBudget, socket: socketPath ?? containerSocketPath() })
|
|
61
|
+
|
|
62
|
+
if (!reply.ok) return { ok: false, containerName, reason: reply.reason }
|
|
63
|
+
|
|
64
|
+
const restartTimestamp = restartedAt ?? new Date().toISOString()
|
|
65
|
+
|
|
66
|
+
// Fan out the restart notice to every live session BEFORE writing the handoff.
|
|
67
|
+
// The originating session's subscribeRestartNotice appends the
|
|
68
|
+
// typeclaw.restart-self entry synchronously (broker delivery + the JSONL
|
|
69
|
+
// append are both synchronous), so the handoff below points at a JSONL that
|
|
70
|
+
// already carries the "I'm back" instruction the rebooted container hydrates.
|
|
71
|
+
// Only after an accepted ACK, never on a failed/timed-out restart.
|
|
72
|
+
if (stream !== undefined && originatingSessionId !== undefined) {
|
|
73
|
+
const broadcast: ContainerRestartingBroadcast = {
|
|
74
|
+
kind: 'container-restarting',
|
|
75
|
+
restartedAt: restartTimestamp,
|
|
76
|
+
originatingSessionId,
|
|
77
|
+
}
|
|
78
|
+
stream.publish({ target: { kind: 'broadcast' }, payload: broadcast })
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Post-ACK: hostd has committed the restart, so a handoff-write failure must
|
|
82
|
+
// never demote it to a failure — that would render a false error in the TUI
|
|
83
|
+
// and swallow the accepted response. The handoff is a best-effort resume hint
|
|
84
|
+
// only; a missing one just cold-starts the rebooted container without the
|
|
85
|
+
// "I'm back" greeting. writeRestartHandoff swallows its own errors today, but
|
|
86
|
+
// guard here too so this contract survives the writer being changed later.
|
|
87
|
+
if (agentDir !== undefined && originatingSessionId !== undefined && originatingSessionFile !== undefined) {
|
|
88
|
+
try {
|
|
89
|
+
await writeRestartHandoff(agentDir, {
|
|
90
|
+
schemaVersion: 1,
|
|
91
|
+
restartedAt: restartTimestamp,
|
|
92
|
+
originatingSessionId,
|
|
93
|
+
originatingSessionFile: basename(originatingSessionFile),
|
|
94
|
+
})
|
|
95
|
+
} catch {
|
|
96
|
+
// intentional swallow — see the post-ACK rationale above
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return { ok: true, containerName, restartedAt: restartTimestamp }
|
|
101
|
+
}
|
|
@@ -1,14 +1,9 @@
|
|
|
1
|
-
import { basename } from 'node:path'
|
|
2
|
-
|
|
3
1
|
import { Type } from '@mariozechner/pi-ai'
|
|
4
2
|
import { defineTool } from '@mariozechner/pi-coding-agent'
|
|
5
3
|
|
|
6
|
-
import {
|
|
7
|
-
import { send, sendHttp } from '@/hostd/client'
|
|
8
|
-
import { containerSocketPath } from '@/hostd/paths'
|
|
4
|
+
import { requestContainerRestart } from '@/agent/restart'
|
|
9
5
|
import type { Stream } from '@/stream'
|
|
10
6
|
|
|
11
|
-
const ACK_TIMEOUT_MS = 5_000
|
|
12
7
|
const EXIT_DELAY_MS = 500
|
|
13
8
|
|
|
14
9
|
export type CreateRestartToolOptions = {
|
|
@@ -61,11 +56,7 @@ export type CreateRestartToolOptions = {
|
|
|
61
56
|
|
|
62
57
|
export type RestartToolDetails = { ok: boolean; containerName: string; reason?: string }
|
|
63
58
|
|
|
64
|
-
export type ContainerRestartingBroadcast
|
|
65
|
-
kind: 'container-restarting'
|
|
66
|
-
restartedAt: string
|
|
67
|
-
originatingSessionId: string
|
|
68
|
-
}
|
|
59
|
+
export type { ContainerRestartingBroadcast } from '@/agent/restart'
|
|
69
60
|
|
|
70
61
|
export function createRestartTool({
|
|
71
62
|
containerName,
|
|
@@ -80,9 +71,6 @@ export function createRestartTool({
|
|
|
80
71
|
originatingSessionFile,
|
|
81
72
|
}: CreateRestartToolOptions) {
|
|
82
73
|
const doExit = exit ?? ((code: number) => process.exit(code))
|
|
83
|
-
const httpUrl = hostdUrl ?? process.env.TYPECLAW_HOSTD_URL
|
|
84
|
-
const ackBudget = ackTimeoutMs ?? ACK_TIMEOUT_MS
|
|
85
|
-
const httpToken = hostdToken ?? process.env.TYPECLAW_HOSTD_TOKEN
|
|
86
74
|
|
|
87
75
|
return defineTool({
|
|
88
76
|
name: 'restart',
|
|
@@ -109,49 +97,32 @@ export function createRestartTool({
|
|
|
109
97
|
}),
|
|
110
98
|
async execute(_toolCallId, params) {
|
|
111
99
|
const build = params.build === true
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
100
|
+
// requestContainerRestart owns the post-ACK broadcast->handoff ordering:
|
|
101
|
+
// on a successful ACK it publishes the container-restarting notice (which
|
|
102
|
+
// every live session's subscribeRestartNotice turns into a transcript
|
|
103
|
+
// entry) and then writes the handoff. Handoff fields are gated to TUI
|
|
104
|
+
// origins by the caller passing originatingSessionFile only for those —
|
|
105
|
+
// see issue #291's scoping concerns.
|
|
106
|
+
const result = await requestContainerRestart({
|
|
107
|
+
containerName,
|
|
108
|
+
build,
|
|
109
|
+
originatingSessionId,
|
|
110
|
+
...(socketPath !== undefined ? { socketPath } : {}),
|
|
111
|
+
...(hostdUrl !== undefined ? { hostdUrl } : {}),
|
|
112
|
+
...(hostdToken !== undefined ? { hostdToken } : {}),
|
|
113
|
+
...(ackTimeoutMs !== undefined ? { ackTimeoutMs } : {}),
|
|
114
|
+
...(stream !== undefined ? { stream } : {}),
|
|
115
|
+
...(agentDir !== undefined ? { agentDir } : {}),
|
|
116
|
+
...(originatingSessionFile !== undefined ? { originatingSessionFile } : {}),
|
|
117
|
+
})
|
|
118
|
+
if (!result.ok) {
|
|
119
|
+
const details: RestartToolDetails = { ok: false, containerName, reason: result.reason }
|
|
119
120
|
return {
|
|
120
|
-
content: [{ type: 'text' as const, text: `restart denied: ${
|
|
121
|
+
content: [{ type: 'text' as const, text: `restart denied: ${result.reason}` }],
|
|
121
122
|
details,
|
|
122
123
|
}
|
|
123
124
|
}
|
|
124
125
|
|
|
125
|
-
// Hostd ACK == restart is committed. Fan out the notice to every live
|
|
126
|
-
// session BEFORE arming the exit timer. Stream broker delivery is
|
|
127
|
-
// synchronous (broker.ts deliver()) and SessionManager.appendCustomMessageEntry
|
|
128
|
-
// does a synchronous JSONL write, so the fan-out completes inside this
|
|
129
|
-
// tick — well before the EXIT_DELAY_MS timer fires.
|
|
130
|
-
const restartedAt = new Date().toISOString()
|
|
131
|
-
const broadcast: ContainerRestartingBroadcast = {
|
|
132
|
-
kind: 'container-restarting',
|
|
133
|
-
restartedAt,
|
|
134
|
-
originatingSessionId,
|
|
135
|
-
}
|
|
136
|
-
stream?.publish({ target: { kind: 'broadcast' }, payload: broadcast })
|
|
137
|
-
|
|
138
|
-
// Write the cross-restart handoff AFTER the broadcast has run so the
|
|
139
|
-
// originating session's JSONL already contains the `typeclaw.restart-self`
|
|
140
|
-
// custom message entry that the next container will hydrate on
|
|
141
|
-
// `SessionManager.open`. Without that ordering, the new container could
|
|
142
|
-
// theoretically open the JSONL before the entry was flushed and miss
|
|
143
|
-
// the model-instruction the entry carries. Gated on agentDir +
|
|
144
|
-
// originatingSessionFile so non-TUI origins (channel/cron/subagent)
|
|
145
|
-
// skip the file write — see issue #291's scoping concerns.
|
|
146
|
-
if (agentDir !== undefined && originatingSessionFile !== undefined) {
|
|
147
|
-
await writeRestartHandoff(agentDir, {
|
|
148
|
-
schemaVersion: 1,
|
|
149
|
-
restartedAt,
|
|
150
|
-
originatingSessionId,
|
|
151
|
-
originatingSessionFile: basename(originatingSessionFile),
|
|
152
|
-
})
|
|
153
|
-
}
|
|
154
|
-
|
|
155
126
|
// Schedule the exit on the next tick so the tool result is delivered to
|
|
156
127
|
// the model before the process dies. The host daemon polls for the
|
|
157
128
|
// container's removal before re-running `start`, so a small delay here
|