@davait/aipictury 1.9.2

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 (54) hide show
  1. package/LICENSE.md +77 -0
  2. package/README.md +39 -0
  3. package/ext/about.js +209 -0
  4. package/ext/account.js +242 -0
  5. package/ext/accountUi.js +287 -0
  6. package/ext/agent.js +1929 -0
  7. package/ext/browser.js +263 -0
  8. package/ext/cloud.js +385 -0
  9. package/ext/context.js +241 -0
  10. package/ext/deps.js +142 -0
  11. package/ext/dialogs.js +803 -0
  12. package/ext/diff.js +150 -0
  13. package/ext/extension.js +2483 -0
  14. package/ext/feedback.js +55 -0
  15. package/ext/files.js +565 -0
  16. package/ext/firestore.js +131 -0
  17. package/ext/gpuRun.js +77 -0
  18. package/ext/gpuServer.js +76 -0
  19. package/ext/hooks.js +153 -0
  20. package/ext/log.js +29 -0
  21. package/ext/lsp.js +122 -0
  22. package/ext/mcp.js +469 -0
  23. package/ext/mcpAuth.js +128 -0
  24. package/ext/media/vendor/firebase-config.json +9 -0
  25. package/ext/media/vendor/signin.js +1 -0
  26. package/ext/mediagen.js +174 -0
  27. package/ext/memory.js +117 -0
  28. package/ext/notebook.js +82 -0
  29. package/ext/package.json +1234 -0
  30. package/ext/paths.js +50 -0
  31. package/ext/permissions.js +229 -0
  32. package/ext/plans.js +110 -0
  33. package/ext/plugins.js +243 -0
  34. package/ext/prefs.js +157 -0
  35. package/ext/remote.js +170 -0
  36. package/ext/review.js +126 -0
  37. package/ext/sandbox.js +79 -0
  38. package/ext/scheduler.js +87 -0
  39. package/ext/sessions.js +228 -0
  40. package/ext/sessionsView.js +449 -0
  41. package/ext/shell.js +357 -0
  42. package/ext/storage.js +80 -0
  43. package/ext/tasklist.js +112 -0
  44. package/ext/text.js +31 -0
  45. package/ext/tools.js +781 -0
  46. package/ext/usage.js +77 -0
  47. package/ext/voice.js +135 -0
  48. package/ext/workbench.js +162 -0
  49. package/ext/workflow.js +51 -0
  50. package/ext/worktree.js +83 -0
  51. package/main.js +758 -0
  52. package/package.json +43 -0
  53. package/terminal.js +124 -0
  54. package/vscode-shim.js +231 -0
package/ext/agent.js ADDED
@@ -0,0 +1,1929 @@
1
+ 'use strict'
2
+ // The agent loop: ask the server for the model's next move, carry it out, ask again.
3
+ //
4
+ // The server never sees the workspace. It is given the conversation and the shape of
5
+ // the tools, and answers with text and tool calls; everything those calls do happens
6
+ // here, under the approval rules the user set.
7
+
8
+ const os = require('os')
9
+ const vscode = require('vscode')
10
+ const { toolSchema, normalizeQuestions, normalizeFindings, IMPLEMENTATIONS, WRITES, RUNS, resolve } = require('./tools')
11
+ const decoder = new TextDecoder('utf-8', { ignoreBOM: true })
12
+ const files = require('./files')
13
+ const shell = require('./shell')
14
+ const mcp = require('./mcp')
15
+ const context = require('./context')
16
+ const { safeSlice } = require('./text')
17
+ const { runCommand, stopBackground, plain, commandEnv } = shell
18
+ const { summarize, hunks, applyHunks } = require('./diff')
19
+ const mediagen = require('./mediagen')
20
+ const memory = require('./memory')
21
+ const deps = require('./deps')
22
+ const workbench = require('./workbench')
23
+ const permissions = require('./permissions')
24
+ const userHooks = require('./hooks')
25
+ const sandboxing = require('./sandbox')
26
+ const tasklist = require('./tasklist')
27
+ const workflows = require('./workflow')
28
+ const browserTool = require('./browser')
29
+ const scheduler = require('./scheduler')
30
+ const worktrees = require('./worktree')
31
+ const notebooks = require('./notebook')
32
+ const prefs = require('./prefs')
33
+ const log = require('./log')
34
+ const { root: workspaceRoot } = require('./paths')
35
+
36
+ // A local model answers slowly but steadily. What must never happen is waiting for
37
+ // ever: the connection is given a while to open, and then the stream has its own
38
+ // deadline. The server sends a "still waiting" event every 10s while the model is quiet
39
+ // (reading a long prompt, or composing a tool call, which Ollama holds back until it is
40
+ // whole — five silent minutes for a large file). So silence on the wire means the link
41
+ // is gone, never that the model is slow, and it can be short.
42
+ const OPEN_TIMEOUT = 90_000
43
+ const STALL_TIMEOUT = 90_000
44
+ // A server that sends "still waiting" without saying for how long (an older one) is held
45
+ // to this, in seconds.
46
+ const QUIET_LIMIT = 30 * 60
47
+
48
+ /** An error the run can recover from by asking again: a dropped link, a busy GPU. */
49
+ function failure(message, { transient = false, busy = false } = {}) {
50
+ const error = new Error(message)
51
+ error.transient = transient || busy
52
+ error.busy = busy
53
+ return error
54
+ }
55
+
56
+ /** Rejects if the promise has not settled in time, naming what was being waited for. */
57
+ function withDeadline(promise, ms, what) {
58
+ let timer
59
+ const limit = new Promise((_, reject) => {
60
+ timer = setTimeout(() => reject(failure(
61
+ `the connection to the server was lost while ${what} (nothing for ${Math.round(ms / 1000)}s)`,
62
+ { transient: true })), ms)
63
+ })
64
+ return Promise.race([promise, limit]).finally(() => clearTimeout(timer))
65
+ }
66
+
67
+ // The permission modes shown beside Send. Stored in aipictury.approval.
68
+ const MODES = {
69
+ ask: { label: 'Manual', icon: '\u270B',
70
+ hint: 'Asks before every file change and command.' },
71
+ edits: { label: 'Edit Automatically', icon: '\u270E',
72
+ hint: 'Applies file edits without asking; still asks before running commands.' },
73
+ plan: { label: 'Plan', icon: '\u25F4',
74
+ hint: 'Read-only: investigates and proposes a plan, changes nothing.' },
75
+ never: { label: 'Auto', icon: '\u26A1',
76
+ hint: 'Goes ahead with routine work; asks before anything risky — judged by fixed rules and the model.' },
77
+ bypass: { label: 'Bypass permissions', icon: '\u26A0',
78
+ hint: 'Does everything without asking, with no checks at all. Only with aipictury.allowDangerouslySkipPermissions.' },
79
+ }
80
+
81
+ /** The mode in force. Bypass needs its own setting: without it, it is Auto. */
82
+ function modeNow() {
83
+ const config = vscode.workspace.getConfiguration('aipictury')
84
+ const mode = config.get('approval', 'ask')
85
+ return mode === 'bypass' && !config.get('allowDangerouslySkipPermissions', false) ? 'never' : mode
86
+ }
87
+ const unattended = (mode) => mode === 'never' || mode === 'bypass'
88
+
89
+ const PLAN_NOTE = `
90
+
91
+ You are in PLAN mode. You may only read and search the workspace — files cannot be
92
+ changed, and only read-only commands run (ls, cat, git status/log/diff, grep…).
93
+ Investigate what the user asked about, then write a concise plan: the goal, which files
94
+ change and how, the order, and anything you are unsure about. Describe the code, do not
95
+ write it out in full. When the plan is ready, present it with exit_plan_mode: the user
96
+ approves it (and you may then make the changes) or asks for changes to it.`
97
+
98
+ const AUTO_NOTE = `
99
+
100
+ You are in AUTO mode: the user is not approving each step. Still call ask_user whenever
101
+ you choose between real options — it keeps a record of the choice. Unless you mark the
102
+ call important, your recommended (first) option is taken at once, you carry on without
103
+ waiting, and the user can change it when you finish. Mark important only a decision that
104
+ would be costly to undo.`
105
+
106
+ const SYSTEM = `You are AIPictury's coding agent, working inside the user's editor on
107
+ their own machine. You can read and change the workspace with the tools provided.
108
+
109
+ How to work:
110
+ - Look before you act. Find things with grep (contents) and glob (file names); ask
111
+ code_intel where a symbol is defined or used; read the file you are about to change;
112
+ check the reported problems when fixing a bug. For a broad search across the codebase,
113
+ hand it to a helper with task (subagent_type "explore") so your context stays small.
114
+ - Make the change with edit_file when the file already exists, quoting enough context
115
+ that old_text appears exactly once (or replace_all). Use write_file for a new file, or
116
+ a rewrite. After a change, the editor's new errors in that file come back with the
117
+ result: fix them.
118
+ - Use the file tools for files — never cat, sed, awk or echo in run_command to read,
119
+ search or edit them. run_command is for tests, builds, git, installs and servers
120
+ (run_in_background for servers and watchers).
121
+ - Change what was asked for and what that change requires — nothing else.
122
+ - For a task of three or more steps, keep a to-do list with todo_write.
123
+ - Make pictures and video with generate_media when the user wants an image, photo,
124
+ illustration, icon, background or clip. To replace one the code uses, find it first
125
+ (search, read the component), then save_to that same path: it keeps the size and
126
+ format. To change an existing picture, pass it as source. Describe what to draw in
127
+ full — subject, setting, light, style, composition — never a bare label.
128
+ - Research online when the task needs current or external facts: web_search to find
129
+ pages, fetch_url to read one. Bring what you learn back into your answer, and cite the
130
+ page you took it from inline, as a Markdown link: [Node.js releases](https://nodejs.org/…).
131
+ - When you are done, say in a few lines what you changed and what the user should check.
132
+ Do not paste the whole file back: they can see the diff in their editor.
133
+
134
+ When a decision belongs to the user — which library or stack, the scope, naming or
135
+ structure they will live with, a real trade-off between options, anything costly to
136
+ undo — do not settle it in your reasoning: ask with ask_user. Give 2 to 4 concrete
137
+ options, your recommended one first with " (Recommended)" at the end of its label, each
138
+ with a one-line description of what it means. The user can always write their own
139
+ answer. Ask related decisions together (up to 4 in one call), before you start the work
140
+ they shape. Do not ask about what you can find out by reading the code, or small choices
141
+ with an obvious answer.
142
+ In particular: before you install a package, or pick a library, framework, database,
143
+ provider, service or platform the user did not name and the project does not already
144
+ use, ask — look at the project first (package.json, the code), then ask with what you
145
+ found in mind. Installing something they did not choose is costly to undo. Where data is
146
+ kept (in memory, a file, a database) and which platform or provider to deploy to, store
147
+ files with or pay through are theirs to choose too. Look first — a few reads at most —
148
+ then ask; do not keep exploring for an answer that only they can give.
149
+
150
+ When the user states something to keep — a rule, a preference, a convention, anything
151
+ said with "remember", "always", "never", "from now on", "recordá", "regla", "siempre",
152
+ "nunca", "de ahora en más" — save it with the remember tool before carrying on: scope
153
+ "project" for this codebase, "user" for how they like to work everywhere. The rules
154
+ already saved are listed at the end of these instructions; follow them.
155
+
156
+ Put code in the tool call, not in your reply and not in your reasoning. Showing the
157
+ user a block of code is not the same as changing their file, and only the tool call
158
+ changes anything.
159
+
160
+ Your answers are rendered as Markdown: fenced code blocks tagged with their language,
161
+ tables where they help, and \`backticks\` for paths and identifiers. Keep prose short —
162
+ this is a side panel, not an essay.`
163
+
164
+ /** One streamed turn from the server. Returns what the model said and wants done. */
165
+ async function turn(server, messages, { role = 'agent', tools, model, think = true, effort, signal, onDelta, onThinking,
166
+ onWaiting, onSpeed, watchReasoning = false, watchLoops = false, steer,
167
+ thinkBudget = 0 } = {}) {
168
+ const sent = compact(messages)
169
+ // Our own switch for cutting the reasoning short, tied to the caller's Stop as well.
170
+ const cut = new AbortController()
171
+ const onStop = () => cut.abort()
172
+ signal?.addEventListener('abort', onStop, { once: true })
173
+ // The user said something mid-generation: this answer is cut so the next one hears it.
174
+ let interrupted = false
175
+ const onSteer = () => { interrupted = true; cut.abort() }
176
+ if (steer?.aborted) onSteer()
177
+ steer?.addEventListener('abort', onSteer, { once: true })
178
+ const body = { messages: sent, tools: tools || toolSchema(), role, think }
179
+ if (effort) body.effort = effort
180
+ if (model) body.model = model // the picker's choice overrides the role default
181
+ const stopped = () => { const stop = new Error('stopped'); stop.name = 'AbortError'; return stop }
182
+ let response
183
+ try {
184
+ response = await withDeadline(
185
+ fetch(`${server}/api/agent/turn`, {
186
+ method: 'POST',
187
+ headers: { 'Content-Type': 'application/json' },
188
+ body: JSON.stringify(body),
189
+ signal: cut.signal,
190
+ }), OPEN_TIMEOUT, 'opening the connection')
191
+ } catch (error) {
192
+ cut.abort()
193
+ signal?.removeEventListener('abort', onStop)
194
+ steer?.removeEventListener('abort', onSteer)
195
+ if (signal?.aborted) throw stopped()
196
+ if (interrupted) return { text: '', thinking: '', calls: [], meta: {}, interrupted: true }
197
+ if (error.transient) throw error
198
+ // fetch itself failed: the server is restarting, or the Wi-Fi link blinked.
199
+ throw failure(`the server could not be reached (${error.cause?.code || error.message})`,
200
+ { transient: true })
201
+ }
202
+
203
+ if (!response.ok) {
204
+ const detail = await response.text().catch(() => '')
205
+ signal?.removeEventListener('abort', onStop)
206
+ // 409 is the one the user meets in practice: a render already holds the GPU.
207
+ if (response.status === 409) {
208
+ throw failure('a generation is using the GPU right now, so the agent waits for it '
209
+ + 'to finish', { busy: true })
210
+ }
211
+ throw failure(`the server answered ${response.status}: ${detail.slice(0, 300)}`,
212
+ { transient: response.status >= 500 || response.status === 429 })
213
+ }
214
+
215
+ const reader = response.body.getReader()
216
+ const decode = new TextDecoder()
217
+ let buffer = ''
218
+ let text = ''
219
+ let thinking = ''
220
+ let calls = []
221
+ let meta = {}
222
+
223
+ let overthought = false
224
+ let looped = false
225
+ let checkedAt = 0 // reasoning length at the last loop check
226
+ for (;;) {
227
+ let chunk
228
+ try {
229
+ chunk = await withDeadline(reader.read(), STALL_TIMEOUT, 'answering')
230
+ } catch (error) {
231
+ if (overthought || looped || interrupted) break // we cut it ourselves
232
+ // Close our end, so the server stops the generation instead of finishing a
233
+ // turn nobody will read while the retry waits behind it for the GPU.
234
+ cut.abort()
235
+ signal?.removeEventListener('abort', onStop)
236
+ if (signal?.aborted) throw stopped()
237
+ if (error.transient) throw error
238
+ throw failure(`the connection to the server broke while answering (${error.message})`,
239
+ { transient: true })
240
+ }
241
+ const { done, value } = chunk
242
+ if (done) break
243
+ buffer += decode.decode(value, { stream: true })
244
+ const lines = buffer.split('\n')
245
+ buffer = lines.pop()
246
+ for (const line of lines) {
247
+ if (!line.trim()) continue
248
+ let event
249
+ try { event = JSON.parse(line) } catch { continue }
250
+ if (event.delta) { text += event.delta; onDelta?.(event.delta) }
251
+ if (event.thinking) { thinking += event.thinking; onThinking?.(event.thinking) }
252
+ if (event.tool_calls) calls = event.tool_calls
253
+ if (event.waiting) {
254
+ onWaiting?.(event)
255
+ // The server says how long it will wait before giving up; its "still waiting" events
256
+ // keep the stream alive, so this is what stops a wait that outlives that promise.
257
+ if ((event.silent ?? event.waiting) > (event.limit || QUIET_LIMIT) + 30) {
258
+ cut.abort()
259
+ signal?.removeEventListener('abort', onStop)
260
+ throw failure(`the model made no progress for ${Math.round(event.waiting / 60)} minutes`, { transient: true })
261
+ }
262
+ }
263
+ if (event.speed) onSpeed?.(event.speed)
264
+ if (event.done || event.error) meta = event
265
+ // The effort chosen caps the reasoning: past its budget, the reasoning is cut and the
266
+ // model acts on what it worked out, as with a loop.
267
+ if (thinkBudget && !looped && !overthought && !text && !calls.length && thinking.length > thinkBudget) {
268
+ looped = true
269
+ cut.abort()
270
+ }
271
+ if (watchReasoning && !overthought && !text && !calls.length && overthinking(thinking)) {
272
+ overthought = true
273
+ cut.abort() // stop spending minutes on a draft
274
+ }
275
+ // Checked every ~1,500 characters rather than per token: the check reads the
276
+ // whole reasoning, and a loop is only visible after a few paragraphs anyway.
277
+ if (watchLoops && !overthought && !looped && !text && !calls.length
278
+ && thinking.length - checkedAt >= 1_500) {
279
+ checkedAt = thinking.length
280
+ if (thinkingLoops(thinking)) {
281
+ // Going round in circles. When what repeats is a code draft, the fitting
282
+ // instruction is the draft one — write the files — not "act on your notes".
283
+ if (watchReasoning && (CODE_BLOCK.test(thinking) || looksLikeCode(thinking))) overthought = true
284
+ else looped = true
285
+ cut.abort()
286
+ }
287
+ }
288
+ }
289
+ if (overthought || looped || interrupted) break
290
+ }
291
+ signal?.removeEventListener('abort', onStop)
292
+ steer?.removeEventListener('abort', onSteer)
293
+ if (signal?.aborted) throw stopped()
294
+ if (overthought) return { text: '', thinking, calls: [], meta: {}, overthought: true }
295
+ if (looped) return { text: '', thinking, calls: [], meta: {}, looped: true, budget: thinkBudget && thinking.length > thinkBudget }
296
+ if (interrupted) return { text, thinking, calls: [], meta: {}, interrupted: true }
297
+ // An error the server reported mid-stream (the model stopped early, Ollama failed):
298
+ // the turn is incomplete, and asking again is the fix.
299
+ if (meta.error) throw failure(meta.error, { transient: true })
300
+ calibrate(size(sent), meta.context_used)
301
+ if (meta.context_window) setWindow(meta.context_window)
302
+ return { text, thinking, calls, meta }
303
+ }
304
+
305
+ /** Asks the user about a change or a command, unless their settings already answered.
306
+ * Asked in the panel — the change shown hunk by hunk, each accepted or rejected, or the
307
+ * whole of it; "no" can say what to do instead — as Claude Code asks in its chat, not in
308
+ * a dialog that hides the editor. Without a panel (tests, a closed view) a dialog asks.
309
+ * Returns { ok, all, feedback, content } — content when only part of a change was kept
310
+ * or the user edited the proposal. */
311
+ async function approve(call, session, signal, hooks = {}, preview = null, { force = false, because = null, server = '' } = {}) {
312
+ const mode = modeNow()
313
+ const name = call.function.name
314
+ const args = call.function.arguments || {}
315
+ const isRun = RUNS.has(name) || mcp.isMcp(name) || name === 'browser'
316
+ if (mode === 'plan' && WRITES.has(name)) return { ok: false } // plan mode changes nothing
317
+ // An "ask" rule asks whatever the mode and any earlier yes: that is what it is for.
318
+ if (!force) {
319
+ if (mode === 'bypass') return { ok: true }
320
+ if (mode === 'never') {
321
+ // Auto: edits inside the workspace go ahead (a checkpoint can undo them); anything else
322
+ // is judged — fixed rules, routine work, then the model — and asked when it is risky.
323
+ if (WRITES.has(name)) return { ok: true }
324
+ const verdict = await judge(call, session, signal, hooks, server)
325
+ if (verdict.verdict === 'allow') return { ok: true, judged: verdict }
326
+ because = `Auto mode asks: ${verdict.reason || 'it could not tell whether this is safe'}`
327
+ }
328
+ if (mode === 'edits' && !isRun) return { ok: true }
329
+ if (session.allowAll) return { ok: true }
330
+ if (RUNS.has(name) && session.allowCommands?.some((p) => String(args.command || '').trim().startsWith(p))) return { ok: true }
331
+ }
332
+
333
+ if (hooks.approve) {
334
+ const prefix = RUNS.has(name) ? commandPrefix(args.command) : undefined
335
+ const request = RUNS.has(name)
336
+ ? { kind: 'command', command: String(args.command || ''), description: args.description || '', prefix }
337
+ : mcp.isMcp(name) ? { kind: 'mcp', tool: name, args }
338
+ : WRITES.has(name) ? { kind: 'edit', path: args.path, tool: name, before: preview?.before ?? null, after: preview?.after ?? null,
339
+ hunks: preview ? hunks(preview.before ?? '', preview.after ?? '') : [] }
340
+ : { kind: 'tool', tool: name, args }
341
+ // A standing yes for a command, an MCP tool or another tool can be kept beyond this
342
+ // conversation: the rule it would save is shown, and where to save it is chosen.
343
+ if (request.kind !== 'edit') { request.rule = permissions.ruleFor(name, args, prefix); request.destinations = permissions.DESTINATIONS }
344
+ if (name === 'browser') {
345
+ request.site = browserTool.hostOf(args.page || '')
346
+ if (!because) because = `the browser would ${browserTool.describe(args)} on ${request.site || 'a page'}`
347
+ }
348
+ if (because) request.because = because
349
+ const answer = await Promise.race([hooks.approve(request, signal), stopped(signal)])
350
+ if (answer === STOPPED || !answer) return { ok: false }
351
+ if (answer.decision === 'allow_all') {
352
+ const where = answer.destination || 'conversation'
353
+ if (where !== 'conversation' && request.rule) {
354
+ // Not saved is said, not swallowed: the yes still counts for this conversation.
355
+ try { permissions.remember(folderPath(), where, request.rule) } catch (error) {
356
+ log.warn(`could not save the rule ${request.rule}`, error)
357
+ vscode.window.showWarningMessage(`AIPictury could not save the rule ${request.rule}: ${error.message}. It applies to this conversation only.`)
358
+ if (RUNS.has(name) && answer.prefix) (session.allowCommands ||= []).push(answer.prefix)
359
+ else if (name === 'browser') (session.allowSites ||= []).push(request.site)
360
+ else (session.allowTools ||= []).push(name)
361
+ }
362
+ } else if (name === 'browser') (session.allowSites ||= []).push(request.site)
363
+ else if (RUNS.has(name) && answer.prefix) (session.allowCommands ||= []).push(answer.prefix)
364
+ else if (mcp.isMcp(name) || request.kind === 'tool') (session.allowTools ||= []).push(name)
365
+ else session.allowAll = true
366
+ }
367
+ if (answer.decision === 'deny') return { ok: false, feedback: answer.feedback || '' }
368
+ let content
369
+ if (preview && Array.isArray(answer.keep) && answer.keep.length < request.hunks.length) {
370
+ content = applyHunks(preview.before ?? '', preview.after ?? '', new Set(answer.keep))
371
+ }
372
+ if (typeof answer.edited === 'string' && answer.edited !== preview?.after) content = answer.edited
373
+ return { ok: true, content, partial: Array.isArray(answer.keep) && answer.keep.length < request.hunks.length,
374
+ edited: typeof answer.edited === 'string' && answer.edited !== preview?.after }
375
+ }
376
+
377
+ const what = RUNS.has(name) ? `Run this command?\n\n${args.command}`
378
+ : mcp.isMcp(name) ? `Use ${name}?\n\n${JSON.stringify(args).slice(0, 400)}`
379
+ : `${name === 'write_file' ? 'Write' : 'Edit'} ${args.path}?`
380
+ // Stop has to work while a question is on screen, or the only way out is to answer it.
381
+ const asked = vscode.window.showWarningMessage(what, { modal: true },
382
+ 'Allow', 'Allow for this conversation', ...(isRun ? [] : ['Show diff first']))
383
+ const choice = await Promise.race([asked, stopped(signal)])
384
+ if (choice === STOPPED) return { ok: false }
385
+ if (choice === 'Show diff first') {
386
+ await showDiff(args.path, preview)
387
+ const second = await Promise.race([vscode.window.showWarningMessage(`Apply the change to ${args.path}?`,
388
+ { modal: true }, 'Allow', 'Allow for this conversation'), stopped(signal)])
389
+ if (second === STOPPED) return { ok: false }
390
+ if (second === 'Allow for this conversation') { session.allowAll = true; return { ok: true } }
391
+ return { ok: second === 'Allow' }
392
+ }
393
+ if (choice === 'Allow for this conversation') { session.allowAll = true; return { ok: true } }
394
+ return { ok: choice === 'Allow' }
395
+ }
396
+
397
+ /** Auto mode's judgement of one call, from the server — remembered for the conversation, so
398
+ * the same command is judged once. Anything that goes wrong is an "ask". */
399
+ async function judge(call, session, signal, hooks, server) {
400
+ const name = call.function.name
401
+ const args = call.function.arguments || {}
402
+ const key = JSON.stringify([name, args])
403
+ const cache = (session.judged ||= new Map())
404
+ if (cache.has(key)) return cache.get(key)
405
+ if (!server) return { verdict: 'ask', reason: 'no server to judge it' }
406
+ const controller = new AbortController()
407
+ const stop = () => controller.abort()
408
+ signal?.addEventListener('abort', stop, { once: true })
409
+ const timer = setTimeout(stop, 45_000)
410
+ try {
411
+ const response = await fetch(`${server}/api/agent/classify`, { method: 'POST', signal: controller.signal,
412
+ headers: { 'Content-Type': 'application/json' },
413
+ body: JSON.stringify({ tool: name, args, request: String(hooks.userSaid?.() || '').slice(-1500),
414
+ root: folderPath(), model: hooks.model || undefined }) })
415
+ if (!response.ok) return { verdict: 'ask', reason: `the judge answered ${response.status}` }
416
+ const verdict = await response.json()
417
+ log.info(`Auto mode: ${verdict.verdict} ${name} (${verdict.by}: ${verdict.reason})`)
418
+ if (verdict.by !== 'error') cache.set(key, verdict)
419
+ return verdict
420
+ } catch (error) {
421
+ return { verdict: 'ask', reason: signal?.aborted ? 'stopped' : `it could not be judged (${error.message})` }
422
+ } finally {
423
+ clearTimeout(timer)
424
+ signal?.removeEventListener('abort', stop)
425
+ }
426
+ }
427
+
428
+ /** How a command runs with the sandbox settings: { run } sandboxed, { outside } left out of it
429
+ * (excluded, or asked for), { unavailable } on a machine without one, or null with it off.
430
+ * A setting made in VS Code wins over the settings files' "sandbox". */
431
+ function sandboxFor(args, folder) {
432
+ const config = vscode.workspace.getConfiguration('aipictury')
433
+ const set = (key) => { const i = config.inspect(`sandbox.${key}`); return i?.workspaceValue ?? i?.globalValue }
434
+ const s = sandboxing.settings(prefs.load(folder).merged.sandbox, { enabled: set('enabled'), allowNetwork: set('allowNetwork'),
435
+ autoAllow: set('autoAllow'), excludedCommands: set('excludedCommands'), writablePaths: set('writablePaths') })
436
+ if (!s.enabled) return null
437
+ if (args.outside_sandbox || sandboxing.excluded(args.command, s.excluded)) return { s, outside: true, asked: !!args.outside_sandbox }
438
+ if (!sandboxing.available()) return { s, unavailable: true }
439
+ return { s, run: { root: folder, writable: s.writable, network: s.allowNetwork } }
440
+ }
441
+
442
+ /** The workspace folder, for rules written relative to it — or none, with no folder open. */
443
+ function folderPath() { try { return workspaceRoot().fsPath } catch { return undefined } }
444
+
445
+ // Tools that steer the conversation itself: no rule applies to them.
446
+ const CONTROL = new Set(['ask_user', 'exit_plan_mode', 'enter_plan_mode', 'todo_write'])
447
+
448
+ /** The part of a command a standing yes covers: its program and, for tools with verbs
449
+ * (git, npm, cargo…), the verb — "npm test", "git status" — never the arguments. */
450
+ function commandPrefix(command) {
451
+ const words = String(command || '').trim().split(/\s+/)
452
+ const verbs = /^(git|npm|pnpm|yarn|bun|npx|cargo|go|pip|pip3|python|python3|node|docker|make|uv|poetry|dotnet|mvn|gradle|kubectl)$/
453
+ return words.slice(0, verbs.test(words[0]) && words[1] && !words[1].startsWith('-') ? 2 : 1).join(' ')
454
+ }
455
+
456
+ // A sentinel for "the user pressed stop while this was being asked".
457
+ const STOPPED = Symbol('stopped')
458
+
459
+ /** Resolves if and when the run is aborted. */
460
+ function stopped(signal) {
461
+ if (!signal) return new Promise(() => {})
462
+ if (signal.aborted) return Promise.resolve(STOPPED)
463
+ return new Promise((resolve) =>
464
+ signal.addEventListener('abort', () => resolve(STOPPED), { once: true }))
465
+ }
466
+
467
+ /** What a write or an edit would make of a file, without making it. */
468
+ async function previewChange(call, session) {
469
+ const name = call.function.name
470
+ const args = call.function.arguments || {}
471
+ if (!['write_file', 'edit_file'].includes(name)) return null
472
+ const uri = resolve(args.path)
473
+ let raw = null
474
+ try { raw = decoder.decode(await vscode.workspace.fs.readFile(uri)) } catch { /* a new file */ }
475
+ const before = raw === null ? null : files.parseText(raw).text
476
+ if (name === 'write_file') return { before, after: String(args.content ?? '').replace(/\r\n/g, '\n') }
477
+ if (before === null) return null
478
+ await files.checkFresh(session, uri, args.path)
479
+ const edits = Array.isArray(args.edits) && args.edits.length ? args.edits
480
+ : [{ old_text: args.old_text, new_text: args.new_text, replace_all: args.replace_all }]
481
+ let text = before
482
+ for (const [i, e] of edits.entries()) {
483
+ text = files.replaceOnce(text, e.old_text, e.new_text, !!e.replace_all, edits.length > 1 ? `edit ${i + 1}: ` : '').text
484
+ }
485
+ return { before, after: text }
486
+ }
487
+
488
+ /** Shows what a write would do, against what is on disk, in VS Code's diff. */
489
+ async function showDiff(relPath, preview) {
490
+ if (!preview) return
491
+ const uri = resolve(relPath)
492
+ const proposed = uri.with({ scheme: 'aipictury-proposed', query: String(Date.now()) })
493
+ proposedContents.set(proposed.toString(), preview.after)
494
+ await vscode.commands.executeCommand('vscode.diff', uri, proposed, `${relPath} — proposed by AIPictury`, { preview: true })
495
+ }
496
+
497
+ // What a file held before the agent changed it, for VS Code's side-by-side diff. In
498
+ // memory only, and only the most recent few — it is a convenience, not a history.
499
+ const befores = new Map()
500
+ function rememberBefore(path, text) {
501
+ const key = `${Date.now().toString(36)}-${befores.size}`
502
+ befores.set(key, { path, text })
503
+ while (befores.size > 50) befores.delete(befores.keys().next().value)
504
+ return key
505
+ }
506
+
507
+ /** Opens VS Code's own side-by-side diff: what the file was, against what it is now. */
508
+ async function openSideBySide(key) {
509
+ const entry = befores.get(key)
510
+ if (!entry) {
511
+ vscode.window.showInformationMessage('That earlier version is no longer in memory; '
512
+ + 'the change is still shown in the panel.')
513
+ return
514
+ }
515
+ const current = resolve(entry.path)
516
+ const was = current.with({ scheme: 'aipictury-proposed', query: `before-${key}` })
517
+ proposedContents.set(was.toString(), entry.text)
518
+ await vscode.commands.executeCommand('vscode.diff', was, current,
519
+ `${entry.path} — before ↔ after`, { preview: true })
520
+ }
521
+
522
+ // Backing store for the proposed side of a diff.
523
+ const proposedContents = new Map()
524
+ const proposedProvider = {
525
+ provideTextDocumentContent(uri) { return proposedContents.get(uri.toString()) ?? '' },
526
+ }
527
+
528
+ /** The model's question, answered. Blocks on the panel's chooser — except in Auto mode,
529
+ * where a question not marked important takes the recommended options at once and is
530
+ * kept as a decision the user can change when the run ends. */
531
+ async function askUser(args, report, signal, hooks) {
532
+ let questions
533
+ try {
534
+ questions = normalizeQuestions(args)
535
+ } catch (error) {
536
+ report({ tool: 'ask_user', args, status: 'failed', result: error.message })
537
+ return `error: ${error.message}`
538
+ }
539
+ const mode = modeNow()
540
+ const recommended = (q) => q.options[0].label.replace(/\s*\(recommended\)\s*$/i, '')
541
+ if ((unattended(mode) && !args.important) || !hooks.askUser) {
542
+ const picks = questions.map((q) => ({ ...q, picked: [q.options[0].label], other: '' }))
543
+ hooks.onDecision?.(picks)
544
+ report({ tool: 'ask_user', args: { questions }, status: 'decided', result: picks })
545
+ return 'Auto mode: the user is not asked now. Proceed with your recommended option'
546
+ + `${picks.length > 1 ? 's' : ''}: ${questions.map((q) => `${q.header} → ${recommended(q)}`).join('; ')}. `
547
+ + 'The user can change these when you finish.'
548
+ }
549
+ report({ tool: 'ask_user', args: { questions }, status: 'asking-user' })
550
+ const answer = await hooks.askUser(questions, signal)
551
+ if (!answer) { // Stop, while the question was open
552
+ report({ tool: 'ask_user', args: { questions }, status: 'refused' })
553
+ return 'stopped by the user before answering'
554
+ }
555
+ if (answer.dismissed) {
556
+ report({ tool: 'ask_user', args: { questions }, status: 'dismissed' })
557
+ return 'The user closed the question without answering. Use your best judgement, and say '
558
+ + 'in your answer what you chose so they can change it.'
559
+ }
560
+ report({ tool: 'ask_user', args: { questions }, status: 'answered', result: answer.answers })
561
+ const lines = answer.answers.map((a) => {
562
+ const chosen = [...(a.picked || []), ...(a.other ? [`their own answer: "${a.other}"`] : [])]
563
+ return `- ${a.header} (${a.question}): ${chosen.join(', ') || 'no answer'}`
564
+ })
565
+ return `The user answered:\n${lines.join('\n')}\nCarry on with these.`
566
+ }
567
+
568
+ /** A picture or clip made on the server and written into the workspace. */
569
+ async function makeMedia(call, session, report, signal, server, hooks = {}) {
570
+ const args = call.function.arguments || {}
571
+ const mode = modeNow()
572
+ if (mode === 'plan') {
573
+ report({ tool: 'generate_media', args, status: 'plan' })
574
+ return 'Plan mode: nothing was generated. Describe the picture in your plan instead.'
575
+ }
576
+ // Writing a file needs the same yes as any write: asked for in Manual only.
577
+ const ask = async (question) => {
578
+ if (unattended(mode) || mode === 'edits' || session.allowAll) return true
579
+ report({ tool: 'generate_media', args, status: 'asking' })
580
+ if (hooks.approve) {
581
+ const answer = await Promise.race([hooks.approve({ kind: 'media', question, path: args.save_to }, signal), stopped(signal)])
582
+ if (answer === STOPPED || !answer || answer.decision === 'deny') return false
583
+ if (answer.decision === 'allow_all') session.allowAll = true
584
+ return true
585
+ }
586
+ const choice = await Promise.race([
587
+ vscode.window.showWarningMessage(question, { modal: true }, 'Allow', 'Allow for this conversation'),
588
+ stopped(signal)])
589
+ if (choice === 'Allow for this conversation') { session.allowAll = true; return true }
590
+ return choice === 'Allow'
591
+ }
592
+ try {
593
+ const result = await mediagen.generate(server, args, {
594
+ ask, signal, report: (update) => report({ tool: 'generate_media', args, ...update }) })
595
+ if (result.refused) report({ tool: 'generate_media', args, status: 'refused' })
596
+ return result.text
597
+ } catch (error) {
598
+ if (error.name === 'AbortError' || signal?.aborted) return 'stopped by the user'
599
+ report({ tool: 'generate_media', args, status: 'failed', result: error.message })
600
+ return `error: ${error.message}`
601
+ }
602
+ }
603
+
604
+ /** The to-do list the model keeps, cleaned: at most 30 items, a known status each. */
605
+ function normalizeTodos(todos) {
606
+ let list = todos
607
+ if (typeof list === 'string') { try { list = JSON.parse(list) } catch { list = [] } }
608
+ if (!Array.isArray(list)) throw new Error('todos must be a list of { content, status }')
609
+ const out = list.slice(0, 30).map((t) => ({
610
+ content: String(t?.content || t?.task || '').trim(),
611
+ status: ['pending', 'in_progress', 'completed'].includes(t?.status) ? t.status : 'pending',
612
+ activeForm: String(t?.activeForm || t?.active_form || '').trim(),
613
+ })).filter((t) => t.content)
614
+ if (!out.length) throw new Error('todos is empty — give at least one item')
615
+ return out
616
+ }
617
+
618
+ /** Carries out one tool call and returns what goes back to the model: text, or
619
+ * { text, images } when there is a picture for it to see. */
620
+ /** The hooks' context for this conversation: where, who, and how to ask for trust. */
621
+ const hookContext = (session, signal, hooks, subject) => ({ folder: folderPath(), subject, sessionId: session?.id || '',
622
+ trust: hooks.trustHooks, signal, log: (line) => log.warn(line) })
623
+
624
+ /** One tool call, with the user's PreToolUse and PostToolUse hooks around it. */
625
+ async function execute(call, session, report, signal, server, hooks = {}) {
626
+ const name = call.function.name
627
+ let args = call.function.arguments || {}
628
+ if (typeof args === 'string') { try { args = JSON.parse(args) } catch { args = {} } }
629
+ const folder = folderPath()
630
+ const ctx = hookContext(session, signal, hooks, name)
631
+ const asClaude = { tool_name: userHooks.claudeName(name), tool_input: userHooks.toolInput(name, args, folder) }
632
+ const pre = await userHooks.fire('PreToolUse', asClaude, ctx)
633
+ if (pre.stop) {
634
+ session.hookStop = pre.stopReason || 'a hook stopped the run'
635
+ report({ tool: name, args, status: 'refused', result: `stopped by a hook: ${session.hookStop}` })
636
+ return `Stopped by a hook: ${session.hookStop}`
637
+ }
638
+ if (pre.block || pre.permission === 'deny') {
639
+ const why = [...pre.reasons, ...pre.permissionReasons].filter(Boolean).join('; ') || 'no reason given'
640
+ report({ tool: name, args, status: 'refused', result: `blocked by a hook: ${why}` })
641
+ return `A PreToolUse hook blocked this: ${why}. Do not try to reach the same result another way; `
642
+ + 'if it matters, say in your reply what you could not do and why.'
643
+ }
644
+ const result = await executeTool(call, session, report, signal, server,
645
+ pre.permission ? { ...hooks, hookPermission: pre.permission, hookReason: pre.permissionReasons.filter(Boolean).join('; ') } : hooks)
646
+ const text = typeof result === 'object' ? result.text : result
647
+ const post = await userHooks.fire('PostToolUse', { ...asClaude, tool_response: String(text ?? '').slice(0, 20_000) }, ctx)
648
+ if (post.stop) session.hookStop = post.stopReason || 'a hook stopped the run'
649
+ const notes = [...(post.block ? post.reasons : []), ...post.context]
650
+ if (!notes.length) return result
651
+ const added = `\n\n[A PostToolUse hook says: ${notes.join('\n')}]`
652
+ return typeof result === 'object' ? { ...result, text: `${result.text}${added}` } : `${result}${added}`
653
+ }
654
+
655
+ // The tools that take a path: in a worktree, their paths are the worktree's.
656
+ const PATH_TOOLS = new Set(['read_file', 'write_file', 'edit_file', 'notebook_edit', 'list_files', 'grep', 'glob', 'code_intel', 'search'])
657
+
658
+ async function executeTool(call, session, report, signal, server, hooks = {}) {
659
+ const name = call.function.name
660
+ let args = call.function.arguments || {}
661
+ if (typeof args === 'string') { try { args = JSON.parse(args) } catch { args = {} } }
662
+ if (session.worktree && PATH_TOOLS.has(name)) args = { ...args, path: worktrees.inside(session.worktree, args.path) }
663
+ // The browser: the site a call goes to or acts on — for the rules, the question and the judge.
664
+ if (name === 'browser') args = { ...args, page: browserTool.targetUrl(args) || undefined }
665
+ const mode = modeNow()
666
+ const planning = mode === 'plan'
667
+ const ctx = { session, signal, readSpecial: hooks.readSpecial }
668
+ const fail = (error) => {
669
+ report({ tool: name, args, status: 'failed', result: error.message })
670
+ return `error: ${error.message}`
671
+ }
672
+ // The permission rules of the settings files: a deny stops the call here, whatever the mode.
673
+ const rule = CONTROL.has(name) ? { decision: null } : permissions.decide(name, args, { folder: folderPath(), extraAllow: session.commandAllow, extraDeny: session.commandDeny })
674
+ if (rule.decision === 'deny') {
675
+ const where = rule.file ? ` (${rule.level} settings, ${rule.file.replace(os.homedir(), '~')})` : ''
676
+ report({ tool: name, args, status: 'refused', result: `denied by ${rule.rule}` })
677
+ return `Denied by the permission rule ${rule.rule}${where}. Do not try to reach the same result another way; `
678
+ + 'if it matters, say in your reply what you could not do and why.'
679
+ }
680
+ if (name === 'ask_user') return askUser(args, report, signal, hooks)
681
+ if (name === 'generate_media') return makeMedia(call, session, report, signal, server, hooks)
682
+
683
+ if (name === 'remember') {
684
+ report({ tool: name, args, status: 'running' })
685
+ try {
686
+ const rule = String(args.rule || '').trim()
687
+ if (!rule) throw new Error('rule is required')
688
+ const result = args.scope === 'user' ? await memory.addUserRule(server, rule) : await memory.addProjectRule(rule)
689
+ report({ tool: name, args, status: 'done', result })
690
+ return result
691
+ } catch (error) { return fail(error) }
692
+ }
693
+
694
+ if (name === 'todo_write') {
695
+ try {
696
+ const todos = normalizeTodos(args.todos)
697
+ session.todos = todos
698
+ hooks.onTodos?.(todos)
699
+ report({ tool: name, args: { todos }, status: 'done', todos })
700
+ const doing = todos.find((t) => t.status === 'in_progress')
701
+ return `To-do list saved (${todos.filter((t) => t.status === 'completed').length}/${todos.length} done`
702
+ + `${doing ? `; now: ${doing.content}` : ''}). Keep it up to date as you go.`
703
+ } catch (error) { return fail(error) }
704
+ }
705
+
706
+ // A git worktree for this conversation: changes away from the user's checkout.
707
+ if (name === 'enter_worktree' || name === 'exit_worktree') {
708
+ try {
709
+ const base = folderPath()
710
+ let text
711
+ if (name === 'enter_worktree') {
712
+ if (session.worktree) throw new Error(`already in the worktree ${session.worktree.name} — exit_worktree first`)
713
+ session.worktree = await worktrees.enter(base, args.name)
714
+ session.cwd = session.worktree.path
715
+ text = `Working in the worktree ${session.worktree.relative} on the branch ${session.worktree.branch}. Paths are now relative to it, and commands run in it.`
716
+ } else {
717
+ if (!session.worktree) throw new Error('not in a worktree')
718
+ const wt = session.worktree
719
+ const done = await worktrees.exit(base, wt, { action: args.action === 'remove' ? 'remove' : 'keep', discard: !!args.discard_changes })
720
+ delete session.worktree
721
+ session.cwd = base
722
+ text = done.kept ? `Left the worktree. It is kept at ${wt.relative} on ${wt.branch} (${done.commits} commit(s), ${done.dirty.length} uncommitted change(s)) — tell the user, so they can merge it.`
723
+ : 'Left the worktree and removed it.'
724
+ }
725
+ report({ tool: name, args, status: 'done', result: text })
726
+ return text
727
+ } catch (error) { return fail(error) }
728
+ }
729
+
730
+ // This conversation's schedule: cron prompts and wake-ups.
731
+ if (['cron_create', 'cron_list', 'cron_delete', 'schedule_wakeup'].includes(name)) {
732
+ try {
733
+ if (!hooks.schedule) throw new Error('scheduling is not available here')
734
+ const jobs = hooks.schedule.get()
735
+ let text
736
+ if (name === 'cron_list') text = jobs.length ? jobs.map((j) => scheduler.describe(j)).join('\n') : 'Nothing is scheduled.'
737
+ else if (name === 'cron_delete') {
738
+ if (!jobs.some((j) => j.id === args.id)) throw new Error(`no scheduled prompt ${args.id}`)
739
+ hooks.schedule.set(jobs.filter((j) => j.id !== args.id))
740
+ text = `${args.id} removed.`
741
+ } else {
742
+ const minutes = Number(args.delay_minutes)
743
+ if (name === 'schedule_wakeup' && !(minutes >= 1 && minutes <= 1440)) throw new Error('delay_minutes is 1 to 1440')
744
+ const j = name === 'schedule_wakeup'
745
+ ? scheduler.job(jobs, { schedule: `${Math.round(minutes)}m`, prompt: args.prompt, recurring: false, kind: 'wakeup' })
746
+ : scheduler.job(jobs, { schedule: args.schedule, prompt: args.prompt, recurring: args.recurring !== false })
747
+ hooks.schedule.set([...jobs, j])
748
+ text = `Scheduled as ${j.id}: ${scheduler.describe(j)}`
749
+ }
750
+ report({ tool: name, args, status: 'done', result: text })
751
+ return text
752
+ } catch (error) { return fail(error) }
753
+ }
754
+
755
+ // The task list with dependencies — the same list todo_write keeps.
756
+ if (['task_create', 'task_update', 'task_list', 'task_get'].includes(name)) {
757
+ try {
758
+ const list = tasklist.fromTodos(session.todos || [])
759
+ let text
760
+ if (name === 'task_list') text = tasklist.summary(list)
761
+ else if (name === 'task_get') text = tasklist.detail(list, args.taskId)
762
+ else {
763
+ const done = name === 'task_create' ? tasklist.create(list, args) : tasklist.update(list, args)
764
+ session.todos = done.list
765
+ hooks.onTodos?.(done.list)
766
+ text = name === 'task_create' ? `Task #${done.task.id} added${done.task.blockedBy.length ? `, waiting for ${done.task.blockedBy.map((i) => `#${i}`).join(', ')}` : ''}.`
767
+ : args.status === 'deleted' ? `Task #${done.task.id} deleted.`
768
+ : `Task #${done.task.id} is ${done.task.status}.${done.freed?.length ? ` Now free to start: ${done.freed.map((t) => `#${t.id} ${t.content}`).join(', ')}.` : ''}`
769
+ }
770
+ report({ tool: name, args, status: 'done', result: text, todos: session.todos })
771
+ return text
772
+ } catch (error) { return fail(error) }
773
+ }
774
+
775
+ if (name === 'exit_plan_mode') {
776
+ if (!planning) return 'You are not in plan mode — carry on with the task.'
777
+ const plan = String(args.plan || '').trim()
778
+ if (!plan) return fail(new Error('plan is required'))
779
+ report({ tool: name, args: { plan }, status: 'asking-user' })
780
+ const answer = hooks.approvePlan ? await Promise.race([hooks.approvePlan(plan, signal), stopped(signal)]) : null
781
+ if (answer === STOPPED || !answer) {
782
+ report({ tool: name, args: { plan }, status: 'refused' })
783
+ return 'The plan was not approved yet. Stop here: the user will reply.'
784
+ }
785
+ if (!answer.approved) {
786
+ report({ tool: name, args: { plan }, status: 'revise', result: answer.feedback || '' })
787
+ return `The user wants the plan changed${answer.feedback ? `: ${answer.feedback}` : ''}. Revise it and present it again with exit_plan_mode.`
788
+ }
789
+ await vscode.workspace.getConfiguration('aipictury').update('approval', answer.mode || 'edits', vscode.ConfigurationTarget.Global)
790
+ report({ tool: name, args: { plan }, status: 'approved', result: answer.mode })
791
+ const edited = answer.plan && answer.plan.trim() !== plan
792
+ return `The user approved the plan and switched you to ${MODES[answer.mode || 'edits']?.label || answer.mode} mode. `
793
+ + 'Carry it out now, step by step: you can change files and run commands.'
794
+ + (edited ? `\n\nThey edited the plan first — follow their version:\n\n${answer.plan.trim()}` : '')
795
+ }
796
+
797
+ if (name === 'enter_plan_mode') {
798
+ if (planning) return 'You are already in Plan mode: investigate, then present the plan with exit_plan_mode.'
799
+ const reason = String(args.reason || '').trim()
800
+ // Auto mode does not stop for this: the plan itself is still put to the user.
801
+ if (!unattended(mode)) {
802
+ report({ tool: name, args, status: 'asking' })
803
+ const answer = hooks.approve
804
+ ? await Promise.race([hooks.approve({ kind: 'plan-mode', reason }, signal), stopped(signal)]) : null
805
+ if (answer === STOPPED || !answer || answer.decision === 'deny') {
806
+ report({ tool: name, args, status: 'refused', result: answer?.feedback })
807
+ return answer?.feedback ? `The user does not want a plan first and said: ${answer.feedback}`
808
+ : 'The user does not want a plan first: go ahead with the task directly.'
809
+ }
810
+ }
811
+ await vscode.workspace.getConfiguration('aipictury').update('approval', 'plan', vscode.ConfigurationTarget.Global)
812
+ report({ tool: name, args, status: 'approved', result: 'plan' })
813
+ return 'You are in Plan mode now. Investigate with the read-only tools, then present your plan with '
814
+ + 'exit_plan_mode; nothing can be changed until the user approves it.'
815
+ }
816
+
817
+ if (name === 'push_notification') {
818
+ const message = String(args.message || '').trim()
819
+ if (!message) return fail(new Error('message is required'))
820
+ const title = String(args.title || '').trim().slice(0, 60) || 'AIPictury'
821
+ const sent = workbench.notify(title, message)
822
+ report({ tool: name, args: { title, message }, status: 'done', result: sent ? 'sent' : 'not sent' })
823
+ return sent ? 'The user was notified.' : 'Not sent: the user is looking at VS Code right now, or notifications are off — say it in your reply.'
824
+ }
825
+
826
+ if (name === 'report_findings') {
827
+ const findings = normalizeFindings(args)
828
+ report({ tool: name, args: { count: findings.length }, status: 'done', findings })
829
+ if (!findings.length) return 'Reported: no findings. Tell the user in a sentence what you checked.'
830
+ const counts = ['high', 'medium', 'low'].map((s) => [s, findings.filter((f) => f.severity === s).length])
831
+ .filter(([, n]) => n).map(([s, n]) => `${n} ${s}`).join(', ')
832
+ return `The user sees ${findings.length} finding${findings.length === 1 ? '' : 's'} (${counts}) as a list they can open. `
833
+ + 'Summarize in a sentence or two; do not repeat the list.'
834
+ }
835
+
836
+ if (name === 'tool_search') {
837
+ const found = mcp.search(args, (session.loadedMcp ||= new Set()))
838
+ report({ tool: name, args, status: 'done', result: found.split('\n')[0] })
839
+ return found
840
+ }
841
+ if (name === 'list_mcp_resources' || name === 'read_mcp_resource') {
842
+ report({ tool: name, args, status: 'running' })
843
+ try {
844
+ const text = name === 'list_mcp_resources' ? await mcp.listResources(args) : await mcp.readResource(args)
845
+ report({ tool: name, args, status: 'done', result: text })
846
+ return text
847
+ } catch (error) { return fail(error) }
848
+ }
849
+
850
+ // A notebook cell runs code: the user is asked (a native Execute / Cancel), unless in Bypass.
851
+ if (name === 'notebook_run_cell') {
852
+ if (planning) { report({ tool: name, args, status: 'plan' }); return 'Plan mode: no cell was run. Describe it in your plan instead.' }
853
+ report({ tool: name, args, status: 'running' })
854
+ try {
855
+ const ask = hooks.askRun || (async (question, code) => (await Promise.race([vscode.window.showQuickPick(
856
+ [{ label: 'Execute', description: 'run the cell' }, { label: 'Cancel' }], { title: question, placeHolder: code.split('\n').slice(0, 3).join(' ⏎ ').slice(0, 200) }),
857
+ stopped(signal)]))?.label === 'Execute')
858
+ const text = await notebooks.runCell(args, { ask, skipAsk: mode === 'bypass', signal })
859
+ report({ tool: name, args, status: 'done', result: text })
860
+ return text
861
+ } catch (error) { return fail(error) }
862
+ }
863
+ if (name === 'publish_page') {
864
+ try {
865
+ if (!String(args.markdown || args.html || '').trim()) throw new Error('give the page markdown or html')
866
+ const response = await fetch(`${server}/api/pages`, { method: 'POST', headers: { 'Content-Type': 'application/json' },
867
+ body: JSON.stringify({ title: String(args.title || 'Page').slice(0, 200), markdown: args.markdown, html: args.html }), signal })
868
+ const data = await response.json().catch(() => ({}))
869
+ if (!response.ok) throw new Error(data.detail || `the server answered ${response.status}`)
870
+ const url = `${server}${data.path}`
871
+ report({ tool: name, args: { title: args.title }, status: 'done', result: url, url })
872
+ return `Published at ${url} — give the user this link.`
873
+ } catch (error) { return fail(error) }
874
+ }
875
+ if (name === 'send_user_file') {
876
+ try {
877
+ const uri = resolve(args.path)
878
+ const stat = await vscode.workspace.fs.stat(uri)
879
+ if (stat.type & vscode.FileType.Directory) throw new Error(`${args.path} is a folder — send a file`)
880
+ report({ tool: name, args, status: 'done', result: args.message || '', file: { path: args.path, size: stat.size, message: args.message || '' } })
881
+ return `The user has ${args.path} as a card in the conversation, to open or save.`
882
+ } catch (error) { return fail(error) }
883
+ }
884
+
885
+ if (name === 'task') return runHelper(args, { server, session, signal, report, hooks, model: hooks.model })
886
+ if (name === 'workflow') return runWorkflow(args, { server, session, signal, report, hooks, model: hooks.model })
887
+ if (name === 'send_message') return sendToHelper(args, { server, session, signal, report, hooks, model: hooks.model })
888
+ if (name === 'list_agents') { const text = listHelpers(session); report({ tool: name, args, status: 'done', result: text }); return text }
889
+ if (name === 'task_stop') {
890
+ const h = findHelper(session, args.id)
891
+ if (h) {
892
+ if (h.status !== 'running') return `${h.id} is not running (${h.status}).`
893
+ h.controller?.abort()
894
+ report({ tool: name, args, status: 'done', result: `${h.id} stopped` })
895
+ return `${h.id} stopped.`
896
+ }
897
+ try { const text = shell.killShell({ shell_id: args.id }); report({ tool: name, args, status: 'done', result: text }); return text } catch (error) { return fail(error) }
898
+ }
899
+
900
+ const isMcp = mcp.isMcp(name)
901
+ const isRun = RUNS.has(name)
902
+ // PowerShell is never taken as read-only: the read-only check knows bash, not PowerShell.
903
+ const readOnlyRun = isRun && name !== 'powershell' && shell.isReadOnly(args.command)
904
+ // Read-only, but of somewhere outside the workspace (and the added folders): asked first.
905
+ const readsOutside = readOnlyRun && shell.readsOutside(args.command)
906
+ if (planning && (WRITES.has(name) || (isRun && !readOnlyRun) || (isMcp && !mcp.readOnly(name)))) {
907
+ report({ tool: name, args, status: 'plan' })
908
+ return isRun ? 'Plan mode: only read-only commands (ls, cat, git status/log/diff, grep…) run here. '
909
+ + 'Describe this one in your plan instead.' : 'Plan mode: this change was not applied. Describe it in your plan instead.'
910
+ }
911
+
912
+ // The browser in Plan mode looks — opens and reads pages — but does not act on them.
913
+ if (name === 'browser' && planning && !['navigate', 'back', 'close', ...browserTool.LOOKS].includes(args.action)) {
914
+ report({ tool: name, args, status: 'plan' })
915
+ return 'Plan mode: the browser only opens and reads pages here. Describe this step in your plan instead.'
916
+ }
917
+
918
+ let preview
919
+ let approval = { ok: true }
920
+ // The browser acts on a site once the user has said yes to it (localhost needs no yes); a
921
+ // password, card or similar field is asked about every time, whatever the mode.
922
+ const site = name === 'browser' ? browserTool.hostOf(args.page || '') : ''
923
+ const browserAsks = name === 'browser' && browserTool.ACTS.has(args.action) && args.action !== 'close'
924
+ && !browserTool.isLocal(args.page || '') && !session.allowSites?.includes(site)
925
+ const tender = name === 'browser' && browserTool.sensitive(args)
926
+ if (tender) Object.assign(rule, { decision: 'ask', rule: null, hook: null, why: `the browser always asks: ${tender}` })
927
+ // A PreToolUse hook's say: allow skips the question (a deny rule has already stopped the
928
+ // call), ask asks whatever the mode.
929
+ if (hooks.hookPermission === 'allow' && rule.decision !== 'ask') rule.decision = 'allow'
930
+ // The sandbox: a command kept in it (no network) needs no question; one leaving it is asked.
931
+ const box = isRun ? sandboxFor(args, folderPath()) : null
932
+ if (box?.run && box.s.autoAllow && !box.s.allowNetwork && rule.decision !== 'ask' && hooks.hookPermission !== 'ask') rule.decision = 'allow'
933
+ if (box?.asked || box?.unavailable) {
934
+ Object.assign(rule, { decision: 'ask', rule: null, hook: null,
935
+ sandbox: box.asked ? 'the agent asks to run this outside the sandbox' : 'the sandbox is on, but this machine has none (sandbox-exec / bubblewrap)' })
936
+ }
937
+ if (hooks.hookPermission === 'ask') Object.assign(rule, { decision: 'ask', rule: null, hook: hooks.hookReason || 'a hook' })
938
+ const standing = session.allowTools?.includes(name)
939
+ const needsYes = rule.decision === 'ask'
940
+ || (rule.decision !== 'allow' && !standing && (WRITES.has(name) || (isRun && (!readOnlyRun || readsOutside)) || (isMcp && !mcp.readOnly(name)) || browserAsks))
941
+ if (needsYes) {
942
+ if (WRITES.has(name)) { try { preview = await previewChange(call, session) } catch (error) { return fail(error) } }
943
+ // Say in the panel that a question is waiting: a silent pause reads as a hang.
944
+ report({ tool: name, args, status: 'asking' })
945
+ approval = await approve({ ...call, function: { ...call.function, arguments: args } }, session, signal, hooks, preview,
946
+ rule.decision === 'ask' ? { force: true, server, because: rule.why || rule.sandbox || (rule.hook ? `asked by a hook: ${rule.hook}` : `asked by the rule ${rule.rule}`) } : { server })
947
+ if (!approval.ok) {
948
+ report({ tool: name, args, status: 'refused', result: approval.feedback })
949
+ return approval.feedback
950
+ ? `The user declined this and said: ${approval.feedback}`
951
+ : 'The user declined this. Do not try it again in another way; ask what they want, or carry on without it.'
952
+ }
953
+ }
954
+ report({ tool: name, args, status: 'running' })
955
+ let before
956
+ if (WRITES.has(name)) {
957
+ try { before = files.parseText(decoder.decode(await vscode.workspace.fs.readFile(resolve(args.path)))).text } catch { before = null }
958
+ hooks.onFileChange?.(args.path, before) // for checkpoints: what to restore
959
+ }
960
+ try {
961
+ let result
962
+ if (name === 'monitor') {
963
+ const task = shell.startMonitor(String(args.command || ''), { session, sandbox: box?.run, description: String(args.description || ''),
964
+ limitMs: Math.max(1, Math.min(120, Number(args.timeout_minutes) || 30)) * 60_000,
965
+ onLines: (t, lines, info) => hooks.onMonitor?.(t, lines, info) })
966
+ hooks.onTasks?.(shell.taskList())
967
+ result = `Watching as ${task.id}: each new output line will reach you as an event — between your steps, or as a new turn `
968
+ + `once you have finished. Stop it with kill_shell (shell_id "${task.id}").`
969
+ } else if (isRun) {
970
+ result = await runCommand(String(args.command || ''), { signal, session, timeout: args.timeout, sandbox: name === 'powershell' ? null : box?.run, powershell: name === 'powershell',
971
+ background: !!args.run_in_background, onTasks: () => hooks.onTasks?.(shell.taskList()) })
972
+ } else if (isMcp) {
973
+ result = await mcp.call(name, args)
974
+ } else if (approval.content !== undefined && WRITES.has(name)) {
975
+ // Only part of the change was kept, or the user edited it: write what they approved.
976
+ result = await files.writeFile({ path: args.path, content: approval.content }, { ...ctx, session: null })
977
+ files.remember(session, resolve(args.path), await files.stat(resolve(args.path)))
978
+ result = `${approval.edited ? 'The user edited your change before accepting it' : 'The user accepted only part of your change'}; `
979
+ + `${args.path} now holds what they approved — read it before changing it further.`
980
+ } else if (IMPLEMENTATIONS[name]) {
981
+ result = await IMPLEMENTATIONS[name](args, ctx)
982
+ } else {
983
+ throw new Error(`there is no tool called ${name}`)
984
+ }
985
+ // A web tool hands back what the model reads and, beside it, what the panel shows.
986
+ let web
987
+ let images
988
+ let text = result
989
+ if (result && typeof result === 'object' && 'text' in result) ({ text, web, images } = result)
990
+ let diff
991
+ if (WRITES.has(name)) {
992
+ try {
993
+ const uri = resolve(args.path)
994
+ const after = files.parseText(decoder.decode(await vscode.workspace.fs.readFile(uri))).text
995
+ diff = summarize(before, after)
996
+ diff.key = rememberBefore(args.path, before ?? '')
997
+ if (!/\.ipynb$/.test(args.path)) text += await files.diagnosticsAfter(uri)
998
+ } catch { /* the file could not be read back; the row still says it was written */ }
999
+ }
1000
+ report({ tool: name, args, status: 'done', result: text, diff, web, tasks: isRun ? shell.taskList() : undefined })
1001
+ return images?.length ? { text, images } : text
1002
+ } catch (error) {
1003
+ // A failed tool is information for the model, not the end of the conversation.
1004
+ return fail(error)
1005
+ }
1006
+ }
1007
+
1008
+ // ---- helper agents (the task tool) ----
1009
+ // A piece of work handed to a fresh agent, as Claude Code's subagents are: its own
1010
+ // context, so the files it reads and the searches it runs never fill the main one; only
1011
+ // its final report comes back. "explore" only looks; "general" may change things, asking
1012
+ // as the main agent would. The panel shows one row for it, with its latest activity.
1013
+ const HELPER_STEPS = 40
1014
+
1015
+ const HELPER_SYSTEM = (type) => `You are a helper agent working for AIPictury's coding agent, inside the
1016
+ user's workspace. You were given one task; do it with the tools and then reply with your
1017
+ final report — that report is all the main agent will see, so make it complete and
1018
+ specific: file paths with line numbers, the facts found, what you changed. Be efficient:
1019
+ search (grep, glob, code_intel) before reading whole files, and read only what you need.
1020
+ ${type === 'explore' ? 'You are read-only: find and explain, do not change anything.' : 'You may change files and run commands when the task needs it.'}`
1021
+
1022
+ // Claude Code's tool names, in an agent's "tools:", as ours.
1023
+ const AGENT_TOOLS = { Read: ['read_file', 'list_files'], LS: ['list_files'], Grep: ['grep'], Glob: ['glob'], Bash: ['run_command', 'bash_output', 'kill_shell'],
1024
+ Edit: ['edit_file'], MultiEdit: ['edit_file'], Write: ['write_file'], NotebookEdit: ['notebook_edit'], WebFetch: ['fetch_url'],
1025
+ WebSearch: ['web_search'], TodoWrite: [] }
1026
+
1027
+ /** The helpers of a conversation: id or name → their record (messages, state, status). */
1028
+ function helpersOf(session) { return (session.helpers ||= new Map()) }
1029
+
1030
+ /** A helper by its id or its name. */
1031
+ function findHelper(session, key) {
1032
+ const all = helpersOf(session)
1033
+ return all.get(String(key)) || [...all.values()].find((h) => h.name && h.name === String(key))
1034
+ }
1035
+
1036
+ async function runHelper(args, { server, session, signal, report, hooks, model }) {
1037
+ // A helper with a role (an agent file: .aipictury/agents, .claude/agents, a plugin's agents/):
1038
+ // its instructions, and only the tools it lists.
1039
+ const role = !['explore', 'general', undefined, ''].includes(args.subagent_type)
1040
+ ? context.agents().find((a) => a.name === args.subagent_type) : null
1041
+ if (!role && !['explore', 'general', undefined, ''].includes(args.subagent_type)) {
1042
+ return `error: no helper called ${args.subagent_type} — use "explore", "general" or one of: ${context.agents().map((a) => a.name).join(', ') || '(none)'}`
1043
+ }
1044
+ const name = String(args.name || '').trim().replace(/[^\w.-]+/g, '-').slice(0, 40) || null
1045
+ if (name && findHelper(session, name)) return `error: there is already a helper called ${name} — send_message to it, or choose another name`
1046
+ const allowedTools = role?.tools.length ? new Set(role.tools.flatMap((t) => AGENT_TOOLS[t] || [t])) : null
1047
+ const writes = !allowedTools || [...allowedTools].some((t) => WRITES.has(t) || t === 'run_command')
1048
+ const type = role ? (writes ? 'general' : 'explore') : args.subagent_type === 'general' ? 'general' : 'explore'
1049
+ const sub = { allowAll: session.allowAll, allowCommands: session.allowCommands, allowSites: session.allowSites, seen: new Map(), cwd: session.cwd, worktree: session.worktree }
1050
+ // isolation: "worktree" — the helper works in a checkout of its own.
1051
+ let ownTree = null
1052
+ if (args.isolation === 'worktree') {
1053
+ try { ownTree = await worktrees.enter(folderPath(), `helper-${Date.now().toString(36)}`) } catch (error) { return `error: no worktree for the helper: ${error.message}` }
1054
+ sub.worktree = ownTree
1055
+ sub.cwd = ownTree.path
1056
+ }
1057
+ const readOnly = type === 'explore' || modeNow() === 'plan'
1058
+ const tools = toolSchema({ readOnly, helper: true, extra: [...mcp.schema({ readOnly, loaded: (session.loadedMcp ||= new Set()) }), ...mcp.resourceSchema()] })
1059
+ .filter((t) => !allowedTools || allowedTools.has(t.function.name) || (t.function.name.startsWith('mcp__') && allowedTools.has(t.function.name)))
1060
+ const id = `agent_${helpersOf(session).size + 1}`
1061
+ const h = {
1062
+ id, name, type, role: role?.name || null, description: String(args.description || '').slice(0, 80), tools, readOnly, sub, ownTree,
1063
+ messages: [{ role: 'system', content: HELPER_SYSTEM(type) + (role ? `\n\n## Your role: ${role.name}\n${role.body}` : '') + context.instructionsBlock() },
1064
+ { role: 'user', content: String(args.prompt || args.description || '') }],
1065
+ state: { steps: 0, tokens: 0, last: '' }, status: 'running', started: Date.now(), inbox: [], background: !!args.run_in_background,
1066
+ }
1067
+ helpersOf(session).set(id, h)
1068
+ const run = () => helperLoop(h, { args, server, session, signal: h.background ? null : signal, report, hooks, model })
1069
+ if (!h.background) return run()
1070
+ // In the background: the main agent carries on; the report arrives as an event.
1071
+ run().then((text) => hooks?.onHelperDone?.(h, text)).catch(() => {})
1072
+ return `Started ${id}${name ? ` (${name})` : ''} in the background. Its report will reach you as an event when it is done; `
1073
+ + `send_message to it meanwhile, list_agents to see it, task_stop to stop it.`
1074
+ }
1075
+
1076
+ /** A helper's work, until its report — started, or resumed by send_message. */
1077
+ async function helperLoop(h, { args, server, session, signal, report, hooks, model }) {
1078
+ const { state, sub, tools, readOnly, type, ownTree } = h
1079
+ h.status = 'running'
1080
+ h.controller = new AbortController()
1081
+ const stopAll = () => h.controller.abort()
1082
+ signal?.addEventListener('abort', stopAll, { once: true })
1083
+ const halt = h.controller.signal
1084
+ const started = Date.now()
1085
+ const show = (status, extra = {}) => report({ tool: 'task', args: { ...args, subagent_type: h.role || type }, status,
1086
+ progress: { steps: state.steps, tokens: state.tokens, seconds: Math.round((Date.now() - h.started) / 1000), last: state.last }, ...extra })
1087
+ show('running')
1088
+ const { messages } = h
1089
+ const recent = []
1090
+ let known = null
1091
+ const holds = new Map()
1092
+ try {
1093
+ for (;;) {
1094
+ if (halt.aborted) throw Object.assign(new Error('stopped'), { name: 'AbortError' })
1095
+ // Messages sent to it meanwhile (send_message) join between its steps.
1096
+ if (h.inbox.length) messages.push(...h.inbox.splice(0).map((text) => ({ role: 'user', content: `[A message from the main agent:]\n${text}` })))
1097
+ const result = await steadyTurn(server, messages, { tools, model, signal: halt, think: false })
1098
+ state.tokens += (result.meta?.tokens || 0)
1099
+ const { text, calls } = result
1100
+ messages.push({ role: 'assistant', content: text, tool_calls: calls })
1101
+ if (!calls.length && state.steps < HELPER_STEPS && (state.stopBlocks || 0) < 3) {
1102
+ const held = await userHooks.fire('SubagentStop', { stop_hook_active: (state.stopBlocks || 0) > 0 }, hookContext(session, halt, hooks || {}))
1103
+ if (held.block) {
1104
+ state.stopBlocks = (state.stopBlocks || 0) + 1
1105
+ messages.push({ role: 'user', content: `A SubagentStop hook says this is not finished yet: ${held.reasons.join('\n')}\nCarry on, then report.` })
1106
+ continue
1107
+ }
1108
+ }
1109
+ if (!calls.length && h.inbox.length) continue
1110
+ if (!calls.length || state.steps >= HELPER_STEPS) {
1111
+ const final = String(text || '').trim() || '(the helper finished without a report)'
1112
+ let where = ''
1113
+ if (ownTree) {
1114
+ const left = await worktrees.changes(ownTree)
1115
+ if (left.commits || left.dirty.length) where = `\n\nIts changes are in the worktree ${ownTree.relative} (branch ${ownTree.branch}): ${left.commits} commit(s), ${left.dirty.length} uncommitted change(s).`
1116
+ else if (!h.name) await worktrees.exit(folderPath(), ownTree, { action: 'remove' })
1117
+ }
1118
+ h.status = 'done'
1119
+ h.report = final
1120
+ show('done', { result: final })
1121
+ return `Report from ${h.id}${h.name ? ` (${h.name})` : ''} (${type}, ${state.steps} steps, ${Math.round((Date.now() - started) / 1000)}s):\n\n${final}${where}`
1122
+ }
1123
+ for (const call of calls) {
1124
+ state.steps += 1
1125
+ state.last = `${call.function.name} ${String(call.function.arguments?.path || call.function.arguments?.pattern
1126
+ || call.function.arguments?.command || call.function.arguments?.query || '').slice(0, 80)}`.trim()
1127
+ show('running')
1128
+ recent.push(signature(call))
1129
+ if (isSpinning(recent)) {
1130
+ messages.push({ role: 'tool', tool_name: call.function.name, content: 'You are repeating yourself. Write your final report now.' })
1131
+ break
1132
+ }
1133
+ // A helper cannot ask the user: a package they have not chosen goes back as a question.
1134
+ const brought = deps.brings(call.function.name, parsedArgs(call))
1135
+ if (brought.length && hooks?.userSaid) {
1136
+ known ??= await deps.declared()
1137
+ const open = deps.unchosen(brought, hooks.userSaid(), known, holds)
1138
+ if (open.length) {
1139
+ messages.push({ role: 'tool', tool_name: call.function.name, content: deps.holdNote(open, { helper: true }) })
1140
+ continue
1141
+ }
1142
+ }
1143
+ // Its steps are not drawn one by one: the row shows the latest, as Claude Code's does.
1144
+ const out = await execute(call, readOnly && type === 'general' ? { ...sub } : sub, () => {}, halt, server,
1145
+ { ...hooks, askUser: null, onTodos: null, approvePlan: null })
1146
+ const content = typeof out === 'object' ? out.text : out
1147
+ messages.push({ role: 'tool', tool_name: call.function.name, content: capResult(content) })
1148
+ }
1149
+ const fitted = compact(messages)
1150
+ if (fitted !== messages) messages.splice(0, messages.length, ...fitted)
1151
+ }
1152
+ } catch (error) {
1153
+ if (error.name === 'AbortError') { h.status = 'stopped'; show('refused'); return `${h.id} was stopped` }
1154
+ h.status = 'failed'
1155
+ show('failed', { result: error.message })
1156
+ return `error: the helper failed: ${error.message}`
1157
+ } finally {
1158
+ signal?.removeEventListener('abort', stopAll)
1159
+ }
1160
+ }
1161
+
1162
+ /** workflow: steps run as helpers in the order their dependencies give — one at a time (one GPU) —
1163
+ * each told what the steps it depends on found; one report of all of them. */
1164
+ async function runWorkflow(args, ctx) {
1165
+ let order
1166
+ try { order = workflows.plan(args.steps) } catch (error) { return `error: ${error.message}` }
1167
+ const results = new Map()
1168
+ const show = (status, extra = {}) => ctx.report({ tool: 'workflow', args: { goal: args.goal, steps: order.length }, status,
1169
+ progress: { done: results.size, total: order.length, current: extra.current }, ...extra })
1170
+ for (const s of order) {
1171
+ if (ctx.signal?.aborted) break
1172
+ show('running', { current: s.name })
1173
+ const out = await runHelper({ description: s.name, prompt: workflows.stepPrompt(s, args.goal, results), subagent_type: s.subagent_type || 'general' },
1174
+ { ...ctx, report: () => {} })
1175
+ results.set(s.name, String(out).replace(/^Report from [^\n]*\n\n/, ''))
1176
+ }
1177
+ show('done', { result: `${results.size}/${order.length} steps` })
1178
+ return workflows.combine(order, results, { goal: args.goal, stopped: !!ctx.signal?.aborted })
1179
+ }
1180
+
1181
+ /** send_message: a message to a named (or numbered) helper — between its steps while it works,
1182
+ * or it carries on from where it was when it has reported. */
1183
+ async function sendToHelper(args, ctx) {
1184
+ const h = findHelper(ctx.session, args.to)
1185
+ if (!h) return `error: no helper ${args.to} — list_agents shows them`
1186
+ const text = String(args.message || '').trim()
1187
+ if (!text) return 'error: an empty message'
1188
+ if (h.status === 'running') { h.inbox.push(text); return `Sent to ${h.id}: it reads it before its next step.` }
1189
+ h.inbox.push(text)
1190
+ const run = () => helperLoop(h, { args: { description: h.description, subagent_type: h.role || h.type }, ...ctx, signal: h.background ? null : ctx.signal })
1191
+ if (!h.background) return run()
1192
+ run().then((out) => ctx.hooks?.onHelperDone?.(h, out)).catch(() => {})
1193
+ return `Sent to ${h.id}: it carries on in the background; its report will reach you as an event.`
1194
+ }
1195
+
1196
+ function listHelpers(session) {
1197
+ const all = [...helpersOf(session).values()]
1198
+ if (!all.length) return 'No helpers in this conversation yet.'
1199
+ return all.map((h) => `${h.id}${h.name ? ` (${h.name})` : ''} — ${h.role || h.type}, ${h.status}${h.background ? ', in the background' : ''}`
1200
+ + ` · ${h.state.steps} steps, ${h.state.tokens} tokens, ${Math.round((Date.now() - h.started) / 1000)} s · ${h.description}`).join('\n')
1201
+ }
1202
+
1203
+ // A fenced block with a few lines in it: enough to be a change the model meant to make
1204
+ // rather than an inline example.
1205
+ const CODE_BLOCK = /```[\w-]*\n(?:[^\n]*\n){3,}?```/
1206
+
1207
+ // A request that asks for something to be made or changed, in English or Spanish.
1208
+ const ASKS_CHANGE = /\b(add|change|fix|implement|rename|refactor|update|write|create|remove|delete|replace|modify|edit|convert|move|make|build|set ?up|install|apply|insert|append|generate|go ahead|do it|agreg|añad|cambi|arregl|corrig|renombr|actualiz|escrib|cre[aá]|elimin|borr|reemplaz|modific|mov[eé]|hacé|hacelo|hazlo|haz |gener|instal|aplic|insert|dale)/i
1209
+
1210
+ /** A tool call's arguments as an object, whether they came parsed or as JSON text. */
1211
+ function parsedArgs(call) {
1212
+ const args = call.function.arguments || {}
1213
+ if (typeof args !== 'string') return args
1214
+ try { return JSON.parse(args) } catch { return {} }
1215
+ }
1216
+
1217
+ /** Whether the user's latest message asks for a change (not only to find or explain). */
1218
+ function askedForChange(messages, fromUser) {
1219
+ const last = [...messages].reverse().find((m) => fromUser.has(m))
1220
+ if (!last) return true // no record of what was asked: keep the safety net
1221
+ const text = typeof last.content === 'string' ? last.content
1222
+ : (last.content || []).map((p) => p.text || '').join(' ')
1223
+ return ASKS_CHANGE.test(text)
1224
+ }
1225
+
1226
+ /**
1227
+ * True when every fenced block in the text only quotes what the tools returned — code the
1228
+ * model read and is showing back, as in "where is route defined?", not a change it drafted.
1229
+ * Line numbers from read_file ("12\t…") are ignored, as is indentation.
1230
+ */
1231
+ function onlyQuotes(text, messages) {
1232
+ const blocks = [...String(text || '').matchAll(/```[\w-]*\n([\s\S]*?)```/g)].map((m) => m[1])
1233
+ if (!blocks.length) return false
1234
+ const seen = new Set()
1235
+ for (const m of messages) {
1236
+ if (m.role !== 'tool') continue
1237
+ for (const line of String(m.content || '').split('\n')) seen.add(line.replace(/^\s*\d+\t/, '').trim())
1238
+ }
1239
+ return blocks.every((block) => block.split('\n').map((l) => l.trim()).filter(Boolean).every((l) => seen.has(l)))
1240
+ }
1241
+
1242
+ // ---- keeping the conversation inside the model's window ----
1243
+ // The model sees a 32k-token window. The agent used to resend its whole history every
1244
+ // turn — every file read, every page fetched — so a long task eventually filled the
1245
+ // window and Ollama failed with a 500 (or spent minutes re-reading 32k tokens). This
1246
+ // keeps what is sent under budget the way Claude Code's auto-compact does: recent steps
1247
+ // stay whole, older tool output is replaced by a note saying it can be fetched again.
1248
+ // The budget is in tokens, and the character count that fits is *measured*: code and
1249
+ // repetitive text tokenize far denser than prose, so a fixed chars-per-token guess
1250
+ // either wastes the window or overflows it. Every turn Ollama reports how many tokens
1251
+ // the prompt really took; the ratio is learned from that, starting conservative.
1252
+ // Of the 128k window (config: agent_num_ctx). Compaction keeps what is sent under this;
1253
+ // the rest is headroom for the answer, the tools and the reasoning. Far larger than the
1254
+ // old 18k, so summaries are rare and, when they happen, the model has room to work.
1255
+ // 112k of the 131k window (measured: a larger window no longer fits the GPU and the 27B
1256
+ // model drops from 40 to 11 tok/s at 196k): the rest is room for the tools (~6k) and the answer.
1257
+ const TARGET_TOKENS = 112_000
1258
+ let charsPerToken = 2.5 // conservative start; replaced by measurement
1259
+ // The window the model in use is loaded with — 131k, or less for a model measured not to
1260
+ // fit the GPU at that (the server says which). A smaller window gets a smaller budget.
1261
+ const FULL_WINDOW = 131_072
1262
+ let windowTokens = FULL_WINDOW
1263
+ const targetTokens = () => (windowTokens >= FULL_WINDOW ? TARGET_TOKENS : Math.round(windowTokens * 0.75))
1264
+ const setWindow = (tokens) => { windowTokens = Number(tokens) > 0 ? Number(tokens) : FULL_WINDOW }
1265
+ const budget = () => Math.round(targetTokens() * charsPerToken)
1266
+ const TOOL_RESULT_CAP = 16_000 // one tool result may not take more than this
1267
+
1268
+ /** Roughly how many tokens these messages take, at the density measured so far. */
1269
+ function estimateTokens(messages) { return Math.round(size(messages) / charsPerToken) }
1270
+
1271
+ /** Learns the real density from what a turn cost. The prompt count also includes the
1272
+ * tool list and the chat template, so the ratio errs small — i.e. safe. */
1273
+ function calibrate(sentChars, promptTokens) {
1274
+ if (!promptTokens || promptTokens < 500 || !sentChars) return
1275
+ const measured = sentChars / promptTokens
1276
+ charsPerToken = Math.min(4, Math.max(1.5, measured))
1277
+ }
1278
+ const KEEP_RECENT = 6 // the newest messages are never shortened
1279
+
1280
+ /** Caps one tool result as it enters the conversation. */
1281
+ function capResult(text) {
1282
+ const t = String(text)
1283
+ if (t.length <= TOOL_RESULT_CAP) return t
1284
+ return `${safeSlice(t, 0, TOOL_RESULT_CAP)}\n… [${t.length - TOOL_RESULT_CAP} more characters not shown — `
1285
+ + 'read a narrower range, or search, if you need them]'
1286
+ }
1287
+
1288
+ // A picture costs the model roughly a thousand tokens; counted as characters here.
1289
+ const IMAGE_COST = 4_000
1290
+ const size = (messages) => messages.reduce((n, m) => n + String(m.content || '').length
1291
+ + (m.tool_calls ? JSON.stringify(m.tool_calls).length : 0)
1292
+ + (m.images ? m.images.length * IMAGE_COST : 0), 0)
1293
+
1294
+ /** What is actually sent: the same conversation, shortened from the oldest end until
1295
+ * it fits. The system prompt and the first question are always kept. */
1296
+ function compact(messages, limit = budget()) {
1297
+ if (size(messages) <= limit) return messages
1298
+ const out = messages.map((m) => ({ ...m }))
1299
+ const firstUser = out.findIndex((m) => m.role === 'user')
1300
+ const protectedIdx = new Set([0, firstUser])
1301
+ const middle = []
1302
+ for (let i = 1; i < out.length - KEEP_RECENT; i += 1) if (!protectedIdx.has(i)) middle.push(i)
1303
+
1304
+ // 1. Old tool output becomes a one-line note.
1305
+ for (const i of middle) {
1306
+ if (size(out) <= limit) return out
1307
+ if (out[i].role === 'tool' && String(out[i].content).length > 200) {
1308
+ out[i].content = `[earlier output of ${out[i].tool_name || 'a tool'} omitted to fit the `
1309
+ + 'context window — call it again if you still need it]'
1310
+ }
1311
+ }
1312
+ // 2. Old reasoning-out-loud from the assistant is shortened.
1313
+ for (const i of middle) {
1314
+ if (size(out) <= limit) return out
1315
+ if (out[i].role === 'assistant' && String(out[i].content || '').length > 400) {
1316
+ out[i].content = `${safeSlice(out[i].content, 0, 400)} …`
1317
+ }
1318
+ }
1319
+ // 3. Still too big: drop whole old steps (an assistant turn and its tool results
1320
+ // together, so every tool result still follows the call that asked for it).
1321
+ let dropped = 0
1322
+ while (size(out) > limit) {
1323
+ const at = out.findIndex((m, i) => i > firstUser && i < out.length - KEEP_RECENT
1324
+ && m.role === 'assistant')
1325
+ if (at === -1) break
1326
+ let end = at + 1
1327
+ while (end < out.length - KEEP_RECENT && out[end].role === 'tool') end += 1
1328
+ out.splice(at, end - at)
1329
+ dropped += 1
1330
+ }
1331
+ if (dropped) {
1332
+ out.splice(firstUser + 1, 0, { role: 'user',
1333
+ content: `[${dropped} earlier step(s) omitted to fit the context window.]` })
1334
+ }
1335
+ // 4. Last resort: the recent steps alone are too big (a few large files in a row).
1336
+ // Shorten their tool output too, oldest first, keeping the newest result whole.
1337
+ for (let i = firstUser + 1; i < out.length - 1 && size(out) > limit; i += 1) {
1338
+ if (out[i].role === 'tool' && String(out[i].content).length > 3000) {
1339
+ out[i].content = `${safeSlice(out[i].content, 0, 3000)}\n… [shortened to fit the `
1340
+ + 'context window — read it again if you need the rest]'
1341
+ }
1342
+ }
1343
+ return out
1344
+ }
1345
+
1346
+ // A line that reads as source code, for spotting a file written out in the reasoning.
1347
+ const CODE_LINE = /^\s*(def |class |import |from \S+ import|function |const |let |var |export |return |async |public |private |#include|<\/?[a-zA-Z][\w-]*[ >]|[}\])];?\s*$|.*[{(]\s*$|.*=>)/
1348
+
1349
+ /** True when the reasoning contains a real block of code rather than a passing mention. */
1350
+ function looksLikeCode(text) {
1351
+ return String(text || '').split('\n').filter((line) => CODE_LINE.test(line)).length >= 5
1352
+ }
1353
+
1354
+ // ---- summarizing the older part of a long conversation ----
1355
+ // Dropping old steps loses them — including an instruction the user gave halfway
1356
+ // through. When the conversation nears the window, the older part is summarized
1357
+ // instead: the user's goals and instructions, decisions, what was changed, what is left.
1358
+ // The summary replaces those messages in what is kept, and is itself folded into the
1359
+ // next summary, so a long session keeps its thread. Rules saved with `remember` sit in
1360
+ // the system prompt and never depend on this.
1361
+ const SUMMARY_AT = 0.9 // share of the budget that triggers a summary (~100k tokens)
1362
+ const SUMMARY_MARK = '[Summary of the earlier part of this conversation'
1363
+
1364
+ function asDialogue(messages) {
1365
+ return messages.map((m) => {
1366
+ if (m.role === 'tool') return `TOOL RESULT (${m.tool_name || 'tool'}): ${safeSlice(m.content || '', 0, 1500)}`
1367
+ if (m.role === 'assistant') {
1368
+ const calls = (m.tool_calls || []).map((c) => `${c.function.name}(${safeSlice(JSON.stringify(c.function.arguments || {}), 0, 300)})`)
1369
+ return `ASSISTANT: ${safeSlice(m.content || '', 0, 3000)}${calls.length ? `\n called: ${calls.join('; ')}` : ''}`
1370
+ }
1371
+ return `USER: ${safeSlice(m.content || '', 0, 6000)}`
1372
+ }).join('\n\n')
1373
+ }
1374
+
1375
+ /** Replaces the older part of `messages` with a summary written by the model. Returns
1376
+ * the new list, or null when there is too little to summarize or the model failed. */
1377
+ const READ_TPS = 1000 // prompt tokens/s this GPU reads uncached (measured)
1378
+
1379
+ /** How far a summary has got, as a percentage the panel can show. Reading the
1380
+ * conversation is the first 40% — timed against how long a prompt that size takes to
1381
+ * read — and writing the summary the rest, by its length against the length expected.
1382
+ * It stops at 95%: only the end says 100. */
1383
+ function summaryProgress(dialogueChars, onProgress) {
1384
+ const started = Date.now()
1385
+ const readFor = Math.max(3, dialogueChars / charsPerToken / READ_TPS)
1386
+ const expected = Math.min(5000, Math.max(1500, dialogueChars * 0.08))
1387
+ let written = 0
1388
+ let reading = true
1389
+ const tick = () => {
1390
+ const seconds = (Date.now() - started) / 1000
1391
+ const percent = reading
1392
+ ? Math.min(40, (seconds / readFor) * 40)
1393
+ : 40 + Math.min(55, (written / expected) * 55)
1394
+ // `stage`, not `phase`: the caller sends this inside a message whose `phase` says what
1395
+ // kind of message it is — the same name overwrote it, and every tick read as the end.
1396
+ onProgress?.({ percent: Math.round(percent), stage: reading ? 'reading' : 'writing', seconds: Math.round(seconds) })
1397
+ }
1398
+ const timer = setInterval(tick, 500)
1399
+ tick()
1400
+ return {
1401
+ wrote: (chars) => { reading = false; written += chars },
1402
+ stop: () => clearInterval(timer),
1403
+ }
1404
+ }
1405
+
1406
+ /** What a summary would cover — the messages between the first question and the recent
1407
+ * ones kept whole — or null when there is nothing worth summarizing. One plan for both
1408
+ * deciding to summarize and summarizing: they disagreed, and a run announced a summary on
1409
+ * every step that then had nothing to do (the conversation was large, but in its recent
1410
+ * messages, which are never summarized). */
1411
+ function summaryPlan(messages, force = false) {
1412
+ if (!force && size(messages) < budget() * SUMMARY_AT) return null
1413
+ const firstUser = messages.findIndex((m) => m.role === 'user')
1414
+ let end = messages.length - KEEP_RECENT
1415
+ // Never split an assistant's tool calls from their results.
1416
+ while (end > firstUser + 1 && messages[end] && messages[end].role === 'tool') end -= 1
1417
+ const older = messages.slice(firstUser + 1, end)
1418
+ if (older.length < (force ? 1 : 4)) return null
1419
+ return { firstUser, end, older }
1420
+ }
1421
+
1422
+ async function summarizeEarlier(server, messages, { model, signal, force = false, onProgress, focus = '' } = {}) {
1423
+ const plan = summaryPlan(messages, force)
1424
+ if (!plan) return null
1425
+ const { firstUser, end, older } = plan
1426
+ const dialogue = safeSlice(asDialogue(older), -Math.round(budget() * 0.9))
1427
+ const progress = summaryProgress(dialogue.length, onProgress)
1428
+ try {
1429
+ const { text } = await turn(server, [
1430
+ { role: 'system', content: 'You write the working notes that let a coding agent carry on '
1431
+ + 'a conversation after its early part is removed from its memory.' },
1432
+ { role: 'user', content: 'Summarize the conversation below so it can be continued without '
1433
+ + 'it. Keep, precisely: the user\'s goals and every instruction, preference or rule they '
1434
+ + 'gave; decisions made and why; files created or changed and what they now contain, in '
1435
+ + 'outline; commands run and how they ended; problems still open and what is left to do. '
1436
+ + 'Be concise, but never drop an instruction. Write in the language the user writes in.'
1437
+ + (focus ? ` Pay particular attention to: ${focus}.` : '')
1438
+ + `\n\n<conversation>\n${dialogue}\n</conversation>` },
1439
+ ], { tools: [], think: false, model, signal, onDelta: (d) => progress.wrote(d.length) })
1440
+ if (!text.trim()) return null
1441
+ return [
1442
+ ...messages.slice(0, firstUser + 1),
1443
+ { role: 'user', content: `${SUMMARY_MARK} — written to keep the context window in bounds]\n${text.trim()}` },
1444
+ ...messages.slice(end),
1445
+ ]
1446
+ } catch (error) {
1447
+ if (error.name === 'AbortError') throw error
1448
+ return null // mechanical compaction still keeps the turn inside the window
1449
+ } finally {
1450
+ progress.stop()
1451
+ }
1452
+ }
1453
+
1454
+ // ---- a budget for the reasoning ----
1455
+ // Measured on a real session: asked to build a frontend, the model spent 4½ minutes and
1456
+ // 9,316 tokens writing every component out in its reasoning, with no tool call — and it
1457
+ // could never have finished, since writing it all again in the tool calls would have
1458
+ // overflowed the window. Reasoning is kept (it is useful to watch), but once it has
1459
+ // turned into a long draft of code with nothing applied, that generation is stopped and
1460
+ // the model is told to write the files, with reasoning off for that one step.
1461
+ const THINK_BUDGET = 6_000 // characters of reasoning (~1.5k tokens) before a code draft is cut
1462
+
1463
+ /** True when the reasoning has become a draft of the code rather than a plan. */
1464
+ function overthinking(thinking) {
1465
+ return thinking.length > THINK_BUDGET && (CODE_BLOCK.test(thinking) || looksLikeCode(thinking))
1466
+ }
1467
+
1468
+ // ---- reasoning that goes round in circles ----
1469
+ // A model that loses its thread starts restating what it already thought — the same
1470
+ // paragraphs, back and forth, for minutes. Measured by word 8-grams: in ordinary
1471
+ // reasoning almost every 8-word run is new, while in a loop most of the latest
1472
+ // stretch has already been said. When it has, the reasoning is stopped and the model
1473
+ // is handed back what it had worked out, with the repeats removed, and told to act.
1474
+ const GRAM = 8
1475
+ const LOOP_TAIL = 240 // words: the latest stretch of reasoning that is judged
1476
+ const LOOP_SHARE = 0.6 // share of that stretch already said before = a loop
1477
+ const LOOP_MIN_WORDS = 500 // too short to tell before this
1478
+
1479
+ const words = (text) => String(text || '').toLowerCase().match(/[\p{L}\p{N}_]+/gu) || []
1480
+
1481
+ /** True when most of the latest reasoning repeats what was already reasoned. */
1482
+ function thinkingLoops(thinking) {
1483
+ const w = words(thinking)
1484
+ if (w.length < LOOP_MIN_WORDS) return false
1485
+ const seen = new Set()
1486
+ const tailStart = w.length - LOOP_TAIL
1487
+ let judged = 0
1488
+ let repeated = 0
1489
+ for (let i = 0; i + GRAM <= w.length; i += 1) {
1490
+ const gram = w.slice(i, i + GRAM).join(' ')
1491
+ if (i >= tailStart) {
1492
+ judged += 1
1493
+ if (seen.has(gram)) repeated += 1
1494
+ }
1495
+ seen.add(gram)
1496
+ }
1497
+ return judged > 0 && repeated / judged >= LOOP_SHARE
1498
+ }
1499
+
1500
+ /** The reasoning with its repeats removed: each sentence kept once, in order. */
1501
+ function distill(thinking, limit = 6_000) {
1502
+ const seen = new Set()
1503
+ const lines = []
1504
+ // Line by line, sentence by sentence, so lists and steps keep their shape.
1505
+ for (const line of String(thinking || '').split('\n')) {
1506
+ const kept = []
1507
+ for (const sentence of line.split(/(?<=[.!?])\s+/)) {
1508
+ const w = words(sentence)
1509
+ if (!w.length) continue
1510
+ const grams = []
1511
+ for (let i = 0; i + GRAM <= w.length; i += 1) grams.push(w.slice(i, i + GRAM).join(' '))
1512
+ const keys = grams.length ? grams : [w.join(' ')]
1513
+ const old = keys.filter((g) => seen.has(g)).length
1514
+ keys.forEach((g) => seen.add(g))
1515
+ if (old / keys.length < 0.5) kept.push(sentence.trim()) // not said already
1516
+ }
1517
+ if (kept.length) lines.push(kept.join(' '))
1518
+ }
1519
+ let notes = lines.join('\n')
1520
+ // The latest conclusions matter most: when it is long, keep the start and the end.
1521
+ if (notes.length > limit) {
1522
+ notes = `${safeSlice(notes, 0, limit * 0.3)} […] ${safeSlice(notes, -limit * 0.7)}`
1523
+ }
1524
+ return notes
1525
+ }
1526
+
1527
+ // ---- a question on the side (/btw) ----
1528
+ // Asked about the conversation without adding to it, as Claude Code's /btw. The prompt is
1529
+ // the conversation exactly as the next turn would send it — the same system prompt, the
1530
+ // same tools — with the question after it, so the model's prompt cache serves all of it
1531
+ // and the conversation's next turn still finds its own prefix cached.
1532
+ /** The tools of one step — the same for the run and for /btw, so the prompt cache serves both.
1533
+ * MCP tools past a score of them are given whole only once loaded by tool_search. */
1534
+ function stepTools(planning, session = {}) {
1535
+ const all = toolSchema({ readOnly: planning, autoMemory: vscode.workspace.getConfiguration('aipictury').get('autoMemory', true),
1536
+ extra: [...mcp.schema({ readOnly: planning, loaded: (session.loadedMcp ||= new Set()) }), ...mcp.resourceSchema()] })
1537
+ // A tool denied outright for this run (--disallowedTools "Bash") is not offered at all.
1538
+ const off = new Set((session.commandDeny || []).flatMap((rule) => permissions.toolsNamed(rule)))
1539
+ return off.size ? all.filter((t) => !off.has(t.function?.name)) : all
1540
+ }
1541
+
1542
+ async function sideQuestion(server, history, question, { model, signal, onDelta, session, thread = [], effort } = {}) {
1543
+ const mode = modeNow()
1544
+ const rules = await memory.block(server)
1545
+ // The earlier side questions of the thread come after the conversation, so its prefix stays
1546
+ // cached, and a follow-up ("and what about…?") knows what it follows.
1547
+ const before = thread.flatMap((x) => [{ role: 'user', content: `[A side question:] ${x.question}` }, { role: 'assistant', content: x.answer }])
1548
+ const messages = [{ role: 'system', content: systemPrompt(mode, rules, effortOf(effort || vscode.workspace.getConfiguration('aipictury').get('effort', 'high'))) }, ...history, ...before,
1549
+ { role: 'user', content: '[A side question — answer it briefly from what you already know of this '
1550
+ + 'conversation and the code you have seen. Do not call any tool, and do not continue the task.]\n'
1551
+ + question }]
1552
+ const tools = stepTools(mode === 'plan', session)
1553
+ const { text } = await steadyTurn(server, messages, { tools, model, signal, onDelta, think: false })
1554
+ return String(text || '').trim() || '(no answer — ask again, or ask the agent directly)'
1555
+ }
1556
+
1557
+ // ---- getting through a lost connection ----
1558
+ // The server sits on Wi-Fi and shares its GPU with renders. A blip, a restart or a
1559
+ // render in progress must not end the task: the run waits and asks again from exactly
1560
+ // where it was — the conversation so far, tool results included, is all still here.
1561
+ const RETRY_WAITS = [5, 10, 20, 40, 60, 60, 120, 120] // seconds, then it gives up
1562
+ const BUSY_WAIT = 20 // seconds between GPU checks
1563
+ const BUSY_LIMIT = 90 // 30 minutes of a render
1564
+
1565
+ /** Waits `seconds` unless Stop is pressed first. */
1566
+ function pause(seconds, signal) {
1567
+ return new Promise((resolve, reject) => {
1568
+ const timer = setTimeout(done, seconds * 1000)
1569
+ function done() { signal?.removeEventListener('abort', onStop); resolve() }
1570
+ function onStop() {
1571
+ clearTimeout(timer)
1572
+ const stop = new Error('stopped'); stop.name = 'AbortError'; reject(stop)
1573
+ }
1574
+ if (signal?.aborted) return onStop()
1575
+ signal?.addEventListener('abort', onStop, { once: true })
1576
+ })
1577
+ }
1578
+
1579
+ /** One turn, asked again through transient failures. Non-transient errors pass through. */
1580
+ async function steadyTurn(server, messages, options, onRetry) {
1581
+ let failures = 0
1582
+ let busy = 0
1583
+ for (;;) {
1584
+ try {
1585
+ return await turn(server, messages, options)
1586
+ } catch (error) {
1587
+ if (error.name === 'AbortError' || !error.transient) throw error
1588
+ let wait
1589
+ if (error.busy) {
1590
+ busy += 1
1591
+ if (busy > BUSY_LIMIT) throw error
1592
+ wait = BUSY_WAIT
1593
+ } else {
1594
+ if (failures >= RETRY_WAITS.length) throw error
1595
+ wait = RETRY_WAITS[failures]
1596
+ failures += 1
1597
+ }
1598
+ onRetry?.({ error: error.message, wait, attempt: error.busy ? busy : failures,
1599
+ of: error.busy ? BUSY_LIMIT : RETRY_WAITS.length, busy: !!error.busy })
1600
+ await pause(wait, options.signal)
1601
+ }
1602
+ }
1603
+ }
1604
+
1605
+ // How Auto mode stops: not by capping work, but by noticing when the agent is spinning
1606
+ // — repeating the same call, or a short cycle of calls, without getting anywhere. A
1607
+ // weaker local model loops where a stronger one would finish; this catches that.
1608
+ const REPEAT_LIMIT = 3 // the same exact call this many times = stuck
1609
+ const WINDOW = 8 // over the last this-many calls
1610
+
1611
+ /** A stable signature for a call, so "the same call" is recognised regardless of key order. */
1612
+ function signature(call) {
1613
+ const args = call.function.arguments || {}
1614
+ const keys = Object.keys(args).sort()
1615
+ return `${call.function.name}(${keys.map((k) => `${k}=${JSON.stringify(args[k])}`).join(',')})`
1616
+ }
1617
+
1618
+ /** True when the recent calls show the agent is stuck rather than making progress. */
1619
+ function isSpinning(recent) {
1620
+ const window = recent.slice(-WINDOW)
1621
+ // The same call, over and over.
1622
+ for (const sig of new Set(window)) {
1623
+ if (window.filter((s) => s === sig).length >= REPEAT_LIMIT) return true
1624
+ }
1625
+ // A short cycle repeated: A,B,A,B,A,B or A,B,C,A,B,C.
1626
+ for (const period of [2, 3]) {
1627
+ if (window.length >= period * 3) {
1628
+ const tail = window.slice(-period * 3)
1629
+ const unit = tail.slice(0, period).join('|')
1630
+ if ([1, 2].every((n) => tail.slice(period * n, period * (n + 1)).join('|') === unit)) {
1631
+ return true
1632
+ }
1633
+ }
1634
+ }
1635
+ return false
1636
+ }
1637
+
1638
+ /** Runs the whole exchange: turns and tool calls until the model has nothing left. */
1639
+ async function run(options) {
1640
+ // Whatever happens, the work done so far comes back. A thrown error used to discard
1641
+ // every tool call and result of the run, leaving only the user's message — so
1642
+ // "continue" started the task over from nothing. Now the caller always receives the
1643
+ // conversation as far as it got, and the error beside it.
1644
+ const messages = []
1645
+ // One run at a time on the GPU (gpuRun.js): another app holding it, nothing is started.
1646
+ const last = [...(options.history || [])].reverse().find((m) => m.role === 'user')
1647
+ const label = String(last?.content || '').split('\n')[0]
1648
+ try {
1649
+ return await require('./gpuRun').holding({ server: options.server, client: vscode.cli ? 'cli' : 'vscode', label },
1650
+ () => work(options, messages))
1651
+ } catch (error) {
1652
+ const kept = messages.slice(1)
1653
+ if (error.name === 'AbortError') return { messages: kept, stopped: true }
1654
+ if (error.busy) return { messages: kept, error: error.message, busy: true, gpu: error.gpu }
1655
+ return { messages: kept, error: error.message }
1656
+ }
1657
+ }
1658
+
1659
+ // How hard the model reasons, as Claude Code's effort levels: low answers without
1660
+ // reasoning (fastest), medium and high cap the reasoning (about 1.5k and 6k tokens), max
1661
+ // lets it reason as long as it needs — a loop or a code draft is still cut.
1662
+ const EFFORT = {
1663
+ low: { think: false, budget: 0 },
1664
+ medium: { think: true, budget: 6_000 },
1665
+ high: { think: true, budget: 24_000 },
1666
+ max: { think: true, budget: 0 },
1667
+ // Ultracode: as max, and large tasks are planned as workflows of helpers.
1668
+ ultra: { think: true, budget: 0 },
1669
+ }
1670
+ const effortOf = (value) => (EFFORT[value] ? value : 'high')
1671
+
1672
+ const TODO_NOTE = `
1673
+
1674
+ Use todo_write to plan and track a task of three or more steps: the user sees the list.`
1675
+
1676
+ /** The system prompt for this step: the mode may change mid-run (a plan approved, the
1677
+ * user switching), and the project's instruction files are read fresh. */
1678
+ const ULTRA_NOTE = `
1679
+
1680
+ ULTRACODE: for a substantial task — several parts, several files, research then changes — plan it as a
1681
+ workflow: the workflow tool runs its steps as helpers, in order, each given what the earlier ones found,
1682
+ and returns one report. Then check the result yourself before you finish.`
1683
+
1684
+ // Text added after the system prompt for every run (the CLI's --append-system-prompt).
1685
+ let appended = ''
1686
+ function appendSystem(text) { appended = String(text || '').trim() }
1687
+
1688
+ function systemPrompt(mode, rules, effort) {
1689
+ const dirs = require('./paths').extraRoots()
1690
+ const added = dirs.length ? `\n\nBesides the workspace, you may also work in these folders (name their files by absolute path): ${dirs.join(', ')}.` : ''
1691
+ return SYSTEM + TODO_NOTE + (effort === 'ultra' ? ULTRA_NOTE : '') + (mode === 'plan' ? PLAN_NOTE : unattended(mode) ? AUTO_NOTE : '')
1692
+ + context.instructionsBlock() + rules + context.styleBlock() + added + (appended ? `\n\n${appended}` : '')
1693
+ }
1694
+
1695
+ async function work({ server, model, history, session, signal,
1696
+ onDelta, onThinking, onTool, onTurn, onNudge, onWaiting, onSpeed, takeQueued, onSteerable,
1697
+ askUser: ask, onDecision, approve: approveHook, approvePlan, onTodos, onTasks, onFileChange,
1698
+ readSpecial, effort: chosenEffort, onMessages, window: modelWindow, trustHooks, trustMcp, onMonitor, schedule, onHelperDone }, messages) {
1699
+ setWindow(modelWindow)
1700
+ // 0 (the default) means no limit: the agent runs until it is done or is stopped.
1701
+ const settings = () => vscode.workspace.getConfiguration('aipictury')
1702
+ const configured = settings().get('maxSteps', 0)
1703
+ const maxSteps = configured > 0 ? configured : Infinity
1704
+ // The rules are read once per request; a rule saved with remember is added as it is saved.
1705
+ const rules = await memory.block(server)
1706
+ await mcp.ensure({ trust: trustMcp }).catch(() => [])
1707
+ messages.push({ role: 'system', content: systemPrompt(modeNow(), rules, effortOf(chosenEffort || settings().get('effort', 'high'))) }, ...history)
1708
+ // What the user wrote, as opposed to the notes the loop adds in their voice.
1709
+ const fromUser = new Set(history.filter((m) => m.role === 'user'))
1710
+ let known = null // the dependencies the workspace declares, read when first needed
1711
+ const holds = new Map() // package → times a call bringing it in was held
1712
+ const hooks = { askUser: ask, onDecision, approve: approveHook, approvePlan, onTodos, onTasks, onFileChange, readSpecial, model, trustHooks, onMonitor, schedule, onHelperDone,
1713
+ // For helpers: what the user said, so a package is judged chosen by them, not by a task text.
1714
+ userSaid: () => deps.saidByUser(messages, fromUser) }
1715
+ let steps = 0
1716
+ let changed = false // has anything actually been written this time round?
1717
+ let nudged = false // the reminder below is given once, never in a loop
1718
+ const recent = [] // recent call signatures, to notice spinning
1719
+ let spinNudged = false
1720
+ let stopBlocks = 0 // times a Stop hook has kept the run going
1721
+
1722
+ let actNow = false // the previous turn drafted code in its reasoning: act this time
1723
+ let truncations = 0 // turns in a row cut off at the output limit
1724
+ let retrySummaryAt = 0 // after a summary failed: the size to reach before trying again
1725
+ for (;;) {
1726
+ if (signal?.aborted) return { messages: messages.slice(1), stopped: true }
1727
+ // Messages the user sent while this was running enter here, between steps — never
1728
+ // between a tool call and its result — and the next turn reasons about them.
1729
+ const queued = takeQueued?.() || []
1730
+ for (const m of queued) fromUser.add(m)
1731
+ messages.push(...queued)
1732
+ // A summary the run needs — only when there is something to summarize — shown as it
1733
+ // happens (the input held), then what it saved. One that failed is not tried again on
1734
+ // every step: only once the conversation has grown by a fifth since.
1735
+ if (summaryPlan(messages) && size(messages) > retrySummaryAt) {
1736
+ const before = estimateTokens(messages)
1737
+ const started = Date.now()
1738
+ onNudge?.('summarizing', { phase: 'start', tokens: before })
1739
+ await userHooks.fire('PreCompact', { trigger: 'auto', custom_instructions: '' }, hookContext(session, signal, hooks))
1740
+ const summarized = await summarizeEarlier(server, messages, { model, signal,
1741
+ onProgress: (p) => onNudge?.('summarizing', { phase: 'progress', ...p }) })
1742
+ if (summarized) {
1743
+ messages.splice(0, messages.length, ...summarized)
1744
+ retrySummaryAt = 0
1745
+ } else {
1746
+ retrySummaryAt = size(messages) * 1.2
1747
+ }
1748
+ onNudge?.('summarizing', { phase: 'done', ok: !!summarized, before,
1749
+ after: estimateTokens(messages), seconds: Math.round((Date.now() - started) / 1000) })
1750
+ }
1751
+ const mode = modeNow()
1752
+ const planning = mode === 'plan'
1753
+ const think = settings().get('showThinking', true)
1754
+ const effort = EFFORT[effortOf(chosenEffort || settings().get('effort', 'high'))]
1755
+ messages[0].content = systemPrompt(mode, rules, effortOf(chosenEffort || settings().get('effort', 'high')))
1756
+ const tools = stepTools(planning, session)
1757
+ const thinking = think && effort.think && !actNow
1758
+ // A fresh switch per turn: a message queued while the model generates cuts this
1759
+ // generation, so the correction is heard now rather than after it finishes.
1760
+ const steer = new AbortController()
1761
+ onSteerable?.(steer)
1762
+ const result = await steadyTurn(server, messages, { tools, model, signal, onDelta, onThinking,
1763
+ onWaiting, onSpeed, think: thinking, effort: effortOf(chosenEffort || settings().get('effort', 'high')),
1764
+ thinkBudget: thinking ? effort.budget : 0,
1765
+ watchReasoning: thinking && !planning, watchLoops: thinking, steer: steer.signal },
1766
+ (retry) => onNudge?.('retry', retry))
1767
+ onSteerable?.(null)
1768
+ actNow = false
1769
+ if (result.interrupted) {
1770
+ onNudge?.('steered')
1771
+ // What it had already said stays in the conversation; it continues from there.
1772
+ if (String(result.text || '').trim()) {
1773
+ messages.push({ role: 'assistant', content: `${result.text}\n[interrupted by the user's next message]` })
1774
+ }
1775
+ continue // the queued message is taken at the top of the loop
1776
+ }
1777
+ if (result.looped) {
1778
+ // Keep what it worked out, drop the circles, and have it act on it — with the
1779
+ // reasoning off for that one step, so it cannot start the same circle again.
1780
+ actNow = true
1781
+ onNudge?.(result.budget ? 'budget' : 'looped')
1782
+ const notes = distill(result.thinking)
1783
+ messages.push({ role: 'user', content:
1784
+ (result.budget
1785
+ ? 'Your reasoning reached the length the chosen effort allows, so it was stopped. '
1786
+ + 'This is what you had worked out:\n\n'
1787
+ : 'Your reasoning started repeating itself — the same points, round and round — so it '
1788
+ + 'was stopped. This is what you had worked out, with the repeats removed:\n\n')
1789
+ + `<your-reasoning>\n${notes}\n</your-reasoning>\n\n`
1790
+ + 'Do not work through it again. Decide from it and act now: make the next tool call, '
1791
+ + 'or give your answer if the task is done. Where you were torn between options, '
1792
+ + 'pick the simplest one that works.' })
1793
+ continue
1794
+ }
1795
+ if (result.overthought) {
1796
+ actNow = true
1797
+ onNudge?.('overthought')
1798
+ messages.push({ role: 'user', content:
1799
+ 'You have planned enough and started writing the code in your reasoning, where it '
1800
+ + 'changes nothing. Stop planning. Write the files now with write_file (or change '
1801
+ + 'existing ones with edit_file), one call per file, then continue until the task '
1802
+ + 'is done.' })
1803
+ continue
1804
+ }
1805
+ const { text, calls, meta } = result
1806
+ truncations = meta.truncated ? truncations + 1 : 0
1807
+ // Cut off three times running despite being told to write in parts: that is a loop
1808
+ // too, and each round costs minutes of GPU. Stop, keeping everything done.
1809
+ if (truncations >= 3) return { messages: messages.slice(1), meta, stuck: true }
1810
+ if (meta.truncated) {
1811
+ // It ran into the output cap mid-answer. A tool call that long is either a file
1812
+ // too big for one call or the model repeating itself inside one — either way,
1813
+ // writing it in parts is the way through. Nothing half-written is applied.
1814
+ onNudge?.('truncated')
1815
+ messages.push({ role: 'assistant', content: text || '(the answer was cut off at the output limit)' })
1816
+ messages.push({ role: 'user', content:
1817
+ 'Your last answer hit the output limit (about 32k tokens) and was cut off, so nothing '
1818
+ + 'in it was applied. If you were writing a large file, write it in parts: create it '
1819
+ + 'with the first part using write_file, then add each further part with edit_file '
1820
+ + '(quote the last lines as old_text and repeat them followed by the new part). Keep '
1821
+ + 'each call well under the limit. If you were repeating yourself, stop and take the '
1822
+ + 'next concrete step.' })
1823
+ onTurn?.({ text, thinking: result.thinking, meta, calls: [] })
1824
+ continue
1825
+ }
1826
+ messages.push({ role: 'assistant', content: text, tool_calls: calls })
1827
+ onTurn?.({ text, thinking: result.thinking, meta, calls })
1828
+ onMessages?.(messages.slice(1))
1829
+
1830
+ if (!calls.length) {
1831
+ // A model that has written out the code but called nothing has described the
1832
+ // change instead of making it. That is worth one reminder — from the code it
1833
+ // just produced, applying it is a small step, and the alternative is a user who
1834
+ // believes their file was edited when it was not.
1835
+ // With reasoning shown, a model sometimes writes the file out in its thinking and
1836
+ // stops there. A substantial answer means it was explaining, not editing, so the
1837
+ // unfenced-code check only applies when the answer itself is thin.
1838
+ const narrated = looksLikeCode(result.thinking) && String(text || '').trim().length < 200
1839
+ // Code that only quotes what the tools returned is an answer, not an unapplied change.
1840
+ const drafted = (CODE_BLOCK.test(text) && !onlyQuotes(text, messages))
1841
+ || (CODE_BLOCK.test(result.thinking || '') && !onlyQuotes(result.thinking, messages))
1842
+ || narrated
1843
+ // Only when the user asked for a change: to "find and tell me" the reminder would
1844
+ // push the agent into editing something nobody asked it to touch.
1845
+ if (!planning && !changed && !nudged && drafted && askedForChange(messages, fromUser)) {
1846
+ nudged = true
1847
+ onNudge?.()
1848
+ messages.push({ role: 'user', content:
1849
+ 'You wrote the code but did not apply it — showing it is not the same as '
1850
+ + 'changing the file. Make the change now with write_file or edit_file, then '
1851
+ + 'confirm in one line. If nothing actually needs changing, say why.' })
1852
+ continue
1853
+ }
1854
+ // About to finish, but the user has said more meanwhile: carry on with it.
1855
+ const late = takeQueued?.() || []
1856
+ if (late.length) { for (const m of late) fromUser.add(m); messages.push(...late); continue }
1857
+ // The user's Stop hooks may say the work is not finished (tests not run…): the agent
1858
+ // carries on with their reason — at most three times, so a hook cannot keep it going for ever.
1859
+ const stopHook = await userHooks.fire('Stop', { stop_hook_active: stopBlocks > 0 }, hookContext(session, signal, hooks))
1860
+ if (stopHook.block && stopBlocks < 3 && !signal?.aborted) {
1861
+ stopBlocks += 1
1862
+ const why = stopHook.reasons.join('\n')
1863
+ onNudge?.('hook', { text: `a Stop hook: ${why}` })
1864
+ messages.push({ role: 'user', content: `A Stop hook says the work is not finished yet: ${why}\nCarry on, then finish.` })
1865
+ continue
1866
+ }
1867
+ return { messages: messages.slice(1), meta, nudged }
1868
+ }
1869
+
1870
+ for (const call of calls) {
1871
+ if (signal?.aborted) return { messages: messages.slice(1), meta, stopped: true }
1872
+ steps += 1
1873
+ if (steps > maxSteps) {
1874
+ messages.push({ role: 'tool', tool_name: call.function.name,
1875
+ content: `stopped: this request has already taken ${maxSteps} steps` })
1876
+ return { messages: messages.slice(1), meta, exhausted: true }
1877
+ }
1878
+
1879
+ recent.push(signature(call))
1880
+ if (isSpinning(recent)) {
1881
+ if (!spinNudged) {
1882
+ // One chance to break out of it on its own: tell the model plainly.
1883
+ spinNudged = true
1884
+ recent.length = 0
1885
+ onNudge?.()
1886
+ messages.push({ role: 'tool', tool_name: call.function.name, content:
1887
+ 'You are repeating the same action without making progress. Stop repeating '
1888
+ + 'it. Either finish now with a short summary of what you have done, or take a '
1889
+ + 'clearly different approach.' })
1890
+ break // skip the rest of this batch, get a fresh turn
1891
+ }
1892
+ // Still spinning after the reminder — end it. This is what makes Auto stop.
1893
+ return { messages: messages.slice(1), meta, stuck: true }
1894
+ }
1895
+
1896
+ // Each call its own row in the panel, even when it repeats an earlier one exactly.
1897
+ const callId = `c${Date.now().toString(36)}-${steps}`
1898
+ // A package the user has not chosen is not installed or imported: the model asks first.
1899
+ const brought = deps.brings(call.function.name, parsedArgs(call))
1900
+ if (brought.length) {
1901
+ known ??= await deps.declared()
1902
+ const open = deps.unchosen(brought, deps.saidByUser(messages, fromUser), known, holds)
1903
+ if (open.length) {
1904
+ const note = deps.holdNote(open)
1905
+ onTool?.({ tool: call.function.name, args: parsedArgs(call), status: 'failed', result: note, id: callId })
1906
+ messages.push({ role: 'tool', tool_name: call.function.name, content: note })
1907
+ continue
1908
+ }
1909
+ }
1910
+ const result = await execute(call, session, (event) => onTool?.({ ...event, id: callId }), signal, server, hooks)
1911
+ if (WRITES.has(call.function.name) || call.function.name === 'generate_media') changed = true
1912
+ const content = typeof result === 'object' ? result.text : result
1913
+ const toolMessage = { role: 'tool', tool_name: call.function.name, content: capResult(content) }
1914
+ if (typeof result === 'object' && result.images?.length) toolMessage.images = result.images
1915
+ messages.push(toolMessage)
1916
+ onMessages?.(messages.slice(1))
1917
+ if (session.hookStop) {
1918
+ onNudge?.('hook', { text: `a hook stopped the run: ${session.hookStop}` })
1919
+ delete session.hookStop
1920
+ return { messages: messages.slice(1), meta, stopped: true }
1921
+ }
1922
+ // Approving a plan switches the mode: the next step gets the tools that go with it.
1923
+ if (call.function.name === 'exit_plan_mode' && modeNow() !== 'plan') break
1924
+ }
1925
+ }
1926
+ }
1927
+
1928
+ module.exports = { appendSystem, helpersOf, findHelper, listHelpers, modeNow, setWindow, targetTokens, sideQuestion, run, turn, steadyTurn, execute, runHelper, normalizeTodos, commandPrefix, previewChange, approve, EFFORT, effortOf, systemPrompt, estimateTokens, summaryPlan, plain, commandEnv, AUTO_NOTE, thinkingLoops, distill, summarizeEarlier, runCommand, openSideBySide, stopBackground, compact, capResult, looksLikeCode, onlyQuotes, askedForChange, overthinking, calibrate, budget, SYSTEM, MODES,
1929
+ TARGET_TOKENS, proposedProvider, proposedContents }