typeclaw 0.23.0 → 0.25.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/README.md +1 -1
- package/package.json +1 -1
- package/src/agent/index.ts +133 -27
- package/src/agent/llm-replay-sanitizer.ts +120 -0
- package/src/agent/loop-guard.ts +34 -0
- package/src/agent/multimodal/look-at.ts +1 -1
- package/src/agent/plugin-tools.ts +122 -8
- package/src/agent/restart/index.ts +15 -3
- package/src/agent/restart-handoff/index.ts +110 -12
- package/src/agent/session-origin.ts +30 -0
- package/src/agent/subagent-completion-reminder.ts +26 -1
- package/src/agent/subagents.ts +75 -3
- package/src/agent/system-prompt.ts +5 -1
- package/src/agent/todo/continuation-policy.ts +242 -0
- package/src/agent/todo/continuation-state.ts +87 -0
- package/src/agent/todo/continuation-wiring.ts +113 -0
- package/src/agent/todo/continuation.ts +71 -0
- package/src/agent/todo/scope.ts +77 -0
- package/src/agent/todo/store.ts +98 -0
- package/src/agent/tool-not-found-nudge.ts +126 -0
- package/src/agent/tools/channel-reply.ts +51 -0
- package/src/agent/tools/curl-impersonate.ts +2 -2
- package/src/agent/tools/restart.ts +11 -4
- package/src/agent/tools/spawn-subagent.ts +19 -2
- package/src/agent/tools/subagent-access.ts +40 -5
- package/src/agent/tools/subagent-cancel.ts +3 -1
- package/src/agent/tools/subagent-output.ts +6 -2
- package/src/agent/tools/todo/index.ts +119 -0
- package/src/agent/tools/webfetch/fetch.ts +18 -18
- package/src/agent/tools/webfetch/index.ts +1 -1
- package/src/agent/tools/webfetch/tool.ts +13 -13
- package/src/agent/tools/webfetch/types.ts +1 -1
- package/src/agent/tools/websearch.ts +6 -6
- package/src/bundled-plugins/backup/index.ts +40 -37
- package/src/bundled-plugins/backup/runner.ts +23 -2
- package/src/bundled-plugins/github-cli-auth/gh-command.ts +15 -7
- package/src/bundled-plugins/guard/policies/non-workspace-write.ts +38 -1
- package/src/bundled-plugins/memory/README.md +11 -11
- package/src/bundled-plugins/memory/dreaming.ts +5 -0
- package/src/bundled-plugins/memory/search-tool.ts +98 -1
- package/src/bundled-plugins/operator/operator.ts +5 -1
- package/src/bundled-plugins/reviewer/reviewer.ts +32 -9
- package/src/bundled-plugins/reviewer/skills/code-review.ts +1 -1
- package/src/bundled-plugins/reviewer/skills/general.ts +1 -1
- package/src/bundled-plugins/scout/scout.ts +7 -7
- package/src/bundled-plugins/security/policies/private-surface-read.ts +2 -2
- package/src/bundled-plugins/security/policies/ssrf.ts +3 -3
- package/src/bundled-plugins/tool-result-cap/README.md +1 -1
- package/src/channels/adapters/discord-bot-reference.ts +78 -0
- package/src/channels/adapters/discord-bot.ts +25 -3
- package/src/channels/adapters/github/inbound.ts +172 -10
- package/src/channels/adapters/github/index.ts +10 -0
- package/src/channels/adapters/github/review-thread-resolver.ts +246 -0
- package/src/channels/adapters/github/webhook-register.ts +32 -27
- package/src/channels/adapters/kakaotalk-classify.ts +67 -6
- package/src/channels/adapters/slack-bot-classify.ts +9 -1
- package/src/channels/adapters/slack-bot-reference.ts +129 -0
- package/src/channels/adapters/slack-bot.ts +67 -8
- package/src/channels/manager.ts +8 -2
- package/src/channels/router.ts +506 -45
- package/src/channels/schema.ts +21 -4
- package/src/channels/subagent-completion-bridge.ts +18 -18
- package/src/channels/types.ts +69 -1
- package/src/cli/inspect-controller.ts +132 -33
- package/src/cli/inspect.ts +2 -1
- package/src/commands/index.ts +9 -0
- package/src/container/start.ts +7 -1
- package/src/git/mutex.ts +22 -0
- package/src/git/reconcile-ignored.ts +214 -0
- package/src/hostd/daemon.ts +26 -1
- package/src/hostd/portbroker-manager.ts +7 -0
- package/src/init/dockerfile.ts +1 -1
- package/src/init/gitignore.ts +28 -16
- package/src/inspect/index.ts +53 -4
- package/src/inspect/loop.ts +16 -12
- package/src/plugin/define.ts +2 -2
- package/src/plugin/index.ts +2 -2
- package/src/portbroker/hostd-client.ts +36 -13
- package/src/run/index.ts +74 -5
- package/src/sandbox/build.ts +20 -0
- package/src/sandbox/index.ts +10 -0
- package/src/sandbox/policy.ts +22 -0
- package/src/sandbox/session-tmp.ts +43 -0
- package/src/sandbox/writable-zones.ts +178 -0
- package/src/server/command-runner.ts +1 -1
- package/src/server/index.ts +126 -4
- package/src/skills/typeclaw-channel-github/SKILL.md +71 -17
- package/src/skills/typeclaw-memory/SKILL.md +3 -1
- package/src/tui/format.ts +11 -11
- package/typeclaw.schema.json +10 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { Type } from '@mariozechner/pi-ai'
|
|
2
|
+
import { defineTool } from '@mariozechner/pi-coding-agent'
|
|
3
|
+
|
|
4
|
+
import type { SessionOrigin } from '@/agent/session-origin'
|
|
5
|
+
import { resolveTodoScope, type TodoScope } from '@/agent/todo/scope'
|
|
6
|
+
import { incompleteTodos, type Todo, TODO_PRIORITIES, TODO_STATUSES, readTodos, writeTodos } from '@/agent/todo/store'
|
|
7
|
+
|
|
8
|
+
export type CreateTodoToolsOptions = {
|
|
9
|
+
agentDir: string
|
|
10
|
+
getOrigin: () => SessionOrigin | undefined
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const NO_SCOPE_NOTICE =
|
|
14
|
+
'Todos are owned by the originating session. This session (a subagent, system task, or one ' +
|
|
15
|
+
'with no resolvable origin) does not own a todo list, so the call was a no-op.'
|
|
16
|
+
|
|
17
|
+
type TodoToolDetails = {
|
|
18
|
+
ok: boolean
|
|
19
|
+
reason?: string
|
|
20
|
+
total?: number
|
|
21
|
+
remaining?: number
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Resolve the scope for the current origin, or null when this session owns no
|
|
25
|
+
// todo list. An UNDEFINED origin is treated as no-scope, NOT defaulted to the
|
|
26
|
+
// shared TUI scope — defaulting would fail open, silently routing an unknown
|
|
27
|
+
// actor's todos into the operator's global `tui` list.
|
|
28
|
+
function scopeForOrigin(getOrigin: () => SessionOrigin | undefined): TodoScope | null {
|
|
29
|
+
const origin = getOrigin()
|
|
30
|
+
return origin === undefined ? null : resolveTodoScope(origin)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const TODO_ITEM = Type.Object({
|
|
34
|
+
content: Type.String({ minLength: 1, description: 'What the task is.' }),
|
|
35
|
+
status: Type.Union(
|
|
36
|
+
TODO_STATUSES.map((s) => Type.Literal(s)),
|
|
37
|
+
{ description: 'One of: pending, in_progress, completed, cancelled.' },
|
|
38
|
+
),
|
|
39
|
+
priority: Type.Optional(Type.Union(TODO_PRIORITIES.map((p) => Type.Literal(p)))),
|
|
40
|
+
id: Type.Optional(Type.String()),
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
export function createTodoTools({ agentDir, getOrigin }: CreateTodoToolsOptions) {
|
|
44
|
+
const writeTool = defineTool({
|
|
45
|
+
name: 'todo_write',
|
|
46
|
+
label: 'Write Todos',
|
|
47
|
+
description:
|
|
48
|
+
'Replace your entire todo list for this session with the provided items. Maintain a todo ' +
|
|
49
|
+
'list for any multi-step or long-running task so that if this session is interrupted ' +
|
|
50
|
+
'(restart, crash, or a later turn), you can resume the remaining work instead of silently ' +
|
|
51
|
+
'dropping it. Mark items `completed` (or `cancelled`) as you finish them by writing the full ' +
|
|
52
|
+
'list again with updated statuses. This is a full replace, not a merge: include every item ' +
|
|
53
|
+
'you still care about on each call.',
|
|
54
|
+
parameters: Type.Object({
|
|
55
|
+
todos: Type.Array(TODO_ITEM, { description: 'The complete todo list. Replaces any prior list.' }),
|
|
56
|
+
}),
|
|
57
|
+
async execute(_toolCallId, params) {
|
|
58
|
+
const scope = scopeForOrigin(getOrigin)
|
|
59
|
+
if (scope === null) {
|
|
60
|
+
const details: TodoToolDetails = { ok: false, reason: 'no-scope' }
|
|
61
|
+
return { content: [{ type: 'text' as const, text: NO_SCOPE_NOTICE }], details }
|
|
62
|
+
}
|
|
63
|
+
const todos = params.todos as Todo[]
|
|
64
|
+
await writeTodos(agentDir, scope, todos)
|
|
65
|
+
const remaining = incompleteTodos(todos).length
|
|
66
|
+
const details: TodoToolDetails = { ok: true, total: todos.length, remaining }
|
|
67
|
+
return {
|
|
68
|
+
content: [
|
|
69
|
+
{
|
|
70
|
+
type: 'text' as const,
|
|
71
|
+
text: `Saved ${todos.length} todo(s); ${remaining} remaining (${todos.length - remaining} done).`,
|
|
72
|
+
},
|
|
73
|
+
],
|
|
74
|
+
details,
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
const readTool = defineTool({
|
|
80
|
+
name: 'todo_read',
|
|
81
|
+
label: 'Read Todos',
|
|
82
|
+
description: 'Return your current todo list for this session. Use it to re-sync after an interruption.',
|
|
83
|
+
parameters: Type.Object({}),
|
|
84
|
+
async execute() {
|
|
85
|
+
const scope = scopeForOrigin(getOrigin)
|
|
86
|
+
if (scope === null) {
|
|
87
|
+
const details: TodoToolDetails = { ok: false, reason: 'no-scope' }
|
|
88
|
+
return { content: [{ type: 'text' as const, text: NO_SCOPE_NOTICE }], details }
|
|
89
|
+
}
|
|
90
|
+
const todos = await readTodos(agentDir, scope)
|
|
91
|
+
const details: TodoToolDetails = { ok: true, total: todos.length }
|
|
92
|
+
return {
|
|
93
|
+
content: [{ type: 'text' as const, text: JSON.stringify(todos, null, 2) }],
|
|
94
|
+
details,
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
const clearTool = defineTool({
|
|
100
|
+
name: 'todo_clear',
|
|
101
|
+
label: 'Clear Todos',
|
|
102
|
+
description:
|
|
103
|
+
'Empty your todo list for this session. Call this when all work is genuinely done or the ' +
|
|
104
|
+
'task was abandoned, so the runtime stops tracking pending work.',
|
|
105
|
+
parameters: Type.Object({}),
|
|
106
|
+
async execute() {
|
|
107
|
+
const scope = scopeForOrigin(getOrigin)
|
|
108
|
+
if (scope === null) {
|
|
109
|
+
const details: TodoToolDetails = { ok: false, reason: 'no-scope' }
|
|
110
|
+
return { content: [{ type: 'text' as const, text: NO_SCOPE_NOTICE }], details }
|
|
111
|
+
}
|
|
112
|
+
await writeTodos(agentDir, scope, [])
|
|
113
|
+
const details: TodoToolDetails = { ok: true }
|
|
114
|
+
return { content: [{ type: 'text' as const, text: 'Todo list cleared.' }], details }
|
|
115
|
+
},
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
return [writeTool, readTool, clearTool]
|
|
119
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
//
|
|
1
|
+
// WebFetch's HTTP transport.
|
|
2
2
|
//
|
|
3
3
|
// Production path (container, curl-impersonate available): we shell out to
|
|
4
4
|
// `curl_chrome136` so outbound requests carry Chrome 136's TLS handshake
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
//
|
|
18
18
|
// Best-effort doctrine: this transport does NOT guarantee the fetch succeeds.
|
|
19
19
|
// Bot-detected sites can still serve 403/CAPTCHA pages. We surface what we
|
|
20
|
-
// got (status, body, final URL) and let the caller decide. The
|
|
20
|
+
// got (status, body, final URL) and let the caller decide. The web_fetch tool
|
|
21
21
|
// translates non-2xx into a tool-level error message that's useful to the
|
|
22
22
|
// model.
|
|
23
23
|
|
|
@@ -38,10 +38,10 @@ export type FetchResult = {
|
|
|
38
38
|
bytesIn: number
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
export class
|
|
41
|
+
export class WebFetchError extends Error {
|
|
42
42
|
constructor(message: string) {
|
|
43
43
|
super(message)
|
|
44
|
-
this.name = '
|
|
44
|
+
this.name = 'WebFetchError'
|
|
45
45
|
}
|
|
46
46
|
}
|
|
47
47
|
|
|
@@ -55,7 +55,7 @@ export function normalizeUrl(input: string): string {
|
|
|
55
55
|
const trimmed = input.trim()
|
|
56
56
|
if (/^[a-z][a-z0-9+.-]*:/i.test(trimmed)) {
|
|
57
57
|
if (!trimmed.startsWith('http://') && !trimmed.startsWith('https://')) {
|
|
58
|
-
throw new
|
|
58
|
+
throw new WebFetchError('URL must use http:// or https://')
|
|
59
59
|
}
|
|
60
60
|
return trimmed
|
|
61
61
|
}
|
|
@@ -100,28 +100,28 @@ async function fetchWithCurlImpersonate(
|
|
|
100
100
|
})
|
|
101
101
|
} catch (error) {
|
|
102
102
|
if (parentSignal?.aborted) {
|
|
103
|
-
throw new
|
|
103
|
+
throw new WebFetchError('Request aborted')
|
|
104
104
|
}
|
|
105
105
|
if (error instanceof CurlImpersonateError) {
|
|
106
106
|
if (isCurlExitTimeout(error)) {
|
|
107
|
-
throw new
|
|
107
|
+
throw new WebFetchError(`Request timed out after ${timeoutSeconds}s`)
|
|
108
108
|
}
|
|
109
109
|
if (isCurlExitFilesizeExceeded(error)) {
|
|
110
|
-
throw new
|
|
110
|
+
throw new WebFetchError(`Response too large (exceeds ${formatBytes(MAX_RESPONSE_BYTES)} limit)`)
|
|
111
111
|
}
|
|
112
|
-
throw new
|
|
112
|
+
throw new WebFetchError(`Fetch failed: ${error.message}`)
|
|
113
113
|
}
|
|
114
114
|
const message = error instanceof Error ? error.message : String(error)
|
|
115
|
-
throw new
|
|
115
|
+
throw new WebFetchError(`Fetch failed: ${message}`)
|
|
116
116
|
}
|
|
117
117
|
|
|
118
118
|
if (response.httpStatus < 200 || response.httpStatus >= 300) {
|
|
119
|
-
throw new
|
|
119
|
+
throw new WebFetchError(`Fetch failed: HTTP ${response.httpStatus}`)
|
|
120
120
|
}
|
|
121
121
|
|
|
122
122
|
const bodyByteLength = new TextEncoder().encode(response.body).byteLength
|
|
123
123
|
if (bodyByteLength > MAX_RESPONSE_BYTES) {
|
|
124
|
-
throw new
|
|
124
|
+
throw new WebFetchError(
|
|
125
125
|
`Response too large (${formatBytes(bodyByteLength)} exceeds ${formatBytes(MAX_RESPONSE_BYTES)} limit)`,
|
|
126
126
|
)
|
|
127
127
|
}
|
|
@@ -148,14 +148,14 @@ async function fetchWithBunFetch(
|
|
|
148
148
|
try {
|
|
149
149
|
const response = await fetch(url, { headers: FALLBACK_HEADERS, signal: controller.signal, redirect: 'follow' })
|
|
150
150
|
if (!response.ok) {
|
|
151
|
-
throw new
|
|
151
|
+
throw new WebFetchError(`Fetch failed: HTTP ${response.status} ${response.statusText}`)
|
|
152
152
|
}
|
|
153
153
|
|
|
154
154
|
const contentLengthHeader = response.headers.get('content-length')
|
|
155
155
|
if (contentLengthHeader) {
|
|
156
156
|
const declared = Number(contentLengthHeader)
|
|
157
157
|
if (Number.isFinite(declared) && declared > MAX_RESPONSE_BYTES) {
|
|
158
|
-
throw new
|
|
158
|
+
throw new WebFetchError(
|
|
159
159
|
`Response too large (${formatBytes(declared)} exceeds ${formatBytes(MAX_RESPONSE_BYTES)} limit)`,
|
|
160
160
|
)
|
|
161
161
|
}
|
|
@@ -163,7 +163,7 @@ async function fetchWithBunFetch(
|
|
|
163
163
|
|
|
164
164
|
const buffer = await response.arrayBuffer()
|
|
165
165
|
if (buffer.byteLength > MAX_RESPONSE_BYTES) {
|
|
166
|
-
throw new
|
|
166
|
+
throw new WebFetchError(
|
|
167
167
|
`Response too large (${formatBytes(buffer.byteLength)} exceeds ${formatBytes(MAX_RESPONSE_BYTES)} limit)`,
|
|
168
168
|
)
|
|
169
169
|
}
|
|
@@ -182,11 +182,11 @@ async function fetchWithBunFetch(
|
|
|
182
182
|
controller.signal.reason instanceof Error &&
|
|
183
183
|
controller.signal.reason.message === 'timeout'
|
|
184
184
|
) {
|
|
185
|
-
throw new
|
|
185
|
+
throw new WebFetchError(`Request timed out after ${timeoutSeconds}s`)
|
|
186
186
|
}
|
|
187
|
-
if (error instanceof
|
|
187
|
+
if (error instanceof WebFetchError) throw error
|
|
188
188
|
const message = error instanceof Error ? error.message : String(error)
|
|
189
|
-
throw new
|
|
189
|
+
throw new WebFetchError(`Fetch failed: ${message}`)
|
|
190
190
|
} finally {
|
|
191
191
|
clearTimeout(timeout)
|
|
192
192
|
parentSignal?.removeEventListener('abort', onAbort)
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export {
|
|
1
|
+
export { webFetchTool } from './tool'
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Type } from '@mariozechner/pi-ai'
|
|
2
2
|
import { defineTool } from '@mariozechner/pi-coding-agent'
|
|
3
3
|
|
|
4
|
-
import { fetchWithLimits, normalizeUrl, parseMimeType,
|
|
4
|
+
import { fetchWithLimits, normalizeUrl, parseMimeType, WebFetchError } from './fetch'
|
|
5
5
|
import { applyGrep, GrepError } from './strategies/grep'
|
|
6
6
|
import { applyJq, JqError } from './strategies/jq'
|
|
7
7
|
import { applyRaw } from './strategies/raw'
|
|
@@ -13,17 +13,17 @@ import {
|
|
|
13
13
|
DEFAULT_TIMEOUT_SECONDS,
|
|
14
14
|
MAX_TIMEOUT_SECONDS,
|
|
15
15
|
OUTPUT_CAPS,
|
|
16
|
-
type
|
|
16
|
+
type WebFetchDetails,
|
|
17
17
|
} from './types'
|
|
18
18
|
|
|
19
19
|
const STRATEGY_VALUES = ['readability', 'jq', 'selector', 'grep', 'snapshot', 'raw'] as const
|
|
20
20
|
|
|
21
|
-
export const
|
|
22
|
-
name: '
|
|
21
|
+
export const webFetchTool = defineTool({
|
|
22
|
+
name: 'web_fetch',
|
|
23
23
|
label: 'Web Fetch',
|
|
24
24
|
description:
|
|
25
25
|
'Fetch a single HTTP(S) URL and return the body, optionally compacted by a strategy. ' +
|
|
26
|
-
'Use this when the user references a specific URL or when
|
|
26
|
+
'Use this when the user references a specific URL or when web_search surfaced a result you need to read in full. ' +
|
|
27
27
|
'If `spawn_subagent` is available to you, PREFER delegating to the `scout` subagent by default: spawn it whenever you expect more than one fetch, an "across multiple sources" task, or any search-then-fetch loop. Scout runs the noisy fetching in its own context window and returns a distilled, citation-backed answer, keeping bulky page bodies out of yours. Only call this tool directly for a single known URL whose content you will cite immediately — or whenever you cannot spawn subagents (e.g. you are yourself a subagent), in which case fetch here. ' +
|
|
28
28
|
'Outbound requests impersonate Chrome 136 at the TLS, HTTP/2, and header layers ' +
|
|
29
29
|
'(via curl-impersonate), which helps with TLS/header fingerprint gates on sites behind Cloudflare/Akamai. ' +
|
|
@@ -72,7 +72,7 @@ export const webfetchTool = defineTool({
|
|
|
72
72
|
try {
|
|
73
73
|
normalizedUrl = normalizeUrl(inputUrl)
|
|
74
74
|
} catch (error) {
|
|
75
|
-
const message = error instanceof
|
|
75
|
+
const message = error instanceof WebFetchError ? error.message : `Invalid URL: ${error}`
|
|
76
76
|
return errorResult(inputUrl, message, { startedAt })
|
|
77
77
|
}
|
|
78
78
|
|
|
@@ -130,7 +130,7 @@ export const webfetchTool = defineTool({
|
|
|
130
130
|
}
|
|
131
131
|
|
|
132
132
|
const capped = capOutput(output, strategy)
|
|
133
|
-
const details:
|
|
133
|
+
const details: WebFetchDetails = {
|
|
134
134
|
url: normalizedUrl,
|
|
135
135
|
finalUrl: response.finalUrl,
|
|
136
136
|
strategy,
|
|
@@ -150,7 +150,7 @@ export const webfetchTool = defineTool({
|
|
|
150
150
|
},
|
|
151
151
|
})
|
|
152
152
|
|
|
153
|
-
type
|
|
153
|
+
type WebFetchParams = {
|
|
154
154
|
url: string
|
|
155
155
|
strategy?: CompactionStrategy
|
|
156
156
|
query?: string
|
|
@@ -187,7 +187,7 @@ function resolveStrategy(explicit: CompactionStrategy | undefined, mime: string)
|
|
|
187
187
|
return { kind: 'ok', strategy: 'raw', autoDetected: true }
|
|
188
188
|
}
|
|
189
189
|
|
|
190
|
-
function validateStrategyArgs(strategy: CompactionStrategy, params:
|
|
190
|
+
function validateStrategyArgs(strategy: CompactionStrategy, params: WebFetchParams): string | null {
|
|
191
191
|
if (strategy === 'jq' && !params.query) return 'Missing required arg `query` for strategy "jq".'
|
|
192
192
|
if (strategy === 'selector' && !params.selector) return 'Missing required arg `selector` for strategy "selector".'
|
|
193
193
|
if (strategy === 'grep' && !params.pattern) return 'Missing required arg `pattern` for strategy "grep".'
|
|
@@ -198,7 +198,7 @@ async function runStrategy(
|
|
|
198
198
|
strategy: CompactionStrategy,
|
|
199
199
|
body: string,
|
|
200
200
|
url: string,
|
|
201
|
-
params:
|
|
201
|
+
params: WebFetchParams,
|
|
202
202
|
): Promise<string> {
|
|
203
203
|
switch (strategy) {
|
|
204
204
|
case 'raw':
|
|
@@ -250,10 +250,10 @@ function capOutput(text: string, strategy: CompactionStrategy): { text: string;
|
|
|
250
250
|
function errorResult(
|
|
251
251
|
url: string,
|
|
252
252
|
message: string,
|
|
253
|
-
partial: Partial<
|
|
254
|
-
): { content: [{ type: 'text'; text: string }]; details:
|
|
253
|
+
partial: Partial<WebFetchDetails> & { startedAt: number },
|
|
254
|
+
): { content: [{ type: 'text'; text: string }]; details: WebFetchDetails } {
|
|
255
255
|
const { startedAt, ...rest } = partial
|
|
256
|
-
const details:
|
|
256
|
+
const details: WebFetchDetails = {
|
|
257
257
|
url,
|
|
258
258
|
finalUrl: rest.finalUrl ?? url,
|
|
259
259
|
strategy: rest.strategy ?? 'none',
|
|
@@ -7,7 +7,7 @@ import { wikipediaSearch, type WikipediaResult } from './wikipedia'
|
|
|
7
7
|
const DEFAULT_LIMIT = 10
|
|
8
8
|
const MAX_LIMIT = 20
|
|
9
9
|
|
|
10
|
-
type
|
|
10
|
+
type WebSearchDetails = {
|
|
11
11
|
query: string
|
|
12
12
|
source: 'web' | 'wikipedia' | 'none'
|
|
13
13
|
count: number
|
|
@@ -16,12 +16,12 @@ type WebsearchDetails = {
|
|
|
16
16
|
message?: string
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
export const
|
|
20
|
-
name: '
|
|
19
|
+
export const webSearchTool = defineTool({
|
|
20
|
+
name: 'web_search',
|
|
21
21
|
label: 'Web Search',
|
|
22
22
|
description:
|
|
23
23
|
'Search the public web. Returns a ranked list of {title, url, snippet} entries. Use `source: "wikipedia"` for encyclopedic lookups; otherwise default to general web results from DuckDuckGo. Pair this with the `read` tool by visiting URLs you find with `bash` (curl) when you need full page contents.\n' +
|
|
24
|
-
'If `spawn_subagent` is available to you, PREFER delegating to the `scout` subagent by default: spawn it whenever the research is non-trivial (more than 1-2 queries, any "across multiple sources" framing, or follow-up fetches of the results). Scout runs `
|
|
24
|
+
'If `spawn_subagent` is available to you, PREFER delegating to the `scout` subagent by default: spawn it whenever the research is non-trivial (more than 1-2 queries, any "across multiple sources" framing, or follow-up fetches of the results). Scout runs `web_search`/`web_fetch` in its own context window and returns a distilled, citation-backed answer, so the search churn never pollutes yours. Only call this tool directly for a single query whose top result you will cite immediately — or whenever you cannot spawn subagents (e.g. you are yourself a subagent), in which case run the searches here.',
|
|
25
25
|
parameters: Type.Object({
|
|
26
26
|
query: Type.String({ description: 'The search query.' }),
|
|
27
27
|
limit: Type.Optional(
|
|
@@ -66,7 +66,7 @@ function clampLimit(value: number | undefined): number {
|
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
function successResult(query: string, source: 'web' | 'wikipedia', results: DdgResult[] | WikipediaResult[]) {
|
|
69
|
-
const details:
|
|
69
|
+
const details: WebSearchDetails = { query, source, count: results.length, results }
|
|
70
70
|
if (results.length === 0) {
|
|
71
71
|
return {
|
|
72
72
|
content: [{ type: 'text' as const, text: `No results for "${query}" on ${source}.` }],
|
|
@@ -89,7 +89,7 @@ function successResult(query: string, source: 'web' | 'wikipedia', results: DdgR
|
|
|
89
89
|
}
|
|
90
90
|
|
|
91
91
|
function errorResult(message: string) {
|
|
92
|
-
const details:
|
|
92
|
+
const details: WebSearchDetails = { query: '', source: 'none', count: 0, results: [], error: true, message }
|
|
93
93
|
return {
|
|
94
94
|
content: [{ type: 'text' as const, text: message }],
|
|
95
95
|
details,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod'
|
|
2
2
|
|
|
3
|
+
import { withGitLock } from '@/git/mutex'
|
|
3
4
|
import { definePlugin, type PluginContext, type SpawnSubagentOptions, type Subagent } from '@/plugin'
|
|
4
5
|
|
|
5
6
|
import { COMMIT_TIMEOUT_MS, makeDefaultGitSpawn, NETWORK_TIMEOUT_MS, runBackup, type BackupResult } from './runner'
|
|
@@ -174,44 +175,46 @@ async function runBackupOnce(
|
|
|
174
175
|
spawnedByOrigin: { kind: 'tui', sessionId: 'backup-runner' },
|
|
175
176
|
}
|
|
176
177
|
|
|
177
|
-
const result = await
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
178
|
+
const result = await withGitLock(payload.agentDir, () =>
|
|
179
|
+
runBackup(
|
|
180
|
+
{ cwd: payload.agentDir, pushToOrigin: payload.pushToOrigin },
|
|
181
|
+
{
|
|
182
|
+
gitSpawn: makeDefaultGitSpawn(),
|
|
183
|
+
pickCommitMessage: async ({ status, diffstat }) => {
|
|
184
|
+
await cleanupMessageFile(messagePath)
|
|
185
|
+
const messagePayload: CommitMessagePayload = {
|
|
186
|
+
agentDir: payload.agentDir,
|
|
187
|
+
status,
|
|
188
|
+
diffstat,
|
|
189
|
+
outputPath: messagePath,
|
|
190
|
+
}
|
|
191
|
+
try {
|
|
192
|
+
await ctx.spawnSubagent(SUBAGENT_COMMIT_MESSAGE, messagePayload, inheritOwner)
|
|
193
|
+
} catch (err) {
|
|
194
|
+
ctx.logger.warn(
|
|
195
|
+
`${SUBAGENT_COMMIT_MESSAGE} subagent failed, using fallback: ${err instanceof Error ? err.message : String(err)}`,
|
|
196
|
+
)
|
|
197
|
+
}
|
|
198
|
+
const written = await readMessageFile(messagePath)
|
|
199
|
+
await cleanupMessageFile(messagePath)
|
|
200
|
+
return written ?? 'chore: backup'
|
|
201
|
+
},
|
|
202
|
+
diagnoseFailure: async (input) => {
|
|
203
|
+
const diagPayload: DiagnoseFailurePayload = {
|
|
204
|
+
agentDir: input.cwd,
|
|
205
|
+
stage: input.stage,
|
|
206
|
+
exitCode: input.exitCode,
|
|
207
|
+
stderr: input.stderr,
|
|
208
|
+
stdout: input.stdout,
|
|
209
|
+
}
|
|
210
|
+
try {
|
|
211
|
+
await ctx.spawnSubagent(SUBAGENT_DIAGNOSE, diagPayload, inheritOwner)
|
|
212
|
+
} catch (err) {
|
|
213
|
+
ctx.logger.warn(`${SUBAGENT_DIAGNOSE} subagent failed: ${err instanceof Error ? err.message : String(err)}`)
|
|
214
|
+
}
|
|
215
|
+
},
|
|
213
216
|
},
|
|
214
|
-
|
|
217
|
+
),
|
|
215
218
|
)
|
|
216
219
|
|
|
217
220
|
await cleanupMessageFile(messagePath)
|
|
@@ -5,7 +5,7 @@ export const COMMIT_TIMEOUT_MS = 30_000
|
|
|
5
5
|
export const NETWORK_TIMEOUT_MS = 60_000
|
|
6
6
|
|
|
7
7
|
const RUNTIME_OWNED_PREFIXES = ['memory/'] as const
|
|
8
|
-
const FORCE_ADD_PREFIXES = ['sessions/'] as const
|
|
8
|
+
const FORCE_ADD_PREFIXES = ['sessions/', 'todo/'] as const
|
|
9
9
|
|
|
10
10
|
const NONINTERACTIVE_ENV = {
|
|
11
11
|
GIT_TERMINAL_PROMPT: '0',
|
|
@@ -217,7 +217,7 @@ function sanitizeCommitMessage(raw: string): string {
|
|
|
217
217
|
}
|
|
218
218
|
|
|
219
219
|
export function makeDefaultGitSpawn(): GitSpawn {
|
|
220
|
-
return async (args, { cwd, timeoutMs }) => {
|
|
220
|
+
return withIndexLockRetry(async (args, { cwd, timeoutMs }) => {
|
|
221
221
|
const bun = (globalThis as { Bun?: { spawn: typeof Bun.spawn } }).Bun
|
|
222
222
|
if (!bun) {
|
|
223
223
|
return { exitCode: 127, stdout: '', stderr: 'Bun runtime not available', timedOut: false }
|
|
@@ -249,5 +249,26 @@ export function makeDefaultGitSpawn(): GitSpawn {
|
|
|
249
249
|
} finally {
|
|
250
250
|
clearTimeout(timer)
|
|
251
251
|
}
|
|
252
|
+
})
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export function withIndexLockRetry(spawn: GitSpawn): GitSpawn {
|
|
256
|
+
return async (args, opts) => {
|
|
257
|
+
let result = await spawn(args, opts)
|
|
258
|
+
for (const delayMs of [50, 150, 350]) {
|
|
259
|
+
if (result.exitCode === 0 || !isIndexLockContention(result.stderr)) return result
|
|
260
|
+
await sleep(delayMs)
|
|
261
|
+
result = await spawn(args, opts)
|
|
262
|
+
}
|
|
263
|
+
return result
|
|
252
264
|
}
|
|
253
265
|
}
|
|
266
|
+
|
|
267
|
+
function isIndexLockContention(stderr: string): boolean {
|
|
268
|
+
const lower = stderr.toLowerCase()
|
|
269
|
+
return lower.includes('index.lock') || (lower.includes('unable to create') && lower.includes('index.lock'))
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async function sleep(ms: number): Promise<void> {
|
|
273
|
+
await new Promise<void>((resolve) => setTimeout(resolve, ms))
|
|
274
|
+
}
|
|
@@ -281,7 +281,7 @@ function isCommandBoundaryBefore(tokens: readonly string[], index: number): bool
|
|
|
281
281
|
while (cursor >= 0) {
|
|
282
282
|
const prev = tokens[cursor]
|
|
283
283
|
if (prev === undefined) return false
|
|
284
|
-
if (prev === '&&' || prev === '||' || prev === '|' || prev === ';') return true
|
|
284
|
+
if (prev === '&&' || prev === '||' || prev === '|' || prev === ';' || prev === '\n') return true
|
|
285
285
|
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(prev)) {
|
|
286
286
|
cursor -= 1
|
|
287
287
|
continue
|
|
@@ -409,11 +409,14 @@ function isPlaceholderSegment(segment: string): boolean {
|
|
|
409
409
|
return segment.includes('{') || segment.includes('}')
|
|
410
410
|
}
|
|
411
411
|
|
|
412
|
-
// Splits on whitespace AND shell control operators (; | & && ||) so a
|
|
413
|
-
// like `true; gh ...` (no surrounding spaces)
|
|
414
|
-
// token.
|
|
415
|
-
//
|
|
416
|
-
//
|
|
412
|
+
// Splits on whitespace AND shell control operators (newline ; | & && ||) so a
|
|
413
|
+
// boundary like `true; gh ...` (no surrounding spaces) or a `gh` on its own line
|
|
414
|
+
// yields a standalone separator token. A newline ends a simple command in bash,
|
|
415
|
+
// so it must be a boundary too — otherwise a `gh` on a later line (e.g. after a
|
|
416
|
+
// heredoc) is not seen at command position and escapes classification. Quote-
|
|
417
|
+
// aware: operators inside quotes are literal. This is a command-position
|
|
418
|
+
// detector, not a full shell parser — it does not interpret redirections,
|
|
419
|
+
// subshells, heredoc bodies, or backgrounding semantics beyond boundary marking.
|
|
417
420
|
function tokenize(command: string): string[] {
|
|
418
421
|
const tokens: string[] = []
|
|
419
422
|
let current = ''
|
|
@@ -441,10 +444,15 @@ function tokenize(command: string): string[] {
|
|
|
441
444
|
hasContent = true
|
|
442
445
|
continue
|
|
443
446
|
}
|
|
444
|
-
if (ch === ' ' || ch === '\t'
|
|
447
|
+
if (ch === ' ' || ch === '\t') {
|
|
445
448
|
flush()
|
|
446
449
|
continue
|
|
447
450
|
}
|
|
451
|
+
if (ch === '\n') {
|
|
452
|
+
flush()
|
|
453
|
+
tokens.push('\n')
|
|
454
|
+
continue
|
|
455
|
+
}
|
|
448
456
|
if (ch === ';' || ch === '|' || ch === '&') {
|
|
449
457
|
flush()
|
|
450
458
|
const next = command[i + 1]
|
|
@@ -43,15 +43,27 @@ export async function checkNonWorkspaceWriteGuard(options: {
|
|
|
43
43
|
|
|
44
44
|
const targetPath = path.resolve(agentDir, rawPath)
|
|
45
45
|
const workspacePath = path.resolve(agentDir, 'workspace')
|
|
46
|
-
const [realTargetPath, realWorkspacePath] = await Promise.all([
|
|
46
|
+
const [realTargetPath, realWorkspacePath, realAgentDir, realTmpRoot] = await Promise.all([
|
|
47
47
|
resolveRealIntendedPath(targetPath),
|
|
48
48
|
resolveRealIntendedPath(workspacePath),
|
|
49
|
+
resolveRealIntendedPath(path.resolve(agentDir)),
|
|
50
|
+
resolveRealIntendedPath('/tmp'),
|
|
49
51
|
])
|
|
50
52
|
if (await isSkillAuthoringAllowed({ tool, args, agentDir })) return undefined
|
|
51
53
|
if (await isMemoryRetrievalCacheWriteAllowed({ tool, args, agentDir, origin })) return undefined
|
|
52
54
|
if (await isMemoryTopicsWriteAllowed({ tool, args, agentDir, origin })) return undefined
|
|
53
55
|
if (await isAllowedAgentRootWrite(agentDir, targetPath, realTargetPath)) return undefined
|
|
54
56
|
if (isInside(realWorkspacePath, realTargetPath)) return undefined
|
|
57
|
+
// /tmp is virtual per-session scratch (see src/sandbox/session-tmp.ts), not a
|
|
58
|
+
// project or secret surface — throwaway, never committed, so an unacknowledged
|
|
59
|
+
// write is expected. Allowed only on LEXICAL intent: the model's raw path must
|
|
60
|
+
// itself be an absolute /tmp/... path. A relative path that merely realpaths
|
|
61
|
+
// into /tmp (e.g. `workspace/link` where `link -> /tmp/x`) is a workspace
|
|
62
|
+
// escape, not scratch, and must stay blocked by the rules above. The physical
|
|
63
|
+
// target must also still resolve under real /tmp (blocks `/tmp/../agent/.env`
|
|
64
|
+
// and a `/tmp/link -> /agent/.env`) and must not land inside the agent dir
|
|
65
|
+
// (a container/test agent dir can itself sit under /tmp).
|
|
66
|
+
if (isTmpScratchWrite(rawPath, realTargetPath, realAgentDir, realTmpRoot)) return undefined
|
|
55
67
|
if (isGuardAcknowledged(args, GUARD_NON_WORKSPACE_WRITE)) return undefined
|
|
56
68
|
|
|
57
69
|
return {
|
|
@@ -77,6 +89,31 @@ async function isAllowedAgentRootWrite(agentDir: string, targetPath: string, rea
|
|
|
77
89
|
return false
|
|
78
90
|
}
|
|
79
91
|
|
|
92
|
+
// `rawPath`: the model's RAW path normalized; only an absolute /tmp/... path
|
|
93
|
+
// counts as scratch intent (a relative workspace path that escapes into /tmp is
|
|
94
|
+
// handled by the escape rules above, never here). `realTargetPath`: the
|
|
95
|
+
// realpath-resolved physical target — must still land under /tmp (not /agent via
|
|
96
|
+
// `..` or a planted symlink) and must not land inside the agent dir.
|
|
97
|
+
function isTmpScratchWrite(
|
|
98
|
+
rawPath: string,
|
|
99
|
+
realTargetPath: string,
|
|
100
|
+
realAgentDir: string,
|
|
101
|
+
realTmpRoot: string,
|
|
102
|
+
): boolean {
|
|
103
|
+
const normalizedRaw = path.normalize(rawPath)
|
|
104
|
+
const rawIsAbsoluteTmp = normalizedRaw === '/tmp' || isInside('/tmp', normalizedRaw)
|
|
105
|
+
if (!rawIsAbsoluteTmp) return false
|
|
106
|
+
|
|
107
|
+
// Compare against the REALPATH of /tmp, not the literal: on macOS /tmp is a
|
|
108
|
+
// symlink to /private/tmp, so realTargetPath resolves there and a literal-/tmp
|
|
109
|
+
// containment check would never match.
|
|
110
|
+
const physicallyUnderTmp = realTargetPath === realTmpRoot || isInside(realTmpRoot, realTargetPath)
|
|
111
|
+
if (!physicallyUnderTmp) return false
|
|
112
|
+
|
|
113
|
+
const insideAgent = realTargetPath === realAgentDir || isInside(realAgentDir, realTargetPath)
|
|
114
|
+
return !insideAgent
|
|
115
|
+
}
|
|
116
|
+
|
|
80
117
|
function isInside(parent: string, child: string): boolean {
|
|
81
118
|
const relative = path.relative(parent, child)
|
|
82
119
|
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative))
|