@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/usage.js ADDED
@@ -0,0 +1,77 @@
1
+ 'use strict'
2
+ // What the agent has used, turn by turn, kept for 30 days in ~/.aipictury/usage.jsonl — for a
3
+ // /usage like Claude Code's: today or this week, by tool, helper, MCP server and skill, and
4
+ // what is worth knowing (prompts re-read in full, contexts near their limit).
5
+ // No VS Code here: the tests give their own clock and folder.
6
+ const fs = require('fs')
7
+ const path = require('path')
8
+ const { home } = require('./sessions')
9
+
10
+ const DAY = 86_400_000
11
+ const KEEP = 30 * DAY
12
+ const file = () => path.join(home(), 'usage.jsonl')
13
+ const READ_TPS = 1000 // prompt tokens a second this GPU reads uncached (measured)
14
+
15
+ // Told of every turn recorded (the account's daily totals follow it).
16
+ const listeners = new Set()
17
+ function onRecord(fn) { listeners.add(fn); return { dispose: () => listeners.delete(fn) } }
18
+
19
+ /** One turn: { conversation, model, context, read, output, genSeconds, tools: [names] }. */
20
+ function record(entry, now = Date.now()) {
21
+ for (const fn of listeners) { try { fn(entry, now) } catch { /* its own problem */ } }
22
+ try {
23
+ fs.mkdirSync(home(), { recursive: true })
24
+ fs.appendFileSync(file(), `${JSON.stringify({ t: now, ...entry })}\n`)
25
+ // Older than 30 days goes, now and then (about one write in fifty).
26
+ if (Math.random() < 0.02) prune(now)
27
+ } catch { /* usage is a convenience, never a failure */ }
28
+ }
29
+
30
+ function read() {
31
+ try {
32
+ return fs.readFileSync(file(), 'utf8').split('\n').filter(Boolean).map((l) => { try { return JSON.parse(l) } catch { return null } }).filter(Boolean)
33
+ } catch { return [] }
34
+ }
35
+
36
+ function prune(now = Date.now()) {
37
+ const kept = read().filter((r) => now - r.t < KEEP)
38
+ const tmp = `${file()}.${process.pid}.tmp`
39
+ fs.writeFileSync(tmp, kept.map((r) => JSON.stringify(r)).join('\n') + (kept.length ? '\n' : ''))
40
+ fs.renameSync(tmp, file())
41
+ }
42
+
43
+ /** Since when: 'today' (midnight) or 'week' (seven days back). */
44
+ function since(range, now = Date.now()) {
45
+ if (range === 'week') return now - 7 * DAY
46
+ const d = new Date(now)
47
+ d.setHours(0, 0, 0, 0)
48
+ return d.getTime()
49
+ }
50
+
51
+ /** The turns of a range, summed and broken down. */
52
+ function summary(range = 'today', { now = Date.now(), records = read(), window = 131_072 } = {}) {
53
+ const rows = records.filter((r) => r.t >= since(range, now))
54
+ const count = (key) => {
55
+ const out = {}
56
+ for (const r of rows) for (const name of r[key] || []) out[name] = (out[name] || 0) + 1
57
+ return Object.entries(out).sort((a, b) => b[1] - a[1])
58
+ }
59
+ const tools = count('tools')
60
+ const mcp = {}
61
+ for (const [name, n] of tools) if (name.startsWith('mcp__')) { const server = name.split('__')[1]; mcp[server] = (mcp[server] || 0) + n }
62
+ const missed = rows.filter((r) => (r.read || 0) > 20_000)
63
+ const full = rows.filter((r) => (r.context || 0) > 0.8 * window)
64
+ return {
65
+ range, turns: rows.length, conversations: new Set(rows.map((r) => r.conversation)).size,
66
+ context: rows.reduce((n, r) => n + (r.context || 0), 0), read: rows.reduce((n, r) => n + (r.read || 0), 0),
67
+ output: rows.reduce((n, r) => n + (r.output || 0), 0), genSeconds: rows.reduce((n, r) => n + (r.genSeconds || 0), 0),
68
+ tools: tools.filter(([n]) => !n.startsWith('mcp__')), mcp: Object.entries(mcp).sort((a, b) => b[1] - a[1]),
69
+ skills: count('skills'), helpers: count('helpers'), models: count('models'),
70
+ flags: [
71
+ ...(missed.length ? [`${missed.length} turn(s) re-read more than 20k prompt tokens (about ${Math.round(missed.reduce((n, r) => n + r.read, 0) / READ_TPS)} s of reading): the prompt cache was missed — a model switch, an edited earlier message, or another conversation in between.`] : []),
72
+ ...(full.length ? [`${full.length} turn(s) used over 80% of the context window: /compact, or start a new conversation for a new task.`] : []),
73
+ ],
74
+ }
75
+ }
76
+
77
+ module.exports = { record, read, prune, summary, file, onRecord }
package/ext/voice.js ADDED
@@ -0,0 +1,135 @@
1
+ 'use strict'
2
+ // Dictation in the editor. VS Code gives its webviews no microphone (getUserMedia is refused),
3
+ // so the recording is made here, in the extension host, by SoX or ffmpeg — whichever is
4
+ // installed — from the system's default microphone, and written by the server's Whisper
5
+ // (POST /api/transcribe). The first recording makes the system ask for the microphone, for VS
6
+ // Code. AIPICTURY_FAKE_AUDIO (tests) plays a file in real time instead of the microphone, so
7
+ // everything else — starting, stopping, the file, the upload — is the real path.
8
+ // No VS Code here.
9
+ const { spawn, spawnSync } = require('child_process')
10
+ const fs = require('fs')
11
+ const os = require('os')
12
+ const path = require('path')
13
+
14
+ const MAX_SECONDS = 300
15
+ const EXTRA_DIRS = ['/opt/homebrew/bin', '/usr/local/bin', '/usr/bin'] // VS Code started from the Dock has a short PATH
16
+
17
+ function which(name, env = process.env, platform = process.platform) {
18
+ const exts = platform === 'win32' ? ['.exe', ''] : ['']
19
+ const dirs = [...String(env.PATH || '').split(path.delimiter), ...(platform === 'win32' ? [] : EXTRA_DIRS)].filter(Boolean)
20
+ for (const dir of dirs) for (const ext of exts) {
21
+ const p = path.join(dir, name + ext)
22
+ try { fs.accessSync(p, fs.constants.X_OK); return p } catch { /* not here */ }
23
+ }
24
+ return null
25
+ }
26
+
27
+ /** Windows' first microphone, as ffmpeg's dshow names it. */
28
+ function dshowMic(ffmpeg) {
29
+ const out = spawnSync(ffmpeg, ['-hide_banner', '-list_devices', 'true', '-f', 'dshow', '-i', 'dummy'], { encoding: 'utf8', timeout: 10_000 })
30
+ const m = /"([^"]+)"\s*\(audio\)/.exec(`${out.stderr}${out.stdout}`)
31
+ return m ? m[1] : null
32
+ }
33
+
34
+ /** How to record here: { tool, command, args(out) }, or null with nothing to record with. */
35
+ function recorder({ env = process.env, platform = process.platform } = {}) {
36
+ const ffmpeg = which('ffmpeg', env, platform)
37
+ const pcm = ['-ac', '1', '-ar', '16000', '-c:a', 'pcm_s16le']
38
+ if (env.AIPICTURY_FAKE_AUDIO && ffmpeg) {
39
+ return { tool: 'ffmpeg (a file, for tests)', command: ffmpeg, args: (out) => ['-hide_banner', '-loglevel', 'error', '-y', '-re', '-i', env.AIPICTURY_FAKE_AUDIO, ...pcm, out] }
40
+ }
41
+ // SoX records from the default input on every system.
42
+ const rec = which('rec', env, platform)
43
+ if (rec && platform !== 'win32') return { tool: 'SoX', command: rec, args: (out) => ['-q', '-c', '1', '-r', '16000', '-b', '16', out] }
44
+ if (!ffmpeg) return null
45
+ let input
46
+ if (platform === 'darwin') input = ['-f', 'avfoundation', '-i', ':default']
47
+ else if (platform === 'linux') input = which('pactl', env, platform) ? ['-f', 'pulse', '-i', 'default'] : ['-f', 'alsa', '-i', 'default']
48
+ else if (platform === 'win32') { const mic = dshowMic(ffmpeg); if (!mic) return null; input = ['-f', 'dshow', '-i', `audio=${mic}`] } else return null
49
+ return { tool: 'ffmpeg', command: ffmpeg, args: (out) => ['-hide_banner', '-loglevel', 'error', '-y', ...input, ...pcm, out] }
50
+ }
51
+
52
+ function missing(platform = process.platform) {
53
+ const how = platform === 'darwin' ? 'brew install sox (or ffmpeg)' : platform === 'win32' ? 'winget install ffmpeg' : 'install sox or ffmpeg with your package manager'
54
+ return `Dictation records with SoX or ffmpeg, and neither is installed here — ${how}, then try again.`
55
+ }
56
+
57
+ /** Bytes of sound in a WAV file (its "data" chunk; when the recorder could not write the
58
+ * chunk's size, what follows it). */
59
+ function soundBytes(file) {
60
+ let buf
61
+ try { buf = fs.readFileSync(file) } catch { return 0 }
62
+ const at = buf.indexOf('data', 12, 'latin1')
63
+ if (at < 0 || at + 8 > buf.length) return 0
64
+ const declared = buf.readUInt32LE(at + 4)
65
+ const rest = buf.length - (at + 8)
66
+ return declared > 0 && declared <= rest ? declared : rest
67
+ }
68
+ const MIN_SOUND = 16_000 * 2 / 4 // a quarter of a second at 16 kHz, 16-bit mono
69
+
70
+ /** One recording: start() it, then stop() (resolves to the file) or cancel(). */
71
+ class Recording {
72
+ constructor(how = recorder()) {
73
+ if (!how) throw new Error(missing())
74
+ this.how = how
75
+ this.file = path.join(os.tmpdir(), `aipictury-dictation-${process.pid}-${Date.now().toString(36)}.wav`)
76
+ this.started = 0
77
+ this.child = null
78
+ this.error = ''
79
+ }
80
+
81
+ start() {
82
+ this.child = spawn(this.how.command, this.how.args(this.file), { stdio: ['pipe', 'ignore', 'pipe'] })
83
+ this.child.stderr.on('data', (d) => { this.error = `${this.error}${d}`.slice(-2000) })
84
+ this.exited = new Promise((resolve) => {
85
+ this.child.on('error', (e) => { this.error = e.message; resolve(-1) })
86
+ this.child.on('exit', (code) => resolve(code))
87
+ })
88
+ this.started = Date.now()
89
+ this.limit = setTimeout(() => this.finish(), MAX_SECONDS * 1000)
90
+ return this
91
+ }
92
+
93
+ get seconds() { return this.started ? Math.floor((Date.now() - this.started) / 1000) : 0 }
94
+
95
+ /** Ends the recording cleanly (ffmpeg: "q"; SoX: an interrupt — both then close the file). */
96
+ finish() {
97
+ clearTimeout(this.limit)
98
+ if (!this.child || this.child.exitCode !== null) return
99
+ if (/ffmpeg/.test(this.how.tool)) { try { this.child.stdin.write('q') } catch { this.child.kill('SIGINT') } } else this.child.kill('SIGINT')
100
+ setTimeout(() => { if (this.child.exitCode === null) this.child.kill('SIGKILL') }, 5000).unref()
101
+ }
102
+
103
+ /** Stops and resolves to the recording's file; throws when nothing was recorded. */
104
+ async stop() {
105
+ this.finish()
106
+ await this.exited
107
+ if (soundBytes(this.file) < MIN_SOUND) {
108
+ this.discard()
109
+ const said = this.error.trim().split('\n').pop()
110
+ throw new Error(/not authori[sz]ed|permission|denied/i.test(said)
111
+ ? 'The microphone is not allowed for VS Code — allow it in the system\'s privacy settings (Microphone).'
112
+ : `nothing was recorded${said ? `: ${said}` : ''}`)
113
+ }
114
+ return this.file
115
+ }
116
+
117
+ cancel() { this.finish(); this.exited?.then(() => this.discard()) }
118
+
119
+ discard() { try { fs.rmSync(this.file, { force: true }) } catch { /* gone */ } }
120
+ }
121
+
122
+ /** The server writes it: { text, language, duration, seconds }. The file is removed after. */
123
+ async function transcribe(server, file, { language = '', signal } = {}) {
124
+ try {
125
+ const form = new FormData()
126
+ form.append('audio', new Blob([fs.readFileSync(file)], { type: 'audio/wav' }), 'dictation.wav')
127
+ if (language) form.append('language', language)
128
+ const response = await fetch(`${server}/api/transcribe`, { method: 'POST', body: form, signal })
129
+ const data = await response.json().catch(() => ({}))
130
+ if (!response.ok) throw new Error(data.detail || `the server answered ${response.status}${response.status === 404 ? ' — it is older than dictation; update it' : ''}`)
131
+ return data
132
+ } finally { fs.rmSync(file, { force: true }) }
133
+ }
134
+
135
+ module.exports = { recorder, Recording, transcribe, missing, which, soundBytes, MAX_SECONDS }
@@ -0,0 +1,162 @@
1
+ 'use strict'
2
+ // The agent in VS Code's own furniture: the status bar entry, where a new conversation opens,
3
+ // locking the editor group a tab opens in, reopening a closed agent tab, the
4
+ // vscode://aipictury.aipictury/open link, and a system notification when VS Code is not in front.
5
+ const vscode = require('vscode')
6
+ const { execFile } = require('child_process')
7
+ const log = require('./log')
8
+
9
+ const config = () => vscode.workspace.getConfiguration('aipictury')
10
+
11
+ // ---- the status bar ----
12
+
13
+ let item = null
14
+ const LOOK = {
15
+ idle: { text: '$(sparkle) AIPictury', tip: 'Open the AIPictury agent' },
16
+ working: { text: '$(sync~spin) AIPictury', tip: 'The agent is working — click to open it' },
17
+ waiting: { text: '$(bell-dot) AIPictury', tip: 'The agent is waiting for you' },
18
+ finished: { text: '$(check) AIPictury', tip: 'The agent has finished' },
19
+ }
20
+
21
+ function statusBar() {
22
+ if (!item) {
23
+ item = vscode.window.createStatusBarItem('aipictury.status', vscode.StatusBarAlignment.Right, 100)
24
+ item.name = 'AIPictury'
25
+ item.command = 'aipictury.focusInput'
26
+ setState('idle')
27
+ item.show()
28
+ }
29
+ return item
30
+ }
31
+
32
+ /** What the agent is doing, in the status bar: idle, working, waiting (for you) or finished. */
33
+ function setState(state) {
34
+ if (!item) return
35
+ const look = LOOK[state] || LOOK.idle
36
+ item.text = look.text
37
+ item.tooltip = look.tip
38
+ item.backgroundColor = state === 'waiting' ? new vscode.ThemeColor('statusBarItem.warningBackground') : undefined
39
+ }
40
+
41
+ // ---- where a conversation opens ----
42
+
43
+ /** The side bar or a new tab, as `preferredLocation` says. Returns the panel. */
44
+ async function openPreferred(registry) {
45
+ if (config().get('preferredLocation', 'sidebar') === 'panel') return openTab(registry)
46
+ await vscode.commands.executeCommand('aipictury.agent.focus')
47
+ return registry.sidebar
48
+ }
49
+
50
+ /** A new agent tab; when it made a new editor group, that group is locked (`lockEditorGroups`),
51
+ * so files opened while the tab is focused go to another group. */
52
+ async function openTab(registry) {
53
+ const before = vscode.window.tabGroups.all.length
54
+ const panel = registry.openTab()
55
+ if (config().get('lockEditorGroups', true) && vscode.window.tabGroups.all.length > before) {
56
+ try { await vscode.commands.executeCommand('workbench.action.lockEditorGroup') } catch (error) { log.warn('could not lock the editor group', error) }
57
+ }
58
+ return panel
59
+ }
60
+
61
+ // ---- reopening a closed agent tab ----
62
+
63
+ const closed = [] // conversations of agent tabs closed, newest last
64
+ let lastClosedWasAgent = false
65
+
66
+ /** Called when an agent tab closes, with its conversation. */
67
+ function tabClosed(id) {
68
+ if (id) { closed.push(id); if (closed.length > 20) closed.shift() }
69
+ }
70
+
71
+ /** Every tab closed anywhere: was the last one an agent tab? */
72
+ function watchTabs() {
73
+ return vscode.window.tabGroups.onDidChangeTabs((event) => {
74
+ for (const tab of event.closed) {
75
+ const input = tab.input
76
+ lastClosedWasAgent = !!input && input instanceof vscode.TabInputWebview && /aipictury\.agentTab/.test(input.viewType)
77
+ }
78
+ })
79
+ }
80
+
81
+ /** Cmd/Ctrl+Shift+T: the agent tab closed last, or VS Code's own Reopen Closed Editor. */
82
+ async function reopenClosed(registry) {
83
+ if (lastClosedWasAgent && closed.length) {
84
+ const id = closed.pop()
85
+ const panel = await openTab(registry)
86
+ await panel.open(id, { focus: false })
87
+ lastClosedWasAgent = false
88
+ return panel
89
+ }
90
+ await vscode.commands.executeCommand('workbench.action.reopenClosedEditor')
91
+ return null
92
+ }
93
+
94
+ // ---- vscode://aipictury.aipictury/open?prompt=…&session=… ----
95
+
96
+ function uriHandler(registry) {
97
+ return {
98
+ async handleUri(uri) {
99
+ const params = new URLSearchParams(uri.query)
100
+ // The browser back from signing in to an MCP server.
101
+ if (uri.path === '/oauth') {
102
+ if (!require('./mcpAuth').handleCallback(uri.query)) vscode.window.showWarningMessage('That sign-in link is not one AIPictury is waiting for.')
103
+ return
104
+ }
105
+ // vscode://aipictury.aipictury/plugin?marketplace=owner/repo&install=name@market — asked first.
106
+ if (uri.path === '/plugin') {
107
+ const install = params.get('install')
108
+ const market = params.get('marketplace')
109
+ const yes = 'Install'
110
+ const ok = await vscode.window.showWarningMessage(`Install the plugin ${install || ''}${market ? ` from ${market}` : ''}?`,
111
+ { modal: true, detail: 'A plugin can add commands, helper agents, hooks (commands that run on this machine) and MCP servers.' }, yes)
112
+ if (ok !== yes) return
113
+ const plugins = require('./plugins')
114
+ try {
115
+ if (market && !Object.keys(plugins.marketplaces()).some((n) => install?.endsWith(`@${n}`))) await plugins.addMarketplace(market)
116
+ if (install) await plugins.install(install, { level: 'user' })
117
+ vscode.window.showInformationMessage(`Installed ${install}.`)
118
+ for (const p of registry.all()) p.postCommands?.()
119
+ } catch (error) { vscode.window.showErrorMessage(`Could not install it: ${error.message}`) }
120
+ return
121
+ }
122
+ if (uri.path !== '/open') {
123
+ vscode.window.showWarningMessage(`AIPictury does not handle ${uri.path}`)
124
+ return
125
+ }
126
+ const session = params.get('session')
127
+ const prompt = params.get('prompt')
128
+ if (session) {
129
+ const open = registry.all().find((p) => p.id === session && p.host)
130
+ if (open) { open.host.reveal?.(); if (open.kind === 'sidebar') await vscode.commands.executeCommand('aipictury.agent.focus'); if (prompt) open.post({ type: 'draft', text: prompt }); return }
131
+ }
132
+ const panel = await openPreferred(registry)
133
+ if (session && !(await panel.open(session, { focus: false }))) panel.say?.('that conversation is not in this workspace — a new one is open instead')
134
+ // The prompt is filled in, never sent: the user sends it.
135
+ if (prompt) setTimeout(() => panel.post({ type: 'draft', text: prompt }), 300)
136
+ },
137
+ }
138
+ }
139
+
140
+ // ---- telling the user, when VS Code is not in front ----
141
+
142
+ /** A system notification — only when VS Code is not the focused window, and `notifications` is on. */
143
+ function notify(title, body) {
144
+ if (!config().get('notifications', true) || vscode.window.state.focused) return false
145
+ const text = String(body || '').slice(0, 200)
146
+ try {
147
+ if (process.platform === 'darwin') {
148
+ const esc = (s) => String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"')
149
+ execFile('osascript', ['-e', `display notification "${esc(text)}" with title "${esc(title)}"`], { timeout: 5000 }, () => {})
150
+ } else if (process.platform === 'linux') {
151
+ execFile('notify-send', [title, text], { timeout: 5000 }, () => {})
152
+ } else {
153
+ vscode.window.showInformationMessage(`${title}: ${text}`)
154
+ }
155
+ return true
156
+ } catch (error) {
157
+ log.warn('notification failed', error)
158
+ return false
159
+ }
160
+ }
161
+
162
+ module.exports = { statusBar, setState, openPreferred, openTab, tabClosed, watchTabs, reopenClosed, uriHandler, notify }
@@ -0,0 +1,51 @@
1
+ 'use strict'
2
+ // A workflow's steps: checked, then put in the order their dependencies give (each after the
3
+ // steps it names). Pure: the agent runs the steps as helpers, one at a time — one GPU.
4
+ const MAX_STEPS = 12
5
+
6
+ /** [{ name, prompt, after?, subagent_type? }] (or its JSON) → the steps in running order. Throws on a bad plan. */
7
+ function plan(given) {
8
+ let steps = given
9
+ if (typeof steps === 'string') { try { steps = JSON.parse(steps) } catch { steps = null } }
10
+ if (!Array.isArray(steps) || !steps.length) throw new Error('a workflow needs steps: [{ name, prompt, after? }]')
11
+ if (steps.length > MAX_STEPS) throw new Error(`at most ${MAX_STEPS} steps — group the work`)
12
+ const byName = new Map()
13
+ for (const [i, s] of steps.entries()) {
14
+ const name = String(s?.name || `step ${i + 1}`).trim()
15
+ if (!String(s?.prompt || '').trim()) throw new Error(`step ${name} has no prompt`)
16
+ if (byName.has(name)) throw new Error(`two steps are called ${name}`)
17
+ const after = Array.isArray(s.after) ? s.after : s.after ? [s.after] : []
18
+ byName.set(name, { ...s, name, prompt: String(s.prompt).trim(), after: after.map((a) => String(a).trim()) })
19
+ }
20
+ for (const s of byName.values()) {
21
+ for (const a of s.after) if (!byName.has(a)) throw new Error(`${s.name} comes after ${a}, which is not a step`)
22
+ }
23
+ const order = []
24
+ const state = new Map()
25
+ const visit = (s) => {
26
+ if (state.get(s.name) === 'done') return
27
+ if (state.get(s.name) === 'visiting') throw new Error(`the steps wait for each other in a loop (at ${s.name})`)
28
+ state.set(s.name, 'visiting')
29
+ for (const a of s.after) visit(byName.get(a))
30
+ state.set(s.name, 'done')
31
+ order.push(s)
32
+ }
33
+ for (const s of byName.values()) visit(s)
34
+ return order
35
+ }
36
+
37
+ /** What a step's helper is told: the goal, its step, and what the steps it depends on found. */
38
+ function stepPrompt(step, goal, results) {
39
+ const earlier = step.after.map((a) => `## ${a}\n${results.get(a)}`).join('\n\n')
40
+ return `${goal ? `The overall goal: ${goal}\n\n` : ''}Your step: ${step.prompt}`
41
+ + (earlier ? `\n\nWhat the steps before yours found:\n\n${earlier}` : '')
42
+ }
43
+
44
+ /** The one report: every step that ran, in order. */
45
+ function combine(order, results, { goal, stopped = false } = {}) {
46
+ const ran = order.filter((s) => results.has(s.name))
47
+ return `Workflow${goal ? ` — ${goal}` : ''}: ${ran.length} of ${order.length} steps done${stopped ? ' (stopped)' : ''}.\n\n`
48
+ + ran.map((s) => `## ${s.name}\n${results.get(s.name)}`).join('\n\n')
49
+ }
50
+
51
+ module.exports = { plan, stepPrompt, combine, MAX_STEPS }
@@ -0,0 +1,83 @@
1
+ 'use strict'
2
+ // Working in a git worktree, as Claude Code's enter/exit worktree: a checkout of its own, on a
3
+ // branch of its own, inside the workspace at .aipictury/worktrees/<name> — so the agent (or a
4
+ // helper) can change things without touching the user's checkout. The folder is kept out of
5
+ // the main checkout's git status through .git/info/exclude (nothing in the repository changes).
6
+ // No VS Code here: tests run it on a real repository. Asynchronous: a checkout of a large
7
+ // repository takes a while, and a synchronous one would freeze the window's extension host.
8
+ const fs = require('fs')
9
+ const path = require('path')
10
+ const { execFile } = require('child_process')
11
+
12
+ const DIR = path.join('.aipictury', 'worktrees')
13
+
14
+ function git(args, cwd) {
15
+ return new Promise((resolve, reject) => {
16
+ execFile('git', args, { cwd, encoding: 'utf8', timeout: 60_000, maxBuffer: 16 * 1024 * 1024 }, (error, stdout, stderr) => {
17
+ if (error) reject(new Error(String(stderr || error.message).trim().split('\n').pop(), { cause: error }))
18
+ else resolve(stdout.trim())
19
+ })
20
+ })
21
+ }
22
+
23
+ /** The repository's top folder, or an error saying there is none. */
24
+ async function top(root) {
25
+ try { return await git(['rev-parse', '--show-toplevel'], root) } catch { throw new Error('this workspace is not a git repository: a worktree needs one') }
26
+ }
27
+
28
+ /** Keeps .aipictury/worktrees/ out of the main checkout's status, without touching .gitignore. */
29
+ async function exclude(repo) {
30
+ const file = path.join((await git(['rev-parse', '--git-common-dir'], repo)).replace(/^(?!\/)/, `${repo}/`), 'info', 'exclude')
31
+ const line = '/.aipictury/worktrees/'
32
+ const text = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : ''
33
+ if (!text.split('\n').includes(line)) {
34
+ fs.mkdirSync(path.dirname(file), { recursive: true })
35
+ fs.appendFileSync(file, `${text && !text.endsWith('\n') ? '\n' : ''}${line}\n`)
36
+ }
37
+ }
38
+
39
+ /** Creates a worktree on a new branch from HEAD. Returns { name, path, relative, branch, base }. */
40
+ async function enter(root, name) {
41
+ const repo = await top(root)
42
+ const clean = String(name || `wt-${Date.now().toString(36)}`).trim().replace(/[^\w.-]+/g, '-').slice(0, 40)
43
+ if (!clean) throw new Error('a worktree needs a name')
44
+ const relative = path.join(DIR, clean)
45
+ const where = path.join(repo, relative)
46
+ if (fs.existsSync(where)) throw new Error(`a worktree called ${clean} already exists`)
47
+ const branch = `aipictury/${clean}`
48
+ const base = await git(['rev-parse', 'HEAD'], repo)
49
+ await exclude(repo)
50
+ await git(['worktree', 'add', '-q', '-b', branch, where, 'HEAD'], repo)
51
+ return { name: clean, path: where, relative, branch, base }
52
+ }
53
+
54
+ /** What the worktree has that its base has not: commits, and uncommitted changes. */
55
+ async function changes(wt) {
56
+ const commits = Number(await git(['rev-list', '--count', `${wt.base}..HEAD`], wt.path)) || 0
57
+ const dirty = (await git(['status', '--porcelain'], wt.path)).split('\n').filter(Boolean)
58
+ return { commits, dirty }
59
+ }
60
+
61
+ /** Leaves a worktree: kept (its folder and branch stay), or removed — refused when it holds
62
+ * work that would be lost, unless `discard` says so. */
63
+ async function exit(root, wt, { action = 'keep', discard = false } = {}) {
64
+ const repo = await top(root)
65
+ const what = await changes(wt)
66
+ if (action === 'keep') return { kept: true, ...what, path: wt.path, branch: wt.branch }
67
+ if ((what.commits || what.dirty.length) && !discard) {
68
+ throw new Error(`${wt.name} has ${what.commits} commit(s) and ${what.dirty.length} uncommitted change(s): keep it, or remove it with discard_changes: true`)
69
+ }
70
+ await git(['worktree', 'remove', '--force', wt.path], repo)
71
+ try { await git(['branch', '-D', wt.branch], repo) } catch { /* already gone */ }
72
+ return { kept: false, ...what }
73
+ }
74
+
75
+ /** A path from the model, while working in a worktree: relative to the worktree. */
76
+ function inside(wt, given) {
77
+ const p = String(given ?? '.').trim() || '.'
78
+ if (!wt) return p
79
+ if (p === wt.relative || p.startsWith(`${wt.relative}/`) || path.isAbsolute(p)) return p
80
+ return path.join(wt.relative, p)
81
+ }
82
+
83
+ module.exports = { enter, exit, changes, inside, DIR }