typeclaw 0.24.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 +42 -5
- 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 +90 -12
- package/src/agent/session-origin.ts +30 -0
- package/src/agent/subagent-completion-reminder.ts +23 -0
- package/src/agent/subagents.ts +31 -2
- package/src/agent/system-prompt.ts +1 -1
- package/src/agent/tool-not-found-nudge.ts +8 -1
- package/src/agent/tools/channel-reply.ts +3 -3
- package/src/agent/tools/curl-impersonate.ts +2 -2
- 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/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 +22 -1
- 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 +18 -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/github/inbound.ts +11 -0
- package/src/channels/adapters/github/webhook-register.ts +32 -27
- package/src/channels/router.ts +61 -23
- package/src/channels/schema.ts +2 -1
- package/src/channels/subagent-completion-bridge.ts +18 -18
- package/src/channels/types.ts +1 -1
- package/src/cli/inspect-controller.ts +130 -38
- 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 +25 -16
- package/src/inspect/index.ts +31 -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 +14 -0
- package/src/sandbox/build.ts +10 -0
- package/src/sandbox/index.ts +9 -1
- package/src/sandbox/policy.ts +12 -0
- package/src/sandbox/session-tmp.ts +43 -0
- package/src/sandbox/writable-zones.ts +103 -3
- package/src/server/command-runner.ts +1 -1
- package/src/server/index.ts +8 -0
- package/src/skills/typeclaw-channel-github/SKILL.md +37 -10
- package/src/skills/typeclaw-memory/SKILL.md +3 -1
- package/src/tui/format.ts +11 -11
|
@@ -83,9 +83,9 @@ export function createChannelReplyTool({
|
|
|
83
83
|
resolve_review_thread: Type.Optional(
|
|
84
84
|
Type.Boolean({
|
|
85
85
|
description:
|
|
86
|
-
'GitHub
|
|
87
|
-
'
|
|
88
|
-
|
|
86
|
+
'GitHub review threads ONLY — ignored on Slack, Discord, Telegram, KakaoTalk, and any non-github session, and ignored on a github reply that is not inside a `thread`. On those, leave this unset and ignore the rest of this description. ' +
|
|
87
|
+
'On a github reply inside a review thread you authored: when your `text` acknowledges the concern is fixed/verified/addressed (e.g. "verified at <sha>", "thanks, that resolves it"), treat setting this `true` as the expected close-out — do it in the SAME call. This is a strong instruction, not a schema requirement: the field stays optional and nothing rejects an acknowledgement that omits it, but a bare ack without it leaves the thread open, because a successful reply ends the turn and the resolve cannot run in a later one. So this flag is the only way the close-out actually happens. ' +
|
|
88
|
+
"It is safe to set by default: the runtime resolves BEFORE posting and ONLY if the thread's root comment is yours — it refuses (and blocks the reply) on a human reviewer's thread, so you never close someone else's open question. You need not pre-check authorship; just set it on your acknowledgement and let the runtime enforce ownership. Leave it unset when you intend to keep the thread open (partial fix, disagreement, mid-discussion).",
|
|
89
89
|
}),
|
|
90
90
|
),
|
|
91
91
|
}),
|
|
@@ -101,7 +101,7 @@ export async function curlImpersonate(req: CurlImpersonateRequest): Promise<Curl
|
|
|
101
101
|
const method = req.method ?? 'GET'
|
|
102
102
|
|
|
103
103
|
// Per-request random sentinel + UTF-8-safe parsing. The static sentinel
|
|
104
|
-
// approach (previous revision) had a hardening hole:
|
|
104
|
+
// approach (previous revision) had a hardening hole: web_fetch reads
|
|
105
105
|
// attacker-controlled pages, and a static sentinel is a public, fixed
|
|
106
106
|
// string. A page could include the sentinel byte sequence plus fabricated
|
|
107
107
|
// metadata before the real write-out tail and `indexOf` would split at
|
|
@@ -137,7 +137,7 @@ export async function curlImpersonate(req: CurlImpersonateRequest): Promise<Curl
|
|
|
137
137
|
'--proto-redir',
|
|
138
138
|
'=http,https',
|
|
139
139
|
// `--fail-with-body` would make curl exit non-zero on >=400 but still
|
|
140
|
-
// write the body. We intentionally DO NOT pass it: callers (
|
|
140
|
+
// write the body. We intentionally DO NOT pass it: callers (web_fetch,
|
|
141
141
|
// ddg) want to inspect httpStatus themselves and decide. Curl exits 0
|
|
142
142
|
// on a 404-with-body in this mode, which matches our contract.
|
|
143
143
|
'--compressed',
|
|
@@ -7,7 +7,7 @@ import type { PermissionService } from '@/permissions'
|
|
|
7
7
|
import type { Stream } from '@/stream'
|
|
8
8
|
|
|
9
9
|
import { type LiveSubagentRegistry, type SubagentCompletion } from '../live-subagents'
|
|
10
|
-
import type
|
|
10
|
+
import { MAX_SUBAGENT_DEPTH, type SessionOrigin, subagentDepth } from '../session-origin'
|
|
11
11
|
import { type CreateSessionForSubagent, type Subagent, type SubagentRegistry, startSubagent } from '../subagents'
|
|
12
12
|
|
|
13
13
|
export const SPAWN_TASK_ID_PREFIX = 'bg_'
|
|
@@ -95,6 +95,16 @@ export function createSpawnSubagentTool(options: CreateSpawnSubagentToolOptions)
|
|
|
95
95
|
if (!hasPermissionForSubagent(permissions, origin, params.subagent_type, subagent)) {
|
|
96
96
|
return errorResult('subagent.spawn denied: insufficient permissions')
|
|
97
97
|
}
|
|
98
|
+
// Fail closed past the chain-length ceiling. The tool is present on
|
|
99
|
+
// subagent sessions (operator/reviewer can delegate), but a session
|
|
100
|
+
// already at MAX_SUBAGENT_DEPTH cannot spawn a deeper one — this is the
|
|
101
|
+
// execute-time guard against runaway recursion, robust to tool-surface
|
|
102
|
+
// drift and serialized-origin resumes.
|
|
103
|
+
if (subagentDepth(origin) >= MAX_SUBAGENT_DEPTH) {
|
|
104
|
+
return errorResult(
|
|
105
|
+
`subagent.spawn denied: maximum delegation depth (${MAX_SUBAGENT_DEPTH}) reached; a subagent at this depth cannot spawn further subagents`,
|
|
106
|
+
)
|
|
107
|
+
}
|
|
98
108
|
|
|
99
109
|
const taskId = generateTaskId()
|
|
100
110
|
const subagentName = params.subagent_type
|
|
@@ -136,6 +146,11 @@ export function createSpawnSubagentTool(options: CreateSpawnSubagentToolOptions)
|
|
|
136
146
|
}
|
|
137
147
|
liveRegistry.register(live)
|
|
138
148
|
|
|
149
|
+
const channelKey =
|
|
150
|
+
origin?.kind === 'channel'
|
|
151
|
+
? { adapter: origin.adapter, workspace: origin.workspace, chat: origin.chat, thread: origin.thread }
|
|
152
|
+
: undefined
|
|
153
|
+
|
|
139
154
|
void completion.then((c) => {
|
|
140
155
|
const durationMs = now() - startedAt
|
|
141
156
|
liveRegistry.recordCompletion(taskId, completionToFinalShape(c, durationMs))
|
|
@@ -150,6 +165,7 @@ export function createSpawnSubagentTool(options: CreateSpawnSubagentToolOptions)
|
|
|
150
165
|
ok: c.ok,
|
|
151
166
|
durationMs,
|
|
152
167
|
...(c.ok ? {} : { error: c.error }),
|
|
168
|
+
...(channelKey !== undefined ? { channelKey } : {}),
|
|
153
169
|
},
|
|
154
170
|
})
|
|
155
171
|
}
|
|
@@ -218,7 +234,8 @@ export function spawnSubagentDescription(registry: SubagentRegistry): string {
|
|
|
218
234
|
`When run_in_background=true (preferred for long-running work), the tool returns a task_id immediately and the subagent runs concurrently — ` +
|
|
219
235
|
`you will receive a system-reminder when it completes; do NOT poll subagent_output. ` +
|
|
220
236
|
`When run_in_background=false (default), the tool blocks and returns the subagent's final message synchronously. ` +
|
|
221
|
-
`
|
|
237
|
+
`The delegation chain is depth-limited: a subagent you spawn may itself delegate once more, but no deeper — ` +
|
|
238
|
+
`keep your delegation tree shallow.`
|
|
222
239
|
)
|
|
223
240
|
}
|
|
224
241
|
|
|
@@ -13,27 +13,46 @@ export type AuthorizeLiveSubagentAccessArgs = {
|
|
|
13
13
|
liveRegistry: LiveSubagentRegistry
|
|
14
14
|
taskId: string
|
|
15
15
|
permission: SubagentAccessPermission
|
|
16
|
+
// The caller's own session id. When the caller is itself a subagent, access
|
|
17
|
+
// is scoped to subagents IT spawned (live.parentSessionId === callerSessionId)
|
|
18
|
+
// so a nested subagent cannot read or cancel siblings or parent-branch runs.
|
|
19
|
+
// Omitted by main-session callers, which keep the role-severity cap only.
|
|
20
|
+
callerSessionId?: string
|
|
16
21
|
}
|
|
17
22
|
|
|
18
23
|
// Authorizes a single subagent_output/subagent_cancel call and resolves the
|
|
19
|
-
// live entry in one place so the two tools cannot drift.
|
|
20
|
-
//
|
|
21
|
-
//
|
|
24
|
+
// live entry in one place so the two tools cannot drift. Two authorization
|
|
25
|
+
// modes, both requiring the base permission first:
|
|
26
|
+
// - SUBAGENT caller: scoped to runs it spawned (live.parentSessionId ===
|
|
27
|
+
// callerSessionId). Ownership is the authorization; the role cap is skipped.
|
|
28
|
+
// - MAIN-SESSION caller: capped to the requester's role — must resolve to a
|
|
29
|
+
// role at least as high as the role that spawned the subagent.
|
|
22
30
|
//
|
|
23
31
|
// The ordering closes an existence oracle: the task-independent base-permission
|
|
24
32
|
// check runs BEFORE any registry lookup, and for non-owner callers an absent
|
|
25
33
|
// task, a capped task, and a task with missing provenance all collapse to one
|
|
26
34
|
// identical denial — so a lower-role caller cannot probe which task IDs are
|
|
27
35
|
// live. Only `owner` (the trust root, which outranks every spawner) learns the
|
|
28
|
-
// truthful `Unknown task_id` for a genuine miss.
|
|
36
|
+
// truthful `Unknown task_id` for a genuine miss. Both modes fail closed.
|
|
29
37
|
export function authorizeLiveSubagentAccess(args: AuthorizeLiveSubagentAccessArgs): SubagentAccessResult {
|
|
30
|
-
const { permissions, origin, liveRegistry, taskId, permission } = args
|
|
38
|
+
const { permissions, origin, liveRegistry, taskId, permission, callerSessionId } = args
|
|
39
|
+
|
|
40
|
+
// A subagent caller may only touch subagents it spawned itself — never a
|
|
41
|
+
// sibling's or its parent's run. For subagent callers this ownership check
|
|
42
|
+
// REPLACES the role-severity cap (see the ownershipScoped branch below);
|
|
43
|
+
// main-session callers (subagent origin absent) skip it and fall through to
|
|
44
|
+
// the role cap, preserving the operator's global visibility over every spawn.
|
|
45
|
+
const ownershipScoped = origin?.kind === 'subagent'
|
|
46
|
+
const opaqueOwnershipDenial = `${permission} denied: unknown task_id or not owned by caller`
|
|
31
47
|
|
|
32
48
|
if (permissions === undefined) {
|
|
33
49
|
const live = liveRegistry.get(taskId)
|
|
34
50
|
if (live === undefined) {
|
|
35
51
|
return { ok: false, message: `Unknown task_id: ${taskId}.` }
|
|
36
52
|
}
|
|
53
|
+
if (ownershipScoped && live.parentSessionId !== callerSessionId) {
|
|
54
|
+
return { ok: false, message: opaqueOwnershipDenial }
|
|
55
|
+
}
|
|
37
56
|
return { ok: true, live }
|
|
38
57
|
}
|
|
39
58
|
|
|
@@ -43,6 +62,22 @@ export function authorizeLiveSubagentAccess(args: AuthorizeLiveSubagentAccessArg
|
|
|
43
62
|
|
|
44
63
|
const requesterRole = permissions.resolveRole(origin)
|
|
45
64
|
const accessAll = requesterRole === 'owner'
|
|
65
|
+
|
|
66
|
+
// For a subagent caller, ownership of the run IS the authorization: having
|
|
67
|
+
// passed the base permission check above, it may manage exactly the children
|
|
68
|
+
// it spawned. The role-severity cap (below) does NOT apply — a deep subagent
|
|
69
|
+
// that inherited a low role from, say, a guest channel turn must still be
|
|
70
|
+
// able to read/cancel its own children; the cap is meant to stop a low-role
|
|
71
|
+
// MAIN session from reaching a higher-role-spawned run, which ownership
|
|
72
|
+
// already prevents here. A non-owning subagent caller fails closed.
|
|
73
|
+
if (ownershipScoped) {
|
|
74
|
+
const live = liveRegistry.get(taskId)
|
|
75
|
+
if (live === undefined || live.parentSessionId !== callerSessionId) {
|
|
76
|
+
return { ok: false, message: opaqueOwnershipDenial }
|
|
77
|
+
}
|
|
78
|
+
return { ok: true, live }
|
|
79
|
+
}
|
|
80
|
+
|
|
46
81
|
const opaqueDenial = `${permission} denied: unknown task_id or insufficient role`
|
|
47
82
|
|
|
48
83
|
const live = liveRegistry.get(taskId)
|
|
@@ -15,10 +15,11 @@ export type CreateSubagentCancelToolOptions = {
|
|
|
15
15
|
liveRegistry: LiveSubagentRegistry
|
|
16
16
|
getOrigin: () => SessionOrigin | undefined
|
|
17
17
|
permissions?: PermissionService
|
|
18
|
+
callerSessionId?: string
|
|
18
19
|
}
|
|
19
20
|
|
|
20
21
|
export function createSubagentCancelTool(options: CreateSubagentCancelToolOptions) {
|
|
21
|
-
const { liveRegistry, getOrigin, permissions } = options
|
|
22
|
+
const { liveRegistry, getOrigin, permissions, callerSessionId } = options
|
|
22
23
|
|
|
23
24
|
return defineTool({
|
|
24
25
|
name: 'subagent_cancel',
|
|
@@ -40,6 +41,7 @@ export function createSubagentCancelTool(options: CreateSubagentCancelToolOption
|
|
|
40
41
|
liveRegistry,
|
|
41
42
|
taskId: params.task_id,
|
|
42
43
|
permission: 'subagent.cancel',
|
|
44
|
+
...(callerSessionId !== undefined ? { callerSessionId } : {}),
|
|
43
45
|
})
|
|
44
46
|
if (!access.ok) {
|
|
45
47
|
return errorResult(access.message)
|
|
@@ -7,6 +7,8 @@ import type { LiveSubagentRegistry, StatusSnapshot, SubagentProgressEvent } from
|
|
|
7
7
|
import type { SessionOrigin } from '../session-origin'
|
|
8
8
|
import { authorizeLiveSubagentAccess } from './subagent-access'
|
|
9
9
|
|
|
10
|
+
export const SUBAGENT_OUTPUT_TOOL_NAME = 'subagent_output'
|
|
11
|
+
|
|
10
12
|
export type SubagentOutputToolDetails =
|
|
11
13
|
| {
|
|
12
14
|
ok: true
|
|
@@ -42,14 +44,15 @@ export type CreateSubagentOutputToolOptions = {
|
|
|
42
44
|
liveRegistry: LiveSubagentRegistry
|
|
43
45
|
getOrigin: () => SessionOrigin | undefined
|
|
44
46
|
permissions?: PermissionService
|
|
47
|
+
callerSessionId?: string
|
|
45
48
|
now?: () => number
|
|
46
49
|
}
|
|
47
50
|
|
|
48
51
|
export function createSubagentOutputTool(options: CreateSubagentOutputToolOptions) {
|
|
49
|
-
const { liveRegistry, getOrigin, permissions, now = () => Date.now() } = options
|
|
52
|
+
const { liveRegistry, getOrigin, permissions, callerSessionId, now = () => Date.now() } = options
|
|
50
53
|
|
|
51
54
|
return defineTool({
|
|
52
|
-
name:
|
|
55
|
+
name: SUBAGENT_OUTPUT_TOOL_NAME,
|
|
53
56
|
label: 'Subagent Output',
|
|
54
57
|
description:
|
|
55
58
|
'Fetch the current state of a subagent you previously spawned. Returns one of three statuses: ' +
|
|
@@ -71,6 +74,7 @@ export function createSubagentOutputTool(options: CreateSubagentOutputToolOption
|
|
|
71
74
|
liveRegistry,
|
|
72
75
|
taskId: params.task_id,
|
|
73
76
|
permission: 'subagent.output',
|
|
77
|
+
...(callerSessionId !== undefined ? { callerSessionId } : {}),
|
|
74
78
|
})
|
|
75
79
|
if (!access.ok) {
|
|
76
80
|
return errorResult(access.message)
|
|
@@ -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)
|
|
@@ -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
|
+
}
|