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.
Files changed (68) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/src/agent/index.ts +42 -5
  4. package/src/agent/llm-replay-sanitizer.ts +120 -0
  5. package/src/agent/loop-guard.ts +34 -0
  6. package/src/agent/multimodal/look-at.ts +1 -1
  7. package/src/agent/plugin-tools.ts +90 -12
  8. package/src/agent/session-origin.ts +30 -0
  9. package/src/agent/subagent-completion-reminder.ts +23 -0
  10. package/src/agent/subagents.ts +31 -2
  11. package/src/agent/system-prompt.ts +1 -1
  12. package/src/agent/tool-not-found-nudge.ts +8 -1
  13. package/src/agent/tools/channel-reply.ts +3 -3
  14. package/src/agent/tools/curl-impersonate.ts +2 -2
  15. package/src/agent/tools/spawn-subagent.ts +19 -2
  16. package/src/agent/tools/subagent-access.ts +40 -5
  17. package/src/agent/tools/subagent-cancel.ts +3 -1
  18. package/src/agent/tools/subagent-output.ts +6 -2
  19. package/src/agent/tools/webfetch/fetch.ts +18 -18
  20. package/src/agent/tools/webfetch/index.ts +1 -1
  21. package/src/agent/tools/webfetch/tool.ts +13 -13
  22. package/src/agent/tools/webfetch/types.ts +1 -1
  23. package/src/agent/tools/websearch.ts +6 -6
  24. package/src/bundled-plugins/backup/index.ts +40 -37
  25. package/src/bundled-plugins/backup/runner.ts +22 -1
  26. package/src/bundled-plugins/github-cli-auth/gh-command.ts +15 -7
  27. package/src/bundled-plugins/guard/policies/non-workspace-write.ts +38 -1
  28. package/src/bundled-plugins/memory/README.md +11 -11
  29. package/src/bundled-plugins/memory/dreaming.ts +5 -0
  30. package/src/bundled-plugins/memory/search-tool.ts +98 -1
  31. package/src/bundled-plugins/operator/operator.ts +5 -1
  32. package/src/bundled-plugins/reviewer/reviewer.ts +18 -9
  33. package/src/bundled-plugins/reviewer/skills/code-review.ts +1 -1
  34. package/src/bundled-plugins/reviewer/skills/general.ts +1 -1
  35. package/src/bundled-plugins/scout/scout.ts +7 -7
  36. package/src/bundled-plugins/security/policies/private-surface-read.ts +2 -2
  37. package/src/bundled-plugins/security/policies/ssrf.ts +3 -3
  38. package/src/bundled-plugins/tool-result-cap/README.md +1 -1
  39. package/src/channels/adapters/github/inbound.ts +11 -0
  40. package/src/channels/adapters/github/webhook-register.ts +32 -27
  41. package/src/channels/router.ts +61 -23
  42. package/src/channels/schema.ts +2 -1
  43. package/src/channels/subagent-completion-bridge.ts +18 -18
  44. package/src/channels/types.ts +1 -1
  45. package/src/cli/inspect-controller.ts +130 -38
  46. package/src/container/start.ts +7 -1
  47. package/src/git/mutex.ts +22 -0
  48. package/src/git/reconcile-ignored.ts +214 -0
  49. package/src/hostd/daemon.ts +26 -1
  50. package/src/hostd/portbroker-manager.ts +7 -0
  51. package/src/init/dockerfile.ts +1 -1
  52. package/src/init/gitignore.ts +25 -16
  53. package/src/inspect/index.ts +31 -4
  54. package/src/inspect/loop.ts +16 -12
  55. package/src/plugin/define.ts +2 -2
  56. package/src/plugin/index.ts +2 -2
  57. package/src/portbroker/hostd-client.ts +36 -13
  58. package/src/run/index.ts +14 -0
  59. package/src/sandbox/build.ts +10 -0
  60. package/src/sandbox/index.ts +9 -1
  61. package/src/sandbox/policy.ts +12 -0
  62. package/src/sandbox/session-tmp.ts +43 -0
  63. package/src/sandbox/writable-zones.ts +103 -3
  64. package/src/server/command-runner.ts +1 -1
  65. package/src/server/index.ts +8 -0
  66. package/src/skills/typeclaw-channel-github/SKILL.md +37 -10
  67. package/src/skills/typeclaw-memory/SKILL.md +3 -1
  68. 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 only. Set `true` when this reply acknowledges that a review-comment thread YOU authored has been addressed, to resolve (close) that thread atomically with the reply. ' +
87
- 'The thread is resolved BEFORE the acknowledgement is posted, and only if its root comment is yoursso it never closes a human reviewer\'s thread, and a failed resolve blocks the misleading "looks resolved" reply. ' +
88
- 'Valid only on a github session replying inside a thread (the origin must carry a `thread`). Ignored elsewhere.',
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: webfetch reads
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 (webfetch,
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 { SessionOrigin } from '../session-origin'
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
- `Subagents cannot recursively spawn other subagents.`
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. Caps access to the
20
- // requester's role: the caller must hold the permission AND resolve to a role
21
- // at least as high as the role that spawned the subagent.
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. The cap fails closed.
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: 'subagent_output',
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
- // Webfetch's HTTP transport.
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 webfetch tool
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 WebfetchError extends Error {
41
+ export class WebFetchError extends Error {
42
42
  constructor(message: string) {
43
43
  super(message)
44
- this.name = 'WebfetchError'
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 WebfetchError('URL must use http:// or https://')
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 WebfetchError('Request aborted')
103
+ throw new WebFetchError('Request aborted')
104
104
  }
105
105
  if (error instanceof CurlImpersonateError) {
106
106
  if (isCurlExitTimeout(error)) {
107
- throw new WebfetchError(`Request timed out after ${timeoutSeconds}s`)
107
+ throw new WebFetchError(`Request timed out after ${timeoutSeconds}s`)
108
108
  }
109
109
  if (isCurlExitFilesizeExceeded(error)) {
110
- throw new WebfetchError(`Response too large (exceeds ${formatBytes(MAX_RESPONSE_BYTES)} limit)`)
110
+ throw new WebFetchError(`Response too large (exceeds ${formatBytes(MAX_RESPONSE_BYTES)} limit)`)
111
111
  }
112
- throw new WebfetchError(`Fetch failed: ${error.message}`)
112
+ throw new WebFetchError(`Fetch failed: ${error.message}`)
113
113
  }
114
114
  const message = error instanceof Error ? error.message : String(error)
115
- throw new WebfetchError(`Fetch failed: ${message}`)
115
+ throw new WebFetchError(`Fetch failed: ${message}`)
116
116
  }
117
117
 
118
118
  if (response.httpStatus < 200 || response.httpStatus >= 300) {
119
- throw new WebfetchError(`Fetch failed: HTTP ${response.httpStatus}`)
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 WebfetchError(
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 WebfetchError(`Fetch failed: HTTP ${response.status} ${response.statusText}`)
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 WebfetchError(
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 WebfetchError(
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 WebfetchError(`Request timed out after ${timeoutSeconds}s`)
185
+ throw new WebFetchError(`Request timed out after ${timeoutSeconds}s`)
186
186
  }
187
- if (error instanceof WebfetchError) throw error
187
+ if (error instanceof WebFetchError) throw error
188
188
  const message = error instanceof Error ? error.message : String(error)
189
- throw new WebfetchError(`Fetch failed: ${message}`)
189
+ throw new WebFetchError(`Fetch failed: ${message}`)
190
190
  } finally {
191
191
  clearTimeout(timeout)
192
192
  parentSignal?.removeEventListener('abort', onAbort)
@@ -1 +1 @@
1
- export { webfetchTool } from './tool'
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, WebfetchError } from './fetch'
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 WebfetchDetails,
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 webfetchTool = defineTool({
22
- name: 'webfetch',
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 websearch surfaced a result you need to read in full. ' +
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 WebfetchError ? error.message : `Invalid URL: ${error}`
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: WebfetchDetails = {
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 WebfetchParams = {
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: WebfetchParams): string | null {
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: WebfetchParams,
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<WebfetchDetails> & { startedAt: number },
254
- ): { content: [{ type: 'text'; text: string }]; details: WebfetchDetails } {
253
+ partial: Partial<WebFetchDetails> & { startedAt: number },
254
+ ): { content: [{ type: 'text'; text: string }]; details: WebFetchDetails } {
255
255
  const { startedAt, ...rest } = partial
256
- const details: WebfetchDetails = {
256
+ const details: WebFetchDetails = {
257
257
  url,
258
258
  finalUrl: rest.finalUrl ?? url,
259
259
  strategy: rest.strategy ?? 'none',
@@ -1,6 +1,6 @@
1
1
  export type CompactionStrategy = 'readability' | 'jq' | 'selector' | 'grep' | 'snapshot' | 'raw'
2
2
 
3
- export type WebfetchDetails = {
3
+ export type WebFetchDetails = {
4
4
  url: string
5
5
  finalUrl: string
6
6
  strategy: CompactionStrategy | '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 WebsearchDetails = {
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 websearchTool = defineTool({
20
- name: 'websearch',
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 `websearch`/`webfetch` 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.',
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: WebsearchDetails = { query, source, count: results.length, results }
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: WebsearchDetails = { query: '', source: 'none', count: 0, results: [], error: true, message }
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 runBackup(
178
- { cwd: payload.agentDir, pushToOrigin: payload.pushToOrigin },
179
- {
180
- gitSpawn: makeDefaultGitSpawn(),
181
- pickCommitMessage: async ({ status, diffstat }) => {
182
- await cleanupMessageFile(messagePath)
183
- const messagePayload: CommitMessagePayload = {
184
- agentDir: payload.agentDir,
185
- status,
186
- diffstat,
187
- outputPath: messagePath,
188
- }
189
- try {
190
- await ctx.spawnSubagent(SUBAGENT_COMMIT_MESSAGE, messagePayload, inheritOwner)
191
- } catch (err) {
192
- ctx.logger.warn(
193
- `${SUBAGENT_COMMIT_MESSAGE} subagent failed, using fallback: ${err instanceof Error ? err.message : String(err)}`,
194
- )
195
- }
196
- const written = await readMessageFile(messagePath)
197
- await cleanupMessageFile(messagePath)
198
- return written ?? 'chore: backup'
199
- },
200
- diagnoseFailure: async (input) => {
201
- const diagPayload: DiagnoseFailurePayload = {
202
- agentDir: input.cwd,
203
- stage: input.stage,
204
- exitCode: input.exitCode,
205
- stderr: input.stderr,
206
- stdout: input.stdout,
207
- }
208
- try {
209
- await ctx.spawnSubagent(SUBAGENT_DIAGNOSE, diagPayload, inheritOwner)
210
- } catch (err) {
211
- ctx.logger.warn(`${SUBAGENT_DIAGNOSE} subagent failed: ${err instanceof Error ? err.message : String(err)}`)
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
+ }