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.
- package/README.md +15 -0
- package/package.json +33 -0
- package/src/agent-transcript.js +80 -0
- package/src/agent.js +170 -0
- package/src/agents.js +213 -0
- package/src/attachments.js +141 -0
- package/src/boot.js +28 -0
- package/src/catalog-snapshot.json +1 -0
- package/src/catalog.js +86 -0
- package/src/codex-models.js +56 -0
- package/src/commands.js +61 -0
- package/src/compaction.js +82 -0
- package/src/completion.js +21 -0
- package/src/config.js +32 -0
- package/src/context.js +82 -0
- package/src/controller.js +1263 -0
- package/src/conversation-search.js +101 -0
- package/src/deliberation-history.js +65 -0
- package/src/deliberation.js +61 -0
- package/src/derive.js +307 -0
- package/src/events.js +54 -0
- package/src/export.js +16 -0
- package/src/files.js +39 -0
- package/src/format.js +6 -0
- package/src/fuzzy.js +41 -0
- package/src/git.js +156 -0
- package/src/history.js +51 -0
- package/src/init.js +25 -0
- package/src/keys.js +26 -0
- package/src/mcp.js +280 -0
- package/src/memory.js +120 -0
- package/src/models.js +14 -0
- package/src/openai-auth.js +204 -0
- package/src/paths.js +67 -0
- package/src/reversible-edit.js +79 -0
- package/src/rewind.js +84 -0
- package/src/session-index.js +264 -0
- package/src/session-lock.js +27 -0
- package/src/session.js +160 -0
- package/src/shells.js +166 -0
- package/src/skills.js +164 -0
- package/src/steer.js +129 -0
- package/src/system-prompt.js +49 -0
- package/src/terminal-theme.js +49 -0
- package/src/tools/bash.js +184 -0
- package/src/tools/diff.js +18 -0
- package/src/tools/edit.js +95 -0
- package/src/tools/glob.js +43 -0
- package/src/tools/grep.js +59 -0
- package/src/tools/index.js +296 -0
- package/src/tools/read.js +49 -0
- package/src/tools/recorder.js +74 -0
- package/src/tools/web.js +84 -0
- package/src/tools/write.js +48 -0
- package/src/update.js +82 -0
- package/src/user-tools.js +59 -0
- 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
|
+
}
|