@proagentstore/cli 0.4.41 → 0.4.43
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.
|
@@ -94,6 +94,70 @@ function prRef(segment) {
|
|
|
94
94
|
const num = segment.match(/\bgh\s+pr\s+(?:merge|create)\b(?:\s+-{1,2}\S+(?:=\S+)?)*\s+(\d+)\b/);
|
|
95
95
|
return num ? `#${num[1]}` : null;
|
|
96
96
|
}
|
|
97
|
+
/** A pull request URL anywhere in a command's own output. Anchored on a literal `http` prefix. */
|
|
98
|
+
const PR_URL = /https?:\/\/\S*?\/pull\/(\d+)/;
|
|
99
|
+
/**
|
|
100
|
+
* Flatten a `tool_result` block's content to text — the RAW result, not the display line.
|
|
101
|
+
*
|
|
102
|
+
* `headless.ts`'s `toolResult()` collapses whitespace and truncates to 240 characters for the
|
|
103
|
+
* transcript, which is right for a human-readable pane and wrong here: `gh pr create` prints its
|
|
104
|
+
* URL after whatever else the compound command wrote, so the one token that matters is exactly what
|
|
105
|
+
* a 240-character cap would drop.
|
|
106
|
+
*/
|
|
107
|
+
export function resultText(content) {
|
|
108
|
+
if (typeof content === "string")
|
|
109
|
+
return content;
|
|
110
|
+
if (Array.isArray(content)) {
|
|
111
|
+
return content
|
|
112
|
+
.map((b) => (b && typeof b === "object" && "text" in b ? String(b.text ?? "") : ""))
|
|
113
|
+
.join("\n");
|
|
114
|
+
}
|
|
115
|
+
return "";
|
|
116
|
+
}
|
|
117
|
+
/** An act that names a pull request but does not yet say WHICH one. */
|
|
118
|
+
function wantsPrNumber(act) {
|
|
119
|
+
return (act.kind === "pr.open" || act.kind === "pr.merge") && act.target === null;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Fill in the PR number from the command's OWN output, for the acts that lack one (#417).
|
|
123
|
+
*
|
|
124
|
+
* `gh pr create --fill` is the common form and carries no number on the command line, so
|
|
125
|
+
* {@link prRef} returns null and `pull-attribution.ts` leaves the row unbadged. That file states the
|
|
126
|
+
* rule — **ATTRIBUTION IS EXACT OR ABSENT** — and rejects the tempting repair of pairing an
|
|
127
|
+
* unnumbered `pr.open` with whichever PR appeared around the same time, because a badge that is
|
|
128
|
+
* right most of the time is worse than no badge.
|
|
129
|
+
*
|
|
130
|
+
* This is not that repair. `gh pr create` prints the new PR's URL on stdout, and that output comes
|
|
131
|
+
* back as the `tool_result` for the SAME `tool_use_id` as the command that was classified. It is the
|
|
132
|
+
* command's own answer, in the same class of fact as the `ok` flag already taken from that block —
|
|
133
|
+
* not a temporal guess about what appeared nearby. When the output names no PR the target stays
|
|
134
|
+
* null, so absent stays absent.
|
|
135
|
+
*
|
|
136
|
+
* Deliberately narrow, because the one failure this module exists to avoid is a confident wrong
|
|
137
|
+
* number: only `pr.open`/`pr.merge` are eligible, only when their target is still null, and only
|
|
138
|
+
* against the result correlated to them by `tool_use_id`. A PR URL quoted incidentally in some other
|
|
139
|
+
* command's output reaches no act.
|
|
140
|
+
*
|
|
141
|
+
* **The "already exists" case is decided, not overlooked.** `gh pr create` on a branch that already
|
|
142
|
+
* has a PR fails with "a pull request for branch X already exists:" and that PR's URL, and this
|
|
143
|
+
* attributes the act to it. That is a judgement: it IS the pull request for this branch and the
|
|
144
|
+
* agent did just act on it, and dropping a real signal to avoid a case where the answer is still
|
|
145
|
+
* true costs more than it saves. The act still carries `ok: false`, so the record remains honest
|
|
146
|
+
* that the command failed.
|
|
147
|
+
*
|
|
148
|
+
* Only reachable from the structured stream-json path. A Codex/Grok raw spawn has no
|
|
149
|
+
* `tool_use`/`tool_result` framing at all, so its PRs stay unattributed — regexing its transcript
|
|
150
|
+
* instead would reintroduce exactly the temporal guess this design refuses.
|
|
151
|
+
*/
|
|
152
|
+
export function fillTargetFromResult(acts, content) {
|
|
153
|
+
if (!acts.some(wantsPrNumber))
|
|
154
|
+
return acts;
|
|
155
|
+
const m = resultText(content).match(PR_URL);
|
|
156
|
+
if (!m)
|
|
157
|
+
return acts;
|
|
158
|
+
const target = `#${m[1]}`;
|
|
159
|
+
return acts.map((a) => (wantsPrNumber(a) ? { ...a, target } : a));
|
|
160
|
+
}
|
|
97
161
|
function pushTarget(segment) {
|
|
98
162
|
// `git push [flags] <remote> <refspec>` — take the first two non-flag words after `push`.
|
|
99
163
|
const after = segment.split(/\bgit\s+push\b/)[1] ?? "";
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
-
import { classifyCommand, commandFromToolInput } from "./engine-acts.js";
|
|
2
|
+
import { classifyCommand, commandFromToolInput, fillTargetFromResult } from "./engine-acts.js";
|
|
3
3
|
import { parseEngineUsage } from "./engine-usage.js";
|
|
4
4
|
/**
|
|
5
5
|
* Merge the platform's resolved engine env over the machine's, where an EMPTY value means
|
|
@@ -604,7 +604,19 @@ export class HeadlessSession {
|
|
|
604
604
|
return;
|
|
605
605
|
this.awaitingResult.set(toolUseId, acts);
|
|
606
606
|
}
|
|
607
|
-
/**
|
|
607
|
+
/**
|
|
608
|
+
* The matching `tool_result` arrived — stamp the outcome and publish.
|
|
609
|
+
*
|
|
610
|
+
* The result carries more than the outcome: `gh pr create --fill` states its PR number nowhere
|
|
611
|
+
* but its own stdout, so an unnumbered `pr.open`/`pr.merge` takes it from here (#417). It is read
|
|
612
|
+
* from the RAW `block.content`, never from `toolResult()`'s display line — that truncates to 240
|
|
613
|
+
* characters for the transcript and would cut the URL off a verbose result.
|
|
614
|
+
*
|
|
615
|
+
* This path (and `noteAct`) is reachable ONLY from the structured stream-json handling above
|
|
616
|
+
* (`assistant` → `tool_use`, `user` → `tool_result`). A Codex/Grok session is a raw spawn with no
|
|
617
|
+
* such framing, so its PRs stay unattributed by construction; scraping its transcript instead
|
|
618
|
+
* would be the temporal guess `pull-attribution.ts` refuses.
|
|
619
|
+
*/
|
|
608
620
|
settleAct(block) {
|
|
609
621
|
const id = typeof block.tool_use_id === "string" ? block.tool_use_id : "";
|
|
610
622
|
const acts = this.awaitingResult.get(id);
|
|
@@ -612,7 +624,7 @@ export class HeadlessSession {
|
|
|
612
624
|
return;
|
|
613
625
|
this.awaitingResult.delete(id);
|
|
614
626
|
const ok = block.is_error !== true;
|
|
615
|
-
for (const a of acts)
|
|
627
|
+
for (const a of fillTargetFromResult(acts, block.content))
|
|
616
628
|
this.publishAct({ ...a, ok });
|
|
617
629
|
}
|
|
618
630
|
/** Publish everything still waiting, with an unknown outcome. */
|
|
@@ -1,15 +1,49 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
|
-
import { existsSync, mkdirSync, readdirSync, rmSync } from "node:fs";
|
|
2
|
+
import { existsSync, mkdirSync, readdirSync, rmSync, statSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
/**
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* clone`. The coding engine stopped using tmux when it moved to the structured stream-json
|
|
9
|
-
* interface, so anyone cleaning up that module found its two most load-bearing functions
|
|
10
|
-
* inside it (#247). Split out so the tmux module is only tmux, and only the terminal-operator
|
|
11
|
-
* agents depend on it.
|
|
5
|
+
* Inspect a workdir: does it exist, does it hold anything, is it a checkout. Never throws —
|
|
6
|
+
* every failure is a `false`, because this function exists to REPORT a broken path and one that
|
|
7
|
+
* threw would be reported as a broken runner instead.
|
|
12
8
|
*/
|
|
9
|
+
export function checkWorkdir(dir) {
|
|
10
|
+
const absent = { checked: true, path: dir, exists: false, isDirectory: false, entryCount: 0, insideWorkTree: false, gitChecked: true };
|
|
11
|
+
let isDirectory = false;
|
|
12
|
+
try {
|
|
13
|
+
isDirectory = statSync(dir).isDirectory();
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return { ...absent };
|
|
17
|
+
}
|
|
18
|
+
if (!isDirectory)
|
|
19
|
+
return { ...absent, exists: true };
|
|
20
|
+
let entryCount = 0;
|
|
21
|
+
try {
|
|
22
|
+
entryCount = readdirSync(dir).length;
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
// Unreadable (permissions) — reported as empty rather than as a crash; the cloud's
|
|
26
|
+
// message for "empty" tells the owner to look at the path either way.
|
|
27
|
+
}
|
|
28
|
+
let insideWorkTree = false;
|
|
29
|
+
let gitChecked = true;
|
|
30
|
+
try {
|
|
31
|
+
const out = execFileSync("git", ["rev-parse", "--is-inside-work-tree"], {
|
|
32
|
+
cwd: dir,
|
|
33
|
+
encoding: "utf-8",
|
|
34
|
+
timeout: 10_000,
|
|
35
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
36
|
+
});
|
|
37
|
+
insideWorkTree = out.trim() === "true";
|
|
38
|
+
}
|
|
39
|
+
catch (e) {
|
|
40
|
+
// Exit 128 ("not a git repository") is an ANSWER. ENOENT is git missing from PATH, which
|
|
41
|
+
// is not — and reporting it as "not a checkout" would condemn every repo on that machine.
|
|
42
|
+
if (e?.code === "ENOENT")
|
|
43
|
+
gitChecked = false;
|
|
44
|
+
}
|
|
45
|
+
return { checked: true, path: dir, exists: true, isDirectory, entryCount, insideWorkTree, gitChecked };
|
|
46
|
+
}
|
|
13
47
|
/**
|
|
14
48
|
* A safe, collision-resistant label derived from an arbitrary string.
|
|
15
49
|
*
|
|
@@ -2,7 +2,7 @@ import { homedir } from "node:os";
|
|
|
2
2
|
import { join, resolve } from "node:path";
|
|
3
3
|
import { defaultStatePath, HeadlessSession } from "./headless.js";
|
|
4
4
|
import { InspectError, readGitRemoteOrigin, readRepoFile, repoTree, runRepoGit } from "./inspect.js";
|
|
5
|
-
import { ensureRepo, sanitizeSessionName } from "./repo.js";
|
|
5
|
+
import { checkWorkdir, ensureRepo, sanitizeSessionName } from "./repo.js";
|
|
6
6
|
/** Hard cap on a pane returned to the brain/console (matches the worker MAX_PANE_CHARS). */
|
|
7
7
|
const MAX_PANE = 64 * 1024;
|
|
8
8
|
export class CodingRuntime {
|
|
@@ -58,6 +58,16 @@ export class CodingRuntime {
|
|
|
58
58
|
gitRemote(input) {
|
|
59
59
|
return { remote: readGitRemoteOrigin(this.resolveWorkDir(input)) };
|
|
60
60
|
}
|
|
61
|
+
/**
|
|
62
|
+
* Is the configured workdir usable AT ALL — present, non-empty, a checkout (#405)?
|
|
63
|
+
*
|
|
64
|
+
* The only endpoint here that answers a question about the PATH rather than about its
|
|
65
|
+
* contents, and the one the cloud needs before it may call a repo `ready`. Reports the
|
|
66
|
+
* resolved path so a message can name the thing the owner actually typed, `~` and all.
|
|
67
|
+
*/
|
|
68
|
+
checkRepo(input) {
|
|
69
|
+
return checkWorkdir(this.resolveWorkDir(input));
|
|
70
|
+
}
|
|
61
71
|
static taskTypes() {
|
|
62
72
|
return ["coding.session"];
|
|
63
73
|
}
|
|
@@ -232,6 +232,18 @@ async function route(runner, req, res) {
|
|
|
232
232
|
return json(res, 400, { error: e instanceof Error ? e.message : String(e) });
|
|
233
233
|
}
|
|
234
234
|
}
|
|
235
|
+
// Does the configured workdir exist / hold files / belong to a checkout (#405)? An older
|
|
236
|
+
// runner 404s this; the cloud reads the missing `checked:true` as "unverified" and leaves
|
|
237
|
+
// the repo's status alone rather than condemning it.
|
|
238
|
+
if (req.method === "POST" && path === "/coding/repo-check") {
|
|
239
|
+
const b = await readJson(req);
|
|
240
|
+
try {
|
|
241
|
+
return json(res, 200, runner.coding.checkRepo(b));
|
|
242
|
+
}
|
|
243
|
+
catch (e) {
|
|
244
|
+
return json(res, 400, { error: e instanceof Error ? e.message : String(e) });
|
|
245
|
+
}
|
|
246
|
+
}
|
|
235
247
|
if (req.method === "POST" && path === "/coding/tree") {
|
|
236
248
|
const b = await readJson(req);
|
|
237
249
|
try {
|