@proagentstore/cli 0.4.73 → 0.4.75
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/dist/browser-runner/coding/engine-turn.js +27 -5
- package/dist/browser-runner/coding/headless.js +17 -5
- package/dist/browser-runner/coding/inspect.js +42 -11
- package/dist/browser-runner/coding/runtime.js +5 -2
- package/dist/browser-runner/local-browser/bridge.js +7 -0
- package/dist/browser-runner/local-browser/engine.js +13 -0
- package/dist/browser-runner/server.js +4 -3
- package/dist/index.js +1 -1
- package/package.json +1 -1
|
@@ -29,17 +29,39 @@ export const MAX_TURN_DETAIL = 240;
|
|
|
29
29
|
export const MAX_TURN_TAIL_LINES = 60;
|
|
30
30
|
export const MAX_TURN_TAIL_LINE = 400;
|
|
31
31
|
export const MAX_TURN_TAIL_CHARS = 8000;
|
|
32
|
-
/**
|
|
32
|
+
/** Leads a tail that has lost its oldest lines, with how many (#898). */
|
|
33
|
+
const DROPPED_LINE = /^\[(\d+) earlier lines? of this turn not kept\]$/;
|
|
34
|
+
/**
|
|
35
|
+
* Append one line of the turn's own output to its tail, keeping the newest within the bounds above.
|
|
36
|
+
*
|
|
37
|
+
* What falls off the front is COUNTED, in a first line that says so (#898): the cloud reports "the
|
|
38
|
+
* last N lines", and a tail that silently lost its head made N read as all the turn printed.
|
|
39
|
+
*/
|
|
33
40
|
export function appendTurnTail(tail, line) {
|
|
34
41
|
const text = line.trim();
|
|
35
42
|
if (!text)
|
|
36
43
|
return;
|
|
44
|
+
const marked = tail.length ? DROPPED_LINE.exec(tail[0]) : null;
|
|
45
|
+
let dropped = marked ? Number(marked[1]) : 0;
|
|
46
|
+
if (marked)
|
|
47
|
+
tail.shift();
|
|
37
48
|
tail.push(text.length > MAX_TURN_TAIL_LINE ? `${text.slice(0, MAX_TURN_TAIL_LINE)}…` : text);
|
|
38
|
-
while (tail.length > MAX_TURN_TAIL_LINES)
|
|
49
|
+
while (tail.length > MAX_TURN_TAIL_LINES - 1) {
|
|
39
50
|
tail.shift();
|
|
51
|
+
dropped++;
|
|
52
|
+
}
|
|
40
53
|
let total = tail.reduce((n, l) => n + l.length + 1, 0);
|
|
41
|
-
|
|
54
|
+
// Room is kept for the count line itself, so the tail stays inside its total bound.
|
|
55
|
+
while (total > MAX_TURN_TAIL_CHARS - 48 && tail.length > 1) {
|
|
42
56
|
total -= tail.shift().length + 1;
|
|
57
|
+
dropped++;
|
|
58
|
+
}
|
|
59
|
+
if (dropped)
|
|
60
|
+
tail.unshift(`[${dropped} earlier line${dropped === 1 ? "" : "s"} of this turn not kept]`);
|
|
61
|
+
}
|
|
62
|
+
/** The engine's last line within {@link MAX_TURN_DETAIL}, with an ellipsis when it was cut (#898). */
|
|
63
|
+
function detailOf(text) {
|
|
64
|
+
return text.length > MAX_TURN_DETAIL ? `${text.slice(0, MAX_TURN_DETAIL)}…` : text;
|
|
43
65
|
}
|
|
44
66
|
/**
|
|
45
67
|
* A one-shot turn's process has exited — say what that means, and nothing more.
|
|
@@ -49,7 +71,7 @@ export function appendTurnTail(tail, line) {
|
|
|
49
71
|
* failure would let three slow builds read as a broken CLI.
|
|
50
72
|
*/
|
|
51
73
|
export function turnReportFromExit(code, signal, lastLine = "", now = Date.now(), tail = []) {
|
|
52
|
-
const detail = (lastLine.trim() || tail.at(-1) || "")
|
|
74
|
+
const detail = detailOf(lastLine.trim() || tail.at(-1) || "");
|
|
53
75
|
const verdict = signal !== null ? "killed" : code === 0 ? "ok" : "failed";
|
|
54
76
|
return {
|
|
55
77
|
verdict,
|
|
@@ -68,7 +90,7 @@ export function turnReportFromExit(code, signal, lastLine = "", now = Date.now()
|
|
|
68
90
|
* case that runs most.
|
|
69
91
|
*/
|
|
70
92
|
export function turnReportFromResult(isError, detail = "", now = Date.now(), tail = []) {
|
|
71
|
-
const text = detail.trim()
|
|
93
|
+
const text = detailOf(detail.trim());
|
|
72
94
|
return {
|
|
73
95
|
verdict: isError ? "failed" : "ok",
|
|
74
96
|
exitCode: null,
|
|
@@ -59,6 +59,8 @@ export class HeadlessSession {
|
|
|
59
59
|
proc = null;
|
|
60
60
|
buf = "";
|
|
61
61
|
transcript = [];
|
|
62
|
+
/** Records trimmed off the front of {@link transcript} — said in the pane, never silent (#898). */
|
|
63
|
+
droppedRecords = 0;
|
|
62
64
|
run = "idle";
|
|
63
65
|
/** Claude Code's own session id (from the init event) — used to --resume. */
|
|
64
66
|
claudeSessionId = null;
|
|
@@ -351,7 +353,8 @@ export class HeadlessSession {
|
|
|
351
353
|
}
|
|
352
354
|
/** The rendered conversation the brain/console reads (Claude's real output). */
|
|
353
355
|
snapshot() {
|
|
354
|
-
|
|
356
|
+
const text = this.transcript.join("\n");
|
|
357
|
+
return this.droppedRecords > 0 ? `[${this.droppedRecords} earlier lines of this session's output are no longer kept]\n${text}` : text;
|
|
355
358
|
}
|
|
356
359
|
/** Launch (or resume) the agent process. Idempotent if already alive. */
|
|
357
360
|
/**
|
|
@@ -691,6 +694,10 @@ export class HeadlessSession {
|
|
|
691
694
|
if (this.buf.length > cap) {
|
|
692
695
|
if (this.mode === "raw")
|
|
693
696
|
this.pushRaw(this.buf);
|
|
697
|
+
// Said in the pane (#898): an event this size was discarded whole, and a reader could not
|
|
698
|
+
// tell a dropped tool result from one that never came.
|
|
699
|
+
else
|
|
700
|
+
this.push(`[a ${this.buf.length.toLocaleString("en-US")}-character line of engine output was too large to keep and was dropped]`);
|
|
694
701
|
this.buf = "";
|
|
695
702
|
}
|
|
696
703
|
}
|
|
@@ -730,8 +737,14 @@ export class HeadlessSession {
|
|
|
730
737
|
// never scraped back out of the rendered pane.
|
|
731
738
|
this.turnLastLine = clean.trim();
|
|
732
739
|
}
|
|
733
|
-
|
|
734
|
-
|
|
740
|
+
this.trimTranscript();
|
|
741
|
+
}
|
|
742
|
+
/** Keep the newest 3,000 records once past 4,000, and COUNT what went (#898). */
|
|
743
|
+
trimTranscript() {
|
|
744
|
+
if (this.transcript.length <= 4000)
|
|
745
|
+
return;
|
|
746
|
+
this.droppedRecords += this.transcript.length - 3000;
|
|
747
|
+
this.transcript = this.transcript.slice(-3000);
|
|
735
748
|
}
|
|
736
749
|
handle(line) {
|
|
737
750
|
const events = this.adapter.parseLine(line);
|
|
@@ -808,8 +821,7 @@ export class HeadlessSession {
|
|
|
808
821
|
// Keep the in-memory transcript bounded. Counts ENTRIES, and an entry may now be a
|
|
809
822
|
// multi-line block (a result, or a long assistant reply) rather than one line — the
|
|
810
823
|
// character bound that matters is `MAX_PANE` in runtime.ts, applied on the way out.
|
|
811
|
-
|
|
812
|
-
this.transcript = this.transcript.slice(-3000);
|
|
824
|
+
this.trimTranscript();
|
|
813
825
|
return events.length > 0;
|
|
814
826
|
}
|
|
815
827
|
push(line) {
|
|
@@ -272,7 +272,9 @@ export function repoSearch(workDir, opts) {
|
|
|
272
272
|
const limit = Math.max(1, Math.min(SEARCH_MAX_RESULTS, Math.floor(opts.maxResults ?? SEARCH_MAX_RESULTS)));
|
|
273
273
|
const relPath = opts.path ? relative(workDir, resolveInside(workDir, opts.path)) : undefined;
|
|
274
274
|
const argv = mode === "content"
|
|
275
|
-
?
|
|
275
|
+
? // No `--max-count` (#898): git stopped at 5 per file and `total` counted only those, so a
|
|
276
|
+
// file with 200 matches read as one with 5. The per-file cap is applied below, COUNTED.
|
|
277
|
+
["grep", "-n", "-I", "-i", "-F", "--untracked", "-e", pattern]
|
|
276
278
|
: // Tracked PLUS untracked-not-ignored: a file created ten minutes ago is exactly the one
|
|
277
279
|
// somebody is trying to find, and `ls-files` alone would deny it exists.
|
|
278
280
|
["ls-files", "--cached", "--others", "--exclude-standard"];
|
|
@@ -297,12 +299,33 @@ export function repoSearch(workDir, opts) {
|
|
|
297
299
|
throw new InspectError(err.message || `git ${mode} search failed`);
|
|
298
300
|
}
|
|
299
301
|
const lines = out.split("\n").filter((l) => l !== "");
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
302
|
+
if (mode === "path") {
|
|
303
|
+
// The pattern never reaches git here — the filter is a plain case-insensitive substring
|
|
304
|
+
// over the path, which is what "find the file called X" actually means.
|
|
305
|
+
const all = lines.filter((p) => p.toLowerCase().includes(pattern.toLowerCase())).map((p) => ({ path: p }));
|
|
306
|
+
return { mode, pattern, matches: all.slice(0, limit), shown: Math.min(all.length, limit), total: all.length, truncated: all.length > limit };
|
|
307
|
+
}
|
|
308
|
+
const all = lines.map(parseGrepLine).filter((m) => m !== null);
|
|
309
|
+
const perFile = new Map();
|
|
310
|
+
const omittedPerFile = {};
|
|
311
|
+
const capped = all.filter((m) => {
|
|
312
|
+
const n = (perFile.get(m.path) ?? 0) + 1;
|
|
313
|
+
perFile.set(m.path, n);
|
|
314
|
+
if (n <= SEARCH_PER_FILE)
|
|
315
|
+
return true;
|
|
316
|
+
omittedPerFile[m.path] = (omittedPerFile[m.path] ?? 0) + 1;
|
|
317
|
+
return false;
|
|
318
|
+
});
|
|
319
|
+
const matches = capped.slice(0, limit);
|
|
320
|
+
return {
|
|
321
|
+
mode,
|
|
322
|
+
pattern,
|
|
323
|
+
matches,
|
|
324
|
+
shown: matches.length,
|
|
325
|
+
total: all.length,
|
|
326
|
+
truncated: matches.length < all.length,
|
|
327
|
+
...(Object.keys(omittedPerFile).length ? { omittedPerFile } : {}),
|
|
328
|
+
};
|
|
306
329
|
}
|
|
307
330
|
/** `path:line:text` — split on the FIRST two colons only, since code is full of them. */
|
|
308
331
|
function parseGrepLine(line) {
|
|
@@ -315,7 +338,9 @@ function parseGrepLine(line) {
|
|
|
315
338
|
const n = Number.parseInt(line.slice(first + 1, second), 10);
|
|
316
339
|
if (!Number.isFinite(n))
|
|
317
340
|
return null;
|
|
318
|
-
|
|
341
|
+
const text = line.slice(second + 1).trim();
|
|
342
|
+
// Marked (#898): a cut line read as the whole line. `repo_read_file` with `startLine` has the rest.
|
|
343
|
+
return { path: line.slice(0, first), line: n, text: text.length > SEARCH_MAX_LINE ? `${text.slice(0, SEARCH_MAX_LINE)}…` : text };
|
|
319
344
|
}
|
|
320
345
|
/** The deepest `maxDepth` this will honour. Named because the TOOL has to say it (#508). */
|
|
321
346
|
export const TREE_MAX_DEPTH = 4;
|
|
@@ -342,6 +367,10 @@ export function repoTree(workDir, relPath = ".", maxDepth = 3, maxEntries = 500)
|
|
|
342
367
|
const queue = [{ dir: start, depth: 0 }];
|
|
343
368
|
let truncated = false;
|
|
344
369
|
let truncatedByDepth = false;
|
|
370
|
+
// Dot-entries ARE listed now (#898): `.github/`, `.env.example`, `.claude/` were hidden with
|
|
371
|
+
// nothing saying so. What is still skipped is named in `skipped`, so absence is never a claim.
|
|
372
|
+
const skipped = new Set();
|
|
373
|
+
const result = () => ({ root: relPath, entries, truncated, truncatedByDepth, depthCap, ...(skipped.size ? { skipped: [...skipped].sort() } : {}) });
|
|
345
374
|
while (queue.length) {
|
|
346
375
|
const { dir, depth } = queue.shift();
|
|
347
376
|
let items;
|
|
@@ -352,11 +381,13 @@ export function repoTree(workDir, relPath = ".", maxDepth = 3, maxEntries = 500)
|
|
|
352
381
|
continue;
|
|
353
382
|
}
|
|
354
383
|
for (const it of items) {
|
|
355
|
-
if (
|
|
384
|
+
if (IGNORE_DIRS.has(it.name)) {
|
|
385
|
+
skipped.add(it.name);
|
|
356
386
|
continue;
|
|
387
|
+
}
|
|
357
388
|
if (entries.length >= entryCap) {
|
|
358
389
|
truncated = true;
|
|
359
|
-
return
|
|
390
|
+
return result();
|
|
360
391
|
}
|
|
361
392
|
const abs = resolve(dir, it.name);
|
|
362
393
|
const rel = relative(workDir, abs);
|
|
@@ -387,7 +418,7 @@ export function repoTree(workDir, relPath = ".", maxDepth = 3, maxEntries = 500)
|
|
|
387
418
|
}
|
|
388
419
|
}
|
|
389
420
|
}
|
|
390
|
-
return
|
|
421
|
+
return result();
|
|
391
422
|
}
|
|
392
423
|
export const SYNC_FETCH_TTL_MS = 60_000;
|
|
393
424
|
/** Per work-tree root: when the last fetch attempt was made and how it went. */
|
|
@@ -189,10 +189,12 @@ export class CodingRuntime {
|
|
|
189
189
|
// `[exited with code N]` / `[error]` lines recorded on exit. Blanking it when the
|
|
190
190
|
// process is dead lost exactly the output + failure reason the brain/console needs
|
|
191
191
|
// to diagnose a crash or read a one-shot CLI's final result.
|
|
192
|
-
const
|
|
192
|
+
const full = session.snapshot();
|
|
193
|
+
const pane = clip(full);
|
|
193
194
|
return {
|
|
194
195
|
sessionId,
|
|
195
196
|
pane,
|
|
197
|
+
paneChars: full.length,
|
|
196
198
|
alive,
|
|
197
199
|
ready: alive ? session.ready : false,
|
|
198
200
|
runState: alive ? session.runState() : "idle",
|
|
@@ -391,6 +393,7 @@ export class CodingRuntime {
|
|
|
391
393
|
return session;
|
|
392
394
|
}
|
|
393
395
|
}
|
|
396
|
+
/** The tail, marked (#898): the head it drops is said, with both numbers, at the top. */
|
|
394
397
|
function clip(pane) {
|
|
395
|
-
return pane.length > MAX_PANE ? pane.slice(pane.length - MAX_PANE) : pane;
|
|
398
|
+
return pane.length > MAX_PANE ? `[cut: showing the last ${MAX_PANE} of ${pane.length} characters]\n${pane.slice(pane.length - MAX_PANE)}` : pane;
|
|
396
399
|
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { LOCAL_BROWSER_SOURCE_FAILURE_REASONS, redactDetail } from "./contract.js";
|
|
2
2
|
/** The browser tools a research run may use. Everything else from the browser is not even listed. */
|
|
3
3
|
const READ_TOOLS = new Set(["browser_navigate", "browser_navigate_back", "browser_snapshot", "browser_wait_for", "browser_click"]);
|
|
4
|
+
/** The research tools the bridge adds; the CLI records its results through these. */
|
|
5
|
+
const RESEARCH_TOOL_NAMES = ["record_finding", "report_source_failure", "finish_research"];
|
|
4
6
|
/** Refused BY NAME with the reason, so a CLI that guesses one is told why rather than "unknown tool". */
|
|
5
7
|
const WRITE_TOOLS = new Set([
|
|
6
8
|
"browser_type",
|
|
@@ -18,6 +20,11 @@ const WRITE_TOOLS = new Set([
|
|
|
18
20
|
/** Roles a click may target in research mode: moving between pages, never acting on one. */
|
|
19
21
|
const CLICKABLE_ROLES = new Set(["link", "tab", "menuitem", "treeitem", "option"]);
|
|
20
22
|
const PAGINATION_NAME = /^(next|previous|prev|more|load more|show more|see more|view more|older|newer|page \d+|\d+|›|»|‹|«)( page| results| jobs)?$/i;
|
|
23
|
+
/**
|
|
24
|
+
* Every tool name the bridge can ever list (#952) — the exact set a CLI may be told to trust
|
|
25
|
+
* without asking. Write tools are not in it, so trusting this set cannot trust a write.
|
|
26
|
+
*/
|
|
27
|
+
export const BRIDGE_TOOL_NAMES = [...READ_TOOLS, ...RESEARCH_TOOL_NAMES];
|
|
21
28
|
const RESEARCH_TOOLS = [
|
|
22
29
|
{
|
|
23
30
|
name: "record_finding",
|
|
@@ -12,6 +12,13 @@
|
|
|
12
12
|
* - Codex: `--sandbox read-only` (its shell cannot write, and per Codex's sandbox design has no
|
|
13
13
|
* network), `--ignore-user-config` (no MCP servers or providers from the user's own config — auth
|
|
14
14
|
* still comes from the login), web search switched off, and the bridge as its only MCP server.
|
|
15
|
+
* `codex exec` runs with approval policy `never`, so an MCP tool that needs approval is simply
|
|
16
|
+
* refused — a live run on 2026-10-07 opened no page for exactly that reason (#952). The bridge is
|
|
17
|
+
* therefore pre-approved, and ONLY it: `default_tools_approval_mode="approve"` on the
|
|
18
|
+
* `pags_browser` server, and `enabled_tools` pinned to the bridge's own tool names
|
|
19
|
+
* (`BRIDGE_TOOL_NAMES`). Nothing else is approved — the shell stays read-only with no network,
|
|
20
|
+
* there is no other server — and the bridge still enforces sites, consent, limits and the
|
|
21
|
+
* research-only tool set in the runner, whatever the CLI is allowed to call.
|
|
15
22
|
* NOT VERIFIED END TO END on a live machine: that read-only shell commands get no network, and
|
|
16
23
|
* which of the two web-search keys a given Codex version honours. #947 holds that test.
|
|
17
24
|
*
|
|
@@ -21,6 +28,7 @@
|
|
|
21
28
|
* turns a subscription run into per-token billing.
|
|
22
29
|
*/
|
|
23
30
|
import { mergeEnv } from "../coding/engine-env.js";
|
|
31
|
+
import { BRIDGE_TOOL_NAMES } from "./bridge.js";
|
|
24
32
|
import { resolveEngineAuth } from "../coding/engine-auth.js";
|
|
25
33
|
/** The bridge's MCP server name — and so the prefix of its tools inside the CLI. */
|
|
26
34
|
export const BRIDGE_SERVER_NAME = "pags_browser";
|
|
@@ -89,6 +97,11 @@ export function buildEngineSpec(input) {
|
|
|
89
97
|
`${key}.env=${envTable}`,
|
|
90
98
|
"-c",
|
|
91
99
|
`${key}.tool_timeout_sec=${Math.ceil(input.toolTimeoutMs / 1000)}`,
|
|
100
|
+
// Pre-approve the bridge, and only its own tools (#952); see the note atop this file.
|
|
101
|
+
"-c",
|
|
102
|
+
`${key}.default_tools_approval_mode="approve"`,
|
|
103
|
+
"-c",
|
|
104
|
+
`${key}.enabled_tools=[${BRIDGE_TOOL_NAMES.map(toml).join(", ")}]`,
|
|
92
105
|
// Two spellings across Codex versions; an unknown key is ignored, so both are set.
|
|
93
106
|
"-c",
|
|
94
107
|
'web_search="disabled"',
|
|
@@ -304,15 +304,16 @@ async function route(runner, req, res) {
|
|
|
304
304
|
const raw = (b.dir || "~").replace(/^~(?=$|\/)/, homedir());
|
|
305
305
|
const dir = resolve(raw);
|
|
306
306
|
try {
|
|
307
|
-
const
|
|
308
|
-
|
|
307
|
+
const visible = readdirSync(dir, { withFileTypes: true }).filter((e) => !e.name.startsWith("."));
|
|
308
|
+
const entries = visible
|
|
309
309
|
.slice(0, 200)
|
|
310
310
|
.map((e) => ({
|
|
311
311
|
name: e.name,
|
|
312
312
|
type: e.isDirectory() ? "dir" : "file",
|
|
313
313
|
size: e.isFile() ? statSync(resolve(dir, e.name)).size : undefined,
|
|
314
314
|
}));
|
|
315
|
-
|
|
315
|
+
// Said, not silent (#898): a folder of 500 entries read as one of 200.
|
|
316
|
+
return json(res, 200, { dir, entries, ...(visible.length > entries.length ? { total: visible.length, truncated: true } : {}) });
|
|
316
317
|
}
|
|
317
318
|
catch (e) {
|
|
318
319
|
return json(res, 400, { error: e instanceof Error ? e.message : String(e), dir });
|
package/dist/index.js
CHANGED
|
@@ -1718,7 +1718,7 @@ function openRelaySocket(instanceId, wsBase, mintToken, localUrl, runnerToken, f
|
|
|
1718
1718
|
try {
|
|
1719
1719
|
result = text2 ? JSON.parse(text2) : {};
|
|
1720
1720
|
} catch {
|
|
1721
|
-
result = { raw: text2.slice(0, 500) };
|
|
1721
|
+
result = text2.length > 500 ? { raw: text2.slice(0, 500), rawChars: text2.length, truncated: true } : { raw: text2 };
|
|
1722
1722
|
}
|
|
1723
1723
|
try {
|
|
1724
1724
|
ws.send(JSON.stringify({ id: cmd.id, status: res.status, result }));
|