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.
Files changed (90) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/src/agent/index.ts +133 -27
  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 +122 -8
  8. package/src/agent/restart/index.ts +15 -3
  9. package/src/agent/restart-handoff/index.ts +110 -12
  10. package/src/agent/session-origin.ts +30 -0
  11. package/src/agent/subagent-completion-reminder.ts +26 -1
  12. package/src/agent/subagents.ts +75 -3
  13. package/src/agent/system-prompt.ts +5 -1
  14. package/src/agent/todo/continuation-policy.ts +242 -0
  15. package/src/agent/todo/continuation-state.ts +87 -0
  16. package/src/agent/todo/continuation-wiring.ts +113 -0
  17. package/src/agent/todo/continuation.ts +71 -0
  18. package/src/agent/todo/scope.ts +77 -0
  19. package/src/agent/todo/store.ts +98 -0
  20. package/src/agent/tool-not-found-nudge.ts +126 -0
  21. package/src/agent/tools/channel-reply.ts +51 -0
  22. package/src/agent/tools/curl-impersonate.ts +2 -2
  23. package/src/agent/tools/restart.ts +11 -4
  24. package/src/agent/tools/spawn-subagent.ts +19 -2
  25. package/src/agent/tools/subagent-access.ts +40 -5
  26. package/src/agent/tools/subagent-cancel.ts +3 -1
  27. package/src/agent/tools/subagent-output.ts +6 -2
  28. package/src/agent/tools/todo/index.ts +119 -0
  29. package/src/agent/tools/webfetch/fetch.ts +18 -18
  30. package/src/agent/tools/webfetch/index.ts +1 -1
  31. package/src/agent/tools/webfetch/tool.ts +13 -13
  32. package/src/agent/tools/webfetch/types.ts +1 -1
  33. package/src/agent/tools/websearch.ts +6 -6
  34. package/src/bundled-plugins/backup/index.ts +40 -37
  35. package/src/bundled-plugins/backup/runner.ts +23 -2
  36. package/src/bundled-plugins/github-cli-auth/gh-command.ts +15 -7
  37. package/src/bundled-plugins/guard/policies/non-workspace-write.ts +38 -1
  38. package/src/bundled-plugins/memory/README.md +11 -11
  39. package/src/bundled-plugins/memory/dreaming.ts +5 -0
  40. package/src/bundled-plugins/memory/search-tool.ts +98 -1
  41. package/src/bundled-plugins/operator/operator.ts +5 -1
  42. package/src/bundled-plugins/reviewer/reviewer.ts +32 -9
  43. package/src/bundled-plugins/reviewer/skills/code-review.ts +1 -1
  44. package/src/bundled-plugins/reviewer/skills/general.ts +1 -1
  45. package/src/bundled-plugins/scout/scout.ts +7 -7
  46. package/src/bundled-plugins/security/policies/private-surface-read.ts +2 -2
  47. package/src/bundled-plugins/security/policies/ssrf.ts +3 -3
  48. package/src/bundled-plugins/tool-result-cap/README.md +1 -1
  49. package/src/channels/adapters/discord-bot-reference.ts +78 -0
  50. package/src/channels/adapters/discord-bot.ts +25 -3
  51. package/src/channels/adapters/github/inbound.ts +172 -10
  52. package/src/channels/adapters/github/index.ts +10 -0
  53. package/src/channels/adapters/github/review-thread-resolver.ts +246 -0
  54. package/src/channels/adapters/github/webhook-register.ts +32 -27
  55. package/src/channels/adapters/kakaotalk-classify.ts +67 -6
  56. package/src/channels/adapters/slack-bot-classify.ts +9 -1
  57. package/src/channels/adapters/slack-bot-reference.ts +129 -0
  58. package/src/channels/adapters/slack-bot.ts +67 -8
  59. package/src/channels/manager.ts +8 -2
  60. package/src/channels/router.ts +506 -45
  61. package/src/channels/schema.ts +21 -4
  62. package/src/channels/subagent-completion-bridge.ts +18 -18
  63. package/src/channels/types.ts +69 -1
  64. package/src/cli/inspect-controller.ts +132 -33
  65. package/src/cli/inspect.ts +2 -1
  66. package/src/commands/index.ts +9 -0
  67. package/src/container/start.ts +7 -1
  68. package/src/git/mutex.ts +22 -0
  69. package/src/git/reconcile-ignored.ts +214 -0
  70. package/src/hostd/daemon.ts +26 -1
  71. package/src/hostd/portbroker-manager.ts +7 -0
  72. package/src/init/dockerfile.ts +1 -1
  73. package/src/init/gitignore.ts +28 -16
  74. package/src/inspect/index.ts +53 -4
  75. package/src/inspect/loop.ts +16 -12
  76. package/src/plugin/define.ts +2 -2
  77. package/src/plugin/index.ts +2 -2
  78. package/src/portbroker/hostd-client.ts +36 -13
  79. package/src/run/index.ts +74 -5
  80. package/src/sandbox/build.ts +20 -0
  81. package/src/sandbox/index.ts +10 -0
  82. package/src/sandbox/policy.ts +22 -0
  83. package/src/sandbox/session-tmp.ts +43 -0
  84. package/src/sandbox/writable-zones.ts +178 -0
  85. package/src/server/command-runner.ts +1 -1
  86. package/src/server/index.ts +126 -4
  87. package/src/skills/typeclaw-channel-github/SKILL.md +71 -17
  88. package/src/skills/typeclaw-memory/SKILL.md +3 -1
  89. package/src/tui/format.ts +11 -11
  90. 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
- // 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)
@@ -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 boundary
413
- // like `true; gh ...` (no surrounding spaces) yields a standalone operator
414
- // token. Quote-aware: operators inside quotes are literal. This is a
415
- // command-position detector, not a full shell parser it does not interpret
416
- // redirections, subshells, or backgrounding semantics beyond boundary marking.
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 toootherwise 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' || ch === '\n') {
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))