@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/prefs.js ADDED
@@ -0,0 +1,157 @@
1
+ 'use strict'
2
+ // Settings kept in files, at three levels — as Claude Code keeps them, and in the same shape:
3
+ //
4
+ // user ~/.aipictury/settings.json everywhere you work
5
+ // project <workspace>/.aipictury/settings.json shared with the project (committed)
6
+ // local <workspace>/.aipictury/settings.local.json yours, in this project only
7
+ //
8
+ // Claude Code's own files are read too, and never written: ~/.claude/settings.json,
9
+ // .claude/settings.json and .claude/settings.local.json — so rules and hooks written for it
10
+ // apply here as well.
11
+ //
12
+ // Merging: lists add up (permission rules, hooks per event, disabled servers); objects are
13
+ // merged key by key (enabledPlugins, skillVisibility, sandbox); for a single value the more
14
+ // specific level wins (local over project over user; AIPictury's file over Claude Code's at the
15
+ // same level). Deny beats ask beats allow — that is decided where rules are matched.
16
+ const fs = require('fs')
17
+ const os = require('os')
18
+ const path = require('path')
19
+ const { home } = require('./sessions')
20
+
21
+ const LEVELS = ['user', 'project', 'local']
22
+ // Lists that add up across levels, as permission rules do.
23
+ const LISTS = new Set(['disabledMcpServers', 'disabledMcpjsonServers', 'enabledMcpjsonServers'])
24
+ const isPlain = (v) => !!v && typeof v === 'object' && !Array.isArray(v)
25
+
26
+ /** Every settings file that applies, least specific first. */
27
+ function sources(folder) {
28
+ const ours = {
29
+ user: path.join(home(), 'settings.json'),
30
+ project: folder && path.join(folder, '.aipictury', 'settings.json'),
31
+ local: folder && path.join(folder, '.aipictury', 'settings.local.json'),
32
+ }
33
+ const claude = {
34
+ user: path.join(process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude'), 'settings.json'),
35
+ project: folder && path.join(folder, '.claude', 'settings.json'),
36
+ local: folder && path.join(folder, '.claude', 'settings.local.json'),
37
+ }
38
+ const out = []
39
+ for (const level of LEVELS) {
40
+ if (claude[level]) out.push({ level, file: claude[level], editable: false, origin: 'Claude Code' })
41
+ if (ours[level]) out.push({ level, file: ours[level], editable: true, origin: 'AIPictury' })
42
+ }
43
+ return out
44
+ }
45
+
46
+ function readJson(file) {
47
+ try { return JSON.parse(fs.readFileSync(file, 'utf8')) } catch { return null }
48
+ }
49
+
50
+ // Read at every tool call (rules, hooks): kept until one of the files changes.
51
+ const cache = new Map()
52
+ const signature = (list) => list.map((s) => { try { const st = fs.statSync(s.file); return `${s.file}:${st.mtimeMs}:${st.size}` } catch { return `${s.file}:-` } }).join('|')
53
+
54
+ /** The settings, merged, and where each came from. */
55
+ function load(folder) {
56
+ const list = sources(folder)
57
+ const key = `${folder || ''}`
58
+ const sig = signature(list)
59
+ const hit = cache.get(key)
60
+ if (hit && hit.sig === sig) return hit.value
61
+ const value = loadFresh(list)
62
+ cache.set(key, { sig, value })
63
+ return value
64
+ }
65
+
66
+ function loadFresh(list) {
67
+ const found = list.map((s) => ({ ...s, data: readJson(s.file) }))
68
+ const merged = { permissions: { allow: [], ask: [], deny: [] }, hooks: {} }
69
+ for (const s of found) {
70
+ if (!s.data || typeof s.data !== 'object') continue
71
+ for (const [key, value] of Object.entries(s.data)) {
72
+ if (key === 'permissions' && value && typeof value === 'object') {
73
+ for (const kind of ['allow', 'ask', 'deny']) {
74
+ for (const rule of Array.isArray(value[kind]) ? value[kind] : []) {
75
+ merged.permissions[kind].push({ rule: String(rule), level: s.level, file: s.file, editable: s.editable })
76
+ }
77
+ }
78
+ if (value.defaultMode) merged.permissions.defaultMode = value.defaultMode
79
+ if (Array.isArray(value.additionalDirectories)) {
80
+ merged.permissions.additionalDirectories = [...(merged.permissions.additionalDirectories || []), ...value.additionalDirectories]
81
+ }
82
+ } else if (key === 'hooks' && value && typeof value === 'object') {
83
+ for (const [event, groups] of Object.entries(value)) {
84
+ for (const group of Array.isArray(groups) ? groups : []) {
85
+ (merged.hooks[event] ||= []).push({ ...group, level: s.level, file: s.file, editable: s.editable })
86
+ }
87
+ }
88
+ } else if (key === 'env' && value && typeof value === 'object') {
89
+ merged.env = { ...(merged.env || {}), ...value }
90
+ } else if (isPlain(value) && isPlain(merged[key])) {
91
+ merged[key] = { ...merged[key], ...value } // enabledPlugins, sandbox…: merged key by key
92
+ } else if (Array.isArray(value) && LISTS.has(key)) {
93
+ merged[key] = [...new Set([...(merged[key] || []), ...value])]
94
+ } else {
95
+ merged[key] = value // later (more specific) wins
96
+ }
97
+ }
98
+ }
99
+ return { merged, sources: found }
100
+ }
101
+
102
+ /** The file for a level (always AIPictury's own: Claude Code's are never written). */
103
+ function fileFor(folder, level) {
104
+ const s = sources(folder).find((x) => x.level === level && x.editable)
105
+ if (!s) throw new Error(`no ${level} settings file here (open a folder)`)
106
+ return s.file
107
+ }
108
+
109
+ function edit(folder, level, change) {
110
+ const file = fileFor(folder, level)
111
+ const data = readJson(file) || {}
112
+ change(data)
113
+ fs.mkdirSync(path.dirname(file), { recursive: true })
114
+ const tmp = `${file}.${process.pid}.tmp`
115
+ fs.writeFileSync(tmp, `${JSON.stringify(data, null, 2)}\n`)
116
+ fs.renameSync(tmp, file)
117
+ if (level === 'local') keepOutOfGit(folder)
118
+ return file
119
+ }
120
+
121
+ /** The local file is personal: it is added to the project's .gitignore when there is one. */
122
+ function keepOutOfGit(folder) {
123
+ const ignore = path.join(folder, '.gitignore')
124
+ try {
125
+ const text = fs.existsSync(ignore) ? fs.readFileSync(ignore, 'utf8') : null
126
+ if (text === null || /(^|\n)\.aipictury\/settings\.local\.json\s*(\n|$)/.test(text)) return
127
+ fs.appendFileSync(ignore, `${text.endsWith('\n') || !text ? '' : '\n'}.aipictury/settings.local.json\n`)
128
+ } catch { /* not ours to insist on */ }
129
+ }
130
+
131
+ function addRule(folder, level, kind, rule) {
132
+ if (!['allow', 'ask', 'deny'].includes(kind)) throw new Error(`a rule is allow, ask or deny, not ${kind}`)
133
+ const clean = String(rule || '').trim()
134
+ if (!clean) throw new Error('an empty rule')
135
+ return edit(folder, level, (data) => {
136
+ data.permissions ||= {}
137
+ const list = (data.permissions[kind] ||= [])
138
+ if (!list.includes(clean)) list.push(clean)
139
+ })
140
+ }
141
+
142
+ function removeRule(folder, level, kind, rule) {
143
+ return edit(folder, level, (data) => {
144
+ const list = data.permissions?.[kind]
145
+ if (Array.isArray(list)) data.permissions[kind] = list.filter((r) => r !== rule)
146
+ })
147
+ }
148
+
149
+ /** Sets one top-level value (an output style, skill visibility…) at a level. */
150
+ function set(folder, level, key, value) {
151
+ return edit(folder, level, (data) => {
152
+ if (value === undefined) delete data[key]
153
+ else data[key] = value
154
+ })
155
+ }
156
+
157
+ module.exports = { load, sources, addRule, removeRule, set, edit, fileFor, LEVELS }
package/ext/remote.js ADDED
@@ -0,0 +1,170 @@
1
+ 'use strict'
2
+ // Remote control: a conversation of this editor carried on from the web app — a phone included —
3
+ // while it keeps running here, as Claude Code's Remote Control. The extension connects to the
4
+ // AIPictury server (/ws/remote/host) and sends the conversation's state whenever it changes;
5
+ // what the user does in the browser comes back as commands: a message, an approval, an answer,
6
+ // a plan's verdict, Stop. The agent runs here, under this editor's rules.
7
+ //
8
+ // The link carries a key only the editor and the link know (the server keeps its hash). From the
9
+ // browser one can say yes or no once, but not save a standing rule, change the permission mode
10
+ // or run a slash command: those stay in VS Code.
11
+ // No VS Code here: the panel is handed in.
12
+ const crypto = require('crypto')
13
+
14
+ const ITEMS = 60 // transcript entries sent (the latest)
15
+ const TEXT_CAP = 8000 // characters of one entry
16
+ const LIVE_CAP = 20_000
17
+ const DEBOUNCE = 150
18
+ const RETRY = [1000, 2000, 5000, 10_000, 30_000]
19
+
20
+ const cut = (text, n) => { const t = String(text ?? ''); return t.length > n ? `${t.slice(0, n)}…` : t }
21
+
22
+ /** One line saying what a tool call did, for the remote view. */
23
+ function toolLabel(t) {
24
+ const a = t.args || {}
25
+ return cut(a.command || a.path || a.url || a.pattern || a.query || a.description || a.title || a.action || '', 160)
26
+ }
27
+
28
+ /** What an approval asks, for the remote view: kind, the command or file, why. */
29
+ function approvalOf(id, request = {}) {
30
+ return {
31
+ id, kind: request.kind,
32
+ tool: request.tool, command: cut(request.command, 2000), path: request.path, site: request.site,
33
+ because: request.because, description: request.description,
34
+ args: request.kind === 'tool' || request.kind === 'mcp' ? cut(JSON.stringify(request.args || {}), 1200) : undefined,
35
+ // An edit as a diff: each changed part, its removed lines (-) and added ones (+), from line n.
36
+ diff: request.kind === 'edit' ? cut((request.hunks || []).map((h) => [`@@ line ${h.line}`, ...h.removed.map((l) => `- ${l}`),
37
+ ...h.added.map((l) => `+ ${l}`)].join('\n')).join('\n') || request.after, 6000) : undefined,
38
+ }
39
+ }
40
+
41
+ /** The conversation as the remote view shows it. */
42
+ function snapshot(panel, live) {
43
+ const items = (panel.transcript || []).slice(-ITEMS).map((t) => {
44
+ if (t.kind === 'user' || t.kind === 'assistant') return { kind: t.kind, text: cut(t.text, TEXT_CAP), at: t.at }
45
+ if (t.kind === 'tool') return { kind: 'tool', tool: t.tool, status: t.status, label: toolLabel(t), result: cut(typeof t.result === 'string' ? t.result : '', 400) }
46
+ if (t.kind === 'error') return { kind: 'error', text: cut(t.text || t.error, 1000) }
47
+ return null
48
+ }).filter(Boolean)
49
+ return {
50
+ title: panel.title || panel.aiTitle || 'New conversation',
51
+ running: !!panel.abort,
52
+ live: cut(live, LIVE_CAP),
53
+ items,
54
+ approvals: [...(panel.approvals || new Map())].map(([id, p]) => approvalOf(id, p.request)),
55
+ question: panel.pendingAsk ? { id: panel.pendingAsk.id, questions: panel.pendingAsk.questions } : null,
56
+ plan: panel.pendingPlan ? { id: panel.pendingPlan.id, plan: cut(panel.pendingPlan.plan, 20_000) } : null,
57
+ queue: (panel.queue || []).map((q) => cut(q.text, 400)),
58
+ }
59
+ }
60
+
61
+ class RemoteLink {
62
+ /** `panel`: the AgentPanel; `server`: its http(s) address. */
63
+ constructor(panel, server, { log = () => {}, onViewers = () => {}, webApp = 'https://aipictury.com', token = async () => null } = {}) {
64
+ this.webApp = webApp
65
+ this.token = token // the account's ID token: the server may ask for it
66
+ this.panel = panel
67
+ this.server = server.replace(/\/+$/, '')
68
+ this.id = crypto.randomUUID()
69
+ this.key = crypto.randomBytes(24).toString('base64url')
70
+ this.log = log
71
+ this.onViewers = onViewers
72
+ this.live = ''
73
+ this.viewers = 0
74
+ this.active = false
75
+ this.tries = 0
76
+ }
77
+
78
+ /** The address to open in a browser: the key travels in the fragment, never to a server log. */
79
+ /** The address to open: the web app at aipictury.com (aipictury.webApp), with the conversation,
80
+ * its key and the GPU server it is on — all in the fragment, which never reaches a server's log. */
81
+ get url() {
82
+ const web = (this.webApp || 'https://aipictury.com').replace(/\/+$/, '')
83
+ return `${web}/#remote/${this.id}.${this.key}~${Buffer.from(this.server).toString('base64url')}`
84
+ }
85
+
86
+ start() {
87
+ this.active = true
88
+ const post = this.panel.post.bind(this.panel)
89
+ this.restorePost = () => { this.panel.post = post }
90
+ this.panel.post = (message) => {
91
+ post(message)
92
+ if (message.type === 'delta') this.live += message.text || ''
93
+ if (message.type === 'begin' || message.type === 'turn' || message.type === 'end') this.live = ''
94
+ this.schedule()
95
+ }
96
+ this.connect()
97
+ return this
98
+ }
99
+
100
+ async connect() {
101
+ if (!this.active) return
102
+ const token = await this.token().catch(() => null)
103
+ if (!this.active) return
104
+ const ws = new WebSocket(`${this.server.replace(/^http/, 'ws')}/ws/remote/host${token ? `?token=${encodeURIComponent(token)}` : ''}`)
105
+ this.ws = ws
106
+ ws.onopen = () => { ws.send(JSON.stringify({ type: 'hello', id: this.id, key: this.key })) }
107
+ ws.onmessage = (event) => {
108
+ let msg
109
+ try { msg = JSON.parse(String(event.data)) } catch { return }
110
+ if (msg.type === 'ready') { this.tries = 0; this.connected = true; this.setViewers(msg.viewers); this.send() }
111
+ if (msg.type === 'viewers') this.setViewers(msg.viewers)
112
+ if (msg.type === 'command') this.command(msg.command || {}).catch((error) => this.log(`remote command failed: ${error.message}`))
113
+ }
114
+ ws.onclose = (event) => {
115
+ this.connected = false
116
+ if (!this.active || event.code === 4403 || event.code === 4409) return
117
+ const wait = RETRY[Math.min(this.tries, RETRY.length - 1)]
118
+ this.tries += 1
119
+ this.retry = setTimeout(() => this.connect(), wait)
120
+ }
121
+ ws.onerror = () => {} // onclose follows, and retries
122
+ }
123
+
124
+ setViewers(n) { this.viewers = n || 0; this.onViewers(this.viewers) }
125
+
126
+ schedule() {
127
+ if (!this.connected || this.pending) return
128
+ this.pending = setTimeout(() => { this.pending = null; this.send() }, DEBOUNCE)
129
+ }
130
+
131
+ send() {
132
+ if (this.ws?.readyState !== 1) return
133
+ this.ws.send(JSON.stringify({ type: 'state', state: snapshot(this.panel, this.live) }))
134
+ }
135
+
136
+ /** What the browser asked for. Only what a user in the panel could do with one click. */
137
+ async command(c) {
138
+ const p = this.panel
139
+ if (c.type === 'message') {
140
+ const text = String(c.text || '').trim()
141
+ if (!text) return
142
+ if (/^[/#]/.test(text)) { p.say('from the browser: slash commands and # rules work in VS Code itself — sent as a message they would not run', 'remote'); return }
143
+ await p.ask(text)
144
+ } else if (c.type === 'approval') {
145
+ // Yes or no to this one call — a standing yes is saved from VS Code only.
146
+ if (!['allow', 'deny'].includes(c.decision)) return
147
+ await p.onMessage({ type: 'approval', id: c.id, decision: c.decision, feedback: c.decision === 'deny' ? cut(c.feedback, 2000) : undefined })
148
+ } else if (c.type === 'answer') {
149
+ await p.onMessage({ type: 'answer', id: c.id, ...(c.dismissed ? { dismissed: true } : { answers: Array.isArray(c.answers) ? c.answers : [] }) })
150
+ } else if (c.type === 'plan') {
151
+ // Approved from the browser, the mode is the one the conversation had before planning.
152
+ await p.onMessage({ type: 'planAnswer', id: c.id, approved: !!c.approved, feedback: c.approved ? undefined : cut(c.feedback, 4000) })
153
+ } else if (c.type === 'stop') {
154
+ p.abort?.abort()
155
+ }
156
+ this.schedule()
157
+ }
158
+
159
+ /** Ends the sharing: the link stops working for everyone. */
160
+ stop() {
161
+ this.active = false
162
+ clearTimeout(this.retry)
163
+ clearTimeout(this.pending)
164
+ this.restorePost?.()
165
+ try { if (this.ws?.readyState === 1) this.ws.send(JSON.stringify({ type: 'end' })) } catch { /* closing anyway */ }
166
+ setTimeout(() => { try { this.ws?.close() } catch { /* gone */ } }, 200)
167
+ }
168
+ }
169
+
170
+ module.exports = { RemoteLink, snapshot, approvalOf }
package/ext/review.js ADDED
@@ -0,0 +1,126 @@
1
+ 'use strict'
2
+ // Reviewing a proposed change in the editor, change by change, as Claude Code's diff does:
3
+ // above each change in the proposal, "Keep" and "Undo this change" (which puts the original
4
+ // lines back), and at the top "Accept and apply" / "Reject all" — the same answers as in the
5
+ // panel. The same at the cursor from the context menu. Over 100 changes, whole-file only.
6
+ const vscode = require('vscode')
7
+ const { hunkRanges, applyHunks } = require('./diff')
8
+
9
+ const MAX_HUNKS = 100
10
+
11
+ /** The pending approval whose proposal is open in this file, and the panel asking. */
12
+ function findReview(registry, file) {
13
+ for (const panel of registry.all()) {
14
+ for (const [id, pending] of panel.approvals || []) {
15
+ if (pending.review && pending.review === file) return { panel, id, pending }
16
+ }
17
+ }
18
+ return null
19
+ }
20
+
21
+ /** The changes of the proposal as it is now, against the file as it was. */
22
+ function changes(review, doc) {
23
+ return hunkRanges(review.pending.request.before ?? '', doc.getText())
24
+ }
25
+
26
+ function register(context, registry) {
27
+ const changed = new vscode.EventEmitter()
28
+ const lenses = {
29
+ onDidChangeCodeLenses: changed.event,
30
+ provideCodeLenses(doc) {
31
+ const review = findReview(registry, doc.uri.fsPath)
32
+ if (!review) return []
33
+ const list = changes(review, doc)
34
+ const top = new vscode.Range(0, 0, 0, 0)
35
+ const out = [
36
+ new vscode.CodeLens(top, { title: '$(check) Accept and apply', command: 'aipictury.review.apply', arguments: [doc.uri] }),
37
+ new vscode.CodeLens(top, { title: '$(close) Reject all', command: 'aipictury.review.reject', arguments: [doc.uri] }),
38
+ ]
39
+ if (list.length > MAX_HUNKS) {
40
+ out.push(new vscode.CodeLens(top, { title: `${list.length} changes — too many to review one by one: accept or reject the whole file`, command: '' }))
41
+ return out
42
+ }
43
+ const kept = review.pending.kept || new Set()
44
+ for (const h of list) {
45
+ const at = new vscode.Range(Math.min(h.start, doc.lineCount - 1), 0, Math.min(h.start, doc.lineCount - 1), 0)
46
+ const sign = signature(review, doc, h)
47
+ out.push(new vscode.CodeLens(at, kept.has(sign)
48
+ ? { title: '$(pass-filled) Kept', command: 'aipictury.review.keepChange', arguments: [doc.uri, h.index] }
49
+ : { title: '$(check) Keep', command: 'aipictury.review.keepChange', arguments: [doc.uri, h.index] }))
50
+ out.push(new vscode.CodeLens(at, { title: '$(discard) Undo this change', command: 'aipictury.review.undoChange', arguments: [doc.uri, h.index] }))
51
+ }
52
+ return out
53
+ },
54
+ }
55
+ const add = (d) => context.subscriptions.push(d)
56
+ add(vscode.languages.registerCodeLensProvider({ scheme: 'file', pattern: '**/aipictury-review/**' }, lenses))
57
+ add(vscode.workspace.onDidChangeTextDocument((e) => { if (findReview(registry, e.document.uri.fsPath)) changed.fire() }))
58
+
59
+ /** The document and the change a command is about: given, or at the cursor. */
60
+ const target = (uri, index) => {
61
+ const editor = vscode.window.activeTextEditor
62
+ const doc = uri ? vscode.workspace.textDocuments.find((d) => d.uri.fsPath === uri.fsPath) : editor?.document
63
+ if (!doc) return null
64
+ const review = findReview(registry, doc.uri.fsPath)
65
+ if (!review) return null
66
+ const list = changes(review, doc)
67
+ let h = Number.isInteger(index) ? list[index] : null
68
+ if (!h && editor?.document === doc) {
69
+ const line = editor.selection.active.line
70
+ h = list.find((x) => line >= x.start && line <= Math.max(x.start, x.end)) || list.find((x) => x.start === line + 1 && x.end < x.start)
71
+ }
72
+ return { doc, review, list, h }
73
+ }
74
+
75
+ add(vscode.commands.registerCommand('aipictury.review.undoChange', async (uri, index) => {
76
+ const t = target(uri, index)
77
+ if (!t?.h) { vscode.window.showInformationMessage('Put the cursor on a proposed change to undo it.'); return false }
78
+ const before = t.review.pending.request.before ?? ''
79
+ const keep = new Set(t.list.map((h) => h.index).filter((i) => i !== t.h.index))
80
+ const text = applyHunks(before, t.doc.getText(), keep)
81
+ const edit = new vscode.WorkspaceEdit()
82
+ edit.replace(t.doc.uri, new vscode.Range(0, 0, t.doc.lineCount, 0), text)
83
+ await vscode.workspace.applyEdit(edit)
84
+ changed.fire()
85
+ return true
86
+ }))
87
+ add(vscode.commands.registerCommand('aipictury.review.keepChange', (uri, index) => {
88
+ const t = target(uri, index)
89
+ if (!t?.h) return false
90
+ const kept = (t.review.pending.kept ||= new Set())
91
+ const sign = signature(t.review, t.doc, t.h)
92
+ if (kept.has(sign)) kept.delete(sign); else kept.add(sign)
93
+ changed.fire()
94
+ return true
95
+ }))
96
+ const answer = (decision) => async (uri) => {
97
+ const doc = uri ? vscode.workspace.textDocuments.find((d) => d.uri.fsPath === uri.fsPath) : vscode.window.activeTextEditor?.document
98
+ const review = doc && findReview(registry, doc.uri.fsPath)
99
+ if (!review) return false
100
+ if (doc.isDirty) await doc.save()
101
+ review.panel.post({ type: 'approvalClosed', id: review.id })
102
+ await closeTabsOf(doc.uri.fsPath)
103
+ await review.panel.onMessage({ type: 'approval', id: review.id, decision })
104
+ return true
105
+ }
106
+ add(vscode.commands.registerCommand('aipictury.review.apply', answer('allow')))
107
+ add(vscode.commands.registerCommand('aipictury.review.reject', answer('deny')))
108
+ return { refresh: () => changed.fire() }
109
+ }
110
+
111
+ /** Closes every tab showing the proposal — its diff, or the file itself — before its copy is
112
+ * removed; not whichever editor happens to be active. */
113
+ async function closeTabsOf(file) {
114
+ const shows = (input) => (input instanceof vscode.TabInputTextDiff && input.modified.fsPath === file)
115
+ || (input instanceof vscode.TabInputText && input.uri.fsPath === file)
116
+ const tabs = vscode.window.tabGroups.all.flatMap((g) => g.tabs).filter((t) => shows(t.input))
117
+ if (tabs.length) await vscode.window.tabGroups.close(tabs, true)
118
+ }
119
+
120
+ /** A change's identity while the proposal is edited around it: its lines, not its number. */
121
+ function signature(review, doc, h) {
122
+ const lines = doc.getText().split('\n').slice(h.start, h.end + 1).join('\n')
123
+ return `${h.start - h.end}:${lines}`
124
+ }
125
+
126
+ module.exports = { register, findReview, MAX_HUNKS }
package/ext/sandbox.js ADDED
@@ -0,0 +1,79 @@
1
+ 'use strict'
2
+ // A sandbox for the agent's shell commands: they can write only inside the workspace (and the
3
+ // temp folders), and reach the network only when allowed — macOS's sandbox-exec, Linux's
4
+ // bubblewrap. Like Claude Code's: settings in VS Code or the settings files ("sandbox": {…},
5
+ // Claude Code's own included), commands that are left out of it (docker…), and a way out
6
+ // that always asks the user (outside_sandbox). No VS Code here: tests run it directly.
7
+ const fs = require('fs')
8
+ const os = require('os')
9
+ const path = require('path')
10
+ const { execFileSync } = require('child_process')
11
+ const { commandParts } = require('./permissions')
12
+
13
+ let found
14
+ /** Which sandbox this machine has: 'macos', 'linux' or null. */
15
+ function available() {
16
+ if (found !== undefined) return found
17
+ found = null
18
+ if (process.platform === 'darwin' && fs.existsSync('/usr/bin/sandbox-exec')) found = 'macos'
19
+ else if (process.platform === 'linux') {
20
+ try { execFileSync('bwrap', ['--version'], { stdio: 'ignore', timeout: 3000 }); found = 'linux' } catch { found = null }
21
+ }
22
+ return found
23
+ }
24
+
25
+ /** The sandbox settings: VS Code's (aipictury.sandbox.*) over the settings files' "sandbox". */
26
+ function settings(fromFiles = {}, fromVsCode = {}) {
27
+ const f = fromFiles || {}
28
+ const pick = (a, b, fallback) => (a !== undefined ? a : b !== undefined ? b : fallback)
29
+ return {
30
+ enabled: !!pick(fromVsCode.enabled, f.enabled, false),
31
+ allowNetwork: !!pick(fromVsCode.allowNetwork, f.network?.allow ?? f.allowNetwork, false),
32
+ autoAllow: !!pick(fromVsCode.autoAllow, f.autoAllowBashIfSandboxed, true),
33
+ excluded: [...new Set([...(f.excludedCommands || []), ...(fromVsCode.excludedCommands || [])])],
34
+ writable: [...new Set([...(f.writablePaths || []), ...(fromVsCode.writablePaths || [])])],
35
+ }
36
+ }
37
+
38
+ /** Is any part of this command one the user left out of the sandbox? */
39
+ function excluded(command, list) {
40
+ return commandParts(command).some((part) => list.some((p) => part === p || part.startsWith(`${p} `)))
41
+ }
42
+
43
+ const real = (p) => { try { return fs.realpathSync(p) } catch { return path.resolve(p) } }
44
+ const quote = (p) => `"${String(p).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`
45
+
46
+ /** The macOS profile: everything allowed but writes outside the workspace and temp folders,
47
+ * and (without allowNetwork) connections beyond this machine. */
48
+ function macProfile({ root, writable = [], network = false }) {
49
+ const places = [real(root), real(os.tmpdir()), '/private/tmp', '/private/var/folders', ...writable.map((w) => real(w.replace(/^~/, os.homedir())))]
50
+ return ['(version 1)', '(allow default)', '(deny file-write*)',
51
+ `(allow file-write* ${places.map((p) => `(subpath ${quote(p)})`).join(' ')} (subpath "/dev"))`,
52
+ ...(network ? [] : ['(deny network-outbound)', '(allow network-outbound (remote unix-socket))', '(allow network-outbound (remote ip "localhost:*"))']),
53
+ ].join('\n')
54
+ }
55
+
56
+ /** How to start `script` sandboxed: { file, args }, or null when this machine has no sandbox. */
57
+ function wrap(shell, script, { root, writable = [], network = false } = {}) {
58
+ const kind = available()
59
+ if (kind === 'macos') return { file: '/usr/bin/sandbox-exec', args: ['-p', macProfile({ root, writable, network }), shell, '-c', script] }
60
+ if (kind === 'linux') {
61
+ const binds = [real(root), real(os.tmpdir()), ...writable.map((w) => real(w.replace(/^~/, os.homedir())))]
62
+ .flatMap((p) => ['--bind', p, p])
63
+ return { file: 'bwrap', args: ['--ro-bind', '/', '/', '--dev', '/dev', '--proc', '/proc', ...binds,
64
+ ...(network ? [] : ['--unshare-net']), '--die-with-parent', '--chdir', real(root), '--', shell, '-c', script] }
65
+ }
66
+ return null
67
+ }
68
+
69
+ /** What a sandbox refusal looks like in a command's output. */
70
+ const REFUSED = /Operation not permitted|Read-only file system|sandbox-exec: |deny\(\d\)|Could not resolve host|Network is unreachable|nodename nor servname/i
71
+
72
+ /** A note for the model when the sandbox stopped a command. */
73
+ function note(output, { network }) {
74
+ if (!REFUSED.test(String(output || ''))) return ''
75
+ return `\n[The sandbox may have stopped this: commands can write only inside the workspace${network ? '' : ' and cannot reach the network'}. `
76
+ + 'If that is needed, run it again with outside_sandbox: true — the user will be asked first.]'
77
+ }
78
+
79
+ module.exports = { available, settings, excluded, macProfile, wrap, note, REFUSED }
@@ -0,0 +1,87 @@
1
+ 'use strict'
2
+ // Prompts on a schedule, for one conversation: a cron expression ("*/15 9-17 * * 1-5") or an
3
+ // interval ("10m", "2h"), recurring or once — /loop, the agent's cron tools and its wake-ups.
4
+ // Pure: the time is given, so the tests use a clock of their own.
5
+ const MINUTE = 60_000
6
+
7
+ /** "10m", "2h", "90s", "every 5 minutes", "1d" → milliseconds, or null. */
8
+ function interval(text) {
9
+ const m = /^(?:every\s+)?(\d+(?:\.\d+)?)\s*(s|sec|secs|seconds?|m|min|mins|minutes?|h|hr|hrs|hours?|d|days?)$/i.exec(String(text || '').trim())
10
+ if (!m) return null
11
+ const n = Number(m[1])
12
+ const unit = m[2][0].toLowerCase()
13
+ const ms = n * { s: 1000, m: MINUTE, h: 60 * MINUTE, d: 24 * 60 * MINUTE }[unit]
14
+ return ms >= MINUTE ? ms : null // at most once a minute
15
+ }
16
+
17
+ /** One cron field ("*", "5", "1-5", "*\/15", "1,3,5", "9-17/2") → the set of values it allows. */
18
+ function field(text, min, max) {
19
+ const out = new Set()
20
+ for (const part of String(text).split(',')) {
21
+ const m = /^(\*|\d+(?:-\d+)?)(?:\/(\d+))?$/.exec(part.trim())
22
+ if (!m) throw new Error(`"${part}" is not a cron field`)
23
+ let [lo, hi] = m[1] === '*' ? [min, max] : m[1].split('-').map(Number)
24
+ if (hi === undefined) hi = m[2] ? max : lo
25
+ const step = Number(m[2] || 1)
26
+ if (lo < min || hi > max || lo > hi || step < 1) throw new Error(`"${part}" is outside ${min}-${max}`)
27
+ for (let v = lo; v <= hi; v += step) out.add(v)
28
+ }
29
+ return out
30
+ }
31
+
32
+ /** A 5-field cron expression (minute hour day-of-month month day-of-week), in local time. */
33
+ function cron(text) {
34
+ const parts = String(text || '').trim().split(/\s+/)
35
+ if (parts.length !== 5) throw new Error('a cron expression has 5 fields: minute hour day month weekday')
36
+ const [mi, h, dom, mo, dow] = parts
37
+ const spec = { minutes: field(mi, 0, 59), hours: field(h, 0, 23), days: field(dom, 1, 31), months: field(mo, 1, 12),
38
+ weekdays: new Set([...field(dow, 0, 7)].map((d) => d % 7)), anyDay: dom === '*', anyWeekday: dow === '*' }
39
+ return spec
40
+ }
41
+
42
+ /** The first time after `after` (ms) that a cron spec allows, or null within a year. */
43
+ function nextCron(spec, after) {
44
+ const t = new Date(after)
45
+ t.setSeconds(0, 0)
46
+ t.setMinutes(t.getMinutes() + 1)
47
+ for (let i = 0; i < 366 * 24 * 60; i += 1) {
48
+ // Day of month and day of week: either matches when both are restricted, as cron does.
49
+ const dayOk = spec.anyDay && spec.anyWeekday ? true
50
+ : spec.anyDay ? spec.weekdays.has(t.getDay()) : spec.anyWeekday ? spec.days.has(t.getDate())
51
+ : spec.days.has(t.getDate()) || spec.weekdays.has(t.getDay())
52
+ if (spec.months.has(t.getMonth() + 1) && dayOk && spec.hours.has(t.getHours()) && spec.minutes.has(t.getMinutes())) return t.getTime()
53
+ t.setMinutes(t.getMinutes() + 1)
54
+ }
55
+ return null
56
+ }
57
+
58
+ /** A new job: { id, prompt, schedule, recurring, next }. `schedule` is an interval or a cron line. */
59
+ function job(jobs, { schedule, prompt, recurring = true, now = Date.now(), kind = 'cron' }) {
60
+ const text = String(prompt || '').trim()
61
+ if (!text) throw new Error('a scheduled prompt cannot be empty')
62
+ if (jobs.length >= 20) throw new Error('at most 20 scheduled prompts in a conversation — delete some first')
63
+ const every = interval(schedule)
64
+ const spec = every ? null : cron(schedule)
65
+ const next = every ? now + every : nextCron(spec, now)
66
+ if (!next) throw new Error(`"${schedule}" never comes in the next year`)
67
+ const id = `job_${Math.max(0, ...jobs.map((j) => Number(String(j.id).replace('job_', '')) || 0)) + 1}`
68
+ return { id, prompt: text, schedule: String(schedule).trim(), recurring: !!recurring, next, kind, created: now }
69
+ }
70
+
71
+ /** The jobs due at `now`, and the list as it is after them (recurring ones moved on). */
72
+ function due(jobs, now = Date.now()) {
73
+ const ready = jobs.filter((j) => j.next <= now)
74
+ const rest = jobs.filter((j) => j.next > now || j.recurring).map((j) => {
75
+ if (j.next > now) return j
76
+ const every = interval(j.schedule)
77
+ return { ...j, next: every ? Math.max(now, j.next) + every - ((Math.max(now, j.next) - j.next) % every) : nextCron(cron(j.schedule), now) }
78
+ }).filter((j) => j.next)
79
+ return { ready, jobs: rest }
80
+ }
81
+
82
+ function describe(j, now = Date.now()) {
83
+ const mins = Math.max(0, Math.round((j.next - now) / MINUTE))
84
+ return `${j.id} — ${j.recurring ? `every ${j.schedule}` : `once (${j.schedule})`}, next in ${mins < 60 ? `${mins} min` : `${Math.round(mins / 60)} h`}: ${j.prompt.slice(0, 120)}`
85
+ }
86
+
87
+ module.exports = { interval, cron, nextCron, job, due, describe }