picocode-core 0.9.119

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 (57) hide show
  1. package/README.md +15 -0
  2. package/package.json +33 -0
  3. package/src/agent-transcript.js +80 -0
  4. package/src/agent.js +170 -0
  5. package/src/agents.js +213 -0
  6. package/src/attachments.js +141 -0
  7. package/src/boot.js +28 -0
  8. package/src/catalog-snapshot.json +1 -0
  9. package/src/catalog.js +86 -0
  10. package/src/codex-models.js +56 -0
  11. package/src/commands.js +61 -0
  12. package/src/compaction.js +82 -0
  13. package/src/completion.js +21 -0
  14. package/src/config.js +32 -0
  15. package/src/context.js +82 -0
  16. package/src/controller.js +1263 -0
  17. package/src/conversation-search.js +101 -0
  18. package/src/deliberation-history.js +65 -0
  19. package/src/deliberation.js +61 -0
  20. package/src/derive.js +307 -0
  21. package/src/events.js +54 -0
  22. package/src/export.js +16 -0
  23. package/src/files.js +39 -0
  24. package/src/format.js +6 -0
  25. package/src/fuzzy.js +41 -0
  26. package/src/git.js +156 -0
  27. package/src/history.js +51 -0
  28. package/src/init.js +25 -0
  29. package/src/keys.js +26 -0
  30. package/src/mcp.js +280 -0
  31. package/src/memory.js +120 -0
  32. package/src/models.js +14 -0
  33. package/src/openai-auth.js +204 -0
  34. package/src/paths.js +67 -0
  35. package/src/reversible-edit.js +79 -0
  36. package/src/rewind.js +84 -0
  37. package/src/session-index.js +264 -0
  38. package/src/session-lock.js +27 -0
  39. package/src/session.js +160 -0
  40. package/src/shells.js +166 -0
  41. package/src/skills.js +164 -0
  42. package/src/steer.js +129 -0
  43. package/src/system-prompt.js +49 -0
  44. package/src/terminal-theme.js +49 -0
  45. package/src/tools/bash.js +184 -0
  46. package/src/tools/diff.js +18 -0
  47. package/src/tools/edit.js +95 -0
  48. package/src/tools/glob.js +43 -0
  49. package/src/tools/grep.js +59 -0
  50. package/src/tools/index.js +296 -0
  51. package/src/tools/read.js +49 -0
  52. package/src/tools/recorder.js +74 -0
  53. package/src/tools/web.js +84 -0
  54. package/src/tools/write.js +48 -0
  55. package/src/update.js +82 -0
  56. package/src/user-tools.js +59 -0
  57. package/src/wakeups.js +40 -0
package/src/skills.js ADDED
@@ -0,0 +1,164 @@
1
+ import { readFile, readdir } from 'node:fs/promises'
2
+ import { join } from 'node:path'
3
+ import { globalSkillsDir, projectSkillsDir } from './paths.js'
4
+
5
+ export function parseFrontmatter(text) {
6
+ const match = text.match(/^---\n([\s\S]*?)\n---\n?/)
7
+ if (!match) return { meta: {}, body: text }
8
+ const meta = {}
9
+ for (const line of match[1].split('\n')) {
10
+ const sep = line.indexOf(':')
11
+ if (sep === -1) continue
12
+ meta[line.slice(0, sep).trim()] = line.slice(sep + 1).trim()
13
+ }
14
+ return { meta, body: text.slice(match[0].length) }
15
+ }
16
+
17
+ async function scanDir(dir, source) {
18
+ let names = []
19
+ try {
20
+ names = await readdir(dir, { withFileTypes: true })
21
+ } catch {
22
+ return []
23
+ }
24
+ const skills = []
25
+ for (const entry of names) {
26
+ if (!entry.isDirectory()) continue
27
+ const file = join(dir, entry.name, 'SKILL.md')
28
+ try {
29
+ const { meta } = parseFrontmatter(await readFile(file, 'utf-8'))
30
+ skills.push({
31
+ name: meta.name || entry.name,
32
+ description: meta.description || '',
33
+ source,
34
+ file,
35
+ })
36
+ } catch {}
37
+ }
38
+ return skills
39
+ }
40
+
41
+ const ASK_RULE = `Before writing anything, resolve ambiguity:
42
+ - If the user did not say global or project, ask exactly one question: "global (available everywhere) or just this project?" Do not guess silently.
43
+ - If the behavior or output is underspecified, state the assumptions you are making in one short line before proceeding, so the user can correct you.`
44
+
45
+ const NEW_TOOL_SKILL = `Create a custom pico tool from the user's description.
46
+
47
+ ${ASK_RULE}
48
+
49
+ A pico tool is an ES module whose default export is either a tool object or a factory
50
+ \`(context) => tool\` where context is \`{ cwd, root }\`. The tool object shape:
51
+
52
+ \`\`\`js
53
+ export default {
54
+ name: 'word_count',
55
+ description: 'Count words, lines, and characters in a file',
56
+ schema: {
57
+ path: { type: 'string', description: 'file path relative to the working directory' },
58
+ words: { type: 'boolean', description: 'include word count', optional: true },
59
+ },
60
+ execute: async ({ path, words = true }) => {
61
+ // return any JSON-serializable value; throw an Error to report failure
62
+ return { path, words: 42 }
63
+ },
64
+ }
65
+ \`\`\`
66
+
67
+ Schema properties support: type (string, number, boolean, array, object), description,
68
+ optional, enum (string values), items (for arrays), properties (for nested objects).
69
+
70
+ Locations: \`.pico/tools/<name>.js\` in the project root for project tools,
71
+ \`~/.pico/tools/<name>.js\` for tools available everywhere. Default to the project unless
72
+ the user asks for a global tool. Node builtins can be imported; npm packages cannot be
73
+ assumed. Tools are rescanned every turn, so a new or edited tool is usable on the next
74
+ message with no restart.
75
+
76
+ Steps:
77
+ 1. Decide the tool name (snake_case), inputs, and output shape from the user's request.
78
+ 2. Write the module to the right location with the write tool.
79
+ 3. Sanity-check it loads: \`node --input-type=module -e "const t = (await import('file://<abs path>')).default; console.log(t.name)"\` (adjust if the export is a factory).
80
+ 4. Tell the user the tool is ready and will be available from their next message.`
81
+
82
+ const NEW_SKILL_SKILL = `Create a pico skill from the user's description.
83
+
84
+ ${ASK_RULE}
85
+
86
+ A skill is a markdown file at \`.pico/skills/<name>/SKILL.md\` (project) or
87
+ \`~/.pico/skills/<name>/SKILL.md\` (global) with frontmatter and an instruction body:
88
+
89
+ \`\`\`
90
+ ---
91
+ name: release-notes
92
+ description: draft release notes from commits since the last tag
93
+ ---
94
+ Instructions the agent follows when this skill is loaded. Be imperative and specific.
95
+ \`\`\`
96
+
97
+ The description matters most: it is listed in the agent's system prompt, and the agent
98
+ decides from it when to load the skill on its own. Write it as a trigger condition
99
+ ("when the user asks for X"), not marketing. The body is only loaded on invocation, so
100
+ it can be long and detailed. Supporting files may sit next to SKILL.md in the same
101
+ directory and be referenced by relative path.
102
+
103
+ Skills are rescanned every turn, so a new skill is usable from the next message.`
104
+
105
+ const NEW_COMMAND_SKILL = `Create a pico command from the user's description.
106
+
107
+ ${ASK_RULE}
108
+
109
+ A command is a prompt template the USER invokes as \`/<name> [args]\`. The agent never
110
+ sees commands until one is run, so use a command for user-triggered macros and a skill
111
+ for capabilities the agent should reach for on its own.
112
+
113
+ It is a markdown file at \`.pico/commands/<name>.md\` (project) or
114
+ \`~/.pico/commands/<name>.md\` (global), optionally with a description in frontmatter:
115
+
116
+ \`\`\`
117
+ ---
118
+ description: review a file for security problems
119
+ ---
120
+ Review $ARGUMENTS for security problems. Focus on input validation and secrets.
121
+ \`\`\`
122
+
123
+ \`$ARGUMENTS\` is replaced with whatever follows the command; without the placeholder,
124
+ arguments are appended after the body. Commands are rescanned every turn, so a new
125
+ command appears in the slash menu from the next message.`
126
+
127
+ export async function createSkillIndex(root) {
128
+ const builtin = [
129
+ {
130
+ name: 'new-tool',
131
+ description: 'create a custom pico tool from a description of what it should do',
132
+ source: 'builtin',
133
+ body: NEW_TOOL_SKILL,
134
+ },
135
+ {
136
+ name: 'new-skill',
137
+ description: 'create a pico skill that teaches the agent a reusable capability',
138
+ source: 'builtin',
139
+ body: NEW_SKILL_SKILL,
140
+ },
141
+ {
142
+ name: 'new-command',
143
+ description: 'create a pico slash command, a prompt template the user invokes',
144
+ source: 'builtin',
145
+ body: NEW_COMMAND_SKILL,
146
+ },
147
+ ]
148
+ const global = await scanDir(globalSkillsDir(), 'global')
149
+ const project = await scanDir(projectSkillsDir(root), 'project')
150
+ const byName = new Map()
151
+ for (const skill of [...builtin, ...global, ...project]) byName.set(skill.name, skill)
152
+ const skills = [...byName.values()]
153
+
154
+ return {
155
+ list: () => skills,
156
+ async load(name) {
157
+ const skill = byName.get(name)
158
+ if (!skill) return null
159
+ if (skill.body) return skill.body
160
+ const { body } = parseFrontmatter(await readFile(skill.file, 'utf-8'))
161
+ return body
162
+ },
163
+ }
164
+ }
package/src/steer.js ADDED
@@ -0,0 +1,129 @@
1
+ export const STEER_ROLES = ['user', 'assistant']
2
+
3
+ function validRole(role) {
4
+ return STEER_ROLES.includes(role)
5
+ }
6
+
7
+ function insertionIndex(timeline, anchor) {
8
+ if (!anchor) {
9
+ const first = timeline.findIndex((event) => event.type === 'message')
10
+ return first < 0 ? timeline.length : first
11
+ }
12
+ let index = timeline.indexOf(anchor) + 1
13
+ let followsToolSequence = !!anchor.data.message.tool_calls?.length
14
+ while (index < timeline.length) {
15
+ const event = timeline[index]
16
+ if (event.type !== 'message') {
17
+ index++
18
+ continue
19
+ }
20
+ const message = event.data.message
21
+ if (followsToolSequence && message.role === 'tool') {
22
+ index++
23
+ continue
24
+ }
25
+ if (followsToolSequence && message.role === 'assistant' && !message.content && message.tool_calls?.length) {
26
+ index++
27
+ continue
28
+ }
29
+ break
30
+ }
31
+ return index
32
+ }
33
+
34
+ function insertedEvent(transaction, change, index) {
35
+ return {
36
+ id: change.id,
37
+ at: transaction.at,
38
+ type: 'message',
39
+ data: { message: change.message },
40
+ _steered: true,
41
+ _sourceIndex: index,
42
+ }
43
+ }
44
+
45
+ export function applySteering(events) {
46
+ const timeline = []
47
+ const messages = new Map()
48
+ const active = new Set()
49
+ const insertionTails = new Map()
50
+ let compactIndex = -1
51
+
52
+ const rebuildActive = () => {
53
+ active.clear()
54
+ for (const event of timeline) {
55
+ if (event.type === 'clear') active.clear()
56
+ else if (event.type === 'message') active.add(event.id)
57
+ }
58
+ }
59
+
60
+ events.forEach((event, sourceIndex) => {
61
+ if (event.type !== 'steer') {
62
+ const normalized = event.type === 'message' ? { ...event, _sourceIndex: sourceIndex } : event
63
+ timeline.push(normalized)
64
+ if (event.type === 'message') {
65
+ messages.set(event.id, normalized)
66
+ active.add(event.id)
67
+ } else if (event.type === 'clear') {
68
+ active.clear()
69
+ compactIndex = -1
70
+ } else if (event.type === 'compact') {
71
+ compactIndex = sourceIndex
72
+ }
73
+ return
74
+ }
75
+
76
+ for (const change of event.data.changes || []) {
77
+ if (change.op === 'replace') {
78
+ const target = messages.get(change.target)
79
+ if (!target || !active.has(change.target) || target._sourceIndex < compactIndex) continue
80
+ if (!validRole(change.message?.role)) continue
81
+ target.data = {
82
+ ...target.data,
83
+ message: { ...target.data.message, role: change.message.role, content: change.message.content },
84
+ }
85
+ target._steered = true
86
+ continue
87
+ }
88
+ if (change.op === 'insert') {
89
+ if (!change.id || !STEER_ROLES.includes(change.message?.role)) continue
90
+ const anchor = change.after == null ? null : messages.get(change.after)
91
+ if (anchor && (!active.has(change.after) || anchor._sourceIndex < compactIndex)) continue
92
+ if (!anchor && change.after != null) continue
93
+ const inserted = insertedEvent(event, change, sourceIndex)
94
+ const stagedTail = insertionTails.get(change.after)
95
+ const tailId = stagedTail && active.has(stagedTail) ? stagedTail : change.after
96
+ const tail = tailId == null ? null : messages.get(tailId)
97
+ const at = insertionIndex(timeline, tail)
98
+ timeline.splice(at, 0, inserted)
99
+ messages.set(inserted.id, inserted)
100
+ active.add(inserted.id)
101
+ insertionTails.set(change.after, inserted.id)
102
+ continue
103
+ }
104
+ if (change.op === 'delete') {
105
+ const target = messages.get(change.target)
106
+ if (!target || !active.has(change.target) || target._sourceIndex < compactIndex) continue
107
+ const start = timeline.indexOf(target)
108
+ const end = insertionIndex(timeline, target)
109
+ const removed = new Set([target])
110
+ if (target.data.message.tool_calls?.length) {
111
+ for (let index = start + 1; index < end; index++) {
112
+ if (timeline[index].type === 'message') removed.add(timeline[index])
113
+ }
114
+ }
115
+ for (let index = timeline.length - 1; index >= 0; index--) {
116
+ if (removed.has(timeline[index])) timeline.splice(index, 1)
117
+ }
118
+ for (const message of removed) active.delete(message.id)
119
+ }
120
+ }
121
+ rebuildActive()
122
+ })
123
+
124
+ return timeline
125
+ }
126
+
127
+ export function steerableTranscript(transcript) {
128
+ return transcript.filter((item) => item.messageId && STEER_ROLES.includes(item.role || item.kind))
129
+ }
@@ -0,0 +1,49 @@
1
+ import { platform, arch, release } from 'node:os'
2
+
3
+ const PLATFORM_NOTES = {
4
+ darwin: `macOS (darwin ${arch()}, kernel ${release()}). BSD userland: GNU-only tools like timeout, tac, gdate, or sed -i without a suffix argument are absent or behave differently. Prefer portable POSIX forms, and check a tool exists before relying on it.`,
5
+ linux: `Linux (${arch()}, kernel ${release()}). GNU userland.`,
6
+ win32: `Windows (${arch()}). Commands run through a POSIX-style shell; prefer portable forms.`,
7
+ }
8
+
9
+ export function buildSystemPrompt({ cwd, contextFiles = [], skills = [], memoryIndexText = '' }) {
10
+ const now = new Date()
11
+ const parts = [
12
+ `You are pico, a coding agent running in a terminal.`,
13
+ ``,
14
+ `Working directory: ${cwd}`,
15
+ `Platform: ${PLATFORM_NOTES[platform()] || platform()}`,
16
+ `Current date and time: ${now.toString()}`,
17
+ ``,
18
+ `Use the available tools to read, search, and modify files, and to run commands.`,
19
+ `Use absolute paths in tool calls.`,
20
+ `Prefer the built-in tools for file and shell work: read, write, edit, bash, glob, grep.`,
21
+ `Tools with server-prefixed names come from MCP servers: reach for them only when they provide a capability the built-in tools do not, never as a substitute for simple file access or shell commands.`,
22
+ `Prefer reading files before editing them. Keep edits minimal and precise.`,
23
+ `When a tool result includes context_from_agents_md, treat it as project instructions that apply from that point on.`,
24
+ `Tools are yours alone: the user cannot invoke them, so never suggest the user run a tool. Do it yourself, or describe the outcome instead.`,
25
+ `Delegate only when a worker's result will materially inform the task. Workers do not receive the parent conversation, so every worker prompt must be a self-contained brief containing all conversation-specific terms, goals, constraints, and decisions that cannot be discovered from the available files and tools. Before starting an agent, identify the work that depends on its result; while it runs, perform only genuinely independent work, then collect the result before making dependent decisions or edits. If you can confidently complete the task without the result, do not start the agent.`,
26
+ `Deliberation and delegation serve different purposes. Use deliberate when the task is to evaluate competing approaches, challenge a proposal, or reach a consequential decision under genuine uncertainty. Use ordinary agents when independent work can be divided and collected. When the user requests deliberation, immediately call deliberate with a self-contained brief; do not research first or approximate deliberation with multiple ordinary agents. The deliberation participants own all supporting research. Do not deliberate routine implementation or questions answerable through direct research.`,
27
+ `Be direct and concise. Use markdown. Never invent file contents you have not read.`,
28
+ `When providing links that you want the user to be able to click, output either plain URLs or unfenced Markdown links.`,
29
+ `Announcing future work and stopping is forbidden. If your reply contains phrases like "I'll update", "I will now", or "Next I'll", you must make those tool calls in this same turn instead of ending it. A turn may only end in one of two states: the work is done, or you are asking the user a question and waiting. When the user asks "should we do X?" and your answer is yes, say yes briefly and then do X immediately in the same turn; stop to ask first only when doing X is destructive, expensive, or genuinely ambiguous.`,
30
+ ]
31
+
32
+ if (memoryIndexText) {
33
+ parts.push(``, memoryIndexText)
34
+ }
35
+
36
+ if (skills.length) {
37
+ parts.push(
38
+ ``,
39
+ `Skills available via the skill tool (load one when its description matches the task):`,
40
+ ...skills.map((s) => `- ${s.name}: ${s.description}`),
41
+ )
42
+ }
43
+
44
+ for (const file of contextFiles) {
45
+ parts.push(``, `Project instructions from ${file.path}:`, ``, file.content.trim())
46
+ }
47
+
48
+ return parts.join('\n')
49
+ }
@@ -0,0 +1,49 @@
1
+ export function parseOsc11(response) {
2
+ const match = response.match(/\]11;rgb:([0-9a-f]+)\/([0-9a-f]+)\/([0-9a-f]+)/i)
3
+ if (!match) return null
4
+ const channel = (hex) => parseInt(hex, 16) / (16 ** hex.length - 1)
5
+ const luminance = 0.2126 * channel(match[1]) + 0.7152 * channel(match[2]) + 0.0722 * channel(match[3])
6
+ return luminance > 0.5 ? 'light' : 'dark'
7
+ }
8
+
9
+ export function themeFromColorfgbg(value) {
10
+ const token = (value || '').split(';').at(-1)
11
+ if (!/^\d+$/.test(token)) return null
12
+ const bg = Number(token)
13
+ return bg === 7 || bg === 15 ? 'light' : 'dark'
14
+ }
15
+
16
+ export function detectTerminalTheme({ timeoutMs = 150 } = {}) {
17
+ const fallback = () => themeFromColorfgbg(process.env.COLORFGBG) || 'dark'
18
+ if (!process.stdin.isTTY || !process.stdout.isTTY) return Promise.resolve(fallback())
19
+
20
+ return new Promise((resolve) => {
21
+ let buffer = ''
22
+ let settled = false
23
+ const wasRaw = process.stdin.isRaw
24
+
25
+ // never pause() here: an explicit pause sticks, and trend's mount only
26
+ // attaches a data listener, which will not un-pause an explicitly
27
+ // paused stream - input would be frozen for the whole session
28
+ const finish = (theme) => {
29
+ if (settled) return
30
+ settled = true
31
+ clearTimeout(timer)
32
+ process.stdin.off('data', onData)
33
+ process.stdin.setRawMode(wasRaw)
34
+ resolve(theme)
35
+ }
36
+
37
+ const onData = (chunk) => {
38
+ buffer += chunk.toString('latin1')
39
+ const theme = parseOsc11(buffer)
40
+ if (theme) finish(theme)
41
+ }
42
+
43
+ const timer = setTimeout(() => finish(fallback()), timeoutMs)
44
+ process.stdin.setRawMode(true)
45
+ process.stdin.on('data', onData)
46
+ process.stdin.resume()
47
+ process.stdout.write('\x1b]11;?\x1b\\')
48
+ })
49
+ }
@@ -0,0 +1,184 @@
1
+ import { spawn } from 'node:child_process'
2
+ import { sgr, stripAnsi, updateSgrState } from '@trendr/core'
3
+
4
+ const MAX_OUTPUT_CHARS = 30000
5
+ const MAX_BUFFER_BYTES = 10 * 1024 * 1024
6
+ const OUTPUT_PREVIEW_LINES = 10
7
+ const MAX_PREVIEW_LINE_CHARS = 4000
8
+ export const AUTO_BACKGROUND_MS = 150000
9
+
10
+ function capped(text) {
11
+ if (text.length <= MAX_OUTPUT_CHARS) return text
12
+ return text.slice(0, MAX_OUTPUT_CHARS) + `\n[output truncated at ${MAX_OUTPUT_CHARS} characters]`
13
+ }
14
+
15
+ function createOutputPreview() {
16
+ let lines = []
17
+ let current = ''
18
+ let currentStartStyle = ''
19
+ let completed = 0
20
+ const ansiState = { fg: null, bg: null, attrs: 0 }
21
+
22
+ function add(chunk) {
23
+ const parts = String(chunk).split('\n')
24
+ current = (current + parts[0]).slice(0, MAX_PREVIEW_LINE_CHARS)
25
+ updateSgrState(parts[0], ansiState)
26
+ for (let i = 1; i < parts.length; i++) {
27
+ lines.push({ text: current, startStyle: currentStartStyle })
28
+ if (lines.length > OUTPUT_PREVIEW_LINES) lines.shift()
29
+ completed++
30
+ currentStartStyle = ansiState.fg != null || ansiState.bg != null || ansiState.attrs ? sgr(ansiState.fg, ansiState.bg, ansiState.attrs) : ''
31
+ current = parts[i].slice(0, MAX_PREVIEW_LINE_CHARS)
32
+ updateSgrState(parts[i], ansiState)
33
+ }
34
+ }
35
+
36
+ function snapshot() {
37
+ const hasCurrentText = stripAnsi(current).length > 0
38
+ const visible = hasCurrentText ? [...lines, { text: current, startStyle: currentStartStyle }].slice(-OUTPUT_PREVIEW_LINES) : lines
39
+ if (!visible.length) return null
40
+ const count = completed + (hasCurrentText ? 1 : 0)
41
+ return {
42
+ fullOutput: visible.map((line) => `${line.startStyle}${line.text}`).join('\n'),
43
+ outputLineStart: count - visible.length + 1,
44
+ outputLineCount: count,
45
+ }
46
+ }
47
+
48
+ return { add, snapshot }
49
+ }
50
+
51
+ function killTree(child, signal = 'SIGTERM') {
52
+ if (!child.pid) return false
53
+ if (process.platform !== 'win32') {
54
+ try {
55
+ process.kill(-child.pid, signal)
56
+ return true
57
+ } catch {}
58
+ }
59
+ return child.kill(signal)
60
+ }
61
+
62
+ export function createBash({ cwd, env, recorder, signal, shells, sessionId, sessionFile, autoBackgroundMs = AUTO_BACKGROUND_MS }) {
63
+ return {
64
+ name: 'bash',
65
+ description: 'Run a shell command in the working directory. Each call is a fresh shell: cd does not persist to later calls, so chain directory changes within one command (cd /x && ls) or use absolute paths. Returns stdout, stderr, and exit code. Foreground commands still running after 150 seconds are automatically backgrounded. Run known long-lived commands with background true. Background shells notify you when they exit. If no independent work remains, end your turn and wait for that notification instead of polling. Use shell_output only when intermediate output is needed to diagnose a problem or make a decision before exit. Stop shells with shell_kill.',
66
+ schema: {
67
+ command: { type: 'string', description: 'the command to run' },
68
+ timeout: { type: 'number', description: 'optional foreground timeout in milliseconds; commands still running after 150 seconds are backgrounded instead', optional: true },
69
+ background: { type: 'boolean', description: 'run in the background and return a shell id immediately', optional: true },
70
+ description: { type: 'string', description: 'a few words explaining the purpose of this command, shown to the human watching' },
71
+ },
72
+ execute: ({ command, timeout, background, description }) => {
73
+ if (background && shells) {
74
+ recorder.extra({ title: command, titleLang: 'bash', description, background: true })
75
+ const { id } = shells.start(command, { cwd, env, description, sessionId, sessionFile })
76
+ return {
77
+ shellId: id,
78
+ status: 'running',
79
+ note: 'the shell will notify you when it exits; if no independent work remains, end your turn and wait instead of polling with shell_output, wake-ups, sleeps, or another shell',
80
+ }
81
+ }
82
+ return new Promise((resolve) => {
83
+ recorder.extra({ title: command, titleLang: 'bash' })
84
+ const child = spawn(command, {
85
+ shell: true,
86
+ cwd: cwd || process.cwd(),
87
+ env: { ...process.env, ...env, FORCE_COLOR: '0', NO_COLOR: '1' },
88
+ stdio: ['ignore', 'pipe', 'pipe'],
89
+ detached: process.platform !== 'win32',
90
+ })
91
+ const { id } = shells
92
+ ? shells.track(child, command, { cwd, description, sessionId, sessionFile, hidden: true })
93
+ : { id: null }
94
+ let stdout = ''
95
+ let stderr = ''
96
+ let settled = false
97
+ let timedOut = false
98
+ let killTimer = null
99
+ const preview = createOutputPreview()
100
+
101
+ const updateOutput = () => {
102
+ const snapshot = preview.snapshot()
103
+ if (snapshot) recorder.extra(snapshot)
104
+ }
105
+ const collectStdout = (chunk) => {
106
+ stdout = (stdout + chunk).slice(-MAX_BUFFER_BYTES)
107
+ preview.add(chunk)
108
+ updateOutput()
109
+ }
110
+ const collectStderr = (chunk) => {
111
+ stderr = (stderr + chunk).slice(-MAX_BUFFER_BYTES)
112
+ preview.add(chunk)
113
+ updateOutput()
114
+ }
115
+ child.stdout.on('data', collectStdout)
116
+ child.stderr.on('data', collectStderr)
117
+
118
+ const timeoutTimer = timeout
119
+ ? setTimeout(() => {
120
+ timedOut = true
121
+ terminate()
122
+ }, timeout)
123
+ : null
124
+ const backgroundTimer = shells
125
+ ? setTimeout(() => {
126
+ if (settled) return
127
+ settled = true
128
+ clearTimeout(timeoutTimer)
129
+ cleanup()
130
+ shells.reveal(id)
131
+ recorder.extra({ title: command, titleLang: 'bash', description, background: true })
132
+ resolve({
133
+ shellId: id,
134
+ status: 'running',
135
+ note: `automatically backgrounded after ${autoBackgroundMs}ms; the shell will notify you when it exits; if no independent work remains, end your turn and wait instead of polling`,
136
+ })
137
+ }, autoBackgroundMs)
138
+ : null
139
+ backgroundTimer?.unref?.()
140
+ timeoutTimer?.unref?.()
141
+
142
+ function cleanup() {
143
+ if (signal) signal.removeEventListener('abort', abort)
144
+ child.stdout.off('data', collectStdout)
145
+ child.stderr.off('data', collectStderr)
146
+ clearTimeout(killTimer)
147
+ }
148
+ function terminate() {
149
+ if (!killTree(child, 'SIGTERM')) return
150
+ clearTimeout(killTimer)
151
+ killTimer = setTimeout(() => killTree(child, 'SIGKILL'), 3000)
152
+ killTimer.unref?.()
153
+ }
154
+ function abort() {
155
+ terminate()
156
+ }
157
+ if (signal) {
158
+ if (signal.aborted) terminate()
159
+ else signal.addEventListener('abort', abort, { once: true })
160
+ }
161
+ child.on('close', (code) => {
162
+ if (settled) return
163
+ settled = true
164
+ clearTimeout(backgroundTimer)
165
+ clearTimeout(timeoutTimer)
166
+ cleanup()
167
+ if (id) shells.discardHidden(id)
168
+ const exitCode = code ?? 1
169
+ updateOutput()
170
+ recorder.extra({ exitCode })
171
+ resolve({
172
+ stdout: capped(stdout),
173
+ stderr: capped(stderr),
174
+ exitCode,
175
+ ...(timedOut && {
176
+ timedOut: true,
177
+ note: `killed at the ${timeout}ms timeout`,
178
+ }),
179
+ })
180
+ })
181
+ })
182
+ },
183
+ }
184
+ }
@@ -0,0 +1,18 @@
1
+ import { structuredPatch } from 'diff'
2
+
3
+ export function makeDiff(path, oldText, newText, contextLines = 3) {
4
+ const patch = structuredPatch(path, path, oldText, newText, '', '', { context: contextLines })
5
+ let additions = 0
6
+ let deletions = 0
7
+ const hunks = patch.hunks.map((h) => ({
8
+ oldStart: h.oldStart,
9
+ newStart: h.newStart,
10
+ lines: h.lines.map((line) => {
11
+ const type = line[0] === '-' ? 'remove' : line[0] === '+' ? 'add' : 'context'
12
+ if (type === 'add') additions++
13
+ if (type === 'remove') deletions++
14
+ return { type, text: line.slice(1) }
15
+ }),
16
+ }))
17
+ return { path, hunks, additions, deletions }
18
+ }