@davait/aipictury 1.9.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +77 -0
- package/README.md +39 -0
- package/ext/about.js +209 -0
- package/ext/account.js +242 -0
- package/ext/accountUi.js +287 -0
- package/ext/agent.js +1929 -0
- package/ext/browser.js +263 -0
- package/ext/cloud.js +385 -0
- package/ext/context.js +241 -0
- package/ext/deps.js +142 -0
- package/ext/dialogs.js +803 -0
- package/ext/diff.js +150 -0
- package/ext/extension.js +2483 -0
- package/ext/feedback.js +55 -0
- package/ext/files.js +565 -0
- package/ext/firestore.js +131 -0
- package/ext/gpuRun.js +77 -0
- package/ext/gpuServer.js +76 -0
- package/ext/hooks.js +153 -0
- package/ext/log.js +29 -0
- package/ext/lsp.js +122 -0
- package/ext/mcp.js +469 -0
- package/ext/mcpAuth.js +128 -0
- package/ext/media/vendor/firebase-config.json +9 -0
- package/ext/media/vendor/signin.js +1 -0
- package/ext/mediagen.js +174 -0
- package/ext/memory.js +117 -0
- package/ext/notebook.js +82 -0
- package/ext/package.json +1234 -0
- package/ext/paths.js +50 -0
- package/ext/permissions.js +229 -0
- package/ext/plans.js +110 -0
- package/ext/plugins.js +243 -0
- package/ext/prefs.js +157 -0
- package/ext/remote.js +170 -0
- package/ext/review.js +126 -0
- package/ext/sandbox.js +79 -0
- package/ext/scheduler.js +87 -0
- package/ext/sessions.js +228 -0
- package/ext/sessionsView.js +449 -0
- package/ext/shell.js +357 -0
- package/ext/storage.js +80 -0
- package/ext/tasklist.js +112 -0
- package/ext/text.js +31 -0
- package/ext/tools.js +781 -0
- package/ext/usage.js +77 -0
- package/ext/voice.js +135 -0
- package/ext/workbench.js +162 -0
- package/ext/workflow.js +51 -0
- package/ext/worktree.js +83 -0
- package/main.js +758 -0
- package/package.json +43 -0
- package/terminal.js +124 -0
- package/vscode-shim.js +231 -0
package/ext/firestore.js
ADDED
|
@@ -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 }
|
package/ext/gpuServer.js
ADDED
|
@@ -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 }
|