@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.
- package/LICENSE.md +77 -0
- package/README.md +39 -0
- package/ext/about.js +209 -0
- package/ext/account.js +242 -0
- package/ext/accountUi.js +287 -0
- package/ext/agent.js +1929 -0
- package/ext/browser.js +263 -0
- package/ext/cloud.js +385 -0
- package/ext/context.js +241 -0
- package/ext/deps.js +142 -0
- package/ext/dialogs.js +803 -0
- package/ext/diff.js +150 -0
- package/ext/extension.js +2483 -0
- package/ext/feedback.js +55 -0
- package/ext/files.js +565 -0
- package/ext/firestore.js +131 -0
- package/ext/gpuRun.js +77 -0
- package/ext/gpuServer.js +76 -0
- package/ext/hooks.js +153 -0
- package/ext/log.js +29 -0
- package/ext/lsp.js +122 -0
- package/ext/mcp.js +469 -0
- package/ext/mcpAuth.js +128 -0
- package/ext/media/vendor/firebase-config.json +9 -0
- package/ext/media/vendor/signin.js +1 -0
- package/ext/mediagen.js +174 -0
- package/ext/memory.js +117 -0
- package/ext/notebook.js +82 -0
- package/ext/package.json +1234 -0
- package/ext/paths.js +50 -0
- package/ext/permissions.js +229 -0
- package/ext/plans.js +110 -0
- package/ext/plugins.js +243 -0
- package/ext/prefs.js +157 -0
- package/ext/remote.js +170 -0
- package/ext/review.js +126 -0
- package/ext/sandbox.js +79 -0
- package/ext/scheduler.js +87 -0
- package/ext/sessions.js +228 -0
- package/ext/sessionsView.js +449 -0
- package/ext/shell.js +357 -0
- package/ext/storage.js +80 -0
- package/ext/tasklist.js +112 -0
- package/ext/text.js +31 -0
- package/ext/tools.js +781 -0
- package/ext/usage.js +77 -0
- package/ext/voice.js +135 -0
- package/ext/workbench.js +162 -0
- package/ext/workflow.js +51 -0
- package/ext/worktree.js +83 -0
- package/main.js +758 -0
- package/package.json +43 -0
- package/terminal.js +124 -0
- package/vscode-shim.js +231 -0
package/ext/shell.js
ADDED
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
// Shell commands, as Claude Code's Bash tool runs them:
|
|
3
|
+
// - bash, in the workspace, with the directory a `cd` left carried over to the next command;
|
|
4
|
+
// - a timeout the model can set (2 minutes by default, 10 at most). A command still going
|
|
5
|
+
// when it runs out is not killed but left running in the background, as Claude Code does;
|
|
6
|
+
// - `run_in_background` for what never ends — a dev server, a watcher — whose output is
|
|
7
|
+
// read later with bash_output and which is stopped with kill_shell;
|
|
8
|
+
// - a server that says it is listening and goes quiet is noticed and left running;
|
|
9
|
+
// - exit 1 from grep, diff, test and the like is an answer ("no match"), not a failure;
|
|
10
|
+
// - output kept whole when short, its start and end when long.
|
|
11
|
+
// Every command gets its own process group, so Stop kills it and all it started at once.
|
|
12
|
+
|
|
13
|
+
const { spawn } = require('child_process')
|
|
14
|
+
const sandboxing = require('./sandbox')
|
|
15
|
+
const fs = require('fs')
|
|
16
|
+
const os = require('os')
|
|
17
|
+
const path = require('path')
|
|
18
|
+
const { root, extraRoots } = require('./paths')
|
|
19
|
+
const { safeSlice, wellFormed } = require('./text')
|
|
20
|
+
|
|
21
|
+
// Colour is noise to the model, and worse: a shell that exports GREP_OPTIONS=--color=always
|
|
22
|
+
// made every grep come back with escape codes inside the matches, and the model, unable to
|
|
23
|
+
// read them, ran the same search again and again. Commands get an environment that asks for
|
|
24
|
+
// no colour, and what comes out anyway is cleaned.
|
|
25
|
+
const ESC = String.fromCharCode(27)
|
|
26
|
+
const BEL = String.fromCharCode(7)
|
|
27
|
+
const ANSI = new RegExp(`${ESC}\\[[0-9;?]*[ -/]*[@-~]|${ESC}\\][^${BEL}${ESC}]*(?:${BEL}|${ESC}\\\\)|${ESC}[()][A-Za-z0-9]`, 'g')
|
|
28
|
+
const plain = (text) => wellFormed(String(text).replace(ANSI, '').replace(/\r(?!\n)/g, '\n'))
|
|
29
|
+
|
|
30
|
+
// What the user asked to add to every command: the workspace's Python environment (the one
|
|
31
|
+
// the Python extension selected), the `env` of the settings files, and the
|
|
32
|
+
// aipictury.environmentVariables setting — later ones win. Set by the extension.
|
|
33
|
+
let extra = { python: null, files: {}, setting: {} }
|
|
34
|
+
function setExtraEnv(part, value) { extra = { ...extra, [part]: value } }
|
|
35
|
+
|
|
36
|
+
function commandEnv() {
|
|
37
|
+
const env = { ...process.env, GIT_PAGER: 'cat', PAGER: 'cat', CI: '1', GIT_TERMINAL_PROMPT: '0',
|
|
38
|
+
FORCE_COLOR: '0', NO_COLOR: '1', CLICOLOR: '0', TERM: 'dumb' }
|
|
39
|
+
for (const key of ['GREP_OPTIONS', 'GREP_COLOR', 'GREP_COLORS', 'CLICOLOR_FORCE', 'LS_COLORS']) delete env[key]
|
|
40
|
+
if (extra.python) {
|
|
41
|
+
env.PATH = `${extra.python.bin}${path.delimiter}${env.PATH || ''}`
|
|
42
|
+
if (extra.python.venv) env.VIRTUAL_ENV = extra.python.venv
|
|
43
|
+
delete env.PYTHONHOME
|
|
44
|
+
}
|
|
45
|
+
Object.assign(env, extra.files || {}, extra.setting || {})
|
|
46
|
+
return env
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const DEFAULT_TIMEOUT = 120 // seconds
|
|
50
|
+
const MAX_TIMEOUT = 600
|
|
51
|
+
const SERVER_QUIET = 3_000 // quiet this long after saying it is ready → a server that is up
|
|
52
|
+
const SHOWN = 30_000 // characters of output handed back whole
|
|
53
|
+
const KEEP = 1_000_000 // characters a background task keeps
|
|
54
|
+
|
|
55
|
+
// A command that prints this and then goes quiet has started a server: it will not exit.
|
|
56
|
+
const READY = /listening|ready in|running (at|on)|started server|localhost:\d+|127\.0\.0\.1:\d+|0\.0\.0\.0:\d+|press ctrl\+c/i
|
|
57
|
+
|
|
58
|
+
// Exit 1 that means "no" rather than "broken".
|
|
59
|
+
const BENIGN = /^\s*(?:\S+=\S+\s+)*(grep|egrep|fgrep|rg|ag|diff|cmp|test|\[|find|git\s+(diff|grep|ls-files|merge-base))\b/
|
|
60
|
+
|
|
61
|
+
// ---- the shell's working directory, per conversation ----
|
|
62
|
+
|
|
63
|
+
// Every marker is removed: when its command ends, or — for one still running when VS Code
|
|
64
|
+
// closes — when the extension host exits.
|
|
65
|
+
const markers = new Set()
|
|
66
|
+
const dropMarker = (marker) => { markers.delete(marker); try { fs.unlinkSync(marker) } catch { /* never written */ } }
|
|
67
|
+
process.on('exit', () => { for (const marker of markers) dropMarker(marker) })
|
|
68
|
+
const cwdFile = () => {
|
|
69
|
+
const marker = path.join(os.tmpdir(), `aipictury-cwd-${process.pid}-${Math.random().toString(36).slice(2, 8)}`)
|
|
70
|
+
markers.add(marker)
|
|
71
|
+
return marker
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The command wrapped so the directory it ends in is recorded, and its exit code kept. */
|
|
75
|
+
function wrap(command, cwd, marker) {
|
|
76
|
+
const quoted = (p) => `'${p.replace(/'/g, `'\\''`)}'`
|
|
77
|
+
return `cd ${quoted(cwd)} 2>/dev/null || cd ${quoted(root().fsPath)}\n${command}\n__aip_rc=$?\npwd -P > ${quoted(marker)} 2>/dev/null\nexit $__aip_rc`
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Where the next command starts: the conversation's directory, when still in the workspace. */
|
|
81
|
+
function startDir(session) {
|
|
82
|
+
const base = root().fsPath
|
|
83
|
+
const cwd = session?.cwd
|
|
84
|
+
if (!cwd) return base
|
|
85
|
+
const inside = cwd === base || cwd.startsWith(base + path.sep)
|
|
86
|
+
return inside && fs.existsSync(cwd) ? cwd : base
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function followCwd(session, marker) {
|
|
90
|
+
let note = ''
|
|
91
|
+
try {
|
|
92
|
+
let now
|
|
93
|
+
try { now = fs.readFileSync(marker, 'utf8').trim() } finally { dropMarker(marker) }
|
|
94
|
+
if (!now || !session) return ''
|
|
95
|
+
const base = fs.realpathSync(root().fsPath)
|
|
96
|
+
if (now === base || now.startsWith(base + path.sep)) {
|
|
97
|
+
session.cwd = now === base ? root().fsPath : path.join(root().fsPath, path.relative(base, now))
|
|
98
|
+
} else {
|
|
99
|
+
session.cwd = root().fsPath
|
|
100
|
+
note = `\n[the command left the workspace (${now}); the next one starts in the workspace root again]`
|
|
101
|
+
}
|
|
102
|
+
} catch { /* the command exited before recording it (exec, a crash): keep the old one */ }
|
|
103
|
+
return note
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ---- output ----
|
|
107
|
+
|
|
108
|
+
/** What the model reads of a command's output: whole when short, else its start and its end. */
|
|
109
|
+
function shown(out) {
|
|
110
|
+
const t = plain(out).replace(/\s+$/, '')
|
|
111
|
+
if (!t) return '(no output)'
|
|
112
|
+
if (t.length <= SHOWN) return t
|
|
113
|
+
const head = safeSlice(t, 0, 8_000)
|
|
114
|
+
const tail = safeSlice(t, t.length - 20_000)
|
|
115
|
+
return `${head}\n\n… [${t.length - 28_000} characters of output not shown] …\n\n${tail}`
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ---- background tasks ----
|
|
119
|
+
|
|
120
|
+
const tasks = new Map() // id → { child, command, out, read, exit, started, cwd }
|
|
121
|
+
let nextTask = 1
|
|
122
|
+
const stopGroup = (child) => { try { process.kill(-child.pid, 'SIGKILL') } catch { /* already gone */ } }
|
|
123
|
+
|
|
124
|
+
function keep(task, chunk) {
|
|
125
|
+
task.out += chunk.toString()
|
|
126
|
+
if (task.out.length > KEEP) {
|
|
127
|
+
const cut = task.out.length - KEEP
|
|
128
|
+
task.out = task.out.slice(cut)
|
|
129
|
+
task.read = Math.max(0, task.read - cut)
|
|
130
|
+
task.dropped = (task.dropped || 0) + cut
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Leaves a running child as a background task the model can read and stop. */
|
|
135
|
+
function adopt(child, command, out, onChange) {
|
|
136
|
+
const id = `bash_${nextTask++}`
|
|
137
|
+
const task = { id, child, command, out: '', read: 0, exit: null, started: Date.now() }
|
|
138
|
+
keep(task, out)
|
|
139
|
+
task.read = task.out.length // what was already shown is not shown again
|
|
140
|
+
child.stdout?.removeAllListeners('data')
|
|
141
|
+
child.stderr?.removeAllListeners('data')
|
|
142
|
+
child.stdout?.on('data', (c) => keep(task, c))
|
|
143
|
+
child.stderr?.on('data', (c) => keep(task, c))
|
|
144
|
+
child.on('close', (code, sig) => { task.exit = code ?? sig ?? 'killed'; onChange?.() })
|
|
145
|
+
tasks.set(id, task)
|
|
146
|
+
onChange?.()
|
|
147
|
+
return task
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** A monitor: a command left running whose output lines reach the agent as events — in batches,
|
|
151
|
+
* at most one a second, each line whole. Stopped like any background task (kill_shell), or at
|
|
152
|
+
* its time limit. `onLines(task, lines, { ended })` is called with each batch. */
|
|
153
|
+
function startMonitor(command, { session, onLines, limitMs = 30 * 60_000, sandbox: boxed = null, description = '' } = {}) {
|
|
154
|
+
const shell = fs.existsSync('/bin/bash') ? '/bin/bash' : '/bin/sh'
|
|
155
|
+
const quoted = (p) => `'${p.replace(/'/g, `'\\''`)}'`
|
|
156
|
+
const script = `cd ${quoted(startDir(session))} 2>/dev/null || cd ${quoted(root().fsPath)}\n${command}`
|
|
157
|
+
const w = boxed ? sandboxing.wrap(shell, script, boxed) : { file: shell, args: ['-c', script] }
|
|
158
|
+
const child = spawn(w.file, w.args, { cwd: root().fsPath, detached: process.platform !== 'win32', env: commandEnv() })
|
|
159
|
+
const task = adopt(child, command, '', null)
|
|
160
|
+
task.monitor = { description, delivered: 0 }
|
|
161
|
+
let partial = ''
|
|
162
|
+
let batch = []
|
|
163
|
+
let timer = null
|
|
164
|
+
const flush = (ended = false) => {
|
|
165
|
+
clearTimeout(timer); timer = null
|
|
166
|
+
if (!batch.length && !ended) return
|
|
167
|
+
const lines = batch.slice(0, 200)
|
|
168
|
+
if (batch.length > 200) lines.push(`… ${batch.length - 200} more lines`)
|
|
169
|
+
batch = []
|
|
170
|
+
task.monitor.delivered += lines.length
|
|
171
|
+
try { onLines?.(task, lines, { ended }) } catch { /* a listener's own problem */ }
|
|
172
|
+
}
|
|
173
|
+
const take = (chunk) => {
|
|
174
|
+
partial += String(chunk)
|
|
175
|
+
const parts = partial.split('\n')
|
|
176
|
+
partial = parts.pop()
|
|
177
|
+
batch.push(...parts.map((l) => l.replace(/\r$/, '')).filter((l) => l.trim()))
|
|
178
|
+
if (batch.length && !timer) timer = setTimeout(() => flush(), 1000)
|
|
179
|
+
}
|
|
180
|
+
child.stdout?.on('data', take)
|
|
181
|
+
child.stderr?.on('data', take)
|
|
182
|
+
const limit = setTimeout(() => { stopGroup(child) }, Math.max(1000, Math.min(limitMs, 2 * 3_600_000)))
|
|
183
|
+
child.on('close', (code, sig) => {
|
|
184
|
+
clearTimeout(limit)
|
|
185
|
+
if (partial.trim()) batch.push(partial.trim())
|
|
186
|
+
batch.push(`[the monitor stopped: ${code === null ? `killed (${sig})` : `exit ${code}`}]`)
|
|
187
|
+
flush(true)
|
|
188
|
+
})
|
|
189
|
+
return task
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** PowerShell, where there is one: Windows' own, or PowerShell 7 (pwsh) on the PATH. */
|
|
193
|
+
function powershellPath(env = process.env, platform = process.platform) {
|
|
194
|
+
const names = platform === 'win32' ? ['pwsh.exe', 'powershell.exe'] : ['pwsh']
|
|
195
|
+
for (const dir of String(env.PATH || '').split(path.delimiter)) {
|
|
196
|
+
for (const n of names) { const p = path.join(dir, n); if (dir && fs.existsSync(p)) return p }
|
|
197
|
+
}
|
|
198
|
+
return platform === 'win32' ? 'powershell.exe' : null
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function taskList() {
|
|
202
|
+
return [...tasks.values()].map((t) => ({ id: t.id, command: t.command, running: t.exit === null,
|
|
203
|
+
exit: t.exit, seconds: Math.round((Date.now() - t.started) / 1000) }))
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** New output of a background task since it was last read. */
|
|
207
|
+
function bashOutput(args) {
|
|
208
|
+
const task = tasks.get(String(args.bash_id || args.id || ''))
|
|
209
|
+
if (!task) {
|
|
210
|
+
const known = [...tasks.keys()]
|
|
211
|
+
throw new Error(`no background task ${args.bash_id || ''}${known.length ? ` — running: ${known.join(', ')}` : ' — none are running'}`)
|
|
212
|
+
}
|
|
213
|
+
let fresh = task.out.slice(task.read)
|
|
214
|
+
task.read = task.out.length
|
|
215
|
+
if (args.filter) {
|
|
216
|
+
let re
|
|
217
|
+
try { re = new RegExp(String(args.filter)) } catch (e) { throw new Error(`bad filter: ${e.message}`, { cause: e }) }
|
|
218
|
+
fresh = fresh.split('\n').filter((l) => re.test(l)).join('\n')
|
|
219
|
+
}
|
|
220
|
+
const state = task.exit === null ? `still running (${Math.round((Date.now() - task.started) / 1000)}s)` : `exited: ${task.exit}`
|
|
221
|
+
return `${task.id} — ${task.command}\n${state}\n\n${fresh.trim() ? shown(fresh) : '(no new output)'}`
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function killShell(args) {
|
|
225
|
+
const task = tasks.get(String(args.shell_id || args.bash_id || args.id || ''))
|
|
226
|
+
if (!task) throw new Error(`no background task ${args.shell_id || ''}`)
|
|
227
|
+
if (task.exit === null) stopGroup(task.child)
|
|
228
|
+
task.exit ??= 'killed'
|
|
229
|
+
return `stopped ${task.id} (${task.command})`
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Stops every background task — when VS Code closes, or the user asks. */
|
|
233
|
+
function stopBackground() {
|
|
234
|
+
for (const task of tasks.values()) if (task.exit === null) stopGroup(task.child)
|
|
235
|
+
tasks.clear()
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ---- read-only commands ----
|
|
239
|
+
// Commands that only look: run without asking in Manual, and allowed in Plan mode, as
|
|
240
|
+
// Claude Code's built-in read-only commands are. Anything that could write — redirection,
|
|
241
|
+
// command substitution, an option that changes files — is not one.
|
|
242
|
+
const READ_ONLY_CMD = /^(ls|ll|pwd|cat|head|tail|wc|file|stat|du|df|tree|which|whereis|type|echo|printf|date|uname|whoami|env(?=\s*$)|printenv|grep|egrep|fgrep|rg|ag|find|fd|sort|uniq|cut|tr|column|diff|cmp|basename|dirname|realpath|readlink|nl|jq|yq|true|node\s+(-v|--version)|npm\s+(ls|list|view|outdated|-v|--version|run$)|npx\s+--version|yarn\s+(-v|--version)|pnpm\s+(-v|--version)|python3?\s+(-V|--version)|pip3?\s+(list|show|freeze)|go\s+(version|env|list)|cargo\s+(--version|tree|metadata)|git\s+(status|log|diff|show|branch|tag|remote|rev-parse|ls-files|blame|describe|shortlog|config\s+--get|stash\s+list|grep|reflog))\b/
|
|
243
|
+
// Redirections that only silence or merge output: 2>&1, >&2, 2>/dev/null, &>/dev/null.
|
|
244
|
+
const QUIET = /\s*(?:\d?>&\d|&?\d?>>?\s*\/dev\/null)(?=\s|$|[;&|])/g
|
|
245
|
+
// Anything that could write or run something else, anywhere in the line.
|
|
246
|
+
const UNSAFE = new RegExp([
|
|
247
|
+
String.raw`>|<\(|\$\(|` + '`', // redirection, substitution
|
|
248
|
+
String.raw`(^|[^&])&(?!&)`, // a command sent to the background
|
|
249
|
+
String.raw`(^|\s)(-delete|-exec|-execdir|-ok|-okdir|-fprint\S*|--output\S*|-o|--pre|--pre-glob)(\s|=|$)`,
|
|
250
|
+
String.raw`\bsed\s+-i|\btee\b|\byq\b.*\s-i\b`,
|
|
251
|
+
String.raw`\bgit\s+(branch|tag)\b.*\s(-[a-zA-Z]*[dDmMcCfu]\b|--(delete|move|copy|force|set-upstream\S*|unset-upstream|edit-description))`,
|
|
252
|
+
// a bare name creates a branch or tag, unless it is a pattern to list by
|
|
253
|
+
String.raw`\bgit\s+(branch|tag)\b(?!.*\s(-l|--list|--contains|--no-contains|--merged|--no-merged|--points-at)\b)(\s+-\S+)*\s+[^-\s;&|]`,
|
|
254
|
+
String.raw`\bgit\s+remote\s+(add|remove|rm|rename|set-url|set-head|set-branches|prune|update)\b`,
|
|
255
|
+
].join('|'))
|
|
256
|
+
|
|
257
|
+
function isReadOnly(command) {
|
|
258
|
+
const c = String(command || '').trim().replace(QUIET, '')
|
|
259
|
+
if (!c || /\n/.test(c)) return false
|
|
260
|
+
if (UNSAFE.test(c)) return false
|
|
261
|
+
// Every command of a list or pipeline must be a read-only one.
|
|
262
|
+
return c.split(/\s*(?:\|\||&&|;|\|)\s*/).filter(Boolean).every((part) => READ_ONLY_CMD.test(part.trim()))
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Whether a command names a path outside the workspace and the added folders (/etc/hosts,
|
|
266
|
+
* ~/.ssh, ../other): read-only or not, that is asked about first, as Claude Code does. */
|
|
267
|
+
function readsOutside(command) {
|
|
268
|
+
let base
|
|
269
|
+
try { base = root().fsPath } catch { return false }
|
|
270
|
+
const roots = [base, ...extraRoots()]
|
|
271
|
+
const within = (abs) => roots.some((r) => abs === r || abs.startsWith(r + path.sep)) || abs === '/dev/null'
|
|
272
|
+
const words = String(command || '').replace(QUIET, '').split(/[\s;&|<>()]+/).map((w) => w.replace(/^['"]|['"]$/g, '')).filter(Boolean)
|
|
273
|
+
return words.some((word) => {
|
|
274
|
+
const value = word.startsWith('-') && word.includes('=') ? word.slice(word.indexOf('=') + 1) : word
|
|
275
|
+
if (value.startsWith('-')) return false
|
|
276
|
+
if (value.startsWith('~')) return !within(path.join(os.homedir(), value.slice(1)))
|
|
277
|
+
if (value.startsWith('/')) return !within(path.resolve(value))
|
|
278
|
+
if (/(^|\/)\.\.(\/|$)/.test(value)) return !within(path.resolve(base, value))
|
|
279
|
+
return false
|
|
280
|
+
})
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// ---- running ----
|
|
284
|
+
|
|
285
|
+
/** Runs a shell command in the workspace and returns what it printed. */
|
|
286
|
+
function runCommand(command, options = {}) {
|
|
287
|
+
// runCommand(command, signal) — the older form — still works.
|
|
288
|
+
const { signal, session, timeout, background = false, onTasks, sandbox: boxed = null, powershell = false } = options && typeof options.aborted === 'boolean'
|
|
289
|
+
? { signal: options } : options || {}
|
|
290
|
+
return new Promise((done) => {
|
|
291
|
+
const started = Date.now()
|
|
292
|
+
const limit = Math.min(MAX_TIMEOUT, Math.max(1, Number(timeout) || DEFAULT_TIMEOUT)) * 1000
|
|
293
|
+
const marker = cwdFile()
|
|
294
|
+
const shell = fs.existsSync('/bin/bash') ? '/bin/bash' : '/bin/sh'
|
|
295
|
+
let child
|
|
296
|
+
try {
|
|
297
|
+
child = powershell
|
|
298
|
+
? spawn(powershellPath(), ['-NoProfile', '-NonInteractive', '-Command', command], { cwd: startDir(session), env: commandEnv(), detached: process.platform !== 'win32' })
|
|
299
|
+
: process.platform === 'win32'
|
|
300
|
+
? spawn(command, { shell: true, cwd: startDir(session), env: commandEnv() })
|
|
301
|
+
: boxed
|
|
302
|
+
// Sandboxed: writes kept to the workspace, the network as allowed (sandbox.js).
|
|
303
|
+
? (() => { const w = sandboxing.wrap(shell, wrap(command, startDir(session), marker), boxed)
|
|
304
|
+
return spawn(w.file, w.args, { cwd: root().fsPath, detached: true, env: commandEnv() }) })()
|
|
305
|
+
: spawn(shell, ['-c', wrap(command, startDir(session), marker)], { cwd: root().fsPath, detached: true, env: commandEnv() })
|
|
306
|
+
} catch (error) {
|
|
307
|
+
dropMarker(marker)
|
|
308
|
+
done(`could not run it: ${error.message}`)
|
|
309
|
+
return
|
|
310
|
+
}
|
|
311
|
+
let finished = false
|
|
312
|
+
let out = ''
|
|
313
|
+
let lastOutput = started
|
|
314
|
+
const finish = (text) => {
|
|
315
|
+
if (finished) return
|
|
316
|
+
finished = true
|
|
317
|
+
clearInterval(watch)
|
|
318
|
+
signal?.removeEventListener('abort', onAbort)
|
|
319
|
+
done(text)
|
|
320
|
+
}
|
|
321
|
+
const add = (chunk) => { if (out.length < 5_000_000) out += chunk.toString(); lastOutput = Date.now() }
|
|
322
|
+
child.stdout.on('data', add)
|
|
323
|
+
child.stderr.on('data', add)
|
|
324
|
+
|
|
325
|
+
const onAbort = () => { stopGroup(child); finish(`stopped by the user\n${shown(out)}`) }
|
|
326
|
+
signal?.addEventListener('abort', onAbort, { once: true })
|
|
327
|
+
|
|
328
|
+
const leave = (why) => {
|
|
329
|
+
const task = adopt(child, command, out, onTasks)
|
|
330
|
+
finish(`${why} — left running in the background as ${task.id}.\n${shown(out)}\n\n`
|
|
331
|
+
+ `Read its new output with bash_output (bash_id "${task.id}"); stop it with kill_shell.`)
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if (background) {
|
|
335
|
+
// Give it a moment, so an immediate failure (a typo, a port in use) is seen now.
|
|
336
|
+
setTimeout(() => { if (!finished) leave('started') }, 1500)
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const watch = setInterval(() => {
|
|
340
|
+
if (background) return
|
|
341
|
+
const quiet = Date.now() - lastOutput
|
|
342
|
+
if (READY.test(out) && quiet > SERVER_QUIET) leave('it looks like a server that is up')
|
|
343
|
+
else if (Date.now() - started > limit) leave(`still running after ${Math.round(limit / 1000)}s`)
|
|
344
|
+
}, 500)
|
|
345
|
+
|
|
346
|
+
child.on('close', (code, sig) => {
|
|
347
|
+
const note = followCwd(session, marker)
|
|
348
|
+
const status = code === null ? `killed (${sig})` : `exit ${code}`
|
|
349
|
+
const benign = code === 1 && BENIGN.test(command) ? ' (for this command 1 means "no match" or "differs" — not an error)' : ''
|
|
350
|
+
const refused = boxed && code !== 0 ? sandboxing.note(out, boxed) : ''
|
|
351
|
+
finish(`${status}${benign}\n${shown(out)}${note}${refused}`)
|
|
352
|
+
})
|
|
353
|
+
child.on('error', (err) => { dropMarker(marker); finish(`could not run it: ${err.message}`) })
|
|
354
|
+
})
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
module.exports = { powershellPath, startMonitor, setExtraEnv, runCommand, bashOutput, killShell, stopBackground, taskList, isReadOnly, readsOutside, plain, commandEnv, shown, tasks }
|
package/ext/storage.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
// Firebase Storage over its REST API (the same one the SDK uses), for the extension and the CLI:
|
|
3
|
+
// the media a conversation generated, and files attached to it, kept with the account under
|
|
4
|
+
// users/{uid}/media/. The caller's ID token decides what may be touched (storage.rules).
|
|
5
|
+
// With `emulator` ("127.0.0.1:9399") it talks to the local emulator. No VS Code here.
|
|
6
|
+
|
|
7
|
+
class Storage {
|
|
8
|
+
constructor({ bucket, getToken, emulator = null, fetchImpl = globalThis.fetch }) {
|
|
9
|
+
this.bucket = bucket
|
|
10
|
+
this.getToken = getToken
|
|
11
|
+
this.fetch = fetchImpl
|
|
12
|
+
this.base = emulator ? `http://${emulator}/v0/b/${bucket}/o` : `https://firebasestorage.googleapis.com/v0/b/${bucket}/o`
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async headers(extra = {}) {
|
|
16
|
+
const token = await this.getToken()
|
|
17
|
+
return token ? { ...extra, Authorization: `Firebase ${token}` } : extra
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async check(response) {
|
|
21
|
+
if (response.ok) return response
|
|
22
|
+
const data = await response.json().catch(() => ({}))
|
|
23
|
+
const err = new Error(data?.error?.message || (response.status === 403 ? 'permission denied' : `Storage answered ${response.status}`))
|
|
24
|
+
err.status = response.status
|
|
25
|
+
throw err
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Stores bytes at `path` ("users/u/media/a.png"). Resolves to the object's metadata. A multipart
|
|
29
|
+
* upload, as the SDK's: the type goes in the object's metadata, where the rules read it. */
|
|
30
|
+
async upload(path, bytes, contentType) {
|
|
31
|
+
const boundary = `aipictury-${Date.now().toString(36)}${Math.random().toString(36).slice(2)}`
|
|
32
|
+
const body = Buffer.concat([
|
|
33
|
+
Buffer.from(`--${boundary}\r\nContent-Type: application/json; charset=utf-8\r\n\r\n${JSON.stringify({ name: path, contentType })}\r\n`
|
|
34
|
+
+ `--${boundary}\r\nContent-Type: ${contentType}\r\n\r\n`),
|
|
35
|
+
Buffer.from(bytes),
|
|
36
|
+
Buffer.from(`\r\n--${boundary}--`),
|
|
37
|
+
])
|
|
38
|
+
const r = await this.fetch(`${this.base}?uploadType=multipart&name=${encodeURIComponent(path)}`, {
|
|
39
|
+
method: 'POST', headers: await this.headers({ 'Content-Type': `multipart/related; boundary=${boundary}`, 'X-Goog-Upload-Protocol': 'multipart' }),
|
|
40
|
+
body, signal: AbortSignal.timeout(10 * 60_000),
|
|
41
|
+
})
|
|
42
|
+
return (await this.check(r)).json()
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The object's bytes, or null when there is none. */
|
|
46
|
+
async download(path) {
|
|
47
|
+
const r = await this.fetch(`${this.base}/${encodeURIComponent(path)}?alt=media`, { headers: await this.headers(), signal: AbortSignal.timeout(10 * 60_000) })
|
|
48
|
+
if (r.status === 404) return null
|
|
49
|
+
return Buffer.from(await (await this.check(r)).arrayBuffer())
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Its metadata ({ size, contentType, … }), or null. */
|
|
53
|
+
async stat(path) {
|
|
54
|
+
const r = await this.fetch(`${this.base}/${encodeURIComponent(path)}`, { headers: await this.headers(), signal: AbortSignal.timeout(30_000) })
|
|
55
|
+
if (r.status === 404) return null
|
|
56
|
+
return (await this.check(r)).json()
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async delete(path) {
|
|
60
|
+
const r = await this.fetch(`${this.base}/${encodeURIComponent(path)}`, { method: 'DELETE', headers: await this.headers(), signal: AbortSignal.timeout(30_000) })
|
|
61
|
+
if (r.status === 404) return
|
|
62
|
+
await this.check(r)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Every object under a prefix: [{ name, size, contentType }]. */
|
|
66
|
+
async list(prefix) {
|
|
67
|
+
const out = []
|
|
68
|
+
let token = ''
|
|
69
|
+
do {
|
|
70
|
+
const r = await this.fetch(`${this.base}?prefix=${encodeURIComponent(prefix)}${token ? `&pageToken=${encodeURIComponent(token)}` : ''}`,
|
|
71
|
+
{ headers: await this.headers(), signal: AbortSignal.timeout(30_000) })
|
|
72
|
+
const data = await (await this.check(r)).json()
|
|
73
|
+
for (const i of data.items || []) out.push({ name: i.name, size: Number(i.size), contentType: i.contentType })
|
|
74
|
+
token = data.nextPageToken || ''
|
|
75
|
+
} while (token)
|
|
76
|
+
return out
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
module.exports = { Storage }
|
package/ext/tasklist.js
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
// The agent's task list with dependencies, as Claude Code's task tools keep it: tasks with an
|
|
3
|
+
// id, a subject, a status, and the tasks they wait for. A task cannot start while one it waits
|
|
4
|
+
// for is unfinished; finishing it says which tasks are now free. The same list as todo_write's
|
|
5
|
+
// (that tool stays as the shortcut: the whole list at once), shown in the panel.
|
|
6
|
+
const STATUSES = ['pending', 'in_progress', 'completed']
|
|
7
|
+
const MAX = 50
|
|
8
|
+
|
|
9
|
+
const label = (t) => `#${t.id} ${t.content}`
|
|
10
|
+
const nextId = (list) => String(Math.max(0, ...list.map((t) => Number(t.id) || 0)) + 1)
|
|
11
|
+
const find = (list, id) => list.find((t) => t.id === String(id).replace(/^#/, ''))
|
|
12
|
+
|
|
13
|
+
/** The unfinished tasks `task` waits for. */
|
|
14
|
+
function waitingOn(list, task) {
|
|
15
|
+
return (task.blockedBy || []).map((id) => find(list, id)).filter((t) => t && t.status !== 'completed')
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Would `from` waiting for `to` close a loop (to already waits, through others, for from)? */
|
|
19
|
+
function loops(list, from, to) {
|
|
20
|
+
const seen = new Set()
|
|
21
|
+
const walk = (id) => {
|
|
22
|
+
if (id === from) return true
|
|
23
|
+
if (seen.has(id)) return false
|
|
24
|
+
seen.add(id)
|
|
25
|
+
return (find(list, id)?.blockedBy || []).some(walk)
|
|
26
|
+
}
|
|
27
|
+
return walk(to)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function addWait(list, task, ids) {
|
|
31
|
+
for (const raw of ids || []) {
|
|
32
|
+
const other = find(list, raw)
|
|
33
|
+
if (!other) throw new Error(`there is no task #${String(raw).replace(/^#/, '')}`)
|
|
34
|
+
if (other.id === task.id) throw new Error('a task cannot wait for itself')
|
|
35
|
+
if (loops(list, task.id, other.id)) throw new Error(`#${task.id} waiting for #${other.id} would be a loop: #${other.id} already waits for #${task.id}`)
|
|
36
|
+
task.blockedBy = [...new Set([...(task.blockedBy || []), other.id])]
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** task_create: a new pending task. Returns { list, task }. */
|
|
41
|
+
function create(list, args = {}) {
|
|
42
|
+
const content = String(args.subject || args.content || '').trim()
|
|
43
|
+
if (!content) throw new Error('a task needs a subject')
|
|
44
|
+
if (list.length >= MAX) throw new Error(`at most ${MAX} tasks — finish or delete some first`)
|
|
45
|
+
const task = { id: nextId(list), content, status: 'pending', description: String(args.description || '').trim() || undefined,
|
|
46
|
+
activeForm: String(args.activeForm || '').trim() || undefined, blockedBy: [] }
|
|
47
|
+
const out = [...list.map((t) => ({ ...t })), task]
|
|
48
|
+
addWait(out, task, args.blockedBy || args.addBlockedBy)
|
|
49
|
+
return { list: out, task }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** task_update: change a task. Returns { list, task, freed } — the tasks its completion freed. */
|
|
53
|
+
function update(list, args = {}) {
|
|
54
|
+
const out = list.map((t) => ({ ...t, blockedBy: [...(t.blockedBy || [])] }))
|
|
55
|
+
const task = find(out, args.taskId ?? args.id)
|
|
56
|
+
if (!task) throw new Error(`there is no task #${String(args.taskId ?? args.id ?? '').replace(/^#/, '')}`)
|
|
57
|
+
if (args.status === 'deleted') {
|
|
58
|
+
const rest = out.filter((t) => t !== task)
|
|
59
|
+
for (const t of rest) t.blockedBy = t.blockedBy.filter((id) => id !== task.id)
|
|
60
|
+
return { list: rest, task, freed: [] }
|
|
61
|
+
}
|
|
62
|
+
if (args.subject) task.content = String(args.subject).trim()
|
|
63
|
+
if (args.description !== undefined) task.description = String(args.description).trim() || undefined
|
|
64
|
+
if (args.activeForm !== undefined) task.activeForm = String(args.activeForm).trim() || undefined
|
|
65
|
+
if (args.owner !== undefined) task.owner = String(args.owner).trim() || undefined
|
|
66
|
+
addWait(out, task, args.addBlockedBy)
|
|
67
|
+
for (const id of args.addBlocks || []) {
|
|
68
|
+
const other = find(out, id)
|
|
69
|
+
if (!other) throw new Error(`there is no task #${String(id).replace(/^#/, '')}`)
|
|
70
|
+
addWait(out, other, [task.id])
|
|
71
|
+
}
|
|
72
|
+
let freed = []
|
|
73
|
+
if (args.status) {
|
|
74
|
+
if (!STATUSES.includes(args.status)) throw new Error(`a status is ${STATUSES.join(', ')} or deleted`)
|
|
75
|
+
const waits = waitingOn(out, task)
|
|
76
|
+
if (args.status === 'in_progress' && waits.length) throw new Error(`#${task.id} cannot start yet: it waits for ${waits.map(label).join(', ')}`)
|
|
77
|
+
const was = task.status
|
|
78
|
+
task.status = args.status
|
|
79
|
+
if (args.status === 'completed' && was !== 'completed') {
|
|
80
|
+
freed = out.filter((t) => t.status === 'pending' && t.blockedBy.includes(task.id) && !waitingOn(out, t).length)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return { list: out, task, freed }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** task_list: every task, with what it waits for. */
|
|
87
|
+
function summary(list) {
|
|
88
|
+
if (!list.length) return 'No tasks yet.'
|
|
89
|
+
const done = list.filter((t) => t.status === 'completed').length
|
|
90
|
+
return [`${done}/${list.length} done.`, ...list.map((t) => {
|
|
91
|
+
const waits = waitingOn(list, t)
|
|
92
|
+
return `${t.status === 'completed' ? '[x]' : t.status === 'in_progress' ? '[~]' : '[ ]'} ${label(t)}${t.owner ? ` (${t.owner})` : ''}`
|
|
93
|
+
+ `${waits.length ? ` — waits for ${waits.map((w) => `#${w.id}`).join(', ')}` : ''}`
|
|
94
|
+
})].join('\n')
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** task_get: one task in full. */
|
|
98
|
+
function detail(list, id) {
|
|
99
|
+
const t = find(list, id)
|
|
100
|
+
if (!t) throw new Error(`there is no task #${String(id).replace(/^#/, '')}`)
|
|
101
|
+
const waits = waitingOn(list, t)
|
|
102
|
+
const blocks = list.filter((o) => o.blockedBy?.includes(t.id))
|
|
103
|
+
return [`${label(t)} — ${t.status}`, t.description, waits.length ? `Waits for: ${waits.map(label).join(', ')}` : '',
|
|
104
|
+
blocks.length ? `Others wait for it: ${blocks.map(label).join(', ')}` : ''].filter(Boolean).join('\n')
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** todo_write's list given ids, so the task tools can carry on from it. */
|
|
108
|
+
function fromTodos(todos) {
|
|
109
|
+
return todos.map((t, i) => ({ ...t, id: t.id || String(i + 1), blockedBy: t.blockedBy || [] }))
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
module.exports = { create, update, summary, detail, fromTodos, waitingOn, STATUSES }
|
package/ext/text.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
// Text helpers that must never produce something a JSON parser rejects.
|
|
3
|
+
|
|
4
|
+
/** `s.slice(start, end)` that never cuts an emoji or other astral character in half. A
|
|
5
|
+
* lone surrogate is invalid UTF-8: the server refused a whole conversation over one
|
|
6
|
+
* (Claude Code met the same: "surrogates not allowed"). */
|
|
7
|
+
function safeSlice(s, start = 0, end = undefined) {
|
|
8
|
+
const t = String(s)
|
|
9
|
+
let a = start < 0 ? Math.max(0, t.length + start) : Math.min(start, t.length)
|
|
10
|
+
let b = end === undefined ? t.length : end < 0 ? Math.max(0, t.length + end) : Math.min(end, t.length)
|
|
11
|
+
const low = (i) => { const c = t.charCodeAt(i); return c >= 0xdc00 && c <= 0xdfff }
|
|
12
|
+
if (a > 0 && a < t.length && low(a)) a += 1
|
|
13
|
+
if (b > 0 && b < t.length && low(b)) b -= 1
|
|
14
|
+
return t.slice(a, Math.max(a, b))
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Removes lone surrogates anywhere in a string (text from a process can have them). */
|
|
18
|
+
const wellFormed = (s) => String(s).replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, '�')
|
|
19
|
+
|
|
20
|
+
// Characters that do not show but change what text means: zero-width spaces and joiners,
|
|
21
|
+
// bidirectional overrides (the "Trojan Source" trick), the BOM, tag characters. Claude Code
|
|
22
|
+
// strips them from what is pasted and sent; so do we, and says how many. The zero-width
|
|
23
|
+
// joiner stays: emoji families and flags are built with it.
|
|
24
|
+
const INVISIBLE = /[\u200B\u200C\u2060-\u2064\u2066-\u2069\u202A-\u202E\u200E\u200F\uFEFF\u00AD\u180E]|[\u{E0000}-\u{E007F}]/gu
|
|
25
|
+
function stripInvisible(s) {
|
|
26
|
+
const t = String(s)
|
|
27
|
+
const removed = (t.match(INVISIBLE) || []).length
|
|
28
|
+
return { text: removed ? t.replace(INVISIBLE, '') : t, removed }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
module.exports = { safeSlice, wellFormed, stripInvisible, INVISIBLE }
|