@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
@@ -0,0 +1,131 @@
1
+ 'use strict'
2
+ // Firestore over its REST API, for the extension and the CLI: no SDK, no dependency. Documents
3
+ // are plain objects here and Firestore's typed values on the wire. The ID token comes from the
4
+ // caller (account.js), so every request is the signed-in user's — the security rules decide
5
+ // what it may touch. With `emulator` ("127.0.0.1:8299") it talks to the local emulator.
6
+ // No VS Code here.
7
+
8
+ const BATCH_MAX = 400 // writes in one commit (Firestore allows 500)
9
+
10
+ /** A JavaScript value as a Firestore Value. */
11
+ function encode(v) {
12
+ if (v === null || v === undefined) return { nullValue: null }
13
+ if (typeof v === 'boolean') return { booleanValue: v }
14
+ if (typeof v === 'number') return Number.isInteger(v) ? { integerValue: String(v) } : { doubleValue: v }
15
+ if (typeof v === 'string') return { stringValue: v }
16
+ if (v instanceof Date) return { timestampValue: v.toISOString() }
17
+ if (Array.isArray(v)) return { arrayValue: v.length ? { values: v.map(encode) } : {} }
18
+ if (typeof v === 'object') return { mapValue: { fields: encodeFields(v) } }
19
+ throw new Error(`cannot store a ${typeof v}`)
20
+ }
21
+
22
+ function encodeFields(obj) {
23
+ const out = {}
24
+ for (const [k, v] of Object.entries(obj)) if (v !== undefined) out[k] = encode(v)
25
+ return out
26
+ }
27
+
28
+ /** A Firestore Value as a JavaScript value. */
29
+ function decode(v) {
30
+ if (!v || 'nullValue' in v) return null
31
+ if ('booleanValue' in v) return v.booleanValue
32
+ if ('integerValue' in v) return Number(v.integerValue)
33
+ if ('doubleValue' in v) return v.doubleValue
34
+ if ('stringValue' in v) return v.stringValue
35
+ if ('timestampValue' in v) return v.timestampValue
36
+ if ('arrayValue' in v) return (v.arrayValue.values || []).map(decode)
37
+ if ('mapValue' in v) return decodeFields(v.mapValue.fields || {})
38
+ if ('bytesValue' in v) return v.bytesValue
39
+ if ('referenceValue' in v) return v.referenceValue
40
+ if ('geoPointValue' in v) return v.geoPointValue
41
+ return null
42
+ }
43
+
44
+ function decodeFields(fields) {
45
+ const out = {}
46
+ for (const [k, v] of Object.entries(fields || {})) out[k] = decode(v)
47
+ return out
48
+ }
49
+
50
+ class FirestoreError extends Error {
51
+ constructor(status, message, code) { super(message); this.status = status; this.code = code }
52
+ }
53
+
54
+ class Firestore {
55
+ /** { projectId, getToken: async () => idToken, emulator?: "host:port", fetch? } */
56
+ constructor({ projectId, getToken, emulator = null, fetchImpl = globalThis.fetch, timeoutMs = 30_000 }) {
57
+ this.projectId = projectId
58
+ this.getToken = getToken
59
+ this.fetch = fetchImpl
60
+ this.timeoutMs = timeoutMs
61
+ this.root = `projects/${projectId}/databases/(default)/documents`
62
+ this.base = emulator ? `http://${emulator}/v1` : 'https://firestore.googleapis.com/v1'
63
+ }
64
+
65
+ async request(method, url, body) {
66
+ const token = await this.getToken()
67
+ const response = await this.fetch(url, {
68
+ method, body: body === undefined ? undefined : JSON.stringify(body),
69
+ headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
70
+ signal: AbortSignal.timeout(this.timeoutMs),
71
+ })
72
+ const text = await response.text()
73
+ let data
74
+ try { data = text ? JSON.parse(text) : null } catch { data = null }
75
+ if (!response.ok) {
76
+ const err = Array.isArray(data) ? data[0]?.error : data?.error
77
+ const said = String(err?.message || '').trim().split('\n')[0] || (response.status === 403 ? 'permission denied' : response.status === 401 ? 'not signed in' : '')
78
+ throw new FirestoreError(response.status, said || `Firestore answered ${response.status}`, err?.status)
79
+ }
80
+ return data
81
+ }
82
+
83
+ url(path) { return `${this.base}/${this.root}/${path.split('/').map(encodeURIComponent).join('/')}` }
84
+
85
+ /** The document at `path` ("users/u1/projects/p1"), or null. */
86
+ async get(path) {
87
+ try { return decodeFields((await this.request('GET', this.url(path))).fields) } catch (error) {
88
+ if (error.status === 404) return null
89
+ throw error
90
+ }
91
+ }
92
+
93
+ /** Writes the whole document (creates or replaces). */
94
+ async set(path, data) {
95
+ await this.request('PATCH', this.url(path), { fields: encodeFields(data) })
96
+ }
97
+
98
+ /** Changes only the given fields. */
99
+ async update(path, data) {
100
+ const mask = Object.keys(data).map((k) => `updateMask.fieldPaths=${encodeURIComponent(k)}`).join('&')
101
+ await this.request('PATCH', `${this.url(path)}?${mask}`, { fields: encodeFields(data) })
102
+ }
103
+
104
+ async delete(path) {
105
+ try { await this.request('DELETE', this.url(path)) } catch (error) { if (error.status !== 404) throw error }
106
+ }
107
+
108
+ /** Every document of a collection: [{ id, data }]. */
109
+ async list(collectionPath, { pageSize = 300 } = {}) {
110
+ const out = []
111
+ let token = ''
112
+ do {
113
+ const data = await this.request('GET', `${this.url(collectionPath)}?pageSize=${pageSize}${token ? `&pageToken=${encodeURIComponent(token)}` : ''}`)
114
+ for (const d of data?.documents || []) out.push({ id: d.name.split('/').pop(), data: decodeFields(d.fields) })
115
+ token = data?.nextPageToken || ''
116
+ } while (token)
117
+ return out
118
+ }
119
+
120
+ /** Several writes, all or nothing (in commits of up to 400). Each: { set: [path, data] } or { delete: path }. */
121
+ async batch(writes) {
122
+ for (let i = 0; i < writes.length; i += BATCH_MAX) {
123
+ const part = writes.slice(i, i + BATCH_MAX).map((w) => (w.delete
124
+ ? { delete: `${this.root}/${w.delete}` }
125
+ : { update: { name: `${this.root}/${w.set[0]}`, fields: encodeFields(w.set[1]) } }))
126
+ await this.request('POST', `${this.base}/${this.root}:commit`, { writes: part })
127
+ }
128
+ }
129
+ }
130
+
131
+ module.exports = { Firestore, FirestoreError, encode, decode, encodeFields, decodeFields }
package/ext/gpuRun.js ADDED
@@ -0,0 +1,77 @@
1
+ 'use strict'
2
+ // The GPU, held for one run at a time (the server's gpu.py): an agent run — from the prompt to
3
+ // its last step, helpers and tools included — names itself on every call it makes to the GPU
4
+ // server (X-AIPictury-Client, -Run, -Label: account.js serverFetch adds them), takes the GPU when
5
+ // it starts, keeps it alive while its tools work between model calls, and lets go when it ends.
6
+ // Another app holding the GPU: the run does not start, and says who has it.
7
+ const crypto = require('crypto')
8
+ const { AsyncLocalStorage } = require('async_hooks')
9
+
10
+ const store = new AsyncLocalStorage()
11
+ const BEAT = 30_000
12
+
13
+ /** The headers of the run this code is part of (none outside a run). */
14
+ function headers() {
15
+ const r = store.getStore()
16
+ if (!r) return {}
17
+ return { 'X-AIPictury-Client': r.client, 'X-AIPictury-Run': r.run, 'X-AIPictury-Label': encodeURIComponent(r.label || '') }
18
+ }
19
+
20
+ class GpuBusyError extends Error {
21
+ constructor(message, gpu) {
22
+ super(message)
23
+ this.name = 'GpuBusyError'
24
+ this.busy = true
25
+ this.gpu = gpu
26
+ }
27
+ }
28
+
29
+ /**
30
+ * fn() as a run holding the GPU of `server`. `client`: 'vscode' | 'cli'. A GPU held by another
31
+ * run: GpuBusyError before anything is done. A server without holds (older): fn() as before.
32
+ */
33
+ async function holding({ server, client, label = '' }, fn) {
34
+ const run = { client, run: `${client}-${crypto.randomUUID()}`, label: String(label).replace(/\s+/g, ' ').slice(0, 100) }
35
+ return store.run(run, async () => {
36
+ const h = headers()
37
+ let held = false
38
+ try {
39
+ const r = await fetch(`${server}/api/gpu/hold`, { method: 'POST', headers: h, signal: AbortSignal.timeout(10_000) })
40
+ if (r.status === 423) {
41
+ const body = await r.json().catch(() => ({}))
42
+ throw new GpuBusyError(body.detail || 'the GPU is in use by another app', body.gpu)
43
+ }
44
+ held = r.ok
45
+ } catch (error) {
46
+ if (error instanceof GpuBusyError) throw error
47
+ // No answer about holds (an older server, a moment's network trouble): the work goes on —
48
+ // its calls still carry the run, so the server holds the GPU for it if it can.
49
+ }
50
+ const beat = held ? setInterval(() => {
51
+ fetch(`${server}/api/gpu/hold`, { method: 'POST', headers: h, signal: AbortSignal.timeout(10_000) }).catch(() => {})
52
+ }, BEAT) : null
53
+ try {
54
+ return await fn()
55
+ } finally {
56
+ if (beat) clearInterval(beat)
57
+ if (held) await fetch(`${server}/api/gpu/hold`, { method: 'DELETE', headers: h, signal: AbortSignal.timeout(10_000) }).catch(() => {})
58
+ }
59
+ })
60
+ }
61
+
62
+ /** Who holds the GPU now: { lanes: [{ hold }] } — null when the server cannot say. */
63
+ async function state(server) {
64
+ try {
65
+ const r = await fetch(`${server}/api/gpu`, { signal: AbortSignal.timeout(8000) })
66
+ return r.ok ? await r.json() : null
67
+ } catch { return null }
68
+ }
69
+
70
+ /** The hold of another run on every GPU (all busy), or null (one is free, or it is this run's). */
71
+ function busyFor(gpu, mine = null) {
72
+ const lanes = gpu?.lanes || []
73
+ if (!lanes.length || lanes.some((l) => !l.hold || l.hold.run === mine)) return null
74
+ return lanes[0].hold
75
+ }
76
+
77
+ module.exports = { holding, headers, state, busyFor, GpuBusyError, store }
@@ -0,0 +1,76 @@
1
+ 'use strict'
2
+ // Where the work goes: the GPU server — one rule for every part of the extension and the CLI.
3
+ // 1. the address set here (`aipictury.server`: a user or workspace setting, the CLI's --server
4
+ // or AIPICTURY_URL) when there is one — this machine's own choice;
5
+ // 2. else the one chosen with the AIPictury account (aipictury.com keeps it in the account's
6
+ // preferences as `gpuServer`: a LAN and a WAN address, and which to prefer) — the LAN one
7
+ // when it answers, as the web app does;
8
+ // 3. else none: the panel says where to choose it.
9
+ const vscode = require('vscode')
10
+
11
+ let chosen = null // the account's { lan, wan, prefer }
12
+ let active = '' // the address picked from it
13
+ const listeners = new Set()
14
+
15
+ const clean = (u) => String(u || '').trim().replace(/\/+$/, '')
16
+
17
+ /** The address set on this machine, or ''. */
18
+ function explicit() {
19
+ return clean(vscode.workspace.getConfiguration('aipictury').get('server', ''))
20
+ }
21
+
22
+ /** The GPU server's address, or '' when none is set anywhere. */
23
+ function url() {
24
+ return explicit() || active
25
+ }
26
+
27
+ /** Which of the account's addresses is in use: 'lan' | 'wan' | null. */
28
+ function route() {
29
+ if (explicit() || !chosen || !active) return null
30
+ return active === chosen.lan ? 'lan' : 'wan'
31
+ }
32
+
33
+ /** Where the address came from: 'setting' | 'account' | null. */
34
+ function source() {
35
+ return explicit() ? 'setting' : active ? 'account' : null
36
+ }
37
+
38
+ async function answers(base, ms) {
39
+ try {
40
+ const r = await fetch(`${base}/api/health`, { signal: AbortSignal.timeout(ms) })
41
+ return r.ok && (await r.json()).app === 'AIPictury'
42
+ } catch { return false }
43
+ }
44
+
45
+ /** Picks from the account's choice: the preferred route that answers; else the WAN address (so
46
+ * what goes wrong is said about it), else the LAN one. */
47
+ async function resolve() {
48
+ const before = active
49
+ if (!chosen) active = ''
50
+ else {
51
+ const order = chosen.prefer === 'wan' ? ['wan', 'lan'] : ['lan', 'wan']
52
+ let picked = ''
53
+ for (const route of order) {
54
+ if (chosen[route] && await answers(chosen[route], route === 'lan' ? 1500 : 5000)) { picked = chosen[route]; break }
55
+ }
56
+ active = picked || chosen.wan || chosen.lan || ''
57
+ }
58
+ if (active !== before) for (const fn of listeners) { try { fn(url()) } catch { /* a listener's own problem */ } }
59
+ return url()
60
+ }
61
+
62
+ /** The account's choice (or null: signed out, none kept), then the address resolved from it. */
63
+ async function adopt(choice) {
64
+ const lan = clean(choice?.lan)
65
+ const wan = clean(choice?.wan)
66
+ chosen = lan || wan ? { lan, wan, prefer: ['lan', 'wan'].includes(choice?.prefer) ? choice.prefer : 'auto' } : null
67
+ return resolve()
68
+ }
69
+
70
+ /** fn(url) when the account's address changes (a setting changed is VS Code's own event). */
71
+ function onChange(fn) {
72
+ listeners.add(fn)
73
+ return { dispose: () => listeners.delete(fn) }
74
+ }
75
+
76
+ module.exports = { url, explicit, source, route, adopt, resolve, onChange }
package/ext/hooks.js ADDED
@@ -0,0 +1,153 @@
1
+ 'use strict'
2
+ // Hooks: your commands, run at moments of the agent's work — in Claude Code's format, from
3
+ // the same settings files (Claude Code's own included), so hooks written for it work here:
4
+ //
5
+ // "hooks": { "PreToolUse": [ { "matcher": "Bash|Edit", "hooks": [ { "type": "command",
6
+ // "command": "~/bin/check.sh", "timeout": 30 } ] } ] }
7
+ //
8
+ // A hook gets the event as JSON on stdin (session_id, cwd, hook_event_name, tool_name,
9
+ // tool_input, tool_response, prompt…) and CLAUDE_PROJECT_DIR / AIPICTURY_PROJECT_DIR. It
10
+ // answers with its exit code — 2 blocks, and what it wrote to stderr is the reason — or with
11
+ // JSON on stdout: decision "block", hookSpecificOutput.permissionDecision (allow, deny, ask),
12
+ // additionalContext, continue: false with a stopReason. Any other failure is reported and the
13
+ // work goes on. No VS Code here: tests run it directly, and so will the CLI.
14
+ const { spawn } = require('child_process')
15
+ const crypto = require('crypto')
16
+ const path = require('path')
17
+ const prefs = require('./prefs')
18
+
19
+ const EVENTS = ['PreToolUse', 'PostToolUse', 'UserPromptSubmit', 'Stop', 'SubagentStop', 'SessionStart',
20
+ 'SessionEnd', 'Notification', 'PreCompact']
21
+ const DEFAULT_TIMEOUT = 60
22
+ const OUTPUT_CAP = 100_000
23
+
24
+ // Claude Code's names for our tools, so a matcher like "Bash|Edit|Write" matches ours.
25
+ const CLAUDE_NAME = {
26
+ run_command: 'Bash', monitor: 'Monitor', powershell: 'PowerShell', read_file: 'Read', write_file: 'Write', edit_file: 'Edit', notebook_edit: 'NotebookEdit',
27
+ grep: 'Grep', glob: 'Glob', list_files: 'LS', fetch_url: 'WebFetch', web_search: 'WebSearch', task: 'Task',
28
+ todo_write: 'TodoWrite', exit_plan_mode: 'ExitPlanMode', enter_plan_mode: 'EnterPlanMode', ask_user: 'AskUserQuestion',
29
+ tool_search: 'ToolSearch', list_mcp_resources: 'ListMcpResourcesTool', read_mcp_resource: 'ReadMcpResourceTool',
30
+ }
31
+ const claudeName = (name) => CLAUDE_NAME[name] || name
32
+
33
+ /** A call's arguments as Claude Code's tools take them (absolute file_path, old_string…),
34
+ * with ours kept beside them. */
35
+ function toolInput(name, args = {}, root) {
36
+ const abs = (p) => (p ? path.resolve(root || '/', p) : undefined)
37
+ const extra = {}
38
+ if (args.path && ['read_file', 'write_file', 'edit_file', 'notebook_edit'].includes(name)) extra.file_path = abs(args.path)
39
+ if (name === 'edit_file') {
40
+ if (args.old_text !== undefined) extra.old_string = args.old_text
41
+ if (args.new_text !== undefined) extra.new_string = args.new_text
42
+ }
43
+ if (name === 'notebook_edit') extra.notebook_path = abs(args.path)
44
+ if (name === 'fetch_url') extra.prompt = args.query
45
+ return { ...args, ...extra }
46
+ }
47
+
48
+ /** The hook groups for an event whose matcher covers this tool (or every group, for events
49
+ * without tools). A matcher is a regular expression; empty or "*" is everything. */
50
+ function matching(configured, event, subject) {
51
+ return (configured[event] || []).filter((group) => {
52
+ const m = String(group.matcher ?? '').trim()
53
+ if (!m || m === '*' || subject === undefined) return true
54
+ try { return new RegExp(`^(?:${m})$`).test(subject) } catch { return m === subject }
55
+ })
56
+ }
57
+
58
+ /** Runs one command hook: resolves to { code, stdout, stderr, timedOut, error }. */
59
+ function runOne(command, input, { cwd, env, timeout = DEFAULT_TIMEOUT, signal } = {}) {
60
+ return new Promise((resolve) => {
61
+ let child
62
+ try {
63
+ child = spawn(process.platform === 'win32' ? 'cmd' : '/bin/sh', process.platform === 'win32' ? ['/c', command] : ['-c', command],
64
+ { cwd, env, detached: process.platform !== 'win32', stdio: ['pipe', 'pipe', 'pipe'] })
65
+ } catch (error) { resolve({ code: null, stdout: '', stderr: '', error: error.message }); return }
66
+ let stdout = ''
67
+ let stderr = ''
68
+ let timedOut = false
69
+ const kill = () => { try { process.kill(-child.pid, 'SIGKILL') } catch { try { child.kill('SIGKILL') } catch { /* gone */ } } }
70
+ const timer = setTimeout(() => { timedOut = true; kill() }, Math.max(1, Number(timeout) || DEFAULT_TIMEOUT) * 1000)
71
+ const onAbort = () => kill()
72
+ signal?.addEventListener('abort', onAbort, { once: true })
73
+ child.stdout.on('data', (c) => { if (stdout.length < OUTPUT_CAP) stdout += c })
74
+ child.stderr.on('data', (c) => { if (stderr.length < OUTPUT_CAP) stderr += c })
75
+ child.on('error', (error) => { clearTimeout(timer); resolve({ code: null, stdout, stderr, error: error.message }) })
76
+ child.on('close', (code) => {
77
+ clearTimeout(timer)
78
+ signal?.removeEventListener('abort', onAbort)
79
+ resolve({ code: timedOut ? null : code, stdout, stderr, timedOut })
80
+ })
81
+ child.stdin.on('error', () => { /* a hook that does not read its input */ })
82
+ child.stdin.end(JSON.stringify(input))
83
+ })
84
+ }
85
+
86
+ /** What the hooks of one event decided, all of them together. */
87
+ function combine(event, results) {
88
+ const out = { block: false, reasons: [], context: [], permission: null, permissionReasons: [], stop: false,
89
+ stopReason: '', errors: [], ran: results.length }
90
+ const order = { deny: 3, ask: 2, allow: 1 }
91
+ for (const { hook, r } of results) {
92
+ const where = `${hook.command.slice(0, 60)}`
93
+ if (r.error || r.timedOut) { out.errors.push(`${where}: ${r.timedOut ? `no answer in ${hook.timeout || DEFAULT_TIMEOUT} s` : r.error}`); continue }
94
+ let json = null
95
+ if (r.code === 0 && r.stdout.trim().startsWith('{')) { try { json = JSON.parse(r.stdout) } catch { json = null } }
96
+ if (r.code === 2) {
97
+ out.block = true
98
+ out.reasons.push(r.stderr.trim() || `blocked by ${where}`)
99
+ continue
100
+ }
101
+ if (r.code !== 0) { out.errors.push(`${where}: exit ${r.code}${r.stderr.trim() ? ` — ${r.stderr.trim().slice(0, 300)}` : ''}`); continue }
102
+ if (!json) {
103
+ // Plain output: for these two events it is context the model is given.
104
+ if (['UserPromptSubmit', 'SessionStart'].includes(event) && r.stdout.trim()) out.context.push(r.stdout.trim())
105
+ continue
106
+ }
107
+ if (json.continue === false) { out.stop = true; out.stopReason = json.stopReason || out.stopReason }
108
+ if (json.decision === 'block') { out.block = true; out.reasons.push(json.reason || `blocked by ${where}`) }
109
+ const specific = json.hookSpecificOutput || {}
110
+ const decision = specific.permissionDecision || (json.decision === 'approve' ? 'allow' : null)
111
+ if (decision && order[decision] > (order[out.permission] || 0)) out.permission = decision
112
+ if (decision) out.permissionReasons.push(specific.permissionDecisionReason || json.reason || '')
113
+ if (specific.additionalContext) out.context.push(String(specific.additionalContext))
114
+ }
115
+ return out
116
+ }
117
+
118
+ /** A fingerprint of the project's own hooks: trust given to them is for these exactly. */
119
+ function fingerprint(groups) {
120
+ return crypto.createHash('sha256').update(JSON.stringify(groups.map((g) => ({ file: g.file, matcher: g.matcher, hooks: g.hooks })))).digest('hex').slice(0, 16)
121
+ }
122
+
123
+ /**
124
+ * Runs the hooks of `event`. `trust(groups)` is asked before hooks from the project's own files
125
+ * run (yours, at the user level, always run); `subject` is the tool name for tool events.
126
+ * Resolves to what they decided together (combine).
127
+ */
128
+ async function fire(event, payload, { folder, subject, sessionId = '', trust = async () => true, signal, log } = {}) {
129
+ const configured = { ...(prefs.load(folder).merged.hooks || {}) }
130
+ // The plugins' hooks (hooks/hooks.json): installed by the user, so trusted like theirs.
131
+ try {
132
+ for (const [ev, groups] of Object.entries(require('./plugins').hooks(folder))) configured[ev] = [...(configured[ev] || []), ...groups.map((g) => ({ ...g, level: 'user' }))]
133
+ } catch { /* no plugins */ }
134
+ const groups = matching(configured, event, subject === undefined ? undefined : claudeName(subject))
135
+ if (!groups.length) return combine(event, [])
136
+ const fromProject = groups.filter((g) => g.level !== 'user')
137
+ const trusted = !fromProject.length || await trust(fromProject, fingerprint(fromProject))
138
+ const usable = trusted ? groups : groups.filter((g) => g.level === 'user')
139
+ // The same command twice (Claude Code's file and ours) runs once.
140
+ const seen = new Set()
141
+ const hooks = usable.flatMap((g) => (g.hooks || []).filter((h) => h.type === 'command' && h.command))
142
+ .filter((h) => !seen.has(h.command) && seen.add(h.command))
143
+ const env = { ...process.env, CLAUDE_PROJECT_DIR: folder || '', AIPICTURY_PROJECT_DIR: folder || '' }
144
+ const input = { session_id: sessionId, cwd: folder || process.cwd(), hook_event_name: event, ...payload }
145
+ const results = await Promise.all(hooks.map(async (hook) => ({ hook, r: await runOne(hook.command, input,
146
+ { cwd: folder || process.cwd(), env, timeout: hook.timeout, signal }) })))
147
+ const out = combine(event, results)
148
+ if (!trusted) out.errors.push('hooks from this project were not run: not trusted')
149
+ for (const e of out.errors) log?.(`${event} hook: ${e}`)
150
+ return out
151
+ }
152
+
153
+ module.exports = { fire, runOne, combine, matching, toolInput, claudeName, fingerprint, EVENTS }
package/ext/log.js ADDED
@@ -0,0 +1,29 @@
1
+ 'use strict'
2
+ // The extension's log: View → Output → "AIPictury", or the "AIPictury: Show Logs" command.
3
+ // What is worth reading after something went wrong — a turn, a failure, a stall, a setting
4
+ // that could not be written — never the conversation itself.
5
+ const vscode = require('vscode')
6
+
7
+ let channel = null
8
+ function out() {
9
+ if (!channel) {
10
+ try { channel = vscode.window.createOutputChannel('AIPictury', { log: true }) } catch { channel = null }
11
+ }
12
+ return channel
13
+ }
14
+
15
+ // The last lines, kept here too: VS Code offers no way to read an output channel back, and a
16
+ // problem report needs them.
17
+ const recent = []
18
+ const KEEP = 400
19
+
20
+ const write = (level) => (...parts) => {
21
+ const text = parts.map((p) => (p instanceof Error ? p.stack || p.message : typeof p === 'string' ? p : JSON.stringify(p))).join(' ')
22
+ recent.push(`${new Date().toISOString()} [${level}] ${text}`)
23
+ if (recent.length > KEEP) recent.splice(0, recent.length - KEEP)
24
+ const c = out()
25
+ if (c && typeof c[level] === 'function') c[level](text)
26
+ else if (c) c.appendLine(`[${level}] ${text}`)
27
+ }
28
+
29
+ module.exports = { tail: () => [...recent], info: write('info'), warn: write('warn'), error: write('error'), show: () => out()?.show(true), channel: out }
package/ext/lsp.js ADDED
@@ -0,0 +1,122 @@
1
+ 'use strict'
2
+ // Code intelligence from the language servers VS Code already runs — Claude Code's LSP
3
+ // tool, without installing anything: where a symbol is defined, who uses it, its type,
4
+ // what a file declares, who calls a function. A model finds a column badly, so a
5
+ // position may be given as a line and the symbol's name on it.
6
+
7
+ const vscode = require('vscode')
8
+ const { resolve, rel } = require('./paths')
9
+
10
+ const OPS = ['definition', 'references', 'hover', 'type_definition', 'implementations',
11
+ 'document_symbols', 'workspace_symbols', 'incoming_calls', 'outgoing_calls']
12
+ const CAP = 60
13
+
14
+ const KIND = Object.fromEntries(Object.entries(vscode.SymbolKind || {}).map(([k, v]) => [v, k.toLowerCase()]))
15
+
16
+ async function lineText(uri, line) {
17
+ try { return (await vscode.workspace.openTextDocument(uri)).lineAt(line).text.trim() } catch { return '' }
18
+ }
19
+
20
+ async function where(loc) {
21
+ const uri = loc.targetUri || loc.uri
22
+ const range = loc.targetSelectionRange || loc.targetRange || loc.range
23
+ const line = range.start.line
24
+ let p
25
+ try { p = rel(uri) } catch { p = uri.fsPath }
26
+ return `${p}:${line + 1}:${range.start.character + 1} ${(await lineText(uri, line)).slice(0, 200)}`
27
+ }
28
+
29
+ /** The position asked for: line (1-based) and either a column or the symbol's name. */
30
+ async function position(doc, args) {
31
+ const line = Math.max(0, Math.min(doc.lineCount - 1, (Number(args.line) || 1) - 1))
32
+ const text = doc.lineAt(line).text
33
+ if (args.symbol) {
34
+ const name = String(args.symbol)
35
+ const escaped = name.replace(/[.*+?^$()|[\]\\{}]/g, (c) => `\\${c}`)
36
+ const re = new RegExp(`(^|[^\\w$])(${escaped})(?![\\w$])`)
37
+ const m = re.exec(text)
38
+ if (m) return new vscode.Position(line, m.index + m[1].length)
39
+ // Not on that line: the nearest line that has it, a few either way.
40
+ for (let d = 1; d <= 5; d += 1) {
41
+ for (const l of [line - d, line + d]) {
42
+ if (l < 0 || l >= doc.lineCount) continue
43
+ const mm = re.exec(doc.lineAt(l).text)
44
+ if (mm) return new vscode.Position(l, mm.index + mm[1].length)
45
+ }
46
+ }
47
+ throw new Error(`${name} is not on line ${line + 1} (or near it) — read the file for the right line`)
48
+ }
49
+ const col = Math.max(0, (Number(args.character) || (text.search(/\S/) + 1) || 1) - 1)
50
+ return new vscode.Position(line, Math.min(col, text.length))
51
+ }
52
+
53
+ function flatSymbols(list, depth = 0, out = []) {
54
+ for (const s of list || []) {
55
+ const range = s.selectionRange || s.location?.range
56
+ out.push(`${' '.repeat(depth)}${KIND[s.kind] || 'symbol'} ${s.name}${range ? ` (line ${range.start.line + 1})` : ''}`)
57
+ if (s.children?.length) flatSymbols(s.children, depth + 1, out)
58
+ }
59
+ return out
60
+ }
61
+
62
+ // A language server that is busy (indexing) or wedged answers late or never; the agent is
63
+ // told so, rather than waiting on it for ever.
64
+ const LSP_LIMIT = 20_000
65
+
66
+ function bounded(promise, what, ms = LSP_LIMIT) {
67
+ let timer
68
+ const late = new Promise((_, reject) => {
69
+ timer = setTimeout(() => reject(new Error(`the language server did not answer ${what.replace('vscode.execute', '').replace('Provider', '')} `
70
+ + `within ${ms / 1000}s — it may still be indexing; try grep instead`)), ms)
71
+ })
72
+ return Promise.race([Promise.resolve(promise), late]).finally(() => clearTimeout(timer))
73
+ }
74
+
75
+ async function codeIntel(args) {
76
+ const op = String(args.operation || '')
77
+ if (!OPS.includes(op)) throw new Error(`operation must be one of: ${OPS.join(', ')}`)
78
+ const run = (cmd, ...a) => bounded(vscode.commands.executeCommand(cmd, ...a), cmd)
79
+ if (op === 'workspace_symbols') {
80
+ const found = await run('vscode.executeWorkspaceSymbolProvider', String(args.query || args.symbol || ''))
81
+ if (!found?.length) return `no symbol matches ${JSON.stringify(args.query || args.symbol || '')}`
82
+ const lines = await Promise.all(found.slice(0, CAP).map(async (s) => `${KIND[s.kind] || 'symbol'} ${s.name} — ${await where(s.location)}`))
83
+ return lines.join('\n') + (found.length > CAP ? `\n… ${found.length - CAP} more` : '')
84
+ }
85
+ if (!args.path) throw new Error('path is required')
86
+ const uri = resolve(args.path)
87
+ const doc = await vscode.workspace.openTextDocument(uri)
88
+ if (op === 'document_symbols') {
89
+ const symbols = await run('vscode.executeDocumentSymbolProvider', uri)
90
+ if (!symbols?.length) return `no symbols reported for ${args.path} (is a language extension for it installed?)`
91
+ return flatSymbols(symbols).slice(0, 300).join('\n')
92
+ }
93
+ const pos = await position(doc, args)
94
+ const at = `${args.path}:${pos.line + 1}:${pos.character + 1}`
95
+ if (op === 'hover') {
96
+ const hovers = await run('vscode.executeHoverProvider', uri, pos)
97
+ const text = (hovers || []).flatMap((h) => h.contents.map((c) => (typeof c === 'string' ? c : c.value || ''))).join('\n\n').trim()
98
+ return text ? `${at}\n${text.slice(0, 4000)}` : `nothing known about ${at}`
99
+ }
100
+ if (op === 'incoming_calls' || op === 'outgoing_calls') {
101
+ const items = await run('vscode.prepareCallHierarchy', uri, pos)
102
+ if (!items?.length) return `no call hierarchy at ${at}`
103
+ const calls = await run(op === 'incoming_calls' ? 'vscode.provideIncomingCalls' : 'vscode.provideOutgoingCalls', items[0])
104
+ if (!calls?.length) return `no ${op === 'incoming_calls' ? 'callers of' : 'calls from'} ${items[0].name}`
105
+ const lines = await Promise.all(calls.slice(0, CAP).map(async (c) => {
106
+ const item = c.from || c.to
107
+ return `${KIND[item.kind] || ''} ${item.name} — ${await where({ uri: item.uri, range: item.selectionRange })}`
108
+ }))
109
+ return `${op === 'incoming_calls' ? 'Called by' : 'Calls'} (${items[0].name}):\n${lines.join('\n')}`
110
+ }
111
+ const command = { definition: 'vscode.executeDefinitionProvider', references: 'vscode.executeReferenceProvider',
112
+ type_definition: 'vscode.executeTypeDefinitionProvider', implementations: 'vscode.executeImplementationProvider' }[op]
113
+ const found = await run(command, uri, pos)
114
+ const noun = { definition: ['definition', 'definitions'], references: ['reference', 'references'],
115
+ type_definition: ['type definition', 'type definitions'], implementations: ['implementation', 'implementations'] }[op]
116
+ if (!found?.length) return `no ${noun[1]} found for ${at} (is a language extension for this file installed?)`
117
+ const lines = await Promise.all(found.slice(0, CAP).map(where))
118
+ return `${found.length} ${noun[found.length === 1 ? 0 : 1]} for ${at}:\n${lines.join('\n')}`
119
+ + (found.length > CAP ? `\n… ${found.length - CAP} more` : '')
120
+ }
121
+
122
+ module.exports = { bounded, LSP_LIMIT, codeIntel, OPS }