@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/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 }
@@ -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 }