Updates to Agent Harness

This commit is contained in:
2026-08-18 15:38:18 -04:00
parent e081b719d0
commit a94de5be1d
19 changed files with 2350 additions and 167 deletions
+292 -34
View File
@@ -43,6 +43,12 @@ var BARE_AGENT_PLAN_READONLY_TOOLS = Object.freeze({
memory_get: 1,
memory_append: 1,
list_skills: 1,
fuzzy_find: 1,
wait_for: 1,
read_many: 1,
git_diff: 1,
history_search: 1,
list_scheduled: 1,
todo_write: 1,
enter_plan_mode: 1,
exit_plan_mode: 1,
@@ -447,37 +453,9 @@ function bareAgentHookDenies(hook, toolName, args) {
* @param {Record<string, unknown>} args
*/
async function bareAgentRunPreToolHooks(ctx, hooksDir, toolName, args) {
const vfs = ctx && ctx.vfs
if (!vfs || typeof vfs.readdir !== 'function' || typeof vfs.readFile !== 'function') {
return ''
}
let names = []
try {
names = await vfs.readdir(hooksDir)
} catch {
return ''
}
if (!Array.isArray(names)) return ''
names = names.filter(function (n) {
return /\.json$/i.test(String(n || ''))
})
names.sort()
for (let i = 0; i < names.length; i++) {
try {
const buf = await vfs.readFile(hooksDir + '/' + names[i])
if (!buf || !buf.length) continue
const t =
typeof ctx.b4a !== 'undefined' &&
ctx.b4a &&
typeof ctx.b4a.toString === 'function'
? ctx.b4a.toString(buf)
: String(new TextDecoder().decode(buf))
const hook = JSON.parse(t)
const reason = bareAgentHookDenies(hook, toolName, args)
if (reason) return reason
} catch {
/* ignore bad hook files */
}
if (typeof bareAgentRunHooks === 'function') {
const out = await bareAgentRunHooks(ctx, hooksDir, 'PreToolUse', toolName, args, '')
return out.deny || ''
}
return ''
}
@@ -620,12 +598,17 @@ async function bareAgentVfsWalkFiles(ctx, root, opts) {
skip['.git'] = 1
skip['node_modules'] = 1
skip['.bare-os'] = 1
const rootN = String(root || '/').replace(/\/+$/, '') || '/'
const rules =
opts && Array.isArray(opts.ignore)
? opts.ignore
: typeof bareAgentLoadIgnoreRules === 'function'
? await bareAgentLoadIgnoreRules(ctx, rootN)
: []
/** @type {string[]} */
const out = []
/** @type {{ dir: string, depth: number }[]} */
const queue = [
{ dir: String(root || '/').replace(/\/+$/, '') || '/', depth: 0 }
]
const queue = [{ dir: rootN, depth: 0 }]
while (queue.length && out.length < maxFiles) {
const cur = queue.shift()
if (!cur) break
@@ -640,7 +623,11 @@ async function bareAgentVfsWalkFiles(ctx, root, opts) {
const name = String(names[i] || '')
if (!name || name === '.' || name === '..' || skip[name]) continue
const full = (cur.dir === '/' ? '' : cur.dir) + '/' + name
let rel = full
if (rootN !== '/' && full.indexOf(rootN + '/') === 0) rel = full.slice(rootN.length + 1)
else if (full.charAt(0) === '/') rel = full.slice(1)
const isDir = await bareAgentVfsIsDir(ctx, full)
if (rules.length && bareAgentIgnoreMatch(rel, isDir, rules)) continue
if (isDir) {
if (cur.depth + 1 < maxDepth) queue.push({ dir: full, depth: cur.depth + 1 })
} else {
@@ -1247,6 +1234,277 @@ function bareAgentParseScheduleInterval(raw) {
/**
* @param {string} id
*/
function bareAgentParseIgnoreRules(text) {
const lines = String(text || '').split(/\r?\n/)
/** @type {{ pattern: string, negate: boolean, dirOnly: boolean }[]} */
const rules = []
for (let i = 0; i < lines.length; i++) {
let line = String(lines[i] || '').trim()
if (!line || line.charAt(0) === '#') continue
let negate = false
if (line.charAt(0) === '!') {
negate = true
line = line.slice(1)
}
let dirOnly = false
if (line.charAt(line.length - 1) === '/') {
dirOnly = true
line = line.slice(0, -1)
}
if (line.charAt(0) === '/') line = line.slice(1)
if (!line) continue
rules.push({ pattern: line, negate, dirOnly })
}
return rules
}
/**
* Last matching gitignore-style rule wins.
* @param {string} rel
* @param {boolean} isDir
* @param {{ pattern: string, negate: boolean, dirOnly: boolean }[]} rules
*/
function bareAgentIgnoreMatch(rel, isDir, rules) {
const n = String(rel || '').replace(/^\/+/, '')
if (!n || !Array.isArray(rules) || !rules.length) return false
let ignored = false
for (let i = 0; i < rules.length; i++) {
const rule = rules[i]
if (rule.dirOnly && !isDir) continue
let hit = false
if (rule.pattern.indexOf('/') === -1) {
const parts = n.split('/')
for (let p = 0; p < parts.length; p++) {
if (bareAgentGlobMatch(parts[p], rule.pattern)) {
hit = true
break
}
}
} else {
hit = bareAgentGlobMatch(n, rule.pattern) || bareAgentGlobMatch(n, '**/' + rule.pattern)
}
if (!hit) continue
ignored = !rule.negate
}
return ignored
}
/**
* @param {Record<string, unknown>} ctx
* @param {string} root
*/
async function bareAgentLoadIgnoreRules(ctx, root) {
const names = ['.gitignore', '.agentignore', '.grokignore']
/** @type {string[]} */
const chunks = []
for (let i = 0; i < names.length; i++) {
const t = await bareAgentReadTextFile(ctx, String(root || '').replace(/\/+$/, '') + '/' + names[i])
if (t && t.trim()) chunks.push(t)
}
return bareAgentParseIgnoreRules(chunks.join('\n'))
}
/**
* Simple Grok-style fuzzy score (basename + path subsequence). Higher is better.
* @param {string} query
* @param {string} path
*/
function bareAgentFuzzyScore(query, path) {
const q = String(query || '').toLowerCase().trim()
const p = String(path || '')
if (!q || !p) return 0
const low = p.toLowerCase()
const base = (p.split('/').pop() || p).toLowerCase()
if (base === q) return 1000
if (base.indexOf(q) === 0) return 800 - Math.min(base.length, 80)
if (base.indexOf(q) !== -1) return 600 - base.indexOf(q)
if (low.indexOf(q) !== -1) return 400
let qi = 0
let score = 0
let streak = 0
for (let i = 0; i < low.length && qi < q.length; i++) {
if (low.charAt(i) === q.charAt(qi)) {
qi++
streak++
score += 8 + streak * 4
} else streak = 0
}
if (qi < q.length) return 0
if (base.length && q.length / base.length > 0.5) score += 40
return score
}
/**
* @param {Record<string, unknown>} ctx
* @param {string} root
* @param {string} query
* @param {{ max?: number, maxFiles?: number }} [opts]
*/
async function bareAgentFuzzyFind(ctx, root, query, opts) {
const files = await bareAgentVfsWalkFiles(ctx, root, {
maxFiles: Math.min(Math.max(Number(opts && opts.maxFiles) || 600, 40), 2000),
maxDepth: 12
})
const cap = Math.min(Math.max(Number(opts && opts.max) || 20, 1), 80)
/** @type {{ path: string, score: number }[]} */
const scored = []
for (let i = 0; i < files.length; i++) {
const score = bareAgentFuzzyScore(query, files[i])
if (score <= 0) continue
scored.push({ path: files[i], score })
}
scored.sort(function (a, b) {
return b.score - a.score
})
return scored.slice(0, cap)
}
/**
* @param {Record<string, unknown>} hook
* @param {string} event
* @param {string} toolName
* @param {Record<string, unknown>} args
* @param {string} [result]
*/
function bareAgentHookMatch(hook, event, toolName, args, result) {
if (!hook || typeof hook !== 'object') return null
const ev = String(hook.event || hook.type || 'PreToolUse')
const want = String(event || '')
if (ev !== want && ev.toLowerCase() !== want.toLowerCase()) return null
const tools = Array.isArray(hook.tools) ? hook.tools.map(String) : []
if (tools.length && toolName && tools.indexOf(toolName) === -1) return null
const reSrc = String(hook.deny_regex || hook.match_regex || hook.denyRegex || '').trim()
if (reSrc) {
let re
try {
re = new RegExp(reSrc, 'i')
} catch {
return null
}
const blob =
toolName +
' ' +
String((args && (args.command || args.path || args.file_path || args.pattern)) || '') +
' ' +
String(result || '').slice(0, 400)
if (!re.test(blob)) return null
}
return hook
}
/**
* @param {Record<string, unknown>} ctx
* @param {string} hooksDir
* @param {string} event
* @param {string} [toolName]
* @param {Record<string, unknown>} [args]
* @param {string} [result]
*/
async function bareAgentRunHooks(ctx, hooksDir, event, toolName, args, result) {
const vfs = ctx && ctx.vfs
if (!vfs || typeof vfs.readdir !== 'function' || typeof vfs.readFile !== 'function') {
return { deny: '', inject: '' }
}
let names = []
try {
names = await vfs.readdir(hooksDir)
} catch {
return { deny: '', inject: '' }
}
if (!Array.isArray(names)) return { deny: '', inject: '' }
names = names.filter(function (n) {
return /\.json$/i.test(String(n || ''))
})
names.sort()
/** @type {string[]} */
const injects = []
for (let i = 0; i < names.length; i++) {
try {
const t = await bareAgentReadTextFile(ctx, hooksDir + '/' + names[i])
if (!t.trim()) continue
const hook = JSON.parse(t)
const matched = bareAgentHookMatch(hook, event, toolName || '', args || {}, result || '')
if (!matched) continue
if (event === 'PreToolUse' || event === 'pre') {
const reason = String(matched.reason || matched.message || '').trim()
if (reason && (matched.deny === true || matched.deny_regex || matched.denyRegex)) {
return { deny: reason.slice(0, 240), inject: '' }
}
if (typeof bareAgentHookDenies === 'function') {
const d = bareAgentHookDenies(matched, toolName || '', args || {})
if (d) return { deny: d, inject: '' }
}
}
const inj = String(matched.inject || matched.append || '').trim()
if (inj) injects.push(inj.slice(0, 800))
} catch {
/* ignore */
}
}
return { deny: '', inject: injects.join('\n') }
}
/**
* @param {Record<string, unknown>} ctx
* @param {string} editsPath
* @param {{ path: string, prev: string, tool?: string }} rec
*/
async function bareAgentPushEdit(ctx, editsPath, rec) {
const prev = await bareAgentReadJsonFile(ctx, editsPath, [])
const list = Array.isArray(prev) ? prev : []
list.push({
path: String(rec.path || ''),
prev: String(rec.prev == null ? '' : rec.prev),
tool: String(rec.tool || ''),
ts: new Date().toISOString()
})
while (list.length > 20) list.shift()
await bareAgentWriteJsonFile(ctx, editsPath, list)
}
/**
* @param {Record<string, unknown>} ctx
* @param {string} editsPath
*/
async function bareAgentPopEdit(ctx, editsPath) {
const prev = await bareAgentReadJsonFile(ctx, editsPath, [])
const list = Array.isArray(prev) ? prev : []
const last = list.pop()
await bareAgentWriteJsonFile(ctx, editsPath, list)
return last && typeof last === 'object' ? last : null
}
/**
* @param {unknown[]} messages
* @param {string} query
* @param {number} [max]
*/
function bareAgentHistorySearch(messages, query, max) {
const tokens = bareAgentMemoryTokens(query)
const cap = Math.min(Math.max(Number(max) || 8, 1), 24)
if (!Array.isArray(messages) || !tokens.length) return []
/** @type {{ role: string, score: number, snippet: string }[]} */
const hits = []
for (let i = 0; i < messages.length; i++) {
const m = messages[i] && typeof messages[i] === 'object' ? messages[i] : null
if (!m) continue
const role = String(m.role || '')
if (role !== 'user' && role !== 'assistant') continue
const text = String(m.content || '')
const score = bareAgentMemoryScore(text, tokens)
if (score <= 0) continue
hits.push({
role,
score: Math.round(score * 1000) / 1000,
snippet: text.replace(/\s+/g, ' ').trim().slice(0, 280)
})
}
hits.sort(function (a, b) {
return b.score - a.score
})
return hits.slice(0, cap)
}
function bareAgentScheduleId(id) {
let s = String(id || '')
.trim()
@@ -42,7 +42,9 @@ function bareAgentPaths(home) {
todos: base + '/todos.json',
plan: base + '/plan.md',
hooks: base + '/hooks',
ask: base + '/ask.json'
ask: base + '/ask.json',
edits: base + '/edits.json',
lastCwd: base + '/last_cwd'
}
}
+332 -3
View File
@@ -188,6 +188,10 @@ function bareAgentToolDefinitions() {
type: 'string',
description: 'Full command string (e.g. ls -la /bin)'
},
cwd: {
type: 'string',
description: 'Working directory (cd there first). Persists as ~/.agent/last_cwd.'
},
timeout_ms: { type: 'integer' },
capture_exit: {
type: 'boolean',
@@ -1257,6 +1261,107 @@ function bareAgentToolDefinitions() {
}
}
},
{
type: 'function',
function: {
name: 'list_scheduled',
description:
'List guest agent timers (agent-*.timer) under ~/.config/bare-os/timers, with previews.',
parameters: { type: 'object', properties: {} }
}
},
{
type: 'function',
function: {
name: 'fuzzy_find',
description:
'Fuzzy-search file names under a root (Grok-style). Prefer this when you remember part of a filename.',
parameters: {
type: 'object',
properties: {
query: { type: 'string' },
root: { type: 'string', description: 'Directory to walk (default home)' },
max: { type: 'integer', description: 'Default 20, cap 80' }
},
required: ['query']
}
}
},
{
type: 'function',
function: {
name: 'read_many',
description:
'Read several UTF-8 files in one call (bounded). Prefer over many read_file calls for a small set.',
parameters: {
type: 'object',
properties: {
paths: { type: 'array', items: { type: 'string' } },
max_bytes_each: { type: 'integer', description: 'Default 32000' }
},
required: ['paths']
}
}
},
{
type: 'function',
function: {
name: 'wait_for',
description:
'Poll a file or guest command until a regex matches, or timeout (Grok monitor lite; sync).',
parameters: {
type: 'object',
properties: {
pattern: { type: 'string', description: 'JS regex' },
path: { type: 'string', description: 'File to poll' },
command: { type: 'string', description: 'Guest command whose output is polled' },
timeout_ms: { type: 'integer', description: 'Default 15000, cap 120000' },
interval_ms: { type: 'integer', description: 'Default 400, min 100' },
ignore_case: { type: 'boolean' }
},
required: ['pattern']
}
}
},
{
type: 'function',
function: {
name: 'undo_last_edit',
description:
'Restore the last file snapshot from ~/.agent/edits.json (write_file / edit / apply_patch / delete).',
parameters: { type: 'object', properties: {} }
}
},
{
type: 'function',
function: {
name: 'git_diff',
description: 'git diff (optional path / --stat) via /bin/git. Prefer over raw run_command.',
parameters: {
type: 'object',
properties: {
cwd: { type: 'string' },
path: { type: 'string' },
stat: { type: 'boolean', description: 'Only --stat (default false)' }
}
}
}
},
{
type: 'function',
function: {
name: 'history_search',
description: 'Search this session\'s ~/.agent/history.json by keyword.',
parameters: {
type: 'object',
properties: {
query: { type: 'string' },
max: { type: 'integer' }
},
required: ['query']
}
}
},
{
type: 'function',
function: {
@@ -2037,6 +2142,14 @@ async function bareAgentDispatchTool(o) {
return bareAgentJsonResult({ ok: false, error: 'vfs unavailable' })
}
appendProgress('write_file ' + path)
if (typeof bareAgentPushEdit === 'function' && paths.edits) {
try {
const prev = await bareAgentReadTextFile(ctx, path)
await bareAgentPushEdit(ctx, paths.edits, { path, prev, tool: 'write_file' })
} catch {
/* new file */
}
}
let dir = path.replace(/\/[^/]+$/, '')
if (dir && dir !== path) await vfs.mkdir(dir, { recursive: true })
const body =
@@ -2062,6 +2175,21 @@ async function bareAgentDispatchTool(o) {
}
appendProgress(toolName + ' ' + path)
const buf = await vfs.readFile(path)
if (typeof bareAgentPushEdit === 'function' && paths.edits) {
try {
const snap =
buf && buf.length
? typeof ctx.b4a !== 'undefined' &&
ctx.b4a &&
typeof ctx.b4a.toString === 'function'
? ctx.b4a.toString(buf)
: String(new TextDecoder().decode(buf))
: ''
await bareAgentPushEdit(ctx, paths.edits, { path, prev: snap, tool: toolName })
} catch {
/* ignore */
}
}
let prev =
buf && buf.length
? typeof ctx.b4a !== 'undefined' &&
@@ -2186,10 +2314,30 @@ async function bareAgentDispatchTool(o) {
if (!execLine) {
return bareAgentJsonResult({ ok: false, error: 'execLine unavailable' })
}
appendProgress('run_command ' + command.slice(0, 160))
const r = await captureExec(command, timeoutMs, { captureExit })
let cwd = typeof args.cwd === 'string' ? args.cwd.trim() : ''
if (!cwd && paths.lastCwd) {
try {
cwd = (await bareAgentReadTextFile(ctx, paths.lastCwd)).trim()
} catch {
cwd = ''
}
}
let line = command
if (cwd) {
if (!bareAgentPathAllowed(cwd)) {
return bareAgentJsonResult({ ok: false, error: 'cwd_not_allowed' })
}
line = 'cd ' + bareAgentShellQuote(cwd) + ' && ' + command
try {
if (paths.lastCwd) await bareAgentWriteTextFile(ctx, paths.lastCwd, cwd + '\n')
} catch {
/* optional */
}
}
appendProgress('run_command ' + line.slice(0, 160))
const r = await captureExec(line, timeoutMs, { captureExit })
return bareAgentJsonResult(
r.ok === false ? r : { ok: true, stdout_stderr: r.stdout_stderr }
r.ok === false ? r : { ok: true, cwd: cwd || null, stdout_stderr: r.stdout_stderr }
)
}
@@ -2467,6 +2615,14 @@ async function bareAgentDispatchTool(o) {
return bareAgentJsonResult({ ok: false, error: 'vfs unavailable' })
}
appendProgress('delete_path ' + path)
if (typeof bareAgentPushEdit === 'function' && paths.edits) {
try {
const prev = await bareAgentReadTextFile(ctx, path)
await bareAgentPushEdit(ctx, paths.edits, { path, prev, tool: 'delete_path' })
} catch {
/* missing */
}
}
try {
/** @type {unknown} */
let st = null
@@ -3370,6 +3526,19 @@ async function bareAgentDispatchTool(o) {
const parsed = bareAgentParseApplyPatch(patch)
if (!parsed.ok) return bareAgentJsonResult(parsed)
appendProgress('apply_patch ops=' + String((parsed.ops || []).length))
if (typeof bareAgentPushEdit === 'function' && paths.edits && parsed.ops) {
for (let i = 0; i < parsed.ops.length; i++) {
const op = parsed.ops[i]
const p = String((op && op.path) || '')
if (!p) continue
try {
const prev = await bareAgentReadTextFile(ctx, p)
await bareAgentPushEdit(ctx, paths.edits, { path: p, prev, tool: 'apply_patch' })
} catch {
/* new file */
}
}
}
const out = await bareAgentApplyPatchOps(ctx, parsed.ops, {
home,
denyPrefixes: mutateDenyPrefixes
@@ -3647,6 +3816,166 @@ async function bareAgentDispatchTool(o) {
return bareAgentJsonResult({ ok: true, id, path: dest })
}
if (toolName === 'list_scheduled') {
const dir = (home || '/home') + '/.config/bare-os/timers'
let names = []
try {
names = vfs && typeof vfs.readdir === 'function' ? await vfs.readdir(dir) : []
} catch {
names = []
}
const rows = []
const list = Array.isArray(names) ? names : []
for (let i = 0; i < list.length; i++) {
const name = String(list[i] || '')
if (!name.endsWith('.timer') || name.indexOf('agent-') !== 0) continue
const path = dir + '/' + name
const preview = await bareAgentReadTextFile(ctx, path)
rows.push({ id: name.replace(/\.timer$/, ''), path, preview: preview.slice(0, 400) })
}
appendProgress('list_scheduled ' + String(rows.length))
return bareAgentJsonResult({ ok: true, count: rows.length, timers: rows })
}
if (toolName === 'fuzzy_find') {
const query = typeof args.query === 'string' ? args.query.trim() : ''
if (!query) return bareAgentJsonResult({ ok: false, error: 'query_required' })
const root =
(typeof args.root === 'string' && args.root.trim()) || home || '/home'
if (typeof bareAgentFuzzyFind !== 'function') {
return bareAgentJsonResult({ ok: false, error: 'fuzzy_unavailable' })
}
appendProgress('fuzzy_find ' + query)
const hits = await bareAgentFuzzyFind(ctx, root, query, { max: args.max })
return bareAgentJsonResult({ ok: true, query, root, hits })
}
if (toolName === 'read_many') {
const pathsIn = Array.isArray(args.paths) ? args.paths : []
if (!pathsIn.length) return bareAgentJsonResult({ ok: false, error: 'paths_required' })
const maxB =
typeof args.max_bytes_each === 'number' && Number.isFinite(args.max_bytes_each)
? Math.min(Math.max(Math.floor(args.max_bytes_each), 200), 200000)
: 32000
const files = []
for (let i = 0; i < pathsIn.length && i < 16; i++) {
const path = String(pathsIn[i] || '').trim()
if (!path || !bareAgentPathAllowed(path)) {
files.push({ path, ok: false, error: 'path_not_allowed' })
continue
}
let text = await bareAgentReadTextFile(ctx, path)
const truncated = text.length > maxB
if (truncated) text = text.slice(0, maxB) + '\n… truncated'
files.push({ path, ok: true, content: text, truncated })
}
appendProgress('read_many ' + String(files.length))
return bareAgentJsonResult({ ok: true, files })
}
if (toolName === 'wait_for') {
const pattern = typeof args.pattern === 'string' ? args.pattern : ''
if (!pattern) return bareAgentJsonResult({ ok: false, error: 'pattern_required' })
let re
try {
re = new RegExp(pattern, args.ignore_case ? 'i' : '')
} catch (e) {
return bareAgentJsonResult({ ok: false, error: 'bad_regex' })
}
const timeoutMs = Math.min(
120000,
Math.max(200, Math.floor(Number(args.timeout_ms) || 15000))
)
const intervalMs = Math.min(
5000,
Math.max(100, Math.floor(Number(args.interval_ms) || 400))
)
const path = typeof args.path === 'string' ? args.path.trim() : ''
const command = typeof args.command === 'string' ? args.command.trim() : ''
if (!path && !command) {
return bareAgentJsonResult({ ok: false, error: 'path_or_command_required' })
}
appendProgress('wait_for ' + pattern)
const deadline = Date.now() + timeoutMs
let last = ''
while (Date.now() <= deadline) {
if (path) last = await bareAgentReadTextFile(ctx, path)
else if (command && execLine) {
const r = await captureExec(command, Math.min(intervalMs + 2000, 15000))
last = r && r.stdout_stderr ? String(r.stdout_stderr) : ''
}
re.lastIndex = 0
if (re.test(last)) {
return bareAgentJsonResult({
ok: true,
matched: true,
elapsed_ms: timeoutMs - Math.max(0, deadline - Date.now()),
sample: last.slice(0, 800)
})
}
if (Date.now() + intervalMs > deadline) break
await new Promise(function (resolve) {
setTimeout(resolve, intervalMs)
})
}
return bareAgentJsonResult({
ok: false,
error: 'wait_timeout',
sample: last.slice(0, 400)
})
}
if (toolName === 'undo_last_edit') {
const editsPath = paths.edits || paths.dir + '/edits.json'
if (typeof bareAgentPopEdit !== 'function') {
return bareAgentJsonResult({ ok: false, error: 'undo_unavailable' })
}
const rec = await bareAgentPopEdit(ctx, editsPath)
if (!rec || !rec.path) {
return bareAgentJsonResult({ ok: false, error: 'nothing_to_undo' })
}
await bareAgentWriteTextFile(ctx, String(rec.path), String(rec.prev || ''))
appendProgress('undo_last_edit ' + rec.path)
return bareAgentJsonResult({ ok: true, path: rec.path, tool: rec.tool || null })
}
if (toolName === 'git_diff') {
const cwd =
(typeof args.cwd === 'string' && args.cwd.trim()) ||
(ctx.env && typeof ctx.env === 'object'
? String(ctx.env.PWD || ctx.env.CWD || home || '').trim()
: '') ||
home ||
'/home'
if (!execLine) return bareAgentJsonResult({ ok: false, error: 'execLine unavailable' })
const p = typeof args.path === 'string' ? args.path.trim() : ''
const stat = Boolean(args.stat)
const cmd =
'git -C ' +
bareAgentShellQuote(cwd) +
' diff' +
(stat ? ' --stat' : '') +
(p ? ' -- ' + bareAgentShellQuote(p) : '')
appendProgress('git_diff ' + cwd)
const r = await captureExec(cmd, 30000)
return bareAgentJsonResult(
r.ok === false ? r : { ok: true, cwd, stdout_stderr: r.stdout_stderr }
)
}
if (toolName === 'history_search') {
const query = typeof args.query === 'string' ? args.query.trim() : ''
if (!query) return bareAgentJsonResult({ ok: false, error: 'query_required' })
const histPath = paths.history || paths.dir + '/history.json'
const raw = await bareAgentReadJsonFile(ctx, histPath, [])
const hits =
typeof bareAgentHistorySearch === 'function'
? bareAgentHistorySearch(Array.isArray(raw) ? raw : [], query, args.max)
: []
appendProgress('history_search ' + query)
return bareAgentJsonResult({ ok: true, query, hits })
}
return bareAgentJsonResult({ ok: false, error: 'unknown_tool ' + toolName })
} catch (e) {
const msg =
+40 -3
View File
@@ -284,8 +284,8 @@ TOOL CALLING. Prefer specialized tools over bash: grep not run_command grep; rea
COMMUNICATION. Write for a reader who has not seen tool calls. Lead with the answer. Define project terms on first use. State facts literally. The final message must stand alone. Do not invent acronyms.
TOOL MAP (schemas are already attached — use them):
- Files: read_file, write_file, edit_file, search_replace, apply_patch, create_directory, list_directory, file_stat, glob_files, grep, search_files, move_path, delete_path, list_bin
- Code / harness: run_command, run_js_script, run_js_script_at_path, todo_write, enter_plan_mode, exit_plan_mode, memory_search, memory_get, memory_append, list_skills, read_skill, edit_agent_config, git_status, schedule_task, unschedule_task
- Files: read_file, read_many, write_file, edit_file, search_replace, apply_patch, undo_last_edit, create_directory, list_directory, file_stat, glob_files, fuzzy_find, grep, search_files, move_path, delete_path, list_bin
- Code / harness: run_command (optional cwd), run_js_script, run_js_script_at_path, todo_write, enter_plan_mode, exit_plan_mode, memory_search, memory_get, memory_append, list_skills, read_skill, edit_agent_config, git_status, git_diff, history_search, schedule_task, unschedule_task, list_scheduled, wait_for
- Kernel / ops: read_proc_file, runtime_diagnostic_bundle, get_system_info, get_resource_limits, get_swarm_peers, list_services, service_status, list_timers, read_cron_log, read_audit_log, read_boot_policy, read_kernel_extension_resolution, get_initd_graph, read_unit_journal, inspect_ipc_backpressure, get_network_summary, tail_telemetry_streams, pkg_index_lookup
- Checks: list_verification_scripts, run_maintenance_gate, run_contract_checks, summarize_build_drift, verification_hints
- Docs: read_man_page, apropos_man (documentation search only)
@@ -1431,6 +1431,17 @@ async function bareOsRunAgentSession(ctx, argv0, task, runOpts) {
if (instructions)
systemContent +=
'\n\n## Session notes\n' + instructions.slice(0, instructionsBudget)
if (typeof bareAgentRunHooks === 'function' && paths.hooks) {
try {
const startHook = await bareAgentRunHooks(ctx, paths.hooks, 'SessionStart', '', {}, '')
if (startHook && startHook.inject) {
systemContent +=
'\n\n## SessionStart hook\n' + String(startHook.inject).slice(0, 1500)
}
} catch {
/* optional */
}
}
if (runOpts && runOpts.extraSystemFile) {
try {
const extraPath = String(runOpts.extraSystemFile).trim()
@@ -2443,7 +2454,7 @@ async function bareOsRunAgentSession(ctx, argv0, task, runOpts) {
bareAgentWriteOut(ctx, stdout, spinner + '\r')
statusLineActive = true
const resultStr = await bareAgentDispatchTool({
let resultStr = await bareAgentDispatchTool({
ctx,
toolName: name,
argsJson: argsStr,
@@ -2455,6 +2466,32 @@ async function bareOsRunAgentSession(ctx, argv0, task, runOpts) {
manCacheRef,
onTaskComplete
})
if (typeof bareAgentRunHooks === 'function' && paths.hooks) {
try {
let parsedArgs = {}
try {
parsedArgs = JSON.parse(argsStr || '{}')
} catch {
parsedArgs = {}
}
const post = await bareAgentRunHooks(
ctx,
paths.hooks,
'PostToolUse',
name,
parsedArgs,
resultStr
)
if (post && post.inject) {
resultStr =
resultStr +
'\n[hook PostToolUse] ' +
String(post.inject).slice(0, 800)
}
} catch {
/* optional */
}
}
clearStatusLine()
@@ -8,7 +8,7 @@
"agent --status",
"agent --plan REQUEST...",
"agent --auto GOAL...",
"agent skills | todos | plan | compact | reset"
"agent skills | todos | plan | compact | reset | undo | hooks | history"
],
"description": "Runs the in-guest ReAct coding agent (QVAC local or OpenAI-compatible REST). Full guest admin by default (denylist). Tools include grep, apply_patch, web_search, git_status, todos, plan mode, and skills. History lives in ~/.agent/history.json.",
"options": [
@@ -19,14 +19,21 @@ Prefer `list_directory` / `glob_files` / `file_stat` / `grep` over `ls` / `find`
## Code / harness
- `run_command` — any guest shell line (`command_deny` empty by default).
- `run_command` — any guest shell line (`cwd` optional; last cwd persisted). `command_deny` empty by default.
- `run_js_script` — required for agent-authored JS. Node is not installed. Writes `~/.agent/_tmp_agent_run.mjs` and runs it on the Bare kernel. Prefer `async function run(ctx, argv)`.
- `run_js_script_at_path` — existing absolute `.mjs`.
- `todo_write` — session todos (`merge=true` to update by id).
- `enter_plan_mode` / `exit_plan_mode` — plan mode is read-only except `~/.agent/plan.md`.
- `memory_search` / `memory_get` / `memory_append` — MEMORY.md and daily logs.
- `list_skills` / `read_skill` — compact catalog then full SKILL.md.
- `schedule_task` / `unschedule_task` — guest timers (`~/.config/bare-os/timers/agent-*.timer`).
- `schedule_task` / `unschedule_task` / `list_scheduled` — guest timers (`agent-*.timer`).
- `fuzzy_find` — filename search when you remember part of a name.
- `read_many` — several files in one call.
- `wait_for` — poll a file or command until a regex matches (sync, timeout).
- `undo_last_edit` — restore the last snapshot from `~/.agent/edits.json`.
- `git_diff``git diff` / `--stat`.
- `history_search` — keyword search of this session's history.
- glob/grep honor `.gitignore`, `.agentignore`, and `.grokignore` at the walk root.
- `edit_agent_config` — shallow merge of known `~/.agent/config.json` keys.
## Kernel / ops
+76 -10
View File
@@ -52,19 +52,23 @@ async function run(ctx, argv) {
!newFlag &&
!compactFlag &&
!resetFlag &&
rest.length === 1 &&
(sub === 'status' ||
sub === 'skills' ||
sub === 'todos' ||
sub === 'plan' ||
sub === 'compact' ||
sub === 'reset')
((rest.length === 1 &&
(sub === 'status' ||
sub === 'skills' ||
sub === 'todos' ||
sub === 'plan' ||
sub === 'compact' ||
sub === 'reset' ||
sub === 'undo' ||
sub === 'hooks' ||
sub === 'history')) ||
(sub === 'history' && rest[0] === 'history'))
) {
if (sub === 'status') statusFlag = true
else if (sub === 'reset') resetFlag = true
else if (sub === 'compact') compactFlag = true
else {
await bareAgentRunInspectSubcommand(ctx, argv0, sub)
await bareAgentRunInspectSubcommand(ctx, argv0, sub, rest.slice(1).join(' '))
return
}
}
@@ -178,7 +182,7 @@ function bareAgentCliUsage(argv0) {
' --compact\n' +
' ' +
argv0 +
' skills | todos | plan\n' +
' skills | todos | plan | undo | hooks | history\n' +
'\n' +
'Production coding/OS agent. Default backend is QVAC (local); or any OpenAI-compatible REST API.\n' +
'Config: ~/.agent/config.json. Full guest admin (denylist). NEVER ASK — it just works.\n' +
@@ -199,6 +203,9 @@ function bareAgentCliUsage(argv0) {
' skills List discovered SKILL.md ids\n' +
' todos Print session todos\n' +
' plan Print ~/.agent/plan.md\n' +
' undo Restore the last file edit snapshot\n' +
' hooks List ~/.agent/hooks/*.json\n' +
' history [query] Search or tail chat history\n' +
'\n' +
'Examples:\n' +
' ' +
@@ -269,7 +276,7 @@ async function bareAgentPrintStatus(ctx) {
* @param {string} argv0
* @param {string} sub
*/
async function bareAgentRunInspectSubcommand(ctx, argv0, sub) {
async function bareAgentRunInspectSubcommand(ctx, argv0, sub, extra) {
const home = bareAgentResolveHome(ctx)
const paths = bareAgentPaths(home)
try {
@@ -309,6 +316,65 @@ async function bareAgentRunInspectSubcommand(ctx, argv0, sub) {
ctx.exitCode = 0
return
}
if (sub === 'undo') {
if (typeof bareAgentPopEdit !== 'function') {
ctx.console.error(argv0 + ': undo unavailable')
ctx.exitCode = 1
return
}
const rec = await bareAgentPopEdit(ctx, paths.edits)
if (!rec || !rec.path) {
ctx.console.log(argv0 + ': nothing to undo')
ctx.exitCode = 0
return
}
await bareAgentWriteTextFile(ctx, String(rec.path), String(rec.prev || ''))
ctx.console.log(argv0 + ': restored ' + rec.path)
ctx.exitCode = 0
return
}
if (sub === 'hooks') {
let names = []
try {
names = ctx.vfs && typeof ctx.vfs.readdir === 'function'
? await ctx.vfs.readdir(paths.hooks)
: []
} catch {
names = []
}
const json = (Array.isArray(names) ? names : []).filter(function (n) {
return /\.json$/i.test(String(n || ''))
})
ctx.console.log(
json.length
? json.map(function (n) {
return paths.hooks + '/' + n
}).join('\n')
: argv0 + ': no hooks in ' + paths.hooks
)
ctx.exitCode = 0
return
}
if (sub === 'history') {
const q = String(extra || '').trim()
const messages = await bareAgentLoadHistory(ctx, paths.history)
if (q && typeof bareAgentHistorySearch === 'function') {
const hits = bareAgentHistorySearch(messages, q, 12)
ctx.console.log(
hits.length
? hits
.map(function (h) {
return '[' + h.role + '] ' + h.snippet
})
.join('\n')
: argv0 + ': no history matches'
)
} else {
ctx.console.log(argv0 + ': ' + String(messages.length) + ' messages in history')
}
ctx.exitCode = 0
return
}
} catch (e) {
const msg =
e && typeof e === 'object' && 'message' in e ? String(e.message) : String(e)
@@ -149,7 +149,14 @@ test('agent-tools exposes agent ops tools', async (t) => {
'memory_append',
'list_skills',
'schedule_task',
'unschedule_task'
'unschedule_task',
'list_scheduled',
'fuzzy_find',
'read_many',
'wait_for',
'undo_last_edit',
'git_diff',
'history_search'
]) {
t.ok(
TOOLS.includes("name: '" + toolName + "'"),
@@ -210,6 +217,9 @@ test('agent CLI exposes production flags and inspect subcommands', async (t) =>
t.ok(CLI.includes('--compact'))
t.ok(CLI.includes("sub === 'skills'"))
t.ok(CLI.includes("sub === 'todos'"))
t.ok(CLI.includes("sub === 'undo'"))
t.ok(CLI.includes("sub === 'hooks'"))
t.ok(CLI.includes("sub === 'history'"))
t.ok(TUI.includes('planMode'))
t.ok(TUI.includes('modelOverride'))
t.ok(TUI.includes('extraSystemFile'))
@@ -535,6 +535,82 @@ test('schedule interval parse and memory_append', async (t) => {
t.ok(String(files.get(sched.path) || '').includes('agent --auto'))
})
test('gitignore, fuzzy, undo, history search', async (t) => {
const s = loadPort()
const rules = s.bareAgentParseIgnoreRules('*.log\n!keep.log\nbuild/\n')
t.ok(s.bareAgentIgnoreMatch('foo.log', false, rules))
t.absent(s.bareAgentIgnoreMatch('keep.log', false, rules))
t.ok(s.bareAgentIgnoreMatch('build/out.js', true, rules) || s.bareAgentIgnoreMatch('build', true, rules))
t.ok(s.bareAgentFuzzyScore('readme', '/home/x/README.md') > s.bareAgentFuzzyScore('readme', '/home/x/a.js'))
const hits = s.bareAgentHistorySearch(
[
{ role: 'user', content: 'fix the holesail tunnel' },
{ role: 'assistant', content: 'ok' }
],
'holesail',
4
)
t.ok(hits.length >= 1)
t.is(hits[0].role, 'user')
const d = loadDispatch()
const { vfs, files, b4a } = makeVfs({
'/home/guest/src/keep.js': 'const keep = 1\n',
'/home/guest/src/skip.log': 'noise\n',
'/home/guest/.gitignore': '*.log\n'
})
await vfs.mkdir('/home/guest/src')
await vfs.mkdir('/home/guest/.agent')
const ctx = { vfs, b4a }
const paths = {
dir: '/home/guest/.agent',
edits: '/home/guest/.agent/edits.json'
}
const globbed = await dispatch(d, {
ctx,
paths,
toolName: 'glob_files',
args: { pattern: '**/*', root: '/home/guest' },
configRef: { current: {} },
home: '/home/guest'
})
t.ok(globbed.ok)
t.ok(globbed.files.some((p) => /keep\.js$/.test(p)))
t.absent(globbed.files.some((p) => /skip\.log$/.test(p)))
const wrote = await dispatch(d, {
ctx,
paths,
toolName: 'write_file',
args: { path: '/home/guest/src/keep.js', content: 'changed\n' },
configRef: { current: {} },
home: '/home/guest'
})
t.ok(wrote.ok)
t.is(files.get('/home/guest/src/keep.js'), 'changed\n')
const undone = await dispatch(d, {
ctx,
paths,
toolName: 'undo_last_edit',
args: {},
configRef: { current: {} },
home: '/home/guest'
})
t.ok(undone.ok)
t.ok(String(files.get('/home/guest/src/keep.js') || '').includes('const keep'))
const waited = await dispatch(d, {
ctx,
paths,
toolName: 'wait_for',
args: { path: '/home/guest/src/keep.js', pattern: 'keep', timeout_ms: 500, interval_ms: 100 },
configRef: { current: {} },
home: '/home/guest'
})
t.ok(waited.ok)
t.ok(waited.matched)
})
test('search result parser extracts DDG-style topics', async (t) => {
const s = loadPort()
const rows = s.bareAgentParseSearchResults(