Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ verboo /login
## What Works

- **Tool-driven coding workflows**: Bash, file read/write/edit, grep, glob, agents, tasks, MCP, and slash commands
- **Persistent session goals**: `/goal <objective>` keeps a goal active across turns; use `/goal status`, `/goal pause`, `/goal resume`, `/goal clear`, and `/goal complete` to manage it. A new `/goal <objective>` replaces the current goal.
- **Streaming responses**: Real-time token output and tool progress
- **Tool calling**: Multi-step tool loops with model calls, tool execution, and follow-up responses
- **Images**: URL and base64 image inputs for providers that support vision
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@verboo/code",
"version": "0.15.34",
"version": "0.15.35",
"description": "Verboo Code — coding agent for the Verboo platform",
"type": "module",
"bin": {
Expand Down
30 changes: 30 additions & 0 deletions scripts/e2e/cli.e2e.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,36 @@ for (const fullscreen of [false, true]) test(`routing header appears once with t
} finally { await cli.stop() }
})

test('interactive /goal shows, retries, and clears the active objective', { timeout: 60_000 }, async () => {
const objective = 'Explain where slash commands are registered'
const cli = await startCli({ columns: 120, rows: 40, slashCommands: true, routerOptions: { routedCompletion: true } })
try {
await cli.waitFor(() => cli.screen().includes('❯'))
cli.write(`/goal ${objective}`)
await cli.waitFor(() => cli.screen().includes(`│ ❯ /goal ${objective}`))
cli.write('\r')
await cli.waitFor(() => cli.screen().includes(`Goal active: ${objective}`))
await cli.waitFor(() => cli.screen().includes('E2E_ROUTED_COMPLETE fixture-1'))
assert.equal(cli.router.requests.length, 1)

const goalRequestIndex = () => cli.router.requests.findIndex(request =>
(JSON.stringify(request.messages).match(/<command-name>\/goal<\/command-name>/g) || []).length >= 2,
)
cli.write(`/goal ${objective}`)
await cli.waitFor(() => cli.screen().includes(`│ ❯ /goal ${objective}`))
cli.write('\r')
await cli.waitFor(() => cli.screen().includes('Goal already active; continuing:'))
await cli.waitFor(() => goalRequestIndex() >= 0)
await cli.waitFor(() => cli.screen().includes(`E2E_ROUTED_COMPLETE fixture-${goalRequestIndex() + 1}`))

cli.write('/goal clear')
await cli.waitFor(() => cli.screen().includes('│ ❯ /goal clear'))
cli.write('\r')
await cli.waitFor(() => cli.screen().includes('Goal cleared:') && !cli.screen().includes(`Goal active: ${objective}`))
assert.deepEqual(cli.router.unexpected, [])
} finally { await cli.stop() }
})

for (const fullscreen of [false, true]) for (const [columns, rows] of [[40, 12], [80, 24], [120, 40]]) for (const agents of [1, 2, 8, 20]) {
test(`installed CLI: ${agents} agents, ${columns}x${rows}, fullscreen=${fullscreen}`, { timeout: 60_000 }, async () => {
const cli = await startCli({ columns, rows, fullscreen, routerOptions: { agents }, args: ['E2E_PARENT: delegate to the fixture agents.'] })
Expand Down
2 changes: 1 addition & 1 deletion scripts/e2e/fake-router.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export async function createFakeRouter({ agents = 2, omitUsage = false, zeroUsag
emit({ role: 'assistant' })
if (routedCompletion && body.tools?.length) {
emit({ reasoning_content: 'Synthetic hidden reasoning.' })
emit({ content: 'Oi! E2E_ROUTED_COMPLETE' })
emit({ content: `Oi! E2E_ROUTED_COMPLETE ${id}` })
emit({}, 'stop', { prompt_tokens: 10, completion_tokens: 4 })
} else if (isChild && childToolLoop) {
emit({ tool_calls: [{ index: 0, id: `read-${sequence}`, type: 'function', function: { name: 'Read', arguments: JSON.stringify({ file_path: 'README.md' }) } }] })
Expand Down
4 changes: 2 additions & 2 deletions scripts/e2e/terminal.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { spawn, spawnSync } from 'node:child_process'
import { once } from 'node:events'
import { createFakeRouter } from './fake-router.mjs'

export async function startCli({ columns = 80, rows = 24, fullscreen = false, usePty = true, model = 'fixture-model', args = [], routerOptions } = {}) {
export async function startCli({ columns = 80, rows = 24, fullscreen = false, usePty = true, slashCommands = false, model = 'fixture-model', args = [], routerOptions } = {}) {
const consumer = (await readFile(resolve('.artifacts/package/consumer-path.txt'), 'utf8')).trim()
const root = resolve('.artifacts/pty')
await mkdir(root, { recursive: true })
Expand Down Expand Up @@ -49,7 +49,7 @@ export async function startCli({ columns = 80, rows = 24, fullscreen = false, us
terminal.unicode.activeVersion = '11'
let child
let stderr = ''
const cliArgs = ['--import', pathToFileURL(resolve('scripts/e2e/transport-preload.mjs')).href, join(consumer, 'node_modules/@verboo/code/bin/verboo'), '--model', model, '--dangerously-skip-permissions', '--setting-sources', 'user', '--strict-mcp-config', '--mcp-config', '{"mcpServers":{}}', '--disable-slash-commands', '--debug-file', join(dir, 'debug.log'), ...args]
const cliArgs = ['--import', pathToFileURL(resolve('scripts/e2e/transport-preload.mjs')).href, join(consumer, 'node_modules/@verboo/code/bin/verboo'), '--model', model, '--dangerously-skip-permissions', '--setting-sources', 'user', '--strict-mcp-config', '--mcp-config', '{"mcpServers":{}}', ...(slashCommands ? [] : ['--disable-slash-commands']), '--debug-file', join(dir, 'debug.log'), ...args]
try {
if (usePty) {
child = pty.spawn(process.execPath, cliArgs, { cwd: project, env, cols: columns, rows, name: 'xterm-256color' })
Expand Down
25 changes: 25 additions & 0 deletions scripts/npm-pack-json.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/**
* npm versions have emitted both an array and a single object for `npm pack
* --json`. Normalize both shapes while rejecting output that cannot safely
* describe the tarball consumed by the package verification step.
*/
export function parseNpmPackJson(stdout) {
let parsed
try {
parsed = JSON.parse(stdout)
} catch (error) {
throw new Error(`npm pack returned invalid JSON: ${error.message}`)
}

let pack = Array.isArray(parsed) ? parsed[0] : parsed
// npm 12 can wrap the one packed workspace under its package name.
if (pack && typeof pack === 'object' && typeof pack.filename !== 'string' && Object.keys(pack).length === 1) {
const wrapped = Object.values(pack)[0]
pack = Array.isArray(wrapped) ? wrapped[0] : wrapped
}
if (!pack || typeof pack !== 'object' || typeof pack.filename !== 'string' || !Array.isArray(pack.files)) {
const shape = pack && typeof pack === 'object' ? `object fields: ${Object.keys(pack).join(', ') || '(none)'}` : typeof pack
throw new Error(`npm pack returned JSON without a package filename and files list (${shape})`)
}
return pack
}
24 changes: 24 additions & 0 deletions scripts/npm-pack-json.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { describe, expect, test } from 'bun:test'
import { parseNpmPackJson } from './npm-pack-json.mjs'

describe('parseNpmPackJson', () => {
const pack = { filename: 'verboo-code-1.2.3.tgz', files: [{ path: 'dist/cli.mjs' }] }

test('accepts npm output as a one-item array', () => {
expect(parseNpmPackJson(JSON.stringify([pack]))).toEqual(pack)
})

test('accepts npm output as a single object', () => {
expect(parseNpmPackJson(JSON.stringify(pack))).toEqual(pack)
})

test('accepts npm output wrapped under the package name', () => {
expect(parseNpmPackJson(JSON.stringify({ '@verboo/code': pack }))).toEqual(pack)
})

test('rejects malformed npm output', () => {
expect(() => parseNpmPackJson('not json')).toThrow('npm pack returned invalid JSON')
expect(() => parseNpmPackJson('{}')).toThrow('without a package filename and files list')
expect(() => parseNpmPackJson('[]')).toThrow('without a package filename and files list')
})
})
3 changes: 2 additions & 1 deletion scripts/prepare-cli-package.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { createHash } from 'node:crypto'
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
import { dirname, resolve, join } from 'node:path'
import { tmpdir } from 'node:os'
import { parseNpmPackJson } from './npm-pack-json.mjs'

// Build once before this script. Neither packing nor installing may rebuild it.
const output = resolve('.artifacts/package')
Expand All @@ -28,7 +29,7 @@ function npm(args, cwd) {
return result.stdout
}
if (!process.argv.includes('--install-only') && !process.argv.includes('--verify-only')) {
const [pack] = JSON.parse(npm(['pack', '--ignore-scripts', '--json', '--pack-destination', output]))
const pack = parseNpmPackJson(npm(['pack', '--ignore-scripts', '--json', '--pack-destination', output]))
writeFileSync(join(output, 'sha256.txt'), `${createHash('sha256').update(readFileSync(join(output, pack.filename))).digest('hex')} ${pack.filename}\n`)
writeFileSync(join(output, 'package-info.json'), JSON.stringify({ tarball: pack.filename, files: pack.files.map(file => file.path) }, null, 2))
}
Expand Down
4 changes: 3 additions & 1 deletion scripts/run-tests-isolated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,9 @@ export async function main(): Promise<void> {
process.stdout.write(`[${results.length}/${files.length}] ${result.exitCode ? 'FAIL' : 'PASS'} ${file}\n`)
}
}
await Promise.all(Array.from({ length: Math.min(files.length, positiveInteger(process.env.TEST_ISOLATION_CONCURRENCY, 4)) }, worker))
// Each suite launches a full Bun process. Running four UI-heavy suites at
// once starves their short terminal/render deadlines on CI-sized machines.
await Promise.all(Array.from({ length: Math.min(files.length, positiveInteger(process.env.TEST_ISOLATION_CONCURRENCY, 1)) }, worker))
const failures = results.filter(r => r.exitCode !== 0)
const reportDir = process.env.TEST_REPORT_DIR || '.artifacts/test-results'
await mkdir(reportDir, { recursive: true })
Expand Down
2 changes: 2 additions & 0 deletions src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import onboardGithub from './commands/onboard-github/index.js'
import knowledge from './commands/knowledge/index.js'
import memory from './commands/memory/index.js'
import help from './commands/help/index.js'
import goal from './commands/goal/index.js'
import ide from './commands/ide/index.js'
import init from './commands/init.js'
import initVerifiers from './commands/init-verifiers.js'
Expand Down Expand Up @@ -305,6 +306,7 @@ const COMMANDS = memoize((): Command[] => [
files,
heapDump,
help,
goal,
ide,
init,
keybindings,
Expand Down
180 changes: 180 additions & 0 deletions src/commands/goal/goal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
import type { LocalJSXCommandCall } from '../../types/command.js'
import {
clearSessionGoal,
createSessionGoal,
getSessionGoal,
startSessionGoal,
setSessionGoalStatus,
type SessionGoal,
} from '../../utils/goals.js'

function formatGoal(goal: SessionGoal): string {
const status = goal.status === 'complete' ? 'completed' : goal.status
const blocker =
goal.status === 'blocked' && goal.blocker?.reason
? `\nBlocker: ${goal.blocker.reason}`
: ''
return `Current goal (${status}):\n${goal.objective}${blocker}`
}

function usage(): string {
return [
'Usage:',
' /goal <objective> Set or replace a persistent goal and start working on it',
' /goal Show the current goal',
' /goal status Show the current goal',
' /goal complete Mark the current goal complete',
' /goal pause Pause the current goal',
' /goal resume Resume a paused or blocked goal',
' /goal clear Remove the current goal',
].join('\n')
}

export const call: LocalJSXCommandCall = async (onDone, _context, args) => {
const input = args.trim()
const [action, ...rest] = input.split(/\s+/)
const normalizedAction = action?.toLowerCase()
const isSingleAction = rest.length === 0

if (
!input ||
(isSingleAction &&
(normalizedAction === 'status' || normalizedAction === 'show'))
) {
const goal = getSessionGoal()
onDone(
goal
? formatGoal(goal)
: `No goal is set for this session.\n\n${usage()}`,
{ display: 'system' },
)
return null
}

if (
isSingleAction &&
(normalizedAction === 'help' || normalizedAction === '--help')
) {
onDone(usage(), { display: 'system' })
return null
}

if (
isSingleAction &&
(normalizedAction === 'set' || normalizedAction === 'start')
) {
onDone(usage(), { display: 'system' })
return null
}

if (
isSingleAction &&
['clear', 'stop', 'off', 'reset', 'none', 'cancel'].includes(
normalizedAction ?? '',
)
) {
let goal: SessionGoal | null
try {
goal = clearSessionGoal()
} catch (error) {
onDone(error instanceof Error ? error.message : String(error), {
display: 'system',
})
return null
}
onDone(
goal
? `Goal cleared:\n${goal.objective}`
: 'No goal is set for this session.',
{ display: 'system' },
)
return null
}

if (
isSingleAction &&
(normalizedAction === 'complete' ||
normalizedAction === 'pause' ||
normalizedAction === 'resume')
) {
const status =
normalizedAction === 'complete'
? 'complete'
: normalizedAction === 'pause'
? 'paused'
: 'active'
let goal: SessionGoal | null
try {
goal = setSessionGoalStatus(status)
} catch (error) {
onDone(error instanceof Error ? error.message : String(error), {
display: 'system',
})
return null
}
if (!goal) {
const current = getSessionGoal()
const message = current?.status === 'complete'
? 'This goal is already complete. Set a new goal with /goal <objective>.'
: 'No goal is set for this session.'
onDone(message, { display: 'system' })
return null
}

const result = normalizedAction === 'resume'
? `Goal resumed:\n${goal.objective}`
: normalizedAction === 'pause'
? `Goal paused:\n${goal.objective}`
: `Goal marked complete:\n${goal.objective}`
const shouldQuery = normalizedAction === 'resume'
onDone(result, {
display: 'system',
shouldQuery,
...(shouldQuery
? {
metaMessages: [
`The user resumed this persistent goal. Continue working toward it: ${JSON.stringify(goal.objective)}`,
],
}
: {}),
})
return null
}

const objective =
(normalizedAction === 'set' || normalizedAction === 'start') && rest.length > 0
? rest.join(' ')
: input
if (!objective.trim()) {
onDone(usage(), { display: 'system' })
return null
}

try {
const previous = getSessionGoal()
const goal = previous
? startSessionGoal(objective)
: createSessionGoal(objective)
const hadUnfinishedGoal = previous && previous.status !== 'complete'
const sameObjective = previous?.objective === goal.objective
const result = !hadUnfinishedGoal
? `Goal set:\n${goal.objective}`
: sameObjective && previous.status === 'active'
? `Goal already active; continuing:\n${goal.objective}`
: sameObjective
? `Goal resumed:\n${goal.objective}`
: `Goal replaced:\n${goal.objective}`
onDone(result, {
display: 'system',
shouldQuery: true,
metaMessages: [
`The user set or resumed this persistent goal for the current session. Work toward it across turns until it is complete or the user pauses or clears it: ${JSON.stringify(goal.objective)}`,
],
})
} catch (error) {
onDone(error instanceof Error ? error.message : String(error), {
display: 'system',
})
}
return null
}
11 changes: 11 additions & 0 deletions src/commands/goal/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import type { Command } from '../../commands.js'

const goal = {
type: 'local-jsx',
name: 'goal',
description: 'Set and track a persistent goal for this session',
argumentHint: '[<objective>|status|complete|pause|resume]',
load: () => import('./goal.js'),
} satisfies Command

export default goal
Loading
Loading