Skip to content
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# Changelog

## 2026-09-05

### Changes

- [Prima] The `network.jsonl` artifact now holds the requests the browser was seen to make. It was
reading the requests an API run sends itself, a list nothing fills during a browser command, so the
file was never written and no envelope ever pointed at it — while the page's own traffic was being
captured the whole time.
## 2026-09-07

### New CLI Options
Expand Down Expand Up @@ -219,6 +227,12 @@
inline drawer or a split-pane form.
- The area the agent is told to stay inside is called an overlay when it floats above the page and
a region when it sits in it. Logs and the supervisor's notes now use that wording throughout.
- Prima: The three envelope builders (`pw`/`do`/`go`, failures, and `check`/`ask`/`verify`/`research`)
now share one tail builder instead of each repeating the instance/status/artifacts block. Artifact
paths flow back from the write as a return value rather than through a mutable field that had to be
read in the right order, so an envelope can no longer come back missing its Artifacts section.
Per-step file names (`aria.yaml`/`html`/`diff.yaml`) written during a `do` run now have a single
owner shared with the doc line the envelope advertises, so the two can no longer drift apart.

## 2026-09-01

Expand Down
13 changes: 5 additions & 8 deletions boat/prima/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,21 +67,22 @@ function primaFor(options: any): Prima {
return new Prima(buildOptions(options));
}

async function runPrima(options: any, command: string, run: (prima: Prima) => Promise<EnvelopeData>): Promise<void> {
async function runPrima(options: any, command: string, run: (prima: Prima) => Promise<EnvelopeData>, opts: { browser?: boolean; record?: boolean } = {}): Promise<void> {
const { browser = true, record = true } = opts;
setQuietMode(!isVerboseMode());
trackActivityLine();
const prima = primaFor(options);
const startedAt = Date.now();

let envelope: EnvelopeData;
try {
await prima.start();
if (browser) await prima.start();
envelope = await run(prima);
} catch (error) {
envelope = await prima.toolFailureEnvelope(command, error);
}

prima.record(envelope, Date.now() - startedAt);
if (record) prima.record(envelope, Date.now() - startedAt);
clearActivityLine();
console.log(renderEnvelope(envelope));
await prima.stop().catch(() => {});
Expand Down Expand Up @@ -163,11 +164,7 @@ export function createPrimaCommands(name = 'prima'): Command {
addCommonOptions(cmd.command('status <hash>').description('Show the artifacts and page detail recorded for an earlier command'))
.addHelpText('after', `\n${statusHelp}`)
.action(async (hash, options) => {
setQuietMode(!isVerboseMode());
const prima = primaFor(options);
const envelope = await prima.status(hash).catch((error: unknown) => prima.toolFailureEnvelope(`status ${hash}`, error));
console.log(renderEnvelope(envelope));
process.exit(envelope.ok ? 0 : 1);
await runPrima(options, `status ${hash}`, (prima) => prima.status(hash), { browser: false, record: false });
});

addCommonOptions(cmd.command('report').description('Turn every command of a session into one html and markdown report'))
Expand Down
4 changes: 3 additions & 1 deletion boat/prima/src/envelope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ export const STATUS_FILE = 'status.json';

const ARTIFACT_FILES = { aria: 'aria.yml', html: 'page.html', screenshot: 'page.png', network: 'network.jsonl' };

export const STEP_FILES = { aria: 'aria.yaml', html: 'html', diff: 'diff.yaml' };

const EXPECTATION_LABELS = {
passed: 'PASSED ',
failed: 'FAILED ',
Expand Down Expand Up @@ -117,7 +119,7 @@ function renderSteps(data: EnvelopeData): string | null {
lines.push(`${index + 1}. ${mark} ${step.label}`);
for (const line of (step.proof || '').split('\n').filter(Boolean)) lines.push(` ${line}`);
});
if (data.stepFiles) lines.push('', `page after each step: ${data.stepFiles}/<n>-<step>.{aria.yaml,html,diff.yaml}`);
if (data.stepFiles) lines.push('', `page after each step: ${data.stepFiles}/<n>-<step>.{${Object.values(STEP_FILES).join(',')}}`);
return section('Steps', lines.join('\n'));
}

Expand Down
41 changes: 18 additions & 23 deletions boat/prima/src/prima.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import { browserErrorMessage } from '../../../src/utils/browser-errors.ts';
import { pluralize } from '../../../src/utils/logger.ts';
import { mdq } from '../../../src/utils/markdown-query.ts';
import { safeFilename } from '../../../src/utils/strings.ts';
import { type EnvelopeData, type InstanceInfo, STATUS_FILE, readArtifacts, writeArtifacts } from './envelope.ts';
import { type ArtifactPaths, type EnvelopeData, type InstanceInfo, STATUS_FILE, STEP_FILES, readArtifacts, writeArtifacts } from './envelope.ts';
import { isFunctionExpression, takePwValue, toCodeceptWrapper } from './pw-parser.ts';
import { type PwServerDescriptor, readDescriptors, selectDescriptor } from './pw-registry.ts';
import { type SessionRun, latestSessionFile, readSession, recordCommand, sessionFile, sessionsDir } from './session-log.ts';
Expand Down Expand Up @@ -73,7 +73,6 @@ export class Prima {
private server: { close: () => Promise<void> } | null = null;
private attached: string | null = null;
private session: SessionRun | null = null;
private artifacts?: EnvelopeData['artifacts'];

constructor(options: PrimaOptions = {}) {
this.options = options;
Expand Down Expand Up @@ -977,16 +976,13 @@ export class Prima {

private async successEnvelope(command: string, used: string[], result: ActionResult, previousState: WebPageState | null): Promise<EnvelopeData> {
const changes = await this.pageChanges(result, previousState, used[0]);
const status = await this.saveStatus(result);
return {
ok: true,
command,
used,
page: this.pageBlock(result, previousState),
changes,
instance: await this.instanceInfo(),
status,
artifacts: this.artifacts,
...(await this.envelopeTail(result)),
};
}

Expand All @@ -995,31 +991,30 @@ export class Prima {
const failure: EnvelopeData['failure'] = { error: browserErrorMessage(error) };
if (result.ariaSnapshot) failure.compactAria = compactAriaSnapshot(result.ariaSnapshot, true);

const status = await this.saveStatus(result);
return {
ok: false,
command,
page: this.pageBlock(result, previousState),
failure,
instance: await this.instanceInfo(),
status,
artifacts: this.artifacts,
...(await this.envelopeTail(result)),
};
}

private async reportEnvelope(command: string, result: ActionResult, previousState: WebPageState | null, outcome: Partial<EnvelopeData>): Promise<EnvelopeData> {
const status = await this.saveStatus(result);
return {
ok: true,
command,
page: this.pageBlock(result, previousState),
...outcome,
instance: await this.instanceInfo(),
status,
artifacts: this.artifacts,
...(await this.envelopeTail(result)),
};
}

private async envelopeTail(result: ActionResult): Promise<Pick<EnvelopeData, 'instance' | 'status' | 'artifacts'>> {
const { hash, artifacts } = await this.saveStatus(result);
return { instance: await this.instanceInfo(), status: hash, artifacts };
}

private async capturedResult(previousState: WebPageState | null, opts: { screenshot?: boolean } = {}): Promise<ActionResult> {
const captured = await this.bot
.getExplorer()
Expand Down Expand Up @@ -1081,11 +1076,11 @@ export class Prima {
};
}

private async saveStatus(result: ActionResult): Promise<string> {
private async saveStatus(result: ActionResult): Promise<{ hash: string; artifacts: ArtifactPaths }> {
const hash = this.statusHash();
await this.writeSnapshot(result);
const artifacts = await this.writeSnapshot(result);
writeFileSync(path.join(this.statusDir(hash), STATUS_FILE), JSON.stringify({ page: this.pageBlock(result, null) }), 'utf-8');
return hash;
return { hash, artifacts };
}

private async writeStepFiles(index: number, label: string, diff: string): Promise<void> {
Expand All @@ -1097,17 +1092,17 @@ export class Prima {
const stem = path.join(dir, `${index}-${safeFilename(label.slice(0, 60))}`);
const result = ActionResult.fromState(state);

writeFileSync(`${stem}.aria.yaml`, result.ariaSnapshot ?? '', 'utf-8');
writeFileSync(`${stem}.html`, await result.combinedHtml(), 'utf-8');
if (diff) writeFileSync(`${stem}.diff.yaml`, diff, 'utf-8');
writeFileSync(`${stem}.${STEP_FILES.aria}`, result.ariaSnapshot ?? '', 'utf-8');
writeFileSync(`${stem}.${STEP_FILES.html}`, await result.combinedHtml(), 'utf-8');
if (diff) writeFileSync(`${stem}.${STEP_FILES.diff}`, diff, 'utf-8');
}

private async writeSnapshot(result: ActionResult): Promise<void> {
this.artifacts = writeArtifacts(this.statusDir(), {
private async writeSnapshot(result: ActionResult): Promise<ArtifactPaths> {
return writeArtifacts(this.statusDir(), {
aria: result.ariaSnapshot,
html: await result.combinedHtml(),
screenshot: result.screenshot,
requests: this.bot.requestStore().getMadeRequests(),
requests: this.bot.requestStore().getCapturedRequests(),
});
}

Expand Down
8 changes: 7 additions & 1 deletion boat/prima/tests/envelope.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { describe, expect, test } from 'bun:test';
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { type EnvelopeData, readArtifacts, renderEnvelope, writeArtifacts } from '../src/envelope.ts';
import { type EnvelopeData, STEP_FILES, readArtifacts, renderEnvelope, writeArtifacts } from '../src/envelope.ts';

const base: EnvelopeData = {
ok: true,
Expand Down Expand Up @@ -180,6 +180,12 @@ describe('renderEnvelope', () => {
expect(out).toContain('page after each step: /tmp/x/<n>-<step>.{aria.yaml,html,diff.yaml}');
});

test('the advertised step-file line names every STEP_FILES extension', () => {
const out = renderEnvelope({ ...base, steps: [{ label: "I.click('Add')", ok: true, proof: '' }], stepFiles: '/tmp/x' });
const line = out.split('\n').find((entry) => entry.includes('page after each step'))!;
for (const extension of Object.values(STEP_FILES)) expect(line).toContain(extension);
});

test('open tabs alone are evidence enough for a running browser', () => {
const out = renderEnvelope({ ...base, instance: { name: 'default', tabs: 2, others: [] } });
expect(out).toContain('| running');
Expand Down
12 changes: 11 additions & 1 deletion boat/prima/tests/prima.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ function fakePrima(options: Record<string, unknown> = {}) {
}),
getCurrentState: () => fakeState(),
getConfig: () => ({}),
requestStore: () => ({ getMadeRequests: () => [] }),
requestStore: () => ({ getCapturedRequests: () => [] }),
getProvider: () => ({ chat: async () => '' }),
agentResearcher: () => ({ enable: () => {}, disable: () => {} }),
};
Expand Down Expand Up @@ -142,6 +142,16 @@ describe('Prima.pw', () => {
expect(envelope.instance.name).toBe('default');
});

test('the network artifact holds the requests the browser was seen to make', async () => {
const { prima } = fakePrima();
(prima as any).bot.requestStore = () => ({ getCapturedRequests: () => [{ method: 'POST', path: '/api/session', status: 201 }] });
const envelope = await prima.pw("({ page }) => page.click('text=Login')");

const lines = readFileSync(envelope.artifacts!.network!, 'utf-8').trim().split('\n');
expect(lines.length).toBe(1);
expect(JSON.parse(lines[0]).path).toBe('/api/session');
});

test('status points at the artifacts instead of printing the page tree back', async () => {
const { prima } = fakePrima();
const first = await prima.pw("({ page }) => page.click('text=Login')");
Expand Down
2 changes: 1 addition & 1 deletion tests/integration/prima-do.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ describe('Prima.do with aimock', () => {
getExplorer: () => ({ action: () => action, capture: async () => null }),
stateManager: () => ({ getCurrentState: () => boardState, getVisitCount: () => 1 }),
getCurrentState: () => boardState,
requestStore: () => ({ getMadeRequests: () => [] }),
requestStore: () => ({ getCapturedRequests: () => [] }),
getProvider: () => provider,
experienceTracker: () => ({ renderExperienceTocFor: () => '' }),
agentResearcher: () => ({}),
Expand Down
Loading