@proagentstore/cli 0.4.65 → 0.4.67
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-acts.js +11 -3
- package/dist/browser-runner/coding/engine-check.js +114 -0
- package/dist/browser-runner/coding/headless.js +25 -3
- package/dist/browser-runner/coding/heredoc.js +103 -0
- package/dist/browser-runner/coding/runtime.js +1 -1
- package/dist/browser-runner/server.js +13 -0
- package/package.json +1 -1
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
* A raw engine emits nothing parseable, so an empty act list means "nothing observed", never
|
|
35
35
|
* "nothing happened", and every consumer has to say so rather than render it as an all-clear.
|
|
36
36
|
*/
|
|
37
|
+
import { splitHeredocs } from "./heredoc.js";
|
|
37
38
|
/** Acts whose consequences reach outside the machine, or cannot be walked back locally. */
|
|
38
39
|
const IRREVERSIBLE = new Set([
|
|
39
40
|
"pr.merge",
|
|
@@ -77,10 +78,15 @@ export function redactCommand(command) {
|
|
|
77
78
|
* Over-splitting costs nothing (a fragment simply matches no rule); under-splitting loses an act,
|
|
78
79
|
* and the real-world shape of the case this exists for is exactly a compound line:
|
|
79
80
|
* `cd repo && git push -u origin fix && gh pr create --fill && gh pr merge --squash`.
|
|
81
|
+
*
|
|
82
|
+
* Heredoc BODIES are removed first (#873): they are data — a commit message, a comment body, a
|
|
83
|
+
* script editing a test file — and their lines, split as commands, recorded pushes, merges and a
|
|
84
|
+
* repository deletion that never ran. This side sees the whole command, so a real command after
|
|
85
|
+
* the terminator is still read.
|
|
80
86
|
*/
|
|
81
87
|
export function splitSegments(command) {
|
|
82
|
-
return String(command ?? "")
|
|
83
|
-
.split(/\|\||&&|[;|\n]/)
|
|
88
|
+
return splitHeredocs(String(command ?? ""))
|
|
89
|
+
.executed.split(/\|\||&&|[;|\n]/)
|
|
84
90
|
.map((s) => s.trim())
|
|
85
91
|
.filter(Boolean);
|
|
86
92
|
}
|
|
@@ -265,7 +271,9 @@ export function classifySegment(segment) {
|
|
|
265
271
|
* was on that line.
|
|
266
272
|
*/
|
|
267
273
|
export function classifyCommand(id, command, at = new Date().toISOString()) {
|
|
268
|
-
|
|
274
|
+
// The evidence keeps what was executed and collapses each heredoc body to one line (#873), so
|
|
275
|
+
// the 400-character cap is not spent on a commit message while the push after it is cut off.
|
|
276
|
+
const full = redactCommand(splitHeredocs(command).elided);
|
|
269
277
|
if (!full)
|
|
270
278
|
return [];
|
|
271
279
|
const out = [];
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { accessSync, constants, statSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { delimiter, isAbsolute, join, resolve } from "node:path";
|
|
5
|
+
import { handlerFor } from "./handlers.js";
|
|
6
|
+
import { parseCommand } from "./headless.js";
|
|
7
|
+
function defaultIsExecutable(p) {
|
|
8
|
+
try {
|
|
9
|
+
if (!statSync(p).isFile())
|
|
10
|
+
return false;
|
|
11
|
+
accessSync(p, constants.X_OK);
|
|
12
|
+
return true;
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
function defaultExists(p) {
|
|
19
|
+
try {
|
|
20
|
+
statSync(p);
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
function defaultKeychainItemStatus(service) {
|
|
28
|
+
// Existence only: without `-w`/`-g` the secret is never read, so this cannot prompt or leak.
|
|
29
|
+
const r = spawnSync("security", ["find-generic-password", "-s", service], { stdio: "ignore", timeout: 5_000 });
|
|
30
|
+
return r.error ? null : r.status;
|
|
31
|
+
}
|
|
32
|
+
/** The same resolution `spawn(bin)` does: a path is taken as-is, a bare name is searched on PATH. */
|
|
33
|
+
export function resolveBinary(bin, deps = {}) {
|
|
34
|
+
const isExec = deps.isExecutable ?? defaultIsExecutable;
|
|
35
|
+
if (!bin)
|
|
36
|
+
return null;
|
|
37
|
+
if (bin.includes("/") || isAbsolute(bin)) {
|
|
38
|
+
const p = resolve(bin);
|
|
39
|
+
return isExec(p) ? p : null;
|
|
40
|
+
}
|
|
41
|
+
const env = deps.env ?? process.env;
|
|
42
|
+
const exts = (deps.platform ?? process.platform) === "win32" ? (env.PATHEXT || ".EXE;.CMD;.BAT").split(";") : [""];
|
|
43
|
+
for (const dir of (env.PATH || "").split(delimiter)) {
|
|
44
|
+
if (!dir)
|
|
45
|
+
continue;
|
|
46
|
+
for (const ext of exts) {
|
|
47
|
+
const p = join(dir, bin + ext);
|
|
48
|
+
if (isExec(p))
|
|
49
|
+
return p;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
/** Does this engine have a stored interactive login on the machine? */
|
|
55
|
+
export function detectEngineLogin(engine, deps = {}) {
|
|
56
|
+
const env = deps.env ?? process.env;
|
|
57
|
+
const home = deps.home ?? homedir();
|
|
58
|
+
const exists = deps.exists ?? defaultExists;
|
|
59
|
+
const platform = deps.platform ?? process.platform;
|
|
60
|
+
if (engine === "codex") {
|
|
61
|
+
// `codex login` (ChatGPT subscription) writes auth.json under CODEX_HOME, default ~/.codex.
|
|
62
|
+
const dir = env.CODEX_HOME?.trim() || join(home, ".codex");
|
|
63
|
+
const file = join(dir, "auth.json");
|
|
64
|
+
return { login: exists(file) ? "found" : "missing", where: file };
|
|
65
|
+
}
|
|
66
|
+
if (engine === "claude") {
|
|
67
|
+
// An exported subscription token is a login the engine can read in `machine` mode.
|
|
68
|
+
if (env.CLAUDE_CODE_OAUTH_TOKEN?.trim())
|
|
69
|
+
return { login: "found", where: "CLAUDE_CODE_OAUTH_TOKEN" };
|
|
70
|
+
const dir = env.CLAUDE_CONFIG_DIR?.trim() || join(home, ".claude");
|
|
71
|
+
const file = join(dir, ".credentials.json");
|
|
72
|
+
if (exists(file))
|
|
73
|
+
return { login: "found", where: file };
|
|
74
|
+
if (platform === "darwin") {
|
|
75
|
+
// Claude Code keeps its macOS login in the keychain. 0 = the item exists, 44 = it does
|
|
76
|
+
// not; anything else (a locked keychain, no `security`) is not an answer.
|
|
77
|
+
const status = (deps.keychainItemStatus ?? defaultKeychainItemStatus)("Claude Code-credentials");
|
|
78
|
+
if (status === 0)
|
|
79
|
+
return { login: "found", where: "keychain" };
|
|
80
|
+
if (status === 44)
|
|
81
|
+
return { login: "missing", where: `keychain / ${file}` };
|
|
82
|
+
return { login: "unknown" };
|
|
83
|
+
}
|
|
84
|
+
return { login: "missing", where: file };
|
|
85
|
+
}
|
|
86
|
+
if (engine === "gemini") {
|
|
87
|
+
// Google sign-in lands here; Gemini also accepts other auth (gcloud, Vertex), so absence is
|
|
88
|
+
// not proof of "signed out".
|
|
89
|
+
const file = join(home, ".gemini", "oauth_creds.json");
|
|
90
|
+
return exists(file) ? { login: "found", where: file } : { login: "unknown" };
|
|
91
|
+
}
|
|
92
|
+
return { login: "unknown" };
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* The whole answer. `credentialInjected` is the cloud saying it will hand this spawn a credential
|
|
96
|
+
* of its own (a platform-stored subscription token), in which case the machine's login is not what
|
|
97
|
+
* the engine reads and is not checked.
|
|
98
|
+
*/
|
|
99
|
+
export function checkEngine(input, deps = {}) {
|
|
100
|
+
const engine = (["claude", "gemini", "codex", "grok", "generic"].includes(input.clientType ?? "") ? input.clientType : "claude");
|
|
101
|
+
const parsed = parseCommand(input.command);
|
|
102
|
+
const bin = parsed.bin || parseCommand(handlerFor(engine).cliCommand).bin || engine;
|
|
103
|
+
const binaryPath = resolveBinary(bin, deps);
|
|
104
|
+
const login = input.credentialInjected ? { login: "found", where: "platform" } : detectEngineLogin(engine, deps);
|
|
105
|
+
return {
|
|
106
|
+
checked: true,
|
|
107
|
+
engine,
|
|
108
|
+
bin,
|
|
109
|
+
binaryFound: binaryPath !== null,
|
|
110
|
+
...(binaryPath ? { binaryPath } : {}),
|
|
111
|
+
login: login.login,
|
|
112
|
+
...(login.where ? { loginWhere: login.where } : {}),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
@@ -428,7 +428,26 @@ export class HeadlessSession {
|
|
|
428
428
|
}
|
|
429
429
|
});
|
|
430
430
|
}
|
|
431
|
-
/**
|
|
431
|
+
/**
|
|
432
|
+
* Does the engine carry its own conversation into THIS turn (#693 slice 2)?
|
|
433
|
+
*
|
|
434
|
+
* The boundary ADR 0005 draws, stated where the runner applies it: the platform's record is the
|
|
435
|
+
* source of truth and the engine's own memory is an optimisation preferred when it is there. It is
|
|
436
|
+
* there for a PERSISTENT process (Claude's stream-json session, a persistent REPL), which holds
|
|
437
|
+
* every turn since it started, and for a structured Codex turn that will resume its explicit
|
|
438
|
+
* thread (#848). A one-shot raw process has nothing: each turn is a new process.
|
|
439
|
+
*/
|
|
440
|
+
get holdsConversationThisTurn() {
|
|
441
|
+
return this.adapter.persistent || this.codexResumeThreadId !== null;
|
|
442
|
+
}
|
|
443
|
+
/**
|
|
444
|
+
* Send a user turn. `author` names who wrote it, because `role` cannot — see turn-author.ts (#505).
|
|
445
|
+
*
|
|
446
|
+
* `replay` is the cloud's per-turn composition of the platform's record (#693 slice 2). It is spent
|
|
447
|
+
* only when {@link holdsConversationThisTurn} is false, so an engine with its own memory is never
|
|
448
|
+
* handed a summary of the conversation it is already in; when spent it replaces the one-shot
|
|
449
|
+
* session-start brief, being the same record composed later.
|
|
450
|
+
*/
|
|
432
451
|
input(text, opts = {}) {
|
|
433
452
|
// The Engine reads the preamble, the pane the short marker; the instruction stays evidence.
|
|
434
453
|
const sent = authoredTurn(text, opts.author);
|
|
@@ -437,11 +456,14 @@ export class HeadlessSession {
|
|
|
437
456
|
// Pilot re-read through `/coding/capture` and twelve thousand characters of reconstructed
|
|
438
457
|
// history would evict the live output they are looking at — the same budget argument
|
|
439
458
|
// `turn-author.ts` makes for its two-word marker.
|
|
440
|
-
const
|
|
459
|
+
const replay = this.holdsConversationThisTurn ? null : opts.replay?.trim() || null;
|
|
460
|
+
const seed = replay ?? this.pendingSeed;
|
|
441
461
|
this.pendingSeed = null;
|
|
442
462
|
if (!this.alive)
|
|
443
463
|
this.start();
|
|
444
|
-
if (
|
|
464
|
+
if (replay)
|
|
465
|
+
this.push("[pags] this engine keeps no memory between turns, so ProAgentStore's record of the conversation so far was delivered with this turn — a reconstruction, not the engine's own memory");
|
|
466
|
+
else if (seed)
|
|
445
467
|
this.push("[pags] a context brief from ProAgentStore's record was delivered with this turn — a reconstruction, not the previous conversation");
|
|
446
468
|
this.push(`\n❯ [${stamp()}] ${authorTag(opts.author)}${text}`); // ❯ — your turn, timestamped
|
|
447
469
|
this.run = "thinking";
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Separate a shell command's heredoc BODIES from the text that is executed (#873).
|
|
3
|
+
*
|
|
4
|
+
* The act record carries the whole command line, and a heredoc body is data: a commit message, an
|
|
5
|
+
* issue-comment body, a Python script editing a test file. Read as commands, their lines produced
|
|
6
|
+
* acts that never happened ("merged a pull request #3" from a string inside a Python heredoc) and
|
|
7
|
+
* repository targets nobody wrote to (#872, `mcp/apps` from a comment body).
|
|
8
|
+
*
|
|
9
|
+
* VENDORED: `workers/api/src/lib/heredoc.ts` is the original; this is a copy, because a Worker must not
|
|
10
|
+
* import the runner package. `workers/api/src/lib/heredoc.test.ts` runs the same cases through both.
|
|
11
|
+
*
|
|
12
|
+
* The rules, and the one place this is stricter than "strip from `<<` to the terminator":
|
|
13
|
+
* - `<<WORD`, `<<'WORD'`, `<<"WORD"`, `<<\WORD` and `<<-WORD` open a heredoc. `<<<` is a
|
|
14
|
+
* here-string, not a heredoc. Several operators on one line are read in order, as the shell does.
|
|
15
|
+
* - An operator only counts outside quotes, except inside a `$( … )` substitution, which is how
|
|
16
|
+
* `git commit -m "$(cat <<'EOF'` is written — a `<<` inside `"a << b"` is not one.
|
|
17
|
+
* - The body is the lines AFTER the operator's line, up to a line that is exactly the delimiter
|
|
18
|
+
* (leading tabs allowed for `<<-`). The terminator line is dropped too.
|
|
19
|
+
* - The REST OF THE OPERATOR'S LINE is executed and kept: `git commit -F - <<'EOF' && git push`
|
|
20
|
+
* pushes, and dropping it would hide a real act.
|
|
21
|
+
* - No terminator — the stored command is cut at 400 characters — makes everything after the
|
|
22
|
+
* operator's line body. A heredoc that never closed cannot have run anything after it that
|
|
23
|
+
* this text shows.
|
|
24
|
+
*/
|
|
25
|
+
/** A delimiter, read at the position just after `<<`. */
|
|
26
|
+
const DELIMITER = /(-?)[ \t]*(?:'([^'\n]*)'|"([^"\n]*)"|\\?([A-Za-z_][A-Za-z0-9_]*))/y;
|
|
27
|
+
/** The heredoc operators on one executed line, in order, respecting quotes and `$( … )`. */
|
|
28
|
+
function operatorsOn(line) {
|
|
29
|
+
const out = [];
|
|
30
|
+
// A stack of contexts: "sh" (unquoted, including inside `$( … )`), "dq" and "sq".
|
|
31
|
+
const stack = ["sh"];
|
|
32
|
+
for (let i = 0; i < line.length; i++) {
|
|
33
|
+
const ch = line[i];
|
|
34
|
+
const top = stack[stack.length - 1];
|
|
35
|
+
if (top === "sq") {
|
|
36
|
+
if (ch === "'")
|
|
37
|
+
stack.pop();
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if (ch === "\\") {
|
|
41
|
+
i++;
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (ch === "$" && line[i + 1] === "(") {
|
|
45
|
+
stack.push("sh");
|
|
46
|
+
i++;
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (top === "dq") {
|
|
50
|
+
if (ch === '"')
|
|
51
|
+
stack.pop();
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (ch === "'")
|
|
55
|
+
stack.push("sq");
|
|
56
|
+
else if (ch === '"')
|
|
57
|
+
stack.push("dq");
|
|
58
|
+
else if (ch === ")" && stack.length > 1)
|
|
59
|
+
stack.pop();
|
|
60
|
+
else if (ch === "#" && (i === 0 || /\s/.test(line[i - 1])))
|
|
61
|
+
break;
|
|
62
|
+
else if (ch === "<" && line[i + 1] === "<" && line[i + 2] !== "<" && line[i - 1] !== "<") {
|
|
63
|
+
DELIMITER.lastIndex = i + 2;
|
|
64
|
+
const m = DELIMITER.exec(line);
|
|
65
|
+
const word = m ? (m[2] ?? m[3] ?? m[4]) : undefined;
|
|
66
|
+
if (m && word) {
|
|
67
|
+
out.push({ word, dash: m[1] === "-" });
|
|
68
|
+
i = DELIMITER.lastIndex - 1;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
export function splitHeredocs(command) {
|
|
75
|
+
const executed = [];
|
|
76
|
+
const bodies = [];
|
|
77
|
+
const elided = [];
|
|
78
|
+
const pending = [];
|
|
79
|
+
let bodyLines = 0;
|
|
80
|
+
const marker = () => `[heredoc body: ${bodyLines} line${bodyLines === 1 ? "" : "s"}]`;
|
|
81
|
+
for (const raw of String(command ?? "").split("\n")) {
|
|
82
|
+
const line = raw.replace(/\r$/, "");
|
|
83
|
+
if (pending.length) {
|
|
84
|
+
const { word, dash } = pending[0];
|
|
85
|
+
if ((dash ? line.replace(/^\t+/, "") : line) === word) {
|
|
86
|
+
pending.shift();
|
|
87
|
+
elided.push(marker(), raw);
|
|
88
|
+
bodyLines = 0;
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
bodies.push(raw);
|
|
92
|
+
bodyLines++;
|
|
93
|
+
}
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
executed.push(raw);
|
|
97
|
+
elided.push(raw);
|
|
98
|
+
pending.push(...operatorsOn(line));
|
|
99
|
+
}
|
|
100
|
+
if (pending.length)
|
|
101
|
+
elided.push(marker());
|
|
102
|
+
return { executed: executed.join("\n"), bodies: bodies.join("\n"), unterminated: pending.length > 0, elided: elided.join("\n") };
|
|
103
|
+
}
|
|
@@ -216,7 +216,7 @@ export class CodingRuntime {
|
|
|
216
216
|
case "message":
|
|
217
217
|
// Narrowed rather than trusted: this runner is a published package any caller can
|
|
218
218
|
// POST to, and an unrecognised author must read as "unstated", not become a label.
|
|
219
|
-
session.input(action.text, { author: asTurnAuthor(action.author) });
|
|
219
|
+
session.input(action.text, { author: asTurnAuthor(action.author), replay: typeof action.replay === "string" ? action.replay : undefined });
|
|
220
220
|
break;
|
|
221
221
|
case "keys": {
|
|
222
222
|
// A snapshot is no longer the whole answer (#448). `key()` records the attempt and
|
|
@@ -2,6 +2,7 @@ import { createServer, } from "node:http";
|
|
|
2
2
|
import { URL } from "node:url";
|
|
3
3
|
import { LocalRunner, RunnerInputError } from "./runner.js";
|
|
4
4
|
import { probeGitSshIdentity } from "./coding/repo.js";
|
|
5
|
+
import { checkEngine } from "./coding/engine-check.js";
|
|
5
6
|
import { listGithubOrgs, listGithubRepos, searchGithubRepos, getGithubRepoDetail, getGithubCredentialScope } from "./coding/github-browse.js";
|
|
6
7
|
export function createRunnerServer(runner) {
|
|
7
8
|
return createServer(async (req, res) => {
|
|
@@ -383,6 +384,18 @@ async function route(runner, req, res) {
|
|
|
383
384
|
return json(res, 400, { error: e instanceof Error ? e.message : String(e) });
|
|
384
385
|
}
|
|
385
386
|
}
|
|
387
|
+
// Can this machine run an engine — its binary on PATH, its subscription login stored (#879)? Asked
|
|
388
|
+
// BEFORE a session is launched, so a missing engine is a named refusal rather than a dead pane.
|
|
389
|
+
// An older runner 404s this, and the cloud reads that as "unverified", never as "missing".
|
|
390
|
+
if (req.method === "POST" && path === "/coding/engine-check") {
|
|
391
|
+
const b = await readJson(req);
|
|
392
|
+
try {
|
|
393
|
+
return json(res, 200, checkEngine(b));
|
|
394
|
+
}
|
|
395
|
+
catch (e) {
|
|
396
|
+
return json(res, 400, { error: e instanceof Error ? e.message : String(e) });
|
|
397
|
+
}
|
|
398
|
+
}
|
|
386
399
|
// Background cold-start clones (#858): start (or join) one, and read it back. An older runner 404s
|
|
387
400
|
// both, and the cloud falls back to the synchronous `/coding/clone` below.
|
|
388
401
|
if (req.method === "POST" && (path === "/coding/clone-start" || path === "/coding/clone-status")) {
|