@zhuxixi/pi-agent-board 0.5.2 → 0.6.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 +32 -0
- package/README.md +41 -3
- package/docs/superpowers/plans/2026-09-03-code-refs-pr-backlink-narrow.md +551 -0
- package/docs/superpowers/plans/2026-09-04-evidence-outputpreview.md +209 -0
- package/docs/superpowers/plans/2026-09-04-warm-host-reclaim.md +796 -0
- package/docs/superpowers/plans/2026-09-05-issue-13-drainnextfollowup-pty-probe.md +114 -0
- package/docs/superpowers/plans/2026-09-05-issue-38-windows-wezterm-ime-cursor.md +73 -0
- package/docs/superpowers/plans/2026-09-05-issue-39-truncate-codepoint-boundary.md +143 -0
- package/docs/superpowers/plans/2026-09-05-issue-61-mention-fallback-guards.md +226 -0
- package/docs/superpowers/plans/2026-09-05-issue-63-flaky-manual-completion.md +87 -0
- package/docs/superpowers/plans/2026-09-05-issue-64-changelog-release-helper.md +53 -0
- package/docs/superpowers/plans/2026-09-05-pty-host-stacking-sock-race.md +731 -0
- package/docs/superpowers/specs/2026-08-29-code-refs-badges-design.md +1 -1
- package/docs/superpowers/specs/2026-09-03-code-refs-pr-backlink-narrow-design.md +92 -0
- package/docs/superpowers/specs/2026-09-04-evidence-outputpreview-design.md +50 -0
- package/docs/superpowers/specs/2026-09-04-warm-host-reclaim-design.md +106 -0
- package/docs/superpowers/specs/2026-09-05-issue-13-drainnextfollowup-pty-probe-design.md +64 -0
- package/docs/superpowers/specs/2026-09-05-issue-38-windows-wezterm-ime-design.md +48 -0
- package/docs/superpowers/specs/2026-09-05-issue-39-truncate-codepoint-boundary-design.md +64 -0
- package/docs/superpowers/specs/2026-09-05-issue-61-mention-fallback-design.md +71 -0
- package/docs/superpowers/specs/2026-09-05-issue-63-flaky-manual-completion-design.md +49 -0
- package/docs/superpowers/specs/2026-09-05-issue-64-changelog-helper-design.md +76 -0
- package/docs/superpowers/specs/2026-09-05-pty-host-stacking-sock-race-design.md +510 -0
- package/package.json +83 -81
- package/runner/job-runner.mjs +2 -2
- package/runner/pty-runner.mjs +573 -2
- package/runner/state-runner.mjs +3 -0
- package/runner/title-runner.mjs +1 -1
- package/scripts/release_helper.mjs +277 -0
- package/src/commands/agent-board.ts +38 -35
- package/src/commands/attach-decision.mjs +66 -0
- package/src/commands/attach-flow.ts +45 -39
- package/src/core/code-refs.mjs +85 -33
- package/src/core/evidence.mjs +2 -2
- package/src/core/heuristics.mjs +40 -2
- package/src/core/host-coordination.mjs +159 -0
- package/src/core/host-crash.mjs +43 -3
- package/src/core/host-probe.mjs +196 -0
- package/src/core/launch.mjs +3 -1
- package/src/core/locks.mjs +196 -1
- package/src/core/paths.mjs +24 -0
- package/src/core/store.mjs +164 -5
- package/src/core/types.mjs +17 -1
- package/src/core/warm-host-sweeper.mjs +150 -0
- package/src/index.ts +29 -1
- package/src/runtime/service.mjs +967 -109
- package/src/ui/dashboard-decisions.mjs +55 -0
- package/src/ui/dashboard.ts +26 -12
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Conventional-commit driven CHANGELOG helper (port of the jfox release
|
|
4
|
+
* helper, adapted to this repo's Node stack and `vX.Y.Z` tags).
|
|
5
|
+
*
|
|
6
|
+
* Modes:
|
|
7
|
+
* node scripts/release_helper.mjs [--dry-run] [patch|minor|major|X.Y.Z]
|
|
8
|
+
* Compute the next version (default patch) from package.json, parse
|
|
9
|
+
* conventional commits since the last `v*` tag, and print a JSON result
|
|
10
|
+
* with a `changelog_preview`. Without --dry-run, also insert the section
|
|
11
|
+
* at the top of CHANGELOG.md.
|
|
12
|
+
* node scripts/release_helper.mjs verify
|
|
13
|
+
* Check that every functional commit (feat/fix/refactor/docs/perf) in
|
|
14
|
+
* `last v* tag..HEAD` has its trailing (#N) present in the CHANGELOG top
|
|
15
|
+
* section. Exits 1 with a missing list when the changelog has drifted
|
|
16
|
+
* (e.g. a PR merged after the section was generated).
|
|
17
|
+
*
|
|
18
|
+
* The pure functions below are exported for unit tests; the CLI layer is the
|
|
19
|
+
* only place that touches git or the filesystem.
|
|
20
|
+
*/
|
|
21
|
+
import { execFileSync } from "node:child_process";
|
|
22
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
23
|
+
import { dirname, join } from "node:path";
|
|
24
|
+
import { fileURLToPath } from "node:url";
|
|
25
|
+
|
|
26
|
+
const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
27
|
+
const PACKAGE_JSON = join(REPO_ROOT, "package.json");
|
|
28
|
+
const CHANGELOG_MD = join(REPO_ROOT, "CHANGELOG.md");
|
|
29
|
+
const REPO_URL = "https://github.com/zhuxixi/pi-agent-board";
|
|
30
|
+
const COMPARE_BASE = `${REPO_URL}/compare`;
|
|
31
|
+
|
|
32
|
+
/** Conventional commit subject: type[(scope)][!]: message [(#N)] */
|
|
33
|
+
const CONVENTIONAL_RE =
|
|
34
|
+
/^(feat|fix|perf|refactor|docs|chore|test)(?:\(([^)]*)\))?!?:\s+(.+?)(?:\s+\(#(\d+)\))?$/;
|
|
35
|
+
/** Version-squash subjects produced by `npm version` PRs, e.g. `0.5.2 (#74)`. */
|
|
36
|
+
const VERSION_SQUASH_RE = /^\d+\.\d+\.\d+(?:[-.][\w.]+)?\s*\(#\d+\)$/;
|
|
37
|
+
/** Functional whitelist for verify (CHANGELOG-worthy types). */
|
|
38
|
+
const FUNCTIONAL_TYPES = new Set(["feat", "fix", "refactor", "docs", "perf"]);
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Parse commit subject lines into changelog entries.
|
|
42
|
+
* @param {Iterable<string>} lines
|
|
43
|
+
* @returns {Array<{type: string, scope: string, message: string, pr: number|null}>}
|
|
44
|
+
*/
|
|
45
|
+
export function parseCommitLines(lines) {
|
|
46
|
+
const entries = [];
|
|
47
|
+
const seen = new Set();
|
|
48
|
+
for (const raw of lines) {
|
|
49
|
+
const s = String(raw ?? "").trim();
|
|
50
|
+
if (!s) continue;
|
|
51
|
+
if (VERSION_SQUASH_RE.test(s)) continue;
|
|
52
|
+
if (/bump\s+version/i.test(s)) continue;
|
|
53
|
+
if (s.startsWith("Merge ")) continue;
|
|
54
|
+
const m = s.match(CONVENTIONAL_RE);
|
|
55
|
+
const prTail = s.match(/\(#(\d+)\)\s*$/);
|
|
56
|
+
const entry = m
|
|
57
|
+
? { type: m[1], scope: m[2] || "", message: m[3].trim(), pr: m[4] ? Number(m[4]) : null }
|
|
58
|
+
: { type: "other", scope: "", message: s, pr: prTail ? Number(prTail[1]) : null };
|
|
59
|
+
const key = `${entry.type}|${entry.scope}|${entry.message}`;
|
|
60
|
+
if (seen.has(key)) continue;
|
|
61
|
+
seen.add(key);
|
|
62
|
+
entries.push(entry);
|
|
63
|
+
}
|
|
64
|
+
return entries;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Render one changelog section for a version.
|
|
69
|
+
* @param {{version: string, date: string, entries: Array<{type: string, scope: string, message: string, pr: number|null}>, prevTag?: string|null}} opts
|
|
70
|
+
* @returns {string}
|
|
71
|
+
*/
|
|
72
|
+
export function generateChangelog({ version, date, entries, prevTag = null }) {
|
|
73
|
+
const groups = [
|
|
74
|
+
["Features", (e) => e.type === "feat"],
|
|
75
|
+
["Fixes", (e) => e.type === "fix"],
|
|
76
|
+
["Performance", (e) => e.type === "perf"],
|
|
77
|
+
["Changes", (e) => e.type !== "feat" && e.type !== "fix" && e.type !== "perf"],
|
|
78
|
+
];
|
|
79
|
+
const lines = [`## [${version}] - ${date}`, ""];
|
|
80
|
+
for (const [title, keep] of groups) {
|
|
81
|
+
const group = entries.filter(keep);
|
|
82
|
+
if (group.length === 0) continue;
|
|
83
|
+
lines.push(`### ${title}`, "");
|
|
84
|
+
for (const e of group) {
|
|
85
|
+
const scope = e.scope ? `**${e.scope}**: ` : "";
|
|
86
|
+
const pr = e.pr ? ` (#${e.pr})` : "";
|
|
87
|
+
lines.push(`- ${scope}${e.message}${pr}`);
|
|
88
|
+
}
|
|
89
|
+
lines.push("");
|
|
90
|
+
}
|
|
91
|
+
const prev = prevTag ? prevTag.replace(/^v/, "") : null;
|
|
92
|
+
if (prev) lines.push(`[${version}]: ${COMPARE_BASE}/v${prev}...v${version}`);
|
|
93
|
+
return lines.join("\n");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* PR numbers referenced inside the first `## [...]` section of a CHANGELOG.
|
|
98
|
+
* @param {string} text
|
|
99
|
+
* @returns {Set<number>}
|
|
100
|
+
*/
|
|
101
|
+
export function changelogTopPrs(text) {
|
|
102
|
+
// Split before each `## [` heading and take the first real section — the
|
|
103
|
+
// JS port of jfox's regex uses split because Python's \Z (absolute end)
|
|
104
|
+
// has no JS equivalent and a `$` lookahead matches zero-width.
|
|
105
|
+
const sections = String(text ?? "").split(/(?=^##\s*\[)/m);
|
|
106
|
+
const first = sections.find((s) => /^##\s*\[/.test(s));
|
|
107
|
+
const section = first ?? String(text ?? "");
|
|
108
|
+
return new Set([...section.matchAll(/\(#(\d+)\)/g)].map((x) => Number(x[1])));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Filter subject lines down to functional (CHANGELOG-worthy) commits:
|
|
113
|
+
* conventional whitelist types, skipping merges, version squashes, and
|
|
114
|
+
* `docs(changelog)` maintenance commits (their PRs fix the changelog itself
|
|
115
|
+
* and must not feed back into verify — infinite loop guard, jfox #333).
|
|
116
|
+
* @param {Iterable<string>} lines
|
|
117
|
+
* @returns {string[]}
|
|
118
|
+
*/
|
|
119
|
+
export function functionalLines(lines) {
|
|
120
|
+
const out = [];
|
|
121
|
+
for (const raw of lines) {
|
|
122
|
+
const s = String(raw ?? "").trim();
|
|
123
|
+
if (!s || VERSION_SQUASH_RE.test(s) || /bump\s+version/i.test(s)) continue;
|
|
124
|
+
if (s.startsWith("Merge ")) continue;
|
|
125
|
+
const m = s.match(/^(\w+)(?:\(([^)]*)\))?!?:/);
|
|
126
|
+
if (!m) continue;
|
|
127
|
+
if (!FUNCTIONAL_TYPES.has(m[1].toLowerCase())) continue;
|
|
128
|
+
if (m[1].toLowerCase() === "docs" && (m[2] || "").toLowerCase().includes("changelog")) continue;
|
|
129
|
+
out.push(s);
|
|
130
|
+
}
|
|
131
|
+
return out;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Pure verify: functional commit lines vs CHANGELOG text.
|
|
136
|
+
* @param {{lines: Iterable<string>, changelogText: string}} input
|
|
137
|
+
* @returns {{ok: boolean, missing: number[], extra: number[], functionalCommits: number}}
|
|
138
|
+
*/
|
|
139
|
+
export function verifyFrom({ lines, changelogText }) {
|
|
140
|
+
const funcPrs = new Set();
|
|
141
|
+
for (const s of functionalLines(lines)) {
|
|
142
|
+
const m = s.match(/\(#(\d+)\)\s*$/);
|
|
143
|
+
if (m) funcPrs.add(Number(m[1]));
|
|
144
|
+
}
|
|
145
|
+
const clPrs = changelogTopPrs(changelogText);
|
|
146
|
+
const missing = [...funcPrs].filter((n) => !clPrs.has(n)).sort((a, b) => a - b);
|
|
147
|
+
const extra = [...clPrs].filter((n) => !funcPrs.has(n)).sort((a, b) => a - b);
|
|
148
|
+
return { ok: missing.length === 0, missing, extra, functionalCommits: funcPrs.size };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Whether the CHANGELOG already has a section for this exact version
|
|
153
|
+
* (apply idempotency guard — a re-apply must not duplicate the heading).
|
|
154
|
+
* @param {string} existing
|
|
155
|
+
* @param {string} version
|
|
156
|
+
* @returns {boolean}
|
|
157
|
+
*/
|
|
158
|
+
export function hasSection(existing, version) {
|
|
159
|
+
const escaped = version.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
160
|
+
return new RegExp(`^##\\s*\\[${escaped}]`, "m").test(String(existing ?? ""));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Insert a rendered section above the first existing `## ` heading (or append
|
|
165
|
+
* after the header when none exists yet).
|
|
166
|
+
* @param {string} existing
|
|
167
|
+
* @param {string} section
|
|
168
|
+
* @returns {string}
|
|
169
|
+
*/
|
|
170
|
+
export function insertSection(existing, section) {
|
|
171
|
+
const content = String(existing ?? "");
|
|
172
|
+
const insertAt = content.indexOf("\n## ");
|
|
173
|
+
if (insertAt === -1) return `${content.replace(/\s*$/, "")}\n\n${section}\n`;
|
|
174
|
+
return `${content.slice(0, insertAt + 1)}${section}\n\n${content.slice(insertAt + 1)}`;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** @returns {string|null} latest `v*` tag, null when the repo has none, throws on git errors (fail-closed) */
|
|
178
|
+
function lastTag(root = REPO_ROOT) {
|
|
179
|
+
const tag = gitOut(["tag", "--list", "v*"], root);
|
|
180
|
+
const tags = tag.split("\n").map((s) => s.trim()).filter(Boolean);
|
|
181
|
+
if (tags.length === 0) return null;
|
|
182
|
+
return gitOut(["describe", "--tags", "--abbrev=0", "--match", "v*"], root).trim() || null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** @param {string[]} args @param {string} root @returns {string} */
|
|
186
|
+
function gitOut(args, root) {
|
|
187
|
+
return execFileSync("git", args, { cwd: root, encoding: "utf8" });
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** @returns {string[]} commit subjects in `lastTag..HEAD` (or all when no tag); throws on git errors */
|
|
191
|
+
function commitSubjects(tag, root = REPO_ROOT) {
|
|
192
|
+
const range = tag ? `${tag}..HEAD` : "HEAD";
|
|
193
|
+
return execFileSync("git", ["log", range, "--format=%s"], { cwd: root, encoding: "utf8" })
|
|
194
|
+
.split("\n")
|
|
195
|
+
.map((s) => s.trim())
|
|
196
|
+
.filter(Boolean);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** @returns {string} current version from package.json */
|
|
200
|
+
function currentVersion() {
|
|
201
|
+
const pkg = JSON.parse(readFileSync(PACKAGE_JSON, "utf8"));
|
|
202
|
+
if (!/^\d+\.\d+\.\d+$/.test(pkg.version ?? "")) throw new Error(`package.json has no semver version: ${pkg.version}`);
|
|
203
|
+
return pkg.version;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** @param {string} arg @param {string} current */
|
|
207
|
+
export function nextVersion(arg, current) {
|
|
208
|
+
const cur = current.split(".").map(Number);
|
|
209
|
+
if (arg === "major") return `${cur[0] + 1}.0.0`;
|
|
210
|
+
if (arg === "minor") return `${cur[0]}.${cur[1] + 1}.0`;
|
|
211
|
+
if (arg === "patch") return `${cur[0]}.${cur[1]}.${cur[2] + 1}`;
|
|
212
|
+
if (/^\d+\.\d+\.\d+$/.test(arg)) {
|
|
213
|
+
// Component-wise compare — lexicographic join(".") misorders "0.10.0" vs "0.5.2".
|
|
214
|
+
const parts = arg.split(".").map(Number);
|
|
215
|
+
for (let i = 0; i < 3; i++) {
|
|
216
|
+
if (parts[i] !== cur[i]) {
|
|
217
|
+
if (parts[i] < cur[i]) throw new Error(`version ${arg} is not greater than current ${current}`);
|
|
218
|
+
break;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
if (arg === current) throw new Error(`version ${arg} is not greater than current ${current}`);
|
|
222
|
+
return arg;
|
|
223
|
+
}
|
|
224
|
+
throw new Error(`invalid version or bump type: ${arg} (expected patch/minor/major or X.Y.Z)`);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function main(argv) {
|
|
228
|
+
if (argv[0] === "verify") {
|
|
229
|
+
let result;
|
|
230
|
+
try {
|
|
231
|
+
const tag = lastTag();
|
|
232
|
+
const lines = tag ? commitSubjects(tag) : [];
|
|
233
|
+
const changelogText = existsSync(CHANGELOG_MD) ? readFileSync(CHANGELOG_MD, "utf8") : "";
|
|
234
|
+
result = verifyFrom({ lines, changelogText });
|
|
235
|
+
result.lastTag = tag;
|
|
236
|
+
} catch (err) {
|
|
237
|
+
// Fail-closed: a git/filesystem failure must block the release, not pass it.
|
|
238
|
+
result = { ok: false, error: String(err?.message ?? err), missing: [], extra: [], functionalCommits: 0, lastTag: null };
|
|
239
|
+
}
|
|
240
|
+
console.log(JSON.stringify(result));
|
|
241
|
+
process.exitCode = result.ok ? 0 : 1;
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
const dryRun = argv.includes("--dry-run");
|
|
245
|
+
const versionArg = argv.find((a) => !a.startsWith("--")) ?? "patch";
|
|
246
|
+
try {
|
|
247
|
+
const current = currentVersion();
|
|
248
|
+
const version = nextVersion(versionArg, current);
|
|
249
|
+
const tag = lastTag();
|
|
250
|
+
const entries = parseCommitLines(tag ? commitSubjects(tag) : commitSubjects(null));
|
|
251
|
+
const date = new Date().toISOString().slice(0, 10);
|
|
252
|
+
const changelogPreview = generateChangelog({ version, date, entries, prevTag: tag });
|
|
253
|
+
const result = {
|
|
254
|
+
current_version: current,
|
|
255
|
+
new_version: version,
|
|
256
|
+
last_tag: tag,
|
|
257
|
+
changelog_summary: `${entries.filter((e) => e.type === "feat").length} features, ${entries.filter((e) => e.type === "fix").length} fixes, ${entries.filter((e) => !["feat", "fix"].includes(e.type)).length} changes`,
|
|
258
|
+
changelog_preview: changelogPreview,
|
|
259
|
+
};
|
|
260
|
+
if (!dryRun) {
|
|
261
|
+
const existing = existsSync(CHANGELOG_MD) ? readFileSync(CHANGELOG_MD, "utf8") : "# Changelog\n";
|
|
262
|
+
if (hasSection(existing, version)) {
|
|
263
|
+
throw new Error(`CHANGELOG.md already has a [${version}] section; remove the stale section before re-applying`);
|
|
264
|
+
}
|
|
265
|
+
writeFileSync(CHANGELOG_MD, insertSection(existing, changelogPreview));
|
|
266
|
+
result.files_modified = ["CHANGELOG.md"];
|
|
267
|
+
}
|
|
268
|
+
console.log(JSON.stringify(result, null, 1));
|
|
269
|
+
} catch (err) {
|
|
270
|
+
console.log(JSON.stringify({ error: String(err?.message ?? err) }));
|
|
271
|
+
process.exitCode = 1;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
|
276
|
+
main(process.argv.slice(2));
|
|
277
|
+
}
|
|
@@ -9,6 +9,7 @@ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-c
|
|
|
9
9
|
import { Key, matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
|
|
10
10
|
import { requestDashboardRender } from "../core/dashboard-render.mjs";
|
|
11
11
|
import { createService } from "../runtime/service.mjs";
|
|
12
|
+
import { planAttachPrelude, planAttachResolved } from "./attach-decision.mjs";
|
|
12
13
|
import { screenLogPath } from "../core/paths.mjs";
|
|
13
14
|
import { DashboardComponent, type DashboardResult } from "../ui/dashboard.js";
|
|
14
15
|
import { PtyAttachComponent, type PtyAttachResult } from "../ui/pty-attach.js";
|
|
@@ -161,54 +162,56 @@ async function attach(
|
|
|
161
162
|
ctx.ui.notify("Session no longer exists.", "warning");
|
|
162
163
|
return { action: "none" };
|
|
163
164
|
}
|
|
164
|
-
|
|
165
|
+
const prelude = planAttachPrelude({ rowAlive: row.alive, rowHostActive: row.hostActive, stopFirst });
|
|
166
|
+
if (prelude.plan === "warn-running") {
|
|
167
|
+
ctx.ui.notify("Session is still running. Stop it before attaching, or confirm from the dashboard.", "warning");
|
|
168
|
+
return { action: "none" };
|
|
169
|
+
}
|
|
170
|
+
if (prelude.plan === "stop-first") {
|
|
165
171
|
service.stop(viewId);
|
|
166
172
|
// Give the runner a moment to terminate the worker and release the session file.
|
|
167
173
|
await sleep(500);
|
|
168
|
-
} else if (row.alive && !row.hostAlive) {
|
|
169
|
-
ctx.ui.notify("Session is still running. Stop it before attaching, or confirm from the dashboard.", "warning");
|
|
170
|
-
return { action: "none" };
|
|
171
174
|
}
|
|
172
175
|
|
|
173
|
-
|
|
174
|
-
|
|
176
|
+
// Single async resolver entry: real probe + bounded recovery decide readiness (issue #70).
|
|
177
|
+
const plan = planAttachResolved(await service.resolveAttachTarget(viewId));
|
|
178
|
+
if (plan.plan === "open-pty") {
|
|
175
179
|
service.markVisited?.(viewId);
|
|
176
|
-
const result = await openPtyAttach(ctx, root, row.meta.id, row.meta.name,
|
|
180
|
+
const result = await openPtyAttach(ctx, root, row.meta.id, row.meta.name, plan.socketPath);
|
|
177
181
|
service.markVisited?.(viewId);
|
|
178
182
|
return { action: result.action === "closed" ? "closed" : "detached" };
|
|
179
183
|
}
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
184
|
+
if (plan.plan === "session-switch") {
|
|
185
|
+
const latest = service.row(viewId) ?? row;
|
|
186
|
+
if (!existsSync(latest.meta.sessionFile)) {
|
|
187
|
+
ctx.ui.notify("Session file isn't ready yet — try again once the run has started.", "warning");
|
|
188
|
+
return { action: "none" };
|
|
189
|
+
}
|
|
190
|
+
const name = latest.meta.name;
|
|
183
191
|
service.markVisited?.(viewId);
|
|
184
|
-
const
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
192
|
+
const switchingOverlay = await showSwitchingOverlay(ctx, name, "PTY unavailable");
|
|
193
|
+
const result = await ctx.switchSession(latest.meta.sessionFile, {
|
|
194
|
+
withSession: async (replaced) => {
|
|
195
|
+
replaced.ui.notify(`Attached to "${name}". Press ← on empty input to return to agent board.`, "info");
|
|
196
|
+
installBackToDashboard(replaced, service);
|
|
197
|
+
},
|
|
198
|
+
}).finally(() => {
|
|
199
|
+
try {
|
|
200
|
+
switchingOverlay?.hide();
|
|
201
|
+
} catch {}
|
|
202
|
+
});
|
|
203
|
+
if (result.cancelled) {
|
|
204
|
+
ctx.ui.notify("Attach cancelled.", "warning");
|
|
205
|
+
return { action: "none" };
|
|
206
|
+
}
|
|
207
|
+
return { action: "switched" };
|
|
193
208
|
}
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
const switchingOverlay = await showSwitchingOverlay(ctx, name, ensured.fallbackReason ?? ensured.error ?? "PTY unavailable");
|
|
197
|
-
const result = await ctx.switchSession(latest.meta.sessionFile, {
|
|
198
|
-
withSession: async (replaced) => {
|
|
199
|
-
replaced.ui.notify(`Attached to "${name}". Press ← on empty input to return to agent board.`, "info");
|
|
200
|
-
installBackToDashboard(replaced, service);
|
|
201
|
-
},
|
|
202
|
-
}).finally(() => {
|
|
203
|
-
try {
|
|
204
|
-
switchingOverlay?.hide();
|
|
205
|
-
} catch {}
|
|
206
|
-
});
|
|
207
|
-
if (result.cancelled) {
|
|
208
|
-
ctx.ui.notify("Attach cancelled.", "warning");
|
|
209
|
+
if (plan.plan === "notify-pending") {
|
|
210
|
+
ctx.ui.notify(plan.reason, "info");
|
|
209
211
|
return { action: "none" };
|
|
210
212
|
}
|
|
211
|
-
|
|
213
|
+
ctx.ui.notify("Session no longer exists.", "warning");
|
|
214
|
+
return { action: "none" };
|
|
212
215
|
}
|
|
213
216
|
|
|
214
217
|
async function openPtyAttach(
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Attach decision tables for the agent-board attach flow (issue #70).
|
|
3
|
+
*
|
|
4
|
+
* Pure mapping functions extracted from the two near-duplicate attach()
|
|
5
|
+
* implementations (src/commands/attach-flow.ts and the private copy in
|
|
6
|
+
* src/commands/agent-board.ts) so the branching contract is node-testable.
|
|
7
|
+
* Both TS files consume these for their prelude and resolver-result
|
|
8
|
+
* branching; they must stay free of I/O, service calls, and UI.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Default message surfaced when the resolver reports pending without a reason. */
|
|
12
|
+
export const ATTACH_PENDING_DEFAULT_MESSAGE = "Session host is starting — try again shortly.";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Prelude facts as observed from a dashboard row.
|
|
16
|
+
* @typedef {{ rowAlive: boolean, rowHostActive: boolean, stopFirst: boolean }} AttachPreludeFacts
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Map the attach prelude guards to a plan.
|
|
21
|
+
*
|
|
22
|
+
* The prelude only guards live JSON-runner rows (alive with no host claim):
|
|
23
|
+
* a row with an active host (starting/alive/stopping) passes straight
|
|
24
|
+
* through to the async resolver — attaching must not block or warn while a
|
|
25
|
+
* host is starting (issue #70).
|
|
26
|
+
*
|
|
27
|
+
* @param {AttachPreludeFacts} facts
|
|
28
|
+
* @returns {{ plan: "warn-running" } | { plan: "stop-first" } | { plan: "proceed" }}
|
|
29
|
+
*/
|
|
30
|
+
export function planAttachPrelude({ rowAlive, rowHostActive, stopFirst }) {
|
|
31
|
+
if (rowAlive && !rowHostActive) {
|
|
32
|
+
return stopFirst ? { plan: "stop-first" } : { plan: "warn-running" };
|
|
33
|
+
}
|
|
34
|
+
return { plan: "proceed" };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Map a `resolveAttachTarget()` result to the attach action plan.
|
|
39
|
+
*
|
|
40
|
+
* `socketPath` is typed `string | null` upstream; a pty resolution without a
|
|
41
|
+
* usable path is a transient resolver inconsistency and maps to
|
|
42
|
+
* notify-pending (retry later) rather than a misleading "missing".
|
|
43
|
+
*
|
|
44
|
+
* @param {{ kind: "pty", socketPath: string | null, sessionFile?: string, instanceId?: string | null } |
|
|
45
|
+
* { kind: "session", sessionFile: string } |
|
|
46
|
+
* { kind: "pending", sessionFile?: string, reason?: string } |
|
|
47
|
+
* { kind: "missing" }} resolved
|
|
48
|
+
* @returns {{ plan: "open-pty", socketPath: string } |
|
|
49
|
+
* { plan: "session-switch", sessionFile: string } |
|
|
50
|
+
* { plan: "notify-pending", reason: string } |
|
|
51
|
+
* { plan: "notify-missing" }}
|
|
52
|
+
*/
|
|
53
|
+
export function planAttachResolved(resolved) {
|
|
54
|
+
switch (resolved?.kind) {
|
|
55
|
+
case "pty":
|
|
56
|
+
return resolved.socketPath != null
|
|
57
|
+
? { plan: "open-pty", socketPath: resolved.socketPath }
|
|
58
|
+
: { plan: "notify-pending", reason: ATTACH_PENDING_DEFAULT_MESSAGE };
|
|
59
|
+
case "session":
|
|
60
|
+
return { plan: "session-switch", sessionFile: resolved.sessionFile };
|
|
61
|
+
case "pending":
|
|
62
|
+
return { plan: "notify-pending", reason: resolved.reason ?? ATTACH_PENDING_DEFAULT_MESSAGE };
|
|
63
|
+
default:
|
|
64
|
+
return { plan: "notify-missing" };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Reusable attach-flow helpers shared by the `agent-board` and `bg` commands.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Pure helpers (paths, prompts, current-view lookup) plus the resolver-driven
|
|
5
|
+
* attach flow: since issue #70 the attach path calls the async
|
|
6
|
+
* `resolveAttachTarget()` once and plans via `attach-decision.mjs`, instead of
|
|
7
|
+
* the old attachTarget-hint → ensureHost double path.
|
|
6
8
|
*/
|
|
7
9
|
import { existsSync } from "node:fs";
|
|
8
10
|
import { resolve } from "node:path";
|
|
9
11
|
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
10
12
|
import { Key, matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
|
|
11
13
|
import { createService } from "../runtime/service.mjs";
|
|
14
|
+
import { planAttachPrelude, planAttachResolved } from "./attach-decision.mjs";
|
|
12
15
|
import { screenLogPath } from "../core/paths.mjs";
|
|
13
16
|
import { PtyAttachComponent, type PtyAttachResult } from "../ui/pty-attach.js";
|
|
14
17
|
import type { DashboardResult } from "../ui/dashboard.js";
|
|
@@ -99,8 +102,10 @@ export function samePath(a: string, b: string): boolean {
|
|
|
99
102
|
}
|
|
100
103
|
|
|
101
104
|
/**
|
|
102
|
-
* Attach to an agent session
|
|
103
|
-
*
|
|
105
|
+
* Attach to an agent session through the single async resolver entry point.
|
|
106
|
+
* PTY readiness is decided by `resolveAttachTarget()` (real connect + hello
|
|
107
|
+
* probe, with bounded recovery); this function only maps its result to UI
|
|
108
|
+
* actions (issue #70 — no more attachTarget-hint → ensureHost double path).
|
|
104
109
|
*/
|
|
105
110
|
export async function attach(
|
|
106
111
|
ctx: ExtensionCommandContext,
|
|
@@ -114,54 +119,55 @@ export async function attach(
|
|
|
114
119
|
ctx.ui.notify("Session no longer exists.", "warning");
|
|
115
120
|
return { action: "none" };
|
|
116
121
|
}
|
|
117
|
-
|
|
122
|
+
const prelude = planAttachPrelude({ rowAlive: row.alive, rowHostActive: row.hostActive, stopFirst });
|
|
123
|
+
if (prelude.plan === "warn-running") {
|
|
124
|
+
ctx.ui.notify("Session is still running. Stop it before attaching, or confirm from the dashboard.", "warning");
|
|
125
|
+
return { action: "none" };
|
|
126
|
+
}
|
|
127
|
+
if (prelude.plan === "stop-first") {
|
|
118
128
|
service.stop(viewId);
|
|
119
129
|
// Give the runner a moment to terminate the worker and release the session file.
|
|
120
130
|
await sleep(500);
|
|
121
|
-
} else if (row.alive && !row.hostAlive) {
|
|
122
|
-
ctx.ui.notify("Session is still running. Stop it before attaching, or confirm from the dashboard.", "warning");
|
|
123
|
-
return { action: "none" };
|
|
124
131
|
}
|
|
125
132
|
|
|
126
|
-
const
|
|
127
|
-
if (
|
|
133
|
+
const plan = planAttachResolved(await service.resolveAttachTarget(viewId));
|
|
134
|
+
if (plan.plan === "open-pty") {
|
|
128
135
|
service.markVisited?.(viewId);
|
|
129
|
-
const result = await openPtyAttach(ctx, root, row.meta.id, row.meta.name,
|
|
136
|
+
const result = await openPtyAttach(ctx, root, row.meta.id, row.meta.name, plan.socketPath);
|
|
130
137
|
service.markVisited?.(viewId);
|
|
131
138
|
return { action: result.action === "closed" ? "closed" : "detached" };
|
|
132
139
|
}
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
140
|
+
if (plan.plan === "session-switch") {
|
|
141
|
+
const latest = service.row(viewId) ?? row;
|
|
142
|
+
if (!existsSync(latest.meta.sessionFile)) {
|
|
143
|
+
ctx.ui.notify("Session file isn't ready yet — try again once the run has started.", "warning");
|
|
144
|
+
return { action: "none" };
|
|
145
|
+
}
|
|
146
|
+
const name = latest.meta.name;
|
|
136
147
|
service.markVisited?.(viewId);
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
148
|
+
const switchingOverlay = await showSwitchingOverlay(ctx, name, "PTY unavailable");
|
|
149
|
+
const result = await ctx.switchSession(latest.meta.sessionFile, {
|
|
150
|
+
withSession: async (replaced) => {
|
|
151
|
+
replaced.ui.notify(`Attached to "${name}". Press ← on empty input to return to agent board.`, "info");
|
|
152
|
+
installBackToDashboard(replaced, service, openDashboardFn);
|
|
153
|
+
},
|
|
154
|
+
}).finally(() => {
|
|
155
|
+
try {
|
|
156
|
+
switchingOverlay?.hide();
|
|
157
|
+
} catch {}
|
|
158
|
+
});
|
|
159
|
+
if (result.cancelled) {
|
|
160
|
+
ctx.ui.notify("Attach cancelled.", "warning");
|
|
161
|
+
return { action: "none" };
|
|
162
|
+
}
|
|
163
|
+
return { action: "switched" };
|
|
146
164
|
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
const switchingOverlay = await showSwitchingOverlay(ctx, name, ensured.fallbackReason ?? ensured.error ?? "PTY unavailable");
|
|
150
|
-
const result = await ctx.switchSession(latest.meta.sessionFile, {
|
|
151
|
-
withSession: async (replaced) => {
|
|
152
|
-
replaced.ui.notify(`Attached to "${name}". Press ← on empty input to return to agent board.`, "info");
|
|
153
|
-
installBackToDashboard(replaced, service, openDashboardFn);
|
|
154
|
-
},
|
|
155
|
-
}).finally(() => {
|
|
156
|
-
try {
|
|
157
|
-
switchingOverlay?.hide();
|
|
158
|
-
} catch {}
|
|
159
|
-
});
|
|
160
|
-
if (result.cancelled) {
|
|
161
|
-
ctx.ui.notify("Attach cancelled.", "warning");
|
|
165
|
+
if (plan.plan === "notify-pending") {
|
|
166
|
+
ctx.ui.notify(plan.reason, "info");
|
|
162
167
|
return { action: "none" };
|
|
163
168
|
}
|
|
164
|
-
|
|
169
|
+
ctx.ui.notify("Session no longer exists.", "warning");
|
|
170
|
+
return { action: "none" };
|
|
165
171
|
}
|
|
166
172
|
|
|
167
173
|
/**
|