@zhuxixi/pi-agent-board 0.8.0 → 0.9.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.
- package/CHANGELOG.md +20 -0
- package/README.md +12 -11
- package/docs/RELEASE.md +148 -0
- package/docs/superpowers/plans/2026-09-23-a5-mid-stream-assertion.md +25 -0
- package/docs/superpowers/plans/2026-09-23-cwd-browse-tab-drill.md +393 -0
- package/docs/superpowers/plans/2026-09-23-terminal-query-forwarding.md +51 -0
- package/docs/superpowers/plans/2026-09-23-unconditional-detach-key.md +58 -0
- package/docs/superpowers/plans/2026-09-24-control-reconcile-flake.md +698 -0
- package/docs/superpowers/plans/2026-09-24-probe-attached-clients.md +605 -0
- package/docs/superpowers/specs/2026-09-23-a5-mid-stream-assertion-design.md +43 -0
- package/docs/superpowers/specs/2026-09-23-cwd-browse-tab-drill-design.md +95 -0
- package/docs/superpowers/specs/2026-09-23-terminal-query-forwarding-design.md +63 -0
- package/docs/superpowers/specs/2026-09-23-unconditional-detach-key-design.md +57 -0
- package/docs/superpowers/specs/2026-09-24-control-reconcile-flake-design.md +98 -0
- package/docs/superpowers/specs/2026-09-24-probe-attached-clients-design.md +190 -0
- package/package.json +1 -1
- package/runner/pty-runner.mjs +34 -21
- package/scripts/release.mjs +446 -0
- package/src/core/host-protocol.mjs +43 -5
- package/src/core/launch-options.mjs +37 -0
- package/src/core/terminal-attach-client.mjs +11 -5
- package/src/core/terminal-query-sequences.mjs +132 -0
- package/src/ui/dashboard.ts +20 -8
- package/src/ui/pty-attach.ts +84 -2
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Release orchestrator — the single entry point for a release, from a clean
|
|
4
|
+
* `main` to a published npm version.
|
|
5
|
+
*
|
|
6
|
+
* Flow (prose version: docs/RELEASE.md):
|
|
7
|
+
*
|
|
8
|
+
* precheck → prepare <bump> → open-pr <version>
|
|
9
|
+
* → merge the PR with a MERGE COMMIT
|
|
10
|
+
* → finish <version> (creates the GitHub Release)
|
|
11
|
+
* → .github/workflows/publish.yml publishes to npm with provenance
|
|
12
|
+
*
|
|
13
|
+
* Two ordering rules are baked in, because breaking either one ships a version
|
|
14
|
+
* that `main` does not contain, or skips a version's notes:
|
|
15
|
+
*
|
|
16
|
+
* 1. The CHANGELOG section is generated BEFORE `npm version`, because
|
|
17
|
+
* `npm version` commits and tags the bump and would empty the commit range
|
|
18
|
+
* the section is rendered from.
|
|
19
|
+
* 2. The GitHub Release is created AFTER the release PR is merged, and only
|
|
20
|
+
* once the tag is an ancestor of `origin/main`. A squash-merged release PR
|
|
21
|
+
* leaves the tag orphaned; `finish` refuses to continue in that case
|
|
22
|
+
* instead of publishing a version whose bump commit never reached main.
|
|
23
|
+
*
|
|
24
|
+
* Modes:
|
|
25
|
+
*
|
|
26
|
+
* node scripts/release.mjs precheck
|
|
27
|
+
* Read-only gate: on main, clean tree, in sync with origin, no open
|
|
28
|
+
* release/* PR, last tag reachable from origin/main, and at least one
|
|
29
|
+
* functional commit to release.
|
|
30
|
+
* node scripts/release.mjs prepare <patch|minor|major|X.Y.Z> [--dry-run]
|
|
31
|
+
* Runs the gate, renders the CHANGELOG section from conventional commits,
|
|
32
|
+
* commits it, runs `npm version` (bump commit + tag), and switches to
|
|
33
|
+
* `release/vX.Y.Z`. Nothing leaves the machine.
|
|
34
|
+
* node scripts/release.mjs open-pr <version> [--dry-run]
|
|
35
|
+
* Pushes the release branch and its tag, then opens the release PR.
|
|
36
|
+
* node scripts/release.mjs finish <version> [--dry-run] [--sync-main]
|
|
37
|
+
* After the PR is merged: checks tag ancestry and CHANGELOG sync, then
|
|
38
|
+
* creates the GitHub Release — the CI publish trigger. `--sync-main`
|
|
39
|
+
* additionally resets a clean local main to origin/main.
|
|
40
|
+
* node scripts/release.mjs notes <version>
|
|
41
|
+
* Prints the CHANGELOG section for a version (the GitHub Release notes).
|
|
42
|
+
*
|
|
43
|
+
* Every mode is fail-closed: a failed check stops the run with exit code 1 and
|
|
44
|
+
* a JSON body naming the failure. All modes print JSON except `notes`, which
|
|
45
|
+
* prints the section itself so it can be redirected to a file.
|
|
46
|
+
*
|
|
47
|
+
* Changelog rendering is delegated to the pure, unit-tested functions in
|
|
48
|
+
* release_helper.mjs; the git/npm/gh side effects live here and nowhere else.
|
|
49
|
+
*/
|
|
50
|
+
import { execFileSync } from "node:child_process";
|
|
51
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
52
|
+
import { tmpdir } from "node:os";
|
|
53
|
+
import { dirname, join } from "node:path";
|
|
54
|
+
import { fileURLToPath } from "node:url";
|
|
55
|
+
import {
|
|
56
|
+
functionalLines,
|
|
57
|
+
generateChangelog,
|
|
58
|
+
hasSection,
|
|
59
|
+
insertSection,
|
|
60
|
+
nextVersion,
|
|
61
|
+
parseCommitLines,
|
|
62
|
+
verifyFrom,
|
|
63
|
+
} from "./release_helper.mjs";
|
|
64
|
+
|
|
65
|
+
const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
66
|
+
const CHANGELOG_MD = join(REPO_ROOT, "CHANGELOG.md");
|
|
67
|
+
const PACKAGE_JSON = join(REPO_ROOT, "package.json");
|
|
68
|
+
const MAIN_BRANCH = "main";
|
|
69
|
+
const PACKAGE_NAME = "@zhuxixi/pi-agent-board";
|
|
70
|
+
const COMMANDS = new Set(["precheck", "prepare", "open-pr", "finish", "notes"]);
|
|
71
|
+
const FLAGS = new Set(["--dry-run", "--sync-main"]);
|
|
72
|
+
|
|
73
|
+
/* ------------------------------------------------------------------ *
|
|
74
|
+
* Pure helpers (unit-tested in test/release-flow.test.mjs)
|
|
75
|
+
* ------------------------------------------------------------------ */
|
|
76
|
+
|
|
77
|
+
/** Branch a version is prepared on: `release/v0.9.0`. */
|
|
78
|
+
export function releaseBranchName(version) {
|
|
79
|
+
return `release/v${version}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The `## [version]` section of a CHANGELOG, without the trailing link
|
|
84
|
+
* reference definition — i.e. the body used as GitHub Release notes.
|
|
85
|
+
* @param {string} changelogText
|
|
86
|
+
* @param {string} version
|
|
87
|
+
* @returns {string|null} null when the section is absent
|
|
88
|
+
*/
|
|
89
|
+
export function extractReleaseSection(changelogText, version) {
|
|
90
|
+
const text = String(changelogText ?? "");
|
|
91
|
+
const escaped = String(version).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
92
|
+
const heading = new RegExp(`^##\\s*\\[${escaped}]`, "m");
|
|
93
|
+
const match = heading.exec(text);
|
|
94
|
+
if (!match) return null;
|
|
95
|
+
const rest = text.slice(match.index);
|
|
96
|
+
// The next `## [` heading ends the section; +1 because the search runs on a
|
|
97
|
+
// sliced string, so its index is relative to rest[1].
|
|
98
|
+
const nextHeading = rest.slice(1).search(/^##\s*\[/m);
|
|
99
|
+
const section = nextHeading === -1 ? rest : rest.slice(0, nextHeading + 1);
|
|
100
|
+
return section
|
|
101
|
+
.replace(/^\[[^\]]+]:\s+\S+\s*$/gm, "")
|
|
102
|
+
.replace(/\s+$/, "")
|
|
103
|
+
.replace(/^\n+/, "");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Decide whether a release may start, from a collected snapshot of repo state.
|
|
108
|
+
* Pure so the rules can be tested without a repository.
|
|
109
|
+
* @param {{branch: string, clean: boolean, ahead: number, behind: number, openReleasePrs: number|null, lastTag: string|null, lastTagReachable: boolean, functionalCommits: number}} state
|
|
110
|
+
* @returns {{ok: boolean, failures: string[]}}
|
|
111
|
+
*/
|
|
112
|
+
export function evaluatePreconditions(state) {
|
|
113
|
+
const failures = [];
|
|
114
|
+
if (state.branch !== MAIN_BRANCH) {
|
|
115
|
+
failures.push(`must run on ${MAIN_BRANCH} (currently on "${state.branch || "detached HEAD"}")`);
|
|
116
|
+
}
|
|
117
|
+
if (!state.clean) failures.push("working tree is dirty — commit or stash first");
|
|
118
|
+
if (state.behind > 0) failures.push(`local ${MAIN_BRANCH} is behind origin/${MAIN_BRANCH} by ${state.behind} commit(s) — pull first`);
|
|
119
|
+
if (state.ahead > 0) failures.push(`local ${MAIN_BRANCH} is ahead of origin/${MAIN_BRANCH} by ${state.ahead} commit(s)`);
|
|
120
|
+
if ((state.openReleasePrs ?? 0) > 0) failures.push(`${state.openReleasePrs} open release/* PR(s) — finish or close them first`);
|
|
121
|
+
if (state.lastTag && !state.lastTagReachable) {
|
|
122
|
+
failures.push(`tag ${state.lastTag} is not an ancestor of origin/${MAIN_BRANCH} (orphan tag) — re-point it before releasing`);
|
|
123
|
+
}
|
|
124
|
+
if (state.functionalCommits === 0) failures.push("no functional commits since the last tag — nothing to release");
|
|
125
|
+
return { ok: failures.length === 0, failures };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Parse the CLI argv into a command, its positional argument, and flags.
|
|
130
|
+
* @param {string[]} argv
|
|
131
|
+
* @returns {{command: string, arg: string|undefined, dryRun: boolean, syncMain: boolean}}
|
|
132
|
+
*/
|
|
133
|
+
export function parseArgs(argv) {
|
|
134
|
+
const flags = [];
|
|
135
|
+
const positional = [];
|
|
136
|
+
for (const raw of argv) {
|
|
137
|
+
if (raw.startsWith("--")) flags.push(raw);
|
|
138
|
+
else positional.push(raw);
|
|
139
|
+
}
|
|
140
|
+
const [command, arg] = positional;
|
|
141
|
+
if (!COMMANDS.has(command ?? "")) {
|
|
142
|
+
throw new Error(`unknown command "${command ?? ""}" — expected one of ${[...COMMANDS].join(", ")}`);
|
|
143
|
+
}
|
|
144
|
+
for (const flag of flags) {
|
|
145
|
+
if (!FLAGS.has(flag)) throw new Error(`unknown flag "${flag}" — expected ${[...FLAGS].join(", ")}`);
|
|
146
|
+
}
|
|
147
|
+
return { command, arg, dryRun: flags.includes("--dry-run"), syncMain: flags.includes("--sync-main") };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/* ------------------------------------------------------------------ *
|
|
151
|
+
* Repo plumbing
|
|
152
|
+
* ------------------------------------------------------------------ */
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Run a command, capturing both streams. stderr is folded into the thrown error
|
|
156
|
+
* so a failure names the command and what it said, while a successful run stays
|
|
157
|
+
* quiet (git's "Switched to a new branch" chatter included).
|
|
158
|
+
* @param {string} cmd
|
|
159
|
+
* @param {string[]} args
|
|
160
|
+
* @returns {string} stdout
|
|
161
|
+
*/
|
|
162
|
+
function run(cmd, args) {
|
|
163
|
+
try {
|
|
164
|
+
return execFileSync(cmd, args, { cwd: REPO_ROOT, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
|
|
165
|
+
} catch (err) {
|
|
166
|
+
const stderr = String(err?.stderr ?? "").trim();
|
|
167
|
+
const detail = stderr ? `: ${stderr}` : "";
|
|
168
|
+
throw new Error(`${cmd} ${args.join(" ")} failed${detail}`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** @param {string[]} args @returns {string} git stdout */
|
|
173
|
+
function git(args) {
|
|
174
|
+
return run("git", args);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** @param {string[]} args @returns {boolean} true when the command succeeded */
|
|
178
|
+
function gitOk(args) {
|
|
179
|
+
try {
|
|
180
|
+
git(args);
|
|
181
|
+
return true;
|
|
182
|
+
} catch {
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** @param {string[]} args @returns {string} npm stdout */
|
|
188
|
+
function npm(args) {
|
|
189
|
+
return run("npm", args);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** @param {string[]} args @returns {string} gh stdout */
|
|
193
|
+
function gh(args) {
|
|
194
|
+
return run("gh", args);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** @returns {string} version from package.json */
|
|
198
|
+
function packageVersion() {
|
|
199
|
+
const version = JSON.parse(readFileSync(PACKAGE_JSON, "utf8")).version;
|
|
200
|
+
if (!/^\d+\.\d+\.\d+$/.test(version ?? "")) throw new Error(`package.json has no semver version: ${version}`);
|
|
201
|
+
return version;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** @returns {string|null} newest reachable `v*` tag */
|
|
205
|
+
function latestTag() {
|
|
206
|
+
try {
|
|
207
|
+
const tag = git(["describe", "--tags", "--abbrev=0", "--match", "v*"]).trim();
|
|
208
|
+
return tag || null;
|
|
209
|
+
} catch {
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** @param {string} range @returns {string[]} commit subjects in the range */
|
|
215
|
+
function subjects(range) {
|
|
216
|
+
return git(["log", range, "--format=%s"])
|
|
217
|
+
.split("\n")
|
|
218
|
+
.map((s) => s.trim())
|
|
219
|
+
.filter(Boolean);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** @returns {boolean} whether the tag exists locally */
|
|
223
|
+
function tagExists(tag) {
|
|
224
|
+
return gitOk(["rev-parse", "--verify", "--quiet", `refs/tags/${tag}`]);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** @param {string} ref @returns {boolean} whether ref exists locally */
|
|
228
|
+
function refExists(ref) {
|
|
229
|
+
return gitOk(["rev-parse", "--verify", "--quiet", ref]);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** @param {string} tag @param {string} ref @returns {boolean} */
|
|
233
|
+
function isAncestor(tag, ref) {
|
|
234
|
+
return gitOk(["merge-base", "--is-ancestor", tag, ref]);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Count open `release/*` PRs. null means the check could not run (no `gh`, no
|
|
239
|
+
* auth, or not a GitHub remote) — reported but never a hard failure, so a
|
|
240
|
+
* missing `gh` cannot block a release that does not need it.
|
|
241
|
+
* @returns {number|null}
|
|
242
|
+
*/
|
|
243
|
+
function openReleasePrCount() {
|
|
244
|
+
try {
|
|
245
|
+
const json = gh(["pr", "list", "--state", "open", "--limit", "50", "--json", "headRefName"]);
|
|
246
|
+
return JSON.parse(json).filter((pr) => String(pr.headRefName ?? "").startsWith("release/")).length;
|
|
247
|
+
} catch {
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** @returns {object} the state consumed by evaluatePreconditions */
|
|
253
|
+
function collectState() {
|
|
254
|
+
git(["fetch", "origin", "--quiet"]);
|
|
255
|
+
const [behind, ahead] = git(["rev-list", "--left-right", "--count", `origin/${MAIN_BRANCH}...HEAD`])
|
|
256
|
+
.trim()
|
|
257
|
+
.split(/\s+/)
|
|
258
|
+
.map(Number);
|
|
259
|
+
const lastTag = latestTag();
|
|
260
|
+
const functionalCommits = functionalLines(subjects(lastTag ? `${lastTag}..HEAD` : "HEAD")).length;
|
|
261
|
+
return {
|
|
262
|
+
branch: git(["branch", "--show-current"]).trim(),
|
|
263
|
+
clean: git(["status", "--porcelain"]).trim() === "",
|
|
264
|
+
ahead,
|
|
265
|
+
behind,
|
|
266
|
+
openReleasePrs: openReleasePrCount(),
|
|
267
|
+
lastTag,
|
|
268
|
+
lastTagReachable: lastTag ? isAncestor(lastTag, `origin/${MAIN_BRANCH}`) : true,
|
|
269
|
+
functionalCommits,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Throw unless the release may start; returns the collected state. */
|
|
274
|
+
function assertPreconditions() {
|
|
275
|
+
const state = collectState();
|
|
276
|
+
const { ok, failures } = evaluatePreconditions(state);
|
|
277
|
+
if (!ok) throw new Error(`precheck failed:\n- ${failures.join("\n- ")}`);
|
|
278
|
+
return state;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/* ------------------------------------------------------------------ *
|
|
282
|
+
* Commands
|
|
283
|
+
* ------------------------------------------------------------------ */
|
|
284
|
+
|
|
285
|
+
/** @returns {object} precheck report (never throws on a failed gate) */
|
|
286
|
+
function cmdPrecheck() {
|
|
287
|
+
const state = collectState();
|
|
288
|
+
const { ok, failures } = evaluatePreconditions(state);
|
|
289
|
+
return { ok, failures, ...state, prCheck: state.openReleasePrs === null ? "unavailable (gh not authenticated or not a GitHub remote)" : "ok" };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* @param {string|undefined} bump patch|minor|major|X.Y.Z
|
|
294
|
+
* @param {boolean} dryRun
|
|
295
|
+
* @returns {object}
|
|
296
|
+
*/
|
|
297
|
+
function cmdPrepare(bump, dryRun) {
|
|
298
|
+
const state = assertPreconditions();
|
|
299
|
+
const version = nextVersion(bump ?? "patch", packageVersion());
|
|
300
|
+
const branch = releaseBranchName(version);
|
|
301
|
+
const existing = readFileSync(CHANGELOG_MD, "utf8");
|
|
302
|
+
if (hasSection(existing, version)) {
|
|
303
|
+
throw new Error(`CHANGELOG.md already has a [${version}] section — remove the stale section before re-applying`);
|
|
304
|
+
}
|
|
305
|
+
const range = state.lastTag ? `${state.lastTag}..HEAD` : "HEAD";
|
|
306
|
+
const entries = parseCommitLines(subjects(range));
|
|
307
|
+
const section = generateChangelog({
|
|
308
|
+
version,
|
|
309
|
+
date: new Date().toISOString().slice(0, 10),
|
|
310
|
+
entries,
|
|
311
|
+
prevTag: state.lastTag,
|
|
312
|
+
});
|
|
313
|
+
const plan = [
|
|
314
|
+
`git add CHANGELOG.md && git commit -m "docs(changelog): add ${version} section"`,
|
|
315
|
+
`npm version ${bump ?? "patch"}`,
|
|
316
|
+
`git switch -c ${branch}`,
|
|
317
|
+
];
|
|
318
|
+
const next = `node scripts/release.mjs open-pr ${version}`;
|
|
319
|
+
if (dryRun) {
|
|
320
|
+
return { ok: true, dry_run: true, current_version: packageVersion(), new_version: version, last_tag: state.lastTag, changelog_preview: section, plan, next };
|
|
321
|
+
}
|
|
322
|
+
writeFileSync(CHANGELOG_MD, insertSection(existing, section));
|
|
323
|
+
git(["add", "CHANGELOG.md"]);
|
|
324
|
+
git(["commit", "-m", `docs(changelog): add ${version} section`]);
|
|
325
|
+
npm(["version", bump ?? "patch"]);
|
|
326
|
+
git(["switch", "-c", branch]);
|
|
327
|
+
return { ok: true, version, branch, tag: `v${version}`, next };
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* @param {string|undefined} version
|
|
332
|
+
* @param {boolean} dryRun
|
|
333
|
+
* @returns {object}
|
|
334
|
+
*/
|
|
335
|
+
function cmdOpenPr(version, dryRun) {
|
|
336
|
+
if (!version) throw new Error("open-pr requires a version (node scripts/release.mjs open-pr 0.9.0)");
|
|
337
|
+
const branch = releaseBranchName(version);
|
|
338
|
+
const tag = `v${version}`;
|
|
339
|
+
const current = git(["branch", "--show-current"]).trim();
|
|
340
|
+
if (packageVersion() !== version) throw new Error(`package.json is at ${packageVersion()}, expected ${version}`);
|
|
341
|
+
if (current !== branch) throw new Error(`expected to be on ${branch} (currently on "${current || "detached HEAD"}")`);
|
|
342
|
+
if (!tagExists(tag)) throw new Error(`tag ${tag} does not exist — run prepare first`);
|
|
343
|
+
const notes = extractReleaseSection(readFileSync(CHANGELOG_MD, "utf8"), version);
|
|
344
|
+
if (!notes) throw new Error(`CHANGELOG.md has no [${version}] section`);
|
|
345
|
+
const body = `${notes}\n\n---\n\nMerge with a **merge commit** — squashing would orphan the \`${tag}\` tag and break the changelog range for the next release.`;
|
|
346
|
+
const commands = [
|
|
347
|
+
`git push -u origin ${branch}`,
|
|
348
|
+
`git push origin ${tag}`,
|
|
349
|
+
`gh pr create --base ${MAIN_BRANCH} --head ${branch} --title "chore: release ${version}"`,
|
|
350
|
+
];
|
|
351
|
+
if (dryRun) return { ok: true, dry_run: true, version, branch, tag, commands };
|
|
352
|
+
git(["push", "-u", "origin", branch]);
|
|
353
|
+
git(["push", "origin", tag]);
|
|
354
|
+
const url = gh(["pr", "create", "--base", MAIN_BRANCH, "--head", branch, "--title", `chore: release ${version}`, "--body", body]).trim();
|
|
355
|
+
return { ok: true, version, branch, tag, pr: url, next: `merge with a merge commit, then: node scripts/release.mjs finish ${version} --sync-main` };
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* @param {string|undefined} version
|
|
360
|
+
* @param {{dryRun: boolean, syncMain: boolean}} opts
|
|
361
|
+
* @returns {object}
|
|
362
|
+
*/
|
|
363
|
+
function cmdFinish(version, { dryRun, syncMain }) {
|
|
364
|
+
if (!version) throw new Error("finish requires a version (node scripts/release.mjs finish 0.9.0)");
|
|
365
|
+
const tag = `v${version}`;
|
|
366
|
+
if (packageVersion() !== version) throw new Error(`package.json is at ${packageVersion()}, expected ${version}`);
|
|
367
|
+
git(["fetch", "origin", "--quiet"]);
|
|
368
|
+
if (!tagExists(tag)) throw new Error(`tag ${tag} is missing — push it with open-pr first`);
|
|
369
|
+
if (!isAncestor(tag, `origin/${MAIN_BRANCH}`)) {
|
|
370
|
+
throw new Error(
|
|
371
|
+
`tag ${tag} is not an ancestor of origin/${MAIN_BRANCH} — the release PR was probably squash-merged. ` +
|
|
372
|
+
`Point the tag at the content-equivalent commit on main (git tag -f ${tag} <sha> && git push origin ${tag} --force) and retry`,
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
const changelogText = readFileSync(CHANGELOG_MD, "utf8");
|
|
376
|
+
const drift = verifyFrom({ lines: subjects(`${tag}..origin/${MAIN_BRANCH}`), changelogText });
|
|
377
|
+
if (!drift.ok) {
|
|
378
|
+
throw new Error(
|
|
379
|
+
`CHANGELOG is missing functional PR(s) ${drift.missing.join(", ")} merged after the bump — ` +
|
|
380
|
+
`add them to the ${version} section in a docs(changelog) PR, then retry`,
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
const notes = extractReleaseSection(changelogText, version);
|
|
384
|
+
if (!notes) throw new Error(`CHANGELOG.md has no [${version}] section`);
|
|
385
|
+
if (dryRun) {
|
|
386
|
+
return { ok: true, dry_run: true, tag, notes, commands: [`gh release create ${tag} --title ${tag} --notes-file <notes>`], syncMain };
|
|
387
|
+
}
|
|
388
|
+
const dir = mkdtempSync(join(tmpdir(), "release-notes-"));
|
|
389
|
+
const notesFile = join(dir, `${tag}.md`);
|
|
390
|
+
try {
|
|
391
|
+
writeFileSync(notesFile, `${notes}\n`);
|
|
392
|
+
gh(["release", "create", tag, "--title", tag, "--notes-file", notesFile]);
|
|
393
|
+
} finally {
|
|
394
|
+
rmSync(dir, { recursive: true, force: true });
|
|
395
|
+
}
|
|
396
|
+
let syncedMain = false;
|
|
397
|
+
if (syncMain && git(["status", "--porcelain"]).trim() === "") {
|
|
398
|
+
git(["switch", MAIN_BRANCH]);
|
|
399
|
+
git(["reset", "--hard", `origin/${MAIN_BRANCH}`]);
|
|
400
|
+
syncedMain = true;
|
|
401
|
+
}
|
|
402
|
+
return {
|
|
403
|
+
ok: true,
|
|
404
|
+
version,
|
|
405
|
+
tag,
|
|
406
|
+
release: `https://github.com/zhuxixi/pi-agent-board/releases/tag/${tag}`,
|
|
407
|
+
package: `${PACKAGE_NAME}@${version}`,
|
|
408
|
+
synced_main: syncedMain,
|
|
409
|
+
publish: "CI publishes on release — watch it with: gh run list --workflow=publish.yml --limit 1",
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* @param {string|undefined} version
|
|
415
|
+
* @returns {string} the release notes text
|
|
416
|
+
*/
|
|
417
|
+
function cmdNotes(version) {
|
|
418
|
+
if (!version) throw new Error("notes requires a version (node scripts/release.mjs notes 0.9.0)");
|
|
419
|
+
const notes = extractReleaseSection(readFileSync(CHANGELOG_MD, "utf8"), version);
|
|
420
|
+
if (!notes) throw new Error(`CHANGELOG.md has no [${version}] section`);
|
|
421
|
+
return notes;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/* ------------------------------------------------------------------ *
|
|
425
|
+
* CLI
|
|
426
|
+
* ------------------------------------------------------------------ */
|
|
427
|
+
|
|
428
|
+
function main(argv) {
|
|
429
|
+
try {
|
|
430
|
+
const { command, arg, dryRun, syncMain } = parseArgs(argv);
|
|
431
|
+
if (command === "notes") {
|
|
432
|
+
process.stdout.write(`${cmdNotes(arg)}\n`);
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
const result = command === "precheck" ? cmdPrecheck() : command === "prepare" ? cmdPrepare(arg, dryRun) : command === "open-pr" ? cmdOpenPr(arg, dryRun) : cmdFinish(arg, { dryRun, syncMain });
|
|
436
|
+
console.log(JSON.stringify(result, null, 1));
|
|
437
|
+
if (result.ok === false) process.exitCode = 1;
|
|
438
|
+
} catch (err) {
|
|
439
|
+
console.log(JSON.stringify({ ok: false, error: String(err?.message ?? err) }));
|
|
440
|
+
process.exitCode = 1;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
|
445
|
+
main(process.argv.slice(2));
|
|
446
|
+
}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
/** Control-socket client identity helpers (issue #103).
|
|
2
2
|
*
|
|
3
|
-
* Client ids travel in the `hello` handshake.
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* every host against warm-host reclaim (issue #75 / #103 §C
|
|
3
|
+
* Client ids travel in the `hello` handshake. `helloBookkeeping` turns the
|
|
4
|
+
* classification into the explicit socket policy: probes and the resident
|
|
5
|
+
* editor-state reporter stay out of `attachedClients` / `attachedEver`, and
|
|
6
|
+
* probes additionally never trigger a host.json write — counting or writing
|
|
7
|
+
* either would pin every host against warm-host reclaim (issue #75 / #103 §C /
|
|
8
|
+
* #130).
|
|
8
9
|
*/
|
|
9
10
|
|
|
10
11
|
/** Read-only endpoint probe (attach resolver); never counts as attached. */
|
|
@@ -22,3 +23,40 @@ export function classifyClientHello(msg) {
|
|
|
22
23
|
if (clientId === CLIENT_ID_EDITOR_REPORTER) return "editor-reporter";
|
|
23
24
|
return "client";
|
|
24
25
|
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Socket bookkeeping implied by a hello classification (issue #103 §C / #130).
|
|
29
|
+
*
|
|
30
|
+
* `probe` and `editor-reporter` are bookkeeping-only: neither may keep
|
|
31
|
+
* `attachedClients` — whose sole source is the runner's `clients` set —
|
|
32
|
+
* non-zero, or warm-host reclaim never fires and hosts leak (issue #75). Both
|
|
33
|
+
* therefore leave `clients` at hello time, while their socket stays writable so
|
|
34
|
+
* probe replies and editor_state keep flowing.
|
|
35
|
+
*
|
|
36
|
+
* `persist: false` for probes is deliberate: the attach resolver probes every
|
|
37
|
+
* HOST_PROBE_RETRY_MS (see host-probe.mjs), so one host.json write per probe
|
|
38
|
+
* would amplify fenced writes — the exact cost the runner's probe-socket write
|
|
39
|
+
* suppression exists to avoid. Dropping the socket from `clients` already makes
|
|
40
|
+
* the in-memory count right, and the next heartbeat (<=1s) persists it.
|
|
41
|
+
*
|
|
42
|
+
* `suppressCloseWrite: true` is the other half of that suppression: close is the
|
|
43
|
+
* wrong moment to flush a record a probe never changed.
|
|
44
|
+
*
|
|
45
|
+
* An unrecognized kind falls back to the real-client row on purpose — counting
|
|
46
|
+
* an unknown connection is the conservative direction (it can only delay
|
|
47
|
+
* reclaim, never risk killing a host with a live client).
|
|
48
|
+
*
|
|
49
|
+
* @param {"probe" | "editor-reporter" | "client"} kind
|
|
50
|
+
* @returns {{ keepInClients: boolean, registerReporter: boolean,
|
|
51
|
+
* flipAttachedEver: boolean, persist: boolean, suppressCloseWrite: boolean }}
|
|
52
|
+
*/
|
|
53
|
+
export function helloBookkeeping(kind) {
|
|
54
|
+
switch (kind) {
|
|
55
|
+
case "probe":
|
|
56
|
+
return { keepInClients: false, registerReporter: false, flipAttachedEver: false, persist: false, suppressCloseWrite: true };
|
|
57
|
+
case "editor-reporter":
|
|
58
|
+
return { keepInClients: false, registerReporter: true, flipAttachedEver: false, persist: true, suppressCloseWrite: false };
|
|
59
|
+
default:
|
|
60
|
+
return { keepInClients: true, registerReporter: false, flipAttachedEver: true, persist: true, suppressCloseWrite: false };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -361,6 +361,43 @@ export function filterCwdCandidates(candidates, query) {
|
|
|
361
361
|
return candidates.filter((entry) => entry.path.toLowerCase().includes(q));
|
|
362
362
|
}
|
|
363
363
|
|
|
364
|
+
/**
|
|
365
|
+
* Whether two path strings resolve to the same directory. Blank inputs never
|
|
366
|
+
* match (path.resolve("") would silently equal process.cwd()).
|
|
367
|
+
* @param {string} a
|
|
368
|
+
* @param {string} b
|
|
369
|
+
* @returns {boolean}
|
|
370
|
+
*/
|
|
371
|
+
export function sameResolvedDir(a, b) {
|
|
372
|
+
const na = String(a ?? "").trim();
|
|
373
|
+
const nb = String(b ?? "").trim();
|
|
374
|
+
if (!na || !nb) return false;
|
|
375
|
+
return path.resolve(expandHome(na)) === path.resolve(expandHome(nb));
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Browse-mode Tab completion for the cwd picker. Completes to the highlighted
|
|
380
|
+
* suggestion with a trailing separator (so the next recompute lists that
|
|
381
|
+
* directory's children); when the highlight already equals the current query
|
|
382
|
+
* (no progress), advances cyclically so repeated Tab drills downward. A lone
|
|
383
|
+
* self suggestion stays put, so drilling stops naturally at leaf directories.
|
|
384
|
+
* @param {string} query
|
|
385
|
+
* @param {string[]} suggestions
|
|
386
|
+
* @param {number} index
|
|
387
|
+
* @returns {{query: string, completed: string, usedIndex: number} | null}
|
|
388
|
+
*/
|
|
389
|
+
export function browseTabCompletion(query, suggestions, index) {
|
|
390
|
+
if (!suggestions || suggestions.length === 0) return null;
|
|
391
|
+
const len = suggestions.length;
|
|
392
|
+
let usedIndex = ((index % len) + len) % len;
|
|
393
|
+
let completed = suggestions[usedIndex];
|
|
394
|
+
if (len > 1 && sameResolvedDir(completed, query)) {
|
|
395
|
+
usedIndex = (usedIndex + 1) % len;
|
|
396
|
+
completed = suggestions[usedIndex];
|
|
397
|
+
}
|
|
398
|
+
return { query: completed + path.sep, completed, usedIndex };
|
|
399
|
+
}
|
|
400
|
+
|
|
364
401
|
/**
|
|
365
402
|
* Decide cwd picker mode + suggestions for a query: favorites when the query
|
|
366
403
|
* is empty or matches ranked candidates, filesystem browse otherwise.
|
|
@@ -48,8 +48,9 @@
|
|
|
48
48
|
* binds the reconnect order hello → reconcile → snapshot/subscribe. All
|
|
49
49
|
* runner messages in this state are consumed; outputs are ALSO EMITTED —
|
|
50
50
|
* they are legacy-broadcast strays that must reach the UI exactly once
|
|
51
|
-
* (strays cover (cursor, strayHighWater]; the replay starts past
|
|
52
|
-
* high-water;
|
|
51
|
+
* (OBSERVED strays cover (cursor, strayHighWater]; the replay starts past
|
|
52
|
+
* the high-water; in-flight strays are re-sent by the replay and deduped —
|
|
53
|
+
* see the reconciling output branch). Resolution:
|
|
53
54
|
* - `reconcile_result` → epoch rule: generation CHANGED since last seen ⇒
|
|
54
55
|
* discard the cursor (`lastSeq = 0`, `epochReset` event) ⇒ seq-less
|
|
55
56
|
* subscribe (fresh snapshot — ring replay across runner generations is
|
|
@@ -336,7 +337,11 @@ export function createTerminalAttachClient({
|
|
|
336
337
|
|
|
337
338
|
const issueReconnectSubscribe = (sinceSeq) => {
|
|
338
339
|
// The cursor must clear every stray the legacy broadcast already
|
|
339
|
-
//
|
|
340
|
+
// DELIVERED to this socket during the gate. Strays still in flight when
|
|
341
|
+
// the subscribe is written cannot be folded in: the ring replay re-sends
|
|
342
|
+
// them and seq-checked consumption dedups — the guarantee is UI-level
|
|
343
|
+
// exactly-once; a wire-level repeat at the broadcast→replay handoff is
|
|
344
|
+
// expected (#140).
|
|
340
345
|
const effective = Math.max(sinceSeq, strayHighWater);
|
|
341
346
|
strayHighWater = 0;
|
|
342
347
|
const cursor = reconnectCursor;
|
|
@@ -666,8 +671,9 @@ export function createTerminalAttachClient({
|
|
|
666
671
|
// The fresh socket is not subscribed yet: everything here is
|
|
667
672
|
// broadcast stray. Consume so the UI legacy path can never
|
|
668
673
|
// double-feed — and EMIT it: these bytes must reach the UI exactly
|
|
669
|
-
// once (strays cover (cursor, strayHighWater]; the replay
|
|
670
|
-
// past
|
|
674
|
+
// once (OBSERVED strays cover (cursor, strayHighWater]; the replay
|
|
675
|
+
// starts past the high-water; in-flight strays are re-sent by the
|
|
676
|
+
// replay and deduped). Consuming WITHOUT emitting would silently
|
|
671
677
|
// drop them from the rendered output — a data-loss regression the
|
|
672
678
|
// task-5 review caught. lastSeq deliberately does NOT advance here:
|
|
673
679
|
// the subscribe cursor (max(cursor, strayHighWater)) carries the
|