pi-better-subagents 0.1.0

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.
@@ -0,0 +1,145 @@
1
+ /**
2
+ * First-party finalization + result assembly for child exits.
3
+ *
4
+ * Kept free of the pi host package so tests can execute the real
5
+ * parser/classifier/registry/callback path against durable run metadata.
6
+ */
7
+
8
+ import { buildCompletionDelivery } from "./completion.ts";
9
+ import {
10
+ classifyChildExit,
11
+ formatSubagentResult,
12
+ resolveLifecycle,
13
+ type ChildExitOutcome,
14
+ } from "./lifecycle.ts";
15
+ import { parseRunForLifecycle, tailLog } from "./parse.ts";
16
+ import {
17
+ canExitFinalize,
18
+ effectiveStatus,
19
+ isFinalResultStatus,
20
+ readMeta,
21
+ writeMeta,
22
+ type RunMeta,
23
+ } from "./registry.ts";
24
+ import { fmtElapsed, fmtSpend } from "./widget.ts";
25
+
26
+ export interface FinalizeHooks {
27
+ renderWidget?: () => void;
28
+ notify?: (message: string, level: "info" | "warning") => void;
29
+ sendMessage?: (
30
+ message: { customType: string; content: string; display: boolean },
31
+ options: Record<string, unknown>,
32
+ ) => void;
33
+ }
34
+
35
+ export interface FinalizeResult {
36
+ applied: boolean;
37
+ meta?: RunMeta;
38
+ outcome?: ChildExitOutcome;
39
+ delivery?: { content: string; options: Record<string, unknown> };
40
+ }
41
+
42
+ /**
43
+ * Finalize a run once its child exits. Idempotent: a run already marked
44
+ * terminal is left alone. Persists lifecycle classification + failureReason
45
+ * and builds the completion callback delivery from real log evidence.
46
+ */
47
+ export function finalizeRun(
48
+ id: string,
49
+ code: number | null,
50
+ hooks: FinalizeHooks = {},
51
+ ): FinalizeResult {
52
+ const meta = readMeta(id);
53
+ // Coherent child-exit evidence may supersede provisional orphaned/lost
54
+ // reconciliation, but never overwrites a true terminal record.
55
+ if (!meta || !canExitFinalize(meta.status)) return { applied: false };
56
+
57
+ // Lifecycle authority streams the complete NDJSON log; result text stays bounded.
58
+ const r = parseRunForLifecycle(id);
59
+ const outcome = classifyChildExit(code, r);
60
+ meta.status = outcome.status;
61
+ meta.lifecycleClassification = outcome.classification;
62
+ if (outcome.incomplete) meta.failureReason = "incomplete-stream";
63
+ meta.exitCode = code;
64
+ meta.endedAt = Date.now();
65
+ writeMeta(meta);
66
+
67
+ const label = meta.name ? `${meta.name} (${id})` : id;
68
+ const verdict = outcome.verdict;
69
+ const el = fmtElapsed(meta.endedAt - meta.startedAt);
70
+ const spend = fmtSpend(r.usage);
71
+ const stat = `${el}${spend ? ` · ${spend}` : ""}`;
72
+ const tools = r.toolCalls.length ? r.toolCalls.join(", ") : undefined;
73
+
74
+ // A finished run is no longer in the widget; redraw (and stop the ticker if
75
+ // it was the last one).
76
+ hooks.renderWidget?.();
77
+
78
+ // Best-effort human toast. ctx may be stale by now; never let it throw.
79
+ try {
80
+ hooks.notify?.(
81
+ `Subagent ${label} ${verdict} · ${stat}`,
82
+ meta.status === "completed" ? "info" : "warning",
83
+ );
84
+ } catch {
85
+ /* ignore */
86
+ }
87
+
88
+ const callback = meta.callback !== false; // default: trigger completion
89
+ // buildCompletionDelivery is the single place sendMessage content/options are
90
+ // assembled. resultText is accepted here so callers/tests can pass it without
91
+ // breaking, but it is NEVER put into content — the result lives in subagent_result.
92
+ const delivery = buildCompletionDelivery({
93
+ id,
94
+ label,
95
+ verdict,
96
+ stat,
97
+ tools,
98
+ callback,
99
+ incomplete: outcome.incomplete,
100
+ lifecycleClassification: outcome.classification,
101
+ resultText: r.finalText || r.lastActivity || "",
102
+ });
103
+ hooks.sendMessage?.(
104
+ { customType: "subagent-complete", content: delivery.content, display: true },
105
+ delivery.options,
106
+ );
107
+
108
+ return {
109
+ applied: true,
110
+ meta: readMeta(id),
111
+ outcome,
112
+ delivery,
113
+ };
114
+ }
115
+
116
+ /**
117
+ * Assemble the user-visible `subagent_result` body from durable meta + log.
118
+ * Returns null when the run is still live so the tool can emit its running message.
119
+ */
120
+ export function buildSubagentResultText(id: string): string | null {
121
+ const meta = readMeta(id);
122
+ if (!meta) throw new Error(`Unknown run id: ${id}`);
123
+ const st = effectiveStatus(meta);
124
+ // Non-final statuses (running / orphaned) have no final result body.
125
+ if (!isFinalResultStatus(st)) return null;
126
+
127
+ const exit = meta.exitCode === undefined ? "?" : String(meta.exitCode);
128
+ // Re-derive lifecycle diagnostics from the complete stream even when meta is stale.
129
+ const r = parseRunForLifecycle(id);
130
+ const el = fmtElapsed((meta.endedAt ?? Date.now()) - meta.startedAt);
131
+ const spend = fmtSpend(r.usage);
132
+ const statSeg = ` · ${el}${spend ? ` · ${spend}` : ""}`;
133
+ const tools = r.toolCalls.length ? ` · tools: ${r.toolCalls.join(", ")}` : "";
134
+ const lifecycle = resolveLifecycle(meta, r);
135
+ return formatSubagentResult({
136
+ id,
137
+ status: st,
138
+ exitCode: exit,
139
+ statSeg,
140
+ toolsSeg: tools,
141
+ run: r,
142
+ rawLogTail: tailLog(id, 40),
143
+ lifecycle,
144
+ });
145
+ }
package/git-remotes.ts ADDED
@@ -0,0 +1,413 @@
1
+ /**
2
+ * First-class Git remote semantics for disposable clone workspaces
3
+ * (issue #103 normal fetch-URL + pushurl topologies + issue #109 edge cases).
4
+ *
5
+ * Invariant (`git-remote-preservation`): disposable clone preparation must
6
+ * preserve every source remote name, **every** configured `remote.<name>.url`
7
+ * value, and **every** configured `remote.<name>.pushurl` value — not just the
8
+ * first of either. Git permits multi-valued keys for both:
9
+ *
10
+ * - With no explicit pushurl, `git remote get-url --push --all` returns all
11
+ * fetch URLs and one `git push` reaches every URL.
12
+ * - With explicit pushurl(s), push uses those destinations only.
13
+ * - Push-only remotes (issue #109): zero `remote.<name>.url` entries and one or
14
+ * more `remote.<name>.pushurl` entries. Model `urls` as an empty ordered list;
15
+ * never invent a fetch URL from the first pushurl.
16
+ *
17
+ * Collapsing either multi-valued set rewrites producer push/fetch topology.
18
+ *
19
+ * Values are read from null-delimited `git config` output rather than
20
+ * `git remote -v` line parsing, which drops any URL containing spaces.
21
+ *
22
+ * Source remote read-failure safety (issue #109): if source remote config cannot
23
+ * be read because the source is missing/unreadable/not a Git repo,
24
+ * `syncGitRemotes` fails before mutating the target. A valid Git repo with no
25
+ * remote keys still returns `[]` and may clear target remotes.
26
+ *
27
+ * Consumed by disposable clone workspace preparation (issue #78 / PR #89).
28
+ */
29
+
30
+ import { execFileSync } from "node:child_process";
31
+
32
+ /** One configured Git remote, including every fetch URL and every explicit push URL. */
33
+ export interface GitRemote {
34
+ name: string;
35
+ /**
36
+ * Every configured `remote.<name>.url`, in config order.
37
+ * Empty for push-only remotes (zero fetch URLs, one or more pushurls).
38
+ * Git fetches from the first when present and, when no pushurl is set,
39
+ * pushes to all.
40
+ */
41
+ urls: string[];
42
+ /**
43
+ * Every configured `remote.<name>.pushurl`, in config order.
44
+ * Empty when the remote has no explicit push URL (Git then pushes to every
45
+ * entry in `urls`).
46
+ */
47
+ pushUrls: string[];
48
+ }
49
+
50
+ function runGit(cwd: string, args: string[]): string {
51
+ try {
52
+ return execFileSync("git", args, {
53
+ cwd,
54
+ encoding: "utf-8",
55
+ stdio: ["ignore", "pipe", "pipe"],
56
+ }).trim();
57
+ } catch (err) {
58
+ const message = (err as Error).message ?? String(err);
59
+ throw new Error(`git ${args.join(" ")} failed in ${cwd}: ${message}`);
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Confirm `dir` is a readable Git repository (work tree or bare).
65
+ *
66
+ * `git config --get-regexp` exits 1 both when there are no matching keys in a
67
+ * valid repo and when the directory is not a Git repo, so callers must validate
68
+ * the repository first. Missing paths and permission errors also surface here.
69
+ */
70
+ function assertGitRepository(dir: string): void {
71
+ try {
72
+ execFileSync("git", ["rev-parse", "--git-dir"], {
73
+ cwd: dir,
74
+ encoding: "utf-8",
75
+ stdio: ["ignore", "pipe", "pipe"],
76
+ });
77
+ } catch (err) {
78
+ const message = (err as Error).message ?? String(err);
79
+ throw new Error(
80
+ `cannot read Git remotes from ${dir}: not a readable Git repository (${message})`,
81
+ );
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Read remote fetch/push URLs from structured Git config.
87
+ *
88
+ * Uses `git config --null --get-regexp` so URL values may contain spaces, tabs,
89
+ * or other whitespace without being truncated. Collects **every**
90
+ * `remote.<name>.url` and **every** `remote.<name>.pushurl` entry (Git allows
91
+ * multiple of each; multi-url with no pushurl is a multi-destination push set;
92
+ * push-only remotes keep `urls: []`).
93
+ *
94
+ * A valid Git repository with no matching remote keys returns `[]`.
95
+ * Missing/unreadable/non-git paths throw (do not conflate with empty).
96
+ */
97
+ export function readGitRemotes(dir: string): GitRemote[] {
98
+ // Validate the repository before interpreting get-regexp exit status.
99
+ // git config --get-regexp exits 1 for "no match" AND for "not a git repo",
100
+ // so empty-valid vs operational failure is only distinguishable after this.
101
+ assertGitRepository(dir);
102
+
103
+ let raw: string;
104
+ try {
105
+ // Call git directly (not runGit) so we do not .trim() away trailing
106
+ // structure from null-delimited config output.
107
+ raw = execFileSync(
108
+ "git",
109
+ ["config", "--null", "--get-regexp", "^remote\\..*\\.(url|pushurl)$"],
110
+ {
111
+ cwd: dir,
112
+ encoding: "utf-8",
113
+ stdio: ["ignore", "pipe", "pipe"],
114
+ },
115
+ );
116
+ } catch (err) {
117
+ const status = (err as { status?: number }).status;
118
+ // Exit 1 with empty output = no matching keys in a valid repo.
119
+ if (status === 1) {
120
+ return [];
121
+ }
122
+ const message = (err as Error).message ?? String(err);
123
+ throw new Error(
124
+ `cannot read Git remote config from ${dir}: ${message}`,
125
+ );
126
+ }
127
+ if (!raw) return [];
128
+
129
+ const byName = new Map<string, { fetch: string[]; push: string[] }>();
130
+ for (const record of raw.split("\0")) {
131
+ if (!record) continue;
132
+ const nl = record.indexOf("\n");
133
+ if (nl < 0) continue;
134
+ const key = record.slice(0, nl);
135
+ const value = record.slice(nl + 1);
136
+ // Empty values are not meaningful remote URLs; skip.
137
+ if (value === "") continue;
138
+
139
+ // remote.<name>.url | remote.<name>.pushurl — name may itself contain dots.
140
+ const match = key.match(/^remote\.(.+)\.(url|pushurl)$/);
141
+ if (!match) continue;
142
+ const [, name, kind] = match;
143
+ const entry = byName.get(name) ?? { fetch: [], push: [] };
144
+ if (kind === "url") {
145
+ // Preserve every fetch URL in config order — do not collapse to one.
146
+ entry.fetch.push(value);
147
+ } else {
148
+ // Preserve every pushurl in config order — do not collapse to one.
149
+ entry.push.push(value);
150
+ }
151
+ byName.set(name, entry);
152
+ }
153
+
154
+ const remotes: GitRemote[] = [];
155
+ for (const [name, urls] of byName.entries()) {
156
+ // Push-only remotes (issue #109): zero url keys, one or more pushurls.
157
+ // Keep urls as an empty ordered list — never invent a fetch URL.
158
+ if (urls.fetch.length === 0 && urls.push.length === 0) continue;
159
+ remotes.push({
160
+ name,
161
+ urls: [...urls.fetch],
162
+ pushUrls: [...urls.push],
163
+ });
164
+ }
165
+ // Stable order by remote name so tests and sync are deterministic.
166
+ remotes.sort((a, b) => a.name.localeCompare(b.name));
167
+ return remotes;
168
+ }
169
+
170
+ /**
171
+ * Make `targetDir`'s remotes match `sourceDir`'s remote contract: names, every
172
+ * fetch URL (possibly zero for push-only remotes), and every configured push
173
+ * URL. Removes stale remotes that exist only on the target (typical after a
174
+ * path-style clone).
175
+ *
176
+ * Reads and validates the source remote set **before** mutating the target.
177
+ * If source remote config cannot be read, throws and leaves the target unchanged.
178
+ */
179
+ export function syncGitRemotes(sourceDir: string, targetDir: string): void {
180
+ // Read source first. Any throw here (missing/unreadable/not-a-repo) aborts
181
+ // before target remotes are inspected or mutated (#109 non-mutation).
182
+ const sourceRemotes = readGitRemotes(sourceDir);
183
+ const targetRemotes = readGitRemotes(targetDir);
184
+ const sourceNames = new Set(sourceRemotes.map((remote) => remote.name));
185
+
186
+ // Drop remotes the source does not have (typical case: clone-from-path set
187
+ // origin to the parent working tree, or leftover scratch remotes).
188
+ for (const remote of targetRemotes) {
189
+ if (!sourceNames.has(remote.name)) {
190
+ runGit(targetDir, ["remote", "remove", remote.name]);
191
+ }
192
+ }
193
+
194
+ for (const remote of sourceRemotes) {
195
+ const existing = targetRemotes.find((entry) => entry.name === remote.name);
196
+ if (!existing) {
197
+ ensureRemoteExists(targetDir, remote);
198
+ }
199
+ applyFetchUrls(
200
+ targetDir,
201
+ remote.name,
202
+ remote.urls,
203
+ existing?.urls ?? (remote.urls.length > 0 ? [remote.urls[0]] : []),
204
+ );
205
+ applyPushUrls(
206
+ targetDir,
207
+ remote.name,
208
+ remote.pushUrls,
209
+ existing?.pushUrls ?? [],
210
+ );
211
+ }
212
+ }
213
+
214
+ /**
215
+ * Ensure a remote name exists on the target so url/pushurl keys can be applied.
216
+ *
217
+ * `git remote add` requires a fetch URL, so push-only remotes are created via
218
+ * `git config` (fetch refspec + pushurls) rather than inventing a fetch URL.
219
+ */
220
+ function ensureRemoteExists(dir: string, remote: GitRemote): void {
221
+ if (remote.urls.length > 0) {
222
+ // Normal path: create with the first fetch URL; applyFetchUrls rebuilds the full set.
223
+ runGit(dir, ["remote", "add", remote.name, remote.urls[0]]);
224
+ return;
225
+ }
226
+ // Push-only: create the remote section without a url key.
227
+ // A fetch refspec is enough for Git to list the remote name.
228
+ try {
229
+ runGit(dir, ["config", "--get", `remote.${remote.name}.fetch`]);
230
+ } catch {
231
+ runGit(dir, [
232
+ "config",
233
+ `remote.${remote.name}.fetch`,
234
+ `+refs/heads/*:refs/remotes/${remote.name}/*`,
235
+ ]);
236
+ }
237
+ }
238
+
239
+ /**
240
+ * Replace the target remote's fetch URL set with `desired`.
241
+ *
242
+ * Same multi-value constraint as pushurls: `git remote set-url <name> <url>`
243
+ * fatals when the key already has multiple values. Always clear the complete
244
+ * existing url set first (`git config --unset-all`), then rebuild with
245
+ * set-url + --add when desired is non-empty. When desired is empty (push-only),
246
+ * leave the remote with no `remote.<name>.url` keys.
247
+ *
248
+ * Never use regex `--delete` of URL text.
249
+ */
250
+ function applyFetchUrls(
251
+ dir: string,
252
+ name: string,
253
+ desired: string[],
254
+ existing: string[],
255
+ ): void {
256
+ // Fast path: already identical in order — nothing to do (including both empty).
257
+ if (
258
+ existing.length === desired.length &&
259
+ existing.every((url, i) => url === desired[i])
260
+ ) {
261
+ return;
262
+ }
263
+
264
+ // Always clear the complete existing url set first. A bare `set-url`
265
+ // cannot replace a multi-valued set (Git fatals). For push-only desired
266
+ // sets this is the whole operation (no rebuild).
267
+ clearAllFetchUrls(dir, name);
268
+
269
+ if (desired.length === 0) {
270
+ // Push-only: remote remains with fetch refspec + pushurls only.
271
+ return;
272
+ }
273
+
274
+ // Rebuild desired ordered set: first set-url, then --add.
275
+ // If the remote has no url key (e.g. was push-only), set-url still works
276
+ // when the remote section exists; otherwise ensureRemoteExists already added it.
277
+ runGit(dir, ["remote", "set-url", name, desired[0]]);
278
+ for (let i = 1; i < desired.length; i++) {
279
+ runGit(dir, ["remote", "set-url", "--add", name, desired[i]]);
280
+ }
281
+ }
282
+
283
+ /**
284
+ * Replace the target remote's push URL set with `desired`.
285
+ *
286
+ * Git has no "set all pushurls" primitive. Critically, `git remote set-url
287
+ * --push <name> <url>` is rejected when the target already has multiple
288
+ * pushurl values (`warning: remote.<name>.pushurl has multiple values;
289
+ * fatal: could not set ...`). Always clear the complete existing pushurl
290
+ * set first, then rebuild the desired ordered set with set-url + --add.
291
+ * When the desired set is empty, every existing pushurl is deleted so push
292
+ * falls back to every configured fetch URL.
293
+ *
294
+ * For push-only remotes (no fetch URL), rebuild via `git config --add
295
+ * remote.<name>.pushurl` because `git remote set-url --push` may require an
296
+ * existing remote url section depending on Git version; config keys are the
297
+ * durable representation either way.
298
+ */
299
+ function applyPushUrls(
300
+ dir: string,
301
+ name: string,
302
+ desired: string[],
303
+ existing: string[],
304
+ ): void {
305
+ // Fast path: already identical in order — nothing to do (desired empty or not).
306
+ if (
307
+ existing.length === desired.length &&
308
+ existing.every((url, i) => url === desired[i])
309
+ ) {
310
+ return;
311
+ }
312
+
313
+ // Always clear the complete existing pushurl set first. A bare
314
+ // `set-url --push` cannot replace a multi-valued set (Git fatals).
315
+ clearAllPushUrls(dir, name);
316
+
317
+ if (desired.length === 0) {
318
+ // Desired is empty: after unset-all, Git's default (push → all fetch
319
+ // URLs) holds when fetch URLs exist. `get-url --push --all` reports
320
+ // fetch URL(s) when no pushurl key exists, so treat remaining entries
321
+ // equal to fetch set as already-default.
322
+ const remaining = safePushUrlsAll(dir, name);
323
+ const fetchUrls = safeFetchUrlsAll(dir, name);
324
+ const isDefaultOnly =
325
+ remaining.length === 0 ||
326
+ (remaining.length === fetchUrls.length &&
327
+ remaining.every((url, i) => url === fetchUrls[i]));
328
+ if (isDefaultOnly) return;
329
+
330
+ // Retry once if values somehow remain (should not happen after unset-all).
331
+ clearAllPushUrls(dir, name);
332
+ const still = safePushUrlsAll(dir, name);
333
+ const stillDefaultOnly =
334
+ still.length === 0 ||
335
+ (still.length === fetchUrls.length &&
336
+ still.every((url, i) => url === fetchUrls[i]));
337
+ if (!stillDefaultOnly) {
338
+ throw new Error(
339
+ `unable to clear multi-valued remote.${name}.pushurl in ${dir}`,
340
+ );
341
+ }
342
+ return;
343
+ }
344
+
345
+ // Prefer git-config direct writes so push-only remotes (no url key) work
346
+ // the same as normal remotes. set-url --push can behave oddly when there
347
+ // is no fetch URL (it may treat the remote name as a URL).
348
+ for (const url of desired) {
349
+ runGit(dir, ["config", "--add", `remote.${name}.pushurl`, url]);
350
+ }
351
+ }
352
+
353
+ /**
354
+ * Delete every configured fetch URL for `name` as a complete set.
355
+ *
356
+ * Must NOT use `git remote set-url --delete <name> <url>`: that treats the
357
+ * final argument as a regex, so legal local paths containing unmatched
358
+ * metacharacters (e.g. `[`) fail to match and leave stale destinations intact.
359
+ * `git config --unset-all remote.<name>.url` drops the multi-valued key
360
+ * wholesale without interpreting URL text as a pattern.
361
+ *
362
+ * Note: after unset-all the remote still exists (fetch refspec remains) but
363
+ * has no url until rebuild. Callers must rebuild immediately when desired
364
+ * is non-empty; empty desired is the push-only topology.
365
+ */
366
+ function clearAllFetchUrls(dir: string, name: string): void {
367
+ try {
368
+ runGit(dir, ["config", "--unset-all", `remote.${name}.url`]);
369
+ } catch {
370
+ // Key already absent (git exits non-zero) — nothing to clear.
371
+ }
372
+ }
373
+
374
+ /**
375
+ * Delete every configured pushurl for `name` as a complete set.
376
+ *
377
+ * Must NOT use `git remote set-url --push --delete <name> <url>`: that treats
378
+ * the final argument as a regex, so legal local paths containing unmatched
379
+ * metacharacters (e.g. `[`) fail to match and leave stale destinations intact.
380
+ * `git config --unset-all remote.<name>.pushurl` drops the multi-valued key
381
+ * wholesale without interpreting URL text as a pattern.
382
+ */
383
+ function clearAllPushUrls(dir: string, name: string): void {
384
+ try {
385
+ runGit(dir, ["config", "--unset-all", `remote.${name}.pushurl`]);
386
+ } catch {
387
+ // Key already absent (git exits non-zero) — nothing to clear.
388
+ }
389
+ }
390
+
391
+ function safePushUrlsAll(dir: string, name: string): string[] {
392
+ try {
393
+ const out = runGit(dir, ["remote", "get-url", "--push", "--all", name]);
394
+ return out
395
+ .split("\n")
396
+ .map((line) => line.trim())
397
+ .filter(Boolean);
398
+ } catch {
399
+ return [];
400
+ }
401
+ }
402
+
403
+ function safeFetchUrlsAll(dir: string, name: string): string[] {
404
+ try {
405
+ const out = runGit(dir, ["remote", "get-url", "--all", name]);
406
+ return out
407
+ .split("\n")
408
+ .map((line) => line.trim())
409
+ .filter(Boolean);
410
+ } catch {
411
+ return [];
412
+ }
413
+ }